@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgecart/cli",
3
- "version": "2.202607061936.0",
3
+ "version": "2.202607070306.0",
4
4
  "type": "module",
5
5
  "description": "CLI for scaffolding and operating ForgeCart channel storefronts",
6
6
  "bin": {
@@ -1,88 +1,13 @@
1
- 'use client';
1
+ import { CartView } from '../../components/CartView';
2
+ import { getChannelSellingPlanGroups } from '../../lib/forgecart';
2
3
 
3
- import Link from 'next/link';
4
+ export const dynamic = 'force-dynamic';
4
5
 
5
- import { useCart } from '../../lib/cart-context';
6
- import { formatPrice } from '../../lib/forgecart';
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
- export default function CartPage() {
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 { getProductBySlug } from '../../../lib/forgecart';
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 type { Product, ProductVariant } from '@forgecart/sdk/shop';
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 { formatPrice } from '../lib/forgecart';
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({ product }: { product: Product }) {
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(variant.priceWithTax)}</p>
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 type { Order } from '@forgecart/sdk/shop';
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 returning the live
9
- * `Order` (the SDK type) so callers render straight off it.
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 returns
12
- * a fresh token in the `forgecart-auth-token` response header on the first
13
- * mutation, which we persist into the httpOnly `forgecart-session` cookie and
14
- * send back as a bearer token on every subsequent request. The channel token and
15
- * the session never reach the browser all cart calls run here, on the server.
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
- /** The order fields the storefront renders (a subset of the SDK `Order` type). */
23
- const ORDER_FIELDS = `
24
- id
25
- totalQuantity
26
- subTotalWithTax
27
- totalWithTax
28
- currencyCode
29
- lines {
30
- id
31
- quantity
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
- * Call the channel's shop API for a cart operation.
45
+ * Persist the session token the client captured during a mutation.
51
46
  *
52
- * `persist` MUST be `false` when called from a Server Component (the initial
53
- * cart read): Server Components cannot set cookies only Server Actions (the
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 shopFetch<T>(
57
- query: string,
58
- variables: Record<string, unknown>,
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
- const session = store.get(SESSION_COOKIE)?.value;
63
-
64
- const headers: Record<string, string> = {
65
- 'content-type': 'application/json',
66
- 'forgecart-token': CHANNEL_TOKEN,
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
- function unwrap<T>(res: GraphQLResponse<T>): T {
95
- if (res.errors?.length) {
96
- throw new Error(res.errors[0].message);
97
- }
98
- if (!res.data) {
99
- throw new Error('Empty response from shop API');
100
- }
101
- return res.data;
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
- /** Read the active order (cart). Safe to call from a Server Component. */
105
- export async function getCart(): Promise<Order | null> {
106
- const res = await shopFetch<{ activeOrder: Order | null }>(
107
- `query { activeOrder { ${ORDER_FIELDS} } }`,
108
- {},
109
- false,
110
- );
111
- return res.data?.activeOrder ?? null;
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
- /** Add a variant to the cart (creates the order + session on first call). */
115
- export async function addToCart(variantId: string, quantity = 1): Promise<Order | null> {
116
- const data = unwrap(
117
- await shopFetch<{ addItemToOrder: Order }>(
118
- `mutation($input: ShopAddItemToOrderInput!) { addItemToOrder(input: $input) { ${ORDER_FIELDS} } }`,
119
- { input: { variantId, quantity } },
120
- true,
121
- ),
122
- );
123
- return data.addItemToOrder;
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 data = unwrap(
129
- await shopFetch<{ adjustOrderLine: Order }>(
130
- `mutation($input: ShopAdjustOrderLineInput!) { adjustOrderLine(input: $input) { ${ORDER_FIELDS} } }`,
131
- { input: { lineId, quantity } },
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 data = unwrap(
141
- await shopFetch<{ removeOrderLine: Order }>(
142
- `mutation($lineId: ID!) { removeOrderLine(lineId: $lineId) { ${ORDER_FIELDS} } }`,
143
- { lineId },
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 '@forgecart/sdk/shop';
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 { addToCart, getCart, removeLine as removeLineAction, updateLine } from './cart-actions';
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) => run(() => addToCart(variantId, quantity)),
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 { Product, ProductList } from '@forgecart/sdk/shop';
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. The channel token is sent as the `forgecart-token` header on every
9
- * request (the SDK adds it from `channelToken`), and the endpoint points at the
10
- * channel's shop-api.
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
- * Both values come from the environment (`.env.local`, written by
13
- * `forgecart init`):
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
- * Constructing per-request would open a fresh websocket transport each time, so
32
- * we keep a singleton for the lifetime of the server process.
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
- * Typed product helpers
59
- *
60
- * The generated typed operations (`shopClient.product.shopProducts` /
61
- * `.shopProduct`) only select `id` + `optionGroups`, which is not enough to
62
- * render a storefront. We therefore use the SDK's supported raw-query escape
63
- * hatch (`ForgeCartShopClient.query<T>(document, variables)`) with documents
64
- * that select the display fields, typed against the SDK's own `Product` /
65
- * `ProductList` types (`@forgecart/sdk/shop`).
66
- * ------------------------------------------------------------------ */
67
-
68
- const PRODUCT_FIELDS = `
69
- id
70
- name
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 result = await getShopClient().query<ProductsQueryResult>(PRODUCTS_QUERY, {
135
- options: { take, skip },
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 result = await getShopClient().query<ProductQueryResult>(PRODUCT_BY_SLUG_QUERY, {
146
- slug,
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 result = await getShopClient().query<ProductQueryResult>(PRODUCT_BY_ID_QUERY, {
154
- id,
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) {