create-brainerce-store 1.71.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.
Files changed (27) hide show
  1. package/README.md +31 -10
  2. package/dist/index.js +179 -107
  3. package/messages/en.json +14 -1
  4. package/messages/he.json +14 -1
  5. package/package.json +1 -1
  6. package/templates/nextjs/base/TRANSLATIONS.md +207 -200
  7. package/templates/nextjs/base/src/app/checkout/page.tsx +1074 -1018
  8. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +21 -2
  9. package/templates/nextjs/base/src/components/account/order-history.tsx +371 -385
  10. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -66
  11. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +127 -58
  12. package/templates/nextjs/base/src/core/lib/auth.ts +32 -39
  13. package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +5 -16
  14. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +3 -6
  15. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +164 -146
  16. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +176 -140
  17. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -95
  18. package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
  19. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -163
  20. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +158 -140
  21. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +184 -147
  22. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -89
  23. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
  24. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -123
  25. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -106
  26. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -81
  27. package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
@@ -1,89 +1,131 @@
1
- 'use client';
2
-
3
- import { useState, useEffect, useCallback } from 'react';
4
- import type { ReservationInfo } from 'brainerce';
5
- import { useTranslations } from '@/core/lib/translations';
6
- import { cn } from '@/core/lib/utils';
7
- import { IconClock } from '@/ui/shared/icons';
8
-
9
- interface ReservationCountdownProps {
10
- reservation: ReservationInfo;
11
- className?: string;
12
- }
13
-
14
- /**
15
- * Stock-reservation countdown on the cart/checkout pages; the `reservation`
16
- * prop comes from useCartPage().cart.reservation. Keeps the ticking logic
17
- * and the expired/urgent states (exposed via data-state).
18
- */
19
- export function ReservationCountdown({ reservation, className }: ReservationCountdownProps) {
20
- const t = useTranslations('reservation');
21
- const [remainingSeconds, setRemainingSeconds] = useState<number>(0);
22
-
23
- const calculateRemaining = useCallback(() => {
24
- if (!reservation.expiresAt) return 0;
25
- const expiresAtMs = new Date(reservation.expiresAt).getTime();
26
- const nowMs = Date.now();
27
- return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
28
- }, [reservation.expiresAt]);
29
-
30
- useEffect(() => {
31
- setRemainingSeconds(calculateRemaining());
32
-
33
- const interval = setInterval(() => {
34
- const remaining = calculateRemaining();
35
- setRemainingSeconds(remaining);
36
-
37
- if (remaining <= 0) {
38
- clearInterval(interval);
39
- }
40
- }, 1000);
41
-
42
- return () => clearInterval(interval);
43
- }, [calculateRemaining]);
44
-
45
- if (!reservation.hasReservation) return null;
46
-
47
- const minutes = Math.floor(remainingSeconds / 60);
48
- const seconds = remainingSeconds % 60;
49
- const isExpired = remainingSeconds <= 0;
50
- const isUrgent = remainingSeconds > 0 && remainingSeconds < 120;
51
-
52
- const displayMessage = reservation.countdownMessage
53
- ? reservation.countdownMessage.replace(
54
- '{time}',
55
- `${minutes}:${seconds.toString().padStart(2, '0')}`
56
- )
57
- : null;
58
-
59
- return (
60
- <div
61
- role="timer"
62
- data-state={isExpired ? 'expired' : isUrgent ? 'urgent' : 'active'}
63
- className={cn(
64
- 'flex items-center gap-2.5 rounded-lg px-4 py-3 text-sm font-medium',
65
- isExpired || isUrgent
66
- ? 'bg-destructive/10 text-destructive'
67
- : 'bg-secondary text-foreground',
68
- className
69
- )}
70
- >
71
- <span aria-hidden="true" className="shrink-0">
72
- <IconClock size={18} />
73
- </span>
74
- {isExpired ? (
75
- <p>{t('expired')}</p>
76
- ) : displayMessage ? (
77
- <p>{displayMessage}</p>
78
- ) : (
79
- <p>
80
- {isUrgent ? `${t('hurry')} ` : ''}
81
- {t('reservedFor')}{' '}
82
- <span className="font-bold tabular-nums">
83
- {minutes}:{seconds.toString().padStart(2, '0')}
84
- </span>
85
- </p>
86
- )}
87
- </div>
88
- );
89
- }
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
+ import { IconClock } from '@/ui/shared/icons';
8
+
9
+ interface ReservationCountdownProps {
10
+ reservation: ReservationInfo;
11
+ /**
12
+ * Fired once per reservation window when the timer reaches zero, including
13
+ * when the page opens on an already-expired reservation. Wired to
14
+ * `useCartPage().onReservationExpired`, which refreshes the cart and blocks
15
+ * checkout. This component only displays; it never decides what is still
16
+ * purchasable.
17
+ */
18
+ onExpire?: () => void;
19
+ className?: string;
20
+ }
21
+
22
+ /**
23
+ * Stock-reservation countdown on the cart/checkout pages; the `reservation`
24
+ * prop comes from useCartPage().cart.reservation. Keeps the ticking logic
25
+ * and the expired/urgent states (exposed via data-state).
26
+ */
27
+ export function ReservationCountdown({
28
+ reservation,
29
+ onExpire,
30
+ className,
31
+ }: ReservationCountdownProps) {
32
+ const t = useTranslations('reservation');
33
+ // `null` means "not measured yet". Server-rendered HTML must not compute a
34
+ // time, or it disagrees with the client on hydration; starting at 0 instead
35
+ // would render the expired state for one frame on a perfectly live
36
+ // reservation, and would fire onExpire on every mount.
37
+ const [remainingSeconds, setRemainingSeconds] = useState<number | null>(null);
38
+
39
+ const calculateRemaining = useCallback(() => {
40
+ if (!reservation.expiresAt) return 0;
41
+ const expiresAtMs = new Date(reservation.expiresAt).getTime();
42
+ const nowMs = Date.now();
43
+ return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
44
+ }, [reservation.expiresAt]);
45
+
46
+ // Report each expiry window at most once. Keyed on `expiresAt` rather than a
47
+ // boolean, so the cart refresh that expiry triggers (which remounts this
48
+ // component) cannot bounce straight back into a second report.
49
+ const expiresAt = reservation.expiresAt;
50
+ const reportedExpiryRef = useRef<string | null>(null);
51
+ const onExpireRef = useRef(onExpire);
52
+ // Kept in a ref, and synced in an effect rather than during render, so a
53
+ // caller passing an inline arrow does not restart the timer every render.
54
+ useEffect(() => {
55
+ onExpireRef.current = onExpire;
56
+ }, [onExpire]);
57
+
58
+ const reportExpired = useCallback(() => {
59
+ if (!expiresAt) return;
60
+ if (reportedExpiryRef.current === expiresAt) return;
61
+ reportedExpiryRef.current = expiresAt;
62
+ onExpireRef.current?.();
63
+ }, [expiresAt]);
64
+
65
+ useEffect(() => {
66
+ const initial = calculateRemaining();
67
+ setRemainingSeconds(initial);
68
+ if (initial <= 0) reportExpired();
69
+
70
+ const interval = setInterval(() => {
71
+ const remaining = calculateRemaining();
72
+ setRemainingSeconds(remaining);
73
+
74
+ if (remaining <= 0) {
75
+ clearInterval(interval);
76
+ reportExpired();
77
+ }
78
+ }, 1000);
79
+
80
+ return () => clearInterval(interval);
81
+ }, [calculateRemaining, reportExpired]);
82
+
83
+ if (!reservation.hasReservation) return null;
84
+
85
+ const displaySeconds = remainingSeconds ?? 0;
86
+ const minutes = Math.floor(displaySeconds / 60);
87
+ const seconds = displaySeconds % 60;
88
+ const isExpired = remainingSeconds !== null && remainingSeconds <= 0;
89
+ const isUrgent = displaySeconds > 0 && displaySeconds < 120;
90
+
91
+ const displayMessage = reservation.countdownMessage
92
+ ? reservation.countdownMessage.replace(
93
+ '{time}',
94
+ `${minutes}:${seconds.toString().padStart(2, '0')}`
95
+ )
96
+ : null;
97
+
98
+ return (
99
+ <div
100
+ role="timer"
101
+ data-state={isExpired ? 'expired' : isUrgent ? 'urgent' : 'active'}
102
+ className={cn(
103
+ 'flex items-center gap-2.5 rounded-lg px-4 py-3 text-sm font-medium',
104
+ isExpired || isUrgent
105
+ ? 'bg-destructive/10 text-destructive'
106
+ : 'bg-secondary text-foreground',
107
+ className
108
+ )}
109
+ >
110
+ <span aria-hidden="true" className="shrink-0">
111
+ <IconClock size={18} />
112
+ </span>
113
+ {isExpired ? (
114
+ <div>
115
+ <p>{t('expired')}</p>
116
+ <p className="text-xs font-normal opacity-90">{t('expiredHint')}</p>
117
+ </div>
118
+ ) : displayMessage ? (
119
+ <p>{displayMessage}</p>
120
+ ) : (
121
+ <p>
122
+ {isUrgent ? `${t('hurry')} ` : ''}
123
+ {t('reservedFor')}{' '}
124
+ <span className="font-bold tabular-nums">
125
+ {minutes}:{seconds.toString().padStart(2, '0')}
126
+ </span>
127
+ </p>
128
+ )}
129
+ </div>
130
+ );
131
+ }
@@ -6,7 +6,7 @@ import { getClient } from '@/core/lib/brainerce';
6
6
  import { checkAuthStatus } from '@/core/lib/auth';
