@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
@@ -1,27 +1,70 @@
1
+ import { connection } from 'next/server';
2
+ import { Suspense } from 'react';
3
+
1
4
  import { ProductCard } from '../../components/ProductCard';
2
5
  import { getProducts } from '../../lib/forgecart';
3
6
 
4
- export const dynamic = 'force-dynamic';
7
+ // PROGRESSIVE PRE-RENDERING: static shell; the data sections below are
8
+ // per-request holes streamed inside their Suspense boundaries
9
+ // (cacheComponents — see next.config.js).
10
+
11
+ /**
12
+ * Catalog — the browse grid. The header carries the count as orientation
13
+ * (Blueprint listing guidance: state scope up front); cards do the visual
14
+ * lifting, the grid stays quiet.
15
+ */
16
+ export default function ProductsPage() {
17
+ return (
18
+ <div className="space-y-8">
19
+ <Suspense fallback={<CatalogSkeleton />}>
20
+ <Catalog />
21
+ </Suspense>
22
+ </div>
23
+ );
24
+ }
5
25
 
6
- export default async function ProductsPage() {
26
+ async function Catalog() {
27
+ // Request-time hole (cacheComponents): the build keeps the fallback shell.
28
+ await connection();
7
29
  const { items, totalItems } = await getProducts({ take: 24 });
8
30
 
9
31
  return (
10
- <div className="space-y-6">
11
- <div className="flex items-baseline justify-between">
12
- <h1 className="text-2xl font-bold tracking-tight text-gray-900">Products</h1>
13
- <span className="text-sm text-gray-500">{totalItems} items</span>
14
- </div>
32
+ <>
33
+ <header className="flex flex-wrap items-baseline justify-between gap-3 border-b border-base-300 pb-6">
34
+ <h1 className="text-3xl font-bold tracking-tight">All products</h1>
35
+ <span className="text-sm text-base-content/60">
36
+ {totalItems} {totalItems === 1 ? 'item' : 'items'}
37
+ </span>
38
+ </header>
15
39
 
16
40
  {items.length === 0 ? (
17
- <p className="text-gray-500">No products yet. Add some in your dashboard.</p>
41
+ <p className="text-base-content/60">
42
+ No products yet — add your first product in the dashboard and it appears here.
43
+ </p>
18
44
  ) : (
19
- <div className="grid grid-cols-2 gap-6 sm:grid-cols-3 lg:grid-cols-4">
45
+ <div className="grid grid-cols-2 gap-4 sm:grid-cols-3 sm:gap-6 lg:grid-cols-4">
20
46
  {items.map((product) => (
21
47
  <ProductCard key={product.id} product={product} />
22
48
  ))}
23
49
  </div>
24
50
  )}
51
+ </>
52
+ );
53
+ }
54
+
55
+ function CatalogSkeleton() {
56
+ return (
57
+ <div aria-busy="true">
58
+ <header className="flex items-baseline justify-between gap-3 border-b border-base-300 pb-6">
59
+ <h1 className="text-3xl font-bold tracking-tight">All products</h1>
60
+ <div className="skeleton h-4 w-16"></div>
61
+ </header>
62
+ <div className="mt-8 grid grid-cols-2 gap-4 sm:grid-cols-3 sm:gap-6 lg:grid-cols-4">
63
+ <div className="skeleton aspect-square w-full"></div>
64
+ <div className="skeleton aspect-square w-full"></div>
65
+ <div className="skeleton aspect-square w-full"></div>
66
+ <div className="skeleton aspect-square w-full"></div>
67
+ </div>
25
68
  </div>
26
69
  );
27
70
  }
@@ -1,18 +1,16 @@
1
1
  'use client';
2
2
 
3
3
  import Link from 'next/link';
4
+ import { useState } from 'react';
4
5
 
5
6
  import { useCart } from '../lib/cart-context';
6
- import {
7
- formatPrice,
8
- getPlanCadenceLabel,
9
- getPlanSavingsLabel,
10
- type SellingPlan,
11
- type SellingPlanGroup,
12
- } from '../lib/forgecart';
7
+ import type { SellingPlan, SellingPlanGroup } from '../lib/forgecart';
8
+ import { formatPrice, getPlanCadenceLabel, getPlanSavingsLabel } from '../lib/format';
13
9
 
14
10
  /**
15
- * The interactive cart, rendered off the live order from `useCart()`.
11
+ * The interactive cart, rendered off the live order from `useCart()` —
12
+ * Blueprint `pages/checkout-flow` cart shape: line items on the left,
13
+ * a sticky order-summary rail (coupon, totals, checkout) on the right.
16
14
  *
17
15
  * `channelGroups` are the channel-wide subscription selling plans, fetched
18
16
  * server-side by the cart route and passed in (the shop client is server-only,
@@ -23,19 +21,45 @@ import {
23
21
  * page) get a small "Subscription" label.
24
22
  */
25
23
  export function CartView({ channelGroups }: { channelGroups: SellingPlanGroup[] }) {
26
- const { cart, itemCount, subtotal, setQuantity, remove, setSellingPlan, pending } = useCart();
24
+ const {
25
+ cart,
26
+ itemCount,
27
+ subtotal,
28
+ hydrated,
29
+ setQuantity,
30
+ remove,
31
+ setSellingPlan,
32
+ applyCoupon,
33
+ removeCoupon,
34
+ pending,
35
+ error,
36
+ clearError,
37
+ } = useCart();
38
+ const [couponInput, setCouponInput] = useState('');
27
39
  const lines = cart?.lines ?? [];
28
40
 
41
+ // The cart hydrates over the shopper's websocket after mount (PPR shell) —
42
+ // a skeleton, never a false "empty cart" flash.
43
+ if (!hydrated) {
44
+ return (
45
+ <div className="space-y-8" aria-busy="true" aria-label="Loading your cart">
46
+ <h1 className="text-3xl font-bold tracking-tight">Your cart</h1>
47
+ <div className="grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
48
+ <div className="skeleton h-64 w-full"></div>
49
+ <div className="skeleton h-80 w-full"></div>
50
+ </div>
51
+ </div>
52
+ );
53
+ }
54
+
29
55
  if (itemCount === 0) {
30
56
  return (
31
- <div className="space-y-4">
32
- <h1 className="text-2xl font-bold tracking-tight text-gray-900">Your cart</h1>
33
- <p className="text-gray-500">Your cart is empty.</p>
34
- <Link
35
- href="/products"
36
- data-fc-track="cart-browse-products"
37
- className="inline-block rounded-md bg-gray-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-gray-700"
38
- >
57
+ <div className="mx-auto max-w-md space-y-4 py-16 text-center">
58
+ <h1 className="text-3xl font-bold tracking-tight">Your cart</h1>
59
+ <p className="text-base-content/60">
60
+ Your cart is empty — it starts the moment you add your first item.
61
+ </p>
62
+ <Link href="/products" data-fc-track="cart-browse-products" className="btn btn-primary">
39
63
  Browse products
40
64
  </Link>
41
65
  </div>
@@ -43,120 +67,223 @@ export function CartView({ channelGroups }: { channelGroups: SellingPlanGroup[]
43
67
  }
44
68
 
45
69
  // Channel-wide plans (enabled only), flattened across the channel groups.
46
- const channelPlans: SellingPlan[] = channelGroups.flatMap((g) => g.plans).filter((p) => p.enabled);
70
+ const channelPlans: SellingPlan[] = channelGroups
71
+ .flatMap((g) => g.plans)
72
+ .filter((p) => p.enabled);
47
73
  const activePlanId = cart?.sellingPlanId ?? null;
48
74
 
49
75
  return (
50
- <div className="space-y-6">
51
- <h1 className="text-2xl font-bold tracking-tight text-gray-900">Your cart</h1>
52
-
53
- <ul className="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
54
- {lines.map((line) => (
55
- <li key={line.id} className="flex items-center gap-4 p-4">
56
- <div className="h-16 w-16 shrink-0 overflow-hidden rounded bg-gray-100">
57
- {line.featuredAsset?.preview ? (
58
- // eslint-disable-next-line @next/next/no-img-element
59
- <img
60
- src={line.featuredAsset.preview}
61
- alt={line.productVariant.name}
62
- className="h-full w-full object-cover"
63
- />
64
- ) : null}
65
- </div>
76
+ <div className="space-y-8">
77
+ <h1 className="text-3xl font-bold tracking-tight">Your cart</h1>
66
78
 
67
- <div className="min-w-0 flex-1">
68
- <p className="truncate font-medium text-gray-900">{line.productVariant.name}</p>
69
- <p className="text-sm text-gray-500">{formatPrice(line.unitPriceWithTax)} each</p>
70
- {line.sellingPlanId && (
71
- <span className="mt-1 inline-block rounded bg-gray-900 px-1.5 py-0.5 text-xs font-medium text-white">
72
- Subscription
73
- </span>
74
- )}
79
+ <div className="grid items-start gap-8 lg:grid-cols-[minmax(0,1fr)_360px]">
80
+ <div className="space-y-6">
81
+ <ul className="list rounded-box border border-base-300 bg-base-100">
82
+ {lines.map((line) => (
83
+ <li key={line.id} className="list-row items-center">
84
+ <div className="size-16 overflow-hidden rounded-box bg-base-200">
85
+ {line.featuredAsset?.preview ? (
86
+ // eslint-disable-next-line @next/next/no-img-element
87
+ <img
88
+ src={line.featuredAsset.preview}
89
+ alt={line.productVariant.name}
90
+ className="h-full w-full object-cover"
91
+ />
92
+ ) : null}
93
+ </div>
94
+
95
+ <div className="min-w-0">
96
+ <p className="truncate font-medium">{line.productVariant.name}</p>
97
+ <p className="text-sm text-base-content/60">
98
+ {formatPrice(line.unitPriceWithTax)} each
99
+ </p>
100
+ {line.sellingPlanId && (
101
+ <span className="badge badge-secondary badge-sm mt-1 font-medium">
102
+ Subscription
103
+ </span>
104
+ )}
105
+ </div>
106
+
107
+ <div className="flex items-center gap-3">
108
+ <label className="sr-only" htmlFor={`qty-${line.id}`}>
109
+ Quantity
110
+ </label>
111
+ <input
112
+ id={`qty-${line.id}`}
113
+ type="number"
114
+ min={0}
115
+ value={line.quantity}
116
+ disabled={pending}
117
+ onChange={(e) => {
118
+ // 0 is a real value — updateLine removes the line for it.
119
+ const parsed = Number.parseInt(e.target.value, 10);
120
+ setQuantity(line.id, Number.isNaN(parsed) ? line.quantity : parsed);
121
+ }}
122
+ className="input input-sm w-16 disabled:opacity-50"
123
+ />
124
+ <div className="w-20 text-right font-medium tabular-nums">
125
+ {formatPrice(line.linePriceWithTax)}
126
+ </div>
127
+ <button
128
+ type="button"
129
+ onClick={() => remove(line.id)}
130
+ disabled={pending}
131
+ className="btn btn-ghost btn-xs text-base-content/50 hover:text-error"
132
+ aria-label={`Remove ${line.productVariant.name}`}
133
+ >
134
+ Remove
135
+ </button>
136
+ </div>
137
+ </li>
138
+ ))}
139
+ </ul>
140
+
141
+ {channelPlans.length > 0 && (
142
+ <div className="card card-border bg-base-100">
143
+ <div className="card-body gap-3 p-5">
144
+ <h2 className="card-title text-base">Subscribe to your whole order</h2>
145
+ <div className="flex flex-col gap-2">
146
+ <button
147
+ type="button"
148
+ onClick={() => setSellingPlan(null)}
149
+ disabled={pending}
150
+ aria-pressed={activePlanId === null}
151
+ className={`rounded-field border px-3 py-2 text-left text-sm transition-colors disabled:opacity-50 ${
152
+ activePlanId === null
153
+ ? 'border-primary ring-1 ring-primary'
154
+ : 'border-base-300 hover:border-primary'
155
+ }`}
156
+ >
157
+ <span className="font-medium">No, one-time</span>
158
+ </button>
159
+ {channelPlans.map((plan) => {
160
+ const savings = getPlanSavingsLabel(plan);
161
+ const hint = [
162
+ savings,
163
+ plan.trialDays > 0 ? `${plan.trialDays}-day free trial` : null,
164
+ ]
165
+ .filter(Boolean)
166
+ .join(' · ');
167
+ const active = activePlanId === plan.id;
168
+ return (
169
+ <button
170
+ key={plan.id}
171
+ type="button"
172
+ onClick={() => setSellingPlan(plan.id)}
173
+ disabled={pending}
174
+ data-fc-track="cart-subscribe-order"
175
+ aria-pressed={active}
176
+ className={`rounded-field border px-3 py-2 text-left text-sm transition-colors disabled:opacity-50 ${
177
+ active
178
+ ? 'border-primary ring-1 ring-primary'
179
+ : 'border-base-300 hover:border-primary'
180
+ }`}
181
+ >
182
+ <span className="block font-medium">
183
+ Subscribe — {getPlanCadenceLabel(plan)}
184
+ </span>
185
+ {hint && <span className="block text-xs text-base-content/60">{hint}</span>}
186
+ </button>
187
+ );
188
+ })}
189
+ </div>
190
+ </div>
75
191
  </div>
192
+ )}
193
+ </div>
194
+
195
+ <aside
196
+ className="card card-border bg-base-100 lg:sticky lg:top-24"
197
+ aria-label="Order summary"
198
+ >
199
+ <div className="card-body gap-4 p-5">
200
+ <h2 className="card-title text-base">Order summary</h2>
76
201
 
77
- <div className="flex items-center gap-2">
78
- <label className="sr-only" htmlFor={`qty-${line.id}`}>
79
- Quantity
202
+ <form
203
+ className="flex gap-2"
204
+ onSubmit={(e) => {
205
+ e.preventDefault();
206
+ const code = couponInput.trim();
207
+ if (!code) return;
208
+ applyCoupon(code);
209
+ setCouponInput('');
210
+ }}
211
+ >
212
+ <label className="sr-only" htmlFor="coupon-code">
213
+ Coupon code
80
214
  </label>
81
215
  <input
82
- id={`qty-${line.id}`}
83
- type="number"
84
- min={1}
85
- value={line.quantity}
216
+ id="coupon-code"
217
+ type="text"
218
+ value={couponInput}
219
+ placeholder="Coupon code"
86
220
  disabled={pending}
87
- onChange={(e) => setQuantity(line.id, Number.parseInt(e.target.value, 10) || 1)}
88
- className="w-16 rounded border border-gray-300 px-2 py-1 text-sm disabled:opacity-50"
221
+ onChange={(e) => setCouponInput(e.target.value)}
222
+ className="input input-sm flex-1"
89
223
  />
90
- </div>
91
-
92
- <div className="w-24 text-right font-medium text-gray-900">
93
- {formatPrice(line.linePriceWithTax)}
94
- </div>
224
+ <button
225
+ type="submit"
226
+ className="btn btn-sm"
227
+ disabled={pending}
228
+ data-fc-track="cart-apply-coupon"
229
+ >
230
+ Apply
231
+ </button>
232
+ </form>
95
233
 
96
- <button
97
- type="button"
98
- onClick={() => remove(line.id)}
99
- disabled={pending}
100
- className="text-sm text-gray-400 hover:text-red-600 disabled:opacity-50"
101
- aria-label={`Remove ${line.productVariant.name}`}
102
- >
103
- Remove
104
- </button>
105
- </li>
106
- ))}
107
- </ul>
108
-
109
- {channelPlans.length > 0 && (
110
- <div className="space-y-2 rounded-lg border border-gray-200 bg-white p-4">
111
- <h2 className="text-sm font-medium text-gray-700">Subscribe to your whole order</h2>
112
- <div className="flex flex-col gap-2">
113
- <button
114
- type="button"
115
- onClick={() => setSellingPlan(null)}
116
- disabled={pending}
117
- aria-pressed={activePlanId === null}
118
- className={`rounded-md border px-3 py-2 text-left text-sm transition disabled:opacity-50 ${
119
- activePlanId === null
120
- ? 'border-gray-900 ring-1 ring-gray-900'
121
- : 'border-gray-300 hover:border-gray-900'
122
- }`}
123
- >
124
- <span className="font-medium text-gray-900">No, one-time</span>
125
- </button>
126
- {channelPlans.map((plan) => {
127
- const savings = getPlanSavingsLabel(plan);
128
- const hint = [savings, plan.trialDays > 0 ? `${plan.trialDays}-day free trial` : null]
129
- .filter(Boolean)
130
- .join(' · ');
131
- const active = activePlanId === plan.id;
132
- return (
133
- <button
134
- key={plan.id}
135
- type="button"
136
- onClick={() => setSellingPlan(plan.id)}
137
- disabled={pending}
138
- data-fc-track="cart-subscribe-order"
139
- aria-pressed={active}
140
- className={`rounded-md border px-3 py-2 text-left text-sm transition disabled:opacity-50 ${
141
- active
142
- ? 'border-gray-900 ring-1 ring-gray-900'
143
- : 'border-gray-300 hover:border-gray-900'
144
- }`}
145
- >
146
- <span className="block font-medium text-gray-900">
147
- Subscribe — {getPlanCadenceLabel(plan)}
234
+ {(cart?.couponCodes.length ?? 0) > 0 && (
235
+ <div className="flex flex-wrap gap-2">
236
+ {cart!.couponCodes.map((code) => (
237
+ <span key={code} className="badge badge-secondary gap-1">
238
+ {code}
239
+ <button
240
+ type="button"
241
+ onClick={() => removeCoupon(code)}
242
+ disabled={pending}
243
+ aria-label={`Remove coupon ${code}`}
244
+ >
245
+
246
+ </button>
148
247
  </span>
149
- {hint && <span className="block text-xs text-gray-500">{hint}</span>}
248
+ ))}
249
+ </div>
250
+ )}
251
+
252
+ {error && (
253
+ <div role="alert" className="alert alert-error text-sm">
254
+ <span>{error.message ?? error.code}</span>
255
+ <button type="button" className="btn btn-ghost btn-xs" onClick={clearError}>
256
+ Dismiss
150
257
  </button>
151
- );
152
- })}
153
- </div>
154
- </div>
155
- )}
258
+ </div>
259
+ )}
156
260
 
157
- <div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4">
158
- <span className="text-sm text-gray-600">Subtotal ({itemCount} items)</span>
159
- <span className="text-lg font-semibold text-gray-900">{formatPrice(subtotal)}</span>
261
+ <div className="space-y-1 border-t border-base-300 pt-3">
262
+ <div className="flex items-center justify-between">
263
+ <span className="text-sm text-base-content/60">Subtotal ({itemCount} items)</span>
264
+ <span className="font-medium tabular-nums">{formatPrice(subtotal)}</span>
265
+ </div>
266
+ {(cart?.discounts ?? []).map((discount) => (
267
+ <div key={discount.description} className="flex items-center justify-between">
268
+ <span className="text-sm text-base-content/60">{discount.description}</span>
269
+ <span className="text-sm tabular-nums">
270
+ −{formatPrice(Math.abs(discount.amountWithTax))}
271
+ </span>
272
+ </div>
273
+ ))}
274
+ <div className="flex items-center justify-between pt-1">
275
+ <span className="text-sm text-base-content/60">Total</span>
276
+ <span className="text-lg font-semibold tabular-nums">
277
+ {formatPrice(cart?.totalWithTax ?? subtotal)}
278
+ </span>
279
+ </div>
280
+ </div>
281
+
282
+ <Link href="/checkout" data-fc-track="cart-checkout" className="btn btn-primary w-full">
283
+ Checkout
284
+ </Link>
285
+ </div>
286
+ </aside>
160
287
  </div>
161
288
  </div>
162
289
  );