@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.
Files changed (63) hide show
  1. package/dist/src/commands/init.d.ts +16 -0
  2. package/dist/src/commands/init.js +18 -7
  3. package/dist/src/commands/init.js.map +1 -1
  4. package/package.json +1 -1
  5. package/templates/storefront/README.md +145 -61
  6. package/templates/storefront/next.config.js +20 -0
  7. package/templates/storefront/package.json +10 -2
  8. package/templates/storefront/postcss.config.js +1 -2
  9. package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +30 -12
  10. package/templates/storefront/src/app/__forge_beacon/route.ts +1 -2
  11. package/templates/storefront/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  12. package/templates/storefront/src/app/cart/page.tsx +33 -6
  13. package/templates/storefront/src/app/checkout/page.tsx +40 -0
  14. package/templates/storefront/src/app/error.tsx +21 -0
  15. package/templates/storefront/src/app/global-error.tsx +23 -0
  16. package/templates/storefront/src/app/globals.css +105 -8
  17. package/templates/storefront/src/app/layout.tsx +45 -17
  18. package/templates/storefront/src/app/page.tsx +153 -43
  19. package/templates/storefront/src/app/ping/route.ts +1 -2
  20. package/templates/storefront/src/app/products/[slug]/not-found.tsx +3 -8
  21. package/templates/storefront/src/app/products/[slug]/page.tsx +69 -23
  22. package/templates/storefront/src/app/products/page.tsx +52 -9
  23. package/templates/storefront/src/components/CartView.tsx +244 -117
  24. package/templates/storefront/src/components/ForgeTracker.tsx +131 -26
  25. package/templates/storefront/src/components/Header.tsx +8 -10
  26. package/templates/storefront/src/components/ProductCard.tsx +25 -13
  27. package/templates/storefront/src/components/ProductPurchase.tsx +13 -18
  28. package/templates/storefront/src/components/checkout/AddressStep.tsx +288 -0
  29. package/templates/storefront/src/components/checkout/CheckoutFlow.tsx +543 -0
  30. package/templates/storefront/src/components/checkout/CheckoutGate.tsx +45 -0
  31. package/templates/storefront/src/components/checkout/PaymentElementForm.tsx +138 -0
  32. package/templates/storefront/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  33. package/templates/storefront/src/components/checkout/RatesStep.tsx +113 -0
  34. package/templates/storefront/src/instrumentation.ts +34 -0
  35. package/templates/storefront/src/lib/action-result.ts +30 -0
  36. package/templates/storefront/src/lib/backend-actions.ts +20 -0
  37. package/templates/storefront/src/lib/backend-client.ts +47 -0
  38. package/templates/storefront/src/lib/cart-context.tsx +157 -22
  39. package/templates/storefront/src/lib/checkout-session.ts +185 -0
  40. package/templates/storefront/src/lib/error-messages.ts +24 -0
  41. package/templates/storefront/src/lib/experiments.ts +42 -44
  42. package/templates/storefront/src/lib/forgecart.ts +61 -78
  43. package/templates/storefront/src/lib/format.ts +91 -0
  44. package/templates/storefront/src/lib/session-actions.ts +54 -0
  45. package/templates/storefront/src/lib/shop-config.ts +44 -0
  46. package/templates/storefront/src/lib/shop-session.ts +114 -0
  47. package/templates/storefront/src/lib/track-forward.ts +14 -1
  48. package/templates/storefront/src/lib/uuid.ts +19 -0
  49. package/templates/storefront/src/server/app.module.ts +18 -0
  50. package/templates/storefront/src/server/backend-api.ts +26 -0
  51. package/templates/storefront/src/server/backend-method.decorator.ts +23 -0
  52. package/templates/storefront/src/server/bootstrap.ts +122 -0
  53. package/templates/storefront/src/server/customer-extras/customer-extras.module.ts +13 -0
  54. package/templates/storefront/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  55. package/templates/storefront/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  56. package/templates/storefront/src/server/forgecart/forgecart-client.factory.ts +69 -0
  57. package/templates/storefront/src/server/forgecart/forgecart.module.ts +9 -0
  58. package/templates/storefront/src/server/runner.ts +91 -0
  59. package/templates/storefront/src/server/types.ts +36 -0
  60. package/templates/storefront/tsconfig.json +2 -0
  61. package/templates/storefront/.env.example +0 -12
  62. package/templates/storefront/src/lib/cart-actions.ts +0 -139
  63. 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
+ }