wineater-bot 4.19.0 → 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/dist/wineater-chatbot.mjs +13 -11
- package/dist/wineater-chatbot.umd.js +13 -11
- package/package.json +1 -1
- package/src/App.svelte +57 -3
- package/src/components/CookieConsent.svelte +73 -0
- package/src/services/analytics/analytics.ts +5 -0
- package/src/services/api/api.ts +65 -73
- package/src/services/i18n/translations.json +46 -10
- package/src/services/profile/consent.svelte.ts +103 -0
- package/src/services/profile/events.ts +73 -0
- package/src/services/profile/uid.ts +87 -0
- package/src/services/store/app-store.svelte.ts +7 -1
- package/src/services/store/deserialize.ts +2 -0
- package/src/services/store/types.ts +2 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Cookie / tracking consent (GDPR, ePrivacy).
|
|
2
|
+
//
|
|
3
|
+
// The store decides whether consent is required: `Stores.requires_cookie_consent`
|
|
4
|
+
// arrives with the store config (true for EU shops). A host page can force it
|
|
5
|
+
// with `window.wineaterData.requireConsent = true`.
|
|
6
|
+
//
|
|
7
|
+
// Until the visitor accepts, the widget creates no identifiers and sends no
|
|
8
|
+
// tracking: no taste-profile uid (localStorage + cookie), no `uniqueUserID`
|
|
9
|
+
// cookie, no /events, no admin-api analytics. Search works as usual, just not
|
|
10
|
+
// personalised. Declining also wipes identifiers created earlier.
|
|
11
|
+
//
|
|
12
|
+
// The choice itself is kept in localStorage — strictly necessary, so it is
|
|
13
|
+
// allowed without consent.
|
|
14
|
+
//
|
|
15
|
+
// Leaf module on purpose: no imports. appStore pushes the store flag here via
|
|
16
|
+
// setStoreConsentPolicy(); importing appStore from here created a cycle
|
|
17
|
+
// (app-store → api → uid → consent → app-store) that crashed the bundle with a
|
|
18
|
+
// TDZ error on load.
|
|
19
|
+
|
|
20
|
+
export type ConsentChoice = 'granted' | 'denied';
|
|
21
|
+
|
|
22
|
+
const CONSENT_KEY = 'wineater_consent';
|
|
23
|
+
const IDENTIFIER_KEYS = ['wineater_uid', 'uniqueUserID'];
|
|
24
|
+
|
|
25
|
+
function readChoice(): ConsentChoice | null {
|
|
26
|
+
try {
|
|
27
|
+
const v = window.localStorage.getItem(CONSENT_KEY);
|
|
28
|
+
return v === 'granted' || v === 'denied' ? v : null;
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let choice = $state<ConsentChoice | null>(readChoice());
|
|
35
|
+
|
|
36
|
+
/** null — store config not loaded yet (unknown whether consent is required). */
|
|
37
|
+
let storeRequires = $state<boolean | null>(null);
|
|
38
|
+
|
|
39
|
+
/** Called by appStore once the store config is applied. */
|
|
40
|
+
export function setStoreConsentPolicy(requiresConsent: boolean): void {
|
|
41
|
+
storeRequires = requiresConsent;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Store (or host page) requires an explicit opt-in before any tracking. */
|
|
45
|
+
export function consentRequired(): boolean {
|
|
46
|
+
if ((window as any)?.wineaterData?.requireConsent === true) return true;
|
|
47
|
+
return storeRequires === true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function consentChoice(): ConsentChoice | null {
|
|
51
|
+
return choice;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Show the banner: consent needed and the visitor has not chosen yet. */
|
|
55
|
+
export function shouldAskConsent(): boolean {
|
|
56
|
+
return storeRequires !== null && consentRequired() && choice === null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Single gate for everything that identifies or tracks the visitor.
|
|
61
|
+
* Before the store config is loaded we do not know whether consent is
|
|
62
|
+
* required, so nothing is allowed yet — fail closed.
|
|
63
|
+
*/
|
|
64
|
+
export function trackingAllowed(): boolean {
|
|
65
|
+
if ((window as any)?.wineaterData?.tracking === false) return false;
|
|
66
|
+
if (storeRequires === null) return false;
|
|
67
|
+
if (!consentRequired()) return true;
|
|
68
|
+
return choice === 'granted';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function wipeIdentifiers(): void {
|
|
72
|
+
for (const key of IDENTIFIER_KEYS) {
|
|
73
|
+
try {
|
|
74
|
+
window.localStorage.removeItem(key);
|
|
75
|
+
} catch {
|
|
76
|
+
/* blocked */
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
document.cookie = `${key}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
|
|
80
|
+
} catch {
|
|
81
|
+
/* blocked */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const listeners = new Set<(c: ConsentChoice) => void>();
|
|
87
|
+
|
|
88
|
+
/** React to a choice (e.g. drop the in-memory uid after "decline"). */
|
|
89
|
+
export function onConsentChange(fn: (c: ConsentChoice) => void): () => void {
|
|
90
|
+
listeners.add(fn);
|
|
91
|
+
return () => listeners.delete(fn);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function setConsent(next: ConsentChoice): void {
|
|
95
|
+
choice = next;
|
|
96
|
+
try {
|
|
97
|
+
window.localStorage.setItem(CONSENT_KEY, next);
|
|
98
|
+
} catch {
|
|
99
|
+
/* blocked — choice lives for this page only */
|
|
100
|
+
}
|
|
101
|
+
if (next === 'denied') wipeIdentifiers();
|
|
102
|
+
for (const fn of listeners) fn(next);
|
|
103
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Implicit-feedback events for the taste profile → POST /events on the main
|
|
2
|
+
// backend (not admin-api: that service is being retired). Events are buffered
|
|
3
|
+
// and flushed every FLUSH_MS or on page hide, in one request, with
|
|
4
|
+
// `keepalive` so the last batch survives navigation to the shop's product page.
|
|
5
|
+
//
|
|
6
|
+
// `search` is deliberately NOT sent from here: the backend records it itself
|
|
7
|
+
// (it has the parsed query). We only send what the backend cannot see.
|
|
8
|
+
|
|
9
|
+
import { getUid, trackingEnabled } from './uid';
|
|
10
|
+
|
|
11
|
+
export type FeedbackEventType = 'shown' | 'click' | 'more' | 'similar' | 'add_to_cart' | 'buy_click';
|
|
12
|
+
|
|
13
|
+
export type FeedbackEvent = {
|
|
14
|
+
type: FeedbackEventType;
|
|
15
|
+
query_id?: string;
|
|
16
|
+
sku_ids?: string[];
|
|
17
|
+
text?: string;
|
|
18
|
+
position?: number;
|
|
19
|
+
meta?: Record<string, unknown>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const FLUSH_MS = 2000;
|
|
23
|
+
const MAX_BATCH = 100;
|
|
24
|
+
|
|
25
|
+
type Sender = (batch: { uid: string; events: FeedbackEvent[] }) => Promise<void>;
|
|
26
|
+
|
|
27
|
+
let sender: Sender | null = null;
|
|
28
|
+
let queue: FeedbackEvent[] = [];
|
|
29
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
30
|
+
let listenersBound = false;
|
|
31
|
+
|
|
32
|
+
export function initFeedback(send: Sender): void {
|
|
33
|
+
sender = send;
|
|
34
|
+
if (listenersBound || typeof document === 'undefined') return;
|
|
35
|
+
listenersBound = true;
|
|
36
|
+
document.addEventListener('visibilitychange', () => {
|
|
37
|
+
if (document.visibilityState === 'hidden') void flushFeedback();
|
|
38
|
+
});
|
|
39
|
+
window.addEventListener('pagehide', () => void flushFeedback());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function trackFeedback(event: FeedbackEvent): void {
|
|
43
|
+
if (!trackingEnabled() || !sender) return;
|
|
44
|
+
if (!event.sku_ids?.length && event.type !== 'more') return;
|
|
45
|
+
queue.push(event);
|
|
46
|
+
if (queue.length >= MAX_BATCH) {
|
|
47
|
+
void flushFeedback();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (!timer) timer = setTimeout(() => void flushFeedback(), FLUSH_MS);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function flushFeedback(): Promise<void> {
|
|
54
|
+
if (timer) {
|
|
55
|
+
clearTimeout(timer);
|
|
56
|
+
timer = null;
|
|
57
|
+
}
|
|
58
|
+
if (!sender || queue.length === 0) return;
|
|
59
|
+
const uid = getUid();
|
|
60
|
+
if (!uid) {
|
|
61
|
+
queue = [];
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const events = queue.splice(0, MAX_BATCH);
|
|
65
|
+
try {
|
|
66
|
+
await sender({ uid, events });
|
|
67
|
+
} catch (error) {
|
|
68
|
+
// Feedback is best-effort: never surface it to the visitor, never retry
|
|
69
|
+
// (a retry storm on a broken endpoint would hurt more than lost events).
|
|
70
|
+
console.debug('[wineater] feedback flush failed:', (error as Error)?.message);
|
|
71
|
+
}
|
|
72
|
+
if (queue.length > 0) void flushFeedback();
|
|
73
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Anonymous visitor id for the taste profile (backend: docs/PROFILE.md).
|
|
2
|
+
// UUID v4, persisted in localStorage with a cookie fallback; both can be
|
|
3
|
+
// unavailable (private mode, blocked storage), in which case the id lives for
|
|
4
|
+
// the page only and the backend simply sees a one-off visitor.
|
|
5
|
+
|
|
6
|
+
import { trackingAllowed, onConsentChange } from './consent.svelte';
|
|
7
|
+
|
|
8
|
+
const STORAGE_KEY = 'wineater_uid';
|
|
9
|
+
const COOKIE_DAYS = 365;
|
|
10
|
+
|
|
11
|
+
let memoryUid: string | null = null;
|
|
12
|
+
|
|
13
|
+
function generateUuidV4(): string {
|
|
14
|
+
const c = globalThis.crypto as Crypto | undefined;
|
|
15
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
16
|
+
const bytes = new Uint8Array(16);
|
|
17
|
+
if (c?.getRandomValues) c.getRandomValues(bytes);
|
|
18
|
+
else for (let i = 0; i < 16; i++) bytes[i] = (Math.random() * 256) | 0;
|
|
19
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
|
20
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
21
|
+
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
22
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
26
|
+
|
|
27
|
+
function readCookie(name: string): string | null {
|
|
28
|
+
try {
|
|
29
|
+
const prefix = name + '=';
|
|
30
|
+
for (const part of document.cookie.split(';')) {
|
|
31
|
+
const c = part.trim();
|
|
32
|
+
if (c.startsWith(prefix)) return decodeURIComponent(c.slice(prefix.length));
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
/* no document / blocked */
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeCookie(name: string, value: string): void {
|
|
41
|
+
try {
|
|
42
|
+
const expires = new Date(Date.now() + COOKIE_DAYS * 864e5).toUTCString();
|
|
43
|
+
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax; Secure`;
|
|
44
|
+
} catch {
|
|
45
|
+
/* blocked */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** tracking !== false и, если магазин требует, получено согласие (consent.svelte.ts). */
|
|
50
|
+
export function trackingEnabled(): boolean {
|
|
51
|
+
return trackingAllowed();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// "Отклонить" стирает id из storage — и из памяти, чтобы текущая страница
|
|
55
|
+
// перестала слать заголовок сразу, а не после перезагрузки.
|
|
56
|
+
onConsentChange((c) => {
|
|
57
|
+
if (c === 'denied') memoryUid = null;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
/** Stable anonymous id; same value for the whole page lifetime even if storage is blocked. */
|
|
61
|
+
export function getUid(): string | null {
|
|
62
|
+
if (!trackingEnabled()) return null;
|
|
63
|
+
if (memoryUid) return memoryUid;
|
|
64
|
+
|
|
65
|
+
let uid: string | null = null;
|
|
66
|
+
try {
|
|
67
|
+
uid = window.localStorage.getItem(STORAGE_KEY);
|
|
68
|
+
} catch {
|
|
69
|
+
/* blocked */
|
|
70
|
+
}
|
|
71
|
+
if (!uid || !UUID_RE.test(uid)) uid = readCookie(STORAGE_KEY);
|
|
72
|
+
if (!uid || !UUID_RE.test(uid)) uid = generateUuidV4();
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
window.localStorage.setItem(STORAGE_KEY, uid);
|
|
76
|
+
} catch {
|
|
77
|
+
/* quota / blocked */
|
|
78
|
+
}
|
|
79
|
+
if (readCookie(STORAGE_KEY) !== uid) writeCookie(STORAGE_KEY, uid);
|
|
80
|
+
|
|
81
|
+
memoryUid = uid;
|
|
82
|
+
return uid;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function newQueryId(): string {
|
|
86
|
+
return generateUuidV4();
|
|
87
|
+
}
|
|
@@ -5,6 +5,7 @@ import type { StoreConfig } from "./types";
|
|
|
5
5
|
import { deserializeStoreConfig } from "./deserialize";
|
|
6
6
|
import type { ProjectUiConfig } from "../ui-config/types";
|
|
7
7
|
import { getProjectUiConfig } from "../ui-config/get-config";
|
|
8
|
+
import { setStoreConsentPolicy } from "../profile/consent.svelte";
|
|
8
9
|
|
|
9
10
|
export type AppState = {
|
|
10
11
|
storeConfig: StoreConfig | null;
|
|
@@ -56,6 +57,9 @@ export const appStore = {
|
|
|
56
57
|
getSupportedLanguages(): LanguageCode[] {
|
|
57
58
|
return state.storeConfig?.available_languages ?? ['en'];
|
|
58
59
|
},
|
|
60
|
+
getConsentRequired(): boolean {
|
|
61
|
+
return state.storeConfig?.requires_cookie_consent === true;
|
|
62
|
+
},
|
|
59
63
|
getCurrency(): CurrencyCode {
|
|
60
64
|
return state.storeConfig?.currency ?? 'USD';
|
|
61
65
|
},
|
|
@@ -70,13 +74,15 @@ export const appStore = {
|
|
|
70
74
|
this.setStoreLanguage(state.storeConfig.lang as LanguageCode);
|
|
71
75
|
}
|
|
72
76
|
this.setUiConfig(getProjectUiConfig(state.storeConfig.id));
|
|
77
|
+
setStoreConsentPolicy(state.storeConfig.requires_cookie_consent);
|
|
73
78
|
state.isConfigLoaded = true;
|
|
74
79
|
},
|
|
75
80
|
// Stale-while-revalidate: a cached config is applied synchronously (first
|
|
76
81
|
// paint without waiting for the network) and refreshed in the background
|
|
77
82
|
// for the next visit. Without a cache we wait for the network as before.
|
|
78
83
|
async initializeStore() {
|
|
79
|
-
|
|
84
|
+
// :v2 — конфиг получил requires_cookie_consent; старый кэш (24 ч) его не знает
|
|
85
|
+
const cacheKey = `${cacheScope}:store-config:v2`;
|
|
80
86
|
const fetchAndCache = async () => {
|
|
81
87
|
const config = await wineaterApi.getStoreConfig();
|
|
82
88
|
writeCache(cacheKey, config);
|
|
@@ -9,6 +9,7 @@ type RawStoreConfig = {
|
|
|
9
9
|
open_ai_key: string | null;
|
|
10
10
|
currency: CurrencyCode;
|
|
11
11
|
title: string;
|
|
12
|
+
requires_cookie_consent?: boolean | null;
|
|
12
13
|
}
|
|
13
14
|
|
|
14
15
|
export function deserializeStoreConfig(data: RawStoreConfig): StoreConfig {
|
|
@@ -19,5 +20,6 @@ export function deserializeStoreConfig(data: RawStoreConfig): StoreConfig {
|
|
|
19
20
|
open_ai_key: data.open_ai_key ?? null,
|
|
20
21
|
currency: data.currency,
|
|
21
22
|
title: data.title,
|
|
23
|
+
requires_cookie_consent: data.requires_cookie_consent === true,
|
|
22
24
|
}
|
|
23
25
|
}
|