create-brainerce-store 1.4.1 → 1.5.1

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 (37) hide show
  1. package/dist/index.js +1 -1
  2. package/messages/en.json +9 -1
  3. package/messages/he.json +9 -1
  4. package/package.json +1 -1
  5. package/templates/nextjs/base/src/app/account/page.tsx +8 -4
  6. package/templates/nextjs/base/src/app/auth/callback/page.tsx +90 -90
  7. package/templates/nextjs/base/src/app/cart/page.tsx +110 -110
  8. package/templates/nextjs/base/src/app/checkout/page.tsx +614 -614
  9. package/templates/nextjs/base/src/app/login/page.tsx +58 -58
  10. package/templates/nextjs/base/src/app/order-confirmation/page.tsx +193 -193
  11. package/templates/nextjs/base/src/app/page.tsx +98 -98
  12. package/templates/nextjs/base/src/app/products/[slug]/page.tsx +435 -435
  13. package/templates/nextjs/base/src/app/products/page.tsx +246 -246
  14. package/templates/nextjs/base/src/app/register/page.tsx +68 -68
  15. package/templates/nextjs/base/src/app/verify-email/page.tsx +293 -293
  16. package/templates/nextjs/base/src/components/account/order-history.tsx +198 -198
  17. package/templates/nextjs/base/src/components/account/profile-section.tsx +189 -40
  18. package/templates/nextjs/base/src/components/auth/login-form.tsx +94 -94
  19. package/templates/nextjs/base/src/components/auth/oauth-buttons.tsx +137 -137
  20. package/templates/nextjs/base/src/components/auth/register-form.tsx +184 -184
  21. package/templates/nextjs/base/src/components/cart/cart-item.tsx +153 -153
  22. package/templates/nextjs/base/src/components/cart/cart-summary.tsx +70 -70
  23. package/templates/nextjs/base/src/components/cart/coupon-input.tsx +134 -134
  24. package/templates/nextjs/base/src/components/cart/reservation-countdown.tsx +103 -103
  25. package/templates/nextjs/base/src/components/checkout/checkout-form.tsx +305 -305
  26. package/templates/nextjs/base/src/components/checkout/delivery-method-step.tsx +64 -64
  27. package/templates/nextjs/base/src/components/checkout/payment-step.tsx +350 -344
  28. package/templates/nextjs/base/src/components/checkout/pickup-step.tsx +199 -199
  29. package/templates/nextjs/base/src/components/checkout/shipping-step.tsx +110 -110
  30. package/templates/nextjs/base/src/components/checkout/tax-display.tsx +65 -65
  31. package/templates/nextjs/base/src/components/layout/footer.tsx +38 -38
  32. package/templates/nextjs/base/src/components/layout/header.tsx +332 -332
  33. package/templates/nextjs/base/src/components/products/product-card.tsx +96 -96
  34. package/templates/nextjs/base/src/components/products/product-grid.tsx +35 -35
  35. package/templates/nextjs/base/src/components/shared/loading-spinner.tsx +32 -32
  36. package/templates/nextjs/base/src/lib/translations.ts +11 -11
  37. package/templates/nextjs/base/src/providers/store-provider.tsx.ejs +5 -1
@@ -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 || '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 || '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
+ }