| 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 |