create-brainerce-store 1.50.1 → 1.51.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/dist/index.js +171 -66
- package/messages/en.json +4 -1
- package/messages/he.json +4 -1
- package/package.json +1 -1
- package/templates/nextjs/base/.claude/commands/design.md +51 -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 +94 -0
- package/templates/nextjs/base/CLAUDE.md +42 -0
- package/templates/nextjs/base/TRANSLATIONS.md +1 -1
- package/templates/nextjs/base/next.config.ts +18 -10
- package/templates/nextjs/base/package.json.ejs +5 -1
- 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/layout.tsx.ejs +16 -14
- 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 +6 -6
- 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/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/image-hosts.ts +7 -0
- 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-html.ts +12 -0
- 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/middleware.ts.ejs +2 -2
- package/templates/nextjs/base/src/{components → ui}/cart/cart-bundle-offer.tsx +5 -5
- package/templates/nextjs/base/src/{components → ui}/cart/cart-item.tsx +6 -6
- package/templates/nextjs/base/src/{components → ui}/cart/cart-nudges.tsx +1 -1
- package/templates/nextjs/base/src/{components → ui}/cart/cart-summary.tsx +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/cart-upgrade-banner.tsx +5 -5
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +149 -0
- package/templates/nextjs/base/src/{components → ui}/cart/coupon-input.tsx +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/free-shipping-bar.tsx +4 -4
- package/templates/nextjs/base/src/{components → ui}/cart/reservation-countdown.tsx +2 -2
- 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 +29 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -0
- package/templates/nextjs/base/src/{components/content → ui/layout}/faq-section.tsx.ejs +2 -2
- package/templates/nextjs/base/src/{components/content → ui/layout}/header-account.tsx +3 -3
- 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-header.tsx.ejs +1 -1
- package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx +18 -51
- package/templates/nextjs/base/src/{components/products → ui/product}/discount-badge.tsx +1 -1
- package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx +6 -6
- package/templates/nextjs/base/src/{components/products → ui/product}/modifier-group-selector.tsx +10 -77
- package/templates/nextjs/base/src/{components/shared → ui/product}/price-display.tsx +2 -2
- package/templates/nextjs/base/src/{components/products → ui/product}/product-card.tsx +10 -10
- package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx +45 -206
- package/templates/nextjs/base/src/{components/products → ui/product}/product-grid.tsx +3 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +429 -0
- package/templates/nextjs/base/src/{components/products → ui/product}/recommendation-section.tsx +4 -4
- package/templates/nextjs/base/src/{components/reviews → ui/product}/review-form.tsx +2 -2
- package/templates/nextjs/base/src/{components/reviews → ui/product}/reviews-section.tsx +1 -1
- package/templates/nextjs/base/src/{components/products → ui/product}/stock-badge.tsx +2 -2
- package/templates/nextjs/base/src/{components/products → ui/product}/variant-selector.tsx +3 -3
- package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -0
- package/templates/nextjs/base/src/{components → ui}/shared/loading-spinner.tsx +2 -2
- package/templates/nextjs/base/tailwind.config.ts +12 -0
- package/templates/nextjs/designs/passepartout/app-overlay/layout.tsx.ejs +253 -0
- package/templates/nextjs/designs/passepartout/globals.css +412 -0
- package/templates/nextjs/designs/passepartout/messages-patch/en.json +87 -0
- package/templates/nextjs/designs/passepartout/messages-patch/he.json +87 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-bundle-offer.tsx +111 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +39 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-upgrade-banner.tsx +141 -0
- package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +149 -0
- package/templates/nextjs/designs/passepartout/ui/cart/free-shipping-bar.tsx +62 -0
- package/templates/nextjs/designs/passepartout/ui/cart/reservation-countdown.tsx +103 -0
- package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +401 -0
- package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +139 -0
- package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +45 -0
- package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +103 -0
- package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +159 -0
- package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +155 -0
- package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +60 -0
- package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +34 -0
- package/templates/nextjs/designs/passepartout/ui/product/discount-badge.tsx +22 -0
- package/templates/nextjs/designs/passepartout/ui/product/frequently-bought-together.tsx +205 -0
- package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +111 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +265 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +667 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +35 -0
- package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +427 -0
- package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +113 -0
- package/templates/nextjs/designs/passepartout/ui/product/review-form.tsx +294 -0
- package/templates/nextjs/designs/passepartout/ui/product/reviews-section.tsx.ejs +96 -0
- package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +52 -0
- package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +307 -0
- package/templates/nextjs/designs/passepartout/ui/shared/fly-to-cart.ts +85 -0
- package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +33 -0
- package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +87 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +121 -0
- package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +25 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +97 -0
- package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +103 -0
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -0
- package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +117 -0
- package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +52 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +79 -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/ui-canvas/layout/language-switcher.tsx.ejs +74 -0
- package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +34 -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 +443 -0
- package/templates/nextjs/ui-canvas/product/discount-badge.tsx +17 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +166 -0
- package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +163 -0
- package/templates/nextjs/ui-canvas/product/price-display.tsx +50 -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 +95 -0
- package/templates/nextjs/ui-canvas/product/review-form.tsx +269 -0
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx +78 -0
- package/templates/nextjs/ui-canvas/product/stock-badge.tsx +43 -0
- package/templates/nextjs/ui-canvas/product/variant-selector.tsx +210 -0
- package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +29 -0
- package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +25 -0
- package/templates/nextjs/base/src/app/home-client.tsx +0 -98
- package/templates/nextjs/base/src/components/products/allergen-chips.tsx +0 -78
- /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}/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}/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
- /package/templates/nextjs/base/src/{components/content → ui/layout}/announcement-bar.tsx.ejs +0 -0
- /package/templates/nextjs/base/src/{components → ui}/layout/language-switcher.tsx.ejs +0 -0
- /package/templates/nextjs/base/src/{components/content → ui/layout}/site-footer.tsx.ejs +0 -0
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useMemo } from 'react';
|
|
4
|
+
import type { Product, ProductVariant } from 'brainerce';
|
|
5
|
+
import { getVariantOptions, getProductSwatches, formatPrice } from 'brainerce';
|
|
6
|
+
import type { InventoryInfo } from 'brainerce';
|
|
7
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
8
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
9
|
+
import { cn } from '@/core/lib/utils';
|
|
10
|
+
|
|
11
|
+
interface VariantSelectorProps {
|
|
12
|
+
product: Product;
|
|
13
|
+
selectedVariant: ProductVariant | null;
|
|
14
|
+
onVariantChange: (variant: ProductVariant) => void;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface AttributeGroup {
|
|
19
|
+
name: string;
|
|
20
|
+
displayType: string;
|
|
21
|
+
values: Array<{
|
|
22
|
+
value: string;
|
|
23
|
+
swatchColor?: string | null;
|
|
24
|
+
swatchColor2?: string | null;
|
|
25
|
+
swatchImageUrl?: string | null;
|
|
26
|
+
variants: ProductVariant[];
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function VariantSelector({
|
|
31
|
+
product,
|
|
32
|
+
selectedVariant,
|
|
33
|
+
onVariantChange,
|
|
34
|
+
className,
|
|
35
|
+
}: VariantSelectorProps) {
|
|
36
|
+
const t = useTranslations('productDetail');
|
|
37
|
+
const currency = useCurrency();
|
|
38
|
+
const variants = useMemo(() => product.variants || [], [product.variants]);
|
|
39
|
+
|
|
40
|
+
// Get swatch metadata from product attribute options
|
|
41
|
+
const swatchData = useMemo(() => getProductSwatches(product), [product]);
|
|
42
|
+
const swatchMap = useMemo(() => {
|
|
43
|
+
const map = new Map<
|
|
44
|
+
string,
|
|
45
|
+
{
|
|
46
|
+
displayType: string;
|
|
47
|
+
options: Map<
|
|
48
|
+
string,
|
|
49
|
+
{
|
|
50
|
+
swatchColor?: string | null;
|
|
51
|
+
swatchColor2?: string | null;
|
|
52
|
+
swatchImageUrl?: string | null;
|
|
53
|
+
}
|
|
54
|
+
>;
|
|
55
|
+
}
|
|
56
|
+
>();
|
|
57
|
+
for (const attr of swatchData) {
|
|
58
|
+
const optMap = new Map<
|
|
59
|
+
string,
|
|
60
|
+
{
|
|
61
|
+
swatchColor?: string | null;
|
|
62
|
+
swatchColor2?: string | null;
|
|
63
|
+
swatchImageUrl?: string | null;
|
|
64
|
+
}
|
|
65
|
+
>();
|
|
66
|
+
for (const opt of attr.options) {
|
|
67
|
+
optMap.set(opt.name, {
|
|
68
|
+
swatchColor: opt.swatchColor,
|
|
69
|
+
swatchColor2: opt.swatchColor2,
|
|
70
|
+
swatchImageUrl: opt.swatchImageUrl,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
map.set(attr.attributeName, { displayType: attr.displayType, options: optMap });
|
|
74
|
+
}
|
|
75
|
+
return map;
|
|
76
|
+
}, [swatchData]);
|
|
77
|
+
|
|
78
|
+
// Build attribute groups from variant data, enriched with swatch info
|
|
79
|
+
const attributeGroups = useMemo<AttributeGroup[]>(() => {
|
|
80
|
+
const groups = new Map<string, Map<string, ProductVariant[]>>();
|
|
81
|
+
|
|
82
|
+
for (const variant of variants) {
|
|
83
|
+
const options = getVariantOptions(variant);
|
|
84
|
+
for (const { name, value } of options) {
|
|
85
|
+
if (!groups.has(name)) {
|
|
86
|
+
groups.set(name, new Map());
|
|
87
|
+
}
|
|
88
|
+
const valuesMap = groups.get(name)!;
|
|
89
|
+
if (!valuesMap.has(value)) {
|
|
90
|
+
valuesMap.set(value, []);
|
|
91
|
+
}
|
|
92
|
+
valuesMap.get(value)!.push(variant);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return Array.from(groups.entries()).map(([name, valuesMap]) => {
|
|
97
|
+
const attrSwatch = swatchMap.get(name);
|
|
98
|
+
return {
|
|
99
|
+
name,
|
|
100
|
+
displayType: attrSwatch?.displayType || 'DROPDOWN',
|
|
101
|
+
values: Array.from(valuesMap.entries()).map(([value, variantList]) => {
|
|
102
|
+
const optSwatch = attrSwatch?.options.get(value);
|
|
103
|
+
return {
|
|
104
|
+
value,
|
|
105
|
+
swatchColor: optSwatch?.swatchColor,
|
|
106
|
+
swatchColor2: optSwatch?.swatchColor2,
|
|
107
|
+
swatchImageUrl: optSwatch?.swatchImageUrl,
|
|
108
|
+
variants: variantList,
|
|
109
|
+
};
|
|
110
|
+
}),
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
}, [variants, swatchMap]);
|
|
114
|
+
|
|
115
|
+
// Get currently selected attribute values
|
|
116
|
+
const selectedOptions = useMemo(() => {
|
|
117
|
+
if (!selectedVariant) return new Map<string, string>();
|
|
118
|
+
const opts = getVariantOptions(selectedVariant);
|
|
119
|
+
return new Map(opts.map(({ name, value }) => [name, value]));
|
|
120
|
+
}, [selectedVariant]);
|
|
121
|
+
|
|
122
|
+
// Find the variant that matches all selected attributes
|
|
123
|
+
function findMatchingVariant(
|
|
124
|
+
attributeName: string,
|
|
125
|
+
newValue: string
|
|
126
|
+
): ProductVariant | undefined {
|
|
127
|
+
const nextSelection = new Map(selectedOptions);
|
|
128
|
+
nextSelection.set(attributeName, newValue);
|
|
129
|
+
|
|
130
|
+
return variants.find((v) => {
|
|
131
|
+
const opts = getVariantOptions(v);
|
|
132
|
+
return Array.from(nextSelection.entries()).every(([name, value]) =>
|
|
133
|
+
opts.some((o) => o.name === name && o.value === value)
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (attributeGroups.length === 0) return null;
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<div className={cn('space-y-4', className)}>
|
|
142
|
+
{attributeGroups.map((group) => (
|
|
143
|
+
<div key={group.name}>
|
|
144
|
+
<label className="placard mb-3 block font-sans">
|
|
145
|
+
{group.name}
|
|
146
|
+
{selectedOptions.get(group.name) && (
|
|
147
|
+
<span className="text-foreground ms-1">— {selectedOptions.get(group.name)}</span>
|
|
148
|
+
)}
|
|
149
|
+
</label>
|
|
150
|
+
<div className="flex flex-wrap gap-x-2 gap-y-3">
|
|
151
|
+
{group.values.map(
|
|
152
|
+
({
|
|
153
|
+
value,
|
|
154
|
+
swatchColor,
|
|
155
|
+
swatchColor2,
|
|
156
|
+
swatchImageUrl,
|
|
157
|
+
variants: matchingVariants,
|
|
158
|
+
}) => {
|
|
159
|
+
const isSelected = selectedOptions.get(group.name) === value;
|
|
160
|
+
const matchedVariant = findMatchingVariant(group.name, value);
|
|
161
|
+
const isAvailable = matchedVariant?.inventory?.canPurchase !== false;
|
|
162
|
+
|
|
163
|
+
// Color swatch rendering
|
|
164
|
+
if (group.displayType === 'COLOR_SWATCH' && swatchColor) {
|
|
165
|
+
return (
|
|
166
|
+
<button
|
|
167
|
+
key={value}
|
|
168
|
+
type="button"
|
|
169
|
+
disabled={!isAvailable}
|
|
170
|
+
title={value}
|
|
171
|
+
onClick={() => {
|
|
172
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
173
|
+
if (variant) onVariantChange(variant);
|
|
174
|
+
}}
|
|
175
|
+
className={cn(
|
|
176
|
+
'relative h-9 w-9 border transition-colors',
|
|
177
|
+
isSelected
|
|
178
|
+
? 'border-foreground'
|
|
179
|
+
: isAvailable
|
|
180
|
+
? 'border-border hover:border-foreground'
|
|
181
|
+
: 'cursor-not-allowed opacity-40'
|
|
182
|
+
)}
|
|
183
|
+
style={{
|
|
184
|
+
background: swatchColor2
|
|
185
|
+
? `linear-gradient(135deg, ${swatchColor} 50%, ${swatchColor2} 50%)`
|
|
186
|
+
: swatchColor,
|
|
187
|
+
}}
|
|
188
|
+
>
|
|
189
|
+
{isSelected && (
|
|
190
|
+
<span
|
|
191
|
+
aria-hidden="true"
|
|
192
|
+
className="bg-accent absolute -bottom-2 start-1/2 h-1 w-1 -translate-x-1/2 rounded-full rtl:translate-x-1/2"
|
|
193
|
+
/>
|
|
194
|
+
)}
|
|
195
|
+
{!isAvailable && (
|
|
196
|
+
<span
|
|
197
|
+
className="bg-muted-foreground block h-full w-full opacity-50"
|
|
198
|
+
style={{
|
|
199
|
+
backgroundImage:
|
|
200
|
+
'linear-gradient(135deg, transparent 45%, currentColor 45%, currentColor 55%, transparent 55%)',
|
|
201
|
+
}}
|
|
202
|
+
/>
|
|
203
|
+
)}
|
|
204
|
+
</button>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Image swatch rendering
|
|
209
|
+
if (group.displayType === 'IMAGE_SWATCH' && swatchImageUrl) {
|
|
210
|
+
return (
|
|
211
|
+
<button
|
|
212
|
+
key={value}
|
|
213
|
+
type="button"
|
|
214
|
+
disabled={!isAvailable}
|
|
215
|
+
title={value}
|
|
216
|
+
onClick={() => {
|
|
217
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
218
|
+
if (variant) onVariantChange(variant);
|
|
219
|
+
}}
|
|
220
|
+
className={cn(
|
|
221
|
+
'relative h-10 w-10 overflow-visible border transition-colors',
|
|
222
|
+
isSelected
|
|
223
|
+
? 'border-foreground'
|
|
224
|
+
: isAvailable
|
|
225
|
+
? 'border-border hover:border-foreground'
|
|
226
|
+
: 'cursor-not-allowed opacity-40'
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
229
|
+
<img
|
|
230
|
+
src={swatchImageUrl}
|
|
231
|
+
alt={value}
|
|
232
|
+
className="h-full w-full object-cover"
|
|
233
|
+
/>
|
|
234
|
+
{isSelected && (
|
|
235
|
+
<span
|
|
236
|
+
aria-hidden="true"
|
|
237
|
+
className="bg-accent absolute -bottom-2 start-1/2 h-1 w-1 -translate-x-1/2 rounded-full rtl:translate-x-1/2"
|
|
238
|
+
/>
|
|
239
|
+
)}
|
|
240
|
+
</button>
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Default button rendering (BUTTON, DROPDOWN, or fallback)
|
|
245
|
+
return (
|
|
246
|
+
<button
|
|
247
|
+
key={value}
|
|
248
|
+
type="button"
|
|
249
|
+
disabled={!isAvailable}
|
|
250
|
+
onClick={() => {
|
|
251
|
+
const variant = matchedVariant || matchingVariants[0];
|
|
252
|
+
if (variant) onVariantChange(variant);
|
|
253
|
+
}}
|
|
254
|
+
className={cn(
|
|
255
|
+
'relative min-h-11 border px-4 py-2.5 text-[0.8125rem] transition-colors',
|
|
256
|
+
isSelected
|
|
257
|
+
? 'border-foreground text-foreground'
|
|
258
|
+
: isAvailable
|
|
259
|
+
? 'border-border bg-surface text-foreground hover:border-foreground'
|
|
260
|
+
: 'border-border text-muted-foreground cursor-not-allowed line-through opacity-40'
|
|
261
|
+
)}
|
|
262
|
+
>
|
|
263
|
+
{value}
|
|
264
|
+
{isSelected && (
|
|
265
|
+
<span
|
|
266
|
+
aria-hidden="true"
|
|
267
|
+
className="bg-accent absolute -bottom-2 start-1/2 h-1 w-1 -translate-x-1/2 rounded-full rtl:translate-x-1/2"
|
|
268
|
+
/>
|
|
269
|
+
)}
|
|
270
|
+
</button>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
)}
|
|
274
|
+
</div>
|
|
275
|
+
</div>
|
|
276
|
+
))}
|
|
277
|
+
|
|
278
|
+
{/* Variant-specific info */}
|
|
279
|
+
{selectedVariant && (
|
|
280
|
+
<div className="text-muted-foreground tabular flex items-center gap-3 pt-1 text-[0.6875rem] tracking-[0.08em]">
|
|
281
|
+
{selectedVariant.price && (
|
|
282
|
+
<span>
|
|
283
|
+
{
|
|
284
|
+
formatPrice(selectedVariant.salePrice || selectedVariant.price, {
|
|
285
|
+
currency,
|
|
286
|
+
}) as string
|
|
287
|
+
}
|
|
288
|
+
</span>
|
|
289
|
+
)}
|
|
290
|
+
<span>{getTranslatedStockStatus(selectedVariant.inventory, t)}</span>
|
|
291
|
+
</div>
|
|
292
|
+
)}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function getTranslatedStockStatus(
|
|
298
|
+
inventory: InventoryInfo | null | undefined,
|
|
299
|
+
t: (key: string) => string
|
|
300
|
+
): string {
|
|
301
|
+
if (!inventory) return t('outOfStock');
|
|
302
|
+
const { trackingMode, inStock, available } = inventory;
|
|
303
|
+
if (trackingMode === 'DISABLED') return t('unavailable');
|
|
304
|
+
if (!inStock) return t('outOfStock');
|
|
305
|
+
if (trackingMode === 'UNLIMITED') return t('inStock');
|
|
306
|
+
return t('availableInStock').replace('{available}', String(available));
|
|
307
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* "Fly to cart" — a small round thumbnail of the product lifts off from the
|
|
3
|
+
* image and drifts into the header cart icon, which then bumps.
|
|
4
|
+
*
|
|
5
|
+
* RTL gotcha: next/image renders its <img> with `inset: 0`; a naive clone
|
|
6
|
+
* keeps that, and in an RTL document an over-constrained left+right resolves
|
|
7
|
+
* to RIGHT — pinning the clone to the wrong edge. `inset: auto` first.
|
|
8
|
+
*
|
|
9
|
+
* Pure presentation; no-ops under prefers-reduced-motion or when either end
|
|
10
|
+
* of the flight is missing.
|
|
11
|
+
*/
|
|
12
|
+
export function flyToCart(source: HTMLElement | null) {
|
|
13
|
+
if (!source || typeof document === 'undefined') return;
|
|
14
|
+
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
|
15
|
+
|
|
16
|
+
const target = document.getElementById('cart-anchor');
|
|
17
|
+
if (!target) return;
|
|
18
|
+
|
|
19
|
+
const img = source.querySelector('img') ?? source;
|
|
20
|
+
const from = img.getBoundingClientRect();
|
|
21
|
+
const to = target.getBoundingClientRect();
|
|
22
|
+
if (from.width === 0 || to.width === 0) return;
|
|
23
|
+
|
|
24
|
+
const SIZE = 64;
|
|
25
|
+
const startX = from.left + from.width / 2 - SIZE / 2;
|
|
26
|
+
const startY = from.top + from.height / 2 - SIZE / 2;
|
|
27
|
+
|
|
28
|
+
const clone = img.cloneNode(true) as HTMLElement;
|
|
29
|
+
Object.assign(clone.style, {
|
|
30
|
+
inset: 'auto', // kill next/image's inset:0 BEFORE top/left (RTL right-wins)
|
|
31
|
+
position: 'fixed',
|
|
32
|
+
top: `${startY}px`,
|
|
33
|
+
left: `${startX}px`,
|
|
34
|
+
width: `${SIZE}px`,
|
|
35
|
+
height: `${SIZE}px`,
|
|
36
|
+
borderRadius: '9999px',
|
|
37
|
+
objectFit: 'cover',
|
|
38
|
+
zIndex: '9999',
|
|
39
|
+
pointerEvents: 'none',
|
|
40
|
+
margin: '0',
|
|
41
|
+
border: '1px solid hsl(38 18% 88%)',
|
|
42
|
+
boxShadow: '0 10px 24px -10px hsl(28 30% 22% / 0.45)',
|
|
43
|
+
background: '#fff',
|
|
44
|
+
});
|
|
45
|
+
document.body.appendChild(clone);
|
|
46
|
+
|
|
47
|
+
const dx = to.left + to.width / 2 - (startX + SIZE / 2);
|
|
48
|
+
const dy = to.top + to.height / 2 - (startY + SIZE / 2);
|
|
49
|
+
|
|
50
|
+
const flight = clone.animate(
|
|
51
|
+
[
|
|
52
|
+
{ transform: 'translate(0, 0) scale(0.6)', opacity: 0 },
|
|
53
|
+
{ transform: 'translate(0, -14px) scale(1)', opacity: 1, offset: 0.18 },
|
|
54
|
+
{
|
|
55
|
+
transform: `translate(${dx * 0.55}px, ${dy * 0.55 - 56}px) scale(0.75)`,
|
|
56
|
+
opacity: 1,
|
|
57
|
+
offset: 0.62,
|
|
58
|
+
},
|
|
59
|
+
{ transform: `translate(${dx}px, ${dy}px) scale(0.15)`, opacity: 0.4 },
|
|
60
|
+
],
|
|
61
|
+
{ duration: 750, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' }
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const land = () => {
|
|
65
|
+
clone.remove();
|
|
66
|
+
target.classList.add('cart-bump');
|
|
67
|
+
window.setTimeout(() => target.classList.remove('cart-bump'), 450);
|
|
68
|
+
};
|
|
69
|
+
flight.onfinish = land;
|
|
70
|
+
// Failsafe — never leave a stray clone in the DOM (tab switch, WAAPI hiccup)
|
|
71
|
+
window.setTimeout(() => {
|
|
72
|
+
if (clone.isConnected) land();
|
|
73
|
+
}, 1100);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Picks the first visible element matching the selector — for PDPs that
|
|
77
|
+
* render separate mobile/desktop galleries. */
|
|
78
|
+
export function firstVisible(selector: string): HTMLElement | null {
|
|
79
|
+
if (typeof document === 'undefined') return null;
|
|
80
|
+
for (const el of document.querySelectorAll<HTMLElement>(selector)) {
|
|
81
|
+
const rect = el.getBoundingClientRect();
|
|
82
|
+
if (rect.width > 0 && rect.height > 0) return el;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
4
|
+
import { cn } from '@/core/lib/utils';
|
|
5
|
+
|
|
6
|
+
interface LoadingSpinnerProps {
|
|
7
|
+
size?: 'sm' | 'md' | 'lg';
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const sizeClasses = {
|
|
12
|
+
sm: 'h-1.5 w-1.5',
|
|
13
|
+
md: 'h-2 w-2',
|
|
14
|
+
lg: 'h-2.5 w-2.5',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The breathing dot — a single brass dot pulsing opacity 0.3 → 1.
|
|
19
|
+
* A spinner, however styled, still spins; the dot is the only loading
|
|
20
|
+
* state consistent with a site where motion confirms touch.
|
|
21
|
+
*/
|
|
22
|
+
export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
|
|
23
|
+
const t = useTranslations('common');
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className={cn('breathing-dot', sizeClasses[size], className)}
|
|
27
|
+
role="status"
|
|
28
|
+
aria-label={t('loading')}
|
|
29
|
+
>
|
|
30
|
+
<span className="sr-only">{t('loading')}</span>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
|
+
import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
|
|
6
|
+
import { formatPrice } from 'brainerce';
|
|
7
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
8
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
9
|
+
import { cn } from '@/core/lib/utils';
|
|
10
|
+
|
|
11
|
+
interface CartBundleOfferCardProps {
|
|
12
|
+
offer: CartBundleOfferType;
|
|
13
|
+
cartId: string;
|
|
14
|
+
onAdd: () => void;
|
|
15
|
+
className?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* DESIGN ME — bundle offer card on the cart page: offered products with
|
|
20
|
+
* original vs. discounted prices + add-bundle button; offers come from
|
|
21
|
+
* useCartPage().bundles.
|
|
22
|
+
*/
|
|
23
|
+
export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBundleOfferCardProps) {
|
|
24
|
+
const t = useTranslations('cart');
|
|
25
|
+
const currency = useCurrency();
|
|
26
|
+
const [adding, setAdding] = useState(false);
|
|
27
|
+
|
|
28
|
+
const offered = offer.offeredProducts;
|
|
29
|
+
|
|
30
|
+
const totalOriginal = parseFloat(offer.totalOriginalPrice);
|
|
31
|
+
const totalDiscounted = parseFloat(offer.totalDiscountedPrice);
|
|
32
|
+
const discountLabel =
|
|
33
|
+
offer.discountType === 'PERCENTAGE'
|
|
34
|
+
? `${offer.discountValue}%`
|
|
35
|
+
: (formatPrice(parseFloat(offer.discountValue), { currency }) as string);
|
|
36
|
+
|
|
37
|
+
async function handleAdd() {
|
|
38
|
+
if (adding) return;
|
|
39
|
+
try {
|
|
40
|
+
setAdding(true);
|
|
41
|
+
const { getClient } = await import('@/core/lib/brainerce');
|
|
42
|
+
const client = getClient();
|
|
43
|
+
// No variant selections in the default template — products with
|
|
44
|
+
// variants are best handled with a dedicated picker per offered item.
|
|
45
|
+
await client.addBundleToCart(cartId, offer.id);
|
|
46
|
+
onAdd();
|
|
47
|
+
} catch (err) {
|
|
48
|
+
console.error('Failed to add bundle:', err);
|
|
49
|
+
} finally {
|
|
50
|
+
setAdding(false);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<article className={cn(className)}>
|
|
56
|
+
<h4>{offer.name}</h4>
|
|
57
|
+
{offer.description && <p>{offer.description}</p>}
|
|
58
|
+
|
|
59
|
+
<ul>
|
|
60
|
+
{offered.map((p) => {
|
|
61
|
+
const firstImage = p.images?.[0];
|
|
62
|
+
const imageUrl = firstImage?.url ?? null;
|
|
63
|
+
return (
|
|
64
|
+
<li key={p.id} className="flex items-center gap-3">
|
|
65
|
+
{/* `relative` + fixed box are layout-critical for next/image fill */}
|
|
66
|
+
<span className="relative block h-12 w-12">
|
|
67
|
+
{imageUrl ? <Image src={imageUrl} alt={p.name} fill sizes="48px" /> : null}
|
|
68
|
+
</span>
|
|
69
|
+
<span>{p.name}</span>
|
|
70
|
+
<s>{formatPrice(parseFloat(p.originalPrice), { currency }) as string}</s>
|
|
71
|
+
<span>{formatPrice(parseFloat(p.discountedPrice), { currency }) as string}</span>
|
|
72
|
+
</li>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
</ul>
|
|
76
|
+
|
|
77
|
+
<div className="flex items-center gap-2">
|
|
78
|
+
<s>{formatPrice(totalOriginal, { currency }) as string}</s>
|
|
79
|
+
<span>{formatPrice(totalDiscounted, { currency }) as string}</span>
|
|
80
|
+
<span>-{discountLabel}</span>
|
|
81
|
+
<button type="button" onClick={handleAdd} disabled={adding}>
|
|
82
|
+
{adding ? t('addingBundle') : t('addBundleItem')}
|
|
83
|
+
</button>
|
|
84
|
+
</div>
|
|
85
|
+
</article>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { CdnImage as Image } from '@/ui/shared/cdn-image';
|
|
5
|
+
import type { CartItem as CartItemType } from 'brainerce';
|
|
6
|
+
import { getCartItemImage, formatPrice } from 'brainerce';
|
|
7
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
8
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
9
|
+
import { useCurrency } from '@/core/lib/use-currency';
|
|
10
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
11
|
+
import { cn } from '@/core/lib/utils';
|
|
12
|
+
|
|
13
|
+
interface CartItemProps {
|
|
14
|
+
item: CartItemType;
|
|
15
|
+
onUpdate: () => void;
|
|
16
|
+
className?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* DESIGN ME — single cart line: image, name, variant, unit price, quantity
|
|
21
|
+
* controls, remove, line total; the `item` prop comes from useCartPage().cart.
|
|
22
|
+
*/
|
|
23
|
+
export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
24
|
+
const t = useTranslations('common');
|
|
25
|
+
const td = useTranslations('productDetail');
|
|
26
|
+
const currency = useCurrency();
|
|
27
|
+
const [updating, setUpdating] = useState(false);
|
|
28
|
+
const [removing, setRemoving] = useState(false);
|
|
29
|
+
|
|
30
|
+
const productName = item.product.name;
|
|
31
|
+
const imageUrl = getCartItemImage(item);
|
|
32
|
+
const variantName = item.variant?.name;
|
|
33
|
+
const unitPrice = parseFloat(item.unitPrice);
|
|
34
|
+
const lineTotal = unitPrice * item.quantity;
|
|
35
|
+
|
|
36
|
+
async function handleQuantityChange(newQuantity: number) {
|
|
37
|
+
if (newQuantity < 1 || updating) return;
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
setUpdating(true);
|
|
41
|
+
const client = getClient();
|
|
42
|
+
await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined);
|
|
43
|
+
onUpdate();
|
|
44
|
+
} catch (err) {
|
|
45
|
+
console.error('Failed to update quantity:', err);
|
|
46
|
+
} finally {
|
|
47
|
+
setUpdating(false);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function handleRemove() {
|
|
52
|
+
if (removing) return;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
setRemoving(true);
|
|
56
|
+
const client = getClient();
|
|
57
|
+
await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
|
|
58
|
+
onUpdate();
|
|
59
|
+
} catch (err) {
|
|
60
|
+
console.error('Failed to remove item:', err);
|
|
61
|
+
} finally {
|
|
62
|
+
setRemoving(false);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className={cn('flex items-start gap-4', className)}>
|
|
68
|
+
{/* Image — `relative` + fixed box are layout-critical for next/image fill */}
|
|
69
|
+
<div className="relative h-20 w-20">
|
|
70
|
+
{imageUrl ? (
|
|
71
|
+
<Image src={imageUrl} alt={productName} fill sizes="80px" />
|
|
72
|
+
) : (
|
|
73
|
+
<span className="sr-only">{productName}</span>
|
|
74
|
+
)}
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{/* Details */}
|
|
78
|
+
<div>
|
|
79
|
+
<h3>{productName}</h3>
|
|
80
|
+
|
|
81
|
+
{/* Variant name */}
|
|
82
|
+
{variantName && <p>{variantName}</p>}
|
|
83
|
+
|
|
84
|
+
{/* Unit price */}
|
|
85
|
+
<p>{formatPrice(unitPrice, { currency }) as string}</p>
|
|
86
|
+
|
|
87
|
+
{/* Quantity controls */}
|
|
88
|
+
<div className="flex items-center gap-3">
|
|
89
|
+
<div className="flex items-center gap-2">
|
|
90
|
+
<button
|
|
91
|
+
type="button"
|
|
92
|
+
onClick={() => handleQuantityChange(item.quantity - 1)}
|
|
93
|
+
disabled={updating || item.quantity <= 1}
|
|
94
|
+
aria-label={td('decreaseQuantity')}
|
|
95
|
+
>
|
|
96
|
+
-
|
|
97
|
+
</button>
|
|
98
|
+
<span aria-live="polite">
|
|
99
|
+
{updating ? <LoadingSpinner size="sm" /> : item.quantity}
|
|
100
|
+
</span>
|
|
101
|
+
<button
|
|
102
|
+
type="button"
|
|
103
|
+
onClick={() => handleQuantityChange(item.quantity + 1)}
|
|
104
|
+
disabled={updating}
|
|
105
|
+
aria-label={td('increaseQuantity')}
|
|
106
|
+
>
|
|
107
|
+
+
|
|
108
|
+
</button>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<button type="button" onClick={handleRemove} disabled={removing}>
|
|
112
|
+
{removing ? t('removing') : t('remove')}
|
|
113
|
+
</button>
|
|
114
|
+
</div>
|
|
115
|
+
</div>
|
|
116
|
+
|
|
117
|
+
{/* Line total */}
|
|
118
|
+
<span>{formatPrice(lineTotal, { currency }) as string}</span>
|
|
119
|
+
</div>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { CartNudge } from 'brainerce';
|
|
4
|
+
import { cn } from '@/core/lib/utils';
|
|
5
|
+
|
|
6
|
+
interface CartNudgesProps {
|
|
7
|
+
nudges: CartNudge[];
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* DESIGN ME — discount-rule nudges on the cart page ("add X more for Y");
|
|
13
|
+
* texts come from useCartPage().cart.nudges.
|
|
14
|
+
*/
|
|
15
|
+
export function CartNudges({ nudges, className }: CartNudgesProps) {
|
|
16
|
+
if (nudges.length === 0) return null;
|
|
17
|
+
|
|
18
|
+
return (
|
|
19
|
+
<ul className={cn(className)} aria-label="Cart suggestions">
|
|
20
|
+
{nudges.map((nudge) => (
|
|
21
|
+
<li key={nudge.ruleId}>{nudge.text}</li>
|
|
22
|
+
))}
|
|
23
|
+
</ul>
|
|
24
|
+
);
|
|
25
|
+
}
|