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