Skip to content

Commit ed3cbef

Browse files
Add pickupSlotInterval and uncouple leadTime from time slot generation (#1329)
* Add pickupSlotInterval and uncouple leadTime from time slot generation * use store tz for timeslot validation * use leadTime in asap display * extract asap and leadtime display into helpers * update readme * adjust express click handler ref to avoid stale closure * single use effect on gd express payments
1 parent 42dae23 commit ed3cbef

10 files changed

Lines changed: 1397 additions & 288 deletions

File tree

.changeset/cold-lemons-camp.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@godaddy/react": patch
3+
---
4+
5+
Fix bug with large leadTimes and add pickupSlotInterval to uncouple leadTime from time slot generation

packages/react/README.md

Lines changed: 113 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,34 +18,41 @@ The first parameter accepts all checkout session configuration options from the
1818

1919
#### Required Parameters
2020

21-
- **`channelId`** (string): The ID of the sales channel that originated this session
22-
- **`storeId`** (string): The ID of the store this checkout session belongs to
23-
- **`draftOrderId`** (string): The ID of the draft order
21+
- **`storeId`** (string): The ID of the store this checkout session belongs to
2422
- **`returnUrl`** (string): URL to redirect to when user cancels checkout
2523
- **`successUrl`** (string): URL to redirect to after successful checkout
24+
- **`draftOrderId`** (string): ID of an existing draft order (required if `lineItems` not provided)
25+
- **`lineItems`** ([CheckoutSessionLineItemInput!]): Line items to create a draft order from (required if `draftOrderId` not provided)
2626

2727
#### Optional Parameters
2828

29+
- **`appearance`** (GoDaddyAppearanceInput): Appearance configuration for the checkout (see [Appearance](#appearance))
30+
- **`channelId`** (string): The ID of the sales channel that originated this session
2931
- **`customerId`** (string): Customer ID for the checkout session
30-
- **`storeName`** (string): The name of the store this checkout session belongs to
31-
- **`url`** (string): Custom URL for the checkout session
32-
- **`environment`** (enum): Environment - `ote`, `prod`
33-
- **`expiresAt`** (DateTime): When the session expires
32+
- **`enableAddressAutocomplete`** (boolean): Enable address autocomplete
3433
- **`enableBillingAddressCollection`** (boolean): Enable billing address collection
3534
- **`enableLocalPickup`** (boolean): Enable local pickup option
3635
- **`enableNotesCollection`** (boolean): Enable order notes collection
3736
- **`enablePaymentMethodCollection`** (boolean): Enable payment method collection
3837
- **`enablePhoneCollection`** (boolean): Enable phone number collection
3938
- **`enablePromotionCodes`** (boolean): Enable promotion/discount codes
39+
- **`enableShipping`** (boolean): Enable shipping
4040
- **`enableShippingAddressCollection`** (boolean): Enable shipping address collection
4141
- **`enableSurcharge`** (boolean): Enable surcharge fees
4242
- **`enableTaxCollection`** (boolean): Enable tax collection
4343
- **`enableTips`** (boolean): Enable tip/gratuity options
4444
- **`enabledLocales`** ([String!]): List of enabled locales
4545
- **`enabledPaymentProviders`** ([String!]): List of enabled payment providers
46+
- **`environment`** (enum): Environment - `ote`, `prod`
47+
- **`expiresAt`** (DateTime): When the session expires
4648
- **`locations`** ([CheckoutSessionLocationInput!]): Available pickup locations
47-
- **`operatingHours`** (CheckoutSessionOperatingHoursMapInput): Store operating hours configuration
49+
- **`operatingHours`** (CheckoutSessionOperatingHoursMapInput): Store operating hours configuration (see [Operating Hours](#operating-hours))
4850
- **`paymentMethods`** (CheckoutSessionPaymentMethodsInput): Payment method configurations
51+
- **`shipping`** (CheckoutSessionShippingOptionsInput): Shipping configuration — primarily used to set an `originAddress` for shipping rate calculations and an optional `fulfillmentLocationId`
52+
- **`sourceApp`** (string): The source application that created this checkout session
53+
- **`storeName`** (string): The name of the store this checkout session belongs to
54+
- **`taxes`** (CheckoutSessionTaxesOptionsInput): Tax configuration — used to set an `originAddress` for tax calculations (e.g. the store or warehouse address that taxes are calculated from)
55+
- **`url`** (string): Custom URL for the checkout session
4956

5057
### Checkout Session Options
5158

@@ -76,12 +83,106 @@ The checkout session supports multiple environments through the input parameter:
7683

7784
### API Scopes
7885

79-
The checkout session automatically requests the following OAuth2 scopes:
86+
The checkout session automatically requests the following OAuth2 scope:
8087

8188
- `commerce.product:read`
82-
- `commerce.order:read`
83-
- `commerce.order:update`
84-
- `location.address-verification:execute`
89+
90+
### Operating Hours
91+
92+
The `operatingHours` field configures local pickup scheduling — time zones, lead times, pickup windows, and slot intervals.
93+
94+
```typescript
95+
operatingHours: {
96+
default: {
97+
timeZone: 'America/New_York',
98+
leadTime: 60,
99+
pickupWindowInDays: 7,
100+
pickupSlotInterval: 30,
101+
hours: {
102+
monday: { enabled: true, openTime: '09:00', closeTime: '17:00' },
103+
tuesday: { enabled: true, openTime: '09:00', closeTime: '17:00' },
104+
wednesday: { enabled: true, openTime: '09:00', closeTime: '17:00' },
105+
thursday: { enabled: true, openTime: '09:00', closeTime: '17:00' },
106+
friday: { enabled: true, openTime: '09:00', closeTime: '18:00' },
107+
saturday: { enabled: true, openTime: '10:00', closeTime: '16:00' },
108+
sunday: { enabled: false, openTime: null, closeTime: null },
109+
},
110+
},
111+
}
112+
```
113+
114+
#### Store Hours Fields
115+
116+
| Field | Type | Required | Description |
117+
|-------|------|----------|-------------|
118+
| `timeZone` | string | Yes | IANA timezone for the store (e.g. `America/New_York`). All slot times are displayed in this timezone. |
119+
| `leadTime` | number | Yes | Minimum advance notice in minutes before a pickup can be scheduled. Controls the earliest available slot (now + leadTime). |
120+
| `pickupWindowInDays` | number | Yes | Number of days ahead customers can schedule pickup. Set to `0` for ASAP-only mode (no date/time picker). |
121+
| `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. |
122+
| `hours` | object | Yes | Per-day operating hours. Each day has `enabled` (boolean), `openTime` (HH:mm or null), and `closeTime` (HH:mm or null). |
123+
124+
#### Behavior Notes
125+
126+
- **ASAP option** — Shown for today only, when the store can fulfill an order (now + leadTime) before closing time.
127+
- **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.
128+
- **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.
129+
- **No available slots** — When leadTime exceeds the entire pickup window, or no days are enabled, a "No available time slots" banner is shown.
130+
131+
### Appearance
132+
133+
The `appearance` field customizes the checkout's look and feel.
134+
135+
```typescript
136+
appearance: {
137+
theme: 'base',
138+
variables: {
139+
primary: '#4f46e5',
140+
background: '#ffffff',
141+
foreground: '#111827',
142+
radius: '0.5rem',
143+
},
144+
}
145+
```
146+
147+
#### Theme
148+
149+
| Value | Description |
150+
|-------|-------------|
151+
| `base` | Default theme |
152+
| `orange` | Orange accent theme |
153+
| `purple` | Purple accent theme |
154+
155+
#### CSS Variables
156+
157+
All fields are optional strings. Pass any subset to override the defaults.
158+
159+
| Variable | Description |
160+
|----------|-------------|
161+
| `accent` | Accent color |
162+
| `accentForeground` | Text on accent backgrounds |
163+
| `background` | Page background |
164+
| `border` | Border color |
165+
| `card` | Card background |
166+
| `cardForeground` | Text on cards |
167+
| `defaultFontFamily` | Default font family |
168+
| `destructive` | Destructive action color (errors, delete) |
169+
| `destructiveForeground` | Text on destructive backgrounds |
170+
| `fontMono` | Monospace font family |
171+
| `fontSans` | Sans-serif font family |
172+
| `fontSerif` | Serif font family |
173+
| `foreground` | Primary text color |
174+
| `input` | Input field background |
175+
| `muted` | Muted/subtle background |
176+
| `mutedForeground` | Text on muted backgrounds |
177+
| `popover` | Popover background |
178+
| `popoverForeground` | Text in popovers |
179+
| `primary` | Primary brand color |
180+
| `primaryForeground` | Text on primary backgrounds |
181+
| `radius` | Border radius (e.g. `0.5rem`) |
182+
| `ring` | Focus ring color |
183+
| `secondary` | Secondary color |
184+
| `secondaryBackground` | Secondary background |
185+
| `secondaryForeground` | Text on secondary backgrounds |
85186

86187
## Codegen
87188

packages/react/src/components/checkout/payment/checkout-buttons/express/godaddy.tsx

Lines changed: 71 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,4 @@
1-
import {
2-
useCallback,
3-
useEffect,
4-
useLayoutEffect,
5-
useMemo,
6-
useRef,
7-
useState,
8-
} from 'react';
1+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
92
import { useFormContext } from 'react-hook-form';
103
import { useCheckoutContext } from '@/components/checkout/checkout';
114
import { useGetPriceAdjustments } from '@/components/checkout/discount/utils/use-get-price-adjustments';
@@ -16,7 +9,6 @@ import {
169
import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes';
1710
import type {
1811
Address,
19-
ShippingMethod,
2012
ShippingMethods,
2113
TokenizeJs,
2214
WalletError,
@@ -96,6 +88,9 @@ export function ExpressCheckoutButton() {
9688
const confirmCheckout = useConfirmCheckout();
9789
const collect = useRef<TokenizeJs | null>(null);
9890
const hasMounted = useRef(false);
91+
const handleExpressPayClickRef = useRef<
92+
(args: { source?: 'apple_pay' | 'google_pay' | 'paze' }) => Promise<void>
93+
>(async () => undefined);
9994

10095
// Use refs to store current coupon state to avoid stale closures in event handlers
10196
const appliedCouponCodeRef = useRef<string | null>(null);
@@ -305,6 +300,9 @@ export function ExpressCheckoutButton() {
305300
]
306301
);
307302

303+
// Keep ref in sync so the SDK's stale onClick closure always calls the latest handler
304+
handleExpressPayClickRef.current = handleExpressPayClick;
305+
308306
// Track the status of coupon code fetching with a state variable
309307
const [couponFetchStatus, setCouponFetchStatus] = useState<
310308
'idle' | 'fetching' | 'done'
@@ -401,20 +399,21 @@ export function ExpressCheckoutButton() {
401399

402400
// Initialize the TokenizeJs instance when the component mounts
403401
// But only after price adjustments have been fetched
404-
useLayoutEffect(() => {
405-
const shouldInitialize =
406-
godaddyPaymentsConfig &&
407-
(godaddyPaymentsConfig?.businessId || session?.businessId) &&
408-
isPoyntLoaded &&
409-
isCollectLoading &&
410-
draftOrder &&
411-
couponFetchStatus === 'done';
412-
413-
if (!shouldInitialize) return;
414-
415-
if (!collect.current && !hasMounted.current) {
402+
// Initialize TokenizeJs and mount wallet buttons in a single effect
403+
useEffect(() => {
404+
if (
405+
!isPoyntLoaded ||
406+
!godaddyPaymentsConfig ||
407+
!isCollectLoading ||
408+
!draftOrder ||
409+
hasMounted.current ||
410+
couponFetchStatus !== 'done' ||
411+
(!godaddyPaymentsConfig?.businessId && !session?.businessId)
412+
)
413+
return;
414+
415+
if (!collect.current) {
416416
// Create coupon config if there's a price adjustment from existing coupon
417-
// Read from refs to get current values
418417
const currentAdjustments = calculatedAdjustmentsRef.current;
419418
const currentCouponCode = appliedCouponCodeRef.current;
420419

@@ -452,8 +451,58 @@ export function ExpressCheckoutButton() {
452451
}
453452
);
454453
}
454+
455+
if (collect.current) {
456+
collect.current.supportWalletPayments().then(supports => {
457+
const paymentMethods: string[] = [];
458+
if (supports.applePay) {
459+
track({
460+
eventId: eventIds.expressApplePayImpression,
461+
type: TrackingEventType.IMPRESSION,
462+
properties: {
463+
provider: 'poynt',
464+
},
465+
});
466+
paymentMethods.push('apple_pay');
467+
}
468+
if (supports.googlePay) {
469+
paymentMethods.push('google_pay');
470+
track({
471+
eventId: eventIds.expressGooglePayImpression,
472+
type: TrackingEventType.IMPRESSION,
473+
properties: {
474+
provider: 'poynt',
475+
},
476+
});
477+
}
478+
479+
if (paymentMethods.length > 0 && !hasMounted.current) {
480+
hasMounted.current = true;
481+
collect.current?.mount('gdpay-express-pay-element', document, {
482+
paymentMethods: paymentMethods,
483+
buttonsContainerOptions: {
484+
className: 'gap-1 !flex-col sm:!flex-row place-items-center',
485+
},
486+
buttonOptions: {
487+
type: 'plain',
488+
margin: '0',
489+
height: '50px',
490+
width: '100%',
491+
justifyContent: 'flex-start',
492+
onClick: (args: {
493+
source?: 'apple_pay' | 'google_pay' | 'paze';
494+
}) => handleExpressPayClickRef.current(args),
495+
},
496+
});
497+
}
498+
});
499+
}
455500
}, [
501+
isPoyntLoaded,
456502
godaddyPaymentsConfig,
503+
isCollectLoading,
504+
draftOrder,
505+
couponFetchStatus,
457506
countryCode,
458507
currencyCode,
459508
session?.businessId,
@@ -462,78 +511,10 @@ export function ExpressCheckoutButton() {
462511
session?.enablePromotionCodes,
463512
session?.enableShippingAddressCollection,
464513
session?.storeName,
465-
isPoyntLoaded,
466-
isCollectLoading,
467-
draftOrder,
468-
couponFetchStatus,
469514
t,
470-
handleExpressPayClick,
471515
formatCurrency,
472516
]);
473517

474-
// Mount the TokenizeJs instance
475-
useEffect(() => {
476-
if (
477-
!isPoyntLoaded ||
478-
!godaddyPaymentsConfig ||
479-
!isCollectLoading ||
480-
!collect.current ||
481-
hasMounted.current ||
482-
(!godaddyPaymentsConfig?.businessId && !session?.businessId)
483-
)
484-
return;
485-
486-
collect.current?.supportWalletPayments().then(supports => {
487-
const paymentMethods: string[] = [];
488-
if (supports.applePay) {
489-
track({
490-
eventId: eventIds.expressApplePayImpression,
491-
type: TrackingEventType.IMPRESSION,
492-
properties: {
493-
provider: 'poynt',
494-
},
495-
});
496-
paymentMethods.push('apple_pay');
497-
}
498-
if (supports.googlePay) {
499-
paymentMethods.push('google_pay');
500-
track({
501-
eventId: eventIds.expressGooglePayImpression,
502-
type: TrackingEventType.IMPRESSION,
503-
properties: {
504-
provider: 'poynt',
505-
},
506-
});
507-
}
508-
// if (supports.paze) paymentMethods.push("paze"); // paze is not an "express" payment and needs to be implemented as a standard flow
509-
510-
if (paymentMethods.length > 0 && !hasMounted.current) {
511-
hasMounted.current = true;
512-
// console.log("[poynt collect] Mounting");
513-
collect?.current?.mount('gdpay-express-pay-element', document, {
514-
paymentMethods: paymentMethods,
515-
buttonsContainerOptions: {
516-
className: 'gap-1 !flex-col sm:!flex-row place-items-center',
517-
},
518-
buttonOptions: {
519-
type: 'plain',
520-
margin: '0',
521-
height: '50px',
522-
width: '100%',
523-
justifyContent: 'flex-start',
524-
onClick: handleExpressPayClick,
525-
},
526-
});
527-
}
528-
});
529-
}, [
530-
isPoyntLoaded,
531-
godaddyPaymentsConfig,
532-
isCollectLoading,
533-
handleExpressPayClick,
534-
session?.businessId,
535-
]);
536-
537518
// Function to convert shipping address to shippingLines format for price adjustments
538519
const convertAddressToShippingLines = useCallback(
539520
(

packages/react/src/components/checkout/payment/checkout-buttons/mercadopago/mercadopago.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ import React, { useCallback, useLayoutEffect, useState } from 'react';
33
import { useFormContext } from 'react-hook-form';
44
import { useCheckoutContext } from '@/components/checkout/checkout';
55
import { useDraftOrderTotals } from '@/components/checkout/order/use-draft-order';
6-
import { formatCurrency } from '@/components/checkout/utils/format-currency';
76
import {
87
PaymentProvider,
98
useConfirmCheckout,
109
} from '@/components/checkout/payment/utils/use-confirm-checkout';
1110
import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled';
1211
import { useLoadMercadoPago } from '@/components/checkout/payment/utils/use-load-mercadopago';
12+
import { formatCurrency } from '@/components/checkout/utils/format-currency';
1313
import { Button } from '@/components/ui/button';
1414
import { useGoDaddyContext } from '@/godaddy-provider';
1515
import { GraphQLErrorWithCodes } from '@/lib/graphql-with-errors';

0 commit comments

Comments
 (0)