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:
chris 2026-05-05 09:22:42 -04:00
parent 68a987a921
commit 9d02417059
12 changed files with 129 additions and 34 deletions

View File

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

View File

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

View 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())
}

View File

@ -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;

View File

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

View File

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

View File

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

View File

@ -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"
/> />
@ -736,6 +736,7 @@ export default function ColorPicker({ product, maxColors, onClose, editingEntry
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 })

View File

@ -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 &nbsp;·&nbsp; Please reserve <strong>at least 2 hours</strong> at your venue &nbsp;·&nbsp; select a start time ~{drive} min drive &nbsp;·&nbsp; Please reserve <strong>at least 1 hour</strong> at your venue &nbsp;·&nbsp; 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) => {

View File

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

View File

@ -20,6 +20,7 @@ export interface CartEntry {
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 {

View File

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