Initial commit: Price Hunter — self-hosted price comparison engine

Complete application scaffolding with:
- Backend: Node.js + Fastify + sql.js (SQLite)
- Frontend: SvelteKit + Tailwind CSS
- Scraper engine with parallel fan-out, rate limiting, cheerio-based parsing
- Store management with CSS selector config and per-store test pages
- Docker setup for single-command deployment

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
mariosemes
2026-03-26 20:54:52 +01:00
commit e0f67d0835
47 changed files with 9181 additions and 0 deletions

2502
src/client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

23
src/client/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "pricehunter-client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@sveltejs/adapter-static": "^3.0.8",
"@sveltejs/kit": "^2.15.0",
"@sveltejs/vite-plugin-svelte": "^5.0.0",
"svelte": "^5.16.0"
},
"devDependencies": {
"autoprefixer": "^10.4.20",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"vite": "^6.0.6"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

13
src/client/src/app.css Normal file
View File

@@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--color-primary: #2563eb;
--color-primary-hover: #1d4ed8;
}
body {
@apply bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

13
src/client/src/app.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<title>Price Hunter</title>
%sveltekit.head%
</head>
<body data-sveltekit-prerender="true">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>

91
src/client/src/lib/api.ts Normal file
View File

@@ -0,0 +1,91 @@
const BASE = '';
class ApiError extends Error {
constructor(public status: number, public body: string) {
super(`API error ${status}: ${body}`);
}
}
export async function api<T>(path: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
});
if (!res.ok) {
throw new ApiError(res.status, await res.text());
}
if (res.status === 204) return undefined as T;
return res.json();
}
export function getStores() {
return api<any[]>('/api/stores');
}
export function getStore(id: number) {
return api<any>(`/api/stores/${id}`);
}
export function createStore(data: any) {
return api<any>('/api/stores', { method: 'POST', body: JSON.stringify(data) });
}
export function updateStore(id: number, data: any) {
return api<any>(`/api/stores/${id}`, { method: 'PUT', body: JSON.stringify(data) });
}
export function toggleStore(id: number) {
return api<any>(`/api/stores/${id}/toggle`, { method: 'PATCH' });
}
export function deleteStore(id: number) {
return api<void>(`/api/stores/${id}`, { method: 'DELETE' });
}
export function getCategories() {
return api<any[]>('/api/categories');
}
export function createCategory(name: string, color?: string) {
return api<any>('/api/categories', { method: 'POST', body: JSON.stringify({ name, color }) });
}
export function updateCategory(id: number, data: any) {
return api<any>(`/api/categories/${id}`, { method: 'PUT', body: JSON.stringify(data) });
}
export function deleteCategory(id: number) {
return api<void>(`/api/categories/${id}`, { method: 'DELETE' });
}
export function getGroups() {
return api<any[]>('/api/groups');
}
export function createGroup(name: string, description?: string) {
return api<any>('/api/groups', { method: 'POST', body: JSON.stringify({ name, description }) });
}
export function updateGroupApi(id: number, data: any) {
return api<any>(`/api/groups/${id}`, { method: 'PUT', body: JSON.stringify(data) });
}
export function setGroupMembersApi(id: number, storeIds: number[]) {
return api<any>(`/api/groups/${id}/members`, { method: 'PUT', body: JSON.stringify({ store_ids: storeIds }) });
}
export function deleteGroup(id: number) {
return api<void>(`/api/groups/${id}`, { method: 'DELETE' });
}
export function searchProducts(query: string, params?: { stores?: string; category?: string; group?: string }) {
const searchParams = new URLSearchParams({ q: query });
if (params?.stores) searchParams.set('stores', params.stores);
if (params?.category) searchParams.set('category', params.category);
if (params?.group) searchParams.set('group', params.group);
return api<any>(`/api/search?${searchParams}`);
}
export function testStore(id: number, query: string) {
return api<any>(`/api/stores/${id}/test`, { method: 'POST', body: JSON.stringify({ query }) });
}

View File

