@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.
- package/package.json +226 -0
- package/src/api/auth.ts +108 -0
- package/src/api/carts.ts +506 -0
- package/src/api/categories.ts +184 -0
- package/src/api/checkout.ts +440 -0
- package/src/api/collections.ts +130 -0
- package/src/api/consent.ts +75 -0
- package/src/api/content.ts +125 -0
- package/src/api/customers.ts +307 -0
- package/src/api/gift-cards.ts +112 -0
- package/src/api/http.ts +122 -0
- package/src/api/index.ts +29 -0
- package/src/api/integrations.ts +130 -0
- package/src/api/menus.ts +77 -0
- package/src/api/metaobjects.ts +136 -0
- package/src/api/orders.ts +290 -0
- package/src/api/products.ts +303 -0
- package/src/api/redirects.ts +37 -0
- package/src/api/regions.ts +141 -0
- package/src/api/reviews.ts +259 -0
- package/src/api/search.ts +133 -0
- package/src/api/types.ts +91 -0
- package/src/cart-drawer/cart-drawer.tsx +86 -0
- package/src/cart-drawer/context.tsx +569 -0
- package/src/cart-drawer/continue-shopping.tsx +28 -0
- package/src/cart-drawer/cross-sell-carousel.tsx +212 -0
- package/src/cart-drawer/cross-sell-sidebar.tsx +158 -0
- package/src/cart-drawer/empty.tsx +52 -0
- package/src/cart-drawer/free-gift.tsx +71 -0
- package/src/cart-drawer/gift-wrap.tsx +83 -0
- package/src/cart-drawer/header.tsx +52 -0
- package/src/cart-drawer/index.ts +69 -0
- package/src/cart-drawer/item/index.tsx +164 -0
- package/src/cart-drawer/item/quantity.tsx +100 -0
- package/src/cart-drawer/item/upsell.tsx +110 -0
- package/src/cart-drawer/item/variant.tsx +46 -0
- package/src/cart-drawer/labels-bg.ts +72 -0
- package/src/cart-drawer/labels.ts +119 -0
- package/src/cart-drawer/notes.tsx +131 -0
- package/src/cart-drawer/payment-badges.tsx +96 -0
- package/src/cart-drawer/promo-banner.tsx +43 -0
- package/src/cart-drawer/rewards-points.tsx +78 -0
- package/src/cart-drawer/sticky-footer.tsx +73 -0
- package/src/cart-drawer/summary-breakdown.tsx +196 -0
- package/src/cart-drawer/template.tsx +225 -0
- package/src/cart-drawer/tiered-progress.tsx +168 -0
- package/src/checkout/address-error-copy.ts +119 -0
- package/src/checkout/address-form.tsx +224 -0
- package/src/checkout/address-select.tsx +79 -0
- package/src/checkout/boxnow-locker-selector.tsx +410 -0
- package/src/checkout/checkout-client.tsx +222 -0
- package/src/checkout/company-details.tsx +94 -0
- package/src/checkout/compare-addresses.ts +40 -0
- package/src/checkout/context.tsx +76 -0
- package/src/checkout/discount-section.tsx +218 -0
- package/src/checkout/econt-office-selector.tsx +332 -0
- package/src/checkout/error-message.tsx +25 -0
- package/src/checkout/geocode.ts +154 -0
- package/src/checkout/gift-card-section.tsx +224 -0
- package/src/checkout/index.ts +74 -0
- package/src/checkout/labels-bg.ts +128 -0
- package/src/checkout/labels.ts +263 -0
- package/src/checkout/line-item-card.tsx +152 -0
- package/src/checkout/order-summary.tsx +524 -0
- package/src/checkout/payment-button.tsx +373 -0
- package/src/checkout/payment-error-copy.ts +269 -0
- package/src/checkout/payment-method-list.tsx +365 -0
- package/src/checkout/payment-wrapper.tsx +102 -0
- package/src/checkout/promotion-error-copy.ts +124 -0
- package/src/checkout/shipping-method-list.tsx +335 -0
- package/src/checkout/stripe-wrapper.tsx +165 -0
- package/src/checkout/use-checkout-orchestration.ts +1504 -0
- package/src/common/cart-button-client.tsx +39 -0
- package/src/common/cart-button.tsx +28 -0
- package/src/common/country-select.tsx +65 -0
- package/src/common/delete-button.tsx +66 -0
- package/src/common/index.ts +17 -0
- package/src/common/language-select.tsx +78 -0
- package/src/common/localized-link.tsx +45 -0
- package/src/common/skeleton.tsx +29 -0
- package/src/index.ts +12 -0
- package/src/lib/cart-helpers.ts +113 -0
- package/src/lib/dual-price.tsx +73 -0
- package/src/lib/get-percentage-diff.ts +5 -0
- package/src/lib/get-product-price.ts +133 -0
- package/src/lib/hooks/use-intersection.ts +30 -0
- package/src/lib/hooks/use-toggle-state.ts +25 -0
- package/src/lib/money.ts +73 -0
- package/src/lib/payment-constants.ts +66 -0
- package/src/lib/product.ts +22 -0
- package/src/lib/sort-products.ts +63 -0
- package/src/lib/store-api-error.ts +36 -0
- package/src/lib/utils.ts +16 -0
- package/src/order/context.tsx +32 -0
- package/src/order/index.ts +63 -0
- package/src/order/labels-bg.ts +39 -0
- package/src/order/labels.ts +79 -0
- package/src/order/order-address-card.tsx +47 -0
- package/src/order/order-completed-template.tsx +165 -0
- package/src/order/order-confirmation-header.tsx +65 -0
- package/src/order/order-delivery-card.tsx +258 -0
- package/src/order/order-help-section.tsx +47 -0
- package/src/order/order-item.tsx +201 -0
- package/src/order/order-items-list.tsx +52 -0
- package/src/order/order-payment-card.tsx +95 -0
- package/src/order/order-timeline.tsx +141 -0
- package/src/order/order-totals.tsx +245 -0
- package/src/primitives/field.tsx +125 -0
- package/src/primitives/select-field.tsx +77 -0
- package/src/primitives/ui/accordion.tsx +61 -0
- package/src/primitives/ui/button.tsx +68 -0
- package/src/primitives/ui/collapsible.tsx +16 -0
- package/src/primitives/ui/dialog.tsx +112 -0
- package/src/primitives/ui/input.tsx +30 -0
- package/src/primitives/ui/label.tsx +31 -0
- package/src/primitives/ui/popover.tsx +38 -0
- package/src/primitives/ui/select.tsx +163 -0
- package/src/primitives/ui/sheet.tsx +131 -0
- package/src/primitives/ui/tabs.tsx +62 -0
- package/src/products/context.tsx +34 -0
- package/src/products/image-gallery.tsx +43 -0
- package/src/products/index.ts +44 -0
- package/src/products/labels-bg.ts +35 -0
- package/src/products/labels.ts +57 -0
- package/src/products/mobile-actions.tsx +180 -0
- package/src/products/option-select.tsx +67 -0
- package/src/products/preview-price.tsx +36 -0
- package/src/products/product-actions-wrapper.tsx +58 -0
- package/src/products/product-actions.tsx +217 -0
- package/src/products/product-info.tsx +43 -0
- package/src/products/product-preview.tsx +49 -0
- package/src/products/product-price.tsx +69 -0
- package/src/products/product-tabs.tsx +169 -0
- package/src/products/product-template.tsx +114 -0
- package/src/products/purchase-options.tsx +130 -0
- package/src/products/related-products.tsx +86 -0
- package/src/products/thumbnail.tsx +71 -0
- package/src/products/variant-matching.ts +71 -0
- package/src/reviews-ui/helpers.ts +174 -0
- package/src/reviews-ui/index.ts +74 -0
- package/src/reviews-ui/labels-bg.ts +91 -0
- package/src/reviews-ui/labels.ts +199 -0
- package/src/reviews-ui/photo-upload.tsx +345 -0
- package/src/reviews-ui/review-list.tsx +249 -0
- package/src/reviews-ui/review-widget.tsx +224 -0
- package/src/reviews-ui/review-wizard.tsx +560 -0
- package/src/reviews-ui/star-badge.tsx +104 -0
- package/src/reviews-ui/wizard-state.ts +81 -0
- package/src/store/category-template.tsx +129 -0
- package/src/store/collection-template.tsx +139 -0
- package/src/store/index.ts +41 -0
- package/src/store/labels-bg.ts +22 -0
- package/src/store/labels.ts +52 -0
- package/src/store/paginated-products.tsx +116 -0
- package/src/store/pagination.tsx +103 -0
- package/src/store/search-params.ts +256 -0
- package/src/store/search-template.tsx +249 -0
- package/src/store/skeleton-product-grid.tsx +26 -0
- package/src/store/sort-select.tsx +81 -0
- package/src/store/store-template.tsx +65 -0
- package/src/tracking/attribution.ts +418 -0
- package/src/tracking/consent-banner.tsx +355 -0
- package/src/tracking/consent-init.tsx +44 -0
- package/src/tracking/consent.ts +243 -0
- package/src/tracking/fbq.ts +168 -0
- package/src/tracking/ga4.tsx +49 -0
- package/src/tracking/get-tracking-attribution.ts +224 -0
- package/src/tracking/get-tracking-config.ts +50 -0
- package/src/tracking/gtag.ts +200 -0
- package/src/tracking/index.ts +133 -0
- package/src/tracking/meta-pixel.tsx +166 -0
- package/src/tracking/rybbit-events.ts +242 -0
- package/src/tracking/rybbit.tsx +40 -0
- package/src/tracking/types.ts +185 -0
- package/src/tracking/use-engagement-time.ts +58 -0
- 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 }
|