add comments

This commit is contained in:
hamid zarghami
2025-09-07 15:37:00 +03:30
parent 42e5966e32
commit 554312ddc6
11 changed files with 460 additions and 12 deletions
+54
View File
@@ -23,6 +23,7 @@
"iconsax-react": "^0.0.8",
"lucide-react": "^0.539.0",
"next": "15.4.5",
"rc-rate": "^2.13.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
@@ -74,6 +75,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.28.4",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@emnapi/core": {
"version": "1.4.5",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.5.tgz",
@@ -2882,6 +2892,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@@ -5773,6 +5789,44 @@
],
"license": "MIT"
},
"node_modules/rc-rate": {
"version": "2.13.1",
"resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz",
"integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.10.1",
"classnames": "^2.2.5",
"rc-util": "^5.0.1"
},
"engines": {
"node": ">=8.x"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/rc-util": {
"version": "5.44.4",
"resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz",
"integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.18.3",
"react-is": "^18.2.0"
},
"peerDependencies": {
"react": ">=16.9.0",
"react-dom": ">=16.9.0"
}
},
"node_modules/rc-util/node_modules/react-is": {
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT"
},
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
+1
View File
@@ -24,6 +24,7 @@
"iconsax-react": "^0.0.8",
"lucide-react": "^0.539.0",
"next": "15.4.5",
"rc-rate": "^2.13.1",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-hook-form": "^7.62.0",
+20
View File
@@ -244,3 +244,23 @@ textarea.place-black::placeholder {
.filterBlack {
filter: brightness(0) saturate(100%);
}
.modalGlass {
background: rgba(0, 0, 0, 0.436);
background-blend-mode: multiply;
backdrop-filter: blur(7px);
}
.modalGlass2 {
background: rgba(255, 255, 255, 0.502);
background-blend-mode: multiply;
/* backdrop-filter: blur(5px); */
}
.modalGlass3 {
background: rgba(62, 61, 61, 0.2);
background-blend-mode: multiply;
backdrop-filter: blur(44px);
}
+255
View File
@@ -0,0 +1,255 @@
import DefaulModal from '@/components/DefaulModal'
import Input from '@/components/Input'
import { Button } from '@/components/ui/button'
import { FC, useState } from 'react'
import { useForm } from 'react-hook-form'
import { AddCommentProps } from '../types/Types'
import * as yup from 'yup'
import { yupResolver } from '@hookform/resolvers/yup'
import { Add } from 'iconsax-react'
import { PRIMARY_COLOR } from '@/config/const'
import Rate from 'rc-rate'
import 'rc-rate/assets/index.css'
import { Textarea } from '@/components/ui/textarea'
import { useParams } from 'next/navigation'
import { useAddComment, useGetDetailProduct } from '../hooks/useProductData'
import { toast } from '@/components/Toast'
import { extractErrorMessage } from '@/helpers/errorUtils'
type Props = {
open: boolean,
close: () => void,
}
const AddComment: FC<Props> = (props) => {
const { open, close } = props
const { id } = useParams()
const [advantages, setAdvantages] = useState<string[]>([])
const [disadvantages, setDisadvantages] = useState<string[]>([])
const [currentAdvantage, setCurrentAdvantage] = useState<string>('')
const [currentDisadvantage, setCurrentDisadvantage] = useState<string>('')
const [rating, setRating] = useState<number>(0)
const { refetch } = useGetDetailProduct(id as string)
const { mutate: addComment, isPending } = useAddComment()
const validationSchema = yup.object({
title: yup.string().required('عنوان خود را وارد کنید'),
advantage: yup.array().of(yup.string().required()).required('مزایای خود را وارد کنید'),
disAdvantage: yup.array().of(yup.string().required()).required('معایب خود را وارد کنید'),
content: yup.string().required('محتوای خود را وارد کنید'),
rate: yup.number().required('رای خود را وارد کنید'),
productId: yup.string().optional()
})
const {
register,
handleSubmit,
formState: { errors },
setValue
} = useForm({
resolver: yupResolver(validationSchema),
defaultValues: {
title: '',
advantage: [],
disAdvantage: [],
content: '',
rate: 0,
productId: id as string
}
})
const addAdvantage = () => {
if (currentAdvantage.trim()) {
const newAdvantages = [...advantages, currentAdvantage.trim()]
setAdvantages(newAdvantages)
setValue('advantage', newAdvantages)
setCurrentAdvantage('')
}
}
const removeAdvantage = (index: number) => {
const newAdvantages = advantages.filter((_, i) => i !== index)
setAdvantages(newAdvantages)
setValue('advantage', newAdvantages)
}
const addDisadvantage = () => {
if (currentDisadvantage.trim()) {
const newDisadvantages = [...disadvantages, currentDisadvantage.trim()]
setDisadvantages(newDisadvantages)
setValue('disAdvantage', newDisadvantages)
setCurrentDisadvantage('')
}
}
const removeDisadvantage = (index: number) => {
const newDisadvantages = disadvantages.filter((_, i) => i !== index)
setDisadvantages(newDisadvantages)
setValue('disAdvantage', newDisadvantages)
}
const handleRating = (rate: number) => {
setRating(rate)
setValue('rate', rate, { shouldValidate: true, shouldDirty: true })
}
const onSubmit = (data: AddCommentProps) => {
addComment(data, {
onSuccess: (res) => {
toast(res?.results?.message, 'success')
refetch()
close()
},
onError: (error: Error) => {
toast(extractErrorMessage(error), 'error')
}
})
}
return (
<DefaulModal
open={open}
close={close}
isHeader
title_header='ثبت دیدگاه'
>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* عنوان نظر */}
<Input
label="عنوان نظر"
{...register('title')}
error_text={errors.title?.message}
placeholder="عنوان نظر خود را وارد کنید"
/>
<div className='relative'>
<Input
value={currentAdvantage}
onChange={(e) => setCurrentAdvantage(e.target.value)}
placeholder="نکته مثبت خود را وارد کنید"
className="flex-1"
label="نکته مثبت"
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addAdvantage()
}
}}
/>
<button
type="button"
onClick={addAdvantage}
className="absolute top-[37px] left-3 rounded"
>
<Add size={22} color={PRIMARY_COLOR} />
</button>
</div>
{/* لیست نکات مثبت اضافه شده */}
{advantages.length > 0 && (
<div className="space-y-2 flex gap-4 flex-wrap">
{advantages.map((advantage, index) => (
<div key={index} className="flex min-w-[45%] flex-1 items-center justify-between bg-gray-50 py-1 px-4 rounded-lg">
<span className="text-sm text-gray-700">{advantage}</span>
<button
type="button"
onClick={() => removeAdvantage(index)}
className="text-red-500 hover:text-red-700 p-1"
>
×
</button>
</div>
))}
<div className='flex-1 min-w-[45%] px-4'></div>
</div>
)}
{/* نکات منفی */}
<div className='relative'>
<Input
value={currentDisadvantage}
onChange={(e) => setCurrentDisadvantage(e.target.value)}
placeholder="نکته منفی خود را وارد کنید"
className="flex-1"
label="نکته منفی"
onKeyPress={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addDisadvantage()
}
}}
/>
<button
type="button"
onClick={addDisadvantage}
className="absolute top-[37px] left-3 rounded"
>
<Add size={22} color={PRIMARY_COLOR} />
</button>
</div>
{/* لیست نکات منفی اضافه شده */}
{disadvantages.length > 0 && (
<div className="space-y-2 flex gap-4 flex-wrap">
{disadvantages.map((disadvantage, index) => (
<div key={index} className="flex min-w-[45%] flex-1 items-center justify-between bg-gray-50 py-1 px-4 rounded-lg">
<span className="text-sm text-gray-700">{disadvantage}</span>
<button
type="button"
onClick={() => removeDisadvantage(index)}
className="text-red-500 hover:text-red-700 p-1"
>
×
</button>
</div>
))}
<div className='flex-1 min-w-[45%] px-4'></div>
</div>
)}
{/* امتیاز */}
<div className="space-y-2 flex gap-1 items-center">
<label className="text-sm text-black">امتیاز شما:</label>
<div className='-mt-1.5'>
<Rate
value={rating}
onChange={handleRating}
count={5}
allowHalf={false}
style={{ fontSize: 20 }}
/>
</div>
<input
type="hidden"
{...register('rate')}
value={rating}
/>
</div>
{/* توضیحات */}
<Textarea
{...register('content')}
placeholder="متن توضیحات خود را بنویسید"
className="w-full h-24 p-3 text-sm rounded-xl border border-[#D0D0D0] resize-none focus:outline-none focus:ring-2 focus:ring-primary/20"
error_text={errors.content?.message}
/>
{/* دکمه ثبت */}
<Button
type="submit"
className="w-full"
isLoading={isPending}
>
ثبت دیدگاه
</Button>
</form>
</DefaulModal>
)
}
export default AddComment
+5 -2
View File
@@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import { Star1 } from 'iconsax-react'
import { Product, Review } from '@/types/product.types'
import AddComment from './AddComment'
interface ReviewsProps {
product: Product
@@ -12,7 +13,7 @@ interface ReviewsProps {
const Reviews: FC<ReviewsProps> = ({ reviews = [] }) => {
const [sort, setSort] = useState<'new' | 'helpful'>('new')
const [open, setOpen] = useState(false)
const sortedReviews = useMemo(() => {
if (sort === 'new') {
return [...reviews].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
@@ -111,10 +112,12 @@ const Reviews: FC<ReviewsProps> = ({ reviews = [] }) => {
<div className="mt-3 sm:mt-4 text-xs text-[#7F7F7F] leading-5 sm:leading-6 text-justify">
شما هم درباره این کالا نظر خود را ثبت کنید. نظر شما پس از بررسی و تایید نمایش داده میشود.
</div>
<Button className="w-full mt-4 sm:mt-5 text-xs sm:text-sm">ثبت دیدگاه</Button>
<Button className="w-full mt-4 sm:mt-5 text-xs sm:text-sm" onClick={() => setOpen(true)}>ثبت دیدگاه</Button>
</div>
</div>
</div>
<AddComment open={open} close={() => setOpen(false)} />
</div>
)
}
+7 -1
View File
@@ -1,4 +1,4 @@
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery } from "@tanstack/react-query";
import * as api from "../service/Service";
export const useGetDetailProduct = (id: string) => {
@@ -12,3 +12,9 @@ export const getProductQueryOptions = (id: string) => ({
queryKey: ["product", id],
queryFn: () => api.getProduct(id),
});
export const useAddComment = () => {
return useMutation({
mutationFn: api.addComment,
});
};
+9
View File
@@ -1,5 +1,6 @@
import axios from "@/config/axios";
import { ProductDetailResponse } from "@/types/product.types";
import { AddCommentProps } from "../types/Types";
export const getProduct = async (
id: string
@@ -7,3 +8,11 @@ export const getProduct = async (
const { data } = await axios.get(`/product/${id}`);
return data;
};
export const addComment = async (params: AddCommentProps) => {
const { data } = await axios.post(
`/product/${params.productId}/comments`,
params
);
return data;
};
+8
View File
@@ -0,0 +1,8 @@
export type AddCommentProps = {
title: string;
advantage: string[];
disAdvantage: string[];
content: string;
rate: number;
productId?: string;
};
+57
View File
@@ -0,0 +1,57 @@
import { FC, Fragment, ReactNode, useEffect } from 'react'
import HeaderModal from './HeaderModal'
interface Props {
open: boolean,
close: () => void,
children: ReactNode,
isHeader?: boolean,
title_header?: string,
width?: number
}
const DefaulModal: FC<Props> = (props: Props) => {
useEffect(() => {
if (props.open) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = 'auto'
}
}, [props.open])
return (
<Fragment>
{
props.open && (
<Fragment>
<div style={{ maxWidth: props.width }} className='xl:justify-center xl:items-center items-end flex overflow-x-hidden overflow-y-auto fixed inset-0 z-[99999999999] h-auto top-0 bottom-0 m-auto outline-none focus:outline-none xl:max-w-xl mx-auto'>
<div className='relative xl:h-full max-h-[80%] bottom-0 left-0 flex xl:items-center sm:h-auto w-full xl:my-6 xl:p-2'>
<div className='border-0 h-auto p-5 lg:min-w-full overflow-y-auto rounded-3xl rounded-b-none xl:rounded-b-3xl relative flex flex-col w-full bg-white max-h-[100%] outline-none focus:outline-none'>
{
props.isHeader && props.title_header &&
<div onClick={props.close} className='pb-6 border-b border-white border-opacity-20'>
<div className='h-[5px] w-[200px] mx-auto bg-[#D1D3D7] rounded-full mb-4 xl:hidden'></div>
<HeaderModal close={props.close} label={props.title_header} />
</div>
}
{props.children}
</div>
</div>
</div>
<div onClick={props.close} className='fixed size-full top-0 bottom-0 right-0 modalGlass inset-0 z-[99999] '></div>
</Fragment>
)
}
</Fragment>
)
}
export default DefaulModal
+21
View File
@@ -0,0 +1,21 @@
import { CloseCircle } from 'iconsax-react'
import { FC } from 'react'
type Props = {
label: string,
close: () => void,
}
const HeaderModal: FC<Props> = (props: Props) => {
return (
<div className='flex justify-between items-center border-b border-border pb-4'>
<div className='text-sm'>{props.label}</div>
<div className='size-7 rounded-full flex justify-center items-center'>
<CloseCircle size={20} color='black' onClick={props.close} />
</div>
</div>
)
}
export default HeaderModal
+23 -9
View File
@@ -1,17 +1,31 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import Error from "../Error"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
interface TextareaProps extends React.ComponentProps<"textarea"> {
error_text?: string
}
function Textarea({ className, error_text, ...props }: TextareaProps) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
<div className="w-full">
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
error_text && "border-destructive focus-visible:border-destructive focus-visible:ring-destructive/20",
className
)}
{...props}
/>
{
error_text &&
<Error
errorText={error_text}
/>
}
</div>
)
}