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
@@ -1,13 +1,16 @@
1
1
  'use client';
2
2
 
3
+ import { useState } from 'react';
3
4
  import Link from 'next/link';
5
+ import { useRouter } from 'next/navigation';
4
6
  import Image from 'next/image';
5
7
  import type { Product } from 'brainerce';
6
- import { getProductPriceInfo } from 'brainerce';
8
+ import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
7
9
  import { useTranslations } from '@/lib/translations';
8
10
  import { PriceDisplay } from '@/components/shared/price-display';
9
11
  import { StockBadge } from '@/components/products/stock-badge';
10
12
  import { DiscountBadge } from '@/components/products/discount-badge';
13
+ import { useCart, useStoreInfo } from '@/providers/store-provider';
11
14
  import { cn } from '@/lib/utils';
12
15
 
13
16
  interface ProductCardProps {
@@ -15,60 +18,167 @@ interface ProductCardProps {
15
18
  className?: string;
16
19
  }
17
20
 
21
+ function VariantPriceRange({ product }: { product: Product }) {
22
+ const { storeInfo } = useStoreInfo();
23
+ const currency = storeInfo?.currency || 'USD';
24
+ const variants = product.variants ?? [];
25
+ if (variants.length === 0) return null;
26
+
27
+ const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
28
+ const min = Math.min(...prices);
29
+ const max = Math.max(...prices);
30
+
31
+ return (
32
+ <span className="text-foreground text-sm font-medium">
33
+ {min === max
34
+ ? (formatPrice(min, { currency }) as string)
35
+ : `${formatPrice(min, { currency })} – ${formatPrice(max, { currency })}`}
36
+ </span>
37
+ );
38
+ }
39
+
18
40
  export function ProductCard({ product, className }: ProductCardProps) {
19
41
  const t = useTranslations('common');
20
42
  const tp = useTranslations('productDetail');
43
+ const tProd = useTranslations('products');
44
+ const router = useRouter();
45
+ const { refreshCart } = useCart();
21
46
  const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
22
47
  const mainImage = product.images?.[0];
23
48
  const imageUrl = mainImage?.url || null;
24
49
  const slug = product.slug || product.id;
50
+ const isVariable = product.type === 'VARIABLE';
51
+
52
+ const [adding, setAdding] = useState(false);
53
+ const [added, setAdded] = useState(false);
54
+
55
+ const canPurchase = product.inventory?.canPurchase !== false;
56
+
57
+ async function handleAddToCart(e: React.MouseEvent) {
58
+ e.preventDefault();
59
+ e.stopPropagation();
60
+
61
+ if (isVariable) {
62
+ router.push(`/products/${slug}`);
63
+ return;
64
+ }
65
+
66
+ if (adding || !canPurchase) return;
67
+
68
+ try {
69
+ setAdding(true);
70
+ const { getClient } = await import('@/lib/brainerce');
71
+ const client = getClient();
72
+ await client.smartAddToCart({ productId: product.id, quantity: 1 });
73
+ await refreshCart();
74
+ setAdded(true);
75
+ setTimeout(() => setAdded(false), 2000);
76
+ } catch (err) {
77
+ console.error('Failed to add to cart:', err);
78
+ } finally {
79
+ setAdding(false);
80
+ }
81
+ }
25
82
 
26
83
  return (
27
- <Link
28
- href={`/products/${slug}`}
84
+ <div
29
85
  className={cn(
30
86
  'border-border bg-background group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
31
87
  className
32
88
  )}
33
89
  >
34
- {/* Image */}
35
- <div className="bg-muted relative aspect-square overflow-hidden">
36
- {imageUrl ? (
37
- <Image
38
- src={imageUrl}
39
- alt={mainImage?.alt || product.name}
40
- fill
41
- sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
42
- className="object-cover transition-transform duration-300 group-hover:scale-105"
43
- />
44
- ) : (
45
- <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
46
- <svg className="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
47
- <path
48
- strokeLinecap="round"
49
- strokeLinejoin="round"
50
- strokeWidth={1.5}
51
- d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
52
- />
53
- </svg>
90
+ {/* Image — clickable */}
91
+ <Link href={`/products/${slug}`} className="block">
92
+ <div className="bg-muted relative aspect-square overflow-hidden">
93
+ {imageUrl ? (
94
+ <Image
95
+ src={imageUrl}
96
+ alt={mainImage?.alt || product.name}
97
+ fill
98
+ sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 25vw"
99
+ className="object-cover transition-transform duration-300 group-hover:scale-105"
100
+ />
101
+ ) : (
102
+ <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
103
+ <svg className="h-12 w-12" fill="none" viewBox="0 0 24 24" stroke="currentColor">
104
+ <path
105
+ strokeLinecap="round"
106
+ strokeLinejoin="round"
107
+ strokeWidth={1.5}
108
+ d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"
109
+ />
110
+ </svg>
111
+ </div>
112
+ )}
113
+
114
+ {/* Badges */}
115
+ <div className="absolute start-2 top-2 flex flex-col gap-1">
116
+ {isOnSale && (
117
+ <span className="bg-destructive text-destructive-foreground rounded px-2 py-1 text-xs font-bold">
118
+ {t('sale')}
119
+ </span>
120
+ )}
121
+ <DiscountBadge discount={product.discount} />
122
+ {product.isDownloadable && (
123
+ <span className="bg-primary text-primary-foreground rounded px-2 py-1 text-xs font-bold">
124
+ {tp('digitalProduct')}
125
+ </span>
126
+ )}
54
127
  </div>
55
- )}
56
128
 
57
- {/* Badges */}
58
- <div className="absolute start-2 top-2 flex flex-col gap-1">
59
- {isOnSale && (
60
- <span className="bg-destructive text-destructive-foreground rounded px-2 py-1 text-xs font-bold">
61
- {t('sale')}
62
- </span>
63
- )}
64
- <DiscountBadge discount={product.discount} />
65
- {product.isDownloadable && (
66
- <span className="bg-primary text-primary-foreground rounded px-2 py-1 text-xs font-bold">
67
- {tp('digitalProduct')}
68
- </span>
129
+ {/* Add to cart overlay button */}
130
+ {(isVariable || canPurchase) && (
131
+ <button
132
+ onClick={handleAddToCart}
133
+ disabled={adding}
134
+ aria-label={isVariable ? tProd('selectOptions') : tp('addToCart')}
135
+ className={cn(
136
+ 'absolute bottom-2 end-2 flex h-8 w-8 items-center justify-center rounded-full shadow-md transition-all',
137
+ 'translate-y-2 opacity-0 group-hover:translate-y-0 group-hover:opacity-100',
138
+ added
139
+ ? 'bg-green-500 text-white'
140
+ : 'bg-primary text-primary-foreground hover:opacity-90'
141
+ )}
142
+ >
143
+ {added ? (
144
+ <svg
145
+ className="h-4 w-4"
146
+ fill="none"
147
+ viewBox="0 0 24 24"
148
+ stroke="currentColor"
149
+ strokeWidth={2.5}
150
+ >
151
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
152
+ </svg>
153
+ ) : isVariable ? (
154
+ <svg
155
+ className="h-4 w-4"
156
+ fill="none"
157
+ viewBox="0 0 24 24"
158
+ stroke="currentColor"
159
+ strokeWidth={2}
160
+ >
161
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 4v16m8-8H4" />
162
+ </svg>
163
+ ) : (
164
+ <svg
165
+ className="h-4 w-4"
166
+ fill="none"
167
+ viewBox="0 0 24 24"
168
+ stroke="currentColor"
169
+ strokeWidth={2}
170
+ >
171
+ <path
172
+ strokeLinecap="round"
173
+ strokeLinejoin="round"
174
+ d="M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z"
175
+ />
176
+ </svg>
177
+ )}
178
+ </button>
69
179
  )}
70
180
  </div>
71
- </div>
181
+ </Link>
72
182
 
73
183
  {/* Content */}
74
184
  <div className="space-y-2 p-3">
@@ -86,17 +196,23 @@ export function ProductCard({ product, className }: ProductCardProps) {
86
196
  </div>
87
197
  )}
88
198
 
89
- {/* Name */}
90
- <h3 className="text-foreground group-hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
91
- {product.name}
92
- </h3>
199
+ {/* Name — clickable */}
200
+ <Link href={`/products/${slug}`}>
201
+ <h3 className="text-foreground hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
202
+ {product.name}
203
+ </h3>
204
+ </Link>
93
205
 
94
206
  {/* Price */}
95
- <PriceDisplay price={originalPrice} salePrice={isOnSale ? price : undefined} size="sm" />
207
+ {isVariable ? (
208
+ <VariantPriceRange product={product} />
209
+ ) : (
210
+ <PriceDisplay price={originalPrice} salePrice={isOnSale ? price : undefined} size="sm" />
211
+ )}
96
212
 
97
213
  {/* Stock */}
98
214
  <StockBadge inventory={product.inventory} />
99
215
  </div>
100
- </Link>
216
+ </div>
101
217
  );
102
218
  }
@@ -1,53 +1,60 @@
1
- 'use client';
2
-
3
- import type { InventoryInfo } from 'brainerce';
4
- import { cn } from '@/lib/utils';
5
-
6
- interface StockBadgeProps {
7
- inventory: InventoryInfo | null | undefined;
8
- lowStockThreshold?: number;
9
- className?: string;
10
- }
11
-
12
- export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
13
- const label = getStockLabel(inventory, lowStockThreshold);
14
- const color = getStockColor(inventory, lowStockThreshold);
15
-
16
- return (
17
- <span
18
- className={cn(
19
- 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
20
- color,
21
- className
22
- )}
23
- >
24
- {label}
25
- </span>
26
- );
27
- }
28
-
29
- function getStockLabel(inventory: InventoryInfo | null | undefined, lowStockThreshold: number): string {
30
- if (!inventory) return 'Out of Stock';
31
-
32
- const { trackingMode, inStock, available } = inventory;
33
-
34
- if (trackingMode === 'DISABLED') return 'Unavailable';
35
- if (!inStock) return 'Out of Stock';
36
- if (trackingMode === 'UNLIMITED') return 'In Stock';
37
-
38
- // TRACKED show actual quantity
39
- if (available <= lowStockThreshold) {
40
- return `Only ${available} left`;
41
- }
42
- return `${available} in stock`;
43
- }
44
-
45
- function getStockColor(inventory: InventoryInfo | null | undefined, lowStockThreshold: number): string {
46
- if (!inventory) return 'bg-red-100 text-red-800';
47
-
48
- const { trackingMode, inStock, available } = inventory;
49
-
50
- if (trackingMode === 'DISABLED' || !inStock) return 'bg-red-100 text-red-800';
51
- if (trackingMode === 'TRACKED' && available <= lowStockThreshold) return 'bg-yellow-100 text-yellow-800';
52
- return 'bg-green-100 text-green-800';
53
- }
1
+ 'use client';
2
+
3
+ import type { InventoryInfo } from 'brainerce';
4
+ import { cn } from '@/lib/utils';
5
+
6
+ interface StockBadgeProps {
7
+ inventory: InventoryInfo | null | undefined;
8
+ lowStockThreshold?: number;
9
+ className?: string;
10
+ }
11
+
12
+ export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
13
+ const label = getStockLabel(inventory, lowStockThreshold);
14
+ const color = getStockColor(inventory, lowStockThreshold);
15
+
16
+ return (
17
+ <span
18
+ className={cn(
19
+ 'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium',
20
+ color,
21
+ className
22
+ )}
23
+ >
24
+ {label}
25
+ </span>
26
+ );
27
+ }
28
+
29
+ function getStockLabel(
30
+ inventory: InventoryInfo | null | undefined,
31
+ lowStockThreshold: number
32
+ ): string {
33
+ if (!inventory) return 'Out of Stock';
34
+
35
+ const { trackingMode, inStock, available } = inventory;
36
+
37
+ if (trackingMode === 'DISABLED') return 'Unavailable';
38
+ if (!inStock) return 'Out of Stock';
39
+ if (trackingMode === 'UNLIMITED') return 'In Stock';
40
+
41
+ // TRACKED — show actual quantity
42
+ if (available <= lowStockThreshold) {
43
+ return `Only ${available} left`;
44
+ }
45
+ return `${available} in stock`;
46
+ }
47
+
48
+ function getStockColor(
49
+ inventory: InventoryInfo | null | undefined,
50
+ lowStockThreshold: number
51
+ ): string {
52
+ if (!inventory) return 'bg-red-100 text-red-800';
53
+
54
+ const { trackingMode, inStock, available } = inventory;
55
+
56
+ if (trackingMode === 'DISABLED' || !inStock) return 'bg-red-100 text-red-800';
57
+ if (trackingMode === 'TRACKED' && available <= lowStockThreshold)
58
+ return 'bg-yellow-100 text-yellow-800';
59
+ return 'bg-green-100 text-green-800';
60
+ }
@@ -4,13 +4,15 @@ import { getProductPriceInfo } from 'brainerce';
4
4
  interface ProductJsonLdProps {
5
5
  product: Product;
6
6
  url: string;
7
+ currency?: string;
7
8
  }
