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:
@@ -1,34 +1,100 @@
|
||||
<script>
|
||||
import { goto } from '$app/navigation';
|
||||
import { getCategories, getGroups } from '$lib/api';
|
||||
import { getCategories, getStores } from '$lib/api';
|
||||
import { onMount } from 'svelte';
|
||||
|
||||
let query = $state('');
|
||||
let categories = $state([]);
|
||||
let groups = $state([]);
|
||||
let selectedCategory = $state('');
|
||||
let selectedGroup = $state('');
|
||||
let stores = $state([]);
|
||||
let selectedStoreIds = $state(new Set());
|
||||
let allSelected = $state(true);
|
||||
|
||||
onMount(async () => {
|
||||
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 */ }
|
||||
});
|
||||
|
||||
// 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) {
|
||||
e.preventDefault();
|
||||
if (!query.trim()) return;
|
||||
|
||||
const enabledIds = stores.filter(s => s.enabled).map(s => s.id);
|
||||
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}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
@@ -57,34 +123,67 @@
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#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"
|
||||
<!-- 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"
|
||||
>
|
||||
<option value="">All categories</option>
|
||||
{#each categories as cat}
|
||||
<option value={cat.id}>{cat.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<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>
|
||||
|
||||
{#if groups.length > 0}
|
||||
<select
|
||||
bind:value={selectedGroup}
|
||||
class="input-field w-auto text-xs py-1.5 px-2.5"
|
||||
<!-- 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'}"
|
||||
>
|
||||
<option value="">All groups</option>
|
||||
{#each groups as group}
|
||||
<option value={group.id}>{group.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<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}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user