Add shareable search links with saved filters

Save a search and get a shareable URL at /share/<id> that preserves:
- Search query
- Selected stores
- Filter text
- Excluded stores
- Sort column and direction

Backend:
- New shared_searches table (migration 005)
- POST /api/share — saves search state, returns share_id
- GET /api/share/:id — loads saved search config

Frontend:
- Share button appears on results page after search completes
- Copies link to clipboard with confirmation message
- /share/[id] page loads saved config, re-runs search with same
  store selection, and restores all filters
- Shows "Shared" badge in header with "Search Again" link

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
mariosemes
2026-03-27 07:20:16 +01:00
parent fd82f5aef4
commit bc6090abe8
7 changed files with 501 additions and 2 deletions

View File

@@ -125,3 +125,18 @@ export function searchProductsStream(
export function testStore(id: number, query: string) {
return api<any>(`/api/stores/${id}/test`, { method: 'POST', body: JSON.stringify({ query }) });
}
export function saveSharedSearch(data: {
query: string;
storeIds?: string;
filterText?: string;
excludedStores?: string;
sortBy?: string;
sortAsc?: boolean;
}) {
return api<any>('/api/share', { method: 'POST', body: JSON.stringify(data) });
}
export function getSharedSearch(shareId: string) {
return api<any>(`/api/share/${shareId}`);
}

View File

@@ -1,7 +1,7 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { searchProductsStream } from '$lib/api';
import { searchProductsStream, saveSharedSearch } from '$lib/api';
import { onMount } from 'svelte';
let query = $state('');
@@ -123,6 +123,29 @@
excludedStores = next;
}
let shareMessage = $state('');
async function handleShare() {
try {
const params = $page.url.searchParams;
const shared = await saveSharedSearch({
query,
storeIds: params.get('stores') || undefined,
filterText: filterText || undefined,
excludedStores: excludedStores.size > 0 ? [...excludedStores].join(',') : undefined,
sortBy,
sortAsc,
});
const shareUrl = `${window.location.origin}/share/${shared.share_id}`;
await navigator.clipboard.writeText(shareUrl);
shareMessage = 'Link copied to clipboard!';
setTimeout(() => shareMessage = '', 3000);
} catch {
shareMessage = 'Failed to create share link';
setTimeout(() => shareMessage = '', 3000);
}
}
function positionPreview(e) {
const thumb = e.currentTarget;
const preview = thumb.querySelector('.thumb-preview');
@@ -161,7 +184,20 @@
</span>
{/if}
</div>
<a href="/" class="btn-secondary text-xs py-1">New Search</a>
<div class="flex items-center gap-2">
{#if shareMessage}
<span class="text-2xs text-emerald-400">{shareMessage}</span>
{/if}
{#if searchDone && results.length > 0}
<button onclick={handleShare} class="btn-secondary text-xs py-1 flex items-center gap-1.5">
<svg class="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M7.217 10.907a2.25 2.25 0 100 2.186m0-2.186c.18.324.283.696.283 1.093s-.103.77-.283 1.093m0-2.186l9.566-5.314m-9.566 7.5l9.566 5.314m0 0a2.25 2.25 0 103.935 2.186 2.25 2.25 0 00-3.935-2.186zm0-12.814a2.25 2.25 0 103.933-2.185 2.25 2.25 0 00-3.933 2.185z" />
</svg>
Share
</button>
{/if}
<a href="/" class="btn-secondary text-xs py-1">New Search</a>
</div>
</div>
<!-- Store Progress -->

View File

@@ -0,0 +1,341 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getSharedSearch, searchProductsStream, saveSharedSearch } from '$lib/api';
import { onMount } from 'svelte';
let query = $state('');
let results = $state([]);
let meta = $state(null);
let loading = $state(true);
let sortBy = $state('price');
let sortAsc = $state(true);
let filterText = $state('');
let excludedStores = $state(new Set());
let storeProgress = $state([]);
let searchDone = $state(false);
let loadError = $state('');
let shareMessage = $state('');
let sharedConfig = $state(null);
let cleanupStream = null;
onMount(async () => {
try {
const shared = await getSharedSearch($page.params.id);
sharedConfig = shared;
query = shared.query;
sortBy = shared.sort_by || 'price';
sortAsc = shared.sort_asc !== 0;
filterText = shared.filter_text || '';
if (shared.excluded_stores) {
excludedStores = new Set(shared.excluded_stores.split(','));
}
// Run the search with saved store selection
doSearch(shared.store_ids || undefined);
} catch {
loadError = 'Shared search not found or has expired.';
loading = false;
}
return () => { if (cleanupStream) cleanupStream(); };
});
function doSearch(storeIds) {
loading = true;
searchDone = false;
results = [];
meta = null;
storeProgress = [];
cleanupStream = searchProductsStream(query, {
stores: storeIds,
}, (event) => {
switch (event.type) {
case 'start':
storeProgress = event.stores.map((s) => ({
id: s.id, name: s.name, renderJs: s.renderJs,
status: 'searching', resultCount: 0, duration: 0, error: null,
}));
loading = false;
break;
case 'store_complete':
results = [...results, ...event.results];
storeProgress = storeProgress.map((s) =>
s.id === event.storeId
? { ...s, status: 'done', resultCount: event.resultCount, duration: event.duration }
: s
);
break;
case 'store_error':
storeProgress = storeProgress.map((s) =>
s.id === event.storeId
? { ...s, status: 'error', error: event.error, duration: event.duration }
: s
);
break;
case 'done':
meta = event.meta;
searchDone = true;
break;
}
});
}
let storeNames = $derived(() => {
const names = [...new Set(results.map(r => r.storeName))];
return names.sort();
});
let filteredAndSorted = $derived(() => {
let items = [...results];
if (filterText) {
const lower = filterText.toLowerCase();
items = items.filter(r => r.name.toLowerCase().includes(lower));
}
if (excludedStores.size > 0) {
items = items.filter(r => !excludedStores.has(r.storeName));
}
items.sort((a, b) => {
let cmp = 0;
switch (sortBy) {
case 'price': cmp = (a.price ?? Infinity) - (b.price ?? Infinity); break;
case 'name': cmp = a.name.localeCompare(b.name); break;
case 'store': cmp = a.storeName.localeCompare(b.storeName); break;
}
return sortAsc ? cmp : -cmp;
});
return items;
});
function handleSort(column) {
if (sortBy === column) { sortAsc = !sortAsc; } else { sortBy = column; sortAsc = column === 'name' || column === 'store'; }
}
function toggleStore(name) {
const next = new Set(excludedStores);
if (next.has(name)) next.delete(name); else next.add(name);
excludedStores = next;
}
function positionPreview(e) {
const thumb = e.currentTarget;
const preview = thumb.querySelector('.thumb-preview');
if (!preview) return;
const rect = thumb.getBoundingClientRect();
preview.style.left = `${rect.right + 12}px`;
preview.style.top = `${rect.top + rect.height / 2}px`;
preview.style.transform = 'translateY(-50%)';
}
function formatPrice(price, currency) {
if (price === null || price === undefined) return 'N/A';
try { return new Intl.NumberFormat('en-US', { style: 'currency', currency }).format(price); }
catch { return `${currency} ${price.toFixed(2)}`; }
}
function sortIcon(column) {
if (sortBy !== column) return '';
return sortAsc ? '\u2191' : '\u2193';
}
let completedCount = $derived(() => storeProgress.filter(s => s.status !== 'searching').length);
let totalStores = $derived(() => storeProgress.length);
</script>
<div class="h-full flex flex-col max-w-[1400px] mx-auto w-full">
<!-- Header -->
<div class="flex-shrink-0 border-b border-surface-border px-6 py-3 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-2xs text-accent-text bg-accent-muted px-2 py-0.5 rounded">Shared</span>
<h1 class="text-sm font-semibold text-text-primary">Search results for:
<span class="text-accent-text">{query}</span>
</h1>
{#if meta}
<span class="text-2xs text-text-tertiary">
({meta.totalResults} results from {meta.storeCount} stores in {meta.duration}ms)
</span>
{/if}
</div>
<div class="flex items-center gap-2">
{#if shareMessage}
<span class="text-2xs text-emerald-400">{shareMessage}</span>
{/if}
<a href="/results?q={encodeURIComponent(query)}" class="btn-secondary text-xs py-1">Search Again</a>
<a href="/" class="btn-secondary text-xs py-1">New Search</a>
</div>
</div>
{#if loadError}
<div class="flex flex-col items-center justify-center py-20 text-text-tertiary">
<svg class="w-10 h-10 mb-3 text-text-tertiary/50" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
</svg>
<p class="text-sm">{loadError}</p>
<a href="/" class="text-xs text-accent-text hover:text-accent mt-2">Go to search</a>
</div>
{:else}
<!-- Store Progress -->
{#if storeProgress.length > 0}
<div class="flex-shrink-0 border-b border-surface-border px-6 py-3">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-medium text-text-secondary">
{#if searchDone}All stores completed{:else}Searching stores ({completedCount()}/{totalStores()}){/if}
</span>
{#if !searchDone}
<div class="flex-1 h-1 bg-surface-hover rounded-full overflow-hidden max-w-xs">
<div class="h-full bg-accent rounded-full transition-all duration-300"
style="width: {totalStores() > 0 ? (completedCount() / totalStores() * 100) : 0}%"></div>
</div>
{/if}
</div>
<div class="flex flex-wrap gap-2">
{#each storeProgress as store}
<div class="flex items-center gap-2 text-xs px-3 py-1.5 rounded-lg border transition-all duration-300
{store.status === 'searching' ? 'bg-surface-raised border-surface-border text-text-secondary'
: store.status === 'done' ? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
: 'bg-red-500/10 border-red-500/20 text-red-400'}">
{#if store.status === 'searching'}
<svg class="w-3.5 h-3.5 animate-spin text-accent-text" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
{:else if store.status === 'done'}
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
</svg>
{:else}
<svg class="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
{/if}
<span class="font-medium">{store.name}</span>
{#if store.status === 'searching'}
<span class="text-text-tertiary">{store.renderJs ? 'rendering...' : 'fetching...'}</span>
{:else if store.status === 'done'}
<span>{store.resultCount} products</span>
<span class="text-emerald-600">{store.duration}ms</span>
{:else}
<span class="truncate max-w-[150px]" title={store.error}>{store.error}</span>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<!-- Filters -->
{#if results.length > 0}
<div class="flex-shrink-0 border-b border-surface-border px-6 py-3 space-y-2.5">
<div class="flex items-center gap-3">
<div class="relative flex-1">
<svg class="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-text-tertiary" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" />
</svg>
<input type="text" bind:value={filterText} placeholder="Filter results..."
class="w-full pl-10 pr-4 py-2.5 bg-surface-raised border border-surface-border rounded-lg text-sm
text-text-primary placeholder-text-tertiary focus:border-accent/50 focus:ring-1 focus:ring-accent/20 focus:outline-none transition-all" />
</div>
<span class="text-xs text-text-tertiary whitespace-nowrap">{filteredAndSorted().length} of {results.length}</span>
</div>
<div class="flex items-center gap-1.5 flex-wrap">
{#each storeNames() as name}
<button onclick={() => toggleStore(name)}
class="flex items-center gap-1.5 text-2xs px-2 py-1 rounded border transition-colors duration-100
{excludedStores.has(name) ? 'bg-surface border-surface-border text-text-tertiary line-through opacity-50 hover:opacity-75'
: 'bg-accent-muted border-accent/30 text-accent-text hover:bg-accent/20'}">
<span class="w-3 h-3 rounded-sm border flex items-center justify-center flex-shrink-0
{excludedStores.has(name) ? 'border-text-tertiary/30' : 'border-accent/50 bg-accent/20'}">
{#if !excludedStores.has(name)}
<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>
{name}
</button>
{/each}
</div>
</div>
{/if}
<!-- Table -->
<div class="flex-1 overflow-auto">
{#if loading}
<div class="flex flex-col items-center justify-center py-20 text-text-tertiary">
<svg class="w-8 h-8 animate-spin text-accent mb-3" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-sm">Loading shared search...</p>
</div>
{:else if searchDone && results.length === 0}
<div class="flex flex-col items-center justify-center py-20 text-text-tertiary">
<p class="text-sm">No results found for "{query}"</p>
</div>
{:else if results.length > 0}
<table class="w-full">
<thead class="sticky top-0 z-10 bg-surface">
<tr class="border-b border-surface-border">
<th class="w-12 px-6 py-2.5"></th>
<th class="px-4 py-2.5 text-left">
<button onclick={() => handleSort('name')} class="text-2xs font-medium text-text-tertiary uppercase tracking-wider hover:text-text-secondary transition-colors">Product {sortIcon('name')}</button>
</th>
<th class="px-4 py-2.5 text-right">
<button onclick={() => handleSort('price')} class="text-2xs font-medium text-text-tertiary uppercase tracking-wider hover:text-text-secondary transition-colors">Price {sortIcon('price')}</button>
</th>
<th class="px-4 py-2.5 text-left">
<button onclick={() => handleSort('store')} class="text-2xs font-medium text-text-tertiary uppercase tracking-wider hover:text-text-secondary transition-colors">Store {sortIcon('store')}</button>
</th>
<th class="px-6 py-2.5 text-right">
<span class="text-2xs font-medium text-text-tertiary uppercase tracking-wider">Link</span>
</th>
</tr>
</thead>
<tbody>
{#each filteredAndSorted() as product}
<tr class="border-b border-surface-border hover:bg-surface-hover/50 transition-colors duration-100 group">
<td class="px-6 py-2">
{#if product.image}
<div class="relative thumb-hover" onmouseenter={positionPreview}>
<div class="w-8 h-8 rounded bg-surface flex items-center justify-center overflow-hidden cursor-pointer">
<img src={product.image} alt="" class="max-w-full max-h-full object-contain" />
</div>
<div class="thumb-preview pointer-events-none">
<div class="w-80 max-h-80 rounded-lg bg-surface-raised border border-surface-border shadow-2xl shadow-black/50 p-5">
<img src={product.image} alt={product.name} class="max-w-full max-h-full object-contain" />
</div>
</div>
</div>
{:else}
<div class="w-8 h-8 rounded bg-surface flex items-center justify-center">
<svg class="w-4 h-4 text-text-tertiary/20" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909M3.75 21h16.5" />
</svg>
</div>
{/if}
</td>
<td class="px-4 py-2">
<span class="text-sm text-text-primary group-hover:text-white transition-colors line-clamp-1">{product.name}</span>
</td>
<td class="px-4 py-2 text-right">
<span class="text-sm font-semibold text-accent-text whitespace-nowrap">{formatPrice(product.price, product.currency)}</span>
</td>
<td class="px-4 py-2 whitespace-nowrap">
<span class="text-2xs text-text-tertiary bg-surface px-1.5 py-0.5 rounded">{product.storeName}</span>
</td>
<td class="px-6 py-2 text-right whitespace-nowrap">
<a href={product.url} target="_blank" rel="noopener noreferrer"
class="text-2xs text-accent-text hover:text-accent transition-colors opacity-0 group-hover:opacity-100">Open &rarr;</a>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/if}
</div>

View File

@@ -0,0 +1,13 @@
CREATE TABLE shared_searches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
share_id TEXT NOT NULL UNIQUE,
query TEXT NOT NULL,
store_ids TEXT,
filter_text TEXT,
excluded_stores TEXT,
sort_by TEXT DEFAULT 'price',
sort_asc INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX idx_shared_searches_share_id ON shared_searches(share_id);

View File

@@ -14,6 +14,7 @@ import { categoryRoutes } from './routes/categories.js';
import { searchRoutes } from './routes/search.js';
import { testRoutes } from './routes/test.js';
import { healthRoutes } from './routes/health.js';
import { shareRoutes } from './routes/share.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -34,6 +35,7 @@ await app.register(categoryRoutes, { prefix: '/api' });
await app.register(searchRoutes, { prefix: '/api' });
await app.register(testRoutes, { prefix: '/api' });
await app.register(healthRoutes, { prefix: '/api' });
await app.register(shareRoutes, { prefix: '/api' });
// Serve static frontend in production
if (config.isProduction) {

View File

@@ -0,0 +1,51 @@
import { getDatabase, saveDatabase } from '../db/connection.js';
import { queryOne } from '../db/query.js';
import crypto from 'node:crypto';
export interface SharedSearch {
id: number;
share_id: string;
query: string;
store_ids: string | null;
filter_text: string | null;
excluded_stores: string | null;
sort_by: string;
sort_asc: number;
created_at: string;
}
function generateShareId(): string {
return crypto.randomBytes(6).toString('base64url');
}
export function createSharedSearch(data: {
query: string;
storeIds?: string;
filterText?: string;
excludedStores?: string;
sortBy?: string;
sortAsc?: boolean;
}): SharedSearch {
const db = getDatabase();
const shareId = generateShareId();
db.run(`
INSERT INTO shared_searches (share_id, query, store_ids, filter_text, excluded_stores, sort_by, sort_asc)
VALUES (?, ?, ?, ?, ?, ?, ?)
`, [
shareId,
data.query,
data.storeIds || null,
data.filterText || null,
data.excludedStores || null,
data.sortBy || 'price',
data.sortAsc !== false ? 1 : 0,
]);
saveDatabase();
return getSharedSearch(shareId) as SharedSearch;
}
export function getSharedSearch(shareId: string): SharedSearch | undefined {
return queryOne('SELECT * FROM shared_searches WHERE share_id = ?', [shareId]);
}

View File

@@ -0,0 +1,41 @@
import type { FastifyPluginAsync } from 'fastify';
import { createSharedSearch, getSharedSearch } from '../models/shared-search.js';
export const shareRoutes: FastifyPluginAsync = async (app) => {
// Save a search
app.post<{
Body: {
query: string;
storeIds?: string;
filterText?: string;
excludedStores?: string;
sortBy?: string;
sortAsc?: boolean;
};
}>('/share', {
schema: {
body: {
type: 'object',
required: ['query'],
properties: {
query: { type: 'string', minLength: 1 },
storeIds: { type: 'string' },
filterText: { type: 'string' },
excludedStores: { type: 'string' },
sortBy: { type: 'string' },
sortAsc: { type: 'boolean' },
},
},
},
}, async (request, reply) => {
const shared = createSharedSearch(request.body);
return reply.code(201).send(shared);
});
// Load a shared search
app.get<{ Params: { id: string } }>('/share/:id', async (request, reply) => {
const shared = getSharedSearch(request.params.id);
if (!shared) return reply.code(404).send({ error: 'Shared search not found' });
return shared;
});
};