create-brainerce-store 1.50.5 → 1.52.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/README.md +72 -0
- package/dist/index.js +146 -100
- package/messages/en.json +36 -1
- package/messages/he.json +36 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +60 -0
- package/templates/nextjs/base/.env.local.ejs +5 -0
- package/templates/nextjs/base/.eslintrc.json +57 -3
- package/templates/nextjs/base/AGENTS.md +41 -0
- package/templates/nextjs/base/AI-GUIDE.md +143 -0
- package/templates/nextjs/base/CLAUDE.md +52 -0
- package/templates/nextjs/base/TRANSLATIONS.md +1 -1
- package/templates/nextjs/base/next.config.ts +2 -2
- package/templates/nextjs/base/package.json.ejs +52 -39
- package/templates/nextjs/base/src/app/account/page.tsx +5 -5
- package/templates/nextjs/base/src/app/api/auth/logout/route.ts +1 -1
- package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +2 -2
- package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +1 -1
- package/templates/nextjs/base/src/app/auth/callback/page.tsx +4 -4
- package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +4 -4
- package/templates/nextjs/base/src/app/blog/page.tsx.ejs +2 -2
- package/templates/nextjs/base/src/app/cart/page.tsx +2 -173
- package/templates/nextjs/base/src/app/checkout/page.tsx +12 -11
- package/templates/nextjs/base/src/app/contact/page.tsx +3 -3
- package/templates/nextjs/base/src/app/faq/page.tsx.ejs +2 -2
- package/templates/nextjs/base/src/app/forgot-password/page.tsx +4 -4
- package/templates/nextjs/base/src/app/globals.css +36 -30
- package/templates/nextjs/base/src/app/layout.tsx.ejs +12 -12
- package/templates/nextjs/base/src/app/login/page.tsx +4 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +19 -12
- package/templates/nextjs/base/src/app/page.tsx +4 -4
- package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +3 -3
- package/templates/nextjs/base/src/app/payment-complete/page.tsx +1 -1
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +6 -6
- package/templates/nextjs/base/src/app/products/page.tsx +4 -623
- package/templates/nextjs/base/src/app/register/page.tsx +4 -4
- package/templates/nextjs/base/src/app/reset-password/page.tsx +5 -5
- package/templates/nextjs/base/src/app/sitemap.ts +1 -1
- package/templates/nextjs/base/src/app/verify-email/page.tsx +5 -5
- package/templates/nextjs/base/src/components/account/address-book.tsx +3 -3
- package/templates/nextjs/base/src/components/account/order-customizations.tsx +3 -3
- package/templates/nextjs/base/src/components/account/order-history.tsx +13 -5
- package/templates/nextjs/base/src/components/account/order-payment-block.tsx +2 -2
- package/templates/nextjs/base/src/components/account/order-shipping-block.tsx +2 -2
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +2 -2
- package/templates/nextjs/base/src/components/account/profile-section.tsx +3 -3
- package/templates/nextjs/base/src/components/auth/login-form.tsx +4 -4
- package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +4 -4
- package/templates/nextjs/base/src/components/auth/register-form.tsx +4 -4
- package/templates/nextjs/base/src/components/brainerce-bot.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +24 -3
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +2 -2
- package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +4 -4
- package/templates/nextjs/base/src/components/checkout/payment-step.tsx +20 -16
- package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +3 -3
- package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +3 -3
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +3 -3
- package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +2 -2
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +3 -3
- package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
- package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
- package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
- package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
- package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
- package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
- package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
- package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
- package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
- package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
- package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
- package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
- package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
- package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -0
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -0
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -0
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +278 -0
- package/templates/nextjs/base/src/{hooks → core/hooks}/use-search.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/brainerce.ts.ejs +1 -1
- package/templates/nextjs/base/src/core/lib/product-options.ts +121 -0
- package/templates/nextjs/base/src/{lib → core/lib}/resolve-currency.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/safe-redirect.ts +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/sanitize.ts.ejs +1 -1
- package/templates/nextjs/base/src/{lib → core/lib}/use-currency.ts +1 -1
- package/templates/nextjs/base/src/{providers → core/providers}/store-provider.tsx.ejs +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/cart-bundle-offer.tsx +22 -16
- package/templates/nextjs/base/src/{components → ui}/cart/cart-item.tsx +8 -14
- package/templates/nextjs/base/src/{components → ui}/cart/cart-nudges.tsx +28 -39
- package/templates/nextjs/base/src/{components → ui}/cart/cart-summary.tsx +7 -5
- package/templates/nextjs/base/src/{components → ui}/cart/cart-upgrade-banner.tsx +13 -28
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -0
- package/templates/nextjs/base/src/{components → ui}/cart/coupon-input.tsx +14 -25
- package/templates/nextjs/base/src/{components → ui}/cart/free-shipping-bar.tsx +6 -7
- package/templates/nextjs/base/src/{components → ui}/cart/reservation-countdown.tsx +95 -103
- package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -0
- package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -0
- package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -0
- package/templates/nextjs/base/src/{components/content → ui/layout}/announcement-bar.tsx.ejs +116 -125
- package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -0
- package/templates/nextjs/base/src/{components/content → ui/layout}/header-account.tsx +5 -20
- package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -0
- package/templates/nextjs/base/src/{components/content → ui/layout}/rich-text-block.tsx.ejs +1 -1
- package/templates/nextjs/base/src/{components/content → ui/layout}/site-footer.tsx.ejs +144 -164
- package/templates/nextjs/base/src/{components/content → ui/layout}/site-header.tsx.ejs +152 -182
- package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx +457 -478
- package/templates/nextjs/base/src/{components/products → ui/product}/discount-badge.tsx +18 -22
- package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx +21 -33
- package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -0
- package/templates/nextjs/base/src/{components/shared → ui/product}/price-display.tsx +5 -4
- package/templates/nextjs/base/src/{components/products → ui/product}/product-card.tsx +238 -271
- package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx +455 -631
- package/templates/nextjs/base/src/{components/products → ui/product}/product-grid.tsx +3 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -0
- package/templates/nextjs/base/src/{components/products → ui/product}/recommendation-section.tsx +102 -107
- package/templates/nextjs/base/src/{components/reviews → ui/product}/review-form.tsx +46 -45
- package/templates/nextjs/base/src/{components/reviews/reviews-section.tsx → ui/product/reviews-section.tsx.ejs} +38 -15
- package/templates/nextjs/base/src/{components/products → ui/product}/stock-badge.tsx +61 -63
- package/templates/nextjs/base/src/{components/products → ui/product}/variant-selector.tsx +3 -3
- package/templates/nextjs/base/src/{components → ui}/shared/cdn-image.tsx +1 -1
- package/templates/nextjs/base/src/{components → ui}/shared/loading-spinner.tsx +2 -2
- package/templates/nextjs/base/tailwind.config.ts +69 -30
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +251 -0
- package/templates/nextjs/designs/atelier/globals.css +396 -0
- package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
- package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
- package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
- package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -0
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -0
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
- package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
- package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
- package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
- package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
- package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
- package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
- package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
- package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
- package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -0
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -0
- package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
- package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
- package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -0
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
- package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
- package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
- package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -0
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
- package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
- package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
- package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
- package/templates/nextjs/themes/luxury/globals.css +405 -399
- package/templates/nextjs/themes/minimal/globals.css +36 -30
- package/templates/nextjs/themes/playful/globals.css +406 -400
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -0
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +27 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -0
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -0
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -0
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -0
- package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -0
- package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -0
- package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -0
- package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -0
- package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -0
- package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -0
- package/templates/nextjs/{base/src/components → ui-canvas}/layout/language-switcher.tsx.ejs +76 -70
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -0
- package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -0
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -0
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -0
- package/templates/nextjs/ui-canvas/product/product-card.tsx +197 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -0
- package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -0
- package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -0
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -0
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -0
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -0
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -0
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -0
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -0
- package/templates/nextjs/base/src/app/home-client.tsx +0 -98
- package/templates/nextjs/base/src/components/content/faq-section.tsx.ejs +0 -103
- package/templates/nextjs/base/src/components/products/modifier-group-selector.tsx +0 -238
- /package/templates/nextjs/base/src/{lib → core/lib}/auth.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/csrf.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/image-hosts.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/navigation.tsx.ejs +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/nonce.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/sanitize-html.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/seo.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/store-info.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/translations.ts.ejs +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/utils.ts +0 -0
- /package/templates/nextjs/base/src/{lib → core/lib}/validation.ts +0 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import type { Product, DiscountBanner } from 'brainerce';
|
|
5
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
6
|
+
|
|
7
|
+
export interface UseHomeDataResult {
|
|
8
|
+
/** Newest products for the featured grid (max 8). */
|
|
9
|
+
products: Product[];
|
|
10
|
+
/** Active store-wide discount banners. */
|
|
11
|
+
banners: DiscountBanner[];
|
|
12
|
+
/** True while the initial fetch is in flight. */
|
|
13
|
+
loading: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Homepage data: featured products + discount banners.
|
|
18
|
+
* Pure data/behavior — rendering lives in `ui/home/`.
|
|
19
|
+
*/
|
|
20
|
+
export function useHomeData(): UseHomeDataResult {
|
|
21
|
+
const [products, setProducts] = useState<Product[]>([]);
|
|
22
|
+
const [banners, setBanners] = useState<DiscountBanner[]>([]);
|
|
23
|
+
const [loading, setLoading] = useState(true);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
async function load() {
|
|
27
|
+
try {
|
|
28
|
+
const client = getClient();
|
|
29
|
+
const [productsRes, bannersRes] = await Promise.allSettled([
|
|
30
|
+
client.getProducts({ limit: 8, sortBy: 'createdAt', sortOrder: 'desc' }),
|
|
31
|
+
client.getDiscountBanners(),
|
|
32
|
+
]);
|
|
33
|
+
|
|
34
|
+
if (productsRes.status === 'fulfilled') {
|
|
35
|
+
setProducts(productsRes.value.data);
|
|
36
|
+
}
|
|
37
|
+
if (bannersRes.status === 'fulfilled') {
|
|
38
|
+
setBanners(bannersRes.value);
|
|
39
|
+
}
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.error('Failed to load home page data:', err);
|
|
42
|
+
} finally {
|
|
43
|
+
setLoading(false);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
load();
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
return { products, banners, loading };
|
|
51
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useState, useCallback } from 'react';
|
|
4
|
+
import { useSearchParams } from 'next/navigation';
|
|
5
|
+
import { useRouter } from '@/core/lib/navigation';
|
|
6
|
+
import type { Product, ProductQueryParams, PublicMetafieldDefinition } from 'brainerce';
|
|
7
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
8
|
+
|
|
9
|
+
const PAGE_SIZE = 20;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Metafield (custom-field) values are passed in the URL as `mf_<key>=v1,v2`
|
|
13
|
+
* for readability (no JSON encoding). The SDK assembles them back into
|
|
14
|
+
* `{ [key]: string[] }` before calling `client.getProducts`.
|
|
15
|
+
*
|
|
16
|
+
* Only definitions with `filterable: true` of type SELECT / MULTI_SELECT /
|
|
17
|
+
* BOOLEAN are honored server-side — the storefront renders facets only for
|
|
18
|
+
* those.
|
|
19
|
+
*/
|
|
20
|
+
const METAFIELD_URL_PREFIX = 'mf_';
|
|
21
|
+
const FILTERABLE_TYPES = new Set(['SELECT', 'MULTI_SELECT', 'BOOLEAN']);
|
|
22
|
+
|
|
23
|
+
export type SortOption = {
|
|
24
|
+
labelKey: 'sortNewest' | 'sortNameAZ' | 'sortNameZA' | 'sortPriceLow' | 'sortPriceHigh';
|
|
25
|
+
sortBy: ProductQueryParams['sortBy'];
|
|
26
|
+
sortOrder: ProductQueryParams['sortOrder'];
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const sortOptions: SortOption[] = [
|
|
30
|
+
{ labelKey: 'sortNewest', sortBy: 'createdAt', sortOrder: 'desc' },
|
|
31
|
+
{ labelKey: 'sortNameAZ', sortBy: 'name', sortOrder: 'asc' },
|
|
32
|
+
{ labelKey: 'sortNameZA', sortBy: 'name', sortOrder: 'desc' },
|
|
33
|
+
{ labelKey: 'sortPriceLow', sortBy: 'price', sortOrder: 'asc' },
|
|
34
|
+
{ labelKey: 'sortPriceHigh', sortBy: 'price', sortOrder: 'desc' },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export interface CategoryNode {
|
|
38
|
+
id: string;
|
|
39
|
+
name: string;
|
|
40
|
+
image?: string | null;
|
|
41
|
+
parentId?: string | null;
|
|
42
|
+
children: CategoryNode[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Collect all descendant IDs (including self) */
|
|
46
|
+
export function getAllDescendantIds(node: CategoryNode): string[] {
|
|
47
|
+
const ids = [node.id];
|
|
48
|
+
for (const child of node.children) {
|
|
49
|
+
ids.push(...getAllDescendantIds(child));
|
|
50
|
+
}
|
|
51
|
+
return ids;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Check if a category or any of its descendants matches the selected ID */
|
|
55
|
+
export function isActiveInTree(node: CategoryNode, selectedId: string): boolean {
|
|
56
|
+
if (node.id === selectedId) return true;
|
|
57
|
+
return node.children.some((child) => isActiveInTree(child, selectedId));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface UseProductListingResult {
|
|
61
|
+
/** Loaded products (accumulates across "load more" pages). */
|
|
62
|
+
products: Product[];
|
|
63
|
+
loading: boolean;
|
|
64
|
+
loadingMore: boolean;
|
|
65
|
+
page: number;
|
|
66
|
+
totalPages: number;
|
|
67
|
+
total: number;
|
|
68
|
+
/** Facet sources. */
|
|
69
|
+
categories: CategoryNode[];
|
|
70
|
+
brands: Array<{ id: string; name: string }>;
|
|
71
|
+
tags: Array<{ id: string; name: string }>;
|
|
72
|
+
metafieldDefs: PublicMetafieldDefinition[];
|
|
73
|
+
/** Current URL filter state. */
|
|
74
|
+
searchQuery: string;
|
|
75
|
+
categoryId: string;
|
|
76
|
+
brandId: string;
|
|
77
|
+
tagId: string;
|
|
78
|
+
sortIndex: number;
|
|
79
|
+
/** Handlers. */
|
|
80
|
+
handleLoadMore: () => void;
|
|
81
|
+
/** Set/clear a raw URL query param (`category`, `brand`, `tag`, `sort`, …). */
|
|
82
|
+
updateParam: (key: string, value: string) => void;
|
|
83
|
+
handleCategorySelect: (id: string) => void;
|
|
84
|
+
/** Read the currently-selected values for a given metafield definition. */
|
|
85
|
+
getSelectedMetafieldValues: (key: string) => string[];
|
|
86
|
+
/** Replace the selected values of a metafield facet. */
|
|
87
|
+
setMetafieldValue: (key: string, values: string[]) => void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Product-listing behavior: URL-driven filters (search / category / brand /
|
|
92
|
+
* tag / sort / metafield facets), product loading, and load-more pagination.
|
|
93
|
+
* Pure data/behavior — rendering lives in `ui/product/product-listing.tsx`.
|
|
94
|
+
*
|
|
95
|
+
* Must be rendered inside a `<Suspense>` boundary (uses `useSearchParams`).
|
|
96
|
+
*/
|
|
97
|
+
export function useProductListing(): UseProductListingResult {
|
|
98
|
+
const searchParams = useSearchParams();
|
|
99
|
+
const router = useRouter();
|
|
100
|
+
|
|
101
|
+
const searchQuery = searchParams.get('search') || '';
|
|
102
|
+
const categoryId = searchParams.get('category') || '';
|
|
103
|
+
const brandId = searchParams.get('brand') || '';
|
|
104
|
+
const tagId = searchParams.get('tag') || '';
|
|
105
|
+
const sortParam = searchParams.get('sort') || '0';
|
|
106
|
+
|
|
107
|
+
const [products, setProducts] = useState<Product[]>([]);
|
|
108
|
+
const [loading, setLoading] = useState(true);
|
|
109
|
+
const [loadingMore, setLoadingMore] = useState(false);
|
|
110
|
+
const [page, setPage] = useState(1);
|
|
111
|
+
const [totalPages, setTotalPages] = useState(1);
|
|
112
|
+
const [total, setTotal] = useState(0);
|
|
113
|
+
const [categories, setCategories] = useState<CategoryNode[]>([]);
|
|
114
|
+
const [brands, setBrands] = useState<Array<{ id: string; name: string }>>([]);
|
|
115
|
+
const [tags, setTags] = useState<Array<{ id: string; name: string }>>([]);
|
|
116
|
+
const [metafieldDefs, setMetafieldDefs] = useState<PublicMetafieldDefinition[]>([]);
|
|
117
|
+
|
|
118
|
+
const sortIndex = parseInt(sortParam, 10) || 0;
|
|
119
|
+
const currentSort = sortOptions[sortIndex] || sortOptions[0];
|
|
120
|
+
|
|
121
|
+
// Load categories, brands, tags, and metafield definitions (for custom-field facets).
|
|
122
|
+
// The metafield-definitions endpoint already returns only definitions
|
|
123
|
+
// published to this sales channel; we filter client-side to the ones flagged
|
|
124
|
+
// `filterable: true` and of a UI-renderable type.
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
async function loadFilters() {
|
|
127
|
+
const client = getClient();
|
|
128
|
+
const [catRes, brandRes, tagRes, mfRes] = await Promise.allSettled([
|
|
129
|
+
client.getCategories(),
|
|
130
|
+
client.getBrands(),
|
|
131
|
+
client.getTags(),
|
|
132
|
+
client.getPublicMetafieldDefinitions(),
|
|
133
|
+
]);
|
|
134
|
+
if (catRes.status === 'fulfilled') setCategories(catRes.value.categories as CategoryNode[]);
|
|
135
|
+
if (brandRes.status === 'fulfilled') setBrands(brandRes.value.brands);
|
|
136
|
+
if (tagRes.status === 'fulfilled') setTags(tagRes.value.tags);
|
|
137
|
+
if (mfRes.status === 'fulfilled') {
|
|
138
|
+
setMetafieldDefs(
|
|
139
|
+
mfRes.value.definitions.filter(
|
|
140
|
+
(d) => d.filterable === true && FILTERABLE_TYPES.has(d.type)
|
|
141
|
+
)
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
loadFilters();
|
|
146
|
+
}, []);
|
|
147
|
+
|
|
148
|
+
// Stable serialized signature of the selected metafield facets — used as
|
|
149
|
+
// the `loadProducts` dependency (and to derive the SDK shape). When no
|
|
150
|
+
// facets are selected, this is `''` so the SDK call omits `metafields`
|
|
151
|
+
// entirely and the backend short-circuits the filter branch.
|
|
152
|
+
const metafieldsKey = useMemo(() => {
|
|
153
|
+
if (metafieldDefs.length === 0) return '';
|
|
154
|
+
const parts: string[] = [];
|
|
155
|
+
for (const def of metafieldDefs) {
|
|
156
|
+
const raw = searchParams.get(`${METAFIELD_URL_PREFIX}${def.key}`);
|
|
157
|
+
if (raw) parts.push(`${def.key}=${raw}`);
|
|
158
|
+
}
|
|
159
|
+
return parts.sort().join('&');
|
|
160
|
+
}, [metafieldDefs, searchParams]);
|
|
161
|
+
|
|
162
|
+
// Load products when filters change
|
|
163
|
+
const loadProducts = useCallback(
|
|
164
|
+
async (pageNum: number, append: boolean) => {
|
|
165
|
+
try {
|
|
166
|
+
if (append) {
|
|
167
|
+
setLoadingMore(true);
|
|
168
|
+
} else {
|
|
169
|
+
setLoading(true);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const client = getClient();
|
|
173
|
+
const params: ProductQueryParams = {
|
|
174
|
+
page: pageNum,
|
|
175
|
+
limit: PAGE_SIZE,
|
|
176
|
+
sortBy: currentSort.sortBy,
|
|
177
|
+
sortOrder: currentSort.sortOrder,
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
if (searchQuery) params.search = searchQuery;
|
|
181
|
+
if (categoryId) params.categories = categoryId;
|
|
182
|
+
if (brandId) params.brands = brandId;
|
|
183
|
+
if (tagId) params.tags = tagId;
|
|
184
|
+
// Build the `metafields` shape from `metafieldsKey` (each segment is
|
|
185
|
+
// `key=v1,v2`). Equivalent to reading the URL params directly but
|
|
186
|
+
// tied to the stable signature already in this callback's deps.
|
|
187
|
+
if (metafieldsKey) {
|
|
188
|
+
const mf: Record<string, string[]> = {};
|
|
189
|
+
for (const segment of metafieldsKey.split('&')) {
|
|
190
|
+
const eq = segment.indexOf('=');
|
|
191
|
+
if (eq < 0) continue;
|
|
192
|
+
const k = segment.slice(0, eq);
|
|
193
|
+
const v = segment
|
|
194
|
+
.slice(eq + 1)
|
|
195
|
+
.split(',')
|
|
196
|
+
.map((s) => s.trim())
|
|
197
|
+
.filter(Boolean);
|
|
198
|
+
if (v.length > 0) mf[k] = v;
|
|
199
|
+
}
|
|
200
|
+
if (Object.keys(mf).length > 0) params.metafields = mf;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const result = await client.getProducts(params);
|
|
204
|
+
|
|
205
|
+
if (append) {
|
|
206
|
+
setProducts((prev) => [...prev, ...result.data]);
|
|
207
|
+
} else {
|
|
208
|
+
setProducts(result.data);
|
|
209
|
+
}
|
|
210
|
+
setTotalPages(result.meta.totalPages);
|
|
211
|
+
setTotal(result.meta.total);
|
|
212
|
+
setPage(pageNum);
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.error('Failed to load products:', err);
|
|
215
|
+
} finally {
|
|
216
|
+
setLoading(false);
|
|
217
|
+
setLoadingMore(false);
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
[
|
|
221
|
+
searchQuery,
|
|
222
|
+
categoryId,
|
|
223
|
+
brandId,
|
|
224
|
+
tagId,
|
|
225
|
+
currentSort.sortBy,
|
|
226
|
+
currentSort.sortOrder,
|
|
227
|
+
metafieldsKey,
|
|
228
|
+
]
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
loadProducts(1, false);
|
|
233
|
+
}, [loadProducts]);
|
|
234
|
+
|
|
235
|
+
function handleLoadMore() {
|
|
236
|
+
if (page < totalPages && !loadingMore) {
|
|
237
|
+
loadProducts(page + 1, true);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function updateParam(key: string, value: string) {
|
|
242
|
+
const params = new URLSearchParams(searchParams.toString());
|
|
243
|
+
if (value) {
|
|
244
|
+
params.set(key, value);
|
|
245
|
+
} else {
|
|
246
|
+
params.delete(key);
|
|
247
|
+
}
|
|
248
|
+
router.push(`/products?${params.toString()}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function handleCategorySelect(id: string) {
|
|
252
|
+
updateParam('category', id);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** Read the currently-selected values for a given metafield definition. */
|
|
256
|
+
function getSelectedMetafieldValues(key: string): string[] {
|
|
257
|
+
const raw = searchParams.get(`${METAFIELD_URL_PREFIX}${key}`);
|
|
258
|
+
if (!raw) return [];
|
|
259
|
+
return raw
|
|
260
|
+
.split(',')
|
|
261
|
+
.map((v) => v.trim())
|
|
262
|
+
.filter(Boolean);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Toggle one value on/off in a metafield facet's URL param. Multi-select
|
|
267
|
+
* adds/removes from the list; single-select / boolean replace the value.
|
|
268
|
+
*/
|
|
269
|
+
function setMetafieldValue(key: string, values: string[]) {
|
|
270
|
+
updateParam(`${METAFIELD_URL_PREFIX}${key}`, values.join(','));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
return {
|
|
274
|
+
products,
|
|
275
|
+
loading,
|
|
276
|
+
loadingMore,
|
|
277
|
+
page,
|
|
278
|
+
totalPages,
|
|
279
|
+
total,
|
|
280
|
+
categories,
|
|
281
|
+
brands,
|
|
282
|
+
tags,
|
|
283
|
+
metafieldDefs,
|
|
284
|
+
searchQuery,
|
|
285
|
+
categoryId,
|
|
286
|
+
brandId,
|
|
287
|
+
tagId,
|
|
288
|
+
sortIndex,
|
|
289
|
+
handleLoadMore,
|
|
290
|
+
updateParam,
|
|
291
|
+
handleCategorySelect,
|
|
292
|
+
getSelectedMetafieldValues,
|
|
293
|
+
setMetafieldValue,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useMemo } from 'react';
|
|
4
|
+
import type {
|
|
5
|
+
Product,
|
|
6
|
+
ProductCustomizationField,
|
|
7
|
+
ProductImage,
|
|
8
|
+
ProductRecommendationsResponse,
|
|
9
|
+
ProductVariant,
|
|
10
|
+
InventoryInfo,
|
|
11
|
+
ModifierGroup,
|
|
12
|
+
} from 'brainerce';
|
|
13
|
+
import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
|
|
14
|
+
import { useCart } from '@/core/providers/store-provider';
|
|
15
|
+
import {
|
|
16
|
+
buildInitialSelections,
|
|
17
|
+
toModifierSelections,
|
|
18
|
+
validateCustomization,
|
|
19
|
+
validateSelections,
|
|
20
|
+
type CustomizationValues,
|
|
21
|
+
} from '@/core/lib/product-options';
|
|
22
|
+
|
|
23
|
+
export interface ProductPriceInfo {
|
|
24
|
+
price: number;
|
|
25
|
+
originalPrice: number;
|
|
26
|
+
isOnSale: boolean;
|
|
27
|
+
discountAmount?: number;
|
|
28
|
+
discountPercent: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface UseProductPageResult {
|
|
32
|
+
product: Product;
|
|
33
|
+
recommendations: ProductRecommendationsResponse | null;
|
|
34
|
+
/** Product image list. */
|
|
35
|
+
images: ProductImage[];
|
|
36
|
+
/** Index into `images`; -1 means "variant image not in the list". */
|
|
37
|
+
selectedImageIndex: number;
|
|
38
|
+
setSelectedImageIndex: (index: number) => void;
|
|
39
|
+
/** URL of the image to show as the main image (variant-aware). */
|
|
40
|
+
mainImageUrl: string | null;
|
|
41
|
+
/** Variant selection. */
|
|
42
|
+
selectedVariant: ProductVariant | null;
|
|
43
|
+
setSelectedVariant: (variant: ProductVariant | null) => void;
|
|
44
|
+
/** Effective price info (variant price + discount-rule overlay). */
|
|
45
|
+
priceInfo: ProductPriceInfo;
|
|
46
|
+
/** Variant inventory when a variant is selected, else product inventory. */
|
|
47
|
+
inventory: InventoryInfo | null;
|
|
48
|
+
canPurchase: boolean;
|
|
49
|
+
/** Resolved description content ({ html } or { text }). */
|
|
50
|
+
description: ReturnType<typeof getDescriptionContent> | null;
|
|
51
|
+
/** Quantity picker. */
|
|
52
|
+
quantity: number;
|
|
53
|
+
setQuantity: (update: number | ((q: number) => number)) => void;
|
|
54
|
+
/** Add-to-cart state machine. */
|
|
55
|
+
addingToCart: boolean;
|
|
56
|
+
addedMessage: boolean;
|
|
57
|
+
handleAddToCart: () => Promise<void>;
|
|
58
|
+
/** Customization (buyer input) sub-contract. */
|
|
59
|
+
customizationFields: ProductCustomizationField[];
|
|
60
|
+
customizationValues: CustomizationValues;
|
|
61
|
+
setCustomizationValues: (values: CustomizationValues) => void;
|
|
62
|
+
customizationErrors: Record<string, string>;
|
|
63
|
+
/** Modifier groups (toppings, sauce, …) sub-contract. */
|
|
64
|
+
modifierGroups: ModifierGroup[];
|
|
65
|
+
modifierSelections: Record<string, string[]>;
|
|
66
|
+
setModifierSelection: (groupId: string, next: string[]) => void;
|
|
67
|
+
modifierError: string | null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Product-page behavior: variant selection, variant-aware image + price +
|
|
72
|
+
* inventory resolution, quantity, customization + modifier state and
|
|
73
|
+
* validation, and the add-to-cart state machine. Pure data/behavior —
|
|
74
|
+
* rendering lives in `ui/product/product-client-section.tsx`.
|
|
75
|
+
*/
|
|
76
|
+
export function useProductPage(initialProduct: Product): UseProductPageResult {
|
|
77
|
+
const { refreshCart } = useCart();
|
|
78
|
+
|
|
79
|
+
const product = initialProduct;
|
|
80
|
+
const recommendations = product?.recommendations ?? null;
|
|
81
|
+
|
|
82
|
+
const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
|
|
83
|
+
product.variants && product.variants.length > 0 ? product.variants[0] : null
|
|
84
|
+
);
|
|
85
|
+
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
|
86
|
+
const [quantity, setQuantity] = useState(1);
|
|
87
|
+
const [addingToCart, setAddingToCart] = useState(false);
|
|
88
|
+
const [addedMessage, setAddedMessage] = useState(false);
|
|
89
|
+
const customizationFields = product.customizationFields ?? [];
|
|
90
|
+
const [customizationValues, setCustomizationValues] = useState<CustomizationValues>(() => {
|
|
91
|
+
const initial: CustomizationValues = {};
|
|
92
|
+
for (const field of customizationFields) {
|
|
93
|
+
if (field.defaultValue != null) initial[field.key] = field.defaultValue;
|
|
94
|
+
}
|
|
95
|
+
return initial;
|
|
96
|
+
});
|
|
97
|
+
const [customizationErrors, setCustomizationErrors] = useState<Record<string, string>>({});
|
|
98
|
+
|
|
99
|
+
// Modifier groups (PRD §8.4) — only present on restaurant / build-your-own products.
|
|
100
|
+
const modifierGroups: ModifierGroup[] = useMemo(
|
|
101
|
+
() => (product as Product & { modifierGroups?: ModifierGroup[] }).modifierGroups ?? [],
|
|
102
|
+
[product]
|
|
103
|
+
);
|
|
104
|
+
const [modifierSelections, setModifierSelections] = useState<Record<string, string[]>>(() =>
|
|
105
|
+
buildInitialSelections(modifierGroups)
|
|
106
|
+
);
|
|
107
|
+
const [modifierError, setModifierError] = useState<string | null>(null);
|
|
108
|
+
|
|
109
|
+
function setModifierSelection(groupId: string, next: string[]) {
|
|
110
|
+
setModifierSelections((prev) => ({ ...prev, [groupId]: next }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Images list - switch main image when variant changes
|
|
114
|
+
const images: ProductImage[] = useMemo(() => {
|
|
115
|
+
return product?.images || [];
|
|
116
|
+
}, [product]);
|
|
117
|
+
|
|
118
|
+
// When variant changes, update selected image to variant image if available
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
if (!selectedVariant?.image || !product) return;
|
|
121
|
+
|
|
122
|
+
const variantImgUrl =
|
|
123
|
+
typeof selectedVariant.image === 'string' ? selectedVariant.image : selectedVariant.image.url;
|
|
124
|
+
|
|
125
|
+
// Find if variant image exists in product images
|
|
126
|
+
const idx = images.findIndex((img) => img.url === variantImgUrl);
|
|
127
|
+
if (idx >= 0) {
|
|
128
|
+
setSelectedImageIndex(idx);
|
|
129
|
+
} else {
|
|
130
|
+
// Variant image not in product images - select index 0 as fallback
|
|
131
|
+
setSelectedImageIndex(-1);
|
|
132
|
+
}
|
|
133
|
+
}, [selectedVariant, images, product]);
|
|
134
|
+
|
|
135
|
+
// Determine which image to show
|
|
136
|
+
const mainImageUrl = useMemo(() => {
|
|
137
|
+
if (selectedImageIndex === -1 && selectedVariant?.image) {
|
|
138
|
+
const img = selectedVariant.image;
|
|
139
|
+
return typeof img === 'string' ? img : img.url;
|
|
140
|
+
}
|
|
141
|
+
return images[selectedImageIndex]?.url || null;
|
|
142
|
+
}, [selectedImageIndex, selectedVariant, images]);
|
|
143
|
+
|
|
144
|
+
// Price info - use variant price if selected, else product price
|
|
145
|
+
const priceInfo = useMemo(() => {
|
|
146
|
+
if (selectedVariant?.price) {
|
|
147
|
+
const variantBase = parseFloat(selectedVariant.price);
|
|
148
|
+
const variantSale = selectedVariant.salePrice ? parseFloat(selectedVariant.salePrice) : null;
|
|
149
|
+
const variantEffective =
|
|
150
|
+
variantSale != null && variantSale < variantBase ? variantSale : variantBase;
|
|
151
|
+
|
|
152
|
+
// Overlay any product-level discount rule onto the variant price using the rule's ratio
|
|
153
|
+
if (product.discount) {
|
|
154
|
+
const ruleOriginal = parseFloat(product.discount.originalPrice) || 0;
|
|
155
|
+
const ruleDiscounted = parseFloat(product.discount.discountedPrice) || 0;
|
|
156
|
+
const ratio = ruleOriginal > 0 ? ruleDiscounted / ruleOriginal : 1;
|
|
157
|
+
const discounted = variantEffective * ratio;
|
|
158
|
+
const amount = Math.max(0, variantEffective - discounted);
|
|
159
|
+
return {
|
|
160
|
+
price: discounted,
|
|
161
|
+
originalPrice: variantEffective,
|
|
162
|
+
isOnSale: discounted < variantEffective,
|
|
163
|
+
discountAmount: amount,
|
|
164
|
+
discountPercent: variantEffective > 0 ? Math.round((amount / variantEffective) * 100) : 0,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
price: variantEffective,
|
|
170
|
+
originalPrice: variantBase,
|
|
171
|
+
isOnSale: variantEffective < variantBase,
|
|
172
|
+
discountPercent:
|
|
173
|
+
variantEffective < variantBase && variantBase > 0
|
|
174
|
+
? Math.round(((variantBase - variantEffective) / variantBase) * 100)
|
|
175
|
+
: 0,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
return getProductPriceInfo(product);
|
|
179
|
+
}, [product, selectedVariant]);
|
|
180
|
+
|
|
181
|
+
// Inventory: use variant inventory if selected, else product inventory
|
|
182
|
+
const inventory = selectedVariant?.inventory ?? product?.inventory ?? null;
|
|
183
|
+
const canPurchase = inventory?.canPurchase !== false;
|
|
184
|
+
|
|
185
|
+
// Description
|
|
186
|
+
const description = useMemo(() => {
|
|
187
|
+
return product ? getDescriptionContent(product) : null;
|
|
188
|
+
}, [product]);
|
|
189
|
+
|
|
190
|
+
async function handleAddToCart() {
|
|
191
|
+
if (!product || addingToCart) return;
|
|
192
|
+
|
|
193
|
+
if (customizationFields.length > 0) {
|
|
194
|
+
const errs = validateCustomization(customizationFields, customizationValues);
|
|
195
|
+
if (Object.keys(errs).length > 0) {
|
|
196
|
+
setCustomizationErrors(errs);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
setCustomizationErrors({});
|
|
201
|
+
|
|
202
|
+
// Client-side modifier validation mirrors the server's checks. The server
|
|
203
|
+
// is authoritative — `MODIFIER_VALIDATION_FAILED` envelope on add-to-cart
|
|
204
|
+
// failure is the source of truth — but pre-flighting catches the obvious
|
|
205
|
+
// issues without a round-trip.
|
|
206
|
+
if (modifierGroups.length > 0) {
|
|
207
|
+
const error = validateSelections(modifierGroups, modifierSelections);
|
|
208
|
+
if (error) {
|
|
209
|
+
setModifierError(error);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
setModifierError(null);
|
|
214
|
+
|
|
215
|
+
const selections =
|
|
216
|
+
modifierGroups.length > 0
|
|
217
|
+
? toModifierSelections(modifierGroups, modifierSelections)
|
|
218
|
+
: undefined;
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
setAddingToCart(true);
|
|
222
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
223
|
+
const client = getClient();
|
|
224
|
+
await client.smartAddToCart({
|
|
225
|
+
productId: product.id,
|
|
226
|
+
variantId: selectedVariant?.id,
|
|
227
|
+
quantity,
|
|
228
|
+
metadata:
|
|
229
|
+
customizationFields.length > 0 && Object.keys(customizationValues).length > 0
|
|
230
|
+
? customizationValues
|
|
231
|
+
: undefined,
|
|
232
|
+
...(selections && selections.length > 0 ? { selections } : {}),
|
|
233
|
+
});
|
|
234
|
+
await refreshCart();
|
|
235
|
+
setAddedMessage(true);
|
|
236
|
+
setTimeout(() => setAddedMessage(false), 2000);
|
|
237
|
+
} catch (err) {
|
|
238
|
+
// Surface the structured `MODIFIER_VALIDATION_FAILED` envelope when present.
|
|
239
|
+
const e = err as { details?: { code?: string; errors?: Array<{ message: string }> } };
|
|
240
|
+
const validationErrors = e?.details?.errors;
|
|
241
|
+
if (e?.details?.code === 'MODIFIER_VALIDATION_FAILED' && validationErrors?.length) {
|
|
242
|
+
setModifierError(validationErrors.map((v) => v.message).join('; '));
|
|
243
|
+
} else {
|
|
244
|
+
console.error('Failed to add to cart:', err);
|
|
245
|
+
}
|
|
246
|
+
} finally {
|
|
247
|
+
setAddingToCart(false);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
product,
|
|
253
|
+
recommendations,
|
|
254
|
+
images,
|
|
255
|
+
selectedImageIndex,
|
|
256
|
+
setSelectedImageIndex,
|
|
257
|
+
mainImageUrl,
|
|
258
|
+
selectedVariant,
|
|
259
|
+
setSelectedVariant,
|
|
260
|
+
priceInfo,
|
|
261
|
+
inventory,
|
|
262
|
+
canPurchase,
|
|
263
|
+
description,
|
|
264
|
+
quantity,
|
|
265
|
+
setQuantity,
|
|
266
|
+
addingToCart,
|
|
267
|
+
addedMessage,
|
|
268
|
+
handleAddToCart,
|
|
269
|
+
customizationFields,
|
|
270
|
+
customizationValues,
|
|
271
|
+
setCustomizationValues,
|
|
272
|
+
customizationErrors,
|
|
273
|
+
modifierGroups,
|
|
274
|
+
modifierSelections,
|
|
275
|
+
setModifierSelection,
|
|
276
|
+
modifierError,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
|
4
4
|
import type { SearchSuggestions } from 'brainerce';
|
|
5
|
-
import { getClient } from '@/lib/brainerce';
|
|
5
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
6
6
|
|
|
7
7
|
const DEBOUNCE_MS = 300;
|
|
8
8
|
const MIN_QUERY_LENGTH = 2;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { BrainerceClient } from 'brainerce';
|
|
2
2
|
import { cache } from 'react';
|
|
3
|
-
import { pickPublicStoreInfo, type PublicStoreInfo } from '@/lib/store-info';
|
|
3
|
+
import { pickPublicStoreInfo, type PublicStoreInfo } from '@/core/lib/store-info';
|
|
4
4
|
|
|
5
5
|
// Read either env var name. The new one is preferred; the old one is a soft
|
|
6
6
|
// alias kept for backwards compatibility — both are accepted by the SDK.
|