create-brainerce-store 1.14.2 → 1.14.4

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 (29) hide show
  1. package/dist/index.js +47 -4
  2. package/messages/en.json +36 -4
  3. package/messages/he.json +36 -4
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/scripts/fetch-store-info.mjs +81 -74
  6. package/templates/nextjs/base/src/app/account/layout.tsx +9 -0
  7. package/templates/nextjs/base/src/app/account/page.tsx +122 -112
  8. package/templates/nextjs/base/src/app/cart/layout.tsx +9 -0
  9. package/templates/nextjs/base/src/app/checkout/layout.tsx +9 -0
  10. package/templates/nextjs/base/src/app/checkout/page.tsx +107 -8
  11. package/templates/nextjs/base/src/app/layout.tsx.ejs +29 -1
  12. package/templates/nextjs/base/src/app/login/layout.tsx +9 -0
  13. package/templates/nextjs/base/src/app/order-confirmation/layout.tsx +9 -0
  14. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +5 -1
  15. package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +1 -6
  16. package/templates/nextjs/base/src/app/products/layout.tsx +18 -0
  17. package/templates/nextjs/base/src/app/products/page.tsx +1 -0
  18. package/templates/nextjs/base/src/app/register/layout.tsx +9 -0
  19. package/templates/nextjs/base/src/app/register/page.tsx +1 -0
  20. package/templates/nextjs/base/src/app/verify-email/page.tsx +1 -1
  21. package/templates/nextjs/base/src/components/account/address-book.tsx +432 -0
  22. package/templates/nextjs/base/src/components/account/order-history.tsx +2 -1
  23. package/templates/nextjs/base/src/components/auth/register-form.tsx +232 -184
  24. package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
  25. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +359 -305
  26. package/templates/nextjs/base/src/components/products/product-card.tsx +159 -43
  27. package/templates/nextjs/base/src/components/products/stock-badge.tsx +60 -53
  28. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +40 -7
  29. package/templates/nextjs/base/src/lib/auth.ts +1 -0
@@ -0,0 +1,9 @@
1
+ import type { Metadata } from 'next';
2
+
3
+ export const metadata: Metadata = {
4
+ robots: { index: false, follow: false },
5
+ };
6
+
7
+ export default function Layout({ children }: { children: React.ReactNode }) {
8
+ return <>{children}</>;
9
+ }
@@ -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(address: SetShippingAddressDto) {
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
- : undefined
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>
@@ -564,11 +662,12 @@ function CheckoutContent() {
564
662
  )}
565
663
 
566
664
  {/* Coupon input — show from shipping/pickup step onwards (or immediately if digital) */}
567
- {cart && (isAllDigital || step === 'shipping' || step === 'pickup' || step === 'payment') && (
568
- <div className="border-border border-t pt-4">
569
- <CouponInput cart={cart} onUpdate={handleCouponUpdate} />
570
- </div>
571
- )}
665
+ {cart &&
666
+ (isAllDigital || step === 'shipping' || step === 'pickup' || step === 'payment') && (
667
+ <div className="border-border border-t pt-4">
668
+ <CouponInput cart={cart} onUpdate={handleCouponUpdate} />
669
+ </div>
670
+ )}
572
671
 
573
672
  {/* Totals */}
574
673
  {checkout && (
@@ -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
- title: '<%= storeName %>',
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">
@@ -0,0 +1,9 @@
1
+ import type { Metadata } from 'next';
2
+
3
+ export const metadata: Metadata = {
4
+ robots: { index: false, follow: false },
5
+ };
6
+
7
+ export default function Layout({ children }: { children: React.ReactNode }) {
8
+ return <>{children}</>;
9
+ }
@@ -0,0 +1,9 @@
1
+ import type { Metadata } from 'next';
2
+
3
+ export const metadata: Metadata = {
4
+ robots: { index: false, follow: false },
5
+ };
6
+
7
+ export default function Layout({ children }: { children: React.ReactNode }) {
8
+ return <>{children}</>;
9
+ }
@@ -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
  );
@@ -7,13 +7,8 @@ import type {
7
7
  ProductVariant,
8
8
  ProductImage,
9
9
  ProductMetafield,
10
- ProductRecommendationsResponse,
11
10
  DownloadFile,
12
11
  } from 'brainerce';
13
-
14
- type ProductWithRecommendations = Product & {
15
- recommendations?: ProductRecommendationsResponse;
16
- };
17
12
  import { getProductPriceInfo, getDescriptionContent } from 'brainerce';
18
13
  import { useCart } from '@/providers/store-provider';
19
14
  import { PriceDisplay } from '@/components/shared/price-display';
@@ -116,7 +111,7 @@ export function ProductClientSection({ product: initialProduct }: ProductClientS
116
111
  const { refreshCart } = useCart();
117
112
  const t = useTranslations('productDetail');
118
113
 
119
- const product = initialProduct as ProductWithRecommendations;
114
+ const product = initialProduct;
120
115
  const recommendations = product?.recommendations ?? null;
121
116
 
122
117
  const [selectedVariant, setSelectedVariant] = useState<ProductVariant | null>(
@@ -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
+ }
@@ -29,6 +29,7 @@ const sortOptions: SortOption[] = [
29
29
  interface CategoryNode {
30
30
  id: string;
31
31
  name: string;
32
+ image?: string | null;
32
33
  parentId?: string | null;
33
34
  children: CategoryNode[];
34
35
  }
@@ -0,0 +1,9 @@
1
+ import type { Metadata } from 'next';
2
+
3
+ export const metadata: Metadata = {
4
+ robots: { index: false, follow: false },
5
+ };
6
+
7
+ export default function Layout({ children }: { children: React.ReactNode }) {
8
+ return <>{children}</>;
9
+ }
@@ -20,6 +20,7 @@ export default function RegisterPage() {
20
20
  lastName: string;
21
21
  email: string;
22
22
  password: string;
23
+ acceptsMarketing: boolean;
23
24
  }) {
24
25
  try {
25
26
  setError(null);
@@ -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}