@cartbase/storefront 0.1.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 (176) hide show
  1. package/package.json +226 -0
  2. package/src/api/auth.ts +108 -0
  3. package/src/api/carts.ts +506 -0
  4. package/src/api/categories.ts +184 -0
  5. package/src/api/checkout.ts +440 -0
  6. package/src/api/collections.ts +130 -0
  7. package/src/api/consent.ts +75 -0
  8. package/src/api/content.ts +125 -0
  9. package/src/api/customers.ts +307 -0
  10. package/src/api/gift-cards.ts +112 -0
  11. package/src/api/http.ts +122 -0
  12. package/src/api/index.ts +29 -0
  13. package/src/api/integrations.ts +130 -0
  14. package/src/api/menus.ts +77 -0
  15. package/src/api/metaobjects.ts +136 -0
  16. package/src/api/orders.ts +290 -0
  17. package/src/api/products.ts +303 -0
  18. package/src/api/redirects.ts +37 -0
  19. package/src/api/regions.ts +141 -0
  20. package/src/api/reviews.ts +259 -0
  21. package/src/api/search.ts +133 -0
  22. package/src/api/types.ts +91 -0
  23. package/src/cart-drawer/cart-drawer.tsx +86 -0
  24. package/src/cart-drawer/context.tsx +569 -0
  25. package/src/cart-drawer/continue-shopping.tsx +28 -0
  26. package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
  27. package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
  28. package/src/cart-drawer/empty.tsx +52 -0
  29. package/src/cart-drawer/free-gift.tsx +71 -0
  30. package/src/cart-drawer/gift-wrap.tsx +83 -0
  31. package/src/cart-drawer/header.tsx +52 -0
  32. package/src/cart-drawer/index.ts +69 -0
  33. package/src/cart-drawer/item/index.tsx +164 -0
  34. package/src/cart-drawer/item/quantity.tsx +100 -0
  35. package/src/cart-drawer/item/upsell.tsx +110 -0
  36. package/src/cart-drawer/item/variant.tsx +46 -0
  37. package/src/cart-drawer/labels-bg.ts +72 -0
  38. package/src/cart-drawer/labels.ts +119 -0
  39. package/src/cart-drawer/notes.tsx +131 -0
  40. package/src/cart-drawer/payment-badges.tsx +96 -0
  41. package/src/cart-drawer/promo-banner.tsx +43 -0
  42. package/src/cart-drawer/rewards-points.tsx +78 -0
  43. package/src/cart-drawer/sticky-footer.tsx +73 -0
  44. package/src/cart-drawer/summary-breakdown.tsx +196 -0
  45. package/src/cart-drawer/template.tsx +225 -0
  46. package/src/cart-drawer/tiered-progress.tsx +168 -0
  47. package/src/checkout/address-error-copy.ts +119 -0
  48. package/src/checkout/address-form.tsx +224 -0
  49. package/src/checkout/address-select.tsx +79 -0
  50. package/src/checkout/boxnow-locker-selector.tsx +410 -0
  51. package/src/checkout/checkout-client.tsx +222 -0
  52. package/src/checkout/company-details.tsx +94 -0
  53. package/src/checkout/compare-addresses.ts +40 -0
  54. package/src/checkout/context.tsx +76 -0
  55. package/src/checkout/discount-section.tsx +218 -0
  56. package/src/checkout/econt-office-selector.tsx +332 -0
  57. package/src/checkout/error-message.tsx +25 -0
  58. package/src/checkout/geocode.ts +154 -0
  59. package/src/checkout/gift-card-section.tsx +224 -0
  60. package/src/checkout/index.ts +74 -0
  61. package/src/checkout/labels-bg.ts +128 -0
  62. package/src/checkout/labels.ts +263 -0
  63. package/src/checkout/line-item-card.tsx +152 -0
  64. package/src/checkout/order-summary.tsx +524 -0
  65. package/src/checkout/payment-button.tsx +373 -0
  66. package/src/checkout/payment-error-copy.ts +269 -0
  67. package/src/checkout/payment-method-list.tsx +365 -0
  68. package/src/checkout/payment-wrapper.tsx +102 -0
  69. package/src/checkout/promotion-error-copy.ts +124 -0
  70. package/src/checkout/shipping-method-list.tsx +335 -0
  71. package/src/checkout/stripe-wrapper.tsx +165 -0
  72. package/src/checkout/use-checkout-orchestration.ts +1504 -0
  73. package/src/common/cart-button-client.tsx +39 -0
  74. package/src/common/cart-button.tsx +28 -0
  75. package/src/common/country-select.tsx +65 -0
  76. package/src/common/delete-button.tsx +66 -0
  77. package/src/common/index.ts +17 -0
  78. package/src/common/language-select.tsx +78 -0
  79. package/src/common/localized-link.tsx +45 -0
  80. package/src/common/skeleton.tsx +29 -0
  81. package/src/index.ts +12 -0
  82. package/src/lib/cart-helpers.ts +113 -0
  83. package/src/lib/dual-price.tsx +73 -0
  84. package/src/lib/get-percentage-diff.ts +5 -0
  85. package/src/lib/get-product-price.ts +133 -0
  86. package/src/lib/hooks/use-intersection.ts +30 -0
  87. package/src/lib/hooks/use-toggle-state.ts +25 -0
  88. package/src/lib/money.ts +73 -0
  89. package/src/lib/payment-constants.ts +66 -0
  90. package/src/lib/product.ts +22 -0
  91. package/src/lib/sort-products.ts +63 -0
  92. package/src/lib/store-api-error.ts +36 -0
  93. package/src/lib/utils.ts +16 -0
  94. package/src/order/context.tsx +32 -0
  95. package/src/order/index.ts +63 -0
  96. package/src/order/labels-bg.ts +39 -0
  97. package/src/order/labels.ts +79 -0
  98. package/src/order/order-address-card.tsx +47 -0
  99. package/src/order/order-completed-template.tsx +165 -0
  100. package/src/order/order-confirmation-header.tsx +65 -0
  101. package/src/order/order-delivery-card.tsx +258 -0
  102. package/src/order/order-help-section.tsx +47 -0
  103. package/src/order/order-item.tsx +201 -0
  104. package/src/order/order-items-list.tsx +52 -0
  105. package/src/order/order-payment-card.tsx +95 -0
  106. package/src/order/order-timeline.tsx +141 -0
  107. package/src/order/order-totals.tsx +245 -0
  108. package/src/primitives/field.tsx +125 -0
  109. package/src/primitives/select-field.tsx +77 -0
  110. package/src/primitives/ui/accordion.tsx +61 -0
  111. package/src/primitives/ui/button.tsx +68 -0
  112. package/src/primitives/ui/collapsible.tsx +16 -0
  113. package/src/primitives/ui/dialog.tsx +112 -0
  114. package/src/primitives/ui/input.tsx +30 -0
  115. package/src/primitives/ui/label.tsx +31 -0
  116. package/src/primitives/ui/popover.tsx +38 -0
  117. package/src/primitives/ui/select.tsx +163 -0
  118. package/src/primitives/ui/sheet.tsx +131 -0
  119. package/src/primitives/ui/tabs.tsx +62 -0
  120. package/src/products/context.tsx +34 -0
  121. package/src/products/image-gallery.tsx +43 -0
  122. package/src/products/index.ts +44 -0
  123. package/src/products/labels-bg.ts +35 -0
  124. package/src/products/labels.ts +57 -0
  125. package/src/products/mobile-actions.tsx +180 -0
  126. package/src/products/option-select.tsx +67 -0
  127. package/src/products/preview-price.tsx +36 -0
  128. package/src/products/product-actions-wrapper.tsx +58 -0
  129. package/src/products/product-actions.tsx +217 -0
  130. package/src/products/product-info.tsx +43 -0
  131. package/src/products/product-preview.tsx +49 -0
  132. package/src/products/product-price.tsx +69 -0
  133. package/src/products/product-tabs.tsx +169 -0
  134. package/src/products/product-template.tsx +114 -0
  135. package/src/products/purchase-options.tsx +130 -0
  136. package/src/products/related-products.tsx +86 -0
  137. package/src/products/thumbnail.tsx +71 -0
  138. package/src/products/variant-matching.ts +71 -0
  139. package/src/reviews-ui/helpers.ts +174 -0
  140. package/src/reviews-ui/index.ts +74 -0
  141. package/src/reviews-ui/labels-bg.ts +91 -0
  142. package/src/reviews-ui/labels.ts +199 -0
  143. package/src/reviews-ui/photo-upload.tsx +345 -0
  144. package/src/reviews-ui/review-list.tsx +249 -0
  145. package/src/reviews-ui/review-widget.tsx +224 -0
  146. package/src/reviews-ui/review-wizard.tsx +560 -0
  147. package/src/reviews-ui/star-badge.tsx +104 -0
  148. package/src/reviews-ui/wizard-state.ts +81 -0
  149. package/src/store/category-template.tsx +129 -0
  150. package/src/store/collection-template.tsx +139 -0
  151. package/src/store/index.ts +41 -0
  152. package/src/store/labels-bg.ts +22 -0
  153. package/src/store/labels.ts +52 -0
  154. package/src/store/paginated-products.tsx +116 -0
  155. package/src/store/pagination.tsx +103 -0
  156. package/src/store/search-params.ts +256 -0
  157. package/src/store/search-template.tsx +249 -0
  158. package/src/store/skeleton-product-grid.tsx +26 -0
  159. package/src/store/sort-select.tsx +81 -0
  160. package/src/store/store-template.tsx +65 -0
  161. package/src/tracking/attribution.ts +418 -0
  162. package/src/tracking/consent-banner.tsx +355 -0
  163. package/src/tracking/consent-init.tsx +44 -0
  164. package/src/tracking/consent.ts +243 -0
  165. package/src/tracking/fbq.ts +168 -0
  166. package/src/tracking/ga4.tsx +49 -0
  167. package/src/tracking/get-tracking-attribution.ts +224 -0
  168. package/src/tracking/get-tracking-config.ts +50 -0
  169. package/src/tracking/gtag.ts +200 -0
  170. package/src/tracking/index.ts +133 -0
  171. package/src/tracking/meta-pixel.tsx +166 -0
  172. package/src/tracking/rybbit-events.ts +242 -0
  173. package/src/tracking/rybbit.tsx +40 -0
  174. package/src/tracking/types.ts +185 -0
  175. package/src/tracking/use-engagement-time.ts +58 -0
  176. package/tailwind-preset.cjs +72 -0
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Wizard step resolution — the pure state machine behind the review token
3
+ * wizard. Ported from the Alenika /review/[token] server page's branching
4
+ * (page.tsx) so the SAME rules hold wherever the wizard mounts.
5
+ * Unit-tested in tests/unit/storefront-order-reviews.test.ts.
6
+ */
7
+ import type { TokenValidation } from "../api/reviews"
8
+
9
+ export type WizardStep = "rate" | "photo" | "done"
10
+
11
+ export type WizardEntry =
12
+ /** Token bad — show the invalid/expired panel. */
13
+ | { kind: "invalid"; reason: "not_found" | "expired" | "invalid" }
14
+ /**
15
+ * Consumed token with NO review row (deleted / never created) — the
16
+ * terminal "thanks, already done" panel. NOT resumable.
17
+ */
18
+ | { kind: "already-submitted" }
19
+ /** Render the form at `step`. */
20
+ | {
21
+ kind: "form"
22
+ step: WizardStep
23
+ reviewId: string | null
24
+ rewardCode: string | null
25
+ }
26
+
27
+ /**
28
+ * THE resume rule (production behavior, verified against barter's token
29
+ * endpoint in batch 9): a consumed token does NOT mean "done".
30
+ *
31
+ * - `valid: false` → invalid panel
32
+ * - consumed + review row + `reward_code` set → "done" (show code)
33
+ * - consumed + review row + `reward_code` null → resume at "photo"
34
+ * - consumed + NO review row → terminal thanks panel
35
+ * - not consumed → fresh "rate"
36
+ */
37
+ export function resolveWizardEntry(validation: TokenValidation): WizardEntry {
38
+ if (!validation.valid) {
39
+ return { kind: "invalid", reason: validation.reason }
40
+ }
41
+ const review = validation.already_submitted ? validation.review : null
42
+ if (validation.already_submitted && !review) {
43
+ return { kind: "already-submitted" }
44
+ }
45
+ const step: WizardStep = review?.reward_code
46
+ ? "done"
47
+ : review
48
+ ? "photo"
49
+ : "rate"
50
+ return {
51
+ kind: "form",
52
+ step,
53
+ reviewId: review?.id ?? null,
54
+ rewardCode: review?.reward_code ?? null,
55
+ }
56
+ }
57
+
58
+ /** Labels keys usable as submit-error copy. */
59
+ export type SubmitErrorKey =
60
+ | "errRateLimited"
61
+ | "errAlreadySubmitted"
62
+ | "errExpired"
63
+ | "errGeneric"
64
+
65
+ /**
66
+ * HTTP status → labels key for the submit step (POST /api/store/reviews
67
+ * error contract: 429 rate_limited · 409 conflict (token replay) · 410
68
+ * gone (expired) · anything else generic). Port of Alenika's
69
+ * `submitErrorMessage`.
70
+ */
71
+ export function submitErrorKeyFor(status: number): SubmitErrorKey {
72
+ if (status === 429) return "errRateLimited"
73
+ if (status === 409) return "errAlreadySubmitted"
74
+ if (status === 410) return "errExpired"
75
+ return "errGeneric"
76
+ }
77
+
78
+ /** Step-1 gate: a rating AND a non-blank body are both required. */
79
+ export function canSubmitRating(rating: number, body: string): boolean {
80
+ return rating >= 1 && rating <= 5 && body.trim().length > 0
81
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Category page — ported from
3
+ * `@1click/ui/src/store/category-template.tsx` (v2.3.1). Data seam:
4
+ * `HttpTypes.StoreProductCategory` → barter `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
+ * (barter 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
+ labels?: Partial<StoreLabels>
34
+ /** Override the default ProductPreview with a store-specific card. */
35
+ renderProduct?: ProductCardComponent
36
+ }
37
+
38
+ export function CategoryTemplate({
39
+ client,
40
+ category,
41
+ sortBy,
42
+ page,
43
+ pricingContext,
44
+ labels,
45
+ renderProduct,
46
+ }: CategoryTemplateProps) {
47
+ const pageNumber = page ? parseInt(page) : 1
48
+ const sort = sortBy || "created_at"
49
+
50
+ if (!category) notFound()
51
+
52
+ const parents: StoreProductCategory[] = []
53
+ const getParents = (cat: StoreProductCategory) => {
54
+ if (cat.parent_category) {
55
+ parents.push(cat.parent_category)
56
+ getParents(cat.parent_category)
57
+ }
58
+ }
59
+ getParents(category)
60
+
61
+ return (
62
+ <div
63
+ className="flex flex-col sm:flex-row sm:items-start py-6 max-w-7xl mx-auto px-4"
64
+ data-testid="category-container"
65
+ >
66
+ <div className="flex sm:flex-col gap-12 py-4 mb-8 sm:px-0 pl-6 sm:min-w-[250px]">
67
+ <SortSelect
68
+ sortBy={sort}
69
+ labels={labels}
70
+ data-testid="sort-by-container"
71
+ />
72
+ </div>
73
+ <div className="w-full">
74
+ <div className="flex flex-row mb-8 gap-4">
75
+ {parents.map((parent) => (
76
+ <span key={parent.id} className="text-muted-foreground text-2xl">
77
+ <Link
78
+ className="mr-4 hover:text-foreground"
79
+ href={`/categories/${parent.handle}`}
80
+ >
81
+ {parent.name}
82
+ </Link>
83
+ /
84
+ </span>
85
+ ))}
86
+ <h1
87
+ className="text-2xl font-semibold text-foreground"
88
+ data-testid="category-page-title"
89
+ >
90
+ {category.name}
91
+ </h1>
92
+ </div>
93
+ {category.description && (
94
+ <div className="mb-8 text-sm text-muted-foreground">
95
+ <p>{category.description}</p>
96
+ </div>
97
+ )}
98
+ {category.category_children && (
99
+ <div className="mb-8">
100
+ <ul className="grid grid-cols-1 gap-2">
101
+ {category.category_children.map((c) => (
102
+ <li key={c.id}>
103
+ <Link
104
+ href={`/categories/${c.handle}`}
105
+ className="text-primary hover:underline"
106
+ >
107
+ {c.name}
108
+ </Link>
109
+ </li>
110
+ ))}
111
+ </ul>
112
+ </div>
113
+ )}
114
+ <Suspense fallback={<SkeletonProductGrid />}>
115
+ <PaginatedProducts
116
+ client={client}
117
+ sortBy={sort}
118
+ page={pageNumber}
119
+ categoryId={category.id}
120
+ pricingContext={pricingContext}
121
+ renderProduct={renderProduct}
122
+ />
123
+ </Suspense>
124
+ </div>
125
+ </div>
126
+ )
127
+ }
128
+
129
+ export { type CategoryTemplateProps }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Collection page — ported from
3
+ * `@1click/ui/src/store/collection-template.tsx` (v2.3.1), REWIRED to the
4
+ * barter collection MEMBERSHIP listing (docs/storefront/collections.md).
5
+ *
6
+ * The original filtered the plain products listing by `collection_id`
7
+ * (Medusa primary collection). barter collections are curated groupings
8
+ * (manual or smart) with an admin-set `default_sort` — the membership
9
+ * endpoint `api/collections` `listCollectionProducts` honors it SERVER-side
10
+ * (manual position, title, EUR price asc/desc, newest/oldest, best_selling).
11
+ * So: when the shopper has NOT picked a sort, send NO `order` — the admin's
12
+ * `default_sort` applies, and nothing is re-sorted client-side. A shopper
13
+ * override maps to the `order` param (still server-side, incl. price).
14
+ *
15
+ * `SortSelect` therefore receives `sortBy` unset until the shopper picks
16
+ * (no highlight = admin default).
17
+ */
18
+ import { Suspense } from "react"
19
+ import type { StorefrontClient } from "../api/http"
20
+ import type { PricingContextQuery } from "../api/types"
21
+ import type { StoreProduct } from "../api/products"
22
+ import type { CollectionSortMode, StoreCollection } from "../api/collections"
23
+ import { listCollectionProducts } from "../api/collections"
24
+ import type { SortOptions } from "../lib/sort-products"
25
+ import type { StoreLabels } from "./labels"
26
+ import { SortSelect } from "./sort-select"
27
+ import type { ProductCardComponent } from "./paginated-products"
28
+ import { ProductPreview } from "../products/product-preview"
29
+ import { Pagination } from "./pagination"
30
+ import { SkeletonProductGrid } from "./skeleton-product-grid"
31
+
32
+ const PRODUCT_LIMIT = 12
33
+
34
+ /** Shopper sort override → membership-listing `order` param. */
35
+ const ORDER_BY_SORT: Record<SortOptions, CollectionSortMode> = {
36
+ created_at: "newest",
37
+ price_asc: "price_asc",
38
+ price_desc: "price_desc",
39
+ }
40
+
41
+ type CollectionTemplateProps = {
42
+ client: StorefrontClient
43
+ collection: StoreCollection
44
+ /** Shopper override from the `sortBy` query param; unset = admin default_sort. */
45
+ sortBy?: SortOptions
46
+ /** From the `page` query param. */
47
+ page?: string
48
+ pricingContext?: PricingContextQuery
49
+ labels?: Partial<StoreLabels>
50
+ /** Override the default ProductPreview with a store-specific card. */
51
+ renderProduct?: ProductCardComponent
52
+ }
53
+
54
+ async function CollectionProducts({
55
+ client,
56
+ collectionId,
57
+ sortBy,
58
+ page,
59
+ pricingContext,
60
+ renderProduct: Card = ProductPreview,
61
+ }: {
62
+ client: StorefrontClient
63
+ collectionId: string
64
+ sortBy?: SortOptions
65
+ page: number
66
+ pricingContext?: PricingContextQuery
67
+ renderProduct?: ProductCardComponent
68
+ }) {
69
+ const res = await listCollectionProducts(client, collectionId, {
70
+ limit: PRODUCT_LIMIT,
71
+ offset: (page - 1) * PRODUCT_LIMIT,
72
+ ...(sortBy ? { order: ORDER_BY_SORT[sortBy] } : {}),
73
+ ...pricingContext,
74
+ })
75
+
76
+ const products: StoreProduct[] = res.products
77
+ const totalPages = Math.ceil(res.count / PRODUCT_LIMIT)
78
+
79
+ return (
80
+ <>
81
+ <ul
82
+ className="grid grid-cols-2 w-full sm:grid-cols-3 md:grid-cols-4 gap-x-6 gap-y-8"
83
+ data-testid="products-list"
84
+ >
85
+ {products.map((p) => (
86
+ <li key={p.id}>
87
+ <Card product={p} />
88
+ </li>
89
+ ))}
90
+ </ul>
91
+ {totalPages > 1 && (
92
+ <Pagination
93
+ data-testid="product-pagination"
94
+ page={page}
95
+ totalPages={totalPages}
96
+ />
97
+ )}
98
+ </>
99
+ )
100
+ }
101
+
102
+ export function CollectionTemplate({
103
+ client,
104
+ collection,
105
+ sortBy,
106
+ page,
107
+ pricingContext,
108
+ labels,
109
+ renderProduct,
110
+ }: CollectionTemplateProps) {
111
+ const pageNumber = page ? parseInt(page) : 1
112
+
113
+ return (
114
+ <div className="flex flex-col sm:flex-row sm:items-start py-6 max-w-7xl mx-auto px-4">
115
+ <div className="flex sm:flex-col gap-12 py-4 mb-8 sm:px-0 pl-6 sm:min-w-[250px]">
116
+ <SortSelect sortBy={sortBy} labels={labels} />
117
+ </div>
118
+ <div className="w-full">
119
+ <div className="mb-8">
120
+ <h1 className="text-2xl font-semibold text-foreground">
121
+ {collection.title}
122
+ </h1>
123
+ </div>
124
+ <Suspense fallback={<SkeletonProductGrid />}>
125
+ <CollectionProducts
126
+ client={client}
127
+ collectionId={collection.id}
128
+ sortBy={sortBy}
129
+ page={pageNumber}
130
+ pricingContext={pricingContext}
131
+ renderProduct={renderProduct}
132
+ />
133
+ </Suspense>
134
+ </div>
135
+ </div>
136
+ )
137
+ }
138
+
139
+ export { type CollectionTemplateProps }
@@ -0,0 +1,41 @@
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
+ export { bulgarianStoreLabels } from "./labels-bg"
12
+
13
+ export { Pagination } from "./pagination"
14
+ export { SortSelect, type SortSelectProps } from "./sort-select"
15
+ export { PaginatedProducts, type ProductCardComponent } from "./paginated-products"
16
+ export { SkeletonProductGrid } from "./skeleton-product-grid"
17
+ export { StoreTemplate, type StoreTemplateProps } from "./store-template"
18
+ export {
19
+ CollectionTemplate,
20
+ type CollectionTemplateProps,
21
+ } from "./collection-template"
22
+ export {
23
+ CategoryTemplate,
24
+ type CategoryTemplateProps,
25
+ } from "./category-template"
26
+ export { SearchTemplate, type SearchTemplateProps } from "./search-template"
27
+
28
+ export {
29
+ parseSearchParams,
30
+ buildSearchQueryString,
31
+ fromQueryString,
32
+ toggleFacetSelection,
33
+ isFacetSelected,
34
+ clearFilters,
35
+ toSearchQuery,
36
+ type SearchPageState,
37
+ type RawSearchParams,
38
+ type FacetBucketRef,
39
+ } from "./search-params"
40
+
41
+ export { type SortOptions } from "../lib/sort-products"
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Bulgarian store labels — ported from `@1click/ui/src/store/labels-bg.ts`
3
+ * (v2.3.1), extended with the search-template keys. Typed against the full
4
+ * `StoreLabels` shape so a missing translation is a compile error.
5
+ */
6
+ import type { StoreLabels } from "./labels"
7
+
8
+ export const bulgarianStoreLabels: StoreLabels = {
9
+ allProducts: "Всички продукти",
10
+ sortBy: "Сортирай по",
11
+ latestArrivals: "Най-нови",
12
+ priceLowToHigh: "Цена: ниска → висока",
13
+ priceHighToLow: "Цена: висока → ниска",
14
+ searchTitle: "Търсене",
15
+ searchPlaceholder: "Търсете продукти…",
16
+ searchAction: "Търси",
17
+ resultsFor: "Резултати за",
18
+ resultsCount: "резултата",
19
+ noResults: "Няма намерени продукти",
20
+ filters: "Филтри",
21
+ clearFilters: "Изчисти филтрите",
22
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Store-family label map — ported from `@1click/ui/src/store/labels.ts`
3
+ * (v2.3.1), EXTENDED with the search-template keys (search-template is new
4
+ * in barter — no @1click source). i18n discipline: components merge
5
+ * `labels` props over these defaults; `labels-bg.ts` must cover every key
6
+ * (typed against the full shape — completeness is unit-tested).
7
+ */
8
+ import type { SortOptions } from "../lib/sort-products"
9
+
10
+ export type StoreLabels = {
11
+ allProducts: string
12
+ sortBy: string
13
+ latestArrivals: string
14
+ priceLowToHigh: string
15
+ priceHighToLow: string
16
+ // search-template (barter addition)
17
+ searchTitle: string
18
+ searchPlaceholder: string
19
+ searchAction: string
20
+ resultsFor: string
21
+ resultsCount: string
22
+ noResults: string
23
+ filters: string
24
+ clearFilters: string
25
+ }
26
+
27
+ export const defaultStoreLabels: StoreLabels = {
28
+ allProducts: "All products",
29
+ sortBy: "Sort by",
30
+ latestArrivals: "Latest Arrivals",
31
+ priceLowToHigh: "Price: Low → High",
32
+ priceHighToLow: "Price: High → Low",
33
+ searchTitle: "Search",
34
+ searchPlaceholder: "Search products…",
35
+ searchAction: "Search",
36
+ resultsFor: "Results for",
37
+ resultsCount: "results",
38
+ noResults: "No products found",
39
+ filters: "Filters",
40
+ clearFilters: "Clear filters",
41
+ }
42
+
43
+ /**
44
+ * Sort mode → label key. THE map `SortSelect` renders from — kept here
45
+ * (pure module) so completeness against `SortOptions` is unit-testable
46
+ * without importing React components.
47
+ */
48
+ export const sortOptionLabelKeys: Record<SortOptions, keyof StoreLabels> = {
49
+ created_at: "latestArrivals",
50
+ price_asc: "priceLowToHigh",
51
+ price_desc: "priceHighToLow",
52
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Paginated product grid — ported from
3
+ * `@1click/ui/src/store/paginated-products.tsx` (v2.3.1). Data seam: the
4
+ * Medusa `listProductsWithSort` server action (region-resolved via
5
+ * `countryCode`) becomes `api/products` `listProducts` with the barter
6
+ * pricing context (`currency_code`/`region_id`).
7
+ *
8
+ * Sorting (server truth first):
9
+ * - `created_at` (default) → server `order: "-created_at"` with real
10
+ * limit/offset pagination.
11
+ * - `price_asc`/`price_desc` → price is NOT a products-table column, so the
12
+ * @1click production approach is preserved: fetch the sort window
13
+ * (100 items, the `listProductsWithSort` cap), re-sort client-side on the
14
+ * server-computed `calculated_price` (`lib/sort-products`), slice the
15
+ * page. Catalogs past 100 items should sort via the collection membership
16
+ * listing (collection-template), where price sorting IS server-side.
17
+ *
18
+ * Async server component — render inside `<Suspense>`.
19
+ */
20
+ import type { ComponentType } from "react"
21
+ import type { StorefrontClient } from "../api/http"
22
+ import type { PricingContextQuery } from "../api/types"
23
+ import type { ListProductsQuery, StoreProduct } from "../api/products"
24
+ import { listProducts } from "../api/products"
25
+ import { sortProducts, type SortOptions } from "../lib/sort-products"
26
+ import { ProductPreview } from "../products/product-preview"
27
+ import { Pagination } from "./pagination"
28
+
29
+ /** A component that renders a single product card. */
30
+ export type ProductCardComponent = ComponentType<{
31
+ product: StoreProduct
32
+ }>
33
+
34
+ const PRODUCT_LIMIT = 12
35
+ /** The @1click client-sort window (listProductsWithSort cap). */
36
+ const SORT_WINDOW = 100
37
+
38
+ export async function PaginatedProducts({
39
+ client,
40
+ sortBy,
41
+ page,
42
+ collectionId,
43
+ categoryId,
44
+ productsIds,
45
+ pricingContext,
46
+ renderProduct: Card = ProductPreview,
47
+ }: {
48
+ client: StorefrontClient
49
+ sortBy?: SortOptions
50
+ page: number
51
+ /** Filters by PRIMARY collection (`products.collection_id`) — for the
52
+ * membership join use collection-template / `listCollectionProducts`. */
53
+ collectionId?: string
54
+ categoryId?: string
55
+ productsIds?: string[]
56
+ /** Pricing context so cards carry `calculated_price`. */
57
+ pricingContext?: PricingContextQuery
58
+ /** Override the default ProductPreview with a store-specific card. */
59
+ renderProduct?: ProductCardComponent
60
+ }) {
61
+ const queryParams: ListProductsQuery = {
62
+ limit: PRODUCT_LIMIT,
63
+ ...pricingContext,
64
+ }
65
+
66
+ if (collectionId) queryParams.collection_id = [collectionId]
67
+ if (categoryId) queryParams.category_id = [categoryId]
68
+ if (productsIds) queryParams.id = productsIds
69
+
70
+ let products: StoreProduct[]
71
+ let count: number
72
+
73
+ if (sortBy === "price_asc" || sortBy === "price_desc") {
74
+ const res = await listProducts(client, {
75
+ ...queryParams,
76
+ limit: SORT_WINDOW,
77
+ offset: 0,
78
+ })
79
+ const sorted = sortProducts(res.products, sortBy)
80
+ const offset = (page - 1) * PRODUCT_LIMIT
81
+ products = sorted.slice(offset, offset + PRODUCT_LIMIT)
82
+ count = res.count
83
+ } else {
84
+ const res = await listProducts(client, {
85
+ ...queryParams,
86
+ order: "-created_at",
87
+ offset: (page - 1) * PRODUCT_LIMIT,
88
+ })
89
+ products = res.products
90
+ count = res.count
91
+ }
92
+
93
+ const totalPages = Math.ceil(count / PRODUCT_LIMIT)
94
+
95
+ return (
96
+ <>
97
+ <ul
98
+ className="grid grid-cols-2 w-full sm:grid-cols-3 md:grid-cols-4 gap-x-6 gap-y-8"
99
+ data-testid="products-list"
100
+ >
101
+ {products.map((p) => (
102
+ <li key={p.id}>
103
+ <Card product={p} />
104
+ </li>
105
+ ))}
106
+ </ul>
107
+ {totalPages > 1 && (
108
+ <Pagination
109
+ data-testid="product-pagination"
110
+ page={page}
111
+ totalPages={totalPages}
112
+ />
113
+ )}
114
+ </>
115
+ )
116
+ }
@@ -0,0 +1,103 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Page-number pagination — ported from `@1click/ui/src/store/pagination.tsx`
5
+ * (v2.3.1) unchanged. Writes the `page` query param and pushes the route;
6
+ * the server templates read it back and convert to `offset`. Windowed
7
+ * layout: ≤7 pages inline, otherwise ellipsis around the current window.
8
+ */
9
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
10
+ import { cn } from "../lib/utils"
11
+
12
+ export function Pagination({
13
+ page,
14
+ totalPages,
15
+ "data-testid": dataTestid,
16
+ }: {
17
+ page: number
18
+ totalPages: number
19
+ "data-testid"?: string
20
+ }) {
21
+ const router = useRouter()
22
+ const pathname = usePathname()
23
+ const searchParams = useSearchParams()
24
+
25
+ const arrayRange = (start: number, stop: number) =>
26
+ Array.from({ length: stop - start + 1 }, (_, index) => start + index)
27
+
28
+ const handlePageChange = (newPage: number) => {
29
+ const params = new URLSearchParams(searchParams)
30
+ params.set("page", newPage.toString())
31
+ router.push(`${pathname}?${params.toString()}`)
32
+ }
33
+
34
+ const renderPageButton = (
35
+ p: number,
36
+ label: string | number,
37
+ isCurrent: boolean
38
+ ) => (
39
+ <button
40
+ key={p}
41
+ className={cn("text-xl text-muted-foreground", {
42
+ "text-foreground font-semibold": isCurrent,
43
+ "hover:text-muted-foreground": !isCurrent,
44
+ })}
45
+ disabled={isCurrent}
46
+ onClick={() => handlePageChange(p)}
47
+ >
48
+ {label}
49
+ </button>
50
+ )
51
+
52
+ const renderEllipsis = (key: string) => (
53
+ <span key={key} className="text-xl text-muted-foreground cursor-default">
54
+ ...
55
+ </span>
56
+ )
57
+
58
+ const renderPageButtons = () => {
59
+ const buttons: React.ReactNode[] = []
60
+
61
+ if (totalPages <= 7) {
62
+ buttons.push(
63
+ ...arrayRange(1, totalPages).map((p) =>
64
+ renderPageButton(p, p, p === page)
65
+ )
66
+ )
67
+ } else if (page <= 4) {
68
+ buttons.push(
69
+ ...arrayRange(1, 5).map((p) => renderPageButton(p, p, p === page))
70
+ )
71
+ buttons.push(renderEllipsis("e1"))
72
+ buttons.push(renderPageButton(totalPages, totalPages, false))
73
+ } else if (page >= totalPages - 3) {
74
+ buttons.push(renderPageButton(1, 1, false))
75
+ buttons.push(renderEllipsis("e2"))
76
+ buttons.push(
77
+ ...arrayRange(totalPages - 4, totalPages).map((p) =>
78
+ renderPageButton(p, p, p === page)
79
+ )
80
+ )
81
+ } else {
82
+ buttons.push(renderPageButton(1, 1, false))
83
+ buttons.push(renderEllipsis("e3"))
84
+ buttons.push(
85
+ ...arrayRange(page - 1, page + 1).map((p) =>
86
+ renderPageButton(p, p, p === page)
87
+ )
88
+ )
89
+ buttons.push(renderEllipsis("e4"))
90
+ buttons.push(renderPageButton(totalPages, totalPages, false))
91
+ }
92
+
93
+ return buttons
94
+ }
95
+
96
+ return (
97
+ <div className="flex justify-center w-full mt-12">
98
+ <div className="flex gap-3 items-end" data-testid={dataTestid}>
99
+ {renderPageButtons()}
100
+ </div>
101
+ </div>
102
+ )
103
+ }