返回 CodeWhale
contributor-merch.tsx
根目录 / web / components / contributor-merch.tsx
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
122 lines Plain Text