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:
parent
e34dfc397c
commit
1f1dabdb31
22
data/vinyl-config.json
Normal file
22
data/vinyl-config.json
Normal 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" }
|
||||
]
|
||||
}
|
||||
@ -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))
|
||||
|
||||
39
src/app/api/vinyl-config/route.ts
Normal file
39
src/app/api/vinyl-config/route.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
@ -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<ItemOverride> = {
|
||||
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({
|
||||
</label>
|
||||
</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 */}
|
||||
<div className="field">
|
||||
<label className="label is-small">Category</label>
|
||||
|
||||
@ -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,8 +155,37 @@ 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) => ({
|
||||
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),
|
||||
@ -167,7 +200,8 @@ export default function CartDrawer() {
|
||||
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,6 +301,15 @@ export default function CartDrawer() {
|
||||
<span style={{ fontSize: '0.82rem', color: '#666', marginLeft: '4px' }}>{fmt(entryUnitPrice(entry) * entry.quantity)}</span>
|
||||
)}
|
||||
</div>
|
||||
{entry.vinylText ? (
|
||||
<div style={{ fontSize: '0.8rem', color: '#5a3e9e', marginTop: '0.3rem', background: '#f3eeff', borderRadius: '6px', padding: '4px 8px' }}>
|
||||
<div>Shape: 18" {entry.vinylShapeName}</div>
|
||||
<div>Text: “{entry.vinylText}”</div>
|
||||
{entry.vinylFontName && <div>Font: {entry.vinylFontName}</div>}
|
||||
{entry.notes && <div style={{ fontStyle: 'italic', color: '#888' }}>“{entry.notes}”</div>}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{entry.selectedColors.length > 0 && (
|
||||
<div style={{ fontSize: '0.8rem', color: '#555', marginTop: '0.2rem' }}>
|
||||
Colors: {entry.selectedColors.join(', ')}
|
||||
@ -288,6 +331,8 @@ export default function CartDrawer() {
|
||||
“{entry.notes}”
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@ -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<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(() => {
|
||||
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 (
|
||||
<div className="modal is-active" onClick={onClose}>
|
||||
@ -225,6 +272,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'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' }}>
|
||||
@ -484,6 +538,113 @@ 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: 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" {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>
|
||||
@ -526,10 +687,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>
|
||||
)}
|
||||
@ -544,12 +707,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()
|
||||
}}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -42,6 +42,10 @@ export interface CatalogItem {
|
||||
variations: CatalogVariation[] // all enabled variations; first is the default
|
||||
/** Unit label for quantity, e.g. "ft". Omitted for plain count items. */
|
||||
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[] = (([
|
||||
|
||||
@ -20,6 +20,10 @@ export interface ItemOverride {
|
||||
chromeSurchargePerColor?: number
|
||||
/** Unit label for the quantity field, e.g. "ft". When set, the quantity control shows "X ft". */
|
||||
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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user