Herstructureer src/ naar feature-based folderstructuur
Continuous Integration / config (pull_request) Successful in 10s
Continuous Integration / prepare (pull_request) Successful in 1m26s
Continuous Integration / build-production (pull_request) Skipped
Continuous Integration / build (pull_request) Successful in 2m6s
Continuous Integration / test (pull_request) Successful in 2m2s
Continuous Integration / deploy-production (pull_request) Skipped
Deploy / deploy (pull_request) Successful in 38s
Continuous Integration / deploy-test (pull_request) Successful in 39s

Volgt de aanpak uit het React 2025 folder structure artikel en het
SlpModularCms/frontend referentieproject: components/{layout,ui,shared},
features/landing/{components,data,hooks,pages,__tests__}, en lib/ voor
de query client. routes/index.tsx wordt een dunne wrapper rond de nieuwe
features/landing/pages/LandingPage.tsx.
This commit is contained in:
2026-08-01 00:10:21 +02:00
parent a3ecfec668
commit 0037b0d304
25 changed files with 34 additions and 33 deletions
@@ -0,0 +1,37 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PackagesSection } from '../components/PackagesSection';
import { packages } from '../data/content';
function renderWithQueryClient() {
const queryClient = new QueryClient();
return render(
<QueryClientProvider client={queryClient}>
<PackagesSection />
</QueryClientProvider>,
);
}
describe('PackagesSection', () => {
it('renders one PackageCard per package once loaded', async () => {
renderWithQueryClient();
await waitFor(() => {
expect(screen.getByTestId('packages-grid').children.length).toBe(packages.length);
});
for (const pkg of packages) {
expect(screen.getByTestId(`package-card-${pkg.id}`)).toBeInTheDocument();
expect(screen.getByText(pkg.title)).toBeInTheDocument();
}
});
it('marks the featured package as "Meest gekozen"', async () => {
renderWithQueryClient();
await waitFor(() => {
expect(screen.getByText('Meest gekozen')).toBeInTheDocument();
});
});
});
@@ -0,0 +1,44 @@
import { aboutContent } from '../data/content';
export function AboutSection() {
return (
<section id="over" className="py-[88px]">
<div className="mx-auto max-w-[1080px] px-6">
<div className="grid grid-cols-1 items-start gap-12 sm:grid-cols-[1.2fr_0.8fr]">
<div>
<div className="mb-6">
<span className="font-mono block text-sm uppercase tracking-wider text-accent">
// over
</span>
<h2 className="font-display mt-3.5 text-3xl font-bold sm:text-4xl">
Eén ontwikkelaar, korte lijnen
</h2>
</div>
{aboutContent.paragraphs.map((paragraph) => (
<p key={paragraph} className="mb-4 text-muted">
{paragraph}
</p>
))}
</div>
<div className="font-mono rounded border border-line bg-surface p-6 text-[0.85rem]">
<div className="mb-3.5 text-[0.75rem] uppercase tracking-wider text-accent">
tech_stack
</div>
<ul className="list-none">
{aboutContent.techStack.map((item, index) => (
<li
key={item.label}
className={`py-1.5 text-muted ${
index < aboutContent.techStack.length - 1 ? 'border-b border-line' : ''
}`}
>
<b className="font-medium text-text">{item.label}</b> {item.value}
</li>
))}
</ul>
</div>
</div>
</div>
</section>
);
}
@@ -0,0 +1,33 @@
import { contactInfo } from '../data/content';
export function ContactSection() {
const mailtoHref = `mailto:${contactInfo.email}?subject=${encodeURIComponent(contactInfo.mailSubject)}`;
return (
<section id="contact" className="pb-[110px]">
<div className="mx-auto max-w-[1080px] px-6">
<div className="rounded-[20px] border border-accent-line bg-gradient-to-br from-surface-2 to-surface px-10 py-14 text-center">
<span className="font-mono block text-sm uppercase tracking-wider text-accent">
// contact
</span>
<h2 className="font-display mt-3.5 text-3xl font-bold sm:text-4xl">
{contactInfo.heading}
</h2>
<p className="mx-auto mb-8 mt-3.5 max-w-[46ch] text-muted">{contactInfo.description}</p>
<a
href={mailtoHref}
className="rounded-lg bg-accent px-6 py-3.5 text-[0.98rem] font-semibold text-[var(--color-accent-contrast)] transition-colors hover:bg-[var(--color-accent-hover)]"
>
Stuur een bericht
</a>
<span className="font-mono mt-5 block text-[0.9rem] text-muted">
of mail direct naar{' '}
<a href={`mailto:${contactInfo.email}`} className="text-accent underline-offset-2 hover:underline">
{contactInfo.email}
</a>
</span>
</div>
</div>
</section>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { heroContent } from '../data/content';
import { handleAnchorClick } from '../../../utils/scrollToHash';
export function Hero() {
return (
<header id="top" className="relative overflow-hidden pb-[88px] pt-24">
<div className="mx-auto max-w-[1080px] px-6">
<span className="font-mono mb-5 block text-sm uppercase tracking-wider text-accent">
{heroContent.eyebrow}
</span>
<h1 className="font-display mb-6 max-w-[15ch] text-4xl font-extrabold leading-tight tracking-tight sm:text-5xl">
{heroContent.heading}
</h1>
<p className="mb-9 max-w-[52ch] text-lg text-muted">{heroContent.lead}</p>
<div
aria-hidden="true"
className="font-mono mb-10 inline-flex max-w-full items-center gap-2 overflow-x-auto whitespace-nowrap rounded border border-line bg-surface px-6 py-4 shadow-lg"
>
<span>{heroContent.codeLine}</span>
<span className="caret" data-testid="hero-caret" />
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap">
<a
href="#pakketten"
onClick={(event) => handleAnchorClick(event, '#pakketten')}
className="rounded-lg bg-accent px-6 py-3.5 text-center text-[0.98rem] font-semibold text-[var(--color-accent-contrast)] transition-transform hover:bg-[var(--color-accent-hover)] active:scale-[0.98] sm:text-left"
>
Bekijk pakketten
</a>
<a
href="#contact"
onClick={(event) => handleAnchorClick(event, '#contact')}
className="rounded-lg border border-line px-6 py-3.5 text-center text-[0.98rem] font-semibold text-text transition-colors hover:border-accent-line sm:text-left"
>
Plan een gesprek
</a>
</div>
</div>
</header>
);
}
@@ -0,0 +1,49 @@
import type { PackageCardData } from '../data/content';
import { handleAnchorClick } from '../../../utils/scrollToHash';
export function PackageCard({ pkg }: { pkg: PackageCardData }) {
return (
<div
className={`relative flex flex-col rounded border p-8 transition-all hover:-translate-y-1 ${
pkg.featured
? 'border-accent-line bg-surface-2'
: 'border-line bg-surface hover:border-accent-line'
}`}
data-testid={`package-card-${pkg.id}`}
>
{pkg.featured && (
<span className="font-mono absolute -top-3 left-7 rounded-full bg-accent px-3 py-1 text-[0.7rem] font-bold uppercase tracking-wide text-[var(--color-accent-contrast)]">
Meest gekozen
</span>
)}
<span className="font-mono mb-3.5 text-[0.78rem] text-accent">{pkg.id}</span>
<h3 className="mb-2.5 text-xl font-bold">{pkg.title}</h3>
<p className="mb-6 text-[0.95rem] text-muted">{pkg.description}</p>
<div className="font-mono mb-1 text-3xl font-bold">
{pkg.price}
<small className="mt-0.5 block text-[0.78rem] font-normal text-muted">{pkg.priceNote}</small>
</div>
<ul className="my-6 flex-1 list-none">
{pkg.features.map((feature) => (
<li key={feature} className="relative mb-2.5 pl-6 text-[0.93rem] text-muted">
<span className="font-mono absolute left-0 text-accent" aria-hidden="true">
</span>
{feature}
</li>
))}
</ul>
<a
href="#contact"
onClick={(event) => handleAnchorClick(event, '#contact')}
className={`rounded-lg px-6 py-3.5 text-center text-[0.98rem] font-semibold ${
pkg.featured
? 'bg-accent text-[var(--color-accent-contrast)] hover:bg-[var(--color-accent-hover)]'
: 'border border-line text-text hover:border-accent-line'
}`}
>
{pkg.ctaLabel}
</a>
</div>
);
}
@@ -0,0 +1,33 @@
import { usePackagesQuery } from '../hooks/usePackagesQuery';
import { PackageCard } from './PackageCard';
export function PackagesSection() {
const { data: packages, isLoading } = usePackagesQuery();
return (
<section
id="pakketten"
className="bg-gradient-to-b from-transparent via-surface/50 to-transparent py-[88px]"
>
<div className="mx-auto max-w-[1080px] px-6">
<div className="mb-[52px]">
<span className="font-mono block text-sm uppercase tracking-wider text-accent">
// pakketten
</span>
<h2 className="font-display mt-3.5 text-3xl font-bold sm:text-4xl">
Drie manieren om te starten
</h2>
<p className="mt-3 max-w-[56ch] text-muted">
Van één sterke pagina tot volledig maatwerk met eigen back-end. Elk pakket heeft een
vaste scope en een vaste prijs.
</p>
</div>
<div className="grid grid-cols-1 gap-[22px] sm:grid-cols-3" data-testid="packages-grid">
{isLoading || !packages
? null
: packages.map((pkg) => <PackageCard key={pkg.id} pkg={pkg} />)}
</div>
</div>
</section>
);
}
@@ -0,0 +1,24 @@
import { processSteps } from '../data/content';
import { ProcessStep } from './ProcessStep';
export function ProcessSection() {
return (
<section id="werkwijze" className="py-[88px]">
<div className="mx-auto max-w-[1080px] px-6">
<div className="mb-[52px]">
<span className="font-mono block text-sm uppercase tracking-wider text-accent">
// werkwijze
</span>
<h2 className="font-display mt-3.5 text-3xl font-bold sm:text-4xl">
Van idee naar live in drie stappen
</h2>
</div>
<div className="grid grid-cols-1 gap-[22px] sm:grid-cols-3">
{processSteps.map((step) => (
<ProcessStep key={step.label} step={step} />
))}
</div>
</div>
</section>
);
}
@@ -0,0 +1,11 @@
import type { ProcessStepData } from '../data/content';
export function ProcessStep({ step }: { step: ProcessStepData }) {
return (
<div className="border-l-2 border-line py-1 pl-6 transition-colors hover:border-accent">
<span className="font-mono mb-2.5 block text-[0.78rem] text-accent">{step.label}</span>
<h3 className="mb-2 text-[1.08rem]">{step.title}</h3>
<p className="text-[0.93rem] text-muted">{step.description}</p>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
/**
* Static site content, ported 1-to-1 from the reference designs
* (References/slp-software.html and References/slp-software-rood.html).
*
* Shapes mirror the domain entities defined in
* aidlc-docs/features/react-frontend/construction/react-frontend-app/functional-design/domain-entities.md
*
* This module is the current source of truth for content (requirements FR-1/FR-6).
* It is deliberately structured so it can be swapped for TanStack Query data later
* (see src/features/landing/hooks/usePackagesQuery.ts) with minimal changes (requirements FR-5).
*/
export interface NavLink {
label: string;
href: string;
isCta?: boolean;
}
export interface HeroContent {
eyebrow: string;
heading: string;
lead: string;
codeLine: string;
}
export interface PackageCardData {
id: string;
title: string;
description: string;
price: string;
priceNote: string;
features: string[];
ctaLabel: string;
featured?: boolean;
}
export interface ProcessStepData {
label: string;
title: string;
description: string;
}
export interface TechStackItem {
label: string;
value: string;
}
export interface AboutContent {
paragraphs: string[];
techStack: TechStackItem[];
}
export interface ContactInfo {
heading: string;
description: string;
email: string;
mailSubject: string;
}
export const navLinks: NavLink[] = [
{ label: 'Pakketten', href: '#pakketten' },
{ label: 'Werkwijze', href: '#werkwijze' },
{ label: 'Over', href: '#over' },
{ label: 'Start project', href: '#contact', isCta: true },
];
export const heroContent: HeroContent = {
eyebrow: 'Web & .NET development',
heading: 'Software die gewoon werkt. Tegen een prijs die je vooraf kent.',
lead: 'SLP Software bouwt snelle websites en maatwerk .NET-oplossingen voor ondernemers die geen verrassingen willen — niet in de code, niet op de factuur.',
codeLine: 'var website = slp.Bouw(uwIdee);',
};
export const packages: PackageCardData[] = [
{
id: 'pakket_01',
title: 'Landingspagina',
description: 'Eén overtuigende pagina die je product of dienst helder neerzet.',
price: '€ 300',
priceNote: 'eenmalig, excl. btw',
features: [
'Eén pagina in HTML & CSS',
'Ontwerp op maat, geen template',
'Responsive op elk scherm',
'Snelle laadtijd & SEO-basis',
],
ctaLabel: 'Kies landingspagina',
},
{
id: 'pakket_02',
title: 'Website',
description: "Een complete website met meerdere pagina's, klaar om te groeien.",
price: '€ 750',
priceNote: 'eenmalig, excl. btw',
features: [
"Drie pagina's in HTML & CSS",
"Extra pagina's als optie bij te bestellen",
"Consistente huisstijl over alle pagina's",
'Responsive, snel & SEO-basis',
],
ctaLabel: 'Kies website',
featured: true,
},
{
id: 'pakket_03',
title: 'Maatwerk',
description: 'Grotere websites, een eigen back-end of andere .NET-projecten.',
price: 'Op maat',
priceNote: 'offerte na intake',
features: [
'Grotere websites & webapplicaties',
'Losse back-end in .NET / C#',
"Koppelingen & API's",
'Advies over de beste aanpak',
],
ctaLabel: 'Vraag offerte aan',
},
];
export const processSteps: ProcessStepData[] = [
{
label: 'stap 01 — intake',
title: 'Kennismaken',
description:
'We bespreken je idee, doelgroep en wensen. Je krijgt direct een eerlijk advies over welk pakket past.',
},
{
label: 'stap 02 — bouwen',
title: 'Ontwerpen & ontwikkelen',
description:
'Ik bouw je site of applicatie en houd je onderweg op de hoogte, zodat er geen verrassingen zijn bij oplevering.',
},
{
label: 'stap 03 — live',
title: 'Opleveren',
description:
'Je site gaat live en je krijgt uitleg over hoe alles werkt. Later uitbreiden? Dat kan altijd.',
},
];
export const aboutContent: AboutContent = {
paragraphs: [
'SLP Software is het bedrijf van een web- en .NET-ontwikkelaar die gelooft dat goede software niet ingewikkeld hoeft te voelen. Geen accountmanagers of lange trajecten: je schakelt direct met degene die je project bouwt.',
'Van een strakke landingspagina tot een applicatie met eigen back-end — de aanpak is hetzelfde: heldere afspraken, nette code en een resultaat waar je jaren mee vooruit kunt.',
],
techStack: [
{ label: 'Front-end', value: 'HTML, CSS, JavaScript' },
{ label: 'Back-end', value: '.NET / C#' },
{ label: "API's", value: 'REST & koppelingen' },
{ label: 'Focus', value: 'snelheid & onderhoudbaarheid' },
],
};
export const contactInfo: ContactInfo = {
heading: 'Klaar om te bouwen?',
description:
'Vertel kort wat je voor ogen hebt. Je krijgt binnen één werkdag reactie met een eerlijk advies — vrijblijvend.',
email: 'info@slpsoftware.nl',
mailSubject: 'Projectaanvraag',
};
@@ -0,0 +1,19 @@
import { useQuery } from '@tanstack/react-query';
import { packages, type PackageCardData } from '../data/content';
/**
* Placeholder query hook (requirements FR-5). The queryFn currently just resolves the
* static package data, wrapped in a Promise so the calling component already consumes
* it exactly the way it will once a real API exists — only this queryFn's body will
* need to change in a future iteration.
*/
async function fetchPackages(): Promise<PackageCardData[]> {
return Promise.resolve(packages);
}
export function usePackagesQuery() {
return useQuery({
queryKey: ['packages'],
queryFn: fetchPackages,
});
}
@@ -0,0 +1,17 @@
import { Hero } from '../components/Hero';
import { PackagesSection } from '../components/PackagesSection';
import { ProcessSection } from '../components/ProcessSection';
import { AboutSection } from '../components/AboutSection';
import { ContactSection } from '../components/ContactSection';
export function LandingPage() {
return (
<>
<Hero />
<PackagesSection />
<ProcessSection />
<AboutSection />
<ContactSection />
</>
);
}