feat: demonstrate shared component architecture

This commit is contained in:
2026-06-29 12:06:18 +01:00
parent 561e948cb4
commit 7d637c4afe
25 changed files with 1319 additions and 0 deletions

View File

@@ -0,0 +1,32 @@
@reference '../../styles/globals.css';
.page {
@apply mx-auto max-w-3xl px-10 py-12;
}
.title {
@apply font-display text-4xl text-fg;
}
.subtitle {
@apply mb-3 mt-10 font-display text-2xl text-fg;
}
.lead {
@apply mt-4 text-lg text-fg;
}
.body {
@apply mt-4 leading-relaxed text-muted;
}
.inlineCode {
@apply rounded bg-surface2 px-1 py-0.5 text-sm text-fg;
font-family: var(--font-mono);
}
.code {
@apply mt-2 overflow-x-auto rounded bg-surface2 p-4 text-sm text-fg;
font-family: var(--font-mono);
border: 1px solid var(--color-line);
}

63
src/app/about/page.tsx Normal file
View File

@@ -0,0 +1,63 @@
import { store } from '@config/store';
import { stores } from '@config/stores';
import styles from './page.module.css';
const exampleCss = `/* ProductCard.module.css — one file, every storefront */
.cta {
@apply mt-5 inline-block text-sm;
color: var(--color-accent);
}
@store executive { .cta { @apply text-[10px] uppercase tracking-[0.28em]; } }
@store crypto { .cta { @apply w-full rounded-lg bg-accent py-3 font-bold; } }
@store supper { .cta { @apply rounded-full bg-accent px-5 py-2.5; } }
@store collector { .cta { @apply border px-4 py-2; } }`;
const examplePlugin = `// postcss-storestyles keeps only the active store's blocks.
// shopSystem = NEXT_PUBLIC_STORE_CODE.split('_')[0] // e.g. "crypto"
if (atRule.params !== shopSystem && !atRule.params.startsWith(shopSystem + '_')) {
atRule.remove(); // dropped from the build
} else {
atRule.replaceWith(atRule.nodes); // unwrapped into plain CSS
}`;
export default function AboutPage() {
return (
<div className={styles.page}>
<h1 className={styles.title}>One codebase, four storefronts</h1>
<p className={styles.lead}>
You are looking at <strong>{store.brandName}</strong> the{' '}
<code className={styles.inlineCode}>{store.code}</code> storefront. The other three {' '}
{stores
.filter((s) => s.code !== store.code)
.map((s) => s.label)
.join(', ')}{' '}
are <em>the same React components and the same CSS files</em>. No component knows which store it is.
</p>
<p className={styles.body}>
Every visual difference colour, type, spacing, even layout lives inside{' '}
<code className={styles.inlineCode}>@store</code> blocks in the CSS. A small PostCSS plugin,{' '}
<code className={styles.inlineCode}>postcss-storestyles</code>, runs at build time and keeps only the blocks
that match <code className={styles.inlineCode}>NEXT_PUBLIC_STORE_CODE</code>. Every other store&apos;s styles
are stripped before they reach the browser, so each build ships one lean stylesheet with zero runtime branching.
</p>
<h2 className={styles.subtitle}>The styles</h2>
<pre className={styles.code}>
<code>{exampleCss}</code>
</pre>
<h2 className={styles.subtitle}>The plugin that decides</h2>
<pre className={styles.code}>
<code>{examplePlugin}</code>
</pre>
<p className={styles.body}>
Each storefront is its own build, deployed to its own Cloudflare Worker. One codebase, four storefronts, each
maintained in the file it belongs to.
</p>
</div>
);
}

15
src/app/page.tsx Normal file
View File

@@ -0,0 +1,15 @@
import Hero from '@/components/Hero/Hero';
import ProductGrid from '@/components/ProductGrid/ProductGrid';
import FeaturedBlock from '@/components/FeaturedBlock/FeaturedBlock';
import Reviews from '@/components/Reviews/Reviews';
export default function HomePage() {
return (
<>
<Hero />
<ProductGrid />
<FeaturedBlock />
<Reviews />
</>
);
}

View File

