From 8670f50570bd38e99e8da87f5ed2f12508e8bfa0 Mon Sep 17 00:00:00 2001 From: Pushp Date: Tue, 27 Oct 2026 06:38:52 +0530 Subject: [PATCH 1/2] feat(onboarding): compulsory date of birth with a permanent-change warning; DOB locked on profile - Onboarding asks for the date of birth (18+) instead of age, with an explicit 'this can never be changed' warning and a required confirmation checkbox - Age is no longer sent; the server derives it from birthDetails.date - Profile shows the DOB read-only; members with a provisional (estimated) DOB may enter their real date once, with the same warning - Maps State's complete-onboarding 409 codes (BIRTH_DATE_REQUIRED, PROFILE_REQUIRED, PROFILE_CHECK_UNAVAILABLE) Requires onehook-api-client with birthDetails (next release after 1.0.30). --- .../onboarding/DateOfBirthField.tsx | 96 +++++++++++++++++++ .../onboarding/OnboardingWizard.tsx | 65 ++++++++----- src/features/profile/ProfileView.tsx | 53 +++++++--- src/tests/dob.test.ts | 42 ++++++++ src/utils/dob.ts | 90 +++++++++++++++++ 5 files changed, 307 insertions(+), 39 deletions(-) create mode 100644 src/components/onboarding/DateOfBirthField.tsx create mode 100644 src/tests/dob.test.ts create mode 100644 src/utils/dob.ts diff --git a/src/components/onboarding/DateOfBirthField.tsx b/src/components/onboarding/DateOfBirthField.tsx new file mode 100644 index 0000000..6ffa473 --- /dev/null +++ b/src/components/onboarding/DateOfBirthField.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react'; +import { AlertTriangle, Lock } from 'lucide-react'; +import { + DOB_CONFIRM_LABEL, + DOB_PERMANENT_BODY, + DOB_PERMANENT_TITLE, + earliestAllowedDob, + formatDob, + latestAllowedDob, + validateDob, + ageOn, +} from '../../utils/dob'; + +interface Props { + value: string; + onChange: (isoDate: string) => void; + confirmed: boolean; + onConfirmedChange: (confirmed: boolean) => void; +} + +/** + * Date-of-birth entry with the permanent-change warning and a required confirmation checkbox. + * The warning sits next to the field, not in a toast, so it cannot be missed or dismissed unread. + */ +export function DateOfBirthField({ value, onChange, confirmed, onConfirmedChange }: Props) { + const [touched, setTouched] = useState(false); + const error = touched ? validateDob(value) : null; + const age = value ? ageOn(value) : null; + + return ( +
+ + { + onChange(e.target.value); + onConfirmedChange(false); // a new date needs a fresh confirmation + }} + onBlur={() => setTouched(true)} + className="field-input" + aria-describedby="dob-warning" + aria-invalid={Boolean(error)} + /> + {error &&

{error}

} + {!error && value && age !== null && ( +

+ {formatDob(value)}. You will appear as {age}. +

+ )} + + +
+ ); +} + +/** Read-only display of a stored date of birth (profile screens). */ +export function LockedDateOfBirth({ isoDate, hint }: { isoDate: string; hint: string }) { + return ( +
+
+ {formatDob(isoDate)} + +
+

{hint}

+
+ ); +} diff --git a/src/components/onboarding/OnboardingWizard.tsx b/src/components/onboarding/OnboardingWizard.tsx index e6769b3..2faa4f1 100644 --- a/src/components/onboarding/OnboardingWizard.tsx +++ b/src/components/onboarding/OnboardingWizard.tsx @@ -21,6 +21,8 @@ import { useToast } from '../common/Toast'; import { LoadingSpinner } from '../common/LoadingSpinner'; import { UserState } from '../../types'; import { MediaImage } from '../common/MediaImage'; +import { DateOfBirthField } from './DateOfBirthField'; +import { ageOn, onboardingGateMessage, validateDob } from '../../utils/dob'; import { useMediaSrc, isMediaKey } from '../../utils/media-url'; import { getCognitoAuth } from '../../lib/cognito-auth'; import { UiElement, trackProps, trackUi, trackScreenView, Screen } from '../../lib/analytics/analytics'; @@ -32,8 +34,6 @@ const STEP_INDEX: Record = { basics: 0, photos: 1, prompts: 2, l * Selectable ages. The lower bound is the platform minimum, so an under-18 value cannot be entered at * all rather than being rejected after the fact. */ -const AGE_OPTIONS = Array.from({ length: 82 }, (_, i) => i + 18); - /** Heights in cm, covering the adult range. Picked, not typed. */ const HEIGHT_OPTIONS = Array.from({ length: 91 }, (_, i) => i + 130); @@ -194,7 +194,13 @@ export function OnboardingWizard() { const [uploadingAudio, setUploadingAudio] = useState(false); const [displayName, setDisplayName] = useState(currentUser?.name || currentUser?.displayName || ''); - const [age, setAge] = useState(currentUser?.age || 25); + // Date of birth, captured once and never editable afterwards (the backend derives age from it). + // A member returning to the wizard with a real date already stored keeps it, locked. + const storedDob = + currentUser?.birthDetails?.date && !currentUser.birthDetails.provisional ? currentUser.birthDetails.date : ''; + const [birthDate, setBirthDate] = useState(storedDob); + const [dobConfirmed, setDobConfirmed] = useState(Boolean(storedDob)); + const age = birthDate ? ageOn(birthDate) ?? 0 : 0; const [gender, setGender] = useState(currentUser?.gender || 'MALE'); const [interestedInGenders, setInterestedInGenders] = useState( currentUser?.interestedIn && currentUser.interestedIn.length > 0 ? currentUser.interestedIn : ['FEMALE'] @@ -376,8 +382,13 @@ export function OnboardingWizard() { showToast('Please enter your name.', 'error'); return; } - if (!age || age < 18) { - showToast('You must be at least 18 years old.', 'error'); + const dobError = validateDob(birthDate); + if (dobError) { + showToast(dobError, 'error'); + return; + } + if (!dobConfirmed) { + showToast('Please confirm your date of birth. It cannot be changed later.', 'error'); return; } setStep('photos'); @@ -425,7 +436,8 @@ export function OnboardingWizard() { const profilePayload: Record = { displayName: displayName.trim(), bio: bio.trim(), - age: Number(age) || 18, + // Age is derived by the server from birthDetails.date; `age` is no longer sent. + birthDetails: { date: birthDate }, gender: gender || 'MALE', interestedIn: interestedInGenders.length > 0 ? interestedInGenders : ['FEMALE'], pictures: cleanedPictures, @@ -459,7 +471,8 @@ export function OnboardingWizard() { id: uid, name: displayName.trim(), displayName: displayName.trim(), - age: Number(age) || 18, + age, + birthDetails: { date: birthDate }, gender, interestedIn: interestedInGenders, bio: bio.trim(), @@ -492,7 +505,13 @@ export function OnboardingWizard() { // what is happening and that retrying is the next step, rather than implying their profile was // rejected — and keep them in the wizard with their input intact so "Finish" simply works later. const status = (err as any)?.status ?? (err as any)?.$metadata?.httpStatusCode; - if (status === 409) { + const gate = status === 409 ? onboardingGateMessage((err as any)?.message) : null; + if (gate) { + showToast(gate, 'error'); + } else if (status === 400 && /birthDetails|at least 18/.test((err as any)?.message ?? '')) { + showToast((err as any).message, 'error'); + setStep('basics'); + } else if (status === 409) { showToast( "Your account is still finishing setup — your profile is saved. Please tap Finish again in a few moments.", 'error' @@ -582,24 +601,22 @@ export function OnboardingWizard() { placeholder="How you want to be introduced" /> - - {/* Chosen, not typed: a select cannot produce 5, 200 or an empty box, which removes a - whole class of "check your age" errors that only appear after submitting. */} - - + {storedDob ? ( + +
{storedDob} (age {age})
+
+ ) : null} + {!storedDob && ( + + )} +