create-brainerce-store 1.72.0 → 1.73.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 +4 -4
- package/messages/en.json +3 -0
- package/messages/he.json +3 -0
- package/package.json +1 -1
- package/templates/nextjs/base/TRANSLATIONS.md +207 -207
- package/templates/nextjs/base/src/app/checkout/page.tsx +1074 -1074
- package/templates/nextjs/base/src/components/account/order-history.tsx +371 -371
- package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +127 -127
- package/templates/nextjs/base/src/ui/cart/cart-item.tsx +164 -164
- package/templates/nextjs/base/src/ui/cart/cart-view.tsx +176 -176
- package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
- package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
- package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
- package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +158 -158
- package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +184 -184
- package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
- package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
- package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
- package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
- package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
|
@@ -1,85 +1,85 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import type { Order, OrderStatus } from 'brainerce';
|
|
4
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
5
|
-
import { cn } from '@/core/lib/utils';
|
|
6
|
-
|
|
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'
|
|
16
|
-
| 'statusPending'
|
|
17
|
-
| 'statusProcessing'
|
|
18
|
-
| 'statusOnHold'
|
|
19
|
-
| 'statusPaid'
|
|
20
|
-
| 'statusShipped'
|
|
21
|
-
| 'statusDelivered'
|
|
22
|
-
| 'statusCompleted'
|
|
23
|
-
| 'statusFulfilled'
|
|
24
|
-
| 'statusCancelled'
|
|
25
|
-
| 'statusRefunded'
|
|
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',
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
interface OrderStatusTimelineProps {
|
|
44
|
-
history: Order['statusHistory'];
|
|
45
|
-
className?: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export function OrderStatusTimeline({ history, className }: OrderStatusTimelineProps) {
|
|
49
|
-
const t = useTranslations('account');
|
|
50
|
-
if (!history || history.length === 0) return null;
|
|
51
|
-
|
|
52
|
-
return (
|
|
53
|
-
<div className={cn('border-border border-t pt-2', className)}>
|
|
54
|
-
<p className="text-foreground mb-2 text-sm font-medium">{t('statusTimeline')}</p>
|
|
55
|
-
<ol className="space-y-1.5">
|
|
56
|
-
{history.map((entry, idx) => {
|
|
57
|
-
const key = ORDER_STATUS_LABEL_KEYS[entry.status] || 'statusPending';
|
|
58
|
-
const when = new Date(entry.at);
|
|
59
|
-
const whenStr = isNaN(when.getTime())
|
|
60
|
-
? entry.at
|
|
61
|
-
: when.toLocaleString(undefined, {
|
|
62
|
-
year: 'numeric',
|
|
63
|
-
month: 'short',
|
|
64
|
-
day: 'numeric',
|
|
65
|
-
hour: '2-digit',
|
|
66
|
-
minute: '2-digit',
|
|
67
|
-
});
|
|
68
|
-
return (
|
|
69
|
-
<li key={`${entry.status}-${idx}`} className="flex items-center gap-2 text-xs">
|
|
70
|
-
<span
|
|
71
|
-
className={cn(
|
|
72
|
-
'bg-primary inline-block h-2 w-2 flex-shrink-0 rounded-full',
|
|
73
|
-
idx === history.length - 1 ? 'opacity-100' : 'opacity-60'
|
|
74
|
-
)}
|
|
75
|
-
/>
|
|
76
|
-
<span className="text-foreground font-medium">{t(key)}</span>
|
|
77
|
-
<span className="text-muted-foreground">· {whenStr}</span>
|
|
78
|
-
{entry.note && <span className="text-muted-foreground truncate">({entry.note})</span>}
|
|
79
|
-
</li>
|
|
80
|
-
);
|
|
81
|
-
})}
|
|
82
|
-
</ol>
|
|
83
|
-
</div>
|
|
84
|
-
);
|
|
85
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { Order, OrderStatus } from 'brainerce';
|
|
4
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
5
|
+
import { cn } from '@/core/lib/utils';
|
|
6
|
+
|
|
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'
|
|
16
|
+
| 'statusPending'
|
|
17
|
+
| 'statusProcessing'
|
|
18
|
+
| 'statusOnHold'
|
|
19
|
+
| 'statusPaid'
|
|
20
|
+
| 'statusShipped'
|
|
21
|
+
| 'statusDelivered'
|
|
22
|
+
| 'statusCompleted'
|
|
23
|
+
| 'statusFulfilled'
|
|
24
|
+
| 'statusCancelled'
|
|
25
|
+
| 'statusRefunded'
|
|
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',
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
interface OrderStatusTimelineProps {
|
|
44
|
+
history: Order['statusHistory'];
|
|
45
|
+
className?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function OrderStatusTimeline({ history, className }: OrderStatusTimelineProps) {
|
|
49
|
+
const t = useTranslations('account');
|
|
50
|
+
if (!history || history.length === 0) return null;
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className={cn('border-border border-t pt-2', className)}>
|
|
54
|
+
<p className="text-foreground mb-2 text-sm font-medium">{t('statusTimeline')}</p>
|
|
55
|
+
<ol className="space-y-1.5">
|
|
56
|
+
{history.map((entry, idx) => {
|
|
57
|
+
const key = ORDER_STATUS_LABEL_KEYS[entry.status] || 'statusPending';
|
|
58
|
+
const when = new Date(entry.at);
|
|
59
|
+
const whenStr = isNaN(when.getTime())
|
|
60
|
+
? entry.at
|
|
61
|
+
: when.toLocaleString(undefined, {
|
|
62
|
+
year: 'numeric',
|
|
63
|
+
month: 'short',
|
|
64
|
+
day: 'numeric',
|
|
65
|
+
hour: '2-digit',
|
|
66
|
+
minute: '2-digit',
|
|
67
|
+
});
|
|
68
|
+
return (
|
|
69
|
+
<li key={`${entry.status}-${idx}`} className="flex items-center gap-2 text-xs">
|
|
70
|
+
<span
|
|
71
|
+
className={cn(
|
|
72
|
+
'bg-primary inline-block h-2 w-2 flex-shrink-0 rounded-full',
|
|
73
|
+
idx === history.length - 1 ? 'opacity-100' : 'opacity-60'
|
|
74
|
+
)}
|
|
75
|
+
/>
|
|
76
|
+
<span className="text-foreground font-medium">{t(key)}</span>
|
|
77
|
+
<span className="text-muted-foreground">· {whenStr}</span>
|
|
78
|
+
{entry.note && <span className="text-muted-foreground truncate">({entry.note})</span>}
|
|
79
|
+
</li>
|
|
80
|
+
);
|
|
81
|
+
})}
|
|
82
|
+
</ol>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -1,127 +1,127 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
-
import type {
|
|
5
|
-
Cart,
|
|
6
|
-
CartItem,
|
|
7
|
-
CartRecommendationsResponse,
|
|
8
|
-
CartUpgradesResponse,
|
|
9
|
-
CartBundlesResponse,
|
|
10
|
-
} from 'brainerce';
|
|
11
|
-
import { getClient } from '@/core/lib/brainerce';
|
|
12
|
-
import { useCart } from '@/core/providers/store-provider';
|
|
13
|
-
|
|
14
|
-
export interface UseCartPageResult {
|
|
15
|
-
cart: Cart | null;
|
|
16
|
-
cartLoading: boolean;
|
|
17
|
-
refreshCart: () => Promise<void>;
|
|
18
|
-
itemCount: number;
|
|
19
|
-
/** Cross-sell recommendations for the current cart contents. */
|
|
20
|
-
cartRecs: CartRecommendationsResponse | null;
|
|
21
|
-
/** Per-item upgrade suggestions keyed by productId. */
|
|
22
|
-
upgrades: CartUpgradesResponse | null;
|
|
23
|
-
/** Bundle offers matching the current cart. */
|
|
24
|
-
bundles: CartBundlesResponse | null;
|
|
25
|
-
/**
|
|
26
|
-
* True once the stock reservation on this cart has run out and the refreshed
|
|
27
|
-
* cart still carries the same expired window. Goes back to false on its own
|
|
28
|
-
* if the server hands back a fresh reservation.
|
|
29
|
-
*/
|
|
30
|
-
reservationExpired: boolean;
|
|
31
|
-
/** Lines the server says cannot be bought right now (`isAvailable === false`). */
|
|
32
|
-
unavailableItems: CartItem[];
|
|
33
|
-
/**
|
|
34
|
-
* 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;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/**
|
|
48
|
-
* Cart-page behavior: the shared cart state (from StoreProvider) plus the
|
|
49
|
-
* enrichment fetch (recommendations / upgrades / bundles) that runs whenever
|
|
50
|
-
* the cart contents change. Pure data/behavior — rendering lives in
|
|
51
|
-
* `ui/cart/cart-view.tsx`.
|
|
52
|
-
*/
|
|
53
|
-
export function useCartPage(): UseCartPageResult {
|
|
54
|
-
const { cart, cartLoading, refreshCart, itemCount } = useCart();
|
|
55
|
-
const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
|
|
56
|
-
const [upgrades, setUpgrades] = useState<CartUpgradesResponse | null>(null);
|
|
57
|
-
const [bundles, setBundles] = useState<CartBundlesResponse | null>(null);
|
|
58
|
-
|
|
59
|
-
// Load recommendations, upgrades, and bundles in a single request
|
|
60
|
-
useEffect(() => {
|
|
61
|
-
if (!cart?.id || cart.items.length === 0) {
|
|
62
|
-
setCartRecs(null);
|
|
63
|
-
setUpgrades(null);
|
|
64
|
-
setBundles(null);
|
|
65
|
-
return;
|
|
66
|
-
}
|
|
67
|
-
const client = getClient();
|
|
68
|
-
client
|
|
69
|
-
.getCart(cart.id, { include: ['recommendations', 'upgrades', 'bundles'] })
|
|
70
|
-
.then((enriched) => {
|
|
71
|
-
setCartRecs(enriched.recommendations ?? null);
|
|
72
|
-
setUpgrades(enriched.upgrades ?? null);
|
|
73
|
-
setBundles(enriched.bundles ?? null);
|
|
74
|
-
})
|
|
75
|
-
.catch(() => {});
|
|
76
|
-
}, [cart?.id, cart?.items.length]);
|
|
77
|
-
|
|
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
|
-
};
|
|
127
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
4
|
+
import type {
|
|
5
|
+
Cart,
|
|
6
|
+
CartItem,
|
|
7
|
+
CartRecommendationsResponse,
|
|
8
|
+
CartUpgradesResponse,
|
|
9
|
+
CartBundlesResponse,
|
|
10
|
+
} from 'brainerce';
|
|
11
|
+
import { getClient } from '@/core/lib/brainerce';
|
|
12
|
+
import { useCart } from '@/core/providers/store-provider';
|
|
13
|
+
|
|
14
|
+
export interface UseCartPageResult {
|
|
15
|
+
cart: Cart | null;
|
|
16
|
+
cartLoading: boolean;
|
|
17
|
+
refreshCart: () => Promise<void>;
|
|
18
|
+
itemCount: number;
|
|
19
|
+
/** Cross-sell recommendations for the current cart contents. */
|
|
20
|
+
cartRecs: CartRecommendationsResponse | null;
|
|
21
|
+
/** Per-item upgrade suggestions keyed by productId. */
|
|
22
|
+
upgrades: CartUpgradesResponse | null;
|
|
23
|
+
/** Bundle offers matching the current cart. */
|
|
24
|
+
bundles: CartBundlesResponse | null;
|
|
25
|
+
/**
|
|
26
|
+
* True once the stock reservation on this cart has run out and the refreshed
|
|
27
|
+
* cart still carries the same expired window. Goes back to false on its own
|
|
28
|
+
* if the server hands back a fresh reservation.
|
|
29
|
+
*/
|
|
30
|
+
reservationExpired: boolean;
|
|
31
|
+
/** Lines the server says cannot be bought right now (`isAvailable === false`). */
|
|
32
|
+
unavailableItems: CartItem[];
|
|
33
|
+
/**
|
|
34
|
+
* 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;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Cart-page behavior: the shared cart state (from StoreProvider) plus the
|
|
49
|
+
* enrichment fetch (recommendations / upgrades / bundles) that runs whenever
|
|
50
|
+
* the cart contents change. Pure data/behavior — rendering lives in
|
|
51
|
+
* `ui/cart/cart-view.tsx`.
|
|
52
|
+
*/
|
|
53
|
+
export function useCartPage(): UseCartPageResult {
|
|
54
|
+
const { cart, cartLoading, refreshCart, itemCount } = useCart();
|
|
55
|
+
const [cartRecs, setCartRecs] = useState<CartRecommendationsResponse | null>(null);
|
|
56
|
+
const [upgrades, setUpgrades] = useState<CartUpgradesResponse | null>(null);
|
|
57
|
+
const [bundles, setBundles] = useState<CartBundlesResponse | null>(null);
|
|
58
|
+
|
|
59
|
+
// Load recommendations, upgrades, and bundles in a single request
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
if (!cart?.id || cart.items.length === 0) {
|
|
62
|
+
setCartRecs(null);
|
|
63
|
+
setUpgrades(null);
|
|
64
|
+
setBundles(null);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const client = getClient();
|
|
68
|
+
client
|
|
69
|
+
.getCart(cart.id, { include: ['recommendations', 'upgrades', 'bundles'] })
|
|
70
|
+
.then((enriched) => {
|
|
71
|
+
setCartRecs(enriched.recommendations ?? null);
|
|
72
|
+
setUpgrades(enriched.upgrades ?? null);
|
|
73
|
+
setBundles(enriched.bundles ?? null);
|
|
74
|
+
})
|
|
75
|
+
.catch(() => {});
|
|
76
|
+
}, [cart?.id, cart?.items.length]);
|
|
77
|
+
|
|
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
|
+
};
|
|
127
|
+
}
|