From 13561c56fad94db528032b80cbb468d0b1d37858 Mon Sep 17 00:00:00 2001 From: Ghost69 Date: Sat, 18 Jul 2026 21:23:10 +0100 Subject: [PATCH] feat(storefront): design both sides of the garment (same artwork) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Design Studio can now print BOTH the front and back of one piece (the same artwork, placed independently per side), and the cart shows each case. Studio: - StudioConfig models the active side (placement/scale/transform) plus a stashed otherSide; `switchView` turns the garment over, keeping each side's print. `collectSides` yields the printed sides. - Placement chips are filtered to the side being viewed; the view toggle shows a dot per printed side; a "leave this side blank" clears the active side when the other stays printed; the summary lists every printed side. - add-to-bag carries per-side geometry in `designSides`. - Share URL round-trips both sides (o-prefixed params); an old single-side link viewing the back migrates its print to the other side. Cart: - Each line already drew front + back; now each side renders from its own designSides entry. Front-only → front·art/back·blank; back-only → front·blank/back·art; both → front·art/back·art with a "Both sides" tag. - Line identity + saved-design signature fold in every printed side, so distinct one/two-sided compositions never merge. Verified in-browser end-to-end: designed front, turned to back, printed it, added to bag — the line stored both sides and the cart drew art on both. Gate green: format, lint, typecheck, 290 storefront tests, build. Co-Authored-By: Claude Opus 4.8 --- .../components/cart/cart-line-list.tsx | 41 ++- .../components/site/hero-slideshow.tsx | 4 +- .../components/studio/design-studio.tsx | 186 ++++++++++---- apps/storefront/lib/account.ts | 20 +- apps/storefront/lib/cart-view.ts | 5 +- apps/storefront/lib/cart.spec.ts | 14 ++ apps/storefront/lib/cart.ts | 39 ++- apps/storefront/lib/studio.spec.ts | 139 +++++++++- apps/storefront/lib/studio.ts | 238 +++++++++++++----- 9 files changed, 554 insertions(+), 132 deletions(-) diff --git a/apps/storefront/components/cart/cart-line-list.tsx b/apps/storefront/components/cart/cart-line-list.tsx index 5a72c2c..babdbc7 100644 --- a/apps/storefront/components/cart/cart-line-list.tsx +++ b/apps/storefront/components/cart/cart-line-list.tsx @@ -8,6 +8,7 @@ import { artworkImage } from '@/lib/artwork-images'; import { MAX_LINE_QUANTITY } from '@/lib/cart'; import { cartIssueMessage, isRecoverableByQuantity } from '@/lib/cart-api'; import type { CartLineView } from '@/lib/cart-view'; +import { isIdentityTransform } from '@/lib/studio'; import { useCart } from './cart-provider'; /** Human summary of a line's configuration (colour · size · placement · scale). */ @@ -15,9 +16,33 @@ function lineDetail(line: CartLineView): string { return [line.colour, `Size ${line.size}`, line.placement, line.scale].filter(Boolean).join(' · '); } -/** Does this line carry a print on the given side? */ -function sideHasDesign(line: CartLineView, side: 'front' | 'back'): boolean { - return Boolean(line.artworkSlug) && (line.printView ?? 'front') === side; +/** True when both sides of the line carry a print. */ +function isTwoSided(line: CartLineView): boolean { + return Boolean(sideRender(line, 'front')) && Boolean(sideRender(line, 'back')); +} + +/** True when any printed side was freely repositioned/resized/rotated/cropped. */ +function hasCustomPlacement(line: CartLineView): boolean { + return (['front', 'back'] as const).some((side) => { + const t = sideRender(line, side)?.transform; + return t ? !isIdentityTransform(t) : false; + }); +} + +/** + * The print on one side of a line, or null when that side is blank. Prefers the per-side design + * (two-sided pieces) and falls back to the single-side fields (one-side studio/product lines). + */ +function sideRender( + line: CartLineView, + side: 'front' | 'back', +): { printScale: number; transform?: CartLineView['transform'] } | null { + if (!line.artworkSlug) return null; + const perSide = line.designSides?.[side]; + if (perSide) return { printScale: perSide.printScale, transform: perSide.transform }; + if ((line.printView ?? 'front') === side) + return { printScale: line.printScale ?? 0.8, transform: line.transform }; + return null; } /** @@ -35,9 +60,10 @@ function GarmentSide({ side: 'front' | 'back'; size: number; }) { - const designed = sideHasDesign(line, side); + const render = sideRender(line, side); + const designed = Boolean(render); const print = designed && line.artworkSlug ? artworkImage(line.artworkSlug) : null; - const t = designed ? line.transform : undefined; + const t = render?.transform; return (
{line.garment}

{lineDetail(line)} - {line.transform ? ' · Custom placement' : ''} + {isTwoSided(line) ? ' · Both sides' : ''} + {hasCustomPlacement(line) ? ' · Custom placement' : ''}

{line.note ? (

“{line.note}”

diff --git a/apps/storefront/components/site/hero-slideshow.tsx b/apps/storefront/components/site/hero-slideshow.tsx index f0c6e7e..bb35cf9 100644 --- a/apps/storefront/components/site/hero-slideshow.tsx +++ b/apps/storefront/components/site/hero-slideshow.tsx @@ -84,8 +84,8 @@ export function HeroSlideshow({ slides, pillars }: { slides: HeroSlide[]; pillar delay={160} className="mt-6 max-w-md text-sm leading-relaxed text-white/75 sm:text-base" > - Hand-drawn art from across Africa — printed on cotton, made to order. Own a piece of - the continent, positioned the way the studio approved it. + Hand-drawn art from across Africa — printed on cotton, made to order. Own a piece of the + continent, positioned the way the studio approved it. diff --git a/apps/storefront/components/studio/design-studio.tsx b/apps/storefront/components/studio/design-studio.tsx index b9d0cc2..83fcb71 100644 --- a/apps/storefront/components/studio/design-studio.tsx +++ b/apps/storefront/components/studio/design-studio.tsx @@ -14,8 +14,10 @@ import { artworkImage } from '@/lib/artwork-images'; import { dataProvider } from '@/lib/data'; import type { ArtworkSummary, StudioOptions } from '@/lib/data'; import { GARMENT_VIEWBOX, GARMENTS, garmentStyleFromName } from '@/lib/garments/registry'; +import type { CartSideRender } from '@/lib/cart'; import { buildStudioQuery, + collectSides, EMPTY_STUDIO_CONFIG, findGarment, findPlacement, @@ -27,6 +29,7 @@ import { resolveStudioConfig, type StudioConfig, type StudioView, + switchView, } from '@/lib/studio'; function ChipButton({ @@ -152,6 +155,29 @@ export function DesignStudio({ const resetPlacement = useCallback(() => updateTransform(IDENTITY_TRANSFORM), [updateTransform]); + // Turn the garment over: the side you were editing is stashed and the other side loads, so each + // side keeps its own print. This is how a piece gets a design on BOTH sides. + const switchToView = useCallback((next: StudioView) => { + setCopied(false); + setStatus(null); + setAdded(false); + setSaved(false); + setConfig((c) => switchView(c, next)); + }, []); + + // Clear the print from the side currently being viewed. + const clearSide = useCallback(() => { + setCopied(false); + setAdded(false); + setSaved(false); + setConfig((c) => + resolveStudioConfig( + { ...c, placement: null, scale: null, transform: IDENTITY_TRANSFORM }, + options, + ), + ); + }, [options]); + // Clearing the artwork cascades: with no artwork there is no garment, colour, size, placement or // scale to keep, so resetting these three is enough for resolveStudioConfig to empty the rest. const reset = useCallback(() => update({ artwork: null, garment: null, size: null }), [update]); @@ -204,6 +230,12 @@ export function DesignStudio({ // The interactive layer is live only when a print is actually shown on the side being viewed. const canEdit = Boolean(artworkPrint && placement && scale && artworkOnThisView); const customised = !isIdentityTransform(transform); + // Which sides currently carry a print — drives the front/back indicators and the "this side is + // blank" prompt. The active side is printed when it has a placement + scale. + const printedAreas = useMemo(() => new Set(collectSides(config).map((s) => s.area)), [config]); + const activePrinted = printedAreas.has(config.view); + // The other side is printed too → clearing the active side still leaves a valid piece. + const otherPrinted = Boolean(config.otherSide); // Every section gates on the artwork (`disabled={!artwork}`), and everything else is resolved // from it, so the artwork is what "something to reset" means — and what keeps the button's // enabled state in step with the visible selection. @@ -249,11 +281,9 @@ export function DesignStudio({ [config.artwork, config.view, config.quantity, loadingArtwork], ); - const selectPlacement = (id: string) => { - const next = findPlacement(garment, id); - // Turn the garment to the side the print is actually on. - update({ placement: id, ...(next ? { view: next.area } : {}) }); - }; + // Placement chips are already limited to the side being viewed, so this just prints the active + // side. (Switching sides is the garment-view toggle's job, via `switchToView`.) + const selectPlacement = (id: string) => update({ placement: id }); const copyShareLink = useCallback(async () => { const url = `${window.location.origin}/design-studio${buildStudioQuery(config)}`; @@ -278,10 +308,44 @@ export function DesignStudio({ return; } const variantId = findVariantId(config, garment); - if (!variantId || !garment || !placement || !scale) { + if (!variantId || !garment) { setStatus('That combination is not available — choose a different colour or size.'); return; } + // Build per-side render data for every printed side (one piece can print front AND back). + const designSides: Partial> = {}; + const sideLabels: string[] = []; + let primary: { + plLabel: string; + scLabel: string; + plId: string; + scSlug: string; + area: StudioView; + } | null = null; + for (const side of collectSides(config)) { + const pl = garment.placements.find((p) => p.id === side.placement); + const sc = pl?.scalePresets.find((s) => s.slug === side.scale); + if (!pl || !sc) continue; + const zone = GARMENTS[garmentStyle].print[side.area]; + designSides[side.area] = { + printScale: (sc.widthPct / 100) * (GARMENT_VIEWBOX.w / zone.maxW), + ...(isIdentityTransform(side.transform) ? {} : { transform: side.transform }), + placementId: pl.id, + scalePresetId: sc.slug, + }; + sideLabels.push(`${side.area} (${sc.label.toLowerCase()})`); + primary ??= { + plLabel: pl.label, + scLabel: sc.label, + plId: pl.id, + scSlug: sc.slug, + area: side.area, + }; + } + if (!primary) { + setStatus('Add a print to at least one side to continue.'); + return; + } addItem({ productSlug: `${artwork.slug}-studio`, href: `/design-studio${buildStudioQuery(config)}`, @@ -292,32 +356,27 @@ export function DesignStudio({ priceMinor: artwork.startingPriceMinor, currency: artwork.currency, quantity: Math.max(1, config.quantity), - placement: placement.label, - scale: scale.label, - view: config.view, - // The approved tuple. Carrying it means the line's identity is the contract's - // canonical form rather than a slug string we made up, and it is what a - // server-backed add will post (never a price). + placement: primary.plLabel, + scale: primary.scLabel, + view: primary.area, + // The approved tuple for the primary side — the canonical-form base for the line's identity + // and what a server-backed add will post (the per-side geometry rides in `designSides`). configuration: { artworkVersionId: garment.artworkVersionId, garmentVariantId: variantId, - placementId: placement.id, - scalePresetId: scale.slug, - view: config.view === 'back' ? 'BACK' : 'FRONT', + placementId: primary.plId, + scalePresetId: primary.scSlug, + view: primary.area === 'back' ? 'BACK' : 'FRONT', }, - // The free adjustment, when the customer moved/resized/rotated/cropped. Undefined for an - // untouched approved placement, so those lines keep their plain canonical identity. - ...(customised ? { transform } : null), - // Enough for the cart to redraw the exact piece as a thumbnail. + // The exact per-side composition, so the cart redraws front and back precisely. artworkSlug: artwork.slug, - printView: placement.area, - printScale: mockupScale, + designSides, ...(note.trim() ? { note: note.trim() } : null), }); setAdded(true); setStatus( - `Added to your bag: ${artwork.title} on ${garment.title}, ${config.colour}, size ${config.size}, ` + - `${placement.label.toLowerCase()} · ${scale.label.toLowerCase()}.`, + `Added to your bag: ${artwork.title} on ${garment.title}, ${config.colour}, size ${config.size} — ` + + `printed ${sideLabels.join(' + ')}.`, ); }; @@ -422,20 +481,35 @@ export function DesignStudio({
{(['front', 'back'] as StudioView[]).map((v) => ( ))}
@@ -625,25 +699,41 @@ export function DesignStudio({
-
-
- {/* Approved for this artwork on this garment — never a free-form position. */} - {(garment?.placements ?? []).map((p) => ( - selectPlacement(p.id)} +
+
+ {/* Starting points approved for THIS side; drag/resize/crop fine-tunes from here. */} + {(garment?.placements ?? []) + .filter((p) => p.area === config.view) + .map((p) => ( + selectPlacement(p.id)} + > + {p.label} + + ))} + {/* Clearing is only offered when the other side keeps the piece printed. */} + {activePrinted && otherPrinted ? ( + + ) : null}
{placement ? ( - Printed at {placement.printWidthMm} × {placement.printHeightMm} mm, positioned to - studio standards. + Printed at {placement.printWidthMm} × {placement.printHeightMm} mm on the{' '} + {config.view}. Turn the garment over to print the other side too. - ) : null} + ) : ( + + The {config.view} is blank — pick a placement above to print it, or leave it as is. + + )}
@@ -691,13 +781,21 @@ export function DesignStudio({ ['Garment', garment?.title ?? null], ['Colour', config.colour], ['Size', config.size], - ['Placement', placement?.label], - ['Scale', scale?.label], + [ + 'Prints', + collectSides(config) + .map((s) => { + const pl = garment?.placements.find((p) => p.id === s.placement); + const sc = pl?.scalePresets.find((x) => x.slug === s.scale); + return `${s.area} · ${sc?.label ?? ''}`.trim(); + }) + .join(' · ') || null, + ], ] as const ).map(([label, value]) => (
{label}
-
{value ?? '—'}
+
{value ?? '—'}
))} @@ -705,7 +803,7 @@ export function DesignStudio({ {artwork && customised ? ( - Custom placement — you’ve adjusted the artwork from the{' '} + Custom placement — you’ve adjusted the {config.view} print from its{' '} {scale?.label?.toLowerCase()} {placement?.label?.toLowerCase()} start. ) : null} diff --git a/apps/storefront/lib/account.ts b/apps/storefront/lib/account.ts index 27829aa..de83360 100644 --- a/apps/storefront/lib/account.ts +++ b/apps/storefront/lib/account.ts @@ -15,7 +15,7 @@ import type { PlacedOrder } from './order'; import { normalizeEmail } from './auth'; -import { IDENTITY_TRANSFORM, type StudioConfig, transformKey } from './studio'; +import { collectSides, type StudioConfig, transformKey } from './studio'; // --- Saved designs ------------------------------------------------------------- @@ -33,18 +33,12 @@ export interface SavedDesign { /** Configuration signature — identical designs collapse to one saved entry. */ export function designSignature(config: StudioConfig): string { - return [ - config.artwork, - config.garment, - config.colour, - config.size, - config.placement, - config.scale, - config.view, - // The free transform is part of what the customer made: two designs that share the approved - // tuple but sit/scale/crop differently are different saved designs, not one. - transformKey(config.transform ?? IDENTITY_TRANSFORM), - ] + // Every printed side is part of what the customer made: two pieces with different sides, or the + // same sides placed/scaled/cropped differently, are different saved designs — not one. + const sidesKey = collectSides(config) + .map((s) => `${s.area}:${s.placement}:${s.scale}:${transformKey(s.transform)}`) + .join('~'); + return [config.artwork, config.garment, config.colour, config.size, sidesKey] .map((part) => (part ?? '').toString().trim().toLowerCase()) .join('|'); } diff --git a/apps/storefront/lib/cart-view.ts b/apps/storefront/lib/cart-view.ts index d09b5e8..adfaad4 100644 --- a/apps/storefront/lib/cart-view.ts +++ b/apps/storefront/lib/cart-view.ts @@ -16,7 +16,7 @@ */ import type { Artwork, Cart, CartLine, GarmentTemplate } from '@tms/contracts'; -import type { CartItem, Promotion } from './cart'; +import type { CartItem, CartSideRender, Promotion } from './cart'; import { discountMinor, estimatedTotalMinor, subtotalMinor } from './cart'; import type { PrintTransform } from './studio'; @@ -40,6 +40,8 @@ export interface CartLineView { printView?: 'front' | 'back'; printScale?: number; transform?: PrintTransform; + /** Per-side print data when the piece is printed on both sides (same artwork, placed per side). */ + designSides?: Partial>; note?: string; /** Server-resolved. Null when the server could not price the line. */ unitPriceMinor: number | null; @@ -180,6 +182,7 @@ export function toLocalCartView(items: CartItem[], promotion: Promotion | null): printView: item.printView, printScale: item.printScale, transform: item.transform, + designSides: item.designSides, note: item.note, unitPriceMinor: item.priceMinor, lineTotalMinor: item.priceMinor * item.quantity, diff --git a/apps/storefront/lib/cart.spec.ts b/apps/storefront/lib/cart.spec.ts index 3f46c04..5f8a7a8 100644 --- a/apps/storefront/lib/cart.spec.ts +++ b/apps/storefront/lib/cart.spec.ts @@ -40,6 +40,20 @@ describe('lineId', () => { ); }); + it('forks the line when the printed sides differ (front-only vs both sides)', () => { + const frontOnly = { ...base, designSides: { front: { printScale: 0.4, placementId: 'p-fc' } } }; + const bothSides = { + ...base, + designSides: { + front: { printScale: 0.4, placementId: 'p-fc' }, + back: { printScale: 0.6, placementId: 'p-bk' }, + }, + }; + expect(lineId(frontOnly)).not.toBe(lineId(bothSides)); + // The same two-sided composition merges. + expect(lineId(bothSides)).toBe(lineId({ ...bothSides })); + }); + it('forks the line for a different note, and merges an identical one', () => { expect(lineId({ ...base, note: 'For mum' })).not.toBe(lineId(base)); expect(lineId({ ...base, note: 'For mum' })).toBe(lineId({ ...base, note: ' for mum ' })); diff --git a/apps/storefront/lib/cart.ts b/apps/storefront/lib/cart.ts index fc9b488..3906cd8 100644 --- a/apps/storefront/lib/cart.ts +++ b/apps/storefront/lib/cart.ts @@ -63,10 +63,26 @@ export interface CartItem { artworkSlug?: string; printView?: 'front' | 'back'; printScale?: number; + /** + * A piece can be printed on BOTH sides (same artwork, placed independently). When it is, each + * printed side is here with its own base scale + free transform, so the cart draws front and back + * exactly as designed. Absent for plain one-side product lines (which use the fields above). + */ + designSides?: Partial>; /** A customer note for this line (personalisation / gift message). Part of the line identity. */ note?: string; } +/** One printed side of a cart line, enough to both redraw it and identify it. */ +export interface CartSideRender { + /** Base print width as a fraction of the side's print zone. */ + printScale: number; + transform?: PrintTransform; + /** Approved ids, for line identity + a future server-backed add. */ + placementId?: string; + scalePresetId?: string; +} + /** A configuration a caller wants to add — everything but the derived id. */ export type CartItemInput = Omit & { quantity?: number }; @@ -85,6 +101,21 @@ export const MAX_LINE_QUANTITY = 20; * The slug fallback serves the local preview cart, where a line was built from catalogue slugs * and no approved ids exist yet. */ +/** A deterministic identity for the printed sides (empty when there are none). */ +function sidesKey( + designSides: Partial> | undefined, +): string { + if (!designSides) return ''; + return (['front', 'back'] as const) + .filter((side) => designSides[side]) + .map((side) => { + const r = designSides[side]!; + const tk = r.transform ? transformKey(r.transform) : ''; + return `${side}:${r.placementId ?? ''}:${r.scalePresetId ?? ''}:${tk}`; + }) + .join('|'); +} + export function lineId(input: { productSlug: string; colour: string; @@ -93,12 +124,14 @@ export function lineId(input: { scale?: string; configuration?: ApprovedConfiguration; transform?: PrintTransform; + designSides?: Partial>; note?: string; }): string { - // A free transform or a note forks the line: same approved tuple, but a different composition or - // a different personal note is a different piece. Suffix is empty for a plain approved add so it + // A different composition (per-side geometry, or a single free transform) or a different note + // forks the line: same approved tuple, but a different piece. Empty for a plain approved add so it // keeps its canonical id. - const geomKey = input.transform ? transformKey(input.transform) : ''; + const geomKey = + sidesKey(input.designSides) || (input.transform ? transformKey(input.transform) : ''); const noteKey = input.note?.trim() ? input.note.trim().toLowerCase() : ''; const suffix = [geomKey, noteKey].filter(Boolean).join('~~'); const extra = suffix ? `##${suffix}` : ''; diff --git a/apps/storefront/lib/studio.spec.ts b/apps/storefront/lib/studio.spec.ts index f0f0f99..4894b77 100644 --- a/apps/storefront/lib/studio.spec.ts +++ b/apps/storefront/lib/studio.spec.ts @@ -3,6 +3,7 @@ import type { StudioOptions } from './data/types'; import { buildStudioQuery, clampTransform, + collectSides, EMPTY_STUDIO_CONFIG, findVariantId, IDENTITY_TRANSFORM, @@ -12,6 +13,7 @@ import { type PrintTransform, resolveStudioConfig, type StudioConfig, + switchView, } from './studio'; const full: StudioConfig = { @@ -22,7 +24,8 @@ const full: StudioConfig = { placement: 'placement-centre-chest', scale: 'medium', transform: IDENTITY_TRANSFORM, - view: 'back', + otherSide: null, + view: 'front', quantity: 3, }; @@ -163,6 +166,26 @@ describe('buildStudioQuery / round-trip', () => { expect(qs).toContain('scale=medium'); expect(qs).toContain('px=8.5'); }); + + it('round-trips a two-sided config (a print on each side)', () => { + const twoSided: StudioConfig = { + ...full, + view: 'front', + placement: 'placement-centre-chest', + scale: 'medium', + transform: moved, + otherSide: { + area: 'back', + placement: 'placement-back', + scale: 'large', + transform: { ...IDENTITY_TRANSFORM, dx: 3 }, + }, + }; + const qs = buildStudioQuery(twoSided); + expect(qs).toContain('oplacement=placement-back'); + const params = Object.fromEntries(new URLSearchParams(qs.slice(1))); + expect(parseStudioParams(params)).toEqual(twoSided); + }); }); describe('clampTransform / isIdentityTransform', () => { @@ -219,7 +242,11 @@ describe('resolveStudioConfig', () => { }); it('re-picks the scale when the placement changes, since presets belong to a placement', () => { - const resolved = resolveStudioConfig({ ...full, placement: 'placement-back' }, options); + // The back placement lives on the back, so we view that side to design it. + const resolved = resolveStudioConfig( + { ...full, view: 'back', placement: 'placement-back' }, + options, + ); expect(resolved.scale).toBe('large'); }); @@ -244,11 +271,16 @@ describe('resolveStudioConfig', () => { expect(resolved).toMatchObject({ garment: null, colour: null, size: null, placement: null }); }); - it('leaves the view alone, so turning the garment around does not fight the customer', () => { + it('migrates an old single-sided link (front placement, viewing back) to the other side', () => { + // `?placement=&view=back` predates two-sided design: keep the front print rather than + // dropping it, by stashing it as the other side while the viewed (back) side stays blank. const resolved = resolveStudioConfig({ ...full, view: 'back' }, options); expect(resolved.view).toBe('back'); - // The print is still on the front placement; the UI says so rather than moving it. - expect(resolved.placement).toBe('placement-centre-chest'); + expect(resolved.placement).toBeNull(); + expect(resolved.otherSide).toMatchObject({ + area: 'front', + placement: 'placement-centre-chest', + }); }); it('keeps the free transform when its placement and scale both survive', () => { @@ -277,12 +309,92 @@ describe('resolveStudioConfig', () => { it('resets the transform when the scale is re-picked because the placement changed', () => { const resolved = resolveStudioConfig( - { ...full, placement: 'placement-back', transform: moved }, + { ...full, view: 'back', placement: 'placement-back', transform: moved }, options, ); expect(resolved.scale).toBe('large'); expect(resolved.transform).toEqual(IDENTITY_TRANSFORM); }); + + it('keeps an approved print on the other (non-viewed) side', () => { + const resolved = resolveStudioConfig( + { + ...full, + view: 'front', + otherSide: { area: 'back', placement: 'placement-back', scale: 'large', transform: moved }, + }, + options, + ); + expect(resolved.placement).toBe('placement-centre-chest'); + expect(resolved.otherSide).toEqual({ + area: 'back', + placement: 'placement-back', + scale: 'large', + transform: moved, + }); + }); + + it('drops an other-side print whose placement is not on that side', () => { + const resolved = resolveStudioConfig( + { + ...full, + view: 'front', + // A front placement cannot be the back side's print. + otherSide: { + area: 'back', + placement: 'placement-centre-chest', + scale: 'medium', + transform: IDENTITY_TRANSFORM, + }, + }, + options, + ); + expect(resolved.otherSide).toBeNull(); + }); +}); + +describe('switchView / collectSides', () => { + const twoSided: StudioConfig = { + ...full, + view: 'front', + placement: 'placement-centre-chest', + scale: 'medium', + transform: moved, + otherSide: { + area: 'back', + placement: 'placement-back', + scale: 'large', + transform: IDENTITY_TRANSFORM, + }, + }; + + it('swaps the active and stashed sides when the view flips', () => { + const flipped = switchView(twoSided, 'back'); + expect(flipped.view).toBe('back'); + // Now editing the back print... + expect(flipped.placement).toBe('placement-back'); + expect(flipped.scale).toBe('large'); + // ...and the front print is stashed. + expect(flipped.otherSide).toMatchObject({ area: 'front', placement: 'placement-centre-chest' }); + }); + + it('is a no-op when the view does not change', () => { + expect(switchView(twoSided, 'front')).toBe(twoSided); + }); + + it('drops the stash when the side being left has no print', () => { + const frontBlank: StudioConfig = { ...full, view: 'front', placement: null, scale: null }; + const flipped = switchView(frontBlank, 'back'); + expect(flipped.otherSide).toBeNull(); + }); + + it('collects every printed side, tagged by area', () => { + expect(collectSides(twoSided)).toEqual([ + { area: 'front', placement: 'placement-centre-chest', scale: 'medium', transform: moved }, + { area: 'back', placement: 'placement-back', scale: 'large', transform: IDENTITY_TRANSFORM }, + ]); + expect(collectSides({ ...full, placement: null, scale: null, otherSide: null })).toEqual([]); + }); }); describe('findVariantId', () => { @@ -303,4 +415,19 @@ describe('isStudioConfigComplete', () => { expect(isStudioConfigComplete({ ...full, scale: null })).toBe(false); expect(isStudioConfigComplete(full)).toBe(true); }); + + it('is complete when only the other side carries the print', () => { + const backOnly: StudioConfig = { + ...full, + placement: null, + scale: null, + otherSide: { + area: 'back', + placement: 'placement-back', + scale: 'large', + transform: IDENTITY_TRANSFORM, + }, + }; + expect(isStudioConfigComplete(backOnly)).toBe(true); + }); }); diff --git a/apps/storefront/lib/studio.ts b/apps/storefront/lib/studio.ts index dc66553..714894e 100644 --- a/apps/storefront/lib/studio.ts +++ b/apps/storefront/lib/studio.ts @@ -101,12 +101,27 @@ export function isIdentityTransform(t: PrintTransform): boolean { ); } +/** The print on one side of the garment: an approved placement + scale + the free transform. */ +export interface SideDesign { + placement: string | null; + scale: string | null; + transform: PrintTransform; +} + +/** The non-active side's design, tagged with which side it is (the opposite of `view`). */ +export interface StashedSide extends SideDesign { + area: StudioView; +} + /** * A Design Studio configuration. * - * `placement` is an approved placement id and `scale` an approved scale-preset slug — the starting - * point the customer chose. `transform` is their free adjustment on top of it (see PrintTransform). - * A shared URL carries the approved ids *and* the transform, so the exact composition round-trips. + * The customer can print BOTH sides of one garment (the same artwork, placed independently). The + * side currently being edited is `view`, and its design lives in `placement` / `scale` / + * `transform`. The *other* side's design, when it has one, is stashed in `otherSide` (whose `area` + * is the opposite of `view`). Switching sides (`switchView`) swaps the two. A shared URL carries the + * active side under `placement`/`scale`/`px…` and the other side under `oplacement`/`oscale`/`opx…`, + * so a single-sided link stays exactly as it was and a two-sided one round-trips both prints. */ export interface StudioConfig { /** Artwork slug. */ @@ -115,12 +130,14 @@ export interface StudioConfig { garment: string | null; colour: string | null; size: string | null; - /** An approved placement id — the starting position. */ + /** The active side's approved placement id (its area is `view`), or null when this side is blank. */ placement: string | null; - /** An approved scale-preset slug, valid only within the selected placement — the starting size. */ + /** The active side's approved scale-preset slug, valid only within its placement. */ scale: string | null; - /** Free drag/resize/rotate/crop layered on top of placement + scale. */ + /** The active side's free drag/resize/rotate/crop, layered on its placement + scale. */ transform: PrintTransform; + /** The other side's design, when it carries a print. `area` is always the opposite of `view`. */ + otherSide: StashedSide | null; view: StudioView; quantity: number; } @@ -133,10 +150,23 @@ export const EMPTY_STUDIO_CONFIG: StudioConfig = { placement: null, scale: null, transform: IDENTITY_TRANSFORM, + otherSide: null, view: 'front', quantity: 1, }; +/** The side opposite the one given. */ +export function otherView(view: StudioView): StudioView { + return view === 'front' ? 'back' : 'front'; +} + +/** True when a side actually carries a print (an approved placement + scale). */ +export function sideHasPrint( + side: { placement: string | null; scale: string | null } | null, +): boolean { + return Boolean(side && side.placement && side.scale); +} + type RawParams = Record; function first(value: string | string[] | undefined): string | null { @@ -164,16 +194,17 @@ function round(n: number, decimals: number): number { return Math.round(n * f) / f; } -function parseTransform(p: RawParams): PrintTransform { +function parseTransform(p: RawParams, prefix = ''): PrintTransform { + const g = (k: string) => first(p[`${prefix}${k}`]); return clampTransform({ - dx: num(first(p.px), 0), - dy: num(first(p.py), 0), - scale: num(first(p.ps), 1), - rotation: num(first(p.pr), 0), - cropTop: num(first(p.ct), 0), - cropRight: num(first(p.cr), 0), - cropBottom: num(first(p.cb), 0), - cropLeft: num(first(p.cl), 0), + dx: num(g('px'), 0), + dy: num(g('py'), 0), + scale: num(g('ps'), 1), + rotation: num(g('pr'), 0), + cropTop: num(g('ct'), 0), + cropRight: num(g('cr'), 0), + cropBottom: num(g('cb'), 0), + cropLeft: num(g('cl'), 0), }); } @@ -185,6 +216,17 @@ function parseTransform(p: RawParams): PrintTransform { * are in hand, and nothing parsed here is trusted enough to send anywhere. */ export function parseStudioParams(searchParams: RawParams): StudioConfig { + const view: StudioView = first(searchParams.view) === 'back' ? 'back' : 'front'; + // The other side (opposite `view`) is present only when it carries its own placement. + const otherPlacement = first(searchParams.oplacement); + const otherSide: StashedSide | null = otherPlacement + ? { + area: otherView(view), + placement: otherPlacement, + scale: first(searchParams.oscale), + transform: parseTransform(searchParams, 'o'), + } + : null; return { artwork: first(searchParams.artwork), garment: first(searchParams.garment), @@ -193,7 +235,8 @@ export function parseStudioParams(searchParams: RawParams): StudioConfig { placement: first(searchParams.placement), scale: first(searchParams.scale), transform: parseTransform(searchParams), - view: first(searchParams.view) === 'back' ? 'back' : 'front', + otherSide, + view, quantity: clampQuantity(first(searchParams.quantity)), }; } @@ -208,21 +251,31 @@ export function buildStudioQuery(config: StudioConfig): string { if (config.placement) params.set('placement', config.placement); if (config.scale) params.set('scale', config.scale); // Free transform: only the parts that differ from the approved start are written. - const t = config.transform; - if (t.dx !== 0) params.set('px', String(round(t.dx, 2))); - if (t.dy !== 0) params.set('py', String(round(t.dy, 2))); - if (t.scale !== 1) params.set('ps', String(round(t.scale, 3))); - if (t.rotation !== 0) params.set('pr', String(round(t.rotation, 1))); - if (t.cropTop !== 0) params.set('ct', String(round(t.cropTop, 3))); - if (t.cropRight !== 0) params.set('cr', String(round(t.cropRight, 3))); - if (t.cropBottom !== 0) params.set('cb', String(round(t.cropBottom, 3))); - if (t.cropLeft !== 0) params.set('cl', String(round(t.cropLeft, 3))); + writeTransform(params, config.transform, ''); + // The other side (opposite `view`), when it carries a print. + if (config.otherSide && config.otherSide.placement) { + params.set('oplacement', config.otherSide.placement); + if (config.otherSide.scale) params.set('oscale', config.otherSide.scale); + writeTransform(params, config.otherSide.transform, 'o'); + } if (config.view === 'back') params.set('view', 'back'); if (config.quantity > 1) params.set('quantity', String(config.quantity)); const qs = params.toString(); return qs ? `?${qs}` : ''; } +/** Write a transform's non-identity parts to the query, under an optional key prefix. */ +function writeTransform(params: URLSearchParams, t: PrintTransform, prefix: string): void { + if (t.dx !== 0) params.set(`${prefix}px`, String(round(t.dx, 2))); + if (t.dy !== 0) params.set(`${prefix}py`, String(round(t.dy, 2))); + if (t.scale !== 1) params.set(`${prefix}ps`, String(round(t.scale, 3))); + if (t.rotation !== 0) params.set(`${prefix}pr`, String(round(t.rotation, 1))); + if (t.cropTop !== 0) params.set(`${prefix}ct`, String(round(t.cropTop, 3))); + if (t.cropRight !== 0) params.set(`${prefix}cr`, String(round(t.cropRight, 3))); + if (t.cropBottom !== 0) params.set(`${prefix}cb`, String(round(t.cropBottom, 3))); + if (t.cropLeft !== 0) params.set(`${prefix}cl`, String(round(t.cropLeft, 3))); +} + export function findGarment(options: StudioOptions, slug: string | null): StudioGarment | null { if (!slug) return null; return options.garments.find((garment) => garment.slug === slug) ?? null; @@ -244,24 +297,41 @@ export function findPlacement( * garment, a scale that does not belong to the chosen placement, or a colour with no buyable * variant must not survive into something we send to the server. */ +/** + * Reconcile one side's raw (from-URL) design against the garment, for a specific area. Returns null + * when the placement is not an approved one on that side. A transform is a delta from a specific + * placement + scale, so it resets when the scale it was authored against does not survive. + */ +function resolveSide(garment: StudioGarment, raw: SideDesign, area: StudioView): SideDesign | null { + const placement = garment.placements.find((p) => p.id === raw.placement && p.area === area); + if (!placement) return null; + const scale = + placement.scalePresets.find((preset) => preset.slug === raw.scale) ?? + placement.scalePresets[0] ?? + null; + const scaleKept = scale?.slug === raw.scale; + return { + placement: placement.id, + scale: scale?.slug ?? null, + transform: scaleKept ? clampTransform(raw.transform ?? IDENTITY_TRANSFORM) : IDENTITY_TRANSFORM, + }; +} + export function resolveStudioConfig(config: StudioConfig, options: StudioOptions): StudioConfig { const garment = findGarment(options, config.garment) ?? options.garments[0] ?? null; if (!garment) { - return { ...config, garment: null, colour: null, size: null, placement: null, scale: null }; + return { + ...config, + garment: null, + colour: null, + size: null, + placement: null, + scale: null, + transform: IDENTITY_TRANSFORM, + otherSide: null, + }; } - const placement = - findPlacement(garment, config.placement) ?? - garment.placements.find((entry) => entry.area === config.view) ?? - garment.placements[0] ?? - null; - - // A scale preset belongs to a placement, so it is only valid within the resolved one. - const scale = - placement?.scalePresets.find((preset) => preset.slug === config.scale) ?? - placement?.scalePresets[0] ?? - null; - const colour = garment.colours.some((entry) => entry.name === config.colour) ? config.colour : (garment.colours[0]?.name ?? null); @@ -273,30 +343,87 @@ export function resolveStudioConfig(config: StudioConfig, options: StudioOptions const size = config.size && sizesForColour.includes(config.size) ? config.size : (sizesForColour[0] ?? null); - // A transform is a delta from a specific placement + scale. If either was dropped as unapproved - // (a shared link from another garment, say), the delta no longer means anything — reset it so a - // stray URL can never compose a print against the wrong start. - const placementKept = placement?.id === config.placement; - const scaleKept = scale?.slug === config.scale; - const transform = - placementKept && scaleKept - ? clampTransform(config.transform ?? IDENTITY_TRANSFORM) - : IDENTITY_TRANSFORM; + const otherArea = otherView(config.view); + const activeRaw: SideDesign = { + placement: config.placement, + scale: config.scale, + transform: config.transform ?? IDENTITY_TRANSFORM, + }; + let active = resolveSide(garment, activeRaw, config.view); + let other = config.otherSide ? resolveSide(garment, config.otherSide, otherArea) : null; + + // An old single-sided link may carry a placement for the *other* side under the active keys + // (e.g. `?placement=&view=back`). Migrate it to the other side rather than dropping it. + if (!active && config.placement && !other) { + other = resolveSide(garment, activeRaw, otherArea); + } + + // Land with a print on the viewed side if the piece is entirely blank. + if (!sideHasPrint(active) && !sideHasPrint(other)) { + const seed = + garment.placements.find((p) => p.area === config.view) ?? garment.placements[0] ?? null; + active = seed + ? { + placement: seed.id, + scale: seed.scalePresets[0]?.slug ?? null, + transform: IDENTITY_TRANSFORM, + } + : null; + } return { ...config, garment: garment.slug, colour, size, - placement: placement?.id ?? null, - scale: scale?.slug ?? null, - transform, - // `view` is left alone on purpose: it is which side the preview shows, not part of the - // approved tuple's identity. Forcing it to the placement's side would fight the customer - // every time they turned the garment around to look at the back. + placement: active?.placement ?? null, + scale: active?.scale ?? null, + transform: active?.transform ?? IDENTITY_TRANSFORM, + otherSide: sideHasPrint(other) ? { area: otherArea, ...(other as SideDesign) } : null, + // `view` is left alone on purpose: it is which side the preview shows. }; } +/** + * Switch the side being edited, stashing the current side and loading the other. With only two + * sides, the active fields and `otherSide` simply swap. + */ +export function switchView(config: StudioConfig, next: StudioView): StudioConfig { + if (next === config.view) return config; + const incoming = config.otherSide?.area === next ? config.otherSide : null; + const stash: StashedSide = { + area: config.view, + placement: config.placement, + scale: config.scale, + transform: config.transform, + }; + return { + ...config, + view: next, + placement: incoming?.placement ?? null, + scale: incoming?.scale ?? null, + transform: incoming?.transform ?? IDENTITY_TRANSFORM, + otherSide: sideHasPrint(stash) ? stash : null, + }; +} + +/** The printed sides of a configuration (0, 1, or 2), each tagged with its area. */ +export function collectSides(config: StudioConfig): Array { + const sides: Array = []; + if (sideHasPrint({ placement: config.placement, scale: config.scale })) { + sides.push({ + area: config.view, + placement: config.placement, + scale: config.scale, + transform: config.transform, + }); + } + if (config.otherSide && sideHasPrint(config.otherSide)) { + sides.push({ ...config.otherSide }); + } + return sides; +} + /** The approved variant for the chosen colour+size, or null when that pair is not buyable. */ export function findVariantId(config: StudioConfig, garment: StudioGarment | null): string | null { if (!garment || !config.colour || !config.size) return null; @@ -307,14 +434,13 @@ export function findVariantId(config: StudioConfig, garment: StudioGarment | nul ); } -/** True once the customer has made every choice the approved tuple needs. */ +/** True once the customer has chosen artwork + garment + colour + size and printed at least one side. */ export function isStudioConfigComplete(config: StudioConfig): boolean { return Boolean( config.artwork && config.garment && config.colour && config.size && - config.placement && - config.scale, + collectSides(config).length > 0, ); }