@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,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
  );
@@ -3,6 +3,8 @@
3
3
  import { usePathname } from 'next/navigation';
4
4
  import { useEffect } from 'react';
5
5
 
6
+ import { mintUuid } from '../lib/uuid';
7
+
6
8
  /**
7
9
  * Storefront event tracker (design doc S7). Renders nothing.
8
10
  *
@@ -28,12 +30,14 @@ import { useEffect } from 'react';
28
30
  * one on refocus) — liveness plumbing only, excluded from aggregates.
29
31
  *
30
32
  * Delivery: events buffer and flush as one batch at {@link FLUSH_MAX_EVENTS}
31
- * events or after {@link FLUSH_INTERVAL_MS} via `fetch(keepalive)`;
32
- * `sendBeacon` is used ONLY for the `pagehide` flush (its unload-time queueing
33
- * guarantee is the one thing keepalive fetch lacks). Every event carries a
34
- * client-minted `eventId` (`crypto.randomUUID`) the server dedupes on it, so
35
- * an accidental double-send is harmless. Delivery is fire-and-forget: failures
36
- * are swallowed and never retried; analytics must never break the storefront.
33
+ * events or after an age timer via `fetch(keepalive)` — the FIRST batch waits
34
+ * {@link ESTABLISHMENT_GRACE_MS}, every later batch {@link FLUSH_INTERVAL_MS}
35
+ * (see the grace rationale on the constant); `sendBeacon` is used ONLY for the
36
+ * `pagehide` flush (its unload-time queueing guarantee is the one thing
37
+ * keepalive fetch lacks). Every event carries a client-minted `eventId`
38
+ * (`crypto.randomUUID`) the server dedupes on it, so an accidental
39
+ * double-send is harmless. Delivery is fire-and-forget: failures are
40
+ * swallowed and never retried; analytics must never break the storefront.
37
41
  *
38
42
  * Deliberately NO 30-second same-path dedup (the old `ForgeAnalytics`
39
43
  * behavior): it was live-map-correct but funnel-lossy — A→B→A inside 30s
@@ -54,6 +58,19 @@ const HEARTBEAT_MIN_SPACING_MS = 30_000;
54
58
  /** Batch flush thresholds: whichever of size / age is hit first sends. */
55
59
  const FLUSH_MAX_EVENTS = 10;
56
60
  const FLUSH_INTERVAL_MS = 500;
61
+ /**
62
+ * Age timer for the FIRST batch only. On a cookie-less first touch the relay
63
+ * and the first cart Server Action race to mint the shopper session, and the
64
+ * browser keeps whichever `Set-Cookie` lands last — if the tracker's ~500ms
65
+ * page_view batch wins that race against a fast add-to-cart, the cookie flips
66
+ * away from the session holding the just-created cart. Holding the initial
67
+ * flush for this grace lets the cart's cookie land first, so the relay batch
68
+ * then CARRIES it and rides the cart's session instead of minting a rival
69
+ * (design doc S7: the first tracked interaction establishes the identity the
70
+ * cart later reuses — establishment still happens, just not mid-race).
71
+ * Events are timestamped at enqueue (`occurredAt`), so deferral loses nothing.
72
+ */
73
+ const ESTABLISHMENT_GRACE_MS = 2_000;
57
74
  /** Same-signal suppression window (per click slug; per page_view path). */
58
75
  const SUPPRESS_MS = 300;
59
76
  const TRACK_ENDPOINT = '/__fc/track';
