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,124 +1,124 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
-
import type { ReservationInfo } from 'brainerce';
|
|
5
|
-
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
-
import { cn } from '@/core/lib/utils';
|
|
7
|
-
|
|
8
|
-
interface ReservationCountdownProps {
|
|
9
|
-
reservation: ReservationInfo;
|
|
10
|
-
/**
|
|
11
|
-
* Fired once per reservation window when the timer reaches zero, including
|
|
12
|
-
* when the page opens on an already-expired reservation. Wired to
|
|
13
|
-
* `useCartPage().onReservationExpired`, which refreshes the cart and blocks
|
|
14
|
-
* checkout. Keep the prop when you redesign this: the gate lives in the
|
|
15
|
-
* hook, but nothing tells it about the expiry unless this still calls it.
|
|
16
|
-
*/
|
|
17
|
-
onExpire?: () => void;
|
|
18
|
-
className?: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* DESIGN ME — stock-reservation countdown on the cart/checkout pages; the
|
|
23
|
-
* `reservation` prop comes from useCartPage().cart.reservation. Keep the
|
|
24
|
-
* ticking logic, the `onExpire` call and the expired/urgent states (exposed
|
|
25
|
-
* via data-state).
|
|
26
|
-
*
|
|
27
|
-
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
28
|
-
*/
|
|
29
|
-
export function ReservationCountdown({
|
|
30
|
-
reservation,
|
|
31
|
-
onExpire,
|
|
32
|
-
className,
|
|
33
|
-
}: ReservationCountdownProps) {
|
|
34
|
-
const t = useTranslations('reservation');
|
|
35
|
-
// `null` means "not measured yet". Server-rendered HTML must not compute a
|
|
36
|
-
// time, or it disagrees with the client on hydration; starting at 0 instead
|
|
37
|
-
// would render the expired state for one frame on a perfectly live
|
|
38
|
-
// reservation, and would fire onExpire on every mount.
|
|
39
|
-
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
|
|
40
|
-
|
|
41
|
-
const calculateRemaining = useCallback(() => {
|
|
42
|
-
if (!reservation.expiresAt) return 0;
|
|
43
|
-
const expiresAtMs = new Date(reservation.expiresAt).getTime();
|
|
44
|
-
const nowMs = Date.now();
|
|
45
|
-
return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
|
|
46
|
-
}, [reservation.expiresAt]);
|
|
47
|
-
|
|
48
|
-
// Report each expiry window at most once. Keyed on `expiresAt` rather than a
|
|
49
|
-
// boolean, so the cart refresh that expiry triggers (which remounts this
|
|
50
|
-
// component) cannot bounce straight back into a second report.
|
|
51
|
-
const expiresAt = reservation.expiresAt;
|
|
52
|
-
const reportedExpiryRef = useRef<string | null>(null);
|
|
53
|
-
const onExpireRef = useRef(onExpire);
|
|
54
|
-
// Kept in a ref, and synced in an effect rather than during render, so a
|
|
55
|
-
// caller passing an inline arrow does not restart the timer every render.
|
|
56
|
-
useEffect(() => {
|
|
57
|
-
onExpireRef.current = onExpire;
|
|
58
|
-
}, [onExpire]);
|
|
59
|
-
|
|
60
|
-
const reportExpired = useCallback(() => {
|
|
61
|
-
if (!expiresAt) return;
|
|
62
|
-
if (reportedExpiryRef.current === expiresAt) return;
|
|
63
|
-
reportedExpiryRef.current = expiresAt;
|
|
64
|
-
onExpireRef.current?.();
|
|
65
|
-
}, [expiresAt]);
|
|
66
|
-
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
const initial = calculateRemaining();
|
|
69
|
-
setRemainingSeconds(initial);
|
|
70
|
-
if (initial <= 0) reportExpired();
|
|
71
|
-
|
|
72
|
-
const interval = setInterval(() => {
|
|
73
|
-
const remaining = calculateRemaining();
|
|
74
|
-
setRemainingSeconds(remaining);
|
|
75
|
-
|
|
76
|
-
if (remaining <= 0) {
|
|
77
|
-
clearInterval(interval);
|
|
78
|
-
reportExpired();
|
|
79
|
-
}
|
|
80
|
-
}, 1000);
|
|
81
|
-
|
|
82
|
-
return () => clearInterval(interval);
|
|
83
|
-
}, [calculateRemaining, reportExpired]);
|
|
84
|
-
|
|
85
|
-
if (!reservation.hasReservation) return null;
|
|
86
|
-
|
|
87
|
-
const displaySeconds = remainingSeconds ?? 0;
|
|
88
|
-
const minutes = Math.floor(displaySeconds / 60);
|
|
89
|
-
const seconds = displaySeconds % 60;
|
|
90
|
-
const isExpired = remainingSeconds !== null && remainingSeconds <= 0;
|
|
91
|
-
const isUrgent = displaySeconds > 0 && displaySeconds < 120;
|
|
92
|
-
|
|
93
|
-
const displayMessage = reservation.countdownMessage
|
|
94
|
-
? reservation.countdownMessage.replace(
|
|
95
|
-
'{time}',
|
|
96
|
-
`${minutes}:${seconds.toString().padStart(2, '0')}`
|
|
97
|
-
)
|
|
98
|
-
: null;
|
|
99
|
-
|
|
100
|
-
return (
|
|
101
|
-
<div
|
|
102
|
-
role="timer"
|
|
103
|
-
data-state={isExpired ? 'expired' : isUrgent ? 'urgent' : 'active'}
|
|
104
|
-
className={cn(className)}
|
|
105
|
-
>
|
|
106
|
-
{isExpired ? (
|
|
107
|
-
<>
|
|
108
|
-
<p>{t('expired')}</p>
|
|
109
|
-
<p>{t('expiredHint')}</p>
|
|
110
|
-
</>
|
|
111
|
-
) : displayMessage ? (
|
|
112
|
-
<p>{displayMessage}</p>
|
|
113
|
-
) : (
|
|
114
|
-
<p>
|
|
115
|
-
{isUrgent ? `${t('hurry')} ` : ''}
|
|
116
|
-
{t('reservedFor')}{' '}
|
|
117
|
-
<span>
|
|
118
|
-
{minutes}:{seconds.toString().padStart(2, '0')}
|
|
119
|
-
</span>
|
|
120
|
-
</p>
|
|
121
|
-
)}
|
|
122
|
-
</div>
|
|
123
|
-
);
|
|
124
|
-
}
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
|
+
import type { ReservationInfo } from 'brainerce';
|
|
5
|
+
import { useTranslations } from '@/core/lib/translations';
|
|
6
|
+
import { cn } from '@/core/lib/utils';
|
|
7
|
+
|
|
8
|
+
interface ReservationCountdownProps {
|
|
9
|
+
reservation: ReservationInfo;
|
|
10
|
+
/**
|
|
11
|
+
* Fired once per reservation window when the timer reaches zero, including
|
|
12
|
+
* when the page opens on an already-expired reservation. Wired to
|
|
13
|
+
* `useCartPage().onReservationExpired`, which refreshes the cart and blocks
|
|
14
|
+
* checkout. Keep the prop when you redesign this: the gate lives in the
|
|
15
|
+
* hook, but nothing tells it about the expiry unless this still calls it.
|
|
16
|
+
*/
|
|
17
|
+
onExpire?: () => void;
|
|
18
|
+
className?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* DESIGN ME — stock-reservation countdown on the cart/checkout pages; the
|
|
23
|
+
* `reservation` prop comes from useCartPage().cart.reservation. Keep the
|
|
24
|
+
* ticking logic, the `onExpire` call and the expired/urgent states (exposed
|
|
25
|
+
* via data-state).
|
|
26
|
+
*
|
|
27
|
+
* Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
|
|
28
|
+
*/
|
|
29
|
+
export function ReservationCountdown({
|
|
30
|
+
reservation,
|
|
31
|
+
onExpire,
|
|
32
|
+
className,
|
|
33
|
+
}: ReservationCountdownProps) {
|
|
34
|
+
const t = useTranslations('reservation');
|
|
35
|
+
// `null` means "not measured yet". Server-rendered HTML must not compute a
|
|
36
|
+
// time, or it disagrees with the client on hydration; starting at 0 instead
|
|
37
|
+
// would render the expired state for one frame on a perfectly live
|
|
38
|
+
// reservation, and would fire onExpire on every mount.
|
|
39
|
+
const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
|
|
40
|
+
|
|
41
|
+
const calculateRemaining = useCallback(() => {
|
|
42
|
+
if (!reservation.expiresAt) return 0;
|
|
43
|
+
const expiresAtMs = new Date(reservation.expiresAt).getTime();
|
|
44
|
+
const nowMs = Date.now();
|
|
45
|
+
return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
|
|
46
|
+
}, [reservation.expiresAt]);
|
|
47
|
+
|
|
48
|
+
// Report each expiry window at most once. Keyed on `expiresAt` rather than a
|
|
49
|
+
// boolean, so the cart refresh that expiry triggers (which remounts this
|
|
50
|
+
// component) cannot bounce straight back into a second report.
|
|
51
|
+
const expiresAt = reservation.expiresAt;
|
|
52
|
+
const reportedExpiryRef = useRef<string | null>(null);
|
|
53
|
+
const onExpireRef = useRef(onExpire);
|
|
54
|
+
// Kept in a ref, and synced in an effect rather than during render, so a
|
|
55
|
+
// caller passing an inline arrow does not restart the timer every render.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
onExpireRef.current = onExpire;
|
|
58
|
+
}, [onExpire]);
|
|
59
|
+
|
|
60
|
+
const reportExpired = useCallback(() => {
|
|
61
|
+
if (!expiresAt) return;
|
|
62
|
+
if (reportedExpiryRef.current === expiresAt) return;
|
|
63
|
+
reportedExpiryRef.current = expiresAt;
|
|
64
|
+
onExpireRef.current?.();
|
|
65
|
+
}, [expiresAt]);
|
|
66
|
+
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
const initial = calculateRemaining();
|
|
69
|
+
setRemainingSeconds(initial);
|
|
70
|
+
if (initial <= 0) reportExpired();
|
|
71
|
+
|
|
72
|
+
const interval = setInterval(() => {
|
|
73
|
+
const remaining = calculateRemaining();
|
|
74
|
+
setRemainingSeconds(remaining);
|
|
75
|
+
|
|
76
|
+
if (remaining <= 0) {
|
|
77
|
+
clearInterval(interval);
|
|
78
|
+
reportExpired();
|
|
79
|
+
}
|
|
80
|
+
}, 1000);
|
|
81
|
+
|
|
82
|
+
return () => clearInterval(interval);
|
|
83
|
+
}, [calculateRemaining, reportExpired]);
|
|
84
|
+
|
|
85
|
+
if (!reservation.hasReservation) return null;
|
|
86
|
+
|
|
87
|
+
const displaySeconds = remainingSeconds ?? 0;
|
|
88
|
+
const minutes = Math.floor(displaySeconds / 60);
|
|
89
|
+
const seconds = displaySeconds % 60;
|
|
90
|
+
const isExpired = remainingSeconds !== null && remainingSeconds <= 0;
|
|
91
|
+
const isUrgent = displaySeconds > 0 && displaySeconds < 120;
|
|
92
|
+
|
|
93
|
+
const displayMessage = reservation.countdownMessage
|
|
94
|
+
? reservation.countdownMessage.replace(
|
|
95
|
+
'{time}',
|
|
96
|
+
`${minutes}:${seconds.toString().padStart(2, '0')}`
|
|
97
|
+
)
|
|
98
|
+
: null;
|
|
99
|
+
|
|
100
|
+
return (
|
|
101
|
+
<div
|
|
102
|
+
role="timer"
|
|
103
|
+
data-state={isExpired ? 'expired' : isUrgent ? 'urgent' : 'active'}
|
|
104
|
+
className={cn(className)}
|
|
105
|
+
>
|
|
106
|
+
{isExpired ? (
|
|
107
|
+
<>
|
|
108
|
+
<p>{t('expired')}</p>
|
|
109
|
+
<p>{t('expiredHint')}</p>
|
|
110
|
+
</>
|
|
111
|
+
) : displayMessage ? (
|
|
112
|
+
<p>{displayMessage}</p>
|
|
113
|
+
) : (
|
|
114
|
+
<p>
|
|
115
|
+
{isUrgent ? `${t('hurry')} ` : ''}
|
|
116
|
+
{t('reservedFor')}{' '}
|
|
117
|
+
<span>
|
|
118
|
+
{minutes}:{seconds.toString().padStart(2, '0')}
|
|
119
|
+
</span>
|
|
120
|
+
</p>
|
|
121
|
+
)}
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
@@ -323,7 +323,15 @@ function ReviewEditor({
|
|
|
323
323
|
</label>
|
|
324
324
|
|
|
325
325
|
{/* DESIGN ME — review photo picker. Renders only when the store allows
|
|
326
|
-
photos; every limit comes from `photos`, never hard-coded.
|
|
326
|
+
photos; every limit comes from `photos`, never hard-coded.
|
|
327
|
+
Two things to get right when you style it. Wrap the input in a <label>
|
|
328
|
+
and hide the input with sr-only: the browser's own "Choose Files" button
|
|
329
|
+
cannot be styled and will look nothing like the rest of your form. Then
|
|
330
|
+
paint your own disabled state, because `disabled` on a hidden input greys
|
|
331
|
+
NOTHING — at photos.maxPerReview the control would look alive and do
|
|
332
|
+
nothing. Say why it is disabled. Keep it a real input (no ref.click()) so
|
|
333
|
+
keyboard and assistive tech reach it, and add focus-within so the focus
|
|
334
|
+
ring the native control gave you for free survives. */}
|
|
327
335
|
{photos.enabled && (
|
|
328
336
|
<fieldset>
|
|
329
337
|
<legend>
|