import { Result, ok, err, assertNever } from '@shared/kernel/fp'; import { Uren, parseUren } from '@registratie/domain/value-objects/uren'; /** * A FIXED 3-step wizard with progressive disclosure. The steps never change in * number (always `STEPS`); instead, follow-up questions appear *inline within a * step* depending on earlier answers — answer "buiten Nederland gewerkt? → ja" * and the country/hours questions reveal in the same step; report few hours and * the scholing-question reveals inside the 'werk' step. "Is this field required * right now" is a pure function (`validateStep`/`lageUren`), so it's trivial to * test and impossible to get out of sync with the data. */ export type JaNee = 'ja' | 'nee'; /** The three fixed steps. Each step groups one or more questions. */ export type StepId = 'buitenland' | 'werk' | 'review'; /** One record carried across every step (and persisted). All optional: the user fills it in gradually, and branches may never ask some fields. */ export interface Answers { buitenlandGewerkt?: JaNee; // Q1 land?: string; // Q1a — only when buitenlandGewerkt === 'ja' buitenlandseUren?: string; // Q1b — only when buitenlandGewerkt === 'ja' uren?: string; // Q2 — uren in NL scholingGevolgd?: JaNee; // Q3 — only when total hours are below the threshold punten?: string; // Q4 } /** What we have after the review step parses — guaranteed valid/typed. */ export interface ValidIntake { werktBuitenland: boolean; land?: string; buitenlandseUren?: Uren; uren: Uren; aanvullendeScholing?: boolean; punten?: Uren; // only collected when aanvullende scholing is gevolgd (scholingGevolgd === 'ja') } /** Demo fallback only — the real threshold is a SERVER-OWNED policy value fetched at runtime (see IntakePolicyDto / SetPolicy). ponytail: default is the offline fallback; the server value wins. */ export const SCHOLING_THRESHOLD_DEFAULT = 1000; /** True when NL-hours are low enough that the scholing question must be answered. The threshold is passed in (server-owned), not hardcoded. */ export function lageUren(a: Answers, scholingThreshold = SCHOLING_THRESHOLD_DEFAULT): boolean { const r = parseUren(a.uren ?? ''); return r.ok && r.value < scholingThreshold; } /** The fixed step list. Number of steps never changes; questions reveal inline. */ export const STEPS: StepId[] = ['buitenland', 'werk', 'review']; /** Per-field error map: one message per question, since a step holds several. */ type Errors = Partial>; export type IntakeState = | { tag: 'Answering'; answers: Answers; cursor: number; errors: Errors; scholingThreshold: number } | { tag: 'Submitting'; data: ValidIntake } | { tag: 'Submitted'; data: ValidIntake } | { tag: 'Failed'; data: ValidIntake; error: string }; export const initial: IntakeState = { tag: 'Answering', answers: {}, cursor: 0, errors: {}, scholingThreshold: SCHOLING_THRESHOLD_DEFAULT }; /** Which step the cursor currently points at (clamped to the fixed list). */ export function currentStep(s: Extract): StepId { return STEPS[Math.min(s.cursor, STEPS.length - 1)]; } /** Validate every question currently visible in ONE step. Errors keyed per field. */ function validateStep(step: StepId, a: Answers, scholingThreshold: number): Result { const errors: Errors = {}; switch (step) { case 'buitenland': if (!a.buitenlandGewerkt) errors.buitenlandGewerkt = $localize`:@@validation.maakKeuze:Maak een keuze.`; else if (a.buitenlandGewerkt === 'ja') { if (!a.land || a.land.trim() === '') errors.land = $localize`:@@validation.land:Vul een land in.`; const u = parseUren(a.buitenlandseUren ?? ''); if (!u.ok) errors.buitenlandseUren = u.error; } break; case 'werk': { const u = parseUren(a.uren ?? ''); if (!u.ok) errors.uren = u.error; if (lageUren(a, scholingThreshold) && !a.scholingGevolgd) errors.scholingGevolgd = $localize`:@@validation.maakKeuze:Maak een keuze.`; // Nascholingspunten are only asked (and required) when scholing was followed. if (a.scholingGevolgd === 'ja') { const p = parseUren(a.punten ?? ''); if (!p.ok) errors.punten = p.error; } break; } case 'review': break; // review shows a summary; no own fields default: return assertNever(step); } return Object.keys(errors).length > 0 ? err(errors) : ok(undefined); } /** Parse the whole questionnaire into a ValidIntake (called on submit). */ function validateAll(a: Answers, scholingThreshold: number): Result { const errors: Errors = {}; for (const step of STEPS) { const r = validateStep(step, a, scholingThreshold); if (!r.ok) Object.assign(errors, r.error); } if (Object.keys(errors).length > 0) return err(errors); const uren = parseUren(a.uren ?? ''); // validateStep guaranteed uren parses, but keep the compiler happy. if (!uren.ok) return err(errors); const werktBuitenland = a.buitenlandGewerkt === 'ja'; const buitenland = parseUren(a.buitenlandseUren ?? ''); // Punten are only collected when aanvullende scholing was gevolgd. const punten = a.scholingGevolgd === 'ja' ? parseUren(a.punten ?? '') : undefined; return ok({ werktBuitenland, land: werktBuitenland ? a.land : undefined, buitenlandseUren: werktBuitenland && buitenland.ok ? buitenland.value : undefined, uren: uren.value, aanvullendeScholing: lageUren(a, scholingThreshold) ? a.scholingGevolgd === 'ja' : undefined, punten: punten?.ok ? punten.value : undefined, }); } export function setAnswer(s: IntakeState, key: keyof Answers, value: string): IntakeState { if (s.tag !== 'Answering') return s; // Steps are fixed, so editing an answer never moves the cursor — it only // reveals/hides inline questions within the current step. return { ...s, answers: { ...s.answers, [key]: value } }; } export function next(s: IntakeState): IntakeState { if (s.tag !== 'Answering') return s; const r = validateStep(currentStep(s), s.answers, s.scholingThreshold); if (!r.ok) return { ...s, errors: r.error }; return { ...s, cursor: Math.min(s.cursor + 1, STEPS.length - 1), errors: {} }; } /** Apply a server-owned policy value (e.g. the scholing threshold). */ export function setPolicy(s: IntakeState, scholingThreshold: number): IntakeState { return s.tag === 'Answering' ? { ...s, scholingThreshold } : s; } export function back(s: IntakeState): IntakeState { if (s.tag !== 'Answering' || s.cursor === 0) return s; return { ...s, cursor: s.cursor - 1, errors: {} }; } export function submit(s: IntakeState): IntakeState { if (s.tag !== 'Answering') return s; const r = validateAll(s.answers, s.scholingThreshold); return r.ok ? { tag: 'Submitting', data: r.value } : { ...s, errors: r.error }; } export function resolve(s: IntakeState, r: Result): IntakeState { if (s.tag !== 'Submitting') return s; return r.ok ? { tag: 'Submitted', data: s.data } : { tag: 'Failed', data: s.data, error: r.error }; } export type IntakeMsg = | { tag: 'SetAnswer'; key: keyof Answers; value: string } | { tag: 'Next' } | { tag: 'Back' } | { tag: 'Submit' } | { tag: 'Retry' } | { tag: 'SubmitConfirmed' } | { tag: 'SubmitFailed'; error: string } | { tag: 'SetPolicy'; scholingThreshold: number } | { tag: 'Seed'; state: IntakeState }; export function reduce(s: IntakeState, m: IntakeMsg): IntakeState { switch (m.tag) { case 'SetAnswer': return setAnswer(s, m.key, m.value); case 'Next': return next(s); case 'Back': return back(s); case 'Submit': return submit(s); case 'Retry': return s.tag === 'Failed' ? { tag: 'Submitting', data: s.data } : s; case 'SubmitConfirmed': return s.tag === 'Submitting' ? { tag: 'Submitted', data: s.data } : s; case 'SubmitFailed': return s.tag === 'Submitting' ? { tag: 'Failed', data: s.data, error: m.error } : s; case 'SetPolicy': return setPolicy(s, m.scholingThreshold); case 'Seed': return m.state; default: return assertNever(m); } }