feat: add party-size queueing and call modes
This commit is contained in:
259
web/src/pages/VisitorTakeTicketPage.tsx
Normal file
259
web/src/pages/VisitorTakeTicketPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user