Инициализация приложения
Загрузка приложения · Svelte
Загрузка приложения — первый запрос магазина к персональному бэкенду. Он возвращает настройки и начальный контент для интерфейса, профиль вошедшего покупателя и методы API, доступные текущей сессии.
/api/apploadЧто приходит в ответе
page_data — данные приложения внутри ответа API. У четырёх основных разделов разные задачи:
Начальные данные магазина, профиль покупателя и доступные эндпоинты API.
src/lib/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загружает Яндекс Карты для карты доставки в корзине.
Состав полей зависит от настроек магазина и текущей сессии. Поле может отсутствовать, а список — быть пустым. Начальная подборка товаров не заменяет полный каталог с пагинацией.
Как данные попадают в компоненты
useOrionplaceStore()- Store текущего запроса из контекста приложения; вызывается при инициализации компонента.
store.result- Весь результат запроса: HTTP-статус и ответ сервера.
store.data- Объект
page_dataиз ответа. store.init- Начальные данные
page_data.init; пока загрузки не было,null. store.profile- Профиль покупателя после входа, иначе
null. store.status · store.isReady · store.failure- Состояние загрузки: идёт, готово или ошибка с её видом и полями.
src/hooks.server.tsЗапрещает кэшировать персональные страницы и отдаёт страницу ошибки 503, если Application Load не удался.
Исходный код
import type { Handle } from '@sveltejs/kit'; import { env } from '$env/dynamic/private'; import { marketplaceNoindexPath } from '@orionplace-runtime/marketplace.mjs'; function escapeAttribute(value: string) { return value .replaceAll('&', '&') .replaceAll('"', '"') .replaceAll('<', '<') .replaceAll('>', '>'); } /** * Personalized pages are never shared between visitors or storefront domains. * The API gateway (/api, /ps3 media), robots.txt and the sitemap set their own * headers, so those requests are left alone: public blog images keep the * backend's browser cache rule. */ const ownHeaders = ['/robots.txt', '/sitemap.xml']; const gatewayPaths = ['/api/', '/ps3/']; export const handle: Handle = async ({ event, resolve }) => { const response = await resolve(event, { transformPageChunk: ({ html }) => html.replace( '%orionplace.locale%', escapeAttribute(event.locals.orionplaceLocale ?? 'en'), ), }); if ( !gatewayPaths.some((path) => event.url.pathname.startsWith(path)) && !ownHeaders.includes(event.url.pathname) ) { response.headers.set('Cache-Control', 'private, no-store'); response.headers.set('Vary', 'Host, X-Forwarded-Host, X-Front-Domain'); } // Pages under ORIONPLACE_NOINDEX_PATHS stay out of search engines. if ( marketplaceNoindexPath(event.url.pathname, env.ORIONPLACE_NOINDEX_PATHS) ) { response.headers.set('X-Robots-Tag', 'noindex, follow'); } // A page rendered while Application Load failed is the 503 error page. if ( event.locals.orionplaceUnavailable && !event.isDataRequest && response.status === 200 ) { return new Response(response.body, { status: 503, statusText: 'Service Unavailable', headers: response.headers, }); } return response; };src/routes/api/[...orionplace]/+server.tsШлюз: каждый запрос браузера к
/api/...уходит отсюда в ваш API маркетплейса с доменом витрины и cookie сессии.Исходный код
// Every browser request to /api/... reaches your personal marketplace API here. import { env } from '$env/dynamic/private'; import { forwardMarketplace } from '@orionplace-runtime/marketplace.mjs'; import type { RequestHandler } from './$types'; const handler: RequestHandler = ({ request }) => forwardMarketplace(request, { mimicFrontDomain: env.MIMIC_FRONT_DOMAIN ?? '', }); export const GET = handler; export const HEAD = handler; export const OPTIONS = handler; export const POST = handler; export const PUT = handler; export const PATCH = handler; export const DELETE = handler;src/lib/orionplace/appLoadServer.tsloadOrionplaceAppLoad()вызывает тот же шлюз на сервере и передаёт браузеру cookie сессии из ответа. Ошибка не бросается, а попадает в snapshot.Исходный код
import { env } from '$env/dynamic/private'; import { forwardMarketplace } from '@orionplace-runtime/marketplace.mjs'; import { appLoadRejectedLocale, appLoadResult, inspectAppLoad, } from '@/lib/orionplace/appLoadResult'; import type { OrionplaceSnapshot } from '@/lib/orionplace/appLoadData'; import type { AppLoadRequest } from '@/lib/interfaces/orionplace/transport'; import type { Cookies, RequestEvent } from '@sveltejs/kit'; /** Headers a server render forwards to the marketplace gateway. */ const FORWARDED = [ 'cookie', 'x-front-domain', 'x-forwarded-host', 'x-forwarded-proto', 'host', 'accept-language', 'user-agent', 'x-init-ip', 'x-real-ip', 'x-forwarded-for', 'x-tz', ]; function buildRequest( event: RequestEvent, signal: AbortSignal, withLocale = true, ): Request { const url = new URL('/api/appload', event.url.origin); const locale = withLocale ? event.url.searchParams.get('locale') : null; if (locale) { url.searchParams.set('lang', locale); } const forward = new Headers(); for (const name of FORWARDED) { const value = event.request.headers.get(name); if (value) { forward.set(name, value); } } if (!forward.has('host')) { forward.set('host', event.url.host); } // The visitor's current cookies, including ones set earlier in this render. const cookies = event.cookies.getAll(); if (cookies.length) { forward.set( 'cookie', cookies.map((item) => `${item.name}=${item.value}`).join('; '), ); } forward.set('accept', 'application/json'); forward.set('content-type', 'application/json'); // Same-origin server request: the gateway rejects cross-site mutations by Origin. forward.set('origin', url.origin); return new Request(url, { method: 'POST', headers: forward, body: JSON.stringify({ detalize_mode: 'light', ignore_cache: false, } satisfies AppLoadRequest), signal, }); } /** * Relays each backend cookie separately, preserving its exact signed value. * The gateway already removed the backend `Domain`, binding cookies to this storefront. */ export function relayCookies(cookies: Cookies, received: string[]): void { for (const raw of received) { const [pair, ...attributes] = raw.split(';'); const separator = pair.indexOf('='); if (separator < 1) { continue; } const name = pair.slice(0, separator).trim(); const value = pair.slice(separator + 1).trim(); const options: Parameters<Cookies['set']>[2] = { path: '/', httpOnly: false, secure: false, encode: (raw) => raw, }; for (const attribute of attributes) { const [key, ...rest] = attribute.split('='); const flag = key.trim().toLowerCase(); const detail = rest.join('=').trim(); if (flag === 'path') { options.path = detail || '/'; } else if (flag === 'max-age' && detail) { options.maxAge = Number(detail); } else if (flag === 'expires' && detail) { options.expires = new Date(detail); } else if (flag === 'samesite' && detail) { options.sameSite = detail.toLowerCase() as 'lax' | 'strict' | 'none'; } else if (flag === 'httponly') { options.httpOnly = true; } else if (flag === 'secure') { options.secure = true; } } cookies.set(name, value, options); } } export interface ServerAppLoad extends OrionplaceSnapshot { /** Raw backend `Set-Cookie` headers, already relayed to the visitor. */ cookies: string[]; } /** * Calls the same gateway the browser uses, in process, with this request's domain * and session. It never throws: the failure travels to the interface in the snapshot. */ export async function loadOrionplaceAppLoad( event: RequestEvent, signal?: AbortSignal, withLocale = true, ): Promise<ServerAppLoad> { const timeout = AbortSignal.timeout(35_000); const combined = signal ? AbortSignal.any([signal, timeout]) : timeout; let response: Response; try { response = await forwardMarketplace( buildRequest(event, combined, withLocale), { mimicFrontDomain: env.MIMIC_FRONT_DOMAIN ?? '', }, ); } catch { return { result: null, failure: { kind: 'network', httpStatus: null, fields: [] }, cookies: [], }; } // AppLoad establishes the guest or customer session during a server render. const cookies = response.headers.getSetCookie(); relayCookies(event.cookies, cookies); let body: unknown = null; try { body = await response.json(); } catch { body = null; } const failure = inspectAppLoad(body, response.status); // An address with a language the storefront does not have (`?locale=xx`) // opens in the storefront's default language instead of the 503 page. if ( failure && withLocale && event.url.searchParams.get('locale') && appLoadRejectedLocale(failure) ) { return loadOrionplaceAppLoad(event, signal, false); } if (failure) { return { result: null, failure, cookies }; } return { result: appLoadResult(body, response.status), failure: null, cookies, }; }src/routes/+layout.server.tsКорневой
loadвыполняет Application Load до рендера страницы и возвращает snapshot в данных layout.Исходный код
import { loadOrionplaceAppLoad } from '@/lib/orionplace/appLoadServer'; import { OrionplaceStore } from '@/lib/orionplace/store.svelte'; import { storefrontErrorLocale } from '@/lib/utils/orionplace/errorPageLabels'; import type { LayoutServerLoad } from './$types'; // Initialization depends on the request domain and session, so pages are dynamic. export const prerender = false; /** Bootstrap precedes page rendering. A failure opens the 503 error page. */ export const load: LayoutServerLoad = async (event) => { // The address is read untracked: a link with another `?locale` switches the // language in StorefrontLayout and does not run Application Load again, // which would drop the products the open page has just loaded. const { result, failure } = await event.untrack(() => loadOrionplaceAppLoad(event), ); const store = new OrionplaceStore({ result, failure }); const locale = event.untrack(() => event.url.searchParams.get('locale'), ); if (locale) { store.setLocale(locale); } if (!store.isReady) { store.localeCode = storefrontErrorLocale(locale, store.localeCode); } event.locals.orionplaceLocale = store.localeCode || 'en'; // The root layout shows the 503 error page; hooks.server.ts sets the status. event.locals.orionplaceUnavailable = store.status === 'error' && event.route.id !== null && !event.route.id.startsWith('/__components/'); return { orionplace: { result, failure }, orionplaceLocale: store.localeCode, }; };src/routes/+layout.svelteКорневой layout кладёт snapshot в store (
hydrate) и показывает страницу ошибки, если загрузка не удалась.Исходный код
<script lang="ts"> import { untrack } from 'svelte'; import { computed } from '@/lib/orionplace/reactivity.svelte'; import { page } from '$app/state'; import { replaceState } from '$app/navigation'; import { localesMatch } from '@/lib/utils/orionplace/presentation'; import { APP_ORIGIN, handleAppBackButton, saveAppLocale, } from '@/lib/utils/orionplace/nativeApp'; import { setOrionplaceContext } from '@/lib/orionplace/store.svelte'; import { followAppSession } from '@/lib/orionplace/appSession'; import { providePageHead } from '@/lib/orionplace/navigation.svelte'; import { storefrontErrorKind, storefrontErrorLocale, } from '@/lib/utils/orionplace/errorPageLabels'; import { provideOrionplaceProgress } from '@/lib/composables/useOrionplaceProgress.svelte'; import { useOrionplaceTheme } from '@/lib/composables/useOrionplaceTheme.svelte'; import OrionplaceThemeProvider from '@/lib/components/orionplace/theme/OrionplaceThemeProvider.svelte'; import StorefrontLayout from '@/lib/layouts/StorefrontLayout.svelte'; import StorefrontPageState from '@/lib/layouts/StorefrontPageState.svelte'; import StorefrontPageHead from '@/lib/layouts/StorefrontPageHead.svelte'; import StorefrontUnavailable from '@/lib/layouts/StorefrontUnavailable.svelte'; import '@/lib/assets/css/orionplace.css'; import type { Snippet } from 'svelte'; import type { LayoutData } from './$types'; let { data, children }: { data: LayoutData; children: Snippet } = $props(); const marketplace = setOrionplaceContext(untrack(() => data.orionplace)); // App page loads see sign-in and logout (see lib/orionplace/appSession.ts). if (APP_ORIGIN) { followAppSession(() => marketplace.result ? { result: marketplace.result, failure: null } : null, ); } marketplace.setLocale(untrack(() => data.orionplaceLocale)); if (!marketplace.isReady) { marketplace.localeCode = untrack(() => data.orionplaceLocale); } const head = providePageHead(); const progress = provideOrionplaceProgress(); const { mode } = useOrionplaceTheme(); const metadata = computed(() => head.value?.() ?? {}); const preview = computed( () => page.route.id?.startsWith('/__components/') ?? false, ); const standaloneError = computed( () => page.status >= 400 && (!marketplace.isReady || storefrontErrorKind(page.status) === 'error'), ); // A failed Application Load replaces every existing page with the 503 // error page; an unknown address keeps its 404 page. const unavailable = computed( () => marketplace.status === 'error' && page.route.id !== null, ); $effect(() => { const snapshot = data.orionplace; untrack(() => marketplace.hydrate(snapshot)); }); $effect(() => { if (!marketplace.isReady) { marketplace.localeCode = storefrontErrorLocale( page.url.searchParams.get('locale'), marketplace.localeCode, ); } document.documentElement.lang = marketplace.localeCode; }); // The Android app's back button: close a dialog, go back, or leave the app. $effect(() => { if (APP_ORIGIN) { handleAppBackButton(); } }); // A language the customer picks goes into the address, so a reload and a // shared link keep it; the Android app also keeps it on the device. The // first language set while the storefront loads leaves the address as it is. let shownLocale = untrack(() => marketplace.localeCode); $effect(() => { const code = marketplace.localeCode; const previous = shownLocale; shownLocale = code; untrack(() => { if (APP_ORIGIN && previous && code && previous !== code) { saveAppLocale(code); } // `replaceState` changes the address without updating `page.url`. const url = new URL(location.href); const requested = url.searchParams.get('locale'); if ( !previous || !code || previous === code || !marketplace.isReady || (requested && localesMatch(requested, code)) ) { return; } url.searchParams.set('locale', code); replaceState(url, page.state); }); }); </script> <OrionplaceThemeProvider mode={mode.value} class="min-h-screen" > {#if progress.value > 0} <div class="storefront-progress" role="progressbar" aria-label="Loading" ></div> {/if} {#if preview.value || standaloneError.value} {@render children()} {:else if unavailable.value} <StorefrontUnavailable /> {:else} <StorefrontLayout> {#key page.url.pathname + page.url.search} <StorefrontPageState state={page.data.orionplacePageState}> {@render children()} </StorefrontPageState> {/key} </StorefrontLayout> {/if} </OrionplaceThemeProvider> <!-- Render after the page registers its metadata during server rendering. --> <StorefrontPageHead metadata={metadata.value} icon={marketplace.init?.logos?.ico} /> <style> .storefront-progress { position: fixed; top: 0; left: 0; z-index: 100; width: 100%; height: 2px; background: var(--orionplace-text); animation: storefront-pending 1s ease-in-out infinite; transform-origin: left; } @keyframes storefront-pending { from { transform: scaleX(0.1); } to { transform: scaleX(0.95); } } </style>src/lib/orionplace/store.svelte.tsStore одного запроса в контексте приложения, поэтому данные разных посетителей на сервере не смешиваются.
Исходный код
import { getContext, setContext } from 'svelte'; import { createOrionplaceStore } from '@/lib/stores/orionplace.svelte'; import { createScope } from '@/lib/orionplace/scope.svelte'; import { snapshotStatus } from '@/lib/orionplace/appLoadData'; import { requestBrowserAppLoad } from '@/lib/orionplace/appLoadClient'; import type { OrionplaceSnapshot } from '@/lib/orionplace/appLoadData'; const ORIONPLACE_KEY = Symbol('orionplace'); export class OrionplaceStore { readonly facade = createOrionplaceStore('orionplace', createScope()); #snapshot: OrionplaceSnapshot | null = null; constructor(snapshot?: OrionplaceSnapshot | null) { this.hydrate(snapshot); } get result() { return this.facade.result; } set result(value: typeof this.facade.result) { this.facade.result = value; } get status() { return this.facade.status; } set status(value: typeof this.facade.status) { this.facade.status = value; } get failure() { return this.facade.failure; } set failure(value: typeof this.facade.failure) { this.facade.failure = value; } get isReady() { return this.facade.isReady; } set isReady(value: typeof this.facade.isReady) { this.facade.isReady = value; } get data() { return this.facade.data; } set data(value: typeof this.facade.data) { this.facade.data = value; } get init() { return this.facade.init; } set init(value: typeof this.facade.init) { this.facade.init = value; } get profile() { return this.facade.profile; } set profile(value: typeof this.facade.profile) { this.facade.profile = value; } get isAuthenticated() { return this.facade.isAuthenticated; } set isAuthenticated(value: typeof this.facade.isAuthenticated) { this.facade.isAuthenticated = value; } get locales() { return this.facade.locales; } set locales(value: typeof this.facade.locales) { this.facade.locales = value; } get currencies() { return this.facade.currencies; } set currencies(value: typeof this.facade.currencies) { this.facade.currencies = value; } get catalog() { return this.facade.catalog; } set catalog(value: typeof this.facade.catalog) { this.facade.catalog = value; } get catalogSnapshot() { return this.facade.catalogSnapshot; } set catalogSnapshot(value: typeof this.facade.catalogSnapshot) { this.facade.catalogSnapshot = value; } get catalogRoots() { return this.facade.catalogRoots; } set catalogRoots(value: typeof this.facade.catalogRoots) { this.facade.catalogRoots = value; } get catalogShortcuts() { return this.facade.catalogShortcuts; } set catalogShortcuts(value: typeof this.facade.catalogShortcuts) { this.facade.catalogShortcuts = value; } get catalogCategories() { return this.facade.catalogCategories; } set catalogCategories(value: typeof this.facade.catalogCategories) { this.facade.catalogCategories = value; } get products() { return this.facade.products; } set products(value: typeof this.facade.products) { this.facade.products = value; } get tariffs() { return this.facade.tariffs; } set tariffs(value: typeof this.facade.tariffs) { this.facade.tariffs = value; } get tariffsSnapshot() { return this.facade.tariffsSnapshot; } set tariffsSnapshot(value: typeof this.facade.tariffsSnapshot) { this.facade.tariffsSnapshot = value; } get storeTariffs() { return this.facade.storeTariffs; } get featured() { return this.facade.featured; } set featured(value: typeof this.facade.featured) { this.facade.featured = value; } get homeNewProductIds() { return this.facade.homeNewProductIds; } set homeNewProductIds(value: typeof this.facade.homeNewProductIds) { this.facade.homeNewProductIds = value; } get homeProductSections() { return this.facade.homeProductSections; } set homeProductSections(value: typeof this.facade.homeProductSections) { this.facade.homeProductSections = value; } get weights() { return this.facade.weights; } set weights(value: typeof this.facade.weights) { this.facade.weights = value; } get lengths() { return this.facade.lengths; } set lengths(value: typeof this.facade.lengths) { this.facade.lengths = value; } get localeCode() { return this.facade.localeCode; } set localeCode(value: typeof this.facade.localeCode) { this.facade.localeCode = value; } get currencyId() { return this.facade.currencyId; } set currencyId(value: typeof this.facade.currencyId) { this.facade.currencyId = value; } get weightId() { return this.facade.weightId; } set weightId(value: typeof this.facade.weightId) { this.facade.weightId = value; } get lengthId() { return this.facade.lengthId; } set lengthId(value: typeof this.facade.lengthId) { this.facade.lengthId = value; } get searchQuery() { return this.facade.searchQuery; } set searchQuery(value: typeof this.facade.searchQuery) { this.facade.searchQuery = value; } get searchFacets() { return this.facade.searchFacets; } set searchFacets(value: typeof this.facade.searchFacets) { this.facade.searchFacets = value; } get searchFilters() { return this.facade.searchFilters; } set searchFilters(value: typeof this.facade.searchFilters) { this.facade.searchFilters = value; } get blogPosts() { return this.facade.blogPosts; } set blogPosts(value: typeof this.facade.blogPosts) { this.facade.blogPosts = value; } get currentLocale() { return this.facade.currentLocale; } set currentLocale(value: typeof this.facade.currentLocale) { this.facade.currentLocale = value; } get currentCurrency() { return this.facade.currentCurrency; } set currentCurrency(value: typeof this.facade.currentCurrency) { this.facade.currentCurrency = value; } get productUpdates() { return this.facade.productUpdates; } set productUpdates(value: typeof this.facade.productUpdates) { this.facade.productUpdates = value; } get productRequests() { return this.facade.productRequests; } set productRequests(value: typeof this.facade.productRequests) { this.facade.productRequests = value; } get counters() { return this.facade.counters; } set counters(value: typeof this.facade.counters) { this.facade.counters = value; } get productSecondaryVariants() { return this.facade.productSecondaryVariants; } set productSecondaryVariants( value: typeof this.facade.productSecondaryVariants, ) { this.facade.productSecondaryVariants = value; } get publicEndpoints() { return this.facade.publicEndpoints; } set publicEndpoints(value: typeof this.facade.publicEndpoints) { this.facade.publicEndpoints = value; } get authenticatedEndpoints() { return this.facade.authenticatedEndpoints; } set authenticatedEndpoints(value: typeof this.facade.authenticatedEndpoints) { this.facade.authenticatedEndpoints = value; } get endpoints() { return this.facade.endpoints; } set endpoints(value: typeof this.facade.endpoints) { this.facade.endpoints = value; } patchProfile = (...args: Parameters<typeof this.facade.patchProfile>) => this.facade.patchProfile(...args); patchWallet = (...args: Parameters<typeof this.facade.patchWallet>) => this.facade.patchWallet(...args); setCatalogTree = (...args: Parameters<typeof this.facade.setCatalogTree>) => this.facade.setCatalogTree(...args); setTariffs = (...args: Parameters<typeof this.facade.setTariffs>) => this.facade.setTariffs(...args); setStoreTariffs = ( ...args: Parameters<typeof this.facade.setStoreTariffs> ) => this.facade.setStoreTariffs(...args); setHomeNewProducts = ( ...args: Parameters<typeof this.facade.setHomeNewProducts> ) => this.facade.setHomeNewProducts(...args); setSearchFacets = (...args: Parameters<typeof this.facade.setSearchFacets>) => this.facade.setSearchFacets(...args); preserveSearchFacets = ( ...args: Parameters<typeof this.facade.preserveSearchFacets> ) => this.facade.preserveSearchFacets(...args); setSearchFilters = ( ...args: Parameters<typeof this.facade.setSearchFilters> ) => this.facade.setSearchFilters(...args); setBlogPosts = (...args: Parameters<typeof this.facade.setBlogPosts>) => this.facade.setBlogPosts(...args); setLocale = (...args: Parameters<typeof this.facade.setLocale>) => this.facade.setLocale(...args); setCurrency = (...args: Parameters<typeof this.facade.setCurrency>) => this.facade.setCurrency(...args); setUnit = (...args: Parameters<typeof this.facade.setUnit>) => this.facade.setUnit(...args); getProducts = (...args: Parameters<typeof this.facade.getProducts>) => this.facade.getProducts(...args); getTariff = (...args: Parameters<typeof this.facade.getTariff>) => this.facade.getTariff(...args); getProduct = (...args: Parameters<typeof this.facade.getProduct>) => this.facade.getProduct(...args); getProductRequest = ( ...args: Parameters<typeof this.facade.getProductRequest> ) => this.facade.getProductRequest(...args); setProductRequest = ( ...args: Parameters<typeof this.facade.setProductRequest> ) => this.facade.setProductRequest(...args); setProductCounters = ( ...args: Parameters<typeof this.facade.setProductCounters> ) => this.facade.setProductCounters(...args); setProduct = (...args: Parameters<typeof this.facade.setProduct>) => this.facade.setProduct(...args); patchProductState = ( ...args: Parameters<typeof this.facade.patchProductState> ) => this.facade.patchProductState(...args); synchronizeProductCart = ( ...args: Parameters<typeof this.facade.synchronizeProductCart> ) => this.facade.synchronizeProductCart(...args); getSecondaryVariants = ( ...args: Parameters<typeof this.facade.getSecondaryVariants> ) => this.facade.getSecondaryVariants(...args); setSecondaryVariants = ( ...args: Parameters<typeof this.facade.setSecondaryVariants> ) => this.facade.setSecondaryVariants(...args); getProductAvailability = ( ...args: Parameters<typeof this.facade.getProductAvailability> ) => this.facade.getProductAvailability(...args); getCatalogCategory = ( ...args: Parameters<typeof this.facade.getCatalogCategory> ) => this.facade.getCatalogCategory(...args); getCatalogProducts = ( ...args: Parameters<typeof this.facade.getCatalogProducts> ) => this.facade.getCatalogProducts(...args); applyResult = (...args: Parameters<typeof this.facade.applyResult>) => this.facade.applyResult(...args); getEndpoint = (...args: Parameters<typeof this.facade.getEndpoint>) => this.facade.getEndpoint(...args); load = (...args: Parameters<typeof this.facade.load>) => this.facade.load(...args); endSession = (...args: Parameters<typeof this.facade.endSession>) => this.facade.endSession(...args); reset = (...args: Parameters<typeof this.facade.reset>) => this.facade.reset(...args); disposeChildren = (...args: Parameters<typeof this.facade.disposeChildren>) => this.facade.disposeChildren(...args); hydrate(snapshot: OrionplaceSnapshot | null | undefined) { if (!snapshot || snapshot === this.#snapshot) { return; } this.#snapshot = snapshot; if (snapshot.result && !snapshot.failure) { this.facade.applyResult(snapshot.result); } else { this.facade.failure = snapshot.failure ?? null; this.facade.status = snapshotStatus(snapshot); } } initialize() { return this.facade.load(requestBrowserAppLoad); } refresh() { return this.facade.load(requestBrowserAppLoad, true); } } export function setOrionplaceContext(snapshot?: OrionplaceSnapshot | null) { const store = new OrionplaceStore(snapshot); setContext(ORIONPLACE_KEY, store); return store; } export function getOrionplaceContext(): OrionplaceStore { const store = getContext<OrionplaceStore>(ORIONPLACE_KEY); if (!store) { throw new Error('The storefront layout must initialize Orionplace.'); } return store; }src/lib/stores/orionplace.svelte.tsЗначения store:
init,profile,currencies,products,isAuthenticatedи другие;useOrionplaceStore()возвращает его.Исходный код
import type { IApploadUserProfile } from '@/lib/interfaces/orionplace/appBase/IApploadUserProfile'; import { computed, ref, shallowRef, hasInjectionContext, inject, provide, } from '@/lib/orionplace/reactivity.svelte'; import type { InjectionKey, Ref } from '@/lib/orionplace/reactivity.svelte'; import type { StoreScope } from '@/lib/orionplace/scope.svelte'; import { defineStore, storeToRefs } from '@/lib/orionplace/scope.svelte'; import { useOrionplaceSelections } from '@/lib/stores/orionplaceSelections.svelte'; import { useOrionplaceBrowse } from '@/lib/stores/orionplaceBrowse.svelte'; import { useOrionplaceCatalog } from '@/lib/stores/orionplaceCatalog.svelte'; import { useOrionplaceProducts } from '@/lib/stores/orionplaceProducts.svelte'; import { attachOrionplaceDomainStores, registerOrionplaceDomainResolver, registerOrionplaceDomainStores, releaseOrionplaceDomainStores, } from '@/lib/stores/orionplaceDomainAccess.svelte'; import { guestAppLoadResult, guestProduct, } from '@/lib/utils/orionplace/guestSession'; import type { IProduct } from '@/lib/interfaces/orionplace/products/IProduct'; import type { AppLoadFailure, AppLoadResult, AppLoadLoader, AppLoadStatus, IAppLoadPathObject, } from '@/lib/interfaces/orionplace'; function forwardedState<T>(source: Ref<T>) { return computed({ get: () => source.value, set: (value: T) => { source.value = value; }, }); } /** State is scoped to the current SvelteKit render and hydrated from Application Load. */ export function createStoreState(scope: string, instanceScope: StoreScope) { 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, instanceScope, 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, instanceScope, 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, instanceScope, 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, instanceScope, 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(instanceScope, 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, }; } export function createOrionplaceStore(id: string, scope: StoreScope) { const store = defineStore(id, () => createStoreState(id, scope))(scope); attachOrionplaceDomainStores(store, scope, id); return store; } import { getOrionplaceContext } from '@/lib/orionplace/store.svelte'; export function useOrionplaceStore(_scope?: StoreScope | null) { return getOrionplaceContext().facade; } registerOrionplaceDomainResolver(() => useOrionplaceStore());src/lib/stores/orionplaceSelections.svelte.tsВыбор посетителя: язык, валюта, единицы веса и длины.
Исходный код
import { computed, ref } from '@/lib/orionplace/reactivity.svelte'; import type { ComputedRef } from '@/lib/orionplace/reactivity.svelte'; import { defineStore } from '@/lib/orionplace/scope.svelte'; import type { StoreScope } from '@/lib/orionplace/scope.svelte'; import { resolveOrionplaceDomain } from '@/lib/stores/orionplaceDomainAccess.svelte'; import '@/lib/stores/orionplace.svelte'; import type { IAppLoadCommon } from '@/lib/interfaces/orionplace/appBase/IAppLoadCommon'; /** Customer choices shared by the storefront's language, currency and unit controls. */ export function useOrionplaceSelections( scope: string, instanceScope: StoreScope, 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, }; })(instanceScope); } /** Access the selection store associated with the current storefront scope. */ export function useOrionplaceSelectionsStore() { return resolveOrionplaceDomain<ReturnType<typeof useOrionplaceSelections>>( 'selections', ); }src/lib/composables/useOrionplaceAppLoad.svelte.tsДействия в браузере:
initialize,refresh,endSessionиreset.Исходный код
import { computed } from '@/lib/orionplace/reactivity.svelte'; import { storeToRefs } from '@/lib/orionplace/scope.svelte'; import { useOrionplaceStore } from '@/lib/stores/orionplace.svelte'; import { requestBrowserAppLoad } from '@/lib/orionplace/appLoadClient'; export function useOrionplaceAppLoad() { const store = useOrionplaceStore(); const refs = storeToRefs(store); const request = (signal: AbortSignal) => requestBrowserAppLoad(signal, store.localeCode); return { store, data: refs.result, status: refs.status, error: computed(() => (store.failure ? { data: store.failure } : null)), initialize: () => store.load(request), refresh: () => store.load(request, true), endSession: () => store.endSession(request), reset: () => store.reset(), }; }src/lib/orionplace/appLoadClient.tsЗапрос из браузера через шлюз того же сайта; cookie сессии браузер хранит и отправляет сам.
Исходный код
import { AppLoadError, appLoadRejectedLocale, appLoadResult, inspectAppLoad, } from '@/lib/orionplace/appLoadResult'; import type { AppLoadRequest, AppLoadResult, } from '@/lib/interfaces/orionplace/transport'; import { APP_ORIGIN, nativeAppRequest } from '@/lib/utils/orionplace/nativeApp'; function withTimeout(signal: AbortSignal, milliseconds: number): AbortSignal { if (typeof AbortSignal.any !== 'function') { return signal; } return AbortSignal.any([signal, AbortSignal.timeout(milliseconds)]); } /** * Browser transport for explicit initialize and refresh actions. The browser * sends and stores session cookies itself through the same-origin gateway; * the Android app sends the request to its storefront (nativeApp.ts). */ export async function requestBrowserAppLoad( signal: AbortSignal, locale = '', ): Promise<AppLoadResult> { const path = locale ? `/api/appload?lang=${encodeURIComponent(locale)}` : '/api/appload'; const headers = { Accept: 'application/json', 'Content-Type': 'application/json', }; const request = { detalize_mode: 'light', ignore_cache: false, } satisfies AppLoadRequest; let status: number; let body: unknown = null; try { if (APP_ORIGIN) { const response = await nativeAppRequest(APP_ORIGIN, path, { method: 'POST', headers, data: request, signal: withTimeout(signal, 35_000), }); status = response.status; body = response.body; } else { const response = await fetch(path, { method: 'POST', headers, body: JSON.stringify(request), credentials: 'same-origin', cache: 'no-store', signal: withTimeout(signal, 35_000), }); status = response.status; body = await response.json().catch(() => null); } } catch { throw new AppLoadError('Could not reach the marketplace API.', { kind: 'network', httpStatus: null, fields: [], }); } const failure = inspectAppLoad(body, status); // A language the storefront does not have opens its default language. if (failure && locale && appLoadRejectedLocale(failure)) { return requestBrowserAppLoad(signal); } if (failure) { throw new AppLoadError('Marketplace initialization failed.', failure); } return appLoadResult(body, status); }