@handled-ai/design-system 0.20.21 → 0.20.23
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.
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/conversation-panel.d.ts +9 -0
- package/dist/components/conversation-panel.js +2 -1
- package/dist/components/conversation-panel.js.map +1 -1
- package/dist/components/pill.d.ts +1 -1
- package/dist/components/tabs.d.ts +1 -1
- package/dist/components/timeline-activity.d.ts +45 -0
- package/dist/components/timeline-activity.js +401 -52
- package/dist/components/timeline-activity.js.map +1 -1
- package/dist/internal/safe-html.js +32 -0
- package/dist/internal/safe-html.js.map +1 -1
- package/package.json +1 -1
- package/src/components/__tests__/conversation-panel.test.tsx +40 -1
- package/src/components/__tests__/timeline-activity.test.tsx +329 -0
- package/src/components/conversation-panel.tsx +10 -0
- package/src/components/timeline-activity.tsx +516 -39
- package/src/internal/__tests__/safe-html.test.ts +24 -5
- package/src/internal/safe-html.ts +39 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import * as React from "react"
|
|
4
4
|
import { cn } from "../lib/utils"
|
|
5
|
-
import { ChevronDown, ChevronUp, ExternalLink } from "lucide-react"
|
|
5
|
+
import { ChevronDown, ChevronUp, ExternalLink, Phone } from "lucide-react"
|
|
6
6
|
import { EmailBody as SharedEmailBody } from "./email-body"
|
|
7
7
|
import {
|
|
8
8
|
decodeEmailDisplayText,
|
|
@@ -54,6 +54,51 @@ export interface TimelineEvent {
|
|
|
54
54
|
* existing consumers are unaffected.
|
|
55
55
|
*/
|
|
56
56
|
bodyHtml?: string
|
|
57
|
+
/**
|
|
58
|
+
* Sender signature HTML, split out of the main body by the consuming app's
|
|
59
|
+
* email pipeline. When present, it is hidden behind a subtle "Show
|
|
60
|
+
* signature" toggle (collapsed by default) so signatures don't add noise to
|
|
61
|
+
* the timeline. Sanitized before rendering. Optional — when absent the body
|
|
62
|
+
* renders exactly as before. Mirrors `signatureHtml` on EmailPreviewCard.
|
|
63
|
+
*/
|
|
64
|
+
signatureHtml?: string | null
|
|
65
|
+
/**
|
|
66
|
+
* Quoted / trailing thread content (the "On <date> X wrote:" history) split
|
|
67
|
+
* out of the main body. When present, it is hidden behind a subtle "Show
|
|
68
|
+
* quoted text" toggle (collapsed by default). Sanitized before rendering.
|
|
69
|
+
* Optional — when absent the body renders exactly as before.
|
|
70
|
+
*/
|
|
71
|
+
quotedHtml?: string | null
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Gong call payload. When present, the card renders a dedicated, first-class
|
|
75
|
+
* Gong call card (mirroring the `email` treatment) instead of generic
|
|
76
|
+
* content: a collapsed duration + brief-snippet preview, and an expanded
|
|
77
|
+
* card with the call title, start time, direction/outcome chips, the AI
|
|
78
|
+
* brief, key points, next steps, and a "View in Gong" link. Opt-in: when
|
|
79
|
+
* absent, existing consumers are unaffected. Every field except `title` is
|
|
80
|
+
* optional/nullable and renders nothing when absent (no empty sections,
|
|
81
|
+
* no "null"/"undefined", no "Invalid Date").
|
|
82
|
+
*/
|
|
83
|
+
gongCall?: {
|
|
84
|
+
/** Gong call title. */
|
|
85
|
+
title: string
|
|
86
|
+
/** ISO datetime of the call start. */
|
|
87
|
+
startTime?: string | null
|
|
88
|
+
/** Call length in seconds. Formatted to a human duration when present. */
|
|
89
|
+
durationSeconds?: number | null
|
|
90
|
+
/** Call direction, e.g. "Outbound" / "Inbound". */
|
|
91
|
+
direction?: string | null
|
|
92
|
+
/** Call outcome, e.g. "Connected". */
|
|
93
|
+
outcome?: string | null
|
|
94
|
+
/** Gong AI call brief (plain text, may be multi-paragraph). */
|
|
95
|
+
brief?: string | null
|
|
96
|
+
/** Gong AI key points (plain text, often newline-delimited bullets). */
|
|
97
|
+
keyPoints?: string | null
|
|
98
|
+
/** Gong AI highlights / next steps (plain text). */
|
|
99
|
+
nextSteps?: string | null
|
|
100
|
+
/** Deep link to the call in Gong. */
|
|
101
|
+
url?: string | null
|
|
57
102
|
}
|
|
58
103
|
content?: React.ReactNode
|
|
59
104
|
source?: {
|
|
@@ -163,7 +208,7 @@ const TIMELINE_VARIANT_CLASSES: Record<TimelineActivityVariant, TimelineVariantC
|
|
|
163
208
|
title: "min-w-0 pr-1 text-[13.5px] font-medium leading-tight text-foreground",
|
|
164
209
|
time: "shrink-0 whitespace-nowrap pt-px text-[11px] leading-tight text-muted-foreground/60",
|
|
165
210
|
cardContainer: "overflow-hidden rounded-lg border border-border/70 bg-card",
|
|
166
|
-
cardHeader: "flex items-
|
|
211
|
+
cardHeader: "flex items-start justify-between border-b border-border/60 bg-muted/30 px-3 py-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70",
|
|
167
212
|
cardBody: "px-3 py-2.5 text-[13px] leading-relaxed",
|
|
168
213
|
cardFooter: "border-t border-border/60 bg-muted/10 px-3 py-1.5",
|
|
169
214
|
collapsedPreview: "flex items-center justify-between gap-2 px-3 py-2 text-[13px] text-muted-foreground",
|
|
@@ -245,6 +290,7 @@ function TimelineItem({
|
|
|
245
290
|
const [showAllRecipients, setShowAllRecipients] = React.useState(false)
|
|
246
291
|
const hasContent = !!event.content
|
|
247
292
|
const hasEmail = !!event.email
|
|
293
|
+
const hasGongCall = !!event.gongCall
|
|
248
294
|
const classes = TIMELINE_VARIANT_CLASSES[variant]
|
|
249
295
|
|
|
250
296
|
const toneStyle = event.tone ? TONE_CLASSES[event.tone] : null
|
|
@@ -275,10 +321,18 @@ function TimelineItem({
|
|
|
275
321
|
|
|
276
322
|
{event.actor && <ActorByline actor={event.actor} time={event.time} />}
|
|
277
323
|
|
|
278
|
-
{(hasContent || hasEmail) && (
|
|
324
|
+
{(hasContent || hasEmail || hasGongCall) && (
|
|
279
325
|
<div className="mt-2">
|
|
280
326
|
{event.isInteractive ? (
|
|
281
|
-
|
|
327
|
+
hasGongCall ? (
|
|
328
|
+
<GongCallCard
|
|
329
|
+
event={event}
|
|
330
|
+
expanded={expanded}
|
|
331
|
+
setExpanded={setExpanded}
|
|
332
|
+
variant={variant}
|
|
333
|
+
classes={classes}
|
|
334
|
+
/>
|
|
335
|
+
) : hasEmail ? (
|
|
282
336
|
<EmailCard
|
|
283
337
|
event={event}
|
|
284
338
|
expanded={expanded}
|
|
@@ -310,6 +364,7 @@ function TimelineItem({
|
|
|
310
364
|
}
|
|
311
365
|
|
|
312
366
|
type TimelineEmail = NonNullable<TimelineEvent["email"]>
|
|
367
|
+
type TimelineGongCall = NonNullable<TimelineEvent["gongCall"]>
|
|
313
368
|
type TimelineSource = NonNullable<TimelineEvent["source"]>
|
|
314
369
|
|
|
315
370
|
function reactNodeToDisplayText(value: React.ReactNode): string {
|
|
@@ -317,6 +372,41 @@ function reactNodeToDisplayText(value: React.ReactNode): string {
|
|
|
317
372
|
return ""
|
|
318
373
|
}
|
|
319
374
|
|
|
375
|
+
/**
|
|
376
|
+
* Formats a call length in seconds to a compact human duration:
|
|
377
|
+
* "38 min", ">= 60 min" → "1 h 12 min". Returns "" for absent/invalid
|
|
378
|
+
* input so callers can omit the chip entirely (never render "0 min").
|
|
379
|
+
*/
|
|
380
|
+
function formatCallDuration(durationSeconds?: number | null): string {
|
|
381
|
+
if (durationSeconds == null || !Number.isFinite(durationSeconds) || durationSeconds <= 0) return ""
|
|
382
|
+
const totalMinutes = Math.round(durationSeconds / 60)
|
|
383
|
+
if (totalMinutes < 1) return "< 1 min"
|
|
384
|
+
if (totalMinutes < 60) return `${totalMinutes} min`
|
|
385
|
+
const hours = Math.floor(totalMinutes / 60)
|
|
386
|
+
const minutes = totalMinutes % 60
|
|
387
|
+
return minutes === 0 ? `${hours} h` : `${hours} h ${minutes} min`
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function cleanGongText(value?: string | null): string {
|
|
391
|
+
if (!value) return ""
|
|
392
|
+
return decodeEmailDisplayText(value).trim()
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function getTimelineGongCallDisplay(gongCall: TimelineGongCall) {
|
|
396
|
+
const title = cleanGongText(gongCall.title)
|
|
397
|
+
const startTime = formatEmailTimestamp(gongCall.startTime) ?? ""
|
|
398
|
+
const duration = formatCallDuration(gongCall.durationSeconds)
|
|
399
|
+
const direction = cleanGongText(gongCall.direction)
|
|
400
|
+
const outcome = cleanGongText(gongCall.outcome)
|
|
401
|
+
const brief = cleanGongText(gongCall.brief)
|
|
402
|
+
const keyPoints = cleanGongText(gongCall.keyPoints)
|
|
403
|
+
const nextSteps = cleanGongText(gongCall.nextSteps)
|
|
404
|
+
const url = gongCall.url?.trim() || ""
|
|
405
|
+
const snippet = brief.split("\n").find((line) => line.trim())?.trim() ?? ""
|
|
406
|
+
|
|
407
|
+
return { title, startTime, duration, direction, outcome, brief, keyPoints, nextSteps, url, snippet }
|
|
408
|
+
}
|
|
409
|
+
|
|
320
410
|
function getTimelineEmailDisplay(email: TimelineEmail) {
|
|
321
411
|
const sender = normalizeEmailSender({ name: email.from, email: email.fromEmail })
|
|
322
412
|
const to = formatAddressList(email.to) || decodeEmailDisplayText(email.to ?? "")
|
|
@@ -326,8 +416,9 @@ function getTimelineEmailDisplay(email: TimelineEmail) {
|
|
|
326
416
|
const subject = email.subject ? decodeEmailDisplayText(email.subject) : ""
|
|
327
417
|
const bodyText = reactNodeToDisplayText(email.body)
|
|
328
418
|
const snippet = emailBodySnippet({ bodyHtml: email.bodyHtml, body: bodyText }, 140)
|
|
419
|
+
const hasRecipients = Boolean(to || cc || bcc)
|
|
329
420
|
|
|
330
|
-
return { sender, to, cc, bcc, date, subject, bodyText, snippet }
|
|
421
|
+
return { sender, to, cc, bcc, date, subject, bodyText, snippet, hasRecipients }
|
|
331
422
|
}
|
|
332
423
|
|
|
333
424
|
function EmailMetadata({
|
|
@@ -340,9 +431,13 @@ function EmailMetadata({
|
|
|
340
431
|
setShowAllRecipients: React.Dispatch<React.SetStateAction<boolean>>
|
|
341
432
|
}) {
|
|
342
433
|
const display = getTimelineEmailDisplay(email)
|
|
434
|
+
const hasExpandableRecipients = Boolean(display.cc || display.bcc)
|
|
343
435
|
|
|
344
436
|
return (
|
|
345
437
|
<>
|
|
438
|
+
{/* Row 1: sender name + email, with the date right-aligned. The `gap-4`
|
|
439
|
+
guarantees whitespace between the sender block and the date so they
|
|
440
|
+
never visually jam together. */}
|
|
346
441
|
<div className="flex items-center justify-between gap-4">
|
|
347
442
|
<div className="flex min-w-0 items-baseline gap-1.5">
|
|
348
443
|
<span className="font-semibold text-foreground text-[13px] whitespace-nowrap">{display.sender.name}</span>
|
|
@@ -354,36 +449,94 @@ function EmailMetadata({
|
|
|
354
449
|
<span className="shrink-0 text-xs text-muted-foreground/50 whitespace-nowrap">{display.date}</span>
|
|
355
450
|
) : null}
|
|
356
451
|
</div>
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
452
|
+
{/* Recipient lines live on their own full-width, stacked rows below the
|
|
453
|
+
sender/date row. Each line truncates independently. The To-segment is
|
|
454
|
+
suppressed entirely when there is no recipient data (history emails)
|
|
455
|
+
rather than rendering a meaningless "no recipient yet" placeholder. */}
|
|
456
|
+
{display.hasRecipients ? (
|
|
457
|
+
<div className="mt-0.5 text-xs text-muted-foreground">
|
|
458
|
+
<div className="flex items-center gap-1">
|
|
459
|
+
<span className="min-w-0 flex-1 truncate">
|
|
460
|
+
{display.to ? (
|
|
461
|
+
<>To {display.to}</>
|
|
462
|
+
) : (
|
|
463
|
+
<>
|
|
464
|
+
<span className="text-muted-foreground/40">cc</span> {display.cc || display.bcc}
|
|
465
|
+
</>
|
|
466
|
+
)}
|
|
467
|
+
{display.to && !showAllRecipients && hasExpandableRecipients ? (
|
|
468
|
+
<>, …</>
|
|
469
|
+
) : null}
|
|
470
|
+
</span>
|
|
471
|
+
{hasExpandableRecipients ? (
|
|
472
|
+
<button
|
|
473
|
+
type="button"
|
|
474
|
+
onClick={(e) => {
|
|
475
|
+
e.stopPropagation()
|
|
476
|
+
setShowAllRecipients((prev) => !prev)
|
|
477
|
+
}}
|
|
478
|
+
className="shrink-0 text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
|
479
|
+
aria-label={showAllRecipients ? "Hide cc and bcc" : "Show cc and bcc"}
|
|
480
|
+
>
|
|
481
|
+
<ChevronDown className={cn("h-3 w-3 transition-transform", showAllRecipients && "rotate-180")} />
|
|
482
|
+
</button>
|
|
483
|
+
) : null}
|
|
484
|
+
</div>
|
|
485
|
+
{showAllRecipients && display.to && display.cc ? (
|
|
486
|
+
<div className="truncate">
|
|
487
|
+
<span className="text-muted-foreground/40">cc</span> {display.cc}
|
|
488
|
+
</div>
|
|
365
489
|
) : null}
|
|
366
490
|
{showAllRecipients && display.bcc ? (
|
|
367
|
-
|
|
491
|
+
<div className="truncate">
|
|
492
|
+
<span className="text-muted-foreground/40">bcc</span> {display.bcc}
|
|
493
|
+
</div>
|
|
368
494
|
) : null}
|
|
369
|
-
</
|
|
370
|
-
|
|
371
|
-
<button
|
|
372
|
-
type="button"
|
|
373
|
-
onClick={(e) => {
|
|
374
|
-
e.stopPropagation()
|
|
375
|
-
setShowAllRecipients((prev) => !prev)
|
|
376
|
-
}}
|
|
377
|
-
className="shrink-0 text-muted-foreground/40 hover:text-muted-foreground transition-colors"
|
|
378
|
-
>
|
|
379
|
-
<ChevronDown className={cn("h-3 w-3 transition-transform", showAllRecipients && "rotate-180")} />
|
|
380
|
-
</button>
|
|
381
|
-
) : null}
|
|
382
|
-
</div>
|
|
495
|
+
</div>
|
|
496
|
+
) : null}
|
|
383
497
|
</>
|
|
384
498
|
)
|
|
385
499
|
}
|
|
386
500
|
|
|
501
|
+
function SuppressedHtmlSection({
|
|
502
|
+
html,
|
|
503
|
+
showLabel,
|
|
504
|
+
hideLabel,
|
|
505
|
+
slot,
|
|
506
|
+
}: {
|
|
507
|
+
html: string
|
|
508
|
+
showLabel: string
|
|
509
|
+
hideLabel: string
|
|
510
|
+
slot: string
|
|
511
|
+
}) {
|
|
512
|
+
const [open, setOpen] = React.useState(false)
|
|
513
|
+
|
|
514
|
+
return (
|
|
515
|
+
<div className="mt-2" data-slot={slot}>
|
|
516
|
+
<button
|
|
517
|
+
type="button"
|
|
518
|
+
onClick={(e) => {
|
|
519
|
+
e.stopPropagation()
|
|
520
|
+
setOpen((value) => !value)
|
|
521
|
+
}}
|
|
522
|
+
aria-expanded={open}
|
|
523
|
+
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[11px] text-muted-foreground/70 transition-colors hover:bg-muted hover:text-foreground"
|
|
524
|
+
>
|
|
525
|
+
{open ? hideLabel : showLabel}
|
|
526
|
+
<ChevronDown className={cn("h-3 w-3 transition-transform", open && "rotate-180")} />
|
|
527
|
+
</button>
|
|
528
|
+
{open ? (
|
|
529
|
+
<div
|
|
530
|
+
data-slot={`${slot}-content`}
|
|
531
|
+
className="mt-2 border-l-2 border-border pl-3 text-sm leading-relaxed text-muted-foreground"
|
|
532
|
+
>
|
|
533
|
+
<SharedEmailBody html={html} variant="history" collapseDetails={false} />
|
|
534
|
+
</div>
|
|
535
|
+
) : null}
|
|
536
|
+
</div>
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
|
|
387
540
|
function TimelineEmailBody({ email }: { email: TimelineEmail }) {
|
|
388
541
|
const display = getTimelineEmailDisplay(email)
|
|
389
542
|
const bodyFallback = display.bodyText || (typeof email.body === "string" ? email.body : "")
|
|
@@ -402,7 +555,35 @@ function TimelineEmailBody({ email }: { email: TimelineEmail }) {
|
|
|
402
555
|
/>
|
|
403
556
|
)
|
|
404
557
|
|
|
405
|
-
|
|
558
|
+
const hasSignature = Boolean(email.signatureHtml && email.signatureHtml.trim())
|
|
559
|
+
const hasQuoted = Boolean(email.quotedHtml && email.quotedHtml.trim())
|
|
560
|
+
|
|
561
|
+
// Fast path: no signature/quoted slots → identical output to before.
|
|
562
|
+
if (!hasSignature && !hasQuoted) {
|
|
563
|
+
return email.bodyHtml ? <div data-slot="timeline-email-html">{body}</div> : body
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return (
|
|
567
|
+
<div data-slot={email.bodyHtml ? "timeline-email-html" : undefined}>
|
|
568
|
+
{body}
|
|
569
|
+
{hasSignature ? (
|
|
570
|
+
<SuppressedHtmlSection
|
|
571
|
+
html={email.signatureHtml as string}
|
|
572
|
+
showLabel="Show signature"
|
|
573
|
+
hideLabel="Hide signature"
|
|
574
|
+
slot="timeline-email-signature"
|
|
575
|
+
/>
|
|
576
|
+
) : null}
|
|
577
|
+
{hasQuoted ? (
|
|
578
|
+
<SuppressedHtmlSection
|
|
579
|
+
html={email.quotedHtml as string}
|
|
580
|
+
showLabel="Show quoted text"
|
|
581
|
+
hideLabel="Hide quoted text"
|
|
582
|
+
slot="timeline-email-quoted"
|
|
583
|
+
/>
|
|
584
|
+
) : null}
|
|
585
|
+
</div>
|
|
586
|
+
)
|
|
406
587
|
}
|
|
407
588
|
|
|
408
589
|
function renderDecodedPreview(preview?: React.ReactNode): React.ReactNode {
|
|
@@ -428,7 +609,7 @@ function CollapsedEmailPreview({
|
|
|
428
609
|
|
|
429
610
|
return (
|
|
430
611
|
<div className={className} onClick={onClick}>
|
|
431
|
-
<span className="line-clamp-1 pr-3 text-[13px]">
|
|
612
|
+
<span className="line-clamp-1 min-w-0 flex-1 pr-3 text-[13px]">
|
|
432
613
|
<span className="text-muted-foreground">{display?.sender.name}</span>
|
|
433
614
|
<span className="mx-1.5 text-muted-foreground/40">·</span>
|
|
434
615
|
{display?.subject ? (
|
|
@@ -462,6 +643,34 @@ function ShowLessButton({
|
|
|
462
643
|
)
|
|
463
644
|
}
|
|
464
645
|
|
|
646
|
+
/**
|
|
647
|
+
* Top-of-card collapse affordance. Long expanded emails are tedious to collapse
|
|
648
|
+
* from the bottom alone, so the header row also carries a "Show less" control.
|
|
649
|
+
*/
|
|
650
|
+
function CollapseTopButton({
|
|
651
|
+
onClick,
|
|
652
|
+
className,
|
|
653
|
+
}: {
|
|
654
|
+
onClick: React.MouseEventHandler<HTMLButtonElement>
|
|
655
|
+
className?: string
|
|
656
|
+
}) {
|
|
657
|
+
return (
|
|
658
|
+
<button
|
|
659
|
+
type="button"
|
|
660
|
+
onClick={onClick}
|
|
661
|
+
aria-label="Show less"
|
|
662
|
+
data-slot="timeline-collapse-top"
|
|
663
|
+
className={cn(
|
|
664
|
+
"shrink-0 inline-flex items-center gap-1 text-[11px] font-medium text-muted-foreground/60 transition-colors hover:text-foreground",
|
|
665
|
+
className,
|
|
666
|
+
)}
|
|
667
|
+
>
|
|
668
|
+
<span className="sr-only sm:not-sr-only">Show less</span>
|
|
669
|
+
<ChevronUp className="h-3 w-3" />
|
|
670
|
+
</button>
|
|
671
|
+
)
|
|
672
|
+
}
|
|
673
|
+
|
|
465
674
|
function SourceAction({
|
|
466
675
|
source,
|
|
467
676
|
onSourceClick,
|
|
@@ -499,6 +708,256 @@ function SourceAction({
|
|
|
499
708
|
)
|
|
500
709
|
}
|
|
501
710
|
|
|
711
|
+
function GongLinkAction({
|
|
712
|
+
url,
|
|
713
|
+
className,
|
|
714
|
+
}: {
|
|
715
|
+
url: string
|
|
716
|
+
className: string
|
|
717
|
+
}) {
|
|
718
|
+
return (
|
|
719
|
+
<a
|
|
720
|
+
href={url}
|
|
721
|
+
target="_blank"
|
|
722
|
+
rel="noopener noreferrer"
|
|
723
|
+
onClick={(e) => e.stopPropagation()}
|
|
724
|
+
className={className}
|
|
725
|
+
data-slot="timeline-gong-link"
|
|
726
|
+
>
|
|
727
|
+
View in Gong
|
|
728
|
+
<ExternalLink className="h-3 w-3" />
|
|
729
|
+
</a>
|
|
730
|
+
)
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* The expanded Gong call card body — call title + start time header, optional
|
|
735
|
+
* direction/outcome chips, the AI brief as primary body text, and labeled
|
|
736
|
+
* Key points / Next steps subsections. Every section renders only when its
|
|
737
|
+
* content is present.
|
|
738
|
+
*/
|
|
739
|
+
function TimelineGongCallBody({
|
|
740
|
+
display,
|
|
741
|
+
labelClassName,
|
|
742
|
+
}: {
|
|
743
|
+
display: ReturnType<typeof getTimelineGongCallDisplay>
|
|
744
|
+
labelClassName: string
|
|
745
|
+
}) {
|
|
746
|
+
return (
|
|
747
|
+
<div className="space-y-3" data-slot="timeline-gong-body">
|
|
748
|
+
{display.brief ? (
|
|
749
|
+
<div className="whitespace-pre-line text-sm leading-relaxed text-foreground/90">
|
|
750
|
+
{display.brief}
|
|
751
|
+
</div>
|
|
752
|
+
) : null}
|
|
753
|
+
{display.keyPoints ? (
|
|
754
|
+
<div data-slot="timeline-gong-key-points">
|
|
755
|
+
<div className={labelClassName}>Key points</div>
|
|
756
|
+
<div className="mt-1 whitespace-pre-line text-sm leading-relaxed text-foreground/90">
|
|
757
|
+
{display.keyPoints}
|
|
758
|
+
</div>
|
|
759
|
+
</div>
|
|
760
|
+
) : null}
|
|
761
|
+
{display.nextSteps ? (
|
|
762
|
+
<div data-slot="timeline-gong-next-steps">
|
|
763
|
+
<div className={labelClassName}>Next steps</div>
|
|
764
|
+
<div className="mt-1 whitespace-pre-line text-sm leading-relaxed text-foreground/90">
|
|
765
|
+
{display.nextSteps}
|
|
766
|
+
</div>
|
|
767
|
+
</div>
|
|
768
|
+
) : null}
|
|
769
|
+
</div>
|
|
770
|
+
)
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
function GongCallHeader({
|
|
774
|
+
display,
|
|
775
|
+
}: {
|
|
776
|
+
display: ReturnType<typeof getTimelineGongCallDisplay>
|
|
777
|
+
}) {
|
|
778
|
+
return (
|
|
779
|
+
<>
|
|
780
|
+
<div className="flex items-start justify-between gap-4">
|
|
781
|
+
<div className="flex min-w-0 items-center gap-1.5">
|
|
782
|
+
<Phone className="h-3.5 w-3.5 shrink-0 text-muted-foreground/60" />
|
|
783
|
+
<span className="min-w-0 truncate font-semibold text-foreground text-[13px]">{display.title}</span>
|
|
784
|
+
</div>
|
|
785
|
+
{display.startTime ? (
|
|
786
|
+
<span className="shrink-0 text-xs text-muted-foreground/50 whitespace-nowrap">{display.startTime}</span>
|
|
787
|
+
) : null}
|
|
788
|
+
</div>
|
|
789
|
+
{display.duration || display.direction || display.outcome ? (
|
|
790
|
+
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
|
|
791
|
+
{display.duration ? <span data-slot="timeline-gong-duration">{display.duration}</span> : null}
|
|
792
|
+
{display.direction ? (
|
|
793
|
+
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">{display.direction}</span>
|
|
794
|
+
) : null}
|
|
795
|
+
{display.outcome ? (
|
|
796
|
+
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">{display.outcome}</span>
|
|
797
|
+
) : null}
|
|
798
|
+
</div>
|
|
799
|
+
) : null}
|
|
800
|
+
</>
|
|
801
|
+
)
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function CollapsedGongCallPreview({
|
|
805
|
+
display,
|
|
806
|
+
className,
|
|
807
|
+
actionClassName,
|
|
808
|
+
onClick,
|
|
809
|
+
}: {
|
|
810
|
+
display: ReturnType<typeof getTimelineGongCallDisplay>
|
|
811
|
+
className: string
|
|
812
|
+
actionClassName: string
|
|
813
|
+
onClick?: () => void
|
|
814
|
+
}) {
|
|
815
|
+
return (
|
|
816
|
+
<div className={className} onClick={onClick}>
|
|
817
|
+
<span className="line-clamp-1 min-w-0 flex-1 pr-3 text-[13px] text-muted-foreground">
|
|
818
|
+
{display.duration ? (
|
|
819
|
+
<>
|
|
820
|
+
<span className="inline-flex items-center gap-1">
|
|
821
|
+
<Phone className="h-3 w-3" />
|
|
822
|
+
{display.duration}
|
|
823
|
+
</span>
|
|
824
|
+
{display.snippet ? <span className="mx-1.5 text-muted-foreground/40">·</span> : null}
|
|
825
|
+
</>
|
|
826
|
+
) : null}
|
|
827
|
+
{display.snippet ? <span>{display.snippet}</span> : null}
|
|
828
|
+
</span>
|
|
829
|
+
<button type="button" className={actionClassName}>
|
|
830
|
+
Expand <ChevronDown className="h-3 w-3" />
|
|
831
|
+
</button>
|
|
832
|
+
</div>
|
|
833
|
+
)
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function GongCallCard({
|
|
837
|
+
event,
|
|
838
|
+
expanded,
|
|
839
|
+
setExpanded,
|
|
840
|
+
variant,
|
|
841
|
+
classes,
|
|
842
|
+
}: {
|
|
843
|
+
event: TimelineEvent
|
|
844
|
+
expanded: boolean
|
|
845
|
+
setExpanded: React.Dispatch<React.SetStateAction<boolean>>
|
|
846
|
+
variant: TimelineActivityVariant
|
|
847
|
+
classes: TimelineVariantClasses
|
|
848
|
+
}) {
|
|
849
|
+
const gongCall = event.gongCall as TimelineGongCall
|
|
850
|
+
const display = getTimelineGongCallDisplay(gongCall)
|
|
851
|
+
|
|
852
|
+
if (variant === "default") {
|
|
853
|
+
return (
|
|
854
|
+
<div className={classes.cardContainer} data-variant={variant} data-slot="timeline-gong-card">
|
|
855
|
+
<div
|
|
856
|
+
className={cn(
|
|
857
|
+
"px-3 py-2.5 text-sm",
|
|
858
|
+
!expanded && "cursor-pointer hover:bg-muted/30 transition-colors",
|
|
859
|
+
)}
|
|
860
|
+
onClick={() => !expanded && setExpanded(true)}
|
|
861
|
+
>
|
|
862
|
+
{expanded ? (
|
|
863
|
+
<div className="space-y-3">
|
|
864
|
+
<div className="flex items-start justify-between gap-3">
|
|
865
|
+
<div className="min-w-0 flex-1">
|
|
866
|
+
<GongCallHeader display={display} />
|
|
867
|
+
</div>
|
|
868
|
+
<CollapseTopButton
|
|
869
|
+
onClick={(e) => {
|
|
870
|
+
e.stopPropagation()
|
|
871
|
+
setExpanded(false)
|
|
872
|
+
}}
|
|
873
|
+
className="mt-0.5"
|
|
874
|
+
/>
|
|
875
|
+
</div>
|
|
876
|
+
|
|
877
|
+
<TimelineGongCallBody
|
|
878
|
+
display={display}
|
|
879
|
+
labelClassName="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70"
|
|
880
|
+
/>
|
|
881
|
+
|
|
882
|
+
<div className="mt-2 flex items-center gap-3">
|
|
883
|
+
{display.url ? (
|
|
884
|
+
<GongLinkAction
|
|
885
|
+
url={display.url}
|
|
886
|
+
className="mr-auto inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
|
|
887
|
+
/>
|
|
888
|
+
) : null}
|
|
889
|
+
<ShowLessButton
|
|
890
|
+
onClick={(e) => {
|
|
891
|
+
e.stopPropagation()
|
|
892
|
+
setExpanded(false)
|
|
893
|
+
}}
|
|
894
|
+
className="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground transition-colors hover:text-foreground"
|
|
895
|
+
/>
|
|
896
|
+
</div>
|
|
897
|
+
</div>
|
|
898
|
+
) : (
|
|
899
|
+
<CollapsedGongCallPreview
|
|
900
|
+
display={display}
|
|
901
|
+
className="flex items-center justify-between gap-2 text-muted-foreground"
|
|
902
|
+
actionClassName="flex shrink-0 items-center gap-1 text-[11px] font-semibold uppercase tracking-wider transition-colors hover:text-foreground"
|
|
903
|
+
/>
|
|
904
|
+
)}
|
|
905
|
+
</div>
|
|
906
|
+
</div>
|
|
907
|
+
)
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
return (
|
|
911
|
+
<div className={classes.cardContainer} data-variant={variant} data-slot="timeline-gong-card">
|
|
912
|
+
{expanded ? (
|
|
913
|
+
<>
|
|
914
|
+
<div className={classes.cardHeader} data-slot="timeline-card-header">
|
|
915
|
+
<div className="min-w-0 flex-1 normal-case tracking-normal">
|
|
916
|
+
<GongCallHeader display={display} />
|
|
917
|
+
</div>
|
|
918
|
+
<CollapseTopButton
|
|
919
|
+
onClick={(e) => {
|
|
920
|
+
e.stopPropagation()
|
|
921
|
+
setExpanded(false)
|
|
922
|
+
}}
|
|
923
|
+
className="ml-3 normal-case tracking-normal"
|
|
924
|
+
/>
|
|
925
|
+
</div>
|
|
926
|
+
|
|
927
|
+
<div className={classes.cardBody} data-slot="timeline-card-body">
|
|
928
|
+
<TimelineGongCallBody
|
|
929
|
+
display={display}
|
|
930
|
+
labelClassName="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70"
|
|
931
|
+
/>
|
|
932
|
+
</div>
|
|
933
|
+
|
|
934
|
+
<div
|
|
935
|
+
className={cn(classes.cardFooter, classes.actionLinkRow, display.url ? "justify-between" : "justify-end")}
|
|
936
|
+
data-slot="timeline-card-footer"
|
|
937
|
+
>
|
|
938
|
+
{display.url ? <GongLinkAction url={display.url} className={classes.actionLink} /> : null}
|
|
939
|
+
<ShowLessButton
|
|
940
|
+
type="button"
|
|
941
|
+
onClick={(e) => {
|
|
942
|
+
e.stopPropagation()
|
|
943
|
+
setExpanded(false)
|
|
944
|
+
}}
|
|
945
|
+
className={classes.actionLink}
|
|
946
|
+
/>
|
|
947
|
+
</div>
|
|
948
|
+
</>
|
|
949
|
+
) : (
|
|
950
|
+
<CollapsedGongCallPreview
|
|
951
|
+
display={display}
|
|
952
|
+
className={cn(classes.collapsedPreview, "cursor-pointer hover:bg-muted/30 transition-colors")}
|
|
953
|
+
actionClassName={cn(classes.actionLink, "shrink-0")}
|
|
954
|
+
onClick={() => setExpanded(true)}
|
|
955
|
+
/>
|
|
956
|
+
)}
|
|
957
|
+
</div>
|
|
958
|
+
)
|
|
959
|
+
}
|
|
960
|
+
|
|
502
961
|
function EmailCard({
|
|
503
962
|
event,
|
|
504
963
|
expanded,
|
|
@@ -528,11 +987,20 @@ function EmailCard({
|
|
|
528
987
|
>
|
|
529
988
|
{expanded && event.email ? (
|
|
530
989
|
<div className="space-y-3">
|
|
531
|
-
<div>
|
|
532
|
-
<
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
990
|
+
<div className="flex items-start justify-between gap-3">
|
|
991
|
+
<div className="min-w-0 flex-1">
|
|
992
|
+
<EmailMetadata
|
|
993
|
+
email={event.email}
|
|
994
|
+
showAllRecipients={showAllRecipients}
|
|
995
|
+
setShowAllRecipients={setShowAllRecipients}
|
|
996
|
+
/>
|
|
997
|
+
</div>
|
|
998
|
+
<CollapseTopButton
|
|
999
|
+
onClick={(e) => {
|
|
1000
|
+
e.stopPropagation()
|
|
1001
|
+
setExpanded(false)
|
|
1002
|
+
}}
|
|
1003
|
+
className="mt-0.5"
|
|
536
1004
|
/>
|
|
537
1005
|
</div>
|
|
538
1006
|
|
|
@@ -579,10 +1047,19 @@ function EmailCard({
|
|
|
579
1047
|
{expanded && event.email ? (
|
|
580
1048
|
<>
|
|
581
1049
|
<div className={classes.cardHeader} data-slot="timeline-card-header">
|
|
582
|
-
<
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
1050
|
+
<div className="min-w-0 flex-1 normal-case tracking-normal">
|
|
1051
|
+
<EmailMetadata
|
|
1052
|
+
email={event.email}
|
|
1053
|
+
showAllRecipients={showAllRecipients}
|
|
1054
|
+
setShowAllRecipients={setShowAllRecipients}
|
|
1055
|
+
/>
|
|
1056
|
+
</div>
|
|
1057
|
+
<CollapseTopButton
|
|
1058
|
+
onClick={(e) => {
|
|
1059
|
+
e.stopPropagation()
|
|
1060
|
+
setExpanded(false)
|
|
1061
|
+
}}
|
|
1062
|
+
className="ml-3 normal-case tracking-normal"
|
|
586
1063
|
/>
|
|
587
1064
|
</div>
|
|
588
1065
|
|