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>
|
<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>
|
||||||
|
</form>
|
||||||
|
|
||||||
{#if categories.length > 0 || groups.length > 0}
|
<!-- Store picker by category -->
|
||||||
<div class="flex gap-2 mt-3 justify-center">
|
{#if storesByCategory().length > 0}
|
||||||
{#if categories.length > 0}
|
<div class="mt-6 space-y-3">
|
||||||
<select
|
{#each storesByCategory() as group}
|
||||||
bind:value={selectedCategory}
|
<div>
|
||||||
class="input-field w-auto text-xs py-1.5 px-2.5"
|
<!-- Category header -->
|
||||||
|
<button
|
||||||
|
onclick={() => toggleCategory(group.category.id)}
|
||||||
|
class="flex items-center gap-2 mb-1.5 group/cat"
|
||||||
>
|
>
|
||||||
<option value="">All categories</option>
|
<span class="w-3 h-3 rounded-sm border flex items-center justify-center flex-shrink-0 transition-colors
|
||||||
{#each categories as cat}
|
{isCategorySelected(group.category.id)
|
||||||
<option value={cat.id}>{cat.name}</option>
|
? 'border-accent/50 bg-accent/20'
|
||||||
{/each}
|
: isCategoryPartial(group.category.id)
|
||||||
</select>
|
? '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}
|
{/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}
|
<!-- Store chips -->
|
||||||
<select
|
<div class="flex flex-wrap gap-1.5 pl-5">
|
||||||
bind:value={selectedGroup}
|
{#each group.stores as store}
|
||||||
class="input-field w-auto text-xs py-1.5 px-2.5"
|
<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>
|
<span class="w-3 h-3 rounded-sm border flex items-center justify-center flex-shrink-0
|
||||||
{#each groups as group}
|
{selectedStoreIds.has(store.id)
|
||||||
<option value={group.id}>{group.name}</option>
|
? 'border-accent/50 bg-accent/20'
|
||||||
{/each}
|
: 'border-text-tertiary/30'}">
|
||||||
</select>
|
{#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}
|
{/if}
|
||||||
|
</span>
|
||||||
|
{store.name}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user