diff --git a/src/app/about/page.module.css b/src/app/about/page.module.css new file mode 100644 index 0000000..5f74edc --- /dev/null +++ b/src/app/about/page.module.css @@ -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); +} diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx new file mode 100644 index 0000000..96cff0e --- /dev/null +++ b/src/app/about/page.tsx @@ -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 ( +
+

One codebase, four storefronts

+ +

+ You are looking at {store.brandName} — the{' '} + {store.code} storefront. The other three —{' '} + {stores + .filter((s) => s.code !== store.code) + .map((s) => s.label) + .join(', ')}{' '} + — are the same React components and the same CSS files. No component knows which store it is. +

+ +

+ Every visual difference — colour, type, spacing, even layout — lives inside{' '} + @store blocks in the CSS. A small PostCSS plugin,{' '} + postcss-storestyles, runs at build time and keeps only the blocks + that match NEXT_PUBLIC_STORE_CODE. Every other store's styles + are stripped before they reach the browser, so each build ships one lean stylesheet with zero runtime branching. +

+ +

The styles

+
+				{exampleCss}
+			
+ +

The plugin that decides

+
+				{examplePlugin}
+			
+ +

+ Each storefront is its own build, deployed to its own Cloudflare Worker. One codebase, four storefronts, each + maintained in the file it belongs to. +

+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..994495b --- /dev/null +++ b/src/app/page.tsx @@ -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 ( + <> + + + + + + ); +} diff --git a/src/app/product/[slug]/page.module.css b/src/app/product/[slug]/page.module.css new file mode 100644 index 0000000..6653dc4 --- /dev/null +++ b/src/app/product/[slug]/page.module.css @@ -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]; + } +} diff --git a/src/app/product/[slug]/page.tsx b/src/app/product/[slug]/page.tsx new file mode 100644 index 0000000..dd0e8db --- /dev/null +++ b/src/app/product/[slug]/page.tsx @@ -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 ( +
+ + ← Back to shop + + +
+ ); +} diff --git a/src/components/Duck/Duck.module.css b/src/components/Duck/Duck.module.css new file mode 100644 index 0000000..765608e --- /dev/null +++ b/src/components/Duck/Duck.module.css @@ -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; + } +} diff --git a/src/components/Duck/Duck.tsx b/src/components/Duck/Duck.tsx new file mode 100644 index 0000000..7e93dd6 --- /dev/null +++ b/src/components/Duck/Duck.tsx @@ -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 ( + + + + + + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/FeaturedBlock/FeaturedBlock.module.css b/src/components/FeaturedBlock/FeaturedBlock.module.css new file mode 100644 index 0000000..b976e4e --- /dev/null +++ b/src/components/FeaturedBlock/FeaturedBlock.module.css @@ -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; + } +} diff --git a/src/components/FeaturedBlock/FeaturedBlock.tsx b/src/components/FeaturedBlock/FeaturedBlock.tsx new file mode 100644 index 0000000..c21e435 --- /dev/null +++ b/src/components/FeaturedBlock/FeaturedBlock.tsx @@ -0,0 +1,20 @@ +import { store } from '@config/store'; +import styles from './FeaturedBlock.module.css'; + +export default function FeaturedBlock() { + return ( +
+
+
+
{store.featured.kicker}
+

{store.featured.title}

+

{store.featured.body}

+ +
+ +
+ ); +} diff --git a/src/components/Hero/Hero.module.css b/src/components/Hero/Hero.module.css new file mode 100644 index 0000000..ad5caf6 --- /dev/null +++ b/src/components/Hero/Hero.module.css @@ -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; + } +} diff --git a/src/components/Hero/Hero.tsx b/src/components/Hero/Hero.tsx new file mode 100644 index 0000000..a449205 --- /dev/null +++ b/src/components/Hero/Hero.tsx @@ -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 ( +
+
+
{store.hero.eyebrow}
+

{store.hero.title}

+

{store.hero.body}

+ +
+
+
+ +
+
+
+ ); +} diff --git a/src/components/ProductCard/ProductCard.module.css b/src/components/ProductCard/ProductCard.module.css new file mode 100644 index 0000000..fc03531 --- /dev/null +++ b/src/components/ProductCard/ProductCard.module.css @@ -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); + } +} diff --git a/src/components/ProductCard/ProductCard.tsx b/src/components/ProductCard/ProductCard.tsx new file mode 100644 index 0000000..f0f61f5 --- /dev/null +++ b/src/components/ProductCard/ProductCard.tsx @@ -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 ( +
+ + {product.tag} +
+ +
+ +
+

+ {product.name} +

+

{product.blurb}

+
+ {product.priceMain} + {product.priceSub && {product.priceSub}} +
+ + {store.ctaLabel} + +
+
+ ); +} diff --git a/src/components/ProductDetail/ProductDetail.module.css b/src/components/ProductDetail/ProductDetail.module.css new file mode 100644 index 0000000..6ab22e5 --- /dev/null +++ b/src/components/ProductDetail/ProductDetail.module.css @@ -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; + } +} diff --git a/src/components/ProductDetail/ProductDetail.tsx b/src/components/ProductDetail/ProductDetail.tsx new file mode 100644 index 0000000..4299e3c --- /dev/null +++ b/src/components/ProductDetail/ProductDetail.tsx @@ -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 ( +
+
+ {product.tag} +
+ +
+
+
+

{product.name}

+
+ {product.priceMain} + {product.priceSub && {product.priceSub}} +
+

{product.blurb}

+ +
+
+ ); +} diff --git a/src/components/ProductGrid/ProductGrid.module.css b/src/components/ProductGrid/ProductGrid.module.css new file mode 100644 index 0000000..1b429fe --- /dev/null +++ b/src/components/ProductGrid/ProductGrid.module.css @@ -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; + } +} diff --git a/src/components/ProductGrid/ProductGrid.tsx b/src/components/ProductGrid/ProductGrid.tsx new file mode 100644 index 0000000..33c1b42 --- /dev/null +++ b/src/components/ProductGrid/ProductGrid.tsx @@ -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 ( +
+
+

{store.collection.title}

+ {store.collection.meta} +
+
+ {catalog.map((product) => ( + + ))} +
+
+ ); +} diff --git a/src/components/Reviews/Reviews.module.css b/src/components/Reviews/Reviews.module.css new file mode 100644 index 0000000..10ce447 --- /dev/null +++ b/src/components/Reviews/Reviews.module.css @@ -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]; + } +} diff --git a/src/components/Reviews/Reviews.tsx b/src/components/Reviews/Reviews.tsx new file mode 100644 index 0000000..1c1705c --- /dev/null +++ b/src/components/Reviews/Reviews.tsx @@ -0,0 +1,17 @@ +import { store } from '@config/store'; +import styles from './Reviews.module.css'; + +export default function Reviews() { + return ( +
+
+ {store.reviews.map((review, i) => ( +
+
“{review.q}”
+
— {review.a}
+
+ ))} +
+
+ ); +} diff --git a/src/components/SiteFooter/SiteFooter.module.css b/src/components/SiteFooter/SiteFooter.module.css new file mode 100644 index 0000000..fd9e1c5 --- /dev/null +++ b/src/components/SiteFooter/SiteFooter.module.css @@ -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]; + } +} diff --git a/src/components/SiteFooter/SiteFooter.tsx b/src/components/SiteFooter/SiteFooter.tsx new file mode 100644 index 0000000..d637aba --- /dev/null +++ b/src/components/SiteFooter/SiteFooter.tsx @@ -0,0 +1,18 @@ +import { store } from '@config/store'; +import styles from './SiteFooter.module.css'; + +export default function SiteFooter() { + return ( +
+
+
{store.brandName}
+ +
{store.footer.note}
+
+
+ ); +} diff --git a/src/components/SiteHeader/SiteHeader.module.css b/src/components/SiteHeader/SiteHeader.module.css new file mode 100644 index 0000000..32ffad3 --- /dev/null +++ b/src/components/SiteHeader/SiteHeader.module.css @@ -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; + } +} diff --git a/src/components/SiteHeader/SiteHeader.tsx b/src/components/SiteHeader/SiteHeader.tsx new file mode 100644 index 0000000..1a3fbec --- /dev/null +++ b/src/components/SiteHeader/SiteHeader.tsx @@ -0,0 +1,25 @@ +import { store } from '@config/store'; +import styles from './SiteHeader.module.css'; + +export default function SiteHeader() { + return ( +
+
+ + {store.brandName} + {store.brandSub} + + + + {store.cartLabel} · 0 + +
+
+ ); +} diff --git a/src/data/catalog.ts b/src/data/catalog.ts new file mode 100644 index 0000000..7090bf4 --- /dev/null +++ b/src/data/catalog.ts @@ -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); +} diff --git a/src/data/products.ts b/src/data/products.ts new file mode 100644 index 0000000..07d824a --- /dev/null +++ b/src/data/products.ts @@ -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' }, +];