Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Natalie
c699f0a4f1 feat(web): implement StreamView (Wave B) on cocotte ui + getStream
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 21:53:20 -04:00

View 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>
);
}