Документация API

Инициализация приложения

Загрузка приложения · React

Загрузка приложения — первый запрос магазина к персональному бэкенду. Он возвращает настройки и начальный контент для интерфейса, профиль вошедшего покупателя и методы API, доступные текущей сессии.

POST/api/appload

Что приходит в ответе

page_data — данные приложения внутри ответа API. У четырёх основных разделов разные задачи:

IAppLoad

Начальные данные магазина, профиль покупателя и доступные эндпоинты API.

interfaces/orionplace/appBase/IAppLoad.ts
TypeScript
import 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;
}

Сессия и cookie

Ответ создаёт сессию гостя или покупателя в cookie sessionid и csrftoken. Шлюз убирает из них домен API, поэтому cookie принадлежат вашей витрине. Серверный рендер в layout не может записать cookie, поэтому браузер получает их с ответом на свой первый запрос к /api/...; дальше все запросы отправляют их сами.

Для чего использовать поля init

Каждое поле ведёт к описанию данных, их использования и связанных компонентов.

Поле ответа API
page_data.init
POST/api/appload
Доступ к хранилищуuseOrionplaceStore()
useOrionplace(selectInit)stores/orionplace.ts

Состав полей зависит от настроек магазина и текущей сессии. Поле может отсутствовать, а список — быть пустым. Начальная подборка товаров не заменяет полный каталог с пагинацией.

Как данные попадают в компоненты

useOrionplace(selector)
Подписывает компонент на выбранную часть состояния и перерисовывает его только при её изменении.
useOrionplaceStore()
Сам store: для действий и разового чтения вне рендера (getState()).
selectResult
Весь результат запроса: HTTP-статус и ответ сервера.
selectData
Объект page_data из ответа.
selectInit
Начальные данные page_data.init; пока загрузки не было, null.
selectProfile
Профиль покупателя после входа, иначе null.
selectStatus · selectIsReady · selectFailure
Состояние загрузки: идёт, готово или ошибка с её видом и полями.
  1. proxy.ts

    Next.js-прокси передаёт серверному рендеру адрес открытой страницы и запрещает кэшировать персональные страницы.

    Исходный код
    typescript
    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).*)'],
    };
    
  2. app/api/[...orionplace]/route.ts

    Шлюз: каждый запрос браузера к /api/... уходит отсюда в ваш API маркетплейса с доменом витрины и cookie сессии.

    Исходный код
    typescript
    // 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,
    };
    
  3. lib/orionplace/appLoadServer.ts

    getOrionplaceAppLoad() вызывает тот же шлюз на сервере, один раз за рендер. Ошибка не бросается, а попадает в store.

    Исходный код
    typescript
    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));
    });
    
  4. app/layout.tsx

    Корневой layout ждёт Application Load до рендера страницы и передаёт результат в OrionplaceProvider.

    Исходный код
    tsx
    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>
      );
    }
    
  5. providers/OrionplaceProvider.tsx

    Создаёт store один раз на сессию браузера и даёт хуки useOrionplace, useOrionplaceStore и хуки соседних store.

    Исходный код
    tsx
    '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;
    
  6. stores/orionplace.ts

    Store с ответом и селекторы для его частей: selectInit, selectCurrencies, selectProducts и другие.

    Исходный код
    typescript
    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];
    
  7. stores/orionplaceSelections.ts

    Выбор посетителя: язык, валюта, единицы веса и длины.

    Исходный код
    typescript
    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 });
        },
      }));
    }
    
  8. hooks/useOrionplaceAppLoad.ts

    Действия в браузере: initialize, refresh и reset. Сам хук запрос не отправляет; после входа или выхода вызовите reset, затем refresh.

    Исходный код
    typescript
    '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 };
    }
    
  9. lib/orionplace/appLoadClient.ts

    Запрос из браузера через шлюз того же сайта; cookie сессии браузер хранит и отправляет сам.

    Исходный код
    typescript
    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);
    }
    
  10. lib/orionplace/appLoadResult.ts

    Проверяет ответ и превращает сетевые ошибки и ошибки сервера в одну безопасную форму для store.

    Исходный код
    typescript
    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;
    }