hellth-hub/apps/admin/app/(authed)/profil/[handle]/page.tsx
Sebastian Mayer af66d49f2c Feature: Fremdprofil-Seite /profil/[handle] + klickbare Usernames im Leaderboard
UI-Teil zum Fremdprofil-Backend (2dfc770).

apps/admin/app/(authed)/profil/[handle]/page.tsx:
- Oeffentliche Profilseite: Avatar/Initiale, Handle, Bio, Punkte (gesamt +
  30 Tage) und aktive Tage (7-Tage-Streak).
- Sauberes NOT_FOUND-Handling fuer private/unbekannte Profile.
- Hinweis, dass Gesundheitsdaten immer privat bleiben.

dashboard.ts (clubLeaderboard):
- Liefert pro Eintrag einen handleSlug, aber NUR fuer profileVisible=true
  (sonst null -> kein toter Link).

hellfireclub/page.tsx:
- Leaderboard-Namen werden zu Links auf /profil/[handleSlug], wenn das
  Profil oeffentlich ist; sonst unveraenderter Text.

Verifiziert: Typecheck 3/3, 75 API-Tests + 10 Admin-Tests gruen.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:58:45 +02:00

113 lines
3.7 KiB
TypeScript

"use client";
import { FireIcon, SparklesIcon } from "@heroicons/react/24/outline";
import { useParams } from "next/navigation";
import { DataPageLayout } from "@/app/_components/data-page-layout";
import { trpc } from "@/lib/trpc";
export default function PublicProfilePage() {
const params = useParams<{ handle: string }>();
const handleSlug = decodeURIComponent(params.handle ?? "");
const profileQuery = trpc.users.publicProfileByHandle.useQuery(
{ handle: handleSlug },
{ retry: false },
);
if (profileQuery.isLoading) {
return (
<DataPageLayout title="Profil">
<p className="text-sm text-gray-500">Profil wird geladen …</p>
</DataPageLayout>
);
}
if (profileQuery.isError || !profileQuery.data) {
return (
<DataPageLayout title="Profil nicht gefunden">
<div className="rounded-xl border border-gray-200 bg-white p-6 text-sm text-gray-600">
Dieses Profil gibt es nicht oder es ist privat. Nur Mitglieder, die ihr
Profil öffentlich geschaltet haben, sind hier sichtbar.
</div>
</DataPageLayout>
);
}
const profile = profileQuery.data;
const initial = (profile.username ?? "?").charAt(0).toUpperCase();
return (
<DataPageLayout title={profile.handle ?? profile.username ?? "Profil"}>
<div className="space-y-4">
<div className="flex items-center gap-4 rounded-xl border border-gray-200 bg-white p-5">
{profile.image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={profile.image}
alt={profile.username ?? "Avatar"}
className="h-16 w-16 rounded-full object-cover"
/>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-[#0F766E] text-xl font-bold text-white">
{initial}
</div>
)}
<div className="min-w-0">
<p className="truncate text-lg font-semibold text-gray-900">
{profile.username}
<span className="font-normal text-gray-400">#{profile.discriminator}</span>
</p>
{profile.bio ? (
<p className="mt-1 whitespace-pre-line text-sm text-gray-600">{profile.bio}</p>
) : (
<p className="mt-1 text-sm text-gray-400">Keine Bio hinterlegt.</p>
)}
</div>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
icon={<FireIcon className="h-5 w-5 text-[#0F766E]" />}
label="Punkte gesamt"
value={profile.points.total.toLocaleString("de-DE")}
/>
<StatCard
icon={<FireIcon className="h-5 w-5 text-[#0F766E]" />}
label="Punkte (30 Tage)"
value={profile.points.last30Days.toLocaleString("de-DE")}
/>
<StatCard
icon={<SparklesIcon className="h-5 w-5 text-[#0F766E]" />}
label="Aktive Tage (7 Tage)"
value={`${profile.activeDaysLast7}/7`}
/>
</div>
<p className="px-1 text-xs text-gray-400">
Gesundheitsdaten wie Gewicht, Kalorien und Ziele sind immer privat und
werden hier nie angezeigt.
</p>
</div>
</DataPageLayout>
);
}
function StatCard({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="rounded-xl border border-gray-200 bg-white p-4">
<div className="flex items-center gap-2 text-xs font-medium text-gray-500">
{icon}
{label}
</div>
<p className="mt-2 text-2xl font-bold text-gray-900">{value}</p>
</div>
);
}