---
title: "n8n Chatbot Widget: Ultimate Floating Next.js Guide"
slug: "n8n-chatbot-widget-nextjs-guide"
published: "2026-06-28"
updated: "2026-07-09"
validated: "2026-07-09"
categories:
  - "Next.js"
tags:
  - "n8n chatbot widget"
  - "Next.js 15"
  - "n8n webhook"
  - "shadcn/ui"
  - "react-markdown"
  - "remark-gfm"
  - "floating chat widget"
  - "localStorage session persistence"
  - "SSR safe chat widget"
  - "message queueing"
  - "lucide-react"
  - "tailwindcss"
llm-intent: "reference"
audience-level: "intermediate"
framework-versions:
  - "next.js 15"
  - "react 18+"
  - "n8n"
  - "shadcn/ui"
  - "react-markdown"
status: "stable"
llm-purpose: "n8n chatbot widget: Implement a floating, SSR-safe Next.js 15 widget with shadcn/ui, n8n webhook integration, Markdown replies and session persistence…"
llm-prereqs:
  - "Access to Next.js 15"
  - "Access to React 18+"
  - "Access to n8n"
  - "Access to shadcn/ui"
  - "Access to react-markdown"
llm-outputs:
  - "Completed outcome: n8n chatbot widget: Implement a floating, SSR-safe Next.js 15 widget with shadcn/ui, n8n webhook integration, Markdown replies and session persistence…"
---

**Summary Triples**
- (widget, requires, Next.js App Router (next ≥ 14) and React 18+)
- (use-n8n-chat hook, manages, messages array, isLoading, isOpen, sessionId)
- (sessionId, is, UUID persisted to localStorage and reused across page loads)
- (localStorage, is persisted, debounced at 300ms to avoid excessive writes)
- (bot replies, are rendered with, react-markdown + remark-gfm for GFM support)
- (client-chat-wrapper, ensures, SSR-safe client-only rendering via next/dynamic (ssr: false))
- (message sending, uses, message queueing and an n8n webhook endpoint for bot responses)
- (UI, built with, shadcn/ui primitives + Tailwind CSS and lucide-react icons)
- (accessibility, considerations, keyboard focus management and ARIA attributes on launcher & panel)

### {GOAL}
n8n chatbot widget: Implement a floating, SSR-safe Next.js 15 widget with shadcn/ui, n8n webhook integration, Markdown replies and session persistence…

### {PREREQS}
- Access to Next.js 15
- Access to React 18+
- Access to n8n
- Access to shadcn/ui
- Access to react-markdown

### {STEPS}
1. Install prerequisites and remark-gfm
2. Create the use-n8n-chat hook
3. Build the ChatMessage component
4. Add the loading indicator component
5. Implement the floating ChatWidget UI
6. Create SSR-safe wrapper and mount

<!-- llm:goal="n8n chatbot widget: Implement a floating, SSR-safe Next.js 15 widget with shadcn/ui, n8n webhook integration, Markdown replies and session persistence…" -->
<!-- llm:prereq="Access to Next.js 15" -->
<!-- llm:prereq="Access to React 18+" -->
<!-- llm:prereq="Access to n8n" -->
<!-- llm:prereq="Access to shadcn/ui" -->
<!-- llm:prereq="Access to react-markdown" -->
<!-- llm:output="Completed outcome: n8n chatbot widget: Implement a floating, SSR-safe Next.js 15 widget with shadcn/ui, n8n webhook integration, Markdown replies and session persistence…" -->

# n8n Chatbot Widget: Ultimate Floating Next.js Guide
> n8n chatbot widget: Implement a floating, SSR-safe Next.js 15 widget with shadcn/ui, n8n webhook integration, Markdown replies and session persistence…
Matija Žiberna · 2026-06-28

A step-by-step guide to implementing a floating AI chatbot widget powered by an n8n webhook in a **Next.js 15 App Router** project with shadcn/ui.

---

## Overview

The widget is built from **6 files** following a clear separation of concerns:

```
src/
├── hooks/
│   └── use-n8n-chat.ts              ← all state, localStorage, webhook logic
├── components/shared/chat/
│   ├── chat-message.tsx             ← message bubble with Markdown rendering
│   ├── chat-loading-indicator.tsx   ← animated "typing…" indicator
│   ├── custom-chat-widget.tsx       ← full floating UI (launcher + panel)
│   └── client-chat-wrapper.tsx      ← SSR-safe dynamic import wrapper
└── app/(frontend)/layout.tsx        ← one-line mount point
```

---

## Prerequisites

Your project must already have:

