Skip to content
Open
5 changes: 5 additions & 0 deletions .changeset/cold-lemons-camp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@godaddy/react": patch
---

Fix bug with large leadTimes and add pickupSlotInterval to uncouple leadTime from time slot generation
125 changes: 113 additions & 12 deletions packages/react/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -16,7 +9,6 @@ import {
import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes';
import type {
Address,
ShippingMethod,
ShippingMethods,
TokenizeJs,
WalletError,
Expand Down Expand Up @@ -96,6 +88,9 @@ export function ExpressCheckoutButton() {
const confirmCheckout = useConfirmCheckout();
const collect = useRef<TokenizeJs | null>(null);
const hasMounted = useRef(false);
const handleExpressPayClickRef = useRef<
(args: { source?: 'apple_pay' | 'google_pay' | 'paze' }) => Promise<void>
>(async () => undefined);

// Use refs to store current coupon state to avoid stale closures in event handlers
const appliedCouponCodeRef = useRef<string | null>(null);
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand All @@ -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(
(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading
Loading