@open-mercato/ui 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2

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.
Files changed (148) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +2 -1
  3. package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +204 -0
  4. package/dist/ai/AiAssistantLauncher.js +596 -0
  5. package/dist/ai/AiAssistantLauncher.js.map +7 -0
  6. package/dist/ai/AiChat.js +1092 -0
  7. package/dist/ai/AiChat.js.map +7 -0
  8. package/dist/ai/AiChatSessions.js +297 -0
  9. package/dist/ai/AiChatSessions.js.map +7 -0
  10. package/dist/ai/AiDock.js +347 -0
  11. package/dist/ai/AiDock.js.map +7 -0
  12. package/dist/ai/AiMessageContent.js +369 -0
  13. package/dist/ai/AiMessageContent.js.map +7 -0
  14. package/dist/ai/ChatPaneTabs.js +251 -0
  15. package/dist/ai/ChatPaneTabs.js.map +7 -0
  16. package/dist/ai/index.js +115 -0
  17. package/dist/ai/index.js.map +7 -0
  18. package/dist/ai/parts/ConfirmationCard.js +211 -0
  19. package/dist/ai/parts/ConfirmationCard.js.map +7 -0
  20. package/dist/ai/parts/FieldDiffCard.js +119 -0
  21. package/dist/ai/parts/FieldDiffCard.js.map +7 -0
  22. package/dist/ai/parts/MutationPreviewCard.js +224 -0
  23. package/dist/ai/parts/MutationPreviewCard.js.map +7 -0
  24. package/dist/ai/parts/MutationResultCard.js +240 -0
  25. package/dist/ai/parts/MutationResultCard.js.map +7 -0
  26. package/dist/ai/parts/approval-cards-map.js +15 -0
  27. package/dist/ai/parts/approval-cards-map.js.map +7 -0
  28. package/dist/ai/parts/index.js +24 -0
  29. package/dist/ai/parts/index.js.map +7 -0
  30. package/dist/ai/parts/pending-action-api.js +60 -0
  31. package/dist/ai/parts/pending-action-api.js.map +7 -0
  32. package/dist/ai/parts/types.js +1 -0
  33. package/dist/ai/parts/types.js.map +7 -0
  34. package/dist/ai/parts/useAiPendingActionPolling.js +126 -0
  35. package/dist/ai/parts/useAiPendingActionPolling.js.map +7 -0
  36. package/dist/ai/records/ActivityCard.js +83 -0
  37. package/dist/ai/records/ActivityCard.js.map +7 -0
  38. package/dist/ai/records/CompanyCard.js +81 -0
  39. package/dist/ai/records/CompanyCard.js.map +7 -0
  40. package/dist/ai/records/DealCard.js +76 -0
  41. package/dist/ai/records/DealCard.js.map +7 -0
  42. package/dist/ai/records/PersonCard.js +68 -0
  43. package/dist/ai/records/PersonCard.js.map +7 -0
  44. package/dist/ai/records/ProductCard.js +68 -0
  45. package/dist/ai/records/ProductCard.js.map +7 -0
  46. package/dist/ai/records/RecordCard.js +29 -0
  47. package/dist/ai/records/RecordCard.js.map +7 -0
  48. package/dist/ai/records/RecordCardShell.js +103 -0
  49. package/dist/ai/records/RecordCardShell.js.map +7 -0
  50. package/dist/ai/records/index.js +31 -0
  51. package/dist/ai/records/index.js.map +7 -0
  52. package/dist/ai/records/registry.js +51 -0
  53. package/dist/ai/records/registry.js.map +7 -0
  54. package/dist/ai/records/types.js +1 -0
  55. package/dist/ai/records/types.js.map +7 -0
  56. package/dist/ai/ui-part-registry.js +112 -0
  57. package/dist/ai/ui-part-registry.js.map +7 -0
  58. package/dist/ai/ui-part-slots.js +14 -0
  59. package/dist/ai/ui-part-slots.js.map +7 -0
  60. package/dist/ai/ui-parts/pending-phase3-placeholder.js +35 -0
  61. package/dist/ai/ui-parts/pending-phase3-placeholder.js.map +7 -0
  62. package/dist/ai/upload-adapter.js +256 -0
  63. package/dist/ai/upload-adapter.js.map +7 -0
  64. package/dist/ai/useAiChat.js +549 -0
  65. package/dist/ai/useAiChat.js.map +7 -0
  66. package/dist/ai/useAiChatUpload.js +127 -0
  67. package/dist/ai/useAiChatUpload.js.map +7 -0
  68. package/dist/ai/useAiShortcuts.js +43 -0
  69. package/dist/ai/useAiShortcuts.js.map +7 -0
  70. package/dist/backend/AppShell.js +8 -4
  71. package/dist/backend/AppShell.js.map +2 -2
  72. package/dist/backend/BackendChromeProvider.js +2 -0
  73. package/dist/backend/BackendChromeProvider.js.map +2 -2
  74. package/dist/backend/DataTable.js +19 -2
  75. package/dist/backend/DataTable.js.map +2 -2
  76. package/dist/backend/FilterBar.js +19 -15
  77. package/dist/backend/FilterBar.js.map +2 -2
  78. package/dist/backend/dashboard/DashboardScreen.js +31 -3
  79. package/dist/backend/dashboard/DashboardScreen.js.map +2 -2
  80. package/dist/backend/injection/spotIds.js +6 -0
  81. package/dist/backend/injection/spotIds.js.map +2 -2
  82. package/dist/backend/notifications/useNotificationEffect.js +38 -2
  83. package/dist/backend/notifications/useNotificationEffect.js.map +2 -2
  84. package/dist/index.js +1 -0
  85. package/dist/index.js.map +2 -2
  86. package/jest.config.cjs +7 -1
  87. package/jest.markdown-mock.tsx +7 -0
  88. package/package.json +10 -4
  89. package/src/ai/AiAssistantLauncher.tsx +805 -0
  90. package/src/ai/AiChat.tsx +1483 -0
  91. package/src/ai/AiChatSessions.tsx +429 -0
  92. package/src/ai/AiDock.tsx +505 -0
  93. package/src/ai/AiMessageContent.tsx +515 -0
  94. package/src/ai/ChatPaneTabs.tsx +310 -0
  95. package/src/ai/__tests__/AiChat.conversation.test.tsx +160 -0
  96. package/src/ai/__tests__/AiChat.debug.test.tsx +152 -0
  97. package/src/ai/__tests__/AiChat.registry.test.tsx +213 -0
  98. package/src/ai/__tests__/AiChat.test.tsx +257 -0
  99. package/src/ai/__tests__/AiDock.test.tsx +124 -0
  100. package/src/ai/__tests__/AiMessageContent.test.ts +111 -0
  101. package/src/ai/__tests__/ui-part-registry.test.ts +199 -0
  102. package/src/ai/__tests__/ui-part-slots.test.ts +43 -0
  103. package/src/ai/__tests__/upload-adapter.test.ts +213 -0
  104. package/src/ai/__tests__/useAiChatUpload.test.tsx +163 -0
  105. package/src/ai/__tests__/useAiShortcuts.test.tsx +100 -0
  106. package/src/ai/index.ts +125 -0
  107. package/src/ai/parts/ConfirmationCard.tsx +310 -0
  108. package/src/ai/parts/FieldDiffCard.tsx +173 -0
  109. package/src/ai/parts/MutationPreviewCard.tsx +302 -0
  110. package/src/ai/parts/MutationResultCard.tsx +360 -0
  111. package/src/ai/parts/__tests__/ConfirmationCard.test.tsx +169 -0
  112. package/src/ai/parts/__tests__/FieldDiffCard.test.tsx +74 -0
  113. package/src/ai/parts/__tests__/MutationPreviewCard.test.tsx +177 -0
  114. package/src/ai/parts/__tests__/MutationResultCard.test.tsx +127 -0
  115. package/src/ai/parts/__tests__/useAiPendingActionPolling.test.tsx +151 -0
  116. package/src/ai/parts/approval-cards-map.ts +24 -0
  117. package/src/ai/parts/index.ts +27 -0
  118. package/src/ai/parts/pending-action-api.ts +123 -0
  119. package/src/ai/parts/types.ts +84 -0
  120. package/src/ai/parts/useAiPendingActionPolling.ts +210 -0
  121. package/src/ai/records/ActivityCard.tsx +102 -0
  122. package/src/ai/records/CompanyCard.tsx +89 -0
  123. package/src/ai/records/DealCard.tsx +85 -0
  124. package/src/ai/records/PersonCard.tsx +77 -0
  125. package/src/ai/records/ProductCard.tsx +83 -0
  126. package/src/ai/records/RecordCard.tsx +37 -0
  127. package/src/ai/records/RecordCardShell.tsx +169 -0
  128. package/src/ai/records/index.ts +30 -0
  129. package/src/ai/records/registry.tsx +80 -0
  130. package/src/ai/records/types.ts +90 -0
  131. package/src/ai/ui-part-registry.ts +233 -0
  132. package/src/ai/ui-part-slots.ts +32 -0
  133. package/src/ai/ui-parts/pending-phase3-placeholder.tsx +50 -0
  134. package/src/ai/upload-adapter.ts +421 -0
  135. package/src/ai/useAiChat.ts +865 -0
  136. package/src/ai/useAiChatUpload.ts +180 -0
  137. package/src/ai/useAiShortcuts.ts +79 -0
  138. package/src/backend/AppShell.tsx +12 -5
  139. package/src/backend/BackendChromeProvider.tsx +2 -0
  140. package/src/backend/DataTable.tsx +20 -1
  141. package/src/backend/FilterBar.tsx +26 -13
  142. package/src/backend/__tests__/BackendChromeProvider.test.tsx +45 -0
  143. package/src/backend/dashboard/DashboardScreen.tsx +38 -3
  144. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +24 -1
  145. package/src/backend/injection/spotIds.ts +6 -0
  146. package/src/backend/notifications/__tests__/useNotificationEffect.test.tsx +77 -0
  147. package/src/backend/notifications/useNotificationEffect.ts +47 -2
  148. package/src/index.ts +1 -0
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Shared props + payload shapes for the Phase 3 mutation-approval cards
3
+ * (Step 5.10).
4
+ *
5
+ * The cards render the server-emitted `AiPendingAction` row via the
6
+ * `@open-mercato/ai-assistant` serializer. Re-declared here by structure
7
+ * rather than imported to keep `packages/ui` free of server-only imports.
8
+ * The shape mirrors `SerializedPendingAction` from
9
+ * `@open-mercato/ai-assistant/modules/ai_assistant/lib/pending-action-client`.
10
+ */
11
+
12
+ export type AiPendingActionCardStatus =
13
+ | 'pending'
14
+ | 'confirmed'
15
+ | 'cancelled'
16
+ | 'expired'
17
+ | 'executing'
18
+ | 'failed'
19
+
20
+ export type AiPendingActionCardFieldDiff = {
21
+ field: string
22
+ before: unknown
23
+ after: unknown
24
+ }
25
+
26
+ export type AiPendingActionCardRecordDiff = {
27
+ recordId: string
28
+ entityType: string
29
+ label: string
30
+ fieldDiff: AiPendingActionCardFieldDiff[]
31
+ recordVersion?: string | null
32
+ attachmentIds?: string[]
33
+ }
34
+
35
+ export type AiPendingActionCardFailedRecord = {
36
+ recordId: string
37
+ error: { code: string; message: string }
38
+ }
39
+
40
+ export type AiPendingActionCardExecutionErrorDetails = {
41
+ issues?: Array<{
42
+ path?: (string | number)[]
43
+ message?: string
44
+ code?: string
45
+ expected?: string
46
+ received?: string
47
+ }>
48
+ fieldErrors?: Record<string, string[]>
49
+ cause?: unknown
50
+ [key: string]: unknown
51
+ }
52
+
53
+ export type AiPendingActionCardExecutionResult = {
54
+ recordId?: string
55
+ commandName?: string
56
+ error?: {
57
+ code: string
58
+ message: string
59
+ name?: string
60
+ details?: AiPendingActionCardExecutionErrorDetails
61
+ input?: unknown
62
+ stack?: string
63
+ }
64
+ }
65
+
66
+ export interface AiPendingActionCardAction {
67
+ id: string
68
+ agentId: string
69
+ toolName: string
70
+ status: AiPendingActionCardStatus
71
+ fieldDiff: AiPendingActionCardFieldDiff[]
72
+ records: AiPendingActionCardRecordDiff[] | null
73
+ failedRecords: AiPendingActionCardFailedRecord[] | null
74
+ sideEffectsSummary: string | null
75
+ attachmentIds: string[]
76
+ targetEntityType: string | null
77
+ targetRecordId: string | null
78
+ recordVersion: string | null
79
+ executionResult: AiPendingActionCardExecutionResult | null
80
+ createdAt: string
81
+ expiresAt: string
82
+ resolvedAt: string | null
83
+ resolvedByUserId: string | null
84
+ }
@@ -0,0 +1,210 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { apiCallOrThrow } from '../../backend/utils/apiCall'
5
+ import type { AiPendingActionCardAction, AiPendingActionCardStatus } from './types'
6
+
7
+ /**
8
+ * Shared polling hook for the Phase 3 mutation-approval cards (Step 5.10).
9
+ *
10
+ * Responsibilities:
11
+ * - Fetch `GET /api/ai_assistant/ai/actions/:id` on mount, even when the
12
+ * server previously streamed a preview card. This is the "reconnect" path:
13
+ * after a page reload or navigation away+back, the card recovers the
14
+ * current pending-action state instead of staying blank.
15
+ * - Poll every 3 seconds while the status is non-terminal
16
+ * (`pending` / `executing`). Terminal states (`confirmed`, `cancelled`,
17
+ * `failed`, `expired`) stop polling.
18
+ * - Expose a `refresh()` force-fetch helper the confirmation card uses after
19
+ * a confirm POST races with the polling loop.
20
+ *
21
+ * The hook owns a single `setInterval`/`setTimeout` — unmounting clears
22
+ * every outstanding timer. This is required for the Jest fake-timers
23
+ * "mount, unmount mid-poll" test contract.
24
+ */
25
+
26
+ const TERMINAL_STATUSES: ReadonlyArray<AiPendingActionCardStatus> = [
27
+ 'confirmed',
28
+ 'cancelled',
29
+ 'failed',
30
+ 'expired',
31
+ ]
32
+
33
+ function isTerminal(status: AiPendingActionCardStatus | null): boolean {
34
+ if (!status) return false
35
+ return TERMINAL_STATUSES.includes(status)
36
+ }
37
+
38
+ export interface UseAiPendingActionPollingOptions {
39
+ pendingActionId: string
40
+ /**
41
+ * Poll interval in ms while the status is non-terminal. Defaults to 3000.
42
+ */
43
+ intervalMs?: number
44
+ /**
45
+ * Endpoint base. Override to point at a mock during tests.
46
+ */
47
+ endpoint?: string
48
+ /**
49
+ * When true, the hook does NOT schedule any network activity. Used by the
50
+ * result card which already holds a terminal state and only needs to read
51
+ * what the preview card fetched.
52
+ */
53
+ disabled?: boolean
54
+ }
55
+
56
+ export interface AiPendingActionFetchResult {
57
+ pendingAction: AiPendingActionCardAction | null
58
+ error?: { code?: string; message: string } | null
59
+ }
60
+
61
+ export interface UseAiPendingActionPollingResult {
62
+ action: AiPendingActionCardAction | null
63
+ status: AiPendingActionCardStatus | null
64
+ isPolling: boolean
65
+ error: { code?: string; message: string } | null
66
+ refresh: () => Promise<AiPendingActionCardAction | null>
67
+ }
68
+
69
+ async function fetchPendingAction(
70
+ pendingActionId: string,
71
+ endpoint: string,
72
+ ): Promise<AiPendingActionFetchResult> {
73
+ const url = `${endpoint}/${encodeURIComponent(pendingActionId)}`
74
+ const call = await apiCallOrThrow<unknown>(url, { method: 'GET' })
75
+ const body = call.result as
76
+ | (Partial<AiPendingActionCardAction> & {
77
+ pendingAction?: AiPendingActionCardAction
78
+ error?: string
79
+ code?: string
80
+ })
81
+ | null
82
+ | undefined
83
+
84
+ // The GET route returns the bare action object (`serializePendingActionForClient(row)`),
85
+ // but earlier client code expected it under a `pendingAction` envelope.
86
+ // Accept BOTH shapes so the cards work whether the dispatcher wraps or
87
+ // not — bare-object payloads were the actual cause of empty
88
+ // `fieldDiff` and missing `executionResult.error` displays in
89
+ // MutationPreviewCard / ConfirmationCard / MutationResultCard.
90
+ if (body && typeof body === 'object') {
91
+ if (body.pendingAction) {
92
+ return { pendingAction: body.pendingAction, error: null }
93
+ }
94
+ if (
95
+ typeof (body as Partial<AiPendingActionCardAction>).id === 'string' &&
96
+ typeof (body as Partial<AiPendingActionCardAction>).status === 'string'
97
+ ) {
98
+ return { pendingAction: body as AiPendingActionCardAction, error: null }
99
+ }
100
+ if (body.error) {
101
+ return {
102
+ pendingAction: null,
103
+ error: { message: body.error, code: body.code },
104
+ }
105
+ }
106
+ }
107
+ return { pendingAction: null, error: null }
108
+ }
109
+
110
+ export function useAiPendingActionPolling(
111
+ options: UseAiPendingActionPollingOptions,
112
+ ): UseAiPendingActionPollingResult {
113
+ const {
114
+ pendingActionId,
115
+ intervalMs = 3000,
116
+ endpoint = '/api/ai_assistant/ai/actions',
117
+ disabled = false,
118
+ } = options
119
+
120
+ const [action, setAction] = React.useState<AiPendingActionCardAction | null>(null)
121
+ const [error, setError] = React.useState<{ code?: string; message: string } | null>(null)
122
+ const [isPolling, setIsPolling] = React.useState<boolean>(!disabled)
123
+
124
+ const mountedRef = React.useRef(true)
125
+ const timerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
126
+ const statusRef = React.useRef<AiPendingActionCardStatus | null>(null)
127
+
128
+ const clearTimer = React.useCallback(() => {
129
+ if (timerRef.current) {
130
+ clearTimeout(timerRef.current)
131
+ timerRef.current = null
132
+ }
133
+ }, [])
134
+
135
+ const refresh = React.useCallback(async (): Promise<AiPendingActionCardAction | null> => {
136
+ if (!pendingActionId) return null
137
+ try {
138
+ const result = await fetchPendingAction(pendingActionId, endpoint)
139
+ if (!mountedRef.current) return result.pendingAction ?? null
140
+ if (result.error) {
141
+ setError(result.error)
142
+ } else {
143
+ setError(null)
144
+ }
145
+ if (result.pendingAction) {
146
+ setAction(result.pendingAction)
147
+ statusRef.current = result.pendingAction.status
148
+ }
149
+ return result.pendingAction
150
+ } catch (err) {
151
+ if (!mountedRef.current) return null
152
+ const message = err instanceof Error ? err.message : 'Failed to load pending action.'
153
+ setError({ message })
154
+ return null
155
+ }
156
+ }, [endpoint, pendingActionId])
157
+
158
+ const scheduleNext = React.useCallback(() => {
159
+ clearTimer()
160
+ if (!mountedRef.current) return
161
+ if (disabled) {
162
+ setIsPolling(false)
163
+ return
164
+ }
165
+ if (isTerminal(statusRef.current)) {
166
+ setIsPolling(false)
167
+ return
168
+ }
169
+ setIsPolling(true)
170
+ timerRef.current = setTimeout(async () => {
171
+ await refresh()
172
+ scheduleNext()
173
+ }, intervalMs)
174
+ }, [clearTimer, disabled, intervalMs, refresh])
175
+
176
+ React.useEffect(() => {
177
+ mountedRef.current = true
178
+ statusRef.current = null
179
+ if (disabled) {
180
+ setIsPolling(false)
181
+ return () => {
182
+ mountedRef.current = false
183
+ clearTimer()
184
+ }
185
+ }
186
+ setIsPolling(true)
187
+ // Always fetch on mount — the "reconnect behavior" guarantee.
188
+ void refresh().then(() => {
189
+ if (!mountedRef.current) return
190
+ scheduleNext()
191
+ })
192
+ return () => {
193
+ mountedRef.current = false
194
+ clearTimer()
195
+ }
196
+ // eslint-disable-next-line react-hooks/exhaustive-deps
197
+ }, [pendingActionId, endpoint, intervalMs, disabled])
198
+
199
+ const status = action?.status ?? null
200
+
201
+ return {
202
+ action,
203
+ status,
204
+ isPolling: isPolling && !isTerminal(status),
205
+ error,
206
+ refresh,
207
+ }
208
+ }
209
+
210
+ export default useAiPendingActionPolling
@@ -0,0 +1,102 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import {
5
+ Activity as ActivityIcon,
6
+ CalendarClock,
7
+ CheckCircle2,
8
+ ListChecks,
9
+ Mail,
10
+ MessageSquare,
11
+ Phone,
12
+ StickyNote,
13
+ } from 'lucide-react'
14
+ import type { LucideIcon } from 'lucide-react'
15
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
16
+ import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
17
+ import type { ActivityRecordPayload } from './types'
18
+
19
+ function formatDate(value: string | null | undefined): string | null {
20
+ if (!value) return null
21
+ const d = new Date(value)
22
+ if (Number.isNaN(d.getTime())) return value
23
+ return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
24
+ }
25
+
26
+ function pickActivityIcon(type: string | null | undefined): LucideIcon {
27
+ if (!type) return ActivityIcon
28
+ const t = type.toLowerCase()
29
+ if (t.includes('call')) return Phone
30
+ if (t.includes('mail') || t.includes('email')) return Mail
31
+ if (t.includes('meeting') || t.includes('chat')) return MessageSquare
32
+ if (t.includes('task') || t.includes('todo')) return ListChecks
33
+ if (t.includes('note')) return StickyNote
34
+ if (t.includes('done') || t.includes('complete')) return CheckCircle2
35
+ return ActivityIcon
36
+ }
37
+
38
+ export interface ActivityCardProps extends ActivityRecordPayload {}
39
+
40
+ export function ActivityCard(props: ActivityCardProps) {
41
+ const t = useT()
42
+ const Icon = pickActivityIcon(props.type)
43
+ const status = props.status
44
+ ? { label: props.status, variant: statusToTagVariant(props.status) }
45
+ : null
46
+ const dueDate = formatDate(props.dueDate)
47
+ const completedAt = formatDate(props.completedAt)
48
+
49
+ const items = [
50
+ props.type ? { label: t('ai_assistant.chat.records.fields.type', 'Type'), value: props.type } : null,
51
+ dueDate
52
+ ? {
53
+ label: t('ai_assistant.chat.records.fields.due', 'Due'),
54
+ value: (
55
+ <span className="inline-flex items-center gap-1">
56
+ <CalendarClock className="size-3 text-muted-foreground" aria-hidden />
57
+ {dueDate}
58
+ </span>
59
+ ),
60
+ }
61
+ : null,
62
+ completedAt
63
+ ? {
64
+ label: t('ai_assistant.chat.records.fields.completed', 'Completed'),
65
+ value: (
66
+ <span className="inline-flex items-center gap-1">
67
+ <CheckCircle2 className="size-3 text-status-success-icon" aria-hidden />
68
+ {completedAt}
69
+ </span>
70
+ ),
71
+ }
72
+ : null,
73
+ props.relatedTo ? { label: t('ai_assistant.chat.records.fields.related', 'Related'), value: props.relatedTo } : null,
74
+ props.ownerName ? { label: t('ai_assistant.chat.records.fields.owner', 'Owner'), value: props.ownerName } : null,
75
+ ].filter(Boolean) as { label: string; value: React.ReactNode }[]
76
+
77
+ const subtitle = [props.type, props.relatedTo].filter(Boolean).join(' • ')
78
+
79
+ return (
80
+ <RecordCardShell
81
+ kindLabel={t('ai_assistant.chat.records.kinds.activity', 'Activity')}
82
+ kindIcon={<Icon className="size-4" aria-hidden />}
83
+ title={props.title}
84
+ subtitle={subtitle || undefined}
85
+ status={status}
86
+ href={props.href}
87
+ id={props.id}
88
+ className={props.className}
89
+ dataKind="activity"
90
+ >
91
+ <div className="space-y-2">
92
+ <KeyValueList items={items} />
93
+ {props.description ? (
94
+ <p className="line-clamp-3 text-muted-foreground">{props.description}</p>
95
+ ) : null}
96
+ {props.tags && props.tags.length > 0 ? <TagRow tags={props.tags} /> : null}
97
+ </div>
98
+ </RecordCardShell>
99
+ )
100
+ }
101
+
102
+ export default ActivityCard
@@ -0,0 +1,89 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Building2, Globe, Mail, MapPin, Phone } from 'lucide-react'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { Avatar } from '../../primitives/avatar'
7
+ import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
8
+ import type { CompanyRecordPayload } from './types'
9
+
10
+ export interface CompanyCardProps extends CompanyRecordPayload {}
11
+
12
+ function normalizeWebsite(value: string): { href: string; label: string } {
13
+ const trimmed = value.trim()
14
+ if (/^https?:\/\//i.test(trimmed)) {
15
+ return { href: trimmed, label: trimmed.replace(/^https?:\/\//i, '') }
16
+ }
17
+ return { href: `https://${trimmed}`, label: trimmed }
18
+ }
19
+
20
+ export function CompanyCard(props: CompanyCardProps) {
21
+ const t = useT()
22
+ const status = props.status
23
+ ? { label: props.status, variant: statusToTagVariant(props.status) }
24
+ : null
25
+
26
+ const websiteEntry = props.website ? normalizeWebsite(props.website) : null
27
+ const location = [props.city, props.country].filter(Boolean).join(', ')
28
+
29
+ const items = [
30
+ props.industry ? { label: t('ai_assistant.chat.records.fields.industry', 'Industry'), value: props.industry } : null,
31
+ websiteEntry
32
+ ? {
33
+ label: t('ai_assistant.chat.records.fields.website', 'Website'),
34
+ value: (
35
+ <a
36
+ href={websiteEntry.href}
37
+ target="_blank"
38
+ rel="noopener noreferrer"
39
+ className="text-primary hover:underline"
40
+ onClick={(event) => event.stopPropagation()}
41
+ >
42
+ {websiteEntry.label}
43
+ </a>
44
+ ),
45
+ }
46
+ : null,
47
+ props.email
48
+ ? {
49
+ label: t('ai_assistant.chat.records.fields.email', 'Email'),
50
+ value: (
51
+ <a
52
+ href={`mailto:${props.email}`}
53
+ className="text-primary hover:underline"
54
+ onClick={(event) => event.stopPropagation()}
55
+ >
56
+ {props.email}
57
+ </a>
58
+ ),
59
+ }
60
+ : null,
61
+ props.phone ? { label: t('ai_assistant.chat.records.fields.phone', 'Phone'), value: props.phone } : null,
62
+ location ? { label: t('ai_assistant.chat.records.fields.location', 'Location'), value: location } : null,
63
+ props.ownerName ? { label: t('ai_assistant.chat.records.fields.owner', 'Owner'), value: props.ownerName } : null,
64
+ ].filter(Boolean) as { label: string; value: React.ReactNode }[]
65
+
66
+ return (
67
+ <RecordCardShell
68
+ kindLabel={t('ai_assistant.chat.records.kinds.company', 'Company')}
69
+ kindIcon={<Building2 className="size-4" aria-hidden />}
70
+ leading={<Avatar label={props.name} src={props.logoUrl ?? undefined} size="md" variant="monochrome" />}
71
+ title={props.name}
72
+ subtitle={[props.industry, location].filter(Boolean).join(' • ') || undefined}
73
+ status={status}
74
+ href={props.href}
75
+ id={props.id}
76
+ className={props.className}
77
+ dataKind="company"
78
+ >
79
+ <div className="space-y-2">
80
+ <KeyValueList items={items} />
81
+ {props.tags && props.tags.length > 0 ? <TagRow tags={props.tags} /> : null}
82
+ </div>
83
+ </RecordCardShell>
84
+ )
85
+ }
86
+
87
+ export default CompanyCard
88
+
89
+ export { Building2, Globe, Mail, MapPin, Phone }
@@ -0,0 +1,85 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Briefcase, Building2, CalendarDays, CircleDollarSign, User } from 'lucide-react'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
7
+ import type { DealRecordPayload } from './types'
8
+
9
+ function formatAmount(amount: string | number | null | undefined, currency?: string | null): string | null {
10
+ if (amount === null || amount === undefined || amount === '') return null
11
+ const value = typeof amount === 'number' ? amount : Number(amount)
12
+ if (!Number.isFinite(value)) {
13
+ return typeof amount === 'string' ? amount : null
14
+ }
15
+ const code = currency && currency.length === 3 ? currency.toUpperCase() : undefined
16
+ try {
17
+ if (code) {
18
+ return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(value)
19
+ }
20
+ } catch {
21
+ // fall through to fallback
22
+ }
23
+ const formatted = new Intl.NumberFormat().format(value)
24
+ return code ? `${formatted} ${code}` : formatted
25
+ }
26
+
27
+ function formatDate(value: string | null | undefined): string | null {
28
+ if (!value) return null
29
+ const d = new Date(value)
30
+ if (Number.isNaN(d.getTime())) return value
31
+ return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
32
+ }
33
+
34
+ export interface DealCardProps extends DealRecordPayload {}
35
+
36
+ export function DealCard(props: DealCardProps) {
37
+ const t = useT()
38
+ const status = props.status
39
+ ? { label: props.status, variant: statusToTagVariant(props.status) }
40
+ : null
41
+ const stage = props.stage && props.stage !== props.status ? props.stage : null
42
+ const amount = formatAmount(props.amount, props.currency)
43
+ const closeDate = formatDate(props.closeDate)
44
+
45
+ const items = [
46
+ stage ? { label: t('ai_assistant.chat.records.fields.stage', 'Stage'), value: stage } : null,
47
+ amount ? { label: t('ai_assistant.chat.records.fields.amount', 'Amount'), value: <span className="font-medium">{amount}</span> } : null,
48
+ closeDate ? { label: t('ai_assistant.chat.records.fields.close', 'Close'), value: closeDate } : null,
49
+ props.companyName ? { label: t('ai_assistant.chat.records.fields.company', 'Company'), value: props.companyName } : null,
50
+ props.personName ? { label: t('ai_assistant.chat.records.fields.contact', 'Contact'), value: props.personName } : null,
51
+ props.ownerName ? { label: t('ai_assistant.chat.records.fields.owner', 'Owner'), value: props.ownerName } : null,
52
+ ].filter(Boolean) as { label: string; value: React.ReactNode }[]
53
+
54
+ const subtitleParts: string[] = []
55
+ if (props.companyName) subtitleParts.push(props.companyName)
56
+ if (props.personName && !props.companyName) subtitleParts.push(props.personName)
57
+ if (amount) subtitleParts.push(amount)
58
+
59
+ return (
60
+ <RecordCardShell
61
+ kindLabel={t('ai_assistant.chat.records.kinds.deal', 'Deal')}
62
+ kindIcon={<Briefcase className="size-4" aria-hidden />}
63
+ title={props.title}
64
+ subtitle={subtitleParts.join(' • ')}
65
+ status={status}
66
+ href={props.href}
67
+ id={props.id}
68
+ className={props.className}
69
+ dataKind="deal"
70
+ >
71
+ <div className="space-y-2">
72
+ <KeyValueList items={items} />
73
+ {props.description ? (
74
+ <p className="line-clamp-2 text-muted-foreground">{props.description}</p>
75
+ ) : null}
76
+ {props.tags && props.tags.length > 0 ? <TagRow tags={props.tags} /> : null}
77
+ </div>
78
+ </RecordCardShell>
79
+ )
80
+ }
81
+
82
+ export default DealCard
83
+
84
+ // Re-export icons consumers may want when extending the layout
85
+ export { Briefcase, Building2, CalendarDays, CircleDollarSign, User }
@@ -0,0 +1,77 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Mail, Phone, User as UserIcon } from 'lucide-react'
5
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { Avatar } from '../../primitives/avatar'
7
+ import { KeyValueList, RecordCardShell, TagRow, statusToTagVariant } from './RecordCardShell'
8
+ import type { PersonRecordPayload } from './types'
9
+
10
+ export interface PersonCardProps extends PersonRecordPayload {}
11
+
12
+ export function PersonCard(props: PersonCardProps) {
13
+ const t = useT()
14
+ const status = props.status
15
+ ? { label: props.status, variant: statusToTagVariant(props.status) }
16
+ : null
17
+
18
+ const items = [
19
+ props.title ? { label: t('ai_assistant.chat.records.fields.title', 'Title'), value: props.title } : null,
20
+ props.companyName ? { label: t('ai_assistant.chat.records.fields.company', 'Company'), value: props.companyName } : null,
21
+ props.email
22
+ ? {
23
+ label: t('ai_assistant.chat.records.fields.email', 'Email'),
24
+ value: (
25
+ <a
26
+ href={`mailto:${props.email}`}
27
+ className="text-primary hover:underline"
28
+ onClick={(event) => event.stopPropagation()}
29
+ >
30
+ {props.email}
31
+ </a>
32
+ ),
33
+ }
34
+ : null,
35
+ props.phone
36
+ ? {
37
+ label: t('ai_assistant.chat.records.fields.phone', 'Phone'),
38
+ value: (
39
+ <a
40
+ href={`tel:${props.phone.replace(/\s+/g, '')}`}
41
+ className="text-primary hover:underline"
42
+ onClick={(event) => event.stopPropagation()}
43
+ >
44
+ {props.phone}
45
+ </a>
46
+ ),
47
+ }
48
+ : null,
49
+ props.ownerName ? { label: t('ai_assistant.chat.records.fields.owner', 'Owner'), value: props.ownerName } : null,
50
+ ].filter(Boolean) as { label: string; value: React.ReactNode }[]
51
+
52
+ const subtitle = [props.title, props.companyName].filter(Boolean).join(' • ')
53
+
54
+ return (
55
+ <RecordCardShell
56
+ kindLabel={t('ai_assistant.chat.records.kinds.person', 'Person')}
57
+ kindIcon={<UserIcon className="size-4" aria-hidden />}
58
+ leading={<Avatar label={props.name} src={props.avatarUrl ?? undefined} size="md" />}
59
+ title={props.name}
60
+ subtitle={subtitle || undefined}
61
+ status={status}
62
+ href={props.href}
63
+ id={props.id}
64
+ className={props.className}
65
+ dataKind="person"
66
+ >
67
+ <div className="space-y-2">
68
+ <KeyValueList items={items} />
69
+ {props.tags && props.tags.length > 0 ? <TagRow tags={props.tags} /> : null}
70
+ </div>
71
+ </RecordCardShell>
72
+ )
73
+ }
74
+
75
+ export default PersonCard
76
+
77
+ export { Mail, Phone, UserIcon }