Add category-grouped store picker on search page

Replaces dropdown filters with visual store picker organized by
category. Each category shows as a header with checkbox — clicking
it selects/deselects all stores in that category. Individual stores
are toggleable chips with checkmarks. Partial selection shows a
dash indicator on the category checkbox. Only passes specific
store IDs to search when not all stores are selected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
mariosemes
2026-03-26 22:58:45 +01:00
parent 631e07f7ae
commit d9a3693469

View File

@@ -1,34 +1,100 @@
<script> <script>
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { getCategories, getGroups } from '$lib/api'; import { getCategories, getStores } from '$lib/api';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
let query = $state(''); let query = $state('');
let categories = $state([]); let categories = $state([]);
let groups = $state([]); let stores = $state([]);
let selectedCategory = $state(''); let selectedStoreIds = $state(new Set());
let selectedGroup = $state(''); let allSelected = $state(true);
onMount(async () => { onMount(async () => {
try { try {
[categories, groups] = await Promise.all([getCategories(), getGroups()]); [categories, stores] = await Promise.all([getCategories(), getStores()]);
// Start with all enabled stores selected
const enabledIds = stores.filter(s => s.enabled).map(s => s.id);
selectedStoreIds = new Set(enabledIds);
} catch { /* server may not be ready yet */ } } catch { /* server may not be ready yet */ }
}); });
// Group stores by category
let storesByCategory = $derived(() => {
const enabledStores = stores.filter(s => s.enabled);
const grouped = new Map();
// Stores with a category
for (const cat of categories) {
const catStores = enabledStores.filter(s => s.category_id === cat.id);
if (catStores.length > 0) {
grouped.set(cat.id, { category: cat, stores: catStores });
}
}
// Stores without a category
const uncategorized = enabledStores.filter(s => !s.category_id);
if (uncategorized.length > 0) {
grouped.set(null, { category: { id: null, name: 'Other', color: '#6B7280' }, stores: uncategorized });
}
return [...grouped.values()];
});
// Check if all enabled stores are selected
$effect(() => {
const enabledIds = stores.filter(s => s.enabled).map(s => s.id);
allSelected = enabledIds.length > 0 && enabledIds.every(id => selectedStoreIds.has(id));
});
function toggleStore(id) {
const next = new Set(selectedStoreIds);
if (next.has(id)) next.delete(id); else next.add(id);
selectedStoreIds = next;
}
function toggleCategory(catId) {
const catStores = stores.filter(s => s.enabled && (catId === null ? !s.category_id : s.category_id === catId));
const catIds = catStores.map(s => s.id);
const allInCatSelected = catIds.every(id => selectedStoreIds.has(id));
const next = new Set(selectedStoreIds);
if (allInCatSelected) {
catIds.forEach(id => next.delete(id));
} else {
catIds.forEach(id => next.add(id));
}
selectedStoreIds = next;
}
function isCategorySelected(catId) {
const catStores = stores.filter(s => s.enabled && (catId === null ? !s.category_id : s.category_id === catId));
return catStores.length > 0 && catStores.every(s => selectedStoreIds.has(s.id));
}
function isCategoryPartial(catId) {
const catStores = stores.filter(s => s.enabled && (catId === null ? !s.category_id : s.category_id === catId));
const selected = catStores.filter(s => selectedStoreIds.has(s.id));
return selected.length > 0 && selected.length < catStores.length;
}
function handleSearch(e) { function handleSearch(e) {
e.preventDefault(); e.preventDefault();
if (!query.trim()) return; if (!query.trim()) return;
const enabledIds = stores.filter(s => s.enabled).map(s => s.id);
const params = new URLSearchParams({ q: query.trim() }); const params = new URLSearchParams({ q: query.trim() });
if (selectedCategory) params.set('category', selectedCategory);
if (selectedGroup) params.set('group', selectedGroup); // Only pass store IDs if not all are selected
if (!allSelected && selectedStoreIds.size > 0) {
params.set('stores', [...selectedStoreIds].join(','));
}
goto(`/results?${params}`); goto(`/results?${params}`);
} }
</script> </script>
<div class="flex flex-col items-center justify-center min-h-full px-4"> <div class="flex flex-col items-center justify-center min-h-full px-4">
<div class="w-full max-w-xl -mt-20"> <div class="w-full max-w-xl -mt-10">
<div class="mb-8 text-center"> <div class="mb-8 text-center">
<h1 class="text-3xl font-semibold text-text-primary mb-1.5">Price Hunter</h1> <h1 class="text-3xl font-semibold text-text-primary mb-1.5">Price Hunter</h1>
<p class="text-sm text-text-tertiary">Search across all your stores at once</p> <p class="text-sm text-text-tertiary">Search across all your stores at once</p>
@@ -57,34 +123,67 @@
Search Search
</button> </button>
</div> </div>
{#if categories.length > 0 || groups.length > 0}
<div class="flex gap-2 mt-3 justify-center">
{#if categories.length > 0}
<select
bind:value={selectedCategory}
class="input-field w-auto text-xs py-1.5 px-2.5"
>
<option value="">All categories</option>
{#each categories as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
{/if}
{#if groups.length > 0}
<select
bind:value={selectedGroup}
class="input-field w-auto text-xs py-1.5 px-2.5"
>
<option value="">All groups</option>
{#each groups as group}
<option value={group.id}>{group.name}</option>
{/each}
</select>
{/if}
</div>
{/if}
</form> </form>
<!-- Store picker by category -->
{#if storesByCategory().length > 0}
<div class="mt-6 space-y-3">
{#each storesByCategory() as group}
<div>
<!-- Category header -->
<button
onclick={() => toggleCategory(group.category.id)}
class="flex items-center gap-2 mb-1.5 group/cat"
>
<span class="w-3 h-3 rounded-sm border flex items-center justify-center flex-shrink-0 transition-colors
{isCategorySelected(group.category.id)
? 'border-accent/50 bg-accent/20'
: isCategoryPartial(group.category.id)
? 'border-accent/30 bg-accent/10'
: 'border-text-tertiary/30'}">
{#if isCategorySelected(group.category.id)}
<svg class="w-2 h-2 text-accent-text" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{:else if isCategoryPartial(group.category.id)}
<span class="w-1.5 h-0.5 bg-accent-text rounded-full"></span>
{/if}
</span>
<span class="text-2xs font-medium uppercase tracking-wider transition-colors
{isCategorySelected(group.category.id) ? 'text-text-secondary' : 'text-text-tertiary'}
group-hover/cat:text-text-primary"
style="color: {isCategorySelected(group.category.id) ? group.category.color : ''}">
{group.category.name}
</span>
</button>
<!-- Store chips -->
<div class="flex flex-wrap gap-1.5 pl-5">
{#each group.stores as store}
<button
onclick={() => toggleStore(store.id)}
class="flex items-center gap-1.5 text-2xs px-2 py-1 rounded border transition-colors duration-100
{selectedStoreIds.has(store.id)
? 'bg-accent-muted border-accent/30 text-accent-text hover:bg-accent/20'
: 'bg-surface border-surface-border text-text-tertiary line-through opacity-50 hover:opacity-75'}"
>
<span class="w-3 h-3 rounded-sm border flex items-center justify-center flex-shrink-0
{selectedStoreIds.has(store.id)
? 'border-accent/50 bg-accent/20'
: 'border-text-tertiary/30'}">
{#if selectedStoreIds.has(store.id)}
<svg class="w-2 h-2 text-accent-text" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{/if}
</span>
{store.name}
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</div> </div>
</div> </div>