create-brainerce-store 1.72.0 → 1.74.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 (88) hide show
  1. package/dist/index.js +37 -4
  2. package/messages/en.json +30 -1
  3. package/messages/he.json +30 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/AGENTS.md.ejs +63 -16
  6. package/templates/nextjs/base/AI-GUIDE.md +17 -4
  7. package/templates/nextjs/base/CLAUDE.md.ejs +67 -20
  8. package/templates/nextjs/base/TRANSLATIONS.md +207 -207
  9. package/templates/nextjs/base/src/app/account/page.tsx +168 -122
  10. package/templates/nextjs/base/src/app/category/[slug]/page.tsx +10 -1
  11. package/templates/nextjs/base/src/app/checkout/page.tsx +1179 -1074
  12. package/templates/nextjs/base/src/app/layout.tsx.ejs +48 -4
  13. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +98 -3
  14. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +14 -2
  15. package/templates/nextjs/base/src/components/account/loyalty-panel.tsx +226 -0
  16. package/templates/nextjs/base/src/components/account/order-history.tsx +422 -371
  17. package/templates/nextjs/base/src/components/account/order-status-timeline.tsx +85 -85
  18. package/templates/nextjs/base/src/components/account/saved-payment-methods.tsx +104 -0
  19. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +618 -612
  20. package/templates/nextjs/base/src/components/checkout/custom-fields-step.tsx +354 -306
  21. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +141 -74
  22. package/templates/nextjs/base/src/core/hooks/use-cart-page.ts +218 -127
  23. package/templates/nextjs/base/src/core/hooks/use-home-data.ts +63 -51
  24. package/templates/nextjs/base/src/core/hooks/use-product-listing.ts +379 -295
  25. package/templates/nextjs/base/src/core/hooks/use-product-page.ts +33 -1
  26. package/templates/nextjs/base/src/core/lib/capabilities.ts +65 -0
  27. package/templates/nextjs/base/src/core/lib/display-price.ts +139 -0
  28. package/templates/nextjs/base/src/core/lib/region.server.ts +112 -0
  29. package/templates/nextjs/base/src/core/lib/region.ts +112 -0
  30. package/templates/nextjs/base/src/core/lib/store-info.ts +19 -0
  31. package/templates/nextjs/base/src/core/providers/store-provider.tsx.ejs +423 -243
  32. package/templates/nextjs/base/src/ui/cart/cart-item.tsx +174 -164
  33. package/templates/nextjs/base/src/ui/cart/cart-summary.tsx +109 -110
  34. package/templates/nextjs/base/src/ui/cart/cart-view.tsx +219 -176
  35. package/templates/nextjs/base/src/ui/cart/gift-card-input.tsx +191 -0
  36. package/templates/nextjs/base/src/ui/cart/reservation-countdown.tsx +137 -137
  37. package/templates/nextjs/base/src/ui/cart/tax-estimate-line.tsx +108 -0
  38. package/templates/nextjs/base/src/ui/home/home-client.tsx +27 -29
  39. package/templates/nextjs/base/src/ui/layout/header-search.tsx +249 -0
  40. package/templates/nextjs/base/src/ui/layout/region-switcher.tsx +59 -0
  41. package/templates/nextjs/base/src/ui/layout/site-header.tsx.ejs +171 -152
  42. package/templates/nextjs/base/src/ui/product/frequently-bought-together.tsx +197 -191
  43. package/templates/nextjs/base/src/ui/product/product-card.tsx +25 -52
  44. package/templates/nextjs/base/src/ui/product/product-client-section.tsx +12 -3
  45. package/templates/nextjs/base/src/ui/product/product-listing.tsx +460 -446
  46. package/templates/nextjs/base/src/ui/product/recommendation-section.tsx +108 -102
  47. package/templates/nextjs/base/src/ui/product/review-form.tsx +33 -11
  48. package/templates/nextjs/base/src/ui/product/stock-badge.tsx +15 -3
  49. package/templates/nextjs/designs/atelier/app-overlay/layout.tsx.ejs +48 -4
  50. package/templates/nextjs/designs/atelier/messages-patch/en.json +21 -25
  51. package/templates/nextjs/designs/atelier/messages-patch/he.json +21 -25
  52. package/templates/nextjs/designs/atelier/ui/cart/cart-drawer.tsx +177 -181
  53. package/templates/nextjs/designs/atelier/ui/cart/cart-item.tsx +168 -158
  54. package/templates/nextjs/designs/atelier/ui/cart/cart-summary.tsx +106 -107
  55. package/templates/nextjs/designs/atelier/ui/cart/cart-view.tsx +229 -184
  56. package/templates/nextjs/designs/atelier/ui/cart/reservation-countdown.tsx +131 -131
  57. package/templates/nextjs/designs/atelier/ui/cart/tax-estimate-line.tsx +108 -0
  58. package/templates/nextjs/designs/atelier/ui/home/benefits-band.tsx +10 -1
  59. package/templates/nextjs/designs/atelier/ui/home/hero-section.tsx +178 -174
  60. package/templates/nextjs/designs/atelier/ui/home/home-client.tsx +4 -6
  61. package/templates/nextjs/designs/atelier/ui/home/newsletter-section.tsx +150 -78
  62. package/templates/nextjs/designs/atelier/ui/home/testimonials-band.tsx +79 -46
  63. package/templates/nextjs/designs/atelier/ui/layout/header-search.tsx +264 -0
  64. package/templates/nextjs/designs/atelier/ui/layout/region-switcher.tsx +44 -0
  65. package/templates/nextjs/designs/atelier/ui/layout/site-header.tsx.ejs +5 -0
  66. package/templates/nextjs/designs/atelier/ui/product/frequently-bought-together.tsx +202 -196
  67. package/templates/nextjs/designs/atelier/ui/product/product-card.tsx +27 -52
  68. package/templates/nextjs/designs/atelier/ui/product/product-client-section.tsx +12 -3
  69. package/templates/nextjs/designs/atelier/ui/product/product-listing.tsx +332 -260
  70. package/templates/nextjs/designs/atelier/ui/product/recommendation-section.tsx +101 -95
  71. package/templates/nextjs/designs/atelier/ui/product/review-form.tsx +30 -10
  72. package/templates/nextjs/designs/atelier/ui/product/stock-badge.tsx +17 -2
  73. package/templates/nextjs/designs/atelier/ui/shared/icons.tsx +9 -0
  74. package/templates/nextjs/ui-canvas/cart/cart-item.tsx +137 -137
  75. package/templates/nextjs/ui-canvas/cart/cart-summary.tsx +98 -99
  76. package/templates/nextjs/ui-canvas/cart/cart-view.tsx +140 -140
  77. package/templates/nextjs/ui-canvas/cart/gift-card-input.tsx +142 -0
  78. package/templates/nextjs/ui-canvas/cart/reservation-countdown.tsx +124 -124
  79. package/templates/nextjs/ui-canvas/cart/tax-estimate-line.tsx +104 -0
  80. package/templates/nextjs/ui-canvas/home/home-client.tsx +24 -26
  81. package/templates/nextjs/ui-canvas/layout/region-switcher.tsx +51 -0
  82. package/templates/nextjs/ui-canvas/layout/site-header.tsx.ejs +3 -0
  83. package/templates/nextjs/ui-canvas/product/frequently-bought-together.tsx +174 -168
  84. package/templates/nextjs/ui-canvas/product/product-card.tsx +9 -51
  85. package/templates/nextjs/ui-canvas/product/product-client-section.tsx +5 -3
  86. package/templates/nextjs/ui-canvas/product/recommendation-section.tsx +105 -99
  87. package/templates/nextjs/ui-canvas/product/review-form.tsx +9 -1
  88. package/templates/nextjs/ui-canvas/product/stock-badge.tsx +17 -2
