From f5ef16578ec85134410eb73349b5a9a422002708 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Tue, 3 Mar 2026 10:49:14 -0600 Subject: [PATCH 1/2] use store tz for pickup times --- examples/nextjs/app/page.tsx | 2 +- .../pickup/utils/build-pickup-payload.test.ts | 170 ++++++++++++++++++ .../pickup/utils/build-pickup-payload.ts | 42 +++-- 3 files changed, 200 insertions(+), 14 deletions(-) create mode 100644 packages/react/src/components/checkout/pickup/utils/build-pickup-payload.test.ts diff --git a/examples/nextjs/app/page.tsx b/examples/nextjs/app/page.tsx index 28e0c01c..ba3079a8 100644 --- a/examples/nextjs/app/page.tsx +++ b/examples/nextjs/app/page.tsx @@ -68,7 +68,7 @@ export default async function Home() { }, paypal: { processor: 'paypal', - checkoutTypes: ['express', 'standard'], + checkoutTypes: ['standard'], }, }, operatingHours: { diff --git a/packages/react/src/components/checkout/pickup/utils/build-pickup-payload.test.ts b/packages/react/src/components/checkout/pickup/utils/build-pickup-payload.test.ts new file mode 100644 index 00000000..002ab5bd --- /dev/null +++ b/packages/react/src/components/checkout/pickup/utils/build-pickup-payload.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildPickupPayload } from './build-pickup-payload'; + +describe('buildPickupPayload', () => { + describe('date + time (scheduled pickup)', () => { + it('should produce the correct ISO string in the store timezone', () => { + const result = buildPickupPayload({ + pickupDate: '2026-03-26', + pickupTime: '13:00', + pickupLocationId: 'loc-1', + timezone: 'America/New_York', + }); + + // March 26 1:00 PM EDT = 2026-03-26T13:00:00-04:00 + expect(result.fulfillmentStartAt).toBe('2026-03-26T13:00:00-04:00'); + expect(result.fulfillmentEndAt).toBe('2026-03-26T13:00:00-04:00'); + expect(result.fulfillmentLocationId).toBe('loc-1'); + }); + + it('should handle a timezone far from the browser timezone', () => { + const result = buildPickupPayload({ + pickupDate: '2026-03-26', + pickupTime: '09:30', + pickupLocationId: 'loc-2', + timezone: 'Asia/Kolkata', + }); + + // March 26 9:30 AM IST = 2026-03-26T09:30:00+05:30 + expect(result.fulfillmentStartAt).toBe('2026-03-26T09:30:00+05:30'); + expect(result.fulfillmentEndAt).toBe('2026-03-26T09:30:00+05:30'); + }); + + it('should handle UTC timezone', () => { + const result = buildPickupPayload({ + pickupDate: '2026-07-15', + pickupTime: '18:45', + pickupLocationId: 'loc-3', + timezone: 'UTC', + }); + + // XXX token outputs "Z" for UTC + expect(result.fulfillmentStartAt).toBe('2026-07-15T18:45:00Z'); + expect(result.fulfillmentEndAt).toBe('2026-07-15T18:45:00Z'); + }); + + it('should handle a Date object for pickupDate', () => { + // Date object for March 26, 2026 (local fields are what matter) + const dateObj = new Date(2026, 2, 26); + + const result = buildPickupPayload({ + pickupDate: dateObj, + pickupTime: '14:00', + pickupLocationId: 'loc-4', + timezone: 'America/Chicago', + }); + + // March 26 2:00 PM CDT = 2026-03-26T14:00:00-05:00 + expect(result.fulfillmentStartAt).toBe('2026-03-26T14:00:00-05:00'); + }); + + it('should default missing hours/minutes to 0', () => { + const result = buildPickupPayload({ + pickupDate: '2026-06-01', + pickupTime: ':', + pickupLocationId: 'loc-5', + timezone: 'America/Los_Angeles', + }); + + // Midnight PDT + expect(result.fulfillmentStartAt).toBe('2026-06-01T00:00:00-07:00'); + }); + + it('should handle DST boundary correctly', () => { + // Nov 1 2026 — US falls back from EDT to EST + const result = buildPickupPayload({ + pickupDate: '2026-11-02', + pickupTime: '10:00', + pickupLocationId: 'loc-6', + timezone: 'America/New_York', + }); + + // After fall-back, EST = UTC-5 + expect(result.fulfillmentStartAt).toBe('2026-11-02T10:00:00-05:00'); + }); + }); + + describe('date only (no time)', () => { + it('should default to midnight in the store timezone', () => { + const result = buildPickupPayload({ + pickupDate: '2026-04-10', + pickupTime: null, + pickupLocationId: 'loc-7', + timezone: 'America/New_York', + }); + + expect(result.fulfillmentStartAt).toBe('2026-04-10T00:00:00-04:00'); + }); + }); + + describe('ASAP', () => { + it('should add lead time and use the store timezone', () => { + const fakeNow = new Date('2026-03-26T17:00:00.000Z'); // 1 PM EDT + vi.useFakeTimers({ now: fakeNow }); + + const result = buildPickupPayload({ + pickupTime: 'ASAP', + pickupLocationId: 'loc-8', + leadTime: 30, + timezone: 'America/New_York', + }); + + // 1:00 PM + 30 min = 1:30 PM EDT + expect(result.fulfillmentStartAt).toBe('2026-03-26T13:30:00-04:00'); + + vi.useRealTimers(); + }); + }); + + describe('no date and no time (fallback)', () => { + it('should use current time in the store timezone', () => { + const fakeNow = new Date('2026-03-26T20:00:00.000Z'); // 4 PM EDT + vi.useFakeTimers({ now: fakeNow }); + + const result = buildPickupPayload({ + pickupLocationId: 'loc-9', + timezone: 'America/New_York', + }); + + expect(result.fulfillmentStartAt).toBe('2026-03-26T16:00:00-04:00'); + + vi.useRealTimers(); + }); + }); + + describe('timezone defaults', () => { + it('should fall back to UTC when timezone is null', () => { + const result = buildPickupPayload({ + pickupDate: '2026-03-26', + pickupTime: '13:00', + pickupLocationId: 'loc-10', + timezone: null, + }); + + expect(result.fulfillmentStartAt).toBe('2026-03-26T13:00:00Z'); + }); + }); + + describe('fulfillmentLocationId', () => { + it('should pass through the location id', () => { + const result = buildPickupPayload({ + pickupDate: '2026-03-26', + pickupTime: '13:00', + pickupLocationId: 'my-store', + timezone: 'UTC', + }); + + expect(result.fulfillmentLocationId).toBe('my-store'); + }); + + it('should default to null when location id is not provided', () => { + const result = buildPickupPayload({ + pickupDate: '2026-03-26', + pickupTime: '13:00', + timezone: 'UTC', + }); + + expect(result.fulfillmentLocationId).toBeNull(); + }); + }); +}); diff --git a/packages/react/src/components/checkout/pickup/utils/build-pickup-payload.ts b/packages/react/src/components/checkout/pickup/utils/build-pickup-payload.ts index 410b291b..16787be0 100644 --- a/packages/react/src/components/checkout/pickup/utils/build-pickup-payload.ts +++ b/packages/react/src/components/checkout/pickup/utils/build-pickup-payload.ts @@ -1,4 +1,4 @@ -import { format as formatTz, toZonedTime } from 'date-fns-tz'; +import { format as formatTz, fromZonedTime, toZonedTime } from 'date-fns-tz'; type FormFields = { pickupDate?: string | Date | null; @@ -14,9 +14,18 @@ type PickupPayload = { fulfillmentLocationId: string | null; }; -function parseDate(dateStr: string): Date { - const [year, month, day] = dateStr.split('-').map(Number); - return new Date(year, month - 1, day); +/** + * Extract a yyyy-MM-dd date string from either a string or Date. + * When given a Date, reads its local (runtime) fields — this is safe + * because the calendar UI stores dates as yyyy-MM-dd strings or as + * midnight-local Date objects whose year/month/day are always correct. + */ +function toDateString(pickupDate: string | Date): string { + if (typeof pickupDate === 'string') return pickupDate; + const y = pickupDate.getFullYear(); + const m = String(pickupDate.getMonth() + 1).padStart(2, '0'); + const d = String(pickupDate.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; } export function buildPickupPayload({ @@ -26,27 +35,34 @@ export function buildPickupPayload({ leadTime = 0, timezone = 'UTC', }: FormFields): PickupPayload { + const tz = timezone ?? 'UTC'; let date: Date; if (pickupTime === 'ASAP') { const now = new Date(); now.setMinutes(now.getMinutes() + leadTime); - date = toZonedTime(now, timezone ?? 'UTC'); + date = toZonedTime(now, tz); } else if (pickupDate && pickupTime) { - const baseDate = - typeof pickupDate === 'string' ? parseDate(pickupDate) : pickupDate; + const dateStr = toDateString(pickupDate); const [hours, minutes] = pickupTime.split(':').map(Number); - const zonedDate = toZonedTime(baseDate, timezone ?? 'UTC'); - zonedDate.setHours(hours || 0, minutes || 0, 0, 0); - date = zonedDate; + const h = String(hours || 0).padStart(2, '0'); + const m = String(minutes || 0).padStart(2, '0'); + + // Build the wall-clock datetime in the store timezone, then convert to + // a correct UTC instant via fromZonedTime before creating the zoned + // representation that formatTz expects. + const utcDate = fromZonedTime(`${dateStr}T${h}:${m}:00`, tz); + date = toZonedTime(utcDate, tz); } else if (pickupDate) { - date = typeof pickupDate === 'string' ? parseDate(pickupDate) : pickupDate; + const dateStr = toDateString(pickupDate); + const utcDate = fromZonedTime(`${dateStr}T00:00:00`, tz); + date = toZonedTime(utcDate, tz); } else { - date = new Date(); + date = toZonedTime(new Date(), tz); } const isoString = formatTz(date, "yyyy-MM-dd'T'HH:mm:ssXXX", { - timeZone: timezone ?? 'UTC', + timeZone: tz, }); return { From 210505e1f393b1354ab91d0468c8e0ba06305d04 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Tue, 3 Mar 2026 10:50:11 -0600 Subject: [PATCH 2/2] add changeset --- .changeset/clever-sloths-behave.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clever-sloths-behave.md diff --git a/.changeset/clever-sloths-behave.md b/.changeset/clever-sloths-behave.md new file mode 100644 index 00000000..16b644d6 --- /dev/null +++ b/.changeset/clever-sloths-behave.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +Format local pickup times to store timezone