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,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure product-option logic (zero markup) shared by `core/hooks/use-product-page`
|
|
3
|
+
* and the `ui/product` option components. Moved verbatim from
|
|
4
|
+
* `customization-fields.tsx` / `modifier-group-selector.tsx` so that core
|
|
5
|
+
* never depends on `ui/` — the UI components re-export these for
|
|
6
|
+
* backward compatibility.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ModifierGroup, ModifierSelection, ProductCustomizationField } from 'brainerce';
|
|
10
|
+
|
|
11
|
+
export type CustomizationValues = Record<string, unknown>;
|
|
12
|
+
|
|
13
|
+
export function validateCustomization(
|
|
14
|
+
fields: ProductCustomizationField[],
|
|
15
|
+
values: CustomizationValues
|
|
16
|
+
): Record<string, string> {
|
|
17
|
+
const errors: Record<string, string> = {};
|
|
18
|
+
for (const field of fields) {
|
|
19
|
+
const value = values[field.key];
|
|
20
|
+
const empty =
|
|
21
|
+
value === undefined ||
|
|
22
|
+
value === null ||
|
|
23
|
+
value === '' ||
|
|
24
|
+
(Array.isArray(value) && value.length === 0);
|
|
25
|
+
|
|
26
|
+
if (field.required && empty) {
|
|
27
|
+
errors[field.key] = 'required';
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (empty) continue;
|
|
31
|
+
|
|
32
|
+
if (field.type === 'SELECT') {
|
|
33
|
+
const allowed = (field.enumValues ?? []).map((o) => o.value);
|
|
34
|
+
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
35
|
+
errors[field.key] = 'invalidOption';
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (field.type === 'MULTI_SELECT') {
|
|
39
|
+
const allowed = (field.enumValues ?? []).map((o) => o.value);
|
|
40
|
+
if (
|
|
41
|
+
!Array.isArray(value) ||
|
|
42
|
+
!(value as unknown[]).every((v) => typeof v === 'string' && allowed.includes(v as string))
|
|
43
|
+
) {
|
|
44
|
+
errors[field.key] = 'invalidOption';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return errors;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Lightweight client-side validation that mirrors the server's checks. Use
|
|
53
|
+
* this to gate the add-to-cart button — but always treat the server's 400
|
|
54
|
+
* (`MODIFIER_VALIDATION_FAILED`) as the authoritative answer.
|
|
55
|
+
*/
|
|
56
|
+
export function validateSelections(
|
|
57
|
+
groups: ModifierGroup[],
|
|
58
|
+
selections: Record<string, string[]>
|
|
59
|
+
): string | null {
|
|
60
|
+
for (const group of groups) {
|
|
61
|
+
if (group.max === 0) continue; // disabled-for-variant — skip
|
|
62
|
+
const picks = selections[group.id] ?? [];
|
|
63
|
+
if (group.required && picks.length === 0) {
|
|
64
|
+
return `${group.name} is required`;
|
|
65
|
+
}
|
|
66
|
+
if (picks.length < group.min) {
|
|
67
|
+
return `Pick at least ${group.min} from ${group.name}`;
|
|
68
|
+
}
|
|
69
|
+
if (group.max != null && picks.length > group.max) {
|
|
70
|
+
return `Pick at most ${group.max} from ${group.name}`;
|
|
71
|
+
}
|
|
72
|
+
if (group.selectionType === 'SINGLE' && picks.length > 1) {
|
|
73
|
+
return `Only one option allowed in ${group.name}`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Adapter: convert the local Record<groupId, modifierIds> shape used by the
|
|
81
|
+
* selector component into the wire-format `ModifierSelection[]` array the SDK
|
|
82
|
+
* expects on `addToCart` / `updateCartItem`.
|
|
83
|
+
*/
|
|
84
|
+
export function toModifierSelections(
|
|
85
|
+
groups: ModifierGroup[],
|
|
86
|
+
selections: Record<string, string[]>
|
|
87
|
+
): ModifierSelection[] {
|
|
88
|
+
const out: ModifierSelection[] = [];
|
|
89
|
+
for (const group of groups) {
|
|
90
|
+
if (group.max === 0) continue;
|
|
91
|
+
const picks = selections[group.id] ?? [];
|
|
92
|
+
if (picks.length === 0) continue;
|
|
93
|
+
out.push({ modifierGroupId: group.id, modifierIds: picks });
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build the initial selection map from the group's defaults — call once on
|
|
100
|
+
* first render for each group. Honors `defaultModifierIds` first (per-attach
|
|
101
|
+
* defaults), falling back to per-modifier `isDefault` flags. Sold-out
|
|
102
|
+
* modifiers are filtered out so the user doesn't start with an invalid pick.
|
|
103
|
+
*/
|
|
104
|
+
export function buildInitialSelections(groups: ModifierGroup[]): Record<string, string[]> {
|
|
105
|
+
const out: Record<string, string[]> = {};
|
|
106
|
+
for (const group of groups) {
|
|
107
|
+
if (group.max === 0) continue;
|
|
108
|
+
const fromDefaults = group.defaultModifierIds ?? [];
|
|
109
|
+
const fromIsDefault = group.modifiers
|
|
110
|
+
.filter((m) => m.isDefault && m.available)
|
|
111
|
+
.map((m) => m.id);
|
|
112
|
+
const merged =
|
|
113
|
+
fromDefaults.length > 0
|
|
114
|
+
? fromDefaults.filter((id) => group.modifiers.some((m) => m.id === id && m.available))
|
|
115
|
+
: fromIsDefault;
|
|
116
|
+
// Cap by max for SINGLE groups — defaults can't violate selection rules.
|
|
117
|
+
const capped = group.selectionType === 'SINGLE' ? merged.slice(0, 1) : merged;
|
|
118
|
+
if (capped.length > 0) out[group.id] = capped;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Re-export the platform-maintained payment URL allowlist from the SDK.
|
|
2
2
|
//
|
|
3
3
|
// Why a re-export and not a direct import everywhere: keeping a stable
|
|
4
|
-
// `@/lib/safe-redirect` import path means future SDK API changes (e.g. an
|
|
4
|
+
// `@/core/lib/safe-redirect` import path means future SDK API changes (e.g. an
|
|
5
5
|
// `extraHosts` rename) only need editing here, and merchants don't have to
|
|
6
6
|
// touch every component that does payment redirects. It also leaves a clear
|
|
7
7
|
// place to plug in store-specific extras (custom self-hosted PSP) without
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import DOMPurify from 'isomorphic-dompurify';
|
|
16
16
|
|
|
17
|
-
const DEFAULT_CONFIG
|
|
17
|
+
const DEFAULT_CONFIG = {
|
|
18
18
|
// Allow iframes for embedded videos/maps the merchant may include.
|
|
19
19
|
ADD_TAGS: ['iframe'],
|
|
20
20
|
ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling', 'target', 'rel'],
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
|
4
4
|
import type { Cart, CustomerProfile } from 'brainerce';
|
|
5
5
|
import { getCartTotals } from 'brainerce';
|
|
6
|
-
import { getClient, initClient, setStoredCartId } from '@/lib/brainerce';
|
|
7
|
-
import { pickPublicStoreInfo, type PublicStoreInfo } from '@/lib/store-info';
|
|
8
|
-
import { checkAuthStatus, proxyLogout } from '@/lib/auth';
|
|
6
|
+
import { getClient, initClient, setStoredCartId } from '@/core/lib/brainerce';
|
|
7
|
+
import { pickPublicStoreInfo, type PublicStoreInfo } from '@/core/lib/store-info';
|
|
8
|
+
import { checkAuthStatus, proxyLogout } from '@/core/lib/auth';
|
|
9
9
|
<% if (i18nEnabled) { %>
|
|
10
|
-
import { MessagesContext } from '@/lib/translations';
|
|
10
|
+
import { MessagesContext } from '@/core/lib/translations';
|
|
11
11
|
import { getMessages, defaultLocale } from '@/i18n';
|
|
12
12
|
<% } %>
|
|
13
13
|
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
|
-
import { CdnImage as Image } from '@/
|
|
4
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
5
|
import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
|
|
6
6
|
import { formatPrice } from 'brainerce';
|
|
7
|
-
import { useCurrency } from '@/lib/use-currency';
|
|
8
|
-
import { useTranslations } from '@/lib/translations';
|
|
9
|
-
import {
|
|
7
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
8
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
9
|
+
import { Badge } from '@/components/ui/badge';
|
|
10
|
+
import { Button } from '@/components/ui/button';
|
|
11
|
+
import { Card } from '@/components/ui/card';
|
|
12
|
+
import { Separator } from '@/components/ui/separator';
|
|
13
|
+
import { cn } from '@/core/lib/utils';
|
|
10
14
|
|
|
11
15
|
interface CartBundleOfferCardProps {
|
|
12
16
|
offer: CartBundleOfferType;
|
|
@@ -33,7 +37,7 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
|
|
|
33
37
|
if (adding) return;
|
|
34
38
|
try {
|
|
35
39
|
setAdding(true);
|
|
36
|
-
const { getClient } = await import('@/lib/brainerce');
|
|
40
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
37
41
|
const client = getClient();
|
|
38
42
|
// No variant selections in the default template — products with
|
|
39
43
|
// variants are best handled with a dedicated picker per offered item.
|
|
@@ -47,7 +51,7 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
|
|
|
47
51
|
}
|
|
48
52
|
|
|
49
53
|
return (
|
|
50
|
-
<
|
|
54
|
+
<Card className={cn('p-4 shadow-none', className)}>
|
|
51
55
|
<div className="mb-3">
|
|
52
56
|
<p className="text-foreground text-sm font-medium">{offer.name}</p>
|
|
53
57
|
{offer.description && (
|
|
@@ -82,7 +86,8 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
|
|
|
82
86
|
})}
|
|
83
87
|
</ul>
|
|
84
88
|
|
|
85
|
-
<
|
|
89
|
+
<Separator className="mt-3" />
|
|
90
|
+
<div className="flex items-center justify-between pt-3">
|
|
86
91
|
<div className="flex items-center gap-2">
|
|
87
92
|
<span className="text-muted-foreground text-sm line-through">
|
|
88
93
|
{formatPrice(totalOriginal, { currency }) as string}
|
|
@@ -90,22 +95,23 @@ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBun
|
|
|
90
95
|
<span className="text-foreground text-sm font-semibold">
|
|
91
96
|
{formatPrice(totalDiscounted, { currency }) as string}
|
|
92
97
|
</span>
|
|
93
|
-
<
|
|
98
|
+
<Badge
|
|
99
|
+
variant="outline"
|
|
100
|
+
className="bg-destructive/10 text-destructive rounded border-transparent px-1.5 py-0.5 font-medium"
|
|
101
|
+
>
|
|
94
102
|
-{discountLabel}
|
|
95
|
-
</
|
|
103
|
+
</Badge>
|
|
96
104
|
</div>
|
|
97
|
-
<
|
|
105
|
+
<Button
|
|
98
106
|
type="button"
|
|
107
|
+
size="sm"
|
|
99
108
|
onClick={handleAdd}
|
|
100
109
|
disabled={adding}
|
|
101
|
-
className=
|
|
102
|
-
'bg-primary text-primary-foreground flex-shrink-0 rounded px-4 py-2 text-xs font-medium transition-opacity hover:opacity-90',
|
|
103
|
-
'disabled:cursor-not-allowed disabled:opacity-50'
|
|
104
|
-
)}
|
|
110
|
+
className="h-auto flex-shrink-0 rounded px-4 py-2 text-xs"
|
|
105
111
|
>
|
|
106
112
|
{adding ? t('addingBundle') : t('addBundleItem')}
|
|
107
|
-
</
|
|
113
|
+
</Button>
|
|
108
114
|
</div>
|
|
109
|
-
</
|
|
115
|
+
</Card>
|
|
110
116
|
);
|
|
111
117
|
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState } from 'react';
|
|
4
|
-
import {
|
|
4
|
+
import { Image as ImageIcon } from 'lucide-react';
|
|
5
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
6
|
import type { CartItem as CartItemType } from 'brainerce';
|
|
6
7
|
import { getCartItemImage, formatPrice } from 'brainerce';
|
|
7
|
-
import { getClient } from '@/lib/brainerce';
|
|
8
|
-
import { useTranslations } from '@/lib/translations';
|
|
9
|
-
import { useCurrency } from '@/lib/use-currency';
|
|
10
|
-
import { LoadingSpinner } from '@/
|
|
11
|
-
import { cn } from '@/lib/utils';
|
|
8
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
9
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
10
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
11
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
12
|
+
import { cn } from '@/core/lib/utils';
|
|
12
13
|
|
|
13
14
|
interface CartItemProps {
|
|
14
15
|
item: CartItemType;
|
|
@@ -73,14 +74,7 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
73
74
|
<Image src={imageUrl} alt={productName} fill sizes="80px" className="object-cover" />
|
|
74
75
|
) : (
|
|
75
76
|
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
76
|
-
<
|
|
77
|
-
<path
|
|
78
|
-
strokeLinecap="round"
|
|
79
|
-
strokeLinejoin="round"
|
|
80
|
-
strokeWidth={1.5}
|
|
81
|
-
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
82
|
-
/>
|
|
83
|
-
</svg>
|
|
77
|
+
<ImageIcon className="h-8 w-8" strokeWidth={1.5} aria-hidden="true" />
|
|
84
78
|
</div>
|
|
85
79
|
)}
|
|
86
80
|
</div>
|
|
@@ -1,39 +1,28 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
strokeLinejoin="round"
|
|
30
|
-
strokeWidth={2}
|
|
31
|
-
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
|
32
|
-
/>
|
|
33
|
-
</svg>
|
|
34
|
-
<p className="text-foreground flex-1 text-sm">{nudge.text}</p>
|
|
35
|
-
</div>
|
|
36
|
-
))}
|
|
37
|
-
</div>
|
|
38
|
-
);
|
|
39
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Info } from 'lucide-react';
|
|
4
|
+
import type { CartNudge } from 'brainerce';
|
|
5
|
+
import { cn } from '@/core/lib/utils';
|
|
6
|
+
|
|
7
|
+
interface CartNudgesProps {
|
|
8
|
+
nudges: CartNudge[];
|
|
9
|
+
className?: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function CartNudges({ nudges, className }: CartNudgesProps) {
|
|
13
|
+
if (nudges.length === 0) return null;
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div className={cn('space-y-2', className)}>
|
|
17
|
+
{nudges.map((nudge) => (
|
|
18
|
+
<div
|
|
19
|
+
key={nudge.ruleId}
|
|
20
|
+
className="bg-primary/5 border-primary/20 flex items-start gap-3 rounded-lg border px-4 py-3"
|
|
21
|
+
>
|
|
22
|
+
<Info className="text-primary mt-0.5 h-5 w-5 flex-shrink-0" aria-hidden="true" />
|
|
23
|
+
<p className="text-foreground flex-1 text-sm">{nudge.text}</p>
|
|
24
|
+
</div>
|
|
25
|
+
))}
|
|
26
|
+
</div>
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { formatPrice } from 'brainerce';
|
|
4
|
-
import { useTranslations } from '@/lib/translations';
|
|
5
|
-
import { useCart } from '@/providers/store-provider';
|
|
6
|
-
import { useCurrency } from '@/lib/use-currency';
|
|
7
|
-
import {
|
|
4
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
5
|
+
import { useCart } from '@/core/providers/store-provider';
|
|
6
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
7
|
+
import { Separator } from '@/components/ui/separator';
|
|
8
|
+
import { cn } from '@/core/lib/utils';
|
|
8
9
|
|
|
9
10
|
interface CartSummaryProps {
|
|
10
11
|
className?: string;
|
|
@@ -94,7 +95,8 @@ export function CartSummary({ className }: CartSummaryProps) {
|
|
|
94
95
|
</div>
|
|
95
96
|
|
|
96
97
|
{/* Divider */}
|
|
97
|
-
<
|
|
98
|
+
<Separator className="mt-2" />
|
|
99
|
+
<div className="pt-1">
|
|
98
100
|
<div className="flex items-center justify-between">
|
|
99
101
|
<span className="text-foreground font-semibold">{tc('total')}</span>
|
|
100
102
|
<span className="text-foreground text-base font-semibold">
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
import { useState, useEffect } from 'react';
|
|
4
|
-
import {
|
|
4
|
+
import { Image as ImageIcon, X } from 'lucide-react';
|
|
5
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
6
|
import type { CartUpgradeSuggestion, CartItem as CartItemType } from 'brainerce';
|
|
6
7
|
import { formatPrice } from 'brainerce';
|
|
7
|
-
import { getClient } from '@/lib/brainerce';
|
|
8
|
-
import { useCurrency } from '@/lib/use-currency';
|
|
9
|
-
import { useTranslations } from '@/lib/translations';
|
|
10
|
-
import {
|
|
8
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
9
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
10
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
11
|
+
import { Button } from '@/components/ui/button';
|
|
12
|
+
import { cn } from '@/core/lib/utils';
|
|
11
13
|
|
|
12
14
|
interface CartUpgradeBannerProps {
|
|
13
15
|
suggestion: CartUpgradeSuggestion;
|
|
@@ -88,15 +90,7 @@ export function CartUpgradeBanner({
|
|
|
88
90
|
className="text-muted-foreground hover:text-foreground absolute end-2 top-2 text-xs"
|
|
89
91
|
aria-label={t('dismissUpgrade')}
|
|
90
92
|
>
|
|
91
|
-
<
|
|
92
|
-
className="h-4 w-4"
|
|
93
|
-
fill="none"
|
|
94
|
-
viewBox="0 0 24 24"
|
|
95
|
-
stroke="currentColor"
|
|
96
|
-
strokeWidth={2}
|
|
97
|
-
>
|
|
98
|
-
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
99
|
-
</svg>
|
|
93
|
+
<X className="h-4 w-4" aria-hidden="true" />
|
|
100
94
|
</button>
|
|
101
95
|
|
|
102
96
|
{/* Product image */}
|
|
@@ -105,14 +99,7 @@ export function CartUpgradeBanner({
|
|
|
105
99
|
<Image src={imageUrl} alt={target.name} fill sizes="48px" className="object-cover" />
|
|
106
100
|
) : (
|
|
107
101
|
<div className="text-muted-foreground flex h-full w-full items-center justify-center">
|
|
108
|
-
<
|
|
109
|
-
<path
|
|
110
|
-
strokeLinecap="round"
|
|
111
|
-
strokeLinejoin="round"
|
|
112
|
-
strokeWidth={1.5}
|
|
113
|
-
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
114
|
-
/>
|
|
115
|
-
</svg>
|
|
102
|
+
<ImageIcon className="h-5 w-5" strokeWidth={1.5} aria-hidden="true" />
|
|
116
103
|
</div>
|
|
117
104
|
)}
|
|
118
105
|
</div>
|
|
@@ -125,17 +112,15 @@ export function CartUpgradeBanner({
|
|
|
125
112
|
</div>
|
|
126
113
|
|
|
127
114
|
{/* Upgrade button */}
|
|
128
|
-
<
|
|
115
|
+
<Button
|
|
129
116
|
type="button"
|
|
117
|
+
size="sm"
|
|
130
118
|
onClick={handleUpgrade}
|
|
131
119
|
disabled={upgrading}
|
|
132
|
-
className=
|
|
133
|
-
'bg-primary text-primary-foreground flex-shrink-0 rounded px-3 py-1.5 text-xs font-medium transition-opacity hover:opacity-90',
|
|
134
|
-
'disabled:cursor-not-allowed disabled:opacity-50'
|
|
135
|
-
)}
|
|
120
|
+
className="h-auto flex-shrink-0 rounded px-3 py-1.5 text-xs"
|
|
136
121
|
>
|
|
137
122
|
{upgrading ? t('upgrading') : t('upgrade')}
|
|
138
|
-
</
|
|
123
|
+
</Button>
|
|
139
124
|
</div>
|
|
140
125
|
);
|
|
141
126
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { ShoppingBag } from 'lucide-react';
|
|
4
|
+
import { Link } from '@/core/lib/navigation';
|
|
5
|
+
import { useCartPage } from '@/core/hooks/use-cart-page';
|
|
6
|
+
import { CartItem } from '@/ui/cart/cart-item';
|
|
7
|
+
import { CartUpgradeBanner } from '@/ui/cart/cart-upgrade-banner';
|
|
8
|
+
import { CartBundleOfferCard } from '@/ui/cart/cart-bundle-offer';
|
|
9
|
+
import { CartSummary } from '@/ui/cart/cart-summary';
|
|
10
|
+
import { CouponInput } from '@/ui/cart/coupon-input';
|
|
11
|
+
import { CartNudges } from '@/ui/cart/cart-nudges';
|
|
12
|
+
import { FreeShippingBar } from '@/ui/cart/free-shipping-bar';
|
|
13
|
+
import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
|
|
14
|
+
import { CartRecommendationSection } from '@/ui/product/recommendation-section';
|
|
15
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
16
|
+
import { Button } from '@/components/ui/button';
|
|
17
|
+
import { Card } from '@/components/ui/card';
|
|
18
|
+
import { Separator } from '@/components/ui/separator';
|
|
19
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
20
|
+
|
|
21
|
+
export function CartView() {
|
|
22
|
+
const t = useTranslations('cart');
|
|
23
|
+
const tc = useTranslations('common');
|
|
24
|
+
const { cart, cartLoading, refreshCart, itemCount, cartRecs, upgrades, bundles } = useCartPage();
|
|
25
|
+
|
|
26
|
+
if (cartLoading) {
|
|
27
|
+
return (
|
|
28
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
29
|
+
<LoadingSpinner size="lg" />
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Empty cart state
|
|
35
|
+
if (!cart || cart.items.length === 0) {
|
|
36
|
+
return (
|
|
37
|
+
<div className="mx-auto max-w-7xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
38
|
+
<ShoppingBag
|
|
39
|
+
className="text-muted-foreground mx-auto mb-4 h-16 w-16"
|
|
40
|
+
strokeWidth={1.5}
|
|
41
|
+
aria-hidden="true"
|
|
42
|
+
/>
|
|
43
|
+
<h1 className="text-foreground text-2xl font-bold">{t('emptyTitle')}</h1>
|
|
44
|
+
<p className="text-muted-foreground mt-2">{t('emptySubtitle')}</p>
|
|
45
|
+
<Button asChild size="lg" className="mt-6 rounded px-6 text-base">
|
|
46
|
+
<Link href="/products">{tc('continueShopping')}</Link>
|
|
47
|
+
</Button>
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
|
54
|
+
<h1 className="text-foreground mb-6 text-2xl font-bold">
|
|
55
|
+
{t('title')} ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
|
|
56
|
+
</h1>
|
|
57
|
+
|
|
58
|
+
{/* Reservation countdown */}
|
|
59
|
+
{cart.reservation?.hasReservation && (
|
|
60
|
+
<ReservationCountdown reservation={cart.reservation} className="mb-6" />
|
|
61
|
+
)}
|
|
62
|
+
|
|
63
|
+
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
64
|
+
{/* Cart Items */}
|
|
65
|
+
<div className="lg:col-span-2">
|
|
66
|
+
{/* Nudges */}
|
|
67
|
+
{cart.nudges && cart.nudges.length > 0 && (
|
|
68
|
+
<CartNudges nudges={cart.nudges} className="mb-4" />
|
|
69
|
+
)}
|
|
70
|
+
|
|
71
|
+
{/* Cart items */}
|
|
72
|
+
<div>
|
|
73
|
+
{cart.items.map((item) => (
|
|
74
|
+
<div key={item.id}>
|
|
75
|
+
<CartItem item={item} onUpdate={refreshCart} />
|
|
76
|
+
{upgrades?.upgrades?.[item.productId] && (
|
|
77
|
+
<CartUpgradeBanner
|
|
78
|
+
suggestion={upgrades.upgrades[item.productId]}
|
|
79
|
+
cartItem={item}
|
|
80
|
+
onUpgrade={refreshCart}
|
|
81
|
+
className="mb-2 ms-24"
|
|
82
|
+
/>
|
|
83
|
+
)}
|
|
84
|
+
</div>
|
|
85
|
+
))}
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{/* Bundle offers */}
|
|
89
|
+
{bundles?.bundles && bundles.bundles.length > 0 && (
|
|
90
|
+
<div className="mt-6 space-y-3">
|
|
91
|
+
<h3 className="text-foreground text-sm font-semibold">{t('bundleOffers')}</h3>
|
|
92
|
+
{bundles.bundles.map((offer) => (
|
|
93
|
+
<CartBundleOfferCard
|
|
94
|
+
key={offer.id}
|
|
95
|
+
offer={offer}
|
|
96
|
+
cartId={cart.id}
|
|
97
|
+
onAdd={refreshCart}
|
|
98
|
+
/>
|
|
99
|
+
))}
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
|
|
103
|
+
{/* Coupon input */}
|
|
104
|
+
<Separator className="mt-6" />
|
|
105
|
+
<div className="pt-4">
|
|
106
|
+
<CouponInput cart={cart} onUpdate={refreshCart} />
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{/* Summary sidebar */}
|
|
111
|
+
<div className="lg:col-span-1">
|
|
112
|
+
<Card className="bg-muted/50 sticky top-24 p-6 shadow-none">
|
|
113
|
+
<FreeShippingBar className="mb-4" />
|
|
114
|
+
<CartSummary />
|
|
115
|
+
|
|
116
|
+
<Button asChild size="lg" className="mt-6 w-full rounded px-6 text-sm">
|
|
117
|
+
<Link href="/checkout">{t('proceedToCheckout')}</Link>
|
|
118
|
+
</Button>
|
|
119
|
+
|
|
120
|
+
<Link
|
|
121
|
+
href="/products"
|
|
122
|
+
className="text-muted-foreground hover:text-foreground mt-3 inline-flex w-full items-center justify-center px-6 py-2 text-sm transition-colors"
|
|
123
|
+
>
|
|
124
|
+
{tc('continueShopping')}
|
|
125
|
+
</Link>
|
|
126
|
+
</Card>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{/* Cross-sell recommendations */}
|
|
131
|
+
{cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
|
|
132
|
+
<CartRecommendationSection
|
|
133
|
+
title={t('youMightAlsoNeed')}
|
|
134
|
+
items={cartRecs.recommendations}
|
|
135
|
+
className="mt-10"
|
|
136
|
+
/>
|
|
137
|
+
)}
|
|
138
|
+
</div>
|
|
139
|
+
);
|
|
140
|
+
}
|