@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@proteos/ui",
3
- "version": "0.49.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.49.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, pill-shaped status chip with a leading dot.
9
- Matches the Proteos spec's six registers: default, neutral, active (indigo),
10
- success, warning, danger. Paper-compatible fills — never pure-color.
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 rounded-pill border',
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({ variant, color, icon, className, style, children, ...props }: BadgeProps) {
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(badgeStyles({ variant: register }), Icon ? 'before:hidden' : null, className)}
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
- /** The Badge chrome props an option carries — spread onto every Badge it renders as. */
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({ variant, color, icon })}>
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
+ }