diff --git a/package-lock.json b/package-lock.json index b0694b6..6309fff 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "jsqr": "1.4.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", - "onehook-api-client": "1.0.30", + "onehook-api-client": "^1.0.31", "qrcode.react": "^4.2.0", "react": "^19.0.1", "react-dom": "^19.0.1", @@ -5704,9 +5704,9 @@ } }, "node_modules/onehook-api-client": { - "version": "1.0.30", - "resolved": "https://registry.npmjs.org/onehook-api-client/-/onehook-api-client-1.0.30.tgz", - "integrity": "sha512-oJKZWq+uHgTKXvg1VgRBuDWUXNQsCJV6se4mEv8iWT9QpjIlPx3JkYtY00geDL6nh8LXF/CdPZDkz2c3B0gV6w==", + "version": "1.0.31", + "resolved": "https://registry.npmjs.org/onehook-api-client/-/onehook-api-client-1.0.31.tgz", + "integrity": "sha512-viO4c+6Pa3brsz//Y0UHHLvAqxY1oAxRjoX+b+pBoAjrnuBGzZJH9UEQFKlW4AIp97RkTAbo2XR3V+KQLkl8Ow==", "license": "UNLICENSED", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", diff --git a/package.json b/package.json index 39761d7..0576191 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "jsqr": "1.4.0", "lucide-react": "^0.546.0", "motion": "^12.23.24", - "onehook-api-client": "1.0.30", + "onehook-api-client": "^1.0.31", "qrcode.react": "^4.2.0", "react": "^19.0.1", "react-dom": "^19.0.1", 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 && ( + + )} +