@@ -0,0 +1,21 @@
@reference '../../../styles/globals.css';
.page {
@apply mx-auto max-w-6xl px-10 pt-8;
}
.back {
@apply inline-block text-sm text-muted hover:text-fg;
}
@store crypto {
.back {
@apply font-mono;
}
}
@store collector {
.back {
@apply font-mono uppercase tracking-[0.1em];
}
}

View File

@@ -0,0 +1,33 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { catalog, getProduct } from '@/data/catalog';
import ProductDetail from '@/components/ProductDetail/ProductDetail';
import styles from './page.module.css';
export function generateStaticParams() {
return catalog.map((product) => ({ slug: product.slug }));
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const product = getProduct(slug);
return { title: product?.name };
}
export default async function ProductPage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params;
const product = getProduct(slug);
if (!product) {
notFound();
}
return (
<div className={styles.page}>
<Link href="/" className={styles.back}>
Back to shop
</Link>
<ProductDetail product={product} />
</div>
);
}

View File

@@ -0,0 +1,86 @@
@reference '../../styles/globals.css';
.duck {
display: block;
width: 100%;
height: 100%;
overflow: visible;
}
.shadow {
fill: rgba(0, 0, 0, 0.14);
}
.body {
stroke: rgba(120, 120, 120, 0.28);
stroke-width: 1;
}
.wing {
fill: rgba(0, 0, 0, 0.07);
}
.beak {
fill: #e8893f;
}
.eye {
fill: #15151a;
}
/* accessories hidden by default; one is revealed per store */
.monocle,
.shades,
.hat {
display: none;
}
.monocle circle,
.monocle line {
stroke: #c9a86a;
stroke-width: 1.3;
fill: none;
}
.shades rect {
fill: #0a0a0a;
}
.shades line {
stroke: #0a0a0a;
stroke-width: 2;
}
.hat rect,
.hat ellipse {
fill: #fff;
stroke: rgba(0, 0, 0, 0.08);
stroke-width: 1;
}
@store executive {
.beak {
fill: #c9a86a;
}
.monocle {
display: block;
}
}
@store crypto {
.duck {
filter: drop-shadow(0 0 7px var(--color-accent));
}
.beak {
fill: var(--color-accent);
}
.shades {
display: block;
}
}
@store supper {
.hat {
display: block;
}
}

View File

@@ -0,0 +1,39 @@
import styles from './Duck.module.css';
type DuckProps = {
/** Body colour — per product, not per store. */
color: string;
className?: string;
};
/**
* One shared duck. Body colour comes from the product; the beak colour and which
* accessory shows (monocle / shades / chef hat) are decided purely by @store CSS.
*/
export default function Duck({ color, className }: DuckProps) {
return (
<svg viewBox="0 0 112 100" className={`${styles.duck} ${className ?? ''}`} role="img" aria-label="Rubber duck">
<ellipse className={styles.shadow} cx="55" cy="90" rx="30" ry="5" />
<polygon className={styles.body} points="18,58 31,51 31,67" style={{ fill: color }} />
<ellipse className={styles.body} cx="53" cy="64" rx="34" ry="24" style={{ fill: color }} />
<ellipse className={styles.wing} cx="47" cy="65" rx="15" ry="11" />
<circle className={styles.body} cx="71" cy="40" r="18" style={{ fill: color }} />
<polygon className={styles.beak} points="87,38 103,43 87,49" />
<circle className={styles.eye} cx="74" cy="36" r="3" />
<g className={styles.monocle}>
<circle cx="74" cy="36" r="7.5" fill="none" />
<line x1="74" y1="43.5" x2="70" y2="60" />
</g>
<g className={styles.shades}>
<rect x="62" y="31" width="11" height="9" rx="2" />
<rect x="75" y="31" width="11" height="9" rx="2" />
<line x1="73" y1="34" x2="75" y2="34" />
</g>
<g className={styles.hat}>
<rect x="60" y="23" width="22" height="6" rx="3" />
<ellipse cx="71" cy="18" rx="13" ry="8" />
</g>
</svg>
);
}

View File

