Initial commit: React frontend (SLP Software) + AIDLC workflow docs
Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Component } from 'react';
|
||||
import type { ErrorInfo, ReactNode } from 'react';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Top-level, on-brand error boundary (NFR Design resilience pattern; SECURITY-09/SECURITY-15).
|
||||
* Shows a generic, themed fallback message — never technical details like stack traces.
|
||||
*/
|
||||
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
state: ErrorBoundaryState = { hasError: false };
|
||||
|
||||
static getDerivedStateFromError(): ErrorBoundaryState {
|
||||
return { hasError: true };
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo): void {
|
||||
console.error('Unexpected application error', error, info);
|
||||
}
|
||||
|
||||
render(): ReactNode {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div
|
||||
className="theme-red flex min-h-screen items-center justify-center bg-bg px-6 text-center text-text"
|
||||
role="alert"
|
||||
data-testid="error-boundary-fallback"
|
||||
>
|
||||
<p className="font-display text-lg">Er ging iets mis. Probeer de pagina te vernieuwen.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-line py-7 text-[0.85rem] text-muted">
|
||||
<div className="mx-auto flex max-w-[1080px] flex-wrap justify-between gap-2.5 px-6">
|
||||
<span>© 2026 SLP Software</span>
|
||||
<span className="font-mono">slp.Bouw(uwIdee);</span>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -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-wrap gap-4">
|
||||
<a
|
||||
href="#pakketten"
|
||||
onClick={(event) => handleAnchorClick(event, '#pakketten')}
|
||||
className="rounded-lg bg-accent px-6 py-3.5 text-[0.98rem] font-semibold text-[var(--color-accent-contrast)] transition-transform hover:bg-[var(--color-accent-hover)] active:scale-[0.98]"
|
||||
>
|
||||
Bekijk pakketten
|
||||
</a>
|
||||
<a
|
||||
href="#contact"
|
||||
onClick={(event) => handleAnchorClick(event, '#contact')}
|
||||
className="rounded-lg border border-line px-6 py-3.5 text-[0.98rem] font-semibold text-text transition-colors hover:border-accent-line"
|
||||
>
|
||||
Plan een gesprek
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { navLinks } from '../data/content';
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
import { handleAnchorClick } from '../utils/scrollToHash';
|
||||
|
||||
export function Nav() {
|
||||
return (
|
||||
<nav className="sticky top-0 z-50 border-b border-line bg-bg/80 backdrop-blur-md">
|
||||
<div className="mx-auto flex h-16 max-w-[1080px] items-center justify-between px-6">
|
||||
<a
|
||||
href="#top"
|
||||
onClick={(event) => handleAnchorClick(event, '#top')}
|
||||
className="font-mono text-base font-bold tracking-wide text-text"
|
||||
>
|
||||
SLP<span className="text-accent">.</span>Software
|
||||
</a>
|
||||
<ul className="flex items-center gap-7">
|
||||
{navLinks.map((link) =>
|
||||
link.isCta ? (
|
||||
<li key={link.href}>
|
||||
<a
|
||||
href={link.href}
|
||||
onClick={(event) => handleAnchorClick(event, link.href)}
|
||||
className="font-mono rounded-lg border border-accent-line bg-accent-soft px-4 py-2 text-sm font-medium text-text"
|
||||
data-testid="nav-cta-link"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
) : (
|
||||
<li key={link.href} className="hidden sm:block">
|
||||
<a
|
||||
href={link.href}
|
||||
onClick={(event) => handleAnchorClick(event, link.href)}
|
||||
className="text-sm font-medium text-muted transition-colors hover:text-text"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
<li>
|
||||
<ThemeToggle />
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Nav } from './Nav';
|
||||
import { Footer } from './Footer';
|
||||
|
||||
export function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="font-sans min-h-screen bg-bg text-text">
|
||||
<Nav />
|
||||
{children}
|
||||
<Footer />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useTheme } from '../theme/ThemeProvider';
|
||||
|
||||
/**
|
||||
* Visible theme switcher (requirements FR-3, Functional Design Q1/Q7).
|
||||
* Rendered as a small icon/swatch button in the nav.
|
||||
*/
|
||||
export function ThemeToggle() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
const isPurple = theme === 'purple';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleTheme}
|
||||
aria-label="Wissel kleurthema"
|
||||
aria-pressed={isPurple}
|
||||
data-testid="theme-toggle-button"
|
||||
className="font-mono flex h-8 w-8 items-center justify-center rounded-full border border-line text-xs font-medium text-text transition-colors hover:border-accent-line"
|
||||
>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="block h-4 w-4 rounded-full bg-accent"
|
||||
data-testid="theme-toggle-swatch"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { Nav } from '../Nav';
|
||||
import { ThemeProvider } from '../../theme/ThemeProvider';
|
||||
|
||||
function renderNav() {
|
||||
return render(
|
||||
<ThemeProvider>
|
||||
<Nav />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('Nav', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.className = '';
|
||||
});
|
||||
|
||||
it('renders all nav links including the CTA', () => {
|
||||
renderNav();
|
||||
expect(screen.getByText('Pakketten')).toBeInTheDocument();
|
||||
expect(screen.getByText('Werkwijze')).toBeInTheDocument();
|
||||
expect(screen.getByText('Over')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('nav-cta-link')).toHaveTextContent('Start project');
|
||||
});
|
||||
|
||||
it('renders the theme toggle with a Dutch accessible label', () => {
|
||||
renderNav();
|
||||
expect(screen.getByRole('button', { name: 'Wissel kleurthema' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles the theme when the toggle button is clicked', () => {
|
||||
renderNav();
|
||||
const toggle = screen.getByTestId('theme-toggle-button');
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'false');
|
||||
fireEvent.click(toggle);
|
||||
expect(toggle).toHaveAttribute('aria-pressed', 'true');
|
||||
});
|
||||
});
|
||||
@@ -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 '../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,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/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,18 @@
|
||||
/**
|
||||
* Self-hosted fonts (NFR Design decision: bundle fonts instead of loading from the
|
||||
* Google Fonts CDN, avoiding an external dependency and the SRI concerns that would
|
||||
* otherwise apply to a CDN-loaded stylesheet).
|
||||
*
|
||||
* Weights match the reference designs' Google Fonts request:
|
||||
* Sora: 400;600;700;800 — Instrument Sans: 400;500;600 — JetBrains Mono: 400;500;700
|
||||
*/
|
||||
import '@fontsource/sora/400.css';
|
||||
import '@fontsource/sora/600.css';
|
||||
import '@fontsource/sora/700.css';
|
||||
import '@fontsource/sora/800.css';
|
||||
import '@fontsource/instrument-sans/400.css';
|
||||
import '@fontsource/instrument-sans/500.css';
|
||||
import '@fontsource/instrument-sans/600.css';
|
||||
import '@fontsource/jetbrains-mono/400.css';
|
||||
import '@fontsource/jetbrains-mono/500.css';
|
||||
import '@fontsource/jetbrains-mono/700.css';
|
||||
@@ -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,84 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Theme tokens — values ported 1-to-1 from References/slp-software-rood.html (red, default)
|
||||
and References/slp-software.html (purple, alternate). See
|
||||
aidlc-docs/features/react-frontend/construction/react-frontend-app/functional-design/domain-entities.md
|
||||
(ThemeTokens) for the data model these mirror. */
|
||||
.theme-red {
|
||||
--color-bg: #140c0e;
|
||||
--color-surface: #1f1315;
|
||||
--color-surface-2: #291719;
|
||||
--color-line: #402226;
|
||||
--color-text: #f5eaeb;
|
||||
--color-muted: #b69c9f;
|
||||
--color-accent: #e04848;
|
||||
--color-accent-soft: rgba(224, 72, 72, 0.16);
|
||||
--color-accent-line: rgba(224, 72, 72, 0.4);
|
||||
--color-accent-hover: #ea6161;
|
||||
--color-accent-contrast: #140c0e;
|
||||
}
|
||||
|
||||
.theme-purple {
|
||||
--color-bg: #0e1220;
|
||||
--color-surface: #151a2c;
|
||||
--color-surface-2: #1b2138;
|
||||
--color-line: #262e4a;
|
||||
--color-text: #e9ebf5;
|
||||
--color-muted: #98a0ba;
|
||||
--color-accent: #8b7cff;
|
||||
--color-accent-soft: rgba(139, 124, 255, 0.14);
|
||||
--color-accent-line: rgba(139, 124, 255, 0.35);
|
||||
--color-accent-hover: #9d90ff;
|
||||
--color-accent-contrast: #0e1220;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Instant, non-animated theme switch when the visitor prefers reduced motion (BR-5). */
|
||||
.theme-red,
|
||||
.theme-purple {
|
||||
transition: background-color 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.theme-red,
|
||||
.theme-purple {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes caret-blink {
|
||||
50% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.caret {
|
||||
display: inline-block;
|
||||
width: 9px;
|
||||
height: 1.2em;
|
||||
background: var(--color-accent);
|
||||
animation: caret-blink 1.1s steps(1) infinite;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.caret {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { RouterProvider } from '@tanstack/react-router';
|
||||
import './fonts';
|
||||
import './index.css';
|
||||
import { router } from './router';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error('Root element "#root" not found in index.html');
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
|
||||
/**
|
||||
* Single shared QueryClient. staleTime is set to Infinity by default because, for now,
|
||||
* all queries (see src/hooks/usePackagesQuery.ts) resolve static local data — there is
|
||||
* no real network staleness yet. Revisit once a real backend exists (requirements FR-5).
|
||||
*/
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createHashHistory, createRouter } from '@tanstack/react-router';
|
||||
import { rootRoute } from './routes/__root';
|
||||
import { indexRoute } from './routes/index';
|
||||
|
||||
/**
|
||||
* Hash-based history (NFR Requirements decision): the confirmed deployment target is a
|
||||
* traditional FTP/static web host, where server-side rewrite rules are not guaranteed.
|
||||
* Hash history works on any static host with zero server configuration.
|
||||
*/
|
||||
const routeTree = rootRoute.addChildren([indexRoute]);
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
history: createHashHistory(),
|
||||
});
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createRootRoute, Outlet } from '@tanstack/react-router';
|
||||
import { QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ErrorBoundary } from '../components/ErrorBoundary';
|
||||
import { ThemeProvider } from '../theme/ThemeProvider';
|
||||
import { RootLayout } from '../components/RootLayout';
|
||||
import { queryClient } from '../queryClient';
|
||||
|
||||
/**
|
||||
* Root route composition (Functional Design / NFR Design logical components):
|
||||
* ErrorBoundary -> ThemeProvider -> QueryClientProvider -> RootLayout -> routed content.
|
||||
*/
|
||||
export const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<ErrorBoundary>
|
||||
<ThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RootLayout>
|
||||
<Outlet />
|
||||
</RootLayout>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</ErrorBoundary>
|
||||
),
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createRoute } from '@tanstack/react-router';
|
||||
import { rootRoute } from './__root';
|
||||
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';
|
||||
|
||||
function IndexPage() {
|
||||
return (
|
||||
<>
|
||||
<Hero />
|
||||
<PackagesSection />
|
||||
<ProcessSection />
|
||||
<AboutSection />
|
||||
<ContactSection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export const indexRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/',
|
||||
component: IndexPage,
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
@@ -0,0 +1,65 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
DEFAULT_THEME,
|
||||
THEME_STORAGE_KEY,
|
||||
resolveInitialTheme,
|
||||
themeClassName,
|
||||
type ThemeName,
|
||||
} from './tokens';
|
||||
|
||||
interface ThemeContextValue {
|
||||
theme: ThemeName;
|
||||
toggleTheme: () => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
function readStoredTheme(): string | null {
|
||||
try {
|
||||
return window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
} catch {
|
||||
// BR-3: treat unavailable/unreadable storage the same as "nothing stored" — fall back to default.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function persistTheme(theme: ThemeName): void {
|
||||
try {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
} catch {
|
||||
// Ignore write failures (e.g. storage disabled) — theme still works for this session via state.
|
||||
}
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<ThemeName>(() => resolveInitialTheme(readStoredTheme()));
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('theme-red', 'theme-purple');
|
||||
root.classList.add(themeClassName(theme));
|
||||
}, [theme]);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
setTheme((current) => {
|
||||
const next: ThemeName = current === 'red' ? 'purple' : 'red';
|
||||
persistTheme(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(() => ({ theme, toggleTheme }), [theme, toggleTheme]);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTheme(): ThemeContextValue {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export { DEFAULT_THEME };
|
||||
@@ -0,0 +1,63 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ThemeProvider, useTheme } from '../ThemeProvider';
|
||||
import { THEME_STORAGE_KEY } from '../tokens';
|
||||
|
||||
function ThemeProbe() {
|
||||
const { theme, toggleTheme } = useTheme();
|
||||
return (
|
||||
<div>
|
||||
<span data-testid="current-theme">{theme}</span>
|
||||
<button onClick={toggleTheme}>toggle</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThemeProvider', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
document.documentElement.className = '';
|
||||
});
|
||||
|
||||
it('defaults to red when nothing is stored (BR-1)', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeProbe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('current-theme')).toHaveTextContent('red');
|
||||
expect(document.documentElement.classList.contains('theme-red')).toBe(true);
|
||||
});
|
||||
|
||||
it('persists the chosen theme to localStorage on toggle (BR-2)', () => {
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeProbe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByText('toggle'));
|
||||
expect(screen.getByTestId('current-theme')).toHaveTextContent('purple');
|
||||
expect(window.localStorage.getItem(THEME_STORAGE_KEY)).toBe('purple');
|
||||
expect(document.documentElement.classList.contains('theme-purple')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to the default theme when the stored value is invalid (BR-3)', () => {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, 'blue');
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeProbe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('current-theme')).toHaveTextContent('red');
|
||||
});
|
||||
|
||||
it('uses a valid stored theme on load', () => {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, 'purple');
|
||||
render(
|
||||
<ThemeProvider>
|
||||
<ThemeProbe />
|
||||
</ThemeProvider>,
|
||||
);
|
||||
expect(screen.getByTestId('current-theme')).toHaveTextContent('purple');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Theme values (BR-3: only 'red' and 'purple' are valid).
|
||||
* Actual token colors live in src/index.css (.theme-red / .theme-purple);
|
||||
* this module only owns the *selectable* theme identifiers and the
|
||||
* localStorage key used for persistence (BR-2).
|
||||
*/
|
||||
|
||||
export type ThemeName = 'red' | 'purple';
|
||||
|
||||
export const THEMES: ThemeName[] = ['red', 'purple'];
|
||||
|
||||
export const DEFAULT_THEME: ThemeName = 'red';
|
||||
|
||||
export const THEME_STORAGE_KEY = 'slp-software-theme';
|
||||
|
||||
/** BR-3: fall back to the default theme for anything that isn't a valid ThemeName. */
|
||||
export function isValidTheme(value: unknown): value is ThemeName {
|
||||
return value === 'red' || value === 'purple';
|
||||
}
|
||||
|
||||
export function resolveInitialTheme(storedValue: string | null): ThemeName {
|
||||
if (isValidTheme(storedValue)) {
|
||||
return storedValue;
|
||||
}
|
||||
return DEFAULT_THEME;
|
||||
}
|
||||
|
||||
export function themeClassName(theme: ThemeName): string {
|
||||
return `theme-${theme}`;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { MouseEvent } from 'react';
|
||||
|
||||
/**
|
||||
* Smoothly scrolls to the element matching the given in-page anchor (e.g. "#pakketten").
|
||||
*
|
||||
* The app uses hash-based routing (see src/router.tsx) so plain `<a href="#id">` links
|
||||
* would otherwise be intercepted by the router as a path change, which resets the URL
|
||||
* hash back to "/" right after the browser's native anchor scroll. That causes the
|
||||
* "scrolls correctly, then jumps back to top on the next click" behaviour. Intercepting
|
||||
* the click and scrolling manually (without touching the URL hash) avoids the conflict.
|
||||
*/
|
||||
export function scrollToHash(hash: string) {
|
||||
const id = hash.replace(/^#/, '');
|
||||
const target = document.getElementById(id);
|
||||
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Click handler for in-page anchor links. Prevents the default hash navigation
|
||||
* (which would conflict with the app's hash-based router) and scrolls smoothly instead.
|
||||
*/
|
||||
export function handleAnchorClick(event: MouseEvent<HTMLAnchorElement>, href: string) {
|
||||
if (!href.startsWith('#')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
scrollToHash(href);
|
||||
}
|
||||
Reference in New Issue
Block a user