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