@@ -0,0 +1,94 @@
@reference '../../styles/globals.css';
.section {
@apply border-y border-line bg-surface;
}
.inner {
@apply mx-auto grid max-w-6xl items-center gap-14 px-10 py-[74px];
grid-template-columns: 1fr 1fr;
}
.kicker {
@apply mb-5 text-[11px] uppercase tracking-[0.28em] text-accent;
}
.title {
@apply m-0 mb-5 font-display text-fg;
font-size: 34px;
line-height: 1.12;
}
.body {
@apply mb-7 max-w-md text-muted;
font-size: 15px;
line-height: 1.75;
}
.cta {
@apply cursor-pointer border bg-transparent px-6 py-3 text-sm;
border-color: var(--color-fg);
color: var(--color-fg);
border-radius: var(--radius-card);
}
.visual {
@apply border border-line;
aspect-ratio: 4 / 3;
background: repeating-linear-gradient(135deg, var(--color-surface2), var(--color-surface2) 11px, var(--color-bg) 11px, var(--color-bg) 22px);
}
/* ── EXECUTIVE: hairline crosshatch, restrained ── */
@store executive {
.title {
@apply font-medium;
}
.cta {
@apply text-[11px] uppercase tracking-[0.18em];
}
}
/* ── CRYPTO: gradient panel, glowing visual ── */
@store crypto {
.section {
background: linear-gradient(180deg, var(--color-surface), var(--color-bg));
}
.title {
@apply font-bold tracking-tight;
}
.cta {
@apply rounded-xl border-line font-semibold;
}
.visual {
@apply rounded-2xl;
box-shadow: 0 0 30px color-mix(in srgb, var(--color-accent) 16%, transparent);
}
}
/* ── SUPPER: warm, rounded, visual on the left feel ── */
@store supper {
.section {
@apply border-none;
}
.title {
@apply font-medium;
}
.cta {
@apply rounded-full;
border-width: 1.5px;
}
.visual {
@apply rounded-[22px] border-none bg-surface2;
background-image: none;
}
}
/* ── COLLECTOR: framed plate, serif ── */
@store collector {
.title {
@apply font-semibold;
}
.cta {
@apply rounded-none;
}
}

View File

@@ -0,0 +1,20 @@
import { store } from '@config/store';
import styles from './FeaturedBlock.module.css';
export default function FeaturedBlock() {
return (
<section className={styles.section}>
<div className={styles.inner}>
<div className={styles.copy}>
<div className={styles.kicker}>{store.featured.kicker}</div>
<h3 className={styles.title}>{store.featured.title}</h3>
<p className={styles.body}>{store.featured.body}</p>
<button type="button" className={styles.cta}>
{store.featured.cta}
</button>
</div>
<div className={styles.visual} aria-hidden="true" />
</div>
</section>
);
}

View File

@@ -0,0 +1,107 @@
@reference '../../styles/globals.css';
.hero {
@apply mx-auto grid max-w-6xl items-center gap-12 px-10 pt-16 pb-14;
grid-template-columns: 1.05fr 0.95fr;
}
.eyebrow {
@apply mb-6 text-[11px] uppercase tracking-[0.3em] text-accent;
}
.title {
@apply m-0 mb-6 font-display text-fg;
font-size: clamp(38px, 5.4vw, 64px);
line-height: 1.05;
}
.body {
@apply mb-8 max-w-md text-muted;
font-size: 16px;
line-height: 1.7;
}
.cta {
@apply cursor-pointer border-none bg-accent px-7 py-3.5 text-sm font-semibold;
color: var(--color-bg);
border-radius: var(--radius-card);
}
.figure {
@apply flex items-center justify-center;
}
.duck {
width: 230px;
height: 200px;
animation: floaty 6s ease-in-out infinite;
}
/* ── EXECUTIVE: centered, gilt CTA, duck below ── */
@store executive {
.hero {
@apply grid-cols-1 pt-24 text-center;
}
.copy {
@apply mx-auto max-w-2xl;
}
.body {
@apply mx-auto;
}
.title {
@apply font-medium;
}
.cta {
@apply text-xs uppercase tracking-[0.18em];
color: #15110a;
}
}
/* ── CRYPTO: glowing headline, duck in a neon grid card ── */
@store crypto {
.title {
@apply font-bold tracking-tight;
}
.cta {
@apply rounded-xl font-bold;
box-shadow: 0 0 26px color-mix(in srgb, var(--color-accent) 45%, transparent);
}
.figure {
@apply rounded-3xl border border-line bg-surface p-8;
background-image: linear-gradient(color-mix(in srgb, var(--color-accent) 6%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in srgb, var(--color-accent) 6%, transparent) 1px, transparent 1px);
background-size: 26px 26px;
}
}
/* ── SUPPER: rounded warm panel, pill CTA ── */
@store supper {
.title {
@apply font-medium;
}
.cta {
@apply rounded-full;
color: #fff;
}
.figure {
@apply rounded-[22px] bg-surface2;
aspect-ratio: 1 / 1;
}
}
/* ── COLLECTOR: framed exhibit card ── */
@store collector {
.title {
@apply font-semibold;
}
.cta {
color: #f3f0e6;
}
.figure {
@apply border p-1.5;
border-color: var(--color-accent);
}
.duck {
@apply flex h-full w-full items-center justify-center bg-surface2 p-8;
}
}