| Package | Why |
|---|---|
| `next` ≥ 14 (App Router) | `next/dynamic` with `ssr: false` |
| `react` ≥ 18 | hooks |
| `lucide-react` | icons (Bot, MessageCircle, Send…) |
| `react-markdown` ≥ 9 | Markdown rendering in bot messages |
| `clsx` + `tailwind-merge` (or a `cn()` helper) | class merging |
| shadcn/ui: `Button`, `Textarea`, `ScrollArea`, `Card`, `DropdownMenu`, `AlertDialog` | widget UI primitives |

### Install the one missing package

`remark-gfm` is needed to parse GitHub Flavored Markdown (tables, task lists, strikethrough).
It is **not** bundled with `react-markdown`:

```bash
pnpm add remark-gfm
# or: npm install remark-gfm  /  yarn add remark-gfm
```

---

## Step 1 — The Chat Hook

**File:** `src/hooks/use-n8n-chat.ts`

This is the brain of the widget. It manages:
- `messages[]` state
- `isLoading` / `isOpen` booleans
- `sessionId` (UUID, persisted across page loads)
- `localStorage` persistence (debounced at 300 ms)
- `sendMessage()` — POSTs to the n8n webhook and parses the response
- `toggleChat()` / `clearSession()`
- request cancellation on unmount, so a closed widget can't set state on a dead component

```ts
'use client';

import { useState, useEffect, useCallback, useRef } from 'react';

export type Message = {
  id: string;
  role: 'user' | 'bot';
  content: string;
  createdAt: Date;
};

// Set NEXT_PUBLIC_N8N_WEBHOOK_URL in your .env file. This value is sent from
// the browser either way, so there's no security reason to keep it out of an
// env var — it just makes swapping instances (dev/staging/prod) easier.
const WEBHOOK_URL =
  process.env.NEXT_PUBLIC_N8N_WEBHOOK_URL ?? 'https://your-n8n-instance.com/webhook/YOUR-UUID/chat';

interface UseN8nChatProps {
  webhookUrl?: string;
  initialMessages?: string[];
}

const DEFAULT_INITIAL_MESSAGES = ['Hello! 👋', 'How can I help you today?'];

// Prefix with your app name to avoid localStorage collisions
const LS_SESSION_ID      = 'myapp_chat_session_id';
const LS_SESSION_CREATED = 'myapp_chat_session_created_at';
const messagesKey = (sid: string) => `myapp_chat_messages_${sid}`;

function generateUUID(): string {
  if (typeof crypto !== 'undefined' && crypto.randomUUID) return crypto.randomUUID();
  return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
    const r = (Math.random() * 16) | 0;
    return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
  });
}

export function useN8nChat({
  webhookUrl = WEBHOOK_URL,
  initialMessages = DEFAULT_INITIAL_MESSAGES,
}: UseN8nChatProps = {}) {
  const [messages, setMessages]   = useState<Message[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [isOpen, setIsOpen]       = useState(false);
  const [sessionId, setSessionId] = useState<string>('');
  const abortRef                  = useRef<AbortController | null>(null);

  // On mount: restore or create session and messages
  useEffect(() => {
    let sid = localStorage.getItem(LS_SESSION_ID);
    if (!sid) {
      sid = generateUUID();
      localStorage.setItem(LS_SESSION_ID, sid);
      localStorage.setItem(LS_SESSION_CREATED, new Date().toISOString());
    }
    setSessionId(sid);

    const stored = localStorage.getItem(messagesKey(sid));
    if (stored) {
      try {
        const parsed: Message[] = JSON.parse(stored).map((m: Message) => ({
          ...m, createdAt: new Date(m.createdAt),
        }));
        setMessages(parsed);
        return;
      } catch (e) { console.error('Failed to parse stored messages', e); }
    }

    if (initialMessages.length > 0) {
      setMessages(initialMessages.map((msg, idx) => ({
        id: `init-${idx}`, role: 'bot', content: msg, createdAt: new Date(),
      })));
    }
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  // Debounced localStorage write
  useEffect(() => {
    if (!sessionId || messages.length === 0) return;
    const t = setTimeout(
      () => localStorage.setItem(messagesKey(sessionId), JSON.stringify(messages)),
      300,
    );
    return () => clearTimeout(t);
  }, [messages, sessionId]);

  // Cancel any in-flight request when the component unmounts
  useEffect(() => {
    return () => abortRef.current?.abort();
  }, []);

  const sendMessage = useCallback(async (content: string) => {
    if (!content.trim() || !webhookUrl) return;

    setMessages(prev => [...prev, { id: generateUUID(), role: 'user', content, createdAt: new Date() }]);
    setIsLoading(true);

    const controller = new AbortController();
    abortRef.current = controller;

    try {
      const res = await fetch(webhookUrl, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ action: 'sendMessage', sessionId, chatInput: content, node_env: process.env.NODE_ENV }),
        signal: controller.signal,
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);

      const data: unknown = await res.json();
      let text = 'Sorry, I did not understand that.';
      if (Array.isArray(data)) {
        text = data.map(i => (typeof i === 'object' && i !== null)
          ? (i as Record<string,string>).output || (i as Record<string,string>).text || JSON.stringify(i)
          : String(i)).join('\n');
      } else if (data !== null && typeof data === 'object') {
        const o = data as Record<string,string>;
        text = o.output || o.text || o.message || JSON.stringify(data);
      } else if (typeof data === 'string') {
        text = data;
      }

      setMessages(prev => [...prev, { id: generateUUID(), role: 'bot', content: text, createdAt: new Date() }]);
    } catch (err) {
      if (err instanceof DOMException && err.name === 'AbortError') return; // unmounted mid-request, nothing to do
      console.error('Chat error:', err);
      setMessages(prev => [...prev, {
        id: generateUUID(), role: 'bot',
        content: "Sorry, I'm having trouble connecting. Please try again later.",
        createdAt: new Date(),
      }]);
    } finally {
      setIsLoading(false);
    }
  }, [webhookUrl, sessionId]);

  const toggleChat   = useCallback(() => setIsOpen(p => !p), []);
  const clearSession = useCallback(() => {
    if (sessionId) {
      localStorage.removeItem(LS_SESSION_ID);
      localStorage.removeItem(messagesKey(sessionId));
      localStorage.removeItem(LS_SESSION_CREATED);
    }
    const newId = generateUUID();
    setSessionId(newId);
    localStorage.setItem(LS_SESSION_ID, newId);
    localStorage.setItem(LS_SESSION_CREATED, new Date().toISOString());
    setMessages(initialMessages.map((msg, idx) => ({
      id: `init-${idx}`, role: 'bot', content: msg, createdAt: new Date(),
    })));
  }, [sessionId, initialMessages]);

  return { messages, isLoading, isOpen, sendMessage, toggleChat, setIsOpen, clearSession };
}
```

