fix: calendar newlines, admin delivery window setting

CalDAV: joins were using literal '\n' strings which icalEscape then
double-escaped the backslash, so calendar entries showed raw \n. Now
joins use real newline chars which icalEscape converts correctly.

Added deliveryWindowMinutes to HoursConfig (default 60 min). The
checkout route reads this at request time to set both the Square
deliveryWindowDuration and the customer email arrival window. Admin
hours page now has a number input to configure it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chris 2026-05-05 10:57:37 -04:00
parent ffd07e35bd
commit bb6c8a03a7
5 changed files with 40 additions and 16 deletions

View File

@ -281,6 +281,26 @@ function HoursEditor() {
</div>
))}
<div style={{ marginBottom: '1.5rem' }}>
<label style={{ fontWeight: 600, fontSize: '0.85rem', display: 'block', marginBottom: '0.4rem' }}>
Delivery arrival window (minutes)
</label>
<p style={{ fontSize: '0.78rem', color: '#888', marginBottom: '0.5rem' }}>
How long the customer&apos;s arrival window is shown on their confirmation email and sent to Square.
</p>
<input
type="number"
className="input is-small"
min={15}
max={480}
step={15}
value={config.deliveryWindowMinutes ?? 60}
onChange={(e) => setConfig((prev) => prev ? { ...prev, deliveryWindowMinutes: Math.max(15, parseInt(e.target.value) || 60) } : prev)}
style={{ width: 100 }}
/>
<span style={{ marginLeft: 8, fontSize: '0.82rem', color: '#666' }}>min</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<button
className={`button is-dark is-small${saving ? ' is-loading' : ''}`}

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { createHash } from 'crypto'
import { inferTier, JOB_MINUTES, type DeliveryTier } from '@/lib/delivery'
import { getHoursConfig } from '@/lib/hours'
interface LineItem {
name: string
@ -98,11 +99,12 @@ export async function POST(req: NextRequest) {
? deliveryTier
: inferTier(lineItems.map((l) => l.name))
const arrivalISO = deliverySlotISO
const jobMin = JOB_MINUTES[resolvedTier]
const windowDurationStr = deliverySlotISO
? `PT${Math.floor(jobMin / 60) > 0 ? `${Math.floor(jobMin / 60)}H` : ''}${jobMin % 60 > 0 ? `${jobMin % 60}M` : ''}`
: undefined
const arrivalISO = deliverySlotISO
const jobMin = JOB_MINUTES[resolvedTier] // calendar event duration (internal)
const windowMin = getHoursConfig().deliveryWindowMinutes // customer-facing window
const toIsoDuration = (m: number) =>
`PT${Math.floor(m / 60) > 0 ? `${Math.floor(m / 60)}H` : ''}${m % 60 > 0 ? `${m % 60}M` : ''}`
const windowDurationStr = deliverySlotISO ? toIsoDuration(windowMin) : undefined
const fmtSlot = (iso: string) =>
new Date(iso).toLocaleString('en-US', { timeZone: 'America/New_York', dateStyle: 'medium', timeStyle: 'short' })
@ -386,10 +388,9 @@ export async function POST(req: NextRequest) {
if (!captured) throw new Error('Payment capture returned no result')
// ── Fire-and-forget: emails only (calendar already written above) ────────
const subtotalCents = lineItems.reduce((sum, li) => sum + li.priceCents * li.quantity, 0)
const CUSTOMER_WINDOW_MIN = 60 // 1-hour arrival window shown to customer
const subtotalCents = lineItems.reduce((sum, li) => sum + li.priceCents * li.quantity, 0)
const slotEndISO = deliverySlotISO
? new Date(new Date(deliverySlotISO).getTime() + CUSTOMER_WINDOW_MIN * 60_000).toISOString()
? new Date(new Date(deliverySlotISO).getTime() + windowMin * 60_000).toISOString()
: undefined
void (async () => {

View File

@ -108,8 +108,8 @@ function buildItemLines(lineItems: CalendarLineItem[]): string {
if (li.modifiers?.length) parts.push(` ${li.modifiers.map((m) => m.name).join(', ')}`)
if (li.colors?.length) parts.push(` Colors: ${li.colors.join(', ')}`)
if (li.note) parts.push(` Note: ${li.note}`)
return parts.join('\\n')
}).join('\\n')
return parts.join('\n')
}).join('\n')
}
export async function createDeliveryEvent(params: {
@ -143,7 +143,7 @@ export async function createDeliveryEvent(params: {
'',
`Order: ${orderId}`,
`Drive: ~${driveMinutes} min each way`,
].filter((p) => p !== null).join('\\n')
].filter((p) => p !== null).join('\n')
const ical = [
'BEGIN:VCALENDAR',
@ -204,7 +204,7 @@ export async function createPickupEvent(params: {
notes ? `Notes: ${notes}` : null,
'',
`Order: ${orderId}`,
].filter((p) => p !== null).join('\\n')
].filter((p) => p !== null).join('\n')
const ical = [
'BEGIN:VCALENDAR',

View File

@ -8,11 +8,13 @@ export interface DayHours {
export type WeekHours = Record<string, DayHours | null> // keys '0''6'
export interface HoursConfig {
delivery: WeekHours
pickup: WeekHours
delivery: WeekHours
pickup: WeekHours
deliveryWindowMinutes: number // customer-facing arrival window (email + Square)
}
export const DEFAULT_HOURS: HoursConfig = {
deliveryWindowMinutes: 60,
delivery: {
'0': { open: 480, close: 1020 }, // Sun 8:00 AM 5:00 PM
'1': null, // Mon closed

View File

@ -14,8 +14,9 @@ export function getHoursConfig(): HoursConfig {
try {
const saved = JSON.parse(readFileSync(HOURS_PATH, 'utf-8')) as Partial<HoursConfig>
return {
delivery: { ...DEFAULT_HOURS.delivery, ...(saved.delivery ?? {}) },
pickup: { ...DEFAULT_HOURS.pickup, ...(saved.pickup ?? {}) },
delivery: { ...DEFAULT_HOURS.delivery, ...(saved.delivery ?? {}) },
pickup: { ...DEFAULT_HOURS.pickup, ...(saved.pickup ?? {}) },
deliveryWindowMinutes: saved.deliveryWindowMinutes ?? DEFAULT_HOURS.deliveryWindowMinutes,
}
} catch {
return DEFAULT_HOURS