'use client'; import { X } from 'lucide-react'; import { useEffect, useState } from 'react'; import type { CSSProperties, ReactNode } from 'react'; import { cn } from '@djangocfg/ui-core/lib'; import { Z_INDEX } from '../constants'; import { useChatPresence } from './useChatPresence'; import type { ChatFABPosition } from './ChatFAB'; export interface ChatGreetingProps { /** Controlled visibility — usually `!chatOpen && !userDismissed`. */ open: boolean; /** Greeting text. Pass a string for the default bubble, or any ReactNode. */ children: ReactNode; /** Click handler — typically opens the chat. Bubble is clickable when set. */ onClick?: () => void; /** Close (×) button handler — typically marks the greeting as dismissed. */ onDismiss?: () => void; /** Anchor relative to a FAB on the same side. @default 'bottom-right' */ position?: ChatFABPosition; /** * Horizontal pixel offset matching the FAB's `offset` prop, so the greeting * lines up under the FAB. @default 24 */ fabOffset?: number; /** * Vertical pixel offset above/below the FAB centerline. @default 96 * (room for an `md` FAB plus a small gap). */ fabClearance?: number; /** Delay before the greeting appears, in ms. @default 1500 */ delayMs?: number; /** z-index. @default 99 — companion tier, just below the open dock. */ zIndex?: number; /** Override classes on the bubble. */ className?: string; /** Override styles on the bubble. */ style?: CSSProperties; /** Optional sender avatar / icon shown on the left. */ avatar?: ReactNode; /** Optional sender label rendered above the text. */ senderName?: string; /** ARIA label for the dismiss button. @default 'Dismiss' */ dismissLabel?: string; /** * Render in-place (no fixed positioning). Useful for stories and inline previews. * @default false */ inline?: boolean; } function anchorStyle( position: ChatFABPosition, fabOffset: number, fabClearance: number, ): CSSProperties { const [vert, horiz] = position.split('-') as ['bottom' | 'top', 'right' | 'left']; return { [vert]: fabClearance, [horiz]: fabOffset } as CSSProperties; } function originClass(position: ChatFABPosition): string { // Scale-in origin matches the corner the bubble attaches to. if (position === 'bottom-right') return 'origin-bottom-right'; if (position === 'bottom-left') return 'origin-bottom-left'; if (position === 'top-right') return 'origin-top-right'; return 'origin-top-left'; } /** * Greeting bubble shown next to a `ChatFAB` to invite the user to start a * conversation (LiveChat / Intercom-style proactive prompt). * * Renders fixed-position, anchored to the same corner as the FAB. Owns its * own delayed-mount + presence animation. Hide on chat open and/or after * user dismissal. * * @example * ```tsx * const [open, setOpen] = useState(false); * const [dismissed, setDismissed] = useState(false); * * * * * * setOpen(true)} * onDismiss={() => setDismissed(true)} * senderName="Anna from Support" * delayMs={2000} * > * Hi! 👋 Got a question? I'm here to help. * * ``` */ export function ChatGreeting({ open, children, onClick, onDismiss, position = 'bottom-right', fabOffset = 24, fabClearance = 96, delayMs = 1500, zIndex = Z_INDEX.companion, className, style, avatar, senderName, dismissLabel = 'Dismiss', inline = false, }: ChatGreetingProps) { const [delayed, setDelayed] = useState(delayMs <= 0); useEffect(() => { if (!open || delayMs <= 0) return; const t = setTimeout(() => setDelayed(true), delayMs); return () => clearTimeout(t); }, [open, delayMs]); const shouldShow = open && delayed; const phase = useChatPresence(shouldShow, 220); if (phase === 'hidden') return null; const animating = phase === 'entering' || phase === 'leaving'; const clickable = !!onClick; return (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onClick?.(); } } : undefined } className={cn( inline ? 'relative inline-flex' : 'fixed', 'flex items-start gap-2.5 max-w-[280px]', 'rounded-2xl border border-border bg-popover text-popover-foreground', 'px-3.5 py-2.5 shadow-2xl transition-all duration-200 ease-out', clickable && 'cursor-pointer hover:bg-accent/40 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring', originClass(position), animating ? 'opacity-0 scale-95 translate-y-1' : 'opacity-100 scale-100 translate-y-0', className, )} style={{ ...(inline ? {} : anchorStyle(position, fabOffset, fabClearance)), ...(inline ? {} : { zIndex }), pointerEvents: phase === 'visible' ? 'auto' : 'none', ...style, }} > {avatar &&
{avatar}
}
{senderName && (
{senderName}
)}
{children}
{onDismiss && ( )}
); }