2026-10-03 14:29:28 +02:00
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
|
|
|
import type { Account } from '../lib/auth';
|
|
|
|
|
import { useI18n, type Key } from '../lib/i18n';
|
|
|
|
|
import { DEFAULT_PROFILE, PRESETS, activePreset, bucket } from '../lib/profile';
|
|
|
|
|
import type { Profile, ProfileKind, Verdict } from '../lib/types';
|
|
|
|
|
import { Icon, InfoTip, VERDICT_STYLE } from './ui';
|
2026-10-03 13:33:50 +02:00
|
|
|
|
|
|
|
|
interface Props {
|
2026-10-03 14:29:28 +02:00
|
|
|
account: Account;
|
|
|
|
|
/** Saved profile; null until the user finishes onboarding (guests, new accounts). */
|
|
|
|
|
profile: Profile | null;
|
|
|
|
|
onChange: (p: Profile) => void;
|
|
|
|
|
onDone: () => void;
|
|
|
|
|
onSignOut: () => void;
|
|
|
|
|
preview: (p: Profile) => Record<Verdict, number>;
|
|
|
|
|
children?: React.ReactNode;
|
2026-10-03 13:33:50 +02:00
|
|
|
}
|
|
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
const VERDICTS: Verdict[] = ['match', 'partial', 'conflict', 'incomplete', 'fail'];
|
|
|
|
|
|
|
|
|
|
export function ProfileView({ account, profile, onChange, onDone, onSignOut, preview, children }: Props) {
|
2026-10-03 13:33:50 +02:00
|
|
|
const { t } = useI18n();
|
2026-10-03 14:29:28 +02:00
|
|
|
const [draft, setDraft] = useState<Profile>(profile ?? DEFAULT_PROFILE[account.preset ?? 'wheelchair']);
|
|
|
|
|
const update = (p: Profile) => { setDraft(p); if (profile) onChange(p); };
|
|
|
|
|
const set = <K extends keyof Profile>(k: K, v: Profile[K]) => update({ ...draft, [k]: v });
|
|
|
|
|
|
|
|
|
|
// Debounced live preview: how many places fall into each verdict with these limits.
|
|
|
|
|
const [counts, setCounts] = useState<Record<Verdict, number> | null>(null);
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const id = setTimeout(() => setCounts(preview(draft)), 250);
|
|
|
|
|
return () => clearTimeout(id);
|
|
|
|
|
}, [draft, preview]);
|
|
|
|
|
|
|
|
|
|
const active = activePreset(draft);
|
|
|
|
|
const presets = PRESETS.filter((p) => p.kind === draft.kind);
|
|
|
|
|
const kerbHint = `hint.kerb.${bucket(draft.maxKerbCm, [0, 3, 6, 10])}` as Key;
|
|
|
|
|
const widthHint = `hint.width.${bucket(Math.round(draft.minWidthM * 100), [65, 80, 90, 100])}` as Key;
|
|
|
|
|
const inclineHint = `hint.incline.${bucket(draft.maxInclinePct, [5, 8, 12])}` as Key;
|
|
|
|
|
|
|
|
|
|
const summary = useMemo(() => {
|
|
|
|
|
let s = t('profile.summary.text', { kerb: draft.maxKerbCm, width: Math.round(draft.minWidthM * 100), incline: draft.maxInclinePct });
|
|
|
|
|
if (draft.avoidCobbles) s += t('profile.summary.cobbles');
|
|
|
|
|
if (draft.needToilet) s += t('profile.summary.toilet');
|
|
|
|
|
if (draft.needRest) s += t('profile.summary.rest');
|
|
|
|
|
if (draft.needChanging && draft.kind === 'stroller') s += t('profile.summary.changing');
|
|
|
|
|
return s;
|
|
|
|
|
}, [draft, t]);
|
2026-10-03 13:33:50 +02:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="grid-container padding-y-4 kbb-narrow">
|
2026-10-03 14:29:28 +02:00
|
|
|
<h1 className="margin-top-0">{profile ? t('profile.title') : t('profile.welcome')}</h1>
|
2026-10-03 13:33:50 +02:00
|
|
|
<p className="usa-intro">{t('profile.intro')}</p>
|
2026-10-03 14:29:28 +02:00
|
|
|
|
|
|
|
|
<div className="border-1px border-base-lighter radius-md padding-2 bg-base-lightest display-flex flex-justify flex-align-center flex-wrap">
|
2026-10-03 17:08:38 +02:00
|
|
|
<p className="kbb-acct margin-0">
|
|
|
|
|
<Icon name="accessibility_new" size={3} className="text-primary" />
|
2026-10-03 14:29:28 +02:00
|
|
|
<strong>{account.id === 'guest' ? t('auth.role.guest') : t(`auth.${account.id}` as Key)}</strong>
|
2026-10-03 17:08:38 +02:00
|
|
|
<span className="text-base-dark">{t(`auth.role.${account.role}` as Key)}</span>
|
|
|
|
|
{account.id !== 'guest' && <span className="usa-tag bg-ink text-white">{t('auth.demoBadge')}</span>}
|
2026-10-03 14:29:28 +02:00
|
|
|
</p>
|
|
|
|
|
<button type="button" className="usa-button usa-button--outline usa-button--small margin-0" onClick={onSignOut}>
|
|
|
|
|
{t('auth.signOut')}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<section aria-labelledby="st1" className="margin-top-4">
|
|
|
|
|
<h2 id="st1" className="kbb-step"><span className="kbb-step__n" aria-hidden="true">1</span>{t('profile.step1')}</h2>
|
2026-10-03 13:33:50 +02:00
|
|
|
<fieldset className="usa-fieldset">
|
2026-10-03 14:29:28 +02:00
|
|
|
<legend className="usa-sr-only">{t('profile.step1')}</legend>
|
|
|
|
|
<div className="kbb-choice">
|
|
|
|
|
{(['wheelchair', 'stroller'] as ProfileKind[]).map((k) => (
|
|
|
|
|
<div className="usa-radio" key={k}>
|
|
|
|
|
<input
|
|
|
|
|
className="usa-radio__input usa-radio__input--tile" id={`kind-${k}`} type="radio" name="kind"
|
|
|
|
|
checked={draft.kind === k} onChange={() => update(DEFAULT_PROFILE[k])}
|
|
|
|
|
/>
|
|
|
|
|
<label className="usa-radio__label" htmlFor={`kind-${k}`}>
|
|
|
|
|
{t(`profile.${k}`)}
|
|
|
|
|
<span className="usa-checkbox__label-description">{t(`profile.${k}.hint`)}</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-10-03 13:33:50 +02:00
|
|
|
</fieldset>
|
2026-10-03 14:29:28 +02:00
|
|
|
</section>
|
2026-10-03 13:33:50 +02:00
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
<section aria-labelledby="st2" className="margin-top-4">
|
|
|
|
|
<h2 id="st2" className="kbb-step">
|
|
|
|
|
<span className="kbb-step__n" aria-hidden="true">2</span>{t('profile.step2')}
|
|
|
|
|
<InfoTip topic={t('profile.step2')} text={t('tip.preset')} />
|
|
|
|
|
</h2>
|
|
|
|
|
<fieldset className="usa-fieldset">
|
|
|
|
|
<legend className="usa-sr-only">{t('profile.step2')}</legend>
|
|
|
|
|
<div className="kbb-choice">
|
|
|
|
|
{presets.map((p) => (
|
|
|
|
|
<div className="usa-radio" key={p.id}>
|
|
|
|
|
<input
|
|
|
|
|
className="usa-radio__input usa-radio__input--tile" id={`preset-${p.id}`} type="radio" name="preset"
|
|
|
|
|
checked={active === p.id} onChange={() => update({ ...p.values, ...keepPrefs(draft) })}
|
|
|
|
|
/>
|
|
|
|
|
<label className="usa-radio__label" htmlFor={`preset-${p.id}`}>
|
|
|
|
|
{t(`profile.preset.${p.id}` as Key)}
|
|
|
|
|
<span className="usa-checkbox__label-description">{t(`profile.preset.${p.id}.d` as Key)}</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
{active === 'custom' && (
|
|
|
|
|
<div className="usa-radio">
|
|
|
|
|
<input className="usa-radio__input usa-radio__input--tile" id="preset-custom" type="radio" name="preset" checked readOnly />
|
|
|
|
|
<label className="usa-radio__label" htmlFor="preset-custom">{t('profile.preset.custom')}</label>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-10-03 13:33:50 +02:00
|
|
|
</fieldset>
|
2026-10-03 14:29:28 +02:00
|
|
|
</section>
|
2026-10-03 13:33:50 +02:00
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
<section aria-labelledby="st3" className="margin-top-4">
|
|
|
|
|
<h2 id="st3" className="kbb-step"><span className="kbb-step__n" aria-hidden="true">3</span>{t('profile.step3')}</h2>
|
|
|
|
|
<p className="margin-top-0 text-base-dark">{t('profile.step3.help')}</p>
|
|
|
|
|
<Slider id="kerb" label={t('profile.kerb')} tip={t('tip.kerb')} min={0} max={10} step={1} value={draft.maxKerbCm} unit={t('unit.cm')} hint={t(kerbHint)} minLabel="0" maxLabel="10 cm" onChange={(v) => set('maxKerbCm', v)} />
|
|
|
|
|
<Slider id="width" label={t('profile.width')} tip={t('tip.width')} min={60} max={120} step={5} value={Math.round(draft.minWidthM * 100)} unit={t('unit.cm')} hint={t(widthHint)} minLabel="60 cm" maxLabel="120 cm" onChange={(v) => set('minWidthM', v / 100)} />
|
|
|
|
|
<Slider id="incline" label={t('profile.incline')} tip={t('tip.incline')} min={3} max={12} step={1} value={draft.maxInclinePct} unit={t('unit.pct')} hint={t(inclineHint)} minLabel="3%" maxLabel="12%" onChange={(v) => set('maxInclinePct', v)} />
|
|
|
|
|
</section>
|
2026-10-03 13:33:50 +02:00
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
<section aria-labelledby="st4" className="margin-top-4">
|
|
|
|
|
<h2 id="st4" className="kbb-step"><span className="kbb-step__n" aria-hidden="true">4</span>{t('profile.step4')}</h2>
|
|
|
|
|
<Check id="cobbles" label={t('profile.cobbles')} tip={t('tip.cobbles')} checked={draft.avoidCobbles} onChange={(v) => set('avoidCobbles', v)} />
|
|
|
|
|
<Check id="toilet" label={t('profile.toilet')} tip={t('tip.toilet')} checked={draft.needToilet} onChange={(v) => set('needToilet', v)} />
|
|
|
|
|
<Check id="rest" label={t('profile.rest')} tip={t('tip.rest')} checked={draft.needRest} onChange={(v) => set('needRest', v)} />
|
|
|
|
|
{draft.kind === 'stroller' && <Check id="changing" label={t('profile.changing')} tip={t('tip.changing')} checked={draft.needChanging} onChange={(v) => set('needChanging', v)} />}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section className="usa-summary-box margin-top-4" aria-labelledby="sum">
|
|
|
|
|
<div className="usa-summary-box__body">
|
|
|
|
|
<h2 className="usa-summary-box__heading" id="sum">{t('profile.summary')}</h2>
|
|
|
|
|
<p className="usa-summary-box__text">{summary}</p>
|
|
|
|
|
<h3 className="font-sans-sm margin-bottom-1">{t('profile.preview')}</h3>
|
|
|
|
|
<ul className="usa-list--unstyled display-flex flex-wrap margin-0 padding-0" aria-live="polite">
|
|
|
|
|
{counts && VERDICTS.map((v) => (
|
|
|
|
|
<li key={v} className="margin-right-1 margin-bottom-1">
|
|
|
|
|
<span className={`usa-tag display-inline-flex flex-align-center ${VERDICT_STYLE[v].tag}`}>
|
|
|
|
|
<Icon name={VERDICT_STYLE[v].icon} size={2} className="margin-right-05" />
|
|
|
|
|
{t('profile.preview.n', { n: counts[v], label: t(`verdict.count.${v}` as Key) })}
|
|
|
|
|
</span>
|
|
|
|
|
</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<p className="font-sans-xs text-base-dark margin-top-2">
|
|
|
|
|
<Icon name="lock" size={2} className="margin-right-05" />{t('profile.privacy')} {profile && t('profile.autosave')}
|
|
|
|
|
</p>
|
|
|
|
|
<button type="button" className="usa-button usa-button--big margin-top-2" onClick={() => { onChange(draft); onDone(); }}>
|
|
|
|
|
{t('profile.done')}
|
|
|
|
|
<Icon name="arrow_forward" size={3} className="margin-left-1" />
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{children}
|
2026-10-03 13:33:50 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
const keepPrefs = (p: Profile) => ({ avoidCobbles: p.avoidCobbles, needToilet: p.needToilet, needRest: p.needRest, needChanging: p.needChanging });
|
|
|
|
|
|
|
|
|
|
function Slider({ id, label, tip, min, max, step, value, unit, hint, minLabel, maxLabel, onChange }: {
|
|
|
|
|
id: string; label: string; tip: string; min: number; max: number; step: number; value: number; unit: string;
|
|
|
|
|
hint: string; minLabel: string; maxLabel: string; onChange: (v: number) => void;
|
2026-10-03 13:33:50 +02:00
|
|
|
}) {
|
|
|
|
|
return (
|
2026-10-03 14:29:28 +02:00
|
|
|
<div className="margin-top-3">
|
|
|
|
|
<div className="display-flex flex-align-center">
|
|
|
|
|
<label className="usa-label margin-0" htmlFor={`range-${id}`}>
|
|
|
|
|
{label}: <strong>{value} {unit}</strong>
|
|
|
|
|
</label>
|
|
|
|
|
<InfoTip topic={label} text={tip} />
|
|
|
|
|
</div>
|
2026-10-03 13:33:50 +02:00
|
|
|
<input
|
|
|
|
|
id={`range-${id}`} className="usa-range" type="range" min={min} max={max} step={step} value={value}
|
2026-10-03 14:29:28 +02:00
|
|
|
aria-describedby={`hint-${id}`} onChange={(e) => onChange(Number(e.target.value))}
|
2026-10-03 13:33:50 +02:00
|
|
|
/>
|
2026-10-03 14:29:28 +02:00
|
|
|
<div className="display-flex flex-justify font-sans-3xs text-base-dark" aria-hidden="true"><span>{minLabel}</span><span>{maxLabel}</span></div>
|
|
|
|
|
<p id={`hint-${id}`} className="margin-top-05 margin-bottom-0 font-sans-xs"><Icon name="info_outline" size={2} className="margin-right-05 text-primary" />{hint}</p>
|
2026-10-03 13:33:50 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-10-03 14:29:28 +02:00
|
|
|
function Check({ id, label, tip, checked, onChange }: { id: string; label: string; tip: string; checked: boolean; onChange: (v: boolean) => void }) {
|
2026-10-03 13:33:50 +02:00
|
|
|
return (
|
2026-10-03 14:29:28 +02:00
|
|
|
<div className="display-flex flex-align-center">
|
|
|
|
|
<div className="usa-checkbox">
|
|
|
|
|
<input className="usa-checkbox__input" id={`chk-${id}`} type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
|
|
|
|
|
<label className="usa-checkbox__label" htmlFor={`chk-${id}`}>{label}</label>
|
|
|
|
|
</div>
|
|
|
|
|
<InfoTip topic={label} text={tip} />
|
2026-10-03 13:33:50 +02:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|