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 => (