create-brainerce-store 1.34.4 → 1.34.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.
@@ -1,134 +1,145 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import type { Cart } from 'brainerce';
5
- import { getClient } from '@/lib/brainerce';
6
- import { useTranslations } from '@/lib/translations';
7
- import { LoadingSpinner } from '@/components/shared/loading-spinner';
8
- import { cn } from '@/lib/utils';
9
-
10
- interface CouponInputProps {
11
- cart: Cart;
12
- onUpdate: () => void;
13
- className?: string;
14
- }
15
-
16
- export function CouponInput({ cart, onUpdate, className }: CouponInputProps) {
17
- const t = useTranslations('coupon');
18
- const tc = useTranslations('common');
19
- const [code, setCode] = useState('');
20
- const [applying, setApplying] = useState(false);
21
- const [removing, setRemoving] = useState(false);
22
- const [error, setError] = useState<string | null>(null);
23
-
24
- const appliedCoupon = cart.couponCode || null;
25
-
26
- async function handleApply() {
27
- const trimmed = code.trim();
28
- if (!trimmed || applying) return;
29
-
30
- try {
31
- setApplying(true);
32
- setError(null);
33
- const client = getClient();
34
- await client.applyCoupon(cart.id, trimmed);
35
- setCode('');
36
- onUpdate();
37
- } catch (err) {
38
- const message = err instanceof Error ? err.message : t('invalidCode');
39
- setError(message);
40
- } finally {
41
- setApplying(false);
42
- }
43
- }
44
-
45
- async function handleRemove() {
46
- if (removing) return;
47
-
48
- try {
49
- setRemoving(true);
50
- setError(null);
51
- const client = getClient();
52
- await client.removeCoupon(cart.id);
53
- onUpdate();
54
- } catch (err) {
55
- console.error('Failed to remove coupon:', err);
56
- } finally {
57
- setRemoving(false);
58
- }
59
- }
60
-
61
- // Show applied coupon
62
- if (appliedCoupon) {
63
- return (
64
- <div className={cn('space-y-2', className)}>
65
- <div className="bg-muted flex items-center justify-between rounded px-3 py-2">
66
- <div className="flex items-center gap-2">
67
- <svg
68
- className="text-primary h-4 w-4 flex-shrink-0"
69
- fill="none"
70
- viewBox="0 0 24 24"
71
- stroke="currentColor"
72
- >
73
- <path
74
- strokeLinecap="round"
75
- strokeLinejoin="round"
76
- strokeWidth={2}
77
- d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
78
- />
79
- </svg>
80
- <span className="text-foreground text-sm font-medium">{appliedCoupon}</span>
81
- </div>
82
- <button
83
- type="button"
84
- onClick={handleRemove}
85
- disabled={removing}
86
- className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
87
- >
88
- {removing ? tc('removing') : tc('remove')}
89
- </button>
90
- </div>
91
- </div>
92
- );
93
- }
94
-
95
- return (
96
- <div className={cn('space-y-2', className)}>
97
- <div className="flex gap-2">
98
- <input
99
- type="text"
100
- value={code}
101
- onChange={(e) => {
102
- setCode(e.target.value);
103
- if (error) setError(null);
104
- }}
105
- onKeyDown={(e) => {
106
- if (e.key === 'Enter') {
107
- e.preventDefault();
108
- handleApply();
109
- }
110
- }}
111
- placeholder={t('placeholder')}
112
- className={cn(
113
- 'bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-9 flex-1 rounded border px-3 text-sm focus:outline-none focus:ring-2',
114
- error ? 'border-destructive' : 'border-border'
115
- )}
116
- />
117
- <button
118
- type="button"
119
- onClick={handleApply}
120
- disabled={applying || !code.trim()}
121
- className="border-border bg-background text-foreground hover:bg-muted h-9 rounded border px-4 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40"
122
- >
123
- {applying ? (
124
- <LoadingSpinner size="sm" className="border-muted-foreground/30 border-t-foreground" />
125
- ) : (
126
- tc('apply')
127
- )}
128
- </button>
129
- </div>
130
-
131
- {error && <p className="text-destructive text-xs">{error}</p>}
132
- </div>
133
- );
134
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { Cart } from 'brainerce';
5
+ import { getClient } from '@/lib/brainerce';
6
+ import { useTranslations } from '@/lib/translations';
7
+ import { LoadingSpinner } from '@/components/shared/loading-spinner';
8
+ import { cn } from '@/lib/utils';
9
+
10
+ interface CouponInputProps {
11
+ cart: Cart;
12
+ /** When provided, uses applyCheckoutCoupon/removeCheckoutCoupon instead of
13
+ * applyCoupon/removeCoupon. Always pass this when a checkout session exists. */
14
+ checkoutId?: string;
15
+ onUpdate: () => void;
16
+ className?: string;
17
+ }
18
+
19
+ export function CouponInput({ cart, checkoutId, onUpdate, className }: CouponInputProps) {
20
+ const t = useTranslations('coupon');
21
+ const tc = useTranslations('common');
22
+ const [code, setCode] = useState('');
23
+ const [applying, setApplying] = useState(false);
24
+ const [removing, setRemoving] = useState(false);
25
+ const [error, setError] = useState<string | null>(null);
26
+
27
+ const appliedCoupon = cart.couponCode || null;
28
+
29
+ async function handleApply() {
30
+ const trimmed = code.trim();
31
+ if (!trimmed || applying) return;
32
+
33
+ try {
34
+ setApplying(true);
35
+ setError(null);
36
+ const client = getClient();
37
+ if (checkoutId) {
38
+ await client.applyCheckoutCoupon(checkoutId, trimmed);
39
+ } else {
40
+ await client.applyCoupon(cart.id, trimmed);
41
+ }
42
+ setCode('');
43
+ onUpdate();
44
+ } catch (err) {
45
+ const message = err instanceof Error ? err.message : t('invalidCode');
46
+ setError(message);
47
+ } finally {
48
+ setApplying(false);
49
+ }
50
+ }
51
+
52
+ async function handleRemove() {
53
+ if (removing) return;
54
+
55
+ try {
56
+ setRemoving(true);
57
+ setError(null);
58
+ const client = getClient();
59
+ if (checkoutId) {
60
+ await client.removeCheckoutCoupon(checkoutId);
61
+ } else {
62
+ await client.removeCoupon(cart.id);
63
+ }
64
+ onUpdate();
65
+ } catch (err) {
66
+ console.error('Failed to remove coupon:', err);
67
+ } finally {
68
+ setRemoving(false);
69
+ }
70
+ }
71
+
72
+ // Show applied coupon
73
+ if (appliedCoupon) {
74
+ return (
75
+ <div className={cn('space-y-2', className)}>
76
+ <div className="bg-muted flex items-center justify-between rounded px-3 py-2">
77
+ <div className="flex items-center gap-2">
78
+ <svg
79
+ className="text-primary h-4 w-4 flex-shrink-0"
80
+ fill="none"
81
+ viewBox="0 0 24 24"
82
+ stroke="currentColor"
83
+ >
84
+ <path
85
+ strokeLinecap="round"
86
+ strokeLinejoin="round"
87
+ strokeWidth={2}
88
+ d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z"
89
+ />
90
+ </svg>
91
+ <span className="text-foreground text-sm font-medium">{appliedCoupon}</span>
92
+ </div>
93
+ <button
94
+ type="button"
95
+ onClick={handleRemove}
96
+ disabled={removing}
97
+ className="text-destructive hover:text-destructive/80 text-xs transition-colors disabled:opacity-40"
98
+ >
99
+ {removing ? tc('removing') : tc('remove')}
100
+ </button>
101
+ </div>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ return (
107
+ <div className={cn('space-y-2', className)}>
108
+ <div className="flex gap-2">
109
+ <input
110
+ type="text"
111
+ value={code}
112
+ onChange={(e) => {
113
+ setCode(e.target.value);
114
+ if (error) setError(null);
115
+ }}
116
+ onKeyDown={(e) => {
117
+ if (e.key === 'Enter') {
118
+ e.preventDefault();
119
+ handleApply();
120
+ }
121
+ }}
122
+ placeholder={t('placeholder')}
123
+ className={cn(
124
+ 'bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-9 flex-1 rounded border px-3 text-sm focus:outline-none focus:ring-2',
125
+ error ? 'border-destructive' : 'border-border'
126
+ )}
127
+ />
128
+ <button
129
+ type="button"
130
+ onClick={handleApply}
131
+ disabled={applying || !code.trim()}
132
+ className="border-border bg-background text-foreground hover:bg-muted h-9 rounded border px-4 text-sm font-medium transition-colors disabled:cursor-not-allowed disabled:opacity-40"
133
+ >
134
+ {applying ? (
135
+ <LoadingSpinner size="sm" className="border-muted-foreground/30 border-t-foreground" />
136
+ ) : (
137
+ tc('apply')
138
+ )}
139
+ </button>
140
+ </div>
141
+
142
+ {error && <p className="text-destructive text-xs">{error}</p>}
143
+ </div>
144
+ );
145
+ }
@@ -20,12 +20,21 @@ interface ProductCardProps {
20
20
  function VariantPriceRange({ product }: { product: Product }) {
21
21
  const { storeInfo } = useStoreInfo();
22
22
  const currency = storeInfo?.currency || 'USD';
23
- const variants = product.variants ?? [];
24
- if (variants.length === 0) return null;
25
23
 
26
- const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
27
- const min = Math.min(...prices);
28
- const max = Math.max(...prices);
24
+ let min: number;
25
+ let max: number;
26
+ if (product.priceMin && product.priceMax) {
27
+ min = parseFloat(product.priceMin);
28
+ max = parseFloat(product.priceMax);
29
+ } else {
30
+ const variants = product.variants ?? [];
31
+ if (variants.length === 0) return null;
32
+ const prices = variants.map((v) => getVariantPrice(v, product.basePrice));
33
+ min = Math.min(...prices);
34
+ max = Math.max(...prices);
35
+ }
36
+
37
+ if (isNaN(min) || isNaN(max)) return null;
29
38
 
30
39
  return (
31
40
  <span className="text-foreground text-sm font-medium">
@@ -14,6 +14,31 @@ export async function ProductJsonLd({ product, url, currency = 'USD' }: ProductJ
14
14
  const imageUrl = product.images?.[0]?.url;
15
15
  const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || '';
16
16
 
17
+ const availability =
18
+ product.inventory?.canPurchase !== false
19
+ ? 'https://schema.org/InStock'
20
+ : 'https://schema.org/OutOfStock';
21
+
22
+ const isVariable = product.type === 'VARIABLE';
23
+ const offers =
24
+ isVariable && product.priceMin
25
+ ? {
26
+ '@type': 'AggregateOffer',
27
+ lowPrice: product.priceMin,
28
+ highPrice: product.priceMax ?? product.priceMin,
29
+ offerCount: product.variants?.length ?? 1,
30
+ priceCurrency: currency,
31
+ availability,
32
+ url,
33
+ }
34
+ : {
35
+ '@type': 'Offer',
36
+ price: priceInfo.price,
37
+ priceCurrency: currency,
38
+ availability,
39
+ url,
40
+ };
41
+
17
42
  const productJsonLd = {
18
43
  '@context': 'https://schema.org',
19
44
  '@type': 'Product',
@@ -22,16 +47,7 @@ export async function ProductJsonLd({ product, url, currency = 'USD' }: ProductJ
22
47
  image: imageUrl,
23
48
  url,
24
49
  sku: product.sku || product.id,
25
- offers: {
26
- '@type': 'Offer',
27
- price: priceInfo.price,
28
- priceCurrency: currency,
29
- availability:
30
- product.inventory?.canPurchase !== false
31
- ? 'https://schema.org/InStock'
32
- : 'https://schema.org/OutOfStock',
33
- url,
34
- },
50
+ offers,
35
51
  };
36
52
 
37
53
  const breadcrumbJsonLd = {