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.
Files changed (41) hide show
  1. package/.prettierignore +9 -0
  2. package/.prettierrc +16 -0
  3. package/README.md +86 -0
  4. package/dist/wineater-chatbot.css +1 -0
  5. package/dist/wineater-chatbot.mjs +6493 -5129
  6. package/dist/wineater-chatbot.umd.js +40 -985
  7. package/docs/SVELTE.md +146 -0
  8. package/e2e/demo.test.ts +6 -0
  9. package/eslint.config.js +41 -0
  10. package/index.html +11 -3
  11. package/package.json +30 -5
  12. package/playwright.config.ts +9 -0
  13. package/pnpm-workspace.yaml +2 -0
  14. package/src/App.svelte +394 -0
  15. package/src/App.svelte.test.ts +13 -0
  16. package/src/components/Footer.svelte +131 -0
  17. package/src/components/Header.svelte +92 -0
  18. package/src/components/Loader.svelte +62 -0
  19. package/src/components/Search.svelte +188 -0
  20. package/src/components/WinePopup.svelte +192 -0
  21. package/src/components/WineTile.svelte +331 -0
  22. package/src/demo.spec.ts +13 -0
  23. package/src/index.js +4 -36
  24. package/src/services/analytics/analytics.ts +103 -0
  25. package/src/{js/widget/widget-events.js → services/analytics/constants.ts} +0 -2
  26. package/src/services/api/api.ts +112 -0
  27. package/src/services/api/deserialize.ts +102 -0
  28. package/src/services/i18n/i18n.ts +134 -0
  29. package/src/services/i18n/translations.json +521 -0
  30. package/src/services/store/app-store.svelte.ts +84 -0
  31. package/tsconfig.json +23 -0
  32. package/vite.config.js +69 -19
  33. package/vitest-setup-client.ts +2 -0
  34. package/src/js/analytics.js +0 -117
  35. package/src/js/api.js +0 -138
  36. package/src/js/i18n.js +0 -703
  37. package/src/js/widget/widget-language.js +0 -107
  38. package/src/js/widget/widget-search.js +0 -68
  39. package/src/js/widget/widget-styles.js +0 -855
  40. package/src/js/widget/widget-ui.js +0 -186
  41. package/src/js/widget.js +0 -241
