base create order

This commit is contained in:
hamid zarghami
2026-06-23 10:12:56 +03:30
parent 6975e2d0b0
commit 223b1b5e8f
15 changed files with 1205 additions and 2 deletions
@@ -0,0 +1,159 @@
import { type FC, useState } from 'react'
import { SearchNormal, TickCircle, UserAdd } from 'iconsax-react'
import type { FormikProps } from 'formik'
import { toast } from 'react-toastify'
import Button from '@/components/Button'
import Input from '@/components/Input'
import { useSearchUserByPhone } from '@/pages/customers/hooks/useUsersData'
import type { CreateOrderFormValues } from '../hooks/useCreateOrderForm'
import type { ErrorType } from '@/helpers/types'
import { extractErrorMessage } from '@/config/func'
import { isAxiosError } from 'axios'
interface CreateOrderCustomerSectionProps {
formik: FormikProps<CreateOrderFormValues>
}
type CustomerSearchState = 'idle' | 'found' | 'not_found'
const CreateOrderCustomerSection: FC<CreateOrderCustomerSectionProps> = ({ formik }) => {
const { mutate: searchUser, isPending: isSearching } = useSearchUserByPhone()
const [searchState, setSearchState] = useState<CustomerSearchState>('idle')
const handleSearch = () => {
const phone = formik.values.userPhone.trim()
if (!phone) {
toast.error('شماره تلفن را وارد کنید')
return
}
searchUser(phone, {
onSuccess: (response) => {
const user = response?.data
if (!user) {
setSearchState('not_found')
formik.setFieldValue('firstName', '')
formik.setFieldValue('lastName', '')
toast.info('مشتری یافت نشد. اطلاعات را وارد کنید.')
return
}
formik.setFieldValue('userPhone', user.phone || phone)
formik.setFieldValue('firstName', user.firstName || '')
formik.setFieldValue('lastName', user.lastName || '')
setSearchState('found')
toast.success('اطلاعات مشتری بارگذاری شد')
},
onError: (error: ErrorType) => {
if (isAxiosError(error) && error.response?.status === 404) {
setSearchState('not_found')
formik.setFieldValue('firstName', '')
formik.setFieldValue('lastName', '')
toast.info('مشتری یافت نشد. اطلاعات را وارد کنید.')
return
}
setSearchState('idle')
toast.error(extractErrorMessage(error, 'خطا در جستجوی مشتری'))
},
})
}
const handlePhoneChange = (e: React.ChangeEvent<HTMLInputElement>) => {
formik.handleChange(e)
if (searchState !== 'idle') {
setSearchState('idle')
}
}
const isCustomerLocked = searchState !== 'not_found'
return (
<div className='w-full bg-white rounded-4xl p-8'>
<div className='text-lg font-light'>اطلاعات مشتری</div>
<div className='flex flex-col sm:flex-row gap-3 mt-6 sm:items-end'>
<div className='flex-1'>
<Input
label='شماره تلفن'
name='userPhone'
placeholder='۰۹۱۲۱۲۳۴۵۶۷'
value={formik.values.userPhone}
onChange={handlePhoneChange}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
handleSearch()
}
}}
error_text={
formik.touched.userPhone && formik.errors.userPhone
? formik.errors.userPhone
: ''
}
/>
</div>
<Button
type='button'
className='w-full sm:w-fit px-6'
isloading={isSearching}
onClick={handleSearch}
>
<div className='flex gap-2 items-center'>
<SearchNormal size={18} color='#fff' />
<span>جستجو</span>
</div>
</Button>
</div>
{searchState === 'found' && (
<div className='mt-4 flex items-center gap-2 text-xs text-green-600 bg-green-50 rounded-xl px-4 py-3'>
<TickCircle size={16} color='#16A34A' />
<span>مشتری یافت شد و اطلاعات تکمیل شد</span>
</div>
)}
{searchState === 'not_found' && (
<div className='mt-4 flex items-center gap-2 text-xs text-description bg-[#F8F9FC] rounded-xl px-4 py-3'>
<UserAdd size={16} color='#8C90A3' />
<span>مشتری یافت نشد. نام و نام خانوادگی را وارد کنید.</span>
</div>
)}
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6'>
<Input
label='نام'
name='firstName'
value={formik.values.firstName}
onChange={formik.handleChange}
readOnly={isCustomerLocked}
error_text={
formik.touched.firstName && formik.errors.firstName
? formik.errors.firstName
: ''
}
/>
<Input
label='نام خانوادگی'
name='lastName'
value={formik.values.lastName}
onChange={formik.handleChange}
readOnly={isCustomerLocked}
error_text={
formik.touched.lastName && formik.errors.lastName
? formik.errors.lastName
: ''
}
/>
</div>
{searchState === 'idle' && (
<div className='mt-4 text-xs text-description'>
ابتدا با شماره تلفن مشتری را جستجو کنید.
</div>
)}
</div>
)
}
export default CreateOrderCustomerSection
@@ -0,0 +1,223 @@
import { type FC } from 'react'
import type { FormikProps } from 'formik'
import { useTranslation } from 'react-i18next'
import Input from '@/components/Input'
import Textarea from '@/components/Textarea'
import { formatPrice } from '@/helpers/func'
import type { CreateOrderFormValues } from '../hooks/useCreateOrderForm'
import type { RestaurantPaymentMethod, RestaurantShipmentMethod } from '../hooks/useCreateOrderForm'
interface CreateOrderDeliverySectionProps {
formik: FormikProps<CreateOrderFormValues>
deliveryMethods: RestaurantShipmentMethod[]
cashPaymentMethod?: RestaurantPaymentMethod
isCourierDelivery: boolean
isCarDelivery: boolean
isDineIn: boolean
}
const CreateOrderDeliverySection: FC<CreateOrderDeliverySectionProps> = ({
formik,
deliveryMethods,
cashPaymentMethod,
isCourierDelivery,
isCarDelivery,
isDineIn,
}) => {
const { t } = useTranslation('global')
const getDeliveryLabel = (method: string) => {
return t(`shipment.${method}`, {
defaultValue: method,
})
}
return (
<>
<div className='w-full bg-white rounded-4xl p-8'>
<div className='text-lg font-light'>تحویل و پرداخت</div>
<div className='mt-6'>
<div className='text-sm mb-3'>روش تحویل</div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-3'>
{deliveryMethods.map((method) => {
const isSelected = formik.values.deliveryMethodId === method.id
return (
<button
key={method.id}
type='button'
onClick={() => formik.setFieldValue('deliveryMethodId', method.id)}
className={`text-right p-4 rounded-xl border transition-colors ${
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'
}`}
>
<div className='text-sm font-medium'>
{getDeliveryLabel(method.method)}
</div>
{method.description && (
<div className='text-xs text-description mt-1 line-clamp-2'>
{method.description}
</div>
)}
{method.deliveryFee > 0 && (
<div className='text-xs mt-2 text-primary'>
{formatPrice(method.deliveryFee)}
</div>
)}
</button>
)
})}
</div>
{formik.touched.deliveryMethodId && formik.errors.deliveryMethodId && (
<div className='text-xs text-red-500 mt-2'>
{formik.errors.deliveryMethodId}
</div>
)}
</div>
<div className='mt-8'>
<div className='text-sm mb-3'>روش پرداخت</div>
<div className='p-4 rounded-xl border border-primary bg-primary/5'>
<div className='text-sm font-medium'>پرداخت نقدی</div>
{cashPaymentMethod?.description && (
<div className='text-xs text-description mt-1'>
{cashPaymentMethod.description}
</div>
)}
</div>
{!cashPaymentMethod && (
<div className='text-xs text-red-500 mt-2'>
روش پرداخت نقدی فعال یافت نشد
</div>
)}
</div>
{(isDineIn || isCarDelivery) && (
<div className='mt-8'>
<Input
label='شماره میز'
name='tableNumber'
placeholder='مثال: ۱۲'
value={formik.values.tableNumber}
onChange={formik.handleChange}
/>
</div>
)}
</div>
{isCourierDelivery && (
<div className='w-full bg-white rounded-4xl p-8 mt-8'>
<div className='text-lg font-light'>آدرس تحویل</div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6'>
<Input
label='نام گیرنده'
name='userAddress.fullName'
value={formik.values.userAddress.fullName}
onChange={formik.handleChange}
/>
<Input
label='شماره تماس گیرنده'
name='userAddress.phone'
value={formik.values.userAddress.phone}
onChange={formik.handleChange}
/>
<Input
label='استان'
name='userAddress.province'
value={formik.values.userAddress.province}
onChange={formik.handleChange}
/>
<Input
label='شهر'
name='userAddress.city'
value={formik.values.userAddress.city}
onChange={formik.handleChange}
/>
<Input
label='کد پستی'
name='userAddress.postalCode'
value={formik.values.userAddress.postalCode}
onChange={formik.handleChange}
/>
<Input
label='عرض جغرافیایی'
type='number'
name='userAddress.latitude'
value={formik.values.userAddress.latitude || ''}
onChange={formik.handleChange}
isNotRequired
/>
<Input
label='طول جغرافیایی'
type='number'
name='userAddress.longitude'
value={formik.values.userAddress.longitude || ''}
onChange={formik.handleChange}
isNotRequired
/>
</div>
<div className='mt-5'>
<Textarea
label='آدرس کامل'
name='userAddress.address'
value={formik.values.userAddress.address}
onChange={formik.handleChange}
/>
</div>
</div>
)}
{isCarDelivery && (
<div className='w-full bg-white rounded-4xl p-8 mt-8'>
<div className='text-lg font-light'>اطلاعات خودرو</div>
<div className='grid grid-cols-1 sm:grid-cols-2 gap-4 mt-6'>
<Input
label='مدل خودرو'
name='carAddress.carModel'
value={formik.values.carAddress.carModel}
onChange={formik.handleChange}
/>
<Input
label='رنگ خودرو'
name='carAddress.carColor'
value={formik.values.carAddress.carColor}
onChange={formik.handleChange}
/>
<Input
label='پلاک'
name='carAddress.plateNumber'
value={formik.values.carAddress.plateNumber}
onChange={formik.handleChange}
/>
<Input
label='شماره تماس'
name='carAddress.phone'
value={formik.values.carAddress.phone}
onChange={formik.handleChange}
/>
</div>
</div>
)}
<div className='w-full bg-white rounded-4xl p-8 mt-8'>
<div className='text-lg font-light'>توضیحات سفارش</div>
<div className='mt-6'>
<Textarea
label='توضیحات'
name='description'
placeholder='توضیحات اضافی برای سفارش...'
value={formik.values.description}
onChange={formik.handleChange}
/>
</div>
</div>
</>
)
}
export default CreateOrderDeliverySection
@@ -0,0 +1,191 @@
import { type FC, useMemo, useState } from 'react'
import type { FormikProps } from 'formik'
import { Add, Minus, Trash } from 'iconsax-react'
import Input from '@/components/Input'
import type { Food } from '@/pages/food/types/Types'
import { formatFaNumber, formatPrice } from '@/helpers/func'
import { getFoodUnitPrice } from '../hooks/useCreateOrderForm'
import type { CreateOrderFormValues } from '../hooks/useCreateOrderForm'
interface CreateOrderItemsSectionProps {
formik: FormikProps<CreateOrderFormValues>
foods: Food[]
onAddFood: (foodId: string) => void
onUpdateQuantity: (foodId: string, quantity: number) => void
onRemoveItem: (foodId: string) => void
}
const CreateOrderItemsSection: FC<CreateOrderItemsSectionProps> = ({
formik,
foods,
onAddFood,
onUpdateQuantity,
onRemoveItem,
}) => {
const [search, setSearch] = useState('')
const foodsMap = useMemo(
() => new Map(foods.map((food) => [food.id, food])),
[foods]
)
const filteredFoods = useMemo(() => {
const query = search.trim().toLowerCase()
if (!query) return foods
return foods.filter((food) => food.title.toLowerCase().includes(query))
}, [foods, search])
const selectedItems = formik.values.items
.map((item) => {
const food = foodsMap.get(item.foodId)
if (!food) return null
return { ...item, food }
})
.filter(Boolean)
return (
<div className='w-full bg-white rounded-4xl p-8'>
<div className='text-lg font-light'>آیتمهای سفارش</div>
<div className='mt-6 border border-border rounded-xl p-4'>
<Input
variant='search'
className='bg-[#EEF0F7]'
placeholder='جستجوی غذا...'
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
<div className='mt-3 space-y-2 max-h-56 overflow-y-auto'>
{filteredFoods.length === 0 ? (
<div className='text-center py-6 text-xs text-description'>
غذایی یافت نشد
</div>
) : (
filteredFoods.map((food) => {
const inCart = formik.values.items.some(
(item) => item.foodId === food.id
)
return (
<div
key={food.id}
className='flex items-center gap-3 p-2 rounded-xl hover:bg-[#F8F9FC] transition-colors'
>
{food.images?.[0] ? (
<img
src={food.images[0]}
alt={food.title}
className='w-11 h-11 rounded-lg object-cover flex-shrink-0'
/>
) : (
<div className='w-11 h-11 rounded-lg bg-gray-100 flex-shrink-0' />
)}
<div className='flex-1 min-w-0'>
<div className='text-sm truncate'>{food.title}</div>
<div className='text-xs text-description mt-0.5'>
{formatPrice(getFoodUnitPrice(food))}
</div>
</div>
<button
type='button'
onClick={() => onAddFood(food.id)}
className={`flex items-center gap-1 px-3 h-8 rounded-lg text-xs transition-colors ${
inCart
? 'bg-primary/10 text-primary'
: 'bg-primary text-white'
}`}
>
<Add size={14} color={inCart ? '#000' : '#fff'} />
{inCart ? 'افزودن' : 'انتخاب'}
</button>
</div>
)
})
)}
</div>
</div>
<div className='mt-6'>
<div className='text-sm mb-3'>سبد سفارش</div>
{selectedItems.length === 0 ? (
<div className='border border-dashed border-border rounded-xl py-10 text-center text-xs text-description'>
هنوز آیتمی انتخاب نشده است
</div>
) : (
<div className='space-y-3'>
{selectedItems.map((item) => {
if (!item) return null
const unitPrice = getFoodUnitPrice(item.food)
return (
<div
key={item.foodId}
className='flex items-center gap-3 p-3 border border-border rounded-xl'
>
{item.food.images?.[0] ? (
<img
src={item.food.images[0]}
alt={item.food.title}
className='w-12 h-12 rounded-lg object-cover flex-shrink-0'
/>
) : (
<div className='w-12 h-12 rounded-lg bg-gray-100 flex-shrink-0' />
)}
<div className='flex-1 min-w-0'>
<div className='text-sm truncate'>{item.food.title}</div>
<div className='text-xs text-description mt-1'>
{formatPrice(unitPrice)}
</div>
</div>
<div className='flex items-center gap-2'>
<button
type='button'
onClick={() =>
onUpdateQuantity(item.foodId, item.quantity - 1)
}
className='w-8 h-8 rounded-lg border border-border flex items-center justify-center'
>
<Minus size={14} color='#000' />
</button>
<span className='w-6 text-center text-sm'>
{formatFaNumber(item.quantity)}
</span>
<button
type='button'
onClick={() =>
onUpdateQuantity(item.foodId, item.quantity + 1)
}
className='w-8 h-8 rounded-lg border border-border flex items-center justify-center'
>
<Add size={14} color='#000' />
</button>
</div>
<div className='text-sm font-medium min-w-[90px] text-left'>
{formatPrice(unitPrice * item.quantity)}
</div>
<button
type='button'
onClick={() => onRemoveItem(item.foodId)}
className='text-red-500 p-1'
>
<Trash size={18} color='#EF4444' />
</button>
</div>
)
})}
</div>
)}
{formik.touched.items && formik.errors.items && typeof formik.errors.items === 'string' && (
<div className='text-xs text-red-500 mt-2'>{formik.errors.items}</div>
)}
</div>
</div>
)
}
export default CreateOrderItemsSection
@@ -0,0 +1,148 @@
import { type FC } from 'react'
import { ShoppingCart, User } from 'iconsax-react'
import type { FormikProps } from 'formik'
import { formatFaNumber, formatPrice } from '@/helpers/func'
import { PaymentMethodEnum } from '@/pages/paymentMethods/enum/Enum'
import { DeliveryMethodEnum } from '@/pages/shipmentMethod/enum/Enum'
import type { CreateOrderFormValues } from '../hooks/useCreateOrderForm'
import type { RestaurantPaymentMethod, RestaurantShipmentMethod } from '../hooks/useCreateOrderForm'
interface OrderSummaryData {
lineItems: Array<{
foodId: string
title: string
image: string
quantity: number
unitPrice: number
totalPrice: number
} | null>
subTotal: number
deliveryFee: number
total: number
totalItems: number
}
interface CreateOrderSidebarProps {
formik: FormikProps<CreateOrderFormValues>
orderSummary: OrderSummaryData
selectedDeliveryMethod?: RestaurantShipmentMethod
selectedPaymentMethod?: RestaurantPaymentMethod
}
const getDeliveryMethodText = (method?: string): string => {
const methodMap: Record<string, string> = {
[DeliveryMethodEnum.DineIn]: 'سرو در رستوران',
[DeliveryMethodEnum.CustomerPickup]: 'دریافت از محل',
[DeliveryMethodEnum.DeliveryCar]: 'تحویل به خودرو',
[DeliveryMethodEnum.DeliveryCourier]: 'بیرون‌بر',
}
return method ? methodMap[method] || method : '-'
}
const getPaymentMethodText = (method?: string): string => {
const methodMap: Record<string, string> = {
[PaymentMethodEnum.Cash]: 'پرداخت نقدی',
[PaymentMethodEnum.Online]: 'پرداخت آنلاین',
[PaymentMethodEnum.CreditCard]: 'کارت به کارت',
[PaymentMethodEnum.Wallet]: 'کیف پول',
}
return method ? methodMap[method] || method : '-'
}
const CreateOrderSidebar: FC<CreateOrderSidebarProps> = ({
formik,
orderSummary,
selectedDeliveryMethod,
selectedPaymentMethod,
}) => {
const customerName = [formik.values.firstName, formik.values.lastName]
.filter(Boolean)
.join(' ')
return (
<div className='space-y-6'>
<div className='bg-white rounded-4xl p-8'>
<div className='flex items-center gap-2 text-lg font-light'>
<User size={20} color='#000' />
خلاصه سفارش
</div>
<div className='mt-6 space-y-3 text-[13px] font-light'>
<div className='flex justify-between items-center border-b border-border border-dashed pb-3'>
<span className='text-description'>مشتری</span>
<span>{customerName || '-'}</span>
</div>
<div className='flex justify-between items-center border-b border-border border-dashed pb-3'>
<span className='text-description'>شماره تماس</span>
<span>{formik.values.userPhone || '-'}</span>
</div>
<div className='flex justify-between items-center border-b border-border border-dashed pb-3'>
<span className='text-description'>روش تحویل</span>
<span>{getDeliveryMethodText(selectedDeliveryMethod?.method)}</span>
</div>
<div className='flex justify-between items-center border-b border-border border-dashed pb-3'>
<span className='text-description'>روش پرداخت</span>
<span>{getPaymentMethodText(selectedPaymentMethod?.method)}</span>
</div>
</div>
</div>
<div className='bg-white rounded-4xl p-8'>
<div className='flex items-center gap-2 text-lg font-light'>
<ShoppingCart size={20} color='#000' />
آیتمها
{orderSummary.totalItems > 0 && (
<span className='text-xs text-description'>
({formatFaNumber(orderSummary.totalItems)})
</span>
)}
</div>
{orderSummary.lineItems.length === 0 ? (
<div className='mt-6 text-center text-xs text-description py-6'>
سبد خالی است
</div>
) : (
<div className='mt-6 space-y-3'>
{orderSummary.lineItems.map((item) => {
if (!item) return null
return (
<div
key={item.foodId}
className='flex justify-between items-start gap-3 text-[13px] border-b border-border border-dashed pb-3'
>
<div className='flex-1'>
<div>{item.title}</div>
<div className='text-description mt-1'>
{formatFaNumber(item.quantity)} × {formatPrice(item.unitPrice)}
</div>
</div>
<div>{formatPrice(item.totalPrice)}</div>
</div>
)
})}
</div>
)}
<div className='mt-6 space-y-3 text-[13px] font-light'>
<div className='flex justify-between items-center'>
<span className='text-description'>جمع آیتمها</span>
<span>{formatPrice(orderSummary.subTotal)}</span>
</div>
{orderSummary.deliveryFee > 0 && (
<div className='flex justify-between items-center'>
<span className='text-description'>هزینه ارسال</span>
<span>{formatPrice(orderSummary.deliveryFee)}</span>
</div>
)}
<div className='flex justify-between items-center font-bold text-base pt-3 border-t-2 border-gray-200'>
<span>جمع کل (تقریبی)</span>
<span>{formatPrice(orderSummary.total)}</span>
</div>
</div>
</div>
</div>
)
}
export default CreateOrderSidebar