@forgecart/cli 2.202607061936.0 → 2.202607070306.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/package.json +1 -1
- package/templates/storefront/src/app/cart/page.tsx +9 -84
- package/templates/storefront/src/app/products/[slug]/page.tsx +22 -3
- package/templates/storefront/src/components/CartView.tsx +161 -0
- package/templates/storefront/src/components/ProductCard.tsx +1 -2
- package/templates/storefront/src/components/ProductPurchase.tsx +153 -8
- package/templates/storefront/src/lib/cart-actions.ts +95 -112
- package/templates/storefront/src/lib/cart-context.tsx +18 -5
- package/templates/storefront/src/lib/forgecart.ts +94 -91
package/package.json
CHANGED
|
@@ -1,88 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
import { CartView } from '../../components/CartView';
|
|
2
|
+
import { getChannelSellingPlanGroups } from '../../lib/forgecart';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
+
export const dynamic = 'force-dynamic';
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
6
|
+
export default async function CartPage() {
|
|
7
|
+
// Channel-wide subscription plans are fetched server-side (the shop client is
|
|
8
|
+
// server-only) and handed to the client cart view, which renders the
|
|
9
|
+
// "Subscribe to your whole order" box only when there are any.
|
|
10
|
+
const channelGroups = await getChannelSellingPlanGroups();
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
const { cart, itemCount, subtotal, setQuantity, remove, pending } = useCart();
|
|
10
|
-
const lines = cart?.lines ?? [];
|
|
11
|
-
|
|
12
|
-
if (itemCount === 0) {
|
|
13
|
-
return (
|
|
14
|
-
<div className="space-y-4">
|
|
15
|
-
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Your cart</h1>
|
|
16
|
-
<p className="text-gray-500">Your cart is empty.</p>
|
|
17
|
-
<Link
|
|
18
|
-
href="/products"
|
|
19
|
-
className="inline-block rounded-md bg-gray-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-gray-700"
|
|
20
|
-
>
|
|
21
|
-
Browse products
|
|
22
|
-
</Link>
|
|
23
|
-
</div>
|
|
24
|
-
);
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return (
|
|
28
|
-
<div className="space-y-6">
|
|
29
|
-
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Your cart</h1>
|
|
30
|
-
|
|
31
|
-
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
|
|
32
|
-
{lines.map((line) => (
|
|
33
|
-
<li key={line.id} className="flex items-center gap-4 p-4">
|
|
34
|
-
<div className="h-16 w-16 shrink-0 overflow-hidden rounded bg-gray-100">
|
|
35
|
-
{line.featuredAsset?.preview ? (
|
|
36
|
-
// eslint-disable-next-line @next/next/no-img-element
|
|
37
|
-
<img
|
|
38
|
-
src={line.featuredAsset.preview}
|
|
39
|
-
alt={line.productVariant.name}
|
|
40
|
-
className="h-full w-full object-cover"
|
|
41
|
-
/>
|
|
42
|
-
) : null}
|
|
43
|
-
</div>
|
|
44
|
-
|
|
45
|
-
<div className="min-w-0 flex-1">
|
|
46
|
-
<p className="truncate font-medium text-gray-900">{line.productVariant.name}</p>
|
|
47
|
-
<p className="text-sm text-gray-500">{formatPrice(line.unitPriceWithTax)} each</p>
|
|
48
|
-
</div>
|
|
49
|
-
|
|
50
|
-
<div className="flex items-center gap-2">
|
|
51
|
-
<label className="sr-only" htmlFor={`qty-${line.id}`}>
|
|
52
|
-
Quantity
|
|
53
|
-
</label>
|
|
54
|
-
<input
|
|
55
|
-
id={`qty-${line.id}`}
|
|
56
|
-
type="number"
|
|
57
|
-
min={1}
|
|
58
|
-
value={line.quantity}
|
|
59
|
-
disabled={pending}
|
|
60
|
-
onChange={(e) => setQuantity(line.id, Number.parseInt(e.target.value, 10) || 1)}
|
|
61
|
-
className="w-16 rounded border border-gray-300 px-2 py-1 text-sm disabled:opacity-50"
|
|
62
|
-
/>
|
|
63
|
-
</div>
|
|
64
|
-
|
|
65
|
-
<div className="w-24 text-right font-medium text-gray-900">
|
|
66
|
-
{formatPrice(line.linePriceWithTax)}
|
|
67
|
-
</div>
|
|
68
|
-
|
|
69
|
-
<button
|
|
70
|
-
type="button"
|
|
71
|
-
onClick={() => remove(line.id)}
|
|
72
|
-
disabled={pending}
|
|
73
|
-
className="text-sm text-gray-400 hover:text-red-600 disabled:opacity-50"
|
|
74
|
-
aria-label={`Remove ${line.productVariant.name}`}
|
|
75
|
-
>
|
|
76
|
-
Remove
|
|
77
|
-
</button>
|
|
78
|
-
</li>
|
|
79
|
-
))}
|
|
80
|
-
</ul>
|
|
81
|
-
|
|
82
|
-
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4">
|
|
83
|
-
<span className="text-sm text-gray-600">Subtotal ({itemCount} items)</span>
|
|
84
|
-
<span className="text-lg font-semibold text-gray-900">{formatPrice(subtotal)}</span>
|
|
85
|
-
</div>
|
|
86
|
-
</div>
|
|
87
|
-
);
|
|
12
|
+
return <CartView channelGroups={channelGroups} />;
|
|
88
13
|
}
|
|
@@ -2,7 +2,11 @@ import Link from 'next/link';
|
|
|
2
2
|
import { notFound } from 'next/navigation';
|
|
3
3
|
|
|
4
4
|
import { ProductPurchase } from '../../../components/ProductPurchase';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
getProductBySlug,
|
|
7
|
+
getSellingPlanGroupsForVariant,
|
|
8
|
+
type SellingPlanGroup,
|
|
9
|
+
} from '../../../lib/forgecart';
|
|
6
10
|
|
|
7
11
|
export const dynamic = 'force-dynamic';
|
|
8
12
|
|
|
@@ -19,6 +23,21 @@ export default async function ProductDetailPage({
|
|
|
19
23
|
notFound();
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
// Subscription selling plans are eligible per variant, so fetch the groups for
|
|
27
|
+
// each of the product's variants (in parallel) and pass a variantId -> groups
|
|
28
|
+
// map to the client purchase control. Variants with no plans get the plain
|
|
29
|
+
// one-time add-to-cart path.
|
|
30
|
+
const planGroupsByVariant: Record<string, SellingPlanGroup[]> = Object.fromEntries(
|
|
31
|
+
await Promise.all(
|
|
32
|
+
product.variants.map(
|
|
33
|
+
async (variant): Promise<[string, SellingPlanGroup[]]> => [
|
|
34
|
+
variant.id,
|
|
35
|
+
await getSellingPlanGroupsForVariant(variant.id),
|
|
36
|
+
],
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
);
|
|
40
|
+
|
|
22
41
|
return (
|
|
23
42
|
<div className="space-y-6">
|
|
24
43
|
<Link href="/products" className="text-sm text-gray-600 hover:text-gray-900">
|
|
@@ -52,8 +71,8 @@ export default async function ProductDetailPage({
|
|
|
52
71
|
/>
|
|
53
72
|
)}
|
|
54
73
|
|
|
55
|
-
{/* Pick a variant (Size/Color) -> price + add-to-cart. */}
|
|
56
|
-
<ProductPurchase product={product} />
|
|
74
|
+
{/* Pick a variant (Size/Color) -> price + purchase options + add-to-cart. */}
|
|
75
|
+
<ProductPurchase product={product} planGroupsByVariant={planGroupsByVariant} />
|
|
57
76
|
</div>
|
|
58
77
|
</div>
|
|
59
78
|
</div>
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import Link from 'next/link';
|
|
4
|
+
|
|
5
|
+
import { useCart } from '../lib/cart-context';
|
|
6
|
+
import {
|
|
7
|
+
formatPrice,
|
|
8
|
+
getPlanCadenceLabel,
|
|
9
|
+
getPlanSavingsLabel,
|
|
10
|
+
type SellingPlan,
|
|
11
|
+
type SellingPlanGroup,
|
|
12
|
+
} from '../lib/forgecart';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The interactive cart, rendered off the live order from `useCart()`.
|
|
16
|
+
*
|
|
17
|
+
* `channelGroups` are the channel-wide subscription selling plans, fetched
|
|
18
|
+
* server-side by the cart route and passed in (the shop client is server-only,
|
|
19
|
+
* so a client component can't fetch them itself). When present, a "Subscribe to
|
|
20
|
+
* your whole order" box lets the customer put the entire order on a recurring
|
|
21
|
+
* plan; the chosen plan is reflected by the order's `sellingPlanId`. Lines that
|
|
22
|
+
* carry their own `sellingPlanId` (per-line subscriptions added from a product
|
|
23
|
+
* page) get a small "Subscription" label.
|
|
24
|
+
*/
|
|
25
|
+
export function CartView({ channelGroups }: { channelGroups: SellingPlanGroup[] }) {
|
|
26
|
+
const { cart, itemCount, subtotal, setQuantity, remove, setSellingPlan, pending } = useCart();
|
|
27
|
+
const lines = cart?.lines ?? [];
|
|
28
|
+
|
|
29
|
+
if (itemCount === 0) {
|
|
30
|
+
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
|
+
className="inline-block rounded-md bg-gray-900 px-5 py-2.5 text-sm font-medium text-white transition hover:bg-gray-700"
|
|
37
|
+
>
|
|
38
|
+
Browse products
|
|
39
|
+
</Link>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Channel-wide plans (enabled only), flattened across the channel groups.
|
|
45
|
+
const channelPlans: SellingPlan[] = channelGroups.flatMap((g) => g.plans).filter((p) => p.enabled);
|
|
46
|
+
const activePlanId = cart?.sellingPlanId ?? null;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="space-y-6">
|
|
50
|
+
<h1 className="text-2xl font-bold tracking-tight text-gray-900">Your cart</h1>
|
|
51
|
+
|
|
52
|
+
<ul className="divide-y divide-gray-200 rounded-lg border border-gray-200 bg-white">
|
|
53
|
+
{lines.map((line) => (
|
|
54
|
+
<li key={line.id} className="flex items-center gap-4 p-4">
|
|
55
|
+
<div className="h-16 w-16 shrink-0 overflow-hidden rounded bg-gray-100">
|
|
56
|
+
{line.featuredAsset?.preview ? (
|
|
57
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
58
|
+
<img
|
|
59
|
+
src={line.featuredAsset.preview}
|
|
60
|
+
alt={line.productVariant.name}
|
|
61
|
+
className="h-full w-full object-cover"
|
|
62
|
+
/>
|
|
63
|
+
) : null}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div className="min-w-0 flex-1">
|
|
67
|
+
<p className="truncate font-medium text-gray-900">{line.productVariant.name}</p>
|
|
68
|
+
<p className="text-sm text-gray-500">{formatPrice(line.unitPriceWithTax)} each</p>
|
|
69
|
+
{line.sellingPlanId && (
|
|
70
|
+
<span className="mt-1 inline-block rounded bg-gray-900 px-1.5 py-0.5 text-xs font-medium text-white">
|
|
71
|
+
Subscription
|
|
72
|
+
</span>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div className="flex items-center gap-2">
|
|
77
|
+
<label className="sr-only" htmlFor={`qty-${line.id}`}>
|
|
78
|
+
Quantity
|
|
79
|
+
</label>
|
|
80
|
+
<input
|
|
81
|
+
id={`qty-${line.id}`}
|
|
82
|
+
type="number"
|
|
83
|
+
min={1}
|
|
84
|
+
value={line.quantity}
|
|
85
|
+
disabled={pending}
|
|
86
|
+
onChange={(e) => setQuantity(line.id, Number.parseInt(e.target.value, 10) || 1)}
|
|
87
|
+
className="w-16 rounded border border-gray-300 px-2 py-1 text-sm disabled:opacity-50"
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="w-24 text-right font-medium text-gray-900">
|
|
92
|
+
{formatPrice(line.linePriceWithTax)}
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
onClick={() => remove(line.id)}
|
|
98
|
+
disabled={pending}
|
|
99
|
+
className="text-sm text-gray-400 hover:text-red-600 disabled:opacity-50"
|
|
100
|
+
aria-label={`Remove ${line.productVariant.name}`}
|
|
101
|
+
>
|
|
102
|
+
Remove
|
|
103
|
+
</button>
|
|
104
|
+
</li>
|
|
105
|
+
))}
|
|
106
|
+
</ul>
|
|
107
|
+
|
|
108
|
+
{channelPlans.length > 0 && (
|
|
109
|
+
<div className="space-y-2 rounded-lg border border-gray-200 bg-white p-4">
|
|
110
|
+
<h2 className="text-sm font-medium text-gray-700">Subscribe to your whole order</h2>
|
|
111
|
+
<div className="flex flex-col gap-2">
|
|
112
|
+
<button
|
|
113
|
+
type="button"
|
|
114
|
+
onClick={() => setSellingPlan(null)}
|
|
115
|
+
disabled={pending}
|
|
116
|
+
aria-pressed={activePlanId === null}
|
|
117
|
+
className={`rounded-md border px-3 py-2 text-left text-sm transition disabled:opacity-50 ${
|
|
118
|
+
activePlanId === null
|
|
119
|
+
? 'border-gray-900 ring-1 ring-gray-900'
|
|
120
|
+
: 'border-gray-300 hover:border-gray-900'
|
|
121
|
+
}`}
|
|
122
|
+
>
|
|
123
|
+
<span className="font-medium text-gray-900">No, one-time</span>
|
|
124
|
+
</button>
|
|
125
|
+
{channelPlans.map((plan) => {
|
|
126
|
+
const savings = getPlanSavingsLabel(plan);
|
|
127
|
+
const hint = [savings, plan.trialDays > 0 ? `${plan.trialDays}-day free trial` : null]
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.join(' · ');
|
|
130
|
+
const active = activePlanId === plan.id;
|
|
131
|
+
return (
|
|
132
|
+
<button
|
|
133
|
+
key={plan.id}
|
|
134
|
+
type="button"
|
|
135
|
+
onClick={() => setSellingPlan(plan.id)}
|
|
136
|
+
disabled={pending}
|
|
137
|
+
aria-pressed={active}
|
|
138
|
+
className={`rounded-md border px-3 py-2 text-left text-sm transition disabled:opacity-50 ${
|
|
139
|
+
active
|
|
140
|
+
? 'border-gray-900 ring-1 ring-gray-900'
|
|
141
|
+
: 'border-gray-300 hover:border-gray-900'
|
|
142
|
+
}`}
|
|
143
|
+
>
|
|
144
|
+
<span className="block font-medium text-gray-900">
|
|
145
|
+
Subscribe — {getPlanCadenceLabel(plan)}
|
|
146
|
+
</span>
|
|
147
|
+
{hint && <span className="block text-xs text-gray-500">{hint}</span>}
|
|
148
|
+
</button>
|
|
149
|
+
);
|
|
150
|
+
})}
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
|
|
155
|
+
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-white p-4">
|
|
156
|
+
<span className="text-sm text-gray-600">Subtotal ({itemCount} items)</span>
|
|
157
|
+
<span className="text-lg font-semibold text-gray-900">{formatPrice(subtotal)}</span>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import type { Product } from '@forgecart/sdk/shop';
|
|
2
1
|
import Link from 'next/link';
|
|
3
2
|
|
|
4
|
-
import { formatPrice, getStartingPrice } from '../lib/forgecart';
|
|
3
|
+
import { formatPrice, getStartingPrice, type Product } from '../lib/forgecart';
|
|
5
4
|
|
|
6
5
|
/** A product tile linking to its detail page. Used by the home and grid pages. */
|
|
7
6
|
export function ProductCard({ product }: { product: Product }) {
|
|
@@ -1,19 +1,40 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import
|
|
4
|
-
import { useMemo, useState } from 'react';
|
|
3
|
+
import { useEffect, useMemo, useState } from 'react';
|
|
5
4
|
|
|
6
5
|
import { useCart } from '../lib/cart-context';
|
|
7
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
formatPrice,
|
|
8
|
+
getPlanCadenceLabel,
|
|
9
|
+
getPlanPreviewPrice,
|
|
10
|
+
getPlanSavingsLabel,
|
|
11
|
+
type Product,
|
|
12
|
+
type ProductVariant,
|
|
13
|
+
type SellingPlan,
|
|
14
|
+
type SellingPlanGroup,
|
|
15
|
+
} from '../lib/forgecart';
|
|
8
16
|
|
|
9
17
|
/**
|
|
10
|
-
* Variant picker + add-to-cart for the product detail page.
|
|
18
|
+
* Variant picker + purchase-options + add-to-cart for the product detail page.
|
|
11
19
|
*
|
|
12
20
|
* The customer picks one value per option group (Size, Color, …); the matching
|
|
13
21
|
* variant drives the displayed price and what gets added to the cart — you add a
|
|
14
22
|
* **variant**, never the product. Single-variant products skip the selectors.
|
|
23
|
+
*
|
|
24
|
+
* When the selected variant has subscription selling plans (passed in as
|
|
25
|
+
* `planGroupsByVariant`, fetched server-side by the page), a "Purchase options"
|
|
26
|
+
* control appears above Add-to-cart: a one-time purchase plus one option per
|
|
27
|
+
* plan, each with a price preview and a savings/trial hint. The chosen plan id
|
|
28
|
+
* (or `undefined` for one-time) flows into the cart on Add. Variants with no
|
|
29
|
+
* plans keep the plain one-time add-to-cart path unchanged.
|
|
15
30
|
*/
|
|
16
|
-
export function ProductPurchase({
|
|
31
|
+
export function ProductPurchase({
|
|
32
|
+
product,
|
|
33
|
+
planGroupsByVariant = {},
|
|
34
|
+
}: {
|
|
35
|
+
product: Product;
|
|
36
|
+
planGroupsByVariant?: Record<string, SellingPlanGroup[]>;
|
|
37
|
+
}) {
|
|
17
38
|
const { add, pending } = useCart();
|
|
18
39
|
const [added, setAdded] = useState(false);
|
|
19
40
|
|
|
@@ -36,19 +57,77 @@ export function ProductPurchase({ product }: { product: Product }) {
|
|
|
36
57
|
);
|
|
37
58
|
}, [product.variants, selected]);
|
|
38
59
|
|
|
60
|
+
// The selling-plan groups the *selected* variant is eligible for.
|
|
61
|
+
const groups: SellingPlanGroup[] = useMemo(
|
|
62
|
+
() => (variant ? (planGroupsByVariant[variant.id] ?? []) : []),
|
|
63
|
+
[planGroupsByVariant, variant],
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
// The selectable plans (enabled only), flattened across applicable groups.
|
|
67
|
+
const plans: SellingPlan[] = useMemo(
|
|
68
|
+
() => groups.flatMap((g) => g.plans).filter((p) => p.enabled),
|
|
69
|
+
[groups],
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// One-time is offered unless EVERY applicable group forbids it (subscription-only).
|
|
73
|
+
const oneTimeAllowed = groups.length === 0 || groups.some((g) => g.oneTimePurchaseAllowed);
|
|
74
|
+
const hasPurchaseOptions = plans.length > 0;
|
|
75
|
+
|
|
76
|
+
// `null` = one-time purchase; otherwise the chosen plan id.
|
|
77
|
+
const [selectedPlanId, setSelectedPlanId] = useState<string | null>(null);
|
|
78
|
+
|
|
79
|
+
// The set of valid choices changes with the variant (and thus its plans). Re-
|
|
80
|
+
// normalize the selection whenever that signature changes: keep a still-valid
|
|
81
|
+
// choice, otherwise default to one-time when allowed, else the first plan —
|
|
82
|
+
// never leave a stale id that is no longer offered for the current variant.
|
|
83
|
+
// `planIds` is a stable, primitive signature of the current plan set, so the
|
|
84
|
+
// effect re-runs exactly when the options (or the one-time gate) change and
|
|
85
|
+
// closes over no array identities.
|
|
86
|
+
const planIds = plans.map((p) => p.id).join(',');
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
const ids = planIds ? planIds.split(',') : [];
|
|
89
|
+
const validIds = new Set(ids);
|
|
90
|
+
setSelectedPlanId((current) => {
|
|
91
|
+
if (current !== null && validIds.has(current)) {
|
|
92
|
+
return current;
|
|
93
|
+
}
|
|
94
|
+
if (oneTimeAllowed) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
return ids[0] ?? null;
|
|
98
|
+
});
|
|
99
|
+
}, [planIds, oneTimeAllowed]);
|
|
100
|
+
|
|
101
|
+
const selectedPlan: SellingPlan | undefined = useMemo(
|
|
102
|
+
() => plans.find((p) => p.id === selectedPlanId),
|
|
103
|
+
[plans, selectedPlanId],
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// Price preview for the chosen option: one-time shows the variant price; a
|
|
107
|
+
// plan shows its policy-adjusted per-unit price.
|
|
108
|
+
const previewPrice = useMemo(() => {
|
|
109
|
+
if (!variant) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
if (!selectedPlan) {
|
|
113
|
+
return variant.priceWithTax;
|
|
114
|
+
}
|
|
115
|
+
return getPlanPreviewPrice(variant.priceWithTax, selectedPlan);
|
|
116
|
+
}, [variant, selectedPlan]);
|
|
117
|
+
|
|
39
118
|
const onAdd = () => {
|
|
40
119
|
if (!variant) {
|
|
41
120
|
return;
|
|
42
121
|
}
|
|
43
|
-
add(variant.id, 1);
|
|
122
|
+
add(variant.id, 1, selectedPlanId ?? undefined);
|
|
44
123
|
setAdded(true);
|
|
45
124
|
window.setTimeout(() => setAdded(false), 1500);
|
|
46
125
|
};
|
|
47
126
|
|
|
48
127
|
return (
|
|
49
128
|
<div className="space-y-4">
|
|
50
|
-
{variant ? (
|
|
51
|
-
<p className="text-xl font-semibold text-gray-900">{formatPrice(
|
|
129
|
+
{variant && previewPrice !== null ? (
|
|
130
|
+
<p className="text-xl font-semibold text-gray-900">{formatPrice(previewPrice)}</p>
|
|
52
131
|
) : (
|
|
53
132
|
<p className="text-sm text-gray-500">Select options to see the price.</p>
|
|
54
133
|
)}
|
|
@@ -78,6 +157,38 @@ export function ProductPurchase({ product }: { product: Product }) {
|
|
|
78
157
|
</div>
|
|
79
158
|
))}
|
|
80
159
|
|
|
160
|
+
{variant && hasPurchaseOptions && (
|
|
161
|
+
<div>
|
|
162
|
+
<h3 className="mb-1 text-sm font-medium text-gray-700">Purchase options</h3>
|
|
163
|
+
<div className="flex flex-col gap-2">
|
|
164
|
+
{oneTimeAllowed && (
|
|
165
|
+
<PurchaseOption
|
|
166
|
+
label="One-time purchase"
|
|
167
|
+
priceLabel={formatPrice(variant.priceWithTax)}
|
|
168
|
+
active={selectedPlanId === null}
|
|
169
|
+
onSelect={() => setSelectedPlanId(null)}
|
|
170
|
+
/>
|
|
171
|
+
)}
|
|
172
|
+
{plans.map((plan) => {
|
|
173
|
+
const savings = getPlanSavingsLabel(plan);
|
|
174
|
+
const hint = [savings, plan.trialDays > 0 ? `${plan.trialDays}-day free trial` : null]
|
|
175
|
+
.filter(Boolean)
|
|
176
|
+
.join(' · ');
|
|
177
|
+
return (
|
|
178
|
+
<PurchaseOption
|
|
179
|
+
key={plan.id}
|
|
180
|
+
label={`Subscribe — ${getPlanCadenceLabel(plan)}`}
|
|
181
|
+
hint={hint || undefined}
|
|
182
|
+
priceLabel={formatPrice(getPlanPreviewPrice(variant.priceWithTax, plan))}
|
|
183
|
+
active={selectedPlanId === plan.id}
|
|
184
|
+
onSelect={() => setSelectedPlanId(plan.id)}
|
|
185
|
+
/>
|
|
186
|
+
);
|
|
187
|
+
})}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
)}
|
|
191
|
+
|
|
81
192
|
<button
|
|
82
193
|
type="button"
|
|
83
194
|
onClick={onAdd}
|
|
@@ -89,3 +200,37 @@ export function ProductPurchase({ product }: { product: Product }) {
|
|
|
89
200
|
</div>
|
|
90
201
|
);
|
|
91
202
|
}
|
|
203
|
+
|
|
204
|
+
/** A single selectable purchase option (one-time or a subscription plan). */
|
|
205
|
+
function PurchaseOption({
|
|
206
|
+
label,
|
|
207
|
+
hint,
|
|
208
|
+
priceLabel,
|
|
209
|
+
active,
|
|
210
|
+
onSelect,
|
|
211
|
+
}: {
|
|
212
|
+
label: string;
|
|
213
|
+
hint?: string;
|
|
214
|
+
priceLabel: string;
|
|
215
|
+
active: boolean;
|
|
216
|
+
onSelect: () => void;
|
|
217
|
+
}) {
|
|
218
|
+
return (
|
|
219
|
+
<button
|
|
220
|
+
type="button"
|
|
221
|
+
onClick={onSelect}
|
|
222
|
+
aria-pressed={active}
|
|
223
|
+
className={`flex items-center justify-between gap-3 rounded-md border px-3 py-2 text-left text-sm transition ${
|
|
224
|
+
active
|
|
225
|
+
? 'border-gray-900 ring-1 ring-gray-900'
|
|
226
|
+
: 'border-gray-300 hover:border-gray-900'
|
|
227
|
+
}`}
|
|
228
|
+
>
|
|
229
|
+
<span className="min-w-0">
|
|
230
|
+
<span className="block font-medium text-gray-900">{label}</span>
|
|
231
|
+
{hint && <span className="block text-xs text-gray-500">{hint}</span>}
|
|
232
|
+
</span>
|
|
233
|
+
<span className="shrink-0 font-medium text-gray-900">{priceLabel}</span>
|
|
234
|
+
</button>
|
|
235
|
+
);
|
|
236
|
+
}
|
|
@@ -1,148 +1,131 @@
|
|
|
1
1
|
'use server';
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import { ForgeCartShopClient } from '@forgecart/sdk';
|
|
4
|
+
import type { ShopAddItemToOrderInput } from '@forgecart/sdk/shop';
|
|
4
5
|
import { cookies } from 'next/headers';
|
|
5
6
|
|
|
7
|
+
import type { Order } from './forgecart';
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
10
|
* Server-side cart, backed by the ForgeCart shop **order** API (the real cart),
|
|
8
|
-
* not browser storage. Every export here is a Server Action
|
|
9
|
-
*
|
|
11
|
+
* not browser storage. Every export here is a Server Action driving the SDK's
|
|
12
|
+
* generated, typed cart operations and returning the live `Order` (the SDK
|
|
13
|
+
* type) so callers render straight off it.
|
|
10
14
|
*
|
|
11
|
-
* The anonymous active order is tracked by a session token: the shop API
|
|
12
|
-
* a fresh token
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
15
|
+
* The anonymous active order is tracked by a session token: the shop API
|
|
16
|
+
* returns a fresh token (result extensions) on the first mutation, which the
|
|
17
|
+
* SDK client captures — we persist it into the httpOnly `forgecart-session`
|
|
18
|
+
* cookie and replay it as the client's auth token on every subsequent request.
|
|
19
|
+
* Session state is per shopper, so each action builds a short-lived client
|
|
20
|
+
* around its own request's cookie (queries/mutations ride plain fetch — the
|
|
21
|
+
* client only opens a websocket for subscriptions, which the cart never uses).
|
|
22
|
+
* The channel token and the session never reach the browser — all cart calls
|
|
23
|
+
* run here, on the server.
|
|
16
24
|
*/
|
|
17
25
|
|
|
18
26
|
const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
|
|
19
27
|
const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
|
|
20
28
|
const SESSION_COOKIE = 'forgecart-session';
|
|
21
29
|
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
unitPriceWithTax
|
|
33
|
-
linePriceWithTax
|
|
34
|
-
productVariant {
|
|
35
|
-
id
|
|
36
|
-
name
|
|
37
|
-
}
|
|
38
|
-
featuredAsset {
|
|
39
|
-
preview
|
|
40
|
-
}
|
|
30
|
+
/** A per-request shop client carrying this shopper's session, when one exists. */
|
|
31
|
+
async function getCartClient(): Promise<ForgeCartShopClient> {
|
|
32
|
+
const store = await cookies();
|
|
33
|
+
const session = store.get(SESSION_COOKIE)?.value;
|
|
34
|
+
const client = new ForgeCartShopClient({
|
|
35
|
+
endpoint: SHOP_API_URL,
|
|
36
|
+
channelToken: CHANNEL_TOKEN,
|
|
37
|
+
});
|
|
38
|
+
if (session) {
|
|
39
|
+
client.setAuthToken(session);
|
|
41
40
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
interface GraphQLResponse<T> {
|
|
45
|
-
data?: T;
|
|
46
|
-
errors?: { message: string }[];
|
|
41
|
+
return client;
|
|
47
42
|
}
|
|
48
43
|
|
|
49
44
|
/**
|
|
50
|
-
*
|
|
45
|
+
* Persist the session token the client captured during a mutation.
|
|
51
46
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
* mutations) can, so they capture/refresh the session token.
|
|
47
|
+
* MUST only be called from Server Actions (the mutations): Server Components
|
|
48
|
+
* cannot set cookies, which is why the read path never persists.
|
|
55
49
|
*/
|
|
56
|
-
async function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
persist: boolean,
|
|
60
|
-
): Promise<GraphQLResponse<T>> {
|
|
50
|
+
async function persistSession(client: ForgeCartShopClient): Promise<void> {
|
|
51
|
+
const token = client.getAuthToken();
|
|
52
|
+
if (!token) return;
|
|
61
53
|
const store = await cookies();
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
'
|
|
67
|
-
|
|
68
|
-
if (session) {
|
|
69
|
-
headers.authorization = `Bearer ${session}`;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
const res = await fetch(SHOP_API_URL, {
|
|
73
|
-
method: 'POST',
|
|
74
|
-
headers,
|
|
75
|
-
body: JSON.stringify({ query, variables }),
|
|
76
|
-
cache: 'no-store',
|
|
54
|
+
if (store.get(SESSION_COOKIE)?.value === token) return;
|
|
55
|
+
store.set(SESSION_COOKIE, token, {
|
|
56
|
+
httpOnly: true,
|
|
57
|
+
sameSite: 'lax',
|
|
58
|
+
path: '/',
|
|
59
|
+
maxAge: 60 * 60 * 24 * 30,
|
|
77
60
|
});
|
|
78
|
-
|
|
79
|
-
if (persist) {
|
|
80
|
-
const token = res.headers.get('forgecart-auth-token');
|
|
81
|
-
if (token && token !== session) {
|
|
82
|
-
store.set(SESSION_COOKIE, token, {
|
|
83
|
-
httpOnly: true,
|
|
84
|
-
sameSite: 'lax',
|
|
85
|
-
path: '/',
|
|
86
|
-
maxAge: 60 * 60 * 24 * 30,
|
|
87
|
-
});
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
return (await res.json()) as GraphQLResponse<T>;
|
|
92
61
|
}
|
|
93
62
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
63
|
+
/**
|
|
64
|
+
* Read the active order (cart). Safe to call from a Server Component.
|
|
65
|
+
*
|
|
66
|
+
* Before `forgecart init` writes `.env.local` (notably the image-build pre-warm,
|
|
67
|
+
* which renders `/` with no env), there is no shop to talk to — return an empty
|
|
68
|
+
* cart immediately instead of driving the client into a doomed fetch. The
|
|
69
|
+
* layout renders on every route, so this read must never stall an unconfigured
|
|
70
|
+
* scaffold.
|
|
71
|
+
*/
|
|
72
|
+
export async function getCart(): Promise<Order | null> {
|
|
73
|
+
if (!SHOP_API_URL || !CHANNEL_TOKEN) return null;
|
|
74
|
+
const client = await getCartClient();
|
|
75
|
+
const { activeOrder } = await client.order.activeOrder();
|
|
76
|
+
return activeOrder ?? null;
|
|
102
77
|
}
|
|
103
78
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Add a variant to the cart (creates the order + session on first call).
|
|
81
|
+
*
|
|
82
|
+
* Pass `sellingPlanId` to add the line as a subscription on the given plan;
|
|
83
|
+
* omit it (the default) for a one-time purchase. The id is only included in the
|
|
84
|
+
* mutation input when present, so the one-time path sends the same shape as
|
|
85
|
+
* before.
|
|
86
|
+
*/
|
|
87
|
+
export async function addToCart(
|
|
88
|
+
variantId: string,
|
|
89
|
+
quantity = 1,
|
|
90
|
+
sellingPlanId?: string,
|
|
91
|
+
): Promise<Order | null> {
|
|
92
|
+
const input: ShopAddItemToOrderInput = { variantId, quantity };
|
|
93
|
+
if (sellingPlanId) {
|
|
94
|
+
input.sellingPlanId = sellingPlanId;
|
|
95
|
+
}
|
|
96
|
+
const client = await getCartClient();
|
|
97
|
+
const { addItemToOrder } = await client.cart.addItemToOrder({ input });
|
|
98
|
+
await persistSession(client);
|
|
99
|
+
return addItemToOrder;
|
|
112
100
|
}
|
|
113
101
|
|
|
114
|
-
/**
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
102
|
+
/**
|
|
103
|
+
* Set (or clear) the subscription plan for the whole order.
|
|
104
|
+
*
|
|
105
|
+
* Pass a channel-wide selling plan id to subscribe the entire cart on that
|
|
106
|
+
* plan, or `null` to clear it back to a one-time order. Returns the live order.
|
|
107
|
+
*/
|
|
108
|
+
export async function setCartSellingPlan(sellingPlanId: string | null): Promise<Order | null> {
|
|
109
|
+
const client = await getCartClient();
|
|
110
|
+
const { setOrderSellingPlan } = await client.cart.setOrderSellingPlan({
|
|
111
|
+
input: { sellingPlanId },
|
|
112
|
+
});
|
|
113
|
+
await persistSession(client);
|
|
114
|
+
return setOrderSellingPlan;
|
|
124
115
|
}
|
|
125
116
|
|
|
126
117
|
/** Set the quantity of a cart line (0 removes it). */
|
|
127
118
|
export async function updateLine(lineId: string, quantity: number): Promise<Order | null> {
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
true,
|
|
133
|
-
),
|
|
134
|
-
);
|
|
135
|
-
return data.adjustOrderLine;
|
|
119
|
+
const client = await getCartClient();
|
|
120
|
+
const { adjustOrderLine } = await client.cart.adjustOrderLine({ input: { lineId, quantity } });
|
|
121
|
+
await persistSession(client);
|
|
122
|
+
return adjustOrderLine;
|
|
136
123
|
}
|
|
137
124
|
|
|
138
125
|
/** Remove a line from the cart. */
|
|
139
126
|
export async function removeLine(lineId: string): Promise<Order | null> {
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
true,
|
|
145
|
-
),
|
|
146
|
-
);
|
|
147
|
-
return data.removeOrderLine;
|
|
127
|
+
const client = await getCartClient();
|
|
128
|
+
const { removeOrderLine } = await client.cart.removeOrderLine({ lineId });
|
|
129
|
+
await persistSession(client);
|
|
130
|
+
return removeOrderLine;
|
|
148
131
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import type { Order } from '
|
|
3
|
+
import type { Order } from './forgecart';
|
|
4
4
|
import {
|
|
5
5
|
createContext,
|
|
6
6
|
useCallback,
|
|
@@ -11,7 +11,13 @@ import {
|
|
|
11
11
|
type ReactNode,
|
|
12
12
|
} from 'react';
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
addToCart,
|
|
16
|
+
getCart,
|
|
17
|
+
removeLine as removeLineAction,
|
|
18
|
+
setCartSellingPlan,
|
|
19
|
+
updateLine,
|
|
20
|
+
} from './cart-actions';
|
|
15
21
|
|
|
16
22
|
/**
|
|
17
23
|
* Cart state backed by the ForgeCart shop order API (see `cart-actions.ts`).
|
|
@@ -26,9 +32,10 @@ interface CartContextValue {
|
|
|
26
32
|
itemCount: number;
|
|
27
33
|
subtotal: number;
|
|
28
34
|
pending: boolean;
|
|
29
|
-
add: (variantId: string, quantity?: number) => void;
|
|
35
|
+
add: (variantId: string, quantity?: number, sellingPlanId?: string) => void;
|
|
30
36
|
setQuantity: (lineId: string, quantity: number) => void;
|
|
31
37
|
remove: (lineId: string) => void;
|
|
38
|
+
setSellingPlan: (sellingPlanId: string | null) => void;
|
|
32
39
|
refresh: () => void;
|
|
33
40
|
}
|
|
34
41
|
|
|
@@ -51,7 +58,8 @@ export function CartProvider({
|
|
|
51
58
|
}, []);
|
|
52
59
|
|
|
53
60
|
const add = useCallback(
|
|
54
|
-
(variantId: string, quantity = 1
|
|
61
|
+
(variantId: string, quantity = 1, sellingPlanId?: string) =>
|
|
62
|
+
run(() => addToCart(variantId, quantity, sellingPlanId)),
|
|
55
63
|
[run],
|
|
56
64
|
);
|
|
57
65
|
const setQuantity = useCallback(
|
|
@@ -59,6 +67,10 @@ export function CartProvider({
|
|
|
59
67
|
[run],
|
|
60
68
|
);
|
|
61
69
|
const remove = useCallback((lineId: string) => run(() => removeLineAction(lineId)), [run]);
|
|
70
|
+
const setSellingPlan = useCallback(
|
|
71
|
+
(sellingPlanId: string | null) => run(() => setCartSellingPlan(sellingPlanId)),
|
|
72
|
+
[run],
|
|
73
|
+
);
|
|
62
74
|
const refresh = useCallback(() => run(() => getCart()), [run]);
|
|
63
75
|
|
|
64
76
|
const value = useMemo<CartContextValue>(
|
|
@@ -70,9 +82,10 @@ export function CartProvider({
|
|
|
70
82
|
add,
|
|
71
83
|
setQuantity,
|
|
72
84
|
remove,
|
|
85
|
+
setSellingPlan,
|
|
73
86
|
refresh,
|
|
74
87
|
}),
|
|
75
|
-
[cart, pending, add, setQuantity, remove, refresh],
|
|
88
|
+
[cart, pending, add, setQuantity, remove, setSellingPlan, refresh],
|
|
76
89
|
);
|
|
77
90
|
|
|
78
91
|
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
import { ForgeCartShopClient } from '@forgecart/sdk';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ShopProductFieldFragment as Product,
|
|
4
|
+
ShopSellingPlanFieldFragment as SellingPlan,
|
|
5
|
+
ShopSellingPlanGroupFieldFragment as SellingPlanGroup,
|
|
6
|
+
} from '@forgecart/sdk/shop';
|
|
3
7
|
|
|
4
8
|
/**
|
|
5
9
|
* Server-side ForgeCart shop client.
|
|
6
10
|
*
|
|
7
11
|
* The storefront talks to the ForgeCart shop GraphQL API on behalf of a single
|
|
8
|
-
* channel
|
|
9
|
-
*
|
|
10
|
-
*
|
|
12
|
+
* channel through the SDK's generated, typed operations — every read here is a
|
|
13
|
+
* typed method on the client (no hand-written query documents), and every type
|
|
14
|
+
* the components render is the SDK's own (`@forgecart/sdk/shop`).
|
|
11
15
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
16
|
+
* The channel token is sent as the `forgecart-token` header on every request
|
|
17
|
+
* (the SDK adds it from `channelToken`), and the endpoint points at the
|
|
18
|
+
* channel's shop-api. Both values come from the environment (`.env.local`,
|
|
19
|
+
* written by `forgecart init`):
|
|
14
20
|
* - FORGECART_SHOP_API_URL -> endpoint
|
|
15
21
|
* - FORGECART_CHANNEL_TOKEN -> channelToken
|
|
16
22
|
*
|
|
@@ -26,10 +32,12 @@ const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
|
|
|
26
32
|
let client: ForgeCartShopClient | null = null;
|
|
27
33
|
|
|
28
34
|
/**
|
|
29
|
-
* Lazily construct and memoize the shop client.
|
|
35
|
+
* Lazily construct and memoize the channel-scoped shop client.
|
|
30
36
|
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
37
|
+
* All reads here are anonymous (channel scope only), so one client instance
|
|
38
|
+
* serves the whole server process. Per-shopper session state lives in
|
|
39
|
+
* `cart-actions.ts`, which constructs a per-request client around the session
|
|
40
|
+
* cookie instead.
|
|
33
41
|
*/
|
|
34
42
|
export function getShopClient(): ForgeCartShopClient {
|
|
35
43
|
if (!SHOP_API_URL) {
|
|
@@ -54,73 +62,20 @@ export function getShopClient(): ForgeCartShopClient {
|
|
|
54
62
|
/** Convenience singleton for direct use in Server Components. */
|
|
55
63
|
export const shopClient = (): ForgeCartShopClient => getShopClient();
|
|
56
64
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
slug
|
|
72
|
-
description
|
|
73
|
-
featuredAsset {
|
|
74
|
-
id
|
|
75
|
-
preview
|
|
76
|
-
}
|
|
77
|
-
optionGroups {
|
|
78
|
-
id
|
|
79
|
-
name
|
|
80
|
-
code
|
|
81
|
-
options {
|
|
82
|
-
id
|
|
83
|
-
name
|
|
84
|
-
code
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
variants {
|
|
88
|
-
id
|
|
89
|
-
name
|
|
90
|
-
sku
|
|
91
|
-
price
|
|
92
|
-
priceWithTax
|
|
93
|
-
options {
|
|
94
|
-
id
|
|
95
|
-
name
|
|
96
|
-
code
|
|
97
|
-
groupId
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
`;
|
|
101
|
-
|
|
102
|
-
const PRODUCTS_QUERY = `query StorefrontProducts($options: ListQueryOptions) {
|
|
103
|
-
products(options: $options) {
|
|
104
|
-
items {${PRODUCT_FIELDS}}
|
|
105
|
-
totalItems
|
|
106
|
-
}
|
|
107
|
-
}`;
|
|
108
|
-
|
|
109
|
-
const PRODUCT_BY_SLUG_QUERY = `query StorefrontProduct($slug: String) {
|
|
110
|
-
product(slug: $slug) {${PRODUCT_FIELDS}}
|
|
111
|
-
}`;
|
|
112
|
-
|
|
113
|
-
const PRODUCT_BY_ID_QUERY = `query StorefrontProductById($id: ID) {
|
|
114
|
-
product(id: $id) {${PRODUCT_FIELDS}}
|
|
115
|
-
}`;
|
|
116
|
-
|
|
117
|
-
interface ProductsQueryResult {
|
|
118
|
-
products: Pick<ProductList, 'items' | 'totalItems'>;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
interface ProductQueryResult {
|
|
122
|
-
product: Product | null;
|
|
123
|
-
}
|
|
65
|
+
// The SDK's operation-shaped fragment types, re-exported under their domain
|
|
66
|
+
// names so components import them from one place. These are exactly what the
|
|
67
|
+
// typed operations return: `Product`/`ProductVariant` carry the storefront
|
|
68
|
+
// display fields (name, slug, assets, priced variants); `Order`/`OrderLine`
|
|
69
|
+
// the live cart incl. the subscription fields; the selling-plan pair backs the
|
|
70
|
+
// subscription selector and the whole-cart subscribe box.
|
|
71
|
+
export type {
|
|
72
|
+
ShopOrderFieldFragment as Order,
|
|
73
|
+
ShopOrderLineFieldFragment as OrderLine,
|
|
74
|
+
ShopProductFieldFragment as Product,
|
|
75
|
+
ShopProductVariantFieldFragment as ProductVariant,
|
|
76
|
+
ShopSellingPlanFieldFragment as SellingPlan,
|
|
77
|
+
ShopSellingPlanGroupFieldFragment as SellingPlanGroup,
|
|
78
|
+
} from '@forgecart/sdk/shop';
|
|
124
79
|
|
|
125
80
|
/**
|
|
126
81
|
* Fetch a page of products for the channel.
|
|
@@ -131,29 +86,20 @@ export async function getProducts(
|
|
|
131
86
|
options: { take?: number; skip?: number } = {},
|
|
132
87
|
): Promise<{ items: Product[]; totalItems: number }> {
|
|
133
88
|
const { take = 24, skip = 0 } = options;
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
});
|
|
137
|
-
return {
|
|
138
|
-
items: result.products.items,
|
|
139
|
-
totalItems: result.products.totalItems,
|
|
140
|
-
};
|
|
89
|
+
const { products } = await getShopClient().product.shopProducts({ options: { take, skip } });
|
|
90
|
+
return { items: products.items, totalItems: products.totalItems };
|
|
141
91
|
}
|
|
142
92
|
|
|
143
93
|
/** Fetch a single product by its URL slug. Returns `null` if not found. */
|
|
144
94
|
export async function getProductBySlug(slug: string): Promise<Product | null> {
|
|
145
|
-
const
|
|
146
|
-
|
|
147
|
-
});
|
|
148
|
-
return result.product ?? null;
|
|
95
|
+
const { product } = await getShopClient().product.shopProduct({ slug });
|
|
96
|
+
return product ?? null;
|
|
149
97
|
}
|
|
150
98
|
|
|
151
99
|
/** Fetch a single product by id. Returns `null` if not found. */
|
|
152
100
|
export async function getProductById(id: string): Promise<Product | null> {
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
});
|
|
156
|
-
return result.product ?? null;
|
|
101
|
+
const { product } = await getShopClient().product.shopProduct({ id });
|
|
102
|
+
return product ?? null;
|
|
157
103
|
}
|
|
158
104
|
|
|
159
105
|
/**
|
|
@@ -164,6 +110,63 @@ export async function getFeaturedProducts(count = 4): Promise<Product[]> {
|
|
|
164
110
|
return items;
|
|
165
111
|
}
|
|
166
112
|
|
|
113
|
+
/** Fetch the subscription groups a given variant is eligible for (per-line subscribe). */
|
|
114
|
+
export async function getSellingPlanGroupsForVariant(
|
|
115
|
+
variantId: string,
|
|
116
|
+
): Promise<SellingPlanGroup[]> {
|
|
117
|
+
const { sellingPlanGroupsForVariant } = await getShopClient().sellingPlan.sellingPlanGroupsForVariant({ variantId });
|
|
118
|
+
return sellingPlanGroupsForVariant;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Fetch the channel-wide subscription groups (whole-cart subscribe box). */
|
|
122
|
+
export async function getChannelSellingPlanGroups(): Promise<SellingPlanGroup[]> {
|
|
123
|
+
const { channelSellingPlanGroups } = await getShopClient().sellingPlan.channelSellingPlanGroups();
|
|
124
|
+
return channelSellingPlanGroups;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Preview the per-unit price a plan yields from the one-time `priceWithTax`.
|
|
129
|
+
*
|
|
130
|
+
* Mirrors the server's pricing policy: `none` keeps the price, `percentage`
|
|
131
|
+
* applies the percent discount (rounded to whole minor units), `fixed_amount`
|
|
132
|
+
* subtracts the minor-unit adjustment (floored at zero). A `null`
|
|
133
|
+
* `adjustmentValue` (only valid for `none`) is treated as no adjustment.
|
|
134
|
+
*/
|
|
135
|
+
export function getPlanPreviewPrice(basePrice: number, plan: SellingPlan): number {
|
|
136
|
+
if (plan.pricingPolicy === 'percentage') {
|
|
137
|
+
const adjustment = plan.adjustmentValue ?? 0;
|
|
138
|
+
// `adjustmentValue` is a whole-number percent (e.g. 15 -> 15% off).
|
|
139
|
+
const PERCENT_BASE = 100;
|
|
140
|
+
return Math.round(basePrice * (1 - adjustment / PERCENT_BASE));
|
|
141
|
+
}
|
|
142
|
+
if (plan.pricingPolicy === 'fixed_amount') {
|
|
143
|
+
return Math.max(0, basePrice - (plan.adjustmentValue ?? 0));
|
|
144
|
+
}
|
|
145
|
+
return basePrice;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A short human-readable savings hint for a plan, or `null` when it offers no
|
|
150
|
+
* discount (policy `none`, a zero adjustment, or a non-positive percentage).
|
|
151
|
+
*/
|
|
152
|
+
export function getPlanSavingsLabel(plan: SellingPlan, currency = 'USD'): string | null {
|
|
153
|
+
if (plan.pricingPolicy === 'percentage' && (plan.adjustmentValue ?? 0) > 0) {
|
|
154
|
+
return `Save ${plan.adjustmentValue}%`;
|
|
155
|
+
}
|
|
156
|
+
if (plan.pricingPolicy === 'fixed_amount' && (plan.adjustmentValue ?? 0) > 0) {
|
|
157
|
+
return `Save ${formatPrice(plan.adjustmentValue ?? 0, currency)}`;
|
|
158
|
+
}
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The billing cadence as a phrase, e.g. "every 1 monthly" or "every 2 weekly",
|
|
164
|
+
* built from the plan's interval count and the (lower-cased) billing interval.
|
|
165
|
+
*/
|
|
166
|
+
export function getPlanCadenceLabel(plan: SellingPlan): string {
|
|
167
|
+
return `every ${plan.intervalCount} ${plan.billingInterval.toLowerCase()}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
167
170
|
/** Lowest variant price for a product, in minor units, or `null` if none. */
|
|
168
171
|
export function getStartingPrice(product: Product): number | null {
|
|
169
172
|
if (product.variants.length === 0) {
|