create-brainerce-store 1.14.3 → 1.14.5
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 +47 -4
- package/messages/en.json +37 -4
- package/messages/he.json +37 -4
- package/package.json +1 -1
- package/templates/nextjs/base/src/app/account/layout.tsx +9 -0
- package/templates/nextjs/base/src/app/account/page.tsx +122 -112
- package/templates/nextjs/base/src/app/cart/layout.tsx +9 -0
- package/templates/nextjs/base/src/app/checkout/layout.tsx +9 -0
- package/templates/nextjs/base/src/app/checkout/page.tsx +101 -3
- package/templates/nextjs/base/src/app/layout.tsx.ejs +29 -1
- package/templates/nextjs/base/src/app/login/layout.tsx +9 -0
- package/templates/nextjs/base/src/app/order-confirmation/layout.tsx +9 -0
- package/templates/nextjs/base/src/app/products/[slug]/page.tsx +5 -1
- package/templates/nextjs/base/src/app/products/layout.tsx +18 -0
- package/templates/nextjs/base/src/app/products/page.tsx +1 -0
- package/templates/nextjs/base/src/app/register/layout.tsx +9 -0
- package/templates/nextjs/base/src/app/register/page.tsx +1 -0
- package/templates/nextjs/base/src/app/verify-email/page.tsx +1 -1
- package/templates/nextjs/base/src/components/account/address-book.tsx +432 -0
- package/templates/nextjs/base/src/components/auth/register-form.tsx +232 -184
- package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +359 -305
- package/templates/nextjs/base/src/components/products/product-card.tsx +26 -4
- package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +40 -7
- package/templates/nextjs/base/src/lib/auth.ts +1 -0
|
@@ -13,7 +13,7 @@ import type {
|
|
|
13
13
|
} from 'brainerce';
|
|
14
14
|
import { formatPrice } from 'brainerce';
|
|
15
15
|
import { getClient } from '@/lib/brainerce';
|
|
16
|
-
import { useStoreInfo, useCart } from '@/providers/store-provider';
|
|
16
|
+
import { useStoreInfo, useCart, useAuth } from '@/providers/store-provider';
|
|
17
17
|
import { CheckoutForm } from '@/components/checkout/checkout-form';
|
|
18
18
|
import { ShippingStep } from '@/components/checkout/shipping-step';
|
|
19
19
|
import { PaymentStep } from '@/components/checkout/payment-step';
|
|
@@ -32,9 +32,11 @@ function CheckoutContent() {
|
|
|
32
32
|
const searchParams = useSearchParams();
|
|
33
33
|
const { storeInfo } = useStoreInfo();
|
|
34
34
|
const { cart, refreshCart } = useCart();
|
|
35
|
+
const { isLoggedIn } = useAuth();
|
|
35
36
|
const currency = storeInfo?.currency || 'USD';
|
|
36
37
|
const t = useTranslations('checkout');
|
|
37
38
|
const tc = useTranslations('common');
|
|
39
|
+
const tAddr = useTranslations('checkoutAddress');
|
|
38
40
|
|
|
39
41
|
const [step, setStep] = useState<CheckoutStep>('address');
|
|
40
42
|
const [checkout, setCheckout] = useState<Checkout | null>(null);
|
|
@@ -47,11 +49,28 @@ function CheckoutContent() {
|
|
|
47
49
|
const [pickupLocations, setPickupLocations] = useState<PickupLocation[]>([]);
|
|
48
50
|
const [deliveryType, setDeliveryType] = useState<'shipping' | 'pickup'>('shipping');
|
|
49
51
|
const [isAllDigital, setIsAllDigital] = useState(false);
|
|
52
|
+
const [prefillAddress, setPrefillAddress] = useState<SetShippingAddressDto | null>(null);
|
|
53
|
+
const [lastSubmittedAddress, setLastSubmittedAddress] = useState<SetShippingAddressDto | null>(
|
|
54
|
+
null
|
|
55
|
+
);
|
|
56
|
+
const [showSavePrompt, setShowSavePrompt] = useState(false);
|
|
57
|
+
const [savingAddress, setSavingAddress] = useState(false);
|
|
50
58
|
|
|
51
59
|
// Check for returning from canceled payment
|
|
52
60
|
const canceled = searchParams.get('canceled') === 'true';
|
|
53
61
|
const existingCheckoutId = searchParams.get('checkout_id');
|
|
54
62
|
|
|
63
|
+
// Pre-fill address from customer profile when logged in
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!isLoggedIn) return;
|
|
66
|
+
getClient()
|
|
67
|
+
.getCheckoutPrefillData()
|
|
68
|
+
.then((data) => {
|
|
69
|
+
if (data.shippingAddress) setPrefillAddress(data.shippingAddress);
|
|
70
|
+
})
|
|
71
|
+
.catch(() => {});
|
|
72
|
+
}, [isLoggedIn]);
|
|
73
|
+
|
|
55
74
|
// Initialize or resume checkout
|
|
56
75
|
const initCheckout = useCallback(async () => {
|
|
57
76
|
try {
|
|
@@ -134,7 +153,10 @@ function CheckoutContent() {
|
|
|
134
153
|
}, [cartLoaded, initCheckout]);
|
|
135
154
|
|
|
136
155
|
// Handle shipping address submission
|
|
137
|
-
async function handleAddressSubmit(
|
|
156
|
+
async function handleAddressSubmit(
|
|
157
|
+
address: SetShippingAddressDto,
|
|
158
|
+
consent: { acceptsMarketing: boolean }
|
|
159
|
+
) {
|
|
138
160
|
if (!checkout) return;
|
|
139
161
|
|
|
140
162
|
try {
|
|
@@ -145,6 +167,22 @@ function CheckoutContent() {
|
|
|
145
167
|
const response = await client.setShippingAddress(checkout.id, address);
|
|
146
168
|
setCheckout(response.checkout);
|
|
147
169
|
setShippingRates(response.rates);
|
|
170
|
+
|
|
171
|
+
// Update marketing preference for logged-in users
|
|
172
|
+
if (isLoggedIn) {
|
|
173
|
+
try {
|
|
174
|
+
await client.updateMyProfile({ acceptsMarketing: consent.acceptsMarketing });
|
|
175
|
+
} catch {
|
|
176
|
+
// non-critical
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Offer to save address to profile if logged in and no prefill (new address)
|
|
181
|
+
if (isLoggedIn && !prefillAddress) {
|
|
182
|
+
setLastSubmittedAddress(address);
|
|
183
|
+
setShowSavePrompt(true);
|
|
184
|
+
}
|
|
185
|
+
|
|
148
186
|
setStep('shipping');
|
|
149
187
|
} catch (err) {
|
|
150
188
|
const message = err instanceof Error ? err.message : t('failedToSaveAddress');
|
|
@@ -154,6 +192,30 @@ function CheckoutContent() {
|
|
|
154
192
|
}
|
|
155
193
|
}
|
|
156
194
|
|
|
195
|
+
async function handleSaveAddressToProfile() {
|
|
196
|
+
if (!lastSubmittedAddress) return;
|
|
197
|
+
setSavingAddress(true);
|
|
198
|
+
try {
|
|
199
|
+
await getClient().addMyAddress({
|
|
200
|
+
firstName: lastSubmittedAddress.firstName,
|
|
201
|
+
lastName: lastSubmittedAddress.lastName,
|
|
202
|
+
line1: lastSubmittedAddress.line1,
|
|
203
|
+
line2: lastSubmittedAddress.line2,
|
|
204
|
+
city: lastSubmittedAddress.city,
|
|
205
|
+
region: lastSubmittedAddress.region,
|
|
206
|
+
postalCode: lastSubmittedAddress.postalCode,
|
|
207
|
+
country: lastSubmittedAddress.country,
|
|
208
|
+
phone: lastSubmittedAddress.phone,
|
|
209
|
+
isDefault: true,
|
|
210
|
+
});
|
|
211
|
+
} catch {
|
|
212
|
+
// ignore
|
|
213
|
+
} finally {
|
|
214
|
+
setSavingAddress(false);
|
|
215
|
+
setShowSavePrompt(false);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
157
219
|
// Handle shipping method selection
|
|
158
220
|
async function handleShippingSelect(rateId: string) {
|
|
159
221
|
if (!checkout) return;
|
|
@@ -405,6 +467,29 @@ function CheckoutContent() {
|
|
|
405
467
|
</button>
|
|
406
468
|
)}
|
|
407
469
|
</div>
|
|
470
|
+
{/* Save-to-profile prompt */}
|
|
471
|
+
{showSavePrompt && (
|
|
472
|
+
<div className="border-border bg-muted/50 mb-4 flex items-center justify-between gap-3 rounded-lg border px-4 py-3 text-sm">
|
|
473
|
+
<span className="text-foreground">{tAddr('saveToProfile')}</span>
|
|
474
|
+
<div className="flex gap-2">
|
|
475
|
+
<button
|
|
476
|
+
type="button"
|
|
477
|
+
onClick={handleSaveAddressToProfile}
|
|
478
|
+
disabled={savingAddress}
|
|
479
|
+
className="bg-primary text-primary-foreground rounded px-3 py-1 text-xs font-medium transition-opacity hover:opacity-90 disabled:opacity-50"
|
|
480
|
+
>
|
|
481
|
+
{savingAddress ? '...' : tAddr('saveYes')}
|
|
482
|
+
</button>
|
|
483
|
+
<button
|
|
484
|
+
type="button"
|
|
485
|
+
onClick={() => setShowSavePrompt(false)}
|
|
486
|
+
className="text-muted-foreground hover:text-foreground rounded px-3 py-1 text-xs transition-colors"
|
|
487
|
+
>
|
|
488
|
+
{tAddr('saveNo')}
|
|
489
|
+
</button>
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
492
|
+
)}
|
|
408
493
|
<CheckoutForm
|
|
409
494
|
onSubmit={handleAddressSubmit}
|
|
410
495
|
loading={loading}
|
|
@@ -423,7 +508,20 @@ function CheckoutContent() {
|
|
|
423
508
|
country: checkout.shippingAddress.country,
|
|
424
509
|
phone: checkout.shippingAddress.phone || '',
|
|
425
510
|
}
|
|
426
|
-
:
|
|
511
|
+
: prefillAddress
|
|
512
|
+
? {
|
|
513
|
+
email: prefillAddress.email,
|
|
514
|
+
firstName: prefillAddress.firstName,
|
|
515
|
+
lastName: prefillAddress.lastName,
|
|
516
|
+
line1: prefillAddress.line1,
|
|
517
|
+
line2: prefillAddress.line2 || '',
|
|
518
|
+
city: prefillAddress.city,
|
|
519
|
+
region: prefillAddress.region || '',
|
|
520
|
+
postalCode: prefillAddress.postalCode,
|
|
521
|
+
country: prefillAddress.country,
|
|
522
|
+
phone: prefillAddress.phone || '',
|
|
523
|
+
}
|
|
524
|
+
: undefined
|
|
427
525
|
}
|
|
428
526
|
/>
|
|
429
527
|
</div>
|
|
@@ -7,12 +7,34 @@ import './globals.css';
|
|
|
7
7
|
|
|
8
8
|
<%- fontVariable %>
|
|
9
9
|
|
|
10
|
+
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://example.com';
|
|
11
|
+
|
|
10
12
|
export const metadata: Metadata = {
|
|
11
|
-
|
|
13
|
+
metadataBase: new URL(baseUrl),
|
|
14
|
+
title: {
|
|
15
|
+
default: '<%= storeName %>',
|
|
16
|
+
template: `%s | <%= storeName %>`,
|
|
17
|
+
},
|
|
12
18
|
description: '<%= storeName %>',
|
|
19
|
+
alternates: {
|
|
20
|
+
canonical: '/',
|
|
21
|
+
},
|
|
13
22
|
openGraph: {
|
|
23
|
+
siteName: '<%= storeName %>',
|
|
14
24
|
locale: '<%= ogLocale %>',
|
|
25
|
+
type: 'website',
|
|
15
26
|
},
|
|
27
|
+
robots: {
|
|
28
|
+
index: true,
|
|
29
|
+
follow: true,
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const organizationJsonLd = {
|
|
34
|
+
'@context': 'https://schema.org',
|
|
35
|
+
'@type': 'Organization',
|
|
36
|
+
name: '<%= storeName %>',
|
|
37
|
+
url: baseUrl,
|
|
16
38
|
};
|
|
17
39
|
|
|
18
40
|
export default function RootLayout({
|
|
@@ -22,6 +44,12 @@ export default function RootLayout({
|
|
|
22
44
|
}) {
|
|
23
45
|
return (
|
|
24
46
|
<html lang="<%= language %>" dir="<%= direction %>">
|
|
47
|
+
<head>
|
|
48
|
+
<script
|
|
49
|
+
type="application/ld+json"
|
|
50
|
+
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationJsonLd) }}
|
|
51
|
+
/>
|
|
52
|
+
</head>
|
|
25
53
|
<body className={font.className}>
|
|
26
54
|
<StoreProvider>
|
|
27
55
|
<div className="min-h-screen flex flex-col">
|
|
@@ -20,6 +20,9 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
|
20
20
|
return {
|
|
21
21
|
title: product.name,
|
|
22
22
|
description,
|
|
23
|
+
alternates: {
|
|
24
|
+
canonical: `/products/${slug}`,
|
|
25
|
+
},
|
|
23
26
|
openGraph: {
|
|
24
27
|
title: product.name,
|
|
25
28
|
description,
|
|
@@ -53,10 +56,11 @@ export default async function ProductDetailPage({ params }: Props) {
|
|
|
53
56
|
|
|
54
57
|
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
|
|
55
58
|
const productUrl = `${baseUrl}/products/${slug}`;
|
|
59
|
+
const currency = process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
|
|
56
60
|
|
|
57
61
|
return (
|
|
58
62
|
<>
|
|
59
|
-
<ProductJsonLd product={product} url={productUrl} />
|
|
63
|
+
<ProductJsonLd product={product} url={productUrl} currency={currency} />
|
|
60
64
|
<ProductClientSection product={product} />
|
|
61
65
|
</>
|
|
62
66
|
);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Metadata } from 'next';
|
|
2
|
+
|
|
3
|
+
export const metadata: Metadata = {
|
|
4
|
+
title: 'Products',
|
|
5
|
+
description: 'Browse our full collection of products.',
|
|
6
|
+
alternates: {
|
|
7
|
+
canonical: '/products',
|
|
8
|
+
},
|
|
9
|
+
openGraph: {
|
|
10
|
+
title: 'Products',
|
|
11
|
+
description: 'Browse our full collection of products.',
|
|
12
|
+
type: 'website',
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export default function ProductsLayout({ children }: { children: React.ReactNode }) {
|
|
17
|
+
return <>{children}</>;
|
|
18
|
+
}
|
|
@@ -180,7 +180,7 @@ function VerifyEmailContent() {
|
|
|
180
180
|
|
|
181
181
|
<form onSubmit={handleFormSubmit} className="space-y-6">
|
|
182
182
|
{/* Digit inputs */}
|
|
183
|
-
<div className="flex justify-center gap-2 sm:gap-3" onPaste={handlePaste}>
|
|
183
|
+
<div dir="ltr" className="flex justify-center gap-2 sm:gap-3" onPaste={handlePaste}>
|
|
184
184
|
{digits.map((digit, index) => (
|
|
185
185
|
<input
|
|
186
186
|
key={index}
|