prices: fold groceries and CS skins into one price tracker (/trackers/prices) with tabs
Deploy production / deploy (push) Successful in 1m30s
Deploy production / deploy (push) Successful in 1m30s
- tab registry in #lib/price-trackers (add a tab = route + entry + icon) - groceries -> /trackers/prices/groceries (owner-only), CS prices -> /trackers/prices/cs (public) - old /trackers/groceries/* and /projects/cs-prices/* redirect (301 GET, 308 POST) with query - nav: one prices entry replaces groceries and skins; cyan price-tag icon
This commit is contained in:
1 parent
c175c25615
commit
81b0c51672
29 files changed
+163
-28
No files matched your search
@@ -443,7 +443,7 @@ No public signup grants admin privileges.
|
|||||||
|
|
||||||
# CS prices
|
# CS prices
|
||||||
|
|
||||||
`/projects/cs-prices` reads only from Postgres. Each Steam market hash name is an
|
`/trackers/prices/cs` (the CS skins tab of the price tracker; the old `/projects/cs-prices` URLs redirect) reads only from Postgres. Each Steam market hash name is an
|
||||||
individual listing. The separate `cs.item_dimensions` table makes it possible to
|
individual listing. The separate `cs.item_dimensions` table makes it possible to
|
||||||
compare or group by weapon type, weapon name, weapon plus skin, skin, wear, StatTrak, Souvenir, and
|
compare or group by weapon type, weapon name, weapon plus skin, skin, wear, StatTrak, Souvenir, and
|
||||||
additional imported dimensions such as collection or rarity. Group chart points
|
additional imported dimensions such as collection or rarity. Group chart points
|
||||||
|
|||||||
+10
-1
@@ -9,7 +9,8 @@ import { startNotesIndexer } from '#lib/server/notes/indexer.js';
|
|||||||
import { building } from '$app/env';
|
import { building } from '$app/env';
|
||||||
import { auth } from '#lib/server/auth.js';
|
import { auth } from '#lib/server/auth.js';
|
||||||
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
|
import { isOwner, isOwnerOnlyPath, isOwnerWritePath } from '#lib/server/owner.js';
|
||||||
import { error } from '@sveltejs/kit';
|
import { error, redirect } from '@sveltejs/kit';
|
||||||
|
import { legacyPriceRedirect } from '#lib/price-trackers.js';
|
||||||
|
|
||||||
const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/;
|
const analytics = /<!-- analytics -->[\s\S]*?<!-- \/analytics -->/;
|
||||||
|
|
||||||
@@ -47,6 +48,14 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
const read = event.request.method === 'GET' || event.request.method === 'HEAD';
|
const read = event.request.method === 'GET' || event.request.method === 'HEAD';
|
||||||
if (!owner && ((ownerOnly && !read) || (!read && isOwnerWritePath(event.url.pathname))))
|
if (!owner && ((ownerOnly && !read) || (!read && isOwnerWritePath(event.url.pathname))))
|
||||||
error(404, 'Not Found');
|
error(404, 'Not Found');
|
||||||
|
// Groceries and CS prices moved into the price tracker (/trackers/prices/<tab>): old URLs,
|
||||||
|
// sub-paths and API endpoints redirect with their query string (301 for reads, 308 for
|
||||||
|
// POSTs so the body and method survive). The old private URLs 404 for everyone else.
|
||||||
|
const moved = legacyPriceRedirect(event.url.pathname);
|
||||||
|
if (moved) {
|
||||||
|
if (moved.private && !owner) error(404, 'Not Found');
|
||||||
|
redirect(read ? 301 : 308, moved.to + event.url.search);
|
||||||
|
}
|
||||||
// Private pages (the /start new-tab dashboard, trackers) skip Google Analytics: less to
|
// Private pages (the /start new-tab dashboard, trackers) skip Google Analytics: less to
|
||||||
// load, and nothing private gets reported.
|
// load, and nothing private gets reported.
|
||||||
const response = await resolve(
|
const response = await resolve(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Shared (client + server) types and helpers for /trackers/groceries/stats. The snapshot is
|
// Shared (client + server) types and helpers for /trackers/prices/groceries/stats. The snapshot is
|
||||||
// computed nightly by computeStats (src/lib/server/groceries/stats.ts); short keys keep it small.
|
// computed nightly by computeStats (src/lib/server/groceries/stats.ts); short keys keep it small.
|
||||||
|
|
||||||
export type StatsScope = 'food' | 'all';
|
export type StatsScope = 'food' | 'all';
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { defaultPriceTab, legacyPriceRedirect, visiblePriceTabs } from './price-trackers';
|
||||||
|
|
||||||
|
describe('price tracker', () => {
|
||||||
|
it('redirects old groceries and CS prices URLs with their sub-paths', () => {
|
||||||
|
expect(legacyPriceRedirect('/trackers/groceries')).toEqual({
|
||||||
|
to: '/trackers/prices/groceries',
|
||||||
|
private: true
|
||||||
|
});
|
||||||
|
expect(legacyPriceRedirect('/trackers/groceries/stats')?.to).toBe(
|
||||||
|
'/trackers/prices/groceries/stats'
|
||||||
|
);
|
||||||
|
expect(legacyPriceRedirect('/projects/cs-prices/ak-47-redline')).toEqual({
|
||||||
|
to: '/trackers/prices/cs/ak-47-redline',
|
||||||
|
private: false
|
||||||
|
});
|
||||||
|
expect(legacyPriceRedirect('/projects/cs-prices/api/series')?.to).toBe(
|
||||||
|
'/trackers/prices/cs/api/series'
|
||||||
|
);
|
||||||
|
expect(legacyPriceRedirect('/trackers/groceriesx')).toBeNull();
|
||||||
|
expect(legacyPriceRedirect('/trackers/prices/cs')).toBeNull();
|
||||||
|
});
|
||||||
|
it('hides private tabs from everyone but the owner', () => {
|
||||||
|
expect(visiblePriceTabs(false).map((t) => t.slug)).toEqual(['cs']);
|
||||||
|
expect(visiblePriceTabs(true).map((t) => t.slug)).toEqual(['groceries', 'cs']);
|
||||||
|
expect(defaultPriceTab(false).slug).toBe('cs');
|
||||||
|
expect(defaultPriceTab(true).slug).toBe('groceries');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
// The price tracker (/trackers/prices): one page per thing whose prices I follow, shown as
|
||||||
|
// tabs. To add a tracker: put its route under src/routes/trackers/prices/<slug>, add a tab
|
||||||
|
// here (and a colourful icon in #lib/tracker-icons under `icon`), and add the slug to
|
||||||
|
// ownerOnlyPrefixes/ownerWritePrefixes in #lib/server/owner when it is private.
|
||||||
|
|
||||||
|
export type PriceTab = {
|
||||||
|
/** URL segment: /trackers/prices/<slug>. */
|
||||||
|
slug: string;
|
||||||
|
/** Tab label (short, lowercase like the rest of the nav). */
|
||||||
|
name: string;
|
||||||
|
/** Icon key in #lib/tracker-icons. */
|
||||||
|
icon: string;
|
||||||
|
subtitle: string;
|
||||||
|
href: string;
|
||||||
|
/** Owner-only: hidden from the tab bar for everyone else, and the route 404s for them. */
|
||||||
|
private: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const priceTabs: readonly PriceTab[] = [
|
||||||
|
{
|
||||||
|
slug: 'groceries',
|
||||||
|
name: 'groceries',
|
||||||
|
icon: 'groceries',
|
||||||
|
subtitle: 'supermarket products, favorites and prices per store',
|
||||||
|
href: '/trackers/prices/groceries',
|
||||||
|
private: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'cs',
|
||||||
|
name: 'cs skins',
|
||||||
|
icon: 'skins',
|
||||||
|
subtitle: 'Counter-Strike skin market prices across time',
|
||||||
|
href: '/trackers/prices/cs',
|
||||||
|
private: false
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const visiblePriceTabs = (isOwner: boolean) =>
|
||||||
|
priceTabs.filter((tab) => isOwner || !tab.private);
|
||||||
|
|
||||||
|
/** Where the bare /trackers/prices goes: the first tab the visitor can see. */
|
||||||
|
export const defaultPriceTab = (isOwner: boolean) => visiblePriceTabs(isOwner)[0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Old URLs from before the merge (whole subtrees, including the stats page, item pages and
|
||||||
|
* API endpoints). hooks.server.ts redirects them; `private` ones 404 for everyone but me.
|
||||||
|
*/
|
||||||
|
export const legacyPriceRoutes = [
|
||||||
|
{ from: '/trackers/groceries', to: '/trackers/prices/groceries', private: true },
|
||||||
|
{ from: '/projects/cs-prices', to: '/trackers/prices/cs', private: false }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** New location for an old price-tracker path (keeps the rest of the path), or null. */
|
||||||
|
export function legacyPriceRedirect(pathname: string): { to: string; private: boolean } | null {
|
||||||
|
for (const route of legacyPriceRoutes) {
|
||||||
|
if (pathname === route.from || pathname.startsWith(`${route.from}/`))
|
||||||
|
return { to: route.to + pathname.slice(route.from.length), private: route.private };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
+5
-11
@@ -448,11 +448,12 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
|
|||||||
private: true
|
private: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'groceries',
|
// One price tracker with a tab per thing (#lib/price-trackers): groceries (mine) and CS skins.
|
||||||
subtitle: 'supermarket products, favorites and prices per store',
|
name: 'prices',
|
||||||
href: '/trackers/groceries',
|
subtitle: 'prices across time: groceries per store and Counter-Strike skins',
|
||||||
|
href: '/trackers/prices',
|
||||||
external: false,
|
external: false,
|
||||||
private: true
|
private: false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'houses',
|
name: 'houses',
|
||||||
@@ -476,13 +477,6 @@ const allTrackers: readonly (Tracker & { private: boolean })[] = [
|
|||||||
children: gameTrackers,
|
children: gameTrackers,
|
||||||
private: false
|
private: false
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'skins',
|
|
||||||
subtitle: 'Counter-Strike skin market prices across time',
|
|
||||||
href: '/projects/cs-prices',
|
|
||||||
external: false,
|
|
||||||
private: false
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'pets',
|
name: 'pets',
|
||||||
subtitle: 'animaldex: add neighborhood pets to the world map',
|
subtitle: 'animaldex: add neighborhood pets to the world map',
|
||||||
|
|||||||
+1
-1
@@ -104,7 +104,7 @@ const publicPages: Record<string, { title: string; description: string }> = {
|
|||||||
title: 'Animaldex',
|
title: 'Animaldex',
|
||||||
description: 'Find and add neighborhood animals on a map.'
|
description: 'Find and add neighborhood animals on a map.'
|
||||||
},
|
},
|
||||||
'/projects/cs-prices': {
|
'/trackers/prices/cs': {
|
||||||
title: 'CS Prices',
|
title: 'CS Prices',
|
||||||
description:
|
description:
|
||||||
'Compare historical Counter-Strike market prices by listing, weapon, skin, wear, and other item dimensions.'
|
'Compare historical Counter-Strike market prices by listing, weapon, skin, wear, and other item dimensions.'
|
||||||
|
|||||||
@@ -279,7 +279,7 @@ export const grocerySynonyms = groceriesSchema.table(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Nightly analytics for /trackers/groceries/stats (computeStats in
|
* Nightly analytics for /trackers/prices/groceries/stats (computeStats in
|
||||||
* src/lib/server/groceries/stats.ts, run after import + regroup + taxonomy under the import lock).
|
* src/lib/server/groceries/stats.ts, run after import + regroup + taxonomy under the import lock).
|
||||||
* One row per day per scope ('food' = edible only, 'all' = incl. drugstore/household). `data` holds
|
* One row per day per scope ('food' = edible only, 'all' = incl. drugstore/household). `data` holds
|
||||||
* the overall section plus one per top category; `series` (daily chained price index) is only kept
|
* the overall section plus one per top category; `series` (daily chained price index) is only kept
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Analytics for /trackers/groceries/stats. computeStats() runs nightly after the import, regroup
|
// Analytics for /trackers/prices/groceries/stats. computeStats() runs nightly after the import, regroup
|
||||||
// and taxonomy (scripts/groceries-import.ts, under the import lock) and stores one snapshot per
|
// and taxonomy (scripts/groceries-import.ts, under the import lock) and stores one snapshot per
|
||||||
// scope in groceries.stats_snapshots; the page only reads snapshots. computeBattles() is the
|
// scope in groceries.stats_snapshots; the page only reads snapshots. computeBattles() is the
|
||||||
// same battle maths for a store subset, used live when the page filters stores.
|
// same battle maths for a store subset, used live when the page filters stores.
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ describe('owner gate', () => {
|
|||||||
expect(isOwnerWritePath('/trackers/tasks')).toBe(true);
|
expect(isOwnerWritePath('/trackers/tasks')).toBe(true);
|
||||||
expect(isOwnerOnlyPath('/trackers/groceries')).toBe(true);
|
expect(isOwnerOnlyPath('/trackers/groceries')).toBe(true);
|
||||||
expect(isOwnerWritePath('/trackers/groceries')).toBe(true);
|
expect(isOwnerWritePath('/trackers/groceries')).toBe(true);
|
||||||
|
expect(isOwnerOnlyPath('/trackers/prices/groceries/stats')).toBe(true);
|
||||||
|
expect(isOwnerWritePath('/trackers/prices/groceries')).toBe(true);
|
||||||
|
expect(isOwnerOnlyPath('/trackers/prices/cs')).toBe(false);
|
||||||
|
expect(isOwnerWritePath('/trackers/prices/cs/api/series')).toBe(false);
|
||||||
expect(isOwnerOnlyPath('/trackers/data-holders')).toBe(true);
|
expect(isOwnerOnlyPath('/trackers/data-holders')).toBe(true);
|
||||||
expect(isOwnerWritePath('/trackers/data-holders')).toBe(true);
|
expect(isOwnerWritePath('/trackers/data-holders')).toBe(true);
|
||||||
expect(isOwnerOnlyPath('/trackers/houses')).toBe(true);
|
expect(isOwnerOnlyPath('/trackers/houses')).toBe(true);
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ export const ownerOnlyPrefixes = [
|
|||||||
'/start',
|
'/start',
|
||||||
'/trackers/health',
|
'/trackers/health',
|
||||||
'/trackers/tasks',
|
'/trackers/tasks',
|
||||||
|
// Price tracker: the groceries tab is mine; the old URL 404s too instead of redirecting.
|
||||||
|
'/trackers/prices/groceries',
|
||||||
'/trackers/groceries',
|
'/trackers/groceries',
|
||||||
'/trackers/houses',
|
'/trackers/houses',
|
||||||
'/trackers/data-holders',
|
'/trackers/data-holders',
|
||||||
@@ -60,6 +62,7 @@ export const ownerWritePrefixes = [
|
|||||||
'/start',
|
'/start',
|
||||||
'/trackers/health',
|
'/trackers/health',
|
||||||
'/trackers/tasks',
|
'/trackers/tasks',
|
||||||
|
'/trackers/prices/groceries',
|
||||||
'/trackers/groceries',
|
'/trackers/groceries',
|
||||||
'/trackers/houses',
|
'/trackers/houses',
|
||||||
'/trackers/data-holders',
|
'/trackers/data-holders',
|
||||||
|
|||||||
+3
-1
@@ -36,7 +36,9 @@ export function getTitle(path: string, environment: SiteEnvironment = null): str
|
|||||||
'/projects/animaldex/register': 'Log a sighting',
|
'/projects/animaldex/register': 'Log a sighting',
|
||||||
'/projects/animaldex/admin': 'Animaldex admin',
|
'/projects/animaldex/admin': 'Animaldex admin',
|
||||||
'/projects/derby': 'Derby Lens',
|
'/projects/derby': 'Derby Lens',
|
||||||
'/projects/cs-prices': 'CS Prices',
|
'/trackers/prices': 'Prices',
|
||||||
|
'/trackers/prices/cs': 'CS Prices',
|
||||||
|
'/trackers/prices/groceries': 'Groceries',
|
||||||
'/trackers/games': 'Game Tracker',
|
'/trackers/games': 'Game Tracker',
|
||||||
'/projects/language-learner': 'Language Learner',
|
'/projects/language-learner': 'Language Learner',
|
||||||
'/projects/spotify-beatmap-finder': 'osu! map finder',
|
'/projects/spotify-beatmap-finder': 'osu! map finder',
|
||||||
|
|||||||
@@ -189,6 +189,16 @@ export const trackerIcons: Record<string, TrackerIcon> = {
|
|||||||
'M17.3 5H6.7a4 4 0 0 0-4 3.6C2.6 9.4 2 14.5 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.4-1.4A2 2 0 0 1 9.8 16h4.4a2 2 0 0 1 1.4.6L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.5-.6-6.6-.7-7.3A4 4 0 0 0 17.3 5z'
|
'M17.3 5H6.7a4 4 0 0 0-4 3.6C2.6 9.4 2 14.5 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.4-1.4A2 2 0 0 1 9.8 16h4.4a2 2 0 0 1 1.4.6L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.5-.6-6.6-.7-7.3A4 4 0 0 0 17.3 5z'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
prices: {
|
||||||
|
from: '#22d3ee',
|
||||||
|
to: '#0e7490',
|
||||||
|
ink: WHITE,
|
||||||
|
// price tag
|
||||||
|
paths: [
|
||||||
|
'M12.6 2.6A2 2 0 0 0 11.2 2H4a2 2 0 0 0-2 2v7.2a2 2 0 0 0 .6 1.4l8.7 8.7a2.4 2.4 0 0 0 3.4 0l6.6-6.6a2.4 2.4 0 0 0 0-3.4z',
|
||||||
|
c(7.5, 7.5, 1.2)
|
||||||
|
]
|
||||||
|
},
|
||||||
skins: {
|
skins: {
|
||||||
from: '#e879f9',
|
from: '#e879f9',
|
||||||
to: '#a21caf',
|
to: '#a21caf',
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ const staticPaths = [
|
|||||||
'/trackers/books',
|
'/trackers/books',
|
||||||
'/trackers/ai',
|
'/trackers/ai',
|
||||||
'/trackers/radio',
|
'/trackers/radio',
|
||||||
|
'/trackers/prices/cs',
|
||||||
'/trackers/spotting',
|
'/trackers/spotting',
|
||||||
'/trackers/cars',
|
'/trackers/cars',
|
||||||
'/trackers/cars/brands',
|
'/trackers/cars/brands',
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
// One price tracker, a tab per thing (#lib/price-trackers). Each tab keeps its own page,
|
||||||
|
// loader and data; this only adds the tab bar. Private tabs are only listed for me.
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import Tag from 'lucide-svelte/icons/tag';
|
||||||
|
import SpottingSwitcher from '#lib/components/SpottingSwitcher.svelte';
|
||||||
|
import { visiblePriceTabs } from '#lib/price-trackers.js';
|
||||||
|
|
||||||
|
let { children } = $props();
|
||||||
|
const tabs = $derived(visiblePriceTabs(Boolean(page.data.isOwner)));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<SpottingSwitcher items={tabs} home="/trackers/prices" label="prices" homeIcon={Tag} />
|
||||||
|
{@render children()}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { isOwner } from '#lib/server/owner.js';
|
||||||
|
import { defaultPriceTab } from '#lib/price-trackers.js';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
// /trackers/prices has no page of its own: open the first tab this visitor can see.
|
||||||
|
export const load: PageServerLoad = (event) => {
|
||||||
|
redirect(307, defaultPriceTab(isOwner(event)).href);
|
||||||
|
};
|
||||||
File renamed without changes.
+5
-5
@@ -28,7 +28,7 @@
|
|||||||
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- local, non-reactive query builder
|
||||||
const params = new URLSearchParams({ q: browseQuery, kind: browseKind, sort: browseSort });
|
const params = new URLSearchParams({ q: browseQuery, kind: browseKind, sort: browseSort });
|
||||||
params.set('offset', String(offset));
|
params.set('offset', String(offset));
|
||||||
const response = await fetch(`/projects/cs-prices/api/skins?${params}`, { signal });
|
const response = await fetch(`/trackers/prices/cs/api/skins?${params}`, { signal });
|
||||||
if (!response.ok) throw new Error(`Search failed (${response.status})`);
|
if (!response.ok) throw new Error(`Search failed (${response.status})`);
|
||||||
return (await response.json()).skins as Hit[];
|
return (await response.json()).skins as Hit[];
|
||||||
}
|
}
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
searchError = '';
|
searchError = '';
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/projects/cs-prices/api/search?dimension=${encodeURIComponent(d)}&q=${encodeURIComponent(q)}`,
|
`/trackers/prices/cs/api/search?dimension=${encodeURIComponent(d)}&q=${encodeURIComponent(q)}`,
|
||||||
{ signal: controller.signal }
|
{ signal: controller.signal }
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error(`Search failed (${response.status})`);
|
if (!response.ok) throw new Error(`Search failed (${response.status})`);
|
||||||
@@ -225,7 +225,7 @@
|
|||||||
searchError = '';
|
searchError = '';
|
||||||
try {
|
try {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
`/projects/cs-prices/api/search?dimension=${encodeURIComponent(dimension)}&q=${encodeURIComponent(query)}&limit=1000`
|
`/trackers/prices/cs/api/search?dimension=${encodeURIComponent(dimension)}&q=${encodeURIComponent(query)}&limit=1000`
|
||||||
);
|
);
|
||||||
if (!response.ok) throw new Error(`Search failed (${response.status})`);
|
if (!response.ok) throw new Error(`Search failed (${response.status})`);
|
||||||
const found = (await response.json()).results as Result[];
|
const found = (await response.json()).results as Result[];
|
||||||
@@ -262,7 +262,7 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/projects/cs-prices/api/series', {
|
const response = await fetch('/trackers/prices/cs/api/series', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
@@ -357,7 +357,7 @@
|
|||||||
{#each hits as hit (hit.slug)}
|
{#each hits as hit (hit.slug)}
|
||||||
<a
|
<a
|
||||||
class="hit"
|
class="hit"
|
||||||
href={`/projects/cs-prices/${hit.slug}`}
|
href={`/trackers/prices/cs/${hit.slug}`}
|
||||||
style:--rarity={hit.rarity_color ?? 'transparent'}
|
style:--rarity={hit.rarity_color ?? 'transparent'}
|
||||||
>
|
>
|
||||||
{#if hit.image}<img
|
{#if hit.image}<img
|
||||||
File renamed without changes.
+1
-1
@@ -164,7 +164,7 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div class="cs-item">
|
<div class="cs-item">
|
||||||
<a class="back" href="/projects/cs-prices">← CS prices</a>
|
<a class="back" href="/trackers/prices/cs">← CS prices</a>
|
||||||
<header class="head">
|
<header class="head">
|
||||||
{#if image}<img src={image} alt="" width="160" height="120" loading="eager" />{/if}
|
{#if image}<img src={image} alt="" width="160" height="120" loading="eager" />{/if}
|
||||||
<div class="title">
|
<div class="title">
|
||||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+3
-3
@@ -254,7 +254,7 @@
|
|||||||
// ---- merge / move picker ------------------------------------------------------------------
|
// ---- merge / move picker ------------------------------------------------------------------
|
||||||
async function findGroups(q: string): Promise<Hit[]> {
|
async function findGroups(q: string): Promise<Hit[]> {
|
||||||
if (!q.trim()) return [];
|
if (!q.trim()) return [];
|
||||||
const res = await fetch(`/trackers/groceries/search?q=${encodeURIComponent(q.trim())}`);
|
const res = await fetch(`/trackers/prices/groceries/search?q=${encodeURIComponent(q.trim())}`);
|
||||||
return res.ok ? ((await res.json()) as Hit[]) : [];
|
return res.ok ? ((await res.json()) as Hit[]) : [];
|
||||||
}
|
}
|
||||||
function openPicker(p: NonNullable<typeof picker>) {
|
function openPicker(p: NonNullable<typeof picker>) {
|
||||||
@@ -487,7 +487,7 @@
|
|||||||
<dd>{fmtNum(data.stats.promos)}</dd>
|
<dd>{fmtNum(data.stats.promos)}</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
<a href="/trackers/groceries/stats" class="stats-link">
|
<a href="/trackers/prices/groceries/stats" class="stats-link">
|
||||||
<ChartColumn size={16} aria-hidden="true" class="text-violet-500" /> stats
|
<ChartColumn size={16} aria-hidden="true" class="text-violet-500" /> stats
|
||||||
</a>
|
</a>
|
||||||
</header>
|
</header>
|
||||||
@@ -621,7 +621,7 @@
|
|||||||
· page {data.page} of {fmtNum(pages)}{/if}</span
|
· page {data.page} of {fmtNum(pages)}{/if}</span
|
||||||
>
|
>
|
||||||
{#if data.filters.q || data.filters.category || data.filters.store || data.filters.favorites || data.filters.promo || data.filters.multi}
|
{#if data.filters.q || data.filters.category || data.filters.store || data.filters.favorites || data.filters.promo || data.filters.multi}
|
||||||
<a href="/trackers/groceries" class="clear" data-sveltekit-reset="false"
|
<a href="/trackers/prices/groceries" class="clear" data-sveltekit-reset="false"
|
||||||
><X size={12} />Clear</a
|
><X size={12} />Clear</a
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
File renamed without changes.
File renamed without changes.
+1
-1
@@ -588,7 +588,7 @@
|
|||||||
<div class="wrap mx-auto max-w-6xl px-4 pt-10 pb-28 sm:px-6 sm:pt-14">
|
<div class="wrap mx-auto max-w-6xl px-4 pt-10 pb-28 sm:px-6 sm:pt-14">
|
||||||
<header class="mb-5 flex flex-wrap items-end justify-between gap-3">
|
<header class="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<a href="/trackers/groceries" class="back"><ArrowLeft size={14} /> groceries</a>
|
<a href="/trackers/prices/groceries" class="back"><ArrowLeft size={14} /> groceries</a>
|
||||||
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">groceries stats</h1>
|
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">groceries stats</h1>
|
||||||
<p class="mt-2 flex flex-wrap items-center gap-1.5 text-sm text-[var(--site-muted)]">
|
<p class="mt-2 flex flex-wrap items-center gap-1.5 text-sm text-[var(--site-muted)]">
|
||||||
<Lock size={14} aria-hidden="true" />
|
<Lock size={14} aria-hidden="true" />
|
||||||
Reference in new issue
Block a user