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,443 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { ProductCustomizationField } from 'brainerce';
5
+ import { useTranslations } from '@/core/lib/translations';
6
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
7
+ import type { CustomizationValues } from '@/core/lib/product-options';
8
+
9
+ // The pure validation logic lives in core (zero markup) so that
10
+ // `core/hooks/use-product-page` never imports from `ui/`. Re-exported here
11
+ // for backward compatibility with existing imports.
12
+ export { validateCustomization } from '@/core/lib/product-options';
13
+ export type { CustomizationValues } from '@/core/lib/product-options';
14
+
15
+ interface CustomizationFieldsProps {
16
+ fields: ProductCustomizationField[];
17
+ values: CustomizationValues;
18
+ onChange: (values: CustomizationValues) => void;
19
+ errors?: Record<string, string>;
20
+ }
21
+
22
+ /**
23
+ * DESIGN ME — buyer-input customization fields on the product page
24
+ * (engraving text, gift date, uploaded artwork, …). Field definitions,
25
+ * values and validation errors come from useProductPage(); every input type
26
+ * below must stay functional (including the image/gallery uploads).
27
+ */
28
+ export function CustomizationFields({
29
+ fields,
30
+ values,
31
+ onChange,
32
+ errors = {},
33
+ }: CustomizationFieldsProps) {
34
+ const t = useTranslations('customization');
35
+
36
+ if (fields.length === 0) return null;
37
+
38
+ const sorted = [...fields].sort((a, b) => a.position - b.position);
39
+
40
+ const setValue = (key: string, value: unknown) => {
41
+ onChange({ ...values, [key]: value });
42
+ };
43
+
44
+ return (
45
+ <fieldset>
46
+ <legend>{t('title')}</legend>
47
+ <div>
48
+ {sorted.map((field) => (
49
+ <CustomizationFieldRow
50
+ key={field.definitionId}
51
+ field={field}
52
+ value={values[field.key]}
53
+ error={errors[field.key]}
54
+ onChange={(v) => setValue(field.key, v)}
55
+ />
56
+ ))}
57
+ </div>
58
+ </fieldset>
59
+ );
60
+ }
61
+
62
+ interface RowProps {
63
+ field: ProductCustomizationField;
64
+ value: unknown;
65
+ error?: string;
66
+ onChange: (value: unknown) => void;
67
+ }
68
+
69
+ function CustomizationFieldRow({ field, value, error, onChange }: RowProps) {
70
+ const t = useTranslations('customization');
71
+ const labelText = (
72
+ <span>
73
+ {field.name}
74
+ {field.required && <span aria-hidden="true"> *</span>}
75
+ </span>
76
+ );
77
+
78
+ const help = field.description ? <p>{field.description}</p> : null;
79
+
80
+ const errorHint = error ? <p role="alert">{error}</p> : null;
81
+
82
+ switch (field.type) {
83
+ case 'TEXTAREA':
84
+ return (
85
+ <div>
86
+ <label>
87
+ {labelText}
88
+ <textarea
89
+ value={typeof value === 'string' ? value : ''}
90
+ onChange={(e) => onChange(e.target.value)}
91
+ minLength={field.minLength ?? undefined}
92
+ maxLength={field.maxLength ?? undefined}
93
+ required={field.required}
94
+ rows={3}
95
+ />
96
+ </label>
97
+ {help}
98
+ {errorHint}
99
+ </div>
100
+ );
101
+
102
+ case 'NUMBER':
103
+ return (
104
+ <div>
105
+ <label>
106
+ {labelText}
107
+ <input
108
+ type="number"
109
+ value={typeof value === 'number' ? value : ''}
110
+ onChange={(e) =>
111
+ onChange(e.target.value === '' ? undefined : Number(e.target.value))
112
+ }
113
+ min={field.minValue ?? undefined}
114
+ max={field.maxValue ?? undefined}
115
+ required={field.required}
116
+ />
117
+ </label>
118
+ {help}
119
+ {errorHint}
120
+ </div>
121
+ );
122
+
123
+ case 'BOOLEAN':
124
+ return (
125
+ <div>
126
+ <label className="inline-flex items-center gap-2">
127
+ <input
128
+ type="checkbox"
129
+ checked={value === true}
130
+ onChange={(e) => onChange(e.target.checked)}
131
+ />
132
+ {labelText}
133
+ </label>
134
+ {help}
135
+ {errorHint}
136
+ </div>
137
+ );
138
+
139
+ case 'DATE':
140
+ return (
141
+ <div>
142
+ <label>
143
+ {labelText}
144
+ <input
145
+ type="date"
146
+ value={typeof value === 'string' ? value : ''}
147
+ onChange={(e) => onChange(e.target.value)}
148
+ required={field.required}
149
+ />
150
+ </label>
151
+ {help}
152
+ {errorHint}
153
+ </div>
154
+ );
155
+
156
+ case 'DATETIME':
157
+ return (
158
+ <div>
159
+ <label>
160
+ {labelText}
161
+ <input
162
+ type="datetime-local"
163
+ value={typeof value === 'string' ? value : ''}
164
+ onChange={(e) => onChange(e.target.value)}
165
+ required={field.required}
166
+ />
167
+ </label>
168
+ {help}
169
+ {errorHint}
170
+ </div>
171
+ );
172
+
173
+ case 'COLOR':
174
+ return (
175
+ <div>
176
+ <label>
177
+ {labelText}
178
+ <input
179
+ type="color"
180
+ value={typeof value === 'string' && value ? value : '#000000'}
181
+ onChange={(e) => onChange(e.target.value)}
182
+ required={field.required}
183
+ />
184
+ </label>
185
+ {help}
186
+ {errorHint}
187
+ </div>
188
+ );
189
+
190
+ case 'SELECT': {
191
+ const options = field.enumValues ?? [];
192
+ return (
193
+ <div>
194
+ <label>
195
+ {labelText}
196
+ <select
197
+ value={typeof value === 'string' ? value : ''}
198
+ onChange={(e) => onChange(e.target.value || undefined)}
199
+ required={field.required}
200
+ >
201
+ <option value="">{t('selectPlaceholder')}</option>
202
+ {options.map((opt) => (
203
+ <option key={opt.value} value={opt.value}>
204
+ {opt.label || opt.value}
205
+ </option>
206
+ ))}
207
+ </select>
208
+ </label>
209
+ {help}
210
+ {errorHint}
211
+ </div>
212
+ );
213
+ }
214
+
215
+ case 'MULTI_SELECT': {
216
+ const options = field.enumValues ?? [];
217
+ const current = Array.isArray(value) ? (value as string[]) : [];
218
+ const toggle = (opt: string) => {
219
+ onChange(current.includes(opt) ? current.filter((v) => v !== opt) : [...current, opt]);
220
+ };
221
+ return (
222
+ <fieldset>
223
+ <legend>{labelText}</legend>
224
+ <div>
225
+ {options.map((opt) => (
226
+ <label key={opt.value} className="inline-flex items-center gap-2">
227
+ <input
228
+ type="checkbox"
229
+ checked={current.includes(opt.value)}
230
+ onChange={() => toggle(opt.value)}
231
+ />
232
+ {opt.label || opt.value}
233
+ </label>
234
+ ))}
235
+ </div>
236
+ {help}
237
+ {errorHint}
238
+ </fieldset>
239
+ );
240
+ }
241
+
242
+ case 'IMAGE':
243
+ return (
244
+ <SingleImageField
245
+ field={field}
246
+ value={typeof value === 'string' ? value : undefined}
247
+ onChange={onChange}
248
+ labelText={labelText}
249
+ help={help}
250
+ errorHint={errorHint}
251
+ />
252
+ );
253
+
254
+ case 'GALLERY':
255
+ return (
256
+ <GalleryField
257
+ field={field}
258
+ value={Array.isArray(value) ? (value as string[]) : []}
259
+ onChange={onChange}
260
+ labelText={labelText}
261
+ help={help}
262
+ errorHint={errorHint}
263
+ />
264
+ );
265
+
266
+ case 'URL':
267
+ return (
268
+ <div>
269
+ <label>
270
+ {labelText}
271
+ <input
272
+ type="url"
273
+ value={typeof value === 'string' ? value : ''}
274
+ onChange={(e) => onChange(e.target.value)}
275
+ required={field.required}
276
+ />
277
+ </label>
278
+ {help}
279
+ {errorHint}
280
+ </div>
281
+ );
282
+
283
+ default:
284
+ return (
285
+ <div>
286
+ <label>
287
+ {labelText}
288
+ <input
289
+ type="text"
290
+ value={typeof value === 'string' ? value : ''}
291
+ onChange={(e) => onChange(e.target.value)}
292
+ minLength={field.minLength ?? undefined}
293
+ maxLength={field.maxLength ?? undefined}
294
+ required={field.required}
295
+ />
296
+ </label>
297
+ {help}
298
+ {errorHint}
299
+ </div>
300
+ );
301
+ }
302
+ }
303
+
304
+ interface FileFieldProps {
305
+ field: ProductCustomizationField;
306
+ labelText: React.ReactNode;
307
+ help: React.ReactNode;
308
+ errorHint: React.ReactNode;
309
+ onChange: (value: unknown) => void;
310
+ }
311
+
312
+ function SingleImageField({
313
+ field,
314
+ value,
315
+ onChange,
316
+ labelText,
317
+ help,
318
+ errorHint,
319
+ }: FileFieldProps & { value?: string }) {
320
+ const t = useTranslations('customization');
321
+ const [uploading, setUploading] = useState(false);
322
+ const [uploadError, setUploadError] = useState<string | null>(null);
323
+
324
+ async function handleFile(file: File | null) {
325
+ if (!file) return;
326
+ setUploading(true);
327
+ setUploadError(null);
328
+ try {
329
+ const { getClient } = await import('@/core/lib/brainerce');
330
+ const client = getClient();
331
+ const result = await client.uploadCustomizationFile(file);
332
+ onChange(result.url);
333
+ } catch (err) {
334
+ console.error('Upload failed', err);
335
+ setUploadError(t('uploadFailed'));
336
+ } finally {
337
+ setUploading(false);
338
+ }
339
+ }
340
+
341
+ return (
342
+ <div>
343
+ <label>
344
+ {labelText}
345
+ <input
346
+ type="file"
347
+ accept="image/*"
348
+ onChange={(e) => handleFile(e.target.files?.[0] ?? null)}
349
+ disabled={uploading}
350
+ required={field.required && !value}
351
+ />
352
+ </label>
353
+ {uploading && (
354
+ <span>
355
+ <LoadingSpinner size="sm" />
356
+ {t('uploading')}
357
+ </span>
358
+ )}
359
+ {value && !uploading && (
360
+ <div>
361
+ <img src={value} alt="" className="h-20 w-20" />
362
+ </div>
363
+ )}
364
+ {uploadError && <p role="alert">{uploadError}</p>}
365
+ {help}
366
+ {errorHint}
367
+ </div>
368
+ );
369
+ }
370
+
371
+ function GalleryField({
372
+ field,
373
+ value,
374
+ onChange,
375
+ labelText,
376
+ help,
377
+ errorHint,
378
+ }: FileFieldProps & { value: string[] }) {
379
+ const t = useTranslations('customization');
380
+ const [uploading, setUploading] = useState(false);
381
+ const [uploadError, setUploadError] = useState<string | null>(null);
382
+
383
+ async function handleFiles(fileList: FileList | null) {
384
+ if (!fileList || fileList.length === 0) return;
385
+ setUploading(true);
386
+ setUploadError(null);
387
+ try {
388
+ const { getClient } = await import('@/core/lib/brainerce');
389
+ const client = getClient();
390
+ const newUrls: string[] = [];
391
+ for (const file of Array.from(fileList)) {
392
+ const result = await client.uploadCustomizationFile(file);
393
+ newUrls.push(result.url);
394
+ }
395
+ onChange([...value, ...newUrls]);
396
+ } catch (err) {
397
+ console.error('Upload failed', err);
398
+ setUploadError(t('uploadFailed'));
399
+ } finally {
400
+ setUploading(false);
401
+ }
402
+ }
403
+
404
+ function removeAt(idx: number) {
405
+ onChange(value.filter((_, i) => i !== idx));
406
+ }
407
+
408
+ return (
409
+ <div>
410
+ <label>
411
+ {labelText}
412
+ <input
413
+ type="file"
414
+ accept="image/*"
415
+ multiple
416
+ onChange={(e) => handleFiles(e.target.files)}
417
+ disabled={uploading}
418
+ />
419
+ </label>
420
+ {uploading && (
421
+ <span>
422
+ <LoadingSpinner size="sm" />
423
+ {t('uploading')}
424
+ </span>
425
+ )}
426
+ {value.length > 0 && (
427
+ <ul className="flex flex-wrap gap-2">
428
+ {value.map((url, idx) => (
429
+ <li key={`${url}-${idx}`}>
430
+ <img src={url} alt="" className="h-16 w-16" />
431
+ <button type="button" onClick={() => removeAt(idx)} aria-label={t('removeImage')}>
432
+ ×
433
+ </button>
434
+ </li>
435
+ ))}
436
+ </ul>
437
+ )}
438
+ {uploadError && <p role="alert">{uploadError}</p>}
439
+ {help}
440
+ {errorHint}
441
+ </div>
442
+ );
443
+ }
@@ -0,0 +1,17 @@
1
+ import type { ProductDiscount } from 'brainerce';
2
+ import { cn } from '@/core/lib/utils';
3
+
4
+ interface DiscountBadgeProps {
5
+ discount?: ProductDiscount | null;
6
+ className?: string;
7
+ }
8
+
9
+ /**
10
+ * DESIGN ME — discount-rule badge shown on product cards; text comes from
11
+ * the product's `discount.badgeText` (merchant-configured rule).
12
+ */
13
+ export function DiscountBadge({ discount, className }: DiscountBadgeProps) {
14
+ if (!discount) return null;
15
+
16
+ return <span className={cn(className)}>{discount.badgeText}</span>;
17
+ }
@@ -0,0 +1,166 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { Product, ProductRecommendation } from 'brainerce';
6
+ import { formatPrice } from 'brainerce';
7
+ import { useCart, useStoreInfo } from '@/core/providers/store-provider';
8
+ import { useCurrency } from '@/core/lib/use-currency';
9
+ import { useTranslations } from '@/core/lib/translations';
10
+ import { cn } from '@/core/lib/utils';
11
+
12
+ interface FrequentlyBoughtTogetherProps {
13
+ items: ProductRecommendation[];
14
+ currentProduct: Product;
15
+ className?: string;
16
+ }
17
+
18
+ function getEffectivePrice(item: { basePrice: string; salePrice?: string | null }): number {
19
+ const sale = item.salePrice ? parseFloat(item.salePrice) : null;
20
+ const base = parseFloat(item.basePrice);
21
+ return sale != null && sale < base ? sale : base;
22
+ }
23
+
24
+ function ProductThumb({
25
+ name,
26
+ imageUrl,
27
+ price,
28
+ currency,
29
+ checked,
30
+ onToggle,
31
+ disabled,
32
+ }: {
33
+ name: string;
34
+ imageUrl: string | null;
35
+ price: number;
36
+ currency: string;
37
+ checked: boolean;
38
+ onToggle?: () => void;
39
+ disabled?: boolean;
40
+ }) {
41
+ return (
42
+ <label className="inline-flex items-center gap-2">
43
+ {onToggle && <input type="checkbox" checked={checked} onChange={onToggle} disabled={disabled} />}
44
+ {/* `relative` + fixed box are layout-critical for next/image fill */}
45
+ <span className="relative block h-20 w-20">
46
+ {imageUrl ? <Image src={imageUrl} alt={name} fill sizes="80px" /> : null}
47
+ </span>
48
+ <span>{name}</span>
49
+ <span>{formatPrice(price, { currency }) as string}</span>
50
+ </label>
51
+ );
52
+ }
53
+
54
+ /**
55
+ * DESIGN ME — "frequently bought together" bundle on the product page:
56
+ * current product + selectable cross-sells + combined total + add-all button;
57
+ * items come from useProductPage().recommendations.crossSells, the feature
58
+ * gate from useStoreInfo().upsell.
59
+ */
60
+ export function FrequentlyBoughtTogether({
61
+ items,
62
+ currentProduct,
63
+ className,
64
+ }: FrequentlyBoughtTogetherProps) {
65
+ // Hooks must be called unconditionally and in the same order on every
66
+ // render — keep all of them above any early `return null` branch.
67
+ const { storeInfo } = useStoreInfo();
68
+ const { refreshCart } = useCart();
69
+ const t = useTranslations('productDetail');
70
+ const currency = useCurrency();
71
+
72
+ // Only show up to 3 cross-sells
73
+ const crossSells = items.slice(0, 3);
74
+
75
+ const [selected, setSelected] = useState<Set<string>>(() => new Set(crossSells.map((i) => i.id)));
76
+ const [adding, setAdding] = useState(false);
77
+
78
+ if (!storeInfo?.upsell?.frequentlyBoughtTogetherEnabled) return null;
79
+ if (crossSells.length === 0) return null;
80
+
81
+ const currentPrice = getEffectivePrice(currentProduct);
82
+ const currentImage = currentProduct.images?.[0];
83
+ const currentImageUrl = currentImage
84
+ ? typeof currentImage === 'string'
85
+ ? currentImage
86
+ : currentImage.url
87
+ : null;
88
+
89
+ const totalPrice = crossSells
90
+ .filter((item) => selected.has(item.id))
91
+ .reduce((sum, item) => sum + getEffectivePrice(item), currentPrice);
92
+
93
+ const toggleItem = (id: string) => {
94
+ setSelected((prev) => {
95
+ const next = new Set(prev);
96
+ if (next.has(id)) {
97
+ next.delete(id);
98
+ } else {
99
+ next.add(id);
100
+ }
101
+ return next;
102
+ });
103
+ };
104
+
105
+ async function handleAddAll() {
106
+ if (adding || selected.size === 0) return;
107
+ try {
108
+ setAdding(true);
109
+ const { getClient } = await import('@/core/lib/brainerce');
110
+ const client = getClient();
111
+ const selectedItems = crossSells.filter((item) => selected.has(item.id));
112
+ for (const item of selectedItems) {
113
+ await client.smartAddToCart({ productId: item.id, quantity: 1 });
114
+ }
115
+ await refreshCart();
116
+ } catch (err) {
117
+ console.error('Failed to add items to cart:', err);
118
+ } finally {
119
+ setAdding(false);
120
+ }
121
+ }
122
+
123
+ return (
124
+ <section className={cn(className)}>
125
+ <h2>{t('frequentlyBoughtTogether')}</h2>
126
+
127
+ <div className="flex flex-wrap items-center gap-3">
128
+ {/* Current product (always included, no checkbox) */}
129
+ <ProductThumb
130
+ name={currentProduct.name}
131
+ imageUrl={currentImageUrl}
132
+ price={currentPrice}
133
+ currency={currency}
134
+ checked={true}
135
+ disabled
136
+ />
137
+
138
+ {crossSells.map((item) => {
139
+ const img = item.images?.[0];
140
+ const imgUrl = img ? (typeof img === 'string' ? img : img.url) : null;
141
+ return (
142
+ <div key={item.id} className="flex items-center gap-3">
143
+ <span aria-hidden="true">+</span>
144
+ <ProductThumb
145
+ name={item.name}
146
+ imageUrl={imgUrl}
147
+ price={getEffectivePrice(item)}
148
+ currency={currency}
149
+ checked={selected.has(item.id)}
150
+ onToggle={() => toggleItem(item.id)}
151
+ />
152
+ </div>
153
+ );
154
+ })}
155
+ </div>
156
+
157
+ {/* Total + Add button */}
158
+ <div className="flex flex-wrap items-center gap-4">
159
+ <span>{t('totalPrice', { price: formatPrice(totalPrice, { currency }) as string })}</span>
160
+ <button type="button" onClick={handleAddAll} disabled={adding || selected.size === 0}>
161
+ {adding ? t('addingAll') : t('addSelectedToCart')}
162
+ </button>
163
+ </div>
164
+ </section>
165
+ );
166
+ }