@86d-app/products 0.0.3
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/AGENTS.md +65 -0
- package/COMPONENTS.md +231 -0
- package/README.md +201 -0
- package/package.json +46 -0
- package/src/__tests__/controllers.test.ts +2227 -0
- package/src/__tests__/state.test.ts +138 -0
- package/src/admin/components/categories-admin.mdx +3 -0
- package/src/admin/components/categories-admin.tsx +449 -0
- package/src/admin/components/category-form.mdx +9 -0
- package/src/admin/components/category-form.tsx +490 -0
- package/src/admin/components/category-list.mdx +75 -0
- package/src/admin/components/category-list.tsx +168 -0
- package/src/admin/components/collections-admin.mdx +3 -0
- package/src/admin/components/collections-admin.tsx +771 -0
- package/src/admin/components/index.tsx +8 -0
- package/src/admin/components/product-detail.mdx +12 -0
- package/src/admin/components/product-detail.tsx +790 -0
- package/src/admin/components/product-edit.tsx +60 -0
- package/src/admin/components/product-form.tsx +793 -0
- package/src/admin/components/product-list.mdx +3 -0
- package/src/admin/components/product-list.tsx +1125 -0
- package/src/admin/components/product-new.tsx +38 -0
- package/src/admin/endpoints/add-collection-product.ts +17 -0
- package/src/admin/endpoints/bulk-action.ts +43 -0
- package/src/admin/endpoints/create-category.ts +52 -0
- package/src/admin/endpoints/create-collection.ts +35 -0
- package/src/admin/endpoints/create-product.ts +50 -0
- package/src/admin/endpoints/create-variant.ts +45 -0
- package/src/admin/endpoints/delete-category.ts +27 -0
- package/src/admin/endpoints/delete-collection.ts +12 -0
- package/src/admin/endpoints/delete-product.ts +27 -0
- package/src/admin/endpoints/delete-variant.ts +27 -0
- package/src/admin/endpoints/get-product.ts +23 -0
- package/src/admin/endpoints/import-products.ts +47 -0
- package/src/admin/endpoints/index.ts +43 -0
- package/src/admin/endpoints/list-categories.ts +21 -0
- package/src/admin/endpoints/list-collections.ts +20 -0
- package/src/admin/endpoints/list-products.ts +25 -0
- package/src/admin/endpoints/remove-collection-product.ts +15 -0
- package/src/admin/endpoints/update-category.ts +82 -0
- package/src/admin/endpoints/update-collection.ts +22 -0
- package/src/admin/endpoints/update-product.ts +67 -0
- package/src/admin/endpoints/update-variant.ts +41 -0
- package/src/controllers.ts +1410 -0
- package/src/index.ts +120 -0
- package/src/markdown.ts +150 -0
- package/src/mdx.d.ts +5 -0
- package/src/schema.ts +352 -0
- package/src/state.ts +84 -0
- package/src/store/components/_hooks.ts +78 -0
- package/src/store/components/_types.ts +73 -0
- package/src/store/components/_utils.ts +14 -0
- package/src/store/components/back-in-stock-notify.tsx +97 -0
- package/src/store/components/collection-card.mdx +42 -0
- package/src/store/components/collection-card.tsx +12 -0
- package/src/store/components/collection-detail.mdx +12 -0
- package/src/store/components/collection-detail.tsx +149 -0
- package/src/store/components/collection-grid.mdx +9 -0
- package/src/store/components/collection-grid.tsx +80 -0
- package/src/store/components/featured-products.mdx +9 -0
- package/src/store/components/featured-products.tsx +75 -0
- package/src/store/components/filter-chip.mdx +25 -0
- package/src/store/components/filter-chip.tsx +12 -0
- package/src/store/components/index.tsx +39 -0
- package/src/store/components/product-card.mdx +69 -0
- package/src/store/components/product-card.tsx +71 -0
- package/src/store/components/product-detail.mdx +30 -0
- package/src/store/components/product-detail.tsx +488 -0
- package/src/store/components/product-listing.mdx +7 -0
- package/src/store/components/product-listing.tsx +423 -0
- package/src/store/components/product-reviews-section.mdx +21 -0
- package/src/store/components/product-reviews-section.tsx +372 -0
- package/src/store/components/recently-viewed.tsx +100 -0
- package/src/store/components/related-products.mdx +6 -0
- package/src/store/components/related-products.tsx +62 -0
- package/src/store/components/star-display.mdx +18 -0
- package/src/store/components/star-display.tsx +27 -0
- package/src/store/components/star-picker.mdx +21 -0
- package/src/store/components/star-picker.tsx +21 -0
- package/src/store/components/stock-badge.mdx +12 -0
- package/src/store/components/stock-badge.tsx +19 -0
- package/src/store/endpoints/get-category.ts +61 -0
- package/src/store/endpoints/get-collection.ts +46 -0
- package/src/store/endpoints/get-featured.ts +18 -0
- package/src/store/endpoints/get-product.ts +52 -0
- package/src/store/endpoints/get-related.ts +20 -0
- package/src/store/endpoints/index.ts +23 -0
- package/src/store/endpoints/list-categories.ts +13 -0
- package/src/store/endpoints/list-collections.ts +22 -0
- package/src/store/endpoints/list-products.ts +28 -0
- package/src/store/endpoints/search-products.ts +18 -0
- package/src/store/endpoints/store-search.ts +111 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { useProductsApi } from "./_hooks";
|
|
5
|
+
import type { Category, Product } from "./_types";
|
|
6
|
+
import { FilterChip } from "./filter-chip";
|
|
7
|
+
import { ProductCard } from "./product-card";
|
|
8
|
+
import ProductListingTemplate from "./product-listing.mdx";
|
|
9
|
+
|
|
10
|
+
export interface ProductListingProps {
|
|
11
|
+
initialCategory?: string;
|
|
12
|
+
initialSearch?: string;
|
|
13
|
+
pageSize?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function ProductListing({
|
|
17
|
+
initialCategory = "",
|
|
18
|
+
initialSearch = "",
|
|
19
|
+
pageSize = 12,
|
|
20
|
+
}: ProductListingProps) {
|
|
21
|
+
const api = useProductsApi();
|
|
22
|
+
|
|
23
|
+
const [page, setPage] = useState(1);
|
|
24
|
+
const [category, setCategory] = useState(initialCategory);
|
|
25
|
+
const [search, setSearch] = useState(initialSearch);
|
|
26
|
+
const [sort, setSort] = useState<"name" | "price" | "createdAt">("createdAt");
|
|
27
|
+
const [order, setOrder] = useState<"asc" | "desc">("desc");
|
|
28
|
+
const [minPrice, setMinPrice] = useState("");
|
|
29
|
+
const [maxPrice, setMaxPrice] = useState("");
|
|
30
|
+
const [inStock, setInStock] = useState(false);
|
|
31
|
+
const [tag, setTag] = useState("");
|
|
32
|
+
const [showFilters, setShowFilters] = useState(false);
|
|
33
|
+
|
|
34
|
+
const queryInput: Record<string, string> = {
|
|
35
|
+
page: String(page),
|
|
36
|
+
limit: String(pageSize),
|
|
37
|
+
sort,
|
|
38
|
+
order,
|
|
39
|
+
};
|
|
40
|
+
if (category) queryInput.category = category;
|
|
41
|
+
if (search) queryInput.search = search;
|
|
42
|
+
if (minPrice) queryInput.minPrice = String(parseInt(minPrice, 10) * 100);
|
|
43
|
+
if (maxPrice) queryInput.maxPrice = String(parseInt(maxPrice, 10) * 100);
|
|
44
|
+
if (inStock) queryInput.inStock = "true";
|
|
45
|
+
if (tag) queryInput.tag = tag;
|
|
46
|
+
|
|
47
|
+
const { data: productsData, isLoading } = api.listProducts.useQuery(
|
|
48
|
+
queryInput,
|
|
49
|
+
) as {
|
|
50
|
+
data: { products: Product[]; total: number } | undefined;
|
|
51
|
+
isLoading: boolean;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const { data: categoriesData } = api.listCategories.useQuery() as {
|
|
55
|
+
data: { categories: Category[] } | undefined;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const products = productsData?.products ?? [];
|
|
59
|
+
const total = productsData?.total ?? 0;
|
|
60
|
+
const categories = categoriesData?.categories ?? [];
|
|
61
|
+
const totalPages = Math.ceil(total / pageSize);
|
|
62
|
+
|
|
63
|
+
const hasActiveFilters =
|
|
64
|
+
!!search || !!category || !!minPrice || !!maxPrice || inStock || !!tag;
|
|
65
|
+
|
|
66
|
+
const clearAllFilters = () => {
|
|
67
|
+
setSearch("");
|
|
68
|
+
setCategory("");
|
|
69
|
+
setMinPrice("");
|
|
70
|
+
setMaxPrice("");
|
|
71
|
+
setInStock(false);
|
|
72
|
+
setTag("");
|
|
73
|
+
setPage(1);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const handleSearch = (e: React.FormEvent<HTMLFormElement>) => {
|
|
77
|
+
e.preventDefault();
|
|
78
|
+
setPage(1);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const filtersBar = (
|
|
82
|
+
<div className="mb-4 flex flex-wrap items-center gap-2.5">
|
|
83
|
+
<form
|
|
84
|
+
onSubmit={handleSearch}
|
|
85
|
+
className="flex min-w-[180px] flex-1 items-center"
|
|
86
|
+
>
|
|
87
|
+
<div className="relative flex-1">
|
|
88
|
+
<svg
|
|
89
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
90
|
+
width="14"
|
|
91
|
+
height="14"
|
|
92
|
+
viewBox="0 0 24 24"
|
|
93
|
+
fill="none"
|
|
94
|
+
stroke="currentColor"
|
|
95
|
+
strokeWidth="2"
|
|
96
|
+
strokeLinecap="round"
|
|
97
|
+
strokeLinejoin="round"
|
|
98
|
+
className="absolute top-1/2 left-3 -translate-y-1/2 text-muted-foreground"
|
|
99
|
+
aria-hidden="true"
|
|
100
|
+
>
|
|
101
|
+
<circle cx="11" cy="11" r="8" />
|
|
102
|
+
<path d="m21 21-4.3-4.3" />
|
|
103
|
+
</svg>
|
|
104
|
+
<input
|
|
105
|
+
type="search"
|
|
106
|
+
value={search}
|
|
107
|
+
onChange={(e) => {
|
|
108
|
+
setSearch(e.target.value);
|
|
109
|
+
setPage(1);
|
|
110
|
+
}}
|
|
111
|
+
placeholder="Search…"
|
|
112
|
+
className="h-9 w-full rounded-md border border-border bg-background pr-3 pl-8 text-foreground text-sm placeholder:text-muted-foreground/60 focus:border-foreground/20 focus:outline-none focus:ring-1 focus:ring-foreground/10"
|
|
113
|
+
/>
|
|
114
|
+
</div>
|
|
115
|
+
</form>
|
|
116
|
+
|
|
117
|
+
{categories.length > 0 && (
|
|
118
|
+
<select
|
|
119
|
+
value={category}
|
|
120
|
+
onChange={(e) => {
|
|
121
|
+
setCategory(e.target.value);
|
|
122
|
+
setPage(1);
|
|
123
|
+
}}
|
|
124
|
+
className="h-9 rounded-md border border-border bg-background px-2.5 text-foreground text-sm focus:border-foreground/20 focus:outline-none focus:ring-1 focus:ring-foreground/10"
|
|
125
|
+
>
|
|
126
|
+
<option value="">All categories</option>
|
|
127
|
+
{categories.map((c) => (
|
|
128
|
+
<option key={c.id} value={c.id}>
|
|
129
|
+
{c.name}
|
|
130
|
+
</option>
|
|
131
|
+
))}
|
|
132
|
+
</select>
|
|
133
|
+
)}
|
|
134
|
+
|
|
135
|
+
<select
|
|
136
|
+
value={`${sort}:${order}`}
|
|
137
|
+
onChange={(e) => {
|
|
138
|
+
const [s, o] = e.target.value.split(":");
|
|
139
|
+
setSort(s as "name" | "price" | "createdAt");
|
|
140
|
+
setOrder(o as "asc" | "desc");
|
|
141
|
+
setPage(1);
|
|
142
|
+
}}
|
|
143
|
+
className="h-9 rounded-md border border-border bg-background px-2.5 text-foreground text-sm focus:border-foreground/20 focus:outline-none focus:ring-1 focus:ring-foreground/10"
|
|
144
|
+
>
|
|
145
|
+
<option value="createdAt:desc">Newest</option>
|
|
146
|
+
<option value="createdAt:asc">Oldest</option>
|
|
147
|
+
<option value="price:asc">Price: Low → High</option>
|
|
148
|
+
<option value="price:desc">Price: High → Low</option>
|
|
149
|
+
<option value="name:asc">A → Z</option>
|
|
150
|
+
<option value="name:desc">Z → A</option>
|
|
151
|
+
</select>
|
|
152
|
+
|
|
153
|
+
<button
|
|
154
|
+
type="button"
|
|
155
|
+
onClick={() => setShowFilters((v) => !v)}
|
|
156
|
+
className={`flex h-9 items-center gap-1.5 rounded-md border px-2.5 text-sm transition-colors ${
|
|
157
|
+
showFilters || hasActiveFilters
|
|
158
|
+
? "border-foreground/30 bg-foreground/5 text-foreground"
|
|
159
|
+
: "border-border text-muted-foreground hover:text-foreground"
|
|
160
|
+
}`}
|
|
161
|
+
>
|
|
162
|
+
<svg
|
|
163
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
164
|
+
width="14"
|
|
165
|
+
height="14"
|
|
166
|
+
viewBox="0 0 24 24"
|
|
167
|
+
fill="none"
|
|
168
|
+
stroke="currentColor"
|
|
169
|
+
strokeWidth="2"
|
|
170
|
+
strokeLinecap="round"
|
|
171
|
+
strokeLinejoin="round"
|
|
172
|
+
aria-hidden="true"
|
|
173
|
+
>
|
|
174
|
+
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
|
|
175
|
+
</svg>
|
|
176
|
+
Filters
|
|
177
|
+
{hasActiveFilters && (
|
|
178
|
+
<span className="flex h-4 min-w-4 items-center justify-center rounded-full bg-foreground px-1 text-2xs text-background">
|
|
179
|
+
{
|
|
180
|
+
[search, category, minPrice, maxPrice, inStock, tag].filter(
|
|
181
|
+
Boolean,
|
|
182
|
+
).length
|
|
183
|
+
}
|
|
184
|
+
</span>
|
|
185
|
+
)}
|
|
186
|
+
</button>
|
|
187
|
+
|
|
188
|
+
{total > 0 && (
|
|
189
|
+
<span className="text-muted-foreground text-xs tabular-nums">
|
|
190
|
+
{total} {total === 1 ? "product" : "products"}
|
|
191
|
+
</span>
|
|
192
|
+
)}
|
|
193
|
+
</div>
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const filterPanel = showFilters ? (
|
|
197
|
+
<div className="mb-6 rounded-lg border border-border bg-muted/30 p-4">
|
|
198
|
+
<div className="flex flex-wrap items-end gap-4">
|
|
199
|
+
<div className="flex items-end gap-2">
|
|
200
|
+
<div>
|
|
201
|
+
<label
|
|
202
|
+
htmlFor="minPrice"
|
|
203
|
+
className="mb-1 block text-muted-foreground text-xs"
|
|
204
|
+
>
|
|
205
|
+
Min price
|
|
206
|
+
</label>
|
|
207
|
+
<div className="relative">
|
|
208
|
+
<span className="absolute top-1/2 left-2.5 -translate-y-1/2 text-muted-foreground text-xs">
|
|
209
|
+
$
|
|
210
|
+
</span>
|
|
211
|
+
<input
|
|
212
|
+
id="minPrice"
|
|
213
|
+
type="number"
|
|
214
|
+
min="0"
|
|
215
|
+
value={minPrice}
|
|
216
|
+
onChange={(e) => {
|
|
217
|
+
setMinPrice(e.target.value);
|
|
218
|
+
setPage(1);
|
|
219
|
+
}}
|
|
220
|
+
placeholder="0"
|
|
221
|
+
className="h-8 w-20 rounded-md border border-border bg-background pr-2 pl-6 text-foreground text-sm tabular-nums focus:border-foreground/20 focus:outline-none focus:ring-1 focus:ring-foreground/10"
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
</div>
|
|
225
|
+
<span className="pb-1.5 text-muted-foreground text-xs">–</span>
|
|
226
|
+
<div>
|
|
227
|
+
<label
|
|
228
|
+
htmlFor="maxPrice"
|
|
229
|
+
className="mb-1 block text-muted-foreground text-xs"
|
|
230
|
+
>
|
|
231
|
+
Max price
|
|
232
|
+
</label>
|
|
233
|
+
<div className="relative">
|
|
234
|
+
<span className="absolute top-1/2 left-2.5 -translate-y-1/2 text-muted-foreground text-xs">
|
|
235
|
+
$
|
|
236
|
+
</span>
|
|
237
|
+
<input
|
|
238
|
+
id="maxPrice"
|
|
239
|
+
type="number"
|
|
240
|
+
min="0"
|
|
241
|
+
value={maxPrice}
|
|
242
|
+
onChange={(e) => {
|
|
243
|
+
setMaxPrice(e.target.value);
|
|
244
|
+
setPage(1);
|
|
245
|
+
}}
|
|
246
|
+
placeholder="Any"
|
|
247
|
+
className="h-8 w-20 rounded-md border border-border bg-background pr-2 pl-6 text-foreground text-sm tabular-nums focus:border-foreground/20 focus:outline-none focus:ring-1 focus:ring-foreground/10"
|
|
248
|
+
/>
|
|
249
|
+
</div>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<label
|
|
254
|
+
htmlFor="inStockToggle"
|
|
255
|
+
className="flex h-8 cursor-pointer items-center gap-2 rounded-md border border-border bg-background px-2.5"
|
|
256
|
+
>
|
|
257
|
+
<input
|
|
258
|
+
id="inStockToggle"
|
|
259
|
+
type="checkbox"
|
|
260
|
+
checked={inStock}
|
|
261
|
+
onChange={(e) => {
|
|
262
|
+
setInStock(e.target.checked);
|
|
263
|
+
setPage(1);
|
|
264
|
+
}}
|
|
265
|
+
className="accent-foreground"
|
|
266
|
+
/>
|
|
267
|
+
<span className="text-foreground text-sm">In stock only</span>
|
|
268
|
+
</label>
|
|
269
|
+
|
|
270
|
+
<div>
|
|
271
|
+
<label
|
|
272
|
+
htmlFor="tagFilter"
|
|
273
|
+
className="mb-1 block text-muted-foreground text-xs"
|
|
274
|
+
>
|
|
275
|
+
Tag
|
|
276
|
+
</label>
|
|
277
|
+
<input
|
|
278
|
+
id="tagFilter"
|
|
279
|
+
type="text"
|
|
280
|
+
value={tag}
|
|
281
|
+
onChange={(e) => {
|
|
282
|
+
setTag(e.target.value);
|
|
283
|
+
setPage(1);
|
|
284
|
+
}}
|
|
285
|
+
placeholder="e.g. sale"
|
|
286
|
+
className="h-8 w-28 rounded-md border border-border bg-background px-2.5 text-foreground text-sm placeholder:text-muted-foreground/50 focus:border-foreground/20 focus:outline-none focus:ring-1 focus:ring-foreground/10"
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
|
|
290
|
+
{hasActiveFilters && (
|
|
291
|
+
<button
|
|
292
|
+
type="button"
|
|
293
|
+
onClick={clearAllFilters}
|
|
294
|
+
className="h-8 rounded-md border border-border px-2.5 text-muted-foreground text-xs transition-colors hover:bg-muted hover:text-foreground"
|
|
295
|
+
>
|
|
296
|
+
Clear all
|
|
297
|
+
</button>
|
|
298
|
+
)}
|
|
299
|
+
</div>
|
|
300
|
+
</div>
|
|
301
|
+
) : null;
|
|
302
|
+
|
|
303
|
+
const filterChips =
|
|
304
|
+
hasActiveFilters && !showFilters ? (
|
|
305
|
+
<div className="mb-4 flex flex-wrap items-center gap-1.5">
|
|
306
|
+
{category && (
|
|
307
|
+
<FilterChip
|
|
308
|
+
label={`Category: ${categories.find((c) => c.id === category)?.name ?? category}`}
|
|
309
|
+
onRemove={() => {
|
|
310
|
+
setCategory("");
|
|
311
|
+
setPage(1);
|
|
312
|
+
}}
|
|
313
|
+
/>
|
|
314
|
+
)}
|
|
315
|
+
{(minPrice || maxPrice) && (
|
|
316
|
+
<FilterChip
|
|
317
|
+
label={`Price: ${minPrice ? `$${minPrice}` : "$0"} – ${maxPrice ? `$${maxPrice}` : "any"}`}
|
|
318
|
+
onRemove={() => {
|
|
319
|
+
setMinPrice("");
|
|
320
|
+
setMaxPrice("");
|
|
321
|
+
setPage(1);
|
|
322
|
+
}}
|
|
323
|
+
/>
|
|
324
|
+
)}
|
|
325
|
+
{inStock && (
|
|
326
|
+
<FilterChip
|
|
327
|
+
label="In stock"
|
|
328
|
+
onRemove={() => {
|
|
329
|
+
setInStock(false);
|
|
330
|
+
setPage(1);
|
|
331
|
+
}}
|
|
332
|
+
/>
|
|
333
|
+
)}
|
|
334
|
+
{tag && (
|
|
335
|
+
<FilterChip
|
|
336
|
+
label={`Tag: ${tag}`}
|
|
337
|
+
onRemove={() => {
|
|
338
|
+
setTag("");
|
|
339
|
+
setPage(1);
|
|
340
|
+
}}
|
|
341
|
+
/>
|
|
342
|
+
)}
|
|
343
|
+
<button
|
|
344
|
+
type="button"
|
|
345
|
+
onClick={clearAllFilters}
|
|
346
|
+
className="text-muted-foreground text-xs transition-colors hover:text-foreground"
|
|
347
|
+
>
|
|
348
|
+
Clear all
|
|
349
|
+
</button>
|
|
350
|
+
</div>
|
|
351
|
+
) : null;
|
|
352
|
+
|
|
353
|
+
const gridContent = isLoading ? (
|
|
354
|
+
<div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-4">
|
|
355
|
+
{Array.from({ length: pageSize }).map((_, i) => (
|
|
356
|
+
<div key={i}>
|
|
357
|
+
<div className="aspect-[3/4] animate-pulse rounded-lg bg-muted" />
|
|
358
|
+
<div className="mt-3 space-y-1.5">
|
|
359
|
+
<div className="h-3.5 w-3/4 animate-pulse rounded bg-muted-foreground/10" />
|
|
360
|
+
<div className="h-3.5 w-1/3 animate-pulse rounded bg-muted-foreground/10" />
|
|
361
|
+
</div>
|
|
362
|
+
</div>
|
|
363
|
+
))}
|
|
364
|
+
</div>
|
|
365
|
+
) : products.length === 0 ? (
|
|
366
|
+
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
367
|
+
<p className="font-medium text-foreground text-sm">No products found</p>
|
|
368
|
+
<p className="mt-1 text-muted-foreground text-sm">
|
|
369
|
+
Try adjusting your search or filters
|
|
370
|
+
</p>
|
|
371
|
+
{hasActiveFilters && (
|
|
372
|
+
<button
|
|
373
|
+
type="button"
|
|
374
|
+
onClick={clearAllFilters}
|
|
375
|
+
className="mt-4 rounded-full border border-border px-4 py-1.5 text-foreground text-xs transition-colors hover:bg-muted"
|
|
376
|
+
>
|
|
377
|
+
Clear filters
|
|
378
|
+
</button>
|
|
379
|
+
)}
|
|
380
|
+
</div>
|
|
381
|
+
) : (
|
|
382
|
+
<div className="grid grid-cols-2 gap-x-4 gap-y-8 sm:grid-cols-3 lg:grid-cols-4">
|
|
383
|
+
{products.map((product) => (
|
|
384
|
+
<ProductCard key={product.id} product={product} />
|
|
385
|
+
))}
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
|
|
389
|
+
const pagination =
|
|
390
|
+
totalPages > 1 ? (
|
|
391
|
+
<div className="mt-12 flex items-center justify-center gap-2">
|
|
392
|
+
<button
|
|
393
|
+
type="button"
|
|
394
|
+
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
395
|
+
disabled={page === 1}
|
|
396
|
+
className="h-8 rounded-md border border-border px-3 text-foreground text-xs transition-colors hover:bg-muted disabled:opacity-30"
|
|
397
|
+
>
|
|
398
|
+
Previous
|
|
399
|
+
</button>
|
|
400
|
+
<span className="min-w-15 text-center text-muted-foreground text-xs tabular-nums">
|
|
401
|
+
{page} / {totalPages}
|
|
402
|
+
</span>
|
|
403
|
+
<button
|
|
404
|
+
type="button"
|
|
405
|
+
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
406
|
+
disabled={page === totalPages}
|
|
407
|
+
className="h-8 rounded-md border border-border px-3 text-foreground text-xs transition-colors hover:bg-muted disabled:opacity-30"
|
|
408
|
+
>
|
|
409
|
+
Next
|
|
410
|
+
</button>
|
|
411
|
+
</div>
|
|
412
|
+
) : null;
|
|
413
|
+
|
|
414
|
+
return (
|
|
415
|
+
<ProductListingTemplate
|
|
416
|
+
filtersBar={filtersBar}
|
|
417
|
+
filterPanel={filterPanel}
|
|
418
|
+
filterChips={filterChips}
|
|
419
|
+
gridContent={gridContent}
|
|
420
|
+
pagination={pagination}
|
|
421
|
+
/>
|
|
422
|
+
);
|
|
423
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<section id="reviews" className="border-border/50 border-t py-10">
|
|
2
|
+
{/* Header */}
|
|
3
|
+
<div className="mb-6 flex flex-wrap items-center justify-between gap-4">
|
|
4
|
+
<div>
|
|
5
|
+
<h2 className="font-display font-semibold text-foreground text-lg tracking-tight sm:text-xl">
|
|
6
|
+
Customer Reviews
|
|
7
|
+
</h2>
|
|
8
|
+
{props.summaryDisplay}
|
|
9
|
+
</div>
|
|
10
|
+
{props.toggleFormButton}
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
{/* Review form */}
|
|
14
|
+
{props.formContent}
|
|
15
|
+
|
|
16
|
+
{/* Empty state */}
|
|
17
|
+
{props.emptyState}
|
|
18
|
+
|
|
19
|
+
{/* Distribution + reviews list */}
|
|
20
|
+
{props.reviewsContent}
|
|
21
|
+
</section>
|