create-brainerce-store 1.78.0 → 1.80.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 (36) hide show
  1. package/dist/index.js +27 -2
  2. package/messages/en.json +12 -3
  3. package/messages/he.json +12 -3
  4. package/package.json +10 -1
  5. package/templates/nextjs/base/.eslintrc.json +2 -0
  6. package/templates/nextjs/base/AGENTS.md.ejs +23 -5
  7. package/templates/nextjs/base/CLAUDE.md.ejs +23 -5
  8. package/templates/nextjs/base/src/app/checkout/page.tsx +19 -2
  9. package/templates/nextjs/base/src/app/order-status/page.tsx +18 -3
  10. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +220 -214
  11. package/templates/nextjs/base/src/components/account/order-history.tsx +11 -4
  12. package/templates/nextjs/base/src/components/account/profile-section.tsx +7 -1
  13. package/templates/nextjs/base/src/components/auth/register-form.tsx +18 -1
  14. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +14 -2
  15. package/templates/nextjs/base/src/components/tracking-bootstrap.tsx +1 -1
  16. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +343 -328
  17. package/templates/nextjs/base/src/core/lib/kit.ts +88 -0
  18. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +87 -12
  19. package/templates/nextjs/base/src/ui/layout/newsletter-signup.tsx +59 -12
  20. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +205 -197
  21. package/templates/nextjs/base/src/ui/product/product-card.tsx +230 -221
  22. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +524 -493
  23. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +117 -108
  24. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +23 -3
  25. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +210 -202
  26. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +251 -242
  27. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +540 -509
  28. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +110 -101
  29. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +22 -2
  30. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +41 -11
  31. package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +50 -8
  32. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +182 -174
  33. package/templates/nextjs/ui-canvas/product/product-card.tsx +174 -165
  34. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +31 -0
  35. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +114 -105
  36. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +22 -2
