@forgecart/cli 2.202608221935.0 → 2.202609190800.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/cli.js +3 -21
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/__test__/cli-harness.d.ts +21 -0
- package/dist/src/commands/__test__/cli-harness.js +29 -0
- package/dist/src/commands/__test__/cli-harness.js.map +1 -0
- package/dist/src/commands/init.d.ts +28 -2
- package/dist/src/commands/init.js +100 -15
- package/dist/src/commands/init.js.map +1 -1
- package/dist/src/commands/refresh.d.ts +40 -0
- package/dist/src/commands/refresh.js +147 -0
- package/dist/src/commands/refresh.js.map +1 -0
- package/dist/src/commands/template-manifest.d.ts +38 -0
- package/dist/src/commands/template-manifest.js +116 -0
- package/dist/src/commands/template-manifest.js.map +1 -0
- package/dist/src/version.d.ts +10 -0
- package/dist/src/version.js +25 -0
- package/dist/src/version.js.map +1 -0
- package/package.json +1 -1
- package/templates/storefront/README.md +42 -4
- package/templates/storefront/next.config.js +29 -7
- package/templates/storefront/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront/src/app/%5F%5Ffc/track/route.ts +28 -25
- package/templates/storefront/src/app/__forge_beacon/route.ts +1 -1
- package/templates/storefront/src/app/cart/page.tsx +14 -2
- package/templates/storefront/src/app/checkout/page.tsx +14 -2
- package/templates/storefront/src/app/layout.tsx +85 -22
- package/templates/storefront/src/app/page.tsx +63 -20
- package/templates/storefront/src/app/pages/[slug]/not-found.tsx +23 -0
- package/templates/storefront/src/app/pages/[slug]/page.tsx +114 -0
- package/templates/storefront/src/app/ping/route.ts +1 -1
- package/templates/storefront/src/app/products/[slug]/not-found.tsx +6 -4
- package/templates/storefront/src/app/products/[slug]/page.tsx +204 -21
- package/templates/storefront/src/app/products/page.tsx +41 -6
- package/templates/storefront/src/app/register/page.tsx +54 -0
- package/templates/storefront/src/app/reset-password/page.tsx +60 -0
- package/templates/storefront/src/app/robots.ts +69 -0
- package/templates/storefront/src/app/sitemap.ts +106 -0
- package/templates/storefront/src/app/verify/page.tsx +155 -0
- package/templates/storefront/src/components/CartView.tsx +26 -7
- package/templates/storefront/src/components/ForgeTracker.tsx +108 -1
- package/templates/storefront/src/components/Header.tsx +30 -10
- package/templates/storefront/src/components/LanguageSwitcher.tsx +88 -0
- package/templates/storefront/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront/src/components/ProductCard.tsx +10 -4
- package/templates/storefront/src/components/account/AccountMessage.tsx +59 -0
- package/templates/storefront/src/components/account/RegisterForm.tsx +283 -0
- package/templates/storefront/src/components/account/RequestPasswordResetForm.tsx +96 -0
- package/templates/storefront/src/components/account/ResetPasswordForm.tsx +169 -0
- package/templates/storefront/src/components/checkout/CheckoutGate.tsx +12 -4
- package/templates/storefront/src/lib/account/account-link.ts +76 -0
- package/templates/storefront/src/lib/account/register-state.ts +133 -0
- package/templates/storefront/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront/src/lib/account-actions.ts +76 -0
- package/templates/storefront/src/lib/account-session.ts +47 -0
- package/templates/storefront/src/lib/asset-alt.ts +34 -0
- package/templates/storefront/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront/src/lib/experiments.ts +1 -1
- package/templates/storefront/src/lib/forgecart.ts +300 -27
- package/templates/storefront/src/lib/format.ts +12 -14
- package/templates/storefront/src/lib/identify-forward.ts +152 -0
- package/templates/storefront/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront/src/lib/marketing-params.ts +213 -0
- package/templates/storefront/src/lib/money.ts +50 -0
- package/templates/storefront/src/lib/seo/alternates.ts +120 -0
- package/templates/storefront/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront/src/lib/seo/metadata.ts +323 -0
- package/templates/storefront/src/lib/seo/noindex.ts +218 -0
- package/templates/storefront/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront/src/lib/seo/resolve-path.ts +126 -0
- package/templates/storefront/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront/src/lib/seo/sitemap-entries.ts +321 -0
- package/templates/storefront/src/lib/session-actions.ts +15 -8
- package/templates/storefront/src/lib/session-cookies.ts +98 -0
- package/templates/storefront/src/lib/shop-config.ts +9 -2
- package/templates/storefront/src/lib/shop-session.ts +42 -5
- package/templates/storefront/src/lib/track-forward.ts +43 -14
- package/templates/storefront/src/middleware.ts +196 -16
- package/templates/storefront/src/seo/redirects.ts +44 -0
- package/templates/storefront/src/server/runner.ts +1 -2
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
2
|
|
|
3
|
-
import { ForgeCartShopClient } from '@forgecart/sdk/shop';
|
|
3
|
+
import { ForgeCartShopClient, extractError } from '@forgecart/sdk/shop';
|
|
4
4
|
import type {
|
|
5
5
|
ShopProductFieldFragment as Product,
|
|
6
6
|
ShopSellingPlanGroupFieldFragment as SellingPlanGroup,
|
|
7
7
|
} from '@forgecart/sdk/shop';
|
|
8
8
|
|
|
9
|
+
import type { AccountCallOutcome } from './account/account-link';
|
|
10
|
+
import { UNREACHABLE_ERROR } from './action-result';
|
|
11
|
+
import { getRequestLocale } from './locale/request-binding';
|
|
12
|
+
|
|
9
13
|
/**
|
|
10
14
|
* Server-side ForgeCart shop client.
|
|
11
15
|
*
|
|
@@ -35,17 +39,25 @@ import type {
|
|
|
35
39
|
const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
|
|
36
40
|
const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
|
|
37
41
|
|
|
38
|
-
let client: ForgeCartShopClient | null = null;
|
|
39
|
-
|
|
40
42
|
/**
|
|
41
|
-
*
|
|
43
|
+
* One client per language (#1346, W1-8 / S1).
|
|
44
|
+
*
|
|
45
|
+
* The SDK's language is a per-CONNECTION header fixed at socket handshake, so
|
|
46
|
+
* a single instance structurally cannot serve two locales — and mutating a
|
|
47
|
+
* shared one with `setLanguageCode()` is worse than useless here: it DISPOSES
|
|
48
|
+
* the websocket, tearing down whatever concurrent request was mid-flight on
|
|
49
|
+
* it. Hence one client, one language, memoized for the process.
|
|
42
50
|
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
51
|
+
* The map is bounded by the channel's language count, not by traffic: it is
|
|
52
|
+
* keyed on the locale the layout RESOLVED, and the layout only resolves a
|
|
53
|
+
* language the channel offers — `/zz/`, `/qq/` … 404 before they ever reach a
|
|
54
|
+
* data read. Entries are never evicted: eviction would dispose a live socket
|
|
55
|
+
* and force a re-handshake, converting bounded memory into unbounded backend
|
|
56
|
+
* churn. Each idle socket is reaped by the SDK's own `lazyCloseTimeout`.
|
|
47
57
|
*/
|
|
48
|
-
|
|
58
|
+
const clientsByLocale = new Map<string, ForgeCartShopClient>();
|
|
59
|
+
|
|
60
|
+
function assertShopConfigured(): void {
|
|
49
61
|
if (!SHOP_API_URL) {
|
|
50
62
|
throw new Error(
|
|
51
63
|
'FORGECART_SHOP_API_URL is not set. Add it to .env (forgecart init writes it for you).',
|
|
@@ -56,17 +68,43 @@ export function getShopClient(): ForgeCartShopClient {
|
|
|
56
68
|
'FORGECART_CHANNEL_TOKEN is not set. Add it to .env (forgecart init writes it for you).',
|
|
57
69
|
);
|
|
58
70
|
}
|
|
59
|
-
if (!client) {
|
|
60
|
-
client = new ForgeCartShopClient({
|
|
61
|
-
endpoint: SHOP_API_URL,
|
|
62
|
-
channelToken: CHANNEL_TOKEN,
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
return client;
|
|
66
71
|
}
|
|
67
72
|
|
|
68
|
-
/**
|
|
69
|
-
|
|
73
|
+
/**
|
|
74
|
+
* The client for an explicit language.
|
|
75
|
+
*
|
|
76
|
+
* `locale` must be one the channel offers — the caller has already resolved it
|
|
77
|
+
* through `getRequestLocale()`, whose decision function 404s anything else. It
|
|
78
|
+
* is not re-validated here, and it deliberately does NOT fall back to the
|
|
79
|
+
* default on a surprise: silently answering in another language than the
|
|
80
|
+
* document just declared in `<html lang>` is precisely the bug this slice
|
|
81
|
+
* exists to close, and it would look correct in every review.
|
|
82
|
+
*/
|
|
83
|
+
export function getShopClientForLocale(locale: string): ForgeCartShopClient {
|
|
84
|
+
assertShopConfigured();
|
|
85
|
+
const existing = clientsByLocale.get(locale);
|
|
86
|
+
if (existing) return existing;
|
|
87
|
+
|
|
88
|
+
const created = new ForgeCartShopClient({
|
|
89
|
+
endpoint: SHOP_API_URL,
|
|
90
|
+
channelToken: CHANNEL_TOKEN,
|
|
91
|
+
languageCode: locale,
|
|
92
|
+
});
|
|
93
|
+
clientsByLocale.set(locale, created);
|
|
94
|
+
return created;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The client for THIS request's language.
|
|
99
|
+
*
|
|
100
|
+
* Every read below goes through it, so a German page's product names, facets
|
|
101
|
+
* and selling plans come back in German — the half of the locale contract that
|
|
102
|
+
* `<html lang>` alone would only claim.
|
|
103
|
+
*/
|
|
104
|
+
export async function getShopClient(): Promise<ForgeCartShopClient> {
|
|
105
|
+
const { binding } = await getRequestLocale();
|
|
106
|
+
return getShopClientForLocale(binding.locale);
|
|
107
|
+
}
|
|
70
108
|
|
|
71
109
|
// The SDK's operation-shaped fragment types, re-exported under their domain
|
|
72
110
|
// names so components import them from one place. These are exactly what the
|
|
@@ -93,10 +131,43 @@ import type {
|
|
|
93
131
|
GetSessionTemplateQuery,
|
|
94
132
|
GetSessionVariablesQuery,
|
|
95
133
|
RefreshShippingRateGroupsMutation,
|
|
134
|
+
SeoEntriesQuery,
|
|
96
135
|
ShopEligiblePaymentProvidersQuery,
|
|
136
|
+
ShopEntriesQuery,
|
|
97
137
|
ShopOrderByCodeQuery,
|
|
138
|
+
ShopPageByRouteQuery,
|
|
139
|
+
ShopProductQuery,
|
|
98
140
|
} from '@forgecart/sdk/shop';
|
|
99
141
|
|
|
142
|
+
/**
|
|
143
|
+
* A single product AS SELECTED by the detail query — the display fields plus
|
|
144
|
+
* the per-language SEO sidecar (#1341) and `resolvedLanguageCode` (#1339).
|
|
145
|
+
*
|
|
146
|
+
* Distinct from `Product` (the list-row fragment) on purpose: only this shape
|
|
147
|
+
* carries what the slug law needs, and taking it by indexed access means the
|
|
148
|
+
* template cannot drift from what the operation actually asks for.
|
|
149
|
+
*/
|
|
150
|
+
export type ProductWithSeo = NonNullable<ShopProductQuery['product']>;
|
|
151
|
+
|
|
152
|
+
/** One page of the XML sitemap's catalog feed (#1339). */
|
|
153
|
+
export type SeoEntryFeedPage = SeoEntriesQuery['seoEntries'];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* An ACF page definition as `pageByRoute` selects it (#1934) — the merchant's
|
|
157
|
+
* own field definitions, in the order the editor arranged them.
|
|
158
|
+
*
|
|
159
|
+
* Taken by indexed access off the generated query rather than re-declared, like
|
|
160
|
+
* every other shape here: what the route renders and what the operation asks
|
|
161
|
+
* for cannot drift apart.
|
|
162
|
+
*/
|
|
163
|
+
export type PageGroup = NonNullable<ShopPageByRouteQuery['pageByRoute']>;
|
|
164
|
+
/** One field definition of a page — the label and the identity of a value. */
|
|
165
|
+
export type PageFieldDefinition = PageGroup['fieldDefinitions'][number];
|
|
166
|
+
/** One record of a page, with its values and repeater rows. */
|
|
167
|
+
export type PageEntry = ShopEntriesQuery['entries']['items'][number];
|
|
168
|
+
/** One stored value, discriminated by `__typename` over the ACF field types. */
|
|
169
|
+
export type PageFieldValue = PageEntry['fields'][number];
|
|
170
|
+
|
|
100
171
|
export type Country = AvailableCountriesQuery['availableCountries'][number];
|
|
101
172
|
export type ShippingRateGroup =
|
|
102
173
|
RefreshShippingRateGroupsMutation['refreshShippingRateGroups'][number];
|
|
@@ -134,19 +205,34 @@ export async function getProducts(
|
|
|
134
205
|
options: { take?: number; skip?: number } = {},
|
|
135
206
|
): Promise<{ items: Product[]; totalItems: number }> {
|
|
136
207
|
const { take = 24, skip = 0 } = options;
|
|
137
|
-
const { products } = await
|
|
208
|
+
const { products } = await (
|
|
209
|
+
await getShopClient()
|
|
210
|
+
).product.shopProducts({ options: { take, skip } });
|
|
138
211
|
return { items: products.items, totalItems: products.totalItems };
|
|
139
212
|
}
|
|
140
213
|
|
|
141
|
-
/**
|
|
142
|
-
|
|
143
|
-
|
|
214
|
+
/**
|
|
215
|
+
* Fetch a single product by its URL slug. Returns `null` if not found.
|
|
216
|
+
*
|
|
217
|
+
* Typed as {@link ProductWithSeo}, not `Product`: the single-product query
|
|
218
|
+
* selects the per-language SEO sidecar (#1341) alongside the display fields,
|
|
219
|
+
* and the slug law in `lib/seo/resolve-path.ts` reads it to decide whether this
|
|
220
|
+
* URL is the locale's own address, a derived copy, or a redirect. The list
|
|
221
|
+
* queries deliberately do NOT select it — resolving the sidecar per row would
|
|
222
|
+
* pay for it once per card.
|
|
223
|
+
*
|
|
224
|
+
* The lookup resolves across languages (#1339): a slug that belongs to another
|
|
225
|
+
* language, or to this product's PREVIOUS slug, still finds it, and the
|
|
226
|
+
* returned `slug` is this locale's current one.
|
|
227
|
+
*/
|
|
228
|
+
export async function getProductBySlug(slug: string): Promise<ProductWithSeo | null> {
|
|
229
|
+
const { product } = await (await getShopClient()).product.shopProduct({ slug });
|
|
144
230
|
return product ?? null;
|
|
145
231
|
}
|
|
146
232
|
|
|
147
233
|
/** Fetch a single product by id. Returns `null` if not found. */
|
|
148
234
|
export async function getProductById(id: string): Promise<Product | null> {
|
|
149
|
-
const { product } = await getShopClient().product.shopProduct({ id });
|
|
235
|
+
const { product } = await (await getShopClient()).product.shopProduct({ id });
|
|
150
236
|
return product ?? null;
|
|
151
237
|
}
|
|
152
238
|
|
|
@@ -162,23 +248,210 @@ export async function getFeaturedProducts(count = 4): Promise<Product[]> {
|
|
|
162
248
|
export async function getSellingPlanGroupsForVariant(
|
|
163
249
|
variantId: string,
|
|
164
250
|
): Promise<SellingPlanGroup[]> {
|
|
165
|
-
const { sellingPlanGroupsForVariant } =
|
|
166
|
-
await getShopClient()
|
|
251
|
+
const { sellingPlanGroupsForVariant } = await (
|
|
252
|
+
await getShopClient()
|
|
253
|
+
).sellingPlan.sellingPlanGroupsForVariant({ variantId });
|
|
167
254
|
return sellingPlanGroupsForVariant;
|
|
168
255
|
}
|
|
169
256
|
|
|
170
257
|
/** Fetch the channel-wide subscription groups (whole-cart subscribe box). */
|
|
171
258
|
export async function getChannelSellingPlanGroups(): Promise<SellingPlanGroup[]> {
|
|
172
|
-
const { channelSellingPlanGroups } = await
|
|
259
|
+
const { channelSellingPlanGroups } = await (
|
|
260
|
+
await getShopClient()
|
|
261
|
+
).sellingPlan.channelSellingPlanGroups();
|
|
173
262
|
return channelSellingPlanGroups;
|
|
174
263
|
}
|
|
175
264
|
|
|
265
|
+
/**
|
|
266
|
+
* One page of the catalog entries the XML sitemap enumerates (#1339).
|
|
267
|
+
*
|
|
268
|
+
* The connection language is passed in rather than taken from the request,
|
|
269
|
+
* because this feed has no request locale to inherit: it returns EVERY
|
|
270
|
+
* language's path for every entry (languages without a translation row are
|
|
271
|
+
* omitted, never synthesized), so the answer is the same whichever language
|
|
272
|
+
* asks. `/sitemap.xml` is an unprefixed route by grammar — there is no locale
|
|
273
|
+
* in its URL to resolve — and the caller names the channel default so that the
|
|
274
|
+
* choice is visible instead of inherited from a header that is not there.
|
|
275
|
+
*/
|
|
276
|
+
export async function getProductSeoEntries(
|
|
277
|
+
languageCode: string,
|
|
278
|
+
options: { take: number; skip: number },
|
|
279
|
+
): Promise<SeoEntryFeedPage> {
|
|
280
|
+
const { seoEntries } = await getShopClientForLocale(languageCode).seo.seoEntries({
|
|
281
|
+
input: { kind: 'PRODUCT', take: options.take, skip: options.skip },
|
|
282
|
+
});
|
|
283
|
+
return seoEntries;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The page definition mounted at a storefront route (#1934). `null` is "no page
|
|
288
|
+
* here" — an unknown route, a data definition's code, anything outside the
|
|
289
|
+
* route grammar — and the caller turns that into a real 404.
|
|
290
|
+
*
|
|
291
|
+
* METADATA only. Whether the page has content to show is the entries read
|
|
292
|
+
* below; keeping the two apart is what lets `resolvePage` state the whole 404
|
|
293
|
+
* decision in one place instead of splitting it across a query and a render.
|
|
294
|
+
*/
|
|
295
|
+
export async function getPageByRoute(route: string): Promise<PageGroup | null> {
|
|
296
|
+
const { pageByRoute } = await (await getShopClient()).acf.shopPageByRoute({ route });
|
|
297
|
+
return pageByRoute ?? null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The record a page URL serves, as a list of at most one (#1934).
|
|
302
|
+
*
|
|
303
|
+
* `take: 1` because a route is ONE address; `createdAt ASC` because that makes
|
|
304
|
+
* it the page's ORIGINAL record. The shop API's own default is `createdAt
|
|
305
|
+
* DESC`, and inheriting it would mean that adding a second record to a live
|
|
306
|
+
* page silently REPLACES what the URL has been serving — a content change
|
|
307
|
+
* nobody asked for, made by a create. Ascending is a stable answer: the page a
|
|
308
|
+
* merchant published stays the page at that URL.
|
|
309
|
+
*
|
|
310
|
+
* The list may come back EMPTY, and that is the feature's whole 404 arm: every
|
|
311
|
+
* storefront read is fenced to published content, so a page whose only records
|
|
312
|
+
* are DRAFT or SCHEDULED-not-yet-live looks exactly like a page with no records
|
|
313
|
+
* at all. `resolvePage` decides; this function only asks.
|
|
314
|
+
*/
|
|
315
|
+
export async function getPageEntries(definitionCode: string): Promise<PageEntry[]> {
|
|
316
|
+
const { entries } = await (
|
|
317
|
+
await getShopClient()
|
|
318
|
+
).acf.shopEntries({
|
|
319
|
+
definitionCode,
|
|
320
|
+
options: { take: 1, sort: [{ field: 'createdAt', direction: 'ASC' }] },
|
|
321
|
+
});
|
|
322
|
+
return entries.items;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Every ACF page route the shop currently SERVES (#1934) — the sitemap's page
|
|
327
|
+
* feed.
|
|
328
|
+
*
|
|
329
|
+
* A stricter question than `getPageByRoute`'s, deliberately: this list must
|
|
330
|
+
* contain no URL that would answer 404, because a sitemap entry that does costs
|
|
331
|
+
* crawl budget and lands in Search Console as an error. The API applies that
|
|
332
|
+
* gate; unpublishing a page takes it out of this list, and out of the document
|
|
333
|
+
* on the next window.
|
|
334
|
+
*
|
|
335
|
+
* The connection language is named by the caller for the same reason
|
|
336
|
+
* `getProductSeoEntries` names it: `/sitemap.xml` is an unprefixed route with
|
|
337
|
+
* no request locale to inherit, and a route exists in every language alike.
|
|
338
|
+
*/
|
|
339
|
+
export async function getServedPageRoutes(languageCode: string): Promise<string[]> {
|
|
340
|
+
const { pageRoutes } = await getShopClientForLocale(languageCode).acf.shopPageRoutes();
|
|
341
|
+
return pageRoutes;
|
|
342
|
+
}
|
|
343
|
+
|
|
176
344
|
/**
|
|
177
345
|
* Countries the channel ships to — a PUBLIC channel read (no session), so it
|
|
178
346
|
* belongs on this server singleton: the checkout page prefetches it and hands
|
|
179
347
|
* the list to the client flow.
|
|
180
348
|
*/
|
|
181
349
|
export async function getAvailableCountries(): Promise<Country[]> {
|
|
182
|
-
const { availableCountries } = await getShopClient().country.availableCountries();
|
|
350
|
+
const { availableCountries } = await (await getShopClient()).country.availableCountries();
|
|
183
351
|
return availableCountries;
|
|
184
352
|
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* ── The four CUSTOMER-ACCOUNT operations (#1472, #1471) ─────────────────────
|
|
356
|
+
*
|
|
357
|
+
* The platform mails a shopper a verification link and a password-reset link
|
|
358
|
+
* pointing at THIS storefront's origin (`/verify` and `/reset-password`), and
|
|
359
|
+
* three of these are the calls those two pages make. The fourth asks for the
|
|
360
|
+
* verification mail to be sent AGAIN — the prompt `/register` leaves a shopper
|
|
361
|
+
* on, for the one whose first mail never arrived (#1471). They are the only
|
|
362
|
+
* WRITES in this module, and the only functions in it that do not throw.
|
|
363
|
+
*
|
|
364
|
+
* Not throwing is the point. Every read above serves a page whose failure IS a
|
|
365
|
+
* failure — an unreachable catalog is a 500, and the route error boundary is
|
|
366
|
+
* the right answer. For these four, refusal is the ORDINARY case: a
|
|
367
|
+
* one-shot token is used exactly once, so the second click on the same link is
|
|
368
|
+
* an expected outcome to RENDER, not an incident to report. A thrown error
|
|
369
|
+
* would put the shopper on an error page for doing something entirely normal.
|
|
370
|
+
*
|
|
371
|
+
* They answer with {@link AccountCallOutcome} rather than the `ActionResult`
|
|
372
|
+
* envelope the checkout operations use, because there is nothing to carry: the
|
|
373
|
+
* API's answer to a one-shot credential is whether it was accepted and, if
|
|
374
|
+
* not, which `ErrorCode` it refused with. That is a pure, serializable value —
|
|
375
|
+
* it crosses the Server Action boundary unchanged — and `lib/account/*`
|
|
376
|
+
* interprets it in ONE place for both pages. The unreachable/unparseable arm
|
|
377
|
+
* borrows `UNREACHABLE_ERROR`'s code so the whole template spells that
|
|
378
|
+
* condition one way.
|
|
379
|
+
*/
|
|
380
|
+
|
|
381
|
+
/** Verify a customer's e-mail address with the token from their mail. */
|
|
382
|
+
export async function verifyCustomerAccount(token: string): Promise<AccountCallOutcome> {
|
|
383
|
+
return runAccountOperation(async (client) => {
|
|
384
|
+
await client.customer.verifyCustomerAccount({ token });
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Set a new password from the token in a password-reset mail. */
|
|
389
|
+
export async function resetPassword(input: {
|
|
390
|
+
token: string;
|
|
391
|
+
password: string;
|
|
392
|
+
}): Promise<AccountCallOutcome> {
|
|
393
|
+
return runAccountOperation(async (client) => {
|
|
394
|
+
await client.customer.resetPassword({ input });
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Ask the platform to mail a password-reset link.
|
|
400
|
+
*
|
|
401
|
+
* The API's boolean answer (whether an account matched) is deliberately
|
|
402
|
+
* DISCARDED here rather than returned: surfacing it would let a visitor learn
|
|
403
|
+
* whether an address shops here, and a value this module returns is a value a
|
|
404
|
+
* page can render by accident. The neutral answer is decided once, in
|
|
405
|
+
* `lib/account/reset-password-state.ts`.
|
|
406
|
+
*/
|
|
407
|
+
export async function requestPasswordReset(emailAddress: string): Promise<AccountCallOutcome> {
|
|
408
|
+
return runAccountOperation(async (client) => {
|
|
409
|
+
await client.customer.requestPasswordReset({ emailAddress });
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Ask the platform to send the verification mail again (#1471).
|
|
415
|
+
*
|
|
416
|
+
* The API's boolean is deliberately DISCARDED, exactly as the password-reset
|
|
417
|
+
* request's is: a value this module returns is a value a page can render by
|
|
418
|
+
* accident, and rendering whether an address is registered here is an
|
|
419
|
+
* enumeration oracle. The neutral answer is decided once, in
|
|
420
|
+
* `lib/account/register-state.ts`.
|
|
421
|
+
*
|
|
422
|
+
* Unauthenticated by necessity rather than by convenience. A shopper who never
|
|
423
|
+
* confirmed their address cannot log in at all, so a resend sitting behind a
|
|
424
|
+
* login would be unreachable by precisely the people who need it.
|
|
425
|
+
*/
|
|
426
|
+
export async function requestCustomerVerification(
|
|
427
|
+
emailAddress: string,
|
|
428
|
+
): Promise<AccountCallOutcome> {
|
|
429
|
+
return runAccountOperation(async (client) => {
|
|
430
|
+
await client.customer.requestCustomerVerification({ emailAddress });
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Run one account operation and report its outcome.
|
|
436
|
+
*
|
|
437
|
+
* The `try` covers `getShopClient()` as well as the call, and that is
|
|
438
|
+
* deliberate: an unconfigured scaffold throws there (`assertShopConfigured`),
|
|
439
|
+
* and the prewarm contract says a storefront with no `.env` must SERVE rather
|
|
440
|
+
* than crash — so that failure has to arrive as a rendered state like any
|
|
441
|
+
* other, not as an unhandled throw inside a render.
|
|
442
|
+
*
|
|
443
|
+
* This is the template's one sanctioned use of `catch`: wrapping an external
|
|
444
|
+
* SDK's errors at the call site, exactly as `shop-session.ts#runSessionOp` and
|
|
445
|
+
* `server/runner.ts` already do. Nothing is swallowed — every failure leaves
|
|
446
|
+
* here as a code the page renders.
|
|
447
|
+
*/
|
|
448
|
+
async function runAccountOperation(
|
|
449
|
+
operation: (client: ForgeCartShopClient) => Promise<void>,
|
|
450
|
+
): Promise<AccountCallOutcome> {
|
|
451
|
+
try {
|
|
452
|
+
await operation(await getShopClient());
|
|
453
|
+
return { kind: 'ok' };
|
|
454
|
+
} catch (error) {
|
|
455
|
+
return { kind: 'error', code: extractError(error)?.code ?? UNREACHABLE_ERROR.code };
|
|
456
|
+
}
|
|
457
|
+
}
|
|
@@ -3,6 +3,8 @@ import type {
|
|
|
3
3
|
ShopSellingPlanFieldFragment as SellingPlan,
|
|
4
4
|
} from '@forgecart/sdk/shop';
|
|
5
5
|
|
|
6
|
+
import { toMajorUnits } from './money';
|
|
7
|
+
|
|
6
8
|
/**
|
|
7
9
|
* Pure display helpers over SDK types — safe to import from ANY component.
|
|
8
10
|
*
|
|
@@ -18,22 +20,18 @@ import type {
|
|
|
18
20
|
* Format a minor-unit price as a currency string. Defaults to USD; pass a
|
|
19
21
|
* different ISO currency code as needed.
|
|
20
22
|
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* with a defensive fallback of 2 should the formatter resolve none.
|
|
23
|
+
* The minor-unit divisor comes from {@link toMajorUnits}, which derives the
|
|
24
|
+
* currency's ISO-4217 exponent from CLDR — never a hardcoded hundred, which is
|
|
25
|
+
* wrong for zero-decimal yen and three-decimal fils. That derivation moved to
|
|
26
|
+
* `lib/money.ts` when the JSON-LD layer acquired the second consumer: the
|
|
27
|
+
* shopper-facing string and the machine-readable `Offer.price` disagreeing
|
|
28
|
+
* about what 2500 means would publish one price to people and another to
|
|
29
|
+
* search engines.
|
|
29
30
|
*/
|
|
30
31
|
export function formatPrice(minorUnits: number, currency = 'USD'): string {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
});
|
|
35
|
-
const exponent = formatter.resolvedOptions().maximumFractionDigits ?? 2;
|
|
36
|
-
return formatter.format(minorUnits / 10 ** exponent);
|
|
32
|
+
return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(
|
|
33
|
+
toMajorUnits(minorUnits, currency),
|
|
34
|
+
);
|
|
37
35
|
}
|
|
38
36
|
|
|
39
37
|
/** Lowest variant price for a product, in minor units, or `null` if none. */
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildUpstreamHeaders,
|
|
3
|
+
getUpstreamConfig,
|
|
4
|
+
readMintedSessionToken,
|
|
5
|
+
type ForwardHeaders,
|
|
6
|
+
} from './track-forward';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Marketing-identity upstream forwarder (#1596).
|
|
10
|
+
*
|
|
11
|
+
* ONE implementation of "tell the ForgeCart shop API which ad click this
|
|
12
|
+
* shopper arrived from". The click IDs a landing URL carries (`gclid`,
|
|
13
|
+
* `fbclid`, `ttclid`) are read on the client by `lib/marketing-params.ts`,
|
|
14
|
+
* posted to the same-origin `/__fc/identify` relay, and land here as a single
|
|
15
|
+
* `setMarketingIdentifiers` mutation.
|
|
16
|
+
*
|
|
17
|
+
* Why a module of its own rather than a branch inside `track-forward.ts`: a
|
|
18
|
+
* click ID is not an event. It names the IDENTITY the events belong to, it is
|
|
19
|
+
* sent once per landing instead of once per batch, and the ad platforms read it
|
|
20
|
+
* back out of that identity months later when a conversion is reported. The two
|
|
21
|
+
* forwarders share their TRANSPORT and nothing else — `getUpstreamConfig`,
|
|
22
|
+
* `buildUpstreamHeaders` and `readMintedSessionToken` live next door and are
|
|
23
|
+
* imported rather than restated, so the session a request rides can never
|
|
24
|
+
* differ between them; the shapes stay apart because they describe different
|
|
25
|
+
* things.
|
|
26
|
+
*
|
|
27
|
+
* Same discipline as the event forwarder, for the same reasons: raw HTTP
|
|
28
|
+
* GraphQL (the generated SDK transports over one WebSocket whose connection
|
|
29
|
+
* pins ONE identity, and has no plain-HTTP query path), inert while
|
|
30
|
+
* `forgecart init` has not written `.env` yet, and EVERY failure mode —
|
|
31
|
+
* missing config, network, HTTP status, GraphQL errors — resolving to a typed
|
|
32
|
+
* negative outcome instead of a throw. Attribution must never break the page
|
|
33
|
+
* the shopper came for.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The SDK's own `ShopSetMarketingIdentifiers` document, inlined verbatim.
|
|
38
|
+
*
|
|
39
|
+
* Kept character-identical to the shipped operation
|
|
40
|
+
* (`app/client-sdk-generators/src/operations/shop/marketing-identity.graphql`)
|
|
41
|
+
* so checking the two for drift is a diff rather than a reading. `identityId`
|
|
42
|
+
* belongs to that document and is deliberately not read below: the storefront
|
|
43
|
+
* has no use for the backend's internal id, and the client it answers has even
|
|
44
|
+
* less.
|
|
45
|
+
*/
|
|
46
|
+
const SET_MARKETING_IDENTIFIERS_MUTATION = `mutation ShopSetMarketingIdentifiers($input: SetMarketingIdentifiersInput!) {
|
|
47
|
+
setMarketingIdentifiers(input: $input) {
|
|
48
|
+
identityId
|
|
49
|
+
accepted
|
|
50
|
+
rejected
|
|
51
|
+
}
|
|
52
|
+
}`;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One click ID, shaped for the API's `MarketingIdentifierInput`.
|
|
56
|
+
*
|
|
57
|
+
* The client parser's `MarketingIdentifier` is the same pair by design — this
|
|
58
|
+
* declaration exists because the SERVER side of the hop mirrors the SDL (the
|
|
59
|
+
* sibling's `TrackEventInput` does the same), and the relay route rebuilds the
|
|
60
|
+
* pair from untrusted JSON rather than trusting anything the client typed.
|
|
61
|
+
*/
|
|
62
|
+
export interface MarketingIdentifierInput {
|
|
63
|
+
/** The registered identifier key the value is stored under. */
|
|
64
|
+
key: string;
|
|
65
|
+
/** The click ID exactly as the ad platform wrote it into the URL. */
|
|
66
|
+
value: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What the upstream made of the identifiers.
|
|
71
|
+
*
|
|
72
|
+
* `rejected` is NOT an error and must never be turned into one. The backend
|
|
73
|
+
* partitions the submitted keys against its identifier registry, stores the
|
|
74
|
+
* ones it knows, drops the ones it does not and REPORTS both — no throw, no
|
|
75
|
+
* rollback, no partial failure (`MarketingIdentityService.setIdentifiersForShop`).
|
|
76
|
+
* A key landing in `rejected` wrote no row and broke nothing; it means only
|
|
77
|
+
* that this storefront and the backend registry disagree about a spelling,
|
|
78
|
+
* which is a deployment fact worth being able to read and not a runtime
|
|
79
|
+
* condition to handle. Nothing downstream may treat a non-empty `rejected` as a
|
|
80
|
+
* reason to retry, to fail the request, or to hold up the page.
|
|
81
|
+
*/
|
|
82
|
+
export interface IdentifyOutcome {
|
|
83
|
+
/** Identifier keys the backend recognised and stored. */
|
|
84
|
+
accepted: readonly string[];
|
|
85
|
+
/** Keys it does not know — dropped upstream; see the note above. */
|
|
86
|
+
rejected: readonly string[];
|
|
87
|
+
/** Session token surfaced in the response extensions, if any. */
|
|
88
|
+
sessionToken: string | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface GraphQLIdentifyResponse {
|
|
92
|
+
data?: {
|
|
93
|
+
setMarketingIdentifiers?: { accepted?: string[]; rejected?: string[] } | null;
|
|
94
|
+
} | null;
|
|
95
|
+
errors?: unknown[];
|
|
96
|
+
extensions?: Record<string, unknown>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The outcome every failure mode answers with: nothing stored, nothing minted. */
|
|
100
|
+
const NO_IDENTIFIERS_STORED: IdentifyOutcome = { accepted: [], rejected: [], sessionToken: null };
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Forward the captured click IDs as one raw HTTP GraphQL POST.
|
|
104
|
+
*
|
|
105
|
+
* One call for the whole list — the mutation takes an array and the backend
|
|
106
|
+
* writes them against a single identity, so there is no per-item sequencing to
|
|
107
|
+
* get wrong here (the event relay loops only because `trackEvent` takes one
|
|
108
|
+
* event at a time).
|
|
109
|
+
*
|
|
110
|
+
* Any failure — missing config, network, HTTP status, GraphQL errors —
|
|
111
|
+
* resolves to an empty outcome; the forwarder is best-effort by design and the
|
|
112
|
+
* outcome is terminal (callers never retry).
|
|
113
|
+
*/
|
|
114
|
+
export async function forwardMarketingIdentifiers(
|
|
115
|
+
identifiers: readonly MarketingIdentifierInput[],
|
|
116
|
+
forward: ForwardHeaders,
|
|
117
|
+
): Promise<IdentifyOutcome> {
|
|
118
|
+
const upstream = getUpstreamConfig();
|
|
119
|
+
if (!upstream) return NO_IDENTIFIERS_STORED;
|
|
120
|
+
|
|
121
|
+
try {
|
|
122
|
+
const response = await fetch(upstream.shopApiUrl, {
|
|
123
|
+
method: 'POST',
|
|
124
|
+
headers: buildUpstreamHeaders(upstream.channelToken, forward),
|
|
125
|
+
body: JSON.stringify({
|
|
126
|
+
query: SET_MARKETING_IDENTIFIERS_MUTATION,
|
|
127
|
+
variables: { input: { identifiers } },
|
|
128
|
+
}),
|
|
129
|
+
});
|
|
130
|
+
if (!response.ok) return NO_IDENTIFIERS_STORED;
|
|
131
|
+
|
|
132
|
+
const payload = (await response.json()) as GraphQLIdentifyResponse;
|
|
133
|
+
// Read the mint even when the mutation itself did not answer: a cookie-less
|
|
134
|
+
// request mints the session in middleware, BEFORE the resolver runs, so the
|
|
135
|
+
// token is real regardless of what happened to the identifiers — and the
|
|
136
|
+
// relay must persist it or the next request mints a second identity.
|
|
137
|
+
const capturedToken = readMintedSessionToken(payload.extensions);
|
|
138
|
+
const result = payload.data?.setMarketingIdentifiers;
|
|
139
|
+
if (!result) return { accepted: [], rejected: [], sessionToken: capturedToken };
|
|
140
|
+
return {
|
|
141
|
+
accepted: result.accepted ?? [],
|
|
142
|
+
rejected: result.rejected ?? [],
|
|
143
|
+
sessionToken: capturedToken,
|
|
144
|
+
};
|
|
145
|
+
} catch {
|
|
146
|
+
// Best-effort by design: an unreachable or still-booting backend must never
|
|
147
|
+
// break the landing page. The click ID is lost with the page view it came
|
|
148
|
+
// with, which is the accepted cost of never letting analytics hold up a
|
|
149
|
+
// shopper — no caller retries.
|
|
150
|
+
return NO_IDENTIFIERS_STORED;
|
|
151
|
+
}
|
|
152
|
+
}
|