comment + favorive

This commit is contained in:
hamid zarghami
2025-09-17 12:29:58 +03:30
parent bc22bbd6d9
commit f849ebdb42
15 changed files with 562 additions and 85 deletions
+31
View File
@@ -33,6 +33,7 @@
"react-hook-form": "^7.62.0",
"react-infinite-scroll-component": "^6.1.0",
"react-leaflet": "^5.0.0",
"react-multi-date-picker": "^4.5.2",
"react-otp-input": "^3.1.1",
"react-spinners": "^0.17.0",
"swiper": "^11.2.10",
@@ -6244,6 +6245,12 @@
"node": ">=0.10.0"
}
},
"node_modules/react-date-object": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/react-date-object/-/react-date-object-2.1.9.tgz",
"integrity": "sha512-BHxD/quWOTo9fLKV/cfL/M31ePoj4a1JaJ/CnOf8Ndg3mrkh4x9wEMMkCfTrzduxDOgU8ZgR8uarhqI5G71sTg==",
"license": "MIT"
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
@@ -6256,6 +6263,16 @@
"react": "^19.1.0"
}
},
"node_modules/react-element-popper": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/react-element-popper/-/react-element-popper-2.1.7.tgz",
"integrity": "sha512-tuM2OxKlW32h+6uFSK6EENHPeZ2OGgOipHfOAl+VLWEv9/j3QkSGbD+ADX3A9uJlmq24i37n28RjJmAbGTfpEg==",
"license": "MIT",
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-hook-form": {
"version": "7.62.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz",
@@ -6303,6 +6320,20 @@
"react-dom": "^19.0.0"
}
},
"node_modules/react-multi-date-picker": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/react-multi-date-picker/-/react-multi-date-picker-4.5.2.tgz",
"integrity": "sha512-FgWjZB3Z6IA6XpcWiLPk85PwcRUhOiYhKK42o5k672gD/n2I6rzPfQ8bUrldOIiF/Z7FfOCdH7a6FeubzqteLg==",
"license": "MIT",
"dependencies": {
"react-date-object": "^2.1.8",
"react-element-popper": "^2.1.6"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/react-otp-input": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/react-otp-input/-/react-otp-input-3.1.1.tgz",
+1
View File
@@ -34,6 +34,7 @@
"react-hook-form": "^7.62.0",
"react-infinite-scroll-component": "^6.1.0",
"react-leaflet": "^5.0.0",
"react-multi-date-picker": "^4.5.2",
"react-otp-input": "^3.1.1",
"react-spinners": "^0.17.0",
"swiper": "^11.2.10",
+1 -1
View File
@@ -16,6 +16,6 @@ export const verifyOtp = async (params: VerifyOtpType) => {
};
export const refreshToken = async (params: RefreshTokenType) => {
const { data } = await axios.post(`/auth/refresh`, params);
const { data } = await axios.post(`/user/token`, params);
return data;
};
+17
View File
@@ -265,3 +265,20 @@ textarea.place-black::placeholder {
backdrop-filter: blur(44px);
}
.rmdp-input {
min-height: 40px;
border-radius: 12px !important;
/* border: 1px solid #d0d0d0 !important; */
font-size: 12px !important;
width: 100% !important;
margin: 0px;
padding-right: 16px !important;
}
.readOny .rmdp-input {
background-color: #f5f5f5 !important;
border: none !important;
}
.rmdp-container {
width: 100%;
}
+1
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import "./globals.css";
import "../../public/css/fontiran.css";
import 'swiper/css';
import 'rc-rate/assets/index.css'
import QueryProvider from "@/config/QueryProvider";
import ToastContainer from "@/components/Toast";
import MobileBottomMenu from "@/components/MobileBottomMenu";
@@ -9,7 +9,6 @@ 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 } from '../hooks/useProductData'
+11 -1
View File
@@ -1,4 +1,4 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/Service";
import { ProductsResponse } from "@/types/products.types";
@@ -36,13 +36,23 @@ export const useCategoryProductsData = (
};
export const useAddWishlist = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.addWishlist,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["with-list"] });
},
});
};
export const useRemoveWishlist = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: api.removeWishlist,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["with-list"] });
},
});
};
+123 -71
View File
@@ -1,85 +1,137 @@
'use client'
import Layout from '@/hoc/Layout'
import { NextPage } from 'next'
import Menu from '../components/Menu'
import Image from 'next/image'
import PageLoading from '@/components/PageLoading'
import { useGetComments } from '../hooks/useProfileData'
import { Comment } from '../types/ProfileTypes'
import Rate from 'rc-rate'
const CommentItem: React.FC<{ comment: Comment }> = ({ comment }) => {
const formatDate = (dateString: string) => {
const date = new Date(dateString)
return date.toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
return (
<div className='bg-white rounded-[20px] p-4 sm:p-6 mb-4 sm:mb-6 shadow-sm border border-border'>
<div className='flex flex-col sm:flex-row gap-4 sm:gap-6'>
<div className='flex justify-center sm:justify-start'>
<Image
src={comment.product.imagesUrl.cover || 'https://picsum.photos/200/300'}
alt={comment.product.title_fa}
width={120}
height={120}
className='max-w-[100px] sm:max-w-[120px] max-h-[100px] sm:max-h-[120px] object-contain rounded-lg'
/>
</div>
<div className='flex-1'>
<div className='flex flex-col sm:flex-row sm:items-start justify-between gap-2 mb-3'>
<h3 className='text-sm sm:text-base font-medium text-[#383E43] leading-5'>
{comment.product.title_fa}
</h3>
<div className='flex items-center gap-2'>
<Rate value={comment.rate} disabled />
</div>
</div>
<div className='flex flex-wrap items-center gap-2 text-xs text-[#999999] mb-4'>
<span>{comment.user.fullName}</span>
<span className="size-1 rounded-full bg-[#E5E5E5]"></span>
<span>{formatDate(comment.createdAt)}</span>
<span className="size-1 rounded-full bg-[#E5E5E5]"></span>
<span className='px-2 py-0.5 rounded bg-[#F1F1F1] text-[#7F7F7F]'>خریدار</span>
</div>
{comment.title && (
<div className='text-sm font-medium text-[#333333] mb-2'>
{comment.title}
</div>
)}
{comment.content && (
<div className='text-xs sm:text-sm text-[#666666] leading-6 sm:leading-7 mb-3'>
{comment.content}
</div>
)}
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
{comment.advantage && comment.advantage.length > 0 && (
<div className='bg-green-50 rounded-lg p-3'>
<div className='text-xs sm:text-sm text-green-700 font-medium mb-2'>مزایا:</div>
<ul className='text-xs sm:text-sm text-green-600 space-y-1'>
{comment.advantage.map((adv, index) => (
<li key={index} className='flex items-start gap-2'>
<span className='text-green-500 mt-0.5'></span>
<span>{adv}</span>
</li>
))}
</ul>
</div>
)}
{comment.disAdvantage && comment.disAdvantage.length > 0 && (
<div className='bg-red-50 rounded-lg p-3'>
<div className='text-xs sm:text-sm text-red-700 font-medium mb-2'>معایب:</div>
<ul className='text-xs sm:text-sm text-red-600 space-y-1'>
{comment.disAdvantage.map((disadv, index) => (
<li key={index} className='flex items-start gap-2'>
<span className='text-red-500 mt-0.5'></span>
<span>{disadv}</span>
</li>
))}
</ul>
</div>
)}
</div>
</div>
</div>
</div>
)
}
const Comments: NextPage = () => {
const { data: comments, isLoading } = useGetComments()
if (isLoading) {
return <PageLoading />
}
if (!comments?.results?.comments?.length) {
return (
<div className='p-3 sm:p-4 md:p-6'>
<Menu pageActive='comments' />
<div className='mt-6 sm:mt-8 md:mt-10 lg:mt-14'>
<div className='bg-white rounded-[20px] p-8 sm:p-12 text-center shadow-sm border border-border'>
<div className='text-lg sm:text-xl text-gray-600 mb-2'>
کامنتی یافت نشد
</div>
<div className='text-sm text-gray-500'>
شما هنوز کامنتی ثبت نکردهاید
</div>
</div>
</div>
</div>
)
}
return (
<div className='p-3 sm:p-4 md:p-6'>
<Menu pageActive='comments' />
<div className='mt-6 sm:mt-8 md:mt-10 lg:mt-14'>
<div className='flex flex-col sm:flex-row gap-3 sm:gap-4 lg:gap-6 border-b border-border pb-4 sm:pb-6'>
<Image
src='https://picsum.photos/200/300'
alt='comment'
width={100}
height={100}
className='max-w-16 sm:max-w-20 max-h-16 sm:max-h-20 object-contain mx-auto sm:mx-0'
/>
<div className='flex-1'>
<div className='border-b border-border pb-3 sm:pb-4'>
<div className='text-sm sm:text-base'>
نان بربری سنتی سبوس دار
</div>
<div className='mt-1.5 text-xs sm:text-sm text-[#999999]'>
2 آذر 1401
</div>
</div>
<div className='mt-3 sm:mt-4 text-[#4C4C4C] text-xs sm:text-sm'>
خیلی راضیم از خریدم
</div>
</div>
</div>
<div className='flex flex-col sm:flex-row gap-3 sm:gap-4 lg:gap-6 border-b border-border py-4 sm:py-6'>
<Image
src='https://picsum.photos/200/300'
alt='comment'
width={100}
height={100}
className='max-w-16 sm:max-w-20 max-h-16 sm:max-h-20 object-contain mx-auto sm:mx-0'
/>
<div className='flex-1'>
<div className='border-b border-border pb-3 sm:pb-4'>
<div className='text-sm sm:text-base'>
نان بربری سنتی سبوس دار
</div>
<div className='mt-1.5 text-xs sm:text-sm text-[#999999]'>
2 آذر 1401
</div>
</div>
<div className='mt-3 sm:mt-4 text-[#4C4C4C] text-xs sm:text-sm'>
خیلی راضیم از خریدم
</div>
</div>
</div>
<div className='flex flex-col sm:flex-row gap-3 sm:gap-4 lg:gap-6 border-b border-border py-4 sm:py-6'>
<Image
src='https://picsum.photos/200/300'
alt='comment'
width={100}
height={100}
className='max-w-16 sm:max-w-20 max-h-16 sm:max-h-20 object-contain mx-auto sm:mx-0'
/>
<div className='flex-1'>
<div className='border-b border-border pb-3 sm:pb-4'>
<div className='text-sm sm:text-base'>
نان بربری سنتی سبوس دار
</div>
<div className='mt-1.5 text-xs sm:text-sm text-[#999999]'>
2 آذر 1401
</div>
</div>
<div className='mt-3 sm:mt-4 text-[#4C4C4C] text-xs sm:text-sm'>
خیلی راضیم از خریدم
</div>
</div>
<div className='space-y-4 sm:space-y-6'>
{comments?.results?.comments.map((comment) => (
<CommentItem key={comment._id} comment={comment} />
))}
</div>
</div>
</div>
+72 -6
View File
@@ -1,21 +1,87 @@
'use client'
import Layout from "@/hoc/Layout"
import { NextPage } from "next"
import Menu from "../components/Menu"
import GridWrapper from "@/components/GridWrapper"
import ProductCard from "@/components/ProductCard"
import { useGetWithList } from "../hooks/useProfileData"
import { IProduct } from "@/types/landing.types"
import { WishlistItem } from "../types/ProfileTypes"
const FavoritePage: NextPage = () => {
const { data: withList, isLoading } = useGetWithList()
// تبدیل داده‌های wishlist به فرمت IProduct
const transformWishlistToProducts = (wishlistItems: WishlistItem[]): IProduct[] => {
return wishlistItems.map((item) => ({
_id: item.product._id,
title_fa: item.product.title_fa,
title_en: item.product.title_en,
seoTitle: item.product.seoTitle,
seoDescription: item.product.seoDescription,
model: '', // این فیلد در wishlist وجود ندارد
description: item.product.description,
tags: item.product.tags,
shop: {
_id: item.variant.shop._id,
shopName: item.variant.shop.shopName,
shopDescription: item.variant.shop.shopDescription,
logo: item.variant.shop.logo,
},
status: 'active', // فرض می‌کنیم فعال است
imagesUrl: item.product.imagesUrl,
url: item.product.url,
default_variant: {
_id: item.variant._id,
market_status: item.variant.market_status,
price: item.variant.price,
stock: item.variant.stock,
isFreeShip: item.variant.isFreeShip,
isWholeSale: item.variant.isWholeSale,
},
variants: [{
_id: item.variant._id,
market_status: item.variant.market_status as "Marketable" | "Unmarketable" | "OutOfStock",
price: item.variant.price,
stock: item.variant.stock,
postingTime: item.variant.postingTime,
isFreeShip: item.variant.isFreeShip,
isWholeSale: item.variant.isWholeSale,
shop: {
_id: item.variant.shop._id,
shopName: item.variant.shop.shopName,
shopDescription: item.variant.shop.shopDescription,
logo: item.variant.shop.logo,
},
shipmentMethod: [], // این فیلد در wishlist وجود ندارد
warranty: item.variant.warranty,
meterage: item.variant.meterage ? { _id: 0, value: item.variant.meterage.toString() } : undefined,
size: item.variant.size ? { _id: 0, value: item.variant.size.toString() } : undefined,
}],
isWishlist: true, // چون در لیست علاقه‌مندی‌ها است
}))
}
const products = withList?.results?.wishlist ? transformWishlistToProducts(withList.results.wishlist) : []
return (
<div className="p-3 sm:p-4 md:p-6">
<Menu pageActive='favorite' />
<GridWrapper desktop={5} mobile={2} className="mt-6 sm:mt-8 md:mt-10 lg:mt-14">
<ProductCard />
<ProductCard />
<ProductCard />
<ProductCard />
</GridWrapper>
{products.length === 0 && !isLoading ? (
<div className="flex flex-col items-center justify-center mt-12 text-center">
<div className="text-gray-400 text-lg mb-4">لیست علاقهمندیهای شما خالی است</div>
<div className="text-gray-500 text-sm">محصولات مورد علاقه خود را اضافه کنید</div>
</div>
) : (
<GridWrapper desktop={5} mobile={2} className="mt-6 sm:mt-8 md:mt-10 lg:mt-14">
{products.map((product) => (
<ProductCard key={product._id} item={product} />
))}
</GridWrapper>
)}
</div>
)
}
+14
View File
@@ -38,3 +38,17 @@ export const useUpdateAddress = () => {
api.updateAddress(addressData),
});
};
export const useGetWithList = () => {
return useQuery({
queryKey: ["with-list"],
queryFn: api.getWithList,
});
};
export const useGetComments = () => {
return useQuery({
queryKey: ["comments"],
queryFn: api.getComments,
});
};
+7 -4
View File
@@ -7,11 +7,12 @@ import { Button } from '@/components/ui/button'
import Input from '@/components/Input'
import { Edit2 } from 'lucide-react'
import Layout from '@/hoc/Layout'
import { Heart, ShoppingCart, Edit } from 'iconsax-react'
import { Heart, ShoppingCart } from 'iconsax-react'
import Menu from './components/Menu'
import AddressModal from './components/AddressModal'
import { useGetProfile, useUpdateProfile } from './hooks/useProfileData'
import { UpdateAddressType } from './types/ProfileTypes'
import DatePickerComponent from '@/components/DatePicker'
// Schema validation
const profileSchema = yup.object({
@@ -146,11 +147,13 @@ const ProfilePage = () => {
</div>
<div>
<Input
<DatePickerComponent
label="تاریخ تولد"
{...register('dateOfBirth')}
readOnly={!isEditing}
placeholder="تاریخ تولد خود را انتخاب کنید"
onChange={(date) => setValue('dateOfBirth', date)}
defaulValue={profile?.results?.info?.dateOfBirth || ''}
error_text={errors.dateOfBirth?.message}
readOnly={!isEditing}
/>
</div>
</div>
+12
View File
@@ -5,6 +5,8 @@ import {
UpdateProfileResponse,
AddressLocationResponse,
UpdateAddressType,
WishlistResponse,
CommentsResponse,
} from "../types/ProfileTypes";
export const getProfile = async () => {
@@ -31,3 +33,13 @@ export const updateAddress = async (addressData: UpdateAddressType) => {
const { data } = await axios.post("/address/user/save", addressData);
return data;
};
export const getWithList = async (): Promise<WishlistResponse> => {
const { data } = await axios.get("/user/profile/wishlist");
return data;
};
export const getComments = async (): Promise<CommentsResponse> => {
const { data } = await axios.get("/user/profile/comments");
return data;
};
+190
View File
@@ -90,3 +90,193 @@ export type UpdateAddressType = {
address: string;
plaque: string;
};
// Wishlist Types
export interface WishlistUser {
_id: string;
fullName: string;
phoneNumber: string;
dateOfBirth: string;
address: {
_id: string;
};
nationalCode: string;
homeNumber: string | null;
}
export interface WishlistBrand {
_id: string;
}
export interface WishlistCategory {
_id: string;
}
export interface WishlistSpecification {
title: string;
values: string[];
}
export interface WishlistImagesUrl {
cover: string;
list: string[];
}
export interface WishlistProduct {
_id: number;
url: string;
title_fa: string;
title_en: string;
seoTitle: string | null;
seoDescription: string | null;
source: string;
description: string;
metaDescription: string;
tags: string[];
advantages: string[];
disAdvantages: string[];
imagesUrl: WishlistImagesUrl;
isFake: string;
voice: string | null;
specifications: WishlistSpecification[];
brand: WishlistBrand;
category: WishlistCategory;
}
export interface WishlistShop {
_id: string;
shopName: string;
shopCode: number;
owner: string;
shopDescription: string;
telephoneNumber: string;
isChatActive: boolean;
shopPostalCode: string;
logo: string;
}
export interface WishlistWarranty {
_id: number;
duration: string;
logoUrl: string;
name: string;
}
export interface WishlistPrice {
order_limit: number;
retailPrice: number;
selling_price: number;
is_specialSale: boolean;
discount_percent: number;
specialSale_order_limit: number | null;
specialSale_quantity: number | null;
specialSale_endDate: string | null;
}
export interface WishlistVariant {
_id: string;
market_status: string;
price: WishlistPrice;
stock: number;
postingTime: number;
isFreeShip: boolean;
isWholeSale: boolean;
shop: WishlistShop;
warranty: WishlistWarranty;
size?: number;
meterage?: number;
}
export interface WishlistItem {
_id: string;
user: WishlistUser;
product: WishlistProduct;
variant: WishlistVariant;
}
export interface WishlistResponse {
status: number;
success: boolean;
results: {
wishlist: WishlistItem[];
};
}
// Comments Types
export interface CommentUser {
fullName: string;
}
export interface CommentBrand {
_id: string;
status: string;
title_en: string;
title_fa: string;
images: string[];
logoUrl: string;
}
export interface CommentCategory {
_id: string;
title_fa: string;
title_en: string;
icon: string;
imageUrl: string;
theme: string;
description: string;
leaf: boolean;
parent: string;
}
export interface CommentSpecification {
title: string;
values: string[];
}
export interface CommentImagesUrl {
cover: string;
list: string[];
}
export interface CommentProduct {
_id: number;
url: string;
title_fa: string;
title_en: string;
seoTitle: string;
seoDescription: string | null;
source: string;
description: string;
metaDescription: string;
tags: string[];
advantages: string[];
disAdvantages: string[];
imagesUrl: CommentImagesUrl;
isFake: string;
voice: string | null;
specifications: CommentSpecification[];
brand: CommentBrand;
category: CommentCategory;
createdAt: string;
}
export interface Comment {
_id: string;
title: string;
advantage: string[];
disAdvantage: string[];
content: string;
status: string;
rate: number;
user: CommentUser;
product: CommentProduct;
createdAt: string;
}
export interface CommentsResponse {
status: number;
success: boolean;
results: {
comments: Comment[];
};
}
+78
View File
@@ -0,0 +1,78 @@
import { useState, useEffect, FC } from 'react';
import DatePicker from 'react-multi-date-picker';
import persian from 'react-date-object/calendars/persian';
import persian_fa from 'react-date-object/locales/persian_fa';
import DateObject from 'react-date-object';
import { clx } from '../helpers/utils';
type Props = {
onChange: (date: string) => void;
defaulValue?: string;
error_text?: string;
placeholder: string;
reset?: boolean;
isDateTime?: boolean;
className?: string;
label: string,
readOnly?: boolean;
};
const DatePickerComponent: FC<Props> = (props: Props) => {
const [value, setValue] = useState<DateObject | null>(null);
useEffect(() => {
if (props.reset) {
setValue(null);
}
}, [props.reset]);
useEffect(() => {
if (value) {
const formattedDate = `${value.year}/${value.month.number}/${value.day}`;
props.onChange(formattedDate);
}
}, [value]);
useEffect(() => {
if (props.defaulValue && !value) {
const defaultDate = new DateObject({
date: props.defaulValue,
calendar: persian,
locale: persian_fa,
});
setValue(defaultDate);
}
}, [props.defaulValue, value]);
return (
<div className="w-full">
<div className='text-sm'>
{props.label}
</div>
<div className={clx(
'relative mt-1.5',
props.readOnly && 'readOny'
)}>
<DatePicker
placeholder={props.placeholder}
value={value}
onChange={(date) => setValue(date as DateObject)}
calendar={persian}
locale={persian_fa}
calendarPosition="bottom-right"
className={props.className}
readOnly={props.readOnly}
/>
{props.error_text && props.error_text !== '' && (
<div className="text-xs text-right text-red-600 mt-2 mr-2 font-medium">
{props.error_text}
</div>
)}
{/* <img src={CalenderIcon} className='absolute top-0 bottom-0 my-auto left-2' /> */}
</div>
</div>
);
};
export default DatePickerComponent;
+4 -1
View File
@@ -22,7 +22,10 @@ const ProductCard: FC<Props> = ({ item }) => {
const { mutate: removeWishlist } = useRemoveWishlist()
const handleWish = () => {
const handleWish = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!isLogin) return
if (!item?._id || !item?.variants[0]?._id) return