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 <noreply@anthropic.com>
This commit is contained in:
chris 2026-04-29 17:01:28 -04:00
parent 7bc84cea75
commit 1dc8a087b6
10 changed files with 331 additions and 31 deletions

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

@ -771,6 +771,8 @@ function ItemEditor({
const [deliveryPerMile, setDeliveryPerMile] = useState<string>(
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
</label>
<label className="checkbox" style={{ fontWeight: 600, color: '#7B3FF2' }}>
<input
type="checkbox"
checked={vinylEnabled}
onChange={(e) => setVinylEnabled(e.target.checked)}
style={{ marginRight: 6, accentColor: '#7B3FF2' }}
/>
🖨 Vinyl configurator
</label>
<label className="checkbox" style={{ fontWeight: 600, color: '#7B3FF2' }}>
<input
type="checkbox"
checked={vinylPromo}
onChange={(e) => setVinylPromo(e.target.checked)}
style={{ marginRight: 6, accentColor: '#7B3FF2' }}
/>
💡 Vinyl promo note
</label>
</div>
{requiresDelivery && (

View File

@ -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) => {

View File

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

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

@ -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<CheckoutPayload>(() => ({
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,

View File

@ -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<VinylConfig | null>(null)
const [vinylText, setVinylText] = useState(editingEntry?.vinylText ?? '')
const [vinylFontId, setVinylFontId] = useState(editingEntry?.vinylFontId ?? '')
const [vinylShape, setVinylShape] = useState<VinylShape | null>(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 (
<div className="modal is-active" onClick={onClose}>
@ -236,6 +271,13 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
</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 ── */}
{selectableVariations.length > 1 && (
<div className="field" style={{ marginBottom: '1.25rem' }}>
@ -496,6 +538,93 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
</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: '#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) => {
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 ── */}
<div className="field mt-4">
<label className="label">Special notes (optional)</label>
@ -538,10 +667,12 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
<footer className="modal-card-foot" style={{ gap: '0.75rem', flexWrap: 'wrap' }}>
{!canAdd && (
<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,
...missingModifiers.map((ml) => ml.name),
needsColors ? `select at least ${colorMin} color${colorMin !== 1 ? 's' : ''}` : null,
needsVinylText ? 'enter your vinyl message' : null,
needsVinylFont ? 'choose a font style' : null,
...missingModifiers.map((ml) => `choose ${ml.name}`),
].filter(Boolean).join(', ')}
</p>
)}
@ -556,12 +687,20 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
if (v.size) choices[k] = Array.from(v)
})
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 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) {
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 {
addToCart({ product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId })
addToCart({ product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId, ...vinylFields })
}
onClose()
}}

View File

@ -13,6 +13,13 @@ export interface CartEntry {
modifierChoices: Record<string, string[]> // listId → optionIds
notes: string
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 {

View File

@ -52,6 +52,10 @@ export interface CatalogItem {
deliveryBaseOverride?: number | null
/** Per-item per-mile rate override in cents. null = use tier default. */
deliveryPerMileOverride?: number | null
/** 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[] = (([

View File

@ -32,6 +32,10 @@ export interface ItemOverride {
deliveryBaseOverride?: number | null
/** Override per-mile rate in cents for this item (replaces the tier default). */
deliveryPerMileOverride?: number | null
/** 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>