Wrap every user-facing Dutch string in Angular's first-party i18n — `i18n`/ `i18n-<attr>` in templates, `$localize` in TS (value-objects, machines, commands, label constants, shared-component defaults). Source locale stays nl; a second locale is now a translation file, not a code change. - M3: ~145 strings localized with stable @@ ids across registratie, herregistratie, auth, shared/ui, shared/layout. Skipped: showcase, debug-state, scenario interceptor, generated client, specs/stories, raw status enum tags, internal parse* diagnostics. - M4: single shared JA_NEE (localized labels) in radio-group; both wizard copies removed. Gate green: lint, check:tokens, build, test 77/77. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
199 lines
8.1 KiB
TypeScript
199 lines
8.1 KiB
TypeScript
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<Record<keyof Answers, string>>;
|
|
|
|
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<IntakeState, { tag: 'Answering' }>): 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<Errors, void> {
|
|
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<Errors, ValidIntake> {
|
|
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<string, void>): 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);
|
|
}
|
|
}
|