返回 CodeWhale
merch-storefront.tsx
根目录 / web / components / merch-storefront.tsx
1 "use client";
2
3 import Image from "next/image";
4 import Link from "next/link";
5 import { useEffect, useId, useRef, useState, type FormEvent } from "react";
6 import { getStorefrontCopy, countryLabel } from "@/lib/content/merch-storefront";
7 import { getMerchCopy } from "@/lib/content/contributor-merch";
8 import { MERCH_PRODUCTS, MERCH_COUNTRIES, MERCH_SIZES, type MerchProductId } from "@/lib/merch/catalog";
9 import type { Address, Quote as StoredQuote, QuoteInput } from "@/lib/merch/types";
10 import styles from "./merch-storefront.module.css";
11 import { MerchSizeGuide } from "./merch-size-guide";
12 import { MerchInterest } from "./merch-interest";
13 import { getInterestCopy } from "@/lib/content/merch-interest";
14
15 type Contributor = NonNullable<QuoteInput["contributor"]>;
16 type Quote = Pick<StoredQuote, "quoteId" | "expiresAt" | "currency" | "merchandise" | "shipping" | "processing" | "total" | "shippingName" | "taxNote">;
17 type Status = { checkoutConfigured: boolean; readyProductIds: string[] };
18 const emptyAddress: Address = { name: "", line1: "", line2: "", city: "", region: "", postalCode: "", phone: "" };
19
20 /** Shared address-bound checkout. No vendor order, fake stock or payment is created by this UI. */
21 export function MerchCheckout({ locale, productId, country, size, contributor, hideSelection = false }: {
22 locale: string; productId: MerchProductId; country?: string; size?: string; contributor?: Contributor; hideSelection?: boolean;
23 }) {
24 const d = getStorefrontCopy(locale);
25 const id = useId();
26 const [ownCountry, setCountry] = useState("CN");
27 const [ownSize, setSize] = useState("XL");
28 const [address, setAddress] = useState<Address>(emptyAddress);
29 const [status, setStatus] = useState<Status | null>(null);
30 const [statusFailed, setStatusFailed] = useState(false);
31 const [busy, setBusy] = useState<"quote" | "checkout" | null>(null);
32 const [error, setError] = useState("");
33 const [quoted, setQuoted] = useState<{ quote: Quote; signature: string } | null>(null);
34 const paymentStarted = useRef(false);
35 const destination = country ?? ownCountry;
36 const selectedSize = size ?? ownSize;
37 const payload = { productId, size: selectedSize, color: "White", quantity: 1, country: destination, address, ...(contributor ? { contributor } : {}) };
38 const signature = JSON.stringify(payload);
39 // Any edit, including contributor print text/identity, immediately invalidates the visible quote.
40 const quote = quoted?.signature === signature ? quoted.quote : null;
41 const ready = status?.checkoutConfigured === true && status.readyProductIds.includes(productId);
42 const countryName = MERCH_COUNTRIES.find((c) => c.code === destination);
43 const displayCountry = countryLabel(destination, countryName?.name ?? destination, locale);
44 const errorMessage = (code: unknown, fallback: string) => code === "quote_expired" ? d.expired : code === "checkout_unavailable" ? d.notReady : code === "route_unavailable" || code === "product_unavailable" ? d.routeUnavailable : code === "try_later" ? d.tryLater : fallback;
45 useEffect(() => {
46 const controller = new AbortController();
47 fetch("/api/merch/status", { signal: controller.signal, cache: "no-store" }).then(async (response) => {
48 if (!response.ok) throw new Error("status");
49 const value = await response.json() as Status;
50 if (typeof value.checkoutConfigured !== "boolean" || !Array.isArray(value.readyProductIds) || !value.readyProductIds.every((product) => typeof product === "string")) throw new Error("status");
51 setStatus(value);
52 }).catch(() => { if (!controller.signal.aborted) setStatusFailed(true); });
53 return () => controller.abort();
54 }, []);
55 useEffect(() => {
56 if (!quoted || quoted.signature !== signature) return;
57 const timeout = setTimeout(() => { setQuoted(null); setError(d.expired); }, Math.max(0, quoted.quote.expiresAt - Date.now()));
58 return () => clearTimeout(timeout);
59 }, [quoted, signature, d.expired]);
60 async function review(event: FormEvent<HTMLFormElement>) {
61 event.preventDefault();
62 if (!ready || busy) return;
63 setBusy("quote"); setError(""); setQuoted(null);
64 try {
65 const response = await fetch("/api/merch/quote", { method: "POST", headers: { "Content-Type": "application/json" }, body: signature });
66 const value = await response.json() as Quote & { code?: string };
67 if (!response.ok) { setError(errorMessage(value.code, d.quoteError)); return; }
68 if (typeof value.quoteId !== "string" || !value.quoteId || !Number.isSafeInteger(value.expiresAt) || value.expiresAt <= Date.now() || !["cny", "usd"].includes(value.currency) || typeof value.shippingName !== "string" || typeof value.taxNote !== "string" || ![value.merchandise, value.shipping, value.processing, value.total].every((v) => Number.isSafeInteger(v) && v >= 0) || value.total !== value.merchandise + value.shipping + value.processing) throw new Error(d.quoteError);
69 setQuoted({ quote: value, signature });
70 } catch { setError(d.quoteError); }
71 finally { setBusy(null); }
72 }
73 async function checkout() {
74 if (!quote || !ready || busy || paymentStarted.current) return;
75 if (quote.expiresAt <= Date.now()) { setQuoted(null); setError(d.expired); return; }
76 paymentStarted.current = true; setBusy("checkout"); setError("");
77 try {
78 const response = await fetch("/api/merch/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quoteId: quote.quoteId }) });
79 const value = await response.json() as { url?: string; code?: string };
80 if (!response.ok) { paymentStarted.current = false; setBusy(null); setError(errorMessage(value.code, d.checkoutError)); return; }
81 const url = new URL(value.url ?? "");
82 if (url.protocol !== "https:" || url.hostname !== "checkout.stripe.com" || url.username || url.password) throw new Error(d.checkoutError);
83 window.location.assign(url.href);
84 } catch { paymentStarted.current = false; setBusy(null); setError(d.checkoutError); }
85 }
86 const money = (cents: number) => new Intl.NumberFormat(locale, { style: "currency", currency: quote?.currency ?? "USD" }).format(cents / 100);
87 const addressLimits: Record<keyof Address, number> = { name: 100, line1: 150, line2: 150, city: 100, region: 100, postalCode: 24, phone: 40 };
88 const field = (name: keyof Address, label: string, autoComplete: string, optional = false) => <div className={styles.field} key={name}>
89 <label htmlFor={`${id}-${name}`}>{label}</label>
90 <input id={`${id}-${name}`} name={name} value={address[name]} required={!optional} autoComplete={`shipping ${autoComplete}`} maxLength={addressLimits[name]} type={name === "phone" ? "tel" : "text"} onChange={(event) => { setAddress({ ...address, [name]: event.target.value }); setError(""); }} />
91 </div>;
92 return <div className={styles.checkout}>
93 <p role="status" className={styles.notice}>{statusFailed ? d.statusError : !status ? d.checking : !ready ? d.notReady : d.deliveryBody}</p>
94 <form onSubmit={review}>
95 <fieldset disabled={busy !== null}>
96 {!hideSelection ? <div className={styles.pair}>
97 <div className={styles.field}><label htmlFor={`${id}-country`}>{d.destination}</label><select id={`${id}-country`} value={destination} onChange={(event) => { setCountry(event.target.value); setError(""); }}>{MERCH_COUNTRIES.map((c) => <option key={c.code} value={c.code}>{countryLabel(c.code, c.name, locale)}</option>)}</select></div>
98 <div className={styles.field}><label htmlFor={`${id}-size`}>{d.size}</label><select id={`${id}-size`} value={selectedSize} onChange={(event) => { setSize(event.target.value); setError(""); }}>{MERCH_SIZES.map((s) => <option key={s} value={s}>{s}</option>)}</select></div>
99 </div> : <p><strong>{displayCountry}</strong> · {selectedSize}</p>}
100 <p className={styles.note}>{d.white}</p>
101 {field("name", d.name, "name")}
102 {field("line1", d.line1, "address-line1")}
103 {field("line2", d.line2, "address-line2", true)}
104 <div className={styles.pair}>{field("city", d.city, "address-level2")}{field("region", d.region, "address-level1", destination !== "US" && destination !== "CA")}</div>
105 <div className={styles.pair}>{field("postalCode", d.postalCode, "postal-code", destination !== "US" && destination !== "CA")}{field("phone", d.phone, "tel")}</div>
106 <p className={styles.note}>{d.addressNote}</p>
107 <button className="btn btn-primary" disabled={!ready || busy !== null} type="submit">{busy === "quote" ? d.reviewing : d.reviewDelivery}</button>
108 </fieldset>
109 </form>
110 {error ? <p className={styles.error} role="alert">{error}</p> : null}
111 {quote ? <section className={styles.quote} aria-label={d.quoteTitle} aria-live="polite">
112 <h3>{d.quoteTitle}</h3><p>{displayCountry} · {selectedSize} · {address.city}, {address.postalCode}</p>
113 <dl><div><dt>{d.merchandise}</dt><dd>{money(quote.merchandise)}</dd></div><div><dt>{d.shipping}<br /><span className={styles.note}>{quote.shippingName}</span></dt><dd>{money(quote.shipping)}</dd></div><div><dt>{d.processing}</dt><dd>{money(quote.processing)}</dd></div><div className={styles.total}><dt>{d.total}</dt><dd>{money(quote.total)}</dd></div></dl>
114 <p className={styles.note}>{quote.taxNote}</p>
115 <button className="btn btn-primary" type="button" disabled={busy !== null || !ready} onClick={checkout}>{busy === "checkout" ? d.opening : d.payment}</button>
116 </section> : null}
117 </div>;
118 }
119
120 export function MerchStorefront({ locale }: { locale: string }) {
121 const d = getStorefrontCopy(locale);
122 const interest = getInterestCopy(locale);
123 const [selected, setSelected] = useState<MerchProductId>("signature");
124 const contributorPrint = getMerchCopy(locale);
125 const seasonal = MERCH_PRODUCTS.find((p) => p.collection === "seasonal")!;
126 const deskGear = MERCH_PRODUCTS.filter((p) => ["deskmat", "mousepad", "stickers"].includes(p.id));
127 const stageDive = MERCH_PRODUCTS.find((p) => p.id === "stage-dive");
128 const plush = MERCH_PRODUCTS.find((p) => p.id === "plush")!;
129 const product = MERCH_PRODUCTS.find((p) => p.id === selected)!;
130 const coreIds: readonly MerchProductId[] = ["signature", "classic", "wordmark", "ocean-line", "use-pocket"];
131 const communityIds: readonly MerchProductId[] = ["whale-bro", "compiling", "use-codewhale"];
132 const details: Partial<Record<MerchProductId, { title: string; body: string }>> = {
133 signature: { title: d.heroTitle, body: d.heroBody }, classic: { title: d.classicTitle, body: d.classicBody },
134 wordmark: { title: d.wordmarkTitle, body: d.wordmarkBody }, "ocean-line": { title: d.oceanTitle, body: d.oceanBody },
135 "use-pocket": { title: d.pocketTitle, body: d.pocketBody }, "whale-bro": { title: d.whaleBroTitle, body: d.whaleBroBody },
136 compiling: { title: d.compilingTitle, body: d.compilingBody }, "use-codewhale": { title: d.useTitle, body: d.useBody },
137 };
138 const detail = (id: MerchProductId) => details[id] ?? { title: d.heroTitle, body: d.designNote };
139 const launchStyles = [
140 { title: d.signatureTee, layout: d.signatureLayout, colors: d.signatureColors, fabric: d.cottonDirection },
141 { title: d.classicTee, layout: d.classicLayout, colors: d.classicColors, fabric: d.cottonDirection },
142 { title: d.whaleBroTee, layout: d.whaleBroLayout, colors: d.whaleBroColors, fabric: d.cottonDirection },
143 { title: d.tidePolo, layout: d.poloLayout, colors: d.poloColors, fabric: d.poloDirection },
144 { title: d.signatureHoodie, layout: d.signatureLayout, colors: d.hoodieColors, fabric: d.hoodieDirection },
145 { title: d.tideTee, layout: d.tideLayout, colors: d.tideColors, fabric: d.ombreDirection },
146 { title: d.mascotTee, layout: d.mascotLayout, colors: d.mascotTeeColors, fabric: d.mascotCotton },
147 { title: d.mascotHoodie, layout: d.mascotHoodieLayout, colors: d.mascotHoodieColors, fabric: d.mascotLightweight },
148 ];
149 function choose(id: MerchProductId) { setSelected(id); document.getElementById("merch-delivery")?.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "start" }); }
150 return <div className={styles.store}>
151 <section className={styles.hero}>
152 <figure className={styles.heroMedia}><Image className={styles.heroArt} src="/merch/mixed-launch-lineup.png" alt={d.launchAlt} width={1536} height={1024} sizes="(max-width: 700px) 100vw, 60vw" priority /><figcaption className={styles.note}>{d.designNote}</figcaption></figure>
153 <div className={styles.heroText}><h1>{d.title}</h1><p>{d.description}</p><div className={styles.actions}><a className="btn btn-primary" href="#merch-interest">{interest.join}</a><a className="btn btn-secondary" href="#merch-lineup">{d.launchLink}</a><a className="btn btn-secondary" href="#merch-mascot">{d.mascotLink}</a><a className="btn btn-secondary" href="#merch-plush">{d.plushLink}</a><a className="btn btn-secondary" href="#merch-sizing">{d.fitLink}</a></div><p className={styles.preparing}>{d.preparing}</p><h2>{d.launchTitle}</h2><p>{d.launchBody}</p><p className={styles.note}>{d.launchPriceNote}</p></div>
154 </section>
155 <section className={`${styles.section} ${styles.seasonal}`} id="merch-mascot" aria-labelledby="merch-mascot-title"><Image className={styles.art} src="/merch/mascot-hoodies.png" alt={d.mascotAlt} width={1224} height={1285} sizes="(max-width: 700px) 100vw, 60vw" /><div><h2 id="merch-mascot-title">{d.mascotTitle}</h2><p>{d.mascotBody}</p><p className={styles.note}>{d.designNote}</p></div></section>
156 <section className={`${styles.section} ${styles.seasonal}`} id="merch-plush" aria-labelledby="merch-plush-title"><Image className={styles.art} src={plush.image} alt={d.plushAlt} width={1222} height={1287} sizes="(max-width: 700px) 100vw, 60vw" /><div><h2 id="merch-plush-title">{d.plushTitle}</h2><p>{d.plushBody}</p><p className={styles.preparing}>{d.plushStatus}</p><p className={styles.note}>{d.designNote}</p></div></section>
157 <section className={styles.section} id="merch-lineup"><div className={styles.sectionHead}><h2>{d.lineupTitle}</h2><p>{d.lineupBody}</p></div>
158 <div className={styles.tableScroll} role="region" aria-label={d.lineupTitle} tabIndex={0}><table className={styles.lineupTable}><caption>{d.launchTitle}</caption><thead><tr><th scope="col">{d.styleLabel}</th><th scope="col">{d.printLabel}</th><th scope="col">{d.colorLabel}</th><th scope="col">{d.fabricLabel}</th></tr></thead><tbody>{launchStyles.map((style) => <tr key={style.title}><th scope="row">{style.title}</th><td>{style.layout}</td><td>{style.colors}</td><td>{style.fabric}</td></tr>)}</tbody></table></div>
159 <p className={styles.note}>{d.paletteNote}</p>
160 </section>
161 <MerchInterest locale={locale} />
162 <div className={styles.section} id="merch-sizing"><MerchSizeGuide locale={locale} /></div>
163 <details className={`${styles.section} ${styles.archive}`}><summary>{d.archiveTitle}</summary><p className={styles.note}>{d.archiveBody}</p>
164 <section className={styles.section} id="merch-tees"><div className={styles.sectionHead}><h2>{d.collection}</h2><p>{d.collectionBody}</p></div>
165 <div className={styles.designs}>{MERCH_PRODUCTS.filter((p) => coreIds.includes(p.id)).map((p) => { const copy = detail(p.id); return <article key={p.id} className={styles.design}><Image className={`${styles.art} ${styles.corePrint}`} src={p.image} alt={copy.title} width={1200} height={900} sizes="(max-width: 700px) 100vw, 50vw" /><h3>{copy.title}</h3><p>{copy.body}</p><button type="button" className="btn btn-secondary" aria-pressed={selected === p.id} onClick={() => choose(p.id)}>{selected === p.id ? d.selected : d.select}</button></article>; })}</div>
166 <p className={styles.note}>{d.colorNote}</p>
167 </section>
168 <section className={styles.section}><div className={styles.sectionHead}><h2>{d.community}</h2><p>{d.communityBody}</p></div><div className={styles.designs}>{MERCH_PRODUCTS.filter((p) => communityIds.includes(p.id)).map((p) => { const copy = detail(p.id); return <article key={p.id} className={styles.design}><Image className={styles.art} src={p.image} alt={copy.title} width={1200} height={900} sizes="(max-width: 700px) 100vw, 50vw" /><h3>{copy.title}</h3><p>{copy.body}</p><button type="button" className="btn btn-secondary" aria-pressed={selected === p.id} onClick={() => choose(p.id)}>{selected === p.id ? d.selected : d.select}</button></article>; })}</div></section>
169 <section className={`${styles.section} ${styles.delivery}`} id="merch-delivery" aria-labelledby="merch-delivery-title"><div className={styles.deliverySummary}><h2 id="merch-delivery-title">{d.deliveryTitle}</h2><p className={styles.selected}>{detail(selected).title}</p><Image className={styles.art} src={product.image} alt={detail(selected).title} width={1200} height={900} sizes="(max-width: 700px) 100vw, 35vw" /><p className={styles.price}>{d.targetPrice}</p><p className={styles.note}>{d.priceNote}</p></div><MerchCheckout locale={locale} productId={selected} /></section>
170 </details>
171 <section className={`${styles.section} ${styles.contributor}`}><div><h2>{d.contributorTitle}</h2><p>{d.contributorBody}</p><Link href={`/${locale}/merch/contributor`} className="btn btn-primary">{d.contributorLink}</Link><p className={styles.note}>{d.contributorArtNote}</p></div><figure className={styles.contributorPrint}><Image src="/brand/mark-mono.svg" alt="" width={56} height={56} unoptimized /><p dir="auto">{contributorPrint.phrases[1]}</p><span>Codewhale</span><span dir="ltr">codewhale.net</span><figcaption className={styles.note}>{contributorPrint.preview}</figcaption></figure></section>
172 <section className={`${styles.section} ${styles.seasonal}`}><Image className={styles.art} src={seasonal.image} alt={seasonal.title} width={1536} height={1024} sizes="(max-width: 700px) 100vw, 60vw" /><div><h2>{d.seasonal}</h2><h3>{seasonal.title}</h3><p>{d.seasonalBody}</p><p className={styles.note}>{d.concept}</p></div></section>
173 {deskGear.length ? <section className={styles.section} id="merch-desk"><div className={styles.sectionHead}><h2>{d.deskTitle}</h2><p>{d.deskBody}</p></div><div className={styles.designs}>{stageDive ? <article className={styles.design}><Image className={styles.art} src={stageDive.image} alt={d.stageAlt} width={1536} height={1024} sizes="(max-width: 700px) 100vw, 50vw" /><h3>{d.stageTitle}</h3><p>{d.stageBody}</p><p className={styles.note}>{d.concept}</p></article> : null}<article className={styles.design}><Image className={styles.art} src="/merch/cache-mousepad-art.png" alt={d.cacheAlt} width={1134} height={1387} sizes="(max-width: 700px) 100vw, 50vw" /><h3>{d.cacheTitle}</h3><ul className={styles.deskItems}>{deskGear.map((p) => <li key={p.id}>{String(p.id) === "deskmat" ? d.deskmat : String(p.id) === "mousepad" ? d.mousepad : d.stickers}</li>)}</ul><p className={styles.note}>{d.concept}</p></article></div></section> : null}
174 <section className={styles.section}><div className={styles.sectionHead}><h2>{d.later}</h2><p>{d.laterBody}</p></div><div className={styles.laterGrid}>{MERCH_PRODUCTS.filter((p) => p.collection === "later" && p.id === "accessories").map((p) => <article key={p.id}><Image className={styles.art} src={p.image} alt={d.accessoriesTitle} width={1536} height={1024} sizes="(max-width: 700px) 100vw, 50vw" /><h3>{d.accessoriesTitle}</h3><p className={styles.note}>{d.concept}</p></article>)}</div></section>
175 </div>;
176 }
177
177 lines Plain Text