ChatWindow

PreviousNext

A fully controlled chatbot panel with a header, scrollable message list, typing indicator, and input bar.

Ask me a question

Hi. Ask me anything about Club Med. I'm here to help you find the right information quickly.

My son is 14 years old, and I would like to find activities for him during our stay.

'use client';

import { useRef, useState } from 'react';
import { ChatWindow } from '@/ui/ChatWindow';
import type {
  ChatWindowCsatPromptLabels,
  ChatWindowCsatThankYouLabels,
  ChatWindowHandle,
  ChatWindowMessage,
} from '@/ui/ChatWindow';

const csatLabels: ChatWindowCsatPromptLabels = {
  intro: 'Your opinion matters! Tell us what you think. Your feedback helps us improve.',
  question:
    'Following our conversation, from 1 to 5, how satisfied are you with the level of service you received,',
  questionNote: '1 star being "Very dissatisfied" and 5 stars "Very satisfied".',
  groupAriaLabel: 'Rate your experience from 1 to 5 stars',
  starLabels: ['Very dissatisfied', 'Dissatisfied', 'Neutral', 'Satisfied', 'Very satisfied'],
};

const csatThankYouLabels: ChatWindowCsatThankYouLabels = {
  title: 'Thank you for your feedback!',
  body: 'Your opinion means a lot to us. Thanks to your responses, we keep improving our services to offer you an even more exceptional Club Med experience.',
};

function csatRenderRatingLabel(rating: number) {
  return `${rating} star${rating > 1 ? 's' : ''} — ${csatLabels.starLabels[rating - 1]}`;
}

export function ChatWindowDemo() {
  const chatRef = useRef<ChatWindowHandle>(null);
  const [messages, setMessages] = useState<ChatWindowMessage[]>([
    {
      id: '1',
      sender: 'assistant',
      content:
        "Hi. Ask me anything about Club Med. I'm here to help you find the right information quickly.",
    },
    {
      id: '2',
      sender: 'user',
      content:
        'My son is 14 years old, and I would like to find activities for him during our stay.',
    },
  ]);
  const [inputValue, setInputValue] = useState('');
  const [isTyping, setIsTyping] = useState(false);

  function handleSubmit() {
    if (!inputValue.trim()) return;
    const userMsg: ChatWindowMessage = {
      id: Date.now().toString(),
      sender: 'user',
      content: inputValue,
    };
    setMessages((prev) => [...prev, userMsg]);
    setInputValue('');
    setIsTyping(true);
    setTimeout(() => {
      setIsTyping(false);
      setMessages((prev) => [
        ...prev,
        {
          id: (Date.now() + 1).toString(),
          sender: 'assistant',
          content: 'Let me look into that for you…',
        },
      ]);
    }, 2000);
  }

  return (
    <ChatWindow
      aria-label="Assistant chat"
      ref={chatRef}
      messages={messages}
      inputValue={inputValue}
      isTyping={isTyping}
      onInputChange={setInputValue}
      onSubmit={handleSubmit}
      onClose={() => {}}
      onAudioClick={() => {}}
      onFeedbackRate={(rating) => console.log('Feedback rating:', rating)}
      csatLabels={csatLabels}
      csatRenderRatingLabel={csatRenderRatingLabel}
      csatThankYouLabels={csatThankYouLabels}
      className="w-380 h-660"
    />
  );
}

Installation

npx shadcn@latest add https://develop.trident-ui.pro.clubmed/r/chat-window.json

Usage

ChatWindow supports two forms: an opaque, props-only form for simple cases, and a composable form where you assemble the panel yourself from ChatHeader, ChatBody, and ChatInput. The composable form is the recommended way to build a chat panel — it gives you full control over the layout while keeping ChatWindow's outer chrome (border, rounded corners, close button).

Props-only

import { ChatWindow } from '@/ui/ChatWindow';
import type { ChatWindowMessage } from '@/ui/ChatWindow';
import { useState } from 'react';
 
export function Component() {
  const [messages, setMessages] = useState<ChatWindowMessage[]>([]);
  const [inputValue, setInputValue] = useState('');
  const [isTyping, setIsTyping] = useState(false);
 
  function handleSubmit() {
    if (!inputValue.trim()) return;
    setMessages((prev) => [
      ...prev,
      { id: Date.now().toString(), sender: 'user', content: inputValue },
    ]);
    setInputValue('');
    // Trigger your AI call here, then set isTyping accordingly
  }
 
  return (
    <ChatWindow
      messages={messages}
      inputValue={inputValue}
      isTyping={isTyping}
      onInputChange={setInputValue}
      onSubmit={handleSubmit}
      onClose={() => console.log('close')}
      onAudioClick={() => console.log('audio')}
    />
  );
}

