feat(onboarding): pick the birth place by level instead of by search
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

The single search field asked people to type a place name and then judge
which of several near-identical results was theirs, which is the one thing
they cannot verify about their own birth record. Province, city and district
are now chosen from the dataset the app already ships, so there is nothing to
type and nothing to disambiguate.

Levels that offer no choice collapse: municipalities show one level, and
prefecture cities without districts stop at the city. A district can be left
as the city centre, which is accurate enough because the chart only needs
coordinates and a timezone.

The timezone is resolved once for the chosen place rather than on every
keystroke, through a dedicated route that both this picker and the Geoapify
path share.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-17 18:35:42 +08:00
parent e1db576284
commit 9dc2948ab6
13 changed files with 744 additions and 460 deletions
@@ -0,0 +1,303 @@
"use client";
import { Check, LoaderCircle, MapPin, RotateCcw } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {
birthPlaceDraftFromLevels,
chinaProvinces,
findCityNode,
findProvinceNode,
formatBirthPlaceCoordinate,
formatBirthPlaceTimezone,
hasVirtualCityLevel,
resolveBirthPlaceNode,
} from "@/lib/china-birth-place";
/** Sentinel for "this city has districts but the user cannot name one". */
const cityCentreOption = "__city_centre__";
const cityCentreLabel = "不确定,用市区中心";
export type BirthPlaceSelection = {
provinceCode: string;
cityCode: string;
districtCode: string;
label: string;
placeType: "province" | "city" | "district";
providerPlaceId: string;
latitude: number;
longitude: number;
timezoneId: string;
timezoneOffset: number | null;
};
export type SavedBirthPlace = {
provinceCode: string;
cityCode: string;
districtCode: string;
timezoneId: string;
timezoneOffset: number | null;
};
type BirthPlacePickerProps = {
value: SavedBirthPlace;
birthDate?: string;
birthTime?: string;
disabled?: boolean;
onChange: (selection: BirthPlaceSelection | null) => void;
};
type TimezoneState =
| { status: "idle" }
| { status: "loading" }
| { status: "ready"; timezoneId: string; timezoneOffset: number | null }
| { status: "error" };
/** The outcome of one lookup, tagged with the place it was requested for. */
type TimezoneOutcome = { key: string; state: Extract<TimezoneState, { status: "ready" | "error" }> };
export function BirthPlacePicker({
value,
birthDate = "",
birthTime = "",
disabled = false,
onChange,
}: BirthPlacePickerProps) {
const { provinceCode, cityCode, districtCode } = value;
const fieldPrefix = useId();
// Read by the lookup effect so that a re-rendered parent does not restart it.
const onChangeRef = useRef(onChange);
const savedRef = useRef(value);
useEffect(() => {
onChangeRef.current = onChange;
savedRef.current = value;
});
const externalKey = `${provinceCode}/${cityCode}/${districtCode}`;
const [draft, setDraft] = useState(() => birthPlaceDraftFromLevels({ provinceCode, cityCode, districtCode }));
const [syncedKey, setSyncedKey] = useState(externalKey);
const [outcome, setOutcome] = useState<TimezoneOutcome | null>(null);
const [retryToken, setRetryToken] = useState(0);
// Adopt a profile that was loaded or replaced outside this picker.
if (syncedKey !== externalKey) {
setSyncedKey(externalKey);
if (provinceCode) setDraft(birthPlaceDraftFromLevels({ provinceCode, cityCode, districtCode }));
}
const province = findProvinceNode(draft.provinceCode);
const virtualCity = Boolean(province && hasVirtualCityLevel(province));
const city = province && virtualCity ? province.cities[0] : findCityNode(province, draft.cityCode);
const districts = city?.districts ?? [];
const districtChosen = Boolean(draft.districtCode) && districts.some((item) => item.code === draft.districtCode);
// Levels that offer no choice are skipped, so completeness stops at the deepest real level.
const complete = Boolean(city) && (districts.length === 0 || districtChosen || draft.cityCentre);
const resolved = complete
? resolveBirthPlaceNode({
provinceCode: draft.provinceCode,
cityCode: city?.code ?? "",
districtCode: districtChosen ? draft.districtCode : "",
})
: null;
// The offset the profile already carries is only authoritative while the birth
// moment it was computed against is unchanged.
const birthMoment = `${birthDate}/${birthTime}`;
const [mountedMoment] = useState(birthMoment);
const savedIsCurrent = resolved !== null
&& mountedMoment === birthMoment
&& value.timezoneId.trim() !== ""
&& provinceCode === resolved.province.code
&& cityCode === resolved.city.code
&& districtCode === (resolved.district?.code ?? "");
const syncKey = savedIsCurrent
? "saved"
: resolved
? `${resolved.node.code}/${birthMoment}/${retryToken}`
: "empty";
useEffect(() => {
if (syncKey === "saved") return;
if (syncKey === "empty") {
const saved = savedRef.current;
if (saved.provinceCode || saved.timezoneId) onChangeRef.current(null);
return;
}
const target = resolved as NonNullable<typeof resolved>;
const controller = new AbortController();
(async () => {
try {
const response = await fetch("/api/locations/timezone", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
signal: controller.signal,
body: JSON.stringify({
latitude: target.latitude,
longitude: target.longitude,
...(birthDate ? { birthDate } : {}),
...(birthDate && birthTime ? { birthTime } : {}),
}),
});
if (!response.ok) throw new Error("timezone_unavailable");
const payload = await response.json() as { timezoneId?: unknown; timezoneOffset?: unknown };
const timezoneId = typeof payload.timezoneId === "string" ? payload.timezoneId.trim() : "";
if (!timezoneId) throw new Error("timezone_unavailable");
const timezoneOffset = typeof payload.timezoneOffset === "number" ? payload.timezoneOffset : null;
setOutcome({ key: syncKey, state: { status: "ready", timezoneId, timezoneOffset } });
onChangeRef.current({
provinceCode: target.province.code,
cityCode: target.city.code,
districtCode: target.district?.code ?? "",
label: target.label,
placeType: target.placeType,
providerPlaceId: target.node.code,
latitude: target.latitude,
longitude: target.longitude,
timezoneId,
timezoneOffset,
});
} catch {
if (controller.signal.aborted) return;
setOutcome({ key: syncKey, state: { status: "error" } });
onChangeRef.current(null);
}
})();
return () => controller.abort();
// syncKey collapses the node plus the birth moment the historical offset depends on.
}, [syncKey]); // eslint-disable-line react-hooks/exhaustive-deps
function chooseProvince(nextCode: string) {
const nextProvince = findProvinceNode(nextCode);
setDraft({
provinceCode: nextCode,
cityCode: nextProvince && hasVirtualCityLevel(nextProvince) ? nextProvince.cities[0].code : "",
districtCode: "",
cityCentre: false,
});
}
function chooseCity(nextCode: string) {
setDraft((current) => ({ ...current, cityCode: nextCode, districtCode: "", cityCentre: false }));
}
function chooseDistrict(nextCode: string) {
setDraft((current) => ({
...current,
districtCode: nextCode === cityCentreOption ? "" : nextCode,
cityCentre: nextCode === cityCentreOption,
}));
}
// A place with no lookup recorded against it is still being looked up.
const settled: TimezoneState = syncKey === "empty"
? { status: "idle" }
: syncKey === "saved"
? { status: "ready", timezoneId: value.timezoneId, timezoneOffset: value.timezoneOffset }
: outcome?.key === syncKey
? outcome.state
: { status: "loading" };
const statusText = settled.status === "loading"
? "正在确认该地点的时区…"
: settled.status === "ready" && resolved
? `${resolved.label} · ${formatBirthPlaceCoordinate(resolved.latitude, resolved.longitude)} · ${formatBirthPlaceTimezone(settled.timezoneId, settled.timezoneOffset)}`
: settled.status === "error"
? "时区服务暂时不可用,稍后重试即可。"
: "按省、市、区县逐级选择即可。";
return (
<div className="birth-place-picker">
<div className="birth-place-levels">
<label htmlFor={`${fieldPrefix}-province`}>
<span> / </span>
<Select
value={draft.provinceCode || null}
disabled={disabled}
onValueChange={(next) => { if (typeof next === "string") chooseProvince(next); }}
>
<SelectTrigger id={`${fieldPrefix}-province`} aria-label="出生省份">
<SelectValue placeholder="请选择">
{(selected) => findProvinceNode(String(selected ?? ""))?.name ?? "请选择"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{chinaProvinces.map((item) => (
<SelectItem key={item.code} value={item.code}>{item.name}</SelectItem>
))}
</SelectContent>
</Select>
</label>
{province && !virtualCity && (
<label htmlFor={`${fieldPrefix}-city`}>
<span> / </span>
<Select
value={draft.cityCode || null}
disabled={disabled}
onValueChange={(next) => { if (typeof next === "string") chooseCity(next); }}
>
<SelectTrigger id={`${fieldPrefix}-city`} aria-label="出生城市">
<SelectValue placeholder="请选择">
{(selected) => findCityNode(province, String(selected ?? ""))?.name ?? "请选择"}
</SelectValue>
</SelectTrigger>
<SelectContent>
{province.cities.map((item) => (
<SelectItem key={item.code} value={item.code}>{item.name}</SelectItem>
))}
</SelectContent>
</Select>
</label>
)}
{city && districts.length > 0 && (
<label htmlFor={`${fieldPrefix}-district`}>
<span> / </span>
<Select
value={draft.cityCentre ? cityCentreOption : draft.districtCode || null}
disabled={disabled}
onValueChange={(next) => { if (typeof next === "string") chooseDistrict(next); }}
>
<SelectTrigger id={`${fieldPrefix}-district`} aria-label="出生区县">
<SelectValue placeholder="请选择">
{(selected) => selected === cityCentreOption
? cityCentreLabel
: districts.find((item) => item.code === String(selected ?? ""))?.name ?? "请选择"}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value={cityCentreOption}>{cityCentreLabel}</SelectItem>
{districts.map((item) => (
<SelectItem key={item.code} value={item.code}>{item.name}</SelectItem>
))}
</SelectContent>
</Select>
</label>
)}
</div>
<p className="birth-place-note"> / </p>
<p
className={`birth-place-status${settled.status === "error" ? " is-error" : ""}${settled.status === "ready" ? " is-ready" : ""}`}
role="status"
aria-live="polite"
>
{settled.status === "loading" && <LoaderCircle className="is-spinning" aria-hidden="true" />}
{settled.status === "ready" && <Check aria-hidden="true" />}
{(settled.status === "idle" || settled.status === "error") && <MapPin aria-hidden="true" />}
<span>{statusText}</span>
{settled.status === "error" && (
<button type="button" className="birth-place-retry" onClick={() => setRetryToken((token) => token + 1)}>
<RotateCcw aria-hidden="true" />
</button>
)}
</p>
</div>
);
}
@@ -1,281 +0,0 @@
"use client";
import { Check, LoaderCircle, MapPin, Search, X } from "lucide-react";
import { useEffect, useId, useRef, useState } from "react";
import type { KeyboardEvent } from "react";
export type ResolvedBirthLocation = {
id: string;
label: string;
placeType: string;
provider: string;
providerPlaceId: string;
countryCode: string;
countryName: string;
admin1: string;
admin2: string;
locality: string;
latitude: number;
longitude: number;
timezoneId: string;
timezoneOffset: number | null;
timezoneSource: string;
};
type LocationSearchComboboxProps = {
value: ResolvedBirthLocation | null;
birthDate?: string;
birthTime?: string;
disabled?: boolean;
onChange: (location: ResolvedBirthLocation | null) => void;
};
type SearchState = "idle" | "loading" | "ready" | "empty" | "error";
function cleanString(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
export function parseLocationSearchResults(payload: unknown): ResolvedBirthLocation[] {
const rawResults = Array.isArray(payload)
? payload
: payload && typeof payload === "object" && Array.isArray((payload as { results?: unknown }).results)
? (payload as { results: unknown[] }).results
: payload && typeof payload === "object" && Array.isArray((payload as { locations?: unknown }).locations)
? (payload as { locations: unknown[] }).locations
: [];
return rawResults.flatMap((candidate): ResolvedBirthLocation[] => {
if (!candidate || typeof candidate !== "object") return [];
const item = candidate as Record<string, unknown>;
const latitude = typeof item.latitude === "number" ? item.latitude : Number.NaN;
const longitude = typeof item.longitude === "number" ? item.longitude : Number.NaN;
const timezoneOffset = item.timezoneOffset === null
? null
: typeof item.timezoneOffset === "number"
? item.timezoneOffset
: Number.NaN;
const provider = cleanString(item.provider);
const providerPlaceId = cleanString(item.providerPlaceId);
const id = cleanString(item.id) || (provider && providerPlaceId ? `${provider}:${providerPlaceId}` : "");
const label = cleanString(item.label);
const countryCode = cleanString(item.countryCode).toUpperCase();
const timezoneId = cleanString(item.timezoneId);
if (!id || !label || !countryCode || !timezoneId
|| !Number.isFinite(latitude) || latitude < -90 || latitude > 90
|| !Number.isFinite(longitude) || longitude < -180 || longitude > 180
|| (timezoneOffset !== null
&& (!Number.isFinite(timezoneOffset) || timezoneOffset < -12 || timezoneOffset > 14))) return [];
return [{
id,
label,
placeType: cleanString(item.placeType),
provider,
providerPlaceId,
countryCode,
countryName: cleanString(item.countryName),
admin1: cleanString(item.admin1) || cleanString(item.regionName),
admin2: cleanString(item.admin2) || cleanString(item.districtName),
locality: cleanString(item.locality) || cleanString(item.localityName),
latitude,
longitude,
timezoneId,
timezoneOffset,
timezoneSource: cleanString(item.timezoneSource),
}];
});
}
export function LocationSearchCombobox({
value,
birthDate = "",
birthTime = "",
disabled = false,
onChange,
}: LocationSearchComboboxProps) {
const inputId = useId();
const listboxId = `${inputId}-listbox`;
const statusId = `${inputId}-status`;
const requestSequence = useRef(0);
const [query, setQuery] = useState(value?.label ?? "");
const [results, setResults] = useState<ResolvedBirthLocation[]>([]);
const [state, setState] = useState<SearchState>("idle");
const [expanded, setExpanded] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
useEffect(() => {
const normalized = query.trim();
if (disabled || value || normalized.length < 2) return;
const sequence = requestSequence.current + 1;
requestSequence.current = sequence;
const controller = new AbortController();
const timer = window.setTimeout(async () => {
setState("loading");
setExpanded(true);
try {
const search = new URLSearchParams({ q: normalized, locale: "zh-CN" });
if (birthDate) search.set("birthDate", birthDate);
if (birthDate && birthTime) search.set("birthTime", birthTime);
const response = await fetch(`/api/locations/search?${search.toString()}`, {
credentials: "same-origin",
signal: controller.signal,
});
if (!response.ok) throw new Error("location_search_failed");
const nextResults = parseLocationSearchResults(await response.json());
if (requestSequence.current !== sequence) return;
setResults(nextResults);
setState(nextResults.length > 0 ? "ready" : "empty");
setActiveIndex(nextResults.length > 0 ? 0 : -1);
} catch {
if (controller.signal.aborted || requestSequence.current !== sequence) return;
setResults([]);
setState("error");
setActiveIndex(-1);
}
}, 260);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [birthDate, birthTime, disabled, query, value]);
function choose(location: ResolvedBirthLocation) {
requestSequence.current += 1;
setQuery(location.label);
setExpanded(false);
setResults([]);
setState("idle");
setActiveIndex(-1);
onChange(location);
}
function clear() {
requestSequence.current += 1;
setQuery("");
setExpanded(false);
setResults([]);
setState("idle");
setActiveIndex(-1);
onChange(null);
}
function handleKeyDown(event: KeyboardEvent<HTMLInputElement>) {
if (value && (event.key === "Backspace" || event.key === "Delete")) {
event.preventDefault();
clear();
return;
}
if (!expanded || results.length === 0) {
if (event.key === "ArrowDown" && results.length > 0) setExpanded(true);
return;
}
if (event.key === "ArrowDown") {
event.preventDefault();
setActiveIndex((current) => (current + 1) % results.length);
} else if (event.key === "ArrowUp") {
event.preventDefault();
setActiveIndex((current) => (current <= 0 ? results.length - 1 : current - 1));
} else if (event.key === "Enter" && activeIndex >= 0) {
event.preventDefault();
choose(results[activeIndex]);
} else if (event.key === "Escape") {
event.preventDefault();
setExpanded(false);
setActiveIndex(-1);
}
}
const isExpanded = expanded && !disabled && !value && query.trim().length >= 2;
const activeDescendant = isExpanded && activeIndex >= 0
? `${listboxId}-option-${activeIndex}`
: undefined;
const statusText = state === "loading"
? "正在搜索地点"
: state === "empty"
? "没有找到匹配地点,请尝试城市、区县或英文地名"
: state === "error"
? "地点搜索暂时不可用,请稍后重试"
: state === "ready"
? `找到 ${results.length} 个地点`
: value
? `已选择 ${value.label}`
: "输入至少两个字开始搜索";
return (
<div className={`location-combobox${value ? " is-selected" : ""}`}>
<label htmlFor={inputId}></label>
<div className="location-combobox-input-wrap">
{state === "loading" ? <LoaderCircle className="location-combobox-leading is-spinning" aria-hidden="true" /> : <Search className="location-combobox-leading" aria-hidden="true" />}
<input
id={inputId}
role="combobox"
aria-autocomplete="list"
aria-expanded={isExpanded}
aria-controls={listboxId}
aria-activedescendant={activeDescendant}
aria-describedby={statusId}
autoComplete="off"
disabled={disabled}
placeholder="输入城市、区县或地标,例如:上海、Taipei"
value={value?.label ?? query}
onChange={(event) => {
if (value) onChange(null);
const nextQuery = event.target.value;
setQuery(nextQuery);
if (nextQuery.trim().length < 2) {
requestSequence.current += 1;
setExpanded(false);
setResults([]);
setState("idle");
setActiveIndex(-1);
}
}}
onFocus={() => {
if (!value && (results.length > 0 || state === "loading" || state === "empty" || state === "error")) setExpanded(true);
}}
onKeyDown={handleKeyDown}
/>
{(query || value) && (
<button className="location-combobox-clear" type="button" onClick={clear} disabled={disabled} aria-label="清除出生地点">
<X aria-hidden="true" />
</button>
)}
</div>
<p id={statusId} className={`location-combobox-status${state === "error" ? " is-error" : ""}`} role="status" aria-live="polite">
{value ? <Check aria-hidden="true" /> : <MapPin aria-hidden="true" />}
<span>{statusText}</span>
</p>
{isExpanded && (
<ul id={listboxId} className="location-combobox-results" role="listbox" aria-label="地点搜索结果">
{state === "loading" && <li className="location-combobox-feedback"></li>}
{state === "empty" && <li className="location-combobox-feedback"></li>}
{state === "error" && <li className="location-combobox-feedback is-error"></li>}
{state === "ready" && results.map((location, index) => (
<li
id={`${listboxId}-option-${index}`}
key={location.id}
role="option"
aria-selected={index === activeIndex}
className={index === activeIndex ? "is-active" : undefined}
>
<button
type="button"
onMouseDown={(event) => event.preventDefault()}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => choose(location)}
>
<MapPin aria-hidden="true" />
<span><b>{location.label}</b><small>{[location.countryName, location.timezoneId].filter(Boolean).join(" · ")}</small></span>
</button>
</li>
))}
</ul>
)}
</div>
);
}