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
166 changes: 116 additions & 50 deletions apps/storefront/components/studio/design-studio.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
'use client';

import { Alert, cn, Eyebrow, Heading, Price, Text } from '@tms/ui';
import { Check, Copy, Crop, Heart, Move, RotateCcw, ShoppingBag } from 'lucide-react';
import { Check, ChevronDown, Copy, Crop, Heart, Move, RotateCcw, ShoppingBag } from 'lucide-react';
import Image from 'next/image';
import { useRouter } from 'next/navigation';
import { type ReactNode, useCallback, useMemo, useRef, useState } from 'react';
Expand Down Expand Up @@ -31,6 +31,11 @@ import {
type StudioView,
switchView,
} from '@/lib/studio';
import {
defaultOpenStudioFolderId,
groupArtworksForStudio,
type StudioArtworkFolder,
} from '@/lib/studio-artwork-folders';

function ChipButton({
selected,
Expand Down Expand Up @@ -104,6 +109,10 @@ export function DesignStudio({
const [config, setConfig] = useState<StudioConfig>(() =>
resolveStudioConfig(initialConfig, initialOptions),
);
const artworkFolders = useMemo(() => groupArtworksForStudio(artworks), [artworks]);
const [openFolders, setOpenFolders] = useState<Set<StudioArtworkFolder['id']>>(
() => new Set([defaultOpenStudioFolderId(groupArtworksForStudio(artworks), initialConfig.artwork)]),
);
const [copied, setCopied] = useState(false);
const [status, setStatus] = useState<string | null>(null);
const [added, setAdded] = useState(false);
Expand Down Expand Up @@ -268,6 +277,8 @@ export function DesignStudio({
);
setOptions(nextOptions);
setConfig(nextConfig);
const folderId = defaultOpenStudioFolderId(artworkFolders, slug);
setOpenFolders((prev) => new Set([...prev, folderId]));
// Keep the URL shareable/refresh-safe without a Next navigation (which would reload).
window.history.replaceState(null, '', `/design-studio?artwork=${encodeURIComponent(slug)}`);
} catch {
Expand All @@ -278,7 +289,7 @@ export function DesignStudio({
if (latestRequest.current === slug) setLoadingArtwork(null);
}
},
[config.artwork, config.view, config.quantity, loadingArtwork],
[config.artwork, config.view, config.quantity, loadingArtwork, artworkFolders],
);

// Placement chips are already limited to the side being viewed, so this just prints the active
Expand Down Expand Up @@ -584,55 +595,110 @@ export function DesignStudio({
</div>

<Section step={1} title="Choose artwork">
<ul className="grid grid-cols-3 gap-3">
{artworks.map((a) => (
<li key={a.slug}>
<button
type="button"
onClick={() => selectArtwork(a.slug)}
aria-pressed={config.artwork === a.slug}
aria-busy={loadingArtwork === a.slug}
className={cn(
'group block w-full overflow-hidden rounded-md border text-left outline-none transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-focus-ring)]',
config.artwork === a.slug
? 'border-[var(--color-accent-primary)] ring-2 ring-[var(--color-accent-primary)]'
: 'border-line-2 hover:border-line',
)}
>
{/* The drawing, not a gradient. Unchosen pieces rest in graphite and the
chosen one is in colour — the same rule as the wall: colour is a reward,
and here it marks what you picked. */}
<span className="relative block aspect-[4/5] w-full overflow-hidden bg-canvas-2">
{artworkImage(a.slug) ? (
<Image
src={artworkImage(a.slug) as string}
alt=""
aria-hidden
fill
sizes="120px"
className={cn(
'object-cover transition-[filter] duration-[var(--duration-base)] motion-reduce:transition-none',
config.artwork === a.slug
? 'grayscale-0'
: 'grayscale group-hover:grayscale-0 motion-reduce:grayscale-0',
)}
/>
) : null}
{loadingArtwork === a.slug ? (
<span className="absolute inset-0 grid place-items-center bg-canvas/60">
<span
role="status"
aria-label="Loading artwork"
className="size-5 animate-spin rounded-full border-2 border-ink border-t-transparent motion-reduce:animate-none"
/>
<p className="mb-3 text-xs text-muted">
Every Collections piece, split into two folders.
</p>
<div className="space-y-2">
{artworkFolders.map(({ folder, artworks: folderArtworks }) => {
const open = openFolders.has(folder.id);
const regionId = `studio-folder-${folder.id}`;
return (
<div key={folder.id} className="overflow-hidden rounded-md border border-line">
<button
type="button"
onClick={() =>
setOpenFolders((prev) => {
const next = new Set(prev);
if (next.has(folder.id)) next.delete(folder.id);
else next.add(folder.id);
return next;
})
}
aria-expanded={open}
aria-controls={regionId}
className="flex w-full items-center justify-between gap-3 bg-canvas-2 px-3 py-2.5 text-left outline-none transition-colors hover:bg-canvas focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-focus-ring)]"
>
<span className="min-w-0">
<span className="block text-xs font-semibold uppercase tracking-[0.08em] text-ink">
{folder.label}
<span className="ml-2 font-normal normal-case tracking-normal text-muted">
{folderArtworks.length}
</span>
</span>
) : null}
</span>
<span className="block truncate px-2 py-1.5 text-xs text-ink">{a.title}</span>
</button>
</li>
))}
</ul>
<span className="mt-0.5 block truncate text-[0.7rem] text-muted">
{folder.hint}
</span>
</span>
<ChevronDown
className={cn(
'size-4 shrink-0 text-muted transition-transform duration-[var(--duration-base)] motion-reduce:transition-none',
open && 'rotate-180',
)}
aria-hidden
/>
</button>
<div
id={regionId}
className={cn(
'grid transition-[grid-template-rows] duration-[var(--duration-base)] motion-reduce:transition-none',
open ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]',
)}
>
<div className="overflow-hidden">
<ul className="grid grid-cols-3 gap-3 p-3">
{folderArtworks.map((a) => (
<li key={a.slug}>
<button
type="button"
onClick={() => selectArtwork(a.slug)}
aria-pressed={config.artwork === a.slug}
aria-busy={loadingArtwork === a.slug}
className={cn(
'group block w-full overflow-hidden rounded-md border text-left outline-none transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[var(--color-focus-ring)]',
config.artwork === a.slug
? 'border-[var(--color-accent-primary)] ring-2 ring-[var(--color-accent-primary)]'
: 'border-line-2 hover:border-line',
)}
>
<span className="relative block aspect-[4/5] w-full overflow-hidden bg-canvas-2">
{artworkImage(a.slug) ? (
<Image
src={artworkImage(a.slug) as string}
alt=""
aria-hidden
fill
sizes="120px"
className={cn(
'object-cover transition-[filter] duration-[var(--duration-base)] motion-reduce:transition-none',
config.artwork === a.slug
? 'grayscale-0'
: 'grayscale group-hover:grayscale-0 motion-reduce:grayscale-0',
)}
/>
) : null}
{loadingArtwork === a.slug ? (
<span className="absolute inset-0 grid place-items-center bg-canvas/60">
<span
role="status"
aria-label="Loading artwork"
className="size-5 animate-spin rounded-full border-2 border-ink border-t-transparent motion-reduce:animate-none"
/>
</span>
) : null}
</span>
<span className="block truncate px-2 py-1.5 text-xs text-ink">
{a.title}
</span>
</button>
</li>
))}
</ul>
</div>
</div>
</div>
);
})}
</div>
</Section>

<Section step={2} title="Garment" disabled={!artwork}>
Expand Down
12 changes: 5 additions & 7 deletions apps/storefront/lib/data/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,13 +348,11 @@ const artworks: ArtworkSummary[] = [
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']
: [],
availability: 'available',
startingPriceMinor: 1200000,
currency: 'NGN',
// Every Collections piece is Design Studio–ready on classic and oversized tees.
compatibleGarments: ['Classic T-shirt', 'Oversized T-shirt'],
limitedEdition: false,
})),
];
Expand Down
69 changes: 69 additions & 0 deletions apps/storefront/lib/studio-artwork-folders.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'vitest';
import type { ArtworkSummary } from './data/types';
import {
defaultOpenStudioFolderId,
groupArtworksForStudio,
STUDIO_ARTWORK_FOLDERS,
} from './studio-artwork-folders';

function art(partial: Pick<ArtworkSummary, 'slug' | 'title' | 'collection'>): ArtworkSummary {
return {
id: partial.slug,
slug: partial.slug,
title: partial.title,
collection: partial.collection,
shortStory: '',
availability: 'available',
startingPriceMinor: 100,
currency: 'NGN',
compatibleGarments: ['Classic T-shirt'],
limitedEdition: false,
};
}

describe('groupArtworksForStudio', () => {
it('puts every collection piece into one of the two folders', () => {
const items = [
art({ slug: 'midnight-in-lagos', title: 'Midnight in Lagos', collection: 'Night Studies' }),
art({ slug: 'paper-tigers', title: 'Paper Tigers', collection: 'Comic Line' }),
art({
slug: 'africa-united-heritage-duo',
title: 'Africa United Heritage Duo',
collection: 'Africa United',
}),
art({ slug: 'resilience-hands-high', title: 'Resilience Hands High', collection: 'Resilience' }),
art({ slug: 'studio-cap-muse', title: 'Studio Cap Muse', collection: 'Studio Muses' }),
];

const groups = groupArtworksForStudio(items);
expect(groups).toHaveLength(2);
expect(groups.map((g) => g.folder.id)).toEqual(['signature', 'studio-collections']);
expect(groups[0]!.artworks.map((a) => a.slug)).toEqual([
'midnight-in-lagos',
'paper-tigers',
]);
expect(groups[1]!.artworks.map((a) => a.slug)).toEqual([
'africa-united-heritage-duo',
'resilience-hands-high',
'studio-cap-muse',
]);
expect(groups.flatMap((g) => g.artworks)).toHaveLength(items.length);
});

it('drops empty folders', () => {
const groups = groupArtworksForStudio([
art({ slug: 'market-day', title: 'Market Day', collection: 'City Portraits' }),
]);
expect(groups).toHaveLength(1);
expect(groups[0]!.folder.id).toBe('signature');
});

it('opens the folder that contains the selected artwork', () => {
const groups = groupArtworksForStudio([
art({ slug: 'paper-tigers', title: 'Paper Tigers', collection: 'Comic Line' }),
art({ slug: 'city-sisters', title: 'City Sisters', collection: 'Africa United' }),
]);
expect(defaultOpenStudioFolderId(groups, 'city-sisters')).toBe('studio-collections');
expect(defaultOpenStudioFolderId(groups, null)).toBe(STUDIO_ARTWORK_FOLDERS[0]!.id);
});
});
64 changes: 64 additions & 0 deletions apps/storefront/lib/studio-artwork-folders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import type { ArtworkSummary } from './data/types';

/**
* Design Studio groups the full Collections catalogue into two folders so the
* artwork picker stays scannable as the wall grows.
*/
export interface StudioArtworkFolder {
id: 'signature' | 'studio-collections';
label: string;
hint: string;
/** Matches `ArtworkSummary.collection`. */
collections: readonly string[];
}

export const STUDIO_ARTWORK_FOLDERS: readonly StudioArtworkFolder[] = [
{
id: 'signature',
label: 'Signature gallery',
hint: 'Night Studies, Comic Line, Season Sketches, City Portraits',
collections: ['Night Studies', 'Comic Line', 'Season Sketches', 'City Portraits'],
},
{
id: 'studio-collections',
label: 'Studio collections',
hint: 'Africa United, Resilience, Studio Muses',
collections: ['Africa United', 'Resilience', 'Studio Muses'],
},
] as const;

export interface StudioArtworkFolderGroup {
folder: StudioArtworkFolder;
artworks: ArtworkSummary[];
}

/** Split the catalogue into the two studio folders; leftovers join Signature. */
export function groupArtworksForStudio(artworks: ArtworkSummary[]): StudioArtworkFolderGroup[] {
const buckets = new Map<StudioArtworkFolder['id'], ArtworkSummary[]>(
STUDIO_ARTWORK_FOLDERS.map((folder) => [folder.id, []]),
);

for (const artwork of artworks) {
const match =
STUDIO_ARTWORK_FOLDERS.find((folder) => folder.collections.includes(artwork.collection)) ??
STUDIO_ARTWORK_FOLDERS[0]!;
buckets.get(match.id)!.push(artwork);
}

return STUDIO_ARTWORK_FOLDERS.map((folder) => ({
folder,
artworks: buckets.get(folder.id) ?? [],
})).filter((group) => group.artworks.length > 0);
}

/** Which folder should open first given a selected (or deep-linked) artwork. */
export function defaultOpenStudioFolderId(
groups: StudioArtworkFolderGroup[],
selectedSlug: string | null,
): StudioArtworkFolder['id'] {
if (selectedSlug) {
const hit = groups.find((group) => group.artworks.some((a) => a.slug === selectedSlug));
if (hit) return hit.folder.id;
}
return groups[0]?.folder.id ?? 'signature';
}
Loading