---

## Step 2 — The Message Bubble

**File:** `src/components/shared/chat/chat-message.tsx`

Renders a single user or bot message. Bot messages use `react-markdown` + `remark-gfm`
so the bot can return tables, lists, code blocks, links — all styled with explicit Tailwind
classes (no `@tailwindcss/typography` needed).

```tsx
'use client';

import { useMemo, type ComponentProps } from 'react';
import ReactMarkdown, { type Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { cn } from '@/lib/utils';
import { type Message } from '@/hooks/use-n8n-chat';

interface ChatMessageProps { message: Message; }

const markdownComponents: Components = {
  p:          ({ children }) => <p className="mb-2 last:mb-0 leading-relaxed">{children}</p>,
  h1:         ({ children }) => <h1 className="text-base font-bold mb-2 mt-3 first:mt-0">{children}</h1>,
  h2:         ({ children }) => <h2 className="text-sm font-bold mb-1.5 mt-2.5 first:mt-0">{children}</h2>,
  h3:         ({ children }) => <h3 className="text-sm font-semibold mb-1 mt-2 first:mt-0">{children}</h3>,
  ul:         ({ children }) => <ul className="mb-2 pl-4 space-y-0.5 list-disc">{children}</ul>,
  ol:         ({ children }) => <ol className="mb-2 pl-4 space-y-0.5 list-decimal">{children}</ol>,
  li:         ({ children }) => <li className="leading-relaxed">{children}</li>,
  strong:     ({ children }) => <strong className="font-semibold">{children}</strong>,
  em:         ({ children }) => <em className="italic">{children}</em>,
  hr:         () => <hr className="border-border my-2" />,
  blockquote: ({ children }) => (
    <blockquote className="border-l-2 border-primary/40 pl-3 italic text-muted-foreground mb-2">
      {children}
    </blockquote>
  ),
  code: ({ children, className }) => {
    const isBlock = className?.includes('language-');
    return isBlock
      ? <code className="block bg-black/10 rounded px-2 py-1 text-xs font-mono overflow-x-auto">{children}</code>
      : <code className="bg-black/10 rounded px-1 py-0.5 text-xs font-mono">{children}</code>;
  },
  pre: ({ children }) => (
    <pre className="bg-black/10 rounded-lg p-3 text-xs font-mono overflow-x-auto mb-2">{children}</pre>
  ),
  a: ({ href, children }) => (
    <a href={href} target="_blank" rel="noopener noreferrer"
       className="text-primary underline underline-offset-2 hover:opacity-80">
      {children}
    </a>
  ),
  // GFM tables
  table:  ({ children }) => (
    <div className="overflow-x-auto mb-2 rounded-lg border border-border">
      <table className="w-full text-xs border-collapse">{children}</table>
    </div>
  ),
  thead:  ({ children }) => <thead className="bg-primary/10">{children}</thead>,
  tbody:  ({ children }) => <tbody className="divide-y divide-border">{children}</tbody>,
  tr:     ({ children }) => <tr className="divide-x divide-border">{children}</tr>,
  th: ({ children, ...props }: ComponentProps<'th'>) => (
    <th className="px-3 py-1.5 text-left font-semibold text-foreground" {...props}>{children}</th>
  ),
  td: ({ children, ...props }: ComponentProps<'td'>) => (
    <td className="px-3 py-1.5 text-foreground/80" {...props}>{children}</td>
  ),
};

export function ChatMessage({ message }: ChatMessageProps) {
  const isUser = message.role === 'user';
  const timestamp = useMemo(
    () => message.createdAt.toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }),
    [message.createdAt],
  );

  return (
    <div className={cn('flex w-full mb-4', isUser ? 'justify-end' : 'justify-start')}>
      <div className={cn(
        'rounded-lg px-4 py-2 text-sm shadow-sm',
        isUser
          ? 'max-w-[80%] bg-primary text-primary-foreground rounded-br-none'
          : 'w-full max-w-full bg-muted text-foreground rounded-bl-none border border-border',
      )}>
        {isUser ? (
          <p className="break-words whitespace-pre-wrap">{message.content}</p>
        ) : (
          <div className="break-words min-w-0">
            <ReactMarkdown remarkPlugins={[remarkGfm]} components={markdownComponents}>
              {message.content}
            </ReactMarkdown>
          </div>
        )}
        <p className={cn('text-[10px] mt-1', isUser ? 'text-primary-foreground/60 text-right' : 'text-muted-foreground')}>
          {timestamp}
        </p>
      </div>
    </div>
  );
}
```

