feat(widget): SP6 PR S6-1+2 - repo scaffolding + Preact bundle #1
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
*.local
|
||||||
|
.env*
|
||||||
|
.DS_Store
|
||||||
50
README.md
50
README.md
@ -1,3 +1,51 @@
|
|||||||
# messenzy-widget
|
# messenzy-widget
|
||||||
|
|
||||||
Embeddable JS chat widget for Messenzy (Preact + Vite)
|
Embeddable JS chat widget for the Messenzy platform. Preact 10 + Vite 6, outputs a single IIFE (~30-50 KB minified) for direct `<script>` inclusion.
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 1. Add a mount point (optional — widget auto-creates one if absent) -->
|
||||||
|
<div id="messenzy-root"></div>
|
||||||
|
|
||||||
|
<!-- 2. Load the widget -->
|
||||||
|
<script
|
||||||
|
src="https://messenger-bot.mind4solutions.cloud/widget/messenzy-widget.iife.js"
|
||||||
|
data-bot-id="YOUR_BOT_ID"
|
||||||
|
data-api-key="YOUR_API_KEY"
|
||||||
|
data-position="bottom-right"
|
||||||
|
></script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Script attributes
|
||||||
|
|
||||||
|
| Attribute | Required | Default | Description |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `data-bot-id` | yes | — | Bot identifier |
|
||||||
|
| `data-api-key` | yes | — | Public API key for the bot |
|
||||||
|
| `data-server-url` | no | derived from script `src` origin | Override API/WS server base URL |
|
||||||
|
| `data-position` | no | `bottom-right` | `bottom-right` or `bottom-left` |
|
||||||
|
|
||||||
|
### Theme customization
|
||||||
|
|
||||||
|
Set CSS variables on `:root` or any parent element before loading the widget:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--messenzy-primary: #6366f1; /* bubble + send button color */
|
||||||
|
--messenzy-accent: #f43f5e; /* unread badge color */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run build # outputs dist/messenzy-widget.iife.js + dist/messenzy-widget.js (ESM)
|
||||||
|
npm run typecheck # no-emit TypeScript check
|
||||||
|
npm run dev # Vite dev server
|
||||||
|
```
|
||||||
|
|
||||||
|
## Spec
|
||||||
|
|
||||||
|
See `messenger-bot/docs/superpowers/specs/2026-04-25-sp6-webchat-widget-design.md` for the full SP6 webchat widget design specification.
|
||||||
|
|||||||
1150
package-lock.json
generated
Normal file
1150
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "messenzy-widget",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Embeddable JS chat widget for Messenzy",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "^10.19.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^6.0.0",
|
||||||
|
"typescript": "^5.6.0",
|
||||||
|
"@types/node": "^22.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/config.ts
Normal file
37
src/config.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
export type WidgetConfig = {
|
||||||
|
botId: string;
|
||||||
|
apiKey: string;
|
||||||
|
serverUrl: string;
|
||||||
|
position?: 'bottom-right' | 'bottom-left';
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseConfig(): WidgetConfig | null {
|
||||||
|
const script =
|
||||||
|
(document.currentScript as HTMLScriptElement | null) ??
|
||||||
|
(document.querySelector(
|
||||||
|
'script[data-bot-id][data-api-key]',
|
||||||
|
) as HTMLScriptElement | null);
|
||||||
|
if (!script) return null;
|
||||||
|
|
||||||
|
const botId = script.getAttribute('data-bot-id');
|
||||||
|
const apiKey = script.getAttribute('data-api-key');
|
||||||
|
const serverUrl =
|
||||||
|
script.getAttribute('data-server-url') ?? deriveServerFromSrc(script.src);
|
||||||
|
const position =
|
||||||
|
(script.getAttribute('data-position') as
|
||||||
|
| 'bottom-right'
|
||||||
|
| 'bottom-left'
|
||||||
|
| null) ?? 'bottom-right';
|
||||||
|
|
||||||
|
if (!botId || !apiKey) return null;
|
||||||
|
|
||||||
|
return { botId, apiKey, serverUrl, position };
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveServerFromSrc(src: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(src).origin;
|
||||||
|
} catch {
|
||||||
|
return 'https://messenger-bot.mind4solutions.cloud';
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/index.ts
Normal file
40
src/index.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import { h, render } from 'preact';
|
||||||
|
import { Widget } from './ui/widget.js';
|
||||||
|
import { parseConfig } from './config.js';
|
||||||
|
import cssText from './ui/theme.css?inline';
|
||||||
|
|
||||||
|
// Inject styles into shadow-free <style> on first load
|
||||||
|
(function injectStyles() {
|
||||||
|
if (document.getElementById('messenzy-styles')) return;
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'messenzy-styles';
|
||||||
|
style.textContent = cssText;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
})();
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
const config = parseConfig();
|
||||||
|
if (!config) {
|
||||||
|
console.warn(
|
||||||
|
'[Messenzy] Missing data-bot-id or data-api-key on script tag. Widget disabled.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const root =
|
||||||
|
document.getElementById('messenzy-root') ??
|
||||||
|
(() => {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.id = 'messenzy-root';
|
||||||
|
document.body.appendChild(d);
|
||||||
|
return d;
|
||||||
|
})();
|
||||||
|
|
||||||
|
render(h(Widget, { config }), root);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', boot);
|
||||||
|
} else {
|
||||||
|
boot();
|
||||||
|
}
|
||||||
14
src/storage/visitor.ts
Normal file
14
src/storage/visitor.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
const KEY = 'messenzy_visitor_id';
|
||||||
|
|
||||||
|
export function getVisitorId(): string {
|
||||||
|
try {
|
||||||
|
const existing = localStorage.getItem(KEY);
|
||||||
|
if (existing && /^[0-9a-f-]{36}$/i.test(existing)) return existing;
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
localStorage.setItem(KEY, id);
|
||||||
|
return id;
|
||||||
|
} catch {
|
||||||
|
// localStorage may be blocked (incognito + cookies disabled); fall back to in-memory
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
}
|
||||||
198
src/transport/ws-client.ts
Normal file
198
src/transport/ws-client.ts
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import type { WidgetConfig } from '../config.js';
|
||||||
|
|
||||||
|
export type WsMessage =
|
||||||
|
| { type: 'welcome'; visitor_id: string }
|
||||||
|
| {
|
||||||
|
type: 'history';
|
||||||
|
messages: Array<{
|
||||||
|
id: string;
|
||||||
|
from: 'bot' | 'user';
|
||||||
|
text: string;
|
||||||
|
at: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
| { type: 'bot_message'; id: string; text: string; created_at: string }
|
||||||
|
| { type: 'typing'; state: 'on' | 'off' }
|
||||||
|
| { type: 'handoff'; message: string }
|
||||||
|
| { type: 'error'; code: string; retry_after_s?: number }
|
||||||
|
| { type: 'pong' };
|
||||||
|
|
||||||
|
export type ClientMessage =
|
||||||
|
| { type: 'user_message'; id: string; text: string }
|
||||||
|
| { type: 'ping' };
|
||||||
|
|
||||||
|
export type Transport = {
|
||||||
|
send: (msg: ClientMessage) => void;
|
||||||
|
close: () => void;
|
||||||
|
isConnected: () => boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type TransportOpts = {
|
||||||
|
botId: string;
|
||||||
|
apiKey: string;
|
||||||
|
visitorId: string;
|
||||||
|
serverUrl: string;
|
||||||
|
onMessage: (msg: WsMessage) => void;
|
||||||
|
onConnectionChange?: (connected: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_WS_RETRIES = 5;
|
||||||
|
const POLL_INTERVAL_MS = 5000;
|
||||||
|
const PING_INTERVAL_MS = 30000;
|
||||||
|
|
||||||
|
export function createTransport(opts: TransportOpts): Transport {
|
||||||
|
let ws: WebSocket | null = null;
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let pingTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let reconnectAttempts = 0;
|
||||||
|
let pendingMessages: ClientMessage[] = [];
|
||||||
|
let lastSeenAt: string | null = null;
|
||||||
|
let usingPolling = false;
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
function connectWs() {
|
||||||
|
if (closed) return;
|
||||||
|
const wsUrl =
|
||||||
|
opts.serverUrl.replace(/^http/, 'ws') +
|
||||||
|
`/webchat/ws?botId=${encodeURIComponent(opts.botId)}` +
|
||||||
|
`&apiKey=${encodeURIComponent(opts.apiKey)}` +
|
||||||
|
`&visitorId=${encodeURIComponent(opts.visitorId)}`;
|
||||||
|
try {
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
} catch {
|
||||||
|
onWsClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
reconnectAttempts = 0;
|
||||||
|
opts.onConnectionChange?.(true);
|
||||||
|
// Flush pending messages accumulated while disconnected
|
||||||
|
for (const m of pendingMessages) ws!.send(JSON.stringify(m));
|
||||||
|
pendingMessages = [];
|
||||||
|
pingTimer = setInterval(() => {
|
||||||
|
try {
|
||||||
|
ws?.send(JSON.stringify({ type: 'ping' }));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, PING_INTERVAL_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(ev.data as string) as WsMessage;
|
||||||
|
opts.onMessage(msg);
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed frames */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = onWsClose;
|
||||||
|
ws.onerror = () => {
|
||||||
|
/* close handler runs after error */
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWsClose() {
|
||||||
|
if (closed) return;
|
||||||
|
if (pingTimer) {
|
||||||
|
clearInterval(pingTimer);
|
||||||
|
pingTimer = null;
|
||||||
|
}
|
||||||
|
opts.onConnectionChange?.(false);
|
||||||
|
reconnectAttempts++;
|
||||||
|
if (reconnectAttempts <= MAX_WS_RETRIES) {
|
||||||
|
const delay = Math.min(
|
||||||
|
1000 * Math.pow(2, reconnectAttempts - 1),
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
setTimeout(connectWs, delay);
|
||||||
|
} else {
|
||||||
|
// Exceeded retry budget — fall back to HTTP polling
|
||||||
|
usingPolling = true;
|
||||||
|
void startPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPolling() {
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
if (closed) return;
|
||||||
|
const url =
|
||||||
|
`${opts.serverUrl}/webchat/history?` +
|
||||||
|
`botId=${encodeURIComponent(opts.botId)}` +
|
||||||
|
`&apiKey=${encodeURIComponent(opts.apiKey)}` +
|
||||||
|
`&visitorId=${encodeURIComponent(opts.visitorId)}` +
|
||||||
|
(lastSeenAt ? `&since=${encodeURIComponent(lastSeenAt)}` : '');
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { credentials: 'omit' });
|
||||||
|
if (!res.ok) return;
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
messages?: Array<{ id: string; text: string; at: string }>;
|
||||||
|
};
|
||||||
|
if (data.messages) {
|
||||||
|
for (const m of data.messages) {
|
||||||
|
opts.onMessage({
|
||||||
|
type: 'bot_message',
|
||||||
|
id: m.id,
|
||||||
|
text: m.text,
|
||||||
|
created_at: m.at,
|
||||||
|
});
|
||||||
|
lastSeenAt = m.at;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* network failure — will retry on next interval */
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
opts.onConnectionChange?.(false); // polling = not "connected" in strict sense
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendViaHttp(msg: ClientMessage) {
|
||||||
|
if (msg.type !== 'user_message') return;
|
||||||
|
try {
|
||||||
|
await fetch(`${opts.serverUrl}/webchat/msg`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
botId: opts.botId,
|
||||||
|
apiKey: opts.apiKey,
|
||||||
|
visitorId: opts.visitorId,
|
||||||
|
text: msg.text,
|
||||||
|
idempotency_key: msg.id,
|
||||||
|
}),
|
||||||
|
credentials: 'omit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
/* ignore — user will see message unacknowledged */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
connectWs();
|
||||||
|
|
||||||
|
return {
|
||||||
|
send(msg) {
|
||||||
|
if (usingPolling) {
|
||||||
|
void sendViaHttp(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(msg));
|
||||||
|
} else {
|
||||||
|
pendingMessages.push(msg);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
close() {
|
||||||
|
closed = true;
|
||||||
|
if (pingTimer) clearInterval(pingTimer);
|
||||||
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
if (ws) ws.close();
|
||||||
|
},
|
||||||
|
isConnected() {
|
||||||
|
return !!(ws && ws.readyState === WebSocket.OPEN);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export WidgetConfig so callers can import from one place if needed
|
||||||
|
export type { WidgetConfig };
|
||||||
50
src/ui/bubble.tsx
Normal file
50
src/ui/bubble.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
|
||||||
|
type BubbleProps = {
|
||||||
|
open: boolean;
|
||||||
|
position: 'bottom-right' | 'bottom-left';
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Bubble({ open, position, onClick }: BubbleProps) {
|
||||||
|
const posClass = position === 'bottom-left' ? 'mz-bubble--left' : 'mz-bubble--right';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="messenzy-widget">
|
||||||
|
<button
|
||||||
|
class={`mz-bubble ${posClass}`}
|
||||||
|
onClick={onClick}
|
||||||
|
aria-label={open ? 'Close chat' : 'Open chat'}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span class="mz-bubble__icon">
|
||||||
|
{open ? <CloseIcon /> : <ChatIcon />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChatIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M20 2H4C2.9 2 2 2.9 2 4V22L6 18H20C21.1 18 22 17.1 22 16V4C22 2.9 21.1 2 20 2Z"
|
||||||
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M15 5L5 15M5 5L15 15"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
29
src/ui/message.tsx
Normal file
29
src/ui/message.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
|
||||||
|
type MessageProps = {
|
||||||
|
id: string;
|
||||||
|
from: 'bot' | 'user';
|
||||||
|
text: string;
|
||||||
|
at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Message({ from, text, at }: MessageProps) {
|
||||||
|
const time = formatTime(at);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`mz-msg mz-msg--${from}`}>
|
||||||
|
<div class="mz-msg__bubble">{text}</div>
|
||||||
|
{time && <span class="mz-msg__time">{time}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(iso: string): string {
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '';
|
||||||
|
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
143
src/ui/panel.tsx
Normal file
143
src/ui/panel.tsx
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
|
import { Message } from './message.js';
|
||||||
|
|
||||||
|
type Msg = { id: string; from: 'bot' | 'user'; text: string; at: string };
|
||||||
|
|
||||||
|
type PanelProps = {
|
||||||
|
messages: Msg[];
|
||||||
|
typing: boolean;
|
||||||
|
connected: boolean;
|
||||||
|
position?: 'bottom-right' | 'bottom-left';
|
||||||
|
onSend: (text: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Panel({
|
||||||
|
messages,
|
||||||
|
typing,
|
||||||
|
connected,
|
||||||
|
position = 'bottom-right',
|
||||||
|
onSend,
|
||||||
|
onClose,
|
||||||
|
}: PanelProps) {
|
||||||
|
const [draft, setDraft] = useState('');
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const posClass = position === 'bottom-left' ? 'mz-panel--left' : 'mz-panel--right';
|
||||||
|
|
||||||
|
// Auto-scroll to bottom when messages or typing changes
|
||||||
|
useEffect(() => {
|
||||||
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [messages, typing]);
|
||||||
|
|
||||||
|
// Focus input when panel opens
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
const text = draft.trim();
|
||||||
|
if (!text) return;
|
||||||
|
setDraft('');
|
||||||
|
onSend(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
submit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSend = draft.trim().length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class={`messenzy-widget mz-panel ${posClass}`} role="dialog" aria-label="Chat">
|
||||||
|
{/* Header */}
|
||||||
|
<div class="mz-panel__header">
|
||||||
|
<div class="mz-panel__title">
|
||||||
|
<span class={`mz-status-dot${connected ? ' mz-status-dot--online' : ''}`} />
|
||||||
|
Messenzy
|
||||||
|
</div>
|
||||||
|
<button class="mz-close-btn" onClick={onClose} aria-label="Close chat">
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div class="mz-messages">
|
||||||
|
{messages.length === 0 && !typing && (
|
||||||
|
<div class="mz-empty">
|
||||||
|
<span class="mz-empty__icon">
|
||||||
|
<EmptyIcon />
|
||||||
|
</span>
|
||||||
|
<p class="mz-empty__text">
|
||||||
|
{connected ? 'Send a message to get started.' : 'Connecting…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((m) => (
|
||||||
|
<Message key={m.id} {...m} />
|
||||||
|
))}
|
||||||
|
{typing && (
|
||||||
|
<div class="mz-typing" aria-label="Bot is typing">
|
||||||
|
<span class="mz-typing__dot" />
|
||||||
|
<span class="mz-typing__dot" />
|
||||||
|
<span class="mz-typing__dot" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div ref={bottomRef} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Input bar */}
|
||||||
|
<div class="mz-input-bar">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
class="mz-input"
|
||||||
|
type="text"
|
||||||
|
placeholder={connected ? 'Type a message…' : 'Reconnecting…'}
|
||||||
|
disabled={!connected}
|
||||||
|
value={draft}
|
||||||
|
onInput={(e) => setDraft((e.target as HTMLInputElement).value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
maxLength={2000}
|
||||||
|
aria-label="Message input"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="mz-send-btn"
|
||||||
|
disabled={!canSend || !connected}
|
||||||
|
onClick={submit}
|
||||||
|
aria-label="Send message"
|
||||||
|
>
|
||||||
|
<SendIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CloseIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
|
<path d="M14 4L4 14M4 4L14 14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SendIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
|
||||||
|
<path d="M15.5 2.5L8 10M15.5 2.5L10.5 15.5L8 10L2.5 7.5L15.5 2.5Z" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyIcon() {
|
||||||
|
return (
|
||||||
|
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" aria-hidden="true">
|
||||||
|
<rect width="40" height="40" rx="20" fill="currentColor" opacity="0.08" />
|
||||||
|
<path d="M27 12H13C11.9 12 11 12.9 11 14V29L15 25H27C28.1 25 29 24.1 29 23V14C29 12.9 28.1 12 27 12Z" fill="currentColor" opacity="0.4" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
348
src/ui/theme.css
Normal file
348
src/ui/theme.css
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
.messenzy-widget {
|
||||||
|
--mz-primary: var(--messenzy-primary, #3b82f6);
|
||||||
|
--mz-accent: var(--messenzy-accent, #ef4444);
|
||||||
|
--mz-bg: #ffffff;
|
||||||
|
--mz-surface: #f9fafb;
|
||||||
|
--mz-text: #111827;
|
||||||
|
--mz-text-muted: #6b7280;
|
||||||
|
--mz-border: #e5e7eb;
|
||||||
|
--mz-radius: 12px;
|
||||||
|
--mz-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||||
|
--mz-z: 2147483000;
|
||||||
|
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--mz-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.messenzy-widget * {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bubble ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mz-bubble {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 24px;
|
||||||
|
z-index: var(--mz-z);
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mz-primary);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.18);
|
||||||
|
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||||||
|
color: #fff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-bubble:hover {
|
||||||
|
transform: scale(1.08);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-bubble:active {
|
||||||
|
transform: scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-bubble--right {
|
||||||
|
right: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-bubble--left {
|
||||||
|
left: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-bubble__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Panel ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mz-panel {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 96px;
|
||||||
|
z-index: var(--mz-z);
|
||||||
|
width: 360px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
height: 520px;
|
||||||
|
max-height: calc(100vh - 120px);
|
||||||
|
background: var(--mz-bg);
|
||||||
|
border-radius: var(--mz-radius);
|
||||||
|
box-shadow: var(--mz-shadow);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
animation: mz-panel-in 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-panel--right {
|
||||||
|
right: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-panel--left {
|
||||||
|
left: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mz-panel-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px) scale(0.97);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panel header */
|
||||||
|
.mz-panel__header {
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--mz-primary);
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-panel__title {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 15px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-status-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 255, 255, 0.45);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-status-dot--online {
|
||||||
|
background: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
padding: 4px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-close-btn:hover {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Message list */
|
||||||
|
.mz-messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-messages::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-messages::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--mz-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.mz-empty {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--mz-text-muted);
|
||||||
|
padding: 32px 16px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-empty__icon {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-empty__text {
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typing indicator */
|
||||||
|
.mz-typing {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--mz-surface);
|
||||||
|
border-radius: 16px 16px 16px 4px;
|
||||||
|
width: fit-content;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-typing__dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mz-text-muted);
|
||||||
|
animation: mz-typing-bounce 1.2s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-typing__dot:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-typing__dot:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes mz-typing-bounce {
|
||||||
|
0%, 60%, 100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Input bar */
|
||||||
|
.mz-input-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
border-top: 1px solid var(--mz-border);
|
||||||
|
background: var(--mz-bg);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-input {
|
||||||
|
flex: 1;
|
||||||
|
border: 1px solid var(--mz-border);
|
||||||
|
border-radius: 24px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
color: var(--mz-text);
|
||||||
|
background: var(--mz-surface);
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-input:focus {
|
||||||
|
border-color: var(--mz-primary);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-input::placeholder {
|
||||||
|
color: var(--mz-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-input:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-send-btn {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--mz-primary);
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.15s, transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-send-btn:hover:not(:disabled) {
|
||||||
|
opacity: 0.88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-send-btn:active:not(:disabled) {
|
||||||
|
transform: scale(0.94);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-send-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Message ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.mz-msg {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 78%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-msg--user {
|
||||||
|
align-self: flex-end;
|
||||||
|
align-items: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-msg--bot {
|
||||||
|
align-self: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-msg__bubble {
|
||||||
|
padding: 9px 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-msg--user .mz-msg__bubble {
|
||||||
|
background: var(--mz-primary);
|
||||||
|
color: #fff;
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-msg--bot .mz-msg__bubble {
|
||||||
|
background: var(--mz-surface);
|
||||||
|
color: var(--mz-text);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
|
border: 1px solid var(--mz-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mz-msg__time {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mz-text-muted);
|
||||||
|
margin-top: 3px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
77
src/ui/widget.tsx
Normal file
77
src/ui/widget.tsx
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import { h, Fragment } from 'preact';
|
||||||
|
import { useState, useEffect, useRef } from 'preact/hooks';
|
||||||
|
import { Bubble } from './bubble.js';
|
||||||
|
import { Panel } from './panel.js';
|
||||||
|
import {
|
||||||
|
createTransport,
|
||||||
|
type Transport,
|
||||||
|
type WsMessage,
|
||||||
|
} from '../transport/ws-client.js';
|
||||||
|
import { getVisitorId } from '../storage/visitor.js';
|
||||||
|
import type { WidgetConfig } from '../config.js';
|
||||||
|
|
||||||
|
type Message = { id: string; from: 'bot' | 'user'; text: string; at: string };
|
||||||
|
|
||||||
|
export function Widget({ config }: { config: WidgetConfig }) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [typing, setTyping] = useState(false);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const visitorId = useRef(getVisitorId()).current;
|
||||||
|
const transportRef = useRef<Transport | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = createTransport({
|
||||||
|
botId: config.botId,
|
||||||
|
apiKey: config.apiKey,
|
||||||
|
visitorId,
|
||||||
|
serverUrl: config.serverUrl,
|
||||||
|
onConnectionChange: setConnected,
|
||||||
|
onMessage: (msg: WsMessage) => {
|
||||||
|
if (msg.type === 'history') {
|
||||||
|
setMessages(msg.messages);
|
||||||
|
} else if (msg.type === 'bot_message') {
|
||||||
|
setMessages((m) => [
|
||||||
|
...m,
|
||||||
|
{ id: msg.id, from: 'bot', text: msg.text, at: msg.created_at },
|
||||||
|
]);
|
||||||
|
} else if (msg.type === 'typing') {
|
||||||
|
setTyping(msg.state === 'on');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
transportRef.current = t;
|
||||||
|
return () => t.close();
|
||||||
|
// config fields are stable after mount — intentionally omit from deps
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const send = (text: string) => {
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
setMessages((m) => [
|
||||||
|
...m,
|
||||||
|
{ id, from: 'user', text, at: new Date().toISOString() },
|
||||||
|
]);
|
||||||
|
transportRef.current?.send({ type: 'user_message', id, text });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
{isOpen && (
|
||||||
|
<Panel
|
||||||
|
messages={messages}
|
||||||
|
typing={typing}
|
||||||
|
connected={connected}
|
||||||
|
position={config.position ?? 'bottom-right'}
|
||||||
|
onSend={send}
|
||||||
|
onClose={() => setIsOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Bubble
|
||||||
|
open={isOpen}
|
||||||
|
position={config.position ?? 'bottom-right'}
|
||||||
|
onClick={() => setIsOpen((v) => !v)}
|
||||||
|
/>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
src/vite-env.d.ts
vendored
Normal file
6
src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.css?inline' {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitReturns": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationDir": "./dist/types",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
31
vite.config.ts
Normal file
31
vite.config.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import { resolve } from 'path';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
react: 'preact/compat',
|
||||||
|
'react-dom': 'preact/compat',
|
||||||
|
'react-dom/test-utils': 'preact/test-utils',
|
||||||
|
'react/jsx-runtime': 'preact/jsx-runtime',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: resolve(__dirname, 'src/index.ts'),
|
||||||
|
name: 'MeszenzyWidget',
|
||||||
|
formats: ['iife', 'es'],
|
||||||
|
fileName: (format) =>
|
||||||
|
format === 'iife' ? 'messenzy-widget.iife.js' : 'messenzy-widget.js',
|
||||||
|
},
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
inlineDynamicImports: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cssCodeSplit: false,
|
||||||
|
minify: 'esbuild',
|
||||||
|
sourcemap: false,
|
||||||
|
target: 'es2022',
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user