From bc6090abe885ae136a961a247cca5c9cd9577a6e Mon Sep 17 00:00:00 2001 From: mariosemes Date: Fri, 27 Mar 2026 07:20:16 +0100 Subject: [PATCH] Add shareable search links with saved filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Save a search and get a shareable URL at /share/ 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) --- src/client/src/lib/api.ts | 15 + src/client/src/routes/results/+page.svelte | 40 +- src/client/src/routes/share/[id]/+page.svelte | 341 ++++++++++++++++++ .../db/migrations/005_add_shared_searches.sql | 13 + src/server/index.ts | 2 + src/server/models/shared-search.ts | 51 +++ src/server/routes/share.ts | 41 +++ 7 files changed, 501 insertions(+), 2 deletions(-) create mode 100644 src/client/src/routes/share/[id]/+page.svelte create mode 100644 src/server/db/migrations/005_add_shared_searches.sql create mode 100644 src/server/models/shared-search.ts create mode 100644 src/server/routes/share.ts diff --git a/src/client/src/lib/api.ts b/src/client/src/lib/api.ts index 08589a3..05ebe27 100644 --- a/src/client/src/lib/api.ts +++ b/src/client/src/lib/api.ts @@ -125,3 +125,18 @@ export function searchProductsStream( export function testStore(id: number, query: string) { return api(`/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('/api/share', { method: 'POST', body: JSON.stringify(data) }); +} + +export function getSharedSearch(shareId: string) { + return api(`/api/share/${shareId}`); +} diff --git a/src/client/src/routes/results/+page.svelte b/src/client/src/routes/results/+page.svelte index ca97ac1..e3410fe 100644 --- a/src/client/src/routes/results/+page.svelte +++ b/src/client/src/routes/results/+page.svelte @@ -1,7 +1,7 @@ + +
+ +
+
+ Shared +

Search results for: + {query} +

+ {#if meta} + + ({meta.totalResults} results from {meta.storeCount} stores in {meta.duration}ms) + + {/if} +
+
+ {#if shareMessage} + {shareMessage} + {/if} + Search Again + New Search +
+
+ + {#if loadError} +
+ + + +

{loadError}

+ Go to search +
+ {:else} + + {#if storeProgress.length > 0} +
+
+ + {#if searchDone}All stores completed{:else}Searching stores ({completedCount()}/{totalStores()}){/if} + + {#if !searchDone} +
+
+
+ {/if} +
+
+ {#each storeProgress as store} +
+ {#if store.status === 'searching'} + + + + + {:else if store.status === 'done'} + + + + {:else} + + + + {/if} + {store.name} + {#if store.status === 'searching'} + {store.renderJs ? 'rendering...' : 'fetching...'} + {:else if store.status === 'done'} + {store.resultCount} products + {store.duration}ms + {:else} + {store.error} + {/if} +
+ {/each} +
+
+ {/if} + + + {#if results.length > 0} +
+
+
+ + + + +
+ {filteredAndSorted().length} of {results.length} +
+
+ {#each storeNames() as name} + + {/each} +
+
+ {/if} + + +
+ {#if loading} +
+ + + + +

Loading shared search...

+
+ {:else if searchDone && results.length === 0} +
+

No results found for "{query}"

+
+ {:else if results.length > 0} + + + + + + + + + + + + {#each filteredAndSorted() as product} + + + + + + + + {/each} + +
+ + + + + + + Link +
+ {#if product.image} +
+
+ +
+
+
+ {product.name} +
+
+
+ {:else} +
+ + + +
+ {/if} +
+ {product.name} + + {formatPrice(product.price, product.currency)} + + {product.storeName} + + Open → +
+ {/if} +
+ {/if} +
diff --git a/src/server/db/migrations/005_add_shared_searches.sql b/src/server/db/migrations/005_add_shared_searches.sql new file mode 100644 index 0000000..4f727cb --- /dev/null +++ b/src/server/db/migrations/005_add_shared_searches.sql @@ -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); diff --git a/src/server/index.ts b/src/server/index.ts index 5eb9f49..5940675 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -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) { diff --git a/src/server/models/shared-search.ts b/src/server/models/shared-search.ts new file mode 100644 index 0000000..d385b6d --- /dev/null +++ b/src/server/models/shared-search.ts @@ -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]); +} diff --git a/src/server/routes/share.ts b/src/server/routes/share.ts new file mode 100644 index 0000000..8bf4984 --- /dev/null +++ b/src/server/routes/share.ts @@ -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; + }); +};