create-brainerce-store 1.73.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 +35 -2
- package/messages/en.json +27 -1
- package/messages/he.json +27 -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/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 +145 -40
- 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 +58 -7
- 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 +97 -6
- 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 +10 -0
- package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +45 -2
- package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
- 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/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-item.tsx +10 -0
- package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +56 -11
- 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/stock-badge.tsx +17 -2
- package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
- package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
- package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
- 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/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,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import type {
|
|
5
5
|
Cart,
|
|
6
6
|
CartItem,
|
|
@@ -31,9 +31,36 @@ export interface UseCartPageResult {
|
|
|
31
31
|
/** Lines the server says cannot be bought right now (`isAvailable === false`). */
|
|
32
32
|
unavailableItems: CartItem[];
|
|
33
33
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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.
|
|
37
64
|
*/
|
|
38
65
|
canProceedToCheckout: boolean;
|
|
39
66
|
/**
|
|
@@ -106,10 +133,68 @@ export function useCartPage(): UseCartPageResult {
|
|
|
106
133
|
expiredWindow === reservationExpiresAt &&
|
|
107
134
|
cart?.reservation?.hasReservation === true;
|
|
108
135
|
|
|
109
|
-
|
|
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);
|
|
110
192
|
|
|
111
193
|
const canProceedToCheckout =
|
|
112
|
-
!!cart &&
|
|
194
|
+
!!cart &&
|
|
195
|
+
selectedItems.length > 0 &&
|
|
196
|
+
!reservationExpired &&
|
|
197
|
+
unavailableSelected.length === 0;
|
|
113
198
|
|
|
114
199
|
return {
|
|
115
200
|
cart,
|
|
@@ -121,6 +206,12 @@ export function useCartPage(): UseCartPageResult {
|
|
|
121
206
|
bundles,
|
|
122
207
|
reservationExpired,
|
|
123
208
|
unavailableItems,
|
|
209
|
+
selectedItemIds,
|
|
210
|
+
selectedItems,
|
|
211
|
+
toggleItemSelected,
|
|
212
|
+
allSelected,
|
|
213
|
+
setAllSelected,
|
|
214
|
+
checkoutHref,
|
|
124
215
|
canProceedToCheckout,
|
|
125
216
|
onReservationExpired,
|
|
126
217
|
};
|
|
@@ -1,51 +1,63 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useEffect, useState } from 'react';
|
|
4
|
-
import type { Product
|
|
5
|
-
import { getClient } from '@/core/lib/brainerce';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
products
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import type { Product } from 'brainerce';
|
|
5
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
6
|
+
import { useRegion } from '@/core/providers/store-provider';
|
|
7
|
+
|
|
8
|
+
export interface UseHomeDataResult {
|
|
9
|
+
/** Newest products for the featured grid (max 8). */
|
|
10
|
+
products: Product[];
|
|
11
|
+
/** True while the initial fetch is in flight. */
|
|
12
|
+
loading: boolean;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Homepage data: the featured product grid.
|
|
17
|
+
*
|
|
18
|
+
* Discount banners are deliberately NOT fetched here. They render below the
|
|
19
|
+
* header on every page, so the root layout fetches them server-side and hands
|
|
20
|
+
* them to `<DiscountBannerStrip>`. Fetching them again on the home page would
|
|
21
|
+
* be a duplicate request and a second, later-arriving copy of the same strip.
|
|
22
|
+
*
|
|
23
|
+
* Pure data/behavior — rendering lives in `ui/home/`.
|
|
24
|
+
*/
|
|
25
|
+
export function useHomeData(): UseHomeDataResult {
|
|
26
|
+
const [products, setProducts] = useState<Product[]>([]);
|
|
27
|
+
const [loading, setLoading] = useState(true);
|
|
28
|
+
// ⛔ Every product read carries the region, or the featured grid quotes the
|
|
29
|
+
// default region's currency to a shopper the product page then prices
|
|
30
|
+
// differently. `undefined` on a store with no regions, and the SDK omits
|
|
31
|
+
// the param, so a single-region store behaves exactly as before.
|
|
32
|
+
const { regionId } = useRegion();
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
async function load() {
|
|
36
|
+
try {
|
|
37
|
+
const client = getClient();
|
|
38
|
+
const [productsRes] = await Promise.allSettled([
|
|
39
|
+
client.getProducts({
|
|
40
|
+
limit: 8,
|
|
41
|
+
sortBy: 'createdAt',
|
|
42
|
+
sortOrder: 'desc',
|
|
43
|
+
...(regionId ? { regionId } : {}),
|
|
44
|
+
}),
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
if (productsRes.status === 'fulfilled') {
|
|
48
|
+
setProducts(productsRes.value.data);
|
|
49
|
+
}
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.error('Failed to load home page data:', err);
|
|
52
|
+
} finally {
|
|
53
|
+
setLoading(false);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
load();
|
|
58
|
+
// Re-runs when the shopper switches region, so the grid re-prices instead
|
|
59
|
+
// of keeping the currency it was first rendered with.
|
|
60
|
+
}, [regionId]);
|
|
61
|
+
|
|
62
|
+
return { products, loading };
|
|
63
|
+
}
|