import { useEffect, useRef, useState, type FormEvent } from "react"; import { Link } from "react-router-dom"; import { ApiError, api, newIdempotencyKey } from "../api"; import { FeedbackBanner, LoadingState } from "../components/Feedback"; import { usePollingResource } from "../hooks/usePollingResource"; import type { CreateTicketPayload, CreateTicketResponse } from "../types"; type VisitorTicketFormState = Omit & { party_size: number | "" }; const initialTicket: VisitorTicketFormState = { phone: "", last_name: "", honorific: "", party_size: 1 }; interface WriteIntent { fingerprint: string; idempotencyKey: string; } function isPhoneInputValid(value: string): boolean { const digits = value.replace(/\D/g, ""); return /^[+\d\s\-()]+$/.test(value) && digits.length >= 7 && digits.length <= 15; } function keyForIntent(ref: { current: WriteIntent | null }, fingerprint: string): string { if (!ref.current || ref.current.fingerprint !== fingerprint) { ref.current = { fingerprint, idempotencyKey: newIdempotencyKey() }; } return ref.current.idempotencyKey; } function releaseIntentAfterDefinitiveError(ref: { current: WriteIntent | null }, error: unknown) { if (error instanceof ApiError && error.status >= 400 && error.status < 500 && error.status !== 429) ref.current = null; } function ticketStatusPath(response: CreateTicketResponse): string | null { if (response.public_url) return response.public_url; if (response.public_token) return `/visitor/${response.public_token}`; return null; } export function VisitorTakeTicketForm() { const projectsResource = usePollingResource((signal) => api.publicProjects(signal), { intervalMs: 60_000, resourceKey: "visitor-public-projects", }); const projects = projectsResource.data?.projects ?? []; const [selectedProjectId, setSelectedProjectId] = useState(""); const [ticketForm, setTicketForm] = useState(initialTicket); const [phoneError, setPhoneError] = useState(null); const [formError, setFormError] = useState(null); const [confirmDuplicatePhone, setConfirmDuplicatePhone] = useState(false); const [createdTicket, setCreatedTicket] = useState(null); const [busy, setBusy] = useState(false); const [retryAfterSeconds, setRetryAfterSeconds] = useState(0); const createIntentRef = useRef(null); useEffect(() => { if (retryAfterSeconds <= 0) return; const timer = window.setTimeout(() => { setRetryAfterSeconds((current) => Math.max(0, current - 1)); }, 1000); return () => window.clearTimeout(timer); }, [retryAfterSeconds]); useEffect(() => { if (!projects.length) return; if (!projects.some((project) => project.id === selectedProjectId)) { setSelectedProjectId(projects[0].id); } }, [projects, selectedProjectId]); const selectedProject = projects.find((project) => project.id === selectedProjectId); const minPartySize = selectedProject?.min_party_size ?? 1; const maxPartySize = selectedProject?.max_party_size ?? minPartySize; useEffect(() => { if (!selectedProject) return; setTicketForm((current) => ({ ...current, party_size: typeof current.party_size === "number" && current.party_size >= minPartySize && current.party_size <= maxPartySize ? current.party_size : minPartySize, })); }, [selectedProject?.id]); function updateTicketForm(patch: Partial) { setTicketForm((current) => ({ ...current, ...patch })); setConfirmDuplicatePhone(false); setPhoneError(null); setFormError(null); createIntentRef.current = null; } function resetForAnotherTicket() { setCreatedTicket(null); setTicketForm({ ...initialTicket, party_size: minPartySize }); setConfirmDuplicatePhone(false); setPhoneError(null); setFormError(null); createIntentRef.current = null; } async function submitTicket(event: FormEvent) { event.preventDefault(); if (retryAfterSeconds > 0) return; if (!selectedProjectId) { setFormError("请选择要排队的项目"); return; } const partySize = ticketForm.party_size; if (typeof partySize !== "number" || !Number.isInteger(partySize) || partySize < minPartySize || partySize > maxPartySize) { setFormError(`本项目每个号码可绑定 ${minPartySize} 到 ${maxPartySize} 人`); return; } const phone = ticketForm.phone.trim(); if (!isPhoneInputValid(phone)) { setPhoneError("请输入有效的手机号(7-15 位数字)"); setFormError(null); return; } setPhoneError(null); setFormError(null); setBusy(true); const payload: CreateTicketPayload = { ...ticketForm, phone, party_size: partySize, allow_duplicate: confirmDuplicatePhone }; const fingerprint = JSON.stringify([selectedProjectId, payload]); const idempotencyKey = keyForIntent(createIntentRef, fingerprint); try { const response = await api.publicCreateTicket(selectedProjectId, payload, idempotencyKey); createIntentRef.current = null; setCreatedTicket(response); setTicketForm({ ...initialTicket, party_size: minPartySize }); setConfirmDuplicatePhone(false); setRetryAfterSeconds(0); } catch (caught) { if (caught instanceof ApiError && caught.code === "DUPLICATE_PHONE") { createIntentRef.current = null; setConfirmDuplicatePhone(true); setFormError(null); setRetryAfterSeconds(0); } else if (caught instanceof ApiError && caught.status === 429) { setConfirmDuplicatePhone(false); setFormError(null); setRetryAfterSeconds(caught.retryAfterSeconds ?? 60); } else { releaseIntentAfterDefinitiveError(createIntentRef, caught); setConfirmDuplicatePhone(false); setRetryAfterSeconds(0); setFormError(caught instanceof ApiError ? caught.message : "取号未提交,请检查当前数据后重试。"); } } finally { setBusy(false); } } const statusPath = createdTicket ? ticketStatusPath(createdTicket) : null; return (
{projectsResource.loading && !projects.length ? : null} {projectsResource.error && !projects.length ? ( 重试} > {projectsResource.error.message} ) : null} {!projectsResource.loading && !projectsResource.error && !projects.length ? ( 请稍后再试,或咨询现场工作人员。 ) : null} {createdTicket ? (
取号成功

您的排队号码

{createdTicket.ticket.ticket_number} {selectedProject?.name ?? "当前项目"} · {createdTicket.ticket.party_size} 人 · 手机号尾号 {createdTicket.ticket.phone_last4 ?? "未返回"} {statusPath ? 查看排队状态 : null}
) : (
{selectedProject?.visitor_notice ?

{selectedProject.visitor_notice}

: null}
称谓(选填)
{[ { value: "", label: "不填写" }, { value: "先生", label: "先生" }, { value: "女士", label: "女士" }, ].map((option) => ( ))}
{confirmDuplicatePhone ? 如需继续取号,请再次点击确认按钮。 : null} {retryAfterSeconds > 0 ? : null} {formError ? : null} )}
); }