wineater-bot 4.16.1 → 4.20.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 +11 -2
- package/dist/wineater-chatbot.mjs +40 -10204
- package/dist/wineater-chatbot.umd.js +30 -41
- package/package.json +4 -6
- package/src/App.svelte +146 -45
- package/src/components/CookieConsent.svelte +73 -0
- package/src/components/Footer.svelte +2 -0
- package/src/components/Search.svelte +1 -1
- package/src/components/SkeletonTiles.svelte +94 -0
- package/src/components/WineTile.svelte +11 -1
- package/src/services/analytics/analytics.ts +5 -0
- package/src/services/api/api.ts +76 -74
- package/src/services/cache.ts +28 -0
- package/src/services/i18n/translations.json +46 -10
- package/src/services/profile/consent.svelte.ts +103 -0
- package/src/services/profile/events.ts +73 -0
- package/src/services/profile/uid.ts +87 -0
- package/src/services/store/app-store.svelte.ts +42 -13
- package/src/services/store/deserialize.ts +2 -0
- package/src/services/store/types.ts +2 -0
- package/src/services/ui-config/utils.ts +30 -2
- package/vite.config.js +20 -1
- package/src/components/Loader.svelte +0 -62
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wineater-bot",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.20.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"main": "dist/wineater-chatbot.mjs",
|
|
6
6
|
"module": "src/index.js",
|
|
@@ -22,15 +22,13 @@
|
|
|
22
22
|
"author": "",
|
|
23
23
|
"license": "ISC",
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@openreplay/tracker": "^14.0.4",
|
|
26
|
-
"flagsmith": "^7.0.2",
|
|
27
|
-
"vite": "^5.2.9",
|
|
28
25
|
"svelte": "^5.0.0"
|
|
29
26
|
},
|
|
30
27
|
"devDependencies": {
|
|
31
28
|
"@eslint/compat": "^1.4.0",
|
|
32
29
|
"@eslint/js": "^9.36.0",
|
|
33
30
|
"@playwright/test": "^1.55.1",
|
|
31
|
+
"@sveltejs/vite-plugin-svelte": "^4.0.0",
|
|
34
32
|
"@types/node": "^22",
|
|
35
33
|
"@vitest/browser": "^3.2.4",
|
|
36
34
|
"eslint": "^9.36.0",
|
|
@@ -42,9 +40,9 @@
|
|
|
42
40
|
"prettier-plugin-svelte": "^3.4.0",
|
|
43
41
|
"rollup-plugin-postcss": "^4.0.2",
|
|
44
42
|
"typescript-eslint": "^8.44.1",
|
|
43
|
+
"vite": "^5.2.9",
|
|
45
44
|
"vite-plugin-devtools-json": "^1.0.0",
|
|
46
45
|
"vitest": "^3.2.4",
|
|
47
|
-
"vitest-browser-svelte": "^1.1.0"
|
|
48
|
-
"@sveltejs/vite-plugin-svelte": "^4.0.0"
|
|
46
|
+
"vitest-browser-svelte": "^1.1.0"
|
|
49
47
|
}
|
|
50
48
|
}
|
package/src/App.svelte
CHANGED
|
@@ -1,35 +1,46 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
|
-
import { setCookie,
|
|
3
|
-
import Tracker from '@openreplay/tracker';
|
|
2
|
+
import { setCookie, trackWidgetEvent } from './services/analytics/analytics';
|
|
4
3
|
import type { RecommendedWine, Wine, WineId } from './services/api/api';
|
|
5
|
-
import { wineaterApi, apiVersion } from './services/api/api';
|
|
4
|
+
import { wineaterApi, apiVersion, cacheScope } from './services/api/api';
|
|
5
|
+
import { readCache, writeCache } from './services/cache';
|
|
6
6
|
import { onMount } from 'svelte';
|
|
7
7
|
import { WIDGET_EVENTS } from './services/analytics/constants';
|
|
8
|
+
import { trackFeedback } from './services/profile/events';
|
|
9
|
+
import { newQueryId } from './services/profile/uid';
|
|
8
10
|
import Header from './components/Header.svelte';
|
|
9
11
|
import Footer from './components/Footer.svelte';
|
|
10
12
|
import Search from './components/Search.svelte';
|
|
11
|
-
import
|
|
13
|
+
import SkeletonTiles from './components/SkeletonTiles.svelte';
|
|
12
14
|
import WineTile from './components/WineTile.svelte';
|
|
13
15
|
import Action from './components/ui/Action.svelte';
|
|
16
|
+
import CookieConsent from './components/CookieConsent.svelte';
|
|
17
|
+
import { shouldAskConsent, onConsentChange } from './services/profile/consent.svelte';
|
|
14
18
|
import { appStore } from './services/store/app-store.svelte';
|
|
15
19
|
import { getTranslation, translateTexts } from './services/i18n/i18n';
|
|
16
|
-
import { generateInlineStyle } from './services/ui-config/utils';
|
|
20
|
+
import { generateInlineStyle, preconnectFontHosts } from './services/ui-config/utils';
|
|
17
21
|
|
|
18
22
|
function initCookies() {
|
|
23
|
+
// chatbotToken — токен магазина, не данные посетителя. uniqueUserID
|
|
24
|
+
// больше не ставится при загрузке: его создаёт trackWidgetEvent, и только
|
|
25
|
+
// когда трекинг разрешён (consent.svelte.ts).
|
|
19
26
|
setCookie('chatbotToken', window.wineaterData?.CLIENT_TOKEN || DEMO_TOKEN, 365);
|
|
20
|
-
setUniqueUserCookie();
|
|
21
27
|
}
|
|
22
28
|
|
|
23
29
|
const DEMO_TOKEN = 'HeUw6IXuhvz6tRuXdPEGEbj3SGQ3Q2YtEc1Z8jd41Kg9LtyPb4Epu0to31SYIadG';
|
|
24
|
-
const tracker = new Tracker({ projectKey: 'PA8j9fAi49i6g2QnvpNs', __DISABLE_SECURE_MODE: true });
|
|
25
|
-
tracker.start();
|
|
26
30
|
initCookies();
|
|
31
|
+
preconnectFontHosts();
|
|
32
|
+
|
|
33
|
+
const INSTANT_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
34
|
+
const instantCacheKey = `${cacheScope}:instant`;
|
|
27
35
|
|
|
28
36
|
const widgetType = window?.wineaterData?.WIDGET_TYPE || 'store';
|
|
29
37
|
const countryCode = window?.wineaterData?.COUNTRY_CODE || null;
|
|
30
38
|
const widgetWrapper = window?.wineaterData?.WIDGET_WRAPPER || 'wineater-widget-conteiner';
|
|
31
39
|
|
|
32
40
|
let initialized = $state(false);
|
|
41
|
+
let configFailed = $state(false);
|
|
42
|
+
// Set once the user searches/clicks, so a late instant response never overwrites their results
|
|
43
|
+
let userActed = false;
|
|
33
44
|
let isLoading = $state(false);
|
|
34
45
|
let isLoadingMore = $state(false);
|
|
35
46
|
let results = $state<RecommendedWine[] | Wine[]>([]);
|
|
@@ -47,6 +58,20 @@
|
|
|
47
58
|
// Всё, что уже показывали по текущему запросу — «Try again» заменяет выдачу,
|
|
48
59
|
// и без накопления повторный клик вернул бы те же вина.
|
|
49
60
|
let seenIds = $state<string[]>([]);
|
|
61
|
+
// Один id на поиск: уходит в тело search/v2 и в каждое событие обратной
|
|
62
|
+
// связи (shown/click/...), чтобы бэкенд связал клики с конкретной выдачей.
|
|
63
|
+
let currentQueryId: string | null = null;
|
|
64
|
+
|
|
65
|
+
// Профиль вкуса: что показали и в каком порядке. Пустая выдача не событие.
|
|
66
|
+
function feedbackShown(wines: (Wine | RecommendedWine)[], text: string) {
|
|
67
|
+
if (!wines?.length) return;
|
|
68
|
+
trackFeedback({
|
|
69
|
+
type: 'shown',
|
|
70
|
+
query_id: currentQueryId ?? undefined,
|
|
71
|
+
sku_ids: wines.map((w) => w.id),
|
|
72
|
+
text: text || undefined
|
|
73
|
+
});
|
|
74
|
+
}
|
|
50
75
|
let appLanguage = $derived(appStore.getAppLanguage());
|
|
51
76
|
let storeLanguage = $derived(appStore.getStoreLanguage());
|
|
52
77
|
let apiKey = $derived(appStore.getApiKey());
|
|
@@ -103,15 +128,30 @@
|
|
|
103
128
|
return translatedResults;
|
|
104
129
|
}
|
|
105
130
|
|
|
106
|
-
|
|
107
|
-
|
|
131
|
+
// Stale-while-revalidate: cached wines are painted immediately and the fresh
|
|
132
|
+
// response only refreshes the cache for the next visit, so the grid the
|
|
133
|
+
// visitor is already looking at never swaps under their cursor.
|
|
134
|
+
async function loadInstantRecommendations(configReady: Promise<void>) {
|
|
135
|
+
const cached = readCache<Wine[]>(instantCacheKey, INSTANT_CACHE_MAX_AGE_MS);
|
|
136
|
+
const hasCached = !!cached?.length;
|
|
137
|
+
if (hasCached) {
|
|
138
|
+
results = cached;
|
|
139
|
+
} else {
|
|
140
|
+
isLoading = true;
|
|
141
|
+
}
|
|
108
142
|
try {
|
|
109
|
-
|
|
143
|
+
const fresh = await wineaterApi.getInstantRecommendations();
|
|
144
|
+
if (fresh.length > 0) writeCache(instantCacheKey, fresh);
|
|
145
|
+
if (!hasCached && !userActed) results = fresh;
|
|
110
146
|
} catch (error) {
|
|
111
147
|
console.error('[wineater] instant recommendations error:', error);
|
|
112
|
-
|
|
148
|
+
if (!hasCached && !userActed) {
|
|
149
|
+
// the message is translated, which needs the store language
|
|
150
|
+
await configReady;
|
|
151
|
+
setSearchError();
|
|
152
|
+
}
|
|
113
153
|
} finally {
|
|
114
|
-
isLoading = false;
|
|
154
|
+
if (!userActed) isLoading = false;
|
|
115
155
|
}
|
|
116
156
|
}
|
|
117
157
|
|
|
@@ -128,12 +168,28 @@
|
|
|
128
168
|
|
|
129
169
|
async function handleSearch(event) {
|
|
130
170
|
event.preventDefault();
|
|
171
|
+
userActed = true;
|
|
131
172
|
|
|
132
173
|
let query = '';
|
|
133
174
|
try {
|
|
134
175
|
isLoading = true;
|
|
135
176
|
clearError();
|
|
136
177
|
|
|
178
|
+
query = searchQuery.trim() + ';';
|
|
179
|
+
lastSearchedQuery = query;
|
|
180
|
+
currentQueryId = newQueryId();
|
|
181
|
+
try {
|
|
182
|
+
trackWidgetEvent(WIDGET_EVENTS.SEARCH_PERFORMED, storeId, { query });
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.error('Error tracking search event:', error);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Запрос уходит сразу, карточки уезжают вверх, пока он в полёте.
|
|
188
|
+
// Пустой catch нужен, чтобы отказ запроса во время анимации не стал
|
|
189
|
+
// unhandled rejection: ошибку обработает await ниже.
|
|
190
|
+
const request = wineaterApi.getWineRecommendationsSearch(query, currentQueryId);
|
|
191
|
+
request.catch(() => {});
|
|
192
|
+
|
|
137
193
|
// шаг 1: плавно поднять карточки
|
|
138
194
|
await animateClearAll(220);
|
|
139
195
|
|
|
@@ -143,15 +199,7 @@
|
|
|
143
199
|
hasMore = false;
|
|
144
200
|
seenIds = [];
|
|
145
201
|
|
|
146
|
-
|
|
147
|
-
lastSearchedQuery = query;
|
|
148
|
-
try {
|
|
149
|
-
trackWidgetEvent(WIDGET_EVENTS.SEARCH_PERFORMED, storeId, { query });
|
|
150
|
-
} catch (error) {
|
|
151
|
-
console.error('Error tracking search event:', error);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const searchResults = await wineaterApi.getWineRecommendationsSearch(query);
|
|
202
|
+
const searchResults = await request;
|
|
155
203
|
|
|
156
204
|
if (!searchResults || searchResults.length === 0) {
|
|
157
205
|
results = [];
|
|
@@ -165,6 +213,7 @@
|
|
|
165
213
|
// "more"-эндпоинта. Неполная выдача (< 4) означает, что кандидаты и так
|
|
166
214
|
// кончились: кнопка в этом случае только мигнула бы впустую.
|
|
167
215
|
hasMore = apiVersion === '2' && searchResults.length >= 4;
|
|
216
|
+
feedbackShown(searchResults, query);
|
|
168
217
|
trackWidgetEvent(WIDGET_EVENTS.SEARCH_RESULTS_DISPLAYED, storeId, {
|
|
169
218
|
results_count: searchResults.length,
|
|
170
219
|
results: searchResults,
|
|
@@ -186,6 +235,7 @@
|
|
|
186
235
|
|
|
187
236
|
async function handleShowMore() {
|
|
188
237
|
if (isLoadingMore || !hasMore) return;
|
|
238
|
+
userActed = true;
|
|
189
239
|
// Выдача заменяется целиком, поэтому держим предыдущую под рукой: если
|
|
190
240
|
// новых вин не нашлось или запрос упал, покупатель не должен остаться
|
|
191
241
|
// перед пустой сеткой.
|
|
@@ -198,15 +248,21 @@
|
|
|
198
248
|
// показанное за сессию — иначе следующий клик вернёт те же вина.
|
|
199
249
|
seenIds = [...new Set([...seenIds, ...results.map((w) => w.id)])];
|
|
200
250
|
|
|
201
|
-
// Тот же жест, что и при поиске:
|
|
251
|
+
// Тот же жест, что и при поиске: запрос уходит сразу, карточки уезжают
|
|
252
|
+
// вверх, дальше скелетон
|
|
253
|
+
const request = wineaterApi.getMoreWineRecommendations(lastSearchedQuery, seenIds);
|
|
254
|
+
request.catch(() => {});
|
|
202
255
|
await animateClearAll(220);
|
|
203
256
|
results = [];
|
|
204
257
|
isAnimating = false;
|
|
205
258
|
|
|
206
|
-
const { recommendations, hasMore: more } = await
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
259
|
+
const { recommendations, hasMore: more } = await request;
|
|
260
|
+
trackFeedback({
|
|
261
|
+
type: 'more',
|
|
262
|
+
query_id: currentQueryId ?? undefined,
|
|
263
|
+
text: lastSearchedQuery || undefined
|
|
264
|
+
});
|
|
265
|
+
feedbackShown(recommendations, lastSearchedQuery);
|
|
210
266
|
trackWidgetEvent(WIDGET_EVENTS.SHOW_MORE_CLICKED, storeId, {
|
|
211
267
|
query: lastSearchedQuery,
|
|
212
268
|
results_count: recommendations.length
|
|
@@ -224,6 +280,7 @@
|
|
|
224
280
|
}
|
|
225
281
|
|
|
226
282
|
async function handleSimilarClick(wine: Wine | RecommendedWine) {
|
|
283
|
+
userActed = true;
|
|
227
284
|
try {
|
|
228
285
|
isLoading = true;
|
|
229
286
|
clearError();
|
|
@@ -231,14 +288,20 @@
|
|
|
231
288
|
wine_id: wine.id,
|
|
232
289
|
wine_title: wine.title
|
|
233
290
|
});
|
|
291
|
+
trackFeedback({ type: 'similar', query_id: currentQueryId ?? undefined, sku_ids: [wine.id] });
|
|
292
|
+
// «Похожие» — новая выдача без текста запроса: свой id, чтобы shown не
|
|
293
|
+
// приписались к предыдущему поиску.
|
|
294
|
+
currentQueryId = newQueryId();
|
|
234
295
|
|
|
296
|
+
const request = wineaterApi.getSimilarWines(wine.id);
|
|
297
|
+
request.catch(() => {});
|
|
235
298
|
await animateClearAll(220);
|
|
236
299
|
results = [];
|
|
237
300
|
isAnimating = false;
|
|
238
301
|
hasMore = false;
|
|
239
302
|
selectedWineId = null;
|
|
240
303
|
|
|
241
|
-
const similarWines = await
|
|
304
|
+
const similarWines = await request;
|
|
242
305
|
searchQuery = wine.title;
|
|
243
306
|
lastSearchedQuery = '';
|
|
244
307
|
|
|
@@ -248,6 +311,7 @@
|
|
|
248
311
|
errorMessage = getTranslation({ key: 'noResultsMessage', lang: appLanguage });
|
|
249
312
|
} else {
|
|
250
313
|
results = similarWines;
|
|
314
|
+
feedbackShown(similarWines, '');
|
|
251
315
|
}
|
|
252
316
|
} catch (error) {
|
|
253
317
|
results = [];
|
|
@@ -284,6 +348,7 @@
|
|
|
284
348
|
|
|
285
349
|
selectedWineId = wine.id;
|
|
286
350
|
|
|
351
|
+
trackFeedback({ type: 'click', query_id: currentQueryId ?? undefined, sku_ids: [wine.id], position: index });
|
|
287
352
|
trackWidgetEvent(WIDGET_EVENTS.WINE_DETAILS_VIEWED, storeId, {
|
|
288
353
|
wine_id: wine.id,
|
|
289
354
|
wine_title: wine.title,
|
|
@@ -292,6 +357,9 @@
|
|
|
292
357
|
}
|
|
293
358
|
|
|
294
359
|
function handleBuyLinkClick(wine: Wine | RecommendedWine, index: number) {
|
|
360
|
+
// Переход на страницу товара — самый сильный сигнал, который виджет видит
|
|
361
|
+
// (корзины внутри виджета нет). keepalive-батч доедет и после ухода со страницы.
|
|
362
|
+
trackFeedback({ type: 'buy_click', query_id: currentQueryId ?? undefined, sku_ids: [wine.id], position: index });
|
|
295
363
|
trackWidgetEvent(WIDGET_EVENTS.BUY_BUTTON_CLICKED, storeId, {
|
|
296
364
|
wine_id: wine.id,
|
|
297
365
|
wine_title: wine.title,
|
|
@@ -333,6 +401,12 @@
|
|
|
333
401
|
return;
|
|
334
402
|
}
|
|
335
403
|
|
|
404
|
+
trackFeedback({
|
|
405
|
+
type: 'click',
|
|
406
|
+
query_id: currentQueryId ?? undefined,
|
|
407
|
+
sku_ids: [wine.id],
|
|
408
|
+
position: results.findIndex((w) => w.id === wine.id)
|
|
409
|
+
});
|
|
336
410
|
trackWidgetEvent(WIDGET_EVENTS.WINE_CARD_CLICK, storeId, {
|
|
337
411
|
wine_id: wine.id,
|
|
338
412
|
wine_title: wine.title
|
|
@@ -358,25 +432,39 @@
|
|
|
358
432
|
}
|
|
359
433
|
});
|
|
360
434
|
|
|
361
|
-
onMount(
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
if (!isStoreInitialized) return;
|
|
435
|
+
onMount(() => {
|
|
436
|
+
// Close popups on outside click
|
|
437
|
+
document.addEventListener('click', handleClickOutside);
|
|
365
438
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
439
|
+
(async () => {
|
|
440
|
+
// Config and the first wines do not depend on each other (the token is
|
|
441
|
+
// known up front), so both requests start together.
|
|
442
|
+
const configReady = appStore.initializeStore();
|
|
443
|
+
loadInstantRecommendations(configReady);
|
|
370
444
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
445
|
+
await configReady;
|
|
446
|
+
if (!isStoreInitialized) {
|
|
447
|
+
configFailed = true;
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
374
450
|
|
|
375
|
-
|
|
376
|
-
|
|
451
|
+
if (uiConfig.customCss) {
|
|
452
|
+
generateInlineStyle(uiConfig.customCss);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
initialized = true;
|
|
456
|
+
// без согласия (EU-магазин) trackWidgetEvent — no-op; после «Принять»
|
|
457
|
+
// событие инициализации отправляется один раз, см. onConsentChange ниже
|
|
458
|
+
trackWidgetEvent(WIDGET_EVENTS.WIDGET_INITIALIZED, storeId);
|
|
459
|
+
})();
|
|
460
|
+
|
|
461
|
+
const offConsent = onConsentChange((c) => {
|
|
462
|
+
if (c === 'granted') trackWidgetEvent(WIDGET_EVENTS.WIDGET_INITIALIZED, storeId);
|
|
463
|
+
});
|
|
377
464
|
|
|
378
465
|
return () => {
|
|
379
466
|
document.removeEventListener('click', handleClickOutside);
|
|
467
|
+
offConsent();
|
|
380
468
|
};
|
|
381
469
|
});
|
|
382
470
|
</script>
|
|
@@ -394,10 +482,6 @@
|
|
|
394
482
|
|
|
395
483
|
<Search bind:searchQuery onSubmit={handleSearch} {initialized} />
|
|
396
484
|
|
|
397
|
-
{#if isLoading}
|
|
398
|
-
<Loader />
|
|
399
|
-
{/if}
|
|
400
|
-
|
|
401
485
|
<div class="wr-App-content">
|
|
402
486
|
{#if errorMessage}
|
|
403
487
|
<div class="wr-App-error">
|
|
@@ -430,11 +514,23 @@
|
|
|
430
514
|
</Action>
|
|
431
515
|
</div>
|
|
432
516
|
{/if}
|
|
517
|
+
{:else if isLoading}
|
|
518
|
+
<SkeletonTiles />
|
|
433
519
|
{/if}
|
|
434
520
|
</div>
|
|
435
521
|
|
|
522
|
+
{#if shouldAskConsent()}
|
|
523
|
+
<CookieConsent lang={appLanguage} />
|
|
524
|
+
{/if}
|
|
525
|
+
|
|
436
526
|
<Footer {countryCode} />
|
|
437
527
|
</div>
|
|
528
|
+
{:else if !configFailed}
|
|
529
|
+
<!-- Shell with the same footprint as the widget while the store config loads -->
|
|
530
|
+
<div class="wr-App wr-App--loading" aria-busy="true">
|
|
531
|
+
<div class="wr-App-loadingTop"></div>
|
|
532
|
+
<SkeletonTiles />
|
|
533
|
+
</div>
|
|
438
534
|
{/if}
|
|
439
535
|
|
|
440
536
|
<style>
|
|
@@ -474,6 +570,11 @@
|
|
|
474
570
|
position: relative;
|
|
475
571
|
}
|
|
476
572
|
|
|
573
|
+
.wr-App-loadingTop {
|
|
574
|
+
/* roughly the height of Header + Search, so tiles do not jump once they render */
|
|
575
|
+
height: 150px;
|
|
576
|
+
}
|
|
577
|
+
|
|
477
578
|
.wr-App-picture {
|
|
478
579
|
display: flex;
|
|
479
580
|
justify-content: center;
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Consent banner for shops with Stores.requires_cookie_consent (EU).
|
|
3
|
+
// Rendered inside the widget, not over the host page: the shop has its own
|
|
4
|
+
// site-wide banner, ours covers only what the widget itself stores/sends.
|
|
5
|
+
import Action from './ui/Action.svelte';
|
|
6
|
+
import { getTranslation } from '../services/i18n/i18n';
|
|
7
|
+
import { setConsent } from '../services/profile/consent.svelte';
|
|
8
|
+
|
|
9
|
+
let { lang }: { lang: string } = $props();
|
|
10
|
+
|
|
11
|
+
// optional link to the shop's privacy policy
|
|
12
|
+
const privacyUrl: string | undefined = window?.wineaterData?.PRIVACY_URL;
|
|
13
|
+
|
|
14
|
+
const t = (key: string) => getTranslation({ key, lang });
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<div class="wr-Consent" role="region" aria-label="Cookies">
|
|
18
|
+
<p class="wr-Consent-text">
|
|
19
|
+
{t('consentText')}
|
|
20
|
+
{#if privacyUrl}
|
|
21
|
+
<a class="wr-Consent-link" href={privacyUrl} target="_blank" rel="noopener noreferrer">
|
|
22
|
+
{t('consentPrivacy')}
|
|
23
|
+
</a>
|
|
24
|
+
{/if}
|
|
25
|
+
</p>
|
|
26
|
+
<div class="wr-Consent-actions">
|
|
27
|
+
<Action variant="outlined" handleClick={() => setConsent('denied')}>
|
|
28
|
+
{t('consentDecline')}
|
|
29
|
+
</Action>
|
|
30
|
+
<Action variant="primary" handleClick={() => setConsent('granted')}>
|
|
31
|
+
{t('consentAccept')}
|
|
32
|
+
</Action>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<style>
|
|
37
|
+
.wr-Consent {
|
|
38
|
+
display: flex;
|
|
39
|
+
align-items: center;
|
|
40
|
+
justify-content: space-between;
|
|
41
|
+
gap: 16px;
|
|
42
|
+
margin: 12px 0 0;
|
|
43
|
+
padding: 12px 16px;
|
|
44
|
+
border-radius: 12px;
|
|
45
|
+
background: #f6f4f2;
|
|
46
|
+
font-size: 13px;
|
|
47
|
+
line-height: 1.4;
|
|
48
|
+
color: #333;
|
|
49
|
+
}
|
|
50
|
+
.wr-Consent-text {
|
|
51
|
+
margin: 0;
|
|
52
|
+
flex: 1;
|
|
53
|
+
}
|
|
54
|
+
.wr-Consent-link {
|
|
55
|
+
color: inherit;
|
|
56
|
+
text-decoration: underline;
|
|
57
|
+
margin-left: 4px;
|
|
58
|
+
}
|
|
59
|
+
.wr-Consent-actions {
|
|
60
|
+
display: flex;
|
|
61
|
+
gap: 8px;
|
|
62
|
+
flex-shrink: 0;
|
|
63
|
+
}
|
|
64
|
+
@media (max-width: 640px) {
|
|
65
|
+
.wr-Consent {
|
|
66
|
+
flex-direction: column;
|
|
67
|
+
align-items: stretch;
|
|
68
|
+
}
|
|
69
|
+
.wr-Consent-actions {
|
|
70
|
+
justify-content: flex-end;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
</style>
|
|
@@ -37,6 +37,8 @@
|
|
|
37
37
|
? 'https://czvgkhagwvmknscoerfy.supabase.co/storage/v1/object/public/static-media/divino_logo.svg?x=5'
|
|
38
38
|
: 'https://czvgkhagwvmknscoerfy.supabase.co/storage/v1/object/public/static-media/logo_medium_gray.svg'}
|
|
39
39
|
alt="Wineater Logo"
|
|
40
|
+
loading="lazy"
|
|
41
|
+
decoding="async"
|
|
40
42
|
/>
|
|
41
43
|
<span class="wr-Footer-text wr-Footer-text--sommelier">
|
|
42
44
|
{getTranslation({ key: 'ai_sommelier', lang: appLanguage })}
|
|
@@ -138,7 +138,7 @@
|
|
|
138
138
|
width: 24px;
|
|
139
139
|
height: 24px;
|
|
140
140
|
flex: 0 0 auto;
|
|
141
|
-
background-image: url(
|
|
141
|
+
background-image: url("data:image/svg+xml,%3Csvg%20width=%2224%22%20height=%2225%22%20viewBox=%220%200%2024%2025%22%20fill=%22none%22%20xmlns=%22http://www.w3.org/2000/svg%22%3E%3Cpath%20d=%22M11%2019.3884C15.4183%2019.3884%2019%2015.8067%2019%2011.3884C19%206.97015%2015.4183%203.38843%2011%203.38843C6.58172%203.38843%203%206.97015%203%2011.3884C3%2015.8067%206.58172%2019.3884%2011%2019.3884Z%22%20stroke=%22%238250D2%22%20stroke-width=%222%22%20stroke-linecap=%22round%22%20stroke-linejoin=%22round%22/%3E%3Cpath%20d=%22M21%2021.3884L16.7%2017.0884%22%20stroke=%22%238250D2%22%20stroke-width=%222%22%20stroke-linecap=%22round%22%20stroke-linejoin=%22round%22/%3E%3C/svg%3E");
|
|
142
142
|
background-repeat: no-repeat;
|
|
143
143
|
background-size: contain;
|
|
144
144
|
z-index: 2;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<!-- Placeholder cards that match the WineTile layout so the grid does not jump when real data arrives -->
|
|
2
|
+
<div class="wr-Skeleton" aria-hidden="true">
|
|
3
|
+
{#each [0, 1, 2, 3] as n (n)}
|
|
4
|
+
<div class="wr-Skeleton-tile">
|
|
5
|
+
<div class="wr-Skeleton-block wr-Skeleton-image"></div>
|
|
6
|
+
<div class="wr-Skeleton-block wr-Skeleton-price"></div>
|
|
7
|
+
<div class="wr-Skeleton-block wr-Skeleton-title"></div>
|
|
8
|
+
<div class="wr-Skeleton-block wr-Skeleton-title wr-Skeleton-title--short"></div>
|
|
9
|
+
<div class="wr-Skeleton-block wr-Skeleton-button"></div>
|
|
10
|
+
</div>
|
|
11
|
+
{/each}
|
|
12
|
+
</div>
|
|
13
|
+
|
|
14
|
+
<style>
|
|
15
|
+
.wr-Skeleton {
|
|
16
|
+
display: grid;
|
|
17
|
+
grid-template-columns: repeat(4, 1fr);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.wr-Skeleton-tile {
|
|
21
|
+
display: flex;
|
|
22
|
+
flex-direction: column;
|
|
23
|
+
align-items: center;
|
|
24
|
+
gap: 10px;
|
|
25
|
+
padding: 16px 8px;
|
|
26
|
+
border-right: 2px solid #f6f7fd;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
.wr-Skeleton-tile:last-child {
|
|
30
|
+
border-right: none;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
.wr-Skeleton-block {
|
|
34
|
+
border-radius: 6px;
|
|
35
|
+
background: linear-gradient(90deg, #f1f2f8 25%, #f8f9fc 50%, #f1f2f8 75%);
|
|
36
|
+
background-size: 200% 100%;
|
|
37
|
+
animation: wr-shimmer 1.2s linear infinite;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.wr-Skeleton-image {
|
|
41
|
+
width: 60%;
|
|
42
|
+
height: 200px;
|
|
43
|
+
margin-bottom: 6px;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.wr-Skeleton-price {
|
|
47
|
+
width: 40px;
|
|
48
|
+
height: 16px;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.wr-Skeleton-title {
|
|
52
|
+
width: 80%;
|
|
53
|
+
height: 14px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.wr-Skeleton-title--short {
|
|
57
|
+
width: 55%;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.wr-Skeleton-button {
|
|
61
|
+
width: 96px;
|
|
62
|
+
height: 36px;
|
|
63
|
+
margin-top: 14px;
|
|
64
|
+
border-radius: 10px;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@keyframes wr-shimmer {
|
|
68
|
+
from {
|
|
69
|
+
background-position: 200% 0;
|
|
70
|
+
}
|
|
71
|
+
to {
|
|
72
|
+
background-position: -200% 0;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
@media (prefers-reduced-motion: reduce) {
|
|
77
|
+
.wr-Skeleton-block {
|
|
78
|
+
animation: none;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@media screen and (max-width: 768px) {
|
|
83
|
+
.wr-Skeleton {
|
|
84
|
+
display: flex;
|
|
85
|
+
gap: 8px;
|
|
86
|
+
overflow: hidden;
|
|
87
|
+
padding: 8px 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.wr-Skeleton-tile {
|
|
91
|
+
flex: 0 0 240px;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
</style>
|
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
import { appStore } from '../services/store/app-store.svelte';
|
|
7
7
|
import Action from './ui/Action.svelte';
|
|
8
8
|
|
|
9
|
+
// Desktop grid has 4 columns: the first row is above the fold
|
|
10
|
+
const FIRST_ROW_SIZE = 4;
|
|
11
|
+
|
|
9
12
|
let lang = $derived(appStore.getAppLanguage());
|
|
10
13
|
|
|
11
14
|
let {
|
|
@@ -95,7 +98,14 @@
|
|
|
95
98
|
>
|
|
96
99
|
<header class="wr-WineTile-header">
|
|
97
100
|
{#if wine.image}
|
|
98
|
-
<img
|
|
101
|
+
<img
|
|
102
|
+
src={wine.image}
|
|
103
|
+
alt={wine.title}
|
|
104
|
+
class="wr-WineTile-image"
|
|
105
|
+
loading={index < FIRST_ROW_SIZE ? 'eager' : 'lazy'}
|
|
106
|
+
fetchpriority={index < FIRST_ROW_SIZE ? 'high' : 'auto'}
|
|
107
|
+
decoding="async"
|
|
108
|
+
/>
|
|
99
109
|
{/if}
|
|
100
110
|
{#if wine.total}
|
|
101
111
|
<div class="wr-WineTile-price">
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { trackingAllowed } from '../profile/consent.svelte';
|
|
2
|
+
|
|
1
3
|
function generateUUID(): string {
|
|
2
4
|
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
|
|
3
5
|
const r = Math.random() * 16 | 0,
|
|
@@ -39,6 +41,9 @@ export function setUniqueUserCookie(): string {
|
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
export function trackWidgetEvent(event_type: string, store_id, data = {}, meta = {}) {
|
|
44
|
+
// Аналитика идентифицирует посетителя (uniqueUserID, user agent, экран) —
|
|
45
|
+
// в магазинах с requires_cookie_consent только после «Принять».
|
|
46
|
+
if (!trackingAllowed()) return;
|
|
42
47
|
const endpoint = 'https://admin-api.wineater.com/api/widget-events';
|
|
43
48
|
|
|
44
49
|
// Ensure we have a unique user ID
|