View File

@@ -0,0 +1,26 @@
import { store } from '@config/store';
import { ducks } from '@/data/products';
import Duck from '@/components/Duck/Duck';
import styles from './Hero.module.css';
const heroDuck = ducks[3];
export default function Hero() {
return (
<section className={styles.hero}>
<div className={styles.copy}>
<div className={styles.eyebrow}>{store.hero.eyebrow}</div>
<h1 className={styles.title}>{store.hero.title}</h1>
<p className={styles.body}>{store.hero.body}</p>
<button type="button" className={styles.cta}>
{store.hero.cta}
</button>
</div>
<div className={styles.figure}>
<div className={styles.duck}>
<Duck color={heroDuck.color} />
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,187 @@
@reference '../../styles/globals.css';
.card {
@apply flex flex-col overflow-hidden bg-surface;
border-radius: var(--radius-card);
}
.media {
@apply relative flex items-center justify-center bg-surface2;
height: 200px;
}
.tag {
@apply absolute left-4 top-4 text-[10px] uppercase tracking-[0.1em] text-accent;
}
.duck {
width: 150px;
height: 130px;
}
.body {
@apply flex flex-col items-start p-6;
}
.name {
@apply font-display text-xl text-fg;
}
.blurb {
@apply mt-2 text-sm text-muted;
line-height: 1.55;
min-height: 42px;
}
.priceRow {
@apply mt-4 flex items-baseline gap-2;
}
.price {
@apply font-mono text-lg text-fg;
}
.priceSub {
@apply text-xs text-muted;
}
.cta {
@apply mt-5 inline-block cursor-pointer text-sm;
color: var(--color-accent);
}
/* ── EXECUTIVE: dark plinth, centered serif, gilt "Acquire" underline ── */
@store executive {
.media {
@apply border-b border-line;
background: radial-gradient(120% 78% at 50% 34%, #20222b, #0c0d11 72%);
}
.tag {
@apply left-1/2 -translate-x-1/2 text-[9px] tracking-[0.3em];
}
.body {
@apply items-center px-7 pb-9 pt-7 text-center;
}
.blurb {
@apply font-display italic;
font-size: 15px;
}
.price {
@apply font-display text-accent;
font-size: 19px;
}
.cta {
@apply text-[10px] uppercase tracking-[0.28em] text-fg;
border-bottom: 1px solid var(--color-accent);
}
}
/* ── CRYPTO: neon trading card, full-width PUMP button ── */
@store crypto {
.card {
@apply border border-line p-3.5 transition-transform;
}
.card:hover {
transform: translateY(-5px);
box-shadow: 0 0 30px color-mix(in srgb, var(--color-accent) 18%, transparent);
}
.media {
@apply rounded-xl;
background-color: #070b11;
background-image: linear-gradient(color-mix(in srgb, var(--color-accent) 7%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in srgb, var(--color-accent) 7%, transparent) 1px, transparent 1px);
background-size: 20px 20px;
}
.tag {
@apply rounded bg-accent px-2.5 py-1 font-mono text-[9px] font-bold tracking-[0.1em];
color: var(--color-bg);
}
.body {
@apply px-1 pb-1 pt-3.5;
}
.name {
@apply font-bold;
}
.priceRow {
@apply w-full justify-between;
}
.price {
@apply text-accent;
}
.cta {
@apply mt-4 w-full rounded-lg bg-accent py-3 text-center font-bold;
color: var(--color-bg);
box-shadow: 0 0 18px color-mix(in srgb, var(--color-accent) 30%, transparent);
}
}
/* ── SUPPER: warm polaroid card, pill "Add to table" ── */
@store supper {
.card {
@apply p-4;
box-shadow: 0 16px 38px rgba(120, 90, 50, 0.1);
}
.media {
@apply rounded;
background: #fffdf9;
}
.duck {
transform: rotate(-1.4deg);
}
.tag {
@apply left-1/2 bottom-3 top-auto -translate-x-1/2 font-display normal-case italic tracking-normal;
color: var(--color-muted);
}
.name {
@apply text-[23px] font-medium;
}
.priceRow {
@apply w-full items-center justify-between border-t border-line pt-4;
}
.price {
@apply font-display text-2xl;
}
.priceSub {
@apply hidden;
}
.cta {
@apply mt-0 rounded-full bg-accent px-5 py-2.5 font-semibold;
color: #fff;
}
}
/* ── COLLECTOR: ruled catalogue cell, outline "Acquire lot" ── */
@store collector {
.card {
@apply border-b border-r border-line p-6;
}
.media {
@apply mb-4 h-[150px] w-full;
}
.tag {
@apply left-auto right-3 top-3 font-mono text-[10px] font-bold tracking-[0.08em];
color: #5a4410;
background: linear-gradient(180deg, #f6dd92 0%, #dcb95e 46%, #bd9740 54%, #ecd485 100%);
border: 1px solid #8a6a28;
padding: 4px 10px;
}
.body {
@apply p-0;
}
.name {
@apply text-[21px];
}
.priceRow {
@apply w-full items-center justify-between border-t border-line pt-3.5;
}
.price {
@apply text-lg;
}
.priceSub {
@apply text-[9px] uppercase tracking-[0.1em];
}
.cta {
@apply mt-0 border px-4 py-2 text-[11px] tracking-[0.05em];
border-color: var(--color-accent);
}
}

View File

@@ -0,0 +1,31 @@
import Link from 'next/link';
import { store } from '@config/store';
import type { Product } from '@/data/catalog';
import Duck from '@/components/Duck/Duck';
import styles from './ProductCard.module.css';
export default function ProductCard({ product }: { product: Product }) {
return (
<article className={styles.card}>
<Link href={`/product/${product.slug}`} className={styles.media}>
<span className={styles.tag}>{product.tag}</span>
<div className={styles.duck}>
<Duck color={product.color} />
</div>
</Link>
<div className={styles.body}>
<h3 className={styles.name}>
<Link href={`/product/${product.slug}`}>{product.name}</Link>
</h3>
<p className={styles.blurb}>{product.blurb}</p>
<div className={styles.priceRow}>
<span className={styles.price}>{product.priceMain}</span>
{product.priceSub && <span className={styles.priceSub}>{product.priceSub}</span>}
</div>
<Link href={`/product/${product.slug}`} className={styles.cta}>
{store.ctaLabel}
</Link>
</div>
</article>
);
}

View File

@@ -0,0 +1,84 @@
@reference '../../styles/globals.css';
.detail {
@apply mx-auto grid max-w-6xl items-center gap-12 px-10 py-12 md:grid-cols-2;
}
.media {
@apply relative flex items-center justify-center bg-surface2;
aspect-ratio: 1 / 1;
border-radius: var(--radius-card);
}
.tag {
@apply absolute left-5 top-5 text-[10px] uppercase tracking-[0.1em] text-accent;
}
.duck {
width: 240px;
height: 210px;
animation: floaty 6s ease-in-out infinite;
}
.info {
@apply flex flex-col items-start gap-4;
}
.name {
@apply font-display text-4xl text-fg;
}
.priceRow {
@apply flex items-baseline gap-2;
}
.price {
@apply font-mono text-2xl text-fg;
}
.priceSub {
@apply text-sm text-muted;
}
.blurb {
@apply max-w-prose text-muted;
line-height: 1.7;
}
.cta {
@apply cursor-pointer border-none bg-accent px-7 py-3.5 text-sm font-semibold;
color: var(--color-bg);
border-radius: var(--radius-card);
}
@store executive {
.name {
@apply font-medium;
}
.cta {
@apply text-xs uppercase tracking-[0.18em];
color: #15110a;
}
}
@store crypto {
.cta {
@apply rounded-xl font-bold;
}
}
@store supper {
.name {
@apply italic;
}
.cta {
@apply rounded-full;
color: #fff;
}
}
@store collector {
.cta {
color: #f3f0e6;
}
}

View File

@@ -0,0 +1,28 @@
import { store } from '@config/store';
import type { Product } from '@/data/catalog';
import Duck from '@/components/Duck/Duck';
import styles from './ProductDetail.module.css';
export default function ProductDetail({ product }: { product: Product }) {
return (
<article className={styles.detail}>
<div className={styles.media}>
<span className={styles.tag}>{product.tag}</span>
<div className={styles.duck}>
<Duck color={product.color} />
</div>
</div>
<div className={styles.info}>
<h1 className={styles.name}>{product.name}</h1>
<div className={styles.priceRow}>
<span className={styles.price}>{product.priceMain}</span>
{product.priceSub && <span className={styles.priceSub}>{product.priceSub}</span>}
</div>
<p className={styles.blurb}>{product.blurb}</p>
<button type="button" className={styles.cta}>
{store.ctaLabel}
</button>
</div>
</article>
);
}

View File

@@ -0,0 +1,68 @@
@reference '../../styles/globals.css';
.section {
@apply mx-auto max-w-6xl px-10 pt-8 pb-20;
}
.head {
@apply mb-10 flex items-end justify-between gap-4 border-b border-line pb-5;
}
.title {
@apply m-0 font-display text-3xl text-fg;
}
.meta {
@apply text-[11px] uppercase tracking-[0.18em] text-muted;
}
.grid {
@apply grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3;
}
/* ── EXECUTIVE: hairline gallery grid (cards share 1px lines) ── */
@store executive {
.title {
@apply font-medium;
}
.grid {
@apply gap-px border border-line bg-line;
}
}
/* ── CRYPTO: loud heading, airy card grid ── */
@store crypto {
.title {
@apply font-bold tracking-tight;
}
.grid {
@apply gap-[18px];
}
}
/* ── SUPPER: centered heading, generous spacing ── */
@store supper {
.head {
@apply flex-col items-center border-none text-center;
}
.title {
@apply font-medium;
}
.grid {
@apply gap-8;
}
}
/* ── COLLECTOR: ruled catalogue, double underline ── */
@store collector {
.head {
@apply border-b-2;
border-bottom-color: var(--color-fg);
}
.title {
@apply font-semibold;
}
.grid {
@apply gap-0 border-l border-line;
}
}

View File

@@ -0,0 +1,20 @@
import { store } from '@config/store';
import { catalog } from '@/data/catalog';
import ProductCard from '@/components/ProductCard/ProductCard';
import styles from './ProductGrid.module.css';
export default function ProductGrid() {
return (
<section className={styles.section}>
<div className={styles.head}>
<h2 className={styles.title}>{store.collection.title}</h2>
<span className={styles.meta}>{store.collection.meta}</span>
</div>
<div className={styles.grid}>
{catalog.map((product) => (
<ProductCard key={product.id} product={product} />
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,69 @@
@reference '../../styles/globals.css';
.section {
@apply mx-auto max-w-6xl px-10 py-20;
}
.grid {
@apply grid grid-cols-1 gap-12 md:grid-cols-2;
}
.figure {
@apply m-0 border-t pt-6;
border-top-color: var(--color-accent);
}
.quote {
@apply m-0 mb-4 font-display text-fg;
font-size: 23px;
line-height: 1.4;
}
.cite {
@apply text-[11px] uppercase tracking-[0.16em] text-muted;
}
/* ── EXECUTIVE: italic serif epigraph ── */
@store executive {
.quote {
@apply italic;
font-size: 24px;
}
}
/* ── CRYPTO: timeline cards, mono handle ── */
@store crypto {
.figure {
@apply rounded-2xl border border-line bg-surface p-6;
border-top-color: var(--color-line);
}
.quote {
@apply font-mono;
font-size: 15px;
}
.cite {
@apply font-mono normal-case tracking-normal text-accent;
}
}
/* ── SUPPER: soft, no rules, warm cite ── */
@store supper {
.figure {
@apply border-none pt-0;
}
.quote {
@apply font-normal;
font-size: 24px;
}
.cite {
@apply normal-case tracking-normal text-accent;
font-size: 13px;
}
}
/* ── COLLECTOR: ledger epigraph, mono cite ── */
@store collector {
.cite {
@apply font-mono tracking-[0.08em];
}
}

View File

@@ -0,0 +1,17 @@
import { store } from '@config/store';
import styles from './Reviews.module.css';
export default function Reviews() {
return (
<section className={styles.section}>
<div className={styles.grid}>
{store.reviews.map((review, i) => (
<figure key={i} className={styles.figure}>
<blockquote className={styles.quote}>{review.q}</blockquote>
<figcaption className={styles.cite}> {review.a}</figcaption>
</figure>
))}
</div>
</section>
);
}

View File

@@ -0,0 +1,68 @@
@reference '../../styles/globals.css';
.footer {
@apply border-t border-line bg-bg;
}
.inner {
@apply mx-auto flex max-w-6xl flex-wrap items-center justify-between gap-6 px-10 py-14;
}
.brand {
@apply font-display text-xl text-fg;
}
.links {
@apply flex flex-wrap gap-7 text-xs uppercase tracking-[0.16em] text-muted;
}
.note {
@apply text-xs text-muted;
}
/* ── EXECUTIVE: letter-spaced wordmark ── */
@store executive {
.brand {
@apply uppercase tracking-[0.45em];
padding-left: 0.45em;
}
}
/* ── CRYPTO: dark base, mono ── */
@store crypto {
.footer {
background: #04060a;
}
.brand {
@apply font-bold;
}
.links,
.note {
@apply font-mono normal-case tracking-normal;
}
}
/* ── SUPPER: warm surface, italic wordmark ── */
@store supper {
.footer {
@apply border-none bg-surface2;
}
.brand {
@apply italic;
}
.links {
@apply normal-case tracking-normal;
font-size: 13px;
}
}
/* ── COLLECTOR: paper surface, mono links ── */
@store collector {
.footer {
@apply bg-surface2;
}
.links,
.note {
@apply font-mono tracking-[0.08em];
}
}

View File

@@ -0,0 +1,18 @@
import { store } from '@config/store';
import styles from './SiteFooter.module.css';
export default function SiteFooter() {
return (
<footer className={styles.footer}>
<div className={styles.inner}>
<div className={styles.brand}>{store.brandName}</div>
<nav className={styles.links}>
{store.footer.links.map((link) => (
<span key={link}>{link}</span>
))}
</nav>
<div className={styles.note}>{store.footer.note}</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,102 @@
@reference '../../styles/globals.css';
.header {
@apply border-b border-line bg-bg;
}
.inner {
@apply mx-auto flex max-w-6xl items-center gap-6 px-10 py-5;
}
.brand {
@apply flex flex-col leading-tight;
}
.brandName {
@apply font-display text-2xl text-fg;
}
.brandSub {
@apply mt-1 text-[10px] uppercase tracking-[0.2em] text-muted;
}
.nav {
@apply ml-auto flex gap-7 text-sm text-muted;
}
.navItem {
@apply cursor-pointer hover:text-fg;
}
.cart {
@apply cursor-pointer text-sm;
}
/* ── EXECUTIVE: centered serif wordmark, nav split left, bordered bag ── */
@store executive {
.inner {
@apply grid grid-cols-[1fr_auto_1fr] items-center;
}
.nav {
@apply order-1 ml-0 justify-self-start text-[11px] uppercase tracking-[0.2em];
}
.brand {
@apply order-2 justify-self-center items-center;
}
.brandName {
@apply text-2xl uppercase tracking-[0.45em];
padding-left: 0.45em;
}
.cart {
@apply order-3 justify-self-end border border-line px-4 py-2 text-[11px] uppercase tracking-[0.2em];
}
}
/* ── CRYPTO: glowing wordmark, mono nav, neon pill bag ── */
@store crypto {
.header {
@apply border-line;
}
.brandName {
@apply text-[22px] font-bold tracking-tight;
text-shadow: 0 0 18px color-mix(in srgb, var(--color-accent) 50%, transparent);
}
.nav {
@apply font-mono text-[13px];
}
.cart {
@apply rounded-xl bg-accent px-5 py-2.5 font-mono font-bold text-bg;
box-shadow: 0 0 22px color-mix(in srgb, var(--color-accent) 40%, transparent);
}
}
/* ── SUPPER: warm italic wordmark, rounded basket pill ── */
@store supper {
.header {
@apply border-transparent;
}
.brandName {
@apply text-[26px] italic;
}
.cart {
@apply rounded-full bg-fg px-5 py-2.5 text-[13px] text-surface;
}
}
/* ── COLLECTOR: serif wordmark, uppercase nav, hard maroon button ── */
@store collector {
.header {
@apply border-b-2;
border-bottom-color: var(--color-fg);
}
.brandName {
@apply text-[23px] font-semibold tracking-wide;
}
.nav {
@apply text-xs uppercase tracking-[0.08em];
}
.cart {
@apply bg-accent px-5 py-2.5 text-xs tracking-wide;
color: #f3f0e6;
}
}

View File

@@ -0,0 +1,25 @@
import { store } from '@config/store';
import styles from './SiteHeader.module.css';
export default function SiteHeader() {
return (
<header className={styles.header}>
<div className={styles.inner}>
<a href="/" className={styles.brand}>
<span className={styles.brandName}>{store.brandName}</span>
<span className={styles.brandSub}>{store.brandSub}</span>
</a>
<nav className={styles.nav}>
{store.nav.map((item) => (
<span key={item} className={styles.navItem}>
{item}
</span>
))}
</nav>
<a href="#" className={styles.cart}>
{store.cartLabel} · 0
</a>
</div>
</header>
);
}

46
src/data/catalog.ts Normal file
View File

@@ -0,0 +1,46 @@
import { store } from '@config/store';
import { ducks, type Duck } from './products';
import { metaBySystem } from './overrides';
export type Product = Duck & {
name: string;
blurb: string;
tag: string;
priceMain: string;
priceSub: string;
};
/** Shop system = store code up to the first `_` (e.g. `crypto_eu` -> `crypto`). */
const shopSystem = store.code.split('_')[0];
const meta = metaBySystem[shopSystem] ?? metaBySystem.executive;
function formatPrice(cents: number): { main: string; sub: string } {
const dollars = (cents / 100) * store.priceMultiplier;
switch (store.priceStyle) {
case 'usd':
return { main: `$${Math.round(dollars).toLocaleString('en-US')}`, sub: 'USD' };
case 'crypto':
return { main: `$${Math.round(dollars)} USD`, sub: `${(dollars / 2400).toFixed(3)} Ξ` };
case 'appraisal':
return { main: `$${Math.round(dollars).toLocaleString('en-US')}`, sub: 'Last appraisal' };
default:
return { main: `$${dollars.toFixed(0)}`, sub: '' };
}
}
/** The active storefront's catalogue: shared ducks merged with its own copy. */
export const catalog: Product[] = ducks.map((duck, i) => {
const price = formatPrice(duck.cents);
return {
...duck,
name: meta.name[i],
blurb: meta.blurb[i],
tag: meta.tag[i],
priceMain: price.main,
priceSub: price.sub,
};
});
export function getProduct(slug: string): Product | undefined {
return catalog.find((product) => product.slug === slug);
}

20
src/data/products.ts Normal file
View File

@@ -0,0 +1,20 @@
/** One shared catalogue of six rubber ducks. Every storefront sells these exact
* ducks — only the copy (per-store META) and the visuals (@store CSS) differ. */
export type Duck = {
id: string;
sku: string;
/** Base price in cents; each store scales + formats it differently. */
cents: number;
/** Body colour of the duck (per product, not per store). */
color: string;
slug: string;
};
export const ducks: Duck[] = [
{ id: 'd1', sku: 'DUCK-01', cents: 2400, color: '#f5c518', slug: 'duck-01' },
{ id: 'd2', sku: 'DUCK-02', cents: 3000, color: '#26262b', slug: 'duck-02' },
{ id: 'd3', sku: 'DUCK-03', cents: 2800, color: '#ece3d2', slug: 'duck-03' },
{ id: 'd4', sku: 'DUCK-04', cents: 4200, color: '#d0b25e', slug: 'duck-04' },
{ id: 'd5', sku: 'DUCK-05', cents: 2600, color: '#3f74b8', slug: 'duck-05' },
{ id: 'd6', sku: 'DUCK-06', cents: 2700, color: '#bd4a44', slug: 'duck-06' },
];