@@ -71,6 +88,8 @@ interface TrackedEvent {
71
88
  // exactly one tracker per page.
72
89
  let buffer: TrackedEvent[] = [];
73
90
  let flushTimer: number | null = null;
91
+ /** True once any batch has gone out — ends the establishment grace. */
92
+ let firstBatchSent = false;
74
93
  const lastClickAtBySlug = new Map<string, number>();
75
94
  let lastPageView: { path: string; at: number } | null = null;
76
95
  let previousPathname: string | null = null;
@@ -87,19 +106,12 @@ function hasTrackingConsent(): boolean {
87
106
  }
88
107
 
89
108
  /**
90
- * Client-side idempotency key: `crypto.randomUUID` where available; on the one
91
- * environment it isn't (insecure-context LAN-IP dev hosts the API is
92
- * secure-context gated, `getRandomValues` is not) mint the same v4 shape by
93
- * hand, because a thrown TypeError inside a click listener would violate the
94
- * "analytics never breaks the storefront" invariant.
109
+ * Client-side idempotency key via the shared insecure-context-safe minter
110
+ * (lib/uuid.ts) a thrown TypeError inside a click listener would violate
111
+ * the "analytics never breaks the storefront" invariant.
95
112
  */
96
113
  function mintEventId(): string {
97
- if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
98
- const bytes = crypto.getRandomValues(new Uint8Array(16));
99
- bytes[6] = (bytes[6] & 0x0f) | 0x40;
100
- bytes[8] = (bytes[8] & 0x3f) | 0x80;
101
- const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
102
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
114
+ return mintUuid();
103
115
  }
104
116
 
105
117
  /** Stamp and buffer one event; flush on size, else arm the age timer. */
@@ -118,7 +130,8 @@ function enqueue(eventType: string, properties?: Record<string, unknown>): void
118
130
  return;
119
131
  }
120
132
  if (flushTimer === null) {
121
- flushTimer = window.setTimeout(flush, FLUSH_INTERVAL_MS);
133
+ const delay = firstBatchSent ? FLUSH_INTERVAL_MS : ESTABLISHMENT_GRACE_MS;
134
+ flushTimer = window.setTimeout(flush, delay);
122
135
  }
123
136
  }
124
137
 
@@ -130,6 +143,9 @@ function drain(): TrackedEvent[] {
130
143
  }
131
144
  const events = buffer;
132
145
  buffer = [];
146
+ // The grace ends once a batch actually goes out — from then on the age
147
+ // timer runs at the normal cadence.
148
+ if (events.length > 0) firstBatchSent = true;
133
149
  return events;
134
150
  }
135
151
 
@@ -166,15 +182,13 @@ function flushOnPagehide(): void {
166
182
  }).catch(() => undefined);
167
183
  }
168
184
 
169
- export function ForgeTracker({
170
- shopApiUrl,
171
- channelToken,
172
- }: {
173
- shopApiUrl: string;
174
- channelToken: string;
175
- }) {
185
+ /**
186
+ * `enabled` is computed SERVER-side (env presence) and passed as a boolean —
187
+ * the tracker only needs to know whether the relay is configured; the channel
188
+ * token itself must never serialize into the RSC payload.
189
+ */
190
+ export function ForgeTracker({ enabled }: { enabled: boolean }) {
176
191
  const pathname = usePathname();
177
- const enabled = Boolean(shopApiUrl && channelToken);
178
192
 
179
193
  // page_view — initial load + every route change.
180
194
  useEffect(() => {
@@ -20,24 +20,22 @@ export function Header() {
20
20
  }, []);
21
21
 
22
22
  return (
23
- <header className="border-b border-gray-200 bg-white">
24
- <div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
25
- <Link href="/" className="text-lg font-bold tracking-tight text-gray-900">
23
+ <header className="sticky top-0 z-30 border-b border-base-300 bg-base-100">
24
+ <div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
25
+ <Link href="/" className="text-lg font-bold tracking-tight">
26
26
  Storefront
27
27
  </Link>
28
- <nav className="flex items-center gap-6 text-sm font-medium text-gray-600">
29
- <Link href="/" className="hover:text-gray-900">
28
+ <nav aria-label="Store" className="flex items-center gap-1 text-sm font-medium">
29
+ <Link href="/" className="btn btn-ghost btn-sm">
30
30
  Home
31
31
  </Link>
32
- <Link href="/products" className="hover:text-gray-900">
32
+ <Link href="/products" className="btn btn-ghost btn-sm">
33
33
  Products
34
34
  </Link>
35
- <Link href="/cart" data-fc-track="header-cart" className="relative hover:text-gray-900">
35
+ <Link href="/cart" data-fc-track="header-cart" className="btn btn-ghost btn-sm">
36
36
  Cart
37
37
  {mounted && itemCount > 0 && (
38
- <span className="ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-gray-900 px-1.5 text-xs font-semibold text-white">
39
- {itemCount}
40
- </span>
38
+ <span className="badge badge-primary badge-sm font-semibold">{itemCount}</span>
41
39
  )}
42
40
  </Link>
43
41
  </nav>