wineater-bot 4.16.0 → 4.19.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/settings.local.json +4 -1
- package/CLAUDE.md +11 -2
- package/dist/wineater-chatbot.mjs +38 -10204
- package/dist/wineater-chatbot.umd.js +28 -41
- package/package.json +4 -6
- package/src/App.svelte +90 -43
- 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 +12 -2
- package/src/services/api/api.ts +11 -1
- package/src/services/cache.ts +28 -0
- package/src/services/store/app-store.svelte.ts +36 -13
- 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.19.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,19 +1,19 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { setCookie, setUniqueUserCookie, trackWidgetEvent } from './services/analytics/analytics';
|
|
3
|
-
import Tracker from '@openreplay/tracker';
|
|
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
8
|
import Header from './components/Header.svelte';
|
|
9
9
|
import Footer from './components/Footer.svelte';
|
|
10
10
|
import Search from './components/Search.svelte';
|
|
11
|
-
import
|
|
11
|
+
import SkeletonTiles from './components/SkeletonTiles.svelte';
|
|
12
12
|
import WineTile from './components/WineTile.svelte';
|
|
13
13
|
import Action from './components/ui/Action.svelte';
|
|
14
14
|
import { appStore } from './services/store/app-store.svelte';
|
|
15
15
|
import { getTranslation, translateTexts } from './services/i18n/i18n';
|
|
16
|
-
import { generateInlineStyle } from './services/ui-config/utils';
|
|
16
|
+
import { generateInlineStyle, preconnectFontHosts } from './services/ui-config/utils';
|
|
17
17
|
|
|
18
18
|
function initCookies() {
|
|
19
19
|
setCookie('chatbotToken', window.wineaterData?.CLIENT_TOKEN || DEMO_TOKEN, 365);
|
|
@@ -21,15 +21,20 @@
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
const DEMO_TOKEN = 'HeUw6IXuhvz6tRuXdPEGEbj3SGQ3Q2YtEc1Z8jd41Kg9LtyPb4Epu0to31SYIadG';
|
|
24
|
-
const tracker = new Tracker({ projectKey: 'PA8j9fAi49i6g2QnvpNs', __DISABLE_SECURE_MODE: true });
|
|
25
|
-
tracker.start();
|
|
26
24
|
initCookies();
|
|
25
|
+
preconnectFontHosts();
|
|
26
|
+
|
|
27
|
+
const INSTANT_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
|
|
28
|
+
const instantCacheKey = `${cacheScope}:instant`;
|
|
27
29
|
|
|
28
30
|
const widgetType = window?.wineaterData?.WIDGET_TYPE || 'store';
|
|
29
31
|
const countryCode = window?.wineaterData?.COUNTRY_CODE || null;
|
|
30
32
|
const widgetWrapper = window?.wineaterData?.WIDGET_WRAPPER || 'wineater-widget-conteiner';
|
|
31
33
|
|
|
32
34
|
let initialized = $state(false);
|
|
35
|
+
let configFailed = $state(false);
|
|
36
|
+
// Set once the user searches/clicks, so a late instant response never overwrites their results
|
|
37
|
+
let userActed = false;
|
|
33
38
|
let isLoading = $state(false);
|
|
34
39
|
let isLoadingMore = $state(false);
|
|
35
40
|
let results = $state<RecommendedWine[] | Wine[]>([]);
|
|
@@ -103,15 +108,30 @@
|
|
|
103
108
|
return translatedResults;
|
|
104
109
|
}
|
|
105
110
|
|
|
106
|
-
|
|
107
|
-
|
|
111
|
+
// Stale-while-revalidate: cached wines are painted immediately and the fresh
|
|
112
|
+
// response only refreshes the cache for the next visit, so the grid the
|
|
113
|
+
// visitor is already looking at never swaps under their cursor.
|
|
114
|
+
async function loadInstantRecommendations(configReady: Promise<void>) {
|
|
115
|
+
const cached = readCache<Wine[]>(instantCacheKey, INSTANT_CACHE_MAX_AGE_MS);
|
|
116
|
+
const hasCached = !!cached?.length;
|
|
117
|
+
if (hasCached) {
|
|
118
|
+
results = cached;
|
|
119
|
+
} else {
|
|
120
|
+
isLoading = true;
|
|
121
|
+
}
|
|
108
122
|
try {
|
|
109
|
-
|
|
123
|
+
const fresh = await wineaterApi.getInstantRecommendations();
|
|
124
|
+
if (fresh.length > 0) writeCache(instantCacheKey, fresh);
|
|
125
|
+
if (!hasCached && !userActed) results = fresh;
|
|
110
126
|
} catch (error) {
|
|
111
127
|
console.error('[wineater] instant recommendations error:', error);
|
|
112
|
-
|
|
128
|
+
if (!hasCached && !userActed) {
|
|
129
|
+
// the message is translated, which needs the store language
|
|
130
|
+
await configReady;
|
|
131
|
+
setSearchError();
|
|
132
|
+
}
|
|
113
133
|
} finally {
|
|
114
|
-
isLoading = false;
|
|
134
|
+
if (!userActed) isLoading = false;
|
|
115
135
|
}
|
|
116
136
|
}
|
|
117
137
|
|
|
@@ -128,12 +148,27 @@
|
|
|
128
148
|
|
|
129
149
|
async function handleSearch(event) {
|
|
130
150
|
event.preventDefault();
|
|
151
|
+
userActed = true;
|
|
131
152
|
|
|
132
153
|
let query = '';
|
|
133
154
|
try {
|
|
134
155
|
isLoading = true;
|
|
135
156
|
clearError();
|
|
136
157
|
|
|
158
|
+
query = searchQuery.trim() + ';';
|
|
159
|
+
lastSearchedQuery = query;
|
|
160
|
+
try {
|
|
161
|
+
trackWidgetEvent(WIDGET_EVENTS.SEARCH_PERFORMED, storeId, { query });
|
|
162
|
+
} catch (error) {
|
|
163
|
+
console.error('Error tracking search event:', error);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Запрос уходит сразу, карточки уезжают вверх, пока он в полёте.
|
|
167
|
+
// Пустой catch нужен, чтобы отказ запроса во время анимации не стал
|
|
168
|
+
// unhandled rejection: ошибку обработает await ниже.
|
|
169
|
+
const request = wineaterApi.getWineRecommendationsSearch(query);
|
|
170
|
+
request.catch(() => {});
|
|
171
|
+
|
|
137
172
|
// шаг 1: плавно поднять карточки
|
|
138
173
|
await animateClearAll(220);
|
|
139
174
|
|
|
@@ -143,15 +178,7 @@
|
|
|
143
178
|
hasMore = false;
|
|
144
179
|
seenIds = [];
|
|
145
180
|
|
|
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);
|
|
181
|
+
const searchResults = await request;
|
|
155
182
|
|
|
156
183
|
if (!searchResults || searchResults.length === 0) {
|
|
157
184
|
results = [];
|
|
@@ -186,6 +213,7 @@
|
|
|
186
213
|
|
|
187
214
|
async function handleShowMore() {
|
|
188
215
|
if (isLoadingMore || !hasMore) return;
|
|
216
|
+
userActed = true;
|
|
189
217
|
// Выдача заменяется целиком, поэтому держим предыдущую под рукой: если
|
|
190
218
|
// новых вин не нашлось или запрос упал, покупатель не должен остаться
|
|
191
219
|
// перед пустой сеткой.
|
|
@@ -198,15 +226,15 @@
|
|
|
198
226
|
// показанное за сессию — иначе следующий клик вернёт те же вина.
|
|
199
227
|
seenIds = [...new Set([...seenIds, ...results.map((w) => w.id)])];
|
|
200
228
|
|
|
201
|
-
// Тот же жест, что и при поиске:
|
|
229
|
+
// Тот же жест, что и при поиске: запрос уходит сразу, карточки уезжают
|
|
230
|
+
// вверх, дальше скелетон
|
|
231
|
+
const request = wineaterApi.getMoreWineRecommendations(lastSearchedQuery, seenIds);
|
|
232
|
+
request.catch(() => {});
|
|
202
233
|
await animateClearAll(220);
|
|
203
234
|
results = [];
|
|
204
235
|
isAnimating = false;
|
|
205
236
|
|
|
206
|
-
const { recommendations, hasMore: more } = await
|
|
207
|
-
lastSearchedQuery,
|
|
208
|
-
seenIds
|
|
209
|
-
);
|
|
237
|
+
const { recommendations, hasMore: more } = await request;
|
|
210
238
|
trackWidgetEvent(WIDGET_EVENTS.SHOW_MORE_CLICKED, storeId, {
|
|
211
239
|
query: lastSearchedQuery,
|
|
212
240
|
results_count: recommendations.length
|
|
@@ -224,6 +252,7 @@
|
|
|
224
252
|
}
|
|
225
253
|
|
|
226
254
|
async function handleSimilarClick(wine: Wine | RecommendedWine) {
|
|
255
|
+
userActed = true;
|
|
227
256
|
try {
|
|
228
257
|
isLoading = true;
|
|
229
258
|
clearError();
|
|
@@ -232,13 +261,15 @@
|
|
|
232
261
|
wine_title: wine.title
|
|
233
262
|
});
|
|
234
263
|
|
|
264
|
+
const request = wineaterApi.getSimilarWines(wine.id);
|
|
265
|
+
request.catch(() => {});
|
|
235
266
|
await animateClearAll(220);
|
|
236
267
|
results = [];
|
|
237
268
|
isAnimating = false;
|
|
238
269
|
hasMore = false;
|
|
239
270
|
selectedWineId = null;
|
|
240
271
|
|
|
241
|
-
const similarWines = await
|
|
272
|
+
const similarWines = await request;
|
|
242
273
|
searchQuery = wine.title;
|
|
243
274
|
lastSearchedQuery = '';
|
|
244
275
|
|
|
@@ -358,22 +389,29 @@
|
|
|
358
389
|
}
|
|
359
390
|
});
|
|
360
391
|
|
|
361
|
-
onMount(
|
|
362
|
-
//
|
|
363
|
-
|
|
364
|
-
if (!isStoreInitialized) return;
|
|
392
|
+
onMount(() => {
|
|
393
|
+
// Close popups on outside click
|
|
394
|
+
document.addEventListener('click', handleClickOutside);
|
|
365
395
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
396
|
+
(async () => {
|
|
397
|
+
// Config and the first wines do not depend on each other (the token is
|
|
398
|
+
// known up front), so both requests start together.
|
|
399
|
+
const configReady = appStore.initializeStore();
|
|
400
|
+
loadInstantRecommendations(configReady);
|
|
370
401
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
402
|
+
await configReady;
|
|
403
|
+
if (!isStoreInitialized) {
|
|
404
|
+
configFailed = true;
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
374
407
|
|
|
375
|
-
|
|
376
|
-
|
|
408
|
+
if (uiConfig.customCss) {
|
|
409
|
+
generateInlineStyle(uiConfig.customCss);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
initialized = true;
|
|
413
|
+
trackWidgetEvent(WIDGET_EVENTS.WIDGET_INITIALIZED, storeId);
|
|
414
|
+
})();
|
|
377
415
|
|
|
378
416
|
return () => {
|
|
379
417
|
document.removeEventListener('click', handleClickOutside);
|
|
@@ -394,10 +432,6 @@
|
|
|
394
432
|
|
|
395
433
|
<Search bind:searchQuery onSubmit={handleSearch} {initialized} />
|
|
396
434
|
|
|
397
|
-
{#if isLoading}
|
|
398
|
-
<Loader />
|
|
399
|
-
{/if}
|
|
400
|
-
|
|
401
435
|
<div class="wr-App-content">
|
|
402
436
|
{#if errorMessage}
|
|
403
437
|
<div class="wr-App-error">
|
|
@@ -430,11 +464,19 @@
|
|
|
430
464
|
</Action>
|
|
431
465
|
</div>
|
|
432
466
|
{/if}
|
|
467
|
+
{:else if isLoading}
|
|
468
|
+
<SkeletonTiles />
|
|
433
469
|
{/if}
|
|
434
470
|
</div>
|
|
435
471
|
|
|
436
472
|
<Footer {countryCode} />
|
|
437
473
|
</div>
|
|
474
|
+
{:else if !configFailed}
|
|
475
|
+
<!-- Shell with the same footprint as the widget while the store config loads -->
|
|
476
|
+
<div class="wr-App wr-App--loading" aria-busy="true">
|
|
477
|
+
<div class="wr-App-loadingTop"></div>
|
|
478
|
+
<SkeletonTiles />
|
|
479
|
+
</div>
|
|
438
480
|
{/if}
|
|
439
481
|
|
|
440
482
|
<style>
|
|
@@ -474,6 +516,11 @@
|
|
|
474
516
|
position: relative;
|
|
475
517
|
}
|
|
476
518
|
|
|
519
|
+
.wr-App-loadingTop {
|
|
520
|
+
/* roughly the height of Header + Search, so tiles do not jump once they render */
|
|
521
|
+
height: 150px;
|
|
522
|
+
}
|
|
523
|
+
|
|
477
524
|
.wr-App-picture {
|
|
478
525
|
display: flex;
|
|
479
526
|
justify-content: center;
|
|
@@ -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">
|
|
@@ -109,7 +119,7 @@
|
|
|
109
119
|
</div>
|
|
110
120
|
{/if}
|
|
111
121
|
|
|
112
|
-
<h3 class="wr-WineTile-title">{wine.
|
|
122
|
+
<h3 class="wr-WineTile-title">{wine.title}</h3>
|
|
113
123
|
</header>
|
|
114
124
|
|
|
115
125
|
{#if joinParams(wine, ['type', 'country', 'region', 'grape'])}
|
package/src/services/api/api.ts
CHANGED
|
@@ -166,4 +166,14 @@ class WineaterApi {
|
|
|
166
166
|
const DEMO_TOKEN = 'HeUw6IXuhvz6tRuXdPEGEbj3SGQ3Q2YtEc1Z8jd41Kg9LtyPb4Epu0to31SYIadG';
|
|
167
167
|
const token = window?.wineaterData?.CLIENT_TOKEN || DEMO_TOKEN;
|
|
168
168
|
export const apiVersion = String(window?.wineaterData?.apiVersion || '1');
|
|
169
|
-
export const wineaterApi = new WineaterApi(token, apiVersion)
|
|
169
|
+
export const wineaterApi = new WineaterApi(token, apiVersion)
|
|
170
|
+
|
|
171
|
+
// Namespaces cached responses per client token and API version, so two widgets
|
|
172
|
+
// on one origin (or a token switch) never see each other's data. Not a secret:
|
|
173
|
+
// only a short non-reversible hash of the token ends up in the storage key.
|
|
174
|
+
function hashString(input: string): string {
|
|
175
|
+
let h = 5381;
|
|
176
|
+
for (let i = 0; i < input.length; i++) h = ((h << 5) + h + input.charCodeAt(i)) | 0;
|
|
177
|
+
return (h >>> 0).toString(36);
|
|
178
|
+
}
|
|
179
|
+
export const cacheScope = `${hashString(token)}:${apiVersion}`;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Tiny stale-while-revalidate cache backed by localStorage.
|
|
2
|
+
// Storage can throw or be unavailable (private mode, blocked cookies, embeds),
|
|
3
|
+
// so every access is wrapped and the widget works the same without it.
|
|
4
|
+
|
|
5
|
+
const PREFIX = 'wineater:v1:';
|
|
6
|
+
|
|
7
|
+
type Entry<T> = { savedAt: number; value: T };
|
|
8
|
+
|
|
9
|
+
export function readCache<T>(key: string, maxAgeMs: number): T | null {
|
|
10
|
+
try {
|
|
11
|
+
const raw = window.localStorage.getItem(PREFIX + key);
|
|
12
|
+
if (!raw) return null;
|
|
13
|
+
const entry = JSON.parse(raw) as Entry<T>;
|
|
14
|
+
if (Date.now() - entry.savedAt > maxAgeMs) return null;
|
|
15
|
+
return entry.value;
|
|
16
|
+
} catch {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function writeCache<T>(key: string, value: T): void {
|
|
22
|
+
try {
|
|
23
|
+
const entry: Entry<T> = { savedAt: Date.now(), value };
|
|
24
|
+
window.localStorage.setItem(PREFIX + key, JSON.stringify(entry));
|
|
25
|
+
} catch {
|
|
26
|
+
// quota exceeded or storage disabled: caching is best-effort
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { wineaterApi } from "../api/api";
|
|
1
|
+
import { wineaterApi, cacheScope } from "../api/api";
|
|
2
|
+
import { readCache, writeCache } from "../cache";
|
|
2
3
|
import type { CurrencyCode, LanguageCode } from "../i18n/i18n";
|
|
3
4
|
import type { StoreConfig } from "./types";
|
|
4
5
|
import { deserializeStoreConfig } from "./deserialize";
|
|
@@ -12,6 +13,8 @@ export type AppState = {
|
|
|
12
13
|
uiConfig: ProjectUiConfig;
|
|
13
14
|
};
|
|
14
15
|
|
|
16
|
+
const STORE_CONFIG_MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
17
|
+
|
|
15
18
|
let state = $state<AppState>({
|
|
16
19
|
storeConfig: null,
|
|
17
20
|
currentLanguage: 'en',
|
|
@@ -56,21 +59,41 @@ export const appStore = {
|
|
|
56
59
|
getCurrency(): CurrencyCode {
|
|
57
60
|
return state.storeConfig?.currency ?? 'USD';
|
|
58
61
|
},
|
|
62
|
+
applyStoreConfig(config: unknown) {
|
|
63
|
+
state.storeConfig = deserializeStoreConfig(config as Parameters<typeof deserializeStoreConfig>[0]);
|
|
64
|
+
state.currentLanguage = state.storeConfig.lang as LanguageCode;
|
|
65
|
+
const urlLang = this.getLanguageFromURL();
|
|
66
|
+
if (urlLang && state.storeConfig.available_languages.includes(urlLang)) {
|
|
67
|
+
this.setStoreLanguage(urlLang);
|
|
68
|
+
} else {
|
|
69
|
+
// fallback to store default language
|
|
70
|
+
this.setStoreLanguage(state.storeConfig.lang as LanguageCode);
|
|
71
|
+
}
|
|
72
|
+
this.setUiConfig(getProjectUiConfig(state.storeConfig.id));
|
|
73
|
+
state.isConfigLoaded = true;
|
|
74
|
+
},
|
|
75
|
+
// Stale-while-revalidate: a cached config is applied synchronously (first
|
|
76
|
+
// paint without waiting for the network) and refreshed in the background
|
|
77
|
+
// for the next visit. Without a cache we wait for the network as before.
|
|
59
78
|
async initializeStore() {
|
|
60
|
-
|
|
79
|
+
const cacheKey = `${cacheScope}:store-config`;
|
|
80
|
+
const fetchAndCache = async () => {
|
|
61
81
|
const config = await wineaterApi.getStoreConfig();
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
82
|
+
writeCache(cacheKey, config);
|
|
83
|
+
return config;
|
|
84
|
+
};
|
|
85
|
+
try {
|
|
86
|
+
const cached = readCache<unknown>(cacheKey, STORE_CONFIG_MAX_AGE_MS);
|
|
87
|
+
if (cached) {
|
|
88
|
+
try {
|
|
89
|
+
this.applyStoreConfig(cached);
|
|
90
|
+
fetchAndCache().catch(() => {});
|
|
91
|
+
return;
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.warn('Ignoring invalid cached store config:', error);
|
|
94
|
+
}
|
|
70
95
|
}
|
|
71
|
-
this.
|
|
72
|
-
|
|
73
|
-
state.isConfigLoaded = true;
|
|
96
|
+
this.applyStoreConfig(await fetchAndCache());
|
|
74
97
|
} catch (error) {
|
|
75
98
|
console.error('Failed to initialize store:', error);
|
|
76
99
|
state.isConfigLoaded = false;
|
|
@@ -1,12 +1,40 @@
|
|
|
1
|
+
const IMPORT_RE = /@import\s+url\(\s*['"]?([^'")]+)['"]?\s*\)\s*;?/g;
|
|
2
|
+
|
|
3
|
+
function addLink(rel: string, href: string, crossOrigin = false) {
|
|
4
|
+
const link = document.createElement('link');
|
|
5
|
+
link.rel = rel;
|
|
6
|
+
link.href = href;
|
|
7
|
+
if (crossOrigin) link.crossOrigin = 'anonymous';
|
|
8
|
+
document.head.appendChild(link);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Opens the connections to the font hosts before the config tells us which
|
|
12
|
+
// font the project uses, so the stylesheet and font file start downloading
|
|
13
|
+
// without a fresh DNS/TLS handshake.
|
|
14
|
+
export function preconnectFontHosts() {
|
|
15
|
+
if (document.querySelector('link[rel="preconnect"][href="https://fonts.googleapis.com"]')) return;
|
|
16
|
+
addLink('preconnect', 'https://fonts.googleapis.com');
|
|
17
|
+
addLink('preconnect', 'https://fonts.gstatic.com', true);
|
|
18
|
+
}
|
|
19
|
+
|
|
1
20
|
export function generateInlineStyle(cssString: string | null): string {
|
|
2
21
|
if (!cssString) {
|
|
3
22
|
return '';
|
|
4
23
|
}
|
|
5
24
|
const styleId = "custom-ui-config-style";
|
|
6
25
|
if (document.getElementById(styleId)) return;
|
|
26
|
+
|
|
27
|
+
// `@import` inside <style> is only discovered after that stylesheet is
|
|
28
|
+
// parsed, which chains the font download behind it. A <link> is fetched in
|
|
29
|
+
// parallel with the rest of the page.
|
|
30
|
+
const css = cssString.replace(IMPORT_RE, (_match, href: string) => {
|
|
31
|
+
addLink('stylesheet', href);
|
|
32
|
+
return '';
|
|
33
|
+
});
|
|
34
|
+
|
|
7
35
|
const style = document.createElement('style');
|
|
8
36
|
style.id = styleId;
|
|
9
|
-
style.innerHTML =
|
|
37
|
+
style.innerHTML = css;
|
|
10
38
|
document.head.appendChild(style);
|
|
11
39
|
return styleId;
|
|
12
|
-
}
|
|
40
|
+
}
|
package/vite.config.js
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
import devtoolsJson from 'vite-plugin-devtools-json';
|
|
2
2
|
import { defineConfig } from 'vitest/config';
|
|
3
|
+
import { transformWithEsbuild } from 'vite';
|
|
3
4
|
import { svelte } from '@sveltejs/vite-plugin-svelte';
|
|
4
5
|
|
|
6
|
+
// Vite skips whitespace minification for `es` library output (to keep tree-shaking
|
|
7
|
+
// annotations for downstream bundlers). The widget is a final artifact loaded by a
|
|
8
|
+
// <script> tag, so minify it ourselves: the unminified ESM bundle was ~40% larger.
|
|
9
|
+
const minifyEsmBundle = {
|
|
10
|
+
name: 'minify-esm-bundle',
|
|
11
|
+
apply: 'build',
|
|
12
|
+
// generateBundle runs after every renderChunk, including Vite's own esbuild
|
|
13
|
+
// step that re-prints the code with indentation for `es` library builds
|
|
14
|
+
async generateBundle(options, bundle) {
|
|
15
|
+
if (options.format !== 'es') return;
|
|
16
|
+
for (const chunk of Object.values(bundle)) {
|
|
17
|
+
if (chunk.type !== 'chunk') continue;
|
|
18
|
+
const result = await transformWithEsbuild(chunk.code, chunk.fileName, { minify: true, target: 'es2020' });
|
|
19
|
+
chunk.code = result.code;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
|
|
5
24
|
export default defineConfig({
|
|
6
25
|
server: {
|
|
7
26
|
port: 3001 // Customize the port if necessary
|
|
@@ -57,8 +76,8 @@ export default defineConfig({
|
|
|
57
76
|
},
|
|
58
77
|
plugins: [
|
|
59
78
|
devtoolsJson(),
|
|
79
|
+
minifyEsmBundle,
|
|
60
80
|
svelte({
|
|
61
|
-
compilerOptions: { hydratable: true },
|
|
62
81
|
emitCss: false
|
|
63
82
|
})
|
|
64
83
|
]
|