feat: demonstrate shared component architecture
This commit is contained in:
32
src/app/about/page.module.css
Normal file
32
src/app/about/page.module.css
Normal 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
63
src/app/about/page.tsx
Normal 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'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
15
src/app/page.tsx
Normal 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
21
src/app/product/[slug]/page.module.css
Normal file
21
src/app/product/[slug]/page.module.css
Normal 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];
|
||||
}
|
||||
}
|
||||
33
src/app/product/[slug]/page.tsx
Normal file
33
src/app/product/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/components/Duck/Duck.module.css
Normal file
86
src/components/Duck/Duck.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
39
src/components/Duck/Duck.tsx
Normal file
39
src/components/Duck/Duck.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
src/components/FeaturedBlock/FeaturedBlock.module.css
Normal file
94
src/components/FeaturedBlock/FeaturedBlock.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
20
src/components/FeaturedBlock/FeaturedBlock.tsx
Normal file
20
src/components/FeaturedBlock/FeaturedBlock.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
107
src/components/Hero/Hero.module.css
Normal file
107
src/components/Hero/Hero.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
26
src/components/Hero/Hero.tsx
Normal file
26
src/components/Hero/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
187
src/components/ProductCard/ProductCard.module.css
Normal file
187
src/components/ProductCard/ProductCard.module.css
Normal 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);
|
||||
}
|
||||
}
|
||||
31
src/components/ProductCard/ProductCard.tsx
Normal file
31
src/components/ProductCard/ProductCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
84
src/components/ProductDetail/ProductDetail.module.css
Normal file
84
src/components/ProductDetail/ProductDetail.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
28
src/components/ProductDetail/ProductDetail.tsx
Normal file
28
src/components/ProductDetail/ProductDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
src/components/ProductGrid/ProductGrid.module.css
Normal file
68
src/components/ProductGrid/ProductGrid.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
20
src/components/ProductGrid/ProductGrid.tsx
Normal file
20
src/components/ProductGrid/ProductGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/components/Reviews/Reviews.module.css
Normal file
69
src/components/Reviews/Reviews.module.css
Normal 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];
|
||||
}
|
||||
}
|
||||
17
src/components/Reviews/Reviews.tsx
Normal file
17
src/components/Reviews/Reviews.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
src/components/SiteFooter/SiteFooter.module.css
Normal file
68
src/components/SiteFooter/SiteFooter.module.css
Normal 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];
|
||||
}
|
||||
}
|
||||
18
src/components/SiteFooter/SiteFooter.tsx
Normal file
18
src/components/SiteFooter/SiteFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
src/components/SiteHeader/SiteHeader.module.css
Normal file
102
src/components/SiteHeader/SiteHeader.module.css
Normal 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;
|
||||
}
|
||||
}
|
||||
25
src/components/SiteHeader/SiteHeader.tsx
Normal file
25
src/components/SiteHeader/SiteHeader.tsx
Normal 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
46
src/data/catalog.ts
Normal 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
20
src/data/products.ts
Normal 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' },
|
||||
];
|
||||
Reference in New Issue
Block a user