Pass ChatHeader, ChatBody, and ChatInput as children instead of the message-list props. When ChatWindow receives children, it renders them as-is inside its bordered wrapper and ignores title/logo/messages/inputValue/etc.

import { ChatWindow } from '@/ui/ChatWindow';
import { ChatHeader } from '@/ui/ChatHeader';
import { ChatBody } from '@/ui/ChatBody';
import { ChatInput } from '@/ui/ChatInput';
import type { ChatMessage } from '@/ui/ChatBody';
import { useState } from 'react';
 
export function Component() {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [inputValue, setInputValue] = useState('');
  const [isTyping, setIsTyping] = useState(false);
 
  function handleSubmit() {
    if (!inputValue.trim()) return;
    setMessages((prev) => [
      ...prev,
      { id: Date.now().toString(), sender: 'user', content: inputValue },
    ]);
    setInputValue('');
    // Trigger your AI call here, then set isTyping accordingly
  }
 
  return (
    <ChatWindow onClose={() => console.log('close')}>
      <ChatHeader title="Ask me a question" />
      <ChatBody messages={messages} isTyping={isTyping} />
      <div className="shrink-0 px-8 pb-8 pt-12">
        <ChatInput value={inputValue} onValueChange={setInputValue} onSubmit={handleSubmit} />
      </div>
    </ChatWindow>
  );
}

Advanced: compose without ChatWindow

If you don't need ChatWindow's bordered wrapper or close button — for example to embed the chat inside an existing layout, or to fully customize the outer markup — skip ChatWindow entirely and compose ChatHeader, ChatBody, and ChatInput directly. This reproduces exactly what ChatWindow renders internally when it has no children.

import { ChatHeader } from '@/ui/ChatHeader';
import { ChatBody } from '@/ui/ChatBody';
import { ChatInput } from '@/ui/ChatInput';
import type { ChatMessage } from '@/ui/ChatBody';
import { useState } from 'react';
 
export function Component() {
  const [messages, setMessages] = useState<ChatMessage[]>([]);
  const [inputValue, setInputValue] = useState('');
  const [isTyping, setIsTyping] = useState(false);
 
  function handleSubmit() {
    if (!inputValue.trim()) return;
    setMessages((prev) => [
      ...prev,
      { id: Date.now().toString(), sender: 'user', content: inputValue },
    ]);
    setInputValue('');
    // Trigger your AI call here, then set isTyping accordingly
  }
 
  return (
    <div className="chat-border bg-white flex flex-col w-380 h-660 rounded-32 overflow-hidden">
      <ChatHeader title="Ask me a question" />
      <ChatBody messages={messages} isTyping={isTyping} />
      <div className="shrink-0 px-8 pb-8 pt-12">
        <ChatInput value={inputValue} onValueChange={setInputValue} onSubmit={handleSubmit} />
      </div>
    </div>
  );
}

Ask for Feedback (CSAT)

Ask me a question

Hi. Ask me anything about Club Med. I'm here to help you find the right information quickly.

My son is 14 years old, what activities are available for him during our stay?

Great question! For teenagers aged 13–17, Club Med offers the Passworld programme — supervised sports, creative workshops, and evening events designed specifically for that age group.

'use client';

import { useRef } from 'react';
import { ChatWindow } from '@/ui/ChatWindow';
import type {
  ChatWindowCsatPromptLabels,
  ChatWindowCsatThankYouLabels,
  ChatWindowHandle,
  ChatWindowMessage,
} from '@/ui/ChatWindow';

const messages: ChatWindowMessage[] = [
  {
    id: '1',
    sender: 'assistant',
    content:
      "Hi. Ask me anything about Club Med. I'm here to help you find the right information quickly.",
  },
  {
    id: '2',
    sender: 'user',
    content: 'My son is 14 years old, what activities are available for him during our stay?',
  },
  {
    id: '3',
    sender: 'assistant',
    content:
      'Great question! For teenagers aged 13–17, Club Med offers the Passworld programme — supervised sports, creative workshops, and evening events designed specifically for that age group.',
  },
];

const csatLabels: ChatWindowCsatPromptLabels = {
  intro: 'Your opinion matters! Tell us what you think. Your feedback helps us improve.',
  question:
    'Following our conversation, from 1 to 5, how satisfied are you with the level of service you received,',
  questionNote: '1 star being "Very dissatisfied" and 5 stars "Very satisfied".',
  groupAriaLabel: 'Rate your experience from 1 to 5 stars',
  starLabels: ['Very dissatisfied', 'Dissatisfied', 'Neutral', 'Satisfied', 'Very satisfied'],
};

const csatThankYouLabels: ChatWindowCsatThankYouLabels = {
  title: 'Thank you for your feedback!',
  body: 'Your opinion means a lot to us. Thanks to your responses, we keep improving our services to offer you an even more exceptional Club Med experience.',
};

function csatRenderRatingLabel(rating: number) {
  return `${rating} star${rating > 1 ? 's' : ''} — ${csatLabels.starLabels[rating - 1]}`;
}

export function ChatWindowCsatDemo() {
  const chatRef = useRef<ChatWindowHandle>(null);

  return (
    <div className="flex flex-col items-center gap-12">
      <ChatWindow
        aria-label="Assistant chat"
        ref={chatRef}
        messages={messages}
        inputValue=""
        onInputChange={() => {}}
        onSubmit={() => {}}
        csatLabels={csatLabels}
        csatRenderRatingLabel={csatRenderRatingLabel}
        csatThankYouLabels={csatThankYouLabels}
        onFeedbackRate={(rating) => console.log('Feedback rating:', rating)}
        onClose={() => {}}
        className="w-380 h-660"
      />
      <button
        type="button"
        onClick={() => chatRef.current?.askForFeedback()}
        className="px-16 py-8 bg-saffron rounded-pill text-black text-b4 font-semibold hover:opacity-80 transition-opacity"
      >
        Trigger feedback prompt
      </button>
    </div>
  );
}

Call chatRef.current.askForFeedback() at any point to inject the CSAT flow into the message log. When the user picks a star rating, three things happen in sequence:

  1. A user-side message appears in the log with the rating label (e.g. "4 stars — Satisfied").
  2. The star buttons lock — the prompt stays visible but cannot be re-submitted.
  3. A thank-you note from the assistant is appended below.
  4. onFeedbackRate is called with the chosen value (1–5).
import { useRef } from 'react';
import { ChatWindow } from '@/ui/ChatWindow';
import type {
  ChatWindowCsatPromptLabels,
  ChatWindowCsatThankYouLabels,
  ChatWindowHandle,
} from '@/ui/ChatWindow';
 
const csatLabels: ChatWindowCsatPromptLabels = {
  intro: 'Your opinion matters! Tell us what you think.',
  question: 'From 1 to 5, how satisfied are you with the service you received,',
  questionNote: '1 star being "Very dissatisfied" and 5 stars "Very satisfied".',
  groupAriaLabel: 'Rate your experience from 1 to 5 stars',
  starLabels: ['Very dissatisfied', 'Dissatisfied', 'Neutral', 'Satisfied', 'Very satisfied'],
};
 
const csatThankYouLabels: ChatWindowCsatThankYouLabels = {
  title: 'Thank you for your feedback!',
  body: 'Your opinion means a lot to us.',
};
 
function csatRenderRatingLabel(rating: number) {
  return `${rating} star${rating > 1 ? 's' : ''} — ${csatLabels.starLabels[rating - 1]}`;
}
 
export function Component() {
  const chatRef = useRef<ChatWindowHandle>(null);
 
  return (
    <ChatWindow
      ref={chatRef}
      messages={messages}
      inputValue={inputValue}
      onInputChange={setInputValue}
      onSubmit={handleSubmit}
      csatLabels={csatLabels}
      csatRenderRatingLabel={csatRenderRatingLabel}
      csatThankYouLabels={csatThankYouLabels}
      onFeedbackRate={(rating) => console.log('Rated:', rating)}
    />
  );
}

Show Price Quotes

Ask me a question

Hi. Ask me anything about Club Med. I'm here to help you find the right information quickly.

Can you show me some quotes for a family trip to the Caribbean?

Of course! Here are some resorts in the Caribbean that could be perfect for your family vacation.

'use client';

import { useRef } from 'react';
import { ChatWindow } from '@/ui/ChatWindow';
import type { ChatWindowHandle, ChatWindowMessage, ChatWindowQuoteItem } from '@/ui/ChatWindow';

const messages: ChatWindowMessage[] = [
  {
    id: '1',
    sender: 'assistant',
    content:
      "Hi. Ask me anything about Club Med. I'm here to help you find the right information quickly.",
  },
  {
    id: '2',
    sender: 'user',
    content: 'Can you show me some quotes for a family trip to the Caribbean?',
  },
  {
    id: '3',
    sender: 'assistant',
    content:
      'Of course! Here are some resorts in the Caribbean that could be perfect for your family vacation.',
  },
];

const quotes: ChatWindowQuoteItem[] = [
  {
    id: '1',
    resortName: 'Cancún',
    country: 'Mexico',
    imageUrl: 'https://picsum.photos/seed/cancun/400/300',
    dateRange: '15 June 2026 to 24 June 2026',
    pax: 'for 2 adults 2 children',
    packageIncludes: [
      'Meals and snacks at any time',
      'Drinks, alcoholic and non-alcoholic',
      'Sports and activities',
    ],
    additionalInfo: 'You can customise your transport and room category directly on the website.',
  },
  {
    id: '2',
    resortName: 'Punta Cana',
    country: 'Dominican Republic',
    imageUrl: 'https://picsum.photos/seed/puntacana/400/300',
    dateRange: '15 June 2026 to 24 June 2026',
    pax: 'for 2 adults 2 children',
  },
];

export function ChatWindowQuotesDemo() {
  const chatRef = useRef<ChatWindowHandle>(null);

  return (
    <div className="flex flex-col items-center gap-12">
      <ChatWindow
        aria-label="Assistant chat"
        ref={chatRef}
        messages={messages}
        inputValue=""
        onInputChange={() => {}}
        onSubmit={() => {}}
        onQuoteCtaClick={(quote) => console.log('Selected quote:', quote.resortName)}
        onClose={() => {}}
        className="w-380 h-660"
      />
      <button
        type="button"
        onClick={() => chatRef.current?.showQuotes(quotes)}
        className="px-16 py-8 bg-saffron rounded-pill text-black text-b4 font-semibold hover:opacity-80 transition-opacity"
      >
        Trigger quotes
      </button>
    </div>
  );
}

Call chatRef.current.showQuotes(quotes) to inject a quote block into the message log. Each call appends a new block below any previous ones, so multiple rounds of quotes stack naturally in the conversation.

  • Single quote — renders as a full-width card with resort image, dates, a saffron CTA, and optional package details.
  • Multiple quotes — renders as compact square cards in a horizontal scroll zone.
import { useRef } from 'react';
import { ChatWindow } from '@/ui/ChatWindow';
import type { ChatWindowHandle, ChatWindowQuoteItem } from '@/ui/ChatWindow';
 
const quotes: ChatWindowQuoteItem[] = [
  {
    id: '1',
    resortName: 'Cancún',
    country: 'Mexico',
    imageUrl: 'https://example.com/cancun.jpg',
    dateRange: '15 June 2026 to 24 June 2026',
    pax: 'for 2 adults 2 children',
    packageIncludes: ['Meals and snacks at any time', 'Drinks included', 'Sports and activities'],
    additionalInfo: 'You can customise your transport directly on the website.',
  },
  {
    id: '2',
    resortName: 'Punta Cana',
    country: 'Dominican Republic',
    imageUrl: 'https://example.com/puntacana.jpg',
    dateRange: '15 June 2026 to 24 June 2026',
    pax: 'for 2 adults 2 children',
  },
];
 
export function Component() {
  const chatRef = useRef<ChatWindowHandle>(null);
 
  return (
    <ChatWindow
      ref={chatRef}
      messages={messages}
      inputValue={inputValue}
      onInputChange={setInputValue}
      onSubmit={handleSubmit}
      onQuoteCtaClick={(quote) => console.log('Selected:', quote.resortName)}
    />
  );
}

API Reference

ChatWindow

PropTypeDefaultDescription
childrenReactNodeundefinedComposable form: pass ChatHeader/ChatBody/ChatInput (or any custom markup) to fully control the layout. When set, all props below except onClose/title/logo are ignored
messagesChatWindowMessage[]—Required unless children is set. Ordered list of messages to display
inputValuestring—Required unless children is set. Controlled value of the text input
onInputChange(value: string) => void—Required unless children is set. Called on every keystroke in the input
onSubmit() => void—Required unless children is set. Called when the user submits a message
titlestring"Ask me a question"Heading displayed in the intro header
greetingstringundefinedOptional static greeting text shown above the message list
inputPlaceholderstring"Ask a question?"Placeholder text for the input field
isTypingbooleanfalseWhen true, shows the animated typing indicator
onClose() => voidundefinedWhen provided, renders a close button below the panel
onAudioClick() => voidundefinedEnables the microphone button in the input
submitKey"mod+enter" | "enter""mod+enter"Keyboard shortcut that submits the message, see ChatInput
isStreamingbooleanfalseWhile true, the send button becomes a stop button and submitting is disabled
onStop() => voidundefinedCalled when the stop button, shown while isStreaming, is clicked
onMessageReplay(message: ChatWindowMessage) => voidundefinedRenders the replay action on the user messages
onMessageEdit(message: ChatWindowMessage, content: string) => voidundefinedEnables the in-place edition of the user text messages, called with the new text
onMessageCopy(message: ChatWindowMessage) => void | Promise<void>undefinedRenders the copy action on the user messages
messageActionLabelsPartial<ChatMessageLabels>EnglishLabels of the message actions and of the editor
logoReactNodeundefinedCustom logo node; defaults to a Sparkles icon button
onFeedbackRate(rating: number) => voidundefinedCalled with the star rating (1–5) when the user submits feedback
csatLabelsChatWindowCsatPromptLabelsundefinedLabels for the rating prompt. Required to enable the CSAT flow
csatRenderRatingLabel(rating: number) => ReactNodeundefinedRenders the caption shown under the active star. Required with CSAT
csatThankYouLabelsChatWindowCsatThankYouLabelsundefinedLabels for the thank-you note shown after submission
onQuoteCtaClick(quote: ChatWindowQuoteItem) => voidundefinedCalled with the selected quote when a "See prices" CTA is tapped
classNamestringundefinedAdditional CSS classes on the outer wrapper