7
7
  import { useTranslations } from '@/core/lib/translations';
8
8
  import type { MyProductReview, ProductReview, ReviewPhotoUpload } from 'brainerce';
9
- import { IconStar } from '@/ui/shared/icons';
9
+ import { IconPlus, IconStar } from '@/ui/shared/icons';
10
10
 
11
11
  interface ReviewFormProps {
12
12
  productId: string;
@@ -381,16 +381,36 @@ function ReviewEditor({
381
381
  </ul>
382
382
  )}
383
383
 
384
- <input
385
- type="file"
386
- accept="image/jpeg,image/png,image/webp,image/gif"
387
- multiple
388
- disabled={uploading || roomLeft <= 0}
389
- onChange={handleFiles}
390
- className="text-muted-foreground block text-sm"
391
- />
384
+ {/* A <label> wrapping a visually hidden input, not a bare file control.
385
+ The browser's own "Choose Files" button cannot be styled and looks
386
+ nothing like the rest of the form. Two things this has to do that the
387
+ native control did for free: `disabled` on a hidden input greys
388
+ NOTHING, so the label paints its own disabled state, and the line
389
+ underneath says WHY it is disabled rather than leaving a dead button.
390
+ The input stays a real input (no ref.click()) so keyboard and
391
+ assistive tech reach it, and focus-within restores the focus ring. */}
392
+ <label
393
+ className={`btn-outline btn-sm focus-within:ring-primary/40 inline-flex cursor-pointer items-center gap-2 focus-within:ring-2 ${
394
+ uploading || roomLeft <= 0 ? 'pointer-events-none opacity-50' : ''
395
+ }`}
396
+ >
397
+ <IconPlus size={16} />
398
+ {uploading ? t('photoUploading') : t('choosePhotos')}
399
+ <input
400
+ type="file"
401
+ accept="image/jpeg,image/png,image/webp,image/gif"
402
+ multiple
403
+ disabled={uploading || roomLeft <= 0}
404
+ onChange={handleFiles}
405
+ className="sr-only"
406
+ />
407
+ </label>
392
408
 
393
- {uploading && <p className="text-muted-foreground text-xs">{t('photoUploading')}</p>}
409
+ <p className="text-muted-foreground text-xs">
410
+ {roomLeft <= 0
411
+ ? t('photoLimitReached', { max: String(photos.maxPerReview) })
412
+ : t('photoFormats', { mb: String(Math.round(photos.maxBytes / (1024 * 1024))) })}
413
+ </p>
394
414
  {/* Said BEFORE they submit: a shopper who uploads, submits, and cannot
395
415
  find their photo will conclude the site is broken. */}
396
416
  {photos.requiresApproval && (
@@ -1,123 +1,137 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
- import type { CartItem as CartItemType } from 'brainerce';
6
- import { getCartItemImage, formatPrice } from 'brainerce';
7
- import { getClient } from '@/core/lib/brainerce';
8
- import { useTranslations } from '@/core/lib/translations';
9
- import { useCurrency } from '@/core/lib/use-currency';
10
- import { LoadingSpinner } from '@/ui/shared/loading-spinner';
11
- import { cn } from '@/core/lib/utils';
12
-
13
- interface CartItemProps {
14
- item: CartItemType;
15
- onUpdate: () => void;
16
- className?: string;
17
- }
18
-
19
- /**
20
- * DESIGN ME — single cart line: image, name, variant, unit price, quantity
21
- * controls, remove, line total; the `item` prop comes from useCartPage().cart.
22
- *
23
- * 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.
24
- */
25
- export function CartItem({ item, onUpdate, className }: CartItemProps) {
26
- const t = useTranslations('common');
27
- const td = useTranslations('productDetail');
28
- const currency = useCurrency();
29
- const [updating, setUpdating] = useState(false);
30
- const [removing, setRemoving] = useState(false);
31
-
32
- const productName = item.product.name;
33
- const imageUrl = getCartItemImage(item);
34
- const variantName = item.variant?.name;
35
- const unitPrice = parseFloat(item.unitPrice);
36
- const lineTotal = unitPrice * item.quantity;
37
-
38
- async function handleQuantityChange(newQuantity: number) {
39
- if (newQuantity < 1 || updating) return;
40
-
41
- try {
42
- setUpdating(true);
43
- const client = getClient();
44
- await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined);
45
- onUpdate();
46
- } catch (err) {
47
- console.error('Failed to update quantity:', err);
48
- } finally {
49
- setUpdating(false);
50
- }
51
- }
52
-
53
- async function handleRemove() {
54
- if (removing) return;
55
-
56
- try {
57
- setRemoving(true);
58
- const client = getClient();
59
- await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
60
- onUpdate();
61
- } catch (err) {
62
- console.error('Failed to remove item:', err);
63
- } finally {
64
- setRemoving(false);
65
- }
66
- }
67
-
68
- return (
69
- <div className={cn('flex items-start gap-4', className)}>
70
- {/* Image `relative` + fixed box are layout-critical for next/image fill */}
71
- <div className="relative h-20 w-20">
72
- {imageUrl ? (
73
- <Image src={imageUrl} alt={productName} fill sizes="80px" />
74
- ) : (
75
- <span className="sr-only">{productName}</span>
76
- )}
77
- </div>
78
-
79
- {/* Details */}
80
- <div>
81
- <h3>{productName}</h3>
82
-
83
- {/* Variant name */}
84
- {variantName && <p>{variantName}</p>}
85
-
86
- {/* Unit price */}
87
- <p>{formatPrice(unitPrice, { currency }) as string}</p>
88
-
89
- {/* Quantity controls */}
90
- <div className="flex items-center gap-3">
91
- <div className="flex items-center gap-2">
92
- <button
93
- type="button"
94
- onClick={() => handleQuantityChange(item.quantity - 1)}
95
- disabled={updating || item.quantity <= 1}
96
- aria-label={td('decreaseQuantity')}
97
- >
98
- -
99
- </button>
100
- <span aria-live="polite">
101
- {updating ? <LoadingSpinner size="sm" /> : item.quantity}
102
- </span>
103
- <button
104
- type="button"
105
- onClick={() => handleQuantityChange(item.quantity + 1)}
106
- disabled={updating}
107
- aria-label={td('increaseQuantity')}
108
- >
109
- +
110
- </button>
111
- </div>
112
-
113
- <button type="button" onClick={handleRemove} disabled={removing}>
114
- {removing ? t('removing') : t('remove')}
115
- </button>
116
- </div>
117
- </div>
118
-
119
- {/* Line total */}
120
- <span>{formatPrice(lineTotal, { currency }) as string}</span>
121
- </div>
122
- );
123
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { CartItem as CartItemType } from 'brainerce';
6
+ import { getCartItemImage, formatPrice } from 'brainerce';
7
+ import { getClient } from '@/core/lib/brainerce';
8
+ import { useTranslations } from '@/core/lib/translations';
9
+ import { useCurrency } from '@/core/lib/use-currency';
10
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
11
+ import { cn } from '@/core/lib/utils';
12
+
13
+ interface CartItemProps {
14
+ item: CartItemType;
15
+ onUpdate: () => void;
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * DESIGN ME — single cart line: image, name, variant, unit price, quantity
21
+ * controls, remove, line total; the `item` prop comes from useCartPage().cart.
22
+ *
23
+ * 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.
24
+ */
25
+ export function CartItem({ item, onUpdate, className }: CartItemProps) {
26
+ const t = useTranslations('common');
27
+ const td = useTranslations('productDetail');
28
+ const currency = useCurrency();
29
+ const [updating, setUpdating] = useState(false);
30
+ const [removing, setRemoving] = useState(false);
31
+
32
+ const productName = item.product.name;
33
+ const imageUrl = getCartItemImage(item);
34
+ const variantName = item.variant?.name;
35
+ const unitPrice = parseFloat(item.unitPrice);
36
+ const lineTotal = unitPrice * item.quantity;
37
+
38
+ // The server decides purchasability, per line. `isAvailable === false` means
39
+ // this line blocks checkout until it is removed, whether the stock ran out
40
+ // (a released reservation is the common cause) or the product/variant was
41
+ // withdrawn from sale. Keep the badge when you redesign this line.
42
+ const isUnavailable = item.isAvailable === false;
43
+ const unavailableLabel = isUnavailable
44
+ ? item.unavailableReason === 'OUT_OF_STOCK'
45
+ ? td('outOfStock')
46
+ : td('unavailable')
47
+ : null;
48
+
49
+ async function handleQuantityChange(newQuantity: number) {
50
+ if (newQuantity < 1 || updating) return;
51
+
52
+ try {
53
+ setUpdating(true);
54
+ const client = getClient();
55
+ await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined);
56
+ onUpdate();
57
+ } catch (err) {
58
+ console.error('Failed to update quantity:', err);
59
+ } finally {
60
+ setUpdating(false);
61
+ }
62
+ }
63
+
64
+ async function handleRemove() {
65
+ if (removing) return;
66
+
67
+ try {
68
+ setRemoving(true);
69
+ const client = getClient();
70
+ await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
71
+ onUpdate();
72
+ } catch (err) {
73
+ console.error('Failed to remove item:', err);
74
+ } finally {
75
+ setRemoving(false);
76
+ }
77
+ }
78
+
79
+ return (
80
+ <div className={cn('flex items-start gap-4', className)}>
81
+ {/* Image — `relative` + fixed box are layout-critical for next/image fill */}
82
+ <div className="relative h-20 w-20">
83
+ {imageUrl ? (
84
+ <Image src={imageUrl} alt={productName} fill sizes="80px" />
85
+ ) : (
86
+ <span className="sr-only">{productName}</span>
87
+ )}
88
+ </div>
89
+
90
+ {/* Details */}
91
+ <div>
92
+ <h3>{productName}</h3>
93
+
94
+ {/* Availability badge. This line blocks checkout while it shows. */}
95
+ {unavailableLabel && <span data-state="unavailable">{unavailableLabel}</span>}
96
+
97
+ {/* Variant name */}
98
+ {variantName && <p>{variantName}</p>}
99
+
100
+ {/* Unit price */}
101
+ <p>{formatPrice(unitPrice, { currency }) as string}</p>
102
+
103
+ {/* Quantity controls */}
104
+ <div className="flex items-center gap-3">
105
+ <div className="flex items-center gap-2">
106
+ <button
107
+ type="button"
108
+ onClick={() => handleQuantityChange(item.quantity - 1)}
109
+ disabled={updating || item.quantity <= 1}
110
+ aria-label={td('decreaseQuantity')}
111
+ >
112
+ -
113
+ </button>
114
+ <span aria-live="polite">
115
+ {updating ? <LoadingSpinner size="sm" /> : item.quantity}
116
+ </span>
117
+ <button
118
+ type="button"
119
+ onClick={() => handleQuantityChange(item.quantity + 1)}
120
+ disabled={updating || isUnavailable}
121
+ aria-label={td('increaseQuantity')}
122
+ >
123
+ +
124
+ </button>
125
+ </div>
126
+
127
+ <button type="button" onClick={handleRemove} disabled={removing}>
128
+ {removing ? t('removing') : t('remove')}
129
+ </button>
130
+ </div>
131
+ </div>
132
+
133
+ {/* Line total */}
134
+ <span>{formatPrice(lineTotal, { currency }) as string}</span>
135
+ </div>
136
+ );
137
+ }