@forgecart/cli 2.202610052310.0 → 2.202610071755.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/package.json +1 -1
- package/templates/storefront-shadcn/.forgecartignore +2 -0
- package/templates/storefront-shadcn/Procfile +1 -0
- package/templates/storefront-shadcn/README.md +229 -0
- package/templates/storefront-shadcn/SEO-MIGRATION.md +708 -0
- package/templates/storefront-shadcn/components.json +21 -0
- package/templates/storefront-shadcn/next.config.js +105 -0
- package/templates/storefront-shadcn/package.json +39 -0
- package/templates/storefront-shadcn/postcss.config.js +5 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/identify/route.ts +205 -0
- package/templates/storefront-shadcn/src/app/%5F%5Ffc/track/route.ts +189 -0
- package/templates/storefront-shadcn/src/app/%5F%5Fforge_beacon/route.ts +87 -0
- package/templates/storefront-shadcn/src/app/api/%5F%5Fbackend/methods/route.ts +27 -0
- package/templates/storefront-shadcn/src/app/cart/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/checkout/page.tsx +53 -0
- package/templates/storefront-shadcn/src/app/error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/global-error.tsx +23 -0
- package/templates/storefront-shadcn/src/app/globals.css +156 -0
- package/templates/storefront-shadcn/src/app/layout.tsx +185 -0
- package/templates/storefront-shadcn/src/app/page.tsx +217 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/not-found.tsx +24 -0
- package/templates/storefront-shadcn/src/app/pages/[slug]/page.tsx +115 -0
- package/templates/storefront-shadcn/src/app/ping/route.ts +21 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/not-found.tsx +18 -0
- package/templates/storefront-shadcn/src/app/products/[slug]/page.tsx +317 -0
- package/templates/storefront-shadcn/src/app/products/page.tsx +107 -0
- package/templates/storefront-shadcn/src/app/register/page.tsx +54 -0
- package/templates/storefront-shadcn/src/app/reset-password/page.tsx +60 -0
- package/templates/storefront-shadcn/src/app/robots.ts +69 -0
- package/templates/storefront-shadcn/src/app/sitemap.ts +106 -0
- package/templates/storefront-shadcn/src/app/verify/page.tsx +157 -0
- package/templates/storefront-shadcn/src/components/CartView.tsx +333 -0
- package/templates/storefront-shadcn/src/components/ForgeErrorBeacon.tsx +102 -0
- package/templates/storefront-shadcn/src/components/ForgeTracker.tsx +479 -0
- package/templates/storefront-shadcn/src/components/ForgecartDesigner.tsx +43 -0
- package/templates/storefront-shadcn/src/components/Header.tsx +73 -0
- package/templates/storefront-shadcn/src/components/LanguageSwitcher.tsx +96 -0
- package/templates/storefront-shadcn/src/components/LocaleLink.tsx +49 -0
- package/templates/storefront-shadcn/src/components/ProductCard.tsx +59 -0
- package/templates/storefront-shadcn/src/components/ProductPurchase.tsx +235 -0
- package/templates/storefront-shadcn/src/components/account/AccountMessage.tsx +63 -0
- package/templates/storefront-shadcn/src/components/account/RegisterForm.tsx +257 -0
- package/templates/storefront-shadcn/src/components/account/RequestPasswordResetForm.tsx +93 -0
- package/templates/storefront-shadcn/src/components/account/ResetPasswordForm.tsx +163 -0
- package/templates/storefront-shadcn/src/components/checkout/AddressStep.tsx +271 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutFlow.tsx +551 -0
- package/templates/storefront-shadcn/src/components/checkout/CheckoutGate.tsx +55 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentElementForm.tsx +140 -0
- package/templates/storefront-shadcn/src/components/checkout/PaymentFormEmbed.tsx +89 -0
- package/templates/storefront-shadcn/src/components/checkout/RatesStep.tsx +115 -0
- package/templates/storefront-shadcn/src/components/ui/alert.tsx +75 -0
- package/templates/storefront-shadcn/src/components/ui/badge.tsx +40 -0
- package/templates/storefront-shadcn/src/components/ui/button.tsx +64 -0
- package/templates/storefront-shadcn/src/components/ui/card.tsx +28 -0
- package/templates/storefront-shadcn/src/components/ui/input.tsx +26 -0
- package/templates/storefront-shadcn/src/components/ui/label.tsx +22 -0
- package/templates/storefront-shadcn/src/components/ui/native-select.tsx +27 -0
- package/templates/storefront-shadcn/src/components/ui/skeleton.tsx +21 -0
- package/templates/storefront-shadcn/src/components/ui/utils.ts +16 -0
- package/templates/storefront-shadcn/src/instrumentation.ts +109 -0
- package/templates/storefront-shadcn/src/lib/account/account-link.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account/register-state.ts +133 -0
- package/templates/storefront-shadcn/src/lib/account/reset-password-state.ts +111 -0
- package/templates/storefront-shadcn/src/lib/account/verify-state.ts +56 -0
- package/templates/storefront-shadcn/src/lib/account-actions.ts +76 -0
- package/templates/storefront-shadcn/src/lib/account-session.ts +47 -0
- package/templates/storefront-shadcn/src/lib/action-result.ts +30 -0
- package/templates/storefront-shadcn/src/lib/asset-alt.ts +34 -0
- package/templates/storefront-shadcn/src/lib/backend-actions.ts +20 -0
- package/templates/storefront-shadcn/src/lib/backend-client.ts +47 -0
- package/templates/storefront-shadcn/src/lib/cart-context.tsx +236 -0
- package/templates/storefront-shadcn/src/lib/checkout-session.ts +185 -0
- package/templates/storefront-shadcn/src/lib/content/page-metadata.ts +113 -0
- package/templates/storefront-shadcn/src/lib/content/render-fields.tsx +256 -0
- package/templates/storefront-shadcn/src/lib/content/resolve-page.ts +143 -0
- package/templates/storefront-shadcn/src/lib/error-messages.ts +24 -0
- package/templates/storefront-shadcn/src/lib/experiments.ts +333 -0
- package/templates/storefront-shadcn/src/lib/forgecart.ts +464 -0
- package/templates/storefront-shadcn/src/lib/format.ts +89 -0
- package/templates/storefront-shadcn/src/lib/identify-forward.ts +152 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-loader.ts +169 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales-map.ts +46 -0
- package/templates/storefront-shadcn/src/lib/locale/channel-locales.ts +191 -0
- package/templates/storefront-shadcn/src/lib/locale/grammar.ts +194 -0
- package/templates/storefront-shadcn/src/lib/locale/localized-path.ts +55 -0
- package/templates/storefront-shadcn/src/lib/locale/middleware-plan.ts +107 -0
- package/templates/storefront-shadcn/src/lib/locale/request-binding.ts +80 -0
- package/templates/storefront-shadcn/src/lib/locale/request-locale.ts +66 -0
- package/templates/storefront-shadcn/src/lib/marketing-params.ts +213 -0
- package/templates/storefront-shadcn/src/lib/money.ts +50 -0
- package/templates/storefront-shadcn/src/lib/seo/alternates.ts +123 -0
- package/templates/storefront-shadcn/src/lib/seo/json-ld.ts +266 -0
- package/templates/storefront-shadcn/src/lib/seo/metadata.ts +419 -0
- package/templates/storefront-shadcn/src/lib/seo/noindex.ts +218 -0
- package/templates/storefront-shadcn/src/lib/seo/public-origin.ts +166 -0
- package/templates/storefront-shadcn/src/lib/seo/redirect-plan.ts +86 -0
- package/templates/storefront-shadcn/src/lib/seo/resolve-path.ts +107 -0
- package/templates/storefront-shadcn/src/lib/seo/scaffolded-routes.ts +83 -0
- package/templates/storefront-shadcn/src/lib/seo/sidecar.ts +75 -0
- package/templates/storefront-shadcn/src/lib/seo/site-verification.ts +98 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-cache.ts +114 -0
- package/templates/storefront-shadcn/src/lib/seo/sitemap-entries.ts +321 -0
- package/templates/storefront-shadcn/src/lib/session-actions.ts +61 -0
- package/templates/storefront-shadcn/src/lib/session-cookies.ts +98 -0
- package/templates/storefront-shadcn/src/lib/shop-config.ts +51 -0
- package/templates/storefront-shadcn/src/lib/shop-session.ts +151 -0
- package/templates/storefront-shadcn/src/lib/track-forward.ts +200 -0
- package/templates/storefront-shadcn/src/lib/uuid.ts +19 -0
- package/templates/storefront-shadcn/src/middleware.ts +379 -0
- package/templates/storefront-shadcn/src/seo/redirects.ts +44 -0
- package/templates/storefront-shadcn/src/server/app.module.ts +18 -0
- package/templates/storefront-shadcn/src/server/backend-api.ts +26 -0
- package/templates/storefront-shadcn/src/server/backend-method.decorator.ts +23 -0
- package/templates/storefront-shadcn/src/server/bootstrap.ts +122 -0
- package/templates/storefront-shadcn/src/server/customer-extras/customer-extras.module.ts +13 -0
- package/templates/storefront-shadcn/src/server/customer-extras/service/customer-extras.service.ts +58 -0
- package/templates/storefront-shadcn/src/server/customer-extras/type/customer-extras.types.ts +11 -0
- package/templates/storefront-shadcn/src/server/forge/live-revision.ts +158 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart-client.factory.ts +69 -0
- package/templates/storefront-shadcn/src/server/forgecart/forgecart.module.ts +9 -0
- package/templates/storefront-shadcn/src/server/runner.ts +90 -0
- package/templates/storefront-shadcn/src/server/types.ts +36 -0
- package/templates/storefront-shadcn/tsconfig.json +25 -0
- package/templates/storefront-shadcn-sdk-floor.json +1174 -0
- 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
|
+
}
|