Try the interactive chat
Embedded chat widget
A website chat connected to your assistant, with complete HTML/JavaScript and React/Next.js examples.
Both examples read streaming replies, persist the conversation in the browser, render product cards and buttons, and display staff replies after handoff.
Create a publishable key in Sonecentral first, and add your site to İzin verilen siteler (Allowed origins). For development you can add http://localhost:3000. See Authentication and keys.
Plain HTML and JavaScript
No library is required. Paste these three pieces immediately before your page's </body> and replace KEY with your publishable key.
<button id="sone-open" type="button" aria-expanded="false" aria-controls="sone-panel">Chat with us</button>
<section id="sone-panel" hidden aria-label="Chat">
<header class="sone-head">
<strong>Chat with us</strong>
<span>
<button id="sone-reset" type="button">New chat</button>
<button id="sone-close" type="button" aria-label="Close chat">Close</button>
</span>
</header>
<ol id="sone-log" aria-live="polite"></ol>
<p id="sone-status" hidden></p>
<form id="sone-form">
<textarea id="sone-input" rows="2" maxlength="4000" placeholder="Write your message" aria-label="Message"></textarea>
<button type="submit">Send</button>
</form>
</section>#sone-open {
position: fixed; right: 20px; bottom: 20px; z-index: 50;
padding: 12px 18px; border: 0; border-radius: 999px;
background: #17123f; color: #fff; font: 600 15px/1 system-ui, sans-serif; cursor: pointer;
}
#sone-panel {
position: fixed; right: 20px; bottom: 76px; z-index: 50;
width: min(380px, calc(100vw - 40px)); height: min(580px, calc(100dvh - 110px));
display: flex; flex-direction: column; overflow: hidden;
background: #fff; color: #17123f; border-radius: 16px;
box-shadow: 0 2px 6px rgba(23, 18, 63, 0.08), 0 12px 32px rgba(23, 18, 63, 0.12);
font: 15px/1.45 system-ui, sans-serif;
}
#sone-panel[hidden] { display: none; }
.sone-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; }
.sone-head button { border: 0; background: none; color: #524d7a; font: inherit; font-size: 13px; cursor: pointer; }
#sone-log { flex: 1; overflow-y: auto; margin: 0; padding: 4px 16px 12px; list-style: none; display: flex; flex-direction: column; gap: 8px; }
.sone-row { display: flex; flex-direction: column; align-items: flex-start; }
.sone-row.sone-user { align-items: flex-end; }
.sone-text { margin: 0; max-width: 85%; padding: 8px 12px; border-radius: 14px; background: #f1eefb; white-space: pre-wrap; }
.sone-user .sone-text { background: #17123f; color: #fff; }
.sone-staff .sone-text::before { content: 'Ekip'; display: block; font-size: 12px; font-weight: 600; color: #524d7a; }
.sone-catalog { display: grid; grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: 8px; width: 100%; }
.sone-item { display: flex; flex-direction: column; gap: 2px; padding: 10px; border-radius: 12px; background: #f7f5fd; }
.sone-item img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 8px; margin-bottom: 4px; }
.sone-item span { font-size: 13px; color: #524d7a; }
.sone-item .sone-price { color: #17123f; font-weight: 600; }
.sone-item a, .sone-file { color: #4f31d6; font-size: 14px; font-weight: 600; }
.sone-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.sone-actions p { width: 100%; margin: 0 0 2px; }
.sone-actions button, .sone-actions a {
padding: 6px 12px; border: 1px solid #cfc5f7; border-radius: 999px;
background: #fff; color: #4f31d6; font: inherit; font-size: 14px; text-decoration: none; cursor: pointer;
}
#sone-status { margin: 0; padding: 0 16px 8px; font-size: 13px; color: #524d7a; }
#sone-form { display: flex; gap: 8px; padding: 12px; background: #faf9fe; }
#sone-input { flex: 1; resize: none; border: 1px solid #ddd6f3; border-radius: 10px; padding: 8px 10px; font: inherit; }
#sone-form button { border: 0; border-radius: 10px; padding: 0 14px; background: #17123f; color: #fff; font: inherit; font-weight: 600; cursor: pointer; }<script type="module">
const API = 'https://pb.sonecentral.com/api/v1';
const KEY = 'pk-sone-v1-TARAYICI_ANAHTARINIZ';
const MODEL = 'default';
const STORE = 'sone-chat-v1';
// Errors where the assistant did not reply. In an existing chat, the message reached the team.
const SENT_TO_TEAM = ['ai_turn_ceiling', 'assistant_quota_exhausted', 'plan_required', 'assistant_paused'];
const $ = (id) => document.getElementById(id);
const panel = $('sone-panel');
const log = $('sone-log');
const input = $('sone-input');
const statusLine = $('sone-status');
let chat = load();
let busy = false;
let pollTimer = null;
// team: team owns the chat. waiting: message reached the team; awaiting reply.
// cursor: last message ID read from the conversation.
function empty() {
return { conversationId: null, team: false, waiting: false, cursor: null, items: [] };
}
function load() {
try {
return { ...empty(), ...JSON.parse(localStorage.getItem(STORE)) };
} catch {
return empty();
}
}
function save() {
try {
localStorage.setItem(STORE, JSON.stringify(chat));
} catch {}
}
// All text uses textContent so fields cannot inject code.
function el(tag, props = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(props)) {
if (value == null || value === false) continue;
if (key === 'class') node.className = value;
else if (key.startsWith('on')) node.addEventListener(key.slice(2), value);
else node.setAttribute(key, value);
}
for (const child of children.flat()) {
if (child == null || child === false) continue;
node.append(typeof child === 'string' ? document.createTextNode(child) : child);
}
return node;
}
function renderMessage(message, current) {
if (message.type === 'catalog' && message.catalog) {
return el('div', { class: 'sone-catalog' }, message.catalog.items.map((item) =>
el('article', { class: 'sone-item' },
item.image_url ? el('img', { src: item.image_url, alt: '', loading: 'lazy' }) : null,
el('strong', {}, item.title),
item.detail ? el('span', {}, item.detail) : null,
item.price ? el('span', { class: 'sone-price' }, item.price) : null,
item.url ? el('a', { href: item.url, target: '_blank', rel: 'noopener' }, item.button_title || 'View') : null,
)));
}
if (message.type === 'actions' && message.actions) {
const { title, text, buttons = [], choices = [] } = message.actions;
return el('div', { class: 'sone-actions' },
title ? el('strong', {}, title) : null,
text ? el('p', { class: 'sone-text' }, text) : null,
buttons.map((button) => el('a', { href: button.url, target: '_blank', rel: 'noopener' }, button.title)),
// Show choices until the visitor writes again.
current ? choices.map((choice) => el('button', { type: 'button', onclick: () => send(choice) }, choice)) : null);
}
if (message.type === 'file' && message.file?.url) {
return el('a', { class: 'sone-file', href: message.file.url, download: message.file.name }, message.file.name);
}
return el('p', { class: 'sone-text' }, message.text);
}
function render() {
const lastUser = chat.items.map((item) => item.role).lastIndexOf('user');
log.replaceChildren(...chat.items.map((item, index) => {
if (item.role === 'user') return el('li', { class: 'sone-row sone-user' }, el('p', { class: 'sone-text' }, item.text));
return el('li', { class: `sone-row sone-${item.message.role}` }, renderMessage(item.message, index > lastUser));
}));
log.scrollTop = log.scrollHeight;
}
function setStatus(text) {
statusLine.hidden = !text;
statusLine.textContent = text || '';
}
function idleStatus() {
if (chat.team) return 'Our team will reply here as soon as possible.';
if (chat.waiting) return 'Your message reached our team. We will reply here as soon as possible.';
return '';
}
// Add business-side messages once by ID; return the number added.
function remember(messages) {
const known = new Set(chat.items.map((item) => item.message?.id));
let added = 0;
for (const message of messages) {
if (known.has(message.id)) continue;
known.add(message.id);
chat.items.push({ role: 'business', message });
chat.cursor = message.id;
added += 1;
}
return added;
}
// Read Chat Completions streams. sone is always top-level on the chunk:
// sone.message is a completed message; sone.activity is the current activity;
// sone on the final chunk summarizes the turn.
async function readStream(response, on) {
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
let buffer = '';
let summary = null;
for (;;) {
const { value, done } = await reader.read();
if (done) return summary;
buffer += value.replace(/\r\n/g, '\n');
let end;
while ((end = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
for (const line of frame.split('\n')) {
if (!line.startsWith('data:')) continue; // skip ": ping" comments
const data = line.slice(5).trim();
if (data === '[DONE]') return summary;
const chunk = JSON.parse(data);
if (chunk.error) throw Object.assign(new Error(chunk.error.message), { code: chunk.error.code });
const choice = chunk.choices?.[0];
if (choice?.delta?.content) on.text(choice.delta.content);
if (chunk.sone?.activity) on.activity(chunk.sone.activity);
if (chunk.sone?.message) on.message(chunk.sone.message);
if (choice?.finish_reason === 'stop' && chunk.sone) summary = chunk.sone;
}
}
}
}
async function send(raw) {
const text = raw.trim();
if (!text || busy) return;
busy = true;
chat.items.push({ role: 'user', text });
save();
render();
// Show streamed text here; replace it with the completed message.
const live = el('p', { class: 'sone-text' });
const liveRow = el('li', { class: 'sone-row sone-assistant' }, live);
let problem = '';
setStatus('Typing…');
try {
const response = await fetch(`${API}/chat/completions`, {
method: 'POST',
headers: {
Authorization: `Bearer ${KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': crypto.randomUUID(),
},
body: JSON.stringify({
model: MODEL,
stream: true,
conversation_id: chat.conversationId ?? undefined,
messages: [{ role: 'user', content: text }],
}),
});
if (!response.ok) {
const body = await response.json().catch(() => null);
throw Object.assign(new Error(body?.error?.message || ''), { code: body?.error?.code });
}
// The conversation ID arrives in headers so it survives a disconnected stream.
chat.conversationId = response.headers.get('x-sone-conversation-id') || chat.conversationId;
const summary = await readStream(response, {
text(delta) {
if (!liveRow.isConnected) log.append(liveRow);
live.textContent += live.textContent ? delta : delta.trimStart();
log.scrollTop = log.scrollHeight;
},
activity(label) {
setStatus(`${label}…`);
},
message(message) {
liveRow.remove();
live.textContent = '';
remember([message]);
render();
},
});
if (summary) {
chat.conversationId = summary.conversation_id;
chat.team = summary.status === 'handoff' || summary.status === 'human';
chat.waiting = false;
}
} catch (error) {
if (error.code === 'conversation_not_found' || error.code === 'conversation_model_mismatch') {
// Saved conversation is unavailable; the next message starts a new chat.
Object.assign(chat, { conversationId: null, team: false, waiting: false, cursor: null });
}
if (SENT_TO_TEAM.includes(error.code) && chat.conversationId) chat.waiting = true;
// Sonecentral errors are Turkish; network errors use our own fallback.
else problem = (error.code && error.message) || 'Could not send your message. Please try again later.';
} finally {
liveRow.remove();
busy = false;
setStatus(problem || idleStatus());
syncPolling();
save();
render();
}
}
// Poll while the team owns the chat or a forwarded message awaits a reply.
// Always advance to response last_id, including visitor messages.
// Skip rendering visitor messages already shown.
async function poll() {
if (!chat.conversationId || busy || panel.hidden || document.hidden) return;
for (;;) {
const url = new URL(`${API}/conversations/${encodeURIComponent(chat.conversationId)}/messages`);
if (chat.cursor) url.searchParams.set('after', chat.cursor);
const response = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } });
if (response.status === 400) chat.cursor = null; // invalid_cursor: next poll restarts from the beginning
if (!response.ok) break;
const page = await response.json();
const added = remember(page.data.filter((message) => message.role !== 'user'));
chat.cursor = page.last_id ?? chat.cursor;
if (page.mode === 'team') chat.team = true;
if (added > 0) chat.waiting = false;
if (page.mode === 'assistant' && !page.reply_pending && !chat.waiting) chat.team = false;
if (!page.has_more) break;
}
save();
render();
setStatus(idleStatus());
syncPolling();
}
function syncPolling() {
const wanted = Boolean(chat.conversationId) && (chat.team || chat.waiting);
if (wanted && !pollTimer) pollTimer = setInterval(() => poll().catch(() => {}), 5000);
if (!wanted && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
$('sone-open').addEventListener('click', () => {
const opening = panel.hidden;
panel.hidden = !opening;
$('sone-open').setAttribute('aria-expanded', String(opening));
if (opening) {
render();
setStatus(idleStatus());
input.focus();
if (chat.team || chat.waiting) poll().catch(() => {});
}
});
$('sone-close').addEventListener('click', () => {
panel.hidden = true;
$('sone-open').setAttribute('aria-expanded', 'false');
});
$('sone-reset').addEventListener('click', () => {
chat = empty();
syncPolling();
save();
setStatus('');
render();
});
$('sone-form').addEventListener('submit', (event) => {
event.preventDefault();
const text = input.value;
input.value = '';
send(text);
});
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
$('sone-form').requestSubmit();
}
});
syncPolling();
</script>Customize CSS and button labels to match your site. To display the assistant's dashboard name, use name from GET /models.
What this example does
- Streams
POST /chat/completionsand displays text as it arrives. When a message completes, top-levelsone.messageredraws it as a product card, buttons or file. - Sends the conversation ID and only the new message, with a new
Idempotency-Keyfor each message. - Shows
sone.activityin the status line while the assistant uses tools. - Sends quick reply and non-URL button titles as new visitor messages.
- Saves conversation ID, displayed messages and the latest cursor in
localStorage. Refreshes resume the chat; if storage is unavailable, it continues within the current tab. - After
handofforhuman, pollsGET /conversations/{id}/messages?after=every five seconds. Advances tolast_id, skips already displayed visitor messages, and stops whenmodeis"assistant"andreply_pendingis false. Pauses when the widget is closed or the tab is hidden. Increase the interval for many simultaneous visitors: Polling frequency. - After
ai_turn_ceiling,assistant_quota_exhausted,plan_requiredorassistant_pausedin an existing chat, explains that the message reached the team and waits for their reply. - Starts a new conversation on the next message if the stored one is gone (
conversation_not_found). - Catches errors inside the stream and shows them in the status line.
React and Next.js
Use the CSS above, replacing selectors starting with #sone- with classes such as .sone-panel.
This is the same widget using Vercel AI SDK. Add SoneMessage, SoneCatalog, SoneActions and SoneFile from Rich messages, and SoneUIMessage from Vercel AI SDK, to lib/sone.ts first.
npm i ai @ai-sdk/react'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useEffect, useState } from 'react';
import type { SoneMessage, SoneUIMessage } from '@/lib/sone';
const API = 'https://pb.sonecentral.com/api/v1';
const KEY = process.env.NEXT_PUBLIC_SONE_PUBLISHABLE_KEY!;
const STORE = 'sone-chat-v1';
const conversationOf = (messages: SoneUIMessage[]) =>
messages.findLast((m) => m.metadata?.conversationId)?.metadata?.conversationId;
const transport = new DefaultChatTransport<SoneUIMessage>({
api: `${API}/chat`,
headers: { Authorization: `Bearer ${KEY}` },
// With a conversation ID, send only the new message; Sonecentral stores history.
prepareSendMessagesRequest: ({ id, messages }) => {
const conversationId = conversationOf(messages);
return {
body: { id, model: 'default', conversationId, messages: conversationId ? messages.slice(-1) : messages },
};
},
});
// team: team owns the chat. cursor: last message ID read.
type Stored = { id: string; messages: SoneUIMessage[]; team: boolean; cursor: string | null };
type ThreadPage = {
data: SoneMessage[];
has_more: boolean;
mode: 'assistant' | 'team';
reply_pending: boolean;
last_id?: string;
};
const fresh = (): Stored => ({ id: crypto.randomUUID(), messages: [], team: false, cursor: null });
function readStored(): Stored {
try {
const saved = JSON.parse(localStorage.getItem(STORE) ?? 'null');
if (saved?.id) return { team: false, cursor: null, ...saved };
} catch {}
return fresh();
}
// Convert a polled team or assistant message into the same part format.
function fromThread(message: SoneMessage): SoneUIMessage {
if (message.type === 'text' && message.role === 'assistant') {
return { id: message.id, role: 'assistant', parts: [{ type: 'text', text: message.text }] };
}
const data = { text: message.text, role: message.role === 'staff' ? 'staff' as const : 'assistant' as const };
const part =
message.type === 'catalog'
? { type: 'data-sone-catalog' as const, id: message.id, data: { ...data, catalog: message.catalog } }
: message.type === 'actions'
? { type: 'data-sone-actions' as const, id: message.id, data: { ...data, actions: message.actions } }
: message.type === 'file'
? { type: 'data-sone-file' as const, id: message.id, data: { ...data, file: message.file } }
: { type: 'data-sone-staff' as const, id: message.id, data: { text: message.text, role: 'staff' as const } };
return { id: message.id, role: 'assistant', parts: [part] };
}
// Read role from data parts to identify staff messages.
function byTeam(message: SoneUIMessage) {
return message.parts.some((part) => 'data' in part && 'role' in part.data && part.data.role === 'staff');
}
export function SoneChat() {
const [stored, setStored] = useState(readStored);
const [open, setOpen] = useState(false);
const [input, setInput] = useState('');
const [activity, setActivity] = useState('');
const [team, setTeam] = useState(stored.team);
const [cursor, setCursor] = useState(stored.cursor);
// Changing id starts a new useChat conversation.
const { messages, setMessages, sendMessage, status, error } = useChat<SoneUIMessage>({
id: stored.id,
messages: stored.messages,
transport,
onData: (part) => {
if (part.type === 'data-sone-status') setActivity(part.data.label);
},
onError: () => setActivity(''),
onFinish: ({ message }) => {
setActivity('');
const turn = message.metadata?.status;
if (turn) setTeam(turn === 'handoff' || turn === 'human');
},
});
const busy = status === 'submitted' || status === 'streaming';
useEffect(() => {
if (busy) return;
try {
localStorage.setItem(STORE, JSON.stringify({ id: stored.id, messages, team, cursor }));
} catch {}
}, [busy, messages, team, cursor, stored.id]);
// Read team replies and delayed replies after returning to the assistant.
// Stream finish metadata.messageIds prevents redrawing assistant messages.
// First poll reads all pages from the start; later polls continue after the cursor.
const conversationId = conversationOf(messages);
useEffect(() => {
if (!open || !team || busy || !conversationId) return;
const read = async () => {
if (document.hidden) return;
let next = cursor;
let mode: ThreadPage['mode'] = 'team';
let replyPending = true;
const replies: SoneMessage[] = [];
for (;;) {
const url = new URL(`${API}/conversations/${encodeURIComponent(conversationId)}/messages`);
if (next) url.searchParams.set('after', next);
const response = await fetch(url, { headers: { Authorization: `Bearer ${KEY}` } }).catch(() => null);
if (response?.status === 400) setCursor(null); // invalid_cursor: next poll restarts from the beginning
if (!response?.ok) return;
const page = (await response.json()) as ThreadPage;
replies.push(...page.data.filter((m) => m.role !== 'user'));
next = page.last_id ?? next;
mode = page.mode;
replyPending = page.reply_pending;
if (!page.has_more) break;
}
setMessages((current) => {
const known = new Set(current.flatMap((m) => [m.id, ...(m.metadata?.messageIds ?? []), ...m.parts.map((p) => ('id' in p ? p.id : undefined))]));
const added = replies.filter((m) => !known.has(m.id)).map(fromThread);
return added.length > 0 ? [...current, ...added] : current;
});
setCursor(next);
if (mode === 'assistant' && !replyPending) setTeam(false);
};
const timer = setInterval(read, 5000);
return () => clearInterval(timer);
}, [open, team, busy, conversationId, cursor, setMessages]);
const send = (text: string) => {
if (!text.trim() || busy) return;
sendMessage({ text });
};
const reset = () => {
const next = fresh();
try {
localStorage.removeItem(STORE);
} catch {}
setStored(next);
setTeam(false);
setCursor(null);
setActivity('');
};
// Team-owned turns may have no reply; do not render empty assistant messages.
const shown = messages.filter((m) => m.parts.some((p) => p.type === 'text' || p.type.startsWith('data-')));
// Show choices until the visitor writes again.
const lastUser = shown.map((m) => m.role).lastIndexOf('user');
return (
<>
<button className="sone-open" onClick={() => setOpen(!open)} aria-expanded={open}>
Chat with us
</button>
{open ? (
<section className="sone-panel" aria-label="Chat">
<header className="sone-head">
<strong>Chat with us</strong>
<button type="button" onClick={reset} disabled={busy}>
New chat
</button>
</header>
<ol className="sone-log" aria-live="polite">
{shown.map((message, messageIndex) => (
<li key={message.id} className={`sone-row sone-${byTeam(message) ? 'staff' : message.role}`}>
{message.parts.map((part, index) => {
const key = `${message.id}-${index}`;
if (part.type === 'text') return <p key={key} className="sone-text">{part.text}</p>;
if (part.type === 'data-sone-staff') return <p key={key} className="sone-text">{part.data.text}</p>;
if (part.type === 'data-sone-catalog')
return (
<div key={key} className="sone-catalog">
{part.data.catalog?.items.map((item) => (
<article key={item.id} className="sone-item">
{item.image_url ? <img src={item.image_url} alt="" loading="lazy" /> : null}
<strong>{item.title}</strong>
{item.detail ? <span>{item.detail}</span> : null}
{item.price ? <span className="sone-price">{item.price}</span> : null}
{item.url ? (
<a href={item.url} target="_blank" rel="noopener">
{item.button_title || 'View'}
</a>
) : null}
</article>
))}
</div>
);
if (part.type === 'data-sone-actions') {
const current = messageIndex > lastUser;
const actions = part.data.actions;
return (
<div key={key} className="sone-actions">
{actions?.title ? <strong>{actions.title}</strong> : null}
{actions?.text ? <p className="sone-text">{actions.text}</p> : null}
{actions?.buttons?.map((button) => (
<a key={button.title} href={button.url} target="_blank" rel="noopener">
{button.title}
</a>
))}
{current
? actions?.choices?.map((choice) => (
<button key={choice} onClick={() => send(choice)}>
{choice}
</button>
))
: null}
</div>
);
}
if (part.type === 'data-sone-file')
return part.data.file?.url ? (
<a key={key} className="sone-file" href={part.data.file.url} download={part.data.file.name}>
{part.data.file.name}
</a>
) : (
<p key={key} className="sone-text">{part.data.text}</p>
);
return null;
})}
</li>
))}
</ol>
{activity ? <p className="sone-status">{activity}…</p> : null}
{team ? <p className="sone-status">Our team will reply here as soon as possible.</p> : null}
{error ? <p className="sone-status">{error.message || 'Could not send your message. Please try again later.'}</p> : null}
<form
className="sone-form"
onSubmit={(event) => {
event.preventDefault();
send(input);
setInput('');
}}
>
<textarea
className="sone-input"
value={input}
onChange={(event) => setInput(event.target.value)}
maxLength={4000}
rows={2}
placeholder="Write your message"
aria-label="Message"
/>
<button type="submit">Send</button>
</form>
</section>
) : null}
</>
);
}The component:
- Uses
prepareSendMessagesRequestto send only the latest visitor message andconversationIdwhen available. Messageidmakes retries safe. - Stores
useChatID, messages, team ownership and the latest cursor inlocalStorage. Assistantmetadata.conversationIdstays with the messages. - Renders
data-sone-*, sends quick replies and non-URL buttons as new messages, and displays transientdata-sone-statusthroughonData. - Polls every five seconds after
finishreportshandofforhuman, stopping whenmodeis"assistant"andreply_pendingis false. See Polling frequency. - Generates a new chat ID and starts fresh through “New chat”. If a saved conversation is no longer available, the error line explains this and the visitor can start a new chat.
The component uses localStorage and crypto, so it must run only in the browser. Next.js permits dynamic with ssr: false only in Client Components. Load it through a small 'use client' wrapper and include that wrapper in your root layout:
'use client';
import dynamic from 'next/dynamic';
const SoneChat = dynamic(() => import('./sone-chat').then((m) => m.SoneChat), { ssr: false });
export function SoneChatLoader() {
return <SoneChat />;
}import type { ReactNode } from 'react';
import { SoneChatLoader } from '@/components/sone-chat-loader';
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>
{children}
<SoneChatLoader />
</body>
</html>
);
}Class names correspond to the CSS above. Replace its #sone- ID selectors with equivalent classes, for example .sone-panel and .sone-log.