- settings.tsx: neuer Abschnitt 'Lyra (Legend)' — nur sichtbar wenn plan==='legend', UIMenu mit 3 Optionen (Standard / Stimme 1 / Stimme 2), chevron-forward Anchor. Optimistic Update via PATCH /api/profile/me/lyra-voice, Revert bei Error. - useMe.ts: lyraVoiceId im Me-Type — Hydration aus /api/auth/me beim App-Start. - de.json + en.json: settings.lyra_voice + lyra_voice_default/_1/_2. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
91 lines
2.5 KiB
TypeScript
91 lines
2.5 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { apiFetch } from '../lib/api';
|
|
|
|
export type Plan = 'free' | 'pro' | 'legend';
|
|
|
|
/**
|
|
* Single source of truth für den eingeloggten User. /api/auth/me joint
|
|
* `auth.users` mit `rebreak.profiles` server-side — wir bekommen alles in
|
|
* einem Request: plan, avatar, nickname, streak.
|
|
*
|
|
* Live-Update-Pattern (siehe RECOVERY_LOG): nach Profile-Edit (PATCH /api/auth/me)
|
|
* MUSS `invalidateMe()` aufgerufen werden — alle useMe-Konsumenten (AppHeader,
|
|
* PostCard, ComposeCard, etc.) re-fetchen automatisch via Listener-Subscribe.
|
|
*
|
|
* WICHTIG: nicht aus `supabase.auth.getUser().user_metadata` lesen — das
|
|
* sind nur die JWT-Claims vom Signup-Zeitpunkt, NICHT der aktuelle Profile-
|
|
* Stand (Avatar/Nickname/Plan werden via Profile-Edit-API geupdated, landen
|
|
* in der DB, NICHT zurück ins JWT-Claim).
|
|
*/
|
|
export type Me = {
|
|
id: string;
|
|
email: string;
|
|
username: string;
|
|
nickname: string | null;
|
|
avatar: string | null;
|
|
plan: Plan;
|
|
streak: number;
|
|
lyraVoiceId: string | null;
|
|
created_at?: string;
|
|
};
|
|
|
|
let cachedMe: Me | null = null;
|
|
const listeners = new Set<() => void>();
|
|
|
|
/**
|
|
* Lädt /api/auth/me neu und benachrichtigt ALLE useMe-Konsumenten in der App.
|
|
* Nach jedem PATCH /api/auth/me aufrufen — sonst sehen Konsumenten alten Cache.
|
|
*/
|
|
export function invalidateMe(): void {
|
|
cachedMe = null;
|
|
for (const cb of listeners) cb();
|
|
}
|
|
|
|
export function useMe(): { me: Me | null; loading: boolean; reload: () => void } {
|
|
const [me, setMe] = useState<Me | null>(cachedMe);
|
|
const [loading, setLoading] = useState(cachedMe === null);
|
|
const [version, setVersion] = useState(0);
|
|
|
|
// Auf globale Invalidierung lauschen (Avatar-/Nickname-Update aus Profile-Edit)
|
|
useEffect(() => {
|
|
const cb = () => setVersion((v) => v + 1);
|
|
listeners.add(cb);
|
|
return () => {
|
|
listeners.delete(cb);
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
(async () => {
|
|
// Falls cache schon frisch ist (von anderem Konsumenten gerade geladen): nutzen
|
|
if (cachedMe !== null) {
|
|
setMe(cachedMe);
|
|
setLoading(false);
|
|
return;
|
|
}
|
|
try {
|
|
const res = await apiFetch<Me>('/api/auth/me');
|
|
if (cancelled) return;
|
|
cachedMe = res;
|
|
setMe(res);
|
|
} catch (e) {
|
|
console.warn('[useMe] fetch failed:', e);
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [version]);
|
|
|
|
return {
|
|
me,
|
|
loading,
|
|
reload: () => {
|
|
invalidateMe();
|
|
},
|
|
};
|
|
}
|