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

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

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

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

POST/api/appload

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

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

IAppLoad

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

app/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;
}

Сессия и CSRF cookies

Загрузка приложения подготавливает данные приложения и сессию браузера, в том числе для гостя. Cookies приходят в HTTP-заголовках ответа, отдельно от JSON с данными.

Set-Cookie — Cookies и их атрибуты задаёт бэкенд. Cookie сессии имеет атрибут HttpOnly и не предназначена для чтения клиентским JavaScript. Значения cookies не должны попадать в стор инициализации, логи или локальное хранилище.

Вызовы через общий API-клиент разберём после знакомства с путями. Методы выбираются поurl_name — Эндпоинты API .

Как стартер Nuxt передаёт cookies при SSR

app/composables/useOrionplaceAppLoad.ts — общий транспорт API отправляет запрос загрузки приложения и запросы данных страницы при отрисовке и передаёт браузеру каждый Set-Cookie от бэкенда.

useRequestFetch() · useResponseHeader('set-cookie') — запрос получает контекст входящего HTTP-запроса, а cookies ответа передаются отдельно. Сохраняйте каждый заголовок Set-Cookie отдельно и без пересборки: это важно для подписанных значений, срока действия и удаления cookies.

Cookies, созданные при загрузке приложения, отсутствуют во входящем запросе. Стартовый проект хранит список cookies только для текущей отрисовки: он начинается с cookies посетителя и дополняется каждым Set-Cookie от бэкенда, включая обновлённый CSRF-токен. Следующие запросы той же отрисовки, например загрузка товара или статьи, отправляют этот список и поэтому работают в том же сеансе, что и браузер. Браузер получает каждый cookie вместе с ответом страницы. Список никогда не используется для других запросов или посетителей.

orionplace/runtime/marketplace.mjs — созданный шлюз передаёт cookies и CSRF-заголовок на выбранный персональный бэкенд. Он удаляет только атрибут Domain бэкенда, привязывая cookies к магазину, и сохраняет HttpOnly, Secure, SameSite, Path и срок действия.

Обновление, ошибки и смена сессии

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

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

Поле ответа API
page_data.init
POST/api/appload
Доступ к хранилищуuseOrionplaceStore()
store.initapp/stores/orionplace.ts
  • Язык, валюты и единицы

    Языки, валюты и единицы измерения с названиями, форматами отображения и значениями по умолчанию для каждой локали.

  • Каталог и поиск

    Категории, начальные коллекции товаров, поисковые подсказки и тарифы подписки.

  • Оформление и метаданные

    Логотипы, значок сайта, настройки отображения магазина и SEO-метаданные.

    • logosstore.init?.logos

      BrandLogos показывает логотип выбранного размера или значок.

    • visual_settingsstore.init?.visual_settings

      Изображения в меню категорий, количество и порядок категорий главной, остальные настройки отображения магазина.

    • seostore.init?.seo

      getStorefrontSeo() подготавливает метаданные для app/pages/index.vue: заголовок, описание и ключевые слова применяются через useSeoMeta(), дополнительные теги — через useHead().

  • Контент и контакты

    Баннеры, события магазина, контакты компании и ссылки на социальные сети.

  • Покупатель и счётчики

    Признак авторизации, баланс кошелька и начальные счётчики корзины и избранного. Полная корзина и данные личного кабинета доступны через отдельные методы API.

  • Карты и интеграции

    Центр карты, публичный ключ карты и доступность поиска адреса по координатам.

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