@@ -1,101 +1,110 @@
1
- 'use client';
2
-
3
- import { Link } from '@/core/lib/navigation';
4
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
- import type { ProductRecommendation } from 'brainerce';
6
- import { PriceDisplay } from '@/ui/product/price-display';
7
- import { cn } from '@/core/lib/utils';
8
-
9
- interface RecommendationCardProps {
10
- item: ProductRecommendation;
11
- className?: string;
12
- }
13
-
14
- function RecommendationCard({ item, className }: RecommendationCardProps) {
15
- const firstImage = item.images?.[0];
16
- const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
17
- const slug = item.slug || item.id;
18
- // ⛔ NOT region-converted, and it cannot be. `ProductRecommendation` is a
19
- // trimmed shape that carries `basePrice` / `salePrice` only: the backend
20
- // attaches no displayPrice/displayCurrency to it, so there is no converted
21
- // amount to render and converting here would mean inventing an FX rate.
22
- // These tiles stay in the store currency on a multi-region store. Do not
23
- // "fix" it with a client-side conversion.
24
- const basePrice = parseFloat(item.basePrice);
25
- const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
26
- const isOnSale = salePrice != null && salePrice < basePrice;
27
-
28
- return (
29
- <Link
30
- href={`/products/${slug}`}
31
- className={cn('group card card-hover block overflow-hidden', className)}
32
- >
33
- {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
34
- <span className="relative block aspect-square overflow-hidden bg-secondary">
35
- {imageUrl ? (
36
- <Image
37
- src={imageUrl}
38
- alt={item.name}
39
- fill
40
- sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
41
- className="img-zoom object-cover"
42
- />
43
- ) : null}
44
- </span>
45
- <span className="block space-y-1 p-4">
46
- <span className="block text-sm font-semibold leading-snug text-foreground group-hover:text-primary">
47
- {item.name}
48
- </span>
49
- <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
50
- </span>
51
- </Link>
52
- );
53
- }
54
-
55
- interface RecommendationSectionProps {
56
- title: string;
57
- items: ProductRecommendation[];
58
- className?: string;
59
- }
60
-
61
- /** Recommendation strip on the product page (upsells / related). */
62
- export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
63
- if (items.length === 0) return null;
64
-
65
- return (
66
- <section className={cn('border-t pt-10', className)}>
67
- <h2 className="mb-6 text-2xl">{title}</h2>
68
- <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
69
- {items.slice(0, 4).map((item) => (
70
- <RecommendationCard key={item.id} item={item} />
71
- ))}
72
- </div>
73
- </section>
74
- );
75
- }
76
-
77
- interface CartRecommendationSectionProps {
78
- title: string;
79
- items: ProductRecommendation[];
80
- className?: string;
81
- }
82
-
83
- /** Cross-sell strip on the cart page ("you might also need"). */
84
- export function CartRecommendationSection({
85
- title,
86
- items,
87
- className,
88
- }: CartRecommendationSectionProps) {
89
- if (items.length === 0) return null;
90
-
91
- return (
92
- <section className={cn('border-t pt-10', className)}>
93
- <h2 className="mb-6 text-2xl">{title}</h2>
94
- <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
95
- {items.slice(0, 4).map((item) => (
96
- <RecommendationCard key={item.id} item={item} />
97
- ))}
98
- </div>
99
- </section>
100
- );
101
- }
1
+ 'use client';
2
+
3
+ import { Link } from '@/core/lib/navigation';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { ProductRecommendation } from 'brainerce';
6
+ import { PriceDisplay } from '@/ui/product/price-display';
7
+ import { cn } from '@/core/lib/utils';
8
+
9
+ interface RecommendationCardProps {
10
+ item: ProductRecommendation;
11
+ className?: string;
12
+ }
13
+
14
+ function RecommendationCard({ item, className }: RecommendationCardProps) {
15
+ const firstImage = item.images?.[0];
16
+ const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
17
+ const slug = item.slug || item.id;
18
+ // ⛔ NOT region-converted, and it cannot be. `ProductRecommendation` is a
19
+ // trimmed shape that carries `basePrice` / `salePrice` only: the backend
20
+ // attaches no displayPrice/displayCurrency to it, so there is no converted
21
+ // amount to render and converting here would mean inventing an FX rate.
22
+ // These tiles stay in the store currency on a multi-region store. Do not
23
+ // "fix" it with a client-side conversion.
24
+ const basePrice = parseFloat(item.basePrice);
25
+ const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
26
+ const isOnSale = salePrice != null && salePrice < basePrice;
27
+ // ⛔ No price on a KIT tile. The recommendations endpoint does not resolve
28
+ // kits, so `item.basePrice` here is the kit's stored PLACEHOLDER — right only
29
+ // for FIXED pricing and wrong for every SUM / SUM_MINUS_PERCENT kit. Showing
30
+ // it would quote a price the product page then contradicts. The tile still
31
+ // links through, and the product page reads the resolved price from the API.
32
+ // Do NOT recompute a kit price on the client.
33
+ const priceIsUnresolved = item.type === 'KIT';
34
+
35
+ return (
36
+ <Link
37
+ href={`/products/${slug}`}
38
+ className={cn('group card card-hover block overflow-hidden', className)}
39
+ >
40
+ {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
41
+ <span className="relative block aspect-square overflow-hidden bg-secondary">
42
+ {imageUrl ? (
43
+ <Image
44
+ src={imageUrl}
45
+ alt={item.name}
46
+ fill
47
+ sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
48
+ className="img-zoom object-cover"
49
+ />
50
+ ) : null}
51
+ </span>
52
+ <span className="block space-y-1 p-4">
53
+ <span className="block text-sm font-semibold leading-snug text-foreground group-hover:text-primary">
54
+ {item.name}
55
+ </span>
56
+ {priceIsUnresolved ? null : (
57
+ <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
58
+ )}
59
+ </span>
60
+ </Link>
61
+ );
62
+ }
63
+
64
+ interface RecommendationSectionProps {
65
+ title: string;
66
+ items: ProductRecommendation[];
67
+ className?: string;
68
+ }
69
+
70
+ /** Recommendation strip on the product page (upsells / related). */
71
+ export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
72
+ if (items.length === 0) return null;
73
+
74
+ return (
75
+ <section className={cn('border-t pt-10', className)}>
76
+ <h2 className="mb-6 text-2xl">{title}</h2>
77
+ <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
78
+ {items.slice(0, 4).map((item) => (
79
+ <RecommendationCard key={item.id} item={item} />
80
+ ))}
81
+ </div>
82
+ </section>
83
+ );
84
+ }
85
+
86
+ interface CartRecommendationSectionProps {
87
+ title: string;
88
+ items: ProductRecommendation[];
89
+ className?: string;
90
+ }
91
+
92
+ /** Cross-sell strip on the cart page ("you might also need"). */
93
+ export function CartRecommendationSection({
94
+ title,
95
+ items,
96
+ className,
97
+ }: CartRecommendationSectionProps) {
98
+ if (items.length === 0) return null;
99
+
100
+ return (
101
+ <section className={cn('border-t pt-10', className)}>
102
+ <h2 className="mb-6 text-2xl">{title}</h2>
103
+ <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
104
+ {items.slice(0, 4).map((item) => (
105
+ <RecommendationCard key={item.id} item={item} />
106
+ ))}
107
+ </div>
108
+ </section>
109
+ );
110
+ }
@@ -5,9 +5,20 @@ import { cn } from '@/core/lib/utils';
5
5
  import { useTranslations } from '@/core/lib/translations';
