create-brainerce-store 1.71.0 → 1.72.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/README.md +31 -10
- package/dist/index.js +177 -105
- package/messages/en.json +11 -1
- package/messages/he.json +11 -1
- package/package.json +1 -1
- package/templates/nextjs/base/TRANSLATIONS.md +14 -7
- package/templates/nextjs/base/src/app/checkout/page.tsx +59 -3
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +21 -2
- package/templates/nextjs/base/src/components/account/order-history.tsx +25 -39
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +30 -11
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +71 -2
- package/templates/nextjs/base/src/core/lib/auth.ts +32 -39
- package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +5 -16
- package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +3 -6
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +19 -1
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +42 -6
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +52 -10
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +21 -3
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +19 -1
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +44 -7
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +52 -10
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +15 -1
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +38 -4
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +54 -11
|
@@ -43,6 +43,7 @@ function CheckoutContent() {
|
|
|
43
43
|
const currency = useCurrency();
|
|
44
44
|
const t = useTranslations('checkout');
|
|
45
45
|
const tc = useTranslations('common');
|
|
46
|
+
const tr = useTranslations('reservation');
|
|
46
47
|
|
|
47
48
|
const [step, setStep] = useState<CheckoutStep>('address');
|
|
48
49
|
const [checkout, setCheckout] = useState<Checkout | null>(null);
|
|
@@ -70,6 +71,30 @@ function CheckoutContent() {
|
|
|
70
71
|
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>({});
|
|
71
72
|
const [customFieldsLoading, setCustomFieldsLoading] = useState(false);
|
|
72
73
|
|
|
74
|
+
// ---- Reservation expiry blocks payment ----
|
|
75
|
+
//
|
|
76
|
+
// When the reservation window runs out the held stock goes back on sale, so
|
|
77
|
+
// the amount and the line items on this page may no longer be deliverable.
|
|
78
|
+
// Payment is blocked until the shopper revisits the cart. Both pieces of
|
|
79
|
+
// state key on the reservation's `expiresAt` rather than a bare boolean, so
|
|
80
|
+
// the block survives a remount of the countdown and the ref stops the cart
|
|
81
|
+
// refresh from looping. This page does not refetch the checkout, so the
|
|
82
|
+
// block stands for the rest of the session once it closes.
|
|
83
|
+
const reservationExpiresAt = checkout?.reservation?.expiresAt ?? null;
|
|
84
|
+
const handledExpiryRef = useRef<string | null>(null);
|
|
85
|
+
const [expiredWindow, setExpiredWindow] = useState<string | null>(null);
|
|
86
|
+
const reservationExpired = expiredWindow !== null && expiredWindow === reservationExpiresAt;
|
|
87
|
+
|
|
88
|
+
const handleReservationExpired = useCallback(() => {
|
|
89
|
+
if (!reservationExpiresAt) return;
|
|
90
|
+
setExpiredWindow(reservationExpiresAt);
|
|
91
|
+
if (handledExpiryRef.current === reservationExpiresAt) return;
|
|
92
|
+
handledExpiryRef.current = reservationExpiresAt;
|
|
93
|
+
// Keep the header badge honest: the server may have dropped lines that
|
|
94
|
+
// are no longer purchasable.
|
|
95
|
+
void refreshCart();
|
|
96
|
+
}, [reservationExpiresAt, refreshCart]);
|
|
97
|
+
|
|
73
98
|
// `begin_checkout` — fired once the cart is loaded, guarded by a ref so a
|
|
74
99
|
// re-render (or the shopper returning from a canceled payment) doesn't
|
|
75
100
|
// report a second checkout start for the same cart.
|
|
@@ -528,9 +553,24 @@ function CheckoutContent() {
|
|
|
528
553
|
</div>
|
|
529
554
|
)}
|
|
530
555
|
|
|
531
|
-
{/* Reservation countdown */}
|
|
556
|
+
{/* Reservation countdown. onExpire is what blocks payment below. */}
|
|
532
557
|
{checkout?.reservation?.hasReservation && (
|
|
533
|
-
<ReservationCountdown
|
|
558
|
+
<ReservationCountdown
|
|
559
|
+
reservation={checkout.reservation}
|
|
560
|
+
onExpire={handleReservationExpired}
|
|
561
|
+
className="mb-6"
|
|
562
|
+
/>
|
|
563
|
+
)}
|
|
564
|
+
|
|
565
|
+
{/* Expired reservation: payment is off the table until the cart is
|
|
566
|
+
reviewed, so say so on every step, not only on the payment step. */}
|
|
567
|
+
{reservationExpired && (
|
|
568
|
+
<div className="bg-destructive/10 border-destructive/20 text-destructive mb-6 rounded-lg border px-4 py-3 text-sm">
|
|
569
|
+
<p>{tr('expiredCheckout')}</p>
|
|
570
|
+
<Link href="/cart" className="mt-2 inline-flex font-medium underline">
|
|
571
|
+
{tr('backToCart')}
|
|
572
|
+
</Link>
|
|
573
|
+
</div>
|
|
534
574
|
)}
|
|
535
575
|
|
|
536
576
|
{/* Step indicator */}
|
|
@@ -763,7 +803,23 @@ function CheckoutContent() {
|
|
|
763
803
|
)}
|
|
764
804
|
</div>
|
|
765
805
|
|
|
766
|
-
|
|
806
|
+
{/* Never mount the payment form on an expired reservation: the
|
|
807
|
+
stock behind these lines is back on sale, so a charge here
|
|
808
|
+
can take money for something that cannot ship. */}
|
|
809
|
+
{reservationExpired ? (
|
|
810
|
+
<div className="border-border rounded-lg border px-4 py-6 text-center">
|
|
811
|
+
<p className="text-foreground text-sm font-medium">{tr('expired')}</p>
|
|
812
|
+
<p className="text-muted-foreground mt-1 text-sm">{tr('expiredCheckout')}</p>
|
|
813
|
+
<Link
|
|
814
|
+
href="/cart"
|
|
815
|
+
className="bg-primary text-primary-foreground mt-4 inline-flex items-center rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90"
|
|
816
|
+
>
|
|
817
|
+
{tr('backToCart')}
|
|
818
|
+
</Link>
|
|
819
|
+
</div>
|
|
820
|
+
) : (
|
|
821
|
+
<PaymentStep checkoutId={checkout.id} />
|
|
822
|
+
)}
|
|
767
823
|
</div>
|
|
768
824
|
)}
|
|
769
825
|
</div>
|
|
@@ -32,10 +32,29 @@ function OrderConfirmationContent() {
|
|
|
32
32
|
try {
|
|
33
33
|
const client = getClient();
|
|
34
34
|
|
|
35
|
-
// Clear cart state after successful payment
|
|
36
|
-
|
|
35
|
+
// Clear cart state after successful payment. handlePaymentSuccess is
|
|
36
|
+
// SYNCHRONOUS: it returns the outcome rather than a promise, so there
|
|
37
|
+
// is nothing to await. Read the result, then refresh the cart AFTER
|
|
38
|
+
// it, or the header badge can be repopulated from the pre-clear cart.
|
|
39
|
+
//
|
|
40
|
+
// mode 'full' - whole cart cleared
|
|
41
|
+
// mode 'partial' - only the purchased items were removed (partial
|
|
42
|
+
// checkout); whatever is left is still for sale
|
|
43
|
+
// mode 'none' - nothing to clear. Usually the SDK's idempotency
|
|
44
|
+
// guard: React Strict Mode runs this effect twice
|
|
45
|
+
// in development, and a refresh of this URL hits it
|
|
46
|
+
// too. On a good order this is expected, so it must
|
|
47
|
+
// never become a warning to the shopper.
|
|
48
|
+
const clearResult = client.handlePaymentSuccess(checkoutId!);
|
|
37
49
|
await refreshCart();
|
|
38
50
|
|
|
51
|
+
if (!clearResult.cleared && clearResult.mode !== 'none') {
|
|
52
|
+
// A clear that neither succeeded nor was skipped leaves purchased
|
|
53
|
+
// items in the badge. Nothing the shopper can act on, but a silent
|
|
54
|
+
// gap here looks exactly like a working cart.
|
|
55
|
+
console.warn('Cart not cleared after payment:', clearResult);
|
|
56
|
+
}
|
|
57
|
+
|
|
39
58
|
// For redirect-based payment providers, the customer returns with
|
|
40
59
|
// provider params in the URL — CardCom: lowprofilecode; PayPal:
|
|
41
60
|
// token + PayerID. Send these to the backend for server-side
|
|
@@ -9,35 +9,29 @@ import { useCurrency } from '@/core/lib/use-currency';
|
|
|
9
9
|
import { useTranslations } from '@/core/lib/translations';
|
|
10
10
|
import { cn } from '@/core/lib/utils';
|
|
11
11
|
import { OrderCustomizations } from './order-customizations';
|
|
12
|
-
import { OrderStatusTimeline } from './order-status-timeline';
|
|
12
|
+
import { OrderStatusTimeline, ORDER_STATUS_LABEL_KEYS } from './order-status-timeline';
|
|
13
13
|
import { OrderShippingBlock } from './order-shipping-block';
|
|
14
14
|
import { OrderPaymentBlock } from './order-payment-block';
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
className: 'bg-red-100 text-red-800 dark:bg-red-950/30 dark:text-red-400',
|
|
36
|
-
},
|
|
37
|
-
refunded: {
|
|
38
|
-
labelKey: 'statusRefunded',
|
|
39
|
-
className: 'bg-orange-100 text-orange-800 dark:bg-orange-950/30 dark:text-orange-400',
|
|
40
|
-
},
|
|
16
|
+
/**
|
|
17
|
+
* Badge colour per order status. The API sends the status UPPERCASE and
|
|
18
|
+
* verbatim, so these keys are uppercase too. Do NOT lowercase `order.status`
|
|
19
|
+
* before the lookup: every row would miss and render as "Pending".
|
|
20
|
+
* Labels come from ORDER_STATUS_LABEL_KEYS so the two stay in step.
|
|
21
|
+
*/
|
|
22
|
+
const STATUS_STYLES: Record<OrderStatus, string> = {
|
|
23
|
+
DRAFT: 'bg-muted text-muted-foreground',
|
|
24
|
+
PENDING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-950/30 dark:text-yellow-400',
|
|
25
|
+
PROCESSING: 'bg-blue-100 text-blue-800 dark:bg-blue-950/30 dark:text-blue-400',
|
|
26
|
+
ON_HOLD: 'bg-amber-100 text-amber-800 dark:bg-amber-950/30 dark:text-amber-400',
|
|
27
|
+
PAID: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950/30 dark:text-emerald-400',
|
|
28
|
+
SHIPPED: 'bg-purple-100 text-purple-800 dark:bg-purple-950/30 dark:text-purple-400',
|
|
29
|
+
DELIVERED: 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400',
|
|
30
|
+
COMPLETED: 'bg-green-100 text-green-800 dark:bg-green-950/30 dark:text-green-400',
|
|
31
|
+
FULFILLED: 'bg-teal-100 text-teal-800 dark:bg-teal-950/30 dark:text-teal-400',
|
|
32
|
+
CANCELLED: 'bg-red-100 text-red-800 dark:bg-red-950/30 dark:text-red-400',
|
|
33
|
+
REFUNDED: 'bg-orange-100 text-orange-800 dark:bg-orange-950/30 dark:text-orange-400',
|
|
34
|
+
PARTIALLY_REFUNDED: 'bg-orange-100 text-orange-800 dark:bg-orange-950/30 dark:text-orange-400',
|
|
41
35
|
};
|
|
42
36
|
|
|
43
37
|
interface OrderHistoryProps {
|
|
@@ -82,8 +76,8 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
82
76
|
const t = useTranslations('account');
|
|
83
77
|
const tc = useTranslations('common');
|
|
84
78
|
const [expanded, setExpanded] = useState(false);
|
|
85
|
-
const
|
|
86
|
-
|
|
79
|
+
const statusLabelKey = ORDER_STATUS_LABEL_KEYS[order.status] || 'statusPending';
|
|
80
|
+
const statusClassName = STATUS_STYLES[order.status] || STATUS_STYLES.PENDING;
|
|
87
81
|
const currency = useCurrency(order.currency);
|
|
88
82
|
const totalAmount = order.totalAmount || order.total || '0';
|
|
89
83
|
|
|
@@ -103,18 +97,10 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
103
97
|
<span
|
|
104
98
|
className={cn(
|
|
105
99
|
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
|
|
106
|
-
|
|
100
|
+
statusClassName
|
|
107
101
|
)}
|
|
108
102
|
>
|
|
109
|
-
{t(
|
|
110
|
-
statusConfig.labelKey as
|
|
111
|
-
| 'statusPending'
|
|
112
|
-
| 'statusProcessing'
|
|
113
|
-
| 'statusShipped'
|
|
114
|
-
| 'statusDelivered'
|
|
115
|
-
| 'statusCancelled'
|
|
116
|
-
| 'statusRefunded'
|
|
117
|
-
)}
|
|
103
|
+
{t(statusLabelKey)}
|
|
118
104
|
</span>
|
|
119
105
|
</div>
|
|
120
106
|
<div className="text-muted-foreground mt-1 flex items-center gap-4 text-xs">
|
|
@@ -125,7 +111,7 @@ function OrderCard({ order }: { order: Order }) {
|
|
|
125
111
|
month: 'short',
|
|
126
112
|
day: 'numeric',
|
|
127
113
|
})
|
|
128
|
-
: '
|
|
114
|
+
: ''}
|
|
129
115
|
</span>
|
|
130
116
|
<span>
|
|
131
117
|
{order.items.length} {order.items.length === 1 ? tc('item') : tc('items')}
|
|
@@ -4,21 +4,40 @@ import type { Order, OrderStatus } from 'brainerce';
|
|
|
4
4
|
import { useTranslations } from '@/core/lib/translations';
|
|
5
5
|
import { cn } from '@/core/lib/utils';
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Message key for every order status the API can return.
|
|
9
|
+
*
|
|
10
|
+
* The API sends these values UPPERCASE and verbatim (`PENDING`, `ON_HOLD`,
|
|
11
|
+
* ...). Never lowercase them before the lookup: a lowercase key silently
|
|
12
|
+
* misses and every row falls through to the default label.
|
|
13
|
+
*/
|
|
14
|
+
export type OrderStatusLabelKey =
|
|
15
|
+
| 'statusDraft'
|
|
9
16
|
| 'statusPending'
|
|
10
17
|
| 'statusProcessing'
|
|
18
|
+
| 'statusOnHold'
|
|
19
|
+
| 'statusPaid'
|
|
11
20
|
| 'statusShipped'
|
|
12
21
|
| 'statusDelivered'
|
|
22
|
+
| 'statusCompleted'
|
|
23
|
+
| 'statusFulfilled'
|
|
13
24
|
| 'statusCancelled'
|
|
14
25
|
| 'statusRefunded'
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
26
|
+
| 'statusPartiallyRefunded';
|
|
27
|
+
|
|
28
|
+
export const ORDER_STATUS_LABEL_KEYS: Record<OrderStatus, OrderStatusLabelKey> = {
|
|
29
|
+
DRAFT: 'statusDraft',
|
|
30
|
+
PENDING: 'statusPending',
|
|
31
|
+
PROCESSING: 'statusProcessing',
|
|
32
|
+
ON_HOLD: 'statusOnHold',
|
|
33
|
+
PAID: 'statusPaid',
|
|
34
|
+
SHIPPED: 'statusShipped',
|
|
35
|
+
DELIVERED: 'statusDelivered',
|
|
36
|
+
COMPLETED: 'statusCompleted',
|
|
37
|
+
FULFILLED: 'statusFulfilled',
|
|
38
|
+
CANCELLED: 'statusCancelled',
|
|
39
|
+
REFUNDED: 'statusRefunded',
|
|
40
|
+
PARTIALLY_REFUNDED: 'statusPartiallyRefunded',
|
|
22
41
|
};
|
|
23
42
|
|
|
24
43
|
interface OrderStatusTimelineProps {
|
|
@@ -35,7 +54,7 @@ export function OrderStatusTimeline({ history, className }: OrderStatusTimelineP
|
|
|
35
54
|
<p className="text-foreground mb-2 text-sm font-medium">{t('statusTimeline')}</p>
|
|
36
55
|
<ol className="space-y-1.5">
|
|
37
56
|
{history.map((entry, idx) => {
|
|
38
|
-
const key =
|
|
57
|
+
const key = ORDER_STATUS_LABEL_KEYS[entry.status] || 'statusPending';
|
|
39
58
|
const when = new Date(entry.at);
|
|
40
59
|
const whenStr = isNaN(when.getTime())
|
|
41
60
|
? entry.at
|
|
@@ -56,7 +75,7 @@ export function OrderStatusTimeline({ history, className }: OrderStatusTimelineP
|
|
|
56
75
|
/>
|
|
57
76
|
<span className="text-foreground font-medium">{t(key)}</span>
|
|
58
77
|
<span className="text-muted-foreground">· {whenStr}</span>
|
|
59
|
-
{entry.note && <span className="text-muted-foreground truncate"
|
|
78
|
+
{entry.note && <span className="text-muted-foreground truncate">({entry.note})</span>}
|
|
60
79
|
</li>
|
|
61
80
|
);
|
|
62
81
|
})}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useState } from 'react';
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
4
|
import type {
|
|
5
5
|
Cart,
|
|
6
|
+
CartItem,
|
|
6
7
|
CartRecommendationsResponse,
|
|
7
8
|
CartUpgradesResponse,
|
|
8
9
|
CartBundlesResponse,
|
|
@@ -21,6 +22,26 @@ export interface UseCartPageResult {
|
|
|
21
22
|
upgrades: CartUpgradesResponse | null;
|
|
22
23
|
/** Bundle offers matching the current cart. */
|
|
23
24
|
bundles: CartBundlesResponse | null;
|
|
25
|
+
/**
|
|
26
|
+
* True once the stock reservation on this cart has run out and the refreshed
|
|
27
|
+
* cart still carries the same expired window. Goes back to false on its own
|
|
28
|
+
* if the server hands back a fresh reservation.
|
|
29
|
+
*/
|
|
30
|
+
reservationExpired: boolean;
|
|
31
|
+
/** Lines the server says cannot be bought right now (`isAvailable === false`). */
|
|
32
|
+
unavailableItems: CartItem[];
|
|
33
|
+
/**
|
|
34
|
+
* False while the reservation is expired or any line is unavailable. The
|
|
35
|
+
* proceed-to-checkout action must be genuinely disabled on false, not just
|
|
36
|
+
* styled as disabled.
|
|
37
|
+
*/
|
|
38
|
+
canProceedToCheckout: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Hand this to `<ReservationCountdown onExpire={...}>`. It refreshes the
|
|
41
|
+
* cart once per expiry window: the server, not the client timer, decides
|
|
42
|
+
* what is still purchasable.
|
|
43
|
+
*/
|
|
44
|
+
onReservationExpired: () => void;
|
|
24
45
|
}
|
|
25
46
|
|
|
26
47
|
/**
|
|
@@ -54,5 +75,53 @@ export function useCartPage(): UseCartPageResult {
|
|
|
54
75
|
.catch(() => {});
|
|
55
76
|
}, [cart?.id, cart?.items.length]);
|
|
56
77
|
|
|
57
|
-
|
|
78
|
+
// ---- Reservation expiry ----
|
|
79
|
+
//
|
|
80
|
+
// The countdown component only ticks. Expiry is handled here, once, so all
|
|
81
|
+
// three design variants share one behaviour and a user redesigning the
|
|
82
|
+
// countdown cannot delete the gate along with the markup.
|
|
83
|
+
//
|
|
84
|
+
// Both pieces of state key on the reservation's `expiresAt`, which is the
|
|
85
|
+
// only stable identity a reservation window has:
|
|
86
|
+
// - `handledExpiryRef` stops the refresh loop (refresh remounts the
|
|
87
|
+
// countdown, which would otherwise report the same expiry again).
|
|
88
|
+
// - comparing `expiredWindow` to the CURRENT `expiresAt` means a server
|
|
89
|
+
// that renews the reservation clears the expired state by itself.
|
|
90
|
+
const reservationExpiresAt = cart?.reservation?.expiresAt ?? null;
|
|
91
|
+
const handledExpiryRef = useRef<string | null>(null);
|
|
92
|
+
const [expiredWindow, setExpiredWindow] = useState<string | null>(null);
|
|
93
|
+
|
|
94
|
+
const onReservationExpired = useCallback(() => {
|
|
95
|
+
if (!reservationExpiresAt) return;
|
|
96
|
+
setExpiredWindow(reservationExpiresAt);
|
|
97
|
+
if (handledExpiryRef.current === reservationExpiresAt) return;
|
|
98
|
+
handledExpiryRef.current = reservationExpiresAt;
|
|
99
|
+
// Re-read from the server: it is the source of truth for what survived
|
|
100
|
+
// the released reservation. `isAvailable` on each line comes back updated.
|
|
101
|
+
void refreshCart();
|
|
102
|
+
}, [reservationExpiresAt, refreshCart]);
|
|
103
|
+
|
|
104
|
+
const reservationExpired =
|
|
105
|
+
expiredWindow !== null &&
|
|
106
|
+
expiredWindow === reservationExpiresAt &&
|
|
107
|
+
cart?.reservation?.hasReservation === true;
|
|
108
|
+
|
|
109
|
+
const unavailableItems = cart?.items.filter((item) => item.isAvailable === false) ?? [];
|
|
110
|
+
|
|
111
|
+
const canProceedToCheckout =
|
|
112
|
+
!!cart && cart.items.length > 0 && !reservationExpired && unavailableItems.length === 0;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
cart,
|
|
116
|
+
cartLoading,
|
|
117
|
+
refreshCart,
|
|
118
|
+
itemCount,
|
|
119
|
+
cartRecs,
|
|
120
|
+
upgrades,
|
|
121
|
+
bundles,
|
|
122
|
+
reservationExpired,
|
|
123
|
+
unavailableItems,
|
|
124
|
+
canProceedToCheckout,
|
|
125
|
+
onReservationExpired,
|
|
126
|
+
};
|
|
58
127
|
}
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Client-side auth helpers
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Client-side auth helpers.
|
|
3
|
+
*
|
|
4
|
+
* Everything the SDK covers goes through an SDK method. Never hand-build a
|
|
5
|
+
* REST path: the SDK owns the URL shape, and a string built here silently
|
|
6
|
+
* rots the day a route moves. `getClient()` is configured with
|
|
7
|
+
* `baseUrl: '/api/store'` + `proxyMode`, so each call still lands on the
|
|
8
|
+
* same-origin BFF proxy, which adds the Authorization header from the
|
|
9
|
+
* httpOnly cookie and strips the token out of the response. The SDK adds the
|
|
10
|
+
* CSRF header the proxy requires on every non-GET request.
|
|
11
|
+
*
|
|
12
|
+
* Only the routes with no SDK equivalent are plain fetches: `/api/auth/me`,
|
|
13
|
+
* `/api/auth/logout` and `/api/auth/reset-password` are this app's own Next
|
|
14
|
+
* route handlers, not Brainerce endpoints.
|
|
15
|
+
*
|
|
16
|
+
* The token is managed server-side via httpOnly cookies, never exposed to JS.
|
|
5
17
|
*/
|
|
6
|
-
|
|
7
|
-
// Read either env var name. The new one is preferred; the old one is a soft
|
|
8
|
-
// alias kept for backwards compatibility — both are accepted by the SDK.
|
|
9
|
-
const CONNECTION_ID =
|
|
10
|
-
process.env.NEXT_PUBLIC_BRAINERCE_SALES_CHANNEL_ID ||
|
|
11
|
-
process.env.NEXT_PUBLIC_BRAINERCE_CONNECTION_ID ||
|
|
12
|
-
'';
|
|
18
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
13
19
|
|
|
14
20
|
const CSRF_HEADERS: Record<string, string> = {
|
|
15
21
|
'Content-Type': 'application/json',
|
|
@@ -67,19 +73,19 @@ async function handleResponse<T>(response: Response): Promise<T> {
|
|
|
67
73
|
}
|
|
68
74
|
|
|
69
75
|
/**
|
|
70
|
-
* Login via BFF proxy
|
|
76
|
+
* Login via the SDK, routed through the BFF proxy, which sets the httpOnly
|
|
77
|
+
* cookie on success and strips the token from the response. The narrow
|
|
78
|
+
* `LoginResult` return type is deliberate: the SDK's own response type
|
|
79
|
+
* declares a `token`, but the proxy removes it before it reaches the browser,
|
|
80
|
+
* so nothing here should read one.
|
|
71
81
|
*/
|
|
72
82
|
export async function proxyLogin(email: string, password: string): Promise<LoginResult> {
|
|
73
|
-
|
|
74
|
-
method: 'POST',
|
|
75
|
-
headers: CSRF_HEADERS,
|
|
76
|
-
body: JSON.stringify({ email, password }),
|
|
77
|
-
});
|
|
78
|
-
return handleResponse<LoginResult>(response);
|
|
83
|
+
return getClient().loginCustomer(email, password);
|
|
79
84
|
}
|
|
80
85
|
|
|
81
86
|
/**
|
|
82
|
-
* Register via BFF proxy
|
|
87
|
+
* Register via the SDK, routed through the BFF proxy, which sets the httpOnly
|
|
88
|
+
* cookie on success.
|
|
83
89
|
*/
|
|
84
90
|
export async function proxyRegister(data: {
|
|
85
91
|
firstName: string;
|
|
@@ -96,12 +102,7 @@ export async function proxyRegister(data: {
|
|
|
96
102
|
birthMonth?: number;
|
|
97
103
|
birthDay?: number;
|
|
98
104
|
}): Promise<RegisterResult> {
|
|
99
|
-
|
|
100
|
-
method: 'POST',
|
|
101
|
-
headers: CSRF_HEADERS,
|
|
102
|
-
body: JSON.stringify(data),
|
|
103
|
-
});
|
|
104
|
-
return handleResponse<RegisterResult>(response);
|
|
105
|
+
return getClient().registerCustomer(data);
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
/**
|
|
@@ -123,28 +124,20 @@ export async function proxyLogout(): Promise<void> {
|
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
/**
|
|
126
|
-
* Verify email via
|
|
127
|
-
*
|
|
127
|
+
* Verify email via the SDK. No token argument is passed: the auth token lives
|
|
128
|
+
* in the httpOnly cookie (set during login/register) and the proxy attaches
|
|
129
|
+
* the Authorization header. The SDK skips its own token check in proxy mode.
|
|
128
130
|
*/
|
|
129
131
|
export async function proxyVerifyEmail(code: string): Promise<VerifyEmailResult> {
|
|
130
|
-
|
|
131
|
-
method: 'POST',
|
|
132
|
-
headers: CSRF_HEADERS,
|
|
133
|
-
body: JSON.stringify({ code }),
|
|
134
|
-
});
|
|
135
|
-
return handleResponse<VerifyEmailResult>(response);
|
|
132
|
+
return getClient().verifyEmail(code);
|
|
136
133
|
}
|
|
137
134
|
|
|
138
135
|
/**
|
|
139
|
-
* Resend verification email via
|
|
140
|
-
*
|
|
136
|
+
* Resend the verification email via the SDK. Uses the auth token from the
|
|
137
|
+
* httpOnly cookie, added by the proxy. Rate limited to 3 requests per hour.
|
|
141
138
|
*/
|
|
142
139
|
export async function proxyResendVerification(): Promise<{ message: string }> {
|
|
143
|
-
|
|
144
|
-
method: 'POST',
|
|
145
|
-
headers: CSRF_HEADERS,
|
|
146
|
-
});
|
|
147
|
-
return handleResponse<{ message: string }>(response);
|
|
140
|
+
return getClient().resendVerificationEmail();
|
|
148
141
|
}
|
|
149
142
|
|
|
150
143
|
/**
|
|
@@ -37,22 +37,11 @@ export function initClientWithLocale(locale: string): BrainerceClient {
|
|
|
37
37
|
}
|
|
38
38
|
<% } %>
|
|
39
39
|
|
|
40
|
-
// Cart
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return localStorage.getItem(CART_ID_KEY);
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function setStoredCartId(cartId: string | null): void {
|
|
49
|
-
if (typeof window === 'undefined') return;
|
|
50
|
-
if (cartId) {
|
|
51
|
-
localStorage.setItem(CART_ID_KEY, cartId);
|
|
52
|
-
} else {
|
|
53
|
-
localStorage.removeItem(CART_ID_KEY);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
40
|
+
// Cart identity belongs to the SDK. It owns the guest cart id, the session
|
|
41
|
+
// cart and the logged-in cart cache, and `smartGetCart()` resolves the right
|
|
42
|
+
// one. Do not mirror a cart id into localStorage here: a second copy drifts
|
|
43
|
+
// out of step on login, cart merge and post-payment clear, and the SDK will
|
|
44
|
+
// not read it back.
|
|
56
45
|
|
|
57
46
|
// Initialize client (no token hydration — auth handled by httpOnly cookie)
|
|
58
47
|
export function initClient(): BrainerceClient {
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
|
4
4
|
import type { Cart, CustomerProfile } from 'brainerce';
|
|
5
5
|
import { getCartTotals } from 'brainerce';
|
|
6
|
-
import { getClient, initClient
|
|
6
|
+
import { getClient, initClient } from '@/core/lib/brainerce';
|
|
7
7
|
import { pickPublicStoreInfo, type PublicStoreInfo } from '@/core/lib/store-info';
|
|
8
8
|
import { checkAuthStatus, proxyLogout } from '@/core/lib/auth';
|
|
9
9
|
<% if (i18nEnabled) { %>
|
|
@@ -170,13 +170,10 @@ export function StoreProvider({
|
|
|
170
170
|
try {
|
|
171
171
|
setCartLoading(true);
|
|
172
172
|
const client = getClient();
|
|
173
|
+
// The SDK owns cart identity: smartGetCart() picks the guest, session or
|
|
174
|
+
// customer cart on its own. Never mirror the id into localStorage.
|
|
173
175
|
const c = await client.smartGetCart();
|
|
174
176
|
setCart(c);
|
|
175
|
-
|
|
176
|
-
// Persist server cart ID
|
|
177
|
-
if (c && c.id) {
|
|
178
|
-
setStoredCartId(c.id);
|
|
179
|
-
}
|
|
180
177
|
} catch (err) {
|
|
181
178
|
console.error('Failed to load cart:', err);
|
|
182
179
|
} finally {
|
|
@@ -30,6 +30,17 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
30
30
|
const unitPrice = parseFloat(item.unitPrice);
|
|
31
31
|
const lineTotal = unitPrice * item.quantity;
|
|
32
32
|
|
|
33
|
+
// The server decides purchasability, per line. `isAvailable === false` means
|
|
34
|
+
// this line blocks checkout until it is removed, whether the stock ran out
|
|
35
|
+
// (a released reservation is the common cause) or the product/variant was
|
|
36
|
+
// withdrawn from sale. `unavailableReason` separates the two for the label.
|
|
37
|
+
const isUnavailable = item.isAvailable === false;
|
|
38
|
+
const unavailableLabel = isUnavailable
|
|
39
|
+
? item.unavailableReason === 'OUT_OF_STOCK'
|
|
40
|
+
? td('outOfStock')
|
|
41
|
+
: td('unavailable')
|
|
42
|
+
: null;
|
|
43
|
+
|
|
33
44
|
async function handleQuantityChange(newQuantity: number) {
|
|
34
45
|
if (newQuantity < 1 || updating) return;
|
|
35
46
|
|
|
@@ -83,6 +94,13 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
83
94
|
<div className="min-w-0 flex-1">
|
|
84
95
|
<h3 className="text-foreground truncate text-sm font-medium">{productName}</h3>
|
|
85
96
|
|
|
97
|
+
{/* Availability badge. This line blocks checkout while it shows. */}
|
|
98
|
+
{unavailableLabel && (
|
|
99
|
+
<span className="bg-destructive/10 text-destructive mt-1 inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium">
|
|
100
|
+
{unavailableLabel}
|
|
101
|
+
</span>
|
|
102
|
+
)}
|
|
103
|
+
|
|
86
104
|
{/* Variant name */}
|
|
87
105
|
{variantName && <p className="text-muted-foreground mt-1 text-xs">{variantName}</p>}
|
|
88
106
|
|
|
@@ -116,7 +134,7 @@ export function CartItem({ item, onUpdate, className }: CartItemProps) {
|
|
|
116
134
|
<button
|
|
117
135
|
type="button"
|
|
118
136
|
onClick={() => handleQuantityChange(item.quantity + 1)}
|
|
119
|
-
disabled={updating}
|
|
137
|
+
disabled={updating || isUnavailable}
|
|
120
138
|
className="text-foreground hover:bg-muted px-2 py-1 text-sm transition-colors disabled:cursor-not-allowed disabled:opacity-40"
|
|
121
139
|
aria-label={td('increaseQuantity')}
|
|
122
140
|
>
|
|
@@ -21,7 +21,20 @@ import { useTranslations } from '@/core/lib/translations';
|
|
|
21
21
|
export function CartView() {
|
|
22
22
|
const t = useTranslations('cart');
|
|
23
23
|
const tc = useTranslations('common');
|
|
24
|
-
const
|
|
24
|
+
const tr = useTranslations('reservation');
|
|
25
|
+
const {
|
|
26
|
+
cart,
|
|
27
|
+
cartLoading,
|
|
28
|
+
refreshCart,
|
|
29
|
+
itemCount,
|
|
30
|
+
cartRecs,
|
|
31
|
+
upgrades,
|
|
32
|
+
bundles,
|
|
33
|
+
reservationExpired,
|
|
34
|
+
unavailableItems,
|
|
35
|
+
canProceedToCheckout,
|
|
36
|
+
onReservationExpired,
|
|
37
|
+
} = useCartPage();
|
|
25
38
|
|
|
26
39
|
if (cartLoading) {
|
|
27
40
|
return (
|
|
@@ -55,9 +68,14 @@ export function CartView() {
|
|
|
55
68
|
{t('title')} ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
|
|
56
69
|
</h1>
|
|
57
70
|
|
|
58
|
-
{/* Reservation countdown
|
|
71
|
+
{/* Reservation countdown. onExpire refreshes the cart and closes the
|
|
72
|
+
checkout gate below. Passing it is what makes expiry mean anything. */}
|
|
59
73
|
{cart.reservation?.hasReservation && (
|
|
60
|
-
<ReservationCountdown
|
|
74
|
+
<ReservationCountdown
|
|
75
|
+
reservation={cart.reservation}
|
|
76
|
+
onExpire={onReservationExpired}
|
|
77
|
+
className="mb-6"
|
|
78
|
+
/>
|
|
61
79
|
)}
|
|
62
80
|
|
|
63
81
|
<div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
|
|
@@ -113,9 +131,27 @@ export function CartView() {
|
|
|
113
131
|
<FreeShippingBar className="mb-4" />
|
|
114
132
|
<CartSummary />
|
|
115
133
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
134
|
+
{/* Proceed to checkout. When the gate is closed this must be a
|
|
135
|
+
real disabled control, never a styled-down link: an anchor
|
|
136
|
+
ignores `disabled` and would still navigate. */}
|
|
137
|
+
{canProceedToCheckout ? (
|
|
138
|
+
<Button asChild size="lg" className="mt-6 w-full rounded px-6 text-sm">
|
|
139
|
+
<Link href="/checkout">{t('proceedToCheckout')}</Link>
|
|
140
|
+
</Button>
|
|
141
|
+
) : (
|
|
142
|
+
<>
|
|
143
|
+
<Button disabled size="lg" className="mt-6 w-full rounded px-6 text-sm">
|
|
144
|
+
{t('proceedToCheckout')}
|
|
145
|
+
</Button>
|
|
146
|
+
<p className="text-destructive mt-2 text-xs">
|
|
147
|
+
{unavailableItems.length > 0
|
|
148
|
+
? t('unavailableItemsHint')
|
|
149
|
+
: reservationExpired
|
|
150
|
+
? tr('expiredHint')
|
|
151
|
+
: null}
|
|
152
|
+
</p>
|
|
153
|
+
</>
|
|
154
|
+
)}
|
|
119
155
|
|
|
120
156
|
<Link
|
|
121
157
|
href="/products"
|