Sprint 5.1: reviews & user profile mobile UI

- ReviewApi: create/listForUser/stats
- ReviewCreateScreen: stars 1-5, comment, anonymous toggle
- UserReviewsScreen: list + stats header
- UserProfileScreen: avatar, rating, bio, prefs (drinks/activities/purposes)
  * Написать / Все отзывы / Заблокировать / Пожаловаться
- MapScreen: tap marker -> UserProfile
- ChatScreen header: 👤 profile +  leave review
- ChatListScreen: pass other_id in nav params for review screen
- NavStack: UserProfile, ReviewCreate, UserReviews

Backend now returns snake_case JSON (id/name/photo_url etc.) - matches mobile types
This commit is contained in:
ga 2026-08-20 16:30:10 +00:00
parent 4bd3ebae99
commit e27cef09c3
7 changed files with 482 additions and 3 deletions

View File

@ -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<Stats> {
const r = await api.get(`/api/v1/users/${userId}/stats`);
return r.data;
},
};
// ---------- WS ----------
import { io, Socket } from 'socket.io-client';

View File

@ -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() {
<>
<Stack.Screen name="Main" component={MainTabs} options={{ headerShown: false }} />
<Stack.Screen name="Chat" component={ChatScreen} options={({ route }: any) => ({ title: route.params?.otherName ?? 'Чат' })} />
<Stack.Screen name="UserProfile" component={UserProfileScreen} options={{ title: 'Профиль' }} />
<Stack.Screen name="ReviewCreate" component={ReviewCreateScreen} options={{ title: 'Отзыв' }} />
<Stack.Screen name="UserReviews" component={UserReviewsScreen} options={{ title: 'Отзывы' }} />
</>
) : (
<>

View File

@ -12,7 +12,7 @@ import type { Message } from '@/types/api';
export default function ChatScreen() {
const route = useRoute<any>();
const nav = useNavigation<any>();
const { chatId, otherName } = route.params;
const { chatId, otherId, otherName } = route.params;
const { user } = useAuth();
const [msgs, setMsgs] = useState<Message[]>([]);
@ -20,7 +20,22 @@ export default function ChatScreen() {
const [editing, setEditing] = useState<Message | null>(null);
const listRef = useRef<FlatList>(null);
useEffect(() => { load(); markRead(); setupWS(); }, [chatId]);
useEffect(() => { load(); markRead(); setupWS(); setupHeader(); }, [chatId]);
const setupHeader = () => {
nav.setOptions({
headerRight: () => (
<View style={{ flexDirection: 'row', gap: 12 }}>
<TouchableOpacity onPress={() => nav.navigate('UserProfile', { userId: otherId, userName: otherName })}>
<Text style={{ fontSize: 22 }}>👤</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => nav.navigate('ReviewCreate', { chatId, otherId, otherName })}>
<Text style={{ fontSize: 22 }}></Text>
</TouchableOpacity>
</View>
),
});
};
const load = async () => {
try {

View File

@ -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<any>();
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 })}>
<View style={styles.marker}>
<Text style={styles.markerText}>{n.name?.[0] ?? '?'}</Text>
</View>

View File

@ -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<any>();
const nav = useNavigation<any>();
const { userId } = route.params;
const { user: me } = useAuth();
const [profile, setProfile] = useState<any>(null);
const [stats, setStats] = useState<Stats | null>(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 <View style={styles.center}><ActivityIndicator /></View>;
if (!profile) return <View style={styles.center}><Text>Не удалось загрузить</Text></View>;
return (
<ScrollView style={styles.root}>
<View style={styles.header}>
{profile.photo ? (
<Image source={{ uri: profile.photo }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>{profile.name?.[0] ?? '?'}</Text>
</View>
)}
<Text style={styles.name}>{profile.name}</Text>
{profile.city ? <Text style={styles.city}>📍 {profile.city}</Text> : null}
{profile.age ? <Text style={styles.age}>{profile.age} лет</Text> : null}
<View style={styles.ratingRow}>
<Text style={styles.ratingBig}>{stats?.rating_avg?.toFixed(1) ?? '—'}</Text>
<View>
<Text style={styles.stars}>
{[1, 2, 3, 4, 5].map((n) => (
<Text key={n} style={{ color: n <= Math.round(stats?.rating_avg ?? 0) ? Colors.warning : Colors.border }}></Text>
))}
</Text>
<Text style={styles.count}>{stats?.rating_count ?? 0} отзывов</Text>
</View>
</View>
</View>
{profile.bio ? (
<View style={styles.section}>
<Text style={styles.sectionTitle}>О себе</Text>
<Text style={styles.bio}>{profile.bio}</Text>
</View>
) : null}
{profile.prefs && (
<View style={styles.section}>
{profile.prefs.drinks?.length > 0 && (
<ChipSection title="Что пью" items={profile.prefs.drinks} />
)}
{profile.prefs.activities?.length > 0 && (
<ChipSection title="Что делаю" items={profile.prefs.activities} />
)}
{profile.prefs.purposes?.length > 0 && (
<ChipSection title="Цели" items={profile.prefs.purposes} />
)}
</View>
)}
<View style={styles.actions}>
<TouchableOpacity style={styles.btnPrimary} onPress={onStartChat}>
<Text style={styles.btnPrimaryText}>💬 Написать</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.btn} onPress={() => nav.navigate('UserReviews', { userId, userName: profile.name })}>
<Text style={styles.btnText}> Все отзывы ({stats?.rating_count ?? 0})</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.btnGhost} onPress={onBlock}>
<Text style={[styles.btnText, { color: Colors.danger }]}>🚫 Заблокировать</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.btnGhost} onPress={onReport}>
<Text style={[styles.btnText, { color: Colors.textDim }]}> Пожаловаться</Text>
</TouchableOpacity>
</View>
</ScrollView>
);
}
function ChipSection({ title, items }: { title: string; items: string[] }) {
return (
<View style={{ marginBottom: Spacing.m }}>
<Text style={styles.sectionTitle}>{title}</Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap' }}>
{items.map((it) => (
<View key={it} style={styles.chip}><Text style={styles.chipText}>{it}</Text></View>
))}
</View>
</View>
);
}
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' },
});

