Read-card scan: honest no-market empty state + surface real priced lines
The Session-78 diagnosis stands: the join works, and a marketless scan rightly
shows no triplet. This makes that absence legible and points the user at what IS
priced, without fabricating a market.
PHASE 0 gate — design-check, reachability, timing, all clear. Design-check: the
bundle has the triplet's own REFUSAL language ("we'd rather show nothing than a
number we can't stand behind") as the honesty precedent, and a designed
EmptyState component whose actions give a path forward — so the empty state is
built in the established visual language, not freelanced. Reachability: the
scanner already fetches games/odds/search per selection; the snapshot is one
more public, 30s-cached fetch per sport, re-run when the sport changes.
Staleness: the snapshot rotates 5x/day and every scan re-validates the market
server-side at submit time, so a surfaced line that goes stale degrades to the
empty state on tap rather than a vanishing triplet — the stale-tap guard is
inherent, not bolted on.
Reversibility was the design constraint. The working card, price triplet, grade
adapter, valueState and the scan route are BYTE-IDENTICAL — a test asserts none
of them even reference the new empty state. Everything new lives in two added
files (lib/pricedLines.js, components/vyndr/NoMarketState.tsx) and additive
blocks in the scan page. Removing them leaves the Scan-A path untouched.
Non-fabricating by construction: indexPricedLines only keeps snapshot rows that
carry a real book price, keyed by exact player+stat via nameKey. A different
stat priced for the same player surfaces nothing for the picked stat; an
off-slate player surfaces nothing; nothing is suggested, interpolated, or
rounded to a nearest line. The empty state shows no market numbers of its own —
only real priced lines as one-tap chips, or a link to the live board when there
are none.
Framing is help, not restriction: a "PRICED TONIGHT" chip row sits under the
free-typed line input, and the scanner still accepts any player, stat and line.
Tapping a chip pre-fills the priced line and re-scans it — the market is
re-resolved server-side, so the tap either yields a real triplet or degrades to
the honest empty state.
Path forward, not a wall: a marketless scan no longer dead-ends in blank space.
It states truthfully that the board didn't price that line, keeps the grade, and
routes the user to the priced lines for that exact player+stat or to tonight's
board.
Tests 3760 passed / 303 suites, web build exit 0.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCNgGSt5qvcLxaeQqa7Zpj
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* NoMarketState (Session 79) — the honest empty state for a scanned prop the
|
||||
* board never priced.
|
||||
*
|
||||
* A manual scan is an arbitrary player + stat + free-typed line, so most scans
|
||||
* land on a prop no book line was captured for. The join correctly returns no
|
||||
* market and the read card's price triplet correctly self-hides — but a blank
|
||||
* space reads as "the product is empty." This says, truthfully, what happened
|
||||
* and points the user at what IS priced.
|
||||
*
|
||||
* TRUTH LAW: NO market numbers, no placeholder odds, no "coming soon." The only
|
||||
* numbers shown are REAL priced lines from the current snapshot (passed in),
|
||||
* each a one-tap route to a genuine triplet. If there are none, it degrades to
|
||||
* copy + a path to the live board. Everything renders from tokens.
|
||||
*
|
||||
* FRAMING: this is HELP ("here's what we price tonight"), not RESTRICTION — the
|
||||
* scanner still accepts any prop; this only appears once a scan came back
|
||||
* marketless.
|
||||
*/
|
||||
|
||||
export interface PricedLine {
|
||||
line: number;
|
||||
direction: 'over' | 'under';
|
||||
book_odds: number;
|
||||
}
|
||||
|
||||
const fmtOdds = (o: number) => (o > 0 ? `+${Math.round(o)}` : String(Math.round(o)));
|
||||
|
||||
export default function NoMarketState({
|
||||
player,
|
||||
stat,
|
||||
line,
|
||||
pricedLines = [],
|
||||
onPick,
|
||||
}: {
|
||||
player: string;
|
||||
stat: string;
|
||||
line: number | string;
|
||||
/** REAL priced lines for this exact player+stat from the current snapshot. */
|
||||
pricedLines?: PricedLine[];
|
||||
/** Tap a real priced line → re-scan it (the market is re-validated server-side). */
|
||||
onPick?: (l: PricedLine) => void;
|
||||
}) {
|
||||
const statLabel = String(stat || '').replace(/_/g, ' ');
|
||||
return (
|
||||
<div
|
||||
className="no-market-state"
|
||||
style={{
|
||||
border: '1px solid var(--border)',
|
||||
borderRadius: 12,
|
||||
padding: '16px 18px',
|
||||
background: 'var(--bg-deep)',
|
||||
marginTop: 12,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="mono"
|
||||
style={{ fontSize: 10.5, fontWeight: 800, letterSpacing: '0.12em', color: 'var(--amber)', marginBottom: 6 }}
|
||||
>
|
||||
NO LIVE MARKET FOR THIS LINE
|
||||
</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text-1)', margin: '0 0 12px', lineHeight: 1.5 }}>
|
||||
The board hasn’t priced <span className="mono" style={{ color: 'var(--text-0)' }}>{player}</span>{' '}
|
||||
{statLabel} at <span className="mono" style={{ color: 'var(--text-0)' }}>{String(line)}</span>. The grade
|
||||
above stands; there’s just no book number to price it against.
|
||||
</p>
|
||||
|
||||
{pricedLines.length > 0 ? (
|
||||
<>
|
||||
<div
|
||||
className="mono"
|
||||
style={{ fontSize: 9, fontWeight: 700, letterSpacing: '0.12em', color: 'var(--text-3)', marginBottom: 8 }}
|
||||
>
|
||||
PRICED TONIGHT · TAP TO READ THE TRIPLET
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
|
||||
{pricedLines.map((l, i) => (
|
||||
<button
|
||||
key={`${l.direction}-${l.line}-${i}`}
|
||||
type="button"
|
||||
onClick={() => onPick && onPick(l)}
|
||||
className="mono"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
fontWeight: 700,
|
||||
padding: '7px 12px',
|
||||
borderRadius: 999,
|
||||
border: '1px solid var(--fair-border)',
|
||||
background: 'var(--fair-tint)',
|
||||
color: 'var(--text-0)',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
}}
|
||||
>
|
||||
{l.direction === 'under' ? 'U' : 'O'} {l.line}{' '}
|
||||
<span style={{ color: 'var(--text-2)' }}>· {fmtOdds(l.book_odds)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<a
|
||||
href="/dashboard"
|
||||
className="mono"
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
fontSize: 11,
|
||||
fontWeight: 800,
|
||||
letterSpacing: '0.08em',
|
||||
color: 'var(--g-a)',
|
||||
textDecoration: 'none',
|
||||
}}
|
||||
>
|
||||
SEE TONIGHT’S PRICED BOARD ▸
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user