create-brainerce-store 1.62.0 → 1.63.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/LICENSE +21 -21
- package/README.md +71 -71
- package/dist/index.js +5 -2
- package/messages/en.json +502 -502
- package/messages/he.json +502 -502
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +60 -60
- package/templates/nextjs/base/.env.local.ejs +24 -24
- package/templates/nextjs/base/.eslintrc.json +57 -57
- package/templates/nextjs/base/.mcp.json +8 -8
- package/templates/nextjs/base/AI-GUIDE.md +174 -174
- package/templates/nextjs/base/TRANSLATIONS.md +200 -200
- package/templates/nextjs/base/package.json.ejs +52 -52
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +262 -262
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +276 -276
- package/templates/nextjs/base/src/app/cart/page.tsx +7 -7
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +106 -106
- package/templates/nextjs/base/src/app/checkout/page.tsx +12 -0
- package/templates/nextjs/base/src/app/globals.css +36 -36
- package/templates/nextjs/base/src/app/indexnow-key.txt/route.ts +26 -26
- package/templates/nextjs/base/src/app/layout.tsx.ejs +10 -0
- package/templates/nextjs/base/src/app/llms.txt/route.ts +44 -44
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +295 -278
- package/templates/nextjs/base/src/app/order-status/page.tsx +121 -121
- package/templates/nextjs/base/src/app/page.tsx +64 -64
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +175 -175
- package/templates/nextjs/base/src/app/products/page.tsx +19 -19
- package/templates/nextjs/base/src/components/account/order-history.tsx +385 -385
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +70 -70
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +612 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +292 -292
- package/templates/nextjs/base/src/components/checkout/date-picker.tsx +309 -309
- package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +144 -144
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +242 -242
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +198 -198
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +125 -125
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +74 -74
- package/templates/nextjs/base/src/components/seo/article-json-ld.tsx +59 -59
- package/templates/nextjs/base/src/components/seo/breadcrumbs.tsx +37 -37
- package/templates/nextjs/base/src/components/seo/category-json-ld.tsx +61 -61
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +97 -97
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +179 -179
- package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +62 -0
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -53
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -32
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -49
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -28
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -107
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -22
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -38
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -153
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -26
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -125
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -7
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -55
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -21
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -32
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -58
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +20 -2
- package/templates/nextjs/base/src/core/lib/image-hosts.ts +7 -7
- package/templates/nextjs/base/src/core/lib/product-options.ts +121 -121
- package/templates/nextjs/base/src/core/lib/resolve-currency.ts +20 -20
- package/templates/nextjs/base/src/core/lib/safe-redirect.ts +38 -38
- package/templates/nextjs/base/src/core/lib/seo.ts +46 -46
- package/templates/nextjs/base/src/core/lib/store-info.ts +9 -0
- package/templates/nextjs/base/src/core/lib/tracking.ts +171 -0
- package/templates/nextjs/base/src/core/lib/use-currency.ts +19 -19
- package/templates/nextjs/base/src/core/lib/utils.ts +21 -21
- package/templates/nextjs/base/src/middleware.ts.ejs +54 -2
- package/templates/nextjs/base/src/ui/cart/cart-bundle-offer.tsx +117 -117
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +146 -146
- package/templates/nextjs/base/src/ui/cart/cart-nudges.tsx +28 -28
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +110 -110
- package/templates/nextjs/base/src/ui/cart/cart-upgrade-banner.tsx +126 -126
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -140
- package/templates/nextjs/base/src/ui/cart/coupon-input.tsx +134 -134
- package/templates/nextjs/base/src/ui/cart/free-shipping-bar.tsx +61 -61
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +95 -95
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -20
- package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -24
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -27
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -29
- package/templates/nextjs/base/src/ui/layout/announcement-bar.tsx.ejs +116 -116
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -90
- package/templates/nextjs/base/src/ui/layout/header-account.tsx +40 -40
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -80
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +144 -144
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +152 -152
- package/templates/nextjs/base/src/ui/product/customization-fields.tsx +457 -457
- package/templates/nextjs/base/src/ui/product/discount-badge.tsx +18 -18
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +191 -191
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -206
- package/templates/nextjs/base/src/ui/product/price-display.tsx +62 -62
- package/templates/nextjs/base/src/ui/product/product-card.tsx +7 -2
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +455 -455
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +102 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +294 -294
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +108 -108
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +61 -61
- package/templates/nextjs/base/src/ui/product/variant-selector.tsx +291 -291
- package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -26
- package/templates/nextjs/base/tailwind.config.ts +69 -69
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +10 -0
- package/templates/nextjs/designs/atelier/globals.css +402 -402
- package/templates/nextjs/designs/atelier/messages-patch/en.json +100 -100
- package/templates/nextjs/designs/atelier/messages-patch/he.json +100 -100
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -110
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -163
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -140
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -123
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -147
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -142
- package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -77
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -89
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +151 -151
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -27
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +75 -75
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +174 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -54
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -46
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -118
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -96
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -36
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -137
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -14
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -34
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -141
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -138
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -466
- package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -17
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -196
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -193
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -69
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +13 -11
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +477 -477
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +42 -42
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -267
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -148
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -66
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -206
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -29
- package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -86
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +276 -276
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +63 -63
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -71
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -125
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -89
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -123
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +27 -27
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -99
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -105
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -106
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -119
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -54
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -81
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -19
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -23
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -21
- package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -26
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -101
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -71
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -31
- package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +76 -76
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -36
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -82
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -119
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -445
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -19
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -168
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -165
- package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -52
- package/templates/nextjs/ui-canvas/product/product-card.tsx +7 -2
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -352
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -27
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -224
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -266
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -96
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -45
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -212
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -31
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -27
- package/templates/nextjs/base/tsconfig.tsbuildinfo +0 -1
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import type { Product } from 'brainerce';
|
|
4
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
5
|
-
import { ProductCard } from '@/ui/product/product-card';
|
|
6
|
-
import { cn } from '@/core/lib/utils';
|
|
7
|
-
|
|
8
|
-
interface ProductGridProps {
|
|
9
|
-
products: Product[];
|
|
10
|
-
className?: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export function ProductGrid({ products, className }: ProductGridProps) {
|
|
14
|
-
const t = useTranslations('products');
|
|
15
|
-
if (products.length === 0) {
|
|
16
|
-
return <p>{t('noProducts')}</p>;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
return (
|
|
20
|
-
<div className={cn('grid grid-cols-2 gap-4 lg:grid-cols-4', className)}>
|
|
21
|
-
{/* DESIGN ME — product grid: lays out ProductCard units; products arrive as a prop from the listing/home hooks. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
22
|
-
{products.map((product) => (
|
|
23
|
-
<ProductCard key={product.id} product={product} />
|
|
24
|
-
))}
|
|
25
|
-
</div>
|
|
26
|
-
);
|
|
27
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Product } from 'brainerce';
|
|
4
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
5
|
+
import { ProductCard } from '@/ui/product/product-card';
|
|
6
|
+
import { cn } from '@/core/lib/utils';
|
|
7
|
+
|
|
8
|
+
interface ProductGridProps {
|
|
9
|
+
products: Product[];
|
|
10
|
+
className?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ProductGrid({ products, className }: ProductGridProps) {
|
|
14
|
+
const t = useTranslations('products');
|
|
15
|
+
if (products.length === 0) {
|
|
16
|
+
return <p>{t('noProducts')}</p>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className={cn('grid grid-cols-2 gap-4 lg:grid-cols-4', className)}>
|
|
21
|
+
{/* DESIGN ME — product grid: lays out ProductCard units; products arrive as a prop from the listing/home hooks. Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
22
|
+
{products.map((product) => (
|
|
23
|
+
<ProductCard key={product.id} product={product} />
|
|
24
|
+
))}
|
|
25
|
+
</div>
|
|
26
|
+
);
|
|
27
|
+
}
|
|
@@ -1,224 +1,224 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import * as React from 'react';
|
|
4
|
-
import {
|
|
5
|
-
useProductListing,
|
|
6
|
-
sortOptions,
|
|
7
|
-
type CategoryNode,
|
|
8
|
-
} from '@/core/hooks/use-product-listing';
|
|
9
|
-
import { ProductGrid } from '@/ui/product/product-grid';
|
|
10
|
-
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
11
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Flatten the category tree into indented <option>s so nested categories
|
|
15
|
-
* stay selectable in a plain, semantic <select>. Replace with any control
|
|
16
|
-
* you like (chips, dropdown tree, sidebar) — keep `handleCategorySelect`.
|
|
17
|
-
*/
|
|
18
|
-
function CategoryOptions({ items, depth }: { items: CategoryNode[]; depth: number }) {
|
|
19
|
-
return (
|
|
20
|
-
<>
|
|
21
|
-
{items.map((node) => (
|
|
22
|
-
<React.Fragment key={node.id}>
|
|
23
|
-
<option value={node.id}>
|
|
24
|
-
{`${'— '.repeat(depth)}${node.name}`}
|
|
25
|
-
</option>
|
|
26
|
-
{node.children.length > 0 && <CategoryOptions items={node.children} depth={depth + 1} />}
|
|
27
|
-
</React.Fragment>
|
|
28
|
-
))}
|
|
29
|
-
</>
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Product listing page (bare skeleton): filters + sort + grid + load-more.
|
|
35
|
-
* All data and URL-state behavior comes from `useProductListing`; this
|
|
36
|
-
* component is presentation only. Must be rendered inside `<Suspense>`
|
|
37
|
-
* (URL params).
|
|
38
|
-
*/
|
|
39
|
-
export function ProductListing() {
|
|
40
|
-
const t = useTranslations('products');
|
|
41
|
-
const tc = useTranslations('common');
|
|
42
|
-
|
|
43
|
-
const {
|
|
44
|
-
products,
|
|
45
|
-
loading,
|
|
46
|
-
loadingMore,
|
|
47
|
-
page,
|
|
48
|
-
totalPages,
|
|
49
|
-
total,
|
|
50
|
-
categories,
|
|
51
|
-
brands,
|
|
52
|
-
tags,
|
|
53
|
-
metafieldDefs,
|
|
54
|
-
searchQuery,
|
|
55
|
-
categoryId,
|
|
56
|
-
brandId,
|
|
57
|
-
tagId,
|
|
58
|
-
sortIndex,
|
|
59
|
-
handleLoadMore,
|
|
60
|
-
updateParam,
|
|
61
|
-
handleCategorySelect,
|
|
62
|
-
getSelectedMetafieldValues,
|
|
63
|
-
setMetafieldValue,
|
|
64
|
-
} = useProductListing();
|
|
65
|
-
|
|
66
|
-
return (
|
|
67
|
-
<section>
|
|
68
|
-
{/* DESIGN ME — product listing page: header, category/brand/tag/custom-field filters, sort, product grid, load-more; everything comes from useProductListing(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
69
|
-
<header>
|
|
70
|
-
<h1>{searchQuery ? `${t('searchPrefix')} "${searchQuery}"` : t('allProducts')}</h1>
|
|
71
|
-
{!loading && (
|
|
72
|
-
<p>
|
|
73
|
-
{total} {total === 1 ? tc('product') : tc('products')} {tc('found')}
|
|
74
|
-
</p>
|
|
75
|
-
)}
|
|
76
|
-
</header>
|
|
77
|
-
|
|
78
|
-
{/* Filters and Sort */}
|
|
79
|
-
<div className="flex flex-wrap items-center gap-3">
|
|
80
|
-
{/* Category filter — nested tree flattened into a select */}
|
|
81
|
-
{categories.length > 0 && (
|
|
82
|
-
<label>
|
|
83
|
-
<span className="sr-only">{tc('all')}</span>
|
|
84
|
-
<select
|
|
85
|
-
value={categoryId}
|
|
86
|
-
onChange={(e) =>
|
|
87
|
-
e.target.value ? handleCategorySelect(e.target.value) : updateParam('category', '')
|
|
88
|
-
}
|
|
89
|
-
>
|
|
90
|
-
<option value="">{tc('all')}</option>
|
|
91
|
-
<CategoryOptions items={categories} depth={0} />
|
|
92
|
-
</select>
|
|
93
|
-
</label>
|
|
94
|
-
)}
|
|
95
|
-
|
|
96
|
-
{/* Brand Filter */}
|
|
97
|
-
{brands.length > 0 && (
|
|
98
|
-
<select
|
|
99
|
-
value={brandId}
|
|
100
|
-
onChange={(e) => updateParam('brand', e.target.value)}
|
|
101
|
-
aria-label={t('allBrands')}
|
|
102
|
-
>
|
|
103
|
-
<option value="">{t('allBrands')}</option>
|
|
104
|
-
{brands.map((b) => (
|
|
105
|
-
<option key={b.id} value={b.id}>
|
|
106
|
-
{b.name}
|
|
107
|
-
</option>
|
|
108
|
-
))}
|
|
109
|
-
</select>
|
|
110
|
-
)}
|
|
111
|
-
|
|
112
|
-
{/* Tag Filter */}
|
|
113
|
-
{tags.length > 0 && (
|
|
114
|
-
<select
|
|
115
|
-
value={tagId}
|
|
116
|
-
onChange={(e) => updateParam('tag', e.target.value)}
|
|
117
|
-
aria-label={t('allTags')}
|
|
118
|
-
>
|
|
119
|
-
<option value="">{t('allTags')}</option>
|
|
120
|
-
{tags.map((tg) => (
|
|
121
|
-
<option key={tg.id} value={tg.id}>
|
|
122
|
-
{tg.name}
|
|
123
|
-
</option>
|
|
124
|
-
))}
|
|
125
|
-
</select>
|
|
126
|
-
)}
|
|
127
|
-
|
|
128
|
-
{/* Custom-field (metafield) facets — rendered per definition type.
|
|
129
|
-
Only definitions flagged `filterable: true` and published to
|
|
130
|
-
this sales channel reach this list (see `metafieldDefs` filter
|
|
131
|
-
in the hook). The merchant's display name (`def.name`) is shown
|
|
132
|
-
as-is — `enumValues` come from the merchant's dashboard config. */}
|
|
133
|
-
{metafieldDefs.map((def) => {
|
|
134
|
-
const selected = getSelectedMetafieldValues(def.key);
|
|
135
|
-
|
|
136
|
-
if (def.type === 'BOOLEAN') {
|
|
137
|
-
const isOn = selected.includes('true');
|
|
138
|
-
return (
|
|
139
|
-
<label key={def.id} className="inline-flex items-center gap-2">
|
|
140
|
-
<input
|
|
141
|
-
type="checkbox"
|
|
142
|
-
checked={isOn}
|
|
143
|
-
onChange={(e) => setMetafieldValue(def.key, e.target.checked ? ['true'] : [])}
|
|
144
|
-
/>
|
|
145
|
-
{def.name}
|
|
146
|
-
</label>
|
|
147
|
-
);
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (def.type === 'SELECT') {
|
|
151
|
-
return (
|
|
152
|
-
<select
|
|
153
|
-
key={def.id}
|
|
154
|
-
value={selected[0] || ''}
|
|
155
|
-
onChange={(e) =>
|
|
156
|
-
setMetafieldValue(def.key, e.target.value ? [e.target.value] : [])
|
|
157
|
-
}
|
|
158
|
-
aria-label={def.name}
|
|
159
|
-
>
|
|
160
|
-
<option value="">{`${tc('all')} ${def.name}`}</option>
|
|
161
|
-
{(def.enumValues || []).map((v) => (
|
|
162
|
-
<option key={v.value} value={v.value}>
|
|
163
|
-
{v.label || v.value}
|
|
164
|
-
</option>
|
|
165
|
-
))}
|
|
166
|
-
</select>
|
|
167
|
-
);
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// MULTI_SELECT — checkbox toggles. Each click adds/removes one value.
|
|
171
|
-
return (
|
|
172
|
-
<fieldset key={def.id} className="inline-flex items-center gap-2">
|
|
173
|
-
<legend>{def.name}</legend>
|
|
174
|
-
{(def.enumValues || []).map((v) => {
|
|
175
|
-
const active = selected.includes(v.value);
|
|
176
|
-
return (
|
|
177
|
-
<label key={v.value} className="inline-flex items-center gap-1">
|
|
178
|
-
<input
|
|
179
|
-
type="checkbox"
|
|
180
|
-
checked={active}
|
|
181
|
-
onChange={() =>
|
|
182
|
-
setMetafieldValue(
|
|
183
|
-
def.key,
|
|
184
|
-
active ? selected.filter((s) => s !== v.value) : [...selected, v.value]
|
|
185
|
-
)
|
|
186
|
-
}
|
|
187
|
-
/>
|
|
188
|
-
{v.label || v.value}
|
|
189
|
-
</label>
|
|
190
|
-
);
|
|
191
|
-
})}
|
|
192
|
-
</fieldset>
|
|
193
|
-
);
|
|
194
|
-
})}
|
|
195
|
-
|
|
196
|
-
{/* Sort */}
|
|
197
|
-
<label htmlFor="sort">{tc('sortBy')}</label>
|
|
198
|
-
<select id="sort" value={sortIndex} onChange={(e) => updateParam('sort', e.target.value)}>
|
|
199
|
-
{sortOptions.map((opt, idx) => (
|
|
200
|
-
<option key={idx} value={idx}>
|
|
201
|
-
{t(opt.labelKey)}
|
|
202
|
-
</option>
|
|
203
|
-
))}
|
|
204
|
-
</select>
|
|
205
|
-
</div>
|
|
206
|
-
|
|
207
|
-
{/* Products Grid */}
|
|
208
|
-
{loading ? (
|
|
209
|
-
<LoadingSpinner size="lg" />
|
|
210
|
-
) : (
|
|
211
|
-
<>
|
|
212
|
-
<ProductGrid products={products} />
|
|
213
|
-
|
|
214
|
-
{/* Load More */}
|
|
215
|
-
{page < totalPages && (
|
|
216
|
-
<button type="button" onClick={handleLoadMore} disabled={loadingMore}>
|
|
217
|
-
{loadingMore ? tc('loading') : t('loadMore')}
|
|
218
|
-
</button>
|
|
219
|
-
)}
|
|
220
|
-
</>
|
|
221
|
-
)}
|
|
222
|
-
</section>
|
|
223
|
-
);
|
|
224
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import * as React from 'react';
|
|
4
|
+
import {
|
|
5
|
+
useProductListing,
|
|
6
|
+
sortOptions,
|
|
7
|
+
type CategoryNode,
|
|
8
|
+
} from '@/core/hooks/use-product-listing';
|
|
9
|
+
import { ProductGrid } from '@/ui/product/product-grid';
|
|
10
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
11
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Flatten the category tree into indented <option>s so nested categories
|
|
15
|
+
* stay selectable in a plain, semantic <select>. Replace with any control
|
|
16
|
+
* you like (chips, dropdown tree, sidebar) — keep `handleCategorySelect`.
|
|
17
|
+
*/
|
|
18
|
+
function CategoryOptions({ items, depth }: { items: CategoryNode[]; depth: number }) {
|
|
19
|
+
return (
|
|
20
|
+
<>
|
|
21
|
+
{items.map((node) => (
|
|
22
|
+
<React.Fragment key={node.id}>
|
|
23
|
+
<option value={node.id}>
|
|
24
|
+
{`${'— '.repeat(depth)}${node.name}`}
|
|
25
|
+
</option>
|
|
26
|
+
{node.children.length > 0 && <CategoryOptions items={node.children} depth={depth + 1} />}
|
|
27
|
+
</React.Fragment>
|
|
28
|
+
))}
|
|
29
|
+
</>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Product listing page (bare skeleton): filters + sort + grid + load-more.
|
|
35
|
+
* All data and URL-state behavior comes from `useProductListing`; this
|
|
36
|
+
* component is presentation only. Must be rendered inside `<Suspense>`
|
|
37
|
+
* (URL params).
|
|
38
|
+
*/
|
|
39
|
+
export function ProductListing() {
|
|
40
|
+
const t = useTranslations('products');
|
|
41
|
+
const tc = useTranslations('common');
|
|
42
|
+
|
|
43
|
+
const {
|
|
44
|
+
products,
|
|
45
|
+
loading,
|
|
46
|
+
loadingMore,
|
|
47
|
+
page,
|
|
48
|
+
totalPages,
|
|
49
|
+
total,
|
|
50
|
+
categories,
|
|
51
|
+
brands,
|
|
52
|
+
tags,
|
|
53
|
+
metafieldDefs,
|
|
54
|
+
searchQuery,
|
|
55
|
+
categoryId,
|
|
56
|
+
brandId,
|
|
57
|
+
tagId,
|
|
58
|
+
sortIndex,
|
|
59
|
+
handleLoadMore,
|
|
60
|
+
updateParam,
|
|
61
|
+
handleCategorySelect,
|
|
62
|
+
getSelectedMetafieldValues,
|
|
63
|
+
setMetafieldValue,
|
|
64
|
+
} = useProductListing();
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<section>
|
|
68
|
+
{/* DESIGN ME — product listing page: header, category/brand/tag/custom-field filters, sort, product grid, load-more; everything comes from useProductListing(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
|
|
69
|
+
<header>
|
|
70
|
+
<h1>{searchQuery ? `${t('searchPrefix')} "${searchQuery}"` : t('allProducts')}</h1>
|
|
71
|
+
{!loading && (
|
|
72
|
+
<p>
|
|
73
|
+
{total} {total === 1 ? tc('product') : tc('products')} {tc('found')}
|
|
74
|
+
</p>
|
|
75
|
+
)}
|
|
76
|
+
</header>
|
|
77
|
+
|
|
78
|
+
{/* Filters and Sort */}
|
|
79
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
80
|
+
{/* Category filter — nested tree flattened into a select */}
|
|
81
|
+
{categories.length > 0 && (
|
|
82
|
+
<label>
|
|
83
|
+
<span className="sr-only">{tc('all')}</span>
|
|
84
|
+
<select
|
|
85
|
+
value={categoryId}
|
|
86
|
+
onChange={(e) =>
|
|
87
|
+
e.target.value ? handleCategorySelect(e.target.value) : updateParam('category', '')
|
|
88
|
+
}
|
|
89
|
+
>
|
|
90
|
+
<option value="">{tc('all')}</option>
|
|
91
|
+
<CategoryOptions items={categories} depth={0} />
|
|
92
|
+
</select>
|
|
93
|
+
</label>
|
|
94
|
+
)}
|
|
95
|
+
|
|
96
|
+
{/* Brand Filter */}
|
|
97
|
+
{brands.length > 0 && (
|
|
98
|
+
<select
|
|
99
|
+
value={brandId}
|
|
100
|
+
onChange={(e) => updateParam('brand', e.target.value)}
|
|
101
|
+
aria-label={t('allBrands')}
|
|
102
|
+
>
|
|
103
|
+
<option value="">{t('allBrands')}</option>
|
|
104
|
+
{brands.map((b) => (
|
|
105
|
+
<option key={b.id} value={b.id}>
|
|
106
|
+
{b.name}
|
|
107
|
+
</option>
|
|
108
|
+
))}
|
|
109
|
+
</select>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
{/* Tag Filter */}
|
|
113
|
+
{tags.length > 0 && (
|
|
114
|
+
<select
|
|
115
|
+
value={tagId}
|
|
116
|
+
onChange={(e) => updateParam('tag', e.target.value)}
|
|
117
|
+
aria-label={t('allTags')}
|
|
118
|
+
>
|
|
119
|
+
<option value="">{t('allTags')}</option>
|
|
120
|
+
{tags.map((tg) => (
|
|
121
|
+
<option key={tg.id} value={tg.id}>
|
|
122
|
+
{tg.name}
|
|
123
|
+
</option>
|
|
124
|
+
))}
|
|
125
|
+
</select>
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{/* Custom-field (metafield) facets — rendered per definition type.
|
|
129
|
+
Only definitions flagged `filterable: true` and published to
|
|
130
|
+
this sales channel reach this list (see `metafieldDefs` filter
|
|
131
|
+
in the hook). The merchant's display name (`def.name`) is shown
|
|
132
|
+
as-is — `enumValues` come from the merchant's dashboard config. */}
|
|
133
|
+
{metafieldDefs.map((def) => {
|
|
134
|
+
const selected = getSelectedMetafieldValues(def.key);
|
|
135
|
+
|
|
136
|
+
if (def.type === 'BOOLEAN') {
|
|
137
|
+
const isOn = selected.includes('true');
|
|
138
|
+
return (
|
|
139
|
+
<label key={def.id} className="inline-flex items-center gap-2">
|
|
140
|
+
<input
|
|
141
|
+
type="checkbox"
|
|
142
|
+
checked={isOn}
|
|
143
|
+
onChange={(e) => setMetafieldValue(def.key, e.target.checked ? ['true'] : [])}
|
|
144
|
+
/>
|
|
145
|
+
{def.name}
|
|
146
|
+
</label>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (def.type === 'SELECT') {
|
|
151
|
+
return (
|
|
152
|
+
<select
|
|
153
|
+
key={def.id}
|
|
154
|
+
value={selected[0] || ''}
|
|
155
|
+
onChange={(e) =>
|
|
156
|
+
setMetafieldValue(def.key, e.target.value ? [e.target.value] : [])
|
|
157
|
+
}
|
|
158
|
+
aria-label={def.name}
|
|
159
|
+
>
|
|
160
|
+
<option value="">{`${tc('all')} ${def.name}`}</option>
|
|
161
|
+
{(def.enumValues || []).map((v) => (
|
|
162
|
+
<option key={v.value} value={v.value}>
|
|
163
|
+
{v.label || v.value}
|
|
164
|
+
</option>
|
|
165
|
+
))}
|
|
166
|
+
</select>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// MULTI_SELECT — checkbox toggles. Each click adds/removes one value.
|
|
171
|
+
return (
|
|
172
|
+
<fieldset key={def.id} className="inline-flex items-center gap-2">
|
|
173
|
+
<legend>{def.name}</legend>
|
|
174
|
+
{(def.enumValues || []).map((v) => {
|
|
175
|
+
const active = selected.includes(v.value);
|
|
176
|
+
return (
|
|
177
|
+
<label key={v.value} className="inline-flex items-center gap-1">
|
|
178
|
+
<input
|
|
179
|
+
type="checkbox"
|
|
180
|
+
checked={active}
|
|
181
|
+
onChange={() =>
|
|
182
|
+
setMetafieldValue(
|
|
183
|
+
def.key,
|
|
184
|
+
active ? selected.filter((s) => s !== v.value) : [...selected, v.value]
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
/>
|
|
188
|
+
{v.label || v.value}
|
|
189
|
+
</label>
|
|
190
|
+
);
|
|
191
|
+
})}
|
|
192
|
+
</fieldset>
|
|
193
|
+
);
|
|
194
|
+
})}
|
|
195
|
+
|
|
196
|
+
{/* Sort */}
|
|
197
|
+
<label htmlFor="sort">{tc('sortBy')}</label>
|
|
198
|
+
<select id="sort" value={sortIndex} onChange={(e) => updateParam('sort', e.target.value)}>
|
|
199
|
+
{sortOptions.map((opt, idx) => (
|
|
200
|
+
<option key={idx} value={idx}>
|
|
201
|
+
{t(opt.labelKey)}
|
|
202
|
+
</option>
|
|
203
|
+
))}
|
|
204
|
+
</select>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
{/* Products Grid */}
|
|
208
|
+
{loading ? (
|
|
209
|
+
<LoadingSpinner size="lg" />
|
|
210
|
+
) : (
|
|
211
|
+
<>
|
|
212
|
+
<ProductGrid products={products} />
|
|
213
|
+
|
|
214
|
+
{/* Load More */}
|
|
215
|
+
{page < totalPages && (
|
|
216
|
+
<button type="button" onClick={handleLoadMore} disabled={loadingMore}>
|
|
217
|
+
{loadingMore ? tc('loading') : t('loadMore')}
|
|
218
|
+
</button>
|
|
219
|
+
)}
|
|
220
|
+
</>
|
|
221
|
+
)}
|
|
222
|
+
</section>
|
|
223
|
+
);
|
|
224
|
+
}
|