@forgecart/cli 2.202610052310.0 → 2.202610060357.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.
Files changed (125) hide show
  1. package/package.json +1 -1
  2. package/templates/storefront-shadcn/.forgecartignore +2 -0
  3. package/templates/storefront-shadcn/Procfile +1 -0
  4. package/templates/storefront-shadcn/README.md +229 -0
  5. package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
  6. package/templates/storefront-shadcn/components.json +21 -0
  7. package/templates/storefront-shadcn/next.config.js +105 -0
  8. package/templates/storefront-shadcn/package.json +39 -0
  9. package/templates/storefront-shadcn/postcss.config.js +5 -0
  10. package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
  11. package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
  12. package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
  13. package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
  14. package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
  15. package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
  16. package/templates/storefront-shadcn/src/app/error.tsx +23 -0
  17. package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
  18. package/templates/storefront-shadcn/src/app/globals.css +156 -0
  19. package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
  20. package/templates/storefront-shadcn/src/app/page.tsx +217 -0
  21. package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
  22. package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
  23. package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
  24. package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
  25. package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
  26. package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
  27. package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
  28. package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
  29. package/templates/storefront-shadcn/src/app/robots.ts +69 -0
  30. package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
  31. package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
  32. package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
  33. package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
  34. package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
  35. package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
  36. package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
  37. package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
  38. package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
  39. package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
  40. package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
  41. package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
  42. package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
  43. package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
  44. package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
  45. package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
  46. package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
  47. package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
  48. package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
  49. package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
  50. package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
  51. package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
  52. package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
  53. package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
  54. package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
  55. package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
  56. package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
  57. package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
  58. package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
  59. package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
  60. package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
  61. package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
  62. package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
  63. package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
  64. package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
  65. package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
  66. package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
  67. package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
  68. package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
  69. package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
  70. package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
  71. package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
  72. package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
  73. package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
  74. package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
  75. package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
  76. package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
  77. package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
  78. package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
  79. package/templates/storefront-shadcn/src/lib/format.ts +89 -0
  80. package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
  81. package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
  82. package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
  83. package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
  84. package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
  85. package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
  86. package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
  87. package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
  88. package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
  89. package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
  90. package/templates/storefront-shadcn/src/lib/money.ts +50 -0
  91. package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
  92. package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
  93. package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
  94. package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
  95. package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
  96. package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
  97. package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
  98. package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
  99. package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
  100. package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
  101. package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
  102. package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
  103. package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
  104. package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
  105. package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
  106. package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
  107. package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
  108. package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
  109. package/templates/storefront-shadcn/src/middleware.ts +379 -0
  110. package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
  111. package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
  112. package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
  113. package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
  114. package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
  115. package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
  116. package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
  117. package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
  118. package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
  119. package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
  120. package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
  121. package/templates/storefront-shadcn/src/server/runner.ts +90 -0
  122. package/templates/storefront-shadcn/src/server/types.ts +36 -0
  123. package/templates/storefront-shadcn/tsconfig.json +25 -0
  124. package/templates/storefront-shadcn-sdk-floor.json +1174 -0
  125. package/templates/template-set.json +10 -0
