create-brainerce-store 1.50.5 → 1.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (230) hide show
  1. package/README.md +72 -0
  2. package/dist/index.js +146 -100
  3. package/messages/en.json +36 -1
  4. package/messages/he.json +36 -1
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/.claude/commands/design.md +60 -0
  7. package/templates/nextjs/base/.env.local.ejs +5 -0
  8. package/templates/nextjs/base/.eslintrc.json +57 -3
  9. package/templates/nextjs/base/AGENTS.md +41 -0
  10. package/templates/nextjs/base/AI-GUIDE.md +143 -0
  11. package/templates/nextjs/base/CLAUDE.md +52 -0
  12. package/templates/nextjs/base/TRANSLATIONS.md +1 -1
  13. package/templates/nextjs/base/next.config.ts +2 -2
  14. package/templates/nextjs/base/package.json.ejs +52 -39
  15. package/templates/nextjs/base/src/app/account/page.tsx +5 -5
  16. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +1 -1
  17. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +2 -2
  18. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +1 -1
  19. package/templates/nextjs/base/src/app/auth/callback/page.tsx +4 -4
  20. package/templates/nextjs/base/src/app/blog/[slug]/page.tsx.ejs +4 -4
  21. package/templates/nextjs/base/src/app/blog/page.tsx.ejs +2 -2
  22. package/templates/nextjs/base/src/app/cart/page.tsx +2 -173
  23. package/templates/nextjs/base/src/app/checkout/page.tsx +12 -11
  24. package/templates/nextjs/base/src/app/contact/page.tsx +3 -3
  25. package/templates/nextjs/base/src/app/faq/page.tsx.ejs +2 -2
  26. package/templates/nextjs/base/src/app/forgot-password/page.tsx +4 -4
  27. package/templates/nextjs/base/src/app/globals.css +36 -30
  28. package/templates/nextjs/base/src/app/layout.tsx.ejs +12 -12
  29. package/templates/nextjs/base/src/app/login/page.tsx +4 -4
  30. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +19 -12
  31. package/templates/nextjs/base/src/app/page.tsx +4 -4
  32. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +3 -3
  33. package/templates/nextjs/base/src/app/payment-complete/page.tsx +1 -1
  34. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +6 -6
  35. package/templates/nextjs/base/src/app/products/page.tsx +4 -623
  36. package/templates/nextjs/base/src/app/register/page.tsx +4 -4
  37. package/templates/nextjs/base/src/app/reset-password/page.tsx +5 -5
  38. package/templates/nextjs/base/src/app/sitemap.ts +1 -1
  39. package/templates/nextjs/base/src/app/verify-email/page.tsx +5 -5
  40. package/templates/nextjs/base/src/components/account/address-book.tsx +3 -3
  41. package/templates/nextjs/base/src/components/account/order-customizations.tsx +3 -3
  42. package/templates/nextjs/base/src/components/account/order-history.tsx +13 -5
  43. package/templates/nextjs/base/src/components/account/order-payment-block.tsx +2 -2
  44. package/templates/nextjs/base/src/components/account/order-shipping-block.tsx +2 -2
  45. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +2 -2
  46. package/templates/nextjs/base/src/components/account/profile-section.tsx +3 -3
  47. package/templates/nextjs/base/src/components/auth/login-form.tsx +4 -4
  48. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +4 -4
  49. package/templates/nextjs/base/src/components/auth/register-form.tsx +4 -4
  50. package/templates/nextjs/base/src/components/brainerce-bot.tsx +2 -2
  51. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +24 -3
  52. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +2 -2
  53. package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +2 -2
  54. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +4 -4
  55. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +20 -16
  56. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +3 -3
  57. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +3 -3
  58. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +3 -3
  59. package/templates/nextjs/base/src/components/seo/organization-json-ld.tsx +2 -2
  60. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +3 -3
  61. package/templates/nextjs/base/src/components/ui/accordion.tsx +53 -0
  62. package/templates/nextjs/base/src/components/ui/badge.tsx +32 -0
  63. package/templates/nextjs/base/src/components/ui/button.tsx +49 -0
  64. package/templates/nextjs/base/src/components/ui/card.tsx +55 -0
  65. package/templates/nextjs/base/src/components/ui/checkbox.tsx +28 -0
  66. package/templates/nextjs/base/src/components/ui/dialog.tsx +107 -0
  67. package/templates/nextjs/base/src/components/ui/input.tsx +22 -0
  68. package/templates/nextjs/base/src/components/ui/label.tsx +21 -0
  69. package/templates/nextjs/base/src/components/ui/radio-group.tsx +38 -0
  70. package/templates/nextjs/base/src/components/ui/select.tsx +153 -0
  71. package/templates/nextjs/base/src/components/ui/separator.tsx +26 -0
  72. package/templates/nextjs/base/src/components/ui/sheet.tsx +125 -0
  73. package/templates/nextjs/base/src/components/ui/skeleton.tsx +7 -0
  74. package/templates/nextjs/base/src/components/ui/tabs.tsx +55 -0
  75. package/templates/nextjs/base/src/components/ui/textarea.tsx +21 -0
  76. package/templates/nextjs/base/src/components/ui/tooltip.tsx +32 -0
  77. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +58 -0
  78. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +51 -0
  79. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +295 -0
  80. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +278 -0
  81. package/templates/nextjs/base/src/{hooks → core/hooks}/use-search.ts +1 -1
  82. package/templates/nextjs/base/src/{lib → core/lib}/brainerce.ts.ejs +1 -1
  83. package/templates/nextjs/base/src/core/lib/product-options.ts +121 -0
  84. package/templates/nextjs/base/src/{lib → core/lib}/resolve-currency.ts +1 -1
  85. package/templates/nextjs/base/src/{lib → core/lib}/safe-redirect.ts +1 -1
  86. package/templates/nextjs/base/src/{lib → core/lib}/sanitize.ts.ejs +1 -1
  87. package/templates/nextjs/base/src/{lib → core/lib}/use-currency.ts +1 -1
  88. package/templates/nextjs/base/src/{providers → core/providers}/store-provider.tsx.ejs +4 -4
  89. package/templates/nextjs/base/src/{components → ui}/cart/cart-bundle-offer.tsx +22 -16
  90. package/templates/nextjs/base/src/{components → ui}/cart/cart-item.tsx +8 -14
  91. package/templates/nextjs/base/src/{components → ui}/cart/cart-nudges.tsx +28 -39
  92. package/templates/nextjs/base/src/{components → ui}/cart/cart-summary.tsx +7 -5
  93. package/templates/nextjs/base/src/{components → ui}/cart/cart-upgrade-banner.tsx +13 -28
  94. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +140 -0
  95. package/templates/nextjs/base/src/{components → ui}/cart/coupon-input.tsx +14 -25
  96. package/templates/nextjs/base/src/{components → ui}/cart/free-shipping-bar.tsx +6 -7
  97. package/templates/nextjs/base/src/{components → ui}/cart/reservation-countdown.tsx +95 -103
  98. package/templates/nextjs/base/src/ui/home/discount-banner-strip.tsx +20 -0
  99. package/templates/nextjs/base/src/ui/home/featured-products-section.tsx +24 -0
  100. package/templates/nextjs/base/src/ui/home/hero-section.tsx +27 -0
  101. package/templates/nextjs/base/src/ui/home/home-client.tsx +29 -0
  102. package/templates/nextjs/base/src/{components/content → ui/layout}/announcement-bar.tsx.ejs +116 -125
  103. package/templates/nextjs/base/src/ui/layout/faq-section.tsx.ejs +90 -0
  104. package/templates/nextjs/base/src/{components/content → ui/layout}/header-account.tsx +5 -20
  105. package/templates/nextjs/base/src/ui/layout/language-switcher.tsx.ejs +80 -0
  106. package/templates/nextjs/base/src/{components/content → ui/layout}/rich-text-block.tsx.ejs +1 -1
  107. package/templates/nextjs/base/src/{components/content → ui/layout}/site-footer.tsx.ejs +144 -164
  108. package/templates/nextjs/base/src/{components/content → ui/layout}/site-header.tsx.ejs +152 -182
  109. package/templates/nextjs/base/src/{components/products → ui/product}/customization-fields.tsx +457 -478
  110. package/templates/nextjs/base/src/{components/products → ui/product}/discount-badge.tsx +18 -22
  111. package/templates/nextjs/base/src/{components/products → ui/product}/frequently-bought-together.tsx +21 -33
  112. package/templates/nextjs/base/src/ui/product/modifier-group-selector.tsx +206 -0
  113. package/templates/nextjs/base/src/{components/shared → ui/product}/price-display.tsx +5 -4
  114. package/templates/nextjs/base/src/{components/products → ui/product}/product-card.tsx +238 -271
  115. package/templates/nextjs/base/src/{app/products/[slug] → ui/product}/product-client-section.tsx +455 -631
  116. package/templates/nextjs/base/src/{components/products → ui/product}/product-grid.tsx +3 -3
  117. package/templates/nextjs/base/src/ui/product/product-listing.tsx +446 -0
  118. package/templates/nextjs/base/src/{components/products → ui/product}/recommendation-section.tsx +102 -107
  119. package/templates/nextjs/base/src/{components/reviews → ui/product}/review-form.tsx +46 -45
  120. package/templates/nextjs/base/src/{components/reviews/reviews-section.tsx → ui/product/reviews-section.tsx.ejs} +38 -15
  121. package/templates/nextjs/base/src/{components/products → ui/product}/stock-badge.tsx +61 -63
  122. package/templates/nextjs/base/src/{components/products → ui/product}/variant-selector.tsx +3 -3
  123. package/templates/nextjs/base/src/{components → ui}/shared/cdn-image.tsx +1 -1
  124. package/templates/nextjs/base/src/{components → ui}/shared/loading-spinner.tsx +2 -2
  125. package/templates/nextjs/base/tailwind.config.ts +69 -30
  126. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +251 -0
  127. package/templates/nextjs/designs/atelier/globals.css +396 -0
  128. package/templates/nextjs/designs/atelier/messages-patch/en.json +99 -0
  129. package/templates/nextjs/designs/atelier/messages-patch/he.json +99 -0
  130. package/templates/nextjs/designs/atelier/ui/cart/cart-bundle-offer.tsx +110 -0
  131. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +163 -0
  132. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +140 -0
  133. package/templates/nextjs/designs/atelier/ui/cart/cart-nudges.tsx +34 -0
  134. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +107 -0
  135. package/templates/nextjs/designs/atelier/ui/cart/cart-upgrade-banner.tsx +123 -0
  136. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +147 -0
  137. package/templates/nextjs/designs/atelier/ui/cart/coupon-input.tsx +142 -0
  138. package/templates/nextjs/designs/atelier/ui/cart/free-shipping-bar.tsx +77 -0
  139. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +89 -0
  140. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +34 -0
  141. package/templates/nextjs/designs/atelier/ui/home/category-tiles.tsx +148 -0
  142. package/templates/nextjs/designs/atelier/ui/home/discount-banner-strip.tsx +27 -0
  143. package/templates/nextjs/designs/atelier/ui/home/editorial-band.tsx +73 -0
  144. package/templates/nextjs/designs/atelier/ui/home/featured-products-section.tsx +36 -0
  145. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +148 -0
  146. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +54 -0
  147. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +78 -0
  148. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +46 -0
  149. package/templates/nextjs/designs/atelier/ui/layout/announcement-bar.tsx +118 -0
  150. package/templates/nextjs/designs/atelier/ui/layout/faq-section.tsx +96 -0
  151. package/templates/nextjs/designs/atelier/ui/layout/header-account.tsx +36 -0
  152. package/templates/nextjs/designs/atelier/ui/layout/header-cart.tsx +36 -0
  153. package/templates/nextjs/designs/atelier/ui/layout/language-switcher.tsx.ejs +137 -0
  154. package/templates/nextjs/designs/atelier/ui/layout/nav-url.ts +14 -0
  155. package/templates/nextjs/designs/atelier/ui/layout/rich-text-block.tsx +34 -0
  156. package/templates/nextjs/designs/atelier/ui/layout/site-footer.tsx.ejs +141 -0
  157. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +138 -0
  158. package/templates/nextjs/designs/atelier/ui/product/customization-fields.tsx +466 -0
  159. package/templates/nextjs/designs/atelier/ui/product/discount-badge.tsx +17 -0
  160. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +196 -0
  161. package/templates/nextjs/designs/atelier/ui/product/modifier-group-selector.tsx +193 -0
  162. package/templates/nextjs/designs/atelier/ui/product/price-display.tsx +69 -0
  163. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +260 -0
  164. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +471 -0
  165. package/templates/nextjs/designs/atelier/ui/product/product-grid.tsx +47 -0
  166. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +260 -0
  167. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +95 -0
  168. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +267 -0
  169. package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +148 -0
  170. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +66 -0
  171. package/templates/nextjs/designs/atelier/ui/product/variant-selector.tsx +206 -0
  172. package/templates/nextjs/designs/atelier/ui/shared/cdn-image.tsx +29 -0
  173. package/templates/nextjs/designs/atelier/ui/shared/fly-to-cart.ts +86 -0
  174. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +269 -0
  175. package/templates/nextjs/designs/atelier/ui/shared/loading-spinner.tsx +64 -0
  176. package/templates/nextjs/designs/atelier/ui/shared/reveal.tsx +71 -0
  177. package/templates/nextjs/designs/atelier/ui/shared/select-menu.tsx +125 -0
  178. package/templates/nextjs/themes/luxury/globals.css +405 -399
  179. package/templates/nextjs/themes/minimal/globals.css +36 -30
  180. package/templates/nextjs/themes/playful/globals.css +406 -400
  181. package/templates/nextjs/ui-canvas/cart/cart-bundle-offer.tsx +89 -0
  182. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +123 -0
  183. package/templates/nextjs/ui-canvas/cart/cart-nudges.tsx +27 -0
  184. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +99 -0
  185. package/templates/nextjs/ui-canvas/cart/cart-upgrade-banner.tsx +105 -0
  186. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +106 -0
  187. package/templates/nextjs/ui-canvas/cart/coupon-input.tsx +119 -0
  188. package/templates/nextjs/ui-canvas/cart/free-shipping-bar.tsx +54 -0
  189. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +81 -0
  190. package/templates/nextjs/ui-canvas/home/discount-banner-strip.tsx +19 -0
  191. package/templates/nextjs/ui-canvas/home/featured-products-section.tsx +23 -0
  192. package/templates/nextjs/ui-canvas/home/hero-section.tsx +21 -0
  193. package/templates/nextjs/ui-canvas/home/home-client.tsx +26 -0
  194. package/templates/nextjs/ui-canvas/layout/announcement-bar.tsx.ejs +101 -0
  195. package/templates/nextjs/ui-canvas/layout/faq-section.tsx.ejs +71 -0
  196. package/templates/nextjs/ui-canvas/layout/header-account.tsx +31 -0
  197. package/templates/nextjs/{base/src/components → ui-canvas}/layout/language-switcher.tsx.ejs +76 -70
  198. package/templates/nextjs/ui-canvas/layout/rich-text-block.tsx.ejs +36 -0
  199. package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +82 -0
  200. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +119 -0
  201. package/templates/nextjs/ui-canvas/product/customization-fields.tsx +445 -0
  202. package/templates/nextjs/ui-canvas/product/discount-badge.tsx +19 -0
  203. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +168 -0
  204. package/templates/nextjs/ui-canvas/product/modifier-group-selector.tsx +165 -0
  205. package/templates/nextjs/ui-canvas/product/price-display.tsx +52 -0
  206. package/templates/nextjs/ui-canvas/product/product-card.tsx +197 -0
  207. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +352 -0
  208. package/templates/nextjs/ui-canvas/product/product-grid.tsx +27 -0
  209. package/templates/nextjs/ui-canvas/product/product-listing.tsx +224 -0
  210. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +99 -0
  211. package/templates/nextjs/ui-canvas/product/review-form.tsx +266 -0
  212. package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +96 -0
  213. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +45 -0
  214. package/templates/nextjs/ui-canvas/product/variant-selector.tsx +212 -0
  215. package/templates/nextjs/ui-canvas/shared/cdn-image.tsx +31 -0
  216. package/templates/nextjs/ui-canvas/shared/loading-spinner.tsx +27 -0
  217. package/templates/nextjs/base/src/app/home-client.tsx +0 -98
  218. package/templates/nextjs/base/src/components/content/faq-section.tsx.ejs +0 -103
  219. package/templates/nextjs/base/src/components/products/modifier-group-selector.tsx +0 -238
  220. /package/templates/nextjs/base/src/{lib → core/lib}/auth.ts +0 -0
  221. /package/templates/nextjs/base/src/{lib → core/lib}/csrf.ts +0 -0
  222. /package/templates/nextjs/base/src/{lib → core/lib}/image-hosts.ts +0 -0
  223. /package/templates/nextjs/base/src/{lib → core/lib}/navigation.tsx.ejs +0 -0
  224. /package/templates/nextjs/base/src/{lib → core/lib}/nonce.ts +0 -0
  225. /package/templates/nextjs/base/src/{lib → core/lib}/sanitize-html.ts +0 -0
  226. /package/templates/nextjs/base/src/{lib → core/lib}/seo.ts +0 -0
  227. /package/templates/nextjs/base/src/{lib → core/lib}/store-info.ts +0 -0
  228. /package/templates/nextjs/base/src/{lib → core/lib}/translations.ts.ejs +0 -0
  229. /package/templates/nextjs/base/src/{lib → core/lib}/utils.ts +0 -0
  230. /package/templates/nextjs/base/src/{lib → core/lib}/validation.ts +0 -0
