Files
XQKqueue/web/src/pages/VisitorTakeTicketPage.tsx
2026-07-16 11:40:34 +08:00

260 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<CreateTicketPayload, "party_size"> & { 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) 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<VisitorTicketFormState>(initialTicket);
const [phoneError, setPhoneError] = useState<string | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [confirmDuplicatePhone, setConfirmDuplicatePhone] = useState(false);
const [createdTicket, setCreatedTicket] = useState<CreateTicketResponse | null>(null);
const [busy, setBusy] = useState(false);
const createIntentRef = useRef<WriteIntent | null>(null);
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<VisitorTicketFormState>) {
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<HTMLFormElement>) {
event.preventDefault();
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);
} catch (caught) {
if (caught instanceof ApiError && caught.code === "DUPLICATE_PHONE") {
createIntentRef.current = null;
setConfirmDuplicatePhone(true);
setFormError(null);
} else {
releaseIntentAfterDefinitiveError(createIntentRef, caught);
setConfirmDuplicatePhone(false);
setFormError(caught instanceof ApiError ? caught.message : "取号未提交,请检查当前数据后重试。");
}
} finally {
setBusy(false);
}
}
const statusPath = createdTicket ? ticketStatusPath(createdTicket) : null;
return (
<section className="visitor-lookup-card visitor-take-ticket-card" aria-label="取号">
{projectsResource.loading && !projects.length ? <LoadingState label="正在读取可取号项目" /> : null}
{projectsResource.error && !projects.length ? (
<FeedbackBanner
tone="danger"
title="暂时无法读取项目"
action={<button className="button button--secondary button--small" type="button" onClick={projectsResource.refresh}></button>}
>
{projectsResource.error.message}
</FeedbackBanner>
) : null}
{!projectsResource.loading && !projectsResource.error && !projects.length ? (
<FeedbackBanner tone="warning" title="当前暂无可取号项目"></FeedbackBanner>
) : null}
{createdTicket ? (
<div className="visitor-ticket-created" role="status" aria-live="polite">
<span></span>
<p></p>
<strong>{createdTicket.ticket.ticket_number}</strong>
<small>{selectedProject?.name ?? "当前项目"} · {createdTicket.ticket.party_size} · {createdTicket.ticket.phone_last4 ?? "未返回"}</small>
{statusPath ? <Link className="button button--primary button--wide" to={statusPath}></Link> : null}
<button className="button button--ghost button--wide" type="button" onClick={resetForAnotherTicket}></button>
</div>
) : (
<form className="visitor-lookup-form visitor-take-ticket-form" onSubmit={submitTicket} noValidate>
<label className="field" htmlFor="visitor-ticket-project">
<span></span>
<select
id="visitor-ticket-project"
value={selectedProjectId}
onChange={(event) => {
setSelectedProjectId(event.target.value);
setFormError(null);
}}
disabled={busy || !projects.length}
>
<option value=""></option>
{projects.map((project) => <option key={project.id} value={project.id}>{project.name}</option>)}
</select>
</label>
{selectedProject?.visitor_notice ? <p className="visitor-take-ticket__notice">{selectedProject.visitor_notice}</p> : null}
<label className="field" htmlFor="visitor-ticket-party-size">
<span></span>
<input
id="visitor-ticket-party-size"
name="party_size"
aria-label="同行人数"
type="number"
inputMode="numeric"
min={minPartySize}
max={maxPartySize}
value={ticketForm.party_size}
onChange={(event) => updateTicketForm({
party_size: Number.isNaN(event.currentTarget.valueAsNumber) ? "" : event.currentTarget.valueAsNumber,
})}
disabled={busy}
required
/>
<small> {minPartySize}{maxPartySize} </small>
</label>
<label className="field" htmlFor="visitor-ticket-phone">
<span></span>
<input
id="visitor-ticket-phone"
name="phone"
type="tel"
inputMode="tel"
autoComplete="tel"
placeholder="请输入手机号"
value={ticketForm.phone}
onChange={(event) => updateTicketForm({ phone: event.target.value })}
disabled={busy}
aria-invalid={phoneError ? "true" : "false"}
aria-describedby={phoneError ? "visitor-ticket-phone-error" : undefined}
/>
{phoneError ? <small id="visitor-ticket-phone-error" className="field-error" role="alert">{phoneError}</small> : null}
</label>
<div className="field-row">
<label className="field" htmlFor="visitor-ticket-last-name">
<span></span>
<input
id="visitor-ticket-last-name"
value={ticketForm.last_name}
onChange={(event) => updateTicketForm({ last_name: event.target.value })}
disabled={busy}
/>
</label>
<fieldset className="field honorific-field" disabled={busy}>
<legend></legend>
<div className="honorific-options">
{[
{ value: "", label: "不填写" },
{ value: "先生", label: "先生" },
{ value: "女士", label: "女士" },
].map((option) => (
<label key={option.label}>
<input
type="radio"
name="visitor-ticket-honorific"
value={option.value}
checked={ticketForm.honorific === option.value}
onChange={() => updateTicketForm({ honorific: option.value })}
/>
<span>{option.label}</span>
</label>
))}
</div>
</fieldset>
</div>
{confirmDuplicatePhone ? <FeedbackBanner tone="warning" title="该手机号已有活动号码"></FeedbackBanner> : null}
{formError ? <FeedbackBanner tone="danger" title={formError} /> : null}
<button className="button button--primary button--wide" type="submit" disabled={busy || !selectedProjectId || !projects.length}>
{busy ? "正在创建排队号码" : confirmDuplicatePhone ? "确认继续取号" : "创建排队号码"}
</button>
</form>
)}
</section>
);
}