Compare commits
2 Commits
dd789eee49
...
c7edf8c66c
| Author | SHA1 | Date | |
|---|---|---|---|
| c7edf8c66c | |||
|
|
9676bbf09f |
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
|
||||
|
||||
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