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.31",
"onehook-api-client": "^1.0.32",
"qrcode.react": "^4.2.0",
"react": "^19.0.1",
"react-dom": "^19.0.1",
Expand Down
55 changes: 53 additions & 2 deletions src/components/legal/Privacy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,11 @@ export function Privacy() {
<h3 className="text-2xl font-bold mb-3 mt-6">Account Information</h3>
<ul className="list-disc list-inside space-y-2 opacity-70">
<li>Email address</li>
<li>Name and date of birth</li>
<li>Name and date of birth (required; your age is calculated from it and is never stored separately)</li>
<li>
Birth time, time zone and birthplace (optional; see{' '}
<a href="#birth-details" className="underline" data-track-exempt="duplicate: in-page anchor; the privacy screen view is already tracked">Date of birth and traditional compatibility</a>)
</li>
<li>Location (geographic area only)</li>
<li>Profile photos, videos, and audio (encrypted storage)</li>
<li>Profile details, bio, and interests</li>
Expand Down Expand Up @@ -136,6 +140,40 @@ export function Privacy() {
available for non-critical)
</p>
</div>
<div id="birth-details" className="scroll-mt-28">
<h4 className="font-bold mb-2">✓ Date of Birth and Traditional Compatibility</h4>
<div className="opacity-70 space-y-2">
<p>
<strong>Date of birth.</strong> You give it once, at sign-up. We use it to show your age
(calculated fresh every time, never stored as a separate number), to keep OneHook adults-only
(18+), and for age preferences in matching. Other members see your age, never your date of
birth. You cannot change it yourself; if it is wrong, our support team can correct it after
checking proof such as a government ID, at most twice per account, and each correction is
recorded with the reason.
</p>
<p>
<strong>Birth time, time zone and birthplace (optional).</strong> If you add them, together
with your date of birth they are used for one purpose: estimating traditional compatibility
between members (for example Kundali, Western natal charts, Bazi and Tzolk&apos;in). The
result slightly changes the order in which profiles are suggested to you and to others; it
never hides anyone and is never shown as a score. You can add, change or remove them at any
time in your profile.
</p>
<p>
<strong>How it is handled.</strong> We store the coordinates of your birthplace rounded to
about 1 km, plus the place name you picked. Birth details are visible only to you. They are
not shared with other members, not sent to our AI recommendation service, and removed from our
analytics, which keep only your age at the time of an event. From these details we compute a
short coded summary (a few bytes) that is used only for matching. Birthplace search is
answered by Amazon Location Service; what you type is not stored.
</p>
<p>
By entering your date of birth you consent to these uses; by adding birth time or place you
consent to their use for traditional compatibility. You can withdraw that consent by removing
the optional details from your profile.
</p>
</div>
</div>
<div>
<h4 className="font-bold mb-2">✗ We Do NOT Use Your Data For</h4>
<p className="opacity-70">
Expand Down Expand Up @@ -577,6 +615,14 @@ export function Privacy() {
Data deleted within 30 days of account deletion (except where required by law)
</p>
</div>
<div>
<strong>Date of Birth and Birth Details:</strong>
<p className="opacity-70">
Kept while your account is active and deleted with it, together with the matching summary
computed from them and any record of a support correction. Removing your birth time or place
deletes them and their summary straight away.
</p>
</div>
<div>
<strong>Video Applications:</strong>
<p className="opacity-70">
Expand Down Expand Up @@ -626,7 +672,9 @@ export function Privacy() {
<strong>Right to Access:</strong> Request a copy of all your personal data we hold
</li>
<li>
<strong>Right to Correction:</strong> Update or correct inaccurate information
<strong>Right to Correction:</strong> Update or correct inaccurate information. Your date of
birth is corrected by our support team on request, with proof (see{' '}
<a href="#birth-details" className="underline" data-track-exempt="duplicate: in-page anchor; the privacy screen view is already tracked">section 3</a>)
</li>
<li>
<strong>Right to Deletion:</strong> Request permanent deletion of your account and
Expand Down Expand Up @@ -727,6 +775,9 @@ export function Privacy() {
SageMaker, hosted
in AWS (India region)
</li>
<li>
<strong>Birthplace Search:</strong> Amazon Location Service, hosted in AWS (India region)
</li>
<li>
<strong>Analytics:</strong> Hosted in AWS (India region)
</li>
Expand Down
256 changes: 256 additions & 0 deletions src/components/onboarding/BirthTimePlaceFields.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { MapPin, Clock, X } from 'lucide-react';
import { sdkClient } from '../../api/sdk-client';
import { UiElement, trackProps } from '../../lib/analytics/analytics';

/** The optional parts of birthDetails. Only the date is locked; these can change at any time. */
export interface BirthTimePlace {
time?: string;
timeZone?: string;
latitude?: number;
longitude?: number;
placeName?: string;
}

interface Props {
value: BirthTimePlace;
onChange: (next: BirthTimePlace) => void;
/** Where the fields are shown: onboarding or the profile editor (telemetry id; never a value). */
surface?: 'onboarding' | 'profile';
}

const browserZone = (() => {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch {
return 'UTC';
}
})();

const allZones: string[] = (() => {
try {
return (Intl as unknown as { supportedValuesOf(k: string): string[] }).supportedValuesOf(
'timeZone'
);
} catch {
return [browserZone, 'UTC'];
}
})();

/**
* Optional birth time and birthplace. Picking a place fills its coordinates and time zone (from Amazon
* Location via GET /profile/places/{id}); without a place the member can still choose a time zone so a
* birth time is meaningful. Everything here only refines traditional compatibility, never age.
*/
export function BirthTimePlaceFields({ value, onChange, surface = 'onboarding' }: Props) {
// Birth details are personal data: telemetry records only that a choice was made, never what.
const element =
surface === 'profile'
? UiElement.PROFILE_EDIT_CHOICE_SELECT
: UiElement.ONBOARDING_CHOICE_SELECT;
const [query, setQuery] = useState(value.placeName ?? '');
const [results, setResults] = useState<{ placeId: string; label: string }[]>([]);
const [searching, setSearching] = useState(false);
const [error, setError] = useState<string | null>(null);
const seq = useRef(0);

useEffect(() => {
const q = query.trim();
if (q.length < 2 || q === value.placeName) {
setResults([]);
return;
}
const mine = ++seq.current;
const timer = setTimeout(async () => {
setSearching(true);
try {
const res = await sdkClient.searchPlaces({ q, lang: navigator.language?.slice(0, 2) });
if (mine === seq.current)
setResults((res.places ?? []).map((p) => ({ placeId: p.placeId!, label: p.label! })));
setError(null);
} catch {
if (mine === seq.current) setError('Place search is unavailable right now.');
} finally {
if (mine === seq.current) setSearching(false);
}
}, 300);
return () => clearTimeout(timer);
}, [query, value.placeName]);

const choose = async (placeId: string) => {
setResults([]);
try {
const p = await sdkClient.getPlace({ placeId });
setQuery(p.label!);
onChange({
...value,
placeName: p.label,
latitude: p.latitude,
longitude: p.longitude,
timeZone: p.timeZone ?? value.timeZone,
});
} catch {
setError('Could not load that place. Please pick it again.');
}
};

const clearPlace = () => {
setQuery('');
onChange({ ...value, placeName: undefined, latitude: undefined, longitude: undefined });
};

const zones = useMemo(() => allZones, []);
const zone = value.timeZone ?? (value.time ? browserZone : undefined);

return (
<div className="space-y-4 border border-border p-4">
<p className="text-xs opacity-70">
Optional. Your birth time and place only refine traditional compatibility (Kundali, natal
chart, Bazi and more). They are never shown to other members, and you can add or change them
at any time.
</p>

<div className="space-y-2">
<label
className="text-[10px] uppercase tracking-[0.3em] font-black opacity-50 flex items-center gap-2"
htmlFor="birth-time"
>
<Clock className="w-3 h-3" aria-hidden /> Birth time
</label>
<div className="flex gap-2 items-center">
<input
id="birth-time"
type="time"
value={value.time ?? ''}
onChange={(e) =>
onChange({
...value,
time: e.target.value || undefined,
timeZone: value.timeZone ?? browserZone,
})
}
className="field-input"
/>
{value.time && (
<button
type="button"
className="text-xs underline"
{...trackProps(element)}
onClick={() => onChange({ ...value, time: undefined })}
>
Clear
</button>
)}
</div>
</div>

<div className="space-y-2 relative">
<label
className="text-[10px] uppercase tracking-[0.3em] font-black opacity-50 flex items-center gap-2"
htmlFor="birth-place"
>
<MapPin className="w-3 h-3" aria-hidden /> Birthplace
</label>
<div className="flex gap-2 items-center">
<input
id="birth-place"
value={query}
placeholder="Start typing a city or town"
onChange={(e) => setQuery(e.target.value)}
className="field-input"
autoComplete="off"
role="combobox"
aria-expanded={results.length > 0}
aria-controls="birth-place-results"
/>
{value.placeName && (
<button
type="button"
aria-label="Clear birthplace"
{...trackProps(element)}
onClick={clearPlace}
>
<X className="w-4 h-4" />
</button>
)}
</div>
{searching && <p className="text-xs opacity-60">Searching…</p>}
{error && <p className="text-xs text-red-600">{error}</p>}
{results.length > 0 && (
<ul
id="birth-place-results"
role="listbox"
className="absolute z-20 left-0 right-0 bg-white border border-border shadow-md max-h-60 overflow-auto"
>
{results.map((r) => (
<li key={r.placeId}>
<button
type="button"
role="option"
aria-selected={false}
className="w-full text-left px-3 py-2 text-sm hover:bg-accent/10"
{...trackProps(element)}
onClick={() => choose(r.placeId)}
>
{r.label}
</button>
</li>
))}
</ul>
)}
{value.latitude !== undefined && value.longitude !== undefined && (
<p className="text-xs opacity-60">
{value.latitude.toFixed(2)}, {value.longitude.toFixed(2)}
</p>
)}
</div>

<div className="space-y-2">
<label
className="text-[10px] uppercase tracking-[0.3em] font-black opacity-50 block"
htmlFor="birth-zone"
>
Time zone at birth
</label>
<select
id="birth-zone"
value={zone ?? ''}
onChange={(e) => onChange({ ...value, timeZone: e.target.value || undefined })}
className="field-input"
>
<option value="">Not set</option>
{zones.map((z) => (
<option key={z} value={z}>
{z}
</option>
))}
</select>
<p className="text-xs opacity-60">
Filled in from the birthplace. Needed when you add a birth time.
</p>
</div>
</div>
);
}

/** The birthDetails payload: the (locked) date plus whatever optional fields are set. */
export function birthDetailsPayload(date: string, extra: BirthTimePlace) {
const timeZone = extra.timeZone ?? (extra.time ? browserZone : undefined);
return {
date,
...(extra.time ? { time: extra.time } : {}),
...(timeZone ? { timeZone } : {}),
...(extra.latitude !== undefined && extra.longitude !== undefined
? { latitude: extra.latitude, longitude: extra.longitude, placeName: extra.placeName }
: {}),
};
}

export function sameTimePlace(a: BirthTimePlace, b: BirthTimePlace): boolean {
return (
a.time === b.time &&
a.timeZone === b.timeZone &&
a.latitude === b.latitude &&
a.longitude === b.longitude
);
}
Loading
Loading