@@ -0,0 +1,471 @@
1
+ 'use client';
2
+
3
+ import { useEffect } from 'react';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { Product, ProductMetafield, DownloadFile } from 'brainerce';
6
+ import { useProductPage } from '@/core/hooks/use-product-page';
7
+ import { PriceDisplay } from '@/ui/product/price-display';
8
+ import { VariantSelector } from '@/ui/product/variant-selector';
9
+ import { StockBadge } from '@/ui/product/stock-badge';
10
+ import { RecommendationSection } from '@/ui/product/recommendation-section';
11
+ import { FrequentlyBoughtTogether } from '@/ui/product/frequently-bought-together';
12
+ import { CustomizationFields } from '@/ui/product/customization-fields';
13
+ import { ModifierGroupSelector } from '@/ui/product/modifier-group-selector';
14
+ import { useTranslations } from '@/core/lib/translations';
15
+ import { flyToCart, firstVisible } from '@/ui/shared/fly-to-cart';
16
+ import { openCartDrawer } from '@/ui/cart/cart-drawer';
17
+ import { sanitizeProductHtml } from '@/core/lib/sanitize-html';
18
+ import { cn } from '@/core/lib/utils';
19
+ import {
20
+ IconBag,
21
+ IconCheck,
22
+ IconMinus,
23
+ IconPlus,
24
+ IconTruck,
25
+ IconGem,
26
+ IconDownload,
27
+ } from '@/ui/shared/icons';
28
+
29
+ /** Render a metafield value based on its type */
30
+ function MetafieldValue({ field }: { field: ProductMetafield }) {
31
+ const tc = useTranslations('common');
32
+ switch (field.type) {
33
+ case 'IMAGE': {
34
+ if (!field.value) return <span>-</span>;
35
+ return (
36
+ <img
37
+ src={field.value}
38
+ alt={field.definitionName}
39
+ className="h-16 w-16 rounded-lg border object-cover"
40
+ />
41
+ );
42
+ }
43
+ case 'GALLERY': {
44
+ let urls: string[] = [];
45
+ try {
46
+ const parsed = JSON.parse(field.value);
47
+ urls = Array.isArray(parsed)
48
+ ? parsed.filter((u: unknown) => typeof u === 'string' && u)
49
+ : [];
50
+ } catch {
51
+ urls = field.value ? [field.value] : [];
52
+ }
53
+ if (urls.length === 0) return <span>-</span>;
54
+ return (
55
+ <span className="flex flex-wrap gap-2">
56
+ {urls.map((url, i) => (
57
+ <img
58
+ key={i}
59
+ src={url}
60
+ alt={`${field.definitionName} ${i + 1}`}
61
+ className="h-16 w-16 rounded-lg border object-cover"
62
+ />
63
+ ))}
64
+ </span>
65
+ );
66
+ }
67
+ case 'URL':
68
+ return field.value ? (
69
+ <a
70
+ href={field.value}
71
+ target="_blank"
72
+ rel="noopener noreferrer"
73
+ className="text-primary underline underline-offset-2"
74
+ >
75
+ {field.value}
76
+ </a>
77
+ ) : (
78
+ <span>-</span>
79
+ );
80
+ case 'COLOR':
81
+ return field.value ? (
82
+ <span className="inline-flex items-center gap-2">
83
+ <span
84
+ aria-hidden="true"
85
+ className="inline-block h-4 w-4 rounded-full border"
86
+ style={{ backgroundColor: field.value }}
87
+ />
88
+ {field.value}
89
+ </span>
90
+ ) : (
91
+ <span>-</span>
92
+ );
93
+ case 'BOOLEAN':
94
+ return <span>{field.value === 'true' ? tc('yes') : tc('no')}</span>;
95
+ case 'DATE':
96
+ case 'DATETIME': {
97
+ if (!field.value) return <span>-</span>;
98
+ try {
99
+ const date = new Date(field.value);
100
+ return (
101
+ <span>
102
+ {field.type === 'DATETIME' ? date.toLocaleString() : date.toLocaleDateString()}
103
+ </span>
104
+ );
105
+ } catch {
106
+ return <span>{field.value}</span>;
107
+ }
108
+ }
109
+ default:
110
+ return <span>{field.value || '-'}</span>;
111
+ }
112
+ }
113
+
114
+ interface ProductClientSectionProps {
115
+ product: Product;
116
+ }
117
+
118
+ export function ProductClientSection({ product: initialProduct }: ProductClientSectionProps) {
119
+ const t = useTranslations('productDetail');
120
+
121
+ const {
122
+ product,
123
+ recommendations,
124
+ images,
125
+ selectedImageIndex,
126
+ setSelectedImageIndex,
127
+ mainImageUrl,
128
+ selectedVariant,
129
+ setSelectedVariant,
130
+ priceInfo,
131
+ inventory,
132
+ canPurchase,
133
+ description,
134
+ quantity,
135
+ setQuantity,
136
+ addingToCart,
137
+ addedMessage,
138
+ handleAddToCart,
139
+ customizationFields,
140
+ customizationValues,
141
+ setCustomizationValues,
142
+ customizationErrors,
143
+ modifierGroups,
144
+ modifierSelections,
145
+ setModifierSelection,
146
+ modifierError,
147
+ } = useProductPage(initialProduct);
148
+
149
+ // `addedMessage` flips true only after the server confirms the add (it
150
+ // stays false on validation failures), so it is the reliable moment to
151
+ // slide the mini-cart open.
152
+ useEffect(() => {
153
+ if (addedMessage) openCartDrawer();
154
+ }, [addedMessage]);
155
+
156
+ return (
157
+ <article className="container-narrow py-8 lg:py-12">
158
+ <div className="grid grid-cols-1 gap-8 lg:grid-cols-2 lg:gap-14">
159
+ {/* Image Gallery */}
160
+ <figure className="lg:sticky lg:top-24 lg:self-start">
161
+ {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
162
+ <div data-pdp-gallery className="relative aspect-square overflow-hidden rounded-3xl border bg-secondary shadow-[0_28px_60px_-30px_hsl(var(--primary)/0.3)]">
163
+ {mainImageUrl ? (
164
+ <Image
165
+ src={mainImageUrl}
166
+ alt={product.name}
167
+ fill
168
+ sizes="(max-width: 1024px) 100vw, 50vw"
169
+ priority
170
+ className="object-cover"
171
+ />
172
+ ) : (
173
+ <span className="sr-only">{product.name}</span>
174
+ )}
175
+ </div>
176
+
177
+ {/* Thumbnails */}
178
+ {images.length > 1 && (
179
+ <div className="mt-3 flex flex-wrap gap-2.5">
180
+ {images.map((img, idx) => (
181
+ <button
182
+ key={idx}
183
+ type="button"
184
+ onClick={() => setSelectedImageIndex(idx)}
185
+ aria-pressed={selectedImageIndex === idx}
186
+ className={cn(
187
+ 'relative h-[4.5rem] w-[4.5rem] overflow-hidden rounded-lg border-2 bg-secondary transition-colors',
188
+ selectedImageIndex === idx
189
+ ? 'border-primary'
190
+ : 'border-transparent hover:border-border'
191
+ )}
192
+ >
193
+ <Image
194
+ src={img.url}
195
+ alt={img.alt || `${product.name} ${idx + 1}`}
196
+ fill
197
+ sizes="72px"
198
+ className="object-cover"
199
+ />
200
+ </button>
201
+ ))}
202
+ </div>
203
+ )}
204
+ <figcaption className="sr-only">{product.name}</figcaption>
205
+ </figure>
206
+
207
+ {/* Product Info / buy box */}
208
+ <section className="max-w-xl">
209
+ {/* Categories */}
210
+ {product.categories && product.categories.length > 0 && (
211
+ <ul className="mb-3 flex flex-wrap gap-1.5">
212
+ {product.categories.map((cat) => (
213
+ <li key={cat.id} className="badge-soft">
214
+ {cat.name}
215
+ </li>
216
+ ))}
217
+ </ul>
218
+ )}
219
+
220
+ {/* Brand */}
221
+ {(product as { brands?: Array<{ id: string; name: string }> }).brands &&
222
+ (product as { brands: Array<{ id: string; name: string }> }).brands.length > 0 && (
223
+ <p className="mb-1 text-sm text-muted-foreground">
224
+ {t('by')}{' '}
225
+ <span className="font-medium text-foreground">
226
+ {(product as { brands: Array<{ id: string; name: string }> }).brands
227
+ .map((b) => b.name)
228
+ .join(', ')}
229
+ </span>
230
+ </p>
231
+ )}
232
+
233
+ {/* Title */}
234
+ <h1 className="text-3xl sm:text-4xl">{product.name}</h1>
235
+
236
+ {/* Tags */}
237
+ {(product as unknown as { tags?: Array<{ id: string; name: string }> }).tags &&
238
+ (product as unknown as { tags: Array<{ id: string; name: string }> }).tags.length >
239
+ 0 && (
240
+ <ul className="mt-2 flex flex-wrap gap-1.5 text-xs text-muted-foreground">
241
+ {(product as unknown as { tags: Array<{ id: string; name: string }> }).tags.map(
242
+ (tag) => (
243
+ <li key={tag.id}>#{tag.name}</li>
244
+ )
245
+ )}
246
+ </ul>
247
+ )}
248
+
249
+ {/* Price + stock */}
250
+ <div className="mt-5 flex flex-wrap items-center gap-4 border-b pb-6">
251
+ <PriceDisplay
252
+ price={priceInfo.originalPrice}
253
+ salePrice={priceInfo.isOnSale ? priceInfo.price : undefined}
254
+ size="lg"
255
+ />
256
+ {product.isDownloadable ? (
257
+ <span className="badge-soft">
258
+ <IconDownload size={16} />
259
+ {t('instantDownload')}
260
+ </span>
261
+ ) : (
262
+ <StockBadge inventory={inventory} lowStockThreshold={5} />
263
+ )}
264
+ </div>
265
+
266
+ {/* Downloadable files info */}
267
+ {product.isDownloadable && product.downloads && product.downloads.length > 0 && (
268
+ <section className="mt-6 rounded-xl border bg-secondary/60 p-4">
269
+ <h2 className="text-sm font-semibold">
270
+ {t('filesIncluded').replace('{count}', String(product.downloads.length))}
271
+ </h2>
272
+ <ul className="mt-2 space-y-1.5 text-sm text-muted-foreground">
273
+ {product.downloads.map((file: DownloadFile) => (
274
+ <li key={file.id} className="flex items-center gap-2">
275
+ <IconDownload size={16} />
276
+ <span>{file.name}</span>
277
+ {file.size && (
278
+ <span className="text-xs">
279
+ (
280
+ {file.size < 1024 * 1024
281
+ ? `${(file.size / 1024).toFixed(0)} KB`
282
+ : `${(file.size / (1024 * 1024)).toFixed(1)} MB`}
283
+ )
284
+ </span>
285
+ )}
286
+ </li>
287
+ ))}
288
+ </ul>
289
+ </section>
290
+ )}
291
+
292
+ {/* Variant Selector */}
293
+ {product.type === 'VARIABLE' && product.variants && product.variants.length > 0 && (
294
+ <VariantSelector
295
+ product={product}
296
+ selectedVariant={selectedVariant}
297
+ onVariantChange={setSelectedVariant}
298
+ className="mt-6"
299
+ />
300
+ )}
301
+
302
+ {/* Customization Fields (buyer input) */}
303
+ {customizationFields.length > 0 && (
304
+ <CustomizationFields
305
+ fields={customizationFields}
306
+ values={customizationValues}
307
+ onChange={setCustomizationValues}
308
+ errors={customizationErrors}
309
+ />
310
+ )}
311
+
312
+ {/* Modifier groups (gift wrap, engraving options, …) */}
313
+ {modifierGroups.length > 0 && (
314
+ <div className="mt-6 space-y-5">
315
+ {modifierGroups.map((group) => (
316
+ <ModifierGroupSelector
317
+ key={group.attachmentId ?? group.id}
318
+ group={group}
319
+ value={modifierSelections[group.id] ?? []}
320
+ onChange={(next) => setModifierSelection(group.id, next)}
321
+ disabled={addingToCart}
322
+ />
323
+ ))}
324
+ {modifierError && (
325
+ <p role="alert" className="text-sm font-medium text-destructive">
326
+ {modifierError}
327
+ </p>
328
+ )}
329
+ </div>
330
+ )}
331
+
332
+ {/* Quantity + Add to Cart */}
333
+ <div className="mt-7 flex flex-wrap items-center gap-3">
334
+ <div
335
+ className="flex h-12 items-center rounded-full border"
336
+ role="group"
337
+ aria-label={t('quantityLabel')}
338
+ >
339
+ <button
340
+ type="button"
341
+ onClick={() => setQuantity((q) => Math.max(1, q - 1))}
342
+ aria-label={t('decreaseQuantity')}
343
+ className="flex h-full w-11 items-center justify-center rounded-s-full text-foreground transition-colors hover:bg-secondary disabled:opacity-40"
344
+ disabled={quantity <= 1}
345
+ >
346
+ <IconMinus size={16} />
347
+ </button>
348
+ <span aria-live="polite" className="min-w-8 text-center text-sm font-semibold">
349
+ {quantity}
350
+ </span>
351
+ <button
352
+ type="button"
353
+ onClick={() => setQuantity((q) => q + 1)}
354
+ aria-label={t('increaseQuantity')}
355
+ className="flex h-full w-11 items-center justify-center rounded-e-full text-foreground transition-colors hover:bg-secondary"
356
+ >
357
+ <IconPlus size={16} />
358
+ </button>
359
+ </div>
360
+
361
+ <button
362
+ type="button"
363
+ onClick={() => { flyToCart(firstVisible('[data-pdp-gallery]')); handleAddToCart(); }}
364
+ disabled={!canPurchase || addingToCart}
365
+ className={cn(
366
+ 'btn-primary btn-lg flex-1 sm:min-w-64 sm:flex-none',
367
+ addedMessage && 'bg-primary/90'
368
+ )}
369
+ >
370
+ {addedMessage ? <IconCheck size={20} /> : <IconBag size={20} />}
371
+ {addingToCart
372
+ ? t('addingToCart')
373
+ : addedMessage
374
+ ? t('addedToCart')
375
+ : !canPurchase
376
+ ? t('outOfStock')
377
+ : t('addToCart')}
378
+ </button>
379
+ </div>
380
+
381
+ {/* Reassurance row */}
382
+ <ul className="mt-6 space-y-2 border-t pt-5 text-sm text-muted-foreground">
383
+ <li className="flex items-center gap-2.5">
384
+ <span className="text-primary">
385
+ <IconTruck size={20} />
386
+ </span>
387
+ {t('shippingNote')}
388
+ </li>
389
+ <li className="flex items-center gap-2.5">
390
+ <span className="text-primary">
391
+ <IconGem size={20} />
392
+ </span>
393
+ {t('guaranteeNote')}
394
+ </li>
395
+ </ul>
396
+
397
+ {/* Download after purchase note */}
398
+ {product.isDownloadable && (
399
+ <p className="mt-4 text-sm text-muted-foreground">{t('downloadAfterPurchase')}</p>
400
+ )}
401
+
402
+ {/* Description */}
403
+ {description && (
404
+ <section className="mt-8">
405
+ <h2 className="mb-3 text-xl">{t('description')}</h2>
406
+ {'html' in description ? (
407
+ <div
408
+ className="prose-store"
409
+ dangerouslySetInnerHTML={{ __html: sanitizeProductHtml(description.html) }}
410
+ />
411
+ ) : (
412
+ <p className="prose-store whitespace-pre-wrap">{description.text}</p>
413
+ )}
414
+ </section>
415
+ )}
416
+
417
+ {/* Metafields / Specifications */}
418
+ {product.metafields && product.metafields.length > 0 && (
419
+ <section className="mt-8">
420
+ <h2 className="mb-3 text-xl">{t('specifications')}</h2>
421
+ <table className="w-full overflow-hidden rounded-xl border text-sm">
422
+ <tbody className="divide-y">
423
+ {product.metafields.map((field) => (
424
+ <tr key={field.id} className="odd:bg-secondary/50">
425
+ <th
426
+ scope="row"
427
+ className="w-1/3 px-4 py-3 text-start font-medium text-muted-foreground"
428
+ >
429
+ {field.definitionName}
430
+ </th>
431
+ <td className="px-4 py-3">
432
+ <MetafieldValue field={field} />
433
+ </td>
434
+ </tr>
435
+ ))}
436
+ </tbody>
437
+ </table>
438
+ </section>
439
+ )}
440
+ </section>
441
+ </div>
442
+
443
+ {/* Frequently Bought Together (cross-sells) */}
444
+ {recommendations?.crossSells && recommendations.crossSells.length > 0 && (
445
+ <FrequentlyBoughtTogether
446
+ items={recommendations.crossSells}
447
+ currentProduct={product}
448
+ className="mt-14"
449
+ />
450
+ )}
451
+
452
+ {/* Upsells */}
453
+ {recommendations?.upsells && recommendations.upsells.length > 0 && (
454
+ <RecommendationSection
455
+ title={t('upgradeYourChoice')}
456
+ items={recommendations.upsells}
457
+ className="mt-14"
458
+ />
459
+ )}
460
+
461
+ {/* Related products */}
462
+ {recommendations?.related && recommendations.related.length > 0 && (
463
+ <RecommendationSection
464
+ title={t('similarProducts')}
465
+ items={recommendations.related}
466
+ className="mt-14"
467
+ />
468
+ )}
469
+ </article>
470
+ );
471
+ }
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+
3
+ import type { Product } from 'brainerce';
4
+ import { Link } from '@/core/lib/navigation';
5
+ import { useTranslations } from '@/core/lib/translations';
6
+ import { ProductCard } from '@/ui/product/product-card';
7
+ import { cn } from '@/core/lib/utils';
8
+ import { IconGem } from '@/ui/shared/icons';
9
+
10
+ interface ProductGridProps {
11
+ products: Product[];
12
+ className?: string;
13
+ }
14
+
15
+ export function ProductGrid({ products, className }: ProductGridProps) {
16
+ const t = useTranslations('products');
17
+ const tc = useTranslations('common');
18
+
19
+ // Designed empty state — never a bare sentence in blank space.
20
+ if (products.length === 0) {
21
+ return (
22
+ <div className="card mx-auto flex max-w-md flex-col items-center gap-3 px-8 py-14 text-center">
23
+ <span className="flex h-14 w-14 items-center justify-center rounded-full bg-secondary text-primary">
24
+ <IconGem size={28} />
25
+ </span>
26
+ <p className="font-display text-xl text-foreground">{t('emptyTitle')}</p>
27
+ <p className="text-sm text-muted-foreground">{t('emptyBody')}</p>
28
+ <Link href="/products" className="btn-soft btn-sm mt-2">
29
+ {tc('viewAll')}
30
+ </Link>
31
+ </div>
32
+ );
33
+ }
34
+
35
+ return (
36
+ <div
37
+ className={cn(
38
+ 'grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4 lg:gap-6',
39
+ className
40
+ )}
41
+ >
42
+ {products.map((product) => (
43
+ <ProductCard key={product.id} product={product} />
44
+ ))}
45
+ </div>
46
+ );
47
+ }