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,122 +1,168 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useEffect, useState } from 'react';
|
|
4
|
-
import { useRouter } from '@/core/lib/navigation';
|
|
5
|
-
import type { CustomerProfile, Order } from 'brainerce';
|
|
6
|
-
import { getClient } from '@/core/lib/brainerce';
|
|
7
|
-
import { useAuth } from '@/core/providers/store-provider';
|
|
8
|
-
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
9
|
-
import { ProfileSection } from '@/components/account/profile-section';
|
|
10
|
-
import { AddressBook } from '@/components/account/address-book';
|
|
11
|
-
import { OrderHistory } from '@/components/account/order-history';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
const
|
|
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
|
-
|
|
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
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
4
|
+
import { useRouter } from '@/core/lib/navigation';
|
|
5
|
+
import type { CustomerProfile, Order } from 'brainerce';
|
|
6
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
7
|
+
import { useAuth } from '@/core/providers/store-provider';
|
|
8
|
+
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
9
|
+
import { ProfileSection } from '@/components/account/profile-section';
|
|
10
|
+
import { AddressBook } from '@/components/account/address-book';
|
|
11
|
+
import { OrderHistory } from '@/components/account/order-history';
|
|
12
|
+
import { LoyaltyPanel } from '@/components/account/loyalty-panel';
|
|
13
|
+
import { SavedPaymentMethods } from '@/components/account/saved-payment-methods';
|
|
14
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
15
|
+
|
|
16
|
+
/** Orders fetched per page. The API caps `limit` at 100. */
|
|
17
|
+
const ORDERS_PAGE_SIZE = 20;
|
|
18
|
+
|
|
19
|
+
export default function AccountPage() {
|
|
20
|
+
const router = useRouter();
|
|
21
|
+
const { isLoggedIn, authLoading, logout } = useAuth();
|
|
22
|
+
const t = useTranslations('account');
|
|
23
|
+
const tc = useTranslations('common');
|
|
24
|
+
const [profile, setProfile] = useState<CustomerProfile | null>(null);
|
|
25
|
+
const [orders, setOrders] = useState<Order[]>([]);
|
|
26
|
+
// Order history is paginated: `getMyOrders` answers the standard
|
|
27
|
+
// { data, meta: { page, limit, total, totalPages } } envelope. Without the
|
|
28
|
+
// page cursor below, order 21 is unreachable forever.
|
|
29
|
+
const [ordersPage, setOrdersPage] = useState(1);
|
|
30
|
+
const [ordersTotalPages, setOrdersTotalPages] = useState(1);
|
|
31
|
+
const [ordersLoadingMore, setOrdersLoadingMore] = useState(false);
|
|
32
|
+
const [loading, setLoading] = useState(true);
|
|
33
|
+
const [error, setError] = useState<string | null>(null);
|
|
34
|
+
|
|
35
|
+
/** Fetch one page of orders. `append` distinguishes load-more from a reload. */
|
|
36
|
+
const loadOrders = useCallback(async (page: number, append: boolean) => {
|
|
37
|
+
if (append) setOrdersLoadingMore(true);
|
|
38
|
+
try {
|
|
39
|
+
const result = await getClient().getMyOrders({ page, limit: ORDERS_PAGE_SIZE });
|
|
40
|
+
setOrders((prev) => (append ? [...prev, ...result.data] : result.data));
|
|
41
|
+
setOrdersPage(result.meta.page);
|
|
42
|
+
setOrdersTotalPages(result.meta.totalPages);
|
|
43
|
+
} catch {
|
|
44
|
+
// A failed page must not blank the pages already on screen.
|
|
45
|
+
} finally {
|
|
46
|
+
if (append) setOrdersLoadingMore(false);
|
|
47
|
+
}
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (authLoading) return;
|
|
52
|
+
|
|
53
|
+
if (!isLoggedIn) {
|
|
54
|
+
router.push('/login');
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function loadAccountData() {
|
|
59
|
+
try {
|
|
60
|
+
const client = getClient();
|
|
61
|
+
const [profileResult, ordersResult] = await Promise.allSettled([
|
|
62
|
+
client.getMyProfile(),
|
|
63
|
+
client.getMyOrders({ page: 1, limit: ORDERS_PAGE_SIZE }),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
if (profileResult.status === 'fulfilled') {
|
|
67
|
+
setProfile(profileResult.value);
|
|
68
|
+
} else {
|
|
69
|
+
setError('Failed to load profile.');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (ordersResult.status === 'fulfilled') {
|
|
73
|
+
setOrders(ordersResult.value.data);
|
|
74
|
+
setOrdersPage(ordersResult.value.meta.page);
|
|
75
|
+
setOrdersTotalPages(ordersResult.value.meta.totalPages);
|
|
76
|
+
}
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const message = err instanceof Error ? err.message : 'Failed to load account data.';
|
|
79
|
+
setError(message);
|
|
80
|
+
} finally {
|
|
81
|
+
setLoading(false);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
loadAccountData();
|
|
86
|
+
}, [isLoggedIn, authLoading, router]);
|
|
87
|
+
|
|
88
|
+
if (authLoading || !isLoggedIn) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (loading) {
|
|
93
|
+
return (
|
|
94
|
+
<div className="flex min-h-[60vh] items-center justify-center">
|
|
95
|
+
<LoadingSpinner size="lg" />
|
|
96
|
+
</div>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (error && !profile) {
|
|
101
|
+
return (
|
|
102
|
+
<div className="mx-auto max-w-3xl px-4 py-16 text-center sm:px-6 lg:px-8">
|
|
103
|
+
<h1 className="text-foreground text-2xl font-bold">{tc('error')}</h1>
|
|
104
|
+
<p className="text-muted-foreground mt-2">{error}</p>
|
|
105
|
+
<button
|
|
106
|
+
type="button"
|
|
107
|
+
onClick={() => window.location.reload()}
|
|
108
|
+
className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
|
|
109
|
+
>
|
|
110
|
+
{tc('tryAgain')}
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
|
118
|
+
<div className="mb-6 flex items-center justify-between">
|
|
119
|
+
<h1 className="text-foreground text-2xl font-bold">{t('myAccount')}</h1>
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
onClick={logout}
|
|
123
|
+
className="text-muted-foreground hover:text-foreground text-sm transition-colors"
|
|
124
|
+
>
|
|
125
|
+
{t('signOut')}
|
|
126
|
+
</button>
|
|
127
|
+
</div>
|
|
128
|
+
|
|
129
|
+
{/* Profile Section */}
|
|
130
|
+
{profile && (
|
|
131
|
+
<ProfileSection profile={profile} onProfileUpdate={setProfile} className="mb-6" />
|
|
132
|
+
)}
|
|
133
|
+
|
|
134
|
+
{/* Address Book */}
|
|
135
|
+
{profile && (
|
|
136
|
+
<AddressBook
|
|
137
|
+
addresses={profile.addresses ?? []}
|
|
138
|
+
onUpdate={(updated) => setProfile((p) => (p ? { ...p, addresses: updated } : p))}
|
|
139
|
+
className="mb-8"
|
|
140
|
+
/>
|
|
141
|
+
)}
|
|
142
|
+
|
|
143
|
+
{/* Saved cards. Renders nothing at all until the customer has vaulted
|
|
144
|
+
one by checking out with saveCard, which is the normal case, so it
|
|
145
|
+
is mounted unconditionally and hides itself. Read-only on purpose:
|
|
146
|
+
the only storefront call that CHARGES a vaulted card is
|
|
147
|
+
subscribeToMembership, so there is no "pay with this card" action to
|
|
148
|
+
offer here. */}
|
|
149
|
+
<SavedPaymentMethods className="mb-8" />
|
|
150
|
+
|
|
151
|
+
{/* Rewards. Renders nothing at all when the store has no loyalty
|
|
152
|
+
programme, which is the normal case until the merchant turns one on —
|
|
153
|
+
so it is mounted unconditionally and hides itself. */}
|
|
154
|
+
<LoyaltyPanel className="mb-8" />
|
|
155
|
+
|
|
156
|
+
{/* Order History */}
|
|
157
|
+
<div>
|
|
158
|
+
<h2 className="text-foreground mb-4 text-lg font-semibold">{t('orderHistory')}</h2>
|
|
159
|
+
<OrderHistory
|
|
160
|
+
orders={orders}
|
|
161
|
+
hasMore={ordersPage < ordersTotalPages}
|
|
162
|
+
loadingMore={ordersLoadingMore}
|
|
163
|
+
onLoadMore={() => loadOrders(ordersPage + 1, true)}
|
|
164
|
+
/>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -2,6 +2,7 @@ import type { Metadata } from 'next';
|
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
3
|
import { BrainerceError } from 'brainerce';
|
|
4
4
|
import { getServerClient, fetchStoreInfo } from '@/core/lib/brainerce.server';
|
|
5
|
+
import { getRegionId } from '@/core/lib/region.server';
|
|
5
6
|
import { buildMetaDescription } from '@/core/lib/seo';
|
|
6
7
|
import { sanitizeHtml } from '@/core/lib/sanitize';
|
|
7
8
|
import { decodeSlug } from '@/core/lib/utils';
|
|
@@ -103,7 +104,15 @@ export default async function CategoryPage({ params }: Props) {
|
|
|
103
104
|
|
|
104
105
|
// The listing endpoint owns pagination/FX/publish gating — reuse it rather
|
|
105
106
|
// than duplicating a product query here.
|
|
106
|
-
|
|
107
|
+
// `regionId` on the listing read too: a category grid that prices in the
|
|
108
|
+
// default region while the product page prices in the buyer's is worse than
|
|
109
|
+
// neither doing it. Resolves to undefined on a single-region store, which
|
|
110
|
+
// makes this call byte-identical to what it was.
|
|
111
|
+
const { data: products } = await client.getProducts({
|
|
112
|
+
categories: [category.id],
|
|
113
|
+
limit: 48,
|
|
114
|
+
regionId: await getRegionId(),
|
|
115
|
+
});
|
|
107
116
|
|
|
108
117
|
const baseUrl = await getCanonicalSiteUrl();
|
|
109
118
|
const url = `${baseUrl}/category/${category.slug || slug}`;
|
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
} from 'brainerce';
|
|
16
16
|
import { formatPrice } from 'brainerce';
|
|
17
17
|
import { getClient } from '@/core/lib/brainerce';
|
|
18
|
-
import { useStoreInfo, useCart, useAuth } from '@/core/providers/store-provider';
|
|
18
|
+
import { useStoreInfo, useCart, useAuth, useRegion } from '@/core/providers/store-provider';
|
|
19
19
|
import { useCurrency } from '@/core/lib/use-currency';
|
|
20
20
|
import { CheckoutForm } from '@/components/checkout/checkout-form';
|
|
21
21
|
import { ShippingStep } from '@/components/checkout/shipping-step';
|
|
@@ -25,7 +25,9 @@ import { PickupStep } from '@/components/checkout/pickup-step';
|
|
|
25
25
|
import { CustomFieldsStep } from '@/components/checkout/custom-fields-step';
|
|
26
26
|
import { TaxDisplay } from '@/components/checkout/tax-display';
|
|
27
27
|
import { OrderBumpCard } from '@/components/checkout/order-bump-card';
|
|
28
|
+
import { OrderCustomizations } from '@/components/account/order-customizations';
|
|
28
29
|
import { CouponInput } from '@/ui/cart/coupon-input';
|
|
30
|
+
import { GiftCardInput } from '@/ui/cart/gift-card-input';
|
|
29
31
|
import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
|
|
30
32
|
import { LoadingSpinner } from '@/ui/shared/loading-spinner';
|
|
31
33
|
import { useTranslations } from '@/core/lib/translations';
|
|
@@ -40,6 +42,11 @@ function CheckoutContent() {
|
|
|
40
42
|
const { storeInfo } = useStoreInfo();
|
|
41
43
|
const { cart, refreshCart } = useCart();
|
|
42
44
|
const { isLoggedIn } = useAuth();
|
|
45
|
+
// ⛔ THE call the region feature exists for. Product reads alone and the
|
|
46
|
+
// shopper is SHOWN one price and CHARGED another: the checkout records the
|
|
47
|
+
// region for reporting and for scoping which payment providers are offered.
|
|
48
|
+
// `undefined` on a store with no regions, and the field is then omitted.
|
|
49
|
+
const { regionId } = useRegion();
|
|
43
50
|
const currency = useCurrency();
|
|
44
51
|
const t = useTranslations('checkout');
|
|
45
52
|
const tc = useTranslations('common');
|
|
@@ -111,6 +118,16 @@ function CheckoutContent() {
|
|
|
111
118
|
const checkoutIdParam = searchParams.get('checkout_id');
|
|
112
119
|
const existingCheckoutId = isValidCheckoutId(checkoutIdParam) ? checkoutIdParam : null;
|
|
113
120
|
|
|
121
|
+
// ---- Partial checkout ----
|
|
122
|
+
//
|
|
123
|
+
// The cart page hands the ticked lines over as `?items=id1,id2` and omits
|
|
124
|
+
// the param entirely for a full cart. Read as a raw string here and resolved
|
|
125
|
+
// against the live cart inside initCheckout: it is URL input, so it is only
|
|
126
|
+
// ever used after filtering against ids the cart actually has, and it is
|
|
127
|
+
// read once at init rather than added to the effect's deps — a change must
|
|
128
|
+
// not silently create a SECOND checkout for the same cart.
|
|
129
|
+
const selectedItemsParam = searchParams.get('items');
|
|
130
|
+
|
|
114
131
|
// Pre-fill address and customer data from profile when logged in
|
|
115
132
|
useEffect(() => {
|
|
116
133
|
if (!isLoggedIn) return;
|
|
@@ -198,8 +215,27 @@ function CheckoutContent() {
|
|
|
198
215
|
return;
|
|
199
216
|
}
|
|
200
217
|
|
|
201
|
-
// Create new checkout — cart is always server-side now
|
|
202
|
-
|
|
218
|
+
// Create new checkout — cart is always server-side now.
|
|
219
|
+
//
|
|
220
|
+
// `selectedItemIds` scopes the checkout to a subset of the cart: after
|
|
221
|
+
// payment those lines are removed and the rest stay in the still-ACTIVE
|
|
222
|
+
// cart. Sent ONLY when the shopper picked a strict, non-empty subset —
|
|
223
|
+
// an empty result (a stale link, or ids from another cart) and a
|
|
224
|
+
// full-cart selection both fall through to the normal whole-cart call,
|
|
225
|
+
// because omitting the field is what "check out everything" means.
|
|
226
|
+
const cartItemIds = new Set(cart.items.map((item) => item.id));
|
|
227
|
+
const requestedItemIds = (selectedItemsParam ?? '')
|
|
228
|
+
.split(',')
|
|
229
|
+
.map((id) => id.trim())
|
|
230
|
+
.filter((id) => cartItemIds.has(id));
|
|
231
|
+
const isStrictSubset =
|
|
232
|
+
requestedItemIds.length > 0 && requestedItemIds.length < cartItemIds.size;
|
|
233
|
+
|
|
234
|
+
const newCheckout = await client.createCheckout({
|
|
235
|
+
cartId: cart.id,
|
|
236
|
+
...(isStrictSubset ? { selectedItemIds: requestedItemIds } : {}),
|
|
237
|
+
...(regionId ? { regionId } : {}),
|
|
238
|
+
});
|
|
203
239
|
setCheckout(newCheckout);
|
|
204
240
|
|
|
205
241
|
// If all items are downloadable, skip shipping — show contact info step
|
|
@@ -225,7 +261,11 @@ function CheckoutContent() {
|
|
|
225
261
|
};
|
|
226
262
|
|
|
227
263
|
initCheckout();
|
|
228
|
-
|
|
264
|
+
// `regionId` is a dependency because a checkout is created ONCE per cart:
|
|
265
|
+
// if the shopper switches region on the cart page and lands here, the
|
|
266
|
+
// effect has to re-run or the checkout is stamped with the region they
|
|
267
|
+
// just left.
|
|
268
|
+
}, [cart?.id, existingCheckoutId, regionId]);
|
|
229
269
|
|
|
230
270
|
// Load order bumps when checkout is available
|
|
231
271
|
useEffect(() => {
|
|
@@ -838,45 +878,57 @@ function CheckoutContent() {
|
|
|
838
878
|
const lineTotal = parseFloat(item.unitPrice) * item.quantity;
|
|
839
879
|
|
|
840
880
|
return (
|
|
841
|
-
<div key={item.id} className="
|
|
842
|
-
<div className="
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
<
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
881
|
+
<div key={item.id} className="space-y-1">
|
|
882
|
+
<div className="flex gap-3">
|
|
883
|
+
<div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
|
|
884
|
+
{imageUrl ? (
|
|
885
|
+
<Image
|
|
886
|
+
src={imageUrl}
|
|
887
|
+
alt={name}
|
|
888
|
+
fill
|
|
889
|
+
sizes="48px"
|
|
890
|
+
className="object-cover"
|
|
891
|
+
/>
|
|
892
|
+
) : (
|
|
893
|
+
<div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
|
|
894
|
+
<svg
|
|
895
|
+
className="h-5 w-5"
|
|
896
|
+
fill="none"
|
|
897
|
+
viewBox="0 0 24 24"
|
|
898
|
+
stroke="currentColor"
|
|
899
|
+
>
|
|
900
|
+
<path
|
|
901
|
+
strokeLinecap="round"
|
|
902
|
+
strokeLinejoin="round"
|
|
903
|
+
strokeWidth={1.5}
|
|
904
|
+
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
|
|
905
|
+
/>
|
|
906
|
+
</svg>
|
|
907
|
+
</div>
|
|
908
|
+
)}
|
|
909
|
+
</div>
|
|
910
|
+
|
|
911
|
+
<div className="min-w-0 flex-1">
|
|
912
|
+
<p className="text-foreground truncate text-sm">{name}</p>
|
|
913
|
+
<p className="text-muted-foreground text-xs">
|
|
914
|
+
{tc('qty')} {item.quantity}
|
|
915
|
+
</p>
|
|
916
|
+
</div>
|
|
869
917
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
{tc('qty')} {item.quantity}
|
|
874
|
-
</p>
|
|
918
|
+
<span className="text-foreground flex-shrink-0 text-sm font-medium">
|
|
919
|
+
{formatPrice(lineTotal, { currency }) as string}
|
|
920
|
+
</span>
|
|
875
921
|
</div>
|
|
876
922
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
923
|
+
{/* The buyer's own input on this line — engraving text,
|
|
924
|
+
uploaded photo, picked colour — so it can be checked
|
|
925
|
+
before paying. `CheckoutLineItem.customizations` is
|
|
926
|
+
the SDK's resolved label/value/type map, absent on a
|
|
927
|
+
line with no customization, so a plain order renders
|
|
928
|
+
exactly as before. */}
|
|
929
|
+
{item.customizations && (
|
|
930
|
+
<OrderCustomizations customizations={item.customizations} />
|
|
931
|
+
)}
|
|
880
932
|
</div>
|
|
881
933
|
);
|
|
882
934
|
})}
|
|
@@ -919,6 +971,22 @@ function CheckoutContent() {
|
|
|
919
971
|
checkoutId={checkout?.id}
|
|
920
972
|
onUpdate={handleCouponUpdate}
|
|
921
973
|
/>
|
|
974
|
+
{/* Beside the coupon field, and deliberately NOT inside it. A
|
|
975
|
+
coupon reduces what the order is worth; a gift card pays
|
|
976
|
+
for an order still worth the same. They look adjacent
|
|
977
|
+
because a shopper reaches for them at the same moment, and
|
|
978
|
+
they behave differently because they are different things. */}
|
|
979
|
+
{checkout?.id && (
|
|
980
|
+
<GiftCardInput
|
|
981
|
+
className="mt-3"
|
|
982
|
+
checkoutId={checkout.id}
|
|
983
|
+
tenders={checkout.tenders ?? []}
|
|
984
|
+
formatAmount={(value) =>
|
|
985
|
+
formatPrice(parseFloat(value), { currency }) as string
|
|
986
|
+
}
|
|
987
|
+
onUpdate={handleCouponUpdate}
|
|
988
|
+
/>
|
|
989
|
+
)}
|
|
922
990
|
</div>
|
|
923
991
|
)}
|
|
924
992
|
|
|
@@ -1048,6 +1116,43 @@ function CheckoutContent() {
|
|
|
1048
1116
|
{formatPrice(parseFloat(checkout.total), { currency }) as string}
|
|
1049
1117
|
</span>
|
|
1050
1118
|
</div>
|
|
1119
|
+
|
|
1120
|
+
{/* Gift cards sit BELOW the total, not among the discounts
|
|
1121
|
+
above it. The total is what the order is worth and does
|
|
1122
|
+
not move; what changes is only what the card leaves for
|
|
1123
|
+
the payment provider to charge. Putting this in the
|
|
1124
|
+
discount block would understate the taxable base to the
|
|
1125
|
+
shopper and on the receipt. */}
|
|
1126
|
+
{checkout.tenders && checkout.tenders.length > 0 && (
|
|
1127
|
+
<>
|
|
1128
|
+
{checkout.tenders.map((tender) => (
|
|
1129
|
+
<div
|
|
1130
|
+
key={tender.tenderId}
|
|
1131
|
+
className="mt-2 flex items-center justify-between text-sm"
|
|
1132
|
+
>
|
|
1133
|
+
<span className="text-muted-foreground">{tc('giftCard')}</span>
|
|
1134
|
+
<span className="text-primary">
|
|
1135
|
+
-
|
|
1136
|
+
{
|
|
1137
|
+
formatPrice(parseFloat(tender.amountApplied), {
|
|
1138
|
+
currency,
|
|
1139
|
+
}) as string
|
|
1140
|
+
}
|
|
1141
|
+
</span>
|
|
1142
|
+
</div>
|
|
1143
|
+
))}
|
|
1144
|
+
<div className="border-border mt-2 flex items-center justify-between border-t pt-2">
|
|
1145
|
+
<span className="text-foreground font-semibold">{tc('amountDue')}</span>
|
|
1146
|
+
<span className="text-foreground text-base font-semibold">
|
|
1147
|
+
{
|
|
1148
|
+
formatPrice(parseFloat(checkout.providerAmountDue ?? checkout.total), {
|
|
1149
|
+
currency,
|
|
1150
|
+
}) as string
|
|
1151
|
+
}
|
|
1152
|
+
</span>
|
|
1153
|
+
</div>
|
|
1154
|
+
</>
|
|
1155
|
+
)}
|
|
1051
1156
|
</div>
|
|
1052
1157
|
</div>
|
|
1053
1158
|
);
|