create-brainerce-store 1.41.0 → 1.42.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 (30) hide show
  1. package/dist/index.js +41 -12
  2. package/messages/en.json +441 -441
  3. package/messages/he.json +441 -441
  4. package/package.json +2 -2
  5. package/templates/nextjs/base/TRANSLATIONS.md +200 -0
  6. package/templates/nextjs/base/next.config.ts +22 -0
  7. package/templates/nextjs/base/package.json.ejs +3 -0
  8. package/templates/nextjs/base/src/app/checkout/page.tsx +1 -1
  9. package/templates/nextjs/base/src/app/layout.tsx.ejs +14 -6
  10. package/templates/nextjs/base/src/app/pages/[slug]/page.tsx.ejs +9 -4
  11. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +45 -5
  12. package/templates/nextjs/base/src/components/account/order-history.tsx +367 -367
  13. package/templates/nextjs/base/src/components/cart/cart-bundle-offer.tsx +112 -112
  14. package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
  15. package/templates/nextjs/base/src/components/cart/cart-summary.tsx +108 -108
  16. package/templates/nextjs/base/src/components/cart/cart-upgrade-banner.tsx +142 -142
  17. package/templates/nextjs/base/src/components/cart/free-shipping-bar.tsx +59 -59
  18. package/templates/nextjs/base/src/components/checkout/order-bump-card.tsx +243 -243
  19. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +199 -199
  20. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +110 -110
  21. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +65 -65
  22. package/templates/nextjs/base/src/components/products/frequently-bought-together.tsx +202 -202
  23. package/templates/nextjs/base/src/components/products/product-card.tsx +226 -226
  24. package/templates/nextjs/base/src/components/products/variant-selector.tsx +292 -292
  25. package/templates/nextjs/base/src/components/seo/product-json-ld.tsx +5 -1
  26. package/templates/nextjs/base/src/components/shared/price-display.tsx +65 -62
  27. package/templates/nextjs/base/src/lib/brainerce.ts.ejs +42 -0
  28. package/templates/nextjs/base/src/lib/store-info.ts +48 -0
  29. package/templates/nextjs/base/src/lib/utils.ts +21 -6
  30. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +37 -14
