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.
Files changed (189) hide show
  1. package/dist/index.js +171 -66
  2. package/messages/en.json +4 -1
  3. package/messages/he.json +4 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/.claude/commands/design.md +51 -0
  6. package/templates/nextjs/base/.env.local.ejs +5 -0
  7. package/templates/nextjs/base/.eslintrc.json +57 -3
  8. package/templates/nextjs/base/AGENTS.md +41 -0
  9. package/templates/nextjs/base/AI-GUIDE.md +94 -0
  10. package/templates/nextjs/base/CLAUDE.md +42 -0
  11. package/templates/nextjs/base/TRANSLATIONS.md +1 -1
  12. package/templates/nextjs/base/next.config.ts +18 -10
  13. package/templates/nextjs/base/package.json.ejs +5 -1
  14. package/templates/nextjs/base/src/app/account/page.tsx +5 -5
  15. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +1 -1
  16. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +2 -2
  17. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +1 -1
  18. package/templates/nextjs/base/src/app/auth/callback/page.tsx +4 -4
  19. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +4 -4
  20. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +2 -2
  21. package/templates/nextjs/base/src/app/cart/page.tsx +2 -173
  22. package/templates/nextjs/base/src/app/checkout/page.tsx +12 -11
  23. package/templates/nextjs/base/src/app/contact/page.tsx +3 -3
  24. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +2 -2
  25. package/templates/nextjs/base/src/app/forgot-password/page.tsx +4 -4
  26. package/templates/nextjs/base/src/app/layout.tsx.ejs +16 -14
  27. package/templates/nextjs/base/src/app/login/page.tsx +4 -4
  28. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +19 -12
  29. package/templates/nextjs/base/src/app/page.tsx +4 -4
  30. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +3 -3
  31. package/templates/nextjs/base/src/app/payment-complete/page.tsx +1 -1
  32. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +6 -6
  33. package/templates/nextjs/base/src/app/products/page.tsx +4 -623
  34. package/templates/nextjs/base/src/app/register/page.tsx +4 -4
  35. package/templates/nextjs/base/src/app/reset-password/page.tsx +5 -5
  36. package/templates/nextjs/base/src/app/sitemap.ts +1 -1
  37. package/templates/nextjs/base/src/app/verify-email/page.tsx +5 -5
  38. package/templates/nextjs/base/src/components/account/address-book.tsx +3 -3
  39. package/templates/nextjs/base/src/components/account/order-customizations.tsx +3 -3
  40. package/templates/nextjs/base/src/components/account/order-history.tsx +13 -5
  41. package/templates/nextjs/base/src/components/account/order-payment-block.tsx +2 -2
  42. package/templates/nextjs/base/src/components/account/order-shipping-block.tsx +2 -2
  43. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +2 -2
  44. package/templates/nextjs/base/src/components/account/profile-section.tsx +3 -3
  45. package/templates/nextjs/base/src/components/auth/login-form.tsx +4 -4
  46. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +4 -4
  47. package/templates/nextjs/base/src/components/auth/register-form.tsx +4 -4
  48. package/templates/nextjs/base/src/components/brainerce-bot.tsx +2 -2
  49. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +24 -3
  50. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +2 -2
  51. package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +2 -2
  52. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +4 -4
  53. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +6 -6
  54. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +3 -3
  55. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +3 -3
  56. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +3 -3
  57. package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +2 -2
  58. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +3 -3
  59. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -0
  60. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -0
  61. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -0
  62. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +278 -0
  63. package/templates/nextjs/base/src/{hooks → core/hooks}/use-search.ts +1 -1
  64. package/templates/nextjs/base/src/{lib → core/lib}/brainerce.ts.ejs +1 -1
  65. package/templates/nextjs/base/src/core/lib/image-hosts.ts +7 -0
  66. package/templates/nextjs/base/src/core/lib/product-options.ts +121 -0
  67. package/templates/nextjs/base/src/{lib → core/lib}/resolve-currency.ts +1 -1
  68. package/templates/nextjs/base/src/{lib → core/lib}/safe-redirect.ts +1 -1
  69. package/templates/nextjs/base/src/{lib → core/lib}/sanitize-html.ts +12 -0
  70. package/templates/nextjs/base/src/{lib → core/lib}/sanitize.ts.ejs +1 -1
  71. package/templates/nextjs/base/src/{lib → core/lib}/use-currency.ts +1 -1
  72. package/templates/nextjs/base/src/{providers → core/providers}/store-provider.tsx.ejs +4 -4
  73. package/templates/nextjs/base/src/middleware.ts.ejs +2 -2
  74. package/templates/nextjs/base/src/{components → ui}/cart/cart-bundle-offer.tsx +5 -5
  75. package/templates/nextjs/base/src/{components → ui}/cart/cart-item.tsx +6 -6
  76. package/templates/nextjs/base/src/{components → ui}/cart/cart-nudges.tsx +1 -1
  77. package/templates/nextjs/base/src/{components → ui}/cart/cart-summary.tsx +4 -4
  78. package/templates/nextjs/base/src/{components → ui}/cart/cart-upgrade-banner.tsx +5 -5
  79. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +149 -0
  80. package/templates/nextjs/base/src/{components → ui}/cart/coupon-input.tsx +4 -4
  81. package/templates/nextjs/base/src/{components → ui}/cart/free-shipping-bar.tsx +4 -4
  82. package/templates/nextjs/base/src/{components → ui}/cart/reservation-countdown.tsx +2 -2
  83. package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -0
  84. package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -0
  85. package/templates/nextjs/base/src/ui/home/hero-section.tsx +29 -0
  86. package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -0
  87. package/templates/nextjs/base/src/{components/content → ui/layout}/faq-section.tsx.ejs +2 -2
  88. package/templates/nextjs/base/src/{components/content → ui/layout}/header-account.tsx +3 -3
  89. package/templates/nextjs/base/src/{components/content → ui/layout}/rich-text-block.tsx.ejs +1 -1
  90. package/templates/nextjs/base/src/{components/content → ui/layout}/site-header.tsx.ejs +1 -1
  91. package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx +18 -51
  92. package/templates/nextjs/base/src/{components/products → ui/product}/discount-badge.tsx +1 -1
  93. package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx +6 -6
  94. package/templates/nextjs/base/src/{components/products → ui/product}/modifier-group-selector.tsx +10 -77
  95. package/templates/nextjs/base/src/{components/shared → ui/product}/price-display.tsx +2 -2
  96. package/templates/nextjs/base/src/{components/products → ui/product}/product-card.tsx +10 -10
  97. package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx +45 -206
  98. package/templates/nextjs/base/src/{components/products → ui/product}/product-grid.tsx +3 -3
  99. package/templates/nextjs/base/src/ui/product/product-listing.tsx +429 -0
  100. package/templates/nextjs/base/src/{components/products → ui/product}/recommendation-section.tsx +4 -4
  101. package/templates/nextjs/base/src/{components/reviews → ui/product}/review-form.tsx +2 -2
  102. package/templates/nextjs/base/src/{components/reviews → ui/product}/reviews-section.tsx +1 -1
  103. package/templates/nextjs/base/src/{components/products → ui/product}/stock-badge.tsx +2 -2
  104. package/templates/nextjs/base/src/{components/products → ui/product}/variant-selector.tsx +3 -3
  105. package/templates/nextjs/base/src/ui/shared/cdn-image.tsx +26 -0
  106. package/templates/nextjs/base/src/{components → ui}/shared/loading-spinner.tsx +2 -2
  107. package/templates/nextjs/base/tailwind.config.ts +12 -0
  108. package/templates/nextjs/designs/passepartout/app-overlay/layout.tsx.ejs +253 -0
  109. package/templates/nextjs/designs/passepartout/globals.css +412 -0
  110. package/templates/nextjs/designs/passepartout/messages-patch/en.json +87 -0
  111. package/templates/nextjs/designs/passepartout/messages-patch/he.json +87 -0
  112. package/templates/nextjs/designs/passepartout/ui/cart/cart-bundle-offer.tsx +111 -0
  113. package/templates/nextjs/designs/passepartout/ui/cart/cart-nudges.tsx +39 -0
  114. package/templates/nextjs/designs/passepartout/ui/cart/cart-upgrade-banner.tsx +141 -0
  115. package/templates/nextjs/designs/passepartout/ui/cart/cart-view.tsx +149 -0
  116. package/templates/nextjs/designs/passepartout/ui/cart/free-shipping-bar.tsx +62 -0
  117. package/templates/nextjs/designs/passepartout/ui/cart/reservation-countdown.tsx +103 -0
  118. package/templates/nextjs/designs/passepartout/ui/home/home-client.tsx +401 -0
  119. package/templates/nextjs/designs/passepartout/ui/layout/announcement-bar.tsx +139 -0
  120. package/templates/nextjs/designs/passepartout/ui/layout/cart-link.tsx +45 -0
  121. package/templates/nextjs/designs/passepartout/ui/layout/faq-section.tsx +103 -0
  122. package/templates/nextjs/designs/passepartout/ui/layout/site-footer.tsx +159 -0
  123. package/templates/nextjs/designs/passepartout/ui/layout/site-header.tsx.ejs +155 -0
  124. package/templates/nextjs/designs/passepartout/ui/layout/trust-icons.tsx +60 -0
  125. package/templates/nextjs/designs/passepartout/ui/layout/whatsapp-button.tsx +34 -0
  126. package/templates/nextjs/designs/passepartout/ui/product/discount-badge.tsx +22 -0
  127. package/templates/nextjs/designs/passepartout/ui/product/frequently-bought-together.tsx +205 -0
  128. package/templates/nextjs/designs/passepartout/ui/product/price-display.tsx +111 -0
  129. package/templates/nextjs/designs/passepartout/ui/product/product-card.tsx +265 -0
  130. package/templates/nextjs/designs/passepartout/ui/product/product-client-section.tsx +667 -0
  131. package/templates/nextjs/designs/passepartout/ui/product/product-grid.tsx +35 -0
  132. package/templates/nextjs/designs/passepartout/ui/product/product-listing.tsx +427 -0
  133. package/templates/nextjs/designs/passepartout/ui/product/recommendation-section.tsx +113 -0
  134. package/templates/nextjs/designs/passepartout/ui/product/review-form.tsx +294 -0
  135. package/templates/nextjs/designs/passepartout/ui/product/reviews-section.tsx.ejs +96 -0
  136. package/templates/nextjs/designs/passepartout/ui/product/stock-badge.tsx +52 -0
  137. package/templates/nextjs/designs/passepartout/ui/product/variant-selector.tsx +307 -0
  138. package/templates/nextjs/designs/passepartout/ui/shared/fly-to-cart.ts +85 -0
  139. package/templates/nextjs/designs/passepartout/ui/shared/loading-spinner.tsx +33 -0
  140. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +87 -0
  141. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +121 -0
  142. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +25 -0
  143. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +97 -0
  144. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +103 -0
  145. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -0
  146. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +117 -0
  147. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +52 -0
  148. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +79 -0
  149. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -0
  150. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -0
  151. package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -0
  152. package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -0
  153. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -0
  154. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -0
  155. package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -0
  156. package/templates/nextjs/ui-canvas/layout/language-switcher.tsx.ejs +74 -0
  157. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +34 -0
  158. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -0
  159. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -0
  160. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +443 -0
  161. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +17 -0
  162. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +166 -0
  163. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +163 -0
  164. package/templates/nextjs/ui-canvas/product/price-display.tsx +50 -0
  165. package/templates/nextjs/ui-canvas/product/product-card.tsx +197 -0
  166. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -0
  167. package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -0
  168. package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -0
  169. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +95 -0
  170. package/templates/nextjs/ui-canvas/product/review-form.tsx +269 -0
  171. package/templates/nextjs/ui-canvas/product/reviews-section.tsx +78 -0
  172. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +43 -0
  173. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +210 -0
  174. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +29 -0
  175. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +25 -0
  176. package/templates/nextjs/base/src/app/home-client.tsx +0 -98
  177. package/templates/nextjs/base/src/components/products/allergen-chips.tsx +0 -78
  178. /package/templates/nextjs/base/src/{lib → core/lib}/auth.ts +0 -0
  179. /package/templates/nextjs/base/src/{lib → core/lib}/csrf.ts +0 -0
  180. /package/templates/nextjs/base/src/{lib → core/lib}/navigation.tsx.ejs +0 -0
  181. /package/templates/nextjs/base/src/{lib → core/lib}/nonce.ts +0 -0
  182. /package/templates/nextjs/base/src/{lib → core/lib}/seo.ts +0 -0
  183. /package/templates/nextjs/base/src/{lib → core/lib}/store-info.ts +0 -0
  184. /package/templates/nextjs/base/src/{lib → core/lib}/translations.ts.ejs +0 -0
  185. /package/templates/nextjs/base/src/{lib → core/lib}/utils.ts +0 -0
  186. /package/templates/nextjs/base/src/{lib → core/lib}/validation.ts +0 -0
  187. /package/templates/nextjs/base/src/{components/content → ui/layout}/announcement-bar.tsx.ejs +0 -0
  188. /package/templates/nextjs/base/src/{components → ui}/layout/language-switcher.tsx.ejs +0 -0
  189. /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
+ }