create-brainerce-store 1.18.0 → 1.20.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 (67) hide show
  1. package/LICENSE +0 -0
  2. package/dist/index.js +31 -9
  3. package/messages/en.json +366 -362
  4. package/messages/he.json +366 -362
  5. package/package.json +8 -8
  6. package/templates/nextjs/base/next.config.ts +31 -31
  7. package/templates/nextjs/base/scripts/fetch-store-info.mjs +81 -81
  8. package/templates/nextjs/base/src/app/.well-known/apple-developer-merchantid-domain-association/route.ts +26 -26
  9. package/templates/nextjs/base/src/app/account/layout.tsx +9 -9
  10. package/templates/nextjs/base/src/app/account/page.tsx +122 -122
  11. package/templates/nextjs/base/src/app/api/auth/logout/route.ts +14 -14
  12. package/templates/nextjs/base/src/app/api/auth/me/route.ts +56 -56
  13. package/templates/nextjs/base/src/app/api/auth/oauth-callback/route.ts +59 -59
  14. package/templates/nextjs/base/src/app/api/auth/reset-callback/route.ts +41 -41
  15. package/templates/nextjs/base/src/app/api/auth/reset-password/route.ts +77 -77
  16. package/templates/nextjs/base/src/app/api/store/[...path]/route.ts +198 -198
  17. package/templates/nextjs/base/src/app/auth/callback/page.tsx +92 -92
  18. package/templates/nextjs/base/src/app/cart/layout.tsx +9 -9
  19. package/templates/nextjs/base/src/app/cart/page.tsx +204 -204
  20. package/templates/nextjs/base/src/app/checkout/layout.tsx +9 -9
  21. package/templates/nextjs/base/src/app/checkout/page.tsx +860 -860
  22. package/templates/nextjs/base/src/app/forgot-password/page.tsx +112 -112
  23. package/templates/nextjs/base/src/app/layout.tsx.ejs +75 -0
  24. package/templates/nextjs/base/src/app/login/layout.tsx +9 -9
  25. package/templates/nextjs/base/src/app/login/page.tsx +59 -59
  26. package/templates/nextjs/base/src/app/order-confirmation/layout.tsx +9 -9
  27. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +17 -0
  28. package/templates/nextjs/base/src/app/payment-complete/page.tsx +59 -0
  29. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +67 -67
  30. package/templates/nextjs/base/src/app/products/[slug]/product-client-section.tsx +486 -486
  31. package/templates/nextjs/base/src/app/products/layout.tsx +18 -18
  32. package/templates/nextjs/base/src/app/products/page.tsx +431 -431
  33. package/templates/nextjs/base/src/app/register/layout.tsx +9 -9
  34. package/templates/nextjs/base/src/app/register/page.tsx +65 -65
  35. package/templates/nextjs/base/src/app/reset-password/page.tsx +132 -132
  36. package/templates/nextjs/base/src/app/robots.ts +14 -14
  37. package/templates/nextjs/base/src/app/sitemap.ts +25 -25
  38. package/templates/nextjs/base/src/app/verify-email/page.tsx +258 -258
  39. package/templates/nextjs/base/src/components/account/address-book.tsx +432 -432
  40. package/templates/nextjs/base/src/components/account/order-history.tsx +350 -350
  41. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +137 -137
  42. package/templates/nextjs/base/src/components/auth/register-form.tsx +232 -232
  43. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +247 -111
  44. package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
  45. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +142 -142
  46. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +59 -59
  47. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +415 -415
  48. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +243 -83
  49. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +49 -3
  50. package/templates/nextjs/base/src/components/layout/footer.tsx +41 -41
  51. package/templates/nextjs/base/src/components/layout/header.tsx +336 -336
  52. package/templates/nextjs/base/src/components/layout/language-switcher.tsx.ejs +63 -0
  53. package/templates/nextjs/base/src/components/products/discount-badge.tsx +22 -22
  54. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +202 -202
  55. package/templates/nextjs/base/src/components/products/product-card.tsx +218 -218
  56. package/templates/nextjs/base/src/components/products/recommendation-section.tsx +107 -107
  57. package/templates/nextjs/base/src/components/products/stock-badge.tsx +63 -63
  58. package/templates/nextjs/base/src/components/products/variant-selector.tsx +292 -292
  59. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +72 -72
  60. package/templates/nextjs/base/src/i18n.ts.ejs +21 -0
  61. package/templates/nextjs/base/src/lib/auth.ts +149 -149
  62. package/templates/nextjs/base/src/lib/brainerce.ts.ejs +9 -0
  63. package/templates/nextjs/base/src/lib/translations.ts.ejs +31 -0
  64. package/templates/nextjs/base/src/middleware.ts.ejs +81 -0
  65. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +41 -0
  66. package/templates/nextjs/base/src/lib/translations.ts +0 -11
  67. package/templates/nextjs/base/src/middleware.ts +0 -25
