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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wineater-bot",
3
- "version": "4.16.1",
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, setUniqueUserCookie, trackWidgetEvent } from './services/analytics/analytics';
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 Loader from './components/Loader.svelte';
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
- async function loadInstantRecommendations() {
107
- isLoading = true;
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
- results = await wineaterApi.getInstantRecommendations();
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
- setSearchError();
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
- query = searchQuery.trim() + ';';
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 wineaterApi.getMoreWineRecommendations(
207
- lastSearchedQuery,
208
- seenIds
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 wineaterApi.getSimilarWines(wine.id);
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(async () => {
362
- // initialize store first
363
- await appStore.initializeStore();
364
- if (!isStoreInitialized) return;
435
+ onMount(() => {
436
+ // Close popups on outside click
437
+ document.addEventListener('click', handleClickOutside);
365
438
 
366
- uiConfig = appStore.getUiConfig();
367
- if (uiConfig.customCss) {
368
- generateInlineStyle(uiConfig.customCss);
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
- await loadInstantRecommendations();
372
- trackWidgetEvent(WIDGET_EVENTS.WIDGET_INITIALIZED, storeId);
373
- initialized = true;
445
+ await configReady;
446
+ if (!isStoreInitialized) {
447
+ configFailed = true;
448
+ return;
449
+ }
374
450
 
375
- // Add global click listener for closing popups
376
- document.addEventListener('click', handleClickOutside);
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('https://czvgkhagwvmknscoerfy.supabase.co/storage/v1/object/public/static-media//search.svg?x=1');
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 src={wine.image} alt={wine.title} class="wr-WineTile-image" />
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