wineater-bot 4.23.3 → 4.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +9 -0
- package/dist/wineater-chatbot.mjs +19 -16
- package/dist/wineater-chatbot.umd.js +19 -16
- package/package.json +1 -1
- package/src/App.svelte +112 -10
- package/src/components/WineTile.svelte +43 -2
- package/src/index.js +3 -1
- package/src/services/analytics/analytics.ts +12 -2
- package/src/services/api/api.ts +59 -7
- package/src/services/i18n/translations.json +36 -9
- package/src/services/profile/consent.svelte.ts +11 -0
- package/src/services/shopify/cart.ts +105 -0
- package/src/services/shopify/shopify.spec.ts +66 -0
- package/src/services/shopify/shopify.ts +150 -0
- package/src/services/store/app-store.svelte.ts +57 -2
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// "Add to cart" on a Shopify storefront. Only ever runs on the shopper's click.
|
|
2
|
+
// Order (phase 0 check on Horizon and Debut):
|
|
3
|
+
// 1. Shopify.actions.updateCart + openCart — every Liquid storefront since 2026-06; the theme
|
|
4
|
+
// refreshes its own cart UI. Checked at click time: the module loads at the end of the page.
|
|
5
|
+
// 2. AJAX /cart/add.js with `sections` — older themes; Dawn-style cart drawer/notification
|
|
6
|
+
// re-render from the returned sections, other themes get the usual cart events.
|
|
7
|
+
// 3. Cart permalink /cart/<variant>:1?storefront=true — keeps the shopper on the cart page.
|
|
8
|
+
// The cart attribute `_wineater_session` (hidden at checkout: leading underscore) ties the order
|
|
9
|
+
// to the widget session for later attribution. It goes through /cart/update.js, which changes only
|
|
10
|
+
// the keys it is given (updateCart's `attributes` would replace every other app's attributes).
|
|
11
|
+
|
|
12
|
+
export type AddToCartResult = { ok: true; via: 'actions' | 'ajax' } | { ok: false; via: 'permalink' };
|
|
13
|
+
|
|
14
|
+
const SESSION_ATTRIBUTE = '_wineater_session';
|
|
15
|
+
|
|
16
|
+
function tagCart(root: string, sessionId: string): void {
|
|
17
|
+
fetch(`${root}cart/update.js`, {
|
|
18
|
+
method: 'POST',
|
|
19
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
20
|
+
body: JSON.stringify({ attributes: { [SESSION_ATTRIBUTE]: sessionId } }),
|
|
21
|
+
keepalive: true
|
|
22
|
+
}).catch(() => {});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function sectionIds(): string[] {
|
|
26
|
+
// Dawn and its descendants render these sections after an add
|
|
27
|
+
const ids = ['cart-drawer', 'cart-icon-bubble', 'cart-notification-product', 'cart-notification-button', 'cart-live-region-text'];
|
|
28
|
+
return ids.filter((id) => id === 'cart-drawer' || id === 'cart-icon-bubble' || document.getElementById(id));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function refreshThemeCart(res: any): void {
|
|
32
|
+
const drawer = document.querySelector('cart-drawer') as any;
|
|
33
|
+
if (drawer && typeof drawer.renderContents === 'function') {
|
|
34
|
+
drawer.classList.remove('is-empty');
|
|
35
|
+
drawer.renderContents(res);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const notification = document.querySelector('cart-notification') as any;
|
|
39
|
+
if (notification && typeof notification.renderContents === 'function') {
|
|
40
|
+
notification.renderContents(res);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const bubble = document.getElementById('cart-icon-bubble');
|
|
44
|
+
if (bubble && res?.sections?.['cart-icon-bubble']) {
|
|
45
|
+
const doc = new DOMParser().parseFromString(res.sections['cart-icon-bubble'], 'text/html');
|
|
46
|
+
bubble.innerHTML = (doc.querySelector('.shopify-section') || doc.body).innerHTML;
|
|
47
|
+
}
|
|
48
|
+
// themes that listen for their own refresh events
|
|
49
|
+
document.dispatchEvent(new CustomEvent('cart:refresh', { bubbles: true }));
|
|
50
|
+
document.documentElement.dispatchEvent(new CustomEvent('cart:refresh', { bubbles: true }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function viaActions(variantId: string): Promise<boolean> {
|
|
54
|
+
const actions = (window as any).Shopify?.actions;
|
|
55
|
+
if (!actions || typeof actions.updateCart !== 'function') return false;
|
|
56
|
+
// resolves with userErrors instead of throwing (sold out, quantity rules…)
|
|
57
|
+
const res = await actions.updateCart({
|
|
58
|
+
lines: [{ merchandiseId: `gid://shopify/ProductVariant/${variantId}`, quantity: 1 }]
|
|
59
|
+
});
|
|
60
|
+
if (res?.userErrors?.length) throw new Error(res.userErrors[0]?.message || 'cart rejected the line');
|
|
61
|
+
if (typeof actions.openCart === 'function') {
|
|
62
|
+
try {
|
|
63
|
+
await actions.openCart();
|
|
64
|
+
} catch {
|
|
65
|
+
/* the item is in the cart; the drawer just did not open */
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function viaAjax(variantId: string, root: string): Promise<boolean> {
|
|
72
|
+
const res = await fetch(`${root}cart/add.js`, {
|
|
73
|
+
method: 'POST',
|
|
74
|
+
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
|
75
|
+
body: JSON.stringify({
|
|
76
|
+
items: [{ id: Number(variantId), quantity: 1 }],
|
|
77
|
+
sections: sectionIds(),
|
|
78
|
+
sections_url: window.location.pathname
|
|
79
|
+
})
|
|
80
|
+
});
|
|
81
|
+
if (!res.ok) return false;
|
|
82
|
+
refreshThemeCart(await res.json().catch(() => null));
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function addToCart(variantId: string, opts: { root: string; sessionId: string }): Promise<AddToCartResult> {
|
|
87
|
+
try {
|
|
88
|
+
if (await viaActions(variantId)) {
|
|
89
|
+
tagCart(opts.root, opts.sessionId);
|
|
90
|
+
return { ok: true, via: 'actions' };
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.debug('[wineater] cart actions failed, trying ajax:', (error as Error)?.message);
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
if (await viaAjax(variantId, opts.root)) {
|
|
97
|
+
tagCart(opts.root, opts.sessionId);
|
|
98
|
+
return { ok: true, via: 'ajax' };
|
|
99
|
+
}
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.debug('[wineater] cart ajax failed, using the cart link:', (error as Error)?.message);
|
|
102
|
+
}
|
|
103
|
+
window.location.href = `${opts.root}cart/${variantId}:1?storefront=true&attributes[${SESSION_ATTRIBUTE}]=${encodeURIComponent(opts.sessionId)}`;
|
|
104
|
+
return { ok: false, via: 'permalink' };
|
|
105
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { parseShopifyContext, pickLanguage, quotaErrorOf, variantIdOf } from './shopify';
|
|
3
|
+
|
|
4
|
+
describe('parseShopifyContext', () => {
|
|
5
|
+
it('needs a myshopify domain', () => {
|
|
6
|
+
expect(parseShopifyContext({ shop: 'demo.myshopify.com' })?.shop).toBe('demo.myshopify.com');
|
|
7
|
+
expect(parseShopifyContext({ shop: 'DEMO.myshopify.com ' })?.shop).toBe('demo.myshopify.com');
|
|
8
|
+
expect(parseShopifyContext({ shop: 'demo.com' })).toBeNull();
|
|
9
|
+
expect(parseShopifyContext({ shop: '' })).toBeNull();
|
|
10
|
+
expect(parseShopifyContext(undefined)).toBeNull();
|
|
11
|
+
expect(parseShopifyContext(null)).toBeNull();
|
|
12
|
+
});
|
|
13
|
+
it('keeps the storefront root and falls back to /', () => {
|
|
14
|
+
expect(parseShopifyContext({ shop: 'a.myshopify.com', root: '/fr/' })?.root).toBe('/fr/');
|
|
15
|
+
expect(parseShopifyContext({ shop: 'a.myshopify.com' })?.root).toBe('/');
|
|
16
|
+
expect(parseShopifyContext({ shop: 'a.myshopify.com', root: 'fr' })?.root).toBe('/');
|
|
17
|
+
});
|
|
18
|
+
it('accepts only a hex accent color', () => {
|
|
19
|
+
expect(parseShopifyContext({ shop: 'a.myshopify.com', color: '#7b2beb' })?.color).toBe('#7b2beb');
|
|
20
|
+
expect(parseShopifyContext({ shop: 'a.myshopify.com', color: 'red;}body{display:none' })?.color).toBeNull();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('variantIdOf', () => {
|
|
25
|
+
it('reads the variant from the product url the backend builds', () => {
|
|
26
|
+
expect(variantIdOf({ url: 'https://s.myshopify.com/products/x?variant=68290776793339', itemId: null })).toBe(
|
|
27
|
+
'68290776793339'
|
|
28
|
+
);
|
|
29
|
+
expect(variantIdOf({ url: '/products/x?foo=1&variant=42', itemId: null })).toBe('42');
|
|
30
|
+
});
|
|
31
|
+
it('falls back to item_id (instant cards)', () => {
|
|
32
|
+
expect(variantIdOf({ url: 'https://s.myshopify.com/products/x', itemId: '777' })).toBe('777');
|
|
33
|
+
expect(variantIdOf({ url: null, itemId: '777' })).toBe('777');
|
|
34
|
+
});
|
|
35
|
+
it('accepts only numeric ids', () => {
|
|
36
|
+
expect(variantIdOf({ url: '/products/x?variant=abc', itemId: 'sku-1' })).toBeNull();
|
|
37
|
+
expect(variantIdOf({ url: null, itemId: null })).toBeNull();
|
|
38
|
+
expect(variantIdOf({ url: 'not a url ?variant=12', itemId: null })).toBe('12');
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('pickLanguage', () => {
|
|
43
|
+
const supported = ['en', 'es', 'fr', 'de', 'pt', 'ru'];
|
|
44
|
+
it('prefers the storefront locale when the widget speaks it', () => {
|
|
45
|
+
expect(pickLanguage('fr', 'en', supported)).toBe('fr');
|
|
46
|
+
expect(pickLanguage('pt-BR', 'en', supported)).toBe('pt');
|
|
47
|
+
expect(pickLanguage('ES', 'en', supported)).toBe('es');
|
|
48
|
+
});
|
|
49
|
+
it('falls back to the language set in the app, then English', () => {
|
|
50
|
+
expect(pickLanguage('it', 'es', supported)).toBe('es');
|
|
51
|
+
expect(pickLanguage(undefined, 'de', supported)).toBe('de');
|
|
52
|
+
expect(pickLanguage('it', 'it', supported)).toBe('en');
|
|
53
|
+
expect(pickLanguage(null, null, supported)).toBe('en');
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('quotaErrorOf', () => {
|
|
58
|
+
it('recognises the plan limit answer', () => {
|
|
59
|
+
expect(quotaErrorOf(429, { code: 'quota_exceeded', limit: 500, used: 500 })).toBe(true);
|
|
60
|
+
});
|
|
61
|
+
it('does not confuse it with rate limiting or other errors', () => {
|
|
62
|
+
expect(quotaErrorOf(429, { code: 'rate_limited' })).toBe(false);
|
|
63
|
+
expect(quotaErrorOf(500, { code: 'quota_exceeded' })).toBe(false);
|
|
64
|
+
expect(quotaErrorOf(429, null)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Shopify mode: the widget runs on a Shopify storefront, opened from the Wineater app embed.
|
|
2
|
+
// The embed loader puts the shop context into `window.wineaterData.shopify` before it loads this
|
|
3
|
+
// bundle. In this mode the widget talks to /shopify/storefront/* (the shop is the key, the
|
|
4
|
+
// backend checks Origin), puts wines into the Shopify cart instead of linking out, and follows
|
|
5
|
+
// the shop's own consent banner (Customer Privacy API) instead of showing ours.
|
|
6
|
+
//
|
|
7
|
+
// Leaf module: no imports from the store or the API, so both can use it.
|
|
8
|
+
|
|
9
|
+
export type ShopifyConfig = {
|
|
10
|
+
token?: string | null;
|
|
11
|
+
lang?: string | null;
|
|
12
|
+
currency?: string | null;
|
|
13
|
+
widget_type?: string | null;
|
|
14
|
+
title?: string | null;
|
|
15
|
+
hide_without_image?: boolean;
|
|
16
|
+
search_available?: boolean;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ShopifyContext = {
|
|
20
|
+
/** permanent myshopify domain */
|
|
21
|
+
shop: string;
|
|
22
|
+
/** storefront locale (request.locale.iso_code) */
|
|
23
|
+
locale: string | null;
|
|
24
|
+
/** cart currency */
|
|
25
|
+
currency: string | null;
|
|
26
|
+
/** Shopify.routes.root: '/' or a market/locale prefix like '/fr/' */
|
|
27
|
+
root: string;
|
|
28
|
+
/** public widget config (app-data metafield or /shopify/storefront/config) */
|
|
29
|
+
config: ShopifyConfig | null;
|
|
30
|
+
/** accent color from the theme editor setting */
|
|
31
|
+
color: string | null;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const MYSHOPIFY = /^[a-z0-9][a-z0-9-]*\.myshopify\.com$/;
|
|
35
|
+
|
|
36
|
+
export function parseShopifyContext(raw: unknown): ShopifyContext | null {
|
|
37
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
38
|
+
const r = raw as Record<string, unknown>;
|
|
39
|
+
const shop = String(r.shop ?? '')
|
|
40
|
+
.trim()
|
|
41
|
+
.toLowerCase();
|
|
42
|
+
if (!MYSHOPIFY.test(shop)) return null;
|
|
43
|
+
const root = typeof r.root === 'string' && r.root.startsWith('/') ? r.root : '/';
|
|
44
|
+
return {
|
|
45
|
+
shop,
|
|
46
|
+
locale: typeof r.locale === 'string' && r.locale ? r.locale : null,
|
|
47
|
+
currency: typeof r.currency === 'string' && r.currency ? r.currency : null,
|
|
48
|
+
root: root.endsWith('/') ? root : `${root}/`,
|
|
49
|
+
config: r.config && typeof r.config === 'object' ? (r.config as ShopifyConfig) : null,
|
|
50
|
+
color: typeof r.color === 'string' && /^#[0-9a-f]{3,8}$/i.test(r.color) ? r.color : null
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let cached: ShopifyContext | null | undefined;
|
|
55
|
+
|
|
56
|
+
/** Shop context set by the embed loader; null on every non-Shopify page. */
|
|
57
|
+
export function shopifyContext(): ShopifyContext | null {
|
|
58
|
+
if (cached === undefined) {
|
|
59
|
+
cached = typeof window === 'undefined' ? null : parseShopifyContext((window as any).wineaterData?.shopify);
|
|
60
|
+
}
|
|
61
|
+
return cached;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const isShopifyMode = (): boolean => shopifyContext() !== null;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Shopify variant id of a card. Search cards carry the product url the backend builds from the
|
|
68
|
+
* variant (`…/products/<handle>?variant=<id>`); instant cards also carry `item_id` = variant id.
|
|
69
|
+
*/
|
|
70
|
+
export function variantIdOf(wine: { url?: string | null; itemId?: string | null }): string | null {
|
|
71
|
+
const fromUrl = /[?&]variant=(\d+)/.exec(wine.url ?? '')?.[1];
|
|
72
|
+
if (fromUrl) return fromUrl;
|
|
73
|
+
const item = String(wine.itemId ?? '').trim();
|
|
74
|
+
return /^\d+$/.test(item) ? item : null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Storefront locale if the widget speaks it, else the language set in the app, else English. */
|
|
78
|
+
export function pickLanguage(
|
|
79
|
+
locale: string | null | undefined,
|
|
80
|
+
configLang: string | null | undefined,
|
|
81
|
+
supported: string[]
|
|
82
|
+
): string {
|
|
83
|
+
const base = (l: string | null | undefined) => (l ?? '').toLowerCase().split('-')[0];
|
|
84
|
+
for (const l of [base(locale), base(configLang)]) if (l && supported.includes(l)) return l;
|
|
85
|
+
return 'en';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 429 from /shopify/storefront/search because the shop used up its monthly searches. */
|
|
89
|
+
export function quotaErrorOf(status: number, body: unknown): boolean {
|
|
90
|
+
return status === 429 && (body as { code?: unknown } | null)?.code === 'quota_exceeded';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class QuotaExceededError extends Error {
|
|
94
|
+
constructor() {
|
|
95
|
+
super('quota_exceeded');
|
|
96
|
+
this.name = 'QuotaExceededError';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Per-tab session for the storefront search: the backend needs it to tell a repeated question
|
|
102
|
+
* from a new one (repeats within 10 minutes do not count toward the plan). Not a visitor id:
|
|
103
|
+
* it lives in sessionStorage (or memory) and dies with the tab.
|
|
104
|
+
*/
|
|
105
|
+
let memorySession: string | null = null;
|
|
106
|
+
export function storefrontSessionId(): string {
|
|
107
|
+
if (memorySession) return memorySession;
|
|
108
|
+
let id: string | null = null;
|
|
109
|
+
try {
|
|
110
|
+
id = window.sessionStorage.getItem('wineater_sf_session');
|
|
111
|
+
} catch {
|
|
112
|
+
/* blocked */
|
|
113
|
+
}
|
|
114
|
+
if (!id) {
|
|
115
|
+
id = globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
116
|
+
try {
|
|
117
|
+
window.sessionStorage.setItem('wineater_sf_session', id);
|
|
118
|
+
} catch {
|
|
119
|
+
/* blocked: the id lives for this page only */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
memorySession = id;
|
|
123
|
+
return id;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Shopify Customer Privacy API: the shop's own cookie banner decides whether we may identify the
|
|
128
|
+
* visitor. Until the API answers (or if the shop never loads it) we treat tracking as not allowed:
|
|
129
|
+
* analytics still go out, anonymised, as for every other shop.
|
|
130
|
+
*/
|
|
131
|
+
export function shopifyAnalyticsAllowed(): boolean {
|
|
132
|
+
try {
|
|
133
|
+
const cp = (window as any).Shopify?.customerPrivacy;
|
|
134
|
+
if (cp && typeof cp.analyticsProcessingAllowed === 'function') return cp.analyticsProcessingAllowed() === true;
|
|
135
|
+
} catch {
|
|
136
|
+
/* ignore */
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Ask Shopify to load the Customer Privacy API (no-op if the theme already did). */
|
|
142
|
+
export function loadShopifyPrivacyApi(): void {
|
|
143
|
+
try {
|
|
144
|
+
const s = (window as any).Shopify;
|
|
145
|
+
if (s?.customerPrivacy || typeof s?.loadFeatures !== 'function') return;
|
|
146
|
+
s.loadFeatures([{ name: 'consent-tracking-api', version: '0.1' }], () => {});
|
|
147
|
+
} catch {
|
|
148
|
+
/* ignore */
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -5,15 +5,25 @@ import type { StoreConfig } from "./types";
|
|
|
5
5
|
import { deserializeStoreConfig } from "./deserialize";
|
|
6
6
|
import type { ProjectUiConfig } from "../ui-config/types";
|
|
7
7
|
import { getProjectUiConfig } from "../ui-config/get-config";
|
|
8
|
-
import { setStoreConsentPolicy } from "../profile/consent.svelte";
|
|
8
|
+
import { setConsentGate, setStoreConsentPolicy } from "../profile/consent.svelte";
|
|
9
|
+
import translationsJson from "../i18n/translations.json";
|
|
10
|
+
import { loadShopifyPrivacyApi, pickLanguage, shopifyAnalyticsAllowed, shopifyContext, type ShopifyContext } from "../shopify/shopify";
|
|
9
11
|
|
|
10
12
|
export type AppState = {
|
|
11
13
|
storeConfig: StoreConfig | null;
|
|
12
14
|
currentLanguage: LanguageCode;
|
|
13
15
|
isConfigLoaded: boolean;
|
|
14
16
|
uiConfig: ProjectUiConfig;
|
|
17
|
+
/** Shopify mode: false once the shop has used up its monthly searches */
|
|
18
|
+
searchAvailable: boolean;
|
|
15
19
|
};
|
|
16
20
|
|
|
21
|
+
// Shopify mode: the theme's own font and the accent color from the theme editor, no web fonts
|
|
22
|
+
function shopifyUiConfig(sf: ShopifyContext): ProjectUiConfig {
|
|
23
|
+
const color = sf.color || '#7B2BEB';
|
|
24
|
+
return { ...getProjectUiConfig(''), brandColor: color, wineaterColor: color, customCss: '' };
|
|
25
|
+
}
|
|
26
|
+
|
|
17
27
|
const STORE_CONFIG_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
18
28
|
|
|
19
29
|
let state = $state<AppState>({
|
|
@@ -21,10 +31,13 @@ let state = $state<AppState>({
|
|
|
21
31
|
currentLanguage: 'en',
|
|
22
32
|
isConfigLoaded: false,
|
|
23
33
|
uiConfig: null,
|
|
34
|
+
searchAvailable: true,
|
|
24
35
|
});
|
|
25
36
|
|
|
26
37
|
export const appStore = {
|
|
27
38
|
getLanguageFromURL(): LanguageCode | null {
|
|
39
|
+
// a Shopify storefront owns its URL and its locale
|
|
40
|
+
if (shopifyContext()) return null;
|
|
28
41
|
const params = new URLSearchParams(window.location.search);
|
|
29
42
|
const lang = params.get('lang');
|
|
30
43
|
return lang as LanguageCode | null;
|
|
@@ -46,6 +59,10 @@ export const appStore = {
|
|
|
46
59
|
setStoreLanguage(lang: LanguageCode) {
|
|
47
60
|
if (state.storeConfig && state.storeConfig.available_languages.includes(lang)) {
|
|
48
61
|
state.currentLanguage = lang;
|
|
62
|
+
if (shopifyContext()) {
|
|
63
|
+
wineaterApi.setLanguage(lang);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
49
66
|
const params = new URLSearchParams(window.location.search);
|
|
50
67
|
params.set('lang', lang);
|
|
51
68
|
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
|
@@ -73,7 +90,12 @@ export const appStore = {
|
|
|
73
90
|
// fallback to store default language
|
|
74
91
|
this.setStoreLanguage(state.storeConfig.lang as LanguageCode);
|
|
75
92
|
}
|
|
76
|
-
|
|
93
|
+
const sf = shopifyContext();
|
|
94
|
+
this.setUiConfig(
|
|
95
|
+
sf
|
|
96
|
+
? { ...shopifyUiConfig(sf), titleText: state.storeConfig.title || null }
|
|
97
|
+
: getProjectUiConfig(state.storeConfig.id)
|
|
98
|
+
);
|
|
77
99
|
setStoreConsentPolicy(state.storeConfig.requires_cookie_consent);
|
|
78
100
|
state.isConfigLoaded = true;
|
|
79
101
|
},
|
|
@@ -81,6 +103,8 @@ export const appStore = {
|
|
|
81
103
|
// paint without waiting for the network) and refreshed in the background
|
|
82
104
|
// for the next visit. Without a cache we wait for the network as before.
|
|
83
105
|
async initializeStore() {
|
|
106
|
+
const sf = shopifyContext();
|
|
107
|
+
if (sf) return this.initializeShopify(sf);
|
|
84
108
|
// :v2 — конфиг получил requires_cookie_consent; старый кэш (24 ч) его не знает
|
|
85
109
|
const cacheKey = `${cacheScope}:store-config:v2`;
|
|
86
110
|
const fetchAndCache = async () => {
|
|
@@ -105,6 +129,37 @@ export const appStore = {
|
|
|
105
129
|
state.isConfigLoaded = false;
|
|
106
130
|
}
|
|
107
131
|
},
|
|
132
|
+
// Shopify mode: the embed passes the public config (app-data metafield), so the first paint
|
|
133
|
+
// needs no request; without it we ask /shopify/storefront/config once.
|
|
134
|
+
async initializeShopify(sf: ShopifyContext) {
|
|
135
|
+
setConsentGate(shopifyAnalyticsAllowed);
|
|
136
|
+
loadShopifyPrivacyApi();
|
|
137
|
+
try {
|
|
138
|
+
const cfg = sf.config?.lang ? sf.config : await wineaterApi.getShopifyConfig();
|
|
139
|
+
const supported = Object.keys(translationsJson);
|
|
140
|
+
const lang = pickLanguage(sf.locale, cfg?.lang, supported);
|
|
141
|
+
this.applyStoreConfig({
|
|
142
|
+
id: `shopify:${sf.shop}`,
|
|
143
|
+
type: cfg?.widget_type ?? 'store',
|
|
144
|
+
lang,
|
|
145
|
+
available_languages: [lang],
|
|
146
|
+
open_ai_key: null,
|
|
147
|
+
currency: sf.currency || cfg?.currency || 'USD',
|
|
148
|
+
title: cfg?.title ?? '',
|
|
149
|
+
requires_cookie_consent: true,
|
|
150
|
+
});
|
|
151
|
+
state.searchAvailable = cfg?.search_available !== false;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
console.error('Failed to initialize store:', error);
|
|
154
|
+
state.isConfigLoaded = false;
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
isSearchAvailable(): boolean {
|
|
158
|
+
return state.searchAvailable;
|
|
159
|
+
},
|
|
160
|
+
setSearchAvailable(available: boolean) {
|
|
161
|
+
state.searchAvailable = available;
|
|
162
|
+
},
|
|
108
163
|
isInitialized(): boolean {
|
|
109
164
|
return state.isConfigLoaded;
|
|
110
165
|
},
|