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,269 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import { getClient } from '@/core/lib/brainerce';
5
+ import { checkAuthStatus } from '@/core/lib/auth';
6
+ import type { MyProductReview, ProductReview } from 'brainerce';
7
+
8
+ interface ReviewFormProps {
9
+ productId: string;
10
+ }
11
+
12
+ type Stage =
13
+ | { kind: 'loading' }
14
+ | { kind: 'signed_out' }
15
+ | { kind: 'not_eligible'; reason: MyProductReview['reason'] }
16
+ | { kind: 'submit' }
17
+ | { kind: 'edit'; review: ProductReview };
18
+
19
+ /**
20
+ * Single component that decides which UI to render for a customer on a PDP:
21
+ * - Loading while we check auth state and eligibility
22
+ * - Signed out -> sign-in CTA
23
+ * - Signed in but not eligible -> "purchase this product to review"
24
+ * - Eligible without review -> submit form
25
+ * - Eligible with existing review -> edit/delete form (rating + body prefilled)
26
+ *
27
+ * DESIGN ME — review submit/edit form; auth via checkAuthStatus(), eligibility
28
+ * + existing review via getClient().getMyProductReview(productId).
29
+ */
30
+ export function ReviewForm({ productId }: ReviewFormProps) {
31
+ const [stage, setStage] = useState<Stage>({ kind: 'loading' });
32
+
33
+ useEffect(() => {
34
+ let cancelled = false;
35
+ async function load() {
36
+ const auth = await checkAuthStatus();
37
+ if (cancelled) return;
38
+ if (!auth.isLoggedIn) {
39
+ setStage({ kind: 'signed_out' });
40
+ return;
41
+ }
42
+ try {
43
+ const me = await getClient().getMyProductReview(productId);
44
+ if (cancelled) return;
45
+ if (!me.eligible) {
46
+ setStage({ kind: 'not_eligible', reason: me.reason });
47
+ return;
48
+ }
49
+ if (me.myReview) {
50
+ setStage({ kind: 'edit', review: me.myReview });
51
+ return;
52
+ }
53
+ setStage({ kind: 'submit' });
54
+ } catch {
55
+ if (!cancelled) setStage({ kind: 'not_eligible', reason: 'product_not_found' });
56
+ }
57
+ }
58
+ void load();
59
+ return () => {
60
+ cancelled = true;
61
+ };
62
+ }, [productId]);
63
+
64
+ if (stage.kind === 'loading') {
65
+ return <p role="status">Loading…</p>;
66
+ }
67
+
68
+ if (stage.kind === 'signed_out') {
69
+ return (
70
+ <p>
71
+ <a href="/account/login">Sign in</a> to leave a review for this product.
72
+ </p>
73
+ );
74
+ }
75
+
76
+ if (stage.kind === 'not_eligible') {
77
+ return <NotEligibleMessage reason={stage.reason} />;
78
+ }
79
+
80
+ if (stage.kind === 'edit') {
81
+ return (
82
+ <EditReviewBlock
83
+ productId={productId}
84
+ initial={stage.review}
85
+ onDeleted={() => setStage({ kind: 'submit' })}
86
+ />
87
+ );
88
+ }
89
+
90
+ return <SubmitReviewBlock productId={productId} />;
91
+ }
92
+
93
+ function NotEligibleMessage({ reason }: { reason: MyProductReview['reason'] }) {
94
+ let message: string;
95
+ switch (reason) {
96
+ case 'no_eligible_order':
97
+ message = 'Only customers who purchased this product can leave a review.';
98
+ break;
99
+ case 'reviews_disabled':
100
+ message = 'Reviews are not enabled for this store.';
101
+ break;
102
+ default:
103
+ message = 'You cannot review this product right now.';
104
+ }
105
+ return <p>{message}</p>;
106
+ }
107
+
108
+ function SubmitReviewBlock({ productId }: { productId: string }) {
109
+ return <ReviewEditor productId={productId} mode="create" initialRating={5} initialBody="" />;
110
+ }
111
+
112
+ function EditReviewBlock({
113
+ productId,
114
+ initial,
115
+ onDeleted,
116
+ }: {
117
+ productId: string;
118
+ initial: ProductReview;
119
+ onDeleted: () => void;
120
+ }) {
121
+ const [deleting, setDeleting] = useState(false);
122
+ const [deleteError, setDeleteError] = useState<string | null>(null);
123
+
124
+ async function handleDelete() {
125
+ if (!window.confirm('Delete your review for this product?')) return;
126
+ setDeleting(true);
127
+ setDeleteError(null);
128
+ try {
129
+ await getClient().deleteMyProductReview(productId);
130
+ onDeleted();
131
+ window.location.reload();
132
+ } catch {
133
+ setDeleteError('Could not delete your review. Please try again.');
134
+ } finally {
135
+ setDeleting(false);
136
+ }
137
+ }
138
+
139
+ return (
140
+ <div>
141
+ <ReviewEditor
142
+ productId={productId}
143
+ mode="edit"
144
+ initialRating={initial.rating}
145
+ initialBody={initial.body ?? ''}
146
+ />
147
+ <div className="flex items-center gap-2">
148
+ <p>Want to start over? You can delete your review.</p>
149
+ <button type="button" onClick={handleDelete} disabled={deleting}>
150
+ {deleting ? 'Deleting…' : 'Delete review'}
151
+ </button>
152
+ </div>
153
+ {deleteError && <p role="alert">{deleteError}</p>}
154
+ </div>
155
+ );
156
+ }
157
+
158
+ /**
159
+ * The actual form. Same shape for create and edit — only the API method
160
+ * and the heading differ. Optimistic UX: success state + page reload to
161
+ * let the server-rendered reviews list pick up the change.
162
+ */
163
+ function ReviewEditor({
164
+ productId,
165
+ mode,
166
+ initialRating,
167
+ initialBody,
168
+ }: {
169
+ productId: string;
170
+ mode: 'create' | 'edit';
171
+ initialRating: number;
172
+ initialBody: string;
173
+ }) {
174
+ const [rating, setRating] = useState(initialRating);
175
+ const [body, setBody] = useState(initialBody);
176
+ const [submitting, setSubmitting] = useState(false);
177
+ const [error, setError] = useState<string | null>(null);
178
+ const [success, setSuccess] = useState(false);
179
+
180
+ async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
181
+ event.preventDefault();
182
+ setError(null);
183
+ setSubmitting(true);
184
+ try {
185
+ const client = getClient();
186
+ const input = { rating, body: body.trim() || undefined };
187
+ if (mode === 'edit') {
188
+ await client.updateMyProductReview(productId, input);
189
+ } else {
190
+ await client.submitProductReview(productId, input);
191
+ }
192
+ setSuccess(true);
193
+ setTimeout(() => window.location.reload(), 1200);
194
+ } catch (err) {
195
+ const status = (err as { status?: number })?.status;
196
+ if (status === 409) {
197
+ setError("You've already submitted a review for this product.");
198
+ } else if (status === 403) {
199
+ setError('Only customers who purchased this product can leave a review.');
200
+ } else if (status === 429) {
201
+ setError('Too many submissions. Please try again in a few minutes.');
202
+ } else {
203
+ setError(
204
+ mode === 'edit'
205
+ ? 'Could not update your review. Please try again.'
206
+ : 'Could not submit your review. Please try again.'
207
+ );
208
+ }
209
+ } finally {
210
+ setSubmitting(false);
211
+ }
212
+ }
213
+
214
+ if (success) {
215
+ return (
216
+ <p role="status">
217
+ {mode === 'edit'
218
+ ? 'Thanks! Your review has been updated.'
219
+ : 'Thanks! Your review has been submitted.'}
220
+ </p>
221
+ );
222
+ }
223
+
224
+ return (
225
+ <form onSubmit={handleSubmit}>
226
+ <h3>{mode === 'edit' ? 'Edit your review' : 'Write a review'}</h3>
227
+
228
+ <fieldset>
229
+ <legend>Your rating</legend>
230
+ <div className="inline-flex gap-1" role="radiogroup" aria-label="Star rating">
231
+ {[1, 2, 3, 4, 5].map((n) => (
232
+ <button
233
+ key={n}
234
+ type="button"
235
+ role="radio"
236
+ aria-checked={rating === n}
237
+ onClick={() => setRating(n)}
238
+ >
239
+ {n <= rating ? '★' : '☆'}
240
+ </button>
241
+ ))}
242
+ </div>
243
+ </fieldset>
244
+
245
+ <label className="block">
246
+ Your review <span>(optional)</span>
247
+ <textarea
248
+ value={body}
249
+ onChange={(event) => setBody(event.target.value)}
250
+ maxLength={5000}
251
+ rows={4}
252
+ className="block w-full"
253
+ />
254
+ </label>
255
+
256
+ {error && <p role="alert">{error}</p>}
257
+
258
+ <button type="submit" disabled={submitting}>
259
+ {submitting
260
+ ? mode === 'edit'
261
+ ? 'Saving…'
262
+ : 'Submitting…'
263
+ : mode === 'edit'
264
+ ? 'Save changes'
265
+ : 'Submit review'}
266
+ </button>
267
+ </form>
268
+ );
269
+ }
@@ -0,0 +1,78 @@
1
+ import type { ProductReview } from 'brainerce';
2
+ import { getServerClient } from '@/core/lib/brainerce';
3
+ import { ReviewForm } from './review-form';
4
+
5
+ interface ReviewsSectionProps {
6
+ productId: string;
7
+ initialReviews?: ProductReview[];
8
+ /** Where to send users back to after submit (defaults to the current product URL). */
9
+ returnUrl?: string;
10
+ }
11
+
12
+ /**
13
+ * Renders the visible reviews for a product plus a submit form.
14
+ *
15
+ * Server component — fetches with the SDK on the server so reviews are in
16
+ * the initial HTML payload (good for SEO; JSON-LD on the same page already
17
+ * carries `aggregateRating` when reviewCount > 0).
18
+ */
19
+ export async function ReviewsSection({ productId, initialReviews }: ReviewsSectionProps) {
20
+ let reviews: ProductReview[] = initialReviews ?? [];
21
+
22
+ if (!initialReviews) {
23
+ try {
24
+ const result = await getServerClient().listProductReviews(productId, { page: 1, limit: 20 });
25
+ reviews = result.data;
26
+ } catch {
27
+ // Reviews disabled at store level or network error — render the form anyway.
28
+ reviews = [];
29
+ }
30
+ }
31
+
32
+ return (
33
+ <section aria-labelledby="reviews-heading">
34
+ {/* DESIGN ME — product reviews: server-fetched review list (SDK listProductReviews) + eligibility-aware submit form */}
35
+ <h2 id="reviews-heading">Reviews</h2>
36
+
37
+ {reviews.length === 0 ? (
38
+ <p>No reviews yet — be the first to share your experience.</p>
39
+ ) : (
40
+ <ul>
41
+ {reviews.map((review) => (
42
+ <ReviewCard key={review.id} review={review} />
43
+ ))}
44
+ </ul>
45
+ )}
46
+
47
+ <ReviewForm productId={productId} />
48
+ </section>
49
+ );
50
+ }
51
+
52
+ function ReviewCard({ review }: { review: ProductReview }) {
53
+ return (
54
+ <li>
55
+ <article>
56
+ <header className="flex flex-wrap items-center gap-2">
57
+ <Stars rating={review.rating} />
58
+ <span>{review.authorName}</span>
59
+ {review.verifiedPurchase && <span>Verified purchase</span>}
60
+ <time dateTime={review.createdAt}>{new Date(review.createdAt).toLocaleDateString()}</time>
61
+ </header>
62
+ {review.body && <p className="whitespace-pre-line break-words">{review.body}</p>}
63
+ </article>
64
+ </li>
65
+ );
66
+ }
67
+
68
+ function Stars({ rating }: { rating: number }) {
69
+ return (
70
+ <span aria-label={`${rating} of 5 stars`}>
71
+ {[1, 2, 3, 4, 5].map((n) => (
72
+ <span key={n} aria-hidden="true">
73
+ {n <= rating ? '★' : '☆'}
74
+ </span>
75
+ ))}
76
+ </span>
77
+ );
78
+ }
@@ -0,0 +1,43 @@
1
+ 'use client';
2
+
3
+ import type { InventoryInfo } from 'brainerce';
4
+ import { cn } from '@/core/lib/utils';
5
+ import { useTranslations } from '@/core/lib/translations';
6
+
7
+ interface StockBadgeProps {
8
+ inventory: InventoryInfo | null | undefined;
9
+ lowStockThreshold?: number;
10
+ className?: string;
11
+ }
12
+
13
+ /**
14
+ * DESIGN ME — stock status badge (in stock / low stock / out of stock);
15
+ * derives the label from the product/variant `inventory` prop. Keep the
16
+ * label logic; add your own tones per state.
17
+ */
18
+ export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
19
+ const t = useTranslations('productDetail');
20
+ const label = getStockLabel(inventory, lowStockThreshold, t);
21
+
22
+ return <span className={cn(className)}>{label}</span>;
23
+ }
24
+
25
+ function getStockLabel(
26
+ inventory: InventoryInfo | null | undefined,
27
+ lowStockThreshold: number,
28
+ t: (key: string) => string
29
+ ): string {
30
+ if (!inventory) return t('outOfStock');
31
+
32
+ const { trackingMode, inStock, available } = inventory;
33
+
34
+ if (trackingMode === 'DISABLED') return t('unavailable');
35
+ if (!inStock) return t('outOfStock');
36
+ if (trackingMode === 'UNLIMITED') return t('inStock');
37
+
38
+ // TRACKED — show actual quantity
39
+ if (available <= lowStockThreshold) {
40
+ return t('onlyLeft').replace('{available}', String(available));
41
+ }
42
+ return t('availableInStock').replace('{available}', String(available));
43
+ }
@@ -0,0 +1,210 @@
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
+ /**
31
+ * DESIGN ME — variant selector on the product page: one group per attribute
32
+ * (Size, Color, …) with selectable values. Selection state and the matching
33
+ * logic below are the contract; the merchant's swatch metadata
34
+ * (`displayType`, `swatchColor`, `swatchColor2`, `swatchImageUrl`) is
35
+ * computed per value — the skeleton renders plain buttons, use the swatch
36
+ * data to build color/image swatches in your design.
37
+ */
38
+ export function VariantSelector({
39
+ product,
40
+ selectedVariant,
41
+ onVariantChange,
42
+ className,
43
+ }: VariantSelectorProps) {
44
+ const t = useTranslations('productDetail');
45
+ const currency = useCurrency();
46
+ const variants = useMemo(() => product.variants || [], [product.variants]);
47
+
48
+ // Get swatch metadata from product attribute options
49
+ const swatchData = useMemo(() => getProductSwatches(product), [product]);
50
+ const swatchMap = useMemo(() => {
51
+ const map = new Map<
52
+ string,
53
+ {
54
+ displayType: string;
55
+ options: Map<
56
+ string,
57
+ {
58
+ swatchColor?: string | null;
59
+ swatchColor2?: string | null;
60
+ swatchImageUrl?: string | null;
61
+ }
62
+ >;
63
+ }
64
+ >();
65
+ for (const attr of swatchData) {
66
+ const optMap = new Map<
67
+ string,
68
+ {
69
+ swatchColor?: string | null;
70
+ swatchColor2?: string | null;
71
+ swatchImageUrl?: string | null;
72
+ }
73
+ >();
74
+ for (const opt of attr.options) {
75
+ optMap.set(opt.name, {
76
+ swatchColor: opt.swatchColor,
77
+ swatchColor2: opt.swatchColor2,
78
+ swatchImageUrl: opt.swatchImageUrl,
79
+ });
80
+ }
81
+ map.set(attr.attributeName, { displayType: attr.displayType, options: optMap });
82
+ }
83
+ return map;
84
+ }, [swatchData]);
85
+
86
+ // Build attribute groups from variant data, enriched with swatch info
87
+ const attributeGroups = useMemo<AttributeGroup[]>(() => {
88
+ const groups = new Map<string, Map<string, ProductVariant[]>>();
89
+
90
+ for (const variant of variants) {
91
+ const options = getVariantOptions(variant);
92
+ for (const { name, value } of options) {
93
+ if (!groups.has(name)) {
94
+ groups.set(name, new Map());
95
+ }
96
+ const valuesMap = groups.get(name)!;
97
+ if (!valuesMap.has(value)) {
98
+ valuesMap.set(value, []);
99
+ }
100
+ valuesMap.get(value)!.push(variant);
101
+ }
102
+ }
103
+
104
+ return Array.from(groups.entries()).map(([name, valuesMap]) => {
105
+ const attrSwatch = swatchMap.get(name);
106
+ return {
107
+ name,
108
+ displayType: attrSwatch?.displayType || 'DROPDOWN',
109
+ values: Array.from(valuesMap.entries()).map(([value, variantList]) => {
110
+ const optSwatch = attrSwatch?.options.get(value);
111
+ return {
112
+ value,
113
+ swatchColor: optSwatch?.swatchColor,
114
+ swatchColor2: optSwatch?.swatchColor2,
115
+ swatchImageUrl: optSwatch?.swatchImageUrl,
116
+ variants: variantList,
117
+ };
118
+ }),
119
+ };
120
+ });
121
+ }, [variants, swatchMap]);
122
+
123
+ // Get currently selected attribute values
124
+ const selectedOptions = useMemo(() => {
125
+ if (!selectedVariant) return new Map<string, string>();
126
+ const opts = getVariantOptions(selectedVariant);
127
+ return new Map(opts.map(({ name, value }) => [name, value]));
128
+ }, [selectedVariant]);
129
+
130
+ // Find the variant that matches all selected attributes
131
+ function findMatchingVariant(
132
+ attributeName: string,
133
+ newValue: string
134
+ ): ProductVariant | undefined {
135
+ const nextSelection = new Map(selectedOptions);
136
+ nextSelection.set(attributeName, newValue);
137
+
138
+ return variants.find((v) => {
139
+ const opts = getVariantOptions(v);
140
+ return Array.from(nextSelection.entries()).every(([name, value]) =>
141
+ opts.some((o) => o.name === name && o.value === value)
142
+ );
143
+ });
144
+ }
145
+
146
+ if (attributeGroups.length === 0) return null;
147
+
148
+ return (
149
+ <div className={cn(className)}>
150
+ {attributeGroups.map((group) => (
151
+ <fieldset key={group.name} data-display-type={group.displayType}>
152
+ <legend>
153
+ {group.name}
154
+ {selectedOptions.get(group.name) && <span>: {selectedOptions.get(group.name)}</span>}
155
+ </legend>
156
+ <div className="flex flex-wrap gap-2">
157
+ {group.values.map(({ value, variants: matchingVariants }) => {
158
+ const isSelected = selectedOptions.get(group.name) === value;
159
+ const matchedVariant = findMatchingVariant(group.name, value);
160
+ const isAvailable = matchedVariant?.inventory?.canPurchase !== false;
161
+
162
+ return (
163
+ <button
164
+ key={value}
165
+ type="button"
166
+ disabled={!isAvailable}
167
+ aria-pressed={isSelected}
168
+ onClick={() => {
169
+ const variant = matchedVariant || matchingVariants[0];
170
+ if (variant) onVariantChange(variant);
171
+ }}
172
+ >
173
+ {value}
174
+ </button>
175
+ );
176
+ })}
177
+ </div>
178
+ </fieldset>
179
+ ))}
180
+
181
+ {/* Variant-specific info */}
182
+ {selectedVariant && (
183
+ <p>
184
+ {selectedVariant.price && (
185
+ <span>
186
+ {
187
+ formatPrice(selectedVariant.salePrice || selectedVariant.price, {
188
+ currency,
189
+ }) as string
190
+ }
191
+ </span>
192
+ )}{' '}
193
+ <span>{getTranslatedStockStatus(selectedVariant.inventory, t)}</span>
194
+ </p>
195
+ )}
196
+ </div>
197
+ );
198
+ }
199
+
200
+ function getTranslatedStockStatus(
201
+ inventory: InventoryInfo | null | undefined,
202
+ t: (key: string) => string
203
+ ): string {
204
+ if (!inventory) return t('outOfStock');
205
+ const { trackingMode, inStock, available } = inventory;
206
+ if (trackingMode === 'DISABLED') return t('unavailable');
207
+ if (!inStock) return t('outOfStock');
208
+ if (trackingMode === 'UNLIMITED') return t('inStock');
209
+ return t('availableInStock').replace('{available}', String(available));
210
+ }
@@ -0,0 +1,29 @@
1
+ import Image, { type ImageProps } from 'next/image';
2
+ import { OPTIMIZED_IMAGE_HOSTS } from '@/core/lib/image-hosts';
3
+
4
+ const OPTIMIZED_HOSTS = new Set(OPTIMIZED_IMAGE_HOSTS);
5
+
6
+ function isOptimizedHost(src: ImageProps['src']): boolean {
7
+ if (typeof src !== 'string') return true; // StaticImageData — always local
8
+ try {
9
+ return OPTIMIZED_HOSTS.has(new URL(src).hostname);
10
+ } catch {
11
+ return false;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Drop-in `next/image` replacement for Brainerce-sourced images (product
17
+ * photos, blog covers). Uploaded images get re-hosted onto cdn.brainerce.com
18
+ * by the backend's async image-import pipeline, but a product/variant can
19
+ * still carry a raw upstream-merchant URL while mid-import, or if that job
20
+ * failed. next/image throws if `src`'s host isn't in `images.remotePatterns`,
21
+ * so any host outside our known CDN falls back to `unoptimized` — an
22
+ * unresized, direct-from-origin fetch — instead of crashing the page.
23
+ *
24
+ * DESIGN ME — shared image wrapper; render ALL catalog images through this
25
+ * component (not raw next/image) so unknown hosts never crash the page.
26
+ */
27
+ export function CdnImage({ unoptimized, ...props }: ImageProps) {
28
+ return <Image {...props} unoptimized={unoptimized ?? !isOptimizedHost(props.src)} />;
29
+ }
@@ -0,0 +1,25 @@
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
+ /**
12
+ * DESIGN ME — loading indicator used by every page and async button. Bare
13
+ * text placeholder; replace with your own spinner/skeleton. Keep the
14
+ * `role="status"` + sr-only label, and keep the `size` prop (callers across
15
+ * the app pass sm/md/lg).
16
+ */
17
+ export function LoadingSpinner({ size = 'md', className }: LoadingSpinnerProps) {
18
+ const t = useTranslations('common');
19
+ return (
20
+ <span role="status" data-size={size} className={cn(className)}>
21
+ <span aria-hidden="true">…</span>
22
+ <span className="sr-only">{t('loading')}</span>
23
+ </span>
24
+ );
25
+ }