534 lines
18 KiB
Svelte
534 lines
18 KiB
Svelte
<script lang="ts">
|
||
import type {
|
||
TournamentFieldType,
|
||
TournamentInfo,
|
||
TournamentSignupConfig,
|
||
TournamentSignupField
|
||
} from '$lib/types';
|
||
|
||
const props = $props<{ data: { tournament: TournamentInfo } }>();
|
||
const tournament = props.data.tournament;
|
||
|
||
function pickMode(value: string | null | undefined) {
|
||
return value === 'team' ? 'team' : 'solo';
|
||
}
|
||
|
||
function sanitizeField(field: TournamentSignupField): TournamentSignupField {
|
||
return {
|
||
id: field.id,
|
||
label: field.label,
|
||
field_type: field.field_type ?? 'text',
|
||
required: Boolean(field.required),
|
||
placeholder: field.placeholder ?? null,
|
||
unique: Boolean(field.unique)
|
||
};
|
||
}
|
||
|
||
function normalizeSignupConfig(config: TournamentSignupConfig | null | undefined): TournamentSignupConfig {
|
||
if (!config) {
|
||
return {
|
||
mode: 'solo',
|
||
team_size: { min: 1, max: 1 },
|
||
entry_fields: [],
|
||
participant_fields: []
|
||
};
|
||
}
|
||
|
||
const mode = pickMode(config.mode);
|
||
let min = Math.max(1, Math.floor(config.team_size?.min ?? 1));
|
||
let max = Math.max(1, Math.floor(config.team_size?.max ?? 1));
|
||
if (max < min) max = min;
|
||
if (mode === 'solo') {
|
||
min = 1;
|
||
max = 1;
|
||
}
|
||
|
||
return {
|
||
mode,
|
||
team_size: { min, max },
|
||
entry_fields: (config.entry_fields ?? []).map(sanitizeField),
|
||
participant_fields: (config.participant_fields ?? []).map(sanitizeField)
|
||
};
|
||
}
|
||
|
||
type FieldValueMap = Record<string, string>;
|
||
|
||
const signupConfig = normalizeSignupConfig(tournament.signup_config);
|
||
|
||
function formatDateTime(value: string | null) {
|
||
if (!value) return null;
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return null;
|
||
return date.toLocaleString('sv-SE', {
|
||
year: 'numeric',
|
||
month: 'long',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
hour12: false
|
||
});
|
||
}
|
||
|
||
const formattedStart = formatDateTime(tournament.start_at);
|
||
|
||
function createFieldMap(fields: TournamentSignupField[]): FieldValueMap {
|
||
const map: FieldValueMap = {};
|
||
for (const field of fields) {
|
||
map[field.id] = '';
|
||
}
|
||
return map;
|
||
}
|
||
|
||
function participantDisplayName(index: number) {
|
||
return signupConfig.mode === 'team' ? `Spelare ${index + 1}` : 'Spelare';
|
||
}
|
||
|
||
function fieldInputType(field: TournamentFieldType) {
|
||
switch (field) {
|
||
case 'email':
|
||
return 'email';
|
||
case 'tel':
|
||
return 'tel';
|
||
default:
|
||
return 'text';
|
||
}
|
||
}
|
||
|
||
const minParticipants = signupConfig.mode === 'team' ? signupConfig.team_size.min : 1;
|
||
const maxParticipants = signupConfig.mode === 'team' ? signupConfig.team_size.max : 1;
|
||
|
||
let signup = $state({
|
||
entry: createFieldMap(signupConfig.entry_fields),
|
||
participants: [] as FieldValueMap[],
|
||
submitting: false,
|
||
success: '',
|
||
error: '',
|
||
successRegistrationId: null as number | null,
|
||
submittedEntry: {} as Record<string, string>,
|
||
submittedParticipants: [] as Record<string, string>[],
|
||
showSuccessModal: false
|
||
});
|
||
|
||
|
||
|
||
function initializeParticipants() {
|
||
const initialCount = Math.max(1, signupConfig.mode === 'team' ? signupConfig.team_size.min : 1);
|
||
const list: FieldValueMap[] = [];
|
||
for (let i = 0; i < initialCount; i += 1) {
|
||
list.push(createFieldMap(signupConfig.participant_fields));
|
||
}
|
||
signup.participants = list;
|
||
}
|
||
|
||
initializeParticipants();
|
||
|
||
function canAddParticipant() {
|
||
return signupConfig.mode === 'team' && signup.participants.length < maxParticipants;
|
||
}
|
||
|
||
function canRemoveParticipant() {
|
||
return signupConfig.mode === 'team' && signup.participants.length > minParticipants;
|
||
}
|
||
|
||
function addParticipant() {
|
||
if (!canAddParticipant()) return;
|
||
signup.participants = [...signup.participants, createFieldMap(signupConfig.participant_fields)];
|
||
}
|
||
|
||
function removeParticipant(index: number) {
|
||
if (!canRemoveParticipant()) return;
|
||
signup.participants = signup.participants.filter((_, idx) => idx !== index);
|
||
}
|
||
|
||
function resetSignupForm() {
|
||
signup.entry = createFieldMap(signupConfig.entry_fields);
|
||
signup.participants = [];
|
||
initializeParticipants();
|
||
signup.success = '';
|
||
signup.error = '';
|
||
}
|
||
|
||
type SignupPayload = {
|
||
entry: Record<string, string>;
|
||
participants: Record<string, string>[];
|
||
};
|
||
|
||
function buildSignupPayload(): SignupPayload {
|
||
const entry: Record<string, string> = {};
|
||
for (const field of signupConfig.entry_fields) {
|
||
entry[field.id] = (signup.entry[field.id] ?? '').trim();
|
||
}
|
||
|
||
const participants = signup.participants.map((participant) => {
|
||
const map: Record<string, string> = {};
|
||
for (const field of signupConfig.participant_fields) {
|
||
map[field.id] = (participant[field.id] ?? '').trim();
|
||
}
|
||
return map;
|
||
});
|
||
|
||
return { entry, participants };
|
||
}
|
||
|
||
async function handleSignupSubmit(event: SubmitEvent) {
|
||
event.preventDefault();
|
||
signup.error = '';
|
||
signup.success = '';
|
||
|
||
if (signup.participants.length === 0) {
|
||
signup.error = 'Lägg till minst en spelare.';
|
||
return;
|
||
}
|
||
|
||
if (signupConfig.mode === 'team') {
|
||
if (signup.participants.length < minParticipants) {
|
||
signup.error = `Lägg till minst ${minParticipants} spelare.`;
|
||
return;
|
||
}
|
||
}
|
||
|
||
signup.submitting = true;
|
||
try {
|
||
const payload = buildSignupPayload();
|
||
const response = await fetch(`/api/tournament/slug/${tournament.slug}/signup`, {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const text = await response.text();
|
||
if (!response.ok) {
|
||
let message = 'Kunde inte skicka anmälan.';
|
||
try {
|
||
const body = JSON.parse(text);
|
||
message = body.message ?? message;
|
||
} catch {
|
||
if (text) message = text;
|
||
}
|
||
throw new Error(message);
|
||
}
|
||
|
||
let registrationId: number | null = null;
|
||
try {
|
||
const parsed = JSON.parse(text) as { registration_id?: number | string };
|
||
const numeric = Number(parsed?.registration_id);
|
||
if (Number.isFinite(numeric) && numeric > 0) {
|
||
registrationId = numeric;
|
||
}
|
||
} catch {
|
||
// ignored; we handle below
|
||
}
|
||
|
||
if (!registrationId) {
|
||
throw new Error('Kunde inte läsa svar från servern.');
|
||
}
|
||
|
||
const entrySummary = Object.fromEntries(Object.entries(payload.entry));
|
||
const participantSummary = payload.participants.map((values) => ({ ...values }));
|
||
|
||
resetSignupForm();
|
||
signup.successRegistrationId = registrationId;
|
||
signup.submittedEntry = entrySummary;
|
||
signup.submittedParticipants = participantSummary;
|
||
signup.success = 'Tack! Din anmälan har skickats.';
|
||
signup.showSuccessModal = true;
|
||
} catch (err) {
|
||
console.error('Failed to submit signup', err);
|
||
signup.error = err instanceof Error ? err.message : 'Ett oväntat fel inträffade.';
|
||
} finally {
|
||
signup.submitting = false;
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>{tournament.title} – VBytes LAN</title>
|
||
</svelte:head>
|
||
|
||
<div class="min-h-screen bg-slate-950 text-slate-100">
|
||
<div class="mx-auto flex min-h-screen max-w-4xl flex-col gap-8 px-4 py-12">
|
||
<nav class="flex items-center justify-between text-sm text-slate-400">
|
||
<a href="/tournament" class="inline-flex items-center gap-2 transition hover:text-indigo-300">
|
||
<span aria-hidden="true">←</span>
|
||
<span>Tillbaka till turneringsöversikten</span>
|
||
</a>
|
||
<span class="uppercase tracking-[0.4em] text-indigo-300">{tournament.game}</span>
|
||
</nav>
|
||
|
||
<header class="space-y-4 rounded-2xl bg-slate-900/70 p-6 shadow-lg">
|
||
<div class="space-y-2">
|
||
<p class="text-xs uppercase tracking-[0.4em] text-indigo-200">VBytes LAN</p>
|
||
<h1 class="text-3xl font-bold sm:text-4xl">{tournament.title}</h1>
|
||
{#if tournament.tagline}
|
||
<p class="text-base text-slate-300">{tournament.tagline}</p>
|
||
{/if}
|
||
</div>
|
||
<div class="grid gap-3 sm:grid-cols-2">
|
||
{#if formattedStart}
|
||
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
|
||
<p class="text-xs uppercase tracking-wide text-indigo-200">Start</p>
|
||
<p class="text-sm text-slate-100">{formattedStart}</p>
|
||
</div>
|
||
{/if}
|
||
{#if tournament.location}
|
||
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3">
|
||
<p class="text-xs uppercase tracking-wide text-indigo-200">Plats</p>
|
||
<p class="text-sm text-slate-100">{tournament.location}</p>
|
||
</div>
|
||
{/if}
|
||
{#if tournament.contact}
|
||
<div class="rounded-lg border border-slate-800 bg-slate-900/40 p-3 sm:col-span-2">
|
||
<p class="text-xs uppercase tracking-wide text-indigo-200">Kontakt</p>
|
||
<p class="text-sm text-slate-100">{tournament.contact}</p>
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
</header>
|
||
|
||
{#if tournament.description}
|
||
<section class="space-y-3 rounded-2xl border border-slate-800 bg-slate-900/50 p-6">
|
||
<h2 class="text-lg font-semibold text-slate-100">Beskrivning</h2>
|
||
<p class="whitespace-pre-line text-sm leading-relaxed text-slate-200">{tournament.description}</p>
|
||
</section>
|
||
{/if}
|
||
|
||
{#if tournament.sections.length > 0}
|
||
<section class="space-y-4">
|
||
{#each tournament.sections as section, index (section.title + index)}
|
||
<article class="space-y-2 rounded-2xl border border-slate-800 bg-slate-900/50 p-6">
|
||
<h3 class="text-base font-semibold text-indigo-200">{section.title}</h3>
|
||
<p class="whitespace-pre-line text-sm leading-relaxed text-slate-200">{section.body}</p>
|
||
</article>
|
||
{/each}
|
||
</section>
|
||
{/if}
|
||
|
||
<section class="space-y-5 rounded-2xl border border-slate-800 bg-slate-900/50 p-6">
|
||
<header class="space-y-1">
|
||
<h2 class="text-lg font-semibold text-slate-100">Anmälan</h2>
|
||
{#if signupConfig.mode === 'team'}
|
||
<p class="text-sm text-slate-300">Lagstorlek: {signupConfig.team_size.min}–{signupConfig.team_size.max} spelare.</p>
|
||
{:else}
|
||
<p class="text-sm text-slate-300">Individuell anmälan.</p>
|
||
{/if}
|
||
</header>
|
||
|
||
<form class="space-y-5" onsubmit={handleSignupSubmit}>
|
||
{#if signupConfig.entry_fields.length > 0}
|
||
<div class="space-y-3">
|
||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">Lag / deltagare</h3>
|
||
<div class="grid gap-3 md:grid-cols-2">
|
||
{#each signupConfig.entry_fields as field}
|
||
<label class="flex flex-col gap-1 text-sm font-medium text-slate-200">
|
||
<span>{field.label}</span>
|
||
<input
|
||
type={fieldInputType(field.field_type)}
|
||
required={field.required}
|
||
placeholder={field.placeholder ?? ''}
|
||
value={signup.entry[field.id]}
|
||
oninput={(event) => (signup.entry[field.id] = (event.currentTarget as HTMLInputElement).value)}
|
||
class="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-500/40"
|
||
/>
|
||
</label>
|
||
{/each}
|
||
</div>
|
||
</div>
|
||
|
||
{#if signup.showSuccessModal}
|
||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/80 px-4">
|
||
<div
|
||
class="w-full max-w-2xl space-y-6 rounded-2xl border border-slate-800 bg-slate-900 p-6 shadow-2xl"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="signup-success-title"
|
||
>
|
||
<header class="space-y-2 text-center">
|
||
<p class="text-xs uppercase tracking-[0.4em] text-indigo-300">VBytes LAN</p>
|
||
<h2 id="signup-success-title" class="text-2xl font-semibold text-slate-100 sm:text-3xl">
|
||
Anmälan bekräftad
|
||
</h2>
|
||
<p class="text-sm text-slate-300">Du är registrerad till {tournament.title}.</p>
|
||
{#if signup.successRegistrationId}
|
||
<p class="text-xs uppercase tracking-wide text-indigo-200">Anmälan #{signup.successRegistrationId}</p>
|
||
{/if}
|
||
</header>
|
||
|
||
<section class="grid gap-4 rounded-2xl border border-slate-800 bg-slate-900/70 p-4 md:grid-cols-2">
|
||
<div class="space-y-2 text-left">
|
||
<h3 class="text-sm font-semibold uppercase tracking-wide text-indigo-200">Turnering</h3>
|
||
<p class="text-sm text-slate-300"><span class="font-medium text-slate-100">Spel:</span> {tournament.game}</p>
|
||
{#if tournament.start_at}
|
||
<p class="text-sm text-slate-300">
|
||
<span class="font-medium text-slate-100">Start:</span>
|
||
{formatDateTime(tournament.start_at) ?? tournament.start_at}
|
||
</p>
|
||
{/if}
|
||
{#if tournament.location}
|
||
<p class="text-sm text-slate-300">
|
||
<span class="font-medium text-slate-100">Plats:</span> {tournament.location}
|
||
</p>
|
||
{/if}
|
||
</div>
|
||
<div class="space-y-2 text-left">
|
||
<h3 class="text-sm font-semibold uppercase tracking-wide text-indigo-200">Format</h3>
|
||
{#if signupConfig.mode === 'team'}
|
||
<p class="text-sm text-slate-300">
|
||
Lag {signupConfig.team_size.min}–{signupConfig.team_size.max} spelare
|
||
</p>
|
||
{:else}
|
||
<p class="text-sm text-slate-300">Individuell anmälan</p>
|
||
{/if}
|
||
{#if tournament.contact}
|
||
<p class="text-sm text-slate-300">
|
||
<span class="font-medium text-slate-100">Kontakt:</span> {tournament.contact}
|
||
</p>
|
||
{/if}
|
||
</div>
|
||
</section>
|
||
|
||
<section class="space-y-3">
|
||
<h3 class="text-base font-semibold text-slate-100">Anmälningsuppgifter</h3>
|
||
{#if signupConfig.entry_fields.length === 0}
|
||
<p class="rounded-md border border-dashed border-slate-700 px-4 py-3 text-sm text-slate-300">
|
||
Den här turneringen kräver inga uppgifter utöver spelare.
|
||
</p>
|
||
{:else}
|
||
<div class="grid gap-3 md:grid-cols-2">
|
||
{#each signupConfig.entry_fields as field}
|
||
<div class="rounded-md border border-slate-800 bg-slate-900 px-4 py-3">
|
||
<p class="text-xs uppercase tracking-wide text-indigo-200">{field.label}</p>
|
||
<p class="mt-1 text-sm text-slate-100">{signup.submittedEntry[field.id] || '—'}</p>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
|
||
<section class="space-y-3">
|
||
<h3 class="text-base font-semibold text-slate-100">Spelare</h3>
|
||
{#if signupConfig.participant_fields.length === 0}
|
||
{#if signup.submittedParticipants.length === 0}
|
||
<p class="text-sm text-slate-300">Inga spelare angivna.</p>
|
||
{:else}
|
||
<p class="text-sm text-slate-300">Antal spelare: {signup.submittedParticipants.length}</p>
|
||
{/if}
|
||
{:else if signup.submittedParticipants.length === 0}
|
||
<p class="text-sm text-slate-300">Inga spelare angivna.</p>
|
||
{:else}
|
||
<div class="space-y-3">
|
||
{#each signup.submittedParticipants as participant, index}
|
||
<div class="rounded-md border border-slate-800 bg-slate-900 px-4 py-3">
|
||
<p class="text-xs font-semibold uppercase tracking-wide text-indigo-200">Spelare {index + 1}</p>
|
||
<ul class="mt-2 space-y-1 text-sm text-slate-100">
|
||
{#each signupConfig.participant_fields as field}
|
||
<li>
|
||
<span class="font-medium text-slate-300">{field.label}:</span>
|
||
<span class="ml-1">{participant[field.id] || '—'}</span>
|
||
</li>
|
||
{/each}
|
||
</ul>
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</section>
|
||
|
||
<div class="flex justify-center pt-2">
|
||
<a
|
||
href="/"
|
||
class="inline-flex items-center justify-center rounded-full border border-emerald-300 px-5 py-2 text-sm font-semibold text-emerald-200 transition hover:border-emerald-400 hover:bg-emerald-500/10"
|
||
>
|
||
Stäng
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
{/if}
|
||
|
||
<div class="space-y-3">
|
||
<div class="flex items-center justify-between">
|
||
<h3 class="text-sm font-semibold uppercase tracking-wide text-slate-400">Spelare</h3>
|
||
{#if signupConfig.mode === 'team'}
|
||
<button
|
||
type="button"
|
||
onclick={addParticipant}
|
||
disabled={!canAddParticipant() || signup.submitting}
|
||
class="rounded-full border border-indigo-300 px-3 py-1 text-xs font-semibold text-indigo-200 transition hover:border-indigo-400 hover:bg-indigo-500/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
Lägg till spelare
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if signup.participants.length > 0}
|
||
<div class="space-y-4">
|
||
{#each signup.participants as participant, index (index)}
|
||
<div class="space-y-3 rounded-md border border-slate-800 bg-slate-900/60 p-4">
|
||
<div class="flex items-center justify-between">
|
||
<span class="text-sm font-semibold text-slate-200">{participantDisplayName(index)}</span>
|
||
{#if signupConfig.mode === 'team' && canRemoveParticipant()}
|
||
<button
|
||
type="button"
|
||
onclick={() => removeParticipant(index)}
|
||
disabled={signup.submitting}
|
||
class="rounded-full border border-red-300 px-3 py-1 text-xs font-semibold text-red-200 transition hover:border-red-400 hover:bg-red-500/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
Ta bort
|
||
</button>
|
||
{/if}
|
||
</div>
|
||
|
||
{#if signupConfig.participant_fields.length > 0}
|
||
<div class="grid gap-3 md:grid-cols-2">
|
||
{#each signupConfig.participant_fields as field}
|
||
<label class="flex flex-col gap-1 text-sm font-medium text-slate-200">
|
||
<span>{field.label}</span>
|
||
<input
|
||
type={fieldInputType(field.field_type)}
|
||
required={field.required}
|
||
placeholder={field.placeholder ?? ''}
|
||
value={participant[field.id] ?? ''}
|
||
oninput={(event) => (participant[field.id] = (event.currentTarget as HTMLInputElement).value)}
|
||
class="rounded-md border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-indigo-500 focus:outline-none focus:ring focus:ring-indigo-500/40"
|
||
/>
|
||
</label>
|
||
{/each}
|
||
</div>
|
||
{:else}
|
||
<p class="text-xs text-slate-400">Inga spelarspecifika fält krävs.</p>
|
||
{/if}
|
||
</div>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
</div>
|
||
|
||
<div class="space-y-2 text-sm">
|
||
{#if signup.error}
|
||
<p class="rounded-md border border-red-400 bg-red-500/10 px-4 py-2 text-red-200">{signup.error}</p>
|
||
{:else if signup.success}
|
||
<p class="rounded-md border border-emerald-400 bg-emerald-500/10 px-4 py-2 text-emerald-200">{signup.success}</p>
|
||
{:else}
|
||
<p class="text-slate-400">Din anmälan skickas direkt till arrangören.</p>
|
||
{/if}
|
||
</div>
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={signup.submitting}
|
||
class="inline-flex items-center justify-center rounded-full bg-indigo-500 px-5 py-2 text-sm font-semibold text-white transition hover:bg-indigo-600 disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{signup.submitting ? 'Skickar…' : 'Skicka anmälan'}
|
||
</button>
|
||
</form>
|
||
</section>
|
||
|
||
<footer class="mt-auto flex items-center justify-between text-xs text-slate-500">
|
||
<p>Senast uppdaterad {formatDateTime(tournament.updated_at) ?? tournament.updated_at}</p>
|
||
<a href="/admin/tournament" class="rounded-full border border-indigo-300 px-4 py-2 font-semibold text-indigo-300 transition hover:border-indigo-400 hover:bg-indigo-50/5">
|
||
Administrera
|
||
</a>
|
||
</footer>
|
||
</div>
|
||
</div>
|