wineater-bot 4.24.1 → 4.24.3
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/dist/wineater-chatbot.mjs +13 -13
- package/dist/wineater-chatbot.umd.js +13 -13
- package/package.json +5 -1
- package/.claude/settings.local.json +0 -10
- package/.prettierignore +0 -9
- package/.prettierrc +0 -16
- package/CLAUDE.md +0 -115
- package/docs/CUSTOMIZATION.md +0 -133
- package/docs/SVELTE.md +0 -146
- package/e2e/demo.test.ts +0 -6
- package/eslint.config.js +0 -41
- package/index.html +0 -23
- package/playwright.config.ts +0 -9
- package/pnpm-workspace.yaml +0 -2
- package/src/App.svelte +0 -784
- package/src/App.svelte.test.ts +0 -13
- package/src/assets/biobio.png +0 -0
- package/src/components/CookieConsent.svelte +0 -73
- package/src/components/Footer.svelte +0 -122
- package/src/components/Header.svelte +0 -99
- package/src/components/Search.svelte +0 -177
- package/src/components/SkeletonTiles.svelte +0 -94
- package/src/components/WineBottle.svelte +0 -56
- package/src/components/WinePopup.svelte +0 -192
- package/src/components/WineTile.svelte +0 -458
- package/src/components/icons/Budget.svelte +0 -35
- package/src/components/icons/Diamond.svelte +0 -35
- package/src/components/icons/Fish.svelte +0 -56
- package/src/components/icons/Heart.svelte +0 -28
- package/src/components/icons/Meat.svelte +0 -32
- package/src/components/ui/Action.svelte +0 -83
- package/src/demo.spec.ts +0 -13
- package/src/index.js +0 -7
- package/src/services/analytics/analytics.ts +0 -142
- package/src/services/analytics/constants.ts +0 -18
- package/src/services/api/api.ts +0 -298
- package/src/services/api/deserialize.ts +0 -120
- package/src/services/bottle/bottle.spec.ts +0 -32
- package/src/services/bottle/bottle.ts +0 -78
- package/src/services/cache.ts +0 -28
- package/src/services/i18n/i18n.ts +0 -146
- package/src/services/i18n/translations.json +0 -658
- package/src/services/profile/consent.svelte.ts +0 -115
- package/src/services/profile/events.ts +0 -73
- package/src/services/profile/uid.ts +0 -87
- package/src/services/shopify/cart.ts +0 -105
- package/src/services/shopify/shopify.spec.ts +0 -66
- package/src/services/shopify/shopify.ts +0 -150
- package/src/services/store/app-store.svelte.ts +0 -172
- package/src/services/store/deserialize.ts +0 -25
- package/src/services/store/types.ts +0 -12
- package/src/services/ui-config/get-config.ts +0 -25
- package/src/services/ui-config/projects-config.ts +0 -156
- package/src/services/ui-config/types.ts +0 -15
- package/src/services/ui-config/utils.ts +0 -40
- package/tsconfig.json +0 -23
- package/vite.config.js +0 -84
- package/vite.config.js.timestamp-1772121492941-e00587396dc48.mjs +0 -71
- package/vitest-setup-client.ts +0 -2
package/src/App.svelte
DELETED
|
@@ -1,784 +0,0 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
import { setCookie, trackWidgetEvent } from './services/analytics/analytics';
|
|
3
|
-
import type { RecommendedWine, Wine, WineId } from './services/api/api';
|
|
4
|
-
import { wineaterApi, apiVersion, cacheScope } from './services/api/api';
|
|
5
|
-
import { readCache, writeCache } from './services/cache';
|
|
6
|
-
import { onMount } from 'svelte';
|
|
7
|
-
import { WIDGET_EVENTS } from './services/analytics/constants';
|
|
8
|
-
import { trackFeedback } from './services/profile/events';
|
|
9
|
-
import { newQueryId } from './services/profile/uid';
|
|
10
|
-
import Header from './components/Header.svelte';
|
|
11
|
-
import Footer from './components/Footer.svelte';
|
|
12
|
-
import Search from './components/Search.svelte';
|
|
13
|
-
import SkeletonTiles from './components/SkeletonTiles.svelte';
|
|
14
|
-
import WineTile from './components/WineTile.svelte';
|
|
15
|
-
import Action from './components/ui/Action.svelte';
|
|
16
|
-
import CookieConsent from './components/CookieConsent.svelte';
|
|
17
|
-
import { shouldAskConsent } from './services/profile/consent.svelte';
|
|
18
|
-
import { appStore } from './services/store/app-store.svelte';
|
|
19
|
-
import { getTranslation, translateTexts } from './services/i18n/i18n';
|
|
20
|
-
import { generateInlineStyle, preconnectFontHosts } from './services/ui-config/utils';
|
|
21
|
-
import {
|
|
22
|
-
QuotaExceededError,
|
|
23
|
-
shopifyContext,
|
|
24
|
-
storefrontSessionId,
|
|
25
|
-
variantIdOf
|
|
26
|
-
} from './services/shopify/shopify';
|
|
27
|
-
import { addToCart } from './services/shopify/cart';
|
|
28
|
-
|
|
29
|
-
// Shopify storefront (app embed): add to cart instead of a link out, no Wineater link,
|
|
30
|
-
// no cookies of our own, no web fonts; the shop's searches are limited by its plan.
|
|
31
|
-
const shopify = shopifyContext();
|
|
32
|
-
|
|
33
|
-
function initCookies() {
|
|
34
|
-
// chatbotToken — токен магазина, не данные посетителя. uniqueUserID
|
|
35
|
-
// больше не ставится при загрузке: его создаёт trackWidgetEvent, и только
|
|
36
|
-
// когда трекинг разрешён (consent.svelte.ts).
|
|
37
|
-
setCookie('chatbotToken', window.wineaterData?.CLIENT_TOKEN || DEMO_TOKEN, 365);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const DEMO_TOKEN = 'HeUw6IXuhvz6tRuXdPEGEbj3SGQ3Q2YtEc1Z8jd41Kg9LtyPb4Epu0to31SYIadG';
|
|
41
|
-
if (!shopify) {
|
|
42
|
-
initCookies();
|
|
43
|
-
preconnectFontHosts();
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const INSTANT_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
47
|
-
// A personal start grid follows the taste profile, which changes with every
|
|
48
|
-
// click — keep it for 1 h only; the general grid stays cached for 6 h.
|
|
49
|
-
const PERSONAL_INSTANT_CACHE_MAX_AGE_MS = 60 * 60 * 1000;
|
|
50
|
-
// The cache key carries the profile version from the last response
|
|
51
|
-
// (X-Wineater-Profile), remembered separately: a new profile → new key.
|
|
52
|
-
const instantProfileKey = `${cacheScope}:instant-profile`;
|
|
53
|
-
const instantCacheKey = (profileHash: string | null) =>
|
|
54
|
-
`${cacheScope}:instant${profileHash ? `:${profileHash}` : ''}`;
|
|
55
|
-
|
|
56
|
-
const widgetType = window?.wineaterData?.WIDGET_TYPE || 'store';
|
|
57
|
-
const countryCode = window?.wineaterData?.COUNTRY_CODE || null;
|
|
58
|
-
const widgetWrapper = window?.wineaterData?.WIDGET_WRAPPER || 'wineater-widget-conteiner';
|
|
59
|
-
|
|
60
|
-
let initialized = $state(false);
|
|
61
|
-
let configFailed = $state(false);
|
|
62
|
-
// Set once the user searches/clicks, so a late instant response never overwrites their results
|
|
63
|
-
let userActed = false;
|
|
64
|
-
let isLoading = $state(false);
|
|
65
|
-
let isLoadingMore = $state(false);
|
|
66
|
-
let results = $state<RecommendedWine[] | Wine[]>([]);
|
|
67
|
-
let selectedWineId = $state<WineId>(null);
|
|
68
|
-
let searchQuery = $state('');
|
|
69
|
-
let errorMessage = $state(null);
|
|
70
|
-
let isAnimating = $state(false);
|
|
71
|
-
let hoveredWineId = $state(null);
|
|
72
|
-
let translatedPopups = $state([]);
|
|
73
|
-
// Последний реально выполненный поиск (с добавленным ';') — searchQuery
|
|
74
|
-
// может измениться в поле ввода до следующего сабмита, а "ещё вариантов"
|
|
75
|
-
// должен повторять именно тот запрос, что дал текущие results.
|
|
76
|
-
let lastSearchedQuery = $state('');
|
|
77
|
-
let hasMore = $state(false);
|
|
78
|
-
// Всё, что уже показывали по текущему запросу — «Try again» заменяет выдачу,
|
|
79
|
-
// и без накопления повторный клик вернул бы те же вина.
|
|
80
|
-
let seenIds = $state<string[]>([]);
|
|
81
|
-
// Один id на поиск: уходит в тело search/v2 и в каждое событие обратной
|
|
82
|
-
// связи (shown/click/...), чтобы бэкенд связал клики с конкретной выдачей.
|
|
83
|
-
let currentQueryId: string | null = null;
|
|
84
|
-
// Shopify: wines put into the cart from this widget (the button shows "Added")
|
|
85
|
-
let addedIds = $state<string[]>([]);
|
|
86
|
-
// Shopify: the shop used up its monthly searches — instant picks + a soft line instead of an error
|
|
87
|
-
let sommelierResting = $state(false);
|
|
88
|
-
|
|
89
|
-
// Профиль вкуса: что показали и в каком порядке. Пустая выдача не событие.
|
|
90
|
-
function feedbackShown(wines: (Wine | RecommendedWine)[], text: string) {
|
|
91
|
-
if (!wines?.length) return;
|
|
92
|
-
trackFeedback({
|
|
93
|
-
type: 'shown',
|
|
94
|
-
query_id: currentQueryId ?? undefined,
|
|
95
|
-
sku_ids: wines.map((w) => w.id),
|
|
96
|
-
text: text || undefined
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
let appLanguage = $derived(appStore.getAppLanguage());
|
|
100
|
-
let storeLanguage = $derived(appStore.getStoreLanguage());
|
|
101
|
-
let apiKey = $derived(appStore.getApiKey());
|
|
102
|
-
let storeId = $derived(appStore.getStoreId());
|
|
103
|
-
let isStoreInitialized = $derived(appStore.isInitialized());
|
|
104
|
-
let uiConfig = $derived(appStore.getUiConfig());
|
|
105
|
-
let searchAvailable = $derived(appStore.isSearchAvailable());
|
|
106
|
-
|
|
107
|
-
function setSearchError() {
|
|
108
|
-
errorMessage = getTranslation({ key: 'errorMessage', lang: appLanguage });
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const clearError = () => (errorMessage = null);
|
|
112
|
-
|
|
113
|
-
// Returns an array of translated fields for each wine
|
|
114
|
-
async function translatePopupContent(wines: RecommendedWine[] | Wine[]) {
|
|
115
|
-
if (appLanguage === storeLanguage || widgetType !== 'bar') {
|
|
116
|
-
return wines.map(() => ({}));
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const possibleFields = ['shortDescription', 'reason', 'pairingRecommendation', 'tags'];
|
|
120
|
-
const firstWine = wines[0];
|
|
121
|
-
if (!firstWine) return wines.map(() => ({}));
|
|
122
|
-
const fieldsToTranslate = possibleFields.filter((field) => field in firstWine);
|
|
123
|
-
const batchSize = 4;
|
|
124
|
-
const translatedResults = [];
|
|
125
|
-
|
|
126
|
-
for (let i = 0; i < wines.length; i += batchSize) {
|
|
127
|
-
const batch = wines.slice(i, i + batchSize);
|
|
128
|
-
const translationPromises = batch.map(async (wine: Wine | RecommendedWine) => {
|
|
129
|
-
const entries = fieldsToTranslate
|
|
130
|
-
.map((field) => {
|
|
131
|
-
const val = wine[field] || wine[field.toLowerCase()];
|
|
132
|
-
if (!val) return null;
|
|
133
|
-
return {
|
|
134
|
-
key: field,
|
|
135
|
-
text: Array.isArray(val) ? val.join(', ') : val
|
|
136
|
-
};
|
|
137
|
-
})
|
|
138
|
-
.filter(Boolean);
|
|
139
|
-
|
|
140
|
-
if (entries.length === 0) return {};
|
|
141
|
-
|
|
142
|
-
const translated = await translateTexts({
|
|
143
|
-
sourceLang: storeLanguage,
|
|
144
|
-
targetLang: appLanguage,
|
|
145
|
-
texts: Object.fromEntries(entries.map((entry) => [entry.key, entry.text])),
|
|
146
|
-
apiKey: apiKey
|
|
147
|
-
});
|
|
148
|
-
return translated;
|
|
149
|
-
});
|
|
150
|
-
const batchTranslations = await Promise.all(translationPromises);
|
|
151
|
-
translatedResults.push(...batchTranslations);
|
|
152
|
-
}
|
|
153
|
-
return translatedResults;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Stale-while-revalidate: cached wines are painted immediately and the fresh
|
|
157
|
-
// response only refreshes the cache for the next visit, so the grid the
|
|
158
|
-
// visitor is already looking at never swaps under their cursor.
|
|
159
|
-
async function loadInstantRecommendations(configReady: Promise<void>) {
|
|
160
|
-
const lastProfile = readCache<string>(instantProfileKey, 30 * 24 * 60 * 60 * 1000);
|
|
161
|
-
const cached = readCache<Wine[]>(
|
|
162
|
-
instantCacheKey(lastProfile),
|
|
163
|
-
lastProfile ? PERSONAL_INSTANT_CACHE_MAX_AGE_MS : INSTANT_CACHE_MAX_AGE_MS
|
|
164
|
-
);
|
|
165
|
-
const hasCached = !!cached?.length;
|
|
166
|
-
if (hasCached) {
|
|
167
|
-
results = cached;
|
|
168
|
-
} else {
|
|
169
|
-
isLoading = true;
|
|
170
|
-
}
|
|
171
|
-
try {
|
|
172
|
-
const { wines: fresh, profileHash } = await wineaterApi.getInstantRecommendations();
|
|
173
|
-
if (fresh.length > 0) {
|
|
174
|
-
writeCache(instantCacheKey(profileHash), fresh);
|
|
175
|
-
writeCache(instantProfileKey, profileHash);
|
|
176
|
-
}
|
|
177
|
-
if (!hasCached && !userActed) results = fresh;
|
|
178
|
-
} catch (error) {
|
|
179
|
-
console.error('[wineater] instant recommendations error:', error);
|
|
180
|
-
if (!hasCached && !userActed) {
|
|
181
|
-
// the message is translated, which needs the store language
|
|
182
|
-
await configReady;
|
|
183
|
-
setSearchError();
|
|
184
|
-
}
|
|
185
|
-
} finally {
|
|
186
|
-
if (!userActed) isLoading = false;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Shopify: searches used up — show the start picks again with a soft line, never an error
|
|
191
|
-
async function showRestingPicks() {
|
|
192
|
-
sommelierResting = true;
|
|
193
|
-
appStore.setSearchAvailable(false);
|
|
194
|
-
try {
|
|
195
|
-
const { wines } = await wineaterApi.getInstantRecommendations();
|
|
196
|
-
results = wines;
|
|
197
|
-
translatedPopups = [];
|
|
198
|
-
hasMore = false;
|
|
199
|
-
} catch (error) {
|
|
200
|
-
console.error('[wineater] instant recommendations error:', error);
|
|
201
|
-
results = [];
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// Простая функция-анимация: поднимаем карточки и ждём
|
|
206
|
-
function animateClearAll(duration = 220): Promise<void> {
|
|
207
|
-
if (!results || results.length === 0) return Promise.resolve();
|
|
208
|
-
isAnimating = true;
|
|
209
|
-
return new Promise((resolve) => {
|
|
210
|
-
setTimeout(() => {
|
|
211
|
-
resolve();
|
|
212
|
-
}, duration);
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
async function handleSearch(event) {
|
|
217
|
-
event.preventDefault();
|
|
218
|
-
userActed = true;
|
|
219
|
-
|
|
220
|
-
let query = '';
|
|
221
|
-
// id этого поиска: ответы и finally более старого поиска не трогают новый
|
|
222
|
-
let myQueryId: string | undefined;
|
|
223
|
-
try {
|
|
224
|
-
isLoading = true;
|
|
225
|
-
clearError();
|
|
226
|
-
|
|
227
|
-
query = searchQuery.trim() + ';';
|
|
228
|
-
lastSearchedQuery = query;
|
|
229
|
-
currentQueryId = newQueryId();
|
|
230
|
-
myQueryId = currentQueryId;
|
|
231
|
-
try {
|
|
232
|
-
trackWidgetEvent(WIDGET_EVENTS.SEARCH_PERFORMED, storeId, { query });
|
|
233
|
-
} catch (error) {
|
|
234
|
-
console.error('Error tracking search event:', error);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// Запрос уходит сразу, карточки уезжают вверх, пока он в полёте.
|
|
238
|
-
// v2 идёт потоком: 4 вина приходят, как только их выбрал реранкер,
|
|
239
|
-
// причины («почему») — следом, в том же ответе. Они видны только в
|
|
240
|
-
// попапе, поэтому плитки показываем, не дожидаясь их.
|
|
241
|
-
let animationDone = false;
|
|
242
|
-
let shown = false;
|
|
243
|
-
let pendingEarly: RecommendedWine[] | null = null;
|
|
244
|
-
|
|
245
|
-
const showResults = (searchResults: RecommendedWine[]) => {
|
|
246
|
-
if (!searchResults || searchResults.length === 0) {
|
|
247
|
-
results = [];
|
|
248
|
-
translatedPopups = [];
|
|
249
|
-
errorMessage = getTranslation({ key: 'noResultsMessage', lang: appLanguage });
|
|
250
|
-
trackWidgetEvent(WIDGET_EVENTS.NO_RESULTS_FOUND, storeId, { query });
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
results = searchResults;
|
|
254
|
-
isLoading = false;
|
|
255
|
-
if (shown) return;
|
|
256
|
-
shown = true;
|
|
257
|
-
// Пагинация доступна только для search/v2 — у instant/legacy-путей нет
|
|
258
|
-
// "more"-эндпоинта. Неполная выдача (< 4) означает, что кандидаты и так
|
|
259
|
-
// кончились: кнопка в этом случае только мигнула бы впустую.
|
|
260
|
-
// в Shopify нет "more": каждый запрос считается в лимит плана
|
|
261
|
-
hasMore = apiVersion === '2' && !shopify && searchResults.length >= 4;
|
|
262
|
-
feedbackShown(searchResults, query);
|
|
263
|
-
trackWidgetEvent(WIDGET_EVENTS.SEARCH_RESULTS_DISPLAYED, storeId, {
|
|
264
|
-
results_count: searchResults.length,
|
|
265
|
-
results: searchResults,
|
|
266
|
-
query
|
|
267
|
-
});
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
const onWines = (early: RecommendedWine[]) => {
|
|
271
|
-
if (myQueryId !== currentQueryId) return; // устаревший поиск
|
|
272
|
-
if (!animationDone) pendingEarly = early;
|
|
273
|
-
else showResults(early);
|
|
274
|
-
};
|
|
275
|
-
|
|
276
|
-
const request = wineaterApi.searchStream(query, currentQueryId, onWines);
|
|
277
|
-
// Пустой catch нужен, чтобы отказ запроса во время анимации не стал
|
|
278
|
-
// unhandled rejection: ошибку обработает await ниже.
|
|
279
|
-
request.catch(() => {});
|
|
280
|
-
|
|
281
|
-
// шаг 1: плавно поднять карточки
|
|
282
|
-
await animateClearAll(220);
|
|
283
|
-
|
|
284
|
-
// шаг 2: очистить список и сбросить флаг
|
|
285
|
-
results = [];
|
|
286
|
-
isAnimating = false;
|
|
287
|
-
hasMore = false;
|
|
288
|
-
seenIds = [];
|
|
289
|
-
animationDone = true;
|
|
290
|
-
if (pendingEarly) showResults(pendingEarly);
|
|
291
|
-
|
|
292
|
-
const finalResults = await request;
|
|
293
|
-
if (myQueryId !== currentQueryId) return;
|
|
294
|
-
|
|
295
|
-
// Тот же состав — дописываем причины в уже показанные плитки, не
|
|
296
|
-
// перерисовывая сетку под курсором; иначе (fallback, редкие расхождения) — заменяем.
|
|
297
|
-
const sameWines =
|
|
298
|
-
shown &&
|
|
299
|
-
finalResults.length === results.length &&
|
|
300
|
-
finalResults.every((w, i) => w.id === results[i]?.id);
|
|
301
|
-
if (sameWines) {
|
|
302
|
-
const byId = new Map(finalResults.map((w) => [w.id, w]));
|
|
303
|
-
results = results.map((w) => ({ ...w, reason: byId.get(w.id)?.reason ?? w.reason }));
|
|
304
|
-
} else {
|
|
305
|
-
showResults(finalResults);
|
|
306
|
-
}
|
|
307
|
-
} catch (error) {
|
|
308
|
-
if (myQueryId !== currentQueryId) return;
|
|
309
|
-
if (error instanceof QuotaExceededError) {
|
|
310
|
-
await showRestingPicks();
|
|
311
|
-
return;
|
|
312
|
-
}
|
|
313
|
-
results = [];
|
|
314
|
-
console.error('[wineater] search error:', error);
|
|
315
|
-
trackWidgetEvent(WIDGET_EVENTS.ERROR_OCCURRED, storeId, {
|
|
316
|
-
error_message: error?.message,
|
|
317
|
-
query
|
|
318
|
-
});
|
|
319
|
-
setSearchError();
|
|
320
|
-
} finally {
|
|
321
|
-
if (myQueryId === currentQueryId) isLoading = false;
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
async function handleShowMore() {
|
|
326
|
-
if (isLoadingMore || !hasMore) return;
|
|
327
|
-
userActed = true;
|
|
328
|
-
// Выдача заменяется целиком, поэтому держим предыдущую под рукой: если
|
|
329
|
-
// новых вин не нашлось или запрос упал, покупатель не должен остаться
|
|
330
|
-
// перед пустой сеткой.
|
|
331
|
-
const previousResults = results;
|
|
332
|
-
try {
|
|
333
|
-
isLoadingMore = true;
|
|
334
|
-
isLoading = true;
|
|
335
|
-
|
|
336
|
-
// Карточки заменяются, а не дописываются, поэтому исключать надо всё
|
|
337
|
-
// показанное за сессию — иначе следующий клик вернёт те же вина.
|
|
338
|
-
seenIds = [...new Set([...seenIds, ...results.map((w) => w.id)])];
|
|
339
|
-
|
|
340
|
-
// Тот же жест, что и при поиске: запрос уходит сразу, карточки уезжают
|
|
341
|
-
// вверх, дальше скелетон
|
|
342
|
-
const request = wineaterApi.getMoreWineRecommendations(lastSearchedQuery, seenIds);
|
|
343
|
-
request.catch(() => {});
|
|
344
|
-
await animateClearAll(220);
|
|
345
|
-
results = [];
|
|
346
|
-
isAnimating = false;
|
|
347
|
-
|
|
348
|
-
const { recommendations, hasMore: more } = await request;
|
|
349
|
-
trackFeedback({
|
|
350
|
-
type: 'more',
|
|
351
|
-
query_id: currentQueryId ?? undefined,
|
|
352
|
-
text: lastSearchedQuery || undefined
|
|
353
|
-
});
|
|
354
|
-
feedbackShown(recommendations, lastSearchedQuery);
|
|
355
|
-
trackWidgetEvent(WIDGET_EVENTS.SHOW_MORE_CLICKED, storeId, {
|
|
356
|
-
query: lastSearchedQuery,
|
|
357
|
-
results_count: recommendations.length
|
|
358
|
-
});
|
|
359
|
-
results = recommendations.length > 0 ? recommendations : previousResults;
|
|
360
|
-
hasMore = more;
|
|
361
|
-
} catch (error) {
|
|
362
|
-
console.error('[wineater] try again error:', error);
|
|
363
|
-
results = previousResults;
|
|
364
|
-
hasMore = false;
|
|
365
|
-
} finally {
|
|
366
|
-
isLoadingMore = false;
|
|
367
|
-
isLoading = false;
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
async function handleSimilarClick(wine: Wine | RecommendedWine) {
|
|
372
|
-
userActed = true;
|
|
373
|
-
try {
|
|
374
|
-
isLoading = true;
|
|
375
|
-
clearError();
|
|
376
|
-
trackWidgetEvent(WIDGET_EVENTS.SIMILAR_WINES_CLICKED, storeId, {
|
|
377
|
-
wine_id: wine.id,
|
|
378
|
-
wine_title: wine.title
|
|
379
|
-
});
|
|
380
|
-
trackFeedback({ type: 'similar', query_id: currentQueryId ?? undefined, sku_ids: [wine.id] });
|
|
381
|
-
// «Похожие» — новая выдача без текста запроса: свой id, чтобы shown не
|
|
382
|
-
// приписались к предыдущему поиску.
|
|
383
|
-
currentQueryId = newQueryId();
|
|
384
|
-
|
|
385
|
-
const request = wineaterApi.getSimilarWines(wine.id);
|
|
386
|
-
request.catch(() => {});
|
|
387
|
-
await animateClearAll(220);
|
|
388
|
-
results = [];
|
|
389
|
-
isAnimating = false;
|
|
390
|
-
hasMore = false;
|
|
391
|
-
selectedWineId = null;
|
|
392
|
-
|
|
393
|
-
const similarWines = await request;
|
|
394
|
-
searchQuery = wine.title;
|
|
395
|
-
lastSearchedQuery = '';
|
|
396
|
-
|
|
397
|
-
if (!similarWines || similarWines.length === 0) {
|
|
398
|
-
results = [];
|
|
399
|
-
translatedPopups = [];
|
|
400
|
-
errorMessage = getTranslation({ key: 'noResultsMessage', lang: appLanguage });
|
|
401
|
-
} else {
|
|
402
|
-
results = similarWines;
|
|
403
|
-
feedbackShown(similarWines, '');
|
|
404
|
-
}
|
|
405
|
-
} catch (error) {
|
|
406
|
-
results = [];
|
|
407
|
-
console.error('[wineater] similar wines error:', error);
|
|
408
|
-
setSearchError();
|
|
409
|
-
} finally {
|
|
410
|
-
isLoading = false;
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
|
|
414
|
-
$effect(async () => {
|
|
415
|
-
// whenever results change, recompute translatedPopups asynchronously
|
|
416
|
-
if (!results || results.length === 0) {
|
|
417
|
-
translatedPopups = [];
|
|
418
|
-
return;
|
|
419
|
-
}
|
|
420
|
-
// run translation in background; ignore errors but keep latest mapping
|
|
421
|
-
try {
|
|
422
|
-
const translated = await translatePopupContent(results);
|
|
423
|
-
// guard against results having changed again while we awaited
|
|
424
|
-
if (results && results.length > 0) {
|
|
425
|
-
translatedPopups = translated;
|
|
426
|
-
}
|
|
427
|
-
} catch (e) {
|
|
428
|
-
console.error('translatePopupContent failed', e);
|
|
429
|
-
}
|
|
430
|
-
});
|
|
431
|
-
|
|
432
|
-
function handleBuyBtnClick(wine: Wine | RecommendedWine, index: number) {
|
|
433
|
-
// Close all other popups
|
|
434
|
-
if (selectedWineId && selectedWineId !== wine.id) {
|
|
435
|
-
selectedWineId = null;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
selectedWineId = wine.id;
|
|
439
|
-
|
|
440
|
-
trackFeedback({ type: 'click', query_id: currentQueryId ?? undefined, sku_ids: [wine.id], position: index });
|
|
441
|
-
trackWidgetEvent(WIDGET_EVENTS.WINE_DETAILS_VIEWED, storeId, {
|
|
442
|
-
wine_id: wine.id,
|
|
443
|
-
wine_title: wine.title,
|
|
444
|
-
is_mobile: window.innerWidth <= 768
|
|
445
|
-
});
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
function handleBuyLinkClick(wine: Wine | RecommendedWine, index: number) {
|
|
449
|
-
// Переход на страницу товара — самый сильный сигнал, который виджет видит
|
|
450
|
-
// (корзины внутри виджета нет). keepalive-батч доедет и после ухода со страницы.
|
|
451
|
-
trackFeedback({ type: 'buy_click', query_id: currentQueryId ?? undefined, sku_ids: [wine.id], position: index });
|
|
452
|
-
trackWidgetEvent(WIDGET_EVENTS.BUY_BUTTON_CLICKED, storeId, {
|
|
453
|
-
wine_id: wine.id,
|
|
454
|
-
wine_title: wine.title,
|
|
455
|
-
wine_url: wine.url,
|
|
456
|
-
is_mobile: window.innerWidth <= 768
|
|
457
|
-
});
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Shopify: the BUY button puts the bottle into the shop's cart (on click only)
|
|
461
|
-
async function handleAddToCart(wine: Wine | RecommendedWine, index: number) {
|
|
462
|
-
const variantId = variantIdOf(wine as { url?: string | null; itemId?: string | null });
|
|
463
|
-
if (!shopify || !variantId) return;
|
|
464
|
-
trackFeedback({ type: 'add_to_cart', query_id: currentQueryId ?? undefined, sku_ids: [wine.id], position: index });
|
|
465
|
-
trackWidgetEvent(WIDGET_EVENTS.BUY_BUTTON_CLICKED, storeId, {
|
|
466
|
-
action: 'add_to_cart',
|
|
467
|
-
wine_id: wine.id,
|
|
468
|
-
wine_title: wine.title,
|
|
469
|
-
wine_url: wine.url,
|
|
470
|
-
variant_id: variantId,
|
|
471
|
-
query: lastSearchedQuery || undefined,
|
|
472
|
-
is_mobile: window.innerWidth <= 768
|
|
473
|
-
});
|
|
474
|
-
const result = await addToCart(variantId, { root: shopify.root, sessionId: storefrontSessionId() });
|
|
475
|
-
if (!result.ok) return; // the cart page is opening
|
|
476
|
-
addedIds = [...addedIds, wine.id];
|
|
477
|
-
// the embed closes its panel so the theme's cart drawer is visible
|
|
478
|
-
window.dispatchEvent(new CustomEvent('wineater:added-to-cart', { detail: { variantId, via: result.via } }));
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function closePopup() {
|
|
482
|
-
const wine = results.find((w) => w.id === selectedWineId);
|
|
483
|
-
trackWidgetEvent(WIDGET_EVENTS.WINE_DETAILS_CLOSED, storeId, {
|
|
484
|
-
wine_id: selectedWineId,
|
|
485
|
-
wine_title: wine.title
|
|
486
|
-
});
|
|
487
|
-
selectedWineId = null;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function hasHoverPopupContent(wine: Wine | RecommendedWine) {
|
|
491
|
-
return !!(wine.reason || wine.shortDescription || wine.tags);
|
|
492
|
-
}
|
|
493
|
-
|
|
494
|
-
function onTileEnter(wine: Wine | RecommendedWine) {
|
|
495
|
-
if (widgetType === 'store' && hasHoverPopupContent(wine)) {
|
|
496
|
-
hoveredWineId = wine.id;
|
|
497
|
-
trackWidgetEvent(WIDGET_EVENTS.WINE_CARD_HOVER, storeId, {
|
|
498
|
-
wine_id: wine.id,
|
|
499
|
-
wine_title: wine.title
|
|
500
|
-
});
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function onTileLeave(wine: Wine | RecommendedWine) {
|
|
505
|
-
if (widgetType === 'store' && hoveredWineId === wine.id) hoveredWineId = null;
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
function onTileClick(event, wine: Wine | RecommendedWine) {
|
|
509
|
-
// Don't track clicks on buy buttons or close buttons
|
|
510
|
-
if (event.target.closest('.js-WineTileAction') || event.target.closest('.js-WinePopupClose')) {
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
trackFeedback({
|
|
515
|
-
type: 'click',
|
|
516
|
-
query_id: currentQueryId ?? undefined,
|
|
517
|
-
sku_ids: [wine.id],
|
|
518
|
-
position: results.findIndex((w) => w.id === wine.id)
|
|
519
|
-
});
|
|
520
|
-
trackWidgetEvent(WIDGET_EVENTS.WINE_CARD_CLICK, storeId, {
|
|
521
|
-
wine_id: wine.id,
|
|
522
|
-
wine_title: wine.title
|
|
523
|
-
});
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
function handleClickOutside(event: MouseEvent) {
|
|
527
|
-
// inside a shadow root (Shopify embed) document listeners see the host as the target
|
|
528
|
-
const target = (event.composedPath?.()[0] ?? event.target) as HTMLElement;
|
|
529
|
-
if (!target?.closest) return;
|
|
530
|
-
const isInsidePopup = target.closest('.js-WinePopup') || target.closest('.js-WineTileAction');
|
|
531
|
-
|
|
532
|
-
if (!isInsidePopup) {
|
|
533
|
-
selectedWineId = null;
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
// Set CSS variable from config reactively
|
|
538
|
-
// Shopify: variables go on the shadow host, not on the theme's <html>
|
|
539
|
-
const cssVarTarget: HTMLElement =
|
|
540
|
-
((window?.wineaterData?.mountTarget?.getRootNode?.() as ShadowRoot | undefined)?.host as HTMLElement) ||
|
|
541
|
-
document.documentElement;
|
|
542
|
-
$effect(() => {
|
|
543
|
-
if (uiConfig?.brandColor) {
|
|
544
|
-
cssVarTarget.style.setProperty('--wr-brand-color', uiConfig.brandColor);
|
|
545
|
-
}
|
|
546
|
-
if (uiConfig?.wineaterColor) {
|
|
547
|
-
cssVarTarget.style.setProperty('--wr-wineater-color', uiConfig.wineaterColor);
|
|
548
|
-
}
|
|
549
|
-
});
|
|
550
|
-
|
|
551
|
-
onMount(() => {
|
|
552
|
-
// Close popups on outside click
|
|
553
|
-
document.addEventListener('click', handleClickOutside);
|
|
554
|
-
|
|
555
|
-
(async () => {
|
|
556
|
-
// Config and the first wines do not depend on each other (the token is
|
|
557
|
-
// known up front), so both requests start together.
|
|
558
|
-
const configReady = appStore.initializeStore();
|
|
559
|
-
loadInstantRecommendations(configReady);
|
|
560
|
-
|
|
561
|
-
await configReady;
|
|
562
|
-
if (!isStoreInitialized) {
|
|
563
|
-
configFailed = true;
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
if (uiConfig.customCss) {
|
|
568
|
-
generateInlineStyle(uiConfig.customCss);
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
initialized = true;
|
|
572
|
-
if (shopify && !searchAvailable) sommelierResting = true;
|
|
573
|
-
// без согласия (EU-магазин) событие уходит обезличенно — не повторяем
|
|
574
|
-
// его после «Принять», иначе инициализация посчиталась бы дважды
|
|
575
|
-
trackWidgetEvent(WIDGET_EVENTS.WIDGET_INITIALIZED, storeId);
|
|
576
|
-
})();
|
|
577
|
-
|
|
578
|
-
return () => {
|
|
579
|
-
document.removeEventListener('click', handleClickOutside);
|
|
580
|
-
};
|
|
581
|
-
});
|
|
582
|
-
</script>
|
|
583
|
-
|
|
584
|
-
{#if isStoreInitialized}
|
|
585
|
-
<!-- wr-Custom is used only for custom styling -->
|
|
586
|
-
<!-- id is used to increase specificity -->
|
|
587
|
-
<div id="wr-Custom" class="wr-App" class:wr-App--embedded={!!shopify}>
|
|
588
|
-
{#if uiConfig.logoUrl}
|
|
589
|
-
<div class="wr-App-picture">
|
|
590
|
-
<img class="wr-App-logo" src={uiConfig.logoUrl} alt="" />
|
|
591
|
-
</div>
|
|
592
|
-
{/if}
|
|
593
|
-
<Header />
|
|
594
|
-
|
|
595
|
-
<Search bind:searchQuery onSubmit={handleSearch} {initialized} />
|
|
596
|
-
|
|
597
|
-
<div class="wr-App-content">
|
|
598
|
-
{#if sommelierResting && !errorMessage}
|
|
599
|
-
<p class="wr-App-resting" role="status">
|
|
600
|
-
{getTranslation({ key: 'sommelierResting', lang: appLanguage })}
|
|
601
|
-
</p>
|
|
602
|
-
{/if}
|
|
603
|
-
{#if errorMessage}
|
|
604
|
-
<div class="wr-App-error">
|
|
605
|
-
<p>{errorMessage}</p>
|
|
606
|
-
</div>
|
|
607
|
-
{:else if results && results.length > 0}
|
|
608
|
-
<div class="wr-App-placeholder">
|
|
609
|
-
{#each results as wine, index}
|
|
610
|
-
<WineTile
|
|
611
|
-
{wine}
|
|
612
|
-
{index}
|
|
613
|
-
{hoveredWineId}
|
|
614
|
-
{selectedWineId}
|
|
615
|
-
{isAnimating}
|
|
616
|
-
translatedPopup={translatedPopups[index]}
|
|
617
|
-
onBuyClick={handleBuyBtnClick}
|
|
618
|
-
onBuyLinkClick={handleBuyLinkClick}
|
|
619
|
-
onAddToCart={shopify ? handleAddToCart : undefined}
|
|
620
|
-
addedToCart={addedIds.includes(wine.id)}
|
|
621
|
-
onPopupClose={closePopup}
|
|
622
|
-
onSimilarClick={shopify ? undefined : handleSimilarClick}
|
|
623
|
-
{onTileEnter}
|
|
624
|
-
{onTileLeave}
|
|
625
|
-
{onTileClick}
|
|
626
|
-
/>
|
|
627
|
-
{/each}
|
|
628
|
-
</div>
|
|
629
|
-
{#if hasMore}
|
|
630
|
-
<div class="wr-App-showMore">
|
|
631
|
-
<Action handleClick={handleShowMore} variant="outlined">
|
|
632
|
-
{getTranslation({ key: 'tryAgain', lang: appLanguage })}
|
|
633
|
-
</Action>
|
|
634
|
-
</div>
|
|
635
|
-
{/if}
|
|
636
|
-
{:else if isLoading}
|
|
637
|
-
<SkeletonTiles />
|
|
638
|
-
{/if}
|
|
639
|
-
</div>
|
|
640
|
-
|
|
641
|
-
{#if shouldAskConsent()}
|
|
642
|
-
<CookieConsent lang={appLanguage} />
|
|
643
|
-
{/if}
|
|
644
|
-
|
|
645
|
-
{#if !shopify}
|
|
646
|
-
<Footer {countryCode} />
|
|
647
|
-
{/if}
|
|
648
|
-
</div>
|
|
649
|
-
{:else if !configFailed}
|
|
650
|
-
<!-- Shell with the same footprint as the widget while the store config loads -->
|
|
651
|
-
<div class="wr-App wr-App--loading" class:wr-App--embedded={!!shopify} aria-busy="true">
|
|
652
|
-
<div class="wr-App-loadingTop"></div>
|
|
653
|
-
<SkeletonTiles />
|
|
654
|
-
</div>
|
|
655
|
-
{/if}
|
|
656
|
-
|
|
657
|
-
<style>
|
|
658
|
-
/* ===================== 1) CSS Variables ===================== */
|
|
659
|
-
:global(:root) {
|
|
660
|
-
--wineater-corp-color: #0044ff;
|
|
661
|
-
/* --wr-brand-color is set dynamically from uiConfig.brandColor */
|
|
662
|
-
--wr-brand-color: #7b2beb; /* fallback value */
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
/* ===================== 2) Global Styles ===================== */
|
|
666
|
-
:global(*) {
|
|
667
|
-
box-sizing: border-box;
|
|
668
|
-
}
|
|
669
|
-
|
|
670
|
-
:global(.hidden) {
|
|
671
|
-
display: none;
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
:global(body) {
|
|
675
|
-
margin: 0;
|
|
676
|
-
padding: 0;
|
|
677
|
-
|
|
678
|
-
font-family: var(--wineater-font-primary, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif);
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
/* ===================== 3) Widget Container ===================== */
|
|
682
|
-
.wr-App {
|
|
683
|
-
padding: 12px 12px 0px;
|
|
684
|
-
border-radius: 16px;
|
|
685
|
-
background-color: #fff;
|
|
686
|
-
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
|
687
|
-
max-width: 1024px;
|
|
688
|
-
min-width: 1024px;
|
|
689
|
-
min-height: 500px;
|
|
690
|
-
margin: 40px auto;
|
|
691
|
-
position: relative;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
/* Shopify: the embed's panel is the frame — fill it, no card of our own */
|
|
695
|
-
.wr-App.wr-App--embedded {
|
|
696
|
-
min-width: 0;
|
|
697
|
-
max-width: none;
|
|
698
|
-
width: 100%;
|
|
699
|
-
margin: 0;
|
|
700
|
-
box-shadow: none;
|
|
701
|
-
border-radius: 0;
|
|
702
|
-
min-height: 0;
|
|
703
|
-
padding-bottom: 12px;
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
.wr-App-resting {
|
|
707
|
-
margin: 0 0 8px;
|
|
708
|
-
padding: 10px 14px;
|
|
709
|
-
border-radius: 10px;
|
|
710
|
-
background: #f6f7fd;
|
|
711
|
-
color: #444;
|
|
712
|
-
font-size: 14px;
|
|
713
|
-
text-align: center;
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
.wr-App-loadingTop {
|
|
717
|
-
/* roughly the height of Header + Search, so tiles do not jump once they render */
|
|
718
|
-
height: 150px;
|
|
719
|
-
}
|
|
720
|
-
|
|
721
|
-
.wr-App-picture {
|
|
722
|
-
display: flex;
|
|
723
|
-
justify-content: center;
|
|
724
|
-
align-items: center;
|
|
725
|
-
margin-bottom: 12px;
|
|
726
|
-
}
|
|
727
|
-
|
|
728
|
-
.wr-App-logo {
|
|
729
|
-
max-width: 100%;
|
|
730
|
-
height: auto;
|
|
731
|
-
width: auto;
|
|
732
|
-
max-height: 60px;
|
|
733
|
-
}
|
|
734
|
-
|
|
735
|
-
.wr-App-content {
|
|
736
|
-
position: relative;
|
|
737
|
-
min-height: 300px;
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
.wr-App-placeholder {
|
|
741
|
-
display: grid;
|
|
742
|
-
grid-template-columns: repeat(4, 1fr);
|
|
743
|
-
gap: 0;
|
|
744
|
-
align-items: stretch;
|
|
745
|
-
margin-bottom: 0;
|
|
746
|
-
}
|
|
747
|
-
|
|
748
|
-
.wr-App-showMore {
|
|
749
|
-
display: flex;
|
|
750
|
-
justify-content: center;
|
|
751
|
-
padding: 16px 0 24px;
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
/* ===================== 12) Error Message ===================== */
|
|
755
|
-
.wr-App-error {
|
|
756
|
-
text-align: center;
|
|
757
|
-
padding: 24px;
|
|
758
|
-
color: #666;
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
/* ===================== 13) Mobile Styles ===================== */
|
|
762
|
-
@media screen and (max-width: 768px) {
|
|
763
|
-
.wr-App {
|
|
764
|
-
flex-direction: column;
|
|
765
|
-
padding: 0px 16px;
|
|
766
|
-
min-width: 100vw;
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
.wr-App.wr-App--embedded {
|
|
770
|
-
min-width: 0;
|
|
771
|
-
padding: 0 12px 12px;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
.wr-App-placeholder {
|
|
775
|
-
display: flex;
|
|
776
|
-
gap: 8px;
|
|
777
|
-
overflow-x: auto;
|
|
778
|
-
scroll-snap-type: x mandatory;
|
|
779
|
-
padding: 8px 0;
|
|
780
|
-
width: calc(100vw - 32px);
|
|
781
|
-
min-width: calc(100vw - 32px);
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
</style>
|