Merge branch 'feat/admin-market-enhancements'
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
186
web/src/lib/components/admin/ResearchPanel.svelte
Normal file
186
web/src/lib/components/admin/ResearchPanel.svelte
Normal 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>
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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 @@
|
||||
← 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}
|
||||
· 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">
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
← 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();
|
||||
};
|
||||
}}
|
||||
>
|
||||
← 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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
19
web/src/routes/api/geocode/+server.ts
Normal file
19
web/src/routes/api/geocode/+server.ts
Normal 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 });
|
||||
}
|
||||
};
|
||||
@@ -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.' });
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user