diff --git a/.changeset/cold-lemons-camp.md b/.changeset/cold-lemons-camp.md new file mode 100644 index 00000000..0d738448 --- /dev/null +++ b/.changeset/cold-lemons-camp.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +Fix bug with large leadTimes and add pickupSlotInterval to uncouple leadTime from time slot generation diff --git a/packages/react/README.md b/packages/react/README.md index bba2aa04..5762597d 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -18,34 +18,41 @@ The first parameter accepts all checkout session configuration options from the #### Required Parameters -- **`channelId`** (string): The ID of the sales channel that originated this session -- **`storeId`** (string): The ID of the store this checkout session belongs to -- **`draftOrderId`** (string): The ID of the draft order +- **`storeId`** (string): The ID of the store this checkout session belongs to - **`returnUrl`** (string): URL to redirect to when user cancels checkout - **`successUrl`** (string): URL to redirect to after successful checkout +- **`draftOrderId`** (string): ID of an existing draft order (required if `lineItems` not provided) +- **`lineItems`** ([CheckoutSessionLineItemInput!]): Line items to create a draft order from (required if `draftOrderId` not provided) #### Optional Parameters +- **`appearance`** (GoDaddyAppearanceInput): Appearance configuration for the checkout (see [Appearance](#appearance)) +- **`channelId`** (string): The ID of the sales channel that originated this session - **`customerId`** (string): Customer ID for the checkout session -- **`storeName`** (string): The name of the store this checkout session belongs to -- **`url`** (string): Custom URL for the checkout session -- **`environment`** (enum): Environment - `ote`, `prod` -- **`expiresAt`** (DateTime): When the session expires +- **`enableAddressAutocomplete`** (boolean): Enable address autocomplete - **`enableBillingAddressCollection`** (boolean): Enable billing address collection - **`enableLocalPickup`** (boolean): Enable local pickup option - **`enableNotesCollection`** (boolean): Enable order notes collection - **`enablePaymentMethodCollection`** (boolean): Enable payment method collection - **`enablePhoneCollection`** (boolean): Enable phone number collection - **`enablePromotionCodes`** (boolean): Enable promotion/discount codes +- **`enableShipping`** (boolean): Enable shipping - **`enableShippingAddressCollection`** (boolean): Enable shipping address collection - **`enableSurcharge`** (boolean): Enable surcharge fees - **`enableTaxCollection`** (boolean): Enable tax collection - **`enableTips`** (boolean): Enable tip/gratuity options - **`enabledLocales`** ([String!]): List of enabled locales - **`enabledPaymentProviders`** ([String!]): List of enabled payment providers +- **`environment`** (enum): Environment - `ote`, `prod` +- **`expiresAt`** (DateTime): When the session expires - **`locations`** ([CheckoutSessionLocationInput!]): Available pickup locations -- **`operatingHours`** (CheckoutSessionOperatingHoursMapInput): Store operating hours configuration +- **`operatingHours`** (CheckoutSessionOperatingHoursMapInput): Store operating hours configuration (see [Operating Hours](#operating-hours)) - **`paymentMethods`** (CheckoutSessionPaymentMethodsInput): Payment method configurations +- **`shipping`** (CheckoutSessionShippingOptionsInput): Shipping configuration — primarily used to set an `originAddress` for shipping rate calculations and an optional `fulfillmentLocationId` +- **`sourceApp`** (string): The source application that created this checkout session +- **`storeName`** (string): The name of the store this checkout session belongs to +- **`taxes`** (CheckoutSessionTaxesOptionsInput): Tax configuration — used to set an `originAddress` for tax calculations (e.g. the store or warehouse address that taxes are calculated from) +- **`url`** (string): Custom URL for the checkout session ### Checkout Session Options @@ -76,12 +83,106 @@ The checkout session supports multiple environments through the input parameter: ### API Scopes -The checkout session automatically requests the following OAuth2 scopes: +The checkout session automatically requests the following OAuth2 scope: - `commerce.product:read` -- `commerce.order:read` -- `commerce.order:update` -- `location.address-verification:execute` + +### Operating Hours + +The `operatingHours` field configures local pickup scheduling — time zones, lead times, pickup windows, and slot intervals. + +```typescript +operatingHours: { + default: { + timeZone: 'America/New_York', + leadTime: 60, + pickupWindowInDays: 7, + pickupSlotInterval: 30, + hours: { + 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: '18:00' }, + saturday: { enabled: true, openTime: '10:00', closeTime: '16:00' }, + sunday: { enabled: false, openTime: null, closeTime: null }, + }, + }, +} +``` + +#### Store Hours Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `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. | +| `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. +- **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. + +### Appearance + +The `appearance` field customizes the checkout's look and feel. + +```typescript +appearance: { + theme: 'base', + variables: { + primary: '#4f46e5', + background: '#ffffff', + foreground: '#111827', + radius: '0.5rem', + }, +} +``` + +#### Theme + +| Value | Description | +|-------|-------------| +| `base` | Default theme | +| `orange` | Orange accent theme | +| `purple` | Purple accent theme | + +#### CSS Variables + +All fields are optional strings. Pass any subset to override the defaults. + +| Variable | Description | +|----------|-------------| +| `accent` | Accent color | +| `accentForeground` | Text on accent backgrounds | +| `background` | Page background | +| `border` | Border color | +| `card` | Card background | +| `cardForeground` | Text on cards | +| `defaultFontFamily` | Default font family | +| `destructive` | Destructive action color (errors, delete) | +| `destructiveForeground` | Text on destructive backgrounds | +| `fontMono` | Monospace font family | +| `fontSans` | Sans-serif font family | +| `fontSerif` | Serif font family | +| `foreground` | Primary text color | +| `input` | Input field background | +| `muted` | Muted/subtle background | +| `mutedForeground` | Text on muted backgrounds | +| `popover` | Popover background | +| `popoverForeground` | Text in popovers | +| `primary` | Primary brand color | +| `primaryForeground` | Text on primary backgrounds | +| `radius` | Border radius (e.g. `0.5rem`) | +| `ring` | Focus ring color | +| `secondary` | Secondary color | +| `secondaryBackground` | Secondary background | +| `secondaryForeground` | Text on secondary backgrounds | ## Codegen diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx index a5c823f1..1f0745b8 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx @@ -1,11 +1,4 @@ -import { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useGetPriceAdjustments } from '@/components/checkout/discount/utils/use-get-price-adjustments'; @@ -16,7 +9,6 @@ import { import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; import type { Address, - ShippingMethod, ShippingMethods, TokenizeJs, WalletError, @@ -96,6 +88,9 @@ export function ExpressCheckoutButton() { const confirmCheckout = useConfirmCheckout(); const collect = useRef(null); const hasMounted = useRef(false); + const handleExpressPayClickRef = useRef< + (args: { source?: 'apple_pay' | 'google_pay' | 'paze' }) => Promise + >(async () => undefined); // Use refs to store current coupon state to avoid stale closures in event handlers const appliedCouponCodeRef = useRef(null); @@ -305,6 +300,9 @@ export function ExpressCheckoutButton() { ] ); + // Keep ref in sync so the SDK's stale onClick closure always calls the latest handler + handleExpressPayClickRef.current = handleExpressPayClick; + // Track the status of coupon code fetching with a state variable const [couponFetchStatus, setCouponFetchStatus] = useState< 'idle' | 'fetching' | 'done' @@ -401,20 +399,21 @@ export function ExpressCheckoutButton() { // Initialize the TokenizeJs instance when the component mounts // But only after price adjustments have been fetched - useLayoutEffect(() => { - const shouldInitialize = - godaddyPaymentsConfig && - (godaddyPaymentsConfig?.businessId || session?.businessId) && - isPoyntLoaded && - isCollectLoading && - draftOrder && - couponFetchStatus === 'done'; - - if (!shouldInitialize) return; - - if (!collect.current && !hasMounted.current) { + // Initialize TokenizeJs and mount wallet buttons in a single effect + useEffect(() => { + if ( + !isPoyntLoaded || + !godaddyPaymentsConfig || + !isCollectLoading || + !draftOrder || + hasMounted.current || + couponFetchStatus !== 'done' || + (!godaddyPaymentsConfig?.businessId && !session?.businessId) + ) + return; + + if (!collect.current) { // Create coupon config if there's a price adjustment from existing coupon - // Read from refs to get current values const currentAdjustments = calculatedAdjustmentsRef.current; const currentCouponCode = appliedCouponCodeRef.current; @@ -452,8 +451,58 @@ export function ExpressCheckoutButton() { } ); } + + if (collect.current) { + collect.current.supportWalletPayments().then(supports => { + const paymentMethods: string[] = []; + if (supports.applePay) { + track({ + eventId: eventIds.expressApplePayImpression, + type: TrackingEventType.IMPRESSION, + properties: { + provider: 'poynt', + }, + }); + paymentMethods.push('apple_pay'); + } + if (supports.googlePay) { + paymentMethods.push('google_pay'); + track({ + eventId: eventIds.expressGooglePayImpression, + type: TrackingEventType.IMPRESSION, + properties: { + provider: 'poynt', + }, + }); + } + + if (paymentMethods.length > 0 && !hasMounted.current) { + hasMounted.current = true; + collect.current?.mount('gdpay-express-pay-element', document, { + paymentMethods: paymentMethods, + buttonsContainerOptions: { + className: 'gap-1 !flex-col sm:!flex-row place-items-center', + }, + buttonOptions: { + type: 'plain', + margin: '0', + height: '50px', + width: '100%', + justifyContent: 'flex-start', + onClick: (args: { + source?: 'apple_pay' | 'google_pay' | 'paze'; + }) => handleExpressPayClickRef.current(args), + }, + }); + } + }); + } }, [ + isPoyntLoaded, godaddyPaymentsConfig, + isCollectLoading, + draftOrder, + couponFetchStatus, countryCode, currencyCode, session?.businessId, @@ -462,78 +511,10 @@ export function ExpressCheckoutButton() { session?.enablePromotionCodes, session?.enableShippingAddressCollection, session?.storeName, - isPoyntLoaded, - isCollectLoading, - draftOrder, - couponFetchStatus, t, - handleExpressPayClick, formatCurrency, ]); - // Mount the TokenizeJs instance - useEffect(() => { - if ( - !isPoyntLoaded || - !godaddyPaymentsConfig || - !isCollectLoading || - !collect.current || - hasMounted.current || - (!godaddyPaymentsConfig?.businessId && !session?.businessId) - ) - return; - - collect.current?.supportWalletPayments().then(supports => { - const paymentMethods: string[] = []; - if (supports.applePay) { - track({ - eventId: eventIds.expressApplePayImpression, - type: TrackingEventType.IMPRESSION, - properties: { - provider: 'poynt', - }, - }); - paymentMethods.push('apple_pay'); - } - if (supports.googlePay) { - paymentMethods.push('google_pay'); - track({ - eventId: eventIds.expressGooglePayImpression, - type: TrackingEventType.IMPRESSION, - properties: { - provider: 'poynt', - }, - }); - } - // if (supports.paze) paymentMethods.push("paze"); // paze is not an "express" payment and needs to be implemented as a standard flow - - if (paymentMethods.length > 0 && !hasMounted.current) { - hasMounted.current = true; - // console.log("[poynt collect] Mounting"); - collect?.current?.mount('gdpay-express-pay-element', document, { - paymentMethods: paymentMethods, - buttonsContainerOptions: { - className: 'gap-1 !flex-col sm:!flex-row place-items-center', - }, - buttonOptions: { - type: 'plain', - margin: '0', - height: '50px', - width: '100%', - justifyContent: 'flex-start', - onClick: handleExpressPayClick, - }, - }); - } - }); - }, [ - isPoyntLoaded, - godaddyPaymentsConfig, - isCollectLoading, - handleExpressPayClick, - session?.businessId, - ]); - // Function to convert shipping address to shippingLines format for price adjustments const convertAddressToShippingLines = useCallback( ( diff --git a/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx b/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx index dfc9b657..d43b11df 100644 --- a/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx +++ b/packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx @@ -3,13 +3,13 @@ import React, { useCallback, useLayoutEffect, useState } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order'; -import { formatCurrency } from '@/components/checkout/utils/format-currency'; import { PaymentProvider, useConfirmCheckout, } from '@/components/checkout/payment/utils/use-confirm-checkout'; import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; import { useLoadMercadoPago } from '@/components/checkout/payment/utils/use-load-mercadopago'; +import { formatCurrency } from '@/components/checkout/utils/format-currency'; import { Button } from '@/components/ui/button'; import { useGoDaddyContext } from '@/godaddy-provider'; import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors'; diff --git a/packages/react/src/components/checkout/pickup/local-pickup.tsx b/packages/react/src/components/checkout/pickup/local-pickup.tsx index 6b79944a..a46f2e75 100644 --- a/packages/react/src/components/checkout/pickup/local-pickup.tsx +++ b/packages/react/src/components/checkout/pickup/local-pickup.tsx @@ -1,7 +1,8 @@ -import { addDays, format, set } from 'date-fns'; +import { addDays, format } from 'date-fns'; import { format as formatTz, toZonedTime } from 'date-fns-tz'; import { CalendarIcon, ChevronDown, Clock, MapPin, Store } from 'lucide-react'; import React, { useCallback, useEffect, useState } from 'react'; +import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useApplyFulfillmentLocation } from '@/components/checkout/delivery/utils/use-apply-fulfillment-location'; import { NotesForm } from '@/components/checkout/notes/notes-form'; @@ -36,10 +37,13 @@ import { cn } from '@/lib/utils'; import { eventIds } from '@/tracking/events'; import { TrackingEventType, track } from '@/tracking/track'; import type { CheckoutSessionLocation, StoreHours } from '@/types'; - -const FALLBACK_LEAD_TIME = 30; // Default lead time in minutes - -import { useFormContext } from 'react-hook-form'; +import { + FALLBACK_LEAD_TIME, + findFirstAvailablePickupDate, + formatLeadTimeDisplay, + generatePickupTimeSlots, + isAsapAvailable, +} from './utils/generate-pickup-time-slots'; // Map day of week to the corresponding property in hours const dayToProperty = { @@ -136,21 +140,12 @@ export function LocalPickupForm({ return; } - let dateToCheck = new Date(); - const maxDays = locationHours.pickupWindowInDays; - for (let i = 0; i < maxDays; i++) { - const dayOfWeek = dateToCheck.getDay(); - const dayProperty = - dayToProperty[dayOfWeek as keyof typeof dayToProperty]; - if (locationHours.hours[dayProperty]?.enabled) { - const zonedDate = toZonedTime(dateToCheck, locationHours.timeZone); - setSelectedDate(zonedDate); - if (form.getValues('pickupDate') === '') { - form.setValue('pickupDate', format(zonedDate, 'yyyy-MM-dd')); - } - break; + const date = findFirstAvailablePickupDate(locationHours); + if (date) { + setSelectedDate(date); + if (form.getValues('pickupDate') === '') { + form.setValue('pickupDate', format(date, 'yyyy-MM-dd')); } - dateToCheck = addDays(dateToCheck, 1); } }, [session?.locations, getStoreHours, form] @@ -244,192 +239,71 @@ export function LocalPickupForm({ const dayOfWeek = selectedDate.getDay(); const dayProperty = dayToProperty[dayOfWeek as keyof typeof dayToProperty]; const hoursForDay = locationHours.hours[dayProperty]; - if (!hoursForDay?.enabled) { + if ( + !hoursForDay?.enabled || + !hoursForDay.openTime || + !hoursForDay.closeTime + ) { setAvailableTimeSlots([]); return; } + const leadTimeMinutes = locationHours.leadTime || FALLBACK_LEAD_TIME; - // We'll get the raw open time values directly for consistency - if (!hoursForDay.openTime) { - setAvailableTimeSlots([]); - return; - } - const openTimeHours = Number.parseInt( - hoursForDay?.openTime?.split(':')?.[0] ?? '00', - 10 - ); - const openTimeMins = Number.parseInt( - hoursForDay?.openTime?.split(':')?.[1] ?? '00', - 10 - ); - // Create a base date object for the selected date - const baseDate = new Date(selectedDate); - // Set hours and minutes directly to match opening time - const openTime = set(baseDate, { - hours: openTimeHours, - minutes: openTimeMins, - seconds: 0, - milliseconds: 0, + // Delegate timed-slot generation to the pure utility + const timedSlots = generatePickupTimeSlots({ + selectedDate, + storeHours: locationHours, }); - // We'll get the raw close time values directly from the hours object const slots: TimeSlot[] = []; + const isToday = formatTz(selectedDate, 'yyyy-MM-dd', { timeZone: locationTimeZone }) === formatTz(new Date(), 'yyyy-MM-dd', { timeZone: locationTimeZone }); - // Extract hours and minutes for direct string comparison to avoid timezone issues - if (!hoursForDay.closeTime) { - setAvailableTimeSlots([]); - return; - } - const closeTimeHours = Number.parseInt( - hoursForDay?.closeTime?.split(':')?.[0] ?? '23', - 10 - ); - const closeTimeMins = Number.parseInt( - hoursForDay?.closeTime?.split(':')?.[1] ?? '59', - 10 - ); - - // Get the current time in the location's timezone only - const now = toZonedTime(new Date(), locationTimeZone); - const earliestPickup = isToday - ? new Date(now.getTime() + leadTimeMinutes * 60000) - : openTime; - // Initialize currentTime to openTime (exactly matching the hours/minutes) - let currentTime = set(new Date(selectedDate), { - hours: openTimeHours, - minutes: openTimeMins, - seconds: 0, - milliseconds: 0, - }); - - // Only add ASAP option if there will be at least one other time slot available today - // Calculate if any time slots would be available after earliest pickup time - let hasAvailableTimeSlots = false; if (isToday) { + const now = toZonedTime(new Date(), locationTimeZone); const nowInMinutes = now.getHours() * 60 + now.getMinutes(); + const closeTimeHours = Number.parseInt( + hoursForDay.closeTime.split(':')[0] ?? '23', + 10 + ); + const closeTimeMins = Number.parseInt( + hoursForDay.closeTime.split(':')[1] ?? '59', + 10 + ); const closeTimeInMinutes = closeTimeHours * 60 + closeTimeMins; - const minimumBufferMinutes = 30; // Same buffer as in our main logic - - // Check if there's enough time remaining in the day for at least one time slot - if (nowInMinutes + minimumBufferMinutes < closeTimeInMinutes) { - const leadTimeDisplay = - leadTimeMinutes >= 60 - ? `${leadTimeMinutes / 60} ${leadTimeMinutes === 60 ? t.pickup.hour : t.pickup.hours}` - : `${leadTimeMinutes} ${t.pickup.minutes}`; + + let hasAsap = false; + if (isAsapAvailable(nowInMinutes, leadTimeMinutes, closeTimeInMinutes)) { + const leadTimeDisplay = formatLeadTimeDisplay(leadTimeMinutes, { + hour: t.pickup.hour, + hours: t.pickup.hours, + minutes: t.pickup.minutes, + }); slots.push({ label: `${t.pickup.asap} (${leadTimeDisplay})`, value: 'ASAP', }); - hasAvailableTimeSlots = true; - } - if (earliestPickup > openTime) { - const minutesSinceMidnight = - earliestPickup.getHours() * 60 + earliestPickup.getMinutes(); - const roundedMinutes = - Math.ceil(minutesSinceMidnight / leadTimeMinutes) * leadTimeMinutes; - currentTime = set(openTime, { - hours: Math.floor(roundedMinutes / 60), - minutes: roundedMinutes % 60, - seconds: 0, - }); + hasAsap = true; } - // If no time slots will be available today, find the next available day - if (!hasAvailableTimeSlots) { - // Reset the slots array since we'll be finding a new day - slots.length = 0; - - let nextAvailableDate = addDays(selectedDate, 1); - const maxDays = storeHours?.pickupWindowInDays; - let foundNextDay = false; - - for (let i = 1; i < maxDays; i++) { - const nextDayOfWeek = nextAvailableDate.getDay(); - const nextDayProperty = - dayToProperty[nextDayOfWeek as keyof typeof dayToProperty]; - - if (storeHours?.hours[nextDayProperty]?.enabled) { - // We found the next available day - const nextDayZoned = toZonedTime( - nextAvailableDate, - locationTimeZone - ); - setSelectedDate(nextDayZoned); - form.setValue('pickupDate', format(nextDayZoned, 'yyyy-MM-dd')); - foundNextDay = true; - break; - } - - nextAvailableDate = addDays(nextAvailableDate, 1); - } - - // Early return as we'll rerun this effect with the new selected date - if (foundNextDay) { - return; + // If today has no ASAP and no timed slots, jump to the next bookable day + if (!hasAsap && timedSlots.length === 0) { + const nextDate = findFirstAvailablePickupDate(locationHours); + if ( + nextDate && + format(nextDate, 'yyyy-MM-dd') !== format(selectedDate, 'yyyy-MM-dd') + ) { + setSelectedDate(nextDate); + form.setValue('pickupDate', format(nextDate, 'yyyy-MM-dd')); + return; // re-run effect with the new date } } } - while (true) { - // Get the current slot's hour and minute for comparison - const currentSlotHours = currentTime.getHours(); - const currentSlotMins = currentTime.getMinutes(); - - // Make sure current time is at or after opening time - const isAfterOrAtOpeningTime = - currentSlotHours > openTimeHours || - (currentSlotHours === openTimeHours && currentSlotMins >= openTimeMins); - - // If current time is at or after closing time, break - if ( - !isAfterOrAtOpeningTime || - currentSlotHours > closeTimeHours || - (currentSlotHours === closeTimeHours && - currentSlotMins >= closeTimeMins) - ) { - break; - } - if (isToday) { - // If current slot time is before the earliest pickup time (now + lead time), skip to next slot - if (currentTime < earliestPickup) { - currentTime = set(currentTime, { - minutes: currentTime.getMinutes() + leadTimeMinutes, - }); - continue; - } - - const currentTimeInMinutes = - currentTime.getHours() * 60 + currentTime.getMinutes(); - const nowInMinutes = now.getHours() * 60 + now.getMinutes(); - - const minimumBufferMinutes = - locationHours.leadTime || FALLBACK_LEAD_TIME; // Use locationHours.leadTime with fallback - if (currentTimeInMinutes < nowInMinutes + minimumBufferMinutes) { - currentTime = set(currentTime, { - minutes: currentTime.getMinutes() + leadTimeMinutes, - }); - continue; - } - } - const timeString = formatTz(currentTime, 'HH:mm', { - timeZone: locationTimeZone, - }); - // Format the time in the location's timezone, not the user's timezone - const slotLabel = formatTz(currentTime, 'h:mm a', { - timeZone: locationTimeZone, - }); - slots.push({ - label: slotLabel, - value: timeString, // <-- Only HH:MM - }); - currentTime = set(currentTime, { - minutes: currentTime.getMinutes() + leadTimeMinutes, - }); - } + slots.push(...timedSlots); slots.sort((a, b) => { if (a.value === 'ASAP') return -1; if (b.value === 'ASAP') return 1; 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 new file mode 100644 index 00000000..1a5e436d --- /dev/null +++ b/packages/react/src/components/checkout/pickup/utils/generate-pickup-time-slots.test.ts @@ -0,0 +1,865 @@ +import { addDays } from 'date-fns'; +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_SLOT_INTERVAL, + findFirstAvailablePickupDate, + formatLeadTimeDisplay, + generatePickupTimeSlots, + isAsapAvailable, + type OperatingHours, +} from './generate-pickup-time-slots'; + +// ── helpers ────────────────────────────────────────────────────────────── + +const enabledDay = { enabled: true, openTime: '10:00', closeTime: '15:00' }; +const disabledDay = { enabled: false, openTime: null, closeTime: null }; + +const standardWeek = { + monday: enabledDay, + tuesday: enabledDay, + wednesday: enabledDay, + thursday: enabledDay, + friday: enabledDay, + saturday: enabledDay, + sunday: disabledDay, +}; + +function makeHours(overrides: Partial = {}): OperatingHours { + return { + leadTime: 30, + pickupWindowInDays: 7, + timeZone: 'UTC', + hours: standardWeek, + ...overrides, + }; +} + +// Monday 2024-03-25 at 10:00 UTC +const MON_10AM = new Date('2024-03-25T10:00:00Z'); +const TUE = addDays(MON_10AM, 1); // 2024-03-26 +const WED = addDays(MON_10AM, 2); // 2024-03-27 +const _THU = addDays(MON_10AM, 3); // 2024-03-28 + +function slotValues(slots: { value: string }[]) { + return slots.map(s => s.value); +} + +// ── findFirstAvailablePickupDate ───────────────────────────────────────── + +describe('findFirstAvailablePickupDate', () => { + it('returns today when leadTime is small and today is enabled', () => { + const date = findFirstAvailablePickupDate( + makeHours({ leadTime: 30, pickupWindowInDays: 3 }), + MON_10AM + ); + expect(date).toBeDefined(); + expect(date!.getUTCDate()).toBe(25); // Monday + }); + + it('skips today if close time is before earliestPickup', () => { + // now = Mon 10am, leadTime = 360 (6 h), earliestPickup = Mon 4pm + // Mon close = 3pm < 4pm → skip; Tue close = 3pm Tue > Mon 4pm → pick Tue + const date = findFirstAvailablePickupDate( + makeHours({ leadTime: 360, pickupWindowInDays: 3 }), + MON_10AM + ); + expect(date).toBeDefined(); + expect(date!.getUTCDate()).toBe(26); // Tuesday + }); + + it('skips disabled days', () => { + // Sunday is disabled; start on Sunday + const sunday = new Date('2024-03-24T10:00:00Z'); + const date = findFirstAvailablePickupDate( + makeHours({ leadTime: 30, pickupWindowInDays: 3 }), + sunday + ); + expect(date).toBeDefined(); + expect(date!.getUTCDay()).toBe(1); // Monday + }); + + it('returns undefined when leadTime exceeds entire window', () => { + // 5500 min ≈ 3.82 days, window = 3 days + const date = findFirstAvailablePickupDate( + makeHours({ leadTime: 5500, pickupWindowInDays: 3 }), + MON_10AM + ); + expect(date).toBeUndefined(); + }); + + it('returns undefined when leadTime is 14400 with 3-day window', () => { + const date = findFirstAvailablePickupDate( + makeHours({ leadTime: 14400, pickupWindowInDays: 3 }), + MON_10AM + ); + expect(date).toBeUndefined(); + }); + + it('finds day 3 with 2-day leadTime and 3-day window', () => { + // leadTime 2880 min = 2 days; earliestPickup = Wed 10am + // Mon close 3pm < Wed 10am → skip + // Tue close 3pm Tue < Wed 10am → skip + // Wed close 3pm Wed > Wed 10am → pick + const date = findFirstAvailablePickupDate( + makeHours({ leadTime: 2880, pickupWindowInDays: 3 }), + MON_10AM + ); + expect(date).toBeDefined(); + expect(date!.getUTCDate()).toBe(27); // Wednesday + }); + + it('returns today for pickupWindowInDays 0 (ASAP-only)', () => { + const date = findFirstAvailablePickupDate( + makeHours({ leadTime: 99999, pickupWindowInDays: 0 }), + MON_10AM + ); + expect(date).toBeDefined(); + expect(date!.getUTCDate()).toBe(25); + }); + + it('returns undefined when no days are enabled', () => { + const allDisabled = { + monday: disabledDay, + tuesday: disabledDay, + wednesday: disabledDay, + thursday: disabledDay, + friday: disabledDay, + saturday: disabledDay, + sunday: disabledDay, + }; + const date = findFirstAvailablePickupDate( + makeHours({ hours: allDisabled, pickupWindowInDays: 7 }), + MON_10AM + ); + expect(date).toBeUndefined(); + }); + + describe('pickupWindowInDays: 1 (single day)', () => { + it('returns today when today is enabled and has slots after leadTime', () => { + const date = findFirstAvailablePickupDate( + makeHours({ leadTime: 30, pickupWindowInDays: 1 }), + MON_10AM + ); + expect(date).toBeDefined(); + expect(date!.getUTCDate()).toBe(25); + }); + + it('returns undefined when today is disabled (cannot fall back to tomorrow)', () => { + const hours = { + ...standardWeek, + monday: disabledDay, + }; + const date = findFirstAvailablePickupDate( + makeHours({ hours, pickupWindowInDays: 1 }), + MON_10AM + ); + expect(date).toBeUndefined(); + }); + + it('returns undefined when leadTime pushes past today close', () => { + const date = findFirstAvailablePickupDate( + makeHours({ leadTime: 360, pickupWindowInDays: 1 }), + // Mon 10am + 360min lead = 4pm, close is 3pm + MON_10AM + ); + expect(date).toBeUndefined(); + }); + + it('returns today when leadTime still fits before close', () => { + const date = findFirstAvailablePickupDate( + makeHours({ leadTime: 240, pickupWindowInDays: 1 }), + // Mon 10am + 240min lead = 2pm, close is 3pm — fits + MON_10AM + ); + expect(date).toBeDefined(); + expect(date!.getUTCDate()).toBe(25); + }); + }); + + it('uses FALLBACK_LEAD_TIME when leadTime is 0', () => { + // leadTime 0 → falls back to 30 min + const date = findFirstAvailablePickupDate( + makeHours({ leadTime: 0, pickupWindowInDays: 3 }), + MON_10AM + ); + expect(date).toBeDefined(); + expect(date!.getUTCDate()).toBe(25); // today still works with 30 min lead + }); +}); + +// ── generatePickupTimeSlots ────────────────────────────────────────────── + +describe('generatePickupTimeSlots', () => { + // ── basic / edge cases ─────────────────────────────────────────────── + + it('returns empty when pickupWindowInDays is 0', () => { + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ pickupWindowInDays: 0 }), + 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({ + selectedDate: sunday, + storeHours: makeHours(), + now: MON_10AM, + }); + expect(slots).toEqual([]); + }); + + it('returns empty when the day has no openTime', () => { + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ + hours: { + ...standardWeek, + tuesday: { enabled: true, openTime: null, closeTime: '15:00' }, + }, + }), + now: MON_10AM, + }); + expect(slots).toEqual([]); + }); + + // ── leadTime 0 (falls back to FALLBACK_LEAD_TIME = 30) ────────────── + + describe('leadTime: 0 (uses fallback of 30 min)', () => { + const hours = makeHours({ leadTime: 0 }); + + it('generates 30-min slots on a future date', () => { + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: hours, + now: MON_10AM, + }); + // 10:00–14:30 in 30-min steps = 10 slots + expect(slots).toHaveLength(10); + expect(slotValues(slots)[0]).toBe('10:00'); + expect(slotValues(slots)[9]).toBe('14:30'); + }); + }); + + // ── leadTime: 15 ───────────────────────────────────────────────────── + + describe('leadTime: 15', () => { + const hours = makeHours({ leadTime: 15 }); + + it('generates slots at default 30-min interval (pickupSlotInterval not set)', () => { + // leadTime=15 but pickupSlotInterval defaults to 30 + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: hours, + now: MON_10AM, + }); + expect(slots).toHaveLength(10); + expect(slotValues(slots)[0]).toBe('10:00'); + expect(slotValues(slots)[1]).toBe('10:30'); + }); + + it('generates slots at 15-min interval when pickupSlotInterval is 15', () => { + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: { ...hours, pickupSlotInterval: 15 }, + now: MON_10AM, + }); + // 10:00–14:45 in 15-min steps = 20 slots + expect(slots).toHaveLength(20); + expect(slotValues(slots)[0]).toBe('10:00'); + expect(slotValues(slots)[1]).toBe('10:15'); + expect(slotValues(slots)[19]).toBe('14:45'); + }); + }); + + // ── leadTime: 30 (standard) ────────────────────────────────────────── + + describe('leadTime: 30', () => { + const hours = makeHours({ leadTime: 30 }); + + it('generates 10 slots for a future date (10:00–15:00, 30-min gap)', () => { + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: hours, + now: MON_10AM, + }); + expect(slots).toHaveLength(10); + expect(slotValues(slots)).toEqual([ + '10:00', + '10:30', + '11:00', + '11:30', + '12:00', + '12:30', + '13:00', + '13:30', + '14:00', + '14:30', + ]); + }); + + it('skips slots before earliestPickup for today', () => { + // now = Mon 10am, leadTime = 30, earliest = 10:30 + // First timed slot should be 10:30 (10:00 skipped) + const slots = generatePickupTimeSlots({ + selectedDate: MON_10AM, + storeHours: hours, + now: MON_10AM, + }); + expect(slotValues(slots)[0]).toBe('10:30'); + expect(slots).toHaveLength(9); + }); + }); + + // ── leadTime: 60 ───────────────────────────────────────────────────── + + describe('leadTime: 60', () => { + it('generates slots at 30-min default interval, skipping the first hour for today', () => { + // now = 10am, leadTime 60, earliest = 11am + // Rounding: ceil(660/30)*30 = 660 → 11:00 + // Slots: 11:00, 11:30, 12:00, 12:30, 13:00, 13:30, 14:00, 14:30 = 8 + const slots = generatePickupTimeSlots({ + selectedDate: MON_10AM, + storeHours: makeHours({ leadTime: 60 }), + now: MON_10AM, + }); + expect(slotValues(slots)[0]).toBe('11:00'); + expect(slots).toHaveLength(8); + }); + + it('generates all slots for a future date (lead time already satisfied)', () => { + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ leadTime: 60 }), + now: MON_10AM, + }); + expect(slots).toHaveLength(10); + expect(slotValues(slots)[0]).toBe('10:00'); + }); + + it('with pickupSlotInterval: 60, generates hourly slots on a future date', () => { + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ leadTime: 60, pickupSlotInterval: 60 }), + now: MON_10AM, + }); + expect(slotValues(slots)).toEqual([ + '10:00', + '11:00', + '12:00', + '13:00', + '14:00', + ]); + }); + }); + + // ── leadTime: 120 ──────────────────────────────────────────────────── + + describe('leadTime: 120', () => { + it('with pickupSlotInterval: 120, generates 3 slots on a future date', () => { + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ leadTime: 120, pickupSlotInterval: 120 }), + now: MON_10AM, + }); + expect(slotValues(slots)).toEqual(['10:00', '12:00', '14:00']); + }); + + it('with default pickupSlotInterval (30), generates all 30-min slots on a future date', () => { + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ leadTime: 120 }), + now: MON_10AM, + }); + // All 10 slots available because Tue 10am is well past Mon 10am + 2h + expect(slots).toHaveLength(10); + }); + }); + + // ── leadTime: 300 (5 h = full daily window) ────────────────────────── + + describe('leadTime: 300 (equals the 5-hour daily window)', () => { + it('still shows all 30-min slots on a future date (leadTime satisfied by date distance)', () => { + // Tue 10am is 24h after Mon 10am — well past the 5h lead + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ leadTime: 300 }), + now: MON_10AM, + }); + expect(slots).toHaveLength(10); + }); + + it('generates zero timed slots for today', () => { + // earliestPickup = Mon 3pm, close = 3pm → no room + const slots = generatePickupTimeSlots({ + selectedDate: MON_10AM, + storeHours: makeHours({ leadTime: 300 }), + now: MON_10AM, + }); + expect(slots).toHaveLength(0); + }); + }); + + // ── leadTime: 1440 (exactly 1 day) ────────────────────────────────── + + describe('leadTime: 1440 (1 day)', () => { + it('generates all 30-min slots on Tue when now is Mon 10am (lead satisfied)', () => { + // earliestPickup = Tue 10am. Tue 10:00 >= Tue 10:00 → all slots valid + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ leadTime: 1440 }), + now: MON_10AM, + }); + expect(slots).toHaveLength(10); + expect(slotValues(slots)[0]).toBe('10:00'); + }); + + it('generates zero slots on Tue when now is Mon noon (lead pushes past Tue open)', () => { + // now = Mon 12pm, earliestPickup = Tue 12pm + // Tue slots 10:00–11:30 all < Tue 12pm → skipped + // Tue slot 12:00 ≥ Tue 12pm → generated! + const monNoon = new Date('2024-03-25T12:00:00Z'); + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ leadTime: 1440 }), + now: monNoon, + }); + expect(slotValues(slots)[0]).toBe('12:00'); + expect(slots).toHaveLength(6); // 12:00, 12:30, 13:00, 13:30, 14:00, 14:30 + }); + + it('generates zero timed slots for today', () => { + const slots = generatePickupTimeSlots({ + selectedDate: MON_10AM, + storeHours: makeHours({ leadTime: 1440 }), + now: MON_10AM, + }); + expect(slots).toHaveLength(0); + }); + + it('does not infinite loop (completes in reasonable time)', () => { + const start = performance.now(); + generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ leadTime: 1440 }), + now: MON_10AM, + }); + expect(performance.now() - start).toBeLessThan(100); // < 100ms + }); + }); + + // ── leadTime: 2880 (2 days) ────────────────────────────────────────── + + describe('leadTime: 2880 (2 days)', () => { + it('generates zero slots on Tue (within lead window)', () => { + // earliestPickup = Wed 10am. Tue 10am–14:30 all < Wed 10am → 0 slots + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ leadTime: 2880 }), + now: MON_10AM, + }); + expect(slots).toHaveLength(0); + }); + + it('generates all slots on Wed (first day past lead window)', () => { + // Wed 10am ≥ Wed 10am → all slots available + const slots = generatePickupTimeSlots({ + selectedDate: WED, + storeHours: makeHours({ leadTime: 2880 }), + now: MON_10AM, + }); + expect(slots).toHaveLength(10); + expect(slotValues(slots)[0]).toBe('10:00'); + }); + + it('does not infinite loop (completes in reasonable time)', () => { + const start = performance.now(); + generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ leadTime: 2880 }), + now: MON_10AM, + }); + expect(performance.now() - start).toBeLessThan(100); + }); + }); + + // ── leadTime: 5500 (~3.82 days) ───────────────────────────────────── + + describe('leadTime: 5500 (~3.82 days)', () => { + const hours = makeHours({ leadTime: 5500, pickupWindowInDays: 3 }); + + it('generates zero slots for Mon, Tue, and Wed', () => { + for (const date of [MON_10AM, TUE, WED]) { + const slots = generatePickupTimeSlots({ + selectedDate: date, + storeHours: hours, + now: MON_10AM, + }); + expect(slots).toHaveLength(0); + } + }); + + it('does not infinite loop even for every day in the window', () => { + const start = performance.now(); + for (const date of [MON_10AM, TUE, WED]) { + generatePickupTimeSlots({ + selectedDate: date, + storeHours: hours, + now: MON_10AM, + }); + } + expect(performance.now() - start).toBeLessThan(100); + }); + }); + + // ── leadTime: 14400 (10 days) ──────────────────────────────────────── + + describe('leadTime: 14400 (10 days)', () => { + it('generates zero slots for every day in a 7-day window', () => { + const hours = makeHours({ leadTime: 14400, pickupWindowInDays: 7 }); + for (let i = 0; i < 7; i++) { + const date = addDays(MON_10AM, i); + const slots = generatePickupTimeSlots({ + selectedDate: date, + storeHours: hours, + now: MON_10AM, + }); + expect(slots).toHaveLength(0); + } + }); + + it('does not infinite loop', () => { + const hours = makeHours({ leadTime: 14400, pickupWindowInDays: 7 }); + const start = performance.now(); + for (let i = 0; i < 7; i++) { + generatePickupTimeSlots({ + selectedDate: addDays(MON_10AM, i), + storeHours: hours, + now: MON_10AM, + }); + } + expect(performance.now() - start).toBeLessThan(100); + }); + }); + + // ── pickupWindowInDays: 1 (single day) ──────────────────────────────── + + describe('pickupWindowInDays: 1 (single day)', () => { + it('generates slots for today when leadTime is small', () => { + const slots = generatePickupTimeSlots({ + selectedDate: MON_10AM, + storeHours: makeHours({ leadTime: 30, pickupWindowInDays: 1 }), + now: MON_10AM, + }); + // 10:00 + 30min lead = 10:30 earliest; slots: 10:30–14:30 = 9 slots + expect(slots.length).toBeGreaterThan(0); + expect(slotValues(slots)[0]).toBe('10:30'); + }); + + it('generates all slots when selected date is today and leadTime already passed open', () => { + // now is 10am, leadTime 0 → fallback 30min, earliest 10:30 + const slots = generatePickupTimeSlots({ + selectedDate: MON_10AM, + storeHours: makeHours({ leadTime: 0, pickupWindowInDays: 1 }), + now: MON_10AM, + }); + expect(slots.length).toBeGreaterThan(0); + expect(slotValues(slots)[0]).toBe('10:30'); + }); + + it('generates zero slots when leadTime exceeds remaining hours', () => { + const slots = generatePickupTimeSlots({ + selectedDate: MON_10AM, + storeHours: makeHours({ leadTime: 360, pickupWindowInDays: 1 }), + now: MON_10AM, + }); + // 10am + 360min = 4pm, close is 3pm → no slots + expect(slots).toEqual([]); + }); + + it('generates fewer slots as leadTime eats into the window', () => { + const slotsShortLead = generatePickupTimeSlots({ + selectedDate: MON_10AM, + storeHours: makeHours({ leadTime: 30, pickupWindowInDays: 1 }), + now: MON_10AM, + }); + const slotsLongLead = generatePickupTimeSlots({ + selectedDate: MON_10AM, + storeHours: makeHours({ leadTime: 180, pickupWindowInDays: 1 }), + now: MON_10AM, + }); + // 30min lead → first slot 10:30; 180min lead → first slot 13:00 + expect(slotsShortLead.length).toBeGreaterThan(slotsLongLead.length); + expect(slotValues(slotsLongLead)[0]).toBe('13:00'); + }); + + it('respects pickupSlotInterval with single day window', () => { + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ + leadTime: 1440, + pickupWindowInDays: 1, + pickupSlotInterval: 60, + }), + now: MON_10AM, + }); + // Tomorrow (Tue) with 1-day lead satisfied, 60-min interval: 10:00–14:00 = 5 slots + expect(slotValues(slots)).toEqual([ + '10:00', + '11:00', + '12:00', + '13:00', + '14:00', + ]); + }); + }); + + // ── pickupSlotInterval (decoupled from leadTime) ─────────────────────────── + + describe('pickupSlotInterval (decoupled from leadTime)', () => { + it('uses DEFAULT_SLOT_INTERVAL (30) when pickupSlotInterval is not set', () => { + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ leadTime: 1440 }), + now: MON_10AM, + }); + // 30-min interval: 10:00–14:30 = 10 slots + expect(slots).toHaveLength(10); + expect(DEFAULT_SLOT_INTERVAL).toBe(30); + }); + + it('uses pickupSlotInterval: 15 for 15-min gaps with a 1-day leadTime', () => { + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ leadTime: 1440, pickupSlotInterval: 15 }), + now: MON_10AM, + }); + // 15-min interval: 10:00–14:45 = 20 slots + expect(slots).toHaveLength(20); + expect(slotValues(slots)[1]).toBe('10:15'); + }); + + it('uses pickupSlotInterval: 60 for hourly gaps with a 2-day leadTime', () => { + const slots = generatePickupTimeSlots({ + selectedDate: WED, + storeHours: makeHours({ leadTime: 2880, pickupSlotInterval: 60 }), + now: MON_10AM, + }); + expect(slotValues(slots)).toEqual([ + '10:00', + '11:00', + '12:00', + '13:00', + '14:00', + ]); + }); + + it('ignores pickupSlotInterval: 0 and uses default', () => { + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ leadTime: 30, pickupSlotInterval: 0 }), + now: MON_10AM, + }); + // Falls back to 30-min interval + expect(slots).toHaveLength(10); + }); + + it('ignores negative pickupSlotInterval and uses default', () => { + const slots = generatePickupTimeSlots({ + selectedDate: TUE, + storeHours: makeHours({ leadTime: 30, pickupSlotInterval: -15 }), + now: MON_10AM, + }); + expect(slots).toHaveLength(10); + }); + }); + + // ── timezone-aware tests ───────────────────────────────────────────── + + describe('timezone handling', () => { + // 2024-03-25T15:00:00Z = Mon 10:00 AM in America/New_York (EDT, UTC-4) + // but still Mon 15:00 UTC + const MON_10AM_NYC = new Date('2024-03-25T14:00:00Z'); + + function makeNYCHours( + overrides: Partial = {} + ): OperatingHours { + return { + leadTime: 30, + pickupWindowInDays: 7, + timeZone: 'America/New_York', + hours: standardWeek, + ...overrides, + }; + } + + it('findFirstAvailablePickupDate respects store timezone', () => { + // UTC 14:00 = NYC 10:00 AM, leadTime 30 → earliest 10:30, close 15:00 → today works + const date = findFirstAvailablePickupDate( + makeNYCHours({ leadTime: 30, pickupWindowInDays: 1 }), + MON_10AM_NYC + ); + expect(date).toBeDefined(); + }); + + it('findFirstAvailablePickupDate returns undefined when leadTime exceeds remaining store hours', () => { + // UTC 14:00 = NYC 10:00 AM, leadTime 360 → earliest 4pm NYC, close 3pm → no slots + const date = findFirstAvailablePickupDate( + makeNYCHours({ leadTime: 360, pickupWindowInDays: 1 }), + MON_10AM_NYC + ); + expect(date).toBeUndefined(); + }); + + it('generatePickupTimeSlots produces correct slot times in store timezone', () => { + const nycDate = new Date('2024-03-26T14:00:00Z'); // Tue 10am NYC + const slots = generatePickupTimeSlots({ + selectedDate: nycDate, + storeHours: makeNYCHours({ leadTime: 1440 }), + now: MON_10AM_NYC, + }); + // Lead time satisfied (>1 day ahead), slots should start at store open (10:00 NYC) + expect(slots.length).toBeGreaterThan(0); + expect(slotValues(slots)[0]).toBe('10:00'); + }); + + it('generates zero slots when current time is past close in store timezone', () => { + // UTC 20:00 Mon = Mon 4pm NYC (EDT) — past 3pm close, same day in both timezones + const pastCloseNYC = new Date('2024-03-25T20:00:00Z'); + const slots = generatePickupTimeSlots({ + selectedDate: pastCloseNYC, + storeHours: makeNYCHours({ leadTime: 30, pickupWindowInDays: 7 }), + now: pastCloseNYC, + }); + // In NYC it's Mon 4pm, store closes at 3pm — zero slots + expect(slots).toEqual([]); + }); + + it('uses store timezone day-of-week, not system timezone', () => { + // UTC 03:00 Tue = Mon 11pm NYC — getDay() in UTC would say Tuesday, + // but in the store timezone it's still Monday + const lateNightUTC = new Date('2024-03-26T03:00:00Z'); + const slots = generatePickupTimeSlots({ + selectedDate: lateNightUTC, + storeHours: makeNYCHours({ leadTime: 30, pickupWindowInDays: 7 }), + now: lateNightUTC, + }); + // In NYC it's Mon 11pm, store closes at 3pm — zero slots + expect(slots).toEqual([]); + }); + + it('next-day rollover: UTC date is tomorrow but store timezone is still today', () => { + // UTC 01:00 Tue Mar 26 = Mon 9pm EDT Mar 25 — still Monday in NYC + const utcTueMorning = new Date('2024-03-26T01:00:00Z'); + const date = findFirstAvailablePickupDate( + makeNYCHours({ leadTime: 30, pickupWindowInDays: 1 }), + utcTueMorning + ); + // In NYC it's Mon 9pm, 30min lead = 9:30pm, close is 3pm → no slots today + expect(date).toBeUndefined(); + }); + + // US West Coast: UTC-7 (PDT in March 2024) + it('works with America/Los_Angeles timezone', () => { + // UTC 17:00 Mon = 10:00 AM PDT + const monMorningLA = new Date('2024-03-25T17:00:00Z'); + const laHours: OperatingHours = { + leadTime: 60, + pickupWindowInDays: 3, + timeZone: 'America/Los_Angeles', + hours: standardWeek, + }; + const date = findFirstAvailablePickupDate(laHours, monMorningLA); + expect(date).toBeDefined(); + + const slots = generatePickupTimeSlots({ + selectedDate: date!, + storeHours: laHours, + now: monMorningLA, + }); + // 10am + 60min lead = 11am, 30-min slots: 11:00–14:30 = 8 slots + expect(slots.length).toBeGreaterThan(0); + expect(slotValues(slots)[0]).toBe('11:00'); + }); + }); +}); + +// ── formatLeadTimeDisplay ──────────────────────────────────────────────── + +const labels = { hour: 'hour', hours: 'hours', minutes: 'minutes' }; + +describe('formatLeadTimeDisplay', () => { + it('shows only minutes when under an hour', () => { + expect(formatLeadTimeDisplay(45, labels)).toBe('45 minutes'); + }); + + it('shows only minutes for small values', () => { + expect(formatLeadTimeDisplay(5, labels)).toBe('5 minutes'); + }); + + it('shows singular hour for exactly 60 minutes', () => { + expect(formatLeadTimeDisplay(60, labels)).toBe('1 hour'); + }); + + it('shows plural hours for exact multiples', () => { + expect(formatLeadTimeDisplay(120, labels)).toBe('2 hours'); + expect(formatLeadTimeDisplay(1440, labels)).toBe('24 hours'); + }); + + it('shows hours and minutes for non-exact values', () => { + expect(formatLeadTimeDisplay(90, labels)).toBe('1 hour 30 minutes'); + expect(formatLeadTimeDisplay(1400, labels)).toBe('23 hours 20 minutes'); + }); + + it('handles large lead times', () => { + expect(formatLeadTimeDisplay(5500, labels)).toBe('91 hours 40 minutes'); + }); + + it('handles zero', () => { + expect(formatLeadTimeDisplay(0, labels)).toBe('0 minutes'); + }); +}); + +// ── isAsapAvailable ────────────────────────────────────────────────────── + +describe('isAsapAvailable', () => { + // Store closes at 3pm (900 minutes since midnight) + const closeTime = 15 * 60; // 900 + + it('returns true when now + leadTime is before close', () => { + // 10am (600) + 30min lead = 10:30am < 3pm + expect(isAsapAvailable(600, 30, closeTime)).toBe(true); + }); + + it('returns false when now + leadTime is after close', () => { + // 10am (600) + 1400min lead = ~9:20pm next day > 3pm + expect(isAsapAvailable(600, 1400, closeTime)).toBe(false); + }); + + it('returns false when now + leadTime equals close exactly', () => { + // 2pm (840) + 60min lead = 3pm = close → not strictly before + expect(isAsapAvailable(840, 60, closeTime)).toBe(false); + }); + + it('returns true when just barely fits before close', () => { + // 2pm (840) + 59min lead = 2:59pm < 3pm + expect(isAsapAvailable(840, 59, closeTime)).toBe(true); + }); + + it('returns false when already past close', () => { + // 4pm (960) + 30min lead = 4:30pm > 3pm + expect(isAsapAvailable(960, 30, closeTime)).toBe(false); + }); + + it('returns false with large leadTime and early now', () => { + // 9am (540) + 360min (6hr) lead = 3pm = close → not strictly before + expect(isAsapAvailable(540, 360, closeTime)).toBe(false); + }); + + it('returns true with zero leadTime', () => { + // 2pm (840) + 0 = 2pm < 3pm + expect(isAsapAvailable(840, 0, closeTime)).toBe(true); + }); +}); 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 new file mode 100644 index 00000000..ca33f7c3 --- /dev/null +++ b/packages/react/src/components/checkout/pickup/utils/generate-pickup-time-slots.ts @@ -0,0 +1,260 @@ +import { addDays, format, set } from 'date-fns'; +import { toZonedTime } from 'date-fns-tz'; + +export const DEFAULT_SLOT_INTERVAL = 30; +export const FALLBACK_LEAD_TIME = 30; + +export const dayToProperty = { + 0: 'sunday', + 1: 'monday', + 2: 'tuesday', + 3: 'wednesday', + 4: 'thursday', + 5: 'friday', + 6: 'saturday', +} as const; + +export type DayHours = { + enabled: boolean; + openTime: string | null; + closeTime: string | null; +}; + +export type WeekHours = { + monday: DayHours; + tuesday: DayHours; + wednesday: DayHours; + thursday: DayHours; + friday: DayHours; + saturday: DayHours; + sunday: DayHours; +}; + +export type OperatingHours = { + leadTime: number; + pickupWindowInDays: number; + timeZone: string; + hours: WeekHours; + /** + * Interval in minutes between selectable pickup time slots. + * Defaults to DEFAULT_SLOT_INTERVAL (30) when not provided. + * + * Intentionally separate from `leadTime`: + * – leadTime = how much advance notice the store needs + * – pickupSlotInterval = the gap between choosable times (e.g. 30 → 10:00, 10:30, 11:00…) + */ + pickupSlotInterval?: number | null; +}; + +export type TimeSlot = { + label: string; + value: string; +}; + +/** + * Format a lead time in minutes for display. + * e.g. 90 → "1 hour 30 minutes", 60 → "1 hour", 45 → "45 minutes" + */ +export function formatLeadTimeDisplay( + leadTimeMinutes: number, + labels: { hour: string; hours: string; minutes: string } +): string { + const hours = Math.floor(leadTimeMinutes / 60); + const mins = leadTimeMinutes % 60; + if (hours === 0) return `${mins} ${labels.minutes}`; + if (mins === 0) return `${hours} ${hours === 1 ? labels.hour : labels.hours}`; + return `${hours} ${hours === 1 ? labels.hour : labels.hours} ${mins} ${labels.minutes}`; +} + +/** + * Determine whether an ASAP pickup option should be shown for today. + * True when the store can fulfill the order (now + leadTime) before closing. + */ +export function isAsapAvailable( + nowMinutesSinceMidnight: number, + leadTimeMinutes: number, + closeTimeMinutes: number +): boolean { + return nowMinutesSinceMidnight + leadTimeMinutes < closeTimeMinutes; +} + +/** Resolve the effective slot interval, with fallback. */ +function getSlotInterval(hours: OperatingHours): number { + return hours.pickupSlotInterval && hours.pickupSlotInterval > 0 + ? hours.pickupSlotInterval + : DEFAULT_SLOT_INTERVAL; +} + +/** + * Find the first date within the pickup window that has at least one + * bookable time slot, accounting for lead time. + * + * Returns `undefined` when no date in the window qualifies (e.g. when + * leadTime exceeds the entire pickup window). + */ +export function findFirstAvailablePickupDate( + storeHours: OperatingHours, + now?: Date +): Date | undefined { + if (storeHours.pickupWindowInDays === 0) { + // ASAP-only mode — always today + return toZonedTime(now ?? new Date(), storeHours.timeZone); + } + + const leadTimeMinutes = storeHours.leadTime || FALLBACK_LEAD_TIME; + const zonedNow = toZonedTime(now ?? new Date(), storeHours.timeZone); + const earliestPickup = new Date(zonedNow.getTime() + leadTimeMinutes * 60000); + + let dateToCheck = new Date(now ?? Date.now()); + const maxDays = storeHours.pickupWindowInDays; + + for (let i = 0; i < maxDays; i++) { + const zonedDate = toZonedTime(dateToCheck, storeHours.timeZone); + const dayOfWeek = zonedDate.getDay(); + const dayProperty = dayToProperty[dayOfWeek as keyof typeof dayToProperty]; + const dayHours = storeHours.hours[dayProperty]; + + if (dayHours?.enabled && dayHours.closeTime) { + const [closeH, closeM] = dayHours.closeTime.split(':').map(Number); + const dayCloseTime = set(zonedDate, { + hours: closeH, + minutes: closeM, + seconds: 0, + milliseconds: 0, + }); + + if (dayCloseTime > earliestPickup) { + return zonedDate; + } + } + + dateToCheck = addDays(dateToCheck, 1); + } + + return undefined; +} + +/** + * Generate available timed pickup slots for a specific date. + * + * Does NOT include the ASAP option — that is a presentation concern + * handled by the component for "today" only. + * + * Uses `pickupSlotInterval` (default 30 min) for the gap between slots, and + * `leadTime` only for the earliest-pickup constraint (now + leadTime). + */ +export function generatePickupTimeSlots({ + selectedDate, + storeHours, + now: nowInput, +}: { + selectedDate: Date; + storeHours: OperatingHours; + now?: Date; +}): TimeSlot[] { + const tz = storeHours.timeZone; + const leadTimeMinutes = storeHours.leadTime || FALLBACK_LEAD_TIME; + const pickupSlotInterval = getSlotInterval(storeHours); + + if (storeHours.pickupWindowInDays === 0) return []; + + const zonedSelected = toZonedTime(selectedDate, tz); + const dayOfWeek = zonedSelected.getDay(); + const dayProperty = dayToProperty[dayOfWeek as keyof typeof dayToProperty]; + const hoursForDay = storeHours.hours[dayProperty]; + + if ( + !hoursForDay?.enabled || + !hoursForDay.openTime || + !hoursForDay.closeTime + ) { + return []; + } + + const [openTimeHours, openTimeMins] = hoursForDay.openTime + .split(':') + .map(Number); + const [closeTimeHours, closeTimeMins] = hoursForDay.closeTime + .split(':') + .map(Number); + + // All date math uses zoned dates so comparisons are consistent + // when the UTC date and store-local date differ (e.g. UTC Tue 3am = NYC Mon 11pm). + const now = toZonedTime(nowInput ?? new Date(), tz); + const earliestPickup = new Date(now.getTime() + leadTimeMinutes * 60000); + + const openTime = set(new Date(zonedSelected), { + hours: openTimeHours, + minutes: openTimeMins, + seconds: 0, + milliseconds: 0, + }); + + const closeDateTime = set(new Date(zonedSelected), { + hours: closeTimeHours, + minutes: closeTimeMins, + seconds: 0, + milliseconds: 0, + }); + + const isToday = + format(zonedSelected, 'yyyy-MM-dd') === format(now, 'yyyy-MM-dd'); + + let currentTime = set(new Date(zonedSelected), { + hours: openTimeHours, + minutes: openTimeMins, + seconds: 0, + milliseconds: 0, + }); + + // For today, round currentTime forward past earliestPickup + if (isToday && earliestPickup > openTime) { + const minutesSinceMidnight = + earliestPickup.getHours() * 60 + earliestPickup.getMinutes(); + const roundedMinutes = + Math.ceil(minutesSinceMidnight / pickupSlotInterval) * pickupSlotInterval; + currentTime = set(openTime, { + hours: Math.floor(roundedMinutes / 60), + minutes: roundedMinutes % 60, + seconds: 0, + }); + } + + const slots: TimeSlot[] = []; + + while (true) { + const currentSlotHours = currentTime.getHours(); + const currentSlotMins = currentTime.getMinutes(); + + const isAfterOrAtOpeningTime = + currentSlotHours > openTimeHours || + (currentSlotHours === openTimeHours && currentSlotMins >= openTimeMins); + + // Full-Date comparison prevents infinite loops when pickupSlotInterval + // or leadTime causes the date to overflow into the next day. + if (!isAfterOrAtOpeningTime || currentTime >= closeDateTime) { + break; + } + + // Skip slots before the earliest possible pickup (now + leadTime). + // Applied to ALL dates so that large lead times correctly produce + // zero slots on future dates still within the lead-time window. + if (currentTime < earliestPickup) { + currentTime = set(currentTime, { + minutes: currentTime.getMinutes() + pickupSlotInterval, + }); + continue; + } + + const timeString = format(currentTime, 'HH:mm'); + const slotLabel = format(currentTime, 'h:mm a'); + + slots.push({ label: slotLabel, value: timeString }); + + currentTime = set(currentTime, { + minutes: currentTime.getMinutes() + pickupSlotInterval, + }); + } + + return slots; +} diff --git a/packages/react/src/lib/godaddy/checkout-env.ts b/packages/react/src/lib/godaddy/checkout-env.ts index c0c4afc2..13d8a695 100644 --- a/packages/react/src/lib/godaddy/checkout-env.ts +++ b/packages/react/src/lib/godaddy/checkout-env.ts @@ -3108,28 +3108,28 @@ const introspection = { }, }, { - name: 'offline', + name: 'mercadopago', type: { kind: 'INPUT_OBJECT', name: 'CheckoutSessionPaymentMethodConfigInput', }, }, { - name: 'paypal', + name: 'offline', type: { kind: 'INPUT_OBJECT', name: 'CheckoutSessionPaymentMethodConfigInput', }, }, { - name: 'paze', + name: 'paypal', type: { kind: 'INPUT_OBJECT', name: 'CheckoutSessionPaymentMethodConfigInput', }, }, { - name: 'mercadopago', + name: 'paze', type: { kind: 'INPUT_OBJECT', name: 'CheckoutSessionPaymentMethodConfigInput', @@ -3297,6 +3297,15 @@ const introspection = { args: [], isDeprecated: false, }, + { + name: 'pickupSlotInterval', + type: { + kind: 'SCALAR', + name: 'Int', + }, + args: [], + isDeprecated: false, + }, { name: 'pickupWindowInDays', type: { @@ -3349,12 +3358,22 @@ const introspection = { }, }, { - name: 'pickupWindowInDays', + name: 'pickupSlotInterval', type: { kind: 'SCALAR', name: 'Int', }, }, + { + name: 'pickupWindowInDays', + type: { + kind: 'NON_NULL', + ofType: { + kind: 'SCALAR', + name: 'Int', + }, + }, + }, { name: 'timeZone', type: { diff --git a/packages/react/src/lib/godaddy/checkout-mutations.ts b/packages/react/src/lib/godaddy/checkout-mutations.ts index e3e77f19..6ff26d1b 100644 --- a/packages/react/src/lib/godaddy/checkout-mutations.ts +++ b/packages/react/src/lib/godaddy/checkout-mutations.ts @@ -143,6 +143,7 @@ export const CreateCheckoutSessionMutation = graphql(` operatingHours { pickupWindowInDays leadTime + pickupSlotInterval timeZone hours { monday { @@ -186,6 +187,7 @@ export const CreateCheckoutSessionMutation = graphql(` defaultOperatingHours { pickupWindowInDays leadTime + pickupSlotInterval timeZone hours { monday { diff --git a/packages/react/src/lib/godaddy/checkout-queries.ts b/packages/react/src/lib/godaddy/checkout-queries.ts index e463a8c3..1cceb93a 100644 --- a/packages/react/src/lib/godaddy/checkout-queries.ts +++ b/packages/react/src/lib/godaddy/checkout-queries.ts @@ -131,6 +131,7 @@ export const GetCheckoutSessionQuery = graphql(` operatingHours { pickupWindowInDays leadTime + pickupSlotInterval timeZone hours { monday { @@ -174,6 +175,7 @@ export const GetCheckoutSessionQuery = graphql(` defaultOperatingHours { pickupWindowInDays leadTime + pickupSlotInterval timeZone hours { monday {