blocks.tsx43.5 KBView on GitHub 'use client';
import { Fragment, useMemo, useState } from 'react';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
Line,
LineChart,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import { useQuery } from '@tanstack/react-query';
import { AlertTriangle, FileText, Info, MessageSquare, Sparkles } from 'lucide-react';
import { motion } from 'motion/react';
import { cn } from '@/lib/utils';
import { useTRPC } from '@/providers/query-provider';
import { useCedarStore } from '@/modules/store';
import {
inferScopeTypeFromPath as scopeTypeFromPath,
inferScopeIdFromPath as scopeIdFromPath,
} from '@/modules/files/store/documentsSlice';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { ConversationActivityOverview } from '@/modules/conversations/components/timeline/ConversationActivityOverview';
import type { ConversationEvent } from '@/modules/crm/types';
import {
formatValue,
interpolate,
hasTemplate,
teamAggregate,
type NumberFormat,
} from '../lib/data';
import { useScope, useSources, ScopeProvider, templateScope, type RepeaterScope } from './context';
import type {
ChartBlock,
ComparisonBlock,
ContainerNode,
DashboardRow,
DocLinkBlock,
CallComparisonBlock,
CallMoment,
CalloutBlock,
EntityCardBlock,
FunnelBlock,
FunnelStep,
HeatmapBlock,
LayoutNode,
PlaybookBlock,
PlaybookStage,
ProfileBlock,
RepeaterBlock,
StatBlock,
TableBlock,
TabsBlock,
TextBlock,
TimelineBlock,
} from '../types/dashboard';
const HIGHLIGHT = '#10b981'; // emerald-500
const NEUTRAL = '#94a3b8'; // slate-400
const PIE_COLORS = ['#10b981', '#3b82f6', '#f59e0b', '#a855f7', '#ef4444', '#14b8a6'];
const GAP: Record<string, string> = { none: 'gap-0', sm: 'gap-2', md: 'gap-4', lg: 'gap-6' };
// ── Recursive dispatcher ──────────────────────────────────────────────────────
export function LayoutNode({ node }: { node: LayoutNode }) {
switch (node.type) {
case 'row':
case 'column':
case 'grid':
return <Container node={node} />;
case 'tabs':
return <TabsView block={node} />;
case 'text':
return <TextBlockView block={node} />;
case 'stat':
return <StatBlockView block={node} />;
case 'chart':
return <ChartBlockView block={node} />;
case 'timeline':
return <TimelineView block={node} />;
case 'table':
return <TableBlockView block={node} />;
case 'docLink':
return <DocLinkView block={node} />;
case 'entityCard':
return <EntityCardView block={node} />;
case 'profile':
return <ProfileView block={node} />;
case 'funnel':
return <FunnelView block={node} />;
case 'comparison':
return <ComparisonView block={node} />;
case 'heatmap':
return <HeatmapView block={node} />;
case 'callComparison':
return <CallComparisonView block={node} />;
case 'playbook':
return <PlaybookView block={node} />;
case 'callout':
return <CalloutView block={node} />;
case 'repeater':
return <RepeaterView block={node} />;
default:
return null;
}
}
// ── Data resolution helpers ───────────────────────────────────────────────────
/**
* Rows for a block:
* - `source` → a named top-level source,
* - `rowField` → an array held on the current repeater/detail row,
* - otherwise → the current repeater row as a single-row set.
*/
function useBlockRows(opts: { source?: string; rowField?: string }): DashboardRow[] {
const sources = useSources();
const scope = useScope();
if (opts.source) return sources[opts.source]?.rows ?? [];
if (opts.rowField && scope) return (scope.row[opts.rowField] as DashboardRow[]) ?? [];
if (scope) return [scope.row];
return [];
}
function useText(value: string): string {
const scope = useScope();
return hasTemplate(value) ? interpolate(value, templateScope(scope)) : value;
}
// ── Containers ────────────────────────────────────────────────────────────────
function Container({ node }: { node: ContainerNode }) {
const gap = GAP[node.gap ?? 'md'];
const align = node.align ? `items-${node.align}` : '';
if (node.type === 'grid') {
return (
<div
className={cn('grid', gap, node.className)}
style={{ gridTemplateColumns: `repeat(${node.columns ?? node.children.length}, minmax(0, 1fr))` }}
>
{node.children.map((child, i) => (
<LayoutNode key={i} node={child} />
))}
</div>
);
}
return (
<div
className={cn('flex', node.type === 'row' ? 'flex-row' : 'flex-col', gap, align, node.className)}
>
{node.children.map((child, i) => (
<LayoutNode key={i} node={child} />
))}
</div>
);
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
function TabsView({ block }: { block: TabsBlock }) {
const [active, setActive] = useState(0);
const tabs = block.tabs;
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-1 border-b border-border">
{tabs.map((tab, i) => (
<button
key={i}
type="button"
onClick={() => setActive(i)}
className={cn(
'-mb-px border-b-2 px-3 py-1.5 text-[13px] font-medium transition-colors',
i === active
? 'border-emerald-500 text-foreground'
: 'border-transparent text-muted-foreground hover:text-foreground',
)}
>
{tab.label}
</button>
))}
</div>
{tabs[active] && <LayoutNode node={tabs[active].node} />}
</div>
);
}
// ── Text ──────────────────────────────────────────────────────────────────────
function TextBlockView({ block }: { block: TextBlock }) {
const text = useText(block.markdown);
// Lightweight rendering (avoids a cycle with the markdown editor): paragraphs,
// headings (#), and **bold**. Dashboard text blocks are short by design.
const lines = text.split('\n');
return (
<div className={cn('text-sm leading-relaxed', block.className)}>
{lines.map((line, i) => {
const heading = /^(#{1,3})\s+(.*)$/.exec(line);
if (heading) {
const level = heading[1].length;
return (
<p
key={i}
className={cn(
'font-semibold',
level === 1 ? 'text-lg mt-2' : level === 2 ? 'text-base mt-2' : 'text-sm mt-1',
)}
>
{renderBold(heading[2])}
</p>
);
}
if (!line.trim()) return <div key={i} className="h-2" />;
return (
<p key={i} className="my-0.5">
{renderBold(line)}
</p>
);
})}
</div>
);
}
function renderBold(text: string) {
return text.split(/(\*\*[^*]+\*\*)/g).map((part, i) =>
part.startsWith('**') && part.endsWith('**') ? (
<strong key={i} className="font-semibold">
{part.slice(2, -2)}
</strong>
) : (
<span key=[redacted]
),
);
}
// ── Stat (plain + radial) ──────────────────────────────────────────────────────
function StatBlockView({ block }: { block: StatBlock }) {
const rows = useBlockRows({ source: block.source });
const raw = rows[0]?.[block.field];
const numeric = typeof raw === 'number' ? raw : Number(raw);
const display = formatValue(raw, block.format);
if (block.style === 'radial') {
const max = block.max ?? (block.format === 'percent' ? 100 : 100);
const pct = Number.isFinite(numeric) ? Math.max(0, Math.min(1, numeric / max)) : 0;
return (
<div className="flex flex-col items-center justify-center gap-2 p-2">
<Radial pct={pct} highlight={block.highlight} label={display} />
{block.label && (
<span className="text-[11px] font-medium uppercase tracking-wide text-muted-foreground">
{block.label}
</span>
)}
</div>
);
}
return (
<div className="flex flex-col gap-0.5">
<span className="text-2xl font-semibold tabular-nums">{display}</span>
{block.label && <span className="text-xs text-muted-foreground">{block.label}</span>}
</div>
);
}
function Radial({ pct, label, highlight }: { pct: number; label: string; highlight?: boolean }) {
const size = 96;
const stroke = 9;
const r = (size - stroke) / 2;
const c = 2 * Math.PI * r;
const color = highlight === false ? NEUTRAL : HIGHLIGHT;
return (
<div className="relative" style={{ width: size, height: size }}>
<svg width={size} height={size} className="-rotate-90">
<circle cx={size / 2} cy={size / 2} r={r} fill="none" stroke="currentColor" strokeWidth={stroke} className="text-muted/40" />
<motion.circle
cx={size / 2}
cy={size / 2}
r={r}
fill="none"
stroke={color}
strokeWidth={stroke}
strokeLinecap="round"
strokeDasharray={c}
initial={{ strokeDashoffset: c }}
animate={{ strokeDashoffset: c * (1 - pct) }}
transition={{ duration: 0.8, ease: 'easeOut' }}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-lg font-semibold tabular-nums">{label}</span>
</div>
</div>
);
}
// ── Chart ───────────────────────────────────────────────────────────────────
/** Truncate long category-axis labels so they stay on a single readable line. */
function truncateLabel(value: unknown): string {
const s = String(value ?? '');
return s.length > 22 ? `${s.slice(0, 21)}…` : s;
}
function ChartBlockView({ block }: { block: ChartBlock }) {
const rows = useBlockRows({ source: block.source, rowField: block.rowField });
const series = block.series ?? (block.yAxis ? [{ key=[redacted], label: block.yAxis }] : []);
if (block.chart === 'table')
return <TableBlockView block={{ type: 'table', source: block.source, rowField: block.rowField }} />;
// Horizontal bar charts get one row per category, so the height must grow with
// the data — a fixed height crams (and clips) the bars when there are many
// categories. Vertical/line/pie charts keep a constant height.
const isHorizontalBar = block.chart === 'bar' && block.orientation === 'horizontal';
const height = isHorizontalBar ? Math.max(200, rows.length * 44 + 56) : 200;
return (
<div className="flex flex-col gap-1">
{block.title && <span className="text-[13px] font-semibold">{block.title}</span>}
<ResponsiveContainer width="100%" height={height}>
{block.chart === 'pie' ? (
<PieChart>
<Pie
data={rows as Record<string, unknown>[]}
dataKey=[redacted] ?? 'value'}
nameKey=[redacted] ?? 'name'}
outerRadius={80}
label
>
{rows.map((_, i) => (
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
) : block.chart === 'line' ? (
<LineChart data={rows as Record<string, unknown>[]}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey=[redacted] tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
<Tooltip />
{series.length > 1 && <Legend wrapperStyle={{ fontSize: 11 }} />}
{series.map((s, i) => (
<Line key=[redacted] name={s.label ?? s.key} dataKey=[redacted] stroke={PIE_COLORS[i % PIE_COLORS.length]} strokeWidth={2} dot={false} />
))}
</LineChart>
) : (
<BarChart
data={rows as Record<string, unknown>[]}
layout={isHorizontalBar ? 'vertical' : 'horizontal'}
margin={{ top: 4, right: 16, bottom: 4, left: 8 }}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
{isHorizontalBar ? (
<>
<XAxis type="number" tick={{ fontSize: 11 }} />
<YAxis
type="category"
dataKey=[redacted]
tick={{ fontSize: 11 }}
width={160}
interval={0}
tickFormatter={truncateLabel}
/>
</>
) : (
<>
<XAxis dataKey=[redacted] tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} />
</>
)}
<Tooltip />
<Legend wrapperStyle={{ fontSize: 11 }} />
{series.map((s, i) => (
<Bar
key=[redacted]
name={s.label ?? s.key}
dataKey=[redacted]
fill={PIE_COLORS[i % PIE_COLORS.length]}
radius={isHorizontalBar ? [0, 3, 3, 0] : [3, 3, 0, 0]}
/>
))}
</BarChart>
)}
</ResponsiveContainer>
</div>
);
}
// ── Timeline (mock ConversationActivityOverview) ──────────────────────────────
type TimelineKind = 'inbound_email' | 'outbound_email' | 'meeting' | 'call';
/**
* Adapt a mock row (`{ kind, at, title }`) into the minimal shape
* `ConversationActivityOverview` consumes. `getEventType`/`getEventTitle` branch
* on the presence of sub-objects (`emailEvent`/`meetingEvent`/`callEvent`) and
* `direction`, so we only populate those — the rest of `ConversationEvent` is
* never read on this path, hence the cast.
*/
function adaptTimelineEvent(row: DashboardRow, index: number): ConversationEvent {
const kind = String(row.kind ?? 'outbound_email') as TimelineKind;
const title = String(row.title ?? '');
const base = { id: String(row.id ?? index), occurredAt: new Date(String(row.at ?? '')) };
switch (kind) {
case 'inbound_email':
return { ...base, direction: 'inbound', emailEvent: { subject: title } } as ConversationEvent;
case 'meeting':
return { ...base, meetingEvent: { title } } as ConversationEvent;
case 'call':
return { ...base, callEvent: {} } as ConversationEvent;
case 'outbound_email':
default:
return { ...base, direction: 'outbound', emailEvent: { subject: title } } as ConversationEvent;
}
}
function TimelineView({ block }: { block: TimelineBlock }) {
const rows = useBlockRows({ source: block.source, rowField: block.rowField });
const title = useText(block.title ?? '');
const events = useMemo(() => rows.map(adaptTimelineEvent), [rows]);
const timeRange = block.rangeStart
? { start: new Date(block.rangeStart), end: new Date(block.rangeEnd ?? Date.now()) }
: undefined;
return (
<div className="flex flex-col gap-2">
{title && <span className="text-[13px] font-semibold">{title}</span>}
<ConversationActivityOverview events={events} timeRange={timeRange} />
</div>
);
}
// ── Table ───────────────────────────────────────────────────────────────────
function TableBlockView({ block }: { block: TableBlock }) {
const rows = useBlockRows({ source: block.source, rowField: block.rowField });
const columns =
block.columns ??
(rows[0]
? Object.keys(rows[0]).map((key) => ({
key,
label: key,
format: undefined as NumberFormat | undefined,
}))
: []);
const totalWeight = columns.reduce((sum, c) => sum + ((c as { weight?: number }).weight ?? 1), 0);
return (
<div className="flex flex-col gap-1.5">
{block.title && <span className="text-[13px] font-semibold">{block.title}</span>}
<table className="w-full table-fixed border-collapse text-sm">
<colgroup>
{columns.map((col) => (
<col key=[redacted] style={{ width: `${(((col as { weight?: number }).weight ?? 1) / totalWeight) * 100}%` }} />
))}
</colgroup>
<thead>
<tr className="border-b border-border">
{columns.map((col) => (
<th
key=[redacted]
className={cn(
'px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground',
alignClass((col as { align?: string }).align),
)}
>
{col.label}
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, i) => (
<tr key={i} className="border-b border-border/60 align-top">
{columns.map((col) => (
<DataCell key=[redacted] col={col} row={row} />
))}
</tr>
))}
</tbody>
</table>
</div>
);
}
function alignClass(align?: string) {
return align === 'right' ? 'text-right' : align === 'center' ? 'text-center' : 'text-left';
}
type TableColumnDef = {
key=[redacted];
label: string;
format?: NumberFormat;
weight?: number;
type?: 'text' | 'number' | 'docLink' | 'conversationLink' | 'rep';
linkLabel?: string;
align?: 'left' | 'right' | 'center';
};
function DataCell({ col, row }: { col: TableColumnDef; row: DashboardRow }) {
const base = cn('px-3 py-2 text-[12px] leading-snug', alignClass(col.align));
if (col.type === 'docLink') {
const path = String(row[col.key] ?? '');
return (
<td className={base}>{path ? <DocLink path={path} label={col.linkLabel ?? 'View →'} /> : null}</td>
);
}
if (col.type === 'conversationLink') {
const conversationId = String(row[col.key] ?? '');
return (
<td className={base}>
{conversationId ? (
<ConversationLink conversationId={conversationId} label={col.linkLabel ?? 'Open thread →'} />
) : null}
</td>
);
}
if (col.type === 'rep') {
const name = String(row[col.key] ?? '');
return (
<td className={base}>
<span className="inline-flex items-center gap-1.5">
<span className="flex h-5 w-5 items-center justify-center rounded-full bg-emerald-500/15 text-[9px] font-semibold text-emerald-700 ring-1 ring-emerald-500/40 dark:text-emerald-300">
{name.slice(0, 2).toUpperCase()}
</span>
<span className="font-medium">{name}</span>
</span>
</td>
);
}
const isNumeric = col.type === 'number' || col.format;
return <td className={cn(base, isNumeric && 'tabular-nums')}>{formatValue(row[col.key], col.format)}</td>;
}
// ── Document links ────────────────────────────────────────────────────────────
/** Resolve a Cedar Doc path → an onClick that navigates to it, plus its label. */
function useDocNav(path: string): { open: () => void; label: string; ready: boolean } {
const trpc = useTRPC();
const selectDocumentId = useCedarStore((s) => s.selectDocumentId);
const setActiveFolderContext = useCedarStore((s) => s.setActiveFolderContext);
const { data: meta } = useQuery(
trpc.documents.getDoc.queryOptions(
{ documentType: 'document', path, omitContent: true },
{ enabled: !!path, staleTime: 5 * 60_000 },
),
);
const open = () => {
if (!meta?.id) return;
selectDocumentId(meta.id);
setActiveFolderContext({
documentId: meta.id,
scopeType: scopeTypeFromPath(meta.path),
scopeId: scopeIdFromPath(meta.path),
path: meta.path,
label: meta.title ?? path,
});
};
return { open, label: meta?.title ?? '', ready: !!meta?.id };
}
function DocLink({ path, label }: { path: string; label?: string }) {
const nav = useDocNav(path);
// Regular document-link chip — mirrors FileLinkChip's styling.
return (
<button
type="button"
onClick={nav.open}
disabled={!nav.ready}
title={nav.label || label}
className="inline-flex max-w-56 items-center gap-1 rounded-sm border bg-muted/50 px-1.5 py-0.5 align-middle text-xs font-medium text-foreground transition-colors hover:bg-muted disabled:opacity-50"
>
<FileText className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">{label ?? nav.label ?? 'file'}</span>
</button>
);
}
function DocLinkView({ block }: { block: DocLinkBlock }) {
const scope = useScope();
const path = block.path ?? (block.pathField && scope ? String(scope.row[block.pathField] ?? '') : '');
if (!path) return null;
return <DocLink path={path} label={block.label} />;
}
/** Chip that opens a CRM conversation by id (mirrors DocLink's styling). */
function ConversationLink({ conversationId, label }: { conversationId: string; label: string }) {
const openConversationContext = useCedarStore((s) => s.openConversationContext);
return (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openConversationContext(conversationId);
}}
className="inline-flex max-w-56 items-center gap-1 rounded-sm border bg-muted/50 px-1.5 py-0.5 align-middle text-xs font-medium text-foreground transition-colors hover:bg-muted"
>
<MessageSquare className="size-3 shrink-0 text-muted-foreground" />
<span className="truncate">{label}</span>
</button>
);
}
// ── Entity card ───────────────────────────────────────────────────────────────
function EntityCardView({ block }: { block: EntityCardBlock }) {
const scope = useScope();
const path = block.linkPath ?? (block.linkField && scope ? String(scope.row[block.linkField] ?? '') : '');
const nav = useDocNav(path);
const clickable = !!path;
return (
<div
className={cn(
'flex flex-col gap-2 rounded-sm border border-border bg-raised p-3',
clickable && 'cursor-pointer transition-colors hover:border-foreground/30 hover:bg-muted/40',
block.className,
)}
onClick={clickable ? nav.open : undefined}
role={clickable ? 'button' : undefined}
>
{block.children.map((child, i) => (
<LayoutNode key={i} node={child} />
))}
</div>
);
}
// ── Callout ───────────────────────────────────────────────────────────────────
const CALLOUT_VARIANT = {
recommendation: {
container: 'border-emerald-500/40 bg-emerald-500/5',
accent: 'bg-emerald-500',
icon: 'text-emerald-600 dark:text-emerald-400',
eyebrow: 'text-emerald-700 dark:text-emerald-400',
Icon: Sparkles,
},
risk: {
container: 'border-amber-500/40 bg-amber-500/5',
accent: 'bg-amber-500',
icon: 'text-amber-600 dark:text-amber-400',
eyebrow: 'text-amber-700 dark:text-amber-400',
Icon: AlertTriangle,
},
info: {
container: 'border-border bg-muted/40',
accent: 'bg-muted-foreground/40',
icon: 'text-muted-foreground',
eyebrow: 'text-muted-foreground',
Icon: Info,
},
} as const;
function CalloutView({ block }: { block: CalloutBlock }) {
const v = CALLOUT_VARIANT[block.variant ?? 'recommendation'];
const label = useText(block.label ?? '');
const title = useText(block.title ?? '');
const body = useText(block.body);
return (
<div className={cn('relative overflow-hidden rounded-lg border p-4 pl-5', v.container)}>
<div className={cn('absolute inset-y-0 left-0 w-1', v.accent)} />
<div className="flex items-start gap-3">
<v.Icon className={cn('mt-0.5 h-4 w-4 shrink-0', v.icon)} />
<div className="min-w-0 flex-1">
{label && (
<p className={cn('text-[11px] font-semibold uppercase tracking-wider', v.eyebrow)}>
{label}
</p>
)}
{title && <p className="mt-0.5 text-sm font-semibold leading-snug">{renderBold(title)}</p>}
<div className="mt-1 text-sm leading-relaxed text-muted-foreground">
{body.split('\n').map((line, i) =>
line.trim() ? (
<p key={i} className="my-0.5">
{renderBold(line)}
</p>
) : (
<div key={i} className="h-1.5" />
),
)}
</div>
{block.docPath && (
<div className="mt-2.5">
<DocLink path={block.docPath} label={block.docLabel} />
</div>
)}
</div>
</div>
</div>
);
}
// ── Profile ───────────────────────────────────────────────────────────────────
function ProfileView({ block }: { block: ProfileBlock }) {
const name = useText(block.name);
const avatar = useText(block.avatar ?? '');
const initials = useText(block.initials ?? '') || name.slice(0, 2).toUpperCase();
const label = useText(block.label ?? '');
const highlight = block.highlight;
return (
<div className="flex items-center gap-2">
<Avatar className="h-7 w-7">
{avatar && <AvatarImage src={avatar} className="object-cover" />}
<AvatarFallback
className={cn(
'text-[10px] font-semibold',
highlight
? 'bg-emerald-500/15 text-emerald-700 ring-1 ring-emerald-500/40 dark:text-emerald-300'
: 'bg-muted text-muted-foreground',
)}
>
{initials}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col leading-tight">
<span className="truncate text-[13px] font-semibold">{name}</span>
{label && (
<span
className={cn(
'text-[9px] font-medium uppercase tracking-wide',
highlight ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground',
)}
>
{label}
</span>
)}
</div>
</div>
);
}
// ── Funnel ───────────────────────────────────────────────────────────────────
function FunnelView({ block }: { block: FunnelBlock }) {
const sources = useSources();
const scope = useScope();
const steps: FunnelStep[] = block.source
? ((sources[block.source]?.rows as FunnelStep[]) ?? [])
: (block.steps ??
(scope && block.field ? ((scope.row[block.field] as FunnelStep[]) ?? []) : []));
const max = block.max ?? steps[0]?.count ?? 1;
const lg = block.size === 'lg';
return (
<div className={cn('flex flex-col', lg ? 'gap-2.5' : 'gap-1.5')}>
{steps.map((step, i) => {
const widthPct = max > 0 ? (step.count / max) * 100 : 0;
return (
<div key={i} className={cn('flex items-center', lg ? 'gap-3' : 'gap-1.5')}>
<span
className={cn(
'shrink-0 truncate font-medium uppercase tracking-wide text-muted-foreground',
lg ? 'w-28 text-[11px]' : 'w-16 text-[9px]',
)}
>
{step.label}
</span>
<div className={cn('relative flex-1 overflow-hidden rounded-sm bg-muted/50', lg ? 'h-9' : 'h-4')}>
<motion.div
initial={{ width: 0 }}
animate={{ width: `${widthPct}%` }}
transition={{ duration: 0.7, delay: 0.1 + i * 0.1, ease: 'easeOut' }}
className="h-full rounded-sm bg-emerald-500/80"
/>
</div>
<span
className={cn(
'shrink-0 text-right font-semibold tabular-nums',
lg ? 'w-10 text-sm' : 'w-7 text-[11px]',
)}
>
{step.count}
</span>
</div>
);
})}
</div>
);
}
// ── Comparison (row vs team average) ──────────────────────────────────────────
function ComparisonView({ block }: { block: ComparisonBlock }) {
const sources = useSources();
const scope = useScope();
// Subject + team rows come from named single-row sources when given, else the
// enclosing repeater's row/team aggregate.
const subject = block.source ? (sources[block.source]?.rows[0] ?? {}) : scope?.row;
const team = block.teamSource ? (sources[block.teamSource]?.rows[0] ?? {}) : scope?.team;
if (!subject || !team) return null;
const rowLabel = block.rowLabel ?? 'This rep';
const teamLabel = block.teamLabel ?? 'Team avg';
return (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-4 text-[11px] text-muted-foreground">
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ background: HIGHLIGHT }} /> {rowLabel}
</span>
<span className="flex items-center gap-1.5">
<span className="h-2.5 w-2.5 rounded-sm" style={{ background: NEUTRAL }} /> {teamLabel}
</span>
</div>
{block.fields.map((f) => {
const rowVal = Number(subject[f.key]) || 0;
const teamVal = Number(team[f.key]) || 0;
const max = Math.max(rowVal, teamVal, 1);
return (
<div key=[redacted] className="flex flex-col gap-1">
<span className="text-[11px] font-medium text-muted-foreground">{f.label}</span>
<CompareBar value={rowVal} max={max} color={HIGHLIGHT} display={formatValue(rowVal, block.format)} />
<CompareBar value={teamVal} max={max} color={NEUTRAL} display={formatValue(teamVal, block.format)} />
</div>
);
})}
</div>
);
}
function CompareBar({ value, max, color, display }: { value: number; max: number; color: string; display: string }) {
return (
<div className="flex items-center gap-2">
<div className="relative h-5 flex-1 overflow-hidden rounded-sm bg-muted/50">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${(value / max) * 100}%` }}
transition={{ duration: 0.7, ease: 'easeOut' }}
className="h-full rounded-sm"
style={{ background: color }}
/>
</div>
<span className="w-12 shrink-0 text-right text-[11px] font-semibold tabular-nums">{display}</span>
</div>
);
}
// ── Heatmap (coverage matrix: rows × columns, cells shaded by level) ───────────
// Emerald intensity ramp for legend levels ≥ 1. Index 0 ("empty") renders muted
// rather than emerald — in a coverage grid an empty cell is the absence of reach.
const HEAT_RAMP = ['rgba(16,185,129,0.30)', 'rgba(16,185,129,0.62)', 'rgba(16,185,129,0.92)'];
function HeatmapView({ block }: { block: HeatmapBlock }) {
const rows = useBlockRows({ source: block.source, rowField: block.rowField });
const legend = block.legend ?? [];
const cols = block.columns;
// Numeric-mode denominator: the largest cell value across the grid.
const numericMax = useMemo(() => {
if (legend.length) return 1;
const all = rows.flatMap((r) => cols.map((c) => Number(r[c.key]) || 0));
return block.max ?? Math.max(1, ...all);
}, [legend.length, rows, cols, block.max]);
// Resolve a legend index → its accent color (explicit, or the ramp by position).
const colorForLegendIndex = (idx: number, explicit?: string): string | undefined =>
explicit ?? (idx <= 0 ? undefined : HEAT_RAMP[Math.min(idx - 1, HEAT_RAMP.length - 1)]);
// Resolve a raw cell value → { color, label } for shading + tooltip, against
// the column's own legend when it has one, else the block legend.
const cellFor = (
value: unknown,
cellLegend: HeatmapBlock['legend'] = legend,
): { color?: string; label: string } => {
if (cellLegend && cellLegend.length) {
const idx = cellLegend.findIndex((l) => String(l.value) === String(value));
if (idx < 0) return { color: undefined, label: '—' };
return { color: colorForLegendIndex(idx, cellLegend[idx].color), label: cellLegend[idx].label };
}
const n = Number(value);
const t = Number.isFinite(n) ? Math.max(0, Math.min(1, n / numericMax)) : 0;
return {
color: t > 0 ? `rgba(16,185,129,${(0.2 + t * 0.72).toFixed(3)})` : undefined,
label: formatValue(value, 'number'),
};
};
// Legend strip = the block legend followed by any per-column legends (e.g. a
// Won/Lost outcome scale), deduped by label so shared levels show once.
const legendEntries = useMemo(() => {
const seen = new Set<string>();
const out: { label: string; color?: string }[] = [];
for (const lg of [legend, ...cols.map((c) => c.legend ?? [])]) {
lg.forEach((l, idx) => {
if (seen.has(l.label)) return;
seen.add(l.label);
out.push({ label: l.label, color: colorForLegendIndex(idx, l.color) });
});
}
return out;
}, [legend, cols]);
const template = `minmax(110px, 1.5fr) repeat(${cols.length}, minmax(0, 1fr))`;
return (
<div className="flex flex-col gap-2">
{block.title && <span className="text-[13px] font-semibold">{block.title}</span>}
<div className="grid items-stretch gap-1" style={{ gridTemplateColumns: template }}>
{/* Header row: empty corner + column labels */}
<div />
{cols.map((c) => (
<div
key=[redacted]
className="px-0.5 pb-1 text-center text-[10px] font-semibold uppercase leading-tight tracking-wide text-muted-foreground"
>
{c.label}
</div>
))}
{/* Data rows */}
{rows.map((row, i) => {
const rowLabel = String(row[block.rowKey] ?? '');
return (
<Fragment key=[redacted]
<div className="flex items-center truncate pr-2 text-[12px] font-medium" title={rowLabel}>
{rowLabel}
</div>
{cols.map((c) => {
const { color, label } = cellFor(row[c.key], c.legend);
return (
<div
key=[redacted]
title={`${rowLabel} · ${c.label}: ${label}`}
className={cn('h-7 rounded-sm', !color && 'bg-muted/30')}
style={color ? { backgroundColor: color } : undefined}
/>
);
})}
</Fragment>
);
})}
</div>
{legendEntries.length > 0 && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 pt-0.5 text-[10px] text-muted-foreground">
{legendEntries.map((e, i) => (
<span key={i} className="flex items-center gap-1.5">
<span
className={cn('h-2.5 w-2.5 rounded-sm', !e.color && 'bg-muted/40')}
style={e.color ? { backgroundColor: e.color } : undefined}
/>
{e.label}
</span>
))}
</div>
)}
</div>
);
}
// ── Call comparison (two speaker-aligned transcripts) ─────────────────────────
const CALL_TAG: Record<
'good' | 'miss' | 'neutral',
{ bar: string; bg: string; mark: string; markColor: string }
> = {
good: { bar: 'border-emerald-500/70', bg: 'bg-emerald-500/5', mark: '✓', markColor: 'text-emerald-600 dark:text-emerald-400' },
miss: { bar: 'border-red-500/70', bg: 'bg-red-500/5', mark: '✗', markColor: 'text-red-600 dark:text-red-400' },
neutral: { bar: 'border-border', bg: '', mark: '', markColor: 'text-muted-foreground' },
};
function CallChip({
name,
label,
initials,
highlight,
}: {
name: string;
label?: string;
initials?: string;
highlight?: boolean;
}) {
const fallback = (initials || name.slice(0, 2)).toUpperCase();
return (
<div className="flex items-center gap-2">
<Avatar className="h-7 w-7">
<AvatarFallback
className={cn(
'text-[10px] font-semibold',
highlight
? 'bg-emerald-500/15 text-emerald-700 ring-1 ring-emerald-500/40 dark:text-emerald-300'
: 'bg-muted text-muted-foreground',
)}
>
{fallback}
</AvatarFallback>
</Avatar>
<div className="flex min-w-0 flex-col leading-tight">
<span className="truncate text-[13px] font-semibold">{name}</span>
{label && (
<span
className={cn(
'text-[9px] font-medium uppercase tracking-wide',
highlight ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground',
)}
>
{label}
</span>
)}
</div>
</div>
);
}
function CallTurnCell({ turn }: { turn: CallMoment['left'] }) {
const style = CALL_TAG[turn.tag ?? 'neutral'];
return (
<div className={cn('flex flex-col gap-1 rounded-sm border-l-2 px-3 py-2', style.bar, style.bg)}>
{turn.speaker && (
<span className="text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{turn.speaker}
</span>
)}
<span className="text-[12px] leading-snug">{turn.text}</span>
{turn.note && (
<span className={cn('flex gap-1 text-[11px] italic leading-snug', style.markColor)}>
{style.mark && <span className="not-italic">{style.mark}</span>}
<span>{turn.note}</span>
</span>
)}
</div>
);
}
function CallComparisonView({ block }: { block: CallComparisonBlock }) {
const sources = useSources();
const scope = useScope();
const moments: CallMoment[] =
block.moments ??
(block.source
? ((sources[block.source]?.rows as unknown as CallMoment[]) ?? [])
: block.rowField && scope
? ((scope.row[block.rowField] as CallMoment[]) ?? [])
: []);
return (
<div className="flex flex-col gap-2">
{block.title && <span className="text-[13px] font-semibold">{block.title}</span>}
<div className="grid grid-cols-2 gap-3 border-b border-border pb-2">
<CallChip {...block.left} highlight />
<CallChip {...block.right} />
</div>
<div className="flex flex-col gap-2">
{moments.map((m, i) => (
<div key={i} className="flex flex-col gap-1">
{(m.topic || m.at) && (
<div className="flex items-center gap-2 pt-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground">
{m.at && <span className="tabular-nums">{m.at}</span>}
{m.topic && <span>{m.topic}</span>}
</div>
)}
<div className="grid grid-cols-2 items-start gap-3">
<CallTurnCell turn={m.left} />
<CallTurnCell turn={m.right} />
</div>
</div>
))}
</div>
</div>
);
}
// ── Playbook ─────────────────────────────────────────────────────────────────
function PlaybookView({ block }: { block: PlaybookBlock }) {
const sources = useSources();
const scope = useScope();
// Stages come from a named source (its rows ARE the stages) when given, else
// the repeater row's array field.
const stages: PlaybookStage[] = block.source
? ((sources[block.source]?.rows as PlaybookStage[]) ?? [])
: scope && block.field
? ((scope.row[block.field] as PlaybookStage[]) ?? [])
: [];
return (
<div className="flex flex-col gap-2.5">
{block.title && (
<div className="flex items-center gap-2">
<Sparkles className="h-4 w-4 text-action" />
<span className="text-[13px] font-semibold">{block.title}</span>
</div>
)}
{stages.map((stage, i) => (
<motion.div
key=[redacted]
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, delay: 0.1 + i * 0.08 }}
className="rounded-lg border border-border bg-raised p-3"
>
<div className="mb-2 flex items-center gap-2">
<span className="rounded bg-emerald-500/15 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-emerald-700 dark:text-emerald-300">
{stage.num}
</span>
<span className="text-[13px] font-semibold">{stage.title}</span>
{typeof stage.winPct === 'number' && (
<span className="rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-semibold text-emerald-700 dark:text-emerald-300">
{stage.winPct}% win
</span>
)}
{stage.goal && (
<span className="ml-auto truncate text-[11px] text-muted-foreground">Goal: {stage.goal}</span>
)}
</div>
{stage.objection && (
<div className="flex gap-2 text-[12px] leading-snug">
<span className="mt-px shrink-0 text-amber-600 dark:text-amber-400">⚠</span>
<span className="italic text-muted-foreground">“{stage.objection}”</span>
</div>
)}
{stage.reply && (
<div className="mt-1 flex gap-2 text-[12px] leading-snug">
<span className="mt-px shrink-0 text-emerald-600 dark:text-emerald-400">→</span>
<span>{stage.reply}</span>
</div>
)}
{stage.tactic && (
<div className="mt-2 flex gap-2 border-t border-border/40 pt-2 text-[11px] leading-snug text-muted-foreground">
<span className="shrink-0">✓</span>
<span>{stage.tactic}</span>
</div>
)}
{stage.deliverable && (
<div className="mt-1.5 flex items-center gap-2">
<span className="rounded bg-foreground/5 px-1.5 py-0.5 text-[9px] font-bold uppercase tracking-wider text-muted-foreground">
Deliverable
</span>
<span className="font-mono text-[10.5px] text-foreground/80">{stage.deliverable}</span>
</div>
)}
</motion.div>
))}
</div>
);
}
// ── Repeater (grid/list of templated cards) ───────────────────────────────────
function RepeaterView({ block }: { block: RepeaterBlock }) {
const sources = useSources();
const rows = sources[block.source]?.rows ?? [];
const as = block.as ?? 'row';
const team = useMemo(() => teamAggregate(rows), [rows]);
const isGrid = (block.layout ?? 'grid') === 'grid';
const gap = GAP[block.gap ?? 'md'];
return (
<div
className={cn(isGrid ? 'grid' : 'flex flex-col', gap)}
style={isGrid ? { gridTemplateColumns: `repeat(${block.columns ?? rows.length}, minmax(0, 1fr))` } : undefined}
>
{rows.map((row, i) => {
const scope: RepeaterScope = { row, team, as };
return (
<ScopeProvider key={i} value={scope}>
<LayoutNode node={block.template} />
</ScopeProvider>
);
})}
</div>
);
}