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:
parent
ffd07e35bd
commit
bb6c8a03a7
@ -281,6 +281,26 @@ function HoursEditor() {
|
|||||||
</div>
|
</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'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 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
|
||||||
<button
|
<button
|
||||||
className={`button is-dark is-small${saving ? ' is-loading' : ''}`}
|
className={`button is-dark is-small${saving ? ' is-loading' : ''}`}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { createHash } from 'crypto'
|
import { createHash } from 'crypto'
|
||||||
import { inferTier, JOB_MINUTES, type DeliveryTier } from '@/lib/delivery'
|
import { inferTier, JOB_MINUTES, type DeliveryTier } from '@/lib/delivery'
|
||||||
|
import { getHoursConfig } from '@/lib/hours'
|
||||||
|
|
||||||
interface LineItem {
|
interface LineItem {
|
||||||
name: string
|
name: string
|
||||||
@ -98,11 +99,12 @@ export async function POST(req: NextRequest) {
|
|||||||
? deliveryTier
|
? deliveryTier
|
||||||
: inferTier(lineItems.map((l) => l.name))
|
: inferTier(lineItems.map((l) => l.name))
|
||||||
|
|
||||||
const arrivalISO = deliverySlotISO
|
const arrivalISO = deliverySlotISO
|
||||||
const jobMin = JOB_MINUTES[resolvedTier]
|
const jobMin = JOB_MINUTES[resolvedTier] // calendar event duration (internal)
|
||||||
const windowDurationStr = deliverySlotISO
|
const windowMin = getHoursConfig().deliveryWindowMinutes // customer-facing window
|
||||||
? `PT${Math.floor(jobMin / 60) > 0 ? `${Math.floor(jobMin / 60)}H` : ''}${jobMin % 60 > 0 ? `${jobMin % 60}M` : ''}`
|
const toIsoDuration = (m: number) =>
|
||||||
: undefined
|
`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) =>
|
const fmtSlot = (iso: string) =>
|
||||||
new Date(iso).toLocaleString('en-US', { timeZone: 'America/New_York', dateStyle: 'medium', timeStyle: 'short' })
|
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')
|
if (!captured) throw new Error('Payment capture returned no result')
|
||||||
|
|
||||||
// ── Fire-and-forget: emails only (calendar already written above) ────────
|
// ── Fire-and-forget: emails only (calendar already written above) ────────
|
||||||
const subtotalCents = lineItems.reduce((sum, li) => sum + li.priceCents * li.quantity, 0)
|
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 slotEndISO = deliverySlotISO
|
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
|
: undefined
|
||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|||||||
@ -108,8 +108,8 @@ function buildItemLines(lineItems: CalendarLineItem[]): string {
|
|||||||
if (li.modifiers?.length) parts.push(` ${li.modifiers.map((m) => m.name).join(', ')}`)
|
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.colors?.length) parts.push(` Colors: ${li.colors.join(', ')}`)
|
||||||
if (li.note) parts.push(` Note: ${li.note}`)
|
if (li.note) parts.push(` Note: ${li.note}`)
|
||||||
return parts.join('\\n')
|
return parts.join('\n')
|
||||||
}).join('\\n')
|
}).join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createDeliveryEvent(params: {
|
export async function createDeliveryEvent(params: {
|
||||||
@ -143,7 +143,7 @@ export async function createDeliveryEvent(params: {
|
|||||||
'',
|
'',
|
||||||
`Order: ${orderId}`,
|
`Order: ${orderId}`,
|
||||||
`Drive: ~${driveMinutes} min each way`,
|
`Drive: ~${driveMinutes} min each way`,
|
||||||
].filter((p) => p !== null).join('\\n')
|
].filter((p) => p !== null).join('\n')
|
||||||
|
|
||||||
const ical = [
|
const ical = [
|
||||||
'BEGIN:VCALENDAR',
|
'BEGIN:VCALENDAR',
|
||||||
@ -204,7 +204,7 @@ export async function createPickupEvent(params: {
|
|||||||
notes ? `Notes: ${notes}` : null,
|
notes ? `Notes: ${notes}` : null,
|
||||||
'',
|
'',
|
||||||
`Order: ${orderId}`,
|
`Order: ${orderId}`,
|
||||||
].filter((p) => p !== null).join('\\n')
|
].filter((p) => p !== null).join('\n')
|
||||||
|
|
||||||
const ical = [
|
const ical = [
|
||||||
'BEGIN:VCALENDAR',
|
'BEGIN:VCALENDAR',
|
||||||
|
|||||||
@ -8,11 +8,13 @@ export interface DayHours {
|
|||||||
export type WeekHours = Record<string, DayHours | null> // keys '0'–'6'
|
export type WeekHours = Record<string, DayHours | null> // keys '0'–'6'
|
||||||
|
|
||||||
export interface HoursConfig {
|
export interface HoursConfig {
|
||||||
delivery: WeekHours
|
delivery: WeekHours
|
||||||
pickup: WeekHours
|
pickup: WeekHours
|
||||||
|
deliveryWindowMinutes: number // customer-facing arrival window (email + Square)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DEFAULT_HOURS: HoursConfig = {
|
export const DEFAULT_HOURS: HoursConfig = {
|
||||||
|
deliveryWindowMinutes: 60,
|
||||||
delivery: {
|
delivery: {
|
||||||
'0': { open: 480, close: 1020 }, // Sun 8:00 AM – 5:00 PM
|
'0': { open: 480, close: 1020 }, // Sun 8:00 AM – 5:00 PM
|
||||||
'1': null, // Mon closed
|
'1': null, // Mon closed
|
||||||
|
|||||||
@ -14,8 +14,9 @@ export function getHoursConfig(): HoursConfig {
|
|||||||
try {
|
try {
|
||||||
const saved = JSON.parse(readFileSync(HOURS_PATH, 'utf-8')) as Partial<HoursConfig>
|
const saved = JSON.parse(readFileSync(HOURS_PATH, 'utf-8')) as Partial<HoursConfig>
|
||||||
return {
|
return {
|
||||||
delivery: { ...DEFAULT_HOURS.delivery, ...(saved.delivery ?? {}) },
|
delivery: { ...DEFAULT_HOURS.delivery, ...(saved.delivery ?? {}) },
|
||||||
pickup: { ...DEFAULT_HOURS.pickup, ...(saved.pickup ?? {}) },
|
pickup: { ...DEFAULT_HOURS.pickup, ...(saved.pickup ?? {}) },
|
||||||
|
deliveryWindowMinutes: saved.deliveryWindowMinutes ?? DEFAULT_HOURS.deliveryWindowMinutes,
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return DEFAULT_HOURS
|
return DEFAULT_HOURS
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user