Инициализация приложения
Загрузка приложения · Nuxt
Загрузка приложения — первый запрос магазина к персональному бэкенду. Он возвращает настройки и начальный контент для интерфейса, профиль вошедшего покупателя и методы API, доступные текущей сессии.
/api/apploadЧто приходит в ответе
page_data — данные приложения внутри ответа API. У четырёх основных разделов разные задачи:
Начальные данные магазина, профиль покупателя и доступные эндпоинты API.
app/interfaces/orionplace/appBase/IAppLoad.tsimport type { IAppLoadCommon } from '@/interfaces/orionplace/appBase/IAppLoadCommon';
import type { IApploadUserProfile } from '@/interfaces/orionplace/appBase/IApploadUserProfile';
import type { IAppLoadPathsRecord } from '@/interfaces/orionplace/appBase/appLoadCommon/IAppLoadCommonPath';
export interface IAppLoad {
// Настройки и начальные данные магазина.
init: IAppLoadCommon;
// Публичные методы API, сгруппированные в paths.
unauth: IAppLoadPathsRecord;
// Профиль текущего покупателя. Отсутствует у гостя.
user?: IApploadUserProfile;
// Методы авторизованной сессии. Отсутствуют у гостя; права проверяет бэкенд.
auth?: IAppLoadPathsRecord;
// Показывает, включены ли функции панели продавца для этого магазина.
seller_panel_enabled?: boolean;
// Служебный ключ предпросмотра товаров для вошедшего аккаунта с данными
// продавца.
// Не публикуйте значение; покупательскому интерфейсу этот ключ не нужен.
view_key?: string;
// Время подготовки загрузки приложения на бэкенде, в секундах; только при
// debug
// проекта. Не включает сериализацию ответа и передачу по сети.
spent_total?: number;
// Время загрузки init на бэкенде, в секундах. Только при debug проекта.
spent_init?: number;
// Время загрузки публичных путей API, в секундах. Только при debug проекта.
unauth_spent?: number;
// Время подготовки профиля и auth, в секундах. Только при debug проекта; 0 у
// гостя.
auth_spent?: number;
}
Для чего использовать поля init
Каждое поле ведёт к описанию данных, их использования и связанных компонентов.
page_data.init/api/apploadЯзык, валюты и единицы
Языки, валюты и единицы измерения с названиями, форматами отображения и значениями по умолчанию для каждой локали.
localestore.localesMainLanguageSwitcherвыбирает язык; стор заменяет текущую валюту и единицы значениями по умолчанию выбранного языка.currenciesstore.currenciesДоступные валюты для выбора в
MainCurrencySwitcher.weightsstore.weightsЕдиницы веса для выбора в
UnitSwitcher.lengthsstore.lengthsЕдиницы длины для выбора в
UnitSwitcher.shipping_countriesstore.init?.shipping_countriesСтраны доставки магазина; страницы товаров указывают их в условиях возврата.
Каталог и поиск
Категории, начальные коллекции товаров, поисковые подсказки и тарифы подписки.
catalogstore.catalogДерево категорий для
CatalogHeaderиCategoryGroup.productsstore.productsНачальная коллекция для
ProductGroup,ProductCardи компонентов товара, включаяHoverImageViewer.featuredstore.featuredПерсональные рекомендации по предыдущим поискам и категориям. Компоненты товаров читают их с
collection="featured".sellersstore.init?.sellersДанные продавцов по ID. Каждый товар из загрузки приложения указывает продавца в
seller_id; стор товаров находит эту запись, когда компонент читает товар.search_hintsstore.init?.search_hintsПодсказки на доступных языках, которые показывает
MainSearch.tariffsstore.tariffsКоллекция тарифов, из которой
TariffCardsформирует отдельные компонентыTariffCard.
Оформление и метаданные
Логотипы, значок сайта, настройки отображения магазина и SEO-метаданные.
logosstore.init?.logosBrandLogosпоказывает логотип выбранного размера или значок.visual_settingsstore.init?.visual_settingsИзображения в меню категорий, количество и порядок категорий главной, остальные настройки отображения магазина.
seostore.init?.seogetStorefrontSeo()подготавливает метаданные дляapp/pages/index.vue: заголовок, описание и ключевые слова применяются черезuseSeoMeta(), дополнительные теги — черезuseHead().
Контент и контакты
Баннеры, события магазина, контакты компании и ссылки на социальные сети.
main_bannerstore.init?.main_bannerИзображения баннеров, которые
MainBannerпоказывает на текущем языке магазина.eventsstore.init?.eventsИзображения событий, которые
EventsBlockотбирает по языку и значениюis_layout.contactstore.init?.contactКонтакты и регистрационные данные компании, которые показывает
AboutPage.smstore.init?.smСоциальные ссылки и подписи на доступных языках для
SocialLinks.
Покупатель и счётчики
Признак авторизации, баланс кошелька и начальные счётчики корзины и избранного. Полная корзина и данные личного кабинета доступны через отдельные методы API.
is_authenticatedstore.isAuthenticatedstore.isAuthenticatedуправляет доступными авторизованными методами и переключаетStorefrontAccountмежду входом и меню профиля с кошельком.walletstore.init?.walletWalletBalanceпоказывает баланс в выбранной валюте, включая компактный режим вStorefrontAccount.variablesstore.init?.variablesНачальные счётчики кнопок корзины и избранного, разрешение предзаказа и статусы нового контента.
Карты и интеграции
Центр карты, публичный ключ карты и доступность поиска адреса по координатам.
default_mapstore.init?.default_mapКоординаты центра карты по умолчанию.
geo_location_enabledstore.init?.geo_location_enabledОпределяет доступность поиска адреса по координатам. Текстовые подсказки зависят от выбранной страны.
public_keysstore.init?.public_keysmap_keyзагружает Яндекс Карты для карты доставки в корзине.
Состав полей зависит от настроек магазина и текущей сессии. Поле может отсутствовать, а список — быть пустым. Начальная подборка товаров не заменяет полный каталог с пагинацией.
Как использовать данные после загрузки
app/middleware/00.orionplace.global.tsГлобальный middleware запускается перед каждой страницей и ждёт завершения инициализации.
Если данные уже загружены, переход между страницами не вызывает повторный запрос. При сбое стор сохраняет состояние ошибки, а middleware открывает страницу ошибки магазина с HTTP 503 и кнопкой «Повторить», которая перезагружает страницу. Неизвестный адрес по-прежнему открывает страницу 404. Сам middleware не выводит уведомлений.
Исходный код
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); } });app/composables/useOrionplaceAppLoad.tsКомпозабл вызывает общий API-клиент, проверяет ответ инициализации и сохраняет результат в хранилище. Транспорт клиента передаёт браузеру cookies, которые бэкенд устанавливает при серверной отрисовке.
Предоставляет действия initialize, refresh и reset. Само обращение к композаблу не запускает запрос.
Исходный код
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(), }; }app/composables/useOrionplaceApi.tsСоздаёт общие API-клиенты приложения Nuxt: один для сеанса в браузере и по одному для каждой серверной отрисовки. Компоненты отправляют запросы к методам из discovery в браузере. Страницы товара, категории, поиска, рекомендаций, журнала и статьи также загружают свои первые данные при серверной отрисовке через
useOrionplaceServerApi()с cookies посетителя, поэтому эти данные уже есть в HTML страницы. Браузер использует этот результат и не повторяет запрос.Исходный код
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; }app/utils/orionplace/api.tsВыполняет загрузку приложения и находит браузерные методы по
url_nameв актуальном discovery. Читает текущий CSRF-токен перед запросом и объединяет одновременное обновление токена. При отклонении CSRF обновляет токен для следующего действия и возвращает ошибку. Покупатель повторяет действие явно; неудачные запросы не выполняются автоматически.Исходный код
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; } }app/stores/orionplace.tsuseOrionplaceStore()- Доступ компонентов к согласованным хранилищам, показанным ниже.
store.result- Результат загрузки: тело ответа, HTTP-статус и время получения.
store.result?.response- Тело ответа API целиком.
store.data- Данные приложения из ответа API.
store.init- Начальные данные магазина. Ссылки выше ведут к каждому полю, его компоненту и исходному коду.
store.profile- Профиль покупателя, если он присутствует в ответе.
useOrionplaceStore()содержит ответ загрузки приложения, состояние загрузки, профиль, кошелёк и доступные методы API. Компоненты читают выбор языка и валюты, каталог, товары и данные поиска через соответствующие хранилища. Этот файл согласует обновление, сброс и завершение сеанса между небольшими хранилищами.Исходный код
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(), );app/stores/orionplaceSelections.tsСохраняет выбранные язык, валюту и единицы веса и длины. Смена языка выбирает его значения по умолчанию; повторная загрузка приложения сохраняет доступные варианты.
Исходный код
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', ); }app/stores/orionplaceCatalog.tsЧитает начальные списки категорий, товаров и тарифов из загрузки приложения. Успешные запросы каталога и тарифов заменяют соответствующие списки до следующей загрузки.
Исходный код
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', ); }app/stores/orionplaceProducts.tsСохраняет изменения товаров, дополнительные варианты, группы главной страницы и счётчики корзины и избранного. Подтверждённые действия меняют эти значения; повторная загрузка приложения очищает их.
Исходный код
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', ); }app/stores/orionplaceBrowse.tsСохраняет поисковый запрос, выбранные фильтры, варианты фильтров и статьи журнала. Запросы поиска и журнала заполняют эти значения; повторная загрузка приложения очищает результаты и сохраняет текст запроса.
Исходный код
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'); }app/stores/orionplaceDomainAccess.tsСвязывает функции доступа к небольшим хранилищам с текущим магазином или примером документации. Данные серверных запросов и примеров остаются раздельными.
Исходный код
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; }app/interfaces/orionplace/appBase/IAppLoad.tsИнтерфейс
IAppLoadописывает init, unauth, user и auth.Вложенные типы подключаются из соседних файлов; типы общих полей init находятся в app/interfaces/orionplace/appBase/IAppLoadCommon.ts.
app/interfaces/orionplace/appBase/IAppLoadCommon.tsIAppLoadCommonперечисляет поля init и связывает их с вложенными интерфейсами: языки, валюты, единицы, баннеры, каталог и другие начальные данные.Исходный код
Общие настройки и начальное содержимое магазина.
app/interfaces/orionplace/appBase/IAppLoadCommon.tsimport 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; }app/app.vueКорневой компонент применяет выбранную тему через
OrionplaceThemeProviderи показывает страницу текущего маршрута черезNuxtPageвнутриNuxtLayout.NuxtLoadingIndicatorрисует у верхнего края окна тонкую полосу цветом текста шапки, поэтому она видна поверх тёмной полосы шапки, а после ошибки — цветом ошибки. Она идёт при переходах между страницами и пока выполняется запрос магазина: сохранение, обновление, загрузка и вход. Кнопки остаются заблокированными, а текст состояния получают программы экранного доступа; отдельной строки статуса на странице нет.Так страницы маршрутов, раскладки и глобальный посредник маршрута связываются с жизненным циклом приложения.
Исходный код
<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>app/pages/index.vueНачальная главная страница читает состояние загрузки из стора. Первый запрос выполняется в middleware до открытия страницы.
Пока загрузка приложения не завершена, страница показывает сообщение о загрузке. При сбое загрузки приложения страница не открывается: middleware показывает страницу ошибки магазина.
После успешной загрузки страница выводит
StorefrontHome: баннеры, категории, события и подборки товаров из загрузки приложения, а также новинки, полученные черезproducts_search. Если новинки не загрузились, страница показывает сообщение со своей кнопкой повторной попытки. Метаданные страницы берутся изstore.init.seo. Кнопка «Ещё» в разделе «Рекомендуем» открывает страницу/featured.Исходный код
<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, чтобы заменить профиль и список доступных методов.