'use client'; import { X } from 'lucide-react'; import type { CSSProperties, ReactNode } from 'react'; import { Avatar, AvatarFallback, AvatarImage } from '@djangocfg/ui-core/components'; import { cn } from '@djangocfg/ui-core/lib'; import { Z_INDEX } from '../constants'; import type { ChatMessage, ChatPersona } from '../types'; import type { ChatFABPosition } from './ChatFAB'; import { useChatPresence } from './useChatPresence'; export interface ChatUnreadPreviewProps { /** Controlled — usually `!dockOpen && !!message`. */ open: boolean; /** Inbound message to preview. `null` hides the bubble. */ message: ChatMessage | null; /** Tap → open chat + mark read. */ onClick?: () => void; /** × → mark read without opening. */ onDismiss?: () => void; /** Anchor corner — match the FAB so the bubble sits above it. @default 'bottom-right' */ position?: ChatFABPosition; /** Horizontal offset from screen edge, matches the FAB. @default 24 */ fabOffset?: number; /** Vertical clearance above/below the FAB. @default 96 */ fabClearance?: number; /** Lines of body text before ellipsis. @default 2 */ truncate?: number; /** z-index. @default 99 — companion tier, just below the open dock. */ zIndex?: number; /** Render in-place (stories / previews). @default false */ inline?: boolean; /** Override classes on the bubble. */ className?: string; /** Override styles on the bubble. */ style?: CSSProperties; /** ARIA label for the dismiss button. @default 'Mark as read' */ dismissLabel?: string; /** Override the avatar — defaults to derived from `message.sender`. */ avatar?: ReactNode; /** Override the sender label — defaults to `message.sender?.name`. */ senderName?: string; } const TIME_FORMAT = new Intl.DateTimeFormat(undefined, { hour: '2-digit', minute: '2-digit', }); 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 { 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'; } function deriveAvatar(persona?: ChatPersona, name?: string): ReactNode { const initials = persona?.initials ?? (name ?? persona?.name ?? '?') .split(/\s+/) .map((p) => p[0]) .filter(Boolean) .slice(0, 2) .join('') .toUpperCase(); return ( {persona?.avatarUrl ? : null} {initials || '?'} ); } /** * Push-notification bubble next to the chat FAB. * * Shows the last inbound message while the chat is closed — * Intercom / LiveChat-style. Tap → open chat (host wires `onClick`). * Dismiss × → keep chat closed but stop nagging. * * Pair with `useChatUnread()` (inside ``) for state. */ export function ChatUnreadPreview({ open, message, onClick, onDismiss, position = 'bottom-right', fabOffset = 24, fabClearance = 96, truncate = 2, zIndex = Z_INDEX.companion, inline = false, className, style, dismissLabel = 'Mark as read', avatar, senderName, }: ChatUnreadPreviewProps) { const shouldShow = open && !!message; const phase = useChatPresence(shouldShow, 200); if (phase === 'hidden' || !message) return null; const animating = phase === 'entering' || phase === 'leaving'; const clickable = !!onClick; const displayName = senderName ?? message.sender?.name ?? 'New message'; const stamp = TIME_FORMAT.format(new Date(message.createdAt)); 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-[300px]', '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 ?? deriveAvatar(message.sender, displayName)}
{displayName}
{stamp}
{message.content}
{onDismiss ? ( ) : null}
); }