@proteos/ui 0.49.0 → 0.51.0
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/index.cjs +659 -304
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +230 -8
- package/dist/index.d.ts +230 -8
- package/dist/index.js +651 -307
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/badge/badge.stories.tsx +25 -0
- package/src/components/badge/badge.test.tsx +22 -0
- package/src/components/badge/badge.tsx +41 -10
- package/src/components/badge-multi-select/badge-multi-select.tsx +16 -11
- package/src/components/conversation-brief-card/conversation-brief-card.stories.tsx +81 -0
- package/src/components/conversation-brief-card/conversation-brief-card.tsx +102 -0
- package/src/components/conversation-brief-card/conversation-brief-preview.tsx +156 -0
- package/src/components/conversation-brief-card/conversation-brief-review.test.tsx +214 -0
- package/src/components/conversation-brief-card/conversation-brief-review.tsx +192 -0
- package/src/components/conversation-brief-card/create-conversation-brief-client.ts +18 -0
- package/src/components/conversation-brief-card/index.ts +24 -0
- package/src/components/conversation-brief-card/types.ts +50 -0
- package/src/components/data-table/data-table.test.tsx +62 -0
- package/src/components/data-table/data-table.tsx +75 -7
- package/src/components/record-filter/chip-value.tsx +2 -0
- package/src/index.ts +1 -0
- package/src/styles.css +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@proteos/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.0",
|
|
4
4
|
"license": "FSL-1.1-Apache-2.0",
|
|
5
5
|
"description": "Proteos React component library (React Aria Components + Tailwind v4).",
|
|
6
6
|
"repository": {
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
"tailwind-variants": "^3.2.2",
|
|
57
57
|
"tailwindcss": "^4.2.4",
|
|
58
58
|
"thinking-orbs": "^0.3.1",
|
|
59
|
-
"@proteos/sdk": "0.
|
|
59
|
+
"@proteos/sdk": "0.51.0"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
62
62
|
"@biomejs/biome": "^2.4.13",
|
|
@@ -15,6 +15,8 @@ const meta: Meta<typeof Badge> = {
|
|
|
15
15
|
control: { type: 'inline-radio' },
|
|
16
16
|
options: ['default', 'neutral', 'active', 'success', 'warning', 'danger', 'custom'],
|
|
17
17
|
},
|
|
18
|
+
shape: { control: { type: 'inline-radio' }, options: ['pill', 'rounded'] },
|
|
19
|
+
hasDot: { control: 'boolean' },
|
|
18
20
|
},
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -63,6 +65,29 @@ export const WithColorAndIcon: Story = {
|
|
|
63
65
|
),
|
|
64
66
|
}
|
|
65
67
|
|
|
68
|
+
/** The enum-value chip: `rounded` shape, no dot. A value with an icon shows
|
|
69
|
+
* it; one without reads as plain text on its tint. This is exactly what
|
|
70
|
+
* the page-layout enum controls, table cells and filter chips render. */
|
|
71
|
+
export const EnumChip: Story = {
|
|
72
|
+
parameters: { controls: { hideNoControlsWarning: true } },
|
|
73
|
+
render: () => (
|
|
74
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
75
|
+
<Badge shape="rounded" hasDot={false} variant="active">
|
|
76
|
+
to do
|
|
77
|
+
</Badge>
|
|
78
|
+
<Badge shape="rounded" hasDot={false} color="#5b8def">
|
|
79
|
+
in progress
|
|
80
|
+
</Badge>
|
|
81
|
+
<Badge shape="rounded" hasDot={false} icon="CircleCheck" color="#3f9d6b">
|
|
82
|
+
done
|
|
83
|
+
</Badge>
|
|
84
|
+
<Badge shape="rounded" hasDot={false} icon="Flame" color="#d94f4f">
|
|
85
|
+
urgent
|
|
86
|
+
</Badge>
|
|
87
|
+
</div>
|
|
88
|
+
),
|
|
89
|
+
}
|
|
90
|
+
|
|
66
91
|
export const InContext: Story = {
|
|
67
92
|
parameters: { controls: { hideNoControlsWarning: true } },
|
|
68
93
|
render: () => (
|
|
@@ -53,6 +53,28 @@ describe('Badge', () => {
|
|
|
53
53
|
expect(badge.className).toContain('before:hidden')
|
|
54
54
|
})
|
|
55
55
|
|
|
56
|
+
it('defaults to the pill shape with a leading dot', () => {
|
|
57
|
+
render(<Badge>quoted</Badge>)
|
|
58
|
+
const badge = screen.getByText('quoted')
|
|
59
|
+
expect(badge.className).toContain('rounded-pill')
|
|
60
|
+
expect(badge.className).toContain('before:content')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('renders the rounded shape without a dot when asked', () => {
|
|
64
|
+
render(
|
|
65
|
+
<Badge shape="rounded" hasDot={false}>
|
|
66
|
+
to do
|
|
67
|
+
</Badge>,
|
|
68
|
+
)
|
|
69
|
+
const badge = screen.getByText('to do')
|
|
70
|
+
expect(badge.className).toContain('rounded-chip')
|
|
71
|
+
expect(badge.className).not.toContain('rounded-pill')
|
|
72
|
+
expect(badge.className).not.toContain('before:content')
|
|
73
|
+
// Register classes still emit `before:*`; the pseudo must be hidden so it
|
|
74
|
+
// does not occupy a flex gap in front of the text.
|
|
75
|
+
expect(badge.className).toContain('before:hidden')
|
|
76
|
+
})
|
|
77
|
+
|
|
56
78
|
it('keeps the dot when the icon name is unknown', () => {
|
|
57
79
|
render(<Badge icon="not-an-icon">done</Badge>)
|
|
58
80
|
const badge = screen.getByText('done')
|
|
@@ -5,24 +5,28 @@ import { cn } from '../../lib/cn'
|
|
|
5
5
|
import { resolveLucideIcon } from '../icon-picker/icon-picker'
|
|
6
6
|
|
|
7
7
|
/* =========================================================================
|
|
8
|
-
Badge — mono
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
Badge — mono status chip. Matches the Proteos spec's six registers:
|
|
9
|
+
default, neutral, active (indigo), success, warning, danger.
|
|
10
|
+
Paper-compatible fills — never pure-color.
|
|
11
|
+
|
|
12
|
+
Chrome knobs, orthogonal to the register:
|
|
13
|
+
- `shape` — `pill` (default, status badges) or `rounded` (9px corners,
|
|
14
|
+
the enum-value chip on record pages, tables, pickers and filters).
|
|
15
|
+
- `hasDot` — the leading dot. Default on; enum chips turn it off so a
|
|
16
|
+
value without an icon reads as plain text on tint.
|
|
11
17
|
|
|
12
18
|
Two data-driven escape hatches for schema-defined values (enum options,
|
|
13
19
|
labels): `color` — a #rrggbb tint that drives the dot, border and fill
|
|
14
20
|
via color-mix while the text stays ink so any user-picked color keeps
|
|
15
21
|
its contrast in light/dark/paper; and `icon` — a Lucide name that
|
|
16
|
-
replaces the dot.
|
|
22
|
+
replaces the dot (whatever `hasDot` says).
|
|
17
23
|
========================================================================= */
|
|
18
24
|
|
|
19
25
|
export const badgeStyles = tv({
|
|
20
26
|
base: [
|
|
21
27
|
'inline-flex items-center gap-1.5',
|
|
22
28
|
'font-mono text-[11.5px] tracking-[-0.005em]',
|
|
23
|
-
'px-2.5 py-0.5
|
|
24
|
-
// Leading dot
|
|
25
|
-
'before:content-[""] before:block before:size-1.5 before:rounded-full',
|
|
29
|
+
'px-2.5 py-0.5 border',
|
|
26
30
|
],
|
|
27
31
|
variants: {
|
|
28
32
|
variant: {
|
|
@@ -57,8 +61,17 @@ export const badgeStyles = tv({
|
|
|
57
61
|
'bg-[color-mix(in_oklab,var(--badge-color)_12%,var(--color-surface))]',
|
|
58
62
|
],
|
|
59
63
|
},
|
|
64
|
+
shape: {
|
|
65
|
+
pill: 'rounded-pill',
|
|
66
|
+
rounded: 'rounded-chip',
|
|
67
|
+
},
|
|
68
|
+
// Leading dot — its color comes from the register's `before:bg-*`.
|
|
69
|
+
hasDot: {
|
|
70
|
+
true: 'before:content-[""] before:block before:size-1.5 before:rounded-full',
|
|
71
|
+
false: '',
|
|
72
|
+
},
|
|
60
73
|
},
|
|
61
|
-
defaultVariants: { variant: 'default' },
|
|
74
|
+
defaultVariants: { variant: 'default', shape: 'pill', hasDot: true },
|
|
62
75
|
})
|
|
63
76
|
|
|
64
77
|
export type BadgeVariants = VariantProps<typeof badgeStyles>
|
|
@@ -81,15 +94,33 @@ const ICON_TONE: Record<NonNullable<BadgeVariants['variant']>, string> = {
|
|
|
81
94
|
custom: 'text-[var(--badge-color)]',
|
|
82
95
|
}
|
|
83
96
|
|
|
84
|
-
export function Badge({
|
|
97
|
+
export function Badge({
|
|
98
|
+
variant,
|
|
99
|
+
shape,
|
|
100
|
+
hasDot,
|
|
101
|
+
color,
|
|
102
|
+
icon,
|
|
103
|
+
className,
|
|
104
|
+
style,
|
|
105
|
+
children,
|
|
106
|
+
...props
|
|
107
|
+
}: BadgeProps) {
|
|
85
108
|
const Icon = icon ? resolveLucideIcon(icon) : null
|
|
86
109
|
const register: NonNullable<BadgeVariants['variant']> = color ? 'custom' : (variant ?? 'default')
|
|
87
110
|
const mergedStyle = color ? ({ ...style, '--badge-color': color } as CSSProperties) : style
|
|
111
|
+
// The register's `before:bg-*` classes alone materialize an empty ::before
|
|
112
|
+
// (Tailwind injects `content` for every `before:` utility), which as a flex
|
|
113
|
+
// child still eats one `gap`. Hide it whenever no dot should show.
|
|
114
|
+
const showDot = hasDot !== false && !Icon
|
|
88
115
|
return (
|
|
89
116
|
<span
|
|
90
117
|
{...props}
|
|
91
118
|
style={mergedStyle}
|
|
92
|
-
className={cn(
|
|
119
|
+
className={cn(
|
|
120
|
+
badgeStyles({ variant: register, shape, hasDot }),
|
|
121
|
+
showDot ? null : 'before:hidden',
|
|
122
|
+
className,
|
|
123
|
+
)}
|
|
93
124
|
>
|
|
94
125
|
{Icon ? <Icon aria-hidden className={cn('size-3 shrink-0', ICON_TONE[register])} /> : null}
|
|
95
126
|
{children}
|
|
@@ -29,6 +29,10 @@ export type BadgeMultiSelectOption<T extends Key = Key> = {
|
|
|
29
29
|
label: string
|
|
30
30
|
/** Badge color register. Defaults to `default`. */
|
|
31
31
|
variant?: BadgeVariants['variant']
|
|
32
|
+
/** Badge corner shape. Defaults to `pill`; enum chips use `rounded`. */
|
|
33
|
+
shape?: BadgeVariants['shape']
|
|
34
|
+
/** Leading dot. Defaults to on; enum chips turn it off. */
|
|
35
|
+
hasDot?: boolean
|
|
32
36
|
/** `#rrggbb` tint — overrides `variant` (see Badge). */
|
|
33
37
|
color?: string
|
|
34
38
|
/** Lucide icon name; replaces the badge dot. */
|
|
@@ -36,14 +40,20 @@ export type BadgeMultiSelectOption<T extends Key = Key> = {
|
|
|
36
40
|
isDisabled?: boolean
|
|
37
41
|
}
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
function badgeChrome(option: {
|
|
43
|
+
type BadgeChrome = {
|
|
41
44
|
variant?: BadgeVariants['variant'] | undefined
|
|
45
|
+
shape?: BadgeVariants['shape'] | undefined
|
|
46
|
+
hasDot?: boolean | undefined
|
|
42
47
|
color?: string | undefined
|
|
43
48
|
icon?: string | undefined
|
|
44
|
-
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The Badge chrome props an option carries — spread onto every Badge it renders as. */
|
|
52
|
+
function badgeChrome(option: BadgeChrome) {
|
|
45
53
|
return {
|
|
46
54
|
...(option.variant ? { variant: option.variant } : {}),
|
|
55
|
+
...(option.shape ? { shape: option.shape } : {}),
|
|
56
|
+
...(option.hasDot !== undefined ? { hasDot: option.hasDot } : {}),
|
|
47
57
|
...(option.color ? { color: option.color } : {}),
|
|
48
58
|
...(option.icon ? { icon: option.icon } : {}),
|
|
49
59
|
}
|
|
@@ -243,21 +253,16 @@ export function BadgeMultiSelect<T extends Key>({
|
|
|
243
253
|
|
|
244
254
|
function RemovableBadge({
|
|
245
255
|
children,
|
|
246
|
-
variant,
|
|
247
|
-
color,
|
|
248
|
-
icon,
|
|
249
256
|
onRemove,
|
|
250
257
|
isDisabled,
|
|
251
|
-
|
|
258
|
+
...chrome
|
|
259
|
+
}: BadgeChrome & {
|
|
252
260
|
children: ReactNode
|
|
253
|
-
variant?: BadgeVariants['variant']
|
|
254
|
-
color?: string
|
|
255
|
-
icon?: string
|
|
256
261
|
onRemove: () => void
|
|
257
262
|
isDisabled: boolean
|
|
258
263
|
}) {
|
|
259
264
|
return (
|
|
260
|
-
<Badge {...badgeChrome(
|
|
265
|
+
<Badge {...badgeChrome(chrome)}>
|
|
261
266
|
<span>{children}</span>
|
|
262
267
|
<button
|
|
263
268
|
type="button"
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import type { Contact, ConversationBrief } from '@proteos/sdk'
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
3
|
+
|
|
4
|
+
import { ConversationBriefCard } from './conversation-brief-card'
|
|
5
|
+
import { ConversationBriefPreview } from './conversation-brief-preview'
|
|
6
|
+
import { ConversationBriefReview } from './conversation-brief-review'
|
|
7
|
+
import type { ConversationBriefClient } from './types'
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof ConversationBriefCard> = {
|
|
10
|
+
title: 'Components/ConversationBriefCard',
|
|
11
|
+
component: ConversationBriefCard,
|
|
12
|
+
tags: ['autodocs'],
|
|
13
|
+
parameters: { layout: 'padded' },
|
|
14
|
+
}
|
|
15
|
+
export default meta
|
|
16
|
+
type Story = StoryObj<typeof ConversationBriefCard>
|
|
17
|
+
|
|
18
|
+
export const Prepared: Story = {
|
|
19
|
+
args: {
|
|
20
|
+
title: 'Northline renewal',
|
|
21
|
+
summary: 'Dana Ruiz · phone · renewal · Confirm the Q4 volumes before pricing.',
|
|
22
|
+
},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const CallHeld: Story = {
|
|
26
|
+
args: { ...Prepared.args, outcome: 'used' } as Story['args'],
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const Discarded: Story = {
|
|
30
|
+
args: { ...Prepared.args, outcome: 'discarded' } as Story['args'],
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const sampleBrief: ConversationBrief = {
|
|
34
|
+
id: 'brief-1',
|
|
35
|
+
org_id: 'org-1',
|
|
36
|
+
contact_ids: ['contact-1'],
|
|
37
|
+
subject: 'Northline renewal',
|
|
38
|
+
channel: 'phone',
|
|
39
|
+
type_key: 'renewal',
|
|
40
|
+
status: 'prepared',
|
|
41
|
+
content:
|
|
42
|
+
'Objective: renew the Northline contract for 12 months.\n\n- Confirm Q4 volumes before quoting.\n- They asked about the API rate limits last time — have the new tiers ready.\n- Avoid committing to the March migration date.',
|
|
43
|
+
created_at: '2026-09-09T10:00:00Z',
|
|
44
|
+
created_by: { type: 'agent', id: 'agent-1' },
|
|
45
|
+
updated_at: '2026-09-09T10:00:00Z',
|
|
46
|
+
updated_by: { type: 'agent', id: 'agent-1' },
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const sampleContact = {
|
|
50
|
+
id: 'contact-1',
|
|
51
|
+
name: 'Dana Ruiz',
|
|
52
|
+
addresses: [
|
|
53
|
+
{ id: 'a1', kind: 'phone', value: '+4930123456' },
|
|
54
|
+
{ id: 'a2', kind: 'phone', value: '+4917612345' },
|
|
55
|
+
],
|
|
56
|
+
} as unknown as Contact
|
|
57
|
+
|
|
58
|
+
const client: ConversationBriefClient = {
|
|
59
|
+
getConversationBrief: async () => sampleBrief,
|
|
60
|
+
getContact: async () => sampleContact,
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The connected card with the built-in preview modal and a host dial. */
|
|
64
|
+
export const Connected: StoryObj = {
|
|
65
|
+
render: () => (
|
|
66
|
+
<ConversationBriefReview
|
|
67
|
+
conversationBriefId="brief-1"
|
|
68
|
+
client={client}
|
|
69
|
+
onCall={(brief, contact, phoneNumber) =>
|
|
70
|
+
window.alert(`dial ${contact.name} at ${phoneNumber} for ${brief.id}`)
|
|
71
|
+
}
|
|
72
|
+
/>
|
|
73
|
+
),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The preview on its own — as the softphone / hub call panel would embed it. */
|
|
77
|
+
export const Preview: StoryObj = {
|
|
78
|
+
render: () => (
|
|
79
|
+
<ConversationBriefPreview brief={sampleBrief} contacts={[sampleContact]} onCall={() => {}} />
|
|
80
|
+
),
|
|
81
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { Phone } from 'lucide-react'
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { Button as AriaButton } from 'react-aria-components'
|
|
4
|
+
import { tv } from 'tailwind-variants'
|
|
5
|
+
|
|
6
|
+
import { cn } from '../../lib/cn'
|
|
7
|
+
import type { ConversationBriefOutcome } from './types'
|
|
8
|
+
|
|
9
|
+
/* =========================================================================
|
|
10
|
+
ConversationBriefCard — the compact entry point to a prepared call: phone glyph,
|
|
11
|
+
eyebrow, the contact's name, the brief's type / opening line, and one
|
|
12
|
+
action ("Open Brief"). Pure chrome — it neither fetches nor dials, so it can
|
|
13
|
+
sit in a chat flow, a record page, a task list or a custom component alike.
|
|
14
|
+
ConversationBriefReview is the connected wrapper; reach for this one only when the
|
|
15
|
+
host already holds the brief.
|
|
16
|
+
|
|
17
|
+
Once the brief is used or discarded the action is replaced by a status
|
|
18
|
+
label rather than removed — the row keeps its shape and its record.
|
|
19
|
+
========================================================================= */
|
|
20
|
+
|
|
21
|
+
export const conversationBriefCardStyles = tv({
|
|
22
|
+
slots: {
|
|
23
|
+
root: [
|
|
24
|
+
'flex items-center gap-2.5 rounded-md border border-accent/40 px-3 py-2',
|
|
25
|
+
'bg-[color-mix(in_oklab,var(--color-accent)_6%,var(--color-surface))]',
|
|
26
|
+
],
|
|
27
|
+
icon: [
|
|
28
|
+
'grid size-8 shrink-0 place-items-center rounded-full text-accent',
|
|
29
|
+
'bg-[color-mix(in_oklab,var(--color-accent)_12%,transparent)]',
|
|
30
|
+
],
|
|
31
|
+
body: 'min-w-0 flex-1',
|
|
32
|
+
eyebrow: 'font-mono text-[10px] uppercase tracking-[0.08em] text-accent',
|
|
33
|
+
title: 'truncate text-[13px] font-medium text-ink',
|
|
34
|
+
summary: 'truncate text-[12px] text-ink-3',
|
|
35
|
+
action: [
|
|
36
|
+
'shrink-0 cursor-pointer rounded-sm border border-accent/50 px-2.5 py-1',
|
|
37
|
+
'font-mono text-[11px] text-accent outline-none',
|
|
38
|
+
'hover:bg-[color-mix(in_oklab,var(--color-accent)_12%,transparent)]',
|
|
39
|
+
'focus-visible:shadow-[var(--shadow-focus)]',
|
|
40
|
+
'disabled:cursor-default disabled:opacity-50',
|
|
41
|
+
],
|
|
42
|
+
outcome: 'shrink-0 font-mono text-[11px] text-ink-3',
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const styles = conversationBriefCardStyles()
|
|
47
|
+
|
|
48
|
+
export const CONVERSATION_BRIEF_OUTCOME_LABEL: Record<ConversationBriefOutcome, string> = {
|
|
49
|
+
used: 'Call held',
|
|
50
|
+
discarded: 'Discarded',
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ConversationBriefCardProps {
|
|
54
|
+
/** Headline: the brief's subject, else the contact's name. */
|
|
55
|
+
title: string
|
|
56
|
+
/** Second line: the contact, channel, type and/or the opening line. */
|
|
57
|
+
summary?: string
|
|
58
|
+
/** Default 'Conversation brief — prepared'. */
|
|
59
|
+
eyebrow?: ReactNode
|
|
60
|
+
/** Default 'Open Brief'. */
|
|
61
|
+
openLabel?: string
|
|
62
|
+
/** Terminal outcome. Set ⇒ the action is replaced by its status label. */
|
|
63
|
+
outcome?: ConversationBriefOutcome | null
|
|
64
|
+
onOpen?: () => void
|
|
65
|
+
isOpenDisabled?: boolean
|
|
66
|
+
className?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function ConversationBriefCard({
|
|
70
|
+
title,
|
|
71
|
+
summary,
|
|
72
|
+
eyebrow = 'Conversation brief — prepared',
|
|
73
|
+
openLabel = 'Open Brief',
|
|
74
|
+
outcome,
|
|
75
|
+
onOpen,
|
|
76
|
+
isOpenDisabled = false,
|
|
77
|
+
className,
|
|
78
|
+
}: ConversationBriefCardProps) {
|
|
79
|
+
return (
|
|
80
|
+
<div className={cn(styles.root(), className)}>
|
|
81
|
+
<span className={styles.icon()}>
|
|
82
|
+
<Phone className="size-4" aria-hidden />
|
|
83
|
+
</span>
|
|
84
|
+
<div className={styles.body()}>
|
|
85
|
+
<p className={styles.eyebrow()}>{eyebrow}</p>
|
|
86
|
+
<p className={styles.title()}>{title}</p>
|
|
87
|
+
{summary ? <p className={styles.summary()}>{summary}</p> : null}
|
|
88
|
+
</div>
|
|
89
|
+
{outcome ? (
|
|
90
|
+
<span className={styles.outcome()}>{CONVERSATION_BRIEF_OUTCOME_LABEL[outcome]}</span>
|
|
91
|
+
) : (
|
|
92
|
+
<AriaButton
|
|
93
|
+
onPress={onOpen ?? (() => {})}
|
|
94
|
+
isDisabled={isOpenDisabled}
|
|
95
|
+
className={styles.action()}
|
|
96
|
+
>
|
|
97
|
+
{openLabel}
|
|
98
|
+
</AriaButton>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import type { Contact, ConversationBrief } from '@proteos/sdk'
|
|
2
|
+
import { Phone } from 'lucide-react'
|
|
3
|
+
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
|
4
|
+
|
|
5
|
+
import { cn } from '../../lib/cn'
|
|
6
|
+
import { Button } from '../button'
|
|
7
|
+
import { Select } from '../select'
|
|
8
|
+
import { contactPhoneNumbers } from './types'
|
|
9
|
+
|
|
10
|
+
/* =========================================================================
|
|
11
|
+
ConversationBriefPreview — the brief read in full before (and during) a call: the
|
|
12
|
+
contact, the guidance text, and — when the host can dial — the number to
|
|
13
|
+
call and the Call button. Fetch-agnostic and dial-agnostic: the host hands
|
|
14
|
+
in the loaded brief + contact and, optionally, `onCall`; without `onCall`
|
|
15
|
+
it is a read-only preview (a custom component's own modal, the hub's call
|
|
16
|
+
panel). The host may slot extra controls (a caller-id picker) via
|
|
17
|
+
`controls`.
|
|
18
|
+
========================================================================= */
|
|
19
|
+
|
|
20
|
+
export interface ConversationBriefPreviewProps {
|
|
21
|
+
brief: ConversationBrief
|
|
22
|
+
/** The brief's contacts (the resolved subset — a failed lookup is simply
|
|
23
|
+
* absent). Names head the preview; phone addresses are the dial
|
|
24
|
+
* candidates. */
|
|
25
|
+
contacts: Contact[]
|
|
26
|
+
/** Renders the contact/number pickers + Call button. Absent ⇒ read-only
|
|
27
|
+
* preview. Receives the chosen contact and number. */
|
|
28
|
+
onCall?: (contact: Contact, phoneNumber: string) => void
|
|
29
|
+
/** Disables the Call button (softphone busy, another call running…). */
|
|
30
|
+
isCallDisabled?: boolean
|
|
31
|
+
/** Explains why calling is disabled (shown under the controls). */
|
|
32
|
+
callDisabledReason?: string
|
|
33
|
+
/** Host-slotted controls rendered next to the number picker (caller id). */
|
|
34
|
+
controls?: ReactNode
|
|
35
|
+
/** Hide the built-in Call button — for hosts that render the action in
|
|
36
|
+
* their own footer; pair with `onSelectionChange` to know what to dial. */
|
|
37
|
+
isCallButtonHidden?: boolean
|
|
38
|
+
/** Fires whenever the chosen contact / number changes (and once on mount),
|
|
39
|
+
* so a host rendering its own Call button knows the current target.
|
|
40
|
+
* `phoneNumber` is '' when the chosen contact has no number. */
|
|
41
|
+
onSelectionChange?: (contact: Contact | null, phoneNumber: string) => void
|
|
42
|
+
/** Default 'Call'. */
|
|
43
|
+
callLabel?: string
|
|
44
|
+
className?: string
|
|
45
|
+
/** Visual override for the guidance text box (e.g. a max height). */
|
|
46
|
+
contentClassName?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function ConversationBriefPreview({
|
|
50
|
+
brief,
|
|
51
|
+
contacts,
|
|
52
|
+
onCall,
|
|
53
|
+
isCallDisabled = false,
|
|
54
|
+
callDisabledReason,
|
|
55
|
+
controls,
|
|
56
|
+
isCallButtonHidden = false,
|
|
57
|
+
onSelectionChange,
|
|
58
|
+
callLabel = 'Call',
|
|
59
|
+
className,
|
|
60
|
+
contentClassName,
|
|
61
|
+
}: ConversationBriefPreviewProps) {
|
|
62
|
+
const [pickedContactId, setPickedContactId] = useState('')
|
|
63
|
+
const contact = contacts.find((entry) => entry.id === pickedContactId) ?? contacts[0] ?? null
|
|
64
|
+
const numbers = useMemo(() => contactPhoneNumbers(contact), [contact])
|
|
65
|
+
const [pickedNumber, setPickedNumber] = useState('')
|
|
66
|
+
const phoneNumber = numbers.includes(pickedNumber) ? pickedNumber : numbers[0] || ''
|
|
67
|
+
const isOpen = brief.status === 'prepared'
|
|
68
|
+
const names = contacts
|
|
69
|
+
.map((entry) => entry.name)
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
.join(', ')
|
|
72
|
+
|
|
73
|
+
// Latest-closure ref: the host's callback is routinely a fresh arrow, and
|
|
74
|
+
// the effect must fire on TARGET changes only.
|
|
75
|
+
const onSelectionChangeRef = useRef(onSelectionChange)
|
|
76
|
+
onSelectionChangeRef.current = onSelectionChange
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
onSelectionChangeRef.current?.(contact, phoneNumber)
|
|
79
|
+
}, [contact, phoneNumber])
|
|
80
|
+
|
|
81
|
+
return (
|
|
82
|
+
<div className={cn('flex flex-col gap-3', className)}>
|
|
83
|
+
<div className="min-w-0">
|
|
84
|
+
<p className="truncate text-[14px] font-semibold text-ink">
|
|
85
|
+
{brief.subject || names || 'Prepared conversation'}
|
|
86
|
+
</p>
|
|
87
|
+
<p className="font-mono text-[10.5px] text-ink-4">
|
|
88
|
+
{[
|
|
89
|
+
brief.subject ? names : '',
|
|
90
|
+
brief.channel ? `Channel ${brief.channel}` : 'Any channel',
|
|
91
|
+
brief.type_key ? `Type ${brief.type_key}` : '',
|
|
92
|
+
brief.status === 'prepared'
|
|
93
|
+
? 'Prepared'
|
|
94
|
+
: brief.status === 'used'
|
|
95
|
+
? 'Call held'
|
|
96
|
+
: 'Discarded',
|
|
97
|
+
]
|
|
98
|
+
.filter(Boolean)
|
|
99
|
+
.join(' · ')}
|
|
100
|
+
</p>
|
|
101
|
+
</div>
|
|
102
|
+
<div
|
|
103
|
+
className={cn(
|
|
104
|
+
'whitespace-pre-wrap rounded-md border border-rule-2 bg-surface-2/60 px-3 py-2.5',
|
|
105
|
+
'text-[13px] leading-relaxed text-ink-2 overflow-y-auto [scrollbar-width:thin]',
|
|
106
|
+
contentClassName,
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
{brief.content}
|
|
110
|
+
</div>
|
|
111
|
+
{onCall ? (
|
|
112
|
+
<div className="flex flex-col gap-2 border-t border-rule-2 pt-3">
|
|
113
|
+
{contacts.length > 1 ? (
|
|
114
|
+
<Select
|
|
115
|
+
aria-label="Who to call"
|
|
116
|
+
selectedKey={contact?.id ?? ''}
|
|
117
|
+
onSelectionChange={(key) => {
|
|
118
|
+
setPickedContactId(String(key))
|
|
119
|
+
setPickedNumber('')
|
|
120
|
+
}}
|
|
121
|
+
options={contacts.map((entry) => ({ id: entry.id, label: entry.name || entry.id }))}
|
|
122
|
+
/>
|
|
123
|
+
) : null}
|
|
124
|
+
{numbers.length > 1 ? (
|
|
125
|
+
<Select
|
|
126
|
+
aria-label="Number to call"
|
|
127
|
+
selectedKey={phoneNumber}
|
|
128
|
+
onSelectionChange={(key) => setPickedNumber(String(key))}
|
|
129
|
+
options={numbers.map((number) => ({ id: number, label: number }))}
|
|
130
|
+
/>
|
|
131
|
+
) : (
|
|
132
|
+
<p className="font-mono text-[12px] text-ink-3">
|
|
133
|
+
{phoneNumber ||
|
|
134
|
+
(contact ? 'This contact has no phone number on file.' : 'No contact to call.')}
|
|
135
|
+
</p>
|
|
136
|
+
)}
|
|
137
|
+
{controls}
|
|
138
|
+
{callDisabledReason ? (
|
|
139
|
+
<p className="text-[12px] text-warning">{callDisabledReason}</p>
|
|
140
|
+
) : null}
|
|
141
|
+
{isCallButtonHidden ? null : (
|
|
142
|
+
<div className="flex justify-end">
|
|
143
|
+
<Button
|
|
144
|
+
isDisabled={isCallDisabled || !isOpen || phoneNumber === '' || !contact}
|
|
145
|
+
onPress={() => contact && onCall(contact, phoneNumber)}
|
|
146
|
+
>
|
|
147
|
+
<Phone className="size-3.5" aria-hidden />
|
|
148
|
+
{callLabel}
|
|
149
|
+
</Button>
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
</div>
|
|
153
|
+
) : null}
|
|
154
|
+
</div>
|
|
155
|
+
)
|
|
156
|
+
}
|