feat(web): migrate MarketsView to cocotte ui (Wave A)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-29 21:55:22 -04:00
parent f6e72bf41f
commit 7211039ed7

View file

@ -1,5 +1,7 @@
import { useEffect, useState } from 'react';
import styled from 'styled-components';
import {
getMarkets,
getMarketStats,
@ -12,9 +14,153 @@ import {
} from '../api';
import { usePoll } from '../usePoll';
import {
Card,
VStack,
HStack,
Seg,
SegButton,
Muted,
Title,
Bars,
ErrText,
} from '../ui';
const RANGES: readonly number[] = [7, 30, 90];
const MIN_CONVERSION_SAMPLE = 3; // ignore hours with too little volume for "best hour"
/* local styled (cocotte theme tokens; no global css, no classes from styles.css;
viz data heights use style= only as ui/Bars does internally; keeps 100% behavior) */
const MarketField = styled.label`
display: flex;
flex-direction: column;
gap: 4px;
`;
const MarketLabelText = styled.span`
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: ${({ theme }) => theme.colors.text.muted};
`;
const MarketSelect = styled.select`
background: ${({ theme }) => theme.colors.background.tertiary};
border: 1px solid ${({ theme }) => theme.colors.border.default};
color: ${({ theme }) => theme.colors.text.primary};
border-radius: 6px;
padding: 6px 10px;
font-size: 13px;
cursor: pointer;
&:focus {
outline: none;
border-color: ${({ theme }) => theme.colors.primary.main};
}
`;
const Highlights = styled.div`
display: flex;
gap: 12px;
flex-wrap: wrap;
`;
const HighlightBox = styled.div`
background: ${({ theme }) => theme.colors.background.tertiary};
border-radius: 8px;
padding: 10px 14px;
min-width: 140px;
`;
const HighlightValue = styled.div<{ $accent?: boolean }>`
font-size: 18px;
font-weight: 700;
font-family: ui-monospace, Menlo, monospace;
color: ${({ $accent, theme }) => ($accent ? theme.colors.primary.main : theme.colors.text.primary)};
`;
const HighlightLabel = styled.div`
font-size: 9px;
color: ${({ theme }) => theme.colors.text.muted};
margin-top: 2px;
text-transform: uppercase;
letter-spacing: 0.5px;
`;
const Funnel = styled.div`
display: flex;
gap: 8px;
`;
const StageBox = styled.div`
flex: 1;
background: ${({ theme }) => theme.colors.background.tertiary};
border-radius: 8px;
padding: 10px 8px;
text-align: center;
`;
const StageValue = styled.div<{ $accent?: boolean }>`
font-size: 20px;
font-weight: 700;
color: ${({ $accent, theme }) => ($accent ? theme.colors.primary.main : theme.colors.text.primary)};
`;
const StageLabel = styled.div`
font-size: 9px;
color: ${({ theme }) => theme.colors.text.muted};
margin-top: 2px;
`;
const Split = styled.div`
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
`;
const VChart = styled.div`
display: flex;
align-items: flex-end;
gap: 4px;
height: 130px;
`;
const VCol = styled.div<{ $peak?: boolean }>`
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
`;
const VStackEl = styled.div`
flex: 1;
width: 70%;
display: flex;
flex-direction: column;
justify-content: flex-end;
`;
const VSegHold = styled.div`
background: ${({ theme }) => theme.colors.border.default};
width: 100%;
flex-shrink: 0;
`;
const VSegSend = styled.div`
background: ${({ theme }) => theme.colors.success?.main ?? theme.colors.primary.main};
width: 100%;
flex-shrink: 0;
`;
const VLabel = styled.div`
font-size: 9px;
color: ${({ theme }) => theme.colors.text.muted};
margin-top: 3px;
height: 10px;
font-family: ui-monospace, Menlo, monospace;
`;
/** Tour-market selector + the per-market stats dashboard (peak hours/days,
* conversion, locality split, and the shared funnel/volume report). */
export function MarketsView(): JSX.Element {
@ -31,40 +177,37 @@ export function MarketsView(): JSX.Element {
const stats = useMarketStats(market, days);
if (marketsError) return <div className="err">{marketsError}</div>;
if (!markets) return <div className="muted">Loading markets</div>;
if (marketsError) return <ErrText>{marketsError}</ErrText>;
if (!markets) return <Muted>Loading markets</Muted>;
return (
<div className="reports">
<section className="card market-bar">
<label className="market-bar__field">
<span className="market-bar__label">Market</span>
<select className="market-bar__select" value={market} onChange={(e) => setMarket(e.target.value)}>
{markets.items.map((m) => (
<option key={m.key} value={m.key}>
{m.label} · {m.prospects} prospect{m.prospects === 1 ? '' : 's'}
</option>
<VStack $gap={16}>
<Card>
<HStack $justify="space-between" $gap={12} $wrap>
<MarketField>
<MarketLabelText>Market</MarketLabelText>
<MarketSelect value={market} onChange={(e) => setMarket(e.target.value)}>
{markets.items.map((m) => (
<option key={m.key} value={m.key}>
{m.label} · {m.prospects} prospect{m.prospects === 1 ? '' : 's'}
</option>
))}
</MarketSelect>
</MarketField>
<Seg>
{RANGES.map((d) => (
<SegButton key={d} $active={days === d} onClick={() => setDays(d)} type="button">
{d}d
</SegButton>
))}
</select>
</label>
<div className="seg">
{RANGES.map((d) => (
<button
key={d}
type="button"
className={`seg__btn${days === d ? ' seg__btn--active' : ''}`}
onClick={() => setDays(d)}
>
{d}d
</button>
))}
</div>
</section>
</Seg>
</HStack>
</Card>
{stats.error && <div className="err">{stats.error}</div>}
{stats.loading && !stats.data && <div className="muted">Loading stats</div>}
{stats.error && <ErrText>{stats.error}</ErrText>}
{stats.loading && !stats.data && <Muted>Loading stats</Muted>}
{stats.data && <StatsBody data={stats.data} />}
</div>
</VStack>
);
}
@ -74,134 +217,117 @@ function StatsBody({ data }: { data: MarketStatsPayload }): JSX.Element {
const bestConversion = bestConversionHour(data.conversionByHour);
return (
<>
<section className="card">
<div className="card__title">
<VStack $gap={16}>
<Card>
<Title>
{data.market.label} · {data.market.tz} · last {data.rangeDays}d
</div>
<div className="market-highlights">
</Title>
<Highlights>
<Highlight label="Peak inbound hour" value={peakHour === null ? '—' : `${fmtHour(peakHour)}`} />
<Highlight
label="Best-converting hour"
value={bestConversion === null ? '—' : `${fmtHour(bestConversion.hour)} · ${pct(bestConversion.rate)}`}
accent
/>
</div>
</section>
</Highlights>
</Card>
<section className="card">
<div className="card__title">Peak hours · {data.market.tz} (inbound · booked)</div>
<Card>
<Title>Peak hours · {data.market.tz} (inbound · booked)</Title>
<HourChart hours={data.peakHours} peakHour={peakHour} />
</section>
</Card>
<section className="card">
<div className="card__title">Conversion by hour</div>
<Card>
<Title>Conversion by hour</Title>
<ConversionChart buckets={data.conversionByHour} />
</section>
</Card>
<section className="card">
<div className="card__title">Peak days</div>
<Card>
<Title>Peak days</Title>
<DayBars days={data.peakDays} />
</section>
</Card>
<section className="card">
<div className="card__title">Auto-qualify funnel</div>
<div className="funnel">
<Card>
<Title>Auto-qualify funnel</Title>
<Funnel>
<Stage label="Prospects" value={f.total} />
<Stage label={`New ≤${data.rangeDays}d`} value={f.newInRange} />
<Stage label="Drafted" value={f.drafted} />
<Stage label="Sent" value={f.sent} />
<Stage label="Qualified" value={f.qualified} accent />
</div>
</section>
</Funnel>
</Card>
<section className="card">
<div className="card__title">By band & locality</div>
<div className="bars-2col">
<Card>
<Title>By band &amp; locality</Title>
<Split>
<Bars rows={data.report.bySegment} />
<Bars rows={data.byLocality} />
</div>
</section>
</>
</Split>
</Card>
</VStack>
);
}
function HourChart({ hours, peakHour }: { hours: readonly HourBucket[]; peakHour: number | null }): JSX.Element {
const max = Math.max(1, ...hours.map((h) => h.inbound));
if (hours.every((h) => h.inbound === 0)) return <div className="muted">No inbound in range.</div>;
if (hours.every((h) => h.inbound === 0)) return <Muted>No inbound in range.</Muted>;
return (
<div className="vchart vchart--hours">
{hours.map((h) => (
<div
key={h.hour}
className={`vchart__col${h.hour === peakHour ? ' vchart__col--peak' : ''}`}
title={`${fmtHour(h.hour)}: ${h.inbound} inbound · ${h.booked} booked`}
>
<div className="vchart__stack">
<div className="vchart__seg vchart__seg--hold" style={{ height: `${((h.inbound - h.booked) / max) * 100}%` }} />
<div className="vchart__seg vchart__seg--send" style={{ height: `${(h.booked / max) * 100}%` }} />
</div>
<div className="vchart__label">{h.hour % 6 === 0 ? h.hour : ''}</div>
</div>
))}
</div>
<VChart>
{hours.map((h) => {
const isPeak = h.hour === peakHour;
return (
<VCol key={h.hour} $peak={isPeak} title={`${fmtHour(h.hour)}: ${h.inbound} inbound · ${h.booked} booked`}>
<VStackEl>
<VSegHold style={{ height: `${((h.inbound - h.booked) / max) * 100}%` }} />
<VSegSend style={{ height: `${(h.booked / max) * 100}%` }} />
</VStackEl>
<VLabel>{h.hour % 6 === 0 ? h.hour : ''}</VLabel>
</VCol>
);
})}
</VChart>
);
}
function ConversionChart({ buckets }: { buckets: readonly ConversionBucket[] }): JSX.Element {
if (buckets.every((b) => b.total === 0)) return <div className="muted">No inbound in range.</div>;
if (buckets.every((b) => b.total === 0)) return <Muted>No inbound in range.</Muted>;
const maxRate = Math.max(0.001, ...buckets.map((b) => b.rate));
return (
<div className="vchart vchart--hours">
<VChart>
{buckets.map((b) => (
<div key={b.hour} className="vchart__col" title={`${fmtHour(b.hour)}: ${pct(b.rate)} (${b.booked}/${b.total})`}>
<div className="vchart__stack">
<div className="vchart__seg vchart__seg--send" style={{ height: `${b.rate * 100}%` }} />
</div>
<div className="vchart__label">{b.hour % 6 === 0 ? b.hour : ''}</div>
</div>
<VCol key={b.hour} title={`${fmtHour(b.hour)}: ${pct(b.rate)} (${b.booked}/${b.total})`}>
<VStackEl>
<VSegSend style={{ height: `${(b.rate / maxRate) * 100}%` }} />
</VStackEl>
<VLabel>{b.hour % 6 === 0 ? b.hour : ''}</VLabel>
</VCol>
))}
</div>
</VChart>
);
}
function DayBars({ days }: { days: readonly DayBucket[] }): JSX.Element {
const rows: CountRow[] = days.map((d) => ({ key: d.label, count: d.inbound }));
if (rows.every((r) => r.count === 0)) return <div className="muted">No inbound in range.</div>;
if (rows.every((r) => r.count === 0)) return <Muted>No inbound in range.</Muted>;
return <Bars rows={rows} />;
}
function Highlight({ label, value, accent }: { label: string; value: string; accent?: boolean }): JSX.Element {
return (
<div className="highlight">
<div className={`highlight__value${accent ? ' highlight__value--accent' : ''}`}>{value}</div>
<div className="highlight__label">{label}</div>
</div>
<HighlightBox>
<HighlightValue $accent={accent}>{value}</HighlightValue>
<HighlightLabel>{label}</HighlightLabel>
</HighlightBox>
);
}
function Stage({ label, value, accent }: { label: string; value: number; accent?: boolean }): JSX.Element {
return (
<div className="stage">
<div className={`stage__value${accent ? ' stage__value--accent' : ''}`}>{value}</div>
<div className="stage__label">{label}</div>
</div>
);
}
function Bars({ rows }: { rows: readonly CountRow[] }): JSX.Element {
const max = Math.max(1, ...rows.map((r) => r.count));
return (
<div className="bars">
{rows.map((r) => (
<div key={r.key} className="bars__row">
<span className="bars__key">{r.key}</span>
<span className="bars__track">
<span className="bars__fill" style={{ width: `${(r.count / max) * 100}%` }} />
</span>
<span className="bars__count">{r.count}</span>
</div>
))}
</div>
<StageBox>
<StageValue $accent={accent}>{value}</StageValue>
<StageLabel>{label}</StageLabel>
</StageBox>
);
}