Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
96 changes: 96 additions & 0 deletions src/components/onboarding/DateOfBirthField.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-3">
<label
className="text-[10px] uppercase tracking-[0.3em] font-black opacity-50 block"
htmlFor="dob"
>
Date of birth *
</label>
<input
id="dob"
type="date"
value={value}
min={earliestAllowedDob()}
max={latestAllowedDob()}
onChange={(e) => {
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 && <p className="text-xs font-bold text-red-600">{error}</p>}
{!error && value && age !== null && (
<p className="text-xs opacity-70">
{formatDob(value)}. You will appear as <strong>{age}</strong>.
</p>
)}

<div
id="dob-warning"
role="alert"
className="border-2 border-amber-500 bg-amber-50 text-amber-950 p-4 space-y-2"
>
<p className="flex items-center gap-2 text-sm font-black uppercase tracking-wider">
<AlertTriangle className="w-4 h-4 shrink-0" aria-hidden />
{DOB_PERMANENT_TITLE}
</p>
<p className="text-sm leading-relaxed">{DOB_PERMANENT_BODY}</p>
<label className="flex items-start gap-3 pt-1 cursor-pointer">
<input
type="checkbox"
checked={confirmed}
disabled={!value || Boolean(validateDob(value))}
onChange={(e) => onConfirmedChange(e.target.checked)}
className="mt-0.5 w-4 h-4 accent-amber-600"
/>
<span className="text-sm font-bold">{DOB_CONFIRM_LABEL}</span>
</label>
</div>
</div>
);
}

/** Read-only display of a stored date of birth (profile screens). */
export function LockedDateOfBirth({ isoDate, hint }: { isoDate: string; hint: string }) {
return (
<div className="space-y-1">
<div className="field-input flex items-center justify-between opacity-80" aria-readonly>
<span>{formatDob(isoDate)}</span>
<Lock className="w-4 h-4" aria-label="Locked" />
</div>
<p className="text-xs opacity-60">{hint}</p>
</div>
);
}
65 changes: 41 additions & 24 deletions src/components/onboarding/OnboardingWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -32,8 +34,6 @@ const STEP_INDEX: Record<string, number> = { 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);

