| 1 | "use client"; |
| 2 | |
| 3 | import Image from "next/image"; |
| 4 | import Link from "next/link"; |
| 5 | import { useRef, useState, type FormEvent } from "react"; |
| 6 | import { PageHeader } from "@/components/page-header"; |
| 7 | import { getMerchCopy, MERCH_LANGUAGES } from "@/lib/content/contributor-merch"; |
| 8 | import { contributorCost, CONTRIBUTOR_CATALOG_SOURCE, validateContributorIdentity, type IdentityError } from "@/lib/contributor-merch"; |
| 9 | import { MerchCheckout } from "@/components/merch-storefront"; |
| 10 | import { countryLabel, getStorefrontCopy } from "@/lib/content/merch-storefront"; |
| 11 | import { MERCH_COUNTRIES, MERCH_SIZES } from "@/lib/merch/catalog"; |
| 12 | import checkoutStyles from "./merch-storefront.module.css"; |
| 13 | import styles from "./contributor-merch.module.css"; |
| 14 | |
| 15 | /** Local print review/export followed by the shared, server-gated address-bound checkout. */ |
| 16 | export function ContributorMerch({ locale }: { locale: string }) { |
| 17 | const [language, setLanguage] = useState(locale); |
| 18 | const [phrase, setPhrase] = useState(1); |
| 19 | const [github, setGithub] = useState(""); |
| 20 | const [contribution, setContribution] = useState(""); |
| 21 | const [destination, setDestination] = useState("CN"); |
| 22 | const [size, setSize] = useState("M"); |
| 23 | const [error, setError] = useState<IdentityError | null>(null); |
| 24 | const [draft, setDraft] = useState<Record<string, unknown> | null>(null); |
| 25 | const githubInput = useRef<HTMLInputElement>(null); |
| 26 | const contributionInput = useRef<HTMLInputElement>(null); |
| 27 | const d = getMerchCopy(language); |
| 28 | const flow = getStorefrontCopy(language); |
| 29 | const cost = contributorCost(); |
| 30 | const format = (value: number) => new Intl.NumberFormat(language, { style: "currency", currency: "CNY" }).format(value); |
| 31 | const reset = () => { setDraft(null); setError(null); }; |
| 32 | function review(event: FormEvent) { |
| 33 | event.preventDefault(); |
| 34 | const failure = validateContributorIdentity({ github, contribution }); |
| 35 | setError(failure); |
| 36 | if (failure) { |
| 37 | setDraft(null); |
| 38 | (failure === "badUrl" ? contributionInput : githubInput).current?.focus(); |
| 39 | return; |
| 40 | } |
| 41 | setDraft({ |
| 42 | status: "local-draft-not-submitted", |
| 43 | githubIdentity: github.trim().replace(/^@/, "") || null, |
| 44 | contributionUrl: contribution.trim() || null, |
| 45 | language, printPhrase: d.phrases[phrase], brand: "Codewhale", size, |
| 46 | destination, product: "Yoycol DJCTX cotton DTF tee", |
| 47 | costEstimateCny: destination === "CN" ? cost : null, |
| 48 | source: CONTRIBUTOR_CATALOG_SOURCE, |
| 49 | unverified: ["final artwork quote", "size availability", "delivery address quote", "tax", "actual FX", "contribution identity"], |
| 50 | }); |
| 51 | } |
| 52 | function download() { |
| 53 | if (!draft) return; |
| 54 | const url = URL.createObjectURL(new Blob([JSON.stringify(draft, null, 2)], { type: "application/json" })); |
| 55 | const a = document.createElement("a"); |
| 56 | a.href = url; a.download = "codewhale-contributor-draft.json"; a.click(); |
| 57 | setTimeout(() => URL.revokeObjectURL(url), 1000); |
| 58 | } |
| 59 | const amount = (value: number) => destination === "CN" ? format(value) : d.quote; |
| 60 | return ( |
| 61 | <div lang={language} dir={language === "ar" ? "rtl" : "ltr"}> |
| 62 | <PageHeader title={d.title} lede={d.lede} pose="write" /> |
| 63 | <div className={`page-body ${styles.body}`}> |
| 64 | <Link className={`section-link ${checkoutStyles.back}`} href={`/${locale}/merch`}>{flow.returnToMerch}</Link> |
| 65 | <p className={checkoutStyles.notice}>{flow.contributorFlowNote}</p> |
| 66 | <div className={styles.grid}> |
| 67 | <form className={styles.form} onSubmit={review} noValidate aria-label={d.title}> |
| 68 | <p data-testid="merch-product"><strong>{d.product}</strong> <span dir="ltr">· DJCTX</span></p> |
| 69 | <label htmlFor="merch-language">{d.language}</label> |
| 70 | <select id="merch-language" value={language} onChange={(e) => { setLanguage(e.target.value); reset(); }}> |
| 71 | {MERCH_LANGUAGES.map((l) => <option key={l.code} value={l.code} lang={l.code} dir={l.code === "ar" ? "rtl" : "ltr"}>{l.label}</option>)} |
| 72 | </select> |
| 73 | <label htmlFor="merch-phrase">{d.phrase}</label> |
| 74 | <select id="merch-phrase" value={phrase} onChange={(e) => { setPhrase(Number(e.target.value)); reset(); }}> |
| 75 | {d.phrases.map((text, i) => <option key={i} value={i} dir="auto">{text}</option>)} |
| 76 | </select> |
| 77 | <label htmlFor="merch-github">{d.identity}</label> |
| 78 | <input id="merch-github" name="github" ref={githubInput} value={github} maxLength={50} placeholder="@octocat / 1234567" autoComplete="off" autoCapitalize="none" spellCheck={false} dir="ltr" aria-invalid={error === "missing" || error === "badIdentity"} aria-describedby={error ? "merch-error" : undefined} onChange={(e) => { setGithub(e.target.value); reset(); }} /> |
| 79 | <label htmlFor="merch-contribution">{d.url}</label> |
| 80 | <input id="merch-contribution" name="contribution" ref={contributionInput} value={contribution} maxLength={2048} placeholder="https://github.com/codewhale-hq/CodeWhale/pull/…" autoComplete="off" autoCapitalize="none" spellCheck={false} dir="ltr" aria-invalid={error === "badUrl"} aria-describedby={error ? "merch-error" : undefined} onChange={(e) => { setContribution(e.target.value); reset(); }} /> |
| 81 | <div className={styles.pair}> |
| 82 | <div><label htmlFor="merch-destination">{d.destination}</label><select id="merch-destination" value={destination} onChange={(e) => { setDestination(e.target.value); reset(); }}>{MERCH_COUNTRIES.map((c) => <option key={c.code} value={c.code}>{countryLabel(c.code, c.name, language)}</option>)}</select></div> |
| 83 | <div><label htmlFor="merch-size">{d.size}</label><select id="merch-size" value={size} onChange={(e) => { setSize(e.target.value); reset(); }}>{MERCH_SIZES.map((v) => <option key={v}>{v}</option>)}</select></div> |
| 84 | </div> |
| 85 | {error ? <p role="alert" id="merch-error" className={styles.error}>{d[error]}</p> : null} |
| 86 | <button type="submit" className="btn btn-primary">{d.review}</button> |
| 87 | </form> |
| 88 | <aside className={styles.aside} aria-label={d.preview}> |
| 89 | <div className={styles.print}> |
| 90 | <span className={styles.caption}>{d.preview}</span> |
| 91 | <Image src="/brand/mark-mono.svg" alt="" width={56} height={56} unoptimized /> |
| 92 | <p className={styles.printPhrase} dir="auto">{d.phrases[phrase]}</p> |
| 93 | {d.phrases[phrase].includes("Codewhale") ? null : <span className={styles.brand}>Codewhale</span>} |
| 94 | <span className={styles.site} dir="ltr">codewhale.net</span> |
| 95 | </div> |
| 96 | <section className={styles.cost} aria-labelledby="merch-cost"> |
| 97 | <h2 id="merch-cost">{d.costTitle}</h2> |
| 98 | <p><strong>{d.product}</strong> <span dir="ltr">· DJCTX</span></p> |
| 99 | <p>{d.costNote}</p> |
| 100 | <dl> |
| 101 | <div><dt>{d.production}</dt><dd>{amount(cost.production)}</dd></div> |
| 102 | <div><dt>{d.processing}</dt><dd>{amount(cost.processing)}</dd></div> |
| 103 | <div><dt>{d.shipping}</dt><dd>{amount(cost.shipping)}</dd></div> |
| 104 | <div className={styles.total}><dt>{d.total}</dt><dd data-testid="merch-total">{amount(cost.total)}</dd></div> |
| 105 | </dl> |
| 106 | <p className={styles.note} dir="ltr">Alipay 3.9% + US$0.30 · 6.70 CNY/USD</p> |
| 107 | <p className={styles.note}>{d.note}</p> |
| 108 | <a href={CONTRIBUTOR_CATALOG_SOURCE} className="section-link" target="_blank" rel="noopener noreferrer">{d.source}</a> |
| 109 | </section> |
| 110 | </aside> |
| 111 | </div> |
| 112 | {draft ? <section className={styles.draft} role="status" aria-labelledby="merch-draft"> |
| 113 | <h2 id="merch-draft">{d.draft}</h2><p>{d.local}</p> |
| 114 | <dl><div><dt>{d.identity}</dt><dd dir="auto">{github.trim() || "—"}</dd></div><div><dt>{d.url}</dt><dd dir="auto">{contribution.trim() || "—"}</dd></div><div><dt>{d.phrase}</dt><dd>{d.phrases[phrase]}</dd></div><div><dt>{d.total}</dt><dd>{amount(cost.total)}</dd></div></dl> |
| 115 | <div className="actions"><button type="button" className="btn btn-primary" onClick={download}>{d.download}</button><button type="button" className="btn btn-secondary" onClick={() => { setDraft(null); githubInput.current?.focus(); }}>{d.edit}</button></div> |
| 116 | </section> : null} |
| 117 | {draft ? <section className={styles.draft} aria-labelledby="contributor-delivery-title"><h2 id="contributor-delivery-title">{flow.deliveryTitle}</h2><MerchCheckout locale={language} productId="contributor" country={destination} size={size} hideSelection contributor={{ github: github.trim().replace(/^@/, ""), contribution: contribution.trim(), language, phraseIndex: phrase }} /></section> : null} |
| 118 | </div> |
| 119 | </div> |
| 120 | ); |
| 121 | } |
| 122 |