Files
website/src/routes/trackers/groceries/+page.svelte

1786 lines
50 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 { enhance, type SubmitFunction } from '$app/forms';
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/state';
import { SvelteSet } from 'svelte/reactivity';
import { Button } from '#lib/components/ui/button/index.js';
import { Input } from '#lib/components/ui/input/index.js';
import { Textarea } from '#lib/components/ui/textarea/index.js';
import * as Dialog from '#lib/components/ui/dialog/index.js';
import Lock from 'lucide-svelte/icons/lock';
import Unlock from 'lucide-svelte/icons/lock-open';
import Star from 'lucide-svelte/icons/star';
import ExternalLink from 'lucide-svelte/icons/external-link';
import Split from 'lucide-svelte/icons/split';
import MoveRight from 'lucide-svelte/icons/move-right';
import Merge from 'lucide-svelte/icons/merge';
import Search from 'lucide-svelte/icons/search';
import ChevronDown from 'lucide-svelte/icons/chevron-down';
import Plus from 'lucide-svelte/icons/plus';
import Camera from 'lucide-svelte/icons/camera';
import Trash from 'lucide-svelte/icons/trash-2';
import Pencil from 'lucide-svelte/icons/pencil';
import Percent from 'lucide-svelte/icons/percent';
import Layers from 'lucide-svelte/icons/layers';
import Store from 'lucide-svelte/icons/store';
import X from 'lucide-svelte/icons/x';
import Apple from 'lucide-svelte/icons/apple';
import Baby from 'lucide-svelte/icons/baby';
import Beef from 'lucide-svelte/icons/beef';
import Candy from 'lucide-svelte/icons/candy';
import Carrot from 'lucide-svelte/icons/carrot';
import Coffee from 'lucide-svelte/icons/coffee';
import CookingPot from 'lucide-svelte/icons/cooking-pot';
import Croissant from 'lucide-svelte/icons/croissant';
import CupSoda from 'lucide-svelte/icons/cup-soda';
import Egg from 'lucide-svelte/icons/egg';
import Ham from 'lucide-svelte/icons/ham';
import Lamp from 'lucide-svelte/icons/lamp';
import Milk from 'lucide-svelte/icons/milk';
import Package from 'lucide-svelte/icons/package';
import Palette from 'lucide-svelte/icons/palette';
import PartyPopper from 'lucide-svelte/icons/party-popper';
import PawPrint from 'lucide-svelte/icons/paw-print';
import Pill from 'lucide-svelte/icons/pill';
import Salad from 'lucide-svelte/icons/salad';
import Snowflake from 'lucide-svelte/icons/snowflake';
import Soup from 'lucide-svelte/icons/soup';
import SprayCan from 'lucide-svelte/icons/spray-can';
import UtensilsCrossed from 'lucide-svelte/icons/utensils-crossed';
import Wine from 'lucide-svelte/icons/wine';
import {
bestPerStore,
cheapest,
formatAmount,
formatCents,
formatUnitPrice,
parseEuros,
sortByPrice,
toOffers,
type Offer
} from '#lib/groceries.js';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
type Item = (typeof data.items)[number];
type Group = (typeof data.groups)[number];
type Category = (typeof data.categories)[number];
type Hit = {
id: number;
name: string;
brand: string | null;
unit: string | null;
storeCount: number;
itemCount: number;
imageUrl: string | null;
minPriceCents: number | null;
};
const ICONS: Record<string, typeof Apple> = {
apple: Apple,
baby: Baby,
beef: Beef,
candy: Candy,
carrot: Carrot,
coffee: Coffee,
'cooking-pot': CookingPot,
croissant: Croissant,
'cup-soda': CupSoda,
egg: Egg,
ham: Ham,
lamp: Lamp,
milk: Milk,
package: Package,
palette: Palette,
'party-popper': PartyPopper,
'paw-print': PawPrint,
pill: Pill,
salad: Salad,
snowflake: Snowflake,
soup: Soup,
'spray-can': SprayCan,
'utensils-crossed': UtensilsCrossed,
wine: Wine
};
const HOMEWARE = new Set([
'keuken-tafel',
'wonen-tuin',
'feest-seizoen',
'hobby-vrije-tijd',
'overig'
]);
let mode = $state<'unit' | 'pack'>('unit');
const expanded = new SvelteSet<number>();
let actionError = $state<string | null>(null);
let picker = $state<{ kind: 'merge' } | { kind: 'move'; itemId: number } | null>(null);
let pickerQuery = $state('');
let pickerHits = $state.raw<Hit[]>([]);
let pickerTimer: ReturnType<typeof setTimeout> | undefined;
// ---- lookups ------------------------------------------------------------------------------
const storeById = $derived(new Map(data.stores.map((s) => [s.id, s])));
const catById = $derived(new Map(data.categories.map((c) => [c.id, c])));
const tops = $derived(data.categories.filter((c) => !c.parentId));
const selectedCat = $derived(
data.categories.find((c) => c.slug === data.filters.category) ?? null
);
const selectedTop = $derived(
selectedCat ? (selectedCat.parentId ? catById.get(selectedCat.parentId)! : selectedCat) : null
);
// Keep the selected chip visible in the horizontally scrolling rows (mobile).
let chipNav = $state<HTMLElement>();
let subNav = $state<HTMLElement>();
function centerOn(nav: HTMLElement | undefined) {
const el = nav?.querySelector<HTMLElement>('.chip.on');
if (!nav || !el || nav.scrollWidth <= nav.clientWidth) return;
nav.scrollLeft = el.offsetLeft - nav.offsetLeft - (nav.clientWidth - el.offsetWidth) / 2;
}
$effect(() => {
void data.filters.category;
centerOn(chipNav);
centerOn(subNav);
});
const subs = $derived(
selectedTop ? data.categories.filter((c) => c.parentId === selectedTop.id && c.count > 0) : []
);
const visibleTops = $derived(
tops.filter(
(c) =>
c.count > 0 && (data.filters.nonfood || !HOMEWARE.has(c.slug) || c.id === selectedTop?.id)
)
);
const topOf = (id: number | null): Category | null => {
const c = id ? catById.get(id) : null;
return c ? (c.parentId ? (catById.get(c.parentId) ?? c) : c) : null;
};
function byGroup<T extends { groupId: number }>(rows: T[]) {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- rebuilt by $derived, never mutated
const m = new Map<number, T[]>();
for (const r of rows) {
const list = m.get(r.groupId);
if (list) list.push(r);
else m.set(r.groupId, [r]);
}
return m;
}
const itemsByGroup = $derived(byGroup(data.items));
const manualByGroup = $derived(byGroup(data.manual));
const itemNames = $derived(new Map(data.items.map((i) => [i.id, i.name])));
const rows = $derived(
data.groups.map((g) => {
const offers = toOffers(
itemsByGroup.get(g.id) ?? [],
manualByGroup.get(g.id) ?? [],
itemNames
);
return {
g,
...bestPerStore(offers, mode),
anyPromo: offers.some((o) => o.promoLabel || o.regularCents)
};
})
);
const pages = $derived(Math.max(1, Math.ceil(data.total / data.pageSize)));
const lastImport = $derived(
data.runs
.filter((r) => r.finishedAt)
.map((r) => new Date(r.finishedAt!).getTime())
.sort((a, b) => b - a)[0]
);
const historyByItem = $derived.by(() => {
// eslint-disable-next-line svelte/prefer-svelte-reactivity -- rebuilt by $derived, never mutated
const m = new Map<number, NonNullable<typeof data.detail>['history'][number][]>();
for (const h of data.detail?.history ?? []) {
const list = m.get(h.itemId);
if (list) list.push(h);
else m.set(h.itemId, [h]);
}
return m;
});
const fmtNum = (n: number) => n.toLocaleString('nl-NL');
const when = (d: Date | string | number) =>
new Date(d).toLocaleString('en-GB', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
timeZone: 'Europe/Amsterdam'
});
const day = (iso: string) =>
new Date(`${iso}T12:00:00Z`).toLocaleDateString('en-GB', { day: 'numeric', month: 'short' });
const today = () => new Date().toLocaleDateString('sv-SE', { timeZone: 'Europe/Amsterdam' });
/** Readable text on a store colour. */
function ink(hex: string | null | undefined) {
const m = /^#?([0-9a-f]{6})$/i.exec(hex ?? '');
if (!m) return '#fff';
const n = parseInt(m[1], 16);
const l = (0.299 * (n >> 16) + 0.587 * ((n >> 8) & 255) + 0.114 * (n & 255)) / 255;
return l > 0.62 ? '#111' : '#fff';
}
const fmtValue = (o: Offer) =>
mode === 'unit'
? (formatUnitPrice(o.unitPriceCents, o.unit) ??
(o.priceCents != null ? formatCents(o.priceCents) : '–'))
: o.priceCents != null
? formatCents(o.priceCents)
: '–';
// ---- navigation ---------------------------------------------------------------------------
function withParams(changes: Record<string, string | null>) {
const u = new URL(page.url.href);
for (const [k, v] of Object.entries(changes)) {
if (v === null || v === '') u.searchParams.delete(k);
else u.searchParams.set(k, v);
}
if (!('page' in changes) && !('g' in changes)) u.searchParams.delete('page');
return `${u.pathname}${u.search}`;
}
const toggle = (k: string, on: boolean) => withParams({ [k]: on ? null : '1' });
const detailOpen = $derived(!!data.detail);
function closeDetail() {
picker = null;
actionError = null;
goto(withParams({ g: null }), { reset: false });
}
// ---- merge / move picker ------------------------------------------------------------------
async function findGroups(q: string): Promise<Hit[]> {
if (!q.trim()) return [];
const res = await fetch(`/trackers/groceries/search?q=${encodeURIComponent(q.trim())}`);
return res.ok ? ((await res.json()) as Hit[]) : [];
}
function openPicker(p: NonNullable<typeof picker>) {
picker = p;
pickerQuery = data.detail?.group.name ?? '';
searchGroups();
}
function searchGroups() {
clearTimeout(pickerTimer);
pickerTimer = setTimeout(async () => {
pickerHits = (await findGroups(pickerQuery)).filter((h) => h.id !== data.detail?.group.id);
}, 200);
}
const submitAction: SubmitFunction =
() =>
async ({ result, update }) => {
if (result.type === 'success' && typeof result.data?.merged === 'number') {
picker = null;
actionError = null;
await goto(withParams({ g: String(result.data.merged) }), {
reset: false,
refreshAll: true
});
return;
}
await update({ reset: false });
if (result.type === 'success') {
picker = null;
actionError = null;
} else
actionError = String(
(result.type === 'failure' && result.data?.message) || 'Could not save'
);
};
const submitInline: SubmitFunction =
() =>
async ({ update }) => {
await update({ reset: false });
};
// ---- shelf price entry --------------------------------------------------------------------
let shelfOpen = $state(false);
let shelfGroup = $state<{ id: number; name: string; imageUrl: string | null } | null>(null);
let shelfStore = $state<number | null>(null);
let shelfItem = $state<string>('');
let shelfQuery = $state('');
let shelfHits = $state.raw<Hit[]>([]);
let shelfPrice = $state('');
let shelfDate = $state('');
let shelfError = $state<string | null>(null);
let shelfBusy = $state(false);
let shelfPhotoName = $state<string | null>(null);
let shelfTimer: ReturnType<typeof setTimeout> | undefined;
let toast = $state<string | null>(null);
let toastTimer: ReturnType<typeof setTimeout> | undefined;
const shelfStores = $derived(data.stores);
/** Store products in the chosen group at the chosen store (to tie the price to one). */
const shelfItems = $derived.by(() => {
if (!shelfGroup || !shelfStore) return [] as Item[];
const pool: Item[] =
data.detail?.group.id === shelfGroup.id
? data.detail.items
: (itemsByGroup.get(shelfGroup.id) ?? []);
return pool.filter((i) => i.storeId === shelfStore);
});
const shelfCents = $derived(parseEuros(shelfPrice));
function openShelf(g?: { id: number; name: string; imageUrl: string | null }) {
shelfGroup = g ? { id: g.id, name: g.name, imageUrl: g.imageUrl } : null;
shelfStore = data.filters.store
? (data.stores.find((s) => s.slug === data.filters.store)?.id ?? null)
: null;
shelfItem = '';
shelfQuery = '';
shelfHits = [];
shelfPrice = '';
shelfDate = today();
shelfError = null;
shelfPhotoName = null;
shelfOpen = true;
}
function searchShelf() {
clearTimeout(shelfTimer);
shelfTimer = setTimeout(async () => (shelfHits = await findGroups(shelfQuery)), 200);
}
/** Phone photos are big: shrink to ≤ 1600 px JPEG before upload. */
async function shrinkPhoto(e: Event) {
const input = e.currentTarget as HTMLInputElement;
const file = input.files?.[0];
shelfPhotoName = file?.name ?? null;
if (!file || file.size < 1_200_000 || !/^image\/(jpeg|png|webp)$/.test(file.type)) return;
try {
const bmp = await createImageBitmap(file);
const scale = Math.min(1, 1600 / Math.max(bmp.width, bmp.height));
const canvas = document.createElement('canvas');
canvas.width = Math.round(bmp.width * scale);
canvas.height = Math.round(bmp.height * scale);
canvas.getContext('2d')!.drawImage(bmp, 0, 0, canvas.width, canvas.height);
const blob = await new Promise<Blob | null>((r) => canvas.toBlob(r, 'image/jpeg', 0.85));
if (!blob) return;
const dt = new DataTransfer();
dt.items.add(
new File([blob], file.name.replace(/\.\w+$/, '') + '.jpg', { type: 'image/jpeg' })
);
input.files = dt.files;
} catch {
// keep the original
}
}
const submitShelf: SubmitFunction = ({ cancel }) => {
if (!shelfGroup || !shelfStore || !shelfCents) {
shelfError = !shelfGroup
? 'Pick a product'
: !shelfStore
? 'Pick a store'
: 'Enter a price like 1,29';
cancel();
return;
}
shelfBusy = true;
return async ({ result }) => {
shelfBusy = false;
if (result.type === 'success') {
shelfOpen = false;
const st = storeById.get(shelfStore!);
toast = `Saved ${formatCents(shelfCents!)} at ${st?.name ?? 'store'}`;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => (toast = null), 3500);
await invalidateAll();
} else
shelfError = String(
(result.type === 'failure' && result.data?.message) || 'Could not save'
);
};
};
</script>
{#snippet storeBadge(storeId: number, small = false)}
{@const st = storeById.get(storeId)}
<span
class={['badge', small && 'sm']}
style:background={st?.color ?? '#525252'}
style:color={ink(st?.color)}>{st?.name ?? '?'}</span
>
{/snippet}
{#snippet catIcon(c: Category | null, size = 16)}
{@const Icon = ICONS[c?.icon ?? 'package'] ?? Package}
<span class="cat-ico" style:--c={c?.color ?? '#737373'}><Icon {size} aria-hidden="true" /></span>
{/snippet}
{#snippet favoriteButton(g: Group)}
<form method="POST" action="?/favorite" use:enhance={submitInline} class="contents">
<input type="hidden" name="id" value={g.id} />
<input type="hidden" name="favorite" value={String(!g.favorite)} />
<button
type="submit"
class={['icon', g.favorite && 'fav']}
aria-pressed={g.favorite}
aria-label={g.favorite ? `Unfavorite ${g.name}` : `Favorite ${g.name}`}
title={g.favorite ? 'Favorite' : 'Mark as favorite'}
>
<Star size={16} fill={g.favorite ? 'currentColor' : 'none'} />
</button>
</form>
{/snippet}
{#snippet offerLine(o: Offer, isBest: boolean)}
<li class={['offer', isBest && 'is-best']}>
{@render storeBadge(o.storeId, true)}
<div class="min-w-0 flex-1">
<div class="offer-name">
{#if o.url}<a href={o.url} target="_blank" rel="noopener noreferrer">{o.name}</a
>{:else}{o.name}{/if}
</div>
<div class="meta flex flex-wrap items-center gap-x-1.5">
{#if o.sizeText}<span>{o.sizeText}</span>{/if}
{#if o.promoLabel}<span class="promo">{o.promoLabel}</span>{/if}
{#if o.manual}
<span class="manual">manual · {day(o.manual.observedOn)}</span>
{#if o.manual.photoUrl}<a
href={o.manual.photoUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex"
title="Shelf photo"><Camera size={12} /></a
>{/if}
{#if o.manual.note}<span title={o.manual.note}>· {o.manual.note}</span>{/if}
{/if}
</div>
</div>
<div class="text-right">
<div class="num price">
{fmtValue(o)}
</div>
<div class="num meta">
{#if mode === 'unit'}{o.priceCents != null && o.unitPriceCents != null
? formatCents(o.priceCents)
: ''}{:else}{formatUnitPrice(o.unitPriceCents, o.unit) ?? ''}{/if}
{#if o.regularCents}<s>{formatCents(o.regularCents)}</s>{/if}
</div>
</div>
</li>
{/snippet}
<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">
<div>
<h1 class="text-4xl font-semibold tracking-tight sm:text-5xl">groceries</h1>
<p class="mt-2 flex flex-wrap items-center gap-1.5 text-sm text-[var(--site-muted)]">
<Lock size={14} aria-hidden="true" /> Private price comparison per kg, litre or piece.
{#if lastImport}<span>Updated {when(lastImport)}.</span>{/if}
</p>
</div>
<dl class="stats">
<div>
<dt>products</dt>
<dd>{fmtNum(data.stats.items)}</dd>
</div>
<div>
<dt>groups</dt>
<dd>{fmtNum(data.stats.groups)}</dd>
</div>
<div>
<dt>in 2+ stores</dt>
<dd>{fmtNum(data.stats.crossStore)}</dd>
</div>
<div>
<dt>promos</dt>
<dd>{fmtNum(data.stats.promos)}</dd>
</div>
</dl>
</header>
<!-- search + sort -->
<form method="GET" class="toolbar" data-sveltekit-keepfocus data-sveltekit-reset="false">
{#each Object.entries( { store: data.filters.store, cat: data.filters.category, fav: data.filters.favorites ? '1' : '', promo: data.filters.promo ? '1' : '', multi: data.filters.multi ? '1' : '', nonfood: data.filters.nonfood ? '1' : '' } ) as [k, v] (k)}
{#if v}<input type="hidden" name={k} value={v} />{/if}
{/each}
<label class="search">
<Search size={16} aria-hidden="true" />
<input
type="search"
name="q"
value={data.filters.q}
placeholder="Search pindakaas, halfvolle melk, Calvé…"
aria-label="Search products"
enterkeyhint="search"
autocomplete="off"
/>
</label>
<select
name="sort"
class="select"
value={data.filters.sort}
aria-label="Sort"
onchange={(e) => (e.currentTarget.form as HTMLFormElement).requestSubmit()}
>
<option value="">Relevance</option>
<option value="unit">Unit price ↑</option>
<option value="price">Price ↑</option>
<option value="promo">Promos first</option>
<option value="stores">Most stores</option>
</select>
<div class="segmented" role="group" aria-label="Compare by">
<button type="button" aria-pressed={mode === 'unit'} onclick={() => (mode = 'unit')}
>/kg·L·st</button
>
<button type="button" aria-pressed={mode === 'pack'} onclick={() => (mode = 'pack')}
>Pack</button
>
</div>
</form>
<!-- categories -->
<nav class="chips" aria-label="Categories" bind:this={chipNav}>
<a
href={withParams({ cat: null })}
class={['chip', !selectedCat && 'on']}
data-sveltekit-reset="false"
aria-current={!selectedCat ? 'page' : undefined}
>
<span class="cat-ico" style:--c="#a3a3a3"><Layers size={16} aria-hidden="true" /></span>All
</a>
{#each visibleTops as c (c.id)}
<a
href={withParams({
cat: selectedTop?.id === c.id && !selectedCat?.parentId ? null : c.slug
})}
class={['chip', selectedTop?.id === c.id && 'on']}
style:--c={c.color}
data-sveltekit-reset="false"
aria-current={selectedTop?.id === c.id ? 'page' : undefined}
>
{@render catIcon(c)}<span>{c.name}</span><span class="count">{fmtNum(c.count)}</span>
</a>
{/each}
</nav>
{#if subs.length}
<nav class="chips subs" aria-label="Subcategories" bind:this={subNav}>
{#each subs as c (c.id)}
<a
href={withParams({ cat: selectedCat?.id === c.id ? selectedTop!.slug : c.slug })}
class={['chip sub', selectedCat?.id === c.id && 'on']}
style:--c={selectedTop?.color}
data-sveltekit-reset="false"
>
{c.name}<span class="count">{fmtNum(c.count)}</span>
</a>
{/each}
</nav>
{/if}
<!-- stores + toggles -->
<nav class="chips tiles" aria-label="Stores and filters">
{#each data.stores as s (s.id)}
{@const on = data.filters.store === s.slug}
<a
href={withParams({ store: on ? null : s.slug })}
class={['tile', on && 'on', s.items === 0 && 'empty']}
style:--c={s.color ?? '#737373'}
data-sveltekit-reset="false"
title={s.items
? `${fmtNum(s.items)} products online`
: 'No online catalogue (yet); shelf prices only'}
>
<span class="dot"></span>{s.name}
</a>
{/each}
<span class="sep" aria-hidden="true"></span>
<a
href={toggle('fav', data.filters.favorites)}
class={['tile', data.filters.favorites && 'on']}
style:--c="#eab308"
data-sveltekit-reset="false"><Star size={14} />Favorites</a
>
<a
href={toggle('promo', data.filters.promo)}
class={['tile', data.filters.promo && 'on']}
style:--c="#f59e0b"
data-sveltekit-reset="false"><Percent size={14} />Promo</a
>
<a
href={toggle('multi', data.filters.multi)}
class={['tile', data.filters.multi && 'on']}
style:--c="#22c55e"
data-sveltekit-reset="false"><Store size={14} />2+ stores</a
>
<a
href={toggle('nonfood', data.filters.nonfood)}
class={['tile', data.filters.nonfood && 'on']}
style:--c="#94a3b8"
data-sveltekit-reset="false"
title="Homeware, décor, seasonal and hobby"><Lamp size={14} />Non-food</a
>
</nav>
<p class="mb-3 flex items-center gap-2 text-sm text-[var(--site-muted)]">
<span
>{fmtNum(data.total)} products{#if data.total > data.pageSize}
· 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}
<a href="/trackers/groceries" class="clear" data-sveltekit-reset="false"
><X size={12} />Clear</a
>
{/if}
</p>
{#if data.stats.items === 0}
<div class="empty-box">No store products yet. The nightly import fills this in.</div>
{/if}
<ul class="list">
{#each rows as r (r.g.id)}
{@const g = r.g}
{@const open = expanded.has(g.id)}
{@const top = topOf(g.categoryId)}
<li class={['row', open && 'open']}>
<div class="row-main">
<button
type="button"
class="row-hit"
aria-expanded={open}
aria-label={`${g.name}: compare stores`}
onclick={() => (open ? expanded.delete(g.id) : expanded.add(g.id))}
></button>
<div class="thumb">
{#if g.imageUrl}
<img
src={g.imageUrl}
alt=""
loading="lazy"
referrerpolicy="no-referrer"
width="48"
height="48"
/>
{:else}
{@render catIcon(top, 20)}
{/if}
</div>
<div class="min-w-0 flex-1">
<div class="row-title">{g.name}</div>
<div class="meta flex flex-wrap items-center gap-x-1.5">
{#if g.brand}<span>{g.brand}</span>{/if}
{#if r.best?.sizeText}<span>· {r.best.sizeText}</span>{/if}
{#if r.stores.length > 1}<span>· {r.stores.length} stores</span>{/if}
{#if r.spread && r.spread.pct > 0}<span
class="spread"
title="Dearest store vs cheapest">+{r.spread.pct}%</span
>{/if}
{#if r.anyPromo}<span class="promo"><Percent size={10} />promo</span>{/if}
{#if r.best?.manual}<span class="manual">manual</span>{/if}
</div>
</div>
<div class="row-price">
{#if r.best}
<div class="num price">{fmtValue(r.best)}</div>
<div class="flex items-center justify-end gap-1">
{#if mode === 'unit' && r.best.priceCents != null && r.best.unitPriceCents != null}<span
class="num meta">{formatCents(r.best.priceCents)}</span
>{/if}
{@render storeBadge(r.best.storeId, true)}
</div>
{:else}
<span class="meta">no price</span>
{/if}
</div>
<ChevronDown size={16} class="chev" aria-hidden="true" />
</div>
{#if open}
<div class="row-detail">
<ul class="offers">
{#each r.stores as o (o.key)}
{@render offerLine(o, o === r.best && r.stores.length > 1)}
{/each}
</ul>
<div class="row-actions" data-sveltekit-reset="false">
{@render favoriteButton(g)}
{#if top}<a
href={withParams({ cat: catById.get(g.categoryId!)?.slug ?? null })}
class="meta inline-flex items-center gap-1"
data-sveltekit-reset="false"
>{@render catIcon(top, 12)}{catById.get(g.categoryId!)?.name}</a
>{/if}
<span class="flex-1"></span>
<Button size="sm" variant="ghost" onclick={() => openShelf(g)}
><Plus size={14} />Shelf price</Button
>
<Button size="sm" variant="outline" href={withParams({ g: String(g.id) })}>
All {g.itemCount} · edit
</Button>
</div>
</div>
{/if}
</li>
{:else}
{#if data.stats.items > 0}<li class="empty-box">Nothing matches.</li>{/if}
{/each}
</ul>
{#if pages > 1}
<nav class="pager" aria-label="Pages">
<a
class={['tile', data.page <= 1 && 'disabled']}
href={withParams({ page: data.page > 2 ? String(data.page - 1) : null })}
aria-disabled={data.page <= 1}>← Prev</a
>
<span class="meta num">{data.page} / {fmtNum(pages)}</span>
<a
class={['tile', data.page >= pages && 'disabled']}
href={withParams({ page: String(Math.min(pages, data.page + 1)) })}
aria-disabled={data.page >= pages}>Next →</a
>
</nav>
{/if}
</div>
<button type="button" class="fab" onclick={() => openShelf()}>
<Plus size={18} /> Shelf price
</button>
{#if toast}<div class="toast" role="status">{toast}</div>{/if}
<!-- shelf price entry -->
<Dialog.Root bind:open={shelfOpen}>
<Dialog.Content class="block max-h-[92dvh] overflow-y-auto sm:max-w-lg">
<Dialog.Header class="mb-3 pr-8">
<Dialog.Title>Shelf price</Dialog.Title>
<Dialog.Description
>A price you saw in a store. Shown next to the online prices, marked manual.</Dialog.Description
>
</Dialog.Header>
<form
method="POST"
action="?/addManualPrice"
enctype="multipart/form-data"
use:enhance={submitShelf}
class="grid gap-4"
>
<div class="grid gap-1.5">
<span class="label">Product</span>
{#if shelfGroup}
<div class="picked">
{#if shelfGroup.imageUrl}<img
src={shelfGroup.imageUrl}
alt=""
referrerpolicy="no-referrer"
width="36"
height="36"
/>{/if}
<span class="min-w-0 flex-1 truncate font-medium">{shelfGroup.name}</span>
<button
type="button"
class="icon"
aria-label="Pick another product"
onclick={() => ((shelfGroup = null), (shelfItem = ''))}><X size={14} /></button
>
</div>
<input type="hidden" name="groupId" value={shelfGroup.id} />
{:else}
<Input
bind:value={shelfQuery}
oninput={searchShelf}
placeholder="Search a product…"
aria-label="Search a product"
autocomplete="off"
/>
{#if shelfHits.length}
<ul class="hits">
{#each shelfHits as h (h.id)}
<li>
<button type="button" class="hit" onclick={() => (shelfGroup = h)}>
{#if h.imageUrl}<img
src={h.imageUrl}
alt=""
referrerpolicy="no-referrer"
width="28"
height="28"
/>{:else}<span class="w-7"></span>{/if}
<span class="min-w-0 flex-1 truncate">{h.name}</span>
<span class="meta whitespace-nowrap"
>{h.brand ?? ''}
{h.minPriceCents != null ? `· ${formatCents(h.minPriceCents)}` : ''}</span
>
</button>
</li>
{/each}
</ul>
{/if}
{/if}
</div>
<fieldset class="grid gap-1.5">
<legend class="label mb-1.5">Store</legend>
<div class="store-grid">
{#each shelfStores as s (s.id)}
<label
class={['store-tile', shelfStore === s.id && 'on']}
style:--c={s.color ?? '#737373'}
>
<input
type="radio"
name="storeId"
value={s.id}
bind:group={shelfStore}
class="sr-only"
/>
<span class="dot"></span>{s.name}
</label>
{/each}
</div>
</fieldset>
{#if shelfItems.length}
<label class="grid gap-1.5">
<span class="label">Which product (optional)</span>
<select name="itemId" class="select w-full" bind:value={shelfItem}>
<option value="">Any / the whole group</option>
{#each shelfItems as i (i.id)}<option value={i.id}
>{i.name} {i.sizeText ?? formatAmount(i.unitAmount, i.unit) ?? ''}</option
>{/each}
</select>
</label>
{/if}
<div class="grid grid-cols-2 gap-3">
<label class="grid min-w-0 gap-1.5">
<span class="label">Price (€)</span>
<input
name="price"
bind:value={shelfPrice}
inputmode="decimal"
placeholder="1,29"
required
class="big-input"
autocomplete="off"
/>
</label>
<label class="grid gap-1.5">
<span class="label">Pack size</span>
<Input
name="size"
placeholder="500 g, 1 L, 6 st"
maxlength={60}
autocomplete="off"
class="h-11"
/>
</label>
<label class="grid gap-1.5">
<span class="label">Seen on</span>
<Input name="observedOn" type="date" bind:value={shelfDate} max={today()} class="h-11" />
</label>
<label class="check mt-6"><input type="checkbox" name="promo" /> Promo price</label>
</div>
<label class="photo">
<Camera size={18} />
<span class="min-w-0 flex-1 truncate">{shelfPhotoName ?? 'Add a photo (optional)'}</span>
<input
type="file"
name="photo"
accept="image/jpeg,image/png,image/webp"
capture="environment"
class="sr-only"
onchange={shrinkPhoto}
/>
</label>
<Textarea
name="note"
rows={2}
maxlength={500}
placeholder="Note (optional): Lidl Amsterdam-Noord, bottom shelf…"
/>
{#if shelfError}<p class="text-sm text-red-500" role="alert">{shelfError}</p>{/if}
<div class="flex items-center justify-end gap-2">
<Button type="button" variant="ghost" onclick={() => (shelfOpen = false)}>Cancel</Button>
<Button type="submit" disabled={shelfBusy}
>{shelfBusy ? 'Saving…' : shelfCents ? `Save ${formatCents(shelfCents)}` : 'Save'}</Button
>
</div>
</form>
</Dialog.Content>
</Dialog.Root>
<!-- group detail (all products, grouping tools) -->
<Dialog.Root open={detailOpen} onOpenChange={(o) => !o && closeDetail()}>
<Dialog.Content class="block max-h-[92dvh] overflow-y-auto sm:max-w-3xl">
{#if data.detail}
{@const d = data.detail}
{@const sorted = sortByPrice(d.items, mode)}
{@const best = cheapest(d.items, mode)}
{@const dTop = topOf(d.group.categoryId)}
<Dialog.Header class="mb-3 pr-8">
<Dialog.Title class="flex items-center gap-2"
>{@render catIcon(dTop)}{d.group.name}</Dialog.Title
>
<Dialog.Description>
{[
d.group.brand,
catById.get(d.group.categoryId ?? 0)?.name,
d.group.manual ? 'edited by hand' : 'auto-grouped'
]
.filter(Boolean)
.join(' · ')}
</Dialog.Description>
</Dialog.Header>
<div class="mb-3 flex flex-wrap items-center gap-2">
<div class="segmented" role="group" aria-label="Compare by">
<button type="button" aria-pressed={mode === 'unit'} onclick={() => (mode = 'unit')}
>Per unit</button
>
<button type="button" aria-pressed={mode === 'pack'} onclick={() => (mode = 'pack')}
>Pack</button
>
</div>
<Button size="sm" variant="outline" onclick={() => openPicker({ kind: 'merge' })}
><Merge size={14} /> Merge into…</Button
>
<Button size="sm" variant="outline" onclick={() => openShelf(d.group)}
><Plus size={14} /> Shelf price</Button
>
</div>
{#if picker}
<div class="box mb-3">
<p class="mb-2 text-sm font-medium">
{picker.kind === 'merge' ? `Merge “${d.group.name}” into:` : 'Move this product to:'}
</p>
<Input
bind:value={pickerQuery}
oninput={searchGroups}
placeholder="Search groups…"
aria-label="Search groups"
/>
<ul class="mt-2 grid gap-1">
{#each pickerHits as h (h.id)}
<li>
<form
method="POST"
action={picker.kind === 'merge' ? '?/merge' : '?/moveItem'}
use:enhance={submitAction}
>
{#if picker.kind === 'merge'}
<input type="hidden" name="id" value={d.group.id} />
{:else}
<input type="hidden" name="itemId" value={picker.itemId} />
{/if}
<input type="hidden" name="into" value={h.id} />
<button type="submit" class="hit">
<span class="min-w-0 flex-1 truncate">{h.name}</span>
<span class="meta"
>{[h.brand, `${h.storeCount} stores`, `${h.itemCount} products`]
.filter(Boolean)
.join(' · ')}</span
>
</button>
</form>
</li>
{:else}
<li class="meta">No groups found.</li>
{/each}
</ul>
<Button size="sm" variant="ghost" class="mt-2" onclick={() => (picker = null)}
>Cancel</Button
>
</div>
{/if}
{#if actionError}<p class="mb-2 text-sm text-red-500" role="alert">{actionError}</p>{/if}
{#if d.manual.length}
<h3 class="mb-1.5 text-sm font-medium">Shelf prices</h3>
<ul class="offers mb-4">
{#each d.manual as m (m.id)}
<li class="offer">
{@render storeBadge(m.storeId, true)}
<div class="min-w-0 flex-1">
<div class="offer-name">
{m.itemId
? (d.items.find((i) => i.id === m.itemId)?.name ?? 'Product')
: 'Whole group'}
</div>
<div class="meta flex flex-wrap items-center gap-x-1.5">
<span class="manual">manual · {day(m.observedOn)}</span>
{#if m.sizeText}<span>{m.sizeText}</span>{/if}
{#if m.promo}<span class="promo">promo</span>{/if}
{#if m.note}<span>{m.note}</span>{/if}
{#if m.photoUrl}<a
href={m.photoUrl}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1"><Camera size={12} />photo</a
>{/if}
</div>
</div>
<div class="text-right">
<div class="num price">{formatCents(m.priceCents)}</div>
<div class="num meta">{formatUnitPrice(m.unitPriceCents, m.unit) ?? ''}</div>
</div>
<form
method="POST"
action="?/deleteManualPrice"
use:enhance={submitAction}
class="contents"
>
<input type="hidden" name="id" value={m.id} />
<button type="submit" class="icon" aria-label="Delete shelf price" title="Delete"
><Trash size={14} /></button
>
</form>
</li>
{/each}
</ul>
{/if}
<h3 class="mb-1.5 text-sm font-medium">Online ({d.items.length})</h3>
<ul class="offers">
{#each sorted as i (i.id)}
{@const hist = historyByItem.get(i.id) ?? []}
{@const v =
mode === 'unit'
? (i.promoUnitPriceCents ?? i.unitPriceCents)
: (i.promoPriceCents ?? i.priceCents)}
<li class={['offer', !i.available && 'gone', best.overall === i.id && 'is-best']}>
{@render storeBadge(i.storeId, true)}
<div class="min-w-0 flex-1">
<div class="offer-name">
{#if i.url}<a href={i.url} target="_blank" rel="noopener noreferrer"
>{i.name} <ExternalLink size={11} class="inline" /></a
>{:else}{i.name}{/if}
</div>
<div class="meta">
{[
i.sizeText,
i.unitSource === 'store'
? `${formatAmount(i.unitAmount, i.unit)} (from store unit price)`
: null,
i.brand,
i.gtin ? `EAN ${i.gtin}` : null,
!i.available ? 'not available' : null,
i.matchMethod
? `${i.matchMethod}${i.matchConfidence != null ? ` ${Math.round(i.matchConfidence * 100)}%` : ''}`
: null
]
.filter(Boolean)
.join(' · ')}
{#if i.matchLocked}<Lock size={10} class="inline" aria-label="locked" />{/if}
</div>
{#if i.promoLabel}<span class="promo">{i.promoLabel}</span>{/if}
{#if hist.length > 1}
<div class="meta">
History: {hist
.slice(0, 6)
.map(
(h) =>
`${h.observedOn.slice(5)} ${h.priceCents != null ? formatCents(h.promoPriceCents ?? h.priceCents) : '–'}`
)
.join(', ')}
</div>
{/if}
</div>
<div class="text-right">
<div class={['num price', best.perStore.has(i.id) && 'store-best']}>
{#if v == null}–{:else if mode === 'unit'}{formatUnitPrice(
v,
i.unit
)}{:else}{formatCents(v)}{/if}
</div>
<div class="num meta">
{mode === 'unit'
? (i.promoPriceCents ?? i.priceCents) != null
? formatCents((i.promoPriceCents ?? i.priceCents)!)
: ''
: (formatUnitPrice(i.promoUnitPriceCents ?? i.unitPriceCents, i.unit) ?? '')}
{#if i.promoPriceCents != null && i.priceCents != null}<s
>{formatCents(i.priceCents)}</s
>{/if}
</div>
</div>
<div class="flex flex-col items-center">
<button
type="button"
class="icon"
title="Move to another group"
aria-label="Move to another group"
onclick={() => openPicker({ kind: 'move', itemId: i.id })}
><MoveRight size={14} /></button
>
<form method="POST" action="?/moveItem" use:enhance={submitAction} class="contents">
<input type="hidden" name="itemId" value={i.id} />
<input type="hidden" name="into" value="new" />
<button
type="submit"
class="icon"
title="Split into its own group"
aria-label="Split into its own group"><Split size={14} /></button
>
</form>
{#if i.matchLocked}
<form
method="POST"
action="?/unlockItem"
use:enhance={submitAction}
class="contents"
>
<input type="hidden" name="itemId" value={i.id} />
<button
type="submit"
class="icon"
title="Let the nightly matcher place it again"
aria-label="Unlock"><Unlock size={14} /></button
>
</form>
{/if}
</div>
</li>
{/each}
</ul>
{#if d.similar.length}
<h3 class="mt-5 mb-2 text-sm font-medium">Similar (other brands)</h3>
<ul class="grid gap-1 text-sm">
{#each d.similar as s (s.id)}
<li class="flex justify-between gap-3">
<a href={withParams({ g: String(s.id) })} data-sveltekit-reset="false" class="link"
>{s.brand ? `${s.brand} · ` : ''}{s.name}</a
>
<span class="num meta whitespace-nowrap"
>{formatUnitPrice(s.minUnitPriceCents, s.unit) ?? ''} · {s.storeCount} stores</span
>
</li>
{/each}
</ul>
{/if}
<form
method="POST"
action="?/rename"
use:enhance={submitAction}
class="mt-5 grid gap-2 border-t border-[var(--site-border)] pt-4"
>
<input type="hidden" name="id" value={d.group.id} />
<label class="field"
><span class="inline-flex items-center gap-1"><Pencil size={12} />Group name</span>
<Input name="name" value={d.group.name} maxlength={200} required /></label
>
<label class="field"
>Notes <Textarea
name="notes"
value={d.group.notes ?? ''}
rows={2}
maxlength={2000}
/></label
>
<div class="flex justify-end"><Button type="submit" size="sm">Save</Button></div>
</form>
{/if}
</Dialog.Content>
</Dialog.Root>
<style>
.stats {
display: flex;
gap: 1.25rem;
}
.stats dt {
font-size: 0.7rem;
color: var(--site-muted);
}
.stats dd {
font-size: 1.25rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
/* toolbar */
.toolbar {
position: sticky;
top: 0;
z-index: 20;
display: flex;
gap: 0.5rem;
padding: 0.6rem 0;
margin-bottom: 0.25rem;
background: color-mix(in srgb, var(--site-bg) 88%, transparent);
backdrop-filter: blur(10px);
}
.search {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.85rem;
height: 2.6rem;
border: 1px solid var(--site-border);
border-radius: 999px;
background: var(--site-surface);
color: var(--site-muted);
transition: border-color 0.15s;
}
.search:focus-within {
border-color: var(--site-text);
}
.search input {
flex: 1;
min-width: 0;
background: transparent;
outline: none;
color: var(--site-text);
font-size: 0.95rem;
}
.select {
height: 2.6rem;
border: 1px solid var(--site-border);
border-radius: 999px;
background: var(--site-surface);
color: var(--site-text);
padding: 0 0.85rem;
font-size: 0.85rem;
}
.segmented {
display: inline-flex;
border: 1px solid var(--site-border);
border-radius: 999px;
overflow: hidden;
flex-shrink: 0;
height: 2.6rem;
}
.segmented button {
font-size: 0.8rem;
padding: 0 0.8rem;
color: var(--site-muted);
white-space: nowrap;
}
.segmented button[aria-pressed='true'] {
background: var(--site-text);
color: var(--site-bg);
font-weight: 600;
}
/* chips */
.chips {
display: flex;
gap: 0.4rem;
overflow-x: auto;
padding: 0.3rem 0;
margin-bottom: 0.25rem;
scrollbar-width: none;
scroll-snap-type: x proximity;
}
.chips::-webkit-scrollbar {
display: none;
}
@media (min-width: 1024px) {
.chips:not(.tiles) {
flex-wrap: wrap;
overflow: visible;
}
}
.chip {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.3rem 0.75rem 0.3rem 0.3rem;
border: 1px solid var(--site-border);
border-radius: 999px;
font-size: 0.82rem;
background: var(--site-surface);
white-space: nowrap;
scroll-snap-align: start;
transition:
background 0.15s,
border-color 0.15s,
transform 0.15s;
}
.chip:hover {
background: var(--site-hover);
border-color: color-mix(in srgb, var(--c, #a3a3a3) 55%, var(--site-border));
transform: translateY(-1px);
}
.chip.on {
background: var(--site-text);
color: var(--site-bg);
border-color: var(--site-text);
}
.chip.sub {
padding: 0.25rem 0.7rem;
font-size: 0.78rem;
}
.chip.sub.on {
background: color-mix(in srgb, var(--c) 22%, var(--site-surface));
color: var(--site-text);
border-color: var(--c);
}
.count {
font-size: 0.7rem;
color: var(--site-muted);
font-variant-numeric: tabular-nums;
}
.chip.on .count {
color: inherit;
opacity: 0.6;
}
.cat-ico {
display: inline-grid;
place-items: center;
width: 1.7rem;
height: 1.7rem;
border-radius: 999px;
color: var(--c);
background: color-mix(in srgb, var(--c) 18%, transparent);
flex-shrink: 0;
}
.chip.on .cat-ico {
background: var(--c);
color: #fff;
}
.tile {
flex-shrink: 0;
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.35rem 0.75rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
font-size: 0.8rem;
white-space: nowrap;
background: var(--site-surface);
transition:
background 0.15s,
border-color 0.15s,
transform 0.15s;
}
.tile:hover {
background: var(--site-hover);
border-color: var(--c, var(--site-muted));
transform: translateY(-1px);
}
.tile.on {
border-color: var(--c);
background: color-mix(in srgb, var(--c) 20%, var(--site-surface));
font-weight: 600;
}
.tile :global(svg) {
color: var(--c);
}
.tile.empty {
opacity: 0.55;
}
.tile.disabled {
pointer-events: none;
opacity: 0.35;
}
.dot {
width: 0.6rem;
height: 0.6rem;
border-radius: 999px;
background: var(--c);
flex-shrink: 0;
}
.sep {
width: 1px;
background: var(--site-border);
margin: 0.2rem 0.2rem;
flex-shrink: 0;
}
.clear {
display: inline-flex;
align-items: center;
gap: 0.2rem;
font-size: 0.75rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
border: 1px solid var(--site-border);
}
.clear:hover {
color: var(--site-text);
}
/* list */
.list {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.5rem;
align-items: start;
}
@media (min-width: 1024px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.row {
min-width: 0;
border: 1px solid var(--site-border);
border-radius: 0.9rem;
background: var(--site-surface);
transition:
border-color 0.15s,
background 0.15s;
}
.row:hover {
border-color: color-mix(in srgb, var(--site-text) 30%, var(--site-border));
}
.row.open {
border-color: color-mix(in srgb, var(--site-text) 45%, var(--site-border));
}
.row-main {
position: relative;
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.55rem 0.7rem;
}
.row-hit {
position: absolute;
inset: 0;
border-radius: 0.9rem;
cursor: pointer;
}
.row-hit:focus-visible {
outline: 2px solid var(--site-text);
}
.thumb {
width: 48px;
height: 48px;
flex-shrink: 0;
display: grid;
place-items: center;
border-radius: 0.6rem;
background: #fff;
overflow: hidden;
}
.thumb img {
width: 100%;
height: 100%;
object-fit: contain;
}
.thumb :global(.cat-ico) {
width: 100%;
height: 100%;
border-radius: 0;
}
.row-title {
font-weight: 500;
font-size: 0.92rem;
line-height: 1.25;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.row-price {
text-align: right;
flex-shrink: 0;
}
.price {
font-weight: 650;
font-size: 0.98rem;
}
.row :global(.chev) {
color: var(--site-muted);
flex-shrink: 0;
transition: transform 0.2s;
}
.row.open :global(.chev) {
transform: rotate(180deg);
}
.row-detail {
border-top: 1px solid var(--site-border);
padding: 0.5rem 0.7rem 0.6rem;
}
.row-actions {
display: flex;
align-items: center;
gap: 0.4rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.offers {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0.15rem;
}
.offer {
min-width: 0;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.4rem 0.45rem;
border-radius: 0.55rem;
}
.offer.is-best {
background: rgb(34 197 94 / 0.1);
box-shadow: inset 3px 0 0 rgb(34 197 94);
}
.offer.is-best .price {
color: rgb(34 197 94);
}
.offer.gone {
opacity: 0.5;
}
.offer-name {
font-size: 0.84rem;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.offer-name a:hover,
.link:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.store-best {
text-decoration: underline;
text-decoration-color: rgb(34 197 94 / 0.6);
text-underline-offset: 3px;
}
.badge {
display: inline-block;
font-size: 0.72rem;
font-weight: 700;
padding: 0.12rem 0.45rem;
border-radius: 0.35rem;
white-space: nowrap;
line-height: 1.2;
flex-shrink: 0;
}
.badge.sm {
font-size: 0.66rem;
padding: 0.08rem 0.38rem;
min-width: 2.6rem;
text-align: center;
}
.meta {
font-size: 0.74rem;
color: var(--site-muted);
}
.num {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.num s {
margin-left: 0.25rem;
}
.promo {
display: inline-flex;
align-items: center;
gap: 0.15rem;
font-size: 0.66rem;
font-weight: 600;
padding: 0.04rem 0.4rem;
border-radius: 999px;
background: rgb(245 158 11 / 0.18);
color: rgb(245 158 11);
white-space: nowrap;
}
.manual {
font-size: 0.66rem;
font-weight: 600;
padding: 0.04rem 0.4rem;
border-radius: 999px;
background: rgb(139 92 246 / 0.18);
color: rgb(167 139 250);
white-space: nowrap;
}
.spread {
font-size: 0.66rem;
font-weight: 600;
color: rgb(248 113 113);
}
.empty-box,
.box {
border: 1px dashed var(--site-border);
border-radius: 0.9rem;
padding: 1rem;
font-size: 0.875rem;
color: var(--site-muted);
list-style: none;
}
.box {
border-style: solid;
color: inherit;
}
.pager {
margin-top: 1.25rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
}
.fab {
position: fixed;
right: max(1rem, env(safe-area-inset-right));
bottom: max(1rem, env(safe-area-inset-bottom));
z-index: 30;
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.75rem 1.1rem;
border-radius: 999px;
background: var(--site-text);
color: var(--site-bg);
font-weight: 600;
font-size: 0.9rem;
box-shadow: 0 8px 30px rgb(0 0 0 / 0.35);
transition: transform 0.15s;
}
.fab:hover {
transform: translateY(-2px);
}
.toast {
position: fixed;
left: 50%;
bottom: 5rem;
transform: translateX(-50%);
z-index: 40;
padding: 0.55rem 1rem;
border-radius: 999px;
background: rgb(34 197 94);
color: #052e16;
font-weight: 600;
font-size: 0.85rem;
}
/* forms */
.label {
font-size: 0.75rem;
font-weight: 500;
color: var(--site-muted);
}
.picked {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.45rem 0.6rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
}
.picked img,
.hit img {
border-radius: 0.35rem;
background: #fff;
object-fit: contain;
}
.hits {
display: grid;
gap: 0.1rem;
max-height: 15rem;
overflow-y: auto;
}
.hit {
display: flex;
align-items: center;
width: 100%;
gap: 0.6rem;
text-align: left;
padding: 0.35rem 0.5rem;
border-radius: 0.45rem;
font-size: 0.85rem;
}
.hit:hover {
background: var(--site-hover);
}
.store-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(6.5rem, 1fr));
gap: 0.35rem;
}
.store-tile {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.6rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
font-size: 0.8rem;
cursor: pointer;
transition:
background 0.15s,
border-color 0.15s;
}
.store-tile:hover {
border-color: var(--c);
}
.store-tile.on {
border-color: var(--c);
background: color-mix(in srgb, var(--c) 22%, transparent);
font-weight: 600;
}
.store-tile:focus-within {
outline: 2px solid var(--c);
}
.big-input {
width: 100%;
min-width: 0;
height: 2.75rem;
border: 1px solid var(--site-border);
border-radius: 0.6rem;
background: transparent;
padding: 0 0.75rem;
font-size: 1.2rem;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.big-input:focus {
outline: none;
border-color: var(--site-text);
}
.photo {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.65rem 0.75rem;
border: 1px dashed var(--site-border);
border-radius: 0.6rem;
font-size: 0.85rem;
color: var(--site-muted);
cursor: pointer;
}
.photo:hover {
border-color: var(--site-text);
color: var(--site-text);
}
.check {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
}
.field {
display: grid;
gap: 0.25rem;
font-size: 0.8rem;
color: var(--site-muted);
}
.icon {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 0.4rem;
padding: 0.3rem;
color: var(--site-muted);
}
.icon:hover {
background: var(--site-hover);
color: var(--site-text);
}
.icon.fav {
color: rgb(234 179 8);
}
@media (max-width: 639px) {
.stats {
display: none;
}
.toolbar {
flex-wrap: wrap;
}
.search {
flex-basis: 100%;
}
.select {
flex: 1;
}
.fab {
padding: 0.85rem;
}
}
</style>