@@ -1,95 +1,101 @@
1
- 'use client';
2
-
3
- import { Link } from '@/core/lib/navigation';
4
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
- import type { ProductRecommendation } from 'brainerce';
6
- import { PriceDisplay } from '@/ui/product/price-display';
7
- import { cn } from '@/core/lib/utils';
8
-
9
- interface RecommendationCardProps {
10
- item: ProductRecommendation;
11
- className?: string;
12
- }
13
-
14
- function RecommendationCard({ item, className }: RecommendationCardProps) {
15
- const firstImage = item.images?.[0];
16
- const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
17
- const slug = item.slug || item.id;
18
- const basePrice = parseFloat(item.basePrice);
19
- const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
20
- const isOnSale = salePrice != null && salePrice < basePrice;
21
-
22
- return (
23
- <Link
24
- href={`/products/${slug}`}
25
- className={cn('group card card-hover block overflow-hidden', className)}
26
- >
27
- {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
28
- <span className="relative block aspect-square overflow-hidden bg-secondary">
29
- {imageUrl ? (
30
- <Image
31
- src={imageUrl}
32
- alt={item.name}
33
- fill
34
- sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
35
- className="img-zoom object-cover"
36
- />
37
- ) : null}
38
- </span>
39
- <span className="block space-y-1 p-4">
40
- <span className="block text-sm font-semibold leading-snug text-foreground group-hover:text-primary">
41
- {item.name}
42
- </span>
43
- <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
44
- </span>
45
- </Link>
46
- );
47
- }
48
-
49
- interface RecommendationSectionProps {
50
- title: string;
51
- items: ProductRecommendation[];
52
- className?: string;
53
- }
54
-
55
- /** Recommendation strip on the product page (upsells / related). */
56
- export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
57
- if (items.length === 0) return null;
58
-
59
- return (
60
- <section className={cn('border-t pt-10', className)}>
61
- <h2 className="mb-6 text-2xl">{title}</h2>
62
- <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
63
- {items.slice(0, 4).map((item) => (
64
- <RecommendationCard key={item.id} item={item} />
65
- ))}
66
- </div>
67
- </section>
68
- );
69
- }
70
-
71
- interface CartRecommendationSectionProps {
72
- title: string;
73
- items: ProductRecommendation[];
74
- className?: string;
75
- }
76
-
77
- /** Cross-sell strip on the cart page ("you might also need"). */
78
- export function CartRecommendationSection({
79
- title,
80
- items,
81
- className,
82
- }: CartRecommendationSectionProps) {
83
- if (items.length === 0) return null;
84
-
85
- return (
86
- <section className={cn('border-t pt-10', className)}>
87
- <h2 className="mb-6 text-2xl">{title}</h2>
88
- <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
89
- {items.slice(0, 4).map((item) => (
90
- <RecommendationCard key={item.id} item={item} />
91
- ))}
92
- </div>
93
- </section>
94
- );
95
- }
1
+ 'use client';
2
+
3
+ import { Link } from '@/core/lib/navigation';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { ProductRecommendation } from 'brainerce';
6
+ import { PriceDisplay } from '@/ui/product/price-display';
7
+ import { cn } from '@/core/lib/utils';
8
+
9
+ interface RecommendationCardProps {
10
+ item: ProductRecommendation;
11
+ className?: string;
12
+ }
13
+
14
+ function RecommendationCard({ item, className }: RecommendationCardProps) {
15
+ const firstImage = item.images?.[0];
16
+ const imageUrl = typeof firstImage === 'string' ? firstImage : firstImage?.url || null;
17
+ const slug = item.slug || item.id;
18
+ // NOT region-converted, and it cannot be. `ProductRecommendation` is a
19
+ // trimmed shape that carries `basePrice` / `salePrice` only: the backend
20
+ // attaches no displayPrice/displayCurrency to it, so there is no converted
21
+ // amount to render and converting here would mean inventing an FX rate.
22
+ // These tiles stay in the store currency on a multi-region store. Do not
23
+ // "fix" it with a client-side conversion.
24
+ const basePrice = parseFloat(item.basePrice);
25
+ const salePrice = item.salePrice ? parseFloat(item.salePrice) : undefined;
26
+ const isOnSale = salePrice != null && salePrice < basePrice;
27
+
28
+ return (
29
+ <Link
30
+ href={`/products/${slug}`}
31
+ className={cn('group card card-hover block overflow-hidden', className)}
32
+ >
33
+ {/* `relative` + `aspect-square` are layout-critical for next/image fill */}
34
+ <span className="relative block aspect-square overflow-hidden bg-secondary">
35
+ {imageUrl ? (
36
+ <Image
37
+ src={imageUrl}
38
+ alt={item.name}
39
+ fill
40
+ sizes="(max-width: 640px) 50vw, (max-width: 1024px) 33vw, 20vw"
41
+ className="img-zoom object-cover"
42
+ />
43
+ ) : null}
44
+ </span>
45
+ <span className="block space-y-1 p-4">
46
+ <span className="block text-sm font-semibold leading-snug text-foreground group-hover:text-primary">
47
+ {item.name}
48
+ </span>
49
+ <PriceDisplay price={basePrice} salePrice={isOnSale ? salePrice : undefined} size="sm" />
50
+ </span>
51
+ </Link>
52
+ );
53
+ }
54
+
55
+ interface RecommendationSectionProps {
56
+ title: string;
57
+ items: ProductRecommendation[];
58
+ className?: string;
59
+ }
60
+
61
+ /** Recommendation strip on the product page (upsells / related). */
62
+ export function RecommendationSection({ title, items, className }: RecommendationSectionProps) {
63
+ if (items.length === 0) return null;
64
+
65
+ return (
66
+ <section className={cn('border-t pt-10', className)}>
67
+ <h2 className="mb-6 text-2xl">{title}</h2>
68
+ <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
69
+ {items.slice(0, 4).map((item) => (
70
+ <RecommendationCard key={item.id} item={item} />
71
+ ))}
72
+ </div>
73
+ </section>
74
+ );
75
+ }
76
+
77
+ interface CartRecommendationSectionProps {
78
+ title: string;
79
+ items: ProductRecommendation[];
80
+ className?: string;
81
+ }
82
+
83
+ /** Cross-sell strip on the cart page ("you might also need"). */
84
+ export function CartRecommendationSection({
85
+ title,
86
+ items,
87
+ className,
88
+ }: CartRecommendationSectionProps) {
89
+ if (items.length === 0) return null;
90
+
91
+ return (
92
+ <section className={cn('border-t pt-10', className)}>
93
+ <h2 className="mb-6 text-2xl">{title}</h2>
94
+ <div className="grid grid-cols-2 gap-3 sm:gap-5 lg:grid-cols-4">
95
+ {items.slice(0, 4).map((item) => (
96
+ <RecommendationCard key={item.id} item={item} />
97
+ ))}
98
+ </div>
99
+ </section>
100
+ );
101
+ }
@@ -6,7 +6,7 @@ import { getClient } from '@/core/lib/brainerce';
6
6
  import { checkAuthStatus } from '@/core/lib/auth';
