@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,256 @@
1
+ /**
2
+ * Search-template URL-state helpers — NEW in barter (no @1click source;
3
+ * pure module so the round-trip is unit-testable —
4
+ * tests/unit/storefront-catalog.test.ts).
5
+ *
6
+ * The search page keeps its whole filter state in the URL query string,
7
+ * using the SAME param names as the search endpoint's wire format
8
+ * (docs/storefront/search.md): `q`, CSV id filters (`collection_id`,
9
+ * `category_id`, `tag_id`, `type_id`), `price_min`/`price_max`,
10
+ * `availability`, dynamic `option.<Title>` CSV params — plus `page` (UI
11
+ * pagination, converted to `offset` when calling the SDK). Facet buckets
12
+ * toggle by rewriting these params (and resetting `page`).
13
+ */
14
+ import type { SearchProductsQuery } from "../api/search"
15
+
16
+ /** Parsed URL state of the search page. */
17
+ export interface SearchPageState {
18
+ q: string
19
+ /** 1-based UI page (only serialized when > 1). */
20
+ page: number
21
+ collection_id: string[]
22
+ category_id: string[]
23
+ tag_id: string[]
24
+ type_id: string[]
25
+ price_min?: number
26
+ price_max?: number
27
+ availability?: "in_stock" | "out_of_stock"
28
+ /** Option title → selected values (`option.<Title>` params). */
29
+ options: Record<string, string[]>
30
+ }
31
+
32
+ /** Raw `searchParams` shape a Next.js page receives. */
33
+ export type RawSearchParams = Record<string, string | string[] | undefined>
34
+
35
+ const CSV_KEYS = ["collection_id", "category_id", "tag_id", "type_id"] as const
36
+
37
+ function first(v: string | string[] | undefined): string | undefined {
38
+ if (Array.isArray(v)) return v[0]
39
+ return v
40
+ }
41
+
42
+ function parseCsv(v: string | string[] | undefined): string[] {
43
+ const raw = Array.isArray(v) ? v.join(",") : v
44
+ if (!raw) return []
45
+ return raw
46
+ .split(",")
47
+ .map((s) => s.trim())
48
+ .filter(Boolean)
49
+ }
50
+
51
+ function parseNumber(v: string | string[] | undefined): number | undefined {
52
+ const s = first(v)
53
+ if (s === undefined || s === "") return undefined
54
+ const n = Number(s)
55
+ return Number.isFinite(n) ? n : undefined
56
+ }
57
+
58
+ /** Parse a Next.js `searchParams` record into the canonical page state. */
59
+ export function parseSearchParams(params: RawSearchParams): SearchPageState {
60
+ const state: SearchPageState = {
61
+ q: first(params.q)?.trim() ?? "",
62
+ page: Math.max(1, Math.trunc(parseNumber(params.page) ?? 1)),
63
+ collection_id: parseCsv(params.collection_id),
64
+ category_id: parseCsv(params.category_id),
65
+ tag_id: parseCsv(params.tag_id),
66
+ type_id: parseCsv(params.type_id),
67
+ price_min: parseNumber(params.price_min),
68
+ price_max: parseNumber(params.price_max),
69
+ options: {},
70
+ }
71
+
72
+ const availability = first(params.availability)
73
+ if (availability === "in_stock" || availability === "out_of_stock") {
74
+ state.availability = availability
75
+ }
76
+
77
+ for (const [key, value] of Object.entries(params)) {
78
+ if (!key.startsWith("option.")) continue
79
+ const title = key.slice("option.".length)
80
+ if (!title) continue
81
+ const values = parseCsv(value)
82
+ if (values.length) state.options[title] = values
83
+ }
84
+
85
+ return state
86
+ }
87
+
88
+ /**
89
+ * Serialize the page state back to a query string (no leading `?`).
90
+ * Round-trip law: `parseSearchParams(fromQueryString(buildSearchQueryString(s)))`
91
+ * equals `s` for any normalized state. Empty/default fields are omitted so
92
+ * URLs stay canonical (page 1 has no `page` param).
93
+ */
94
+ export function buildSearchQueryString(state: SearchPageState): string {
95
+ const sp = new URLSearchParams()
96
+ if (state.q) sp.set("q", state.q)
97
+ for (const key of CSV_KEYS) {
98
+ const values = state[key]
99
+ if (values.length) sp.set(key, values.join(","))
100
+ }
101
+ if (state.price_min !== undefined) sp.set("price_min", String(state.price_min))
102
+ if (state.price_max !== undefined) sp.set("price_max", String(state.price_max))
103
+ if (state.availability) sp.set("availability", state.availability)
104
+ for (const [title, values] of Object.entries(state.options)) {
105
+ if (values.length) sp.set(`option.${title}`, values.join(","))
106
+ }
107
+ if (state.page > 1) sp.set("page", String(state.page))
108
+ return sp.toString()
109
+ }
110
+
111
+ /** Convenience for tests + client code: query string → raw params record. */
112
+ export function fromQueryString(qs: string): RawSearchParams {
113
+ const out: RawSearchParams = {}
114
+ for (const [key, value] of new URLSearchParams(qs)) {
115
+ out[key] = value
116
+ }
117
+ return out
118
+ }
119
+
120
+ /** The facet-bucket identity needed to toggle it (api/search shapes). */
121
+ export interface FacetBucketRef {
122
+ /** Facet key: "price" | "availability" | "type" | "tags" | "collection" | "options.<Title>" */
123
+ facetKey: string
124
+ /** Bucket `value` (id, option value, availability enum, or "min-max"). */
125
+ value: string
126
+ /** Range (price) buckets only. */
127
+ min?: number
128
+ max?: number | null
129
+ }
130
+
131
+ /** True when the bucket is currently active in the state. */
132
+ export function isFacetSelected(
133
+ state: SearchPageState,
134
+ bucket: FacetBucketRef
135
+ ): boolean {
136
+ const { facetKey, value } = bucket
137
+ switch (facetKey) {
138
+ case "collection":
139
+ return state.collection_id.includes(value)
140
+ case "type":
141
+ return state.type_id.includes(value)
142
+ case "tags":
143
+ return state.tag_id.includes(value)
144
+ case "availability":
145
+ return state.availability === value
146
+ case "price":
147
+ return (
148
+ state.price_min === (bucket.min ?? undefined) &&
149
+ state.price_max === (bucket.max ?? undefined)
150
+ )
151
+ default:
152
+ if (facetKey.startsWith("options.")) {
153
+ const title = facetKey.slice("options.".length)
154
+ return state.options[title]?.includes(value) ?? false
155
+ }
156
+ return false
157
+ }
158
+ }
159
+
160
+ function toggleInList(list: string[], value: string): string[] {
161
+ return list.includes(value)
162
+ ? list.filter((v) => v !== value)
163
+ : [...list, value]
164
+ }
165
+
166
+ /**
167
+ * Toggle a facet bucket on/off, returning the NEXT state (input untouched).
168
+ * Multi-value facets (collection/type/tags/options) toggle membership;
169
+ * single-value facets (availability, price range) select-or-clear. Every
170
+ * toggle resets `page` to 1 (the result set changed).
171
+ */
172
+ export function toggleFacetSelection(
173
+ state: SearchPageState,
174
+ bucket: FacetBucketRef
175
+ ): SearchPageState {
176
+ const next: SearchPageState = {
177
+ ...state,
178
+ collection_id: [...state.collection_id],
179
+ category_id: [...state.category_id],
180
+ tag_id: [...state.tag_id],
181
+ type_id: [...state.type_id],
182
+ options: Object.fromEntries(
183
+ Object.entries(state.options).map(([k, v]) => [k, [...v]])
184
+ ),
185
+ page: 1,
186
+ }
187
+
188
+ const { facetKey, value } = bucket
189
+ if (facetKey === "collection") {
190
+ next.collection_id = toggleInList(next.collection_id, value)
191
+ } else if (facetKey === "type") {
192
+ next.type_id = toggleInList(next.type_id, value)
193
+ } else if (facetKey === "tags") {
194
+ next.tag_id = toggleInList(next.tag_id, value)
195
+ } else if (facetKey === "availability") {
196
+ next.availability =
197
+ state.availability === value
198
+ ? undefined
199
+ : (value as SearchPageState["availability"])
200
+ } else if (facetKey === "price") {
201
+ if (isFacetSelected(state, bucket)) {
202
+ next.price_min = undefined
203
+ next.price_max = undefined
204
+ } else {
205
+ next.price_min = bucket.min ?? undefined
206
+ next.price_max = bucket.max ?? undefined
207
+ }
208
+ } else if (facetKey.startsWith("options.")) {
209
+ const title = facetKey.slice("options.".length)
210
+ const values = toggleInList(next.options[title] ?? [], value)
211
+ if (values.length) {
212
+ next.options[title] = values
213
+ } else {
214
+ delete next.options[title]
215
+ }
216
+ }
217
+
218
+ return next
219
+ }
220
+
221
+ /** State with every filter cleared (query + page reset kept minimal). */
222
+ export function clearFilters(state: SearchPageState): SearchPageState {
223
+ return {
224
+ q: state.q,
225
+ page: 1,
226
+ collection_id: [],
227
+ category_id: [],
228
+ tag_id: [],
229
+ type_id: [],
230
+ options: {},
231
+ }
232
+ }
233
+
234
+ /**
235
+ * Map the page state to the SDK query (`api/search` `searchProducts`).
236
+ * `page` becomes `offset`; empty filters are omitted.
237
+ */
238
+ export function toSearchQuery(
239
+ state: SearchPageState,
240
+ limit: number
241
+ ): SearchProductsQuery {
242
+ const query: SearchProductsQuery = {
243
+ q: state.q,
244
+ limit,
245
+ offset: (state.page - 1) * limit,
246
+ }
247
+ if (state.collection_id.length) query.collection_id = state.collection_id
248
+ if (state.category_id.length) query.category_id = state.category_id
249
+ if (state.tag_id.length) query.tag_id = state.tag_id
250
+ if (state.type_id.length) query.type_id = state.type_id
251
+ if (state.price_min !== undefined) query.price_min = state.price_min
252
+ if (state.price_max !== undefined) query.price_max = state.price_max
253
+ if (state.availability) query.availability = state.availability
254
+ if (Object.keys(state.options).length) query.options = state.options
255
+ return query
256
+ }
@@ -0,0 +1,249 @@
1
+ /**
2
+ * Search results page — NEW in barter (no @1click source; the Medusa stores
3
+ * had no search endpoint). Built in the store-template idiom: filter
4
+ * sidebar, heading, product grid, pagination — over `api/search`
5
+ * `searchProducts` (docs/storefront/search.md).
6
+ *
7
+ * Everything is URL state (see ./search-params): the query box is a plain
8
+ * GET form and every facet bucket is a link that toggles its param — the
9
+ * whole page works server-rendered with zero client JS of its own (only
10
+ * `Pagination` is a client component). Facets arrive from the response
11
+ * (`facets[]` per the tenant's Settings → Search & discovery config):
12
+ * value buckets carry ids/option values, the price facet carries range
13
+ * buckets applied as `price_min`/`price_max`. Bucket counts follow the
14
+ * standard faceting rule (own dimension excluded), so selecting a value
15
+ * never zeroes its siblings.
16
+ *
17
+ * Async server component — give it the page's raw `searchParams`.
18
+ */
19
+ import Link from "next/link"
20
+ import type { StorefrontClient } from "../api/http"
21
+ import type { PricingContextQuery } from "../api/types"
22
+ import type { SearchFacet, SearchProductsResponse } from "../api/search"
23
+ import { searchProducts } from "../api/search"
24
+ import { cn } from "../lib/utils"
25
+ import { Button } from "../primitives/ui/button"
26
+ import { ProductPreview } from "../products/product-preview"
27
+ import { defaultStoreLabels, type StoreLabels } from "./labels"
28
+ import type { ProductCardComponent } from "./paginated-products"
29
+ import { Pagination } from "./pagination"
30
+ import {
31
+ buildSearchQueryString,
32
+ clearFilters,
33
+ isFacetSelected,
34
+ parseSearchParams,
35
+ toggleFacetSelection,
36
+ toSearchQuery,
37
+ type RawSearchParams,
38
+ type SearchPageState,
39
+ } from "./search-params"
40
+
41
+ const DEFAULT_LIMIT = 20
42
+
43
+ type SearchTemplateProps = {
44
+ client: StorefrontClient
45
+ /** The page's raw `searchParams` (q, filters, page — see search-params). */
46
+ searchParams: RawSearchParams
47
+ /** Route the form + facet links target. Default `/search`. */
48
+ basePath?: string
49
+ /** Pricing context so result cards carry `calculated_price`. */
50
+ pricingContext?: PricingContextQuery
51
+ /** Page size, 1–100 (server default 20). */
52
+ limit?: number
53
+ labels?: Partial<StoreLabels>
54
+ /** Override the default ProductPreview with a store-specific card. */
55
+ renderProduct?: ProductCardComponent
56
+ }
57
+
58
+ export async function SearchTemplate({
59
+ client,
60
+ searchParams,
61
+ basePath = "/search",
62
+ pricingContext,
63
+ limit = DEFAULT_LIMIT,
64
+ labels,
65
+ renderProduct: Card = ProductPreview,
66
+ }: SearchTemplateProps) {
67
+ const l = { ...defaultStoreLabels, ...labels }
68
+ const state = parseSearchParams(searchParams)
69
+
70
+ let response: SearchProductsResponse | null = null
71
+ if (state.q) {
72
+ response = await searchProducts(client, {
73
+ ...toSearchQuery(state, limit),
74
+ ...pricingContext,
75
+ })
76
+ }
77
+
78
+ const hasFilters =
79
+ state.collection_id.length > 0 ||
80
+ state.category_id.length > 0 ||
81
+ state.tag_id.length > 0 ||
82
+ state.type_id.length > 0 ||
83
+ state.price_min !== undefined ||
84
+ state.price_max !== undefined ||
85
+ state.availability !== undefined ||
86
+ Object.keys(state.options).length > 0
87
+
88
+ const totalPages = response ? Math.ceil(response.total / limit) : 0
89
+
90
+ return (
91
+ <div
92
+ className="flex flex-col sm:flex-row sm:items-start py-6 max-w-7xl mx-auto px-4"
93
+ data-testid="search-container"
94
+ >
95
+ <div className="flex flex-col gap-8 py-4 mb-8 sm:px-0 pl-6 sm:min-w-[250px] sm:max-w-[250px]">
96
+ <form method="get" action={basePath} className="flex flex-col gap-y-2">
97
+ <label
98
+ htmlFor="search-q"
99
+ className="text-sm font-medium text-muted-foreground"
100
+ >
101
+ {l.searchTitle}
102
+ </label>
103
+ <input
104
+ id="search-q"
105
+ type="search"
106
+ name="q"
107
+ defaultValue={state.q}
108
+ placeholder={l.searchPlaceholder}
109
+ className="h-10 rounded-lg border border-border bg-card px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-primary"
110
+ data-testid="search-input"
111
+ />
112
+ <Button type="submit" className="h-10" data-testid="search-button">
113
+ {l.searchAction}
114
+ </Button>
115
+ </form>
116
+
117
+ {response && response.facets.length > 0 && (
118
+ <div className="flex flex-col gap-y-6" data-testid="search-facets">
119
+ <div className="flex items-center justify-between">
120
+ <span className="text-sm font-medium text-muted-foreground">
121
+ {l.filters}
122
+ </span>
123
+ {hasFilters && (
124
+ <Link
125
+ href={`${basePath}?${buildSearchQueryString(clearFilters(state))}`}
126
+ className="text-sm text-primary hover:underline"
127
+ data-testid="clear-filters"
128
+ >
129
+ {l.clearFilters}
130
+ </Link>
131
+ )}
132
+ </div>
133
+ {response.facets.map((facet) => (
134
+ <FacetGroup
135
+ key={facet.key}
136
+ facet={facet}
137
+ state={state}
138
+ basePath={basePath}
139
+ />
140
+ ))}
141
+ </div>
142
+ )}
143
+ </div>
144
+
145
+ <div className="w-full">
146
+ <div className="mb-8">
147
+ {state.q ? (
148
+ <>
149
+ <h1
150
+ className="text-2xl font-semibold text-foreground"
151
+ data-testid="search-page-title"
152
+ >
153
+ {l.resultsFor} “{state.q}”
154
+ </h1>
155
+ {response && (
156
+ <p className="text-sm text-muted-foreground mt-1">
157
+ {response.total} {l.resultsCount}
158
+ </p>
159
+ )}
160
+ </>
161
+ ) : (
162
+ <h1 className="text-2xl font-semibold text-foreground">
163
+ {l.searchTitle}
164
+ </h1>
165
+ )}
166
+ </div>
167
+
168
+ {response && response.results.length === 0 && (
169
+ <p className="text-muted-foreground" data-testid="no-results">
170
+ {l.noResults}
171
+ </p>
172
+ )}
173
+
174
+ {response && response.results.length > 0 && (
175
+ <>
176
+ <ul
177
+ className="grid grid-cols-2 w-full sm:grid-cols-3 md:grid-cols-4 gap-x-6 gap-y-8"
178
+ data-testid="search-results"
179
+ >
180
+ {response.results.map((p) => (
181
+ <li key={p.id}>
182
+ <Card product={p} />
183
+ </li>
184
+ ))}
185
+ </ul>
186
+ {totalPages > 1 && (
187
+ <Pagination
188
+ data-testid="search-pagination"
189
+ page={state.page}
190
+ totalPages={totalPages}
191
+ />
192
+ )}
193
+ </>
194
+ )}
195
+ </div>
196
+ </div>
197
+ )
198
+ }
199
+
200
+ function FacetGroup({
201
+ facet,
202
+ state,
203
+ basePath,
204
+ }: {
205
+ facet: SearchFacet
206
+ state: SearchPageState
207
+ basePath: string
208
+ }) {
209
+ return (
210
+ <div className="flex flex-col gap-y-2">
211
+ <span className="text-sm font-medium text-muted-foreground">
212
+ {facet.label}
213
+ </span>
214
+ <ul className="flex flex-col gap-y-1">
215
+ {facet.buckets.map((bucket) => {
216
+ const ref = {
217
+ facetKey: facet.key,
218
+ value: bucket.value,
219
+ min: bucket.min,
220
+ max: bucket.max,
221
+ }
222
+ const selected = isFacetSelected(state, ref)
223
+ const href = `${basePath}?${buildSearchQueryString(
224
+ toggleFacetSelection(state, ref)
225
+ )}`
226
+ return (
227
+ <li key={bucket.value}>
228
+ <Link
229
+ href={href}
230
+ className={cn(
231
+ "flex items-center justify-between text-sm px-2 py-1.5 rounded-md transition-colors",
232
+ selected
233
+ ? "text-foreground font-medium bg-muted"
234
+ : "text-muted-foreground hover:text-foreground hover:bg-muted"
235
+ )}
236
+ data-testid="facet-bucket"
237
+ >
238
+ <span>{bucket.label}</span>
239
+ <span className="text-muted-foreground">{bucket.count}</span>
240
+ </Link>
241
+ </li>
242
+ )
243
+ })}
244
+ </ul>
245
+ </div>
246
+ )
247
+ }
248
+
249
+ export { type SearchTemplateProps }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Loading skeleton for product grids — ported from
3
+ * `@1click/ui/src/store/skeleton-product-grid.tsx` (v2.3.1) unchanged.
4
+ * Server-safe; use as the `<Suspense>` fallback for the async listings.
5
+ */
6
+ export function SkeletonProductGrid({
7
+ numberOfProducts = 8,
8
+ }: {
9
+ numberOfProducts?: number
10
+ }) {
11
+ return (
12
+ <ul className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-x-6 gap-y-8">
13
+ {Array.from({ length: numberOfProducts }).map((_, i) => (
14
+ <li key={i}>
15
+ <div className="animate-pulse">
16
+ <div className="aspect-[9/16] w-full bg-muted rounded-lg" />
17
+ <div className="flex justify-between mt-4">
18
+ <div className="h-4 w-24 bg-muted rounded" />
19
+ <div className="h-4 w-16 bg-muted rounded" />
20
+ </div>
21
+ </div>
22
+ </li>
23
+ ))}
24
+ </ul>
25
+ )
26
+ }
@@ -0,0 +1,81 @@
1
+ "use client"
2
+
3
+ /**
4
+ * Listing sort selector — ported from
5
+ * `@1click/ui/src/store/sort-select.tsx` (v2.3.1). Writes the `sortBy`
6
+ * query param; the server templates map it to the API's `order` param
7
+ * (server truth for pagination — see paginated-products.tsx).
8
+ *
9
+ * Port adaptations: `sortBy` is now OPTIONAL — on collection pages no
10
+ * selection means "the collection's admin `default_sort`", so nothing is
11
+ * highlighted until the shopper picks an override. The option list renders
12
+ * from `sortOptionLabelKeys` (labels.ts, pure) so map completeness against
13
+ * `SortOptions` is unit-tested.
14
+ */
15
+ import { usePathname, useRouter, useSearchParams } from "next/navigation"
16
+ import { useCallback } from "react"
17
+ import { cn } from "../lib/utils"
18
+ import type { SortOptions } from "../lib/sort-products"
19
+ import { defaultStoreLabels, sortOptionLabelKeys, type StoreLabels } from "./labels"
20
+
21
+ type SortSelectProps = {
22
+ /** Current selection; undefined = server default (no highlight). */
23
+ sortBy?: SortOptions
24
+ labels?: Partial<
25
+ Pick<StoreLabels, "sortBy" | "latestArrivals" | "priceLowToHigh" | "priceHighToLow">
26
+ >
27
+ "data-testid"?: string
28
+ }
29
+
30
+ export function SortSelect({
31
+ sortBy,
32
+ labels,
33
+ "data-testid": dataTestId,
34
+ }: SortSelectProps) {
35
+ const l = { ...defaultStoreLabels, ...labels }
36
+ const router = useRouter()
37
+ const pathname = usePathname()
38
+ const searchParams = useSearchParams()
39
+
40
+ const sortOptions = (
41
+ Object.keys(sortOptionLabelKeys) as SortOptions[]
42
+ ).map((value) => ({ value, label: l[sortOptionLabelKeys[value]] }))
43
+
44
+ const createQueryString = useCallback(
45
+ (name: string, value: string) => {
46
+ const params = new URLSearchParams(searchParams)
47
+ params.set(name, value)
48
+ return params.toString()
49
+ },
50
+ [searchParams]
51
+ )
52
+
53
+ const handleChange = (value: SortOptions) => {
54
+ const query = createQueryString("sortBy", value)
55
+ router.push(`${pathname}?${query}`)
56
+ }
57
+
58
+ return (
59
+ <div className="flex flex-col gap-y-3" data-testid={dataTestId}>
60
+ <span className="text-sm font-medium text-muted-foreground">{l.sortBy}</span>
61
+ <div className="flex flex-col gap-y-1">
62
+ {sortOptions.map((option) => (
63
+ <button
64
+ key={option.value}
65
+ onClick={() => handleChange(option.value)}
66
+ className={cn(
67
+ "text-sm text-left px-2 py-1.5 rounded-md transition-colors",
68
+ option.value === sortBy
69
+ ? "text-foreground font-medium bg-muted"
70
+ : "text-muted-foreground hover:text-foreground hover:bg-muted"
71
+ )}
72
+ >
73
+ {option.label}
74
+ </button>
75
+ ))}
76
+ </div>
77
+ </div>
78
+ )
79
+ }
80
+
81
+ export { type SortSelectProps }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * "/store" all-products page — ported from
3
+ * `@1click/ui/src/store/store-template.tsx` (v2.3.1). Data seam:
4
+ * `countryCode` → `client` + `pricingContext` (see paginated-products.tsx).
5
+ * Layout — sort sidebar, heading, suspended grid — unchanged.
6
+ */
7
+ import { Suspense } from "react"
8
+ import type { StorefrontClient } from "../api/http"
9
+ import type { PricingContextQuery } from "../api/types"
10
+ import type { SortOptions } from "../lib/sort-products"
11
+ import { defaultStoreLabels, type StoreLabels } from "./labels"
12
+ import { SortSelect } from "./sort-select"
13
+ import { PaginatedProducts, type ProductCardComponent } from "./paginated-products"
14
+ import { SkeletonProductGrid } from "./skeleton-product-grid"
15
+
16
+ type StoreTemplateProps = {
17
+ client: StorefrontClient
18
+ /** From the `sortBy` query param. */
19
+ sortBy?: SortOptions
20
+ /** From the `page` query param. */
21
+ page?: string
22
+ pricingContext?: PricingContextQuery
23
+ labels?: Partial<StoreLabels>
24
+ /** Override the default ProductPreview with a store-specific card. */
25
+ renderProduct?: ProductCardComponent
26
+ }
27
+
28
+ export function StoreTemplate({
29
+ client,
30
+ sortBy,
31
+ page,
32
+ pricingContext,
33
+ labels,
34
+ renderProduct,
35
+ }: StoreTemplateProps) {
36
+ const l = { ...defaultStoreLabels, ...labels }
37
+ const pageNumber = page ? parseInt(page) : 1
38
+ const sort = sortBy || "created_at"
39
+
40
+ return (
41
+ <div className="flex flex-col sm:flex-row sm:items-start py-6 max-w-7xl mx-auto px-4">
42
+ <div className="flex sm:flex-col gap-12 py-4 mb-8 sm:px-0 pl-6 sm:min-w-[250px]">
43
+ <SortSelect sortBy={sort} labels={l} />
44
+ </div>
45
+ <div className="w-full">
46
+ <div className="mb-8">
47
+ <h1 className="text-2xl font-semibold text-foreground">
48
+ {l.allProducts}
49
+ </h1>
50
+ </div>
51
+ <Suspense fallback={<SkeletonProductGrid />}>
52
+ <PaginatedProducts
53
+ client={client}
54
+ sortBy={sort}
55
+ page={pageNumber}
56
+ pricingContext={pricingContext}
57
+ renderProduct={renderProduct}
58
+ />
59
+ </Suspense>
60
+ </div>
61
+ </div>
62
+ )
63
+ }
64
+
65
+ export { type StoreTemplateProps }