@forgecart/cli 2.202608110054.0 → 2.202608160103.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.
- package/dist/src/commands/init.d.ts +16 -0
- package/dist/src/commands/init.js +18 -7
- package/dist/src/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/README.md +145 -61
- package/templates/storefront/next.config.js +20 -0
- package/templates/storefront/package.json +10 -2
- package/templates/storefront/postcss.config.js +1 -2
- package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +23 -11
- package/templates/storefront/src/app/__forge_beacon/route.ts +1 -2
- package/templates/storefront/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront/src/app/cart/page.tsx +33 -6
- package/templates/storefront/src/app/checkout/page.tsx +40 -0
- package/templates/storefront/src/app/error.tsx +21 -0
- package/templates/storefront/src/app/global-error.tsx +23 -0
- package/templates/storefront/src/app/globals.css +105 -8
- package/templates/storefront/src/app/layout.tsx +45 -17
- package/templates/storefront/src/app/page.tsx +153 -43
- package/templates/storefront/src/app/ping/route.ts +1 -2
- package/templates/storefront/src/app/products/[slug]/not-found.tsx +3 -8
- package/templates/storefront/src/app/products/[slug]/page.tsx +69 -23
- package/templates/storefront/src/app/products/page.tsx +52 -9
- package/templates/storefront/src/components/CartView.tsx +244 -117
- package/templates/storefront/src/components/ForgeTracker.tsx +40 -26
- package/templates/storefront/src/components/Header.tsx +8 -10
- package/templates/storefront/src/components/ProductCard.tsx +25 -13
- package/templates/storefront/src/components/ProductPurchase.tsx +13 -18
- package/templates/storefront/src/components/checkout/AddressStep.tsx +288 -0
- package/templates/storefront/src/components/checkout/CheckoutFlow.tsx +543 -0
- package/templates/storefront/src/components/checkout/CheckoutGate.tsx +45 -0
- package/templates/storefront/src/components/checkout/PaymentElementForm.tsx +138 -0
- package/templates/storefront/src/components/checkout/PaymentFormEmbed.tsx +89 -0
- package/templates/storefront/src/components/checkout/RatesStep.tsx +113 -0
- package/templates/storefront/src/instrumentation.ts +34 -0
- package/templates/storefront/src/lib/action-result.ts +30 -0
- package/templates/storefront/src/lib/backend-actions.ts +20 -0
- package/templates/storefront/src/lib/backend-client.ts +47 -0
- package/templates/storefront/src/lib/cart-context.tsx +157 -22
- package/templates/storefront/src/lib/checkout-session.ts +185 -0
- package/templates/storefront/src/lib/error-messages.ts +24 -0
- package/templates/storefront/src/lib/experiments.ts +42 -44
- package/templates/storefront/src/lib/forgecart.ts +61 -78
- package/templates/storefront/src/lib/format.ts +91 -0
- package/templates/storefront/src/lib/session-actions.ts +54 -0
- package/templates/storefront/src/lib/shop-config.ts +44 -0
- package/templates/storefront/src/lib/shop-session.ts +114 -0
- package/templates/storefront/src/lib/uuid.ts +19 -0
- package/templates/storefront/src/server/app.module.ts +18 -0
- package/templates/storefront/src/server/backend-api.ts +26 -0
- package/templates/storefront/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront/src/server/bootstrap.ts +122 -0
- package/templates/storefront/src/server/customer-extras/customer-extras.module.ts +13 -0
- package/templates/storefront/src/server/customer-extras/service/customer-extras.service.ts +58 -0
- package/templates/storefront/src/server/customer-extras/type/customer-extras.types.ts +11 -0
- package/templates/storefront/src/server/forgecart/forgecart-client.factory.ts +69 -0
- package/templates/storefront/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront/src/server/runner.ts +91 -0
- package/templates/storefront/src/server/types.ts +36 -0
- package/templates/storefront/tsconfig.json +2 -0
- package/templates/storefront/.env.example +0 -12
- package/templates/storefront/src/lib/cart-actions.ts +0 -139
- package/templates/storefront/tailwind.config.js +0 -8
|
@@ -1,38 +1,50 @@
|
|
|
1
1
|
import Link from 'next/link';
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import type { Product } from '../lib/forgecart';
|
|
4
|
+
import { formatPrice, getStartingPrice } from '../lib/format';
|
|
4
5
|
|
|
5
|
-
/**
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* A product tile linking to its detail page — canonical daisyUI card
|
|
8
|
+
* (figure + card-body + card-title). Used by the home and grid pages;
|
|
9
|
+
* `emphasis` renders the home grid's large lead tile with a bigger title.
|
|
10
|
+
*/
|
|
11
|
+
export function ProductCard({
|
|
12
|
+
product,
|
|
13
|
+
emphasis = false,
|
|
14
|
+
}: {
|
|
15
|
+
product: Product;
|
|
16
|
+
emphasis?: boolean;
|
|
17
|
+
}) {
|
|
7
18
|
const startingPrice = getStartingPrice(product);
|
|
8
19
|
|
|
9
20
|
return (
|
|
10
21
|
<Link
|
|
11
22
|
href={`/products/${product.slug}`}
|
|
12
23
|
data-fc-track="product-card"
|
|
13
|
-
className="
|
|
24
|
+
className="card card-border group h-full bg-base-100 transition-shadow hover:shadow-md"
|
|
14
25
|
>
|
|
15
|
-
<
|
|
26
|
+
<figure className="aspect-square bg-base-200">
|
|
16
27
|
{product.featuredAsset ? (
|
|
17
28
|
// Plain <img> keeps the template dependency-free of next/image config.
|
|
18
29
|
// eslint-disable-next-line @next/next/no-img-element
|
|
19
30
|
<img
|
|
20
31
|
src={product.featuredAsset.preview}
|
|
21
32
|
alt={product.name}
|
|
22
|
-
|
|
33
|
+
loading="lazy"
|
|
34
|
+
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
23
35
|
/>
|
|
24
36
|
) : (
|
|
25
|
-
<div className="flex h-full w-full items-center justify-center text-sm text-
|
|
37
|
+
<div className="flex h-full w-full items-center justify-center text-sm text-base-content/40">
|
|
26
38
|
No image
|
|
27
39
|
</div>
|
|
28
40
|
)}
|
|
29
|
-
</
|
|
30
|
-
<div className="
|
|
31
|
-
<h3 className=
|
|
41
|
+
</figure>
|
|
42
|
+
<div className="card-body gap-1 p-4">
|
|
43
|
+
<h3 className={`card-title font-medium ${emphasis ? 'text-lg' : 'text-base'}`}>
|
|
44
|
+
{product.name}
|
|
45
|
+
</h3>
|
|
32
46
|
{startingPrice !== null && (
|
|
33
|
-
<p className="text-sm text-
|
|
34
|
-
From {formatPrice(startingPrice)}
|
|
35
|
-
</p>
|
|
47
|
+
<p className="text-sm text-base-content/60">From {formatPrice(startingPrice)}</p>
|
|
36
48
|
)}
|
|
37
49
|
</div>
|
|
38
50
|
</Link>
|
|
@@ -3,16 +3,13 @@
|
|
|
3
3
|
import { useEffect, useMemo, useState } from 'react';
|
|
4
4
|
|
|
5
5
|
import { useCart } from '../lib/cart-context';
|
|
6
|
+
import type { Product, ProductVariant, SellingPlan, SellingPlanGroup } from '../lib/forgecart';
|
|
6
7
|
import {
|
|
7
8
|
formatPrice,
|
|
8
9
|
getPlanCadenceLabel,
|
|
9
10
|
getPlanPreviewPrice,
|
|
10
11
|
getPlanSavingsLabel,
|
|
11
|
-
|
|
12
|
-
type ProductVariant,
|
|
13
|
-
type SellingPlan,
|
|
14
|
-
type SellingPlanGroup,
|
|
15
|
-
} from '../lib/forgecart';
|
|
12
|
+
} from '../lib/format';
|
|
16
13
|
|
|
17
14
|
/**
|
|
18
15
|
* Variant picker + purchase-options + add-to-cart for the product detail page.
|
|
@@ -127,14 +124,14 @@ export function ProductPurchase({
|
|
|
127
124
|
return (
|
|
128
125
|
<div className="space-y-4">
|
|
129
126
|
{variant && previewPrice !== null ? (
|
|
130
|
-
<p className="text-xl font-semibold text-
|
|
127
|
+
<p className="text-xl font-semibold text-base-content">{formatPrice(previewPrice)}</p>
|
|
131
128
|
) : (
|
|
132
|
-
<p className="text-sm text-
|
|
129
|
+
<p className="text-sm text-base-content/60">Select options to see the price.</p>
|
|
133
130
|
)}
|
|
134
131
|
|
|
135
132
|
{product.optionGroups.map((group) => (
|
|
136
133
|
<div key={group.id}>
|
|
137
|
-
<h3 className="mb-1 text-sm font-medium text-
|
|
134
|
+
<h3 className="mb-1 text-sm font-medium text-base-content/70">{group.name}</h3>
|
|
138
135
|
<div className="flex flex-wrap gap-2">
|
|
139
136
|
{group.options.map((opt) => {
|
|
140
137
|
const active = selected[group.id] === opt.id;
|
|
@@ -145,8 +142,8 @@ export function ProductPurchase({
|
|
|
145
142
|
onClick={() => setSelected((s) => ({ ...s, [group.id]: opt.id }))}
|
|
146
143
|
className={`rounded-md border px-3 py-1.5 text-sm transition ${
|
|
147
144
|
active
|
|
148
|
-
? 'border-
|
|
149
|
-
: 'border-
|
|
145
|
+
? 'border-primary bg-primary text-primary-content'
|
|
146
|
+
: 'border-base-300 text-base-content/70 hover:border-primary'
|
|
150
147
|
}`}
|
|
151
148
|
>
|
|
152
149
|
{opt.name}
|
|
@@ -159,7 +156,7 @@ export function ProductPurchase({
|
|
|
159
156
|
|
|
160
157
|
{variant && hasPurchaseOptions && (
|
|
161
158
|
<div>
|
|
162
|
-
<h3 className="mb-1 text-sm font-medium text-
|
|
159
|
+
<h3 className="mb-1 text-sm font-medium text-base-content/70">Purchase options</h3>
|
|
163
160
|
<div className="flex flex-col gap-2">
|
|
164
161
|
{oneTimeAllowed && (
|
|
165
162
|
<PurchaseOption
|
|
@@ -194,7 +191,7 @@ export function ProductPurchase({
|
|
|
194
191
|
onClick={onAdd}
|
|
195
192
|
disabled={!variant || pending}
|
|
196
193
|
data-fc-track="add-to-cart"
|
|
197
|
-
className="
|
|
194
|
+
className="btn btn-primary"
|
|
198
195
|
>
|
|
199
196
|
{pending ? 'Adding…' : added ? 'Added!' : variant ? 'Add to cart' : 'Unavailable'}
|
|
200
197
|
</button>
|
|
@@ -222,16 +219,14 @@ function PurchaseOption({
|
|
|
222
219
|
onClick={onSelect}
|
|
223
220
|
aria-pressed={active}
|
|
224
221
|
className={`flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-left text-sm transition ${
|
|
225
|
-
active
|
|
226
|
-
? 'border-gray-900 ring-1 ring-gray-900'
|
|
227
|
-
: 'border-gray-300 hover:border-gray-900'
|
|
222
|
+
active ? 'border-primary ring-1 ring-primary' : 'border-base-300 hover:border-primary'
|
|
228
223
|
}`}
|
|
229
224
|
>
|
|
230
225
|
<span className="min-w-0">
|
|
231
|
-
<span className="block font-medium text-
|
|
232
|
-
{hint && <span className="block text-xs text-
|
|
226
|
+
<span className="block font-medium text-base-content">{label}</span>
|
|
227
|
+
{hint && <span className="block text-xs text-base-content/60">{hint}</span>}
|
|
233
228
|
</span>
|
|
234
|
-
<span className="shrink-0 font-medium text-
|
|
229
|
+
<span className="shrink-0 font-medium text-base-content">{priceLabel}</span>
|
|
235
230
|
</button>
|
|
236
231
|
);
|
|
237
232
|
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
import type { ExtractedError } from '@forgecart/sdk/shop';
|
|
6
|
+
|
|
7
|
+
import type { Country } from '../../lib/forgecart';
|
|
8
|
+
import { resolveAddressSuggestion, suggestAddresses } from '../../lib/checkout-session';
|
|
9
|
+
import { shopperMessage } from '../../lib/error-messages';
|
|
10
|
+
import { mintUuid } from '../../lib/uuid';
|
|
11
|
+
|
|
12
|
+
/** The form's value shape — field names mirror `CreateAddressInput`. */
|
|
13
|
+
export interface AddressFormValue {
|
|
14
|
+
streetLine1: string;
|
|
15
|
+
streetLine2: string;
|
|
16
|
+
city: string;
|
|
17
|
+
province: string;
|
|
18
|
+
postalCode: string;
|
|
19
|
+
countryId: string;
|
|
20
|
+
phoneNumber: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const EMPTY_ADDRESS: AddressFormValue = {
|
|
24
|
+
streetLine1: '',
|
|
25
|
+
streetLine2: '',
|
|
26
|
+
city: '',
|
|
27
|
+
province: '',
|
|
28
|
+
postalCode: '',
|
|
29
|
+
countryId: '',
|
|
30
|
+
phoneNumber: '',
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const SUGGEST_DEBOUNCE_MS = 300;
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Address form: type-ahead via the shop's `addressAutocomplete` (an optional
|
|
37
|
+
* capability — an empty suggestion list simply leaves manual entry, which is
|
|
38
|
+
* always present), a country select populated from `availableCountries` (the
|
|
39
|
+
* API, never a hardcoded list — channel countries are channel-specific), and
|
|
40
|
+
* the REQUIRED phone field (#863's server gate; the `fieldErrors` map routes
|
|
41
|
+
* `SHIPPING_ADDRESS_PHONE_REQUIRED` / `SHIPPING_ADDRESS_COUNTRY_*` here).
|
|
42
|
+
*/
|
|
43
|
+
export function AddressStep({
|
|
44
|
+
countries,
|
|
45
|
+
value,
|
|
46
|
+
onChange,
|
|
47
|
+
onSubmit,
|
|
48
|
+
pending,
|
|
49
|
+
error,
|
|
50
|
+
}: {
|
|
51
|
+
countries: Country[];
|
|
52
|
+
value: AddressFormValue;
|
|
53
|
+
onChange: (value: AddressFormValue) => void;
|
|
54
|
+
onSubmit: () => void;
|
|
55
|
+
pending: boolean;
|
|
56
|
+
error: ExtractedError | null;
|
|
57
|
+
}) {
|
|
58
|
+
const [suggestions, setSuggestions] = useState<{ id: string; description: string }[]>([]);
|
|
59
|
+
const [suggestOpen, setSuggestOpen] = useState(false);
|
|
60
|
+
// One vendor billing session per address-entry (Google `sessiontoken`).
|
|
61
|
+
// mintUuid, not crypto.randomUUID: the latter is secure-context gated and
|
|
62
|
+
// absent on the plain-http local preview origin — a bare call crashed this
|
|
63
|
+
// whole step into the route error boundary (2026-08-09).
|
|
64
|
+
const sessionTokenRef = useRef<string>('');
|
|
65
|
+
if (!sessionTokenRef.current) sessionTokenRef.current = mintUuid();
|
|
66
|
+
const debounceRef = useRef<number | null>(null);
|
|
67
|
+
|
|
68
|
+
const selectedCountry = useMemo(
|
|
69
|
+
() => countries.find((country) => country.id === value.countryId),
|
|
70
|
+
[countries, value.countryId],
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
return () => {
|
|
75
|
+
if (debounceRef.current !== null) window.clearTimeout(debounceRef.current);
|
|
76
|
+
};
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
function onStreetInput(street: string): void {
|
|
80
|
+
onChange({ ...value, streetLine1: street });
|
|
81
|
+
if (debounceRef.current !== null) window.clearTimeout(debounceRef.current);
|
|
82
|
+
if (street.trim().length < 4) {
|
|
83
|
+
setSuggestions([]);
|
|
84
|
+
setSuggestOpen(false);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
debounceRef.current = window.setTimeout(async () => {
|
|
88
|
+
const result = await suggestAddresses(street, sessionTokenRef.current, selectedCountry?.code);
|
|
89
|
+
// Suggest failures degrade to manual entry — never an error surface.
|
|
90
|
+
if (result.ok && result.data.length > 0) {
|
|
91
|
+
setSuggestions(result.data);
|
|
92
|
+
setSuggestOpen(true);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
setSuggestions([]);
|
|
96
|
+
setSuggestOpen(false);
|
|
97
|
+
}, SUGGEST_DEBOUNCE_MS);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function pickSuggestion(id: string): Promise<void> {
|
|
101
|
+
setSuggestOpen(false);
|
|
102
|
+
const result = await resolveAddressSuggestion(id, sessionTokenRef.current);
|
|
103
|
+
if (!result.ok) return;
|
|
104
|
+
const resolved = result.data;
|
|
105
|
+
const matchedCountry = countries.find((country) => country.code === resolved.countryCode);
|
|
106
|
+
onChange({
|
|
107
|
+
...value,
|
|
108
|
+
streetLine1: resolved.streetLine1,
|
|
109
|
+
streetLine2: resolved.streetLine2 ?? '',
|
|
110
|
+
city: resolved.city ?? '',
|
|
111
|
+
province: resolved.province ?? '',
|
|
112
|
+
postalCode: resolved.postalCode ?? '',
|
|
113
|
+
// Suggestion outside the channel's shipping countries: keep the text
|
|
114
|
+
// fields, leave the select for the shopper (the server would reject an
|
|
115
|
+
// unknown code anyway).
|
|
116
|
+
countryId: matchedCountry?.id ?? value.countryId,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const phoneError = error?.code === 'SHIPPING_ADDRESS_PHONE_REQUIRED';
|
|
121
|
+
const countryError =
|
|
122
|
+
error?.code === 'SHIPPING_ADDRESS_COUNTRY_REQUIRED' ||
|
|
123
|
+
error?.code === 'SHIPPING_ADDRESS_COUNTRY_INVALID';
|
|
124
|
+
const stepError = error && !phoneError && !countryError;
|
|
125
|
+
|
|
126
|
+
return (
|
|
127
|
+
<form
|
|
128
|
+
className="space-y-4"
|
|
129
|
+
onSubmit={(e) => {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
onSubmit();
|
|
132
|
+
}}
|
|
133
|
+
>
|
|
134
|
+
<div className="relative">
|
|
135
|
+
<label
|
|
136
|
+
className="mb-1 block text-sm font-medium text-base-content/70"
|
|
137
|
+
htmlFor="street-line-1"
|
|
138
|
+
>
|
|
139
|
+
Street address
|
|
140
|
+
</label>
|
|
141
|
+
<input
|
|
142
|
+
id="street-line-1"
|
|
143
|
+
type="text"
|
|
144
|
+
required
|
|
145
|
+
autoComplete="address-line1"
|
|
146
|
+
value={value.streetLine1}
|
|
147
|
+
onChange={(e) => onStreetInput(e.target.value)}
|
|
148
|
+
onFocus={() => suggestions.length > 0 && setSuggestOpen(true)}
|
|
149
|
+
className="input w-full"
|
|
150
|
+
/>
|
|
151
|
+
{suggestOpen && suggestions.length > 0 && (
|
|
152
|
+
<ul className="menu absolute z-10 mt-1 w-full rounded-box border border-base-300 bg-base-100 shadow">
|
|
153
|
+
{suggestions.map((suggestion) => (
|
|
154
|
+
<li key={suggestion.id}>
|
|
155
|
+
<button type="button" onClick={() => void pickSuggestion(suggestion.id)}>
|
|
156
|
+
{suggestion.description}
|
|
157
|
+
</button>
|
|
158
|
+
</li>
|
|
159
|
+
))}
|
|
160
|
+
</ul>
|
|
161
|
+
)}
|
|
162
|
+
</div>
|
|
163
|
+
|
|
164
|
+
<div>
|
|
165
|
+
<label
|
|
166
|
+
className="mb-1 block text-sm font-medium text-base-content/70"
|
|
167
|
+
htmlFor="street-line-2"
|
|
168
|
+
>
|
|
169
|
+
Apartment, suite, etc. (optional)
|
|
170
|
+
</label>
|
|
171
|
+
<input
|
|
172
|
+
id="street-line-2"
|
|
173
|
+
type="text"
|
|
174
|
+
autoComplete="address-line2"
|
|
175
|
+
value={value.streetLine2}
|
|
176
|
+
onChange={(e) => onChange({ ...value, streetLine2: e.target.value })}
|
|
177
|
+
className="input w-full"
|
|
178
|
+
/>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<div className="grid grid-cols-2 gap-4">
|
|
182
|
+
<div>
|
|
183
|
+
<label className="mb-1 block text-sm font-medium text-base-content/70" htmlFor="city">
|
|
184
|
+
City
|
|
185
|
+
</label>
|
|
186
|
+
<input
|
|
187
|
+
id="city"
|
|
188
|
+
type="text"
|
|
189
|
+
required
|
|
190
|
+
autoComplete="address-level2"
|
|
191
|
+
value={value.city}
|
|
192
|
+
onChange={(e) => onChange({ ...value, city: e.target.value })}
|
|
193
|
+
className="input w-full"
|
|
194
|
+
/>
|
|
195
|
+
</div>
|
|
196
|
+
<div>
|
|
197
|
+
<label className="mb-1 block text-sm font-medium text-base-content/70" htmlFor="province">
|
|
198
|
+
State / Province
|
|
199
|
+
</label>
|
|
200
|
+
<input
|
|
201
|
+
id="province"
|
|
202
|
+
type="text"
|
|
203
|
+
autoComplete="address-level1"
|
|
204
|
+
value={value.province}
|
|
205
|
+
onChange={(e) => onChange({ ...value, province: e.target.value })}
|
|
206
|
+
className="input w-full"
|
|
207
|
+
/>
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
<div className="grid grid-cols-2 gap-4">
|
|
212
|
+
<div>
|
|
213
|
+
<label
|
|
214
|
+
className="mb-1 block text-sm font-medium text-base-content/70"
|
|
215
|
+
htmlFor="postal-code"
|
|
216
|
+
>
|
|
217
|
+
Postal code
|
|
218
|
+
</label>
|
|
219
|
+
<input
|
|
220
|
+
id="postal-code"
|
|
221
|
+
type="text"
|
|
222
|
+
required
|
|
223
|
+
autoComplete="postal-code"
|
|
224
|
+
value={value.postalCode}
|
|
225
|
+
onChange={(e) => onChange({ ...value, postalCode: e.target.value })}
|
|
226
|
+
className="input w-full"
|
|
227
|
+
/>
|
|
228
|
+
</div>
|
|
229
|
+
<div>
|
|
230
|
+
<label className="mb-1 block text-sm font-medium text-base-content/70" htmlFor="country">
|
|
231
|
+
Country
|
|
232
|
+
</label>
|
|
233
|
+
<select
|
|
234
|
+
id="country"
|
|
235
|
+
required
|
|
236
|
+
value={value.countryId}
|
|
237
|
+
onChange={(e) => onChange({ ...value, countryId: e.target.value })}
|
|
238
|
+
className={`select w-full ${countryError ? 'select-error' : ''}`}
|
|
239
|
+
>
|
|
240
|
+
<option value="" disabled>
|
|
241
|
+
Select a country
|
|
242
|
+
</option>
|
|
243
|
+
{countries.map((country) => (
|
|
244
|
+
<option key={country.id} value={country.id}>
|
|
245
|
+
{country.name}
|
|
246
|
+
</option>
|
|
247
|
+
))}
|
|
248
|
+
</select>
|
|
249
|
+
{countryError && <p className="mt-1 text-sm text-error">{shopperMessage(error)}</p>}
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
<div>
|
|
254
|
+
<label
|
|
255
|
+
className="mb-1 block text-sm font-medium text-base-content/70"
|
|
256
|
+
htmlFor="phone-number"
|
|
257
|
+
>
|
|
258
|
+
Phone number
|
|
259
|
+
</label>
|
|
260
|
+
<input
|
|
261
|
+
id="phone-number"
|
|
262
|
+
type="tel"
|
|
263
|
+
required
|
|
264
|
+
autoComplete="tel"
|
|
265
|
+
value={value.phoneNumber}
|
|
266
|
+
onChange={(e) => onChange({ ...value, phoneNumber: e.target.value })}
|
|
267
|
+
className={`input w-full ${phoneError ? 'input-error' : ''}`}
|
|
268
|
+
/>
|
|
269
|
+
{phoneError && <p className="mt-1 text-sm text-error">{shopperMessage(error)}</p>}
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
{stepError && (
|
|
273
|
+
<div role="alert" className="alert alert-error text-sm">
|
|
274
|
+
<span>{shopperMessage(error)}</span>
|
|
275
|
+
</div>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
<button
|
|
279
|
+
type="submit"
|
|
280
|
+
className="btn btn-primary"
|
|
281
|
+
disabled={pending}
|
|
282
|
+
data-fc-track="checkout-address"
|
|
283
|
+
>
|
|
284
|
+
{pending ? 'Saving…' : 'Continue to shipping'}
|
|
285
|
+
</button>
|
|
286
|
+
</form>
|
|
287
|
+
);
|
|
288
|
+
}
|