Add mixed-material merch collection and private interest signup

Visitors can choose cotton tees, stretch ombre/polo/hoodie styles, desk gear
and the planned doll, then optionally give CNY/USD item-price feedback.
Eight apparel styles retain the Ocean Wave mascot and a separate contributor
at-cost flow; all purchase/supplier gates remain disabled.

Signup awaits durable CURATED_KV, validates bounded same-origin requests,
records explicit unverified opt-ins for 180 days, and applies native 5/min
limits. Owner-session admin read/delete has no public contact/count path.
No campaign service, live payment, supplier order or deployment is enabled.

Validation on wave/0.10.1-next:
- npm test: 1266 passed, 7 existing skips (101+19+384+762).
- npm run check:web:PASS (facts/docs/tokens/ESLint/TypeScript/production build).
- Source interest suite: 38/38 passed; source/assets match reviewed slice.
- Native Workerd persisted local KV through restart; sixth request 429.
- Full OpenNext local preview:EN/ZH 200, availability true, synthetic save 200,
  stored server-derived budgets; unauthorized admin 401.
- Browser administration-policy verification unavailable; no new rendered
  interaction or screenshot proof.

Existing DraftClaimLock, model settings, dependencies and auth safeguards
are preserved. Contributor-language and delivery/artwork routes still need
quotes and physical samples; survey prices exclude shipping/tax.

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-10-04 08:37:02 -07:00
1 parent 7c6251afee
commit be17d7da32
72 files changed
+2516 -8

No files matched your search

