wineater-bot 3.9.4 → 4.0.0-beta.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/.prettierignore +9 -0
- package/.prettierrc +16 -0
- package/README.md +86 -0
- package/dist/wineater-chatbot.css +1 -0
- package/dist/wineater-chatbot.mjs +6493 -5129
- package/dist/wineater-chatbot.umd.js +40 -985
- package/docs/SVELTE.md +146 -0
- package/e2e/demo.test.ts +6 -0
- package/eslint.config.js +41 -0
- package/index.html +11 -3
- package/package.json +30 -5
- package/playwright.config.ts +9 -0
- package/pnpm-workspace.yaml +2 -0
- package/src/App.svelte +394 -0
- package/src/App.svelte.test.ts +13 -0
- package/src/components/Footer.svelte +131 -0
- package/src/components/Header.svelte +92 -0
- package/src/components/Loader.svelte +62 -0
- package/src/components/Search.svelte +188 -0
- package/src/components/WinePopup.svelte +192 -0
- package/src/components/WineTile.svelte +331 -0
- package/src/demo.spec.ts +13 -0
- package/src/index.js +4 -36
- package/src/services/analytics/analytics.ts +103 -0
- package/src/{js/widget/widget-events.js → services/analytics/constants.ts} +0 -2
- package/src/services/api/api.ts +112 -0
- package/src/services/api/deserialize.ts +102 -0
- package/src/services/i18n/i18n.ts +134 -0
- package/src/services/i18n/translations.json +521 -0
- package/src/services/store/app-store.svelte.ts +84 -0
- package/tsconfig.json +23 -0
- package/vite.config.js +69 -19
- package/vitest-setup-client.ts +2 -0
- package/src/js/analytics.js +0 -117
- package/src/js/api.js +0 -138
- package/src/js/i18n.js +0 -703
- package/src/js/widget/widget-language.js +0 -107
- package/src/js/widget/widget-search.js +0 -68
- package/src/js/widget/widget-styles.js +0 -855
- package/src/js/widget/widget-ui.js +0 -186
- package/src/js/widget.js +0 -241
package/docs/SVELTE.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# ADR: Migrating the Widget to Svelte
|
|
2
|
+
|
|
3
|
+
**Date:** 23.11.2025
|
|
4
|
+
**PR:** https://github.com/Wineater/wineater-bot/pull/1
|
|
5
|
+
|
|
6
|
+
## Context
|
|
7
|
+
|
|
8
|
+
Historically, the Wineater widget was written in plain JavaScript. The code was rewritten multiple times while product hypotheses were being tested. As a result:
|
|
9
|
+
|
|
10
|
+
- The widget became hard to maintain: the business logic was obscured by a large amount of DOM‑manipulation boilerplate.
|
|
11
|
+
- The codebase contained a lot of legacy, unused code.
|
|
12
|
+
- Making changes required deep knowledge of implementation details, raising the onboarding cost for new developers.
|
|
13
|
+
|
|
14
|
+
To reduce project-specific knowledge and simplify maintenance, we decided to move to a framework with:
|
|
15
|
+
|
|
16
|
+
- Clear documentation,
|
|
17
|
+
- Declarative UI,
|
|
18
|
+
- Built-in reactivity.
|
|
19
|
+
|
|
20
|
+
At the time of the decision:
|
|
21
|
+
|
|
22
|
+
- The widget must be embeddable on client sites (as a script) and stay very small in terms of JS bundle size.
|
|
23
|
+
- We need to be able to quickly experiment with UI and business logic without accumulating more low-level DOM boilerplate.
|
|
24
|
+
- Migration of services from JS to TS had already started, and a clear separation between business logic and the framework was important.
|
|
25
|
+
|
|
26
|
+
Changes made as part of this migration:
|
|
27
|
+
|
|
28
|
+
1. Removed unused code that was not referenced anywhere.
|
|
29
|
+
2. Improved the “wine” entity handling:
|
|
30
|
+
- Previously, the instant/search APIs returned different shapes (e.g. instant: `ID`, `Title`; search: `id`, `title`), which caused many conditional checks and branches in the code.
|
|
31
|
+
- Introduced deserialization in the data access layer, so the UI layer always works with a consistent wine entity.
|
|
32
|
+
3. Language switching no longer reloads the page; state is managed via a global store and Svelte reactivity.
|
|
33
|
+
4. Currency formatting is now done via the standard browser `Intl` API, reducing custom code and localization bugs.
|
|
34
|
+
5. All services were migrated from JS to TypeScript and rewritten to not depend on the framework (Svelte is used only at the UI layer).
|
|
35
|
+
6. Introduced the global `app-store` for API‑loaded config and helper methods to access it.
|
|
36
|
+
|
|
37
|
+
Current limitations / technical debt:
|
|
38
|
+
|
|
39
|
+
- `App.svelte` is too large and holds too many responsibilities.
|
|
40
|
+
- Popup translations and some animations (search placeholder) still manipulate the DOM directly instead of using Svelte’s reactive state.
|
|
41
|
+
- A significant amount of code was migrated “as is” from the old codebase to avoid blocking the migration; it still needs structural refactoring.
|
|
42
|
+
|
|
43
|
+
## Alternatives
|
|
44
|
+
|
|
45
|
+
Framework options considered:
|
|
46
|
+
|
|
47
|
+
1. **React**
|
|
48
|
+
- Pros:
|
|
49
|
+
- Already used in other products of the startup.
|
|
50
|
+
- Large ecosystem and community.
|
|
51
|
+
- Cons:
|
|
52
|
+
- Relatively heavy runtime and bundle size for a small embedded widget.
|
|
53
|
+
- DX (as of 2025) is not ideal for a lightweight embeddable widget.
|
|
54
|
+
- Typically requires more wiring (hooks, context, styling solutions), which leads to additional boilerplate.
|
|
55
|
+
|
|
56
|
+
2. **Vue**
|
|
57
|
+
- Pros:
|
|
58
|
+
- Good DX, relatively low learning curve.
|
|
59
|
+
- Lighter and faster than React at runtime.
|
|
60
|
+
- Cons:
|
|
61
|
+
- Still relatively heavy for a very small, embeddable widget.
|
|
62
|
+
- Keeps a runtime library in the bundle, increasing overall JS size.
|
|
63
|
+
|
|
64
|
+
3. **Svelte** (chosen)
|
|
65
|
+
- Pros:
|
|
66
|
+
- Compiles to “plain” JS and effectively disappears as a framework at runtime (no heavy runtime library).
|
|
67
|
+
- Much smaller JS payload, which is critical for a widget.
|
|
68
|
+
- Built-in reactivity, templating, and styling support, which reduces external boilerplate.
|
|
69
|
+
- Helps separate business logic (services, store) from the presentation layer.
|
|
70
|
+
- Cons:
|
|
71
|
+
- Smaller ecosystem compared to React/Vue.
|
|
72
|
+
- Fewer developers on the market with Svelte experience.
|
|
73
|
+
|
|
74
|
+
4. **Stay on plain JS** (status quo)
|
|
75
|
+
- Pros:
|
|
76
|
+
- No framework dependency.
|
|
77
|
+
- Maximum control over DOM and the bundle.
|
|
78
|
+
- Cons:
|
|
79
|
+
- High volume of DOM‑manipulation boilerplate.
|
|
80
|
+
- Hard to maintain, high cost of change.
|
|
81
|
+
- Low readability of business logic.
|
|
82
|
+
|
|
83
|
+
## Decision
|
|
84
|
+
|
|
85
|
+
We decided to rewrite the widget’s client-side code using **Svelte**, while keeping:
|
|
86
|
+
|
|
87
|
+
- Type‑safe business logic and services in TypeScript, independent from the framework.
|
|
88
|
+
- A lightweight, compiled UI layer without a heavy runtime.
|
|
89
|
+
|
|
90
|
+
Key aspects of the decision:
|
|
91
|
+
|
|
92
|
+
- **Framework:** Svelte is chosen as the best compromise between DX, performance, and bundle size for an embeddable widget.
|
|
93
|
+
- **Architecture:**
|
|
94
|
+
- Introduce a global `app-store` to keep config and shared state.
|
|
95
|
+
- Keep services (API calls, deserialization, etc.) outside of the UI layer and independent of Svelte.
|
|
96
|
+
- **Compatibility:**
|
|
97
|
+
- We still generate a UMD bundle for historical reasons. If UMD is no longer used in the future, this format can be removed from the build configuration.
|
|
98
|
+
- **Functional changes within this migration:**
|
|
99
|
+
- Unified wine data model.
|
|
100
|
+
- Language switching without full page reload.
|
|
101
|
+
- Currency formatting via `Intl`.
|
|
102
|
+
|
|
103
|
+
## Expectations
|
|
104
|
+
|
|
105
|
+
High-level expectations from this decision:
|
|
106
|
+
|
|
107
|
+
1. **Simpler maintenance and onboarding**
|
|
108
|
+
- Business logic is easier to understand thanks to declarative Svelte UI and reactivity.
|
|
109
|
+
- New developers can rely on framework documentation instead of project-specific DOM patterns.
|
|
110
|
+
|
|
111
|
+
2. **Smaller bundle and better performance for an embeddable widget**
|
|
112
|
+
- Svelte compiles away, reducing runtime overhead compared to heavier frameworks.
|
|
113
|
+
- Unified data model and removal of unused code reduce complexity and runtime branching.
|
|
114
|
+
|
|
115
|
+
3. **Faster and safer product experimentation**
|
|
116
|
+
- Clear separation between services, store, and UI makes it easier to iterate on features without destabilizing the widget.
|
|
117
|
+
- Shared architecture across clients enables more customized UIs with controlled complexity.
|
|
118
|
+
|
|
119
|
+
## Technical Debt and Planned Improvements
|
|
120
|
+
|
|
121
|
+
The following items are explicitly acknowledged as technical debt or future improvement areas, not as part of the initial migration scope:
|
|
122
|
+
|
|
123
|
+
1. **UI structure and DOM usage**
|
|
124
|
+
- `App.svelte` is too large and should be decomposed into smaller, focused components.
|
|
125
|
+
- Remaining direct DOM manipulations (popup translations, search placeholder animations, etc.) should be replaced with reactive Svelte patterns.
|
|
126
|
+
|
|
127
|
+
2. **State management**
|
|
128
|
+
- Reduce coupling between the global state and other modules.
|
|
129
|
+
- Split the single global store into several domain-specific stores (e.g., language, config, session, search results).
|
|
130
|
+
|
|
131
|
+
3. **Testing and typing**
|
|
132
|
+
- Increase end-to-end (E2E) test coverage for critical flows (embedding, search, language switching, recommendations).
|
|
133
|
+
- Gradually improve TypeScript typings at boundaries between services and UI to catch more issues at compile time.
|
|
134
|
+
|
|
135
|
+
4. **Localization**
|
|
136
|
+
- Implement a translation mechanism that does not require passing the locale explicitly through every call, relying instead on a centralized locale context/store.
|
|
137
|
+
|
|
138
|
+
5. **Logging and observability**
|
|
139
|
+
- Improve the logger so that events from local development do not pollute production analytics/logs (environment-aware logging).
|
|
140
|
+
|
|
141
|
+
6. **Configuration and deployment hygiene**
|
|
142
|
+
- Move hardcoded `CLIENT_TOKEN` into environment variables, so credentials are not hardcoded in the HTML files.
|
|
143
|
+
- Introduce project/environment configurations and store `WIDGET_TYPE` and similar parameters there instead of hardcoding them in HTML/JS.
|
|
144
|
+
- Remove per-environment HTML files currently added for each Vercel environment and replace them with a single entry point plus configuration-driven behavior.
|
|
145
|
+
|
|
146
|
+
These items should be addressed iteratively in follow-up work, once the Svelte migration is stable and validated in production.
|
package/e2e/demo.test.ts
ADDED
package/eslint.config.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import prettier from 'eslint-config-prettier';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { includeIgnoreFile } from '@eslint/compat';
|
|
4
|
+
import js from '@eslint/js';
|
|
5
|
+
import svelte from 'eslint-plugin-svelte';
|
|
6
|
+
import { defineConfig } from 'eslint/config';
|
|
7
|
+
import globals from 'globals';
|
|
8
|
+
import ts from 'typescript-eslint';
|
|
9
|
+
import svelteConfig from './svelte.config.js';
|
|
10
|
+
|
|
11
|
+
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
|
12
|
+
|
|
13
|
+
export default defineConfig(
|
|
14
|
+
includeIgnoreFile(gitignorePath),
|
|
15
|
+
js.configs.recommended,
|
|
16
|
+
...ts.configs.recommended,
|
|
17
|
+
...svelte.configs.recommended,
|
|
18
|
+
prettier,
|
|
19
|
+
...svelte.configs.prettier,
|
|
20
|
+
{
|
|
21
|
+
languageOptions: {
|
|
22
|
+
globals: { ...globals.browser, ...globals.node }
|
|
23
|
+
},
|
|
24
|
+
rules: {
|
|
25
|
+
// typescript-eslint strongly recommend that you do not use the no-undef lint rule on TypeScript projects.
|
|
26
|
+
// see: https://typescript-eslint.io/troubleshooting/faqs/eslint/#i-get-errors-from-the-no-undef-rule-about-global-variables-not-being-defined-even-though-there-are-no-typescript-errors
|
|
27
|
+
'no-undef': 'off'
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
|
32
|
+
languageOptions: {
|
|
33
|
+
parserOptions: {
|
|
34
|
+
projectService: true,
|
|
35
|
+
extraFileExtensions: ['.svelte'],
|
|
36
|
+
parser: ts.parser,
|
|
37
|
+
svelteConfig
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
);
|
package/index.html
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
<!DOCTYPE html>
|
|
2
2
|
<html lang="en">
|
|
3
|
+
|
|
3
4
|
<head>
|
|
4
5
|
<meta charset="UTF-8">
|
|
5
6
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
7
|
<title>Chatbot</title>
|
|
7
|
-
|
|
8
|
-
|
|
8
|
+
<!-- TODO: replace later with __WINEATER_WIDGET_DATA__ -->
|
|
9
|
+
<script>window.wineaterData = { CLIENT_TOKEN: 'l3yovIe7qdAkOCO9ZJMb0sOZLWJArwQkHkeC5Fj7IBox5GPGJoaL32dC4WRQ4Yli', WIDGET_TYPE: 'bar' };</script>
|
|
10
|
+
<!-- Production checks -->
|
|
11
|
+
<!-- <script type="module" src="./dist/wineater-chatbot.mjs"></script>
|
|
12
|
+
<link rel="stylesheet" href="./dist/wineater-chatbot.css"> -->
|
|
13
|
+
|
|
14
|
+
<!-- Local development -->
|
|
9
15
|
<script type="module" src="./src/index.js"></script>
|
|
10
16
|
</head>
|
|
17
|
+
|
|
11
18
|
<body style="margin: 0;">
|
|
12
|
-
<div id="wineater-widget-conteiner"></div>
|
|
19
|
+
<div id="wineater-widget-conteiner"></div>
|
|
13
20
|
</body>
|
|
21
|
+
|
|
14
22
|
</html>
|
package/package.json
CHANGED
|
@@ -1,15 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wineater-bot",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0-beta.3",
|
|
4
4
|
"description": "",
|
|
5
|
-
"main": "dist/
|
|
5
|
+
"main": "dist/wineater-chatbot.umd.js",
|
|
6
6
|
"module": "src/index.js",
|
|
7
|
+
"type": "module",
|
|
7
8
|
"scripts": {
|
|
8
9
|
"dev": "vite",
|
|
9
10
|
"build": "vite build",
|
|
10
11
|
"release:patch": "npm version patch && npm publish",
|
|
11
12
|
"release:minor": "npm version minor && npm publish",
|
|
12
|
-
"release:major": "npm version major && npm publish"
|
|
13
|
+
"release:major": "npm version major && npm publish",
|
|
14
|
+
"release:beta": "npm version prerelease --preid=beta && npm run build && npm publish --tag beta",
|
|
15
|
+
"format": "prettier --write .",
|
|
16
|
+
"lint": "prettier --check . && eslint .",
|
|
17
|
+
"test:unit": "vitest",
|
|
18
|
+
"test": "npm run test:unit -- --run && npm run test:e2e",
|
|
19
|
+
"test:e2e": "playwright test"
|
|
13
20
|
},
|
|
14
21
|
"keywords": [],
|
|
15
22
|
"author": "",
|
|
@@ -17,9 +24,27 @@
|
|
|
17
24
|
"dependencies": {
|
|
18
25
|
"@openreplay/tracker": "^14.0.4",
|
|
19
26
|
"flagsmith": "^7.0.2",
|
|
20
|
-
"vite": "^5.2.9"
|
|
27
|
+
"vite": "^5.2.9",
|
|
28
|
+
"svelte": "^5.0.0"
|
|
21
29
|
},
|
|
22
30
|
"devDependencies": {
|
|
23
|
-
"
|
|
31
|
+
"@eslint/compat": "^1.4.0",
|
|
32
|
+
"@eslint/js": "^9.36.0",
|
|
33
|
+
"@playwright/test": "^1.55.1",
|
|
34
|
+
"@types/node": "^22",
|
|
35
|
+
"@vitest/browser": "^3.2.4",
|
|
36
|
+
"eslint": "^9.36.0",
|
|
37
|
+
"eslint-config-prettier": "^10.1.8",
|
|
38
|
+
"eslint-plugin-svelte": "^3.12.4",
|
|
39
|
+
"globals": "^16.4.0",
|
|
40
|
+
"playwright": "^1.55.1",
|
|
41
|
+
"prettier": "^3.6.2",
|
|
42
|
+
"prettier-plugin-svelte": "^3.4.0",
|
|
43
|
+
"rollup-plugin-postcss": "^4.0.2",
|
|
44
|
+
"typescript-eslint": "^8.44.1",
|
|
45
|
+
"vite-plugin-devtools-json": "^1.0.0",
|
|
46
|
+
"vitest": "^3.2.4",
|
|
47
|
+
"vitest-browser-svelte": "^1.1.0",
|
|
48
|
+
"@sveltejs/vite-plugin-svelte": "^4.0.0"
|
|
24
49
|
}
|
|
25
50
|
}
|
package/src/App.svelte
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { setCookie, setUniqueUserCookie, trackWidgetEvent } from './services/analytics/analytics';
|
|
3
|
+
import Tracker from '@openreplay/tracker';
|
|
4
|
+
import type { RecommendedWine, Wine, WineId } from './services/api/api';
|
|
5
|
+
import { wineaterApi } from './services/api/api';
|
|
6
|
+
import { onMount } from 'svelte';
|
|
7
|
+
import { WIDGET_EVENTS } from './services/analytics/constants';
|
|
8
|
+
import Header from './components/Header.svelte';
|
|
9
|
+
import Footer from './components/Footer.svelte';
|
|
10
|
+
import Search from './components/Search.svelte';
|
|
11
|
+
import Loader from './components/Loader.svelte';
|
|
12
|
+
import WineTile from './components/WineTile.svelte';
|
|
13
|
+
import { appStore } from './services/store/app-store.svelte';
|
|
14
|
+
import { getTranslation, translateTexts } from './services/i18n/i18n';
|
|
15
|
+
|
|
16
|
+
function initCookies() {
|
|
17
|
+
setCookie('chatbotToken', window.wineaterData?.CLIENT_TOKEN || DEMO_TOKEN, 365);
|
|
18
|
+
setUniqueUserCookie();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const DEMO_TOKEN = 'HeUw6IXuhvz6tRuXdPEGEbj3SGQ3Q2YtEc1Z8jd41Kg9LtyPb4Epu0to31SYIadG';
|
|
22
|
+
const tracker = new Tracker({ projectKey: 'PA8j9fAi49i6g2QnvpNs', __DISABLE_SECURE_MODE: true });
|
|
23
|
+
tracker.start();
|
|
24
|
+
initCookies();
|
|
25
|
+
|
|
26
|
+
const widgetType = window?.wineaterData?.WIDGET_TYPE || 'store';
|
|
27
|
+
const countryCode = window?.wineaterData?.COUNTRY_CODE || null;
|
|
28
|
+
const widgetWrapper = window?.wineaterData?.WIDGET_WRAPPER || 'wineater-widget-conteiner';
|
|
29
|
+
|
|
30
|
+
let initialized = $state(false);
|
|
31
|
+
let isLoading = $state(false);
|
|
32
|
+
let results = $state<RecommendedWine[] | Wine[]>([]);
|
|
33
|
+
let selectedWineId = $state<WineId>(null);
|
|
34
|
+
let searchQuery = $state('');
|
|
35
|
+
let errorMessage = $state(null);
|
|
36
|
+
let isAnimating = $state(false);
|
|
37
|
+
let hoveredWineId = $state(null);
|
|
38
|
+
let translatedPopups = $state([]);
|
|
39
|
+
let appLanguage = $derived(appStore.getAppLanguage());
|
|
40
|
+
let storeLanguage = $derived(appStore.getStoreLanguage());
|
|
41
|
+
let apiKey = $derived(appStore.getApiKey());
|
|
42
|
+
let storeId = $derived(appStore.getStoreId());
|
|
43
|
+
let isStoreInitialized = $derived(appStore.isInitialized());
|
|
44
|
+
|
|
45
|
+
function setSearchError() {
|
|
46
|
+
errorMessage = getTranslation({ key: 'errorMessage', lang: appLanguage });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const clearError = () => (errorMessage = null);
|
|
50
|
+
|
|
51
|
+
// Returns an array of translated fields for each wine
|
|
52
|
+
async function translatePopupContent(wines: RecommendedWine[] | Wine[]) {
|
|
53
|
+
if (appLanguage === storeLanguage || widgetType !== 'bar') {
|
|
54
|
+
return wines.map(() => ({}));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const possibleFields = ['shortDescription', 'reason', 'pairingRecommendation', 'tags'];
|
|
58
|
+
const firstWine = wines[0];
|
|
59
|
+
if (!firstWine) return wines.map(() => ({}));
|
|
60
|
+
const fieldsToTranslate = possibleFields.filter((field) => field in firstWine);
|
|
61
|
+
const batchSize = 4;
|
|
62
|
+
const translatedResults = [];
|
|
63
|
+
|
|
64
|
+
for (let i = 0; i < wines.length; i += batchSize) {
|
|
65
|
+
const batch = wines.slice(i, i + batchSize);
|
|
66
|
+
const translationPromises = batch.map(async (wine: Wine | RecommendedWine) => {
|
|
67
|
+
const entries = fieldsToTranslate
|
|
68
|
+
.map((field) => {
|
|
69
|
+
const val = wine[field] || wine[field.toLowerCase()];
|
|
70
|
+
if (!val) return null;
|
|
71
|
+
return {
|
|
72
|
+
key: field,
|
|
73
|
+
text: Array.isArray(val) ? val.join(', ') : val
|
|
74
|
+
};
|
|
75
|
+
})
|
|
76
|
+
.filter(Boolean);
|
|
77
|
+
|
|
78
|
+
if (entries.length === 0) return {};
|
|
79
|
+
|
|
80
|
+
const translated = await translateTexts({
|
|
81
|
+
sourceLang: storeLanguage,
|
|
82
|
+
targetLang: appLanguage,
|
|
83
|
+
texts: Object.fromEntries(entries.map((entry) => [entry.key, entry.text])),
|
|
84
|
+
apiKey: apiKey
|
|
85
|
+
});
|
|
86
|
+
return translated;
|
|
87
|
+
});
|
|
88
|
+
const batchTranslations = await Promise.all(translationPromises);
|
|
89
|
+
translatedResults.push(...batchTranslations);
|
|
90
|
+
}
|
|
91
|
+
return translatedResults;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function loadInstantRecommendations() {
|
|
95
|
+
isLoading = true;
|
|
96
|
+
try {
|
|
97
|
+
results = await wineaterApi.getInstantRecommendations();
|
|
98
|
+
} catch (error) {
|
|
99
|
+
setSearchError();
|
|
100
|
+
} finally {
|
|
101
|
+
isLoading = false;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Простая функция-анимация: поднимаем карточки и ждём
|
|
106
|
+
function animateClearAll(duration = 220): Promise<void> {
|
|
107
|
+
if (!results || results.length === 0) return Promise.resolve();
|
|
108
|
+
isAnimating = true;
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
setTimeout(() => {
|
|
111
|
+
resolve();
|
|
112
|
+
}, duration);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function handleSearch(event) {
|
|
117
|
+
event.preventDefault();
|
|
118
|
+
|
|
119
|
+
let query = '';
|
|
120
|
+
try {
|
|
121
|
+
isLoading = true;
|
|
122
|
+
clearError();
|
|
123
|
+
|
|
124
|
+
// шаг 1: плавно поднять карточки
|
|
125
|
+
await animateClearAll(220);
|
|
126
|
+
|
|
127
|
+
// шаг 2: очистить список и сбросить флаг
|
|
128
|
+
results = [];
|
|
129
|
+
isAnimating = false;
|
|
130
|
+
|
|
131
|
+
query = searchQuery.trim() + ';';
|
|
132
|
+
try {
|
|
133
|
+
trackWidgetEvent(WIDGET_EVENTS.SEARCH_PERFORMED, storeId, { query });
|
|
134
|
+
} catch (error) {
|
|
135
|
+
console.error('Error tracking search event:', error);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const searchResults = await wineaterApi.getWineRecommendationsSearch(query);
|
|
139
|
+
|
|
140
|
+
if (!searchResults || searchResults.length === 0) {
|
|
141
|
+
results = [];
|
|
142
|
+
translatedPopups = [];
|
|
143
|
+
errorMessage = getTranslation({ key: 'noResultsMessage', lang: appLanguage });
|
|
144
|
+
|
|
145
|
+
trackWidgetEvent(WIDGET_EVENTS.NO_RESULTS_FOUND, storeId, { query });
|
|
146
|
+
} else {
|
|
147
|
+
results = searchResults;
|
|
148
|
+
trackWidgetEvent(WIDGET_EVENTS.SEARCH_RESULTS_DISPLAYED, storeId, {
|
|
149
|
+
results_count: searchResults.length,
|
|
150
|
+
results: searchResults,
|
|
151
|
+
query
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
} catch (error) {
|
|
155
|
+
results = [];
|
|
156
|
+
trackWidgetEvent(WIDGET_EVENTS.ERROR_OCCURRED, storeId, {
|
|
157
|
+
error_message: error?.message,
|
|
158
|
+
query
|
|
159
|
+
});
|
|
160
|
+
setSearchError();
|
|
161
|
+
} finally {
|
|
162
|
+
isLoading = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
$effect(async () => {
|
|
167
|
+
// whenever results change, recompute translatedPopups asynchronously
|
|
168
|
+
if (!results || results.length === 0) {
|
|
169
|
+
translatedPopups = [];
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
// run translation in background; ignore errors but keep latest mapping
|
|
173
|
+
try {
|
|
174
|
+
const translated = await translatePopupContent(results);
|
|
175
|
+
// guard against results having changed again while we awaited
|
|
176
|
+
if (results && results.length > 0) {
|
|
177
|
+
translatedPopups = translated;
|
|
178
|
+
}
|
|
179
|
+
} catch (e) {
|
|
180
|
+
console.error('translatePopupContent failed', e);
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
function handleBuyBtnClick(wine: Wine | RecommendedWine, index: number) {
|
|
185
|
+
// Close all other popups
|
|
186
|
+
if (selectedWineId && selectedWineId !== wine.id) {
|
|
187
|
+
selectedWineId = null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
selectedWineId = wine.id;
|
|
191
|
+
|
|
192
|
+
trackWidgetEvent(WIDGET_EVENTS.WINE_DETAILS_VIEWED, storeId, {
|
|
193
|
+
wine_id: wine.id,
|
|
194
|
+
wine_title: wine.title,
|
|
195
|
+
is_mobile: window.innerWidth <= 768
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function closePopup() {
|
|
200
|
+
const wine = results.find((w) => w.id === selectedWineId);
|
|
201
|
+
trackWidgetEvent(WIDGET_EVENTS.WINE_DETAILS_CLOSED, storeId, {
|
|
202
|
+
wine_id: selectedWineId,
|
|
203
|
+
wine_title: wine.title
|
|
204
|
+
});
|
|
205
|
+
selectedWineId = null;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function hasHoverPopupContent(wine: Wine | RecommendedWine) {
|
|
209
|
+
return !!(wine.reason || wine.shortDescription || wine.tags);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function onTileEnter(wine: Wine | RecommendedWine) {
|
|
213
|
+
if (widgetType === 'store' && hasHoverPopupContent(wine)) {
|
|
214
|
+
hoveredWineId = wine.id;
|
|
215
|
+
trackWidgetEvent(WIDGET_EVENTS.WINE_CARD_HOVER, storeId, {
|
|
216
|
+
wine_id: wine.id,
|
|
217
|
+
wine_title: wine.title
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function onTileLeave(wine: Wine | RecommendedWine) {
|
|
223
|
+
if (widgetType === 'store' && hoveredWineId === wine.id) hoveredWineId = null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function onTileClick(event, wine: Wine | RecommendedWine) {
|
|
227
|
+
// Don't track clicks on buy buttons or close buttons
|
|
228
|
+
if (event.target.closest('.buy-btn') || event.target.closest('.card-popup-close')) {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
trackWidgetEvent(WIDGET_EVENTS.WINE_CARD_CLICK, storeId, {
|
|
233
|
+
wine_id: wine.id,
|
|
234
|
+
wine_title: wine.title
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function handleClickOutside(event: MouseEvent) {
|
|
239
|
+
const target = event.target as HTMLElement;
|
|
240
|
+
const isInsidePopup = target.closest('.card-popup') || target.closest('.buy-btn');
|
|
241
|
+
|
|
242
|
+
if (!isInsidePopup) {
|
|
243
|
+
selectedWineId = null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
onMount(async () => {
|
|
248
|
+
// initialize store first
|
|
249
|
+
await appStore.initializeStore();
|
|
250
|
+
if (!isStoreInitialized) return;
|
|
251
|
+
|
|
252
|
+
// Set dynamic color variable from widget data
|
|
253
|
+
const customColor = window?.wineaterData?.COLOR || '#7B2BEB';
|
|
254
|
+
document.documentElement.style.setProperty('--wineater-color', customColor);
|
|
255
|
+
|
|
256
|
+
await loadInstantRecommendations();
|
|
257
|
+
trackWidgetEvent(WIDGET_EVENTS.WIDGET_INITIALIZED, storeId);
|
|
258
|
+
initialized = true;
|
|
259
|
+
|
|
260
|
+
// Add global click listener for closing popups
|
|
261
|
+
document.addEventListener('click', handleClickOutside);
|
|
262
|
+
|
|
263
|
+
return () => {
|
|
264
|
+
document.removeEventListener('click', handleClickOutside);
|
|
265
|
+
};
|
|
266
|
+
});
|
|
267
|
+
</script>
|
|
268
|
+
|
|
269
|
+
{#if isStoreInitialized}
|
|
270
|
+
<div id="wine-widget-container" class="svelte-wineater-widget-container">
|
|
271
|
+
<div class="wine-widget-wrapper">
|
|
272
|
+
<Header />
|
|
273
|
+
|
|
274
|
+
<Search bind:searchQuery onSubmit={handleSearch} {initialized} />
|
|
275
|
+
|
|
276
|
+
{#if isLoading}
|
|
277
|
+
<Loader />
|
|
278
|
+
{/if}
|
|
279
|
+
|
|
280
|
+
<div id="wine-widget-right">
|
|
281
|
+
{#if errorMessage}
|
|
282
|
+
<div class="error-message">
|
|
283
|
+
<p>{errorMessage}</p>
|
|
284
|
+
</div>
|
|
285
|
+
{:else if results && results.length > 0}
|
|
286
|
+
<div id="wine-placeholder">
|
|
287
|
+
{#each results as wine, index}
|
|
288
|
+
<WineTile
|
|
289
|
+
{wine}
|
|
290
|
+
{index}
|
|
291
|
+
{hoveredWineId}
|
|
292
|
+
{selectedWineId}
|
|
293
|
+
{isAnimating}
|
|
294
|
+
translatedPopup={translatedPopups[index]}
|
|
295
|
+
onBuyClick={handleBuyBtnClick}
|
|
296
|
+
onPopupClose={closePopup}
|
|
297
|
+
{onTileEnter}
|
|
298
|
+
{onTileLeave}
|
|
299
|
+
{onTileClick}
|
|
300
|
+
/>
|
|
301
|
+
{/each}
|
|
302
|
+
</div>
|
|
303
|
+
{/if}
|
|
304
|
+
</div>
|
|
305
|
+
|
|
306
|
+
<Footer {countryCode} />
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
{/if}
|
|
310
|
+
|
|
311
|
+
<style>
|
|
312
|
+
/* ===================== 0) Font Import ===================== */
|
|
313
|
+
@import url('https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&display=swap');
|
|
314
|
+
|
|
315
|
+
/* ===================== 1) CSS Variables ===================== */
|
|
316
|
+
:global(:root) {
|
|
317
|
+
--wineater-corp-color: #0044ff;
|
|
318
|
+
--wineater-color: #7b2beb;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/* ===================== 2) Global Styles ===================== */
|
|
322
|
+
:global(*) {
|
|
323
|
+
box-sizing: border-box;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
:global(.hidden) {
|
|
327
|
+
display: none;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/* ===================== 3) Widget Container ===================== */
|
|
331
|
+
#wine-widget-container {
|
|
332
|
+
display: flex;
|
|
333
|
+
gap: 24px;
|
|
334
|
+
font-family: 'Bricolage Grotesque', sans-serif;
|
|
335
|
+
padding: 12px 12px 0px;
|
|
336
|
+
border-radius: 16px;
|
|
337
|
+
background-color: #fff;
|
|
338
|
+
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
|
339
|
+
max-width: 1024px;
|
|
340
|
+
min-width: 1024px;
|
|
341
|
+
min-height: 500px;
|
|
342
|
+
margin: 40px auto;
|
|
343
|
+
position: relative;
|
|
344
|
+
box-sizing: border-box;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
#wine-widget-container :global([class^='icon-']),
|
|
348
|
+
#wine-widget-container :global([class*=' icon-']) {
|
|
349
|
+
font-family: 'Bricolage Grotesque', sans-serif !important;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
.wine-widget-wrapper {
|
|
353
|
+
width: 100%;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
#wine-widget-right {
|
|
357
|
+
position: relative;
|
|
358
|
+
min-height: 300px;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
#wine-placeholder {
|
|
362
|
+
display: grid;
|
|
363
|
+
grid-template-columns: repeat(4, 1fr);
|
|
364
|
+
gap: 0;
|
|
365
|
+
align-items: stretch;
|
|
366
|
+
margin-bottom: 0;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/* ===================== 12) Error Message ===================== */
|
|
370
|
+
.error-message {
|
|
371
|
+
text-align: center;
|
|
372
|
+
padding: 24px;
|
|
373
|
+
color: #666;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/* ===================== 13) Mobile Styles ===================== */
|
|
377
|
+
@media screen and (max-width: 768px) {
|
|
378
|
+
#wine-widget-container {
|
|
379
|
+
flex-direction: column;
|
|
380
|
+
padding: 0px 16px;
|
|
381
|
+
min-width: 100vw;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#wine-placeholder {
|
|
385
|
+
display: flex;
|
|
386
|
+
gap: 8px;
|
|
387
|
+
overflow-x: auto;
|
|
388
|
+
scroll-snap-type: x mandatory;
|
|
389
|
+
padding: 8px 0;
|
|
390
|
+
width: calc(100vw - 32px);
|
|
391
|
+
min-width: calc(100vw - 32px);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
</style>
|