@godaddy/gd-commerce-storefront 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -2
- package/dist/index.d.ts +106 -1
- package/dist/index.js +398 -172
- package/dist/styles.css +9 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -84,9 +84,9 @@ A connection failure leaves the surrounding application and its state mounted. C
|
|
|
84
84
|
|
|
85
85
|
The stylesheet includes all required utilities and scopes them to the package's surfaces. The build removes CSS layer wrappers in their declared order, so the exported CSS can pass through a host Tailwind v3 PostCSS pipeline without `@tailwind` directives. Import it directly; consumers do not need to copy or rewrite the CSS. It does not add a global reset or require dependency scanning by a host Tailwind build. The `theme` prop reaches the drawer even though it is portalled into `document.body`.
|
|
86
86
|
|
|
87
|
-
By default, `.commerce-storefront` wrappers do not paint a background and inherit the host's text color. Set page and section backgrounds on the host application's layout elements. `--commerce-surface` colors controls and the opaque cart drawer, and supplies the base for muted colors; it does not color component wrappers. Set `--commerce-surface` and `--commerce-text` together to adapt controls and the drawer to a dark theme. Avoid setting wrapper backgrounds through CSS or the `theme` prop's `background`, `backgroundColor`, or `backgroundImage` properties, which can still override the transparent default. Descendants inherit the surface text and body font instead of global heading styles. Keep text, controls and focus indicators accessible when changing colors.
|
|
87
|
+
By default, `.commerce-storefront` wrappers do not paint a background and inherit the host's text color. Set page and section backgrounds on the host application's layout elements. `--commerce-surface` colors controls and the opaque cart drawer, and supplies the base for muted colors; it does not color component wrappers. Set `--commerce-surface` and `--commerce-text` together to adapt controls and the drawer to a dark theme. Avoid setting wrapper backgrounds through CSS or the `theme` prop's `background`, `backgroundColor`, or `backgroundImage` properties, which can still override the transparent default. Descendants inherit the surface text and body font instead of global heading styles. Keep text, controls and focus indicators accessible when changing colors. Internal utility class names and markup are not a customization API; use the [product parts](#custom-product-layouts) to change a product page's layout.
|
|
88
88
|
|
|
89
|
-
The first release uses English UI text and `en-US` currency formatting. Catalog title and description are configurable. Full localization
|
|
89
|
+
The first release uses English UI text and `en-US` currency formatting. Catalog title and description are configurable. Full localization is outside this initial API.
|
|
90
90
|
|
|
91
91
|
## Compatibility
|
|
92
92
|
|
|
@@ -101,6 +101,7 @@ The repository example uses React Router 7. Follow each router version's own Rea
|
|
|
101
101
|
| `CommerceStorefront` | Recommended integration: provider and one drawer |
|
|
102
102
|
| `Catalog` | Six products per cursor page, with `title`, `description`, and `showHeader` props |
|
|
103
103
|
| `ProductDetails` | Reads `:productId`; validates URL option selections against catalog results |
|
|
104
|
+
| `ProductDetailsRoot` and product parts | Build a [custom product layout](#custom-product-layouts) with the same rules as `ProductDetails` |
|
|
104
105
|
| `ProductCard` | Renders one `SKUGroup` with direct add or a product-details link |
|
|
105
106
|
| `CartButton` | Opens the shared drawer and displays item count |
|
|
106
107
|
| `AddToCartButton` | Adds a verified `sku`, `name`, and optional integer `quantity` |
|
|
@@ -112,6 +113,71 @@ The package exports TypeScript catalog/cart response types and selection/summary
|
|
|
112
113
|
|
|
113
114
|
`Catalog` renders its `title` as an H1 by default. When the host page owns its semantic heading, render that page H1 and pass `showHeader={false}` so the document still has exactly one H1.
|
|
114
115
|
|
|
116
|
+
## Custom product layouts
|
|
117
|
+
|
|
118
|
+
`ProductDetails` is the recommended product page. When its layout does not fit, compose the same parts inside `ProductDetailsRoot`. The root loads the route's `:productId` and shows the connection, loading, error and not-found states itself, so parts render only once the product exists. This is the packaged layout, written out:
|
|
119
|
+
|
|
120
|
+
```tsx
|
|
121
|
+
import {
|
|
122
|
+
ProductDetailsBackLink, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsPrice,
|
|
123
|
+
ProductDetailsPurchase, ProductDetailsRoot, ProductDetailsTitle,
|
|
124
|
+
} from '@godaddy/gd-commerce-storefront';
|
|
125
|
+
|
|
126
|
+
<ProductDetailsRoot>
|
|
127
|
+
<ProductDetailsBackLink className='mb-8 sm:mb-10' />
|
|
128
|
+
<div className='grid items-start gap-8 lg:grid-cols-12 lg:gap-12'>
|
|
129
|
+
<ProductDetailsGallery className='min-w-0 lg:col-span-7' />
|
|
130
|
+
<div className='min-w-0 lg:col-span-5 lg:py-4'>
|
|
131
|
+
<ProductDetailsTitle />
|
|
132
|
+
<ProductDetailsPrice className='mt-6' />
|
|
133
|
+
<ProductDetailsDescription className='my-8' />
|
|
134
|
+
<ProductDetailsPurchase className='border-t border-neutral-200 pt-8' />
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
</ProductDetailsRoot>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The grid, spacing and border classes come from your own stylesheet in a custom layout. Without them, the parts stack in one column with no space between them.
|
|
141
|
+
|
|
142
|
+
Add your own elements between parts, reorder them, or leave out display parts. `ProductDetailsPurchase` renders the purchase parts below in order. Replace it with individual parts to place options apart from the add button:
|
|
143
|
+
|
|
144
|
+
| Part | Renders |
|
|
145
|
+
| --- | --- |
|
|
146
|
+
| `ProductDetailsOptions` | A select for every attribute, or the SKU list for products without attributes |
|
|
147
|
+
| `ProductDetailsOption` | A select for one attribute, by `attribute` name; nothing when the product lacks it |
|
|
148
|
+
| `ProductDetailsPurchaseStatus` | The one message explaining why the selection is not purchasable yet; nothing when ready |
|
|
149
|
+
| `ProductDetailsAvailability`, `ProductDetailsQuantity` | Stock and quantity for the verified SKU; nothing before verification |
|
|
150
|
+
| `ProductDetailsAddToCart` | Adds the verified SKU and quantity; before a SKU is verified, its accessible description gives the reason |
|
|
151
|
+
|
|
152
|
+
Every part is named `ProductDetails*` because it works only inside `ProductDetailsRoot`. `AddToCartButton` is the standalone button for a SKU you already have.
|
|
153
|
+
|
|
154
|
+
Parts read state from the root and take no product, SKU, price or quantity props. Whatever the layout, only a server-verified SKU can be added, and changing a selection resets the quantity and image. Every part throws if it is rendered outside `ProductDetailsRoot`. In development builds, if no control is rendered for an attribute the product requires, a console warning names the missing ones. A control that mounts later, such as one in a closed accordion, can trigger it; the product becomes purchasable once the control renders. Products without attributes but with several SKUs need `ProductDetailsOptions` or `ProductDetailsPurchase`. Parts can be rendered more than once, such as in a sticky mobile purchase bar; each copy gets its own element IDs.
|
|
155
|
+
|
|
156
|
+
Every part accepts `className`, which is added to the part's outer element, a wrapper the package's CSS reset leaves alone. Host classes there control margin, padding, border, color, width and grid placement, including layered utilities such as Tailwind v4's. The packaged layout sets its spacing and grid placement this way. Elements inside the part, such as the title's `<h1>`, keep the package's own styles: a host font size on `ProductDetailsTitle` does not change the heading's size, but a host color does reach it, because the heading inherits its color.
|
|
157
|
+
|
|
158
|
+
Two hooks support custom parts inside the root:
|
|
159
|
+
|
|
160
|
+
- `useProductDetails()` returns read-only `product`, `name`, `selectedSku`, `quantity`, `availableQuantity`, `images` and `status` (`incomplete`, `checking`, `variant-error`, `unavailable`, `not-purchasable` or `ready`). `selectedSku` is set only when `status` is `ready`.
|
|
161
|
+
- `useProductDetailsOption(attribute)` returns `{ name, label, selected, values, select }` for building swatches or buttons, or `null` when the product has no such attribute. `select(value)` accepts only that attribute's value names, or `null` to clear. Other values are ignored. A hook-based control counts toward the layout check.
|
|
162
|
+
|
|
163
|
+
```tsx
|
|
164
|
+
function ColorSwatches() {
|
|
165
|
+
const color = useProductDetailsOption('color');
|
|
166
|
+
if (!color) return null;
|
|
167
|
+
return (
|
|
168
|
+
<div role='group' aria-label={color.label}>
|
|
169
|
+
{color.values.map((value) => (
|
|
170
|
+
<button key={value.name} type='button' aria-pressed={value.selected} onClick={() => color.select(value.name)}>
|
|
171
|
+
{value.label}
|
|
172
|
+
</button>
|
|
173
|
+
))}
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
Parts are flat named exports, not properties such as `ProductDetails.Title`, so each one is an ordinary module export.
|
|
180
|
+
|
|
115
181
|
## How it works
|
|
116
182
|
|
|
117
183
|
Products are SKU groups. The package purchases only an unambiguous SKU, verifies all selected attributes through the server, uses SKU prices/images, and distinguishes untracked inventory from sold-out inventory. Server-side inventory and pricing checks remain mandatory.
|
package/dist/index.d.ts
CHANGED
|
@@ -387,10 +387,115 @@ declare function CommerceStorefront({
|
|
|
387
387
|
...props
|
|
388
388
|
}: CommerceProviderProps): react_jsx_runtime0.JSX.Element;
|
|
389
389
|
//#endregion
|
|
390
|
+
//#region src/product-details-context.d.ts
|
|
391
|
+
/** Where the shopper is in choosing a purchasable SKU. Only `ready` has a verified `selectedSku`. */
|
|
392
|
+
type ProductDetailsStatus = 'incomplete' | 'checking' | 'variant-error' | 'unavailable' | 'not-purchasable' | 'ready';
|
|
393
|
+
/** Read-only product state for custom parts rendered inside `ProductDetailsRoot`. */
|
|
394
|
+
interface ProductDetailsState {
|
|
395
|
+
readonly product: SKUGroup;
|
|
396
|
+
readonly name: string;
|
|
397
|
+
/** Set only after the server verifies the full selection. */
|
|
398
|
+
readonly selectedSku: SKU | null;
|
|
399
|
+
readonly quantity: number;
|
|
400
|
+
/** `null` means inventory is not tracked; `0` means sold out. */
|
|
401
|
+
readonly availableQuantity: number | null;
|
|
402
|
+
readonly status: ProductDetailsStatus;
|
|
403
|
+
readonly images: readonly string[];
|
|
404
|
+
}
|
|
405
|
+
/** Props shared by every product part. `className` is added to the part's outer element. */
|
|
406
|
+
interface ProductDetailsPartProps {
|
|
407
|
+
className?: string;
|
|
408
|
+
}
|
|
409
|
+
/** Read the current product, verified SKU, and purchase status from inside `ProductDetailsRoot`. */
|
|
410
|
+
declare function useProductDetails(): ProductDetailsState;
|
|
411
|
+
interface ProductDetailsOptionValueState {
|
|
412
|
+
readonly name: string;
|
|
413
|
+
readonly label: string;
|
|
414
|
+
readonly selected: boolean;
|
|
415
|
+
}
|
|
416
|
+
interface ProductDetailsOptionState {
|
|
417
|
+
readonly name: string;
|
|
418
|
+
readonly label: string;
|
|
419
|
+
readonly selected: string | null;
|
|
420
|
+
readonly values: readonly ProductDetailsOptionValueState[];
|
|
421
|
+
/** Accepts only this attribute's own value names, or `null` to clear. Other values are ignored. */
|
|
422
|
+
readonly select: (value: string | null) => void;
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Build a custom control for one product attribute. Returns `null` when the product has no such attribute.
|
|
426
|
+
* A selection is never purchasable until the server verifies it.
|
|
427
|
+
*/
|
|
428
|
+
declare function useProductDetailsOption(attribute: string): ProductDetailsOptionState | null;
|
|
429
|
+
interface ProductDetailsRootProps {
|
|
430
|
+
children: ReactNode;
|
|
431
|
+
/** Added to the root `<article>`. */
|
|
432
|
+
className?: string;
|
|
433
|
+
}
|
|
434
|
+
/** Loads the product for the route's `:productId` and provides state to product parts. */
|
|
435
|
+
declare function ProductDetailsRoot({
|
|
436
|
+
children,
|
|
437
|
+
className
|
|
438
|
+
}: ProductDetailsRootProps): ReactElement;
|
|
439
|
+
//#endregion
|
|
390
440
|
//#region src/product-details.d.ts
|
|
441
|
+
/** The packaged product page for `${productPath}/:productId`. Compose the parts for a custom layout. */
|
|
391
442
|
declare function ProductDetails(): ReactElement;
|
|
443
|
+
declare function ProductDetailsBackLink({
|
|
444
|
+
className
|
|
445
|
+
}: ProductDetailsPartProps): ReactElement;
|
|
446
|
+
/** Main image with thumbnails; the selected SKU's images replace the product's when present. */
|
|
447
|
+
declare function ProductDetailsGallery({
|
|
448
|
+
className
|
|
449
|
+
}: ProductDetailsPartProps): ReactElement;
|
|
450
|
+
/** The product name as the page's H1. */
|
|
451
|
+
declare function ProductDetailsTitle({
|
|
452
|
+
className
|
|
453
|
+
}: ProductDetailsPartProps): ReactElement;
|
|
454
|
+
/** The verified SKU's price and compare-at price, or the product's starting price. */
|
|
455
|
+
declare function ProductDetailsPrice({
|
|
456
|
+
className
|
|
457
|
+
}: ProductDetailsPartProps): ReactElement;
|
|
458
|
+
/** The verified SKU's description, or the product's. */
|
|
459
|
+
declare function ProductDetailsDescription({
|
|
460
|
+
className
|
|
461
|
+
}: ProductDetailsPartProps): ReactElement;
|
|
462
|
+
//#endregion
|
|
463
|
+
//#region src/product-purchase.d.ts
|
|
464
|
+
/** The packaged purchase area: options, status, availability, quantity, and add to cart. */
|
|
465
|
+
declare function ProductDetailsPurchase({
|
|
466
|
+
className
|
|
467
|
+
}: ProductDetailsPartProps): ReactElement;
|
|
468
|
+
/** A select for every product attribute, or the SKU list for products without attributes. */
|
|
469
|
+
declare function ProductDetailsOptions({
|
|
470
|
+
className
|
|
471
|
+
}: ProductDetailsPartProps): ReactElement | null;
|
|
472
|
+
interface ProductDetailsOptionProps extends ProductDetailsPartProps {
|
|
473
|
+
/** The attribute `name`, such as `color`. Renders nothing when the product has no such attribute. */
|
|
474
|
+
attribute: string;
|
|
475
|
+
}
|
|
476
|
+
/** A select for one product attribute. Use `useProductDetailsOption` for swatches or other custom controls. */
|
|
477
|
+
declare function ProductDetailsOption({
|
|
478
|
+
attribute,
|
|
479
|
+
className
|
|
480
|
+
}: ProductDetailsOptionProps): ReactElement | null;
|
|
481
|
+
/** The single message explaining why the selection is not yet purchasable. Renders nothing when ready. */
|
|
482
|
+
declare function ProductDetailsPurchaseStatus({
|
|
483
|
+
className
|
|
484
|
+
}: ProductDetailsPartProps): ReactElement | null;
|
|
485
|
+
/** Stock for the verified SKU. Renders nothing until a SKU is verified. */
|
|
486
|
+
declare function ProductDetailsAvailability({
|
|
487
|
+
className
|
|
488
|
+
}: ProductDetailsPartProps): ReactElement | null;
|
|
489
|
+
/** Quantity for the verified SKU. Renders nothing until a SKU is verified. */
|
|
490
|
+
declare function ProductDetailsQuantity({
|
|
491
|
+
className
|
|
492
|
+
}: ProductDetailsPartProps): ReactElement | null;
|
|
493
|
+
/** Adds the verified SKU and quantity. While disabled, its accessible description says why. */
|
|
494
|
+
declare function ProductDetailsAddToCart({
|
|
495
|
+
className
|
|
496
|
+
}: ProductDetailsPartProps): ReactElement;
|
|
392
497
|
//#endregion
|
|
393
498
|
//#region src/storefront-surface.d.ts
|
|
394
499
|
declare function CommerceStatus(): ReactElement | null;
|
|
395
500
|
//#endregion
|
|
396
|
-
export { AddToCartButton, AddToCartItemInput, ApiError, CartAddress, CartButton, CartDiscount, CartDrawer, CartLineItem, CartLineItemDetails, CartNote, CartOrder, CartSelectedAddon, CartSelectedAddonValue, CartSelectedOption, CartShippingInfo, CartSummaryTotals, CartTax, Catalog, type CatalogProps, type CommerceContextValue, CommerceProvider, type CommerceProviderProps, CommerceStatus, CommerceStorefront, Connection, ConnectionEdge, DraftOrderContext, InventoryCount, LabelFilter, LineItemTotals, MediaObject, Money, OrderTotals, PageInfo, PriceRange, ProductCard, ProductDetails, ProductDetailsVariablesInput, ProductGridVariablesInput, SKU, SKUAttributeValue, SKUGroup, SKUGroupAttribute, SKUGroupAttributeValue, SKUGroupSKU, SKUPrice, SkuGroupResult, SkuGroupVariables, SkuGroupsResult, SkuGroupsVariables, SkuResult, SkuVariables, type StorefrontConfig, StorefrontProductAttribute, StorefrontProductAttributeValue, type StorefrontTheme, StringInFilter, addToCart, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, getSingleMatchedSkuId, money, useCommerce };
|
|
501
|
+
export { AddToCartButton, AddToCartItemInput, ApiError, CartAddress, CartButton, CartDiscount, CartDrawer, CartLineItem, CartLineItemDetails, CartNote, CartOrder, CartSelectedAddon, CartSelectedAddonValue, CartSelectedOption, CartShippingInfo, CartSummaryTotals, CartTax, Catalog, type CatalogProps, type CommerceContextValue, CommerceProvider, type CommerceProviderProps, CommerceStatus, CommerceStorefront, Connection, ConnectionEdge, DraftOrderContext, InventoryCount, LabelFilter, LineItemTotals, MediaObject, Money, OrderTotals, PageInfo, PriceRange, ProductCard, ProductDetails, ProductDetailsAddToCart, ProductDetailsAvailability, ProductDetailsBackLink, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsOption, type ProductDetailsOptionProps, type ProductDetailsOptionState, type ProductDetailsOptionValueState, ProductDetailsOptions, type ProductDetailsPartProps, ProductDetailsPrice, ProductDetailsPurchase, ProductDetailsPurchaseStatus, ProductDetailsQuantity, ProductDetailsRoot, type ProductDetailsRootProps, type ProductDetailsState, type ProductDetailsStatus, ProductDetailsTitle, ProductDetailsVariablesInput, ProductGridVariablesInput, SKU, SKUAttributeValue, SKUGroup, SKUGroupAttribute, SKUGroupAttributeValue, SKUGroupSKU, SKUPrice, SkuGroupResult, SkuGroupVariables, SkuGroupsResult, SkuGroupsVariables, SkuResult, SkuVariables, type StorefrontConfig, StorefrontProductAttribute, StorefrontProductAttributeValue, type StorefrontTheme, StringInFilter, addToCart, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, getSingleMatchedSkuId, money, useCommerce, useProductDetails, useProductDetailsOption };
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as Dialog from "@radix-ui/react-dialog";
|
|
2
|
-
import { createContext, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from "react";
|
|
3
3
|
import { Link, useParams, useSearchParams } from "react-router";
|
|
4
4
|
import { useQuery } from "@tanstack/react-query";
|
|
5
5
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
@@ -122,8 +122,9 @@ function getAvailableInventoryQuantity(item) {
|
|
|
122
122
|
if (!edges || edges.length === 0) return null;
|
|
123
123
|
return edges.find((edge) => edge?.node?.type === "AVAILABLE")?.node?.quantity ?? 0;
|
|
124
124
|
}
|
|
125
|
+
const imageMediaTypes = new Set(["IMAGE", "PHOTO"]);
|
|
125
126
|
function getImageUrls(item) {
|
|
126
|
-
return item?.mediaObjects?.edges?.filter((edge) => edge?.node?.type
|
|
127
|
+
return item?.mediaObjects?.edges?.filter((edge) => imageMediaTypes.has(edge?.node?.type ?? "") && edge?.node?.url).map((edge) => edge?.node?.url).filter((url) => Boolean(url)) ?? [];
|
|
127
128
|
}
|
|
128
129
|
function getPrimaryImageUrl(item) {
|
|
129
130
|
return getImageUrls(item)[0] ?? null;
|
|
@@ -1099,15 +1100,72 @@ function CommerceStorefront({ children,...props }) {
|
|
|
1099
1100
|
}
|
|
1100
1101
|
|
|
1101
1102
|
//#endregion
|
|
1102
|
-
//#region src/product-details.tsx
|
|
1103
|
-
|
|
1103
|
+
//#region src/product-details-context.tsx
|
|
1104
|
+
const cx = (...classes) => classes.filter(Boolean).join(" ");
|
|
1105
|
+
/** Classes for the element that receives a part's `className`. The package CSS reset skips `commerce-part`. */
|
|
1106
|
+
const partClass = (...classes) => cx("commerce-part", ...classes);
|
|
1107
|
+
const ProductDetailsContext = createContext(null);
|
|
1108
|
+
function useProductDetailsInternal(part) {
|
|
1109
|
+
const context = useContext(ProductDetailsContext);
|
|
1110
|
+
if (!context) throw new Error(`${part} must be rendered inside <ProductDetailsRoot>.`);
|
|
1111
|
+
return context;
|
|
1112
|
+
}
|
|
1113
|
+
/** Read the current product, verified SKU, and purchase status from inside `ProductDetailsRoot`. */
|
|
1114
|
+
function useProductDetails() {
|
|
1115
|
+
const { product, name, selectedSku, quantity, availableQuantity, status, images } = useProductDetailsInternal("useProductDetails");
|
|
1116
|
+
return {
|
|
1117
|
+
product,
|
|
1118
|
+
name,
|
|
1119
|
+
selectedSku,
|
|
1120
|
+
quantity,
|
|
1121
|
+
availableQuantity,
|
|
1122
|
+
status,
|
|
1123
|
+
images
|
|
1124
|
+
};
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* Build a custom control for one product attribute. Returns `null` when the product has no such attribute.
|
|
1128
|
+
* A selection is never purchasable until the server verifies it.
|
|
1129
|
+
*/
|
|
1130
|
+
function useProductDetailsOption(attribute) {
|
|
1131
|
+
const { attributes, selections, selectOption, register } = useProductDetailsInternal("useProductDetailsOption");
|
|
1132
|
+
useEffect(() => register(`attribute:${attribute}`), [attribute, register]);
|
|
1133
|
+
const index = attributes.findIndex((candidate) => candidate.name === attribute);
|
|
1134
|
+
const match = attributes[index];
|
|
1135
|
+
if (!match) return null;
|
|
1136
|
+
const selected = selections[index] || null;
|
|
1137
|
+
return {
|
|
1138
|
+
name: match.name,
|
|
1139
|
+
label: match.label,
|
|
1140
|
+
selected,
|
|
1141
|
+
values: match.values.map((value) => ({
|
|
1142
|
+
name: value.name,
|
|
1143
|
+
label: value.label,
|
|
1144
|
+
selected: value.name === selected
|
|
1145
|
+
})),
|
|
1146
|
+
select: (value) => {
|
|
1147
|
+
if (value === null) selectOption(attribute, "");
|
|
1148
|
+
else if (match.values.some((candidate) => candidate.name === value)) selectOption(attribute, value);
|
|
1149
|
+
}
|
|
1150
|
+
};
|
|
1151
|
+
}
|
|
1152
|
+
/** Register the explicit SKU control so the layout check knows SKU-list products can be completed. */
|
|
1153
|
+
function useSkuOptionRegistration() {
|
|
1154
|
+
const { register } = useProductDetailsInternal("ProductDetailsOptions");
|
|
1155
|
+
useEffect(() => register("sku"), [register]);
|
|
1156
|
+
}
|
|
1157
|
+
/** Loads the product for the route's `:productId` and provides state to product parts. */
|
|
1158
|
+
function ProductDetailsRoot({ children, className }) {
|
|
1104
1159
|
const { productId = "" } = useParams();
|
|
1105
1160
|
const { connection } = useCommerce();
|
|
1106
|
-
return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(
|
|
1161
|
+
return /* @__PURE__ */ jsx(StorefrontSurface, { children: connection === "ready" ? /* @__PURE__ */ jsx(ProductDetailsLoader, {
|
|
1162
|
+
productId,
|
|
1163
|
+
className,
|
|
1164
|
+
children
|
|
1165
|
+
}, productId) : /* @__PURE__ */ jsx(CommerceStatus, {}) });
|
|
1107
1166
|
}
|
|
1108
|
-
function
|
|
1167
|
+
function ProductDetailsLoader({ productId, className, children }) {
|
|
1109
1168
|
const { config } = useCommerce();
|
|
1110
|
-
const fieldId = useId();
|
|
1111
1169
|
const [params, setParams] = useSearchParams();
|
|
1112
1170
|
const [quantity, setQuantity] = useState(1);
|
|
1113
1171
|
const [imageIndex, setImageIndex] = useState(0);
|
|
@@ -1130,10 +1188,10 @@ function ProductDetailsContent({ productId }) {
|
|
|
1130
1188
|
const skuOptions = getLabeledSkuOptions(group);
|
|
1131
1189
|
const explicitSku = skuOptions.find((sku) => sku.id === params.get("sku"));
|
|
1132
1190
|
const selections = attributes.map((attribute) => params.get(`option.${attribute.name}`) ?? "");
|
|
1133
|
-
const complete = skuOptions.length > 0 ? !!explicitSku : attributes.every((attribute, index) => attribute.values.some((value) => value.name === selections[index]));
|
|
1191
|
+
const complete = skuOptions.length > 0 ? !!explicitSku : attributes.every((attribute, index) => attribute.values.some((value$1) => value$1.name === selections[index]));
|
|
1134
1192
|
const selectionParams = new URLSearchParams();
|
|
1135
|
-
selections.filter(Boolean).forEach((value) => {
|
|
1136
|
-
selectionParams.append("attributeValues", value);
|
|
1193
|
+
selections.filter(Boolean).forEach((value$1) => {
|
|
1194
|
+
selectionParams.append("attributeValues", value$1);
|
|
1137
1195
|
});
|
|
1138
1196
|
const matched = useQuery({
|
|
1139
1197
|
retry: false,
|
|
@@ -1152,20 +1210,34 @@ function ProductDetailsContent({ productId }) {
|
|
|
1152
1210
|
});
|
|
1153
1211
|
const selectionVerified = complete && product.isSuccess && !product.isFetching && (attributes.length === 0 || matched.isSuccess && !matched.isFetching);
|
|
1154
1212
|
const selectedSku = selectionVerified ? explicitSku ?? getSingleMatchedSku(attributes.length ? matched.data?.skuGroup : group) : null;
|
|
1155
|
-
const
|
|
1156
|
-
const skuPrice = selectedSku?.prices?.edges?.find((edge) => edge?.node?.value)?.node;
|
|
1213
|
+
const status = !complete ? "incomplete" : matched.isError && !matched.isFetching ? "variant-error" : !selectionVerified ? "checking" : selectedSku ? "ready" : attributes.length ? "unavailable" : "not-purchasable";
|
|
1157
1214
|
const selectedImages = getImageUrls(selectedSku);
|
|
1158
1215
|
const images = [...new Set(selectedImages.length ? selectedImages : getImageUrls(group))];
|
|
1159
|
-
const
|
|
1160
|
-
const name = group?.label ?? group?.name ?? "Product";
|
|
1161
|
-
const selectOption = (attribute, value) => {
|
|
1216
|
+
const changeSelection = (key, value$1) => {
|
|
1162
1217
|
const next = new URLSearchParams(params);
|
|
1163
|
-
if (value) next.set(
|
|
1164
|
-
else next.delete(
|
|
1218
|
+
if (value$1) next.set(key, value$1);
|
|
1219
|
+
else next.delete(key);
|
|
1165
1220
|
setParams(next);
|
|
1166
1221
|
setQuantity(1);
|
|
1167
1222
|
setImageIndex(0);
|
|
1168
1223
|
};
|
|
1224
|
+
const registered = useRef(/* @__PURE__ */ new Map());
|
|
1225
|
+
const register = useCallback((control) => {
|
|
1226
|
+
const counts = registered.current;
|
|
1227
|
+
counts.set(control, (counts.get(control) ?? 0) + 1);
|
|
1228
|
+
return () => {
|
|
1229
|
+
const count = (counts.get(control) ?? 1) - 1;
|
|
1230
|
+
if (count > 0) counts.set(control, count);
|
|
1231
|
+
else counts.delete(control);
|
|
1232
|
+
};
|
|
1233
|
+
}, []);
|
|
1234
|
+
const requiredControls = [...attributes.map((attribute) => `attribute:${attribute.name}`), ...skuOptions.length ? ["sku"] : []].join("\n");
|
|
1235
|
+
useEffect(() => {
|
|
1236
|
+
if (process.env.NODE_ENV === "production") return;
|
|
1237
|
+
const missing = requiredControls.split("\n").filter((control) => control && !registered.current.has(control));
|
|
1238
|
+
if (missing.length === 0) return;
|
|
1239
|
+
console.warn(`[gd-commerce-storefront] No control for ${missing.map((control) => control === "sku" ? "the SKU option" : `"${control.slice(10)}"`).join(", ")} is rendered right now, so this product cannot be added to the cart until one is. Render <ProductDetailsOptions />, <ProductDetailsOption attribute='…' /> for each attribute, or <ProductDetailsPurchase />, or ignore this if the control mounts later.`);
|
|
1240
|
+
}, [requiredControls]);
|
|
1169
1241
|
if (product.isPending) return /* @__PURE__ */ jsx("p", {
|
|
1170
1242
|
role: "status",
|
|
1171
1243
|
children: "Loading product…"
|
|
@@ -1197,167 +1269,321 @@ function ProductDetailsContent({ productId }) {
|
|
|
1197
1269
|
to: config.catalogPath,
|
|
1198
1270
|
children: "Back to shop"
|
|
1199
1271
|
})] });
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1272
|
+
const value = {
|
|
1273
|
+
product: group,
|
|
1274
|
+
name: group.label ?? group.name ?? "Product",
|
|
1275
|
+
selectedSku,
|
|
1276
|
+
quantity,
|
|
1277
|
+
availableQuantity: getAvailableInventoryQuantity(selectedSku),
|
|
1278
|
+
status,
|
|
1279
|
+
images,
|
|
1280
|
+
attributes,
|
|
1281
|
+
selections,
|
|
1282
|
+
skuOptions,
|
|
1283
|
+
explicitSkuId: explicitSku?.id ?? "",
|
|
1284
|
+
price: selectedSku?.prices?.edges?.find((edge) => edge?.node?.value)?.node,
|
|
1285
|
+
imageIndex: Math.min(imageIndex, Math.max(images.length - 1, 0)),
|
|
1286
|
+
variantError: matched.error,
|
|
1287
|
+
setImageIndex: (index) => {
|
|
1288
|
+
if (Number.isInteger(index) && index >= 0 && index < images.length) setImageIndex(index);
|
|
1289
|
+
},
|
|
1290
|
+
setQuantity,
|
|
1291
|
+
selectOption: (attribute, next) => changeSelection(`option.${attribute}`, next),
|
|
1292
|
+
selectSku: (skuId) => changeSelection("sku", skuOptions.some((sku) => sku.id === skuId) ? skuId : ""),
|
|
1293
|
+
retryVariant: () => {
|
|
1294
|
+
if (matched.isError) matched.refetch();
|
|
1295
|
+
},
|
|
1296
|
+
register
|
|
1297
|
+
};
|
|
1298
|
+
return /* @__PURE__ */ jsx(ProductDetailsContext.Provider, {
|
|
1299
|
+
value,
|
|
1300
|
+
children: /* @__PURE__ */ jsx("article", {
|
|
1301
|
+
className: partClass(className),
|
|
1302
|
+
children
|
|
1303
|
+
})
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
//#endregion
|
|
1308
|
+
//#region src/product-purchase.tsx
|
|
1309
|
+
const disabledReasons = {
|
|
1310
|
+
incomplete: "Select all options to add this item to your cart.",
|
|
1311
|
+
checking: "Checking this variant…",
|
|
1312
|
+
"variant-error": "Availability for this selection could not be checked.",
|
|
1313
|
+
unavailable: "This combination is unavailable. Choose different options.",
|
|
1314
|
+
"not-purchasable": "This product is not currently available to purchase."
|
|
1315
|
+
};
|
|
1316
|
+
const flush = "mb-0";
|
|
1317
|
+
/** The packaged purchase area: options, status, availability, quantity, and add to cart. */
|
|
1318
|
+
function ProductDetailsPurchase({ className }) {
|
|
1319
|
+
useProductDetailsInternal("ProductDetailsPurchase");
|
|
1320
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1321
|
+
className: partClass("space-y-6", className),
|
|
1322
|
+
children: [
|
|
1323
|
+
/* @__PURE__ */ jsx(OptionControls, {}),
|
|
1324
|
+
/* @__PURE__ */ jsx(PurchaseStatusMessage, { textClassName: flush }),
|
|
1325
|
+
/* @__PURE__ */ jsx(ProductDetailsAvailability, { className: flush }),
|
|
1326
|
+
/* @__PURE__ */ jsx(ProductDetailsQuantity, {}),
|
|
1327
|
+
/* @__PURE__ */ jsx(ProductDetailsAddToCart, {})
|
|
1328
|
+
]
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
/** A select for every product attribute, or the SKU list for products without attributes. */
|
|
1332
|
+
function ProductDetailsOptions({ className }) {
|
|
1333
|
+
const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsOptions");
|
|
1334
|
+
if (attributes.length === 0 && skuOptions.length === 0) return null;
|
|
1335
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1336
|
+
className: partClass("space-y-6", className),
|
|
1337
|
+
children: /* @__PURE__ */ jsx(OptionControls, {})
|
|
1338
|
+
});
|
|
1339
|
+
}
|
|
1340
|
+
function OptionControls() {
|
|
1341
|
+
const { attributes, skuOptions } = useProductDetailsInternal("ProductDetailsOptions");
|
|
1342
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [attributes.map((attribute) => /* @__PURE__ */ jsx(ProductDetailsOption, {
|
|
1343
|
+
attribute: attribute.name,
|
|
1344
|
+
className: flush
|
|
1345
|
+
}, attribute.id || attribute.name)), skuOptions.length > 0 && /* @__PURE__ */ jsx(SkuOption, {})] });
|
|
1346
|
+
}
|
|
1347
|
+
function SkuOption() {
|
|
1348
|
+
const { skuOptions, explicitSkuId, selectSku } = useProductDetailsInternal("ProductDetailsOptions");
|
|
1349
|
+
const id = useId();
|
|
1350
|
+
useSkuOptionRegistration();
|
|
1351
|
+
return /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("label", {
|
|
1352
|
+
htmlFor: id,
|
|
1353
|
+
className: "mb-2 block font-medium",
|
|
1354
|
+
children: "Option"
|
|
1355
|
+
}), /* @__PURE__ */ jsxs("select", {
|
|
1356
|
+
id,
|
|
1357
|
+
className: `${inputClass} w-full`,
|
|
1358
|
+
value: explicitSkuId,
|
|
1359
|
+
onChange: (event) => selectSku(event.target.value),
|
|
1360
|
+
children: [/* @__PURE__ */ jsx("option", {
|
|
1361
|
+
value: "",
|
|
1362
|
+
children: "Select an option"
|
|
1363
|
+
}), skuOptions.map((sku) => /* @__PURE__ */ jsx("option", {
|
|
1364
|
+
value: sku.id ?? "",
|
|
1365
|
+
children: sku.label?.trim() || sku.name?.trim()
|
|
1366
|
+
}, sku.id))]
|
|
1367
|
+
})] });
|
|
1368
|
+
}
|
|
1369
|
+
/** A select for one product attribute. Use `useProductDetailsOption` for swatches or other custom controls. */
|
|
1370
|
+
function ProductDetailsOption({ attribute, className }) {
|
|
1371
|
+
const option = useProductDetailsOption(attribute);
|
|
1372
|
+
if (!option) return null;
|
|
1373
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1374
|
+
className: partClass(className),
|
|
1375
|
+
children: /* @__PURE__ */ jsxs("fieldset", { children: [/* @__PURE__ */ jsx("legend", {
|
|
1376
|
+
className: "mb-2 font-medium",
|
|
1377
|
+
children: option.label
|
|
1378
|
+
}), /* @__PURE__ */ jsxs("select", {
|
|
1379
|
+
className: `${inputClass} w-full`,
|
|
1380
|
+
"aria-label": option.label,
|
|
1381
|
+
value: option.selected ?? "",
|
|
1382
|
+
onChange: (event) => option.select(event.target.value || null),
|
|
1383
|
+
children: [/* @__PURE__ */ jsxs("option", {
|
|
1384
|
+
value: "",
|
|
1385
|
+
children: ["Select ", option.label.toLowerCase()]
|
|
1386
|
+
}), option.values.map((value) => /* @__PURE__ */ jsx("option", {
|
|
1387
|
+
value: value.name,
|
|
1388
|
+
children: value.label
|
|
1389
|
+
}, value.name))]
|
|
1390
|
+
})] })
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
/** The single message explaining why the selection is not yet purchasable. Renders nothing when ready. */
|
|
1394
|
+
function ProductDetailsPurchaseStatus({ className }) {
|
|
1395
|
+
return /* @__PURE__ */ jsx(PurchaseStatusMessage, { className });
|
|
1396
|
+
}
|
|
1397
|
+
function PurchaseStatusMessage({ className, textClassName }) {
|
|
1398
|
+
const { status, variantError, retryVariant } = useProductDetailsInternal("ProductDetailsPurchaseStatus");
|
|
1399
|
+
const part = (children, partClassName) => /* @__PURE__ */ jsx("div", {
|
|
1400
|
+
className: partClass(partClassName, className),
|
|
1401
|
+
children
|
|
1402
|
+
});
|
|
1403
|
+
switch (status) {
|
|
1404
|
+
case "ready": return null;
|
|
1405
|
+
case "variant-error": return part(/* @__PURE__ */ jsxs("div", {
|
|
1406
|
+
role: "alert",
|
|
1407
|
+
className: "text-red-700",
|
|
1408
|
+
children: [/* @__PURE__ */ jsx("p", { children: message(variantError) }), /* @__PURE__ */ jsx("button", {
|
|
1409
|
+
type: "button",
|
|
1410
|
+
className: "min-h-11 bg-white text-red-700 underline",
|
|
1411
|
+
onClick: retryVariant,
|
|
1412
|
+
children: "Retry availability"
|
|
1227
1413
|
})]
|
|
1228
|
-
})
|
|
1414
|
+
}));
|
|
1415
|
+
case "unavailable": return part(/* @__PURE__ */ jsx("p", {
|
|
1416
|
+
role: "status",
|
|
1417
|
+
className: "text-sm text-red-700",
|
|
1418
|
+
children: disabledReasons[status]
|
|
1419
|
+
}), textClassName);
|
|
1420
|
+
case "not-purchasable": return part(/* @__PURE__ */ jsx("p", {
|
|
1421
|
+
className: "text-sm",
|
|
1422
|
+
children: disabledReasons[status]
|
|
1423
|
+
}), textClassName);
|
|
1424
|
+
case "checking": return part(/* @__PURE__ */ jsx("p", {
|
|
1425
|
+
role: "status",
|
|
1426
|
+
children: disabledReasons[status]
|
|
1427
|
+
}), textClassName);
|
|
1428
|
+
case "incomplete": return part(/* @__PURE__ */ jsx("p", {
|
|
1429
|
+
role: "status",
|
|
1430
|
+
className: "text-sm",
|
|
1431
|
+
children: disabledReasons[status]
|
|
1432
|
+
}), textClassName);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
/** Stock for the verified SKU. Renders nothing until a SKU is verified. */
|
|
1436
|
+
function ProductDetailsAvailability({ className }) {
|
|
1437
|
+
const { selectedSku, availableQuantity } = useProductDetailsInternal("ProductDetailsAvailability");
|
|
1438
|
+
if (!selectedSku) return null;
|
|
1439
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1440
|
+
className: partClass(className),
|
|
1441
|
+
children: /* @__PURE__ */ jsx("p", {
|
|
1442
|
+
className: "text-sm",
|
|
1443
|
+
"aria-live": "polite",
|
|
1444
|
+
children: availableQuantity === null ? "Available to order" : availableQuantity === 0 ? "This variant is out of stock" : `${availableQuantity} available`
|
|
1445
|
+
})
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
/** Quantity for the verified SKU. Renders nothing until a SKU is verified. */
|
|
1449
|
+
function ProductDetailsQuantity({ className }) {
|
|
1450
|
+
const { selectedSku, availableQuantity, quantity, setQuantity } = useProductDetailsInternal("ProductDetailsQuantity");
|
|
1451
|
+
const id = useId();
|
|
1452
|
+
if (!selectedSku) return null;
|
|
1453
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1454
|
+
className: partClass(className),
|
|
1455
|
+
children: [/* @__PURE__ */ jsx("label", {
|
|
1456
|
+
htmlFor: id,
|
|
1457
|
+
className: "mb-2 block font-medium",
|
|
1458
|
+
children: "Quantity"
|
|
1459
|
+
}), /* @__PURE__ */ jsx("input", {
|
|
1460
|
+
id,
|
|
1461
|
+
type: "number",
|
|
1462
|
+
min: 1,
|
|
1463
|
+
max: availableQuantity ?? void 0,
|
|
1464
|
+
step: 1,
|
|
1465
|
+
className: `${inputClass} w-28`,
|
|
1466
|
+
value: Number.isNaN(quantity) ? "" : quantity,
|
|
1467
|
+
onChange: (event) => setQuantity(event.target.valueAsNumber)
|
|
1468
|
+
})]
|
|
1469
|
+
});
|
|
1470
|
+
}
|
|
1471
|
+
/** Adds the verified SKU and quantity. While disabled, its accessible description says why. */
|
|
1472
|
+
function ProductDetailsAddToCart({ className }) {
|
|
1473
|
+
const { selectedSku, name, quantity, status } = useProductDetailsInternal("ProductDetailsAddToCart");
|
|
1474
|
+
const reasonId = useId();
|
|
1475
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1476
|
+
className: partClass(className),
|
|
1477
|
+
children: selectedSku ? /* @__PURE__ */ jsx(AddToCartButton, {
|
|
1478
|
+
sku: selectedSku,
|
|
1479
|
+
name,
|
|
1480
|
+
quantity
|
|
1481
|
+
}) : /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("button", {
|
|
1482
|
+
type: "button",
|
|
1483
|
+
className: `${buttonClass} w-full`,
|
|
1484
|
+
disabled: true,
|
|
1485
|
+
"aria-describedby": reasonId,
|
|
1486
|
+
children: "Add to cart"
|
|
1487
|
+
}), /* @__PURE__ */ jsx("span", {
|
|
1488
|
+
id: reasonId,
|
|
1489
|
+
hidden: true,
|
|
1490
|
+
children: status === "ready" ? "" : disabledReasons[status]
|
|
1491
|
+
})] })
|
|
1492
|
+
});
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
//#endregion
|
|
1496
|
+
//#region src/product-details.tsx
|
|
1497
|
+
/** The packaged product page for `${productPath}/:productId`. Compose the parts for a custom layout. */
|
|
1498
|
+
function ProductDetails() {
|
|
1499
|
+
return /* @__PURE__ */ jsxs(ProductDetailsRoot, { children: [/* @__PURE__ */ jsx(ProductDetailsBackLink, { className: "mb-8 sm:mb-10" }), /* @__PURE__ */ jsxs("div", {
|
|
1500
|
+
className: "grid items-start gap-8 lg:grid-cols-12 lg:gap-12",
|
|
1501
|
+
children: [/* @__PURE__ */ jsx(ProductDetailsGallery, { className: "min-w-0 lg:col-span-7" }), /* @__PURE__ */ jsxs("div", {
|
|
1229
1502
|
className: "min-w-0 lg:col-span-5 lg:py-4",
|
|
1230
1503
|
children: [
|
|
1231
|
-
/* @__PURE__ */ jsx(
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
})
|
|
1235
|
-
/* @__PURE__ */ jsxs("div", {
|
|
1236
|
-
"aria-live": "polite",
|
|
1237
|
-
className: "mt-6 flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-medium",
|
|
1238
|
-
"data-testid": "product-price",
|
|
1239
|
-
children: [skuPrice?.value?.value != null ? money(skuPrice.value.value, skuPrice.value.currencyCode ?? config.currencyCode) : group.priceRange?.min != null ? `From ${money(group.priceRange.min, config.currencyCode)}` : "Price unavailable", skuPrice?.compareAtValue?.value != null && skuPrice.value?.value != null && skuPrice.compareAtValue.value > skuPrice.value.value && /* @__PURE__ */ jsx("del", {
|
|
1240
|
-
className: "text-lg font-normal",
|
|
1241
|
-
children: money(skuPrice.compareAtValue.value, skuPrice.compareAtValue.currencyCode ?? config.currencyCode)
|
|
1242
|
-
})]
|
|
1243
|
-
}),
|
|
1244
|
-
/* @__PURE__ */ jsx("p", {
|
|
1245
|
-
className: "my-8 whitespace-pre-line text-base leading-7",
|
|
1246
|
-
children: selectedSku?.description ?? group.description
|
|
1247
|
-
}),
|
|
1248
|
-
/* @__PURE__ */ jsxs("div", {
|
|
1249
|
-
className: "space-y-6 border-t border-neutral-200 pt-8",
|
|
1250
|
-
children: [
|
|
1251
|
-
attributes.map((attribute, index) => /* @__PURE__ */ jsxs("fieldset", { children: [/* @__PURE__ */ jsx("legend", {
|
|
1252
|
-
className: "mb-2 font-medium",
|
|
1253
|
-
children: attribute.label
|
|
1254
|
-
}), /* @__PURE__ */ jsxs("select", {
|
|
1255
|
-
className: `${inputClass} w-full`,
|
|
1256
|
-
"aria-label": attribute.label,
|
|
1257
|
-
value: selections[index],
|
|
1258
|
-
onChange: (event) => selectOption(attribute.name, event.target.value),
|
|
1259
|
-
children: [/* @__PURE__ */ jsxs("option", {
|
|
1260
|
-
value: "",
|
|
1261
|
-
children: ["Select ", attribute.label.toLowerCase()]
|
|
1262
|
-
}), attribute.values.map((value) => /* @__PURE__ */ jsx("option", {
|
|
1263
|
-
value: value.name,
|
|
1264
|
-
children: value.label
|
|
1265
|
-
}, value.id || value.name))]
|
|
1266
|
-
})] }, attribute.id || attribute.name)),
|
|
1267
|
-
skuOptions.length > 0 && /* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("label", {
|
|
1268
|
-
htmlFor: `${fieldId}-sku-option`,
|
|
1269
|
-
className: "mb-2 block font-medium",
|
|
1270
|
-
children: "Option"
|
|
1271
|
-
}), /* @__PURE__ */ jsxs("select", {
|
|
1272
|
-
id: `${fieldId}-sku-option`,
|
|
1273
|
-
className: `${inputClass} w-full`,
|
|
1274
|
-
value: explicitSku?.id ?? "",
|
|
1275
|
-
onChange: (event) => {
|
|
1276
|
-
const next = new URLSearchParams(params);
|
|
1277
|
-
if (event.target.value) next.set("sku", event.target.value);
|
|
1278
|
-
else next.delete("sku");
|
|
1279
|
-
setParams(next);
|
|
1280
|
-
setQuantity(1);
|
|
1281
|
-
setImageIndex(0);
|
|
1282
|
-
},
|
|
1283
|
-
children: [/* @__PURE__ */ jsx("option", {
|
|
1284
|
-
value: "",
|
|
1285
|
-
children: "Select an option"
|
|
1286
|
-
}), skuOptions.map((sku) => /* @__PURE__ */ jsx("option", {
|
|
1287
|
-
value: sku.id ?? "",
|
|
1288
|
-
children: sku.label?.trim() || sku.name?.trim()
|
|
1289
|
-
}, sku.id))]
|
|
1290
|
-
})] }),
|
|
1291
|
-
matched.isError ? /* @__PURE__ */ jsxs("div", {
|
|
1292
|
-
role: "alert",
|
|
1293
|
-
className: "text-red-700",
|
|
1294
|
-
children: [/* @__PURE__ */ jsx("p", { children: message(matched.error) }), /* @__PURE__ */ jsx("button", {
|
|
1295
|
-
type: "button",
|
|
1296
|
-
className: "min-h-11 bg-white text-red-700 underline",
|
|
1297
|
-
onClick: () => {
|
|
1298
|
-
if (matched.isError) matched.refetch();
|
|
1299
|
-
},
|
|
1300
|
-
children: "Retry availability"
|
|
1301
|
-
})]
|
|
1302
|
-
}) : null,
|
|
1303
|
-
!complete && /* @__PURE__ */ jsx("p", {
|
|
1304
|
-
id: `${fieldId}-variant-guidance`,
|
|
1305
|
-
role: "status",
|
|
1306
|
-
className: "text-sm",
|
|
1307
|
-
children: "Select all options to add this item to your cart."
|
|
1308
|
-
}),
|
|
1309
|
-
selectionVerified && attributes.length > 0 && !skuId && /* @__PURE__ */ jsx("p", {
|
|
1310
|
-
role: "status",
|
|
1311
|
-
className: "text-sm text-red-700",
|
|
1312
|
-
children: "This combination is unavailable. Choose different options."
|
|
1313
|
-
}),
|
|
1314
|
-
selectionVerified && attributes.length === 0 && !skuId && /* @__PURE__ */ jsx("p", {
|
|
1315
|
-
className: "text-sm",
|
|
1316
|
-
children: "This product is not currently available to purchase."
|
|
1317
|
-
}),
|
|
1318
|
-
complete && (product.isFetching || matched.isFetching) && /* @__PURE__ */ jsx("p", {
|
|
1319
|
-
role: "status",
|
|
1320
|
-
children: "Checking this variant…"
|
|
1321
|
-
}),
|
|
1322
|
-
selectedSku && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
1323
|
-
/* @__PURE__ */ jsx("p", {
|
|
1324
|
-
className: "text-sm",
|
|
1325
|
-
"aria-live": "polite",
|
|
1326
|
-
children: available === null ? "Available to order" : available === 0 ? "This variant is out of stock" : `${available} available`
|
|
1327
|
-
}),
|
|
1328
|
-
/* @__PURE__ */ jsxs("div", { children: [/* @__PURE__ */ jsx("label", {
|
|
1329
|
-
htmlFor: `${fieldId}-quantity`,
|
|
1330
|
-
className: "mb-2 block font-medium",
|
|
1331
|
-
children: "Quantity"
|
|
1332
|
-
}), /* @__PURE__ */ jsx("input", {
|
|
1333
|
-
id: `${fieldId}-quantity`,
|
|
1334
|
-
type: "number",
|
|
1335
|
-
min: 1,
|
|
1336
|
-
max: available ?? void 0,
|
|
1337
|
-
step: 1,
|
|
1338
|
-
className: `${inputClass} w-28`,
|
|
1339
|
-
value: Number.isNaN(quantity) ? "" : quantity,
|
|
1340
|
-
onChange: (event) => setQuantity(event.target.valueAsNumber)
|
|
1341
|
-
})] }),
|
|
1342
|
-
/* @__PURE__ */ jsx(AddToCartButton, {
|
|
1343
|
-
sku: selectedSku,
|
|
1344
|
-
name,
|
|
1345
|
-
quantity
|
|
1346
|
-
})
|
|
1347
|
-
] }),
|
|
1348
|
-
!selectedSku && /* @__PURE__ */ jsx("button", {
|
|
1349
|
-
type: "button",
|
|
1350
|
-
className: `${buttonClass} w-full`,
|
|
1351
|
-
disabled: true,
|
|
1352
|
-
"aria-describedby": !complete ? `${fieldId}-variant-guidance` : void 0,
|
|
1353
|
-
children: "Add to cart"
|
|
1354
|
-
})
|
|
1355
|
-
]
|
|
1356
|
-
})
|
|
1504
|
+
/* @__PURE__ */ jsx(ProductDetailsTitle, {}),
|
|
1505
|
+
/* @__PURE__ */ jsx(ProductDetailsPrice, { className: "mt-6" }),
|
|
1506
|
+
/* @__PURE__ */ jsx(ProductDetailsDescription, { className: "my-8" }),
|
|
1507
|
+
/* @__PURE__ */ jsx(ProductDetailsPurchase, { className: "border-t border-neutral-200 pt-8" })
|
|
1357
1508
|
]
|
|
1358
1509
|
})]
|
|
1359
1510
|
})] });
|
|
1360
1511
|
}
|
|
1512
|
+
function ProductDetailsBackLink({ className }) {
|
|
1513
|
+
useProductDetailsInternal("ProductDetailsBackLink");
|
|
1514
|
+
const { config } = useCommerce();
|
|
1515
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1516
|
+
className: partClass(className),
|
|
1517
|
+
children: /* @__PURE__ */ jsx(Link, {
|
|
1518
|
+
className: "inline-flex min-h-11 items-center text-sm text-inherit underline underline-offset-4 focus-visible:outline focus-visible:outline-2",
|
|
1519
|
+
to: config.catalogPath,
|
|
1520
|
+
children: "← Back to shop"
|
|
1521
|
+
})
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
/** Main image with thumbnails; the selected SKU's images replace the product's when present. */
|
|
1525
|
+
function ProductDetailsGallery({ className }) {
|
|
1526
|
+
const { images, imageIndex, setImageIndex, name } = useProductDetailsInternal("ProductDetailsGallery");
|
|
1527
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1528
|
+
className: partClass(className),
|
|
1529
|
+
children: [/* @__PURE__ */ jsx("div", {
|
|
1530
|
+
className: "aspect-square overflow-hidden rounded-lg",
|
|
1531
|
+
children: /* @__PURE__ */ jsx(ProductImage, {
|
|
1532
|
+
url: images[imageIndex],
|
|
1533
|
+
name
|
|
1534
|
+
}, images[imageIndex])
|
|
1535
|
+
}), images.length > 1 && /* @__PURE__ */ jsx("div", {
|
|
1536
|
+
className: "mt-4 flex gap-3 overflow-x-auto",
|
|
1537
|
+
children: images.map((url, index) => /* @__PURE__ */ jsx("button", {
|
|
1538
|
+
type: "button",
|
|
1539
|
+
className: `h-20 w-20 shrink-0 overflow-hidden rounded-lg border-2 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 ${imageIndex === index ? "border-neutral-900" : "border-transparent"}`,
|
|
1540
|
+
"aria-label": `View image ${index + 1}`,
|
|
1541
|
+
"aria-pressed": imageIndex === index,
|
|
1542
|
+
onClick: () => setImageIndex(index),
|
|
1543
|
+
children: /* @__PURE__ */ jsx(ProductImage, {
|
|
1544
|
+
url,
|
|
1545
|
+
name: ""
|
|
1546
|
+
})
|
|
1547
|
+
}, url))
|
|
1548
|
+
})]
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
/** The product name as the page's H1. */
|
|
1552
|
+
function ProductDetailsTitle({ className }) {
|
|
1553
|
+
const { name } = useProductDetailsInternal("ProductDetailsTitle");
|
|
1554
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1555
|
+
className: partClass(className),
|
|
1556
|
+
children: /* @__PURE__ */ jsx("h1", {
|
|
1557
|
+
className: "break-words text-4xl font-semibold leading-tight sm:text-5xl",
|
|
1558
|
+
children: name
|
|
1559
|
+
})
|
|
1560
|
+
});
|
|
1561
|
+
}
|
|
1562
|
+
/** The verified SKU's price and compare-at price, or the product's starting price. */
|
|
1563
|
+
function ProductDetailsPrice({ className }) {
|
|
1564
|
+
const { price, product } = useProductDetailsInternal("ProductDetailsPrice");
|
|
1565
|
+
const { config } = useCommerce();
|
|
1566
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1567
|
+
"aria-live": "polite",
|
|
1568
|
+
className: partClass("flex flex-wrap items-baseline gap-x-3 gap-y-1 text-2xl font-medium", className),
|
|
1569
|
+
"data-testid": "product-price",
|
|
1570
|
+
children: [price?.value?.value != null ? money(price.value.value, price.value.currencyCode ?? config.currencyCode) : product.priceRange?.min != null ? `From ${money(product.priceRange.min, config.currencyCode)}` : "Price unavailable", price?.compareAtValue?.value != null && price.value?.value != null && price.compareAtValue.value > price.value.value && /* @__PURE__ */ jsx("del", {
|
|
1571
|
+
className: "text-lg font-normal",
|
|
1572
|
+
children: money(price.compareAtValue.value, price.compareAtValue.currencyCode ?? config.currencyCode)
|
|
1573
|
+
})]
|
|
1574
|
+
});
|
|
1575
|
+
}
|
|
1576
|
+
/** The verified SKU's description, or the product's. */
|
|
1577
|
+
function ProductDetailsDescription({ className }) {
|
|
1578
|
+
const { selectedSku, product } = useProductDetailsInternal("ProductDetailsDescription");
|
|
1579
|
+
return /* @__PURE__ */ jsx("div", {
|
|
1580
|
+
className: partClass(className),
|
|
1581
|
+
children: /* @__PURE__ */ jsx("p", {
|
|
1582
|
+
className: "whitespace-pre-line text-base leading-7",
|
|
1583
|
+
children: selectedSku?.description ?? product.description
|
|
1584
|
+
})
|
|
1585
|
+
});
|
|
1586
|
+
}
|
|
1361
1587
|
|
|
1362
1588
|
//#endregion
|
|
1363
|
-
export { AddToCartButton, ApiError, CartButton, CartDrawer, Catalog, CommerceProvider, CommerceStatus, CommerceStorefront, ProductCard, ProductDetails, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, useCommerce };
|
|
1589
|
+
export { AddToCartButton, ApiError, CartButton, CartDrawer, Catalog, CommerceProvider, CommerceStatus, CommerceStorefront, ProductCard, ProductDetails, ProductDetailsAddToCart, ProductDetailsAvailability, ProductDetailsBackLink, ProductDetailsDescription, ProductDetailsGallery, ProductDetailsOption, ProductDetailsOptions, ProductDetailsPrice, ProductDetailsPurchase, ProductDetailsPurchaseStatus, ProductDetailsQuantity, ProductDetailsRoot, ProductDetailsTitle, getAvailableInventoryQuantity, getCartSummaryTotals, getImageUrls, getLabeledSkuOptions, getPrimaryImageUrl, getProductAttributes, getSingleMatchedSku, money, useCommerce, useProductDetails, useProductDetailsOption };
|
package/dist/styles.css
CHANGED
|
@@ -68,12 +68,14 @@
|
|
|
68
68
|
font-family: inherit;
|
|
69
69
|
line-height: 1.5;
|
|
70
70
|
}
|
|
71
|
-
.commerce-storefront :where(
|
|
71
|
+
.commerce-storefront :where(*:not(.commerce-part)) {
|
|
72
72
|
color: inherit;
|
|
73
73
|
font-family: inherit;
|
|
74
74
|
}
|
|
75
75
|
.commerce-storefront *, .commerce-storefront *::before, .commerce-storefront *::after {
|
|
76
76
|
box-sizing: border-box;
|
|
77
|
+
}
|
|
78
|
+
.commerce-storefront :where(*:not(.commerce-part)), .commerce-storefront *::before, .commerce-storefront *::after {
|
|
77
79
|
border-width: 0;
|
|
78
80
|
border-style: solid;
|
|
79
81
|
}
|
|
@@ -133,6 +135,9 @@
|
|
|
133
135
|
.commerce-storefront .fixed {
|
|
134
136
|
position: fixed;
|
|
135
137
|
}
|
|
138
|
+
.commerce-storefront .sticky {
|
|
139
|
+
position: sticky;
|
|
140
|
+
}
|
|
136
141
|
.commerce-storefront .inset-0 {
|
|
137
142
|
inset: calc(var(--spacing) * 0);
|
|
138
143
|
}
|
|
@@ -175,6 +180,9 @@
|
|
|
175
180
|
.commerce-storefront .mt-auto {
|
|
176
181
|
margin-top: auto;
|
|
177
182
|
}
|
|
183
|
+
.commerce-storefront .mb-0 {
|
|
184
|
+
margin-bottom: calc(var(--spacing) * 0);
|
|
185
|
+
}
|
|
178
186
|
.commerce-storefront .mb-2 {
|
|
179
187
|
margin-bottom: calc(var(--spacing) * 2);
|
|
180
188
|
}
|