@startsimpli/ui 0.4.77 → 0.4.79
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 -2
- package/src/components/ActivityTimeline.tsx +302 -105
- package/src/components/activity/ActivityFeedPanel.tsx +208 -0
- package/src/components/activity/ActivityFilterBar.tsx +180 -0
- package/src/components/activity/__tests__/activity-feed.test.tsx +434 -0
- package/src/components/activity/activity-events.ts +179 -0
- package/src/components/activity/activity-feed-panel-default-class-names.ts +31 -0
- package/src/components/activity/activity-filter-bar-default-class-names.ts +34 -0
- package/src/components/activity/index.ts +51 -0
- package/src/components/activity/types.ts +94 -0
- package/src/components/index.ts +10 -2
- package/src/components/team/MembersSection.tsx +7 -2
- package/src/components/team/MembersTable.tsx +16 -0
- package/src/components/team/__tests__/member-lifecycle.test.tsx +34 -0
- package/src/components/team/members-table-default-class-names.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/ui",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.79",
|
|
4
4
|
"description": "Shared UI components package for StartSimpli applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"./theme/contract": "./theme/contract.css",
|
|
18
18
|
"./blog": "./src/components/blog/index.ts",
|
|
19
19
|
"./blog/resolve": "./src/components/blog/resolveArticle.ts",
|
|
20
|
+
"./activity": "./src/components/activity/index.ts",
|
|
20
21
|
"./collection": "./src/components/collection/index.ts",
|
|
21
22
|
"./document-editor": "./src/components/document-editor/index.ts",
|
|
22
23
|
"./email-editor": "./src/components/email-editor/index.ts",
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
"react": "^18.0.0 || ^19.0.0",
|
|
44
45
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
45
46
|
"@startsimpli/auth": "^0.4.31",
|
|
46
|
-
"@startsimpli/api": "^0.5.
|
|
47
|
+
"@startsimpli/api": "^0.5.48"
|
|
47
48
|
},
|
|
48
49
|
"peerDependenciesMeta": {
|
|
49
50
|
"@startsimpli/api": {
|
|
@@ -1,17 +1,57 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* ActivityTimeline — ONE timeline for every "what happened" list in the
|
|
5
|
+
* monorepo (startsim-768w.19.5).
|
|
6
|
+
*
|
|
7
|
+
* It started CRM-shaped (a logged call with a duration and an outcome) and is
|
|
8
|
+
* now generalised rather than forked: every CRM field is optional, and an
|
|
9
|
+
* append-only EVENT STREAM row — an actor, a tone, a free-form detail — renders
|
|
10
|
+
* through the same component. Nothing here knows which event types exist; the
|
|
11
|
+
* caller hands it a title and a tone, so registering a new event type upstream
|
|
12
|
+
* needs no change in this file.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { ComponentType, ReactNode, useEffect, useMemo, useRef } from 'react';
|
|
4
16
|
import { formatDistanceToNow } from 'date-fns';
|
|
5
17
|
import { Clock, User } from 'lucide-react';
|
|
6
18
|
import { EmptyState } from './states';
|
|
7
19
|
|
|
20
|
+
/** Severity of an entry, for the marker. Presentation only — never a gate. */
|
|
21
|
+
export type ActivityTone = 'ok' | 'warn' | 'error' | 'muted';
|
|
22
|
+
|
|
23
|
+
/** Who did it. Rendered as an initials chip so a feed shows a person rather
|
|
24
|
+
* than a bare email string. */
|
|
25
|
+
export interface ActivityTimelineActor {
|
|
26
|
+
id?: string;
|
|
27
|
+
name?: string | null;
|
|
28
|
+
email?: string | null;
|
|
29
|
+
avatarUrl?: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
8
32
|
export interface ActivityTimelineItem {
|
|
9
33
|
id: string;
|
|
10
34
|
type: string;
|
|
11
35
|
title: string;
|
|
12
36
|
description?: string;
|
|
13
37
|
occurredAt: string;
|
|
14
|
-
|
|
38
|
+
|
|
39
|
+
// --- event-stream shape -------------------------------------------------
|
|
40
|
+
/** Identity of the person behind the event (preferred over `createdBy`). */
|
|
41
|
+
actor?: ActivityTimelineActor;
|
|
42
|
+
/** Colours the marker; `activityColors[type]` still wins when supplied. */
|
|
43
|
+
tone?: ActivityTone;
|
|
44
|
+
/** Muted inline qualifier after the title, e.g. "from provisioning". */
|
|
45
|
+
subtitle?: ReactNode;
|
|
46
|
+
/** Small pill next to the title, e.g. "detected by drift reconcile". */
|
|
47
|
+
note?: ReactNode;
|
|
48
|
+
/** Body line under the title — a reason, an error, a target. */
|
|
49
|
+
detail?: ReactNode;
|
|
50
|
+
/** Colours `detail` (an error reads red, a reason reads muted). */
|
|
51
|
+
detailTone?: ActivityTone;
|
|
52
|
+
|
|
53
|
+
// --- CRM shape (apps/market) — all optional ------------------------------
|
|
54
|
+
createdBy?: { name: string };
|
|
15
55
|
durationMinutes?: number;
|
|
16
56
|
outcome?: string;
|
|
17
57
|
isAutomated?: boolean;
|
|
@@ -20,16 +60,39 @@ export interface ActivityTimelineItem {
|
|
|
20
60
|
|
|
21
61
|
export interface ActivityTimelineProps {
|
|
22
62
|
activities: ActivityTimelineItem[];
|
|
23
|
-
loading
|
|
63
|
+
loading?: boolean;
|
|
24
64
|
emptyTitle?: string;
|
|
25
65
|
emptyDescription?: string;
|
|
26
66
|
onLoadMore?: () => void;
|
|
27
67
|
hasMore?: boolean;
|
|
68
|
+
/** Fire `onLoadMore` when a sentinel below the last row scrolls into view. */
|
|
69
|
+
autoLoadMore?: boolean;
|
|
70
|
+
/** Bucket entries under day headings (Today / Yesterday / Mon d, yyyy). */
|
|
71
|
+
groupByDay?: boolean;
|
|
28
72
|
activityIcons?: Record<string, ComponentType<any>>;
|
|
29
73
|
activityColors?: Record<string, string>;
|
|
74
|
+
/** Icon for a type with no entry in `activityIcons` — including types this
|
|
75
|
+
* build has never heard of. */
|
|
76
|
+
defaultIcon?: ComponentType<any>;
|
|
30
77
|
formatTime?: (date: string) => string;
|
|
78
|
+
loadMoreLabel?: string;
|
|
79
|
+
className?: string;
|
|
31
80
|
}
|
|
32
81
|
|
|
82
|
+
const TONE_MARKER: Record<ActivityTone, string> = {
|
|
83
|
+
ok: 'bg-emerald-500/15 text-emerald-600',
|
|
84
|
+
warn: 'bg-amber-500/15 text-amber-600',
|
|
85
|
+
error: 'bg-red-500/15 text-red-600',
|
|
86
|
+
muted: 'bg-muted text-muted-foreground',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const TONE_TEXT: Record<ActivityTone, string> = {
|
|
90
|
+
ok: 'text-emerald-600',
|
|
91
|
+
warn: 'text-amber-600',
|
|
92
|
+
error: 'text-red-600',
|
|
93
|
+
muted: 'text-muted-foreground',
|
|
94
|
+
};
|
|
95
|
+
|
|
33
96
|
function defaultFormatTime(date: string): string {
|
|
34
97
|
try {
|
|
35
98
|
return formatDistanceToNow(new Date(date), { addSuffix: true });
|
|
@@ -38,26 +101,213 @@ function defaultFormatTime(date: string): string {
|
|
|
38
101
|
}
|
|
39
102
|
}
|
|
40
103
|
|
|
104
|
+
/** "Ada Lovelace" -> "AL"; "ada@x.com" -> "A". Empty when we know nobody. */
|
|
105
|
+
export function actorInitials(actor?: ActivityTimelineActor): string {
|
|
106
|
+
const name = (actor?.name ?? '').trim();
|
|
107
|
+
if (name) {
|
|
108
|
+
return name
|
|
109
|
+
.split(/\s+/)
|
|
110
|
+
.slice(0, 2)
|
|
111
|
+
.map((w) => w[0]?.toUpperCase() ?? '')
|
|
112
|
+
.join('');
|
|
113
|
+
}
|
|
114
|
+
const email = (actor?.email ?? '').trim();
|
|
115
|
+
return email ? email[0]!.toUpperCase() : '';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function actorName(actor?: ActivityTimelineActor): string {
|
|
119
|
+
return (actor?.name ?? '').trim() || (actor?.email ?? '').trim();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function dayKey(iso: string): string {
|
|
123
|
+
const d = new Date(iso);
|
|
124
|
+
return Number.isNaN(d.getTime()) ? 'unknown' : d.toDateString();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Day heading. Relative for the two days an operator is actually looking at,
|
|
128
|
+
* absolute beyond that. */
|
|
129
|
+
export function dayHeading(iso: string): string {
|
|
130
|
+
const d = new Date(iso);
|
|
131
|
+
if (Number.isNaN(d.getTime())) return 'Unknown date';
|
|
132
|
+
const today = new Date();
|
|
133
|
+
if (d.toDateString() === today.toDateString()) return 'Today';
|
|
134
|
+
const yesterday = new Date(today);
|
|
135
|
+
yesterday.setDate(yesterday.getDate() - 1);
|
|
136
|
+
if (d.toDateString() === yesterday.toDateString()) return 'Yesterday';
|
|
137
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Fires `onVisible` when it scrolls into view. No-ops where there is no
|
|
141
|
+
* IntersectionObserver (SSR, older jsdom) — the explicit button still works. */
|
|
142
|
+
function LoadMoreSentinel({ onVisible }: { onVisible: () => void }) {
|
|
143
|
+
const ref = useRef<HTMLDivElement | null>(null);
|
|
144
|
+
const cb = useRef(onVisible);
|
|
145
|
+
cb.current = onVisible;
|
|
146
|
+
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
const node = ref.current;
|
|
149
|
+
if (!node || typeof IntersectionObserver === 'undefined') return;
|
|
150
|
+
const io = new IntersectionObserver(
|
|
151
|
+
(entries) => {
|
|
152
|
+
if (entries.some((e) => e.isIntersecting)) cb.current();
|
|
153
|
+
},
|
|
154
|
+
{ rootMargin: '200px' },
|
|
155
|
+
);
|
|
156
|
+
io.observe(node);
|
|
157
|
+
return () => io.disconnect();
|
|
158
|
+
}, []);
|
|
159
|
+
|
|
160
|
+
return <div ref={ref} aria-hidden data-testid="activity-load-more-sentinel" className="h-px w-full" />;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function TimelineEntry({
|
|
164
|
+
activity,
|
|
165
|
+
activityIcons,
|
|
166
|
+
activityColors,
|
|
167
|
+
defaultIcon,
|
|
168
|
+
formatTime,
|
|
169
|
+
}: {
|
|
170
|
+
activity: ActivityTimelineItem;
|
|
171
|
+
activityIcons: Record<string, ComponentType<any>>;
|
|
172
|
+
activityColors: Record<string, string>;
|
|
173
|
+
defaultIcon?: ComponentType<any>;
|
|
174
|
+
formatTime: (date: string) => string;
|
|
175
|
+
}) {
|
|
176
|
+
const Icon = activityIcons[activity.type] ?? defaultIcon;
|
|
177
|
+
const tone = activity.tone;
|
|
178
|
+
const initials = actorInitials(activity.actor);
|
|
179
|
+
const markerClass =
|
|
180
|
+
activityColors[activity.type] ||
|
|
181
|
+
(tone ? TONE_MARKER[tone] : undefined) ||
|
|
182
|
+
'bg-muted text-muted-foreground';
|
|
183
|
+
const who = actorName(activity.actor);
|
|
184
|
+
// With no icon the marker itself becomes the avatar, so the metadata line
|
|
185
|
+
// shows the name alone rather than the same initials twice.
|
|
186
|
+
const markerIsAvatar = !Icon && !!initials;
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<div className="relative flex gap-4" data-activity-id={activity.id}>
|
|
190
|
+
<div
|
|
191
|
+
data-tone={tone}
|
|
192
|
+
className={`relative z-10 flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-xs font-medium ${markerClass}`}
|
|
193
|
+
>
|
|
194
|
+
{Icon ? <Icon className="h-5 w-5" /> : initials || null}
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div className="min-w-0 flex-1 rounded-lg border border-border bg-card p-4">
|
|
198
|
+
<div className="mb-2 flex items-start justify-between gap-3">
|
|
199
|
+
<div className="min-w-0 flex-1">
|
|
200
|
+
<div className="flex flex-wrap items-baseline gap-x-2">
|
|
201
|
+
<h4 className="font-medium text-foreground">{activity.title}</h4>
|
|
202
|
+
{activity.subtitle && (
|
|
203
|
+
<span className="text-xs text-muted-foreground">{activity.subtitle}</span>
|
|
204
|
+
)}
|
|
205
|
+
{activity.note && (
|
|
206
|
+
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
|
207
|
+
{activity.note}
|
|
208
|
+
</span>
|
|
209
|
+
)}
|
|
210
|
+
</div>
|
|
211
|
+
{activity.description && (
|
|
212
|
+
<p className="mt-1 text-sm text-muted-foreground">{activity.description}</p>
|
|
213
|
+
)}
|
|
214
|
+
{activity.detail && (
|
|
215
|
+
<p className={`mt-1 break-words text-xs ${TONE_TEXT[activity.detailTone ?? 'muted']}`}>
|
|
216
|
+
{activity.detail}
|
|
217
|
+
</p>
|
|
218
|
+
)}
|
|
219
|
+
</div>
|
|
220
|
+
<div className="ml-4 whitespace-nowrap text-xs text-muted-foreground">
|
|
221
|
+
{formatTime(activity.occurredAt)}
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm text-muted-foreground">
|
|
226
|
+
{who && (
|
|
227
|
+
<span className="flex items-center gap-1.5">
|
|
228
|
+
{initials && !markerIsAvatar && (
|
|
229
|
+
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-muted text-[10px] font-medium text-muted-foreground">
|
|
230
|
+
{initials}
|
|
231
|
+
</span>
|
|
232
|
+
)}
|
|
233
|
+
{who}
|
|
234
|
+
</span>
|
|
235
|
+
)}
|
|
236
|
+
|
|
237
|
+
{!who && activity.createdBy && (
|
|
238
|
+
<span className="flex items-center gap-1">
|
|
239
|
+
<User className="h-3 w-3" />
|
|
240
|
+
{activity.createdBy.name}
|
|
241
|
+
</span>
|
|
242
|
+
)}
|
|
243
|
+
|
|
244
|
+
{activity.durationMinutes && (
|
|
245
|
+
<span className="flex items-center gap-1">
|
|
246
|
+
<Clock className="h-3 w-3" />
|
|
247
|
+
{activity.durationMinutes}m
|
|
248
|
+
</span>
|
|
249
|
+
)}
|
|
250
|
+
|
|
251
|
+
{activity.outcome && (
|
|
252
|
+
<span className="rounded bg-muted px-2 py-0.5 text-xs text-foreground">
|
|
253
|
+
{activity.outcome.replace(/_/g, ' ')}
|
|
254
|
+
</span>
|
|
255
|
+
)}
|
|
256
|
+
|
|
257
|
+
{activity.isAutomated && (
|
|
258
|
+
<span className="text-xs italic text-muted-foreground">Auto-tracked</span>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
{activity.participants && activity.participants.length > 0 && (
|
|
263
|
+
<div className="mt-2 border-t border-border pt-2">
|
|
264
|
+
<div className="text-xs text-muted-foreground">
|
|
265
|
+
With: {activity.participants.map((p) => p.name).filter(Boolean).join(', ')}
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
41
274
|
export function ActivityTimeline({
|
|
42
275
|
activities,
|
|
43
|
-
loading,
|
|
276
|
+
loading = false,
|
|
44
277
|
emptyTitle = 'No activities yet',
|
|
45
278
|
emptyDescription = 'Log calls, meetings, and notes to track your interactions',
|
|
46
279
|
onLoadMore,
|
|
47
280
|
hasMore = false,
|
|
281
|
+
autoLoadMore = false,
|
|
282
|
+
groupByDay = false,
|
|
48
283
|
activityIcons = {},
|
|
49
284
|
activityColors = {},
|
|
285
|
+
defaultIcon,
|
|
50
286
|
formatTime = defaultFormatTime,
|
|
287
|
+
loadMoreLabel = 'Load more activities',
|
|
288
|
+
className = 'space-y-4',
|
|
51
289
|
}: ActivityTimelineProps) {
|
|
290
|
+
const groups = useMemo(() => {
|
|
291
|
+
if (!groupByDay) return null;
|
|
292
|
+
const out: Array<{ key: string; heading: string; items: ActivityTimelineItem[] }> = [];
|
|
293
|
+
for (const a of activities) {
|
|
294
|
+
const key = dayKey(a.occurredAt);
|
|
295
|
+
const last = out[out.length - 1];
|
|
296
|
+
if (last && last.key === key) last.items.push(a);
|
|
297
|
+
else out.push({ key, heading: dayHeading(a.occurredAt), items: [a] });
|
|
298
|
+
}
|
|
299
|
+
return out;
|
|
300
|
+
}, [activities, groupByDay]);
|
|
301
|
+
|
|
52
302
|
if (loading) {
|
|
53
303
|
return (
|
|
54
|
-
<div className="
|
|
304
|
+
<div className={className} data-testid="activity-timeline">
|
|
55
305
|
{[...Array(3)].map((_, i) => (
|
|
56
|
-
<div key={i} className="flex gap-4
|
|
57
|
-
<div className="
|
|
306
|
+
<div key={i} className="flex animate-pulse gap-4">
|
|
307
|
+
<div className="h-10 w-10 rounded-full bg-muted" />
|
|
58
308
|
<div className="flex-1">
|
|
59
|
-
<div className="h-4
|
|
60
|
-
<div className="h-3
|
|
309
|
+
<div className="mb-2 h-4 w-1/3 rounded bg-muted" />
|
|
310
|
+
<div className="h-3 w-2/3 rounded bg-muted" />
|
|
61
311
|
</div>
|
|
62
312
|
</div>
|
|
63
313
|
))}
|
|
@@ -65,108 +315,55 @@ export function ActivityTimeline({
|
|
|
65
315
|
);
|
|
66
316
|
}
|
|
67
317
|
|
|
318
|
+
const entryProps = { activityIcons, activityColors, defaultIcon, formatTime };
|
|
319
|
+
|
|
68
320
|
return (
|
|
69
|
-
<div className="
|
|
70
|
-
{/* Timeline */}
|
|
321
|
+
<div className={className} data-testid="activity-timeline">
|
|
71
322
|
<div className="relative">
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
return (
|
|
86
|
-
<div key={activity.id} className="relative flex gap-4">
|
|
87
|
-
{/* Icon */}
|
|
88
|
-
<div
|
|
89
|
-
className={`relative z-10 flex items-center justify-center w-10 h-10 rounded-full ${colorClass}`}
|
|
90
|
-
>
|
|
91
|
-
{Icon ? <Icon className="w-5 h-5" /> : null}
|
|
92
|
-
</div>
|
|
93
|
-
|
|
94
|
-
{/* Content */}
|
|
95
|
-
<div className="flex-1 bg-white rounded-lg border border-gray-200 p-4">
|
|
96
|
-
<div className="flex items-start justify-between mb-2">
|
|
97
|
-
<div className="flex-1">
|
|
98
|
-
<h4 className="font-medium text-gray-900">
|
|
99
|
-
{activity.title}
|
|
100
|
-
</h4>
|
|
101
|
-
{activity.description && (
|
|
102
|
-
<p className="text-sm text-gray-600 mt-1">
|
|
103
|
-
{activity.description}
|
|
104
|
-
</p>
|
|
105
|
-
)}
|
|
106
|
-
</div>
|
|
107
|
-
<div className="text-xs text-gray-500 ml-4 whitespace-nowrap">
|
|
108
|
-
{formatTime(activity.occurredAt)}
|
|
109
|
-
</div>
|
|
110
|
-
</div>
|
|
111
|
-
|
|
112
|
-
{/* Metadata */}
|
|
113
|
-
<div className="flex items-center gap-4 text-sm text-gray-600">
|
|
114
|
-
<div className="flex items-center gap-1">
|
|
115
|
-
<User className="w-3 h-3" />
|
|
116
|
-
{activity.createdBy.name}
|
|
117
|
-
</div>
|
|
118
|
-
|
|
119
|
-
{activity.durationMinutes && (
|
|
120
|
-
<div className="flex items-center gap-1">
|
|
121
|
-
<Clock className="w-3 h-3" />
|
|
122
|
-
{activity.durationMinutes}m
|
|
123
|
-
</div>
|
|
124
|
-
)}
|
|
125
|
-
|
|
126
|
-
{activity.outcome && (
|
|
127
|
-
<div className="px-2 py-0.5 text-xs bg-gray-100 text-gray-700 rounded">
|
|
128
|
-
{activity.outcome.replace(/_/g, ' ')}
|
|
129
|
-
</div>
|
|
130
|
-
)}
|
|
131
|
-
|
|
132
|
-
{activity.isAutomated && (
|
|
133
|
-
<div className="text-xs text-gray-500 italic">
|
|
134
|
-
Auto-tracked
|
|
135
|
-
</div>
|
|
136
|
-
)}
|
|
137
|
-
</div>
|
|
138
|
-
|
|
139
|
-
{/* Participants */}
|
|
140
|
-
{activity.participants &&
|
|
141
|
-
activity.participants.length > 0 && (
|
|
142
|
-
<div className="mt-2 pt-2 border-t border-gray-100">
|
|
143
|
-
<div className="text-xs text-gray-500">
|
|
144
|
-
With:{' '}
|
|
145
|
-
{activity.participants
|
|
146
|
-
.map((p) => p.name)
|
|
147
|
-
.filter(Boolean)
|
|
148
|
-
.join(', ')}
|
|
149
|
-
</div>
|
|
150
|
-
</div>
|
|
151
|
-
)}
|
|
152
|
-
</div>
|
|
323
|
+
<div className="absolute bottom-0 left-5 top-0 w-0.5 bg-border" aria-hidden />
|
|
324
|
+
|
|
325
|
+
{activities.length === 0 ? (
|
|
326
|
+
<EmptyState title={emptyTitle} description={emptyDescription} />
|
|
327
|
+
) : groups ? (
|
|
328
|
+
<div className="space-y-6">
|
|
329
|
+
{groups.map((g) => (
|
|
330
|
+
<div key={g.key} className="space-y-4">
|
|
331
|
+
<div
|
|
332
|
+
data-testid="activity-day-heading"
|
|
333
|
+
className="relative z-10 ml-14 text-xs font-medium uppercase tracking-wide text-muted-foreground"
|
|
334
|
+
>
|
|
335
|
+
{g.heading}
|
|
153
336
|
</div>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
337
|
+
<div className="space-y-6">
|
|
338
|
+
{g.items.map((a) => (
|
|
339
|
+
<TimelineEntry key={a.id} activity={a} {...entryProps} />
|
|
340
|
+
))}
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
))}
|
|
344
|
+
</div>
|
|
345
|
+
) : (
|
|
346
|
+
<div className="space-y-6">
|
|
347
|
+
{activities.map((a) => (
|
|
348
|
+
<TimelineEntry key={a.id} activity={a} {...entryProps} />
|
|
349
|
+
))}
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
158
352
|
</div>
|
|
159
353
|
|
|
160
|
-
{/* Load More */}
|
|
161
354
|
{hasMore && onLoadMore && (
|
|
162
|
-
|
|
163
|
-
<
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
355
|
+
<>
|
|
356
|
+
{autoLoadMore && <LoadMoreSentinel onVisible={onLoadMore} />}
|
|
357
|
+
<div className="pt-4 text-center">
|
|
358
|
+
<button
|
|
359
|
+
type="button"
|
|
360
|
+
onClick={onLoadMore}
|
|
361
|
+
className="rounded-md px-4 py-2 text-sm text-primary transition-colors hover:bg-primary/10 hover:text-primary/80"
|
|
362
|
+
>
|
|
363
|
+
{loadMoreLabel}
|
|
364
|
+
</button>
|
|
365
|
+
</div>
|
|
366
|
+
</>
|
|
170
367
|
)}
|
|
171
368
|
</div>
|
|
172
369
|
);
|