public review

This commit is contained in:
hamid zarghami
2026-07-15 12:30:13 +03:30
parent cb2a0e85c4
commit f8bddfb428
12 changed files with 301 additions and 261 deletions
@@ -0,0 +1,243 @@
"use client";
import ToggleButton, { ToggleButtonClassNames } from "@/components/button/ToggleButton";
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 { Button } from "@/components/ui/button";
import { getToken } from "@/lib/api/func";
import { extractErrorMessage } from "@/lib/func";
import { glassSurfaceFlat } from "@/lib/styles/glassSurface";
import clsx from "clsx";
import { useParams, useRouter } from "next/navigation";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { useGetProfile } from "../../../(Profile)/profile/hooks/userProfileData";
import { NegativePointEnum, PositivePointEnum } from "../enum/ReviewPoints";
import { useCreateReview, useGetFoodReviews } from "../hooks/useFoodData";
import { CreateReviewType } from "../types/ReviewTypes";
type Props = {
foodId: string;
};
const positivePointsArray = [
PositivePointEnum.GREAT_TASTE,
PositivePointEnum.FAST_DELIVERY,
PositivePointEnum.GOOD_QUALITY,
PositivePointEnum.GOOD_PORTION_SIZE,
PositivePointEnum.FRIENDLY_SERVICE,
];
const negativePointsArray = [
NegativePointEnum.SMALL_PORTION,
NegativePointEnum.SLOW_DELIVERY,
NegativePointEnum.POOR_QUALITY,
NegativePointEnum.OVERPRICED,
NegativePointEnum.UNFRIENDLY_SERVICE,
];
function FoodReviewsSection({ foodId }: Props) {
const { t } = useTranslation("rating");
const { name } = useParams();
const router = useRouter();
const { isSuccess } = useGetProfile();
const { data: reviewsData, isLoading: reviewsLoading } = useGetFoodReviews(foodId);
const { mutate: createReview, isPending } = useCreateReview(foodId);
const [rating, setRating] = useState<number>(5);
const [comment, setComment] = useState<string>("");
const [positivePoints, setPositivePoints] = useState<string[]>([]);
const [negativePoints, setNegativePoints] = useState<string[]>([]);
const [formKey, setFormKey] = useState(0);
const reviews = (reviewsData?.data || []).filter((review) => review.isApproved);
const sortedReviews = [...reviews].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
const getTranslationKey = (point: string, isPositive: boolean): string => {
const basePath = isPositive ? "Tabs.Strengths.Options" : "Tabs.Weeknesses.Options";
return `${basePath}.${point}`;
};
const handleToggle = (pointValue: string, isPositive: boolean) => (checked: boolean) => {
const setter = isPositive ? setPositivePoints : setNegativePoints;
if (checked) {
setter((prev) => [...prev, pointValue]);
} else {
setter((prev) => prev.filter((p) => p !== pointValue));
}
};
const handleRatingChange = (value: number[]) => {
setRating(value[0]);
};
const resetForm = () => {
setRating(5);
setComment("");
setPositivePoints([]);
setNegativePoints([]);
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<HTMLFormElement>) => {
e.preventDefault();
const token = await getToken();
if (!token || !isSuccess) {
toastLoginRequired(() => router.push(`/${name}/auth`), "error");
return;
}
if (rating === 0) {
toast(t("Errors.RatingRequired") || "لطفا امتیاز خود را انتخاب کنید", "error");
return;
}
const reviewData: CreateReviewType = {
foodId,
rating,
comment,
positivePoints,
negativePoints,
};
createReview(reviewData, {
onSuccess: () => {
toast(t("Success") || "نظر شما با موفقیت ثبت شد", "success");
resetForm();
},
onError: (error) => {
toast(extractErrorMessage(error, t("Errors.SubmitFailed") || "خطا در ثبت نظر"), "error");
},
});
};
const negativePointsView = (
<div className="grid grid-cols-2 gap-x-4.5 gap-y-4 mt-8">
{negativePointsArray.map((point) => (
<ToggleButton
className={{
...ToggleButtonClassNames,
wrapperActive: "border-red-400",
contentActive: "text-red-400",
}}
key={point}
name={`weekness_${point}`}
// @ts-expect-error - Type mismatch between custom ToggleButton and React types
onToggle={handleToggle(point, false)}
>
{t(getTranslationKey(point, false))}
</ToggleButton>
))}
</div>
);
const positivePointsView = (
<div className="grid grid-cols-2 gap-x-4.5 gap-y-4 mt-8">
{positivePointsArray.map((point) => (
<ToggleButton
key={point}
name={`strength_${point}`}
// @ts-expect-error - Type mismatch between custom ToggleButton and React types
onToggle={handleToggle(point, true)}
>
{t(getTranslationKey(point, true))}
</ToggleButton>
))}
</div>
);
return (
<section>
<h2 className="text-base font-bold">ثبت نظر</h2>
<form key={formKey} onSubmit={handleSubmit} className="mt-4">
<div className={glassSurfaceFlat("rounded-2xl p-4")}>
<RateSelectionBar name="rating" defaultValue={[rating]} onValueChange={handleRatingChange} />
<div className="mt-8">
<TabContainer
defaultIndex={1}
changeType={TabContainerRenderType.VISIBILITY}
className={{
...TabContainerClassNames,
wrapper: "p-2!",
scrollView: clsx(
"border-none! rounded-xl! gap-0! h-full bg-[#EAECF0]/80! dark:bg-neutral-900/80! not-dark:gradient-border! grid! grid-cols-2 px-2! py-2! pb-2! w-full overflow-y-hidden justify-center items-center",
),
title: "text-sm2! font-normal mt-1",
header: "rounded-lg h-7! w-full",
headerActive: "bg-white dark:bg-neutral-800",
titleActive: "text-primary",
}}
>
<TabHeader title={t("Tabs.Weeknesses.Title")} viewRenderer={negativePointsView} />
<TabHeader title={t("Tabs.Strengths.Title")} viewRenderer={positivePointsView} />
</TabContainer>
<div className="w-full mt-6">
<label htmlFor="foodReviewComment" className="text-sm font-medium">
{t("InputComment.Label")}
</label>
<textarea
name="foodReviewComment"
id="foodReviewComment"
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder={t("InputComment.Placeholder")}
className={glassSurfaceFlat(
"mt-2 w-full h-21 px-4 py-2.5 text-xs text-foreground rounded-xl border-0 focus:outline-none focus:ring-2 focus:ring-accent",
)}
/>
</div>
</div>
</div>
<Button type="submit" disabled={isPending} className="mt-4 w-full rounded-xl font-normal disabled:opacity-50 disabled:cursor-not-allowed">
{isPending ? t("ButtonSubmitting") || "در حال ارسال..." : t("ButtonSubmit")}
</Button>
</form>
<div className="mt-8 pt-6 border-t border-border/60">
<h2 className="text-sm2 font-medium leading-5">نظرات کاربران</h2>
<div>
{reviewsLoading ? (
<p className="text-sm2 text-center py-8 text-disabled-text">در حال بارگذاری نظرات...</p>
) : sortedReviews.length > 0 ? (
sortedReviews.map((review) => (
<article key={review.id}>
<Comment
className="pt-6"
user={`${review.user?.firstName || ""} ${review.user?.lastName || ""}`.trim() || "کاربر"}
rating={review.rating}
date={formatDate(review.createdAt)}
text={review.comment}
tags={[]}
positivePoints={review.positivePoints}
negativePoints={review.negativePoints}
/>
</article>
))
) : (
<p className="text-sm2 text-center py-8 text-disabled-text">هنوز نظری ثبت نشده است</p>
)}
</div>
</div>
</section>
);
}
export default FoodReviewsSection;
@@ -1,5 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/Service";
import { CreateReviewType } from "../types/ReviewTypes";
export const useGetFood = (id: string) => {
return useQuery({
@@ -13,3 +14,22 @@ export const useToggleFavorite = () => {
mutationFn: api.toggleFavorite,
});
};
export const useGetFoodReviews = (foodId: string) => {
return useQuery({
queryKey: ["food-reviews", foodId],
queryFn: () => api.getFoodReviews(foodId),
enabled: !!foodId,
});
};
export const useCreateReview = (foodId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (params: CreateReviewType) => api.createReview(params),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["food-reviews", foodId] });
},
});
};
+13 -6
View File
@@ -14,6 +14,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useGetProfile } from "../../(Profile)/profile/hooks/userProfileData";
import { useGetAbout } from "../about/hooks/useAboutData";
import { useGetFood, useToggleFavorite } from "./hooks/useFoodData";
import FoodReviewsSection from "./components/FoodReviewsSection";
type Props = object;
@@ -158,12 +159,13 @@ function FoodPage({}: Props) {
};
return (
<div className="not-lg:max-w-lg not-lg:place-self-center not-lg:w-full not-lg:mb-10 lg:pt-6 not-lg:flex not-lg:flex-col lg:fixed lg:z-10 lg:top-20 xl:top-22 lg:bottom-24 lg:left-5 lg:right-4 xl:right-[285px] lg:grid lg:grid-cols-2 lg:min-h-0 lg:overflow-hidden lg:rounded-2xl">
<div
className={glassSurface(
"relative w-full lg:h-full min-h-0 rounded-2xl overflow-hidden lg:rounded-r-none lg:order-1 lg:bg-transparent! lg:border-0! lg:shadow-none! lg:[backdrop-filter:none] not-lg:sticky not-lg:top-6 not-lg:z-0 not-lg:h-[min(50vh,400px)] not-lg:min-h-[280px] not-lg:rounded-b-none",
)}
>
<div className="not-lg:max-w-lg not-lg:place-self-center not-lg:w-full w-full flex flex-col gap-4 pt-6 lg:pt-8 pb-24 lg:pb-28">
<div className="not-lg:flex not-lg:flex-col lg:grid lg:grid-cols-2 lg:min-h-[420px] lg:max-h-[480px] lg:rounded-2xl lg:overflow-hidden shrink-0">
<div
className={glassSurface(
"relative w-full lg:h-full min-h-0 rounded-2xl overflow-hidden lg:rounded-r-none lg:order-1 lg:bg-transparent! lg:border-0! lg:shadow-none! lg:[backdrop-filter:none] not-lg:sticky not-lg:top-6 not-lg:z-0 not-lg:h-[min(50vh,400px)] not-lg:min-h-[280px] not-lg:rounded-b-none",
)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={imageRef}
@@ -261,6 +263,11 @@ function FoodPage({}: Props) {
</motion.div>
</div>
</div>
</div>
<div className={glassSurfaceCard("w-full px-6 pt-6 pb-7.5 rounded-3xl bg-white/60! shrink-0")}>
<FoodReviewsSection foodId={foodId} />
</div>
</div>
);
}
@@ -1,5 +1,6 @@
import { api } from "@/config/axios";
import { FoodResponse } from "../../types/Types";
import { CreateReviewType, FoodReviewsResponse } from "../types/ReviewTypes";
export const getFood = async (id: string): Promise<FoodResponse> => {
const { data } = await api.get<FoodResponse>(`/public/foods/${id}`);
@@ -10,3 +11,13 @@ export const toggleFavorite = async (foodId: string) => {
const { data } = await api.post(`/public/foods/favorite/${foodId}`);
return data;
};
export const getFoodReviews = async (foodId: string): Promise<FoodReviewsResponse> => {
const { data } = await api.get<FoodReviewsResponse>(`/public/reviews/${foodId}`);
return data;
};
export const createReview = async (params: CreateReviewType) => {
const { data } = await api.post("/public/reviews", params);
return data;
};
@@ -0,0 +1,12 @@
import { BaseResponse } from "../../types/Types";
import { Review } from "../../about/types/Types";
export type CreateReviewType = {
foodId: string;
rating: number;
comment: string;
positivePoints: string[];
negativePoints: string[];
};
export type FoodReviewsResponse = BaseResponse<Review[]>;
@@ -12,7 +12,6 @@ import React, { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next';
import { useGetOrders } from './hooks/useHistoryData';
import { HistoryOrderItem } from './types/Types';
import { OrderStatus } from './enum/Enum';
import { glassSurface } from '@/lib/styles/glassSurface';
const fallbackImage = '/assets/images/food-preview.png';
@@ -221,11 +220,6 @@ function OrdersIndex() {
</span>
</div>
</div>
{order.status === OrderStatus.COMPLETED && (
<Link href={`rate/${order.id}?foodId=${item.food.id}`}>
<Button className='text-xs px-3 py-2 h-auto'>{t('Card.Rate')}</Button>
</Link>
)}
</div>
);
})}
@@ -1,224 +0,0 @@
'use client';
import ToggleButton, { ToggleButtonClassNames } from '@/components/button/ToggleButton';
import TabContainer, { TabContainerClassNames, TabContainerRenderType } from '@/components/tab/TabContainer';
import { TabHeader } from '@/components/tab/TabHeader';
import { Button } from '@/components/ui/button';
import RateSelectionBar from '@/components/utils/RateSelectionBar';
import clsx from 'clsx';
import { useParams, useRouter } from 'next/navigation';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCreateRate } from '../hooks/useRateData';
import { CreateRateType } from '../types/Types';
import { toast } from '@/components/Toast';
import { PositivePointEnum, NegativePointEnum } from '../enum/Enum';
import { extractErrorMessage } from '@/lib/func';
import { glassSurfaceCard } from '@/lib/styles/glassSurface';
type Props = object
type Params = {
orderId: string;
}
function RatingOrderIndex({ }: Props) {
const { t } = useTranslation("rating");
const params: Params = useParams();
const router = useRouter();
const urlParams = new URLSearchParams(window.location.search);
const { mutate: createRate, isPending } = useCreateRate();
const [rating, setRating] = useState<number>(5);
const [comment, setComment] = useState<string>('');
const [positivePoints, setPositivePoints] = useState<string[]>([]);
const [negativePoints, setNegativePoints] = useState<string[]>([]);
const positivePointsArray = [
PositivePointEnum.GREAT_TASTE,
PositivePointEnum.FAST_DELIVERY,
PositivePointEnum.GOOD_QUALITY,
PositivePointEnum.GOOD_PORTION_SIZE,
PositivePointEnum.FRIENDLY_SERVICE,
];
const negativePointsArray = [
NegativePointEnum.SMALL_PORTION,
NegativePointEnum.SLOW_DELIVERY,
NegativePointEnum.POOR_QUALITY,
NegativePointEnum.OVERPRICED,
NegativePointEnum.UNFRIENDLY_SERVICE,
];
const getTranslationKey = (point: string, isPositive: boolean): string => {
const basePath = isPositive ? 'Tabs.Strengths.Options' : 'Tabs.Weeknesses.Options';
return `${basePath}.${point}`;
};
const handleToggle = (pointValue: string, isPositive: boolean) => (checked: boolean) => {
const setter = isPositive ? setPositivePoints : setNegativePoints;
if (checked) {
setter(prev => [...prev, pointValue]);
} else {
setter(prev => prev.filter(p => p !== pointValue));
}
};
const badPoitns = () => {
return (
<>
<div className="grid grid-cols-2 gap-x-4.5 gap-y-4 mt-8">
{negativePointsArray.map((point) => (
<ToggleButton
className={{
...ToggleButtonClassNames,
wrapperActive: 'border-red-400',
contentActive: 'text-red-400'
}}
key={point}
name={`weekness_${point}`}
// @ts-expect-error - Type mismatch between custom ToggleButton and React types
onToggle={handleToggle(point, false)}
>
{t(getTranslationKey(point, false))}
</ToggleButton>
))}
</div>
</>
)
}
const goodPoitns = () => {
return (
<>
<div className="grid grid-cols-2 gap-x-4.5 gap-y-4 mt-8">
{positivePointsArray.map((point) => (
<ToggleButton
key={point}
name={`strength_${point}`}
// @ts-expect-error - Type mismatch between custom ToggleButton and React types
onToggle={handleToggle(point, true)}
>
{t(getTranslationKey(point, true))}
</ToggleButton>
))}
</div>
</>
)
}
const handleRatingChange = (value: number[]) => {
setRating(value[0]);
};
const handleCancel = () => {
router.back();
};
const submitForm = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (rating === 0) {
toast(t("Errors.RatingRequired") || "لطفا امتیاز خود را انتخاب کنید", "error");
return;
}
const rateData: CreateRateType = {
foodId: urlParams.get('foodId') || '',
orderId: params.orderId,
rating,
comment,
positivePoints,
negativePoints,
};
createRate(rateData, {
onSuccess: () => {
toast(t("Success") || "نظر شما با موفقیت ثبت شد", "success");
router.back();
},
onError: (error) => {
toast(extractErrorMessage(error), "error");
},
});
};
return (
<section className='flex flex-col h-full pt-6'>
<h1 className='font-medium'>{t("Heading")}</h1>
<form onSubmit={submitForm} className={glassSurfaceCard('flex flex-col h-min justify-between rounded-[30px] pt-10 px-6 pb-7.5 mt-4')}>
<div>
<div className='w-full'>
<RateSelectionBar
name='rating'
defaultValue={[rating]}
onValueChange={handleRatingChange}
/>
</div>
<div className='mt-10'>
<TabContainer
defaultIndex={1}
changeType={TabContainerRenderType.VISIBILITY}
className={{
...TabContainerClassNames,
wrapper: 'p-2!',
scrollView: clsx(
'border-none! rounded-xl! gap-0! h-full bg-[#EAECF0]! dark:bg-neutral-900! not-dark:gradient-border! grid! grid-cols-2 px-2! py-2! pb-2! w-full overflow-y-hidden justify-center items-center',
),
title: 'text-sm2! font-normal mt-1',
header: 'rounded-lg h-7! w-full',
headerActive: 'bg-white',
titleActive: 'text-primary'
}}>
<TabHeader title={t("Tabs.Weeknesses.Title")} viewRenderer={badPoitns()}></TabHeader>
<TabHeader title={t("Tabs.Strengths.Title")} viewRenderer={goodPoitns()}></TabHeader>
</TabContainer>
<div className="w-full mt-6">
<label htmlFor='userOpinion' className='text-sm font-medium'>
{t("InputComment.Label")}
</label>
<br />
<textarea
name='userOpinion'
id='userOpinion'
value={comment}
onChange={(e) => setComment(e.target.value)}
placeholder={t("InputComment.Placeholder")}
className='dark:border-neutral-600 dark:bg-neutral-800 mt-2 w-full h-21 px-4 py-2.5 text-xs text-foreground rounded-xl border border-[#D0D0D0] focus:outline-none focus:ring-2 focus:ring-accent'
/>
<br />
</div>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mt-6">
<Button
type='submit'
disabled={isPending}
className='mt-auto rounded-xl font-normal cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed'
>
{isPending ? t("ButtonSubmitting") || "در حال ارسال..." : t("ButtonSubmit")}
</Button>
<Button
type='button'
onClick={handleCancel}
disabled={isPending}
className='
mt-auto rounded-xl bg-white dark:bg-container cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700
border border-foreground dark:border-neutral-600 text-foreground font-normal disabled:opacity-50 disabled:cursor-not-allowed'
>
{t("ButtonCancel")}
</Button>
</div>
</form>
</section>
)
}
export default RatingOrderIndex
@@ -1,8 +0,0 @@
import { useMutation } from "@tanstack/react-query";
import * as api from "../service/RateService";
export const useCreateRate = () => {
return useMutation({
mutationFn: api.createRate,
});
};
@@ -1,7 +0,0 @@
import { api } from "@/config/axios";
import { CreateRateType } from "../types/Types";
export const createRate = async (params: CreateRateType) => {
const { data } = await api.post("/public/reviews", params);
return data;
};
@@ -1,8 +0,0 @@
export type CreateRateType = {
foodId: string;
orderId: string;
rating: number;
comment: string;
positivePoints: string[];
negativePoints: string[];
};
+1 -1
View File
@@ -1,6 +1,6 @@
// export const API_BASE_URL = "https://dmenuplus-api-production.dev.danakcorp.com";
export const API_BASE_URL = "http://192.168.1.107:2000";
// export const API_BASE_URL = "https://dmenu-api.danakcorp.com";
export const API_BASE_URL = "https://dmenu-api.danakcorp.com";
process.env.NEXT_PUBLIC_API_URL ?? (process.env.NODE_ENV === "development" ? "http://192.168.99.131:2000" : "https://dmenu-api.danakcorp.com");
export const TOKEN_NAME = "dmenu-t";
export const REFRESH_TOKEN_NAME = "dmenu-rt";