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 ----------
|
// ---------- WS ----------
|
||||||
|
|
||||||
import { io, Socket } from 'socket.io-client';
|
import { io, Socket } from 'socket.io-client';
|
||||||
|
|||||||
@ -12,6 +12,9 @@ import MapScreen from '@/screens/map/MapScreen';
|
|||||||
import ChatListScreen from '@/screens/chat/ChatListScreen';
|
import ChatListScreen from '@/screens/chat/ChatListScreen';
|
||||||
import ChatScreen from '@/screens/chat/ChatScreen';
|
import ChatScreen from '@/screens/chat/ChatScreen';
|
||||||
import ProfileScreen from '@/screens/profile/ProfileScreen';
|
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 Stack = createNativeStackNavigator();
|
||||||
const Tab = createBottomTabNavigator();
|
const Tab = createBottomTabNavigator();
|
||||||
@ -65,6 +68,9 @@ export default function RootNavigation() {
|
|||||||
<>
|
<>
|
||||||
<Stack.Screen name="Main" component={MainTabs} options={{ headerShown: false }} />
|
<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="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() {
|
export default function ChatScreen() {
|
||||||
const route = useRoute<any>();
|
const route = useRoute<any>();
|
||||||
const nav = useNavigation<any>();
|
const nav = useNavigation<any>();
|
||||||
const { chatId, otherName } = route.params;
|
const { chatId, otherId, otherName } = route.params;
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
const [msgs, setMsgs] = useState<Message[]>([]);
|
const [msgs, setMsgs] = useState<Message[]>([]);
|
||||||
@ -20,7 +20,22 @@ export default function ChatScreen() {
|
|||||||
const [editing, setEditing] = useState<Message | null>(null);
|
const [editing, setEditing] = useState<Message | null>(null);
|
||||||
const listRef = useRef<FlatList>(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 () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Alert, Switch, Platform } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, Alert, Switch, Platform } from 'react-native';
|
||||||
import MapView, { Marker, Circle, PROVIDER_GOOGLE, PROVIDER_DEFAULT } from 'react-native-maps';
|
import MapView, { Marker, Circle, PROVIDER_GOOGLE, PROVIDER_DEFAULT } from 'react-native-maps';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { useAuth } from '@/store/auth';
|
import { useAuth } from '@/store/auth';
|
||||||
import { useMap } from '@/store/map';
|
import { useMap } from '@/store/map';
|
||||||
import { AuthApi } from '@/api/client';
|
import { AuthApi } from '@/api/client';
|
||||||
@ -10,6 +11,7 @@ import { Config } from '@/config';
|
|||||||
|
|
||||||
export default function MapScreen() {
|
export default function MapScreen() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const nav = useNavigation<any>();
|
||||||
const { myLat, myLng, nearby, visible, radiusKm, setMyLocation, setVisible, setRadius, refresh, loading } = useMap();
|
const { myLat, myLng, nearby, visible, radiusKm, setMyLocation, setVisible, setRadius, refresh, loading } = useMap();
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
|
|
||||||
@ -65,7 +67,8 @@ export default function MapScreen() {
|
|||||||
key={n.user_id}
|
key={n.user_id}
|
||||||
coordinate={{ latitude: n.lat, longitude: n.lng }}
|
coordinate={{ latitude: n.lat, longitude: n.lng }}
|
||||||
title={n.name}
|
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}>
|
<View style={styles.marker}>
|
||||||
<Text style={styles.markerText}>{n.name?.[0] ?? '?'}</Text>
|
<Text style={styles.markerText}>{n.name?.[0] ?? '?'}</Text>
|
||||||
</View>
|
</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