vbytes-lan-attendence/web/src/routes/(tournament)/tournament/[slug]/+page.svelte

534 lines
18 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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