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,106 +1,140 @@
1
- 'use client';
2
-
3
- import { Link } from '@/core/lib/navigation';
4
- import { useCartPage } from '@/core/hooks/use-cart-page';
5
- import { CartItem } from '@/ui/cart/cart-item';
6
- import { CartUpgradeBanner } from '@/ui/cart/cart-upgrade-banner';
7
- import { CartBundleOfferCard } from '@/ui/cart/cart-bundle-offer';
8
- import { CartSummary } from '@/ui/cart/cart-summary';
9
- import { CouponInput } from '@/ui/cart/coupon-input';
10
- import { CartNudges } from '@/ui/cart/cart-nudges';
11
- import { FreeShippingBar } from '@/ui/cart/free-shipping-bar';
12
- import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
13
- import { CartRecommendationSection } from '@/ui/product/recommendation-section';
14
- import { LoadingSpinner } from '@/ui/shared/loading-spinner';
15
- import { useTranslations } from '@/core/lib/translations';
16
-
17
- export function CartView() {
18
- const t = useTranslations('cart');
19
- const tc = useTranslations('common');
20
- const { cart, cartLoading, refreshCart, itemCount, cartRecs, upgrades, bundles } = useCartPage();
21
-
22
- if (cartLoading) {
23
- return <LoadingSpinner size="lg" />;
24
- }
25
-
26
- // Empty cart state
27
- if (!cart || cart.items.length === 0) {
28
- return (
29
- <section>
30
- {/* DESIGN ME — empty-cart state: message + continue-shopping CTA; state comes from useCartPage(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
31
- <h1>{t('emptyTitle')}</h1>
32
- <p>{t('emptySubtitle')}</p>
33
- <Link href="/products">{tc('continueShopping')}</Link>
34
- </section>
35
- );
36
- }
37
-
38
- return (
39
- <section>
40
- {/* DESIGN ME — cart page: items list, upgrade/bundle offers, coupon, summary sidebar, checkout CTA, cross-sells; everything comes from useCartPage(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
41
- <h1>
42
- {t('title')} ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
43
- </h1>
44
-
45
- {/* Reservation countdown */}
46
- {cart.reservation?.hasReservation && <ReservationCountdown reservation={cart.reservation} />}
47
-
48
- <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
49
- {/* Cart Items */}
50
- <div className="lg:col-span-2">
51
- {/* Nudges */}
52
- {cart.nudges && cart.nudges.length > 0 && <CartNudges nudges={cart.nudges} />}
53
-
54
- {/* Cart items */}
55
- <ul>
56
- {cart.items.map((item) => (
57
- <li key={item.id}>
58
- <CartItem item={item} onUpdate={refreshCart} />
59
- {upgrades?.upgrades?.[item.productId] && (
60
- <CartUpgradeBanner
61
- suggestion={upgrades.upgrades[item.productId]}
62
- cartItem={item}
63
- onUpgrade={refreshCart}
64
- />
65
- )}
66
- </li>
67
- ))}
68
- </ul>
69
-
70
- {/* Bundle offers */}
71
- {bundles?.bundles && bundles.bundles.length > 0 && (
72
- <section>
73
- <h3>{t('bundleOffers')}</h3>
74
- {bundles.bundles.map((offer) => (
75
- <CartBundleOfferCard
76
- key={offer.id}
77
- offer={offer}
78
- cartId={cart.id}
79
- onAdd={refreshCart}
80
- />
81
- ))}
82
- </section>
83
- )}
84
-
85
- {/* Coupon input */}
86
- <CouponInput cart={cart} onUpdate={refreshCart} />
87
- </div>
88
-
89
- {/* Summary sidebar */}
90
- <aside className="lg:col-span-1">
91
- <FreeShippingBar />
92
- <CartSummary />
93
-
94
- <Link href="/checkout">{t('proceedToCheckout')}</Link>
95
-
96
- <Link href="/products">{tc('continueShopping')}</Link>
97
- </aside>
98
- </div>
99
-
100
- {/* Cross-sell recommendations */}
101
- {cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
102
- <CartRecommendationSection title={t('youMightAlsoNeed')} items={cartRecs.recommendations} />
103
- )}
104
- </section>
105
- );
106
- }
1
+ 'use client';
2
+
3
+ import { Link } from '@/core/lib/navigation';
4
+ import { useCartPage } from '@/core/hooks/use-cart-page';
5
+ import { CartItem } from '@/ui/cart/cart-item';
6
+ import { CartUpgradeBanner } from '@/ui/cart/cart-upgrade-banner';
7
+ import { CartBundleOfferCard } from '@/ui/cart/cart-bundle-offer';
8
+ import { CartSummary } from '@/ui/cart/cart-summary';
9
+ import { CouponInput } from '@/ui/cart/coupon-input';
10
+ import { CartNudges } from '@/ui/cart/cart-nudges';
11
+ import { FreeShippingBar } from '@/ui/cart/free-shipping-bar';
12
+ import { ReservationCountdown } from '@/ui/cart/reservation-countdown';
13
+ import { CartRecommendationSection } from '@/ui/product/recommendation-section';
14
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
15
+ import { useTranslations } from '@/core/lib/translations';
16
+
17
+ export function CartView() {
18
+ const t = useTranslations('cart');
19
+ const tc = useTranslations('common');
20
+ const tr = useTranslations('reservation');
21
+ const {
22
+ cart,
23
+ cartLoading,
24
+ refreshCart,
25
+ itemCount,
26
+ cartRecs,
27
+ upgrades,
28
+ bundles,
29
+ reservationExpired,
30
+ unavailableItems,
31
+ canProceedToCheckout,
32
+ onReservationExpired,
33
+ } = useCartPage();
34
+
35
+ if (cartLoading) {
36
+ return <LoadingSpinner size="lg" />;
37
+ }
38
+
39
+ // Empty cart state
40
+ if (!cart || cart.items.length === 0) {
41
+ return (
42
+ <section>
43
+ {/* DESIGN ME — empty-cart state: message + continue-shopping CTA; state comes from useCartPage(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
44
+ <h1>{t('emptyTitle')}</h1>
45
+ <p>{t('emptySubtitle')}</p>
46
+ <Link href="/products">{tc('continueShopping')}</Link>
47
+ </section>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <section>
53
+ {/* DESIGN ME — cart page: items list, upgrade/bundle offers, coupon, summary sidebar, checkout CTA, cross-sells; everything comes from useCartPage(). Compose with the shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons. */}
54
+ <h1>
55
+ {t('title')} ({itemCount} {itemCount === 1 ? tc('item') : tc('items')})
56
+ </h1>
57
+
58
+ {/* Reservation countdown. onExpire refreshes the cart and closes the
59
+ checkout gate below. Passing it is what makes expiry mean anything. */}
60
+ {cart.reservation?.hasReservation && (
61
+ <ReservationCountdown reservation={cart.reservation} onExpire={onReservationExpired} />
62
+ )}
63
+
64
+ <div className="grid grid-cols-1 gap-8 lg:grid-cols-3">
65
+ {/* Cart Items */}
66
+ <div className="lg:col-span-2">
67
+ {/* Nudges */}
68
+ {cart.nudges && cart.nudges.length > 0 && <CartNudges nudges={cart.nudges} />}
69
+
70
+ {/* Cart items */}
71
+ <ul>
72
+ {cart.items.map((item) => (
73
+ <li key={item.id}>
74
+ <CartItem item={item} onUpdate={refreshCart} />
75
+ {upgrades?.upgrades?.[item.productId] && (
76
+ <CartUpgradeBanner
77
+ suggestion={upgrades.upgrades[item.productId]}
78
+ cartItem={item}
79
+ onUpgrade={refreshCart}
80
+ />
81
+ )}
82
+ </li>
83
+ ))}
84
+ </ul>
85
+
86
+ {/* Bundle offers */}
87
+ {bundles?.bundles && bundles.bundles.length > 0 && (
88
+ <section>
89
+ <h3>{t('bundleOffers')}</h3>
90
+ {bundles.bundles.map((offer) => (
91
+ <CartBundleOfferCard
92
+ key={offer.id}
93
+ offer={offer}
94
+ cartId={cart.id}
95
+ onAdd={refreshCart}
96
+ />
97
+ ))}
98
+ </section>
99
+ )}
100
+
101
+ {/* Coupon input */}
102
+ <CouponInput cart={cart} onUpdate={refreshCart} />
103
+ </div>
104
+
105
+ {/* Summary sidebar */}
106
+ <aside className="lg:col-span-1">
107
+ <FreeShippingBar />
108
+ <CartSummary />
109
+
110
+ {/* Proceed to checkout. When the gate is closed this must be a real
111
+ disabled control, never a styled-down link: an anchor ignores
112
+ `disabled` and would still navigate. */}
113
+ {canProceedToCheckout ? (
114
+ <Link href="/checkout">{t('proceedToCheckout')}</Link>
115
+ ) : (
116
+ <>
117
+ <button type="button" disabled>
118
+ {t('proceedToCheckout')}
119
+ </button>
120
+ <p data-state="blocked">
121
+ {unavailableItems.length > 0
122
+ ? t('unavailableItemsHint')
123
+ : reservationExpired
124
+ ? tr('expiredHint')
125
+ : null}
126
+ </p>
127
+ </>
128
+ )}
129
+
130
+ <Link href="/products">{tc('continueShopping')}</Link>
131
+ </aside>
132
+ </div>
133
+
134
+ {/* Cross-sell recommendations */}
135
+ {cartRecs?.recommendations && cartRecs.recommendations.length > 0 && (
136
+ <CartRecommendationSection title={t('youMightAlsoNeed')} items={cartRecs.recommendations} />
137
+ )}
138
+ </section>
139
+ );
140
+ }
@@ -1,81 +1,124 @@
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
-
8
- interface ReservationCountdownProps {
9
- reservation: ReservationInfo;
10
- className?: string;
11
- }
12
-
13
- /**
14
- * DESIGN ME — stock-reservation countdown on the cart/checkout pages; the
15
- * `reservation` prop comes from useCartPage().cart.reservation. Keep the
16
- * ticking logic and the expired/urgent states (exposed via data-state).
17
- *
18
- * 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.
19
- */
20
- export function ReservationCountdown({ reservation, className }: ReservationCountdownProps) {
21
- const t = useTranslations('reservation');
22
- const [remainingSeconds, setRemainingSeconds] = useState<number>(0);
23
-
24
- const calculateRemaining = useCallback(() => {
25
- if (!reservation.expiresAt) return 0;
26
- const expiresAtMs = new Date(reservation.expiresAt).getTime();
27
- const nowMs = Date.now();
28
- return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
29
- }, [reservation.expiresAt]);
30
-
31
- useEffect(() => {
32
- setRemainingSeconds(calculateRemaining());
33
-
34
- const interval = setInterval(() => {
35
- const remaining = calculateRemaining();
36
- setRemainingSeconds(remaining);
37
-
38
- if (remaining <= 0) {
39
- clearInterval(interval);
40
- }
41
- }, 1000);
42
-
43
- return () => clearInterval(interval);
44
- }, [calculateRemaining]);
45
-
46
- if (!reservation.hasReservation) return null;
47
-
48
- const minutes = Math.floor(remainingSeconds / 60);
49
- const seconds = remainingSeconds % 60;
50
- const isExpired = remainingSeconds <= 0;
51
- const isUrgent = remainingSeconds > 0 && remainingSeconds < 120;
52
-
53
- const displayMessage = reservation.countdownMessage
54
- ? reservation.countdownMessage.replace(
55
- '{time}',
56
- `${minutes}:${seconds.toString().padStart(2, '0')}`
57
- )
58
- : null;
59
-
60
- return (
61
- <div
62
- role="timer"
63
- data-state={isExpired ? 'expired' : isUrgent ? 'urgent' : 'active'}
64
- className={cn(className)}
65
- >
66
- {isExpired ? (
67
- <p>{t('expired')}</p>
68
- ) : displayMessage ? (
69
- <p>{displayMessage}</p>
70
- ) : (
71
- <p>
72
- {isUrgent ? `${t('hurry')} ` : ''}
73
- {t('reservedFor')}{' '}
74
- <span>
75
- {minutes}:{seconds.toString().padStart(2, '0')}
76
- </span>
77
- </p>
78
- )}
79
- </div>
80
- );
81
- }
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>