Merge branch 'feat/admin-market-enhancements'

This commit is contained in:
2026-03-05 18:18:20 +01:00
16 changed files with 1530 additions and 327 deletions

View File

@@ -20,6 +20,7 @@ export interface PaginationMeta {
// Market types
export interface MarketSummary {
id: string;
series_id?: string;
slug: string;
name: string;
city: string;
@@ -33,6 +34,7 @@ export interface MarketSummary {
image_url: string;
organizer_name: string;
distance?: number; // meters, only in geo queries
edition_count?: number;
}
export interface MarketDetail {
@@ -70,6 +72,12 @@ export interface AdmissionInfo {
notes: string;
}
export interface EditionBrief {
year: number;
start_date: string;
end_date: string;
}
// Auth types
export interface AuthData {
access_token: string;
@@ -99,15 +107,26 @@ export interface ProfileData {
}
// Admin types
export type MarketStatus = 'pending' | 'approved' | 'rejected';
export type EditionStatus =
| 'rumored'
| 'confirmed'
| 'active'
| 'completed'
| 'cancelled'
| 'archived';
// Keep backward compat alias
export type MarketStatus = EditionStatus;
export interface AdminMarketSummary {
id: string;
series_id: string;
year: number;
slug: string;
name: string;
city: string;
state: string;
status: MarketStatus;
status: EditionStatus;
start_date: string;
end_date: string;
organizer_name: string;
@@ -117,7 +136,10 @@ export interface AdminMarketSummary {
export interface AdminMarketDetail {
id: string;
series_id: string;
year: number;
slug: string;
series_name: string;
name: string;
description: string;
street: string;
@@ -134,7 +156,8 @@ export interface AdminMarketDetail {
website: string;
organizer_name: string;
image_url: string;
status: MarketStatus;
sources: string[] | null;
status: EditionStatus;
submitter_email?: string;
submitter_name: string;
admin_notes: string;
@@ -160,6 +183,52 @@ 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;
}
// Series types
export interface SeriesSummary {
id: string;
slug: string;
name: string;
city: string;
country: string;
}
export interface AdminSeriesGroup {
series_id: string;
slug: string;
series_name: string;
city: string;
editions: AdminMarketSummary[];
}
export interface SeriesEditionsResponse {
series: SeriesSummary;
editions: AdminMarketSummary[];
}
// 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

@@ -61,6 +61,11 @@
</svg>
{formatDate(market.start_date)} {formatDate(market.end_date)}
</span>
{#if market.edition_count && market.edition_count > 1}
<span class="text-primary-600 dark:text-primary-400 text-xs font-medium">
+{market.edition_count - 1} weitere {market.edition_count > 2 ? 'Termine' : 'Termin'}
</span>
{/if}
{#if market.distance !== undefined}
<span class="text-primary-600 dark:text-primary-400 flex items-center gap-1">
<svg

View File

@@ -2,23 +2,25 @@ import { error } from '@sveltejs/kit';
import { serverFetch } from '$lib/api/client.server.js';
import { ApiClientError } from '$lib/api/client.js';
import { buildSearchQuery } from '$lib/api/client.js';
import type { AdminMarketSummary, PaginationMeta } from '$lib/api/types.js';
import type { AdminSeriesGroup, PaginationMeta } from '$lib/api/types.js';
import type { PageServerLoad } from './$types.js';
export const load: PageServerLoad = async ({ url, cookies }) => {
const status = url.searchParams.get('status') ?? '';
const q = url.searchParams.get('q') ?? '';
const page = url.searchParams.get('page') ?? '1';
const sort = url.searchParams.get('sort') ?? '';
const order = url.searchParams.get('order') ?? '';
const query = buildSearchQuery({ status, q, page, per_page: '20' });
const query = buildSearchQuery({ status, q, page, per_page: '20', sort, order });
try {
const res = await serverFetch<AdminMarketSummary[]>(`/admin/markets?${query}`, cookies);
const res = await serverFetch<AdminSeriesGroup[]>(`/admin/markets/grouped?${query}`, cookies);
return {
markets: res.data,
groups: res.data,
meta: res.meta as PaginationMeta,
filters: { status, q }
filters: { status, q, sort, order }
};
} catch (err) {
if (err instanceof ApiClientError) {

View File

@@ -1,33 +1,99 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import Button from '$lib/components/ui/Button.svelte';
import type { MarketStatus } from '$lib/api/types.js';
import type { EditionStatus } from '$lib/api/types.js';
let { data } = $props();
const statusLabels: Record<MarketStatus, string> = {
pending: 'Ausstehend',
approved: 'Genehmigt',
rejected: 'Abgelehnt'
let searchValue = $state(data.filters.q);
let expandedSeries = $state(new Set<string>());
const statusLabels: Record<EditionStatus, string> = {
rumored: 'Ausstehend',
confirmed: 'Bestätigt',
active: 'Aktiv',
completed: 'Abgeschlossen',
cancelled: 'Abgesagt',
archived: 'Archiviert'
};
const statusColors: Record<MarketStatus, string> = {
pending: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
approved: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
rejected: 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-200'
const statusColors: Record<EditionStatus, string> = {
rumored: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
confirmed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
active: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
completed: 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-400',
cancelled: 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-200',
archived: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-500'
};
const tabs: { label: string; value: string }[] = [
{ label: 'Alle', value: '' },
{ label: 'Ausstehend', value: 'pending' },
{ label: 'Genehmigt', value: 'approved' },
{ label: 'Abgelehnt', value: 'rejected' }
{ label: 'Ausstehend', value: 'rumored' },
{ label: 'Bestätigt', value: 'confirmed' },
{ label: 'Aktiv', value: 'active' },
{ label: 'Abgeschlossen', value: 'completed' },
{ label: 'Abgesagt', value: 'cancelled' }
];
const currentStatus = $derived(page.url.searchParams.get('status') ?? '');
const currentQ = $derived(page.url.searchParams.get('q') ?? '');
const currentSort = $derived(page.url.searchParams.get('sort') ?? '');
const currentOrder = $derived(page.url.searchParams.get('order') ?? '');
function formatDate(dateStr: string): string {
if (!dateStr) return '';
const d = new Date(dateStr);
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function buildUrl(overrides: Record<string, string> = {}): string {
const params = new URLSearchParams();
const status = overrides.status ?? currentStatus;
const q = overrides.q ?? currentQ;
const sort = overrides.sort ?? currentSort;
const order = overrides.order ?? currentOrder;
const pg = overrides.page ?? '1';
if (status) params.set('status', status);
if (q) params.set('q', q);
if (sort) params.set('sort', sort);
if (order) params.set('order', order);
if (pg !== '1') params.set('page', pg);
const qs = params.toString();
return `/admin/maerkte${qs ? `?${qs}` : ''}`;
}
function sortUrl(column: string): string {
const isActive = currentSort === column;
const defaultAsc = column === 'name' || column === 'city';
if (isActive) {
const newOrder = currentOrder === 'asc' ? 'desc' : 'asc';
return buildUrl({ sort: column, order: newOrder });
}
return buildUrl({ sort: column, order: defaultAsc ? 'asc' : 'desc' });
}
function sortIndicator(column: string): string {
if (currentSort !== column) return '';
return currentOrder === 'asc' ? ' \u2191' : ' \u2193';
}
function handleSearch(e: SubmitEvent) {
e.preventDefault();
goto(buildUrl({ q: searchValue }));
}
function toggleExpand(seriesId: string) {
const next = new Set(expandedSeries);
if (next.has(seriesId)) {
next.delete(seriesId);
} else {
next.add(seriesId);
}
expandedSeries = next;
}
</script>
<svelte:head>
@@ -42,13 +108,33 @@
</a>
</div>
<!-- Search + status filters -->
<div class="flex flex-wrap items-center gap-4">
<form onsubmit={handleSearch} class="flex gap-2">
<input
type="text"
bind:value={searchValue}
placeholder="Name oder Stadt suchen..."
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 rounded-lg border border-stone-300 px-3 py-1.5
text-sm shadow-sm focus:ring-2 focus:outline-none
dark:border-stone-600 dark:bg-stone-800"
/>
<Button type="submit" variant="secondary" size="sm">Suchen</Button>
{#if currentQ}
<a href={buildUrl({ q: '' })}>
<Button type="button" variant="secondary" size="sm">Zurücksetzen</Button>
</a>
{/if}
</form>
</div>
<!-- Status filter tabs -->
<div class="flex gap-1 rounded-lg bg-stone-100 p-1 dark:bg-stone-800">
{#each tabs as tab}
<a
href="/admin/maerkte?status={tab.value}{data.filters.q ? `&q=${data.filters.q}` : ''}"
href={buildUrl({ status: tab.value })}
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors
{data.filters.status === tab.value
{currentStatus === tab.value
? 'bg-white text-stone-900 shadow-sm dark:bg-stone-700 dark:text-stone-100'
: 'text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-200'}"
>
@@ -57,51 +143,108 @@
{/each}
</div>
<!-- Market table -->
<!-- Market table (grouped by series) -->
<div class="overflow-x-auto rounded-lg border border-stone-200 dark:border-stone-700">
<table class="w-full text-left text-sm">
<thead class="bg-stone-50 dark:bg-stone-800">
<tr>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">Name</th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">Stadt</th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">Status</th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">Zeitraum</th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">Erstellt</th>
<th class="w-8 px-2 py-3"></th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">
<a href={sortUrl('name')} class="hover:text-stone-900 dark:hover:text-stone-100">
Name{sortIndicator('name')}
</a>
</th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">
<a href={sortUrl('city')} class="hover:text-stone-900 dark:hover:text-stone-100">
Stadt{sortIndicator('city')}
</a>
</th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">
<a href={sortUrl('status')} class="hover:text-stone-900 dark:hover:text-stone-100">
Status{sortIndicator('status')}
</a>
</th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300"> Jahr </th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">
<a href={sortUrl('date')} class="hover:text-stone-900 dark:hover:text-stone-100">
Zeitraum{sortIndicator('date')}
</a>
</th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">
<a href={sortUrl('created')} class="hover:text-stone-900 dark:hover:text-stone-100">
Erstellt{sortIndicator('created')}
</a>
</th>
<th class="px-4 py-3 font-medium text-stone-600 dark:text-stone-300">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-stone-200 dark:divide-stone-700">
{#each data.markets as market}
{#each data.groups as group}
{@const latest = group.editions[0]}
{@const hasMultiple = group.editions.length > 1}
{@const isExpanded = expandedSeries.has(group.series_id)}
<!-- Primary row (latest edition) -->
<tr class="hover:bg-stone-50 dark:hover:bg-stone-800/50">
<td class="px-4 py-3 font-medium text-stone-900 dark:text-stone-100">
{market.name}
<td class="px-2 py-3 text-center">
{#if hasMultiple}
<button
onclick={() => toggleExpand(group.series_id)}
class="inline-flex h-5 w-5 items-center justify-center rounded text-stone-400 transition-colors hover:bg-stone-200 hover:text-stone-600 dark:hover:bg-stone-700 dark:hover:text-stone-300"
aria-label={isExpanded ? 'Zuklappen' : 'Aufklappen'}
>
<svg
class="h-3.5 w-3.5 transition-transform {isExpanded ? 'rotate-90' : ''}"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M8.25 4.5l7.5 7.5-7.5 7.5"
/>
</svg>
</button>
{/if}
</td>
<td class="px-4 py-3 text-stone-600 dark:text-stone-400">{market.city}</td>
<td class="px-4 py-3 font-medium text-stone-900 dark:text-stone-100">
{latest.name}
{#if hasMultiple}
<span class="text-primary-600 dark:text-primary-400 ml-1.5 text-xs font-normal">
({group.editions.length} Ausgaben)
</span>
{/if}
</td>
<td class="px-4 py-3 text-stone-600 dark:text-stone-400">{latest.city}</td>
<td class="px-4 py-3">
<span
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusColors[
market.status
latest.status
]}"
>
{statusLabels[market.status]}
{statusLabels[latest.status]}
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap text-stone-600 dark:text-stone-400">
{formatDate(market.start_date)} - {formatDate(market.end_date)}
<td class="px-4 py-3 text-stone-600 dark:text-stone-400">
{latest.year}
</td>
<td class="px-4 py-3 whitespace-nowrap text-stone-600 dark:text-stone-400">
{formatDate(market.created_at)}
{formatDate(latest.start_date)} - {formatDate(latest.end_date)}
</td>
<td class="px-4 py-3 whitespace-nowrap text-stone-600 dark:text-stone-400">
{formatDate(latest.created_at)}
</td>
<td class="px-4 py-3">
<div class="flex gap-2">
<a
href="/admin/maerkte/{market.id}"
href="/admin/maerkte/{latest.id}"
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
>
Ansehen
</a>
<a
href="/admin/maerkte/{market.id}/bearbeiten"
href="/admin/maerkte/{latest.id}/bearbeiten"
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
>
Bearbeiten
@@ -109,9 +252,59 @@
</div>
</td>
</tr>
<!-- Expanded sub-rows (older editions) -->
{#if hasMultiple && isExpanded}
{#each group.editions.slice(1) as edition}
<tr
class="bg-stone-50/50 hover:bg-stone-100 dark:bg-stone-800/30 dark:hover:bg-stone-800/50"
>
<td class="px-2 py-2"></td>
<td class="px-4 py-2 pl-8 text-stone-600 dark:text-stone-400">
{edition.name}
</td>
<td class="px-4 py-2 text-stone-500 dark:text-stone-500">
{edition.city}
</td>
<td class="px-4 py-2">
<span
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {statusColors[
edition.status
]}"
>
{statusLabels[edition.status]}
</span>
</td>
<td class="px-4 py-2 text-stone-500 dark:text-stone-500">
{edition.year}
</td>
<td class="px-4 py-2 whitespace-nowrap text-stone-500 dark:text-stone-500">
{formatDate(edition.start_date)} - {formatDate(edition.end_date)}
</td>
<td class="px-4 py-2 whitespace-nowrap text-stone-500 dark:text-stone-500">
{formatDate(edition.created_at)}
</td>
<td class="px-4 py-2">
<div class="flex gap-2">
<a
href="/admin/maerkte/{edition.id}"
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
>
Ansehen
</a>
<a
href="/admin/maerkte/{edition.id}/bearbeiten"
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
>
Bearbeiten
</a>
</div>
</td>
</tr>
{/each}
{/if}
{:else}
<tr>
<td colspan="6" class="px-4 py-8 text-center text-stone-500 dark:text-stone-400">
<td colspan="8" class="px-4 py-8 text-center text-stone-500 dark:text-stone-400">
Keine Märkte gefunden.
</td>
</tr>
@@ -124,26 +317,16 @@
{#if data.meta && data.meta.total_pages > 1}
<div class="flex items-center justify-between">
<p class="text-sm text-stone-600 dark:text-stone-400">
Seite {data.meta.page} von {data.meta.total_pages} ({data.meta.total} Einträge)
Seite {data.meta.page} von {data.meta.total_pages} ({data.meta.total} Serien)
</p>
<div class="flex gap-2">
{#if data.meta.page > 1}
<a
href="/admin/maerkte?page={data.meta.page - 1}&status={data.filters.status}{data.filters
.q
? `&q=${data.filters.q}`
: ''}"
>
<a href={buildUrl({ page: String(data.meta.page - 1) })}>
<Button variant="secondary" size="sm">Zurück</Button>
</a>
{/if}
{#if data.meta.page < data.meta.total_pages}
<a
href="/admin/maerkte?page={data.meta.page + 1}&status={data.filters.status}{data.filters
.q
? `&q=${data.filters.q}`
: ''}"
>
<a href={buildUrl({ page: String(data.meta.page + 1) })}>
<Button variant="secondary" size="sm">Weiter</Button>
</a>
{/if}

View File

@@ -1,11 +1,49 @@
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,
EditionStatus,
SeriesEditionsResponse
} 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 === 'rumored') {
try {
const dupRes = await serverFetch<DuplicateMarket[]>(
`/admin/markets/${params.id}/duplicates`,
cookies
);
duplicates = dupRes.data ?? [];
} catch {
// Non-fatal: duplicates are informational
}
}
// Fetch other editions for this series
let editions: { id: string; year: number; status: EditionStatus }[] = [];
try {
const edRes = await serverFetch<SeriesEditionsResponse>(
`/admin/series/${market.series_id}/editions`,
cookies
);
// The response wraps editions inside { series, editions }
const raw = edRes.data as unknown as SeriesEditionsResponse;
editions = raw.editions.map((e) => ({
id: e.id,
year: e.year,
status: e.status as EditionStatus
}));
} catch {
// Non-fatal
}
return { market, duplicates, editions };
};
export const actions: Actions = {
@@ -44,5 +82,33 @@ export const actions: Actions = {
const message = err instanceof Error ? err.message : 'Loeschen fehlgeschlagen.';
return fail(500, { error: message });
}
},
createEdition: async ({ request, cookies, fetch }) => {
const form = await request.formData();
const seriesId = form.get('series_id')?.toString() ?? '';
const startDate = form.get('start_date')?.toString() ?? '';
const endDate = form.get('end_date')?.toString() ?? '';
if (!seriesId || !startDate || !endDate) {
return fail(400, { error: 'Start- und Enddatum sind erforderlich.' });
}
try {
const res = await serverFetch<AdminMarketDetail>(
`/admin/series/${seriesId}/editions`,
cookies,
{
method: 'POST',
body: JSON.stringify({ start_date: startDate, end_date: endDate }),
fetch
}
);
return { created: true, newEditionId: res.data.id };
} catch (err) {
const message = err instanceof Error ? err.message : 'Edition erstellen fehlgeschlagen.';
return fail(500, { error: message });
}
}
};

View File

@@ -3,34 +3,53 @@
import { goto } from '$app/navigation';
import Button from '$lib/components/ui/Button.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import type { MarketStatus } from '$lib/api/types.js';
import type { EditionStatus } from '$lib/api/types.js';
let { data, form } = $props();
let loading = $state(false);
let showNewEdition = $state(false);
const statusLabels: Record<MarketStatus, string> = {
pending: 'Ausstehend',
approved: 'Genehmigt',
rejected: 'Abgelehnt'
const statusLabels: Record<EditionStatus, string> = {
rumored: 'Ausstehend',
confirmed: 'Bestätigt',
active: 'Aktiv',
completed: 'Abgeschlossen',
cancelled: 'Abgesagt',
archived: 'Archiviert'
};
const statusColors: Record<MarketStatus, string> = {
pending: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
approved: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
rejected: 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-200'
const statusColors: Record<EditionStatus, string> = {
rumored: 'bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200',
confirmed: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
active: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
completed: 'bg-stone-100 text-stone-600 dark:bg-stone-800 dark:text-stone-400',
cancelled: 'bg-danger-100 text-danger-800 dark:bg-danger-900 dark:text-danger-200',
archived: 'bg-stone-100 text-stone-500 dark:bg-stone-800 dark:text-stone-500'
};
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);
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
const isReviewable = $derived(data.market.status === 'rumored');
$effect(() => {
if (form?.deleted) {
goto('/admin/maerkte');
}
if (form?.created && form?.newEditionId) {
goto(`/admin/maerkte/${form.newEditionId}`);
}
});
</script>
@@ -48,8 +67,48 @@
&larr; Zurück zur Liste
</a>
<h1 class="mt-1 text-2xl font-bold">{data.market.name}</h1>
<p class="mt-0.5 text-sm text-stone-500 dark:text-stone-400">
Edition {data.market.year}
{#if data.market.series_name !== data.market.name}
&middot; Serie: {data.market.series_name}
{/if}
</p>
</div>
<div class="flex gap-2">
{#if data.editions && data.editions.length > 1}
<details class="relative">
<summary
class="cursor-pointer rounded-lg border border-stone-300 px-3 py-1.5 text-sm font-medium text-stone-700 hover:bg-stone-50 dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-800"
>
{data.editions.length} Editionen
</summary>
<div
class="absolute right-0 z-10 mt-1 min-w-48 rounded-lg border border-stone-200 bg-white p-2 shadow-lg dark:border-stone-700 dark:bg-stone-900"
>
{#each data.editions as ed}
<a
href="/admin/maerkte/{ed.id}"
class="block rounded px-3 py-1.5 text-sm hover:bg-stone-100 dark:hover:bg-stone-800
{ed.id === data.market.id
? 'text-primary-600 dark:text-primary-400 font-semibold'
: 'text-stone-700 dark:text-stone-300'}"
>
{ed.year}
<span class="ml-1 rounded-full px-1.5 py-0.5 text-xs {statusColors[ed.status]}">
{statusLabels[ed.status]}
</span>
</a>
{/each}
</div>
</details>
{/if}
<button
type="button"
class="rounded-lg border border-stone-300 px-3 py-1.5 text-sm font-medium text-stone-700 hover:bg-stone-50 dark:border-stone-600 dark:text-stone-300 dark:hover:bg-stone-800"
onclick={() => (showNewEdition = !showNewEdition)}
>
+ Neue Edition
</button>
<a href="/admin/maerkte/{data.market.id}/bearbeiten">
<Button variant="secondary" size="sm">Bearbeiten</Button>
</a>
@@ -73,16 +132,105 @@
</div>
</div>
{#if showNewEdition}
<div
class="border-primary-200 bg-primary-50 dark:border-primary-800 dark:bg-primary-950 rounded-lg border p-4"
>
<h3 class="text-primary-800 dark:text-primary-200 mb-3 text-sm font-semibold">
Neue Edition für "{data.market.series_name || data.market.name}"
</h3>
<form
method="POST"
action="?/createEdition"
class="flex flex-wrap items-end gap-3"
use:enhance={() => {
loading = true;
return async ({ update }) => {
loading = false;
await update();
};
}}
>
<input type="hidden" name="series_id" value={data.market.series_id} />
<div class="space-y-1">
<label
for="new_start_date"
class="block text-xs font-medium text-stone-700 dark:text-stone-300"
>
Startdatum
</label>
<input
id="new_start_date"
type="date"
name="start_date"
required
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 rounded-lg border border-stone-300 px-3 py-1.5
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="new_end_date"
class="block text-xs font-medium text-stone-700 dark:text-stone-300"
>
Enddatum
</label>
<input
id="new_end_date"
type="date"
name="end_date"
required
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 rounded-lg border border-stone-300 px-3 py-1.5
text-sm shadow-sm focus:ring-2 focus:outline-none
dark:border-stone-600 dark:bg-stone-800"
/>
</div>
<Button type="submit" size="sm" {loading}>Anlegen</Button>
<button
type="button"
class="text-sm text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200"
onclick={() => (showNewEdition = false)}
>
Abbrechen
</button>
</form>
</div>
{/if}
{#if form?.error}
<Alert variant="error">{form.error}</Alert>
{/if}
{#if form?.success}
<Alert variant="success">
Status erfolgreich auf "{form.action === 'approved' ? 'Genehmigt' : 'Abgelehnt'}" geändert.
Status erfolgreich auf "{form.action === 'approved' ? 'Bestätigt' : 'Abgesagt'}" geändert.
</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">
@@ -96,7 +244,7 @@
</span>
</div>
{#if data.market.status === 'pending'}
{#if isReviewable}
<div class="mt-4 border-t border-stone-200 pt-4 dark:border-stone-700">
<form
method="POST"
@@ -173,7 +321,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 +362,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

@@ -1,12 +1,18 @@
import type { PageServerLoad } from './$types.js';
import { apiFetch, ApiClientError } from '$lib/api/client.js';
import type { MarketDetail } from '$lib/api/types.js';
import type { MarketDetail, EditionBrief } from '$lib/api/types.js';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params, fetch }) => {
export const load: PageServerLoad = async ({ params, url, fetch }) => {
const year = url.searchParams.get('year') ?? '';
const query = year ? `?year=${year}` : '';
try {
const res = await apiFetch<MarketDetail>(`/markets/${params.slug}`, { fetch });
return { market: res.data };
const res = await apiFetch<MarketDetail>(`/markets/${params.slug}${query}`, { fetch });
return {
market: res.data,
editions: (res as unknown as { editions?: EditionBrief[] }).editions ?? []
};
} catch (e) {
if (e instanceof ApiClientError && e.status === 404) {
error(404, { message: 'Markt nicht gefunden.' });

View File

@@ -1,10 +1,18 @@
<script lang="ts">
import MarketMap from '$lib/components/market/MarketMap.svelte';
import type { MarketDetail, OpeningHoursEntry, AdmissionInfo } from '$lib/api/types.js';
import type {
MarketDetail,
OpeningHoursEntry,
AdmissionInfo,
EditionBrief
} from '$lib/api/types.js';
import { stateToSlug, toSlug } from '$lib/utils/slug.js';
let { data } = $props();
const market: MarketDetail = $derived(data.market);
const editions: EditionBrief[] = $derived(data.editions);
const currentYear = $derived(new Date(market.start_date).getFullYear());
const hasMultipleEditions = $derived(editions.length > 1);
const stateSlug = $derived(stateToSlug(market.state));
const citySlug = $derived(toSlug(market.city));
@@ -281,6 +289,26 @@
</span>
</div>
{#if hasMultipleEditions}
<div class="mt-4 flex items-center gap-2">
<span class="text-sm text-stone-500 dark:text-stone-400">Ausgabe:</span>
<div class="flex gap-1">
{#each editions as edition}
{@const isActive = edition.year === currentYear}
<a
href="/markt/{market.slug}{isActive ? '' : `?year=${edition.year}`}"
class="rounded-md px-2.5 py-1 text-sm font-medium transition-colors
{isActive
? 'bg-primary-600 dark:bg-primary-500 text-white'
: 'bg-stone-100 text-stone-600 hover:bg-stone-200 dark:bg-stone-800 dark:text-stone-400 dark:hover:bg-stone-700'}"
>
{edition.year}
</a>
{/each}
</div>
</div>
{/if}
{#if market.organizer_name}
<p class="mt-2 text-sm text-stone-500 dark:text-stone-400">
Veranstalter: {market.organizer_name}

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>