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}.
+
+ )}
+
+
+
+
+ {DOB_PERMANENT_TITLE}
+
+
{DOB_PERMANENT_BODY}
+
+
+
+ );
+}
+
+/** 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 ? (
+
+