From 1f1dabdb31b68f82f1364de8f53d460ea3e17444 Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 29 Apr 2026 13:03:38 -0400 Subject: [PATCH] Add custom vinyl balloon configurator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-letter vinyl text add-on tied to the Custom Vinyl Square item. Customers pick a balloon shape (Heart/Star/Circle), type their message (max 30 non-space chars, ASCII only — no emoji), and choose from 8 Google Fonts rendered as live previews. Price updates in real time at $0.65/letter. At checkout, vinyl orders expand to two Square line items: the 18" Shape balloon at its catalog price and the Custom Vinyl service at the calculated letter count price, with the font attached as a modifier. Also adds a per-item admin toggle ("Suggest custom vinyl add-on") that shows a promo note on any balloon's product modal pointing customers toward the vinyl service. Co-Authored-By: Claude Sonnet 4.6 --- data/vinyl-config.json | 22 ++++ src/app/api/catalog/route.ts | 2 + src/app/api/vinyl-config/route.ts | 39 ++++++ src/app/shop/admin/page.tsx | 17 +++ src/components/CartDrawer.tsx | 115 ++++++++++++------ src/components/ColorPicker.tsx | 189 ++++++++++++++++++++++++++++-- src/context/CartContext.tsx | 7 ++ src/data/mock-catalog.ts | 4 + src/lib/overrides.ts | 4 + 9 files changed, 355 insertions(+), 44 deletions(-) create mode 100644 data/vinyl-config.json create mode 100644 src/app/api/vinyl-config/route.ts diff --git a/data/vinyl-config.json b/data/vinyl-config.json new file mode 100644 index 0000000..79f4cc7 --- /dev/null +++ b/data/vinyl-config.json @@ -0,0 +1,22 @@ +{ + "vinylItemId": "7RLHYXSCI3QBXXCSZHZHVEMG", + "vinylVariationId": "6BORBAGKFLW3A6BREWMV3CB6", + "pricePerLetterCents": 65, + "maxCharacters": 30, + "shapeItemId": "46JGZU6MYPMQL7M6USGL3KKB", + "shapes": [ + { "name": "Heart", "variationId": "OOR23NGE53SDQY6UQRE2X353", "priceCents": 450 }, + { "name": "Star", "variationId": "7GQY7OXJ6HGNPD2PS7EMP7SM", "priceCents": 450 }, + { "name": "Circle", "variationId": "EVI4L5I5ZT3RRONMXXZ3KQ3U", "priceCents": 450 } + ], + "fonts": [ + { "id": "HY4DFCCRUHIV5HLUBUNAFGQY", "name": "Anton", "family": "Anton" }, + { "id": "KMUNDFS4G6QFWCOS5KOBCKHL", "name": "Montserrat", "family": "Montserrat" }, + { "id": "TH7LNXBNV2NKJG5DOSJDOF4K", "name": "Indie Flower", "family": "Indie Flower" }, + { "id": "56CZ2GXWZU3OBBXMGS4FECNX", "name": "Pacifico", "family": "Pacifico" }, + { "id": "JVYX3XCQI2FRB23MOS3PXBJX", "name": "Style Script", "family": "Style Script" }, + { "id": "5I7ICFBD2KUJFH7J3SHU4HX3", "name": "MedievalSharp", "family": "MedievalSharp" }, + { "id": "73RT5RUWTAP4OKLCZZJHV47J", "name": "Luckiest Guy", "family": "Luckiest Guy" }, + { "id": "ST3M6TDB6PRN2JMJXA6EBEZ4", "name": "Playfair Display","family": "Playfair Display" } + ] +} diff --git a/src/app/api/catalog/route.ts b/src/app/api/catalog/route.ts index 1ccfc7e..62afba7 100644 --- a/src/app/api/catalog/route.ts +++ b/src/app/api/catalog/route.ts @@ -19,6 +19,8 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] { colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax, chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor, quantityUnit: ov.quantityUnit ?? item.quantityUnit, + vinylEnabled: ov.vinylEnabled ?? item.vinylEnabled, + vinylPromo: ov.vinylPromo ?? item.vinylPromo, description: ov.descriptionOverride ?? item.description, modifiers: item.modifiers .filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id)) diff --git a/src/app/api/vinyl-config/route.ts b/src/app/api/vinyl-config/route.ts new file mode 100644 index 0000000..0303bb0 --- /dev/null +++ b/src/app/api/vinyl-config/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from 'next/server' +import { readFileSync, existsSync } from 'fs' +import path from 'path' + +export interface VinylShape { + name: string + variationId: string + priceCents: number +} + +export interface VinylFont { + id: string + name: string + family: string +} + +export interface VinylConfig { + vinylItemId: string + vinylVariationId: string + pricePerLetterCents: number + maxCharacters: number + shapeItemId: string + shapes: VinylShape[] + fonts: VinylFont[] +} + +const CONFIG_PATH = path.join(process.cwd(), 'data', 'vinyl-config.json') + +export async function GET() { + if (!existsSync(CONFIG_PATH)) { + return NextResponse.json({ error: 'Vinyl config not found' }, { status: 404 }) + } + try { + const config: VinylConfig = JSON.parse(readFileSync(CONFIG_PATH, 'utf-8')) + return NextResponse.json(config) + } catch { + return NextResponse.json({ error: 'Failed to load vinyl config' }, { status: 500 }) + } +} diff --git a/src/app/shop/admin/page.tsx b/src/app/shop/admin/page.tsx index 97e200c..713b4ee 100644 --- a/src/app/shop/admin/page.tsx +++ b/src/app/shop/admin/page.tsx @@ -595,6 +595,7 @@ function ItemEditor({ const ov = item._override const [hidden, setHidden] = useState(ov.hidden ?? false) + const [vinylPromo, setVinylPromo] = useState(ov.vinylPromo ?? false) const [catOverride, setCatOverride] = useState(ov.categoryOverride ?? '') const [catLabel, setCatLabel] = useState(ov.categoryLabelOverride ?? '') const [sortOrder, setSortOrder] = useState(String(ov.sortOrder ?? '')) @@ -642,6 +643,7 @@ function ItemEditor({ const patch: Partial = { hidden, hiddenModifierIds: hiddenMods, + vinylPromo: vinylPromo || undefined, } if (catOverride) patch.categoryOverride = catOverride if (catLabel) patch.categoryLabelOverride = catLabel @@ -675,6 +677,7 @@ function ItemEditor({ const res = await fetch(`/api/admin/items/${item.id}`, { method: 'DELETE' }) if (res.ok) { setHidden(false) + setVinylPromo(false) setCatOverride('') setCatLabel('') setSortOrder('') @@ -736,6 +739,20 @@ function ItemEditor({ + {/* Vinyl promo */} +
+ +

Shows a note on this item prompting customers to also add a Custom Vinyl balloon.

+
+ {/* Category */}
diff --git a/src/components/CartDrawer.tsx b/src/components/CartDrawer.tsx index f967039..2287ba8 100644 --- a/src/components/CartDrawer.tsx +++ b/src/components/CartDrawer.tsx @@ -119,6 +119,10 @@ export default function CartDrawer() { // Unit price — uses selected variation price if set, otherwise product default const entryUnitPrice = useCallback((entry: (typeof entries)[number]) => { + if (entry.vinylText !== undefined && entry.vinylText !== '') { + const letterCount = entry.vinylText.replace(/ /g, '').length + return (entry.vinylShapePriceCents ?? 0) + letterCount * 65 + } const base = entry.selectedVariationId ? (entry.product.variations.find((v) => v.id === entry.selectedVariationId)?.priceCents ?? (entry.product.price ?? 0)) : (entry.product.price ?? 0) @@ -151,23 +155,53 @@ export default function CartDrawer() { const grandTotal = subtotal + deliveryTotal + taxCents // Build the payload sent to /api/checkout (recomputed only when dependencies change) + type LI = CheckoutPayload['lineItems'][number] const checkoutPayload = useMemo(() => ({ - lineItems: entries.map((e) => ({ - name: e.product.name, - quantity: e.quantity, - priceCents: entryUnitPrice(e), - catalogItemId: e.selectedVariationId ?? e.product.id, - colors: e.selectedColors.length ? e.selectedColors : undefined, - note: e.notes || undefined, - modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => { - const ml = e.product.modifiers.find((m) => m.id === listId) - if (!ml) return [] - return optIds.map((optId) => ({ - catalogObjectId: optId, - name: ml.options.find((o) => o.id === optId)?.name ?? optId, - })) - }), - })), + lineItems: entries.flatMap((e): LI[] => { + if (e.vinylText && e.vinylShapeVariationId) { + const letterCount = e.vinylText.replace(/ /g, '').length + const vinylCents = letterCount * 65 + return [ + { + name: `18" ${e.vinylShapeName ?? 'Shape'} Balloon`, + quantity: e.quantity, + priceCents: e.vinylShapePriceCents ?? 450, + catalogItemId: e.vinylShapeVariationId, + note: 'For custom vinyl', + }, + { + name: 'Custom Vinyl', + quantity: e.quantity, + priceCents: vinylCents, + catalogItemId: e.product.variations[0]?.id ?? e.product.id, + note: [ + `Text: "${e.vinylText}"`, + e.vinylFontName ? `Font: ${e.vinylFontName}` : null, + e.notes || null, + ].filter(Boolean).join(' | ') || undefined, + modifiers: e.vinylFontId + ? [{ catalogObjectId: e.vinylFontId, name: e.vinylFontName ?? '' }] + : undefined, + }, + ] + } + return [{ + name: e.product.name, + quantity: e.quantity, + priceCents: entryUnitPrice(e), + catalogItemId: e.selectedVariationId ?? e.product.id, + colors: e.selectedColors.length ? e.selectedColors : undefined, + note: e.notes || undefined, + modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => { + const ml = e.product.modifiers.find((m) => m.id === listId) + if (!ml) return [] + return optIds.map((optId) => ({ + catalogObjectId: optId, + name: ml.options.find((o) => o.id === optId)?.name ?? optId, + })) + }), + }] + }), selectedColors: entries.flatMap((e) => e.selectedColors), deliverySlotISO: fulfillmentType === 'delivery' ? deliverySlot?.slotISO : undefined, driveMinutes: fulfillmentType === 'delivery' ? deliverySlot?.driveMinutes : undefined, @@ -267,26 +301,37 @@ export default function CartDrawer() { {fmt(entryUnitPrice(entry) * entry.quantity)} )}
- {entry.selectedColors.length > 0 && ( -
- Colors: {entry.selectedColors.join(', ')} -
- )} - {Object.entries(entry.modifierChoices).map(([listId, optIds]) => { - if (!optIds.length) return null - const ml = entry.product.modifiers?.find((m) => m.id === listId) - if (!ml) return null - const names = optIds.map((id) => ml.options.find((o) => o.id === id)?.name ?? id) - return ( -
- {ml.name}: {names.join(', ')} -
- ) - })} - {entry.notes && ( -
- “{entry.notes}” + {entry.vinylText ? ( +
+
Shape: 18" {entry.vinylShapeName}
+
Text: “{entry.vinylText}”
+ {entry.vinylFontName &&
Font: {entry.vinylFontName}
} + {entry.notes &&
“{entry.notes}”
}
+ ) : ( + <> + {entry.selectedColors.length > 0 && ( +
+ Colors: {entry.selectedColors.join(', ')} +
+ )} + {Object.entries(entry.modifierChoices).map(([listId, optIds]) => { + if (!optIds.length) return null + const ml = entry.product.modifiers?.find((m) => m.id === listId) + if (!ml) return null + const names = optIds.map((id) => ml.options.find((o) => o.id === id)?.name ?? id) + return ( +
+ {ml.name}: {names.join(', ')} +
+ ) + })} + {entry.notes && ( +
+ “{entry.notes}” +
+ )} + )}
)) diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index c130190..dc8ccdc 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -5,6 +5,7 @@ import type { CatalogItem, ModifierList } from '@/data/mock-catalog' import { useCart } from '@/context/CartContext' import type { CartEntry } from '@/context/CartContext' import { fmt } from '@/lib/format' +import type { VinylConfig, VinylShape, VinylFont } from '@/app/api/vinyl-config/route' interface ColorEntry { name: string @@ -49,12 +50,46 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry () => editingEntry?.selectedVariationId ?? product.variations[0]?.id ) + // Vinyl state + const [vinylConfig, setVinylConfig] = useState(null) + const [vinylText, setVinylText] = useState(editingEntry?.vinylText ?? '') + const [vinylFontId, setVinylFontId] = useState(editingEntry?.vinylFontId ?? '') + const [vinylShape, setVinylShape] = useState( + () => null // resolved after config loads + ) + useEffect(() => { fetch('/colors.json') .then((r) => r.json()) .then((data: ColorFamily[]) => setFamilies(data)) }, []) + useEffect(() => { + if (!product.vinylEnabled) return + fetch('/api/vinyl-config') + .then((r) => r.json()) + .then((cfg: VinylConfig) => { + setVinylConfig(cfg) + // Restore shape selection when editing + const savedId = editingEntry?.vinylShapeVariationId + if (savedId) { + setVinylShape(cfg.shapes.find((s) => s.variationId === savedId) ?? cfg.shapes[0]) + } else { + setVinylShape(cfg.shapes[0]) + } + // Load Google Fonts for previews + const families = cfg.fonts.map((f) => `family=${encodeURIComponent(f.family).replace(/%20/g, '+')}`).join('&') + const href = `https://fonts.googleapis.com/css2?${families}&display=swap` + if (!document.querySelector(`link[href="${href}"]`)) { + const link = document.createElement('link') + link.rel = 'stylesheet' + link.href = href + document.head.appendChild(link) + } + }) + .catch(() => {/* vinyl config unavailable — fail silently */}) + }, [product.vinylEnabled, editingEntry?.vinylShapeVariationId]) + useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } window.addEventListener('keydown', onKey) @@ -101,7 +136,17 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry return ml.minSelected > 0 && chosen < ml.minSelected }) const needsColors = product.showColors && selected.size < colorMin - const canAdd = missingModifiers.length === 0 && !needsColors + + // Vinyl validation + const vinylLetterCount = vinylText.replace(/ /g, '').length + const vinylMaxChars = vinylConfig?.maxCharacters ?? 30 + const vinylPriceCents = product.vinylEnabled + ? (vinylShape?.priceCents ?? 0) + vinylLetterCount * (vinylConfig?.pricePerLetterCents ?? 65) + : 0 + const needsVinylText = product.vinylEnabled && vinylLetterCount === 0 + const needsVinylFont = product.vinylEnabled && !vinylFontId + + const canAdd = missingModifiers.length === 0 && !needsColors && !needsVinylText && !needsVinylFont // Selectable variations: everything except the chrome variation (auto-applied by color choice) const selectableVariations = product.variations.filter( @@ -143,8 +188,10 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry }, 0) const basePrice = activeVariation?.priceCents ?? product.price ?? 0 const chromeDelta = chromeCount * surchargePerColor - const unitPrice = basePrice + modDelta + chromeDelta - const total = basePrice > 0 ? fmt(unitPrice * quantity) : 'Get Quote' + const unitPrice = product.vinylEnabled + ? vinylPriceCents + : basePrice + modDelta + chromeDelta + const total = unitPrice > 0 ? fmt(unitPrice * quantity) : product.vinylEnabled ? fmt(0) : 'Get Quote' return (
@@ -225,6 +272,13 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry

)} + {/* ── Vinyl promo note ── */} + {product.vinylPromo && ( +
+ Want to personalize this balloon? Add a Custom Vinyl item to your order and we'll apply custom text in your choice of font — starting at $4.50 + $0.65/letter. +
+ )} + {/* ── Size / variation selector ── */} {selectableVariations.length > 1 && (
@@ -484,6 +538,113 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
))} + {/* ── Vinyl configurator ── */} + {product.vinylEnabled && vinylConfig && ( +
+

+ Custom Vinyl Text +

+ + {/* Shape picker */} +
+ +
+ {vinylConfig.shapes.map((shape) => ( + + ))} +
+
+ + {/* Text input */} +
+ +
+ { + // Strip anything outside standard ASCII printable range (no emojis, accented chars, etc.) + const sanitized = e.target.value.replace(/[^\x20-\x7E]/g, '') + const nonSpace = sanitized.replace(/ /g, '').length + if (nonSpace <= vinylMaxChars) setVinylText(sanitized) + }} + /> +
+ {vinylLetterCount > 0 && ( +

+ {vinylLetterCount} letter{vinylLetterCount !== 1 ? 's' : ''} × {fmt(vinylConfig.pricePerLetterCents)} = {fmt(vinylLetterCount * vinylConfig.pricePerLetterCents)} vinyl +

+ )} +
+ + {/* Font picker */} +
+ +
+ {vinylConfig.fonts.map((font) => { + const chosen = vinylFontId === font.id + return ( + + ) + })} +
+
+ + {/* Live price summary */} + {vinylLetterCount > 0 && vinylShape && ( +
+
+ 18" {vinylShape.name} balloon + {fmt(vinylShape.priceCents)} +
+
+ Vinyl ({vinylLetterCount} letters) + {fmt(vinylLetterCount * vinylConfig.pricePerLetterCents)} +
+
+ Total + {fmt(vinylPriceCents)} +
+
+ )} +
+ )} + {/* ── Notes ── */}
@@ -526,10 +687,12 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry