diff --git a/components/seo/ChatbotDesignerSEO.tsx b/components/seo/ChatbotDesignerSEO.tsx new file mode 100644 index 0000000..29984aa --- /dev/null +++ b/components/seo/ChatbotDesignerSEO.tsx @@ -0,0 +1,43 @@ +export default function ChatbotDesignerSEO() { + return ( +
+
+

Design a Custom AI Chat UI

+

+ Build a chatbot interface in minutes with Jam’s Chatbot Designer. Pick + colors, spacing, typography, and layout, then copy the generated React + component and JSON config straight into your product. +

+
+ +
+

What You Can Customize

+ +
+ +
+

Copy Paste React Code

+

+ The designer generates a ready-to-use React component with inline + styles and a placeholder sendMessage function. Update the API + call and ship a production-ready chatbot interface quickly. +

+
+
+ ); +} diff --git a/components/utils/chatbot-designer.utils.ts b/components/utils/chatbot-designer.utils.ts new file mode 100644 index 0000000..6b1955e --- /dev/null +++ b/components/utils/chatbot-designer.utils.ts @@ -0,0 +1,289 @@ +export type ChatbotLayout = "compact" | "spacious"; + +export interface ChatbotDesignerConfig { + title: string; + subtitle: string; + showHeader: boolean; + streaming: boolean; + layout: ChatbotLayout; + fontFamily: string; + fontSize: number; + containerMaxWidth: number; + containerPadding: number; + messageGap: number; + bubbleRadius: number; + bubblePaddingX: number; + bubblePaddingY: number; + inputRadius: number; + inputPaddingX: number; + inputPaddingY: number; + colors: { + surface: string; + border: string; + headerBackground: string; + headerText: string; + userBubble: string; + userText: string; + botBubble: string; + botText: string; + inputBackground: string; + inputText: string; + inputBorder: string; + sendButton: string; + sendButtonText: string; + }; +} + +export const layoutPresets: Record< + ChatbotLayout, + Pick< + ChatbotDesignerConfig, + | "containerPadding" + | "messageGap" + | "bubblePaddingX" + | "bubblePaddingY" + | "inputPaddingX" + | "inputPaddingY" + | "fontSize" + > +> = { + compact: { + containerPadding: 14, + messageGap: 8, + bubblePaddingX: 12, + bubblePaddingY: 8, + inputPaddingX: 12, + inputPaddingY: 8, + fontSize: 14, + }, + spacious: { + containerPadding: 20, + messageGap: 12, + bubblePaddingX: 16, + bubblePaddingY: 12, + inputPaddingX: 16, + inputPaddingY: 10, + fontSize: 16, + }, +}; + +export const defaultChatbotDesignerConfig: ChatbotDesignerConfig = { + title: "Acme Support", + subtitle: "Ask anything about your account", + showHeader: true, + streaming: true, + layout: "compact", + fontFamily: "Inter, system-ui, -apple-system, sans-serif", + fontSize: 14, + containerMaxWidth: 420, + containerPadding: 14, + messageGap: 8, + bubbleRadius: 14, + bubblePaddingX: 12, + bubblePaddingY: 8, + inputRadius: 12, + inputPaddingX: 12, + inputPaddingY: 8, + colors: { + surface: "#FFFFFF", + border: "#E5E7EB", + headerBackground: "#0F172A", + headerText: "#F8FAFC", + userBubble: "#2563EB", + userText: "#FFFFFF", + botBubble: "#F1F5F9", + botText: "#0F172A", + inputBackground: "#FFFFFF", + inputText: "#0F172A", + inputBorder: "#CBD5F5", + sendButton: "#111827", + sendButtonText: "#FFFFFF", + }, +}; + +export const applyLayoutPreset = ( + config: ChatbotDesignerConfig, + layout: ChatbotLayout +): ChatbotDesignerConfig => { + return { + ...config, + layout, + ...layoutPresets[layout], + }; +}; + +export const getChatbotDesignerJson = (config: ChatbotDesignerConfig): string => { + return JSON.stringify(config, null, 2); +}; + +export const getChatbotDesignerReactSnippet = ( + config: ChatbotDesignerConfig +): string => { + const configString = JSON.stringify(config, null, 2); + + return `import React, { useState } from "react"; + +const config = ${configString}; + +type MessageRole = "user" | "assistant"; +type Message = { role: MessageRole; content: string }; + +type SendOptions = { stream: boolean }; +type StreamResult = AsyncIterable; + +async function sendMessage( + messages: Message[], + options: SendOptions +): Promise { + // TODO: Replace with your API call. + if (options.stream) { + return (async function* () {})(); + } + + return "Replace with your API response."; +} + +export function ChatbotWidget() { + const [messages, setMessages] = useState([ + { + role: "assistant", + content: "Hi! How can I help you today?", + }, + ]); + const [input, setInput] = useState(""); + + const handleSend = async () => { + const trimmed = input.trim(); + if (!trimmed) return; + + const nextMessages: Message[] = [ + ...messages, + { role: "user", content: trimmed }, + ]; + setMessages(nextMessages); + setInput(""); + + if (config.streaming) { + const stream = (await sendMessage(nextMessages, { + stream: true, + })) as StreamResult; + let buffer = ""; + + for await (const chunk of stream) { + buffer += chunk; + setMessages([ + ...nextMessages, + { role: "assistant", content: buffer }, + ]); + } + + return; + } + + const reply = (await sendMessage(nextMessages, { + stream: false, + })) as string; + setMessages([...nextMessages, { role: "assistant", content: reply }]); + }; + + return ( +
+ {config.showHeader && ( +
+
{config.title}
+
{config.subtitle}
+
+ )} + +
+ {messages.map((message, index) => { + const isUser = message.role === "user"; + + return ( +
+
+ {message.content} +
+
+ ); + })} +
+ +
+ setInput(event.target.value)} + placeholder="Type a message..." + style={{ + flex: 1, + background: config.colors.inputBackground, + color: config.colors.inputText, + border: \`1px solid \${config.colors.inputBorder}\`, + borderRadius: config.inputRadius, + padding: \`\${config.inputPaddingY}px \${config.inputPaddingX}px\`, + }} + /> + +
+
+ ); +} +`; +}; diff --git a/components/utils/tools-list.ts b/components/utils/tools-list.ts index aa8ef64..389c526 100644 --- a/components/utils/tools-list.ts +++ b/components/utils/tools-list.ts @@ -167,6 +167,12 @@ export const tools = [ "Generate cryptographically secure random strings with configurable character sets. Perfect for API keys, tokens, passwords, and secure identifiers.", link: "/utilities/random-string-generator", }, + { + title: "Chatbot Designer", + description: + "Design a customizable AI chat UI with live preview and copy-paste React code and JSON config.", + link: "/utilities/chatbot-designer", + }, { title: "WCAG Color Contrast Checker", description: diff --git a/pages/utilities/chatbot-designer.tsx b/pages/utilities/chatbot-designer.tsx new file mode 100644 index 0000000..ea6f883 --- /dev/null +++ b/pages/utilities/chatbot-designer.tsx @@ -0,0 +1,574 @@ +import { useCallback, useMemo, useState } from "react"; +import PageHeader from "@/components/PageHeader"; +import { Card } from "@/components/ds/CardComponent"; +import { Label } from "@/components/ds/LabelComponent"; +import Header from "@/components/Header"; +import { CMDK } from "@/components/CMDK"; +import { Input } from "@/components/ds/InputComponent"; +import { ColorPicker } from "@/components/ds/ColorPickerComponent"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ds/TabsComponent"; +import { Textarea } from "@/components/ds/TextareaComponent"; +import { Button } from "@/components/ds/ButtonComponent"; +import { Slider } from "@/components/ds/SliderComponent"; +import { Checkbox } from "@/components/ds/CheckboxComponent"; +import CallToActionGrid from "@/components/CallToActionGrid"; +import Meta from "@/components/Meta"; +import { useCopyToClipboard } from "@/components/hooks/useCopyToClipboard"; +import ChatbotDesignerSEO from "@/components/seo/ChatbotDesignerSEO"; +import { + applyLayoutPreset, + ChatbotDesignerConfig, + ChatbotLayout, + defaultChatbotDesignerConfig, + getChatbotDesignerJson, + getChatbotDesignerReactSnippet, +} from "@/components/utils/chatbot-designer.utils"; +import { normalizeHexInput } from "@/components/utils/wcag-color-contrast.utils"; + +const SAMPLE_MESSAGES = [ + { + role: "assistant", + content: "Hi! I'm the Acme bot. What can I help you with?", + }, + { + role: "user", + content: "Can you update my billing address?", + }, + { + role: "assistant", + content: "Absolutely. Share the new address and I will update it.", + }, +] as const; + +const layoutLabels: Record = { + compact: "Compact", + spacious: "Spacious", +}; + +export default function ChatbotDesigner() { + const [config, setConfig] = useState( + defaultChatbotDesignerConfig + ); + const { buttonText: reactCopyText, handleCopy: handleReactCopy } = + useCopyToClipboard(); + const { buttonText: jsonCopyText, handleCopy: handleJsonCopy } = + useCopyToClipboard(); + + const reactSnippet = useMemo( + () => getChatbotDesignerReactSnippet(config), + [config] + ); + const jsonSnippet = useMemo(() => getChatbotDesignerJson(config), [config]); + + const handleLayoutChange = useCallback((layout: string) => { + setConfig((prev) => applyLayoutPreset(prev, layout as ChatbotLayout)); + }, []); + + const handleToggleChange = useCallback( + (key: "showHeader" | "streaming") => + (value: boolean | "indeterminate") => { + setConfig((prev) => ({ ...prev, [key]: Boolean(value) })); + }, + [] + ); + + const handleTextChange = useCallback( + (key: keyof ChatbotDesignerConfig) => + (event: React.ChangeEvent) => { + const value = event.target.value; + setConfig((prev) => ({ ...prev, [key]: value })); + }, + [] + ); + + const handleNumberChange = useCallback( + (key: keyof ChatbotDesignerConfig) => (value: number) => { + setConfig((prev) => ({ ...prev, [key]: value })); + }, + [] + ); + + const handleColorChange = useCallback( + (key: keyof ChatbotDesignerConfig["colors"]) => + (value: string) => { + setConfig((prev) => ({ + ...prev, + colors: { ...prev.colors, [key]: value }, + })); + }, + [] + ); + + const handleHexInputChange = useCallback( + (key: keyof ChatbotDesignerConfig["colors"]) => + (event: React.ChangeEvent) => { + const normalized = normalizeHexInput(event.target.value); + setConfig((prev) => ({ + ...prev, + colors: { ...prev.colors, [key]: normalized }, + })); + }, + [] + ); + + return ( +
+ +
+ + +
+ +
+ +
+
+ +
+
+ + + {layoutLabels[config.layout]} + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + + + Compact + Spacious + + + +
+ + + + + + + + + + +
+ + +
+
+
+ +
+ +
+ + + + + + + + + + + + + +
+
+
+ + + +
+
+ {config.showHeader && ( +
+
{config.title}
+
{config.subtitle}
+
+ )} + +
+ {SAMPLE_MESSAGES.map((message, index) => { + const isUser = message.role === "user"; + + return ( +
+
+ {message.content} +
+
+ ); + })} +
+ +
+ + +
+
+
+
+ + + + + + React + JSON + + +