@forgecart/cli 2.202606151453.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/dist/src/commands/init.d.ts +8 -0
- package/dist/src/commands/init.js +48 -6
- package/dist/src/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/next.config.js +20 -0
- package/templates/storefront/package.json +1 -1
- package/templates/storefront/src/app/__forge_beacon/route.ts +36 -0
- package/templates/storefront/src/app/cart/page.tsx +9 -84
- package/templates/storefront/src/app/layout.tsx +2 -0
- package/templates/storefront/src/app/ping/route.ts +22 -0
- 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/ForgeErrorBeacon.tsx +71 -0
- package/templates/storefront/src/components/ProductCard.tsx +1 -2
- package/templates/storefront/src/components/ProductPurchase.tsx +153 -8
- package/templates/storefront/src/instrumentation.ts +69 -0
- 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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DEV-ONLY server-error beacon via Next's `onRequestError` instrumentation hook.
|
|
3
|
+
*
|
|
4
|
+
* This is the PRIMARY server-side crash-detection surface for the in-pod dev-server:
|
|
5
|
+
* when a Server Component, a server action, or a route handler THROWS while rendering
|
|
6
|
+
* a request under `next dev`, Next invokes `onRequestError` with the structured error
|
|
7
|
+
* + request + routing context. The hook POSTs that detail SERVER-side, straight to the
|
|
8
|
+
* in-pod workspace-manager loopback receiver at
|
|
9
|
+
* `http://127.0.0.1:<FORGE_BEACON_PORT>/__forge_beacon`, which folds it into the
|
|
10
|
+
* supervisor's runtime state so the shop's recovery brain can originate a fix for a
|
|
11
|
+
* crash the `/ping` liveness probe cannot see (the dev-server process stays up and
|
|
12
|
+
* `/ping` stays 200, but `/` 500s per request).
|
|
13
|
+
*
|
|
14
|
+
* This complements the browser beacon (`ForgeErrorBeacon`): that one catches
|
|
15
|
+
* CLIENT-component / hydration throws that surface only in the browser; this one
|
|
16
|
+
* catches SERVER render throws that surface only in the dev-server. Both POST the
|
|
17
|
+
* same `/__forge_beacon` shape — the only difference is `source` (`'browser'` vs
|
|
18
|
+
* `'server'`).
|
|
19
|
+
*
|
|
20
|
+
* Gated entirely on `NODE_ENV === 'development'`: a deployed `next start` storefront
|
|
21
|
+
* registers no beacon (the literal check is inlined by the bundler, so the body is
|
|
22
|
+
* dead-code-eliminated from a production build) and the loopback receiver does not
|
|
23
|
+
* exist outside the pod. Next compiles `instrumentation.ts` for BOTH the Node and Edge
|
|
24
|
+
* runtimes; the loopback POST is a Node-only concern, so the hook also gates on the
|
|
25
|
+
* Node runtime — the Edge bundle then carries an inert no-op.
|
|
26
|
+
*
|
|
27
|
+
* The hook NEVER throws out of itself: a failed delivery is swallowed (the beacon is a
|
|
28
|
+
* best-effort backstop, never a hard dependency of the request), so a flapping receiver
|
|
29
|
+
* can never mask or replace the underlying request error Next is already reporting.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const BEACON_PORT = process.env.FORGE_BEACON_PORT ?? '3002';
|
|
33
|
+
|
|
34
|
+
interface OnRequestErrorRequest {
|
|
35
|
+
path: string;
|
|
36
|
+
method: string;
|
|
37
|
+
headers: { [key: string]: string | string[] | undefined };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface OnRequestErrorContext {
|
|
41
|
+
routerKind: string;
|
|
42
|
+
routePath: string;
|
|
43
|
+
routeType: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function onRequestError(
|
|
47
|
+
error: unknown,
|
|
48
|
+
request: OnRequestErrorRequest,
|
|
49
|
+
context: OnRequestErrorContext,
|
|
50
|
+
): Promise<void> {
|
|
51
|
+
if (process.env.NODE_ENV !== 'development' || process.env.NEXT_RUNTIME !== 'nodejs') {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const err = error as { message?: string; digest?: string } | undefined;
|
|
56
|
+
const body = JSON.stringify({
|
|
57
|
+
message: typeof err?.message === 'string' ? err.message : String(error),
|
|
58
|
+
digest: typeof err?.digest === 'string' ? err.digest : undefined,
|
|
59
|
+
route: request.path,
|
|
60
|
+
routeType: context.routeType,
|
|
61
|
+
source: 'server',
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
await fetch(`http://127.0.0.1:${BEACON_PORT}/__forge_beacon`, {
|
|
65
|
+
method: 'POST',
|
|
66
|
+
headers: { 'content-type': 'application/json' },
|
|
67
|
+
body,
|
|
68
|
+
}).catch(() => undefined);
|
|
69
|
+
}
|
|
@@ -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>;
|