@@ -1,218 +1,218 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import Link from 'next/link';
5
- import { useRouter } from 'next/navigation';
6
- import Image from 'next/image';
7
- import type { Product } from 'brainerce';
8
- import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
9
- import { useTranslations } from '@/lib/translations';
10
- import { PriceDisplay } from '@/components/shared/price-display';
11
- import { StockBadge } from '@/components/products/stock-badge';
12
- import { DiscountBadge } from '@/components/products/discount-badge';
13
- import { useCart, useStoreInfo } from '@/providers/store-provider';
14
- import { cn } from '@/lib/utils';
15
-
16
- interface ProductCardProps {
17
- product: Product;
18
- className?: string;
19
- }
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
-
40
- export function ProductCard({ product, className }: ProductCardProps) {
41
- const t = useTranslations('common');
42
- const tp = useTranslations('productDetail');
43
- const tProd = useTranslations('products');
44
- const router = useRouter();
45
- const { refreshCart } = useCart();
46
- const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
47
- const mainImage = product.images?.[0];
48
- const imageUrl = mainImage?.url || null;
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
- }
82
-
83
- return (
84
- <div
85
- className={cn(
86
- 'border-border bg-background group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
87
- className
88
- )}
89
- >
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
- )}
127
- </div>
128
-
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>
179
- )}
180
- </div>
181
- </Link>
182
-
183
- {/* Content */}
184
- <div className="space-y-2 p-3">
185
- {/* Categories */}
186
- {product.categories && product.categories.length > 0 && (
187
- <div className="flex flex-wrap gap-1">
188
- {product.categories.slice(0, 2).map((cat) => (
189
- <span
190
- key={cat.id}
191
- className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px]"
192
- >
193
- {cat.name}
194
- </span>
195
- ))}
196
- </div>
197
- )}
198
-
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>
205
-
206
- {/* Price */}
207
- {isVariable ? (
208
- <VariantPriceRange product={product} />
209
- ) : (
210
- <PriceDisplay price={originalPrice} salePrice={isOnSale ? price : undefined} size="sm" />
211
- )}
212
-
213
- {/* Stock */}
214
- <StockBadge inventory={product.inventory} />
215
- </div>
216
- </div>
217
- );
218
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import Link from 'next/link';
5
+ import { useRouter } from 'next/navigation';
6
+ import Image from 'next/image';
7
+ import type { Product } from 'brainerce';
8
+ import { getProductPriceInfo, getVariantPrice, formatPrice } from 'brainerce';
9
+ import { useTranslations } from '@/lib/translations';
10
+ import { PriceDisplay } from '@/components/shared/price-display';
11
+ import { StockBadge } from '@/components/products/stock-badge';
12
+ import { DiscountBadge } from '@/components/products/discount-badge';
13
+ import { useCart, useStoreInfo } from '@/providers/store-provider';
14
+ import { cn } from '@/lib/utils';
15
+
16
+ interface ProductCardProps {
17
+ product: Product;
18
+ className?: string;
19
+ }
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
+
40
+ export function ProductCard({ product, className }: ProductCardProps) {
41
+ const t = useTranslations('common');
42
+ const tp = useTranslations('productDetail');
43
+ const tProd = useTranslations('products');
44
+ const router = useRouter();
45
+ const { refreshCart } = useCart();
46
+ const { price, originalPrice, isOnSale } = getProductPriceInfo(product);
47
+ const mainImage = product.images?.[0];
48
+ const imageUrl = mainImage?.url || null;
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
+ }
82
+
83
+ return (
84
+ <div
85
+ className={cn(
86
+ 'border-border bg-background group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
87
+ className
88
+ )}
89
+ >
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
+ )}
127
+ </div>
128
+
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>
179
+ )}
180
+ </div>
181
+ </Link>
182
+
183
+ {/* Content */}
184
+ <div className="space-y-2 p-3">
185
+ {/* Categories */}
186
+ {product.categories && product.categories.length > 0 && (
187
+ <div className="flex flex-wrap gap-1">
188
+ {product.categories.slice(0, 2).map((cat) => (
189
+ <span
190
+ key={cat.id}
191
+ className="text-muted-foreground bg-muted rounded px-1.5 py-0.5 text-[10px]"
192
+ >
193
+ {cat.name}
194
+ </span>
195
+ ))}
196
+ </div>
197
+ )}
198
+
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>
205
+
206
+ {/* Price */}
207
+ {isVariable ? (
208
+ <VariantPriceRange product={product} />
209
+ ) : (
210
+ <PriceDisplay price={originalPrice} salePrice={isOnSale ? price : undefined} size="sm" />
211
+ )}
212
+
213
+ {/* Stock */}
214
+ <StockBadge inventory={product.inventory} />
215
+ </div>
216
+ </div>
217
+ );
218
+ }
@@ -1,107 +1,107 @@
1
- 'use client';
2
-
3
- import { useEffect, useState } from 'react';
4
- import Link from 'next/link';
5
- import Image from 'next/image';
6
- import type { ProductRecommendation } from 'brainerce';
7
- import { PriceDisplay } from '@/components/shared/price-display';
8
- import { cn } from '@/lib/utils';
9
-
10
- interface RecommendationCardProps {
11
- item: ProductRecommendation;
12
- className?: string;
13
- }
14
-
15
- function RecommendationCard({ item, className }: RecommendationCardProps) {
16
- const firstImage = item.images?.[0];
17
- const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
18
- const slug = item.slug || item.id;
19
- const basePrice = parseFloat(item.basePrice);
20
- const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
21
- const isOnSale = salePrice != null && salePrice < basePrice;
22
-
23
- return (
24
- <Link
25
- href={`/products/${slug}`}
26
- className={cn(
27
- 'border-border bg-background group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
28
- className
29
- )}
30
- >
31
- <div className="bg-muted relative aspect-square overflow-hidden">
32
- {imageUrl ? (
33
- <Image
34
- src={imageUrl}
35
- alt={item.name}
36
- fill
37
- sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
38
- className="object-cover transition-transform duration-300 group-hover:scale-105"
39
- />
40
- ) : (
41
- <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
42
- <svg className="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
43
- <path
44
- strokeLinecap="round"
45
- strokeLinejoin="round"
46
- strokeWidth={1.5}
47
- 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"
48
- />
49
- </svg>
50
- </div>
51
- )}
52
- </div>
53
- <div className="space-y-1.5 p-3">
54
- <h3 className="text-foreground group-hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
55
- {item.name}
56
- </h3>
57
- <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
58
- </div>
59
- </Link>
60
- );
61
- }
62
-
63
- interface RecommendationSectionProps {
64
- title: string;
65
- items: ProductRecommendation[];
66
- className?: string;
67
- }
68
-
69
- export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
70
- if (items.length === 0) return null;
71
-
72
- return (
73
- <div className={cn('', className)}>
74
- <h2 className="text-foreground mb-4 text-xl font-semibold">{title}</h2>
75
- <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
76
- {items.map((item) => (
77
- <RecommendationCard key={item.id} item={item} />
78
- ))}
79
- </div>
80
- </div>
81
- );
82
- }
83
-
84
- interface CartRecommendationSectionProps {
85
- title: string;
86
- items: ProductRecommendation[];
87
- className?: string;
88
- }
89
-
90
- export function CartRecommendationSection({
91
- title,
92
- items,
93
- className,
94
- }: CartRecommendationSectionProps) {
95
- if (items.length === 0) return null;
96
-
97
- return (
98
- <div className={cn('', className)}>
99
- <h2 className="text-foreground mb-4 text-lg font-semibold">{title}</h2>
100
- <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
101
- {items.map((item) => (
102
- <RecommendationCard key={item.id} item={item} />
103
- ))}
104
- </div>
105
- </div>
106
- );
107
- }
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+ import Link from 'next/link';
5
+ import Image from 'next/image';
6
+ import type { ProductRecommendation } from 'brainerce';
7
+ import { PriceDisplay } from '@/components/shared/price-display';
8
+ import { cn } from '@/lib/utils';
9
+
10
+ interface RecommendationCardProps {
11
+ item: ProductRecommendation;
12
+ className?: string;
13
+ }
14
+
15
+ function RecommendationCard({ item, className }: RecommendationCardProps) {
16
+ const firstImage = item.images?.[0];
17
+ const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
18
+ const slug = item.slug || item.id;
19
+ const basePrice = parseFloat(item.basePrice);
20
+ const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
21
+ const isOnSale = salePrice != null && salePrice < basePrice;
22
+
23
+ return (
24
+ <Link
25
+ href={`/products/${slug}`}
26
+ className={cn(
27
+ 'border-border bg-background group block overflow-hidden rounded-lg border transition-shadow hover:shadow-md',
28
+ className
29
+ )}
30
+ >
31
+ <div className="bg-muted relative aspect-square overflow-hidden">
32
+ {imageUrl ? (
33
+ <Image
34
+ src={imageUrl}
35
+ alt={item.name}
36
+ fill
37
+ sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
38
+ className="object-cover transition-transform duration-300 group-hover:scale-105"
39
+ />
40
+ ) : (
41
+ <div className="text-muted-foreground absolute inset-0 flex items-center justify-center">
42
+ <svg className="h-10 w-10" fill="none" viewBox="0 0 24 24" stroke="currentColor">
43
+ <path
44
+ strokeLinecap="round"
45
+ strokeLinejoin="round"
46
+ strokeWidth={1.5}
47
+ 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"
48
+ />
49
+ </svg>
50
+ </div>
51
+ )}
52
+ </div>
53
+ <div className="space-y-1.5 p-3">
54
+ <h3 className="text-foreground group-hover:text-primary line-clamp-2 text-sm font-medium transition-colors">
55
+ {item.name}
56
+ </h3>
57
+ <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
58
+ </div>
59
+ </Link>
60
+ );
61
+ }
62
+
63
+ interface RecommendationSectionProps {
64
+ title: string;
65
+ items: ProductRecommendation[];
66
+ className?: string;
67
+ }
68
+
69
+ export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
70
+ if (items.length === 0) return null;
71
+
72
+ return (
73
+ <div className={cn('', className)}>
74
+ <h2 className="text-foreground mb-4 text-xl font-semibold">{title}</h2>
75
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
76
+ {items.map((item) => (
77
+ <RecommendationCard key={item.id} item={item} />
78
+ ))}
79
+ </div>
80
+ </div>
81
+ );
82
+ }
83
+
84
+ interface CartRecommendationSectionProps {
85
+ title: string;
86
+ items: ProductRecommendation[];
87
+ className?: string;
88
+ }
89
+
90
+ export function CartRecommendationSection({
91
+ title,
92
+ items,
93
+ className,
94
+ }: CartRecommendationSectionProps) {
95
+ if (items.length === 0) return null;
96
+
97
+ return (
98
+ <div className={cn('', className)}>
99
+ <h2 className="text-foreground mb-4 text-lg font-semibold">{title}</h2>
100
+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
101
+ {items.map((item) => (
102
+ <RecommendationCard key={item.id} item={item} />
103
+ ))}
104
+ </div>
105
+ </div>
106
+ );
107
+ }