@usereq/widget 0.2.9 → 0.2.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,573 +0,0 @@
1
- "use client";
2
-
3
- import * as React from "react";
4
- import { AnimatePresence, motion } from "motion/react";
5
- import { cn } from "@usereq/ui/lib/utils";
6
- import {
7
- Avatar,
8
- AvatarFallback,
9
- AvatarImage,
10
- } from "@usereq/ui/components/avatar";
11
- import { Button } from "@usereq/ui/components/button";
12
- import { Textarea } from "@usereq/ui/components/textarea";
13
- import {
14
- ChevronDown,
15
- SendHorizonal,
16
- Phone,
17
- Video,
18
- Info,
19
- } from "lucide-react";
20
- import {
21
- DEFAULT_AGENT_NAME_BUBBLE,
22
- DEFAULT_POWERED_BY,
23
- DEFAULT_SPOTLIGHT_MESSAGES,
24
- } from "../chat-widget-defaults";
25
- import {
26
- ChatWidgetPanel,
27
- ChatWidgetPoweredBy,
28
- ChatWidgetTyping,
29
- useIsChatWidgetFullscreenViewport,
30
- useSpotlightFade,
31
- type ChatWidgetDataProps,
32
- type ChatWidgetPreviewMessage,
33
- } from "../components/chat-widget-primitives";
34
- import {
35
- Conversation,
36
- ConversationContent,
37
- ConversationEmptyState,
38
- ConversationScrollButton,
39
- } from "../components/conversation";
40
- import { foldStopConfirmationMessages } from "../stop-confirmation";
41
- import { getChatWidgetMessagesLayoutClasses } from "../components/chat-widget-layout";
42
- import {
43
- Message,
44
- MessageContent,
45
- MessageResponse,
46
- } from "../components/message";
47
-
48
- const MESSENGER_GRADIENT =
49
- "linear-gradient(135deg, #00B2FF 0%, #006AFF 50%, #8E5BFF 100%)";
50
-
51
- const MESSENGER_BRAND_COLOR = "#0084FF";
52
-
53
- const PANEL_WIDTH = 372;
54
- const PANEL_HEIGHT = 580;
55
- const LAUNCHER_SIZE = 60;
56
- const SPOTLIGHT_GAP = 10;
57
- const PANEL_GAP = 14;
58
-
59
- const fadeTransition = { duration: 0.35 };
60
-
61
- export function ChatWidgetMessenger({
62
- agentName = DEFAULT_AGENT_NAME_BUBBLE,
63
- agentAvatarUrl,
64
- spotlightMessages = [],
65
- messages = [],
66
- poweredBy = DEFAULT_POWERED_BY,
67
- headerStatus = "Active now",
68
- errorMessage,
69
- status = "idle",
70
- sendDisabled = false,
71
- onSendMessage,
72
- isTyping,
73
- onLauncherClick,
74
- className,
75
- open,
76
- defaultOpen = false,
77
- onOpenChange,
78
- previewOnly = false,
79
- onStopConversation,
80
- stopDisabled,
81
- onConfirmationDecision,
82
- onLinkClick,
83
- emptyAction,
84
- portalContainer,
85
- }: ChatWidgetDataProps & {
86
- open?: boolean;
87
- className?: string;
88
- defaultOpen?: boolean;
89
- onOpenChange?: (open: boolean) => void;
90
- onLauncherClick?: () => void;
91
- previewOnly?: boolean;
92
- portalContainer?: React.ComponentProps<
93
- typeof ChatWidgetPanel
94
- >["portalContainer"];
95
- }) {
96
- const [uncontrolledOpen, setUncontrolledOpen] = React.useState(
97
- previewOnly ? false : defaultOpen,
98
- );
99
- const [draftValue, setDraftValue] = React.useState("");
100
- const spotlightForFade =
101
- spotlightMessages.length > 0
102
- ? spotlightMessages
103
- : [...DEFAULT_SPOTLIGHT_MESSAGES];
104
- const spotlightText = useSpotlightFade(spotlightForFade, 3200);
105
- const effectiveOpen = previewOnly ? false : (open ?? uncontrolledOpen);
106
- const handleOpenChange = React.useCallback(
107
- (nextOpen: boolean) => {
108
- if (onOpenChange) {
109
- onOpenChange(nextOpen);
110
- return;
111
- }
112
- setUncontrolledOpen(nextOpen);
113
- },
114
- [onOpenChange],
115
- );
116
- const isFullscreenViewport = useIsChatWidgetFullscreenViewport();
117
- const hideLauncher = isFullscreenViewport && effectiveOpen;
118
-
119
- const sendIsDisabled = sendDisabled || status === "booting";
120
-
121
- return (
122
- <div
123
- className={cn("relative", className)}
124
- style={{
125
- width: spotlightForFade.length > 0 ? 280 : LAUNCHER_SIZE,
126
- height:
127
- spotlightForFade.length > 0
128
- ? LAUNCHER_SIZE + SPOTLIGHT_GAP + 56
129
- : LAUNCHER_SIZE,
130
- }}
131
- >
132
- {spotlightForFade.length > 0 && !effectiveOpen && (
133
- <AnimatePresence mode="wait">
134
- <motion.div
135
- key={spotlightText}
136
- initial={{ opacity: 0, y: 6 }}
137
- animate={{ opacity: 1, y: 0 }}
138
- exit={{ opacity: 0, y: 6 }}
139
- transition={fadeTransition}
140
- className="absolute right-0 bottom-[calc(60px+10px)] flex w-full max-w-70 flex-col items-end"
141
- >
142
- <div className="rounded-3xl bg-background px-4 py-2.5 text-right shadow-[0_4px_16px_rgba(0,0,0,0.12)]">
143
- <p className="text-[14px] leading-snug text-foreground">
144
- {spotlightText}
145
- </p>
146
- </div>
147
- </motion.div>
148
- </AnimatePresence>
149
- )}
150
-
151
- {!previewOnly && (
152
- <ChatWidgetPanel
153
- open={effectiveOpen}
154
- onOpenChange={handleOpenChange}
155
- portalContainer={portalContainer}
156
- desktopClassName="absolute right-0 overflow-hidden rounded-3xl bg-background shadow-[0_8px_32px_rgba(0,0,0,0.16)] border border-border"
157
- desktopStyle={{
158
- width: PANEL_WIDTH,
159
- height: PANEL_HEIGHT,
160
- bottom: LAUNCHER_SIZE + PANEL_GAP,
161
- }}
162
- >
163
- <MessengerHeader
164
- agentName={agentName}
165
- agentAvatarUrl={agentAvatarUrl}
166
- statusText={headerStatus}
167
- onClose={() => handleOpenChange(false)}
168
- onStopConversation={onStopConversation ?? undefined}
169
- stopDisabled={stopDisabled}
170
- />
171
- <div className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
172
- <MessengerMessages
173
- messages={messages}
174
- isTyping={isTyping ?? status === "sending"}
175
- status={status}
176
- errorMessage={errorMessage}
177
- agentName={agentName}
178
- agentAvatarUrl={agentAvatarUrl}
179
- onConfirmationDecision={onConfirmationDecision}
180
- onLinkClick={onLinkClick}
181
- emptyAction={emptyAction}
182
- />
183
- </div>
184
- {!(emptyAction && messages.length === 0 && !isTyping) && (
185
- <MessengerComposer
186
- value={draftValue}
187
- onChange={setDraftValue}
188
- onSubmit={onSendMessage}
189
- disabled={sendIsDisabled}
190
- />
191
- )}
192
- <ChatWidgetPoweredBy
193
- text={poweredBy}
194
- className="bg-background py-1.5"
195
- />
196
- </ChatWidgetPanel>
197
- )}
198
-
199
- {!hideLauncher && (
200
- <motion.button
201
- type="button"
202
- onClick={
203
- previewOnly
204
- ? undefined
205
- : () => {
206
- onLauncherClick?.();
207
- handleOpenChange(!effectiveOpen);
208
- }
209
- }
210
- className={cn(
211
- "absolute bottom-0 right-0 flex items-center justify-center rounded-full text-white",
212
- previewOnly && "pointer-events-none",
213
- )}
214
- style={{
215
- width: LAUNCHER_SIZE,
216
- height: LAUNCHER_SIZE,
217
- backgroundColor: MESSENGER_BRAND_COLOR,
218
- boxShadow: "0 6px 20px rgba(0, 132, 255, 0.45)",
219
- }}
220
- aria-label={effectiveOpen ? "Close chat" : "Open chat"}
221
- aria-expanded={effectiveOpen}
222
- animate={{ scale: 1 }}
223
- whileHover={previewOnly ? undefined : { scale: 1.04 }}
224
- whileTap={previewOnly ? undefined : { scale: 0.96 }}
225
- >
226
- {effectiveOpen ? (
227
- <ChevronDown className="size-7" strokeWidth={2.4} />
228
- ) : (
229
- <MessengerLogo className="size-8" />
230
- )}
231
- </motion.button>
232
- )}
233
- </div>
234
- );
235
- }
236
-
237
- function MessengerMessages({
238
- messages,
239
- isTyping,
240
- status,
241
- errorMessage,
242
- agentName,
243
- agentAvatarUrl,
244
- onConfirmationDecision,
245
- onLinkClick,
246
- emptyAction,
247
- }: {
248
- messages: ChatWidgetPreviewMessage[];
249
- isTyping: boolean;
250
- status: ChatWidgetDataProps["status"];
251
- errorMessage?: string | null;
252
- agentName: string;
253
- agentAvatarUrl?: string | null;
254
- onConfirmationDecision?: ChatWidgetDataProps["onConfirmationDecision"];
255
- onLinkClick?: (linkUrl: string) => void;
256
- emptyAction?: { label: string; onClick: () => void; disabled?: boolean };
257
- }) {
258
- const { scrollClassName, contentClassName } =
259
- getChatWidgetMessagesLayoutClasses();
260
- const renderedMessages = React.useMemo(
261
- () => foldStopConfirmationMessages(messages),
262
- [messages],
263
- );
264
-
265
- if (messages.length === 0 && !isTyping) {
266
- if (status === "error") {
267
- return (
268
- <div className="flex h-full items-center justify-center px-6 py-8 text-center text-sm text-destructive">
269
- {errorMessage ?? "Something went wrong."}
270
- </div>
271
- );
272
- }
273
-
274
- return (
275
- <div className="flex min-h-full flex-1 items-center justify-center px-6 py-8">
276
- <ConversationEmptyState className="w-full max-w-[280px] gap-4">
277
- <Avatar
278
- className="size-16 ring-4 ring-background"
279
- style={{
280
- boxShadow: "0 6px 18px rgba(0, 132, 255, 0.25)",
281
- }}
282
- >
283
- {agentAvatarUrl ? (
284
- <AvatarImage src={agentAvatarUrl} alt={agentName} />
285
- ) : null}
286
- <AvatarFallback
287
- className="text-white text-base font-semibold"
288
- style={{ backgroundColor: MESSENGER_BRAND_COLOR }}
289
- >
290
- {agentName.slice(0, 2).toUpperCase()}
291
- </AvatarFallback>
292
- </Avatar>
293
- <div>
294
- <p className="text-[15px] font-semibold leading-tight text-foreground">
295
- {agentName}
296
- </p>
297
- <p className="mt-1.5 text-[13px] leading-snug text-muted-foreground">
298
- Typically replies in a few minutes.
299
- </p>
300
- </div>
301
- {emptyAction && (
302
- <Button
303
- size="lg"
304
- disabled={emptyAction.disabled}
305
- onClick={emptyAction.onClick}
306
- className="rounded-full px-6 text-white shadow-[0_4px_12px_rgba(0,132,255,0.35)] hover:opacity-95 disabled:opacity-60"
307
- style={{ backgroundColor: MESSENGER_BRAND_COLOR }}
308
- >
309
- {emptyAction.label}
310
- </Button>
311
- )}
312
- </ConversationEmptyState>
313
- </div>
314
- );
315
- }
316
-
317
- return (
318
- <Conversation className="relative size-full">
319
- <ConversationContent
320
- scrollClassName={scrollClassName}
321
- className={cn(contentClassName, "gap-1.5 px-3 py-3")}
322
- >
323
- {renderedMessages.map((item) => {
324
- if (item.type === "confirmation") {
325
- return (
326
- <Message key={item.message.id} from="assistant">
327
- <div className="flex w-fit max-w-[80%] shrink-0 flex-col gap-2">
328
- <MessageResponse
329
- confirmation={item.latestEnvelope}
330
- confirmationPrompt={item.envelope.prompt}
331
- confirmationActionsDisabled={status === "sending"}
332
- onConfirmationDecision={onConfirmationDecision}
333
- />
334
- </div>
335
- </Message>
336
- );
337
- }
338
-
339
- const m = item.message;
340
- const isUser = m.role === "user";
341
- return (
342
- <Message key={m.id} from={m.role}>
343
- <div className="flex w-fit max-w-[80%] shrink-0 flex-col gap-1.5">
344
- <MessageContent
345
- className={cn(
346
- "whitespace-pre-wrap rounded-[18px] px-3.5 py-2 text-[14px] leading-snug",
347
- isUser
348
- ? "text-white"
349
- : "bg-secondary text-secondary-foreground",
350
- )}
351
- style={
352
- isUser ? { background: MESSENGER_GRADIENT } : undefined
353
- }
354
- >
355
- {isUser ? (
356
- m.content
357
- ) : (
358
- <MessageResponse onLinkClick={onLinkClick}>
359
- {m.content}
360
- </MessageResponse>
361
- )}
362
- </MessageContent>
363
- {m.actions && m.actions.length > 0 && (
364
- <div className="flex flex-wrap gap-2">
365
- {m.actions.map((action, i) => (
366
- <Button
367
- key={i}
368
- size="sm"
369
- variant={action.variant ?? "outline"}
370
- onClick={action.onClick}
371
- className="rounded-full"
372
- >
373
- {action.label}
374
- </Button>
375
- ))}
376
- </div>
377
- )}
378
- </div>
379
- </Message>
380
- );
381
- })}
382
- {isTyping && (
383
- <motion.div
384
- initial={{ opacity: 0, y: 6 }}
385
- animate={{ opacity: 1, y: 0 }}
386
- transition={{ duration: 0.2, ease: "easeOut" }}
387
- className="flex w-full justify-start"
388
- >
389
- <div className="flex max-w-[80%] items-center rounded-[18px] bg-secondary px-3.5 py-2 text-secondary-foreground">
390
- <ChatWidgetTyping className="bg-transparent! p-0!" />
391
- </div>
392
- </motion.div>
393
- )}
394
- </ConversationContent>
395
- <ConversationScrollButton />
396
- </Conversation>
397
- );
398
- }
399
-
400
- function MessengerHeader({
401
- agentName,
402
- agentAvatarUrl,
403
- statusText,
404
- onClose,
405
- onStopConversation,
406
- stopDisabled,
407
- }: {
408
- agentName: string;
409
- agentAvatarUrl?: string | null;
410
- statusText: string;
411
- onClose: () => void;
412
- onStopConversation?: () => void;
413
- stopDisabled?: boolean;
414
- }) {
415
- return (
416
- <div
417
- className="flex shrink-0 items-center gap-3 px-4 py-3 text-white"
418
- style={{ backgroundColor: MESSENGER_BRAND_COLOR }}
419
- >
420
- <div className="relative">
421
- <Avatar size="lg" className="ring-2 ring-white/30">
422
- {agentAvatarUrl ? (
423
- <AvatarImage src={agentAvatarUrl} alt={agentName} />
424
- ) : null}
425
- <AvatarFallback className="bg-white/20 text-white">
426
- {agentName.slice(0, 2).toUpperCase()}
427
- </AvatarFallback>
428
- </Avatar>
429
- <span className="absolute -bottom-0.5 -right-0.5 size-3 rounded-full bg-[#31D778] ring-2 ring-white" />
430
- </div>
431
- <div className="min-w-0 flex-1">
432
- <div className="truncate text-[15px] font-semibold leading-tight">
433
- {agentName}
434
- </div>
435
- <div className="text-[12px] leading-tight text-white/80">
436
- {statusText}
437
- </div>
438
- </div>
439
- <div className="flex shrink-0 items-center gap-0.5">
440
- <button
441
- type="button"
442
- aria-label="Phone"
443
- className="flex size-8 items-center justify-center rounded-full text-white/85 transition-colors hover:bg-white/15 hover:text-white"
444
- >
445
- <Phone className="size-4.5" strokeWidth={2.2} />
446
- </button>
447
- <button
448
- type="button"
449
- aria-label="Video"
450
- className="flex size-8 items-center justify-center rounded-full text-white/85 transition-colors hover:bg-white/15 hover:text-white"
451
- >
452
- <Video className="size-4.5" strokeWidth={2.2} />
453
- </button>
454
- {onStopConversation != null && (
455
- <button
456
- type="button"
457
- aria-label="Conversation info"
458
- title="End conversation"
459
- disabled={stopDisabled}
460
- onClick={onStopConversation}
461
- className="flex size-8 items-center justify-center rounded-full text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-50"
462
- >
463
- <Info className="size-4.5" strokeWidth={2.2} />
464
- </button>
465
- )}
466
- <button
467
- type="button"
468
- aria-label="Minimize"
469
- onClick={onClose}
470
- className="ml-0.5 flex size-8 items-center justify-center rounded-full text-white/90 transition-colors hover:bg-white/15 hover:text-white"
471
- >
472
- <ChevronDown className="size-5" strokeWidth={2.4} />
473
- </button>
474
- </div>
475
- </div>
476
- );
477
- }
478
-
479
- function MessengerComposer({
480
- value,
481
- onChange,
482
- onSubmit,
483
- disabled,
484
- }: {
485
- value: string;
486
- onChange: (value: string) => void;
487
- onSubmit?: (value: string) => void;
488
- disabled?: boolean;
489
- }) {
490
- const textareaRef = React.useRef<HTMLTextAreaElement>(null);
491
-
492
- const submit = () => {
493
- const trimmed = value.trim();
494
- if (!trimmed || !onSubmit) return;
495
- onSubmit(trimmed);
496
- onChange("");
497
- textareaRef.current?.focus();
498
- };
499
-
500
- const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
501
- if (e.key !== "Enter") return;
502
- if (e.shiftKey) return;
503
- e.preventDefault();
504
- submit();
505
- };
506
-
507
- const hasText = value.trim().length > 0;
508
- const sendDisabled = disabled || !onSubmit || !hasText;
509
-
510
- return (
511
- <div className="shrink-0 border-t border-border bg-background px-2 py-2">
512
- <div className="flex items-end gap-0.5">
513
- {/* Tool icons (Plus / Camera / Image / Mic) temporarily disabled. */}
514
- <div className="flex flex-1 items-end rounded-3xl bg-secondary px-3.5">
515
- <Textarea
516
- ref={textareaRef}
517
- placeholder="Aa"
518
- value={value}
519
- onChange={(e) => onChange(e.target.value)}
520
- onKeyDown={handleKeyDown}
521
- rows={1}
522
- disabled={disabled}
523
- autoResize
524
- className={cn(
525
- "min-h-9 max-h-32 flex-1 border-0 bg-transparent py-2 text-[14px] leading-snug shadow-none",
526
- "focus-visible:ring-0 focus-visible:border-0",
527
- )}
528
- />
529
- </div>
530
- <button
531
- type="button"
532
- aria-label="Send"
533
- onClick={submit}
534
- disabled={sendDisabled}
535
- className={cn(
536
- "ml-1 flex size-9 shrink-0 items-center justify-center rounded-full transition-colors",
537
- "hover:bg-secondary disabled:hover:bg-transparent",
538
- )}
539
- style={{ color: MESSENGER_BRAND_COLOR }}
540
- >
541
- <SendHorizonal
542
- className={cn(
543
- "size-5 transition-opacity",
544
- sendDisabled && "opacity-40",
545
- )}
546
- strokeWidth={2.2}
547
- />
548
- </button>
549
- </div>
550
- </div>
551
- );
552
- }
553
-
554
- function MessengerLogo({ className }: { className?: string }) {
555
- return (
556
- <svg
557
- viewBox="0 0 24 24"
558
- fill="currentColor"
559
- aria-hidden="true"
560
- className={className}
561
- >
562
- <path d="M12 2C6.48 2 2 6.18 2 11.32c0 2.93 1.45 5.55 3.7 7.27v3.4l3.39-1.86a10.5 10.5 0 0 0 2.91.41c5.52 0 10-4.18 10-9.22S17.52 2 12 2Zm1.06 12.42-2.55-2.72-4.97 2.72 5.46-5.8 2.61 2.72 4.91-2.72-5.46 5.8Z" />
563
- </svg>
564
- );
565
- }
566
-
567
- export const MESSENGER_PRESET_GRADIENT = MESSENGER_GRADIENT;
568
-
569
- export const MESSENGER_PRESET_APPEARANCE = {
570
- avatarOrbColor1: "#00B2FF",
571
- avatarOrbColor2: "#006AFF",
572
- actionText: "Chat with us",
573
- } as const;