# Embedded chat widget

Page: https://docs.sonecentral.com/en/sohbet-penceresi
Markdown: https://docs.sonecentral.com/en/sohbet-penceresi.md

> 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](https://docs.sonecentral.com/en/kimlik-dogrulama.md).

## 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.

```html title="sohbet.html"
<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>
```

```css title="sohbet.css"
#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; }
```

```html title="sohbet.js"
<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/completions` and displays text as it arrives. When a message completes, top-level `sone.message` redraws it as a product card, buttons or file.
- Sends the conversation ID and only the new message, with a new `Idempotency-Key` for each message.
- Shows `sone.activity` in 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 `handoff` or `human`, polls `GET /conversations/{id}/messages?after=` every five seconds. Advances to `last_id`, skips already displayed visitor messages, and stops when `mode` is `"assistant"` and `reply_pending` is false. Pauses when the widget is closed or the tab is hidden. Increase the interval for many simultaneous visitors: [Polling frequency](https://docs.sonecentral.com/en/insan-devri.md#ne-siklikla-sormali).
- After `ai_turn_ceiling`, `assistant_quota_exhausted`, `plan_required` or `assistant_paused` in 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](https://docs.sonecentral.com/en/zengin-mesajlar.md), and `SoneUIMessage` from [Vercel AI SDK](https://docs.sonecentral.com/en/vercel-ai-sdk.md#tipli-mesajlar), to `lib/sone.ts` first.

```npm
npm i ai @ai-sdk/react
```

```tsx title="components/sone-chat.tsx"
'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 `prepareSendMessagesRequest` to send only the latest visitor message and `conversationId` when available. Message `id` makes retries safe.
- Stores `useChat` ID, messages, team ownership and the latest cursor in `localStorage`. Assistant `metadata.conversationId` stays with the messages.
- Renders `data-sone-*`, sends quick replies and non-URL buttons as new messages, and displays transient `data-sone-status` through `onData`.
- Polls every five seconds after `finish` reports `handoff` or `human`, stopping when `mode` is `"assistant"` and `reply_pending` is false. See [Polling frequency](https://docs.sonecentral.com/en/insan-devri.md#ne-siklikla-sormali).
- 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:

```tsx title="components/sone-chat-loader.tsx"
'use client';

import dynamic from 'next/dynamic';

const SoneChat = dynamic(() => import('./sone-chat').then((m) => m.SoneChat), { ssr: false });

export function SoneChatLoader() {
  return <SoneChat />;
}
```

```tsx title="app/layout.tsx"
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`.
