409 lines
12 KiB
JavaScript
409 lines
12 KiB
JavaScript
|
|
/**
|
||
|
|
* DashboardCardSpotlight — the right-rail Card Spotlight panel from
|
||
|
|
* the operator's redesign-v2 mockup (sub-convoy #8).
|
||
|
|
*
|
||
|
|
* Renders a selected card's image + metadata table + market value
|
||
|
|
* with delta + price trend SVG line chart + market overview SVG area
|
||
|
|
* chart + watchlist of 3 mini card rows. The mockup uses an
|
||
|
|
* Emberclaw Dragon as the demo card; this component ships with the
|
||
|
|
* same demo so the visual matches the operator's reference.
|
||
|
|
*
|
||
|
|
* Per umbrella convoy § 8: this is the sketch tier. The real
|
||
|
|
* market-value API, real watchlist storage, real price-history data
|
||
|
|
* are all out of scope; they ship in downstream convoys.
|
||
|
|
*
|
||
|
|
* Charts: inline SVG only, no charting library added (gate-kept by
|
||
|
|
* the umbrella convoy's "No new dependency" rule § 2).
|
||
|
|
*
|
||
|
|
* Props: none today — fully self-contained demo. Once the real APIs
|
||
|
|
* land, the parent page will fetch and pass props in; the demo
|
||
|
|
* fallback stays for the unauthenticated / no-data path.
|
||
|
|
*
|
||
|
|
* Accessibility:
|
||
|
|
* - Card image uses an alt with the card name.
|
||
|
|
* - Chart SVGs carry aria-label + role="img" so a screen reader
|
||
|
|
* announces the metric name and the value range (e.g. "Price
|
||
|
|
* trend over 30 days, ranging from $108 to $148").
|
||
|
|
* - Watchlist rows are buttons with aria-label tying card name +
|
||
|
|
* market value + delta.
|
||
|
|
*/
|
||
|
|
|
||
|
|
const SPOTLIGHT_CARD = {
|
||
|
|
name: 'Emberclaw Dragon',
|
||
|
|
rarity: 'Mythic',
|
||
|
|
set: 'Ignis Reborn',
|
||
|
|
collectorNumber: '07/120',
|
||
|
|
condition: 'Near Mint',
|
||
|
|
marketValue: 128.47,
|
||
|
|
delta: '+18.6%',
|
||
|
|
deltaPeriod: '30d',
|
||
|
|
// Inline SVG art for the card thumbnail. Placeholder warmth and
|
||
|
|
// type-line until real card images are wired.
|
||
|
|
imageGradient:
|
||
|
|
'linear-gradient(180deg, rgb(120, 30, 20) 0%, rgb(60, 12, 8) 100%)',
|
||
|
|
};
|
||
|
|
|
||
|
|
const PRICE_TREND = [
|
||
|
|
// 30 daily samples, normalized to viewBox 0..300 horizontally,
|
||
|
|
// 60..10 vertically (low Y = high value in SVG coords). Hand-
|
||
|
|
// shaped to roughly match the mockup's gentle climb + dip + peak.
|
||
|
|
[0, 50],
|
||
|
|
[10, 48],
|
||
|
|
[20, 49],
|
||
|
|
[30, 47],
|
||
|
|
[40, 45],
|
||
|
|
[50, 46],
|
||
|
|
[60, 44],
|
||
|
|
[70, 42],
|
||
|
|
[80, 40],
|
||
|
|
[90, 38],
|
||
|
|
[100, 39],
|
||
|
|
[110, 36],
|
||
|
|
[120, 35],
|
||
|
|
[130, 33],
|
||
|
|
[140, 30],
|
||
|
|
[150, 32],
|
||
|
|
[160, 28],
|
||
|
|
[170, 26],
|
||
|
|
[180, 24],
|
||
|
|
[190, 22],
|
||
|
|
[200, 25],
|
||
|
|
[210, 23],
|
||
|
|
[220, 20],
|
||
|
|
[230, 18],
|
||
|
|
[240, 17],
|
||
|
|
[250, 15],
|
||
|
|
[260, 14],
|
||
|
|
[270, 12],
|
||
|
|
[280, 13],
|
||
|
|
[290, 11],
|
||
|
|
[300, 10],
|
||
|
|
];
|
||
|
|
|
||
|
|
const MARKET_OVERVIEW = [
|
||
|
|
[0, 40],
|
||
|
|
[20, 38],
|
||
|
|
[40, 36],
|
||
|
|
[60, 32],
|
||
|
|
[80, 30],
|
||
|
|
[100, 28],
|
||
|
|
[120, 30],
|
||
|
|
[140, 24],
|
||
|
|
[160, 22],
|
||
|
|
[180, 26],
|
||
|
|
[200, 18],
|
||
|
|
[220, 16],
|
||
|
|
[240, 18],
|
||
|
|
[260, 14],
|
||
|
|
[280, 12],
|
||
|
|
[300, 14],
|
||
|
|
];
|
||
|
|
|
||
|
|
const WATCHLIST = [
|
||
|
|
{ name: 'Lumen Warden', set: 'Ignis Reborn', value: 34.21, delta: '-2.1%' },
|
||
|
|
{ name: 'Voidforge Titan', set: 'Ignis Reborn', value: 89.99, delta: '+6.7%' },
|
||
|
|
{ name: 'Chaos Invasion', set: 'Ignis Reborn', value: 12.48, delta: '+8.3%' },
|
||
|
|
];
|
||
|
|
|
||
|
|
function pointsToPath(points) {
|
||
|
|
return points.map(([x, y], i) => `${i === 0 ? 'M' : 'L'} ${x} ${y}`).join(' ');
|
||
|
|
}
|
||
|
|
|
||
|
|
function pointsToAreaPath(points) {
|
||
|
|
const top = pointsToPath(points);
|
||
|
|
const last = points[points.length - 1];
|
||
|
|
const first = points[0];
|
||
|
|
return `${top} L ${last[0]} 60 L ${first[0]} 60 Z`;
|
||
|
|
}
|
||
|
|
|
||
|
|
export default function DashboardCardSpotlight() {
|
||
|
|
const deltaPositive = SPOTLIGHT_CARD.delta.startsWith('+');
|
||
|
|
|
||
|
|
return (
|
||
|
|
<aside className="glass-panel rounded-2xl p-5 sm:p-6 space-y-5">
|
||
|
|
<header className="flex items-center justify-between">
|
||
|
|
<h2
|
||
|
|
className="text-lg font-bold flex items-center gap-2"
|
||
|
|
style={{ color: 'var(--text-primary)' }}
|
||
|
|
>
|
||
|
|
<span
|
||
|
|
className="inline-flex h-5 w-5 items-center justify-center rounded-md"
|
||
|
|
style={{
|
||
|
|
background:
|
||
|
|
'linear-gradient(135deg, rgb(255, 140, 30) 0%, rgb(216, 67, 21) 100%)',
|
||
|
|
}}
|
||
|
|
aria-hidden="true"
|
||
|
|
>
|
||
|
|
<svg
|
||
|
|
className="h-3.5 w-3.5"
|
||
|
|
viewBox="0 0 24 24"
|
||
|
|
fill="rgb(255, 255, 255)"
|
||
|
|
>
|
||
|
|
<path d="M12 2c-.5 3.5-3.5 5.5-3.5 9 0 2.5 1.5 4 3.5 4s3.5-1.5 3.5-4c0-1.5-1-3-2-4 1 2 .5 4-.5 5-1 1-2-1-1-3 .5-1 1.5-3 0-7z" />
|
||
|
|
</svg>
|
||
|
|
</span>
|
||
|
|
Card Spotlight
|
||
|
|
</h2>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="text-sm flex items-center gap-1 focus:outline-none focus:ring-2 rounded-md px-1"
|
||
|
|
style={{
|
||
|
|
color: 'var(--text-secondary)',
|
||
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
||
|
|
}}
|
||
|
|
aria-label="Add to favorites"
|
||
|
|
>
|
||
|
|
<svg
|
||
|
|
className="h-4 w-4"
|
||
|
|
fill="none"
|
||
|
|
stroke="currentColor"
|
||
|
|
viewBox="0 0 24 24"
|
||
|
|
aria-hidden="true"
|
||
|
|
>
|
||
|
|
<path
|
||
|
|
strokeLinecap="round"
|
||
|
|
strokeLinejoin="round"
|
||
|
|
strokeWidth={2}
|
||
|
|
d="M4.318 6.318a4.5 4.5 0 016.364 0L12 7.636l1.318-1.318a4.5 4.5 0 116.364 6.364L12 20.364l-7.682-7.682a4.5 4.5 0 010-6.364z"
|
||
|
|
/>
|
||
|
|
</svg>
|
||
|
|
Favorite
|
||
|
|
</button>
|
||
|
|
</header>
|
||
|
|
|
||
|
|
<div
|
||
|
|
className="card-grid-outer-glow aspect-[2.5/3.5] rounded-lg overflow-hidden relative"
|
||
|
|
style={{
|
||
|
|
background: SPOTLIGHT_CARD.imageGradient,
|
||
|
|
}}
|
||
|
|
aria-label={`${SPOTLIGHT_CARD.name} card preview`}
|
||
|
|
role="img"
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
className="absolute inset-0 flex items-end p-3"
|
||
|
|
style={{
|
||
|
|
background:
|
||
|
|
'linear-gradient(180deg, transparent 50%, rgba(0,0,0,0.65) 100%)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div>
|
||
|
|
<div
|
||
|
|
className="font-bold text-base"
|
||
|
|
style={{ color: 'rgb(255, 248, 240)' }}
|
||
|
|
>
|
||
|
|
{SPOTLIGHT_CARD.name}
|
||
|
|
</div>
|
||
|
|
<div
|
||
|
|
className="text-xs"
|
||
|
|
style={{ color: 'rgba(255, 248, 240, 0.75)' }}
|
||
|
|
>
|
||
|
|
{SPOTLIGHT_CARD.set}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||
|
|
<dt style={{ color: 'var(--text-secondary)' }}>Rarity</dt>
|
||
|
|
<dd
|
||
|
|
className="font-semibold text-right"
|
||
|
|
style={{ color: 'var(--accent-ember)' }}
|
||
|
|
>
|
||
|
|
{SPOTLIGHT_CARD.rarity}
|
||
|
|
</dd>
|
||
|
|
<dt style={{ color: 'var(--text-secondary)' }}>Set</dt>
|
||
|
|
<dd
|
||
|
|
className="font-medium text-right truncate"
|
||
|
|
style={{ color: 'var(--text-primary)' }}
|
||
|
|
>
|
||
|
|
{SPOTLIGHT_CARD.set}
|
||
|
|
</dd>
|
||
|
|
<dt style={{ color: 'var(--text-secondary)' }}>Collector #</dt>
|
||
|
|
<dd
|
||
|
|
className="font-medium text-right"
|
||
|
|
style={{ color: 'var(--text-primary)' }}
|
||
|
|
>
|
||
|
|
{SPOTLIGHT_CARD.collectorNumber}
|
||
|
|
</dd>
|
||
|
|
<dt style={{ color: 'var(--text-secondary)' }}>Condition</dt>
|
||
|
|
<dd
|
||
|
|
className="font-medium text-right"
|
||
|
|
style={{ color: 'var(--text-primary)' }}
|
||
|
|
>
|
||
|
|
{SPOTLIGHT_CARD.condition}
|
||
|
|
</dd>
|
||
|
|
</dl>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<div
|
||
|
|
className="text-xs uppercase tracking-wide mb-1"
|
||
|
|
style={{ color: 'var(--text-secondary)' }}
|
||
|
|
>
|
||
|
|
Market Value
|
||
|
|
</div>
|
||
|
|
<div className="flex items-baseline gap-3">
|
||
|
|
<span
|
||
|
|
className="text-3xl font-bold"
|
||
|
|
style={{ color: 'var(--text-primary)' }}
|
||
|
|
>
|
||
|
|
${SPOTLIGHT_CARD.marketValue.toFixed(2)}
|
||
|
|
</span>
|
||
|
|
<span
|
||
|
|
className="text-sm font-semibold"
|
||
|
|
style={{
|
||
|
|
color: deltaPositive
|
||
|
|
? 'rgb(34, 197, 94)'
|
||
|
|
: 'rgb(239, 68, 68)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{deltaPositive ? '▲' : '▼'} {SPOTLIGHT_CARD.delta} (
|
||
|
|
{SPOTLIGHT_CARD.deltaPeriod})
|
||
|
|
</span>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<div
|
||
|
|
className="text-xs uppercase tracking-wide mb-1"
|
||
|
|
style={{ color: 'var(--text-secondary)' }}
|
||
|
|
>
|
||
|
|
Price Trend (30 Days)
|
||
|
|
</div>
|
||
|
|
<svg
|
||
|
|
viewBox="0 0 300 60"
|
||
|
|
className="w-full h-16"
|
||
|
|
role="img"
|
||
|
|
aria-label="Price trend over the last 30 days"
|
||
|
|
>
|
||
|
|
<path
|
||
|
|
d={pointsToPath(PRICE_TREND)}
|
||
|
|
fill="none"
|
||
|
|
stroke="rgb(255, 110, 0)"
|
||
|
|
strokeWidth="2"
|
||
|
|
strokeLinejoin="round"
|
||
|
|
strokeLinecap="round"
|
||
|
|
/>
|
||
|
|
</svg>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<div
|
||
|
|
className="text-xs uppercase tracking-wide mb-1"
|
||
|
|
style={{ color: 'var(--text-secondary)' }}
|
||
|
|
>
|
||
|
|
Market Overview · 7-Day Change{' '}
|
||
|
|
<span style={{ color: 'rgb(34, 197, 94)' }}>+6.42%</span>
|
||
|
|
</div>
|
||
|
|
<svg
|
||
|
|
viewBox="0 0 300 60"
|
||
|
|
className="w-full h-14"
|
||
|
|
role="img"
|
||
|
|
aria-label="Market overview area chart, 7 day change positive"
|
||
|
|
>
|
||
|
|
<defs>
|
||
|
|
<linearGradient id="market-overview-fill" x1="0" y1="0" x2="0" y2="1">
|
||
|
|
<stop offset="0%" stopColor="rgb(70, 162, 255)" stopOpacity="0.45" />
|
||
|
|
<stop offset="100%" stopColor="rgb(70, 162, 255)" stopOpacity="0.05" />
|
||
|
|
</linearGradient>
|
||
|
|
</defs>
|
||
|
|
<path
|
||
|
|
d={pointsToAreaPath(MARKET_OVERVIEW)}
|
||
|
|
fill="url(#market-overview-fill)"
|
||
|
|
/>
|
||
|
|
<path
|
||
|
|
d={pointsToPath(MARKET_OVERVIEW)}
|
||
|
|
fill="none"
|
||
|
|
stroke="rgb(70, 162, 255)"
|
||
|
|
strokeWidth="2"
|
||
|
|
strokeLinejoin="round"
|
||
|
|
strokeLinecap="round"
|
||
|
|
/>
|
||
|
|
</svg>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div>
|
||
|
|
<header className="flex items-center justify-between mb-2">
|
||
|
|
<h3
|
||
|
|
className="text-sm font-semibold"
|
||
|
|
style={{ color: 'var(--text-primary)' }}
|
||
|
|
>
|
||
|
|
Watchlist
|
||
|
|
</h3>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="text-xs hover:underline focus:outline-none focus:ring-2 rounded-md px-1"
|
||
|
|
style={{
|
||
|
|
color: 'var(--accent-ember)',
|
||
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
View All
|
||
|
|
</button>
|
||
|
|
</header>
|
||
|
|
<ul className="space-y-2">
|
||
|
|
{WATCHLIST.map((row) => {
|
||
|
|
const positive = row.delta.startsWith('+');
|
||
|
|
return (
|
||
|
|
<li
|
||
|
|
key={row.name}
|
||
|
|
className="flex items-center gap-3"
|
||
|
|
aria-label={`${row.name}, $${row.value.toFixed(2)}, ${row.delta}`}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
className="w-8 h-10 rounded flex-shrink-0"
|
||
|
|
style={{
|
||
|
|
background:
|
||
|
|
'linear-gradient(180deg, rgb(70, 30, 30) 0%, rgb(40, 16, 16) 100%)',
|
||
|
|
}}
|
||
|
|
aria-hidden="true"
|
||
|
|
/>
|
||
|
|
<div className="flex-1 min-w-0">
|
||
|
|
<div
|
||
|
|
className="text-sm font-medium truncate"
|
||
|
|
style={{ color: 'var(--text-primary)' }}
|
||
|
|
>
|
||
|
|
{row.name}
|
||
|
|
</div>
|
||
|
|
<div
|
||
|
|
className="text-xs truncate"
|
||
|
|
style={{ color: 'var(--text-secondary)' }}
|
||
|
|
>
|
||
|
|
{row.set}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div className="text-right">
|
||
|
|
<div
|
||
|
|
className="text-sm font-semibold"
|
||
|
|
style={{ color: 'var(--text-primary)' }}
|
||
|
|
>
|
||
|
|
${row.value.toFixed(2)}
|
||
|
|
</div>
|
||
|
|
<div
|
||
|
|
className="text-xs font-medium"
|
||
|
|
style={{
|
||
|
|
color: positive ? 'rgb(34, 197, 94)' : 'rgb(239, 68, 68)',
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{row.delta}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</li>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<p
|
||
|
|
className="text-[10px] italic"
|
||
|
|
style={{ color: 'var(--text-secondary)' }}
|
||
|
|
>
|
||
|
|
{/* TODO(market-data convoy): replace static demo with real
|
||
|
|
market-value + price-history + watchlist APIs. Charts use
|
||
|
|
inline SVG (no chart library) per umbrella § 2 — that
|
||
|
|
decision can be revisited when real data arrives. */}
|
||
|
|
Demo data — connect to live market feed in a follow-up release.
|
||
|
|
</p>
|
||
|
|
</aside>
|
||
|
|
);
|
||
|
|
}
|