> **Component renderers over `dangerouslySetInnerHTML`.** `react-markdown` with custom
> component renderers is XSS-safe by default, which removes the need for a separate
> `sanitize-html` pass.

> **Explicit classes over `prose`.** The `prose` class requires `@tailwindcss/typography`.
> The component renderers above produce the same visual result with zero extra dependencies.

---

## Step 3 — The Loading Indicator

**File:** `src/components/shared/chat/chat-loading-indicator.tsx`

Animated spinner + rotating contextual text while waiting for the bot. The first message
gets a "startup" sequence; subsequent ones show a random loading message.

The message sets below are generic placeholders. Swap the text and icons for whatever fits
your domain (support tickets, order lookups, booking availability, etc.) — the structure is
what matters, not the specific wording.

```tsx
'use client';

import { useState, useEffect } from 'react';
import { Loader2, Search, HelpCircle, FileText, CheckCircle, Clock,
         Package, DollarSign, MessageSquare, Info, Settings } from 'lucide-react';

const STARTUP_MESSAGES = [
  { text: 'Connecting to assistant...', icon: MessageSquare },
  { text: 'Loading data...',            icon: FileText },
  { text: 'Preparing response...',      icon: CheckCircle },
  { text: 'Just a moment...',           icon: Loader2 },
];

const LOADING_MESSAGES = [
  { text: 'Searching for an answer...',    icon: Search },
  { text: 'Checking the details...',       icon: HelpCircle },
  { text: 'Gathering information...',      icon: Info },
  { text: 'Reviewing your request...',     icon: FileText },
  { text: 'Checking availability...',      icon: Clock },
  { text: 'Looking into that...',          icon: Settings },
  { text: 'Checking our records...',       icon: Package },
  { text: 'Processing your question...',   icon: FileText },
  { text: 'Checking pricing...',           icon: DollarSign },
  { text: 'Composing your answer...',      icon: CheckCircle },
];

interface ChatLoadingIndicatorProps { isFirstMessage?: boolean; }

export function ChatLoadingIndicator({ isFirstMessage = false }: ChatLoadingIndicatorProps) {
  const [currentIndex, setCurrentIndex] = useState(0);
  const messages = isFirstMessage ? STARTUP_MESSAGES : LOADING_MESSAGES;

  useEffect(() => {
    setCurrentIndex(isFirstMessage ? 0 : Math.floor(Math.random() * messages.length));
    const interval = setInterval(
      () => setCurrentIndex(prev => (prev + 1) % messages.length),
      3500,
    );
    return () => clearInterval(interval);
  }, [isFirstMessage, messages.length]);

  const CurrentIcon = messages[currentIndex]?.icon ?? Loader2;
  const currentText = messages[currentIndex]?.text ?? 'Loading...';

  return (
    <div className="flex w-full mb-4 justify-start">
      <div className="bg-muted text-foreground rounded-lg rounded-bl-none px-4 py-3 shadow-sm flex items-center gap-3">
        <div className="relative flex items-center justify-center w-5 h-5 shrink-0">
          <Loader2 className="absolute h-5 w-5 animate-spin text-muted-foreground/50" />
          <CurrentIcon className="absolute h-3 w-3 text-primary animate-pulse" />
        </div>
        <span key={currentIndex}
              className="text-xs text-muted-foreground min-w-[160px] animate-in fade-in slide-in-from-bottom-1 duration-500">
          {currentText}
        </span>
      </div>
    </div>
  );
}
```