@@ -0,0 +1,464 @@
1
+ import 'server-only';
2
+
3
+ import { ForgeCartShopClient, extractError } from '@forgecart/sdk/shop';
4
+ import type {
5
+ ShopProductFieldFragment as Product,
6
+ ShopSellingPlanGroupFieldFragment as SellingPlanGroup,
7
+ } from '@forgecart/sdk/shop';
8
+
9
+ import type { AccountCallOutcome } from './account/account-link';
10
+ import { UNREACHABLE_ERROR } from './action-result';
11
+ import { getRequestLocale } from './locale/request-binding';
12
+
13
+ /**
14
+ * Server-side ForgeCart shop client.
15
+ *
16
+ * The storefront talks to the ForgeCart shop GraphQL API on behalf of a single
17
+ * channel through the SDK's generated, typed operations — every read here is a
18
+ * typed method on the client (no hand-written query documents), and every type
19
+ * the components render is the SDK's own (`@forgecart/sdk/shop`).
20
+ *
21
+ * The channel token is sent as the `forgecart-token` header on every request
22
+ * (the SDK adds it from `channelToken`), and the endpoint points at the
23
+ * channel's shop-api. Both values come from the environment (`.env`,
24
+ * written by `forgecart init`):
25
+ * - FORGECART_SHOP_API_URL -> endpoint
26
+ * - FORGECART_CHANNEL_TOKEN -> channelToken
27
+ *
28
+ * This module is server-only — the `import 'server-only'` above makes a client
29
+ * component's value-import of it a BUILD error, so the channel token and the
30
+ * SDK's websocket transport can never reach the browser. The same guard sits
31
+ * on every SDK-bearing server module (`cart-actions.ts` today; any future
32
+ * action module talking to the shop API adopts it the same way). Client
33
+ * components may still `import type` from here — type imports erase. Pure
34
+ * display helpers live in `./format`, which client components import freely.
35
+ * The construction mirrors the dashboard's `SdkClientService` (see
36
+ * app/dashboard/src/service/sdk-client.service.ts), adapted to read from env.
37
+ */
38
+
39
+ const SHOP_API_URL = process.env.FORGECART_SHOP_API_URL ?? '';
40
+ const CHANNEL_TOKEN = process.env.FORGECART_CHANNEL_TOKEN ?? '';
41
+
42
+ /**
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.
50
+ *
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`.
57
+ */
58
+ const clientsByLocale = new Map<string, ForgeCartShopClient>();
59
+
60
+ function assertShopConfigured(): void {
61
+ if (!SHOP_API_URL) {
62
+ throw new Error(
63
+ 'FORGECART_SHOP_API_URL is not set. Add it to .env (forgecart init writes it for you).',
64
+ );
65
+ }
66
+ if (!CHANNEL_TOKEN) {
67
+ throw new Error(
68
+ 'FORGECART_CHANNEL_TOKEN is not set. Add it to .env (forgecart init writes it for you).',
69
+ );
70
+ }
71
+ }
72
+
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
+ }
108
+
109
+ // The SDK's operation-shaped fragment types, re-exported under their domain
110
+ // names so components import them from one place. These are exactly what the
111
+ // typed operations return: `Product`/`ProductVariant` carry the storefront
112
+ // display fields (name, slug, assets, priced variants); `Order`/`OrderLine`
113
+ // the live cart incl. the subscription fields; the selling-plan pair backs the
114
+ // subscription selector and the whole-cart subscribe box.
115
+ export type {
116
+ ShopOrderFieldFragment as Order,
117
+ ShopOrderLineFieldFragment as OrderLine,
118
+ ShopProductFieldFragment as Product,
119
+ ShopProductVariantFieldFragment as ProductVariant,
120
+ ShopSellingPlanFieldFragment as SellingPlan,
121
+ ShopSellingPlanGroupFieldFragment as SellingPlanGroup,
122
+ } from '@forgecart/sdk/shop';
123
+
124
+ // Checkout-flow result types, aliased off the generated operation results so
125
+ // the components and Server Actions speak the SDK's own shapes (indexed
126
+ // access keeps them exactly as selected — no parallel hand-written types).
127
+ import type {
128
+ AvailableCountriesQuery,
129
+ ConfirmPaymentSessionMutation,
130
+ CreatePaymentSessionMutation,
131
+ GetSessionTemplateQuery,
132
+ GetSessionVariablesQuery,
133
+ RefreshShippingRateGroupsMutation,
134
+ SeoEntriesQuery,
135
+ ShopEligiblePaymentProvidersQuery,
136
+ ShopOrderByCodeQuery,
137
+ ShopPageByRouteQuery,
138
+ ShopPageEntriesQuery,
139
+ ShopProductQuery,
140
+ } from '@forgecart/sdk/shop';
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
+ /**
167
+ * One record of a page, with its values, its repeater rows and its per-language
168
+ * SEO sidecar (#1375) — the page's title, description, indexability and
169
+ * alternates are read off `seo` by `lib/content/page-metadata.ts`.
170
+ */
171
+ export type PageEntry = ShopPageEntriesQuery['entries']['items'][number];
172
+ /** One stored value, discriminated by `__typename` over the ACF field types. */
173
+ export type PageFieldValue = PageEntry['fields'][number];
174
+
175
+ export type Country = AvailableCountriesQuery['availableCountries'][number];
176
+ export type ShippingRateGroup =
177
+ RefreshShippingRateGroupsMutation['refreshShippingRateGroups'][number];
178
+ export type ShippingRate = ShippingRateGroup['rates'][number];
179
+ export type EligiblePaymentProvider =
180
+ ShopEligiblePaymentProvidersQuery['eligiblePaymentProviders'][number];
181
+ export type PaymentSession = CreatePaymentSessionMutation['createPaymentSession'];
182
+ export type PaymentTemplate = GetSessionTemplateQuery['getSessionTemplate'];
183
+ export type PaymentVariables = GetSessionVariablesQuery['getSessionVariables'];
184
+ export type ConfirmedPaymentSession = ConfirmPaymentSessionMutation['confirmPaymentSession'];
185
+ export type CheckoutOrder = NonNullable<ShopOrderByCodeQuery['orderByCode']>;
186
+
187
+ /**
188
+ * The order fields the checkout flow renders and gates on — the structural
189
+ * common denominator of every checkout mutation's Order selection (each
190
+ * operation selects a slightly different super-set of these, so no single
191
+ * generated type fits them all; this view is the one deliberate non-SDK
192
+ * shape, and every SDK result assigns to it).
193
+ */
194
+ export interface CheckoutOrderView {
195
+ id: string;
196
+ code: string;
197
+ state: string;
198
+ currencyCode: string;
199
+ totalWithTax: number;
200
+ totalQuantity: number;
201
+ }
202
+
203
+ /**
204
+ * Fetch a page of products for the channel.
205
+ *
206
+ * `take` / `skip` map onto the shop API's `ListQueryOptions`.
207
+ */
208
+ export async function getProducts(
209
+ options: { take?: number; skip?: number } = {},
210
+ ): Promise<{ items: Product[]; totalItems: number }> {
211
+ const { take = 24, skip = 0 } = options;
212
+ const { products } = await (
213
+ await getShopClient()
214
+ ).product.shopProducts({ options: { take, skip } });
215
+ return { items: products.items, totalItems: products.totalItems };
216
+ }
217
+
218
+ /**
219
+ * Fetch a single product by its URL slug. Returns `null` if not found.
220
+ *
221
+ * Typed as {@link ProductWithSeo}, not `Product`: the single-product query
222
+ * selects the per-language SEO sidecar (#1341) alongside the display fields,
223
+ * and the slug law in `lib/seo/resolve-path.ts` reads it to decide whether this
224
+ * URL is the locale's own address, a derived copy, or a redirect. The list
225
+ * queries deliberately do NOT select it — resolving the sidecar per row would
226
+ * pay for it once per card.
227
+ *
228
+ * The lookup resolves across languages (#1339): a slug that belongs to another
229
+ * language, or to this product's PREVIOUS slug, still finds it, and the
230
+ * returned `slug` is this locale's current one.
231
+ */
232
+ export async function getProductBySlug(slug: string): Promise<ProductWithSeo | null> {
233
+ const { product } = await (await getShopClient()).product.shopProduct({ slug });
234
+ return product ?? null;
235
+ }
236
+
237
+ /** Fetch a single product by id. Returns `null` if not found. */
238
+ export async function getProductById(id: string): Promise<Product | null> {
239
+ const { product } = await (await getShopClient()).product.shopProduct({ id });
240
+ return product ?? null;
241
+ }
242
+
243
+ /**
244
+ * Convenience: fetch a small set of products to feature on the home page.
245
+ */
246
+ export async function getFeaturedProducts(count = 4): Promise<Product[]> {
247
+ const { items } = await getProducts({ take: count });
248
+ return items;
249
+ }
250
+
251
+ /** Fetch the subscription groups a given variant is eligible for (per-line subscribe). */
252
+ export async function getSellingPlanGroupsForVariant(
253
+ variantId: string,
254
+ ): Promise<SellingPlanGroup[]> {
255
+ const { sellingPlanGroupsForVariant } = await (
256
+ await getShopClient()
257
+ ).sellingPlan.sellingPlanGroupsForVariant({ variantId });
258
+ return sellingPlanGroupsForVariant;
259
+ }
260
+
261
+ /** Fetch the channel-wide subscription groups (whole-cart subscribe box). */
262
+ export async function getChannelSellingPlanGroups(): Promise<SellingPlanGroup[]> {
263
+ const { channelSellingPlanGroups } = await (
264
+ await getShopClient()
265
+ ).sellingPlan.channelSellingPlanGroups();
266
+ return channelSellingPlanGroups;
267
+ }
268
+
269
+ /**
270
+ * One page of the catalog entries the XML sitemap enumerates (#1339).
271
+ *
272
+ * The connection language is passed in rather than taken from the request,
273
+ * because this feed has no request locale to inherit: it returns EVERY
274
+ * language's path for every entry (languages without a translation row are
275
+ * omitted, never synthesized), so the answer is the same whichever language
276
+ * asks. `/sitemap.xml` is an unprefixed route by grammar — there is no locale
277
+ * in its URL to resolve — and the caller names the channel default so that the
278
+ * choice is visible instead of inherited from a header that is not there.
279
+ */
280
+ export async function getProductSeoEntries(
281
+ languageCode: string,
282
+ options: { take: number; skip: number },
283
+ ): Promise<SeoEntryFeedPage> {
284
+ const { seoEntries } = await getShopClientForLocale(languageCode).seo.seoEntries({
285
+ input: { kind: 'PRODUCT', take: options.take, skip: options.skip },
286
+ });
287
+ return seoEntries;
288
+ }
289
+
290
+ /**
291
+ * The page definition mounted at a storefront route (#1934). `null` is "no page
292
+ * here" — an unknown route, a data definition's code, anything outside the
293
+ * route grammar — and the caller turns that into a real 404.
294
+ *
295
+ * METADATA only. Whether the page has content to show is the entries read
296
+ * below; keeping the two apart is what lets `resolvePage` state the whole 404
297
+ * decision in one place instead of splitting it across a query and a render.
298
+ */
299
+ export async function getPageByRoute(route: string): Promise<PageGroup | null> {
300
+ const { pageByRoute } = await (await getShopClient()).acf.shopPageByRoute({ route });
301
+ return pageByRoute ?? null;
302
+ }
303
+
304
+ /**
305
+ * The record a page URL serves, as a list of at most one (#1934), with its SEO
306
+ * sidecar (#1375).
307
+ *
308
+ * `take: 1` because a route is ONE address; `createdAt ASC` because that makes
309
+ * it the page's ORIGINAL record. The shop API's own default is `createdAt
310
+ * DESC`, and inheriting it would mean that adding a second record to a live
311
+ * page silently REPLACES what the URL has been serving — a content change
312
+ * nobody asked for, made by a create. Ascending is a stable answer: the page a
313
+ * merchant published stays the page at that URL. The bound also prices the
314
+ * sidecar: `seo` costs one per-language resolve per returned record, which is
315
+ * why it rides this one-record read (`ShopPageEntries`) and never a listing.
316
+ *
317
+ * The list may come back EMPTY, and that is the feature's whole 404 arm: every
318
+ * storefront read is fenced to published content, so a page whose only records
319
+ * are DRAFT or SCHEDULED-not-yet-live looks exactly like a page with no records
320
+ * at all. `resolvePage` decides; this function only asks.
321
+ */
322
+ export async function getPageEntries(definitionCode: string): Promise<PageEntry[]> {
323
+ const { entries } = await (
324
+ await getShopClient()
325
+ ).acf.shopPageEntries({
326
+ definitionCode,
327
+ options: { take: 1, sort: [{ field: 'createdAt', direction: 'ASC' }] },
328
+ });
329
+ return entries.items;
330
+ }
331
+
332
+ /**
333
+ * Every ACF page route the shop currently SERVES (#1934) — the sitemap's page
334
+ * feed.
335
+ *
336
+ * A stricter question than `getPageByRoute`'s, deliberately: this list must
337
+ * contain no URL that would answer 404, because a sitemap entry that does costs
338
+ * crawl budget and lands in Search Console as an error. The API applies that
339
+ * gate; unpublishing a page takes it out of this list, and out of the document
340
+ * on the next window.
341
+ *
342
+ * The connection language is named by the caller for the same reason
343
+ * `getProductSeoEntries` names it: `/sitemap.xml` is an unprefixed route with
344
+ * no request locale to inherit, and a route exists in every language alike.
345
+ */
346
+ export async function getServedPageRoutes(languageCode: string): Promise<string[]> {
347
+ const { pageRoutes } = await getShopClientForLocale(languageCode).acf.shopPageRoutes();
348
+ return pageRoutes;
349
+ }
350
+
351
+ /**
352
+ * Countries the channel ships to — a PUBLIC channel read (no session), so it
353
+ * belongs on this server singleton: the checkout page prefetches it and hands
354
+ * the list to the client flow.
355
+ */
356
+ export async function getAvailableCountries(): Promise<Country[]> {
357
+ const { availableCountries } = await (await getShopClient()).country.availableCountries();
358
+ return availableCountries;
359
+ }
360
+
361
+ /**
362
+ * ── The four CUSTOMER-ACCOUNT operations (#1472, #1471) ─────────────────────
363
+ *
364
+ * The platform mails a shopper a verification link and a password-reset link
365
+ * pointing at THIS storefront's origin (`/verify` and `/reset-password`), and
366
+ * three of these are the calls those two pages make. The fourth asks for the
367
+ * verification mail to be sent AGAIN — the prompt `/register` leaves a shopper
368
+ * on, for the one whose first mail never arrived (#1471). They are the only
369
+ * WRITES in this module, and the only functions in it that do not throw.
370
+ *
371
+ * Not throwing is the point. Every read above serves a page whose failure IS a
372
+ * failure — an unreachable catalog is a 500, and the route error boundary is
373
+ * the right answer. For these four, refusal is the ORDINARY case: a
374
+ * one-shot token is used exactly once, so the second click on the same link is
375
+ * an expected outcome to RENDER, not an incident to report. A thrown error
376
+ * would put the shopper on an error page for doing something entirely normal.
377
+ *
378
+ * They answer with {@link AccountCallOutcome} rather than the `ActionResult`
379
+ * envelope the checkout operations use, because there is nothing to carry: the
380
+ * API's answer to a one-shot credential is whether it was accepted and, if
381
+ * not, which `ErrorCode` it refused with. That is a pure, serializable value —
382
+ * it crosses the Server Action boundary unchanged — and `lib/account/*`
383
+ * interprets it in ONE place for both pages. The unreachable/unparseable arm
384
+ * borrows `UNREACHABLE_ERROR`'s code so the whole template spells that
385
+ * condition one way.
386
+ */
387
+
388
+ /** Verify a customer's e-mail address with the token from their mail. */
389
+ export async function verifyCustomerAccount(token: string): Promise<AccountCallOutcome> {
390
+ return runAccountOperation(async (client) => {
391
+ await client.customer.verifyCustomerAccount({ token });
392
+ });
393
+ }
394
+
395
+ /** Set a new password from the token in a password-reset mail. */
396
+ export async function resetPassword(input: {
397
+ token: string;
398
+ password: string;
399
+ }): Promise<AccountCallOutcome> {
400
+ return runAccountOperation(async (client) => {
401
+ await client.customer.resetPassword({ input });
402
+ });
403
+ }
404
+
405
+ /**
406
+ * Ask the platform to mail a password-reset link.
407
+ *
408
+ * The API's boolean answer (whether an account matched) is deliberately
409
+ * DISCARDED here rather than returned: surfacing it would let a visitor learn
410
+ * whether an address shops here, and a value this module returns is a value a
411
+ * page can render by accident. The neutral answer is decided once, in
412
+ * `lib/account/reset-password-state.ts`.
413
+ */
414
+ export async function requestPasswordReset(emailAddress: string): Promise<AccountCallOutcome> {
415
+ return runAccountOperation(async (client) => {
416
+ await client.customer.requestPasswordReset({ emailAddress });
417
+ });
418
+ }
419
+
420
+ /**
421
+ * Ask the platform to send the verification mail again (#1471).
422
+ *
423
+ * The API's boolean is deliberately DISCARDED, exactly as the password-reset
424
+ * request's is: a value this module returns is a value a page can render by
425
+ * accident, and rendering whether an address is registered here is an
426
+ * enumeration oracle. The neutral answer is decided once, in
427
+ * `lib/account/register-state.ts`.
428
+ *
429
+ * Unauthenticated by necessity rather than by convenience. A shopper who never
430
+ * confirmed their address cannot log in at all, so a resend sitting behind a
431
+ * login would be unreachable by precisely the people who need it.
432
+ */
433
+ export async function requestCustomerVerification(
434
+ emailAddress: string,
435
+ ): Promise<AccountCallOutcome> {
436
+ return runAccountOperation(async (client) => {
437
+ await client.customer.requestCustomerVerification({ emailAddress });
438
+ });
439
+ }
440
+
441
+ /**
442
+ * Run one account operation and report its outcome.
443
+ *
444
+ * The `try` covers `getShopClient()` as well as the call, and that is
445
+ * deliberate: an unconfigured scaffold throws there (`assertShopConfigured`),
446
+ * and the prewarm contract says a storefront with no `.env` must SERVE rather
447
+ * than crash — so that failure has to arrive as a rendered state like any
448
+ * other, not as an unhandled throw inside a render.
449
+ *
450
+ * This is the template's one sanctioned use of `catch`: wrapping an external
451
+ * SDK's errors at the call site, exactly as `shop-session.ts#runSessionOp` and
452
+ * `server/runner.ts` already do. Nothing is swallowed — every failure leaves
453
+ * here as a code the page renders.
454
+ */
455
+ async function runAccountOperation(
456
+ operation: (client: ForgeCartShopClient) => Promise<void>,
457
+ ): Promise<AccountCallOutcome> {
458
+ try {
459
+ await operation(await getShopClient());
460
+ return { kind: 'ok' };
461
+ } catch (error) {
462
+ return { kind: 'error', code: extractError(error)?.code ?? UNREACHABLE_ERROR.code };
463
+ }
464
+ }
@@ -0,0 +1,89 @@
1
+ import type {
2
+ ShopProductFieldFragment as Product,
3
+ ShopSellingPlanFieldFragment as SellingPlan,
4
+ } from '@forgecart/sdk/shop';
5
+
6
+ import { toMajorUnits } from './money';
7
+
8
+ /**
9
+ * Pure display helpers over SDK types — safe to import from ANY component.
10
+ *
11
+ * This module carries no runtime SDK dependency: the `@forgecart/sdk/shop`
12
+ * import above is type-only, so it erases at compile time and none of the
13
+ * client (`ForgeCartShopClient`, graphql-ws) ever reaches a browser bundle.
14
+ * Client components import their formatting from here; the SDK-bearing
15
+ * modules (`forgecart.ts`, `cart-actions.ts`) are `server-only` and reject a
16
+ * client value-import at build time.
17
+ */
18
+
19
+ /**
20
+ * Format a minor-unit price as a currency string. Defaults to USD; pass a
21
+ * different ISO currency code as needed.
22
+ *
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.
30
+ */
31
+ export function formatPrice(minorUnits: number, currency = 'USD'): string {
32
+ return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(
33
+ toMajorUnits(minorUnits, currency),
34
+ );
35
+ }
36
+
37
+ /** Lowest variant price for a product, in minor units, or `null` if none. */
38
+ export function getStartingPrice(product: Product): number | null {
39
+ if (product.variants.length === 0) {
40
+ return null;
41
+ }
42
+ return product.variants.reduce(
43
+ (min, v) => (v.priceWithTax < min ? v.priceWithTax : min),
44
+ product.variants[0].priceWithTax,
45
+ );
46
+ }
47
+
48
+ /**
49
+ * Preview the per-unit price a plan yields from the one-time `priceWithTax`.
50
+ *
51
+ * Mirrors the server's pricing policy: `none` keeps the price, `percentage`
52
+ * applies the percent discount (rounded to whole minor units), `fixed_amount`
53
+ * subtracts the minor-unit adjustment (floored at zero). A `null`
54
+ * `adjustmentValue` (only valid for `none`) is treated as no adjustment.
55
+ */
56
+ export function getPlanPreviewPrice(basePrice: number, plan: SellingPlan): number {
57
+ if (plan.pricingPolicy === 'percentage') {
58
+ const adjustment = plan.adjustmentValue ?? 0;
59
+ // `adjustmentValue` is a whole-number percent (e.g. 15 -> 15% off).
60
+ const PERCENT_BASE = 100;
61
+ return Math.round(basePrice * (1 - adjustment / PERCENT_BASE));
62
+ }
63
+ if (plan.pricingPolicy === 'fixed_amount') {
64
+ return Math.max(0, basePrice - (plan.adjustmentValue ?? 0));
65
+ }
66
+ return basePrice;
67
+ }
68
+
69
+ /**
70
+ * A short human-readable savings hint for a plan, or `null` when it offers no
71
+ * discount (policy `none`, a zero adjustment, or a non-positive percentage).
72
+ */
73
+ export function getPlanSavingsLabel(plan: SellingPlan, currency = 'USD'): string | null {
74
+ if (plan.pricingPolicy === 'percentage' && (plan.adjustmentValue ?? 0) > 0) {
75
+ return `Save ${plan.adjustmentValue}%`;
76
+ }
77
+ if (plan.pricingPolicy === 'fixed_amount' && (plan.adjustmentValue ?? 0) > 0) {
78
+ return `Save ${formatPrice(plan.adjustmentValue ?? 0, currency)}`;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * The billing cadence as a phrase, e.g. "every 1 monthly" or "every 2 weekly",
85
+ * built from the plan's interval count and the (lower-cased) billing interval.
86
+ */
87
+ export function getPlanCadenceLabel(plan: SellingPlan): string {
88
+ return `every ${plan.intervalCount} ${plan.billingInterval.toLowerCase()}`;
89
+ }