7
7
  import { useTranslations } from '@/core/lib/translations';
8
8
  import type { MyProductReview, ProductReview, ReviewPhotoUpload } from 'brainerce';
9
- import { IconStar } from '@/ui/shared/icons';
9
+ import { IconPlus, IconStar } from '@/ui/shared/icons';
10
10
 
11
11
  interface ReviewFormProps {
12
12
  productId: string;
@@ -381,16 +381,36 @@ function ReviewEditor({
381
381
  </ul>
382
382
  )}
383
383
 
384
- <input
385
- type="file"
386
- accept="image/jpeg,image/png,image/webp,image/gif"
387
- multiple
388
- disabled={uploading || roomLeft <= 0}
389
- onChange={handleFiles}
390
- className="text-muted-foreground block text-sm"
391
- />
384
+ {/* A <label> wrapping a visually hidden input, not a bare file control.
385
+ The browser's own "Choose Files" button cannot be styled and looks
386
+ nothing like the rest of the form. Two things this has to do that the
387
+ native control did for free: `disabled` on a hidden input greys
388
+ NOTHING, so the label paints its own disabled state, and the line
389
+ underneath says WHY it is disabled rather than leaving a dead button.
390
+ The input stays a real input (no ref.click()) so keyboard and
391
+ assistive tech reach it, and focus-within restores the focus ring. */}
392
+ <label
393
+ className={`btn-outline btn-sm focus-within:ring-primary/40 inline-flex cursor-pointer items-center gap-2 focus-within:ring-2 ${
394
+ uploading || roomLeft <= 0 ? 'pointer-events-none opacity-50' : ''
395
+ }`}
396
+ >
397
+ <IconPlus size={16} />
398
+ {uploading ? t('photoUploading') : t('choosePhotos')}
399
+ <input
400
+ type="file"
401
+ accept="image/jpeg,image/png,image/webp,image/gif"
402
+ multiple
403
+ disabled={uploading || roomLeft <= 0}
404
+ onChange={handleFiles}
405
+ className="sr-only"
406
+ />
407
+ </label>
392
408
 
393
- {uploading && <p className="text-muted-foreground text-xs">{t('photoUploading')}</p>}
409
+ <p className="text-muted-foreground text-xs">
410
+ {roomLeft <= 0
411
+ ? t('photoLimitReached', { max: String(photos.maxPerReview) })
412
+ : t('photoFormats', { mb: String(Math.round(photos.maxBytes / (1024 * 1024))) })}
413
+ </p>
394
414
  {/* Said BEFORE they submit: a shopper who uploads, submits, and cannot
395
415
  find their photo will conclude the site is broken. */}
396
416
  {photos.requiresApproval && (
@@ -3,9 +3,16 @@
3
3
  import type { InventoryInfo } from 'brainerce';
4
4
  import { cn } from '@/core/lib/utils';
5
5
  import { useTranslations } from '@/core/lib/translations';
6
+ import { useStoreCapabilities } from '@/core/providers/store-provider';
7
+ import { resolveLowStockThreshold } from '@/core/lib/capabilities';
6
8
 
7
9
  interface StockBadgeProps {
8
10
  inventory: InventoryInfo | null | undefined;
11
+ /**
12
+ * Override the threshold. Leave it unset — the merchant's configured value
13
+ * comes from the store's capabilities, and a hardcoded number here shows the
14
+ * wrong badge on every store that chose a different one.
15
+ */
9
16
  lowStockThreshold?: number;
10
17
  className?: string;
11
18
  }
@@ -16,9 +23,17 @@ type StockState = 'in' | 'low' | 'out';
16
23
  * Stock status pill (in stock / low stock / out of stock) with a colored
17
24
  * status dot; derives the label from the product/variant `inventory` prop.
18
25
  */
19
- export function StockBadge({ inventory, lowStockThreshold = 5, className }: StockBadgeProps) {
26
+ export function StockBadge({ inventory, lowStockThreshold, className }: StockBadgeProps) {
20
27
  const t = useTranslations('productDetail');
21
- const { label, state } = getStock(inventory, lowStockThreshold, t);
28
+ // Merchant-configured, from the channel's capabilities. Falls back to the
29
+ // platform default while the fetch is in flight or if it failed, and resolves
30
+ // to 0 (nothing is ever low) when the merchant turned low-stock warnings off.
31
+ const { capabilities } = useStoreCapabilities();
32
+ const { label, state } = getStock(
33
+ inventory,
34
+ lowStockThreshold ?? resolveLowStockThreshold(capabilities),
35
+ t
36
+ );
22
37
 
23
38
  return (
24
39
  <span
@@ -104,6 +104,15 @@ export function IconCheck(p: IconProps) {
104
104
  );
105
105
  }
106
106
 
107
+ export function IconSearch(p: IconProps) {
108
+ return (
109
+ <Base {...p}>
110
+ <circle cx="11" cy="11" r="6.5" />
111
+ <path d="m16 16 4.5 4.5" />
112
+ </Base>
113
+ );
114
+ }
115
+
107
116
  export function IconStar({ filled = true, ...p }: IconProps & { filled?: boolean }) {
108
117
  return (
109
118
  <Base {...p} fill={filled ? 'currentColor' : 'none'} strokeWidth={filled ? 0 : 1.5}>
@@ -1,137 +1,137 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
- import type { CartItem as CartItemType } from 'brainerce';
6
- import { getCartItemImage, formatPrice } from 'brainerce';
7
- import { getClient } from '@/core/lib/brainerce';
8
- import { useTranslations } from '@/core/lib/translations';
9
- import { useCurrency } from '@/core/lib/use-currency';
10
- import { LoadingSpinner } from '@/ui/shared/loading-spinner';
11
- import { cn } from '@/core/lib/utils';
12
-
13
- interface CartItemProps {
14
- item: CartItemType;
15
- onUpdate: () => void;
16
- className?: string;
17
- }
18
-
19
- /**
20
- * DESIGN ME — single cart line: image, name, variant, unit price, quantity
21
- * controls, remove, line total; the `item` prop comes from useCartPage().cart.
22
- *
23
- * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
24
- */
25
- export function CartItem({ item, onUpdate, className }: CartItemProps) {
26
- const t = useTranslations('common');
27
- const td = useTranslations('productDetail');
28
- const currency = useCurrency();
29
- const [updating, setUpdating] = useState(false);
30
- const [removing, setRemoving] = useState(false);
31
-
32
- const productName = item.product.name;
33
- const imageUrl = getCartItemImage(item);
34
- const variantName = item.variant?.name;
35
- const unitPrice = parseFloat(item.unitPrice);
36
- const lineTotal = unitPrice * item.quantity;
37
-
38
- // The server decides purchasability, per line. `isAvailable === false` means
39
- // this line blocks checkout until it is removed, whether the stock ran out
40
- // (a released reservation is the common cause) or the product/variant was
41
- // withdrawn from sale. Keep the badge when you redesign this line.
42
- const isUnavailable = item.isAvailable === false;
43
- const unavailableLabel = isUnavailable
44
- ? item.unavailableReason === 'OUT_OF_STOCK'
45
- ? td('outOfStock')
46
- : td('unavailable')
47
- : null;
48
-
49
- async function handleQuantityChange(newQuantity: number) {
50
- if (newQuantity < 1 || updating) return;
51
-
52
- try {
53
- setUpdating(true);
54
- const client = getClient();
55
- await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined);
56
- onUpdate();
57
- } catch (err) {
58
- console.error('Failed to update quantity:', err);
59
- } finally {
60
- setUpdating(false);
61
- }
62
- }
63
-
64
- async function handleRemove() {
65
- if (removing) return;
66
-
67
- try {
68
- setRemoving(true);
69
- const client = getClient();
70
- await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
71
- onUpdate();
72
- } catch (err) {
73
- console.error('Failed to remove item:', err);
74
- } finally {
75
- setRemoving(false);
76
- }
77
- }
78
-
79
- return (
80
- <div className={cn('flex items-start gap-4', className)}>
81
- {/* Image — `relative` + fixed box are layout-critical for next/image fill */}
82
- <div className="relative h-20 w-20">
83
- {imageUrl ? (
84
- <Image src={imageUrl} alt={productName} fill sizes="80px" />
85
- ) : (
86
- <span className="sr-only">{productName}</span>
87
- )}
88
- </div>
89
-
90
- {/* Details */}
91
- <div>
92
- <h3>{productName}</h3>
93
-
94
- {/* Availability badge. This line blocks checkout while it shows. */}
95
- {unavailableLabel && <span data-state="unavailable">{unavailableLabel}</span>}
96
-
97
- {/* Variant name */}
98
- {variantName && <p>{variantName}</p>}
99
-
100
- {/* Unit price */}
101
- <p>{formatPrice(unitPrice, { currency }) as string}</p>
102
-
103
- {/* Quantity controls */}
104
- <div className="flex items-center gap-3">
105
- <div className="flex items-center gap-2">
106
- <button
107
- type="button"
108
- onClick={() => handleQuantityChange(item.quantity - 1)}
109
- disabled={updating || item.quantity <= 1}
110
- aria-label={td('decreaseQuantity')}
111
- >
112
- -
113
- </button>
114
- <span aria-live="polite">
115
- {updating ? <LoadingSpinner size="sm" /> : item.quantity}
116
- </span>
117
- <button
118
- type="button"
119
- onClick={() => handleQuantityChange(item.quantity + 1)}
120
- disabled={updating || isUnavailable}
121
- aria-label={td('increaseQuantity')}
122
- >
123
- +
124
- </button>
125
- </div>
126
-
127
- <button type="button" onClick={handleRemove} disabled={removing}>
128
- {removing ? t('removing') : t('remove')}
129
- </button>
130
- </div>
131
- </div>
132
-
133
- {/* Line total */}
134
- <span>{formatPrice(lineTotal, { currency }) as string}</span>
135
- </div>
136
- );
137
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import { CdnImage as Image } from '@/ui/shared/cdn-image';
5
+ import type { CartItem as CartItemType } from 'brainerce';
6
+ import { getCartItemImage, formatPrice } from 'brainerce';
7
+ import { getClient } from '@/core/lib/brainerce';
8
+ import { useTranslations } from '@/core/lib/translations';
9
+ import { useCurrency } from '@/core/lib/use-currency';
10
+ import { LoadingSpinner } from '@/ui/shared/loading-spinner';
11
+ import { cn } from '@/core/lib/utils';
12
+
13
+ interface CartItemProps {
14
+ item: CartItemType;
15
+ onUpdate: () => void;
16
+ className?: string;
17
+ }
18
+
19
+ /**
20
+ * DESIGN ME — single cart line: image, name, variant, unit price, quantity
21
+ * controls, remove, line total; the `item` prop comes from useCartPage().cart.
22
+ *
23
+ * Building blocks: shadcn/ui primitives in src/components/ui (Button, Card, Badge, Input, Select, Accordion, Dialog, Skeleton, ...) + lucide-react icons are installed and ready to compose with.
24
+ */
25
+ export function CartItem({ item, onUpdate, className }: CartItemProps) {
26
+ const t = useTranslations('common');
27
+ const td = useTranslations('productDetail');
28
+ const currency = useCurrency();
29
+ const [updating, setUpdating] = useState(false);
30
+ const [removing, setRemoving] = useState(false);
31
+
32
+ const productName = item.product.name;
33
+ const imageUrl = getCartItemImage(item);
34
+ const variantName = item.variant?.name;
35
+ const unitPrice = parseFloat(item.unitPrice);
36
+ const lineTotal = unitPrice * item.quantity;
37
+
38
+ // The server decides purchasability, per line. `isAvailable === false` means
39
+ // this line blocks checkout until it is removed, whether the stock ran out
40
+ // (a released reservation is the common cause) or the product/variant was
41
+ // withdrawn from sale. Keep the badge when you redesign this line.
42
+ const isUnavailable = item.isAvailable === false;
43
+ const unavailableLabel = isUnavailable
44
+ ? item.unavailableReason === 'OUT_OF_STOCK'
45
+ ? td('outOfStock')
46
+ : td('unavailable')
47
+ : null;
48
+
49
+ async function handleQuantityChange(newQuantity: number) {
50
+ if (newQuantity < 1 || updating) return;
51
+
52
+ try {
53
+ setUpdating(true);
54
+ const client = getClient();
55
+ await client.smartUpdateCartItem(item.productId, newQuantity, item.variantId || undefined);
56
+ onUpdate();
57
+ } catch (err) {
58
+ console.error('Failed to update quantity:', err);
59
+ } finally {
60
+ setUpdating(false);
61
+ }
62
+ }
63
+
64
+ async function handleRemove() {
65
+ if (removing) return;
66
+
67
+ try {
68
+ setRemoving(true);
69
+ const client = getClient();
70
+ await client.smartRemoveFromCart(item.productId, item.variantId || undefined);
71
+ onUpdate();
72
+ } catch (err) {
73
+ console.error('Failed to remove item:', err);
74
+ } finally {
75
+ setRemoving(false);
76
+ }
77
+ }
78
+
79
+ return (
80
+ <div className={cn('flex items-start gap-4', className)}>
81
+ {/* Image — `relative` + fixed box are layout-critical for next/image fill */}
82
+ <div className="relative h-20 w-20">
83
+ {imageUrl ? (
84
+ <Image src={imageUrl} alt={productName} fill sizes="80px" />
85
+ ) : (
86
+ <span className="sr-only">{productName}</span>
87
+ )}
88
+ </div>
89
+
90
+ {/* Details */}
91
+ <div>
92
+ <h3>{productName}</h3>
93
+
94
+ {/* Availability badge. This line blocks checkout while it shows. */}
95
+ {unavailableLabel && <span data-state="unavailable">{unavailableLabel}</span>}
96
+
97
+ {/* Variant name */}
98
+ {variantName && <p>{variantName}</p>}
99
+
100
+ {/* Unit price */}
101
+ <p>{formatPrice(unitPrice, { currency }) as string}</p>
102
+
103
+ {/* Quantity controls */}
104
+ <div className="flex items-center gap-3">
105
+ <div className="flex items-center gap-2">
106
+ <button
107
+ type="button"
108
+ onClick={() => handleQuantityChange(item.quantity - 1)}
109
+ disabled={updating || item.quantity <= 1}
110
+ aria-label={td('decreaseQuantity')}
111
+ >
112
+ -
113
+ </button>
114
+ <span aria-live="polite">
115
+ {updating ? <LoadingSpinner size="sm" /> : item.quantity}
116
+ </span>
117
+ <button
118
+ type="button"
119
+ onClick={() => handleQuantityChange(item.quantity + 1)}
120
+ disabled={updating || isUnavailable}
121
+ aria-label={td('increaseQuantity')}
122
+ >
123
+ +
124
+ </button>
125
+ </div>
126
+
127
+ <button type="button" onClick={handleRemove} disabled={removing}>
128
+ {removing ? t('removing') : t('remove')}
129
+ </button>
130
+ </div>
131
+ </div>
132
+
133
+ {/* Line total */}
134
+ <span>{formatPrice(lineTotal, { currency }) as string}</span>
135
+ </div>
136
+ );
137
+ }