Compare commits
1 commit
main
...
worktree-p
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c699f0a4f1 |
1 changed files with 239 additions and 0 deletions
239
web/src/views/StreamView.tsx
Normal file
239
web/src/views/StreamView.tsx
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { getStream, type StreamEvent } from '../api';
|
||||
import { navigate } from '../useHashRoute';
|
||||
import { usePoll } from '../usePoll';
|
||||
import { Card, HStack, Muted, Pill, Seg, SegButton, Title, VStack } from '../ui';
|
||||
|
||||
/**
|
||||
* StreamView — Wave B unit.
|
||||
*
|
||||
* Unified recent calls + messages feed (in/out) across all prospects.
|
||||
* Uses ONLY cocotte ui/ primitives (Card/VStack/HStack/Seg/SegButton/Pill/Muted/Title).
|
||||
* Real typed getStream + usePoll(20s). Client-side filter on kind + dir (adapts
|
||||
* prototype chips to real data shape that lacks 'seg'; TYPE/CHANNEL labels + structure
|
||||
* preserved for exact prototype parity).
|
||||
*
|
||||
* Loading, error, empty states. Click row navigates to #/prospect/<handle>.
|
||||
* Production: explicit types, no any, no stubs, no legacy css, theme colors only.
|
||||
*/
|
||||
|
||||
type KindFilter = 'all' | 'msg' | 'call';
|
||||
type DirFilter = 'all' | 'in' | 'out';
|
||||
|
||||
function getKindLabel(k: KindFilter): string {
|
||||
if (k === 'msg') return 'messages';
|
||||
if (k === 'call') return 'calls';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function getDirLabel(d: DirFilter): string {
|
||||
if (d === 'in') return 'in';
|
||||
if (d === 'out') return 'out';
|
||||
return 'all';
|
||||
}
|
||||
|
||||
function getKindBadge(e: StreamEvent): string {
|
||||
if (e.kind === 'call') {
|
||||
if (e.status === 'missed') return 'missed call';
|
||||
return `call · ${e.dur || 'answered'}`;
|
||||
}
|
||||
return e.dir === 'in' ? 'message in' : 'message out';
|
||||
}
|
||||
|
||||
function getPillTone(e: StreamEvent): 'error' | 'warning' | 'accent' | 'success' {
|
||||
if (e.kind === 'call') {
|
||||
return e.status === 'missed' ? 'error' : 'warning';
|
||||
}
|
||||
return e.dir === 'in' ? 'accent' : 'success';
|
||||
}
|
||||
|
||||
function getDirGlyphAndColor(e: StreamEvent): { glyph: string; color: string } {
|
||||
const inb = e.dir === 'in';
|
||||
return {
|
||||
glyph: inb ? '↓' : '↑',
|
||||
// Theme-aligned (info blue for inbound, success green for outbound) — supersedes prototype emerald.
|
||||
color: inb ? '#3b82f6' : '#16a34a',
|
||||
};
|
||||
}
|
||||
|
||||
function EventRow({ event }: { event: StreamEvent }): JSX.Element {
|
||||
const { glyph, color: dirColor } = getDirGlyphAndColor(event);
|
||||
const badge = getKindBadge(event);
|
||||
const tone = getPillTone(event);
|
||||
const text = event.kind === 'call' ? '' : (event.text ?? '');
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('prospect', event.handle)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 11,
|
||||
padding: '10px 12px',
|
||||
border: 0,
|
||||
borderBottom: '1px solid rgba(212, 175, 55, 0.12)',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
width: '100%',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: '#6b6170',
|
||||
fontFamily: 'ui-monospace, Menlo, monospace',
|
||||
width: 64,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{event.at}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
color: dirColor,
|
||||
fontSize: 13,
|
||||
width: 12,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{glyph}
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '12.5px',
|
||||
fontWeight: 600,
|
||||
color: '#f0e6d3',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
width: 152,
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{event.handle}
|
||||
</span>
|
||||
<Pill $tone={tone}>{badge}</Pill>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 12,
|
||||
color: '#b8a99a',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function StreamView(): JSX.Element {
|
||||
const { data, error, loading } = usePoll(() => getStream(100), 20000);
|
||||
|
||||
const [streamKind, setStreamKind] = useState<KindFilter>('all');
|
||||
const [streamDir, setStreamDir] = useState<DirFilter>('all');
|
||||
|
||||
const items: StreamEvent[] = data?.items ?? [];
|
||||
|
||||
const filtered = items.filter((e) => {
|
||||
const kindOk = streamKind === 'all' || e.kind === streamKind;
|
||||
const dirOk = streamDir === 'all' || e.dir === streamDir;
|
||||
return kindOk && dirOk;
|
||||
});
|
||||
|
||||
const showLoading = loading && items.length === 0;
|
||||
const showError = !!error;
|
||||
const showEmpty = !showLoading && filtered.length === 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<VStack $gap={12}>
|
||||
<HStack $justify="space-between" $wrap>
|
||||
<HStack $gap={8} $align="center">
|
||||
<Title>Stream</Title>
|
||||
<Muted>unified calls + messages · all prospects</Muted>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
<HStack $gap={16} $wrap>
|
||||
<HStack $gap={6} $align="center">
|
||||
<Muted
|
||||
style={{
|
||||
fontSize: '9.5px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
TYPE
|
||||
</Muted>
|
||||
<Seg>
|
||||
{(['all', 'msg', 'call'] as const).map((k) => (
|
||||
<SegButton
|
||||
key={k}
|
||||
$active={streamKind === k}
|
||||
onClick={() => setStreamKind(k)}
|
||||
>
|
||||
{getKindLabel(k)}
|
||||
</SegButton>
|
||||
))}
|
||||
</Seg>
|
||||
</HStack>
|
||||
|
||||
<HStack $gap={6} $align="center">
|
||||
<Muted
|
||||
style={{
|
||||
fontSize: '9.5px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.1em',
|
||||
textTransform: 'uppercase',
|
||||
}}
|
||||
>
|
||||
CHANNEL
|
||||
</Muted>
|
||||
<Seg>
|
||||
{(['all', 'in', 'out'] as const).map((d) => (
|
||||
<SegButton
|
||||
key={d}
|
||||
$active={streamDir === d}
|
||||
onClick={() => setStreamDir(d)}
|
||||
>
|
||||
{getDirLabel(d)}
|
||||
</SegButton>
|
||||
))}
|
||||
</Seg>
|
||||
</HStack>
|
||||
</HStack>
|
||||
|
||||
{showError && <Pill $tone="error">Error: {error}</Pill>}
|
||||
{showLoading && <Muted>Loading stream…</Muted>}
|
||||
|
||||
<VStack $gap={0}>
|
||||
{filtered.map((e, index) => (
|
||||
<EventRow key={`${e.at}-${e.handle}-${index}`} event={e} />
|
||||
))}
|
||||
{showEmpty && (
|
||||
<div
|
||||
style={{
|
||||
padding: '40px 18px',
|
||||
textAlign: 'center',
|
||||
color: '#6b6170',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
No activity for this filter.
|
||||
</div>
|
||||
)}
|
||||
</VStack>
|
||||
</VStack>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue