@startsimpli/ui 0.4.13 → 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/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
|
+
}
|
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'
|