ChatHeader

Renders the intro header (title + logo/AI-assistant button). Used standalone when composing.

PropTypeDefaultDescription
titlestring"Ask me a question"Heading text
logoReactNodeundefinedCustom logo node; defaults to a Sparkles icon button

ChatBody

Renders the scrollable message log — greeting, messages, typing indicator, CSAT flow, and quotes. Does not render the input bar; pair it with ChatInput.

PropTypeDefaultDescription
messagesChatMessage[]—Required. Ordered list of messages to display
greetingstringundefinedOptional static greeting text shown above the message list
isTypingbooleanfalseWhen true, shows the animated typing indicator
onMessageReplay(message: ChatMessage) => voidundefinedRenders the replay action on the user messages
onMessageEdit(message: ChatMessage, content: string) => voidundefinedEnables the in-place edition of the user text messages
onMessageCopy(message: ChatMessage) => void | Promise<void>undefinedRenders the copy action on the user messages
messageActionLabelsPartial<ChatMessageLabels>EnglishLabels of the message actions and of the editor
onFeedbackRate(rating: number) => voidundefinedCalled with the star rating (1–5) when the user submits feedback
csatLabelsChatWindowCsatPromptLabelsundefinedLabels for the rating prompt. Required to enable the CSAT flow
csatRenderRatingLabel(rating: number) => ReactNodeundefinedRenders the caption shown under the active star. Required with CSAT
csatThankYouLabelsChatWindowCsatThankYouLabelsundefinedLabels for the thank-you note shown after submission
onQuoteCtaClick(quote: ChatWindowQuoteItem) => voidundefinedCalled with the selected quote when a "See prices" CTA is tapped

ChatBody exposes the same imperative handle as ChatWindow (askForFeedback, showQuotes) via ref.

ChatInput

Renders the message text field, send button (a stop button while isStreaming), and optional microphone button. It also takes submitKey, isStreaming and onStop. See its own docs page for the full prop reference.

ChatWindowHandle

Imperative handle exposed via ref. Use forwardRef or useRef<ChatWindowHandle>.

MethodSignatureDescription
askForFeedback() => voidDisplays the CSAT star-rating prompt in the message log
showQuotes(quotes: ChatWindowQuoteItem[]) => voidAppends a quote block to the message log. Each call stacks a new block below the previous.

ChatWindowMessage

interface ChatWindowMessage {
  id: string;
  sender: 'assistant' | 'user';
  content: ReactNode;
}

Notes

  • The component is entirely controlled from the outside — all state (messages, input value, typing indicator) is managed by the parent.
  • ChatWindow is composable: pass ChatHeader/ChatBody/ChatInput as children to control the layout yourself, or skip ChatWindow entirely and compose them inside your own wrapper (see Advanced: compose without ChatWindow).
  • The message list (ChatBody) uses role="log" with aria-live="polite" so screen readers announce new messages automatically.
  • isTyping should be set to true while waiting for the AI response, then false when the response arrives.
  • Built from sub-components: ChatHeader, ChatBody, ChatInput, ChatMessage, ChatTypingIndicator, ChatWindowCsatPrompt, and ChatWindowCsatThankYou.
  • askForFeedback() is idempotent per mount — calling it more than once only shows the prompt once.
  • The CSAT flow only activates when all three csat* props are provided. Omitting any of them silently disables the flow.
  • The rating message appended to the log is purely internal to ChatBody and does not touch the parent-controlled messages array.