@@ -0,0 +1,22 @@
<script>
import '../app.css';
let { children } = $props();
</script>
<div class="min-h-screen flex flex-col">
<nav class="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-800 px-4 py-3">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<a href="/" class="text-xl font-bold text-blue-600 dark:text-blue-400 hover:text-blue-700">
Price Hunter
</a>
<div class="flex gap-4 text-sm">
<a href="/" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Search</a>
<a href="/admin" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Stores</a>
<a href="/admin/categories" class="text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">Categories</a>
</div>
</div>
</nav>
<main class="flex-1">
{@render children()}
</main>
</div>

View File

@@ -0,0 +1,81 @@
<script>
import { goto } from '$app/navigation';
import { getCategories, getGroups } from '$lib/api';
import { onMount } from 'svelte';
let query = $state('');
let categories = $state([]);
let groups = $state([]);
let selectedCategory = $state('');
let selectedGroup = $state('');
onMount(async () => {
[categories, groups] = await Promise.all([getCategories(), getGroups()]);
});
function handleSearch(e) {
e.preventDefault();
if (!query.trim()) return;
const params = new URLSearchParams({ q: query.trim() });
if (selectedCategory) params.set('category', selectedCategory);
if (selectedGroup) params.set('group', selectedGroup);
goto(`/results?${params}`);
}
</script>
<div class="flex flex-col items-center justify-center min-h-[80vh] px-4">
<h1 class="text-5xl font-bold text-gray-900 dark:text-white mb-2">Price Hunter</h1>
<p class="text-gray-500 dark:text-gray-400 mb-8">Search across all your stores at once</p>
<form onsubmit={handleSearch} class="w-full max-w-2xl">
<div class="relative">
<input
type="text"
bind:value={query}
placeholder="Search for a product..."
class="w-full px-6 py-4 text-lg border-2 border-gray-200 dark:border-gray-700 rounded-full
bg-white dark:bg-gray-900 text-gray-900 dark:text-white
focus:border-blue-500 focus:outline-none shadow-sm hover:shadow-md transition-shadow"
/>
<button
type="submit"
class="absolute right-2 top-1/2 -translate-y-1/2 bg-blue-600 hover:bg-blue-700 text-white
px-6 py-2.5 rounded-full font-medium transition-colors"
>
Search
</button>
</div>
{#if categories.length > 0 || groups.length > 0}
<div class="flex flex-wrap gap-2 mt-4 justify-center">
{#if categories.length > 0}
<select
bind:value={selectedCategory}
class="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700 rounded-lg
bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300"
>
<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="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-700 rounded-lg
bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300"
>
<option value="">All groups</option>
{#each groups as group}
<option value={group.id}>{group.name}</option>
{/each}
</select>
{/if}
</div>
{/if}
</form>
</div>

View File

@@ -0,0 +1,101 @@
<script>
import { getStores, toggleStore, deleteStore } from '$lib/api';
import { onMount } from 'svelte';
let stores = $state([]);
let loading = $state(true);
onMount(async () => {
stores = await getStores();
loading = false;
});
async function handleToggle(id) {
const updated = await toggleStore(id);
stores = stores.map((s) => (s.id === id ? { ...s, ...updated } : s));
}
async function handleDelete(id, name) {
if (!confirm(`Delete store "${name}"? This cannot be undone.`)) return;
await deleteStore(id);
stores = stores.filter((s) => s.id !== id);
}
</script>
<div class="max-w-7xl mx-auto px-4 py-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Store Management</h1>
<a
href="/admin/stores/new"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
Add Store
</a>
</div>
{#if loading}
<div class="animate-pulse space-y-3">
{#each Array(5) as _}
<div class="bg-gray-200 dark:bg-gray-800 h-16 rounded-lg"></div>
{/each}
</div>
{:else if stores.length === 0}
<div class="text-center py-20 text-gray-500 dark:text-gray-400">
<p class="text-lg mb-2">No stores configured yet</p>
<a href="/admin/stores/new" class="text-blue-600 hover:underline">Add your first store</a>
</div>
{:else}
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden">
<table class="w-full">
<thead>
<tr class="bg-gray-50 dark:bg-gray-800 text-left text-sm text-gray-600 dark:text-gray-400">
<th class="px-4 py-3 font-medium">Name</th>
<th class="px-4 py-3 font-medium">URL</th>
<th class="px-4 py-3 font-medium">Category</th>
<th class="px-4 py-3 font-medium text-center">Enabled</th>
<th class="px-4 py-3 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{#each stores as store}
<tr class="border-t border-gray-100 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800/50">
<td class="px-4 py-3">
<span class="font-medium text-gray-900 dark:text-white">{store.name}</span>
</td>
<td class="px-4 py-3 text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs">
{store.base_url}
</td>
<td class="px-4 py-3">
{#if store.category_name}
<span
class="text-xs px-2 py-0.5 rounded-full text-white"
style="background-color: {store.category_color}"
>
{store.category_name}
</span>
{:else}
<span class="text-xs text-gray-400"></span>
{/if}
</td>
<td class="px-4 py-3 text-center">
<button
onclick={() => handleToggle(store.id)}
class="w-10 h-5 rounded-full transition-colors {store.enabled ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'} relative"
>
<span class="absolute top-0.5 {store.enabled ? 'right-0.5' : 'left-0.5'} w-4 h-4 bg-white rounded-full shadow transition-all"></span>
</button>
</td>
<td class="px-4 py-3 text-right">
<div class="flex gap-2 justify-end">
<a href="/admin/stores/{store.id}" class="text-sm text-blue-600 hover:underline">Edit</a>
<a href="/admin/stores/{store.id}/test" class="text-sm text-green-600 hover:underline">Test</a>
<button onclick={() => handleDelete(store.id, store.name)} class="text-sm text-red-600 hover:underline">Delete</button>
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</div>

View File

@@ -0,0 +1,155 @@
<script>
import {
getCategories, createCategory, updateCategory, deleteCategory,
getGroups, createGroup, updateGroupApi, deleteGroup, setGroupMembersApi, getStores,
} from '$lib/api';
import { onMount } from 'svelte';
let categories = $state([]);
let groups = $state([]);
let stores = $state([]);
let loading = $state(true);
let newCatName = $state('');
let newCatColor = $state('#6B7280');
let newGroupName = $state('');
let editingCat = $state(null);
let editingGroup = $state(null);
onMount(async () => {
[categories, groups, stores] = await Promise.all([getCategories(), getGroups(), getStores()]);
loading = false;
});
async function handleAddCategory() {
if (!newCatName.trim()) return;
const cat = await createCategory(newCatName.trim(), newCatColor);
categories = [...categories, cat];
newCatName = '';
newCatColor = '#6B7280';
}
async function handleUpdateCategory(id) {
if (!editingCat) return;
await updateCategory(id, { name: editingCat.name, color: editingCat.color });
categories = categories.map((c) => (c.id === id ? { ...c, ...editingCat } : c));
editingCat = null;
}
async function handleDeleteCategory(id, name) {
if (!confirm(`Delete category "${name}"?`)) return;
await deleteCategory(id);
categories = categories.filter((c) => c.id !== id);
}
async function handleAddGroup() {
if (!newGroupName.trim()) return;
const group = await createGroup(newGroupName.trim());
groups = [...groups, group];
newGroupName = '';
}
async function handleDeleteGroup(id, name) {
if (!confirm(`Delete group "${name}"?`)) return;
await deleteGroup(id);
groups = groups.filter((g) => g.id !== id);
}
async function handleToggleGroupMember(groupId, storeId) {
const group = groups.find((g) => g.id === groupId);
if (!group) return;
const ids = group.store_ids.includes(storeId)
? group.store_ids.filter((id) => id !== storeId)
: [...group.store_ids, storeId];
await setGroupMembersApi(groupId, ids);
groups = groups.map((g) => (g.id === groupId ? { ...g, store_ids: ids } : g));
}
</script>
<div class="max-w-5xl mx-auto px-4 py-6">
{#if loading}
<div class="animate-pulse space-y-4">
<div class="bg-gray-200 dark:bg-gray-800 h-40 rounded-lg"></div>
<div class="bg-gray-200 dark:bg-gray-800 h-40 rounded-lg"></div>
</div>
{:else}
<!-- Categories -->
<section class="mb-10">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Categories</h1>
<div class="flex gap-2 mb-4">
<input type="text" bind:value={newCatName} placeholder="Category name"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<input type="color" bind:value={newCatColor}
class="w-10 h-10 rounded border border-gray-300 dark:border-gray-600 cursor-pointer" />
<button onclick={handleAddCategory}
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium">Add</button>
</div>
{#if categories.length === 0}
<p class="text-gray-500 dark:text-gray-400">No categories yet.</p>
{:else}
<div class="space-y-2">
{#each categories as cat}
<div class="flex items-center gap-3 bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 px-4 py-3">
{#if editingCat?.id === cat.id}
<input type="color" bind:value={editingCat.color} class="w-8 h-8 rounded cursor-pointer" />
<input type="text" bind:value={editingCat.name}
class="flex-1 px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white" />
<button onclick={() => handleUpdateCategory(cat.id)} class="text-sm text-blue-600 hover:underline">Save</button>
<button onclick={() => editingCat = null} class="text-sm text-gray-500 hover:underline">Cancel</button>
{:else}
<span class="w-4 h-4 rounded-full" style="background-color: {cat.color}"></span>
<span class="flex-1 text-gray-900 dark:text-white">{cat.name}</span>
<button onclick={() => editingCat = { id: cat.id, name: cat.name, color: cat.color }} class="text-sm text-blue-600 hover:underline">Edit</button>
<button onclick={() => handleDeleteCategory(cat.id, cat.name)} class="text-sm text-red-600 hover:underline">Delete</button>
{/if}
</div>
{/each}
</div>
{/if}
</section>
<!-- Groups -->
<section>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-4">Custom Groups</h1>
<div class="flex gap-2 mb-4">
<input type="text" bind:value={newGroupName} placeholder="Group name"
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<button onclick={handleAddGroup}
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium">Add</button>
</div>
{#if groups.length === 0}
<p class="text-gray-500 dark:text-gray-400">No groups yet.</p>
{:else}
<div class="space-y-4">
{#each groups as group}
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-medium text-gray-900 dark:text-white">{group.name}</h3>
<button onclick={() => handleDeleteGroup(group.id, group.name)} class="text-sm text-red-600 hover:underline">Delete</button>
</div>
<div class="flex flex-wrap gap-2">
{#each stores as store}
<button
onclick={() => handleToggleGroupMember(group.id, store.id)}
class="text-xs px-3 py-1 rounded-full border transition-colors
{group.store_ids.includes(store.id)
? 'bg-blue-100 dark:bg-blue-900/30 border-blue-300 dark:border-blue-700 text-blue-700 dark:text-blue-300'
: 'bg-gray-50 dark:bg-gray-800 border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:border-gray-300'}"
>
{store.name}
</button>
{/each}
</div>
</div>
{/each}
</div>
{/if}
</section>
{/if}
</div>

View File

@@ -0,0 +1,200 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { getStore, updateStore, getCategories } from '$lib/api';
import { onMount } from 'svelte';
let categories = $state([]);
let error = $state('');
let saving = $state(false);
let loading = $state(true);
let form = $state({
name: '',
base_url: '',
search_url: '',
sel_container: '',
sel_name: '',
sel_price: '',
sel_link: '',
sel_image: '',
category_id: '',
currency: 'EUR',
rate_limit: 2,
user_agent: '',
proxy_url: '',
headers_json: '',
});
onMount(async () => {
const id = $page.params.id;
const [store, cats] = await Promise.all([getStore(Number(id)), getCategories()]);
categories = cats;
form = {
name: store.name,
base_url: store.base_url,
search_url: store.search_url,
sel_container: store.sel_container,
sel_name: store.sel_name,
sel_price: store.sel_price,
sel_link: store.sel_link,
sel_image: store.sel_image || '',
category_id: store.category_id?.toString() || '',
currency: store.currency,
rate_limit: store.rate_limit,
user_agent: store.user_agent || '',
proxy_url: store.proxy_url || '',
headers_json: store.headers_json || '',
};
loading = false;
});
async function handleSubmit(e) {
e.preventDefault();
error = '';
saving = true;
try {
const data = { ...form };
if (data.category_id) data.category_id = Number(data.category_id);
else delete data.category_id;
await updateStore(Number($page.params.id), data);
goto('/admin');
} catch (err) {
error = err.message || 'Failed to update store';
} finally {
saving = false;
}
}
</script>
<div class="max-w-3xl mx-auto px-4 py-6">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Edit Store</h1>
<a href="/admin/stores/{$page.params.id}/test"
class="bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg text-sm font-medium">
Test Store
</a>
</div>
{#if loading}
<div class="animate-pulse space-y-4">
<div class="bg-gray-200 dark:bg-gray-800 h-40 rounded-lg"></div>
<div class="bg-gray-200 dark:bg-gray-800 h-60 rounded-lg"></div>
</div>
{:else}
{#if error}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg mb-4">{error}</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-6">
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Basic Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Store Name *</label>
<input type="text" bind:value={form.name} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
<select bind:value={form.category_id}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
<option value="">No category</option>
{#each categories as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Base URL *</label>
<input type="url" bind:value={form.base_url} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search URL *</label>
<input type="text" bind:value={form.search_url} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<p class="text-xs text-gray-500 mt-1">Use {'{query}'} as placeholder</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Currency</label>
<input type="text" bind:value={form.currency}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
</div>
</section>
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">CSS Selectors</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Container *</label>
<input type="text" bind:value={form.sel_container} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Name *</label>
<input type="text" bind:value={form.sel_name} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Price *</label>
<input type="text" bind:value={form.sel_price} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Link *</label>
<input type="text" bind:value={form.sel_link} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Image</label>
<input type="text" bind:value={form.sel_image}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
</div>
</div>
</section>
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Advanced Settings</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rate Limit</label>
<input type="number" bind:value={form.rate_limit} min="1" max="10"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Agent</label>
<input type="text" bind:value={form.user_agent}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Proxy URL</label>
<input type="text" bind:value={form.proxy_url}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Extra Headers (JSON)</label>
<input type="text" bind:value={form.headers_json}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
</div>
</section>
<div class="flex gap-3">
<button type="submit" disabled={saving}
class="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-6 py-2.5 rounded-lg font-medium">
{saving ? 'Saving...' : 'Save Changes'}
</button>
<a href="/admin" class="px-6 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800">
Cancel
</a>
</div>
</form>
{/if}
</div>

View File

@@ -0,0 +1,200 @@
<script>
import { page } from '$app/stores';
import { getStore, testStore } from '$lib/api';
import { onMount } from 'svelte';
let store = $state(null);
let query = $state('');
let result = $state(null);
let testing = $state(false);
let loading = $state(true);
onMount(async () => {
store = await getStore(Number($page.params.id));
loading = false;
});
async function handleTest(e) {
e.preventDefault();
if (!query.trim()) return;
testing = true;
result = null;
try {
result = await testStore(Number($page.params.id), query.trim());
} catch (err) {
result = { success: false, error: err.message };
} finally {
testing = false;
}
}
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)}`;
}
}
</script>
<div class="max-w-7xl mx-auto px-4 py-6">
{#if loading}
<div class="animate-pulse">
<div class="bg-gray-200 dark:bg-gray-800 h-8 w-48 rounded mb-4"></div>
<div class="bg-gray-200 dark:bg-gray-800 h-12 rounded mb-4"></div>
</div>
{:else if store}
<div class="flex items-center gap-4 mb-6">
<a href="/admin/stores/{store.id}" class="text-gray-400 hover:text-gray-600">&larr;</a>
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Test: {store.name}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">{store.base_url}</p>
</div>
</div>
<!-- Health Stats -->
{#if store.health}
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
<div class="text-2xl font-bold text-gray-900 dark:text-white">{store.health.total}</div>
<div class="text-xs text-gray-500">Total Scrapes</div>
</div>
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
<div class="text-2xl font-bold text-green-600">{store.health.successful}</div>
<div class="text-xs text-gray-500">Successful</div>
</div>
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
<div class="text-2xl font-bold text-red-600">{store.health.failed}</div>
<div class="text-xs text-gray-500">Failed</div>
</div>
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
<div class="text-2xl font-bold text-gray-900 dark:text-white">{store.health.avg_duration_ms}ms</div>
<div class="text-xs text-gray-500">Avg Duration</div>
</div>
</div>
{/if}
<!-- Test Form -->
<form onsubmit={handleTest} class="mb-6">
<div class="flex gap-2">
<input
type="text"
bind:value={query}
placeholder="Enter a test search query..."
class="flex-1 px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg
bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none"
/>
<button
type="submit"
disabled={testing}
class="bg-green-600 hover:bg-green-700 disabled:bg-green-400 text-white px-6 py-2.5 rounded-lg font-medium"
>
{testing ? 'Testing...' : 'Run Test'}
</button>
</div>
</form>
<!-- Test Results -->
{#if result}
<div class="space-y-6">
<!-- Status Banner -->
<div class="p-4 rounded-lg {result.success ? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400' : 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400'}">
<div class="font-medium">
{result.success ? `Found ${result.itemsFound} products` : 'Test failed'}
</div>
{#if result.searchUrl}
<div class="text-sm mt-1 break-all opacity-80">URL: {result.searchUrl}</div>
{/if}
{#if result.duration}
<div class="text-sm mt-1 opacity-80">Duration: {result.duration}ms</div>
{/if}
{#if result.error}
<div class="text-sm mt-1">{result.error}</div>
{/if}
</div>
{#if result.success}
<!-- Parsed Results -->
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Parsed Products ({result.parsedProducts?.length || 0})</h2>
{#if result.parsedProducts?.length > 0}
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50 dark:bg-gray-800 text-left">
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Name</th>
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Price</th>
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Raw Price</th>
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Link</th>
</tr>
</thead>
<tbody>
{#each result.parsedProducts as product}
<tr class="border-t border-gray-100 dark:border-gray-800">
<td class="px-4 py-2 text-gray-900 dark:text-white max-w-xs truncate">{product.name}</td>
<td class="px-4 py-2 font-medium text-blue-600 dark:text-blue-400">{formatPrice(product.price, product.currency)}</td>
<td class="px-4 py-2 text-gray-500 font-mono text-xs">{product.priceText}</td>
<td class="px-4 py-2">
<a href={product.url} target="_blank" rel="noopener" class="text-blue-600 hover:underline truncate block max-w-xs">
{product.url}
</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<p class="text-gray-500 dark:text-gray-400">No products parsed. Check your CSS selectors.</p>
{/if}
</div>
<!-- Raw HTML Preview -->
<details class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800">
<summary class="px-4 py-3 cursor-pointer font-medium text-gray-900 dark:text-white hover:bg-gray-50 dark:hover:bg-gray-800">
Raw HTML Preview ({result.rawHtmlLength?.toLocaleString()} bytes)
</summary>
<div class="px-4 py-3 border-t border-gray-200 dark:border-gray-800">
<pre class="text-xs text-gray-600 dark:text-gray-400 overflow-x-auto whitespace-pre-wrap max-h-96 overflow-y-auto">{result.rawHtmlPreview}</pre>
</div>
</details>
{/if}
<!-- Recent Logs -->
{#if result.recentLogs?.length > 0}
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-3">Recent Scrape Logs</h2>
<div class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 overflow-hidden">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50 dark:bg-gray-800 text-left">
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Status</th>
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Query</th>
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Results</th>
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Duration</th>
<th class="px-4 py-2 font-medium text-gray-600 dark:text-gray-400">Time</th>
</tr>
</thead>
<tbody>
{#each result.recentLogs as log}
<tr class="border-t border-gray-100 dark:border-gray-800">
<td class="px-4 py-2">
<span class="inline-block w-2 h-2 rounded-full {log.success ? 'bg-green-500' : 'bg-red-500'}"></span>
</td>
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">{log.query}</td>
<td class="px-4 py-2 text-gray-700 dark:text-gray-300">{log.result_count}</td>
<td class="px-4 py-2 text-gray-500">{log.duration_ms}ms</td>
<td class="px-4 py-2 text-gray-500 text-xs">{log.scraped_at}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,174 @@
<script>
import { goto } from '$app/navigation';
import { createStore, getCategories } from '$lib/api';
import { onMount } from 'svelte';
let categories = $state([]);
let error = $state('');
let saving = $state(false);
let form = $state({
name: '',
base_url: '',
search_url: '',
sel_container: '',
sel_name: '',
sel_price: '',
sel_link: '',
sel_image: '',
category_id: '',
currency: 'EUR',
rate_limit: 2,
user_agent: '',
proxy_url: '',
headers_json: '',
});
onMount(async () => {
categories = await getCategories();
});
async function handleSubmit(e) {
e.preventDefault();
error = '';
saving = true;
try {
const data = { ...form };
if (data.category_id) data.category_id = Number(data.category_id);
else delete data.category_id;
if (!data.sel_image) delete data.sel_image;
if (!data.user_agent) delete data.user_agent;
if (!data.proxy_url) delete data.proxy_url;
if (!data.headers_json) delete data.headers_json;
const store = await createStore(data);
goto(`/admin/stores/${store.id}/test`);
} catch (err) {
error = err.message || 'Failed to create store';
} finally {
saving = false;
}
}
</script>
<div class="max-w-3xl mx-auto px-4 py-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Add New Store</h1>
{#if error}
<div class="bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 px-4 py-3 rounded-lg mb-4">{error}</div>
{/if}
<form onsubmit={handleSubmit} class="space-y-6">
<!-- Basic Info -->
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Basic Information</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Store Name *</label>
<input type="text" bind:value={form.name} required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Category</label>
<select bind:value={form.category_id}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white">
<option value="">No category</option>
{#each categories as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Base URL *</label>
<input type="url" bind:value={form.base_url} required placeholder="https://store.example.com"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search URL *</label>
<input type="text" bind:value={form.search_url} required placeholder={'https://store.example.com/search?q={query}'}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
<p class="text-xs text-gray-500 mt-1">Use {'{query}'} as placeholder for the search term</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Currency</label>
<input type="text" bind:value={form.currency} placeholder="EUR"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
</div>
</section>
<!-- CSS Selectors -->
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">CSS Selectors</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">Define how to extract product data from the store's search results page.</p>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Container *</label>
<input type="text" bind:value={form.sel_container} required placeholder=".product-card"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
<p class="text-xs text-gray-500 mt-1">Selector for each product listing item</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Name *</label>
<input type="text" bind:value={form.sel_name} required placeholder=".product-title"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Price *</label>
<input type="text" bind:value={form.sel_price} required placeholder=".product-price"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Product Link *</label>
<input type="text" bind:value={form.sel_link} required placeholder="a"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Image (optional)</label>
<input type="text" bind:value={form.sel_image} placeholder="img"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
</div>
</div>
</section>
<!-- Advanced -->
<section class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-6">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Advanced Settings</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Rate Limit (req/sec)</label>
<input type="number" bind:value={form.rate_limit} min="1" max="10"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">User Agent</label>
<input type="text" bind:value={form.user_agent} placeholder="Default browser UA"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Proxy URL</label>
<input type="text" bind:value={form.proxy_url} placeholder="http://user:pass@host:port"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Extra Headers (JSON)</label>
<input type="text" bind:value={form.headers_json} placeholder={'{"X-Custom": "value"}'}
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm focus:border-blue-500 focus:outline-none" />
</div>
</div>
</section>
<div class="flex gap-3">
<button type="submit" disabled={saving}
class="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white px-6 py-2.5 rounded-lg font-medium transition-colors">
{saving ? 'Creating...' : 'Create Store'}
</button>
<a href="/admin" class="px-6 py-2.5 rounded-lg border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800">
Cancel
</a>
</div>
</form>
</div>

View File

@@ -0,0 +1,186 @@
<script>
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { searchProducts } 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');
onMount(async () => {
const params = $page.url.searchParams;
query = params.get('q') || '';
if (!query) {
goto('/');
return;
}
await doSearch();
});
async function doSearch() {
if (!query.trim()) return;
loading = true;
try {
const params = $page.url.searchParams;
const data = await searchProducts(query, {
category: params.get('category') || undefined,
group: params.get('group') || undefined,
stores: params.get('stores') || undefined,
});
results = data.results;
meta = data.meta;
} catch (err) {
console.error('Search failed:', err);
results = [];
} finally {
loading = false;
}
}
function handleSearch(e) {
e.preventDefault();
if (!query.trim()) return;
goto(`/results?q=${encodeURIComponent(query.trim())}`);
doSearch();
}
let sortedResults = $derived(() => {
const sorted = [...results];
switch (sortBy) {
case 'price':
sorted.sort((a, b) => (a.price ?? Infinity) - (b.price ?? Infinity));
break;
case 'price-desc':
sorted.sort((a, b) => (b.price ?? -Infinity) - (a.price ?? -Infinity));
break;
case 'name':
sorted.sort((a, b) => a.name.localeCompare(b.name));
break;
case 'store':
sorted.sort((a, b) => a.storeName.localeCompare(b.storeName));
break;
}
return sorted;
});
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)}`;
}
}
</script>
<div class="max-w-7xl mx-auto px-4 py-6">
<!-- Search bar -->
<form onsubmit={handleSearch} class="mb-6">
<div class="relative max-w-2xl">
<input
type="text"
bind:value={query}
placeholder="Search for a product..."
class="w-full px-4 py-2.5 border border-gray-200 dark:border-gray-700 rounded-full
bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:border-blue-500 focus:outline-none"
/>
<button type="submit" class="absolute right-2 top-1/2 -translate-y-1/2 bg-blue-600 hover:bg-blue-700
text-white px-4 py-1.5 rounded-full text-sm">Search</button>
</div>
</form>
<!-- Meta info & sort -->
{#if meta}
<div class="flex items-center justify-between mb-4">
<p class="text-sm text-gray-500 dark:text-gray-400">
{meta.totalResults} results from {meta.storeCount} stores in {meta.duration}ms
</p>
<select
bind:value={sortBy}
class="text-sm border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-1.5
bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300"
>
<option value="price">Price: Low to High</option>
<option value="price-desc">Price: High to Low</option>
<option value="name">Name</option>
<option value="store">Store</option>
</select>
</div>
{/if}
<!-- Errors -->
{#if meta?.errors?.length > 0}
<div class="mb-4 space-y-1">
{#each meta.errors as err}
<div class="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 px-3 py-1.5 rounded">
{err.storeName}: {err.error}
</div>
{/each}
</div>
{/if}
<!-- Loading -->
{#if loading}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{#each Array(8) as _}
<div class="animate-pulse bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800 p-4">
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded mb-3"></div>
<div class="bg-gray-200 dark:bg-gray-700 h-4 rounded w-3/4 mb-2"></div>
<div class="bg-gray-200 dark:bg-gray-700 h-6 rounded w-1/3"></div>
</div>
{/each}
</div>
{:else if sortedResults().length === 0}
<div class="text-center py-20 text-gray-500 dark:text-gray-400">
<p class="text-lg">No results found for "{query}"</p>
<p class="text-sm mt-2">Try a different search term or check your store configurations.</p>
</div>
{:else}
<!-- Results grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{#each sortedResults() as product}
<a
href={product.url}
target="_blank"
rel="noopener noreferrer"
class="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-800
hover:shadow-lg transition-shadow overflow-hidden group"
>
{#if product.image}
<div class="h-40 bg-gray-100 dark:bg-gray-800 flex items-center justify-center overflow-hidden">
<img
src={product.image}
alt={product.name}
class="max-h-full max-w-full object-contain p-2 group-hover:scale-105 transition-transform"
/>
</div>
{:else}
<div class="h-40 bg-gray-100 dark:bg-gray-800 flex items-center justify-center">
<span class="text-gray-400 text-4xl">?</span>
</div>
{/if}
<div class="p-4">
<h3 class="text-sm font-medium text-gray-900 dark:text-white line-clamp-2 mb-2">
{product.name}
</h3>
<div class="flex items-center justify-between">
<span class="text-lg font-bold text-blue-600 dark:text-blue-400">
{formatPrice(product.price, product.currency)}
</span>
<span class="text-xs text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-800 px-2 py-0.5 rounded">
{product.storeName}
</span>
</div>
</div>
</a>
{/each}
</div>
{/if}
</div>

View File

@@ -0,0 +1,16 @@
import adapter from '@sveltejs/adapter-static';
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false,
}),
},
};
export default config;

View File

@@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./src/**/*.{html,js,svelte,ts}'],
darkMode: 'media',
theme: {
extend: {},
},
plugins: [],
};

14
src/client/vite.config.ts Normal file
View File

@@ -0,0 +1,14 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit()],
server: {
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
},
},
},
});