Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/dry-bats-invent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@godaddy/react": patch
---

Add pickup date only mode
Infer ui extension cdn urls from apiHost
17 changes: 12 additions & 5 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,15 @@ The checkout session automatically requests the following OAuth2 scope:

### Operating Hours

The `operatingHours` field configures local pickup scheduling — time zones, lead times, pickup windows, and slot intervals.
The `operatingHours` field configures local pickup scheduling — time zones, lead times, pickup windows, pickup mode, and slot intervals.

```typescript
operatingHours: {
default: {
timeZone: 'America/New_York',
leadTime: 60,
pickupWindowInDays: 7,
pickupMode: 'dateAndTime',
pickupSlotInterval: 30,
hours: {
monday: { enabled: true, openTime: '09:00', closeTime: '17:00' },
Expand All @@ -117,16 +118,22 @@ operatingHours: {
|-------|------|----------|-------------|
| `timeZone` | string | Yes | IANA timezone for the store (e.g. `America/New_York`). All slot times are displayed in this timezone. |
| `leadTime` | number | Yes | Minimum advance notice in minutes before a pickup can be scheduled. Controls the earliest available slot (now + leadTime). |
| `pickupWindowInDays` | number | Yes | Number of days ahead customers can schedule pickup. Set to `0` for ASAP-only mode (no date/time picker). |
| `pickupSlotInterval` | number | No | Minutes between selectable time slots (e.g. `30` → 10:00, 10:30, 11:00…). Defaults to 30 if omitted. Separate from `leadTime` — the interval controls slot spacing, while leadTime controls advance notice. |
| `pickupWindowInDays` | number | Yes | Number of days ahead customers can schedule pickup. For backwards compatibility, `0` means ASAP-only mode when `pickupMode` is omitted. |
| `pickupMode` | `'asap' \| 'dateOnly' \| 'dateAndTime'` | No | Controls the pickup selection UI. If omitted, the legacy behavior is preserved: `pickupWindowInDays: 0` resolves to `asap`; otherwise it resolves to `dateAndTime`. |
| `pickupSlotInterval` | number | No | Minutes between selectable time slots (e.g. `30` → 10:00, 10:30, 11:00…). Defaults to 30 if omitted. Used by `dateAndTime` mode. Separate from `leadTime` — the interval controls slot spacing, while leadTime controls advance notice. |
| `hours` | object | Yes | Per-day operating hours. Each day has `enabled` (boolean), `openTime` (HH:mm or null), and `closeTime` (HH:mm or null). |

#### Behavior Notes

- **ASAP option** — Shown for today only, when the store can fulfill an order (now + leadTime) before closing time.
- **Pickup modes**:
- `asap` — No date/time selectors. Checkout sets pickup to ASAP using `now + leadTime` in the store timezone.
- `dateOnly` — Shows a date selector only. No time selector is shown, and missing time slots do not produce a warning.
- `dateAndTime` — Shows a date selector and selectable time slots. This is the default for scheduled pickup.
- **Backwards compatibility** — If `pickupMode` is omitted, `pickupWindowInDays: 0` remains ASAP-only. If `pickupWindowInDays` is greater than `0`, checkout uses `dateAndTime`.
- **ASAP option in scheduled pickup** — In `dateAndTime` mode, an ASAP option is shown for today only when the store can fulfill an order (now + leadTime) before closing time.
- **Lead time vs slot interval** — A store with `leadTime: 1440` (24 hours) and `pickupSlotInterval: 15` shows 15-minute slots starting tomorrow, not 24-hour gaps.
- **Timezone handling** — All date/time logic uses the store's `timeZone`, not the customer's browser timezone. A store in Phoenix shows Phoenix hours regardless of where the customer is browsing from.
- **No available slots** — When leadTime exceeds the entire pickup window, or no days are enabled, a "No available time slots" banner is shown.
- **No available slots** — In `dateAndTime` mode, when leadTime exceeds the entire pickup window, no days are enabled, or no selectable slots exist, a "No available time slots" banner is shown.

### Appearance

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,64 @@ describe('Checkout pickup location and time selection', () => {
});
});

it('supports date-only pickup mode without showing time slots or warnings', async () => {
const location = scheduledLocation({
id: 'date-only-loc',
isDefault: true,
operatingHours: {
timeZone: 'America/New_York',
leadTime: 30,
pickupWindowInDays: 3,
pickupMode: 'dateOnly',
pickupSlotInterval: 60,
hours: {
sunday: { enabled: true, openTime: '09:00', closeTime: '17:00' },
monday: { enabled: true, openTime: '09:00', closeTime: '17:00' },
tuesday: { enabled: true, openTime: '09:00', closeTime: '17:00' },
wednesday: { enabled: true, openTime: '09:00', closeTime: '17:00' },
thursday: { enabled: true, openTime: '09:00', closeTime: '17:00' },
friday: { enabled: true, openTime: '09:00', closeTime: '17:00' },
saturday: { enabled: true, openTime: '09:00', closeTime: '17:00' },
},
},
});
const draftOrder = buildDraftOrder({
lineItems: [{ fulfillmentMode: 'PICKUP' }],
});
const session = buildCheckoutSession({
draftOrder,
locations: [location],
defaultOperatingHours: location.operatingHours,
paymentMethods: offlinePaymentMethods(),
});

const { user } = renderCheckout({ session, draftOrder });
await waitForCheckoutReady();

await user.click(screen.getByRole('radio', { name: /local pickup/i }));
await waitForOperation('ApplyCheckoutSessionFulfillmentLocation');

expect(await screen.findByText(/pickup date/i)).toBeInTheDocument();
expect(
screen.queryByText(/preferred pickup time/i)
).not.toBeInTheDocument();
expect(
screen.queryByText(/no available time slots/i)
).not.toBeInTheDocument();
clearOperations();

await user.click(
await screen.findByRole('button', { name: /complete your order/i })
);
await waitForOperation('ConfirmCheckoutSession');

expect(getLastConfirmInput()).toMatchObject({
fulfillmentLocationId: 'date-only-loc',
fulfillmentStartAt: '2026-01-05T00:00:00-05:00',
fulfillmentEndAt: '2026-01-05T00:00:00-05:00',
});
});

it('falls back to default operating-hours timezone when the pickup location has none', async () => {
const location = buildPickupLocation({
id: 'fallback-tz-loc',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,7 @@ export function buildPickupLocation(
timeZone: 'America/New_York',
leadTime: 30,
pickupWindowInDays: 0,
pickupMode: null,
pickupSlotInterval: 30,
hours: {
sunday: { enabled: true, openTime: '09:00', closeTime: '17:00' },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} from '@/components/checkout/order/use-draft-order';
import { useFlushCheckoutSync } from '@/components/checkout/payment/utils/use-flush-checkout-sync';
import { buildPickupPayload } from '@/components/checkout/pickup/utils/build-pickup-payload';
import { getPickupMode } from '@/components/checkout/pickup/utils/generate-pickup-time-slots';
import { getShippingFulfillmentSyncKey } from '@/components/checkout/shipping/utils/should-apply-shipping-method';
import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys';
import { useGoDaddyContext } from '@/godaddy-provider';
Expand Down Expand Up @@ -159,14 +160,23 @@ export function useConfirmCheckout() {
throw new Error('MISSING_SHIPPING_INFO');
}

const pickupLocationId = form.getValues('pickupLocationId');
const pickupStoreHours = pickupLocationId
? (session?.locations?.find(
location => location.id === pickupLocationId
)?.operatingHours ?? session?.defaultOperatingHours)
: session?.defaultOperatingHours;
const pickUpData = isPickup
? buildPickupPayload({
pickupDate: form.getValues('pickupDate'),
pickupTime: form.getValues('pickupTime'),
pickupLocationId: form.getValues('pickupLocationId'),
pickupLocationId,
leadTime: form.getValues('pickupLeadTime') ?? 0,
timezone: form.getValues('pickupTimezone'),
defaultTimezone: session?.defaultOperatingHours?.timeZone,
pickupMode: pickupStoreHours
? getPickupMode(pickupStoreHours)
: undefined,
})
: {};

Expand Down
122 changes: 64 additions & 58 deletions packages/react/src/components/checkout/pickup/local-pickup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,10 @@ import {
findFirstAvailablePickupDate,
formatLeadTimeDisplay,
generatePickupTimeSlots,
getPickupMode,
isAsapAvailable,
isPickupDateAvailable,
PICKUP_MODES,
} from './utils/generate-pickup-time-slots';

// Map day of week to the corresponding property in hours
Expand Down Expand Up @@ -155,7 +157,9 @@ export function LocalPickupForm({
locationHours.leadTime || FALLBACK_LEAD_TIME
);

if (locationHours.pickupWindowInDays === 0) {
const pickupMode = getPickupMode(locationHours);

if (pickupMode === PICKUP_MODES.ASAP) {
const today = new Date();
const zonedToday = toZonedTime(today, locationHours.timeZone);
const calendarToday = new Date(
Expand Down Expand Up @@ -292,7 +296,10 @@ export function LocalPickupForm({
return;
}
const locationHours = getStoreHours(selectedLocationId);
if (!locationHours || locationHours.pickupWindowInDays === 0) {
if (
!locationHours ||
getPickupMode(locationHours) !== PICKUP_MODES.DATE_AND_TIME
) {
setAvailableTimeSlots([]);
return;
}
Expand Down Expand Up @@ -427,6 +434,9 @@ export function LocalPickupForm({
() => (selectedLocationId ? getStoreHours(selectedLocationId) : undefined),
[selectedLocationId, getStoreHours]
);
const pickupMode = storeHours ? getPickupMode(storeHours) : undefined;
const showsDatePicker = pickupMode !== PICKUP_MODES.ASAP;
const showsTimePicker = pickupMode === PICKUP_MODES.DATE_AND_TIME;

return (
<div className='space-y-4'>
Expand Down Expand Up @@ -522,7 +532,7 @@ export function LocalPickupForm({
)}
/>

{storeHours?.pickupWindowInDays !== 0 && (
{showsDatePicker && (
<FormField
control={form.control}
name='pickupDate'
Expand Down Expand Up @@ -615,62 +625,58 @@ export function LocalPickupForm({
/>
)}

{storeHours?.pickupWindowInDays !== 0 &&
selectedDate &&
availableTimeSlots.length > 0 && (
<FormField
control={form.control}
name='pickupTime'
render={({ field }) => (
<FormItem>
<FormLabel>{t.pickup.time}</FormLabel>
<Select
onValueChange={value => {
field.onChange(value);

// Track pickup time selection
track({
eventId: eventIds.changePickupTime,
type: TrackingEventType.CLICK,
properties: {
pickupTime: value,
isAsap: value === 'ASAP',
pickupDate: form.getValues('pickupDate'),
locationId: selectedLocationId,
},
});
}}
value={field.value ?? ''}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t.pickup.selectTime} />
</SelectTrigger>
</FormControl>
<SelectContent>
{availableTimeSlots.map(slot => (
<SelectItem key={slot.value} value={slot.value}>
<div className='flex items-center'>
<Clock className='mr-2 h-4 w-4' />
<span>{slot.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}
{showsTimePicker && selectedDate && availableTimeSlots.length > 0 && (
<FormField
control={form.control}
name='pickupTime'
render={({ field }) => (
<FormItem>
<FormLabel>{t.pickup.time}</FormLabel>
<Select
onValueChange={value => {
field.onChange(value);

{storeHours?.pickupWindowInDays !== 0 &&
selectedDate &&
availableTimeSlots.length === 0 && (
<div className='rounded-md bg-yellow-50 p-4'>
<p className='text-sm text-yellow-700'>{t.pickup.noTimeSlots}</p>
</div>
)}
// Track pickup time selection
track({
eventId: eventIds.changePickupTime,
type: TrackingEventType.CLICK,
properties: {
pickupTime: value,
isAsap: value === 'ASAP',
pickupDate: form.getValues('pickupDate'),
locationId: selectedLocationId,
},
});
}}
value={field.value ?? ''}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder={t.pickup.selectTime} />
</SelectTrigger>
</FormControl>
<SelectContent>
{availableTimeSlots.map(slot => (
<SelectItem key={slot.value} value={slot.value}>
<div className='flex items-center'>
<Clock className='mr-2 h-4 w-4' />
<span>{slot.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
)}

{showsTimePicker && selectedDate && availableTimeSlots.length === 0 && (
<div className='rounded-md bg-yellow-50 p-4'>
<p className='text-sm text-yellow-700'>{t.pickup.noTimeSlots}</p>
</div>
)}

{session?.enableNotesCollection ? (
<>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { buildPickupPayload } from './build-pickup-payload';
import { PICKUP_MODES } from './generate-pickup-time-slots';

describe('buildPickupPayload', () => {
afterEach(() => {
Expand Down Expand Up @@ -87,8 +88,8 @@ describe('buildPickupPayload', () => {
});
});

describe('date only (no time)', () => {
it('should default to midnight in the store timezone', () => {
describe('date only', () => {
it('should default to midnight in the store timezone for the legacy no-time fallback', () => {
const result = buildPickupPayload({
pickupDate: '2026-04-10',
pickupTime: null,
Expand All @@ -98,6 +99,46 @@ describe('buildPickupPayload', () => {

expect(result.fulfillmentStartAt).toBe('2026-04-10T00:00:00-04:00');
});

it('should use midnight in the store timezone for explicit date-only pickup even if a stale time exists', () => {
const result = buildPickupPayload({
pickupDate: '2026-04-10',
pickupTime: '13:00',
pickupLocationId: 'loc-7',
timezone: 'America/New_York',
pickupMode: PICKUP_MODES.DATE_ONLY,
});

expect(result.fulfillmentStartAt).toBe('2026-04-10T00:00:00-04:00');
expect(result.fulfillmentEndAt).toBe('2026-04-10T00:00:00-04:00');
});

it('should handle explicit date-only pickup in a timezone far from the browser timezone', () => {
const result = buildPickupPayload({
pickupDate: '2026-04-10',
pickupTime: null,
pickupLocationId: 'loc-7',
timezone: 'Asia/Kolkata',
pickupMode: PICKUP_MODES.DATE_ONLY,
});

expect(result.fulfillmentStartAt).toBe('2026-04-10T00:00:00+05:30');
expect(result.fulfillmentEndAt).toBe('2026-04-10T00:00:00+05:30');
});

it('should use the default timezone for explicit date-only pickup when selected location timezone is empty', () => {
const result = buildPickupPayload({
pickupDate: '2026-04-10',
pickupTime: null,
pickupLocationId: 'loc-7',
timezone: '',
defaultTimezone: 'America/Los_Angeles',
pickupMode: PICKUP_MODES.DATE_ONLY,
});

expect(result.fulfillmentStartAt).toBe('2026-04-10T00:00:00-07:00');
expect(result.fulfillmentEndAt).toBe('2026-04-10T00:00:00-07:00');
});
});

describe('ASAP', () => {
Expand Down
Loading
Loading