@forgecart/cli 2.202608121449.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.
- 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 +23 -11
- 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 +40 -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/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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
<
|
|
12
|
-
<h1 className="text-
|
|
13
|
-
<span className="text-sm text-
|
|
14
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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 {
|
|
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-
|
|
33
|
-
<p className="text-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
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-
|
|
51
|
-
<h1 className="text-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
<
|
|
78
|
-
|
|
79
|
-
|
|
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=
|
|
83
|
-
type="
|
|
84
|
-
|
|
85
|
-
|
|
216
|
+
id="coupon-code"
|
|
217
|
+
type="text"
|
|
218
|
+
value={couponInput}
|
|
219
|
+
placeholder="Coupon code"
|
|
86
220
|
disabled={pending}
|
|
87
|
-
onChange={(e) =>
|
|
88
|
-
className="
|
|
221
|
+
onChange={(e) => setCouponInput(e.target.value)}
|
|
222
|
+
className="input input-sm flex-1"
|
|
89
223
|
/>
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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-
|
|
24
|
-
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-
|
|
25
|
-
<Link href="/" className="text-lg font-bold tracking-tight
|
|
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-
|
|
29
|
-
<Link href="/" className="
|
|
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="
|
|
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="
|
|
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="
|
|
39
|
-
{itemCount}
|
|
40
|
-
</span>
|
|
38
|
+
<span className="badge badge-primary badge-sm font-semibold">{itemCount}</span>
|
|
41
39
|
)}
|
|
42
40
|
</Link>
|
|
43
41
|
</nav>
|