diff --git a/web/src/lib/api/types.ts b/web/src/lib/api/types.ts index 40d3b99..2b7f597 100644 --- a/web/src/lib/api/types.ts +++ b/web/src/lib/api/types.ts @@ -160,6 +160,30 @@ export interface SubmitMarketRequest { turnstile_token: string; } +// AI Research types +export interface ResearchResult { + suggestions: FieldSuggestion[]; + sources: string[]; +} + +export interface FieldSuggestion { + field: string; + current_value: unknown; + suggested_value: unknown; + confidence: 'high' | 'medium' | 'low'; + reason: string; +} + +// Duplicate detection +export interface DuplicateMarket { + id: string; + name: string; + city: string; + start_date: string; + end_date: string; + similarity: number; +} + // Search params (mirrors backend SearchParams) export interface MarketSearchParams { lat?: number; diff --git a/web/src/lib/components/admin/MarketForm.svelte b/web/src/lib/components/admin/MarketForm.svelte index b4be2e9..014dfaa 100644 --- a/web/src/lib/components/admin/MarketForm.svelte +++ b/web/src/lib/components/admin/MarketForm.svelte @@ -1,15 +1,129 @@ {#if error} @@ -26,7 +140,14 @@
Allgemein - +
@@ -46,15 +169,40 @@
Standort - +
- - + +
- +
+ +
+ + {#if geocodeError} + {geocodeError} + {/if} +
@@ -118,22 +322,231 @@
Weitere Infos - +
+
+ Öffnungszeiten + + {#each hours as row, i} +
+
+ + +
+ { + row.open = e.currentTarget.value; + }} + /> + { + row.close = e.currentTarget.value; + }} + /> + +
+ {/each} + + + + +
+ +
+ Eintrittspreise + +
+
+ + { + admission.adult_cents = Math.round(parseFloat(e.currentTarget.value || '0') * 100); + }} + class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2 + text-sm shadow-sm focus:ring-2 focus:outline-none + dark:border-stone-600 dark:bg-stone-800" + /> +
+
+ + { + admission.child_cents = Math.round(parseFloat(e.currentTarget.value || '0') * 100); + }} + class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2 + text-sm shadow-sm focus:ring-2 focus:outline-none + dark:border-stone-600 dark:bg-stone-800" + /> +
+
+ + { + admission.reduced_cents = Math.round(parseFloat(e.currentTarget.value || '0') * 100); + }} + class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2 + text-sm shadow-sm focus:ring-2 focus:outline-none + dark:border-stone-600 dark:bg-stone-800" + /> +
+
+ +
+
+ + +
+
+ +
+ + +
+ + +
+ + {#if mode === 'admin'} +
+ Admin-Notizen + +
+ +
+
+ {/if} + + {#if extraFields} + {@render extraFields()} + {/if} +
- - - - + + {#if mode === 'admin'} + + + + {/if}
diff --git a/web/src/lib/components/admin/ResearchPanel.svelte b/web/src/lib/components/admin/ResearchPanel.svelte new file mode 100644 index 0000000..e82460d --- /dev/null +++ b/web/src/lib/components/admin/ResearchPanel.svelte @@ -0,0 +1,186 @@ + + +
+
+

+ KI-Recherche Ergebnisse +

+ +
+ + {#if result.suggestions.length === 0} +

Keine Vorschläge gefunden.

+ {:else} +
+ {#each result.suggestions as suggestion, i} + + {/each} +
+ +
+ + +
+ {/if} + + {#if result.sources && result.sources.length > 0} +
+

Quellen:

+ +
+ {/if} +
diff --git a/web/src/routes/admin/maerkte/[id]/+page.server.ts b/web/src/routes/admin/maerkte/[id]/+page.server.ts index c468607..7b35454 100644 --- a/web/src/routes/admin/maerkte/[id]/+page.server.ts +++ b/web/src/routes/admin/maerkte/[id]/+page.server.ts @@ -1,11 +1,26 @@ import { fail } from '@sveltejs/kit'; import { serverFetch } from '$lib/api/client.server.js'; -import type { AdminMarketDetail } from '$lib/api/types.js'; +import type { AdminMarketDetail, DuplicateMarket } from '$lib/api/types.js'; import type { Actions, PageServerLoad } from './$types.js'; export const load: PageServerLoad = async ({ params, cookies }) => { const res = await serverFetch(`/admin/markets/${params.id}`, cookies); - return { market: res.data }; + const market = res.data; + + let duplicates: DuplicateMarket[] = []; + if (market.status === 'pending') { + try { + const dupRes = await serverFetch( + `/admin/markets/${params.id}/duplicates`, + cookies + ); + duplicates = dupRes.data ?? []; + } catch { + // Non-fatal: duplicates are informational + } + } + + return { market, duplicates }; }; export const actions: Actions = { diff --git a/web/src/routes/admin/maerkte/[id]/+page.svelte b/web/src/routes/admin/maerkte/[id]/+page.svelte index ed1b807..2db9bad 100644 --- a/web/src/routes/admin/maerkte/[id]/+page.svelte +++ b/web/src/routes/admin/maerkte/[id]/+page.svelte @@ -21,6 +21,13 @@ rejected: 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-200' }; + const chfCountries = new Set(['CH', 'LI']); + const currency = $derived(chfCountries.has(data.market.country) ? 'CHF' : 'EUR'); + + function formatPrice(cents: number): string { + return (cents / 100).toFixed(2) + ' ' + currency; + } + function formatDate(dateStr: string): string { if (!dateStr) return '-'; const d = new Date(dateStr); @@ -83,6 +90,29 @@ {/if} + {#if data.duplicates && data.duplicates.length > 0} +
+

+ Mögliche Duplikate gefunden +

+
    + {#each data.duplicates as dup} +
  • + + {dup.name} + + — {dup.city}, {formatDate(dup.start_date)} - {formatDate(dup.end_date)} + + ({Math.round(dup.similarity * 100)}% Ähnlichkeit) + +
  • + {/each} +
+
+ {/if} +
@@ -173,7 +203,7 @@
Koordinaten
- {data.market.latitude.toFixed(6)}, {data.market.longitude.toFixed(6)} + {data.market.latitude?.toFixed(6) ?? '—'}, {data.market.longitude?.toFixed(6) ?? '—'}
@@ -214,6 +244,76 @@
+ + {#if data.market.opening_hours && data.market.opening_hours.length > 0} +
+

Öffnungszeiten

+ + + + + + + + + + {#each data.market.opening_hours as entry} + + + + + + {/each} + +
TagVonBis
{entry.day}{entry.open}{entry.close}
+
+ {/if} + + + {#if data.market.admission_info && (data.market.admission_info.adult_cents > 0 || data.market.admission_info.child_cents > 0 || data.market.admission_info.notes)} +
+

Eintrittspreise

+
+
+
Erwachsene
+
+ {formatPrice(data.market.admission_info.adult_cents)} +
+
+
+
Kinder
+
+ {formatPrice(data.market.admission_info.child_cents)} +
+
+
+
Ermäßigt
+
+ {formatPrice(data.market.admission_info.reduced_cents)} +
+
+ {#if data.market.admission_info.free_under_age > 0} +
+
+ Frei unter (Alter) +
+
+ {data.market.admission_info.free_under_age} Jahre +
+
+ {/if} + {#if data.market.admission_info.notes} +
+
Hinweise
+
+ {data.market.admission_info.notes} +
+
+ {/if} +
+
+ {/if} + {#if data.market.submitter_email || data.market.submitter_name}
diff --git a/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.server.ts b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.server.ts index fa7bc77..a62750c 100644 --- a/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.server.ts +++ b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.server.ts @@ -1,6 +1,6 @@ import { fail, redirect } from '@sveltejs/kit'; import { serverFetch } from '$lib/api/client.server.js'; -import type { AdminMarketDetail } from '$lib/api/types.js'; +import type { AdminMarketDetail, ResearchResult } from '$lib/api/types.js'; import type { Actions, PageServerLoad } from './$types.js'; export const load: PageServerLoad = async ({ params, cookies }) => { @@ -9,7 +9,7 @@ export const load: PageServerLoad = async ({ params, cookies }) => { }; export const actions: Actions = { - default: async ({ request, params, cookies, fetch }) => { + save: async ({ request, params, cookies, fetch }) => { const form = await request.formData(); const body: Record = {}; @@ -26,7 +26,8 @@ export const actions: Actions = { 'end_date', 'website', 'organizer_name', - 'image_url' + 'image_url', + 'admin_notes' ]; for (const field of strFields) { @@ -41,6 +42,17 @@ export const actions: Actions = { if (lat) body.latitude = parseFloat(lat); if (lon) body.longitude = parseFloat(lon); + const openingHoursRaw = form.get('opening_hours')?.toString(); + if (openingHoursRaw) { + const parsed = JSON.parse(openingHoursRaw); + body.opening_hours = Array.isArray(parsed) && parsed.length > 0 ? parsed : []; + } + + const admissionInfoRaw = form.get('admission_info')?.toString(); + if (admissionInfoRaw) { + body.admission_info = JSON.parse(admissionInfoRaw); + } + try { await serverFetch(`/admin/markets/${params.id}`, cookies, { method: 'PUT', @@ -54,5 +66,19 @@ export const actions: Actions = { const message = err instanceof Error ? err.message : 'Speichern fehlgeschlagen.'; return fail(500, { error: message }); } + }, + + research: async ({ params, cookies, fetch }) => { + try { + const res = await serverFetch( + `/admin/markets/${params.id}/research`, + cookies, + { method: 'POST', fetch } + ); + return { research: res.data }; + } catch (err) { + const message = err instanceof Error ? err.message : 'KI-Recherche fehlgeschlagen.'; + return fail(500, { error: message }); + } } }; diff --git a/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte index ec6279c..080e714 100644 --- a/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte +++ b/web/src/routes/admin/maerkte/[id]/bearbeiten/+page.svelte @@ -1,9 +1,79 @@ @@ -11,18 +81,45 @@
-
- +
+ + ← Zurück zum Markt + +

Markt bearbeiten

+
+
{ + researching = true; + return async ({ update }) => { + researching = false; + await update(); + }; + }} > - ← Zurück zum Markt - -

Markt bearbeiten

+ +
+ {#if researchResult} + { + researchResult = null; + dismissed = true; + }} + /> + {/if} +
{ loading = true; return async ({ update }) => { @@ -31,6 +128,6 @@ }; }} > - +
diff --git a/web/src/routes/admin/maerkte/neu/+page.server.ts b/web/src/routes/admin/maerkte/neu/+page.server.ts index 706fa76..76bb5db 100644 --- a/web/src/routes/admin/maerkte/neu/+page.server.ts +++ b/web/src/routes/admin/maerkte/neu/+page.server.ts @@ -19,7 +19,8 @@ export const actions: Actions = { end_date: form.get('end_date')?.toString().trim() ?? '', website: form.get('website')?.toString().trim() ?? '', organizer_name: form.get('organizer_name')?.toString().trim() ?? '', - image_url: form.get('image_url')?.toString().trim() ?? '' + image_url: form.get('image_url')?.toString().trim() ?? '', + admin_notes: form.get('admin_notes')?.toString().trim() ?? '' }; const lat = form.get('latitude')?.toString(); @@ -27,6 +28,17 @@ export const actions: Actions = { body.latitude = lat ? parseFloat(lat) : 0; body.longitude = lon ? parseFloat(lon) : 0; + const openingHoursRaw = form.get('opening_hours')?.toString(); + if (openingHoursRaw) { + const parsed = JSON.parse(openingHoursRaw); + body.opening_hours = Array.isArray(parsed) && parsed.length > 0 ? parsed : []; + } + + const admissionInfoRaw = form.get('admission_info')?.toString(); + if (admissionInfoRaw) { + body.admission_info = JSON.parse(admissionInfoRaw); + } + try { const res = await serverFetch('/admin/markets', cookies, { method: 'POST', diff --git a/web/src/routes/api/geocode/+server.ts b/web/src/routes/api/geocode/+server.ts new file mode 100644 index 0000000..fdaafcd --- /dev/null +++ b/web/src/routes/api/geocode/+server.ts @@ -0,0 +1,19 @@ +import { json } from '@sveltejs/kit'; +import { apiFetch } from '$lib/api/client.js'; +import type { RequestHandler } from './$types.js'; + +export const POST: RequestHandler = async ({ request, fetch }) => { + const body = await request.json(); + + try { + const res = await apiFetch<{ latitude: number | null; longitude: number | null }>('/geocode', { + method: 'POST', + body: JSON.stringify(body), + fetch + }); + return json(res.data); + } catch (err) { + const message = err instanceof Error ? err.message : 'Geocoding failed'; + return json({ error: { message } }, { status: 500 }); + } +}; diff --git a/web/src/routes/markt/einreichen/+page.server.ts b/web/src/routes/markt/einreichen/+page.server.ts index b3284aa..53e7015 100644 --- a/web/src/routes/markt/einreichen/+page.server.ts +++ b/web/src/routes/markt/einreichen/+page.server.ts @@ -15,6 +15,7 @@ export const actions: Actions = { const name = form.get('name')?.toString().trim() ?? ''; const description = form.get('description')?.toString().trim() ?? ''; + const street = form.get('street')?.toString().trim() ?? ''; const latRaw = form.get('latitude')?.toString().trim() ?? ''; const lonRaw = form.get('longitude')?.toString().trim() ?? ''; const latitude = latRaw ? parseFloat(latRaw) : undefined; @@ -27,6 +28,7 @@ export const actions: Actions = { const endDate = form.get('end_date')?.toString().trim() ?? ''; const website = form.get('website')?.toString().trim() ?? ''; const organizerName = form.get('organizer_name')?.toString().trim() ?? ''; + const imageUrl = form.get('image_url')?.toString().trim() ?? ''; const submitterEmail = form.get('submitter_email')?.toString().trim() ?? ''; const submitterName = form.get('submitter_name')?.toString().trim() ?? ''; const turnstileToken = form.get('cf-turnstile-response')?.toString() ?? ''; @@ -34,6 +36,7 @@ export const actions: Actions = { const formState = { name, description, + street, latitude: latRaw, longitude: lonRaw, city, @@ -44,6 +47,7 @@ export const actions: Actions = { endDate, website, organizerName, + imageUrl, submitterEmail, submitterName }; @@ -56,25 +60,44 @@ export const actions: Actions = { return fail(400, { error: 'Bitte bestaetige die Spam-Pruefung.', ...formState }); } + const body: Record = { + name, + description, + street, + city, + state, + zip, + country, + start_date: startDate, + end_date: endDate, + website, + organizer_name: organizerName, + image_url: imageUrl, + submitter_email: submitterEmail, + submitter_name: submitterName, + turnstile_token: turnstileToken + }; + + if (latitude !== undefined && longitude !== undefined) { + body.latitude = latitude; + body.longitude = longitude; + } + + const openingHoursRaw = form.get('opening_hours')?.toString(); + if (openingHoursRaw) { + const parsed = JSON.parse(openingHoursRaw); + body.opening_hours = Array.isArray(parsed) && parsed.length > 0 ? parsed : null; + } + + const admissionInfoRaw = form.get('admission_info')?.toString(); + if (admissionInfoRaw) { + body.admission_info = JSON.parse(admissionInfoRaw); + } + try { await apiFetch('/markets/submit', { method: 'POST', - body: JSON.stringify({ - name, - description, - ...(latitude !== undefined && longitude !== undefined ? { latitude, longitude } : {}), - city, - state, - zip, - country, - start_date: startDate, - end_date: endDate, - website, - organizer_name: organizerName, - submitter_email: submitterEmail, - submitter_name: submitterName, - turnstile_token: turnstileToken - }), + body: JSON.stringify(body), fetch }); diff --git a/web/src/routes/markt/einreichen/+page.svelte b/web/src/routes/markt/einreichen/+page.svelte index 626d3bb..ddf4484 100644 --- a/web/src/routes/markt/einreichen/+page.svelte +++ b/web/src/routes/markt/einreichen/+page.svelte @@ -1,7 +1,6 @@