Expand Down Expand Up @@ -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<string[]>(
currentUser?.interestedIn && currentUser.interestedIn.length > 0 ? currentUser.interestedIn : ['FEMALE']
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -425,7 +436,8 @@ export function OnboardingWizard() {
const profilePayload: Record<string, unknown> = {
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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -582,24 +601,22 @@ export function OnboardingWizard() {
placeholder="How you want to be introduced"
/>
</Field>
<Field label="Age *">
{/* 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. */}
<select
value={age}
onChange={(e) => setAge(Number(e.target.value))}
className="field-input"
aria-label="Age"
>
{AGE_OPTIONS.map((value) => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</Field>
{storedDob ? (
<Field label="Date of birth">
<div className="field-input opacity-80">{storedDob} (age {age})</div>
</Field>
) : null}
</div>

{!storedDob && (
<DateOfBirthField
value={birthDate}
onChange={setBirthDate}
confirmed={dobConfirmed}
onConfirmedChange={setDobConfirmed}
/>
)}

<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.3em] font-black opacity-50 block">
Your Gender *
Expand Down
53 changes: 38 additions & 15 deletions src/features/profile/ProfileView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import { useAppStore } from '../../store/app-store';
import { useToast } from '../../components/common/Toast';
import { LoadingSpinner } from '../../components/common/LoadingSpinner';
import { MediaImage } from '../../components/common/MediaImage';
import { DateOfBirthField, LockedDateOfBirth } from '../../components/onboarding/DateOfBirthField';
import { DOB_LOCKED_HINT, DOB_PROVISIONAL_HINT, ageOn, validateDob } from '../../utils/dob';
import { pictureTransformStyle } from '../../utils/photo-transform';
import { useMediaSrc } from '../../utils/media-url';
import { ModerationBadge } from '../../components/ui/ModerationBadge';
Expand Down Expand Up @@ -275,14 +277,22 @@ export function ProfileView({
const [basic, setBasic] = useState({
displayName: user.displayName || user.name || '',
bio: user.bio || '',
age: String(user.age || '25'),
work: user.work || '',
education: user.education || '',
hometown: user.hometown || '',
currentLocation: user.currentLocation || '',
height: user.height ? String(user.height) : '',
});

// Date of birth is read-only. The one exception: an existing member whose date is a provisional
// estimate may enter their real date ONCE (the server then locks it).
const dobIsProvisional = Boolean(user.birthDetails?.provisional);
const lockedDob = user.birthDetails?.date && !dobIsProvisional ? user.birthDetails.date : '';
const [realDob, setRealDob] = useState('');
const [realDobConfirmed, setRealDobConfirmed] = useState(false);
const realDobReady = dobIsProvisional && realDob !== '' && realDobConfirmed && !validateDob(realDob);
const displayAge = (realDobReady ? ageOn(realDob) : null) ?? user.age;

const [optional, setOptional] = useState({
maxDistanceKm: String(prefs?.maxDistanceKm || '50'),
minAge: String(prefs?.minAge || '18'),
Expand Down Expand Up @@ -337,7 +347,6 @@ export function ProfileView({
setBasic({
displayName: user.displayName || user.name || '',
bio: user.bio || '',
age: String(user.age || '25'),
work: user.work || '',
education: user.education || '',
hometown: user.hometown || '',
Expand Down Expand Up @@ -600,7 +609,9 @@ export function ProfileView({
const profilePayload: Record<string, unknown> = {
displayName: trimmedDisplayName,
bio: trimmedBio,
age: numberOrUndefined(basic.age) || 18,
// Age is derived server-side from the immutable birth date and is never sent. A provisional
// date may be replaced once by the real one, and only after the member confirmed it.
...(realDobReady ? { birthDetails: { date: realDob } } : {}),
gender: gender || 'MALE',
interestedIn: interestedInGenders.length > 0 ? interestedInGenders : ['FEMALE'],
pictures: photos,
Expand Down Expand Up @@ -670,7 +681,8 @@ export function ProfileView({
name: trimmedDisplayName,
displayName: trimmedDisplayName,
bio: trimmedBio,
age: numberOrUndefined(basic.age) || 18,
age: displayAge,
...(realDobReady ? { birthDetails: { date: realDob } } : {}),
gender,
interestedIn: interestedInGenders,
pictures: photos,
Expand Down Expand Up @@ -740,7 +752,7 @@ export function ProfileView({
id: user.id,
name: basic.displayName || user.name || 'Anonymous',
displayName: basic.displayName || user.name || 'Anonymous',
age: basic.age ? Number(basic.age) : user.age || 25,
age: displayAge || 25,
bio: basic.bio || user.bio || '',
work: basic.work || user.work || '',
education: basic.education || user.education || '',
Expand Down Expand Up @@ -836,7 +848,7 @@ export function ProfileView({
<div className="text-center space-y-1">
<div className="flex items-center justify-center gap-2">
<h2 className="text-lg font-semibold text-text">
{basic.displayName || 'Anonymous'}, {basic.age || '—'}
{basic.displayName || 'Anonymous'}, {displayAge ?? '—'}
</h2>
{user.verified && (
<CheckCircle2 className="w-4 h-4 text-ig-blue fill-ig-blue" />
Expand Down Expand Up @@ -1161,16 +1173,27 @@ export function ProfileView({
/>
</Field>

<div className="grid grid-cols-2 gap-4">
<Field label="Age *">
<input
value={basic.age}
type="number"
min="18"
max="99"
onChange={(e) => setBasic((prev) => ({ ...prev, age: e.target.value }))}
className="field-input"
{lockedDob ? (
<Field label="Date of birth">
<LockedDateOfBirth isoDate={lockedDob} hint={DOB_LOCKED_HINT} />
</Field>
) : dobIsProvisional ? (
<div className="space-y-2">
<p className="text-sm font-bold">{DOB_PROVISIONAL_HINT}</p>
<DateOfBirthField
value={realDob}
onChange={setRealDob}
confirmed={realDobConfirmed}
onConfirmedChange={setRealDobConfirmed}
/>
</div>
) : null}

<div className="grid grid-cols-2 gap-4">
<Field label="Age">
<div className="field-input opacity-80" aria-readonly>
{displayAge ?? '—'}
</div>
</Field>
<Field label="Height (cm)">
<input
Expand Down
Loading
Loading