Feature: Community-Profil-Card im Einstellungen-Profil-Tab

Erganzt den Profil-Tab um eine Community-Profil-Karte (UI-Teil zum
Backend-Fundament 634e67c):
- Benutzername-Feld mit Hinweis auf den vergebenen Handle (Username#XXXX).
- Bio-Textarea (max 280 Zeichen) mit Zeichenzaehler.
- Sichtbarkeits-Checkbox (Privacy-by-Default: aus) mit Datenschutzhinweis,
  dass Gewicht, Kalorien und Ziele immer privat bleiben.
- Eigener Submit, der setMyHandle (nur bei eingegebenem Username) und
  updateMyCommunityProfile aufruft und myProfile invalidiert.

Typecheck gruen (db/api/admin), Admin-Tests 10/10.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sebastian Mayer 2026-05-30 11:59:59 +02:00
parent 634e67c602
commit 9ab6db9c0e

View file

@ -139,6 +139,10 @@ export default function EinstellungenPage() {
const [postalCode, setPostalCode] = useState(""); const [postalCode, setPostalCode] = useState("");
const [city, setCity] = useState(""); const [city, setCity] = useState("");
const [image, setImage] = useState<string | null>(null); const [image, setImage] = useState<string | null>(null);
// Community-Profil (Handle, Bio, Sichtbarkeit)
const [username, setUsername] = useState("");
const [bio, setBio] = useState("");
const [profileVisible, setProfileVisible] = useState(false);
const [dietPreferences, setDietPreferences] = useState<DietPreference[]>([ const [dietPreferences, setDietPreferences] = useState<DietPreference[]>([
"vegan", "vegan",
"vegetarian", "vegetarian",
@ -173,6 +177,9 @@ export default function EinstellungenPage() {
setPostalCode(profileQuery.data.postalCode ?? ""); setPostalCode(profileQuery.data.postalCode ?? "");
setCity(profileQuery.data.city ?? ""); setCity(profileQuery.data.city ?? "");
setImage(profileQuery.data.image ?? null); setImage(profileQuery.data.image ?? null);
setUsername(profileQuery.data.username ?? "");
setBio(profileQuery.data.bio ?? "");
setProfileVisible(profileQuery.data.profileVisible ?? false);
} }
}, [profileQuery.data]); }, [profileQuery.data]);
@ -191,6 +198,34 @@ export default function EinstellungenPage() {
}, },
}); });
const setMyHandle = trpc.users.setMyHandle.useMutation();
const updateMyCommunityProfile = trpc.users.updateMyCommunityProfile.useMutation();
const communityPending = setMyHandle.isPending || updateMyCommunityProfile.isPending;
const onSubmitCommunity = async (event: React.FormEvent) => {
event.preventDefault();
setStatusMessage(null);
setErrorMessage(null);
try {
// Handle nur setzen, wenn ein Username eingegeben wurde. Den Discriminator
// vergibt der Server stabil bei der Erst-Vergabe.
const trimmedUsername = username.trim();
if (trimmedUsername) {
await setMyHandle.mutateAsync({ username: trimmedUsername });
}
await updateMyCommunityProfile.mutateAsync({
bio: bio.trim() || null,
profileVisible,
});
await utils.users.myProfile.invalidate();
setStatusMessage("Community-Profil gespeichert.");
} catch (error) {
setErrorMessage(
error instanceof Error ? error.message : "Speichern fehlgeschlagen.",
);
}
};
const changeMyPassword = trpc.users.changeMyPassword.useMutation({ const changeMyPassword = trpc.users.changeMyPassword.useMutation({
onSuccess: () => { onSuccess: () => {
setCurrentPassword(""); setCurrentPassword("");
@ -562,6 +597,75 @@ export default function EinstellungenPage() {
</SectionCard> </SectionCard>
) : null} ) : null}
{active === "profil" ? (
<form onSubmit={onSubmitCommunity}>
<SectionCard
title="Community-Profil"
description="Dein öffentlicher Auftritt. Gewicht, Kalorien und Ziele bleiben immer privat."
>
<div className="space-y-4">
<div>
<label className={labelClass} htmlFor="community-username">
Benutzername
</label>
<input
id="community-username"
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="z. B. KochHeld"
className={inputClass}
/>
<p className="mt-1.5 text-xs text-gray-500">
{profileQuery.data?.handle
? `Dein Handle: ${profileQuery.data.handle}`
: "Buchstaben, Ziffern, _ und -. Eine vierstellige Kennung (#XXXX) wird automatisch und dauerhaft vergeben."}
</p>
</div>
<div>
<label className={labelClass} htmlFor="community-bio">
Über mich
</label>
<textarea
id="community-bio"
value={bio}
maxLength={280}
rows={3}
onChange={(event) => setBio(event.target.value)}
placeholder="Kurz über dich – optional."
className={inputClass}
/>
<p className="mt-1.5 text-right text-xs text-gray-500">
{bio.length}/280
</p>
</div>
<label className="flex items-start gap-3 rounded-lg bg-gray-50 p-3 text-sm">
<input
type="checkbox"
checked={profileVisible}
onChange={(event) => setProfileVisible(event.target.checked)}
className="mt-0.5 h-4 w-4 shrink-0"
/>
<span className="text-gray-700">
<span className="font-semibold">Profil öffentlich sichtbar machen</span>
<span className="mt-0.5 block text-gray-500">
Andere können dann deinen Benutzernamen und deine Bio sehen.
Gewicht, Kalorien und Ziele bleiben immer privat.
</span>
</span>
</label>
<div className="flex justify-end">
<button type="submit" disabled={communityPending} className={buttonPrimary}>
{communityPending ? "Speichert..." : "Community-Profil speichern"}
</button>
</div>
</div>
</SectionCard>
</form>
) : null}
{active === "ernaehrung" ? ( {active === "ernaehrung" ? (
<SectionCard <SectionCard
title="Rezept- und Mahlzeiten-Präferenzen" title="Rezept- und Mahlzeiten-Präferenzen"