@forgecart/cli 2.202608121449.0 → 2.202608200713.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/commands/init.d.ts +16 -0
- package/dist/src/commands/init.js +18 -7
- package/dist/src/commands/init.js.map +1 -1
- package/package.json +1 -1
- package/templates/storefront/README.md +145 -61
- package/templates/storefront/next.config.js +20 -0
- package/templates/storefront/package.json +10 -2
- package/templates/storefront/postcss.config.js +1 -2
- package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +30 -12
- package/templates/storefront/src/app/__forge_beacon/route.ts +1 -2
- package/templates/storefront/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront/src/app/cart/page.tsx +33 -6
- package/templates/storefront/src/app/checkout/page.tsx +40 -0
- package/templates/storefront/src/app/error.tsx +21 -0
- package/templates/storefront/src/app/global-error.tsx +23 -0
- package/templates/storefront/src/app/globals.css +105 -8
- package/templates/storefront/src/app/layout.tsx +45 -17
- package/templates/storefront/src/app/page.tsx +153 -43
- package/templates/storefront/src/app/ping/route.ts +1 -2
- package/templates/storefront/src/app/products/[slug]/not-found.tsx +3 -8
- package/templates/storefront/src/app/products/[slug]/page.tsx +69 -23
- package/templates/storefront/src/app/products/page.tsx +52 -9
- package/templates/storefront/src/components/CartView.tsx +244 -117
- package/templates/storefront/src/components/ForgeTracker.tsx +131 -26
- package/templates/storefront/src/components/Header.tsx +8 -10
- package/templates/storefront/src/components/ProductCard.tsx +25 -13
- package/templates/storefront/src/components/ProductPurchase.tsx +13 -18
- package/templates/storefront/src/components/checkout/AddressStep.tsx +288 -0
- package/templates/storefront/src/components/checkout/CheckoutFlow.tsx +543 -0
- package/templates/storefront/src/components/checkout/CheckoutGate.tsx +45 -0
- package/templates/storefront/src/components/checkout/PaymentElementForm.tsx +138 -0
- package/templates/storefront/src/components/checkout/PaymentFormEmbed.tsx +89 -0
- package/templates/storefront/src/components/checkout/RatesStep.tsx +113 -0
- package/templates/storefront/src/instrumentation.ts +34 -0
- package/templates/storefront/src/lib/action-result.ts +30 -0
- package/templates/storefront/src/lib/backend-actions.ts +20 -0
- package/templates/storefront/src/lib/backend-client.ts +47 -0
- package/templates/storefront/src/lib/cart-context.tsx +157 -22
- package/templates/storefront/src/lib/checkout-session.ts +185 -0
- package/templates/storefront/src/lib/error-messages.ts +24 -0
- package/templates/storefront/src/lib/experiments.ts +42 -44
- package/templates/storefront/src/lib/forgecart.ts +61 -78
- package/templates/storefront/src/lib/format.ts +91 -0
- package/templates/storefront/src/lib/session-actions.ts +54 -0
- package/templates/storefront/src/lib/shop-config.ts +44 -0
- package/templates/storefront/src/lib/shop-session.ts +114 -0
- package/templates/storefront/src/lib/track-forward.ts +14 -1
- package/templates/storefront/src/lib/uuid.ts +19 -0
- package/templates/storefront/src/server/app.module.ts +18 -0
- package/templates/storefront/src/server/backend-api.ts +26 -0
- package/templates/storefront/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront/src/server/bootstrap.ts +122 -0
- package/templates/storefront/src/server/customer-extras/customer-extras.module.ts +13 -0
- package/templates/storefront/src/server/customer-extras/service/customer-extras.service.ts +58 -0
- package/templates/storefront/src/server/customer-extras/type/customer-extras.types.ts +11 -0
- package/templates/storefront/src/server/forgecart/forgecart-client.factory.ts +69 -0
- package/templates/storefront/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront/src/server/runner.ts +91 -0
- package/templates/storefront/src/server/types.ts +36 -0
- package/templates/storefront/tsconfig.json +2 -0
- package/templates/storefront/.env.example +0 -12
- package/templates/storefront/src/lib/cart-actions.ts +0 -139
- package/templates/storefront/tailwind.config.js +0 -8
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
+
import type { ExtractedError } from '@forgecart/sdk/shop';
|
|
4
|
+
import type { ShopAddItemToOrderInput } from '@forgecart/sdk/shop';
|
|
5
|
+
|
|
6
|
+
import type { ActionResult } from './action-result';
|
|
3
7
|
import type { Order } from './forgecart';
|
|
4
8
|
import {
|
|
5
9
|
createContext,
|
|
6
10
|
useCallback,
|
|
7
11
|
useContext,
|
|
12
|
+
useEffect,
|
|
8
13
|
useMemo,
|
|
9
14
|
useState,
|
|
10
15
|
useTransition,
|
|
@@ -12,80 +17,210 @@ import {
|
|
|
12
17
|
} from 'react';
|
|
13
18
|
|
|
14
19
|
import {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
} from './
|
|
20
|
+
initShopSession,
|
|
21
|
+
isSessionReady,
|
|
22
|
+
readMirrorSession,
|
|
23
|
+
runSessionOp,
|
|
24
|
+
type ShopSessionConfig,
|
|
25
|
+
} from './shop-session';
|
|
21
26
|
|
|
22
27
|
/**
|
|
23
|
-
* Cart state
|
|
28
|
+
* Cart state on the shopper's OWN shop websocket (`shop-session.ts`) — the
|
|
29
|
+
* client half of the progressive stack.
|
|
30
|
+
*
|
|
31
|
+
* PPR-compatible boot: the server shell renders with NO cart (reading the
|
|
32
|
+
* session cookie would dynamize the whole layout), so the provider hydrates
|
|
33
|
+
* client-side. On mount it arms the session singleton with the serialized
|
|
34
|
+
* channel config; when the mirror cookie says a session exists, it reads the
|
|
35
|
+
* active order over the socket (`activeOrder` is nullable and NEVER mints —
|
|
36
|
+
* carts start with the first added item). `hydrated` stays false until that
|
|
37
|
+
* first read settles, so cart surfaces can render a skeleton instead of a
|
|
38
|
+
* false "empty cart" flash.
|
|
24
39
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
40
|
+
* Every mutation runs on the same socket and replaces `cart` with the order
|
|
41
|
+
* from the typed `ActionResult` envelope; a failure keeps the last known
|
|
42
|
+
* cart AND surfaces the typed error on `error`. `pending` is true while a
|
|
43
|
+
* cart mutation is in flight. The first `add` on a fresh visitor mints the
|
|
44
|
+
* session client-side; `shop-session.ts` syncs the captured token back into
|
|
45
|
+
* the cookies so the next SSR pass sees this cart.
|
|
29
46
|
*/
|
|
30
47
|
interface CartContextValue {
|
|
31
48
|
cart: Order | null;
|
|
32
49
|
itemCount: number;
|
|
33
50
|
subtotal: number;
|
|
51
|
+
/** False until the mount-time cart read settles — render skeletons, not "empty". */
|
|
52
|
+
hydrated: boolean;
|
|
34
53
|
pending: boolean;
|
|
54
|
+
error: ExtractedError | null;
|
|
35
55
|
add: (variantId: string, quantity?: number, sellingPlanId?: string) => void;
|
|
36
56
|
setQuantity: (lineId: string, quantity: number) => void;
|
|
37
57
|
remove: (lineId: string) => void;
|
|
38
58
|
setSellingPlan: (sellingPlanId: string | null) => void;
|
|
59
|
+
applyCoupon: (code: string) => void;
|
|
60
|
+
removeCoupon: (code: string) => void;
|
|
61
|
+
clearError: () => void;
|
|
39
62
|
refresh: () => void;
|
|
40
63
|
}
|
|
41
64
|
|
|
42
65
|
const CartContext = createContext<CartContextValue | null>(null);
|
|
43
66
|
|
|
44
67
|
export function CartProvider({
|
|
45
|
-
|
|
68
|
+
shopConfig,
|
|
46
69
|
children,
|
|
47
70
|
}: {
|
|
48
|
-
|
|
71
|
+
shopConfig: ShopSessionConfig | null;
|
|
49
72
|
children: ReactNode;
|
|
50
73
|
}) {
|
|
51
|
-
const [cart, setCart] = useState<Order | null>(
|
|
74
|
+
const [cart, setCart] = useState<Order | null>(null);
|
|
75
|
+
const [hydrated, setHydrated] = useState(false);
|
|
76
|
+
const [error, setError] = useState<ExtractedError | null>(null);
|
|
52
77
|
const [pending, startTransition] = useTransition();
|
|
53
78
|
|
|
54
|
-
const run = useCallback((op: () => Promise<Order | null
|
|
79
|
+
const run = useCallback((op: () => Promise<ActionResult<Order | null>>) => {
|
|
55
80
|
startTransition(async () => {
|
|
56
|
-
|
|
81
|
+
const result = await op();
|
|
82
|
+
if (result.ok) {
|
|
83
|
+
setCart(result.data);
|
|
84
|
+
setError(null);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
setError(result.error);
|
|
57
88
|
});
|
|
58
89
|
}, []);
|
|
59
90
|
|
|
91
|
+
// Arm the session singleton and hydrate the cart. An unconfigured scaffold
|
|
92
|
+
// (pre-`forgecart init`) or a visitor with no session hydrates to the
|
|
93
|
+
// legitimate empty state without touching the network.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!shopConfig) {
|
|
96
|
+
setHydrated(true);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
initShopSession(shopConfig);
|
|
100
|
+
if (!readMirrorSession()) {
|
|
101
|
+
setHydrated(true);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let cancelled = false;
|
|
105
|
+
void (async () => {
|
|
106
|
+
const result = await runSessionOp(
|
|
107
|
+
async (client) => (await client.order.activeOrder()).activeOrder ?? null,
|
|
108
|
+
);
|
|
109
|
+
if (cancelled) return;
|
|
110
|
+
if (result.ok) setCart(result.data);
|
|
111
|
+
setHydrated(true);
|
|
112
|
+
})();
|
|
113
|
+
return () => {
|
|
114
|
+
cancelled = true;
|
|
115
|
+
};
|
|
116
|
+
}, [shopConfig]);
|
|
117
|
+
|
|
60
118
|
const add = useCallback(
|
|
61
119
|
(variantId: string, quantity = 1, sellingPlanId?: string) =>
|
|
62
|
-
run(() =>
|
|
120
|
+
run(async () => {
|
|
121
|
+
const input: ShopAddItemToOrderInput = { variantId, quantity };
|
|
122
|
+
if (sellingPlanId) {
|
|
123
|
+
input.sellingPlanId = sellingPlanId;
|
|
124
|
+
}
|
|
125
|
+
return runSessionOp(
|
|
126
|
+
async (client) => (await client.cart.addItemToOrder({ input })).addItemToOrder,
|
|
127
|
+
);
|
|
128
|
+
}),
|
|
63
129
|
[run],
|
|
64
130
|
);
|
|
65
131
|
const setQuantity = useCallback(
|
|
66
|
-
(lineId: string, quantity: number) =>
|
|
132
|
+
(lineId: string, quantity: number) =>
|
|
133
|
+
run(() =>
|
|
134
|
+
runSessionOp(
|
|
135
|
+
async (client) =>
|
|
136
|
+
(await client.cart.adjustOrderLine({ input: { lineId, quantity } })).adjustOrderLine,
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
[run],
|
|
140
|
+
);
|
|
141
|
+
const remove = useCallback(
|
|
142
|
+
(lineId: string) =>
|
|
143
|
+
run(() =>
|
|
144
|
+
runSessionOp(
|
|
145
|
+
async (client) => (await client.cart.removeOrderLine({ lineId })).removeOrderLine,
|
|
146
|
+
),
|
|
147
|
+
),
|
|
67
148
|
[run],
|
|
68
149
|
);
|
|
69
|
-
const remove = useCallback((lineId: string) => run(() => removeLineAction(lineId)), [run]);
|
|
70
150
|
const setSellingPlan = useCallback(
|
|
71
|
-
(sellingPlanId: string | null) =>
|
|
151
|
+
(sellingPlanId: string | null) =>
|
|
152
|
+
run(() =>
|
|
153
|
+
runSessionOp(
|
|
154
|
+
async (client) =>
|
|
155
|
+
(await client.cart.setOrderSellingPlan({ input: { sellingPlanId } }))
|
|
156
|
+
.setOrderSellingPlan,
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
[run],
|
|
160
|
+
);
|
|
161
|
+
const applyCoupon = useCallback(
|
|
162
|
+
(code: string) =>
|
|
163
|
+
run(() =>
|
|
164
|
+
runSessionOp(
|
|
165
|
+
async (client) => (await client.cart.applyCouponCode({ code })).applyCouponCode,
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
[run],
|
|
169
|
+
);
|
|
170
|
+
const removeCoupon = useCallback(
|
|
171
|
+
(code: string) =>
|
|
172
|
+
run(() =>
|
|
173
|
+
runSessionOp(
|
|
174
|
+
async (client) => (await client.cart.removeCouponCode({ code })).removeCouponCode,
|
|
175
|
+
),
|
|
176
|
+
),
|
|
177
|
+
[run],
|
|
178
|
+
);
|
|
179
|
+
const clearError = useCallback(() => setError(null), []);
|
|
180
|
+
const refresh = useCallback(
|
|
181
|
+
() =>
|
|
182
|
+
run(async () => {
|
|
183
|
+
if (!isSessionReady() || !readMirrorSession()) {
|
|
184
|
+
return { ok: true, data: null };
|
|
185
|
+
}
|
|
186
|
+
return runSessionOp(
|
|
187
|
+
async (client) => (await client.order.activeOrder()).activeOrder ?? null,
|
|
188
|
+
);
|
|
189
|
+
}),
|
|
72
190
|
[run],
|
|
73
191
|
);
|
|
74
|
-
const refresh = useCallback(() => run(() => getCart()), [run]);
|
|
75
192
|
|
|
76
193
|
const value = useMemo<CartContextValue>(
|
|
77
194
|
() => ({
|
|
78
195
|
cart,
|
|
79
196
|
itemCount: cart?.totalQuantity ?? 0,
|
|
80
197
|
subtotal: cart?.subTotalWithTax ?? 0,
|
|
198
|
+
hydrated,
|
|
81
199
|
pending,
|
|
200
|
+
error,
|
|
82
201
|
add,
|
|
83
202
|
setQuantity,
|
|
84
203
|
remove,
|
|
85
204
|
setSellingPlan,
|
|
205
|
+
applyCoupon,
|
|
206
|
+
removeCoupon,
|
|
207
|
+
clearError,
|
|
86
208
|
refresh,
|
|
87
209
|
}),
|
|
88
|
-
[
|
|
210
|
+
[
|
|
211
|
+
cart,
|
|
212
|
+
hydrated,
|
|
213
|
+
pending,
|
|
214
|
+
error,
|
|
215
|
+
add,
|
|
216
|
+
setQuantity,
|
|
217
|
+
remove,
|
|
218
|
+
setSellingPlan,
|
|
219
|
+
applyCoupon,
|
|
220
|
+
removeCoupon,
|
|
221
|
+
clearError,
|
|
222
|
+
refresh,
|
|
223
|
+
],
|
|
89
224
|
);
|
|
90
225
|
|
|
91
226
|
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { CreateAddressInput, SelectRateInput, SetCustomerInput } from '@forgecart/sdk/shop';
|
|
4
|
+
|
|
5
|
+
import type { ActionResult } from './action-result';
|
|
6
|
+
import type {
|
|
7
|
+
CheckoutOrder,
|
|
8
|
+
CheckoutOrderView,
|
|
9
|
+
ConfirmedPaymentSession,
|
|
10
|
+
EligiblePaymentProvider,
|
|
11
|
+
PaymentSession,
|
|
12
|
+
PaymentTemplate,
|
|
13
|
+
PaymentVariables,
|
|
14
|
+
ShippingRateGroup,
|
|
15
|
+
} from './forgecart';
|
|
16
|
+
import { runSessionOp } from './shop-session';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Checkout operations on the shopper's OWN websocket (`shop-session.ts`):
|
|
20
|
+
* contact → address → rates → payment → confirmation. Same names, same
|
|
21
|
+
* `ActionResult` envelopes as the retired Server-Action module — the flow
|
|
22
|
+
* components changed one import, not their contracts. Every operation here
|
|
23
|
+
* is session-scoped, which is exactly why it runs client-side; the only
|
|
24
|
+
* server-fetched checkout data is the public country list (channel read).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Attach the guest contact (email + name) to the active order. */
|
|
28
|
+
export async function setCustomer(
|
|
29
|
+
input: SetCustomerInput,
|
|
30
|
+
): Promise<ActionResult<CheckoutOrderView | null>> {
|
|
31
|
+
return runSessionOp(
|
|
32
|
+
async (client) => (await client.cart.setCustomerForOrder({ input })).setCustomerForOrder,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Persist the shipping address. `countryId` accepts the canonical Country id
|
|
38
|
+
* OR an ISO code (an autocomplete suggestion's `countryCode` feeds it
|
|
39
|
+
* directly); `phoneNumber` is required server-side
|
|
40
|
+
* (SHIPPING_ADDRESS_PHONE_REQUIRED arrives as a typed field error otherwise).
|
|
41
|
+
*/
|
|
42
|
+
export async function setShippingAddress(
|
|
43
|
+
input: CreateAddressInput,
|
|
44
|
+
): Promise<ActionResult<CheckoutOrderView | null>> {
|
|
45
|
+
return runSessionOp(
|
|
46
|
+
async (client) =>
|
|
47
|
+
(await client.checkout.setOrderShippingAddress({ input })).setOrderShippingAddress,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Type-ahead address suggestions ([] = no provider → manual entry). */
|
|
52
|
+
export async function suggestAddresses(
|
|
53
|
+
query: string,
|
|
54
|
+
sessionToken: string,
|
|
55
|
+
countryCode?: string,
|
|
56
|
+
): Promise<ActionResult<{ id: string; description: string }[]>> {
|
|
57
|
+
return runSessionOp(
|
|
58
|
+
async (client) =>
|
|
59
|
+
(await client.address.addressAutocomplete({ input: { query, sessionToken, countryCode } }))
|
|
60
|
+
.addressAutocomplete,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve one suggestion into a structured address for the form. */
|
|
65
|
+
export async function resolveAddressSuggestion(
|
|
66
|
+
id: string,
|
|
67
|
+
sessionToken: string,
|
|
68
|
+
): Promise<
|
|
69
|
+
ActionResult<{
|
|
70
|
+
streetLine1: string;
|
|
71
|
+
streetLine2?: string | null;
|
|
72
|
+
city?: string | null;
|
|
73
|
+
province?: string | null;
|
|
74
|
+
postalCode?: string | null;
|
|
75
|
+
countryCode: string;
|
|
76
|
+
}>
|
|
77
|
+
> {
|
|
78
|
+
return runSessionOp(
|
|
79
|
+
async (client) =>
|
|
80
|
+
(await client.address.addressSuggestion({ input: { id, sessionToken } })).addressSuggestion,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Explicit synchronous carrier re-quote — run on rate-step entry and after
|
|
86
|
+
* an address change. Per-provider failures arrive in each group's
|
|
87
|
+
* `lastRateErrors`; the read query never quotes (#863's read/refresh split).
|
|
88
|
+
*/
|
|
89
|
+
export async function refreshShippingRates(): Promise<ActionResult<ShippingRateGroup[]>> {
|
|
90
|
+
return runSessionOp(
|
|
91
|
+
async (client) => (await client.shipping.refreshShippingRateGroups()).refreshShippingRateGroups,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Read the persisted rate groups (no carrier quoting). */
|
|
96
|
+
export async function getShippingRates(): Promise<ActionResult<ShippingRateGroup[]>> {
|
|
97
|
+
return runSessionOp(
|
|
98
|
+
async (client) => (await client.shipping.shippingRateGroups()).shippingRateGroups,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The CUSTOMER's rate pick — the ArrangingPayment gate requires one per group. */
|
|
103
|
+
export async function selectShippingRate(
|
|
104
|
+
input: SelectRateInput,
|
|
105
|
+
): Promise<ActionResult<CheckoutOrderView | null>> {
|
|
106
|
+
return runSessionOp(
|
|
107
|
+
async (client) => (await client.shipping.selectShippingRate({ input })).selectShippingRate,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Advance the order to ArrangingPayment (gated: address+phone, items, selected rates). */
|
|
112
|
+
export async function transitionToPayment(): Promise<ActionResult<CheckoutOrderView | null>> {
|
|
113
|
+
return runSessionOp(
|
|
114
|
+
async (client) =>
|
|
115
|
+
(await client.checkout.transitionOrderToState({ input: { state: 'arrangingPayment' } }))
|
|
116
|
+
.transitionOrderToState,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Providers eligible for this order (eligibilityMessage is data, not an error). */
|
|
121
|
+
export async function getEligiblePaymentProviders(
|
|
122
|
+
orderId: string,
|
|
123
|
+
): Promise<ActionResult<EligiblePaymentProvider[]>> {
|
|
124
|
+
return runSessionOp(
|
|
125
|
+
async (client) =>
|
|
126
|
+
(await client.payment.shopEligiblePaymentProviders({ orderId })).eligiblePaymentProviders,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Create the payment session (ONE per order — coupon edits freeze at this point). */
|
|
131
|
+
export async function createPaymentSession(
|
|
132
|
+
orderId: string,
|
|
133
|
+
providerCode?: string,
|
|
134
|
+
): Promise<ActionResult<PaymentSession>> {
|
|
135
|
+
return runSessionOp(
|
|
136
|
+
async (client) =>
|
|
137
|
+
(await client.payment.createPaymentSession({ input: { orderId, providerCode } }))
|
|
138
|
+
.createPaymentSession,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Raw variables for the in-page payment adapter (`PaymentElementForm`):
|
|
144
|
+
* providerCode, publishableKey, scriptUrl, amount, currency — straight from
|
|
145
|
+
* the order + the channel's provider config, no session required.
|
|
146
|
+
*/
|
|
147
|
+
export async function getPaymentVariables(
|
|
148
|
+
orderId: string,
|
|
149
|
+
): Promise<ActionResult<PaymentVariables>> {
|
|
150
|
+
return runSessionOp(
|
|
151
|
+
async (client) => (await client.payment.getSessionVariables({ orderId })).getSessionVariables,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The provider's hosted form for `PaymentFormEmbed`. `origin` is the
|
|
157
|
+
* embedding storefront's own origin — the template pins every postMessage of
|
|
158
|
+
* the #865 handshake (iframe-ready / payment-success / payment-error) to it.
|
|
159
|
+
*/
|
|
160
|
+
export async function getPaymentFormTemplate(
|
|
161
|
+
orderId: string,
|
|
162
|
+
origin: string,
|
|
163
|
+
): Promise<ActionResult<PaymentTemplate>> {
|
|
164
|
+
return runSessionOp(
|
|
165
|
+
async (client) =>
|
|
166
|
+
(await client.payment.getSessionTemplate({ input: { orderId, origin } })).getSessionTemplate,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Confirm the session where the provider flow requires it (token/redirect models). */
|
|
171
|
+
export async function confirmPaymentSession(
|
|
172
|
+
sessionId: string,
|
|
173
|
+
): Promise<ActionResult<ConfirmedPaymentSession>> {
|
|
174
|
+
return runSessionOp(
|
|
175
|
+
async (client) =>
|
|
176
|
+
(await client.payment.confirmPaymentSession({ sessionId })).confirmPaymentSession,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The settled order for the confirmation step (by the code shown to the shopper). */
|
|
181
|
+
export async function getOrderByCode(code: string): Promise<ActionResult<CheckoutOrder | null>> {
|
|
182
|
+
return runSessionOp(
|
|
183
|
+
async (client) => (await client.order.shopOrderByCode({ code })).orderByCode ?? null,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ExtractedError } from '@forgecart/sdk/shop';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shopper-facing messages for the typed checkout error codes.
|
|
5
|
+
*
|
|
6
|
+
* The shop API's error contract ships CODES, never prose — `message` on an
|
|
7
|
+
* extracted error is the code string itself, and resolving human copy is the
|
|
8
|
+
* frontend's job (error-handling contract). A raw enum leaking into the UI is
|
|
9
|
+
* a defect (the address step rendered SHIPPING_ADDRESS_PHONE_REQUIRED
|
|
10
|
+
* verbatim, 2026-08-09).
|
|
11
|
+
*/
|
|
12
|
+
const SHOPPER_MESSAGES: Record<string, string> = {
|
|
13
|
+
SHIPPING_ADDRESS_PHONE_REQUIRED: 'A phone number is required for delivery updates.',
|
|
14
|
+
SHIPPING_ADDRESS_COUNTRY_REQUIRED: 'Please select a country.',
|
|
15
|
+
SHIPPING_ADDRESS_COUNTRY_INVALID: 'We can’t ship to the selected country yet.',
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const FALLBACK_MESSAGE = 'Something went wrong. Please check your details and try again.';
|
|
19
|
+
|
|
20
|
+
/** Human copy for a typed error — never the raw enum, never undefined. */
|
|
21
|
+
export function shopperMessage(error: Pick<ExtractedError, 'code'> | null | undefined): string {
|
|
22
|
+
if (!error?.code) return FALLBACK_MESSAGE;
|
|
23
|
+
return SHOPPER_MESSAGES[error.code] ?? FALLBACK_MESSAGE;
|
|
24
|
+
}
|
|
@@ -283,53 +283,51 @@ async function scheduleExposure(
|
|
|
283
283
|
* whole render consistent — layout and page asking for the same key get one
|
|
284
284
|
* resolution and at most ONE exposure emission per request.
|
|
285
285
|
*/
|
|
286
|
-
export const getVariant = cache(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
if (!SLUG_REGEX.test(experimentKey)) return inert;
|
|
286
|
+
export const getVariant = cache(async (experimentKey: string): Promise<ExperimentVariantResult> => {
|
|
287
|
+
const inert: ExperimentVariantResult = {
|
|
288
|
+
variantKey: null,
|
|
289
|
+
assignmentSource: null,
|
|
290
|
+
payload: null,
|
|
291
|
+
};
|
|
292
|
+
if (!SLUG_REGEX.test(experimentKey)) return inert;
|
|
294
293
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
294
|
+
const experiments = await getActiveExperiments();
|
|
295
|
+
const experiment = experiments.find((entry) => entry.experimentKey === experimentKey);
|
|
296
|
+
if (!experiment) return inert;
|
|
298
297
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
298
|
+
if (experiment.subjectType === 'customer') {
|
|
299
|
+
// The storefront server has no verified customer id (auth lives behind
|
|
300
|
+
// the shop API session) — a customer-scoped experiment renders as
|
|
301
|
+
// non-participant control here, with no exposure. Customer-subject
|
|
302
|
+
// resolution belongs to clients that KNOW the customer id.
|
|
303
|
+
const control = experiment.variants[0];
|
|
304
|
+
if (!control) return inert;
|
|
305
|
+
return { variantKey: control.key, assignmentSource: null, payload: control.payload ?? null };
|
|
306
|
+
}
|
|
308
307
|
|
|
309
|
-
|
|
310
|
-
|
|
308
|
+
const visitorId = await getRequestVisitorId();
|
|
309
|
+
const forcedVariantKey = (await getForcedVariantMap())[experimentKey] ?? null;
|
|
311
310
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
311
|
+
let result: AssignmentResult;
|
|
312
|
+
try {
|
|
313
|
+
result = resolveExperimentAssignment(toAssignmentConfig(experiment), {
|
|
314
|
+
subjectKey: visitorId,
|
|
315
|
+
forcedVariantKey,
|
|
316
|
+
});
|
|
317
|
+
} catch {
|
|
318
|
+
// A malformed delivery (out-of-bounds weight, empty variants) must
|
|
319
|
+
// never break rendering — the caller falls back to its default branch.
|
|
320
|
+
return inert;
|
|
321
|
+
}
|
|
323
322
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
323
|
+
if (result.exposed && result.variantKey !== null) {
|
|
324
|
+
await scheduleExposure(experiment, result, visitorId);
|
|
325
|
+
}
|
|
327
326
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
);
|
|
327
|
+
const variant = experiment.variants.find((entry) => entry.key === result.variantKey);
|
|
328
|
+
return {
|
|
329
|
+
variantKey: result.variantKey,
|
|
330
|
+
assignmentSource: result.assignmentSource,
|
|
331
|
+
payload: variant?.payload ?? null,
|
|
332
|
+
};
|
|
333
|
+
});
|