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 { 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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
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";
|
||||
|
||||
export const getByAd = query({
|
||||
@ -21,4 +21,59 @@ export const getByHandyman = query({
|
||||
.order("desc")
|
||||
.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