@@ -0,0 +1,13 @@
1
+ import { page } from '@vitest/browser/context';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { render } from 'vitest-browser-svelte';
4
+ import App from './App.svelte';
5
+
6
+ describe('App.svelte', () => {
7
+ it('should render h1', async () => {
8
+ render(App);
9
+
10
+ const heading = page.getByRole('heading', { level: 1 });
11
+ await expect.element(heading).toBeInTheDocument();
12
+ });
13
+ });
@@ -0,0 +1,131 @@
1
+ <script lang="ts">
2
+ import { trackWidgetEvent } from '../services/analytics/analytics';
3
+ import { WIDGET_EVENTS } from '../services/analytics/constants';
4
+ import { appStore } from '../services/store/app-store.svelte';
5
+ import { getTranslation } from '../services/i18n/i18n';
6
+
7
+ type Props = {
8
+ countryCode?: string | null;
9
+ };
10
+
11
+ let { countryCode = null }: Props = $props();
12
+
13
+ let appLanguage = $derived(appStore.getAppLanguage());
14
+ let supportedLanguages = $derived(appStore.getSupportedLanguages());
15
+ let storeId = $derived(appStore.getStoreId());
16
+
17
+ function handleLanguageChange(event: Event) {
18
+ const target = event.target as HTMLSelectElement;
19
+ const selectedLang = target.value;
20
+
21
+ trackWidgetEvent(WIDGET_EVENTS.LANGUAGE_CHANGED, storeId, {
22
+ from_language: appLanguage,
23
+ to_language: selectedLang
24
+ });
25
+
26
+ appStore.setStoreLanguage(selectedLang);
27
+ }
28
+ </script>
29
+
30
+ <div id="powered-by-widget">
31
+ <div class="powered-by-center">
32
+ <a href="https://wineater.com" target="_blank">
33
+ <span>{getTranslation({ key: 'poweredBy', lang: appLanguage })}</span>
34
+ <img
35
+ src={countryCode === 'ru'
36
+ ? 'https://czvgkhagwvmknscoerfy.supabase.co/storage/v1/object/public/static-media/divino_logo.svg?x=5'
37
+ : 'https://czvgkhagwvmknscoerfy.supabase.co/storage/v1/object/public/static-media/logo_medium_gray.svg'}
38
+ alt="Wineater Logo"
39
+ />
40
+ <span class="powered-by-sommelier"
41
+ >{getTranslation({ key: 'ai_sommelier', lang: appLanguage })}</span
42
+ >
43
+ </a>
44
+ </div>
45
+ <div class="powered-by-lang">
46
+ {#if supportedLanguages.length > 1}
47
+ <select
48
+ id="language-dropdown"
49
+ class="language-dropdown"
50
+ value={appLanguage}
51
+ onchange={handleLanguageChange}
52
+ >
53
+ {#each supportedLanguages as lang}
54
+ <option value={lang} selected={lang === appLanguage}>
55
+ {lang.toUpperCase()}
56
+ </option>
57
+ {/each}
58
+ </select>
59
+ {/if}
60
+ </div>
61
+ </div>
62
+
63
+ <style>
64
+ #powered-by-widget {
65
+ position: relative;
66
+ border-top: 2px solid #f6f7fd;
67
+ font-family: 'Bricolage Grotesque', sans-serif;
68
+ font-weight: 200;
69
+ padding: 12px 24px;
70
+ min-height: 40px;
71
+ }
72
+
73
+ #powered-by-widget .powered-by-center {
74
+ position: absolute;
75
+ left: 50%;
76
+ transform: translateX(-50%);
77
+ text-align: center;
78
+ }
79
+
80
+ #powered-by-widget .powered-by-lang {
81
+ position: absolute;
82
+ right: 0px;
83
+ top: 50%;
84
+ transform: translateY(-50%);
85
+ }
86
+
87
+ .language-dropdown {
88
+ padding: 4px;
89
+ border-radius: 6px;
90
+ border: 2px solid var(--wineater-color);
91
+ background-color: white;
92
+ color: var(--wineater-color);
93
+ font-weight: 500;
94
+ font-family: 'Bricolage Grotesque', sans-serif;
95
+ font-size: 12px;
96
+ cursor: pointer;
97
+ transition: all 0.2s ease;
98
+ }
99
+
100
+ .language-dropdown:hover {
101
+ box-shadow: 0 4px 8px rgba(123, 43, 235, 0.2);
102
+ }
103
+
104
+ #powered-by-widget a {
105
+ text-decoration: none;
106
+ display: flex;
107
+ align-items: center;
108
+ white-space: nowrap;
109
+ gap: 8px;
110
+ color: #a8a8a8;
111
+ font-size: 12px;
112
+ line-height: 16px;
113
+ transition: opacity 0.2s ease;
114
+ }
115
+
116
+ #powered-by-widget a:hover {
117
+ opacity: 0.8;
118
+ }
119
+
120
+ #powered-by-widget img {
121
+ height: 16px;
122
+ width: auto;
123
+ color: #a8a8a8;
124
+ }
125
+
126
+ @media screen and (max-width: 768px) {
127
+ .powered-by-sommelier {
128
+ display: none;
129
+ }
130
+ }
131
+ </style>
@@ -0,0 +1,92 @@
1
+ <script lang="ts">
2
+ import { getTranslation } from '../services/i18n/i18n';
3
+ import { appStore } from '../services/store/app-store.svelte';
4
+
5
+ let lang = $derived(appStore.getAppLanguage());
6
+ const titleText = window?.wineaterData?.TITLE_TEXT || null;
7
+ </script>
8
+
9
+ <div id="wine-widget-header">
10
+ {#if titleText}
11
+ <h1 class="wine-widget-big-title">{titleText}</h1>
12
+ {:else}
13
+ <!-- TODO: translation is not working -->
14
+ <h1 class="wine-widget-big-title">
15
+ {getTranslation({ key: 'headerLine1', lang })}
16
+ <span class="icon icon-meal"></span>
17
+ <span class="icon-label">{getTranslation({ key: 'headerMeal', lang })}</span>,
18
+ <span class="icon icon-mood"></span>
19
+ <span class="icon-label">{getTranslation({ key: 'headerMood', lang })}</span>
20
+ <br />
21
+ {getTranslation({ key: 'headerOr', lang })}
22
+ <span class="icon icon-occasion"></span>
23
+ <span class="icon-label">{getTranslation({ key: 'headerOccasion', lang })}</span>
24
+ {getTranslation({ key: 'headerLine2', lang })}
25
+ </h1>
26
+ {/if}
27
+ </div>
28
+
29
+ <style>
30
+ #wine-widget-header h1 {
31
+ font-family: 'Bricolage Grotesque', serif;
32
+ font-weight: 600;
33
+ font-size: 24px;
34
+ margin: 0 auto;
35
+ line-height: 1.3;
36
+ color: #333;
37
+ text-align: center;
38
+ }
39
+
40
+ #wine-widget-header p {
41
+ font-size: 16px;
42
+ color: #666;
43
+ margin-top: 8px;
44
+ text-align: center;
45
+ padding: 0 15%;
46
+ }
47
+
48
+ .icon {
49
+ display: inline-block;
50
+ width: 1.2em;
51
+ height: 1.2em;
52
+ vertical-align: middle;
53
+ margin: 0 4px 0 2px;
54
+ background-repeat: no-repeat;
55
+ background-size: contain;
56
+ }
57
+
58
+ .icon-meal {
59
+ background-image: url('https://czvgkhagwvmknscoerfy.supabase.co/storage/v1/object/public/static-media//meal1.svg');
60
+ }
61
+
62
+ .icon-meal:hover {
63
+ background-image: url('https://czvgkhagwvmknscoerfy.supabase.co/storage/v1/object/public/static-media//meal3.svg');
64
+ }
65
+
66
+ .icon-mood {
67
+ background-image: url('https://czvgkhagwvmknscoerfy.supabase.co/storage/v1/object/public/static-media//mood.svg');
68
+ }
69
+
70
+ .icon-budget {
71
+ background-image: url('https://czvgkhagwvmknscoerfy.supabase.co/storage/v1/object/public/static-media//budget.svg');
72
+ }
73
+
74
+ .icon-occasion {
75
+ background-image: url('https://czvgkhagwvmknscoerfy.supabase.co/storage/v1/object/public/static-media//ocasion.svg');
76
+ }
77
+
78
+ .icon-label {
79
+ color: var(--wineater-color);
80
+ font-weight: 500;
81
+ }
82
+
83
+ @media screen and (max-width: 768px) {
84
+ #wine-widget-header h1 {
85
+ display: none;
86
+ }
87
+
88
+ #wine-widget-header p {
89
+ display: none;
90
+ }
91
+ }
92
+ </style>
@@ -0,0 +1,62 @@
1
+ <div id="loading-indicator">
2
+ <div id="typing-indicator" class="typing-indicator">
3
+ <span></span>
4
+ <span></span>
5
+ <span></span>
6
+ </div>
7
+ </div>
8
+
9
+ <style>
10
+ #loading-indicator {
11
+ position: absolute;
12
+ top: 50%;
13
+ left: 50%;
14
+ transform: translate(-50%, -50%);
15
+ z-index: 10000;
16
+ text-align: center;
17
+ }
18
+
19
+ .typing-indicator {
20
+ display: flex;
21
+ justify-content: center;
22
+ align-items: center;
23
+ height: 40px;
24
+ visibility: visible;
25
+ background-color: white;
26
+ }
27
+
28
+ .typing-indicator span {
29
+ display: block;
30
+ width: 8px;
31
+ height: 8px;
32
+ margin: 0 3px;
33
+ border-radius: 50%;
34
+ background: linear-gradient(90deg, var(--wineater-color), var(--wineater-corp-color));
35
+ opacity: 0.7;
36
+ animation: typing 1s infinite;
37
+ }
38
+
39
+ @keyframes typing {
40
+ 0%,
41
+ 100% {
42
+ transform: translateY(0);
43
+ opacity: 0.7;
44
+ }
45
+ 50% {
46
+ transform: translateY(-8px);
47
+ opacity: 1;
48
+ }
49
+ }
50
+
51
+ .typing-indicator span:nth-child(1) {
52
+ animation-delay: 0s;
53
+ }
54
+
55
+ .typing-indicator span:nth-child(2) {
56
+ animation-delay: 0.15s;
57
+ }
58
+
59
+ .typing-indicator span:nth-child(3) {
60
+ animation-delay: 0.3s;
61
+ }
62
+ </style>
@@ -0,0 +1,188 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte';
3
+ import { appStore } from '../services/store/app-store.svelte';
4
+ import { getTranslation } from '../services/i18n/i18n';
5
+
6
+ interface Props {
7
+ searchQuery: string;
8
+ onSubmit: (event: SubmitEvent) => void;
9
+ initialized: boolean;
10
+ }
11
+
12
+ let { searchQuery = $bindable(), onSubmit, initialized }: Props = $props();
13
+ let searchEl: HTMLInputElement;
14
+ let isInputFocused = $state(false);
15
+ let animIndex = $state(0);
16
+ let animChar = $state(0);
17
+ let animForward = $state(true);
18
+ let animRunning = $state(false);
19
+ let lang = $derived(appStore.getAppLanguage());
20
+ let animationToken = 0;
21
+
22
+ export function getSearchElement() {
23
+ return searchEl;
24
+ }
25
+
26
+ function initializeSearchAnimation() {
27
+ const localToken = ++animationToken;
28
+ const suggestions = getTranslation({ key: 'placeholderSuggestions', lang });
29
+ if (!searchEl || !suggestions.length) return;
30
+ animRunning = true;
31
+ const loop = () => {
32
+ // abort if a new animation has started or conditions changed
33
+ if (localToken !== animationToken) return;
34
+ if (!searchEl || !animRunning || isInputFocused || searchQuery.length) return;
35
+ const current = suggestions[animIndex];
36
+ if (animForward) {
37
+ searchEl.placeholder = current.substring(0, animChar++);
38
+ if (animChar > current.length) {
39
+ animForward = false;
40
+ setTimeout(loop, 1600);
41
+ return;
42
+ }
43
+ setTimeout(loop, 90);
44
+ } else {
45
+ searchEl.placeholder = current.substring(0, animChar--);
46
+ if (animChar < 0) {
47
+ animForward = true;
48
+ animIndex = (animIndex + 1) % suggestions.length;
49
+ setTimeout(loop, 450);
50
+ return;
51
+ }
52
+ setTimeout(loop, 45);
53
+ }
54
+ };
55
+ loop();
56
+ }
57
+
58
+ function stopSearchAnimation() {
59
+ animRunning = false;
60
+ }
61
+
62
+ function onSearchFocus() {
63
+ isInputFocused = true;
64
+ stopSearchAnimation();
65
+ if (searchEl) searchEl.placeholder = '';
66
+ }
67
+
68
+ function onSearchBlur() {
69
+ isInputFocused = false;
70
+ animChar = 0;
71
+ animForward = true;
72
+ animRunning = false;
73
+ }
74
+
75
+ $effect(() => {
76
+ // update placeholder whenever lang or searchEl change
77
+ if (!searchEl) return;
78
+ if (searchQuery.length > 0) return;
79
+ // cancel any running animation on language change
80
+ animationToken++;
81
+ animRunning = false;
82
+ animIndex = 0;
83
+ animChar = 0;
84
+ animForward = true;
85
+ const suggestions = getTranslation({ key: 'placeholderSuggestions', lang });
86
+ if (suggestions && suggestions.length) {
87
+ searchEl.placeholder = suggestions[0];
88
+ } else {
89
+ searchEl.placeholder = getTranslation({ key: 'searchPlaceholder', lang });
90
+ }
91
+ });
92
+
93
+ $effect(() => {
94
+ // control typing animation separately with latest lang
95
+ if (searchEl && initialized && !isInputFocused && !searchQuery.length && !animRunning) {
96
+ initializeSearchAnimation();
97
+ }
98
+ if (searchQuery.length > 0 && animRunning) {
99
+ animRunning = false;
100
+ animationToken++;
101
+ }
102
+ });
103
+
104
+ onMount(() => {
105
+ // effects handle initial placeholder and animation
106
+ });
107
+ </script>
108
+
109
+ <form id="wine-widget-search" onsubmit={onSubmit}>
110
+ <span class="search-icon"></span>
111
+ <input
112
+ id="wine-search-input"
113
+ bind:this={searchEl}
114
+ type="text"
115
+ bind:value={searchQuery}
116
+ onfocus={onSearchFocus}
117
+ onblur={onSearchBlur}
118
+ />
119
+ <button id="wine-search-button" type="submit">
120
+ {getTranslation({ key: 'searchButton', lang })}
121
+ </button>
122
+ </form>
123
+
124
+ <style>
125
+ #wine-widget-search {
126
+ position: relative;
127
+ margin: 12px auto;
128
+ padding: 0 6px;
129
+ display: flex;
130
+ align-items: center;
131
+ border: 2px solid #d5d9ee;
132
+ border-radius: 10px;
133
+ }
134
+
135
+ .search-icon {
136
+ left: 12px;
137
+ width: 24px;
138
+ height: 24px;
139
+ flex: 0 0 auto;
140
+ background-image: url('https://czvgkhagwvmknscoerfy.supabase.co/storage/v1/object/public/static-media//search.svg?x=1');
141
+ background-repeat: no-repeat;
142
+ background-size: contain;
143
+ z-index: 2;
144
+ }
145
+
146
+ #wine-search-input {
147
+ flex: 1 1 0%;
148
+ min-width: 0;
149
+ padding: 12px;
150
+ font-size: 16px;
151
+ border: none;
152
+ border-radius: 10px;
153
+ outline: none;
154
+ transition: border 0.2s ease;
155
+ }
156
+
157
+ #wine-search-input::placeholder {
158
+ color: var(--wineater-color);
159
+ opacity: 1;
160
+ transition:
161
+ color 0.3s ease,
162
+ opacity 0.3s ease-in-out;
163
+ }
164
+
165
+ #wine-search-input:focus {
166
+ border-color: var(--wineater-color);
167
+ }
168
+
169
+ #wine-search-input:focus::placeholder {
170
+ opacity: 0.5;
171
+ }
172
+
173
+ #wine-search-button {
174
+ flex: 0 0 auto;
175
+ background-color: var(--wineater-color);
176
+ color: #fff;
177
+ border: none;
178
+ border-radius: 6px;
179
+ padding: 8px 16px;
180
+ font-size: 16px;
181
+ cursor: pointer;
182
+ transition: opacity 0.2s ease;
183
+ }
184
+
185
+ #wine-search-button:hover {
186
+ opacity: 0.8;
187
+ }
188
+ </style>
@@ -0,0 +1,192 @@
1
+ <script lang="ts">
2
+ let {
3
+ title,
4
+ shortDescription,
5
+ reason,
6
+ pairing,
7
+ tags,
8
+ onClose,
9
+ isMobile
10
+ }: {
11
+ title: string;
12
+ shortDescription?: string;
13
+ reason?: string;
14
+ pairing?: string;
15
+ tags?: string[];
16
+ onClose: () => void;
17
+ isMobile?: boolean;
18
+ } = $props();
19
+
20
+ const mobile = !!isMobile;
21
+ </script>
22
+
23
+ {#if mobile}
24
+ <div class="mobile-fullscreen-wrapper">
25
+ <div class="mobile-fullscreen-overlay" onclick={onClose}></div>
26
+ <div class="mobile-fullscreen-popup">
27
+ <div class="card-popup">
28
+ <div class="card-popup-header">
29
+ <h2>{title}</h2>
30
+ <button class="card-popup-close" onclick={onClose}>&times;</button>
31
+ </div>
32
+
33
+ {#if shortDescription}
34
+ <p class="popup-short-desc">{shortDescription}</p>
35
+ {/if}
36
+
37
+ {#if reason}
38
+ <div class="popup-reason">{reason}</div>
39
+ {:else if pairing}
40
+ <p class="popup-pairing">{pairing}</p>
41
+ {/if}
42
+
43
+ {#if tags && tags.length}
44
+ <div class="popup-tags">
45
+ {#each tags as tag}
46
+ <span class="popup-tag">{tag}</span>
47
+ {/each}
48
+ </div>
49
+ {/if}
50
+ </div>
51
+ </div>
52
+ </div>
53
+ {:else}
54
+ <div class="card-popup">
55
+ <div class="card-popup-header">
56
+ <h2>{title}</h2>
57
+ <button class="card-popup-close" onclick={onClose}>&times;</button>
58
+ </div>
59
+
60
+ {#if shortDescription}
61
+ <p class="popup-short-desc">{shortDescription}</p>
62
+ {/if}
63
+
64
+ {#if reason}
65
+ <div class="popup-reason">{reason}</div>
66
+ {:else if pairing}
67
+ <p class="popup-pairing">{pairing}</p>
68
+ {/if}
69
+
70
+ {#if tags && tags.length}
71
+ <div class="popup-tags">
72
+ {#each tags as tag}
73
+ <span class="popup-tag">{tag}</span>
74
+ {/each}
75
+ </div>
76
+ {/if}
77
+ </div>
78
+ {/if}
79
+
80
+ <style>
81
+ .card-popup {
82
+ position: absolute;
83
+ width: 100%;
84
+ background-color: white;
85
+ padding: 16px;
86
+ border-radius: 16px;
87
+ box-shadow: 0 6px 30px rgba(0, 0, 0, 0.2);
88
+ text-align: left;
89
+ font-size: 14px;
90
+ box-sizing: border-box;
91
+ display: flex;
92
+ flex-direction: column;
93
+ }
94
+
95
+ .card-popup p {
96
+ margin: 5px 0;
97
+ }
98
+
99
+ .card-popup-header {
100
+ display: flex;
101
+ justify-content: space-between;
102
+ align-items: flex-start;
103
+ gap: 12px;
104
+ }
105
+
106
+ .card-popup-header h2 {
107
+ font-size: 16px;
108
+ font-weight: 600;
109
+ margin: 0;
110
+ flex: 1;
111
+ line-height: 1.3;
112
+ }
113
+
114
+ .card-popup-close {
115
+ font-size: 20px;
116
+ font-weight: bold;
117
+ cursor: pointer;
118
+ color: #999;
119
+ border: none;
120
+ background: transparent;
121
+ line-height: 1;
122
+ }
123
+
124
+ .card-popup-close:hover {
125
+ color: #000;
126
+ }
127
+
128
+ .popup-short-desc {
129
+ margin-top: 8px;
130
+ }
131
+
132
+ .popup-reason {
133
+ font-size: 14px;
134
+ font-weight: 400;
135
+ color: #333;
136
+ margin-top: 8px;
137
+ }
138
+
139
+ .popup-pairing {
140
+ margin-top: 8px;
141
+ }
142
+
143
+ .popup-tags {
144
+ display: flex;
145
+ flex-wrap: wrap;
146
+ gap: 8px;
147
+ margin-top: 8px;
148
+ justify-content: center;
149
+ }
150
+
151
+ .popup-tag {
152
+ display: inline-block;
153
+ padding: 4px 8px;
154
+ border-radius: 12px;
155
+ background-color: #f5f0ff;
156
+ border: 1px solid #ddd;
157
+ font-size: 13px;
158
+ color: #333;
159
+ text-transform: capitalize;
160
+ }
161
+
162
+ .mobile-fullscreen-wrapper {
163
+ position: fixed;
164
+ top: 0;
165
+ left: 0;
166
+ width: 100%;
167
+ height: 100%;
168
+ z-index: 10000;
169
+ }
170
+
171
+ .mobile-fullscreen-overlay {
172
+ position: absolute;
173
+ top: 0;
174
+ left: 0;
175
+ width: 100%;
176
+ height: 100%;
177
+ background: rgba(0, 0, 0, 0.5);
178
+ }
179
+
180
+ .mobile-fullscreen-popup {
181
+ position: absolute;
182
+ width: 100%;
183
+ height: 100%;
184
+ top: 50%;
185
+ left: 50%;
186
+ transform: translate(-50%, -50%);
187
+ max-width: 90%;
188
+ max-height: 80%;
189
+ overflow-y: auto;
190
+ background: transparent;
191
+ }
192
+ </style>