diff --git a/.changeset/dry-bats-invent.md b/.changeset/dry-bats-invent.md
new file mode 100644
index 00000000..9b8dfab6
--- /dev/null
+++ b/.changeset/dry-bats-invent.md
@@ -0,0 +1,6 @@
+---
+"@godaddy/react": patch
+---
+
+Add pickup date only mode
+Infer ui extension cdn urls from apiHost
diff --git a/packages/react/README.md b/packages/react/README.md
index c8f578df..25641b21 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -89,7 +89,7 @@ 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: {
@@ -97,6 +97,7 @@ operatingHours: {
timeZone: 'America/New_York',
leadTime: 60,
pickupWindowInDays: 7,
+ pickupMode: 'dateAndTime',
pickupSlotInterval: 30,
hours: {
monday: { enabled: true, openTime: '09:00', closeTime: '17:00' },
@@ -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
diff --git a/packages/react/src/components/checkout/__tests__/checkout-pickup-selection.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-pickup-selection.test.tsx
index e523454c..99ffdc9d 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-pickup-selection.test.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-pickup-selection.test.tsx
@@ -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',
diff --git a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx
index 9f12aadf..5faecca1 100644
--- a/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx
+++ b/packages/react/src/components/checkout/__tests__/checkout-test-utils.tsx
@@ -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' },
diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts
index 998fb3f9..8f54baff 100644
--- a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts
+++ b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts
@@ -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';
@@ -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,
})
: {};
diff --git a/packages/react/src/components/checkout/pickup/local-pickup.tsx b/packages/react/src/components/checkout/pickup/local-pickup.tsx
index 248bc80d..f004771a 100644
--- a/packages/react/src/components/checkout/pickup/local-pickup.tsx
+++ b/packages/react/src/components/checkout/pickup/local-pickup.tsx
@@ -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
@@ -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(
@@ -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;
}
@@ -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 (
@@ -522,7 +532,7 @@ export function LocalPickupForm({
)}
/>
- {storeHours?.pickupWindowInDays !== 0 && (
+ {showsDatePicker && (
)}
- {storeHours?.pickupWindowInDays !== 0 &&
- selectedDate &&
- availableTimeSlots.length > 0 && (
-
(
-
- {t.pickup.time}
-
-
-
- )}
- />
- )}
+ {showsTimePicker && selectedDate && availableTimeSlots.length > 0 && (
+ (
+
+ {t.pickup.time}
+
+
+
+ )}
+ />
+ )}
+
+ {showsTimePicker && selectedDate && availableTimeSlots.length === 0 && (
+
+
{t.pickup.noTimeSlots}
+
+ )}
{session?.enableNotesCollection ? (
<>
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
index a1103bb3..ec97829d 100644
--- 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
@@ -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(() => {
@@ -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,
@@ -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', () => {
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 30cd8e68..9c10108e 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,5 @@
import { format as formatTz, fromZonedTime, toZonedTime } from 'date-fns-tz';
+import { PICKUP_MODES, type PickupMode } from './generate-pickup-time-slots';
type FormFields = {
pickupDate?: string | Date | null;
@@ -7,6 +8,7 @@ type FormFields = {
leadTime?: number;
timezone: string | null;
defaultTimezone?: string | null;
+ pickupMode?: PickupMode | null;
};
type PickupPayload = {
@@ -36,11 +38,16 @@ export function buildPickupPayload({
leadTime = 0,
timezone = 'UTC',
defaultTimezone,
+ pickupMode,
}: FormFields): PickupPayload {
const tz = timezone || defaultTimezone || 'UTC';
let date: Date;
- if (pickupTime === 'ASAP') {
+ if (pickupMode === PICKUP_MODES.DATE_ONLY && pickupDate) {
+ const dateStr = toDateString(pickupDate);
+ const utcDate = fromZonedTime(`${dateStr}T00:00:00`, tz);
+ date = toZonedTime(utcDate, tz);
+ } else if (pickupTime === 'ASAP') {
const now = new Date();
now.setMinutes(now.getMinutes() + leadTime);
date = toZonedTime(now, tz);
diff --git a/packages/react/src/components/checkout/pickup/utils/generate-pickup-time-slots.test.ts b/packages/react/src/components/checkout/pickup/utils/generate-pickup-time-slots.test.ts
index 0be8a1b9..8174c62a 100644
--- a/packages/react/src/components/checkout/pickup/utils/generate-pickup-time-slots.test.ts
+++ b/packages/react/src/components/checkout/pickup/utils/generate-pickup-time-slots.test.ts
@@ -5,9 +5,12 @@ import {
findFirstAvailablePickupDate,
formatLeadTimeDisplay,
generatePickupTimeSlots,
+ getPickupMode,
isAsapAvailable,
+ isDateOnlyPickupDateAvailable,
isPickupDateAvailable,
type OperatingHours,
+ PICKUP_MODES,
} from './generate-pickup-time-slots';
// ── helpers ──────────────────────────────────────────────────────────────
@@ -109,7 +112,7 @@ describe('findFirstAvailablePickupDate', () => {
expect(date!.getUTCDate()).toBe(27); // Wednesday
});
- it('returns today for pickupWindowInDays 0 (ASAP-only)', () => {
+ it('returns today for legacy pickupWindowInDays 0 (ASAP-only)', () => {
const date = findFirstAvailablePickupDate(
makeHours({ leadTime: 99999, pickupWindowInDays: 0 }),
MON_10AM
@@ -118,6 +121,19 @@ describe('findFirstAvailablePickupDate', () => {
expect(date!.getUTCDate()).toBe(25);
});
+ it('honors explicit ASAP mode even when pickupWindowInDays is greater than zero', () => {
+ const date = findFirstAvailablePickupDate(
+ makeHours({
+ leadTime: 99999,
+ pickupMode: PICKUP_MODES.ASAP,
+ pickupWindowInDays: 7,
+ }),
+ MON_10AM
+ );
+ expect(date).toBeDefined();
+ expect(date!.getUTCDate()).toBe(25);
+ });
+
it('returns undefined when no days are enabled', () => {
const allDisabled = {
monday: disabledDay,
@@ -188,12 +204,38 @@ describe('findFirstAvailablePickupDate', () => {
});
});
+// ── pickup modes ─────────────────────────────────────────────────────────
+
+describe('pickup modes', () => {
+ it('derives legacy modes from pickupWindowInDays when pickupMode is omitted', () => {
+ expect(getPickupMode(makeHours({ pickupWindowInDays: 0 }))).toBe(
+ PICKUP_MODES.ASAP
+ );
+ expect(getPickupMode(makeHours({ pickupWindowInDays: 7 }))).toBe(
+ PICKUP_MODES.DATE_AND_TIME
+ );
+ });
+
+ it('uses explicit pickupMode over pickupWindowInDays', () => {
+ expect(
+ getPickupMode(
+ makeHours({ pickupMode: PICKUP_MODES.DATE_ONLY, pickupWindowInDays: 0 })
+ )
+ ).toBe(PICKUP_MODES.DATE_ONLY);
+ expect(
+ getPickupMode(
+ makeHours({ pickupMode: PICKUP_MODES.ASAP, pickupWindowInDays: 7 })
+ )
+ ).toBe(PICKUP_MODES.ASAP);
+ });
+});
+
// ── generatePickupTimeSlots ──────────────────────────────────────────────
describe('generatePickupTimeSlots', () => {
// ── basic / edge cases ───────────────────────────────────────────────
- it('returns empty when pickupWindowInDays is 0', () => {
+ it('returns empty for legacy pickupWindowInDays 0 ASAP-only mode', () => {
const slots = generatePickupTimeSlots({
selectedDate: TUE,
storeHours: makeHours({ pickupWindowInDays: 0 }),
@@ -202,6 +244,27 @@ describe('generatePickupTimeSlots', () => {
expect(slots).toEqual([]);
});
+ it('returns empty for explicit ASAP mode even when pickupWindowInDays is greater than zero', () => {
+ const slots = generatePickupTimeSlots({
+ selectedDate: TUE,
+ storeHours: makeHours({
+ pickupMode: PICKUP_MODES.ASAP,
+ pickupWindowInDays: 7,
+ }),
+ now: MON_10AM,
+ });
+ expect(slots).toEqual([]);
+ });
+
+ it('returns empty for date-only mode even when a day has timed slot capacity', () => {
+ const slots = generatePickupTimeSlots({
+ selectedDate: TUE,
+ storeHours: makeHours({ pickupMode: PICKUP_MODES.DATE_ONLY }),
+ now: MON_10AM,
+ });
+ expect(slots).toEqual([]);
+ });
+
it('returns empty when the selected day is disabled', () => {
const sunday = new Date('2024-03-24T10:00:00Z');
const slots = generatePickupTimeSlots({
@@ -803,6 +866,43 @@ describe('generatePickupTimeSlots', () => {
});
// US West Coast: UTC-7 (PDT in March 2024)
+ it('date-only availability respects store timezone near UTC day rollover', () => {
+ // UTC Tue 03:00 = Mon 11pm in New York. With 30-minute lead time, Monday is no longer bookable.
+ const lateNightUTC = new Date('2024-03-26T03:00:00Z');
+ const date = findFirstAvailablePickupDate(
+ makeNYCHours({
+ pickupMode: PICKUP_MODES.DATE_ONLY,
+ pickupWindowInDays: 2,
+ }),
+ lateNightUTC
+ );
+
+ expect(date).toBeDefined();
+ expect(date!.getDay()).toBe(2);
+ });
+
+ it('date-only availability uses selected calendar date without double-zoning', () => {
+ const selectedTuesday = new Date(2024, 2, 26);
+ const hours = makeHours({
+ pickupMode: PICKUP_MODES.DATE_ONLY,
+ pickupWindowInDays: 3,
+ timeZone: 'Pacific/Honolulu',
+ hours: {
+ ...standardWeek,
+ monday: disabledDay,
+ tuesday: enabledDay,
+ },
+ });
+
+ expect(
+ isDateOnlyPickupDateAvailable({
+ selectedDate: selectedTuesday,
+ storeHours: hours,
+ now: new Date('2024-03-25T10:00:00Z'),
+ })
+ ).toBe(true);
+ });
+
it('works with America/Los_Angeles timezone', () => {
// UTC 17:00 Mon = 10:00 AM PDT
const monMorningLA = new Date('2024-03-25T17:00:00Z');
@@ -830,6 +930,63 @@ describe('generatePickupTimeSlots', () => {
// ── isPickupDateAvailable ────────────────────────────────────────────────
describe('isPickupDateAvailable', () => {
+ it('returns true for legacy pickupWindowInDays 0 ASAP-only mode', () => {
+ expect(
+ isPickupDateAvailable({
+ selectedDate: TUE,
+ storeHours: makeHours({ pickupWindowInDays: 0, leadTime: 99999 }),
+ now: MON_10AM,
+ })
+ ).toBe(true);
+ });
+
+ it('returns true for explicit ASAP mode even when pickupWindowInDays is greater than zero', () => {
+ expect(
+ isPickupDateAvailable({
+ selectedDate: TUE,
+ storeHours: makeHours({
+ pickupMode: PICKUP_MODES.ASAP,
+ pickupWindowInDays: 7,
+ leadTime: 99999,
+ }),
+ now: MON_10AM,
+ })
+ ).toBe(true);
+ });
+
+ it('returns true for date-only mode when the selected day is open after lead time', () => {
+ expect(
+ isPickupDateAvailable({
+ selectedDate: TUE,
+ storeHours: makeHours({ pickupMode: PICKUP_MODES.DATE_ONLY }),
+ now: MON_10AM,
+ })
+ ).toBe(true);
+ });
+
+ it('returns false for date-only mode when lead time pushes past close', () => {
+ expect(
+ isPickupDateAvailable({
+ selectedDate: MON_10AM,
+ storeHours: makeHours({
+ pickupMode: PICKUP_MODES.DATE_ONLY,
+ leadTime: 360,
+ }),
+ now: MON_10AM,
+ })
+ ).toBe(false);
+ });
+
+ it('returns false for date-only mode when selected day is disabled', () => {
+ expect(
+ isPickupDateAvailable({
+ selectedDate: new Date('2024-03-24T10:00:00Z'),
+ storeHours: makeHours({ pickupMode: PICKUP_MODES.DATE_ONLY }),
+ now: MON_10AM,
+ })
+ ).toBe(false);
+ });
+
it('returns false for an enabled date that has no slots because it is inside the lead-time window', () => {
const slots = generatePickupTimeSlots({
selectedDate: TUE,
diff --git a/packages/react/src/components/checkout/pickup/utils/generate-pickup-time-slots.ts b/packages/react/src/components/checkout/pickup/utils/generate-pickup-time-slots.ts
index 7c3756f2..44bc24b8 100644
--- a/packages/react/src/components/checkout/pickup/utils/generate-pickup-time-slots.ts
+++ b/packages/react/src/components/checkout/pickup/utils/generate-pickup-time-slots.ts
@@ -4,6 +4,14 @@ import { toZonedTime } from 'date-fns-tz';
export const DEFAULT_SLOT_INTERVAL = 30;
export const FALLBACK_LEAD_TIME = 30;
+export const PICKUP_MODES = {
+ ASAP: 'asap',
+ DATE_ONLY: 'dateOnly',
+ DATE_AND_TIME: 'dateAndTime',
+} as const;
+
+export type PickupMode = (typeof PICKUP_MODES)[keyof typeof PICKUP_MODES];
+
export const dayToProperty = {
0: 'sunday',
1: 'monday',
@@ -33,6 +41,7 @@ export type WeekHours = {
export type OperatingHours = {
leadTime: number;
pickupWindowInDays: number;
+ pickupMode?: PickupMode | null;
timeZone: string;
hours: WeekHours;
/**
@@ -51,6 +60,15 @@ export type TimeSlot = {
value: string;
};
+export function getPickupMode(storeHours: OperatingHours): PickupMode {
+ return (
+ storeHours.pickupMode ??
+ (storeHours.pickupWindowInDays === 0
+ ? PICKUP_MODES.ASAP
+ : PICKUP_MODES.DATE_AND_TIME)
+ );
+}
+
/**
* Format a lead time in minutes for display.
* e.g. 90 → "1 hour 30 minutes", 60 → "1 hour", 45 → "45 minutes"
@@ -115,8 +133,8 @@ export function findFirstAvailablePickupDate(
storeHours: OperatingHours,
now?: Date
): Date | undefined {
- if (storeHours.pickupWindowInDays === 0) {
- // ASAP-only mode — always today
+ const pickupMode = getPickupMode(storeHours);
+ if (pickupMode === PICKUP_MODES.ASAP) {
return toLocalCalendarDate(
toZonedTime(now ?? new Date(), storeHours.timeZone)
);
@@ -131,6 +149,19 @@ export function findFirstAvailablePickupDate(
for (let i = 0; i < maxDays; i++) {
const zonedDate = toZonedTime(dateToCheck, storeHours.timeZone);
+ const calendarDate = toLocalCalendarDate(zonedDate);
+
+ if (
+ pickupMode === PICKUP_MODES.DATE_ONLY &&
+ isDateOnlyPickupDateAvailable({
+ selectedDate: calendarDate,
+ storeHours,
+ now,
+ })
+ ) {
+ return calendarDate;
+ }
+
const dayOfWeek = zonedDate.getDay();
const dayProperty = dayToProperty[dayOfWeek as keyof typeof dayToProperty];
const dayHours = storeHours.hours[dayProperty];
@@ -145,7 +176,7 @@ export function findFirstAvailablePickupDate(
});
if (dayCloseTime > earliestPickup) {
- return toLocalCalendarDate(zonedDate);
+ return calendarDate;
}
}
@@ -177,7 +208,7 @@ export function generatePickupTimeSlots({
const leadTimeMinutes = storeHours.leadTime || FALLBACK_LEAD_TIME;
const pickupSlotInterval = getSlotInterval(storeHours);
- if (storeHours.pickupWindowInDays === 0) return [];
+ if (getPickupMode(storeHours) !== PICKUP_MODES.DATE_AND_TIME) return [];
const zonedSelected = toStoreSelectedDate(selectedDate, tz);
const dayOfWeek = zonedSelected.getDay();
@@ -280,6 +311,45 @@ export function generatePickupTimeSlots({
return slots;
}
+export function isDateOnlyPickupDateAvailable({
+ selectedDate,
+ storeHours,
+ now: nowInput,
+}: {
+ selectedDate: Date;
+ storeHours: OperatingHours;
+ now?: Date;
+}): boolean {
+ const tz = storeHours.timeZone;
+ const selected = toStoreSelectedDate(selectedDate, tz);
+ const dayOfWeek = selected.getDay();
+ const dayProperty = dayToProperty[dayOfWeek as keyof typeof dayToProperty];
+ const hoursForDay = storeHours.hours[dayProperty];
+
+ if (
+ !hoursForDay?.enabled ||
+ !hoursForDay.openTime ||
+ !hoursForDay.closeTime
+ ) {
+ return false;
+ }
+
+ const now = toZonedTime(nowInput ?? new Date(), tz);
+ const leadTimeMinutes = storeHours.leadTime || FALLBACK_LEAD_TIME;
+ const earliestPickup = new Date(now.getTime() + leadTimeMinutes * 60000);
+ const [closeTimeHours, closeTimeMins] = hoursForDay.closeTime
+ .split(':')
+ .map(Number);
+ const closeDateTime = set(new Date(selected), {
+ hours: closeTimeHours,
+ minutes: closeTimeMins,
+ seconds: 0,
+ milliseconds: 0,
+ });
+
+ return closeDateTime > earliestPickup;
+}
+
export function isPickupDateAvailable({
selectedDate,
storeHours,
@@ -289,7 +359,15 @@ export function isPickupDateAvailable({
storeHours: OperatingHours;
now?: Date;
}): boolean {
- if (storeHours.pickupWindowInDays === 0) return true;
+ const pickupMode = getPickupMode(storeHours);
+ if (pickupMode === PICKUP_MODES.ASAP) return true;
+ if (pickupMode === PICKUP_MODES.DATE_ONLY) {
+ return isDateOnlyPickupDateAvailable({
+ selectedDate,
+ storeHours,
+ now: nowInput,
+ });
+ }
const timedSlots = generatePickupTimeSlots({
selectedDate,
diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts
index dd8922f5..c9ab79e9 100644
--- a/packages/react/src/lib/godaddy/checkout-env.ts
+++ b/packages/react/src/lib/godaddy/checkout-env.ts
@@ -3838,6 +3838,15 @@ const introspection = {
"args": [],
"isDeprecated": false
},
+ {
+ "name": "pickupMode",
+ "type": {
+ "kind": "ENUM",
+ "name": "PickupMode"
+ },
+ "args": [],
+ "isDeprecated": false
+ },
{
"name": "pickupSlotInterval",
"type": {
@@ -3898,6 +3907,13 @@ const introspection = {
}
}
},
+ {
+ "name": "pickupMode",
+ "type": {
+ "kind": "ENUM",
+ "name": "PickupMode"
+ }
+ },
{
"name": "pickupSlotInterval",
"type": {
@@ -9086,6 +9102,24 @@ const introspection = {
],
"interfaces": []
},
+ {
+ "kind": "ENUM",
+ "name": "PickupMode",
+ "enumValues": [
+ {
+ "name": "asap",
+ "isDeprecated": false
+ },
+ {
+ "name": "dateAndTime",
+ "isDeprecated": false
+ },
+ {
+ "name": "dateOnly",
+ "isDeprecated": false
+ }
+ ]
+ },
{
"kind": "INPUT_OBJECT",
"name": "PriceAdjustmentShippingLineInput",
diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts
index b5af9be9..39f4780a 100644
--- a/packages/react/src/lib/godaddy/checkout-mutations.ts
+++ b/packages/react/src/lib/godaddy/checkout-mutations.ts
@@ -150,6 +150,7 @@ export const CreateCheckoutSessionMutation = graphql(`
}
operatingHours {
pickupWindowInDays
+ pickupMode
leadTime
pickupSlotInterval
timeZone
@@ -194,6 +195,7 @@ export const CreateCheckoutSessionMutation = graphql(`
}
defaultOperatingHours {
pickupWindowInDays
+ pickupMode
leadTime
pickupSlotInterval
timeZone
diff --git a/packages/react/src/lib/godaddy/checkout-queries.ts b/packages/react/src/lib/godaddy/checkout-queries.ts
index d4b6cc26..4e3e6185 100644
--- a/packages/react/src/lib/godaddy/checkout-queries.ts
+++ b/packages/react/src/lib/godaddy/checkout-queries.ts
@@ -79,7 +79,6 @@ export const GetCheckoutSessionQuery = graphql(`
releaseId
name
handle
- cdnUrl
type
target
}
@@ -156,6 +155,7 @@ export const GetCheckoutSessionQuery = graphql(`
}
operatingHours {
pickupWindowInDays
+ pickupMode
leadTime
pickupSlotInterval
timeZone
@@ -200,6 +200,7 @@ export const GetCheckoutSessionQuery = graphql(`
}
defaultOperatingHours {
pickupWindowInDays
+ pickupMode
leadTime
pickupSlotInterval
timeZone
@@ -287,7 +288,6 @@ export const GetEnabledStoreUiExtensionsQuery = graphql(`
releaseId
name
handle
- cdnUrl
type
target
}
diff --git a/packages/react/src/ui-extensions/runtime/dom-bundle-runtime.test.ts b/packages/react/src/ui-extensions/runtime/dom-bundle-runtime.test.ts
index e27ab85e..1af6928a 100644
--- a/packages/react/src/ui-extensions/runtime/dom-bundle-runtime.test.ts
+++ b/packages/react/src/ui-extensions/runtime/dom-bundle-runtime.test.ts
@@ -8,7 +8,7 @@ function createExtension(overrides: Partial = {}): UiExtension {
id: 'extension-1',
applicationId: 'app-1',
releaseId: 'release-1',
- cdnUrl: 'https://cdn.example.com',
+ cdnUrl: 'https://malicious.example.com',
type: 'checkout',
target: 'checkout.test-target',
...overrides,
@@ -42,6 +42,9 @@ describe('DomBundleUiExtensionRuntime', () => {
});
const script = await getLastScript();
+ expect(script.src).toBe(
+ 'https://cdn.ui-extensions.commerce.godaddy.com/apps/targets/checkout.test-target/app-1/release-1/index.js'
+ );
window.GoDaddyUiExtensions?.register({
mount({ container: mountContainer, initialProps }) {
mountContainer.textContent = String(initialProps?.orderId);
diff --git a/packages/react/src/ui-extensions/runtime/dom-bundle-runtime.ts b/packages/react/src/ui-extensions/runtime/dom-bundle-runtime.ts
index 4a66f2a8..32bfd255 100644
--- a/packages/react/src/ui-extensions/runtime/dom-bundle-runtime.ts
+++ b/packages/react/src/ui-extensions/runtime/dom-bundle-runtime.ts
@@ -184,7 +184,8 @@ export class DomBundleUiExtensionRuntime implements UiExtensionRuntime {
}
async mount(input: UiExtensionRuntimeMountInput) {
- const { container, context, extension, initialProps, onError } = input;
+ const { apiHost, container, context, extension, initialProps, onError } =
+ input;
this.clearRuntimeState();
this.isDisposed = false;
this.extension = extension;
@@ -201,7 +202,7 @@ export class DomBundleUiExtensionRuntime implements UiExtensionRuntime {
this.metadata = getExtensionMetadata(extension);
- const scriptUrlResult = getUiExtensionScriptUrl(extension);
+ const scriptUrlResult = getUiExtensionScriptUrl(extension, apiHost);
if (!scriptUrlResult.success) {
onError(scriptUrlResult.error);
return;
diff --git a/packages/react/src/ui-extensions/runtime/runtime-host.tsx b/packages/react/src/ui-extensions/runtime/runtime-host.tsx
index 1f0ef880..68b42cd6 100644
--- a/packages/react/src/ui-extensions/runtime/runtime-host.tsx
+++ b/packages/react/src/ui-extensions/runtime/runtime-host.tsx
@@ -13,6 +13,7 @@ import type {
export interface UiExtensionRuntimeHostProps {
extension: UiExtension;
+ apiHost?: string;
context: UiExtensionContext;
initialProps?: UiExtensionInitialProps;
runtimeType?: Extract;
@@ -95,6 +96,7 @@ class UiExtensionErrorBoundary extends Component
}
function UiExtensionRuntimeHostContainer({
+ apiHost,
context,
extension,
initialProps,
@@ -116,6 +118,7 @@ function UiExtensionRuntimeHostContainer({
void runtime.mount({
extension,
+ apiHost,
context,
initialProps,
container: containerRef.current || undefined,
@@ -135,7 +138,7 @@ function UiExtensionRuntimeHostContainer({
);
});
};
- }, [extensionKey, runtimeType]);
+ }, [apiHost, extensionKey, runtimeType]);
useEffect(() => {
void Promise.resolve(
diff --git a/packages/react/src/ui-extensions/runtime/script-url.test.ts b/packages/react/src/ui-extensions/runtime/script-url.test.ts
index 05e0e0b1..68087986 100644
--- a/packages/react/src/ui-extensions/runtime/script-url.test.ts
+++ b/packages/react/src/ui-extensions/runtime/script-url.test.ts
@@ -1,13 +1,16 @@
import { describe, expect, it } from 'vitest';
import type { UiExtension } from '../types';
-import { getUiExtensionScriptUrl } from './script-url';
+import {
+ getUiExtensionCdnBaseUrl,
+ getUiExtensionScriptUrl,
+} from './script-url';
function createExtension(overrides: Partial = {}): UiExtension {
return {
id: 'extension-1',
applicationId: 'app id',
releaseId: 'release/id',
- cdnUrl: 'https://cdn.example.com/',
+ cdnUrl: 'https://malicious.example.com/',
type: 'checkout',
target: 'checkout.test-target',
...overrides,
@@ -15,12 +18,27 @@ function createExtension(overrides: Partial = {}): UiExtension {
}
describe('getUiExtensionScriptUrl', () => {
- it('builds deterministic script URL and encodes path segments', () => {
- const result = getUiExtensionScriptUrl(createExtension());
+ it('builds deterministic production script URL from apiHost and encodes path segments', () => {
+ const result = getUiExtensionScriptUrl(
+ createExtension(),
+ 'api.godaddy.com'
+ );
expect(result).toEqual({
success: true,
- url: 'https://cdn.example.com/apps/targets/checkout.test-target/app%20id/release%2Fid/index.js',
+ url: 'https://cdn.ui-extensions.commerce.godaddy.com/apps/targets/checkout.test-target/app%20id/release%2Fid/index.js',
+ });
+ });
+
+ it('ignores the extension cdnUrl response field', () => {
+ const result = getUiExtensionScriptUrl(
+ createExtension({ cdnUrl: 'https://evil.example.com' }),
+ 'api.example.com'
+ );
+
+ expect(result).toEqual({
+ success: true,
+ url: 'https://cdn.ui-extensions.commerce.example.com/apps/targets/checkout.test-target/app%20id/release%2Fid/index.js',
});
});
@@ -32,7 +50,24 @@ describe('getUiExtensionScriptUrl', () => {
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.code).toBe('missing_required_field');
+ expect(result.error.message).toBe(
+ 'UI extension requires target, applicationId, and releaseId to load.'
+ );
expect(result.error.extensionId).toBe('extension-1');
}
});
});
+
+describe('getUiExtensionCdnBaseUrl', () => {
+ it.each([
+ [undefined, 'https://cdn.ui-extensions.commerce.godaddy.com'],
+ ['api.godaddy.com', 'https://cdn.ui-extensions.commerce.godaddy.com'],
+ ['api.example.com', 'https://cdn.ui-extensions.commerce.example.com'],
+ [
+ 'https://api.example.com/',
+ 'https://cdn.ui-extensions.commerce.example.com',
+ ],
+ ])('replaces the api host prefix for %s', (apiHost, expectedCdnBaseUrl) => {
+ expect(getUiExtensionCdnBaseUrl(apiHost)).toBe(expectedCdnBaseUrl);
+ });
+});
diff --git a/packages/react/src/ui-extensions/runtime/script-url.ts b/packages/react/src/ui-extensions/runtime/script-url.ts
index 4de14869..529d5080 100644
--- a/packages/react/src/ui-extensions/runtime/script-url.ts
+++ b/packages/react/src/ui-extensions/runtime/script-url.ts
@@ -9,28 +9,28 @@ function encodePathSegment(value: string) {
return encodeURIComponent(value);
}
-function trimTrailingSlashes(value: string) {
- let end = value.length;
+export function getUiExtensionCdnBaseUrl(apiHost?: string) {
+ const host = (apiHost || 'api.godaddy.com')
+ .trim()
+ .replace(/^https?:\/\//i, '')
+ .replace(/\/$/, '');
- while (end > 0 && value[end - 1] === '/') {
- end -= 1;
- }
-
- return value.slice(0, end);
+ return `https://${host.replace(/^api\./i, 'cdn.ui-extensions.commerce.')}`;
}
export function getUiExtensionScriptUrl(
- extension: UiExtension
+ extension: UiExtension,
+ apiHost?: string
): BuildUiExtensionScriptUrlResult {
- const { applicationId, cdnUrl, id, releaseId, target } = extension;
+ const { applicationId, id, releaseId, target } = extension;
- if (!cdnUrl || !target || !applicationId || !releaseId) {
+ if (!target || !applicationId || !releaseId) {
return {
success: false,
error: {
code: 'missing_required_field',
message:
- 'UI extension requires cdnUrl, target, applicationId, and releaseId to load.',
+ 'UI extension requires target, applicationId, and releaseId to load.',
runtimeType: 'dom-bundle',
extensionId: id,
applicationId,
@@ -41,7 +41,7 @@ export function getUiExtensionScriptUrl(
}
try {
- const baseUrl = trimTrailingSlashes(cdnUrl);
+ const baseUrl = getUiExtensionCdnBaseUrl(apiHost);
const url = [
baseUrl,
'apps',
diff --git a/packages/react/src/ui-extensions/runtime/types.ts b/packages/react/src/ui-extensions/runtime/types.ts
index c5f1f6d2..262c829e 100644
--- a/packages/react/src/ui-extensions/runtime/types.ts
+++ b/packages/react/src/ui-extensions/runtime/types.ts
@@ -93,6 +93,7 @@ export interface UiExtensionRuntimeMountInput {
context: UiExtensionContext;
initialProps?: UiExtensionInitialProps;
capabilities?: UiExtensionCapabilities;
+ apiHost?: string;
container?: HTMLElement;
onRender?(tree: UiExtensionComponentTree): void;
onResize?(size: { height?: number; width?: number }): void;
diff --git a/packages/react/src/ui-extensions/target.tsx b/packages/react/src/ui-extensions/target.tsx
index 335edf90..711e8079 100644
--- a/packages/react/src/ui-extensions/target.tsx
+++ b/packages/react/src/ui-extensions/target.tsx
@@ -54,7 +54,7 @@ export function Target({
theme,
onExtensionError,
}: TargetProps) {
- const { debug } = useGoDaddyContext();
+ const { apiHost, debug } = useGoDaddyContext();
const lastDebugLogSignatureRef = useRef(undefined);
const lastDiscoveryErrorSignatureRef = useRef(undefined);
const { data, error, isLoading } = useEnabledStoreUiExtensions({
@@ -136,6 +136,7 @@ export function Target({
<>
{targetExtensions.map(extension => (