View File

@ -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<any>();
const nav = useNavigation<any>();
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 (
<ScrollView style={styles.root} contentContainerStyle={{ padding: Spacing.l }}>
<Text style={styles.title}>Оставить отзыв</Text>
<Text style={styles.sub}>о {otherName}</Text>
<Text style={styles.label}>Ваша оценка</Text>
<View style={styles.stars}>
{[1, 2, 3, 4, 5].map((n) => (
<TouchableOpacity key={n} onPress={() => setRating(n)}>
<Text style={[styles.star, n <= rating ? styles.starOn : styles.starOff]}>
{n <= rating ? '★' : '☆'}
</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.label}>Комментарий (необязательно)</Text>
<TextInput
style={[styles.input, { minHeight: 100, textAlignVertical: 'top' }]}
value={body}
onChangeText={setBody}
multiline
maxLength={2000}
placeholder="Как прошла встреча?"
placeholderTextColor={Colors.textDim}
/>
<View style={styles.row}>
<Text style={styles.label}>Анонимный отзыв</Text>
<Switch
value={anonymous}
onValueChange={setAnonymous}
trackColor={{ true: Colors.primary }}
/>
</View>
<TouchableOpacity
style={[styles.btn, submitting && { opacity: 0.5 }]}
disabled={submitting}
onPress={onSubmit}>
<Text style={styles.btnText}>{submitting ? 'Отправка...' : 'Отправить'}</Text>
</TouchableOpacity>
<Text style={styles.disclaimer}>
Отзыв будет виден другим пользователям. Один отзыв на чат.
</Text>
</ScrollView>
);
}
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' },
});

View File

@ -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<any>();
const { userId, userName } = route.params;
const [stats, setStats] = useState<Stats | null>(null);
const [items, setItems] = useState<ReviewItem[]>([]);
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 <View style={styles.center}><ActivityIndicator /></View>;
return (
<View style={styles.root}>
<View style={styles.header}>
<Text style={styles.name}>{userName ?? 'Пользователь'}</Text>
<View style={styles.ratingRow}>
<Text style={styles.ratingBig}>
{stats?.rating_avg?.toFixed(1) ?? '—'}
</Text>
<View>
<Text style={styles.stars}>
{[1, 2, 3, 4, 5].map((n) => (
<Text key={n} style={{ color: n <= Math.round(stats?.rating_avg ?? 0) ? Colors.warning : Colors.border }}></Text>
))}
</Text>
<Text style={styles.count}>{stats?.rating_count ?? 0} отзывов</Text>
</View>
</View>
</View>
<FlatList
data={items}
keyExtractor={(r) => r.id}
ListEmptyComponent={<Text style={styles.empty}>Пока нет отзывов</Text>}
contentContainerStyle={{ padding: Spacing.l }}
renderItem={({ item }) => (
<View style={styles.review}>
<View style={styles.reviewHeader}>
<Text style={styles.reviewStars}>
{[1, 2, 3, 4, 5].map((n) => (
<Text key={n} style={{ color: n <= item.rating ? Colors.warning : Colors.border }}></Text>
))}
</Text>
<Text style={styles.reviewDate}>
{new Date(item.created_at).toLocaleDateString('ru-RU')}
</Text>
</View>
<Text style={styles.reviewAuthor}>
{item.anonymous ? 'Аноним' : 'ID: ' + item.reviewer_id.slice(0, 8)}
</Text>
{item.body ? <Text style={styles.reviewBody}>{item.body}</Text> : null}
</View>
)}
/>
</View>
);
}
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 },
});