---

## Step 4 — The Chat Widget

**File:** `src/components/shared/chat/custom-chat-widget.tsx`

The full floating UI. Key features:
- Fixed bottom-right launcher with unread badge
- Smooth open/close scale animation
- Message queue with per-message cancel
- Starter question chips (disappear after first user message)
- Enter to submit, Shift+Enter for newline, Escape to close the panel
- Auto-scroll to newest message
- "Start new chat" behind a confirmation dialog
- `aria-label`s on every icon-only button, for screen reader users

```tsx
'use client';

import { useState, useEffect, useRef, useCallback } from 'react';
import { Bot, MessageCircle, Minus, MoreVertical, RefreshCcw, Send, X, Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useN8nChat } from '@/hooks/use-n8n-chat';
import { ChatMessage } from '@/components/shared/chat/chat-message';
import { ChatLoadingIndicator } from '@/components/shared/chat/chat-loading-indicator';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Card, CardContent, CardFooter, CardHeader } from '@/components/ui/card';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
         AlertDialogDescription, AlertDialogFooter, AlertDialogHeader,
         AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';

// Generic placeholders — replace with questions relevant to your product or service.
const STARTER_QUESTIONS = [
  'What can you help me with?',
  'How do I get started?',
  'What are your pricing plans?',
  'Do you offer support?',
  'How can I contact you?',
];

interface ChatWidgetProps {
  apiEndpoint?: string;
  title?: string;
  subtitle?: string;
}

export function ChatWidget({
  apiEndpoint,
  title    = 'AI Assistant',
  subtitle = 'Ask us anything!',
}: ChatWidgetProps) {
  const { messages, isLoading, isOpen, sendMessage, toggleChat, setIsOpen, clearSession } =
    useN8nChat({ webhookUrl: apiEndpoint });

  const [input, setInput]               = useState('');
  const [messageQueue, setMessageQueue] = useState<string[]>([]);
  const lastMessageRef                  = useRef<HTMLDivElement>(null);
  const inputRef                        = useRef<HTMLTextAreaElement>(null);
  const [unreadCount, setUnreadCount]   = useState(0);
  const prevLen                         = useRef(messages.length);
  const isInitialLoad                   = useRef(true);

  // Unread badge
  useEffect(() => {
    if (messages.length > prevLen.current) {
      const last = messages[messages.length - 1];
      if (last?.role === 'bot' && !isInitialLoad.current && !isOpen) setUnreadCount(p => p + 1);
      if (isInitialLoad.current) isInitialLoad.current = false;
    }
    prevLen.current = messages.length;
  }, [messages, isOpen]);

  useEffect(() => { if (isOpen) setUnreadCount(0); }, [isOpen]);

  // Auto-scroll
  useEffect(() => {
    lastMessageRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
  }, [messages, isLoading, isOpen]);

  // Focus textarea on open
  useEffect(() => {
    if (isOpen) setTimeout(() => inputRef.current?.focus(), 100);
  }, [isOpen]);

  // Escape closes the panel
  useEffect(() => {
    if (!isOpen) return;
    const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') setIsOpen(false); };
    window.addEventListener('keydown', onKeyDown);
    return () => window.removeEventListener('keydown', onKeyDown);
  }, [isOpen, setIsOpen]);

  // Flush queue
  useEffect(() => {
    if (!isLoading && messageQueue.length > 0) {
      const [next, ...rest] = messageQueue;
      setMessageQueue(rest);
      sendMessage(next);
    }
  }, [isLoading, messageQueue, sendMessage]);

  const handleSubmit = useCallback(async (e: React.FormEvent) => {
    e.preventDefault();
    if (!input.trim()) return;
    const msg = input;
    setInput('');
    if (isLoading || messageQueue.length > 0) setMessageQueue(p => [...p, msg]);
    else await sendMessage(msg);
    setTimeout(() => inputRef.current?.focus(), 0);
  }, [input, isLoading, messageQueue.length, sendMessage]);

  const handleKeyDown = (e: React.KeyboardEvent) => {
    if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSubmit(e as unknown as React.FormEvent); }
  };

  return (
    <div className={cn(
      'fixed z-50 flex flex-col items-end print:hidden',
      isOpen ? 'inset-0 sm:inset-auto sm:bottom-6 sm:right-6' : 'bottom-4 right-4 sm:bottom-6 sm:right-6',
    )}>
      {/* ── Chat panel ── */}
      <Card className={cn(
        'transition-all duration-300 ease-in-out transform origin-bottom-right overflow-hidden',
        'border bg-background shadow-xl flex flex-col p-0 gap-0',
        isOpen
          ? 'w-full h-full rounded-none sm:rounded-2xl sm:w-[400px] sm:h-[600px] sm:max-h-[calc(100vh-6rem)] opacity-100 scale-100 sm:mb-4'
          : 'w-0 h-0 opacity-0 scale-95 mb-0 rounded-2xl',
      )}>
        {/* Header */}
        <CardHeader className="flex flex-row bg-primary px-4 py-3 items-center justify-between text-primary-foreground shrink-0 space-y-0 gap-0">
          <div className="flex items-center gap-3">
            <div className="h-[48px] w-[48px] rounded-full bg-white/10 flex items-center justify-center shrink-0">
              <Bot className="h-6 w-6 text-primary-foreground" />
              {/* Swap Bot for <Image> if you want a logo */}
            </div>
            <div>
              <h3 className="font-semibold text-sm leading-tight">{title}</h3>
              <p className="text-xs text-primary-foreground/80">{subtitle}</p>
            </div>
          </div>
          <div className="flex items-center gap-1 shrink-0">
            <DropdownMenu>
              <DropdownMenuTrigger asChild>
                <Button variant="ghost" size="icon" aria-label="Chat options"
                        className="h-8 w-8 text-primary-foreground hover:bg-primary-foreground/20">
                  <MoreVertical className="h-5 w-5" />
                </Button>
              </DropdownMenuTrigger>
              <DropdownMenuContent align="end">
                <AlertDialog>
                  <AlertDialogTrigger asChild>
                    <DropdownMenuItem onSelect={e => e.preventDefault()} className="cursor-pointer">
                      <RefreshCcw className="mr-2 h-4 w-4" />
                      <span>Start new chat</span>
                    </DropdownMenuItem>
                  </AlertDialogTrigger>
                  <AlertDialogContent>
                    <AlertDialogHeader>
                      <AlertDialogTitle>Are you sure?</AlertDialogTitle>
                      <AlertDialogDescription>
                        This will clear your current conversation and start a new one.
                      </AlertDialogDescription>
                    </AlertDialogHeader>
                    <AlertDialogFooter>
                      <AlertDialogCancel>Cancel</AlertDialogCancel>
                      <AlertDialogAction onClick={clearSession}>Continue</AlertDialogAction>
                    </AlertDialogFooter>
                  </AlertDialogContent>
                </AlertDialog>
              </DropdownMenuContent>
            </DropdownMenu>
            <Button variant="ghost" size="icon" aria-label="Minimize chat"
                    className="h-8 w-8 text-primary-foreground hover:bg-primary-foreground/20"
                    onClick={() => setIsOpen(false)}>
              <Minus className="h-5 w-5" />
            </Button>
          </div>
        </CardHeader>

        {/* Messages */}
        <CardContent className="p-0 flex-1 min-h-0 bg-muted/30">
          <ScrollArea className="h-full w-full p-4">
            <div className="flex flex-col gap-2 min-h-full justify-end">
              {messages.map((msg, i) => (
                <div key={msg.id} ref={i === messages.length - 1 ? lastMessageRef : null}>
                  <ChatMessage message={msg} />
                </div>
              ))}
              {isLoading && (
                <ChatLoadingIndicator isFirstMessage={messages.filter(m => m.role === 'user').length === 1} />
              )}
              {/* Starter chips — hidden as soon as user sends first message */}
              {!isLoading && messages.every(m => m.role === 'bot') && (
                <div className="flex flex-wrap gap-2 pt-1 pb-2" ref={lastMessageRef}>
                  {STARTER_QUESTIONS.map(q => (
                    <button key={q} type="button" onClick={() => sendMessage(q)}
                            className="text-left text-xs px-3 py-1.5 rounded-full border border-primary/40 bg-primary/5 text-primary hover:bg-primary/15 hover:border-primary/70 transition-colors duration-150 cursor-pointer">
                      {q}
                    </button>
                  ))}
                </div>
              )}
            </div>
          </ScrollArea>
        </CardContent>

        {/* Footer */}
        <CardFooter className="p-4 border-t bg-background shrink-0 block">
          {/* Queue panel */}
          {messageQueue.length > 0 && (
            <div className="mb-2 rounded-lg border border-primary/20 bg-primary/5 overflow-hidden">
              <div className="flex items-center gap-1.5 px-3 py-1.5 border-b border-primary/10 bg-primary/10">
                <Loader2 className="h-3 w-3 animate-spin text-primary" />
                <span className="text-xs font-medium text-primary">
                  {messageQueue.length} message{messageQueue.length > 1 ? 's' : ''} queued
                </span>
              </div>
              <ul className="divide-y divide-primary/10">
                {messageQueue.map((msg, i) => (
                  <li key={i} className="flex items-center justify-between px-3 py-1.5 gap-2">
                    <span className="text-xs text-muted-foreground truncate flex-1">
                      <span className="text-primary/40 mr-1">{i + 1}.</span>{msg}
                    </span>
                    <button type="button" aria-label={`Remove queued message: ${msg}`}
                            onClick={() => setMessageQueue(p => p.filter((_, idx) => idx !== i))}
                            className="text-muted-foreground hover:text-destructive shrink-0">
                      <X className="h-3 w-3" />
                    </button>
                  </li>
                ))}
              </ul>
            </div>
          )}
          <form onSubmit={handleSubmit} className="flex gap-2">
            <Textarea ref={inputRef} id="chat-input" placeholder="Type your message..."
                      value={input} onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown}
                      className="flex-1 min-h-[40px] max-h-[120px] resize-none py-3" rows={1} />
            <Button type="submit" size="icon" aria-label="Send message" disabled={!input.trim() || isLoading}>
              <Send className="h-4 w-4" />
            </Button>
          </form>
          <p className="text-center text-[10px] text-muted-foreground mt-2">Powered by Your Brand</p>
        </CardFooter>
      </Card>

      {/* Launcher */}
      <div className={cn('relative', isOpen && 'hidden sm:block')}>
        {unreadCount > 0 && !isOpen && (
          <div className="absolute -top-2 -right-2 z-50 flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-[10px] font-bold text-white shadow-lg animate-bounce">
            {unreadCount > 9 ? '9+' : unreadCount}
          </div>
        )}
        <Button id="chat-launcher-btn" onClick={toggleChat} size="icon" aria-label="Open chat"
                className={cn('h-14 w-14 rounded-full shadow-lg transition-transform duration-300 hover:scale-105',
                  isOpen ? 'rotate-90 opacity-0 pointer-events-none absolute bottom-0 right-0' : 'rotate-0 opacity-100')}>
          <MessageCircle className="h-7 w-7" />
        </Button>
      </div>

      {/* Close button (desktop) */}
      <Button onClick={toggleChat} size="icon" aria-label="Close chat"
              className={cn('h-14 w-14 rounded-full shadow-lg transition-transform duration-300 absolute bottom-0 right-0 bg-primary text-primary-foreground',
                isOpen ? 'scale-100 rotate-0 hidden sm:flex' : 'scale-0 rotate-90 opacity-0')}>
        <X className="h-7 w-7" />
      </Button>
    </div>
  );
}
```

