1786 lines
50 KiB
Svelte
1786 lines
50 KiB
Svelte
<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>
|