Skip to content

Commit c1ebb52

Browse files
Fix local pickup time offset (#1310)
* use store tz for pickup times * add changeset
1 parent eb64b7d commit c1ebb52

4 files changed

Lines changed: 205 additions & 14 deletions

File tree

.changeset/clever-sloths-behave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@godaddy/react": patch
3+
---
4+
5+
Format local pickup times to store timezone

examples/nextjs/app/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export default async function Home() {
6868
},
6969
paypal: {
7070
processor: 'paypal',
71-
checkoutTypes: ['express', 'standard'],
71+
checkoutTypes: ['standard'],
7272
},
7373
},
7474
operatingHours: {
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { buildPickupPayload } from './build-pickup-payload';
3+
4+
describe('buildPickupPayload', () => {
5+
describe('date + time (scheduled pickup)', () => {
6+
it('should produce the correct ISO string in the store timezone', () => {
7+
const result = buildPickupPayload({
8+
pickupDate: '2026-03-26',
9+
pickupTime: '13:00',
10+
pickupLocationId: 'loc-1',
11+
timezone: 'America/New_York',
12+
});
13+
14+
// March 26 1:00 PM EDT = 2026-03-26T13:00:00-04:00
15+
expect(result.fulfillmentStartAt).toBe('2026-03-26T13:00:00-04:00');
16+
expect(result.fulfillmentEndAt).toBe('2026-03-26T13:00:00-04:00');
17+
expect(result.fulfillmentLocationId).toBe('loc-1');
18+
});
19+
20+
it('should handle a timezone far from the browser timezone', () => {
21+
const result = buildPickupPayload({
22+
pickupDate: '2026-03-26',
23+
pickupTime: '09:30',
24+
pickupLocationId: 'loc-2',
25+
timezone: 'Asia/Kolkata',
26+
});
27+
28+
// March 26 9:30 AM IST = 2026-03-26T09:30:00+05:30
29+
expect(result.fulfillmentStartAt).toBe('2026-03-26T09:30:00+05:30');
30+
expect(result.fulfillmentEndAt).toBe('2026-03-26T09:30:00+05:30');
31+
});
32+
33+
it('should handle UTC timezone', () => {
34+
const result = buildPickupPayload({
35+
pickupDate: '2026-07-15',
36+
pickupTime: '18:45',
37+
pickupLocationId: 'loc-3',
38+
timezone: 'UTC',
39+
});
40+
41+
// XXX token outputs "Z" for UTC
42+
expect(result.fulfillmentStartAt).toBe('2026-07-15T18:45:00Z');
43+
expect(result.fulfillmentEndAt).toBe('2026-07-15T18:45:00Z');
44+
});
45+
46+
it('should handle a Date object for pickupDate', () => {
47+
// Date object for March 26, 2026 (local fields are what matter)
48+
const dateObj = new Date(2026, 2, 26);
49+
50+
const result = buildPickupPayload({
51+
pickupDate: dateObj,
52+
pickupTime: '14:00',
53+
pickupLocationId: 'loc-4',
54+
timezone: 'America/Chicago',
55+
});
56+
57+
// March 26 2:00 PM CDT = 2026-03-26T14:00:00-05:00
58+
expect(result.fulfillmentStartAt).toBe('2026-03-26T14:00:00-05:00');
59+
});
60+
61+
it('should default missing hours/minutes to 0', () => {
62+
const result = buildPickupPayload({
63+
pickupDate: '2026-06-01',
64+
pickupTime: ':',
65+
pickupLocationId: 'loc-5',
66+
timezone: 'America/Los_Angeles',
67+
});
68+
69+
// Midnight PDT
70+
expect(result.fulfillmentStartAt).toBe('2026-06-01T00:00:00-07:00');
71+
});
72+
73+
it('should handle DST boundary correctly', () => {
74+
// Nov 1 2026 — US falls back from EDT to EST
75+
const result = buildPickupPayload({
76+
pickupDate: '2026-11-02',
77+
pickupTime: '10:00',
78+
pickupLocationId: 'loc-6',
79+
timezone: 'America/New_York',
80+
});
81+
82+
// After fall-back, EST = UTC-5
83+
expect(result.fulfillmentStartAt).toBe('2026-11-02T10:00:00-05:00');
84+
});
85+
});
86+
87+
describe('date only (no time)', () => {
88+
it('should default to midnight in the store timezone', () => {
89+
const result = buildPickupPayload({
90+
pickupDate: '2026-04-10',
91+
pickupTime: null,
92+
pickupLocationId: 'loc-7',
93+
timezone: 'America/New_York',
94+
});
95+
96+
expect(result.fulfillmentStartAt).toBe('2026-04-10T00:00:00-04:00');
97+
});
98+
});
99+
100+
describe('ASAP', () => {
101+
it('should add lead time and use the store timezone', () => {
102+
const fakeNow = new Date('2026-03-26T17:00:00.000Z'); // 1 PM EDT
103+
vi.useFakeTimers({ now: fakeNow });
104+
105+
const result = buildPickupPayload({
106+
pickupTime: 'ASAP',
107+
pickupLocationId: 'loc-8',
108+
leadTime: 30,
109+
timezone: 'America/New_York',
110+
});
111+
112+
// 1:00 PM + 30 min = 1:30 PM EDT
113+
expect(result.fulfillmentStartAt).toBe('2026-03-26T13:30:00-04:00');
114+
115+
vi.useRealTimers();
116+
});
117+
});
118+
119+
describe('no date and no time (fallback)', () => {
120+
it('should use current time in the store timezone', () => {
121+
const fakeNow = new Date('2026-03-26T20:00:00.000Z'); // 4 PM EDT
122+
vi.useFakeTimers({ now: fakeNow });
123+
124+
const result = buildPickupPayload({
125+
pickupLocationId: 'loc-9',
126+
timezone: 'America/New_York',
127+
});
128+
129+
expect(result.fulfillmentStartAt).toBe('2026-03-26T16:00:00-04:00');
130+
131+
vi.useRealTimers();
132+
});
133+
});
134+
135+
describe('timezone defaults', () => {
136+
it('should fall back to UTC when timezone is null', () => {
137+
const result = buildPickupPayload({
138+
pickupDate: '2026-03-26',
139+
pickupTime: '13:00',
140+
pickupLocationId: 'loc-10',
141+
timezone: null,
142+
});
143+
144+
expect(result.fulfillmentStartAt).toBe('2026-03-26T13:00:00Z');
145+
});
146+
});
147+
148+
describe('fulfillmentLocationId', () => {
149+
it('should pass through the location id', () => {
150+
const result = buildPickupPayload({
151+
pickupDate: '2026-03-26',
152+
pickupTime: '13:00',
153+
pickupLocationId: 'my-store',
154+
timezone: 'UTC',
155+
});
156+
157+
expect(result.fulfillmentLocationId).toBe('my-store');
158+
});
159+
160+
it('should default to null when location id is not provided', () => {
161+
const result = buildPickupPayload({
162+
pickupDate: '2026-03-26',
163+
pickupTime: '13:00',
164+
timezone: 'UTC',
165+
});
166+
167+
expect(result.fulfillmentLocationId).toBeNull();
168+
});
169+
});
170+
});

packages/react/src/components/checkout/pickup/utils/build-pickup-payload.ts

Lines changed: 29 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { format as formatTz, toZonedTime } from 'date-fns-tz';
1+
import { format as formatTz, fromZonedTime, toZonedTime } from 'date-fns-tz';
22

33
type FormFields = {
44
pickupDate?: string | Date | null;
@@ -14,9 +14,18 @@ type PickupPayload = {
1414
fulfillmentLocationId: string | null;
1515
};
1616

17-
function parseDate(dateStr: string): Date {
18-
const [year, month, day] = dateStr.split('-').map(Number);
19-
return new Date(year, month - 1, day);
17+
/**
18+
* Extract a yyyy-MM-dd date string from either a string or Date.
19+
* When given a Date, reads its local (runtime) fields — this is safe
20+
* because the calendar UI stores dates as yyyy-MM-dd strings or as
21+
* midnight-local Date objects whose year/month/day are always correct.
22+
*/
23+
function toDateString(pickupDate: string | Date): string {
24+
if (typeof pickupDate === 'string') return pickupDate;
25+
const y = pickupDate.getFullYear();
26+
const m = String(pickupDate.getMonth() + 1).padStart(2, '0');
27+
const d = String(pickupDate.getDate()).padStart(2, '0');
28+
return `${y}-${m}-${d}`;
2029
}
2130

2231
export function buildPickupPayload({
@@ -26,27 +35,34 @@ export function buildPickupPayload({
2635
leadTime = 0,
2736
timezone = 'UTC',
2837
}: FormFields): PickupPayload {
38+
const tz = timezone ?? 'UTC';
2939
let date: Date;
3040

3141
if (pickupTime === 'ASAP') {
3242
const now = new Date();
3343
now.setMinutes(now.getMinutes() + leadTime);
34-
date = toZonedTime(now, timezone ?? 'UTC');
44+
date = toZonedTime(now, tz);
3545
} else if (pickupDate && pickupTime) {
36-
const baseDate =
37-
typeof pickupDate === 'string' ? parseDate(pickupDate) : pickupDate;
46+
const dateStr = toDateString(pickupDate);
3847
const [hours, minutes] = pickupTime.split(':').map(Number);
39-
const zonedDate = toZonedTime(baseDate, timezone ?? 'UTC');
40-
zonedDate.setHours(hours || 0, minutes || 0, 0, 0);
41-
date = zonedDate;
48+
const h = String(hours || 0).padStart(2, '0');
49+
const m = String(minutes || 0).padStart(2, '0');
50+
51+
// Build the wall-clock datetime in the store timezone, then convert to
52+
// a correct UTC instant via fromZonedTime before creating the zoned
53+
// representation that formatTz expects.
54+
const utcDate = fromZonedTime(`${dateStr}T${h}:${m}:00`, tz);
55+
date = toZonedTime(utcDate, tz);
4256
} else if (pickupDate) {
43-
date = typeof pickupDate === 'string' ? parseDate(pickupDate) : pickupDate;
57+
const dateStr = toDateString(pickupDate);
58+
const utcDate = fromZonedTime(`${dateStr}T00:00:00`, tz);
59+
date = toZonedTime(utcDate, tz);
4460
} else {
45-
date = new Date();
61+
date = toZonedTime(new Date(), tz);
4662
}
4763

4864
const isoString = formatTz(date, "yyyy-MM-dd'T'HH:mm:ssXXX", {
49-
timeZone: timezone ?? 'UTC',
65+
timeZone: tz,
5066
});
5167

5268
return {

0 commit comments

Comments
 (0)