diff --git a/src/api/client.ts b/src/api/client.ts index 56ce9e3..681962c 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -169,6 +169,45 @@ export const ChatApi = { }, }; +export interface ReviewItem { + id: string; + reviewer_id: string; + reviewed_id: string; + chat_id: string; + rating: number; + body: string; + anonymous: boolean; + created_at: string; +} + +export interface Stats { + user_id: string; + rating_avg: number; + rating_count: number; + reviews_left: number; + last_active_at?: string; +} + +export const ReviewApi = { + async create(chatId: string, rating: number, body: string, anonymous: boolean) { + const r = await api.post('/api/v1/reviews', { + chat_id: chatId, + rating, + body, + anonymous, + }); + return r.data; + }, + async listForUser(userId: string): Promise<{ reviews: ReviewItem[]; count: number }> { + const r = await api.get(`/api/v1/users/${userId}/reviews`); + return r.data; + }, + async stats(userId: string): Promise { + const r = await api.get(`/api/v1/users/${userId}/stats`); + return r.data; + }, +}; + // ---------- WS ---------- import { io, Socket } from 'socket.io-client'; diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index 5d1c170..02acb43 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -12,6 +12,9 @@ import MapScreen from '@/screens/map/MapScreen'; import ChatListScreen from '@/screens/chat/ChatListScreen'; import ChatScreen from '@/screens/chat/ChatScreen'; import ProfileScreen from '@/screens/profile/ProfileScreen'; +import UserProfileScreen from '@/screens/profile/UserProfileScreen'; +import ReviewCreateScreen from '@/screens/review/ReviewCreateScreen'; +import UserReviewsScreen from '@/screens/review/UserReviewsScreen'; const Stack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); @@ -65,6 +68,9 @@ export default function RootNavigation() { <> ({ title: route.params?.otherName ?? 'Чат' })} /> + + + ) : ( <> diff --git a/src/screens/chat/ChatScreen.tsx b/src/screens/chat/ChatScreen.tsx index c9b9ff2..fb03260 100644 --- a/src/screens/chat/ChatScreen.tsx +++ b/src/screens/chat/ChatScreen.tsx @@ -12,7 +12,7 @@ import type { Message } from '@/types/api'; export default function ChatScreen() { const route = useRoute(); const nav = useNavigation(); - const { chatId, otherName } = route.params; + const { chatId, otherId, otherName } = route.params; const { user } = useAuth(); const [msgs, setMsgs] = useState([]); @@ -20,7 +20,22 @@ export default function ChatScreen() { const [editing, setEditing] = useState(null); const listRef = useRef(null); - useEffect(() => { load(); markRead(); setupWS(); }, [chatId]); + useEffect(() => { load(); markRead(); setupWS(); setupHeader(); }, [chatId]); + + const setupHeader = () => { + nav.setOptions({ + headerRight: () => ( + + nav.navigate('UserProfile', { userId: otherId, userName: otherName })}> + 👤 + + nav.navigate('ReviewCreate', { chatId, otherId, otherName })}> + + + + ), + }); + }; const load = async () => { try { diff --git a/src/screens/map/MapScreen.tsx b/src/screens/map/MapScreen.tsx index 47692b6..431d7e3 100644 --- a/src/screens/map/MapScreen.tsx +++ b/src/screens/map/MapScreen.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Alert, Switch, Platform } from 'react-native'; import MapView, { Marker, Circle, PROVIDER_GOOGLE, PROVIDER_DEFAULT } from 'react-native-maps'; +import { useNavigation } from '@react-navigation/native'; import { useAuth } from '@/store/auth'; import { useMap } from '@/store/map'; import { AuthApi } from '@/api/client'; @@ -10,6 +11,7 @@ import { Config } from '@/config'; export default function MapScreen() { const { user } = useAuth(); + const nav = useNavigation(); const { myLat, myLng, nearby, visible, radiusKm, setMyLocation, setVisible, setRadius, refresh, loading } = useMap(); const [filterOpen, setFilterOpen] = useState(false); @@ -65,7 +67,8 @@ export default function MapScreen() { key={n.user_id} coordinate={{ latitude: n.lat, longitude: n.lng }} title={n.name} - description={`${(n.distance_m / 1000).toFixed(1)} км`}> + description={`${(n.distance_m / 1000).toFixed(1)} км`} + onPress={() => nav.navigate('UserProfile', { userId: n.user_id, userName: n.name })}> {n.name?.[0] ?? '?'} diff --git a/src/screens/profile/UserProfileScreen.tsx b/src/screens/profile/UserProfileScreen.tsx new file mode 100644 index 0000000..d39ce65 --- /dev/null +++ b/src/screens/profile/UserProfileScreen.tsx @@ -0,0 +1,206 @@ +import React, { useEffect, useState } from 'react'; +import { + View, Text, ScrollView, TouchableOpacity, Image, StyleSheet, Alert, ActivityIndicator, +} from 'react-native'; +import { useRoute, useNavigation } from '@react-navigation/native'; +import { AuthApi, ReviewApi, ChatApi, Stats } from '@/api/client'; +import { useAuth } from '@/store/auth'; +import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors'; + +export default function UserProfileScreen() { + const route = useRoute(); + const nav = useNavigation(); + const { userId } = route.params; + const { user: me } = useAuth(); + + const [profile, setProfile] = useState(null); + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { load(); }, [userId]); + + const load = async () => { + try { + const [p, s] = await Promise.all([ + AuthApi.getUser(userId), + ReviewApi.stats(userId), + ]); + setProfile(p); + setStats(s); + } catch (e) { + console.warn(e); + } finally { + setLoading(false); + } + }; + + const onStartChat = async () => { + try { + const chat = await ChatApi.ensureChat(userId); + nav.navigate('Chat', { + chatId: chat.id ?? chat.ID, + otherId: userId, + otherName: profile?.name, + }); + } catch (e: any) { + Alert.alert('Ошибка', e?.response?.data?.error ?? 'failed'); + } + }; + + const onBlock = () => { + Alert.alert('Блокировка', `Заблокировать ${profile?.name}?`, [ + { text: 'Отмена', style: 'cancel' }, + { + text: 'Заблокировать', style: 'destructive', + onPress: async () => { + try { + await ChatApi.block(userId); + Alert.alert('OK', 'Пользователь заблокирован'); + nav.goBack(); + } catch (e: any) { + Alert.alert('Ошибка', e?.response?.data?.error ?? 'failed'); + } + }, + }, + ]); + }; + + const onReport = () => { + Alert.alert('Жалоба', 'Пожаловаться на этого пользователя?', [ + { text: 'Отмена', style: 'cancel' }, + { + text: 'Пожаловаться', style: 'destructive', + onPress: async () => { + try { + await ChatApi.report('user', userId, 'spam-or-harassment'); + Alert.alert('OK', 'Жалоба отправлена'); + } catch (e: any) { + Alert.alert('Ошибка', e?.response?.data?.error ?? 'failed'); + } + }, + }, + ]); + }; + + const onLeaveReview = () => { + // попробуем найти существующий чат + Alert.alert('Отзыв', 'Для отзыва нужен чат. Открываем список чатов.', [ + { text: 'Открыть чаты', onPress: () => nav.navigate('Chats') }, + { text: 'Отмена', style: 'cancel' }, + ]); + }; + + if (loading) return ; + if (!profile) return Не удалось загрузить; + + return ( + + + {profile.photo ? ( + + ) : ( + + {profile.name?.[0] ?? '?'} + + )} + {profile.name} + {profile.city ? 📍 {profile.city} : null} + {profile.age ? {profile.age} лет : null} + + + {stats?.rating_avg?.toFixed(1) ?? '—'} + + + {[1, 2, 3, 4, 5].map((n) => ( + + ))} + + {stats?.rating_count ?? 0} отзывов + + + + + {profile.bio ? ( + + О себе + {profile.bio} + + ) : null} + + {profile.prefs && ( + + {profile.prefs.drinks?.length > 0 && ( + + )} + {profile.prefs.activities?.length > 0 && ( + + )} + {profile.prefs.purposes?.length > 0 && ( + + )} + + )} + + + + 💬 Написать + + + nav.navigate('UserReviews', { userId, userName: profile.name })}> + ⭐ Все отзывы ({stats?.rating_count ?? 0}) + + + + 🚫 Заблокировать + + + ⚠️ Пожаловаться + + + + ); +} + +function ChipSection({ title, items }: { title: string; items: string[] }) { + return ( + + {title} + + {items.map((it) => ( + {it} + ))} + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: Colors.bg }, + center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + header: { alignItems: 'center', padding: Spacing.l, backgroundColor: Colors.surface }, + avatar: { width: 100, height: 100, borderRadius: 50, marginBottom: Spacing.m }, + avatarPlaceholder: { backgroundColor: Colors.primary, justifyContent: 'center', alignItems: 'center' }, + avatarText: { color: Colors.textInverse, fontSize: FontSizes.hero, fontWeight: '700' }, + name: { fontSize: FontSizes.xxl, fontWeight: '700', color: Colors.text }, + city: { fontSize: FontSizes.s, color: Colors.textDim, marginTop: 2 }, + age: { fontSize: FontSizes.m, color: Colors.text, marginTop: 2 }, + ratingRow: { flexDirection: 'row', alignItems: 'center', marginTop: Spacing.m, gap: Spacing.s as any }, + ratingBig: { fontSize: FontSizes.xxl, fontWeight: '700', color: Colors.text, marginRight: Spacing.s }, + stars: { fontSize: 16 }, + count: { fontSize: FontSizes.s, color: Colors.textDim }, + section: { padding: Spacing.l, borderBottomWidth: 1, borderBottomColor: Colors.border }, + sectionTitle: { fontSize: FontSizes.s, color: Colors.textDim, marginBottom: Spacing.s, fontWeight: '600' }, + bio: { fontSize: FontSizes.m, color: Colors.text }, + chip: { + paddingVertical: 4, paddingHorizontal: Spacing.s, + backgroundColor: Colors.surface, borderRadius: Radius.full, + marginRight: Spacing.xs, marginBottom: Spacing.xs, + }, + chipText: { fontSize: FontSizes.s, color: Colors.text }, + actions: { padding: Spacing.l, gap: Spacing.s as any }, + btnPrimary: { backgroundColor: Colors.primary, padding: Spacing.m, borderRadius: Radius.m, alignItems: 'center', marginBottom: Spacing.s }, + btnPrimaryText: { color: Colors.textInverse, fontSize: FontSizes.l, fontWeight: '600' }, + btn: { padding: Spacing.m, borderRadius: Radius.m, alignItems: 'center', borderWidth: 1, borderColor: Colors.border, marginBottom: Spacing.s }, + btnGhost: { padding: Spacing.m, borderRadius: Radius.m, alignItems: 'center', marginBottom: Spacing.s }, + btnText: { fontSize: FontSizes.m, color: Colors.text, fontWeight: '500' }, +}); \ No newline at end of file diff --git a/src/screens/review/ReviewCreateScreen.tsx b/src/screens/review/ReviewCreateScreen.tsx new file mode 100644 index 0000000..14fe149 --- /dev/null +++ b/src/screens/review/ReviewCreateScreen.tsx @@ -0,0 +1,107 @@ +import React, { useState } from 'react'; +import { + View, Text, TextInput, TouchableOpacity, ScrollView, Switch, + StyleSheet, Alert, +} from 'react-native'; +import { useRoute, useNavigation } from '@react-navigation/native'; +import { ReviewApi } from '@/api/client'; +import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors'; + +export default function ReviewCreateScreen() { + const route = useRoute(); + const nav = useNavigation(); + const { chatId, otherName } = route.params; + + const [rating, setRating] = useState(5); + const [body, setBody] = useState(''); + const [anonymous, setAnonymous] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const onSubmit = async () => { + if (rating < 1 || rating > 5) { + Alert.alert('Ошибка', 'Поставьте оценку от 1 до 5'); + return; + } + setSubmitting(true); + try { + await ReviewApi.create(chatId, rating, body.trim(), anonymous); + Alert.alert('Спасибо!', 'Ваш отзыв отправлен', [ + { text: 'OK', onPress: () => nav.goBack() }, + ]); + } catch (e: any) { + const msg = e?.response?.data?.error ?? 'не удалось отправить отзыв'; + Alert.alert('Ошибка', msg); + } finally { + setSubmitting(false); + } + }; + + return ( + + Оставить отзыв + о {otherName} + + Ваша оценка + + {[1, 2, 3, 4, 5].map((n) => ( + setRating(n)}> + + {n <= rating ? '★' : '☆'} + + + ))} + + + Комментарий (необязательно) + + + + Анонимный отзыв + + + + + {submitting ? 'Отправка...' : 'Отправить'} + + + + Отзыв будет виден другим пользователям. Один отзыв на чат. + + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: Colors.bg }, + title: { fontSize: FontSizes.xxl, fontWeight: '700', color: Colors.text }, + sub: { fontSize: FontSizes.m, color: Colors.textDim, marginBottom: Spacing.l }, + label: { fontSize: FontSizes.s, color: Colors.textDim, marginTop: Spacing.m, marginBottom: Spacing.xs }, + stars: { flexDirection: 'row', gap: 4, marginBottom: Spacing.m }, + star: { fontSize: 40, marginHorizontal: 2 }, + starOn: { color: Colors.warning }, + starOff: { color: Colors.border }, + input: { + borderWidth: 1, borderColor: Colors.border, borderRadius: Radius.m, + paddingHorizontal: Spacing.m, paddingVertical: Spacing.s, + fontSize: FontSizes.m, color: Colors.text, + }, + row: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginTop: Spacing.m }, + btn: { backgroundColor: Colors.primary, padding: Spacing.m, borderRadius: Radius.m, alignItems: 'center', marginTop: Spacing.l }, + btnText: { color: Colors.textInverse, fontSize: FontSizes.l, fontWeight: '600' }, + disclaimer: { fontSize: FontSizes.xs, color: Colors.textDim, marginTop: Spacing.m, textAlign: 'center' }, +}); \ No newline at end of file diff --git a/src/screens/review/UserReviewsScreen.tsx b/src/screens/review/UserReviewsScreen.tsx new file mode 100644 index 0000000..e48b190 --- /dev/null +++ b/src/screens/review/UserReviewsScreen.tsx @@ -0,0 +1,103 @@ +import React, { useEffect, useState } from 'react'; +import { View, Text, FlatList, StyleSheet, ActivityIndicator } from 'react-native'; +import { useRoute } from '@react-navigation/native'; +import { ReviewApi, ReviewItem, Stats } from '@/api/client'; +import { Colors, Spacing, FontSizes, Radius } from '@/theme/colors'; + +export default function UserReviewsScreen() { + const route = useRoute(); + const { userId, userName } = route.params; + + const [stats, setStats] = useState(null); + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { load(); }, [userId]); + + const load = async () => { + try { + const [s, r] = await Promise.all([ + ReviewApi.stats(userId), + ReviewApi.listForUser(userId), + ]); + setStats(s); + setItems(r.reviews); + } catch (e) { + console.warn(e); + } finally { + setLoading(false); + } + }; + + if (loading) return ; + + return ( + + + {userName ?? 'Пользователь'} + + + {stats?.rating_avg?.toFixed(1) ?? '—'} + + + + {[1, 2, 3, 4, 5].map((n) => ( + + ))} + + {stats?.rating_count ?? 0} отзывов + + + + + r.id} + ListEmptyComponent={Пока нет отзывов} + contentContainerStyle={{ padding: Spacing.l }} + renderItem={({ item }) => ( + + + + {[1, 2, 3, 4, 5].map((n) => ( + + ))} + + + {new Date(item.created_at).toLocaleDateString('ru-RU')} + + + + {item.anonymous ? 'Аноним' : 'ID: ' + item.reviewer_id.slice(0, 8)} + + {item.body ? {item.body} : null} + + )} + /> + + ); +} + +const styles = StyleSheet.create({ + root: { flex: 1, backgroundColor: Colors.bg }, + center: { flex: 1, justifyContent: 'center', alignItems: 'center' }, + header: { + padding: Spacing.l, + backgroundColor: Colors.surface, + borderBottomWidth: 1, borderBottomColor: Colors.border, + }, + name: { fontSize: FontSizes.xl, fontWeight: '700', color: Colors.text, marginBottom: Spacing.s }, + ratingRow: { flexDirection: 'row', alignItems: 'center', gap: Spacing.m as any }, + ratingBig: { fontSize: FontSizes.hero, fontWeight: '700', color: Colors.text, marginRight: Spacing.m }, + stars: { fontSize: 18 }, + count: { fontSize: FontSizes.s, color: Colors.textDim }, + empty: { textAlign: 'center', color: Colors.textDim, paddingVertical: Spacing.xl }, + review: { + paddingVertical: Spacing.m, borderBottomWidth: 1, borderBottomColor: Colors.border, + }, + reviewHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, + reviewStars: { fontSize: 16 }, + reviewDate: { fontSize: FontSizes.s, color: Colors.textDim }, + reviewAuthor: { fontSize: FontSizes.s, color: Colors.textDim, marginTop: 4 }, + reviewBody: { fontSize: FontSizes.m, color: Colors.text, marginTop: 6 }, +}); \ No newline at end of file