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:
parent
4bd3ebae99
commit
e27cef09c3
@ -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';
|
||||
|
||||
@ -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: 'Отзывы' }} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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>
|
||||
|
||||
206
src/screens/profile/UserProfileScreen.tsx
Normal file
206
src/screens/profile/UserProfileScreen.tsx
Normal 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' },
|
||||
});
|
||||
107
src/screens/review/ReviewCreateScreen.tsx
Normal file
107
src/screens/review/ReviewCreateScreen.tsx
Normal 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' },
|
||||
});
|
||||
103
src/screens/review/UserReviewsScreen.tsx
Normal file
103
src/screens/review/UserReviewsScreen.tsx
Normal 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 },
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user