@startsimpli/ui 0.4.13 → 0.4.15
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/README.md +457 -398
- package/package.json +20 -13
- package/src/components/__tests__/calendar-view-popup.test.tsx +42 -0
- package/src/components/__tests__/calendar-view.test.tsx +97 -0
- package/src/components/__tests__/chat.test.tsx +129 -0
- package/src/components/__tests__/meetings-list.test.tsx +114 -0
- package/src/components/__tests__/slide-deck-viewer.test.tsx +82 -0
- package/src/components/__tests__/upcoming-meetings.test.tsx +104 -0
- package/src/components/__tests__/workspace.test.tsx +106 -0
- package/src/components/account/__tests__/account.test.tsx +5 -32
- package/src/components/account/change-password-form.tsx +1 -28
- package/src/components/calendar/calendar-view.tsx +253 -0
- package/src/components/calendar/index.ts +20 -0
- package/src/components/calendar/meetings-list.tsx +202 -0
- package/src/components/calendar/upcoming-meetings.tsx +211 -0
- package/src/components/chat/ChatComposer.tsx +113 -0
- package/src/components/chat/ChatMessage.tsx +81 -0
- package/src/components/chat/ChatThread.tsx +57 -0
- package/src/components/chat/index.ts +12 -0
- package/src/components/chat/types.ts +20 -0
- package/src/components/index.ts +16 -0
- package/src/components/slide-deck/SlideCanvas.tsx +68 -0
- package/src/components/slide-deck/SlideDeckViewer.tsx +144 -0
- package/src/components/slide-deck/SlideFilmstrip.tsx +73 -0
- package/src/components/slide-deck/index.ts +7 -0
- package/src/components/slide-deck/types.ts +18 -0
- package/src/components/team/DomainClaimCard.tsx +170 -0
- package/src/components/team/InviteMemberDialog.tsx +182 -0
- package/src/components/team/LeaveTeamDialog.tsx +130 -0
- package/src/components/team/MembersTable.tsx +138 -0
- package/src/components/team/OrgSwitcher.tsx +68 -0
- package/src/components/team/PendingInvitationCallout.tsx +106 -0
- package/src/components/team/RoleSelector.tsx +68 -0
- package/src/components/team/__tests__/team-components.test.tsx +352 -0
- package/src/components/team/domain-claim-card-default-class-names.ts +45 -0
- package/src/components/team/index.ts +57 -0
- package/src/components/team/invite-member-dialog-default-class-names.ts +41 -0
- package/src/components/team/leave-team-dialog-default-class-names.ts +33 -0
- package/src/components/team/members-table-default-class-names.ts +39 -0
- package/src/components/team/org-switcher-default-class-names.ts +13 -0
- package/src/components/team/pending-invitation-callout-default-class-names.ts +22 -0
- package/src/components/team/role-selector-default-class-names.ts +11 -0
- package/src/components/team/types.ts +97 -0
- package/src/components/workflows/ExecNodeDetails.tsx +83 -0
- package/src/components/workflows/ExecutionTimeline.tsx +146 -0
- package/src/components/workflows/NodeInspector.tsx +257 -0
- package/src/components/workflows/NodePalette.tsx +119 -0
- package/src/components/workflows/WorkflowCanvas.tsx +113 -0
- package/src/components/workflows/WorkflowEdge.tsx +65 -0
- package/src/components/workflows/WorkflowEditor.tsx +130 -0
- package/src/components/workflows/WorkflowNode.tsx +198 -0
- package/src/components/workflows/WorkflowRunViewer.tsx +81 -0
- package/src/components/workflows/__tests__/ExecutionTimeline.test.tsx +99 -0
- package/src/components/workflows/__tests__/NodeInspector.test.tsx +74 -0
- package/src/components/workflows/__tests__/NodePalette.test.tsx +46 -0
- package/src/components/workflows/__tests__/WorkflowCanvas.test.tsx +59 -0
- package/src/components/workflows/__tests__/WorkflowNode.test.tsx +92 -0
- package/src/components/workflows/__tests__/WorkflowRunViewer.test.tsx +138 -0
- package/src/components/workflows/__tests__/auto-layout.test.ts +107 -0
- package/src/components/workflows/__tests__/serialization.test.ts +278 -0
- package/src/components/workflows/exec-status.ts +90 -0
- package/src/components/workflows/hooks/useCanvasGraph.ts +70 -0
- package/src/components/workflows/hooks/useNodeStatusOverlay.ts +47 -0
- package/src/components/workflows/index.ts +78 -0
- package/src/components/workflows/layout/auto-layout.ts +142 -0
- package/src/components/workflows/node-icons.ts +31 -0
- package/src/components/workflows/serialization.ts +171 -0
- package/src/components/workflows/theme/categories.ts +96 -0
- package/src/components/workflows/types.ts +231 -0
- package/src/components/workflows/workflows.css +29 -0
- package/src/components/workspace/DualPaneWorkspace.tsx +187 -0
- package/src/components/workspace/SplitPane.tsx +174 -0
- package/src/components/workspace/index.ts +4 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MeetingsList — table-style meeting list with status badges, join links,
|
|
5
|
+
* and an optional delete action.
|
|
6
|
+
*
|
|
7
|
+
* Distinct from UpcomingMeetings (sidebar card-stack); this is the
|
|
8
|
+
* full-width table-row layout used by /calendar's "All Meetings" section
|
|
9
|
+
* and /fundraises/[id]/outreach's MeetingsTab (Upcoming + Past lists).
|
|
10
|
+
*
|
|
11
|
+
* Pure presentational. The consumer owns sort, filter, fetch, and delete
|
|
12
|
+
* confirmation.
|
|
13
|
+
*
|
|
14
|
+
* lifecycle:calendar-ui (raise-simpli-9cp)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as React from "react"
|
|
18
|
+
import { CalendarDays, ExternalLink, Plus, Trash2 } from "lucide-react"
|
|
19
|
+
import { Button } from "../ui/button"
|
|
20
|
+
import { cn } from "../../utils"
|
|
21
|
+
|
|
22
|
+
export type MeetingsListStatus = "pending" | "confirmed" | "cancelled" | "completed"
|
|
23
|
+
|
|
24
|
+
export interface MeetingsListItem {
|
|
25
|
+
id: string | number
|
|
26
|
+
title: string
|
|
27
|
+
/** ISO 8601 timestamp string */
|
|
28
|
+
scheduled_at: string
|
|
29
|
+
status: MeetingsListStatus
|
|
30
|
+
duration_minutes?: number
|
|
31
|
+
investor_name?: string
|
|
32
|
+
investor_email?: string
|
|
33
|
+
meeting_link?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MeetingsListProps<T extends MeetingsListItem = MeetingsListItem> {
|
|
37
|
+
meetings: T[]
|
|
38
|
+
/** Whether the list is still loading. */
|
|
39
|
+
loading?: boolean
|
|
40
|
+
/** Number of skeleton rows to show while loading. Defaults to 4. */
|
|
41
|
+
loadingRowCount?: number
|
|
42
|
+
/** If set, each row gets a delete (trash) button that calls this with the id. */
|
|
43
|
+
onDelete?: (id: string | number) => void
|
|
44
|
+
/** If set, the whole row becomes clickable. */
|
|
45
|
+
onRowClick?: (meeting: T) => void
|
|
46
|
+
/** If set, the empty state shows a "Schedule First Meeting" CTA wired to this. */
|
|
47
|
+
onSchedule?: () => void
|
|
48
|
+
/** Empty-state copy. Defaults to "No meetings scheduled yet." */
|
|
49
|
+
emptyMessage?: string
|
|
50
|
+
/** className passed to the wrapping div. */
|
|
51
|
+
className?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const STATUS_BADGE: Record<MeetingsListStatus, { label: string; className: string }> = {
|
|
55
|
+
pending: { label: "Pending", className: "bg-yellow-100 text-yellow-800" },
|
|
56
|
+
confirmed: { label: "Confirmed", className: "bg-green-100 text-green-800" },
|
|
57
|
+
cancelled: { label: "Cancelled", className: "bg-red-100 text-red-800" },
|
|
58
|
+
completed: { label: "Completed", className: "bg-gray-100 text-gray-800" },
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatDate(isoStr: string): string {
|
|
62
|
+
return new Date(isoStr).toLocaleDateString("en-US", {
|
|
63
|
+
weekday: "short",
|
|
64
|
+
month: "short",
|
|
65
|
+
day: "numeric",
|
|
66
|
+
year: "numeric",
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function formatTime(isoStr: string): string {
|
|
71
|
+
return new Date(isoStr).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @example
|
|
76
|
+
* <MeetingsList
|
|
77
|
+
* meetings={meetings}
|
|
78
|
+
* loading={loading}
|
|
79
|
+
* onDelete={id => deleteMeeting(id)}
|
|
80
|
+
* onSchedule={() => setShowSchedule(true)}
|
|
81
|
+
* />
|
|
82
|
+
*/
|
|
83
|
+
export function MeetingsList<T extends MeetingsListItem = MeetingsListItem>({
|
|
84
|
+
meetings,
|
|
85
|
+
loading = false,
|
|
86
|
+
loadingRowCount = 4,
|
|
87
|
+
onDelete,
|
|
88
|
+
onRowClick,
|
|
89
|
+
onSchedule,
|
|
90
|
+
emptyMessage = "No meetings scheduled yet.",
|
|
91
|
+
className,
|
|
92
|
+
}: MeetingsListProps<T>) {
|
|
93
|
+
if (loading) {
|
|
94
|
+
return (
|
|
95
|
+
<div className={cn("p-4 space-y-2", className)}>
|
|
96
|
+
{Array.from({ length: loadingRowCount }).map((_, i) => (
|
|
97
|
+
<div key={i} className="h-12 bg-muted animate-pulse rounded" />
|
|
98
|
+
))}
|
|
99
|
+
</div>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (meetings.length === 0) {
|
|
104
|
+
return (
|
|
105
|
+
<div className={cn("text-center py-12 text-muted-foreground", className)}>
|
|
106
|
+
<CalendarDays className="h-10 w-10 mx-auto mb-3 opacity-40" />
|
|
107
|
+
<p>{emptyMessage}</p>
|
|
108
|
+
{onSchedule && (
|
|
109
|
+
<Button className="mt-3" onClick={onSchedule}>
|
|
110
|
+
<Plus className="mr-2 h-4 w-4" />
|
|
111
|
+
Schedule First Meeting
|
|
112
|
+
</Button>
|
|
113
|
+
)}
|
|
114
|
+
</div>
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className={cn("divide-y", className)}>
|
|
120
|
+
{meetings.map(m => {
|
|
121
|
+
const badge = STATUS_BADGE[m.status]
|
|
122
|
+
const interactive = !!onRowClick
|
|
123
|
+
return (
|
|
124
|
+
<div
|
|
125
|
+
key={m.id}
|
|
126
|
+
role={interactive ? "button" : undefined}
|
|
127
|
+
tabIndex={interactive ? 0 : undefined}
|
|
128
|
+
onClick={interactive ? () => onRowClick(m) : undefined}
|
|
129
|
+
onKeyDown={
|
|
130
|
+
interactive
|
|
131
|
+
? e => {
|
|
132
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
133
|
+
e.preventDefault()
|
|
134
|
+
onRowClick(m)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
: undefined
|
|
138
|
+
}
|
|
139
|
+
className={cn(
|
|
140
|
+
"flex items-center justify-between px-4 py-3 transition-colors",
|
|
141
|
+
interactive && "hover:bg-muted/50 cursor-pointer",
|
|
142
|
+
!interactive && "hover:bg-muted/50",
|
|
143
|
+
)}
|
|
144
|
+
>
|
|
145
|
+
<div className="flex items-center gap-4 min-w-0">
|
|
146
|
+
<div className="min-w-0">
|
|
147
|
+
<p className="font-medium text-sm truncate">{m.title}</p>
|
|
148
|
+
{(m.investor_name || m.investor_email) && (
|
|
149
|
+
<p className="text-xs text-muted-foreground truncate">
|
|
150
|
+
{m.investor_name || m.investor_email}
|
|
151
|
+
</p>
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
|
|
156
|
+
<div className="flex items-center gap-4 shrink-0 ml-4">
|
|
157
|
+
<div className="text-right text-xs text-muted-foreground hidden sm:block">
|
|
158
|
+
<p>{formatDate(m.scheduled_at)}</p>
|
|
159
|
+
<p>
|
|
160
|
+
{formatTime(m.scheduled_at)}
|
|
161
|
+
{m.duration_minutes ? ` · ${m.duration_minutes}min` : ""}
|
|
162
|
+
</p>
|
|
163
|
+
</div>
|
|
164
|
+
|
|
165
|
+
<span className={cn("text-xs px-2 py-0.5 rounded font-medium", badge.className)}>
|
|
166
|
+
{badge.label}
|
|
167
|
+
</span>
|
|
168
|
+
|
|
169
|
+
{m.meeting_link && (
|
|
170
|
+
<a
|
|
171
|
+
href={m.meeting_link}
|
|
172
|
+
target="_blank"
|
|
173
|
+
rel="noreferrer"
|
|
174
|
+
onClick={e => e.stopPropagation()}
|
|
175
|
+
className="text-primary"
|
|
176
|
+
title="Join meeting"
|
|
177
|
+
>
|
|
178
|
+
<ExternalLink className="h-4 w-4" />
|
|
179
|
+
</a>
|
|
180
|
+
)}
|
|
181
|
+
|
|
182
|
+
{onDelete && (
|
|
183
|
+
<Button
|
|
184
|
+
variant="ghost"
|
|
185
|
+
size="sm"
|
|
186
|
+
onClick={e => {
|
|
187
|
+
e.stopPropagation()
|
|
188
|
+
onDelete(m.id)
|
|
189
|
+
}}
|
|
190
|
+
className="h-7 w-7 p-0 text-muted-foreground hover:text-red-600"
|
|
191
|
+
aria-label="Delete meeting"
|
|
192
|
+
>
|
|
193
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
194
|
+
</Button>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
)
|
|
199
|
+
})}
|
|
200
|
+
</div>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* UpcomingMeetings — sidebar/widget listing the next N meetings with status,
|
|
5
|
+
* date, time, and an optional join-link.
|
|
6
|
+
*
|
|
7
|
+
* Pure presentational. The consumer owns the data fetch + filtering — pass
|
|
8
|
+
* the already-filtered, already-sorted meetings via the `meetings` prop. This
|
|
9
|
+
* keeps the component decoupled from any specific API client or data shape
|
|
10
|
+
* beyond the small interface declared below.
|
|
11
|
+
*
|
|
12
|
+
* lifecycle:calendar-ui (raise-simpli-azc)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as React from "react"
|
|
16
|
+
import { CalendarDays, Clock, ExternalLink, Plus } from "lucide-react"
|
|
17
|
+
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"
|
|
18
|
+
import { Button } from "../ui/button"
|
|
19
|
+
import { cn } from "../../utils"
|
|
20
|
+
|
|
21
|
+
export type UpcomingMeetingStatus = "pending" | "confirmed" | "cancelled" | "completed"
|
|
22
|
+
|
|
23
|
+
export interface UpcomingMeeting {
|
|
24
|
+
id: string | number
|
|
25
|
+
title: string
|
|
26
|
+
/** ISO 8601 timestamp string */
|
|
27
|
+
scheduled_at: string
|
|
28
|
+
status: UpcomingMeetingStatus
|
|
29
|
+
investor_name?: string
|
|
30
|
+
investor_email?: string
|
|
31
|
+
meeting_link?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UpcomingMeetingsProps<T extends UpcomingMeeting = UpcomingMeeting> {
|
|
35
|
+
meetings: T[]
|
|
36
|
+
/** Whether the list is still loading. Renders skeletons. */
|
|
37
|
+
loading?: boolean
|
|
38
|
+
/** Optional click handler when a meeting row is clicked (whole row). */
|
|
39
|
+
onMeetingClick?: (meeting: T) => void
|
|
40
|
+
/** Optional URL for the "View all" header link. Hidden if absent. */
|
|
41
|
+
viewAllHref?: string
|
|
42
|
+
/** Optional click handler for the "View all" header link. Used instead of href if provided. */
|
|
43
|
+
onViewAll?: () => void
|
|
44
|
+
/** Optional click handler for the empty-state "Schedule one" CTA. Hidden if absent. */
|
|
45
|
+
onSchedule?: () => void
|
|
46
|
+
/** Custom class name for the outer Card. */
|
|
47
|
+
className?: string
|
|
48
|
+
/** Title shown in the card header. Defaults to "Upcoming Meetings". */
|
|
49
|
+
title?: string
|
|
50
|
+
/** Number of skeleton rows to show while loading. Defaults to 3. */
|
|
51
|
+
loadingRowCount?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const STATUS_BADGE: Record<UpcomingMeetingStatus, { label: string; className: string }> = {
|
|
55
|
+
pending: { label: "Pending", className: "bg-yellow-100 text-yellow-800" },
|
|
56
|
+
confirmed: { label: "Confirmed", className: "bg-green-100 text-green-800" },
|
|
57
|
+
cancelled: { label: "Cancelled", className: "bg-red-100 text-red-800" },
|
|
58
|
+
completed: { label: "Completed", className: "bg-gray-100 text-gray-800" },
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatDate(isoStr: string): string {
|
|
62
|
+
return new Date(isoStr).toLocaleDateString("en-US", {
|
|
63
|
+
weekday: "short",
|
|
64
|
+
month: "short",
|
|
65
|
+
day: "numeric",
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function formatTime(isoStr: string): string {
|
|
70
|
+
return new Date(isoStr).toLocaleTimeString("en-US", {
|
|
71
|
+
hour: "2-digit",
|
|
72
|
+
minute: "2-digit",
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @example
|
|
78
|
+
* <UpcomingMeetings
|
|
79
|
+
* meetings={upcoming}
|
|
80
|
+
* loading={isLoading}
|
|
81
|
+
* viewAllHref="/calendar"
|
|
82
|
+
* onSchedule={() => setShowSchedule(true)}
|
|
83
|
+
* />
|
|
84
|
+
*/
|
|
85
|
+
export function UpcomingMeetings<T extends UpcomingMeeting = UpcomingMeeting>({
|
|
86
|
+
meetings,
|
|
87
|
+
loading = false,
|
|
88
|
+
onMeetingClick,
|
|
89
|
+
viewAllHref,
|
|
90
|
+
onViewAll,
|
|
91
|
+
onSchedule,
|
|
92
|
+
className,
|
|
93
|
+
title = "Upcoming Meetings",
|
|
94
|
+
loadingRowCount = 3,
|
|
95
|
+
}: UpcomingMeetingsProps<T>) {
|
|
96
|
+
return (
|
|
97
|
+
<Card className={className}>
|
|
98
|
+
<CardHeader className="pb-3">
|
|
99
|
+
<div className="flex items-center justify-between">
|
|
100
|
+
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
|
101
|
+
<CalendarDays className="h-4 w-4" />
|
|
102
|
+
{title}
|
|
103
|
+
</CardTitle>
|
|
104
|
+
{(viewAllHref || onViewAll) && (
|
|
105
|
+
<Button
|
|
106
|
+
variant="ghost"
|
|
107
|
+
size="sm"
|
|
108
|
+
className="h-7 text-xs"
|
|
109
|
+
asChild={!!viewAllHref && !onViewAll}
|
|
110
|
+
onClick={onViewAll}
|
|
111
|
+
>
|
|
112
|
+
{viewAllHref && !onViewAll ? <a href={viewAllHref}>View all</a> : <span>View all</span>}
|
|
113
|
+
</Button>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
</CardHeader>
|
|
117
|
+
|
|
118
|
+
<CardContent className="pt-0">
|
|
119
|
+
{loading ? (
|
|
120
|
+
<div className="space-y-2">
|
|
121
|
+
{Array.from({ length: loadingRowCount }).map((_, i) => (
|
|
122
|
+
<div key={i} className="h-14 bg-muted animate-pulse rounded-md" />
|
|
123
|
+
))}
|
|
124
|
+
</div>
|
|
125
|
+
) : meetings.length === 0 ? (
|
|
126
|
+
<div className="text-center py-6 text-muted-foreground">
|
|
127
|
+
<CalendarDays className="h-8 w-8 mx-auto mb-2 opacity-40" />
|
|
128
|
+
<p className="text-xs">No upcoming meetings</p>
|
|
129
|
+
{onSchedule && (
|
|
130
|
+
<Button variant="outline" size="sm" className="mt-3 h-7 text-xs" onClick={onSchedule}>
|
|
131
|
+
<Plus className="mr-1 h-3 w-3" />
|
|
132
|
+
Schedule one
|
|
133
|
+
</Button>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
) : (
|
|
137
|
+
<div className="space-y-2">
|
|
138
|
+
{meetings.map(m => {
|
|
139
|
+
const badge = STATUS_BADGE[m.status]
|
|
140
|
+
const interactive = !!onMeetingClick
|
|
141
|
+
return (
|
|
142
|
+
<div
|
|
143
|
+
key={m.id}
|
|
144
|
+
role={interactive ? "button" : undefined}
|
|
145
|
+
tabIndex={interactive ? 0 : undefined}
|
|
146
|
+
onClick={interactive ? () => onMeetingClick(m) : undefined}
|
|
147
|
+
onKeyDown={
|
|
148
|
+
interactive
|
|
149
|
+
? e => {
|
|
150
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
151
|
+
e.preventDefault()
|
|
152
|
+
onMeetingClick(m)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
: undefined
|
|
156
|
+
}
|
|
157
|
+
className={cn(
|
|
158
|
+
"border rounded-lg p-2.5 space-y-1 transition-colors",
|
|
159
|
+
interactive && "hover:bg-muted/40 cursor-pointer",
|
|
160
|
+
)}
|
|
161
|
+
>
|
|
162
|
+
<div className="flex items-start justify-between gap-2">
|
|
163
|
+
<p className="font-medium text-xs leading-tight line-clamp-1">{m.title}</p>
|
|
164
|
+
<span
|
|
165
|
+
className={cn(
|
|
166
|
+
"text-[10px] px-1.5 py-0.5 rounded font-medium whitespace-nowrap shrink-0",
|
|
167
|
+
badge.className,
|
|
168
|
+
)}
|
|
169
|
+
>
|
|
170
|
+
{badge.label}
|
|
171
|
+
</span>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{(m.investor_name || m.investor_email) && (
|
|
175
|
+
<p className="text-[11px] text-muted-foreground truncate">
|
|
176
|
+
{m.investor_name || m.investor_email}
|
|
177
|
+
</p>
|
|
178
|
+
)}
|
|
179
|
+
|
|
180
|
+
<div className="flex items-center gap-3 text-[11px] text-muted-foreground">
|
|
181
|
+
<span className="flex items-center gap-1">
|
|
182
|
+
<CalendarDays className="h-3 w-3" />
|
|
183
|
+
{formatDate(m.scheduled_at)}
|
|
184
|
+
</span>
|
|
185
|
+
<span className="flex items-center gap-1">
|
|
186
|
+
<Clock className="h-3 w-3" />
|
|
187
|
+
{formatTime(m.scheduled_at)}
|
|
188
|
+
</span>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
{m.meeting_link && (
|
|
192
|
+
<a
|
|
193
|
+
href={m.meeting_link}
|
|
194
|
+
target="_blank"
|
|
195
|
+
rel="noreferrer"
|
|
196
|
+
onClick={e => e.stopPropagation()}
|
|
197
|
+
className="flex items-center gap-1 text-[11px] text-primary hover:underline"
|
|
198
|
+
>
|
|
199
|
+
<ExternalLink className="h-3 w-3" />
|
|
200
|
+
Join
|
|
201
|
+
</a>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
)
|
|
205
|
+
})}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
</CardContent>
|
|
209
|
+
</Card>
|
|
210
|
+
)
|
|
211
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { SendHorizonal } from 'lucide-react'
|
|
5
|
+
import { cn } from '../../lib/utils'
|
|
6
|
+
|
|
7
|
+
export interface ChatComposerProps {
|
|
8
|
+
/** Controlled value. Omit for uncontrolled usage. */
|
|
9
|
+
value?: string
|
|
10
|
+
onChange?: (value: string) => void
|
|
11
|
+
/** Called with the trimmed message text on submit. */
|
|
12
|
+
onSubmit: (text: string) => void
|
|
13
|
+
placeholder?: string
|
|
14
|
+
/** Disables input entirely. */
|
|
15
|
+
disabled?: boolean
|
|
16
|
+
/** Submission in flight — input stays editable but submit is blocked. */
|
|
17
|
+
busy?: boolean
|
|
18
|
+
submitLabel?: string
|
|
19
|
+
/** Max rows the textarea grows to before scrolling. Default 10. */
|
|
20
|
+
maxRows?: number
|
|
21
|
+
className?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Chat input with auto-growing textarea. Enter submits, Shift+Enter inserts a
|
|
26
|
+
* newline, and large pastes are accepted as-is. Controlled or uncontrolled.
|
|
27
|
+
*/
|
|
28
|
+
export function ChatComposer({
|
|
29
|
+
value,
|
|
30
|
+
onChange,
|
|
31
|
+
onSubmit,
|
|
32
|
+
placeholder = 'Message…',
|
|
33
|
+
disabled = false,
|
|
34
|
+
busy = false,
|
|
35
|
+
submitLabel = 'Send',
|
|
36
|
+
maxRows = 10,
|
|
37
|
+
className,
|
|
38
|
+
}: ChatComposerProps) {
|
|
39
|
+
const isControlled = value !== undefined
|
|
40
|
+
const [internal, setInternal] = React.useState('')
|
|
41
|
+
const text = isControlled ? value! : internal
|
|
42
|
+
const textareaRef = React.useRef<HTMLTextAreaElement>(null)
|
|
43
|
+
|
|
44
|
+
const setText = (next: string) => {
|
|
45
|
+
if (!isControlled) setInternal(next)
|
|
46
|
+
onChange?.(next)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// auto-grow
|
|
50
|
+
React.useEffect(() => {
|
|
51
|
+
const el = textareaRef.current
|
|
52
|
+
if (!el) return
|
|
53
|
+
el.style.height = 'auto'
|
|
54
|
+
const lineHeight = 24
|
|
55
|
+
el.style.height = `${Math.min(el.scrollHeight, lineHeight * maxRows)}px`
|
|
56
|
+
}, [text, maxRows])
|
|
57
|
+
|
|
58
|
+
const canSubmit = !disabled && !busy && text.trim().length > 0
|
|
59
|
+
|
|
60
|
+
const submit = () => {
|
|
61
|
+
if (!canSubmit) return
|
|
62
|
+
onSubmit(text.trim())
|
|
63
|
+
if (!isControlled) setInternal('')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
67
|
+
if (e.key !== 'Enter') return
|
|
68
|
+
// Cmd/Ctrl+Enter always submits (handy for multi-line content).
|
|
69
|
+
if (e.metaKey || e.ctrlKey) {
|
|
70
|
+
e.preventDefault()
|
|
71
|
+
submit()
|
|
72
|
+
return
|
|
73
|
+
}
|
|
74
|
+
if (e.shiftKey) return // explicit newline
|
|
75
|
+
// Bare Enter submits ONLY single-line content. When the value already
|
|
76
|
+
// spans multiple lines (e.g. a pasted outline) Enter inserts a newline so
|
|
77
|
+
// the multi-line block stays intact — submit via the button or Cmd+Enter.
|
|
78
|
+
if (text.includes('\n')) return
|
|
79
|
+
e.preventDefault()
|
|
80
|
+
submit()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div className={cn('flex items-end gap-2 border-t border-border bg-background p-3', className)}>
|
|
85
|
+
<textarea
|
|
86
|
+
ref={textareaRef}
|
|
87
|
+
value={text}
|
|
88
|
+
onChange={(e) => setText(e.target.value)}
|
|
89
|
+
onKeyDown={onKeyDown}
|
|
90
|
+
placeholder={placeholder}
|
|
91
|
+
disabled={disabled}
|
|
92
|
+
rows={1}
|
|
93
|
+
className={cn(
|
|
94
|
+
'flex-1 resize-none rounded-lg border border-input bg-background px-3 py-2 text-sm shadow-sm',
|
|
95
|
+
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
|
96
|
+
'disabled:cursor-not-allowed disabled:opacity-50'
|
|
97
|
+
)}
|
|
98
|
+
/>
|
|
99
|
+
<button
|
|
100
|
+
type="button"
|
|
101
|
+
onClick={submit}
|
|
102
|
+
disabled={!canSubmit}
|
|
103
|
+
aria-label={submitLabel}
|
|
104
|
+
className={cn(
|
|
105
|
+
'inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-primary text-primary-foreground shadow-sm transition-opacity',
|
|
106
|
+
'hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40'
|
|
107
|
+
)}
|
|
108
|
+
>
|
|
109
|
+
<SendHorizonal className="h-4 w-4" aria-hidden="true" />
|
|
110
|
+
</button>
|
|
111
|
+
</div>
|
|
112
|
+
)
|
|
113
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Wrench } from 'lucide-react'
|
|
5
|
+
import { cn } from '../../lib/utils'
|
|
6
|
+
import type { ChatMessageData } from './types'
|
|
7
|
+
|
|
8
|
+
export interface ChatMessageProps {
|
|
9
|
+
message: ChatMessageData
|
|
10
|
+
/** Optional custom renderer for the message body (e.g. markdown). */
|
|
11
|
+
renderContent?: (content: string, message: ChatMessageData) => React.ReactNode
|
|
12
|
+
className?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function StreamingDots() {
|
|
16
|
+
return (
|
|
17
|
+
<span className="ml-1 inline-flex gap-0.5 align-middle" aria-hidden="true">
|
|
18
|
+
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.3s]" />
|
|
19
|
+
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current [animation-delay:-0.15s]" />
|
|
20
|
+
<span className="h-1.5 w-1.5 animate-bounce rounded-full bg-current" />
|
|
21
|
+
</span>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A single chat row. Text messages render as aligned bubbles (user right,
|
|
27
|
+
* assistant left); `tool` / `status` kinds render as compact centered system
|
|
28
|
+
* rows. Fully generic — no presentation-specific knowledge.
|
|
29
|
+
*/
|
|
30
|
+
export function ChatMessage({ message, renderContent, className }: ChatMessageProps) {
|
|
31
|
+
const { role, content, status, kind = 'text', toolName } = message
|
|
32
|
+
const isStreaming = status === 'streaming'
|
|
33
|
+
const isError = status === 'error'
|
|
34
|
+
|
|
35
|
+
if (kind === 'tool' || kind === 'status') {
|
|
36
|
+
return (
|
|
37
|
+
<div
|
|
38
|
+
data-role={role}
|
|
39
|
+
data-kind={kind}
|
|
40
|
+
data-status={status}
|
|
41
|
+
aria-busy={isStreaming || undefined}
|
|
42
|
+
className={cn(
|
|
43
|
+
'mx-auto flex max-w-[90%] items-center gap-2 rounded-md border border-border bg-muted/40 px-3 py-1.5 text-xs text-muted-foreground',
|
|
44
|
+
isError && 'border-red-300 bg-red-50 text-red-700',
|
|
45
|
+
className
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
{kind === 'tool' && <Wrench className="h-3.5 w-3.5 shrink-0" aria-hidden="true" />}
|
|
49
|
+
{toolName && <span className="font-medium text-foreground">{toolName}</span>}
|
|
50
|
+
<span className="truncate">{content}</span>
|
|
51
|
+
{isStreaming && <StreamingDots />}
|
|
52
|
+
</div>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const isUser = role === 'user'
|
|
57
|
+
const body = renderContent ? renderContent(content, message) : content
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div
|
|
61
|
+
data-role={role}
|
|
62
|
+
data-kind={kind}
|
|
63
|
+
data-status={status}
|
|
64
|
+
aria-busy={isStreaming || undefined}
|
|
65
|
+
className={cn('flex w-full', isUser ? 'justify-end' : 'justify-start', className)}
|
|
66
|
+
>
|
|
67
|
+
<div
|
|
68
|
+
className={cn(
|
|
69
|
+
'max-w-[85%] whitespace-pre-wrap break-words rounded-2xl px-4 py-2 text-sm shadow-sm',
|
|
70
|
+
isUser
|
|
71
|
+
? 'rounded-br-sm bg-primary text-primary-foreground'
|
|
72
|
+
: 'rounded-bl-sm bg-muted text-foreground',
|
|
73
|
+
isError && 'border border-red-300 bg-red-50 text-red-700'
|
|
74
|
+
)}
|
|
75
|
+
>
|
|
76
|
+
{body}
|
|
77
|
+
{isStreaming && <StreamingDots />}
|
|
78
|
+
</div>
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '../../lib/utils'
|
|
5
|
+
import { ChatMessage } from './ChatMessage'
|
|
6
|
+
import type { ChatMessageData } from './types'
|
|
7
|
+
|
|
8
|
+
export interface ChatThreadProps {
|
|
9
|
+
messages: ChatMessageData[]
|
|
10
|
+
/** Rendered when there are no messages. */
|
|
11
|
+
emptyState?: React.ReactNode
|
|
12
|
+
/** Custom message renderer; defaults to <ChatMessage>. */
|
|
13
|
+
renderMessage?: (message: ChatMessageData) => React.ReactNode
|
|
14
|
+
/** Auto-scroll to the newest message on change. Default true. */
|
|
15
|
+
autoScroll?: boolean
|
|
16
|
+
renderContent?: (content: string, message: ChatMessageData) => React.ReactNode
|
|
17
|
+
className?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Scrollable, auto-scrolling list of chat messages. App-agnostic.
|
|
22
|
+
*/
|
|
23
|
+
export function ChatThread({
|
|
24
|
+
messages,
|
|
25
|
+
emptyState,
|
|
26
|
+
renderMessage,
|
|
27
|
+
autoScroll = true,
|
|
28
|
+
renderContent,
|
|
29
|
+
className,
|
|
30
|
+
}: ChatThreadProps) {
|
|
31
|
+
const anchorRef = React.useRef<HTMLDivElement>(null)
|
|
32
|
+
|
|
33
|
+
React.useEffect(() => {
|
|
34
|
+
if (autoScroll) anchorRef.current?.scrollIntoView({ block: 'end' })
|
|
35
|
+
}, [messages, autoScroll])
|
|
36
|
+
|
|
37
|
+
if (messages.length === 0 && emptyState) {
|
|
38
|
+
return (
|
|
39
|
+
<div className={cn('flex h-full items-center justify-center p-6 text-center', className)}>
|
|
40
|
+
{emptyState}
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className={cn('flex h-full flex-col gap-3 overflow-y-auto p-4', className)} role="log" aria-live="polite">
|
|
47
|
+
{messages.map((m) =>
|
|
48
|
+
renderMessage ? (
|
|
49
|
+
<React.Fragment key={m.id}>{renderMessage(m)}</React.Fragment>
|
|
50
|
+
) : (
|
|
51
|
+
<ChatMessage key={m.id} message={m} renderContent={renderContent} />
|
|
52
|
+
)
|
|
53
|
+
)}
|
|
54
|
+
<div ref={anchorRef} data-testid="chat-thread-anchor" />
|
|
55
|
+
</div>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { ChatMessage } from './ChatMessage'
|
|
2
|
+
export type { ChatMessageProps } from './ChatMessage'
|
|
3
|
+
export { ChatThread } from './ChatThread'
|
|
4
|
+
export type { ChatThreadProps } from './ChatThread'
|
|
5
|
+
export { ChatComposer } from './ChatComposer'
|
|
6
|
+
export type { ChatComposerProps } from './ChatComposer'
|
|
7
|
+
export type {
|
|
8
|
+
ChatMessageData,
|
|
9
|
+
ChatRole,
|
|
10
|
+
ChatMessageStatus,
|
|
11
|
+
ChatMessageKind,
|
|
12
|
+
} from './types'
|