feat: add AI research, geocoding, enriched market forms and public submit upgrade

- AI research panel with structured display (opening hours, admission)
- Shared MarketForm component with admin/public mode
- Geocode button to derive coordinates from address
- Opening hours, admission info fields in all forms
- Currency-aware pricing, full European country list
- Public submit now includes all market fields
- Weekday normalization from date for AI research results
- Duplicate detection warning on admin detail view
This commit is contained in:
2026-03-05 15:17:59 +01:00
parent 0a59225e81
commit 6f8df87f80
11 changed files with 1023 additions and 258 deletions

View File

@@ -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;

View File

@@ -1,15 +1,129 @@
<script lang="ts">
import type { AdminMarketDetail } from '$lib/api/types.js';
import type { AdminMarketDetail, OpeningHoursEntry, AdmissionInfo } from '$lib/api/types.js';
import Input from '$lib/components/ui/Input.svelte';
import Button from '$lib/components/ui/Button.svelte';
import type { Snippet } from 'svelte';
interface Props {
market?: AdminMarketDetail;
loading?: boolean;
error?: string;
mode?: 'admin' | 'public';
extraFields?: Snippet;
}
let { market, loading = false, error }: Props = $props();
let { market, loading = false, error, mode = 'admin', extraFields }: Props = $props();
const days = ['Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag', 'Sonntag'];
const currencyByCountry: Record<string, string> = {
CH: 'CHF',
LI: 'CHF',
GB: 'GBP',
DK: 'DKK',
SE: 'SEK',
NO: 'NOK',
PL: 'PLN',
CZ: 'CZK',
HU: 'HUF',
RO: 'RON',
BG: 'BGN',
HR: 'EUR',
IS: 'ISK',
RS: 'RSD',
BA: 'BAM',
AL: 'ALL',
MK: 'MKD',
MD: 'MDL',
UA: 'UAH'
};
let selectedCountry = $state(market?.country ?? 'DE');
const currency = $derived(currencyByCountry[selectedCountry] ?? 'EUR');
let hours: OpeningHoursEntry[] = $state(
market?.opening_hours?.length ? [...market.opening_hours] : []
);
let admission: AdmissionInfo = $state(
market?.admission_info ?? {
adult_cents: 0,
child_cents: 0,
reduced_cents: 0,
free_under_age: 0,
notes: ''
}
);
let geocoding = $state(false);
let geocodeError = $state('');
function addHoursRow() {
hours = [...hours, { day: 'Samstag', open: '10:00', close: '18:00' }];
}
function removeHoursRow(index: number) {
hours = hours.filter((_, i) => i !== index);
}
const admissionJson = $derived(JSON.stringify(admission));
async function geocodeAddress() {
geocoding = true;
geocodeError = '';
const street = document.querySelector<HTMLInputElement>('[name="street"]')?.value ?? '';
const city = document.querySelector<HTMLInputElement>('[name="city"]')?.value ?? '';
const zip = document.querySelector<HTMLInputElement>('[name="zip"]')?.value ?? '';
if (!city) {
geocodeError = 'Stadt ist erforderlich.';
geocoding = false;
return;
}
try {
const res = await fetch('/api/geocode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ street, city, zip, country: selectedCountry })
});
const data = await res.json();
if (!res.ok) {
geocodeError = data.error?.message ?? 'Geocoding fehlgeschlagen.';
return;
}
if (data.latitude != null && data.longitude != null) {
const latEl = document.querySelector<HTMLInputElement>('[name="latitude"]');
const lonEl = document.querySelector<HTMLInputElement>('[name="longitude"]');
if (latEl) {
latEl.value = String(data.latitude);
latEl.dispatchEvent(new Event('input', { bubbles: true }));
}
if (lonEl) {
lonEl.value = String(data.longitude);
lonEl.dispatchEvent(new Event('input', { bubbles: true }));
}
} else {
geocodeError = 'Keine Koordinaten gefunden.';
}
} catch {
geocodeError = 'Geocoding fehlgeschlagen.';
} finally {
geocoding = false;
}
}
export function setHours(newHours: OpeningHoursEntry[]) {
hours = newHours;
}
export function setAdmission(newAdmission: AdmissionInfo) {
admission = newAdmission;
}
</script>
{#if error}
@@ -26,7 +140,14 @@
<fieldset class="space-y-4">
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Allgemein</legend>
<Input label="Name *" name="name" type="text" required value={market?.name ?? ''} />
<Input
label="Name {mode === 'public' ? 'des Marktes' : ''} *"
name="name"
type="text"
required
value={market?.name ?? ''}
placeholder={mode === 'public' ? 'z.B. Ritterturnier zu München' : ''}
/>
<div class="space-y-1">
<label for="description" class="block text-sm font-medium text-stone-700 dark:text-stone-200">
@@ -38,7 +159,9 @@
rows="4"
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">{market?.description ?? ''}</textarea
dark:border-stone-600 dark:bg-stone-800"
placeholder={mode === 'public' ? 'Beschreibe den Markt kurz...' : ''}
>{market?.description ?? ''}</textarea
>
</div>
</fieldset>
@@ -46,15 +169,40 @@
<fieldset class="space-y-4">
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Standort</legend>
<Input label="Straße" name="street" type="text" value={market?.street ?? ''} />
<Input
label="Straße"
name="street"
type="text"
value={market?.street ?? ''}
placeholder={mode === 'public' ? 'z.B. Marienplatz 1' : ''}
/>
<div class="grid grid-cols-2 gap-4">
<Input label="Stadt *" name="city" type="text" required value={market?.city ?? ''} />
<Input label="Bundesland" name="state" type="text" value={market?.state ?? ''} />
<Input
label="Stadt *"
name="city"
type="text"
required
value={market?.city ?? ''}
placeholder={mode === 'public' ? 'z.B. München' : ''}
/>
<Input
label="Bundesland"
name="state"
type="text"
value={market?.state ?? ''}
placeholder={mode === 'public' ? 'z.B. Bayern' : ''}
/>
</div>
<div class="grid grid-cols-2 gap-4">
<Input label="PLZ" name="zip" type="text" value={market?.zip ?? ''} />
<Input
label="PLZ"
name="zip"
type="text"
value={market?.zip ?? ''}
placeholder={mode === 'public' ? 'z.B. 80331' : ''}
/>
<div class="space-y-1">
<label for="country" class="block text-sm font-medium text-stone-700 dark:text-stone-200">
Land *
@@ -63,35 +211,91 @@
id="country"
name="country"
required
bind:value={selectedCountry}
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"
>
<option value="DE" selected={(market?.country ?? 'DE') === 'DE'}>Deutschland</option>
<option value="AT" selected={market?.country === 'AT'}>Österreich</option>
<option value="CH" selected={market?.country === 'CH'}>Schweiz</option>
<option value="DE">Deutschland</option>
<option value="AT">Österreich</option>
<option value="CH">Schweiz</option>
<option disabled>──────────</option>
<option value="AL">Albanien</option>
<option value="AD">Andorra</option>
<option value="BE">Belgien</option>
<option value="BA">Bosnien und Herzegowina</option>
<option value="BG">Bulgarien</option>
<option value="DK">Dänemark</option>
<option value="EE">Estland</option>
<option value="FI">Finnland</option>
<option value="FR">Frankreich</option>
<option value="GR">Griechenland</option>
<option value="IE">Irland</option>
<option value="IS">Island</option>
<option value="IT">Italien</option>
<option value="XK">Kosovo</option>
<option value="HR">Kroatien</option>
<option value="LV">Lettland</option>
<option value="LI">Liechtenstein</option>
<option value="LT">Litauen</option>
<option value="LU">Luxemburg</option>
<option value="MT">Malta</option>
<option value="MD">Moldawien</option>
<option value="MC">Monaco</option>
<option value="ME">Montenegro</option>
<option value="NL">Niederlande</option>
<option value="MK">Nordmazedonien</option>
<option value="NO">Norwegen</option>
<option value="PL">Polen</option>
<option value="PT">Portugal</option>
<option value="RO">Rumänien</option>
<option value="SM">San Marino</option>
<option value="SE">Schweden</option>
<option value="RS">Serbien</option>
<option value="SK">Slowakei</option>
<option value="SI">Slowenien</option>
<option value="ES">Spanien</option>
<option value="CZ">Tschechien</option>
<option value="UA">Ukraine</option>
<option value="HU">Ungarn</option>
<option value="VA">Vatikanstadt</option>
<option value="GB">Vereinigtes Königreich</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
label="Breitengrad *"
label="Breitengrad"
name="latitude"
type="number"
required
step="any"
value={String(market?.latitude ?? '')}
value={market?.latitude ? String(market.latitude) : ''}
placeholder={mode === 'public' ? 'z.B. 48.1351' : ''}
/>
<Input
label="Längengrad *"
label="Längengrad"
name="longitude"
type="number"
required
step="any"
value={String(market?.longitude ?? '')}
value={market?.longitude ? String(market.longitude) : ''}
placeholder={mode === 'public' ? 'z.B. 11.5820' : ''}
/>
</div>
<div class="flex items-center gap-3">
<button
type="button"
onclick={geocodeAddress}
disabled={geocoding}
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm font-medium disabled:opacity-50"
>
{geocoding ? 'Ermittle...' : 'Koordinaten aus Adresse ermitteln'}
</button>
{#if geocodeError}
<span class="text-danger-600 dark:text-danger-400 text-xs">{geocodeError}</span>
{/if}
</div>
</fieldset>
<fieldset class="space-y-4">
@@ -118,22 +322,231 @@
<fieldset class="space-y-4">
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Weitere Infos</legend>
<Input label="Website" name="website" type="url" value={market?.website ?? ''} />
<Input
label="Website"
name="website"
type="url"
value={market?.website ?? ''}
placeholder={mode === 'public' ? 'https://...' : ''}
/>
<Input
label="Veranstalter"
name="organizer_name"
type="text"
value={market?.organizer_name ?? ''}
placeholder={mode === 'public' ? 'Name des Veranstalters' : ''}
/>
<Input label="Bild-URL" name="image_url" type="url" value={market?.image_url ?? ''} />
</fieldset>
<fieldset class="space-y-4">
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Öffnungszeiten</legend>
{#each hours as row, i}
<div class="flex items-end gap-2">
<div class="space-y-1">
<label
for="hours-day-{i}"
class="block text-sm font-medium text-stone-700 dark:text-stone-200">Tag</label
>
<select
id="hours-day-{i}"
bind:value={row.day}
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 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"
>
{#each days as d}
<option value={d}>{d}</option>
{/each}
</select>
</div>
<Input
label="Von"
type="time"
value={row.open}
oninput={(e) => {
row.open = e.currentTarget.value;
}}
/>
<Input
label="Bis"
type="time"
value={row.close}
oninput={(e) => {
row.close = e.currentTarget.value;
}}
/>
<button
type="button"
onclick={() => removeHoursRow(i)}
class="text-danger-600 hover:text-danger-800 dark:text-danger-400 pb-1 text-sm"
>
Entfernen
</button>
</div>
{/each}
<button
type="button"
onclick={addHoursRow}
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm font-medium"
>
+ Zeile hinzufügen
</button>
<input type="hidden" name="opening_hours" value={JSON.stringify(hours)} />
</fieldset>
<fieldset class="space-y-4">
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Eintrittspreise</legend
>
<div class="grid grid-cols-3 gap-4">
<div class="space-y-1">
<label
for="admission-adult"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
Erwachsene ({currency})
</label>
<input
id="admission-adult"
type="number"
step="0.01"
min="0"
value={(admission.adult_cents / 100).toFixed(2)}
onchange={(e) => {
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"
/>
</div>
<div class="space-y-1">
<label
for="admission-child"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
Kinder ({currency})
</label>
<input
id="admission-child"
type="number"
step="0.01"
min="0"
value={(admission.child_cents / 100).toFixed(2)}
onchange={(e) => {
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"
/>
</div>
<div class="space-y-1">
<label
for="admission-reduced"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
Ermäßigt ({currency})
</label>
<input
id="admission-reduced"
type="number"
step="0.01"
min="0"
value={(admission.reduced_cents / 100).toFixed(2)}
onchange={(e) => {
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"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<label
for="admission-free-under"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
Frei unter (Alter)
</label>
<input
id="admission-free-under"
type="number"
min="0"
bind:value={admission.free_under_age}
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"
/>
</div>
</div>
<div class="space-y-1">
<label
for="admission-notes"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
Hinweise
</label>
<textarea
id="admission-notes"
rows="2"
bind:value={admission.notes}
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"
></textarea>
</div>
<input type="hidden" name="admission_info" value={admissionJson} />
</fieldset>
{#if mode === 'admin'}
<fieldset class="space-y-4">
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Admin-Notizen</legend
>
<div class="space-y-1">
<textarea
id="admin_notes"
name="admin_notes"
rows="3"
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"
placeholder="Interne Notizen...">{market?.admin_notes ?? ''}</textarea
>
</div>
</fieldset>
{/if}
{#if extraFields}
{@render extraFields()}
{/if}
<div class="flex gap-3 border-t border-stone-200 pt-6 dark:border-stone-700">
<Button type="submit" {loading}>{market ? 'Speichern' : 'Erstellen'}</Button>
<a href="/admin/maerkte">
<Button variant="secondary" type="button">Abbrechen</Button>
</a>
<Button type="submit" {loading}>
{#if mode === 'public'}
Markt einreichen
{:else if market}
Speichern
{:else}
Erstellen
{/if}
</Button>
{#if mode === 'admin'}
<a href="/admin/maerkte">
<Button variant="secondary" type="button">Abbrechen</Button>
</a>
{/if}
</div>
</div>

View File

@@ -0,0 +1,186 @@
<script lang="ts">
import type { ResearchResult, FieldSuggestion } from '$lib/api/types.js';
import Button from '$lib/components/ui/Button.svelte';
interface Props {
result: ResearchResult;
onApply: (suggestions: FieldSuggestion[]) => void;
onClose: () => void;
}
let { result, onApply, onClose }: Props = $props();
let selected: boolean[] = $state(result.suggestions.map(() => true));
const fieldLabels: Record<string, string> = {
description: 'Beschreibung',
street: 'Straße',
city: 'Stadt',
zip: 'PLZ',
website: 'Website',
organizer_name: 'Veranstalter',
opening_hours: 'Öffnungszeiten',
admission_info: 'Eintrittspreise',
state: 'Bundesland',
image_url: 'Bild-URL'
};
const confidenceColors: Record<string, string> = {
high: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
medium: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
low: 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-400'
};
function formatValue(val: unknown, _field?: string): string {
if (val === null || val === undefined) return '—';
if (typeof val !== 'object') return String(val);
return JSON.stringify(val, null, 2);
}
function isOpeningHours(
val: unknown
): val is Array<{ day: string; open: string; close: string }> {
return Array.isArray(val) && val.length > 0 && 'day' in val[0];
}
function isAdmissionInfo(val: unknown): val is {
adult_cents: number;
child_cents: number;
reduced_cents: number;
free_under_age: number;
notes: string;
} {
return typeof val === 'object' && val !== null && 'adult_cents' in val;
}
function formatCents(cents: number): string {
return (cents / 100).toFixed(2);
}
function handleApply() {
const chosen = result.suggestions.filter((_, i) => selected[i]);
onApply(chosen);
}
</script>
<div
class="rounded-lg border border-stone-200 bg-stone-50 p-6 dark:border-stone-700 dark:bg-stone-900"
>
<div class="mb-4 flex items-center justify-between">
<h3 class="text-lg font-semibold text-stone-800 dark:text-stone-100">
KI-Recherche Ergebnisse
</h3>
<button
type="button"
onclick={onClose}
class="text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200"
>
Schließen
</button>
</div>
{#if result.suggestions.length === 0}
<p class="text-sm text-stone-600 dark:text-stone-400">Keine Vorschläge gefunden.</p>
{:else}
<div class="space-y-3">
{#each result.suggestions as suggestion, i}
<label
class="flex cursor-pointer gap-3 rounded-lg border border-stone-200 p-3 transition-colors hover:bg-stone-100 dark:border-stone-700 dark:hover:bg-stone-800"
>
<input type="checkbox" bind:checked={selected[i]} class="mt-1" />
<div class="flex-1 space-y-1">
<div class="flex items-center gap-2">
<span class="text-sm font-medium text-stone-800 dark:text-stone-100">
{fieldLabels[suggestion.field] ?? suggestion.field}
</span>
<span
class="rounded-full px-2 py-0.5 text-xs font-medium {confidenceColors[
suggestion.confidence
]}"
>
{suggestion.confidence}
</span>
</div>
{#if suggestion.current_value !== null && suggestion.current_value !== undefined}
<div class="text-xs text-stone-500 dark:text-stone-400">
Aktuell: {formatValue(suggestion.current_value, suggestion.field)}
</div>
{/if}
<div class="text-sm text-stone-900 dark:text-stone-100">
{#if suggestion.field === 'opening_hours' && isOpeningHours(suggestion.suggested_value)}
<table class="w-full text-sm">
<thead>
<tr class="text-left text-stone-500 dark:text-stone-400">
<th class="pr-4 pb-1 font-medium">Tag</th>
<th class="pr-4 pb-1 font-medium">Von</th>
<th class="pb-1 font-medium">Bis</th>
</tr>
</thead>
<tbody>
{#each suggestion.suggested_value as entry}
<tr>
<td class="py-0.5 pr-4">{entry.day}</td>
<td class="py-0.5 pr-4">{entry.open}</td>
<td class="py-0.5">{entry.close}</td>
</tr>
{/each}
</tbody>
</table>
{:else if suggestion.field === 'admission_info' && isAdmissionInfo(suggestion.suggested_value)}
<dl class="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<dt class="text-stone-500 dark:text-stone-400">Erwachsene</dt>
<dd>{formatCents(suggestion.suggested_value.adult_cents)}</dd>
<dt class="text-stone-500 dark:text-stone-400">Kinder</dt>
<dd>{formatCents(suggestion.suggested_value.child_cents)}</dd>
<dt class="text-stone-500 dark:text-stone-400">Ermäßigt</dt>
<dd>{formatCents(suggestion.suggested_value.reduced_cents)}</dd>
{#if suggestion.suggested_value.free_under_age > 0}
<dt class="text-stone-500 dark:text-stone-400">Frei unter</dt>
<dd>{suggestion.suggested_value.free_under_age} Jahre</dd>
{/if}
{#if suggestion.suggested_value.notes}
<dt class="col-span-2 mt-1 text-stone-500 dark:text-stone-400">
{suggestion.suggested_value.notes}
</dt>
{/if}
</dl>
{:else}
{formatValue(suggestion.suggested_value, suggestion.field)}
{/if}
</div>
{#if suggestion.reason}
<div class="text-xs text-stone-500 italic dark:text-stone-400">
{suggestion.reason}
</div>
{/if}
</div>
</label>
{/each}
</div>
<div class="mt-4 flex gap-2">
<Button type="button" onclick={handleApply}>Übernehmen</Button>
<Button type="button" variant="secondary" onclick={onClose}>Abbrechen</Button>
</div>
{/if}
{#if result.sources && result.sources.length > 0}
<div class="mt-4 border-t border-stone-200 pt-3 dark:border-stone-700">
<p class="mb-1 text-xs font-medium text-stone-500 dark:text-stone-400">Quellen:</p>
<ul class="space-y-0.5">
{#each result.sources as source}
<li>
<a
href={source}
target="_blank"
rel="noopener noreferrer"
class="text-primary-600 dark:text-primary-400 text-xs break-all hover:underline"
>
{source}
</a>
</li>
{/each}
</ul>
</div>
{/if}
</div>

View File

@@ -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<AdminMarketDetail>(`/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<DuplicateMarket[]>(
`/admin/markets/${params.id}/duplicates`,
cookies
);
duplicates = dupRes.data ?? [];
} catch {
// Non-fatal: duplicates are informational
}
}
return { market, duplicates };
};
export const actions: Actions = {

View File

@@ -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 @@
</Alert>
{/if}
{#if data.duplicates && data.duplicates.length > 0}
<div
class="rounded-lg border border-amber-300 bg-amber-50 p-4 dark:border-amber-700 dark:bg-amber-950"
>
<h3 class="mb-2 font-semibold text-amber-800 dark:text-amber-200">
Mögliche Duplikate gefunden
</h3>
<ul class="space-y-1">
{#each data.duplicates as dup}
<li class="text-sm text-amber-700 dark:text-amber-300">
<a href="/admin/maerkte/{dup.id}" class="font-medium hover:underline">
{dup.name}
</a>
{dup.city}, {formatDate(dup.start_date)} - {formatDate(dup.end_date)}
<span class="text-xs text-amber-600 dark:text-amber-400">
({Math.round(dup.similarity * 100)}% Ähnlichkeit)
</span>
</li>
{/each}
</ul>
</div>
{/if}
<!-- Status badge + review section -->
<div class="rounded-lg border border-stone-200 p-6 dark:border-stone-700">
<div class="flex items-center gap-3">
@@ -173,7 +203,7 @@
<div>
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Koordinaten</dt>
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
{data.market.latitude.toFixed(6)}, {data.market.longitude.toFixed(6)}
{data.market.latitude?.toFixed(6) ?? '—'}, {data.market.longitude?.toFixed(6) ?? '—'}
</dd>
</div>
<div>
@@ -214,6 +244,76 @@
</dl>
</div>
<!-- Opening hours -->
{#if data.market.opening_hours && data.market.opening_hours.length > 0}
<div class="rounded-lg border border-stone-200 p-6 dark:border-stone-700">
<h2 class="mb-4 text-lg font-semibold">Öffnungszeiten</h2>
<table class="w-full text-sm">
<thead>
<tr class="text-left text-stone-500 dark:text-stone-400">
<th class="pb-2 font-medium">Tag</th>
<th class="pb-2 font-medium">Von</th>
<th class="pb-2 font-medium">Bis</th>
</tr>
</thead>
<tbody>
{#each data.market.opening_hours as entry}
<tr class="border-t border-stone-100 dark:border-stone-700">
<td class="py-1.5 text-stone-900 dark:text-stone-100">{entry.day}</td>
<td class="py-1.5 text-stone-900 dark:text-stone-100">{entry.open}</td>
<td class="py-1.5 text-stone-900 dark:text-stone-100">{entry.close}</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<!-- Admission info -->
{#if data.market.admission_info && (data.market.admission_info.adult_cents > 0 || data.market.admission_info.child_cents > 0 || data.market.admission_info.notes)}
<div class="rounded-lg border border-stone-200 p-6 dark:border-stone-700">
<h2 class="mb-4 text-lg font-semibold">Eintrittspreise</h2>
<dl class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Erwachsene</dt>
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
{formatPrice(data.market.admission_info.adult_cents)}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Kinder</dt>
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
{formatPrice(data.market.admission_info.child_cents)}
</dd>
</div>
<div>
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Ermäßigt</dt>
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
{formatPrice(data.market.admission_info.reduced_cents)}
</dd>
</div>
{#if data.market.admission_info.free_under_age > 0}
<div>
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">
Frei unter (Alter)
</dt>
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
{data.market.admission_info.free_under_age} Jahre
</dd>
</div>
{/if}
{#if data.market.admission_info.notes}
<div class="sm:col-span-2">
<dt class="text-sm font-medium text-stone-500 dark:text-stone-400">Hinweise</dt>
<dd class="mt-1 text-sm text-stone-900 dark:text-stone-100">
{data.market.admission_info.notes}
</dd>
</div>
{/if}
</dl>
</div>
{/if}
<!-- Submitter info -->
{#if data.market.submitter_email || data.market.submitter_name}
<div class="rounded-lg border border-stone-200 p-6 dark:border-stone-700">

View File

@@ -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<string, unknown> = {};
@@ -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<ResearchResult>(
`/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 });
}
}
};

View File

@@ -1,9 +1,79 @@
<script lang="ts">
import { enhance } from '$app/forms';
import MarketForm from '$lib/components/admin/MarketForm.svelte';
import ResearchPanel from '$lib/components/admin/ResearchPanel.svelte';
import Button from '$lib/components/ui/Button.svelte';
import type {
ResearchResult,
FieldSuggestion,
OpeningHoursEntry,
AdmissionInfo
} from '$lib/api/types.js';
let { data, form } = $props();
let loading = $state(false);
let researching = $state(false);
let researchResult: ResearchResult | null = $state(null);
let dismissed = $state(false);
let marketForm: MarketForm;
$effect(() => {
if (form?.research && !dismissed) {
researchResult = form.research as ResearchResult;
}
});
const validDays = [
'Sonntag',
'Montag',
'Dienstag',
'Mittwoch',
'Donnerstag',
'Freitag',
'Samstag'
];
function normalizeDayName(day: string): string {
const dateMatch = day.match(/(\d{2})\.(\d{2})\.(\d{4})/);
if (dateMatch) {
const d = new Date(+dateMatch[3], +dateMatch[2] - 1, +dateMatch[1]);
return validDays[d.getDay()];
}
const match = validDays.find((d) => day.startsWith(d));
return match ?? day;
}
function applyResearch(suggestions: FieldSuggestion[]) {
for (const s of suggestions) {
if (s.field === 'opening_hours' && Array.isArray(s.suggested_value)) {
const normalized = (s.suggested_value as OpeningHoursEntry[]).map((entry) => ({
...entry,
day: normalizeDayName(entry.day)
}));
marketForm.setHours(normalized);
continue;
}
if (
s.field === 'admission_info' &&
typeof s.suggested_value === 'object' &&
s.suggested_value !== null
) {
marketForm.setAdmission(s.suggested_value as AdmissionInfo);
continue;
}
const el = document.querySelector<HTMLInputElement | HTMLTextAreaElement>(
`[name="${s.field}"]`
);
if (el) {
el.value =
typeof s.suggested_value === 'string'
? s.suggested_value
: JSON.stringify(s.suggested_value);
el.dispatchEvent(new Event('input', { bubbles: true }));
}
}
researchResult = null;
}
</script>
<svelte:head>
@@ -11,18 +81,45 @@
</svelte:head>
<div class="space-y-6">
<div>
<a
href="/admin/maerkte/{data.market.id}"
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm"
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<a
href="/admin/maerkte/{data.market.id}"
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm"
>
&larr; Zurück zum Markt
</a>
<h1 class="mt-1 text-2xl font-bold">Markt bearbeiten</h1>
</div>
<form
method="POST"
action="?/research"
use:enhance={() => {
researching = true;
return async ({ update }) => {
researching = false;
await update();
};
}}
>
&larr; Zurück zum Markt
</a>
<h1 class="mt-1 text-2xl font-bold">Markt bearbeiten</h1>
<Button type="submit" variant="secondary" loading={researching}>Mit KI recherchieren</Button>
</form>
</div>
{#if researchResult}
<ResearchPanel
result={researchResult}
onApply={applyResearch}
onClose={() => {
researchResult = null;
dismissed = true;
}}
/>
{/if}
<form
method="POST"
action="?/save"
use:enhance={() => {
loading = true;
return async ({ update }) => {
@@ -31,6 +128,6 @@
};
}}
>
<MarketForm market={data.market} {loading} error={form?.error} />
<MarketForm bind:this={marketForm} market={data.market} {loading} error={form?.error} />
</form>
</div>

View File

@@ -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<AdminMarketDetail>('/admin/markets', cookies, {
method: 'POST',

View File

@@ -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 });
}
};

View File

@@ -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<string, unknown> = {
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
});

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import { enhance } from '$app/forms';
import Input from '$lib/components/ui/Input.svelte';
import Button from '$lib/components/ui/Button.svelte';
import MarketForm from '$lib/components/admin/MarketForm.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
let { data, form } = $props();
@@ -36,7 +35,7 @@
<form
method="POST"
class="mt-6 space-y-6"
class="mt-6"
use:enhance={() => {
loading = true;
return async ({ update }) => {
@@ -45,210 +44,61 @@
};
}}
>
<fieldset class="space-y-4">
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">
Markt-Informationen
</legend>
<MarketForm {loading} error={form?.error} mode="public">
{#snippet extraFields()}
<fieldset class="space-y-4">
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">
Deine Kontaktdaten
</legend>
<p class="text-sm text-stone-500 dark:text-stone-400">
Werden nicht veröffentlicht. Nur für Rückfragen.
</p>
<Input
label="Name des Marktes *"
name="name"
type="text"
required
value={form?.name ?? ''}
placeholder="z.B. Ritterturnier zu München"
/>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1">
<label
for="submitter_name"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
Dein Name *
</label>
<input
id="submitter_name"
name="submitter_name"
type="text"
required
value={form?.submitterName ?? ''}
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"
/>
</div>
<div class="space-y-1">
<label
for="submitter_email"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
Deine E-Mail *
</label>
<input
id="submitter_email"
name="submitter_email"
type="email"
required
value={form?.submitterEmail ?? ''}
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"
/>
</div>
</div>
</fieldset>
<div class="space-y-1">
<label
for="description"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
Beschreibung
</label>
<textarea
id="description"
name="description"
rows="4"
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"
placeholder="Beschreibe den Markt kurz...">{form?.description ?? ''}</textarea
>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
label="Stadt *"
name="city"
type="text"
required
value={form?.city ?? ''}
placeholder="z.B. München"
/>
<Input
label="Bundesland"
name="state"
type="text"
value={form?.state ?? ''}
placeholder="z.B. Bayern"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
label="PLZ"
name="zip"
type="text"
value={form?.zip ?? ''}
placeholder="z.B. 80331"
/>
<div class="space-y-1">
<label
for="country"
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
>
Land *
</label>
<select
id="country"
name="country"
required
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"
value={form?.country ?? 'DE'}
>
<option value="DE">Deutschland</option>
<option value="AT">Österreich</option>
<option value="CH">Schweiz</option>
<option disabled>──────────</option>
<option value="AL">Albanien</option>
<option value="AD">Andorra</option>
<option value="BE">Belgien</option>
<option value="BA">Bosnien und Herzegowina</option>
<option value="BG">Bulgarien</option>
<option value="DK">Dänemark</option>
<option value="EE">Estland</option>
<option value="FI">Finnland</option>
<option value="FR">Frankreich</option>
<option value="GR">Griechenland</option>
<option value="IE">Irland</option>
<option value="IS">Island</option>
<option value="IT">Italien</option>
<option value="XK">Kosovo</option>
<option value="HR">Kroatien</option>
<option value="LV">Lettland</option>
<option value="LI">Liechtenstein</option>
<option value="LT">Litauen</option>
<option value="LU">Luxemburg</option>
<option value="MT">Malta</option>
<option value="MD">Moldawien</option>
<option value="MC">Monaco</option>
<option value="ME">Montenegro</option>
<option value="NL">Niederlande</option>
<option value="MK">Nordmazedonien</option>
<option value="NO">Norwegen</option>
<option value="PL">Polen</option>
<option value="PT">Portugal</option>
<option value="RO">Rumänien</option>
<option value="SM">San Marino</option>
<option value="SE">Schweden</option>
<option value="RS">Serbien</option>
<option value="SK">Slowakei</option>
<option value="SI">Slowenien</option>
<option value="ES">Spanien</option>
<option value="CZ">Tschechien</option>
<option value="UA">Ukraine</option>
<option value="HU">Ungarn</option>
<option value="VA">Vatikanstadt</option>
<option value="GB">Vereinigtes Königreich</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
label="Breitengrad"
name="latitude"
type="number"
value={form?.latitude ?? ''}
placeholder="z.B. 48.1351"
step="any"
/>
<Input
label="Längengrad"
name="longitude"
type="number"
value={form?.longitude ?? ''}
placeholder="z.B. 11.5820"
step="any"
/>
</div>
<div class="grid grid-cols-2 gap-4">
<Input
label="Startdatum *"
name="start_date"
type="date"
required
value={form?.startDate ?? ''}
/>
<Input
label="Enddatum *"
name="end_date"
type="date"
required
value={form?.endDate ?? ''}
/>
</div>
<Input
label="Website"
name="website"
type="url"
value={form?.website ?? ''}
placeholder="https://..."
/>
<Input
label="Veranstalter"
name="organizer_name"
type="text"
value={form?.organizerName ?? ''}
placeholder="Name des Veranstalters"
/>
</fieldset>
<fieldset class="space-y-4">
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">
Deine Kontaktdaten
</legend>
<p class="text-sm text-stone-500 dark:text-stone-400">
Werden nicht veröffentlicht. Nur für Rückfragen.
</p>
<Input
label="Dein Name *"
name="submitter_name"
type="text"
required
value={form?.submitterName ?? ''}
/>
<Input
label="Deine E-Mail *"
name="submitter_email"
type="email"
required
value={form?.submitterEmail ?? ''}
/>
</fieldset>
{#if data.turnstileSiteKey}
<div class="cf-turnstile" data-sitekey={data.turnstileSiteKey} data-theme="auto"></div>
{/if}
<Button type="submit" {loading}>Markt einreichen</Button>
{#if data.turnstileSiteKey}
<div class="cf-turnstile" data-sitekey={data.turnstileSiteKey} data-theme="auto"></div>
{/if}
{/snippet}
</MarketForm>
</form>
{/if}
</div>