返回 CodeWhale
merch-interest.tsx
根目录 / web / components / merch-interest.tsx
1 "use client";
2
3 import Link from "next/link";
4 import { useEffect, useId, useRef, useState, type FormEvent } from "react";
5 import { getInterestCopy, getInterestItemCopy } from "@/lib/content/merch-interest";
6 import { MERCH_INTEREST_ITEMS, type MerchInterestCurrency, type MerchInterestItemId, type MerchInterestPriceChoice } from "@/lib/merch/interest-options";
7 import { MERCH_COUNTRIES } from "@/lib/merch/catalog";
8 import { countryLabel } from "@/lib/content/merch-storefront";
9 import styles from "./merch-interest.module.css";
10
11 export function MerchInterest({ locale }: { locale: string }) {
12 const d = getInterestCopy(locale);
13 const id = useId();
14 const [currency, setCurrency] = useState<MerchInterestCurrency>("cny");
15 const [picks, setPicks] = useState<Partial<Record<MerchInterestItemId, MerchInterestPriceChoice>>>({});
16 const [available, setAvailable] = useState<boolean | null>(null);
17 const [saving, setSaving] = useState(false);
18 const [saved, setSaved] = useState(false);
19 const [error, setError] = useState("");
20 const [email, setEmail] = useState("");
21 const [country, setCountry] = useState("");
22 const [consent, setConsent] = useState(false);
23 const errorRef = useRef<HTMLParagraphElement>(null);
24 const groups = ["cotton", "stretch", "desk", "plush"] as const;
25 const prices = new Intl.NumberFormat(locale, { style: "currency", currency: currency.toUpperCase(), maximumFractionDigits: 0 });
26 const changeCurrency = (value: MerchInterestCurrency) => {
27 setCurrency(value);
28 // A USD response must never inherit the visitor's CNY research selection.
29 setPicks(Object.fromEntries(Object.keys(picks).map(key => [key, "unsure"])));
30 setError("");
31 };
32 useEffect(() => {
33 const controller = new AbortController();
34 fetch("/api/merch/interest", { cache: "no-store", signal: controller.signal }).then(async response => {
35 if (!response.ok) throw new Error("status");
36 const value: unknown = await response.json();
37 if (!value || typeof value !== "object" || !("available" in value) || typeof value.available !== "boolean") throw new Error("status");
38 setAvailable(value.available);
39 }).catch(() => { if (!controller.signal.aborted) setAvailable(false); });
40 return () => controller.abort();
41 }, []);
42 useEffect(() => { if (error) errorRef.current?.focus(); }, [error]);
43
44 async function join(event: FormEvent<HTMLFormElement>) {
45 event.preventDefault();
46 if (saving || available !== true) return;
47 if (!Object.keys(picks).length) { setError(d.needPick); return; }
48 const form = new FormData(event.currentTarget);
49 setSaving(true); setError("");
50 try {
51 const response = await fetch("/api/merch/interest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({
52 email: form.get("email"), country: form.get("country"), currency, locale,
53 consent: form.get("consent") === "on", website: form.get("website"),
54 picks: Object.entries(picks).map(([itemId, priceChoice]) => ({ id: itemId, priceChoice })),
55 }) });
56 const value: unknown = await response.json();
57 if (!response.ok) { setError(response.status === 429 ? d.retry : response.status === 503 ? d.unavailable : d.error); return; }
58 if (!value || typeof value !== "object" || !("ok" in value) || value.ok !== true) throw new Error("save");
59 setSaved(true);
60 } catch { setError(d.error); }
61 finally { setSaving(false); }
62 }
63
64 return <section id="merch-interest" className={styles.interest} aria-labelledby={`${id}-title`}>
65 <div className={styles.intro}><h2 id={`${id}-title`}>{d.title}</h2><p>{d.body}</p><p className={styles.note} id={`${id}-price-note`}>{d.priceNote}</p></div>
66 {saved ? <div className={styles.confirmation} role="status" aria-live="polite"><h3>{d.saved}</h3><p>{d.savedNote}</p><button type="button" className="btn btn-secondary" onClick={() => setSaved(false)}>{d.edit}</button></div> : <form onSubmit={join} aria-describedby={`${id}-price-note`}>
67 <fieldset disabled={saving} className={styles.formBody}>
68 <div className={styles.currency}><label htmlFor={`${id}-currency`}>{d.currency}</label><select id={`${id}-currency`} value={currency} onChange={event => changeCurrency(event.target.value as MerchInterestCurrency)}><option value="cny">CNY · 人民币</option><option value="usd">USD · US dollar</option></select><p className={styles.note}>{d.currencyNote}</p></div>
69 <fieldset className={styles.products}><legend>{d.products}</legend><p className={styles.note}>{d.pickNote}</p>
70 {groups.map(group => <div className={styles.group} key={group}><h3>{d[group]}</h3>{group === "cotton" || group === "stretch" ? <p className={styles.note}>{d[group === "cotton" ? "cottonNote" : "stretchNote"]}</p> : null}
71 <div>{MERCH_INTEREST_ITEMS.filter(item => item.group === group).map(item => { const itemCopy = getInterestItemCopy(item.id, locale); const selected = picks[item.id] !== undefined; return <div className={styles.item} key={item.id}>
72 <label className={styles.choice}><input type="checkbox" checked={selected} onChange={event => { setPicks(current => { const next = { ...current }; if (event.target.checked) next[item.id] = "unsure"; else delete next[item.id]; return next; }); setError(""); }} /><span><strong>{itemCopy.name}</strong><span className={styles.detail}>{itemCopy.detail}</span></span></label>
73 <div className={styles.budget}>{selected ? <><label htmlFor={`${id}-${item.id}-price`}>{d.price}<span className={styles.srOnly}> · {itemCopy.name}</span></label><select id={`${id}-${item.id}-price`} value={picks[item.id]} onChange={event => { setPicks(current => ({ ...current, [item.id]: event.target.value as MerchInterestPriceChoice })); setError(""); }}><option value="unsure">{d.unsure}</option>{(["low", "mid", "high"] as const).map((key, index) => <option value={key} key={key}>{prices.format(item.prices[currency][index])}</option>)}<option value="none">{d.none}</option></select></> : <p className={styles.range}>{item.prices[currency].map(price => prices.format(price)).join(" / ")}</p>}</div>
74 </div>; })}</div>
75 </div>)}
76 </fieldset>
77 <div className={styles.contact}><div className={styles.field}><label htmlFor={`${id}-email`}>{d.email}</label><input id={`${id}-email`} name="email" type="email" autoComplete="email" required maxLength={254} value={email} onChange={event => setEmail(event.target.value)} /></div><div className={styles.field}><label htmlFor={`${id}-country`}>{d.country}</label><input id={`${id}-country`} name="country" autoComplete="country-name" required maxLength={80} list={`${id}-countries`} aria-describedby={`${id}-country-hint`} value={country} onChange={event => setCountry(event.target.value)} /><datalist id={`${id}-countries`}>{MERCH_COUNTRIES.map(country => <option key={country.code} value={countryLabel(country.code, country.name, locale)} />)}</datalist><p className={styles.note} id={`${id}-country-hint`}>{d.countryHint}</p></div></div>
78 <div className={styles.trap} aria-hidden="true"><label htmlFor={`${id}-website`}>Website</label><input id={`${id}-website`} name="website" type="text" tabIndex={-1} autoComplete="off" maxLength={200} /></div>
79 <label className={styles.consent}><input name="consent" type="checkbox" required checked={consent} onChange={event => setConsent(event.target.checked)} /><span>{d.consent}</span></label>
80 <p className={styles.note}>{d.privacy} <Link href={`/${locale}/legal/privacy`}>{d.privacyLink}</Link></p>
81 {available !== true ? <p role="status" className={styles.notice}>{available === null ? d.checking : d.unavailable}</p> : null}
82 <button className="btn btn-primary" type="submit" disabled={saving || available !== true}>{saving ? d.saving : d.join}</button>
83 </fieldset>
84 {error ? <p role="alert" ref={errorRef} tabIndex={-1} className={styles.error}>{error}</p> : null}
85 </form>}
86 </section>;
87 }
88
88 lines Plain Text