Add custom vinyl balloon configurator

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 <noreply@anthropic.com>
This commit is contained in:
chris 2026-04-29 13:03:38 -04:00
parent e34dfc397c
commit 1f1dabdb31
9 changed files with 355 additions and 44 deletions

22
data/vinyl-config.json Normal file
View File

@ -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" }
]
}

View File

@ -19,6 +19,8 @@ function applyOverrides(items: CatalogItem[]): CatalogItem[] {
colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax, colorMax: ov.colorMax !== undefined ? ov.colorMax : item.colorMax,
chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor, chromeSurchargePerColor: ov.chromeSurchargePerColor ?? item.chromeSurchargePerColor,
quantityUnit: ov.quantityUnit ?? item.quantityUnit, quantityUnit: ov.quantityUnit ?? item.quantityUnit,
vinylEnabled: ov.vinylEnabled ?? item.vinylEnabled,
vinylPromo: ov.vinylPromo ?? item.vinylPromo,
description: ov.descriptionOverride ?? item.description, description: ov.descriptionOverride ?? item.description,
modifiers: item.modifiers modifiers: item.modifiers
.filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id)) .filter((m) => !(ov.hiddenModifierIds ?? []).includes(m.id))

View File

@ -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 })
}
}

View File

@ -595,6 +595,7 @@ function ItemEditor({
const ov = item._override const ov = item._override
const [hidden, setHidden] = useState(ov.hidden ?? false) const [hidden, setHidden] = useState(ov.hidden ?? false)
const [vinylPromo, setVinylPromo] = useState(ov.vinylPromo ?? false)
const [catOverride, setCatOverride] = useState(ov.categoryOverride ?? '') const [catOverride, setCatOverride] = useState(ov.categoryOverride ?? '')
const [catLabel, setCatLabel] = useState(ov.categoryLabelOverride ?? '') const [catLabel, setCatLabel] = useState(ov.categoryLabelOverride ?? '')
const [sortOrder, setSortOrder] = useState(String(ov.sortOrder ?? '')) const [sortOrder, setSortOrder] = useState(String(ov.sortOrder ?? ''))
@ -642,6 +643,7 @@ function ItemEditor({
const patch: Partial<ItemOverride> = { const patch: Partial<ItemOverride> = {
hidden, hidden,
hiddenModifierIds: hiddenMods, hiddenModifierIds: hiddenMods,
vinylPromo: vinylPromo || undefined,
} }
if (catOverride) patch.categoryOverride = catOverride if (catOverride) patch.categoryOverride = catOverride
if (catLabel) patch.categoryLabelOverride = catLabel if (catLabel) patch.categoryLabelOverride = catLabel
@ -675,6 +677,7 @@ function ItemEditor({
const res = await fetch(`/api/admin/items/${item.id}`, { method: 'DELETE' }) const res = await fetch(`/api/admin/items/${item.id}`, { method: 'DELETE' })
if (res.ok) { if (res.ok) {
setHidden(false) setHidden(false)
setVinylPromo(false)
setCatOverride('') setCatOverride('')
setCatLabel('') setCatLabel('')
setSortOrder('') setSortOrder('')
@ -736,6 +739,20 @@ function ItemEditor({
</label> </label>
</div> </div>
{/* Vinyl promo */}
<div className="field">
<label className="checkbox">
<input
type="checkbox"
checked={vinylPromo}
onChange={(e) => setVinylPromo(e.target.checked)}
style={{ marginRight: 6 }}
/>
Suggest custom vinyl add-on
</label>
<p className="help">Shows a note on this item prompting customers to also add a Custom Vinyl balloon.</p>
</div>
{/* Category */} {/* Category */}
<div className="field"> <div className="field">
<label className="label is-small">Category</label> <label className="label is-small">Category</label>

View File

@ -119,6 +119,10 @@ export default function CartDrawer() {
// Unit price — uses selected variation price if set, otherwise product default // Unit price — uses selected variation price if set, otherwise product default
const entryUnitPrice = useCallback((entry: (typeof entries)[number]) => { 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 const base = entry.selectedVariationId
? (entry.product.variations.find((v) => v.id === entry.selectedVariationId)?.priceCents ?? (entry.product.price ?? 0)) ? (entry.product.variations.find((v) => v.id === entry.selectedVariationId)?.priceCents ?? (entry.product.price ?? 0))
: (entry.product.price ?? 0) : (entry.product.price ?? 0)
@ -151,23 +155,53 @@ export default function CartDrawer() {
const grandTotal = subtotal + deliveryTotal + taxCents const grandTotal = subtotal + deliveryTotal + taxCents
// Build the payload sent to /api/checkout (recomputed only when dependencies change) // Build the payload sent to /api/checkout (recomputed only when dependencies change)
type LI = CheckoutPayload['lineItems'][number]
const checkoutPayload = useMemo<CheckoutPayload>(() => ({ const checkoutPayload = useMemo<CheckoutPayload>(() => ({
lineItems: entries.map((e) => ({ lineItems: entries.flatMap((e): LI[] => {
name: e.product.name, if (e.vinylText && e.vinylShapeVariationId) {
quantity: e.quantity, const letterCount = e.vinylText.replace(/ /g, '').length
priceCents: entryUnitPrice(e), const vinylCents = letterCount * 65
catalogItemId: e.selectedVariationId ?? e.product.id, return [
colors: e.selectedColors.length ? e.selectedColors : undefined, {
note: e.notes || undefined, name: `18" ${e.vinylShapeName ?? 'Shape'} Balloon`,
modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => { quantity: e.quantity,
const ml = e.product.modifiers.find((m) => m.id === listId) priceCents: e.vinylShapePriceCents ?? 450,
if (!ml) return [] catalogItemId: e.vinylShapeVariationId,
return optIds.map((optId) => ({ note: 'For custom vinyl',
catalogObjectId: optId, },
name: ml.options.find((o) => o.id === optId)?.name ?? optId, {
})) 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), selectedColors: entries.flatMap((e) => e.selectedColors),
deliverySlotISO: fulfillmentType === 'delivery' ? deliverySlot?.slotISO : undefined, deliverySlotISO: fulfillmentType === 'delivery' ? deliverySlot?.slotISO : undefined,
driveMinutes: fulfillmentType === 'delivery' ? deliverySlot?.driveMinutes : undefined, driveMinutes: fulfillmentType === 'delivery' ? deliverySlot?.driveMinutes : undefined,
@ -267,26 +301,37 @@ export default function CartDrawer() {
<span style={{ fontSize: '0.82rem', color: '#666', marginLeft: '4px' }}>{fmt(entryUnitPrice(entry) * entry.quantity)}</span> <span style={{ fontSize: '0.82rem', color: '#666', marginLeft: '4px' }}>{fmt(entryUnitPrice(entry) * entry.quantity)}</span>
)} )}
</div> </div>
{entry.selectedColors.length > 0 && ( {entry.vinylText ? (
<div style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}> <div style={{ fontSize: '0.8rem', color: '#5a3e9e', marginTop: '0.3rem', background: '#f3eeff', borderRadius: '6px', padding: '4px 8px' }}>
Colors: {entry.selectedColors.join(', ')} <div>Shape: 18&quot; {entry.vinylShapeName}</div>
</div> <div>Text: &ldquo;{entry.vinylText}&rdquo;</div>
)} {entry.vinylFontName && <div>Font: {entry.vinylFontName}</div>}
{Object.entries(entry.modifierChoices).map(([listId, optIds]) => { {entry.notes && <div style={{ fontStyle: 'italic', color: '#888' }}>&ldquo;{entry.notes}&rdquo;</div>}
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 (
<div key={listId} style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
{ml.name}: {names.join(', ')}
</div>
)
})}
{entry.notes && (
<div style={{ fontSize: '0.8rem', color: '#888', marginTop: '0.2rem', fontStyle: 'italic' }}>
&ldquo;{entry.notes}&rdquo;
</div> </div>
) : (
<>
{entry.selectedColors.length > 0 && (
<div style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
Colors: {entry.selectedColors.join(', ')}
</div>
)}
{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 (
<div key={listId} style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
{ml.name}: {names.join(', ')}
</div>
)
})}
{entry.notes && (
<div style={{ fontSize: '0.8rem', color: '#888', marginTop: '0.2rem', fontStyle: 'italic' }}>
&ldquo;{entry.notes}&rdquo;
</div>
)}
</>
)} )}
</div> </div>
)) ))

View File

@ -5,6 +5,7 @@ import type { CatalogItem, ModifierList } from '@/data/mock-catalog'
import { useCart } from '@/context/CartContext' import { useCart } from '@/context/CartContext'
import type { CartEntry } from '@/context/CartContext' import type { CartEntry } from '@/context/CartContext'
import { fmt } from '@/lib/format' import { fmt } from '@/lib/format'
import type { VinylConfig, VinylShape, VinylFont } from '@/app/api/vinyl-config/route'
interface ColorEntry { interface ColorEntry {
name: string name: string
@ -49,12 +50,46 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
() => editingEntry?.selectedVariationId ?? product.variations[0]?.id () => editingEntry?.selectedVariationId ?? product.variations[0]?.id
) )
// Vinyl state
const [vinylConfig, setVinylConfig] = useState<VinylConfig | null>(null)
const [vinylText, setVinylText] = useState(editingEntry?.vinylText ?? '')
const [vinylFontId, setVinylFontId] = useState(editingEntry?.vinylFontId ?? '')
const [vinylShape, setVinylShape] = useState<VinylShape | null>(
() => null // resolved after config loads
)
useEffect(() => { useEffect(() => {
fetch('/colors.json') fetch('/colors.json')
.then((r) => r.json()) .then((r) => r.json())
.then((data: ColorFamily[]) => setFamilies(data)) .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(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() } const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
@ -101,7 +136,17 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
return ml.minSelected > 0 && chosen < ml.minSelected return ml.minSelected > 0 && chosen < ml.minSelected
}) })
const needsColors = product.showColors && selected.size < colorMin 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) // Selectable variations: everything except the chrome variation (auto-applied by color choice)
const selectableVariations = product.variations.filter( const selectableVariations = product.variations.filter(
@ -143,8 +188,10 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
}, 0) }, 0)
const basePrice = activeVariation?.priceCents ?? product.price ?? 0 const basePrice = activeVariation?.priceCents ?? product.price ?? 0
const chromeDelta = chromeCount * surchargePerColor const chromeDelta = chromeCount * surchargePerColor
const unitPrice = basePrice + modDelta + chromeDelta const unitPrice = product.vinylEnabled
const total = basePrice > 0 ? fmt(unitPrice * quantity) : 'Get Quote' ? vinylPriceCents
: basePrice + modDelta + chromeDelta
const total = unitPrice > 0 ? fmt(unitPrice * quantity) : product.vinylEnabled ? fmt(0) : 'Get Quote'
return ( return (
<div className="modal is-active" onClick={onClose}> <div className="modal is-active" onClick={onClose}>
@ -225,6 +272,13 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
</p> </p>
)} )}
{/* ── Vinyl promo note ── */}
{product.vinylPromo && (
<div style={{ marginBottom: '1.25rem', padding: '0.65rem 0.9rem', background: '#f3eeff', border: '1px solid #d8c8f8', borderRadius: '8px', fontSize: '0.82rem', color: '#4a2d9e' }}>
Want to personalize this balloon? Add a <strong>Custom Vinyl</strong> item to your order and we&apos;ll apply custom text in your choice of font starting at $4.50 + $0.65/letter.
</div>
)}
{/* ── Size / variation selector ── */} {/* ── Size / variation selector ── */}
{selectableVariations.length > 1 && ( {selectableVariations.length > 1 && (
<div className="field" style={{ marginBottom: '1.25rem' }}> <div className="field" style={{ marginBottom: '1.25rem' }}>
@ -484,6 +538,113 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
</div> </div>
))} ))}
{/* ── Vinyl configurator ── */}
{product.vinylEnabled && vinylConfig && (
<div style={{ marginTop: '1.5rem', padding: '1rem', background: '#f8f4ff', border: '1px solid #d8c8f8', borderRadius: '10px' }}>
<p className="label" style={{ marginBottom: '0.75rem', color: '#5a3e9e' }}>
Custom Vinyl Text
</p>
{/* Shape picker */}
<div className="field" style={{ marginBottom: '1rem' }}>
<label className="label is-small">Balloon Shape</label>
<div className="buttons">
{vinylConfig.shapes.map((shape) => (
<button
key={shape.variationId}
type="button"
className={`button is-small${vinylShape?.variationId === shape.variationId ? ' is-info' : ''}`}
onClick={() => setVinylShape(shape)}
>
{shape.name}
<span style={{ marginLeft: 6, opacity: 0.7, fontSize: '0.78em' }}>{fmt(shape.priceCents)}</span>
</button>
))}
</div>
</div>
{/* Text input */}
<div className="field" style={{ marginBottom: '1rem' }}>
<label className="label is-small">
Your message
<span style={{ fontWeight: 'normal', marginLeft: '8px', color: vinylLetterCount > vinylMaxChars ? '#e00' : '#888' }}>
{vinylLetterCount}/{vinylMaxChars} letters (spaces free)
</span>
</label>
<div className="control">
<input
className="input"
type="text"
placeholder="e.g. Happy Birthday!"
value={vinylText}
onChange={(e) => {
// 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)
}}
/>
</div>
{vinylLetterCount > 0 && (
<p className="is-size-7" style={{ marginTop: '0.25rem', color: '#5a3e9e' }}>
{vinylLetterCount} letter{vinylLetterCount !== 1 ? 's' : ''} × {fmt(vinylConfig.pricePerLetterCents)} = {fmt(vinylLetterCount * vinylConfig.pricePerLetterCents)} vinyl
</p>
)}
</div>
{/* Font picker */}
<div className="field" style={{ marginBottom: '0.5rem' }}>
<label className="label is-small">Font Style</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))', gap: '8px' }}>
{vinylConfig.fonts.map((font) => {
const chosen = vinylFontId === font.id
return (
<button
key={font.id}
type="button"
onClick={() => setVinylFontId(font.id)}
style={{
padding: '10px 8px',
border: `2px solid ${chosen ? '#7c4dff' : '#d8c8f8'}`,
borderRadius: '8px',
background: chosen ? '#ede7ff' : '#fff',
cursor: 'pointer',
textAlign: 'center',
transition: 'border-color 0.15s, background 0.15s',
}}
>
<span style={{ fontFamily: `'${font.family}', sans-serif`, fontSize: '1.3rem', display: 'block', color: '#2d1b6b', lineHeight: 1.2 }}>
{vinylText || 'Aa'}
</span>
<span style={{ fontSize: '0.68rem', color: '#888', marginTop: '4px', display: 'block' }}>
{font.name}
</span>
</button>
)
})}
</div>
</div>
{/* Live price summary */}
{vinylLetterCount > 0 && vinylShape && (
<div style={{ marginTop: '0.75rem', padding: '0.6rem 0.85rem', background: '#ede7ff', borderRadius: '8px', fontSize: '0.82rem', color: '#3d2080' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>18&quot; {vinylShape.name} balloon</span>
<span>{fmt(vinylShape.priceCents)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>Vinyl ({vinylLetterCount} letters)</span>
<span>{fmt(vinylLetterCount * vinylConfig.pricePerLetterCents)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontWeight: 700, borderTop: '1px solid #c4b0f0', marginTop: '4px', paddingTop: '4px' }}>
<span>Total</span>
<span>{fmt(vinylPriceCents)}</span>
</div>
</div>
)}
</div>
)}
{/* ── Notes ── */} {/* ── Notes ── */}
<div className="field mt-4"> <div className="field mt-4">
<label className="label">Special notes (optional)</label> <label className="label">Special notes (optional)</label>
@ -526,10 +687,12 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
<footer className="modal-card-foot" style={{ gap: '0.75rem', flexWrap: 'wrap' }}> <footer className="modal-card-foot" style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
{!canAdd && ( {!canAdd && (
<p className="is-size-7 has-text-danger" style={{ width: '100%', marginBottom: '0.25rem' }}> <p className="is-size-7 has-text-danger" style={{ width: '100%', marginBottom: '0.25rem' }}>
Please select:{' '} Please:{' '}
{[ {[
needsColors ? `at least ${colorMin} color${colorMin !== 1 ? 's' : ''}` : null, needsColors ? `select at least ${colorMin} color${colorMin !== 1 ? 's' : ''}` : null,
...missingModifiers.map((ml) => ml.name), needsVinylText ? 'enter your vinyl message' : null,
needsVinylFont ? 'choose a font style' : null,
...missingModifiers.map((ml) => `choose ${ml.name}`),
].filter(Boolean).join(', ')} ].filter(Boolean).join(', ')}
</p> </p>
)} )}
@ -544,12 +707,20 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
if (v.size) choices[k] = Array.from(v) if (v.size) choices[k] = Array.from(v)
}) })
const selectedVariationId = activeVariation?.id const selectedVariationId = activeVariation?.id
// Store the user's manual variation choice (not the chrome override) so editing restores the right selection
const storedVariationId = userVariation?.id ?? selectedVariationId const storedVariationId = userVariation?.id ?? selectedVariationId
const selectedFont = vinylConfig?.fonts.find((f) => f.id === vinylFontId)
const vinylFields = product.vinylEnabled && vinylText && vinylShape ? {
vinylText,
vinylFontId,
vinylFontName: selectedFont?.name,
vinylShapeVariationId: vinylShape.variationId,
vinylShapeName: vinylShape.name,
vinylShapePriceCents: vinylShape.priceCents,
} : {}
if (editingEntry) { if (editingEntry) {
updateEntry(editingEntry.cartId, { product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId }) updateEntry(editingEntry.cartId, { product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId, ...vinylFields })
} else { } else {
addToCart({ product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId }) addToCart({ product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId, ...vinylFields })
} }
onClose() onClose()
}} }}

View File

@ -13,6 +13,13 @@ export interface CartEntry {
modifierChoices: Record<string, string[]> // listId → optionIds modifierChoices: Record<string, string[]> // listId → optionIds
notes: string notes: string
selectedVariationId?: string // set when a non-default variation is chosen selectedVariationId?: string // set when a non-default variation is chosen
// Vinyl fields — only set when the item has vinylEnabled=true
vinylText?: string
vinylFontId?: string // Square modifier option ID
vinylFontName?: string
vinylShapeVariationId?: string // Square variation ID (Heart/Star/Circle)
vinylShapeName?: string
vinylShapePriceCents?: number
} }
interface CartState { interface CartState {

View File

@ -42,6 +42,10 @@ export interface CatalogItem {
variations: CatalogVariation[] // all enabled variations; first is the default variations: CatalogVariation[] // all enabled variations; first is the default
/** Unit label for quantity, e.g. "ft". Omitted for plain count items. */ /** Unit label for quantity, e.g. "ft". Omitted for plain count items. */
quantityUnit?: string quantityUnit?: string
/** When true, the custom vinyl configurator is shown for this item. */
vinylEnabled?: boolean
/** When true, a note is shown suggesting the customer also add a Custom Vinyl item. */
vinylPromo?: boolean
} }
export const MOCK_CATALOG: CatalogItem[] = (([ export const MOCK_CATALOG: CatalogItem[] = (([

View File

@ -20,6 +20,10 @@ export interface ItemOverride {
chromeSurchargePerColor?: number chromeSurchargePerColor?: number
/** Unit label for the quantity field, e.g. "ft". When set, the quantity control shows "X ft". */ /** Unit label for the quantity field, e.g. "ft". When set, the quantity control shows "X ft". */
quantityUnit?: string quantityUnit?: string
/** When true, shows the custom vinyl text configurator on this item's product modal. */
vinylEnabled?: boolean
/** When true, shows a promo note suggesting the customer also add a Custom Vinyl item. */
vinylPromo?: boolean
} }
export type OverridesMap = Record<string, ItemOverride> export type OverridesMap = Record<string, ItemOverride>