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.
- package/dist/index.js +37 -4
- package/messages/en.json +30 -1
- package/messages/he.json +30 -1
- package/package.json +1 -1
- package/templates/nextjs/base/AGENTS.md.ejs +63 -16
- package/templates/nextjs/base/AI-GUIDE.md +17 -4
- package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
- package/templates/nextjs/base/TRANSLATIONS.md +207 -207
- package/templates/nextjs/base/src/app/account/page.tsx +168 -122
- package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
- package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
- package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
- package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
- package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
- package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
- package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
- package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
- package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
- package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
- package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
- package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
- package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
- package/templates/nextjs/base/src/core/lib/region.ts +112 -0
- package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
- package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
- package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
- package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
- package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
- package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
- package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
- package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
- package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
- package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
- package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
- package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
- package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
- package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
- package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
- package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
- package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
- package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
- package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
- package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
- package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
- package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
- package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
- package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
- package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
- package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
- package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
- package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
- package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
- package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
- package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
- package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
- package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
- package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
- package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
- 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
|
-
//
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
+
}
|