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,89 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { CartBundleOffer as CartBundleOfferType } from 'brainerce';
6
+ import { formatPrice } from 'brainerce';
7
+ import { useCurrency } from '@/core/lib/use-currency';
8
+ import { useTranslations } from '@/core/lib/translations';
9
+ import { cn } from '@/core/lib/utils';
10
+
11
+ interface CartBundleOfferCardProps {
12
+ offer: CartBundleOfferType;
13
+ cartId: string;
14
+ onAdd: () => void;
15
+ className?: string;
16
+ }
17
+
18
+ /**
19
+ * DESIGN ME — bundle offer card on the cart page: offered products with
20
+ * original vs. discounted prices + add-bundle button; offers come from
21
+ * useCartPage().bundles.
22
+ *
23
+ * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
24
+ */
25
+ export function CartBundleOfferCard({ offer, cartId, onAdd, className }: CartBundleOfferCardProps) {
26
+ const t = useTranslations('cart');
27
+ const currency = useCurrency();
28
+ const [adding, setAdding] = useState(false);
29
+
30
+ const offered = offer.offeredProducts;
31
+
32
+ const totalOriginal = parseFloat(offer.totalOriginalPrice);
33
+ const totalDiscounted = parseFloat(offer.totalDiscountedPrice);
34
+ const discountLabel =
35
+ offer.discountType === 'PERCENTAGE'
36
+ ? `${offer.discountValue}%`
37
+ : (formatPrice(parseFloat(offer.discountValue), { currency }) as string);
38
+
39
+ async function handleAdd() {
40
+ if (adding) return;
41
+ try {
42
+ setAdding(true);
43
+ const { getClient } = await import('@/core/lib/brainerce');
44
+ const client = getClient();
45
+ // No variant selections in the default template — products with
46
+ // variants are best handled with a dedicated picker per offered item.
47
+ await client.addBundleToCart(cartId, offer.id);
48
+ onAdd();
49
+ } catch (err) {
50
+ console.error('Failed to add bundle:', err);
51
+ } finally {
52
+ setAdding(false);
53
+ }
54
+ }
55
+
56
+ return (
57
+ <article className={cn(className)}>
58
+ <h4>{offer.name}</h4>
59
+ {offer.description && <p>{offer.description}</p>}
60
+
61
+ <ul>
62
+ {offered.map((p) => {
63
+ const firstImage = p.images?.[0];
64
+ const imageUrl = firstImage?.url ?? null;
65
+ return (
66
+ <li key={p.id} className="flex items-center gap-3">
67
+ {/* `relative` + fixed box are layout-critical for next/image fill */}
68
+ <span className="relative block h-12 w-12">
69
+ {imageUrl ? <Image src={imageUrl} alt={p.name} fill sizes="48px" /> : null}
70
+ </span>
71
+ <span>{p.name}</span>
72
+ <s>{formatPrice(parseFloat(p.originalPrice), { currency }) as string}</s>
73
+ <span>{formatPrice(parseFloat(p.discountedPrice), { currency }) as string}</span>
74
+ </li>
75
+ );
76
+ })}
77
+ </ul>
78
+
79
+ <div className="flex items-center gap-2">
80
+ <s>{formatPrice(totalOriginal, { currency }) as string}</s>
81
+ <span>{formatPrice(totalDiscounted, { currency }) as string}</span>
82
+ <span>-{discountLabel}</span>
83
+ <button type="button" onClick={handleAdd} disabled={adding}>
84
+ {adding ? t('addingBundle') : t('addBundleItem')}
85
+ </button>
86
+ </div>
87
+ </article>
88
+ );
89
+ }
@@ -0,0 +1,123 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { CartItem as CartItemType } from 'brainerce';
6
+ import { getCartItemImage, formatPrice } from 'brainerce';
7
+ import { getClient } from '@/core/lib/brainerce';
8
+ import { useTranslations } from '@/core/lib/translations';
9
+ import { useCurrency } from '@/core/lib/use-currency';
10
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
11
+ import { cn } from '@/core/lib/utils';
12
+
13
+ interface CartItemProps {
14
+ item: CartItemType;
15
+ onUpdate: () => void;
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * DESIGN ME — single cart line: image, name, variant, unit price, quantity
21
+ * controls, remove, line total; the `item` prop comes from useCartPage().cart.
22
+ *
23
+ * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
24
+ */
25
+ export function CartItem({ item, onUpdate, className }: CartItemProps) {
26
+ const t = useTranslations('common');
27
+ const td = useTranslations('productDetail');
28
+ const currency = useCurrency();
29
+ const [updating, setUpdating] = useState(false);
30
+ const [removing, setRemoving] = useState(false);
31
+
32
+ const productName = item.product.name;
33
+ const imageUrl = getCartItemImage(item);
34
+ const variantName = item.variant?.name;
35
+ const unitPrice = parseFloat(item.unitPrice);
36
+ const lineTotal = unitPrice * item.quantity;
37
+
38
+ async function handleQuantityChange(newQuantity: number) {
39
+ if (newQuantity < 1 || updating) return;
40
+
41
+ try {
42
+ setUpdating(true);
43
+ const client = getClient();
44
+ await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined);
45
+ onUpdate();
46
+ } catch (err) {
47
+ console.error('Failed to update quantity:', err);
48
+ } finally {
49
+ setUpdating(false);
50
+ }
51
+ }
52
+
53
+ async function handleRemove() {
54
+ if (removing) return;
55
+
56
+ try {
57
+ setRemoving(true);
58
+ const client = getClient();
59
+ await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
60
+ onUpdate();
61
+ } catch (err) {
62
+ console.error('Failed to remove item:', err);
63
+ } finally {
64
+ setRemoving(false);
65
+ }
66
+ }
67
+
68
+ return (
69
+ <div className={cn('flex items-start gap-4', className)}>
70
+ {/* Image — `relative` + fixed box are layout-critical for next/image fill */}
71
+ <div className="relative h-20 w-20">
72
+ {imageUrl ? (
73
+ <Image src={imageUrl} alt={productName} fill sizes="80px" />
74
+ ) : (
75
+ <span className="sr-only">{productName}</span>
76
+ )}
77
+ </div>
78
+
79
+ {/* Details */}
80
+ <div>
81
+ <h3>{productName}</h3>
82
+
83
+ {/* Variant name */}
84
+ {variantName && <p>{variantName}</p>}
85
+
86
+ {/* Unit price */}
87
+ <p>{formatPrice(unitPrice, { currency }) as string}</p>
88
+
89
+ {/* Quantity controls */}
90
+ <div className="flex items-center gap-3">
91
+ <div className="flex items-center gap-2">
92
+ <button
93
+ type="button"
94
+ onClick={() => handleQuantityChange(item.quantity - 1)}
95
+ disabled={updating || item.quantity <= 1}
96
+ aria-label={td('decreaseQuantity')}
97
+ >
98
+ -
99
+ </button>
100
+ <span aria-live="polite">
101
+ {updating ? <LoadingSpinner size="sm" /> : item.quantity}
102
+ </span>
103
+ <button
104
+ type="button"
105
+ onClick={() => handleQuantityChange(item.quantity + 1)}
106
+ disabled={updating}
107
+ aria-label={td('increaseQuantity')}
108
+ >
109
+ +
110
+ </button>
111
+ </div>
112
+
113
+ <button type="button" onClick={handleRemove} disabled={removing}>
114
+ {removing ? t('removing') : t('remove')}
115
+ </button>
116
+ </div>
117
+ </div>
118
+
119
+ {/* Line total */}
120
+ <span>{formatPrice(lineTotal, { currency }) as string}</span>
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,27 @@
1
+ 'use client';
2
+
3
+ import type { CartNudge } from 'brainerce';
4
+ import { cn } from '@/core/lib/utils';
5
+
6
+ interface CartNudgesProps {
7
+ nudges: CartNudge[];
8
+ className?: string;
9
+ }
10
+
11
+ /**
12
+ * DESIGN ME — discount-rule nudges on the cart page ("add X more for Y");
13
+ * texts come from useCartPage().cart.nudges.
14
+ *
15
+ * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
16
+ */
17
+ export function CartNudges({ nudges, className }: CartNudgesProps) {
18
+ if (nudges.length === 0) return null;
19
+
20
+ return (
21
+ <ul className={cn(className)} aria-label="Cart suggestions">
22
+ {nudges.map((nudge) => (
23
+ <li key={nudge.ruleId}>{nudge.text}</li>
24
+ ))}
25
+ </ul>
26
+ );
27
+ }
@@ -0,0 +1,99 @@
1
+ 'use client';
2
+
3
+ import { formatPrice } from 'brainerce';
4
+ import { useTranslations } from '@/core/lib/translations';
5
+ import { useCart } from '@/core/providers/store-provider';
6
+ import { useCurrency } from '@/core/lib/use-currency';
7
+ import { cn } from '@/core/lib/utils';
8
+
9
+ interface CartSummaryProps {
10
+ className?: string;
11
+ }
12
+
13
+ /**
14
+ * DESIGN ME — order summary (subtotal, rule/coupon discounts, shipping, tax
15
+ * note, total); all figures come from useCart().totals and useCart().cart.
16
+ * Keep every discount branch — merchants rely on the per-rule breakdown.
17
+ *
18
+ * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
19
+ */
20
+ export function CartSummary({ className }: CartSummaryProps) {
21
+ const t = useTranslations('cart');
22
+ const tc = useTranslations('common');
23
+ const { totals, cart } = useCart();
24
+ const currency = useCurrency();
25
+
26
+ const rules = cart?.appliedDiscounts;
27
+ const ruleAmt = cart?.ruleDiscountAmount ? parseFloat(cart.ruleDiscountAmount) : 0;
28
+ const couponAmt = totals.discount - ruleAmt;
29
+
30
+ return (
31
+ <section className={cn(className)}>
32
+ <h3>{t('orderSummary')}</h3>
33
+
34
+ <dl>
35
+ {/* Subtotal */}
36
+ <div className="flex items-center justify-between gap-4">
37
+ <dt>{tc('subtotal')}</dt>
38
+ <dd>{formatPrice(totals.subtotal, { currency }) as string}</dd>
39
+ </div>
40
+
41
+ {/* Rule discounts - show each rule by name */}
42
+ {rules && rules.length > 0
43
+ ? rules.map((rule) => (
44
+ <div key={rule.ruleId} className="flex items-center justify-between gap-4">
45
+ <dt>{rule.ruleName}</dt>
46
+ <dd>-{formatPrice(parseFloat(rule.discountAmount), { currency }) as string}</dd>
47
+ </div>
48
+ ))
49
+ : ruleAmt > 0 && (
50
+ <div className="flex items-center justify-between gap-4">
51
+ <dt>{tc('generalDiscount')}</dt>
52
+ <dd>-{formatPrice(ruleAmt, { currency }) as string}</dd>
53
+ </div>
54
+ )}
55
+
56
+ {/* Coupon discount */}
57
+ {cart?.couponCode && couponAmt > 0 && (
58
+ <div className="flex items-center justify-between gap-4">
59
+ <dt>
60
+ {tc('couponDiscount')} ({cart.couponCode})
61
+ </dt>
62
+ <dd>-{formatPrice(couponAmt, { currency }) as string}</dd>
63
+ </div>
64
+ )}
65
+
66
+ {/* Fallback: generic discount when no breakdown available */}
67
+ {totals.discount > 0 &&
68
+ ruleAmt <= 0 &&
69
+ !cart?.couponCode &&
70
+ (!rules || rules.length === 0) && (
71
+ <div className="flex items-center justify-between gap-4">
72
+ <dt>{tc('discount')}</dt>
73
+ <dd>-{formatPrice(totals.discount, { currency }) as string}</dd>
74
+ </div>
75
+ )}
76
+
77
+ {/* Shipping */}
78
+ {totals.shipping > 0 && (
79
+ <div className="flex items-center justify-between gap-4">
80
+ <dt>{tc('shipping')}</dt>
81
+ <dd>{formatPrice(totals.shipping, { currency }) as string}</dd>
82
+ </div>
83
+ )}
84
+
85
+ {/* Tax */}
86
+ <div className="flex items-center justify-between gap-4">
87
+ <dt>{tc('tax')}</dt>
88
+ <dd>{t('taxAtCheckout')}</dd>
89
+ </div>
90
+
91
+ {/* Total */}
92
+ <div className="flex items-center justify-between gap-4">
93
+ <dt>{tc('total')}</dt>
94
+ <dd>{formatPrice(totals.total, { currency }) as string}</dd>
95
+ </div>
96
+ </dl>
97
+ </section>
98
+ );
99
+ }
@@ -0,0 +1,105 @@
1
+ 'use client';
2
+
3
+ import { useState, useEffect } from 'react';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { CartUpgradeSuggestion, CartItem as CartItemType } from 'brainerce';
6
+ import { formatPrice } from 'brainerce';
7
+ import { getClient } from '@/core/lib/brainerce';
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 CartUpgradeBannerProps {
13
+ suggestion: CartUpgradeSuggestion;
14
+ cartItem: CartItemType;
15
+ onUpgrade: () => void;
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * DESIGN ME — per-line upgrade suggestion under a cart item ("upgrade to X
21
+ * for +Y"); the suggestion comes from useCartPage().upgrades keyed by
22
+ * productId. Keep the session-scoped dismiss and the swap logic.
23
+ *
24
+ * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
25
+ */
26
+ export function CartUpgradeBanner({
27
+ suggestion,
28
+ cartItem,
29
+ onUpgrade,
30
+ className,
31
+ }: CartUpgradeBannerProps) {
32
+ const t = useTranslations('cart');
33
+ const currency = useCurrency();
34
+ const [upgrading, setUpgrading] = useState(false);
35
+ const [dismissed, setDismissed] = useState(false);
36
+
37
+ const storageKey = `dismissed_upgrade_${suggestion.sourceProductId}`;
38
+
39
+ useEffect(() => {
40
+ try {
41
+ if (sessionStorage.getItem(storageKey)) {
42
+ setDismissed(true);
43
+ }
44
+ } catch {
45
+ /* ignore */
46
+ }
47
+ }, [storageKey]);
48
+
49
+ if (dismissed) return null;
50
+
51
+ const target = suggestion.targetProduct;
52
+ const firstImage = target.images?.[0];
53
+ const imageUrl = firstImage
54
+ ? typeof firstImage === 'string'
55
+ ? firstImage
56
+ : firstImage.url
57
+ : null;
58
+ const formattedDelta = formatPrice(parseFloat(suggestion.priceDelta), { currency }) as string;
59
+
60
+ function handleDismiss() {
61
+ try {
62
+ sessionStorage.setItem(storageKey, '1');
63
+ } catch {
64
+ /* ignore */
65
+ }
66
+ setDismissed(true);
67
+ }
68
+
69
+ async function handleUpgrade() {
70
+ if (upgrading) return;
71
+ try {
72
+ setUpgrading(true);
73
+ const client = getClient();
74
+ await client.smartRemoveFromCart(cartItem.productId, cartItem.variantId || undefined);
75
+ await client.smartAddToCart({ productId: target.id, quantity: cartItem.quantity });
76
+ onUpgrade();
77
+ } catch (err) {
78
+ console.error('Failed to upgrade cart item:', err);
79
+ } finally {
80
+ setUpgrading(false);
81
+ }
82
+ }
83
+
84
+ return (
85
+ <div className={cn('flex items-center gap-3', className)}>
86
+ {/* Product image — `relative` + fixed box are layout-critical for next/image fill */}
87
+ <span className="relative block h-12 w-12">
88
+ {imageUrl ? <Image src={imageUrl} alt={target.name} fill sizes="48px" /> : null}
89
+ </span>
90
+
91
+ {/* Text */}
92
+ <p>{t('upgradeFor', { name: target.name, amount: formattedDelta })}</p>
93
+
94
+ {/* Upgrade button */}
95
+ <button type="button" onClick={handleUpgrade} disabled={upgrading}>
96
+ {upgrading ? t('upgrading') : t('upgrade')}
97
+ </button>
98
+
99
+ {/* Dismiss button */}
100
+ <button type="button" onClick={handleDismiss} aria-label={t('dismissUpgrade')}>
101
+ ×
102
+ </button>
103
+ </div>
104
+ );
105
+ }
@@ -0,0 +1,106 @@
1
+ 'use client';
2
+
3
+ import { Link } from '@/core/lib/navigation';
4
+ import { useCartPage } from '@/core/hooks/use-cart-page';
5
+ import { CartItem } from '@/ui/cart/cart-item';
6
+ import { CartUpgradeBanner } from '@/ui/cart/cart-upgrade-banner';
7
+ import { CartBundleOfferCard } from '@/ui/cart/cart-bundle-offer';
8
+ import { CartSummary } from '@/ui/cart/cart-summary';
9
+ import { CouponInput } from '@/ui/cart/coupon-input';
10
+ import { CartNudges } from '@/ui/cart/cart-nudges';
11
+ import { FreeShippingBar } from '@/ui/cart/free-shipping-bar';
12
+ import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
13
+ import { CartRecommendationSection } from '@/ui/product/recommendation-section';
14
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
15
+ import { useTranslations } from '@/core/lib/translations';
16
+
17
+ export function CartView() {
18
+ const t = useTranslations('cart');
19
+ const tc = useTranslations('common');
20
+ const { cart, cartLoading, refreshCart, itemCount, cartRecs, upgrades, bundles } = useCartPage();
21
+
22
+ if (cartLoading) {
23
+ return <LoadingSpinner size="lg" />;
24
+ }
25
+
26
+ // Empty cart state
27
+ if (!cart || cart.items.length === 0) {
28
+ return (
29
+ <section>
30
+ {/* DESIGN ME — empty-cart state: message + continue-shopping CTA; state comes from useCartPage(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
31
+ <h1>{t('emptyTitle')}</h1>
32
+ <p>{t('emptySubtitle')}</p>
33
+ <Link href="/products">{tc('continueShopping')}</Link>
34
+ </section>
35
+ );
36
+ }
37
+
38
+ return (
39
+ <section>
40
+ {/* DESIGN ME — cart page: items list, upgrade/bundle offers, coupon, summary sidebar, checkout CTA, cross-sells; everything comes from useCartPage(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
41
+ <h1>
42
+ {t('title')} ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
43
+ </h1>
44
+
45
+ {/* Reservation countdown */}
46
+ {cart.reservation?.hasReservation && <ReservationCountdown reservation={cart.reservation} />}
47
+
48
+ <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
49
+ {/* Cart Items */}
50
+ <div className="lg:col-span-2">
51
+ {/* Nudges */}
52
+ {cart.nudges && cart.nudges.length > 0 && <CartNudges nudges={cart.nudges} />}
53
+
54
+ {/* Cart items */}
55
+ <ul>
56
+ {cart.items.map((item) => (
57
+ <li key={item.id}>
58
+ <CartItem item={item} onUpdate={refreshCart} />
59
+ {upgrades?.upgrades?.[item.productId] && (
60
+ <CartUpgradeBanner
61
+ suggestion={upgrades.upgrades[item.productId]}
62
+ cartItem={item}
63
+ onUpgrade={refreshCart}
64
+ />
65
+ )}
66
+ </li>
67
+ ))}
68
+ </ul>
69
+
70
+ {/* Bundle offers */}
71
+ {bundles?.bundles && bundles.bundles.length > 0 && (
72
+ <section>
73
+ <h3>{t('bundleOffers')}</h3>
74
+ {bundles.bundles.map((offer) => (
75
+ <CartBundleOfferCard
76
+ key={offer.id}
77
+ offer={offer}
78
+ cartId={cart.id}
79
+ onAdd={refreshCart}
80
+ />
81
+ ))}
82
+ </section>
83
+ )}
84
+
85
+ {/* Coupon input */}
86
+ <CouponInput cart={cart} onUpdate={refreshCart} />
87
+ </div>
88
+
89
+ {/* Summary sidebar */}
90
+ <aside className="lg:col-span-1">
91
+ <FreeShippingBar />
92
+ <CartSummary />
93
+
94
+ <Link href="/checkout">{t('proceedToCheckout')}</Link>
95
+
96
+ <Link href="/products">{tc('continueShopping')}</Link>
97
+ </aside>
98
+ </div>
99
+
100
+ {/* Cross-sell recommendations */}
101
+ {cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
102
+ <CartRecommendationSection title={t('youMightAlsoNeed')} items={cartRecs.recommendations} />
103
+ )}
104
+ </section>
105
+ );
106
+ }
@@ -0,0 +1,119 @@
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { Cart } from 'brainerce';
5
+ import { getClient } from '@/core/lib/brainerce';
6
+ import { useTranslations } from '@/core/lib/translations';
7
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
8
+ import { cn } from '@/core/lib/utils';
9
+
10
+ interface CouponInputProps {
11
+ cart: Cart;
12
+ /** When provided, uses applyCheckoutCoupon/removeCheckoutCoupon instead of
13
+ * applyCoupon/removeCoupon. Always pass this when a checkout session exists. */
14
+ checkoutId?: string;
15
+ onUpdate: () => void;
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * DESIGN ME — coupon apply/remove control used on the cart page AND the
21
+ * checkout page (via `checkoutId`); the `cart` prop comes from useCartPage().
22
+ * Keep the applied-coupon state and the error message.
23
+ *
24
+ * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
25
+ */
26
+ export function CouponInput({ cart, checkoutId, onUpdate, className }: CouponInputProps) {
27
+ const t = useTranslations('coupon');
28
+ const tc = useTranslations('common');
29
+ const [code, setCode] = useState('');
30
+ const [applying, setApplying] = useState(false);
31
+ const [removing, setRemoving] = useState(false);
32
+ const [error, setError] = useState<string | null>(null);
33
+
34
+ const appliedCoupon = cart.couponCode || null;
35
+
36
+ async function handleApply() {
37
+ const trimmed = code.trim();
38
+ if (!trimmed || applying) return;
39
+
40
+ try {
41
+ setApplying(true);
42
+ setError(null);
43
+ const client = getClient();
44
+ if (checkoutId) {
45
+ await client.applyCheckoutCoupon(checkoutId, trimmed);
46
+ } else {
47
+ await client.applyCoupon(cart.id, trimmed);
48
+ }
49
+ setCode('');
50
+ onUpdate();
51
+ } catch (err) {
52
+ const message = err instanceof Error ? err.message : t('invalidCode');
53
+ setError(message);
54
+ } finally {
55
+ setApplying(false);
56
+ }
57
+ }
58
+
59
+ async function handleRemove() {
60
+ if (removing) return;
61
+
62
+ try {
63
+ setRemoving(true);
64
+ setError(null);
65
+ const client = getClient();
66
+ if (checkoutId) {
67
+ await client.removeCheckoutCoupon(checkoutId);
68
+ } else {
69
+ await client.removeCoupon(cart.id);
70
+ }
71
+ onUpdate();
72
+ } catch (err) {
73
+ console.error('Failed to remove coupon:', err);
74
+ } finally {
75
+ setRemoving(false);
76
+ }
77
+ }
78
+
79
+ // Show applied coupon
80
+ if (appliedCoupon) {
81
+ return (
82
+ <div className={cn('flex items-center gap-2', className)}>
83
+ <span>{appliedCoupon}</span>
84
+ <button type="button" onClick={handleRemove} disabled={removing}>
85
+ {removing ? tc('removing') : tc('remove')}
86
+ </button>
87
+ </div>
88
+ );
89
+ }
90
+
91
+ return (
92
+ <div className={cn(className)}>
93
+ <div className="flex gap-2">
94
+ <input
95
+ type="text"
96
+ value={code}
97
+ onChange={(e) => {
98
+ setCode(e.target.value);
99
+ if (error) setError(null);
100
+ }}
101
+ onKeyDown={(e) => {
102
+ if (e.key === 'Enter') {
103
+ e.preventDefault();
104
+ handleApply();
105
+ }
106
+ }}
107
+ placeholder={t('placeholder')}
108
+ aria-label={t('placeholder')}
109
+ aria-invalid={error ? true : undefined}
110
+ />
111
+ <button type="button" onClick={handleApply} disabled={applying || !code.trim()}>
112
+ {applying ? <LoadingSpinner size="sm" /> : tc('apply')}
113
+ </button>
114
+ </div>
115
+
116
+ {error && <p role="alert">{error}</p>}
117
+ </div>
118
+ );
119
+ }