create-brainerce-store 1.78.0 → 1.79.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 (31) hide show
  1. package/dist/index.js +12 -2
  2. package/messages/en.json +8 -1
  3. package/messages/he.json +8 -1
  4. package/package.json +1 -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/checkout/payment-step.tsx +14 -2
  13. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +343 -328
  14. package/templates/nextjs/base/src/core/lib/kit.ts +88 -0
  15. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +87 -12
  16. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +205 -197
  17. package/templates/nextjs/base/src/ui/product/product-card.tsx +230 -221
  18. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +524 -493
  19. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +117 -108
  20. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +23 -3
  21. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +210 -202
  22. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +251 -242
  23. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +540 -509
  24. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +110 -101
  25. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +22 -2
  26. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +41 -11
  27. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +182 -174
  28. package/templates/nextjs/ui-canvas/product/product-card.tsx +174 -165
  29. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +31 -0
  30. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +114 -105
  31. 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>