diff --git a/estore/src/app/api/checkout/route.ts b/estore/src/app/api/checkout/route.ts index d78054f..0789e71 100644 --- a/estore/src/app/api/checkout/route.ts +++ b/estore/src/app/api/checkout/route.ts @@ -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 diff --git a/estore/src/app/api/delivery-quote/route.ts b/estore/src/app/api/delivery-quote/route.ts index abf64e0..24fa4c0 100644 --- a/estore/src/app/api/delivery-quote/route.ts +++ b/estore/src/app/api/delivery-quote/route.ts @@ -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, diff --git a/estore/src/app/api/hours/route.ts b/estore/src/app/api/hours/route.ts new file mode 100644 index 0000000..69b2b01 --- /dev/null +++ b/estore/src/app/api/hours/route.ts @@ -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()) +} diff --git a/estore/src/app/globals.css b/estore/src/app/globals.css index 7469cf2..8824054 100644 --- a/estore/src/app/globals.css +++ b/estore/src/app/globals.css @@ -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; diff --git a/estore/src/app/terms/page.tsx b/estore/src/app/terms/page.tsx index d2d6ab8..ed895cd 100644 --- a/estore/src/app/terms/page.tsx +++ b/estore/src/app/terms/page.tsx @@ -76,7 +76,7 @@ export default function TermsPage() {

Delivery

- 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.

diff --git a/estore/src/components/AdminColorFilter.tsx b/estore/src/components/AdminColorFilter.tsx index fa690c2..a09ab66 100644 --- a/estore/src/components/AdminColorFilter.tsx +++ b/estore/src/components/AdminColorFilter.tsx @@ -142,7 +142,7 @@ export default function AdminColorFilter({ disabledColors, onSave, onClose }: Pr

{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 (
)} {/* eslint-disable-next-line @next/next/no-img-element */} - +
(null) + const [hoursConfig, setHoursConfig] = useState(undefined) + useEffect(() => { + fetch(BASE + '/api/hours') + .then(r => r.ok ? r.json() : null) + .then(data => { if (data) setHoursConfig(data) }) + .catch(() => {}) + }, []) + const [step, setStep] = useState('cart') const [orderId, setOrderId] = useState(null) const [shortRef, setShortRef] = useState(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(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, diff --git a/estore/src/components/ColorPicker.tsx b/estore/src/components/ColorPicker.tsx index b02e3f8..3a6770a 100644 --- a/estore/src/components/ColorPicker.tsx +++ b/estore/src/components/ColorPicker.tsx @@ -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 (