diff --git a/app/ad/[id].tsx b/app/ad/[id].tsx index 4984b07..e50fdd5 100644 --- a/app/ad/[id].tsx +++ b/app/ad/[id].tsx @@ -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 = {}; + 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 ( <> - {/* Gallery placeholder */} - {/* Title + Price */} {ad.title} {ad.priceRange && ( @@ -121,18 +157,15 @@ export default function AdDetailScreen() { )} - {/* Category + Location */} 📍 {ad.location} - {/* Rating */} {ad.ratingAvg != null && ad.ratingAvg > 0 && ( )} - {/* Availability */} {ad.availability && ( @@ -142,7 +175,6 @@ export default function AdDetailScreen() { )} - {/* Description */} Опис @@ -150,7 +182,6 @@ export default function AdDetailScreen() { {ad.description} - {/* Handyman info */} { @@ -168,15 +199,22 @@ export default function AdDetailScreen() { - {/* Action buttons */} {!isOwner && isAuthenticated && ( - + {!existingUserReview && ( + + )} + {existingUserReview && ( + + Веќе напишавте оценка за овој оглас + + )} )} - {/* Owner actions */} {isOwner && ( + + + + ); + } + + 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 ( + <> + + + + + + ); + } return ( <> - - - - - Оценка — доаѓа во Фаза 6 + + + + {ad.title} + {handyman && ( + + Мајстор: {handyman.name} + + )} + + + + Оценка + + + {[1, 2, 3, 4, 5].map((star) => ( + setRating(star)} + style={{ padding: 4 }} + > + + ★ + + + ))} + + {rating > 0 && ( + + {rating === 1 && "Многу лошо"} + {rating === 2 && "Лошо"} + {rating === 3 && "Просечно"} + {rating === 4 && "Добро"} + {rating === 5 && "Одлично"} + + )} + + + + + Коментар (опционално) + + + + + ); diff --git a/components/ad-card.tsx b/components/ad-card.tsx index 2f04b30..f041d41 100644 --- a/components/ad-card.tsx +++ b/components/ad-card.tsx @@ -34,9 +34,14 @@ export function AdCard({ ad }: AdCardProps) { }} > - - {ad.title} - + + + {ad.title} + + {ad.ratingAvg != null && ad.ratingAvg > 0 && ( + + )} + {ad.priceRange && ( {ad.priceRange} @@ -48,10 +53,6 @@ export function AdCard({ ad }: AdCardProps) { 📍 {ad.location} - - {ad.ratingAvg != null && ad.ratingAvg > 0 && ( - - )} ); diff --git a/components/review-card.tsx b/components/review-card.tsx new file mode 100644 index 0000000..0bf48e9 --- /dev/null +++ b/components/review-card.tsx @@ -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 ( + + + + + + {customerName} + + + {formatTimeAgo(review.createdAt)} + + + + + + {review.comment ? ( + + {review.comment} + + ) : null} + + ); +} \ No newline at end of file diff --git a/convex/reviews.ts b/convex/reviews.ts index 41c9906..fc8b1f6 100644 --- a/convex/reviews.ts +++ b/convex/reviews.ts @@ -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; + }, }); \ No newline at end of file