Files
website/src/routes/projects/cs-prices/[slug]/+page.svelte
Stan Runge 23a43049a0
All checks were successful
Deploy production / deploy (push) Successful in 1m13s
cs-prices: lime for CSFloat history so it stands apart from CSFloat listings
2026-10-26 09:35:36 +00:00

654 lines
17 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts">
import PriceChart, { type PriceSeries } from '#lib/cs-prices/PriceChart.svelte';
import { phaseOrder, wearOrder, wearShort } from '#lib/cs-prices/catalog.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
type Config = (typeof data.configs)[number];
type Variant = 'normal' | 'stattrak' | 'souvenir';
const colors: Record<string, string> = {
'skinport:lowest-listing': '#3b82f6',
'skinport:sale-median-24h': '#22c55e',
'steam-market:sale-avg': '#a855f7',
'steam-market:lowest-listing': '#d946ef',
'csfloat:lowest-listing': '#f59e0b',
'buff163:lowest-listing': '#ef4444',
'steam-market:sale-median-history': '#14b8a6',
'csfloat:sale-avg-history': '#a3e635'
};
const sourceNames: Record<string, string> = {
skinport: 'Skinport',
'steam-market': 'Steam',
csfloat: 'CSFloat',
buff163: 'Buff163'
};
const kindNames: Record<string, string> = {
'lowest-listing': 'lowest listing',
'sale-median-24h': 'sold, 24h median',
'sale-avg': 'sold, average',
'sale-median-history': 'sold, daily median (history)',
'sale-avg-history': 'sold, daily average (history)'
};
const eur = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'EUR' });
const usd = new Intl.NumberFormat('nl-NL', { style: 'currency', currency: 'USD' });
const count = new Intl.NumberFormat('nl-NL');
const money = (cents: number | null | undefined) =>
cents == null ? '–' : eur.format(cents / 100);
let streamById = $derived(new Map(data.streams.map((s) => [s.id, s])));
let selected = $derived(data.configs.find((c) => c.id === data.selected) ?? null);
const variantOf = (c: { stattrak: boolean; souvenir: boolean }): Variant =>
c.souvenir ? 'souvenir' : c.stattrak ? 'stattrak' : 'normal';
let variants = $derived(
(['normal', 'stattrak', 'souvenir'] as Variant[]).filter((v) =>
data.configs.some((c) => variantOf(c) === v)
)
);
let currentVariant = $derived(selected ? variantOf(selected) : 'normal');
let wears = $derived(
wearOrder.filter((w) =>
data.configs.some((c) => c.wear === w && variantOf(c) === currentVariant)
)
);
let phases = $derived(
phaseOrder.filter((p) =>
data.configs.some(
(c) =>
c.phase === p && variantOf(c) === currentVariant && c.wear === (selected?.wear ?? null)
)
)
);
/** Best price to show for a config: lowest EUR listing, else the latest sale. */
function best(config: Config) {
const listings = config.prices.filter(
(p) => streamById.get(p.stream)?.kind === 'lowest-listing'
);
const pool = listings.length ? listings : config.prices;
return pool.length ? Math.min(...pool.map((p) => p.eur_cents)) : null;
}
function find(v: Variant, wear: string | null, phase: string | null) {
return data.configs.find((c) => variantOf(c) === v && c.wear === wear && c.phase === phase);
}
function href(config: Config | undefined | null) {
if (!config) return '?';
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
const params = new URLSearchParams();
params.set('v', variantOf(config));
if (config.wear) params.set('wear', wearShort[config.wear]);
if (config.phase) params.set('phase', config.phase);
return `?${params}`;
}
function forVariant(v: Variant) {
return (
find(v, selected?.wear ?? null, selected?.phase ?? null) ??
find(v, selected?.wear ?? null, null) ??
data.configs.find((c) => variantOf(c) === v && c.prices.length) ??
data.configs.find((c) => variantOf(c) === v)
);
}
function forWear(w: string) {
return find(currentVariant, w, selected?.phase ?? null) ?? find(currentVariant, w, null);
}
function configLabel(c: Config) {
const parts = [];
if (c.souvenir) parts.push('Souvenir');
if (c.stattrak) parts.push('StatTrak™');
if (c.wear) parts.push(c.wear);
if (c.phase) parts.push(c.phase);
return parts.join(' · ') || 'Standard';
}
function streamLabel(id: number, anyPhase = false) {
const s = streamById.get(id);
if (!s) return `Stream ${id}`;
return `${sourceNames[s.source] ?? s.source} ${kindNames[s.kind] ?? s.kind.replaceAll('-', ' ')}${anyPhase ? ' (any phase)' : ''}`;
}
let range = $state('all');
let cutoff = $derived.by(() => {
if (range === 'all') return 0;
const days = { '30d': 30, '90d': 90, '1y': 365 }[range] ?? 0;
return Date.now() - days * 86_400_000;
});
let series = $derived<PriceSeries[]>(
(data.history?.series ?? [])
.map((s) => {
const stream = streamById.get(s.stream);
return {
key: `${s.stream}:${s.anyPhase}`,
name: streamLabel(s.stream, s.anyPhase),
color: stream ? colors[`${stream.source}:${stream.kind}`] : undefined,
dashed: s.anyPhase,
points: s.points
.map(([day, cents]) => [Date.parse(day), cents] as [number, number])
.filter(([t]) => t >= cutoff)
};
})
.filter((s) => s.points.length)
);
let days = $derived(new Set(series.flatMap((s) => s.points.map(([t]) => t))).size);
let current = $derived(
(selected?.prices ?? [])
.map((p) => ({ ...p, stream: streamById.get(p.stream), id: p.stream }))
.sort((a, b) => a.eur_cents - b.eur_cents)
);
const spanOrder = ['24h', '7d', '30d', '90d'];
let windows = $derived(
[...(data.history?.windows ?? [])].sort(
(a, b) =>
a.source.localeCompare(b.source) || spanOrder.indexOf(a.span) - spanOrder.indexOf(b.span)
)
);
let windowSources = $derived([...new Set(windows.map((w) => w.source))]);
const fxCents = (cents: number | null, currency: string) =>
cents == null
? null
: currency === 'USD'
? Math.round(cents * (data.history?.fx?.rate ?? 0.92))
: cents;
let title = $derived(
selected && selected.market_hash_name !== data.skin.name
? `${selected.market_hash_name}${selected.phase ? ` (${selected.phase})` : ''}`
: data.skin.name
);
let image = $derived(selected?.icon_url ?? data.skin.image);
</script>
<svelte:head>
<title>{title} · CS prices · Stan Runge</title>
<meta
name="description"
content={`Daily ${data.skin.name} prices for every wear, StatTrak, Souvenir and phase, from Skinport, Steam, CSFloat and Buff163.`}
/>
</svelte:head>
<div class="cs-item">
<a class="back" href="/projects/cs-prices">← CS prices</a>
<header class="head">
{#if image}<img src={image} alt="" width="160" height="120" loading="eager" />{/if}
<div class="title">
<p class="eyebrow">
<span class="rarity" style:--rarity={data.skin.rarity_color ?? 'var(--site-muted)'}></span>
{[data.skin.rarity, data.skin.category ?? data.skin.kind].filter(Boolean).join(' · ')}
</p>
<h1>{data.skin.name}</h1>
<p class="meta">
{#if data.skin.min_float != null}Float {data.skin.min_float.toFixed(
2
)}–{data.skin.max_float?.toFixed(2)} ·
{/if}{data.configs.length} config{data.configs.length === 1 ? '' : 's'}
{#if data.skin.collections?.length}
· {data.skin.collections.join(', ')}{/if}
</p>
</div>
{#if selected}<div class="price">
<strong>{money(best(selected))}</strong><span>{configLabel(selected)}</span>
</div>{/if}
</header>
{#if data.configs.length > 1}
<nav class="configs" aria-label="Config">
{#if variants.length > 1}
<div class="row">
{#each variants as v (v)}
{@const target = forVariant(v)}
<a
class="chip"
class:on={currentVariant === v}
class:st={v === 'stattrak'}
class:sv={v === 'souvenir'}
href={href(target)}
data-sveltekit-reset="false"
data-sveltekit-replacestate
>{v === 'normal' ? 'Normal' : v === 'stattrak' ? 'StatTrak™' : 'Souvenir'}</a
>
{/each}
</div>
{/if}
{#if wears.length}
<div class="row">
{#each wears as w (w)}
{@const target = forWear(w)}
<a
class="chip wear"
class:on={selected?.wear === w}
href={href(target)}
data-sveltekit-reset="false"
data-sveltekit-replacestate
title={w}><b>{wearShort[w]}</b><small>{target ? money(best(target)) : '–'}</small></a
>
{/each}
</div>
{/if}
{#if phases.length}
{@const any = find(currentVariant, selected?.wear ?? null, null)}
<div class="row">
{#if any}<a
class="chip"
class:on={!selected?.phase}
href={href(any)}
data-sveltekit-reset="false"
data-sveltekit-replacestate>Any phase</a
>{/if}
{#each phases as p (p)}
{@const target = find(currentVariant, selected?.wear ?? null, p)}
<a
class="chip phase"
class:on={selected?.phase === p}
data-phase={p}
href={href(target)}
data-sveltekit-reset="false"
data-sveltekit-replacestate
><b>{p.replace('Phase ', 'P')}</b><small>{target ? money(best(target)) : '–'}</small
></a
>
{/each}
</div>
{/if}
</nav>
{/if}
{#if current.length}
<section class="now" aria-label="Latest prices">
{#each current as p (p.id)}
<div
class="tile"
style:--accent={p.stream ? colors[`${p.stream.source}:${p.stream.kind}`] : undefined}
>
<span>{streamLabel(p.id)}</span>
<strong>{money(p.eur_cents)}</strong>
<small
>{p.stream?.currency === 'USD' ? `${usd.format(p.cents / 100)} · ` : ''}{p.volume !=
null
? `${count.format(p.volume)} ${p.stream?.kind === 'lowest-listing' ? 'listed' : 'sold'} · `
: ''}{p.day}</small
>
</div>
{/each}
</section>
{/if}
<section class="card chart-card" aria-label="Price history">
<div class="toolbar">
<h2>Price history <small>EUR · daily</small></h2>
<div class="ranges" role="group" aria-label="Range">
{#each ['30d', '90d', '1y', 'all'] as r (r)}
<button class:on={range === r} onclick={() => (range = r)}
>{r === 'all' ? 'All' : r}</button
>
{/each}
</div>
</div>
{#if series.length}
<PriceChart {series} kind="daily" currency="EUR" height="min(52vh, 420px)" zoom={days > 30} />
<p class="note">
{days} day{days === 1 ? '' : 's'} of history · USD sources at the ECB rate of each day{data
.history?.fx
? ` (now ${data.history.fx.rate.toFixed(4)} €/$)`
: ''} · dashed = price for any phase
</p>
{:else}
<div class="empty">
<strong>No prices yet for this config</strong>
<p>Prices are collected daily for every config; untraded ones stay empty.</p>
</div>
{/if}
</section>
{#if windows.length}
<section class="card" aria-label="Recent sales">
<h2>Recent sales</h2>
<div class="table-wrap">
<table>
<thead
><tr
><th>Source</th>{#each spanOrder as s (s)}<th>{s}</th>{/each}</tr
></thead
>
<tbody>
{#each windowSources as source (source)}
<tr>
<th
>{sourceNames[source] ?? source}{windows.find((w) => w.source === source)
?.anyPhase
? ' (any phase)'
: ''}</th
>
{#each spanOrder as span (span)}
{@const w = windows.find((x) => x.source === source && x.span === span)}
<td>
{#if w}
{money(fxCents(w.median_cents ?? w.avg_cents, w.currency))}
{#if w.volume}<small>{count.format(w.volume)} sold</small>{/if}
{:else}–{/if}
</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
<p class="note">Skinport: median sale price and sales count. Steam: average sale price.</p>
</section>
{/if}
{#if data.configs.length > 1}
<section class="card" aria-label="All configs">
<h2>All configs</h2>
<div class="all">
{#each data.configs as c (c.id)}
<a
class="cfg"
class:on={c.id === data.selected}
href={href(c)}
data-sveltekit-reset="false"
data-sveltekit-replacestate><span>{configLabel(c)}</span><b>{money(best(c))}</b></a
>
{/each}
</div>
</section>
{/if}
</div>
<style>
.cs-item {
max-width: 1100px;
margin: 0 auto;
padding: 2rem clamp(0.9rem, 4vw, 2.5rem) 4rem;
color: var(--site-text);
}
.back {
color: var(--site-muted);
font-size: 0.8rem;
text-decoration: none;
}
.back:hover {
color: var(--site-text);
}
.head {
display: flex;
align-items: center;
gap: 1.2rem;
margin: 1rem 0 1.2rem;
}
.head img {
width: 128px;
height: auto;
object-fit: contain;
flex: none;
filter: drop-shadow(0 6px 18px rgba(0, 0, 0, 0.35));
}
.title {
flex: 1;
min-width: 0;
}
.eyebrow {
color: var(--site-muted);
text-transform: uppercase;
letter-spacing: 0.14em;
font-size: 0.66rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 0.45rem;
}
.rarity {
width: 0.6rem;
height: 0.6rem;
border-radius: 50%;
background: var(--rarity);
box-shadow: 0 0 10px var(--rarity);
}
h1 {
font-size: clamp(1.6rem, 4.5vw, 2.6rem);
line-height: 1.05;
letter-spacing: -0.05em;
margin: 0.35rem 0 0.4rem;
}
h2 {
font-size: 1rem;
letter-spacing: -0.03em;
margin-bottom: 0.7rem;
}
h2 small {
color: var(--site-muted);
font-weight: 500;
font-size: 0.72rem;
margin-left: 0.35rem;
}
.meta {
color: var(--site-muted);
font-size: 0.8rem;
}
.price {
text-align: right;
display: flex;
flex-direction: column;
}
.price strong {
font-size: 1.7rem;
letter-spacing: -0.05em;
}
.price span {
color: var(--site-muted);
font-size: 0.72rem;
}
.configs {
display: flex;
flex-direction: column;
gap: 0.45rem;
margin-bottom: 1rem;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
border: 1px solid var(--site-border);
background: var(--site-surface);
color: var(--site-text);
border-radius: 6px;
padding: 0.4rem 0.65rem;
font-size: 0.78rem;
text-decoration: none;
transition:
border-color 0.15s,
transform 0.15s,
background 0.15s;
}
.chip small {
color: var(--site-muted);
font-size: 0.7rem;
}
.chip:hover {
border-color: var(--site-text);
transform: translateY(-1px);
}
.chip.on {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.chip.on small {
color: inherit;
opacity: 0.75;
}
.chip.st:not(.on) {
color: #f97316;
}
.chip.sv:not(.on) {
color: #eab308;
}
.chip.phase[data-phase='Ruby'] b {
color: #ef4444;
}
.chip.phase[data-phase='Sapphire'] b {
color: #3b82f6;
}
.chip.phase[data-phase='Black Pearl'] b {
color: #a78bfa;
}
.chip.phase[data-phase='Emerald'] b {
color: #22c55e;
}
.chip.on.phase b {
color: inherit !important;
}
.now {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(165px, 1fr));
gap: 0.5rem;
margin-bottom: 1rem;
}
.tile {
border: 1px solid var(--site-border);
border-left: 3px solid var(--accent, var(--site-border));
background: var(--site-surface);
border-radius: 8px;
padding: 0.6rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.tile span,
.tile small {
color: var(--site-muted);
font-size: 0.68rem;
}
.tile strong {
font-size: 1.15rem;
letter-spacing: -0.03em;
}
.card {
border: 1px solid var(--site-border);
background: var(--site-surface);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.8rem;
flex-wrap: wrap;
}
.ranges {
display: flex;
gap: 0.25rem;
margin-bottom: 0.7rem;
}
.ranges button {
border: 1px solid var(--site-border);
background: transparent;
color: var(--site-muted);
border-radius: 5px;
padding: 0.2rem 0.55rem;
font-size: 0.72rem;
cursor: pointer;
}
.ranges button:hover {
color: var(--site-text);
}
.ranges button.on {
background: var(--site-text);
color: var(--site-bg);
}
.note {
color: var(--site-muted);
font-size: 0.7rem;
margin-top: 0.5rem;
}
.empty {
min-height: 220px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.3rem;
text-align: center;
}
.empty p {
color: var(--site-muted);
font-size: 0.78rem;
}
.table-wrap {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.8rem;
}
th,
td {
text-align: right;
padding: 0.4rem 0.5rem;
border-bottom: 1px solid var(--site-border);
white-space: nowrap;
}
th:first-child {
text-align: left;
}
thead th {
color: var(--site-muted);
font-weight: 600;
font-size: 0.7rem;
}
td small {
display: block;
color: var(--site-muted);
font-size: 0.66rem;
}
.all {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 0.3rem;
}
.cfg {
display: flex;
justify-content: space-between;
gap: 0.6rem;
padding: 0.45rem 0.6rem;
border: 1px solid var(--site-border);
border-radius: 6px;
font-size: 0.76rem;
color: var(--site-text);
text-decoration: none;
transition: border-color 0.15s;
}
.cfg:hover {
border-color: var(--site-text);
}
.cfg.on {
background: var(--site-hover);
border-color: var(--site-text);
}
.cfg span {
color: var(--site-muted);
}
@media (max-width: 640px) {
.head {
flex-wrap: wrap;
}
.head img {
width: 96px;
}
.price {
text-align: left;
width: 100%;
flex-direction: row;
align-items: baseline;
gap: 0.5rem;
}
.now {
grid-template-columns: repeat(2, 1fr);
}
}
</style>