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.
Files changed (77) hide show
  1. package/dist/index.js +35 -2
  2. package/messages/en.json +27 -1
  3. package/messages/he.json +27 -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/src/app/account/page.tsx +168 -122
  9. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
  10. package/templates/nextjs/base/src/app/checkout/page.tsx +145 -40
  11. package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
  12. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
  13. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
  14. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
  15. package/templates/nextjs/base/src/components/account/order-history.tsx +58 -7
  16. package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
  17. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
  18. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
  19. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
  20. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +97 -6
  21. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
  22. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
  23. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
  24. package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
  25. package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
  26. package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
  27. package/templates/nextjs/base/src/core/lib/region.ts +112 -0
  28. package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
  29. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
  30. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +10 -0
  31. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
  32. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +45 -2
  33. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
  34. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
  35. package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
  36. package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
  37. package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
  38. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
  39. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
  40. package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
  41. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
  42. package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
  43. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
  44. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
  45. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
  46. package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
  47. package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
  48. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +10 -0
  49. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
  50. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +56 -11
  51. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
  52. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
  53. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
  54. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
  55. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
  56. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
  57. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
  58. package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
  59. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
  60. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
  61. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
  62. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
  63. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
  64. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
  65. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
  66. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
  67. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
  68. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
  69. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
  70. package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
  71. package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
  72. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
  73. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
  74. package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
  75. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
  76. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
  77. 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 { useTranslations } from '@/core/lib/translations';
13
-
14
- export default function AccountPage() {
15
- const router = useRouter();
16
- const { isLoggedIn, authLoading, logout } = useAuth();
17
- const t = useTranslations('account');
18
- const tc = useTranslations('common');
19
- const [profile, setProfile] = useState<CustomerProfile | null>(null);
20
- const [orders, setOrders] = useState<Order[]>([]);
21
- const [loading, setLoading] = useState(true);
22
- const [error, setError] = useState<string | null>(null);
23
-
24
- useEffect(() => {
25
- if (authLoading) return;
26
-
27
- if (!isLoggedIn) {
28
- router.push('/login');
29
- return;
30
- }
31
-
32
- async function loadAccountData() {
33
- try {
34
- const client = getClient();
35
- const [profileResult, ordersResult] = await Promise.allSettled([
36
- client.getMyProfile(),
37
- client.getMyOrders({ limit: 20 }),
38
- ]);
39
-
40
- if (profileResult.status === 'fulfilled') {
41
- setProfile(profileResult.value);
42
- } else {
43
- setError('Failed to load profile.');
44
- }
45
-
46
- if (ordersResult.status === 'fulfilled') {
47
- setOrders(ordersResult.value.data);
48
- }
49
- } catch (err) {
50
- const message = err instanceof Error ? err.message : 'Failed to load account data.';
51
- setError(message);
52
- } finally {
53
- setLoading(false);
54
- }
55
- }
56
-
57
- loadAccountData();
58
- }, [isLoggedIn, authLoading, router]);
59
-
60
- if (authLoading || !isLoggedIn) {
61
- return null;
62
- }
63
-
64
- if (loading) {
65
- return (
66
- <div className="flex min-h-[60vh] items-center justify-center">
67
- <LoadingSpinner size="lg" />
68
- </div>
69
- );
70
- }
71
-
72
- if (error && !profile) {
73
- return (
74
- <div className="mx-auto max-w-3xl px-4 py-16 text-center sm:px-6 lg:px-8">
75
- <h1 className="text-foreground text-2xl font-bold">{tc('error')}</h1>
76
- <p className="text-muted-foreground mt-2">{error}</p>
77
- <button
78
- type="button"
79
- onClick={() => window.location.reload()}
80
- className="bg-primary text-primary-foreground mt-6 inline-flex items-center rounded px-6 py-3 font-medium transition-opacity hover:opacity-90"
81
- >
82
- {tc('tryAgain')}
83
- </button>
84
- </div>
85
- );
86
- }
87
-
88
- return (
89
- <div className="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
90
- <div className="mb-6 flex items-center justify-between">
91
- <h1 className="text-foreground text-2xl font-bold">{t('myAccount')}</h1>
92
- <button
93
- type="button"
94
- onClick={logout}
95
- className="text-muted-foreground hover:text-foreground text-sm transition-colors"
96
- >
97
- {t('signOut')}
98
- </button>
99
- </div>
100
-
101
- {/* Profile Section */}
102
- {profile && (
103
- <ProfileSection profile={profile} onProfileUpdate={setProfile} className="mb-6" />
104
- )}
105
-
106
- {/* Address Book */}
107
- {profile && (
108
- <AddressBook
109
- addresses={profile.addresses ?? []}
110
- onUpdate={(updated) => setProfile((p) => (p ? { ...p, addresses: updated } : p))}
111
- className="mb-8"
112
- />
113
- )}
114
-
115
- {/* Order History */}
116
- <div>
117
- <h2 className="text-foreground mb-4 text-lg font-semibold">{t('orderHistory')}</h2>
118
- <OrderHistory orders={orders} />
119
- </div>
120
- </div>
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
- const { data: products } = await client.getProducts({ categories: [category.id], limit: 48 });
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
- const newCheckout = await client.createCheckout({ cartId: cart.id });
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
- }, [cart?.id, existingCheckoutId]);
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="flex gap-3">
842
- <div className="bg-muted relative h-12 w-12 flex-shrink-0 overflow-hidden rounded">
843
- {imageUrl ? (
844
- <Image
845
- src={imageUrl}
846
- alt={name}
847
- fill
848
- sizes="48px"
849
- className="object-cover"
850
- />
851
- ) : (
852
- <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
853
- <svg
854
- className="h-5 w-5"
855
- fill="none"
856
- viewBox="0 0 24 24"
857
- stroke="currentColor"
858
- >
859
- <path
860
- strokeLinecap="round"
861
- strokeLinejoin="round"
862
- strokeWidth={1.5}
863
- 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"
864
- />
865
- </svg>
866
- </div>
867
- )}
868
- </div>
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
- <div className="min-w-0 flex-1">
871
- <p className="text-foreground truncate text-sm">{name}</p>
872
- <p className="text-muted-foreground text-xs">
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
- <span className="text-foreground flex-shrink-0 text-sm font-medium">
878
- {formatPrice(lineTotal, { currency }) as string}
879
- </span>
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
  );