feat: add party-size queueing and call modes

This commit is contained in:
wangxuming
2026-07-16 11:40:34 +08:00
parent 7f751bebae
commit 66951b4dc3
53 changed files with 3348 additions and 533 deletions

View File

@@ -0,0 +1,259 @@
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>
);
}