239 lines
8.6 KiB
TypeScript
239 lines
8.6 KiB
TypeScript
import { useState } from 'react';
|
|
import { View, Text, Pressable, Modal, Image } from 'react-native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { Ionicons } from '@expo/vector-icons';
|
|
import { useRouter, type RelativePathString } from 'expo-router';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useAuthStore } from '../stores/auth';
|
|
import { useNotificationStore } from '../stores/notifications';
|
|
import { supabase } from '../lib/supabase';
|
|
import { resolveAvatar } from '../lib/resolveAvatar';
|
|
import { useMe } from '../hooks/useMe';
|
|
import { NotificationsDropdown } from './NotificationsDropdown';
|
|
|
|
type Props = {
|
|
notifCount?: number;
|
|
};
|
|
|
|
type MenuItem = {
|
|
icon: React.ComponentProps<typeof Ionicons>['name'];
|
|
label: string;
|
|
color?: string;
|
|
action: () => void;
|
|
};
|
|
|
|
export function AppHeader({ notifCount }: Props = {}) {
|
|
const insets = useSafeAreaInsets();
|
|
const router = useRouter();
|
|
const { t } = useTranslation();
|
|
const { user } = useAuthStore();
|
|
const { me } = useMe();
|
|
const storeUnread = useNotificationStore((s) => s.unread);
|
|
const badge = notifCount ?? storeUnread;
|
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
const [notifOpen, setNotifOpen] = useState(false);
|
|
|
|
const firstName = (user?.user_metadata?.first_name as string | undefined) ?? '';
|
|
const lastName = (user?.user_metadata?.last_name as string | undefined) ?? '';
|
|
const email = user?.email ?? '';
|
|
// Initials-Fallback: erst nickname (DB), dann firstName/email
|
|
const initials = (() => {
|
|
if (me?.nickname) return me.nickname.slice(0, 2).toUpperCase();
|
|
return ((firstName.charAt(0) + (lastName.charAt(0) || email.charAt(0))).toUpperCase() || '?');
|
|
})();
|
|
|
|
// Avatar: aus DB (`/api/auth/me` → profiles.avatar). Kann Hero-Avatar-ID
|
|
// ("spider"/"hulk"/...) ODER Custom-Photo-URL (https://... von Foto-Upload)
|
|
// sein. resolveAvatar handlet beide Fälle.
|
|
// user_metadata.avatar_id ist veraltet — wird bei Profile-Edit nicht
|
|
// aktualisiert. DB ist Single Source of Truth.
|
|
const avatarUrl = me ? resolveAvatar(me.avatar, me.nickname ?? '') : '';
|
|
const [avatarLoadFailed, setAvatarLoadFailed] = useState(false);
|
|
const showAvatarImage = !!avatarUrl && !avatarLoadFailed && !!me?.avatar;
|
|
|
|
function closeAndNavigate(path: RelativePathString) {
|
|
setDropdownOpen(false);
|
|
router.push(path);
|
|
}
|
|
|
|
async function handleSignOut() {
|
|
setDropdownOpen(false);
|
|
await supabase.auth.signOut();
|
|
router.replace('/' as RelativePathString);
|
|
}
|
|
|
|
const menuItems: MenuItem[] = [
|
|
{
|
|
icon: 'person-outline',
|
|
label: t('appHeader.editProfile'),
|
|
action: () => closeAndNavigate('/settings' as RelativePathString),
|
|
},
|
|
{
|
|
icon: 'settings-outline',
|
|
label: t('appHeader.settings'),
|
|
action: () => closeAndNavigate('/settings' as RelativePathString),
|
|
},
|
|
];
|
|
|
|
const headerHeight = insets.top + 56;
|
|
|
|
return (
|
|
<View
|
|
className="bg-white border-b border-neutral-200"
|
|
style={{ paddingTop: insets.top }}
|
|
>
|
|
<View className="h-14 flex-row items-center justify-between px-5">
|
|
<Text className="text-lg text-midnight-900 tracking-tight" style={{ fontFamily: 'Nunito_700Bold' }}>
|
|
{t('appHeader.appName')}
|
|
</Text>
|
|
|
|
<View className="flex-row items-center gap-2">
|
|
{/* Notifications dropdown trigger */}
|
|
<Pressable
|
|
onPress={() => setNotifOpen(true)}
|
|
className="w-9 h-9 rounded-full bg-white items-center justify-center"
|
|
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
|
>
|
|
<Ionicons name="notifications-outline" size={18} color="#737373" />
|
|
{badge > 0 && (
|
|
<View className="absolute top-0 right-0 w-4 h-4 rounded-full bg-rebreak-500 items-center justify-center">
|
|
<Text className="text-white text-[9px]" style={{ fontFamily: 'Nunito_700Bold' }}>
|
|
{badge > 9 ? '9+' : String(badge)}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</Pressable>
|
|
|
|
{/* Profil-Avatar — tap → dropdown */}
|
|
<Pressable
|
|
onPress={() => setDropdownOpen(true)}
|
|
className={`w-9 h-9 rounded-full items-center justify-center overflow-hidden ${showAvatarImage ? 'bg-neutral-100' : 'bg-rebreak-500'}`}
|
|
style={({ pressed }) => ({ opacity: pressed ? 0.7 : 1 })}
|
|
>
|
|
{showAvatarImage ? (
|
|
<Image
|
|
source={{ uri: avatarUrl }}
|
|
onError={() => setAvatarLoadFailed(true)}
|
|
style={{ width: 36, height: 36, borderRadius: 18 }}
|
|
/>
|
|
) : (
|
|
<Text className="text-white text-xs" style={{ fontFamily: 'Nunito_700Bold' }}>{initials}</Text>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
</View>
|
|
|
|
{/* Dropdown modal */}
|
|
<Modal
|
|
visible={dropdownOpen}
|
|
transparent
|
|
animationType="fade"
|
|
statusBarTranslucent
|
|
onRequestClose={() => setDropdownOpen(false)}
|
|
>
|
|
<Pressable
|
|
onPress={() => setDropdownOpen(false)}
|
|
style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.18)' }}
|
|
>
|
|
<View
|
|
onStartShouldSetResponder={() => true}
|
|
style={{
|
|
position: 'absolute',
|
|
top: headerHeight + 6,
|
|
right: 12,
|
|
backgroundColor: '#ffffff',
|
|
borderRadius: 18,
|
|
shadowColor: '#000',
|
|
shadowOffset: { width: 0, height: 8 },
|
|
shadowOpacity: 0.18,
|
|
shadowRadius: 20,
|
|
elevation: 12,
|
|
minWidth: 260,
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{/* SOS prominent oben — Pressable mit innerem Row-View */}
|
|
<Pressable onPress={() => closeAndNavigate('/urge' as RelativePathString)}>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 18,
|
|
paddingVertical: 16,
|
|
}}
|
|
>
|
|
<View
|
|
style={{
|
|
width: 36,
|
|
height: 36,
|
|
borderRadius: 18,
|
|
backgroundColor: '#fee2e2',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
marginRight: 14,
|
|
}}
|
|
>
|
|
<Ionicons name="heart" size={18} color="#dc2626" />
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<Text style={{ fontSize: 15, fontFamily: 'Nunito_700Bold', color: '#dc2626' }}>
|
|
{t('appHeader.sosLabel')}
|
|
</Text>
|
|
<Text style={{ fontSize: 12, fontFamily: 'Nunito_400Regular', color: '#a3a3a3', marginTop: 1 }}>
|
|
{t('appHeader.sosSubtitle')}
|
|
</Text>
|
|
</View>
|
|
<Ionicons name="chevron-forward" size={16} color="#d4d4d8" />
|
|
</View>
|
|
</Pressable>
|
|
|
|
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
|
|
|
|
{menuItems.map((item) => (
|
|
<Pressable key={item.label} onPress={item.action}>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 18,
|
|
paddingVertical: 14,
|
|
}}
|
|
>
|
|
<Ionicons name={item.icon} size={18} color="#737373" style={{ marginRight: 14 }} />
|
|
<Text style={{ fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: '#0a0a0a' }}>
|
|
{item.label}
|
|
</Text>
|
|
</View>
|
|
</Pressable>
|
|
))}
|
|
|
|
<View style={{ height: 1, backgroundColor: '#f0f0f0' }} />
|
|
|
|
<Pressable onPress={handleSignOut}>
|
|
<View
|
|
style={{
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
paddingHorizontal: 18,
|
|
paddingVertical: 14,
|
|
}}
|
|
>
|
|
<Ionicons name="log-out-outline" size={18} color="#dc2626" style={{ marginRight: 14 }} />
|
|
<Text style={{ fontSize: 14, fontFamily: 'Nunito_600SemiBold', color: '#dc2626' }}>
|
|
{t('appHeader.signOut')}
|
|
</Text>
|
|
</View>
|
|
</Pressable>
|
|
</View>
|
|
</Pressable>
|
|
</Modal>
|
|
|
|
<NotificationsDropdown
|
|
visible={notifOpen}
|
|
onClose={() => setNotifOpen(false)}
|
|
topOffset={headerHeight}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|