@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.
Files changed (62) 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 +23 -11
  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 +40 -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/uuid.ts +19 -0
  48. package/templates/storefront/src/server/app.module.ts +18 -0
  49. package/templates/storefront/src/server/backend-api.ts +26 -0
  50. package/templates/storefront/src/server/backend-method.decorator.ts +23 -0
  51. package/templates/storefront/src/server/bootstrap.ts +122 -0
  52. package/templates/storefront/src/server/customer-extras/customer-extras.module.ts +13 -0
  53. package/templates/storefront/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  54. package/templates/storefront/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  55. package/templates/storefront/src/server/forgecart/forgecart-client.factory.ts +69 -0
  56. package/templates/storefront/src/server/forgecart/forgecart.module.ts +9 -0
  57. package/templates/storefront/src/server/runner.ts +91 -0
  58. package/templates/storefront/src/server/types.ts +36 -0
  59. package/templates/storefront/tsconfig.json +2 -0
  60. package/templates/storefront/.env.example +0 -12
  61. package/templates/storefront/src/lib/cart-actions.ts +0 -139
  62. package/templates/storefront/tailwind.config.js +0 -8
@@ -1,38 +1,50 @@
1
1
  import Link from 'next/link';
2
2
 
3
- import { formatPrice, getStartingPrice, type Product } from '../lib/forgecart';
3
+ import type { Product } from '../lib/forgecart';
4
+ import { formatPrice, getStartingPrice } from '../lib/format';
4
5
 
5
- /** A product tile linking to its detail page. Used by the home and grid pages. */
6
- export function ProductCard({ product }: { product: Product }) {
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="group flex flex-col overflow-hidden rounded-lg border border-gray-200 bg-white transition hover:shadow-md"
24
+ className="card card-border group h-full bg-base-100 transition-shadow hover:shadow-md"
14
25
  >
15
- <div className="aspect-square overflow-hidden bg-gray-100">
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
- className="h-full w-full object-cover transition group-hover:scale-105"
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-gray-400">
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
- </div>
30
- <div className="flex flex-1 flex-col gap-1 p-4">
31
- <h3 className="font-medium text-gray-900">{product.name}</h3>
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-gray-600">
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
- type Product,
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-gray-900">{formatPrice(previewPrice)}</p>
127
+ <p className="text-xl font-semibold text-base-content">{formatPrice(previewPrice)}</p>
131
128
  ) : (
132
- <p className="text-sm text-gray-500">Select options to see the price.</p>
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-gray-700">{group.name}</h3>
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-gray-900 bg-gray-900 text-white'
149
- : 'border-gray-300 text-gray-700 hover:border-gray-900'
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-gray-700">Purchase options</h3>
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="rounded-md bg-gray-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-gray-700 disabled:cursor-not-allowed disabled:opacity-50"
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-gray-900">{label}</span>
232
- {hint && <span className="block text-xs text-gray-500">{hint}</span>}
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-gray-900">{priceLabel}</span>
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
+ }