From 234729329cacf05b8969dbb209c5a9ea37529b50 Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Fri, 17 Jul 2026 10:54:14 +0330 Subject: [PATCH] review --- .../[id]/components/FoodReviewsSection.tsx | 33 +---- .../[id]/components/ReviewChatThread.tsx | 98 ++++++++++++ .../[name]/(Main)/[id]/utils/reviewHelpers.ts | 140 ++++++++++++++++++ src/app/[name]/(Main)/about/types/Types.ts | 10 +- 4 files changed, 252 insertions(+), 29 deletions(-) create mode 100644 src/app/[name]/(Main)/[id]/components/ReviewChatThread.tsx create mode 100644 src/app/[name]/(Main)/[id]/utils/reviewHelpers.ts diff --git a/src/app/[name]/(Main)/[id]/components/FoodReviewsSection.tsx b/src/app/[name]/(Main)/[id]/components/FoodReviewsSection.tsx index ae48fa1..b181708 100644 --- a/src/app/[name]/(Main)/[id]/components/FoodReviewsSection.tsx +++ b/src/app/[name]/(Main)/[id]/components/FoodReviewsSection.tsx @@ -4,8 +4,9 @@ import ToggleButton, { ToggleButtonClassNames } from "@/components/button/Toggle import TabContainer, { TabContainerClassNames, TabContainerRenderType } from "@/components/tab/TabContainer"; import { TabHeader } from "@/components/tab/TabHeader"; import { toast, toastLoginRequired } from "@/components/Toast"; -import Comment from "@/components/utils/Comment"; import RateSelectionBar from "@/components/utils/RateSelectionBar"; +import ReviewChatThread from "./ReviewChatThread"; +import { buildReviewThreads } from "../utils/reviewHelpers"; import { Button } from "@/components/ui/button"; import { getToken } from "@/lib/api/func"; import { extractErrorMessage } from "@/lib/func"; @@ -18,7 +19,6 @@ import { useGetProfile } from "../../../(Profile)/profile/hooks/userProfileData" import { NegativePointEnum, PositivePointEnum } from "../enum/ReviewPoints"; import { useCreateReview, useGetFoodReviews } from "../hooks/useFoodData"; import { CreateReviewType } from "../types/ReviewTypes"; -import { isReviewApproved } from "../../about/types/Types"; type Props = { foodId: string; @@ -54,8 +54,7 @@ function FoodReviewsSection({ foodId }: Props) { const [negativePoints, setNegativePoints] = useState([]); const [formKey, setFormKey] = useState(0); - const reviews = (reviewsData?.data || []).filter(isReviewApproved); - const sortedReviews = [...reviews].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + const reviewThreads = buildReviewThreads(reviewsData?.data || []); const getTranslationKey = (point: string, isPositive: boolean): string => { const basePath = isPositive ? "Tabs.Strengths.Options" : "Tabs.Weeknesses.Options"; @@ -84,15 +83,6 @@ function FoodReviewsSection({ foodId }: Props) { setFormKey((prev) => prev + 1); }; - const formatDate = (dateString: string) => { - const date = new Date(dateString); - return date.toLocaleDateString("fa-IR", { - year: "numeric", - month: "long", - day: "numeric", - }); - }; - const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -217,21 +207,8 @@ function FoodReviewsSection({ foodId }: Props) {
{reviewsLoading ? (

در حال بارگذاری نظرات...

- ) : sortedReviews.length > 0 ? ( - sortedReviews.map((review) => ( -
- -
- )) + ) : reviewThreads.length > 0 ? ( + reviewThreads.map((thread) => ) ) : (

هنوز نظری ثبت نشده است

)} diff --git a/src/app/[name]/(Main)/[id]/components/ReviewChatThread.tsx b/src/app/[name]/(Main)/[id]/components/ReviewChatThread.tsx new file mode 100644 index 0000000..01cfd02 --- /dev/null +++ b/src/app/[name]/(Main)/[id]/components/ReviewChatThread.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { Star1 } from "iconsax-react"; +import { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { ReviewThread, buildThreadMessages, formatReviewMessageTime } from "../utils/reviewHelpers"; + +type Props = { + thread: ReviewThread; +}; + +function ReviewChatThread({ thread }: Props) { + const { t } = useTranslation("rating"); + + const messages = useMemo(() => buildThreadMessages(thread), [thread]); + + const getTranslationKey = (point: string, isPositive: boolean): string => { + const basePath = isPositive ? "Tabs.Strengths.Options" : "Tabs.Weeknesses.Options"; + return `${basePath}.${point}`; + }; + + return ( +
+
    + {messages.map((message) => { + const isAdmin = message.type === "admin"; + + return ( +
  • +
    +
    {message.senderName}
    + + {message.parentMessage && ( +
    +

    {message.parentMessage.senderName}

    +

    + {message.parentMessage.content} +

    +
    + )} + + {!isAdmin && message.rating !== undefined && message.rating > 0 && ( +
    + + {message.rating} + از ۵ +
    + )} + +

    {message.content}

    + + {!isAdmin && message.positivePoints && message.positivePoints.length > 0 && ( +
    + {message.positivePoints.map((point, index) => ( + + {t(getTranslationKey(point, true))} + + ))} +
    + )} + + {!isAdmin && message.negativePoints && message.negativePoints.length > 0 && ( +
    + {message.negativePoints.map((point, index) => ( + + {t(getTranslationKey(point, false))} + + ))} +
    + )} + + {message.createdAt && ( +
    + + {formatReviewMessageTime(message.createdAt)} + +
    + )} +
    +
  • + ); + })} +
+
+ ); +} + +export default ReviewChatThread; diff --git a/src/app/[name]/(Main)/[id]/utils/reviewHelpers.ts b/src/app/[name]/(Main)/[id]/utils/reviewHelpers.ts new file mode 100644 index 0000000..1dd4247 --- /dev/null +++ b/src/app/[name]/(Main)/[id]/utils/reviewHelpers.ts @@ -0,0 +1,140 @@ +import { isReviewApproved, Review } from "../../about/types/Types"; + +export type ReviewThread = { + root: Review; + replies: Review[]; +}; + +export function getParentId(parent: Review["parent"]): string | null { + if (!parent) return null; + if (typeof parent === "string") return parent; + if (typeof parent === "object" && "id" in parent) return parent.id; + return null; +} + +export function isPopulatedReview(value: Review["parent"]): value is Review { + return Boolean(value && typeof value === "object" && "comment" in value); +} + +export function getDisplayName(firstName?: string | null, lastName?: string | null, fallback = "کاربر"): string { + return [firstName, lastName].filter(Boolean).join(" ") || fallback; +} + +export function buildReviewThreads(reviews: Review[]): ReviewThread[] { + const approved = reviews.filter(isReviewApproved); + const topLevel = approved.filter((review) => !getParentId(review.parent)); + const topLevelById = new Map(topLevel.map((review) => [review.id, review])); + + const repliesByParentId = new Map(); + const orphanReplies: Review[] = []; + + for (const review of approved) { + const parentId = getParentId(review.parent); + if (!parentId) continue; + + if (topLevelById.has(parentId)) { + const list = repliesByParentId.get(parentId) ?? []; + list.push(review); + repliesByParentId.set(parentId, list); + } else { + orphanReplies.push(review); + } + } + + const threads: ReviewThread[] = topLevel.map((root) => ({ + root, + replies: (repliesByParentId.get(root.id) ?? []).sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(), + ), + })); + + for (const reply of orphanReplies) { + if (isPopulatedReview(reply.parent)) { + threads.push({ + root: reply.parent, + replies: [reply], + }); + } + } + + return threads.sort((a, b) => new Date(b.root.createdAt).getTime() - new Date(a.root.createdAt).getTime()); +} + +export function resolveParentReview(reply: Review, threadRoot: Review): Review | null { + if (isPopulatedReview(reply.parent)) { + return reply.parent; + } + + const parentId = getParentId(reply.parent); + if (parentId && parentId === threadRoot.id) { + return threadRoot; + } + + return null; +} + +export function reviewToUserMessage(review: Review): ChatMessage { + return { + id: review.id, + type: "user", + senderName: getDisplayName(review.user?.firstName, review.user?.lastName), + content: review.comment || "نظری ثبت نشده است", + createdAt: review.createdAt, + rating: review.rating, + positivePoints: review.positivePoints, + negativePoints: review.negativePoints, + }; +} + +export type ChatMessage = { + id: string; + type: "user" | "admin"; + senderName: string; + content: string; + createdAt?: string; + rating?: number; + positivePoints?: string[]; + negativePoints?: string[]; + parentMessage?: { + senderName: string; + content: string; + }; +}; + +export function buildThreadMessages(thread: ReviewThread): ChatMessage[] { + const rootMessage = reviewToUserMessage(thread.root); + const items: ChatMessage[] = [rootMessage]; + + thread.replies.forEach((reply) => { + const parentReview = resolveParentReview(reply, thread.root); + const parentMessage = parentReview + ? { + senderName: getDisplayName(parentReview.user?.firstName, parentReview.user?.lastName), + content: parentReview.comment || "-", + } + : undefined; + + items.push({ + id: reply.id, + type: "admin", + senderName: getDisplayName(reply.admin?.firstName, reply.admin?.lastName, "رستوران"), + content: reply.comment || "-", + createdAt: reply.createdAt, + parentMessage, + }); + }); + + return items; +} + +export function formatReviewMessageTime(date?: string) { + if (!date) return ""; + + return new Date(date).toLocaleString("fa-IR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); +} diff --git a/src/app/[name]/(Main)/about/types/Types.ts b/src/app/[name]/(Main)/about/types/Types.ts index 7cec443..7382c87 100644 --- a/src/app/[name]/(Main)/about/types/Types.ts +++ b/src/app/[name]/(Main)/about/types/Types.ts @@ -107,6 +107,12 @@ export interface ReviewUser { export type ReviewStatus = "approved" | "pending" | "rejected"; +export interface ReviewAdmin { + id: string; + firstName?: string | null; + lastName?: string | null; +} + export interface Review { id: string; createdAt: string; @@ -115,12 +121,14 @@ export interface Review { isBuyer?: boolean; order?: string; food: ReviewFood; - user: ReviewUser; + user?: ReviewUser | null; + admin?: ReviewAdmin | null; comment: string; rating: number; positivePoints: string[]; negativePoints: string[]; status?: ReviewStatus; + parent?: string | { id: string } | Review | null; /** @deprecated use status */ isApproved?: boolean; }