478 lines
22 KiB
TypeScript
478 lines
22 KiB
TypeScript
|
|
"use client";
|
|||
|
|
|
|||
|
|
import {
|
|||
|
|
BoltIcon,
|
|||
|
|
CheckCircleIcon,
|
|||
|
|
FireIcon,
|
|||
|
|
GlobeAltIcon,
|
|||
|
|
MagnifyingGlassIcon,
|
|||
|
|
MapPinIcon,
|
|||
|
|
PlusIcon,
|
|||
|
|
TrophyIcon,
|
|||
|
|
UserGroupIcon,
|
|||
|
|
} from "@heroicons/react/24/outline";
|
|||
|
|
import { useMemo, useState } from "react";
|
|||
|
|
import {
|
|||
|
|
ClubActivityCategory,
|
|||
|
|
ClubScope,
|
|||
|
|
GermanState,
|
|||
|
|
clubCategories,
|
|||
|
|
defaultClubProfile,
|
|||
|
|
germanStates,
|
|||
|
|
todayKey,
|
|||
|
|
} from "@/lib/hellth-data";
|
|||
|
|
import { trpc } from "@/lib/trpc";
|
|||
|
|
|
|||
|
|
type ClubForm = {
|
|||
|
|
title: string;
|
|||
|
|
category: ClubActivityCategory;
|
|||
|
|
scope: ClubScope;
|
|||
|
|
date: string;
|
|||
|
|
time: string;
|
|||
|
|
durationMinutes: string;
|
|||
|
|
points: string;
|
|||
|
|
note: string;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
type ClubProfileView = {
|
|||
|
|
userId: string;
|
|||
|
|
state: string;
|
|||
|
|
city?: string | null;
|
|||
|
|
latitude?: number | null;
|
|||
|
|
longitude?: number | null;
|
|||
|
|
privacyMode: "city" | "state";
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const initialForm: ClubForm = {
|
|||
|
|
title: "",
|
|||
|
|
category: "walk",
|
|||
|
|
scope: "state",
|
|||
|
|
date: todayKey(),
|
|||
|
|
time: "18:00",
|
|||
|
|
durationMinutes: "60",
|
|||
|
|
points: "100",
|
|||
|
|
note: "",
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
function formatDateTime(value: string) {
|
|||
|
|
return new Date(value).toLocaleString("de-DE", {
|
|||
|
|
weekday: "short",
|
|||
|
|
day: "2-digit",
|
|||
|
|
month: "2-digit",
|
|||
|
|
hour: "2-digit",
|
|||
|
|
minute: "2-digit",
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function categoryLabel(category: string) {
|
|||
|
|
return clubCategories.find((item) => item.key === category)?.label ?? "Sonstiges";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function categoryStyle(category: string) {
|
|||
|
|
if (category === "walk") return "bg-emerald-50 text-emerald-700";
|
|||
|
|
if (category === "run") return "bg-orange-50 text-orange-700";
|
|||
|
|
if (category === "mobility") return "bg-blue-50 text-blue-700";
|
|||
|
|
if (category === "strength") return "bg-purple-50 text-purple-700";
|
|||
|
|
if (category === "bike") return "bg-cyan-50 text-cyan-700";
|
|||
|
|
return "bg-gray-100 text-gray-700";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function scopeLabel(scope: ClubScope) {
|
|||
|
|
if (scope === "local") return "Lokal";
|
|||
|
|
if (scope === "state") return "Bundesland";
|
|||
|
|
return "Deutschlandweit";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function distanceKm(
|
|||
|
|
profile: { latitude?: number | null; longitude?: number | null },
|
|||
|
|
activity: { latitude?: number | null; longitude?: number | null },
|
|||
|
|
) {
|
|||
|
|
if (
|
|||
|
|
profile.latitude === undefined ||
|
|||
|
|
profile.latitude === null ||
|
|||
|
|
profile.longitude === undefined ||
|
|||
|
|
profile.longitude === null ||
|
|||
|
|
activity.latitude === undefined ||
|
|||
|
|
activity.latitude === null ||
|
|||
|
|
activity.longitude === undefined ||
|
|||
|
|
activity.longitude === null
|
|||
|
|
) {
|
|||
|
|
return undefined;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const toRad = (value: number) => (value * Math.PI) / 180;
|
|||
|
|
const earthKm = 6371;
|
|||
|
|
const deltaLat = toRad(activity.latitude - profile.latitude);
|
|||
|
|
const deltaLon = toRad(activity.longitude - profile.longitude);
|
|||
|
|
const lat1 = toRad(profile.latitude);
|
|||
|
|
const lat2 = toRad(activity.latitude);
|
|||
|
|
const haversine =
|
|||
|
|
Math.sin(deltaLat / 2) ** 2 +
|
|||
|
|
Math.cos(lat1) * Math.cos(lat2) * Math.sin(deltaLon / 2) ** 2;
|
|||
|
|
return Math.round(earthKm * 2 * Math.atan2(Math.sqrt(haversine), Math.sqrt(1 - haversine)));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function locationLabel(profile: { state: string; city?: string | null; privacyMode: string }, scope: ClubScope) {
|
|||
|
|
if (scope === "nationwide") return "Deutschlandweit";
|
|||
|
|
if (scope === "state" || profile.privacyMode === "state") return profile.state;
|
|||
|
|
return profile.city?.trim() || profile.state;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function HellfireclubPage() {
|
|||
|
|
const utils = trpc.useUtils();
|
|||
|
|
const [query, setQuery] = useState("");
|
|||
|
|
const [category, setCategory] = useState<ClubActivityCategory | "all">("all");
|
|||
|
|
const [scope, setScope] = useState<ClubScope>("state");
|
|||
|
|
const [form, setForm] = useState<ClubForm>(initialForm);
|
|||
|
|
const [status, setStatus] = useState<string | null>(null);
|
|||
|
|
const [locationStatus, setLocationStatus] = useState<string | null>(null);
|
|||
|
|
|
|||
|
|
const profileQuery = trpc.health.clubProfile.useQuery();
|
|||
|
|
const activitiesQuery = trpc.health.clubActivities.useQuery({});
|
|||
|
|
const pointDefaultsQuery = trpc.health.pointDefaults.useQuery();
|
|||
|
|
const leaderboardQuery = trpc.health.clubLeaderboard.useQuery({ days: 90 });
|
|||
|
|
const upsertProfileMutation = trpc.health.upsertClubProfile.useMutation({
|
|||
|
|
onSuccess: () => utils.health.clubProfile.invalidate(),
|
|||
|
|
});
|
|||
|
|
const createActivityMutation = trpc.health.createClubActivity.useMutation({
|
|||
|
|
onSuccess: async () => {
|
|||
|
|
await utils.health.clubActivities.invalidate();
|
|||
|
|
setForm(initialForm);
|
|||
|
|
setStatus("Aktivität erstellt. Andere User können jetzt passend zu deinem Gebiet mitmachen.");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const joinActivityMutation = trpc.health.joinClubActivity.useMutation({
|
|||
|
|
onSuccess: async () => {
|
|||
|
|
await utils.health.clubActivities.invalidate();
|
|||
|
|
setStatus("Du bist dabei. Die Aktivität ist in deinem Hellfireclub vorgemerkt.");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
const completeActivityMutation = trpc.health.completeClubActivity.useMutation({
|
|||
|
|
onSuccess: async () => {
|
|||
|
|
await Promise.all([
|
|||
|
|
utils.health.clubActivities.invalidate(),
|
|||
|
|
utils.health.clubLeaderboard.invalidate(),
|
|||
|
|
utils.health.activityWeek.invalidate(),
|
|||
|
|
utils.health.overview.invalidate(),
|
|||
|
|
]);
|
|||
|
|
setStatus("Punkte gutgeschrieben und als Aktivität gespeichert.");
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
const rawProfile = profileQuery.data;
|
|||
|
|
const clubActivityDefaultPoints = pointDefaultsQuery.data?.clubActivityDefaultPoints ?? 100;
|
|||
|
|
const profile: ClubProfileView = {
|
|||
|
|
userId: rawProfile?.userId ?? "",
|
|||
|
|
state: rawProfile?.state ?? defaultClubProfile.state,
|
|||
|
|
city: rawProfile?.city ?? defaultClubProfile.city,
|
|||
|
|
latitude: rawProfile?.latitude == null ? null : Number(rawProfile.latitude),
|
|||
|
|
longitude: rawProfile?.longitude == null ? null : Number(rawProfile.longitude),
|
|||
|
|
privacyMode: (rawProfile?.privacyMode ?? defaultClubProfile.privacyMode) as "city" | "state",
|
|||
|
|
};
|
|||
|
|
const activities = activitiesQuery.data ?? [];
|
|||
|
|
|
|||
|
|
function updateProfile(patch: Partial<ClubProfileView>) {
|
|||
|
|
const next = { ...profile, ...patch };
|
|||
|
|
upsertProfileMutation.mutate({
|
|||
|
|
state: next.state,
|
|||
|
|
city: next.city ?? null,
|
|||
|
|
latitude: next.latitude == null ? null : Number(next.latitude),
|
|||
|
|
longitude: next.longitude == null ? null : Number(next.longitude),
|
|||
|
|
privacyMode: next.privacyMode as "city" | "state",
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function requestLocation() {
|
|||
|
|
if (!navigator.geolocation) {
|
|||
|
|
setLocationStatus("Dein Browser unterstützt keine Standortfreigabe.");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setLocationStatus("Standort wird abgefragt...");
|
|||
|
|
navigator.geolocation.getCurrentPosition(
|
|||
|
|
(position) => {
|
|||
|
|
updateProfile({
|
|||
|
|
latitude: position.coords.latitude,
|
|||
|
|
longitude: position.coords.longitude,
|
|||
|
|
});
|
|||
|
|
setLocationStatus("Standort gespeichert. Sichtbar bleibt nur Stadt oder Bundesland.");
|
|||
|
|
},
|
|||
|
|
() => setLocationStatus("Standort konnte nicht gelesen werden. Bundesland-Filter funktioniert weiterhin."),
|
|||
|
|
{ enableHighAccuracy: false, maximumAge: 1000 * 60 * 30, timeout: 8000 },
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const filteredActivities = useMemo(() => {
|
|||
|
|
const needle = query.trim().toLowerCase();
|
|||
|
|
return activities
|
|||
|
|
.filter((activity) => {
|
|||
|
|
const activityScope = (activity.scope ?? "nationwide") as ClubScope;
|
|||
|
|
if (scope === "nationwide" || activityScope === "nationwide") return true;
|
|||
|
|
if (scope === "state") return activity.state === profile.state;
|
|||
|
|
if (activity.state !== profile.state) return false;
|
|||
|
|
const distance = distanceKm(profile, activity);
|
|||
|
|
if (distance !== undefined) return distance <= 30;
|
|||
|
|
return Boolean(activity.city && profile.city && activity.city.toLowerCase() === profile.city.toLowerCase());
|
|||
|
|
})
|
|||
|
|
.filter((activity) => category === "all" || activity.category === category)
|
|||
|
|
.filter((activity) => {
|
|||
|
|
if (!needle) return true;
|
|||
|
|
return [activity.title, activity.hostName, activity.note, activity.locationLabel, activity.city, activity.state, categoryLabel(activity.category)]
|
|||
|
|
.filter(Boolean)
|
|||
|
|
.some((value) => value!.toLowerCase().includes(needle));
|
|||
|
|
})
|
|||
|
|
.sort((a, b) => {
|
|||
|
|
const distanceA = distanceKm(profile, a) ?? Number.MAX_SAFE_INTEGER;
|
|||
|
|
const distanceB = distanceKm(profile, b) ?? Number.MAX_SAFE_INTEGER;
|
|||
|
|
if (scope === "local" && distanceA !== distanceB) return distanceA - distanceB;
|
|||
|
|
return new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime();
|
|||
|
|
});
|
|||
|
|
}, [activities, category, profile, query, scope]);
|
|||
|
|
|
|||
|
|
const leaderboard = leaderboardQuery.data ?? [];
|
|||
|
|
|
|||
|
|
function createActivity() {
|
|||
|
|
if (!form.title.trim()) return;
|
|||
|
|
createActivityMutation.mutate({
|
|||
|
|
title: form.title.trim(),
|
|||
|
|
category: form.category,
|
|||
|
|
scope: form.scope,
|
|||
|
|
state: profile.state,
|
|||
|
|
city: profile.city ?? null,
|
|||
|
|
latitude: profile.latitude == null ? null : Number(profile.latitude),
|
|||
|
|
longitude: profile.longitude == null ? null : Number(profile.longitude),
|
|||
|
|
locationLabel: locationLabel(profile, form.scope),
|
|||
|
|
startsAt: `${form.date}T${form.time}:00.000Z`,
|
|||
|
|
durationMinutes: Number(form.durationMinutes) || 30,
|
|||
|
|
points: Number(form.points) || clubActivityDefaultPoints,
|
|||
|
|
note: form.note.trim() || null,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="mx-auto max-w-6xl space-y-5 pb-24">
|
|||
|
|
<header className="overflow-hidden rounded-[2rem] bg-gray-950 p-6 text-white shadow-sm">
|
|||
|
|
<div className="flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
|||
|
|
<div>
|
|||
|
|
<p className="text-sm font-black uppercase text-red-300">Hellfireclub</p>
|
|||
|
|
<h1 className="mt-2 text-4xl font-black tracking-normal">Gemeinsam getrennt aktiv</h1>
|
|||
|
|
<p className="mt-3 max-w-3xl text-sm font-semibold text-white/60">
|
|||
|
|
Lokale Challenges, Bundesland-Events und deutschlandweite Aktivitäten. Jetzt persistent über API und Datenbank.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="grid h-28 w-28 place-items-center rounded-full bg-red-500 text-white ring-[12px] ring-red-500/20">
|
|||
|
|
<FireIcon className="h-14 w-14" />
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<section className="grid gap-4 lg:grid-cols-[1fr_0.8fr]">
|
|||
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|||
|
|
<div className="flex flex-col gap-3 md:flex-row">
|
|||
|
|
<div className="flex min-w-0 flex-1 items-center gap-3 rounded-full bg-gray-100 px-4 py-3">
|
|||
|
|
<MagnifyingGlassIcon className="h-5 w-5 text-gray-400" />
|
|||
|
|
<input
|
|||
|
|
value={query}
|
|||
|
|
onChange={(event) => setQuery(event.target.value)}
|
|||
|
|
placeholder="Aktivität, Ort oder Kategorie suchen"
|
|||
|
|
className="min-w-0 flex-1 bg-transparent text-sm font-semibold outline-none"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<select
|
|||
|
|
value={category}
|
|||
|
|
onChange={(event) => setCategory(event.target.value as ClubActivityCategory | "all")}
|
|||
|
|
className="rounded-full border border-gray-200 bg-white px-4 py-3 text-sm font-black text-gray-950"
|
|||
|
|
>
|
|||
|
|
<option value="all">Alle Kategorien</option>
|
|||
|
|
{clubCategories.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}
|
|||
|
|
</select>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="mt-4 grid grid-cols-3 gap-2 rounded-full bg-gray-100 p-1">
|
|||
|
|
{(["local", "state", "nationwide"] as ClubScope[]).map((item) => (
|
|||
|
|
<button
|
|||
|
|
key={item}
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => setScope(item)}
|
|||
|
|
className={`rounded-full px-3 py-2 text-xs font-black transition ${scope === item ? "bg-gray-950 text-white" : "text-gray-500"}`}
|
|||
|
|
>
|
|||
|
|
{scopeLabel(item)}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="mt-5 space-y-3">
|
|||
|
|
{filteredActivities.map((activity) => {
|
|||
|
|
const participant = activity.participants.find((item) => item.userId === profile.userId);
|
|||
|
|
const completed = Boolean(participant?.completedAt);
|
|||
|
|
const distance = distanceKm(profile, activity);
|
|||
|
|
return (
|
|||
|
|
<article key={activity.id} className="rounded-[2rem] bg-gray-50 p-5">
|
|||
|
|
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|||
|
|
<div>
|
|||
|
|
<div className="flex flex-wrap items-center gap-2">
|
|||
|
|
<span className={`rounded-full px-3 py-1 text-xs font-black ${categoryStyle(activity.category)}`}>
|
|||
|
|
{categoryLabel(activity.category)}
|
|||
|
|
</span>
|
|||
|
|
<span className="rounded-full bg-white px-3 py-1 text-xs font-black text-gray-400">
|
|||
|
|
{activity.points} Punkte
|
|||
|
|
</span>
|
|||
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-white px-3 py-1 text-xs font-black text-gray-500">
|
|||
|
|
{activity.scope === "nationwide" ? <GlobeAltIcon className="h-4 w-4" /> : <MapPinIcon className="h-4 w-4" />}
|
|||
|
|
{activity.locationLabel ?? scopeLabel((activity.scope ?? "nationwide") as ClubScope)}
|
|||
|
|
{distance !== undefined ? ` · ${distance} km` : ""}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<h2 className="mt-3 text-2xl font-black text-gray-950">{activity.title}</h2>
|
|||
|
|
<p className="mt-1 text-sm font-bold text-gray-400">
|
|||
|
|
{activity.hostName} möchte am {formatDateTime(activity.startsAt)} für {activity.durationMinutes} Minuten los.
|
|||
|
|
</p>
|
|||
|
|
{activity.note ? <p className="mt-3 text-sm font-semibold text-gray-500">{activity.note}</p> : null}
|
|||
|
|
</div>
|
|||
|
|
<div className="flex min-w-44 flex-col gap-2">
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
disabled={Boolean(participant)}
|
|||
|
|
onClick={() => joinActivityMutation.mutate({ id: activity.id })}
|
|||
|
|
className="rounded-full bg-gray-950 px-4 py-3 text-sm font-black text-white disabled:bg-gray-200 disabled:text-gray-500"
|
|||
|
|
>
|
|||
|
|
{participant ? "Du bist dabei" : "Mitgehen"}
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
disabled={completed}
|
|||
|
|
onClick={() => completeActivityMutation.mutate({ id: activity.id })}
|
|||
|
|
className="rounded-full bg-emerald-600 px-4 py-3 text-sm font-black text-white disabled:bg-emerald-100 disabled:text-emerald-700"
|
|||
|
|
>
|
|||
|
|
{completed ? "Abgeschlossen" : "Abschließen"}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm font-bold text-gray-400">
|
|||
|
|
<UserGroupIcon className="h-5 w-5" />
|
|||
|
|
{activity.participants.length || 0} dabei
|
|||
|
|
{activity.participants.length > 0 ? <span className="text-gray-300">{activity.participants.map((item) => item.displayName).join(", ")}</span> : null}
|
|||
|
|
</div>
|
|||
|
|
</article>
|
|||
|
|
);
|
|||
|
|
})}
|
|||
|
|
{!filteredActivities.length ? <p className="rounded-3xl bg-gray-50 p-5 text-sm font-bold text-gray-400">Für diesen Filter gibt es noch keine Aktivitäten.</p> : null}
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<aside className="space-y-4">
|
|||
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<MapPinIcon className="h-7 w-7 text-red-500" />
|
|||
|
|
<h2 className="text-xl font-black text-gray-950">Club-Profil</h2>
|
|||
|
|
</div>
|
|||
|
|
<div className="mt-4 grid gap-3">
|
|||
|
|
<input
|
|||
|
|
value={profile.city ?? ""}
|
|||
|
|
onChange={(event) => updateProfile({ city: event.target.value })}
|
|||
|
|
placeholder="Stadt, z. B. Augsburg"
|
|||
|
|
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
|
|||
|
|
/>
|
|||
|
|
<select
|
|||
|
|
value={profile.state}
|
|||
|
|
onChange={(event) => updateProfile({ state: event.target.value as GermanState })}
|
|||
|
|
className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold"
|
|||
|
|
>
|
|||
|
|
{germanStates.map((state) => <option key={state} value={state}>{state}</option>)}
|
|||
|
|
</select>
|
|||
|
|
<div className="grid grid-cols-2 gap-2 rounded-2xl bg-gray-100 p-1">
|
|||
|
|
{(["city", "state"] as const).map((mode) => (
|
|||
|
|
<button
|
|||
|
|
key={mode}
|
|||
|
|
type="button"
|
|||
|
|
onClick={() => updateProfile({ privacyMode: mode })}
|
|||
|
|
className={`rounded-xl px-3 py-2 text-xs font-black ${profile.privacyMode === mode ? "bg-white text-gray-950 shadow-sm" : "text-gray-500"}`}
|
|||
|
|
>
|
|||
|
|
{mode === "city" ? "Stadt zeigen" : "Bundesland"}
|
|||
|
|
</button>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
<button type="button" onClick={requestLocation} className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-3 text-sm font-black text-white">
|
|||
|
|
<MapPinIcon className="h-5 w-5" />
|
|||
|
|
Standort verwenden
|
|||
|
|
</button>
|
|||
|
|
{locationStatus ? <p className="text-xs font-bold text-gray-500">{locationStatus}</p> : null}
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|||
|
|
<h2 className="text-xl font-black text-gray-950">Aktivität erstellen</h2>
|
|||
|
|
<div className="mt-4 grid gap-3">
|
|||
|
|
<input value={form.title} onChange={(event) => setForm((current) => ({ ...current, title: event.target.value }))} placeholder="z. B. Eine Stunde spazieren gehen" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
|||
|
|
<select value={form.category} onChange={(event) => setForm((current) => ({ ...current, category: event.target.value as ClubActivityCategory }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
|
|||
|
|
{clubCategories.map((item) => <option key={item.key} value={item.key}>{item.label}</option>)}
|
|||
|
|
</select>
|
|||
|
|
<select value={form.scope} onChange={(event) => setForm((current) => ({ ...current, scope: event.target.value as ClubScope }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold">
|
|||
|
|
<option value="local">Lokal in deiner Nähe</option>
|
|||
|
|
<option value="state">Im Bundesland</option>
|
|||
|
|
<option value="nationwide">Deutschlandweit</option>
|
|||
|
|
</select>
|
|||
|
|
<div className="grid grid-cols-2 gap-2">
|
|||
|
|
<input type="date" value={form.date} onChange={(event) => setForm((current) => ({ ...current, date: event.target.value }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
|||
|
|
<input type="time" value={form.time} onChange={(event) => setForm((current) => ({ ...current, time: event.target.value }))} className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
|||
|
|
<input value={form.durationMinutes} onChange={(event) => setForm((current) => ({ ...current, durationMinutes: event.target.value }))} placeholder="Minuten" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
|||
|
|
<input value={form.points} onChange={(event) => setForm((current) => ({ ...current, points: event.target.value }))} placeholder="Punkte" className="rounded-2xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
|||
|
|
</div>
|
|||
|
|
<textarea value={form.note} onChange={(event) => setForm((current) => ({ ...current, note: event.target.value }))} placeholder="Notiz, Treffpunkt oder Motivation" className="min-h-24 rounded-3xl border border-gray-200 px-4 py-3 text-sm font-semibold" />
|
|||
|
|
<button type="button" onClick={createActivity} className="inline-flex items-center justify-center gap-2 rounded-full bg-gray-950 px-4 py-4 text-sm font-black text-white">
|
|||
|
|
<PlusIcon className="h-5 w-5" />
|
|||
|
|
Einladen
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|||
|
|
<div className="flex items-center gap-3">
|
|||
|
|
<TrophyIcon className="h-8 w-8 text-amber-500" />
|
|||
|
|
<h2 className="text-xl font-black text-gray-950">Highscore</h2>
|
|||
|
|
</div>
|
|||
|
|
<div className="mt-4 space-y-2">
|
|||
|
|
{leaderboard.length ? leaderboard.map((entry, index) => (
|
|||
|
|
<div key={entry.userKey} className="flex items-center gap-3 rounded-2xl bg-gray-50 p-3">
|
|||
|
|
<div className="grid h-9 w-9 place-items-center rounded-full bg-gray-950 text-sm font-black text-white">{index + 1}</div>
|
|||
|
|
<div className="min-w-0 flex-1">
|
|||
|
|
<p className="truncate text-sm font-black text-gray-950">{entry.name}</p>
|
|||
|
|
<p className="text-xs font-bold text-gray-400">{entry.completed} Punkte-Einträge</p>
|
|||
|
|
</div>
|
|||
|
|
<div className="text-right">
|
|||
|
|
<p className="text-lg font-black text-gray-950">{entry.points}</p>
|
|||
|
|
<p className="text-xs font-bold text-gray-400">Punkte</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
)) : (
|
|||
|
|
<p className="rounded-2xl bg-gray-50 p-4 text-sm font-bold text-gray-400">Noch keine Punkte gesammelt.</p>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
{status ? (
|
|||
|
|
<div className="flex gap-2 rounded-3xl bg-emerald-50 p-4 text-sm font-bold text-emerald-800">
|
|||
|
|
<CheckCircleIcon className="h-5 w-5 shrink-0" />
|
|||
|
|
{status}
|
|||
|
|
</div>
|
|||
|
|
) : null}
|
|||
|
|
</aside>
|
|||
|
|
</section>
|
|||
|
|
|
|||
|
|
<section className="rounded-[2rem] bg-white p-5 shadow-sm ring-1 ring-gray-100">
|
|||
|
|
<div className="flex items-start gap-3">
|
|||
|
|
<BoltIcon className="h-7 w-7 text-red-500" />
|
|||
|
|
<div>
|
|||
|
|
<h2 className="text-lg font-black text-gray-950">Persistenter Club</h2>
|
|||
|
|
<p className="mt-1 text-sm font-semibold text-gray-400">
|
|||
|
|
Aktivitäten, Teilnahmen, Abschlüsse und Punkte werden jetzt in der Datenbank gespeichert.
|
|||
|
|
</p>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</section>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|