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) {
|
if (deliveryNotes && typeof deliveryNotes === 'string' && deliveryNotes.length > 1000) {
|
||||||
return NextResponse.json({ error: 'Delivery notes too long' }, { status: 400 })
|
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
|
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)
|
// Apply per-item rate override if provided (overrides just base and perMile for the inferred tier)
|
||||||
if (rateOverride) {
|
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] = {
|
||||||
...rates[tier],
|
...rates[tier],
|
||||||
base: rateOverride.base,
|
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;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
transition: transform 0.2s ease;
|
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-size: contain;
|
||||||
-webkit-mask-repeat: no-repeat;
|
-webkit-mask-repeat: no-repeat;
|
||||||
-webkit-mask-position: center;
|
-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-size: contain;
|
||||||
mask-repeat: no-repeat;
|
mask-repeat: no-repeat;
|
||||||
mask-position: center;
|
mask-position: center;
|
||||||
|
|||||||
@ -76,7 +76,7 @@ export default function TermsPage() {
|
|||||||
|
|
||||||
<h2>Delivery</h2>
|
<h2>Delivery</h2>
|
||||||
<p>
|
<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.
|
windows cannot be guaranteed for on-time arrival, as we account for potential delays.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
|
|||||||
@ -142,7 +142,7 @@ export default function AdminColorFilter({ disabledColors, onSave, onClose }: Pr
|
|||||||
<div className="swatch-container">
|
<div className="swatch-container">
|
||||||
{family.colors.map((color) => {
|
{family.colors.map((color) => {
|
||||||
const isDisabled = disabled.has(color.name)
|
const isDisabled = disabled.has(color.name)
|
||||||
const imageSrc = color.image ? `/color/${color.image}` : null
|
const imageSrc = color.image ? `/color-picker/${color.image}` : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -165,7 +165,7 @@ export default function AdminColorFilter({ disabledColors, onSave, onClose }: Pr
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* 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>
|
</div>
|
||||||
<span style={{
|
<span style={{
|
||||||
fontSize: '0.6rem',
|
fontSize: '0.6rem',
|
||||||
|
|||||||
@ -1,13 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
import { BASE } from '@/lib/basepath'
|
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 { useCart } from '@/context/CartContext'
|
||||||
import type { DeliveryQuote } from '@/lib/delivery'
|
import type { DeliveryQuote } from '@/lib/delivery'
|
||||||
import { fmt } from '@/lib/format'
|
import { fmt } from '@/lib/format'
|
||||||
import DeliveryDatePicker from './DeliveryDatePicker'
|
import DeliveryDatePicker from './DeliveryDatePicker'
|
||||||
import type { DeliverySelection } from './DeliveryDatePicker'
|
import type { DeliverySelection } from './DeliveryDatePicker'
|
||||||
import { getPickupSlots } from '@/lib/slots'
|
import { getPickupSlots } from '@/lib/slots'
|
||||||
|
import type { HoursConfig } from '@/lib/hours-config'
|
||||||
import PaymentForm from './PaymentForm'
|
import PaymentForm from './PaymentForm'
|
||||||
import type { CheckoutPayload } from './PaymentForm'
|
import type { CheckoutPayload } from './PaymentForm'
|
||||||
import ColorPicker from './ColorPicker'
|
import ColorPicker from './ColorPicker'
|
||||||
@ -62,6 +63,14 @@ export default function CartDrawer() {
|
|||||||
|
|
||||||
const [editingEntry, setEditingEntry] = useState<CartEntry | null>(null)
|
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 [step, setStep] = useState<Step>('cart')
|
||||||
const [orderId, setOrderId] = useState<string | null>(null)
|
const [orderId, setOrderId] = useState<string | null>(null)
|
||||||
const [shortRef, setShortRef] = 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 [state, setState] = useStoredString('bpb_state', 'CT')
|
||||||
const [zip, setZip] = useStoredString('bpb_zip', '')
|
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)
|
// Delivery step — ephemeral (quote resets on address change anyway)
|
||||||
const [quote, setQuote] = useState<DeliveryQuote | null>(null)
|
const [quote, setQuote] = useState<DeliveryQuote | null>(null)
|
||||||
const [quoteErr, setQuoteErr] = useState('')
|
const [quoteErr, setQuoteErr] = useState('')
|
||||||
@ -142,16 +161,12 @@ export default function CartDrawer() {
|
|||||||
|
|
||||||
const fullAddress = [street, city, state, zip].filter(Boolean).join(', ')
|
const fullAddress = [street, city, state, zip].filter(Boolean).join(', ')
|
||||||
const canQuote = street.trim() && city.trim()
|
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 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)
|
const maxDateStr = new Date(Date.now() + 90 * 86400_000).toISOString().slice(0, 10)
|
||||||
|
|
||||||
// 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)
|
||||||
@ -163,7 +178,10 @@ export default function CartDrawer() {
|
|||||||
return s + (opt?.priceDelta ?? 0)
|
return s + (opt?.priceDelta ?? 0)
|
||||||
}, 0)
|
}, 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
|
// 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
|
const base = Date.now() + 24 * 60 * 60 * 1000 // start from tomorrow
|
||||||
for (let i = 0; i < 90; i++) {
|
for (let i = 0; i < 90; i++) {
|
||||||
const d = new Date(base + i * 86400_000).toISOString().slice(0, 10)
|
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
|
return disabled
|
||||||
}, [])
|
}, [hoursConfig])
|
||||||
|
|
||||||
const CT_TAX_RATE = 0.0635
|
const CT_TAX_RATE = 0.0635
|
||||||
const subtotal = entries.reduce((sum, e) => sum + entryUnitPrice(e) * e.quantity, 0)
|
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[] => {
|
lineItems: entries.flatMap((e): LI[] => {
|
||||||
if (e.vinylText && e.vinylShapeVariationId) {
|
if (e.vinylText && e.vinylShapeVariationId) {
|
||||||
const letterCount = e.vinylText.replace(/ /g, '').length
|
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 [
|
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`,
|
name: `18" ${e.vinylShapeName ?? 'Shape'} Balloon`,
|
||||||
quantity: e.quantity,
|
quantity: e.quantity,
|
||||||
@ -206,7 +251,6 @@ export default function CartDrawer() {
|
|||||||
note: [
|
note: [
|
||||||
`Text: "${e.vinylText}"`,
|
`Text: "${e.vinylText}"`,
|
||||||
e.vinylFontName ? `Font: ${e.vinylFontName}` : null,
|
e.vinylFontName ? `Font: ${e.vinylFontName}` : null,
|
||||||
e.notes || null,
|
|
||||||
].filter(Boolean).join(' | ') || undefined,
|
].filter(Boolean).join(' | ') || undefined,
|
||||||
modifiers: e.vinylFontId
|
modifiers: e.vinylFontId
|
||||||
? [{ catalogObjectId: e.vinylFontId, name: e.vinylFontName ?? '' }]
|
? [{ catalogObjectId: e.vinylFontId, name: e.vinylFontName ?? '' }]
|
||||||
@ -222,7 +266,7 @@ export default function CartDrawer() {
|
|||||||
colors: e.selectedColors.length ? e.selectedColors : undefined,
|
colors: e.selectedColors.length ? e.selectedColors : undefined,
|
||||||
note: e.notes || undefined,
|
note: e.notes || undefined,
|
||||||
modifiers: Object.entries(e.modifierChoices).flatMap(([listId, optIds]) => {
|
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 []
|
if (!ml) return []
|
||||||
return optIds.map((optId) => ({
|
return optIds.map((optId) => ({
|
||||||
catalogObjectId: optId,
|
catalogObjectId: optId,
|
||||||
|
|||||||
@ -457,7 +457,7 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
|||||||
{family.colors.map((color) => {
|
{family.colors.map((color) => {
|
||||||
const isChosen = selected.has(color.name)
|
const isChosen = selected.has(color.name)
|
||||||
const disabled = atCap && !isChosen
|
const disabled = atCap && !isChosen
|
||||||
const imageSrc = color.image ? `/color/${color.image}` : null
|
const imageSrc = color.image ? `/color-picker/${color.image}` : null
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -492,7 +492,7 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
|||||||
)}
|
)}
|
||||||
<img
|
<img
|
||||||
className="color-shine"
|
className="color-shine"
|
||||||
src="/color/images/shine.svg"
|
src="/color-picker/images/shine.svg"
|
||||||
alt=""
|
alt=""
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
@ -732,10 +732,11 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
|
|||||||
const vinylFields = product.vinylEnabled && wantsVinyl && vinylText && vinylShape ? {
|
const vinylFields = product.vinylEnabled && wantsVinyl && vinylText && vinylShape ? {
|
||||||
vinylText,
|
vinylText,
|
||||||
vinylFontId,
|
vinylFontId,
|
||||||
vinylFontName: selectedFont?.name,
|
vinylFontName: selectedFont?.name,
|
||||||
vinylShapeVariationId: vinylShape.variationId,
|
vinylShapeVariationId: vinylShape.variationId,
|
||||||
vinylShapeName: vinylShape.name,
|
vinylShapeName: vinylShape.name,
|
||||||
vinylShapePriceCents: vinylShape.priceCents,
|
vinylShapePriceCents: vinylShape.priceCents,
|
||||||
|
vinylPricePerLetterCents: vinylConfig?.pricePerLetterCents ?? 65,
|
||||||
} : {}
|
} : {}
|
||||||
if (editingEntry) {
|
if (editingEntry) {
|
||||||
updateEntry(editingEntry.cartId, { product, quantity, selectedColors: selectedNames, modifierChoices: choices, notes, selectedVariationId: storedVariationId, ...vinylFields })
|
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 { BASE } from '@/lib/basepath'
|
||||||
import type { DeliveryTier } from '@/lib/delivery'
|
import type { DeliveryTier } from '@/lib/delivery'
|
||||||
import type { TimeSlot } from '@/lib/slots'
|
import type { TimeSlot } from '@/lib/slots'
|
||||||
|
import type { HoursConfig } from '@/lib/hours-config'
|
||||||
import CalendarPicker from './CalendarPicker'
|
import CalendarPicker from './CalendarPicker'
|
||||||
|
|
||||||
export interface DeliverySelection {
|
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
|
// Busy dates from CalDAV — shown with an orange dot
|
||||||
const [busyDates, setBusyDates] = useState<Set<string>>(new Set())
|
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
|
// Fetch busy dates once on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!process.env.NEXT_PUBLIC_SITE_URL && typeof window === 'undefined') return
|
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 && (
|
{status === 'loaded' && slots.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="is-size-7 has-text-grey" style={{ marginBottom: '0.35rem' }}>
|
<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>
|
</p>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '5px' }}>
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '5px' }}>
|
||||||
{slots.map((slot) => {
|
{slots.map((slot) => {
|
||||||
|
|||||||
@ -253,8 +253,8 @@ export default function PaymentForm({ payload, onSuccess, active }: Props) {
|
|||||||
Place Order · {fmt(payload.grandTotal)}
|
Place Order · {fmt(payload.grandTotal)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p style={{ fontSize: '0.72rem', color: '#888', textAlign: 'center', marginTop: '0.5rem', display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px' }}>
|
<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="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
|
<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"/>
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>
|
||||||
</svg>
|
</svg>
|
||||||
256-bit SSL · Powered by Square · Your card info never touches our servers
|
256-bit SSL · Powered by Square · Your card info never touches our servers
|
||||||
|
|||||||
@ -17,9 +17,10 @@ export interface CartEntry {
|
|||||||
vinylText?: string
|
vinylText?: string
|
||||||
vinylFontId?: string // Square modifier option ID
|
vinylFontId?: string // Square modifier option ID
|
||||||
vinylFontName?: string
|
vinylFontName?: string
|
||||||
vinylShapeVariationId?: string // Square variation ID (Heart/Star/Circle)
|
vinylShapeVariationId?: string // Square variation ID (Heart/Star/Circle)
|
||||||
vinylShapeName?: string
|
vinylShapeName?: string
|
||||||
vinylShapePriceCents?: number
|
vinylShapePriceCents?: number
|
||||||
|
vinylPricePerLetterCents?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CartState {
|
interface CartState {
|
||||||
|
|||||||
@ -93,6 +93,15 @@ function formatPhone(raw: string): string {
|
|||||||
return raw
|
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 {
|
function buildItemLines(lineItems: CalendarLineItem[]): string {
|
||||||
return lineItems.map((li) => {
|
return lineItems.map((li) => {
|
||||||
const parts: string[] = [`${li.quantity} × ${li.name}`]
|
const parts: string[] = [`${li.quantity} × ${li.name}`]
|
||||||
@ -145,9 +154,9 @@ export async function createDeliveryEvent(params: {
|
|||||||
`DTSTAMP:${toIcalDate(new Date())}`,
|
`DTSTAMP:${toIcalDate(new Date())}`,
|
||||||
`DTSTART;TZID=America/New_York:${toIcalDateET(startTime)}`,
|
`DTSTART;TZID=America/New_York:${toIcalDateET(startTime)}`,
|
||||||
`DTEND;TZID=America/New_York:${toIcalDateET(endTime)}`,
|
`DTEND;TZID=America/New_York:${toIcalDateET(endTime)}`,
|
||||||
foldLine(`SUMMARY:${customerName}`),
|
foldLine(`SUMMARY:${icalEscape(customerName)}`),
|
||||||
foldLine(`LOCATION:${address}`),
|
foldLine(`LOCATION:${icalEscape(address)}`),
|
||||||
foldLine(`DESCRIPTION:${descParts}`),
|
foldLine(`DESCRIPTION:${icalEscape(descParts)}`),
|
||||||
'STATUS:CONFIRMED',
|
'STATUS:CONFIRMED',
|
||||||
'TRANSP:OPAQUE',
|
'TRANSP:OPAQUE',
|
||||||
'END:VEVENT',
|
'END:VEVENT',
|
||||||
@ -206,9 +215,9 @@ export async function createPickupEvent(params: {
|
|||||||
`DTSTAMP:${toIcalDate(new Date())}`,
|
`DTSTAMP:${toIcalDate(new Date())}`,
|
||||||
`DTSTART;TZID=America/New_York:${toIcalDateET(startTime)}`,
|
`DTSTART;TZID=America/New_York:${toIcalDateET(startTime)}`,
|
||||||
`DTEND;TZID=America/New_York:${toIcalDateET(endTime)}`,
|
`DTEND;TZID=America/New_York:${toIcalDateET(endTime)}`,
|
||||||
foldLine(`SUMMARY:${customerName}`),
|
foldLine(`SUMMARY:${icalEscape(customerName)}`),
|
||||||
'LOCATION:Beach Party Balloons\\, 554 Boston Post Rd\\, Milford CT',
|
'LOCATION:Beach Party Balloons\\, 554 Boston Post Rd\\, Milford CT',
|
||||||
foldLine(`DESCRIPTION:${descParts}`),
|
foldLine(`DESCRIPTION:${icalEscape(descParts)}`),
|
||||||
'STATUS:CONFIRMED',
|
'STATUS:CONFIRMED',
|
||||||
'TRANSP:TRANSPARENT',
|
'TRANSP:TRANSPARENT',
|
||||||
'END:VEVENT',
|
'END:VEVENT',
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user