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>
This commit is contained in:
Sebastian Mayer 2026-05-30 12:58:45 +02:00
parent 2dfc770950
commit af66d49f2c
3 changed files with 124 additions and 1 deletions

View file

@ -436,7 +436,16 @@ export default function HellfireclubPage() {
<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">
{entry.handleSlug ? (
<Link
href={`/profil/${entry.handleSlug}`}
className="truncate text-sm font-black text-[#0F766E] underline-offset-2 hover:underline"
>
{entry.name}
</Link>
) : (
<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">

View file

@ -0,0 +1,113 @@
"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>
);
}

View file

@ -1,6 +1,7 @@
import { schema } from "@hellth/db";
import { and, eq, isNull } from "drizzle-orm";
import { z } from "zod";
import { handleToSlug } from "../../../lib/handle";
import { platformProcedure, protectedProcedure, router } from "../../init";
import {
auditHealthMutation,