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:
echo 2026-05-29 18:41:07 +02:00
parent 723cf7e8e3
commit c700d139f7
5 changed files with 344 additions and 25 deletions

View File

@ -11,6 +11,7 @@ import { Button } from "../../components/ui/button";
import { Avatar } from "../../components/ui/avatar";
import { Loading } from "../../components/ui/loading";
import { Card } from "../../components/ui/card";
import { ReviewCard } from "../../components/review-card";
import { getCategoryName, CATEGORY_EMOJI } from "../../lib/constants";
export default function AdDetailScreen() {
@ -24,6 +25,41 @@ export default function AdDetailScreen() {
api.users.getById,
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 startChat = useMutation(api.chats.getOrCreate);
const [startingChat, setStartingChat] = useState(false);
@ -79,6 +115,8 @@ export default function AdDetailScreen() {
);
}
const existingUserReview = reviews?.find((r) => r.customerId === userId);
return (
<>
<Stack.Screen
@ -92,7 +130,6 @@ export default function AdDetailScreen() {
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.md, paddingBottom: 100 }}
>
{/* Gallery placeholder */}
<View
style={{
backgroundColor: theme.colors.surface,
@ -111,7 +148,6 @@ export default function AdDetailScreen() {
</Text>
</View>
{/* Title + Price */}
<View>
<Text style={{ fontSize: 22, fontWeight: "700", color: theme.colors.text }}>{ad.title}</Text>
{ad.priceRange && (
@ -121,18 +157,15 @@ export default function AdDetailScreen() {
)}
</View>
{/* Category + Location */}
<View style={{ flexDirection: "row", alignItems: "center", gap: theme.spacing.sm, flexWrap: "wrap" }}>
<Badge label={categoryName} variant="primary" />
<Text style={{ fontSize: 14, color: theme.colors.textSecondary }}>📍 {ad.location}</Text>
</View>
{/* Rating */}
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={20} />
)}
{/* Availability */}
{ad.availability && (
<Card>
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.text, marginBottom: theme.spacing.xs }}>
@ -142,7 +175,6 @@ export default function AdDetailScreen() {
</Card>
)}
{/* Description */}
<Card>
<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>
</Card>
{/* Handyman info */}
<Card>
<Pressable
onPress={() => {
@ -168,15 +199,22 @@ export default function AdDetailScreen() {
</Pressable>
</Card>
{/* Action buttons */}
{!isOwner && isAuthenticated && (
<View style={{ gap: theme.spacing.sm }}>
<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>
)}
{/* Owner actions */}
{isOwner && (
<View style={{ gap: theme.spacing.sm }}>
<Button
@ -201,6 +239,37 @@ export default function AdDetailScreen() {
</View>
</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>
</>
);

View File

@ -1,20 +1,144 @@
import { View, Text, ScrollView } from "react-native";
import { Stack } from "expo-router";
import { useState } from "react";
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 { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { Loading } from "../../components/ui/loading";
export default function CreateReviewScreen() {
const { adId } = useLocalSearchParams<{ adId: string }>();
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 (
<>
<Stack.Screen options={{ title: "Оценка", headerShown: true, headerBackButtonDisplayMode: "minimal" }} />
<ScrollView contentInsetAdjustmentBehavior="automatic" contentContainerStyle={{ padding: 16 }}>
<View style={{ alignItems: "center", paddingVertical: 60 }}>
<Text style={{ fontSize: 48, marginBottom: 16 }}></Text>
<Text style={{ fontSize: 18, fontWeight: "600", color: theme.colors.text }}>
Оценка доаѓа во Фаза 6
<ScrollView
contentInsetAdjustmentBehavior="automatic"
contentContainerStyle={{ padding: theme.spacing.lg, gap: theme.spacing.lg }}
>
<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>
{handyman && (
<Text style={{ fontSize: 14, color: theme.colors.textSecondary, marginTop: theme.spacing.xs }}>
Мајстор: {handyman.name}
</Text>
)}
</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>
</>
);

View File

@ -34,9 +34,14 @@ export function AdCard({ ad }: AdCardProps) {
}}
>
<View style={{ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start" }}>
<Text style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text, flex: 1 }}>
{ad.title}
</Text>
<View style={{ flex: 1, gap: 2 }}>
<Text style={{ fontSize: 16, fontWeight: "600", color: theme.colors.text }}>
{ad.title}
</Text>
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
<Rating value={ad.ratingAvg} count={ad.reviewCount} size={14} />
)}
</View>
{ad.priceRange && (
<Text style={{ fontSize: 14, fontWeight: "600", color: theme.colors.primary, marginLeft: 8 }}>
{ad.priceRange}
@ -48,10 +53,6 @@ export function AdCard({ ad }: AdCardProps) {
<Badge label={categoryName} variant="primary" />
<Text style={{ fontSize: 13, color: theme.colors.textTertiary }}>📍 {ad.location}</Text>
</View>
{ad.ratingAvg != null && ad.ratingAvg > 0 && (
<Rating value={ad.ratingAvg} count={ad.reviewCount} />
)}
</Pressable>
</Link>
);

View 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>
);
}

View File

@ -1,4 +1,4 @@
import { query } from "./_generated/server";
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const getByAd = query({
@ -22,3 +22,58 @@ export const getByHandyman = query({
.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;
},
});