---

## Step 5 — SSR-Safe Wrapper

**File:** `src/components/shared/chat/client-chat-wrapper.tsx`

The widget uses `localStorage` and browser-only APIs. `ssr: false` prevents Next.js from
rendering it on the server.

```tsx
'use client';

import dynamic from 'next/dynamic';

const ChatWidget = dynamic(
  () => import('@/components/shared/chat/custom-chat-widget').then(m => m.ChatWidget),
  { ssr: false },
);

export function ClientChatWrapper() {
  return <ChatWidget />;
}
```

> **Direct imports from Server Components fail.** Importing `custom-chat-widget.tsx`
> directly in a Server Component throws `localStorage is not defined` at build or runtime.
> Always go through this wrapper.

---

## Step 6 — Mount in Layout

Add one import and one JSX tag inside `<body>`:

```diff
+ import { ClientChatWrapper } from '@/components/shared/chat/client-chat-wrapper'

  <body className="antialiased min-h-screen">
    {children}
+   <ClientChatWrapper />
  </body>
```

> If you only want the widget on certain routes, move `<ClientChatWrapper />` to a nested
> layout file instead of the root layout.

---

## N8N Webhook Contract

### Request (frontend → n8n)

```json
{
  "action": "sendMessage",
  "sessionId": "550e8400-e29b-41d4-a716-446655440000",
  "chatInput": "What can you help me with?",
  "node_env": "production"
}
```

