@startsimpli/ui 0.4.11 → 0.4.14
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 +3 -1
- package/src/components/__tests__/calendar-view.test.tsx +97 -0
- package/src/components/__tests__/upcoming-meetings.test.tsx +104 -0
- package/src/components/calendar/calendar-view.tsx +222 -0
- package/src/components/calendar/index.ts +13 -0
- package/src/components/calendar/upcoming-meetings.tsx +211 -0
- package/src/components/enrichment/ApolloEnrichButton.tsx +280 -0
- package/src/components/enrichment/__tests__/ApolloEnrichButton.test.tsx +153 -0
- package/src/components/enrichment/index.ts +7 -0
- package/src/components/index.ts +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.14",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -66,6 +66,7 @@
|
|
|
66
66
|
"date-fns": "^3.6.0",
|
|
67
67
|
"dompurify": "^3.4.1",
|
|
68
68
|
"lucide-react": "^0.408.0",
|
|
69
|
+
"react-big-calendar": "^1.19.4",
|
|
69
70
|
"react-day-picker": "^9.14.0",
|
|
70
71
|
"tailwind-merge": "^2.6.1",
|
|
71
72
|
"tailwindcss-animate": "^1.0.7",
|
|
@@ -81,6 +82,7 @@
|
|
|
81
82
|
"@types/jest": "^30.0.0",
|
|
82
83
|
"@types/node": "^20.19.39",
|
|
83
84
|
"@types/react": "^19.2.14",
|
|
85
|
+
"@types/react-big-calendar": "^1.16.3",
|
|
84
86
|
"@types/react-dom": "^19.2.3",
|
|
85
87
|
"eslint": "^8.57.1",
|
|
86
88
|
"identity-obj-proxy": "^3.0.0",
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { CalendarView, type CalendarEvent } from '../calendar/calendar-view'
|
|
3
|
+
|
|
4
|
+
const FIXED_DATE = new Date('2026-04-15T12:00:00Z')
|
|
5
|
+
|
|
6
|
+
const sampleEvents: CalendarEvent[] = [
|
|
7
|
+
{
|
|
8
|
+
id: 'meet-1',
|
|
9
|
+
title: 'Investor Sync — Sequoia',
|
|
10
|
+
start: new Date('2026-04-15T16:00:00Z'),
|
|
11
|
+
end: new Date('2026-04-15T16:30:00Z'),
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
id: 'meet-2',
|
|
15
|
+
title: 'Demo with Acme',
|
|
16
|
+
start: '2026-04-16T17:00:00Z',
|
|
17
|
+
end: '2026-04-16T17:45:00Z',
|
|
18
|
+
},
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
describe('CalendarView', () => {
|
|
22
|
+
it('renders without crashing in month view', () => {
|
|
23
|
+
render(
|
|
24
|
+
<CalendarView
|
|
25
|
+
events={sampleEvents}
|
|
26
|
+
defaultDate={FIXED_DATE}
|
|
27
|
+
defaultView="month"
|
|
28
|
+
/>
|
|
29
|
+
)
|
|
30
|
+
// The toolbar's "Today" button is always rendered by react-big-calendar
|
|
31
|
+
expect(screen.getByText('Today')).toBeInTheDocument()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders event titles on the grid', () => {
|
|
35
|
+
render(
|
|
36
|
+
<CalendarView
|
|
37
|
+
events={sampleEvents}
|
|
38
|
+
defaultDate={FIXED_DATE}
|
|
39
|
+
defaultView="month"
|
|
40
|
+
/>
|
|
41
|
+
)
|
|
42
|
+
expect(screen.getByText(/Investor Sync — Sequoia/)).toBeInTheDocument()
|
|
43
|
+
expect(screen.getByText(/Demo with Acme/)).toBeInTheDocument()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('fires onEventClick with the original event when an event is clicked', () => {
|
|
47
|
+
const onEventClick = jest.fn()
|
|
48
|
+
render(
|
|
49
|
+
<CalendarView
|
|
50
|
+
events={sampleEvents}
|
|
51
|
+
defaultDate={FIXED_DATE}
|
|
52
|
+
defaultView="month"
|
|
53
|
+
onEventClick={onEventClick}
|
|
54
|
+
/>
|
|
55
|
+
)
|
|
56
|
+
fireEvent.click(screen.getByText(/Investor Sync — Sequoia/))
|
|
57
|
+
expect(onEventClick).toHaveBeenCalledTimes(1)
|
|
58
|
+
expect(onEventClick.mock.calls[0][0].id).toBe('meet-1')
|
|
59
|
+
expect(onEventClick.mock.calls[0][0].title).toBe('Investor Sync — Sequoia')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('accepts string dates in events and normalizes them', () => {
|
|
63
|
+
// Verifies the toDate / normalizeEvent path
|
|
64
|
+
render(
|
|
65
|
+
<CalendarView
|
|
66
|
+
events={[
|
|
67
|
+
{ id: 'x', title: 'Stringy', start: '2026-04-15T16:00:00Z' },
|
|
68
|
+
]}
|
|
69
|
+
defaultDate={FIXED_DATE}
|
|
70
|
+
defaultView="month"
|
|
71
|
+
/>
|
|
72
|
+
)
|
|
73
|
+
expect(screen.getByText(/Stringy/)).toBeInTheDocument()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('passes through resource payload to onEventClick', () => {
|
|
77
|
+
interface MyMeeting {
|
|
78
|
+
uuid: string
|
|
79
|
+
attendees: number
|
|
80
|
+
}
|
|
81
|
+
const meeting: MyMeeting = { uuid: 'abc-123', attendees: 5 }
|
|
82
|
+
const onEventClick = jest.fn()
|
|
83
|
+
render(
|
|
84
|
+
<CalendarView<MyMeeting>
|
|
85
|
+
events={[
|
|
86
|
+
{ id: 'r1', title: 'WithResource', start: FIXED_DATE, resource: meeting },
|
|
87
|
+
]}
|
|
88
|
+
defaultDate={FIXED_DATE}
|
|
89
|
+
defaultView="month"
|
|
90
|
+
onEventClick={onEventClick}
|
|
91
|
+
/>
|
|
92
|
+
)
|
|
93
|
+
fireEvent.click(screen.getByText(/WithResource/))
|
|
94
|
+
expect(onEventClick).toHaveBeenCalledTimes(1)
|
|
95
|
+
expect(onEventClick.mock.calls[0][0].resource).toEqual(meeting)
|
|
96
|
+
})
|
|
97
|
+
})
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
2
|
+
import { UpcomingMeetings, type UpcomingMeeting } from '../calendar/upcoming-meetings'
|
|
3
|
+
|
|
4
|
+
const sampleMeetings: UpcomingMeeting[] = [
|
|
5
|
+
{
|
|
6
|
+
id: 1,
|
|
7
|
+
title: 'Sequoia partner sync',
|
|
8
|
+
scheduled_at: '2026-04-29T16:00:00Z',
|
|
9
|
+
status: 'confirmed',
|
|
10
|
+
investor_name: 'Roelof Botha',
|
|
11
|
+
investor_email: 'roelof@sequoia.com',
|
|
12
|
+
meeting_link: 'https://meet.google.com/abc',
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
id: 2,
|
|
16
|
+
title: 'Acme partner intro',
|
|
17
|
+
scheduled_at: '2026-04-30T14:00:00Z',
|
|
18
|
+
status: 'pending',
|
|
19
|
+
investor_email: 'partner@acme.vc',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 3,
|
|
23
|
+
title: 'Cancelled call',
|
|
24
|
+
scheduled_at: '2026-05-01T10:00:00Z',
|
|
25
|
+
status: 'cancelled',
|
|
26
|
+
},
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
describe('UpcomingMeetings', () => {
|
|
30
|
+
it('renders the title and each meeting title', () => {
|
|
31
|
+
render(<UpcomingMeetings meetings={sampleMeetings} />)
|
|
32
|
+
expect(screen.getByText('Upcoming Meetings')).toBeInTheDocument()
|
|
33
|
+
expect(screen.getByText('Sequoia partner sync')).toBeInTheDocument()
|
|
34
|
+
expect(screen.getByText('Acme partner intro')).toBeInTheDocument()
|
|
35
|
+
expect(screen.getByText('Cancelled call')).toBeInTheDocument()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('shows the right status badge per meeting', () => {
|
|
39
|
+
render(<UpcomingMeetings meetings={sampleMeetings} />)
|
|
40
|
+
expect(screen.getByText('Confirmed')).toBeInTheDocument()
|
|
41
|
+
expect(screen.getByText('Pending')).toBeInTheDocument()
|
|
42
|
+
expect(screen.getByText('Cancelled')).toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('renders skeletons in loading state', () => {
|
|
46
|
+
const { container } = render(
|
|
47
|
+
<UpcomingMeetings meetings={[]} loading loadingRowCount={5} />
|
|
48
|
+
)
|
|
49
|
+
// Skeletons render as empty divs with animate-pulse — count them by class.
|
|
50
|
+
const skeletons = container.querySelectorAll('.animate-pulse')
|
|
51
|
+
expect(skeletons).toHaveLength(5)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('renders empty state when no meetings', () => {
|
|
55
|
+
render(<UpcomingMeetings meetings={[]} />)
|
|
56
|
+
expect(screen.getByText('No upcoming meetings')).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('does not render Schedule CTA when onSchedule is not provided', () => {
|
|
60
|
+
render(<UpcomingMeetings meetings={[]} />)
|
|
61
|
+
expect(screen.queryByText('Schedule one')).not.toBeInTheDocument()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('renders Schedule CTA when onSchedule is provided and fires it on click', () => {
|
|
65
|
+
const onSchedule = jest.fn()
|
|
66
|
+
render(<UpcomingMeetings meetings={[]} onSchedule={onSchedule} />)
|
|
67
|
+
const cta = screen.getByText('Schedule one')
|
|
68
|
+
expect(cta).toBeInTheDocument()
|
|
69
|
+
fireEvent.click(cta)
|
|
70
|
+
expect(onSchedule).toHaveBeenCalledTimes(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('fires onMeetingClick with the original meeting payload', () => {
|
|
74
|
+
const onMeetingClick = jest.fn()
|
|
75
|
+
render(<UpcomingMeetings meetings={sampleMeetings} onMeetingClick={onMeetingClick} />)
|
|
76
|
+
fireEvent.click(screen.getByText('Sequoia partner sync'))
|
|
77
|
+
expect(onMeetingClick).toHaveBeenCalledTimes(1)
|
|
78
|
+
expect(onMeetingClick.mock.calls[0][0].id).toBe(1)
|
|
79
|
+
expect(onMeetingClick.mock.calls[0][0].investor_name).toBe('Roelof Botha')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('renders View all link when viewAllHref is provided', () => {
|
|
83
|
+
render(<UpcomingMeetings meetings={sampleMeetings} viewAllHref="/calendar" />)
|
|
84
|
+
const link = screen.getByText('View all')
|
|
85
|
+
expect(link.closest('a')).toHaveAttribute('href', '/calendar')
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('renders Join link when meeting_link is set', () => {
|
|
89
|
+
render(<UpcomingMeetings meetings={sampleMeetings} />)
|
|
90
|
+
const join = screen.getByText('Join')
|
|
91
|
+
expect(join.closest('a')).toHaveAttribute('href', 'https://meet.google.com/abc')
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it("falls back to investor_email when investor_name is absent", () => {
|
|
95
|
+
render(<UpcomingMeetings meetings={sampleMeetings} />)
|
|
96
|
+
expect(screen.getByText('partner@acme.vc')).toBeInTheDocument()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('uses a custom title when title prop is provided', () => {
|
|
100
|
+
render(<UpcomingMeetings meetings={[]} title="This Week's Calls" />)
|
|
101
|
+
expect(screen.getByText("This Week's Calls")).toBeInTheDocument()
|
|
102
|
+
expect(screen.queryByText('Upcoming Meetings')).not.toBeInTheDocument()
|
|
103
|
+
})
|
|
104
|
+
})
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* CalendarView — shared month/week/day event grid.
|
|
5
|
+
*
|
|
6
|
+
* Wraps react-big-calendar with a date-fns localizer pre-wired and a
|
|
7
|
+
* simplified event shape so consumers don't have to reach for raw RBC types.
|
|
8
|
+
*
|
|
9
|
+
* IMPORTANT: react-big-calendar ships its own CSS. Consumers must import
|
|
10
|
+
* `react-big-calendar/lib/css/react-big-calendar.css` once at the app level
|
|
11
|
+
* (e.g. in _app.tsx / layout.tsx). This package does NOT auto-inject CSS so
|
|
12
|
+
* consumers can layer Tailwind overrides predictably.
|
|
13
|
+
*
|
|
14
|
+
* lifecycle:calendar-ui (raise-simpli-k4u)
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import * as React from "react"
|
|
18
|
+
import {
|
|
19
|
+
Calendar,
|
|
20
|
+
dateFnsLocalizer,
|
|
21
|
+
Views,
|
|
22
|
+
type View,
|
|
23
|
+
type SlotInfo,
|
|
24
|
+
} from "react-big-calendar"
|
|
25
|
+
import { format, parse, startOfWeek, getDay } from "date-fns"
|
|
26
|
+
import { enUS } from "date-fns/locale"
|
|
27
|
+
|
|
28
|
+
const locales = { "en-US": enUS }
|
|
29
|
+
|
|
30
|
+
const localizer = dateFnsLocalizer({
|
|
31
|
+
format,
|
|
32
|
+
parse,
|
|
33
|
+
startOfWeek: () => startOfWeek(new Date(), { weekStartsOn: 0 }),
|
|
34
|
+
getDay,
|
|
35
|
+
locales,
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
/** Domain-friendly event shape callers pass in. */
|
|
39
|
+
export interface CalendarEvent<T = unknown> {
|
|
40
|
+
/** Stable identifier — passed through to onEventClick payload */
|
|
41
|
+
id: string | number
|
|
42
|
+
/** Event title shown on the grid */
|
|
43
|
+
title: string
|
|
44
|
+
/** Event start (Date or ISO string) */
|
|
45
|
+
start: Date | string
|
|
46
|
+
/** Event end. If omitted, defaults to start + 30min */
|
|
47
|
+
end?: Date | string
|
|
48
|
+
/** Whether to render as all-day across the top of the day */
|
|
49
|
+
allDay?: boolean
|
|
50
|
+
/** Optional pass-through payload (the original ScheduledMeeting / etc.) */
|
|
51
|
+
resource?: T
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface InternalRBCEvent {
|
|
55
|
+
id: string | number
|
|
56
|
+
title: string
|
|
57
|
+
start: Date
|
|
58
|
+
end: Date
|
|
59
|
+
allDay: boolean
|
|
60
|
+
resource: unknown
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DEFAULT_DURATION_MS = 30 * 60 * 1000
|
|
64
|
+
|
|
65
|
+
function toDate(value: Date | string): Date {
|
|
66
|
+
return value instanceof Date ? value : new Date(value)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function normalizeEvent(e: CalendarEvent): InternalRBCEvent {
|
|
70
|
+
const start = toDate(e.start)
|
|
71
|
+
const end = e.end ? toDate(e.end) : new Date(start.getTime() + DEFAULT_DURATION_MS)
|
|
72
|
+
return {
|
|
73
|
+
id: e.id,
|
|
74
|
+
title: e.title,
|
|
75
|
+
start,
|
|
76
|
+
end,
|
|
77
|
+
allDay: e.allDay ?? false,
|
|
78
|
+
resource: e.resource,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type CalendarViewMode = "month" | "week" | "day" | "agenda"
|
|
83
|
+
|
|
84
|
+
const VIEW_BY_MODE: Record<CalendarViewMode, View> = {
|
|
85
|
+
month: Views.MONTH,
|
|
86
|
+
week: Views.WEEK,
|
|
87
|
+
day: Views.DAY,
|
|
88
|
+
agenda: Views.AGENDA,
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface CalendarViewProps<T = unknown> {
|
|
92
|
+
/** Events to render on the grid */
|
|
93
|
+
events: CalendarEvent<T>[]
|
|
94
|
+
/** Currently navigated date (controlled) */
|
|
95
|
+
date?: Date
|
|
96
|
+
/** Default date if uncontrolled */
|
|
97
|
+
defaultDate?: Date
|
|
98
|
+
/** Active view (controlled) */
|
|
99
|
+
view?: CalendarViewMode
|
|
100
|
+
/** Default view if uncontrolled */
|
|
101
|
+
defaultView?: CalendarViewMode
|
|
102
|
+
/** Allowed views the toolbar can switch between */
|
|
103
|
+
views?: CalendarViewMode[]
|
|
104
|
+
/** Fires when the user clicks an event. Receives the original event passed in. */
|
|
105
|
+
onEventClick?: (event: CalendarEvent<T>) => void
|
|
106
|
+
/** Fires when the user clicks an empty slot (or drags to select a range). */
|
|
107
|
+
onSlotClick?: (slot: { start: Date; end: Date; action: "click" | "select" }) => void
|
|
108
|
+
/** Fires when the user navigates to a new date. */
|
|
109
|
+
onNavigate?: (date: Date) => void
|
|
110
|
+
/** Fires when the user changes view. */
|
|
111
|
+
onViewChange?: (view: CalendarViewMode) => void
|
|
112
|
+
/** Allow drag-to-select empty time slots (defaults true). */
|
|
113
|
+
selectable?: boolean
|
|
114
|
+
/** Min height for the grid (default 600px). */
|
|
115
|
+
minHeight?: number
|
|
116
|
+
/** className passed to the wrapping div for Tailwind overrides. */
|
|
117
|
+
className?: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Shared calendar grid. Pre-wired with date-fns localizer.
|
|
122
|
+
*
|
|
123
|
+
* @example
|
|
124
|
+
* import "react-big-calendar/lib/css/react-big-calendar.css"
|
|
125
|
+
* import { CalendarView } from "@startsimpli/ui"
|
|
126
|
+
*
|
|
127
|
+
* <CalendarView
|
|
128
|
+
* events={meetings.map(m => ({
|
|
129
|
+
* id: m.id,
|
|
130
|
+
* title: m.title,
|
|
131
|
+
* start: m.scheduled_at,
|
|
132
|
+
* end: addMinutes(new Date(m.scheduled_at), m.duration_minutes),
|
|
133
|
+
* resource: m,
|
|
134
|
+
* }))}
|
|
135
|
+
* onEventClick={e => openMeetingDialog(e.resource)}
|
|
136
|
+
* onSlotClick={s => openCreateMeetingDialog(s.start)}
|
|
137
|
+
* />
|
|
138
|
+
*/
|
|
139
|
+
export function CalendarView<T = unknown>({
|
|
140
|
+
events,
|
|
141
|
+
date,
|
|
142
|
+
defaultDate,
|
|
143
|
+
view,
|
|
144
|
+
defaultView,
|
|
145
|
+
views,
|
|
146
|
+
onEventClick,
|
|
147
|
+
onSlotClick,
|
|
148
|
+
onNavigate,
|
|
149
|
+
onViewChange,
|
|
150
|
+
selectable = true,
|
|
151
|
+
minHeight = 600,
|
|
152
|
+
className,
|
|
153
|
+
}: CalendarViewProps<T>) {
|
|
154
|
+
const normalized = React.useMemo(() => events.map(normalizeEvent), [events])
|
|
155
|
+
|
|
156
|
+
// Map our public CalendarEvent[] back from the internal event RBC hands us
|
|
157
|
+
// on click — keeps the consumer API clean (they only see what they passed).
|
|
158
|
+
const eventByInternalId = React.useMemo(() => {
|
|
159
|
+
const map = new Map<string | number, CalendarEvent<T>>()
|
|
160
|
+
for (const e of events) map.set(e.id, e)
|
|
161
|
+
return map
|
|
162
|
+
}, [events])
|
|
163
|
+
|
|
164
|
+
const handleSelectEvent = React.useCallback(
|
|
165
|
+
(rbcEvent: object) => {
|
|
166
|
+
const id = (rbcEvent as InternalRBCEvent).id
|
|
167
|
+
const original = eventByInternalId.get(id)
|
|
168
|
+
if (original && onEventClick) onEventClick(original)
|
|
169
|
+
},
|
|
170
|
+
[eventByInternalId, onEventClick],
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
const handleSelectSlot = React.useCallback(
|
|
174
|
+
(slot: SlotInfo) => {
|
|
175
|
+
if (!onSlotClick) return
|
|
176
|
+
onSlotClick({
|
|
177
|
+
start: slot.start as Date,
|
|
178
|
+
end: slot.end as Date,
|
|
179
|
+
action: slot.action === "select" ? "select" : "click",
|
|
180
|
+
})
|
|
181
|
+
},
|
|
182
|
+
[onSlotClick],
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
const handleViewChange = React.useCallback(
|
|
186
|
+
(v: View) => {
|
|
187
|
+
if (!onViewChange) return
|
|
188
|
+
const mode = (Object.entries(VIEW_BY_MODE).find(([, val]) => val === v)?.[0] ?? "month") as CalendarViewMode
|
|
189
|
+
onViewChange(mode)
|
|
190
|
+
},
|
|
191
|
+
[onViewChange],
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
const allowedViews = React.useMemo<View[]>(
|
|
195
|
+
() => (views ?? ["month", "week", "day", "agenda"]).map(v => VIEW_BY_MODE[v]),
|
|
196
|
+
[views],
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<div className={className} style={{ minHeight }}>
|
|
201
|
+
<Calendar
|
|
202
|
+
localizer={localizer}
|
|
203
|
+
events={normalized}
|
|
204
|
+
startAccessor="start"
|
|
205
|
+
endAccessor="end"
|
|
206
|
+
titleAccessor="title"
|
|
207
|
+
allDayAccessor="allDay"
|
|
208
|
+
date={date}
|
|
209
|
+
defaultDate={defaultDate}
|
|
210
|
+
view={view ? VIEW_BY_MODE[view] : undefined}
|
|
211
|
+
defaultView={defaultView ? VIEW_BY_MODE[defaultView] : undefined}
|
|
212
|
+
views={allowedViews}
|
|
213
|
+
onSelectEvent={handleSelectEvent}
|
|
214
|
+
onSelectSlot={handleSelectSlot}
|
|
215
|
+
onNavigate={onNavigate}
|
|
216
|
+
onView={handleViewChange}
|
|
217
|
+
selectable={selectable}
|
|
218
|
+
style={{ height: "100%", minHeight }}
|
|
219
|
+
/>
|
|
220
|
+
</div>
|
|
221
|
+
)
|
|
222
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { CalendarView } from "./calendar-view"
|
|
2
|
+
export type {
|
|
3
|
+
CalendarEvent,
|
|
4
|
+
CalendarViewMode,
|
|
5
|
+
CalendarViewProps,
|
|
6
|
+
} from "./calendar-view"
|
|
7
|
+
|
|
8
|
+
export { UpcomingMeetings } from "./upcoming-meetings"
|
|
9
|
+
export type {
|
|
10
|
+
UpcomingMeeting,
|
|
11
|
+
UpcomingMeetingStatus,
|
|
12
|
+
UpcomingMeetingsProps,
|
|
13
|
+
} from "./upcoming-meetings"
|
|
@@ -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 {
|
|
35
|
+
meetings: UpcomingMeeting[]
|
|
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: UpcomingMeeting) => 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({
|
|
86
|
+
meetings,
|
|
87
|
+
loading = false,
|
|
88
|
+
onMeetingClick,
|
|
89
|
+
viewAllHref,
|
|
90
|
+
onViewAll,
|
|
91
|
+
onSchedule,
|
|
92
|
+
className,
|
|
93
|
+
title = "Upcoming Meetings",
|
|
94
|
+
loadingRowCount = 3,
|
|
95
|
+
}: UpcomingMeetingsProps) {
|
|
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,280 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { Sparkles, AlertCircle, CheckCircle2, MinusCircle } from 'lucide-react'
|
|
5
|
+
import {
|
|
6
|
+
Dialog,
|
|
7
|
+
DialogContent,
|
|
8
|
+
DialogHeader,
|
|
9
|
+
DialogFooter,
|
|
10
|
+
DialogTitle,
|
|
11
|
+
DialogDescription,
|
|
12
|
+
} from '../ui/dialog'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Minimal subset of the API client surface this component depends on.
|
|
16
|
+
* Lets consumers pass either the full `EnrichmentApi` from `@startsimpli/api`
|
|
17
|
+
* or a custom adapter — keeps this component decoupled from the singleton.
|
|
18
|
+
*/
|
|
19
|
+
export interface ApolloEnrichmentClient {
|
|
20
|
+
enrichApollo(contactIds: string[]): Promise<ApolloEnrichmentSummary>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Mirrors `ApolloEnrichmentSummary` from @startsimpli/api. */
|
|
24
|
+
export interface ApolloEnrichmentSummary {
|
|
25
|
+
total: number
|
|
26
|
+
enriched: string[]
|
|
27
|
+
skipped: Array<{ contact_id: string; reason: string }>
|
|
28
|
+
errors: Array<{ contact_id: string; error: string }>
|
|
29
|
+
missing: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ApolloEnrichButtonProps {
|
|
33
|
+
/** Contact ids to enrich. Disabled when empty. */
|
|
34
|
+
contactIds: string[]
|
|
35
|
+
/** API client with `enrichApollo`. Pass `api.enrichment` from @startsimpli/api. */
|
|
36
|
+
enrichmentApi: ApolloEnrichmentClient
|
|
37
|
+
/** Called once the API call resolves (success or with per-entry errors). */
|
|
38
|
+
onComplete?: (summary: ApolloEnrichmentSummary) => void
|
|
39
|
+
/** Called when the dialog is closed (regardless of completion). */
|
|
40
|
+
onClose?: () => void
|
|
41
|
+
label?: string
|
|
42
|
+
size?: 'sm' | 'md'
|
|
43
|
+
className?: string
|
|
44
|
+
disabled?: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Phase =
|
|
48
|
+
| { kind: 'idle' }
|
|
49
|
+
| { kind: 'running' }
|
|
50
|
+
| { kind: 'done'; summary: ApolloEnrichmentSummary }
|
|
51
|
+
| { kind: 'error'; message: string }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Trigger Apollo enrichment for a set of contacts. Opens a dialog showing
|
|
55
|
+
* progress + the per-bucket summary (enriched / skipped / errors / missing).
|
|
56
|
+
*
|
|
57
|
+
* App-agnostic — works for any consumer that can supply an
|
|
58
|
+
* `ApolloEnrichmentClient`.
|
|
59
|
+
*/
|
|
60
|
+
export function ApolloEnrichButton({
|
|
61
|
+
contactIds,
|
|
62
|
+
enrichmentApi,
|
|
63
|
+
onComplete,
|
|
64
|
+
onClose,
|
|
65
|
+
label = 'Enrich with Apollo',
|
|
66
|
+
size = 'sm',
|
|
67
|
+
className = '',
|
|
68
|
+
disabled = false,
|
|
69
|
+
}: ApolloEnrichButtonProps) {
|
|
70
|
+
const [open, setOpen] = React.useState(false)
|
|
71
|
+
const [phase, setPhase] = React.useState<Phase>({ kind: 'idle' })
|
|
72
|
+
|
|
73
|
+
const sizeClasses =
|
|
74
|
+
size === 'sm' ? 'px-3 py-1.5 text-xs' : 'px-4 py-2 text-sm'
|
|
75
|
+
const iconClasses = size === 'sm' ? 'w-3 h-3' : 'w-4 h-4'
|
|
76
|
+
|
|
77
|
+
const isEmpty = contactIds.length === 0
|
|
78
|
+
const isRunning = phase.kind === 'running'
|
|
79
|
+
const buttonDisabled = disabled || isEmpty || isRunning
|
|
80
|
+
|
|
81
|
+
const handleClick = async () => {
|
|
82
|
+
setOpen(true)
|
|
83
|
+
setPhase({ kind: 'running' })
|
|
84
|
+
try {
|
|
85
|
+
const summary = await enrichmentApi.enrichApollo(contactIds)
|
|
86
|
+
setPhase({ kind: 'done', summary })
|
|
87
|
+
onComplete?.(summary)
|
|
88
|
+
} catch (err) {
|
|
89
|
+
setPhase({
|
|
90
|
+
kind: 'error',
|
|
91
|
+
message: err instanceof Error ? err.message : 'Enrichment failed',
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const handleOpenChange = (next: boolean) => {
|
|
97
|
+
setOpen(next)
|
|
98
|
+
if (!next) {
|
|
99
|
+
onClose?.()
|
|
100
|
+
// Reset phase after the dialog finishes closing so the next click
|
|
101
|
+
// starts fresh, but keep the result visible until then.
|
|
102
|
+
setTimeout(() => setPhase({ kind: 'idle' }), 200)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<>
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={handleClick}
|
|
111
|
+
disabled={buttonDisabled}
|
|
112
|
+
aria-label={label}
|
|
113
|
+
className={`flex items-center gap-1.5 font-medium text-primary-700 bg-primary-50 hover:bg-primary-100 rounded-md disabled:opacity-50 disabled:cursor-not-allowed transition-colors ${sizeClasses} ${className}`}
|
|
114
|
+
>
|
|
115
|
+
<Sparkles className={iconClasses} />
|
|
116
|
+
{isRunning ? 'Enriching…' : label}
|
|
117
|
+
{!isEmpty && (
|
|
118
|
+
<span className="ml-1 text-[0.65rem] opacity-75">
|
|
119
|
+
({contactIds.length})
|
|
120
|
+
</span>
|
|
121
|
+
)}
|
|
122
|
+
</button>
|
|
123
|
+
|
|
124
|
+
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
125
|
+
<DialogContent>
|
|
126
|
+
<DialogHeader>
|
|
127
|
+
<DialogTitle>Apollo enrichment</DialogTitle>
|
|
128
|
+
<DialogDescription>
|
|
129
|
+
{phase.kind === 'running'
|
|
130
|
+
? `Enriching ${contactIds.length} contact${contactIds.length === 1 ? '' : 's'}…`
|
|
131
|
+
: phase.kind === 'done'
|
|
132
|
+
? 'Apollo enrichment complete.'
|
|
133
|
+
: phase.kind === 'error'
|
|
134
|
+
? 'Apollo enrichment failed.'
|
|
135
|
+
: ''}
|
|
136
|
+
</DialogDescription>
|
|
137
|
+
</DialogHeader>
|
|
138
|
+
|
|
139
|
+
<div className="space-y-3 py-2">
|
|
140
|
+
{phase.kind === 'running' && (
|
|
141
|
+
<div role="status" aria-live="polite" className="text-sm text-gray-600">
|
|
142
|
+
Reaching Apollo for {contactIds.length} contact
|
|
143
|
+
{contactIds.length === 1 ? '' : 's'}. This may take a few seconds.
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{phase.kind === 'error' && (
|
|
148
|
+
<div role="alert" className="flex items-start gap-2 text-sm text-red-700">
|
|
149
|
+
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
|
150
|
+
<span>{phase.message}</span>
|
|
151
|
+
</div>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{phase.kind === 'done' && (
|
|
155
|
+
<ApolloEnrichmentSummaryView summary={phase.summary} />
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<DialogFooter>
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
onClick={() => handleOpenChange(false)}
|
|
163
|
+
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-md"
|
|
164
|
+
>
|
|
165
|
+
{phase.kind === 'running' ? 'Hide' : 'Done'}
|
|
166
|
+
</button>
|
|
167
|
+
</DialogFooter>
|
|
168
|
+
</DialogContent>
|
|
169
|
+
</Dialog>
|
|
170
|
+
</>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function ApolloEnrichmentSummaryView({
|
|
175
|
+
summary,
|
|
176
|
+
}: {
|
|
177
|
+
summary: ApolloEnrichmentSummary
|
|
178
|
+
}) {
|
|
179
|
+
const enrichedCount = summary.enriched.length
|
|
180
|
+
const skippedCount = summary.skipped.length
|
|
181
|
+
const errorCount = summary.errors.length
|
|
182
|
+
const missingCount = summary.missing.length
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<div className="space-y-3 text-sm">
|
|
186
|
+
<div className="grid grid-cols-2 gap-2">
|
|
187
|
+
<Stat
|
|
188
|
+
icon={<CheckCircle2 className="w-4 h-4 text-green-600" />}
|
|
189
|
+
label="Enriched"
|
|
190
|
+
value={enrichedCount}
|
|
191
|
+
/>
|
|
192
|
+
<Stat
|
|
193
|
+
icon={<MinusCircle className="w-4 h-4 text-gray-500" />}
|
|
194
|
+
label="Skipped"
|
|
195
|
+
value={skippedCount}
|
|
196
|
+
/>
|
|
197
|
+
<Stat
|
|
198
|
+
icon={<AlertCircle className="w-4 h-4 text-red-600" />}
|
|
199
|
+
label="Errored"
|
|
200
|
+
value={errorCount}
|
|
201
|
+
/>
|
|
202
|
+
<Stat
|
|
203
|
+
icon={<MinusCircle className="w-4 h-4 text-amber-600" />}
|
|
204
|
+
label="Not accessible"
|
|
205
|
+
value={missingCount}
|
|
206
|
+
/>
|
|
207
|
+
</div>
|
|
208
|
+
|
|
209
|
+
{summary.skipped.length > 0 && (
|
|
210
|
+
<DetailsList
|
|
211
|
+
title="Skipped reasons"
|
|
212
|
+
items={summary.skipped.map((s) => ({
|
|
213
|
+
id: s.contact_id,
|
|
214
|
+
text: s.reason,
|
|
215
|
+
}))}
|
|
216
|
+
/>
|
|
217
|
+
)}
|
|
218
|
+
{summary.errors.length > 0 && (
|
|
219
|
+
<DetailsList
|
|
220
|
+
title="Errors"
|
|
221
|
+
items={summary.errors.map((e) => ({
|
|
222
|
+
id: e.contact_id,
|
|
223
|
+
text: e.error,
|
|
224
|
+
}))}
|
|
225
|
+
tone="error"
|
|
226
|
+
/>
|
|
227
|
+
)}
|
|
228
|
+
</div>
|
|
229
|
+
)
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function Stat({
|
|
233
|
+
icon,
|
|
234
|
+
label,
|
|
235
|
+
value,
|
|
236
|
+
}: {
|
|
237
|
+
icon: React.ReactNode
|
|
238
|
+
label: string
|
|
239
|
+
value: number
|
|
240
|
+
}) {
|
|
241
|
+
return (
|
|
242
|
+
<div className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-md">
|
|
243
|
+
{icon}
|
|
244
|
+
<div>
|
|
245
|
+
<div className="text-xs text-gray-500">{label}</div>
|
|
246
|
+
<div className="text-sm font-medium">{value}</div>
|
|
247
|
+
</div>
|
|
248
|
+
</div>
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function DetailsList({
|
|
253
|
+
title,
|
|
254
|
+
items,
|
|
255
|
+
tone = 'neutral',
|
|
256
|
+
}: {
|
|
257
|
+
title: string
|
|
258
|
+
items: Array<{ id: string; text: string }>
|
|
259
|
+
tone?: 'neutral' | 'error'
|
|
260
|
+
}) {
|
|
261
|
+
const toneClasses =
|
|
262
|
+
tone === 'error'
|
|
263
|
+
? 'border-red-200 bg-red-50 text-red-800'
|
|
264
|
+
: 'border-gray-200 bg-gray-50 text-gray-800'
|
|
265
|
+
return (
|
|
266
|
+
<details className={`border rounded-md ${toneClasses}`}>
|
|
267
|
+
<summary className="px-3 py-2 text-xs font-medium cursor-pointer">
|
|
268
|
+
{title} ({items.length})
|
|
269
|
+
</summary>
|
|
270
|
+
<ul className="px-3 py-2 space-y-1 max-h-40 overflow-auto text-xs">
|
|
271
|
+
{items.map((item, i) => (
|
|
272
|
+
<li key={`${item.id}-${i}`} className="font-mono">
|
|
273
|
+
<span className="opacity-50">{item.id.slice(0, 8)}…</span>{' '}
|
|
274
|
+
<span className="font-sans">{item.text}</span>
|
|
275
|
+
</li>
|
|
276
|
+
))}
|
|
277
|
+
</ul>
|
|
278
|
+
</details>
|
|
279
|
+
)
|
|
280
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
2
|
+
import {
|
|
3
|
+
ApolloEnrichButton,
|
|
4
|
+
type ApolloEnrichmentClient,
|
|
5
|
+
type ApolloEnrichmentSummary,
|
|
6
|
+
} from '../ApolloEnrichButton'
|
|
7
|
+
|
|
8
|
+
function makeClient(
|
|
9
|
+
resolveWith: ApolloEnrichmentSummary | Promise<ApolloEnrichmentSummary> = {
|
|
10
|
+
total: 0,
|
|
11
|
+
enriched: [],
|
|
12
|
+
skipped: [],
|
|
13
|
+
errors: [],
|
|
14
|
+
missing: [],
|
|
15
|
+
},
|
|
16
|
+
): ApolloEnrichmentClient & { enrichApollo: jest.Mock } {
|
|
17
|
+
const enrichApollo = jest
|
|
18
|
+
.fn()
|
|
19
|
+
.mockReturnValue(
|
|
20
|
+
resolveWith instanceof Promise ? resolveWith : Promise.resolve(resolveWith),
|
|
21
|
+
)
|
|
22
|
+
return { enrichApollo }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
describe('ApolloEnrichButton', () => {
|
|
27
|
+
it('renders the button label and contact count', () => {
|
|
28
|
+
const client = makeClient()
|
|
29
|
+
render(
|
|
30
|
+
<ApolloEnrichButton contactIds={['c-1', 'c-2', 'c-3']} enrichmentApi={client} />,
|
|
31
|
+
)
|
|
32
|
+
expect(screen.getByText(/Enrich with Apollo/i)).toBeInTheDocument()
|
|
33
|
+
expect(screen.getByText('(3)')).toBeInTheDocument()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('disables the button when no contactIds', () => {
|
|
37
|
+
const client = makeClient()
|
|
38
|
+
render(<ApolloEnrichButton contactIds={[]} enrichmentApi={client} />)
|
|
39
|
+
const btn = screen.getByRole('button', { name: /Enrich with Apollo/i })
|
|
40
|
+
expect(btn).toBeDisabled()
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('disables the button when disabled=true', () => {
|
|
44
|
+
const client = makeClient()
|
|
45
|
+
render(
|
|
46
|
+
<ApolloEnrichButton contactIds={['c-1']} enrichmentApi={client} disabled />,
|
|
47
|
+
)
|
|
48
|
+
expect(screen.getByRole('button', { name: /Enrich with Apollo/i })).toBeDisabled()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('opens dialog and calls enrichApollo with contactIds on click', async () => {
|
|
52
|
+
const summary: ApolloEnrichmentSummary = {
|
|
53
|
+
total: 2,
|
|
54
|
+
enriched: ['c-1', 'c-2'],
|
|
55
|
+
skipped: [],
|
|
56
|
+
errors: [],
|
|
57
|
+
missing: [],
|
|
58
|
+
}
|
|
59
|
+
const client = makeClient(summary)
|
|
60
|
+
render(
|
|
61
|
+
<ApolloEnrichButton contactIds={['c-1', 'c-2']} enrichmentApi={client} />,
|
|
62
|
+
)
|
|
63
|
+
fireEvent.click(screen.getByRole('button', { name: /Enrich with Apollo/i }))
|
|
64
|
+
|
|
65
|
+
expect(client.enrichApollo).toHaveBeenCalledWith(['c-1', 'c-2'])
|
|
66
|
+
|
|
67
|
+
await waitFor(() => {
|
|
68
|
+
expect(screen.getByText(/Apollo enrichment complete/i)).toBeInTheDocument()
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('shows skipped reasons in the summary', async () => {
|
|
73
|
+
const summary: ApolloEnrichmentSummary = {
|
|
74
|
+
total: 1,
|
|
75
|
+
enriched: [],
|
|
76
|
+
skipped: [{ contact_id: 'c-1', reason: 'insufficient query fields' }],
|
|
77
|
+
errors: [],
|
|
78
|
+
missing: [],
|
|
79
|
+
}
|
|
80
|
+
const client = makeClient(summary)
|
|
81
|
+
render(<ApolloEnrichButton contactIds={['c-1']} enrichmentApi={client} />)
|
|
82
|
+
fireEvent.click(screen.getByRole('button', { name: /Enrich with Apollo/i }))
|
|
83
|
+
|
|
84
|
+
await waitFor(() => {
|
|
85
|
+
expect(screen.getByText(/Skipped reasons/i)).toBeInTheDocument()
|
|
86
|
+
expect(screen.getByText(/insufficient query fields/i)).toBeInTheDocument()
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('shows errors in the summary', async () => {
|
|
91
|
+
const summary: ApolloEnrichmentSummary = {
|
|
92
|
+
total: 1,
|
|
93
|
+
enriched: [],
|
|
94
|
+
skipped: [],
|
|
95
|
+
errors: [{ contact_id: 'c-1', error: 'Apollo error: 401' }],
|
|
96
|
+
missing: [],
|
|
97
|
+
}
|
|
98
|
+
const client = makeClient(summary)
|
|
99
|
+
render(<ApolloEnrichButton contactIds={['c-1']} enrichmentApi={client} />)
|
|
100
|
+
fireEvent.click(screen.getByRole('button', { name: /Enrich with Apollo/i }))
|
|
101
|
+
|
|
102
|
+
await waitFor(() => {
|
|
103
|
+
expect(screen.getByText(/Errored/i)).toBeInTheDocument()
|
|
104
|
+
expect(screen.getByText(/Apollo error: 401/i)).toBeInTheDocument()
|
|
105
|
+
})
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('shows error alert when API call rejects', async () => {
|
|
109
|
+
const client = makeClient(Promise.reject(new Error('network down')))
|
|
110
|
+
render(<ApolloEnrichButton contactIds={['c-1']} enrichmentApi={client} />)
|
|
111
|
+
fireEvent.click(screen.getByRole('button', { name: /Enrich with Apollo/i }))
|
|
112
|
+
|
|
113
|
+
await waitFor(() => {
|
|
114
|
+
expect(screen.getByRole('alert')).toHaveTextContent(/network down/i)
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('calls onComplete with the summary', async () => {
|
|
119
|
+
const summary: ApolloEnrichmentSummary = {
|
|
120
|
+
total: 1,
|
|
121
|
+
enriched: ['c-1'],
|
|
122
|
+
skipped: [],
|
|
123
|
+
errors: [],
|
|
124
|
+
missing: [],
|
|
125
|
+
}
|
|
126
|
+
const client = makeClient(summary)
|
|
127
|
+
const onComplete = jest.fn()
|
|
128
|
+
render(
|
|
129
|
+
<ApolloEnrichButton
|
|
130
|
+
contactIds={['c-1']}
|
|
131
|
+
enrichmentApi={client}
|
|
132
|
+
onComplete={onComplete}
|
|
133
|
+
/>,
|
|
134
|
+
)
|
|
135
|
+
fireEvent.click(screen.getByRole('button', { name: /Enrich with Apollo/i }))
|
|
136
|
+
|
|
137
|
+
await waitFor(() => {
|
|
138
|
+
expect(onComplete).toHaveBeenCalledWith(summary)
|
|
139
|
+
})
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('honors a custom label', () => {
|
|
143
|
+
const client = makeClient()
|
|
144
|
+
render(
|
|
145
|
+
<ApolloEnrichButton
|
|
146
|
+
contactIds={['c-1']}
|
|
147
|
+
enrichmentApi={client}
|
|
148
|
+
label="Custom Label"
|
|
149
|
+
/>,
|
|
150
|
+
)
|
|
151
|
+
expect(screen.getByRole('button', { name: /Custom Label/i })).toBeInTheDocument()
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -6,3 +6,10 @@ export type { EnrichButtonProps } from './EnrichButton'
|
|
|
6
6
|
|
|
7
7
|
export { EnrichmentProgress } from './EnrichmentProgress'
|
|
8
8
|
export type { EnrichmentProgressProps, QueueStatus as EnrichmentQueueStatus } from './EnrichmentProgress'
|
|
9
|
+
|
|
10
|
+
export { ApolloEnrichButton } from './ApolloEnrichButton'
|
|
11
|
+
export type {
|
|
12
|
+
ApolloEnrichButtonProps,
|
|
13
|
+
ApolloEnrichmentClient,
|
|
14
|
+
ApolloEnrichmentSummary,
|
|
15
|
+
} from './ApolloEnrichButton'
|
package/src/components/index.ts
CHANGED
|
@@ -110,6 +110,9 @@ export * from './lists'
|
|
|
110
110
|
// Pipeline components (StageTransitionModal)
|
|
111
111
|
export * from './pipeline'
|
|
112
112
|
|
|
113
|
+
// Calendar (month/week/day event grid wrapping react-big-calendar)
|
|
114
|
+
export * from './calendar'
|
|
115
|
+
|
|
113
116
|
// Activity components (timeline, quick log, log dialog)
|
|
114
117
|
export { ActivityTimeline } from './ActivityTimeline'
|
|
115
118
|
export type { ActivityTimelineProps, ActivityTimelineItem } from './ActivityTimeline'
|