@cartbase/storefront 0.17.0 → 0.18.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/LICENSE +21 -21
- package/package.json +258 -256
- package/src/api/auth.ts +108 -108
- package/src/api/carts.ts +523 -523
- package/src/api/categories.ts +184 -184
- package/src/api/checkout.ts +526 -526
- package/src/api/collections.ts +130 -130
- package/src/api/consent.ts +75 -75
- package/src/api/content.ts +125 -125
- package/src/api/customers.ts +303 -303
- package/src/api/gift-cards.ts +112 -112
- package/src/api/http.ts +180 -180
- package/src/api/index.ts +30 -30
- package/src/api/menus.ts +77 -77
- package/src/api/metaobjects.ts +136 -136
- package/src/api/orders.ts +290 -290
- package/src/api/products.ts +43 -1
- package/src/api/redirects.ts +37 -37
- package/src/api/regions.ts +200 -200
- package/src/api/reviews.ts +259 -259
- package/src/api/search.ts +163 -163
- package/src/api/store.ts +35 -35
- package/src/api/types.ts +91 -91
- package/src/cart-drawer/context.tsx +277 -164
- package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
- package/src/cart-drawer/gift-wrap.tsx +82 -82
- package/src/cart-drawer/index.ts +71 -68
- package/src/cart-drawer/item/index.tsx +162 -162
- package/src/cart-drawer/item/quantity.tsx +8 -14
- package/src/cart-drawer/item/upsell.tsx +110 -110
- package/src/cart-drawer/labels.ts +123 -123
- package/src/cart-drawer/mutation-queue.ts +78 -0
- package/src/cart-drawer/notes.tsx +131 -131
- package/src/cart-drawer/sticky-footer.tsx +73 -73
- package/src/cart-drawer/summary-breakdown.tsx +197 -197
- package/src/checkout/address-error-copy.ts +117 -117
- package/src/checkout/boxnow-locker-selector.tsx +410 -410
- package/src/checkout/compare-addresses.ts +40 -40
- package/src/checkout/discount-section.tsx +218 -218
- package/src/checkout/error-copy-codes.ts +63 -63
- package/src/checkout/geocode.ts +154 -154
- package/src/checkout/gift-card-section.tsx +224 -224
- package/src/checkout/index.ts +72 -72
- package/src/checkout/labels.ts +495 -495
- package/src/checkout/payment-button.tsx +372 -372
- package/src/checkout/payment-error-copy.ts +154 -154
- package/src/checkout/promotion-error-copy.ts +91 -91
- package/src/checkout/use-checkout-orchestration.ts +1592 -1592
- package/src/common/country-flag.tsx +52 -52
- package/src/common/country-select.tsx +11 -11
- package/src/common/index.ts +20 -20
- package/src/common/market-select.tsx +57 -57
- package/src/index.ts +12 -12
- package/src/lib/country-name.ts +59 -59
- package/src/lib/get-product-price.ts +133 -133
- package/src/lib/media-image.tsx +39 -39
- package/src/lib/payment-constants.ts +53 -53
- package/src/lib/platform.ts +13 -13
- package/src/lib/price.tsx +39 -39
- package/src/lib/store-api-error.ts +36 -36
- package/src/lib/variant-caption.ts +32 -32
- package/src/locales/bg.ts +467 -467
- package/src/locales/context.ts +37 -37
- package/src/locales/en.ts +26 -26
- package/src/locales/es.ts +466 -466
- package/src/locales/index.ts +19 -19
- package/src/locales/provider.tsx +59 -59
- package/src/locales/types.ts +77 -77
- package/src/order/index.ts +62 -62
- package/src/order/labels.ts +79 -79
- package/src/order/order-totals.tsx +250 -250
- package/src/primitives/select-field.tsx +93 -93
- package/src/products/image-gallery.tsx +43 -43
- package/src/products/index.ts +68 -59
- package/src/products/option-select.tsx +67 -67
- package/src/products/product-actions-wrapper.tsx +63 -62
- package/src/products/product-actions.tsx +151 -150
- package/src/products/product-price.tsx +69 -69
- package/src/products/product-promises.tsx +61 -61
- package/src/products/product-specs.tsx +65 -65
- package/src/products/product-tabs.tsx +123 -123
- package/src/products/product-template.tsx +163 -162
- package/src/products/purchase-options.tsx +130 -130
- package/src/products/sets.ts +110 -0
- package/src/products/use-product-actions.ts +199 -164
- package/src/products/variant-matching.ts +71 -71
- package/src/products/variant-url.ts +74 -74
- package/src/reviews-ui/index.ts +73 -73
- package/src/store/category-template.tsx +136 -136
- package/src/store/index.ts +40 -40
- package/src/tracking/chatgpt-pixel.tsx +99 -99
- package/src/tracking/consent-init.tsx +62 -62
- package/src/tracking/events.ts +348 -348
- package/src/tracking/ga4.tsx +93 -93
- package/src/tracking/google-ads.ts +84 -84
- package/src/tracking/gtm.tsx +60 -60
- package/src/tracking/inline-script.ts +49 -49
- package/src/tracking/oaiq.ts +206 -206
- package/src/tracking/tiktok-pixel.tsx +91 -91
- package/src/tracking/track-init.tsx +56 -56
- package/src/tracking/track-order-purchase.tsx +122 -122
- package/src/tracking/ttq.ts +180 -180
- package/src/tracking/use-tracking-config.ts +54 -54
- package/theme/index.css +25 -25
- package/theme/theme.css +150 -150
- package/theme/tokens.css +106 -106
|
@@ -1,74 +1,74 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The product page contract, part one: THE ADDRESS IS THE STATE.
|
|
3
|
-
*
|
|
4
|
-
* A product page names its chosen variant in the address, the way Shopify
|
|
5
|
-
* does: `/products/<handle>?variant=<id>`. One parameter name for every
|
|
6
|
-
* store, so a link in an email, an ad or a chat opens on the variant it was
|
|
7
|
-
* copied from, and a refresh never forgets a choice.
|
|
8
|
-
*
|
|
9
|
-
* The value is the id's DIGITS, never the prefixed key: the platform's law
|
|
10
|
-
* for every id in a URL (entity-numbers-everywhere card). `pvar_1000683784068`
|
|
11
|
-
* travels as `1000683784068`; reading accepts both forms, so an old link
|
|
12
|
-
* with the whole key still resolves.
|
|
13
|
-
*
|
|
14
|
-
* The default, when the address names nothing, is THE MERCHANT'S: the
|
|
15
|
-
* variant made of each option's first value, in the option order the admin
|
|
16
|
-
* shows. Not the first variant of the wire, which is whatever the database
|
|
17
|
-
* scanned first (2026-09-15, evoo: the carton before the bottle on a product
|
|
18
|
-
* whose option says bottle first). Pure module, no React.
|
|
19
|
-
*/
|
|
20
|
-
import type { StoreProduct, StoreProductVariant } from "../api/products"
|
|
21
|
-
import { findMatchingVariant } from "./variant-matching"
|
|
22
|
-
|
|
23
|
-
/** Shopify's parameter name, and ours. */
|
|
24
|
-
export const VARIANT_PARAM = "variant"
|
|
25
|
-
|
|
26
|
-
/** The digits of a platform id; an id without a prefix travels whole. */
|
|
27
|
-
export function variantParamValue(variantId: string): string {
|
|
28
|
-
const underscore = variantId.indexOf("_")
|
|
29
|
-
return underscore >= 0 ? variantId.slice(underscore + 1) : variantId
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** The variant an address names, by its digits or by its whole id. */
|
|
33
|
-
export function findVariantByParam<V extends { id: string }>(
|
|
34
|
-
variants: V[] | null | undefined,
|
|
35
|
-
value: string | null | undefined
|
|
36
|
-
): V | undefined {
|
|
37
|
-
if (!value) return undefined
|
|
38
|
-
return variants?.find((v) => v.id === value || variantParamValue(v.id) === value)
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The merchant's default variant: each option's first value, together;
|
|
43
|
-
* failing that, the lowest rank; failing that, the first of the wire.
|
|
44
|
-
*/
|
|
45
|
-
export function defaultVariant(
|
|
46
|
-
product: Pick<StoreProduct, "options" | "variants">
|
|
47
|
-
): StoreProductVariant | undefined {
|
|
48
|
-
const variants = product.variants ?? []
|
|
49
|
-
if (!variants.length) return undefined
|
|
50
|
-
if (variants.length === 1) return variants[0]
|
|
51
|
-
|
|
52
|
-
const firstValues: Record<string, string> = {}
|
|
53
|
-
for (const option of product.options ?? []) {
|
|
54
|
-
const first = option.values?.[0]?.value
|
|
55
|
-
if (first) firstValues[option.id] = first
|
|
56
|
-
}
|
|
57
|
-
const byOptions = Object.keys(firstValues).length
|
|
58
|
-
? findMatchingVariant(variants, firstValues)
|
|
59
|
-
: undefined
|
|
60
|
-
if (byOptions) return byOptions
|
|
61
|
-
|
|
62
|
-
return [...variants].sort((a, b) => (a.variant_rank ?? 0) - (b.variant_rank ?? 0))[0]
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** The page's address with the variant written in, every other parameter kept. */
|
|
66
|
-
export function variantHref(
|
|
67
|
-
pathname: string,
|
|
68
|
-
search: string | URLSearchParams | null | undefined,
|
|
69
|
-
variantId: string
|
|
70
|
-
): string {
|
|
71
|
-
const params = new URLSearchParams(search ?? "")
|
|
72
|
-
params.set(VARIANT_PARAM, variantParamValue(variantId))
|
|
73
|
-
return `${pathname}?${params.toString()}`
|
|
74
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* The product page contract, part one: THE ADDRESS IS THE STATE.
|
|
3
|
+
*
|
|
4
|
+
* A product page names its chosen variant in the address, the way Shopify
|
|
5
|
+
* does: `/products/<handle>?variant=<id>`. One parameter name for every
|
|
6
|
+
* store, so a link in an email, an ad or a chat opens on the variant it was
|
|
7
|
+
* copied from, and a refresh never forgets a choice.
|
|
8
|
+
*
|
|
9
|
+
* The value is the id's DIGITS, never the prefixed key: the platform's law
|
|
10
|
+
* for every id in a URL (entity-numbers-everywhere card). `pvar_1000683784068`
|
|
11
|
+
* travels as `1000683784068`; reading accepts both forms, so an old link
|
|
12
|
+
* with the whole key still resolves.
|
|
13
|
+
*
|
|
14
|
+
* The default, when the address names nothing, is THE MERCHANT'S: the
|
|
15
|
+
* variant made of each option's first value, in the option order the admin
|
|
16
|
+
* shows. Not the first variant of the wire, which is whatever the database
|
|
17
|
+
* scanned first (2026-09-15, evoo: the carton before the bottle on a product
|
|
18
|
+
* whose option says bottle first). Pure module, no React.
|
|
19
|
+
*/
|
|
20
|
+
import type { StoreProduct, StoreProductVariant } from "../api/products"
|
|
21
|
+
import { findMatchingVariant } from "./variant-matching"
|
|
22
|
+
|
|
23
|
+
/** Shopify's parameter name, and ours. */
|
|
24
|
+
export const VARIANT_PARAM = "variant"
|
|
25
|
+
|
|
26
|
+
/** The digits of a platform id; an id without a prefix travels whole. */
|
|
27
|
+
export function variantParamValue(variantId: string): string {
|
|
28
|
+
const underscore = variantId.indexOf("_")
|
|
29
|
+
return underscore >= 0 ? variantId.slice(underscore + 1) : variantId
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The variant an address names, by its digits or by its whole id. */
|
|
33
|
+
export function findVariantByParam<V extends { id: string }>(
|
|
34
|
+
variants: V[] | null | undefined,
|
|
35
|
+
value: string | null | undefined
|
|
36
|
+
): V | undefined {
|
|
37
|
+
if (!value) return undefined
|
|
38
|
+
return variants?.find((v) => v.id === value || variantParamValue(v.id) === value)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The merchant's default variant: each option's first value, together;
|
|
43
|
+
* failing that, the lowest rank; failing that, the first of the wire.
|
|
44
|
+
*/
|
|
45
|
+
export function defaultVariant(
|
|
46
|
+
product: Pick<StoreProduct, "options" | "variants">
|
|
47
|
+
): StoreProductVariant | undefined {
|
|
48
|
+
const variants = product.variants ?? []
|
|
49
|
+
if (!variants.length) return undefined
|
|
50
|
+
if (variants.length === 1) return variants[0]
|
|
51
|
+
|
|
52
|
+
const firstValues: Record<string, string> = {}
|
|
53
|
+
for (const option of product.options ?? []) {
|
|
54
|
+
const first = option.values?.[0]?.value
|
|
55
|
+
if (first) firstValues[option.id] = first
|
|
56
|
+
}
|
|
57
|
+
const byOptions = Object.keys(firstValues).length
|
|
58
|
+
? findMatchingVariant(variants, firstValues)
|
|
59
|
+
: undefined
|
|
60
|
+
if (byOptions) return byOptions
|
|
61
|
+
|
|
62
|
+
return [...variants].sort((a, b) => (a.variant_rank ?? 0) - (b.variant_rank ?? 0))[0]
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The page's address with the variant written in, every other parameter kept. */
|
|
66
|
+
export function variantHref(
|
|
67
|
+
pathname: string,
|
|
68
|
+
search: string | URLSearchParams | null | undefined,
|
|
69
|
+
variantId: string
|
|
70
|
+
): string {
|
|
71
|
+
const params = new URLSearchParams(search ?? "")
|
|
72
|
+
params.set(VARIANT_PARAM, variantParamValue(variantId))
|
|
73
|
+
return `${pathname}?${params.toString()}`
|
|
74
|
+
}
|
package/src/reviews-ui/index.ts
CHANGED
|
@@ -1,73 +1,73 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @cartbase/storefront/reviews-ui — verified-purchase review components.
|
|
3
|
-
* NEW family (no @1click/ui counterpart): ported/adapted from the Alenika
|
|
4
|
-
* production storefront (src/components/product/reviews-list.tsx + the
|
|
5
|
-
* /review/[token] page + review-form.tsx) over the batch-9 SDK
|
|
6
|
-
* (`api/reviews` — DTOs mirror alenika/src/lib/reviews-api.ts verbatim).
|
|
7
|
-
* Contracts: docs/storefront/components.md + docs/storefront/reviews.md.
|
|
8
|
-
*/
|
|
9
|
-
export {
|
|
10
|
-
defaultReviewsUiLabels,
|
|
11
|
-
type ReviewsUiLabels,
|
|
12
|
-
} from "./labels"
|
|
13
|
-
|
|
14
|
-
export {
|
|
15
|
-
formatLabel,
|
|
16
|
-
reviewDisplayName,
|
|
17
|
-
firstNameOf,
|
|
18
|
-
formatReviewDate,
|
|
19
|
-
sortParamsFor,
|
|
20
|
-
reviewCountLabel,
|
|
21
|
-
checkFileForUpload,
|
|
22
|
-
MAX_IMAGES,
|
|
23
|
-
MAX_VIDEOS,
|
|
24
|
-
MAX_BODY,
|
|
25
|
-
MAX_IMAGE_BYTES,
|
|
26
|
-
MAX_VIDEO_BYTES,
|
|
27
|
-
MAX_VIDEO_SECONDS,
|
|
28
|
-
IMAGE_MIME,
|
|
29
|
-
VIDEO_MIME,
|
|
30
|
-
UPLOAD_ACCEPT,
|
|
31
|
-
type ReviewSortKey,
|
|
32
|
-
type FileCheckInput,
|
|
33
|
-
type FileCheckResult,
|
|
34
|
-
} from "./helpers"
|
|
35
|
-
|
|
36
|
-
export {
|
|
37
|
-
resolveWizardEntry,
|
|
38
|
-
submitErrorKeyFor,
|
|
39
|
-
canSubmitRating,
|
|
40
|
-
type WizardEntry,
|
|
41
|
-
type WizardStep,
|
|
42
|
-
type SubmitErrorKey,
|
|
43
|
-
} from "./wizard-state"
|
|
44
|
-
|
|
45
|
-
export {
|
|
46
|
-
StarRow,
|
|
47
|
-
StarBadge,
|
|
48
|
-
RatingDistribution,
|
|
49
|
-
type StarBadgeProps,
|
|
50
|
-
type RatingDistributionProps,
|
|
51
|
-
} from "./star-badge"
|
|
52
|
-
|
|
53
|
-
export {
|
|
54
|
-
ReviewList,
|
|
55
|
-
ReviewLightbox,
|
|
56
|
-
type ReviewListProps,
|
|
57
|
-
} from "./review-list"
|
|
58
|
-
|
|
59
|
-
export { ReviewWidget, type ReviewWidgetProps } from "./review-widget"
|
|
60
|
-
|
|
61
|
-
export {
|
|
62
|
-
ReviewWizard,
|
|
63
|
-
ReviewWizardForm,
|
|
64
|
-
type ReviewWizardProps,
|
|
65
|
-
} from "./review-wizard"
|
|
66
|
-
|
|
67
|
-
export {
|
|
68
|
-
ReviewPhotoUpload,
|
|
69
|
-
readyMedia,
|
|
70
|
-
putToSignedUrl,
|
|
71
|
-
type LocalMedia,
|
|
72
|
-
type ReviewPhotoUploadProps,
|
|
73
|
-
} from "./photo-upload"
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront/reviews-ui — verified-purchase review components.
|
|
3
|
+
* NEW family (no @1click/ui counterpart): ported/adapted from the Alenika
|
|
4
|
+
* production storefront (src/components/product/reviews-list.tsx + the
|
|
5
|
+
* /review/[token] page + review-form.tsx) over the batch-9 SDK
|
|
6
|
+
* (`api/reviews` — DTOs mirror alenika/src/lib/reviews-api.ts verbatim).
|
|
7
|
+
* Contracts: docs/storefront/components.md + docs/storefront/reviews.md.
|
|
8
|
+
*/
|
|
9
|
+
export {
|
|
10
|
+
defaultReviewsUiLabels,
|
|
11
|
+
type ReviewsUiLabels,
|
|
12
|
+
} from "./labels"
|
|
13
|
+
|
|
14
|
+
export {
|
|
15
|
+
formatLabel,
|
|
16
|
+
reviewDisplayName,
|
|
17
|
+
firstNameOf,
|
|
18
|
+
formatReviewDate,
|
|
19
|
+
sortParamsFor,
|
|
20
|
+
reviewCountLabel,
|
|
21
|
+
checkFileForUpload,
|
|
22
|
+
MAX_IMAGES,
|
|
23
|
+
MAX_VIDEOS,
|
|
24
|
+
MAX_BODY,
|
|
25
|
+
MAX_IMAGE_BYTES,
|
|
26
|
+
MAX_VIDEO_BYTES,
|
|
27
|
+
MAX_VIDEO_SECONDS,
|
|
28
|
+
IMAGE_MIME,
|
|
29
|
+
VIDEO_MIME,
|
|
30
|
+
UPLOAD_ACCEPT,
|
|
31
|
+
type ReviewSortKey,
|
|
32
|
+
type FileCheckInput,
|
|
33
|
+
type FileCheckResult,
|
|
34
|
+
} from "./helpers"
|
|
35
|
+
|
|
36
|
+
export {
|
|
37
|
+
resolveWizardEntry,
|
|
38
|
+
submitErrorKeyFor,
|
|
39
|
+
canSubmitRating,
|
|
40
|
+
type WizardEntry,
|
|
41
|
+
type WizardStep,
|
|
42
|
+
type SubmitErrorKey,
|
|
43
|
+
} from "./wizard-state"
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
StarRow,
|
|
47
|
+
StarBadge,
|
|
48
|
+
RatingDistribution,
|
|
49
|
+
type StarBadgeProps,
|
|
50
|
+
type RatingDistributionProps,
|
|
51
|
+
} from "./star-badge"
|
|
52
|
+
|
|
53
|
+
export {
|
|
54
|
+
ReviewList,
|
|
55
|
+
ReviewLightbox,
|
|
56
|
+
type ReviewListProps,
|
|
57
|
+
} from "./review-list"
|
|
58
|
+
|
|
59
|
+
export { ReviewWidget, type ReviewWidgetProps } from "./review-widget"
|
|
60
|
+
|
|
61
|
+
export {
|
|
62
|
+
ReviewWizard,
|
|
63
|
+
ReviewWizardForm,
|
|
64
|
+
type ReviewWizardProps,
|
|
65
|
+
} from "./review-wizard"
|
|
66
|
+
|
|
67
|
+
export {
|
|
68
|
+
ReviewPhotoUpload,
|
|
69
|
+
readyMedia,
|
|
70
|
+
putToSignedUrl,
|
|
71
|
+
type LocalMedia,
|
|
72
|
+
type ReviewPhotoUploadProps,
|
|
73
|
+
} from "./photo-upload"
|
|
@@ -1,136 +1,136 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Category page — ported from
|
|
3
|
-
* `@1click/ui/src/store/category-template.tsx` (v2.3.1). Data seam:
|
|
4
|
-
* `HttpTypes.StoreProductCategory` → Cartbase `StoreProductCategory`
|
|
5
|
-
* (`@cartbase/storefront/api/categories`); products come from the plain
|
|
6
|
-
* listing filtered by `category_id` (membership), via `PaginatedProducts`.
|
|
7
|
-
*
|
|
8
|
-
* Breadcrumbs need `parent_category` chains and the child list needs
|
|
9
|
-
* `category_children` — fetch the category with `include_ancestors_tree` +
|
|
10
|
-
* `include_descendants_tree` (`retrieveCategory` query flags), otherwise
|
|
11
|
-
* those sections simply don't render. Skeleton count is the fixed default
|
|
12
|
-
* (Cartbase categories don't embed a products array).
|
|
13
|
-
*/
|
|
14
|
-
import { Suspense } from "react"
|
|
15
|
-
import { notFound } from "next/navigation"
|
|
16
|
-
import Link from "next/link"
|
|
17
|
-
import type { StorefrontClient } from "../api/http"
|
|
18
|
-
import type { PricingContextQuery } from "../api/types"
|
|
19
|
-
import type { StoreProductCategory } from "../api/categories"
|
|
20
|
-
import type { SortOptions } from "../lib/sort-products"
|
|
21
|
-
import type { StoreLabels } from "./labels"
|
|
22
|
-
import { SortSelect } from "./sort-select"
|
|
23
|
-
import { PaginatedProducts, type ProductCardComponent } from "./paginated-products"
|
|
24
|
-
import { SkeletonProductGrid } from "./skeleton-product-grid"
|
|
25
|
-
|
|
26
|
-
type CategoryTemplateProps = {
|
|
27
|
-
client: StorefrontClient
|
|
28
|
-
category: StoreProductCategory
|
|
29
|
-
sortBy?: SortOptions
|
|
30
|
-
/** From the `page` query param. */
|
|
31
|
-
page?: string
|
|
32
|
-
pricingContext?: PricingContextQuery
|
|
33
|
-
/**
|
|
34
|
-
* The store's pack for this area, REQUIRED: this is a server component and
|
|
35
|
-
* cannot read `StorefrontLocaleProvider`, so the page passes
|
|
36
|
-
* `STORE_LOCALE.store` (`en.store` for an English store). Required since
|
|
37
|
-
* 2026-09-14 so that forgetting it fails the build instead of shipping
|
|
38
|
-
* silent English.
|
|
39
|
-
*/
|
|
40
|
-
labels: StoreLabels
|
|
41
|
-
/** Override the default ProductPreview with a store-specific card. */
|
|
42
|
-
renderProduct?: ProductCardComponent
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function CategoryTemplate({
|
|
46
|
-
client,
|
|
47
|
-
category,
|
|
48
|
-
sortBy,
|
|
49
|
-
page,
|
|
50
|
-
pricingContext,
|
|
51
|
-
labels,
|
|
52
|
-
renderProduct,
|
|
53
|
-
}: CategoryTemplateProps) {
|
|
54
|
-
const pageNumber = page ? parseInt(page) : 1
|
|
55
|
-
const sort = sortBy || "created_at"
|
|
56
|
-
|
|
57
|
-
if (!category) notFound()
|
|
58
|
-
|
|
59
|
-
const parents: StoreProductCategory[] = []
|
|
60
|
-
const getParents = (cat: StoreProductCategory) => {
|
|
61
|
-
if (cat.parent_category) {
|
|
62
|
-
parents.push(cat.parent_category)
|
|
63
|
-
getParents(cat.parent_category)
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
getParents(category)
|
|
67
|
-
|
|
68
|
-
return (
|
|
69
|
-
<div
|
|
70
|
-
className="flex flex-col sm:flex-row sm:items-start py-6 max-w-7xl mx-auto px-4"
|
|
71
|
-
data-testid="category-container"
|
|
72
|
-
>
|
|
73
|
-
<div className="flex sm:flex-col gap-12 py-4 mb-8 sm:px-0 pl-6 sm:min-w-[250px]">
|
|
74
|
-
<SortSelect
|
|
75
|
-
sortBy={sort}
|
|
76
|
-
labels={labels}
|
|
77
|
-
data-testid="sort-by-container"
|
|
78
|
-
/>
|
|
79
|
-
</div>
|
|
80
|
-
<div className="w-full">
|
|
81
|
-
<div className="flex flex-row mb-8 gap-4">
|
|
82
|
-
{parents.map((parent) => (
|
|
83
|
-
<span key={parent.id} className="text-muted-foreground text-2xl">
|
|
84
|
-
<Link
|
|
85
|
-
className="mr-4 hover:text-foreground"
|
|
86
|
-
href={`/categories/${parent.handle}`}
|
|
87
|
-
>
|
|
88
|
-
{parent.name}
|
|
89
|
-
</Link>
|
|
90
|
-
/
|
|
91
|
-
</span>
|
|
92
|
-
))}
|
|
93
|
-
<h1
|
|
94
|
-
className="text-2xl font-semibold text-foreground"
|
|
95
|
-
data-testid="category-page-title"
|
|
96
|
-
>
|
|
97
|
-
{category.name}
|
|
98
|
-
</h1>
|
|
99
|
-
</div>
|
|
100
|
-
{category.description && (
|
|
101
|
-
<div className="mb-8 text-sm text-muted-foreground">
|
|
102
|
-
<p>{category.description}</p>
|
|
103
|
-
</div>
|
|
104
|
-
)}
|
|
105
|
-
{category.category_children && (
|
|
106
|
-
<div className="mb-8">
|
|
107
|
-
<ul className="grid grid-cols-1 gap-2">
|
|
108
|
-
{category.category_children.map((c) => (
|
|
109
|
-
<li key={c.id}>
|
|
110
|
-
<Link
|
|
111
|
-
href={`/categories/${c.handle}`}
|
|
112
|
-
className="text-primary hover:underline"
|
|
113
|
-
>
|
|
114
|
-
{c.name}
|
|
115
|
-
</Link>
|
|
116
|
-
</li>
|
|
117
|
-
))}
|
|
118
|
-
</ul>
|
|
119
|
-
</div>
|
|
120
|
-
)}
|
|
121
|
-
<Suspense fallback={<SkeletonProductGrid />}>
|
|
122
|
-
<PaginatedProducts
|
|
123
|
-
client={client}
|
|
124
|
-
sortBy={sort}
|
|
125
|
-
page={pageNumber}
|
|
126
|
-
categoryId={category.id}
|
|
127
|
-
pricingContext={pricingContext}
|
|
128
|
-
renderProduct={renderProduct}
|
|
129
|
-
/>
|
|
130
|
-
</Suspense>
|
|
131
|
-
</div>
|
|
132
|
-
</div>
|
|
133
|
-
)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
export { type CategoryTemplateProps }
|
|
1
|
+
/**
|
|
2
|
+
* Category page — ported from
|
|
3
|
+
* `@1click/ui/src/store/category-template.tsx` (v2.3.1). Data seam:
|
|
4
|
+
* `HttpTypes.StoreProductCategory` → Cartbase `StoreProductCategory`
|
|
5
|
+
* (`@cartbase/storefront/api/categories`); products come from the plain
|
|
6
|
+
* listing filtered by `category_id` (membership), via `PaginatedProducts`.
|
|
7
|
+
*
|
|
8
|
+
* Breadcrumbs need `parent_category` chains and the child list needs
|
|
9
|
+
* `category_children` — fetch the category with `include_ancestors_tree` +
|
|
10
|
+
* `include_descendants_tree` (`retrieveCategory` query flags), otherwise
|
|
11
|
+
* those sections simply don't render. Skeleton count is the fixed default
|
|
12
|
+
* (Cartbase categories don't embed a products array).
|
|
13
|
+
*/
|
|
14
|
+
import { Suspense } from "react"
|
|
15
|
+
import { notFound } from "next/navigation"
|
|
16
|
+
import Link from "next/link"
|
|
17
|
+
import type { StorefrontClient } from "../api/http"
|
|
18
|
+
import type { PricingContextQuery } from "../api/types"
|
|
19
|
+
import type { StoreProductCategory } from "../api/categories"
|
|
20
|
+
import type { SortOptions } from "../lib/sort-products"
|
|
21
|
+
import type { StoreLabels } from "./labels"
|
|
22
|
+
import { SortSelect } from "./sort-select"
|
|
23
|
+
import { PaginatedProducts, type ProductCardComponent } from "./paginated-products"
|
|
24
|
+
import { SkeletonProductGrid } from "./skeleton-product-grid"
|
|
25
|
+
|
|
26
|
+
type CategoryTemplateProps = {
|
|
27
|
+
client: StorefrontClient
|
|
28
|
+
category: StoreProductCategory
|
|
29
|
+
sortBy?: SortOptions
|
|
30
|
+
/** From the `page` query param. */
|
|
31
|
+
page?: string
|
|
32
|
+
pricingContext?: PricingContextQuery
|
|
33
|
+
/**
|
|
34
|
+
* The store's pack for this area, REQUIRED: this is a server component and
|
|
35
|
+
* cannot read `StorefrontLocaleProvider`, so the page passes
|
|
36
|
+
* `STORE_LOCALE.store` (`en.store` for an English store). Required since
|
|
37
|
+
* 2026-09-14 so that forgetting it fails the build instead of shipping
|
|
38
|
+
* silent English.
|
|
39
|
+
*/
|
|
40
|
+
labels: StoreLabels
|
|
41
|
+
/** Override the default ProductPreview with a store-specific card. */
|
|
42
|
+
renderProduct?: ProductCardComponent
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function CategoryTemplate({
|
|
46
|
+
client,
|
|
47
|
+
category,
|
|
48
|
+
sortBy,
|
|
49
|
+
page,
|
|
50
|
+
pricingContext,
|
|
51
|
+
labels,
|
|
52
|
+
renderProduct,
|
|
53
|
+
}: CategoryTemplateProps) {
|
|
54
|
+
const pageNumber = page ? parseInt(page) : 1
|
|
55
|
+
const sort = sortBy || "created_at"
|
|
56
|
+
|
|
57
|
+
if (!category) notFound()
|
|
58
|
+
|
|
59
|
+
const parents: StoreProductCategory[] = []
|
|
60
|
+
const getParents = (cat: StoreProductCategory) => {
|
|
61
|
+
if (cat.parent_category) {
|
|
62
|
+
parents.push(cat.parent_category)
|
|
63
|
+
getParents(cat.parent_category)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
getParents(category)
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div
|
|
70
|
+
className="flex flex-col sm:flex-row sm:items-start py-6 max-w-7xl mx-auto px-4"
|
|
71
|
+
data-testid="category-container"
|
|
72
|
+
>
|
|
73
|
+
<div className="flex sm:flex-col gap-12 py-4 mb-8 sm:px-0 pl-6 sm:min-w-[250px]">
|
|
74
|
+
<SortSelect
|
|
75
|
+
sortBy={sort}
|
|
76
|
+
labels={labels}
|
|
77
|
+
data-testid="sort-by-container"
|
|
78
|
+
/>
|
|
79
|
+
</div>
|
|
80
|
+
<div className="w-full">
|
|
81
|
+
<div className="flex flex-row mb-8 gap-4">
|
|
82
|
+
{parents.map((parent) => (
|
|
83
|
+
<span key={parent.id} className="text-muted-foreground text-2xl">
|
|
84
|
+
<Link
|
|
85
|
+
className="mr-4 hover:text-foreground"
|
|
86
|
+
href={`/categories/${parent.handle}`}
|
|
87
|
+
>
|
|
88
|
+
{parent.name}
|
|
89
|
+
</Link>
|
|
90
|
+
/
|
|
91
|
+
</span>
|
|
92
|
+
))}
|
|
93
|
+
<h1
|
|
94
|
+
className="text-2xl font-semibold text-foreground"
|
|
95
|
+
data-testid="category-page-title"
|
|
96
|
+
>
|
|
97
|
+
{category.name}
|
|
98
|
+
</h1>
|
|
99
|
+
</div>
|
|
100
|
+
{category.description && (
|
|
101
|
+
<div className="mb-8 text-sm text-muted-foreground">
|
|
102
|
+
<p>{category.description}</p>
|
|
103
|
+
</div>
|
|
104
|
+
)}
|
|
105
|
+
{category.category_children && (
|
|
106
|
+
<div className="mb-8">
|
|
107
|
+
<ul className="grid grid-cols-1 gap-2">
|
|
108
|
+
{category.category_children.map((c) => (
|
|
109
|
+
<li key={c.id}>
|
|
110
|
+
<Link
|
|
111
|
+
href={`/categories/${c.handle}`}
|
|
112
|
+
className="text-primary hover:underline"
|
|
113
|
+
>
|
|
114
|
+
{c.name}
|
|
115
|
+
</Link>
|
|
116
|
+
</li>
|
|
117
|
+
))}
|
|
118
|
+
</ul>
|
|
119
|
+
</div>
|
|
120
|
+
)}
|
|
121
|
+
<Suspense fallback={<SkeletonProductGrid />}>
|
|
122
|
+
<PaginatedProducts
|
|
123
|
+
client={client}
|
|
124
|
+
sortBy={sort}
|
|
125
|
+
page={pageNumber}
|
|
126
|
+
categoryId={category.id}
|
|
127
|
+
pricingContext={pricingContext}
|
|
128
|
+
renderProduct={renderProduct}
|
|
129
|
+
/>
|
|
130
|
+
</Suspense>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export { type CategoryTemplateProps }
|
package/src/store/index.ts
CHANGED
|
@@ -1,40 +1,40 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @cartbase/storefront — store (listing) family barrel. Prefer subpath
|
|
3
|
-
* imports in apps; the barrel exists for convenience and for the extra pure
|
|
4
|
-
* module (search-params).
|
|
5
|
-
*/
|
|
6
|
-
export {
|
|
7
|
-
defaultStoreLabels,
|
|
8
|
-
sortOptionLabelKeys,
|
|
9
|
-
type StoreLabels,
|
|
10
|
-
} from "./labels"
|
|
11
|
-
|
|
12
|
-
export { Pagination } from "./pagination"
|
|
13
|
-
export { SortSelect, type SortSelectProps } from "./sort-select"
|
|
14
|
-
export { PaginatedProducts, type ProductCardComponent } from "./paginated-products"
|
|
15
|
-
export { SkeletonProductGrid } from "./skeleton-product-grid"
|
|
16
|
-
export { StoreTemplate, type StoreTemplateProps } from "./store-template"
|
|
17
|
-
export {
|
|
18
|
-
CollectionTemplate,
|
|
19
|
-
type CollectionTemplateProps,
|
|
20
|
-
} from "./collection-template"
|
|
21
|
-
export {
|
|
22
|
-
CategoryTemplate,
|
|
23
|
-
type CategoryTemplateProps,
|
|
24
|
-
} from "./category-template"
|
|
25
|
-
export { SearchTemplate, type SearchTemplateProps } from "./search-template"
|
|
26
|
-
|
|
27
|
-
export {
|
|
28
|
-
parseSearchParams,
|
|
29
|
-
buildSearchQueryString,
|
|
30
|
-
fromQueryString,
|
|
31
|
-
toggleFacetSelection,
|
|
32
|
-
isFacetSelected,
|
|
33
|
-
clearFilters,
|
|
34
|
-
toSearchQuery,
|
|
35
|
-
type SearchPageState,
|
|
36
|
-
type RawSearchParams,
|
|
37
|
-
type FacetBucketRef,
|
|
38
|
-
} from "./search-params"
|
|
39
|
-
|
|
40
|
-
export { type SortOptions } from "../lib/sort-products"
|
|
1
|
+
/**
|
|
2
|
+
* @cartbase/storefront — store (listing) family barrel. Prefer subpath
|
|
3
|
+
* imports in apps; the barrel exists for convenience and for the extra pure
|
|
4
|
+
* module (search-params).
|
|
5
|
+
*/
|
|
6
|
+
export {
|
|
7
|
+
defaultStoreLabels,
|
|
8
|
+
sortOptionLabelKeys,
|
|
9
|
+
type StoreLabels,
|
|
10
|
+
} from "./labels"
|
|
11
|
+
|
|
12
|
+
export { Pagination } from "./pagination"
|
|
13
|
+
export { SortSelect, type SortSelectProps } from "./sort-select"
|
|
14
|
+
export { PaginatedProducts, type ProductCardComponent } from "./paginated-products"
|
|
15
|
+
export { SkeletonProductGrid } from "./skeleton-product-grid"
|
|
16
|
+
export { StoreTemplate, type StoreTemplateProps } from "./store-template"
|
|
17
|
+
export {
|
|
18
|
+
CollectionTemplate,
|
|
19
|
+
type CollectionTemplateProps,
|
|
20
|
+
} from "./collection-template"
|
|
21
|
+
export {
|
|
22
|
+
CategoryTemplate,
|
|
23
|
+
type CategoryTemplateProps,
|
|
24
|
+
} from "./category-template"
|
|
25
|
+
export { SearchTemplate, type SearchTemplateProps } from "./search-template"
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
parseSearchParams,
|
|
29
|
+
buildSearchQueryString,
|
|
30
|
+
fromQueryString,
|
|
31
|
+
toggleFacetSelection,
|
|
32
|
+
isFacetSelected,
|
|
33
|
+
clearFilters,
|
|
34
|
+
toSearchQuery,
|
|
35
|
+
type SearchPageState,
|
|
36
|
+
type RawSearchParams,
|
|
37
|
+
type FacetBucketRef,
|
|
38
|
+
} from "./search-params"
|
|
39
|
+
|
|
40
|
+
export { type SortOptions } from "../lib/sort-products"
|