create-brainerce-store 1.72.0 → 1.74.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 (88) hide show
  1. package/dist/index.js +37 -4
  2. package/messages/en.json +30 -1
  3. package/messages/he.json +30 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/AGENTS.md.ejs +63 -16
  6. package/templates/nextjs/base/AI-GUIDE.md +17 -4
  7. package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
  8. package/templates/nextjs/base/TRANSLATIONS.md +207 -207
  9. package/templates/nextjs/base/src/app/account/page.tsx +168 -122
  10. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
  11. package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
  12. package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
  13. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
  14. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
  15. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
  16. package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
  17. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
  18. package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
  19. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
  20. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
  21. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
  22. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
  23. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
  24. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
  25. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
  26. package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
  27. package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
  28. package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
  29. package/templates/nextjs/base/src/core/lib/region.ts +112 -0
  30. package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
  31. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
  32. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
  33. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
  34. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
  35. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
  37. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
  38. package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
  39. package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
  40. package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
  41. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
  42. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
  43. package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
  44. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
  45. package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
  46. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
  47. package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
  48. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
  49. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
  50. package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
  51. package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
  52. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
  53. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
  54. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
  55. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
  56. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
  57. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
  58. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
  59. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
  60. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
  61. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
  62. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
  63. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
  64. package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
  65. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
  66. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
  67. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
  68. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
  69. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
  70. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
  71. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
  72. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
  73. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
  74. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
  75. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
  76. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
  77. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
  78. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
  79. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
  80. package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
  81. package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
  82. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
  83. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
  84. package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
  85. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
  86. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
  87. package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
  88. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
@@ -1,74 +1,141 @@
1
- 'use client';
2
-
3
- import type { TaxBreakdown } from 'brainerce';
4
- import { formatPrice } from 'brainerce';
5
- import { useTranslations } from '@/core/lib/translations';
6
- import { useCurrency } from '@/core/lib/use-currency';
7
- import { cn } from '@/core/lib/utils';
8
-
9
- interface TaxDisplayProps {
10
- /** Whether shipping address has been set */
11
- addressSet: boolean;
12
- /** Tax amount string from checkout (only available after address is set) */
13
- taxAmount?: string;
14
- /** Detailed tax breakdown (optional) */
15
- taxBreakdown?: TaxBreakdown | null;
16
- className?: string;
17
- }
18
-
19
- export function TaxDisplay({ addressSet, taxAmount, taxBreakdown, className }: TaxDisplayProps) {
20
- const t = useTranslations('checkout');
21
- const tc = useTranslations('common');
22
- const currency = useCurrency();
23
-
24
- // Before address is set
25
- if (!addressSet) {
26
- return (
27
- <div className={cn('flex items-center justify-between text-sm', className)}>
28
- <span className="text-muted-foreground">{tc('tax')}</span>
29
- <span className="text-muted-foreground text-xs">{t('calculatedAfterAddress')}</span>
30
- </div>
31
- );
32
- }
33
-
34
- // Inclusive-pricing stores back the tax out of the displayed price, so
35
- // `checkout.taxAmount` is stored as 0 and the real VAT lives on the
36
- // aggregated breakdown. Surface whichever is present.
37
- const explicitTax = taxAmount ? parseFloat(taxAmount) : 0;
38
- const tax =
39
- explicitTax > 0
40
- ? explicitTax
41
- : typeof taxBreakdown?.totalTax === 'number'
42
- ? taxBreakdown.totalTax
43
- : 0;
44
-
45
- // When there's a per-rate breakdown, prefer per-row display ("VAT 18% ₪3.05").
46
- // Otherwise show a single aggregated tax line.
47
- const hasBreakdown = !!taxBreakdown?.breakdown && taxBreakdown.breakdown.length > 0;
48
-
49
- if (hasBreakdown) {
50
- return (
51
- <div className={cn('space-y-1', className)}>
52
- {taxBreakdown!.breakdown.map((item, index) => (
53
- <div key={index} className="flex items-center justify-between text-sm">
54
- <span className="text-muted-foreground">
55
- {item.name} ({(item.rate * 100).toFixed(1)}%)
56
- </span>
57
- <span className="text-foreground font-medium">
58
- {formatPrice(item.amount, { currency }) as string}
59
- </span>
60
- </div>
61
- ))}
62
- </div>
63
- );
64
- }
65
-
66
- return (
67
- <div className={cn('flex items-center justify-between text-sm', className)}>
68
- <span className="text-muted-foreground">{tc('tax')}</span>
69
- <span className="text-foreground font-medium">
70
- {tax > 0 ? (formatPrice(tax, { currency }) as string) : t('noTax')}
71
- </span>
72
- </div>
73
- );
74
- }
1
+ 'use client';
2
+
3
+ import type { TaxBreakdown } from 'brainerce';
4
+ import { formatPrice } from 'brainerce';
5
+ import { useTranslations } from '@/core/lib/translations';
6
+ import { useCurrency } from '@/core/lib/use-currency';
7
+ import { cn } from '@/core/lib/utils';
8
+
9
+ interface TaxDisplayProps {
10
+ /** Whether shipping address has been set */
11
+ addressSet: boolean;
12
+ /** Tax amount string from checkout (only available after address is set) */
13
+ taxAmount?: string;
14
+ /** Detailed tax breakdown (optional) */
15
+ taxBreakdown?: TaxBreakdown | null;
16
+ className?: string;
17
+ }
18
+
19
+ export function TaxDisplay({ addressSet, taxAmount, taxBreakdown, className }: TaxDisplayProps) {
20
+ const t = useTranslations('checkout');
21
+ const tc = useTranslations('common');
22
+ const currency = useCurrency();
23
+
24
+ // Before address is set
25
+ if (!addressSet) {
26
+ return (
27
+ <div className={cn('flex items-center justify-between text-sm', className)}>
28
+ <span className="text-muted-foreground">{tc('tax')}</span>
29
+ <span className="text-muted-foreground text-xs">{t('calculatedAfterAddress')}</span>
30
+ </div>
31
+ );
32
+ }
33
+
34
+ // Inclusive-pricing stores back the tax out of the displayed price, so
35
+ // `checkout.taxAmount` is stored as 0 and the real VAT lives on the
36
+ // aggregated breakdown. Surface whichever is present.
37
+ const explicitTax = taxAmount ? parseFloat(taxAmount) : 0;
38
+ const tax =
39
+ explicitTax > 0
40
+ ? explicitTax
41
+ : typeof taxBreakdown?.totalTax === 'number'
42
+ ? taxBreakdown.totalTax
43
+ : 0;
44
+
45
+ // Inclusive (VAT) stores already have this tax INSIDE the line prices, so it
46
+ // has to read as informational and never as an amount added on top. Without
47
+ // it the summary prints Subtotal + Shipping + GST + QST above a Total that is
48
+ // not their sum, and the shopper reads that as a mistake. Same treatment, and
49
+ // deliberately the same wording, as the order history summary.
50
+ const taxIncluded = !!taxBreakdown?.pricesIncludeTax;
51
+
52
+ // When there's a per-rate breakdown, prefer per-row display ("VAT 18% ₪3.05").
53
+ // Otherwise show a single aggregated tax line.
54
+ //
55
+ // `breakdown` holds ONE ROW PER TAX and is routinely more than one: a Quebec
56
+ // order carries GST 5% and QST 9.975%, a British Columbia one GST 5% and
57
+ // PST 7%, and both have to be shown separately. Rendering every row is not an
58
+ // edge case to handle later.
59
+ //
60
+ // Read every field defensively. The breakdown is a FROZEN SNAPSHOT: a row
61
+ // written by older code can reach us with no rate, no name, or a non-numeric
62
+ // amount, and `item.rate * 100` on such a row prints "NaN%" on something the
63
+ // buyer treats as a receipt. This mirrors `resolveDisplayTax()` in
64
+ // @brainerce/types, the platform's canonical filter — keep a row only when it
65
+ // has a printable name AND a finite amount, and turn a missing rate into null
66
+ // instead of dropping the row, because the name and the amount are the parts
67
+ // the shopper actually needs.
68
+ const taxRows: Array<{ name: string; rate: number | null; amount: number }> = (
69
+ taxBreakdown?.breakdown ?? []
70
+ )
71
+ .filter(
72
+ (row) =>
73
+ !!row &&
74
+ typeof row.name === 'string' &&
75
+ row.name.length > 0 &&
76
+ typeof row.amount === 'number' &&
77
+ Number.isFinite(row.amount)
78
+ )
79
+ .map((row) => ({
80
+ name: row.name,
81
+ rate: typeof row.rate === 'number' && Number.isFinite(row.rate) ? row.rate : null,
82
+ amount: row.amount,
83
+ }));
84
+
85
+ // Driven by the rows that SURVIVED the filter, not by the raw array: if every
86
+ // row was unprintable, fall through to the single aggregated line below
87
+ // rather than render an empty box where the tax used to be.
88
+ const hasBreakdown = taxRows.length > 0;
89
+
90
+ if (hasBreakdown) {
91
+ return (
92
+ <div className={cn('space-y-1', className)}>
93
+ {taxRows.map((item, index) => {
94
+ // Parenthetical parts, in order: the rate when the snapshot carried
95
+ // one, then the inclusive marker when prices already contain the tax.
96
+ // Neither present leaves the bare name.
97
+ //
98
+ // Joined with a SPACE, not a comma. In Hebrew this renders
99
+ // `מע"מ (18% כלול)`, and a comma sitting between a Latin numeral and
100
+ // an RTL word is a bidi neutral that can reorder on screen. The cart's
101
+ // tax estimate line already ships the same `<name> <rate>%`
102
+ // space-joined construction, so this follows a proven pattern.
103
+ //
104
+ // Rate is printed to 3 decimals with trailing zeros trimmed.
105
+ // `toFixed(1)` prints Quebec's QST 9.975% as "10.0%", which is the
106
+ // wrong number on something a buyer treats as a receipt.
107
+ const parts: string[] = [];
108
+ if (item.rate !== null) parts.push(`${Number((item.rate * 100).toFixed(3))}%`);
109
+ if (taxIncluded) parts.push(tc('inclSuffix'));
110
+
111
+ return (
112
+ <div
113
+ key={`${item.name}-${index}`}
114
+ className="flex items-center justify-between text-sm"
115
+ >
116
+ <span className="text-muted-foreground">
117
+ {item.name}
118
+ {parts.length > 0 ? ` (${parts.join(' ')})` : ''}
119
+ </span>
120
+ <span className="text-foreground font-medium">
121
+ {formatPrice(item.amount, { currency }) as string}
122
+ </span>
123
+ </div>
124
+ );
125
+ })}
126
+ </div>
127
+ );
128
+ }
129
+
130
+ return (
131
+ <div className={cn('flex items-center justify-between text-sm', className)}>
132
+ {/* An inclusive store with no per-rate breakdown lands here — it persists
133
+ taxAmount=0 and keeps the VAT on `totalTax` — so the label has to say
134
+ "included" on this line too, or the total still fails to add up. */}
135
+ <span className="text-muted-foreground">{taxIncluded ? tc('taxIncl') : tc('tax')}</span>
136
+ <span className="text-foreground font-medium">
137
+ {tax > 0 ? (formatPrice(tax, { currency }) as string) : t('noTax')}
138
+ </span>
139
+ </div>
140
+ );
141
+ }
@@ -1,127 +1,218 @@
1
- 'use client';
2
-
3
- import { useCallback, useEffect, useRef, useState } from 'react';
4
- import type {
5
- Cart,
6
- CartItem,
7
- CartRecommendationsResponse,
8
- CartUpgradesResponse,
9
- CartBundlesResponse,
10
- } from 'brainerce';
11
- import { getClient } from '@/core/lib/brainerce';
12
- import { useCart } from '@/core/providers/store-provider';
13
-
14
- export interface UseCartPageResult {
15
- cart: Cart | null;
16
- cartLoading: boolean;
17
- refreshCart: () => Promise<void>;
18
- itemCount: number;
19
- /** Cross-sell recommendations for the current cart contents. */
20
- cartRecs: CartRecommendationsResponse | null;
21
- /** Per-item upgrade suggestions keyed by productId. */
22
- upgrades: CartUpgradesResponse | null;
23
- /** Bundle offers matching the current cart. */
24
- bundles: CartBundlesResponse | null;
25
- /**
26
- * True once the stock reservation on this cart has run out and the refreshed
27
- * cart still carries the same expired window. Goes back to false on its own
28
- * if the server hands back a fresh reservation.
29
- */
30
- reservationExpired: boolean;
31
- /** Lines the server says cannot be bought right now (`isAvailable === false`). */
32
- unavailableItems: CartItem[];
33
- /**
34
- * False while the reservation is expired or any line is unavailable. The
35
- * proceed-to-checkout action must be genuinely disabled on false, not just
36
- * styled as disabled.
37
- */
38
- canProceedToCheckout: boolean;
39
- /**
40
- * Hand this to `<ReservationCountdown onExpire={...}>`. It refreshes the
41
- * cart once per expiry window: the server, not the client timer, decides
42
- * what is still purchasable.
43
- */
44
- onReservationExpired: () => void;
45
- }
46
-
47
- /**
48
- * Cart-page behavior: the shared cart state (from StoreProvider) plus the
49
- * enrichment fetch (recommendations / upgrades / bundles) that runs whenever
50
- * the cart contents change. Pure data/behavior rendering lives in
51
- * `ui/cart/cart-view.tsx`.
52
- */
53
- export function useCartPage(): UseCartPageResult {
54
- const { cart, cartLoading, refreshCart, itemCount } = useCart();
55
- const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
56
- const [upgrades, setUpgrades] = useState<CartUpgradesResponse | null>(null);
57
- const [bundles, setBundles] = useState<CartBundlesResponse | null>(null);
58
-
59
- // Load recommendations, upgrades, and bundles in a single request
60
- useEffect(() => {
61
- if (!cart?.id || cart.items.length === 0) {
62
- setCartRecs(null);
63
- setUpgrades(null);
64
- setBundles(null);
65
- return;
66
- }
67
- const client = getClient();
68
- client
69
- .getCart(cart.id, { include: ['recommendations', 'upgrades', 'bundles'] })
70
- .then((enriched) => {
71
- setCartRecs(enriched.recommendations ?? null);
72
- setUpgrades(enriched.upgrades ?? null);
73
- setBundles(enriched.bundles ?? null);
74
- })
75
- .catch(() => {});
76
- }, [cart?.id, cart?.items.length]);
77
-
78
- // ---- Reservation expiry ----
79
- //
80
- // The countdown component only ticks. Expiry is handled here, once, so all
81
- // three design variants share one behaviour and a user redesigning the
82
- // countdown cannot delete the gate along with the markup.
83
- //
84
- // Both pieces of state key on the reservation's `expiresAt`, which is the
85
- // only stable identity a reservation window has:
86
- // - `handledExpiryRef` stops the refresh loop (refresh remounts the
87
- // countdown, which would otherwise report the same expiry again).
88
- // - comparing `expiredWindow` to the CURRENT `expiresAt` means a server
89
- // that renews the reservation clears the expired state by itself.
90
- const reservationExpiresAt = cart?.reservation?.expiresAt ?? null;
91
- const handledExpiryRef = useRef<string | null>(null);
92
- const [expiredWindow, setExpiredWindow] = useState<string | null>(null);
93
-
94
- const onReservationExpired = useCallback(() => {
95
- if (!reservationExpiresAt) return;
96
- setExpiredWindow(reservationExpiresAt);
97
- if (handledExpiryRef.current === reservationExpiresAt) return;
98
- handledExpiryRef.current = reservationExpiresAt;
99
- // Re-read from the server: it is the source of truth for what survived
100
- // the released reservation. `isAvailable` on each line comes back updated.
101
- void refreshCart();
102
- }, [reservationExpiresAt, refreshCart]);
103
-
104
- const reservationExpired =
105
- expiredWindow !== null &&
106
- expiredWindow === reservationExpiresAt &&
107
- cart?.reservation?.hasReservation === true;
108
-
109
- const unavailableItems = cart?.items.filter((item) => item.isAvailable === false) ?? [];
110
-
111
- const canProceedToCheckout =
112
- !!cart && cart.items.length > 0 && !reservationExpired && unavailableItems.length === 0;
113
-
114
- return {
115
- cart,
116
- cartLoading,
117
- refreshCart,
118
- itemCount,
119
- cartRecs,
120
- upgrades,
121
- bundles,
122
- reservationExpired,
123
- unavailableItems,
124
- canProceedToCheckout,
125
- onReservationExpired,
126
- };
127
- }
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import type {
5
+ Cart,
6
+ CartItem,
7
+ CartRecommendationsResponse,
8
+ CartUpgradesResponse,
9
+ CartBundlesResponse,
10
+ } from 'brainerce';
11
+ import { getClient } from '@/core/lib/brainerce';
12
+ import { useCart } from '@/core/providers/store-provider';
13
+
14
+ export interface UseCartPageResult {
15
+ cart: Cart | null;
16
+ cartLoading: boolean;
17
+ refreshCart: () => Promise<void>;
18
+ itemCount: number;
19
+ /** Cross-sell recommendations for the current cart contents. */
20
+ cartRecs: CartRecommendationsResponse | null;
21
+ /** Per-item upgrade suggestions keyed by productId. */
22
+ upgrades: CartUpgradesResponse | null;
23
+ /** Bundle offers matching the current cart. */
24
+ bundles: CartBundlesResponse | null;
25
+ /**
26
+ * True once the stock reservation on this cart has run out and the refreshed
27
+ * cart still carries the same expired window. Goes back to false on its own
28
+ * if the server hands back a fresh reservation.
29
+ */
30
+ reservationExpired: boolean;
31
+ /** Lines the server says cannot be bought right now (`isAvailable === false`). */
32
+ unavailableItems: CartItem[];
33
+ /**
34
+ * Cart line ids the shopper has ticked for checkout. Starts as every line
35
+ * and follows the cart: a newly added line arrives selected, a removed one
36
+ * drops out. This is a PARTIAL CHECKOUT selection, not a UI nicety —
37
+ * `createCheckout({ selectedItemIds })` is what the platform honours, and
38
+ * after payment the unselected lines stay in the still-ACTIVE cart.
39
+ */
40
+ selectedItemIds: string[];
41
+ /** Tick / untick one line. */
42
+ toggleItemSelected: (itemId: string) => void;
43
+ /** True when every line is ticked (the default, and the full-cart case). */
44
+ allSelected: boolean;
45
+ /** Tick or untick every line at once. */
46
+ setAllSelected: (selected: boolean) => void;
47
+ /**
48
+ * Where the proceed-to-checkout action should point. Carries the selection
49
+ * as `?items=` ONLY when it is a strict subset — a full cart links to a
50
+ * bare `/checkout`, so the default path is byte-for-byte what it was.
51
+ */
52
+ checkoutHref: string;
53
+ /**
54
+ * Lines the shopper actually ticked. Scope the summary and the checkout
55
+ * gate to these, not to the whole cart.
56
+ */
57
+ selectedItems: CartItem[];
58
+ /**
59
+ * False while the reservation is expired or any SELECTED line is
60
+ * unavailable. The proceed-to-checkout action must be genuinely disabled on
61
+ * false, not just styled as disabled. Scoped to the selection on purpose:
62
+ * an unavailable line the shopper has unticked is not going to the server,
63
+ * so it must not block a checkout of the rest.
64
+ */
65
+ canProceedToCheckout: boolean;
66
+ /**
67
+ * Hand this to `<ReservationCountdown onExpire={...}>`. It refreshes the
68
+ * cart once per expiry window: the server, not the client timer, decides
69
+ * what is still purchasable.
70
+ */
71
+ onReservationExpired: () => void;
72
+ }
73
+
74
+ /**
75
+ * Cart-page behavior: the shared cart state (from StoreProvider) plus the
76
+ * enrichment fetch (recommendations / upgrades / bundles) that runs whenever
77
+ * the cart contents change. Pure data/behavior — rendering lives in
78
+ * `ui/cart/cart-view.tsx`.
79
+ */
80
+ export function useCartPage(): UseCartPageResult {
81
+ const { cart, cartLoading, refreshCart, itemCount } = useCart();
82
+ const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
83
+ const [upgrades, setUpgrades] = useState<CartUpgradesResponse | null>(null);
84
+ const [bundles, setBundles] = useState<CartBundlesResponse | null>(null);
85
+
86
+ // Load recommendations, upgrades, and bundles in a single request
87
+ useEffect(() => {
88
+ if (!cart?.id || cart.items.length === 0) {
89
+ setCartRecs(null);
90
+ setUpgrades(null);
91
+ setBundles(null);
92
+ return;
93
+ }
94
+ const client = getClient();
95
+ client
96
+ .getCart(cart.id, { include: ['recommendations', 'upgrades', 'bundles'] })
97
+ .then((enriched) => {
98
+ setCartRecs(enriched.recommendations ?? null);
99
+ setUpgrades(enriched.upgrades ?? null);
100
+ setBundles(enriched.bundles ?? null);
101
+ })
102
+ .catch(() => {});
103
+ }, [cart?.id, cart?.items.length]);
104
+
105
+ // ---- Reservation expiry ----
106
+ //
107
+ // The countdown component only ticks. Expiry is handled here, once, so all
108
+ // three design variants share one behaviour and a user redesigning the
109
+ // countdown cannot delete the gate along with the markup.
110
+ //
111
+ // Both pieces of state key on the reservation's `expiresAt`, which is the
112
+ // only stable identity a reservation window has:
113
+ // - `handledExpiryRef` stops the refresh loop (refresh remounts the
114
+ // countdown, which would otherwise report the same expiry again).
115
+ // - comparing `expiredWindow` to the CURRENT `expiresAt` means a server
116
+ // that renews the reservation clears the expired state by itself.
117
+ const reservationExpiresAt = cart?.reservation?.expiresAt ?? null;
118
+ const handledExpiryRef = useRef<string | null>(null);
119
+ const [expiredWindow, setExpiredWindow] = useState<string | null>(null);
120
+
121
+ const onReservationExpired = useCallback(() => {
122
+ if (!reservationExpiresAt) return;
123
+ setExpiredWindow(reservationExpiresAt);
124
+ if (handledExpiryRef.current === reservationExpiresAt) return;
125
+ handledExpiryRef.current = reservationExpiresAt;
126
+ // Re-read from the server: it is the source of truth for what survived
127
+ // the released reservation. `isAvailable` on each line comes back updated.
128
+ void refreshCart();
129
+ }, [reservationExpiresAt, refreshCart]);
130
+
131
+ const reservationExpired =
132
+ expiredWindow !== null &&
133
+ expiredWindow === reservationExpiresAt &&
134
+ cart?.reservation?.hasReservation === true;
135
+
136
+ // ---- Partial-checkout selection ----
137
+ //
138
+ // Held as a Set of cart-line ids rather than a per-item boolean so it can be
139
+ // reconciled against the cart in one pass. `null` means "nothing has been
140
+ // untick'd yet", which is the everything-selected default — distinct from an
141
+ // empty Set, which is a shopper who deliberately cleared the list.
142
+ const [deselectedIds, setDeselectedIds] = useState<Set<string>>(() => new Set());
143
+
144
+ const items = useMemo(() => cart?.items ?? [], [cart?.items]);
145
+
146
+ // Drop ids for lines that no longer exist, so a removed-and-readded product
147
+ // does not come back invisibly unticked.
148
+ const liveItemIds = items.map((item) => item.id).join(',');
149
+ useEffect(() => {
150
+ setDeselectedIds((prev) => {
151
+ if (prev.size === 0) return prev;
152
+ const live = new Set(liveItemIds ? liveItemIds.split(',') : []);
153
+ const next = new Set([...prev].filter((id) => live.has(id)));
154
+ return next.size === prev.size ? prev : next;
155
+ });
156
+ }, [liveItemIds]);
157
+
158
+ const selectedItems = items.filter((item) => !deselectedIds.has(item.id));
159
+ const selectedItemIds = selectedItems.map((item) => item.id);
160
+ const allSelected = items.length > 0 && selectedItems.length === items.length;
161
+
162
+ const toggleItemSelected = useCallback((itemId: string) => {
163
+ setDeselectedIds((prev) => {
164
+ const next = new Set(prev);
165
+ if (next.has(itemId)) {
166
+ next.delete(itemId);
167
+ } else {
168
+ next.add(itemId);
169
+ }
170
+ return next;
171
+ });
172
+ }, []);
173
+
174
+ const setAllSelected = useCallback(
175
+ (selected: boolean) => {
176
+ setDeselectedIds(selected ? new Set() : new Set(items.map((item) => item.id)));
177
+ },
178
+ [items]
179
+ );
180
+
181
+ // A full cart keeps the plain `/checkout` URL so the common path is
182
+ // unchanged; only a strict subset carries the ids.
183
+ const checkoutHref =
184
+ allSelected || selectedItemIds.length === 0
185
+ ? '/checkout'
186
+ : `/checkout?items=${encodeURIComponent(selectedItemIds.join(','))}`;
187
+
188
+ // Reported over the whole cart (the banner names every blocked line) but
189
+ // gated over the selection below.
190
+ const unavailableItems = items.filter((item) => item.isAvailable === false);
191
+ const unavailableSelected = selectedItems.filter((item) => item.isAvailable === false);
192
+
193
+ const canProceedToCheckout =
194
+ !!cart &&
195
+ selectedItems.length > 0 &&
196
+ !reservationExpired &&
197
+ unavailableSelected.length === 0;
198
+
199
+ return {
200
+ cart,
201
+ cartLoading,
202
+ refreshCart,
203
+ itemCount,
204
+ cartRecs,
205
+ upgrades,
206
+ bundles,
207
+ reservationExpired,
208
+ unavailableItems,
209
+ selectedItemIds,
210
+ selectedItems,
211
+ toggleItemSelected,
212
+ allSelected,
213
+ setAllSelected,
214
+ checkoutHref,
215
+ canProceedToCheckout,
216
+ onReservationExpired,
217
+ };
218
+ }