### Response (n8n → frontend) — any of these shapes work

```json
"plain text"
```
```json
{ "output": "formatted **Markdown** response" }
```
```json
{ "text": "also works" }
```
```json
{ "message": "also works" }
```
```json
[{ "output": "part one" }, { "output": "part two" }]
```

The bot response may contain GFM Markdown — tables, lists, bold, code blocks, links — and
`ChatMessage` will render all of it correctly.

---

## Customisation Reference

### Set the webhook URL

Add to your `.env` file:
```
NEXT_PUBLIC_N8N_WEBHOOK_URL=https://your-instance.com/webhook/UUID/chat
```
Or override per-instance via the prop: `<ChatWidget apiEndpoint="https://..." />`

### Change the greeting messages

In `use-n8n-chat.ts`:
```ts
const DEFAULT_INITIAL_MESSAGES = ['Hello! 👋', 'How can I help you today?'];
```

### Change the starter question chips

In `custom-chat-widget.tsx`, replace `STARTER_QUESTIONS` with questions relevant to your
product or service.

### Change the loading messages

In `chat-loading-indicator.tsx`, edit `STARTUP_MESSAGES` and `LOADING_MESSAGES`.
Each entry: `{ text: 'Some message...', icon: LucideIcon }`

### Replace the Bot icon with a logo image

