@forgecart/cli 2.202608121449.0 → 2.202608200713.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 +30 -12
- 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 +131 -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/track-forward.ts +14 -1
- 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
|
@@ -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
|
+
}
|