fix: pre-launch audit, calendar closed days, delivery rate reset, and swatch paths
- Fix vinyl add-on checkout: product line item was dropped when vinyl selected; entryUnitPrice also excluded base product price - Store vinyl per-letter price on cart entry so CartDrawer charges the config price, not hardcoded 65¢ - Fix two bare modifiers.find() calls (use optional chaining) to prevent checkout crash on bad data - Validate deliveryCents (must be non-negative integer) and customer name fields (no control chars) in checkout API - Validate rateOverride values are non-negative numbers in delivery-quote API - Add RFC 5545 iCalendar escaping to SUMMARY/LOCATION/DESCRIPTION fields to prevent calendar injection - Add public /api/hours route; pickup and delivery calendars now fetch admin-saved hours and pre-grey closed days - Reset delivery quote and slot when high-rate item is removed from cart - Change delivery window copy from 2 hours to 1 hour (DeliveryDatePicker + terms page) - Fix SVG paths: /color/images/ → /color-picker/images/ (balloon mask, shine, color backgrounds); was causing Safari ? placeholders - Enlarge padlock icon in PaymentForm from 11px to 14px for better alignment Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
68a987a921
commit
9d02417059
@ -78,6 +78,15 @@ export async function POST(req: NextRequest) {
|
||||
if (deliveryNotes && typeof deliveryNotes === 'string' && deliveryNotes.length > 1000) {
|
||||
return NextResponse.json({ error: 'Delivery notes too long' }, { status: 400 })
|
||||
}
|
||||
if (deliveryCents !== undefined && (!Number.isInteger(deliveryCents) || deliveryCents < 0)) {
|
||||
return NextResponse.json({ error: 'Invalid delivery charge' }, { status: 400 })
|
||||
}
|
||||
if (customerFirstName && (typeof customerFirstName !== 'string' || customerFirstName.length > 100 || /[\r\n\x00-\x1f]/.test(customerFirstName))) {
|
||||
return NextResponse.json({ error: 'Invalid first name' }, { status: 400 })
|
||||
}
|
||||
if (customerLastName && (typeof customerLastName !== 'string' || customerLastName.length > 100 || /[\r\n\x00-\x1f]/.test(customerLastName))) {
|
||||
return NextResponse.json({ error: 'Invalid last name' }, { status: 400 })
|
||||
}
|
||||
|
||||
const customerName = [customerFirstName, customerLastName].filter(Boolean).join(' ') || undefined
|
||||
|
||||
|
||||
@ -23,6 +23,10 @@ export async function POST(request: Request) {
|
||||
|
||||
// Apply per-item rate override if provided (overrides just base and perMile for the inferred tier)
|
||||
if (rateOverride) {
|
||||
if (typeof rateOverride.base !== 'number' || rateOverride.base < 0 ||
|
||||
typeof rateOverride.perMile !== 'number' || rateOverride.perMile < 0) {
|
||||
return NextResponse.json({ error: 'Invalid rate override' }, { status: 400 })
|
||||
}
|
||||
rates[tier] = {
|
||||
...rates[tier],
|
||||
base: rateOverride.base,
|
||||
|
||||
8
estore/src/app/api/hours/route.ts
Normal file
8
estore/src/app/api/hours/route.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { NextResponse } from 'next/server'
|
||||
import { getHoursConfig } from '@/lib/hours'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json(getHoursConfig())
|
||||
}
|
||||
@ -135,11 +135,11 @@
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
transition: transform 0.2s ease;
|
||||
-webkit-mask-image: url('/color/images/balloon-mask.svg');
|
||||
-webkit-mask-image: url('/color-picker/images/balloon-mask.svg');
|
||||
-webkit-mask-size: contain;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position: center;
|
||||
mask-image: url('/color/images/balloon-mask.svg');
|
||||
mask-image: url('/color-picker/images/balloon-mask.svg');
|
||||
mask-size: contain;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
|
||||
@ -76,7 +76,7 @@ export default function TermsPage() {
|
||||
|
||||
<h2>Delivery</h2>
|
||||
<p>
|
||||
Balloon orders for delivery require a minimum 2-hour delivery window. Smaller delivery
|
||||
Balloon orders for delivery require a minimum 1-hour delivery window. Smaller delivery
|
||||
windows cannot be guaranteed for on-time arrival, as we account for potential delays.
|
||||
</p>
|
||||
<p>
|
||||
|
||||
@ -142,7 +142,7 @@ export default function AdminColorFilter({ disabledColors, onSave, onClose }: Pr
|
||||
<div className="swatch-container">
|
||||
{family.colors.map((color) => {
|
||||
const isDisabled = disabled.has(color.name)
|
||||
const imageSrc = color.image ? `/color/${color.image}` : null
|
||||
const imageSrc = color.image ? `/color-picker/${color.image}` : null
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -165,7 +165,7 @@ export default function AdminColorFilter({ disabledColors, onSave, onClose }: Pr
|
||||
/>
|
||||
)}
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img className="color-shine" src="/color/images/shine.svg" alt="" aria-hidden="true" />
|
||||
<img className="color-shine" src="/color-picker/images/shine.svg" alt="" aria-hidden="true" />
|
||||
</div>
|
||||
<span style={{
|
||||
fontSize: '0.6rem',
|
||||
|
||||
@ -1,13 +1,14 @@
|
||||
'use client'
|
||||
import { BASE } from '@/lib/basepath'
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import { useCart } from '@/context/CartContext'
|
||||
import type { DeliveryQuote } from '@/lib/delivery'
|
||||
import { fmt } from '@/lib/format'
|
||||
import DeliveryDatePicker from './DeliveryDatePicker'
|
||||
import type { DeliverySelection } from './DeliveryDatePicker'
|
||||
import { getPickupSlots } from '@/lib/slots'
|
||||
import type { HoursConfig } from '@/lib/hours-config'
|
||||
import PaymentForm from './PaymentForm'
|
||||
import type { CheckoutPayload } from './PaymentForm'
|
||||
import ColorPicker from './ColorPicker'
|
||||
@ -62,6 +63,14 @@ export default function CartDrawer() {
|
||||
|
||||
const [editingEntry, setEditingEntry] = useState<CartEntry | null>(null)
|
||||
|
||||
const [hoursConfig, setHoursConfig] = useState<HoursConfig | undefined>(undefined)
|
||||
useEffect(() => {
|
||||
fetch(BASE + '/api/hours')
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(data => { if (data) setHoursConfig(data) })
|
||||
.catch(() => {})
|
||||
}, [])
|
||||
|
||||
const [step, setStep] = useState<Step>('cart')
|
||||
const [orderId, setOrderId] = useState<string | null>(null)
|
||||
const [shortRef, setShortRef] = useState<string | null>(null)
|
||||
@ -93,6 +102,16 @@ export default function CartDrawer() {
|
||||
const [state, setState] = useStoredString('bpb_state', 'CT')
|
||||
const [zip, setZip] = useStoredString('bpb_zip', '')
|
||||
|
||||
// Reset quote whenever the rate override changes (e.g. high-rate item removed from cart)
|
||||
const prevRateOverrideRef = useRef(deliveryRateOverride)
|
||||
useEffect(() => {
|
||||
const prev = prevRateOverrideRef.current
|
||||
const curr = deliveryRateOverride
|
||||
const changed = prev?.base !== curr?.base || prev?.perMile !== curr?.perMile
|
||||
prevRateOverrideRef.current = curr
|
||||
if (changed) { setQuote(null); setQuoteErr(''); setDeliverySlot(null) }
|
||||
}, [deliveryRateOverride])
|
||||
|
||||
// Delivery step — ephemeral (quote resets on address change anyway)
|
||||
const [quote, setQuote] = useState<DeliveryQuote | null>(null)
|
||||
const [quoteErr, setQuoteErr] = useState('')
|
||||
@ -142,16 +161,12 @@ export default function CartDrawer() {
|
||||
|
||||
const fullAddress = [street, city, state, zip].filter(Boolean).join(', ')
|
||||
const canQuote = street.trim() && city.trim()
|
||||
const pickupSlots = useMemo(() => getPickupSlots(pickupDate), [pickupDate])
|
||||
const pickupSlots = useMemo(() => getPickupSlots(pickupDate, hoursConfig), [pickupDate, hoursConfig])
|
||||
const todayStr = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 10)
|
||||
const maxDateStr = new Date(Date.now() + 90 * 86400_000).toISOString().slice(0, 10)
|
||||
|
||||
// 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)
|
||||
@ -163,7 +178,10 @@ export default function CartDrawer() {
|
||||
return s + (opt?.priceDelta ?? 0)
|
||||
}, 0)
|
||||
}, 0)
|
||||
return base + modDelta
|
||||
const vinylAddon = (entry.vinylText && entry.vinylText !== '')
|
||||
? (entry.vinylShapePriceCents ?? 0) + entry.vinylText.replace(/ /g, '').length * (entry.vinylPricePerLetterCents ?? 65)
|
||||
: 0
|
||||
return base + modDelta + vinylAddon
|
||||
}, [])
|
||||
|
||||
// Pre-compute pickup disabled dates (closed days) for the next 90 days
|
||||
@ -172,10 +190,10 @@ export default function CartDrawer() {
|
||||
const base = Date.now() + 24 * 60 * 60 * 1000 // start from tomorrow
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const d = new Date(base + i * 86400_000).toISOString().slice(0, 10)
|
||||
if (getPickupSlots(d).length === 0) disabled.add(d)
|
||||
if (getPickupSlots(d, hoursConfig).length === 0) disabled.add(d)
|
||||
}
|
||||
return disabled
|
||||
}, [])
|
||||
}, [hoursConfig])
|
||||
|
||||
const CT_TAX_RATE = 0.0635
|
||||
const subtotal = entries.reduce((sum, e) => sum + entryUnitPrice(e) * e.quantity, 0)
|
||||
@ -189,8 +207,35 @@ export default function CartDrawer() {
|
||||
lineItems: entries.flatMap((e): LI[] => {
|
||||
if (e.vinylText && e.vinylShapeVariationId) {
|
||||
const letterCount = e.vinylText.replace(/ /g, '').length
|
||||
const vinylCents = letterCount * 65
|
||||
const vinylCents = letterCount * (e.vinylPricePerLetterCents ?? 65)
|
||||
const base = e.selectedVariationId
|
||||
? (e.product.variations.find((v) => v.id === e.selectedVariationId)?.priceCents ?? (e.product.price ?? 0))
|
||||
: (e.product.price ?? 0)
|
||||
const modDelta = Object.entries(e.modifierChoices).reduce((sum, [listId, optIds]) => {
|
||||
const ml = e.product.modifiers?.find((m) => m.id === listId)
|
||||
if (!ml) return sum
|
||||
return sum + optIds.reduce((s, optId) => {
|
||||
const opt = ml.options.find((o) => o.id === optId)
|
||||
return s + (opt?.priceDelta ?? 0)
|
||||
}, 0)
|
||||
}, 0)
|
||||
return [
|
||||
{
|
||||
name: e.product.name,
|
||||
quantity: e.quantity,
|
||||
priceCents: base + modDelta,
|
||||
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,
|
||||
}))
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: `18" ${e.vinylShapeName ?? 'Shape'} Balloon`,
|
||||
quantity: e.quantity,
|
||||
@ -206,7 +251,6 @@ export default function CartDrawer() {
|
||||
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 ?? '' }]
|
||||
@ -222,7 +266,7 @@ export default function CartDrawer() {
|
||||
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)
|
||||
const ml = e.product.modifiers?.find((m) => m.id === listId)
|
||||
if (!ml) return []
|
||||
return optIds.map((optId) => ({
|
||||
catalogObjectId: optId,
|
||||
|
||||
@ -457,7 +457,7 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
||||
{family.colors.map((color) => {
|
||||
const isChosen = selected.has(color.name)
|
||||
const disabled = atCap && !isChosen
|
||||
const imageSrc = color.image ? `/color/${color.image}` : null
|
||||
const imageSrc = color.image ? `/color-picker/${color.image}` : null
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -492,7 +492,7 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
||||
)}
|
||||
<img
|
||||
className="color-shine"
|
||||
src="/color/images/shine.svg"
|
||||
src="/color-picker/images/shine.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
/>
|
||||
@ -732,10 +732,11 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
||||
const vinylFields = product.vinylEnabled && wantsVinyl && vinylText && vinylShape ? {
|
||||
vinylText,
|
||||
vinylFontId,
|
||||
vinylFontName: selectedFont?.name,
|
||||
vinylShapeVariationId: vinylShape.variationId,
|
||||
vinylShapeName: vinylShape.name,
|
||||
vinylShapePriceCents: vinylShape.priceCents,
|
||||
vinylFontName: selectedFont?.name,
|
||||
vinylShapeVariationId: vinylShape.variationId,
|
||||
vinylShapeName: vinylShape.name,
|
||||
vinylShapePriceCents: vinylShape.priceCents,
|
||||
vinylPricePerLetterCents: vinylConfig?.pricePerLetterCents ?? 65,
|
||||
} : {}
|
||||
if (editingEntry) {
|
||||
updateEntry(editingEntry.cartId, { product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId, ...vinylFields })
|
||||
|
||||
@ -4,6 +4,7 @@ import { useState, useEffect } from 'react'
|
||||
import { BASE } from '@/lib/basepath'
|
||||
import type { DeliveryTier } from '@/lib/delivery'
|
||||
import type { TimeSlot } from '@/lib/slots'
|
||||
import type { HoursConfig } from '@/lib/hours-config'
|
||||
import CalendarPicker from './CalendarPicker'
|
||||
|
||||
export interface DeliverySelection {
|
||||
@ -38,6 +39,24 @@ export default function DeliveryDatePicker({ address, tier, value, onChange }: P
|
||||
// Busy dates from CalDAV — shown with an orange dot
|
||||
const [busyDates, setBusyDates] = useState<Set<string>>(new Set())
|
||||
|
||||
// Pre-mark all closed delivery days from admin hours config
|
||||
useEffect(() => {
|
||||
fetch(`${BASE}/api/hours`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then((config: HoursConfig | null) => {
|
||||
if (!config) return
|
||||
const closed = new Set<string>()
|
||||
const start = Date.now() + 24 * 60 * 60 * 1000
|
||||
for (let i = 0; i < 90; i++) {
|
||||
const dateStr = new Date(start + i * 86400_000).toISOString().slice(0, 10)
|
||||
const dow = new Date(`${dateStr}T12:00:00Z`).getUTCDay()
|
||||
if (!config.delivery[String(dow)]) closed.add(dateStr)
|
||||
}
|
||||
setNoSlotDates(closed)
|
||||
})
|
||||
.catch(() => {/* non-fatal */})
|
||||
}, [])
|
||||
|
||||
// Fetch busy dates once on mount
|
||||
useEffect(() => {
|
||||
if (!process.env.NEXT_PUBLIC_SITE_URL && typeof window === 'undefined') return
|
||||
@ -123,7 +142,7 @@ export default function DeliveryDatePicker({ address, tier, value, onChange }: P
|
||||
{status === 'loaded' && slots.length > 0 && (
|
||||
<>
|
||||
<p className="is-size-7 has-text-grey" style={{ marginBottom: '0.35rem' }}>
|
||||
~{drive} min drive · Please reserve <strong>at least 2 hours</strong> at your venue · select a start time
|
||||
~{drive} min drive · Please reserve <strong>at least 1 hour</strong> at your venue · select a start time
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '5px' }}>
|
||||
{slots.map((slot) => {
|
||||
|
||||
@ -253,8 +253,8 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
||||
Place Order · {fmt(payload.grandTotal)}
|
||||
</button>
|
||||
|
||||
<p style={{ fontSize: '0.72rem', color: '#888', textAlign: 'center', marginTop: '0.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px' }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<p style={{ fontSize: '0.72rem', color: '#888', textAlign: 'center', marginTop: '0.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '5px' }}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
|
||||
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||
</svg>
|
||||
256-bit SSL · Powered by Square · Your card info never touches our servers
|
||||
|
||||
@ -17,9 +17,10 @@ export interface CartEntry {
|
||||
vinylText?: string
|
||||
vinylFontId?: string // Square modifier option ID
|
||||
vinylFontName?: string
|
||||
vinylShapeVariationId?: string // Square variation ID (Heart/Star/Circle)
|
||||
vinylShapeName?: string
|
||||
vinylShapePriceCents?: number
|
||||
vinylShapeVariationId?: string // Square variation ID (Heart/Star/Circle)
|
||||
vinylShapeName?: string
|
||||
vinylShapePriceCents?: number
|
||||
vinylPricePerLetterCents?: number
|
||||
}
|
||||
|
||||
interface CartState {
|
||||
|
||||
@ -93,6 +93,15 @@ function formatPhone(raw: string): string {
|
||||
return raw
|
||||
}
|
||||
|
||||
/** Escape user-supplied text for iCalendar TEXT values (RFC 5545 §3.3.11) */
|
||||
function icalEscape(s: string): string {
|
||||
return s
|
||||
.replace(/\\/g, '\\\\')
|
||||
.replace(/;/g, '\\;')
|
||||
.replace(/,/g, '\\,')
|
||||
.replace(/\r\n|\r|\n/g, '\\n')
|
||||
}
|
||||
|
||||
function buildItemLines(lineItems: CalendarLineItem[]): string {
|
||||
return lineItems.map((li) => {
|
||||
const parts: string[] = [`${li.quantity} × ${li.name}`]
|
||||
@ -145,9 +154,9 @@ export async function createDeliveryEvent(params: {
|
||||
`DTSTAMP:${toIcalDate(new Date())}`,
|
||||
`DTSTART;TZID=America/New_York:${toIcalDateET(startTime)}`,
|
||||
`DTEND;TZID=America/New_York:${toIcalDateET(endTime)}`,
|
||||
foldLine(`SUMMARY:${customerName}`),
|
||||
foldLine(`LOCATION:${address}`),
|
||||
foldLine(`DESCRIPTION:${descParts}`),
|
||||
foldLine(`SUMMARY:${icalEscape(customerName)}`),
|
||||
foldLine(`LOCATION:${icalEscape(address)}`),
|
||||
foldLine(`DESCRIPTION:${icalEscape(descParts)}`),
|
||||
'STATUS:CONFIRMED',
|
||||
'TRANSP:OPAQUE',
|
||||
'END:VEVENT',
|
||||
@ -206,9 +215,9 @@ export async function createPickupEvent(params: {
|
||||
`DTSTAMP:${toIcalDate(new Date())}`,
|
||||
`DTSTART;TZID=America/New_York:${toIcalDateET(startTime)}`,
|
||||
`DTEND;TZID=America/New_York:${toIcalDateET(endTime)}`,
|
||||
foldLine(`SUMMARY:${customerName}`),
|
||||
foldLine(`SUMMARY:${icalEscape(customerName)}`),
|
||||
'LOCATION:Beach Party Balloons\\, 554 Boston Post Rd\\, Milford CT',
|
||||
foldLine(`DESCRIPTION:${descParts}`),
|
||||
foldLine(`DESCRIPTION:${icalEscape(descParts)}`),
|
||||
'STATUS:CONFIRMED',
|
||||
'TRANSP:TRANSPARENT',
|
||||
'END:VEVENT',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user