In `custom-chat-widget.tsx`, swap:
```tsx
// Before
<Bot className="h-6 w-6 text-primary-foreground" />

// After
import Image from 'next/image';
import myLogo from '@/assets/logo.png';

<Image src={myLogo} alt="Logo" width={40} height={40} className="object-contain" />
```

### Restrict to specific routes

Move the mount point from the root layout to a nested layout, e.g. only storefront pages:
```
src/app/(frontend)/[locale]/
└── layout.tsx   ← put <ClientChatWrapper /> here instead
```

### Change localStorage key prefix

In `use-n8n-chat.ts`:
```ts
const LS_SESSION_ID      = 'myapp_chat_session_id';
const LS_SESSION_CREATED = 'myapp_chat_session_created_at';
const messagesKey = (sid: string) => `myapp_chat_messages_${sid}`;
```

---

## Troubleshooting

| Symptom | Likely cause | Fix |
|---|---|---|
| `localStorage is not defined` | Widget rendered on server | Ensure `ssr: false` in `client-chat-wrapper.tsx` |
| Tables render as plain text `\| col \|` | `remark-gfm` missing | Add `remarkPlugins={[remarkGfm]}` to `<ReactMarkdown>` |
| Bot reply shows `[object Object]` | Unrecognised response shape | Log `data` in the hook; check n8n output node type |
| Messages lost on refresh | localStorage key mismatch | Verify `LS_SESSION_ID` / `messagesKey` constants |
| Widget sits behind other elements | z-index conflict | Increase `z-50` to `z-[100]` on the outer wrapper `<div>` |
| Panel jumps on mobile | Viewport units | The `inset-0` class takes full screen on mobile — expected |
| `tsc` errors on `Components` types | react-markdown version mismatch | Ensure `react-markdown` ≥ 9 |
| Console warning about setState after unmount | Request in flight when widget unmounts | Confirmed fixed by the `AbortController` in Step 1 |

---

## Verification Checklist

Run these after setup:

```bash
# TypeScript — must produce no output (= no errors)
pnpm --filter your-app exec tsc --noEmit

# Lint
pnpm lint
```

Manual checks:
- [ ] Floating button appears bottom-right on frontend pages
- [ ] Click opens panel; Escape key and minimize button both close it
- [ ] Starter chips disappear after first message
- [ ] Bot Markdown (tables, lists, bold) renders correctly
- [ ] Refresh page → conversation restored from localStorage
- [ ] Three-dot menu → "Start new chat" → confirmation → session clears
- [ ] Send while loading → message queued, sent automatically when bot responds
- [ ] No console errors on first load (SSR safe)
- [ ] Tab through the launcher, header buttons, and send button — each announces a label via screen reader or browser tooltip

## LLM Response Snippet
```json
{
  "goal": "n8n chatbot widget: Implement a floating, SSR-safe Next.js 15 widget with shadcn/ui, n8n webhook integration, Markdown replies and session persistence…",
  "responses": [
    {
      "question": "What does the article \"n8n Chatbot Widget: Ultimate Floating Next.js Guide\" cover?",
      "answer": "n8n chatbot widget: Implement a floating, SSR-safe Next.js 15 widget with shadcn/ui, n8n webhook integration, Markdown replies and session persistence…"
    }
  ]
}
```