+11
View File
@@ -650,3 +650,14 @@ surfaces = { tui = "preview", api = "partial", web = "none" }
since = "unreleased"
docs = "docs/PROVIDERS.md#sign-in-with-chatgpt"
owner = "crates/tui/src/oauth.rs"
# Community marketing website, separate from the Runtime's local web client.
[[feature]]
id = "merch-interest"
name = "Merch interest list"
summary = "The community website collects private merch opt-ins, material preferences and optional item-price feedback."
status = "experimental"
surfaces = { tui = "none", web = "none", api = "none" }
since = "unreleased"
docs = "web/lib/merch/README.md"
owner = "web/components/merch-interest.tsx"
+5
View File
@@ -3,6 +3,7 @@ import { Icon } from "@/components/icon";
import { PageHeader, Section } from "@/components/page-header";
import { getContribute, pickTextLocale } from "@/lib/i18n/dictionaries";
import { buildPageMetadata } from "@/lib/page-meta";
import { getMerchCopy } from "@/lib/content/contributor-merch";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
@@ -141,6 +142,7 @@ export default async function ContributePage({ params }: { params: Promise<{ loc
const { locale } = await params;
const t = getContribute(locale);
const textLocale = pickTextLocale(locale);
const merch = getMerchCopy(locale);
const steps = { en: stepsEn, zh: stepsZh }[textLocale];
const paths = { en: pathsEn, zh: pathsZh }[textLocale];
const reviewNotes = { en: reviewNotesEn, zh: reviewNotesZh }[textLocale];
@@ -232,6 +234,9 @@ cargo clippy --workspace --all-targets --all-features --locked -- -D warnings
cargo test --workspace --all-features --locked`}
</pre>
</Section>
<Section id="contributor-merch" title={merch.title} scope={merch.lede}>
<Link href={`/${locale}/merch`} className="btn btn-secondary">{merch.cta}</Link>
</Section>
</div>
</>
);
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import { ContributorMerch } from "@/components/contributor-merch";
import { getMerchCopy } from "@/lib/content/contributor-merch";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const copy = getMerchCopy(locale);
return { title: `${copy.title} · Codewhale`, description: copy.lede, robots: { index: false, follow: false } };
}
export default async function ContributorMerchPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return <ContributorMerch locale={locale} />;
}
+14
View File
@@ -0,0 +1,14 @@
import type { Metadata } from "next";
import { MerchStorefront } from "@/components/merch-storefront";
import { getStorefrontCopy } from "@/lib/content/merch-storefront";
export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise<Metadata> {
const { locale } = await params;
const copy = getStorefrontCopy(locale);
return { title: `${copy.merch} · Codewhale`, description: copy.description, robots: { index: false, follow: false } };
}
export default async function MerchPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
return <MerchStorefront locale={locale} />;
}
@@ -0,0 +1,5 @@
import { handleAdminMerchInterest } from "@/lib/merch/interest";
export const dynamic = "force-dynamic";
export async function GET(request: Request) { return handleAdminMerchInterest(request); }
export async function DELETE(request: Request) { return handleAdminMerchInterest(request); }
+23
View File
@@ -0,0 +1,23 @@
import { getAgentEnv, validateSession } from "@/lib/community-agent";
import { merchEnv } from "@/lib/merch/server";
import { operatorAction } from "@/lib/merch/operator";
import { MerchError, parseConfig } from "@/lib/merch/model";
import { readBoundedBody, BodyReadError } from "@/lib/bounded-body";
export const dynamic = "force-dynamic";
export async function POST(request: Request) {
try {
const authEnv = await getAgentEnv();
const sid = request.headers.get("cookie")?.split(";").map(c => c.trim()).find(c => c.startsWith("mt_sid="))?.slice(7);
if (!authEnv.MAINTAINER_TOKEN || !await validateSession(authEnv.CURATED_KV, sid)) throw new MerchError(401, "unauthorized", "Maintainer sign-in is required.");
const env = await merchEnv(), config = parseConfig(env.MERCH_CONFIG_JSON);
if (!config || request.headers.get("origin") !== new URL(config.siteOrigin).origin || !request.headers.get("content-type")?.startsWith("application/json")) throw new MerchError(403, "request_blocked", "Use the approved store origin.");
const raw = await readBoundedBody(request, 8192);
let input: unknown;
try { input = JSON.parse(new TextDecoder().decode(raw)); } catch { throw new MerchError(422, "invalid_request", "Invalid operation data."); }
return Response.json(await operatorAction(env, input), { headers: { "Cache-Control": "no-store" } });
} catch (error) {
const known = error instanceof MerchError || error instanceof BodyReadError;
return Response.json({ error: known ? error.message : "Operation unavailable; review the durable receipt before retrying.", code: error instanceof MerchError ? error.code : "operator_unavailable" }, { status: known ? error.status : 503, headers: { "Cache-Control": "no-store" } });
}
}
+4
View File
@@ -0,0 +1,4 @@
import { handleMerch } from "@/lib/merch/server";
export const dynamic = "force-dynamic";
export async function POST(request: Request) { return handleMerch(request, "checkout"); }
+5
View File
@@ -0,0 +1,5 @@
import { handleMerchInterest } from "@/lib/merch/interest";
export const dynamic = "force-dynamic";
export async function GET(request: Request) { return handleMerchInterest(request); }
export async function POST(request: Request) { return handleMerchInterest(request); }
+4
View File
@@ -0,0 +1,4 @@
import { handleMerch } from "@/lib/merch/server";
export const dynamic = "force-dynamic";
export async function POST(request: Request) { return handleMerch(request, "quote"); }
+4
View File
@@ -0,0 +1,4 @@
import { handleMerch } from "@/lib/merch/server";
export const dynamic = "force-dynamic";
export async function GET(request: Request) { return handleMerch(request, "status"); }
+4
View File
@@ -0,0 +1,4 @@
import { handleMerch } from "@/lib/merch/server";
export const dynamic = "force-dynamic";
export async function POST(request: Request) { return handleMerch(request, "webhook"); }
@@ -0,0 +1,31 @@
.body { padding-block-end: var(--section-gap); }
.local { color: var(--muted); margin-block-end: 2rem; }
.grid { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); gap: clamp(2rem, 5vw, 5rem); align-items: start; }
.form { display: grid; gap: .7rem; }
.form > label:not(:first-child) { margin-block-start: .9rem; }
.form label { display: block; font-weight: 600; }
.form input, .form select { width: 100%; min-width: 0; min-height: 48px; font: inherit; font-size: 1rem; padding: .7rem .8rem; border: 1px solid var(--line-strong); border-radius: var(--radius-control); color: var(--text); background: var(--surface); }
.form input:focus-visible, .form select:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
.form input[aria-invalid="true"] { border-color: var(--danger); }
.form button { margin-block-start: 1rem; justify-self: start; }
.pair { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: 1rem; margin-block-start: .9rem; }
.pair label { margin-block-end: .7rem; }
.aside { min-width: 0; }
.print { display: flex; flex-direction: column; align-items: center; gap: .8rem; padding: 2rem 1.5rem; background: var(--ocean-floor); color: white; border: 1px solid var(--ocean-line); border-radius: var(--radius-sheet); text-align: center; }
.caption { font-size: var(--text-ui); color: #b3c5e8; }
.print img { filter: brightness(0) invert(1); }
.printPhrase { margin: .5rem 0; font-size: clamp(1.35rem, 2.8vw, 2rem); font-weight: 650; line-height: 1.4; overflow-wrap: anywhere; }
.brand { font-weight: 600; }
.site { font-size: var(--text-caption); color: #b3c5e8; }
.cost { margin-block-start: 2rem; }
.cost h2, .draft h2 { font-size: var(--text-title); margin-block-end: .5rem; }
.cost p, .draft p { color: var(--muted); line-height: 1.6; }
.cost dl, .draft dl { margin: 1.3rem 0; }
.cost dl > div, .draft dl > div { display: flex; justify-content: space-between; gap: 1rem; padding-block: .7rem; border-bottom: 1px solid var(--line); }
.cost dd { margin: 0; white-space: nowrap; font-variant-numeric: tabular-nums; }
.cost .total { border-top: 1px solid var(--line-strong); border-bottom: 0; margin-block-start: .5rem; font-weight: 700; font-size: var(--text-title); }
.note { font-size: var(--text-ui); }
.error { color: var(--danger); font-weight: 600; }
.draft { margin-block-start: 3rem; padding: 2rem; border: 1px solid var(--line-strong); border-radius: var(--radius-sheet); background: var(--panel); }
.draft dd { margin: 0; max-width: 70%; overflow-wrap: anywhere; }
@media (max-width: 760px) { .grid { grid-template-columns: 1fr; } .draft { padding: 1.25rem; } .cost dl > div { flex-wrap: wrap; } .cost dd { white-space: normal; } }
+121
View File
@@ -0,0 +1,121 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useRef, useState, type FormEvent } from "react";
import { PageHeader } from "@/components/page-header";
import { getMerchCopy, MERCH_LANGUAGES } from "@/lib/content/contributor-merch";
import { contributorCost, CONTRIBUTOR_CATALOG_SOURCE, validateContributorIdentity, type IdentityError } from "@/lib/contributor-merch";
import { MerchCheckout } from "@/components/merch-storefront";
import { countryLabel, getStorefrontCopy } from "@/lib/content/merch-storefront";
import { MERCH_COUNTRIES, MERCH_SIZES } from "@/lib/merch/catalog";
import checkoutStyles from "./merch-storefront.module.css";
import styles from "./contributor-merch.module.css";
/** Local print review/export followed by the shared, server-gated address-bound checkout. */
export function ContributorMerch({ locale }: { locale: string }) {
const [language, setLanguage] = useState(locale);
const [phrase, setPhrase] = useState(1);
const [github, setGithub] = useState("");
const [contribution, setContribution] = useState("");
const [destination, setDestination] = useState("CN");
const [size, setSize] = useState("M");
const [error, setError] = useState<IdentityError | null>(null);
const [draft, setDraft] = useState<Record<string, unknown> | null>(null);
const githubInput = useRef<HTMLInputElement>(null);
const contributionInput = useRef<HTMLInputElement>(null);
const d = getMerchCopy(language);
const flow = getStorefrontCopy(language);
const cost = contributorCost();
const format = (value: number) => new Intl.NumberFormat(language, { style: "currency", currency: "CNY" }).format(value);
const reset = () => { setDraft(null); setError(null); };
function review(event: FormEvent) {
event.preventDefault();
const failure = validateContributorIdentity({ github, contribution });
setError(failure);
if (failure) {
setDraft(null);
(failure === "badUrl" ? contributionInput : githubInput).current?.focus();
return;
}
setDraft({
status: "local-draft-not-submitted",
githubIdentity: github.trim().replace(/^@/, "") || null,
contributionUrl: contribution.trim() || null,
language, printPhrase: d.phrases[phrase], brand: "Codewhale", size,
destination, product: "Yoycol DJCTX cotton DTF tee",
costEstimateCny: destination === "CN" ? cost : null,
source: CONTRIBUTOR_CATALOG_SOURCE,
unverified: ["final artwork quote", "size availability", "delivery address quote", "tax", "actual FX", "contribution identity"],
});
}
function download() {
if (!draft) return;
const url = URL.createObjectURL(new Blob([JSON.stringify(draft, null, 2)], { type: "application/json" }));
const a = document.createElement("a");
a.href = url; a.download = "codewhale-contributor-draft.json"; a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
const amount = (value: number) => destination === "CN" ? format(value) : d.quote;
return (
<div lang={language} dir={language === "ar" ? "rtl" : "ltr"}>
<PageHeader title={d.title} lede={d.lede} pose="write" />
<div className={`page-body ${styles.body}`}>
<Link className={`section-link ${checkoutStyles.back}`} href={`/${locale}/merch`}>{flow.returnToMerch}</Link>
<p className={checkoutStyles.notice}>{flow.contributorFlowNote}</p>
<div className={styles.grid}>
<form className={styles.form} onSubmit={review} noValidate aria-label={d.title}>
<p data-testid="merch-product"><strong>{d.product}</strong> <span dir="ltr">· DJCTX</span></p>
<label htmlFor="merch-language">{d.language}</label>
<select id="merch-language" value={language} onChange={(e) => { setLanguage(e.target.value); reset(); }}>
{MERCH_LANGUAGES.map((l) => <option key={l.code} value={l.code} lang={l.code} dir={l.code === "ar" ? "rtl" : "ltr"}>{l.label}</option>)}
</select>
<label htmlFor="merch-phrase">{d.phrase}</label>
<select id="merch-phrase" value={phrase} onChange={(e) => { setPhrase(Number(e.target.value)); reset(); }}>
{d.phrases.map((text, i) => <option key={i} value={i} dir="auto">{text}</option>)}
</select>
<label htmlFor="merch-github">{d.identity}</label>
<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(); }} />
<label htmlFor="merch-contribution">{d.url}</label>
<input id="merch-contribution" name="contribution" ref={contributionInput} value={contribution} maxLength={2048} placeholder="https://github.com/Hmbown/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(); }} />
<div className={styles.pair}>
<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>
<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>
</div>
{error ? <p role="alert" id="merch-error" className={styles.error}>{d[error]}</p> : null}
<button type="submit" className="btn btn-primary">{d.review}</button>
</form>
<aside className={styles.aside} aria-label={d.preview}>
<div className={styles.print}>
<span className={styles.caption}>{d.preview}</span>
<Image src="/brand/mark-mono.svg" alt="" width={56} height={56} unoptimized />
<p className={styles.printPhrase} dir="auto">{d.phrases[phrase]}</p>
{d.phrases[phrase].includes("Codewhale") ? null : <span className={styles.brand}>Codewhale</span>}
<span className={styles.site} dir="ltr">codewhale.net</span>
</div>
<section className={styles.cost} aria-labelledby="merch-cost">
<h2 id="merch-cost">{d.costTitle}</h2>
<p><strong>{d.product}</strong> <span dir="ltr">· DJCTX</span></p>
<p>{d.costNote}</p>
<dl>
<div><dt>{d.production}</dt><dd>{amount(cost.production)}</dd></div>
<div><dt>{d.processing}</dt><dd>{amount(cost.processing)}</dd></div>
<div><dt>{d.shipping}</dt><dd>{amount(cost.shipping)}</dd></div>
<div className={styles.total}><dt>{d.total}</dt><dd data-testid="merch-total">{amount(cost.total)}</dd></div>
</dl>
<p className={styles.note} dir="ltr">Alipay 3.9% + US$0.30 · 6.70 CNY/USD</p>
<p className={styles.note}>{d.note}</p>
<a href={CONTRIBUTOR_CATALOG_SOURCE} className="section-link" target="_blank" rel="noopener noreferrer">{d.source}</a>
</section>
</aside>
</div>
{draft ? <section className={styles.draft} role="status" aria-labelledby="merch-draft">
<h2 id="merch-draft">{d.draft}</h2><p>{d.local}</p>
<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>
<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>
</section> : null}
{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}
</div>
</div>
);
}
+2
View File
@@ -11,6 +11,7 @@ import {
import { SITE_CONTACT_EMAIL, SITE_SECURITY_EMAIL } from "@/lib/page-meta";
import { USAGE_COUNTING_COPY } from "@/lib/content/usage-counting";
import { pickText } from "@/lib/i18n/dictionaries";
import { getStorefrontCopy } from "@/lib/content/merch-storefront";
import { Strata } from "./strata";
import { WhalePose } from "./whale-pose";
@@ -55,6 +56,7 @@ export function Footer({ locale = "en" }: { locale?: Locale }) {
<span className="site-footer-label">{chrome.footerProject}</span>
<div className="site-footer-list">
{project.map((item) => <Link key={item.href} href={item.href}>{item.label}</Link>)}
<Link href={`/${locale}/merch`}>{getStorefrontCopy(locale).merch}</Link>
</div>
</div>
</div>
+37
View File
@@ -0,0 +1,37 @@
.interest { margin-block-start: var(--section-gap); padding-block: 2.5rem; border-block: 1px solid var(--line-strong); scroll-margin-top: 6rem; }
.intro { max-width: 65ch; margin-block-end: 2rem; }
.intro h2 { font-size: var(--text-section); margin-block-end: 1rem; }
.intro > p { margin-block: .8rem; }
.note { color: var(--muted); font-size: var(--text-ui); line-height: 1.6; max-width: 65ch; }
.formBody, .products { border: 0; padding: 0; margin: 0; min-width: 0; }
.formBody { display: grid; gap: 1.5rem; }
.currency { display: grid; grid-template-columns: max-content minmax(9rem, 13rem); gap: .6rem 1rem; align-items: center; }
.currency label, .field label, .products > legend { font-weight: 600; }
.currency p { grid-column: 1 / -1; }
.products > legend { font-size: var(--text-title); margin-block-end: .5rem; }
.group { margin-block-start: 1.8rem; }
.group h3 { font-size: var(--text-title); margin-block-end: .4rem; }
.group > p { margin-block-end: .7rem; }
.item { display: grid; grid-template-columns: minmax(0, 1fr) minmax(12rem, 20rem); gap: 1rem 2rem; align-items: center; border-block-end: 1px solid var(--line); padding-block: .8rem; }
.choice, .consent { display: flex; gap: .8rem; align-items: flex-start; cursor: pointer; min-height: 48px; padding-block: .35rem; }
.choice input, .consent input { flex: 0 0 auto; width: 1.2rem; height: 1.2rem; margin-block-start: .2rem; accent-color: var(--accent); }
.detail { display: block; color: var(--muted); font-size: var(--text-ui); margin-block-start: .3rem; line-height: 1.5; }
.budget { display: grid; gap: .4rem; min-width: 0; }
.budget label { font-size: var(--text-ui); }
.range { color: var(--muted); font-variant-numeric: tabular-nums; }
.contact { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1rem; max-width: 52rem; margin-block-start: .5rem; }
.field { display: grid; gap: .45rem; min-width: 0; align-content: start; }
.interest select, .field input { min-width: 0; width: 100%; min-height: 48px; border: 1px solid var(--line-strong); border-radius: var(--radius-control); color: var(--text); background: var(--surface); padding: .7rem .85rem; font: inherit; caret-color: var(--accent); }
.interest :is(input, select, button, a):focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
.interest a { text-underline-offset: .2em; }
.interest :global(.btn) { min-height: 48px; justify-self: start; white-space: normal; }
.interest button:disabled { opacity: .65; cursor: not-allowed; }
.formBody:disabled { opacity: .7; }
.error { color: var(--danger); line-height: 1.6; margin-block-start: 1rem; }
.notice { color: var(--muted); line-height: 1.6; }
.confirmation { max-width: 65ch; display: grid; gap: 1rem; }
.confirmation h3 { font-size: var(--text-title); }
.trap, .srOnly { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; }
.interest ::selection { color: var(--on-brand); background: var(--accent); }
@media (max-width: 700px) { .interest { margin-block-start: 3.5rem; } .item, .contact { grid-template-columns: 1fr; } .item { gap: .4rem; } .budget { margin-inline-start: 2rem; } .currency { grid-template-columns: 1fr; max-width: 20rem; } }
@media (prefers-reduced-motion: reduce) { .interest * { transition-duration: 0s !important; } }
+87
View File
@@ -0,0 +1,87 @@
"use client";
import Link from "next/link";
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
import { getInterestCopy, getInterestItemCopy } from "@/lib/content/merch-interest";
import { MERCH_INTEREST_ITEMS, type MerchInterestCurrency, type MerchInterestItemId, type MerchInterestPriceChoice } from "@/lib/merch/interest-options";
import { MERCH_COUNTRIES } from "@/lib/merch/catalog";
import { countryLabel } from "@/lib/content/merch-storefront";
import styles from "./merch-interest.module.css";
export function MerchInterest({ locale }: { locale: string }) {
const d = getInterestCopy(locale);
const id = useId();
const [currency, setCurrency] = useState<MerchInterestCurrency>("cny");
const [picks, setPicks] = useState<Partial<Record<MerchInterestItemId, MerchInterestPriceChoice>>>({});
const [available, setAvailable] = useState<boolean | null>(null);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState("");
const [email, setEmail] = useState("");
const [country, setCountry] = useState("");
const [consent, setConsent] = useState(false);
const errorRef = useRef<HTMLParagraphElement>(null);
const groups = ["cotton", "stretch", "desk", "plush"] as const;
const prices = new Intl.NumberFormat(locale, { style: "currency", currency: currency.toUpperCase(), maximumFractionDigits: 0 });
const changeCurrency = (value: MerchInterestCurrency) => {
setCurrency(value);
// A USD response must never inherit the visitor's CNY research selection.
setPicks(Object.fromEntries(Object.keys(picks).map(key => [key, "unsure"])));
setError("");
};
useEffect(() => {
const controller = new AbortController();
fetch("/api/merch/interest", { cache: "no-store", signal: controller.signal }).then(async response => {
if (!response.ok) throw new Error("status");
const value: unknown = await response.json();
if (!value || typeof value !== "object" || !("available" in value) || typeof value.available !== "boolean") throw new Error("status");
setAvailable(value.available);
}).catch(() => { if (!controller.signal.aborted) setAvailable(false); });
return () => controller.abort();
}, []);
useEffect(() => { if (error) errorRef.current?.focus(); }, [error]);
async function join(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (saving || available !== true) return;
if (!Object.keys(picks).length) { setError(d.needPick); return; }
const form = new FormData(event.currentTarget);
setSaving(true); setError("");
try {
const response = await fetch("/api/merch/interest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({
email: form.get("email"), country: form.get("country"), currency, locale,
consent: form.get("consent") === "on", website: form.get("website"),
picks: Object.entries(picks).map(([itemId, priceChoice]) => ({ id: itemId, priceChoice })),
}) });
const value: unknown = await response.json();
if (!response.ok) { setError(response.status === 429 ? d.retry : response.status === 503 ? d.unavailable : d.error); return; }
if (!value || typeof value !== "object" || !("ok" in value) || value.ok !== true) throw new Error("save");
setSaved(true);
} catch { setError(d.error); }
finally { setSaving(false); }
}
return <section id="merch-interest" className={styles.interest} aria-labelledby={`${id}-title`}>
<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>
{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`}>
<fieldset disabled={saving} className={styles.formBody}>
<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>
<fieldset className={styles.products}><legend>{d.products}</legend><p className={styles.note}>{d.pickNote}</p>
{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}
<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}>
<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>
<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>
</div>; })}</div>
</div>)}
</fieldset>
<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>
<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>
<label className={styles.consent}><input name="consent" type="checkbox" required checked={consent} onChange={event => setConsent(event.target.checked)} /><span>{d.consent}</span></label>
<p className={styles.note}>{d.privacy} <Link href={`/${locale}/legal/privacy`}>{d.privacyLink}</Link></p>
{available !== true ? <p role="status" className={styles.notice}>{available === null ? d.checking : d.unavailable}</p> : null}
<button className="btn btn-primary" type="submit" disabled={saving || available !== true}>{saving ? d.saving : d.join}</button>
</fieldset>
{error ? <p role="alert" ref={errorRef} tabIndex={-1} className={styles.error}>{error}</p> : null}
</form>}
</section>;
}
@@ -0,0 +1,25 @@
.guide { min-width: 0; margin-block: var(--section-gap); }
.guide h2 { font-size: var(--text-section); margin-block-end: 1rem; }
.guide p { max-width: 75ch; margin-block: .8rem; line-height: 1.65; }
.note { color: var(--muted); font-size: var(--text-ui); }
.controls { display: flex; flex-wrap: wrap; align-items: end; gap: 1.5rem 2rem; margin-block: 1.5rem; }
.field { display: grid; gap: .5rem; min-width: 0; max-width: 100%; }
.field label, .units legend { font-size: var(--text-ui); font-weight: 600; }
.field select { width: 100%; max-width: 100%; min-height: 48px; padding: .75rem .85rem; border: 1px solid var(--line-strong); border-radius: var(--radius-control); background: var(--surface); color: var(--text); font: inherit; }
.units { display: flex; flex-wrap: wrap; gap: .5rem 1rem; border: 0; padding: 0; margin: 0; min-width: 0; }
.units legend { margin-block-end: .5rem; }
.units label { display: inline-flex; align-items: center; gap: .5rem; min-height: 48px; cursor: pointer; }
.units input { width: 1.15rem; height: 1.15rem; accent-color: var(--accent); }
.tableScroll { max-width: 100%; overflow-x: auto; border: 1px solid var(--line); border-radius: var(--radius-control); }
.tableScroll table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.tableScroll caption { text-align: start; padding: 1rem; font-weight: 600; }
.tableScroll th, .tableScroll td { padding: .8rem 1rem; text-align: end; border-block-start: 1px solid var(--line); white-space: nowrap; }
.tableScroll thead th { color: var(--muted); font-size: var(--text-ui); white-space: normal; }
.tableScroll th:first-child { text-align: start; }
.tableScroll tbody tr[data-confirmation] { background: var(--panel); }
.tableScroll abbr { color: var(--accent); text-decoration: none; }
.warning { border-inline-start: 3px solid var(--accent); padding-inline-start: 1rem; }
.guide :is(select, input, a, .tableScroll):focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
.guide a { text-underline-offset: .2em; }
@media (max-width: 700px) { .controls { align-items: stretch; } .field { flex-basis: 100%; } .tableScroll th, .tableScroll td { padding: .75rem; } }
@media (prefers-reduced-motion: reduce) { .guide *, .tableScroll { scroll-behavior: auto !important; transition: none !important; animation: none !important; } }
+67
View File
@@ -0,0 +1,67 @@
"use client";
import { useId, useState } from "react";
import { getMerchSizingCopy, merchSizingGarmentLabel } from "@/lib/content/merch-sizing";
import { MERCH_SIZE_CHARTS, merchMeasurement, type MerchSizingProductCode, type MerchSizingUnit } from "@/lib/merch/sizing";
import styles from "./merch-size-guide.module.css";
/** Supplier guidance only. This component does not choose a purchase size or enable variants. */
export function MerchSizeGuide({ locale, initialProductCode = "DJCTX" }: {
locale: string;
initialProductCode?: MerchSizingProductCode;
}) {
const d = getMerchSizingCopy(locale);
const id = useId();
const [productCode, setProductCode] = useState<MerchSizingProductCode>(initialProductCode);
const [unit, setUnit] = useState<MerchSizingUnit>("cm");
const chart = MERCH_SIZE_CHARTS.find((item) => item.code === productCode)!;
const garment = merchSizingGarmentLabel(chart.code, locale);
const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 1 });
const measurement = (value: number | null) => value === null ? "—" : number.format(merchMeasurement(value, unit));
const caption = d.caption.replace("{garment}", garment).replace("{unit}", d[unit]);
const tolerance = d.tolerance.replace("{value}", measurement(chart.toleranceCm)).replace("{unit}", d[unit]);
const tableNotes = `${id}-bust ${id}-tolerance${chart.warning ? ` ${id}-warning` : ""}`;
return <section className={styles.guide} aria-labelledby={`${id}-title`}>
<h2 id={`${id}-title`}>{d.title}</h2>
<p>{d.introduction}</p>
<p className={styles.note}>{d.guidance}</p>
<div className={styles.controls}>
<div className={styles.field}>
<label htmlFor={`${id}-garment`}>{d.garment}</label>
<select id={`${id}-garment`} value={productCode} onChange={(event) => {
const selected = MERCH_SIZE_CHARTS.find((item) => item.code === event.target.value);
if (selected) setProductCode(selected.code);
}}>
{MERCH_SIZE_CHARTS.map((item) => <option key={item.code} value={item.code}>{merchSizingGarmentLabel(item.code, locale)}</option>)}
</select>
</div>
<fieldset className={styles.units}>
<legend>{d.units}</legend>
{(["cm", "in"] as const).map((value) => <label key={value}>
<input type="radio" name={`${id}-unit`} value={value} checked={unit === value} onChange={() => setUnit(value)} />
{d[value]}
</label>)}
</fieldset>
</div>
{unit === "in" ? <p className={styles.note}>{d.conversion}</p> : null}
<div className={styles.tableScroll} role="region" aria-label={d.tableRegion} tabIndex={0}>
<table aria-describedby={tableNotes}>
<caption>{caption}</caption>
<thead><tr>
<th scope="col">{d.size}</th><th scope="col">{d.length}</th><th scope="col">{d.shoulder}</th><th scope="col">{d.bust}</th><th scope="col">{d.sleeve}</th>
</tr></thead>
<tbody>{chart.rows.map((row) => <tr key={row.size} data-confirmation={chart.confirmationNeededSizes.includes(row.size) || undefined}>
<th scope="row">{row.size}{chart.confirmationNeededSizes.includes(row.size) ? <abbr title={d.confirmationRequired}> *</abbr> : null}</th>
<td>{measurement(row.lengthCm)}</td><td>{measurement(row.shoulderCm)}</td><td>{measurement(row.bustCm)}</td><td>{measurement(row.sleeveCm)}</td>
</tr>)}</tbody>
</table>
</div>
<p id={`${id}-bust`} className={styles.note}>{d.bustNote}</p>
<p id={`${id}-tolerance`} className={styles.note}>{tolerance}</p>
{chart.rows.some((row) => row.sleeveCm === null) ? <p className={styles.note}>{d.missingSleeve}</p> : null}
{chart.warning ? <p id={`${id}-warning`} className={styles.warning}>{d[chart.warning]}</p> : null}
<p className={styles.note}><a href={chart.sourceUrl} target="_blank" rel="noopener noreferrer">{d.source} · {chart.code}</a></p>
<p className={styles.note}>{d.dated}</p>
</section>;
}
@@ -0,0 +1,82 @@
.store { width: var(--container); margin-inline: auto; padding-block: var(--section-pad) var(--section-gap); }
.hero { display: grid; grid-template-columns: minmax(0, 1.45fr) minmax(0, 1fr); gap: clamp(1.5rem, 4vw, 4rem); align-items: center; }
.heroMedia { margin: 0; min-width: 0; }
.heroMedia figcaption { margin-block-start: 1rem; }
.heroArt, .art { display: block; width: 100%; height: auto; border-radius: var(--radius-surface); }
.heroText { display: grid; gap: 1.4rem; align-content: center; }
.heroText h1 { font-size: clamp(2.3rem, 4.8vw, 4.4rem); max-width: 12ch; line-height: 1.04; margin: 0; }
.heroText p { max-width: 42ch; color: var(--muted); }
.heroText .actions { display: flex; flex-wrap: wrap; gap: 1rem; }
.heroText a { justify-self: start; }
.price { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
.section { margin-block-start: var(--section-gap); scroll-margin-top: 6rem; }
.sectionHead { display: flex; justify-content: space-between; gap: 2rem; align-items: baseline; margin-block-end: 2rem; }
.sectionHead p { color: var(--muted); max-width: 48ch; }
.sectionHead h2 { max-width: 24ch; font-size: var(--text-section); }
.tableScroll { overflow-x: auto; border-block: 1px solid var(--line); margin-block-end: 1.4rem; }
.tableScroll:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
.lineupTable { width: 100%; min-width: 36rem; border-collapse: collapse; line-height: 1.6; }
.lineupTable caption { text-align: start; padding-block: 1rem; font-weight: 600; }
.lineupTable :is(th, td) { text-align: start; vertical-align: top; padding: 1rem 1.2rem 1rem 0; border-block-end: 1px solid var(--line); }
.lineupTable td { color: var(--muted); }
.lineupTable tbody tr:last-child :is(th, td) { border-block-end: 0; }
.archive { border-block: 1px solid var(--line); padding-block: 1.4rem; }
.archive summary { cursor: pointer; font-weight: 600; padding-block: .5rem; }
.archive summary:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
.archive > p { margin-block-start: 1rem; }
.designs { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2rem; }
.design { min-width: 0; }
.corePrint { aspect-ratio: 4 / 3; object-fit: contain; padding: 2rem; background: var(--panel); }
.design h3 { font-size: var(--text-title); margin-block: 1.2rem .4rem; }
.design p { color: var(--muted); margin-block-end: 1rem; }
.design button { margin-top: .5rem; }
.selected { color: var(--accent); font-weight: 600; }
.note { color: var(--muted); font-size: var(--text-ui); line-height: 1.6; max-width: 65ch; }
.notice { padding-block: .9rem; color: var(--muted); border-block: 1px solid var(--line); line-height: 1.6; }
.heroText .preparing { color: var(--text); font-weight: 600; border-block: 1px solid var(--line); padding-block: 1rem; }
.contributor { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); gap: 3rem; align-items: center; border-block: 1px solid var(--line); padding-block: 2.5rem; }
.contributor h2 { font-size: var(--text-section); margin-block-end: 1rem; }
.contributor p { color: var(--muted); max-width: 50ch; margin-block-end: 1.2rem; }
.contributor .art { max-height: 22rem; object-fit: contain; }
.contributorPrint { margin: 0; display: grid; justify-items: center; gap: .65rem; padding: 3rem 1.5rem; border: 1px solid var(--ocean-line); border-radius: var(--radius-surface); background: var(--ocean-floor); color: var(--on-brand); text-align: center; }
.contributorPrint img { filter: brightness(0) invert(1); }
.contributorPrint p { color: inherit; font-size: var(--text-section); margin: 1rem 0; }
.contributorPrint span { font-size: var(--text-ui); }
.contributorPrint figcaption { margin-block-start: 1rem; color: var(--ocean-line-strong); }
.seasonal { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 2.5rem; align-items: center; }
.seasonal h2 { font-size: var(--text-section); margin-block-end: 1rem; }
.seasonal p { color: var(--muted); max-width: 48ch; margin-block: 1rem; }
.deskItems { display: flex; flex-wrap: wrap; gap: .5rem 1.5rem; padding-inline-start: 1.2rem; margin-block: 1rem; }
.laterGrid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 2rem; }
.laterGrid h3 { margin-block: 1rem .3rem; }
.laterGrid .art { aspect-ratio: 1.3; object-fit: contain; background: var(--panel); }
.delivery { display: grid; grid-template-columns: minmax(0, .8fr) minmax(0, 1.2fr); gap: clamp(2rem, 5vw, 5rem); border-block-start: 1px solid var(--line); padding-block-start: 2.5rem; }
.delivery h2 { font-size: var(--text-section); margin-block-end: 1rem; }
.deliverySummary p { margin-block: 1rem; }
.deliverySummary .art { margin-block: 1.5rem; }
.checkout { min-width: 0; }
.store :global(.btn), .checkout :global(.btn) { min-height: 48px; white-space: normal; text-align: center; }
.checkout form { display: grid; gap: 1rem; }
.checkout fieldset { border: 0; padding: 0; margin: 0; min-width: 0; display: grid; gap: 1rem; }
.pair { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 1rem; }
.field { display: grid; gap: .45rem; min-width: 0; }
.field label { font-weight: 600; font-size: var(--text-ui); }
.field input, .field select { min-width: 0; width: 100%; min-height: 48px; padding: .75rem .85rem; border: 1px solid var(--line-strong); border-radius: var(--radius-control); background: var(--surface); color: var(--text); font: inherit; caret-color: var(--accent); }
.field input:focus-visible, .field select:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
.field input::placeholder { color: var(--muted); opacity: 1; }
.checkout :is(button, a):focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; }
.checkout button { justify-self: start; }
.checkout button:disabled { cursor: not-allowed; opacity: .65; }
.checkout fieldset:disabled { opacity: .7; }
.error { color: var(--danger); line-height: 1.6; }
.quote { margin-block-start: 1.6rem; padding-block-start: 1.6rem; border-top: 1px solid var(--line-strong); }
.quote h3 { font-size: var(--text-title); margin-block-end: .75rem; }
.quote dl { margin-block: 1rem; }
.quote dl div { display: flex; justify-content: space-between; gap: 1.5rem; padding-block: .75rem; border-bottom: 1px solid var(--line); }
.quote dd { margin: 0; font-variant-numeric: tabular-nums; text-align: end; }
.quote .total { font-weight: 650; font-size: var(--text-title); border-bottom: 0; }
.back { display: inline-block; margin-block: 1rem 0; }
.store ::selection, .checkout ::selection { color: var(--on-brand); background: var(--accent); }
@media (max-width: 900px) { .hero { grid-template-columns: 1.1fr 1fr; } .designs { gap: 1.3rem; } .sectionHead { display: block; } .sectionHead p { margin-top: 1rem; } }
@media (max-width: 700px) { .hero, .contributor, .seasonal, .delivery { grid-template-columns: 1fr; gap: 1.5rem; } .heroText { grid-row: 1; } .heroText h1 { max-width: 17ch; } .designs, .laterGrid { grid-template-columns: 1fr; gap: 2.5rem; } .design .art { max-height: 25rem; object-fit: contain; } .contributor .art { max-height: 18rem; } .pair { grid-template-columns: 1fr; } .section { margin-block-start: 3.5rem; } }
@media (prefers-reduced-motion: reduce) { .store *, .checkout * { transition-duration: 0s !important; scroll-behavior: auto !important; } }
+176
View File
@@ -0,0 +1,176 @@
"use client";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useId, useRef, useState, type FormEvent } from "react";
import { getStorefrontCopy, countryLabel } from "@/lib/content/merch-storefront";
import { getMerchCopy } from "@/lib/content/contributor-merch";
import { MERCH_PRODUCTS, MERCH_COUNTRIES, MERCH_SIZES, type MerchProductId } from "@/lib/merch/catalog";
import type { Address, Quote as StoredQuote, QuoteInput } from "@/lib/merch/types";
import styles from "./merch-storefront.module.css";
import { MerchSizeGuide } from "./merch-size-guide";
import { MerchInterest } from "./merch-interest";
import { getInterestCopy } from "@/lib/content/merch-interest";
type Contributor = NonNullable<QuoteInput["contributor"]>;
type Quote = Pick<StoredQuote, "quoteId" | "expiresAt" | "currency" | "merchandise" | "shipping" | "processing" | "total" | "shippingName" | "taxNote">;
type Status = { checkoutConfigured: boolean; readyProductIds: string[] };
const emptyAddress: Address = { name: "", line1: "", line2: "", city: "", region: "", postalCode: "", phone: "" };
/** Shared address-bound checkout. No vendor order, fake stock or payment is created by this UI. */
export function MerchCheckout({ locale, productId, country, size, contributor, hideSelection = false }: {
locale: string; productId: MerchProductId; country?: string; size?: string; contributor?: Contributor; hideSelection?: boolean;
}) {
const d = getStorefrontCopy(locale);
const id = useId();
const [ownCountry, setCountry] = useState("CN");
const [ownSize, setSize] = useState("XL");
const [address, setAddress] = useState<Address>(emptyAddress);
const [status, setStatus] = useState<Status | null>(null);
const [statusFailed, setStatusFailed] = useState(false);
const [busy, setBusy] = useState<"quote" | "checkout" | null>(null);
const [error, setError] = useState("");
const [quoted, setQuoted] = useState<{ quote: Quote; signature: string } | null>(null);
const paymentStarted = useRef(false);
const destination = country ?? ownCountry;
const selectedSize = size ?? ownSize;
const payload = { productId, size: selectedSize, color: "White", quantity: 1, country: destination, address, ...(contributor ? { contributor } : {}) };
const signature = JSON.stringify(payload);
// Any edit, including contributor print text/identity, immediately invalidates the visible quote.
const quote = quoted?.signature === signature ? quoted.quote : null;
const ready = status?.checkoutConfigured === true && status.readyProductIds.includes(productId);
const countryName = MERCH_COUNTRIES.find((c) => c.code === destination);
const displayCountry = countryLabel(destination, countryName?.name ?? destination, locale);
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;
useEffect(() => {
const controller = new AbortController();
fetch("/api/merch/status", { signal: controller.signal, cache: "no-store" }).then(async (response) => {
if (!response.ok) throw new Error("status");
const value = await response.json() as Status;
if (typeof value.checkoutConfigured !== "boolean" || !Array.isArray(value.readyProductIds) || !value.readyProductIds.every((product) => typeof product === "string")) throw new Error("status");
setStatus(value);
}).catch(() => { if (!controller.signal.aborted) setStatusFailed(true); });
return () => controller.abort();
}, []);
useEffect(() => {
if (!quoted || quoted.signature !== signature) return;
const timeout = setTimeout(() => { setQuoted(null); setError(d.expired); }, Math.max(0, quoted.quote.expiresAt - Date.now()));
return () => clearTimeout(timeout);
}, [quoted, signature, d.expired]);
async function review(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!ready || busy) return;
setBusy("quote"); setError(""); setQuoted(null);
try {
const response = await fetch("/api/merch/quote", { method: "POST", headers: { "Content-Type": "application/json" }, body: signature });
const value = await response.json() as Quote & { code?: string };
if (!response.ok) { setError(errorMessage(value.code, d.quoteError)); return; }
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);
setQuoted({ quote: value, signature });
} catch { setError(d.quoteError); }
finally { setBusy(null); }
}
async function checkout() {
if (!quote || !ready || busy || paymentStarted.current) return;
if (quote.expiresAt <= Date.now()) { setQuoted(null); setError(d.expired); return; }
paymentStarted.current = true; setBusy("checkout"); setError("");
try {
const response = await fetch("/api/merch/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ quoteId: quote.quoteId }) });
const value = await response.json() as { url?: string; code?: string };
if (!response.ok) { paymentStarted.current = false; setBusy(null); setError(errorMessage(value.code, d.checkoutError)); return; }
const url = new URL(value.url ?? "");
if (url.protocol !== "https:" || url.hostname !== "checkout.stripe.com" || url.username || url.password) throw new Error(d.checkoutError);
window.location.assign(url.href);
} catch { paymentStarted.current = false; setBusy(null); setError(d.checkoutError); }
}
const money = (cents: number) => new Intl.NumberFormat(locale, { style: "currency", currency: quote?.currency ?? "USD" }).format(cents / 100);
const addressLimits: Record<keyof Address, number> = { name: 100, line1: 150, line2: 150, city: 100, region: 100, postalCode: 24, phone: 40 };
const field = (name: keyof Address, label: string, autoComplete: string, optional = false) => <div className={styles.field} key={name}>
<label htmlFor={`${id}-${name}`}>{label}</label>
<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(""); }} />
</div>;
return <div className={styles.checkout}>
<p role="status" className={styles.notice}>{statusFailed ? d.statusError : !status ? d.checking : !ready ? d.notReady : d.deliveryBody}</p>
<form onSubmit={review}>
<fieldset disabled={busy !== null}>
{!hideSelection ? <div className={styles.pair}>
<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>
<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>
</div> : <p><strong>{displayCountry}</strong> · {selectedSize}</p>}
<p className={styles.note}>{d.white}</p>
{field("name", d.name, "name")}
{field("line1", d.line1, "address-line1")}
{field("line2", d.line2, "address-line2", true)}
<div className={styles.pair}>{field("city", d.city, "address-level2")}{field("region", d.region, "address-level1", destination !== "US" && destination !== "CA")}</div>
<div className={styles.pair}>{field("postalCode", d.postalCode, "postal-code", destination !== "US" && destination !== "CA")}{field("phone", d.phone, "tel")}</div>
<p className={styles.note}>{d.addressNote}</p>
<button className="btn btn-primary" disabled={!ready || busy !== null} type="submit">{busy === "quote" ? d.reviewing : d.reviewDelivery}</button>
</fieldset>
</form>
{error ? <p className={styles.error} role="alert">{error}</p> : null}
{quote ? <section className={styles.quote} aria-label={d.quoteTitle} aria-live="polite">
<h3>{d.quoteTitle}</h3><p>{displayCountry} · {selectedSize} · {address.city}, {address.postalCode}</p>
<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>
<p className={styles.note}>{quote.taxNote}</p>
<button className="btn btn-primary" type="button" disabled={busy !== null || !ready} onClick={checkout}>{busy === "checkout" ? d.opening : d.payment}</button>
</section> : null}
</div>;
}
export function MerchStorefront({ locale }: { locale: string }) {
const d = getStorefrontCopy(locale);
const interest = getInterestCopy(locale);
const [selected, setSelected] = useState<MerchProductId>("signature");
const contributorPrint = getMerchCopy(locale);
const seasonal = MERCH_PRODUCTS.find((p) => p.collection === "seasonal")!;
const deskGear = MERCH_PRODUCTS.filter((p) => ["deskmat", "mousepad", "stickers"].includes(p.id));
const stageDive = MERCH_PRODUCTS.find((p) => p.id === "stage-dive");
const plush = MERCH_PRODUCTS.find((p) => p.id === "plush")!;
const product = MERCH_PRODUCTS.find((p) => p.id === selected)!;
const coreIds: readonly MerchProductId[] = ["signature", "classic", "wordmark", "ocean-line", "use-pocket"];
const communityIds: readonly MerchProductId[] = ["whale-bro", "compiling", "use-codewhale"];
const details: Partial<Record<MerchProductId, { title: string; body: string }>> = {
signature: { title: d.heroTitle, body: d.heroBody }, classic: { title: d.classicTitle, body: d.classicBody },
wordmark: { title: d.wordmarkTitle, body: d.wordmarkBody }, "ocean-line": { title: d.oceanTitle, body: d.oceanBody },
"use-pocket": { title: d.pocketTitle, body: d.pocketBody }, "whale-bro": { title: d.whaleBroTitle, body: d.whaleBroBody },
compiling: { title: d.compilingTitle, body: d.compilingBody }, "use-codewhale": { title: d.useTitle, body: d.useBody },
};
const detail = (id: MerchProductId) => details[id] ?? { title: d.heroTitle, body: d.designNote };
const launchStyles = [
{ title: d.signatureTee, layout: d.signatureLayout, colors: d.signatureColors, fabric: d.cottonDirection },
{ title: d.classicTee, layout: d.classicLayout, colors: d.classicColors, fabric: d.cottonDirection },
{ title: d.whaleBroTee, layout: d.whaleBroLayout, colors: d.whaleBroColors, fabric: d.cottonDirection },
{ title: d.tidePolo, layout: d.poloLayout, colors: d.poloColors, fabric: d.poloDirection },
{ title: d.signatureHoodie, layout: d.signatureLayout, colors: d.hoodieColors, fabric: d.hoodieDirection },
{ title: d.tideTee, layout: d.tideLayout, colors: d.tideColors, fabric: d.ombreDirection },
{ title: d.mascotTee, layout: d.mascotLayout, colors: d.mascotTeeColors, fabric: d.mascotCotton },
{ title: d.mascotHoodie, layout: d.mascotHoodieLayout, colors: d.mascotHoodieColors, fabric: d.mascotLightweight },
];
function choose(id: MerchProductId) { setSelected(id); document.getElementById("merch-delivery")?.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "start" }); }
return <div className={styles.store}>
<section className={styles.hero}>
<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>
<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>
</section>
<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>
<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>
<section className={styles.section} id="merch-lineup"><div className={styles.sectionHead}><h2>{d.lineupTitle}</h2><p>{d.lineupBody}</p></div>
<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>
<p className={styles.note}>{d.paletteNote}</p>
</section>
<MerchInterest locale={locale} />
<div className={styles.section} id="merch-sizing"><MerchSizeGuide locale={locale} /></div>
<details className={`${styles.section} ${styles.archive}`}><summary>{d.archiveTitle}</summary><p className={styles.note}>{d.archiveBody}</p>
<section className={styles.section} id="merch-tees"><div className={styles.sectionHead}><h2>{d.collection}</h2><p>{d.collectionBody}</p></div>
<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>
<p className={styles.note}>{d.colorNote}</p>
</section>
<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>
<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>
</details>
<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>
<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>
{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}
<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>
</div>;
}
+82
View File
@@ -0,0 +1,82 @@
import { ALL_LOCALES } from "@/lib/i18n/config";
// External contract: contributor print text must cover every language Codewhale
// offers, including the shipped Traditional Chinese TUI/README. This selector
// does not add a website route or change the site's canonical locale registry.
export const MERCH_LANGUAGES = [
...ALL_LOCALES.filter((l) => l.status === "shipped" || l.status === "partial"),
{ code: "zh-TW", label: "繁體中文", status: "shipped" as const },
];
const keys = ["title", "lede", "language", "phrase", "identity", "url", "destination", "china", "elsewhere", "size", "costTitle", "production", "processing", "shipping", "total", "note", "quote", "review", "missing", "badIdentity", "badUrl", "draft", "download", "edit", "local", "preview", "source", "costNote", "cta", "product"] as const;
export type MerchCopy = Record<typeof keys[number], string> & { phrases: readonly [string, string, string] };
function copy(values: readonly string[], phrases: MerchCopy["phrases"], product: string): MerchCopy {
const allValues = [...values, product];
if (allValues.length !== keys.length) throw new Error("Incomplete contributor copy");
return { ...Object.fromEntries(keys.map((key, i) => [key, allValues[i]])), phrases } as MerchCopy;
}
/** Draft translations share one key spine. Native-speaker print review is pending. */
export const MERCH_COPY: Record<string, MerchCopy> = {
en: copy([
"For the people building Codewhale", "Contributor merch at cost. Add a GitHub username, numeric user ID or a link to your contribution.", "Language / print language", "Print phrase", "GitHub username or user ID", "Contribution link", "Delivery destination", "Mainland China", "Another destination", "Size", "At-cost estimate", "Printed production", "Payment and conversion", "Shipping", "Estimated total", "Final artwork, size, address, tax and exchange rate need confirmation.", "Destination quote required", "Review request", "Add a GitHub identity or contribution link.", "Enter a valid GitHub username or numeric user ID.", "Enter a valid HTTPS contribution link.", "Your local draft", "Download draft", "Edit request", "Local preview. Nothing is sent or charged.", "Print text preview", "Catalog source", "No profit or returns reserve added. Shipping is shown separately.", "View contributor merch",
], ["Codewhale contributor", "Built together.", "I helped build Codewhale."], "180gsm cotton T-shirt"),
zh: copy([
"献给一起构建 Codewhale 的人", "贡献者周边按成本提供。填写 GitHub 用户名、数字用户 ID 或贡献链接。", "语言 / 印字语言", "印字短语", "GitHub 用户名或用户 ID", "贡献链接", "配送目的地", "中国大陆", "其他目的地", "尺码", "成本价估算", "制作与印刷", "支付与换汇", "运费", "预估合计", "最终图案、尺码、地址、税费及汇率仍需确认。", "需按目的地报价", "查看申请", "请填写 GitHub 身份或贡献链接。", "请输入有效的 GitHub 用户名或数字用户 ID。", "请输入有效的 HTTPS 贡献链接。", "本地草稿", "下载草稿", "修改申请", "本地预览,不会发送信息或收款。", "印字预览", "商品目录来源", "不加利润或退货预留金,运费单独显示。", "查看贡献者周边",
], ["Codewhale 贡献者", "一起构建。", "我参与构建了 Codewhale。"], "180 克/平方米纯棉 T 恤"),
"zh-TW": copy([
"獻給一起打造 Codewhale 的人", "貢獻者周邊按成本提供。填寫 GitHub 使用者名稱、數字使用者 ID 或貢獻連結。", "語言 / 印字語言", "印字短語", "GitHub 使用者名稱或使用者 ID", "貢獻連結", "配送目的地", "中國大陸", "其他目的地", "尺寸", "成本價估算", "製作與印刷", "支付與換匯", "運費", "預估合計", "最終圖案、尺寸、地址、稅費及匯率仍需確認。", "需按目的地報價", "查看申請", "請填寫 GitHub 身分或貢獻連結。", "請輸入有效的 GitHub 使用者名稱或數字使用者 ID。", "請輸入有效的 HTTPS 貢獻連結。", "本機草稿", "下載草稿", "修改申請", "本機預覽,不會傳送資訊或收款。", "印字預覽", "商品目錄來源", "不加利潤或退貨預留金,運費單獨顯示。", "查看貢獻者周邊",
], ["Codewhale 貢獻者", "一起打造。", "我參與打造了 Codewhale。"], "180 克/平方公尺純棉 T 恤"),
ja: copy([
"Codewhale を共につくる人へ", "コントリビューター向けグッズを原価で。GitHub ユーザー名、数値 ID、または貢献へのリンクを入力してください。", "言語 / 印字の言語", "印字する言葉", "GitHub ユーザー名または ID", "貢献へのリンク", "配送先", "中国本土", "その他の配送先", "サイズ", "原価の概算", "製作・印刷", "決済・通貨換算", "送料", "合計見積もり", "最終デザイン、サイズ、住所、税金、為替レートは確認が必要です。", "配送先別の見積もりが必要", "リクエストを確認", "GitHub の識別情報か貢献へのリンクを入力してください。", "有効な GitHub ユーザー名または数値 ID を入力してください。", "有効な HTTPS の貢献リンクを入力してください。", "ローカルの下書き", "下書きをダウンロード", "リクエストを編集", "ローカルプレビューです。送信や請求は行いません。", "印字プレビュー", "商品カタログ", "利益や返品準備金を加えません。送料は別に表示します。", "コントリビューター向けグッズを見る",
], ["Codewhale コントリビューター", "共につくった。", "Codewhale の開発に参加しました。"], "180g/m² コットンTシャツ"),
vi: copy([
"Dành cho những người xây dựng Codewhale", "Hàng dành cho người đóng góp theo giá vốn. Nhập tên GitHub, ID số hoặc liên kết đóng góp.", "Ngôn ngữ / ngôn ngữ in", "Câu in", "Tên hoặc ID người dùng GitHub", "Liên kết đóng góp", "Nơi giao hàng", "Trung Quốc đại lục", "Địa điểm khác", "Kích cỡ", "Ước tính giá vốn", "Sản xuất và in", "Thanh toán và đổi tiền", "Vận chuyển", "Tổng ước tính", "Cần xác nhận mẫu in, kích cỡ, địa chỉ, thuế và tỷ giá cuối cùng.", "Cần báo giá theo nơi giao", "Xem yêu cầu", "Nhập danh tính GitHub hoặc liên kết đóng góp.", "Nhập tên GitHub hoặc ID số hợp lệ.", "Nhập liên kết đóng góp HTTPS hợp lệ.", "Bản nháp cục bộ", "Tải bản nháp", "Sửa yêu cầu", "Xem trước cục bộ. Không gửi dữ liệu hay thu tiền.", "Xem trước chữ in", "Nguồn danh mục", "Không cộng lợi nhuận hay dự phòng trả hàng. Phí vận chuyển hiển thị riêng.", "Xem hàng dành cho người đóng góp",
], ["Người đóng góp Codewhale", "Cùng nhau xây dựng.", "Tôi góp phần xây dựng Codewhale."], "Áo thun cotton 180g/m²"),
ko: copy([
"Codewhale을 함께 만드는 사람들을 위해", "기여자 상품을 원가로 제공합니다. GitHub 사용자 이름, 숫자 ID 또는 기여 링크를 입력하세요.", "언어 / 인쇄 언어", "인쇄 문구", "GitHub 사용자 이름 또는 ID", "기여 링크", "배송 지역", "중국 본토", "다른 지역", "사이즈", "원가 예상 금액", "제작 및 인쇄", "결제 및 환전", "배송비", "예상 합계", "최종 디자인, 사이즈, 주소, 세금 및 환율을 확인해야 합니다.", "배송 지역별 견적 필요", "요청 확인", "GitHub 정보 또는 기여 링크를 입력하세요.", "올바른 GitHub 사용자 이름 또는 숫자 ID를 입력하세요.", "올바른 HTTPS 기여 링크를 입력하세요.", "로컬 초안", "초안 다운로드", "요청 수정", "로컬 미리보기입니다. 전송이나 결제가 이루어지지 않습니다.", "인쇄 문구 미리보기", "상품 카탈로그 출처", "이익이나 반품 준비금을 추가하지 않습니다. 배송비는 별도로 표시합니다.", "기여자 상품 보기",
], ["Codewhale 기여자", "함께 만들었습니다.", "Codewhale 개발에 참여했습니다."], "180g/m² 면 티셔츠"),
ru: copy([
"Для тех, кто создаёт Codewhale", "Мерч для участников по себестоимости. Укажите имя GitHub, числовой ID или ссылку на свой вклад.", "Язык / язык надписи", "Надпись", "Имя пользователя или ID GitHub", "Ссылка на вклад", "Место доставки", "Материковый Китай", "Другое место", "Размер", "Расчёт себестоимости", "Изготовление и печать", "Оплата и конвертация", "Доставка", "Предварительный итог", "Нужно подтвердить рисунок, размер, адрес, налоги и курс валют.", "Нужен расчёт для места доставки", "Проверить запрос", "Укажите GitHub или ссылку на свой вклад.", "Введите допустимое имя GitHub или числовой ID.", "Введите допустимую HTTPS-ссылку на вклад.", "Локальный черновик", "Скачать черновик", "Изменить запрос", "Локальный просмотр. Ничего не отправляется и не оплачивается.", "Предпросмотр надписи", "Источник каталога", "Без прибыли и резерва на возвраты. Доставка показана отдельно.", "Мерч для участников",
], ["Участник Codewhale", "Создали вместе.", "Я помог создать Codewhale."], "Хлопковая футболка 180 г/м²"),
uk: copy([
"Для тих, хто створює Codewhale", "Мерч для учасників за собівартістю. Укажіть ім’я GitHub, числовий ID або посилання на свій внесок.", "Мова / мова напису", "Напис", "Ім’я користувача або ID GitHub", "Посилання на внесок", "Місце доставки", "Материковий Китай", "Інше місце", "Розмір", "Розрахунок собівартості", "Виготовлення та друк", "Оплата й конвертація", "Доставка", "Орієнтовна сума", "Потрібно підтвердити малюнок, розмір, адресу, податки та валютний курс.", "Потрібен розрахунок для місця доставки", "Переглянути запит", "Укажіть GitHub або посилання на свій внесок.", "Введіть дійсне ім’я GitHub або числовий ID.", "Введіть дійсне HTTPS-посилання на внесок.", "Локальна чернетка", "Завантажити чернетку", "Змінити запит", "Локальний перегляд. Нічого не надсилається й не оплачується.", "Попередній вигляд напису", "Джерело каталогу", "Без прибутку та резерву на повернення. Доставка показана окремо.", "Мерч для учасників",
], ["Учасник Codewhale", "Створили разом.", "Я допоміг створити Codewhale."], "Бавовняна футболка 180 г/м²"),
es: copy([
"Para quienes construyen Codewhale", "Productos para colaboradores a precio de costo. Añade tu nombre de GitHub, ID numérico o enlace a tu contribución.", "Idioma / idioma de impresión", "Frase impresa", "Nombre o ID de GitHub", "Enlace a la contribución", "Destino de entrega", "China continental", "Otro destino", "Talla", "Estimación al costo", "Producción e impresión", "Pago y conversión", "Envío", "Total estimado", "Falta confirmar el diseño, la talla, la dirección, los impuestos y el cambio.", "Se necesita cotización para el destino", "Revisar solicitud", "Añade tu identidad de GitHub o un enlace a tu contribución.", "Introduce un nombre de GitHub o ID numérico válido.", "Introduce un enlace HTTPS válido a tu contribución.", "Tu borrador local", "Descargar borrador", "Editar solicitud", "Vista previa local. No se envía nada ni se cobra.", "Vista previa del texto", "Catálogo de origen", "Sin ganancias ni reserva para devoluciones. El envío se muestra por separado.", "Ver productos para colaboradores",
], ["Colaborador de Codewhale", "Construido en equipo.", "Ayudé a crear Codewhale."], "Camiseta de algodón de 180 g/m²"),
"pt-BR": copy([
"Para quem constrói o Codewhale", "Produtos para colaboradores a preço de custo. Informe seu nome no GitHub, ID numérico ou link da contribuição.", "Idioma / idioma da impressão", "Frase impressa", "Nome ou ID do GitHub", "Link da contribuição", "Destino da entrega", "China continental", "Outro destino", "Tamanho", "Estimativa de custo", "Produção e impressão", "Pagamento e conversão", "Frete", "Total estimado", "A arte, o tamanho, o endereço, os impostos e o câmbio final precisam ser confirmados.", "É preciso cotar o destino", "Revisar solicitação", "Informe uma identidade do GitHub ou um link da contribuição.", "Informe um nome do GitHub ou ID numérico válido.", "Informe um link HTTPS válido da contribuição.", "Seu rascunho local", "Baixar rascunho", "Editar solicitação", "Prévia local. Nada é enviado ou cobrado.", "Prévia do texto", "Catálogo de origem", "Sem lucro ou reserva para devoluções. O frete aparece separadamente.", "Ver produtos para colaboradores",
], ["Colaborador do Codewhale", "Construído em conjunto.", "Ajudei a criar o Codewhale."], "Camiseta de algodão de 180 g/m²"),
fr: copy([
"Pour celles et ceux qui construisent Codewhale", "Des articles pour les contributeurs à prix coûtant. Indiquez votre nom GitHub, votre identifiant numérique ou un lien vers votre contribution.", "Langue / langue d’impression", "Phrase imprimée", "Nom ou identifiant GitHub", "Lien vers la contribution", "Destination de livraison", "Chine continentale", "Autre destination", "Taille", "Estimation au prix coûtant", "Fabrication et impression", "Paiement et conversion", "Livraison", "Total estimé", "Le visuel, la taille, l’adresse, les taxes et le taux de change restent à confirmer.", "Devis requis pour la destination", "Vérifier la demande", "Ajoutez une identité GitHub ou un lien vers votre contribution.", "Saisissez un nom GitHub ou un identifiant numérique valide.", "Saisissez un lien HTTPS valide vers votre contribution.", "Votre brouillon local", "Télécharger le brouillon", "Modifier la demande", "Aperçu local. Rien n’est envoyé ni facturé.", "Aperçu du texte", "Catalogue d’origine", "Sans bénéfice ni provision pour retours. Livraison affichée séparément.", "Voir les articles pour contributeurs",
], ["Contributeur Codewhale", "Construit ensemble.", "J’ai contribué à créer Codewhale."], "T-shirt en coton de 180 g/m²"),
de: copy([
"Für alle, die Codewhale mitgestalten", "Merch für Mitwirkende zum Selbstkostenpreis. Gib deinen GitHub-Namen, deine numerische ID oder einen Beitragslink an.", "Sprache / Drucksprache", "Drucktext", "GitHub-Name oder Benutzer-ID", "Link zum Beitrag", "Lieferziel", "Festlandchina", "Anderes Lieferziel", "Größe", "Selbstkostenschätzung", "Herstellung und Druck", "Zahlung und Umrechnung", "Versand", "Geschätzte Gesamtsumme", "Motiv, Größe, Adresse, Steuern und Wechselkurs müssen bestätigt werden.", "Angebot für das Lieferziel erforderlich", "Anfrage prüfen", "Gib eine GitHub-Identität oder einen Beitragslink an.", "Gib einen gültigen GitHub-Namen oder eine numerische ID an.", "Gib einen gültigen HTTPS-Beitragslink an.", "Dein lokaler Entwurf", "Entwurf herunterladen", "Anfrage bearbeiten", "Lokale Vorschau. Nichts wird versendet oder berechnet.", "Drucktextvorschau", "Produktkatalog", "Ohne Gewinn oder Rücklagen für Retouren. Versand wird separat ausgewiesen.", "Merch für Mitwirkende ansehen",
], ["Codewhale-Mitwirkende", "Gemeinsam gebaut.", "Ich habe Codewhale mitentwickelt."], "Baumwoll-T-Shirt mit 180 g/m²"),
ca: copy([
"Per a qui construeix Codewhale", "Articles per a col·laboradors a preu de cost. Afegeix el nom de GitHub, l’ID numèric o un enllaç a la contribució.", "Idioma / idioma d’impressió", "Frase impresa", "Nom o ID de GitHub", "Enllaç a la contribució", "Destinació de lliurament", "Xina continental", "Una altra destinació", "Talla", "Estimació a preu de cost", "Fabricació i impressió", "Pagament i conversió", "Enviament", "Total estimat", "Cal confirmar el disseny, la talla, l’adreça, els impostos i el canvi.", "Cal un pressupost per a la destinació", "Revisar la sol·licitud", "Afegeix una identitat de GitHub o un enllaç a la contribució.", "Introdueix un nom de GitHub o un ID numèric vàlid.", "Introdueix un enllaç HTTPS vàlid a la contribució.", "El teu esborrany local", "Descarregar l’esborrany", "Editar la sol·licitud", "Vista prèvia local. No s’envia ni es cobra res.", "Vista prèvia del text", "Catàleg d’origen", "Sense benefici ni reserva per a devolucions. L’enviament es mostra per separat.", "Veure articles per a col·laboradors",
], ["Col·laborador de Codewhale", "Construït plegats.", "He ajudat a crear Codewhale."], "Samarreta de cotó de 180 g/m²"),
id: copy([
"Untuk mereka yang membangun Codewhale", "Merch kontributor dengan harga pokok. Masukkan nama GitHub, ID numerik, atau tautan kontribusi.", "Bahasa / bahasa cetak", "Frasa cetak", "Nama pengguna atau ID GitHub", "Tautan kontribusi", "Tujuan pengiriman", "Tiongkok daratan", "Tujuan lain", "Ukuran", "Perkiraan harga pokok", "Produksi dan cetak", "Pembayaran dan konversi", "Pengiriman", "Perkiraan total", "Desain akhir, ukuran, alamat, pajak, dan kurs perlu dikonfirmasi.", "Perlu penawaran sesuai tujuan", "Tinjau permintaan", "Tambahkan identitas GitHub atau tautan kontribusi.", "Masukkan nama GitHub atau ID numerik yang valid.", "Masukkan tautan kontribusi HTTPS yang valid.", "Draf lokal Anda", "Unduh draf", "Ubah permintaan", "Pratinjau lokal. Tidak ada data dikirim atau biaya dikenakan.", "Pratinjau teks cetak", "Sumber katalog", "Tanpa laba atau cadangan pengembalian. Ongkir ditampilkan terpisah.", "Lihat merch kontributor",
], ["Kontributor Codewhale", "Dibangun bersama.", "Saya ikut membangun Codewhale."], "Kaus katun 180g/m²"),
hi: copy([
"Codewhale बनाने वाले लोगों के लिए", "योगदानकर्ताओं के लिए लागत पर मर्च। GitHub उपयोगकर्ता नाम, संख्यात्मक ID या योगदान का लिंक दें।", "भाषा / प्रिंट की भाषा", "प्रिंट का वाक्य", "GitHub उपयोगकर्ता नाम या ID", "योगदान का लिंक", "डिलीवरी स्थान", "मुख्यभूमि चीन", "अन्य स्थान", "आकार", "लागत का अनुमान", "निर्माण और प्रिंट", "भुगतान और मुद्रा परिवर्तन", "डिलीवरी", "अनुमानित कुल", "अंतिम डिज़ाइन, आकार, पता, कर और विनिमय दर की पुष्टि बाकी है।", "स्थान के अनुसार मूल्य चाहिए", "अनुरोध देखें", "GitHub पहचान या योगदान का लिंक दें।", "मान्य GitHub नाम या संख्यात्मक ID दें।", "योगदान का मान्य HTTPS लिंक दें।", "आपका स्थानीय मसौदा", "मसौदा डाउनलोड करें", "अनुरोध बदलें", "स्थानीय पूर्वावलोकन। कुछ भेजा या शुल्क नहीं लिया जाता।", "प्रिंट पाठ का पूर्वावलोकन", "कैटलॉग स्रोत", "लाभ या वापसी आरक्षित राशि नहीं जोड़ी गई है। डिलीवरी अलग दिखाई गई है।", "योगदानकर्ता मर्च देखें",
], ["Codewhale योगदानकर्ता", "मिलकर बनाया।", "मैंने Codewhale बनाने में मदद की।"], "180 ग्राम/वर्ग मीटर कॉटन टी-शर्ट"),
tr: copy([
"Codewhale’i geliştirenler için", "Katkıcılara maliyetine ürünler. GitHub adınızı, sayısal kullanıcı ID’nizi veya katkı bağlantısını ekleyin.", "Dil / baskı dili", "Baskı ifadesi", "GitHub adı veya kullanıcı ID’si", "Katkı bağlantısı", "Teslimat yeri", "Çin anakarası", "Başka bir yer", "Beden", "Maliyet tahmini", "Üretim ve baskı", "Ödeme ve dönüşüm", "Kargo", "Tahmini toplam", "Son tasarım, beden, adres, vergi ve döviz kuru doğrulanmalıdır.", "Teslimat yerine göre teklif gerekli", "Talebi incele", "GitHub kimliği veya katkı bağlantısı ekleyin.", "Geçerli bir GitHub adı veya sayısal ID girin.", "Geçerli bir HTTPS katkı bağlantısı girin.", "Yerel taslağınız", "Taslağı indir", "Talebi düzenle", "Yerel önizleme. Hiçbir şey gönderilmez veya ücretlendirilmez.", "Baskı metni önizlemesi", "Katalog kaynağı", "Kâr veya iade rezervi eklenmez. Kargo ayrı gösterilir.", "Katkıcı ürünlerini gör",
], ["Codewhale katkıcısı", "Birlikte inşa ettik.", "Codewhale’i oluşturmaya yardımcı oldum."], "180 g/m² pamuklu tişört"),
it: copy([
"Per chi costruisce Codewhale", "Articoli per i contributori a prezzo di costo. Inserisci il nome GitHub, l’ID numerico o un link al contributo.", "Lingua / lingua di stampa", "Frase stampata", "Nome o ID GitHub", "Link al contributo", "Destinazione di consegna", "Cina continentale", "Altra destinazione", "Taglia", "Stima del prezzo di costo", "Produzione e stampa", "Pagamento e conversione", "Spedizione", "Totale stimato", "Grafica, taglia, indirizzo, imposte e cambio finale devono essere confermati.", "Serve un preventivo per la destinazione", "Rivedi la richiesta", "Aggiungi un’identità GitHub o un link al contributo.", "Inserisci un nome GitHub o un ID numerico valido.", "Inserisci un link HTTPS valido al contributo.", "La tua bozza locale", "Scarica la bozza", "Modifica la richiesta", "Anteprima locale. Nulla viene inviato o addebitato.", "Anteprima del testo", "Catalogo di origine", "Senza profitto né riserva per i resi. La spedizione è mostrata a parte.", "Vedi gli articoli per contributori",
], ["Contributore Codewhale", "Costruito insieme.", "Ho aiutato a creare Codewhale."], "T-shirt in cotone da 180 g/m²"),
pl: copy([
"Dla osób tworzących Codewhale", "Produkty dla współtwórców po kosztach. Podaj nazwę GitHub, numeryczne ID lub link do wkładu.", "Język / język nadruku", "Tekst nadruku", "Nazwa użytkownika lub ID GitHub", "Link do wkładu", "Miejsce dostawy", "Chiny kontynentalne", "Inne miejsce", "Rozmiar", "Szacunkowy koszt", "Produkcja i nadruk", "Płatność i przewalutowanie", "Wysyłka", "Szacunkowa suma", "Trzeba potwierdzić grafikę, rozmiar, adres, podatki i kurs walut.", "Potrzebna wycena dla miejsca dostawy", "Sprawdź zgłoszenie", "Podaj dane GitHub lub link do wkładu.", "Podaj prawidłową nazwę GitHub lub numeryczne ID.", "Podaj prawidłowy link HTTPS do wkładu.", "Twój lokalny szkic", "Pobierz szkic", "Edytuj zgłoszenie", "Podgląd lokalny. Nic nie jest wysyłane ani pobierane.", "Podgląd tekstu nadruku", "Źródło katalogu", "Bez zysku i rezerwy na zwroty. Wysyłka jest pokazana osobno.", "Zobacz produkty dla współtwórców",
], ["Współtwórca Codewhale", "Zbudowane razem.", "Pomogłem stworzyć Codewhale."], "Bawełniany T-shirt 180 g/m²"),
ar: copy([
"لمن يساهمون في بناء Codewhale", "منتجات للمساهمين بسعر التكلفة. أضف اسم GitHub أو المعرّف الرقمي أو رابط مساهمتك.", "اللغة / لغة الطباعة", "العبارة المطبوعة", "اسم مستخدم GitHub أو معرّفه", "رابط المساهمة", "وجهة التوصيل", "الصين القارية", "وجهة أخرى", "المقاس", "تقدير سعر التكلفة", "الإنتاج والطباعة", "الدفع وتحويل العملة", "الشحن", "الإجمالي التقديري", "يجب تأكيد التصميم والمقاس والعنوان والضرائب وسعر الصرف النهائي.", "يلزم عرض سعر للوجهة", "مراجعة الطلب", "أضف هوية GitHub أو رابط مساهمتك.", "أدخل اسم GitHub أو معرّفًا رقميًا صالحًا.", "أدخل رابط مساهمة HTTPS صالحًا.", "مسودتك المحلية", "تنزيل المسودة", "تعديل الطلب", "معاينة محلية. لا يتم إرسال شيء أو تحصيل أي مبلغ.", "معاينة النص المطبوع", "مصدر الكتالوج", "لا تُضاف أرباح أو مخصصات للمرتجعات. يُعرض الشحن منفصلًا.", "عرض منتجات المساهمين",
], ["مساهم في Codewhale", "بنيناها معًا.", "ساعدت في بناء Codewhale."], "قميص قطني قصير الأكمام بوزن 180 غ/م²"),
};
export function getMerchCopy(locale: string): MerchCopy {
return MERCH_COPY[locale === "zh-Hant" ? "zh-TW" : locale] ?? MERCH_COPY.en;
}
+59
View File
@@ -0,0 +1,59 @@
import { pickText } from "@/lib/i18n/dictionaries";
import type { MerchInterestItemId } from "@/lib/merch/interest-options";
const text = {
title: { en: "Help shape the first drop.", zh: "一起决定首批周边。" },
body: { en: "Pick what you’d wear or keep on your desk. Join the waitlist and tell us what price would feel comfortable.", zh: "选出你想穿的、或想放在桌上的周边。加入意向名单,也告诉我们你能接受的价格。" },
join: { en: "Join the merch waitlist", zh: "加入周边意向名单" },
priceNote: { en: "Prices below are research choices for one item. Shipping and applicable tax would be extra. These are not confirmed sale prices or a preorder.", zh: "以下为单件商品的价格调研选项,运费及适用税费另计。这些不是已确定的售价,也不是预售。" },
products: { en: "What are you interested in?", zh: "你对哪些周边感兴趣?" },
pickNote: { en: "Choose one or more. Price feedback is optional.", zh: "可多选,价格反馈为选填。" },
cotton: { en: "Cotton everyday tees", zh: "日常纯棉 T 恤" },
cottonNote: { en: "Cotton for a familiar, everyday feel. Prints and fit still need samples.", zh: "纯棉,适合日常穿着。印花与版型仍需用实物样品确认。" },
stretch: { en: "Stretch fabrics and layers", zh: "弹力面料与外搭" },
stretchNote: { en: "Polyester blends for ombré and all-over prints; fleece for warmth, lighter scuba for the wave hoodie.", zh: "聚酯混纺用于渐变与全幅印花;抓绒款更保暖,海浪卫衣采用较轻的空气层面料。" },
desk: { en: "For your desk", zh: "桌面周边" },
plush: { en: "The mascot companion", zh: "吉祥物毛绒伙伴" },
email: { en: "Email for merch updates", zh: "接收周边消息的邮箱" },
country: { en: "Country or region", zh: "国家或地区" },
countryHint: { en: "For planning delivery. No street address needed.", zh: "用于规划配送,无需填写街道地址。" },
currency: { en: "Price currency", zh: "价格币种" },
currencyNote: { en: "CNY and USD choices are separate research points, not exchange-rate equivalents.", zh: "人民币与美元选项分别用于调研,不代表汇率换算后的等值价格。" },
price: { en: "Comfortable item price (optional)", zh: "可接受的单件价格(选填)" },
unsure: { en: "Not sure yet", zh: "还不确定" },
none: { en: "None of these prices", zh: "这些价格都不合适" },
consent: { en: "Email me about Codewhale merch samples and launch news.", zh: "我愿意接收 Codewhale 周边样品与开售消息。" },
privacy: { en: "Your contact and choices stay private and are kept for up to 180 days. No payment is taken. You can opt out of any future update.", zh: "联系方式与选择会保密,最多保存 180 天。不收取任何费用,之后的消息可随时退订。" },
privacyLink: { en: "Privacy policy", zh: "隐私政策" },
checking: { en: "Checking signup availability…", zh: "正在确认是否可以提交……" },
unavailable: { en: "Signup isn’t open on this preview yet. You can explore the choices; please come back when submissions open.", zh: "此预览暂未开放提交。你可以先看看款式与价格选项,开放后再来登记。" },
saving: { en: "Saving your interest…", zh: "正在保存你的意向……" },
saved: { en: "You’re on the interest list. Thanks for helping shape the drop.", zh: "你的意向已保存。感谢你一起决定首批周边。" },
savedNote: { en: "This is not an order or a reservation. We’ll share sample results and confirmed prices before sales open.", zh: "这不是订单,也不代表预留商品。开售前会公布样品反馈与确认后的价格。" },
edit: { en: "Update my choices", zh: "修改我的选择" },
needPick: { en: "Choose at least one item before joining.", zh: "请先选择至少一款周边。" },
error: { en: "Your signup wasn’t saved. Check the form and try again.", zh: "意向未能保存,请检查填写内容后重试。" },
retry: { en: "Please wait a minute, then try again. Your signup has not been saved.", zh: "请等一分钟后重试,你的意向尚未保存。" },
} as const;
const items: Record<MerchInterestItemId, { name: { en: string; zh: string }; detail: { en: string; zh: string } }> = {
"signature-tee": { name: { en: "Signature Tee", zh: "日常款 T 恤" }, detail: { en: "100% cotton · small chest signature", zh: "纯棉 · 胸前小标志" } },
"classic-tee": { name: { en: "Classic Tee", zh: "经典款 T 恤" }, detail: { en: "100% cotton · centered whale and name", zh: "纯棉 · 正中的小鲸与名字" } },
"whale-bro-tee": { name: { en: "Whale Bro Tee", zh: "鲸鱼兄弟 T 恤" }, detail: { en: "100% cotton · 鲸鱼兄弟", zh: "纯棉 · 鲸鱼兄弟" } },
"mascot-tee": { name: { en: "Mascot Tee", zh: "吉祥物 T 恤" }, detail: { en: "Cotton · saluting character badge", zh: "棉 T 恤 · 敬礼角色徽章" } },
"tide-tee": { name: { en: "Tide Ombré Tee", zh: "海潮渐变 T 恤" }, detail: { en: "95% polyester / 5% spandex · light-to-dark color", zh: "95% 聚酯 / 5% 氨纶 · 浅至深渐变" } },
"signature-polo": { name: { en: "Signature Polo", zh: "日常款 Polo 衫" }, detail: { en: "88% polyester / 12% spandex · small chest signature", zh: "88% 聚酯 / 12% 氨纶 · 胸前小标志" } },
"everyday-hoodie": { name: { en: "Everyday Hoodie", zh: "日常抓绒卫衣" }, detail: { en: "95% polyester / 5% spandex · heavier fleece", zh: "95% 聚酯 / 5% 氨纶 · 较厚抓绒" } },
"mascot-hoodie": { name: { en: "Ocean Wave Mascot Hoodie", zh: "海浪吉祥物卫衣" }, detail: { en: "95% polyester / 5% spandex · lightweight scuba", zh: "95% 聚酯 / 5% 氨纶 · 轻薄空气层" } },
mousepad: { name: { en: "Cache Mousepad", zh: "Cache 鼠标垫" }, detail: { en: "18 × 22cm · rubber-backed cloth", zh: "18 × 22cm · 橡胶底布面" } },
deskmat: { name: { en: "Developer Deskmat", zh: "开发者桌垫" }, detail: { en: "80 × 30cm candidate · detailed ocean art", zh: "80 × 30cm 候选 · 精细海洋图案" } },
stickers: { name: { en: "Sticker Pack", zh: "贴纸包" }, detail: { en: "Developer jokes · pack contents still being planned", zh: "开发者梗 · 内容与数量筹备中" } },
plush: { name: { en: "20cm Mascot Doll", zh: "20cm 吉祥物娃娃" }, detail: { en: "Seated blue-haired plush · prototype planned", zh: "蓝发坐姿毛绒娃娃 · 实物样品筹备中" } },
};
export function getInterestCopy(locale: string) {
return Object.fromEntries(Object.entries(text).map(([key, value]) => [key, pickText(value, locale)])) as Record<keyof typeof text, string>;
}
export function getInterestItemCopy(id: MerchInterestItemId, locale: string) {
return { name: pickText(items[id].name, locale), detail: pickText(items[id].detail, locale) };
}
+48
View File
@@ -0,0 +1,48 @@
import { pickText } from "@/lib/i18n/dictionaries";
import type { MerchSizingProductCode } from "@/lib/merch/sizing";
/** Shared Chinese/English sizing copy; other locales use the site's English fallback. */
const text = {
title: { en: "Compare garment measurements", zh: "对照成衣尺寸,选择尺码" },
introduction: { en: "Start with a garment you like: lay it flat and measure its length, shoulders and sleeves. Compare only with the chart for the exact supplier garment; fit and letter sizes differ between styles.", zh: "找一件穿着合适的衣服,平铺后量衣长、肩宽和袖长,再对照对应供应商款式的尺寸表。不同款式的版型和字母尺码不通用。" },
guidance: { en: "These are supplier garment measurements, not body measurements or a sample-verified fit guide. We do not guess sizes from height, weight or where you live.", zh: "这里列的是供应商提供的成衣尺寸,不是人体尺寸,也尚未用实物样品验证版型。不按身高、体重或所在地区猜测尺码。" },
garment: { en: "Supplier garment", zh: "供应商款式" },
units: { en: "Measurement units", zh: "尺寸单位" },
cm: { en: "cm", zh: "厘米" },
in: { en: "inches", zh: "英寸" },
conversion: { en: "Inches = centimetres ÷ 2.54, rounded to one decimal place. Letter sizes are unchanged.", zh: "英寸 = 厘米 ÷ 2.54,显示到一位小数;字母尺码不作换算。" },
caption: { en: "{garment}: supplier garment measurements ({unit})", zh: "{garment}:供应商成衣尺寸({unit})" },
size: { en: "Size", zh: "尺码" },
length: { en: "Length", zh: "衣长" },
shoulder: { en: "Shoulder", zh: "肩宽" },
bust: { en: "Bust (supplier label)", zh: "胸围(供应商 Bust)" },
sleeve: { en: "Sleeve", zh: "袖长" },
bustNote: { en: "The supplier's Bust measurement diagram has not been verified. Bust is preserved exactly as published; do not assume it is flat half-chest or convert it to a circumference. Confirm the measurement method before choosing a size.", zh: "供应商 Bust 的量法图尚未核实。此列保留原始数值,不将它当作平铺半胸宽,也不擅自换算成整圈胸围。选择尺码前需确认量法。" },
tolerance: { en: "The supplier lists measurement variation up to {value} {unit}. No physical sample has been measured against this chart.", zh: "供应商注明尺寸误差最大为 {value} {unit};尚未用实物样品核对这张表。" },
missingSleeve: { en: "— means the supplier does not list that measurement; no sleeve length has been inferred.", zh: "— 表示供应商未列出该项尺寸;不推算缺失的袖长。" },
confirmationRequired: { en: "Supplier confirmation needed", zh: "需供应商确认" },
poloShoulderNote: { en: "* Published polo shoulders are 55cm for both 2XL and 3XL. These raw values are retained; confirm them before offering those variants.", zh: "* 供应商公布的 Polo 2XL、3XL 肩宽均为 55 厘米。原值保留,开放这两个尺码前需确认。" },
hoodieSleeveNote: { en: "* Published hoodie sleeve length is 75cm for 7XL, shorter than 6XL's 76cm. The raw value is retained; confirm it before offering 7XL.", zh: "* 供应商公布的卫衣 7XL 袖长为 75 厘米,短于 6XL 的 76 厘米。原值保留,开放 7XL 前需确认。" },
cottonHoodieMaterialNote: { en: "This hoodie is listed as cotton loopback, but the supplier also gives a polyester warning for the non-custom fabric option. Confirm the chosen fabric before offering it as cotton; this size chart does not establish a fabric quote.", zh: "此款标为棉质毛圈,但供应商对非定制面料选项另有聚酯纤维提示。作为棉质款售卖前需确认所选面料;尺码表不代表已确认的面料报价。" },
source: { en: "Official supplier chart", zh: "供应商官方尺寸表" },
dated: { en: "Public chart captured 4 October 2026. Listed sizes do not establish stock, checkout availability or delivery coverage.", zh: "公开尺寸表记录于 2026 年 10 月 4 日。列出的尺码不代表已有库存、可付款订购或已确认配送范围。" },
tableRegion: { en: "Garment size table; scroll horizontally if needed", zh: "成衣尺码表,可横向滚动查看" },
} as const;
const garments: Record<MerchSizingProductCode, { en: string; zh: string }> = {
"DJCTX": { en: "DJCTX · Cotton DTF tee", zh: "DJCTX · 纯棉 DTF T 恤" },
"3MTXVYM32": { en: "3MTXVYM32 · Cotton tee", zh: "3MTXVYM32 · 纯棉 T 恤" },
"3MOTS": { en: "3MOTS · Stretch jersey tee", zh: "3MOTS · 弹力针织 T 恤" },
"3MCSPL56": { en: "3MCSPL56 · polo", zh: "3MCSPL56 · Polo 衫" },
"3PH": { en: "3PH · Lightweight scuba hoodie", zh: "3PH · 轻薄 Scuba 面料卫衣" },
"3TPH": { en: "3TPH · Polyester fleece hoodie", zh: "3TPH · 聚酯绒面卫衣" },
"3MHZDM16": { en: "3MHZDM16 · Cotton loopback hoodie", zh: "3MHZDM16 · 棉质毛圈卫衣" },
};
export function getMerchSizingCopy(locale: string) {
return Object.fromEntries(Object.entries(text).map(([key, value]) => [key, pickText(value, locale)])) as Record<keyof typeof text, string>;
}
export function merchSizingGarmentLabel(code: MerchSizingProductCode, locale: string): string {
return pickText(garments[code], locale);
}
+160
View File
@@ -0,0 +1,160 @@
import { pickText } from "@/lib/i18n/dictionaries";
import { MERCH_TARGET_PRICE } from "@/lib/merch/catalog";
/** Merch copy uses the site's shared en/zh content bridge; other locales fall back to English. */
const text = {
title: { en: "Open source, worn out loud.", zh: "把开源精神,穿在身上。" },
description: { en: "For the people building with Codewhale. Everyday solids, an ocean-wave mascot hoodie and one ombré option; a soft mascot companion in development; expressive art for your desk and a contributor edition at cost.", zh: "给和 Codewhale 一起创造的人。日常纯色、海浪吉祥物卫衣,还有一款渐变;正在筹备的毛绒吉祥物、热闹的桌面图案,以及按成本提供的贡献者款。" },
launchTitle: { en: "Everyday staples. Mascot favorites.", zh: "日常基础款,也有吉祥物。" },
launchBody: { en: "Eight apparel styles, led by everyday staples and the blue wave-and-stars mascot hoodie. The seated plush doll joins the planned collection, with its prototype next. Tide stays the single ombré option.", zh: "八款服饰,日常基础款搭配蓝色海浪与星星的吉祥物卫衣。坐姿毛绒娃娃也加入计划中的系列,下一步先做实物样品。海潮款仍是唯一的渐变设计。" },
launchAlt: { en: "Six Codewhale apparel concepts in two color choices each: solid Signature and Classic cotton tees, Whale Bro cotton tees, solid Signature polos, solid Everyday fleece hoodies, and Tide ombré jersey tees.", zh: "六款 Codewhale 服饰概念,每款两种配色:纯色日常款与经典款棉 T 恤、鲸鱼兄弟棉 T 恤、纯色 Polo 衫、纯色抓绒卫衣,以及海潮渐变 T 恤。" },
launchPriceNote: { en: "Fabric and print samples come first. Each garment will have its own price and destination shipping quote.", zh: "先确认面料与印花样品,再分别确定每款服饰的售价和目的地运费。" },
lineupTitle: { en: "The first-drop shortlist.", zh: "首批款式候选。" },
lineupBody: { en: "Six solid styles, the Ocean Wave mascot hoodie with a White alternative, and one ombré tee. Two proposed options per style: sixteen combinations before sizes. The doll is a separate planned product, with a prototype before orders.", zh: "六款纯色、海浪吉祥物卫衣(另有白色款),以及一款渐变 T 恤。每款提出两种选择,共十六种组合,再分别选择尺码。娃娃作为单独筹备的产品,先完成实物样品,再开放订购。" },
mascotTitle: { en: "Ocean waves. Our blue-haired mascot.", zh: "海浪与星星,蓝发吉祥物。" },
mascotBody: { en: "The cobalt hoodie with flowing pale-blue waves and stars leads the mascot edition. Keep the original saluting character, 誓死效忠 Codewhale badge and codewhale.net. A White hoodie and solid mascot tees complete the choices.", zh: "钴蓝衣身、浅蓝海浪与星星,是吉祥物系列的主打卫衣。保留原来的敬礼角色、「誓死效忠 Codewhale」徽章与 codewhale.net。另有白色卫衣与纯色吉祥物 T 恤。" },
mascotAlt: { en: "Original Codewhale mascot hoodie concepts: White and cobalt-blue Ocean Wave hoodies, with pale-blue waves and stars on the blue option and the centered saluting character badge on both.", zh: "原来的 Codewhale 吉祥物卫衣概念:白色与钴蓝海浪款。蓝色款印有浅蓝海浪与星星,两款正中都保留敬礼角色徽章。" },
mascotLink: { en: "Meet the mascot edition", zh: "看看吉祥物款" },
mascotTee: { en: "Mascot Tee", zh: "吉祥物 T 恤" },
mascotHoodie: { en: "Ocean Wave Mascot Hoodie", zh: "海浪吉祥物卫衣" },
mascotLayout: { en: "One centered character badge, 誓死效忠 Codewhale and website", zh: "一个正中的角色徽章,搭配誓死效忠 Codewhale 与网址" },
mascotTeeColors: { en: "White / cobalt", zh: "白色 / 钴蓝" },
mascotHoodieColors: { en: "Ocean Wave / White", zh: "蓝色海浪 / 白色" },
mascotHoodieLayout: { en: "Flowing waves and stars around the centered mascot badge; plain White alternative", zh: "海浪与星星围绕正中的吉祥物徽章,另有纯白款" },
mascotCotton: { en: "Cotton tee; colorful badge print to be sampled", zh: "棉 T 恤,彩色徽章印花待样品确认" },
mascotLightweight: { en: "Lightweight stretch-polyester scuba; all-over print", zh: "轻薄聚酯弹力空气层面料,全幅印花" },
colorLabel: { en: "Color options · concepts", zh: "候选配色 · 概念" },
whaleBroTee: { en: "Whale Bro Tee", zh: "鲸鱼兄弟 T 恤" },
tideTee: { en: "Tide Ombré Tee", zh: "海潮渐变 T 恤" },
whaleBroLayout: { en: "Compact whale, 鲸鱼兄弟, Codewhale and website", zh: "简洁小鲸、鲸鱼兄弟、Codewhale 与网址" },
tideLayout: { en: "Small left-chest signature on the single gradient design", zh: "唯一渐变款,搭配左胸小标志" },
ombreDirection: { en: "Stretch-polyester jersey for the budget gradient option", zh: "聚酯弹力针织面料,用作平价渐变款" },
signatureColors: { en: "White / navy", zh: "白色 / 深蓝" },
classicColors: { en: "Ivory / cobalt", zh: "象牙白 / 钴蓝" },
whaleBroColors: { en: "White / ice blue", zh: "白色 / 冰蓝" },
poloColors: { en: "Navy / ivory", zh: "深蓝 / 象牙白" },
hoodieColors: { en: "Charcoal / navy", zh: "炭灰 / 深蓝" },
tideColors: { en: "Lagoon / Daybreak", zh: "浅海 / 晨光" },
styleLabel: { en: "Style", zh: "款式" },
printLabel: { en: "Print layout", zh: "印花位置" },
fabricLabel: { en: "Fabric direction · samples pending", zh: "面料方向 · 待样品确认" },
signatureTee: { en: "Signature Tee", zh: "日常款 T 恤" },
classicTee: { en: "Classic Tee", zh: "经典款 T 恤" },
tidePolo: { en: "Signature Polo", zh: "日常款 Polo 衫" },
signatureHoodie: { en: "Everyday Hoodie", zh: "日常抓绒卫衣" },
classicHoodie: { en: "Classic Hoodie", zh: "经典款卫衣" },
signatureLayout: { en: "Small left-chest whale, name and website", zh: "左胸小鲸、名字与网址" },
classicLayout: { en: "Centered whale, name and website", zh: "正中小鲸、名字与网址" },
poloLayout: { en: "Small left-chest signature, clear of the buttons", zh: "左胸小标志,避开纽扣" },
cottonDirection: { en: "Solid cotton tee with a simple flat print", zh: "纯色棉 T 恤,搭配简洁单色印花" },
poloDirection: { en: "Solid stretch-polyester polo with a small flat print", zh: "纯色聚酯弹力 Polo 衫,搭配小印花" },
hoodieDirection: { en: "Heavier polyester fleece; solid body and a small flat print", zh: "较厚聚酯抓绒,纯色衣身搭配小印花" },
paletteNote: { en: "Everyday solids, one blue wave-and-stars hoodie, and one Tide gradient tee. Only Tide uses Lagoon (aqua → navy) or Daybreak (ivory → cobalt). Colors are concepts; exact fabric, variants and print need samples.", zh: "日常纯色、一款蓝色海浪星星卫衣,以及一款海潮渐变 T 恤。只有海潮款采用浅海配色(浅水蓝 → 深蓝)或晨光配色(象牙白 → 钴蓝)。配色为概念,具体面料、款式和印花需用实物样品确认。" },
fitLink: { en: "Compare garment measurements", zh: "对比成衣尺寸" },
launchLink: { en: "Explore the full lineup", zh: "看看完整系列" },
archiveTitle: { en: "More layouts and earlier studies", zh: "其他版式与之前的设计" },
archiveBody: { en: "These print studies remain useful references. The White cotton tee price review below belongs to the earlier configured garment; it does not price every color or the polo, hoodie and ombré tee.", zh: "这些印花版式保留作参考。下方白色棉 T 恤的价格确认属于之前配置的服饰方案,不代表所有配色、Polo 衫、卫衣或渐变 T 恤的价格。" },
preparing: { en: "Preparing the first drop. Samples before sales.", zh: "首批周边筹备中,先确认实物样品,再开售。" },
merch: { en: "Merch", zh: "周边" },
browse: { en: "Explore the tees", zh: "看看 T 恤" },
heroTitle: { en: "Codewhale Signature", zh: "Codewhale 日常款" },
heroBody: { en: "A little whale beside the name. Small on the chest, easy to wear every day.", zh: "名字旁边,一只小鲸。胸前小小的图案,日常穿着刚刚好。" },
heroAlt: { en: "Six everyday Codewhale shirt concepts: Signature, Wordmark, Classic, Field, Ocean line and use codewhale., with different colors and print placements.", zh: "六款日常 Codewhale T 恤概念:日常款、字标款、经典款、前后印款、海平线款和 use codewhale.,展示不同颜色与印花位置。" },
collection: { en: "Codewhale, every day.", zh: "日常穿 Codewhale。" },
collectionBody: { en: "Small chest prints, clean wordmarks and the classic whale. Start with Signature or Classic; the other layouts give you more ways to wear Codewhale.", zh: "胸前小图案、简洁字标,还有经典的小鲸。先看看日常款和经典款,再选一种你喜欢的 Codewhale 穿法。" },
colorNote: { en: "Color studies above; ordering begins with reviewed white-tee prints. Field has two print placements and needs a separate quote.", zh: "上图为配色探索;订购先从确认过的白色 T 恤印花开始。前后印款有两个印花位置,需要单独报价。" },
classicTitle: { en: "Codewhale Classic", zh: "Codewhale 经典款" },
classicBody: { en: "The whale and the name, centered. A familiar Codewhale tee for a meetup or an ordinary Tuesday.", zh: "小鲸和名字,放在正中间。去聚会,或普通的一天,都可以穿 Codewhale。" },
wordmarkTitle: { en: "Codewhale Wordmark", zh: "Codewhale 字标款" },
wordmarkBody: { en: "Just Codewhale and the website. Clean type, no extra story.", zh: "只印 Codewhale 和网址。简洁的字,不必多说。" },
oceanTitle: { en: "Ocean line", zh: "海平线款" },
oceanBody: { en: "A small whale, the name, one quiet line. Plenty of space around it.", zh: "一只小鲸,一个名字,一条安静的线。四周,留一点空白。" },
pocketTitle: { en: "use codewhale. · small chest print", zh: "use codewhale. · 胸前小印花" },
pocketBody: { en: "The invitation, kept small. Whale, words and website on the left chest.", zh: "把邀请,印得小一点。小鲸、文字和网址,都在左胸。" },
community: { en: "A little more personality.", zh: "再添一点个性。" },
communityBody: { en: "Whale Bro, a build-time joke, or a larger use codewhale. print. The same simple apparel direction.", zh: "鲸鱼兄弟、编译时的笑话,或更大的 use codewhale. 印花。依然是简洁的日常服饰。" },
whaleBroTitle: { en: "Whale Bro", zh: "鲸鱼兄弟" },
whaleBroBody: { en: "The community nickname, with a small whale and Codewhale signature.", zh: "社区里的昵称,配一只小鲸和 Codewhale 的署名。" },
select: { en: "Review this tee", zh: "查看这款 T 恤" },
selected: { en: "Selected design", zh: "已选图案" },
targetPrice: { en: "Target tee price: ¥{cny} in mainland China · US${usd} elsewhere", zh: "T 恤目标售价:中国大陆 ¥{cny} · 其他地区 US${usd}" },
priceNote: { en: "Shipping is separate. Final pricing depends on the approved print; delivery and tax notes are reviewed before payment.", zh: "运费单独显示。最终价格以确认的印刷方案为准,付款前会说明配送与税费情况。" },
designNote: { en: "Artwork previews are concepts. Print details, fit and availability need a production proof.", zh: "图案预览为设计概念,印刷细节、版型与供货需要生产样品确认。" },
compilingTitle: { en: "Compiling…", zh: "编译中……" },
compilingBody: { en: "Quiet type for the time between builds.", zh: "把编译的等待,留给一行安静的文字。" },
useTitle: { en: "use codewhale.", zh: "use codewhale." },
useBody: { en: "The whale mark and a straightforward invitation. One color, a few words.", zh: "鲸鱼标记,加上一句直接的邀请。一种颜色,几句简单的话。" },
contributorTitle: { en: "Built together. Offered at cost.", zh: "一起构建,按成本提供。" },
contributorBody: { en: "Pick a phrase in one of Codewhale's languages. Add a GitHub username, user ID or contribution link; it stays off the print. Production, processing and postage are shown before payment.", zh: "用 Codewhale 的一种语言,选一句想穿在身上的话。填写 GitHub 用户名、用户 ID 或贡献链接,这些信息不会印在衣服上。付款前,制作、支付和运费都会列明。" },
contributorLink: { en: "Choose your contributor tee", zh: "选择贡献者 T 恤" },
contributorFlowNote: { en: "Choose your print text and review the local draft first. Delivery details are sent only when you request a delivery price.", zh: "先选择印字并查看本地草稿。仅在请求配送价格时发送配送信息。" },
contributorArtNote: { en: "Simple print text in Codewhale's languages. The contributor page previews your selected phrase.", zh: "用 Codewhale 的语言,印上简单的文字。贡献者页面会预览你选择的短语。" },
seasonal: { en: "A moment on tour.", zh: "巡游中的一刻。" },
seasonalBody: { en: "The China Tour design sits beside the everyday collection. Seasonal artwork is being reviewed; ordering is not open for this edition.", zh: "中国巡游图案与日常系列并肩。季节限定设计仍在确认,这一款暂未开放订购。" },
deskTitle: { en: "Detailed art for the desk.", zh: "复杂的故事,留给桌面。" },
deskBody: { en: "Stage Dive, cache jokes and developer characters, with room for color and detail. Mousepads, deskmats and stickers need print, texture and durability samples before pricing or orders.", zh: "舞台跃动、缓存笑话与开发者角色,让色彩与细节展开。鼠标垫、桌垫与贴纸先确认印刷、手感与耐用度,再定价和开放订购。" },
stageTitle: { en: "Open source. Open mic.", zh: "开源,也开麦。" },
stageBody: { en: "Concert energy for a deskmat or sticker. The detailed Stage Dive artwork is being adapted for the desk.", zh: "把开源演出的热闹,放到桌垫和贴纸上。舞台跃动的复杂图案,正在改为桌面周边。" },
stageAlt: { en: "Codewhale desk gear concepts with Stage Dive concert artwork, ocean currents and developer stickers.", zh: "Codewhale 桌面周边概念:舞台演出图案、海流和开发者贴纸。" },
cacheTitle: { en: "Cache me if you can.", zh: "Cache me if you can." },
cacheAlt: { en: "Flat mousepad artwork with a whale, ocean currents and Cache me if you can lettering.", zh: "鼠标垫平面图案:鲸鱼、海流与 Cache me if you can 字样。" },
deskmat: { en: "Deskmat", zh: "桌垫" },
mousepad: { en: "Mousepad", zh: "鼠标垫" },
stickers: { en: "Stickers", zh: "贴纸" },
deskAlt: { en: "Detailed Codewhale mousepad, deskmat and sticker artwork concepts.", zh: "Codewhale 鼠标垫、桌垫与贴纸的复杂图案设计概念。" },
later: { en: "Still in the sketchbook.", zh: "还在画册里。" },
laterBody: { en: "Little things to take with you. These accessory concepts are still being explored.", zh: "能随身带走的小物。这些配件图案仍在探索中。" },
concept: { en: "Concept · not for sale", zh: "设计概念 · 暂不售卖" },
plushTitle: { en: "A Codewhale companion for your desk.", zh: "桌边的 Codewhale 毛绒伙伴。" },
plushBody: { en: "A 20cm seated doll with an embroidered face, layered navy-to-aqua hair, soft whale clip and tail, and a navy-and-white outfit. Part of the planned collection; a physical prototype comes before preorders.", zh: "20 厘米坐姿娃娃,刺绣面孔、深蓝到浅水蓝的层叠头发、柔软的鲸鱼发夹与尾巴,搭配蓝白服装。它已加入计划中的系列,先做实物样品,再开放预订。" },
plushStatus: { en: "In development · prototype planned", zh: "筹备中 · 实物打样计划" },
plushAlt: { en: "Codewhale 20cm seated plush doll concept in front, three-quarter and back views: embroidered blue eyes, layered navy-to-aqua hair, whale clip and tail, and navy-and-white outfit.", zh: "Codewhale 20 厘米坐姿毛绒娃娃概念,展示正面、侧面与背面:蓝色刺绣眼睛、深蓝至浅水蓝的层叠头发、鲸鱼发夹与尾巴,以及蓝白服装。" },
plushLink: { en: "Meet the plush doll", zh: "看看毛绒娃娃" },
accessoriesTitle: { en: "Little companions", zh: "随身小伙伴" },
hoodieTitle: { en: "The same little whale. A simple hoodie.", zh: "还是那只小鲸,换一件简单的卫衣。" },
hoodieBody: { en: "A small whale mark and quiet type on a plain hoodie. Fabric, fit and print are sampled before ordering opens.", zh: "纯色卫衣上,印一只小鲸和简单的字。先用实物样品确认面料、版型与印刷,再开放订购。" },
deliveryTitle: { en: "Your tee, your destination.", zh: "你的 T 恤,寄到你的目的地。" },
deliveryBody: { en: "Choose a country and enter the delivery address. Review the approved destination shipping rate, total and tax note before continuing to payment.", zh: "选择国家或地区并填写配送地址。前往付款前,先查看经确认的目的地运费、合计与税费说明。" },
destination: { en: "Delivery country or region", zh: "配送国家或地区" },
size: { en: "Size", zh: "尺码" },
white: { en: "White cotton tee · one item", zh: "白色纯棉 T 恤 · 一件" },
name: { en: "Recipient name", zh: "收件人姓名" },
line1: { en: "Street address", zh: "街道地址" },
line2: { en: "Apartment, building or unit (optional)", zh: "楼栋、单元、房间号(选填)" },
city: { en: "City", zh: "城市" },
region: { en: "State, province or region", zh: "州、省或地区" },
postalCode: { en: "Postal code", zh: "邮政编码" },
phone: { en: "Delivery phone number", zh: "配送联系电话" },
addressNote: { en: "This address is used for delivery checks and fulfillment. Changing it requires a new price review.", zh: "此地址用于配送确认与履约。修改地址后需重新确认价格。" },
reviewDelivery: { en: "Review delivery price", zh: "确认配送价格" },
reviewing: { en: "Reviewing delivery…", zh: "正在确认配送……" },
notReady: { en: "Samples before sales. Ordering is not open for this design yet; no payment will be taken.", zh: "先确认样品,再开售。此图案尚未开放订购,不会收取付款。" },
checking: { en: "Checking ordering availability…", zh: "正在确认是否可订购……" },
statusError: { en: "Ordering availability could not be checked. Please try again later.", zh: "暂时无法确认是否可订购,请稍后重试。" },
quoteError: { en: "We couldn't review delivery for this address. Check the details and try again.", zh: "无法确认此地址的配送价格,请检查信息后重试。" },
routeUnavailable: { en: "This design and destination still need a print and delivery review.", zh: "此图案与目的地仍需完成印刷及配送确认。" },
tryLater: { en: "Please wait a minute, then try again.", zh: "请稍等一分钟后重试。" },
checkoutError: { en: "Payment could not be opened. Your order has not been placed. Try again or review delivery again.", zh: "无法打开付款页面,订单尚未提交。请重试或重新确认配送价格。" },
expired: { en: "This price review has expired. Please review delivery again before payment.", zh: "此次价格确认已过期,付款前请重新确认配送价格。" },
merchandise: { en: "Printed tee", zh: "印花 T 恤" },
shipping: { en: "Destination shipping rate", zh: "目的地运费" },
processing: { en: "Payment processing", zh: "支付手续费" },
total: { en: "Total before any charges noted below", zh: "合计(下方另有说明的费用除外)" },
quoteTitle: { en: "Review before you pay.", zh: "付款前,确认一下。" },
payment: { en: "Continue to secure payment", zh: "前往安全付款" },
opening: { en: "Opening payment…", zh: "正在打开付款……" },
returnToMerch: { en: "Back to the collection", zh: "返回周边系列" },
} as const;
export type StorefrontCopy = Record<keyof typeof text, string>;
export function getStorefrontCopy(locale: string): StorefrontCopy {
const copy = Object.fromEntries(Object.entries(text).map(([key, value]) => [key, pickText(value, locale)])) as StorefrontCopy;
const number = new Intl.NumberFormat(locale, { maximumFractionDigits: 2 });
copy.targetPrice = copy.targetPrice.replace("{cny}", number.format(MERCH_TARGET_PRICE.cny / 100)).replace("{usd}", number.format(MERCH_TARGET_PRICE.usd / 100));
return copy;
}
export function countryLabel(code: string, fallback: string, locale: string): string {
try { return new Intl.DisplayNames([locale], { type: "region" }).of(code) ?? fallback; }
catch { return fallback; }
}
+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { contributorCost, validateContributorIdentity } from "./contributor-merch";
import { MERCH_COPY, MERCH_LANGUAGES } from "./content/contributor-merch";
import { locales } from "./i18n/config";
describe("contributor at-cost estimate", () => {
it("covers supplier production, separate postage and processing without a profit or reserve", () => {
const quote = contributorCost();
expect(quote.total).toBe(45.53);
expect(quote.merchandise).toBe(38.09);
expect(quote.shipping).toBe(7.44);
expect(quote.production + quote.processing + quote.shipping).toBeCloseTo(quote.total, 10);
const afterProcessorUsd = quote.total / 6.7 * .961 - .30;
expect(afterProcessorUsd).toBeGreaterThanOrEqual(5.12 + 1.11);
expect(afterProcessorUsd - (5.12 + 1.11)).toBeLessThan(.01 / 6.7);
});
it("grosses up different supplier and payment costs rather than adding a hidden margin", () => {
const quote = contributorCost({ production: 4, shipping: 2, rate: .05, flat: .2, fx: 7 });
const expected = Math.ceil((4 + 2 + .2) / .95 * 7 * 100) / 100;
expect(quote.total).toBe(expected);
expect(quote.shipping).toBe(14);
});
it.each([{ fx: 0 }, { rate: 1 }, { shipping: -1 }, { production: Number.NaN }])("rejects invalid cost inputs %o", (inputs) => {
expect(() => contributorCost(inputs)).toThrow(RangeError);
});
});
describe("contributor identity context", () => {
it.each(["octocat", "@octocat", "1234567", "a-name"])("accepts identity alone: %s", (github) => {
expect(validateContributorIdentity({ github, contribution: "" })).toBeNull();
});
it("accepts a contribution URL without requiring a GitHub identity or a merged-PR threshold", () => {
expect(validateContributorIdentity({ github: "", contribution: "https://github.com/Hmbown/CodeWhale/issues/1" })).toBeNull();
expect(validateContributorIdentity({ github: "", contribution: "https://example.org/documentation-improvement" })).toBeNull();
});
it("requires one identity reference", () => {
expect(validateContributorIdentity({ github: " ", contribution: " " })).toBe("missing");
});
it.each(["a--b", "-name", "name-", "a b"])("rejects malformed identity: %s", (github) => {
expect(validateContributorIdentity({ github, contribution: "" })).toBe("badIdentity");
});
it.each(["javascript:alert(1)", "http://example.org", "https://user:[email protected]", "not-a-url"])("rejects unsafe or malformed URL: %s", (contribution) => {
expect(validateContributorIdentity({ github: "", contribution })).toBe("badUrl");
});
});
describe("contributor languages and print text", () => {
it("covers all currently routed website languages and Traditional Chinese", () => {
const languages = MERCH_LANGUAGES.map((v) => v.code);
expect(languages).toHaveLength(19);
expect(new Set(languages).size).toBe(languages.length);
expect(languages).toEqual(expect.arrayContaining([...locales, "zh-TW"]));
for (const language of languages) {
const copy = MERCH_COPY[language];
expect(Object.keys(copy).sort()).toEqual(Object.keys(MERCH_COPY.en).sort());
expect(Object.values(copy).every((value) => typeof value === "string" ? value.length > 0 : value.length === 3)).toBe(true);
expect(copy.phrases[0]).toContain("Codewhale");
expect(copy.phrases[2]).toContain("Codewhale");
if (language !== "en") expect(copy.title).not.toBe(MERCH_COPY.en.title);
}
});
});
+32
View File
@@ -0,0 +1,32 @@
/** Local contributor draft only: no eligibility check, payment or vendor order. */
export type ContributorIdentity = { github: string; contribution: string };
export type IdentityError = "missing" | "badIdentity" | "badUrl";
export function validateContributorIdentity(value: ContributorIdentity): IdentityError | null {
const github = value.github.trim().replace(/^@/, "");
const contribution = value.contribution.trim();
if (!github && !contribution) return "missing";
if (github && (!/^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,37}[a-zA-Z0-9])?$/.test(github) || github.includes("--"))) return "badIdentity";
if (contribution) {
try {
const url = new URL(contribution);
if (contribution.length > 2048 || url.protocol !== "https:" || url.username || url.password) return "badUrl";
} catch { return "badUrl"; }
}
return null;
}
export const CONTRIBUTOR_CATALOG_SOURCE = "https://www.yoycol.com/product_detail/DJCTX/Unisex-O-neck-Short-Sleeve-T-shirt-180GSM-Cotton-DTF";
/** Catalog-floor estimate; artwork, tax, address and real FX are still unquoted. */
export function contributorCost({ production = 5.12, shipping = 1.11, rate = .039, flat = .30, fx = 6.70 } = {}) {
if (![production, shipping, rate, flat, fx].every(Number.isFinite) || production < 0 || shipping < 0 || flat < 0 || rate < 0 || rate >= 1 || fx <= 0) throw new RangeError("Invalid cost inputs");
const productionCny = production * fx;
const shippingCny = shipping * fx;
const unrounded = (productionCny + shippingCny + flat * fx) / (1 - rate);
// Round the whole charge upward to avoid rounding below processing-inclusive cost.
const total = Math.ceil((unrounded - Number.EPSILON) * 100) / 100;
const postage = Math.round(shippingCny * 100) / 100;
const made = Math.round(productionCny * 100) / 100;
return { production: made, shipping: postage, processing: Math.round((total - made - postage) * 100) / 100, total, merchandise: Math.round((total - postage) * 100) / 100, fx, rate, flat };
}
+12 -2
View File
@@ -3,13 +3,14 @@
* Falls back to in-memory cache for `next dev` outside of `wrangler dev`.
*/
import type { CuratedDispatch } from "./types";
import type { MerchEnv } from "./merch/types";
const MEM = new Map<string, string>();
export interface KVNamespace {
get(key: string): Promise<string | null>;
put(key: string, value: string, opts?: { expirationTtl?: number }): Promise<void>;
list(opts?: { prefix?: string; limit?: number }): Promise<{ keys: { name: string }[] }>;
list(opts?: { prefix?: string; limit?: number; cursor?: string }): Promise<{ keys: { name: string }[]; cursor?: string; list_complete?: boolean }>;
delete(key: string): Promise<void>;
}
@@ -19,8 +20,10 @@ export interface KVStreamNamespace {
put: KVNamespace["put"];
}
interface CloudflareEnv {
interface CloudflareEnv extends MerchEnv {
CURATED_KV?: KVNamespace & KVStreamNamespace;
MERCH_INTEREST_LIMITER?: { limit(options: { key: string }): Promise<{ success: boolean }> };
MERCH_INTEREST_SITE_ORIGIN?: string;
DEEPSEEK_API_KEY?: string;
DEEPSEEK_BASE_URL?: string;
DEEPSEEK_MODEL?: string;
@@ -34,6 +37,13 @@ interface CloudflareEnv {
function envFromProcess(): CloudflareEnv {
return {
MERCH_CONFIG_JSON: process.env.MERCH_CONFIG_JSON,
MERCH_INTEREST_SITE_ORIGIN: process.env.MERCH_INTEREST_SITE_ORIGIN,
MERCH_STRIPE_KEY: process.env.MERCH_STRIPE_KEY,
MERCH_STRIPE_WEBHOOK_SECRET: process.env.MERCH_STRIPE_WEBHOOK_SECRET,
MERCH_PAYMENT_CONFIGURATION: process.env.MERCH_PAYMENT_CONFIGURATION,
YOYCOL_ACCESS_KEY: process.env.YOYCOL_ACCESS_KEY,
YOYCOL_SECRET_KEY: process.env.YOYCOL_SECRET_KEY,
SUPABASE_URL: process.env.SUPABASE_URL,
SUPABASE_PUBLISHABLE_KEY: process.env.SUPABASE_PUBLISHABLE_KEY,
DEEPSEEK_API_KEY: process.env.DEEPSEEK_API_KEY,
+66
View File
@@ -0,0 +1,66 @@
# Merch integration
Source preparation for `/[locale]/merch` and `/[locale]/merch/contributor`. The collection includes an independent merch-interest form; checkout, supplier ordering and live deployment remain inactive. Payment configuration defaults to unavailable. The footer links to the collection.
## Merch interest and price feedback
`GET /api/merch/interest` reports availability; `POST` records an explicit opt-in only when real `CURATED_KV` and `MERCH_INTEREST_LIMITER` bindings are available. This works independently of Stripe, Yoycol and `MERCH_DB`; it neither creates nor prices an order. The native limiter is configured as 5 requests/minute under namespace 913002. A normal Next server without bindings leaves signup disabled. There is no in-memory success path.
The form offers 12 item choices: four cotton tees, an ombré polyester/spandex tee, a polyester/spandex polo, heavier polyester fleece and lighter scuba hoodies, mousepad, deskmat, stickers and the planned doll. Materials remain candidates until sample verification. CNY/USD price ladders are optional research answers, with shipping/tax extra; they never replace the disabled commerce quote. Contributor merch remains a separate at-cost flow.
Records are private, unverified email opt-ins under `private:merch-interest:v1:<email-sha256>`. Each submission replaces that email's choices and expires after 180 days. Email, country/region, consent, locale, currency and validated item/budget choices are stored; no address/payment is requested. `GET /api/admin/merch/interest?limit=50` and JSON `DELETE /api/admin/merch/interest` with `{ "id": "email-hash-from-list" }` use the existing maintainer-token/session authentication. Deletion also requires the allowed site Origin. No public list/count or email delivery service is connected; future mail must include working opt-out handling.
Production origins are `https://codewhale.net` and `https://www.codewhale.net`. `MERCH_INTEREST_SITE_ORIGIN` can explicitly select a reviewed origin, including confined local preview. Do not add public HTTP origins or arbitrary CORS. See [shared research choices](./interest-options.ts), [signup and administrative handlers](./interest.ts), and [validation tests](./interest.test.ts).
## What is implemented
- The storefront presents eight apparel styles: six everyday options plus Mascot Tee and Ocean Wave Mascot Hoodie. Six styles are solid; the lead mascot hoodie has cobalt waves/stars and a White alternative; Tide alone is ombré. Each has two proposed options, sixteen apparel combinations before sizes. The original blue wave-and-stars board leads the visible mascot section. A separate, prominent 20cm plush section follows it, with prototype-planned status; the catalog keeps kind concept and classifies it as prototype, so no doll ordering is enabled. Cotton leads solid tees, stretch polyester the polo/gradient tee, 230gsm scuba the budget wave hoodie, and 320gsm polyester fleece the Everyday/Signature hoodie. All colors and exact print/fabric outcomes remain concepts awaiting supplier quotes and samples. No new variant or sale activation is configured.
- A centimetre-first, optional-inch size guide contains 64 exact rows from seven official supplier charts, defaulting to DJCTX solid cotton DTF. Separate AOP cotton/stretch tees, polo, scuba/polyester-fleece/cotton-loopback hoodie charts remain. Raw supplier Bust labels remain unchanged; its measurement method and flagged anomalies need clarification. Garment measurements do not activate purchase variants or establish body-size recommendations.
- Eight earlier single-front plain-tee candidates remain under an archive disclosure: Signature, Classic, Wordmark, Ocean line, use codewhale pocket, Whale Bro, use codewhale. and Compiling. The earlier core board also includes Field, a two-placement concept excluded from Checkout. The old disabled quote route supports White cotton tees; its prices do not price the current mixed collection. Colored garments remain studies.
- Contributor flow accepts GitHub identity **or** contribution URL; hoodie/deskgear/seasonal/plush concepts remain non-purchasable. Contributor text has 19 language choices; native-speaker print review and a separate saved design are required for each offered language/phrase.
- Apparel uses quiet logo/text branding alongside one self-contained mascot badge. Six styles keep solid bodies, the mascot hoodie adds waves/stars with a White alternative, and Tide remains the one gradient tee. Pattern and gradient routes need their own AOP process and quote. The existing mascot source is 1,254px square, about 159ppi at 20cm before margins, and is not a verified 300ppi print master. DJCTX no-gradient guidance needs print-compatibility review for the shaded badge; its simple-print price cannot quote mascot variants. The wave hoodie uses the cheaper 3PH 230gsm scuba candidate; the Everyday/Signature hoodie remains 3TPH 320gsm fleece. Both supplier candidates require complete AOP panels and list a black inner hood, differing from the White concept lining. These renderings do not quote the finished products. The complex Stage Dive narrative art belongs to desk gear. Retail quotes must retain at least 30% of merchandise revenue after recorded production, postage, modeled processing fees and a 5% merchandise reserve. This is a guard against recorded costs, not a profit guarantee. Contributor prices have neither this margin floor nor a reserve.
- Server-only Yoycol V4 HMAC, fixed HTTPS destination, bounded responses, exact SKU/region/service lookup. Regional first/additional-piece rates are not an exact-address, mixed-cart or tax quote. One design/size per checkout, quantity up to five; no mixed-product cart.
- Fifteen-minute stored quotes bind variant, design, quantity, full address, currency, cost and shipping. Browser prices are ignored. Approved postal prefixes bound the first rollout; unsupported/remote destinations are blocked. Do not enable a prefix without checking all state/city/address exclusions. The merchant must cover any freight difference inside accepted coverage or obtain a separate reviewed quote; never charge an unresolved delivery price.
- A changed supplier mapping or country review, including shipping service, cost, fees, FX, address coverage or tax wording, invalidates outstanding website quotes before opening or reusing Checkout. Sessions already opened in Stripe retain their frozen terms until their session expiry; pausing sales does not cancel those sessions automatically.
- Stripe SDK 23.0.0 hosted Checkout with separate shipping, stable idempotency, a dedicated payment-method configuration and a frozen delivery address. A session expires 30 minutes after its quote expiry, satisfying Stripe's minimum while the website quote remains usable for 15 minutes. Address edits require a new quote. Return URLs confer no payment authority.
- Delivery is a separate ordinary Checkout line item, not `shipping_options`/`shipping_cost`; the server supplies PaymentIntent shipping and the supplier uses the stored address. This avoids relying on unverified shipping-options behavior without Stripe's address collector. Reconcile delivery from the stored quote/line item, not Stripe's `amount_shipping` field. Contributor processing is disclosed on the website and included in the printed item's cost-recovery price at Checkout.
- Raw-body signature verification, retrieved session amount/currency/product reconciliation, mode checks, and durable SQLite/D1 payment receipts. Completed-but-unpaid events do not fulfill; delayed successes can record a paid receipt. Duplicate events cannot create another supplier order. Pausing new sales leaves webhooks active.
- Maintainer-authenticated manual preview, supplier submission, ambiguity reconciliation and raw tracking refresh. A compare-and-set lock precedes the supplier POST. Ambiguous writes are held, never blindly retried. Customer payment, supplier creation, supplier funding, production, shipment and delivery remain separate evidence.
## Bindings and secrets to configure after approval
Use a **separate D1 database**, bound as `MERCH_DB`, with `web/migrations/merch/0001_merch.sql`. Do not apply this schema to a product balance store or community KV. Add a Cloudflare rate-limit binding named `MERCH_RATE_LIMITER` using an unused namespace ID and a reviewed limit. Neither binding is silently replaced by an in-memory production store.
Configure server secrets `MERCH_STRIPE_KEY`, `MERCH_STRIPE_WEBHOOK_SECRET`, `YOYCOL_ACCESS_KEY`, `YOYCOL_SECRET_KEY`; server configuration `MERCH_PAYMENT_CONFIGURATION` and `MERCH_CONFIG_JSON`. Keep keys out of browser bundles, committed config, logs and screenshots. The example JSON contains **unverified fee/FX planning assumptions**, blank identifiers and disabled gates; it is not production configuration.
Use an approved Stripe account and a separate sandbox first. Create a dedicated Codewhale merch payment-method configuration with cards and eligible Alipay enabled; this avoids changing other Codewhale purchase flows. Pass its real `pmc_` ID. Dynamic methods display only eligible methods for account/currency/location; the source does not prove Alipay is enabled. Account-specific implementation planning is blocked until the Stripe connector reauthenticates. Register `/api/merch/webhook` for completed, asynchronous success/failure and expired Checkout events, then verify the exact endpoint secret and mode. The SDK uses its current default API version; pin/change versions only after account compatibility review.
Enable each country only after its saved design, SKU/cost, currency of supplier rates, postcode coverage and tax/import-charge responsibility are verified. Do not copy a China rate to another country or a heavier tee’s rate onto DJCTX. The earlier plain-tee quote UI describes a white 180gsm cotton tee; mappings must use that approved garment. Ombré, polo, wave/scuba and fleece routes need their own product types, variants and pricing before ordering. Doll development is a separate manufacturing route; no apparel quote or payment eligibility extends to it. A different regional blank requires visible material, measurements and origin disclosure before activation. No EU, US import or worldwide DDP assumption is built in.
The product-country record's scalar `productionCostUsd` must be the accepted final print/variant cost for every size/design mapped in that record. Do not populate several sizes with different costs behind one scalar, particularly for the at-cost edition. Restrict offered mappings to confirmed equal-cost variants or extend the adapter to individual variant/print costs before offering others.
**Tax limitation:** this first adapter does not calculate/remit retail sales tax, VAT or customs. `taxReviewed` means the merchant has established that the offered fixed merchandise/production amount and disclosed external charges satisfy the actual route; it is not a tax engine. Keep any jurisdiction needing additional collected tax disabled until that calculation, invoice/remittance and contributor cost treatment are implemented and reviewed. Never turn a tax disclosure into an exemption claim. Contributor fees use reviewed planning rates, not knowledge of the customer's final payment rail; reconcile actual expenses and refund any cost recovery excess after settlement under a clear policy.
## Manual fulfillment
`POST /api/admin/merch` reuses the existing maintainer session (`mt_sid`) and requires the exact configured Origin and JSON content type. It does not expand community-agent automation. Supported action bodies:
```json
{ "action": "list" }
{ "action": "preview", "orderId": "stored-quote-uuid" }
{ "action": "submit", "orderId": "stored-quote-uuid", "confirmation": "CREATE_UNPAID_SUPPLIER_ORDER" }
{ "action": "reconcile", "orderId": "stored-quote-uuid", "supplierOrderId": "actual-yoycol-id" }
{ "action": "refresh", "orderId": "stored-quote-uuid" }
```
Preview/list are read-only. Submission additionally requires live mode and `supplierSubmissionEnabled:true`; test Stripe payments cannot create real supplier orders. Inspect the frozen address/name mapping and final saved print in Yoycol before enabling. Submission records `supplier_cost_review`: review actual amount/shipping/taxes and separately fund the supplier in its Dashboard after the concrete order is approved. The public API has no verified supplier-pay operation or automatic-funding contract for this independent API Store. This code never pays Yoycol.
If submission crashes or times out, use the order's `CW-<uuid>` external reference to inspect Yoycol manually. Do not switch the state back to pending. Reconcile only a verified supplier order ID; the handler retrieves and matches the external reference before recording it. `refresh` records supplier receipt/tracking without guessing undocumented numeric status codes. Refund/customer notification handling and an operator UI remain manual; no emails or partner contacts are sent by these routes.
Quotes contain delivery PII and contributor context in durable server storage. Limit maintainer access and set a documented retention/deletion policy before launch; no analytics event or supplier request receives contributor identity. Operate backups, refund/reconciliation review and working capital separately from Stripe payout timing.
## Proof boundary
Focused tests use real SQLite constraints/transactions, the actual Stripe SDK with signed fixture webhooks, and mocked network responses. They prove local behavior; they do not prove account authentication, deployed D1, live Stripe/Alipay, Yoycol mapping/order acceptance, tax obligations, physical print quality or international delivery. Browser access was denied by the desktop administration-policy check; no newer storefront screenshot is claimed.
Primary contracts: [Yoycol API](https://www.yoycol.com/api/2025/redoc), [Stripe Checkout](https://docs.stripe.com/api/checkout/sessions/create), [Stripe fulfillment](https://docs.stripe.com/checkout/fulfillment), [Alipay](https://docs.stripe.com/payments/alipay), [payment-method configurations](https://docs.stripe.com/payments/payment-method-configurations).
+40
View File
@@ -0,0 +1,40 @@
/** Public launch selection. Prices are targets until an approved destination rate is quoted. */
export const MERCH_SIZES = ["S", "M", "L", "XL", "2XL", "3XL"] as const;
export const MERCH_PRODUCTS = [
{ id: "signature", title: "Codewhale Signature", caption: "Small left-chest signature", image: "/merch/signature-core.svg", kind: "regular", collection: "launch" },
{ id: "classic", title: "Codewhale Classic", caption: "The whale and the name", image: "/merch/classic-core.svg", kind: "regular", collection: "launch" },
{ id: "wordmark", title: "Codewhale Wordmark", caption: "Just the name", image: "/merch/wordmark-core.svg", kind: "regular", collection: "launch" },
{ id: "ocean-line", title: "Ocean line", caption: "A quiet horizon", image: "/merch/ocean-line-core.svg", kind: "regular", collection: "launch" },
{ id: "use-pocket", title: "use codewhale.", caption: "Small left-chest print", image: "/merch/use-pocket-core.svg", kind: "regular", collection: "launch" },
{ id: "field", title: "Codewhale Field", caption: "Front and back · separate quote", image: "/merch/field-front-core.svg", kind: "concept", collection: "later" },
{ id: "stage-dive", title: "Open source. Open mic.", caption: "Desk gear · stage dive / deep dive", image: "/merch/developer-desk-kit.png", kind: "concept", collection: "later" },
{ id: "use-codewhale", title: "use codewhale.", caption: "Small whale. Short line.", image: "/merch/use-codewhale-simple.svg", kind: "regular", collection: "launch" },
{ id: "compiling", title: "Compiling…", caption: "Just one more crate.", image: "/merch/compiling-simple.svg", kind: "regular", collection: "launch" },
{ id: "whale-bro", title: "鲸鱼兄弟", caption: "Whale Bro", image: "/merch/whale-bro-simple.svg", kind: "regular", collection: "launch" },
{ id: "contributor", title: "Built together.", caption: "Contributor edition · at cost", image: "/merch/open-ocean-patchwork.png", kind: "contributor", collection: "launch" },
{ id: "china-tour", title: "China Tour 2026", caption: "October 15–19 · Datawhale", image: "/merch/china-tour.png", kind: "regular", collection: "seasonal" },
{ id: "ocean-ombre", title: "Ocean ombré", caption: "Aqua to midnight", image: "/merch/ocean-ombre-reversed.png", kind: "concept", collection: "later" },
{ id: "hoodie", title: "Simple whale hoodie", caption: "Small left-chest mark · sample first", image: "/merch/hoodie-simple.svg", kind: "concept", collection: "later" },
{ id: "deskmat", title: "Open source deskmat", caption: "80 × 30 cm · cloth and rubber", image: "/merch/developer-desk-kit.png", kind: "concept", collection: "later" },
{ id: "mousepad", title: "Cache me if you can.", caption: "Mousepad · size to be selected", image: "/merch/developer-desk-kit.png", kind: "concept", collection: "later" },
{ id: "stickers", title: "Developer stickers", caption: "Compiling… · just one more crate", image: "/merch/developer-desk-kit.png", kind: "concept", collection: "later" },
{ id: "plush", title: "Codewhale plush doll", caption: "20 cm seated mascot · prototype planned", image: "/merch/seated-plush-concept.png", kind: "concept", collection: "prototype" },
{ id: "accessories", title: "Little companions", caption: "Tote · mug · stickers", image: "/merch/mascot-accessories.png", kind: "concept", collection: "later" },
] as const;
export type MerchProductId = typeof MERCH_PRODUCTS[number]["id"];
export const MERCH_COUNTRIES = [
{ code: "CN", name: "China" }, { code: "US", name: "United States" },
{ code: "CA", name: "Canada" }, { code: "GB", name: "United Kingdom" },
{ code: "DE", name: "Germany" }, { code: "FR", name: "France" },
{ code: "NL", name: "Netherlands" }, { code: "ES", name: "Spain" },
{ code: "IT", name: "Italy" }, { code: "SE", name: "Sweden" },
{ code: "IE", name: "Ireland" }, { code: "AU", name: "Australia" },
{ code: "NZ", name: "New Zealand" }, { code: "JP", name: "Japan" },
{ code: "KR", name: "South Korea" }, { code: "SG", name: "Singapore" },
{ code: "MY", name: "Malaysia" }, { code: "TH", name: "Thailand" },
{ code: "VN", name: "Vietnam" }, { code: "PH", name: "Philippines" },
{ code: "ID", name: "Indonesia" }, { code: "HK", name: "Hong Kong" },
{ code: "TW", name: "Taiwan" },
] as const;
// Countries are candidates for quoting, not a promise that a particular SKU/carrier serves every address.
export const MERCH_TARGET_PRICE = { cny: 6900, usd: 1500 } as const;
+261
View File
@@ -0,0 +1,261 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DatabaseSync, type SQLInputValue } from "node:sqlite";
import { readFileSync } from "node:fs";
import Stripe from "stripe";
import { handleMerch } from "./server";
import { operatorAction, supplierRequest } from "./operator";
import { parseConfig, reviewedRoute, validateQuoteInput, priceQuote } from "./model";
import { destinationRate, yoycolHeaders, yoycolRequest } from "./yoycol";
import type { MerchConfig, MerchDatabase, MerchEnv, D1Statement, Quote } from "./types";
class Sqlite implements MerchDatabase {
db = new DatabaseSync(":memory:");
constructor() { this.db.exec(readFileSync(new URL("../../migrations/merch/0001_merch.sql", import.meta.url), "utf8")); }
prepare(sql: string): D1Statement {
let values: SQLInputValue[] = [];
const result: D1Statement = {
bind: (...args) => { values = args as SQLInputValue[]; return result; },
first: async <T>() => this.db.prepare(sql).get(...values) as T ?? null,
all: async <T>() => ({ results: this.db.prepare(sql).all(...values) as T[] }),
run: async () => ({ meta: { changes: Number(this.db.prepare(sql).run(...values).changes) } }),
};
return result;
}
async batch(statements: D1Statement[]) {
this.db.exec("BEGIN");
try { const result = []; for (const statement of statements) result.push(await statement.run()); this.db.exec("COMMIT"); return result; }
catch (error) { this.db.exec("ROLLBACK"); throw error; }
}
}
const input = { productId: "whale-bro", size: "XL", color: "White", quantity: 1, country: "US", address: { name: "Sample Tester", line1: "1 Test Street", line2: "", city: "Test City", region: "CA", postalCode: "94107", phone: "+15555550100" } };
function configuration(): MerchConfig {
return { mode: "test", checkoutEnabled: true, supplierSubmissionEnabled: false, siteOrigin: "http://localhost:3187", countries: { US: { regionCode: "US", shippingLevelCode: "FIXTURE", currency: "usd", usdExchangeRate: 1, feeRate: .054, flatFeeUsd: .30, taxReviewed: true, taxNote: "Fixture: import charges excluded.", supplierPricesUsdReviewed: true, addressPolicyReviewed: true, allowedPostalPrefixes: ["941"], blockedPostalPrefixes: ["94199"] } }, products: { "whale-bro": { US: { approved: true, designCode: "fixture-design", skuBySize: { XL: "fixture-sku" }, productionCostUsd: 5.12 } }, contributor: { US: { approved: true, designCode: "", contributorDesigns: { en: ["fixture-en-0", "fixture-en-1", "fixture-en-2"] }, skuBySize: { XL: "fixture-sku" }, productionCostUsd: 5.12 } } } };
}
let db: Sqlite, env: MerchEnv, config: MerchConfig;
let currentSession: Stripe.Checkout.Session;
let sent: { url: string; method: string; body: string; idempotency: string | null }[];
let supplierFailure = false;
beforeEach(() => {
db = new Sqlite(); config = configuration(); sent = []; supplierFailure = false;
env = { MERCH_DB: db, MERCH_RATE_LIMITER: { limit: async () => ({ success: true }) }, MERCH_CONFIG_JSON: JSON.stringify(config), MERCH_STRIPE_KEY: "sk_test_fixture", MERCH_STRIPE_WEBHOOK_SECRET: "whsec_fixture", MERCH_PAYMENT_CONFIGURATION: "pmc_fixture", YOYCOL_ACCESS_KEY: "fixture-key", YOYCOL_SECRET_KEY: "fixture-secret" };
currentSession = { object: "checkout.session", id: "cs_test_fixture", livemode: false, metadata: {}, client_reference_id: "", currency: "usd", amount_total: 1725, payment_status: "unpaid", status: "complete", url: "https://checkout.stripe.com/c/pay/fixture" } as Stripe.Checkout.Session;
vi.stubGlobal("fetch", vi.fn(async (url: URL | string, init?: RequestInit) => {
const u = new URL(String(url)), method = init?.method ?? "GET", body = String(init?.body ?? "");
sent.push({ url: u.href, method, body, idempotency: new Headers(init?.headers).get("idempotency-key") });
if (u.hostname === "www.yoycol.com") {
if (method === "POST") {
if (supplierFailure) throw new Error("ambiguous connection loss");
const value = JSON.parse(body);
return Response.json({ code: "100000", data: { orderId: 42, storeOrderSn: value.storeOrderSn, currency: "USD", orderRealAmount: 7.37, canPay: true } });
}
return Response.json({ code: "100000", data: [{ regionCode: "US", levels: [{ levelCode: "FIXTURE", levelName: "Fixture service", firstPrice: 2.25, additionalPrice: .75 }] }] });
}
if (u.hostname === "api.stripe.com") {
if (method === "POST") {
const data = new URLSearchParams(body);
currentSession.client_reference_id = data.get("client_reference_id");
currentSession.metadata = { merch_order_id: data.get("metadata[merch_order_id]")!, merch_product_id: data.get("metadata[merch_product_id]")! };
currentSession.currency = data.get("line_items[0][price_data][currency]");
currentSession.amount_total = Number(data.get("line_items[0][price_data][unit_amount]")) + Number(data.get("line_items[1][price_data][unit_amount]"));
}
return Response.json(currentSession);
}
throw new Error("No unmocked network is permitted in commerce tests");
}));
});
afterEach(() => { vi.unstubAllGlobals(); db.db.close(); });
function request(action: string, data: unknown, origin = config.siteOrigin) {
return new Request("http://localhost:3187/api/merch/" + action, { method: "POST", headers: { "Content-Type": "application/json", Origin: origin }, body: JSON.stringify(data) });
}
async function quoted() {
const response = await handleMerch(request("quote", input), "quote", env);
expect(response.status).toBe(200);
return response.json() as Promise<Quote>;
}
async function checkout() {
const quote = await quoted();
const response = await handleMerch(request("checkout", { quoteId: quote.quoteId }), "checkout", env);
expect(response.status).toBe(200); return quote;
}
async function event(type = "checkout.session.completed", eventId = "evt_fixture", valid = true, livemode = config.mode === "live") {
const payload = JSON.stringify({ id: eventId, object: "event", livemode, type, created: Math.floor(Date.now()/1000), data: { object: { id: currentSession.id, metadata: currentSession.metadata } } });
const signature = Stripe.webhooks.generateTestHeaderString({ payload, secret: "whsec_fixture" });
return handleMerch(new Request("http://localhost/api/merch/webhook", { method: "POST", body: payload, headers: { "stripe-signature": valid ? signature : "invalid" } }), "webhook", env);
}
function state(id: string) { return db.db.prepare("SELECT payment_state,fulfillment_state FROM merch_orders WHERE id=?").get(id); }
describe("commerce trust boundaries", () => {
it("fails closed without storage, keys or reviewed rates", async () => {
expect(await (await handleMerch(new Request("http://localhost/status"), "status", {})).json()).toEqual({ checkoutConfigured: false, readyProductIds: [] });
expect((await handleMerch(request("quote", input), "quote", {})).status).toBe(503);
config.countries.US.addressPolicyReviewed = false; env.MERCH_CONFIG_JSON = JSON.stringify(config);
expect((await handleMerch(request("quote", input), "quote", env)).status).toBe(409);
expect(sent).toHaveLength(0);
});
it("accepts only a real catalog variant and bounded quantity", () => {
for (const value of [{ ...input, productId: "deskmat" }, { ...input, productId: "field" }, { ...input, quantity: 0 }, { ...input, quantity: 6 }, { ...input, size: "10XL" }, { ...input, color: "Black" }, { ...input, country: "ZZ" }]) expect(() => validateQuoteInput(value)).toThrow();
expect(validateQuoteInput(input).size).toBe("XL");
});
it("rejects remote/unreviewed postcodes and unapproved contributor print languages", () => {
expect(() => reviewedRoute(config, validateQuoteInput({ ...input, address: { ...input.address, postalCode: "94199" } }))).toThrow();
const c = validateQuoteInput({ ...input, productId: "contributor", contributor: { github: "", contribution: "https://github.com/Hmbown/CodeWhale/pull/123", language: "zh", phraseIndex: 1 } });
expect(() => reviewedRoute(config, c)).toThrow();
c.contributor!.language = "en";
expect(reviewedRoute(config, c).designCode).toBe("fixture-en-1");
});
it("rejects non-boolean activation and insecure production origins", () => {
expect(parseConfig(JSON.stringify({ ...config, checkoutEnabled: "true" }))).toBeNull();
expect(parseConfig(JSON.stringify({ ...config, mode: "live" }))).toBeNull();
expect(parseConfig(JSON.stringify({ ...config, siteOrigin: "ftp://localhost" }))).toBeNull();
});
it("requires same origin and a shared rate limit before contacting a supplier", async () => {
expect((await handleMerch(request("quote", input, "https://other.example"), "quote", env)).status).toBe(403);
env.MERCH_RATE_LIMITER = { limit: async () => ({ success: false }) };
expect((await handleMerch(request("quote", input), "quote", env)).status).toBe(429);
expect(sent).toHaveLength(0);
});
it("bounds streamed requests and ignores client prices", async () => {
expect((await handleMerch(request("quote", { ...input, extra: "x".repeat(9000) }), "quote", env)).status).toBe(413);
const response = await handleMerch(request("quote", { ...input, total: 1 }), "quote", env);
expect((await response.json()).total).toBe(1725);
});
it("grosses contributor costs up without a profit reserve", () => {
const c = validateQuoteInput({ ...input, productId: "contributor", contributor: { github: "123456", contribution: "", language: "en", phraseIndex: 0 } });
const priced = priceQuote(c, config.countries.US, 5.12, 2.25);
expect(priced).toEqual({ merchandise: 512, shipping: 225, processing: 74, total: 811 });
expect(priceQuote(validateQuoteInput(input), config.countries.US, 5.12, 2.25).processing).toBe(0);
expect(() => priceQuote(validateQuoteInput(input), config.countries.US, 14, 2.25)).toThrow("price review");
});
it("selects the exact supplier region/service and additional-piece price", () => {
const rates = [{ regionCode: "US", levels: [{ levelCode: "EXP", firstPrice: 12, additionalPrice: 5 }, { levelCode: "STD", firstPrice: 3, additionalPrice: 1 }] }];
expect(destinationRate(rates, "US", "STD", 3).shippingUsd).toBe(5);
expect(() => destinationRate(rates, "CN", "STD", 1)).toThrow();
expect(() => destinationRate(rates, "US", "UNKNOWN", 1)).toThrow();
});
it("pins HTTPS and cannot POST to shipping or follow arbitrary endpoints", async () => {
await expect(yoycolRequest(env, "POST", "/api/2025/open/v4/shipping/levels")).rejects.toThrow();
await expect(yoycolRequest(env, "GET", "https://other.example/")).rejects.toThrow();
expect(sent).toHaveLength(0);
});
it("signs canonical raw sorted query values, independent of URL encoding", () => {
const headers = yoycolHeaders("GET", "/api/2025/open/v4/shipping/sku_quotes", { z: "two words", a: "x/y" }, "key", "secret", "1700000000000", "0123456789abcdef0123456789abcdef");
expect(headers["X-API-Signature"]).toBe("ojijq4/UJG5lcMwoYJXopBrxYJ8LTYICrzmKmiIE0jE=");
});
});
describe("checkout and durable payment receipts", () => {
it("creates a 15-minute quote and a stable idempotent session with separate shipping", async () => {
const quote = await checkout();
expect(quote.expiresAt-Date.now()).toBeGreaterThan(14*60*1000);
expect(quote.expiresAt-Date.now()).toBeLessThanOrEqual(15*60*1000);
const creation = sent.find(r => r.url.includes("stripe.com") && r.method === "POST")!;
expect(creation.idempotency).toBe("codewhale-merch:" + quote.quoteId);
const params = new URLSearchParams(creation.body);
expect(params.get("payment_method_configuration")).toBe("pmc_fixture");
expect(params.get("line_items[1][price_data][unit_amount]")).toBe("225");
expect(params.get("line_items[1][price_data][product_data][name]")).toBe("Delivery · Fixture service");
expect(params.has("shipping_options[0][shipping_rate_data][fixed_amount][amount]")).toBe(false);
expect(params.get("payment_intent_data[shipping][address][postal_code]")).toBe("94107");
expect(params.has("shipping_address_collection[allowed_countries][0]")).toBe(false);
expect(Number(params.get("expires_at"))*1000-quote.expiresAt).toBeGreaterThan(29*60*1000);
expect((await handleMerch(request("checkout", { quoteId: quote.quoteId }), "checkout", env)).status).toBe(200);
expect(sent.filter(r => r.url.includes("stripe.com") && r.method === "POST")).toHaveLength(1);
});
it("rejects an expired quote and changed saved artwork", async () => {
const quote = await quoted();
config.products["whale-bro"]!.US.designCode = "different-art"; env.MERCH_CONFIG_JSON = JSON.stringify(config);
expect((await handleMerch(request("checkout", { quoteId: quote.quoteId }), "checkout", env)).status).toBe(409);
db.db.prepare("UPDATE merch_orders SET expires_at=0").run();
expect((await handleMerch(request("checkout", { quoteId: quote.quoteId }), "checkout", env)).status).toBe(409);
});
it("revokes outstanding quotes when shipping, costs, fees or tax disclosure change", async () => {
const changes = [
() => { config.countries.US.shippingLevelCode = "REPLACEMENT"; },
() => { config.products["whale-bro"]!.US.productionCostUsd = 14; },
() => { config.countries.US.feeRate = .08; },
() => { config.countries.US.taxNote = "Updated delivery charge disclosure."; },
];
for (const change of changes) {
config = configuration(); env.MERCH_CONFIG_JSON = JSON.stringify(config);
const quote = await quoted();
change(); env.MERCH_CONFIG_JSON = JSON.stringify(config);
const result = await handleMerch(request("checkout", { quoteId: quote.quoteId }), "checkout", env);
expect(result.status).toBe(409);
expect((await result.json()).code).toBe("quote_expired");
}
expect(sent.filter(r => r.url.includes("stripe.com"))).toHaveLength(0);
});
it("rejects bad signatures, test/live mismatch and unreconciled amounts", async () => {
const quote = await checkout();
expect((await event(undefined, undefined, false)).status).toBe(400);
expect((await event(undefined, undefined, true, true)).status).toBe(400);
currentSession.amount_total = 1; currentSession.payment_status = "paid";
expect((await event()).status).toBe(409);
expect(state(quote.quoteId)?.payment_state).toBe("quoted");
});
it("does not fulfill completed-but-unpaid events or a redirect", async () => {
const quote = await checkout();
expect(state(quote.quoteId)?.payment_state).toBe("quoted");
expect((await event()).status).toBe(200);
expect(state(quote.quoteId)?.fulfillment_state).toBe("not_ready");
expect(sent.filter(r => r.method === "POST" && r.url.includes("yoycol"))).toHaveLength(0);
});
it("accepts delayed payment after failure once, and duplicate events do not regress it", async () => {
const quote = await checkout(); await event("checkout.session.async_payment_failed", "evt_fail");
expect(state(quote.quoteId)?.payment_state).toBe("failed");
currentSession.payment_status = "paid";
await event("checkout.session.async_payment_succeeded", "evt_paid");
await event("checkout.session.async_payment_succeeded", "evt_paid");
currentSession.payment_status = "unpaid"; await event("checkout.session.expired", "evt_late");
expect(state(quote.quoteId)).toEqual(expect.objectContaining({ payment_state: "paid", fulfillment_state: "awaiting_supplier_review" }));
expect(db.db.prepare("SELECT count(*) AS n FROM merch_webhook_events WHERE id='evt_paid'").get()?.n).toBe(1);
});
it("keeps webhooks working when new sales are paused", async () => {
const quote = await checkout(); currentSession.payment_status = "paid";
config.checkoutEnabled = false; env.MERCH_CONFIG_JSON = JSON.stringify(config);
env.YOYCOL_ACCESS_KEY = undefined; env.MERCH_RATE_LIMITER = undefined;
expect((await event()).status).toBe(200);
expect(state(quote.quoteId)?.payment_state).toBe("paid");
});
});
describe("manual supplier handoff", () => {
async function paid(live = false) {
if (live) { config.mode = "live"; config.siteOrigin = "https://codewhale.net"; env.MERCH_CONFIG_JSON = JSON.stringify(config); env.MERCH_STRIPE_KEY = "sk_live_fixture"; currentSession.livemode = true; }
const quote = await checkout(); currentSession.payment_status = "paid"; await event(); return quote;
}
it("refuses unpaid and test-mode supplier creation", async () => {
const quote = await checkout();
await expect(operatorAction(env, { action: "submit", orderId: quote.quoteId })).rejects.toThrow();
currentSession.payment_status = "paid"; await event();
await expect(operatorAction(env, { action: "submit", orderId: quote.quoteId, confirmation: "CREATE_UNPAID_SUPPLIER_ORDER" })).rejects.toThrow();
config.mode = "live"; config.siteOrigin = "https://codewhale.net"; config.supplierSubmissionEnabled = true; env.MERCH_CONFIG_JSON = JSON.stringify(config);
await expect(operatorAction(env, { action: "submit", orderId: quote.quoteId, confirmation: "CREATE_UNPAID_SUPPLIER_ORDER" })).rejects.toThrow();
});
it("previews frozen SKU/art/address without forwarding contributor identity", async () => {
const quote = await paid();
const stored = JSON.parse(db.db.prepare("SELECT quote_json FROM merch_orders WHERE id=?").get(quote.quoteId)?.quote_json as string) as Quote;
stored.input.contributor = { github: "private-identity", contribution: "https://github.com/Hmbown/CodeWhale/pull/123", language: "en", phraseIndex: 1 };
const body = JSON.stringify(supplierRequest(stored));
expect(body).not.toContain("private-identity"); expect(body).not.toContain("github.com");
expect(body).toContain("fixture-design"); expect(body).toContain("fixture-sku");
await operatorAction(env, { action: "preview", orderId: quote.quoteId });
expect(sent.filter(r => r.method === "POST" && r.url.includes("yoycol"))).toHaveLength(0);
});
it("atomically allows one submission and keeps supplier funding separate", async () => {
const quote = await paid(true); config.supplierSubmissionEnabled = true; env.MERCH_CONFIG_JSON = JSON.stringify(config);
const value = { action: "submit", orderId: quote.quoteId, confirmation: "CREATE_UNPAID_SUPPLIER_ORDER" };
const results = await Promise.allSettled([operatorAction(env, value), operatorAction(env, value)]);
expect(results.filter(r => r.status === "fulfilled")).toHaveLength(1);
expect(sent.filter(r => r.method === "POST" && r.url.includes("yoycol"))).toHaveLength(1);
expect(state(quote.quoteId)?.fulfillment_state).toBe("supplier_cost_review");
});
it("holds an ambiguous submission instead of automatically retrying", async () => {
const quote = await paid(true); config.supplierSubmissionEnabled = true; env.MERCH_CONFIG_JSON = JSON.stringify(config); supplierFailure = true;
const value = { action: "submit", orderId: quote.quoteId, confirmation: "CREATE_UNPAID_SUPPLIER_ORDER" };
await expect(operatorAction(env, value)).rejects.toThrow("Do not resubmit");
supplierFailure = false; await expect(operatorAction(env, value)).rejects.toThrow();
expect(state(quote.quoteId)?.fulfillment_state).toBe("reconciliation_hold");
expect(sent.filter(r => r.method === "POST" && r.url.includes("yoycol"))).toHaveLength(1);
});
});
+36
View File
@@ -0,0 +1,36 @@
{
"mode": "test",
"checkoutEnabled": false,
"supplierSubmissionEnabled": false,
"siteOrigin": "http://localhost:3187",
"countries": {
"CN": {
"regionCode": "",
"shippingLevelCode": "",
"currency": "cny",
"usdExchangeRate": 6.7,
"feeRate": 0.039,
"flatFeeUsd": 0.3,
"taxReviewed": false,
"taxNote": "",
"supplierPricesUsdReviewed": false,
"addressPolicyReviewed": false,
"allowedPostalPrefixes": [],
"blockedPostalPrefixes": []
}
},
"products": {
"use-codewhale": {
"CN": { "approved": false, "designCode": "", "skuBySize": {}, "productionCostUsd": 5.12 }
},
"compiling": {
"CN": { "approved": false, "designCode": "", "skuBySize": {}, "productionCostUsd": 5.12 }
},
"whale-bro": {
"CN": { "approved": false, "designCode": "", "skuBySize": {}, "productionCostUsd": 5.12 }
},
"contributor": {
"CN": { "approved": false, "designCode": "", "contributorDesigns": {}, "skuBySize": {}, "productionCostUsd": 5.12 }
}
}
}
+20
View File
@@ -0,0 +1,20 @@
/** Willingness-to-pay research, in major currency units. Never checkout prices or FX conversions. */
export const MERCH_INTEREST_ITEMS = [
{ id: "signature-tee", group: "cotton", prices: { cny: [59, 69, 89], usd: [12, 15, 19] } },
{ id: "classic-tee", group: "cotton", prices: { cny: [59, 69, 89], usd: [12, 15, 19] } },
{ id: "whale-bro-tee", group: "cotton", prices: { cny: [59, 69, 89], usd: [12, 15, 19] } },
{ id: "mascot-tee", group: "cotton", prices: { cny: [59, 69, 89], usd: [12, 15, 19] } },
{ id: "tide-tee", group: "stretch", prices: { cny: [69, 89, 109], usd: [15, 19, 23] } },
{ id: "signature-polo", group: "stretch", prices: { cny: [99, 129, 159], usd: [20, 25, 30] } },
{ id: "everyday-hoodie", group: "stretch", prices: { cny: [129, 159, 199], usd: [25, 30, 36] } },
{ id: "mascot-hoodie", group: "stretch", prices: { cny: [99, 129, 159], usd: [22, 28, 34] } },
{ id: "mousepad", group: "desk", prices: { cny: [29, 39, 49], usd: [6, 8, 10] } },
{ id: "deskmat", group: "desk", prices: { cny: [49, 69, 89], usd: [12, 15, 19] } },
{ id: "stickers", group: "desk", prices: { cny: [9, 19, 29], usd: [3, 5, 8] } },
{ id: "plush", group: "plush", prices: { cny: [99, 129, 149], usd: [20, 25, 30] } },
] as const;
export type MerchInterestItemId = typeof MERCH_INTEREST_ITEMS[number]["id"];
export type MerchInterestCurrency = "cny" | "usd";
export type MerchInterestPriceChoice = "low" | "mid" | "high" | "none" | "unsure";
export const MERCH_INTEREST_PRICE_CHOICES = ["low", "mid", "high", "none", "unsure"] as const;
+200
View File
@@ -0,0 +1,200 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createSession, type CommunityAgentEnv } from "../community-agent";
import { handleAdminMerchInterest, handleMerchInterest, interestOriginAllowed, type MerchInterestEnv, type MerchInterest } from "./interest";
import { MERCH_INTEREST_ITEMS } from "./interest-options";
const PREFIX = "private:merch-interest:v1:";
let records: Map<string, string>, env: MerchInterestEnv;
const put = vi.fn(), get = vi.fn(), list = vi.fn(), remove = vi.fn(), limit = vi.fn();
const input = () => ({ email: "[email protected]", country: "China", currency: "cny", locale: "zh", consent: true, website: "", picks: [{ id: "signature-tee", priceChoice: "mid" }, { id: "mousepad", priceChoice: "low" }] });
function request(value: unknown = input(), headers: Record<string, string> = {}) {
return new Request("https://codewhale.net/api/merch/interest", { method: "POST", headers: { "Content-Type": "application/json", Origin: "https://codewhale.net", "CF-Connecting-IP": "192.0.2.10", ...headers }, body: JSON.stringify(value) });
}
function interestRecords() { return [...records.entries()].filter(([key]) => key.startsWith(PREFIX)); }
beforeEach(() => {
vi.clearAllMocks();
records = new Map();
get.mockImplementation(async (key: string) => records.get(key) ?? null);
put.mockImplementation(async (key: string, value: string) => { records.set(key, value); });
remove.mockImplementation(async (key: string) => { records.delete(key); });
list.mockImplementation(async ({ prefix, limit: pageSize, cursor = "0" }: { prefix: string; limit: number; cursor?: string }) => {
const keys = [...records.keys()].filter(key => key.startsWith(prefix)).sort();
const page = keys.slice(Number(cursor), Number(cursor) + pageSize);
const complete = Number(cursor) + pageSize >= keys.length;
return { keys: page.map(name => ({ name })), list_complete: complete, cursor: complete ? "" : String(Number(cursor) + pageSize) };
});
limit.mockResolvedValue({ success: true });
env = { CURATED_KV: { get, put, list, delete: remove }, MERCH_INTEREST_LIMITER: { limit } };
vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("No external calls are allowed in interest tests"); }));
});
afterEach(() => { vi.unstubAllGlobals(); });
describe("durable merch interest registration", () => {
it("writes consent, unverified status and authoritative currency choices before generic success", async () => {
const response = await handleMerchInterest(request({ ...input(), email: " [email protected] ", country: " China " }), env);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(put).toHaveBeenCalledTimes(1);
const [key, serialized, ttl] = put.mock.calls[0];
expect(key).toMatch(/^private:merch-interest:v1:[a-f0-9]{64}$/);
expect(key).not.toContain("fixture");
expect(ttl).toEqual({ expirationTtl: 15552000 });
const saved = JSON.parse(serialized) as MerchInterest;
expect(saved).toMatchObject({ schemaVersion: 1, email: "[email protected]", country: "China", currency: "cny", locale: "zh", consent: true, emailVerified: false, picks: [{ id: "signature-tee", priceChoice: "mid", surveyAmount: 69 }, { id: "mousepad", priceChoice: "low", surveyAmount: 29 }] });
expect(Date.parse(saved.expiresAt) - Date.parse(saved.submittedAt)).toBe(15552000000);
expect(limit.mock.calls.map(([value]) => value.key)).toEqual([expect.stringMatching(/^merch-interest:ip:[a-f0-9]{64}$/), expect.stringMatching(/^merch-interest:email:[a-f0-9]{64}$/)]);
expect(fetch).not.toHaveBeenCalled();
});
it("replaces repeated email choices without exposing whether it existed", async () => {
const first = await handleMerchInterest(request(), env);
const second = await handleMerchInterest(request({ ...input(), currency: "usd", picks: [{ id: "plush", priceChoice: "unsure" }] }), env);
expect(await first.json()).toEqual(await second.json());
expect(interestRecords()).toHaveLength(1);
expect(JSON.parse(interestRecords()[0][1])).toMatchObject({ currency: "usd", picks: [{ id: "plush", priceChoice: "unsure", surveyAmount: null }] });
});
it("accepts all registered items and stores USD amounts from shared survey options", async () => {
const picks = MERCH_INTEREST_ITEMS.map(item => ({ id: item.id, priceChoice: "high" }));
const response = await handleMerchInterest(request({ ...input(), currency: "usd", locale: "pt-BR", picks }), env);
expect(response.status).toBe(200);
const saved = JSON.parse(interestRecords()[0][1]) as MerchInterest;
expect(saved.picks.map(pick => pick.surveyAmount)).toEqual(MERCH_INTEREST_ITEMS.map(item => item.prices.usd[2]));
});
it.each(["storage", "limiter"])("fails closed when %s is absent, including status", async (missing) => {
if (missing === "storage") delete env.CURATED_KV;
else delete env.MERCH_INTEREST_LIMITER;
const status = await handleMerchInterest(new Request("https://codewhale.net/api/merch/interest"), env);
expect(await status.json()).toEqual({ available: false });
const denied = request();
expect((await handleMerchInterest(denied, env)).status).toBe(503);
expect(denied.bodyUsed).toBe(false);
expect(put).not.toHaveBeenCalled();
});
it.each(["storage", "limiter"])("never returns success when %s throws, and does not echo errors", async (failed) => {
(failed === "storage" ? put : limit).mockRejectedValue(new Error("[email protected] secret provider error"));
const response = await handleMerchInterest(request(), env);
expect(response.status).toBe(503);
expect(await response.text()).not.toMatch(/fixture@example|secret provider/);
expect(interestRecords()).toHaveLength(0);
});
it("blocks a denied native rate limit before reading the body", async () => {
limit.mockResolvedValue({ success: false });
const denied = request();
const response = await handleMerchInterest(denied, env);
expect(response.status).toBe(429);
expect(response.headers.get("Retry-After")).toBe("60");
expect(denied.bodyUsed).toBe(false);
expect(put).not.toHaveBeenCalled();
});
it.each(["", "https://evil.example", "https://codewhale.net.evil.example", "https://codewhale.net/", "http://127.0.0.1:3100"])("blocks origin %s", async (origin) => {
const response = await handleMerchInterest(request(input(), { Origin: origin }), env);
expect(response.status).toBe(403);
expect(limit).not.toHaveBeenCalled();
expect(put).not.toHaveBeenCalled();
});
it("permits the exact configured HTTPS origin and development loopback only", () => {
const custom = request(input(), { Origin: "https://merch.example.test" });
expect(interestOriginAllowed(custom, { MERCH_INTEREST_SITE_ORIGIN: "https://merch.example.test" })).toBe(true);
expect(interestOriginAllowed(request(), { MERCH_INTEREST_SITE_ORIGIN: "https://merch.example.test" })).toBe(false);
expect(interestOriginAllowed(request(), { MERCH_INTEREST_SITE_ORIGIN: "https://codewhale.net/path" })).toBe(false);
const local = new Request("http://127.0.0.1:3100/api/merch/interest", { headers: { Origin: "http://127.0.0.1:3100" } });
expect(interestOriginAllowed(local, {}, true)).toBe(true);
expect(interestOriginAllowed(local, {}, false)).toBe(false);
expect(interestOriginAllowed(new Request(local.url, { headers: { Origin: "http://localhost:3100" } }), {}, true)).toBe(false);
});
it.each(["http://127.0.0.1:3100", "http://localhost:3100", "http://[::1]:3100", "https://localhost:3100"])("permits explicit same-origin native preview at %s with production semantics", async (siteOrigin) => {
const localEnv = { ...env, MERCH_INTEREST_SITE_ORIGIN: siteOrigin };
const local = new Request(siteOrigin + "/api/merch/interest", { method: "POST", headers: { Origin: siteOrigin, "Content-Type": "application/json" }, body: JSON.stringify(input()) });
expect(interestOriginAllowed(local, localEnv, false)).toBe(true);
const response = await handleMerchInterest(local, localEnv);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({ ok: true });
expect(interestRecords()).toHaveLength(1);
const production = request(input(), { Origin: siteOrigin });
expect(interestOriginAllowed(production, localEnv, false)).toBe(false);
expect((await handleMerchInterest(production, localEnv)).status).toBe(403);
expect(put).toHaveBeenCalledTimes(1);
});
it("blocks mismatched loopback ports and unencrypted public configured origins", () => {
const local = new Request("http://127.0.0.1:3101/api/merch/interest", { headers: { Origin: "http://127.0.0.1:3100" } });
expect(interestOriginAllowed(local, { MERCH_INTEREST_SITE_ORIGIN: "http://127.0.0.1:3100" }, false)).toBe(false);
const publicHttp = new Request("http://codewhale.net/api/merch/interest", { headers: { Origin: "http://codewhale.net" } });
expect(interestOriginAllowed(publicHttp, { MERCH_INTEREST_SITE_ORIGIN: "http://codewhale.net" }, false)).toBe(false);
});
it.each([
{ email: "invalid" }, { email: "a".repeat(250) + "@example.test" },
{ consent: false }, { consent: "true" }, { country: "" }, { country: "x".repeat(81) },
{ country: "China\nUS" }, { currency: "eur" }, { locale: "unknown" }, { website: "bot.example" },
{ picks: [] }, { picks: [{ id: "unknown" }] },
{ picks: [{ id: "mousepad" }, { id: "mousepad" }] },
{ picks: [{ id: "mousepad", priceChoice: "cheap" }] },
{ picks: [{ id: "mousepad", priceChoice: "low", surveyAmount: 0.01 }] },
])("rejects invalid payload %# without a write", async (patch) => {
expect((await handleMerchInterest(request({ ...input(), ...patch }), env)).status).toBe(422);
expect(put).not.toHaveBeenCalled();
});
it("bounds actual bytes as well as Content-Length and checks the media type exactly", async () => {
const actual = new Request("https://codewhale.net/api/merch/interest", { method: "POST", headers: { Origin: "https://codewhale.net", "Content-Type": "application/json" }, body: JSON.stringify({ padding: "x".repeat(8192) }) });
expect((await handleMerchInterest(actual, env)).status).toBe(413);
expect((await handleMerchInterest(request(input(), { "Content-Length": "8193" }), env)).status).toBe(413);
expect((await handleMerchInterest(request(input(), { "Content-Type": "application/jsonp" }), env)).status).toBe(415);
expect(put).not.toHaveBeenCalled();
});
});
describe("private merch interest list and deletion", () => {
async function signedIn(): Promise<{ auth: CommunityAgentEnv; headers: Record<string, string> }> {
const auth = { CURATED_KV: env.CURATED_KV, MAINTAINER_TOKEN: "local-maintainer-fixture" };
const sid = await createSession(auth.CURATED_KV);
return { auth, headers: { Cookie: "mt_sid=" + sid } };
}
it("requires a real stored maintainer session for reading or deleting contacts", async () => {
const response = await handleAdminMerchInterest(new Request("https://codewhale.net/api/admin/merch/interest"), env, { MAINTAINER_TOKEN: "fixture", CURATED_KV: env.CURATED_KV });
expect(response.status).toBe(401);
expect(list).not.toHaveBeenCalled();
expect(remove).not.toHaveBeenCalled();
});
it("paginates only the private prefix and deletes an opaque id with same-origin CSRF", async () => {
await handleMerchInterest(request(), env);
await handleMerchInterest(request({ ...input(), email: "[email protected]" }), env);
const { auth, headers } = await signedIn();
const response = await handleAdminMerchInterest(new Request("https://codewhale.net/api/admin/merch/interest?limit=1", { headers }), env, auth);
expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toBe("no-store");
const first = await response.json();
expect(first.entries).toHaveLength(1);
expect(first.cursor).toBe("1");
expect(first.entries[0].id).toMatch(/^[a-f0-9]{64}$/);
const second = await handleAdminMerchInterest(new Request("https://codewhale.net/api/admin/merch/interest?limit=1&cursor=1", { headers }), env, auth);
expect((await second.json()).cursor).toBeNull();
const deleting = (origin: string, id: string) => new Request("https://codewhale.net/api/admin/merch/interest", { method: "DELETE", headers: { ...headers, Origin: origin, "Content-Type": "application/json" }, body: JSON.stringify({ id }) });
expect((await handleAdminMerchInterest(deleting("https://evil.example", first.entries[0].id), env, auth)).status).toBe(403);
expect((await handleAdminMerchInterest(deleting("https://codewhale.net", "dispatch:latest"), env, auth)).status).toBe(422);
const removed = await handleAdminMerchInterest(deleting("https://codewhale.net", first.entries[0].id), env, auth);
expect(await removed.json()).toEqual({ ok: true });
expect(interestRecords()).toHaveLength(1);
expect(list).toHaveBeenCalledWith({ prefix: PREFIX, limit: 1, cursor: "1" });
});
it("bounds admin pagination and omits corrupt or expired records", async () => {
await handleMerchInterest(request(), env);
const [key, serialized] = interestRecords()[0];
records.set(key, JSON.stringify({ ...JSON.parse(serialized), expiresAt: "2000-01-01T00:00:00.000Z" }));
records.set(PREFIX + "a".repeat(64), "invalid fixture data");
const { auth, headers } = await signedIn();
expect((await handleAdminMerchInterest(new Request("https://codewhale.net/api/admin/merch/interest?limit=51", { headers }), env, auth)).status).toBe(422);
expect((await handleAdminMerchInterest(new Request("https://codewhale.net/api/admin/merch/interest?cursor=" + "x".repeat(2049), { headers }), env, auth)).status).toBe(422);
const response = await handleAdminMerchInterest(new Request("https://codewhale.net/api/admin/merch/interest", { headers }), env, auth);
expect((await response.json()).entries).toEqual([]);
});
});
+177
View File
@@ -0,0 +1,177 @@
/**
* Private, unverified merch launch opt-ins, separate from orders and payment.
* Real KV and the native rate limiter are mandatory: no in-memory success path.
* A new submission replaces that email's choices and expires after 180 days.
* No email verification, campaign delivery, public counts, or inventory promise.
* KV is eventually consistent; the maintainer list may lag a successful write.
*/
import { readBoundedBody, BodyReadError } from "../bounded-body";
import { getAgentEnv, validateSession, type CommunityAgentEnv } from "../community-agent";
import { isValidLocale } from "../i18n/config";
import { getEnv, type KVNamespace } from "../kv";
import { MERCH_INTEREST_ITEMS, MERCH_INTEREST_PRICE_CHOICES, type MerchInterestPriceChoice, type MerchInterestCurrency } from "./interest-options";
const PREFIX = "private:merch-interest:v1:";
const RETENTION_SECONDS = 180 * 24 * 60 * 60;
const MAX_BODY_BYTES = 8192;
type PriceChoice = MerchInterestPriceChoice;
type Currency = MerchInterestCurrency;
type Pick = { id: string; priceChoice: PriceChoice; surveyAmount: number | null };
export type MerchInterest = {
schemaVersion: 1;
email: string;
country: string;
currency: Currency;
locale: string;
consent: true;
emailVerified: false;
submittedAt: string;
expiresAt: string;
picks: Pick[];
};
export interface MerchInterestEnv {
CURATED_KV?: KVNamespace;
MERCH_INTEREST_LIMITER?: { limit(options: { key: string }): Promise<{ success: boolean }> };
MERCH_INTEREST_SITE_ORIGIN?: string;
}
class InterestError extends Error {
constructor(readonly status: number, readonly code: string, message: string) { super(message); }
}
function json(data: unknown, status = 200) {
return Response.json(data, { status, headers: { "Cache-Control": "no-store", ...(status === 429 ? { "Retry-After": "60" } : {}) } });
}
function invalid(): never { throw new InterestError(422, "invalid_request", "Check the interest form and try again."); }
function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) invalid();
return value as Record<string, unknown>;
}
async function body(request: Request) {
const mediaType = request.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase();
if (mediaType !== "application/json") throw new InterestError(415, "invalid_request", "Use the interest form on the website.");
const bytes = await readBoundedBody(request, MAX_BODY_BYTES);
try { return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)) as unknown; }
catch { invalid(); }
}
function loopback(url: URL) { return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname); }
export function interestOriginAllowed(request: Request, env: MerchInterestEnv, development = process.env.NODE_ENV === "development") {
const origin = request.headers.get("origin");
if (!origin) return false;
try {
const requestUrl = new URL(request.url), incoming = new URL(origin);
if (incoming.origin !== origin) return false;
if (development && loopback(requestUrl) && origin === requestUrl.origin) return true;
if (env.MERCH_INTEREST_SITE_ORIGIN) {
const configured = new URL(env.MERCH_INTEREST_SITE_ORIGIN);
if (configured.origin !== env.MERCH_INTEREST_SITE_ORIGIN || origin !== configured.origin) return false;
// OpenNext's local production preview also needs its real bindings. An
// explicit loopback origin remains confined to that same local request.
if (loopback(configured)) return ["http:", "https:"].includes(configured.protocol) && requestUrl.origin === configured.origin;
return configured.protocol === "https:";
}
return origin === "https://codewhale.net" || origin === "https://www.codewhale.net";
} catch { return false; }
}
function requireOrigin(request: Request, env: MerchInterestEnv) {
if (!interestOriginAllowed(request, env)) throw new InterestError(403, "request_blocked", "Use the interest form on the website.");
}
function available(env: MerchInterestEnv) { return Boolean(env.CURATED_KV && env.MERCH_INTEREST_LIMITER); }
function requireStorage(env: MerchInterestEnv): KVNamespace {
if (!available(env)) throw new InterestError(503, "interest_unavailable", "The interest list is temporarily unavailable. Please try again later.");
return env.CURATED_KV!;
}
async function digest(value: string) {
const bytes = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
return [...new Uint8Array(bytes)].map(value => value.toString(16).padStart(2, "0")).join("");
}
async function limited(env: MerchInterestEnv, key: string) {
const result = await env.MERCH_INTEREST_LIMITER!.limit({ key });
if (!result.success) throw new InterestError(429, "try_later", "Please wait a minute and try again.");
}
function validated(value: unknown, now: number): MerchInterest {
const input = object(value);
if (input.website !== undefined && input.website !== "") invalid();
if (typeof input.email !== "string" || input.email.length > 254) invalid();
const email = input.email.trim().toLowerCase();
if (!/^[^\s@<>\x00-\x1f\x7f]+@[^\s@<>\x00-\x1f\x7f]+\.[^\s@<>\x00-\x1f\x7f]+$/.test(email)) invalid();
if (typeof input.country !== "string" || !input.country.trim() || input.country.length > 80 || /[\x00-\x1f\x7f]/.test(input.country)) invalid();
if (input.currency !== "cny" && input.currency !== "usd") invalid();
if (typeof input.locale !== "string" || input.locale.length > 16 || !isValidLocale(input.locale) || input.consent !== true) invalid();
if (!Array.isArray(input.picks) || !input.picks.length || input.picks.length > Math.min(12, MERCH_INTEREST_ITEMS.length)) invalid();
const seen = new Set<string>(), currency = input.currency;
const picks = input.picks.map(value => {
const pick = object(value);
if (typeof pick.id !== "string" || seen.has(pick.id) || Object.keys(pick).some(key => !["id", "priceChoice"].includes(key))) invalid();
const item = MERCH_INTEREST_ITEMS.find(item => item.id === pick.id);
const priceChoice = pick.priceChoice ?? "unsure";
if (!item || !MERCH_INTEREST_PRICE_CHOICES.includes(priceChoice as PriceChoice)) invalid();
seen.add(pick.id);
const index = ["low", "mid", "high"].indexOf(priceChoice as string);
const surveyAmount = index === -1 ? null : item.prices[currency][index];
if (surveyAmount !== null && (!Number.isFinite(surveyAmount) || surveyAmount <= 0)) throw new InterestError(503, "interest_unavailable", "The interest list is temporarily unavailable.");
return { id: pick.id, priceChoice: priceChoice as PriceChoice, surveyAmount };
});
return { schemaVersion: 1, email, country: input.country.trim(), currency, locale: input.locale, consent: true, emailVerified: false, submittedAt: new Date(now).toISOString(), expiresAt: new Date(now + RETENTION_SECONDS * 1000).toISOString(), picks };
}
function errorResponse(error: unknown) {
if (error instanceof InterestError) return json({ error: error.message, code: error.code }, error.status);
if (error instanceof BodyReadError) return json({ error: "The interest form is too large or invalid.", code: "invalid_request" }, error.status);
// Never echo provider errors, keys, addresses, or request payloads.
return json({ error: "The interest list is temporarily unavailable. Please try again later.", code: "interest_unavailable" }, 503);
}
export async function handleMerchInterest(request: Request, env?: MerchInterestEnv): Promise<Response> {
try {
const current = env ?? await getEnv();
if (request.method === "GET") return json({ available: available(current) });
if (request.method !== "POST") return json({ error: "Method not allowed." }, 405);
requireOrigin(request, current);
const kv = requireStorage(current);
// Cloudflare supplies this trusted header at the edge. Missing headers share
// one bucket; X-Forwarded-For and user-provided identifiers are not accepted.
const ip = request.headers.get("cf-connecting-ip")?.slice(0, 64) ?? "unattributed";
await limited(current, "merch-interest:ip:" + await digest(ip));
const record = validated(await body(request), Date.now());
const emailHash = await digest(record.email);
await limited(current, "merch-interest:email:" + emailHash);
await kv.put(PREFIX + emailHash, JSON.stringify(record), { expirationTtl: RETENTION_SECONDS });
return json({ ok: true });
} catch (error) { return errorResponse(error); }
}
async function requireAdmin(request: Request, auth: CommunityAgentEnv) {
const sid = request.headers.get("cookie")?.split(";").map(cookie => cookie.trim()).find(cookie => cookie.startsWith("mt_sid="))?.slice(7);
if (!auth.MAINTAINER_TOKEN || !await validateSession(auth.CURATED_KV, sid)) throw new InterestError(401, "unauthorized", "Maintainer sign-in is required.");
}
export async function handleAdminMerchInterest(request: Request, env?: MerchInterestEnv, authEnv?: CommunityAgentEnv): Promise<Response> {
try {
const current = env ?? await getEnv();
await requireAdmin(request, authEnv ?? await getAgentEnv());
if (!current.CURATED_KV) throw new InterestError(503, "interest_unavailable", "Interest storage is unavailable.");
const kv = current.CURATED_KV;
if (request.method === "GET") {
const url = new URL(request.url), cursor = url.searchParams.get("cursor") ?? undefined;
const rawLimit = url.searchParams.get("limit") ?? "50";
if ((cursor && (cursor.length > 2048 || /[\x00-\x1f\x7f]/.test(cursor))) || !/^\d{1,2}$/.test(rawLimit)) invalid();
const limit = Number(rawLimit);
if (limit < 1 || limit > 50) invalid();
const listed = await kv.list({ prefix: PREFIX, limit, ...(cursor ? { cursor } : {}) });
const entries = await Promise.all(listed.keys.slice(0, limit).map(async ({ name }) => {
if (!name.startsWith(PREFIX) || !/^[a-f0-9]{64}$/.test(name.slice(PREFIX.length))) return null;
const raw = await kv.get(name);
if (!raw || raw.length > MAX_BODY_BYTES) return null;
try {
const record = JSON.parse(raw) as MerchInterest;
if (record.schemaVersion !== 1 || Date.parse(record.expiresAt) <= Date.now() || !Number.isFinite(Date.parse(record.expiresAt))) return null;
return { id: name.slice(PREFIX.length), ...record };
} catch { return null; }
}));
return json({ entries: entries.filter(entry => entry !== null), cursor: listed.list_complete === false ? listed.cursor ?? null : null });
}
if (request.method === "DELETE") {
requireOrigin(request, current);
const input = object(await body(request));
if (typeof input.id !== "string" || !/^[a-f0-9]{64}$/.test(input.id)) invalid();
await kv.delete(PREFIX + input.id);
return json({ ok: true });
}
return json({ error: "Method not allowed." }, 405);
} catch (error) { return errorResponse(error); }
}
+71
View File
@@ -0,0 +1,71 @@
import { MERCH_COUNTRIES, MERCH_PRODUCTS, MERCH_SIZES, MERCH_TARGET_PRICE } from "./catalog";
import { validateContributorIdentity } from "../contributor-merch";
import { MERCH_LANGUAGES } from "../content/contributor-merch";
import type { MerchConfig, QuoteInput, CountryRoute } from "./types";
export class MerchError extends Error {
constructor(readonly status: number, readonly code: string, message: string) { super(message); }
}
export function object(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new MerchError(422, "invalid_request", "Check the form and try again.");
return value as Record<string, unknown>;
}
function text(value: unknown, max: number, optional = false): string {
if ((value === undefined || value === "") && optional) return "";
if (typeof value !== "string" || !value.trim() || value.length > max || /[\u0000-\u001f\u007f]/.test(value)) throw new MerchError(422, "invalid_request", "Check the address and try again.");
return value.trim();
}
export function validateQuoteInput(raw: unknown): QuoteInput {
const value = object(raw);
const product = MERCH_PRODUCTS.find(p => p.id === value.productId);
if (!product || product.kind === "concept" || product.collection !== "launch") throw new MerchError(422, "product_unavailable", "This product is not ready to order.");
if (!(MERCH_SIZES as readonly string[]).includes(String(value.size)) || value.color !== "White" || !Number.isInteger(value.quantity) || Number(value.quantity) < 1 || Number(value.quantity) > 5) throw new MerchError(422, "invalid_variant", "Select an available size and quantity.");
const country = text(value.country, 2);
if (!MERCH_COUNTRIES.some(c => c.code === country)) throw new MerchError(422, "country_unavailable", "This destination needs a shipping review.");
const address = object(value.address);
const input: QuoteInput = {
productId: product.id, size: String(value.size), color: "White", quantity: Number(value.quantity), country,
address: { name: text(address.name, 100), line1: text(address.line1, 150), line2: text(address.line2, 150, true), city: text(address.city, 100), region: text(address.region, 100, true), postalCode: text(address.postalCode, 24, true), phone: text(address.phone, 40) },
};
if (country === "US" || country === "CA") {
if (!input.address.region || !input.address.postalCode) throw new MerchError(422, "invalid_address", "Add your state or province and postcode.");
}
if (product.kind === "contributor") {
const c = object(value.contributor);
const github = text(c.github, 50, true), contribution = text(c.contribution, 2048, true);
if (validateContributorIdentity({ github, contribution }) || !MERCH_LANGUAGES.some(l => l.code === c.language) || !Number.isInteger(c.phraseIndex) || Number(c.phraseIndex) < 0 || Number(c.phraseIndex) > 2) throw new MerchError(422, "invalid_contributor", "Enter a GitHub identity or contribution link and select a print phrase.");
input.contributor = { github, contribution, language: String(c.language), phraseIndex: Number(c.phraseIndex) };
}
return input;
}
export function parseConfig(raw: string | undefined): MerchConfig | null {
if (!raw) return null;
try {
const value = JSON.parse(raw) as MerchConfig;
const origin = new URL(value.siteOrigin);
if (!["test", "live"].includes(value.mode) || typeof value.checkoutEnabled !== "boolean" || origin.pathname !== "/" || origin.search || origin.hash || origin.username || origin.password || !(origin.protocol === "https:" || (value.mode === "test" && origin.protocol === "http:" && ["localhost", "127.0.0.1"].includes(origin.hostname))) || !value.countries || !value.products || Array.isArray(value.countries) || Array.isArray(value.products)) return null;
return value;
} catch { return null; }
}
export function reviewedRoute(config: MerchConfig, input: QuoteInput) {
const route = config.countries[input.country], product = config.products[input.productId]?.[input.country];
const designCode = input.productId === "contributor" && input.contributor ? product?.contributorDesigns?.[input.contributor.language]?.[input.contributor.phraseIndex] : product?.designCode;
if (!route || product?.approved !== true || !designCode || !product.skuBySize?.[input.size] || route.taxReviewed !== true || route.addressPolicyReviewed !== true || route.supplierPricesUsdReviewed !== true || !Array.isArray(route.allowedPostalPrefixes) || !route.allowedPostalPrefixes.length || route.allowedPostalPrefixes.some(p => typeof p !== "string" || !p.trim()) || !route.taxNote || route.taxNote.length > 250 || !route.regionCode || !route.shippingLevelCode || !["cny", "usd"].includes(route.currency) || ![route.usdExchangeRate, route.feeRate, route.flatFeeUsd, product.productionCostUsd].every(Number.isFinite) || route.usdExchangeRate <= 0 || (route.currency === "usd" && route.usdExchangeRate !== 1) || route.feeRate < 0 || route.feeRate >= .2 || route.flatFeeUsd < 0 || product.productionCostUsd <= 0) throw new MerchError(409, "route_unavailable", "This design and destination are still awaiting a shipping and print review.");
const postcode = input.address.postalCode.replace(/\s/g, "").toUpperCase();
if (route.blockedPostalPrefixes?.some(p => postcode.startsWith(p.toUpperCase())) || (route.allowedPostalPrefixes?.length && !route.allowedPostalPrefixes.some(p => postcode.startsWith(p.toUpperCase())))) throw new MerchError(409, "address_unavailable", "This address needs a separate delivery quote.");
return { route, product, designCode };
}
export function priceQuote(input: QuoteInput, route: CountryRoute, productionUsd: number, shippingUsd: number) {
if (![productionUsd, shippingUsd].every(Number.isFinite) || productionUsd <= 0 || shippingUsd < 0) throw new MerchError(503, "quote_unavailable", "Shipping prices are temporarily unavailable.");
const shipping = Math.ceil(shippingUsd * route.usdExchangeRate * 100);
const merchandise = input.productId === "contributor" ? Math.ceil(productionUsd * route.usdExchangeRate * 100) : MERCH_TARGET_PRICE[route.currency] * input.quantity;
const base = merchandise + shipping;
const total = input.productId === "contributor" ? Math.ceil((base + route.flatFeeUsd * route.usdExchangeRate * 100) / (1 - route.feeRate)) : base;
if (!Number.isSafeInteger(total) || total <= 0 || total > 1000000) throw new MerchError(503, "quote_unavailable", "This order needs a separate quote.");
if (input.productId !== "contributor") {
const grossUsd = total / (route.usdExchangeRate * 100), itemUsd = merchandise / (route.usdExchangeRate * 100);
const contributionUsd = grossUsd - productionUsd - shippingUsd - grossUsd * route.feeRate - route.flatFeeUsd - itemUsd * .05;
if (contributionUsd < itemUsd * .30) throw new MerchError(409, "route_unavailable", "This destination needs a separate price review.");
}
return { merchandise, shipping, processing: total - base, total };
}
+61
View File
@@ -0,0 +1,61 @@
import { MerchError, object, parseConfig } from "./model";
import { yoycolRequest } from "./yoycol";
import type { MerchEnv, Quote } from "./types";
type Row = { id: string; quote_json: string; payment_state: string; fulfillment_state: string; supplier_order_id: string | null; updated_at: number };
function id(value: unknown) {
if (typeof value !== "string" || !/^[0-9a-f-]{36}$/.test(value)) throw new MerchError(422, "invalid_order", "Select an existing paid order.");
return value;
}
function supplierId(value: unknown) {
if ((typeof value === "number" && Number.isSafeInteger(value) && value > 0) || (typeof value === "string" && /^[1-9]\d{0,18}$/.test(value))) return String(value);
throw new MerchError(409, "supplier_receipt_invalid", "Reconcile the supplier order before retrying.");
}
/** Preview uses frozen approved mappings. Contributor identity is never sent to Yoycol. */
export function supplierRequest(value: Quote) {
const a = value.input.address;
return {
storeOrderSn: "CW-" + value.quoteId, shippingLevelCode: value.shippingLevelCode,
autoSwitchShippingLevel: false, currency: "USD",
address: { firstName: a.name, lastName: "", phone: a.phone, country: value.input.country, province: a.region, city: a.city, address1: a.line1, address2: a.line2, zip: a.postalCode },
items: [{ thirdItemId: value.quoteId, skuCode: value.skuCode, designCode: value.designCode, quantity: value.input.quantity, source: "productTemplate" }],
};
}
/** Only the authenticated maintainer route may invoke this handler. Funding stays in Yoycol. */
export async function operatorAction(env: MerchEnv, raw: unknown) {
const input = object(raw), config = parseConfig(env.MERCH_CONFIG_JSON), db = env.MERCH_DB;
if (!config || !db) throw new MerchError(503, "operator_unavailable", "Commerce storage is not configured.");
if (input.action === "list") return db.prepare("SELECT id,payment_state,fulfillment_state,supplier_order_id,updated_at FROM merch_orders WHERE payment_state='paid' ORDER BY updated_at DESC LIMIT 100").all();
const orderId = id(input.orderId);
const row = await db.prepare("SELECT * FROM merch_orders WHERE id=?").bind(orderId).first<Row>();
if (!row || row.payment_state !== "paid") throw new MerchError(409, "not_paid", "Confirm the Stripe payment before fulfillment.");
const value = JSON.parse(row.quote_json) as Quote;
if (input.action === "preview") return { orderId, state: row.fulfillment_state, supplierRequest: supplierRequest(value), expectedProductionUsd: value.vendorProductionUsd, estimatedShippingUsd: value.vendorShippingUsd, note: "Review address, final supplier costs and print. Creation does not pay or start production." };
if (input.action === "submit") {
if (config.mode !== "live" || value.mode !== "live" || config.supplierSubmissionEnabled !== true || input.confirmation !== "CREATE_UNPAID_SUPPLIER_ORDER") throw new MerchError(409, "submission_disabled", "Supplier submission requires an explicitly enabled live operator workflow and a live paid receipt.");
const locked = await db.prepare("UPDATE merch_orders SET fulfillment_state='supplier_submitting',updated_at=? WHERE id=? AND payment_state='paid' AND fulfillment_state='awaiting_supplier_review' AND supplier_order_id IS NULL").bind(Date.now(), orderId).run();
if (locked.meta.changes !== 1) throw new MerchError(409, "already_submitted", "This order is already submitted or needs reconciliation.");
try {
const receipt = object(await yoycolRequest(env, "POST", "/api/2025/open/v4/orders", {}, supplierRequest(value)));
const vendorId = supplierId(receipt.orderId);
if (receipt.storeOrderSn !== "CW-" + orderId || receipt.currency !== "USD" || typeof receipt.orderRealAmount !== "number" || !Number.isFinite(receipt.orderRealAmount) || receipt.orderRealAmount < 0) throw new MerchError(409, "supplier_receipt_invalid", "The supplier receipt needs reconciliation.");
await db.prepare("UPDATE merch_orders SET supplier_order_id=?,supplier_receipt_json=?,fulfillment_state='supplier_cost_review',updated_at=? WHERE id=? AND fulfillment_state='supplier_submitting'").bind(vendorId, JSON.stringify(receipt), Date.now(), orderId).run();
return { orderId, supplierOrderId: vendorId, state: "supplier_cost_review", receipt, note: "Review final shipping and tax; fund manually in Yoycol after approval. No supplier payment was made here." };
} catch {
// A timeout or malformed receipt can follow a successful remote write. Never blindly POST again.
await db.prepare("UPDATE merch_orders SET fulfillment_state='reconciliation_hold',updated_at=? WHERE id=? AND fulfillment_state='supplier_submitting'").bind(Date.now(), orderId).run();
throw new MerchError(409, "reconciliation_required", "Check Yoycol for CW-" + orderId + ". Do not resubmit this order.");
}
}
if (input.action === "reconcile" || input.action === "refresh") {
if (row.fulfillment_state === "supplier_submitting" && (input.action !== "reconcile" || Date.now() - row.updated_at < 60000)) throw new MerchError(409, "submission_pending", "Wait for the current submission or reconcile it after confirming the supplier receipt.");
const vendorId = supplierId(input.action === "reconcile" ? input.supplierOrderId : row.supplier_order_id);
const detail = object(await yoycolRequest(env, "GET", "/api/2025/open/v4/orders/" + vendorId));
const receipt = object(detail.order);
if (receipt.storeOrderSn !== "CW-" + orderId || supplierId(receipt.orderId) !== vendorId) throw new MerchError(409, "supplier_receipt_invalid", "That supplier order does not match this receipt.");
const tracking = await yoycolRequest(env, "GET", "/api/2025/open/v4/orders/" + vendorId + "/tracking");
await db.prepare("UPDATE merch_orders SET supplier_order_id=?,supplier_receipt_json=?,tracking_json=?,fulfillment_state='supplier_status_recorded',updated_at=? WHERE id=? AND payment_state='paid'").bind(vendorId, JSON.stringify(receipt), JSON.stringify(tracking), Date.now(), orderId).run();
return { orderId, receipt, tracking, note: "Raw supplier status is recorded; numeric status codes are not interpreted as paid, shipped or delivered." };
}
throw new MerchError(422, "unknown_action", "Select a supported operator action.");
}
+154
View File
@@ -0,0 +1,154 @@
import StripeClient from "stripe";
import { randomUUID } from "node:crypto";
import { getEnv } from "../kv";
import { readBoundedBody, BodyReadError } from "../bounded-body";
import { MERCH_PRODUCTS } from "./catalog";
import { MerchError, object, parseConfig, validateQuoteInput, reviewedRoute, priceQuote } from "./model";
import { destinationRate, yoycolRequest } from "./yoycol";
import type { MerchEnv, Quote, MerchConfig, MerchDatabase } from "./types";
type OrderRow = { id: string; quote_json: string; expires_at: number; stripe_session_id: string | null; checkout_url: string | null; payment_state: string; fulfillment_state: string; supplier_order_id: string | null; tracking_json: string | null };
export async function merchEnv(): Promise<MerchEnv> {
return await getEnv();
}
function stripe(env: MerchEnv) {
if (!env.MERCH_STRIPE_KEY) throw new MerchError(503, "checkout_unavailable", "Checkout is not configured yet.");
return new StripeClient(env.MERCH_STRIPE_KEY, { httpClient: StripeClient.createFetchHttpClient(), maxNetworkRetries: 1, timeout: 15000 });
}
function paymentEnvironment(env: MerchEnv) {
const config = parseConfig(env.MERCH_CONFIG_JSON);
if (!config || !env.MERCH_DB || !env.MERCH_STRIPE_KEY || !env.MERCH_STRIPE_WEBHOOK_SECRET) throw new MerchError(503, "checkout_unavailable", "The first drop is awaiting samples and checkout setup.");
const liveKey = /^(?:sk|rk)_live_/.test(env.MERCH_STRIPE_KEY);
const testKey = /^(?:sk|rk)_test_/.test(env.MERCH_STRIPE_KEY);
if (!(config.mode === "live" ? liveKey : testKey)) throw new MerchError(503, "checkout_unavailable", "Checkout configuration needs review.");
return { config, db: env.MERCH_DB };
}
function configured(env: MerchEnv) {
const result = paymentEnvironment(env);
if (!result.config.checkoutEnabled || !env.MERCH_RATE_LIMITER || !env.MERCH_PAYMENT_CONFIGURATION || !env.YOYCOL_ACCESS_KEY || !env.YOYCOL_SECRET_KEY) throw new MerchError(503, "checkout_unavailable", "The first drop is awaiting samples and checkout setup.");
return result;
}
export function merchStatus(env: MerchEnv) {
try {
const { config } = configured(env);
const readyProductIds = MERCH_PRODUCTS.filter(p => p.kind !== "concept" && p.collection === "launch" && Object.entries(config.products[p.id] ?? {}).some(([country, product]) => {
const route = config.countries[country];
return product.approved === true && (product.designCode || (p.id === "contributor" && Object.keys(product.contributorDesigns ?? {}).length > 0)) && Object.keys(product.skuBySize ?? {}).length && route?.taxReviewed === true && route.addressPolicyReviewed === true && route.supplierPricesUsdReviewed === true && route.allowedPostalPrefixes?.length;
})).map(p => p.id);
return { checkoutConfigured: readyProductIds.length > 0, readyProductIds };
} catch { return { checkoutConfigured: false, readyProductIds: [] }; }
}
function json(data: unknown, status = 200) {
return Response.json(data, { status, headers: { "Cache-Control": "no-store" } });
}
async function limited(env: MerchEnv, request: Request, config: MerchConfig) {
const origin = request.headers.get("origin");
if (!origin || origin !== new URL(config.siteOrigin).origin || !request.headers.get("content-type")?.startsWith("application/json")) throw new MerchError(403, "request_blocked", "Refresh the page before trying again.");
// Shared limiter never becomes a permissive per-isolate in-memory fallback.
const result = await env.MERCH_RATE_LIMITER!.limit({ key: "codewhale-merch:public" });
if (!result.success) throw new MerchError(429, "try_later", "Please wait a minute and try again.");
}
async function body(request: Request) {
try { return JSON.parse(new TextDecoder().decode(await readBoundedBody(request, 8192))) as unknown; }
catch (error) {
if (error instanceof BodyReadError) throw new MerchError(error.status, "invalid_request", error.message);
throw new MerchError(422, "invalid_request", "Check the form and try again.");
}
}
async function row(db: MerchDatabase, id: string) {
return db.prepare("SELECT * FROM merch_orders WHERE id = ?").bind(id).first<OrderRow>();
}
function safeId(value: unknown): string {
if (typeof value !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value)) throw new MerchError(422, "invalid_quote", "Request a fresh shipping quote.");
return value;
}
function publicQuote(quote: Quote) {
const { quoteId, expiresAt, currency, merchandise, shipping, processing, total, shippingName, taxNote } = quote;
return { quoteId, expiresAt, currency, merchandise, shipping, processing, total, shippingName, taxNote };
}
async function quote(request: Request, env: MerchEnv) {
const { config, db } = configured(env); await limited(env, request, config);
const input = validateQuoteInput(await body(request)), { route, product, designCode } = reviewedRoute(config, input);
// API provides destination/SKU rates, not exact-address or mixed-cart landed quotes.
const skuCode = product.skuBySize[input.size];
const rate = destinationRate(await yoycolRequest(env, "GET", "/api/2025/open/v4/shipping/sku_quotes", { sku_code: skuCode }), route.regionCode, route.shippingLevelCode, input.quantity);
const prices = priceQuote(input, route, product.productionCostUsd * input.quantity, rate.shippingUsd);
const value: Quote = { quoteId: randomUUID(), mode: config.mode, routeApproval: JSON.stringify([route, product]), expiresAt: Date.now() + 15 * 60 * 1000, currency: route.currency, ...prices, shippingName: rate.name, taxNote: route.taxNote, input, skuCode, designCode, shippingLevelCode: route.shippingLevelCode, vendorProductionUsd: product.productionCostUsd * input.quantity, vendorShippingUsd: rate.shippingUsd };
await db.prepare("INSERT INTO merch_orders (id,quote_json,expires_at,updated_at) VALUES (?,?,?,?)").bind(value.quoteId, JSON.stringify(value), value.expiresAt, Date.now()).run();
return json(publicQuote(value));
}
async function checkout(request: Request, env: MerchEnv) {
const { config, db } = configured(env); await limited(env, request, config);
const id = safeId(object(await body(request)).quoteId), order = await row(db, id);
if (!order || order.expires_at <= Date.now() || order.payment_state !== "quoted") throw new MerchError(409, "quote_expired", "Please request a fresh quote.");
const value = JSON.parse(order.quote_json) as Quote;
if (value.mode !== config.mode) throw new MerchError(409, "quote_expired", "Please request a fresh quote.");
const reviewed = reviewedRoute(config, value.input);
if (value.routeApproval !== JSON.stringify([reviewed.route, reviewed.product])) throw new MerchError(409, "quote_expired", "The print or delivery review changed. Please request a fresh quote.");
if (order.checkout_url) return json({ url: order.checkout_url });
const product = MERCH_PRODUCTS.find(p => p.id === value.input.productId)!;
const client = stripe(env);
const session = await client.checkout.sessions.create({
mode: "payment", client_reference_id: id, expires_at: Math.floor((value.expiresAt + 30 * 60 * 1000) / 1000),
integration_identifier: "codewhale_merch_" + Array.from(id.replaceAll("-", "").slice(0, 8), n => String.fromCharCode(97 + parseInt(n, 16))).join(""),
payment_method_configuration: env.MERCH_PAYMENT_CONFIGURATION,
line_items: [
{ price_data: { currency: value.currency, unit_amount: value.merchandise + value.processing, product_data: { name: "Codewhale · " + product.title, description: value.input.size + " · " + value.input.color + " · " + value.input.quantity + " item(s)" + (value.processing ? " · cost-recovery processing included" : "") } }, quantity: 1 },
{ price_data: { currency: value.currency, unit_amount: value.shipping, product_data: { name: "Delivery · " + value.shippingName } }, quantity: 1 },
],
// Delivery is an explicit line item: no dependency on Stripe's address collector.
// Do not collect a different shipping address after the destination quote.
payment_intent_data: { shipping: { name: value.input.address.name, phone: value.input.address.phone, address: { line1: value.input.address.line1, line2: value.input.address.line2 || undefined, city: value.input.address.city, state: value.input.address.region || undefined, postal_code: value.input.address.postalCode || undefined, country: value.input.country } }, metadata: { merch_order_id: id } },
metadata: { merch_order_id: id, merch_product_id: product.id },
custom_text: { submit: { message: "Ships to the address provided on Codewhale. " + value.taxNote } },
success_url: new URL("/en/merch?payment=received", config.siteOrigin).href,
cancel_url: new URL("/en/merch?payment=cancelled", config.siteOrigin).href,
}, { idempotencyKey: "codewhale-merch:" + id });
if (session.livemode !== (config.mode === "live") || !session.url || new URL(session.url).hostname !== "checkout.stripe.com" || !session.url.startsWith("https:")) throw new MerchError(503, "checkout_unavailable", "Checkout needs review.");
await db.prepare("UPDATE merch_orders SET stripe_session_id=?,checkout_url=?,updated_at=? WHERE id=? AND payment_state='quoted'").bind(session.id, session.url, Date.now(), id).run();
return json({ url: session.url });
}
async function webhook(request: Request, env: MerchEnv) {
// Pausing new sales must not discard payment events for existing sessions.
const { config, db } = paymentEnvironment(env), client = stripe(env);
let event: StripeClient.Event;
try {
const raw = new TextDecoder().decode(await readBoundedBody(request, 262144));
event = await client.webhooks.constructEventAsync(raw, request.headers.get("stripe-signature") ?? "", env.MERCH_STRIPE_WEBHOOK_SECRET!, undefined, StripeClient.createSubtleCryptoProvider());
} catch { throw new MerchError(400, "invalid_signature", "Webhook signature verification failed."); }
if (event.livemode !== (config.mode === "live")) throw new MerchError(400, "wrong_mode", "Webhook mode mismatch.");
if (!["checkout.session.completed", "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed", "checkout.session.expired"].includes(event.type)) return json({ received: true });
const snapshot = event.data.object as StripeClient.Checkout.Session;
const id = snapshot.metadata?.merch_order_id;
if (!id) return json({ received: true });
const order = await row(db, safeId(id));
if (!order || order.stripe_session_id !== snapshot.id) throw new MerchError(409, "order_mismatch", "The payment needs reconciliation.");
const value = JSON.parse(order.quote_json) as Quote;
const session = await client.checkout.sessions.retrieve(snapshot.id, { expand: ["line_items"] });
if (value.mode !== config.mode || session.livemode !== (config.mode === "live") || session.client_reference_id !== id || session.metadata?.merch_order_id !== id || session.metadata?.merch_product_id !== value.input.productId || session.currency !== value.currency || session.amount_total !== value.total) throw new MerchError(409, "amount_mismatch", "The payment needs reconciliation.");
if (session.payment_status === "paid") {
await db.batch([
db.prepare("INSERT OR IGNORE INTO merch_webhook_events (id,order_id,kind,received_at) VALUES (?,?,?,?)").bind(event.id, id, event.type, Date.now()),
db.prepare("UPDATE merch_orders SET payment_state='paid',fulfillment_state='awaiting_supplier_review',updated_at=? WHERE id=? AND payment_state IN ('quoted','failed','expired')").bind(Date.now(), id),
]);
} else if (event.type === "checkout.session.async_payment_failed" || event.type === "checkout.session.expired") {
await db.batch([
db.prepare("INSERT OR IGNORE INTO merch_webhook_events (id,order_id,kind,received_at) VALUES (?,?,?,?)").bind(event.id, id, event.type, Date.now()),
db.prepare("UPDATE merch_orders SET payment_state=?,updated_at=? WHERE id=? AND payment_state='quoted'").bind(event.type.endsWith("expired") ? "expired" : "failed", Date.now(), id),
]);
}
return json({ received: true });
}
/** Source-ready entrypoints. No supplier order/payment is triggered by a redirect or webhook. */
export async function handleMerch(request: Request, action: "status" | "quote" | "checkout" | "webhook", suppliedEnv?: MerchEnv): Promise<Response> {
try {
const env = suppliedEnv ?? await merchEnv();
if (action === "status") return json(merchStatus(env));
if (action === "quote") return await quote(request, env);
if (action === "checkout") return await checkout(request, env);
return await webhook(request, env);
} catch (error) {
if (error instanceof MerchError) return json({ error: error.message, code: error.code }, error.status);
return json({ error: "This request could not be completed. Please try again later.", code: "temporarily_unavailable" }, 503);
}
}
+153
View File
@@ -0,0 +1,153 @@
export type MerchSizingProductCode = "DJCTX" | "3MTXVYM32" | "3MOTS" | "3MCSPL56" | "3PH" | "3TPH" | "3MHZDM16";
export type MerchSizingUnit = "cm" | "in";
export type MerchSizingWarning = "poloShoulderNote" | "hoodieSleeveNote" | "cottonHoodieMaterialNote";
export interface MerchSizeRow {
size: string;
lengthCm: number;
shoulderCm: number;
/** Supplier's Bust column; its measurement convention has not been confirmed. */
bustCm: number;
sleeveCm: number | null;
}
export interface MerchSizeChart {
code: MerchSizingProductCode;
sourceUrl: string;
toleranceCm: number;
rows: readonly MerchSizeRow[];
confirmationNeededSizes: readonly string[];
warning?: MerchSizingWarning;
}
/** Public Yoycol charts captured 2026-10-04. Garment guidance, not body sizing or sample proof. */
export const MERCH_SIZE_CHARTS: readonly MerchSizeChart[] = [
{
code: "DJCTX",
sourceUrl: "https://www.yoycol.com/product_detail/DJCTX/Unisex-O-neck-Short-Sleeve-T-shirt-180GSM-Cotton-DTF",
toleranceCm: 3,
confirmationNeededSizes: [],
rows: [
{ size: "S", lengthCm: 67, shoulderCm: 42, bustCm: 94, sleeveCm: null },
{ size: "M", lengthCm: 70, shoulderCm: 44, bustCm: 100, sleeveCm: null },
{ size: "L", lengthCm: 73, shoulderCm: 46, bustCm: 106, sleeveCm: null },
{ size: "XL", lengthCm: 75, shoulderCm: 50, bustCm: 112, sleeveCm: null },
{ size: "2XL", lengthCm: 77, shoulderCm: 54, bustCm: 118, sleeveCm: null },
{ size: "3XL", lengthCm: 80, shoulderCm: 60, bustCm: 126, sleeveCm: null },
],
},
{
code: "3MTXVYM32",
sourceUrl: "https://www.yoycol.com/product_detail/3MTXVYM32",
toleranceCm: 5,
confirmationNeededSizes: [],
rows: [
{ size: "XS", lengthCm: 68, shoulderCm: 37, bustCm: 82, sleeveCm: null },
{ size: "S", lengthCm: 71, shoulderCm: 40, bustCm: 92, sleeveCm: null },
{ size: "M", lengthCm: 74, shoulderCm: 43, bustCm: 102, sleeveCm: null },
{ size: "L", lengthCm: 76, shoulderCm: 46, bustCm: 112, sleeveCm: null },
{ size: "XL", lengthCm: 79, shoulderCm: 49, bustCm: 122, sleeveCm: null },
{ size: "2XL", lengthCm: 81, shoulderCm: 52, bustCm: 132, sleeveCm: null },
{ size: "3XL", lengthCm: 84, shoulderCm: 55, bustCm: 142, sleeveCm: null },
{ size: "4XL", lengthCm: 86, shoulderCm: 58, bustCm: 152, sleeveCm: null },
{ size: "5XL", lengthCm: 88, shoulderCm: 61, bustCm: 162, sleeveCm: null },
],
},
{
code: "3MOTS",
sourceUrl: "https://www.yoycol.com/product_detail/3MOTS",
toleranceCm: 3,
confirmationNeededSizes: [],
rows: [
{ size: "XS", lengthCm: 68, shoulderCm: 37, bustCm: 82, sleeveCm: null },
{ size: "S", lengthCm: 71, shoulderCm: 40, bustCm: 92, sleeveCm: null },
{ size: "M", lengthCm: 74, shoulderCm: 43, bustCm: 102, sleeveCm: null },
{ size: "L", lengthCm: 76, shoulderCm: 46, bustCm: 112, sleeveCm: null },
{ size: "XL", lengthCm: 79, shoulderCm: 49, bustCm: 122, sleeveCm: null },
{ size: "2XL", lengthCm: 81, shoulderCm: 52, bustCm: 132, sleeveCm: null },
{ size: "3XL", lengthCm: 84, shoulderCm: 55, bustCm: 142, sleeveCm: null },
{ size: "4XL", lengthCm: 86, shoulderCm: 58, bustCm: 152, sleeveCm: null },
{ size: "5XL", lengthCm: 88, shoulderCm: 61, bustCm: 162, sleeveCm: null },
{ size: "6XL", lengthCm: 90, shoulderCm: 64, bustCm: 172, sleeveCm: null },
],
},
{
code: "3MCSPL56",
sourceUrl: "https://www.yoycol.com/product_detail/3MCSPL56",
toleranceCm: 3,
confirmationNeededSizes: ["2XL", "3XL"],
warning: "poloShoulderNote",
rows: [
{ size: "S", lengthCm: 71, shoulderCm: 40, bustCm: 92, sleeveCm: 23 },
{ size: "M", lengthCm: 74, shoulderCm: 43, bustCm: 102, sleeveCm: 23.5 },
{ size: "L", lengthCm: 76, shoulderCm: 46, bustCm: 112, sleeveCm: 24 },
{ size: "XL", lengthCm: 79, shoulderCm: 49, bustCm: 122, sleeveCm: 24.5 },
{ size: "2XL", lengthCm: 81, shoulderCm: 55, bustCm: 132, sleeveCm: 25 },
{ size: "3XL", lengthCm: 84, shoulderCm: 55, bustCm: 142, sleeveCm: 25.5 },
{ size: "4XL", lengthCm: 86, shoulderCm: 58, bustCm: 152, sleeveCm: 26 },
{ size: "5XL", lengthCm: 88, shoulderCm: 61, bustCm: 162, sleeveCm: 26.5 },
],
},
{
code: "3PH",
sourceUrl: "https://www.yoycol.com/product_detail/3PH",
toleranceCm: 3,
confirmationNeededSizes: ["7XL"],
warning: "hoodieSleeveNote",
rows: [
{ size: "XS", lengthCm: 65, shoulderCm: 39, bustCm: 92, sleeveCm: 66 },
{ size: "S", lengthCm: 67, shoulderCm: 42, bustCm: 102, sleeveCm: 67 },
{ size: "M", lengthCm: 70, shoulderCm: 45, bustCm: 112, sleeveCm: 68 },
{ size: "L", lengthCm: 72, shoulderCm: 48, bustCm: 122, sleeveCm: 69 },
{ size: "XL", lengthCm: 75, shoulderCm: 51, bustCm: 132, sleeveCm: 70.5 },
{ size: "2XL", lengthCm: 76.5, shoulderCm: 54, bustCm: 142, sleeveCm: 72 },
{ size: "3XL", lengthCm: 79.5, shoulderCm: 57, bustCm: 152, sleeveCm: 73 },
{ size: "4XL", lengthCm: 82, shoulderCm: 60, bustCm: 162, sleeveCm: 74 },
{ size: "5XL", lengthCm: 85, shoulderCm: 63, bustCm: 172, sleeveCm: 75 },
{ size: "6XL", lengthCm: 88, shoulderCm: 66, bustCm: 182, sleeveCm: 76 },
{ size: "7XL", lengthCm: 91, shoulderCm: 69, bustCm: 192, sleeveCm: 75 },
],
},
{
code: "3TPH",
sourceUrl: "https://www.yoycol.com/product_detail/3TPH",
toleranceCm: 3,
confirmationNeededSizes: [],
rows: [
{ size: "XS", lengthCm: 65, shoulderCm: 39, bustCm: 92, sleeveCm: 66 },
{ size: "S", lengthCm: 67, shoulderCm: 42, bustCm: 102, sleeveCm: 67 },
{ size: "M", lengthCm: 70, shoulderCm: 45, bustCm: 112, sleeveCm: 68 },
{ size: "L", lengthCm: 72, shoulderCm: 48, bustCm: 122, sleeveCm: 69 },
{ size: "XL", lengthCm: 75, shoulderCm: 51, bustCm: 132, sleeveCm: 70 },
{ size: "2XL", lengthCm: 76, shoulderCm: 54, bustCm: 142, sleeveCm: 71 },
{ size: "3XL", lengthCm: 79, shoulderCm: 57, bustCm: 152, sleeveCm: 71 },
{ size: "4XL", lengthCm: 82, shoulderCm: 60, bustCm: 162, sleeveCm: 72 },
{ size: "5XL", lengthCm: 85, shoulderCm: 63, bustCm: 172, sleeveCm: 73 },
{ size: "6XL", lengthCm: 88, shoulderCm: 66, bustCm: 182, sleeveCm: 73.5 },
],
},
{
code: "3MHZDM16",
sourceUrl: "https://www.yoycol.com/product_detail/3MHZDM16",
toleranceCm: 5,
confirmationNeededSizes: [],
warning: "cottonHoodieMaterialNote",
rows: [
{ size: "XS", lengthCm: 66, shoulderCm: 41, bustCm: 96, sleeveCm: 67 },
{ size: "S", lengthCm: 69, shoulderCm: 43, bustCm: 104, sleeveCm: 68 },
{ size: "M", lengthCm: 71, shoulderCm: 45, bustCm: 112, sleeveCm: 69 },
{ size: "L", lengthCm: 74, shoulderCm: 47, bustCm: 120, sleeveCm: 70 },
{ size: "XL", lengthCm: 76, shoulderCm: 49.5, bustCm: 130, sleeveCm: 71 },
{ size: "2XL", lengthCm: 79, shoulderCm: 52, bustCm: 140, sleeveCm: 72 },
{ size: "3XL", lengthCm: 81, shoulderCm: 54.5, bustCm: 150, sleeveCm: 73 },
{ size: "4XL", lengthCm: 84, shoulderCm: 57, bustCm: 160, sleeveCm: 74 },
{ size: "5XL", lengthCm: 86, shoulderCm: 59.5, bustCm: 170, sleeveCm: 75 },
{ size: "6XL", lengthCm: 89, shoulderCm: 62, bustCm: 180, sleeveCm: 76 },
],
},
];
/** Unit conversion only; never converts supplier Bust or regional letter sizes. */
export function merchMeasurement(cm: number, unit: MerchSizingUnit): number {
return unit === "in" ? cm / 2.54 : cm;
}
+10
View File
@@ -0,0 +1,10 @@
import type { MerchProductId } from "./catalog";
export type Address = { name: string; line1: string; line2: string; city: string; region: string; postalCode: string; phone: string };
export type QuoteInput = { productId: MerchProductId; size: string; color: "White"; quantity: number; country: string; address: Address; contributor?: { github: string; contribution: string; language: string; phraseIndex: number } };
export type ProductRoute = { approved: boolean; designCode: string; contributorDesigns?: Record<string, string[]>; skuBySize: Record<string, string>; productionCostUsd: number };
export type CountryRoute = { regionCode: string; shippingLevelCode: string; currency: "cny" | "usd"; usdExchangeRate: number; feeRate: number; flatFeeUsd: number; taxReviewed: boolean; taxNote: string; supplierPricesUsdReviewed: boolean; addressPolicyReviewed: boolean; allowedPostalPrefixes: string[]; blockedPostalPrefixes?: string[] };
export type MerchConfig = { mode: "test" | "live"; checkoutEnabled: boolean; supplierSubmissionEnabled?: boolean; siteOrigin: string; countries: Record<string, CountryRoute>; products: Partial<Record<MerchProductId, Record<string, ProductRoute>>> };
export type Quote = { quoteId: string; mode: "test" | "live"; routeApproval: string; expiresAt: number; currency: "cny" | "usd"; merchandise: number; shipping: number; processing: number; total: number; shippingName: string; taxNote: string; input: QuoteInput; skuCode: string; designCode: string; shippingLevelCode: string; vendorProductionUsd: number; vendorShippingUsd: number };
export interface D1Statement { bind(...values: unknown[]): D1Statement; first<T>(): Promise<T | null>; all<T>(): Promise<{ results: T[] }>; run(): Promise<{ meta: { changes: number } }>; }
export interface MerchDatabase { prepare(sql: string): D1Statement; batch(statements: D1Statement[]): Promise<unknown[]>; }
export interface MerchEnv { MERCH_DB?: MerchDatabase; MERCH_RATE_LIMITER?: { limit(value: { key: string }): Promise<{ success: boolean }> }; MERCH_CONFIG_JSON?: string; MERCH_STRIPE_KEY?: string; MERCH_STRIPE_WEBHOOK_SECRET?: string; MERCH_PAYMENT_CONFIGURATION?: string; YOYCOL_ACCESS_KEY?: string; YOYCOL_SECRET_KEY?: string; }
+30
View File
@@ -0,0 +1,30 @@
import { createHmac, randomBytes } from "node:crypto";
import { MerchError, object } from "./model";
import type { MerchEnv } from "./types";
import { readBoundedBody } from "../bounded-body";
const API = "https://www.yoycol.com";
export function yoycolHeaders(method: string, path: string, query: Record<string, string>, accessKey: string, secretKey: string, timestamp = String(Date.now()), nonce = randomBytes(16).toString("hex")) {
const lines = ["method=" + method.toUpperCase(), "path=" + path, "timestamp=" + timestamp, "nonce=" + nonce, "accessKey=" + accessKey, "algorithm=HmacSHA256", "version=4.0"];
const params = Object.keys(query).sort().map(key => key + "=" + query[key]).join("&");
if (params) lines.push("params=" + params);
return { "X-API-Access-Key": accessKey, "X-API-Timestamp": timestamp, "X-API-Nonce": nonce, "X-API-Algorithm": "HmacSHA256", "X-API-Version": "4.0", "X-API-Signature": createHmac("sha256", secretKey).update(lines.join("\n")).digest("base64") };
}
/** Official v4 API. POST orders is never retried: its external idempotency is undocumented. */
export async function yoycolRequest(env: MerchEnv, method: "GET" | "POST", path: string, query: Record<string, string> = {}, body?: unknown, send: typeof fetch = fetch): Promise<unknown> {
if (!env.YOYCOL_ACCESS_KEY || !env.YOYCOL_SECRET_KEY || !/^\/api\/2025\/open\/v4\/(shipping\/(sku_quotes|levels)|orders(\/\d+(\/tracking)?)?)$/.test(path) || (method === "POST" && path !== "/api/2025/open/v4/orders")) throw new MerchError(503, "vendor_unavailable", "Fulfillment is not configured yet.");
const url = new URL(path, API); Object.entries(query).forEach(([k,v]) => url.searchParams.set(k,v));
const response = await send(url, { method, headers: { ...yoycolHeaders(method, path, query, env.YOYCOL_ACCESS_KEY, env.YOYCOL_SECRET_KEY), "Content-Type": "application/json" }, ...(body ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(12000), redirect: "error" });
if (!response.ok) throw new MerchError(503, "vendor_unavailable", "Fulfillment prices are temporarily unavailable.");
const envelope = object(JSON.parse(new TextDecoder().decode(await readBoundedBody(response, 262144))));
if (String(envelope.code) !== "100000") throw new MerchError(503, "vendor_unavailable", "The supplier could not confirm this request.");
return envelope.data;
}
export function destinationRate(data: unknown, regionCode: string, levelCode: string, quantity: number) {
if (!Array.isArray(data)) throw new MerchError(503, "quote_unavailable", "The supplier could not confirm a delivery rate.");
const region = data.map(object).find(r => r.regionCode === regionCode);
const levels = region?.levels;
const level = Array.isArray(levels) ? levels.map(object).find(l => l.levelCode === levelCode) : null;
if (!level || typeof level.firstPrice !== "number" || typeof level.additionalPrice !== "number" || !Number.isFinite(level.firstPrice) || !Number.isFinite(level.additionalPrice) || level.firstPrice < 0 || level.additionalPrice < 0 || !Number.isInteger(quantity) || quantity < 1 || quantity > 5) throw new MerchError(409, "address_unavailable", "No reviewed delivery service is available for this destination.");
return { shippingUsd: level.firstPrice + (quantity - 1) * level.additionalPrice, name: typeof level.levelName === "string" ? level.levelName : levelCode };
}
+20
View File
@@ -0,0 +1,20 @@
-- Separate commerce database: do not apply to the community KV or product balances.
CREATE TABLE IF NOT EXISTS merch_orders (
id TEXT PRIMARY KEY,
quote_json TEXT NOT NULL,
expires_at INTEGER NOT NULL,
stripe_session_id TEXT UNIQUE,
checkout_url TEXT,
payment_state TEXT NOT NULL DEFAULT 'quoted',
fulfillment_state TEXT NOT NULL DEFAULT 'not_ready',
supplier_order_id TEXT UNIQUE,
supplier_receipt_json TEXT,
tracking_json TEXT,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS merch_webhook_events (
id TEXT PRIMARY KEY,
order_id TEXT NOT NULL REFERENCES merch_orders(id),
kind TEXT NOT NULL,
received_at INTEGER NOT NULL
);
+23 -5
View File
@@ -10,7 +10,8 @@
"dependencies": {
"next": "^16.3.6",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"stripe": "23.0.0"
},
"devDependencies": {
"@opennextjs/cloudflare": "^1.20.6",
@@ -4771,11 +4772,11 @@
"version": "26.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.1.tgz",
"integrity": "sha512-VqGJBMCtdhqkBUCcBLvywI0NJ+KLuVzgNnlBUNFOQjqVxzo2lxLUNg1DSey8+u2u6ktswSAxg+s68QLzWHNOuA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"devOptional": true
},
"node_modules/@types/node-fetch": {
"version": "2.6.13",
@@ -13894,6 +13895,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "23.0.0",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-23.0.0.tgz",
"integrity": "sha512-MDE2A5ZVVluYURORaZEVghSmnLJc3w6aHAlZSxDQMsOjOb3V0O6ro/CiaeFlJS4N7uIVmsg98ocRsPmUK6YfyA==",
"license": "MIT",
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@types/node": ">=20"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
@@ -14432,8 +14450,8 @@
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"devOptional": true
},
"node_modules/unenv": {
"version": "2.0.0-rc.24",
+2 -1
View File
@@ -29,7 +29,8 @@
"dependencies": {
"next": "^16.3.6",
"react": "^19.2.8",
"react-dom": "^19.2.8"
"react-dom": "^19.2.8",
"stripe": "23.0.0"
},
"devDependencies": {
"@opennextjs/cloudflare": "^1.20.6",
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="7.0000in" height="6.0000in" viewBox="0 0 2100.0000 1800.0000" role="img" aria-label="Codewhale core classic, navy, center front"><title>Codewhale core classic</title><desc>One-color outlined vector candidate. Placement: center front. No background. Physical sample, supplier template and quote pending.</desc><g fill="#102C46"><g data-official-mark="true" transform="translate(660.0000 80.0000) scale(1.5234)"><path fill-rule="evenodd" d="M215.77 40.96C208.55 41.41 204.88 41.74 201.43 42.19C197 42.77 191.11 43.68 190.66 43.83C190.48 43.9 189.58 44.05 188.65 44.2C185.38 44.71 171.46 48.1 167.61 49.36C166.23 49.81 163.99 50.54 162.68 50.97C157.57 52.61 150.43 55.35 142.93 58.55C140.59 59.53 130.65 64.41 130.1 64.84C129.97 64.94 128.72 65.62 127.33 66.34C125.95 67.07 123.23 68.63 121.29 69.82C119.36 71 117.09 72.36 116.29 72.84C98.57 83.3 78.57 101.12 63.12 120.19C61.59 122.07 58.34 126.28 57.87 126.98C57.54 127.46 56.88 128.41 56.38 129.12C55.6 130.2 54.37 132.01 50.39 137.92C48.61 140.57 47.68 142.13 44.71 147.36C43.02 150.33 41.49 153.05 41.29 153.4C40.78 154.25 36.13 163.69 35.12 165.85C33.76 168.82 33.66 169.05 32.61 171.64C30.62 176.47 29.99 178.08 28.86 181.45C28.45 182.68 27.85 184.34 27.55 185.1C27.27 185.85 26.79 187.19 26.54 188.04C26.27 188.92 25.81 190.38 25.54 191.31C24.6 194.28 22.89 200.62 22.52 202.38C22.42 202.99 22.14 204.04 21.96 204.77C21.61 206.03 21.18 208.05 20.05 213.53C18.84 219.37 17.71 226.51 17.26 231.14C17.11 232.6 16.93 233.96 16.88 234.16C16.58 235.19 16 249.13 16 255.14C16.03 272.78 18.54 293.84 22.06 306.09C22.34 306.97 22.54 307.88 22.54 308.11C22.54 308.33 22.77 309.36 23.07 310.4C23.37 311.43 23.82 313.06 24.08 314.04C25.38 318.77 27.47 325.06 30.44 333.29C30.67 333.92 30.94 334.65 31.07 334.93C31.22 335.2 31.67 336.29 32.1 337.32C32.53 338.35 33.03 339.53 33.23 339.96C33.41 340.36 34.12 341.95 34.77 343.48C36 346.37 41.29 357.09 42.42 358.95C42.82 359.63 45.54 364.34 45.84 364.87C46.57 366.2 47.93 368.31 49.09 369.9C50.27 371.56 50.77 372.29 53.11 375.71C57.92 382.83 65.01 391.39 71.73 398.1C75.96 402.35 76.56 402.83 80.66 405.53C93.59 414.03 103.63 418.51 118.65 422.48C130.05 425.5 149.5 425.93 163.06 423.46C171.06 422 181.4 418.81 189.98 415.19C203.24 409.58 217.93 399.56 228.68 388.82C232.6 384.89 231.34 387.84 225.25 396.9C220.07 404.62 210.76 414.18 202.06 420.72C184.52 433.88 166.28 441.63 143.56 445.53C140.89 445.98 140.09 446.61 141.12 447.47C141.82 448.05 148.82 451.39 153.62 453.43C188.6 468.3 224.17 474.06 262.32 471.04C283.05 469.38 301.29 466.14 318.42 461C328.64 457.96 329.89 457.56 337.42 454.76C359.08 446.76 381.9 434.33 402.3 419.41C414.38 410.61 431.87 394.53 440.75 384.09C443.26 381.15 444.93 379.18 445.98 377.95C446.46 377.37 447.37 376.29 447.99 375.56C449.63 373.62 456.3 364.67 459.57 360.04C460.85 358.22 462.13 356.24 465.76 350.53C466.61 349.17 466.74 348.97 467.37 347.88C467.72 347.31 468.12 346.63 468.25 346.4C468.98 345.24 471.44 341.04 472.12 339.76C472.55 338.98 473.26 337.67 473.71 336.86C474.14 336.08 475.14 334.17 475.92 332.66C476.73 331.13 477.78 329.09 478.29 328.13C478.82 327.15 479.8 325.06 480.48 323.48C481.18 321.89 481.89 320.23 482.09 319.83C482.26 319.4 482.72 318.35 483.09 317.44C483.45 316.53 483.85 315.58 483.97 315.3C484.1 315.03 484.5 314.07 484.85 313.16C485.23 312.26 485.73 311.05 486.01 310.45C486.26 309.87 486.49 309.26 486.49 309.11C486.49 308.96 486.64 308.48 486.84 308.06C487.04 307.6 487.5 306.45 487.87 305.49C488.23 304.51 488.7 303.33 488.9 302.85C489.46 301.46 491.5 294.92 492.5 291.15C492.78 290.17 493.28 288.33 493.63 287.02C498.42 269.79 499.55 245.46 496 236.55C492.08 226.66 484.23 221.93 467.32 219.22C461.05 218.21 437.15 217.78 426.99 218.51C403.97 220.17 389.25 222.19 369.12 226.49C367.46 226.84 364.99 227.34 363.66 227.62C359.26 228.53 340.61 233.36 329.49 236.48C320.36 239.02 302.29 244.7 290.87 248.63C286.59 250.11 282.62 251.45 282.07 251.62C281.26 251.9 268.98 256.35 265.33 257.71C264.86 257.89 263.37 258.44 262.06 258.92C260.76 259.4 258.21 260.35 256.4 261.08C254.62 261.79 252.73 262.52 252.25 262.69C251.77 262.87 249.91 263.6 248.1 264.33C246.31 265.03 244.1 265.91 243.19 266.24C242.29 266.57 235.22 269.36 227.47 272.43C203.49 281.94 201.33 282.74 197.65 283.85C197.03 284.03 195.57 284.53 194.38 284.93C193.2 285.34 191.24 285.99 189.98 286.37C188.75 286.72 187.26 287.2 186.71 287.37C185.75 287.7 183.04 288.46 179.16 289.51C178.21 289.76 176.32 290.22 175.01 290.52C173.7 290.8 171.87 291.2 170.93 291.43C170.03 291.63 168.27 291.98 167.03 292.15C165.83 292.36 164.09 292.63 163.19 292.76C157.6 293.66 155.64 293.84 150.1 294.04C143.94 294.27 136.06 294.02 133.92 293.56C133.35 293.44 131.91 293.19 130.73 293.04C129.55 292.88 127.96 292.58 127.21 292.36C123.89 291.43 119.56 289.99 118.22 289.36C117.42 289.01 115.91 288.31 114.88 287.83C107.28 284.3 99.61 277.64 94.47 270.14C92.39 267.1 89.89 262.29 88.84 259.32C87.43 255.35 86.02 249.91 85.19 245.36C85.06 244.65 8Line truncated

After

Width:  |  Height:  |  Size: 24 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8.0in" height="6.0in" viewBox="0 0 2400.0 1800.0" role="img" aria-label="Codewhale simple compiling print design"><title>Codewhale simple compiling</title><desc>Outlined single-color vector candidate; official whale geometry unchanged. Physical sample and supplier template review pending.</desc><g fill="#102C46"><g aria-label="COMPILING..." transform="translate(256.9901 654.0520) scale(2.9524 -2.9524)"><path d="M40.3000 64.5000Q34.6000 64.5000 30.0000 62.5500Q25.4000 60.6000 22.2000 56.8500Q19.0000 53.1000 17.3000 47.7500Q15.6000 42.4000 15.6000 35.7000Q15.6000 26.9000 18.3500 20.4000Q21.1000 13.9000 26.5500 10.4000Q32.0000 6.9000 40.2000 6.9000Q44.9000 6.9000 49.1000 7.7000Q53.3000 8.5000 57.3000 9.7000L57.3000 1.9000Q53.3000 0.4000 49.0500 -0.3000Q44.8000 -1.0000 38.9000 -1.0000Q28.0000 -1.0000 20.7000 3.5000Q13.4000 8.0000 9.7500 16.3000Q6.1000 24.6000 6.1000 35.8000Q6.1000 43.9000 8.3500 50.6000Q10.6000 57.3000 14.9500 62.2000Q19.3000 67.1000 25.7000 69.7500Q32.1000 72.4000 40.4000 72.4000Q45.9000 72.4000 51.0000 71.3000Q56.1000 70.2000 60.1000 68.2000L56.5000 60.6000Q53.2000 62.1000 49.1500 63.3000Q45.1000 64.5000 40.3000 64.5000ZM133.2000 35.8000Q133.2000 27.5000 131.1000 20.7500Q129.0000 14.0000 124.8500 9.1000Q120.7000 4.2000 114.5500 1.6000Q108.4000 -1.0000 100.3000 -1.0000Q91.9000 -1.0000 85.7000 1.6500Q79.5000 4.3000 75.4000 9.1500Q71.3000 14.0000 69.3000 20.8000Q67.3000 27.6000 67.3000 35.9000Q67.3000 46.9000 70.9000 55.1000Q74.5000 63.3000 81.8500 67.9000Q89.2000 72.5000 100.4000 72.5000Q111.1000 72.5000 118.4000 67.9500Q125.7000 63.4000 129.4500 55.1500Q133.2000 46.9000 133.2000 35.8000ZM76.8000 35.8000Q76.8000 26.8000 79.3000 20.3000Q81.8000 13.8000 87.0500 10.3000Q92.3000 6.8000 100.3000 6.8000Q108.4000 6.8000 113.5500 10.3000Q118.7000 13.8000 121.2000 20.3000Q123.7000 26.8000 123.7000 35.8000Q123.7000 49.3000 118.1000 56.9500Q112.5000 64.6000 100.4000 64.6000Q92.3000 64.6000 87.0500 61.1500Q81.8000 57.7000 79.3000 51.2500Q76.8000 44.8000 76.8000 35.8000ZM180.5000 0.0000L157.0000 62.6000L156.6000 62.6000Q156.8000 60.6000 156.9500 57.5000Q157.1000 54.4000 157.2000 50.7500Q157.3000 47.1000 157.3000 43.3000L157.3000 0.0000L149.0000 0.0000L149.0000 71.4000L162.3000 71.4000L184.3000 12.9000L184.7000 12.9000L207.1000 71.4000L220.3000 71.4000L220.3000 0.0000L211.4000 0.0000L211.4000 43.9000Q211.4000 47.4000 211.5000 50.8500Q211.6000 54.3000 211.8000 57.3500Q212.0000 60.4000 212.1000 62.5000L211.7000 62.5000L187.9000 0.0000ZM258.6000 71.4000Q272.6000 71.4000 279.0000 65.9000Q285.4000 60.4000 285.4000 50.4000Q285.4000 46.0000 283.9500 41.9500Q282.5000 37.9000 279.2000 34.7000Q275.9000 31.5000 270.4000 29.6500Q264.9000 27.8000 256.9000 27.8000L248.7000 27.8000L248.7000 0.0000L239.7000 0.0000L239.7000 71.4000ZM257.8000 63.7000L248.7000 63.7000L248.7000 35.5000L255.9000 35.5000Q262.7000 35.5000 267.2000 36.9500Q271.7000 38.4000 273.9000 41.6000Q276.1000 44.8000 276.1000 50.0000Q276.1000 56.9000 271.7000 60.3000Q267.3000 63.7000 257.8000 63.7000ZM294.5000 0.0000L294.5000 6.3000L303.1000 6.3000L303.1000 65.1000L294.5000 65.1000L294.5000 71.4000L320.3000 71.4000L320.3000 65.1000L311.8000 65.1000L311.8000 6.3000L320.3000 6.3000L320.3000 0.0000ZM334.1000 0.0000L334.1000 71.4000L343.1000 71.4000L343.1000 8.0000L374.3000 8.0000L374.3000 0.0000ZM380.8000 0.0000L380.8000 6.3000L389.4000 6.3000L389.4000 65.1000L380.8000 65.1000L380.8000 71.4000L406.6000 71.4000L406.6000 65.1000L398.1000 65.1000L398.1000 6.3000L406.6000 6.3000L406.6000 0.0000ZM477.0000 0.0000L466.5000 0.0000L428.3000 59.3000L427.9000 59.3000Q428.1000 57.0000 428.2500 53.8500Q428.4000 50.7000 428.5500 47.1500Q428.7000 43.6000 428.7000 39.9000L428.7000 0.0000L420.4000 0.0000L420.4000 71.4000L430.8000 71.4000L468.9000 12.3000L469.3000 12.3000Q469.2000 13.9000 469.0500 17.1000Q468.9000 20.3000 468.7500 24.1000Q468.6000 27.9000 468.6000 31.1000L468.6000 71.4000L477.0000 71.4000ZM551.7000 66.2000Q544.6000 72.4000 522.5000 72.4000Q492.8000 72.4000 492.8000 36.2000Q492.8000 -1.0000 522.5000 -1.0000Q547.4000 -1.0000 552.1000 5.0000L552.1000 37.5000L522.9000 37.5000L522.9000 28.8000L543.4000 28.8000L543.4000 10.6000Q542.3000 7.7000 522.5000 7.7000Q501.5000 7.7000 501.5000 36.2000Q501.5000 63.7000 522.5000 63.7000Q543.9000 63.7000 549.0000 59.9000ZM566.7000 5.4000Q566.7000 9.1000 568.5000 10.6000Q570.3000 12.1000 572.8000 12.1000Q575.4000 12.1000 577.2500 10.6000Q579.1000 9.1000 579.1000 5.4000Q579.1000 1.8000 577.2500 0.2000Q575.4000 -1.4000 572.8000 -1.4000Q570.3000 -1.4000 568.5000 0.2000Q566.7000 1.8000 566.7000 5.4000ZM593.5000 5.4000Q593.5000 9.1000 595.3000 10.6000Q597.1000 12.1000 599.6000 12.1000Q602.2000 12.1000 604.0500 10.6000Q605.9000 9.1000 605.9000 5.4000Q605.9000 1.8000 604.0500 0.2000Q602.2000 -1.4000 599.6000 -1.4000Q597.1000 -1.4000 595.3000 0.2000Q593.5000 1.8000 593.5000 5.4000ZM620.3000 5.4000Q620.3000 9.1000 622.1000 10.6000Q623.9000 12.1000 626.4000 12.1000Q629.0000 12.1000 630.8500 10.6Line truncated

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="6.0000in" height="2.0000in" viewBox="0 0 1800.0000 600.0000" role="img" aria-label="Codewhale core field-back, navy, upper back"><title>Codewhale core field-back</title><desc>One-color outlined vector candidate. Placement: upper back. No background. Physical sample, supplier template and quote pending.</desc><g fill="#102C46"><g aria-label="Codewhale" data-outlined-type="true" transform="translate(157.5577 297.3141) scale(2.8594 -2.8594)"><path d="M40.3000 64.5000Q34.6000 64.5000 30.0000 62.5500Q25.4000 60.6000 22.2000 56.8500Q19.0000 53.1000 17.3000 47.7500Q15.6000 42.4000 15.6000 35.7000Q15.6000 26.9000 18.3500 20.4000Q21.1000 13.9000 26.5500 10.4000Q32.0000 6.9000 40.2000 6.9000Q44.9000 6.9000 49.1000 7.7000Q53.3000 8.5000 57.3000 9.7000L57.3000 1.9000Q53.3000 0.4000 49.0500 -0.3000Q44.8000 -1.0000 38.9000 -1.0000Q28.0000 -1.0000 20.7000 3.5000Q13.4000 8.0000 9.7500 16.3000Q6.1000 24.6000 6.1000 35.8000Q6.1000 43.9000 8.3500 50.6000Q10.6000 57.3000 14.9500 62.2000Q19.3000 67.1000 25.7000 69.7500Q32.1000 72.4000 40.4000 72.4000Q45.9000 72.4000 51.0000 71.3000Q56.1000 70.2000 60.1000 68.2000L56.5000 60.6000Q53.2000 62.1000 49.1500 63.3000Q45.1000 64.5000 40.3000 64.5000ZM118.3000 26.9000Q118.3000 20.2000 116.5500 15.0500Q114.8000 9.9000 111.5500 6.3000Q108.3000 2.7000 103.6500 0.8500Q99.0000 -1.0000 93.3000 -1.0000Q88.0000 -1.0000 83.5000 0.8500Q79.0000 2.7000 75.7000 6.3000Q72.4000 9.9000 70.5500 15.0500Q68.7000 20.2000 68.7000 26.9000Q68.7000 35.8000 71.7000 41.9500Q74.7000 48.1000 80.3000 51.3500Q85.9000 54.6000 93.6000 54.6000Q100.9000 54.6000 106.4500 51.3500Q112.0000 48.1000 115.1500 41.9500Q118.3000 35.8000 118.3000 26.9000ZM77.8000 26.9000Q77.8000 20.6000 79.4500 15.9500Q81.1000 11.3000 84.6000 8.8000Q88.1000 6.3000 93.5000 6.3000Q98.9000 6.3000 102.4000 8.8000Q105.9000 11.3000 107.5500 15.9500Q109.2000 20.6000 109.2000 26.9000Q109.2000 33.3000 107.5000 37.8000Q105.8000 42.3000 102.3500 44.7500Q98.9000 47.2000 93.4000 47.2000Q85.2000 47.2000 81.5000 41.8000Q77.8000 36.4000 77.8000 26.9000ZM151.2000 -1.0000Q141.2000 -1.0000 135.2000 5.9500Q129.2000 12.9000 129.2000 26.7000Q129.2000 40.5000 135.2500 47.5500Q141.3000 54.6000 151.3000 54.6000Q155.5000 54.6000 158.6000 53.5500Q161.7000 52.5000 164.0000 50.7000Q166.3000 48.9000 167.9000 46.7000L168.5000 46.7000Q168.4000 48.0000 168.1500 50.5500Q167.9000 53.1000 167.9000 54.6000L167.9000 76.0000L176.7000 76.0000L176.7000 0.0000L169.6000 0.0000L168.3000 7.2000L167.9000 7.2000Q166.3000 4.9000 164.0000 3.0500Q161.7000 1.2000 158.5500 0.1000Q155.4000 -1.0000 151.2000 -1.0000ZM152.6000 6.3000Q161.1000 6.3000 164.5500 10.9500Q168.0000 15.6000 168.0000 25.0000L168.0000 26.6000Q168.0000 36.6000 164.7000 41.9500Q161.4000 47.3000 152.5000 47.3000Q145.4000 47.3000 141.8500 41.6500Q138.3000 36.0000 138.3000 26.5000Q138.3000 16.9000 141.8500 11.6000Q145.4000 6.3000 152.6000 6.3000ZM214.4000 54.6000Q221.3000 54.6000 226.2500 51.6000Q231.2000 48.6000 233.8500 43.1500Q236.5000 37.7000 236.5000 30.4000L236.5000 25.1000L199.8000 25.1000Q200.0000 16.0000 204.4500 11.2500Q208.9000 6.5000 216.9000 6.5000Q222.0000 6.5000 225.9500 7.4500Q229.9000 8.4000 234.1000 10.2000L234.1000 2.5000Q230.0000 0.7000 226.0000 -0.1500Q222.0000 -1.0000 216.5000 -1.0000Q208.9000 -1.0000 203.0500 2.1000Q197.2000 5.2000 193.9500 11.3500Q190.7000 17.5000 190.7000 26.4000Q190.7000 35.2000 193.6500 41.5000Q196.6000 47.8000 201.9500 51.2000Q207.3000 54.6000 214.4000 54.6000ZM214.3000 47.4000Q208.0000 47.4000 204.3500 43.3500Q200.7000 39.3000 200.0000 32.1000L227.3000 32.1000Q227.3000 36.7000 225.9000 40.1000Q224.5000 43.5000 221.6500 45.4500Q218.8000 47.4000 214.3000 47.4000ZM282.7000 30.3000Q282.1000 32.4000 281.5000 34.4500Q280.9000 36.5000 280.4500 38.3500Q280.0000 40.2000 279.6000 41.8000Q279.2000 43.4000 279.0000 44.5000L278.6000 44.5000Q278.4000 43.4000 278.0500 41.8000Q277.7000 40.2000 277.2500 38.3000Q276.8000 36.4000 276.2500 34.3500Q275.7000 32.3000 275.0000 30.2000L265.4000 0.1000L255.4000 0.1000L240.7000 53.7000L249.8000 53.7000L257.2000 25.1000Q258.0000 22.2000 258.7000 19.2500Q259.4000 16.3000 259.9500 13.6500Q260.5000 11.0000 260.7000 9.1000L261.1000 9.1000Q261.4000 10.3000 261.8000 12.1000Q262.2000 13.9000 262.6500 15.9000Q263.1000 17.9000 263.6500 19.9000Q264.2000 21.9000 264.7000 23.5000L274.2000 53.7000L283.8000 53.7000L293.0000 23.5000Q293.7000 21.2000 294.4500 18.6000Q295.2000 16.0000 295.8000 13.5500Q296.4000 11.1000 296.6000 9.2000L297.0000 9.2000Q297.2000 10.9000 297.7500 13.4500Q298.3000 16.0000 299.0500 19.0500Q299.8000 22.1000 300.6000 25.1000L308.1000 53.7000L317.1000 53.7000L302.2000 0.1000L291.9000 0.1000ZM326.7000 0.0000L326.7000 76.0000L335.4000 76.0000L335.4000 43.7000Q339.9000 54.6000 349.0000 54.6000Q371.9000 54.6000 371.9000 36.3000L371.9000 0.0000L363.2000 0.0000L363.2000 37.1000Q363.2000 45.9000 346.6000 45.9000Q335.4000 45.9000 335.4000 32.9000L335.4000 0.0000ZM386.8000 48.2000Q395.0000 54.6000 406.3000 54.6000Q428.0Line truncated

After

Width:  |  Height:  |  Size: 14 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="3.6000in" height="1.4000in" viewBox="0 0 1080.0000 420.0000" role="img" aria-label="Codewhale core field-front, navy, left chest"><title>Codewhale core field-front</title><desc>One-color outlined vector candidate. Placement: left chest. No background. Physical sample, supplier template and quote pending.</desc><g fill="#102C46"><g data-official-mark="true" transform="translate(30.0000 70.0000) scale(0.4688)"><path fill-rule="evenodd" d="M215.77 40.96C208.55 41.41 204.88 41.74 201.43 42.19C197 42.77 191.11 43.68 190.66 43.83C190.48 43.9 189.58 44.05 188.65 44.2C185.38 44.71 171.46 48.1 167.61 49.36C166.23 49.81 163.99 50.54 162.68 50.97C157.57 52.61 150.43 55.35 142.93 58.55C140.59 59.53 130.65 64.41 130.1 64.84C129.97 64.94 128.72 65.62 127.33 66.34C125.95 67.07 123.23 68.63 121.29 69.82C119.36 71 117.09 72.36 116.29 72.84C98.57 83.3 78.57 101.12 63.12 120.19C61.59 122.07 58.34 126.28 57.87 126.98C57.54 127.46 56.88 128.41 56.38 129.12C55.6 130.2 54.37 132.01 50.39 137.92C48.61 140.57 47.68 142.13 44.71 147.36C43.02 150.33 41.49 153.05 41.29 153.4C40.78 154.25 36.13 163.69 35.12 165.85C33.76 168.82 33.66 169.05 32.61 171.64C30.62 176.47 29.99 178.08 28.86 181.45C28.45 182.68 27.85 184.34 27.55 185.1C27.27 185.85 26.79 187.19 26.54 188.04C26.27 188.92 25.81 190.38 25.54 191.31C24.6 194.28 22.89 200.62 22.52 202.38C22.42 202.99 22.14 204.04 21.96 204.77C21.61 206.03 21.18 208.05 20.05 213.53C18.84 219.37 17.71 226.51 17.26 231.14C17.11 232.6 16.93 233.96 16.88 234.16C16.58 235.19 16 249.13 16 255.14C16.03 272.78 18.54 293.84 22.06 306.09C22.34 306.97 22.54 307.88 22.54 308.11C22.54 308.33 22.77 309.36 23.07 310.4C23.37 311.43 23.82 313.06 24.08 314.04C25.38 318.77 27.47 325.06 30.44 333.29C30.67 333.92 30.94 334.65 31.07 334.93C31.22 335.2 31.67 336.29 32.1 337.32C32.53 338.35 33.03 339.53 33.23 339.96C33.41 340.36 34.12 341.95 34.77 343.48C36 346.37 41.29 357.09 42.42 358.95C42.82 359.63 45.54 364.34 45.84 364.87C46.57 366.2 47.93 368.31 49.09 369.9C50.27 371.56 50.77 372.29 53.11 375.71C57.92 382.83 65.01 391.39 71.73 398.1C75.96 402.35 76.56 402.83 80.66 405.53C93.59 414.03 103.63 418.51 118.65 422.48C130.05 425.5 149.5 425.93 163.06 423.46C171.06 422 181.4 418.81 189.98 415.19C203.24 409.58 217.93 399.56 228.68 388.82C232.6 384.89 231.34 387.84 225.25 396.9C220.07 404.62 210.76 414.18 202.06 420.72C184.52 433.88 166.28 441.63 143.56 445.53C140.89 445.98 140.09 446.61 141.12 447.47C141.82 448.05 148.82 451.39 153.62 453.43C188.6 468.3 224.17 474.06 262.32 471.04C283.05 469.38 301.29 466.14 318.42 461C328.64 457.96 329.89 457.56 337.42 454.76C359.08 446.76 381.9 434.33 402.3 419.41C414.38 410.61 431.87 394.53 440.75 384.09C443.26 381.15 444.93 379.18 445.98 377.95C446.46 377.37 447.37 376.29 447.99 375.56C449.63 373.62 456.3 364.67 459.57 360.04C460.85 358.22 462.13 356.24 465.76 350.53C466.61 349.17 466.74 348.97 467.37 347.88C467.72 347.31 468.12 346.63 468.25 346.4C468.98 345.24 471.44 341.04 472.12 339.76C472.55 338.98 473.26 337.67 473.71 336.86C474.14 336.08 475.14 334.17 475.92 332.66C476.73 331.13 477.78 329.09 478.29 328.13C478.82 327.15 479.8 325.06 480.48 323.48C481.18 321.89 481.89 320.23 482.09 319.83C482.26 319.4 482.72 318.35 483.09 317.44C483.45 316.53 483.85 315.58 483.97 315.3C484.1 315.03 484.5 314.07 484.85 313.16C485.23 312.26 485.73 311.05 486.01 310.45C486.26 309.87 486.49 309.26 486.49 309.11C486.49 308.96 486.64 308.48 486.84 308.06C487.04 307.6 487.5 306.45 487.87 305.49C488.23 304.51 488.7 303.33 488.9 302.85C489.46 301.46 491.5 294.92 492.5 291.15C492.78 290.17 493.28 288.33 493.63 287.02C498.42 269.79 499.55 245.46 496 236.55C492.08 226.66 484.23 221.93 467.32 219.22C461.05 218.21 437.15 217.78 426.99 218.51C403.97 220.17 389.25 222.19 369.12 226.49C367.46 226.84 364.99 227.34 363.66 227.62C359.26 228.53 340.61 233.36 329.49 236.48C320.36 239.02 302.29 244.7 290.87 248.63C286.59 250.11 282.62 251.45 282.07 251.62C281.26 251.9 268.98 256.35 265.33 257.71C264.86 257.89 263.37 258.44 262.06 258.92C260.76 259.4 258.21 260.35 256.4 261.08C254.62 261.79 252.73 262.52 252.25 262.69C251.77 262.87 249.91 263.6 248.1 264.33C246.31 265.03 244.1 265.91 243.19 266.24C242.29 266.57 235.22 269.36 227.47 272.43C203.49 281.94 201.33 282.74 197.65 283.85C197.03 284.03 195.57 284.53 194.38 284.93C193.2 285.34 191.24 285.99 189.98 286.37C188.75 286.72 187.26 287.2 186.71 287.37C185.75 287.7 183.04 288.46 179.16 289.51C178.21 289.76 176.32 290.22 175.01 290.52C173.7 290.8 171.87 291.2 170.93 291.43C170.03 291.63 168.27 291.98 167.03 292.15C165.83 292.36 164.09 292.63 163.19 292.76C157.6 293.66 155.64 293.84 150.1 294.04C143.94 294.27 136.06 294.02 133.92 293.56C133.35 293.44 131.91 293.19 130.73 293.04C129.55 292.88 127.96 292.58 127.21 292.36C123.89 291.43 119.56 289.99 118.22 289.36C117.42 289.01 115.91 288.31 114.88 287.83C107.28 284.3 99.61 277.64 94.47 270.14C92.39 267.1 89.89 262.29 88.84 259.32C87.43 255.35 86.02 249.91 85.19 245.36C85.06 244.65Line truncated

After

Width:  |  Height:  |  Size: 24 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="3.5in" height="3.0in" viewBox="0 0 1050.0 900.0" role="img" aria-label="Codewhale simple hoodie print design"><title>Codewhale simple hoodie</title><desc>Outlined single-color vector candidate; official whale geometry unchanged. Physical sample and supplier template review pending.</desc><g fill="#102C46"><g id="official-codewhale-mark" transform="translate(240.0000 25.0000) scale(1.1133)"><path fill-rule="evenodd" d="M215.77 40.96C208.55 41.41 204.88 41.74 201.43 42.19C197 42.77 191.11 43.68 190.66 43.83C190.48 43.9 189.58 44.05 188.65 44.2C185.38 44.71 171.46 48.1 167.61 49.36C166.23 49.81 163.99 50.54 162.68 50.97C157.57 52.61 150.43 55.35 142.93 58.55C140.59 59.53 130.65 64.41 130.1 64.84C129.97 64.94 128.72 65.62 127.33 66.34C125.95 67.07 123.23 68.63 121.29 69.82C119.36 71 117.09 72.36 116.29 72.84C98.57 83.3 78.57 101.12 63.12 120.19C61.59 122.07 58.34 126.28 57.87 126.98C57.54 127.46 56.88 128.41 56.38 129.12C55.6 130.2 54.37 132.01 50.39 137.92C48.61 140.57 47.68 142.13 44.71 147.36C43.02 150.33 41.49 153.05 41.29 153.4C40.78 154.25 36.13 163.69 35.12 165.85C33.76 168.82 33.66 169.05 32.61 171.64C30.62 176.47 29.99 178.08 28.86 181.45C28.45 182.68 27.85 184.34 27.55 185.1C27.27 185.85 26.79 187.19 26.54 188.04C26.27 188.92 25.81 190.38 25.54 191.31C24.6 194.28 22.89 200.62 22.52 202.38C22.42 202.99 22.14 204.04 21.96 204.77C21.61 206.03 21.18 208.05 20.05 213.53C18.84 219.37 17.71 226.51 17.26 231.14C17.11 232.6 16.93 233.96 16.88 234.16C16.58 235.19 16 249.13 16 255.14C16.03 272.78 18.54 293.84 22.06 306.09C22.34 306.97 22.54 307.88 22.54 308.11C22.54 308.33 22.77 309.36 23.07 310.4C23.37 311.43 23.82 313.06 24.08 314.04C25.38 318.77 27.47 325.06 30.44 333.29C30.67 333.92 30.94 334.65 31.07 334.93C31.22 335.2 31.67 336.29 32.1 337.32C32.53 338.35 33.03 339.53 33.23 339.96C33.41 340.36 34.12 341.95 34.77 343.48C36 346.37 41.29 357.09 42.42 358.95C42.82 359.63 45.54 364.34 45.84 364.87C46.57 366.2 47.93 368.31 49.09 369.9C50.27 371.56 50.77 372.29 53.11 375.71C57.92 382.83 65.01 391.39 71.73 398.1C75.96 402.35 76.56 402.83 80.66 405.53C93.59 414.03 103.63 418.51 118.65 422.48C130.05 425.5 149.5 425.93 163.06 423.46C171.06 422 181.4 418.81 189.98 415.19C203.24 409.58 217.93 399.56 228.68 388.82C232.6 384.89 231.34 387.84 225.25 396.9C220.07 404.62 210.76 414.18 202.06 420.72C184.52 433.88 166.28 441.63 143.56 445.53C140.89 445.98 140.09 446.61 141.12 447.47C141.82 448.05 148.82 451.39 153.62 453.43C188.6 468.3 224.17 474.06 262.32 471.04C283.05 469.38 301.29 466.14 318.42 461C328.64 457.96 329.89 457.56 337.42 454.76C359.08 446.76 381.9 434.33 402.3 419.41C414.38 410.61 431.87 394.53 440.75 384.09C443.26 381.15 444.93 379.18 445.98 377.95C446.46 377.37 447.37 376.29 447.99 375.56C449.63 373.62 456.3 364.67 459.57 360.04C460.85 358.22 462.13 356.24 465.76 350.53C466.61 349.17 466.74 348.97 467.37 347.88C467.72 347.31 468.12 346.63 468.25 346.4C468.98 345.24 471.44 341.04 472.12 339.76C472.55 338.98 473.26 337.67 473.71 336.86C474.14 336.08 475.14 334.17 475.92 332.66C476.73 331.13 477.78 329.09 478.29 328.13C478.82 327.15 479.8 325.06 480.48 323.48C481.18 321.89 481.89 320.23 482.09 319.83C482.26 319.4 482.72 318.35 483.09 317.44C483.45 316.53 483.85 315.58 483.97 315.3C484.1 315.03 484.5 314.07 484.85 313.16C485.23 312.26 485.73 311.05 486.01 310.45C486.26 309.87 486.49 309.26 486.49 309.11C486.49 308.96 486.64 308.48 486.84 308.06C487.04 307.6 487.5 306.45 487.87 305.49C488.23 304.51 488.7 303.33 488.9 302.85C489.46 301.46 491.5 294.92 492.5 291.15C492.78 290.17 493.28 288.33 493.63 287.02C498.42 269.79 499.55 245.46 496 236.55C492.08 226.66 484.23 221.93 467.32 219.22C461.05 218.21 437.15 217.78 426.99 218.51C403.97 220.17 389.25 222.19 369.12 226.49C367.46 226.84 364.99 227.34 363.66 227.62C359.26 228.53 340.61 233.36 329.49 236.48C320.36 239.02 302.29 244.7 290.87 248.63C286.59 250.11 282.62 251.45 282.07 251.62C281.26 251.9 268.98 256.35 265.33 257.71C264.86 257.89 263.37 258.44 262.06 258.92C260.76 259.4 258.21 260.35 256.4 261.08C254.62 261.79 252.73 262.52 252.25 262.69C251.77 262.87 249.91 263.6 248.1 264.33C246.31 265.03 244.1 265.91 243.19 266.24C242.29 266.57 235.22 269.36 227.47 272.43C203.49 281.94 201.33 282.74 197.65 283.85C197.03 284.03 195.57 284.53 194.38 284.93C193.2 285.34 191.24 285.99 189.98 286.37C188.75 286.72 187.26 287.2 186.71 287.37C185.75 287.7 183.04 288.46 179.16 289.51C178.21 289.76 176.32 290.22 175.01 290.52C173.7 290.8 171.87 291.2 170.93 291.43C170.03 291.63 168.27 291.98 167.03 292.15C165.83 292.36 164.09 292.63 163.19 292.76C157.6 293.66 155.64 293.84 150.1 294.04C143.94 294.27 136.06 294.02 133.92 293.56C133.35 293.44 131.91 293.19 130.73 293.04C129.55 292.88 127.96 292.58 127.21 292.36C123.89 291.43 119.56 289.99 118.22 289.36C117.42 289.01 115.91 288.31 114.88 287.83C107.28 284.3 99.61 277.64 94.47 270.14C92.39 267.1 89.89 262.29 88.84 259.32C87.43 255.35 86.02 249.91 85.19 245.36C85.06 244.65 84.79 241.43 84.54Line truncated

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="6.0000in" height="4.6000in" viewBox="0 0 1800.0000 1380.0000" role="img" aria-label="Codewhale core ocean-line, navy, center front"><title>Codewhale core ocean-line</title><desc>One-color outlined vector candidate. Placement: center front. No background. Physical sample, supplier template and quote pending.</desc><g fill="#102C46"><g data-official-mark="true" transform="translate(630.0000 50.0000) scale(1.0547)"><path fill-rule="evenodd" d="M215.77 40.96C208.55 41.41 204.88 41.74 201.43 42.19C197 42.77 191.11 43.68 190.66 43.83C190.48 43.9 189.58 44.05 188.65 44.2C185.38 44.71 171.46 48.1 167.61 49.36C166.23 49.81 163.99 50.54 162.68 50.97C157.57 52.61 150.43 55.35 142.93 58.55C140.59 59.53 130.65 64.41 130.1 64.84C129.97 64.94 128.72 65.62 127.33 66.34C125.95 67.07 123.23 68.63 121.29 69.82C119.36 71 117.09 72.36 116.29 72.84C98.57 83.3 78.57 101.12 63.12 120.19C61.59 122.07 58.34 126.28 57.87 126.98C57.54 127.46 56.88 128.41 56.38 129.12C55.6 130.2 54.37 132.01 50.39 137.92C48.61 140.57 47.68 142.13 44.71 147.36C43.02 150.33 41.49 153.05 41.29 153.4C40.78 154.25 36.13 163.69 35.12 165.85C33.76 168.82 33.66 169.05 32.61 171.64C30.62 176.47 29.99 178.08 28.86 181.45C28.45 182.68 27.85 184.34 27.55 185.1C27.27 185.85 26.79 187.19 26.54 188.04C26.27 188.92 25.81 190.38 25.54 191.31C24.6 194.28 22.89 200.62 22.52 202.38C22.42 202.99 22.14 204.04 21.96 204.77C21.61 206.03 21.18 208.05 20.05 213.53C18.84 219.37 17.71 226.51 17.26 231.14C17.11 232.6 16.93 233.96 16.88 234.16C16.58 235.19 16 249.13 16 255.14C16.03 272.78 18.54 293.84 22.06 306.09C22.34 306.97 22.54 307.88 22.54 308.11C22.54 308.33 22.77 309.36 23.07 310.4C23.37 311.43 23.82 313.06 24.08 314.04C25.38 318.77 27.47 325.06 30.44 333.29C30.67 333.92 30.94 334.65 31.07 334.93C31.22 335.2 31.67 336.29 32.1 337.32C32.53 338.35 33.03 339.53 33.23 339.96C33.41 340.36 34.12 341.95 34.77 343.48C36 346.37 41.29 357.09 42.42 358.95C42.82 359.63 45.54 364.34 45.84 364.87C46.57 366.2 47.93 368.31 49.09 369.9C50.27 371.56 50.77 372.29 53.11 375.71C57.92 382.83 65.01 391.39 71.73 398.1C75.96 402.35 76.56 402.83 80.66 405.53C93.59 414.03 103.63 418.51 118.65 422.48C130.05 425.5 149.5 425.93 163.06 423.46C171.06 422 181.4 418.81 189.98 415.19C203.24 409.58 217.93 399.56 228.68 388.82C232.6 384.89 231.34 387.84 225.25 396.9C220.07 404.62 210.76 414.18 202.06 420.72C184.52 433.88 166.28 441.63 143.56 445.53C140.89 445.98 140.09 446.61 141.12 447.47C141.82 448.05 148.82 451.39 153.62 453.43C188.6 468.3 224.17 474.06 262.32 471.04C283.05 469.38 301.29 466.14 318.42 461C328.64 457.96 329.89 457.56 337.42 454.76C359.08 446.76 381.9 434.33 402.3 419.41C414.38 410.61 431.87 394.53 440.75 384.09C443.26 381.15 444.93 379.18 445.98 377.95C446.46 377.37 447.37 376.29 447.99 375.56C449.63 373.62 456.3 364.67 459.57 360.04C460.85 358.22 462.13 356.24 465.76 350.53C466.61 349.17 466.74 348.97 467.37 347.88C467.72 347.31 468.12 346.63 468.25 346.4C468.98 345.24 471.44 341.04 472.12 339.76C472.55 338.98 473.26 337.67 473.71 336.86C474.14 336.08 475.14 334.17 475.92 332.66C476.73 331.13 477.78 329.09 478.29 328.13C478.82 327.15 479.8 325.06 480.48 323.48C481.18 321.89 481.89 320.23 482.09 319.83C482.26 319.4 482.72 318.35 483.09 317.44C483.45 316.53 483.85 315.58 483.97 315.3C484.1 315.03 484.5 314.07 484.85 313.16C485.23 312.26 485.73 311.05 486.01 310.45C486.26 309.87 486.49 309.26 486.49 309.11C486.49 308.96 486.64 308.48 486.84 308.06C487.04 307.6 487.5 306.45 487.87 305.49C488.23 304.51 488.7 303.33 488.9 302.85C489.46 301.46 491.5 294.92 492.5 291.15C492.78 290.17 493.28 288.33 493.63 287.02C498.42 269.79 499.55 245.46 496 236.55C492.08 226.66 484.23 221.93 467.32 219.22C461.05 218.21 437.15 217.78 426.99 218.51C403.97 220.17 389.25 222.19 369.12 226.49C367.46 226.84 364.99 227.34 363.66 227.62C359.26 228.53 340.61 233.36 329.49 236.48C320.36 239.02 302.29 244.7 290.87 248.63C286.59 250.11 282.62 251.45 282.07 251.62C281.26 251.9 268.98 256.35 265.33 257.71C264.86 257.89 263.37 258.44 262.06 258.92C260.76 259.4 258.21 260.35 256.4 261.08C254.62 261.79 252.73 262.52 252.25 262.69C251.77 262.87 249.91 263.6 248.1 264.33C246.31 265.03 244.1 265.91 243.19 266.24C242.29 266.57 235.22 269.36 227.47 272.43C203.49 281.94 201.33 282.74 197.65 283.85C197.03 284.03 195.57 284.53 194.38 284.93C193.2 285.34 191.24 285.99 189.98 286.37C188.75 286.72 187.26 287.2 186.71 287.37C185.75 287.7 183.04 288.46 179.16 289.51C178.21 289.76 176.32 290.22 175.01 290.52C173.7 290.8 171.87 291.2 170.93 291.43C170.03 291.63 168.27 291.98 167.03 292.15C165.83 292.36 164.09 292.63 163.19 292.76C157.6 293.66 155.64 293.84 150.1 294.04C143.94 294.27 136.06 294.02 133.92 293.56C133.35 293.44 131.91 293.19 130.73 293.04C129.55 292.88 127.96 292.58 127.21 292.36C123.89 291.43 119.56 289.99 118.22 289.36C117.42 289.01 115.91 288.31 114.88 287.83C107.28 284.3 99.61 277.64 94.47 270.14C92.39 267.1 89.89 262.29 88.84 259.32C87.43 255.35 86.02 249.91 85.19 245.36C85.06 24Line truncated

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="3.6000in" height="1.4000in" viewBox="0 0 1080.0000 420.0000" role="img" aria-label="Codewhale core signature, navy, left chest"><title>Codewhale core signature</title><desc>One-color outlined vector candidate. Placement: left chest. No background. Physical sample, supplier template and quote pending.</desc><g fill="#102C46"><g data-official-mark="true" transform="translate(30.0000 70.0000) scale(0.4688)"><path fill-rule="evenodd" d="M215.77 40.96C208.55 41.41 204.88 41.74 201.43 42.19C197 42.77 191.11 43.68 190.66 43.83C190.48 43.9 189.58 44.05 188.65 44.2C185.38 44.71 171.46 48.1 167.61 49.36C166.23 49.81 163.99 50.54 162.68 50.97C157.57 52.61 150.43 55.35 142.93 58.55C140.59 59.53 130.65 64.41 130.1 64.84C129.97 64.94 128.72 65.62 127.33 66.34C125.95 67.07 123.23 68.63 121.29 69.82C119.36 71 117.09 72.36 116.29 72.84C98.57 83.3 78.57 101.12 63.12 120.19C61.59 122.07 58.34 126.28 57.87 126.98C57.54 127.46 56.88 128.41 56.38 129.12C55.6 130.2 54.37 132.01 50.39 137.92C48.61 140.57 47.68 142.13 44.71 147.36C43.02 150.33 41.49 153.05 41.29 153.4C40.78 154.25 36.13 163.69 35.12 165.85C33.76 168.82 33.66 169.05 32.61 171.64C30.62 176.47 29.99 178.08 28.86 181.45C28.45 182.68 27.85 184.34 27.55 185.1C27.27 185.85 26.79 187.19 26.54 188.04C26.27 188.92 25.81 190.38 25.54 191.31C24.6 194.28 22.89 200.62 22.52 202.38C22.42 202.99 22.14 204.04 21.96 204.77C21.61 206.03 21.18 208.05 20.05 213.53C18.84 219.37 17.71 226.51 17.26 231.14C17.11 232.6 16.93 233.96 16.88 234.16C16.58 235.19 16 249.13 16 255.14C16.03 272.78 18.54 293.84 22.06 306.09C22.34 306.97 22.54 307.88 22.54 308.11C22.54 308.33 22.77 309.36 23.07 310.4C23.37 311.43 23.82 313.06 24.08 314.04C25.38 318.77 27.47 325.06 30.44 333.29C30.67 333.92 30.94 334.65 31.07 334.93C31.22 335.2 31.67 336.29 32.1 337.32C32.53 338.35 33.03 339.53 33.23 339.96C33.41 340.36 34.12 341.95 34.77 343.48C36 346.37 41.29 357.09 42.42 358.95C42.82 359.63 45.54 364.34 45.84 364.87C46.57 366.2 47.93 368.31 49.09 369.9C50.27 371.56 50.77 372.29 53.11 375.71C57.92 382.83 65.01 391.39 71.73 398.1C75.96 402.35 76.56 402.83 80.66 405.53C93.59 414.03 103.63 418.51 118.65 422.48C130.05 425.5 149.5 425.93 163.06 423.46C171.06 422 181.4 418.81 189.98 415.19C203.24 409.58 217.93 399.56 228.68 388.82C232.6 384.89 231.34 387.84 225.25 396.9C220.07 404.62 210.76 414.18 202.06 420.72C184.52 433.88 166.28 441.63 143.56 445.53C140.89 445.98 140.09 446.61 141.12 447.47C141.82 448.05 148.82 451.39 153.62 453.43C188.6 468.3 224.17 474.06 262.32 471.04C283.05 469.38 301.29 466.14 318.42 461C328.64 457.96 329.89 457.56 337.42 454.76C359.08 446.76 381.9 434.33 402.3 419.41C414.38 410.61 431.87 394.53 440.75 384.09C443.26 381.15 444.93 379.18 445.98 377.95C446.46 377.37 447.37 376.29 447.99 375.56C449.63 373.62 456.3 364.67 459.57 360.04C460.85 358.22 462.13 356.24 465.76 350.53C466.61 349.17 466.74 348.97 467.37 347.88C467.72 347.31 468.12 346.63 468.25 346.4C468.98 345.24 471.44 341.04 472.12 339.76C472.55 338.98 473.26 337.67 473.71 336.86C474.14 336.08 475.14 334.17 475.92 332.66C476.73 331.13 477.78 329.09 478.29 328.13C478.82 327.15 479.8 325.06 480.48 323.48C481.18 321.89 481.89 320.23 482.09 319.83C482.26 319.4 482.72 318.35 483.09 317.44C483.45 316.53 483.85 315.58 483.97 315.3C484.1 315.03 484.5 314.07 484.85 313.16C485.23 312.26 485.73 311.05 486.01 310.45C486.26 309.87 486.49 309.26 486.49 309.11C486.49 308.96 486.64 308.48 486.84 308.06C487.04 307.6 487.5 306.45 487.87 305.49C488.23 304.51 488.7 303.33 488.9 302.85C489.46 301.46 491.5 294.92 492.5 291.15C492.78 290.17 493.28 288.33 493.63 287.02C498.42 269.79 499.55 245.46 496 236.55C492.08 226.66 484.23 221.93 467.32 219.22C461.05 218.21 437.15 217.78 426.99 218.51C403.97 220.17 389.25 222.19 369.12 226.49C367.46 226.84 364.99 227.34 363.66 227.62C359.26 228.53 340.61 233.36 329.49 236.48C320.36 239.02 302.29 244.7 290.87 248.63C286.59 250.11 282.62 251.45 282.07 251.62C281.26 251.9 268.98 256.35 265.33 257.71C264.86 257.89 263.37 258.44 262.06 258.92C260.76 259.4 258.21 260.35 256.4 261.08C254.62 261.79 252.73 262.52 252.25 262.69C251.77 262.87 249.91 263.6 248.1 264.33C246.31 265.03 244.1 265.91 243.19 266.24C242.29 266.57 235.22 269.36 227.47 272.43C203.49 281.94 201.33 282.74 197.65 283.85C197.03 284.03 195.57 284.53 194.38 284.93C193.2 285.34 191.24 285.99 189.98 286.37C188.75 286.72 187.26 287.2 186.71 287.37C185.75 287.7 183.04 288.46 179.16 289.51C178.21 289.76 176.32 290.22 175.01 290.52C173.7 290.8 171.87 291.2 170.93 291.43C170.03 291.63 168.27 291.98 167.03 292.15C165.83 292.36 164.09 292.63 163.19 292.76C157.6 293.66 155.64 293.84 150.1 294.04C143.94 294.27 136.06 294.02 133.92 293.56C133.35 293.44 131.91 293.19 130.73 293.04C129.55 292.88 127.96 292.58 127.21 292.36C123.89 291.43 119.56 289.99 118.22 289.36C117.42 289.01 115.91 288.31 114.88 287.83C107.28 284.3 99.61 277.64 94.47 270.14C92.39 267.1 89.89 262.29 88.84 259.32C87.43 255.35 86.02 249.91 85.19 245.36C85.06 244.65 84.Line truncated

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8.0in" height="6.0in" viewBox="0 0 2400.0 1800.0" role="img" aria-label="Codewhale simple use-codewhale print design"><title>Codewhale simple use-codewhale</title><desc>Outlined single-color vector candidate; official whale geometry unchanged. Physical sample and supplier template review pending.</desc><g fill="#102C46"><g id="official-codewhale-mark" transform="translate(840.0000 80.0000) scale(1.4062)"><path fill-rule="evenodd" d="M215.77 40.96C208.55 41.41 204.88 41.74 201.43 42.19C197 42.77 191.11 43.68 190.66 43.83C190.48 43.9 189.58 44.05 188.65 44.2C185.38 44.71 171.46 48.1 167.61 49.36C166.23 49.81 163.99 50.54 162.68 50.97C157.57 52.61 150.43 55.35 142.93 58.55C140.59 59.53 130.65 64.41 130.1 64.84C129.97 64.94 128.72 65.62 127.33 66.34C125.95 67.07 123.23 68.63 121.29 69.82C119.36 71 117.09 72.36 116.29 72.84C98.57 83.3 78.57 101.12 63.12 120.19C61.59 122.07 58.34 126.28 57.87 126.98C57.54 127.46 56.88 128.41 56.38 129.12C55.6 130.2 54.37 132.01 50.39 137.92C48.61 140.57 47.68 142.13 44.71 147.36C43.02 150.33 41.49 153.05 41.29 153.4C40.78 154.25 36.13 163.69 35.12 165.85C33.76 168.82 33.66 169.05 32.61 171.64C30.62 176.47 29.99 178.08 28.86 181.45C28.45 182.68 27.85 184.34 27.55 185.1C27.27 185.85 26.79 187.19 26.54 188.04C26.27 188.92 25.81 190.38 25.54 191.31C24.6 194.28 22.89 200.62 22.52 202.38C22.42 202.99 22.14 204.04 21.96 204.77C21.61 206.03 21.18 208.05 20.05 213.53C18.84 219.37 17.71 226.51 17.26 231.14C17.11 232.6 16.93 233.96 16.88 234.16C16.58 235.19 16 249.13 16 255.14C16.03 272.78 18.54 293.84 22.06 306.09C22.34 306.97 22.54 307.88 22.54 308.11C22.54 308.33 22.77 309.36 23.07 310.4C23.37 311.43 23.82 313.06 24.08 314.04C25.38 318.77 27.47 325.06 30.44 333.29C30.67 333.92 30.94 334.65 31.07 334.93C31.22 335.2 31.67 336.29 32.1 337.32C32.53 338.35 33.03 339.53 33.23 339.96C33.41 340.36 34.12 341.95 34.77 343.48C36 346.37 41.29 357.09 42.42 358.95C42.82 359.63 45.54 364.34 45.84 364.87C46.57 366.2 47.93 368.31 49.09 369.9C50.27 371.56 50.77 372.29 53.11 375.71C57.92 382.83 65.01 391.39 71.73 398.1C75.96 402.35 76.56 402.83 80.66 405.53C93.59 414.03 103.63 418.51 118.65 422.48C130.05 425.5 149.5 425.93 163.06 423.46C171.06 422 181.4 418.81 189.98 415.19C203.24 409.58 217.93 399.56 228.68 388.82C232.6 384.89 231.34 387.84 225.25 396.9C220.07 404.62 210.76 414.18 202.06 420.72C184.52 433.88 166.28 441.63 143.56 445.53C140.89 445.98 140.09 446.61 141.12 447.47C141.82 448.05 148.82 451.39 153.62 453.43C188.6 468.3 224.17 474.06 262.32 471.04C283.05 469.38 301.29 466.14 318.42 461C328.64 457.96 329.89 457.56 337.42 454.76C359.08 446.76 381.9 434.33 402.3 419.41C414.38 410.61 431.87 394.53 440.75 384.09C443.26 381.15 444.93 379.18 445.98 377.95C446.46 377.37 447.37 376.29 447.99 375.56C449.63 373.62 456.3 364.67 459.57 360.04C460.85 358.22 462.13 356.24 465.76 350.53C466.61 349.17 466.74 348.97 467.37 347.88C467.72 347.31 468.12 346.63 468.25 346.4C468.98 345.24 471.44 341.04 472.12 339.76C472.55 338.98 473.26 337.67 473.71 336.86C474.14 336.08 475.14 334.17 475.92 332.66C476.73 331.13 477.78 329.09 478.29 328.13C478.82 327.15 479.8 325.06 480.48 323.48C481.18 321.89 481.89 320.23 482.09 319.83C482.26 319.4 482.72 318.35 483.09 317.44C483.45 316.53 483.85 315.58 483.97 315.3C484.1 315.03 484.5 314.07 484.85 313.16C485.23 312.26 485.73 311.05 486.01 310.45C486.26 309.87 486.49 309.26 486.49 309.11C486.49 308.96 486.64 308.48 486.84 308.06C487.04 307.6 487.5 306.45 487.87 305.49C488.23 304.51 488.7 303.33 488.9 302.85C489.46 301.46 491.5 294.92 492.5 291.15C492.78 290.17 493.28 288.33 493.63 287.02C498.42 269.79 499.55 245.46 496 236.55C492.08 226.66 484.23 221.93 467.32 219.22C461.05 218.21 437.15 217.78 426.99 218.51C403.97 220.17 389.25 222.19 369.12 226.49C367.46 226.84 364.99 227.34 363.66 227.62C359.26 228.53 340.61 233.36 329.49 236.48C320.36 239.02 302.29 244.7 290.87 248.63C286.59 250.11 282.62 251.45 282.07 251.62C281.26 251.9 268.98 256.35 265.33 257.71C264.86 257.89 263.37 258.44 262.06 258.92C260.76 259.4 258.21 260.35 256.4 261.08C254.62 261.79 252.73 262.52 252.25 262.69C251.77 262.87 249.91 263.6 248.1 264.33C246.31 265.03 244.1 265.91 243.19 266.24C242.29 266.57 235.22 269.36 227.47 272.43C203.49 281.94 201.33 282.74 197.65 283.85C197.03 284.03 195.57 284.53 194.38 284.93C193.2 285.34 191.24 285.99 189.98 286.37C188.75 286.72 187.26 287.2 186.71 287.37C185.75 287.7 183.04 288.46 179.16 289.51C178.21 289.76 176.32 290.22 175.01 290.52C173.7 290.8 171.87 291.2 170.93 291.43C170.03 291.63 168.27 291.98 167.03 292.15C165.83 292.36 164.09 292.63 163.19 292.76C157.6 293.66 155.64 293.84 150.1 294.04C143.94 294.27 136.06 294.02 133.92 293.56C133.35 293.44 131.91 293.19 130.73 293.04C129.55 292.88 127.96 292.58 127.21 292.36C123.89 291.43 119.56 289.99 118.22 289.36C117.42 289.01 115.91 288.31 114.88 287.83C107.28 284.3 99.61 277.64 94.47 270.14C92.39 267.1 89.89 262.29 88.84 259.32C87.43 255.35 86.02 249.91 85.19 245.36C85.06 244.65 84.Line truncated

After

Width:  |  Height:  |  Size: 26 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="3.5000in" height="3.3000in" viewBox="0 0 1050.0000 990.0000" role="img" aria-label="Codewhale core use-pocket, navy, left chest"><title>Codewhale core use-pocket</title><desc>One-color outlined vector candidate. Placement: left chest. No background. Physical sample, supplier template and quote pending.</desc><g fill="#102C46"><g data-official-mark="true" transform="translate(335.0000 45.0000) scale(0.7422)"><path fill-rule="evenodd" d="M215.77 40.96C208.55 41.41 204.88 41.74 201.43 42.19C197 42.77 191.11 43.68 190.66 43.83C190.48 43.9 189.58 44.05 188.65 44.2C185.38 44.71 171.46 48.1 167.61 49.36C166.23 49.81 163.99 50.54 162.68 50.97C157.57 52.61 150.43 55.35 142.93 58.55C140.59 59.53 130.65 64.41 130.1 64.84C129.97 64.94 128.72 65.62 127.33 66.34C125.95 67.07 123.23 68.63 121.29 69.82C119.36 71 117.09 72.36 116.29 72.84C98.57 83.3 78.57 101.12 63.12 120.19C61.59 122.07 58.34 126.28 57.87 126.98C57.54 127.46 56.88 128.41 56.38 129.12C55.6 130.2 54.37 132.01 50.39 137.92C48.61 140.57 47.68 142.13 44.71 147.36C43.02 150.33 41.49 153.05 41.29 153.4C40.78 154.25 36.13 163.69 35.12 165.85C33.76 168.82 33.66 169.05 32.61 171.64C30.62 176.47 29.99 178.08 28.86 181.45C28.45 182.68 27.85 184.34 27.55 185.1C27.27 185.85 26.79 187.19 26.54 188.04C26.27 188.92 25.81 190.38 25.54 191.31C24.6 194.28 22.89 200.62 22.52 202.38C22.42 202.99 22.14 204.04 21.96 204.77C21.61 206.03 21.18 208.05 20.05 213.53C18.84 219.37 17.71 226.51 17.26 231.14C17.11 232.6 16.93 233.96 16.88 234.16C16.58 235.19 16 249.13 16 255.14C16.03 272.78 18.54 293.84 22.06 306.09C22.34 306.97 22.54 307.88 22.54 308.11C22.54 308.33 22.77 309.36 23.07 310.4C23.37 311.43 23.82 313.06 24.08 314.04C25.38 318.77 27.47 325.06 30.44 333.29C30.67 333.92 30.94 334.65 31.07 334.93C31.22 335.2 31.67 336.29 32.1 337.32C32.53 338.35 33.03 339.53 33.23 339.96C33.41 340.36 34.12 341.95 34.77 343.48C36 346.37 41.29 357.09 42.42 358.95C42.82 359.63 45.54 364.34 45.84 364.87C46.57 366.2 47.93 368.31 49.09 369.9C50.27 371.56 50.77 372.29 53.11 375.71C57.92 382.83 65.01 391.39 71.73 398.1C75.96 402.35 76.56 402.83 80.66 405.53C93.59 414.03 103.63 418.51 118.65 422.48C130.05 425.5 149.5 425.93 163.06 423.46C171.06 422 181.4 418.81 189.98 415.19C203.24 409.58 217.93 399.56 228.68 388.82C232.6 384.89 231.34 387.84 225.25 396.9C220.07 404.62 210.76 414.18 202.06 420.72C184.52 433.88 166.28 441.63 143.56 445.53C140.89 445.98 140.09 446.61 141.12 447.47C141.82 448.05 148.82 451.39 153.62 453.43C188.6 468.3 224.17 474.06 262.32 471.04C283.05 469.38 301.29 466.14 318.42 461C328.64 457.96 329.89 457.56 337.42 454.76C359.08 446.76 381.9 434.33 402.3 419.41C414.38 410.61 431.87 394.53 440.75 384.09C443.26 381.15 444.93 379.18 445.98 377.95C446.46 377.37 447.37 376.29 447.99 375.56C449.63 373.62 456.3 364.67 459.57 360.04C460.85 358.22 462.13 356.24 465.76 350.53C466.61 349.17 466.74 348.97 467.37 347.88C467.72 347.31 468.12 346.63 468.25 346.4C468.98 345.24 471.44 341.04 472.12 339.76C472.55 338.98 473.26 337.67 473.71 336.86C474.14 336.08 475.14 334.17 475.92 332.66C476.73 331.13 477.78 329.09 478.29 328.13C478.82 327.15 479.8 325.06 480.48 323.48C481.18 321.89 481.89 320.23 482.09 319.83C482.26 319.4 482.72 318.35 483.09 317.44C483.45 316.53 483.85 315.58 483.97 315.3C484.1 315.03 484.5 314.07 484.85 313.16C485.23 312.26 485.73 311.05 486.01 310.45C486.26 309.87 486.49 309.26 486.49 309.11C486.49 308.96 486.64 308.48 486.84 308.06C487.04 307.6 487.5 306.45 487.87 305.49C488.23 304.51 488.7 303.33 488.9 302.85C489.46 301.46 491.5 294.92 492.5 291.15C492.78 290.17 493.28 288.33 493.63 287.02C498.42 269.79 499.55 245.46 496 236.55C492.08 226.66 484.23 221.93 467.32 219.22C461.05 218.21 437.15 217.78 426.99 218.51C403.97 220.17 389.25 222.19 369.12 226.49C367.46 226.84 364.99 227.34 363.66 227.62C359.26 228.53 340.61 233.36 329.49 236.48C320.36 239.02 302.29 244.7 290.87 248.63C286.59 250.11 282.62 251.45 282.07 251.62C281.26 251.9 268.98 256.35 265.33 257.71C264.86 257.89 263.37 258.44 262.06 258.92C260.76 259.4 258.21 260.35 256.4 261.08C254.62 261.79 252.73 262.52 252.25 262.69C251.77 262.87 249.91 263.6 248.1 264.33C246.31 265.03 244.1 265.91 243.19 266.24C242.29 266.57 235.22 269.36 227.47 272.43C203.49 281.94 201.33 282.74 197.65 283.85C197.03 284.03 195.57 284.53 194.38 284.93C193.2 285.34 191.24 285.99 189.98 286.37C188.75 286.72 187.26 287.2 186.71 287.37C185.75 287.7 183.04 288.46 179.16 289.51C178.21 289.76 176.32 290.22 175.01 290.52C173.7 290.8 171.87 291.2 170.93 291.43C170.03 291.63 168.27 291.98 167.03 292.15C165.83 292.36 164.09 292.63 163.19 292.76C157.6 293.66 155.64 293.84 150.1 294.04C143.94 294.27 136.06 294.02 133.92 293.56C133.35 293.44 131.91 293.19 130.73 293.04C129.55 292.88 127.96 292.58 127.21 292.36C123.89 291.43 119.56 289.99 118.22 289.36C117.42 289.01 115.91 288.31 114.88 287.83C107.28 284.3 99.61 277.64 94.47 270.14C92.39 267.1 89.89 262.29 88.84 259.32C87.43 255.35 86.02 249.91 85.19 245.36C85.06 244.65 Line truncated

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8.0in" height="6.0in" viewBox="0 0 2400.0 1800.0" role="img" aria-label="Codewhale simple whale-bro print design"><title>Codewhale simple whale-bro</title><desc>Outlined single-color vector candidate; official whale geometry unchanged. Physical sample and supplier template review pending.</desc><g fill="#102C46"><g id="official-codewhale-mark" transform="translate(840.0000 80.0000) scale(1.4062)"><path fill-rule="evenodd" d="M215.77 40.96C208.55 41.41 204.88 41.74 201.43 42.19C197 42.77 191.11 43.68 190.66 43.83C190.48 43.9 189.58 44.05 188.65 44.2C185.38 44.71 171.46 48.1 167.61 49.36C166.23 49.81 163.99 50.54 162.68 50.97C157.57 52.61 150.43 55.35 142.93 58.55C140.59 59.53 130.65 64.41 130.1 64.84C129.97 64.94 128.72 65.62 127.33 66.34C125.95 67.07 123.23 68.63 121.29 69.82C119.36 71 117.09 72.36 116.29 72.84C98.57 83.3 78.57 101.12 63.12 120.19C61.59 122.07 58.34 126.28 57.87 126.98C57.54 127.46 56.88 128.41 56.38 129.12C55.6 130.2 54.37 132.01 50.39 137.92C48.61 140.57 47.68 142.13 44.71 147.36C43.02 150.33 41.49 153.05 41.29 153.4C40.78 154.25 36.13 163.69 35.12 165.85C33.76 168.82 33.66 169.05 32.61 171.64C30.62 176.47 29.99 178.08 28.86 181.45C28.45 182.68 27.85 184.34 27.55 185.1C27.27 185.85 26.79 187.19 26.54 188.04C26.27 188.92 25.81 190.38 25.54 191.31C24.6 194.28 22.89 200.62 22.52 202.38C22.42 202.99 22.14 204.04 21.96 204.77C21.61 206.03 21.18 208.05 20.05 213.53C18.84 219.37 17.71 226.51 17.26 231.14C17.11 232.6 16.93 233.96 16.88 234.16C16.58 235.19 16 249.13 16 255.14C16.03 272.78 18.54 293.84 22.06 306.09C22.34 306.97 22.54 307.88 22.54 308.11C22.54 308.33 22.77 309.36 23.07 310.4C23.37 311.43 23.82 313.06 24.08 314.04C25.38 318.77 27.47 325.06 30.44 333.29C30.67 333.92 30.94 334.65 31.07 334.93C31.22 335.2 31.67 336.29 32.1 337.32C32.53 338.35 33.03 339.53 33.23 339.96C33.41 340.36 34.12 341.95 34.77 343.48C36 346.37 41.29 357.09 42.42 358.95C42.82 359.63 45.54 364.34 45.84 364.87C46.57 366.2 47.93 368.31 49.09 369.9C50.27 371.56 50.77 372.29 53.11 375.71C57.92 382.83 65.01 391.39 71.73 398.1C75.96 402.35 76.56 402.83 80.66 405.53C93.59 414.03 103.63 418.51 118.65 422.48C130.05 425.5 149.5 425.93 163.06 423.46C171.06 422 181.4 418.81 189.98 415.19C203.24 409.58 217.93 399.56 228.68 388.82C232.6 384.89 231.34 387.84 225.25 396.9C220.07 404.62 210.76 414.18 202.06 420.72C184.52 433.88 166.28 441.63 143.56 445.53C140.89 445.98 140.09 446.61 141.12 447.47C141.82 448.05 148.82 451.39 153.62 453.43C188.6 468.3 224.17 474.06 262.32 471.04C283.05 469.38 301.29 466.14 318.42 461C328.64 457.96 329.89 457.56 337.42 454.76C359.08 446.76 381.9 434.33 402.3 419.41C414.38 410.61 431.87 394.53 440.75 384.09C443.26 381.15 444.93 379.18 445.98 377.95C446.46 377.37 447.37 376.29 447.99 375.56C449.63 373.62 456.3 364.67 459.57 360.04C460.85 358.22 462.13 356.24 465.76 350.53C466.61 349.17 466.74 348.97 467.37 347.88C467.72 347.31 468.12 346.63 468.25 346.4C468.98 345.24 471.44 341.04 472.12 339.76C472.55 338.98 473.26 337.67 473.71 336.86C474.14 336.08 475.14 334.17 475.92 332.66C476.73 331.13 477.78 329.09 478.29 328.13C478.82 327.15 479.8 325.06 480.48 323.48C481.18 321.89 481.89 320.23 482.09 319.83C482.26 319.4 482.72 318.35 483.09 317.44C483.45 316.53 483.85 315.58 483.97 315.3C484.1 315.03 484.5 314.07 484.85 313.16C485.23 312.26 485.73 311.05 486.01 310.45C486.26 309.87 486.49 309.26 486.49 309.11C486.49 308.96 486.64 308.48 486.84 308.06C487.04 307.6 487.5 306.45 487.87 305.49C488.23 304.51 488.7 303.33 488.9 302.85C489.46 301.46 491.5 294.92 492.5 291.15C492.78 290.17 493.28 288.33 493.63 287.02C498.42 269.79 499.55 245.46 496 236.55C492.08 226.66 484.23 221.93 467.32 219.22C461.05 218.21 437.15 217.78 426.99 218.51C403.97 220.17 389.25 222.19 369.12 226.49C367.46 226.84 364.99 227.34 363.66 227.62C359.26 228.53 340.61 233.36 329.49 236.48C320.36 239.02 302.29 244.7 290.87 248.63C286.59 250.11 282.62 251.45 282.07 251.62C281.26 251.9 268.98 256.35 265.33 257.71C264.86 257.89 263.37 258.44 262.06 258.92C260.76 259.4 258.21 260.35 256.4 261.08C254.62 261.79 252.73 262.52 252.25 262.69C251.77 262.87 249.91 263.6 248.1 264.33C246.31 265.03 244.1 265.91 243.19 266.24C242.29 266.57 235.22 269.36 227.47 272.43C203.49 281.94 201.33 282.74 197.65 283.85C197.03 284.03 195.57 284.53 194.38 284.93C193.2 285.34 191.24 285.99 189.98 286.37C188.75 286.72 187.26 287.2 186.71 287.37C185.75 287.7 183.04 288.46 179.16 289.51C178.21 289.76 176.32 290.22 175.01 290.52C173.7 290.8 171.87 291.2 170.93 291.43C170.03 291.63 168.27 291.98 167.03 292.15C165.83 292.36 164.09 292.63 163.19 292.76C157.6 293.66 155.64 293.84 150.1 294.04C143.94 294.27 136.06 294.02 133.92 293.56C133.35 293.44 131.91 293.19 130.73 293.04C129.55 292.88 127.96 292.58 127.21 292.36C123.89 291.43 119.56 289.99 118.22 289.36C117.42 289.01 115.91 288.31 114.88 287.83C107.28 284.3 99.61 277.64 94.47 270.14C92.39 267.1 89.89 262.29 88.84 259.32C87.43 255.35 86.02 249.91 85.19 245.36C85.06 244.65 84.79 241.4Line truncated

After

Width:  |  Height:  |  Size: 32 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="7.0000in" height="2.2000in" viewBox="0 0 2100.0000 660.0000" role="img" aria-label="Codewhale core wordmark, navy, center front"><title>Codewhale core wordmark</title><desc>One-color outlined vector candidate. Placement: center front. No background. Physical sample, supplier template and quote pending.</desc><g fill="#102C46"><g aria-label="Codewhale" data-outlined-type="true" transform="translate(153.9489 352.2757) scale(3.4510 -3.4510)"><path d="M40.3000 64.5000Q34.6000 64.5000 30.0000 62.5500Q25.4000 60.6000 22.2000 56.8500Q19.0000 53.1000 17.3000 47.7500Q15.6000 42.4000 15.6000 35.7000Q15.6000 26.9000 18.3500 20.4000Q21.1000 13.9000 26.5500 10.4000Q32.0000 6.9000 40.2000 6.9000Q44.9000 6.9000 49.1000 7.7000Q53.3000 8.5000 57.3000 9.7000L57.3000 1.9000Q53.3000 0.4000 49.0500 -0.3000Q44.8000 -1.0000 38.9000 -1.0000Q28.0000 -1.0000 20.7000 3.5000Q13.4000 8.0000 9.7500 16.3000Q6.1000 24.6000 6.1000 35.8000Q6.1000 43.9000 8.3500 50.6000Q10.6000 57.3000 14.9500 62.2000Q19.3000 67.1000 25.7000 69.7500Q32.1000 72.4000 40.4000 72.4000Q45.9000 72.4000 51.0000 71.3000Q56.1000 70.2000 60.1000 68.2000L56.5000 60.6000Q53.2000 62.1000 49.1500 63.3000Q45.1000 64.5000 40.3000 64.5000ZM118.3000 26.9000Q118.3000 20.2000 116.5500 15.0500Q114.8000 9.9000 111.5500 6.3000Q108.3000 2.7000 103.6500 0.8500Q99.0000 -1.0000 93.3000 -1.0000Q88.0000 -1.0000 83.5000 0.8500Q79.0000 2.7000 75.7000 6.3000Q72.4000 9.9000 70.5500 15.0500Q68.7000 20.2000 68.7000 26.9000Q68.7000 35.8000 71.7000 41.9500Q74.7000 48.1000 80.3000 51.3500Q85.9000 54.6000 93.6000 54.6000Q100.9000 54.6000 106.4500 51.3500Q112.0000 48.1000 115.1500 41.9500Q118.3000 35.8000 118.3000 26.9000ZM77.8000 26.9000Q77.8000 20.6000 79.4500 15.9500Q81.1000 11.3000 84.6000 8.8000Q88.1000 6.3000 93.5000 6.3000Q98.9000 6.3000 102.4000 8.8000Q105.9000 11.3000 107.5500 15.9500Q109.2000 20.6000 109.2000 26.9000Q109.2000 33.3000 107.5000 37.8000Q105.8000 42.3000 102.3500 44.7500Q98.9000 47.2000 93.4000 47.2000Q85.2000 47.2000 81.5000 41.8000Q77.8000 36.4000 77.8000 26.9000ZM151.2000 -1.0000Q141.2000 -1.0000 135.2000 5.9500Q129.2000 12.9000 129.2000 26.7000Q129.2000 40.5000 135.2500 47.5500Q141.3000 54.6000 151.3000 54.6000Q155.5000 54.6000 158.6000 53.5500Q161.7000 52.5000 164.0000 50.7000Q166.3000 48.9000 167.9000 46.7000L168.5000 46.7000Q168.4000 48.0000 168.1500 50.5500Q167.9000 53.1000 167.9000 54.6000L167.9000 76.0000L176.7000 76.0000L176.7000 0.0000L169.6000 0.0000L168.3000 7.2000L167.9000 7.2000Q166.3000 4.9000 164.0000 3.0500Q161.7000 1.2000 158.5500 0.1000Q155.4000 -1.0000 151.2000 -1.0000ZM152.6000 6.3000Q161.1000 6.3000 164.5500 10.9500Q168.0000 15.6000 168.0000 25.0000L168.0000 26.6000Q168.0000 36.6000 164.7000 41.9500Q161.4000 47.3000 152.5000 47.3000Q145.4000 47.3000 141.8500 41.6500Q138.3000 36.0000 138.3000 26.5000Q138.3000 16.9000 141.8500 11.6000Q145.4000 6.3000 152.6000 6.3000ZM214.4000 54.6000Q221.3000 54.6000 226.2500 51.6000Q231.2000 48.6000 233.8500 43.1500Q236.5000 37.7000 236.5000 30.4000L236.5000 25.1000L199.8000 25.1000Q200.0000 16.0000 204.4500 11.2500Q208.9000 6.5000 216.9000 6.5000Q222.0000 6.5000 225.9500 7.4500Q229.9000 8.4000 234.1000 10.2000L234.1000 2.5000Q230.0000 0.7000 226.0000 -0.1500Q222.0000 -1.0000 216.5000 -1.0000Q208.9000 -1.0000 203.0500 2.1000Q197.2000 5.2000 193.9500 11.3500Q190.7000 17.5000 190.7000 26.4000Q190.7000 35.2000 193.6500 41.5000Q196.6000 47.8000 201.9500 51.2000Q207.3000 54.6000 214.4000 54.6000ZM214.3000 47.4000Q208.0000 47.4000 204.3500 43.3500Q200.7000 39.3000 200.0000 32.1000L227.3000 32.1000Q227.3000 36.7000 225.9000 40.1000Q224.5000 43.5000 221.6500 45.4500Q218.8000 47.4000 214.3000 47.4000ZM282.7000 30.3000Q282.1000 32.4000 281.5000 34.4500Q280.9000 36.5000 280.4500 38.3500Q280.0000 40.2000 279.6000 41.8000Q279.2000 43.4000 279.0000 44.5000L278.6000 44.5000Q278.4000 43.4000 278.0500 41.8000Q277.7000 40.2000 277.2500 38.3000Q276.8000 36.4000 276.2500 34.3500Q275.7000 32.3000 275.0000 30.2000L265.4000 0.1000L255.4000 0.1000L240.7000 53.7000L249.8000 53.7000L257.2000 25.1000Q258.0000 22.2000 258.7000 19.2500Q259.4000 16.3000 259.9500 13.6500Q260.5000 11.0000 260.7000 9.1000L261.1000 9.1000Q261.4000 10.3000 261.8000 12.1000Q262.2000 13.9000 262.6500 15.9000Q263.1000 17.9000 263.6500 19.9000Q264.2000 21.9000 264.7000 23.5000L274.2000 53.7000L283.8000 53.7000L293.0000 23.5000Q293.7000 21.2000 294.4500 18.6000Q295.2000 16.0000 295.8000 13.5500Q296.4000 11.1000 296.6000 9.2000L297.0000 9.2000Q297.2000 10.9000 297.7500 13.4500Q298.3000 16.0000 299.0500 19.0500Q299.8000 22.1000 300.6000 25.1000L308.1000 53.7000L317.1000 53.7000L302.2000 0.1000L291.9000 0.1000ZM326.7000 0.0000L326.7000 76.0000L335.4000 76.0000L335.4000 43.7000Q339.9000 54.6000 349.0000 54.6000Q371.9000 54.6000 371.9000 36.3000L371.9000 0.0000L363.2000 0.0000L363.2000 37.1000Q363.2000 45.9000 346.6000 45.9000Q335.4000 45.9000 335.4000 32.9000L335.4000 0.0000ZM386.8000 48.2000Q395.0000 54.6000 406.3000 54.6000Q428.0Line truncated

After

Width:  |  Height:  |  Size: 14 KiB

+5
View File
@@ -17,6 +17,11 @@
"name": "ADMIN_LOGIN_LIMITER",
"namespace_id": "913001",
"simple": { "limit": 5, "period": 60 }
},
{
"name": "MERCH_INTEREST_LIMITER",
"namespace_id": "913002",
"simple": { "limit": 5, "period": 60 }
}
],
"routes": [