feat(web): migrate MarketsView to cocotte ui (Wave A)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f6e72bf41f
commit
7211039ed7
1 changed files with 233 additions and 107 deletions
|
|
@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue