From 1dc8a087b6740c6fee8e96b4156bc891b9a1d6e8 Mon Sep 17 00:00:00 2001 From: chris Date: Wed, 29 Apr 2026 17:01:28 -0400 Subject: [PATCH] Add vinyl configurator feature and admin sync from balloons-shop - vinyl-config route + data file for shape/font/pricing config - CatalogItem: vinylEnabled, vinylPromo fields - ItemOverride: vinylEnabled, vinylPromo fields - catalog route: applies vinylEnabled/vinylPromo overrides - ColorPicker: full vinyl configurator UI (shape picker, text/font, pricing) - CartContext: vinyl cart fields (vinylText, vinylFontId, vinylShape, etc.) - CartDrawer: vinyl line items flatMap (shape balloon + custom vinyl service) - admin/items route: synced more-complete version from balloons-shop - admin page: vinyl configurator and promo note checkboxes in ItemEditor Co-Authored-By: Claude Sonnet 4.6 --- estore/data/vinyl-config.json | 22 ++++ estore/src/app/admin/page.tsx | 24 ++++ estore/src/app/api/admin/items/route.ts | 37 +++++- estore/src/app/api/catalog/route.ts | 2 + estore/src/app/api/vinyl-config/route.ts | 39 ++++++ estore/src/components/CartDrawer.tsx | 66 +++++++--- estore/src/components/ColorPicker.tsx | 157 +++++++++++++++++++++-- estore/src/context/CartContext.tsx | 7 + estore/src/data/mock-catalog.ts | 4 + estore/src/lib/overrides.ts | 4 + 10 files changed, 331 insertions(+), 31 deletions(-) create mode 100644 estore/data/vinyl-config.json create mode 100644 estore/src/app/api/vinyl-config/route.ts diff --git a/estore/data/vinyl-config.json b/estore/data/vinyl-config.json new file mode 100644 index 0000000..79f4cc7 --- /dev/null +++ b/estore/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/estore/src/app/admin/page.tsx b/estore/src/app/admin/page.tsx index 6ff5789..f521299 100644 --- a/estore/src/app/admin/page.tsx +++ b/estore/src/app/admin/page.tsx @@ -771,6 +771,8 @@ function ItemEditor({ const [deliveryPerMile, setDeliveryPerMile] = useState( ov.deliveryPerMileOverride != null ? String(ov.deliveryPerMileOverride / 100) : '' ) + const [vinylEnabled, setVinylEnabled] = useState(ov.vinylEnabled ?? false) + const [vinylPromo, setVinylPromo] = useState(ov.vinylPromo ?? false) // Create category const [newCatName, setNewCatName] = useState('') @@ -813,6 +815,8 @@ function ItemEditor({ patch.requiresDelivery = requiresDelivery || undefined patch.deliveryBaseOverride = deliveryBase !== '' ? Math.round(Number(deliveryBase) * 100) : null patch.deliveryPerMileOverride = deliveryPerMile !== '' ? Math.round(Number(deliveryPerMile) * 100) : null + patch.vinylEnabled = vinylEnabled || undefined + patch.vinylPromo = vinylPromo || undefined const res = await fetch(`${BASE}/api/admin/items/${item.id}`, { method: 'PATCH', @@ -847,6 +851,8 @@ function ItemEditor({ setRequiresDelivery(false) setDeliveryBase('') setDeliveryPerMile('') + setVinylEnabled(false) + setVinylPromo(false) onSaved(item.id, {}) } } @@ -912,6 +918,24 @@ function ItemEditor({ /> 🚗 Requires delivery + + {requiresDelivery && ( diff --git a/estore/src/app/api/admin/items/route.ts b/estore/src/app/api/admin/items/route.ts index 453e012..3a90bce 100644 --- a/estore/src/app/api/admin/items/route.ts +++ b/estore/src/app/api/admin/items/route.ts @@ -2,8 +2,6 @@ import { NextResponse } from 'next/server' import { getCatalog } from '@/lib/catalog-cache' import { readOverrides } from '@/lib/overrides' -export const dynamic = 'force-dynamic' - export async function GET() { try { const [{ items, fetchedAt }, overrides] = await Promise.all([ @@ -13,20 +11,47 @@ export async function GET() { const withOverrides = items.map((item) => { const ov = overrides[item.id] ?? {} + const toSlug = (n: string) => n.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') + + // Resolve categories (same logic as catalog route) + const resolvedCats = ov.categoriesOverride?.length + ? (() => { + const cats = ov.categoriesOverride!.map(toSlug) + return { + categories: cats, + categoryLabels: ov.categoriesOverride!, + category: cats[0], + categoryLabel: ov.categoriesOverride![0], + } + })() + : { + category: ov.categoryOverride ?? item.category, + categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel, + categories: ov.categoryOverride + ? [ov.categoryOverride, ...(item.categories ?? [item.category]).slice(1)] + : (item.categories ?? [item.category]), + categoryLabels: ov.categoryLabelOverride + ? [ov.categoryLabelOverride, ...(item.categoryLabels ?? [item.categoryLabel]).slice(1)] + : (item.categoryLabels ?? [item.categoryLabel]), + } + return { ...item, // Resolved values (what the customer sees) hidden: ov.hidden ?? false, - category: ov.categoryOverride ?? item.category, - categoryLabel: ov.categoryLabelOverride ?? item.categoryLabel, + featured: ov.featured ?? item.featured ?? false, + ...resolvedCats, sortOrder: ov.sortOrder ?? 0, showColors: ov.showColors != null ? ov.showColors : item.showColors, colorMin: ov.colorMin ?? item.colorMin, colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax, chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor, + disabledColors: ov.disabledColors?.length ? ov.disabledColors : item.disabledColors, + requiresDelivery: ov.requiresDelivery != null ? ov.requiresDelivery : item.requiresDelivery, + deliveryBaseOverride: ov.deliveryBaseOverride !== undefined ? ov.deliveryBaseOverride : item.deliveryBaseOverride, + deliveryPerMileOverride: ov.deliveryPerMileOverride !== undefined ? ov.deliveryPerMileOverride : item.deliveryPerMileOverride, description: ov.descriptionOverride ?? item.description, - variations: item.variations - .filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)), + variations: item.variations.filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)), modifiers: item.modifiers .filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id)) .map((m) => { diff --git a/estore/src/app/api/catalog/route.ts b/estore/src/app/api/catalog/route.ts index 07af633..73bb345 100644 --- a/estore/src/app/api/catalog/route.ts +++ b/estore/src/app/api/catalog/route.ts @@ -47,6 +47,8 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] { requiresDelivery: ov.requiresDelivery != null ? ov.requiresDelivery : item.requiresDelivery, deliveryBaseOverride: ov.deliveryBaseOverride !== undefined ? ov.deliveryBaseOverride : item.deliveryBaseOverride, deliveryPerMileOverride: ov.deliveryPerMileOverride !== undefined ? ov.deliveryPerMileOverride : item.deliveryPerMileOverride, + vinylEnabled: ov.vinylEnabled ?? item.vinylEnabled, + vinylPromo: ov.vinylPromo ?? item.vinylPromo, description: ov.descriptionOverride ?? item.description, variations: item.variations .filter((v) => !(ov.hiddenVariationIds ?? []).includes(v.id)), diff --git a/estore/src/app/api/vinyl-config/route.ts b/estore/src/app/api/vinyl-config/route.ts new file mode 100644 index 0000000..0303bb0 --- /dev/null +++ b/estore/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/estore/src/components/CartDrawer.tsx b/estore/src/components/CartDrawer.tsx index 77dfb16..6c28a82 100644 --- a/estore/src/components/CartDrawer.tsx +++ b/estore/src/components/CartDrawer.tsx @@ -148,6 +148,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) @@ -180,23 +184,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: effectiveFulfillment === 'delivery' ? deliverySlot?.slotISO : undefined, driveMinutes: effectiveFulfillment === 'delivery' ? deliverySlot?.driveMinutes : undefined, diff --git a/estore/src/components/ColorPicker.tsx b/estore/src/components/ColorPicker.tsx index 9afddfe..707c045 100644 --- a/estore/src/components/ColorPicker.tsx +++ b/estore/src/components/ColorPicker.tsx @@ -7,6 +7,7 @@ import { BASE } from '@/lib/basepath' import type { CartEntry } from '@/context/CartContext' import { fmt } from '@/lib/format' import { useLockBodyScroll } from '@/lib/useLockBodyScroll' +import type { VinylConfig, VinylShape } from '@/app/api/vinyl-config/route' interface ColorEntry { name: string @@ -52,6 +53,30 @@ 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) + + useEffect(() => { + if (!product.vinylEnabled) return + fetch(BASE + '/api/vinyl-config') + .then((r) => r.json()) + .then((cfg: VinylConfig) => { + setVinylConfig(cfg) + const savedId = editingEntry?.vinylShapeVariationId + setVinylShape(savedId ? (cfg.shapes.find((s) => s.variationId === savedId) ?? cfg.shapes[0]) : cfg.shapes[0]) + 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(() => {}) + }, [product.vinylEnabled, editingEntry?.vinylShapeVariationId]) + useEffect(() => { const disabled = new Set(product.disabledColors ?? []) fetch(BASE + '/colors.json') @@ -112,7 +137,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( @@ -154,8 +189,8 @@ 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 (
@@ -236,6 +271,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 && (
@@ -496,6 +538,93 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
))} + {/* ── Vinyl configurator ── */} + {product.vinylEnabled && vinylConfig && ( +
+

Custom Vinyl Text

+ + {/* Shape picker */} +
+ +
+ {vinylConfig.shapes.map((shape) => ( + + ))} +
+
+ + {/* Text input */} +
+ +
+ { + 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 ── */}
@@ -538,10 +667,12 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry