feat: Phase 6 - reviews and ratings with computed fields
- Review create mutation with auth, customer-only role check, self-review prevention, and ad ratingAvg/reviewCount recomputation - ReviewCard component: reviewer avatar, name, relative time in Macedonian, star rating, comment - Create review screen: interactive 5-star selector (Многу лошо–Отлично), comment field, auth gate, submit and navigate back - Ad detail: review section listing all reviews with reviewer names, overall rating summary with count, 'Напиши оценка' button - AdCard: rating stars shown next to title when ad has reviews All UI text in Macedonian. TypeScript clean, Convex functions deployed.
This commit is contained in:
parent
723cf7e8e3
commit
c700d139f7
@ -11,6 +11,7 @@ import { Button } from "../../components/ui/button";
|
|||||||
import { Avatar } from "../../components/ui/avatar";
|
import { Avatar } from "../../components/ui/avatar";
|
||||||
import { Loading } from "../../components/ui/loading";
|
import { Loading } from "../../components/ui/loading";
|
||||||
import { Card } from "../../components/ui/card";
|
import { Card } from "../../components/ui/card";
|
||||||
|
import { ReviewCard } from "../../components/review-card";
|
||||||
import { getCategoryName, CATEGORY_EMOJI } from "../../lib/constants";
|
import { getCategoryName, CATEGORY_EMOJI } from "../../lib/constants";
|
||||||
|
|
||||||
export default function AdDetailScreen() {
|
export default function AdDetailScreen() {
|
||||||
@ -24,6 +25,41 @@ export default function AdDetailScreen() {
|
|||||||
api.users.getById,
|
api.users.getById,
|
||||||
ad ? { id: ad.handymanId } : "skip"
|
ad ? { id: ad.handymanId } : "skip"
|
||||||
);
|
);
|
||||||
|
const reviews = useQuery(
|
||||||
|
api.reviews.getByAd,
|
||||||
|
id ? { adId: id as any } : "skip"
|
||||||
|
);
|
||||||
|
|
||||||
|
const reviewCustomerIds = reviews
|
||||||
|
? [...new Set(reviews.map((r) => r.customerId))]
|
||||||
|
: [];
|
||||||
|
const customer0 = useQuery(
|
||||||
|
api.users.getById,
|
||||||
|
reviewCustomerIds.length > 0 ? { id: reviewCustomerIds[0] as any } : "skip"
|
||||||
|
);
|
||||||
|
const customer1 = useQuery(
|
||||||
|
api.users.getById,
|
||||||
|
reviewCustomerIds.length > 1 ? { id: reviewCustomerIds[1] as any } : "skip"
|
||||||
|
);
|
||||||
|
const customer2 = useQuery(
|
||||||
|
api.users.getById,
|
||||||
|
reviewCustomerIds.length > 2 ? { id: reviewCustomerIds[2] as any } : "skip"
|
||||||
|
);
|
||||||
|
const customer3 = useQuery(
|
||||||
|
api.users.getById,
|
||||||
|
reviewCustomerIds.length > 3 ? { id: reviewCustomerIds[3] as any } : "skip"
|
||||||
|
);
|
||||||
|
const customer4 = useQuery(
|
||||||
|
api.users.getById,
|
||||||
|
reviewCustomerIds.length > 4 ? { id: reviewCustomerIds[4] as any } : "skip"
|
||||||
|
);
|
||||||
|
const customersById: Record<string, any> = {};
|
||||||
|
if (customer0) customersById[reviewCustomerIds[0] as string] = customer0;
|
||||||
|
if (customer1) customersById[reviewCustomerIds[1] as string] = customer1;
|
||||||
|
if (customer2) customersById[reviewCustomerIds[2] as string] = customer2;
|
||||||
|
if (customer3) customersById[reviewCustomerIds[3] as string] = customer3;
|
||||||
|
if (customer4) customersById[reviewCustomerIds[4] as string] = customer4;
|
||||||
|
|
||||||
const deleteAd = useMutation(api.ads.remove);
|
const deleteAd = useMutation(api.ads.remove);
|
||||||
const startChat = useMutation(api.chats.getOrCreate);
|
const startChat = useMutation(api.chats.getOrCreate);
|
||||||
const [startingChat, setStartingChat] = useState(false);
|
const [startingChat, setStartingChat] = useState(false);
|
||||||
@ -79,6 +115,8 @@ export default function AdDetailScreen() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingUserReview = reviews?.find((r) => r.customerId === userId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
@ -92,7 +130,6 @@ export default function AdDetailScreen() {
|
|||||||
contentInsetAdjustmentBehavior="automatic"
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 100 }}
|
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 100 }}
|
||||||
>
|
>
|
||||||
{/* Gallery placeholder */}
|
|
||||||
<View
|
<View
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.colors.surface,
|
backgroundColor: theme.colors.surface,
|
||||||
@ -111,7 +148,6 @@ export default function AdDetailScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Title + Price */}
|
|
||||||
<View>
|
<View>
|
||||||
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>{ad.title}</Text>
|
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>{ad.title}</Text>
|
||||||
{ad.priceRange && (
|
{ad.priceRange && (
|
||||||
@ -121,18 +157,15 @@ export default function AdDetailScreen() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Category + Location */}
|
|
||||||
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm, flexWrap: "wrap" }}>
|
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm, flexWrap: "wrap" }}>
|
||||||
<Badge label={categoryName} variant="primary" />
|
<Badge label={categoryName} variant="primary" />
|
||||||
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }}>📍 {ad.location}</Text>
|
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }}>📍 {ad.location}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Rating */}
|
|
||||||
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
|
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
|
||||||
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={20} />
|
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={20} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Availability */}
|
|
||||||
{ad.availability && (
|
{ad.availability && (
|
||||||
<Card>
|
<Card>
|
||||||
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||||||
@ -142,7 +175,6 @@ export default function AdDetailScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Description */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
|
||||||
Опис
|
Опис
|
||||||
@ -150,7 +182,6 @@ export default function AdDetailScreen() {
|
|||||||
<Text style={{ fontSize: 15, color: theme.colors.textSecondary, lineHeight: 22 }}>{ad.description}</Text>
|
<Text style={{ fontSize: 15, color: theme.colors.textSecondary, lineHeight: 22 }}>{ad.description}</Text>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Handyman info */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
@ -168,15 +199,22 @@ export default function AdDetailScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Action buttons */}
|
|
||||||
{!isOwner && isAuthenticated && (
|
{!isOwner && isAuthenticated && (
|
||||||
<View style={{ gap: theme.spacing.sm }}>
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
<Button loading={startingChat} onPress={handleStartChat}>Започни разговор</Button>
|
<Button loading={startingChat} onPress={handleStartChat}>Започни разговор</Button>
|
||||||
<Button variant="outline" onPress={() => {}}>Напиши оценка</Button>
|
{!existingUserReview && (
|
||||||
|
<Button variant="outline" onPress={() => router.push(`/review/create?adId=${ad._id}` as any)}>
|
||||||
|
Напиши оценка
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{existingUserReview && (
|
||||||
|
<Text style={{ fontSize: 13, color: theme.colors.textTertiary, textAlign: "center" }}>
|
||||||
|
Веќе напишавте оценка за овој оглас
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Owner actions */}
|
|
||||||
{isOwner && (
|
{isOwner && (
|
||||||
<View style={{ gap: theme.spacing.sm }}>
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
<Button
|
<Button
|
||||||
@ -201,6 +239,37 @@ export default function AdDetailScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Reviews section */}
|
||||||
|
{reviews && reviews.length > 0 && (
|
||||||
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between" }}>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
|
||||||
|
Оцени
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.xs }}>
|
||||||
|
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>
|
||||||
|
{ad.ratingAvg?.toFixed(1)}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 14, color: theme.colors.textTertiary }}>
|
||||||
|
({ad.reviewCount} {ad.reviewCount === 1 ? "оценка" : "оцени"})
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{reviews.map((review) => {
|
||||||
|
const customer = customersById[review.customerId as string];
|
||||||
|
return (
|
||||||
|
<ReviewCard
|
||||||
|
key={review._id}
|
||||||
|
review={review}
|
||||||
|
customerName={customer?.name || "Клиент"}
|
||||||
|
customerAvatarId={customer?.avatarId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,20 +1,144 @@
|
|||||||
import { View, Text, ScrollView } from "react-native";
|
import { useState } from "react";
|
||||||
import { Stack } from "expo-router";
|
import { View, Text, Pressable, Alert, ScrollView } from "react-native";
|
||||||
|
import { useLocalSearchParams, Stack, useRouter } from "expo-router";
|
||||||
|
import { useQuery, useMutation } from "convex/react";
|
||||||
|
import { api } from "../../convex/_generated/api";
|
||||||
|
import { useAuth } from "../_layout";
|
||||||
import { useTheme } from "../../components/theme";
|
import { useTheme } from "../../components/theme";
|
||||||
|
import { Button } from "../../components/ui/button";
|
||||||
|
import { Input } from "../../components/ui/input";
|
||||||
|
import { Loading } from "../../components/ui/loading";
|
||||||
|
|
||||||
export default function CreateReviewScreen() {
|
export default function CreateReviewScreen() {
|
||||||
|
const { adId } = useLocalSearchParams<{ adId: string }>();
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
const router = useRouter();
|
||||||
|
const { token, isAuthenticated, userId } = useAuth();
|
||||||
|
|
||||||
|
const ad = useQuery(api.ads.getById, adId ? { id: adId as any } : "skip");
|
||||||
|
const handyman = useQuery(
|
||||||
|
api.users.getById,
|
||||||
|
ad ? { id: ad.handymanId } : "skip"
|
||||||
|
);
|
||||||
|
|
||||||
|
const [rating, setRating] = useState(0);
|
||||||
|
const [comment, setComment] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const createReview = useMutation(api.reviews.create);
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: "Оценка", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
||||||
|
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: theme.colors.background, padding: theme.spacing.lg }}>
|
||||||
|
<Text style={{ fontSize: 16, color: theme.colors.textSecondary, textAlign: "center" }}>
|
||||||
|
Најавете се за да напишете оценка.
|
||||||
|
</Text>
|
||||||
|
<View style={{ marginTop: theme.spacing.md }}>
|
||||||
|
<Button onPress={() => router.push("/(auth)/login")}>Најави се</Button>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (rating === 0) {
|
||||||
|
Alert.alert("Грешка", "Одберете оценка (1-5 ѕвезди)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!token || !adId) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await createReview({ token, adId: adId as any, rating, comment: comment.trim() || undefined });
|
||||||
|
router.back();
|
||||||
|
} catch (e: any) {
|
||||||
|
Alert.alert("Грешка", e.message || "Неуспешно креирање оценка");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ad) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Stack.Screen options={{ title: "Оценка", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
||||||
|
<View style={{ flex: 1, justifyContent: "center", alignItems: "center", backgroundColor: theme.colors.background }}>
|
||||||
|
<Loading />
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Stack.Screen options={{ title: "Оценка", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
<Stack.Screen options={{ title: "Оценка", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
|
||||||
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}>
|
<ScrollView
|
||||||
<View style={{ alignItems: "center", paddingVertical: 60 }}>
|
contentInsetAdjustmentBehavior="automatic"
|
||||||
<Text style={{ fontSize: 48, marginBottom: 16 }}>⭐</Text>
|
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.lg }}
|
||||||
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}>
|
>
|
||||||
Оценка — доаѓа во Фаза 6
|
<View style={{ backgroundColor: theme.colors.card, borderRadius: theme.radius.lg, padding: theme.spacing.md, borderWidth: 1, borderColor: theme.colors.border }}>
|
||||||
|
<Text style={{ fontSize: 18, fontWeight: "700", color: theme.colors.text }}>
|
||||||
|
{ad.title}
|
||||||
</Text>
|
</Text>
|
||||||
|
{handyman && (
|
||||||
|
<Text style={{ fontSize: 14, color: theme.colors.textSecondary, marginTop: theme.spacing.xs }}>
|
||||||
|
Мајстор: {handyman.name}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text }}>
|
||||||
|
Оценка
|
||||||
|
</Text>
|
||||||
|
<View style={{ flexDirection: "row", gap: theme.spacing.sm }}>
|
||||||
|
{[1, 2, 3, 4, 5].map((star) => (
|
||||||
|
<Pressable
|
||||||
|
key={star}
|
||||||
|
onPress={() => setRating(star)}
|
||||||
|
style={{ padding: 4 }}
|
||||||
|
>
|
||||||
|
<Text style={{ fontSize: 40, color: star <= rating ? theme.colors.star : theme.colors.border }}>
|
||||||
|
★
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
{rating > 0 && (
|
||||||
|
<Text style={{ fontSize: 13, color: theme.colors.textTertiary }}>
|
||||||
|
{rating === 1 && "Многу лошо"}
|
||||||
|
{rating === 2 && "Лошо"}
|
||||||
|
{rating === 3 && "Просечно"}
|
||||||
|
{rating === 4 && "Добро"}
|
||||||
|
{rating === 5 && "Одлично"}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ gap: theme.spacing.sm }}>
|
||||||
|
<Text style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text }}>
|
||||||
|
Коментар (опционално)
|
||||||
|
</Text>
|
||||||
|
<Input
|
||||||
|
value={comment}
|
||||||
|
onChangeText={setComment}
|
||||||
|
placeholder="Споделете го вашето искуство…"
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
style={{ minHeight: 100, textAlignVertical: "top" }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
loading={submitting}
|
||||||
|
disabled={rating === 0}
|
||||||
|
onPress={handleSubmit}
|
||||||
|
>
|
||||||
|
Испрати оценка
|
||||||
|
</Button>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -34,9 +34,14 @@ export function AdCard({ ad }: AdCardProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
|
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
|
||||||
<Text style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text, flex: 1 }}>
|
<View style={{ flex: 1, gap: 2 }}>
|
||||||
{ad.title}
|
<Text style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text }}>
|
||||||
</Text>
|
{ad.title}
|
||||||
|
</Text>
|
||||||
|
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
|
||||||
|
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={14} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
{ad.priceRange && (
|
{ad.priceRange && (
|
||||||
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.primary, marginLeft: 8 }}>
|
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.primary, marginLeft: 8 }}>
|
||||||
{ad.priceRange}
|
{ad.priceRange}
|
||||||
@ -48,10 +53,6 @@ export function AdCard({ ad }: AdCardProps) {
|
|||||||
<Badge label={categoryName} variant="primary" />
|
<Badge label={categoryName} variant="primary" />
|
||||||
<Text style={{ fontSize: 13, color: theme.colors.textTertiary }}>📍 {ad.location}</Text>
|
<Text style={{ fontSize: 13, color: theme.colors.textTertiary }}>📍 {ad.location}</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
|
|
||||||
<Rating value={ad.ratingAvg} count={ad.reviewCount} />
|
|
||||||
)}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
|
|||||||
70
components/review-card.tsx
Normal file
70
components/review-card.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { View, Text } from "react-native";
|
||||||
|
import { useTheme } from "./theme";
|
||||||
|
import { Avatar } from "./ui/avatar";
|
||||||
|
import { Rating } from "./ui/rating";
|
||||||
|
|
||||||
|
function formatTimeAgo(timestamp: number): string {
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - timestamp;
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
const years = Math.floor(days / 365);
|
||||||
|
|
||||||
|
if (years > 0) return `пред ${years} ${years === 1 ? "година" : "години"}`;
|
||||||
|
if (months > 0) return `пред ${months} ${months === 1 ? "месец" : "месеци"}`;
|
||||||
|
if (days > 0) return `пред ${days} ${days === 1 ? "ден" : "денови"}`;
|
||||||
|
if (hours > 0) return `пред ${hours} ${hours === 1 ? "час" : "часа"}`;
|
||||||
|
if (minutes > 0) return `пред ${minutes} ${minutes === 1 ? "минута" : "минути"}`;
|
||||||
|
return "пред малку";
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReviewCardProps {
|
||||||
|
review: {
|
||||||
|
_id: string;
|
||||||
|
rating: number;
|
||||||
|
comment?: string;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
customerName: string;
|
||||||
|
customerAvatarId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReviewCard({ review, customerName, customerAvatarId }: ReviewCardProps) {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
style={{
|
||||||
|
backgroundColor: theme.colors.card,
|
||||||
|
borderRadius: theme.radius.lg,
|
||||||
|
padding: theme.spacing.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
gap: theme.spacing.sm,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm }}>
|
||||||
|
<Avatar uri={customerAvatarId ?? null} name={customerName} size={36} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text }}>
|
||||||
|
{customerName}
|
||||||
|
</Text>
|
||||||
|
<Text style={{ fontSize: 12, color: theme.colors.textTertiary }}>
|
||||||
|
{formatTimeAgo(review.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<Rating value={review.rating} size={16} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{review.comment ? (
|
||||||
|
<Text style={{ fontSize: 14, color: theme.colors.textSecondary, lineHeight: 20 }}>
|
||||||
|
{review.comment}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { query } from "./_generated/server";
|
import { query, mutation } from "./_generated/server";
|
||||||
import { v } from "convex/values";
|
import { v } from "convex/values";
|
||||||
|
|
||||||
export const getByAd = query({
|
export const getByAd = query({
|
||||||
@ -21,4 +21,59 @@ export const getByHandyman = query({
|
|||||||
.order("desc")
|
.order("desc")
|
||||||
.collect();
|
.collect();
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const create = mutation({
|
||||||
|
args: {
|
||||||
|
token: v.string(),
|
||||||
|
adId: v.id("ads"),
|
||||||
|
rating: v.number(),
|
||||||
|
comment: v.optional(v.string()),
|
||||||
|
},
|
||||||
|
handler: async (ctx, args) => {
|
||||||
|
if (args.rating < 1 || args.rating > 5) {
|
||||||
|
throw new Error("Оцената мора да биде помеѓу 1 и 5");
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await ctx.db
|
||||||
|
.query("sessions")
|
||||||
|
.withIndex("by_token", (q) => q.eq("token", args.token))
|
||||||
|
.first();
|
||||||
|
if (!session) throw new Error("Неавторизиран");
|
||||||
|
|
||||||
|
const user = await ctx.db.get(session.userId);
|
||||||
|
if (!user) throw new Error("Корисникот не е пронајден");
|
||||||
|
if (user.role !== "customer") throw new Error("Само клиенти можат да пишуваат оцени");
|
||||||
|
|
||||||
|
const ad = await ctx.db.get(args.adId);
|
||||||
|
if (!ad) throw new Error("Огласот не е пронајден");
|
||||||
|
|
||||||
|
if (ad.handymanId === session.userId) {
|
||||||
|
throw new Error("Не можете да оцените свој оглас");
|
||||||
|
}
|
||||||
|
|
||||||
|
const reviewId = await ctx.db.insert("reviews", {
|
||||||
|
adId: args.adId,
|
||||||
|
customerId: session.userId,
|
||||||
|
handymanId: ad.handymanId,
|
||||||
|
rating: args.rating,
|
||||||
|
comment: args.comment,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const allReviews = await ctx.db
|
||||||
|
.query("reviews")
|
||||||
|
.withIndex("by_ad", (q) => q.eq("adId", args.adId))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
const totalRating = allReviews.reduce((sum, r) => sum + r.rating, 0);
|
||||||
|
const ratingAvg = totalRating / allReviews.length;
|
||||||
|
|
||||||
|
await ctx.db.patch(args.adId, {
|
||||||
|
ratingAvg,
|
||||||
|
reviewCount: allReviews.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reviewId;
|
||||||
|
},
|
||||||
});
|
});
|
||||||
Loading…
Reference in New Issue
Block a user