Как использовать данные после загрузки

  1. app/middleware/00.orionplace.global.ts

    Глобальный middleware запускается перед каждой страницей и ждёт завершения инициализации.

    Если данные уже загружены, переход между страницами не вызывает повторный запрос. При сбое стор сохраняет состояние ошибки, а middleware открывает страницу ошибки магазина с HTTP 503 и кнопкой «Повторить», которая перезагружает страницу. Неизвестный адрес по-прежнему открывает страницу 404. Сам middleware не выводит уведомлений.

    Исходный код
    typescript
    import { abortNavigation, defineNuxtRouteMiddleware } from '#imports';
    import { useOrionplaceAppLoad } from '@/composables/useOrionplaceAppLoad';
    import { storefrontPageError } from '@/composables/useOrionplaceMissingPage';
    
    /**
     * Bootstrap precedes page setup. A failed Application Load opens the
     * storefront error page with HTTP 503 on every page; an unknown address keeps
     * its 404 page.
     */
    export default defineNuxtRouteMiddleware(async (to) => {
      const { initialize, store } = useOrionplaceAppLoad();
      const locale                = typeof to.query.locale === 'string'
        ? to.query.locale
        : '';
    
      await initialize(locale);
    
      if (!to.matched.length) {
        return;
      }
    
      const error                 = storefrontPageError(store.status, false, '');
    
      if (error) {
        return abortNavigation(error);
      }
    });
    
  2. app/composables/useOrionplaceAppLoad.ts

    Композабл вызывает общий API-клиент, проверяет ответ инициализации и сохраняет результат в хранилище. Транспорт клиента передаёт браузеру cookies, которые бэкенд устанавливает при серверной отрисовке.

    Предоставляет действия initialize, refresh и reset. Само обращение к композаблу не запускает запрос.

    Исходный код
    typescript
    import { createError, usePinia, useResponseHeader } from '#imports';
    import { computed } from 'vue';
    import { storeToRefs } from 'pinia';
    import { useOrionplaceStore } from '@/stores/orionplace';
    import type {
      AppLoadFailure,
      AppLoadResult,
    } from '@/interfaces/orionplace/transport';
    import { useOrionplaceApi } from '@/composables/useOrionplaceApi';
    import {
      appLoadRejectedLocale,
      appLoadResult,
      inspectAppLoad,
    } from '@/utils/orionplaceAppLoad';
    
    /** Transport and explicit actions. Calling this accessor does not start a request. */
    export function useOrionplaceAppLoad() {
      // SSR forwards the current request's domain, cookies and authorization to our own gateway.
      const store   = useOrionplaceStore(usePinia());
      const refs    = storeToRefs(store);
      const api     = useOrionplaceApi();
      if (import.meta.server) {
        useResponseHeader('cache-control').value = 'private, no-store';
      }
    
      const request = async (
        signal: AbortSignal,
        locale = '',
      ): Promise<AppLoadResult> => {
        let received:
          { body: unknown; status: number; cookies: string[] } | undefined;
        try {
          received = await api.applicationLoad(signal, locale);
        } catch {
          throw createError({
            message: 'Could not reach the marketplace API.',
            data: {
              kind: 'network',
              httpStatus: null,
              fields: [],
            } satisfies AppLoadFailure,
          });
        }
        if (!received) {
          throw createError({
            message: 'The API returned no response.',
            data: {
              kind: 'network',
              httpStatus: null,
              fields: [],
            } satisfies AppLoadFailure,
          });
        }
        // A cookie that AppLoad sets during a server render (a new guest session
        // or CSRF token) is relayed to the browser by the shared API transport,
        // which also sends it with the later requests of the same render.
        const failure = inspectAppLoad(received.body, received.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 && locale && appLoadRejectedLocale(failure)) {
          return request(signal);
        }
        if (failure) {
          throw createError({
            message: 'Marketplace initialization failed.',
            data: failure,
          });
        }
        return appLoadResult(received.body, received.status);
      };
      return {
        store,
        data: refs.result,
        status: refs.status,
        error: computed(() => (store.failure ? { data: store.failure } : null)),
        initialize: (locale = '') =>
          store.load((signal) => request(signal, locale)),
        refresh: () => store.load(request, true),
        // Called by the shared API client after HTTP 401 in a signed-in session.
        endSession: () => store.endSession(request),
        reset: () => store.reset(),
      };
    }
    
  3. app/composables/useOrionplaceApi.ts

    Создаёт общие API-клиенты приложения Nuxt: один для сеанса в браузере и по одному для каждой серверной отрисовки. Компоненты отправляют запросы к методам из discovery в браузере. Страницы товара, категории, поиска, рекомендаций, журнала и статьи также загружают свои первые данные при серверной отрисовке через useOrionplaceServerApi() с cookies посетителя, поэтому эти данные уже есть в HTML страницы. Браузер использует этот результат и не повторяет запрос.

    Исходный код
    typescript
    import {
      useNuxtApp,
      usePinia,
      useRequestFetch,
      useRequestHeader,
      useResponseHeader,
      useRuntimeConfig,
    } from '#imports';
    import { useOrionplaceStore } from '@/stores/orionplace';
    import { useOrionplaceSelectionsStore } from '@/stores/orionplaceSelections';
    import { useOrionplaceAppLoad } from '@/composables/useOrionplaceAppLoad';
    import { OrionplaceApi } from '@/utils/orionplace/api';
    import type {
      OrionplaceApiRequest,
      OrionplaceApiResponse,
    } from '@/utils/orionplace/api';
    import { createOrionplaceCookieJar } from '@/utils/orionplace/serverCookies';
    import {
      nativeAppCookies,
      nativeAppOrigin,
      nativeAppRequest,
    } from '@/utils/orionplace/nativeApp';
    
    interface OrionplaceClients {
      browser: OrionplaceApi;
      server: OrionplaceApi;
    }
    
    // One entry per Nuxt application: one browser session, or one server render.
    const clients = new WeakMap<object, OrionplaceClients>();
    
    function createClients(): OrionplaceClients {
      const nuxt            = useNuxtApp();
      const store           = useOrionplaceStore(usePinia());
      const selections      = useOrionplaceSelectionsStore();
      const requestFetch    = useRequestFetch();
      // A server render sends the visitor's cookies and the cookies the backend
      // set earlier in the same render; see utils/orionplace/serverCookies.ts.
      const jar             = import.meta.server
        ? createOrionplaceCookieJar(useRequestHeader('cookie'))
        : undefined;
      const responseCookies = import.meta.server
        ? useResponseHeader('set-cookie')
        : undefined;
      // The Android app sends its requests to the public storefront; see
      // utils/orionplace/nativeApp.ts. Empty on the website.
      const appOrigin       = nativeAppOrigin(
        useRuntimeConfig().public.orionplaceAppOrigin,
      );
    
      // Every cookie the backend sets during a server render reaches the
      // browser as its own Set-Cookie header, unchanged.
      function relay(cookies: string[]) {
        if (!responseCookies || !cookies.length) {
          return;
        }
    
        const existing        = responseCookies.value;
        const existingCookies = Array.isArray(existing) ? [...existing] : [];
    
        if (existing && !Array.isArray(existing)) {
          existingCookies.push(String(existing));
        }
    
        responseCookies.value = [...existingCookies, ...cookies];
      }
    
      const transport       = async (
        path: string,
        request: OrionplaceApiRequest,
      ): Promise<OrionplaceApiResponse> => {
        if (appOrigin) {
          return nativeAppRequest(appOrigin, path, request);
        }
        let received: OrionplaceApiResponse | undefined;
        // A server render's own API call copies the visitor's request headers.
        // A visitor who follows a link from another site arrives with
        // `Sec-Fetch-Site: cross-site` (and a form post with a foreign Origin);
        // the gateway's cross-site check would refuse the render's call and the
        // page would answer 503. The call comes from this server, so it is sent
        // as same-origin without the visitor's Origin.
        const headers = jar
          ? {
              ...request.headers,
              cookie: jar.header(),
              origin: '',
              'sec-fetch-site': 'same-origin',
              'sec-fetch-mode': 'cors',
              'sec-fetch-dest': 'empty',
            }
          : request.headers;
    
        await requestFetch<unknown>(path, {
          method: request.method,
          headers,
          body: request.method === 'GET' ? undefined : request.data,
          query:
            request.method === 'GET' && !(request.data instanceof FormData)
              ? request.data
              : undefined,
          signal: request.signal,
          credentials: 'same-origin',
          cache: 'no-store',
          retry: 0,
          timeout: 35_000,
          ignoreResponseError: true,
          onResponse({ response }) {
            received = {
              body: response._data,
              status: response.status,
              cookies: import.meta.server ? response.headers.getSetCookie() : [],
              headers: {
                'content-type': response.headers.get('content-type') ?? '',
                'content-disposition':
                  response.headers.get('content-disposition') ?? '',
              },
            };
          },
        });
    
        if (!received) {
          throw new Error('The API returned no response.');
        }
    
        jar?.apply(received.cookies);
        relay(received.cookies);
    
        return received;
      };
    
      const shared          = {
        transport,
        endpoints: () => store.endpoints,
        cookies: () =>
          jar ? jar.header() : appOrigin ? nativeAppCookies() : document.cookie,
        locale: () => store.localeCode,
        isSignedIn: () => store.isAuthenticated,
        // The customer's currency for orders and plan purchases (CURRENCY_REQUESTS).
        currency: () => selections.currencyId,
      };
    
      return {
        // Components send their requests in the browser. During a server render
        // this client sends only Application Load, so a request started while
        // the page renders cannot outlive the render.
        browser: new OrionplaceApi({
          ...shared,
          canRequest: () => import.meta.client,
          // HTTP 401 ended the customer's session: show the guest storefront.
          // This never opens sign-in and never repeats the failed request.
          onSessionEnded: () =>
            nuxt.runWithContext(() => useOrionplaceAppLoad().endSession()),
        }),
        // Page data that the server render awaits; see useOrionplaceServerData.
        // A failed server request leaves the page to the browser, which loads
        // the data again and handles an ended session there.
        server: new OrionplaceApi(shared),
      };
    }
    
    function useOrionplaceClients() {
      const nuxt     = useNuxtApp();
      const existing = clients.get(nuxt);
    
      if (existing) {
        return existing;
      }
    
      const created  = createClients();
      clients.set(nuxt, created);
    
      return created;
    }
    
    export function useOrionplaceApi() {
      return useOrionplaceClients().browser;
    }
    
    /**
     * The client for page data loaded during a server render. It shares the
     * transport, endpoint discovery, cookies and CSRF handling with
     * `useOrionplaceApi`. Use it through `useOrionplaceServerData`, which
     * awaits the request before the page renders.
     */
    export function useOrionplaceServerApi() {
      return useOrionplaceClients().server;
    }
    
  4. app/utils/orionplace/api.ts

    Выполняет загрузку приложения и находит браузерные методы по url_name в актуальном discovery. Читает текущий CSRF-токен перед запросом и объединяет одновременное обновление токена. При отклонении CSRF обновляет токен для следующего действия и возвращает ошибку. Покупатель повторяет действие явно; неудачные запросы не выполняются автоматически.

    Исходный код
    typescript
    import type { IAppLoadPathObject } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonPath';
    import type { TMethod } from '@/interfaces/orionplace/apiBase/TMethod';
    
    export interface OrionplaceApiResponse {
      body: unknown;
      status: number;
      cookies: string[];
      headers?: Record<string, string>;
    }
    
    export type OrionplaceApiData = Record<string, unknown> | FormData;
    
    export interface OrionplaceApiRequest {
      method: TMethod;
      data: OrionplaceApiData;
      headers: Record<string, string>;
      signal?: AbortSignal;
    }
    
    export type OrionplaceTransport = (
      path: string,
      request: OrionplaceApiRequest,
    ) => Promise<OrionplaceApiResponse>;
    
    /**
     * The texts of the field errors, each once: OrionMarketplace often repeats a
     * field's error for the whole form.
     */
    export function fieldErrorText(fields: Record<string, unknown>) {
      return [...new Set(Object.values(fields).filter(Boolean))].join(' ');
    }
    
    export class OrionplaceApiError extends Error {
      readonly kind: 'unavailable' | 'failed';
      readonly fields: Record<string, string>;
      readonly status?: number;
      readonly data?: unknown;
      // Error codes of the answer, such as `sign_in` when OrionMarketplace asks the visitor
      // to sign in first.
      readonly codes: string[];
    
      constructor(
        kind: 'unavailable' | 'failed',
        fields: Record<string, string> = {},
        status?: number,
        data?: unknown,
        codes: string[] = [],
      ) {
        super(fieldErrorText(fields) || kind);
        this.kind   = kind;
        this.fields = fields;
        this.status = status;
        this.data   = data;
        this.codes  = codes;
      }
    }
    
    /**
     * Requests whose amounts the backend counts in the customer's currency: an
     * order, a preorder and a plan purchase are priced and charged in it. The
     * client adds the selected currency to them (unless the caller set one).
     */
    export const CURRENCY_REQUESTS = new Set([
      'orders_create',
      'preorders_create',
      'subscriptions_subscribe',
      'subscriptions_renew',
      'subscriptions_change_renew',
      'subscriptions_change_tariff',
      'subscriptions_change_option',
    ]);
    
    export class OrionplaceApi {
      private renewal: Promise<void> | undefined;
      private sessionEnding: Promise<void> | undefined;
      private transport: OrionplaceTransport;
      private endpoints: () => IAppLoadPathObject;
      private cookies: () => string;
      private locale: () => string;
      private canRequest: () => boolean;
      private isSignedIn: () => boolean;
      private currency: () => string | number | undefined;
      private onSessionEnded: (() => unknown) | undefined;
    
      constructor(options: {
        transport: OrionplaceTransport;
        endpoints: () => IAppLoadPathObject;
        cookies: () => string;
        locale?: () => string;
        canRequest?: () => boolean;
        isSignedIn?: () => boolean;
        currency?: () => string | number | undefined;
        onSessionEnded?: () => unknown;
      }) {
        this.transport      = options.transport;
        this.endpoints      = options.endpoints;
        this.cookies        = options.cookies;
        this.locale         = options.locale ?? (() => '');
        this.canRequest     = options.canRequest ?? (() => true);
        this.isSignedIn     = options.isSignedIn ?? (() => false);
        this.currency       = options.currency ?? (() => undefined);
        this.onSessionEnded = options.onSessionEnded;
      }
    
      /** The guest refresh started by the latest HTTP 401, while it runs. */
      get sessionEnd() {
        return this.sessionEnding;
      }
    
      applicationLoad(signal: AbortSignal, locale = '') {
        return this.send(
          '/api/appload',
          'POST',
          {
            detalize_mode: 'light',
            ignore_cache: false,
          },
          signal,
          locale,
        );
      }
    
      async request<T>(
        name: string,
        data: OrionplaceApiData,
        signal?: AbortSignal,
        params: Record<string, number> = {},
      ): Promise<T> {
        return this.successData<T>(
          await this.requestResponse(name, data, signal, params),
        );
      }
    
      async requestResponse(
        name: string,
        data: OrionplaceApiData,
        signal?: AbortSignal,
        params: Record<string, number> = {},
      ): Promise<OrionplaceApiResponse> {
        if (!this.canRequest()) {
          throw new OrionplaceApiError('unavailable');
        }
        const discovered       = Object.values(this.endpoints())
          .flatMap((section) => Object.values(section))
          .find((entry) => entry.url_name === name);
        const endpoint         =
          discovered ??
          (name === 'reload_customer_app'
            ? { target: 'api/reload_customer_app', methods: ['PATCH' as const] }
            : undefined);
        const method           = endpoint?.methods[0];
        let path               = `/${endpoint?.target.replace(/^\/+/, '') ?? ''}`;
        path                   = path.replace(/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/g, (_, key: string) => {
          const value = params[key];
          if (!Number.isSafeInteger(value) || Number(value) <= 0) {
            throw new OrionplaceApiError('unavailable');
          }
    
          return String(value);
        });
    
        if (
          !endpoint ||
          !method ||
          !/^\/api\/[a-zA-Z0-9_/-]+(?:\.png)?$/.test(path)
        ) {
          throw new OrionplaceApiError('unavailable');
        }
    
        if (method === 'GET' && data instanceof FormData) {
          throw new OrionplaceApiError('unavailable');
        }
        const currency         = Number(this.currency());
        if (
          CURRENCY_REQUESTS.has(name) &&
          !(data instanceof FormData) &&
          data.currency === undefined &&
          Number.isSafeInteger(currency) &&
          currency > 0
        ) {
          data = { ...data, currency };
        }
        const protectedRequest = method !== 'GET';
        if (protectedRequest && !this.csrfToken()) {
          await this.renewCsrf();
        }
        signal?.throwIfAborted();
    
        const response         = await this.send(path, method, data, signal);
        const body             = response.body as {
          page_data?: { csrf?: boolean };
        } | null;
        // A CSRF rejection: Django views answer 419, API endpoints answer 403
        // with page_data.csrf set to false. The request is not repeated.
        const csrfRejected     =
          response.status === 419 ||
          (response.status === 403 && body?.page_data?.csrf === false);
        // Renew for the next explicit action and retain this request's rejection.
        if (protectedRequest && csrfRejected) {
          await this.renewCsrf();
          signal?.throwIfAborted();
        }
    
        this.detectSessionEnd(response);
        signal?.throwIfAborted();
        return response;
      }
    
      /*
       * The backend answers HTTP 401 when the request has no signed-in session:
       * an authentication-required endpoint without a customer ({ auth: false }),
       * or a session the backend rejected and whose cookies it deleted.
       * A 401 while the application still shows a customer means the session
       * ended. One handler call serves concurrent failures; a guest 401, 403 and
       * CSRF rejection are ordinary request errors.
       */
      private detectSessionEnd(response: OrionplaceApiResponse) {
        if (
          response.status !== 401 ||
          this.sessionEnding ||
          !this.onSessionEnded ||
          !this.isSignedIn()
        ) {
          return;
        }
    
        let ending: Promise<void>;
        try {
          ending = Promise.resolve(this.onSessionEnded()).then(() => undefined);
        } catch (error) {
          ending = Promise.reject(error);
        }
    
        // The failed request reports its own error; the refresh has its own state.
        this.sessionEnding = ending
          .catch(() => undefined)
          .finally(() => {
            this.sessionEnding = undefined;
          });
      }
    
      private csrfToken() {
        return this.cookies().match(/(?:^|;\s*)csrftoken=([^;]*)/)?.[1] ?? '';
      }
    
      private localizedPath(path: string, requestedLocale = '') {
        const locale    = (requestedLocale || this.locale()).trim();
        if (!locale) {
          return path;
        }
    
        const separator = path.includes('?') ? '&' : '?';
        return `${path}${separator}lang=${encodeURIComponent(locale)}`;
      }
    
      private async renewCsrf() {
        if (!this.renewal) {
          this.renewal = this.send('/api/csrf_renew', 'POST', {})
            .then((response) => {
              this.detectSessionEnd(response);
              this.successData(response);
              if (!this.csrfToken()) {
                throw new OrionplaceApiError('failed');
              }
            })
            .finally(() => {
              this.renewal = undefined;
            });
        }
        await this.renewal;
      }
    
      private send(
        path: string,
        method: TMethod,
        data: OrionplaceApiData,
        signal?: AbortSignal,
        locale = '',
      ) {
        const headers: Record<string, string> = {
          Accept: 'application/json',
          ...(data instanceof FormData
            ? {}
            : { 'Content-Type': 'application/json' }),
        };
        const token                           = this.csrfToken();
        if (token) {
          headers['X-CSRFToken'] = token;
        }
        return this.transport(this.localizedPath(path, locale), {
          method,
          data,
          headers,
          signal,
        });
      }
    
      private successData<T>(response: OrionplaceApiResponse): T {
        const body = response.body as {
          form_errors?: unknown;
          success_message?: unknown;
          page_data?: T;
        } | null;
        if (
          response.status < 200 ||
          response.status >= 300 ||
          body?.form_errors !== null ||
          typeof body.success_message !== 'string' ||
          !body.success_message
        ) {
          const fields = Object.fromEntries(
            Object.entries(body?.form_errors ?? {}).map(([name, entries]) => [
              name,
              Array.isArray(entries)
                ? entries.map((entry) => String(entry?.message ?? '')).join(' ')
                : '',
            ]),
          );
          const codes  = Object.values(body?.form_errors ?? {})
            .flatMap((entries) => (Array.isArray(entries) ? entries : []))
            .map((entry) => String(entry?.code ?? ''))
            .filter(Boolean);
          throw new OrionplaceApiError(
            'failed',
            fields,
            response.status,
            body?.page_data,
            codes,
          );
        }
        return body.page_data as T;
      }
    }
    
  5. app/stores/orionplace.ts

    useOrionplaceStore()
    Доступ компонентов к согласованным хранилищам, показанным ниже.
    store.result
    Результат загрузки: тело ответа, HTTP-статус и время получения.
    store.result?.response
    Тело ответа API целиком.
    store.data
    Данные приложения из ответа API.
    store.init
    Начальные данные магазина. Ссылки выше ведут к каждому полю, его компоненту и исходному коду.
    store.profile
    Профиль покупателя, если он присутствует в ответе.

    useOrionplaceStore() содержит ответ загрузки приложения, состояние загрузки, профиль, кошелёк и доступные методы API. Компоненты читают выбор языка и валюты, каталог, товары и данные поиска через соответствующие хранилища. Этот файл согласует обновление, сброс и завершение сеанса между небольшими хранилищами.

    Исходный код
    typescript
    import type { IApploadUserProfile } from '@/interfaces/orionplace/appBase/IApploadUserProfile';
    import {
      computed,
      ref,
      shallowRef,
      hasInjectionContext,
      inject,
      provide,
    } from 'vue';
    import type { InjectionKey, Ref } from 'vue';
    import type { Pinia } from 'pinia';
    import { defineStore, getActivePinia, storeToRefs } from 'pinia';
    import { useOrionplaceSelections } from '@/stores/orionplaceSelections';
    import { useOrionplaceBrowse } from '@/stores/orionplaceBrowse';
    import { useOrionplaceCatalog } from '@/stores/orionplaceCatalog';
    import { useOrionplaceProducts } from '@/stores/orionplaceProducts';
    import {
      attachOrionplaceDomainStores,
      registerOrionplaceDomainResolver,
      registerOrionplaceDomainStores,
      releaseOrionplaceDomainStores,
    } from '@/stores/orionplaceDomainAccess';
    import {
      guestAppLoadResult,
      guestProduct,
    } from '@/utils/orionplace/guestSession';
    import type { IProduct } from '@/interfaces/orionplace/products/IProduct';
    import type {
      AppLoadFailure,
      AppLoadResult,
      AppLoadLoader,
      AppLoadStatus,
      IAppLoadPathObject,
    } from '@/interfaces/orionplace';
    
    function forwardedState<T>(source: Ref<T>) {
      return computed({
        get: () => source.value,
        set: (value: T) => {
          source.value = value;
        },
      });
    }
    
    /** One instance per Nuxt SSR request; @pinia/nuxt hydrates it into the browser. */
    function createStoreState(scope: string, pinia: Pinia) {
      const result                           = shallowRef<AppLoadResult | null>(null);
      const status                           = ref<AppLoadStatus>('idle');
      const failure                          = shallowRef<AppLoadFailure | null>(null);
      const isReady                          = computed(
        () => status.value === 'success' && result.value !== null,
      );
      const data                             = computed(() => result.value?.response.page_data ?? null);
      const init                             = computed(() => data.value?.init ?? null);
      const profile                          = computed(() => data.value?.user ?? null);
      const isAuthenticated                  = computed(() => init.value?.is_authenticated ?? false);
      const selections                       = useOrionplaceSelections(
        scope,
        pinia,
        init,
      );
      const selectionRefs                    = storeToRefs(selections);
      const {
        locales,
        currencies,
        weights,
        lengths,
        currentLocale,
        currentCurrency,
      }                                      = selectionRefs;
      const localeCode                       = forwardedState(selectionRefs.localeCode);
      const currencyId                       = forwardedState(selectionRefs.currencyId);
      const weightId                         = forwardedState(selectionRefs.weightId);
      const lengthId                         = forwardedState(selectionRefs.lengthId);
      const {
        setLocale,
        setCurrency,
        setUnit,
      }                                      = selections;
      const catalogState                     = useOrionplaceCatalog(
        scope,
        pinia,
        init,
      );
      const catalogRefs                      = storeToRefs(catalogState);
      const {
        catalog,
        tariffs,
        storeTariffs,
        products,
        featured,
        catalogRoots,
        catalogShortcuts,
        catalogCategories,
      }                                      = catalogRefs;
      const catalogSnapshot                  = forwardedState(catalogRefs.catalogSnapshot);
      const tariffsSnapshot                  = forwardedState(catalogRefs.tariffsSnapshot);
      const {
        setCatalogTree,
        setTariffs,
        setStoreTariffs,
        getTariff,
        getCatalogCategory,
        getCatalogProducts,
      }                                      = catalogState;
      const productState                     = useOrionplaceProducts(
        scope,
        pinia,
        init,
        products,
        featured,
      );
      const productRefs                      = storeToRefs(productState);
      const {
        homeProductSections,
        counters,
      }                                      = productRefs;
      const homeNewProductIds                = forwardedState(productRefs.homeNewProductIds);
      const productSecondaryVariants         = forwardedState(
        productRefs.productSecondaryVariants,
      );
      const productUpdates                   = forwardedState(productRefs.productUpdates);
      const productRequests                  = forwardedState(productRefs.productRequests);
      const {
        setHomeNewProducts,
        getProducts,
        getProduct,
        setProduct,
        patchProductState,
        getProductRequest,
        setProductRequest,
        getSecondaryVariants,
        synchronizeProductCart,
        setSecondaryVariants,
        getProductAvailability,
        setProductCounters,
      }                                      = productState;
      const browse                           = useOrionplaceBrowse(
        scope,
        pinia,
        init,
      );
      const browseRefs                       = storeToRefs(browse);
      const searchQuery                      = forwardedState(browseRefs.searchQuery);
      const searchFacets                     = forwardedState(browseRefs.searchFacets);
      const searchFilters                    = forwardedState(browseRefs.searchFilters);
      const blogPosts                        = forwardedState(browseRefs.blogPosts);
      const {
        setSearchFacets,
        preserveSearchFacets,
        setSearchFilters,
        setBlogPosts,
      }                                      = browse;
      registerOrionplaceDomainStores(pinia, scope, {
        selections,
        catalog: catalogState,
        products: productState,
        browse,
      });
    
      function patchProfile(value: Partial<IApploadUserProfile>) {
        const current = result.value;
        const page    = current?.response.page_data;
        if (!current || !page?.user || !isAuthenticated.value) {
          return;
        }
        result.value  = {
          ...current,
          response: {
            ...current.response,
            page_data: { ...page, user: { ...page.user, ...value } },
          },
        };
      }
    
      function patchWallet(
        wallet: NonNullable<NonNullable<typeof init.value>['wallet']>,
      ) {
        const current = result.value;
        const page    = current?.response.page_data;
        if (!current || !page?.init || !isAuthenticated.value) {
          return;
        }
        result.value  = {
          ...current,
          response: {
            ...current.response,
            page_data: { ...page, init: { ...page.init, wallet } },
          },
        };
      }
    
      function applyResult(value: AppLoadResult) {
        catalogState.reset();
        productState.reset();
        browse.resetResults();
        result.value                   = value;
        status.value                   = 'success';
        failure.value                  = null;
        selections.reconcile();
      }
    
      const publicEndpoints                  = computed(() => data.value?.unauth.paths ?? {});
      const authenticatedEndpoints           = computed(() =>
        isAuthenticated.value ? (data.value?.auth?.paths ?? {}) : {},
      );
      const endpoints                        = computed<IAppLoadPathObject>(() => {
        const merged: IAppLoadPathObject = {};
        for (const groups of [
          publicEndpoints.value,
          authenticatedEndpoints.value,
        ]) {
          for (const [section, paths] of Object.entries(groups)) {
            merged[section] = { ...merged[section], ...paths };
          }
        }
        return merged;
      });
      const getEndpoint                      = (section: string, name: string) =>
        endpoints.value[section]?.[name];
    
      // 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;
    
      function reset() {
        generation++;
        controller?.abort();
        controller                     = null;
        pending                        = null;
        result.value                   = null;
        catalogState.reset();
        productState.reset();
        browse.reset();
        selections.reset();
        failure.value                  = null;
        status.value                   = 'idle';
      }
    
      /*
       * The shared API client calls this when a request answers HTTP 401 while
       * this store still shows a signed-in customer. The signed-in data is removed
       * at once, so the header and account pages show the guest state, and the
       * guest Application Load replaces the remaining snapshot in the background.
       * A guest store is left unchanged.
       */
      function endSession(loader: AppLoadLoader): Promise<void> {
        if (!isAuthenticated.value || !result.value) {
          return pending ?? Promise.resolve();
        }
        showGuestSnapshot(result.value);
    
        return load(loader, true, true);
      }
    
      function showGuestSnapshot(current: AppLoadResult) {
        const guestProducts            = (items: Record<number, IProduct>) =>
          Object.fromEntries(
            Object.entries(items).map(([id, item]) => [id, guestProduct(item)]),
          );
    
        generation++;
        controller?.abort();
        controller                     = null;
        pending                        = null;
        result.value                   = guestAppLoadResult(current);
        productState.clearSession();
        productUpdates.value           = {
          products: guestProducts(productUpdates.value.products),
          featured: guestProducts(productUpdates.value.featured),
        };
        for (const state of Object.values(productSecondaryVariants.value)) {
          for (const variant of state.data?.variants ?? []) {
            variant.product = guestProduct(variant.product);
          }
        }
      }
    
      /*
       * `preserve` keeps the current snapshot visible while the replacement loads.
       * It is used only after `endSession` has already removed signed-in data.
       */
      function load(
        loader: AppLoadLoader,
        force = false,
        preserve = false,
      ): Promise<void> {
        if (pending) {
          return pending;
        }
        // Reuse both SSR success and failure on hydration/navigation; retry is explicit.
        if (!force && status.value !== 'idle') {
          return Promise.resolve();
        }
        const current                  = ++generation;
        controller                     = new AbortController();
        const signal                   = controller.signal;
        if (!preserve) {
          catalogState.reset();
          productState.reset();
          browse.resetResults();
          result.value                 = null;
          failure.value                = null;
          status.value                 = 'pending';
        }
        pending                        = Promise.resolve()
          .then(() => loader(signal))
          .then((value) => {
            if (current !== generation) {
              return;
            }
            applyResult(value);
          })
          .catch((error: unknown) => {
            if (current !== generation) {
              return;
            }
            const detail  =
              error && typeof error === 'object' && 'data' in error
                ? error.data
                : null;
            const known   =
              detail &&
              typeof detail === 'object' &&
              'kind' in detail &&
              ['http', 'validation', 'invalid-response', 'network'].includes(
                String(detail.kind),
              );
            failure.value = known
              ? {
                  kind: detail.kind as AppLoadFailure['kind'],
                  httpStatus:
                    'httpStatus' in detail && typeof detail.httpStatus === 'number'
                      ? detail.httpStatus
                      : null,
                  fields:
                    'fields' in detail && Array.isArray(detail.fields)
                      ? detail.fields.filter(
                          (field): field is string => typeof field === 'string',
                        )
                      : [],
                }
              : { kind: 'network', httpStatus: null, fields: [] };
            result.value  = null;
            status.value  = 'error';
          })
          .finally(() => {
            if (current !== generation) {
              return;
            }
            pending    = null;
            controller = null;
          });
        return pending;
      }
    
      function disposeChildren() {
        selections.$dispose();
        catalogState.$dispose();
        productState.$dispose();
        browse.$dispose();
      }
    
    
      return {
        result,
        status,
        failure,
        isReady,
        data,
        init,
        profile,
        patchProfile,
        patchWallet,
        isAuthenticated,
        locales,
        currencies,
        catalog,
        catalogSnapshot,
        setCatalogTree,
        catalogRoots,
        catalogShortcuts,
        catalogCategories,
        products,
        tariffs,
        tariffsSnapshot,
        setTariffs,
        storeTariffs,
        setStoreTariffs,
        featured,
        homeNewProductIds,
        homeProductSections,
        setHomeNewProducts,
        weights,
        lengths,
        localeCode,
        currencyId,
        weightId,
        lengthId,
        searchQuery,
        searchFacets,
        searchFilters,
        blogPosts,
        setSearchFacets,
        preserveSearchFacets,
        setSearchFilters,
        setBlogPosts,
        currentLocale,
        currentCurrency,
        setLocale,
        setCurrency,
        setUnit,
        getProducts,
        getTariff,
        getProduct,
        productUpdates,
        productRequests,
        getProductRequest,
        setProductRequest,
        counters,
        setProductCounters,
        setProduct,
        patchProductState,
        synchronizeProductCart,
        productSecondaryVariants,
        getSecondaryVariants,
        setSecondaryVariants,
        getProductAvailability,
        getCatalogCategory,
        getCatalogProducts,
        applyResult,
        publicEndpoints,
        authenticatedEndpoints,
        endpoints,
        getEndpoint,
        load,
        endSession,
        reset,
        disposeChildren,
      };
    }
    
    type OrionplaceStore = ReturnType<typeof createOrionplaceStore>;
    const storeScope: InjectionKey<OrionplaceStore>  = Symbol('orionplace:store');
    const wrappedStores                              = new WeakSet<object>();
    
    // Scoped previews use another instance in the same Pinia, never another global app.
    export function provideOrionplaceStore(store: OrionplaceStore) {
      provide(storeScope, store);
    }
    
    export function createOrionplaceStore(id: string, pinia: Pinia) {
      const store = defineStore(id, () => createStoreState(id, pinia))(pinia);
      attachOrionplaceDomainStores(store, pinia, id);
      if (!wrappedStores.has(store)) {
        const dispose = store.$dispose.bind(store);
        store.$dispose = () => {
          store.disposeChildren();
          dispose();
          releaseOrionplaceDomainStores(store, pinia, id);
          for (const key of [
            id,
            `${id}:selections`,
            `${id}:catalog`,
            `${id}:products`,
            `${id}:browse`,
          ]) {
            delete pinia.state.value[key];
          }
        };
        wrappedStores.add(store);
      }
      return store;
    }
    
    export const useOrionplaceStore                 = Object.assign(
      (pinia?: Pinia | null) => {
        const scoped = hasInjectionContext() ? inject(storeScope, null) : null;
        if (pinia) {
          return createOrionplaceStore('orionplace', pinia);
        }
        if (scoped) {
          return scoped;
        }
        return createOrionplaceStore('orionplace', getActivePinia()!);
      },
      { $id: 'orionplace' },
    );
    
    registerOrionplaceDomainResolver(
      () => useOrionplaceStore(),
    );
    
  6. app/stores/orionplaceSelections.ts

    Сохраняет выбранные язык, валюту и единицы веса и длины. Смена языка выбирает его значения по умолчанию; повторная загрузка приложения сохраняет доступные варианты.

    Исходный код
    typescript
    import { computed, ref } from 'vue';
    import type { ComputedRef } from 'vue';
    import { defineStore } from 'pinia';
    import type { Pinia } from 'pinia';
    import { resolveOrionplaceDomain } from '@/stores/orionplaceDomainAccess';
    import '@/stores/orionplace';
    import type { IAppLoadCommon } from '@/interfaces/orionplace/appBase/IAppLoadCommon';
    
    /** Customer choices shared by the storefront's language, currency and unit controls. */
    export function useOrionplaceSelections(
      scope: string,
      pinia: Pinia,
      init: ComputedRef<IAppLoadCommon | null>,
    ) {
      return defineStore(`${scope}:selections`, () => {
        const localeCode      = ref('');
        const currencyId      = ref('');
        const weightId        = ref('');
        const lengthId        = ref('');
        const locales         = computed(() => init.value?.locale ?? {});
        const currencies      = computed(() => init.value?.currencies ?? {});
        const weights         = computed(() => init.value?.weights ?? {});
        const lengths         = computed(() => init.value?.lengths ?? {});
        const currentLocale   = computed(() => findLocale(localeCode.value)?.[1]);
        const currentCurrency = computed(() => currencies.value[currencyId.value]);
    
        function findLocale(code: string) {
          return Object.entries(locales.value).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(preferred?: number | string) {
          const id = String(preferred ?? '');
          if (currencies.value[id]?.allowed_select) {
            return id;
          }
          return Object.keys(currencies.value).find(
            (key) => currencies.value[key]?.allowed_select,
          ) ?? '';
        }
    
        function setLocale(code: string) {
          const entry = findLocale(code);
          if (!entry) {
            return;
          }
          localeCode.value = entry[1].code?.trim() || entry[0];
          currencyId.value = availableCurrency(entry[1].currency);
          weightId.value   = unitId(weights.value, entry[1].weight);
          lengthId.value   = unitId(lengths.value, entry[1].length);
        }
    
        function setCurrency(id: string) {
          if (currencies.value[id]?.allowed_select) {
            currencyId.value = id;
          }
        }
    
        function setUnit(kind: 'weights' | 'lengths', id: string) {
          const values = kind === 'weights' ? weights.value : lengths.value;
          if (!Object.hasOwn(values, id)) {
            return;
          }
          if (kind === 'weights') {
            weightId.value = id;
          } else {
            lengthId.value = id;
          }
        }
    
        function reconcile() {
          const selected   = findLocale(localeCode.value) ?? Object.entries(locales.value)[0];
          localeCode.value = selected?.[1].code?.trim() || selected?.[0] || '';
          currencyId.value = availableCurrency(
            currencies.value[currencyId.value]?.allowed_select
              ? currencyId.value
              : selected?.[1].currency,
          );
          weightId.value = unitId(
            weights.value,
            Object.hasOwn(weights.value, weightId.value)
              ? weightId.value
              : selected?.[1].weight,
          );
          lengthId.value = unitId(
            lengths.value,
            Object.hasOwn(lengths.value, lengthId.value)
              ? lengthId.value
              : selected?.[1].length,
          );
        }
    
        function reset() {
          localeCode.value = '';
          currencyId.value = '';
          weightId.value   = '';
          lengthId.value   = '';
        }
    
        return {
          localeCode,
          currencyId,
          weightId,
          lengthId,
          locales,
          currencies,
          weights,
          lengths,
          currentLocale,
          currentCurrency,
          setLocale,
          setCurrency,
          setUnit,
          reconcile,
          reset,
        };
      })(pinia);
    }
    
    /** Access the selection store associated with the current storefront scope. */
    export function useOrionplaceSelectionsStore() {
      return resolveOrionplaceDomain<ReturnType<typeof useOrionplaceSelections>>(
        'selections',
      );
    }
    
  7. app/stores/orionplaceCatalog.ts

    Читает начальные списки категорий, товаров и тарифов из загрузки приложения. Успешные запросы каталога и тарифов заменяют соответствующие списки до следующей загрузки.

    Исходный код
    typescript
    import { computed, shallowRef } from 'vue';
    import type { ComputedRef } from 'vue';
    import { defineStore } from 'pinia';
    import type { Pinia } from 'pinia';
    import { resolveOrionplaceDomain } from '@/stores/orionplaceDomainAccess';
    import '@/stores/orionplace';
    import type { IAppLoadCommon } from '@/interfaces/orionplace/appBase/IAppLoadCommon';
    import {
      navigationCatalogCategories,
      findCatalogCategory,
      catalogRepresentatives,
    } from '@/utils/orionplace/catalog';
    import { resolveAppLoadProducts } from '@/utils/orionplace/productSeller';
    
    /**
     * Application Load collections and the later catalog and tariff responses.
     * `storeTariffs` is every plan the storefront sells; the subscriptions page
     * and its dialogs read it. `tariffs` is what the plan pages show: the same
     * list, or a search result or one plan set with `setTariffs`.
     * `usedSubscriptions` holds the subscription records the signed-in customer
     * already had, for the profile that loaded them; a new Application Load
     * keeps it, another profile ignores it.
     */
    export function useOrionplaceCatalog(
      scope: string,
      pinia: Pinia,
      init: ComputedRef<IAppLoadCommon | null>,
    ) {
      return defineStore(`${scope}:catalog`, () => {
        const catalogSnapshot      = shallowRef<NonNullable<IAppLoadCommon['catalog']>>();
        const tariffsSnapshot      = shallowRef<NonNullable<IAppLoadCommon['tariffs']>>();
        const storeTariffsSnapshot = shallowRef<NonNullable<IAppLoadCommon['tariffs']>>();
        const usedSubscriptions    = shallowRef<{ profile: number; ids: number[] }>();
        const catalog              = computed(
          () => catalogSnapshot.value ?? init.value?.catalog ?? [],
        );
        const storeTariffs         = computed(
          () => storeTariffsSnapshot.value ?? init.value?.tariffs ?? null,
        );
        const tariffs              = computed(
          () => tariffsSnapshot.value ?? storeTariffs.value,
        );
        const products             = computed(() =>
          resolveAppLoadProducts(init.value?.products, init.value?.sellers),
        );
        const featured             = computed(() =>
          resolveAppLoadProducts(init.value?.featured, init.value?.sellers),
        );
        const catalogRoots         = computed(() => navigationCatalogCategories(catalog.value));
        const catalogShortcuts     = computed(() =>
          catalogRoots.value.filter((category) => category.separate_menu),
        );
        const catalogCategories    = computed(() => {
          const categories = catalogRoots.value.filter(
            (category) => !category.separate_menu,
          );
          return categories.length ? categories : catalogRoots.value;
        });
    
        function setCatalogTree(tree: NonNullable<IAppLoadCommon['catalog']>) {
          catalogSnapshot.value = tree;
        }
    
        /** A search result or one plan: only the plan pages show it. */
        function setTariffs(value: NonNullable<IAppLoadCommon['tariffs']>) {
          tariffsSnapshot.value = value;
        }
    
        /** The complete plan list of the storefront, loaded after Application Load. */
        function setStoreTariffs(value: NonNullable<IAppLoadCommon['tariffs']>) {
          storeTariffsSnapshot.value = value;
          tariffsSnapshot.value      = undefined;
        }
    
        /** The records the customer had, see `tariffUsedSubscriptions`. */
        function setUsedSubscriptions(profile: number, ids: number[]) {
          usedSubscriptions.value = { profile, ids };
        }
    
        /** Those records for this profile; undefined until they are loaded. */
        function usedSubscriptionsOf(profile: number | undefined) {
          return usedSubscriptions.value &&
            usedSubscriptions.value.profile === profile
            ? usedSubscriptions.value.ids
            : undefined;
        }
    
        function getTariff(id: number) {
          return tariffs.value?.data.find((tariff) => tariff.id === id);
        }
    
        function getCatalogCategory(id: number) {
          return findCatalogCategory(catalog.value, id);
        }
    
        function getCatalogProducts(categoryId: number) {
          return catalogRepresentatives(
            getCatalogCategory(categoryId),
            products.value,
            featured.value,
          );
        }
    
        function reset() {
          catalogSnapshot.value      = undefined;
          tariffsSnapshot.value      = undefined;
          storeTariffsSnapshot.value = undefined;
        }
    
        return {
          catalogSnapshot,
          tariffsSnapshot,
          storeTariffsSnapshot,
          usedSubscriptions,
          catalog,
          tariffs,
          storeTariffs,
          products,
          featured,
          catalogRoots,
          catalogShortcuts,
          catalogCategories,
          setCatalogTree,
          setTariffs,
          setStoreTariffs,
          setUsedSubscriptions,
          usedSubscriptionsOf,
          getTariff,
          getCatalogCategory,
          getCatalogProducts,
          reset,
        };
      })(pinia);
    }
    
    /** Access the catalog store associated with the current storefront scope. */
    export function useOrionplaceCatalogStore() {
      return resolveOrionplaceDomain<ReturnType<typeof useOrionplaceCatalog>>(
        'catalog',
      );
    }
    
  8. app/stores/orionplaceProducts.ts

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

    Исходный код
    typescript
    import { computed, ref } from 'vue';
    import type { ComputedRef } from 'vue';
    import { defineStore } from 'pinia';
    import type { Pinia } from 'pinia';
    import { resolveOrionplaceDomain } from '@/stores/orionplaceDomainAccess';
    import '@/stores/orionplace';
    import type { IAppLoadCommon } from '@/interfaces/orionplace/appBase/IAppLoadCommon';
    import type {
      IProduct,
      IProductSearchSeller,
    } from '@/interfaces/orionplace/products/IProduct';
    import type { IProductSecondaryVariantState } from '@/interfaces/orionplace/products/IProductSecondaryVariantRequest';
    import { homeProductSections as groupHomeProducts } from '@/utils/orionplace/homeContent';
    import { productAvailability } from '@/utils/orionplace/productAvailability';
    import { productWithSellerPermission } from '@/utils/orionplace/productSeller';
    
    /** Products shared by cards, cart actions and variant selectors. */
    export function useOrionplaceProducts(
      scope: string,
      pinia: Pinia,
      init: ComputedRef<IAppLoadCommon | null>,
      products: ComputedRef<IProduct[]>,
      featured: ComputedRef<IProduct[]>,
    ) {
      return defineStore(`${scope}:products`, () => {
        const homeNewProductIds                = ref<number[] | null>(null);
        const homeProductSections              = computed(() =>
          groupHomeProducts(products.value, featured.value, homeNewProductIds.value),
        );
    
        function setHomeNewProducts(
          items: IProduct[],
          sellers: IProductSearchSeller[] = [],
        ) {
          if (!init.value) {
            return;
          }
          for (const product of items) {
            setProduct(product, 'products', sellers);
          }
          homeNewProductIds.value = items.map((product) => product.id);
        }
    
        const productSecondaryVariants         = ref<
          Record<string, IProductSecondaryVariantState>
        >({});
        const productCounters                  = ref<{
          c_cart_positions?: number;
          c_wishlist?: number;
        }>({});
        const counters                         = computed(() => ({
          ...init.value?.variables,
          ...productCounters.value,
        }));
    
        function setProductCounters(values: {
          c_cart_positions?: number;
          c_wishlist?: number;
        }) {
          productCounters.value = { ...productCounters.value, ...values };
        }
    
        const productUpdates                   = ref<{
          products: Record<number, IProduct>;
          featured: Record<number, IProduct>;
        }>({ products: {}, featured: {} });
        // Variant targets the host is loading for a card; see useOrionplaceProductVariants.
        const productRequests                  = ref<
          Record<number, 'loading' | 'failed'>
        >({});
        function getProducts(collection: 'products' | 'featured' = 'products') {
          return collection === 'featured' ? featured.value : products.value;
        }
    
        function getProduct(
          id: number,
          collection: 'products' | 'featured' = 'products',
        ) {
          return (
            productUpdates.value[collection][id] ??
            getProducts(collection).find((product) => product.id === id)
          );
        }
    
        function setProduct(
          product: IProduct,
          collection: 'products' | 'featured' = 'products',
          sellers: IProductSearchSeller[] = [],
        ) {
          if (init.value && Number.isInteger(product.id) && product.id > 0) {
            productUpdates.value[collection][product.id] =
              productWithSellerPermission(product, sellers);
          }
        }
    
        function patchProductState(
          id: number,
          patch: Partial<
            Pick<
              IProduct,
              | 'cart_counts'
              | 'whishlist_id'
              | 'subscription_id'
              | 'c_real_left'
              | 'seller'
            >
          >,
        ) {
          for (const collection of ['products', 'featured'] as const) {
            const product = getProduct(id, collection);
            if (product) {
              setProduct({ ...product, ...patch }, collection);
            }
          }
          for (const state of Object.values(productSecondaryVariants.value)) {
            for (const variant of state.data?.variants ?? []) {
              if (variant.product.id === id) {
                variant.product = { ...variant.product, ...patch };
              }
            }
          }
        }
    
        function getProductRequest(id: number) {
          return productRequests.value[id];
        }
    
        function setProductRequest(id: number, state?: 'loading' | 'failed') {
          if (!Number.isInteger(id) || id <= 0) {
            return;
          }
    
          if (state) {
            productRequests.value[id] = state;
          } else {
            delete productRequests.value[id];
          }
        }
    
        function getSecondaryVariants(
          productGroup: number,
          mainVariantValue: number,
        ) {
          return productSecondaryVariants.value[
            `${productGroup}:${mainVariantValue}`
          ];
        }
    
        function synchronizeProductCart(items: IProduct[]) {
          const counts = new Map(
            items.map((item) => [item.id, item.cart_counts ?? null]),
          );
          for (const collection of ['products', 'featured'] as const) {
            const cached = new Map([
              ...getProducts(collection).map((item) => [item.id, item] as const),
              ...Object.values(productUpdates.value[collection]).map(
                (item) => [item.id, item] as const,
              ),
            ]);
            for (const product of cached.values()) {
              productUpdates.value[collection][product.id] = {
                ...product,
                cart_counts: counts.get(product.id) ?? null,
              };
            }
          }
          for (const state of Object.values(productSecondaryVariants.value)) {
            for (const variant of state.data?.variants ?? []) {
              variant.product = {
                ...variant.product,
                cart_counts: counts.get(variant.product.id) ?? null,
              };
            }
          }
        }
    
        function setSecondaryVariants(
          productGroup: number,
          mainVariantValue: number,
          state: IProductSecondaryVariantState,
        ) {
          if (
            init.value &&
            Number.isInteger(productGroup) &&
            productGroup > 0 &&
            Number.isInteger(mainVariantValue) &&
            mainVariantValue > 0
          ) {
            productSecondaryVariants.value[`${productGroup}:${mainVariantValue}`] =
              state;
          }
        }
    
        function getProductAvailability(
          id: number,
          collection: 'products' | 'featured' = 'products',
        ) {
          const product = getProduct(id, collection);
          return productAvailability(product, init.value?.variables?.allow_preorder);
        }
    
        function resetHome() {
          homeNewProductIds.value = null;
        }
    
        function clearSession() {
          productRequests.value = {};
          productCounters.value = {};
        }
    
        function reset() {
          resetHome();
          productSecondaryVariants.value = {};
          productUpdates.value           = { products: {}, featured: {} };
          clearSession();
        }
    
        return {
          homeNewProductIds,
          homeProductSections,
          setHomeNewProducts,
          productSecondaryVariants,
          productCounters,
          counters,
          setProductCounters,
          productUpdates,
          productRequests,
          getProducts,
          getProduct,
          setProduct,
          patchProductState,
          getProductRequest,
          setProductRequest,
          getSecondaryVariants,
          synchronizeProductCart,
          setSecondaryVariants,
          getProductAvailability,
          clearSession,
          reset,
        };
      })(pinia);
    }
    
    /** Access the product store associated with the current storefront scope. */
    export function useOrionplaceProductsStore() {
      return resolveOrionplaceDomain<ReturnType<typeof useOrionplaceProducts>>(
        'products',
      );
    }
    
  9. app/stores/orionplaceBrowse.ts

    Сохраняет поисковый запрос, выбранные фильтры, варианты фильтров и статьи журнала. Запросы поиска и журнала заполняют эти значения; повторная загрузка приложения очищает результаты и сохраняет текст запроса.

    Исходный код
    typescript
    import { ref, shallowRef } from 'vue';
    import type { ComputedRef } from 'vue';
    import { defineStore } from 'pinia';
    import type { Pinia } from 'pinia';
    import { resolveOrionplaceDomain } from '@/stores/orionplaceDomainAccess';
    import '@/stores/orionplace';
    import type { IAppLoadCommon } from '@/interfaces/orionplace/appBase/IAppLoadCommon';
    import type { IBlogPostPreview } from '@/interfaces/orionplace/blog/IBlogPostPreview';
    import type {
      IProductSearchFacets,
      ProductFacetSelection,
    } from '@/interfaces/orionplace/products/IProductSearchFacets';
    import {
      cloneSearchFilters,
      initialSearchFacets,
    } from '@/utils/orionplace/searchFilters';
    
    /** Search choices and journal results loaded after Application Load. */
    export function useOrionplaceBrowse(
      scope: string,
      pinia: Pinia,
      init: ComputedRef<IAppLoadCommon | null>,
    ) {
      return defineStore(`${scope}:browse`, () => {
        const searchQuery   = ref('');
        const searchFacets  = shallowRef<IProductSearchFacets | null>(null);
        const searchFilters = ref<ProductFacetSelection>({});
        const blogPosts     = shallowRef<IBlogPostPreview[]>([]);
    
        function setSearchFacets(value: IProductSearchFacets | null) {
          searchFacets.value = value;
        }
    
        function preserveSearchFacets(value: IProductSearchFacets) {
          searchFacets.value = initialSearchFacets(searchFacets.value, value);
        }
    
        function setSearchFilters(value: ProductFacetSelection) {
          const selected = cloneSearchFilters(value);
          if (init.value?.visual_settings?.single_seller_mode) {
            delete selected.product_owner;
          }
          searchFilters.value = selected;
        }
    
        function setBlogPosts(value: IBlogPostPreview[]) {
          blogPosts.value = [...value];
        }
    
        function resetResults() {
          searchFacets.value  = null;
          searchFilters.value = {};
          blogPosts.value     = [];
        }
    
        function reset() {
          resetResults();
          searchQuery.value = '';
        }
    
        return {
          searchQuery,
          searchFacets,
          searchFilters,
          blogPosts,
          setSearchFacets,
          preserveSearchFacets,
          setSearchFilters,
          setBlogPosts,
          resetResults,
          reset,
        };
      })(pinia);
    }
    
    /** Access the browse store associated with the current storefront scope. */
    export function useOrionplaceBrowseStore() {
      return resolveOrionplaceDomain<ReturnType<typeof useOrionplaceBrowse>>('browse');
    }
    
  10. app/stores/orionplaceDomainAccess.ts

    Связывает функции доступа к небольшим хранилищам с текущим магазином или примером документации. Данные серверных запросов и примеров остаются раздельными.

    Исходный код
    typescript
    import type { Pinia } from 'pinia';
    
    export type OrionplaceDomain = 'selections' | 'catalog' | 'products' | 'browse';
    
    type DomainStores = Record<OrionplaceDomain, unknown>;
    
    const storesByScope = new WeakMap<Pinia, Map<string, DomainStores>>();
    const storesByFacade = new WeakMap<object, DomainStores>();
    let resolveFacade: (() => object) | undefined;
    
    export function registerOrionplaceDomainResolver(resolver: () => object) {
      resolveFacade = resolver;
    }
    
    export function registerOrionplaceDomainStores(
      pinia: Pinia,
      scope: string,
      stores: DomainStores,
    ) {
      const scopes = storesByScope.get(pinia) ?? new Map<string, DomainStores>();
      scopes.set(scope, stores);
      storesByScope.set(pinia, scopes);
    }
    
    export function attachOrionplaceDomainStores(
      facade: object,
      pinia: Pinia,
      scope: string,
    ) {
      const stores = storesByScope.get(pinia)?.get(scope);
      if (!stores) {
        throw new Error(`Missing Orionplace stores for ${scope}.`);
      }
    
      storesByFacade.set(facade, stores);
    }
    
    export function releaseOrionplaceDomainStores(
      facade: object,
      pinia: Pinia,
      scope: string,
    ) {
      storesByFacade.delete(facade);
      storesByScope.get(pinia)?.delete(scope);
    }
    
    export function resolveOrionplaceDomain<T>(domain: OrionplaceDomain): T {
      const facade = resolveFacade?.();
      const stores = facade && storesByFacade.get(facade);
      if (!stores) {
        throw new Error('The Orionplace store has not been initialized.');
      }
    
      return stores[domain] as T;
    }
    
  11. app/interfaces/orionplace/appBase/IAppLoad.ts

    Интерфейс IAppLoad описывает init, unauth, user и auth.

    Вложенные типы подключаются из соседних файлов; типы общих полей init находятся в app/interfaces/orionplace/appBase/IAppLoadCommon.ts.

  12. app/interfaces/orionplace/appBase/IAppLoadCommon.ts

    IAppLoadCommon перечисляет поля init и связывает их с вложенными интерфейсами: языки, валюты, единицы, баннеры, каталог и другие начальные данные.

    Исходный код
    IAppLoadCommon

    Общие настройки и начальное содержимое магазина.

    app/interfaces/orionplace/appBase/IAppLoadCommon.ts
    TypeScript
    import type { IAppLoadCommonLocale } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonLocale';
    import type { IAppLoadCommonCurrency } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonCurrency';
    import type { IAppLoadCommonUnit } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonUnit';
    import type { IAppLoadCommonLogos } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonLogos';
    import type { IGeoCoordinates } from '@/interfaces/orionplace/shared/IGeoCoordinates';
    import type { IAppLoadCommonSeo } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonSEO';
    import type { IAppLoadCommonSocial } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonSocial';
    import type { IAppLoadCommonVisualSettings } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonVisualSettings';
    import type { IAppLoadCommonEvents } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonEvents';
    import type { IAppLoadCommonBanner } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonBanner';
    import type { ICategory } from '@/interfaces/orionplace/category/ICategory';
    import type {
    	IAppLoadProduct,
    	IProductSeller,
    } from '@/interfaces/orionplace/products/IProduct';
    import type { IAppLoadCommonTariff } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonTariff';
    import type { IAppLoadCommonWalletValue } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonWalletValue';
    import type { IAppLoadCommonVariables } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonVariables';
    import type { IAppLoadCommonContact } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonContact';
    
    export interface IAppLoadCommon {
    	// Доступные языки, их валюта и единицы измерения по умолчанию.
    	locale: Record<string, IAppLoadCommonLocale>;
    	// Валюты, правила отображения, значения для пересчёта и доступность выбора.
    	currencies: Record<string, IAppLoadCommonCurrency>;
    	// Единицы веса и коэффициенты пересчёта.
    	weights: Record<string, IAppLoadCommonUnit>;
    	// Единицы длины и коэффициенты пересчёта.
    	lengths: Record<string, IAppLoadCommonUnit>;
    	// Выбран ли плагин поиска адреса по координатам. Подсказки по тексту адреса и
    	// названию города зависят от страны.
    	geo_location_enabled: boolean;
    	/** ISO codes of the shop's shipping countries, sorted. */
    	shipping_countries?: string[];
    	// Признак входа покупателя в текущей сессии.
    	is_authenticated: boolean;
    	// Баланс покупателя по валютам, если кошелёк доступен.
    	wallet?: Record<string, IAppLoadCommonWalletValue>;
    	// Размеры логотипа и favicon.
    	logos: IAppLoadCommonLogos;
    	// Публичные ключи внешних интеграций, включая API-ключ Яндекс Карт.
    	public_keys: Record<string, string>;
    	// Координаты центра карты по умолчанию, заданные для магазина.
    	default_map: IGeoCoordinates;
    	// Метаданные главной страницы магазина
    	seo?: IAppLoadCommonSeo[];
    	// Ссылки на социальные сети.
    	sm?: IAppLoadCommonSocial[];
    	// Начальные счётчики корзины и избранного, настройки функций.
    	variables?: IAppLoadCommonVariables;
    	// Название компании, контакты и юридические реквизиты.
    	contact?: IAppLoadCommonContact;
    	// Настройки отображения меню, списков и элементов магазина.
    	visual_settings?: IAppLoadCommonVisualSettings;
    	// События и сообщения для отображения в магазине.
    	events?: IAppLoadCommonEvents[];
    	// Наборы баннеров для главной страницы.
    	main_banner?: Record<string, IAppLoadCommonBanner[]>;
    	// Дерево категорий для меню магазина.
    	catalog?: ICategory[];
    	// Начальный список товаров магазина. Каждый товар содержит `seller_id`; полные
    	// данные продавца находятся в `sellers`.
    	products?: IAppLoadProduct[];
    	// Персональные товары по предыдущим поискам и категориям. Каждый товар
    	// содержит `seller_id`; полные данные продавца находятся в `sellers`. Для
    	// гостя массив пуст.
    	featured?: IAppLoadProduct[];
    	// Данные продавцов по ID. Стор товаров использует этот объект, чтобы передать
    	// компонентам `IProduct.seller`.
    	sellers: Record<string, IProductSeller>;
    	// Локализованные подсказки для строки поиска.
    	search_hints?: Record<string, string>[];
    	// Тарифы, которые продаёт этот магазин, с ценами и опциями — как в
    	// `subscriptions_guest_search`. Магазин без списка тарифов получает пустой
    	// список `data`.
    	tariffs?: IAppLoadCommonTariff;
    }
    
  13. app/app.vue

    Корневой компонент применяет выбранную тему через OrionplaceThemeProvider и показывает страницу текущего маршрута через NuxtPage внутри NuxtLayout.

    NuxtLoadingIndicator рисует у верхнего края окна тонкую полосу цветом текста шапки, поэтому она видна поверх тёмной полосы шапки, а после ошибки — цветом ошибки. Она идёт при переходах между страницами и пока выполняется запрос магазина: сохранение, обновление, загрузка и вход. Кнопки остаются заблокированными, а текст состояния получают программы экранного доступа; отдельной строки статуса на странице нет.

    Так страницы маршрутов, раскладки и глобальный посредник маршрута связываются с жизненным циклом приложения.

    Исходный код
    vue
    <script setup lang="ts">
      import { watch } from 'vue';
      import { useOrionplaceStore } from '@/stores/orionplace';
      import { NuxtLayout, NuxtLoadingIndicator, NuxtPage } from '#components';
      import { showError, useHead, useRoute } from '#imports';
      import OrionplaceThemeProvider from '@/components/orionplace/theme/OrionplaceThemeProvider.vue';
      import { useOrionplaceTheme } from '@/composables/useOrionplaceTheme';
      import { storefrontPageError } from '@/composables/useOrionplaceMissingPage';
      import { storefrontIconLinks } from '@/utils/orionplace/pageSeo';
    
      const store    = useOrionplaceStore();
      const route    = useRoute();
      const { mode } = useOrionplaceTheme();
    
      // The store's icon from the admin panel (logos.ico) for browser tabs and
      // search results; a page without it keeps the browser default.
      useHead(() => ({
        htmlAttrs: { lang: store.localeCode },
        link: storefrontIconLinks(store.init?.logos?.ico),
      }));
    
      // The middleware opens the 503 error page during navigation. A refresh that
      // fails while a page is open, for example after sign-in, opens it too.
      watch(
        () => store.status,
        (status) => {
          const error = route.matched.length
            ? storefrontPageError(status, false, '')
            : undefined;
    
          if (error) {
            void showError(error);
          }
        },
      );
    </script>
    
    <template>
      <OrionplaceThemeProvider
        :mode="mode"
        class="min-h-screen"
      >
        <NuxtLoadingIndicator
          :height="2"
          color="var(--orionplace-inverse-text)"
          error-color="var(--orionplace-danger)"
        />
        <NuxtLayout>
          <NuxtPage />
        </NuxtLayout>
      </OrionplaceThemeProvider>
    </template>
    
  14. app/pages/index.vue

    Начальная главная страница читает состояние загрузки из стора. Первый запрос выполняется в middleware до открытия страницы.

    Пока загрузка приложения не завершена, страница показывает сообщение о загрузке. При сбое загрузки приложения страница не открывается: middleware показывает страницу ошибки магазина.

    После успешной загрузки страница выводит StorefrontHome: баннеры, категории, события и подборки товаров из загрузки приложения, а также новинки, полученные через products_search. Если новинки не загрузились, страница показывает сообщение со своей кнопкой повторной попытки. Метаданные страницы берутся из store.init.seo. Кнопка «Ещё» в разделе «Рекомендуем» открывает страницу /featured.

    Исходный код
    vue
    <script setup lang="ts">
      import { useOrionplacePageSeo } from '@/composables/useOrionplacePageSeo';
      import { computed, defineAsyncComponent, onMounted, ref } from 'vue';
      import { useHead, navigateTo, useRoute } from '#imports';
      import { useOrionplaceStore } from '@/stores/orionplace';
      import { useOrionplaceHomeProducts } from '@/composables/useOrionplaceHomeProducts';
      import { useOrionplaceStorefront } from '@/composables/useOrionplaceStorefront';
      import { useOrionplaceSignIn } from '@/composables/useOrionplaceSignIn';
      import { eventReturnPath } from '@/utils/orionplace/signInReturn';
      import StorefrontHome from '@/components/orionplace/storefront/StorefrontHome.vue';
      import { homeLabels } from '@/utils/orionplace/homeLabels';
      import { headerLabels } from '@/utils/orionplace/headerLabels';
      import { getStorefrontSeo } from '@/utils/orionplace/seo';
      import type { IAppLoadCommonBanner } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonBanner';
      import type { IAppLoadCommonEvents } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonEvents';
      const EventEnrollmentDialog = defineAsyncComponent(
        () => import('@/components/orionplace/events/EventEnrollmentDialog.vue'),
      );
      const enrollment   = ref<IAppLoadCommonEvents>();
      function openEnrollment(event: IAppLoadCommonEvents) {
        if (event.promo) {
          enrollment.value = event;
        }
      }
    
      // A guest's click opens sign-in at once; it returns with `?event=<id>`.
      function selectEvent(event: IAppLoadCommonEvents) {
        if (event.promo && !store.isAuthenticated) {
          signIn.requireSignIn(eventReturnPath(route.fullPath, event.id));
          return;
        }
    
        openEnrollment(event);
      }
    
      // The return address `?event=<id>` opens the dialog again for that event,
      // without navigating anywhere. Closing it removes the parameter.
      function reopenEnrollment() {
        const id    = Number(route.query.event);
        const event = store.init?.events?.find((item) => item.id === id);
    
        if (event) {
          openEnrollment(event);
        }
      }
    
      function closeEnrollment() {
        enrollment.value = undefined;
    
        if (route.query.event === undefined) {
          return;
        }
    
        const query = { ...route.query };
        delete query.event;
    
        return navigateTo({ path: route.path, query }, { replace: true });
      }
    
      function openBannerCategory(banner: IAppLoadCommonBanner) {
        if (banner.category != null) {
          return navigateTo({
            path: `/category/${banner.category}`,
            query: { locale: store.localeCode },
          });
        }
      }
    
      const store        = useOrionplaceStore();
      const route        = useRoute();
      const signIn       = useOrionplaceSignIn();
      const {
        actions,
        variants,
        selection: headerSelection,
      }                  = useOrionplaceStorefront();
      const homeProducts = useOrionplaceHomeProducts();
      const labels       = computed(() => headerLabels(store.localeCode));
      const homeCopy     = computed(() => homeLabels(store.localeCode));
      const metadata     = computed(() =>
        getStorefrontSeo(store.init?.seo ?? [], store.localeCode),
      );
      function namedMeta(name: string) {
        return metadata.value.meta.findLast(
          (tag) => 'name' in tag && tag.name === name,
        )?.content;
      }
      onMounted(reopenEnrollment);
      useOrionplacePageSeo('home', () => store.localeCode, {
        title: () =>
          metadata.value.title || store.init?.contact?.company_name || 'Orionplace',
        description: () => namedMeta('description'),
        keywords: () => namedMeta('keywords'),
      });
      useHead(() => ({
        meta: metadata.value.meta.filter(
          (tag) =>
            !('name' in tag && ['description', 'keywords'].includes(tag.name)),
        ),
      }));
    </script>
    
    <template>
      <main>
        <h1 class="sr-only">
          {{ store.init?.contact?.company_name || 'Orionplace' }}
        </h1>
        <p
          v-if="homeProducts.error.value"
          role="status"
          class="
            flex flex-wrap items-center gap-3
            mx-auto max-w-[1440px] px-4 sm:px-6 lg:px-8
            text-sm text-orionplace-muted
          "
        >
          {{ homeCopy.newFailed }}
          <button
            type="button"
            class="
              inline-flex h-11 items-center justify-center
              px-6
              text-xs font-medium uppercase tracking-[0.08em]
              rounded-none border border-solid border-orionplace-text
              bg-transparent text-orionplace-text
              cursor-pointer outline-none
              hover:bg-orionplace-hover focus-visible:bg-orionplace-hover
            "
            @click="homeProducts.load()"
          >
            {{ homeCopy.retry }}
          </button>
        </p>
        <StorefrontHome
          v-if="store.status === 'success'"
          :product-actions-disabled="
            actions.pending.value || actions.needsRefresh.value
          "
          @add="actions.run('add', $event)"
          @remove="actions.run('remove', $event)"
          @toggle-favorite="actions.run('toggleFavorite', $event)"
          @subscribe="actions.run('subscribe', $event)"
          @unsubscribe="actions.run('unsubscribe', $event)"
          @preorder="actions.openPreorder"
          @select-variant="variants.select"
          @request-secondary-variants="variants.requestSecondary"
          @select-category="
            navigateTo({
              path: `/category/${$event.id}`,
              query: { locale: store.localeCode },
            })
          "
          @select-product="
            navigateTo({
              path: `/product/${$event.id}`,
              query: { locale: store.localeCode },
            })
          "
          @select-seller="
            navigateTo({
              path: '/sellers',
              query: { seller: $event.id, locale: store.localeCode },
            })
          "
          @select-banner="openBannerCategory"
          @select-featured="
            navigateTo({ path: '/featured', query: { locale: store.localeCode } })
          "
          @select-event="selectEvent"
        />
        <div
          v-else
          class="
            grid justify-items-start gap-4
            mx-auto max-w-[1440px] px-4 py-12 sm:px-6 lg:px-8
            text-sm
          "
          :aria-busy="store.status === 'pending'"
        >
          <p
            class="m-0"
            role="status"
          >
            {{ homeCopy.loading }}
          </p>
        </div>
        <EventEnrollmentDialog
          v-if="enrollment"
          :key="enrollment.id"
          :event="enrollment"
          @close="closeEnrollment"
        />
      </main>
    </template>
    

При SSR у каждого запроса свой экземпляр Pinia.

Браузер получает готовое состояние при гидратации. После входа или выхода вызовите reset и refresh, чтобы заменить профиль и список доступных методов.