@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.
- 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 +30 -12
- 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 +131 -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/track-forward.ts +14 -1
- 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
|
);
|