Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/storefront/app/products/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ export default async function ProductPage({ params }: Params) {
{product.garment}
</p>
<h1 className="mt-2 font-display text-4xl font-bold uppercase leading-[0.95] tracking-tight text-ink sm:text-5xl">
{product.artworkTitle}
{product.title}
</h1>
<p className="mt-2 text-sm text-muted">
From the{' '}
Expand Down
44 changes: 26 additions & 18 deletions apps/storefront/app/shop/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@ export const metadata: Metadata = {

export default async function ShopPage() {
const products = await dataProvider.listProducts();
const designSlugs = new Set(suppliedShopDesigns.map((d) => d.slug));
const clothingDesigns = products.filter((p) => designSlugs.has(p.slug));
const availablePieces = products.filter((p) => !designSlugs.has(p.slug));

return (
<Container className="py-14">
<PageHeader
eyebrow="Shop"
title="The shop"
lead="Clothing designs and available garments — artwork made wearable."
lead="Ready-to-order tees and garments — pick a piece, choose your size, and add it to your bag."
contained={false}
/>

Expand All @@ -32,25 +35,30 @@ export default async function ShopPage() {
Clothing designs
</h2>
<p className="mt-2 max-w-2xl text-sm text-muted sm:text-base">
Every supplied shirt and worn-piece image lives here in Shop, linked to the artwork it
carries.
Studio-supplied shirts you can buy as-is — open a piece to choose size, add to bag, and
leave a review.
</p>
<ul className="mt-6 grid grid-cols-2 gap-x-5 gap-y-10 sm:gap-6 lg:grid-cols-4">
{suppliedShopDesigns.map((design, i) => (
<li key={design.slug}>
<Reveal delay={Math.min(i, 3) * 60}>
<ShopDesignCard design={design} priority={i < 2} />
</Reveal>
</li>
))}
</ul>
{clothingDesigns.length === 0 ? (
<div className="mt-6">
<EmptyState
title="No clothing designs yet"
description="New shirts are on their way."
/>
</div>
) : (
<ul className="mt-6 grid grid-cols-2 gap-x-5 gap-y-10 sm:gap-6 lg:grid-cols-4">
{clothingDesigns.map((product, i) => (
<li key={product.id}>
<Reveal delay={Math.min(i, 3) * 60}>
<ShopDesignCard product={product} priority={i < 2} />
</Reveal>
</li>
))}
</ul>
)}
</section>

{products.length === 0 ? (
<div className="mt-10">
<EmptyState title="Nothing in the shop yet" description="New drops are on their way." />
</div>
) : (
{availablePieces.length === 0 ? null : (
<section className="mt-16 border-t border-line pt-12" aria-labelledby="available-title">
<h2
id="available-title"
Expand All @@ -59,7 +67,7 @@ export default async function ShopPage() {
Available pieces
</h2>
<ul className="mt-6 grid grid-cols-2 gap-x-5 gap-y-10 sm:gap-6 lg:grid-cols-3">
{products.map((product, i) => (
{availablePieces.map((product, i) => (
<li key={product.id}>
<Reveal delay={Math.min(i, 5) * 60}>
<ProductCard product={product} />
Expand Down
8 changes: 6 additions & 2 deletions apps/storefront/components/product/product-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const availabilityLabel = {
* The image is the product's artwork (products have no photos; the drawing is the product).
*/
export function ProductCard({ product }: { product: ProductSummary }) {
const src = artworkImage(product.artworkSlug);
const src = product.image ?? artworkImage(product.artworkSlug);
const badge = availabilityLabel[product.availability];

return (
Expand All @@ -27,7 +27,11 @@ export function ProductCard({ product }: { product: ProductSummary }) {
>
<TileImage
src={src}
alt={`${product.artworkTitle} on ${product.garment} — preview`}
alt={
product.image
? `${product.title} — product photo`
: `${product.artworkTitle} on ${product.garment} — preview`
}
badge={
badge ? (
<TileBadge className={product.availability === 'sold_out' ? 'bg-neutral-950/80' : ''}>
Expand Down
91 changes: 57 additions & 34 deletions apps/storefront/components/product/product-configurator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { Alert, Badge, Price, cn } from '@tms/ui';
import { Minus, Plus, ShoppingBag } from 'lucide-react';
import Image from 'next/image';
import { useMemo, useRef, useState } from 'react';
import { WishlistButton } from '@/components/account/wishlist-button';
import { Accordion } from '@/components/site/accordion';
Expand Down Expand Up @@ -33,6 +34,7 @@ export function ProductConfigurator({ product }: { product: ProductDetail }) {
const selectedColour = product.colours.find((c) => c.name === colour);
const soldOut = product.availability === 'sold_out';
const print = artworkImage(product.artworkSlug);
const productPhoto = product.image ?? null;

function addToBag() {
if (soldOut) return;
Expand Down Expand Up @@ -69,46 +71,67 @@ export function ProductConfigurator({ product }: { product: ProductDetail }) {

return (
<div className="grid gap-10 lg:grid-cols-2">
{/* Preview */}
{/* Preview — prefer a studio product photo when one was supplied. */}
<div>
<div
className="relative w-full overflow-hidden rounded-[var(--radius-lg)] border border-line bg-canvas-2"
role="img"
aria-label={`${product.artworkTitle} on ${colour ?? 'garment'}, ${view} view`}
aria-label={
productPhoto
? `${product.title} product photo`
: `${product.artworkTitle} on ${colour ?? 'garment'}, ${view} view`
}
>
<GarmentMockup
garment={product.garment}
colour={colour ?? selectedColour?.hex}
view={view}
artwork={print ? { src: print, area: 'front', alt: '' } : null}
priority
className="p-4 sm:p-6"
sizes="(min-width: 1024px) 40vw, 90vw"
/>
<span className="absolute left-3 top-3 rounded-full bg-black/40 px-2 py-0.5 text-xs uppercase tracking-[0.08em] text-white">
{view}
</span>
</div>
<div
className="mt-3 inline-flex rounded-md border border-line p-1"
role="group"
aria-label="Garment view"
>
{(['front', 'back'] as View[]).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
aria-pressed={view === v}
className={cn(
'rounded px-3 py-1.5 text-sm capitalize outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-focus-ring)]',
view === v ? 'bg-accent text-on-accent' : 'text-ink-2 hover:text-ink',
)}
>
{v}
</button>
))}
{productPhoto ? (
<div className="relative aspect-[4/5] w-full">
<Image
src={productPhoto}
alt={`${product.title} — product photo`}
fill
priority
sizes="(min-width: 1024px) 40vw, 90vw"
className="object-cover"
/>
</div>
) : (
<GarmentMockup
garment={product.garment}
colour={colour ?? selectedColour?.hex}
view={view}
artwork={print ? { src: print, area: 'front', alt: '' } : null}
priority
className="p-4 sm:p-6"
sizes="(min-width: 1024px) 40vw, 90vw"
/>
)}
{productPhoto ? null : (
<span className="absolute left-3 top-3 rounded-full bg-black/40 px-2 py-0.5 text-xs uppercase tracking-[0.08em] text-white">
{view}
</span>
)}
</div>
{productPhoto ? null : (
<div
className="mt-3 inline-flex rounded-md border border-line p-1"
role="group"
aria-label="Garment view"
>
{(['front', 'back'] as View[]).map((v) => (
<button
key={v}
type="button"
onClick={() => setView(v)}
aria-pressed={view === v}
className={cn(
'rounded px-3 py-1.5 text-sm capitalize outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-focus-ring)]',
view === v ? 'bg-accent text-on-accent' : 'text-ink-2 hover:text-ink',
)}
>
{v}
</button>
))}
</div>
)}
</div>

{/* Configuration */}
Expand Down
30 changes: 18 additions & 12 deletions apps/storefront/components/product/shop-design-card.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,40 @@
import { Price } from '@tms/ui';
import Link from 'next/link';
import { TileBadge, TileImage } from '@/components/site/tile';
import type { SuppliedShopDesign } from '@/lib/data/supplied-catalogue';
import type { ProductSummary } from '@/lib/data';

/** A supplied clothing photograph linked back to its immutable artwork canvas. */
/** A studio-supplied clothing photograph that opens as a buyable product page. */
export function ShopDesignCard({
design,
product,
priority = false,
}: {
design: SuppliedShopDesign;
product: ProductSummary;
priority?: boolean;
}) {
const src = product.image ?? null;

return (
<Link
href={`/design-studio?artwork=${design.artworkSlug}`}
href={`/products/${product.slug}`}
className="group block rounded-2xl outline-none focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-[var(--color-focus-ring)]"
>
<TileImage
src={design.image}
alt={`${design.title} — clothing design`}
src={src}
alt={`${product.title} — product photo`}
priority={priority}
badge={<TileBadge>Clothing design</TileBadge>}
badge={<TileBadge>Ready to buy</TileBadge>}
/>
<div className="mt-4">
<h3 className="font-display text-sm font-bold uppercase tracking-wide text-ink">
{design.title}
{product.title}
</h3>
<div className="mt-1 flex items-center justify-between gap-3 text-xs">
<p className="text-muted">{design.garment}</p>
<span className="font-semibold uppercase tracking-[0.08em] text-ink">Design yours</span>
<div className="mt-1 flex items-baseline justify-between gap-3 text-xs">
<p className="min-w-0 truncate text-muted">{product.garment}</p>
<span className="shrink-0 font-display text-sm font-semibold text-ink">
<Price amountMinor={product.priceMinor} currency={product.currency} />
</span>
</div>
<p className="mt-2 font-semibold uppercase tracking-[0.08em] text-ink">Shop now</p>
</div>
</Link>
);
Expand Down
70 changes: 68 additions & 2 deletions apps/storefront/lib/data/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { filterApproved } from '../community';
import { summariseReviews } from '../reviews';
import { artworkImage } from '../artwork-images';
import { countShoppableItems, storyHotspotTargets } from '../stories';
import { suppliedArtworkSeeds } from './supplied-catalogue';
import { suppliedArtworkSeeds, suppliedShopDesigns } from './supplied-catalogue';
import type {
ArtworkDetail,
ArtworkPassport,
Expand Down Expand Up @@ -57,6 +57,10 @@ interface ProductSeed {
colours: string[];
unavailableColours?: string[];
soldOutSizes?: string[];
/** Shop-facing title when it should differ from `artwork — garment`. */
displayTitle?: string;
/** Product photograph when the studio supplied a flat-lay / worn shot. */
image?: string;
}

const productSeeds: ProductSeed[] = [
Expand Down Expand Up @@ -124,13 +128,30 @@ const productSeeds: ProductSeed[] = [
availability: 'available',
colours: ['Black', 'Slate', 'Sand'],
},
// Studio-supplied clothing photographs — buyable products in Shop (not Design Studio links).
...suppliedShopDesigns.map<ProductSeed>((design) => {
const artwork = suppliedArtworkSeeds.find((a) => a.slug === design.artworkSlug);
const isBlack = design.garment.toLowerCase().includes('black');
return {
slug: design.slug,
artworkSlug: design.artworkSlug,
artworkTitle: suppliedArtworkTitle(design.artworkSlug),
collection: artwork?.collection ?? 'Africa United',
garment: 'Classic T-shirt',
displayTitle: design.title,
image: design.image,
priceMinor: 1400000,
availability: 'available',
colours: isBlack ? ['Black', 'Slate'] : ['Bone', 'Sand'],
};
}),
];

function toProductSummary(seed: ProductSeed): ProductSummary {
return {
id: seed.slug,
slug: seed.slug,
title: `${seed.artworkTitle} — ${seed.garment}`,
title: seed.displayTitle ?? `${seed.artworkTitle} — ${seed.garment}`,
artworkSlug: seed.artworkSlug,
artworkTitle: seed.artworkTitle,
collection: seed.collection,
Expand All @@ -139,6 +160,7 @@ function toProductSummary(seed: ProductSeed): ProductSummary {
currency: 'NGN',
availability: seed.availability,
colourCount: seed.colours.length,
image: seed.image ?? null,
};
}

Expand Down Expand Up @@ -703,6 +725,50 @@ function toStorySummary(seed: StorySeed): StorySummary {
* read/write/moderation is server-authoritative (TMS-FBR-008).
*/
const reviewSeeds: Record<string, Review[]> = {
'product:africa-united-white-tee': [
{
id: 'rv-auw-1',
rating: 5,
title: 'Wears like the studio shot',
body: 'Print is crisp on the white cotton and the fit is true to size. Exactly what I ordered from Shop.',
author: 'Amaka D.',
createdAt: '2026-07-12T10:00:00.000Z',
verifiedPurchase: true,
},
],
'product:africa-united-black-tee': [
{
id: 'rv-aub-1',
rating: 5,
title: 'Black tee, loud print',
body: 'The duo graphic pops on black. Soft hand-feel after one wash and no fading yet.',
author: 'Chidi O.',
createdAt: '2026-07-10T15:20:00.000Z',
verifiedPurchase: true,
},
],
'product:resilience-white-tee': [
{
id: 'rv-rw-1',
rating: 4,
title: 'Poster energy on cotton',
body: 'Love the type and the heritage symbols. Sized up for a looser street fit and it works.',
author: 'Zainab M.',
createdAt: '2026-07-08T09:10:00.000Z',
verifiedPurchase: true,
},
],
'product:africa-united-model-tee': [
{
id: 'rv-aum-1',
rating: 5,
title: 'Looks like the worn photo',
body: 'Bought the black worn piece and it matches the shop photo. Easy add-to-bag flow, arrived in five days.',
author: 'Ifeanyi B.',
createdAt: '2026-07-14T12:40:00.000Z',
verifiedPurchase: true,
},
],
'product:midnight-in-lagos-classic-tee': [
{
id: 'rv-mil-1',
Expand Down
Loading
Loading