@forgecart/cli 2.202608121449.0 → 2.202608160103.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 +23 -11
- 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 +40 -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/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,7 +1,8 @@
|
|
|
1
|
-
import
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { ForgeCartShopClient } from '@forgecart/sdk/shop';
|
|
2
4
|
import type {
|
|
3
5
|
ShopProductFieldFragment as Product,
|
|
4
|
-
ShopSellingPlanFieldFragment as SellingPlan,
|
|
5
6
|
ShopSellingPlanGroupFieldFragment as SellingPlanGroup,
|
|
6
7
|
} from '@forgecart/sdk/shop';
|
|
7
8
|
|
|
@@ -20,9 +21,14 @@ import type {
|
|
|
20
21
|
* - FORGECART_SHOP_API_URL -> endpoint
|
|
21
22
|
* - FORGECART_CHANNEL_TOKEN -> channelToken
|
|
22
23
|
*
|
|
23
|
-
* This module is server-only
|
|
24
|
-
*
|
|
25
|
-
*
|
|
24
|
+
* This module is server-only — the `import 'server-only'` above makes a client
|
|
25
|
+
* component's value-import of it a BUILD error, so the channel token and the
|
|
26
|
+
* SDK's websocket transport can never reach the browser. The same guard sits
|
|
27
|
+
* on every SDK-bearing server module (`cart-actions.ts` today; any future
|
|
28
|
+
* action module talking to the shop API adopts it the same way). Client
|
|
29
|
+
* components may still `import type` from here — type imports erase. Pure
|
|
30
|
+
* display helpers live in `./format`, which client components import freely.
|
|
31
|
+
* The construction mirrors the dashboard's `SdkClientService` (see
|
|
26
32
|
* app/dashboard/src/service/sdk-client.service.ts), adapted to read from env.
|
|
27
33
|
*/
|
|
28
34
|
|
|
@@ -77,6 +83,48 @@ export type {
|
|
|
77
83
|
ShopSellingPlanGroupFieldFragment as SellingPlanGroup,
|
|
78
84
|
} from '@forgecart/sdk/shop';
|
|
79
85
|
|
|
86
|
+
// Checkout-flow result types, aliased off the generated operation results so
|
|
87
|
+
// the components and Server Actions speak the SDK's own shapes (indexed
|
|
88
|
+
// access keeps them exactly as selected — no parallel hand-written types).
|
|
89
|
+
import type {
|
|
90
|
+
AvailableCountriesQuery,
|
|
91
|
+
ConfirmPaymentSessionMutation,
|
|
92
|
+
CreatePaymentSessionMutation,
|
|
93
|
+
GetSessionTemplateQuery,
|
|
94
|
+
GetSessionVariablesQuery,
|
|
95
|
+
RefreshShippingRateGroupsMutation,
|
|
96
|
+
ShopEligiblePaymentProvidersQuery,
|
|
97
|
+
ShopOrderByCodeQuery,
|
|
98
|
+
} from '@forgecart/sdk/shop';
|
|
99
|
+
|
|
100
|
+
export type Country = AvailableCountriesQuery['availableCountries'][number];
|
|
101
|
+
export type ShippingRateGroup =
|
|
102
|
+
RefreshShippingRateGroupsMutation['refreshShippingRateGroups'][number];
|
|
103
|
+
export type ShippingRate = ShippingRateGroup['rates'][number];
|
|
104
|
+
export type EligiblePaymentProvider =
|
|
105
|
+
ShopEligiblePaymentProvidersQuery['eligiblePaymentProviders'][number];
|
|
106
|
+
export type PaymentSession = CreatePaymentSessionMutation['createPaymentSession'];
|
|
107
|
+
export type PaymentTemplate = GetSessionTemplateQuery['getSessionTemplate'];
|
|
108
|
+
export type PaymentVariables = GetSessionVariablesQuery['getSessionVariables'];
|
|
109
|
+
export type ConfirmedPaymentSession = ConfirmPaymentSessionMutation['confirmPaymentSession'];
|
|
110
|
+
export type CheckoutOrder = NonNullable<ShopOrderByCodeQuery['orderByCode']>;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The order fields the checkout flow renders and gates on — the structural
|
|
114
|
+
* common denominator of every checkout mutation's Order selection (each
|
|
115
|
+
* operation selects a slightly different super-set of these, so no single
|
|
116
|
+
* generated type fits them all; this view is the one deliberate non-SDK
|
|
117
|
+
* shape, and every SDK result assigns to it).
|
|
118
|
+
*/
|
|
119
|
+
export interface CheckoutOrderView {
|
|
120
|
+
id: string;
|
|
121
|
+
code: string;
|
|
122
|
+
state: string;
|
|
123
|
+
currencyCode: string;
|
|
124
|
+
totalWithTax: number;
|
|
125
|
+
totalQuantity: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
80
128
|
/**
|
|
81
129
|
* Fetch a page of products for the channel.
|
|
82
130
|
*
|
|
@@ -114,7 +162,8 @@ export async function getFeaturedProducts(count = 4): Promise<Product[]> {
|
|
|
114
162
|
export async function getSellingPlanGroupsForVariant(
|
|
115
163
|
variantId: string,
|
|
116
164
|
): Promise<SellingPlanGroup[]> {
|
|
117
|
-
const { sellingPlanGroupsForVariant } =
|
|
165
|
+
const { sellingPlanGroupsForVariant } =
|
|
166
|
+
await getShopClient().sellingPlan.sellingPlanGroupsForVariant({ variantId });
|
|
118
167
|
return sellingPlanGroupsForVariant;
|
|
119
168
|
}
|
|
120
169
|
|
|
@@ -125,77 +174,11 @@ export async function getChannelSellingPlanGroups(): Promise<SellingPlanGroup[]>
|
|
|
125
174
|
}
|
|
126
175
|
|
|
127
176
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
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
|
-
|
|
170
|
-
/** Lowest variant price for a product, in minor units, or `null` if none. */
|
|
171
|
-
export function getStartingPrice(product: Product): number | null {
|
|
172
|
-
if (product.variants.length === 0) {
|
|
173
|
-
return null;
|
|
174
|
-
}
|
|
175
|
-
return product.variants.reduce(
|
|
176
|
-
(min, v) => (v.priceWithTax < min ? v.priceWithTax : min),
|
|
177
|
-
product.variants[0].priceWithTax,
|
|
178
|
-
);
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
/**
|
|
182
|
-
* Format a minor-unit price as a currency string. Defaults to USD; pass a
|
|
183
|
-
* different ISO currency code as needed.
|
|
184
|
-
*
|
|
185
|
-
* ForgeCart prices are integers in the currency's smallest denomination, so
|
|
186
|
-
* the divisor depends on the currency's ISO-4217 exponent: 2500 is $25.00
|
|
187
|
-
* (two-decimal cents) but ¥2,500 (zero-decimal yen) and BHD 2.500
|
|
188
|
-
* (three-decimal fils). The exponent is derived from the same
|
|
189
|
-
* `Intl.NumberFormat` instance that renders the price — its
|
|
190
|
-
* `resolvedOptions().maximumFractionDigits` carries the CLDR fraction digits
|
|
191
|
-
* for the currency — so the divisor always agrees with the digits shown,
|
|
192
|
-
* with a defensive fallback of 2 should the formatter resolve none.
|
|
177
|
+
* Countries the channel ships to — a PUBLIC channel read (no session), so it
|
|
178
|
+
* belongs on this server singleton: the checkout page prefetches it and hands
|
|
179
|
+
* the list to the client flow.
|
|
193
180
|
*/
|
|
194
|
-
export function
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
currency,
|
|
198
|
-
});
|
|
199
|
-
const exponent = formatter.resolvedOptions().maximumFractionDigits ?? 2;
|
|
200
|
-
return formatter.format(minorUnits / 10 ** exponent);
|
|
181
|
+
export async function getAvailableCountries(): Promise<Country[]> {
|
|
182
|
+
const { availableCountries } = await getShopClient().country.availableCountries();
|
|
183
|
+
return availableCountries;
|
|
201
184
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ShopProductFieldFragment as Product,
|
|
3
|
+
ShopSellingPlanFieldFragment as SellingPlan,
|
|
4
|
+
} from '@forgecart/sdk/shop';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pure display helpers over SDK types — safe to import from ANY component.
|
|
8
|
+
*
|
|
9
|
+
* This module carries no runtime SDK dependency: the `@forgecart/sdk/shop`
|
|
10
|
+
* import above is type-only, so it erases at compile time and none of the
|
|
11
|
+
* client (`ForgeCartShopClient`, graphql-ws) ever reaches a browser bundle.
|
|
12
|
+
* Client components import their formatting from here; the SDK-bearing
|
|
13
|
+
* modules (`forgecart.ts`, `cart-actions.ts`) are `server-only` and reject a
|
|
14
|
+
* client value-import at build time.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Format a minor-unit price as a currency string. Defaults to USD; pass a
|
|
19
|
+
* different ISO currency code as needed.
|
|
20
|
+
*
|
|
21
|
+
* ForgeCart prices are integers in the currency's smallest denomination, so
|
|
22
|
+
* the divisor depends on the currency's ISO-4217 exponent: 2500 is $25.00
|
|
23
|
+
* (two-decimal cents) but ¥2,500 (zero-decimal yen) and BHD 2.500
|
|
24
|
+
* (three-decimal fils). The exponent is derived from the same
|
|
25
|
+
* `Intl.NumberFormat` instance that renders the price — its
|
|
26
|
+
* `resolvedOptions().maximumFractionDigits` carries the CLDR fraction digits
|
|
27
|
+
* for the currency — so the divisor always agrees with the digits shown,
|
|
28
|
+
* with a defensive fallback of 2 should the formatter resolve none.
|
|
29
|
+
*/
|
|
30
|
+
export function formatPrice(minorUnits: number, currency = 'USD'): string {
|
|
31
|
+
const formatter = new Intl.NumberFormat('en-US', {
|
|
32
|
+
style: 'currency',
|
|
33
|
+
currency,
|
|
34
|
+
});
|
|
35
|
+
const exponent = formatter.resolvedOptions().maximumFractionDigits ?? 2;
|
|
36
|
+
return formatter.format(minorUnits / 10 ** exponent);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Lowest variant price for a product, in minor units, or `null` if none. */
|
|
40
|
+
export function getStartingPrice(product: Product): number | null {
|
|
41
|
+
if (product.variants.length === 0) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
return product.variants.reduce(
|
|
45
|
+
(min, v) => (v.priceWithTax < min ? v.priceWithTax : min),
|
|
46
|
+
product.variants[0].priceWithTax,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Preview the per-unit price a plan yields from the one-time `priceWithTax`.
|
|
52
|
+
*
|
|
53
|
+
* Mirrors the server's pricing policy: `none` keeps the price, `percentage`
|
|
54
|
+
* applies the percent discount (rounded to whole minor units), `fixed_amount`
|
|
55
|
+
* subtracts the minor-unit adjustment (floored at zero). A `null`
|
|
56
|
+
* `adjustmentValue` (only valid for `none`) is treated as no adjustment.
|
|
57
|
+
*/
|
|
58
|
+
export function getPlanPreviewPrice(basePrice: number, plan: SellingPlan): number {
|
|
59
|
+
if (plan.pricingPolicy === 'percentage') {
|
|
60
|
+
const adjustment = plan.adjustmentValue ?? 0;
|
|
61
|
+
// `adjustmentValue` is a whole-number percent (e.g. 15 -> 15% off).
|
|
62
|
+
const PERCENT_BASE = 100;
|
|
63
|
+
return Math.round(basePrice * (1 - adjustment / PERCENT_BASE));
|
|
64
|
+
}
|
|
65
|
+
if (plan.pricingPolicy === 'fixed_amount') {
|
|
66
|
+
return Math.max(0, basePrice - (plan.adjustmentValue ?? 0));
|
|
67
|
+
}
|
|
68
|
+
return basePrice;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A short human-readable savings hint for a plan, or `null` when it offers no
|
|
73
|
+
* discount (policy `none`, a zero adjustment, or a non-positive percentage).
|
|
74
|
+
*/
|
|
75
|
+
export function getPlanSavingsLabel(plan: SellingPlan, currency = 'USD'): string | null {
|
|
76
|
+
if (plan.pricingPolicy === 'percentage' && (plan.adjustmentValue ?? 0) > 0) {
|
|
77
|
+
return `Save ${plan.adjustmentValue}%`;
|
|
78
|
+
}
|
|
79
|
+
if (plan.pricingPolicy === 'fixed_amount' && (plan.adjustmentValue ?? 0) > 0) {
|
|
80
|
+
return `Save ${formatPrice(plan.adjustmentValue ?? 0, currency)}`;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The billing cadence as a phrase, e.g. "every 1 monthly" or "every 2 weekly",
|
|
87
|
+
* built from the plan's interval count and the (lower-cased) billing interval.
|
|
88
|
+
*/
|
|
89
|
+
export function getPlanCadenceLabel(plan: SellingPlan): string {
|
|
90
|
+
return `every ${plan.intervalCount} ${plan.billingInterval.toLowerCase()}`;
|
|
91
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import 'server-only';
|
|
4
|
+
|
|
5
|
+
import { cookies } from 'next/headers';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Session custody glue for the PROGRESSIVE stack.
|
|
9
|
+
*
|
|
10
|
+
* The shopper's session lives CLIENT-SIDE (the browser's own shop websocket
|
|
11
|
+
* mints and carries it — see `shop-session.ts`); the server only needs to
|
|
12
|
+
* SEE it so SSR (checkout gating, any future server read) stays coherent
|
|
13
|
+
* with the cart the shopper built over their socket. Two cookies, one value:
|
|
14
|
+
*
|
|
15
|
+
* - `forgecart-session` httpOnly — the server-readable copy.
|
|
16
|
+
* - `forgecart-session-client` JS-readable mirror — what the browser
|
|
17
|
+
* socket boots from on the next visit.
|
|
18
|
+
*
|
|
19
|
+
* The mirror is deliberately not httpOnly: the session token is the
|
|
20
|
+
*shopper's own low-privilege shop session (channel-scoped; the admin secret
|
|
21
|
+
* never reaches the browser), and the client socket cannot exist without
|
|
22
|
+
* reading it. Both cookies always carry the same value — this action is the
|
|
23
|
+
* single writer.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const SESSION_COOKIE = 'forgecart-session';
|
|
27
|
+
const SESSION_MIRROR_COOKIE = 'forgecart-session-client';
|
|
28
|
+
const SESSION_MAX_AGE = 60 * 60 * 24 * 30;
|
|
29
|
+
/** Session tokens are opaque but bounded — reject junk before it hits a header. */
|
|
30
|
+
const MAX_TOKEN_LENGTH = 512;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Persist (or clear, with `null`) the client-minted session token into both
|
|
34
|
+
* cookies. Called by the browser session layer whenever its socket captures
|
|
35
|
+
* a new token; idempotent when the value already matches.
|
|
36
|
+
*/
|
|
37
|
+
export async function syncShopSession(token: string | null): Promise<void> {
|
|
38
|
+
const store = await cookies();
|
|
39
|
+
if (token === null) {
|
|
40
|
+
store.delete(SESSION_COOKIE);
|
|
41
|
+
store.delete(SESSION_MIRROR_COOKIE);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
if (token.length === 0 || token.length > MAX_TOKEN_LENGTH) return;
|
|
45
|
+
if (
|
|
46
|
+
store.get(SESSION_COOKIE)?.value === token &&
|
|
47
|
+
store.get(SESSION_MIRROR_COOKIE)?.value === token
|
|
48
|
+
) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const shared = { sameSite: 'lax' as const, path: '/', maxAge: SESSION_MAX_AGE };
|
|
52
|
+
store.set(SESSION_COOKIE, token, { ...shared, httpOnly: true });
|
|
53
|
+
store.set(SESSION_MIRROR_COOKIE, token, { ...shared, httpOnly: false });
|
|
54
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The channel's shop-API coordinates, read once from the environment
|
|
5
|
+
* (`forgecart init` writes them into `.env`).
|
|
6
|
+
*
|
|
7
|
+
* Two consumers, two exposure levels — and potentially TWO URLS (split
|
|
8
|
+
* horizon):
|
|
9
|
+
* - Server modules (`forgecart.ts`, the embedded backend) read
|
|
10
|
+
* `FORGECART_SHOP_API_URL` — in a workspace pod that is an INTERNAL
|
|
11
|
+
* address (docker bridge / cluster network) only the pod can reach.
|
|
12
|
+
* - The BROWSER receives its config via {@link getBrowserShopConfig} — the
|
|
13
|
+
* layout serializes it into the client `CartProvider`, which boots the
|
|
14
|
+
* shopper's own session websocket. The browser needs the PUBLIC
|
|
15
|
+
* `FORGECART_SHOP_API_BROWSER_URL` (HTTPS, so the socket runs over WSS —
|
|
16
|
+
* an internal ws:// endpoint is mixed content on an HTTPS page and
|
|
17
|
+
* unreachable from a shopper's machine anyway). When the browser URL is
|
|
18
|
+
* absent, the server URL serves both sides (deployed storefronts and
|
|
19
|
+
* loopback dev, where one origin is reachable from everywhere).
|
|
20
|
+
*
|
|
21
|
+
* The channel token is the shop API's PUBLIC storefront token (it scopes
|
|
22
|
+
* reads/writes to the channel and a shopper session); the admin secret never
|
|
23
|
+
* takes this path.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
|
|
27
|
+
const SHOP_API_BROWSER_URL = process.env.FORGECART_SHOP_API_BROWSER_URL ?? SHOP_API_URL;
|
|
28
|
+
const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
|
|
29
|
+
|
|
30
|
+
export interface BrowserShopConfig {
|
|
31
|
+
apiUrl: string;
|
|
32
|
+
channelToken: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether `forgecart init` has written the shop env yet (pre-warm renders without it). */
|
|
36
|
+
export function isShopConfigured(): boolean {
|
|
37
|
+
return Boolean(SHOP_API_URL && CHANNEL_TOKEN);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The client-socket coordinates, or null on an unconfigured scaffold. */
|
|
41
|
+
export function getBrowserShopConfig(): BrowserShopConfig | null {
|
|
42
|
+
if (!isShopConfigured()) return null;
|
|
43
|
+
return { apiUrl: SHOP_API_BROWSER_URL, channelToken: CHANNEL_TOKEN };
|
|
44
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
// The /shop subpath is the ONLY SDK entry browser code may import: the SDK
|
|
4
|
+
// compiles to CommonJS (never tree-shaken), and only the /shop module graph
|
|
5
|
+
// is guaranteed admin-free — the root barrel would bundle the admin client
|
|
6
|
+
// into every shopper's browser (the pod image build greps chunks for exactly
|
|
7
|
+
// that and fails).
|
|
8
|
+
import { ForgeCartShopClient, extractError } from '@forgecart/sdk/shop';
|
|
9
|
+
|
|
10
|
+
import { UNREACHABLE_ERROR, type ActionResult } from './action-result';
|
|
11
|
+
import { syncShopSession } from './session-actions';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The shopper's OWN shop websocket — the client half of the progressive
|
|
15
|
+
* stack. Sockets are session-based, so the session socket lives where the
|
|
16
|
+
* session lives: in the browser, one long-lived client per tab. The server
|
|
17
|
+
* never builds session-bearing sockets (its two clients are the anonymous
|
|
18
|
+
* channel singleton in `forgecart.ts` and the embedded backend's admin
|
|
19
|
+
* singleton); it only prefetches public data and mirrors the session cookie.
|
|
20
|
+
*
|
|
21
|
+
* Lifecycle:
|
|
22
|
+
* - `initShopSession(config)` (the CartProvider mount) arms the singleton
|
|
23
|
+
* with the channel coordinates; the socket itself opens lazily on the
|
|
24
|
+
* first operation and the SDK's `lazyCloseTimeout` parks it when idle.
|
|
25
|
+
* - The client boots its auth from the JS-readable mirror cookie
|
|
26
|
+
* (`forgecart-session-client`), so a returning shopper's socket adopts
|
|
27
|
+
* the same session SSR saw.
|
|
28
|
+
* - After every operation, a newly-captured session token (the SDK reads
|
|
29
|
+
* it from result extensions on the first mutation) is synced back into
|
|
30
|
+
* BOTH cookies via the `syncShopSession` server action — the next SSR
|
|
31
|
+
* pass sees the cart this socket built.
|
|
32
|
+
*
|
|
33
|
+
* Every operation returns the same `ActionResult` envelope the old Server
|
|
34
|
+
* Actions used, so calling components keep their error contract: typed
|
|
35
|
+
* in-band errors, never a throw.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
export interface ShopSessionConfig {
|
|
39
|
+
apiUrl: string;
|
|
40
|
+
channelToken: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const SESSION_MIRROR_COOKIE = 'forgecart-session-client';
|
|
44
|
+
|
|
45
|
+
let config: ShopSessionConfig | null = null;
|
|
46
|
+
let client: ForgeCartShopClient | null = null;
|
|
47
|
+
let syncedToken: string | null = null;
|
|
48
|
+
|
|
49
|
+
/** Read the session mirror cookie (null outside the browser or when absent). */
|
|
50
|
+
export function readMirrorSession(): string | null {
|
|
51
|
+
if (typeof document === 'undefined') return null;
|
|
52
|
+
const entry = document.cookie
|
|
53
|
+
.split('; ')
|
|
54
|
+
.find((candidate) => candidate.startsWith(`${SESSION_MIRROR_COOKIE}=`));
|
|
55
|
+
if (!entry) return null;
|
|
56
|
+
const value = decodeURIComponent(entry.slice(SESSION_MIRROR_COOKIE.length + 1));
|
|
57
|
+
return value.length > 0 ? value : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Arm the singleton with the channel coordinates (idempotent; provider mount). */
|
|
61
|
+
export function initShopSession(next: ShopSessionConfig): void {
|
|
62
|
+
config = next;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Whether the session socket can exist (configured scaffold + browser). */
|
|
66
|
+
export function isSessionReady(): boolean {
|
|
67
|
+
return config !== null && typeof window !== 'undefined';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ensureClient(): ForgeCartShopClient {
|
|
71
|
+
if (!config) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
'Shop session not initialized — CartProvider must mount with shopConfig first.',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (!client) {
|
|
77
|
+
client = new ForgeCartShopClient({
|
|
78
|
+
endpoint: config.apiUrl,
|
|
79
|
+
channelToken: config.channelToken,
|
|
80
|
+
});
|
|
81
|
+
const existing = readMirrorSession();
|
|
82
|
+
if (existing) {
|
|
83
|
+
client.setAuthToken(existing);
|
|
84
|
+
syncedToken = existing;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return client;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Run one session operation on the shopper's socket and return the typed
|
|
92
|
+
* envelope. Session capture: when the operation minted or rotated the
|
|
93
|
+
* session, the new token is synced into the cookies fire-and-forget —
|
|
94
|
+
* a sync failure never breaks the shopper's interaction.
|
|
95
|
+
*/
|
|
96
|
+
export async function runSessionOp<T>(
|
|
97
|
+
op: (client: ForgeCartShopClient) => Promise<T>,
|
|
98
|
+
): Promise<ActionResult<T>> {
|
|
99
|
+
if (!isSessionReady()) {
|
|
100
|
+
return { ok: false, error: UNREACHABLE_ERROR };
|
|
101
|
+
}
|
|
102
|
+
const live = ensureClient();
|
|
103
|
+
try {
|
|
104
|
+
const data = await op(live);
|
|
105
|
+
const token = live.getAuthToken();
|
|
106
|
+
if (token && token !== syncedToken) {
|
|
107
|
+
syncedToken = token;
|
|
108
|
+
void syncShopSession(token).catch(() => undefined);
|
|
109
|
+
}
|
|
110
|
+
return { ok: true, data };
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return { ok: false, error: extractError(error) ?? UNREACHABLE_ERROR };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v4 UUID that works in EVERY browsing context this storefront serves from.
|
|
3
|
+
*
|
|
4
|
+
* `crypto.randomUUID` is secure-context gated: it exists on https origins and
|
|
5
|
+
* on localhost, but NOT on plain-http LAN/preview hosts — exactly the local
|
|
6
|
+
* k3s preview origin (`http://<code>.127.0.0.1.nip.io:8081`) the dev recipe
|
|
7
|
+
* and the dashboard-e2e forge suite drive. A bare call there is a TypeError
|
|
8
|
+
* mid-render (the checkout AddressStep crash, 2026-08-09). `getRandomValues`
|
|
9
|
+
* carries no such gate, so mint the same v4 shape by hand when the fast path
|
|
10
|
+
* is missing.
|
|
11
|
+
*/
|
|
12
|
+
export function mintUuid(): string {
|
|
13
|
+
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
|
14
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
15
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
16
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
17
|
+
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
|
18
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
19
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { Module } from '@nestjs/common';
|
|
4
|
+
|
|
5
|
+
import { CustomerExtrasModule } from './customer-extras/customer-extras.module';
|
|
6
|
+
import { ForgeCartModule } from './forgecart/forgecart.module';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Root of the storefront's embedded NestJS backend.
|
|
10
|
+
*
|
|
11
|
+
* `bootstrap.ts` boots this module as an APPLICATION CONTEXT — the module
|
|
12
|
+
* system, DI container, and lifecycle only; Next.js keeps owning HTTP — and
|
|
13
|
+
* scans it for `@BackendMethod()` methods, which become the `sdk.backend`
|
|
14
|
+
* surface. Every domain module registers here; a module that is not
|
|
15
|
+
* imported does not exist to the scan.
|
|
16
|
+
*/
|
|
17
|
+
@Module({ imports: [ForgeCartModule, CustomerExtrasModule] })
|
|
18
|
+
export class AppModule {}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import type {
|
|
4
|
+
AssignCustomerHashInput,
|
|
5
|
+
AssignCustomerHashResult,
|
|
6
|
+
} from './customer-extras/type/customer-extras.types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The compile-time surface of the backend gate — the typed twin of the
|
|
10
|
+
* runtime `@BackendMethod()` scan.
|
|
11
|
+
*
|
|
12
|
+
* `lib/backend-client.ts` types the `sdk.backend` proxy from this interface
|
|
13
|
+
* (a type-only import, so nothing here reaches the client bundle), and
|
|
14
|
+
* `bootstrap.ts` validates at boot that the decorator scan discovered
|
|
15
|
+
* EXACTLY the names below — a mismatch in either direction fails the boot
|
|
16
|
+
* with a message naming the drifted method.
|
|
17
|
+
*
|
|
18
|
+
* Adding a method = the decorated service method + one signature here (+
|
|
19
|
+
* the name in the list below). See the `backend-feature-authoring` recipe.
|
|
20
|
+
*/
|
|
21
|
+
export interface BackendApi {
|
|
22
|
+
assignCustomerHash: (input: AssignCustomerHashInput) => Promise<AssignCustomerHashResult>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The declared names, for the boot-time drift check against the scan. */
|
|
26
|
+
export const BACKEND_API_METHOD_NAMES: readonly (keyof BackendApi)[] = ['assignCustomerHash'];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import 'server-only';
|
|
2
|
+
|
|
3
|
+
import { SetMetadata, type CustomDecorator } from '@nestjs/common';
|
|
4
|
+
|
|
5
|
+
/** Metadata key marking a service method as browser-invocable via `sdk.backend`. */
|
|
6
|
+
export const BACKEND_METHOD_KEY = 'forgecart:backend-method';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Opt a service method into the `sdk.backend` gate.
|
|
10
|
+
*
|
|
11
|
+
* The decorator IS the allowlist: the bootstrap scan exposes decorated
|
|
12
|
+
* methods and nothing else, so a method becomes browser-callable only by
|
|
13
|
+
* carrying this marker (plus its line in `backend-api.ts` — the boot-time
|
|
14
|
+
* drift check keeps the two in lock-step). Method names must be unique
|
|
15
|
+
* across ALL services; a duplicate fails the boot loudly.
|
|
16
|
+
*
|
|
17
|
+
* A decorated method runs shopper-invocable with the ADMIN client available
|
|
18
|
+
* in its session — the method body owns the decision of what that authority
|
|
19
|
+
* may be used for, and must treat its `input` as untrusted data.
|
|
20
|
+
*/
|
|
21
|
+
export function BackendMethod(): CustomDecorator<string> {
|
|
22
|
+
return SetMetadata(BACKEND_METHOD_KEY, true);
|
|
23
|
+
}
|