@@ -1,199 +1,199 @@
1
- 'use client';
2
-
3
- import { useState } from 'react';
4
- import type { PickupLocation } from 'brainerce';
5
- import { formatPrice } from 'brainerce';
6
- import { useTranslations } from '@/lib/translations';
7
- import { useStoreInfo } from '@/providers/store-provider';
8
- import { cn } from '@/lib/utils';
9
-
10
- interface PickupStepProps {
11
- locations: PickupLocation[];
12
- onSelect: (
13
- locationId: string,
14
- customerInfo: { email: string; firstName?: string; lastName?: string; phone?: string }
15
- ) => void;
16
- loading?: boolean;
17
- initialEmail?: string;
18
- className?: string;
19
- }
20
-
21
- export function PickupStep({
22
- locations,
23
- onSelect,
24
- loading = false,
25
- initialEmail = '',
26
- className,
27
- }: PickupStepProps) {
28
- const t = useTranslations('checkout');
29
- const tf = useTranslations('checkoutForm');
30
- const tc = useTranslations('common');
31
- const { storeInfo } = useStoreInfo();
32
- const currency = storeInfo?.currency || 'USD';
33
-
34
- const [selectedId, setSelectedId] = useState<string | null>(null);
35
- const [email, setEmail] = useState(initialEmail);
36
- const [firstName, setFirstName] = useState('');
37
- const [lastName, setLastName] = useState('');
38
- const [phone, setPhone] = useState('');
39
- const [error, setError] = useState<string | null>(null);
40
-
41
- const handleSubmit = (e: React.FormEvent) => {
42
- e.preventDefault();
43
-
44
- if (!selectedId) {
45
- setError(t('pickupLocationRequired'));
46
- return;
47
- }
48
- if (!email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
49
- setError(tf('emailInvalid'));
50
- return;
51
- }
52
-
53
- setError(null);
54
- onSelect(selectedId, {
55
- email: email.trim(),
56
- firstName: firstName.trim() || undefined,
57
- lastName: lastName.trim() || undefined,
58
- phone: phone.trim() || undefined,
59
- });
60
- };
61
-
62
- const inputClass =
63
- 'bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2';
64
-
65
- return (
66
- <form onSubmit={handleSubmit} className={cn('space-y-6', className)}>
67
- {/* Pickup locations */}
68
- <div className="space-y-3">
69
- <p className="text-foreground text-sm font-medium">{t('selectPickupLocation')}</p>
70
- {locations.map((loc) => {
71
- const price = parseFloat(loc.price);
72
- const isFree = price === 0;
73
- const isSelected = selectedId === loc.id;
74
-
75
- return (
76
- <button
77
- key={loc.id}
78
- type="button"
79
- onClick={() => {
80
- setSelectedId(loc.id);
81
- setError(null);
82
- }}
83
- className={cn(
84
- 'flex w-full items-start gap-4 rounded border px-4 py-3 text-start transition-colors',
85
- isSelected
86
- ? 'border-primary bg-primary/5'
87
- : 'border-border hover:border-muted-foreground'
88
- )}
89
- >
90
- {/* Radio indicator */}
91
- <div
92
- className={cn(
93
- 'mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2',
94
- isSelected ? 'border-primary' : 'border-muted-foreground/40'
95
- )}
96
- >
97
- {isSelected && <div className="bg-primary h-2 w-2 rounded-full" />}
98
- </div>
99
-
100
- {/* Location info */}
101
- <div className="min-w-0 flex-1">
102
- <p className="text-foreground text-sm font-medium">{loc.name}</p>
103
- <p className="text-muted-foreground mt-0.5 text-xs">
104
- {loc.address.line1}
105
- {loc.address.city && `, ${loc.address.city}`}
106
- </p>
107
- {loc.hours && <p className="text-muted-foreground mt-0.5 text-xs">{loc.hours}</p>}
108
- {loc.instructions && (
109
- <p className="text-muted-foreground mt-1 text-xs italic">{loc.instructions}</p>
110
- )}
111
- </div>
112
-
113
- {/* Price */}
114
- <span
115
- className={cn(
116
- 'flex-shrink-0 text-sm font-medium',
117
- isFree ? 'text-primary' : 'text-foreground'
118
- )}
119
- >
120
- {isFree ? tc('free') : (formatPrice(price, { currency }) as string)}
121
- </span>
122
- </button>
123
- );
124
- })}
125
- </div>
126
-
127
- {/* Customer info */}
128
- <div className="space-y-4">
129
- <p className="text-foreground text-sm font-medium">{t('yourDetails')}</p>
130
-
131
- <div>
132
- <label htmlFor="pickup-email" className="text-foreground mb-1 block text-sm">
133
- {tf('email')} <span className="text-destructive">*</span>
134
- </label>
135
- <input
136
- id="pickup-email"
137
- type="email"
138
- value={email}
139
- onChange={(e) => setEmail(e.target.value)}
140
- className={cn(inputClass, 'border-border')}
141
- placeholder="your@email.com"
142
- required
143
- />
144
- </div>
145
-
146
- <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
147
- <div>
148
- <label htmlFor="pickup-firstName" className="text-foreground mb-1 block text-sm">
149
- {tf('firstName')}
150
- </label>
151
- <input
152
- id="pickup-firstName"
153
- type="text"
154
- value={firstName}
155
- onChange={(e) => setFirstName(e.target.value)}
156
- className={cn(inputClass, 'border-border')}
157
- />
158
- </div>
159
- <div>
160
- <label htmlFor="pickup-lastName" className="text-foreground mb-1 block text-sm">
161
- {tf('lastName')}
162
- </label>
163
- <input
164
- id="pickup-lastName"
165
- type="text"
166
- value={lastName}
167
- onChange={(e) => setLastName(e.target.value)}
168
- className={cn(inputClass, 'border-border')}
169
- />
170
- </div>
171
- </div>
172
-
173
- <div>
174
- <label htmlFor="pickup-phone" className="text-foreground mb-1 block text-sm">
175
- {tf('phone')}
176
- </label>
177
- <input
178
- id="pickup-phone"
179
- type="tel"
180
- value={phone}
181
- onChange={(e) => setPhone(e.target.value)}
182
- className={cn(inputClass, 'border-border')}
183
- placeholder={tf('phonePlaceholder')}
184
- />
185
- </div>
186
- </div>
187
-
188
- {error && <p className="text-destructive text-sm">{error}</p>}
189
-
190
- <button
191
- type="submit"
192
- disabled={loading || !selectedId}
193
- className="bg-primary text-primary-foreground w-full rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
194
- >
195
- {loading ? tc('saving') : t('continueToPayment')}
196
- </button>
197
- </form>
198
- );
199
- }
1
+ 'use client';
2
+
3
+ import { useState } from 'react';
4
+ import type { PickupLocation } from 'brainerce';
5
+ import { formatPrice } from 'brainerce';
6
+ import { useTranslations } from '@/lib/translations';
7
+ import { useStoreInfo } from '@/providers/store-provider';
8
+ import { cn } from '@/lib/utils';
9
+
10
+ interface PickupStepProps {
11
+ locations: PickupLocation[];
12
+ onSelect: (
13
+ locationId: string,
14
+ customerInfo: { email: string; firstName?: string; lastName?: string; phone?: string }
15
+ ) => void;
16
+ loading?: boolean;
17
+ initialEmail?: string;
18
+ className?: string;
19
+ }
20
+
21
+ export function PickupStep({
22
+ locations,
23
+ onSelect,
24
+ loading = false,
25
+ initialEmail = '',
26
+ className,
27
+ }: PickupStepProps) {
28
+ const t = useTranslations('checkout');
29
+ const tf = useTranslations('checkoutForm');
30
+ const tc = useTranslations('common');
31
+ const { storeInfo } = useStoreInfo();
32
+ const currency = storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
33
+
34
+ const [selectedId, setSelectedId] = useState<string | null>(null);
35
+ const [email, setEmail] = useState(initialEmail);
36
+ const [firstName, setFirstName] = useState('');
37
+ const [lastName, setLastName] = useState('');
38
+ const [phone, setPhone] = useState('');
39
+ const [error, setError] = useState<string | null>(null);
40
+
41
+ const handleSubmit = (e: React.FormEvent) => {
42
+ e.preventDefault();
43
+
44
+ if (!selectedId) {
45
+ setError(t('pickupLocationRequired'));
46
+ return;
47
+ }
48
+ if (!email.trim() || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
49
+ setError(tf('emailInvalid'));
50
+ return;
51
+ }
52
+
53
+ setError(null);
54
+ onSelect(selectedId, {
55
+ email: email.trim(),
56
+ firstName: firstName.trim() || undefined,
57
+ lastName: lastName.trim() || undefined,
58
+ phone: phone.trim() || undefined,
59
+ });
60
+ };
61
+
62
+ const inputClass =
63
+ 'bg-background text-foreground placeholder:text-muted-foreground focus:ring-primary/20 focus:border-primary h-10 w-full rounded border px-3 text-sm focus:outline-none focus:ring-2';
64
+
65
+ return (
66
+ <form onSubmit={handleSubmit} className={cn('space-y-6', className)}>
67
+ {/* Pickup locations */}
68
+ <div className="space-y-3">
69
+ <p className="text-foreground text-sm font-medium">{t('selectPickupLocation')}</p>
70
+ {locations.map((loc) => {
71
+ const price = parseFloat(loc.price);
72
+ const isFree = price === 0;
73
+ const isSelected = selectedId === loc.id;
74
+
75
+ return (
76
+ <button
77
+ key={loc.id}
78
+ type="button"
79
+ onClick={() => {
80
+ setSelectedId(loc.id);
81
+ setError(null);
82
+ }}
83
+ className={cn(
84
+ 'flex w-full items-start gap-4 rounded border px-4 py-3 text-start transition-colors',
85
+ isSelected
86
+ ? 'border-primary bg-primary/5'
87
+ : 'border-border hover:border-muted-foreground'
88
+ )}
89
+ >
90
+ {/* Radio indicator */}
91
+ <div
92
+ className={cn(
93
+ 'mt-0.5 flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2',
94
+ isSelected ? 'border-primary' : 'border-muted-foreground/40'
95
+ )}
96
+ >
97
+ {isSelected && <div className="bg-primary h-2 w-2 rounded-full" />}
98
+ </div>
99
+
100
+ {/* Location info */}
101
+ <div className="min-w-0 flex-1">
102
+ <p className="text-foreground text-sm font-medium">{loc.name}</p>
103
+ <p className="text-muted-foreground mt-0.5 text-xs">
104
+ {loc.address.line1}
105
+ {loc.address.city && `, ${loc.address.city}`}
106
+ </p>
107
+ {loc.hours && <p className="text-muted-foreground mt-0.5 text-xs">{loc.hours}</p>}
108
+ {loc.instructions && (
109
+ <p className="text-muted-foreground mt-1 text-xs italic">{loc.instructions}</p>
110
+ )}
111
+ </div>
112
+
113
+ {/* Price */}
114
+ <span
115
+ className={cn(
116
+ 'flex-shrink-0 text-sm font-medium',
117
+ isFree ? 'text-primary' : 'text-foreground'
118
+ )}
119
+ >
120
+ {isFree ? tc('free') : (formatPrice(price, { currency }) as string)}
121
+ </span>
122
+ </button>
123
+ );
124
+ })}
125
+ </div>
126
+
127
+ {/* Customer info */}
128
+ <div className="space-y-4">
129
+ <p className="text-foreground text-sm font-medium">{t('yourDetails')}</p>
130
+
131
+ <div>
132
+ <label htmlFor="pickup-email" className="text-foreground mb-1 block text-sm">
133
+ {tf('email')} <span className="text-destructive">*</span>
134
+ </label>
135
+ <input
136
+ id="pickup-email"
137
+ type="email"
138
+ value={email}
139
+ onChange={(e) => setEmail(e.target.value)}
140
+ className={cn(inputClass, 'border-border')}
141
+ placeholder="your@email.com"
142
+ required
143
+ />
144
+ </div>
145
+
146
+ <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
147
+ <div>
148
+ <label htmlFor="pickup-firstName" className="text-foreground mb-1 block text-sm">
149
+ {tf('firstName')}
150
+ </label>
151
+ <input
152
+ id="pickup-firstName"
153
+ type="text"
154
+ value={firstName}
155
+ onChange={(e) => setFirstName(e.target.value)}
156
+ className={cn(inputClass, 'border-border')}
157
+ />
158
+ </div>
159
+ <div>
160
+ <label htmlFor="pickup-lastName" className="text-foreground mb-1 block text-sm">
161
+ {tf('lastName')}
162
+ </label>
163
+ <input
164
+ id="pickup-lastName"
165
+ type="text"
166
+ value={lastName}
167
+ onChange={(e) => setLastName(e.target.value)}
168
+ className={cn(inputClass, 'border-border')}
169
+ />
170
+ </div>
171
+ </div>
172
+
173
+ <div>
174
+ <label htmlFor="pickup-phone" className="text-foreground mb-1 block text-sm">
175
+ {tf('phone')}
176
+ </label>
177
+ <input
178
+ id="pickup-phone"
179
+ type="tel"
180
+ value={phone}
181
+ onChange={(e) => setPhone(e.target.value)}
182
+ className={cn(inputClass, 'border-border')}
183
+ placeholder={tf('phonePlaceholder')}
184
+ />
185
+ </div>
186
+ </div>
187
+
188
+ {error && <p className="text-destructive text-sm">{error}</p>}
189
+
190
+ <button
191
+ type="submit"
192
+ disabled={loading || !selectedId}
193
+ className="bg-primary text-primary-foreground w-full rounded px-6 py-3 text-sm font-medium transition-opacity hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-60"
194
+ >
195
+ {loading ? tc('saving') : t('continueToPayment')}
196
+ </button>
197
+ </form>
198
+ );
199
+ }
@@ -1,110 +1,110 @@
1
- 'use client';
2
-
3
- import type { ShippingRate } from 'brainerce';
4
- import { formatPrice } from 'brainerce';
5
- import { useTranslations } from '@/lib/translations';
6
- import { useStoreInfo } from '@/providers/store-provider';
7
- import { cn } from '@/lib/utils';
8
-
9
- interface ShippingStepProps {
10
- rates: ShippingRate[];
11
- selectedRateId: string | null;
12
- onSelect: (rateId: string) => void;
13
- loading?: boolean;
14
- className?: string;
15
- }
16
-
17
- export function ShippingStep({
18
- rates,
19
- selectedRateId,
20
- onSelect,
21
- loading = false,
22
- className,
23
- }: ShippingStepProps) {
24
- const t = useTranslations('checkout');
25
- const tc = useTranslations('common');
26
- const { storeInfo } = useStoreInfo();
27
- const currency = storeInfo?.currency || 'USD';
28
-
29
- if (rates.length === 0) {
30
- return (
31
- <div className={cn('py-8 text-center', className)}>
32
- <svg
33
- className="text-muted-foreground mx-auto mb-3 h-10 w-10"
34
- fill="none"
35
- viewBox="0 0 24 24"
36
- stroke="currentColor"
37
- >
38
- <path
39
- strokeLinecap="round"
40
- strokeLinejoin="round"
41
- strokeWidth={1.5}
42
- d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
43
- />
44
- </svg>
45
- <p className="text-muted-foreground text-sm">{t('noShippingOptions')}</p>
46
- <p className="text-muted-foreground mt-1 text-xs">{t('noShippingOptionsHint')}</p>
47
- </div>
48
- );
49
- }
50
-
51
- return (
52
- <div className={cn('space-y-3', className)}>
53
- {rates.map((rate) => {
54
- const price = parseFloat(rate.price);
55
- const isFree = price === 0;
56
- const isSelected = selectedRateId === rate.id;
57
-
58
- return (
59
- <button
60
- key={rate.id}
61
- type="button"
62
- onClick={() => onSelect(rate.id)}
63
- disabled={loading}
64
- className={cn(
65
- 'flex w-full items-center gap-4 rounded border px-4 py-3 text-start transition-colors',
66
- isSelected
67
- ? 'border-primary bg-primary/5'
68
- : 'border-border hover:border-muted-foreground',
69
- loading && 'cursor-not-allowed opacity-60'
70
- )}
71
- >
72
- {/* Radio indicator */}
73
- <div
74
- className={cn(
75
- 'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2',
76
- isSelected ? 'border-primary' : 'border-muted-foreground/40'
77
- )}
78
- >
79
- {isSelected && <div className="bg-primary h-2 w-2 rounded-full" />}
80
- </div>
81
-
82
- {/* Rate info */}
83
- <div className="min-w-0 flex-1">
84
- <p className="text-foreground text-sm font-medium">{rate.name}</p>
85
- {rate.description && (
86
- <p className="text-muted-foreground mt-0.5 text-xs">{rate.description}</p>
87
- )}
88
- {rate.estimatedDays != null && (
89
- <p className="text-muted-foreground mt-0.5 text-xs">
90
- {t('estimatedDelivery')} {rate.estimatedDays}{' '}
91
- {rate.estimatedDays === 1 ? tc('day') : tc('days')}
92
- </p>
93
- )}
94
- </div>
95
-
96
- {/* Price */}
97
- <span
98
- className={cn(
99
- 'flex-shrink-0 text-sm font-medium',
100
- isFree ? 'text-primary' : 'text-foreground'
101
- )}
102
- >
103
- {isFree ? tc('free') : (formatPrice(price, { currency }) as string)}
104
- </span>
105
- </button>
106
- );
107
- })}
108
- </div>
109
- );
110
- }
1
+ 'use client';
2
+
3
+ import type { ShippingRate } from 'brainerce';
4
+ import { formatPrice } from 'brainerce';
5
+ import { useTranslations } from '@/lib/translations';
6
+ import { useStoreInfo } from '@/providers/store-provider';
7
+ import { cn } from '@/lib/utils';
8
+
9
+ interface ShippingStepProps {
10
+ rates: ShippingRate[];
11
+ selectedRateId: string | null;
12
+ onSelect: (rateId: string) => void;
13
+ loading?: boolean;
14
+ className?: string;
15
+ }
16
+
17
+ export function ShippingStep({
18
+ rates,
19
+ selectedRateId,
20
+ onSelect,
21
+ loading = false,
22
+ className,
23
+ }: ShippingStepProps) {
24
+ const t = useTranslations('checkout');
25
+ const tc = useTranslations('common');
26
+ const { storeInfo } = useStoreInfo();
27
+ const currency = storeInfo?.currency || process.env.NEXT_PUBLIC_STORE_CURRENCY || 'USD';
28
+
29
+ if (rates.length === 0) {
30
+ return (
31
+ <div className={cn('py-8 text-center', className)}>
32
+ <svg
33
+ className="text-muted-foreground mx-auto mb-3 h-10 w-10"
34
+ fill="none"
35
+ viewBox="0 0 24 24"
36
+ stroke="currentColor"
37
+ >
38
+ <path
39
+ strokeLinecap="round"
40
+ strokeLinejoin="round"
41
+ strokeWidth={1.5}
42
+ d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
43
+ />
44
+ </svg>
45
+ <p className="text-muted-foreground text-sm">{t('noShippingOptions')}</p>
46
+ <p className="text-muted-foreground mt-1 text-xs">{t('noShippingOptionsHint')}</p>
47
+ </div>
48
+ );
49
+ }
50
+
51
+ return (
52
+ <div className={cn('space-y-3', className)}>
53
+ {rates.map((rate) => {
54
+ const price = parseFloat(rate.price);
55
+ const isFree = price === 0;
56
+ const isSelected = selectedRateId === rate.id;
57
+
58
+ return (
59
+ <button
60
+ key={rate.id}
61
+ type="button"
62
+ onClick={() => onSelect(rate.id)}
63
+ disabled={loading}
64
+ className={cn(
65
+ 'flex w-full items-center gap-4 rounded border px-4 py-3 text-start transition-colors',
66
+ isSelected
67
+ ? 'border-primary bg-primary/5'
68
+ : 'border-border hover:border-muted-foreground',
69
+ loading && 'cursor-not-allowed opacity-60'
70
+ )}
71
+ >
72
+ {/* Radio indicator */}
73
+ <div
74
+ className={cn(
75
+ 'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-full border-2',
76
+ isSelected ? 'border-primary' : 'border-muted-foreground/40'
77
+ )}
78
+ >
79
+ {isSelected && <div className="bg-primary h-2 w-2 rounded-full" />}
80
+ </div>
81
+
82
+ {/* Rate info */}
83
+ <div className="min-w-0 flex-1">
84
+ <p className="text-foreground text-sm font-medium">{rate.name}</p>
85
+ {rate.description && (
86
+ <p className="text-muted-foreground mt-0.5 text-xs">{rate.description}</p>
87
+ )}
88
+ {rate.estimatedDays != null && (
89
+ <p className="text-muted-foreground mt-0.5 text-xs">
90
+ {t('estimatedDelivery')} {rate.estimatedDays}{' '}
91
+ {rate.estimatedDays === 1 ? tc('day') : tc('days')}
92
+ </p>
93
+ )}
94
+ </div>
95
+
96
+ {/* Price */}
97
+ <span
98
+ className={cn(
99
+ 'flex-shrink-0 text-sm font-medium',
100
+ isFree ? 'text-primary' : 'text-foreground'
101
+ )}
102
+ >
103
+ {isFree ? tc('free') : (formatPrice(price, { currency }) as string)}
104
+ </span>
105
+ </button>
106
+ );
107
+ })}
108
+ </div>
109
+ );
110
+ }