@cartbase/storefront 0.16.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.
Files changed (110) hide show
  1. package/LICENSE +21 -21
  2. package/package.json +258 -255
  3. package/src/api/auth.ts +108 -108
  4. package/src/api/carts.ts +523 -523
  5. package/src/api/categories.ts +184 -184
  6. package/src/api/checkout.ts +526 -526
  7. package/src/api/collections.ts +130 -130
  8. package/src/api/consent.ts +75 -75
  9. package/src/api/content.ts +125 -125
  10. package/src/api/customers.ts +303 -303
  11. package/src/api/gift-cards.ts +112 -112
  12. package/src/api/http.ts +180 -130
  13. package/src/api/index.ts +30 -30
  14. package/src/api/menus.ts +77 -77
  15. package/src/api/metaobjects.ts +136 -136
  16. package/src/api/orders.ts +290 -290
  17. package/src/api/products.ts +43 -1
  18. package/src/api/redirects.ts +37 -37
  19. package/src/api/regions.ts +200 -200
  20. package/src/api/reviews.ts +259 -259
  21. package/src/api/search.ts +163 -163
  22. package/src/api/store.ts +35 -35
  23. package/src/api/types.ts +91 -91
  24. package/src/cart-drawer/context.tsx +277 -164
  25. package/src/cart-drawer/cross-sell-carousel.tsx +211 -211
  26. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -158
  27. package/src/cart-drawer/free-gift.tsx +1 -1
  28. package/src/cart-drawer/gift-wrap.tsx +82 -82
  29. package/src/cart-drawer/index.ts +71 -68
  30. package/src/cart-drawer/item/index.tsx +162 -162
  31. package/src/cart-drawer/item/quantity.tsx +8 -14
  32. package/src/cart-drawer/item/upsell.tsx +110 -110
  33. package/src/cart-drawer/labels.ts +123 -123
  34. package/src/cart-drawer/mutation-queue.ts +78 -0
  35. package/src/cart-drawer/notes.tsx +131 -131
  36. package/src/cart-drawer/sticky-footer.tsx +73 -73
  37. package/src/cart-drawer/summary-breakdown.tsx +197 -197
  38. package/src/checkout/address-error-copy.ts +117 -117
  39. package/src/checkout/boxnow-locker-selector.tsx +410 -410
  40. package/src/checkout/compare-addresses.ts +40 -40
  41. package/src/checkout/discount-section.tsx +218 -218
  42. package/src/checkout/error-copy-codes.ts +63 -63
  43. package/src/checkout/geocode.ts +154 -154
  44. package/src/checkout/gift-card-section.tsx +224 -224
  45. package/src/checkout/index.ts +72 -72
  46. package/src/checkout/labels.ts +495 -495
  47. package/src/checkout/payment-button.tsx +372 -372
  48. package/src/checkout/payment-error-copy.ts +154 -154
  49. package/src/checkout/promotion-error-copy.ts +91 -91
  50. package/src/checkout/use-checkout-orchestration.ts +1592 -1592
  51. package/src/common/country-flag.tsx +52 -52
  52. package/src/common/country-select.tsx +11 -11
  53. package/src/common/index.ts +20 -20
  54. package/src/common/market-select.tsx +57 -57
  55. package/src/index.ts +12 -12
  56. package/src/lib/country-name.ts +59 -59
  57. package/src/lib/get-product-price.ts +133 -133
  58. package/src/lib/media-image.tsx +39 -0
  59. package/src/lib/payment-constants.ts +53 -53
  60. package/src/lib/platform.ts +13 -13
  61. package/src/lib/price.tsx +39 -39
  62. package/src/lib/store-api-error.ts +36 -36
  63. package/src/lib/variant-caption.ts +32 -32
  64. package/src/locales/bg.ts +467 -467
  65. package/src/locales/context.ts +37 -37
  66. package/src/locales/en.ts +26 -26
  67. package/src/locales/es.ts +466 -466
  68. package/src/locales/index.ts +19 -19
  69. package/src/locales/provider.tsx +59 -59
  70. package/src/locales/types.ts +77 -77
  71. package/src/order/index.ts +62 -62
  72. package/src/order/labels.ts +79 -79
  73. package/src/order/order-item.tsx +1 -1
  74. package/src/order/order-totals.tsx +250 -250
  75. package/src/primitives/select-field.tsx +93 -93
  76. package/src/products/image-gallery.tsx +43 -43
  77. package/src/products/index.ts +68 -59
  78. package/src/products/option-select.tsx +67 -67
  79. package/src/products/product-actions-wrapper.tsx +63 -62
  80. package/src/products/product-actions.tsx +151 -150
  81. package/src/products/product-price.tsx +69 -69
  82. package/src/products/product-promises.tsx +61 -61
  83. package/src/products/product-specs.tsx +65 -65
  84. package/src/products/product-tabs.tsx +123 -123
  85. package/src/products/product-template.tsx +163 -162
  86. package/src/products/purchase-options.tsx +130 -130
  87. package/src/products/sets.ts +110 -0
  88. package/src/products/thumbnail.tsx +1 -1
  89. package/src/products/use-product-actions.ts +199 -164
  90. package/src/products/variant-matching.ts +71 -71
  91. package/src/products/variant-url.ts +74 -74
  92. package/src/reviews-ui/index.ts +73 -73
  93. package/src/store/category-template.tsx +136 -136
  94. package/src/store/index.ts +40 -40
  95. package/src/tracking/chatgpt-pixel.tsx +99 -99
  96. package/src/tracking/consent-init.tsx +62 -62
  97. package/src/tracking/events.ts +348 -348
  98. package/src/tracking/ga4.tsx +93 -93
  99. package/src/tracking/google-ads.ts +84 -84
  100. package/src/tracking/gtm.tsx +60 -60
  101. package/src/tracking/inline-script.ts +49 -49
  102. package/src/tracking/oaiq.ts +206 -206
  103. package/src/tracking/tiktok-pixel.tsx +91 -91
  104. package/src/tracking/track-init.tsx +56 -56
  105. package/src/tracking/track-order-purchase.tsx +122 -122
  106. package/src/tracking/ttq.ts +180 -180
  107. package/src/tracking/use-tracking-config.ts +54 -54
  108. package/theme/index.css +25 -25
  109. package/theme/theme.css +150 -150
  110. 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
+ }
@@ -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 }
@@ -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"