8
9
 
9
- export function ProductJsonLd({ product, url }: ProductJsonLdProps) {
10
+ export function ProductJsonLd({ product, url, currency = 'USD' }: ProductJsonLdProps) {
10
11
  const priceInfo = getProductPriceInfo(product);
11
12
  const imageUrl = product.images?.[0]?.url;
13
+ const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
12
14
 
13
- const jsonLd = {
15
+ const productJsonLd = {
14
16
  '@context': 'https://schema.org',
15
17
  '@type': 'Product',
16
18
  name: product.name,
@@ -21,7 +23,7 @@ export function ProductJsonLd({ product, url }: ProductJsonLdProps) {
21
23
  offers: {
22
24
  '@type': 'Offer',
23
25
  price: priceInfo.price,
24
- priceCurrency: 'ILS',
26
+ priceCurrency: currency,
25
27
  availability:
26
28
  product.inventory?.canPurchase !== false
27
29
  ? 'https://schema.org/InStock'
@@ -30,10 +32,41 @@ export function ProductJsonLd({ product, url }: ProductJsonLdProps) {
30
32
  },
31
33
  };
32
34
 
35
+ const breadcrumbJsonLd = {
36
+ '@context': 'https://schema.org',
37
+ '@type': 'BreadcrumbList',
38
+ itemListElement: [
39
+ {
40
+ '@type': 'ListItem',
41
+ position: 1,
42
+ name: 'Home',
43
+ item: baseUrl || '/',
44
+ },
45
+ {
46
+ '@type': 'ListItem',
47
+ position: 2,
48
+ name: 'Products',
49
+ item: `${baseUrl}/products`,
50
+ },
51
+ {
52
+ '@type': 'ListItem',
53
+ position: 3,
54
+ name: product.name,
55
+ item: url,
56
+ },
57
+ ],
58
+ };
59
+
33
60
  return (
34
- <script
35
- type="application/ld+json"
36
- dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
37
- />
61
+ <>
62
+ <script
63
+ type="application/ld+json"
64
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(productJsonLd) }}
65
+ />
66
+ <script
67
+ type="application/ld+json"
68
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbJsonLd) }}
69
+ />
70
+ </>
38
71
  );
39
72
  }
@@ -81,6 +81,7 @@ export async function proxyRegister(data: {
81
81
  lastName: string;
82
82
  email: string;
83
83
  password: string;
84
+ acceptsMarketing?: boolean;
84
85
  }): Promise<RegisterResult> {
85
86
  const response = await fetch(`/api/store/api/vc/${CONNECTION_ID}/customers/register`, {
86
87
  method: 'POST',