copy base dmenu to dkala

This commit is contained in:
hamid zarghami
2026-02-07 15:31:22 +03:30
commit c9e37f6177
521 changed files with 57786 additions and 0 deletions
BIN
View File
Binary file not shown.
@@ -0,0 +1,15 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import * as api from "../service/Service";
export const useGetFood = (id: string) => {
return useQuery({
queryKey: ["food", id],
queryFn: () => api.getFood(id),
});
};
export const useToggleFavorite = () => {
return useMutation({
mutationFn: api.toggleFavorite,
});
};
+262
View File
@@ -0,0 +1,262 @@
'use client'
import MinusIcon from '@/components/icons/MinusIcon'
import PlusIcon from '@/components/icons/PlusIcon'
import { ef } from '@/lib/helpers/utfNumbers'
import { useCart } from '@/app/[name]/(Dialogs)/cart/hook/useCart'
import { motion } from 'framer-motion'
import { ArrowLeft, Clock, Cup, Heart, TruckTick } from 'iconsax-react'
import Image from 'next/image'
import { useParams, useRouter } from 'next/navigation'
import React, { useEffect, useMemo, useState } from 'react'
import { useGetFood, useToggleFavorite } from './hooks/useFoodData'
import { useGetAbout } from '../about/hooks/useAboutData'
import { useGetProfile } from '../../(Profile)/profile/hooks/userProfileData'
import { toast } from '@/components/Toast'
import { getToken } from '@/lib/api/func'
type Props = object
function FoodPage({ }: Props) {
const { id, name } = useParams();
const router = useRouter();
const { isSuccess } = useGetProfile();
const { data: food, isLoading } = useGetFood(id as string);
const { data: about } = useGetAbout();
const { items, addToCart, removeFromCart } = useCart();
const [isFavorite, setIsFavorite] = useState<boolean>(false);
const { mutate: toggleFavorite } = useToggleFavorite();
const foodId = food?.data?.id || id as string;
const quantity = useMemo(() => {
const item = items?.[foodId];
if (item && typeof item === 'object' && 'quantity' in item) {
return item.quantity || 0;
}
return 0;
}, [items, foodId]);
const handleAddToCart = () => {
if (foodId) {
addToCart(foodId);
}
};
const handleRemoveFromCart = () => {
if (foodId) {
removeFromCart(foodId);
}
};
const handleToggleFavorite = async () => {
if (!foodId) return;
const token = await getToken();
if (!token || !isSuccess) {
toast('ابتدا لاگین کنید', 'error');
return;
}
// به‌روزرسانی خوش‌بینانه: تغییر state قبل از ارسال درخواست
const previousFavorite = isFavorite;
setIsFavorite(!isFavorite);
toggleFavorite(foodId, {
onSuccess: () => {
// تایید تغییر در صورت موفقیت
},
onError: () => {
// بازگرداندن به حالت قبلی در صورت خطا
setIsFavorite(previousFavorite);
toast('خطا در تغییر وضعیت علاقه‌مندی', 'error');
},
});
};
useEffect(() => {
if (food?.data?.isFavorite) {
setIsFavorite(true);
} else {
setIsFavorite(false);
}
}, [food?.data?.isFavorite]);
if (isLoading) {
return (
<div className='flex items-center justify-center min-h-[400px]'>
<p className='text-disabled-text'>در حال بارگذاری...</p>
</div>
);
}
if (!food?.data) {
return (
<div className='flex items-center justify-center min-h-[400px]'>
<p className='text-disabled-text'>غذا یافت نشد</p>
</div>
);
}
const foodData = food.data;
const foodName = foodData.title || foodData.name || foodData.foodName || '';
const foodImage = typeof foodData.image === 'string'
? foodData.image
: Array.isArray(foodData.images) && foodData.images.length > 0
? typeof foodData.images[0] === 'string'
? foodData.images[0]
: typeof foodData.images[0] === 'object' && foodData.images[0] !== null && 'url' in foodData.images[0]
? (foodData.images[0] as { url: string }).url
: '/assets/images/no-image.png'
: '/assets/images/no-image.png';
const categoryName = foodData.category?.title;
const prepareTime = foodData.prepareTime || 0;
const price = foodData.price || 0;
const content = Array.isArray(foodData.content)
? foodData.content.join('، ')
: foodData.content || foodData.desc || '';
const handleBackToMenu = () => {
const urlParams = new URLSearchParams(window.location.search);
const categoryParam = urlParams.get('category') || foodData.category?.id;
if (categoryParam) {
router.push(`/${name}?category=${categoryParam}`);
} else {
router.push(`/${name}`);
}
};
return (
<div className='lg:absolute lg:top-1/2 not-lg:max-w-lg not-lg:place-self-center lg:-translate-y-1/2 xl:right-[285px] lg:left-5 lg:right-4 lg:bottom-4 not-lg:w-full not-md:-translate-y-2 pt-6 flex flex-col lg:grid grid-cols-2 bottom-10'>
<div className="relative w-full h-full not-lg:bg-container rounded-2xl overflow-hidden lg:rounded-r-none lg:order-1">
<Image
className='w-full object-cover bg-[#F2F2F9] h-full'
src={foodImage}
alt={foodName}
height={100}
width={100}
unoptimized
priority
/>
<div className="absolute top-4 left-0 pe-5.5 ps-7 w-full inline-flex justify-between items-center">
<div className="bg-container whitespace-nowrap rounded-[34px] py-1.5 px-4 w-min text-xs text-disabled2 dark:text-disabled-text font-bold">
{categoryName || '-'}
</div>
<button onClick={handleBackToMenu} className='p-2 rounded-full bg-container/40'>
<ArrowLeft size={18} className='stroke-primary dark:stroke-foreground' />
</button>
</div>
</div>
<div className="relative px-6 pt-6 pb-7.5 lg:flex lg:flex-col lg:justify-center bg-container w-full rounded-3xl overflow-hidden not-lg:-translate-y-6 lg:rounded-l-none">
<div className="w-full inline-flex justify-between items-center">
<h5 className="text-base font-bold">
{foodName}
</h5>
<button
onClick={handleToggleFavorite}
className='p-2 bg-[#EAECF0] dark:bg-neutral-600 rounded-lg'
>
<Heart
variant={isFavorite ? 'Bold' : 'Outline'}
size={24}
color='currentColor'
className={isFavorite ? 'fill-primary dark:fill-foreground' : ''}
/>
</button>
</div>
<div className="mt-4 text-xs">
<div className='flex items-center gap-1'>
<Clock size={14} className='stroke-disabled-text' />
<span className='text-disabled-text'>
زمان پخت و آماده سازی: {prepareTime > 0 ? `${ef(String(prepareTime))} دقیقه` : '-'}
</span>
</div>
<div className='flex items-center gap-2 mt-2'>
<Cup size={14} className='stroke-disabled-text' />
<span className='text-disabled-text flex gap-1'>
{about?.data?.plan === 'base' ? (
<>
<div>۰</div>
<span>امتیاز برای هر بار خرید</span>
</>
) : about?.data?.score?.purchaseScore && about?.data?.score?.purchaseAmount ? (
<>
<div>
{ef(
Math.round(
foodData?.price *
(Number(about.data.score.purchaseScore) / Number(about.data.score.purchaseAmount))
).toLocaleString('en-US')
)}
</div>
<span>امتیاز برای هر بار خرید</span>
</>
) : (
<span>-</span>
)}
</span>
</div>
<div className='flex items-center gap-2 mt-2'>
<TruckTick size={14} className='stroke-disabled-text' />
<span className='text-disabled-text'>
{foodData.pickupServe ? 'ارسال با پیک' : '-'}
</span>
</div>
</div>
<div className="mt-7 text-xs">
<p className='font-bold'>محتویات:</p>
<p className='mt-2 leading-6'>{content || '-'}</p>
</div>
<div className='mt-12 flex justify-between items-center'>
<span dir='ltr'>{ef(price.toLocaleString('en-US'))} T</span>
<motion.div
whileTap={{ scale: 1.05 }}
className="bg-background active:drop-shadow-xs max-w-[115px] rounded-md w-full h-8 inline-flex p-1 justify-between items-center gap-2 relative overflow-hidden"
>
{quantity <= 0 ? (
<button
onClick={handleAddToCart}
className="inline-flex w-full justify-center items-center gap-2"
>
<PlusIcon />
<div className="text-sm2 pt-0.5 font-normal text-foreground">
افزودن
</div>
</button>
) : (
<>
<button
onClick={handleAddToCart}
className="bg-container hover:bg-container/60 active:bg-container/30 rounded-sm p-2 transition-colors"
>
<PlusIcon className="text-foreground" />
</button>
<motion.div
key={quantity}
initial={{ scale: 1 }}
animate={{ scale: [1.2, 0.95, 1] }}
transition={{ duration: 0.3 }}
className="text-sm2 pt-0.5 font-semibold"
>
{quantity}
</motion.div>
<button
onClick={handleRemoveFromCart}
className="bg-container hover:bg-container/60 active:bg-container/30 rounded-sm p-2 transition-colors"
>
<MinusIcon className="text-foreground" />
</button>
</>
)}
</motion.div>
</div>
</div>
</div>
)
}
export default FoodPage
@@ -0,0 +1,12 @@
import { api } from "@/config/axios";
import { FoodResponse } from "../../types/Types";
export const getFood = async (id: string): Promise<FoodResponse> => {
const { data } = await api.get<FoodResponse>(`/public/foods/${id}`);
return data;
};
export const toggleFavorite = async (foodId: string) => {
const { data } = await api.post(`/public/foods/favorite/${foodId}`);
return data;
};
+334
View File
@@ -0,0 +1,334 @@
'use client'
import AnimatedBottomSheet from '@/components/bottomsheet/AnimatedBottomSheet';
import EqualizerIcon from '@/components/icons/EqualizerIcon';
import TelegramIcon from '@/components/icons/TelegramIcon';
import TabContainer from '@/components/tab/TabContainer';
import { TabHeader } from '@/components/tab/TabHeader';
import Comment from '@/components/utils/Comment';
import RateBar from '@/components/utils/RateBar';
import useToggle from '@/hooks/helpers/useToggle';
import { ArrowDown2, CallCalling, Clock, Gallery, InfoCircle, Instagram, Location, Star1, Whatsapp } from 'iconsax-react';
import { useQueryState } from 'next-usequerystate';
import Image from 'next/image';
import React from 'react'
import { useGetAbout, useGetReviews, useGetSchedules } from './hooks/useAboutData';
import AboutSkeleton from './components/AboutSkeleton';
const sortings = [
'جدیدترین',
'قدیمی ترین'
]
function AboutPage() {
const { data: aboutData, isLoading: aboutLoading } = useGetAbout();
const { data: reviewsData, isLoading: reviewsLoading } = useGetReviews();
const { data: schedulesData, isLoading: schedulesLoading } = useGetSchedules();
const [sorting, setSorting] = useQueryState('sortBy', { defaultValue: '0' });
const { state: sortingModal, toggle: toggleSortingModal, set: setSortingModal } = useToggle();
const restaurant = aboutData?.data;
const schedules = schedulesData?.data || [];
const isLoading = aboutLoading || reviewsLoading || schedulesLoading;
if (isLoading) {
return <AboutSkeleton />;
}
const changeSorting = (index: number) => {
setSorting(() => String(index));
setSortingModal(false);
}
const formatTime = (time: string): string => {
return time.substring(0, 5);
};
const getDayName = (weekDay: number): string => {
const days = ['یکشنبه', 'دوشنبه', 'سه‌شنبه', 'چهارشنبه', 'پنج‌شنبه', 'جمعه', 'شنبه'];
return days[weekDay] || '';
};
const groupSchedulesByDay = () => {
const grouped: Record<number, typeof schedules> = {};
schedules
.filter(schedule => schedule.isActive)
.forEach(schedule => {
if (!grouped[schedule.weekDay]) {
grouped[schedule.weekDay] = [];
}
grouped[schedule.weekDay].push(schedule);
});
return grouped;
};
const getTodaySchedule = () => {
const today = new Date().getDay();
const todaySchedules = schedules.filter(schedule =>
schedule.isActive && schedule.weekDay === today
);
if (todaySchedules.length > 0) {
const firstSchedule = todaySchedules[0];
return `${formatTime(firstSchedule.openTime)} تا ${formatTime(firstSchedule.closeTime)}`;
}
return null;
};
const firstTab = () => {
if (!restaurant) return null;
return (
<section aria-labelledby="about-title" className='py-4'>
<section
className="bg-container rounded-container shadow-container p-4">
<div className="flex justify-between items-center border-b-[1.5px] border-gray-200 pb-[25px]">
<div className="">
<h2 className='text-sm2 font-bold leading-5'>{restaurant.name}</h2>
{restaurant.establishedYear && (
<p className="text-sm2 leading-5 mt-4">تاسیس: {restaurant.establishedYear}</p>
)}
{restaurant.tagNames && restaurant.tagNames.length > 0 && (
<p className="text-sm2 leading-5 mt-2">نوع محصولات: {restaurant.tagNames.join('، ')}</p>
)}
</div>
{restaurant.logo && (
<div className="rounded-normal overflow-clip">
<Image
alt='logo'
src={restaurant.logo}
width={88}
height={88}
unoptimized
priority
/>
</div>
)}
</div>
{restaurant.description && (
<div className="mt-[23px]">
<h3 className="text-sm2 font-bold leading-5">درباره مجموعه</h3>
<p className="text-sm2 leading-5 mt-3 border-spacing-48" style={{ wordSpacing: '0.01em' }}>
{restaurant.description}
</p>
{restaurant.images && restaurant.images.length > 0 && (
<div className='inline-flex gap-2 mt-[23px] items-center'>
<Gallery size={20} className='stroke-disabled-text' />
<span className='text-sm2 text-disabled-text font-medium pt-0.5'>عکس های رستوران</span>
</div>
)}
</div>
)}
</section>
{(restaurant.phone || restaurant.telegram || restaurant.whatsapp || restaurant.instagram) && (
<section
className="bg-container rounded-container shadow-container pt-3 pb-3.5 px-[16px] mt-4 grid grid-cols-3 items-center">
<h2 className='text-sm2 font-medium leading-5'>ارتباط</h2>
<div className='col-span-1 text-center flex justify-center gap-4'>
{restaurant.phone &&
<a href={`tel://${restaurant.phone}`} className='bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal'>
<CallCalling className='stroke-foreground' size={24} />
</a>
}
{restaurant.telegram &&
<a href={`https://t.me/${restaurant.telegram}`} className='bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal'>
<TelegramIcon className='stroke-foreground' width={24} height={24} />
</a>
}
{restaurant.whatsapp &&
<a href={`https://whatsapp.com/?phone=${restaurant.whatsapp}`} className='bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal'>
<Whatsapp className='stroke-foreground' size={24} />
</a>
}
{restaurant.instagram &&
<a href={`https://instagram.com/${restaurant.instagram}`} className='bg-[#EAEDF5] dark:bg-neutral-700 p-2 rounded-normal'>
<Instagram className='stroke-foreground' size={24} />
</a>
}
</div>
</section>
)}
{restaurant.address && (
<section className="bg-container rounded-container shadow-container px-4 pt-6 pb-6 mt-4">
<h2 className='text-sm2 font-medium leading-5'>آدرس</h2>
<p className='text-sm2 mt-[9px] leading-5'>{restaurant.address}</p>
{restaurant.latitude && restaurant.longitude && (
<div className='inline-flex gap-2 mt-[23px] items-center '>
<Location size={20} className='stroke-disabled-text' />
<span className='text-sm2 text-disabled-text '>موقعیت روی نقشه</span>
</div>
)}
</section>
)}
{schedules.length > 0 && getTodaySchedule() && (
<section
aria-label='ساعات کاری'
className="bg-container rounded-container shadow-container py-6 px-4 mt-4 flex justify-between items-center">
<div className="flex items-center gap-2 justify-start">
<Clock size={16} className='stroke-disabled-text' />
<div className='text-sm2 font-medium leading-5 mt-0.5 text-[#8C90A3]'>باز</div>
<div className='size-1.5 bg-[#D9D9D9] rounded-full'></div>
<div className='text-sm2 mt-0.5'>امروز از ساعت {getTodaySchedule()}</div>
</div>
<div className="">
<ArrowDown2 size={16} className='stroke-[#292D32]' />
</div>
</section>
)}
{schedules.length > 0 && (
<section>
{Object.entries(groupSchedulesByDay())
.sort(([a], [b]) => Number(a) - Number(b))
.map(([weekDay, daySchedules]) => (
<div
key={weekDay}
style={{ boxShadow: '0px 0px 14px 0px #0000000F' }}
className="bg-[#F6F6FA] dark:bg-border rounded-container leading-5 py-6 px-4 mt-4 flex justify-between items-center">
<div className="text-sm2">
{getDayName(Number(weekDay))}
</div>
<div className='text-sm2 font-light'>
{daySchedules.map((schedule, index) => (
<span key={schedule.id}>
{formatTime(schedule.openTime)} - {formatTime(schedule.closeTime)}
{index < daySchedules.length - 1 && '، '}
</span>
))}
</div>
</div>
))}
</section>
)}
</section>
)
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('fa-IR', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const secondTab = () => {
const reviews = (reviewsData?.data || []).filter(review => review.isApproved);
const sortedReviews = [...reviews].sort((a, b) => {
if (sorting === '0') {
// جدیدترین
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
} else {
// قدیمی ترین
return new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime();
}
});
const averageRating = reviews.length > 0
? reviews.reduce((sum, review) => sum + review.rating, 0) / reviews.length
: 0;
const ratingCounts = {
5: reviews.filter(r => r.rating === 5).length,
4: reviews.filter(r => r.rating === 4).length,
3: reviews.filter(r => r.rating === 3).length,
2: reviews.filter(r => r.rating === 2).length,
1: reviews.filter(r => r.rating === 1).length,
};
const totalReviews = reviews.length;
const ratingPercentages = {
5: totalReviews > 0 ? Math.round((ratingCounts[5] / totalReviews) * 100) : 0,
4: totalReviews > 0 ? Math.round((ratingCounts[4] / totalReviews) * 100) : 0,
3: totalReviews > 0 ? Math.round((ratingCounts[3] / totalReviews) * 100) : 0,
2: totalReviews > 0 ? Math.round((ratingCounts[2] / totalReviews) * 100) : 0,
1: totalReviews > 0 ? Math.round((ratingCounts[1] / totalReviews) * 100) : 0,
};
return (
<section aria-labelledby="reviews-title" className='py-4'>
<section
aria-label='امتیاز کاربران'
className="bg-container rounded-container shadow-container p-4 py-6 grid grid-cols-2 items-center">
<div className="text-center font-bold text-5xl">
{averageRating.toFixed(1)}
</div>
<div className="flex flex-col w-fit items-end">
<RateBar className='mt-1' content='5' percentage={String(ratingPercentages[5])} />
<RateBar className='mt-1' content='4' percentage={String(ratingPercentages[4])} />
<RateBar className='mt-1' content='3' percentage={String(ratingPercentages[3])} />
<RateBar className='mt-1' content='2' percentage={String(ratingPercentages[2])} />
<RateBar className='mt-1' content='1' percentage={String(ratingPercentages[1])} />
</div>
</section>
<section className="bg-container rounded-container shadow-container pt-3 pb-3.5 px-[16px] mt-4">
<div className="flex justify-between items-center border-b-[1.5px] border-border pb-2">
<h2 className='text-sm2 font-medium leading-5'>نظرات کاربران</h2>
<button onClick={toggleSortingModal} className="rounded-xl h-8 bg-container ps-4 pe-2 py-1.5 inline-flex items-center justify-between gap-[7px]">
<EqualizerIcon className='dark:text-white' />
<span className="text-xs leading-5 font-medium">{sortings[+sorting]}</span>
</button>
</div>
<div className="">
{sortedReviews.length > 0 ? (
sortedReviews.map((review) => (
<article key={review.id}>
<Comment
className='pt-8'
user={review.user?.firstName + ' ' + review.user?.lastName}
rating={review.rating}
date={formatDate(review.createdAt)}
text={review.comment}
tags={[review.food.title]}
positivePoints={review.positivePoints}
negativePoints={review.negativePoints}
/>
</article>
))
) : (
<p className='text-sm2 text-center py-8 text-disabled-text'>هنوز نظری ثبت نشده است</p>
)}
</div>
</section>
<AnimatedBottomSheet title="مرتب کردن بر اساس" visible={sortingModal} inDelay={150} onClick={toggleSortingModal}>
<div className="px-8.5 py-10 justify-between">
{sortings.map((v, i) => {
return (
<div key={i}>
<div onClick={() => changeSorting(i)} className="text-sm2 font-normal cursor-pointer">
{v}
</div>
{i < sortings.length - 1 && <hr className="border-white/40 dark:border-border mb-4 mt-4" />}
</div>
)
})}
</div>
</AnimatedBottomSheet>
</section>
)
}
return (
<div className='pt-8 h-full overflow-y-auto noscrollbar'>
<TabContainer>
<TabHeader
viewRenderer={firstTab()}
title='درباره ما' icon={<InfoCircle size={24} />}>
</TabHeader>
<TabHeader
viewRenderer={secondTab()}
title='امتیازات و نظرات' icon={<Star1 size={24} />}>
</TabHeader>
</TabContainer>
</div>
)
}
export default AboutPage
@@ -0,0 +1,133 @@
'use client';
import { Skeleton } from "@/components/ui/skeleton";
import TabContainer from "@/components/tab/TabContainer";
import { TabHeader } from "@/components/tab/TabHeader";
import { InfoCircle, Star1 } from 'iconsax-react';
const AboutSkeleton = () => {
const firstTabSkeleton = () => (
<section className='py-4'>
{/* اطلاعات رستوران */}
<section className="bg-container rounded-container shadow-container p-4">
<div className="flex justify-between items-center border-b-[1.5px] border-gray-200 pb-[25px]">
<div className="flex-1">
<Skeleton className="h-6 w-32 rounded-md mb-4" />
<Skeleton className="h-5 w-24 rounded-md mb-2" />
<Skeleton className="h-5 w-40 rounded-md" />
</div>
<Skeleton className="w-22 h-22 rounded-normal shrink-0" />
</div>
<div className="mt-[23px]">
<Skeleton className="h-5 w-28 rounded-md mb-3" />
<Skeleton className="h-4 w-full rounded-md mb-2" />
<Skeleton className="h-4 w-full rounded-md mb-2" />
<Skeleton className="h-4 w-3/4 rounded-md mb-[23px]" />
<div className='inline-flex gap-2 items-center'>
<Skeleton className="w-5 h-5 rounded-md" />
<Skeleton className="h-5 w-24 rounded-md" />
</div>
</div>
</section>
{/* ارتباط */}
<section className="bg-container rounded-container shadow-container pt-3 pb-3.5 px-[16px] mt-4 grid grid-cols-3 items-center">
<Skeleton className="h-5 w-16 rounded-md" />
<div className='col-span-1 text-center flex justify-center gap-4'>
{[...Array(3)].map((_, index) => (
<Skeleton key={index} className="w-10 h-10 rounded-normal" />
))}
</div>
</section>
{/* آدرس */}
<section className="bg-container rounded-container shadow-container px-4 pt-6 pb-6 mt-4">
<Skeleton className="h-5 w-12 rounded-md mb-[9px]" />
<Skeleton className="h-4 w-full rounded-md mb-2" />
<Skeleton className="h-4 w-3/4 rounded-md mb-[23px]" />
<div className='inline-flex gap-2 items-center'>
<Skeleton className="w-5 h-5 rounded-md" />
<Skeleton className="h-5 w-24 rounded-md" />
</div>
</section>
{/* ساعات کاری امروز */}
<section className="bg-container rounded-container shadow-container py-6 px-4 mt-4 flex justify-between items-center">
<div className="flex items-center gap-2">
<Skeleton className="w-4 h-4 rounded-full" />
<Skeleton className="h-5 w-8 rounded-md" />
<Skeleton className="w-1.5 h-1.5 rounded-full" />
<Skeleton className="h-5 w-32 rounded-md" />
</div>
<Skeleton className="w-4 h-4 rounded-md" />
</section>
{/* ساعات کاری هفته */}
{[...Array(3)].map((_, index) => (
<div
key={index}
className="bg-[#F6F6FA] dark:bg-border rounded-container leading-5 py-6 px-4 mt-4 flex justify-between items-center">
<Skeleton className="h-5 w-20 rounded-md" />
<Skeleton className="h-5 w-24 rounded-md" />
</div>
))}
</section>
);
const secondTabSkeleton = () => (
<section className='py-4'>
{/* امتیاز کاربران */}
<section className="bg-container rounded-container shadow-container p-4 py-6 grid grid-cols-2 items-center">
<Skeleton className="h-16 w-20 rounded-md mx-auto" />
<div className="flex flex-col w-fit items-end gap-1">
{[...Array(5)].map((_, index) => (
<Skeleton key={index} className="h-4 w-24 rounded-md" />
))}
</div>
</section>
{/* نظرات کاربران */}
<section className="bg-container rounded-container shadow-container pt-3 pb-3.5 px-[16px] mt-4">
<div className="flex justify-between items-center border-b-[1.5px] border-border pb-2">
<Skeleton className="h-5 w-24 rounded-md" />
<Skeleton className="h-8 w-28 rounded-xl" />
</div>
<div className="">
{[...Array(3)].map((_, index) => (
<div key={index} className="pt-8">
<div className="flex items-center gap-3 mb-3">
<Skeleton className="w-10 h-10 rounded-full" />
<div className="flex-1">
<Skeleton className="h-4 w-24 rounded-md mb-2" />
<Skeleton className="h-3 w-16 rounded-md" />
</div>
<Skeleton className="h-4 w-12 rounded-md" />
</div>
<Skeleton className="h-4 w-full rounded-md mb-2" />
<Skeleton className="h-4 w-3/4 rounded-md mb-4" />
<Skeleton className="h-3 w-20 rounded-md" />
</div>
))}
</div>
</section>
</section>
);
return (
<div className='pt-8 h-full overflow-y-auto noscrollbar'>
<TabContainer>
<TabHeader
viewRenderer={firstTabSkeleton()}
title='درباره ما' icon={<InfoCircle size={24} />}>
</TabHeader>
<TabHeader
viewRenderer={secondTabSkeleton()}
title='امتیازات و نظرات' icon={<Star1 size={24} />}>
</TabHeader>
</TabContainer>
</div>
);
};
export default AboutSkeleton;
@@ -0,0 +1,33 @@
import { useQuery } from "@tanstack/react-query";
import * as api from "../service/AboutService";
import { useParams } from "next/navigation";
export const useGetAbout = () => {
const { name } = useParams<{ name: string }>();
return useQuery({
queryKey: ["about"],
queryFn: () => api.getAbout(name),
enabled: !!name,
retry: false,
});
};
export const useGetReviews = () => {
const { name } = useParams<{ name: string }>();
return useQuery({
queryKey: ["reviews"],
queryFn: () => api.getReviews(name),
enabled: !!name,
retry: false,
});
};
export const useGetSchedules = () => {
const { name } = useParams<{ name: string }>();
return useQuery({
queryKey: ["schedules"],
queryFn: () => api.getSchedules(name),
enabled: !!name,
retry: false,
});
};
+36
View File
@@ -0,0 +1,36 @@
import React from 'react'
import { Metadata } from 'next';
import { getAboutData } from '@/lib/api/info/getAboutData';
export type AboutPageProps = { name: string }
type Params = { name: string };
export const revalidate = 60
export const dynamicParams = false // or false, to 404 on unknown paths
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { name } = await params;
const data = await getAboutData(name);
return {
title: data.metadata.title || `About | ${name}`,
description: data.metadata.description || `This is the about-us page for ${name}.`,
};
}
export async function generateStaticParams() {
return [
{ name: 'zhivan' },
{ name: 'boote' },
];
}
async function layout({ children }: Readonly<{ children: React.ReactNode; params: Promise<Params> }>) {
return (
<>{children}</>
)
}
export default layout
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import AboutPage from './AboutPage'
const page = () => {
return (
<AboutPage />
)
}
export default page
@@ -0,0 +1,27 @@
import { api } from "@/config/axios";
import {
AboutResponse,
ReviewsResponse,
SchedulesResponse,
} from "../types/Types";
export const getAbout = async (name: string): Promise<AboutResponse> => {
const { data } = await api.get<AboutResponse>(`/public/restaurants/${name}`);
return data;
};
export const getReviews = async (name: string): Promise<ReviewsResponse> => {
const { data } = await api.get<ReviewsResponse>(
`/public/reviews/restuarant/${name}`
);
return data;
};
export const getSchedules = async (
name: string
): Promise<SchedulesResponse> => {
const { data } = await api.get<SchedulesResponse>(
`/public/schedules/restaurant/${name}`
);
return data;
};
+127
View File
@@ -0,0 +1,127 @@
import { BaseResponse } from "@/app/[name]/(Main)/types/Types";
export interface ServiceArea {
type: "Polygon";
coordinates: number[][][];
}
export interface Score {
scoreAmount: string;
scoreCredit: string;
birthdayScore: string;
purchaseScore: string;
referrerScore: string;
registerScore: string;
purchaseAmount: string;
marriageDateScore: string;
}
export interface Restaurant {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
name: string;
slug: string;
logo: string | null;
address: string | null;
menuColor: string | null;
latitude: number | null;
longitude: number | null;
serviceArea: ServiceArea;
isActive: boolean;
establishedYear: number | null;
phoneNumber: string | null;
phone: string;
instagram: string | null;
telegram: string | null;
whatsapp: string | null;
description: string | null;
seoTitle: string | null;
seoDescription: string | null;
tagNames: string[] | null;
images: string[] | null;
vat: number;
domain: string;
score: Score;
plan: 'base' | 'premium';
}
export interface ReviewFood {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
restaurant: string;
category: string;
title: string;
desc: string | null;
content: string | null;
price: number;
points: number | null;
order: number | null;
prepareTime: number | null;
sat: boolean;
sun: boolean;
mon: boolean;
breakfast: boolean;
noon: boolean;
dinner: boolean;
stock: number;
stockDefault: number;
isActive: boolean;
images: string[];
inPlaceServe: boolean;
pickupServe: boolean;
rate: number;
discount: number;
isSpecialOffer: boolean;
}
export interface ReviewUser {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
firstName: string;
lastName: string;
birthDate: string;
marriageDate: string;
referrer: string | null;
isActive: boolean;
gender: boolean;
wallet: number;
points: number;
phone: string;
}
export interface Review {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
order: string;
food: ReviewFood;
user: ReviewUser;
comment: string;
rating: number;
positivePoints: string[];
negativePoints: string[];
isApproved: boolean;
}
export interface Schedule {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
weekDay: number;
openTime: string;
closeTime: string;
isActive: boolean;
restId: string;
}
export type AboutResponse = BaseResponse<Restaurant>;
export type ReviewsResponse = BaseResponse<Review[]>;
export type SchedulesResponse = BaseResponse<Schedule[]>;
+160
View File
@@ -0,0 +1,160 @@
'use client'
import { ef } from '@/lib/helpers/utfNumbers'
import { EmojiHappy, Microphone2, Paperclip2 } from 'iconsax-react'
import React, { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
type Props = object
type MessageModel = {
type: 'sender' | 'receiver'
senderName: string
content: string
date: string
time: string
}
function ChatIndex ({}: Props) {
const { t } = useTranslation('chat')
const [messages, setMessages] = useState<Array<MessageModel>>([
{
type: 'sender',
senderName: 'علی مصلحی',
content:
'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است',
date: '1403/09/30',
time: '10:07'
},
{
type: 'receiver',
senderName: 'سیما فرهادی',
content:
'لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است، چاپگرها و متون بلکه روزنامه و مجله در ستون و سطرآنچنان که لازم است',
date: '1403/09/30',
time: '10:07'
}
])
const addMessageOutgoing = (model: MessageModel) => {
setMessages(previous => {
return [...previous, model]
})
}
const submitMessage = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const content = formData.get('textMessage')
if (!content) return
const model: MessageModel = {
type: 'sender',
senderName: 'علی مصلحی',
content: String(content),
date: '1403/09/30',
time: '10:07'
}
addMessageOutgoing(model)
}
const messagesMemo = useMemo(() => messages, [messages])
return (
<section className='flex flex-col h-full pt-6'>
<h1 className='font-medium'>علی مصلحی</h1>
<div className='bg-container rounded-[30px] flex flex-col justify-between mt-6 flex-1 px-6 pt-7 pb-8 overflow-hidden'>
<div className='overflow-y-auto flex flex-col-reverse'>
<ul className='grid gap-6 pb-6 '>
{messagesMemo.map((v, i) => {
if (v.type === 'sender') {
return (
<li
key={i}
className='bg-[#F6F7FA] dark:bg-gray-700 rounded-[30px] rounded-tr-none p-6'
>
<article>
<p className='text-xs2'>{v.content}</p>
<span
dir='ltr'
className='text-[11px] float-end text-disabled-text mt-2.5'
>
{ef(`${v.time} | ${v.date}`)}
</span>
</article>
</li>
)
} else {
return (
<li
key={i}
className='bg-[#EBEDF5] dark:bg-gray-600 rounded-[30px] rounded-tl-none p-6'
>
<article>
<h6 className='text-xs font-medium'>{v.senderName}</h6>
<p className='text-xs2 mt-2.5'>{v.content}</p>
<span
dir='ltr'
className='text-[11px] float-end text-disabled-text mt-2.5'
>
{ef(`${v.time} | ${v.date}`)}
</span>
</article>
</li>
)
}
})}
</ul>
</div>
<section
aria-labelledby={t('InputMessage.AriaLabelBy')}
className='w-full'
>
<form
onSubmit={submitMessage}
className='focus-within:outline-blue-400 outline outline-transparent transition-colors duration-100 flex items-center gap-0 bg-transparent border border-border h-12 rounded-xl py-2.5 px-2'
>
<button
type='button'
className='active:bg-neutral-100 dark:active:bg-neutral-600 transition-colors duration-150 rounded-full p-1'
>
<Microphone2 className='stroke-foreground' size={24} />
</button>
<input
name='textMessage'
role='textbox'
autoComplete='off'
className='w-full text-[11px] h-full outline-none ms-1'
type='text'
placeholder={t('InputMessage.Placeholder')}
/>
<button
type='button'
className='active:bg-neutral-100 dark:active:bg-neutral-600 transition-colors duration-150 rounded-full p-1.5'
>
<EmojiHappy
className='stroke-disabled2 dark:stroke-neutral-400'
size={20}
/>
</button>
<button
type='button'
className='active:bg-neutral-100 dark:active:bg-neutral-600 transition-colors duration-150 rounded-full p-1.5'
>
<Paperclip2
className='stroke-disabled2 dark:stroke-neutral-400'
size={20}
/>
</button>
</form>
</section>
</div>
</section>
)
}
export default ChatIndex
+184
View File
@@ -0,0 +1,184 @@
'use client'
import { motion } from 'framer-motion'
import Image from 'next/image'
import Link from 'next/link'
import React, { useEffect, useRef, useState } from 'react'
const users = [
{
id: 0,
name: '1 علی مصلحی',
table: 12,
avatar: '/assets/images/user-avatar.png'
},
{
id: 1,
name: '2 علی مصلحی',
table: 10,
avatar: '/assets/images/user-avatar.png'
},
{
id: 2,
name: '3 علی مصلحی',
table: 11,
avatar: '/assets/images/user-avatar.png'
},
{
id: 3,
name: '4 علی مصلحی',
table: 5,
avatar: '/assets/images/user-avatar.png'
},
{
id: 4,
name: '5 علی مصلحی',
table: 2,
avatar: '/assets/images/user-avatar.png'
},
{
id: 5,
name: '6 علی مصلحی',
table: 1,
avatar: '/assets/images/user-avatar.png'
}
]
function ChatNearby () {
const containerRef = useRef<HTMLElement>(null)
const [positions, setPositions] = useState<{ top: number; left: number }[]>(
[]
)
useEffect(() => {
if (containerRef.current) {
const maxW = containerRef.current.offsetWidth - 50
const maxH = containerRef.current.offsetHeight - 180
const elementSize = 110 // approximate size of each user element
const padding = 80 // minimum distance between elements
const positions: { top: number; left: number }[] = []
const isTooClose = (top: number, left: number) => {
return positions.some(p => {
const dx = p.left - left
const dy = p.top - top
const distance = Math.sqrt(dx * dx + dy * dy)
return distance < padding
})
}
users.forEach(() => {
let top = 0
let left = 0
let tries = 0
do {
top = Math.random() * Math.abs(maxH - elementSize)
left = Math.random() * Math.abs(maxW - elementSize)
tries++
} while (isTooClose(top, left) && tries < 100)
positions.push({ top, left })
})
setPositions(positions)
}
}, [])
return (
<section ref={containerRef} className='h-svh relative'>
<section className='relative h-full w-full mt-7'>
<motion.div
className='w-[1145px] h-[1145px] rounded-full fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
bg-[linear-gradient(180deg,#EAECF0_4.52%,#DBDEE8_95.02%)]
dark:bg-[linear-gradient(180deg,#1F2937_4.52%,#374151_95.02%)]'
style={{ willChange: 'transform' }}
></motion.div>
<motion.div
animate={{ scale: [1, 0.95, 1] }}
transition={{ repeat: Infinity, duration: 13 }}
className='w-[824px] h-[824px] rounded-full fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
bg-[linear-gradient(180deg,#EBF0FC_4.52%,#EBF0FC_95.02%)]
dark:bg-[linear-gradient(180deg,#111827_4.52%,#1E293B_95.02%)]'
style={{ willChange: 'transform' }}
></motion.div>
<motion.div
animate={{ scale: [0.9, 1] }}
transition={{ repeat: Infinity, duration: 4, repeatType: 'reverse' }}
className='w-[617px] h-[617px] rounded-full fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
bg-[linear-gradient(180deg,#E6EBFA_0%,#DFE2EC_100%)]
dark:bg-[linear-gradient(180deg,#1E293B_0%,#111827_100%)]'
style={{ willChange: 'transform' }}
></motion.div>
<motion.div
animate={{ scale: [1.02, 0.95] }}
transition={{ repeat: Infinity, duration: 2, repeatType: 'reverse' }}
className='w-[425px] h-[425px] rounded-full fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
bg-[linear-gradient(180deg,#E1E5EF_0%,#D8DDE8_60.2%,#D2D7E3_100%)]
dark:bg-[linear-gradient(180deg,#1F2937_0%,#374151_60.2%,#1E293B_100%)]'
style={{ willChange: 'transform' }}
></motion.div>
<motion.div
animate={{ scale: [1.05, 1] }}
transition={{ repeat: Infinity, duration: 2, repeatType: 'reverse' }}
className='w-[292px] h-[292px] rounded-full fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
bg-[linear-gradient(180deg,#EAEDF5_0%,#E4E7F0_46.52%,#DDDFEB_100%)]
dark:bg-[linear-gradient(180deg,#111827_0%,#1E293B_46.52%,#1F2937_100%)]'
style={{ willChange: 'transform' }}
></motion.div>
<motion.div
animate={{ scale: [1, 1.05] }}
transition={{ repeat: Infinity, duration: 2, repeatType: 'reverse' }}
className='w-[174px] h-[174px] rounded-full fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
bg-[linear-gradient(180deg,#F3F7FF_0%,#F2F5FC_100%)]
dark:bg-[linear-gradient(180deg,#1E293B_0%,#111827_100%)]'
style={{ willChange: 'transform' }}
></motion.div>
<motion.div
animate={{ scale: [0, 1.2], opacity: [1, 0] }}
transition={{ repeat: Infinity, duration: 2 }}
className='h-full w-[100dvh] rounded-full fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
outline outline-solid outline-blue-400'
style={{ willChange: 'transform, opacity' }}
></motion.div>
<div>
{positions.length === users.length &&
users.map((v, i) => (
<Link
href={`${v.id}`}
prefetch={false}
key={v.id}
style={{
position: 'absolute',
top: positions[i].top,
left: positions[i].left
}}
className='justify-items-center'
>
<Image
height={46}
width={46}
alt='user avatar'
src={v.avatar}
/>
<h6 className='mt-2 text-xs font-medium'>{v.name}</h6>
<div className='mt-[3px] text-xs2 font-medium bg-disabled-text dark:bg-neutral-800 text-accent rounded-[5px] px-[5px] py-[3px]'>
میز {v.table}
</div>
</Link>
))}
</div>
</section>
</section>
)
}
export default ChatNearby
+265
View File
@@ -0,0 +1,265 @@
'use client'
import SearchBox from '@/components/input/SearchBox'
import { ScrollArea } from '@/components/ui/scrollarea'
import { Radar2, Trash } from 'iconsax-react'
import { Check, CheckCheck } from 'lucide-react'
import Image from 'next/image'
import Link from 'next/link'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
LongPressCallbackMeta,
LongPressReactEvents,
useLongPress
} from 'use-long-press'
import { useRouter } from 'next/navigation'
import useToggle from '@/hooks/helpers/useToggle'
import Prompt from '@/components/utils/Prompt'
type Props = object
type ChatEntryModel = {
id: string
username: string
avatarUrl: string
update?: {
time: string
content: string
count?: number
seen?: boolean
}
}
function ChatIndex ({}: Props) {
const { t } = useTranslation('chat')
const { t: tDeleteModal } = useTranslation('common', {
keyPrefix: 'DeleteChatModal'
})
const [search, setSearch] = useState('')
const [selectedChats, setSelectedChats] = useState<Array<string>>([])
const router = useRouter()
const { state: deleteModal, toggle: toggleDeleteModal } = useToggle()
const [chatsList, setChatList] = useState<Array<ChatEntryModel>>([
{
id: '0',
username: 'علی مصلحی',
avatarUrl: '/assets/images/user-avatar.png',
update: {
time: '12:03',
content: 'چه خبر',
count: 2
}
},
{
id: '1',
username: 'علی مصلحی',
avatarUrl: '/assets/images/user-avatar.png',
update: {
time: '12:03',
content: 'چه خبر',
count: 2
}
},
{
id: '2',
username: 'علی مصلحی',
avatarUrl: '/assets/images/user-avatar.png',
update: {
time: '12:03',
content: 'چه خبر',
seen: true
}
},
{
id: '3',
username: 'علی مصلحی',
avatarUrl: '/assets/images/user-avatar.png',
update: {
time: '12:03',
content: 'چه خبر',
seen: true
}
},
{
id: '4',
username: 'علی مصلحی',
avatarUrl: '/assets/images/user-avatar.png',
update: {
time: '12:03',
content: 'چه خبر',
seen: true
}
},
{
id: '5',
username: 'علی مصلحی',
avatarUrl: '/assets/images/user-avatar.png',
update: {
time: '12:03',
content: 'چه خبر',
seen: false
}
}
])
const toggleDeleteModalVisiblity = (e?: React.MouseEvent) => {
e?.preventDefault()
e?.stopPropagation()
if (deleteModal) {
setSelectedChats([])
}
toggleDeleteModal()
}
const deleteSelectedChats = () => {
if (setSelectedChats.length <= 0) return
setChatList(prev => {
return prev.filter(x => !selectedChats.includes(x.id))
})
toggleDeleteModalVisiblity()
}
const longPressCallback = useCallback(
(e: LongPressReactEvents<Element>, ctx: LongPressCallbackMeta<unknown>) => {
e.preventDefault()
if (!ctx?.context) return
const id = String(ctx.context)
setSelectedChats(prev => {
if (prev.includes(id)) return prev.filter(x => x !== id)
return [...prev, id]
})
},
[]
)
const longPressCancelledCallback = useCallback(
(id: string, reason?: string) => {
if (reason && reason !== 'cancelled-by-release') return
if (selectedChats.length > 0) {
setSelectedChats(prev => {
if (prev.includes(id)) return prev.filter(x => x !== id)
return [...prev, id]
})
} else {
router.push(`chat/${id}`)
}
},
[router, selectedChats.length]
)
const longPress = useLongPress(longPressCallback, {
onCancel: (event, ctx) =>
longPressCancelledCallback(String(ctx.context), String(ctx.reason)),
threshold: 500, // In milliseconds
captureEvent: true, // Event won't get cleared after React finish processing it
cancelOnMovement: 100, // Square side size (in pixels) inside which movement won't cancel long press
cancelOutsideElement: true // Cancel long press when moved mouse / pointer outside element while pressing
})
return (
<div className='pt-8 mb-8'>
<section className='flex justify-between items-center gap-x-4'>
<SearchBox
placeholder={t('InputSearch.Placeholder')}
value={search}
onChange={e => setSearch(e.target.value)}
/>
{selectedChats.length > 0 ? (
<span
onClick={toggleDeleteModal}
className='bg-container p-2 rounded-xl active:bg-gray-50 dark:active:bg-neutral-500'
>
<Trash
size={24}
className='stroke-primary dark:stroke-disabled-text'
/>
</span>
) : (
<Link
href={'chat/nearby'}
className='bg-container p-2 rounded-xl active:bg-gray-50 dark:active:bg-neutral-500'
>
<Radar2
size={24}
className='stroke-primary dark:stroke-disabled-text'
/>
</Link>
)}
</section>
<section>
<ScrollArea dir='rtl' className='w-full h-full'>
<ul className='pt-4'>
{chatsList.map((chat, i) => {
return (
<li
key={i}
{...longPress(chat.id)}
className='select-none flex items-center gap-x-2 hover:brightness-105 cursor-default active:brightness-102 p-2 bg-background rounded-xl'
>
<div className='size-[49px] min-w-[49px]'>
{selectedChats.includes(chat.id) ? (
<div className='size-[49px] relative bg-primary rounded-full'>
<Check className='absolute top-1/2 left-1/2 -translate-1/2 stroke-white' />
</div>
) : (
<Image
src={chat.avatarUrl}
height={49}
width={49}
alt={`${chat.username}'s avatar`}
unoptimized
/>
)}
</div>
<div className='flex flex-col justify-between w-full gap-2'>
<h5 className='text-sm2 font-medium'>{chat.username}</h5>
<p className='text-xs'>{chat.update?.content}</p>
</div>
<div className='flex flex-col justify-between items-end gap-2'>
<span className='text-sm2 text-disabled2'>
{chat.update?.time}
</span>
{chat.update?.count !== undefined &&
chat.update?.count > 0 ? (
<span className='text-xs w-5 h-5 relative bg-foreground dark:bg-neutral-700 text-white rounded-full'>
<span className='absolute top-1/2 left-1/2 -translate-1/2 pt-1'>
{chat.update?.count}
</span>
</span>
) : chat.update?.seen !== undefined && chat.update?.seen ? (
<CheckCheck className='stroke-foreground' size={16} />
) : (
<Check className='stroke-foreground' size={16} />
)}
</div>
</li>
)
})}
</ul>
</ScrollArea>
</section>
<Prompt
title={tDeleteModal('Heading')}
description={tDeleteModal('Description').replace(
'{count}',
String(selectedChats.length)
)}
textConfirm={tDeleteModal('ButtonOk')}
textCancel={tDeleteModal('ButtonCancel')}
onConfirm={deleteSelectedChats}
onCancel={toggleDeleteModalVisiblity}
onClick={toggleDeleteModalVisiblity}
visible={deleteModal}
/>
</div>
)
}
export default ChatIndex
@@ -0,0 +1,95 @@
'use client';
import Image from "next/image";
import clsx from "clsx";
import HorizontalScrollView from "@/components/listview/HorizontalScrollView";
import CategoryItemRenderer from "@/components/listview/CategoryItemRenderer";
import CategorySmallItemRenderer from "@/components/listview/CategorySmallItemRenderer";
import { Category } from "@/app/[name]/(Main)/types/Types";
type Variant = "large" | "small";
const variantConfig: Record<
Variant,
{
renderer: typeof CategoryItemRenderer;
imageSize: number;
}
> = {
large: {
renderer: CategoryItemRenderer,
imageSize: 32,
},
small: {
renderer: CategorySmallItemRenderer,
imageSize: 24,
},
};
type Props = {
categories: Category[];
selectedCategory: string;
onSelect: (categoryId: string) => void;
variant?: Variant;
className?: string;
};
const CategoryScroll = ({
categories,
selectedCategory,
onSelect,
variant = "large",
className,
}: Props) => {
const { renderer: Renderer, imageSize } = variantConfig[variant];
const handleSelect = (categoryId: string) => () => onSelect(categoryId);
return (
<HorizontalScrollView
className={clsx(
"w-full noscrollbar py-4!",
variant === "large" && "mt-4!",
className
)}
>
{/* <Renderer
key="all"
className={clsx(selectedCategory === "0" && "bg-container!")}
onClick={handleSelect(0)}
>
<Image
priority
src="/assets/images/food-image.png"
width={imageSize}
height={imageSize}
alt="category image"
/>
<span className="text-xs text-foreground">همه</span>
</Renderer> */}
{categories.map((item) => {
const isSelected = item.id === selectedCategory;
return (
<Renderer
key={item.id}
className={clsx(isSelected && "bg-container!")}
onClick={handleSelect(item.id)}
>
<Image
priority
src={item.avatarUrl || "/assets/images/food-image.png"}
width={imageSize}
height={imageSize}
alt="category image"
/>
<span className="text-xs text-foreground text-center">{item.title}</span>
</Renderer>
);
})}
</HorizontalScrollView>
);
};
export default CategoryScroll;
@@ -0,0 +1,138 @@
'use client';
import React, { useState, useEffect, useMemo } from "react";
import Button from "@/components/button/PrimaryButton";
import AnimatedBottomSheet from "@/components/bottomsheet/AnimatedBottomSheet";
import ComboBox, { ComboboxOption } from "@/components/combobox/Combobox";
import { Switch } from "@/components/ui/switch";
import { TicketPercentIcon } from "lucide-react";
type MenuFilterDrawerProps = {
visible: boolean;
onClose: () => void;
selectedIngredients: string;
selectedDeliveryId: string;
onIngredientsChange: (value: string) => void;
onDeliveryChange: (value: string) => void;
onApply: () => void;
tMenu: (key: string) => string;
};
const MenuFilterDrawer = ({
visible,
onClose,
selectedIngredients,
selectedDeliveryId,
onIngredientsChange,
onDeliveryChange,
onApply,
tMenu,
}: MenuFilterDrawerProps) => {
const [tempIngredients, setTempIngredients] = useState(selectedIngredients);
const [tempDeliveryId, setTempDeliveryId] = useState(selectedDeliveryId);
useEffect(() => {
if (visible) {
setTempIngredients(selectedIngredients);
setTempDeliveryId(selectedDeliveryId);
}
}, [visible, selectedIngredients, selectedDeliveryId]);
const serviceOptions: Array<ComboboxOption> = useMemo(() => {
return [
{
id: "0",
title: tMenu("MenuFilterDrawer.SelectDelivery.Options.All") || "همه",
label: "",
},
{
id: "pickup",
title: "بیرون بر",
label: "",
},
{
id: "inPlace",
title: "سرو در رستوران",
label: "",
},
];
}, [tMenu]);
const handleIngredientsChange = (
e: React.ChangeEvent<HTMLInputElement>
) => {
setTempIngredients(e.target.value);
};
const handleDeliveryChange = (
e: React.MouseEvent<HTMLDivElement, MouseEvent>,
index: number
) => {
e.stopPropagation();
setTempDeliveryId(serviceOptions[index]?.id ?? serviceOptions[0].id);
};
const handleApply = () => {
onIngredientsChange(tempIngredients);
onDeliveryChange(tempDeliveryId);
onApply();
onClose();
};
const handleCancel = () => {
setTempIngredients(selectedIngredients);
setTempDeliveryId(selectedDeliveryId);
onClose();
};
return (
<AnimatedBottomSheet
title={tMenu("MenuFilterDrawer.Heading")}
visible={visible}
outDelay={150}
onClick={onClose}
>
<div className="px-6 mt-5">
<div className="flex flex-col gap-2">
<label className="text-sm2 text-foreground">
{tMenu("MenuFilterDrawer.SelectContent.Label")}
</label>
<input
type="text"
value={tempIngredients}
onChange={handleIngredientsChange}
placeholder={tMenu("MenuFilterDrawer.SelectContent.Placeholder") || "محتویات را وارد کنید..."}
className="w-full h-11 px-3 py-2.5 rounded-normal border border-border bg-container/29 focus:outline-none focus:ring-2 focus:ring-black text-xs!"
/>
</div>
<ComboBox
className="relative mt-9.5"
title={tMenu("MenuFilterDrawer.SelectDelivery.Label")}
options={serviceOptions}
selectedId={tempDeliveryId}
onSelectionChange={handleDeliveryChange}
/>
<div className="flex w-full mt-[23px] h-11 items-center justify-between gap-2 px-3 py-4 relative bg-container/29 rounded-xl border border-solid border-border cursor-pointer focus:outline-none focus:ring-2 focus:ring-black">
<span className="inline-flex items-center gap-2.5 text-sm2">
<TicketPercentIcon size={16} />
{tMenu("MenuFilterDrawer.DiscountState")}
</span>
<Switch />
</div>
</div>
<hr className="text-white/40 mt-12" />
<div className="px-9 pt-6 flex justify-between gap-[22px]">
<div className="w-full">
<Button className="dark:bg-white! dark:text-black!" onClick={handleApply}>{tMenu("MenuFilterDrawer.ButtonOk")}</Button>
</div>
<div className="w-full">
<Button className="bg-disabled! text-disabled-text!" onClick={handleCancel}>
{tMenu("MenuFilterDrawer.ButtonCancel")}
</Button>
</div>
</div>
</AnimatedBottomSheet>
);
};
export default MenuFilterDrawer;
@@ -0,0 +1,55 @@
'use client';
import { Skeleton } from "@/components/ui/skeleton";
import VerticalScrollView from "@/components/listview/VerticalScrollView";
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
const MenuSkeleton = () => {
return (
<div className="flex flex-col gap-4 items-center pt-8 mb-8">
<div className="w-full">
<Skeleton className="h-12 w-full rounded-xl mb-4" />
<div className="flex gap-3 overflow-x-hidden py-4">
{[...Array(5)].map((_, index) => (
<Skeleton key={index} className="h-24 w-24 rounded-xl shrink-0" />
))}
</div>
</div>
<section className="w-full">
<div className="flex justify-between items-center mb-5">
<Skeleton className="h-5 w-20 rounded-md" />
<div className="flex gap-2">
<Skeleton className="h-8 w-24 rounded-xl" />
<Skeleton className="h-8 w-28 rounded-xl" />
</div>
</div>
<VerticalScrollView className="mt-5! overflow-y-auto h-full">
{[...Array(6)].map((_, index) => (
<MenuItemRenderer key={index}>
<div className="flex gap-4 w-full h-full items-center">
<Skeleton className="min-w-28 w-28 h-28 rounded-xl" />
<div className="w-full inline-flex flex-col justify-between">
<div>
<Skeleton className="h-5 w-32 rounded-md mb-2" />
<Skeleton className="h-4 w-full rounded-md mb-1" />
<Skeleton className="h-4 w-3/4 rounded-md" />
</div>
<div className="inline-flex mt-2 gap-2 justify-between w-full items-center">
<div className="w-full flex flex-col gap-1">
<Skeleton className="h-4 w-16 rounded-md" />
</div>
<Skeleton className="max-w-[115px] w-full h-8 rounded-md" />
</div>
</div>
</div>
</MenuItemRenderer>
))}
</VerticalScrollView>
</section>
</div>
);
};
export default MenuSkeleton;
@@ -0,0 +1,54 @@
'use client';
import AnimatedBottomSheet from "@/components/bottomsheet/AnimatedBottomSheet";
import clsx from "clsx";
import React from "react";
type MenuSortingDrawerProps = {
visible: boolean;
onClose: () => void;
sortings: ReadonlyArray<string>;
activeIndex: number;
onSelect: (index: number) => void;
tMenu: (key: string) => string;
};
const MenuSortingDrawer = ({
visible,
onClose,
sortings,
activeIndex,
onSelect,
tMenu,
}: MenuSortingDrawerProps) => {
return (
<AnimatedBottomSheet
title={tMenu("MenuSortingDrawer.Heading")}
visible={visible}
inDelay={150}
onClick={onClose}
>
<div className="px-8.5 py-10 flex flex-col gap-2">
{sortings.map((value, index) => {
const isActive = index === activeIndex;
return (
<button
key={value}
type="button"
onClick={() => onSelect(index)}
className={clsx(
"flex items-center justify-between py-3 text-sm2 transition-colors",
isActive ? "text-primary font-semibold" : "text-foreground"
)}
>
<span>{tMenu(`MenuSortingDrawer.Options.${value}`)}</span>
</button>
);
})}
</div>
</AnimatedBottomSheet>
);
};
export default MenuSortingDrawer;
@@ -0,0 +1,28 @@
import { useQuery } from "@tanstack/react-query";
import * as api from "../service/MenuService";
import { useParams } from "next/navigation";
export const useGetFoods = () => {
const { name } = useParams<{ name: string }>();
return useQuery({
queryKey: ["menu"],
queryFn: () => api.getFoods(name),
enabled: !!name,
});
};
export const useGetCategories = () => {
const { name } = useParams<{ name: string }>();
return useQuery({
queryKey: ["categories"],
queryFn: () => api.getCategories(name),
enabled: !!name,
});
};
export const useGetNotificationsCount = () => {
return useQuery({
queryKey: ["notifications-count"],
queryFn: api.getNotificationsCount,
});
};
+17
View File
@@ -0,0 +1,17 @@
import ClientMenuRouteWrapper from "@/components/wrapper/ClientMenuRouteWrapper";
import ClientSideWrapper from "@/components/wrapper/ClientSideWrapper";
export default function MenuLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<ClientMenuRouteWrapper>
<ClientSideWrapper className=''>
{children}
</ClientSideWrapper>
</ClientMenuRouteWrapper>
);
}
+10
View File
@@ -0,0 +1,10 @@
import LoadingOverlay from '@/components/overlays/LoadingOverlay'
import React from 'react'
function Loading() {
return (
<LoadingOverlay />
)
}
export default Loading
@@ -0,0 +1,10 @@
export const enum OrderStatus {
PENDING_PAYMENT = "pendingPayment",
PAID = "paid",
PREPARING = "preparing",
DELIVERED_TO_WAITER = "deliveredToWaiter",
DELIVERED_TO_RECEPTIONIST = "deliveredToReceptionist",
SHIPPED = "shipped",
COMPLETED = "completed",
CANCELED = "canceled",
}
@@ -0,0 +1,9 @@
import { useQuery } from "@tanstack/react-query";
import { getOrders } from "../service/Service";
export const useGetOrders = (status: "old" | "current") => {
return useQuery({
queryKey: ["orders", status],
queryFn: () => getOrders(status),
});
};
@@ -0,0 +1,16 @@
export const metadata = {
title: 'Orders',
}
export default function MenuLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<>
{children}
</>
);
}
@@ -0,0 +1,263 @@
'use client';
import Button from '@/components/button/PrimaryButton';
import CalendarIcon from '@/components/icons/CalendarIcon';
import LocationPinIcon from '@/components/icons/LocationPinIcon';
import TabContainer from '@/components/tab/TabContainer'
import { TabHeader } from '@/components/tab/TabHeader';
import { Receipt2, ReceiptItem, DocumentText, TickCircle } from 'iconsax-react';
import Image from 'next/image';
import Link from 'next/link';
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';
const fallbackImage = '/assets/images/food-preview.png';
const getFoodImage = (images: string[] | string | null | undefined): string => {
if (!images) {
return fallbackImage;
}
if (Array.isArray(images)) {
return images.length > 0 ? images[0] : fallbackImage;
}
if (typeof images === 'string') {
return images;
}
return fallbackImage;
};
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('fa-IR', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
});
};
const getStatusColor = (status: string): string => {
const statusMap: Record<string, string> = {
'pendingPayment': 'text-yellow-600 dark:text-yellow-400',
'paid': 'text-green-600 dark:text-green-400',
'preparing': 'text-blue-600 dark:text-blue-400',
'deliveredToWaiter': 'text-purple-600 dark:text-purple-400',
'deliveredToReceptionist': 'text-indigo-600 dark:text-indigo-400',
'shipped': 'text-cyan-600 dark:text-cyan-400',
'completed': 'text-green-600 dark:text-green-400',
'canceled': 'text-red-600 dark:text-red-400',
'cancelled': 'text-red-600 dark:text-red-400',
};
return statusMap[status] || 'text-gray-600 dark:text-gray-400';
};
function OrdersIndex() {
const { t } = useTranslation('orders');
const [selectedTab, setSelectedTab] = useState(0);
const status = selectedTab === 0 ? 'current' : 'old';
const { data: ordersData } = useGetOrders(status);
const orders = useMemo(() => ordersData?.data || [], [ordersData?.data]);
const categories = [
{ id: 0, title: t('Tab.ActiveOrders'), icon: <ReceiptItem size={22} /> },
{ id: 1, title: t('Tab.PreviousOrders'), icon: <Receipt2 size={22} /> }
]
const firstTab = () => {
if (orders.length === 0) {
return [<div key="empty" className='text-center text-sm text-disabled-text py-8'>سفارش فعالی وجود ندارد</div>];
}
return orders.map((order) => {
const address = order.userAddress?.address || 'آدرس ثبت نشده';
const formattedDate = formatDate(order.createdAt);
const formattedPrice = order.total.toLocaleString('fa-IR');
const items: HistoryOrderItem[] = order.items || [];
return (
<div key={order.id} className='w-full px-[17px] mb-4 pt-8 pb-6 bg-container rounded-3xl'>
<div className="text-sm2">
<div className='flex items-center justify-between mb-2'>
<div className='flex gap-2 items-center'>
<p>#{order.orderNumber}</p>
</div>
<div className={`flex gap-2 items-center ${getStatusColor(order.status)}`}>
<TickCircle size={16} />
<p className='font-medium'>{t(`status.${order.status}`)}</p>
</div>
</div>
<div className='mt-2 flex gap-2'>
<LocationPinIcon size={16} />
<p>{address}</p>
</div>
<div className='mt-2 flex gap-2'>
<CalendarIcon size={16} />
<p>{formattedDate}</p>
</div>
</div>
<hr className='mx-4 mt-6 text-disabled3 border-2 dark:border-neutral-500' />
<div className='mt-6 space-y-3'>
{items.map((item) => {
const imageUrl = getFoodImage(item.food.images);
const foodName = item.food.title || 'بدون نام';
const itemTotalPrice = (item.totalPrice || item.unitPrice * item.quantity).toLocaleString('fa-IR');
return (
<div key={item.id} className='flex gap-3 items-center'>
<div className='relative w-16 h-16 shrink-0'>
<Image
priority
className='rounded-lg object-cover'
src={imageUrl}
width={64}
height={64}
alt={foodName}
/>
</div>
<div className='flex-1 min-w-0'>
<p className='text-sm2 font-normal text-black dark:text-white truncate'>
{foodName}
</p>
<div className='flex items-center gap-2 mt-1'>
<span className='text-xs text-disabled-text'>
{item.quantity}x
</span>
<span className='text-xs font-medium' dir='ltr'>
{itemTotalPrice} T
</span>
</div>
</div>
</div>
);
})}
</div>
<div className='flex justify-end mt-4'>
<div className='font-medium text-sm' dir='ltr'>
{formattedPrice} T
</div>
</div>
<Link href={`track/${order.id}`}>
<Button className='mt-6 font-medium!'>{t('Card.TrackOrder')}</Button>
</Link>
</div>
)
})
}
const secondTab = () => {
if (orders.length === 0) {
return [<div key="empty" className='text-center text-sm text-disabled-text py-8'>سفارش قبلی وجود ندارد</div>];
}
return orders.map((order) => {
const address = order.userAddress?.address || 'آدرس ثبت نشده';
const formattedDate = formatDate(order.createdAt);
const formattedPrice = order.total.toLocaleString('fa-IR');
const items: HistoryOrderItem[] = order.items || [];
return (
<div key={order.id} className='w-full px-[17px] mb-4 pt-8 pb-6 bg-container rounded-3xl'>
<div className="text-sm2">
<div className='flex items-center justify-between mb-2'>
<div className='flex gap-2 items-center'>
<DocumentText size={16} />
<p>#{order.orderNumber}</p>
</div>
<div className={`flex gap-2 items-center ${getStatusColor(order.status)}`}>
<TickCircle size={16} />
<p className='font-medium'>{t(`status.${order.status}`)}</p>
</div>
</div>
<div className='mt-2 flex gap-2'>
<LocationPinIcon size={16} />
<p>{address}</p>
</div>
<div className='mt-2 flex gap-2'>
<CalendarIcon size={16} />
<p>{formattedDate}</p>
</div>
</div>
<hr className='mx-4 mt-6 text-disabled3 border-2 dark:border-neutral-500' />
<div className='mt-6 space-y-3'>
{items.map((item) => {
const imageUrl = getFoodImage(item.food.images);
const foodName = item.food.title || 'بدون نام';
const itemTotalPrice = (item.totalPrice || item.unitPrice * item.quantity).toLocaleString('fa-IR');
return (
<div key={item.id} className='flex gap-3 items-center'>
<div className='relative w-16 h-16 shrink-0'>
<Image
priority
className='rounded-lg object-cover'
src={imageUrl}
width={64}
height={64}
alt={foodName}
/>
</div>
<div className='flex-1 min-w-0'>
<p className='text-sm2 font-normal text-black dark:text-white truncate'>
{foodName}
</p>
<div className='flex items-center gap-2 mt-1'>
<span className='text-xs text-disabled-text'>
{item.quantity}x
</span>
<span className='text-xs font-medium' dir='ltr'>
{itemTotalPrice} T
</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>
);
})}
</div>
<div className='flex justify-end mt-4'>
<div className='font-medium text-sm' dir='ltr'>
{formattedPrice} T
</div>
</div>
<Button className='mt-6 font-medium! bg-disabled! text-disabled-text! w-full'>{t('Card.Reorder')}</Button>
</div>
)
})
}
return (
<div className='pt-8'>
<TabContainer selectedTab={selectedTab} onTabChange={setSelectedTab}>
<TabHeader
viewRenderer={<div className='pt-4'>{firstTab()}</div>}
title={categories[0].title}
icon={categories[0].icon}
/>
<TabHeader
viewRenderer={<div className='pt-4'>{secondTab()}</div>}
title={categories[1].title}
icon={categories[1].icon}
/>
</TabContainer>
</div>
)
}
export default OrdersIndex
@@ -0,0 +1,30 @@
import { api } from "@/config/axios";
import { OrdersResponse } from "../types/Types";
import { OrderStatus } from "../enum/Enum";
export const getOrders = async (
status: "old" | "current"
): Promise<OrdersResponse> => {
const statuses =
status === "old"
? [OrderStatus.COMPLETED, OrderStatus.CANCELED]
: [
OrderStatus.PENDING_PAYMENT,
OrderStatus.PAID,
OrderStatus.PREPARING,
OrderStatus.DELIVERED_TO_WAITER,
OrderStatus.DELIVERED_TO_RECEPTIONIST,
OrderStatus.SHIPPED,
OrderStatus.COMPLETED,
];
const { data } = await api.get<OrdersResponse>(`/public/orders`, {
params: {
statuses: statuses,
},
paramsSerializer: {
indexes: null,
},
});
return data;
};
@@ -0,0 +1,208 @@
import { BaseResponse } from "@/app/[name]/(Main)/types/Types";
import { ServiceArea } from "@/app/[name]/(Dialogs)/order/checkout/types/Types";
export interface HistoryOrderUser {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
firstName: string;
lastName: string | null;
phone: string;
birthDate: string | null;
marriageDate: string | null;
referrer: string | null;
isActive: boolean;
gender: boolean;
avatarUrl: string | null;
}
export interface HistoryOrderRestaurantScore {
scoreAmount: string;
scoreCredit: string;
birthdayScore: string;
purchaseScore: string;
referrerScore: string;
registerScore: string;
purchaseAmount: string;
marriageDateScore: string;
}
export interface HistoryOrderRestaurant {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
name: string;
slug: string;
logo: string | null;
address: string | null;
menuColor: string | null;
latitude: number | null;
longitude: number | null;
serviceArea: ServiceArea | null;
isActive: boolean;
establishedYear: number | null;
phone: string;
instagram: string | null;
telegram: string | null;
whatsapp: string | null;
description: string | null;
seoTitle: string | null;
seoDescription: string | null;
tagNames: string | null;
images: string | null;
vat: number;
domain: string;
score: HistoryOrderRestaurantScore;
plan: string;
subscriptionId: string;
subscriptionEndDate: string;
subscriptionStartDate: string;
}
export interface HistoryOrderDeliveryMethod {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
method: "dineIn" | "pickup" | "deliveryCar" | "deliveryCourier";
restaurant: string;
deliveryFee: number;
deliveryFeeType: string;
perKilometerFee: number | null;
distanceBasedMinCost: number | null;
minOrderPrice: number;
description: string;
enabled: boolean;
order: number;
}
export interface HistoryOrderUserAddress {
city: string;
phone: string;
address: string;
fullName: string;
latitude: number;
province: string;
longitude: number;
postalCode: string;
}
export interface HistoryOrderAddress {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
user: string;
title: string;
address: string;
city: string;
province: string;
postalCode: string;
latitude: number;
longitude: number;
phone: string;
isDefault: boolean;
}
export interface HistoryOrderPaymentMethod {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
restaurant: string;
method: "CardOnDelivery" | "Cash" | "Online" | "Wallet";
gateway: string | null;
description: string;
enabled: boolean;
order: number;
merchantId: string | null;
}
export interface HistoryOrderFood {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
restaurant: string;
category: string;
title: string;
desc: string | null;
content: string | null;
price: number;
order: number | null;
prepareTime: number | null;
weekDays: number[];
mealTypes: ("breakfast" | "lunch" | "dinner" | "snack")[];
isActive: boolean;
images: string[] | null;
inPlaceServe: boolean;
pickupServe: boolean;
score: number;
discount: number;
isSpecialOffer: boolean;
}
export interface HistoryOrderItem {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
order: string;
food: HistoryOrderFood;
quantity: number;
unitPrice: number;
discount: number;
totalPrice: number;
}
export interface HistoryOrderHistoryItem {
status: string;
changedAt: string;
}
export interface HistoryOrder {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
user: HistoryOrderUser;
restaurant: HistoryOrderRestaurant;
deliveryMethod: HistoryOrderDeliveryMethod;
userAddress: HistoryOrderUserAddress | null;
carAddress: HistoryOrderUserAddress | null;
paymentMethod: HistoryOrderPaymentMethod;
orderNumber: number;
couponDiscount: number;
couponDetail: unknown | null;
itemsDiscount: number;
totalDiscount: number;
subTotal: number;
tax: number;
deliveryFee: number;
total: number;
totalItems: number;
description: string;
tableNumber: string | null;
status: string;
history: HistoryOrderHistoryItem[];
items: HistoryOrderItem[];
}
export interface OrdersResponseMeta {
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface OrdersResponseData {
data: HistoryOrder[];
meta: OrdersResponseMeta;
}
export type OrdersResponse = BaseResponse<HistoryOrder[]> & {
meta?: OrdersResponseMeta;
};
@@ -0,0 +1,223 @@
'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';
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='bg-container 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
@@ -0,0 +1,15 @@
export enum PositivePointEnum {
GREAT_TASTE = "great_taste",
FAST_DELIVERY = "fast_delivery",
GOOD_QUALITY = "good_quality",
GOOD_PORTION_SIZE = "good_portion_size",
FRIENDLY_SERVICE = "friendly_service",
}
export enum NegativePointEnum {
SMALL_PORTION = "small_portion",
SLOW_DELIVERY = "slow_delivery",
POOR_QUALITY = "poor_quality",
OVERPRICED = "overpriced",
UNFRIENDLY_SERVICE = "unfriendly_service",
}
@@ -0,0 +1,8 @@
import { useMutation } from "@tanstack/react-query";
import * as api from "../service/RateService";
export const useCreateRate = () => {
return useMutation({
mutationFn: api.createRate,
});
};
@@ -0,0 +1,7 @@
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;
};
@@ -0,0 +1,8 @@
export type CreateRateType = {
foodId: string;
orderId: string;
rating: number;
comment: string;
positivePoints: string[];
negativePoints: string[];
};
+245
View File
@@ -0,0 +1,245 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import SearchBox from "@/components/input/SearchBox";
import TextAlignIcon from "@/components/icons/TextAlignIcon";
import VerticalScrollView from "@/components/listview/VerticalScrollView";
import MenuItemRenderer from "@/components/listview/MenuItemRenderer";
import MenuItem from "@/components/listview/MenuItem";
import { Candle2 } from "iconsax-react";
import { useQueryState } from "next-usequerystate";
import clsx from "clsx";
import { motion } from "framer-motion";
import { useTranslation } from "react-i18next";
import useToggle from "@/hooks/helpers/useToggle";
import CategoryScroll from "@/app/[name]/(Main)/components/CategoryScroll";
import MenuFilterDrawer from "@/app/[name]/(Main)/components/MenuFilterDrawer";
import MenuSortingDrawer from "@/app/[name]/(Main)/components/MenuSortingDrawer";
import MenuSkeleton from "@/app/[name]/(Main)/components/MenuSkeleton";
import { useGetCategories, useGetFoods } from "./hooks/useMenuData";
import type { Food, Category } from "./types/Types";
const sortings = ["PopularityDescending", "RateDescending", "PriceAscending", "PriceDescending"] as const;
const MenuIndex = () => {
const { data: foodsData, isLoading: foodsLoading } = useGetFoods();
const { data: categoriesData, isLoading: categoriesLoading } = useGetCategories();
const foods = useMemo<Food[]>(() => foodsData?.data || [], [foodsData?.data]);
const categories = useMemo<Category[]>(() => categoriesData?.data || [], [categoriesData?.data]);
const isLoading = foodsLoading || categoriesLoading;
const { t: tCommon } = useTranslation('common');
const { t: tMenu } = useTranslation('menu', { keyPrefix: "Menu" });
const { state: filterModal, toggle: toggleFilterModal } = useToggle();
const { state: sortingModal, toggle: toggleSortingModal } = useToggle();
const [sorting, setSorting] = useQueryState('sortBy', { defaultValue: '0' });
const [search, setSearch] = useQueryState("q", { defaultValue: '' });
const [selectedCategory, setSelectedCategory] = useQueryState('category', { defaultValue: '0' });
const [selectedIngredients, setSelectedIngredients] = useQueryState('ingredients', { defaultValue: '' });
const [selectedDeliveryId, setSelectedDeliveryId] = useQueryState('delivery', { defaultValue: '0' });
const smallCategoriesRef: React.RefObject<HTMLDivElement | null> = useRef(null);
const wrapperRef: React.RefObject<HTMLDivElement | null> = useRef(null);
const [smallCategoriesVisible, setSmallCategoriesVisibility] = useState(false);
const [isInitialMount, setIsInitialMount] = useState(true);
useEffect(() => {
if (isInitialMount) {
setSearch(null);
// category را نگه می‌داریم تا در بک زدن حفظ شود
// setSelectedCategory(null);
setSelectedIngredients(null);
setSelectedDeliveryId(null);
setSorting(null);
setIsInitialMount(false);
}
}, [isInitialMount, setSearch, setSelectedIngredients, setSelectedDeliveryId, setSorting]);
const onScroll = useCallback(() => {
if (!wrapperRef?.current?.parentElement?.parentElement?.scrollTop || !smallCategoriesRef.current?.offsetTop) return;
if (wrapperRef.current.parentElement?.parentElement.scrollTop >= smallCategoriesRef.current.offsetTop) {
setSmallCategoriesVisibility(true);
} else {
setSmallCategoriesVisibility(false);
}
}, [wrapperRef, smallCategoriesRef]);
useEffect(() => {
if (!wrapperRef.current) return;
const parent = wrapperRef.current.parentElement?.parentElement;
if (!parent) return;
parent.addEventListener('scroll', onScroll);
return () => {
parent.removeEventListener('scroll', onScroll);
}
}, [onScroll]);
const updateSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
}, [setSearch]);
const updateCategory = useCallback((categoryId: string) => {
setSelectedCategory(categoryId);
}, [setSelectedCategory]);
const sortingIndex = Number(sorting);
const activeSortingIndex = Number.isNaN(sortingIndex) ? 0 : sortingIndex;
const activeSortingKey = sortings[activeSortingIndex] ?? sortings[0];
const activeSortingLabel = tMenu(`MenuSortingDrawer.Options.${activeSortingKey}`);
const changeSorting = (index: number) => {
setSorting(() => String(index));
toggleSortingModal();
};
const changeSelectedIngredients = useCallback((value: string) => {
setSelectedIngredients(value);
}, [setSelectedIngredients]);
const changeSelectedDelivery = useCallback((value: string) => {
setSelectedDeliveryId(() => value);
}, [setSelectedDeliveryId]);
const filteredReceiptItems = useMemo(() => {
if (!foods.length) return [];
const lowerSearch = search.toLowerCase();
const lowerIngredients = selectedIngredients ? selectedIngredients.toLowerCase() : "";
const selectedCatId = selectedCategory === '0' ? null : selectedCategory;
const filtered = foods.filter((item) => {
const matchesCategory = !selectedCatId || item.category?.id === selectedCatId;
const itemName = item.name || item.title || item.foodName || "";
const matchesSearch =
!search || itemName.toLowerCase().includes(lowerSearch);
const matchesIngredients = !selectedIngredients ||
(() => {
// ابتدا در محتویات جستجو می‌کنیم
if (item.content && Array.isArray(item.content) && item.content.length > 0) {
return item.content.some((content: string) =>
content.toLowerCase().includes(lowerIngredients)
);
}
// اگر محتویات وجود نداشت یا خالی بود، در توضیحات جستجو می‌کنیم
const description = item.description || item.desc || "";
return description.toLowerCase().includes(lowerIngredients);
})();
const matchesDelivery = selectedDeliveryId === "0" ||
(selectedDeliveryId === "pickup" && item.pickupServe) ||
(selectedDeliveryId === "inPlace" && item.inPlaceServe);
return matchesCategory && matchesSearch && matchesIngredients && matchesDelivery;
});
const sortingKey = sortings[Number(sorting)] || sortings[0];
return [...filtered].sort((a, b) => {
switch (sortingKey) {
case "PopularityDescending":
return (b.points ?? 0) - (a.points ?? 0);
case "RateDescending":
return (b.rate ?? 0) - (a.rate ?? 0);
case "PriceAscending":
return (a.price ?? 0) - (b.price ?? 0);
case "PriceDescending":
return (b.price ?? 0) - (a.price ?? 0);
default:
return 0;
}
});
}, [selectedCategory, search, selectedIngredients, selectedDeliveryId, foods, sorting]);
if (isLoading) {
return <MenuSkeleton />;
}
return (
<div className="flex flex-col gap-4 items-center pt-8 mb-8" ref={wrapperRef}>
<div className="w-full">
<SearchBox value={search} placeholder={tCommon('SearchPlaceholder')} onChange={updateSearch} />
<CategoryScroll
categories={categories}
selectedCategory={selectedCategory}
onSelect={updateCategory}
/>
</div>
<section className="w-full">
<div className="flex gap-2 justify-between items-center relative" ref={smallCategoriesRef} >
<span className="sm:text-base text-sm font-medium">
{selectedCategory === '0' ? '' : categories.find(c => c.id === selectedCategory)?.title || ''}
</span>
<div className="inline-flex min-w-[247px] gap-2 justify-around items-center">
<button onClick={toggleFilterModal} className="rounded-xl h-8 bg-container ps-4 pe-2 py-1.5 inline-flex items-center justify-between gap-[7px]">
<Candle2 className="stroke-foreground" size={16} />
<span className="text-xs leading-5 font-medium">{tMenu('MenuFilterDrawer.Label')}</span>
</button>
<button
onClick={toggleSortingModal}
className="rounded-xl h-8 bg-container ps-4 pe-2 py-1.5 inline-flex items-center gap-2"
>
<TextAlignIcon className="text-foreground" />
<span className="text-xs leading-5 font-medium">{activeSortingLabel}</span>
</button>
</div>
</div>
<motion.div
initial={{ y: smallCategoriesVisible ? 0 : -50, opacity: smallCategoriesVisible ? 1 : 0 }}
animate={{ y: smallCategoriesVisible ? 0 : -50, opacity: smallCategoriesVisible ? 1 : 0 }}
transition={{ duration: .1 }}
className={clsx(
'fixed left-0 z-10 top-0 px-4 pt-16 bg-[#F4F5F9CC] dark:bg-background/70 backdrop-blur-[44px] right-0 xl:pr-72 xl:pt-20',
``
)}>
<CategoryScroll
categories={categories}
selectedCategory={selectedCategory}
onSelect={updateCategory}
variant="small"
/>
</motion.div>
<VerticalScrollView className="mt-5! overflow-y-auto h-full">
{filteredReceiptItems.length === 0 ? (
<div className="text-center text-foreground/60 py-8">
{foodsData ? 'غذایی یافت نشد' : 'در حال بارگذاری...'}
</div>
) : (
filteredReceiptItems.map((food) => (
<MenuItemRenderer key={food.id}>
<MenuItem food={food} />
</MenuItemRenderer>
))
)}
</VerticalScrollView>
</section>
<MenuFilterDrawer
visible={filterModal}
onClose={toggleFilterModal}
selectedIngredients={selectedIngredients}
selectedDeliveryId={selectedDeliveryId}
onIngredientsChange={changeSelectedIngredients}
onDeliveryChange={changeSelectedDelivery}
onApply={() => { }}
tMenu={tMenu}
/>
<MenuSortingDrawer
visible={sortingModal}
onClose={toggleSortingModal}
sortings={sortings}
activeIndex={activeSortingIndex}
onSelect={changeSorting}
tMenu={tMenu}
/>
</div>
);
};
export default MenuIndex;
@@ -0,0 +1,31 @@
import { api } from "@/config/axios";
import {
CategoriesResponse,
FoodsResponse,
NotificationsCountResponse,
} from "../types/Types";
export const getFoods = async (slug: string): Promise<FoodsResponse> => {
const response = await api.get<FoodsResponse>(
`/public/foods/restaurant/${slug}`
);
return response.data;
};
export const getCategories = async (
slug: string
): Promise<CategoriesResponse> => {
const response = await api.get<CategoriesResponse>(
`/public/categories/restaurant/${slug}`
);
return response.data;
};
export const getNotificationsCount = async (): Promise<
NotificationsCountResponse
> => {
const response = await api.get<NotificationsCountResponse>(
`/public/notifications/unseen-count`
);
return response.data;
};
@@ -0,0 +1,239 @@
'use client'
import * as React from 'react'
import {
ColumnDef,
ColumnFiltersState,
SortingState,
VisibilityState,
flexRender,
getCoreRowModel,
getFilteredRowModel,
getPaginationRowModel,
getSortedRowModel,
useReactTable
} from '@tanstack/react-table'
import { Button } from '@/components/ui/button'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table'
import { ArrowLeft2, ArrowRight2 } from 'iconsax-react'
import { ef } from '@/lib/helpers/utfNumbers'
import { Transaction } from '../types/Types'
import Status from '@/components/utils/Status'
interface TransactionsTableProps {
transactions: Transaction[]
}
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleDateString('fa-IR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
};
export const columns: ColumnDef<Transaction>[] = [
{
accessorKey: 'order.orderNumber',
header: () => <div className='text-right pr-6'>شماره سفارش</div>,
cell: ({ row }) => {
const orderNumber = row.original.order?.orderNumber;
return (
<div className='text-right pr-6'>{ef(orderNumber?.toString() || '-')}</div>
)
}
},
{
accessorKey: 'type',
header: () => <div className='text-center'>نوع تراکنش</div>,
cell: ({ row }) => {
const type = row.original.order?.paymentMethod?.description || '-';
return <div className='text-center'>{type}</div>
}
},
{
accessorKey: 'amount',
header: () => <div className='text-center'>مبلغ</div>,
cell: ({ row }) => {
const amount = row.original.amount;
const formatted = Math.abs(amount).toLocaleString('fa-IR');
return <div className='text-center'>{ef(formatted)} ریال</div>
}
},
{
accessorKey: 'createdAt',
header: () => <div className='text-center'>تاریخ</div>,
cell: ({ row }) => {
const date = formatDate(row.original.createdAt);
return <div className='text-center'>{ef(date)}</div>
}
},
{
accessorKey: 'status',
header: () => <div className='text-center'>وضعیت</div>,
cell: ({ row }) => {
const status = row.original.status;
return <Status status={status} className='text-right' />
}
}
]
export function TransactionsTable({ transactions }: TransactionsTableProps) {
const [sorting, setSorting] = React.useState<SortingState>([])
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
[]
)
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({})
const [rowSelection, setRowSelection] = React.useState({})
const table = useReactTable({
data: transactions,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility,
onRowSelectionChange: setRowSelection,
state: {
sorting,
columnFilters,
columnVisibility,
rowSelection
},
initialState: {
pagination: {
pageSize: 8
}
}
})
return (
<div className='w-full'>
<div className='rounded-3xl min-h-[507px] w-full'>
<Table className='w-[727px] md:w-full'>
<TableHeader>
{table.getHeaderGroups().map(headerGroup => (
<TableRow
className='dark:border-neutral-400 border-[#EAEDF5] h-14'
key={headerGroup.id}
>
{headerGroup.headers.map(header => {
return (
<TableHead
key={header.id}
className='font-light text-xs text-disabled-text text-center'
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map(row => (
<TableRow
key={row.id}
className='text-center w-full border-b-[#EAEDF5] dark:border-b-background text-xs odd:bg-[#E9EBF433] bg-border dark:odd:bg-background/80 even:bg-container border-0 border-b! h-14 border-r-4! border-r-transparent active:border-r-primary dark:active:border-r-foreground'
>
{row.getVisibleCells().map(cell => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow>
<TableCell
colSpan={columns.length}
className='h-24 text-center'
>
تراکنشی یافت نشد.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<div className='flex items-center justify-end space-x-2 pt-4 px-7 pb-11 select-none'>
<div className='flex-1 text-sm text-disabled-text'>
نمایش:{' '}
<span className='text-primary dark:text-foreground'>
{ef(table.getRowModel().rows.length)}
</span>{' '}
از {ef(table.getFilteredRowModel().rows.length)}
</div>
<div className='space-x-2 justify-center flex'>
<Button
variant='ghost'
size='sm'
className='size-6'
onClick={() => table.previousPage()}
hidden={!table.getCanPreviousPage()}
>
<ArrowRight2 size={12} className='stroke-[#A8ABBF]' />
</Button>
<Button
variant='secondary'
size='sm'
className='dark:bg-disabled bg-[#EAECF5] text-xs size-6'
onClick={() => table.previousPage()}
hidden={!table.getCanPreviousPage()}
>
{ef(table.getState().pagination.pageIndex)}
</Button>
<Button
variant='default'
className='text-white dark:text-foreground text-xs size-6'
size='sm'
hidden={!table.getCanPreviousPage() && !table.getCanNextPage()}
>
{ef(table.getState().pagination.pageIndex + 1)}
</Button>
<Button
variant='secondary'
size='sm'
className='dark:bg-disabled bg-[#EAECF5] text-xs size-6'
onClick={() => table.nextPage()}
hidden={!table.getCanNextPage()}
>
{ef(table.getState().pagination.pageIndex + 2)}
</Button>
<Button
variant='ghost'
size='sm'
className='size-6'
onClick={() => table.nextPage()}
hidden={!table.getCanNextPage()}
>
<ArrowLeft2 size={12} className='stroke-[#A8ABBF]' />
</Button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,131 @@
'use client';
import { ef } from '@/lib/helpers/utfNumbers'
import { Cup, Star1, MoneyRecive } from 'iconsax-react'
import React from 'react'
import { useConvertScoreToWallet, useGetUserPointsBalance, useGetUserWallet } from '../../hooks/useTransactionData';
import { Button } from '@/components/ui/button';
import { useGetAbout } from '../../../about/hooks/useAboutData';
import { toast } from '@/components/Toast';
import { extractErrorMessage } from '@/lib/func';
function TransactionsIndex() {
const { data: userWallet } = useGetUserWallet();
const { data: userPointsBalance } = useGetUserPointsBalance();
const { data: aboutData } = useGetAbout();
const { mutate: convertScoreToWallet, isPending: isConverting } = useConvertScoreToWallet();
const userPoints = userPointsBalance?.data?.balance ?? 0;
const userWalletAmount = userWallet?.data?.balance ?? 0;
const conversionRate = Number(aboutData?.data?.score?.purchaseScore ?? 1000);
const calculatedAmount = Math.floor(userPoints / conversionRate) * Number(aboutData?.data?.score?.purchaseAmount ?? 1);
const handleConvert = async () => {
convertScoreToWallet(undefined, {
onSuccess: () => {
toast('تبدیل امتیاز به اعتبار با موفقیت انجام شد', 'success');
window.location.reload();
},
onError: (error) => {
toast(extractErrorMessage(error), 'error');
},
});
};
return (
<section className='pt-6 pb-10'>
<h1 className='font-medium dark:text-foreground'>باشگاه مشتریان</h1>
<section
aria-label="درباره باشگاه مشتریان"
className='mt-3 bg-container dark:bg-container rounded-normal grid grid-cols-4 items-center border border-transparent dark:border-border/50'
>
<div className='col-span-3 py-5 pe-7 ps-5 relative h-36.5'>
<div className='w-full h-full absolute top-0 left-0 overflow-clip text-muted-foreground/30 dark:text-foreground/20'>
<svg width="301" height="146" viewBox="0 0 301 146" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.15">
<path d="M262.479 152.795C202.8 122.128 120.111 132.267 77.763 175.315C35.4154 218.362 49.3247 278.14 109.004 308.807C168.683 339.474 251.373 329.335 293.721 286.287C336.069 243.24 322.159 183.462 262.479 152.795Z" stroke="currentColor" />
<path d="M298.808 115.866C239.129 85.1988 156.44 95.3379 114.092 138.385C71.7445 181.433 85.6538 241.21 145.333 271.877C205.012 302.544 287.702 292.405 330.05 249.358C372.398 206.31 358.488 146.533 298.808 115.866Z" stroke="currentColor" />
<path d="M347.543 66.3263C287.863 35.6595 205.174 45.7986 162.826 88.8461C120.479 131.894 134.388 191.671 194.067 222.338C253.747 253.005 336.437 242.866 378.784 199.819C421.132 156.771 407.222 96.9933 347.543 66.3263Z" stroke="currentColor" />
<path d="M416.657 -3.92933C356.977 -34.5961 274.288 -24.457 231.941 18.5905C189.593 61.638 203.502 121.415 263.182 152.082C322.861 182.749 405.551 172.61 447.899 129.563C490.246 86.5153 476.336 26.7377 416.657 -3.92933Z" stroke="currentColor" />
</g>
</svg>
</div>
<div className='flex flex-col justify-between h-full relative z-10'>
<div>
<h2 className='font-bold text-base dark:text-foreground'>در باشگاه مشتریان</h2>
<p className='text-xs mt-[7px] dark:text-disabled-text'>با امتیازی که داری سفارش بده!</p>
</div>
<p className='text-xs font-medium dark:text-foreground'>مجموع امتیازات شما: {ef(userPointsBalance?.data?.balance?.toString() ?? '0')}</p>
</div>
</div>
<div
className='max-w-36 place-self-end col-span-1 h-full text-end py-4 px-5.5 flex flex-col justify-between rounded-e-normal bg-linear-to-br from-primary to-menu-header'
>
<p className='text-sm2 text-white leading-tight'>
<span className='font-normal'>Customer</span><br />
<span className='font-medium'>Club</span>
</p>
<div className='place-self-center w-fit border max-h-6 flex justify-center items-center gap-1 border-white rounded-sm py-1 px-1.5'>
<Star1 className='fill-white stroke-white' size={8} />
<Star1 className='fill-white stroke-white' size={8} />
<Star1 className='fill-white stroke-white' size={8} />
<Cup className='stroke-white' size={16} />
</div>
</div>
</section>
{/* بخش نمایش اعتبار کاربر */}
<section className='mt-6 bg-container dark:bg-container rounded-normal p-5 border border-transparent dark:border-border/50'>
<div className='flex items-center gap-2 mb-4'>
<MoneyRecive color='currentColor' size={24} className='text-primary dark:text-foreground' variant='Bold' />
<h2 className='font-bold text-base dark:text-foreground'>اعتبار شما</h2>
</div>
<div className='bg-linear-to-l from-primary/10 to-primary/5 dark:from-primary/20 dark:to-primary/10 rounded-md p-4'>
<div className='flex justify-between items-center'>
<span className='text-sm text-muted-foreground dark:text-disabled-text'>موجودی کیف پول:</span>
<span className='font-bold text-lg text-primary dark:text-foreground'>{ef(userWalletAmount.toLocaleString())} تومان</span>
</div>
</div>
</section>
{/* بخش تبدیل امتیاز به اعتبار */}
<section className='mt-6 bg-container dark:bg-container rounded-normal p-5 border border-transparent dark:border-border/50'>
<div className='flex items-center gap-2 mb-4'>
<MoneyRecive color='currentColor' size={24} className='text-primary dark:text-foreground' variant='Bold' />
<h2 className='font-bold text-base dark:text-foreground'>تبدیل امتیاز به اعتبار</h2>
</div>
<p className='text-sm text-muted-foreground dark:text-disabled-text mb-4'>
هر {ef(aboutData?.data?.score?.purchaseScore?.toString() ?? '0')} امتیاز معادل {ef(aboutData?.data?.score?.purchaseAmount?.toString() ?? '0')} تومان اعتبار است
</p>
<div className='space-y-4'>
<div className='bg-accent/50 dark:bg-disabled/30 rounded-md p-4 space-y-2 border border-border/50 dark:border-border'>
<div className='flex justify-between items-center'>
<span className='text-sm text-muted-foreground dark:text-disabled-text'>امتیاز شما:</span>
<span className='font-bold text-lg dark:text-foreground'>{ef(userPoints.toString())}</span>
</div>
<div className='flex justify-between items-center'>
<span className='text-sm text-muted-foreground dark:text-disabled-text'>دریافت میکنید:</span>
<span className='font-bold text-lg text-primary dark:text-foreground'>{ef(calculatedAmount.toString())} تومان</span>
</div>
</div>
<Button
onClick={handleConvert}
disabled={userPoints <= 0 || isConverting}
className='w-full text-xs dark:bg-white dark:text-gray-900 dark:hover:bg-white/90'
>
{isConverting ? 'در حال تبدیل...' : 'تبدیل تمام امتیازات'}
</Button>
</div>
</section>
</section>
)
}
export default TransactionsIndex
@@ -0,0 +1,123 @@
'use client';
import { Button } from '@/components/ui/button'
import React, { useCallback } from 'react'
import { useGetMyCoupons } from '../hooks/useTransactionData';
import { toast } from '@/components/Toast';
import type { Coupon } from '../types/Types';
function TransactionsIndex() {
const { data: myCoupons, isLoading } = useGetMyCoupons();
const handleCopyCode = useCallback(async (code: string) => {
try {
await navigator.clipboard.writeText(code);
toast('کد تخفیف با موفقیت کپی شد', 'success');
} catch {
toast('خطا در کپی کردن کد تخفیف', 'error');
}
}, []);
const formatDiscount = useCallback((coupon: Coupon) => {
if (coupon.type === 'PERCENTAGE') {
return `${coupon.value}%`;
}
return `${coupon.value.toLocaleString('fa-IR')} تومان`;
}, []);
const coupons = myCoupons?.data ?? [];
return (
<section className='pt-6'>
<h1 className='font-medium'>کدهای تخفیف</h1>
<section
aria-label="درباره کدهای تخفیف"
className='mt-3 bg-container rounded-normal grid grid-cols-4 items-center'
>
<div className='col-span-3 py-9.5 pe-7 ps-5 relative'>
<div className='w-full h-full absolute top-0 left-0 overflow-clip'>
<svg width="301" height="146" viewBox="0 0 301 146" fill="none" xmlns="http://www.w3.org/2000/svg">
<g opacity="0.15">
<path d="M262.479 152.795C202.8 122.128 120.111 132.267 77.763 175.315C35.4154 218.362 49.3247 278.14 109.004 308.807C168.683 339.474 251.373 329.335 293.721 286.287C336.069 243.24 322.159 183.462 262.479 152.795Z" stroke="#AFAFAF" />
<path d="M298.808 115.866C239.129 85.1988 156.44 95.3379 114.092 138.385C71.7445 181.433 85.6538 241.21 145.333 271.877C205.012 302.544 287.702 292.405 330.05 249.358C372.398 206.31 358.488 146.533 298.808 115.866Z" stroke="#AFAFAF" />
<path d="M347.543 66.3263C287.863 35.6595 205.174 45.7986 162.826 88.8461C120.479 131.894 134.388 191.671 194.067 222.338C253.747 253.005 336.437 242.866 378.784 199.819C421.132 156.771 407.222 96.9933 347.543 66.3263Z" stroke="#AFAFAF" />
<path d="M416.657 -3.92933C356.977 -34.5961 274.288 -24.457 231.941 18.5905C189.593 61.638 203.502 121.415 263.182 152.082C322.861 182.749 405.551 172.61 447.899 129.563C490.246 86.5153 476.336 26.7377 416.657 -3.92933Z" stroke="#AFAFAF" />
</g>
</svg>
</div>
<h2 className='font-bold text-base'>کدهای تخفیف شما</h2>
<p className='text-xs mt-[7px]'>کد تخفیف مورد نظر را کپی کنید و در بخش پرداختها استفاده کنید</p>
</div>
<div
className='col-span-1 h-full text-end py-4 px-5.5 flex flex-col justify-between rounded-e-normal bg-linear-to-br from-primary to-menu-header'
>
<p className='text-sm2 text-white leading-tight'>
<span className='font-medium'>Your</span><br />
<span className='font-bold'>Coupon</span>
</p>
<svg className='w-full' height="20" viewBox="0 0 72 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M4.0033 0H0.845703V20H4.0033V0Z" fill="white" />
<path d="M9.266 0H6.1084V20H9.266V0Z" fill="white" />
<path d="M12.4236 0H11.3711V20H12.4236V0Z" fill="white" />
<path d="M16.6344 0H14.5293V20H16.6344V0Z" fill="white" />
<path d="M19.7916 0H17.6865V20H19.7916V0Z" fill="white" />
<path d="M24.0018 0H22.9492V20H24.0018V0Z" fill="white" />
<path d="M28.2113 0H25.0537V20H28.2113V0Z" fill="white" />
<path d="M30.3182 0H29.2656V20H30.3182V0Z" fill="white" />
<path d="M33.4744 0H32.4219V20H33.4744V0Z" fill="white" />
<path d="M36.6324 0H34.5273V20H36.6324V0Z" fill="white" />
<path d="M42.9478 0H40.8428V20H42.9478V0Z" fill="white" />
<path d="M47.1576 0H44V20H47.1576V0Z" fill="white" />
<path d="M49.2625 0H48.21V20H49.2625V0Z" fill="white" />
<path d="M55.5785 0H52.4209V20H55.5785V0Z" fill="white" />
<path d="M57.6824 0H56.6299V20H57.6824V0Z" fill="white" />
<path d="M62.9455 0H58.7354V20H62.9455V0Z" fill="white" />
<path d="M66.1031 0H63.998V20H66.1031V0Z" fill="white" />
<path d="M71.3666 0H68.209V20H71.3666V0Z" fill="white" />
</svg>
</div>
</section>
{isLoading ? (
<div className='flex flex-col items-center justify-center gap-2 py-12 text-sm2 text-muted-foreground mt-6'>
<span className='h-4 w-4 animate-spin rounded-full border border-dashed border-foreground border-t-transparent'></span>
<p>در حال دریافت کدهای تخفیف...</p>
</div>
) : coupons.length === 0 ? (
<div className='flex flex-col items-center justify-center gap-2 py-12 text-sm2 text-muted-foreground mt-6'>
<p>کد تخفیفی موجود نیست</p>
</div>
) : (
<section className="mt-6 space-y-6" aria-label="لیست کدهای تخفیف">
{coupons.map((coupon) => (
<article
key={coupon.id}
className='bg-container rounded-normal flex justify-between items-center'
>
<div className='py-4 pe-7 ps-5 border-e-2 w-full border-border border-dashed'>
<h3 className='font-bold text-sm2'>مبلغ تخفیف: {formatDiscount(coupon)}</h3>
<p className='text-xs mt-[7px]'>{coupon.description || 'قابل استفاده برای همه‌ی منوها و محصولات'}</p>
<Button
className='w-fit text-white font-bold px-10 py-1.5 mt-4 text-xs rounded-lg dark:bg-white dark:text-black dark:hover:bg-gray-100'
aria-label="کپی کد تخفیف"
onClick={() => handleCopyCode(coupon.code)}
>
کپی کد
</Button>
</div>
<div className='w-14 h-full flex flex-1 justify-center items-center'>
<p className='text-sm2 transform -rotate-90 text-[#B2B2B2] font-medium' aria-label="کد تخفیف">
{coupon.code}
</p>
</div>
</article>
))}
</section>
)}
</section>
)
}
export default TransactionsIndex
@@ -0,0 +1,6 @@
export const enum PaymentStatusEnum {
Pending = "pending",
Paid = "paid",
Failed = "failed",
Refunded = "refunded",
}
@@ -0,0 +1,36 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import * as api from "../service/TransactionService";
export const useGetMyCoupons = () => {
return useQuery({
queryKey: ["my-coupons"],
queryFn: api.getMyCoupons,
});
};
export const useGetUserWallet = () => {
return useQuery({
queryKey: ["user-wallet"],
queryFn: api.getUserWalletBalance,
});
};
export const useGetUserPointsBalance = () => {
return useQuery({
queryKey: ["user-points-balance"],
queryFn: api.getUserPointsBalance,
});
};
export const useConvertScoreToWallet = () => {
return useMutation({
mutationFn: api.convertScoreToWallet,
});
};
export const useGetTransactions = () => {
return useQuery({
queryKey: ["transactions"],
queryFn: api.getTransactions,
});
};
@@ -0,0 +1,66 @@
'use client';
import React from 'react'
import { useGetTransactions } from './hooks/useTransactionData';
import { TransactionsTable } from './components/TransactionsTable';
import LoadingOverlay from '@/components/overlays/LoadingOverlay';
function TransactionsReviewIndex() {
const { data: transactionsResponse, isLoading: isLoadingTransactions } = useGetTransactions();
const transactions = transactionsResponse?.data || [];
const isLoading = isLoadingTransactions;
return (
<section className='flex flex-col h-full pb-10 pt-6 gap-4 relative'>
{isLoading && <LoadingOverlay />}
<h1 className='font-medium text-base'>لیست تراکنش ها</h1>
{/* <div className='grid grid-cols-3 gap-2 h-28'>
<div className='w-full h-full flex flex-col items-center justify-center p-2.5 text-center bg-container rounded-xl'>
<div className='size-8 bg-background rounded-full grid items-center'>
<MoneySend
className='stroke-primary justify-self-center dark:stroke-neutral-200'
size={20}
/>
</div>
<span className='text-xs2 pt-1'>مجموع واریز ها</span>
<span className='text-xs font-medium mt-1 mb-0.5'>
{formatPrice(stats.totalDeposits)} ریال
</span>
</div>
<div className='w-full h-full flex flex-col items-center justify-center p-2.5 text-center bg-container rounded-xl'>
<div className='size-8 bg-background rounded-full grid items-center'>
<MoneyRecive
className='stroke-primary justify-self-center dark:stroke-neutral-200'
size={20}
/>
</div>
<span className='text-xs2 pt-1'>مجموع برداشت ها</span>
<span className='text-xs font-medium mt-1 mb-0.5'>
{formatPrice(stats.totalWithdrawals)} ریال
</span>
</div>
<div className='w-full h-full flex flex-col items-center justify-center p-2.5 text-center bg-container rounded-xl'>
<div className='size-8 bg-background rounded-full grid items-center'>
<Wallet
className='stroke-primary justify-self-center dark:stroke-neutral-200'
size={20}
/>
</div>
<span className='text-xs2 pt-1'>موجودی کیف پول</span>
<span className='text-xs font-medium mt-1 mb-0.5'>
{formatPrice(walletAmount)} ریال
</span>
</div>
</div> */}
<div className='w-full h-full bg-container rounded-3xl mt-0'>
<TransactionsTable transactions={transactions} />
</div>
</section>
)
}
export default TransactionsReviewIndex
@@ -0,0 +1,38 @@
import { api } from "@/config/axios";
import {
CouponsResponse,
WalletBalanceResponse,
TransactionsResponse,
} from "../types/Types";
export const getMyCoupons = async (): Promise<CouponsResponse> => {
const { data } = await api.get<CouponsResponse>("/public/coupons/me");
return data;
};
export const getUserWalletBalance = async (): Promise<
WalletBalanceResponse
> => {
const { data } = await api.get<WalletBalanceResponse>(
"/public/user/wallet/balance"
);
return data;
};
export const getUserPointsBalance = async (): Promise<
WalletBalanceResponse
> => {
const { data } = await api.get<WalletBalanceResponse>(
"/public/user/points/balance"
);
return data;
};
export const convertScoreToWallet = async () => {
const { data } = await api.post("/public/user/convert-score-to-wallet");
return data;
};
export const getTransactions = async (): Promise<TransactionsResponse> => {
const { data } = await api.get<TransactionsResponse>("/public/payments");
return data;
};
@@ -0,0 +1,106 @@
import { BaseResponse } from "@/app/[name]/(Main)/types/Types";
export interface Coupon {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
restaurant: string;
code: string;
name: string;
description: string;
type: "PERCENTAGE" | "FIXED";
value: number;
maxDiscount: number | null;
minOrderAmount: number;
maxUses: number;
usedCount: number;
maxUsesPerUser: number | null;
startDate: string | null;
endDate: string | null;
isActive: boolean;
foodCategories: unknown | null;
foods: unknown | null;
userPhone: string | null;
}
export type CouponsResponse = BaseResponse<Coupon[]>;
export interface Wallet {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
restaurant: string;
user: string;
wallet: number;
points: number;
}
export type WalletResponse = BaseResponse<Wallet>;
export interface WalletBalance {
balance: number;
}
export type WalletBalanceResponse = BaseResponse<WalletBalance>;
export interface PaymentMethod {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
restaurant: string;
title: string;
method: string;
gateway: string | null;
description: string;
enabled: boolean;
order: number;
merchantId: string | null;
}
export interface TransactionOrder {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
user: string;
restaurant: string;
deliveryMethod: string;
address: string | null;
paymentMethod: PaymentMethod;
orderNumber: number;
couponDiscount: number;
couponDetail: unknown | null;
itemsDiscount: number;
totalDiscount: number;
subTotal: number;
tax: number;
deliveryFee: number;
total: number;
totalItems: number;
description: string | null;
tableNumber: string;
status: string;
paymentStatus: string;
}
export interface Transaction {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
order: TransactionOrder;
amount: number;
authority: string | null;
gateway: string | null;
refId: string | null;
status: string;
cardPan: string | null;
verifyResponse: unknown | null;
paidAt: string | null;
failedAt: string | null;
}
export type TransactionsResponse = BaseResponse<Transaction[]>;
+84
View File
@@ -0,0 +1,84 @@
export interface BaseResponse<T = unknown> {
statusCode?: number;
success: boolean;
data: T;
message?: string;
}
export interface Category {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
title: string;
isActive: boolean;
restaurant: string;
avatarUrl: string | null;
}
export type CategoriesResponse = BaseResponse<Category[]>;
export type FoodImage = string | { url: string; alt?: string | null };
export interface RestaurantRef {
id: string;
}
export interface InventoryRef {
id: string;
}
export type MealType = "breakfast" | "lunch" | "dinner" | "snack";
export interface Food {
id: string;
createdAt: string;
updatedAt: string;
deletedAt: string | null;
restaurant: RestaurantRef;
category: Category;
inventory: InventoryRef;
title: string;
desc: string;
content: string[];
price: number;
order: number | null;
prepareTime: number | null;
weekDays: number[];
mealTypes: MealType[];
isActive: boolean;
images: string[];
inPlaceServe: boolean;
pickupServe: boolean;
score: number;
discount: number;
isSpecialOffer: boolean;
reviews: unknown[];
favorites: unknown[];
isFavorite: boolean;
// فیلدهای قدیمی برای سازگاری با سایر بخش‌های کد
name?: string;
foodName?: string;
description?: string;
image?: string | null;
points?: number | null;
sat?: boolean;
sun?: boolean;
mon?: boolean;
breakfast?: boolean;
noon?: boolean;
dinner?: boolean;
stock?: number;
stockDefault?: number;
rate?: number;
[key: string]: unknown;
}
export type FoodsResponse = BaseResponse<Food[]>;
export type FoodResponse = BaseResponse<Food>;
export interface NotificationsCountData {
count: number;
}
export type NotificationsCountResponse = BaseResponse<NotificationsCountData>;