Next.js 15 app (App Router) using XState 5 to guide users through a short questionnaire and generate tailored recommendations with partner cards and resources.
Prerequisites
- Node.js 18+
- yarn (recommended)
Install & run
- Install dependencies:
yarn install- Start the dev server:
yarn dev- Accessible French UI with proper metadata and a global 404 page
src/
app/
layout.tsx
page.tsx
not-found.tsx
eco-portal/
page.tsx # Redirects to /eco-portal/status
[step]/
page.tsx # Async server route; guards & renders client form
result/
page.tsx # Results page (server) with Suspense
components/
layout/
Header/ # Header ("Portail Économique")
Footer/ # Footer (copyright)
cards/
BrochureDownload/ # PDF download link card
...cards contents
RecommendationCard/ # RECOMMENDATION_CONTENT mapping
Results/ # Results display recommended cards
Recap/ # Recap sidebar component
Loader/ # Loader (spinner + customizable label)
OptionButton/ # Option button component
OptionsList/ # Options list component
containers/
forms/
EcoPortalForm/
index.tsx # Client form UI
useEcoPortalForm.ts # Form logic (hooks/machine wiring)
machines/
ecoPortalMachine/
index.ts # State machine + guards/actions
constants.ts # Labels and copy
types.ts # Types/enums
ecoPortalMachine.test.ts # State machine tests
public/
-
File:
src/machines/ecoPortalMachine/index.ts -
Guards map user choices to next step or direct recommendations
-
Actions build the
recommendationsarray and record aresponsestrail -
Events:
SelectStatus,SelectNeed,SelectFinancing,SelectInvest,Back,Restart -
Question & options helpers from
getEcoQuestion/getEcoOptions -
Responses are appended to
context.responsesfor the recap trail and are forwarded to the results page via arespquery param.
Recommendations content
- File:
src/components/RecommendationCard/index.ts - Export:
RECOMMENDATION_CONTENT{ Cards: { title?: string; card: React.ComponentType; logos: string[] }[] }
- Results page:
src/components/Results/index.tsx- Parses
recsandrespfrom the URL - Renders grouped partner cards and a recap sidebar using the shared
Recapcomponent - Normalizes logo paths by stripping a leading
/public/
- Parses
-
Accueil:
src/app/page.tsx— intro + logos grid (SVGs in/public) -
Questionnaire entry:
src/app/eco-portal/page.tsx— redirects to/eco-portal/status -
Step pages:
src/app/eco-portal/[step]/page.tsx— server-validated step with asyncparams/searchParamsand Suspense-wrapped client form -
Résultats:
src/app/eco-portal/result/page.tsx— Suspense + Results -
404:
src/app/not-found.tsx— friendly French not-found page- « tous droit réservés à l’Ambassade de France à Tunis et le Comité Tunisie des Conseillers du commerce extérieur de la France. »
-
Loader:
src/components/Loader(spinner + customizable label)
- Files under
/publicare served from root (e.g./logo.svg, not/public/logo.svg) - Logos are rendered via
next/imagewith increased intrinsic size for crispness
- Site-wide metadata:
src/app/layout.tsx- French locale, title template, keywords, description
- Page-specific metadata for Accueil, Questionnaire, Résultats
- Framework: Vitest
- Tests:
src/machines/ecoPortalMachine/ecoPortalMachine.test.ts(12 tests)
Run
yarn testyarn lint- Do not link to
/public/...; use/<filename>instead - When adding logos, prefer SVGs; for PNGs, keep adequate intrinsic sizes
- If you add a new recommendation key, update both the machine and
RECOMMENDATION_CONTENT
- The form uses a “select then Next” interaction:
- Clicking an option highlights it (no immediate navigation)
- A “Suivant” button stays disabled until a choice is selected
- On Next, the choice is committed to the XState machine and routing proceeds to the next step (or results when complete)
- The recap sidebar is always visible:
- It shows on all steps and on the results page
- Layout is consistent: right sidebar on large screens, stacked below on small screens
- Results also display the recap using the
respquery param to persist answers
- Install deps and run
yarn dev - Make changes and add tests when editing machine behavior
yarn testandyarn lintbefore commit
Made with Next.js, XState, Tailwind, and TypeScript.
An application-wide iframe modal is available to preview external resources without navigating away.
Usage inside a component:
import { useModal } from "@/components/ui/ModalContext";
export function ExampleButton() {
const { openModal } = useModal();
return (
<button
onClick={() => openModal("https://example.com")}
className="px-3 py-2 rounded-md bg-blue-600 text-white hover:bg-blue-500"
>
Ouvrir exemple
</button>
);
}Features:
- Closes on overlay click, ESC, or the × button
- Focus trap + restore to trigger element
- Body scroll locked while open
- Responsive: full height on small screens, centered panel on larger screens
If you add cards with links you want intercepted, replace their <a target="_blank"> handler with a call to openModal(url) and optionally keep a context menu accessible version.