feat(widget): SP6 PR S6-1+2 - repo scaffolding + Preact bundle #1

Merged
serge merged 1 commits from feat/sp6-s6-1-2-scaffolding-bundle into main 2026-04-26 01:11:07 +03:00
17 changed files with 2233 additions and 1 deletions

6
.gitignore vendored Normal file
View File

@ -0,0 +1,6 @@
node_modules/
dist/
.vite/
*.local
.env*
.DS_Store

View File

@ -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

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,6 @@
/// <reference types="vite/client" />
declare module '*.css?inline' {
const content: string;
export default content;
}

25
tsconfig.json Normal file
View 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
View 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
View 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',
},
});