6
6
  import { useStoreCapabilities } from '@/core/providers/store-provider';
7
7
  import { resolveLowStockThreshold } from '@/core/lib/capabilities';
8
+ import { kitInventory } from '@/core/lib/kit';
8
9
 
9
10
  interface StockBadgeProps {
10
11
  inventory: InventoryInfo | null | undefined;
12
+ /**
13
+ * KIT stock — pass `product.kitAvailable` whenever the product may be a kit.
14
+ *
15
+ * ⛔ A KIT has NO `inventory` block, so passing `inventory` alone sent every
16
+ * kit down the `!inventory` branch below and printed a red "Out of stock" on
17
+ * every card, category page, search result and product page — while the buy
18
+ * button beside it stayed enabled. `undefined` here means "not a kit";
19
+ * `null` means "unlimited". They are not interchangeable.
20
+ */
21
+ kitAvailable?: number | null;
11
22
  /**
12
23
  * Override the threshold. Leave it unset — the merchant's configured value
13
24
  * comes from the store's capabilities, and a hardcoded number here shows the
@@ -23,14 +34,21 @@ type StockState = 'in' | 'low' | 'out';
23
34
  * Stock status pill (in stock / low stock / out of stock) with a colored
24
35
  * status dot; derives the label from the product/variant `inventory` prop.
25
36
  */
26
- export function StockBadge({ inventory, lowStockThreshold, className }: StockBadgeProps) {
37
+ export function StockBadge({
38
+ inventory,
39
+ kitAvailable,
40
+ lowStockThreshold,
41
+ className,
42
+ }: StockBadgeProps) {
27
43
  const t = useTranslations('productDetail');
28
44
  // Merchant-configured, from the channel's capabilities. Falls back to the
29
45
  // platform default while the fetch is in flight or if it failed, and resolves
30
46
  // to 0 (nothing is ever low) when the merchant turned low-stock warnings off.
31
47
  const { capabilities } = useStoreCapabilities();
48
+ // A kit reports from `kitAvailable`; everything else from its inventory row.
49
+ const stock = kitAvailable !== undefined ? kitInventory(kitAvailable) : inventory;
32
50
  const { label, state } = getStock(
33
- inventory,
51
+ stock,
34
52
  lowStockThreshold ?? resolveLowStockThreshold(capabilities),
35
53
  t
36
54
  );
@@ -65,6 +83,8 @@ function getStock(
65
83
  lowStockThreshold: number,
66
84
  t: (key: string) => string
67
85
  ): { label: string; state: StockState } {
86
+ // No stock signal at all. A KIT reaches here only when the caller forgot to
87
+ // pass `kitAvailable` — see the prop comment above.
68
88
  if (!inventory) return { label: t('outOfStock'), state: 'out' };
69
89
 
70
90
  const { trackingMode, inStock, available } = inventory;
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { useState } from 'react';
4
4
  import type { Checkout } from 'brainerce';
5
+ import { BrainerceError } from 'brainerce';
5
6
  import { getClient } from '@/core/lib/brainerce';
6
7
  import { useTranslations } from '@/core/lib/translations';
7
8
  import { LoadingSpinner } from '@/ui/shared/loading-spinner';
@@ -11,6 +12,11 @@ interface GiftCardInputProps {
11
12
  checkoutId: string;
12
13
  /** Cards already on this checkout. Each is removed by its own tenderId. */
13
14
  tenders: NonNullable<Checkout['tenders']>;
15
+ /**
16
+ * Whether the checkout can still take a card. False once a payment intent
17
+ * exists — the server refuses every tender change from then on.
18
+ */
19
+ locked: boolean;
14
20
  /** Formats an amount in the checkout's currency. */
15
21
  formatAmount: (value: string) => string;
16
22
  onUpdate: () => void;
@@ -35,14 +41,22 @@ interface GiftCardInputProps {
35
41
  *
36
42
  * Refusals are deliberately identical for "no such code", "expired", "already
37
43
  * spent" and "wrong currency": anything that tells them apart is an oracle
38
- * someone walks the code space against. Show what the server said; never guess
39
- * a more specific reason.
44
+ * someone walks the code space against. Show THIS store's one message for all
45
+ * of them — never the server's text, which is English and could gain a more
46
+ * helpful sentence later.
47
+ *
48
+ * Two failures are not about the card and must not read as if they were: a 5xx
49
+ * or dropped connection, and a checkout already locked for payment. When
50
+ * `locked` is true, do not render the field at all — the server refuses both
51
+ * applying and removing, changing shipping does not unlock it, and a box that
52
+ * can never work is worse than no box.
40
53
  *
41
54
  * 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.
42
55
  */
43
56
  export function GiftCardInput({
44
57
  checkoutId,
45
58
  tenders,
59
+ locked,
46
60
  formatAmount,
47
61
  onUpdate,
48
62
  className,
@@ -54,6 +68,13 @@ export function GiftCardInput({
54
68
  const [removingId, setRemovingId] = useState<string | null>(null);
55
69
  const [error, setError] = useState<string | null>(null);
56
70
 
71
+ /** `CHECKOUT_LOCKED` is the one 4xx that says nothing about the card. */
72
+ function isCheckoutLocked(err: unknown): boolean {
73
+ if (!(err instanceof BrainerceError)) return false;
74
+ const details = err.details as { code?: unknown } | null | undefined;
75
+ return details?.code === 'CHECKOUT_LOCKED';
76
+ }
77
+
57
78
  async function handleApply() {
58
79
  const trimmed = code.trim();
59
80
  if (!trimmed || applying) return;
@@ -67,7 +88,10 @@ export function GiftCardInput({
67
88
  setCode('');
68
89
  onUpdate();
69
90
  } catch (err) {
70
- setError(err instanceof Error ? err.message : t('invalidCode'));
91
+ const refused = err instanceof BrainerceError && err.statusCode < 500;
92
+ setError(
93
+ isCheckoutLocked(err) ? t('locked') : refused ? t('invalidCode') : t('applyFailed')
94
+ );
71
95
  } finally {
72
96
  setApplying(false);
73
97
  }
@@ -82,7 +106,7 @@ export function GiftCardInput({
82
106
  await getClient().removeGiftCard(checkoutId, tenderId);
83
107
  onUpdate();
84
108
  } catch (err) {
85
- setError(err instanceof Error ? err.message : t('removeFailed'));
109
+ setError(isCheckoutLocked(err) ? t('locked') : t('removeFailed'));
86
110
  } finally {
87
111
  setRemovingId(null);
88
112
  }
@@ -98,18 +122,23 @@ export function GiftCardInput({
98
122
  ₪50 order applies ₪50 and keeps the rest for next time —
99
123
  showing the balance here would look like a lost ₪150. */}
100
124
  <span>{t('applied', { amount: formatAmount(tender.amountApplied) })}</span>
101
- <button
102
- type="button"
103
- onClick={() => handleRemove(tender.tenderId)}
104
- disabled={removingId === tender.tenderId}
105
- >
106
- {removingId === tender.tenderId ? tc('removing') : tc('remove')}
107
- </button>
125
+ {!locked && (
126
+ <button
127
+ type="button"
128
+ onClick={() => handleRemove(tender.tenderId)}
129
+ disabled={removingId === tender.tenderId}
130
+ >
131
+ {removingId === tender.tenderId ? tc('removing') : tc('remove')}
132
+ </button>
133
+ )}
108
134
  </li>
109
135
  ))}
110
136
  </ul>
111
137
  )}
112
138
 
139
+ {locked ? (
140
+ tenders.length > 0 ? <p>{t('lockedHint')}</p> : null
141
+ ) : (
113
142
  <div className="flex gap-2">
114
143
  <input
115
144
  type="text"
@@ -135,6 +164,7 @@ export function GiftCardInput({
135
164
  {applying ? <LoadingSpinner size="sm" /> : tc('apply')}
136
165
  </button>
137
166
  </div>
167
+ )}
138
168
 
139
169
  {error && <p role="alert">{error}</p>}
140
170
  </div>
@@ -17,9 +17,18 @@
17
17
  *
18
18
  * ⛔ KEEP THE HONEYPOT. It is the bot filter; deleting the input silently opens
19
19
  * the form up.
20
+ *
21
+ * **The welcome offer.** `marketing.getBenefit()` returns what the merchant
22
+ * offers people who confirm, or `null` when they offer nothing. Show its terms
23
+ * beside the field. ⛔ NEVER RENDER A COUPON CODE — none exists here or in the
24
+ * success state; it is minted by the confirmation click and emailed to the
25
+ * recipient, which is what stops a forwarded link handing the discount to
26
+ * someone who never asked. There is no eligibility check to call either: a
27
+ * per-address answer would let anyone test who already subscribed.
20
28
  */
21
29
 
22
- import { useMemo, useState } from 'react';
30
+ import { useEffect, useMemo, useState } from 'react';
31
+ import type { PublicNewsletterBenefitOffer } from 'brainerce';
23
32
  import { getClient } from '@/core/lib/brainerce';
24
33
  import { useTranslations } from '@/core/lib/translations';
25
34
 
@@ -30,6 +39,9 @@ export function NewsletterSignup() {
30
39
  const [loading, setLoading] = useState(false);
31
40
  const [done, setDone] = useState(false);
32
41
  const [error, setError] = useState<string | null>(null);
42
+ // null covers "no offer" and "the read failed" alike. Neither deserves an
43
+ // error state: the form still works and simply promises nothing.
44
+ const [offer, setOffer] = useState<PublicNewsletterBenefitOffer | null>(null);
33
45
 
34
46
  // <html lang> carries the active locale, and passing it decides the language
35
47
  // of the confirmation email. Omit it and a Hebrew shopper gets English.
@@ -38,6 +50,29 @@ export function NewsletterSignup() {
38
50
  return undefined;
39
51
  }, []);
40
52
 
53
+ // Once per mount. The offer belongs to the store, not the visitor.
54
+ useEffect(() => {
55
+ let cancelled = false;
56
+ getClient()
57
+ .marketing.getBenefit(locale)
58
+ .then((result) => {
59
+ if (!cancelled) setOffer(result);
60
+ })
61
+ .catch(() => {
62
+ // Silent: a failed offer read must not stop anyone subscribing.
63
+ });
64
+ return () => {
65
+ cancelled = true;
66
+ };
67
+ }, [locale]);
68
+
69
+ const offerHeadline = offer
70
+ ? offer.headline?.trim() ||
71
+ (offer.discountType === 'PERCENTAGE'
72
+ ? t('offerPercent', { value: String(offer.discountValue) })
73
+ : t('offerAmount', { value: String(offer.discountValue) }))
74
+ : null;
75
+
41
76
  async function handleSubmit(e: React.FormEvent) {
42
77
  e.preventDefault();
43
78
  if (loading || !email.trim()) return;
@@ -68,13 +103,9 @@ export function NewsletterSignup() {
68
103
  {/* DESIGN ME — newsletter success state. Do NOT reword to "you're subscribed": the address is not on the list until the recipient clicks the emailed link. Naming the spam folder is deliberate — a filtered confirmation is the commonest reason a signup never converts, and no second copy is sent for 24 hours. */}
69
104
  <p>{t('checkEmailTitle')}</p>
70
105
  <p>{t('checkEmailBody')}</p>
71
- {/*
72
- Merchant offering a signup discount? Render the coupon code here, now
73
- not after the confirmation click, which may never come:
74
- <p>{t('discountCode', { code: 'WELCOME10' })}</p>
75
- The code comes from a dashboard coupon with the `customer_first_order`
76
- condition; subscribing mints nothing on its own.
77
- */}
106
+ {/* Says where the code comes from without ever showing one: the coupon
107
+ is created by the confirmation click and lands in the same inbox. */}
108
+ {offer ? <p>{t('offerByEmail')}</p> : null}
78
109
  </div>
79
110
  );
80
111
  }
@@ -85,6 +116,17 @@ export function NewsletterSignup() {
85
116
  <label htmlFor="newsletter-email">{t('title')}</label>
86
117
  <p>{t('subtitle')}</p>
87
118
 
119
+ {/* DESIGN ME — the welcome offer, when the merchant configured one. Keep
120
+ both lines: the headline is the promise and the terms are its
121
+ conditions, and a shopper who reads one without the other is being
122
+ mis-sold. Never add a coupon code here. */}
123
+ {offerHeadline ? (
124
+ <div>
125
+ <p>{offerHeadline}</p>
126
+ {offer?.terms ? <p>{offer.terms}</p> : null}
127
+ </div>
128
+ ) : null}
129
+
88
130
  <input
89
131
  id="newsletter-email"
90
132
  type="email"