From aa97b9aee0f4cd808ac8d5242604eb30017f8172 Mon Sep 17 00:00:00 2001 From: Ghost69 Date: Tue, 21 Jul 2026 11:39:05 +0100 Subject: [PATCH] feat(storefront): make Shop clothing designs buyable product pages Co-authored-by: Cursor --- apps/storefront/app/products/[slug]/page.tsx | 2 +- apps/storefront/app/shop/page.tsx | 44 +++++---- .../components/product/product-card.tsx | 8 +- .../product/product-configurator.tsx | 91 ++++++++++++------- .../components/product/shop-design-card.tsx | 30 +++--- apps/storefront/lib/data/mock.ts | 70 +++++++++++++- .../lib/data/supplied-catalogue.spec.ts | 16 +++- apps/storefront/lib/data/types.ts | 5 + 8 files changed, 196 insertions(+), 70 deletions(-) diff --git a/apps/storefront/app/products/[slug]/page.tsx b/apps/storefront/app/products/[slug]/page.tsx index bfe9b76..03e3974 100644 --- a/apps/storefront/app/products/[slug]/page.tsx +++ b/apps/storefront/app/products/[slug]/page.tsx @@ -55,7 +55,7 @@ export default async function ProductPage({ params }: Params) { {product.garment}

- {product.artworkTitle} + {product.title}

From the{' '} diff --git a/apps/storefront/app/shop/page.tsx b/apps/storefront/app/shop/page.tsx index a664c97..ebf4991 100644 --- a/apps/storefront/app/shop/page.tsx +++ b/apps/storefront/app/shop/page.tsx @@ -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 ( @@ -32,25 +35,30 @@ export default async function ShopPage() { Clothing designs

- 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.

- + {clothingDesigns.length === 0 ? ( +
+ +
+ ) : ( + + )} - {products.length === 0 ? ( -
- -
- ) : ( + {availablePieces.length === 0 ? null : (

    - {products.map((product, i) => ( + {availablePieces.map((product, i) => (
  • diff --git a/apps/storefront/components/product/product-card.tsx b/apps/storefront/components/product/product-card.tsx index edc71eb..538a2cd 100644 --- a/apps/storefront/components/product/product-card.tsx +++ b/apps/storefront/components/product/product-card.tsx @@ -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 ( @@ -27,7 +27,11 @@ export function ProductCard({ product }: { product: ProductSummary }) { > diff --git a/apps/storefront/components/product/product-configurator.tsx b/apps/storefront/components/product/product-configurator.tsx index 9f329a0..aa20dd3 100644 --- a/apps/storefront/components/product/product-configurator.tsx +++ b/apps/storefront/components/product/product-configurator.tsx @@ -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'; @@ -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; @@ -69,46 +71,67 @@ export function ProductConfigurator({ product }: { product: ProductDetail }) { return (
    - {/* Preview */} + {/* Preview — prefer a studio product photo when one was supplied. */}
    - - - {view} - -
    -
    - {(['front', 'back'] as View[]).map((v) => ( - - ))} + {productPhoto ? ( +
    + {`${product.title} +
    + ) : ( + + )} + {productPhoto ? null : ( + + {view} + + )}
    + {productPhoto ? null : ( +
    + {(['front', 'back'] as View[]).map((v) => ( + + ))} +
    + )}
    {/* Configuration */} diff --git a/apps/storefront/components/product/shop-design-card.tsx b/apps/storefront/components/product/shop-design-card.tsx index b5d4453..87f0684 100644 --- a/apps/storefront/components/product/shop-design-card.tsx +++ b/apps/storefront/components/product/shop-design-card.tsx @@ -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 ( Clothing design} + badge={Ready to buy} />

    - {design.title} + {product.title}

    -
    -

    {design.garment}

    - Design yours +
    +

    {product.garment}

    + + +
    +

    Shop now

    ); diff --git a/apps/storefront/lib/data/mock.ts b/apps/storefront/lib/data/mock.ts index bd62800..60ed8c6 100644 --- a/apps/storefront/lib/data/mock.ts +++ b/apps/storefront/lib/data/mock.ts @@ -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, @@ -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[] = [ @@ -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((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, @@ -139,6 +160,7 @@ function toProductSummary(seed: ProductSeed): ProductSummary { currency: 'NGN', availability: seed.availability, colourCount: seed.colours.length, + image: seed.image ?? null, }; } @@ -703,6 +725,50 @@ function toStorySummary(seed: StorySeed): StorySummary { * read/write/moderation is server-authoritative (TMS-FBR-008). */ const reviewSeeds: Record = { + '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', diff --git a/apps/storefront/lib/data/supplied-catalogue.spec.ts b/apps/storefront/lib/data/supplied-catalogue.spec.ts index c63376e..7275c06 100644 --- a/apps/storefront/lib/data/supplied-catalogue.spec.ts +++ b/apps/storefront/lib/data/supplied-catalogue.spec.ts @@ -22,14 +22,28 @@ describe('studio-supplied catalogue media', () => { } }); - it('keeps clothing photographs in Shop and links them to artwork', () => { + it('keeps clothing photographs in Shop as buyable products', async () => { expect(suppliedShopDesigns).toHaveLength(4); + const products = await mockProvider.listProducts(); + const bySlug = new Map(products.map((p) => [p.slug, p])); const artworkSlugs = new Set(suppliedArtworkSeeds.map(({ slug }) => slug)); + for (const design of suppliedShopDesigns) { expect(design.image).toMatch(/^\/products\/.+\.jpg$/); expect(artworkSlugs.has(design.artworkSlug)).toBe(true); expect(existsSync(join(process.cwd(), 'public', design.image.slice(1)))).toBe(true); + + const product = bySlug.get(design.slug); + expect(product).toBeDefined(); + expect(product?.image).toBe(design.image); + expect(product?.artworkSlug).toBe(design.artworkSlug); + expect(product?.availability).toBe('available'); + + const detail = await mockProvider.getProduct(design.slug); + expect(detail).not.toBeNull(); + expect(detail?.colours.length).toBeGreaterThan(0); + expect(detail?.sizes.some((s) => s.available)).toBe(true); } }); diff --git a/apps/storefront/lib/data/types.ts b/apps/storefront/lib/data/types.ts index 69c9728..0c49c68 100644 --- a/apps/storefront/lib/data/types.ts +++ b/apps/storefront/lib/data/types.ts @@ -258,6 +258,11 @@ export interface ProductSummary { currency: string; availability: Availability; colourCount: number; + /** + * Optional product photograph (flat-lay / worn shot). When set, shop tiles and the product + * page prefer it over composing a garment mockup from the artwork plate alone. + */ + image?: string | null; } /**