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
39 changes: 20 additions & 19 deletions apps/storefront/app/collections/page.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,45 @@
import { Container, EmptyState } from '@tms/ui';
import type { Metadata } from 'next';
import { CollectionCard } from '@/components/collection/collection-card';
import { ArtworkCard } from '@/components/artwork/artwork-card';
import { PageHeader } from '@/components/site/page-header';
import { artworkImage } from '@/lib/artwork-images';
import { Reveal } from '@/components/site/reveal';
import { dataProvider } from '@/lib/data';

export const metadata: Metadata = {
title: 'Collections',
description: 'Curated bodies of work, grouped by theme and season.',
description: 'Browse every standalone artwork in the F.A.T.U collection.',
};

export default async function CollectionsPage() {
const summaries = await dataProvider.listCollectionSummaries();

// Collection summaries carry no cover image, so pull each collection's pieces and pick the first
// one we actually hold a drawing for as the chapter cover. A handful of collections; cheap.
const details = await Promise.all(summaries.map((s) => dataProvider.getCollection(s.slug)));
const collections = summaries.map((summary, i) => ({
summary,
coverSlug: details[i]?.artworks.find((a) => artworkImage(a.slug) !== null)?.slug ?? null,
}));
const { items: artworks } = await dataProvider.listArtworks({ limit: 60 });

return (
<Container className="py-14">
<PageHeader
eyebrow="The gallery"
title="Collections"
lead="Curated bodies of work, grouped by theme and season — each a chapter in the studio's line."
lead={`${artworks.length} standalone artworks — drawings stay in Collections; clothing stays in Shop.`}
contained={false}
/>

{collections.length === 0 ? (
{artworks.length === 0 ? (
<div className="mt-10">
<EmptyState title="No collections yet" description="New collections are on their way." />
<EmptyState
title="No artworks yet"
description="New collection pieces are on their way."
/>
</div>
) : (
<ul className="mt-10 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{collections.map(({ summary, coverSlug }) => (
<li key={summary.slug}>
<CollectionCard collection={summary} coverSlug={coverSlug} />
<ul className="mt-10 grid grid-cols-2 gap-x-5 gap-y-10 sm:gap-6 lg:grid-cols-3">
{artworks.map((artwork, i) => (
<li key={artwork.id}>
<Reveal delay={Math.min(i, 5) * 60}>
<ArtworkCard
artwork={artwork}
sizes="(min-width: 1024px) 30vw, (min-width: 640px) 45vw, 50vw"
priority={i < 2}
/>
</Reveal>
</li>
))}
</ul>
Expand Down
42 changes: 32 additions & 10 deletions apps/storefront/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,25 +32,47 @@ const FEATURES = [
{ icon: Truck, title: 'Shipped nationwide', sub: 'Across Nigeria' },
];

/** Preferred hero order — lead with covered streetwear, then distinct plates (no repeats). */
const HERO_SLUG_ORDER = [
'okada-run',
'africa-united-heritage-trio',
'artisan-circle',
'lantern-keeper',
'rainy-season',
] as const;

/** Too revealing for the opening stage — keep it in the catalogue, just not in the hero loop. */
const HERO_EXCLUDED_SLUGS = new Set(['midnight-in-lagos']);

export default async function HomePage() {
const [{ items: artworks }, collectionSummaries] = await Promise.all([
dataProvider.listArtworks({ limit: 8 }),
dataProvider.listArtworks({ limit: 40 }),
dataProvider.listCollectionSummaries(),
]);

// The night piece opens the page: a dark, atmospheric drawing carries the dark hero.
const hero = artworks.find((a) => a.slug === 'midnight-in-lagos') ?? artworks[0];
const bySlug = new Map(artworks.map((a) => [a.slug, a]));
const seenSrc = new Set<string>();
const heroSlides: { slug: string; src: string; title: string }[] = [];

// Prefer the curated order first, then fill from the catalogue — one plate per slide, no dupes.
for (const slug of [
...HERO_SLUG_ORDER,
...artworks.map((a) => a.slug).filter((s) => !(HERO_SLUG_ORDER as readonly string[]).includes(s)),
]) {
if (heroSlides.length >= PILLARS.length) break;
if (HERO_EXCLUDED_SLUGS.has(slug)) continue;
const artwork = bySlug.get(slug);
const src = artwork ? artworkImage(artwork.slug) : null;
if (!artwork || !src || seenSrc.has(src)) continue;
seenSrc.add(src);
heroSlides.push({ slug: artwork.slug, src, title: artwork.title });
}

const hero = bySlug.get(heroSlides[0]?.slug ?? '') ?? artworks[0];
const drops = artworks.filter((a) => a.slug !== hero?.slug).slice(0, 3);
const feature = artworks.filter((a) => a.slug !== hero?.slug).slice(3, 4)[0] ?? drops[0];
const featureSrc = feature ? artworkImage(feature.slug) : null;

// The hero cycles through up to five drawings, in step with the 01–05 pillar row. Lead with the
// night piece, then fill from the rest of the catalogue — only pieces whose plate we actually hold.
const heroSlides = [hero, ...artworks.filter((a) => a.slug !== hero?.slug)]
.filter((a): a is NonNullable<typeof a> => a != null && artworkImage(a.slug) !== null)
.slice(0, PILLARS.length)
.map((a) => ({ slug: a.slug, src: artworkImage(a.slug) as string, title: a.title }));

// Shop-by-collection tiles need a cover; pull each collection's pieces and take a drawing we hold.
const collectionDetails = await Promise.all(
collectionSummaries.slice(0, 4).map((s) => dataProvider.getCollection(s.slug)),
Expand Down
52 changes: 42 additions & 10 deletions apps/storefront/app/shop/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Container, EmptyState } from '@tms/ui';
import type { Metadata } from 'next';
import { ProductCard } from '@/components/product/product-card';
import { ShopDesignCard } from '@/components/product/shop-design-card';
import { PageHeader } from '@/components/site/page-header';
import { Reveal } from '@/components/site/reveal';
import { dataProvider } from '@/lib/data';
import { suppliedShopDesigns } from '@/lib/data/supplied-catalogue';

export const metadata: Metadata = {
title: 'Shop',
Expand All @@ -18,24 +20,54 @@ export default async function ShopPage() {
<PageHeader
eyebrow="Shop"
title="The shop"
lead="Original artwork on considered garments — hand-drawn, printed to order."
lead="Clothing designs and available garments — artwork made wearable."
contained={false}
/>

<section className="mt-10" aria-labelledby="clothing-designs-title">
<h2
id="clothing-designs-title"
className="font-display text-2xl font-bold uppercase tracking-tight text-ink sm:text-3xl"
>
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.
</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>
</section>

{products.length === 0 ? (
<div className="mt-10">
<EmptyState title="Nothing in the shop yet" description="New drops are on their way." />
</div>
) : (
<ul className="mt-10 grid grid-cols-2 gap-x-5 gap-y-10 sm:gap-6 lg:grid-cols-3">
{products.map((product, i) => (
<li key={product.id}>
<Reveal delay={Math.min(i, 5) * 60}>
<ProductCard product={product} />
</Reveal>
</li>
))}
</ul>
<section className="mt-16 border-t border-line pt-12" aria-labelledby="available-title">
<h2
id="available-title"
className="font-display text-2xl font-bold uppercase tracking-tight text-ink sm:text-3xl"
>
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) => (
<li key={product.id}>
<Reveal delay={Math.min(i, 5) * 60}>
<ProductCard product={product} />
</Reveal>
</li>
))}
</ul>
</section>
)}
</Container>
);
Expand Down
35 changes: 35 additions & 0 deletions apps/storefront/components/product/shop-design-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Link from 'next/link';
import { TileBadge, TileImage } from '@/components/site/tile';
import type { SuppliedShopDesign } from '@/lib/data/supplied-catalogue';

/** A supplied clothing photograph linked back to its immutable artwork canvas. */
export function ShopDesignCard({
design,
priority = false,
}: {
design: SuppliedShopDesign;
priority?: boolean;
}) {
return (
<Link
href={`/design-studio?artwork=${design.artworkSlug}`}
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`}
priority={priority}
badge={<TileBadge>Clothing design</TileBadge>}
/>
<div className="mt-4">
<h3 className="font-display text-sm font-bold uppercase tracking-wide text-ink">
{design.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>
</div>
</Link>
);
}
8 changes: 4 additions & 4 deletions apps/storefront/components/site/hero-slideshow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ const ADVANCE_MS = 4200;
* pillars remain clickable.
*/
export function HeroSlideshow({ slides, pillars }: { slides: HeroSlide[]; pillars: string[] }) {
const steps = pillars.length;
// One pillar per unique slide — never wrap, so the same plate never appears twice in the loop.
const steps = Math.min(pillars.length, Math.max(slides.length, 1));
const [active, setActive] = useState(0);
// The image shown for a step; slides cycle if there are fewer of them than pillars.
const shown = slides.length ? active % slides.length : 0;
const shown = slides.length ? Math.min(active, slides.length - 1) : 0;

useEffect(() => {
if (steps <= 1 || slides.length <= 1) return;
Expand Down Expand Up @@ -107,7 +107,7 @@ export function HeroSlideshow({ slides, pillars }: { slides: HeroSlide[]; pillar
delay={320}
className="grid grid-cols-2 gap-x-6 gap-y-4 border-t border-white/15 pt-6 sm:grid-cols-3 lg:grid-cols-5"
>
{pillars.map((label, i) => {
{pillars.slice(0, steps).map((label, i) => {
const current = i === active;
return (
<li key={label}>
Expand Down
3 changes: 3 additions & 0 deletions apps/storefront/lib/artwork-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* and when it does this module is the single place that changes.
*/

import { suppliedArtworkSeeds } from './data/supplied-catalogue';

/** Slugs we hold a drawing for. Anything else has no plate to show. */
const ARTWORK_SLUGS = new Set([
'harmattan-bloom',
Expand All @@ -16,6 +18,7 @@ const ARTWORK_SLUGS = new Set([
'paper-tigers',
'rainy-season',
'the-getaway',
...suppliedArtworkSeeds.map(({ slug }) => slug),
]);

/**
Expand Down
41 changes: 41 additions & 0 deletions apps/storefront/lib/data/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +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 type {
ArtworkDetail,
ArtworkPassport,
Expand Down Expand Up @@ -190,8 +191,33 @@ const collectionMeta: { slug: string; name: string; description: string }[] = [
description:
'Street-level scenes across African cities — the muse at market and on the move, in full colour.',
},
{
slug: 'africa-united',
name: 'Africa United',
description:
'Portraits of shared heritage, family and creative life, drawn through textiles, symbols and community.',
},
{
slug: 'resilience',
name: 'Resilience',
description:
'Bold poster studies celebrating origin, heritage, power and the confidence to stand tall.',
},
{
slug: 'studio-muses',
name: 'Studio Muses',
description:
'Character and styling studies from the studio — caps, colour, markets and everyday movement.',
},
];

function suppliedArtworkTitle(slug: string): string {
return slug
.split('-')
.map((word) => (word === 'africa' ? 'Africa' : word[0]?.toUpperCase() + word.slice(1)))
.join(' ');
}

const artworks: ArtworkSummary[] = [
{
id: 'a1',
Expand Down Expand Up @@ -289,6 +315,21 @@ const artworks: ArtworkSummary[] = [
compatibleGarments: ['Oversized T-shirt'],
limitedEdition: false,
},
...suppliedArtworkSeeds.map<ArtworkSummary>((seed, index) => ({
id: `studio-supplied-${index + 1}`,
slug: seed.slug,
title: suppliedArtworkTitle(seed.slug),
collection: seed.collection,
shortStory: `A studio-supplied piece from the ${seed.collection} collection.`,
availability: null,
startingPriceMinor: null,
currency: null,
compatibleGarments:
seed.slug === 'resilience-hands-high' || seed.slug.startsWith('africa-united-')
? ['Classic T-shirt']
: [],
limitedEdition: false,
})),
];

const HOUR_MS = 3_600_000;
Expand Down
45 changes: 45 additions & 0 deletions apps/storefront/lib/data/supplied-catalogue.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { artworkImage } from '../artwork-images';
import { primaryNav } from '../nav';
import { mockProvider } from './mock';
import { suppliedArtworkSeeds, suppliedShopDesigns } from './supplied-catalogue';

describe('studio-supplied catalogue media', () => {
it('keeps every standalone artwork in Collections', async () => {
expect(suppliedArtworkSeeds).toHaveLength(34);

const { items } = await mockProvider.listArtworks({ limit: 100 });
const catalogueSlugs = new Set(items.map(({ slug }) => slug));

for (const artwork of suppliedArtworkSeeds) {
expect(catalogueSlugs.has(artwork.slug)).toBe(true);
expect(artworkImage(artwork.slug)).toBe(`/artworks/${artwork.slug}.jpg`);
expect(existsSync(join(process.cwd(), 'public', 'artworks', `${artwork.slug}.jpg`))).toBe(
true,
);
}
});

it('keeps clothing photographs in Shop and links them to artwork', () => {
expect(suppliedShopDesigns).toHaveLength(4);

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);
}
});

it('uses Collections and Shop as the two clear catalogue destinations', () => {
expect(primaryNav).toEqual(
expect.arrayContaining([
{ href: '/collections', label: 'Collections' },
{ href: '/shop', label: 'Shop' },
]),
);
expect(primaryNav.some(({ label }) => label === 'Artworks')).toBe(false);
});
});
Loading
Loading