create-brainerce-store 1.68.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 +197 -105
- package/messages/en.json +63 -3
- package/messages/he.json +63 -3
- package/package.json +1 -1
- package/templates/nextjs/base/TRANSLATIONS.md +14 -7
- package/templates/nextjs/base/src/app/checkout/page.tsx +1074 -1017
- package/templates/nextjs/base/src/app/order-confirmation/page.tsx +21 -2
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +1 -1
- package/templates/nextjs/base/src/app/register/page.tsx +67 -64
- 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/components/account/profile-section.tsx +303 -226
- package/templates/nextjs/base/src/components/auth/register-form.tsx +326 -245
- package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +306 -294
- package/templates/nextjs/base/src/components/checkout/date-picker.tsx +13 -1
- package/templates/nextjs/base/src/components/checkout/datetime-picker.tsx +61 -21
- package/templates/nextjs/base/src/components/shared/birthday-picker.tsx +258 -0
- package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +71 -2
- package/templates/nextjs/base/src/core/lib/auth.ts +155 -154
- package/templates/nextjs/base/src/core/lib/birthday.ts +74 -0
- package/templates/nextjs/base/src/core/lib/brainerce.ts.ejs +5 -16
- package/templates/nextjs/base/src/core/lib/store-info.ts +10 -0
- 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/base/src/ui/layout/newsletter-signup.tsx +143 -0
- package/templates/nextjs/base/src/ui/layout/site-footer.tsx.ejs +18 -2
- package/templates/nextjs/base/src/ui/product/back-in-stock-form.tsx +173 -0
- package/templates/nextjs/base/src/ui/product/product-client-section.tsx +484 -455
- package/templates/nextjs/base/src/ui/product/review-form.tsx +136 -12
- package/templates/nextjs/base/src/ui/product/reviews-section.tsx.ejs +139 -108
- 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/designs/atelier/ui/layout/site-footer.tsx.ejs +155 -142
- package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +500 -477
- package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +135 -11
- package/templates/nextjs/designs/atelier/ui/product/reviews-section.tsx.ejs +179 -148
- 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
- package/templates/nextjs/ui-canvas/layout/newsletter-signup.tsx +122 -0
- package/templates/nextjs/ui-canvas/layout/site-footer.tsx.ejs +87 -83
- package/templates/nextjs/ui-canvas/product/back-in-stock-form.tsx +151 -0
- package/templates/nextjs/ui-canvas/product/product-client-section.tsx +373 -352
- package/templates/nextjs/ui-canvas/product/review-form.tsx +129 -11
- package/templates/nextjs/ui-canvas/product/reviews-section.tsx.ejs +127 -96
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useState, useEffect, useCallback } from 'react';
|
|
3
|
+
import { useState, useEffect, useCallback, useRef } from 'react';
|
|
4
4
|
import type { ReservationInfo } from 'brainerce';
|
|
5
5
|
import { useTranslations } from '@/core/lib/translations';
|
|
6
6
|
import { cn } from '@/core/lib/utils';
|
|
@@ -8,6 +8,14 @@ import { IconClock } from '@/ui/shared/icons';
|
|
|
8
8
|
|
|
9
9
|
interface ReservationCountdownProps {
|
|
10
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;
|
|
11
19
|
className?: string;
|
|
12
20
|
}
|
|
13
21
|
|
|
@@ -16,9 +24,17 @@ interface ReservationCountdownProps {
|
|
|
16
24
|
* prop comes from useCartPage().cart.reservation. Keeps the ticking logic
|
|
17
25
|
* and the expired/urgent states (exposed via data-state).
|
|
18
26
|
*/
|
|
19
|
-
export function ReservationCountdown({
|
|
27
|
+
export function ReservationCountdown({
|
|
28
|
+
reservation,
|
|
29
|
+
onExpire,
|
|
30
|
+
className,
|
|
31
|
+
}: ReservationCountdownProps) {
|
|
20
32
|
const t = useTranslations('reservation');
|
|
21
|
-
|
|
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);
|
|
22
38
|
|
|
23
39
|
const calculateRemaining = useCallback(() => {
|
|
24
40
|
if (!reservation.expiresAt) return 0;
|
|
@@ -27,8 +43,29 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
|
|
|
27
43
|
return Math.max(0, Math.floor((expiresAtMs - nowMs) / 1000));
|
|
28
44
|
}, [reservation.expiresAt]);
|
|
29
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.
|
|
30
54
|
useEffect(() => {
|
|
31
|
-
|
|
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();
|
|
32
69
|
|
|
33
70
|
const interval = setInterval(() => {
|
|
34
71
|
const remaining = calculateRemaining();
|
|
@@ -36,18 +73,20 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
|
|
|
36
73
|
|
|
37
74
|
if (remaining <= 0) {
|
|
38
75
|
clearInterval(interval);
|
|
76
|
+
reportExpired();
|
|
39
77
|
}
|
|
40
78
|
}, 1000);
|
|
41
79
|
|
|
42
80
|
return () => clearInterval(interval);
|
|
43
|
-
}, [calculateRemaining]);
|
|
81
|
+
}, [calculateRemaining, reportExpired]);
|
|
44
82
|
|
|
45
83
|
if (!reservation.hasReservation) return null;
|
|
46
84
|
|
|
47
|
-
const
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
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;
|
|
51
90
|
|
|
52
91
|
const displayMessage = reservation.countdownMessage
|
|
53
92
|
? reservation.countdownMessage.replace(
|
|
@@ -72,7 +111,10 @@ export function ReservationCountdown({ reservation, className }: ReservationCoun
|
|
|
72
111
|
<IconClock size={18} />
|
|
73
112
|
</span>
|
|
74
113
|
{isExpired ? (
|
|
75
|
-
<
|
|
114
|
+
<div>
|
|
115
|
+
<p>{t('expired')}</p>
|
|
116
|
+
<p className="text-xs font-normal opacity-90">{t('expiredHint')}</p>
|
|
117
|
+
</div>
|
|
76
118
|
) : displayMessage ? (
|
|
77
119
|
<p>{displayMessage}</p>
|
|
78
120
|
) : (
|
|
@@ -1,142 +1,155 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Storefront footer — driven by the merchant's dashboard configuration
|
|
3
|
-
* (Sell → Content → Footer), rendered as a deep-forest "signature" band:
|
|
4
|
-
* brand block + tagline + social, merchant link columns, copyright bar.
|
|
5
|
-
*
|
|
6
|
-
* API contract:
|
|
7
|
-
* GET → client.content.footer.get('main', locale) → Content<'FOOTER'> | null
|
|
8
|
-
* Returns null on 404 — the fallback branch renders the same designed
|
|
9
|
-
* chrome with essential storefront links so the page never looks broken.
|
|
10
|
-
*/
|
|
11
|
-
import * as React from 'react';
|
|
12
|
-
import { Link } from '@/core/lib/navigation';
|
|
13
|
-
import type { Content } from 'brainerce';
|
|
14
|
-
<% if (i18nEnabled) { %>
|
|
15
|
-
import { getMessages, defaultLocale } from '@/i18n';
|
|
16
|
-
<% } else { %>
|
|
17
|
-
import { messages as staticMessages, defaultLocale } from '@/i18n';
|
|
18
|
-
<% } %>
|
|
19
|
-
import { IconSocial, IconShield } from '@/ui/shared/icons';
|
|
20
|
-
import { resolveNavUrl } from '@/ui/layout/nav-url';
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
{ url: '/
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
<
|
|
61
|
-
{
|
|
62
|
-
</
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Storefront footer — driven by the merchant's dashboard configuration
|
|
3
|
+
* (Sell → Content → Footer), rendered as a deep-forest "signature" band:
|
|
4
|
+
* brand block + tagline + social, merchant link columns, copyright bar.
|
|
5
|
+
*
|
|
6
|
+
* API contract:
|
|
7
|
+
* GET → client.content.footer.get('main', locale) → Content<'FOOTER'> | null
|
|
8
|
+
* Returns null on 404 — the fallback branch renders the same designed
|
|
9
|
+
* chrome with essential storefront links so the page never looks broken.
|
|
10
|
+
*/
|
|
11
|
+
import * as React from 'react';
|
|
12
|
+
import { Link } from '@/core/lib/navigation';
|
|
13
|
+
import type { Content } from 'brainerce';
|
|
14
|
+
<% if (i18nEnabled) { %>
|
|
15
|
+
import { getMessages, defaultLocale } from '@/i18n';
|
|
16
|
+
<% } else { %>
|
|
17
|
+
import { messages as staticMessages, defaultLocale } from '@/i18n';
|
|
18
|
+
<% } %>
|
|
19
|
+
import { IconSocial, IconShield } from '@/ui/shared/icons';
|
|
20
|
+
import { resolveNavUrl } from '@/ui/layout/nav-url';
|
|
21
|
+
// Base-template component — design packs overlay, so files the pack does not
|
|
22
|
+
// ship stay as base. Wired here because this pack DOES ship its own footer.
|
|
23
|
+
import { NewsletterSignup } from '@/ui/layout/newsletter-signup';
|
|
24
|
+
|
|
25
|
+
interface SiteFooterProps {
|
|
26
|
+
/** Pre-fetched footer payload (server-side). `null` triggers fallback rendering. */
|
|
27
|
+
footer: Content<'FOOTER'> | null;
|
|
28
|
+
/** Store name shown in the brand block + fallback copyright. */
|
|
29
|
+
storeName?: string;
|
|
30
|
+
/** Store locale for the translation-key copy (server component). */
|
|
31
|
+
locale?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function SiteFooter({ footer, storeName, locale = defaultLocale }: SiteFooterProps) {
|
|
35
|
+
<% if (i18nEnabled) { %>
|
|
36
|
+
const messages = await getMessages(locale);
|
|
37
|
+
<% } else { %>
|
|
38
|
+
const messages = staticMessages;
|
|
39
|
+
<% } %>
|
|
40
|
+
const t = (messages.footer ?? {}) as Record<string, string>;
|
|
41
|
+
|
|
42
|
+
const data = footer?.data;
|
|
43
|
+
const columns = data?.columns ?? [];
|
|
44
|
+
const social = data?.social ?? [];
|
|
45
|
+
const year = new Date().getFullYear();
|
|
46
|
+
const brandLabel = storeName ?? 'Store';
|
|
47
|
+
|
|
48
|
+
const fallbackLinks = [
|
|
49
|
+
{ url: '/products', label: t.linkProducts },
|
|
50
|
+
{ url: '/faq', label: t.linkFaq },
|
|
51
|
+
{ url: '/contact', label: t.linkContact },
|
|
52
|
+
{ url: '/cart', label: t.linkCart },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<footer className="mt-auto bg-primary text-primary-foreground">
|
|
57
|
+
<div className="container-page grid gap-10 py-14 sm:grid-cols-2 lg:grid-cols-4 lg:py-16">
|
|
58
|
+
{/* Brand block */}
|
|
59
|
+
<div className="sm:col-span-2 lg:col-span-2 lg:pe-16">
|
|
60
|
+
<Link href="/" className="font-display text-2xl font-semibold">
|
|
61
|
+
{brandLabel}
|
|
62
|
+
</Link>
|
|
63
|
+
<p className="mt-3 max-w-sm text-sm leading-6 text-primary-foreground/70">
|
|
64
|
+
{t.tagline}
|
|
65
|
+
</p>
|
|
66
|
+
|
|
67
|
+
{/*
|
|
68
|
+
Newsletter signup. Remove this block if the merchant does not want a
|
|
69
|
+
mailing list — nothing else depends on it. Confirmed opt-in: the
|
|
70
|
+
address is not subscribed until the recipient clicks the emailed
|
|
71
|
+
link, which is why the success copy says "check your email".
|
|
72
|
+
*/}
|
|
73
|
+
<div className="mt-8 max-w-sm">
|
|
74
|
+
<NewsletterSignup />
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
{social.length > 0 && (
|
|
78
|
+
<div className="mt-6">
|
|
79
|
+
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-primary-foreground/60">
|
|
80
|
+
{t.followUs}
|
|
81
|
+
</p>
|
|
82
|
+
<ul className="flex flex-wrap items-center gap-2">
|
|
83
|
+
{social.map((entry, idx) => (
|
|
84
|
+
<li key={`${entry.platform}-${idx}`}>
|
|
85
|
+
<a
|
|
86
|
+
href={entry.url}
|
|
87
|
+
target="_blank"
|
|
88
|
+
rel="noopener noreferrer"
|
|
89
|
+
aria-label={entry.platform}
|
|
90
|
+
className="inline-flex h-10 w-10 items-center justify-center rounded-full border border-primary-foreground/25 text-primary-foreground/85 transition-colors hover:bg-primary-foreground hover:text-primary"
|
|
91
|
+
>
|
|
92
|
+
<IconSocial platform={entry.platform} size={20} />
|
|
93
|
+
</a>
|
|
94
|
+
</li>
|
|
95
|
+
))}
|
|
96
|
+
</ul>
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{/* Merchant link columns (or essential fallback links) */}
|
|
102
|
+
{columns.length > 0 ? (
|
|
103
|
+
columns.map((col, idx) => (
|
|
104
|
+
<nav key={`${col.title}-${idx}`} aria-label={col.title}>
|
|
105
|
+
<h3 className="mb-4 font-sans text-sm font-semibold text-primary-foreground">
|
|
106
|
+
{col.title}
|
|
107
|
+
</h3>
|
|
108
|
+
<ul className="space-y-2.5">
|
|
109
|
+
{col.links.map((link, linkIdx) => (
|
|
110
|
+
<li key={`${link.url}-${linkIdx}`}>
|
|
111
|
+
<a
|
|
112
|
+
href={resolveNavUrl(link.url)}
|
|
113
|
+
className="text-sm text-primary-foreground/70 transition-colors hover:text-primary-foreground"
|
|
114
|
+
>
|
|
115
|
+
{link.label}
|
|
116
|
+
</a>
|
|
117
|
+
</li>
|
|
118
|
+
))}
|
|
119
|
+
</ul>
|
|
120
|
+
</nav>
|
|
121
|
+
))
|
|
122
|
+
) : (
|
|
123
|
+
<nav aria-label={t.quickLinksTitle}>
|
|
124
|
+
<h3 className="mb-4 font-sans text-sm font-semibold text-primary-foreground">
|
|
125
|
+
{t.quickLinksTitle}
|
|
126
|
+
</h3>
|
|
127
|
+
<ul className="space-y-2.5">
|
|
128
|
+
{fallbackLinks.map((link) => (
|
|
129
|
+
<li key={link.url}>
|
|
130
|
+
<a
|
|
131
|
+
href={link.url}
|
|
132
|
+
className="text-sm text-primary-foreground/70 transition-colors hover:text-primary-foreground"
|
|
133
|
+
>
|
|
134
|
+
{link.label}
|
|
135
|
+
</a>
|
|
136
|
+
</li>
|
|
137
|
+
))}
|
|
138
|
+
</ul>
|
|
139
|
+
</nav>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{/* Bottom bar */}
|
|
144
|
+
<div className="border-t border-primary-foreground/15">
|
|
145
|
+
<div className="container-page flex flex-col items-center justify-between gap-3 py-5 text-xs text-primary-foreground/60 sm:flex-row">
|
|
146
|
+
<p>{data?.copyright ?? `© ${year} ${brandLabel}. ${messages.common?.allRightsReserved ?? ''}`}</p>
|
|
147
|
+
<p className="inline-flex items-center gap-1.5">
|
|
148
|
+
<IconShield size={16} />
|
|
149
|
+
{t.securePayments}
|
|
150
|
+
</p>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
</footer>
|
|
154
|
+
);
|
|
155
|
+
}
|