Инициализация приложения
Загрузка приложения · React
Загрузка приложения — первый запрос магазина к персональному бэкенду. Он возвращает настройки и начальный контент для интерфейса, профиль вошедшего покупателя и методы API, доступные текущей сессии.
/api/apploadЧто приходит в ответе
page_data — данные приложения внутри ответа API. У четырёх основных разделов разные задачи:
Начальные данные магазина, профиль покупателя и доступные эндпоинты API.
interfaces/orionplace/appBase/IAppLoad.tsimport type { IAppLoadCommon } from '@/interfaces/orionplace/appBase/IAppLoadCommon';
import type { IApploadUserProfile } from '@/interfaces/orionplace/appBase/IApploadUserProfile';
import type { IAppLoadPathsRecord } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonPath';
export interface IAppLoad {
// Настройки и начальные данные магазина.
init: IAppLoadCommon;
// Публичные методы API, сгруппированные в paths.
unauth: IAppLoadPathsRecord;
// Профиль текущего покупателя. Отсутствует у гостя.
user?: IApploadUserProfile;
// Методы авторизованной сессии. Отсутствуют у гостя; права проверяет бэкенд.
auth?: IAppLoadPathsRecord;
// Показывает, включены ли функции панели продавца для этого магазина.
seller_panel_enabled?: boolean;
// Служебный ключ предпросмотра товаров для вошедшего аккаунта с данными
// продавца.
// Не публикуйте значение; покупательскому интерфейсу этот ключ не нужен.
view_key?: string;
// Время подготовки загрузки приложения на бэкенде, в секундах; только при
// debug
// проекта. Не включает сериализацию ответа и передачу по сети.
spent_total?: number;
// Время загрузки init на бэкенде, в секундах. Только при debug проекта.
spent_init?: number;
// Время загрузки публичных путей API, в секундах. Только при debug проекта.
unauth_spent?: number;
// Время подготовки профиля и auth, в секундах. Только при debug проекта; 0 у
// гостя.
auth_spent?: number;
}
Для чего использовать поля init
Каждое поле ведёт к описанию данных, их использования и связанных компонентов.
page_data.init/api/apploadЯзык, валюты и единицы
Языки, валюты и единицы измерения с названиями, форматами отображения и значениями по умолчанию для каждой локали.
localeuseOrionplace(selectLocales)MainLanguageSwitcherвыбирает язык; стор заменяет текущую валюту и единицы значениями по умолчанию выбранного языка.currenciesuseOrionplace(selectCurrencies)Доступные валюты для выбора в
MainCurrencySwitcher.weightsuseOrionplace(selectInit)?.weightsЕдиницы веса для выбора в
UnitSwitcher.lengthsuseOrionplace(selectInit)?.lengthsЕдиницы длины для выбора в
UnitSwitcher.shipping_countriesuseOrionplace(selectInit)?.shipping_countriesСтраны доставки магазина; страницы товаров указывают их в условиях возврата.
Каталог и поиск
Категории, начальные коллекции товаров, поисковые подсказки и тарифы подписки.
cataloguseOrionplace(selectCatalog)Дерево категорий для
CatalogHeaderиCategoryGroup.productsuseOrionplace(selectProducts)Начальная коллекция для
ProductGroup,ProductCardи компонентов товара, включаяHoverImageViewer.featureduseOrionplace(selectInit)?.featuredПерсональные рекомендации по предыдущим поискам и категориям. Компоненты товаров читают их с
collection="featured".sellersuseOrionplace(selectInit)?.sellersДанные продавцов по ID. Каждый товар из загрузки приложения указывает продавца в
seller_id; стор товаров находит эту запись, когда компонент читает товар.search_hintsuseOrionplace(selectInit)?.search_hintsПодсказки на доступных языках, которые показывает
MainSearch.tariffsuseOrionplace(selectTariffs)Коллекция тарифов, из которой
TariffCardsформирует отдельные компонентыTariffCard.
Оформление и метаданные
Логотипы, значок сайта, настройки отображения магазина и SEO-метаданные.
logosuseOrionplace(selectInit)?.logosBrandLogosпоказывает логотип выбранного размера или значок.visual_settingsuseOrionplace(selectInit)?.visual_settingsИзображения в меню категорий, количество и порядок категорий главной, остальные настройки отображения магазина.
seouseOrionplace(selectInit)?.seogetStorefrontSeo()подготавливает метаданные дляapp/pages/index.vue: заголовок, описание и ключевые слова применяются черезuseSeoMeta(), дополнительные теги — черезuseHead().
Контент и контакты
Баннеры, события магазина, контакты компании и ссылки на социальные сети.
main_banneruseOrionplace(selectInit)?.main_bannerИзображения баннеров, которые
MainBannerпоказывает на текущем языке магазина.eventsuseOrionplace(selectInit)?.eventsИзображения событий, которые
EventsBlockотбирает по языку и значениюis_layout.contactuseOrionplace(selectInit)?.contactКонтакты и регистрационные данные компании, которые показывает
AboutPage.smuseOrionplace(selectInit)?.smСоциальные ссылки и подписи на доступных языках для
SocialLinks.
Покупатель и счётчики
Признак авторизации, баланс кошелька и начальные счётчики корзины и избранного. Полная корзина и данные личного кабинета доступны через отдельные методы API.
is_authenticateduseOrionplace(selectIsAuthenticated)store.isAuthenticatedуправляет доступными авторизованными методами и переключаетStorefrontAccountмежду входом и меню профиля с кошельком.walletuseOrionplace(selectInit)?.walletWalletBalanceпоказывает баланс в выбранной валюте, включая компактный режим вStorefrontAccount.variablesuseOrionplace(selectInit)?.variablesНачальные счётчики кнопок корзины и избранного, разрешение предзаказа и статусы нового контента.
Карты и интеграции
Центр карты, публичный ключ карты и доступность поиска адреса по координатам.
default_mapuseOrionplace(selectInit)?.default_mapКоординаты центра карты по умолчанию.
geo_location_enableduseOrionplace(selectInit)?.geo_location_enabledОпределяет доступность поиска адреса по координатам. Текстовые подсказки зависят от выбранной страны.
public_keysuseOrionplace(selectInit)?.public_keysmap_keyзагружает Яндекс Карты для карты доставки в корзине.
Состав полей зависит от настроек магазина и текущей сессии. Поле может отсутствовать, а список — быть пустым. Начальная подборка товаров не заменяет полный каталог с пагинацией.
Как данные попадают в компоненты
useOrionplace(selector)- Подписывает компонент на выбранную часть состояния и перерисовывает его только при её изменении.
useOrionplaceStore()- Сам store: для действий и разового чтения вне рендера (
getState()). selectResult- Весь результат запроса: HTTP-статус и ответ сервера.
selectData- Объект
page_dataиз ответа. selectInit- Начальные данные
page_data.init; пока загрузки не было,null. selectProfile- Профиль покупателя после входа, иначе
null. selectStatus · selectIsReady · selectFailure- Состояние загрузки: идёт, готово или ошибка с её видом и полями.
proxy.tsNext.js-прокси передаёт серверному рендеру адрес открытой страницы и запрещает кэшировать персональные страницы.
Исходный код
import { NextResponse, type NextRequest } from 'next/server'; import { marketplaceNoindexPath } from '@orionplace-runtime/marketplace.mjs'; import { APP_LOAD_HANDOFF_HEADER, startAppLoad, } from '@/lib/orionplace/appLoadServer'; /** * A full page load: not an in-app navigation (`RSC`), a prefetch or a public * file such as an image. */ function pageLoad(request: NextRequest) { const accept = request.headers.get('accept') ?? ''; return ( request.method === 'GET' && !request.headers.has('rsc') && !request.headers.has('next-router-prefetch') && !/\.[a-z0-9]+$/i.test(request.nextUrl.pathname) && (!accept || accept.includes('text/html') || accept.includes('*/*')) ); } /** * Personalized pages are never shared between visitors or storefront domains. * Next.js manages its own `Vary` header for pages; the API gateway sets the * storefront-domain `Vary` on its own responses. * * A full page load starts Application Load here and hands it to the render: * when it fails, the error page answers HTTP 503 like the Nuxt and SvelteKit * starters (an unknown address keeps its 404). */ export async function proxy(request: NextRequest) { const forwarded = new Headers(request.headers); // Only this proxy names a started load; a visitor cannot pick one. forwarded.delete(APP_LOAD_HANDOFF_HEADER); forwarded.set( 'x-orionplace-page-url', request.nextUrl.pathname + request.nextUrl.search, ); let unavailable = false; if (pageLoad(request)) { const { id, load } = startAppLoad(forwarded); forwarded.set(APP_LOAD_HANDOFF_HEADER, id); unavailable = Boolean((await load).failure); } const response = NextResponse.next({ request: { headers: forwarded }, ...(unavailable ? { status: 503 } : {}), }); response.headers.set('Cache-Control', 'private, no-store'); // Pages under ORIONPLACE_NOINDEX_PATHS stay out of search engines. if (marketplaceNoindexPath(request.nextUrl.pathname)) { response.headers.set('X-Robots-Tag', 'noindex, follow'); } return response; } // The gateway sets its own headers for /api, /ps3 media, robots.txt and the // sitemap (public blog images keep the backend's browser cache rule); static // assets keep their caching. export const config = { matcher: ['/((?!api/|ps3/|_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml).*)'], };app/api/[...orionplace]/route.tsШлюз: каждый запрос браузера к
/api/...уходит отсюда в ваш API маркетплейса с доменом витрины и cookie сессии.Исходный код
// Every browser request to /api/... reaches your personal marketplace API here. import { forwardMarketplace } from '@orionplace-runtime/marketplace.mjs'; export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; function handler(request: Request): Promise<Response> { return forwardMarketplace(request, { mimicFrontDomain: process.env.MIMIC_FRONT_DOMAIN ?? '', }); } export { handler as GET, handler as HEAD, handler as OPTIONS, handler as POST, handler as PUT, handler as PATCH, handler as DELETE, };lib/orionplace/appLoadServer.tsgetOrionplaceAppLoad()вызывает тот же шлюз на сервере, один раз за рендер. Ошибка не бросается, а попадает в store.Исходный код
import { cache } from 'react'; import { headers } from 'next/headers'; import { forwardMarketplace } from '@orionplace-runtime/marketplace.mjs'; import { appLoadRejectedLocale, appLoadResult, inspectAppLoad, } from '@/lib/orionplace/appLoadResult'; import type { AppLoadFailure, AppLoadRequest, AppLoadResult, } from '@/interfaces/orionplace/transport'; /** Server-side bootstrap. Never import this module from a client component. */ export interface ServerAppLoad { result: AppLoadResult | null; failure: AppLoadFailure | null; /** Raw backend `Set-Cookie` headers; only a route handler or server action may relay them. */ cookies: string[]; } /** Headers a server render forwards to the marketplace gateway. */ const FORWARDED = [ 'cookie', 'x-front-domain', 'x-forwarded-host', 'x-forwarded-proto', 'host', 'accept-language', 'user-agent', 'x-init-ip', 'x-real-ip', 'x-forwarded-for', 'x-tz', ]; /** Header the proxy sets on a page request whose Application Load it started. */ export const APP_LOAD_HANDOFF_HEADER = 'x-orionplace-appload'; /** How long a started Application Load waits for the layout to take it. */ const HANDOFF_TTL = 60_000; const HANDOFF_KEY = Symbol.for('orionplace.appLoadHandoff'); /** * Started loads by id. Kept on `globalThis`: the proxy and the page render run * in the same Node process but in separate bundles. */ function handoffs(): Map<string, Promise<ServerAppLoad>> { const scope = globalThis as typeof globalThis & { [HANDOFF_KEY]?: Map<string, Promise<ServerAppLoad>>; }; scope[HANDOFF_KEY] ??= new Map(); return scope[HANDOFF_KEY]; } function buildRequest( incoming: Headers, signal: AbortSignal, withLocale = true, ): Request { const host = incoming.get('x-forwarded-host') || incoming.get('host') || 'localhost'; const protocol = incoming.get('x-forwarded-proto')?.split(',')[0]?.trim() || 'https'; const url = new URL('/api/appload', `${protocol}://${host}`); const pageUrl = new URL( incoming.get('x-orionplace-page-url') || '/', url.origin, ); const locale = withLocale ? pageUrl.searchParams.get('locale') : null; if (locale) { url.searchParams.set('lang', locale); } const forward = new Headers(); for (const name of FORWARDED) { const value = incoming.get(name); if (value) { forward.set(name, value); } } forward.set('accept', 'application/json'); forward.set('content-type', 'application/json'); // Same-origin server request: the gateway rejects cross-site mutations by Origin. forward.set('origin', url.origin); return new Request(url, { method: 'POST', headers: forward, body: JSON.stringify({ detalize_mode: 'light', ignore_cache: false, } satisfies AppLoadRequest), signal, }); } /** * Calls the same gateway the browser uses, in process, with this request's domain * and session. It never throws: the failure is stored for the interface to handle. */ export async function requestAppLoad( signal?: AbortSignal, incoming?: Headers, withLocale = true, ): Promise<ServerAppLoad> { const timeout = AbortSignal.timeout(35_000); const combined = signal ? AbortSignal.any([signal, timeout]) : timeout; let response: Response; let source: Headers; let request: Request; try { source = incoming ?? new Headers(await headers()); request = buildRequest(source, combined, withLocale); response = await forwardMarketplace(request, { mimicFrontDomain: process.env.MIMIC_FRONT_DOMAIN ?? '', }); } catch { return { result: null, failure: { kind: 'network', httpStatus: null, fields: [] }, cookies: [], }; } const cookies = response.headers.getSetCookie(); let body: unknown = null; try { body = await response.json(); } catch { body = null; } const failure = inspectAppLoad(body, response.status); // An address with a language the storefront does not have (`?locale=xx`) // opens in the storefront's default language instead of the 503 page. if ( failure && new URL(request.url).searchParams.has('lang') && appLoadRejectedLocale(failure) ) { return requestAppLoad(signal, source, false); } if (failure) { return { result: null, failure, cookies }; } return { result: appLoadResult(body, response.status), failure: null, cookies, }; } /** * Starts Application Load for a page request in `proxy.ts`, before the page * renders, so a failure can answer HTTP 503 (a page cannot change its status * once it streams). Returns the id to pass in `APP_LOAD_HANDOFF_HEADER`; the * render takes the same result, so the page still costs one request. */ export function startAppLoad(incoming: Headers): { id: string; load: Promise<ServerAppLoad>; } { const id = crypto.randomUUID(); const load = requestAppLoad(undefined, incoming); const store = handoffs(); store.set(id, load); setTimeout(() => store.delete(id), HANDOFF_TTL).unref?.(); return { id, load }; } /** * One request per server render, shared by the layout and any server component * that awaits it. Reading it in a layout initializes the marketplace before the * page renders; it never opens an authentication prompt. A load the proxy * started for this request is taken once; otherwise the render requests it. */ export const getOrionplaceAppLoad = cache(async (): Promise<ServerAppLoad> => { const incoming = await headers(); const id = incoming.get(APP_LOAD_HANDOFF_HEADER); const started = id ? handoffs().get(id) : undefined; if (id && started) { handoffs().delete(id); return started; } return requestAppLoad(undefined, new Headers(incoming)); });app/layout.tsxКорневой layout ждёт Application Load до рендера страницы и передаёт результат в
OrionplaceProvider.Исходный код
import type { Metadata } from 'next'; import type { ReactNode } from 'react'; import { OrionplaceProvider } from '@/providers/OrionplaceProvider'; import { getOrionplaceAppLoad } from '@/lib/orionplace/appLoadServer'; import { StorefrontShell } from '@/app/StorefrontShell'; import { getOrionplaceServerLocale } from '@/lib/orionplace/serverLocale'; import { getStorefrontMetadata, getStorefrontMetadataContext, } from '@/lib/orionplace/serverMetadata'; import '@/app/globals.css'; export async function generateMetadata(): Promise<Metadata> { return getStorefrontMetadata(); } // Initialization depends on the request domain and session, so pages are dynamic. export const dynamic = 'force-dynamic'; export default async function RootLayout({ children, }: { children: ReactNode; }) { // Bootstrap precedes page rendering. Failures stay in the store for the interface to handle. const { result, failure } = await getOrionplaceAppLoad(); const language = await getOrionplaceServerLocale(); const { tags, jsonLd } = await getStorefrontMetadataContext(); return ( <html lang={language}> <head> {tags.map((tag, index) => 'property' in tag ? ( <meta key={index} property={tag.property} content={tag.content} /> ) : ( <meta key={index} name={tag.name} content={tag.content} /> ), )} {jsonLd ? ( <script type="application/ld+json" // Escaped by structuredDataJson, so the text cannot close the tag. dangerouslySetInnerHTML={{ __html: jsonLd }} /> ) : null} </head> <body> <OrionplaceProvider result={result} failure={failure} locale={language} > <StorefrontShell>{children}</StorefrontShell> </OrionplaceProvider> </body> </html> ); }providers/OrionplaceProvider.tsxСоздаёт store один раз на сессию браузера и даёт хуки
useOrionplace,useOrionplaceStoreи хуки соседних store.Исходный код
'use client'; import { createContext, useContext, useRef, useEffect, type ReactNode, } from 'react'; import { useStore } from 'zustand'; import { type OrionplaceState, type OrionplaceStore, } from '@/stores/orionplace'; import { type OrionplaceSelectionsState, type OrionplaceSelectionsStore, } from '@/stores/orionplaceSelections'; import { type OrionplaceProductsState, type OrionplaceProductsStore, } from '@/stores/orionplaceProducts'; import { type OrionplaceCatalogState, type OrionplaceCatalogStore, createOrionplaceCatalogStore, } from '@/stores/orionplaceCatalog'; import { type OrionplaceBrowseState, type OrionplaceBrowseStore, } from '@/stores/orionplaceBrowse'; import { createOrionplaceStores, type OrionplaceStores, } from '@/stores/orionplaceScope'; import type { AppLoadFailure, AppLoadResult } from '@/interfaces/orionplace'; import type { IAppLoadCommon } from '@/interfaces/orionplace/appBase/IAppLoadCommon'; import { selectInit } from '@/stores/orionplace'; const OrionplaceContext = createContext<OrionplaceStore | null>(null); const SelectionsContext = createContext<OrionplaceSelectionsStore | null>(null); const ProductsContext = createContext<OrionplaceProductsStore | null>(null); const CatalogContext = createContext<OrionplaceCatalogStore | null>(null); const BrowseContext = createContext<OrionplaceBrowseStore | null>(null); export interface OrionplaceProviderProps { /** The server render's snapshot. Hydration reuses it instead of loading again. */ result?: AppLoadResult | null; failure?: AppLoadFailure | null; locale?: string; children: ReactNode; } /** Creates one store for this browser session and keeps it across re-renders. */ export function OrionplaceProvider({ result = null, failure = null, locale, children, }: OrionplaceProviderProps) { const stores = useRef<OrionplaceStores>(null); if (!stores.current) { stores.current = createOrionplaceStores(result, failure, locale); } const current = stores.current; return ( <OrionplaceContext.Provider value={current.store}> <SelectionsContext.Provider value={current.selections}> <ProductsContext.Provider value={current.products}> <CatalogContext.Provider value={current.catalog}> <BrowseContext.Provider value={current.browse}> {children} </BrowseContext.Provider> </CatalogContext.Provider> </ProductsContext.Provider> </SelectionsContext.Provider> </OrionplaceContext.Provider> ); } export function OrionplaceCatalogSnapshotProvider({ snapshot, children, }: { snapshot?: NonNullable<IAppLoadCommon['tariffs']>; children: ReactNode; }) { const init = useOrionplace(selectInit); const parent = useOrionplaceCatalogStore(); const catalog = useRef<OrionplaceCatalogStore | null>(null); if (!catalog.current) { catalog.current = createOrionplaceCatalogStore(init); if (snapshot) { catalog.current.getState().setTariffs(snapshot); } } const current = catalog.current; useEffect(() => { current.getState().reconcile(init); if (snapshot) { current.getState().setTariffs(snapshot); } }, [current, init, snapshot]); return ( <CatalogContext.Provider value={snapshot ? current : parent}> {children} </CatalogContext.Provider> ); } function useStoreApi(): OrionplaceStore { const store = useContext(OrionplaceContext); if (!store) { throw new Error('Render this component inside <OrionplaceProvider>.'); } return store; } /** Subscribes to one selected part of the marketplace state. */ export function useOrionplace<T>(selector: (state: OrionplaceState) => T): T { return useStore(useStoreApi(), selector); } /** The store itself, for actions and one-off reads outside render. */ export const useOrionplaceStore = useStoreApi; function useSelectionsStoreApi(): OrionplaceSelectionsStore { const store = useContext(SelectionsContext); if (!store) { throw new Error('Render this component inside <OrionplaceProvider>.'); } return store; } export function useOrionplaceSelections<T>( selector: (state: OrionplaceSelectionsState) => T, ): T { return useStore(useSelectionsStoreApi(), selector); } export const useOrionplaceSelectionsStore = useSelectionsStoreApi; function useProductsStoreApi(): OrionplaceProductsStore { const store = useContext(ProductsContext); if (!store) { throw new Error('Render this component inside <OrionplaceProvider>.'); } return store; } export function useOrionplaceProducts<T>( selector: (state: OrionplaceProductsState) => T, ): T { return useStore(useProductsStoreApi(), selector); } export const useOrionplaceProductsStore = useProductsStoreApi; function useCatalogStoreApi(): OrionplaceCatalogStore { const store = useContext(CatalogContext); if (!store) { throw new Error('Render this component inside <OrionplaceProvider>.'); } return store; } export function useOrionplaceCatalog<T>( selector: (state: OrionplaceCatalogState) => T, ): T { return useStore(useCatalogStoreApi(), selector); } export const useOrionplaceCatalogStore = useCatalogStoreApi; function useBrowseStoreApi(): OrionplaceBrowseStore { const store = useContext(BrowseContext); if (!store) { throw new Error('Render this component inside <OrionplaceProvider>.'); } return store; } export function useOrionplaceBrowse<T>( selector: (state: OrionplaceBrowseState) => T, ): T { return useStore(useBrowseStoreApi(), selector); } export const useOrionplaceBrowseStore = useBrowseStoreApi;stores/orionplace.tsStore с ответом и селекторы для его частей:
selectInit,selectCurrencies,selectProductsи другие.Исходный код
import { createStore } from 'zustand/vanilla'; import type { AppLoadFailure, AppLoadLoader, AppLoadResult, AppLoadStatus, IAppLoad, IAppLoadCommon, IAppLoadCommonCurrency, IAppLoadCommonLocale, IAppLoadCommonTariff, IAppLoadPath, IAppLoadPathObject, IApploadUserProfile, ICategory, IProduct, } from '@/interfaces/orionplace'; import { appLoadFailure } from '@/lib/orionplace/appLoadResult'; import { resolveAppLoadProducts } from '@/lib/orionplace/productSeller'; import { guestAppLoadResult } from '@/lib/orionplace/guestSession'; /** Shared empty values keep selector results referentially stable between renders. */ const EMPTY_RECORD = Object.freeze({}) as Record<string, never>; const EMPTY_LIST = Object.freeze([]) as readonly never[]; const EMPTY_PATHS = Object.freeze({}) as IAppLoadPathObject; export interface OrionplaceSnapshot { result: AppLoadResult | null; status: AppLoadStatus; failure: AppLoadFailure | null; endpoints: IAppLoadPathObject; } export interface OrionplaceState extends OrionplaceSnapshot { load: ( loader: AppLoadLoader, force?: boolean, preserve?: boolean, ) => Promise<void>; endSession: (loader: AppLoadLoader) => Promise<void>; patchProfile: (patch: Partial<IApploadUserProfile>) => void; patchWallet: (wallet: NonNullable<IAppLoadCommon['wallet']>) => void; reset: () => void; } export type OrionplaceStore = ReturnType<typeof createOrionplaceStore>; /** Public and authenticated discovery are merged once per snapshot, without mutating the response. */ function mergeEndpoints(result: AppLoadResult | null): IAppLoadPathObject { const page = result?.response.page_data; if (!page) { return EMPTY_PATHS; } const groups: IAppLoadPathObject[] = [page.unauth.paths]; if (page.init.is_authenticated && page.auth) { groups.push(page.auth.paths); } const merged: IAppLoadPathObject = {}; for (const group of groups) { for (const [section, paths] of Object.entries(group)) { merged[section] = { ...merged[section], ...paths }; } } return merged; } /** Builds the hydration snapshot from a server render, without repeating the request. */ export function initialSnapshot( result: AppLoadResult | null = null, failure: AppLoadFailure | null = null, ): OrionplaceSnapshot { return { result: failure ? null : result, status: failure ? 'error' : result ? 'success' : 'idle', failure, endpoints: failure ? EMPTY_PATHS : mergeEndpoints(result), }; } /** * One store instance per server render and one per browser session. * Creating the store never starts a request; the loaders are passed to `load`. */ export function createOrionplaceStore( snapshot: OrionplaceSnapshot = initialSnapshot(), ) { // These belong to this store instance, never serialized state or a module-level cache. let pending: Promise<void> | null = null; let controller: AbortController | null = null; let generation = 0; return createStore<OrionplaceState>()((set, get) => ({ ...snapshot, patchProfile(patch) { set((state) => { const page = state.result?.response.page_data; if (!state.result || !page?.user) { return {}; } return { result: { ...state.result, response: { ...state.result.response, page_data: { ...page, user: { ...page.user, ...patch }, }, }, }, }; }); }, // A purchase changes only the balance: it is replaced in place, without // an Application Load that would unmount the page and its confirmation. patchWallet(wallet) { set((state) => { const page = state.result?.response.page_data; if (!state.result || !page?.init?.is_authenticated) { return {}; } return { result: { ...state.result, response: { ...state.result.response, page_data: { ...page, init: { ...page.init, wallet }, }, }, }, }; }); }, reset() { generation++; controller?.abort(); controller = null; pending = null; set({ result: null, failure: null, status: 'idle', endpoints: EMPTY_PATHS, }); }, endSession(loader) { const result = get().result; if (!result?.response.page_data.init.is_authenticated) { return pending ?? Promise.resolve(); } generation++; controller?.abort(); controller = null; pending = null; const guest = guestAppLoadResult(result); set({ result: guest, endpoints: mergeEndpoints(guest) }); return get().load(loader, true, true); }, load( loader: AppLoadLoader, force = false, preserve = false, ): Promise<void> { if (pending) { return pending; } // Reuse both server success and failure after hydration or navigation; retry is explicit. if (!force && get().status !== 'idle') { return Promise.resolve(); } const current = ++generation; controller = new AbortController(); const signal = controller.signal; if (!preserve) { set({ result: null, failure: null, status: 'pending', endpoints: EMPTY_PATHS, }); } pending = Promise.resolve() .then(() => loader(signal)) .then((result) => { if (current !== generation) { return; } set({ result, failure: null, status: 'success', endpoints: mergeEndpoints(result), }); }) .catch((error: unknown) => { if (current !== generation) { return; } set({ result: null, endpoints: EMPTY_PATHS, failure: appLoadFailure(error), status: 'error', }); }) .finally(() => { if (current !== generation) { return; } pending = null; controller = null; }); return pending; }, })); } export const selectIsReady = (state: OrionplaceSnapshot): boolean => state.status === 'success' && state.result !== null; export const selectStatus = (state: OrionplaceSnapshot): AppLoadStatus => state.status; export const selectFailure = ( state: OrionplaceSnapshot, ): AppLoadFailure | null => state.failure; export const selectResult = (state: OrionplaceSnapshot): AppLoadResult | null => state.result; export const selectData = (state: OrionplaceSnapshot): IAppLoad | null => state.result?.response.page_data ?? null; export const selectInit = (state: OrionplaceSnapshot): IAppLoadCommon | null => selectData(state)?.init ?? null; export const selectProfile = ( state: OrionplaceSnapshot, ): IApploadUserProfile | null => selectData(state)?.user ?? null; export const selectIsAuthenticated = (state: OrionplaceSnapshot): boolean => selectInit(state)?.is_authenticated ?? false; export const selectLocales = ( state: OrionplaceSnapshot, ): Record<string, IAppLoadCommonLocale> => selectInit(state)?.locale ?? EMPTY_RECORD; export const selectCurrencies = ( state: OrionplaceSnapshot, ): Record<string, IAppLoadCommonCurrency> => selectInit(state)?.currencies ?? EMPTY_RECORD; export const selectCatalog = ( state: OrionplaceSnapshot, ): readonly ICategory[] => selectInit(state)?.catalog ?? EMPTY_LIST; export const selectProducts = ( state: OrionplaceSnapshot, ): readonly IProduct[] => { const init = selectInit(state); return init ? resolveAppLoadProducts(init.products, init.sellers) : EMPTY_LIST; }; export const selectTariffs = ( state: OrionplaceSnapshot, ): IAppLoadCommonTariff | null => selectInit(state)?.tariffs ?? null; export const selectPublicEndpoints = ( state: OrionplaceSnapshot, ): IAppLoadPathObject => selectData(state)?.unauth.paths ?? EMPTY_PATHS; export const selectEndpoints = ( state: OrionplaceSnapshot, ): IAppLoadPathObject => state.endpoints; export const selectAuthenticatedEndpoints = ( state: OrionplaceSnapshot, ): IAppLoadPathObject => selectIsAuthenticated(state) ? (selectData(state)?.auth?.paths ?? EMPTY_PATHS) : EMPTY_PATHS; /** Live discovery describes availability; the backend still checks permissions on every call. */ export const selectEndpoint = (section: string, name: string) => (state: OrionplaceSnapshot): IAppLoadPath | undefined => state.endpoints[section]?.[name];stores/orionplaceSelections.tsВыбор посетителя: язык, валюта, единицы веса и длины.
Исходный код
import { createStore } from 'zustand/vanilla'; import type { IAppLoadCommon } from '@/interfaces/orionplace'; export interface OrionplaceSelectionsState { localeCode: string; currencyId: string; weightId: string; lengthId: string; init: IAppLoadCommon | null; setLocale: (code: string) => void; setCurrency: (id: string) => void; setUnit: (kind: 'weights' | 'lengths', id: string) => void; reconcile: (init: IAppLoadCommon | null) => void; reset: () => void; } export type OrionplaceSelectionsStore = ReturnType< typeof createOrionplaceSelectionsStore >; function findLocale(init: IAppLoadCommon, code: string) { return Object.entries(init.locale).find( ([key, value]) => (value.code?.trim() || key).toLowerCase() === code.trim().toLowerCase(), ); } function unitId(values: Record<string, unknown>, preferred?: number | string) { const id = String(preferred ?? ''); return Object.hasOwn(values, id) ? id : (Object.keys(values)[0] ?? ''); } function availableCurrency(init: IAppLoadCommon, preferred?: number | string) { const id = String(preferred ?? ''); if (init.currencies[id]?.allowed_select) { return id; } return Object.keys(init.currencies).find( (key) => init.currencies[key]?.allowed_select, ) ?? ''; } export function initialSelections(init: IAppLoadCommon | null, locale = '') { const selected = init ? findLocale(init, locale) ?? Object.entries(init.locale)[0] : undefined; return { localeCode: selected?.[1].code?.trim() || selected?.[0] || '', currencyId: init ? availableCurrency(init, selected?.[1].currency) : '', weightId: init ? unitId(init.weights ?? {}, selected?.[1].weight) : '', lengthId: init ? unitId(init.lengths ?? {}, selected?.[1].length) : '', }; } /** One customer's choices; created per server render and browser session. */ export function createOrionplaceSelectionsStore( init: IAppLoadCommon | null = null, locale = '', ) { return createStore<OrionplaceSelectionsState>()((set, get) => ({ ...initialSelections(init, locale), init, setLocale(code) { const current = get().init; const entry = current && findLocale(current, code); if (!current || !entry) { return; } set({ localeCode: entry[1].code?.trim() || entry[0], currencyId: availableCurrency(current, entry[1].currency), weightId: unitId(current.weights ?? {}, entry[1].weight), lengthId: unitId(current.lengths ?? {}, entry[1].length), }); }, setCurrency(id) { if (get().init?.currencies[id]?.allowed_select) { set({ currencyId: id }); } }, setUnit(kind, id) { const values = get().init?.[kind]; if (!values || !Object.hasOwn(values, id)) { return; } set(kind === 'weights' ? { weightId: id } : { lengthId: id }); }, reconcile(next) { if (!next) { set({ ...initialSelections(null), init: null }); return; } const previous = get(); const selected = findLocale(next, previous.localeCode) ?? Object.entries(next.locale)[0]; const localeCode = selected?.[1].code?.trim() || selected?.[0] || ''; const preferredCurrency = next.currencies[previous.currencyId] ?.allowed_select ? previous.currencyId : selected?.[1].currency; set({ init: next, localeCode, currencyId: availableCurrency(next, preferredCurrency), weightId: unitId( next.weights ?? {}, Object.hasOwn(next.weights ?? {}, previous.weightId) ? previous.weightId : selected?.[1].weight, ), lengthId: unitId( next.lengths ?? {}, Object.hasOwn(next.lengths ?? {}, previous.lengthId) ? previous.lengthId : selected?.[1].length, ), }); }, reset() { set({ ...initialSelections(null), init: null }); }, })); }hooks/useOrionplaceAppLoad.tsДействия в браузере:
initialize,refreshиreset. Сам хук запрос не отправляет; после входа или выхода вызовитеreset, затемrefresh.Исходный код
'use client'; import { useCallback } from 'react'; import { useOrionplace, useOrionplaceStore, useOrionplaceSelectionsStore, } from '@/providers/OrionplaceProvider'; import { requestBrowserAppLoad } from '@/lib/orionplace/appLoadClient'; import { selectFailure, selectIsReady, selectResult, selectStatus, } from '@/stores/orionplace'; import type { AppLoadFailure, AppLoadResult, AppLoadStatus, } from '@/interfaces/orionplace'; export interface OrionplaceAppLoad { result: AppLoadResult | null; status: AppLoadStatus; failure: AppLoadFailure | null; isReady: boolean; initialize: () => Promise<void>; refresh: () => Promise<void>; reset: () => void; } /** * Explicit browser actions. Calling this hook does not start a request: the * server render initializes the marketplace and hydration reuses that snapshot. * After a confirmed login or logout, call reset and then refresh. */ export function useOrionplaceAppLoad(): OrionplaceAppLoad { const store = useOrionplaceStore(); const selections = useOrionplaceSelectionsStore(); const result = useOrionplace(selectResult); const status = useOrionplace(selectStatus); const failure = useOrionplace(selectFailure); const isReady = useOrionplace(selectIsReady); const initialize = useCallback( () => store .getState() .load((signal) => requestBrowserAppLoad(signal, selections.getState().localeCode), ), [store, selections], ); const refresh = useCallback( () => store .getState() .load( (signal) => requestBrowserAppLoad(signal, selections.getState().localeCode), true, ), [store, selections], ); const reset = useCallback(() => store.getState().reset(), [store]); return { result, status, failure, isReady, initialize, refresh, reset }; }lib/orionplace/appLoadClient.tsЗапрос из браузера через шлюз того же сайта; cookie сессии браузер хранит и отправляет сам.
Исходный код
import { AppLoadError, appLoadRejectedLocale, appLoadResult, inspectAppLoad, } from '@/lib/orionplace/appLoadResult'; import type { AppLoadRequest, AppLoadResult, } from '@/interfaces/orionplace/transport'; import { APP_ORIGIN, nativeAppRequest } from '@/lib/orionplace/nativeApp'; function withTimeout(signal: AbortSignal, milliseconds: number): AbortSignal { if (typeof AbortSignal.any !== 'function') { return signal; } return AbortSignal.any([signal, AbortSignal.timeout(milliseconds)]); } /** * Browser transport for explicit initialize and refresh actions. The browser * sends and stores session cookies itself through the same-origin gateway; * the Android app sends the request to its storefront (nativeApp.ts). */ export async function requestBrowserAppLoad( signal: AbortSignal, locale = '', ): Promise<AppLoadResult> { const path = locale ? `/api/appload?lang=${encodeURIComponent(locale)}` : '/api/appload'; const headers = { Accept: 'application/json', 'Content-Type': 'application/json', }; const request = { detalize_mode: 'light', ignore_cache: false, } satisfies AppLoadRequest; let status: number; let body: unknown = null; try { if (APP_ORIGIN) { const response = await nativeAppRequest(APP_ORIGIN, path, { method: 'POST', headers, data: request, signal: withTimeout(signal, 35_000), }); status = response.status; body = response.body; } else { const response = await fetch(path, { method: 'POST', headers, body: JSON.stringify(request), credentials: 'same-origin', cache: 'no-store', signal: withTimeout(signal, 35_000), }); status = response.status; body = await response.json().catch(() => null); } } catch { throw new AppLoadError('Could not reach the marketplace API.', { kind: 'network', httpStatus: null, fields: [], }); } const failure = inspectAppLoad(body, status); // A language the storefront does not have opens its default language. if (failure && locale && appLoadRejectedLocale(failure)) { return requestBrowserAppLoad(signal); } if (failure) { throw new AppLoadError('Marketplace initialization failed.', failure); } return appLoadResult(body, status); }lib/orionplace/appLoadResult.tsПроверяет ответ и превращает сетевые ошибки и ошибки сервера в одну безопасную форму для store.
Исходный код
import type { AppLoadFailure, AppLoadResponse, AppLoadResult, } from '@/interfaces/orionplace/transport'; const record = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value); /** Validate the bootstrap boundary. Interfaces describe nested data, not a runtime schema. */ export function inspectAppLoad( body: unknown, httpStatus: number, ): AppLoadFailure | null { if (httpStatus < 200 || httpStatus >= 300) { return { kind: 'http', httpStatus, fields: [] }; } if ( record(body) && record(body.form_errors) && Object.keys(body.form_errors).length ) { return { kind: 'validation', httpStatus, fields: Object.keys(body.form_errors), }; } const page = record(body) && body.page_data; const init = record(page) && page.init; const unauth = record(page) && page.unauth; if ( !record(body) || !('form_errors' in body) || (body.form_errors !== null && !record(body.form_errors)) || !record(init) || typeof init.is_authenticated !== 'boolean' || !record(init.locale) || !record(init.currencies) || !record(unauth) || !record(unauth.paths) || Object.values(unauth.paths).some( (group) => !record(group) || Object.values(group).some( (endpoint) => !record(endpoint) || typeof endpoint.target !== 'string' || !Array.isArray(endpoint.methods), ), ) ) { return { kind: 'invalid-response', httpStatus, fields: [] }; } return null; } export function appLoadResult( body: unknown, httpStatus: number, ): AppLoadResult { const failure = inspectAppLoad(body, httpStatus); if (failure) { throw new Error(`AppLoad: ${failure.kind}`); } return { response: body as AppLoadResponse & { page_data: NonNullable<AppLoadResponse['page_data']>; }, httpStatus, loadedAt: new Date().toISOString(), }; } /** Transport and envelope failures reach the store as this sanitized shape. */ export function appLoadFailure(error: unknown): AppLoadFailure { const detail = error && typeof error === 'object' && 'data' in error ? (error as { data: unknown }).data : null; const known = record(detail) && 'kind' in detail && ['http', 'validation', 'invalid-response', 'network'].includes( String(detail.kind), ); if (!known || !record(detail)) { return { kind: 'network', httpStatus: null, fields: [] }; } return { kind: detail.kind as AppLoadFailure['kind'], httpStatus: typeof detail.httpStatus === 'number' ? detail.httpStatus : null, fields: Array.isArray(detail.fields) ? detail.fields.filter( (field): field is string => typeof field === 'string', ) : [], }; } /** Carries a sanitized failure from the transport to the store without a stack or response body. */ export class AppLoadError extends Error { readonly data: AppLoadFailure; constructor(message: string, data: AppLoadFailure) { super(message); this.name = 'AppLoadError'; this.data = data; } } /** * Whether App Load refused the request's language (`?lang=` of a language the * storefront does not have): the backend answers it with field errors and * HTTP 452. The language is the only client value App Load reads, so the * storefront loads again without it. */ export function appLoadRejectedLocale(failure: AppLoadFailure): boolean { return failure.kind === 'validation' || failure.httpStatus === 452; }