Marketing site, helpcentrum and tools for Struis ICT. Built with Next.js 16 + React 19 + TypeScript, statically exported, deployed on Cloudflare Pages.
- Next.js 16.2 (App Router, Turbopack)
- React 19.2
- TypeScript 6.0 (strict)
- ESLint 10 (flat config,
eslint-config-next) - No CSS framework — hand-rolled styles in
app/globals.css(ported fromstruis-ict.css) qrcode-generatorfor the WiFi-QR tool
See CONTRIBUTING.md for the full guide. TL;DR: branch
off main, conventional-commit your changes, the pre-commit hook formats
- lints automatically, CI runs unit + integration + e2e + coverage on the PR, Codecov comments the diff, and Cloudflare Pages posts a live preview URL.
npm install
npm run dev # http://localhost:3000npm run build # writes to out/
npm run serve # serves out/ via npx serve, for local preview of the production buildThe build emits a fully static site to out/. next.config.ts sets output: "export", trailingSlash: true, images.unoptimized: true.
npm test # vitest, full run — unit + integration combined (~18 s, 396 tests)
npm run test:unit # only unit tests — pure functions + component renders (310 tests)
npm run test:integration # only integration tests — components + user interactions (86 tests)
npm run test:watch # vitest in watch mode
npm run test:coverage # vitest + V8 coverage report (writes coverage/, enforces thresholds)
npm run e2e # Playwright end-to-end tests (boots dev server, records video)
npm run e2e:ui # Playwright in interactive UI mode
npm run e2e:headed # Run E2E with visible browser windows
npm run e2e:report # Open last Playwright HTML report
npm run lint # next lint (Next-specific rules)
npm run lint:oxlint # oxlint (fast Rust linter, separate ruleset)
npm run lint:oxlint:fix # oxlint + auto-fix
npm run format # oxfmt — format the whole repo
npm run format:check # oxfmt --check (fails if anything is unformatted; CI uses this)Coverage thresholds (enforced by vitest) live in vitest.config.ts:
78 % lines / 76 % statements / 78 % functions / 75 % branches. CI fails if
coverage drops below those. components/SiteScripts.tsx is excluded from
unit coverage — its global-DOM useEffect is exercised end-to-end by Playwright
instead.
The vitest suite is split into two named layers by file naming convention:
- Unit tests —
*.test.{ts,tsx}(excluding*.integration.test.tsx). Pure functions, single-component renders without user interaction, page smoke tests. Fast, isolated, deterministic.app/tools/**/*.test.ts— pure helpers (scoring, filter, regex, etc.)components/{Footer,Nav,Icon,BackToTop,TitleSwap,WhatsAppButton}.test.tsxapp/**/page.test.tsx— route smoke tests
- Integration tests —
*.integration.test.tsx. Render a component, drive it viauserEvent(clicks, typing, slider drags), assert state-driven output. Multiple units cooperating.app/tools/{wachtwoord,wachtwoordzin}/Generator.integration.test.tsxapp/tools/{avg-check,back-up-check}/{AvgCheck,BackupCheck}.integration.test.tsxapp/tools/{faq,foto-verkleinen,wifi-qr,link-check}/*.integration.test.tsxcomponents/SiteScripts.integration.test.tsx
- End-to-end tests —
e2e/*.spec.ts, run by Playwright (next section).
Playwright runs in chromium and records video for every test (kept for both
passes and failures so you can rewatch a flow). Specs live in e2e/:
| Spec | Covers |
|---|---|
smoke.spec.ts |
Every route returns 2xx and shows its expected heading |
theme-and-lang.spec.ts |
Theme/lang toggle wiring, hamburger, back-to-top |
tarieven-calculator.spec.ts |
Full price-calculator flow incl. €40 haal/breng toggle |
wachtwoord.spec.ts, wachtwoordzin.spec.ts |
Generators (length, charsets, copy via clipboard API) |
wifi-qr.spec.ts |
Mode switcher, canvas QR rendering, hidden / open network |
link-check.spec.ts |
Phishing detection, IDN/Punycode, Cmd+Enter, Clear, examples |
avg-check.spec.ts, back-up-check.spec.ts |
Quiz flows, scoring bands, language switch |
foto-verkleinen.spec.ts |
Image upload, preview, slider re-process, reset |
faq.spec.ts |
Search filter, category chips, expand/collapse |
contact-form.spec.ts |
Formspree mock; success + error paths |
CI uploads playwright-report/ and test-results/ (videos, traces, screenshots)
as artifacts retained 14 days. Locally, after a run open npm run e2e:report
to browse the HTML report.
Workflows live in .github/workflows/:
| Workflow | Trigger | What it runs |
|---|---|---|
ci.yml |
every push + every PR | verify job: lint:oxlint → format:check → test:coverage → build. Uploads coverage artifact + (on main) static out/. e2e job: Playwright on chromium with video recording — runs after verify passes; uploads HTML report + videos as artifacts. |
Deploy and PR previews are handled by Cloudflare Pages (no workflow in this repo): every push to main triggers a production build at https://struisict.com/; every PR gets a preview URL posted by the Cloudflare GitHub app.
dependabot.yml opens grouped weekly dependency-update PRs (Mondays).
Use prefixes (feat, fix, chore, docs, test, refactor, style, ci, perf) on every commit. Examples:
fix(tarieven): correct €0.50/km calcfeat(tools): add wifi-qr generatorfeat!: …for breaking changes
To enforce "CI must pass before merge", go to Settings → Branches → Add
branch ruleset in GitHub and require these checks on main:
- Require a pull request before merging
- Require status checks to pass: tick
verify(the CI job) - Require branches to be up to date before merging
- (Optionally) Require conversation resolution before merging
Without that ruleset configured, CI still runs but isn't enforced — the workflow file alone can't block a merge.
.husky/pre-commit runs npx lint-staged, which pipes staged files through oxfmt --write then oxlint --fix. So local commits arrive at GitHub already-formatted and lint-clean — CI is a backstop, not the only check.
Deploys go through Cloudflare Pages, connected to this GitHub repo. The Pages project's build settings:
- Framework preset: Next.js (Static HTML Export)
- Build command:
npm run build - Build output directory:
out - Environment variable:
NODE_VERSION = 24.15.0(matchespackage.json)
Every push to main builds and deploys to https://struisict.com/; every PR gets a Cloudflare-hosted preview URL.
Two CFP-specific files in public/ are copied into out/ at build time:
_redirects— 301s for the Catch a Break legacy paths (now at https://catch-a-break.web.app/) and the*.html→*/URL scheme migration._headers— security headers (CSP, HSTS, X-Frame-Options, Permissions-Policy, etc.). HTTPS enforcement, gzip/brotli, and edge caching come from Cloudflare itself.
Class-based show/hide. Every translatable element renders both NL and EN spans/paragraphs in the DOM; globals.css hides the inactive one based on body.lang-en. A synchronous script in <head> (defined inline in app/layout.tsx) sets the initial lang-en class from localStorage or navigator.languages to prevent flash. The LangProvider is implemented inside components/SiteScripts.tsx (the toggle handler updates the body class + localStorage).
For per-page document titles, drop a <TitleSwap nl="…" en="…" /> at the top of the page; it sets data-title-nl / data-title-en on <html> and updates document.title when the language changes.
app/layout.tsx— owns<Nav>,<Footer>,<WhatsAppButton>,<BackToTop>,<SiteScripts>. Pages do not render any of these.components/SiteScripts.tsx(client) — singleuseEffectkeyed onpathnamethat wires up scroll reveal, smooth-scroll anchors, theme/lang toggle, hamburger menu, contact-form submit, and the tarieven calculator. Re-runs on every route change so observers attach to fresh DOM.components/Icon.tsx—<Icon name="i-foo" />→<svg class="icon"><use href="/icons.svg#i-foo"/></svg>. The sprite lives atpublic/icons.svg.- Per-tool client widgets live alongside their server
page.tsx(e.g.app/tools/wachtwoord/Generator.tsx).
| Route | Source |
|---|---|
/ |
app/page.tsx (LocalBusiness JSON-LD) |
/apps/ |
app/apps/page.tsx |
/daniel/ |
app/daniel/page.tsx |
/myfleet/ |
app/myfleet/page.tsx (SoftwareApplication + FAQPage JSON-LD) |
/tarieven/ |
app/tarieven/page.tsx (price calculator) |
/tools/ |
app/tools/page.tsx (helpcentrum landing) |
/tools/faq/ |
app/tools/faq/page.tsx + Search.tsx + faq-data.ts |
/tools/wachtwoord/ |
app/tools/wachtwoord/page.tsx + Generator.tsx |
/tools/wachtwoordzin/ |
app/tools/wachtwoordzin/page.tsx + Generator.tsx + wordlist.ts |
/tools/wifi-qr/ |
app/tools/wifi-qr/page.tsx + WifiQR.tsx |
/tools/foto-verkleinen/ |
app/tools/foto-verkleinen/page.tsx + ImageResizer.tsx |
/tools/avg-check/ |
app/tools/avg-check/page.tsx + AvgCheck.tsx |
/tools/back-up-check/ |
app/tools/back-up-check/page.tsx + BackupCheck.tsx |
/tools/link-check/ |
app/tools/link-check/page.tsx + LinkCheck.tsx |
/voorwaarden/ |
app/voorwaarden/page.tsx |
/404.html |
app/not-found.tsx (referenced by htaccess ErrorDocument 404) |
- Favicons (
favicon.ico,favicon-32.png,favicon-16.png,apple-touch-icon.png) are not in this repo — they were historically uploaded to the production server out-of-band. They are referenced inapp/layout.tsx's metadata. Add them topublic/if you want them included in the build. - The Catch a Break files are no longer part of this repo; that project lives at
Struis112/catch-a-break-weband deploys to https://catch-a-break.web.app/. The_redirectsfile issues 301s from the old URLs to that host.