498 lines
19 KiB
TypeScript
498 lines
19 KiB
TypeScript
"use client";
|
|
|
|
import { Check, MapPin, RotateCcw } from "lucide-react";
|
|
import { InlineSpinner } from "@/components/inline-spinner";
|
|
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";
|
|
import { worldCountries, type WorldCountryData } from "@/data/world-countries";
|
|
|
|
/** Sentinel for "this city has districts but the user cannot name one". */
|
|
const cityCentreOption = "__city_centre__";
|
|
const cityCentreLabel = "不确定,用市区中心";
|
|
|
|
export type BirthPlaceSelection = {
|
|
countryCode: string;
|
|
provinceCode: string;
|
|
cityCode: string;
|
|
districtCode: string;
|
|
label: string;
|
|
placeType: "province" | "city" | "district";
|
|
provider: "china_locations" | "world_location_table";
|
|
providerPlaceId: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
timezoneId: string;
|
|
timezoneOffset: number | null;
|
|
};
|
|
|
|
export type SavedBirthPlace = {
|
|
countryCode?: string;
|
|
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" };
|
|
|
|
type TimezoneOutcome = { key: string; state: Extract<TimezoneState, { status: "ready" | "error" }> };
|
|
type BirthPlaceDraft = {
|
|
countryCode: string;
|
|
provinceCode: string;
|
|
cityCode: string;
|
|
districtCode: string;
|
|
cityCentre: boolean;
|
|
};
|
|
type ResolvedPickerPlace = {
|
|
countryCode: string;
|
|
provinceCode: string;
|
|
cityCode: string;
|
|
districtCode: string;
|
|
label: string;
|
|
placeType: "province" | "city" | "district";
|
|
provider: BirthPlaceSelection["provider"];
|
|
providerPlaceId: string;
|
|
latitude: number;
|
|
longitude: number;
|
|
};
|
|
|
|
function draftFromValue(value: SavedBirthPlace): BirthPlaceDraft {
|
|
const countryCode = value.countryCode?.trim().toUpperCase() || "CN";
|
|
if (countryCode === "CN") {
|
|
return {
|
|
countryCode,
|
|
...birthPlaceDraftFromLevels(value),
|
|
};
|
|
}
|
|
return {
|
|
countryCode,
|
|
provinceCode: value.provinceCode,
|
|
cityCode: value.cityCode,
|
|
districtCode: value.districtCode,
|
|
cityCentre: false,
|
|
};
|
|
}
|
|
|
|
function countryLabel(countryCode: string) {
|
|
return worldCountries.find((item) => item.code === countryCode)?.name ?? countryCode;
|
|
}
|
|
|
|
export function BirthPlacePicker({
|
|
value,
|
|
birthDate = "",
|
|
birthTime = "",
|
|
disabled = false,
|
|
onChange,
|
|
}: BirthPlacePickerProps) {
|
|
const countryCode = value.countryCode?.trim().toUpperCase() || "CN";
|
|
const fieldPrefix = useId();
|
|
const onChangeRef = useRef(onChange);
|
|
const savedRef = useRef(value);
|
|
useEffect(() => {
|
|
onChangeRef.current = onChange;
|
|
savedRef.current = value;
|
|
});
|
|
|
|
const externalKey = `${countryCode}/${value.provinceCode}/${value.cityCode}/${value.districtCode}`;
|
|
const [draft, setDraft] = useState<BirthPlaceDraft>(() => draftFromValue(value));
|
|
const [syncedKey, setSyncedKey] = useState(externalKey);
|
|
const [globalData, setGlobalData] = useState<WorldCountryData | null>(null);
|
|
const [globalLoadErrorCountry, setGlobalLoadErrorCountry] = useState("");
|
|
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);
|
|
setDraft(draftFromValue(value));
|
|
}
|
|
|
|
const isChina = draft.countryCode === "CN";
|
|
const province = isChina ? findProvinceNode(draft.provinceCode) : undefined;
|
|
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);
|
|
const chinaComplete = Boolean(province && city) && (districts.length === 0 || districtChosen || draft.cityCentre);
|
|
const chinaResolved = isChina && chinaComplete
|
|
? resolveBirthPlaceNode({
|
|
provinceCode: draft.provinceCode,
|
|
cityCode: city?.code ?? "",
|
|
districtCode: districtChosen ? draft.districtCode : "",
|
|
})
|
|
: null;
|
|
|
|
useEffect(() => {
|
|
if (draft.countryCode === "CN") return;
|
|
const controller = new AbortController();
|
|
fetch(`/data/world-locations/${draft.countryCode.toLowerCase()}.json`, {
|
|
signal: controller.signal,
|
|
cache: "force-cache",
|
|
})
|
|
.then((response) => {
|
|
if (!response.ok) throw new Error("location_data_unavailable");
|
|
return response.json() as Promise<WorldCountryData>;
|
|
})
|
|
.then((data) => {
|
|
if (data.code !== draft.countryCode) throw new Error("location_data_invalid");
|
|
setGlobalData(data);
|
|
setGlobalLoadErrorCountry("");
|
|
})
|
|
.catch(() => {
|
|
if (controller.signal.aborted) return;
|
|
setGlobalData(null);
|
|
setGlobalLoadErrorCountry(draft.countryCode);
|
|
});
|
|
return () => controller.abort();
|
|
}, [draft.countryCode]);
|
|
|
|
const globalRegion = !isChina && globalData?.code === draft.countryCode
|
|
? globalData.regions.find((item) => item.code === draft.provinceCode)
|
|
: undefined;
|
|
const globalCity = globalRegion?.cities.find((item) => item.code === draft.cityCode);
|
|
const globalComplete = Boolean(globalRegion)
|
|
&& (globalRegion?.cities.length === 0 || Boolean(globalCity));
|
|
const globalResolved: ResolvedPickerPlace | null = !isChina && globalComplete && globalRegion
|
|
? {
|
|
countryCode: draft.countryCode,
|
|
provinceCode: globalRegion.code,
|
|
cityCode: globalCity?.code ?? "",
|
|
districtCode: "",
|
|
label: [countryLabel(draft.countryCode), globalRegion.name, globalCity?.name]
|
|
.filter((item, index, items): item is string => Boolean(item) && items.indexOf(item) === index)
|
|
.join(" · "),
|
|
placeType: globalCity ? "city" : "province",
|
|
provider: "world_location_table",
|
|
providerPlaceId: `world:${draft.countryCode}:${globalRegion.code}:${globalCity?.code ?? ""}`,
|
|
latitude: globalCity?.latitude ?? globalRegion.latitude,
|
|
longitude: globalCity?.longitude ?? globalRegion.longitude,
|
|
}
|
|
: null;
|
|
|
|
const resolved: ResolvedPickerPlace | null = chinaResolved
|
|
? {
|
|
countryCode: "CN",
|
|
provinceCode: chinaResolved.province.code,
|
|
cityCode: chinaResolved.city.code,
|
|
districtCode: chinaResolved.district?.code ?? "",
|
|
label: chinaResolved.label,
|
|
placeType: chinaResolved.placeType,
|
|
provider: "china_locations",
|
|
providerPlaceId: chinaResolved.node.code,
|
|
latitude: chinaResolved.latitude,
|
|
longitude: chinaResolved.longitude,
|
|
}
|
|
: globalResolved;
|
|
|
|
// The offset the profile already carries is only authoritative while the birth moment is unchanged.
|
|
const birthMoment = `${birthDate}/${birthTime}`;
|
|
const [mountedMoment] = useState(birthMoment);
|
|
const savedIsCurrent = resolved !== null
|
|
&& mountedMoment === birthMoment
|
|
&& value.timezoneId.trim() !== ""
|
|
&& countryCode === resolved.countryCode
|
|
&& value.provinceCode === resolved.provinceCode
|
|
&& value.cityCode === resolved.cityCode
|
|
&& value.districtCode === resolved.districtCode;
|
|
|
|
const syncKey = savedIsCurrent
|
|
? "saved"
|
|
: resolved
|
|
? `${resolved.providerPlaceId}/${birthMoment}/${retryToken}`
|
|
: "empty";
|
|
|
|
useEffect(() => {
|
|
if (syncKey === "saved") return;
|
|
if (syncKey === "empty") {
|
|
const saved = savedRef.current;
|
|
if (saved.countryCode !== "CN" || 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({
|
|
countryCode: target.countryCode,
|
|
provinceCode: target.provinceCode,
|
|
cityCode: target.cityCode,
|
|
districtCode: target.districtCode,
|
|
label: target.label,
|
|
placeType: target.placeType,
|
|
provider: target.provider,
|
|
providerPlaceId: target.providerPlaceId,
|
|
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 chooseCountry(nextCode: string) {
|
|
setDraft(nextCode === "CN"
|
|
? { countryCode: "CN", ...birthPlaceDraftFromLevels({ provinceCode: "", cityCode: "", districtCode: "" }) }
|
|
: { countryCode: nextCode, provinceCode: "", cityCode: "", districtCode: "", cityCentre: false });
|
|
}
|
|
|
|
function chooseProvince(nextCode: string) {
|
|
const nextProvince = findProvinceNode(nextCode);
|
|
setDraft({
|
|
countryCode: "CN",
|
|
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,
|
|
}));
|
|
}
|
|
|
|
function chooseGlobalRegion(nextCode: string) {
|
|
setDraft((current) => ({ ...current, provinceCode: nextCode, cityCode: "", districtCode: "", cityCentre: false }));
|
|
}
|
|
|
|
// 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 globalIsLoading = !isChina
|
|
&& globalData?.code !== draft.countryCode
|
|
&& globalLoadErrorCountry !== draft.countryCode;
|
|
const globalHasError = !isChina && globalLoadErrorCountry === draft.countryCode;
|
|
const statusText = settled.status === "loading"
|
|
? "正在确认该地点的时区…"
|
|
: settled.status === "ready" && resolved
|
|
? `${resolved.label} · ${formatBirthPlaceCoordinate(resolved.latitude, resolved.longitude)} · ${formatBirthPlaceTimezone(settled.timezoneId, settled.timezoneOffset)}`
|
|
: settled.status === "error"
|
|
? "时区服务暂时不可用,稍后重试即可。"
|
|
: globalIsLoading
|
|
? "正在加载该国家的地区列表…"
|
|
: globalHasError
|
|
? "该国家的地区列表暂时不可用,请稍后重试。"
|
|
: isChina
|
|
? "按省、市、区县逐级选择即可。"
|
|
: "海外地点按国家、地区、城市逐级选择,不使用搜索。";
|
|
|
|
return (
|
|
<div className="birth-place-picker">
|
|
<div className="birth-place-levels">
|
|
<label htmlFor={`${fieldPrefix}-country`}>
|
|
<span>国家 / 地区</span>
|
|
<Select
|
|
value={draft.countryCode || null}
|
|
disabled={disabled}
|
|
onValueChange={(next) => { if (typeof next === "string") chooseCountry(next); }}
|
|
>
|
|
<SelectTrigger id={`${fieldPrefix}-country`} aria-label="出生国家或地区">
|
|
<SelectValue placeholder="请选择">
|
|
{(selected) => selected === "CN" ? "中国" : countryLabel(String(selected ?? "")) || "请选择"}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="CN">中国</SelectItem>
|
|
{worldCountries.filter((item) => item.code !== "CN").map((item) => (
|
|
<SelectItem key={item.code} value={item.code}>{item.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</label>
|
|
|
|
{isChina && (
|
|
<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>
|
|
)}
|
|
|
|
{isChina && 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>
|
|
)}
|
|
|
|
{isChina && 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>
|
|
)}
|
|
|
|
{!isChina && globalData && (
|
|
<label htmlFor={`${fieldPrefix}-region`}>
|
|
<span>州 / 省 / 地区</span>
|
|
<Select
|
|
value={draft.provinceCode || null}
|
|
disabled={disabled}
|
|
onValueChange={(next) => { if (typeof next === "string") chooseGlobalRegion(next); }}
|
|
>
|
|
<SelectTrigger id={`${fieldPrefix}-region`} aria-label="出生州省或地区">
|
|
<SelectValue placeholder="请选择">
|
|
{(selected) => globalData.regions.find((item) => item.code === String(selected ?? ""))?.name ?? "请选择"}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{globalData.regions.map((item) => (
|
|
<SelectItem key={item.code} value={item.code}>{item.name}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</label>
|
|
)}
|
|
|
|
{!isChina && globalRegion && globalRegion.cities.length > 0 && (
|
|
<label htmlFor={`${fieldPrefix}-global-city`}>
|
|
<span>城市</span>
|
|
<Select
|
|
value={draft.cityCode || null}
|
|
disabled={disabled}
|
|
onValueChange={(next) => { if (typeof next === "string") chooseCity(next); }}
|
|
>
|
|
<SelectTrigger id={`${fieldPrefix}-global-city`} aria-label="出生海外城市">
|
|
<SelectValue placeholder="请选择">
|
|
{((selected) => globalRegion.cities.find((item) => item.code === String(selected ?? ""))?.name ?? "请选择")}
|
|
</SelectValue>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{globalRegion.cities.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" && <InlineSpinner size={14} />}
|
|
{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>
|
|
);
|
|
}
|