@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,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `alt` attribute for an asset-backed `<img>` — the one place the template
|
|
3
|
+
* decides what an undescribed image announces (#1376).
|
|
4
|
+
*
|
|
5
|
+
* Alt text is the merchant's OWN description of the image, authored in the
|
|
6
|
+
* dashboard's media library. Its absence is therefore a decision rather than a
|
|
7
|
+
* gap: an image nobody described is announced as decorative (`alt=""`) and
|
|
8
|
+
* skipped by screen readers, which is exactly right for the product shots,
|
|
9
|
+
* badges and thumbnails that repeat text already sitting next to them.
|
|
10
|
+
*
|
|
11
|
+
* It is deliberately NEVER back-filled with the product or variant name. A
|
|
12
|
+
* derived placeholder is indistinguishable from a real description to both a
|
|
13
|
+
* screen reader and a crawler, while carrying nothing the adjacent markup does
|
|
14
|
+
* not already say — so it spends a listener's attention repeating the caption
|
|
15
|
+
* they just heard, and tells a crawler the image was described when it was
|
|
16
|
+
* not. The empty string is the honest answer and the accessible one, and this
|
|
17
|
+
* function exists so that judgement is stated once instead of re-derived at
|
|
18
|
+
* every `<img>` (all four of which read `alt={product.name}` before it).
|
|
19
|
+
*
|
|
20
|
+
* A blank-but-present description collapses to the same decorative default.
|
|
21
|
+
* `alt=" "` is the worst of both worlds — not a description, and not the
|
|
22
|
+
* empty string that marks an image skippable, so a reader falls back to
|
|
23
|
+
* announcing the file name. The dashboard already trims before it writes, but
|
|
24
|
+
* the field is equally writable through the admin API, so the guarantee is
|
|
25
|
+
* made here where it is actually rendered rather than assumed upstream.
|
|
26
|
+
*
|
|
27
|
+
* The parameter is structural, not the SDK's `Asset`: every selection that
|
|
28
|
+
* renders an image picks `altText` alongside `preview`, and asking only for
|
|
29
|
+
* the field actually read keeps this module free of the SDK import — which is
|
|
30
|
+
* what lets it be unit-tested directly (see tool/storefront-template-spec).
|
|
31
|
+
*/
|
|
32
|
+
export function getAssetAlt(asset: { altText?: string | null }): string {
|
|
33
|
+
return asset.altText?.trim() ?? '';
|
|
34
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
'use server';
|
|
2
|
+
|
|
3
|
+
import 'server-only';
|
|
4
|
+
|
|
5
|
+
import { invokeBackendMethod } from '../server/runner';
|
|
6
|
+
import type { ActionResult } from './action-result';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The ONE Server Action bridging the browser into the embedded NestJS
|
|
10
|
+
* backend (`src/server/`) — Next's native RPC is the transport, the booted
|
|
11
|
+
* registry is the authority. Components never import this directly: they
|
|
12
|
+
* call through `backend` (`lib/backend-client.ts`), which types the surface
|
|
13
|
+
* off `backend-api.ts` and guards the proxy's magic keys.
|
|
14
|
+
*/
|
|
15
|
+
export async function invokeBackend(
|
|
16
|
+
method: string,
|
|
17
|
+
input: unknown,
|
|
18
|
+
): Promise<ActionResult<unknown>> {
|
|
19
|
+
return invokeBackendMethod(method, input);
|
|
20
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { BackendApi } from '../server/backend-api';
|
|
2
|
+
import type { ActionResult } from './action-result';
|
|
3
|
+
import { invokeBackend } from './backend-actions';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `sdk.backend` — the typed browser gate onto the storefront's embedded
|
|
7
|
+
* NestJS backend.
|
|
8
|
+
*
|
|
9
|
+
* Compile time: the mapped type below projects `backend-api.ts` onto the
|
|
10
|
+
* proxy, so editors autocomplete exactly the registered methods and an
|
|
11
|
+
* unknown name fails the build. (The import is type-only — it erases, so no
|
|
12
|
+
* server code reaches the client bundle; the `server-only` guards enforce
|
|
13
|
+
* it.)
|
|
14
|
+
*
|
|
15
|
+
* Runtime: the proxy fabricates an invoker for ANY string key — the
|
|
16
|
+
* server-side registry is the real authority, answering
|
|
17
|
+
* BACKEND_METHOD_NOT_FOUND in the normal envelope for unknown or
|
|
18
|
+
* stale-bundle names. Magic keys are excluded so the object never
|
|
19
|
+
* masquerades as a thenable (`await backend` must not fire a wire call
|
|
20
|
+
* named "then").
|
|
21
|
+
*
|
|
22
|
+
* Usage, any client component:
|
|
23
|
+
* const result = await backend.assignCustomerHash({});
|
|
24
|
+
* if (!result.ok) return renderError(result.error);
|
|
25
|
+
*/
|
|
26
|
+
type BackendGate = {
|
|
27
|
+
[K in keyof BackendApi]: (
|
|
28
|
+
input: Parameters<BackendApi[K]>[0],
|
|
29
|
+
) => Promise<ActionResult<Awaited<ReturnType<BackendApi[K]>>>>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const RESERVED_KEYS = new Set([
|
|
33
|
+
'then',
|
|
34
|
+
'catch',
|
|
35
|
+
'finally',
|
|
36
|
+
'toJSON',
|
|
37
|
+
'valueOf',
|
|
38
|
+
'constructor',
|
|
39
|
+
'$$typeof',
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
export const backend: BackendGate = new Proxy({} as BackendGate, {
|
|
43
|
+
get(_target, key) {
|
|
44
|
+
if (typeof key !== 'string' || RESERVED_KEYS.has(key)) return undefined;
|
|
45
|
+
return (input: unknown) => invokeBackend(key, input);
|
|
46
|
+
},
|
|
47
|
+
});
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { ExtractedError } from '@forgecart/sdk/shop';
|
|
4
|
+
import type { ShopAddItemToOrderInput } from '@forgecart/sdk/shop';
|
|
5
|
+
|
|
6
|
+
import type { ActionResult } from './action-result';
|
|
7
|
+
import type { Order } from './forgecart';
|
|
8
|
+
import {
|
|
9
|
+
createContext,
|
|
10
|
+
useCallback,
|
|
11
|
+
useContext,
|
|
12
|
+
useEffect,
|
|
13
|
+
useMemo,
|
|
14
|
+
useState,
|
|
15
|
+
useTransition,
|
|
16
|
+
type ReactNode,
|
|
17
|
+
} from 'react';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
initShopSession,
|
|
21
|
+
isSessionReady,
|
|
22
|
+
readMirrorSession,
|
|
23
|
+
runSessionOp,
|
|
24
|
+
type ShopSessionConfig,
|
|
25
|
+
} from './shop-session';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Cart state on the shopper's OWN shop websocket (`shop-session.ts`) — the
|
|
29
|
+
* client half of the progressive stack.
|
|
30
|
+
*
|
|
31
|
+
* PPR-compatible boot: the server shell renders with NO cart (reading the
|
|
32
|
+
* session cookie would dynamize the whole layout), so the provider hydrates
|
|
33
|
+
* client-side. On mount it arms the session singleton with the serialized
|
|
34
|
+
* channel config; when the mirror cookie says a session exists, it reads the
|
|
35
|
+
* active order over the socket (`activeOrder` is nullable and NEVER mints —
|
|
36
|
+
* carts start with the first added item). `hydrated` stays false until that
|
|
37
|
+
* first read settles, so cart surfaces can render a skeleton instead of a
|
|
38
|
+
* false "empty cart" flash.
|
|
39
|
+
*
|
|
40
|
+
* Every mutation runs on the same socket and replaces `cart` with the order
|
|
41
|
+
* from the typed `ActionResult` envelope; a failure keeps the last known
|
|
42
|
+
* cart AND surfaces the typed error on `error`. `pending` is true while a
|
|
43
|
+
* cart mutation is in flight. The first `add` on a fresh visitor mints the
|
|
44
|
+
* session client-side; `shop-session.ts` syncs the captured token back into
|
|
45
|
+
* the cookies so the next SSR pass sees this cart.
|
|
46
|
+
*/
|
|
47
|
+
interface CartContextValue {
|
|
48
|
+
cart: Order | null;
|
|
49
|
+
itemCount: number;
|
|
50
|
+
subtotal: number;
|
|
51
|
+
/** False until the mount-time cart read settles — render skeletons, not "empty". */
|
|
52
|
+
hydrated: boolean;
|
|
53
|
+
pending: boolean;
|
|
54
|
+
error: ExtractedError | null;
|
|
55
|
+
add: (variantId: string, quantity?: number, sellingPlanId?: string) => void;
|
|
56
|
+
setQuantity: (lineId: string, quantity: number) => void;
|
|
57
|
+
remove: (lineId: string) => void;
|
|
58
|
+
setSellingPlan: (sellingPlanId: string | null) => void;
|
|
59
|
+
applyCoupon: (code: string) => void;
|
|
60
|
+
removeCoupon: (code: string) => void;
|
|
61
|
+
clearError: () => void;
|
|
62
|
+
refresh: () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const CartContext = createContext<CartContextValue | null>(null);
|
|
66
|
+
|
|
67
|
+
export function CartProvider({
|
|
68
|
+
shopConfig,
|
|
69
|
+
children,
|
|
70
|
+
}: {
|
|
71
|
+
shopConfig: ShopSessionConfig | null;
|
|
72
|
+
children: ReactNode;
|
|
73
|
+
}) {
|
|
74
|
+
const [cart, setCart] = useState<Order | null>(null);
|
|
75
|
+
const [hydrated, setHydrated] = useState(false);
|
|
76
|
+
const [error, setError] = useState<ExtractedError | null>(null);
|
|
77
|
+
const [pending, startTransition] = useTransition();
|
|
78
|
+
|
|
79
|
+
const run = useCallback((op: () => Promise<ActionResult<Order | null>>) => {
|
|
80
|
+
startTransition(async () => {
|
|
81
|
+
const result = await op();
|
|
82
|
+
if (result.ok) {
|
|
83
|
+
setCart(result.data);
|
|
84
|
+
setError(null);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
setError(result.error);
|
|
88
|
+
});
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
// Arm the session singleton and hydrate the cart. An unconfigured scaffold
|
|
92
|
+
// (pre-`forgecart init`) or a visitor with no session hydrates to the
|
|
93
|
+
// legitimate empty state without touching the network.
|
|
94
|
+
useEffect(() => {
|
|
95
|
+
if (!shopConfig) {
|
|
96
|
+
setHydrated(true);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
initShopSession(shopConfig);
|
|
100
|
+
if (!readMirrorSession()) {
|
|
101
|
+
setHydrated(true);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let cancelled = false;
|
|
105
|
+
void (async () => {
|
|
106
|
+
const result = await runSessionOp(
|
|
107
|
+
async (client) => (await client.order.activeOrder()).activeOrder ?? null,
|
|
108
|
+
);
|
|
109
|
+
if (cancelled) return;
|
|
110
|
+
if (result.ok) setCart(result.data);
|
|
111
|
+
setHydrated(true);
|
|
112
|
+
})();
|
|
113
|
+
return () => {
|
|
114
|
+
cancelled = true;
|
|
115
|
+
};
|
|
116
|
+
}, [shopConfig]);
|
|
117
|
+
|
|
118
|
+
const add = useCallback(
|
|
119
|
+
(variantId: string, quantity = 1, sellingPlanId?: string) =>
|
|
120
|
+
run(async () => {
|
|
121
|
+
const input: ShopAddItemToOrderInput = { variantId, quantity };
|
|
122
|
+
if (sellingPlanId) {
|
|
123
|
+
input.sellingPlanId = sellingPlanId;
|
|
124
|
+
}
|
|
125
|
+
return runSessionOp(
|
|
126
|
+
async (client) => (await client.cart.addItemToOrder({ input })).addItemToOrder,
|
|
127
|
+
);
|
|
128
|
+
}),
|
|
129
|
+
[run],
|
|
130
|
+
);
|
|
131
|
+
const setQuantity = useCallback(
|
|
132
|
+
(lineId: string, quantity: number) =>
|
|
133
|
+
run(() =>
|
|
134
|
+
runSessionOp(
|
|
135
|
+
async (client) =>
|
|
136
|
+
(await client.cart.adjustOrderLine({ input: { lineId, quantity } })).adjustOrderLine,
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
[run],
|
|
140
|
+
);
|
|
141
|
+
const remove = useCallback(
|
|
142
|
+
(lineId: string) =>
|
|
143
|
+
run(() =>
|
|
144
|
+
runSessionOp(
|
|
145
|
+
async (client) => (await client.cart.removeOrderLine({ lineId })).removeOrderLine,
|
|
146
|
+
),
|
|
147
|
+
),
|
|
148
|
+
[run],
|
|
149
|
+
);
|
|
150
|
+
const setSellingPlan = useCallback(
|
|
151
|
+
(sellingPlanId: string | null) =>
|
|
152
|
+
run(() =>
|
|
153
|
+
runSessionOp(
|
|
154
|
+
async (client) =>
|
|
155
|
+
(await client.cart.setOrderSellingPlan({ input: { sellingPlanId } }))
|
|
156
|
+
.setOrderSellingPlan,
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
[run],
|
|
160
|
+
);
|
|
161
|
+
const applyCoupon = useCallback(
|
|
162
|
+
(code: string) =>
|
|
163
|
+
run(() =>
|
|
164
|
+
runSessionOp(
|
|
165
|
+
async (client) => (await client.cart.applyCouponCode({ code })).applyCouponCode,
|
|
166
|
+
),
|
|
167
|
+
),
|
|
168
|
+
[run],
|
|
169
|
+
);
|
|
170
|
+
const removeCoupon = useCallback(
|
|
171
|
+
(code: string) =>
|
|
172
|
+
run(() =>
|
|
173
|
+
runSessionOp(
|
|
174
|
+
async (client) => (await client.cart.removeCouponCode({ code })).removeCouponCode,
|
|
175
|
+
),
|
|
176
|
+
),
|
|
177
|
+
[run],
|
|
178
|
+
);
|
|
179
|
+
const clearError = useCallback(() => setError(null), []);
|
|
180
|
+
const refresh = useCallback(
|
|
181
|
+
() =>
|
|
182
|
+
run(async () => {
|
|
183
|
+
if (!isSessionReady() || !readMirrorSession()) {
|
|
184
|
+
return { ok: true, data: null };
|
|
185
|
+
}
|
|
186
|
+
return runSessionOp(
|
|
187
|
+
async (client) => (await client.order.activeOrder()).activeOrder ?? null,
|
|
188
|
+
);
|
|
189
|
+
}),
|
|
190
|
+
[run],
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const value = useMemo<CartContextValue>(
|
|
194
|
+
() => ({
|
|
195
|
+
cart,
|
|
196
|
+
itemCount: cart?.totalQuantity ?? 0,
|
|
197
|
+
subtotal: cart?.subTotalWithTax ?? 0,
|
|
198
|
+
hydrated,
|
|
199
|
+
pending,
|
|
200
|
+
error,
|
|
201
|
+
add,
|
|
202
|
+
setQuantity,
|
|
203
|
+
remove,
|
|
204
|
+
setSellingPlan,
|
|
205
|
+
applyCoupon,
|
|
206
|
+
removeCoupon,
|
|
207
|
+
clearError,
|
|
208
|
+
refresh,
|
|
209
|
+
}),
|
|
210
|
+
[
|
|
211
|
+
cart,
|
|
212
|
+
hydrated,
|
|
213
|
+
pending,
|
|
214
|
+
error,
|
|
215
|
+
add,
|
|
216
|
+
setQuantity,
|
|
217
|
+
remove,
|
|
218
|
+
setSellingPlan,
|
|
219
|
+
applyCoupon,
|
|
220
|
+
removeCoupon,
|
|
221
|
+
clearError,
|
|
222
|
+
refresh,
|
|
223
|
+
],
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return <CartContext.Provider value={value}>{children}</CartContext.Provider>;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Access the cart. Must be used inside a `<CartProvider>`. */
|
|
230
|
+
export function useCart(): CartContextValue {
|
|
231
|
+
const ctx = useContext(CartContext);
|
|
232
|
+
if (!ctx) {
|
|
233
|
+
throw new Error('useCart must be used within a CartProvider');
|
|
234
|
+
}
|
|
235
|
+
return ctx;
|
|
236
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import type { CreateAddressInput, SelectRateInput, SetCustomerInput } from '@forgecart/sdk/shop';
|
|
4
|
+
|
|
5
|
+
import type { ActionResult } from './action-result';
|
|
6
|
+
import type {
|
|
7
|
+
CheckoutOrder,
|
|
8
|
+
CheckoutOrderView,
|
|
9
|
+
ConfirmedPaymentSession,
|
|
10
|
+
EligiblePaymentProvider,
|
|
11
|
+
PaymentSession,
|
|
12
|
+
PaymentTemplate,
|
|
13
|
+
PaymentVariables,
|
|
14
|
+
ShippingRateGroup,
|
|
15
|
+
} from './forgecart';
|
|
16
|
+
import { runSessionOp } from './shop-session';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Checkout operations on the shopper's OWN websocket (`shop-session.ts`):
|
|
20
|
+
* contact → address → rates → payment → confirmation. Same names, same
|
|
21
|
+
* `ActionResult` envelopes as the retired Server-Action module — the flow
|
|
22
|
+
* components changed one import, not their contracts. Every operation here
|
|
23
|
+
* is session-scoped, which is exactly why it runs client-side; the only
|
|
24
|
+
* server-fetched checkout data is the public country list (channel read).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** Attach the guest contact (email + name) to the active order. */
|
|
28
|
+
export async function setCustomer(
|
|
29
|
+
input: SetCustomerInput,
|
|
30
|
+
): Promise<ActionResult<CheckoutOrderView | null>> {
|
|
31
|
+
return runSessionOp(
|
|
32
|
+
async (client) => (await client.cart.setCustomerForOrder({ input })).setCustomerForOrder,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Persist the shipping address. `countryId` accepts the canonical Country id
|
|
38
|
+
* OR an ISO code (an autocomplete suggestion's `countryCode` feeds it
|
|
39
|
+
* directly); `phoneNumber` is required server-side
|
|
40
|
+
* (SHIPPING_ADDRESS_PHONE_REQUIRED arrives as a typed field error otherwise).
|
|
41
|
+
*/
|
|
42
|
+
export async function setShippingAddress(
|
|
43
|
+
input: CreateAddressInput,
|
|
44
|
+
): Promise<ActionResult<CheckoutOrderView | null>> {
|
|
45
|
+
return runSessionOp(
|
|
46
|
+
async (client) =>
|
|
47
|
+
(await client.checkout.setOrderShippingAddress({ input })).setOrderShippingAddress,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Type-ahead address suggestions ([] = no provider → manual entry). */
|
|
52
|
+
export async function suggestAddresses(
|
|
53
|
+
query: string,
|
|
54
|
+
sessionToken: string,
|
|
55
|
+
countryCode?: string,
|
|
56
|
+
): Promise<ActionResult<{ id: string; description: string }[]>> {
|
|
57
|
+
return runSessionOp(
|
|
58
|
+
async (client) =>
|
|
59
|
+
(await client.address.addressAutocomplete({ input: { query, sessionToken, countryCode } }))
|
|
60
|
+
.addressAutocomplete,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve one suggestion into a structured address for the form. */
|
|
65
|
+
export async function resolveAddressSuggestion(
|
|
66
|
+
id: string,
|
|
67
|
+
sessionToken: string,
|
|
68
|
+
): Promise<
|
|
69
|
+
ActionResult<{
|
|
70
|
+
streetLine1: string;
|
|
71
|
+
streetLine2?: string | null;
|
|
72
|
+
city?: string | null;
|
|
73
|
+
province?: string | null;
|
|
74
|
+
postalCode?: string | null;
|
|
75
|
+
countryCode: string;
|
|
76
|
+
}>
|
|
77
|
+
> {
|
|
78
|
+
return runSessionOp(
|
|
79
|
+
async (client) =>
|
|
80
|
+
(await client.address.addressSuggestion({ input: { id, sessionToken } })).addressSuggestion,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Explicit synchronous carrier re-quote — run on rate-step entry and after
|
|
86
|
+
* an address change. Per-provider failures arrive in each group's
|
|
87
|
+
* `lastRateErrors`; the read query never quotes (#863's read/refresh split).
|
|
88
|
+
*/
|
|
89
|
+
export async function refreshShippingRates(): Promise<ActionResult<ShippingRateGroup[]>> {
|
|
90
|
+
return runSessionOp(
|
|
91
|
+
async (client) => (await client.shipping.refreshShippingRateGroups()).refreshShippingRateGroups,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Read the persisted rate groups (no carrier quoting). */
|
|
96
|
+
export async function getShippingRates(): Promise<ActionResult<ShippingRateGroup[]>> {
|
|
97
|
+
return runSessionOp(
|
|
98
|
+
async (client) => (await client.shipping.shippingRateGroups()).shippingRateGroups,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The CUSTOMER's rate pick — the ArrangingPayment gate requires one per group. */
|
|
103
|
+
export async function selectShippingRate(
|
|
104
|
+
input: SelectRateInput,
|
|
105
|
+
): Promise<ActionResult<CheckoutOrderView | null>> {
|
|
106
|
+
return runSessionOp(
|
|
107
|
+
async (client) => (await client.shipping.selectShippingRate({ input })).selectShippingRate,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Advance the order to ArrangingPayment (gated: address+phone, items, selected rates). */
|
|
112
|
+
export async function transitionToPayment(): Promise<ActionResult<CheckoutOrderView | null>> {
|
|
113
|
+
return runSessionOp(
|
|
114
|
+
async (client) =>
|
|
115
|
+
(await client.checkout.transitionOrderToState({ input: { state: 'arrangingPayment' } }))
|
|
116
|
+
.transitionOrderToState,
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Providers eligible for this order (eligibilityMessage is data, not an error). */
|
|
121
|
+
export async function getEligiblePaymentProviders(
|
|
122
|
+
orderId: string,
|
|
123
|
+
): Promise<ActionResult<EligiblePaymentProvider[]>> {
|
|
124
|
+
return runSessionOp(
|
|
125
|
+
async (client) =>
|
|
126
|
+
(await client.payment.shopEligiblePaymentProviders({ orderId })).eligiblePaymentProviders,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Create the payment session (ONE per order — coupon edits freeze at this point). */
|
|
131
|
+
export async function createPaymentSession(
|
|
132
|
+
orderId: string,
|
|
133
|
+
providerCode?: string,
|
|
134
|
+
): Promise<ActionResult<PaymentSession>> {
|
|
135
|
+
return runSessionOp(
|
|
136
|
+
async (client) =>
|
|
137
|
+
(await client.payment.createPaymentSession({ input: { orderId, providerCode } }))
|
|
138
|
+
.createPaymentSession,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Raw variables for the in-page payment adapter (`PaymentElementForm`):
|
|
144
|
+
* providerCode, publishableKey, scriptUrl, amount, currency — straight from
|
|
145
|
+
* the order + the channel's provider config, no session required.
|
|
146
|
+
*/
|
|
147
|
+
export async function getPaymentVariables(
|
|
148
|
+
orderId: string,
|
|
149
|
+
): Promise<ActionResult<PaymentVariables>> {
|
|
150
|
+
return runSessionOp(
|
|
151
|
+
async (client) => (await client.payment.getSessionVariables({ orderId })).getSessionVariables,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The provider's hosted form for `PaymentFormEmbed`. `origin` is the
|
|
157
|
+
* embedding storefront's own origin — the template pins every postMessage of
|
|
158
|
+
* the #865 handshake (iframe-ready / payment-success / payment-error) to it.
|
|
159
|
+
*/
|
|
160
|
+
export async function getPaymentFormTemplate(
|
|
161
|
+
orderId: string,
|
|
162
|
+
origin: string,
|
|
163
|
+
): Promise<ActionResult<PaymentTemplate>> {
|
|
164
|
+
return runSessionOp(
|
|
165
|
+
async (client) =>
|
|
166
|
+
(await client.payment.getSessionTemplate({ input: { orderId, origin } })).getSessionTemplate,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Confirm the session where the provider flow requires it (token/redirect models). */
|
|
171
|
+
export async function confirmPaymentSession(
|
|
172
|
+
sessionId: string,
|
|
173
|
+
): Promise<ActionResult<ConfirmedPaymentSession>> {
|
|
174
|
+
return runSessionOp(
|
|
175
|
+
async (client) =>
|
|
176
|
+
(await client.payment.confirmPaymentSession({ sessionId })).confirmPaymentSession,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The settled order for the confirmation step (by the code shown to the shopper). */
|
|
181
|
+
export async function getOrderByCode(code: string): Promise<ActionResult<CheckoutOrder | null>> {
|
|
182
|
+
return runSessionOp(
|
|
183
|
+
async (client) => (await client.order.shopOrderByCode({ code })).orderByCode ?? null,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { staticPathsByLocale } from '../seo/alternates';
|
|
2
|
+
import { entityMetadata } from '../seo/metadata';
|
|
3
|
+
import type { MetadataInput, SeoMetaRow } from '../seo/metadata';
|
|
4
|
+
import { advertisedPathsByLocale, ownContentRow } from '../seo/sidecar';
|
|
5
|
+
import type { SeoLanguageRow } from '../seo/sidecar';
|
|
6
|
+
import type { PageResolution } from './resolve-page';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a page URL claims about its own CONTENT (#1375, epic launch#54 D11 as
|
|
10
|
+
* amended by ruling ANSWER-1547Z) — the half of `/pages/<route>`'s metadata the
|
|
11
|
+
* page decides, as a value the route spreads into `routeMetadata`.
|
|
12
|
+
*
|
|
13
|
+
* Every claim is read off the page record's per-language SEO sidecar, through
|
|
14
|
+
* the SAME rules the product route uses, so a page and a product can never
|
|
15
|
+
* disagree about what a language without its own copy is (ruling Q6: product
|
|
16
|
+
* parity):
|
|
17
|
+
*
|
|
18
|
+
* - title, description and social image come from `entityMetadata` — the
|
|
19
|
+
* sidecar's row for this locale, the title falling back to the page
|
|
20
|
+
* definition's own name, and the social card armed only by a STORED image
|
|
21
|
+
* (a page's mapped image never arms it);
|
|
22
|
+
* - the alternates cluster is the route's address in every language the
|
|
23
|
+
* channel offers, narrowed to the languages that hold content of their own
|
|
24
|
+
* (`advertisedPathsByLocale`) — an untranslated language is never an
|
|
25
|
+
* hreflang alternate, and its own copy claims no canonical;
|
|
26
|
+
* - indexability is the merchant's choice for a language with its own copy,
|
|
27
|
+
* and `false` for a derived fallback (`ownContentRow`).
|
|
28
|
+
*
|
|
29
|
+
* PURE BY CONTRACT, for the reason `resolve-page.ts` is: the template-spec
|
|
30
|
+
* imports it by relative path, which is what makes these claims a table of
|
|
31
|
+
* vectors rather than something only a running pod can exercise. The inputs
|
|
32
|
+
* are structural for the same reason — the generated query types satisfy them,
|
|
33
|
+
* so the scaffold's own compiler proves the assignment.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** One language of a page record's sidecar, as these claims read it. */
|
|
37
|
+
export type PageSeoRow = SeoLanguageRow & SeoMetaRow;
|
|
38
|
+
|
|
39
|
+
/** The definition fields these claims read. */
|
|
40
|
+
export interface ClaimingPageGroup {
|
|
41
|
+
/** The merchant's name for the page — the title when the sidecar names none. */
|
|
42
|
+
readonly name: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The record fields these claims read.
|
|
47
|
+
*
|
|
48
|
+
* The sidecar is optional AND nullable because the generated selection is: the
|
|
49
|
+
* shop API answers `seo: null` for a definition that is not a page kind. A
|
|
50
|
+
* resolution that reached its `ok` arm has already proven the definition IS a
|
|
51
|
+
* page, so a null here means the API and the definition disagree — read, like a
|
|
52
|
+
* missing language row, as "no language has content of its own".
|
|
53
|
+
*/
|
|
54
|
+
export interface ClaimingPageEntry {
|
|
55
|
+
readonly seo?: { readonly languages: readonly PageSeoRow[] } | null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Where the page is being rendered, and every language its route exists in. */
|
|
59
|
+
export interface PageClaimsContext {
|
|
60
|
+
/** The locale this page is being rendered in. */
|
|
61
|
+
locale: string;
|
|
62
|
+
/** Every language the channel offers — the languages a page ROUTE exists in. */
|
|
63
|
+
languageCodes: readonly string[];
|
|
64
|
+
/** The locale-STRIPPED address of this page (`/pages/<route>`). */
|
|
65
|
+
pathname: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The content half of a page's `MetadataInput`, every key decided. */
|
|
69
|
+
export type PageContentClaims = Required<
|
|
70
|
+
Pick<
|
|
71
|
+
MetadataInput,
|
|
72
|
+
'pathsByLocale' | 'title' | 'description' | 'socialImage' | 'contentIndexable'
|
|
73
|
+
>
|
|
74
|
+
>;
|
|
75
|
+
|
|
76
|
+
/** The sidecar a record without one is read as: no language holds its own copy. */
|
|
77
|
+
const NO_SIDECAR: NonNullable<ClaimingPageEntry['seo']> = { languages: [] };
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A URL that is not a page claims nothing about content: no name of its own, no
|
|
81
|
+
* alternates, and not indexable — the refusal the product route states for a
|
|
82
|
+
* missing product, stated here rather than left to whatever Next does with the
|
|
83
|
+
* head of a page that answers 404.
|
|
84
|
+
*/
|
|
85
|
+
const NOT_FOUND_CLAIMS: PageContentClaims = {
|
|
86
|
+
pathsByLocale: {},
|
|
87
|
+
title: null,
|
|
88
|
+
description: null,
|
|
89
|
+
socialImage: null,
|
|
90
|
+
contentIndexable: false,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Decide the content claims of one page URL from the resolution the route acts
|
|
95
|
+
* on — the same value that decides its status, so the head and the response
|
|
96
|
+
* can never disagree about what the URL is.
|
|
97
|
+
*/
|
|
98
|
+
export function pageContentClaims(
|
|
99
|
+
resolution: PageResolution<ClaimingPageGroup, ClaimingPageEntry>,
|
|
100
|
+
{ locale, languageCodes, pathname }: PageClaimsContext,
|
|
101
|
+
): PageContentClaims {
|
|
102
|
+
if (resolution.kind === 'notFound') return NOT_FOUND_CLAIMS;
|
|
103
|
+
|
|
104
|
+
const seo = resolution.entry.seo ?? NO_SIDECAR;
|
|
105
|
+
const entity = entityMetadata({ name: resolution.group.name, seo }, locale);
|
|
106
|
+
return {
|
|
107
|
+
pathsByLocale: advertisedPathsByLocale(staticPathsByLocale(languageCodes, pathname), seo),
|
|
108
|
+
title: entity.title,
|
|
109
|
+
description: entity.description,
|
|
110
|
+
socialImage: entity.socialImage,
|
|
111
|
+
contentIndexable: ownContentRow(seo, locale)?.indexable ?? false,
|
|
112
|
+
};
|
|
113
|
+
}
|