copy base dmenu to dkala
This commit is contained in:
Vendored
BIN
Binary file not shown.
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import MenuItem from '@/components/listview/MenuItem';
|
||||
import MenuItemRenderer from '@/components/listview/MenuItemRenderer';
|
||||
import { useGetFoods } from '@/app/[name]/(Main)/hooks/useMenuData';
|
||||
import type { Food } from '@/app/[name]/(Main)/types/Types';
|
||||
import { useCart } from '../hook/useCart';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CartItemsSkeleton } from './CartSkeleton';
|
||||
|
||||
const CartItemsList = () => {
|
||||
const { data: foodsResponse, isFetching } = useGetFoods();
|
||||
const foods = React.useMemo(() => foodsResponse?.data ?? [], [foodsResponse?.data]);
|
||||
const { items } = useCart();
|
||||
const { t } = useTranslation('parallels', { keyPrefix: 'Cart' });
|
||||
|
||||
const cartFoods = React.useMemo(() => {
|
||||
if (!foods.length) return [];
|
||||
return Object.entries(items)
|
||||
.filter(([, detail]) => detail?.quantity && detail.quantity > 0)
|
||||
.map(([id]) =>
|
||||
foods.find((foodItem) => String(foodItem.id) === String(id))
|
||||
)
|
||||
.filter((food): food is Food => Boolean(food));
|
||||
}, [foods, items]);
|
||||
|
||||
if (isFetching && !foods.length) {
|
||||
return <CartItemsSkeleton />;
|
||||
}
|
||||
|
||||
if (cartFoods.length === 0) {
|
||||
return (
|
||||
<div className='flex flex-col items-center justify-center gap-2 py-12 text-sm2 text-muted-foreground'>
|
||||
<p>{t('EmptyStateTitle', { defaultValue: 'سبد خرید شما خالی است' })}</p>
|
||||
<p className='text-xs'>
|
||||
{t('EmptyStateDescription', {
|
||||
defaultValue: 'برای افزودن غذا، به منوی رستوران برگردید.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex-1 space-y-4'>
|
||||
{cartFoods.map((food) => (
|
||||
<MenuItemRenderer key={food.id}>
|
||||
<MenuItem food={food} />
|
||||
</MenuItemRenderer>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartItemsList;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import MenuItemRenderer from '@/components/listview/MenuItemRenderer';
|
||||
|
||||
export const CartItemsSkeleton = () => {
|
||||
return (
|
||||
<div className='flex-1 space-y-4'>
|
||||
{[...Array(3)].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' />
|
||||
<Skeleton className='h-4 w-20 rounded-md mt-1' />
|
||||
</div>
|
||||
<Skeleton className='max-w-[115px] w-full h-8 rounded-md' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MenuItemRenderer>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CartSummarySkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
<div className='mt-2'>
|
||||
<div className='relative'>
|
||||
<Skeleton className='h-5 w-32 rounded-md mb-4' />
|
||||
<Skeleton className='w-full h-24 rounded-normal' />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='fixed bottom-0 left-0 right-0 z-50 bg-white border-t border-border p-4 w-full'>
|
||||
<div className='flex justify-between items-center gap-4'>
|
||||
<div className='flex flex-col'>
|
||||
<Skeleton className='h-3 w-24 rounded-md mb-2' />
|
||||
<Skeleton className='h-5 w-32 rounded-md' />
|
||||
</div>
|
||||
<Skeleton className='h-10 w-32 rounded-md' />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const CartSkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
<CartItemsSkeleton />
|
||||
<CartSummarySkeleton />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartSkeleton;
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter, usePathname } from 'next/navigation';
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import { useGetFoods } from '@/app/[name]/(Main)/hooks/useMenuData';
|
||||
import type { Food } from '@/app/[name]/(Main)/types/Types';
|
||||
import { useCart } from '../hook/useCart';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
|
||||
import { useGetCartItems } from '../hooks/useCartData';
|
||||
import { useCartStore } from '../store/Store';
|
||||
|
||||
interface CartSummaryProps {
|
||||
isPremium: boolean;
|
||||
}
|
||||
|
||||
const CartSummary = ({ isPremium }: CartSummaryProps) => {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const name = params.name as string;
|
||||
const { isSuccess } = useGetProfile();
|
||||
const { data: foodsResponse } = useGetFoods();
|
||||
const foods = React.useMemo(() => foodsResponse?.data ?? [], [foodsResponse?.data]);
|
||||
const { items } = useCart();
|
||||
const { data: cartData } = useGetCartItems(isSuccess);
|
||||
const { t } = useTranslation('parallels', { keyPrefix: 'Cart' });
|
||||
const { description, setDescription } = useCartStore();
|
||||
|
||||
const cartFoods = React.useMemo(() => {
|
||||
if (!foods.length) return [];
|
||||
return Object.entries(items)
|
||||
.filter(([, detail]) => detail?.quantity && detail.quantity > 0)
|
||||
.map(([id]) =>
|
||||
foods.find((foodItem) => String(foodItem.id) === String(id))
|
||||
)
|
||||
.filter((food): food is Food => Boolean(food));
|
||||
}, [foods, items]);
|
||||
|
||||
const totalPrice = React.useMemo(() => {
|
||||
// اگر داده از API آمده و total وجود دارد، از آن استفاده میکنیم
|
||||
if (isSuccess && cartData?.data?.total !== undefined) {
|
||||
return cartData.data.total;
|
||||
}
|
||||
|
||||
// در غیر این صورت، محاسبه محلی با در نظر گرفتن تخفیف
|
||||
return cartFoods.reduce((sum, food) => {
|
||||
const qty = items[String(food.id)]?.quantity ?? 0;
|
||||
const basePrice = food.price ?? 0;
|
||||
const discount = food.discount ?? 0;
|
||||
const priceAfterDiscount = basePrice * (1 - discount / 100);
|
||||
return sum + priceAfterDiscount * qty;
|
||||
}, 0);
|
||||
}, [cartFoods, items, isSuccess, cartData?.data?.total]);
|
||||
|
||||
const isCartEmpty = cartFoods.length === 0;
|
||||
|
||||
const formatPrice = React.useCallback(
|
||||
(value: number) => value.toLocaleString('fa-IR'),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!isCartEmpty && (
|
||||
<>
|
||||
{isPremium && (
|
||||
<div className='mt-2 mb-4'>
|
||||
<div className='relative'>
|
||||
<h3 className='text-sm'>{t('InputDescription.Label')}</h3>
|
||||
<textarea
|
||||
className='w-full px-4 py-2.5 mt-4 text-sm2 leading-6 outline-0 border border-border rounded-normal resize-none focus:ring-0'
|
||||
placeholder={t('InputDescription.Placeholder')}
|
||||
id='description'
|
||||
name='description'
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
></textarea>
|
||||
<span className='absolute top-4 right-2 px-2 bg-background text-foreground text-xs'></span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-container border-t border-border p-4 w-full'>
|
||||
<div className='flex justify-between items-center gap-4'>
|
||||
<div className='flex flex-col'>
|
||||
<div className='text-xs text-gray-400 dark:text-disabled-text'>{t('PayableAmountLabel')}</div>
|
||||
{
|
||||
isPremium &&
|
||||
<div className='text-sm mt-2 font-semibold dark:text-foreground'>{formatPrice(totalPrice)} تومان</div>
|
||||
|
||||
}
|
||||
</div>
|
||||
|
||||
{isPremium && (
|
||||
<Link
|
||||
href={'order/checkout/0'}
|
||||
onClick={(event) => {
|
||||
if (!isSuccess) {
|
||||
event.preventDefault();
|
||||
const redirectUrl = encodeURIComponent(pathname);
|
||||
router.replace(`/${name}/auth?redirect=${redirectUrl}`);
|
||||
return;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button className='w-fit! px-10 dark:bg-white dark:text-black! dark:hover:bg-gray-100'>{t('ButtonCheckout')}</Button>
|
||||
</Link>
|
||||
)}
|
||||
{!isPremium && (
|
||||
<div className='text-sm mt-2 font-semibold dark:text-foreground'>{formatPrice(totalPrice)} تومان</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* {!isCartEmpty && (
|
||||
<div className='flex items-center justify-between rounded-xl border border-border px-4 py-3 text-xs text-muted-foreground'>
|
||||
<span>{t('CartControlsHint', { defaultValue: 'برای ویرایش تعداد، از دکمههای هر آیتم استفاده کنید.' })}</span>
|
||||
<div className='flex items-center gap-1 text-foreground'>
|
||||
<Add size={16} />
|
||||
<Minus size={16} />
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartSummary;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useGetProfile } from "@/app/[name]/(Profile)/profile/hooks/userProfileData";
|
||||
import { useReceiptStore } from "@/zustand/receiptStore";
|
||||
import {
|
||||
useClearCart,
|
||||
useDecrementCart,
|
||||
useGetCartItems,
|
||||
useIncrementCart,
|
||||
} from "../hooks/useCartData";
|
||||
|
||||
export const useCart = () => {
|
||||
const { isSuccess } = useGetProfile();
|
||||
const params = useParams();
|
||||
const currentSlug = (params.name as string) || null;
|
||||
const { clear, increment, decrement, items, slug, setSlug } = useReceiptStore();
|
||||
const { mutate: mutateIncrementCart } = useIncrementCart();
|
||||
const { mutate: mutateDecrementCart } = useDecrementCart();
|
||||
const { mutate: mutateClearCart } = useClearCart();
|
||||
const { data: cartItems } = useGetCartItems(isSuccess);
|
||||
|
||||
const addToCart = (id: string | number) => {
|
||||
if (isSuccess) {
|
||||
mutateIncrementCart(id);
|
||||
} else {
|
||||
// تنظیم slug هنگام افزودن آیتم به سبد (فقط برای کاربران لاگین نشده)
|
||||
if (currentSlug && slug !== currentSlug) {
|
||||
setSlug(currentSlug);
|
||||
}
|
||||
increment(id);
|
||||
}
|
||||
};
|
||||
|
||||
const removeFromCart = (id: string | number) => {
|
||||
if (isSuccess) {
|
||||
mutateDecrementCart(id);
|
||||
} else {
|
||||
decrement(id);
|
||||
}
|
||||
};
|
||||
|
||||
const clearCart = useCallback(() => {
|
||||
if (isSuccess) {
|
||||
mutateClearCart();
|
||||
} else {
|
||||
clear();
|
||||
}
|
||||
}, [isSuccess, mutateClearCart, clear]);
|
||||
|
||||
const cartItemsMap = useMemo(() => {
|
||||
if (isSuccess) {
|
||||
if (!cartItems?.data?.items) {
|
||||
return {};
|
||||
}
|
||||
return cartItems.data.items.reduce((acc, item) => {
|
||||
acc[item.foodId] = { quantity: item.quantity };
|
||||
return acc;
|
||||
}, {} as Record<string | number, { quantity: number }>);
|
||||
} else {
|
||||
return items;
|
||||
}
|
||||
}, [isSuccess, cartItems?.data?.items, items]);
|
||||
|
||||
const isItemLoading = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
// برای کاربران لاگین شده، slug از API میآید (در cartItems.data.restaurantId)
|
||||
// برای کاربران لاگین نشده، slug از receiptStore میآید
|
||||
const cartSlug = useMemo(() => {
|
||||
if (isSuccess && cartItems?.data?.restaurantId) {
|
||||
// اگر از API آمده، میتوانیم restaurantId را برگردانیم
|
||||
// اما بهتر است slug را از URL بگیریم چون API ممکن است restaurantId را برگرداند نه slug
|
||||
return currentSlug;
|
||||
}
|
||||
return slug;
|
||||
}, [isSuccess, cartItems?.data?.restaurantId, currentSlug, slug]);
|
||||
|
||||
return {
|
||||
items: cartItemsMap,
|
||||
slug: cartSlug,
|
||||
increment,
|
||||
decrement,
|
||||
clear,
|
||||
addToCart,
|
||||
removeFromCart,
|
||||
clearCart,
|
||||
isItemLoading,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import * as api from "../service/CartService";
|
||||
import type { CartResponse, CartItem } from "../types/Types";
|
||||
import type { FoodsResponse } from "@/app/[name]/(Main)/types/Types";
|
||||
|
||||
export const useBulkCart = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.bulkCart,
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["cart-items"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useIncrementCart = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.increment,
|
||||
onMutate: async (id: string | number) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["cart-items"] });
|
||||
await queryClient.cancelQueries({ queryKey: ["menu"] });
|
||||
|
||||
const previousCart = queryClient.getQueryData<CartResponse>([
|
||||
"cart-items",
|
||||
]);
|
||||
const foodsData = queryClient.getQueryData<FoodsResponse>(["menu"]);
|
||||
|
||||
if (previousCart?.data && foodsData?.data) {
|
||||
const existingItem = previousCart.data.items.find(
|
||||
(item) => item.foodId === String(id)
|
||||
);
|
||||
|
||||
let updatedItems: CartItem[];
|
||||
let newTotalItems: number;
|
||||
|
||||
if (existingItem) {
|
||||
updatedItems = previousCart.data.items.map((item) => {
|
||||
if (item.foodId === String(id)) {
|
||||
return {
|
||||
...item,
|
||||
quantity: item.quantity + 1,
|
||||
totalPrice: item.price * (item.quantity + 1) * (1 - item.discount / 100),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
});
|
||||
newTotalItems = previousCart.data.totalItems + 1;
|
||||
} else {
|
||||
const food = foodsData.data.find((f) => String(f.id) === String(id));
|
||||
if (food) {
|
||||
const newItem: CartItem = {
|
||||
foodId: String(id),
|
||||
foodTitle: food.title || food.name || "",
|
||||
quantity: 1,
|
||||
price: food.price,
|
||||
discount: food.discount || 0,
|
||||
totalPrice: food.price * (1 - (food.discount || 0) / 100),
|
||||
};
|
||||
updatedItems = [...previousCart.data.items, newItem];
|
||||
newTotalItems = previousCart.data.totalItems + 1;
|
||||
} else {
|
||||
return { previousCart };
|
||||
}
|
||||
}
|
||||
|
||||
// محاسبه subTotal جدید
|
||||
const newSubTotal = updatedItems.reduce(
|
||||
(sum, item) => sum + item.totalPrice,
|
||||
0
|
||||
);
|
||||
|
||||
// محاسبه total جدید (با حفظ deliveryFee، tax و discount)
|
||||
const newTotal =
|
||||
newSubTotal +
|
||||
(previousCart.data.deliveryFee || 0) -
|
||||
(previousCart.data.totalDiscount || 0) +
|
||||
(previousCart.data.tax || 0);
|
||||
|
||||
queryClient.setQueryData<CartResponse>(["cart-items"], {
|
||||
...previousCart,
|
||||
data: {
|
||||
...previousCart.data,
|
||||
items: updatedItems,
|
||||
totalItems: newTotalItems,
|
||||
subTotal: newSubTotal,
|
||||
total: newTotal,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { previousCart };
|
||||
},
|
||||
onError: (_err, _id, context) => {
|
||||
if (context?.previousCart) {
|
||||
queryClient.setQueryData(["cart-items"], context.previousCart);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["cart-items"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDecrementCart = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.decrement,
|
||||
onMutate: async (id: string | number) => {
|
||||
await queryClient.cancelQueries({ queryKey: ["cart-items"] });
|
||||
|
||||
const previousCart = queryClient.getQueryData<CartResponse>([
|
||||
"cart-items",
|
||||
]);
|
||||
|
||||
if (previousCart?.data) {
|
||||
const updatedItems = previousCart.data.items
|
||||
.map((item: CartItem) => {
|
||||
if (item.foodId === String(id)) {
|
||||
const newQuantity = item.quantity - 1;
|
||||
if (newQuantity <= 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
quantity: newQuantity,
|
||||
totalPrice: item.price * newQuantity * (1 - item.discount / 100),
|
||||
};
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.filter((item: CartItem | null): item is CartItem => item !== null);
|
||||
|
||||
// محاسبه subTotal جدید
|
||||
const newSubTotal = updatedItems.reduce(
|
||||
(sum, item) => sum + item.totalPrice,
|
||||
0
|
||||
);
|
||||
|
||||
// محاسبه total جدید
|
||||
const newTotal =
|
||||
newSubTotal +
|
||||
(previousCart.data.deliveryFee || 0) -
|
||||
(previousCart.data.totalDiscount || 0) +
|
||||
(previousCart.data.tax || 0);
|
||||
|
||||
queryClient.setQueryData<CartResponse>(["cart-items"], {
|
||||
...previousCart,
|
||||
data: {
|
||||
...previousCart.data,
|
||||
items: updatedItems,
|
||||
totalItems: Math.max(0, previousCart.data.totalItems - 1),
|
||||
subTotal: newSubTotal,
|
||||
total: newTotal,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { previousCart };
|
||||
},
|
||||
onError: (_err, _id, context) => {
|
||||
if (context?.previousCart) {
|
||||
queryClient.setQueryData(["cart-items"], context.previousCart);
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["cart-items"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useClearCart = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.clear,
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["cart-items"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetCartItems = (enabled = true) => {
|
||||
return useQuery({
|
||||
queryKey: ["cart-items"],
|
||||
queryFn: api.getCartItems,
|
||||
enabled,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSaveAllMethod = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.saveAllMethod,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowLeft, Trash } from 'iconsax-react';
|
||||
import Image from 'next/image';
|
||||
import { useCart } from './hook/useCart';
|
||||
import CartSummary from './components/CartSummary';
|
||||
import Prompt from '@/components/utils/Prompt';
|
||||
import { useGetFoods } from '@/app/[name]/(Main)/hooks/useMenuData';
|
||||
import MenuItem from '@/components/listview/MenuItem';
|
||||
import MenuItemRenderer from '@/components/listview/MenuItemRenderer';
|
||||
import type { Food } from '@/app/[name]/(Main)/types/Types';
|
||||
import { useGetAbout } from '../../(Main)/about/hooks/useAboutData';
|
||||
import PagerModal from '@/components/pager/PagerModal';
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import NotificationBellIcon from '@/components/icons/NotificationBellIcon';
|
||||
|
||||
const CartIndex = () => {
|
||||
const { t } = useTranslation('parallels', { keyPrefix: 'Cart' });
|
||||
const router = useRouter();
|
||||
const { clearCart, items } = useCart();
|
||||
const [showClearConfirm, setShowClearConfirm] = React.useState(false);
|
||||
const [showPagerModal, setShowPagerModal] = React.useState(false);
|
||||
const { data: foodsResponse, isFetching } = useGetFoods();
|
||||
const { data: aboutData } = useGetAbout();
|
||||
const isPremium = aboutData?.data?.plan === 'premium';
|
||||
const foods = React.useMemo(() => foodsResponse?.data ?? [], [foodsResponse?.data]);
|
||||
const isLoading = isFetching && !foods.length;
|
||||
|
||||
const cartFoods = React.useMemo(() => {
|
||||
if (!foods.length) return [];
|
||||
return Object.entries(items)
|
||||
.filter(([, detail]) => detail?.quantity && detail.quantity > 0)
|
||||
.map(([id]) =>
|
||||
foods.find((foodItem) => String(foodItem.id) === String(id))
|
||||
)
|
||||
.filter((food): food is Food => Boolean(food));
|
||||
}, [foods, items]);
|
||||
|
||||
const isCartEmpty = cartFoods.length === 0;
|
||||
|
||||
const handleClearCart = (e: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
clearCart();
|
||||
setShowClearConfirm(false);
|
||||
};
|
||||
|
||||
const toggleClearConfirm = (e: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
setShowClearConfirm(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='overflow-y-auto h-full noscrollbar flex flex-col bg-background -mx-6 -mt-4 -mb-6 px-6 pt-4 pb-6'>
|
||||
<div className='grid grid-cols-3 items-center'>
|
||||
{isCartEmpty ? (
|
||||
<span></span>
|
||||
) : (
|
||||
<Trash
|
||||
className='cursor-pointer place-self-start'
|
||||
size='24'
|
||||
color='currentColor'
|
||||
onClick={() => setShowClearConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
<h1 className='text-sm2 place-self-center font-medium'>{t('Heading')}</h1>
|
||||
<ArrowLeft
|
||||
className='cursor-pointer place-self-end'
|
||||
size='24'
|
||||
color='currentColor'
|
||||
onClick={() => router.back()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex-1 h-full">
|
||||
{isLoading ? (
|
||||
<div className='flex flex-col items-center justify-center gap-2 py-12 text-sm2 text-muted-foreground'>
|
||||
<span className='h-4 w-4 animate-spin rounded-full border border-dashed border-foreground border-t-transparent'></span>
|
||||
<p>در حال دریافت سبد خرید...</p>
|
||||
</div>
|
||||
) : isCartEmpty ? (
|
||||
<div className='flex flex-col items-center justify-center gap-4 h-full'>
|
||||
<Image
|
||||
src='/assets/images/cart.png'
|
||||
alt='cart'
|
||||
width={120}
|
||||
height={120}
|
||||
className='object-contain'
|
||||
/>
|
||||
<div className='flex flex-col items-center gap-2 text-sm2 text-muted-foreground'>
|
||||
<p>{t('EmptyStateTitle', { defaultValue: 'سبد خرید شما خالی است' })}</p>
|
||||
<p className='text-xs'>
|
||||
{t('EmptyStateDescription', {
|
||||
defaultValue: 'برای افزودن غذا، به منوی رستوران برگردید.',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 pb-24">
|
||||
{!isPremium && (
|
||||
<div className='bg-container rounded-container p-4 border border-border shadow-sm'>
|
||||
<div className='flex items-start gap-3 mb-4'>
|
||||
<div className='shrink-0 mt-0.5'>
|
||||
<NotificationBellIcon width={24} height={24} className="currentColor" />
|
||||
</div>
|
||||
<div className='flex-1'>
|
||||
<p className='text-sm font-medium text-foreground mb-1'>
|
||||
ثبت سفارش
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground leading-5'>
|
||||
برای ثبت سفارش، لطفاً گارسون را صدا کنید
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setShowPagerModal(true)}
|
||||
className='w-full dark:bg-white! dark:text-black! flex items-center justify-center gap-2'
|
||||
>
|
||||
<NotificationBellIcon width={18} height={18} className="text-white dark:text-black!" />
|
||||
<span>صدا کردن گارسون</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{cartFoods.map((food) => (
|
||||
<MenuItemRenderer key={food.id}>
|
||||
<MenuItem food={food} />
|
||||
</MenuItemRenderer>
|
||||
))}
|
||||
<CartSummary isPremium={isPremium} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={showClearConfirm ? 'fixed inset-0 z-1001' : 'pointer-events-none fixed inset-0 z-1001'}>
|
||||
<Prompt
|
||||
title='پاک کردن سبد خرید'
|
||||
description='آیا از پاک کردن تمامی آیتمهای سبد خرید اطمینان دارید؟'
|
||||
textConfirm='بله'
|
||||
textCancel='خیر'
|
||||
onConfirm={handleClearCart}
|
||||
onCancel={toggleClearConfirm}
|
||||
visible={showClearConfirm}
|
||||
onClick={toggleClearConfirm}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PagerModal visible={showPagerModal} onClose={() => setShowPagerModal(false)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CartIndex;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { api } from "@/config/axios";
|
||||
import { BulkCartItem, CartResponse, SaveAllMethod } from "../types/Types";
|
||||
|
||||
export const bulkCart = async (params: BulkCartItem) => {
|
||||
const { data } = await api.post("/public/cart/items/bulk", params);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const increment = async (id: string | number) => {
|
||||
const { data } = await api.post(`/public/cart/items/${id}/increment`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const decrement = async (id: string | number) => {
|
||||
const { data } = await api.post(`/public/cart/items/${id}/decrement`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const clear = async () => {
|
||||
const { data } = await api.delete("/public/cart");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCartItems = async (): Promise<CartResponse> => {
|
||||
const { data } = await api.get<CartResponse>("/public/cart");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getCartSummary = async () => {
|
||||
const { data } = await api.get("/public/cart/summary");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const saveAllMethod = async (params: SaveAllMethod) => {
|
||||
const { data } = await api.patch("/public/cart/all", params);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { CartStoreType, SaveAllMethod } from "../types/Types";
|
||||
|
||||
export const useCartStore = create<CartStoreType>()(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
description: "",
|
||||
setDescription: (value) => set({ description: value }),
|
||||
deliveryMethodId: null,
|
||||
setDeliveryMethodId: (value) => set({ deliveryMethodId: value }),
|
||||
addressId: null,
|
||||
setAddressId: (value) => set({ addressId: value }),
|
||||
paymentMethodId: null,
|
||||
setPaymentMethodId: (value) => set({ paymentMethodId: value }),
|
||||
carAddress: null,
|
||||
setCarAddress: (value) => set({ carAddress: value }),
|
||||
reset: () =>
|
||||
set({
|
||||
description: "",
|
||||
deliveryMethodId: null,
|
||||
addressId: null,
|
||||
paymentMethodId: null,
|
||||
carAddress: null,
|
||||
}),
|
||||
getSaveAllMethodData: (): SaveAllMethod | null => {
|
||||
const state = get();
|
||||
if (!state.deliveryMethodId || !state.paymentMethodId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
description: state.description,
|
||||
deliveryMethodId: state.deliveryMethodId,
|
||||
...(state.addressId && { addressId: state.addressId }),
|
||||
paymentMethodId: state.paymentMethodId,
|
||||
...(state.carAddress && { carAddress: state.carAddress }),
|
||||
};
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "cart-storage",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,124 @@
|
||||
import { BaseResponse } from "@/app/[name]/(Main)/types/Types";
|
||||
|
||||
export type BulkCartItem = {
|
||||
items: {
|
||||
foodId: string;
|
||||
quantity: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
export interface CartItem {
|
||||
foodId: string;
|
||||
foodTitle: string;
|
||||
quantity: number;
|
||||
price: number;
|
||||
discount: number;
|
||||
totalPrice: number;
|
||||
}
|
||||
|
||||
export interface Coupon {
|
||||
couponId: string;
|
||||
couponName: string;
|
||||
couponCode: string;
|
||||
}
|
||||
|
||||
export interface CartData {
|
||||
userId: string;
|
||||
restaurantId: string;
|
||||
restaurantName: string;
|
||||
items: CartItem[];
|
||||
deliveryFee: number;
|
||||
subTotal: number;
|
||||
itemsDiscount: number;
|
||||
couponDiscount: number;
|
||||
totalDiscount: number;
|
||||
tax: number;
|
||||
total: number;
|
||||
totalItems: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deliveryMethodId?: string;
|
||||
addressId?: string;
|
||||
paymentMethodId?: string;
|
||||
coupon?: Coupon;
|
||||
}
|
||||
|
||||
export interface CartCategory {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
title: string;
|
||||
isActive: boolean;
|
||||
restaurant: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
export interface CartFood {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
restaurant: string;
|
||||
category: CartCategory;
|
||||
title: string;
|
||||
desc: string;
|
||||
content: string | 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;
|
||||
}
|
||||
|
||||
export type CartResponse = BaseResponse<CartData>;
|
||||
|
||||
export type SaveAllMethod = {
|
||||
description: string;
|
||||
deliveryMethodId: string;
|
||||
addressId?: string;
|
||||
paymentMethodId: string;
|
||||
carAddress?: {
|
||||
carModel: string;
|
||||
carColor: string;
|
||||
plateNumber: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type CartStoreType = {
|
||||
description: string;
|
||||
setDescription: (value: string) => void;
|
||||
deliveryMethodId: string | null;
|
||||
setDeliveryMethodId: (value: string | null) => void;
|
||||
addressId: string | null;
|
||||
setAddressId: (value: string | null) => void;
|
||||
paymentMethodId: string | null;
|
||||
setPaymentMethodId: (value: string | null) => void;
|
||||
carAddress: {
|
||||
carModel: string;
|
||||
carColor: string;
|
||||
plateNumber: string;
|
||||
} | null;
|
||||
setCarAddress: (
|
||||
value: {
|
||||
carModel: string;
|
||||
carColor: string;
|
||||
plateNumber: string;
|
||||
} | null
|
||||
) => void;
|
||||
reset: () => void;
|
||||
getSaveAllMethodData: () => SaveAllMethod | null;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
import { type FC, type ReactNode } from 'react'
|
||||
|
||||
interface Game2048LayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const Game2048Layout: FC<Game2048LayoutProps> = ({ children }) => {
|
||||
return (
|
||||
<div className='h-screen w-full p-0 m-0'>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Game2048Layout
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
import { type FC } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { CloseCircle } from 'iconsax-react'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const Game2048Page: FC = () => {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 w-full h-full p-0 m-0 bg-white'>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className='absolute top-4 left-4 z-50 bg-white rounded-full shadow-lg p-1 hover:bg-gray-100 transition-colors'
|
||||
aria-label='بستن'
|
||||
>
|
||||
<CloseCircle size='32' color='#000' variant='Bold' />
|
||||
</button>
|
||||
<iframe
|
||||
src='/game2048/index.html'
|
||||
className='w-full h-full border-0'
|
||||
title='2048 Game'
|
||||
sandbox='allow-scripts allow-same-origin'
|
||||
referrerPolicy='no-referrer'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Game2048Page
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client'
|
||||
import { ArrowLeft, Play } from 'iconsax-react'
|
||||
import { type FC } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Seperator from '@/components/utils/Seperator'
|
||||
import Image from 'next/image'
|
||||
|
||||
const GamePage: FC = () => {
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='grid grid-cols-3 items-center py-4'>
|
||||
<div className='w-6'></div>
|
||||
<h1 className='text-sm2 place-self-center font-medium dark:text-foreground'>بازی و سرگرمی</h1>
|
||||
<ArrowLeft
|
||||
className='cursor-pointer place-self-end dark:text-foreground'
|
||||
size='24'
|
||||
color='currentColor'
|
||||
onClick={() => router.back()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-11 relative bg-primary text-white dark:bg-primary dark:text-primary-foreground h-[120px] rounded-[10px] p-4'>
|
||||
<div className='max-w-[99px]'>
|
||||
<div className='font-bold'>My Games</div>
|
||||
<div className='mt-1'>
|
||||
<Seperator className='w-7' />
|
||||
</div>
|
||||
|
||||
<div className='text-xs mt-3 leading-5 font-medium'>
|
||||
تا سفارشت آماده بشه بازی کن
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Image
|
||||
src='/assets/images/controller.png'
|
||||
alt='game preview'
|
||||
width={195}
|
||||
height={131}
|
||||
className='absolute left-2 bottom-2'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-8 flex flex-col gap-4'>
|
||||
<div
|
||||
style={{ direction: 'ltr' }}
|
||||
className='bg-white dark:bg-container shadow-sm rounded-[10px] p-2 flex justify-between items-center cursor-pointer border border-transparent dark:border-border/50'
|
||||
onClick={() => {
|
||||
const currentPath = window.location.pathname
|
||||
const basePath = currentPath.split('/game')[0]
|
||||
router.push(`${basePath}/game/2048`)
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-3 items-center'>
|
||||
<Image
|
||||
src='/assets/images/2048_logo.png'
|
||||
alt='2048'
|
||||
width={80}
|
||||
height={80}
|
||||
className='size-[80px] object-cover'
|
||||
/>
|
||||
|
||||
<div className='text-sm font-bold max-w-[92px] dark:text-foreground'>
|
||||
2048
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='h-8 bg-[#F2F2F2] dark:bg-disabled rounded-lg px-3 flex gap-1 items-center justify-center'>
|
||||
<div className='text-xs dark:text-foreground'>اجرا کنید</div>
|
||||
<Play size='20' color='currentColor' className='dark:text-foreground' />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ direction: 'ltr' }}
|
||||
className='bg-white dark:bg-container shadow-sm rounded-[10px] p-2 flex justify-between items-center cursor-pointer border border-transparent dark:border-border/50'
|
||||
onClick={() => {
|
||||
const currentPath = window.location.pathname
|
||||
const basePath = currentPath.split('/game')[0]
|
||||
router.push(`${basePath}/game/tower-builder`)
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-3 items-center'>
|
||||
<Image
|
||||
src='/assets/images/tower_building_logo.webp'
|
||||
alt='Tower Builder'
|
||||
width={80}
|
||||
height={80}
|
||||
className='size-[80px] object-cover'
|
||||
/>
|
||||
|
||||
<div className='text-sm font-bold max-w-[92px] dark:text-foreground'>
|
||||
Tower Builder
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='h-8 bg-[#F2F2F2] dark:bg-disabled rounded-lg px-3 flex gap-1 items-center justify-center'>
|
||||
<div className='text-xs dark:text-foreground'>اجرا کنید</div>
|
||||
<Play size='20' color='currentColor' className='dark:text-foreground' />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ direction: 'ltr' }}
|
||||
className='bg-white dark:bg-container shadow-sm rounded-[10px] p-2 flex justify-between items-center cursor-pointer border border-transparent dark:border-border/50'
|
||||
onClick={() => {
|
||||
const currentPath = window.location.pathname
|
||||
const basePath = currentPath.split('/game')[0]
|
||||
router.push(`${basePath}/game/the-cube`)
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-3 items-center'>
|
||||
<Image
|
||||
src='/assets/images/cube_logo.jpeg'
|
||||
alt='The Cube'
|
||||
width={80}
|
||||
height={80}
|
||||
className='size-[80px] object-cover'
|
||||
/>
|
||||
|
||||
<div className='text-sm font-bold max-w-[92px] dark:text-foreground'>
|
||||
The Cube
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='h-8 bg-[#F2F2F2] dark:bg-disabled rounded-lg px-3 flex gap-1 items-center justify-center'>
|
||||
<div className='text-xs dark:text-foreground'>اجرا کنید</div>
|
||||
<Play size='20' color='currentColor' className='dark:text-foreground' />
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GamePage
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
import { type FC } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { CloseCircle } from 'iconsax-react'
|
||||
|
||||
const TheCubePage: FC = () => {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 w-full h-full p-0 m-0 bg-white'>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className='absolute top-4 left-4 z-50 bg-white rounded-full shadow-lg p-1 hover:bg-gray-100 transition-colors'
|
||||
aria-label='بستن'
|
||||
>
|
||||
<CloseCircle size='32' color='#000' variant='Bold' />
|
||||
</button>
|
||||
<iframe
|
||||
src='/the-cube-master/index.html'
|
||||
className='w-full h-full border-0'
|
||||
title='The Cube Game'
|
||||
sandbox='allow-scripts allow-same-origin'
|
||||
referrerPolicy='no-referrer'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TheCubePage
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
import { type FC, type ReactNode } from 'react'
|
||||
|
||||
interface TowerBuilderLayoutProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const TowerBuilderLayout: FC<TowerBuilderLayoutProps> = ({ children }) => {
|
||||
return (
|
||||
<div className='h-screen w-full p-0 m-0'>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TowerBuilderLayout
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
import { type FC } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { CloseCircle } from 'iconsax-react'
|
||||
|
||||
const TowerBuilderPage: FC = () => {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 w-full h-full p-0 m-0 flex items-center justify-center bg-white'>
|
||||
<button
|
||||
onClick={() => router.back()}
|
||||
className='absolute top-4 left-4 z-50 bg-white rounded-full shadow-lg p-1 hover:bg-gray-100 transition-colors'
|
||||
aria-label='بستن'
|
||||
>
|
||||
<CloseCircle size='32' color='#000' variant='Bold' />
|
||||
</button>
|
||||
<iframe
|
||||
src='/tower_game-master/index.html'
|
||||
className='w-full h-full border-0'
|
||||
title='Tower Builder Game'
|
||||
sandbox='allow-scripts allow-same-origin'
|
||||
referrerPolicy='no-referrer'
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TowerBuilderPage
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client';
|
||||
|
||||
import ClientSideWrapper from '@/components/wrapper/ClientSideWrapper';
|
||||
import React from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import clsx from 'clsx';
|
||||
|
||||
function DialogsLayout({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const isCheckoutPage = pathname?.includes('/checkout/');
|
||||
|
||||
return (
|
||||
<ClientSideWrapper>
|
||||
<main className={clsx(
|
||||
'h-full dark:bg-background w-full overflow-y-auto noscrollbar pt-4 pb-6 px-6',
|
||||
isCheckoutPage
|
||||
? 'bg-background dark:bg-background'
|
||||
: 'bg-container lg:bg-background'
|
||||
)}>
|
||||
{children}
|
||||
</main>
|
||||
</ClientSideWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default DialogsLayout
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as api from "../service/NotificationService";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const useGetNotifications = () => {
|
||||
return useQuery({
|
||||
queryKey: ["notifications"],
|
||||
queryFn: api.getNotifications,
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteNotification = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.deleteNotification,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useReadAllNotifications = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: api.readAll,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notifications"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["notificationsCount"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
import { ef } from '@/lib/helpers/utfNumbers';
|
||||
import { ArrowLeft } from 'iconsax-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react'
|
||||
import Image from 'next/image';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetNotifications, useReadAllNotifications } from './hooks/useNotificationData';
|
||||
import type { Notification } from './types/Types';
|
||||
import { useGetNotificationsCount } from '../../(Main)/hooks/useMenuData';
|
||||
import Prompt from '@/components/utils/Prompt';
|
||||
|
||||
type Props = object
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString('fa-IR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
export default function NotificationsIndex({ }: Props) {
|
||||
const { t } = useTranslation('notifications')
|
||||
const router = useRouter();
|
||||
const { data, isLoading } = useGetNotifications();
|
||||
const { mutate: readAllNotifications } = useReadAllNotifications();
|
||||
const { data: notificationsCount } = useGetNotificationsCount();
|
||||
const [showReadAllConfirm, setShowReadAllConfirm] = React.useState(false);
|
||||
|
||||
const notifications = React.useMemo(() => {
|
||||
return data?.data ?? [];
|
||||
}, [data?.data]);
|
||||
|
||||
const handleReadAll = (e: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
readAllNotifications();
|
||||
setShowReadAllConfirm(false);
|
||||
};
|
||||
|
||||
const toggleReadAllConfirm = (e: React.MouseEvent) => {
|
||||
e?.preventDefault();
|
||||
e?.stopPropagation();
|
||||
setShowReadAllConfirm(false);
|
||||
};
|
||||
|
||||
const handleReadAllClick = () => {
|
||||
setShowReadAllConfirm(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='overflow-y-auto h-full noscrollbar flex flex-col'>
|
||||
<div className='grid grid-cols-3 items-center'>
|
||||
<span></span>
|
||||
<h1 className='text-sm2 place-self-center font-medium'>{t("Heading")}</h1>
|
||||
<ArrowLeft
|
||||
className='cursor-pointer place-self-end'
|
||||
size='24'
|
||||
color='currentColor'
|
||||
onClick={() => { router.back() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex-1 h-full">
|
||||
{isLoading ? (
|
||||
<div className='flex flex-col items-center justify-center gap-2 py-12 text-sm2 text-muted-foreground'>
|
||||
<span className='h-4 w-4 animate-spin rounded-full border border-dashed border-foreground border-t-transparent'></span>
|
||||
<p>در حال دریافت اعلانها...</p>
|
||||
</div>
|
||||
) : notifications.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center gap-4 h-full'>
|
||||
<Image
|
||||
src='/assets/images/notification.png'
|
||||
alt='notification'
|
||||
width={120}
|
||||
height={120}
|
||||
className='object-contain'
|
||||
/>
|
||||
<div className='flex flex-col items-center gap-2 text-sm2 text-muted-foreground'>
|
||||
<p>اعلانی وجود ندارد</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ul className='flex flex-col gap-4 pb-20 px-0.5'>
|
||||
{
|
||||
notificationsCount?.data?.count && notificationsCount?.data?.count > 0 ? (
|
||||
<div className='flex'>
|
||||
<Image src={'/assets/images/readall.png'} alt='notification' width={20} height={20} className='object-contain dark:invert -mt-3 cursor-pointer' onClick={handleReadAllClick} />
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
{notifications.map((notification: Notification) => {
|
||||
// const isNew = !notification.sentAt;
|
||||
const formattedDate = formatDate(notification.createdAt);
|
||||
const formattedTime = formatTime(notification.createdAt);
|
||||
|
||||
return (
|
||||
<li key={notification.id} className='bg-container py-5 px-4 rounded-container shadow-lg'>
|
||||
{/* <h5 className='font-medium text-sm'>{notification.title}</h5> */}
|
||||
<p className='text-xs mt-2 leading-5'>{notification.content}</p>
|
||||
|
||||
<div className='flex items-center justify-between mt-5 '>
|
||||
<div className="flex items-center justify-start gap-2">
|
||||
{/* {isNew && (
|
||||
<span className='px-4 pt-1 pb-0.5 text-xs bg-primary text-white rounded-md'>
|
||||
{t('Tags.New')}
|
||||
</span>
|
||||
)} */}
|
||||
<span className='text-disabled2 text-xs pt-0.5'>
|
||||
{ef(formattedDate)}
|
||||
</span>
|
||||
<span className='size-1.5 bg-disabled rounded-full'></span>
|
||||
<span className='text-disabled2 text-xs pt-0.5'>
|
||||
{ef(formattedTime)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={showReadAllConfirm ? 'fixed inset-0 z-1001' : 'pointer-events-none fixed inset-0 z-1001'}>
|
||||
<Prompt
|
||||
title='خواندن همه اعلانها'
|
||||
description='آیا از خواندن همه اعلانها اطمینان دارید؟'
|
||||
textConfirm='بله'
|
||||
textCancel='خیر'
|
||||
onConfirm={handleReadAll}
|
||||
onCancel={toggleReadAllConfirm}
|
||||
visible={showReadAllConfirm}
|
||||
onClick={toggleReadAllConfirm}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { api } from "@/config/axios";
|
||||
import { NotificationsResponse } from "../types/Types";
|
||||
import { BaseResponse } from "@/app/[name]/(Main)/types/Types";
|
||||
|
||||
export const getNotifications = async (): Promise<NotificationsResponse> => {
|
||||
const { data } = await api.get<NotificationsResponse>(
|
||||
"/public/notifications?status=unseen"
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const deleteNotification = async (
|
||||
id: string
|
||||
): Promise<BaseResponse<unknown>> => {
|
||||
const { data } = await api.delete<BaseResponse<unknown>>(
|
||||
`/public/notifications/${id}`
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const readAll = async () => {
|
||||
const { data } = await api.put("/public/notifications/read/all");
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { BaseResponse } from "@/app/[name]/(Main)/types/Types";
|
||||
|
||||
export interface Notification {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
restaurant: string;
|
||||
user: string;
|
||||
admin: string | null;
|
||||
title: string;
|
||||
content: string;
|
||||
sentAt: string | null;
|
||||
}
|
||||
|
||||
export type NotificationsResponse = BaseResponse<Notification[]>;
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { useGetAddresses } from '@/app/[name]/(Profile)/profile/address/hooks/useAddressData';
|
||||
import { Address } from '@/app/[name]/(Profile)/profile/address/types/Types';
|
||||
import PrimaryButton from '@/components/button/PrimaryButton';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCheckoutStore } from '../../store/Store';
|
||||
import useToggle from '@/hooks/helpers/useToggle';
|
||||
import { AddressSelectionModal } from './AddressSelectionModal';
|
||||
|
||||
const formatAddress = (address: Address) => {
|
||||
return `${address.address}، ${address.city}، ${address.province}`;
|
||||
};
|
||||
|
||||
export const AddressSection = () => {
|
||||
|
||||
const { setIsSelectedAddress, selectedAddressId, setSelectedAddressId, isSelectedShipment } = useCheckoutStore();
|
||||
const params = useParams<{ name: string; id: string }>();
|
||||
const { t } = useTranslation('parallels', { keyPrefix: 'OrderDetail' });
|
||||
const { data: addressesResponse, isLoading } = useGetAddresses();
|
||||
const addresses = useMemo(() => addressesResponse?.data || [], [addressesResponse?.data]);
|
||||
const redirectUrl = `/${params.name}/order/checkout/${params.id}`;
|
||||
const { state: modalVisible, toggle: toggleModal, set: setModalVisible } = useToggle(false);
|
||||
|
||||
// اگر آدرس پیشفرض وجود داشت، آن را تنظیم کن، در غیر این صورت آدرس اول غیر پیشفرض
|
||||
// در صورتی که روش تحویل انتخاب شده بود آدرس رو ست کنه
|
||||
useEffect(() => {
|
||||
if (!isLoading && addresses.length > 0 && !selectedAddressId && isSelectedShipment) {
|
||||
const defaultAddress = addresses.find(addr => addr.isDefault);
|
||||
const addressToSet = defaultAddress || addresses.filter(addr => !addr.isDefault)[0];
|
||||
if (addressToSet) {
|
||||
setIsSelectedAddress(true);
|
||||
setSelectedAddressId(addressToSet.id);
|
||||
}
|
||||
}
|
||||
}, [isLoading, addresses, setIsSelectedAddress, selectedAddressId, setSelectedAddressId, isSelectedShipment]);
|
||||
|
||||
const handleSelectAddress = (addressId: string) => {
|
||||
setIsSelectedAddress(true);
|
||||
setSelectedAddressId(addressId);
|
||||
setModalVisible(false);
|
||||
};
|
||||
|
||||
// پیدا کردن آدرس انتخاب شده یا آدرس پیشفرض یا اولین آدرس غیر پیشفرض
|
||||
const selectedAddress = useMemo(() => {
|
||||
if (!addresses.length) return null;
|
||||
if (selectedAddressId) {
|
||||
return addresses.find(addr => addr.id === selectedAddressId);
|
||||
}
|
||||
const defaultAddress = addresses.find(addr => addr.isDefault);
|
||||
if (defaultAddress) {
|
||||
return defaultAddress;
|
||||
}
|
||||
return addresses.filter(addr => !addr.isDefault)[0];
|
||||
}, [addresses, selectedAddressId]);
|
||||
|
||||
// اگر در حال بارگذاری است، چیزی نمایش نده
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// اگر آدرس ندارد
|
||||
if (addresses.length === 0) {
|
||||
return (
|
||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
||||
<div className="py-3">
|
||||
<h3 className="text-sm2 font-medium leading-5 mb-4">{t("SectionAddress.Title")}</h3>
|
||||
<p className='text-sm2 text-disabled-text mb-4'>
|
||||
آدرسی ثبت نشده است
|
||||
</p>
|
||||
<Link href={`/${params.name}/profile/address/new?redirect=${encodeURIComponent(redirectUrl)}`}>
|
||||
<PrimaryButton>
|
||||
افزودن آدرس جدید
|
||||
</PrimaryButton>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// اگر آدرس انتخاب شده وجود ندارد، نمایش نده
|
||||
// if (!selectedAddress) {
|
||||
// return null;
|
||||
// }
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
||||
<div className="py-3">
|
||||
<div className='w-full flex justify-between items-center gap-2'>
|
||||
<h3 className="text-sm2 font-medium leading-5 inline-block">{t("SectionAddress.Title")}</h3>
|
||||
<button
|
||||
onClick={toggleModal}
|
||||
className='text-sm2 text-primary dark:text-foreground cursor-pointer'
|
||||
>
|
||||
{t("SectionAddress.ButtonChangeAddress")}
|
||||
<ChevronLeft className='inline-block mr-1 mb-0.5 stroke-primary dark:stroke-foreground' size='16' />
|
||||
</button>
|
||||
</div>
|
||||
<p className='mt-6 text-sm2'>
|
||||
<div className='font-bold'>
|
||||
{selectedAddress?.title}
|
||||
</div>
|
||||
{selectedAddress && formatAddress(selectedAddress)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AddressSelectionModal
|
||||
visible={modalVisible}
|
||||
addresses={addresses}
|
||||
redirectUrl={redirectUrl}
|
||||
name={params.name}
|
||||
selectedAddressId={selectedAddressId}
|
||||
onClose={() => setModalVisible(false)}
|
||||
onSelectAddress={handleSelectAddress}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
'use client';
|
||||
|
||||
import { Address } from '@/app/[name]/(Profile)/profile/address/types/Types';
|
||||
import PrimaryButton from '@/components/button/PrimaryButton';
|
||||
import Modal from '@/components/utils/Modal';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ef } from '@/lib/helpers/utfNumbers';
|
||||
|
||||
const formatAddress = (address: Address) => {
|
||||
return `${address.address}، ${address.city}، ${address.province}`;
|
||||
};
|
||||
|
||||
interface AddressSelectionModalProps {
|
||||
visible: boolean;
|
||||
addresses: Address[];
|
||||
redirectUrl: string;
|
||||
name: string;
|
||||
selectedAddressId: string | null;
|
||||
onClose: () => void;
|
||||
onSelectAddress: (addressId: string) => void;
|
||||
}
|
||||
|
||||
export const AddressSelectionModal = ({
|
||||
visible,
|
||||
addresses,
|
||||
redirectUrl,
|
||||
name,
|
||||
selectedAddressId: initialSelectedAddressId,
|
||||
onClose,
|
||||
onSelectAddress,
|
||||
}: AddressSelectionModalProps) => {
|
||||
const [selectedAddressId, setSelectedAddressId] = useState<string | null>(initialSelectedAddressId);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setSelectedAddressId(initialSelectedAddressId);
|
||||
} else {
|
||||
setSelectedAddressId(null);
|
||||
}
|
||||
}, [visible, initialSelectedAddressId]);
|
||||
|
||||
const handleSelectAddress = (addressId: string) => {
|
||||
setSelectedAddressId(addressId);
|
||||
onSelectAddress(addressId);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className='text-right'>
|
||||
<h2 className='text-base font-medium mb-6'>انتخاب آدرس</h2>
|
||||
<div className='max-h-[60vh] overflow-y-auto space-y-3'>
|
||||
{addresses.map((address) => (
|
||||
<div
|
||||
key={address.id}
|
||||
onClick={() => handleSelectAddress(address.id)}
|
||||
className={`bg-background rounded-container p-4 cursor-pointer transition-all ${
|
||||
selectedAddressId === address.id ? 'border border-primary' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-2">
|
||||
<h3 className='text-sm font-medium'>{address.title}</h3>
|
||||
{address.isDefault && (
|
||||
<span className='text-xs bg-primary/10 text-primary px-2 py-1 rounded-full'>
|
||||
پیشفرض
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className='text-sm2 mt-2'>
|
||||
{formatAddress(address)}
|
||||
</p>
|
||||
<p className='text-xs mt-2 text-disabled-text'>
|
||||
کد پستی: {ef(address.postalCode || 'ثبت نشده')}
|
||||
</p>
|
||||
<p className='text-xs mt-2 text-disabled-text'>
|
||||
تلفن: {ef(address.phone)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className='mt-6'>
|
||||
<Link href={`/${name}/profile/address/new?redirect=${encodeURIComponent(redirectUrl)}`}>
|
||||
<PrimaryButton className='w-full'>
|
||||
افزودن آدرس جدید
|
||||
</PrimaryButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import InputField from '@/components/input/InputField';
|
||||
import { useCheckoutStore } from '../../store/Store';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
export const CarAddressSection = () => {
|
||||
const { carAddress, setCarAddress } = useCheckoutStore();
|
||||
|
||||
const handleCarModelChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCarAddress({
|
||||
carModel: e.target.value,
|
||||
carColor: carAddress?.carColor || '',
|
||||
plateNumber: carAddress?.plateNumber || '',
|
||||
});
|
||||
}, [carAddress, setCarAddress]);
|
||||
|
||||
const handleCarColorChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCarAddress({
|
||||
carModel: carAddress?.carModel || '',
|
||||
carColor: e.target.value,
|
||||
plateNumber: carAddress?.plateNumber || '',
|
||||
});
|
||||
}, [carAddress, setCarAddress]);
|
||||
|
||||
const handlePlateNumberChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setCarAddress({
|
||||
carModel: carAddress?.carModel || '',
|
||||
carColor: carAddress?.carColor || '',
|
||||
plateNumber: e.target.value,
|
||||
});
|
||||
}, [carAddress, setCarAddress]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!carAddress) {
|
||||
setCarAddress({
|
||||
carModel: '',
|
||||
carColor: '',
|
||||
plateNumber: '',
|
||||
});
|
||||
}
|
||||
}, [carAddress, setCarAddress]);
|
||||
|
||||
return (
|
||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
||||
<div className="py-3">
|
||||
<h3 className="text-sm2 font-medium leading-5 mb-4">
|
||||
اطلاعات خودرو
|
||||
</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
<InputField
|
||||
htmlFor="carModel"
|
||||
labelText="مدل خودرو"
|
||||
placeholder="مثال: پژو 206"
|
||||
value={carAddress?.carModel || ''}
|
||||
onChange={handleCarModelChange}
|
||||
className="bg-inherit"
|
||||
inputClassName="text-xs!"
|
||||
|
||||
/>
|
||||
<InputField
|
||||
htmlFor="carColor"
|
||||
labelText="رنگ خودرو"
|
||||
placeholder="مثال: سفید"
|
||||
value={carAddress?.carColor || ''}
|
||||
onChange={handleCarColorChange}
|
||||
className="bg-inherit"
|
||||
inputClassName="text-xs!"
|
||||
|
||||
/>
|
||||
<InputField
|
||||
htmlFor="plateNumber"
|
||||
labelText="شماره پلاک"
|
||||
placeholder="مثال: 12ب34567"
|
||||
value={carAddress?.plateNumber || ''}
|
||||
onChange={handlePlateNumberChange}
|
||||
className="bg-inherit"
|
||||
inputClassName="text-xs!"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowLeft } from 'iconsax-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const CheckoutHeader = () => {
|
||||
const { t } = useTranslation('parallels', { keyPrefix: 'OrderDetail' });
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-3 items-center py-4 '>
|
||||
<span></span>
|
||||
<h1 className='text-sm2 place-self-center font-medium'>{t("Heading")}</h1>
|
||||
<ArrowLeft
|
||||
className='cursor-pointer place-self-end'
|
||||
size='24'
|
||||
color='currentColor'
|
||||
onClick={() => { router.back() }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client';
|
||||
|
||||
import InputField from '@/components/input/InputField';
|
||||
import clsx from 'clsx';
|
||||
import { TickCircle, Trash } from 'iconsax-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useApplyCoupon, useRemoveCoupon } from '../../hooks/useOrderData';
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import { extractErrorMessage } from '@/lib/func';
|
||||
import { toast } from '@/components/Toast';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useGetCartItems } from '@/app/[name]/(Dialogs)/cart/hooks/useCartData';
|
||||
|
||||
type CouponSectionProps = {
|
||||
couponType: string;
|
||||
couponCode: string;
|
||||
couponCodeError: string;
|
||||
onCouponTypeChange: (id: string) => void;
|
||||
onCouponCodeChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export const CouponSection = ({
|
||||
couponCode,
|
||||
couponCodeError,
|
||||
onCouponCodeChange,
|
||||
}: CouponSectionProps) => {
|
||||
|
||||
const { t } = useTranslation('parallels', { keyPrefix: 'OrderDetail' });
|
||||
const { mutate: applyCoupon, isPending } = useApplyCoupon();
|
||||
const { mutate: removeCoupon, isPending: isRemoving } = useRemoveCoupon();
|
||||
const { data: cartItems } = useGetCartItems();
|
||||
const [isApplied, setIsApplied] = useState<boolean>(false);
|
||||
|
||||
const handleApplyCoupon = () => {
|
||||
applyCoupon(couponCode, {
|
||||
onSuccess: () => {
|
||||
toast('کوپن با موفقیت اعمال شد', 'success');
|
||||
setIsApplied(true);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error');
|
||||
setIsApplied(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleCouponCodeChange = (value: string) => {
|
||||
onCouponCodeChange(value);
|
||||
if (isApplied) {
|
||||
setIsApplied(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveCoupon = () => {
|
||||
removeCoupon(undefined, {
|
||||
onSuccess: () => {
|
||||
toast('کوپن با موفقیت حذف شد', 'success');
|
||||
setIsApplied(false);
|
||||
onCouponCodeChange('');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const hasCoupon = (cartItems?.data?.couponDiscount ?? 0) > 0;
|
||||
setIsApplied(hasCoupon);
|
||||
if (hasCoupon && cartItems?.data?.coupon?.couponCode) {
|
||||
onCouponCodeChange(cartItems.data.coupon.couponCode);
|
||||
}
|
||||
}, [cartItems, onCouponCodeChange]);
|
||||
|
||||
return (
|
||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
||||
<div className="py-3">
|
||||
<h3 className="text-sm2 font-medium leading-5">{t("SectionCoupon.Title")}</h3>
|
||||
|
||||
<div className='flex flex-col gap-5 mt-6'>
|
||||
{/* {couponOptions.map(({ id, label, icon: Icon }) => (
|
||||
<div key={id} className='flex items-center'>
|
||||
<label className='flex items-center gap-2 w-full' htmlFor={`couponType${id}`}>
|
||||
<Icon
|
||||
size={16}
|
||||
color='currentColor'
|
||||
className='mr-2'
|
||||
/>
|
||||
<span className='text-xs mt-0.5'>{label}</span>
|
||||
</label>
|
||||
|
||||
<input
|
||||
checked={couponType === id}
|
||||
onChange={() => onCouponTypeChange(id)}
|
||||
type='radio'
|
||||
name='couponType'
|
||||
id={`couponType${id}`}
|
||||
className='size-4 accent-primary' />
|
||||
</div>
|
||||
))} */}
|
||||
</div>
|
||||
|
||||
{isApplied ? (
|
||||
<div className='mt-6 flex items-center gap-3'>
|
||||
<div className='flex-1 flex items-center gap-2 px-4 py-2.5 bg-emerald-50 dark:bg-emerald-900/20 border border-emerald-200 dark:border-emerald-800 rounded-normal'>
|
||||
<TickCircle size={20} color='#10b981' variant='Bold' />
|
||||
<span className='text-xs text-emerald-700 dark:text-emerald-400 font-medium'>کد تخفیف اعمال شده</span>
|
||||
<span className='text-xs text-emerald-600 dark:text-emerald-500 font-medium mr-auto'>{couponCode}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemoveCoupon}
|
||||
disabled={isRemoving}
|
||||
className='p-2.5 hover:bg-gray-100 dark:hover:bg-neutral-800 rounded-normal transition-colors disabled:opacity-50'
|
||||
type='button'
|
||||
>
|
||||
<Trash color='currentColor' size={20} className='text-gray-600 dark:text-gray-400' />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex gap-4 items-end'>
|
||||
<InputField
|
||||
autoComplete='off'
|
||||
className={clsx(
|
||||
'relative',
|
||||
couponCodeError.trim() === 'valid' && 'text-emerald-400 **:stroke-emerald-400 ring ring-emerald-400'
|
||||
)}
|
||||
placeholder={t("SectionCoupon.InputCouponCode.Placeholder")}
|
||||
htmlFor={'couponCode'}
|
||||
onChange={(e) => handleCouponCodeChange(e.target.value)}
|
||||
value={couponCode}
|
||||
/>
|
||||
<Button
|
||||
disabled={couponCode.length === 0 || isPending}
|
||||
className='w-fit h-11'
|
||||
onClick={handleApplyCoupon}
|
||||
pending={isPending}
|
||||
>
|
||||
<div className='flex items-center gap-2'>
|
||||
<TickCircle size={16} color='currentColor' variant='Outline' />
|
||||
<span className='text-xs whitespace-nowrap'>اعمال</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
'use client';
|
||||
|
||||
import { Card, Icon, Wallet2 } from 'iconsax-react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { useGetPaymentMethod } from '../../hooks/useOrderData';
|
||||
import { useCheckoutStore } from '../../store/Store';
|
||||
import { useGetUserWallet } from '@/app/[name]/(Main)/transactions/hooks/useTransactionData';
|
||||
import { useGetCartItems } from '@/app/[name]/(Dialogs)/cart/hooks/useCartData';
|
||||
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
|
||||
|
||||
type PaymentSectionProps = {
|
||||
paymentType: string;
|
||||
onPaymentTypeChange: (id: string) => void;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const getIconByMethod = (method: "CardOnDelivery" | "Cash" | "Online", _gateway: string | null): Icon => {
|
||||
if (method === "Online" || method === "CardOnDelivery") {
|
||||
return Card;
|
||||
}
|
||||
if (method === "Cash") {
|
||||
return Wallet2;
|
||||
}
|
||||
return Card;
|
||||
};
|
||||
|
||||
const isWalletPayment = (method: string, gateway: string | null): boolean => {
|
||||
return gateway?.toLowerCase() === 'wallet' || method.toLowerCase() === 'wallet';
|
||||
};
|
||||
|
||||
export const PaymentSection = ({ paymentType, onPaymentTypeChange }: PaymentSectionProps) => {
|
||||
|
||||
const { t: tOrderDetail } = useTranslation('parallels', { keyPrefix: 'OrderDetail' });
|
||||
const { t: tOrders } = useTranslation('orders');
|
||||
const { data: paymentMethod } = useGetPaymentMethod();
|
||||
const { setIsSelectedPayment } = useCheckoutStore();
|
||||
const { data: userWallet } = useGetUserWallet();
|
||||
const { isSuccess } = useGetProfile();
|
||||
const { data: cartData } = useGetCartItems(isSuccess);
|
||||
|
||||
const walletAmount = userWallet?.data?.balance ?? 0;
|
||||
const totalAmount = cartData?.data?.total ?? 0;
|
||||
const isWalletInsufficient = walletAmount < totalAmount;
|
||||
|
||||
const formatPrice = useCallback(
|
||||
(value: number) => value.toLocaleString('fa-IR'),
|
||||
[]
|
||||
);
|
||||
|
||||
const getPaymentMethodTranslation = useCallback((method: string, fallbackTitle: string) => {
|
||||
const translationKey = `paymentMethod.${method}`;
|
||||
const translation = tOrders(translationKey);
|
||||
return translation !== translationKey ? translation : fallbackTitle;
|
||||
}, [tOrders]);
|
||||
|
||||
const paymentOptions = useMemo(() => {
|
||||
if (!paymentMethod?.data) return [];
|
||||
|
||||
return paymentMethod.data
|
||||
.filter(item => item.enabled)
|
||||
.sort((a, b) => a.order - b.order)
|
||||
.map(item => ({
|
||||
id: item.id,
|
||||
label: getPaymentMethodTranslation(item.method, item.title),
|
||||
icon: getIconByMethod(item.method, item.gateway),
|
||||
method: item.method,
|
||||
gateway: item.gateway,
|
||||
isWallet: isWalletPayment(item.method, item.gateway),
|
||||
}));
|
||||
}, [paymentMethod, getPaymentMethodTranslation]);
|
||||
|
||||
return (
|
||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
||||
<div className="py-3">
|
||||
<h3 className="text-sm2 font-medium leading-5">{tOrderDetail("SectionPayment.Title")}</h3>
|
||||
|
||||
<RadioGroup
|
||||
value={paymentType}
|
||||
onValueChange={(value) => {
|
||||
onPaymentTypeChange(value);
|
||||
setIsSelectedPayment(true);
|
||||
}}
|
||||
className='flex flex-col gap-6 mt-6'
|
||||
>
|
||||
{paymentOptions.map(({ id, label, icon: Icon, isWallet }) => {
|
||||
const isDisabled = isWallet && isWalletInsufficient;
|
||||
const walletOption = paymentOptions.find(opt => opt.id === id && opt.isWallet);
|
||||
|
||||
return (
|
||||
<div key={id} className='flex items-center justify-between'>
|
||||
<RadioGroupItem
|
||||
value={id}
|
||||
id={`paymentMethod${id}`}
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<label
|
||||
className={`flex items-center gap-2 ${isDisabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
|
||||
htmlFor={`paymentMethod${id}`}
|
||||
>
|
||||
<div className='flex gap-1'>
|
||||
{walletOption && (
|
||||
<span className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||
({formatPrice(walletAmount)} T)
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className='text-xs mt-0.5'>{label}</span>
|
||||
</div>
|
||||
<Icon
|
||||
size={16}
|
||||
color='currentColor'
|
||||
className='mr-2'
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import Combobox from '@/components/combobox/Combobox';
|
||||
import { Box, Location, Car, Building } from 'iconsax-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useGetShipmentMethod } from '../../hooks/useOrderData';
|
||||
import { DeliveryMethod } from '../../types/Types';
|
||||
import { useCheckoutStore } from '../../store/Store';
|
||||
|
||||
type ShippingSectionProps = {
|
||||
deliveryType: string;
|
||||
onDeliveryTypeChange: (id: string) => void;
|
||||
};
|
||||
|
||||
export const ShippingSection = ({ deliveryType, onDeliveryTypeChange }: ShippingSectionProps) => {
|
||||
|
||||
const { t: tOrderDetail } = useTranslation('parallels', { keyPrefix: 'OrderDetail' });
|
||||
const { t: tOrders } = useTranslation('orders');
|
||||
const { data: shipmentMethod } = useGetShipmentMethod();
|
||||
const { setIsSelectedShipment } = useCheckoutStore();
|
||||
|
||||
const getDeliveryMethodTranslation = (method: string) => {
|
||||
const translationKey = method === 'pickup' ? 'customerPickup' : method;
|
||||
return tOrders(`deliveryMethod.${translationKey}`);
|
||||
};
|
||||
|
||||
const getDeliveryMethodIcon = (method: string) => {
|
||||
switch (method) {
|
||||
case 'dineIn':
|
||||
return Building;
|
||||
case 'pickup':
|
||||
return Location;
|
||||
case 'deliveryCar':
|
||||
return Car;
|
||||
case 'deliveryCourier':
|
||||
return undefined;
|
||||
default:
|
||||
return Box;
|
||||
}
|
||||
};
|
||||
|
||||
const getDeliveryMethodImage = (method: string) => {
|
||||
if (method === 'deliveryCourier') {
|
||||
return '/assets/images/fast-delivery.svg';
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const options = shipmentMethod?.data
|
||||
.filter((item: DeliveryMethod) => item.enabled)
|
||||
.sort((a: DeliveryMethod, b: DeliveryMethod) => a.order - b.order)
|
||||
.map((item: DeliveryMethod) => ({
|
||||
id: item.id,
|
||||
title: getDeliveryMethodTranslation(item.method),
|
||||
icon: getDeliveryMethodIcon(item.method),
|
||||
imagePath: getDeliveryMethodImage(item.method)
|
||||
})) || [];
|
||||
|
||||
return (
|
||||
<section className="bg-container rounded-container box-shadow-normal p-4">
|
||||
<div className="py-3">
|
||||
<h3 className="text-sm2 font-medium leading-5">{tOrderDetail("SectionShipping.Title")}</h3>
|
||||
<Combobox
|
||||
className='relative mt-6'
|
||||
icon={Box}
|
||||
title=''
|
||||
placeholder={'انتخاب روش تحویل'}
|
||||
searchable={false}
|
||||
options={options}
|
||||
selectedId={deliveryType}
|
||||
onSelectionChange={(e, i) => {
|
||||
const selectedOption = options[i];
|
||||
if (selectedOption) {
|
||||
onDeliveryTypeChange(selectedOption.id);
|
||||
setIsSelectedShipment(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
'use client';
|
||||
|
||||
import AnimatedBottomSheet from '@/components/bottomsheet/AnimatedBottomSheet';
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import { ChevronLeft } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useCheckoutStore } from '../../store/Store';
|
||||
import { toast } from '@/components/Toast';
|
||||
import { useCreateOrder } from '../../hooks/useOrderData';
|
||||
import { extractErrorMessage } from '@/lib/func';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useGetCartItems, useSaveAllMethod } from '@/app/[name]/(Dialogs)/cart/hooks/useCartData';
|
||||
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useGetShipmentMethod } from '../../hooks/useOrderData';
|
||||
import CartItemsList from '@/app/[name]/(Dialogs)/cart/components/CartItemsList';
|
||||
import { useCartStore } from '@/app/[name]/(Dialogs)/cart/store/Store';
|
||||
|
||||
type SummarySectionProps = {
|
||||
cartModal: boolean;
|
||||
onToggleCartModal: () => void;
|
||||
deliveryType: string;
|
||||
};
|
||||
|
||||
export const SummarySection = ({ cartModal, onToggleCartModal, deliveryType }: SummarySectionProps) => {
|
||||
|
||||
const { t } = useTranslation('parallels', { keyPrefix: 'OrderDetail' });
|
||||
const { isSelectedShipment, isSelectedPayment, deliveryType: storeDeliveryType, paymentType, selectedAddressId, tableNumber, carAddress, clear } = useCheckoutStore();
|
||||
const { description, reset: resetCart } = useCartStore();
|
||||
const { mutate: createOrder, isPending: isCreateOrderPending } = useCreateOrder();
|
||||
const { mutate: saveAllMethod, isPending: isSaveAllMethodPending } = useSaveAllMethod()
|
||||
const params = useParams();
|
||||
const name = params.name as string;
|
||||
const { isSuccess } = useGetProfile();
|
||||
const { data: cartData } = useGetCartItems(isSuccess);
|
||||
const { data: shipmentMethod } = useGetShipmentMethod();
|
||||
|
||||
const selectedDeliveryMethod = useMemo(() => {
|
||||
if (!shipmentMethod?.data || !deliveryType) return null;
|
||||
return shipmentMethod.data.find((item) => item.id === deliveryType);
|
||||
}, [shipmentMethod?.data, deliveryType]);
|
||||
|
||||
const isCourierDelivery = useMemo(() => {
|
||||
return selectedDeliveryMethod?.method === 'deliveryCourier';
|
||||
}, [selectedDeliveryMethod]);
|
||||
|
||||
const isDeliveryCar = useMemo(() => {
|
||||
return selectedDeliveryMethod?.method === 'deliveryCar';
|
||||
}, [selectedDeliveryMethod]);
|
||||
|
||||
const isDineIn = useMemo(() => {
|
||||
return selectedDeliveryMethod?.method === 'dineIn';
|
||||
}, [selectedDeliveryMethod]);
|
||||
|
||||
const formatPrice = useCallback(
|
||||
(value: number) => value.toLocaleString('fa-IR'),
|
||||
[]
|
||||
);
|
||||
|
||||
const subTotal = cartData?.data?.subTotal ?? 0;
|
||||
const deliveryFee = cartData?.data?.deliveryFee ?? 0;
|
||||
const totalDiscount = cartData?.data?.totalDiscount ?? 0;
|
||||
const tax = cartData?.data?.tax ?? 0;
|
||||
const total = cartData?.data?.total ?? 0;
|
||||
|
||||
const handleSubmit = () => {
|
||||
console.log('sele', storeDeliveryType);
|
||||
|
||||
// if (!isSelectedAddress && isSelectedShipment && isCourierDelivery) {
|
||||
// toast('لطفا آدرس را انتخاب کنید', 'error');
|
||||
// return;
|
||||
// }
|
||||
if (!isSelectedShipment) {
|
||||
toast('لطفا روش تحویل را انتخاب کنید', 'error');
|
||||
return;
|
||||
}
|
||||
if (!isSelectedPayment) {
|
||||
toast('لطفا روش پرداخت را انتخاب کنید', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isDeliveryCar) {
|
||||
if (!carAddress || !carAddress.carModel || !carAddress.carColor || !carAddress.plateNumber) {
|
||||
toast('لطفا اطلاعات خودرو را تکمیل کنید', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!storeDeliveryType || storeDeliveryType === '0' || !paymentType || paymentType === '0') {
|
||||
toast('لطفا تمام اطلاعات را تکمیل کنید', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// if (!isDeliveryCar && !isDineIn && !selectedAddressId) {
|
||||
// toast('لطفا آدرس را انتخاب کنید', 'error');
|
||||
// return;
|
||||
// }
|
||||
|
||||
const saveAllMethodData = {
|
||||
description: description || '',
|
||||
deliveryMethodId: storeDeliveryType,
|
||||
paymentMethodId: paymentType,
|
||||
tableNumber: isDineIn ? tableNumber.toString() : undefined,
|
||||
...(isCourierDelivery && selectedAddressId && { addressId: selectedAddressId }),
|
||||
...(isDeliveryCar && carAddress && { carAddress }),
|
||||
};
|
||||
saveAllMethod(saveAllMethodData, {
|
||||
onSuccess: () => {
|
||||
const orderData: {
|
||||
deliveryMethodId?: string;
|
||||
paymentMethodId?: string;
|
||||
addressId?: string;
|
||||
tableNumber?: number;
|
||||
} = {};
|
||||
|
||||
if (storeDeliveryType && storeDeliveryType !== '0') {
|
||||
orderData.deliveryMethodId = storeDeliveryType;
|
||||
}
|
||||
if (paymentType && paymentType !== '0') {
|
||||
orderData.paymentMethodId = paymentType;
|
||||
}
|
||||
if (isCourierDelivery && selectedAddressId) {
|
||||
orderData.addressId = selectedAddressId;
|
||||
}
|
||||
if (isDineIn && tableNumber) {
|
||||
const numTableNumber = Number(tableNumber);
|
||||
if (numTableNumber > 0 && !isNaN(numTableNumber)) {
|
||||
orderData.tableNumber = numTableNumber;
|
||||
}
|
||||
}
|
||||
createOrder(orderData, {
|
||||
onSuccess: (data) => {
|
||||
clear();
|
||||
resetCart();
|
||||
if (data?.data?.paymentUrl) {
|
||||
window.location.href = data?.data?.paymentUrl;
|
||||
toast('در حال ریدایرکت به صفحه پرداخت...', 'success');
|
||||
} else {
|
||||
window.location.href = `/${name}/order/track/${data?.data?.order?.id}`;
|
||||
toast('سفارش با موفقیت ثبت شد', 'success');
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error');
|
||||
}
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="pb-24">
|
||||
<div className="py-3">
|
||||
<div className='w-full flex justify-between items-center gap-2'>
|
||||
<h3 className="text-sm2 font-medium leading-5 inline-block">{t("SectionSummary.Title")}</h3>
|
||||
<button
|
||||
className='text-sm2 text-primary cursor-pointer dark:text-foreground'
|
||||
onClick={onToggleCartModal}
|
||||
>
|
||||
{t("SectionSummary.ButtonViewCart")}
|
||||
<ChevronLeft className='inline-block mr-1 mb-0.5 stroke-primary dark:stroke-foreground' size='16' />
|
||||
</button>
|
||||
</div>
|
||||
<div className='mt-6 text-sm2 grid grid-cols-2 gap-4'>
|
||||
<span className=''>{t("SectionSummary.SumOfItemsLabel")}</span>
|
||||
<span className='font-medium place-self-end '>{formatPrice(subTotal)} T</span>
|
||||
<span className=''>{t("SectionSummary.DeliveryLabel")}</span>
|
||||
<span className='font-medium place-self-end '>{deliveryFee === 0 ? 'رایگان' : formatPrice(deliveryFee) + ' T'}</span>
|
||||
{totalDiscount > 0 && (
|
||||
<>
|
||||
<span className=''>{t("SectionSummary.DiscountLabel")}</span>
|
||||
<span className='font-medium place-self-end '>{formatPrice(totalDiscount)} T</span>
|
||||
</>
|
||||
)}
|
||||
{tax > 0 && (
|
||||
<>
|
||||
<span className=''>{t("SectionSummary.TaxLabel")}</span>
|
||||
<span className='font-medium place-self-end '>{formatPrice(tax)} T</span>
|
||||
</>
|
||||
)}
|
||||
<span className='font-medium '>{t("SectionSummary.TotalLabel")}</span>
|
||||
<span className='font-bold place-self-end'>{formatPrice(total)} T</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='fixed bottom-0 left-0 right-0 z-50 bg-white dark:bg-background border-t border-border p-4 w-full'>
|
||||
<div className='flex justify-between items-center gap-4'>
|
||||
<div className='flex flex-col'>
|
||||
<div className='text-xs text-gray-400 dark:text-gray-500'>{t("PayableAmountLabel")}</div>
|
||||
<div className='text-sm mt-2 font-semibold'>{formatPrice(total)} تومان</div>
|
||||
</div>
|
||||
<Button pending={isCreateOrderPending || isSaveAllMethodPending} onClick={handleSubmit} className='px-10 w-fit dark:bg-white dark:text-black! dark:hover:bg-gray-100'>
|
||||
{t("ButtonSubmit")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<AnimatedBottomSheet
|
||||
title={t("CartModal.Title")}
|
||||
visible={cartModal}
|
||||
onClick={onToggleCartModal}
|
||||
titlePadding='ps-5 pe-5'
|
||||
>
|
||||
<div className='overflow-y-auto noscrollbar px-4 mt-4' dir='rtl'>
|
||||
<CartItemsList />
|
||||
</div>
|
||||
</AnimatedBottomSheet>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { MinusIcon, PlusIcon } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type TableSectionProps = {
|
||||
tableNumber: number;
|
||||
onIncrement: () => void;
|
||||
onDecrement: () => void;
|
||||
};
|
||||
|
||||
export const TableSection = ({ tableNumber, onIncrement, onDecrement }: TableSectionProps) => {
|
||||
const { t } = useTranslation('parallels', { keyPrefix: 'OrderDetail' });
|
||||
|
||||
return (
|
||||
<section className="bg-container rounded-container box-shadow-normal p-4 py-2">
|
||||
<div className="py-3">
|
||||
<div className='w-full grid grid-cols-2 justify-between items-center gap-2'>
|
||||
<h3 className="text-sm2 font-medium leading-5 inline-block">{t("SectionTable.Title")}</h3>
|
||||
|
||||
<div className='w-full flex justify-end'>
|
||||
<motion.div
|
||||
whileTap={{ scale: 1.05 }}
|
||||
className="bg-background active:drop-shadow-xs max-w-[132px] rounded-md w-full h-8 inline-flex p-1 justify-between items-center gap-2"
|
||||
>
|
||||
<button
|
||||
onClick={onIncrement}
|
||||
className="bg-container hover:bg-container/60 size-6 flex justify-center items-center active:bg-container/30 rounded-sm"
|
||||
>
|
||||
<PlusIcon size={16} />
|
||||
</button>
|
||||
<motion.div
|
||||
key={tableNumber}
|
||||
initial={{ scale: 1 }}
|
||||
animate={{ scale: [1.2, 0.95, 1] }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="text-sm2 pt-0.5 font-semibold"
|
||||
>
|
||||
{tableNumber}
|
||||
</motion.div>
|
||||
<button
|
||||
onClick={onDecrement}
|
||||
className="bg-container hover:bg-container/60 size-6 flex justify-center items-center active:bg-container/30 rounded-sm"
|
||||
>
|
||||
<MinusIcon size={16} />
|
||||
</button>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
'use client';
|
||||
|
||||
import useToggle from '@/hooks/helpers/useToggle';
|
||||
import React, { useCallback, useEffect, useMemo } from 'react';
|
||||
import { AddressSection } from './components/AddressSection';
|
||||
import { CarAddressSection } from './components/CarAddressSection';
|
||||
import { CheckoutHeader } from './components/CheckoutHeader';
|
||||
import { CouponSection } from './components/CouponSection';
|
||||
import { PaymentSection } from './components/PaymentSection';
|
||||
import { ShippingSection } from './components/ShippingSection';
|
||||
import { SummarySection } from './components/SummarySection';
|
||||
import { TableSection } from './components/TableSection';
|
||||
import { useGetShipmentMethod } from '../hooks/useOrderData';
|
||||
import { useGetCartItems } from '@/app/[name]/(Dialogs)/cart/hooks/useCartData';
|
||||
import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData';
|
||||
import { useCheckoutStore } from '../store/Store';
|
||||
|
||||
function OrderDetailInex() {
|
||||
const { isSuccess } = useGetProfile();
|
||||
const { data: cartItems } = useGetCartItems(isSuccess);
|
||||
const { state: cartModal, toggle: toggleCartModal } = useToggle();
|
||||
const { data: shipmentMethod } = useGetShipmentMethod();
|
||||
const {
|
||||
deliveryType,
|
||||
setDeliveryType,
|
||||
paymentType,
|
||||
setPaymentType,
|
||||
tableNumber,
|
||||
setTableNumber,
|
||||
setSelectedAddressId,
|
||||
setIsSelectedAddress,
|
||||
setIsSelectedPayment,
|
||||
setIsSelectedShipment,
|
||||
} = useCheckoutStore();
|
||||
const [couponType, setCouponType] = React.useState<string>('0');
|
||||
const [couponCode, setCouponCode] = React.useState<string>('');
|
||||
const [couponCodeError, setCouponCodeError] = React.useState<string>('');
|
||||
|
||||
const incrementTableNumber = () => setTableNumber(tableNumber + 1);
|
||||
const decrementTableNumber = () => setTableNumber(Math.max(0, tableNumber - 1));
|
||||
|
||||
const changeDeliveryType = useCallback((id: string) => {
|
||||
setDeliveryType(id);
|
||||
}, [setDeliveryType]);
|
||||
|
||||
const changePaymentType = useCallback((id: string) => {
|
||||
setPaymentType(id);
|
||||
}, [setPaymentType]);
|
||||
|
||||
const changeCouponType = useCallback((id: string) => {
|
||||
setCouponCode('');
|
||||
setCouponCodeError('');
|
||||
setCouponType(id);
|
||||
}, []);
|
||||
|
||||
const changeCouponCode = useCallback((value: string) => {
|
||||
setCouponCode(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (cartItems?.data?.deliveryMethodId) {
|
||||
setDeliveryType(cartItems.data.deliveryMethodId);
|
||||
setIsSelectedShipment(true);
|
||||
}
|
||||
}, [cartItems?.data?.deliveryMethodId, setDeliveryType, setIsSelectedShipment]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cartItems?.data?.paymentMethodId) {
|
||||
setPaymentType(cartItems.data.paymentMethodId);
|
||||
setIsSelectedPayment(true);
|
||||
}
|
||||
}, [cartItems?.data?.paymentMethodId, setPaymentType, setIsSelectedPayment]);
|
||||
|
||||
const selectedDeliveryMethod = useMemo(() => {
|
||||
if (!shipmentMethod?.data || !deliveryType) return null;
|
||||
return shipmentMethod.data.find((item) => item.id === deliveryType);
|
||||
}, [shipmentMethod?.data, deliveryType]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cartItems?.data?.addressId && selectedDeliveryMethod?.method === 'deliveryCourier') {
|
||||
const addressId = cartItems.data.addressId;
|
||||
setIsSelectedAddress(true);
|
||||
setSelectedAddressId(addressId ?? null);
|
||||
}
|
||||
}, [cartItems?.data?.addressId, setIsSelectedAddress, setSelectedAddressId, selectedDeliveryMethod]);
|
||||
|
||||
useEffect(() => {
|
||||
if (cartItems?.data?.coupon?.couponCode && cartItems.data.couponDiscount > 0) {
|
||||
setCouponCode(cartItems.data.coupon.couponCode);
|
||||
}
|
||||
}, [cartItems?.data?.coupon?.couponCode, cartItems?.data?.couponDiscount]);
|
||||
|
||||
return (
|
||||
<div className='h-full bg-background -mx-6 -mt-4 -mb-6 px-6 pt-4 pb-6 flex flex-col gap-5'>
|
||||
<CheckoutHeader />
|
||||
|
||||
<ShippingSection
|
||||
deliveryType={deliveryType}
|
||||
onDeliveryTypeChange={changeDeliveryType}
|
||||
/>
|
||||
|
||||
{selectedDeliveryMethod?.method === 'deliveryCar' && (
|
||||
<CarAddressSection />
|
||||
)}
|
||||
|
||||
{selectedDeliveryMethod?.method === 'deliveryCourier' && (
|
||||
<AddressSection />
|
||||
)}
|
||||
|
||||
{selectedDeliveryMethod?.method === 'dineIn' && (
|
||||
<TableSection
|
||||
tableNumber={tableNumber}
|
||||
onIncrement={incrementTableNumber}
|
||||
onDecrement={decrementTableNumber}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PaymentSection
|
||||
paymentType={paymentType}
|
||||
onPaymentTypeChange={changePaymentType}
|
||||
/>
|
||||
|
||||
<CouponSection
|
||||
couponType={couponType}
|
||||
couponCode={couponCode}
|
||||
couponCodeError={couponCodeError}
|
||||
onCouponTypeChange={changeCouponType}
|
||||
onCouponCodeChange={changeCouponCode}
|
||||
/>
|
||||
|
||||
<SummarySection
|
||||
cartModal={cartModal}
|
||||
onToggleCartModal={toggleCartModal}
|
||||
deliveryType={deliveryType}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrderDetailInex
|
||||
@@ -0,0 +1,14 @@
|
||||
export const enum OrderStatus {
|
||||
NEW = "new",
|
||||
PENDING_PAYMENT = "pendingPayment",
|
||||
PAID = "paid",
|
||||
CONFIRMED = "confirmed",
|
||||
PREPARING = "preparing",
|
||||
READY = "ready",
|
||||
SHIPPED = "shipped",
|
||||
COMPLETED = "completed",
|
||||
|
||||
CANCELED = "canceled",
|
||||
FAILED = "failed",
|
||||
REFUNDED = "refunded",
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
getPaymentMethod,
|
||||
getShipmentMethod,
|
||||
setAddressCart,
|
||||
setDeliveryMethod,
|
||||
setPaymentMethod,
|
||||
createOrder,
|
||||
applyCoupon,
|
||||
removeCoupon,
|
||||
setTableNumber,
|
||||
getOrderDetail,
|
||||
cancelOrder,
|
||||
confirmOrder,
|
||||
} from "../service/OrderService";
|
||||
|
||||
export const useGetShipmentMethod = () => {
|
||||
return useQuery({
|
||||
queryKey: ["shipment-method"],
|
||||
queryFn: getShipmentMethod,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetPaymentMethod = () => {
|
||||
return useQuery({
|
||||
queryKey: ["payment-method"],
|
||||
queryFn: getPaymentMethod,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSetAddressCart = () => {
|
||||
return useMutation({
|
||||
mutationFn: setAddressCart,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSetDeliveryMethod = () => {
|
||||
return useMutation({
|
||||
mutationFn: setDeliveryMethod,
|
||||
});
|
||||
};
|
||||
|
||||
export const useSetPaymentMethod = () => {
|
||||
return useMutation({
|
||||
mutationFn: setPaymentMethod,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateOrder = () => {
|
||||
return useMutation({
|
||||
mutationFn: createOrder,
|
||||
});
|
||||
};
|
||||
|
||||
export const useApplyCoupon = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: applyCoupon,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["cart-items"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["cart-total"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useRemoveCoupon = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: removeCoupon,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["cart-items"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["cart-total"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useSetTableNumber = () => {
|
||||
return useMutation({
|
||||
mutationFn: setTableNumber,
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetOrderDetail = (id: string) => {
|
||||
return useQuery({
|
||||
queryKey: ["order-detail", id],
|
||||
queryFn: () => getOrderDetail(id),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
|
||||
export const useCancelOrder = () => {
|
||||
return useMutation({
|
||||
mutationFn: cancelOrder,
|
||||
});
|
||||
};
|
||||
|
||||
export const useConfirmOrder = () => {
|
||||
return useMutation({
|
||||
mutationFn: confirmOrder,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { api } from "@/config/axios";
|
||||
import {
|
||||
ShipmentMethodsResponse,
|
||||
PaymentMethodsResponse,
|
||||
CreateOrderResponse,
|
||||
OrderDetailResponse,
|
||||
} from "../types/Types";
|
||||
|
||||
export const getShipmentMethod = async (): Promise<ShipmentMethodsResponse> => {
|
||||
const { data } = await api.get<ShipmentMethodsResponse>(
|
||||
"/public/delivery-methods/restaurant"
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getPaymentMethod = async (): Promise<PaymentMethodsResponse> => {
|
||||
const { data } = await api.get<PaymentMethodsResponse>(
|
||||
"/public/payments/methods/restaurant"
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const setAddressCart = async (id: string) => {
|
||||
const { data } = await api.patch("/public/cart/address", { addressId: id });
|
||||
return data;
|
||||
};
|
||||
|
||||
export const setDeliveryMethod = async (id: string) => {
|
||||
const { data } = await api.patch("/public/cart/delivery-method", {
|
||||
deliveryMethodId: id,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const setPaymentMethod = async (id: string) => {
|
||||
const { data } = await api.patch("/public/cart/payment-method", {
|
||||
paymentMethodId: id,
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const createOrder = async (orderData?: {
|
||||
deliveryMethodId?: string;
|
||||
paymentMethodId?: string;
|
||||
addressId?: string;
|
||||
tableNumber?: number;
|
||||
}): Promise<CreateOrderResponse> => {
|
||||
const { data } = await api.post<CreateOrderResponse>(
|
||||
"/public/checkout",
|
||||
orderData
|
||||
);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const applyCoupon = async (code: string) => {
|
||||
const { data } = await api.post("/public/cart/apply-coupon", { code });
|
||||
return data;
|
||||
};
|
||||
|
||||
export const removeCoupon = async () => {
|
||||
const { data } = await api.delete("/public/cart/coupon");
|
||||
return data;
|
||||
};
|
||||
|
||||
export const setTableNumber = async (number: number) => {
|
||||
const { data } = await api.patch("/public/cart/table-number", {
|
||||
tableNumber: number.toString(),
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
export const getOrderDetail = async (
|
||||
id: string
|
||||
): Promise<OrderDetailResponse> => {
|
||||
const { data } = await api.get<OrderDetailResponse>(`/public/orders/${id}`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const cancelOrder = async (id: string) => {
|
||||
const { data } = await api.patch(`/public/orders/${id}/cancel`);
|
||||
return data;
|
||||
};
|
||||
|
||||
export const confirmOrder = async (id: string) => {
|
||||
const { data } = await api.get(`/public/payments/pay-order/${id}`);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { create } from "zustand";
|
||||
import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { CheckoutStoreType } from "../types/Types";
|
||||
|
||||
const initialState = {
|
||||
isSelectedAddress: false,
|
||||
selectedAddressId: null,
|
||||
isSelectedPayment: false,
|
||||
isSelectedShipment: false,
|
||||
deliveryType: "0",
|
||||
paymentType: "0",
|
||||
tableNumber: 0,
|
||||
carAddress: null as {
|
||||
carModel: string;
|
||||
carColor: string;
|
||||
plateNumber: string;
|
||||
} | null,
|
||||
lastUpdated: null as number | null,
|
||||
};
|
||||
|
||||
export const useCheckoutStore = create<CheckoutStoreType>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
setIsSelectedAddress: (value) =>
|
||||
set({ isSelectedAddress: value, lastUpdated: Date.now() }),
|
||||
setSelectedAddressId: (value) =>
|
||||
set({ selectedAddressId: value, lastUpdated: Date.now() }),
|
||||
setIsSelectedPayment: (value) =>
|
||||
set({ isSelectedPayment: value, lastUpdated: Date.now() }),
|
||||
setIsSelectedShipment: (value) =>
|
||||
set({ isSelectedShipment: value, lastUpdated: Date.now() }),
|
||||
setDeliveryType: (value) =>
|
||||
set({ deliveryType: value, lastUpdated: Date.now() }),
|
||||
setPaymentType: (value) =>
|
||||
set({ paymentType: value, lastUpdated: Date.now() }),
|
||||
setTableNumber: (value) =>
|
||||
set({ tableNumber: value, lastUpdated: Date.now() }),
|
||||
setCarAddress: (value) =>
|
||||
set({ carAddress: value, lastUpdated: Date.now() }),
|
||||
setLastUpdated: (value) => set({ lastUpdated: value }),
|
||||
clear: () => set({ ...initialState }),
|
||||
}),
|
||||
{
|
||||
name: "checkout-storage",
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// چک خودکار برای پاک کردن بعد از 2 ساعت
|
||||
if (typeof window !== "undefined") {
|
||||
const TWO_HOURS = 2 * 60 * 60 * 1000; // 2 ساعت به میلیثانیه
|
||||
|
||||
const checkAndClear = () => {
|
||||
const stored = localStorage.getItem("checkout-storage");
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
const state = parsed.state;
|
||||
if (state?.lastUpdated) {
|
||||
const now = Date.now();
|
||||
const timeDiff = now - state.lastUpdated;
|
||||
if (timeDiff >= TWO_HOURS) {
|
||||
useCheckoutStore.getState().clear();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// در صورت خطا در parse، store را پاک میکنیم
|
||||
useCheckoutStore.getState().clear();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// چک در زمان لود شدن
|
||||
checkAndClear();
|
||||
|
||||
// چک هر 5 دقیقه
|
||||
setInterval(checkAndClear, 5 * 60 * 1000);
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
import { BaseResponse } from "@/app/[name]/(Main)/types/Types";
|
||||
|
||||
export interface ShipmentMethod {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
name: string;
|
||||
title: string;
|
||||
description: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface RestaurantShipmentMethod {
|
||||
id: string;
|
||||
restaurant: string;
|
||||
shipmentMethod: ShipmentMethod;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
price: number;
|
||||
minOrderPrice: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface DeliveryMethod {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
title: string;
|
||||
method: "dineIn" | "pickup" | "deliveryCar" | "deliveryCourier";
|
||||
restaurant: string;
|
||||
deliveryFee: number;
|
||||
minOrderPrice: number;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export type ShipmentMethodsResponse = BaseResponse<DeliveryMethod[]>;
|
||||
|
||||
export interface ServiceArea {
|
||||
type: "Polygon";
|
||||
coordinates: number[][][];
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export interface PaymentMethod {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
name: string;
|
||||
keyName: string;
|
||||
description: string;
|
||||
icon: string | null;
|
||||
isActive: boolean;
|
||||
isOnline: boolean;
|
||||
order: number;
|
||||
paymentUrl: string | null;
|
||||
}
|
||||
|
||||
export interface RestaurantPaymentMethod {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
restaurant: Restaurant;
|
||||
paymentMethod: PaymentMethod;
|
||||
merchantId: string | null;
|
||||
callbackUrl: string | null;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface PaymentMethodResponse {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
restaurant: Restaurant;
|
||||
title: string;
|
||||
method: "CardOnDelivery" | "Cash" | "Online";
|
||||
gateway: string | null;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
merchantId: string | null;
|
||||
}
|
||||
|
||||
export type PaymentMethodsResponse = BaseResponse<PaymentMethodResponse[]>;
|
||||
|
||||
export type CheckoutStoreType = {
|
||||
isSelectedAddress: boolean;
|
||||
setIsSelectedAddress: (value: boolean) => void;
|
||||
selectedAddressId: string | null;
|
||||
setSelectedAddressId: (value: string | null) => void;
|
||||
isSelectedPayment: boolean;
|
||||
setIsSelectedPayment: (value: boolean) => void;
|
||||
isSelectedShipment: boolean;
|
||||
setIsSelectedShipment: (value: boolean) => void;
|
||||
deliveryType: string;
|
||||
setDeliveryType: (value: string) => void;
|
||||
paymentType: string;
|
||||
setPaymentType: (value: string) => void;
|
||||
tableNumber: number;
|
||||
setTableNumber: (value: number) => void;
|
||||
carAddress: {
|
||||
carModel: string;
|
||||
carColor: string;
|
||||
plateNumber: string;
|
||||
} | null;
|
||||
setCarAddress: (
|
||||
value: {
|
||||
carModel: string;
|
||||
carColor: string;
|
||||
plateNumber: string;
|
||||
} | null
|
||||
) => void;
|
||||
lastUpdated: number | null;
|
||||
setLastUpdated: (value: number | null) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
export interface OrderUser {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
birthDate: string;
|
||||
marriageDate: string;
|
||||
referrer: string | null;
|
||||
isActive: boolean;
|
||||
gender: boolean;
|
||||
wallet: number;
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface OrderDeliveryMethod {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
title: string;
|
||||
method: "dineIn" | "pickup" | "deliveryCar" | "deliveryCourier";
|
||||
restaurant: Restaurant;
|
||||
deliveryFee: number;
|
||||
minOrderPrice: number;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface OrderAddress {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
user: OrderUser;
|
||||
title: string;
|
||||
address: string;
|
||||
city: string;
|
||||
province: string;
|
||||
postalCode: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
phone: string;
|
||||
isDefault: boolean;
|
||||
}
|
||||
|
||||
export interface OrderFood {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
restaurant: Restaurant;
|
||||
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 | null;
|
||||
inPlaceServe: boolean;
|
||||
pickupServe: boolean;
|
||||
rate: number;
|
||||
discount: number;
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
order: Order;
|
||||
food: OrderFood;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
discount: number;
|
||||
totalPrice: number;
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
user: OrderUser;
|
||||
restaurant: Restaurant;
|
||||
deliveryMethod: OrderDeliveryMethod;
|
||||
address: OrderAddress;
|
||||
paymentMethod: PaymentMethodResponse;
|
||||
orderNumber: number;
|
||||
couponDiscount: number;
|
||||
itemsDiscount: number;
|
||||
totalDiscount: number;
|
||||
subTotal: number;
|
||||
tax: number;
|
||||
shipmentFee: number;
|
||||
total: number;
|
||||
totalItems: number;
|
||||
status: string;
|
||||
paymentStatus: string;
|
||||
items?: OrderItem[];
|
||||
}
|
||||
|
||||
export interface CreateOrderData {
|
||||
order: Order;
|
||||
paymentUrl: string;
|
||||
}
|
||||
|
||||
export type CreateOrderResponse = BaseResponse<CreateOrderData>;
|
||||
|
||||
export interface OrderDetailUser {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
birthDate: string;
|
||||
marriageDate: string;
|
||||
referrer: string | null;
|
||||
isActive: boolean;
|
||||
gender: boolean;
|
||||
avatarUrl: string | null;
|
||||
phone: string;
|
||||
}
|
||||
|
||||
export interface RestaurantScore {
|
||||
scoreAmount: string;
|
||||
scoreCredit: string;
|
||||
birthdayScore: string;
|
||||
purchaseScore: string;
|
||||
referrerScore: string;
|
||||
registerScore: string;
|
||||
purchaseAmount: string;
|
||||
marriageDateScore: string;
|
||||
}
|
||||
|
||||
export interface OrderDetailRestaurant {
|
||||
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;
|
||||
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: RestaurantScore;
|
||||
plan: string;
|
||||
subscriptionId: string;
|
||||
}
|
||||
|
||||
export interface OrderDetailDeliveryMethod {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
method: "dineIn" | "pickup" | "deliveryCar" | "deliveryCourier";
|
||||
restaurant: string;
|
||||
deliveryFee: number;
|
||||
deliveryFeeType: "fixed" | "percentage";
|
||||
perKilometerFee: number | null;
|
||||
distanceBasedMinCost: number | null;
|
||||
minOrderPrice: number;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface OrderDetailPaymentMethod {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
restaurant: string;
|
||||
method: "CardOnDelivery" | "Cash" | "Online";
|
||||
gateway: string | null;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
merchantId: string | null;
|
||||
}
|
||||
|
||||
export interface OrderDetailFood {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
restaurant: string;
|
||||
category: string;
|
||||
title: string;
|
||||
desc: string;
|
||||
content: string[];
|
||||
price: number;
|
||||
order: number | null;
|
||||
prepareTime: number | null;
|
||||
weekDays: number[];
|
||||
mealTypes: string[];
|
||||
isActive: boolean;
|
||||
images: string[];
|
||||
inPlaceServe: boolean;
|
||||
pickupServe: boolean;
|
||||
score: number;
|
||||
discount: number;
|
||||
isSpecialOffer: boolean;
|
||||
}
|
||||
|
||||
export interface OrderDetailItem {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
order: string;
|
||||
food: OrderDetailFood;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
discount: number;
|
||||
totalPrice: number;
|
||||
}
|
||||
|
||||
export interface OrderDetailHistory {
|
||||
status: string;
|
||||
changedAt: string;
|
||||
}
|
||||
|
||||
export interface OrderDetailPayment {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
order: string;
|
||||
amount: number;
|
||||
referenceId: string | null;
|
||||
method: string;
|
||||
gateway: string | null;
|
||||
transactionId: string | null;
|
||||
status: string;
|
||||
cardPan: string | null;
|
||||
verifyResponse: unknown | null;
|
||||
paidAt: string | null;
|
||||
failedAt: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export interface OrderDetailCarAddress {
|
||||
phone: string;
|
||||
carColor: string;
|
||||
carModel: string;
|
||||
plateNumber: string;
|
||||
}
|
||||
|
||||
export interface OrderDetail {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
user: OrderDetailUser;
|
||||
restaurant: OrderDetailRestaurant;
|
||||
deliveryMethod: OrderDetailDeliveryMethod;
|
||||
userAddress: OrderAddress | null;
|
||||
carAddress: OrderDetailCarAddress | null;
|
||||
paymentMethod: OrderDetailPaymentMethod;
|
||||
orderNumber: number;
|
||||
couponDiscount: number;
|
||||
couponDetail: string | null;
|
||||
itemsDiscount: number;
|
||||
totalDiscount: number;
|
||||
subTotal: number;
|
||||
tax: number;
|
||||
deliveryFee: number;
|
||||
total: number;
|
||||
totalItems: number;
|
||||
description: string;
|
||||
tableNumber: string | null;
|
||||
status:
|
||||
| "pendingPayment"
|
||||
| "new"
|
||||
| "preparing"
|
||||
| "ready"
|
||||
| "delivering"
|
||||
| "delivered"
|
||||
| "cancelled";
|
||||
history: OrderDetailHistory[];
|
||||
payments: OrderDetailPayment[];
|
||||
items: OrderDetailItem[];
|
||||
}
|
||||
|
||||
export type OrderDetailResponse = BaseResponse<OrderDetail>;
|
||||
@@ -0,0 +1,224 @@
|
||||
'use client';
|
||||
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import React from 'react';
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Clock } from 'iconsax-react';
|
||||
import clsx from 'clsx';
|
||||
import useToggle from '@/hooks/helpers/useToggle';
|
||||
import Prompt from '@/components/utils/Prompt';
|
||||
import { useCancelOrder, useGetOrderDetail } from '../../checkout/hooks/useOrderData';
|
||||
import ordersTranslations from '@/locales/fa/orders.json';
|
||||
import { toast } from '@/components/Toast';
|
||||
import { extractErrorMessage } from '@/lib/func';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
const getDeliveryMethodTitle = (method: string) => {
|
||||
switch (method) {
|
||||
case 'deliveryCourier':
|
||||
return 'ارسال توسط پیک';
|
||||
case 'deliveryCar':
|
||||
return 'تحویل به خودرو';
|
||||
case 'pickup':
|
||||
return 'تحویل حضوری';
|
||||
case 'dineIn':
|
||||
return 'سرو در رستوران';
|
||||
default:
|
||||
return 'روش ارسال';
|
||||
}
|
||||
};
|
||||
|
||||
// const getStatusPercentage = (status: string) => {
|
||||
// switch (status) {
|
||||
// case 'new':
|
||||
// case 'pendingPayment':
|
||||
// return 0;
|
||||
// case 'paid':
|
||||
// case 'confirmed':
|
||||
// return 20;
|
||||
// case 'preparing':
|
||||
// return 40;
|
||||
// case 'ready':
|
||||
// return 60;
|
||||
// case 'shipped':
|
||||
// case 'delivering':
|
||||
// return 80;
|
||||
// case 'completed':
|
||||
// case 'delivered':
|
||||
// return 100;
|
||||
// default:
|
||||
// return 0;
|
||||
// }
|
||||
// };
|
||||
|
||||
const getStatusText = (status: string): string => {
|
||||
const statusKey = status as keyof typeof ordersTranslations.status;
|
||||
return ordersTranslations.status[statusKey] || 'نامشخص';
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
// const formatTime = (dateString: string): string => {
|
||||
// const date = new Date(dateString);
|
||||
// return date.toLocaleTimeString('fa-IR', {
|
||||
// hour: '2-digit',
|
||||
// minute: '2-digit'
|
||||
// });
|
||||
// };
|
||||
|
||||
function OrderTrackingPage() {
|
||||
const { id, name } = useParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const { state: cancelModal, toggle: toggleCancelModal } = useToggle();
|
||||
const { data: orderDetail, isLoading } = useGetOrderDetail(id as string);
|
||||
const { mutate: cancelOrder } = useCancelOrder();
|
||||
|
||||
const onCancelOrder = (e: React.MouseEvent | null) => {
|
||||
if (!e || !id) return;
|
||||
|
||||
cancelOrder(id as string, {
|
||||
onSuccess: () => {
|
||||
toggleCancelModal();
|
||||
queryClient.invalidateQueries({ queryKey: ['order-detail', id] });
|
||||
toast('سفارش با موفقیت لغو شد', 'success');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-container flex flex-col">
|
||||
<div className='flex-1 overflow-y-auto px-4 py-6'>
|
||||
<div className='max-w-2xl mx-auto flex flex-col gap-6'>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Skeleton className='h-6 w-48 mx-auto' />
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Skeleton className='h-5 w-full' />
|
||||
<Skeleton className='h-5 w-full' />
|
||||
<Skeleton className='h-20 w-full' />
|
||||
<Skeleton className='h-5 w-full' />
|
||||
</div>
|
||||
</>
|
||||
) : orderDetail?.data ? (
|
||||
<>
|
||||
<h1 className='text-lg font-semibold text-center'>
|
||||
{getDeliveryMethodTitle(orderDetail.data.deliveryMethod.method)}
|
||||
</h1>
|
||||
|
||||
<div className='flex flex-col'>
|
||||
<div className='flex justify-between items-center h-16 border-b border-border'>
|
||||
<span className='text-sm text-muted-foreground flex items-center gap-2'>
|
||||
<Clock className='stroke-current' size={16} />
|
||||
زمان سفارش
|
||||
</span>
|
||||
<div className='flex items-end gap-0.5'>
|
||||
<span className='text-sm font-semibold'>{formatDate(orderDetail.data.createdAt)}</span>
|
||||
{/* <span className='text-xs text-muted-foreground'>{formatTime(orderDetail.data.createdAt)}</span> */}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-between items-center h-16 border-b border-border'>
|
||||
<span className='text-sm text-muted-foreground'>نوع ارسال</span>
|
||||
<span className='text-sm font-semibold'>{orderDetail.data?.deliveryMethod?.description}</span>
|
||||
</div>
|
||||
{orderDetail.data.carAddress && (
|
||||
<div className='flex justify-between items-center h-16 border-b border-border'>
|
||||
<div className='text-sm text-muted-foreground mb-1'>اطلاعات خودرو</div>
|
||||
<div className='text-sm font-semibold'>
|
||||
{orderDetail.data.carAddress.carModel} {orderDetail.data.carAddress.carColor} - پلاک: {orderDetail.data.carAddress.plateNumber}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='flex justify-between items-center h-16 border-b border-border'>
|
||||
<span className='text-sm text-muted-foreground'>شماره سفارش</span>
|
||||
<span className='text-sm font-semibold'>#{orderDetail.data.orderNumber}</span>
|
||||
</div>
|
||||
<div className='flex justify-between items-center h-16 border-b border-border'>
|
||||
<span className='text-sm text-muted-foreground'>وضعیت سفارش</span>
|
||||
<span className='text-sm font-semibold'>{getStatusText(orderDetail.data.status)}</span>
|
||||
</div>
|
||||
|
||||
{orderDetail.data.tableNumber && (
|
||||
<div className='flex justify-between items-center h-16 border-b border-border'>
|
||||
<span className='text-sm text-muted-foreground'>شماره میز</span>
|
||||
<span className='text-sm font-semibold'>#{orderDetail.data.tableNumber}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{orderDetail.data.userAddress && (
|
||||
<div className='py-3 border-b border-border'>
|
||||
<div className='text-sm text-muted-foreground mb-1'>آدرس تحویل</div>
|
||||
<div className='text-sm'>{orderDetail.data.userAddress.address}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='flex justify-between items-center py-4 border-t border-border'>
|
||||
<span className='text-sm font-medium'>مجموع سفارش</span>
|
||||
<span className='text-sm font-bold '>
|
||||
{orderDetail.data.total.toLocaleString('fa-IR')} تومان
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className='text-center text-muted-foreground py-12'>
|
||||
اطلاعات سفارش یافت نشد
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='p-4 border-t border-border bg-container'>
|
||||
<div className='max-w-2xl mx-auto grid grid-cols-2 gap-4'>
|
||||
<Button
|
||||
className='dark:bg-white dark:text-black! dark:hover:bg-gray-100'
|
||||
onClick={() => router.push(`/${name}`)}
|
||||
type='button'>
|
||||
بازگشت به منو
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
onClick={toggleCancelModal}
|
||||
className='bg-disabled! text-foreground!'
|
||||
disabled={
|
||||
orderDetail?.data?.status === 'cancelled' ||
|
||||
orderDetail?.data?.status === 'delivered' ||
|
||||
orderDetail?.data?.status === 'delivering'
|
||||
}
|
||||
>
|
||||
لغو سفارش
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={clsx(
|
||||
'fixed inset-0 z-1001',
|
||||
!cancelModal && 'pointer-events-none'
|
||||
)}>
|
||||
<Prompt
|
||||
title={'لغو سفارش'}
|
||||
description={'آیا از درخواست خود اطمینان دارید؟'}
|
||||
textConfirm={'بله'}
|
||||
textCancel={'منصرف شدم'}
|
||||
onConfirm={onCancelOrder}
|
||||
visible={cancelModal}
|
||||
onClick={toggleCancelModal}
|
||||
onCancel={toggleCancelModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default OrderTrackingPage;
|
||||
@@ -0,0 +1,7 @@
|
||||
'use client';
|
||||
|
||||
export default function PagerPage() {
|
||||
// This page is just a route trigger, the actual modal is handled in DialogsLayout
|
||||
// We return an empty div so the route exists for pathname detection
|
||||
return <div className="hidden" />;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum ContactScope {
|
||||
APPLICATION = "application",
|
||||
RESTAURANT = "restaurant",
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import * as api from "../service/ReportService";
|
||||
|
||||
export const useCreateReport = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.createReport,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
'use client';
|
||||
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import InputField from '@/components/input/InputField';
|
||||
import { useFormik } from 'formik';
|
||||
import { ArrowLeft } from 'iconsax-react';
|
||||
import Image from 'next/image'
|
||||
import { useRouter } from 'next/navigation';
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { CreateReportType } from './types/Types';
|
||||
import * as Yup from 'yup';
|
||||
import { useCreateReport } from './hooks/useReportData';
|
||||
import { toast } from '@/components/Toast';
|
||||
import { extractErrorMessage } from '@/lib/func';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { ContactScope } from './enum/Enum';
|
||||
|
||||
type Props = object;
|
||||
|
||||
function ReportIndex({ }: Props) {
|
||||
|
||||
const router = useRouter();
|
||||
const { mutate: createReport, isPending } = useCreateReport();
|
||||
const { t } = useTranslation('parallels', {
|
||||
keyPrefix: 'Report'
|
||||
})
|
||||
|
||||
const formik = useFormik<CreateReportType>({
|
||||
initialValues: {
|
||||
subject: '',
|
||||
content: '',
|
||||
scope: ContactScope.RESTAURANT,
|
||||
},
|
||||
validationSchema: Yup.object({
|
||||
subject: Yup.string().required('این فیلد اجباری است'),
|
||||
content: Yup.string().required('این فیلد اجباری است'),
|
||||
scope: Yup.string().oneOf([ContactScope.APPLICATION, ContactScope.RESTAURANT]).required('این فیلد اجباری است'),
|
||||
}),
|
||||
onSubmit: (values) => {
|
||||
createReport(values, {
|
||||
onSuccess: () => {
|
||||
toast('با موفقیت ثبت شد', 'success');
|
||||
formik.resetForm()
|
||||
router.back()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error');
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className='h-full bg-inherit relative flex flex-col lg:gap-4'>
|
||||
<div className='grid grid-cols-3 items-center'>
|
||||
<span></span>
|
||||
<h1 className='text-sm2 place-self-center font-medium'>{t("Heading")}</h1>
|
||||
<ArrowLeft
|
||||
className='cursor-pointer place-self-end'
|
||||
size='24'
|
||||
color='currentColor'
|
||||
onClick={() => { router.back() }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex lg:justify-center lg:items-center flex-1 bg-inherit">
|
||||
<div className="w-full lg:w-fit lg:h-fit lg:min-w-3/4 lg:min-h-3/4 flex flex-col-reverse lg:grid lg:gap-8 lg:bg-container lg:grid-cols-2 lg:p-8 lg:rounded-container lg:justify-around lg:items-center bg-inherit">
|
||||
<form className='flex-1 bg-inherit flex flex-col justify-between items-end' onSubmit={formik.handleSubmit}>
|
||||
<div className='bg-inherit pb-4 lg:max-w-4/5 w-full flex flex-col justify-between flex-1'>
|
||||
<div className="bg-inherit flex flex-col h-full">
|
||||
<div className='w-full px-4 mt-10'>
|
||||
<h2 className='text-sm2 font-medium'>{t('FormHeading')}</h2>
|
||||
<p className='text-sm2 text-disabled-text mt-2'>{t('Description')}</p>
|
||||
</div>
|
||||
<InputField
|
||||
className='w-full mt-6 px-4 bg-inherit'
|
||||
inputClassName='text-xs!'
|
||||
type='text'
|
||||
placeholder={t('InputTitle.Placeholder')}
|
||||
labelText={t('InputTitle.Label')}
|
||||
htmlFor='subject'
|
||||
name='subject'
|
||||
value={formik.values.subject}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
valid={!(formik.touched.subject && formik.errors.subject)}
|
||||
aria-errormessage={formik.touched.subject && formik.errors.subject ? formik.errors.subject : undefined}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
<div className='relative bg-inherit mt-6 flex-1'>
|
||||
<textarea
|
||||
className={`w-full text-xs! px-4 py-2.5 leading-6 outline-0 border rounded-normal h-full max-h-[200px] min-h-[50px] resize-none focus:ring-0 ${formik.touched.content && formik.errors.content
|
||||
? 'border-invalid'
|
||||
: 'border-border'
|
||||
}`}
|
||||
placeholder={t('InputDescription.Placeholder')}
|
||||
id='content'
|
||||
name='content'
|
||||
value={formik.values.content}
|
||||
onChange={formik.handleChange}
|
||||
onBlur={formik.handleBlur}
|
||||
></textarea>
|
||||
<span className='absolute -top-2 right-2 px-2 bg-inherit text-foreground text-xs'>
|
||||
{t('InputDescription.Label')}
|
||||
</span>
|
||||
{formik.touched.content && formik.errors.content && (
|
||||
<span className='absolute -bottom-5 right-2 px-2 text-xs text-invalid'>
|
||||
{formik.errors.content}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='w-full mt-4 px-4 bg-inherit'>
|
||||
<h3 className='text-sm2 font-medium leading-5'>{t('InputScope.Label')}</h3>
|
||||
<RadioGroup
|
||||
value={formik.values.scope}
|
||||
onValueChange={(value) => {
|
||||
formik.setFieldValue('scope', value as ContactScope);
|
||||
}}
|
||||
className='flex flex-col gap-3 mt-2'
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<RadioGroupItem value={ContactScope.RESTAURANT} id={`scope-${ContactScope.RESTAURANT}`} />
|
||||
<label className='flex items-center gap-2 cursor-pointer' htmlFor={`scope-${ContactScope.RESTAURANT}`}>
|
||||
<span className='text-xs mt-0.5'>{t('InputScope.Options.Restaurant')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className='flex items-center justify-between'>
|
||||
<RadioGroupItem value={ContactScope.APPLICATION} id={`scope-${ContactScope.APPLICATION}`} />
|
||||
<label className='flex items-center gap-2 cursor-pointer' htmlFor={`scope-${ContactScope.APPLICATION}`}>
|
||||
<span className='text-xs mt-0.5'>{t('InputScope.Options.Application')}</span>
|
||||
</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className='w-full px-4 mt-6'
|
||||
type='submit'
|
||||
disabled={isPending || !formik.isValid}
|
||||
>
|
||||
{t('ButtonSubmit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
|
||||
<div className='relative'>
|
||||
<Image
|
||||
className='place-self-center w-full px-4 mt-8 md:max-w-4/5'
|
||||
src={'/assets/images/report-hero.svg'}
|
||||
height={200}
|
||||
width={312}
|
||||
unoptimized
|
||||
alt='report hero image'
|
||||
/>
|
||||
<hr className='absolute bottom-0 left-9 right-4 border-border' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ReportIndex
|
||||
@@ -0,0 +1,7 @@
|
||||
import { api } from "@/config/axios";
|
||||
import { CreateReportType } from "../types/Types";
|
||||
|
||||
export const createReport = async (params: CreateReportType) => {
|
||||
const { data } = await api.post("/public/contact", params);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ContactScope } from '../enum/Enum';
|
||||
|
||||
export type CreateReportType = {
|
||||
subject: string;
|
||||
content: string;
|
||||
scope: ContactScope;
|
||||
};
|
||||
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import { ef } from '@/lib/helpers/utfNumbers';
|
||||
import { CardTick, CloseCircle } from 'iconsax-react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useVerify } from '../hooks/useVerifyData';
|
||||
import { toast } from '@/components/Toast';
|
||||
import { VerifyData } from '../types/Types';
|
||||
|
||||
type Props = object
|
||||
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('fa-IR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString('fa-IR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const formatPrice = (value: number): string => {
|
||||
return value.toLocaleString('fa-IR');
|
||||
};
|
||||
|
||||
export default function VerifyIndex({ }: Props) {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const name = params.name as string;
|
||||
|
||||
const urlparams = new URLSearchParams(window.location.search);
|
||||
const authority = urlparams.get('Authority');
|
||||
const id = params.id
|
||||
|
||||
const [verifyData, setVerifyData] = useState<VerifyData | null>(null);
|
||||
const [isError, setIsError] = useState(false);
|
||||
|
||||
const { mutate: verify } = useVerify();
|
||||
|
||||
useEffect(() => {
|
||||
if (authority && id) {
|
||||
verify({ authority: authority as string, orderId: id as string }, {
|
||||
onSuccess: (data) => {
|
||||
toast('پرداخت موفقیت آمیز بود', 'success');
|
||||
setVerifyData(data.data);
|
||||
setIsError(false);
|
||||
},
|
||||
onError: () => {
|
||||
toast('پرداخت ناموفق بوده', 'error');
|
||||
setIsError(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [authority, id]);
|
||||
|
||||
const amount = verifyData?.amount || verifyData?.order?.total || 0;
|
||||
const paidAt = verifyData?.paidAt || '';
|
||||
const cardPan = verifyData?.verifyResponse?.data?.card_pan || '';
|
||||
const gateway = verifyData?.order?.paymentMethod?.description || verifyData?.gateway || '';
|
||||
const referenceId = verifyData?.referenceId || '';
|
||||
|
||||
const formattedCardPan = cardPan || '';
|
||||
|
||||
return (
|
||||
<div className='h-full bg-inherit relative flex flex-col lg:gap-4'>
|
||||
<div className='flex-1 flex flex-col items-center justify-center'>
|
||||
<div className='bg-container py-5 px-2 rounded-container shadow-lg w-full'>
|
||||
<div className={`items-center gap-2 rounded-2xl h-35 flex flex-col justify-center ${isError ? 'bg-red-50' : 'bg-gray-50'}`}>
|
||||
{isError ? (
|
||||
<CloseCircle className='size-10 stroke-red-500' variant='TwoTone' />
|
||||
) : (
|
||||
<CardTick className='size-10 stroke-primary' variant='TwoTone' />
|
||||
)}
|
||||
<h5 className={`font-bold text-sm ${isError ? 'text-red-600' : ''}`}>
|
||||
{isError ? 'پرداخت ناموفق' : 'پرداخت موفق'}
|
||||
</h5>
|
||||
{!isError && verifyData && (
|
||||
<p className='text-sm mt-1 leading-5 text-disabled-text'>
|
||||
{ef(`مبلغ: ${formatPrice(amount)} تومان`)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isError && (
|
||||
<div className='mt-5 w-full text-sm2'>
|
||||
{paidAt && (
|
||||
<>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='font-medium text-disabled-text'>تاریخ:</div>
|
||||
<div className='font-medium'>{ef(formatDate(paidAt))}</div>
|
||||
</div>
|
||||
<hr className='border border-border my-3' />
|
||||
</>
|
||||
)}
|
||||
|
||||
{paidAt && (
|
||||
<>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='text-disabled-text'>ساعت:</div>
|
||||
<div className=''>{ef(formatTime(paidAt))}</div>
|
||||
</div>
|
||||
<hr className='border border-border my-3' />
|
||||
</>
|
||||
)}
|
||||
|
||||
{cardPan && (
|
||||
<>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='text-disabled-text'>از کارت:</div>
|
||||
<div className=''>{ef(formattedCardPan)}</div>
|
||||
</div>
|
||||
<hr className='border border-border my-3' />
|
||||
</>
|
||||
)}
|
||||
|
||||
{gateway && (
|
||||
<>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='text-disabled-text'>درگاه پرداخت:</div>
|
||||
<div className=''>{gateway}</div>
|
||||
</div>
|
||||
<hr className='border border-border my-3' />
|
||||
</>
|
||||
)}
|
||||
|
||||
{referenceId && (
|
||||
<>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='text-disabled-text'>شماره پیگیری:</div>
|
||||
<div className=''>{ef(referenceId)}</div>
|
||||
</div>
|
||||
<hr className='border border-border my-3' />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
className='mt-12'
|
||||
onClick={() => router.push(`/${name}`)}
|
||||
>
|
||||
بازگشت
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import * as api from "../service/VerifyService";
|
||||
|
||||
export const useVerify = () => {
|
||||
return useMutation({
|
||||
mutationFn: api.verify,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
import { api } from "@/config/axios";
|
||||
import { VerifyType, VerifyResponse } from "../types/Types";
|
||||
|
||||
export const verify = async (params: VerifyType): Promise<VerifyResponse> => {
|
||||
const { data } = await api.post<VerifyResponse>(
|
||||
"/public/payments/verify",
|
||||
params
|
||||
);
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { BaseResponse } from "@/app/[name]/(Main)/types/Types";
|
||||
|
||||
export interface VerifyType {
|
||||
authority: string;
|
||||
orderId: string;
|
||||
}
|
||||
|
||||
export interface VerifyOrderHistory {
|
||||
status: string;
|
||||
changedAt: string;
|
||||
}
|
||||
|
||||
export interface VerifyPaymentMethod {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
restaurant: string;
|
||||
method: string;
|
||||
gateway: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
order: number;
|
||||
merchantId: string;
|
||||
}
|
||||
|
||||
export interface VerifyOrder {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
user: string;
|
||||
restaurant: string;
|
||||
deliveryMethod: string;
|
||||
userAddress: string | null;
|
||||
carAddress: string | null;
|
||||
paymentMethod: VerifyPaymentMethod;
|
||||
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;
|
||||
status: string;
|
||||
history: VerifyOrderHistory[];
|
||||
}
|
||||
|
||||
export interface VerifyResponseData {
|
||||
data: {
|
||||
fee: number;
|
||||
code: number;
|
||||
wages: unknown | null;
|
||||
ref_id: number;
|
||||
message: string;
|
||||
card_pan: string;
|
||||
fee_type: string;
|
||||
order_id: string;
|
||||
card_hash: string;
|
||||
shaparak_fee: number;
|
||||
};
|
||||
errors: unknown[];
|
||||
}
|
||||
|
||||
export interface VerifyData {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
order: VerifyOrder;
|
||||
amount: number;
|
||||
referenceId: string;
|
||||
method: string;
|
||||
gateway: string;
|
||||
transactionId: string;
|
||||
status: string;
|
||||
cardPan: string | null;
|
||||
verifyResponse: VerifyResponseData;
|
||||
paidAt: string;
|
||||
failedAt: string | null;
|
||||
description: string | null;
|
||||
}
|
||||
|
||||
export type VerifyResponse = BaseResponse<VerifyData>;
|
||||
Vendored
BIN
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,
|
||||
});
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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[]>;
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
};
|
||||
@@ -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[]>;
|
||||
@@ -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>;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { getFavorites } from "../service/Service";
|
||||
|
||||
export const useGetFavorites = () => {
|
||||
return useQuery({
|
||||
queryKey: ["favorites"],
|
||||
queryFn: getFavorites,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { ArrowLeft } from 'iconsax-react'
|
||||
import Image from 'next/image'
|
||||
import { useGetFavorites } from './hooks/useFavoriteData'
|
||||
import MenuItem from '@/components/listview/MenuItem'
|
||||
import MenuItemRenderer from '@/components/listview/MenuItemRenderer'
|
||||
import VerticalScrollView from '@/components/listview/VerticalScrollView'
|
||||
import type { Favorite } from './types/Types'
|
||||
import type { Food } from '@/app/[name]/(Main)/types/Types'
|
||||
|
||||
function FavoritePage() {
|
||||
const router = useRouter()
|
||||
const { data: favoritesData, isLoading } = useGetFavorites()
|
||||
|
||||
const favorites = useMemo(() => {
|
||||
return favoritesData?.data || []
|
||||
}, [favoritesData?.data])
|
||||
|
||||
const foodItems = useMemo(() => {
|
||||
return favorites.map((favorite: Favorite) => {
|
||||
const favoriteFood = favorite.food
|
||||
// تبدیل FavoriteFood به Food برای استفاده در MenuItem
|
||||
const food: Food = {
|
||||
id: favoriteFood.id,
|
||||
createdAt: favoriteFood.createdAt,
|
||||
updatedAt: favoriteFood.updatedAt,
|
||||
deletedAt: favoriteFood.deletedAt,
|
||||
restaurant: favoriteFood.restaurant as unknown as Food['restaurant'],
|
||||
category: favoriteFood.category as unknown as Food['category'],
|
||||
inventory: {} as Food['inventory'],
|
||||
title: favoriteFood.title,
|
||||
desc: favoriteFood.desc,
|
||||
content: favoriteFood.content,
|
||||
price: favoriteFood.price,
|
||||
order: favoriteFood.order,
|
||||
prepareTime: favoriteFood.prepareTime,
|
||||
weekDays: favoriteFood.weekDays,
|
||||
mealTypes: favoriteFood.mealTypes,
|
||||
isActive: favoriteFood.isActive,
|
||||
images: favoriteFood.images,
|
||||
inPlaceServe: favoriteFood.inPlaceServe,
|
||||
pickupServe: favoriteFood.pickupServe,
|
||||
score: favoriteFood.score,
|
||||
discount: favoriteFood.discount,
|
||||
isSpecialOffer: favoriteFood.isSpecialOffer,
|
||||
reviews: [],
|
||||
favorites: [],
|
||||
isFavorite: true,
|
||||
name: favoriteFood.title,
|
||||
description: favoriteFood.desc,
|
||||
}
|
||||
return food
|
||||
})
|
||||
}, [favorites])
|
||||
|
||||
return (
|
||||
<div className='overflow-y-auto h-full noscrollbar flex flex-col'>
|
||||
<div className='grid grid-cols-3 items-center'>
|
||||
<span></span>
|
||||
<h1 className='text-sm2 place-self-center font-medium'>علاقهمندیها</h1>
|
||||
<ArrowLeft
|
||||
className='cursor-pointer place-self-end'
|
||||
size='24'
|
||||
color='currentColor'
|
||||
onClick={() => { router.back() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex-1 h-full">
|
||||
{isLoading ? (
|
||||
<div className='flex flex-col items-center justify-center gap-2 py-12 text-sm2 text-muted-foreground'>
|
||||
<span className='h-4 w-4 animate-spin rounded-full border border-dashed border-foreground border-t-transparent'></span>
|
||||
<p>در حال دریافت علاقهمندیها...</p>
|
||||
</div>
|
||||
) : foodItems.length === 0 ? (
|
||||
<div className='flex flex-col items-center justify-center gap-4 h-full'>
|
||||
<Image
|
||||
src='/assets/images/favorite.png'
|
||||
alt='favorite'
|
||||
width={120}
|
||||
height={120}
|
||||
className='object-contain'
|
||||
/>
|
||||
<div className='flex flex-col items-center gap-2 text-sm2 text-muted-foreground'>
|
||||
<p>شما هیچ مورد علاقهمندی ثبت نکردهاید</p>
|
||||
<p className='text-xs'>
|
||||
برای افزودن غذا به علاقهمندیها، به صفحه جزئیات غذا بروید
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<VerticalScrollView className="overflow-y-auto h-full">
|
||||
{foodItems.map((food) => (
|
||||
<MenuItemRenderer key={food.id}>
|
||||
<MenuItem food={food} />
|
||||
</MenuItemRenderer>
|
||||
))}
|
||||
</VerticalScrollView>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FavoritePage
|
||||
@@ -0,0 +1,7 @@
|
||||
import { api } from "@/config/axios";
|
||||
import { FavoritesResponse } from "../types/Types";
|
||||
|
||||
export const getFavorites = async (): Promise<FavoritesResponse> => {
|
||||
const { data } = await api.get<FavoritesResponse>("/public/foods/favorite");
|
||||
return data;
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
import { BaseResponse } from "@/app/[name]/(Main)/types/Types";
|
||||
|
||||
export type MealType = "breakfast" | "lunch" | "dinner" | "snack";
|
||||
|
||||
export interface FavoriteFood {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
restaurant: string;
|
||||
category: string;
|
||||
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;
|
||||
}
|
||||
|
||||
export interface Favorite {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
deletedAt: string | null;
|
||||
user: string;
|
||||
food: FavoriteFood;
|
||||
}
|
||||
|
||||
export type FavoritesResponse = BaseResponse<Favorite[]>;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
'use client';
|
||||
|
||||
import ClientSideWrapper from '@/components/wrapper/ClientSideWrapper';
|
||||
import React from 'react'
|
||||
|
||||
function DialogsLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ClientSideWrapper>
|
||||
<main className='h-full w-full bg-background pt-4 pb-6 px-6'>
|
||||
{children}
|
||||
</main>
|
||||
</ClientSideWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
export default DialogsLayout
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createAddress,
|
||||
getAddresses,
|
||||
updateAddress,
|
||||
deleteAddress,
|
||||
setDefaultAddress,
|
||||
getAddressById,
|
||||
} from "../service/AddressService";
|
||||
|
||||
export const useGetAddresses = () => {
|
||||
return useQuery({
|
||||
queryKey: ["addresses"],
|
||||
queryFn: () => getAddresses(),
|
||||
});
|
||||
};
|
||||
|
||||
export const useCreateAddress = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: createAddress,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useUpdateAddress = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: updateAddress,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useDeleteAddress = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: deleteAddress,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useSetDefaultAddress = () => {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: setDefaultAddress,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["addresses"] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetAddressById = (id: string | null) => {
|
||||
return useQuery({
|
||||
queryKey: ["address", id],
|
||||
queryFn: () => getAddressById(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,93 @@
|
||||
import AnimatedBottomSheet from '@/components/bottomsheet/AnimatedBottomSheet';
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
import { AddressDisplay } from './AddressDisplay';
|
||||
import { AddressForm } from './AddressForm';
|
||||
import { NominatimReverseGeocodingResponse } from '../../types/Types';
|
||||
import { Location } from 'iconsax-react';
|
||||
|
||||
interface AddressDetailsModalProps {
|
||||
visible: boolean;
|
||||
selectedAddress: NominatimReverseGeocodingResponse | null;
|
||||
formData: {
|
||||
title: string;
|
||||
addressDetails: string;
|
||||
phone: string;
|
||||
postalCode: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
isPending: boolean;
|
||||
editMode?: boolean;
|
||||
onClose: () => void;
|
||||
onChangePosition?: () => void;
|
||||
onFormDataChange: (data: Partial<AddressDetailsModalProps['formData']>) => void;
|
||||
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||
}
|
||||
|
||||
export const AddressDetailsModal = ({
|
||||
visible,
|
||||
selectedAddress,
|
||||
formData,
|
||||
isPending,
|
||||
editMode = false,
|
||||
onClose,
|
||||
onChangePosition,
|
||||
onFormDataChange,
|
||||
onSubmit,
|
||||
}: AddressDetailsModalProps) => {
|
||||
return (
|
||||
<AnimatedBottomSheet
|
||||
bgOpacity={0}
|
||||
inDuration={0}
|
||||
blurOpacity={null}
|
||||
noBlur
|
||||
visible={visible}
|
||||
title='آدرس'
|
||||
onClick={onClose}
|
||||
overrideClassName='bg-container!'
|
||||
>
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className='px-4'>
|
||||
<AddressDisplay selectedAddress={selectedAddress} />
|
||||
{editMode && onChangePosition && (
|
||||
<div className='mt-4 mb-6 flex justify-end'>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onChangePosition();
|
||||
}}
|
||||
className='bg-background! w-fit! text-foreground! flex items-center gap-2 text-xs'
|
||||
disabled={isPending}
|
||||
>
|
||||
<Location color='#6b7280' variant='Bold' className='size-4' />
|
||||
تغییر موقعیت روی نقشه
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<AddressForm
|
||||
formData={formData}
|
||||
selectedAddress={selectedAddress}
|
||||
onFormDataChange={onFormDataChange}
|
||||
/>
|
||||
<div className='grid grid-cols-2 gap-4 mt-16'>
|
||||
<Button
|
||||
type='submit'
|
||||
disabled={isPending}
|
||||
>
|
||||
{isPending ? (editMode ? 'در حال بهروزرسانی...' : 'در حال ثبت...') : (editMode ? 'بهروزرسانی' : 'تایید')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={onClose}
|
||||
className='bg-disabled! text-foreground!'
|
||||
disabled={isPending}
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</AnimatedBottomSheet>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { NominatimReverseGeocodingResponse } from '../../types/Types';
|
||||
import { formatSelectedAddress } from '../utils/formatAddress';
|
||||
|
||||
interface AddressDisplayProps {
|
||||
selectedAddress: NominatimReverseGeocodingResponse | null;
|
||||
}
|
||||
|
||||
export const AddressDisplay = ({ selectedAddress }: AddressDisplayProps) => {
|
||||
return (
|
||||
<div className='px-4 mt-2 bg-container'>
|
||||
<span className='text-sm'>
|
||||
{!selectedAddress ? (
|
||||
<Skeleton className='h-6 w-full' />
|
||||
) : (
|
||||
formatSelectedAddress(selectedAddress)
|
||||
)}
|
||||
</span>
|
||||
<hr className='border border-border mt-3 mb-10' />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import InputField from '@/components/input/InputField';
|
||||
import { NominatimReverseGeocodingResponse } from '../../types/Types';
|
||||
import { useGetProfile } from '../../../hooks/userProfileData';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface AddressFormProps {
|
||||
formData: {
|
||||
title: string;
|
||||
addressDetails: string;
|
||||
phone: string;
|
||||
postalCode: string;
|
||||
isDefault: boolean;
|
||||
};
|
||||
selectedAddress: NominatimReverseGeocodingResponse | null;
|
||||
onFormDataChange: (data: Partial<AddressFormProps['formData']>) => void;
|
||||
}
|
||||
|
||||
export const AddressForm = ({
|
||||
formData,
|
||||
selectedAddress,
|
||||
onFormDataChange,
|
||||
}: AddressFormProps) => {
|
||||
|
||||
const { data: profileData } = useGetProfile()
|
||||
|
||||
useEffect(() => {
|
||||
if (profileData?.data?.phone && !formData.phone) {
|
||||
onFormDataChange({ phone: '0' + profileData.data.phone });
|
||||
}
|
||||
}, [profileData?.data?.phone, formData.phone, onFormDataChange]);
|
||||
|
||||
return (
|
||||
<div className='px-4'>
|
||||
<InputField
|
||||
htmlFor={'addressTitle'}
|
||||
labelText={'عنوان آدرس'}
|
||||
placeholder='مثال: منزل، محل کار ...'
|
||||
className='bg-inherit'
|
||||
inputClassName='text-xs!'
|
||||
value={formData.title}
|
||||
onChange={(e) => onFormDataChange({ title: e.target.value })}
|
||||
|
||||
/>
|
||||
<InputField
|
||||
htmlFor={'addressDetails'}
|
||||
labelText={'جزئیات آدرس'}
|
||||
placeholder='میدان باغ ملی، مجتمع آسمان، واحد 12، پلاک 1234567890'
|
||||
className='bg-inherit mt-8'
|
||||
value={formData.addressDetails}
|
||||
onChange={(e) => onFormDataChange({ addressDetails: e.target.value })}
|
||||
inputClassName='text-xs!'
|
||||
/>
|
||||
<InputField
|
||||
htmlFor={'phone'}
|
||||
labelText={'شماره تلفن'}
|
||||
placeholder='09123456789'
|
||||
className='bg-inherit mt-8'
|
||||
value={formData.phone}
|
||||
onChange={(e) => onFormDataChange({ phone: e.target.value })}
|
||||
inputClassName='text-xs!'
|
||||
/>
|
||||
<InputField
|
||||
htmlFor={'postalCode'}
|
||||
labelText={'کد پستی'}
|
||||
placeholder={selectedAddress?.address?.postcode || '1234567890'}
|
||||
className='bg-inherit mt-8'
|
||||
value={formData.postalCode || selectedAddress?.address?.postcode || ''}
|
||||
onChange={(e) => onFormDataChange({ postalCode: e.target.value })}
|
||||
inputClassName='text-xs!'
|
||||
/>
|
||||
<div className='inline-flex justify-between items-center w-full mt-8'>
|
||||
<label
|
||||
htmlFor={'isDefault'}
|
||||
className='text-sm2 px-2 py-0.5 mt-0.5 cursor-pointer'
|
||||
>
|
||||
تنظیم به عنوان آدرس پیشفرض
|
||||
</label>
|
||||
<input
|
||||
name={'isDefault'}
|
||||
id={'isDefault'}
|
||||
className='h-4.5 w-4.5 checked:accent-primary cursor-pointer'
|
||||
onChange={(e) => onFormDataChange({ isDefault: e.target.checked })}
|
||||
type='checkbox'
|
||||
checked={formData.isDefault}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import AnimatedBottomSheet from '@/components/bottomsheet/AnimatedBottomSheet';
|
||||
import Button from '@/components/button/PrimaryButton';
|
||||
|
||||
interface ConfirmPositionModalProps {
|
||||
visible: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export const ConfirmPositionModal = ({
|
||||
visible,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmPositionModalProps) => {
|
||||
return (
|
||||
<AnimatedBottomSheet
|
||||
bgOpacity={0}
|
||||
inDuration={0}
|
||||
blurOpacity={null}
|
||||
noBlur
|
||||
visible={visible}
|
||||
showCloseButton={false}
|
||||
showTitle={false}
|
||||
overrideClassName='!pt-6 bg-container!'
|
||||
>
|
||||
<div className='grid grid-cols-2 gap-4 px-4'>
|
||||
<Button onClick={onConfirm}>
|
||||
انتخاب
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
className='bg-disabled! text-foreground!'
|
||||
>
|
||||
انصراف
|
||||
</Button>
|
||||
</div>
|
||||
</AnimatedBottomSheet>
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user