Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd8677648e | |||
| 9f006b36ab | |||
| fff5cd9146 |
+3
-1
@@ -35,7 +35,7 @@ export const fa = {
|
||||
factureCount: "پیش فاکتور تایید نشده",
|
||||
orderCount: "سفارش های در حال انجام",
|
||||
orderDoneCount: "سفارشات انجام شده",
|
||||
services: "خدمات",
|
||||
products: "محصولات",
|
||||
submitNewOrder: "درخواست خودرا ثبت کنید",
|
||||
submitNewOrderButton: "درخواست جدید",
|
||||
specialSolution: "راهحل اختصاصی برای شما در نگاره!",
|
||||
@@ -47,6 +47,8 @@ export const fa = {
|
||||
freeConsultation: "دریافت مشاوره رایگان",
|
||||
contactUs: "با ما تماس بگیرید",
|
||||
phoneNumber: "۰۸۶۹۱۰۰۹۰۰۱",
|
||||
noProducts: "خدماتی برای نمایش وجود ندارد.",
|
||||
inProgressOrders: "سفارش های در حال انجام",
|
||||
},
|
||||
rial: "ریال",
|
||||
uploadBox: {
|
||||
|
||||
@@ -12,9 +12,11 @@ import {
|
||||
Play,
|
||||
} from 'iconsax-react'
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
@@ -47,12 +49,21 @@ type PendingVoice = {
|
||||
status: 'uploading' | 'done' | 'error'
|
||||
}
|
||||
|
||||
export type ChatComposerHandle = {
|
||||
getPayload: () => ChatComposerSubmitPayload
|
||||
hasPendingUploads: () => boolean
|
||||
hasUploadErrors: () => boolean
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onSubmit: (payload: ChatComposerSubmitPayload) => void | Promise<void>
|
||||
isSubmitting?: boolean
|
||||
submitLabel?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
allowEmptySubmit?: boolean
|
||||
showSubmitButton?: boolean
|
||||
replyTo?: {
|
||||
id: string
|
||||
content: string
|
||||
@@ -163,15 +174,17 @@ const VoicePreviewChip: FC<{
|
||||
)
|
||||
}
|
||||
|
||||
const ChatComposer: FC<Props> = ({
|
||||
const ChatComposer = forwardRef<ChatComposerHandle, Props>(({
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
submitLabel = 'ارسال پیام',
|
||||
label = 'پیام شما',
|
||||
placeholder = 'متن پیام خود را بنویسید...',
|
||||
allowEmptySubmit = false,
|
||||
showSubmitButton = true,
|
||||
replyTo = null,
|
||||
onCancelReply,
|
||||
}) => {
|
||||
}, ref) => {
|
||||
const inputId = useId()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [message, setMessage] = useState('')
|
||||
@@ -318,7 +331,7 @@ const ChatComposer: FC<Props> = ({
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const buildPayload = useCallback((): ChatComposerSubmitPayload => {
|
||||
const uploadedFiles = pendingFiles.filter(
|
||||
(item) => item.status === 'done' && item.key,
|
||||
)
|
||||
@@ -326,10 +339,49 @@ const ChatComposer: FC<Props> = ({
|
||||
(item) => item.status === 'done' && item.key,
|
||||
)
|
||||
|
||||
const attachments: ChatComposerAttachment[] = [
|
||||
...uploadedFiles.map((item) => ({
|
||||
type: 'uploads_attach',
|
||||
url: item.key!,
|
||||
})),
|
||||
...uploadedVoices.map((item) => ({
|
||||
type: 'voice',
|
||||
url: item.key!,
|
||||
})),
|
||||
]
|
||||
|
||||
return {
|
||||
content: message.trim(),
|
||||
attachments,
|
||||
}
|
||||
}, [message, pendingFiles, pendingVoices])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getPayload: buildPayload,
|
||||
hasPendingUploads: () => isUploading,
|
||||
hasUploadErrors: () =>
|
||||
pendingFiles.some((item) => item.status === 'error') ||
|
||||
pendingVoices.some((item) => item.status === 'error'),
|
||||
reset: resetComposer,
|
||||
}),
|
||||
[
|
||||
buildPayload,
|
||||
isUploading,
|
||||
pendingFiles,
|
||||
pendingVoices,
|
||||
resetComposer,
|
||||
],
|
||||
)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const payload = buildPayload()
|
||||
|
||||
if (
|
||||
!message.trim() &&
|
||||
uploadedFiles.length === 0 &&
|
||||
uploadedVoices.length === 0
|
||||
!allowEmptySubmit &&
|
||||
!payload.content &&
|
||||
payload.attachments.length === 0
|
||||
) {
|
||||
toast('لطفاً پیام یا فایل ضمیمه وارد کنید', 'error')
|
||||
return
|
||||
@@ -351,22 +403,8 @@ const ChatComposer: FC<Props> = ({
|
||||
return
|
||||
}
|
||||
|
||||
const attachments: ChatComposerAttachment[] = [
|
||||
...uploadedFiles.map((item) => ({
|
||||
type: 'uploads_attach',
|
||||
url: item.key!,
|
||||
})),
|
||||
...uploadedVoices.map((item) => ({
|
||||
type: 'voice',
|
||||
url: item.key!,
|
||||
})),
|
||||
]
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
content: message.trim(),
|
||||
attachments,
|
||||
})
|
||||
await onSubmit(payload)
|
||||
resetComposer()
|
||||
} catch (error) {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
@@ -526,16 +564,20 @@ const ChatComposer: FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
label={submitLabel}
|
||||
onClick={handleSubmit}
|
||||
className="w-[150px]"
|
||||
isLoading={isBusy}
|
||||
/>
|
||||
</div>
|
||||
{showSubmitButton && (
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
label={submitLabel}
|
||||
onClick={handleSubmit}
|
||||
className="w-[150px]"
|
||||
isLoading={isBusy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ChatComposer.displayName = 'ChatComposer'
|
||||
|
||||
export default ChatComposer
|
||||
|
||||
+6
-70
@@ -1,82 +1,18 @@
|
||||
import { type FC } from 'react'
|
||||
import Stats from './components/Stats'
|
||||
import Services from './components/Services'
|
||||
import HomeSidebar from './components/HomeSidebar'
|
||||
import Orders from './components/Orders'
|
||||
import NewOrderImage from '@/assets/images/new_order.png'
|
||||
import { t } from '@/locale'
|
||||
import Button from '@/components/Button'
|
||||
import { AddSquare, MessageQuestion } from 'iconsax-react'
|
||||
import { COLORS } from '@/constants/colors'
|
||||
import SupportImage from '@/assets/images/support1.png'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Services from './components/Services'
|
||||
import Stats from './components/Stats'
|
||||
|
||||
const Home: FC = () => {
|
||||
return (
|
||||
<div className='flex gap-6'>
|
||||
<div className='flex-1'>
|
||||
<div className='flex flex-col gap-4 md:gap-6 xl:flex-row'>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<Stats />
|
||||
<Services />
|
||||
<Orders />
|
||||
</div>
|
||||
<div className='min-w-[267px] mt-5'>
|
||||
<div className='bg-white rounded-3xl p-6'>
|
||||
<img src={NewOrderImage} className='w-[98px] mx-auto' />
|
||||
<h4 className='mt-4 text-center font-medium'>
|
||||
{t('home.submitNewOrder')}
|
||||
</h4>
|
||||
<Link to={Paths.newRequest}>
|
||||
<Button
|
||||
className='mt-2.5'>
|
||||
<div className='flex gap-1'>
|
||||
<AddSquare size={20} color='#292D32' />
|
||||
<div className=''>{t('home.submitNewOrderButton')}</div>
|
||||
</div>
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className='mt-7 bg-white rounded-3xl p-6 relative'>
|
||||
<h4>
|
||||
{t('home.specialSolution')}
|
||||
</h4>
|
||||
<p className='mt-2 text-[#7B7E8B] text-xs'>
|
||||
{t('home.specialSolutionDescription')}
|
||||
</p>
|
||||
|
||||
<div className='mt-2 flex flex-col gap-2'>
|
||||
<div className='flex gap-2 items-center text-xs'>
|
||||
<div className='size-1.5 rounded-full bg-primary'></div>
|
||||
<div>{t('home.specialSolutionFeature1')}</div>
|
||||
</div>
|
||||
<div className='flex gap-2 items-center text-xs'>
|
||||
<div className='size-1.5 rounded-full bg-primary'></div>
|
||||
<div>{t('home.specialSolutionFeature2')}</div>
|
||||
</div>
|
||||
<div className='flex gap-2 items-center text-xs'>
|
||||
<div className='size-1.5 rounded-full bg-primary'></div>
|
||||
<div>{t('home.specialSolutionFeature3')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label={t('home.freeConsultation')}
|
||||
className='mt-12'
|
||||
/>
|
||||
|
||||
<div className='mt-2.5 bg-primary/11 py-6 px-4 rounded-[13px]'>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='flex gap-2 items-center'>
|
||||
<MessageQuestion size={20} color={COLORS.primary} />
|
||||
<div className='text-xs font-light'>{t('home.contactUs')}</div>
|
||||
</div>
|
||||
<div>{t('home.phoneNumber')}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img src={SupportImage} className='w-[50px] absolute bottom-[159px] left-2' />
|
||||
</div>
|
||||
</div>
|
||||
<HomeSidebar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { clx } from '@/helpers/utils'
|
||||
import type { CategoryType } from '@/pages/request/type/Types'
|
||||
import { getCategoriesAtLevel } from '@/pages/request/utils/categoryUtils'
|
||||
import { type FC } from 'react'
|
||||
|
||||
type CategoryTabsProps = {
|
||||
categories: CategoryType[]
|
||||
levels: number[]
|
||||
selectedPath: string[]
|
||||
onSelect: (level: number, categoryId: string) => void
|
||||
}
|
||||
|
||||
const CategoryTabs: FC<CategoryTabsProps> = ({
|
||||
categories,
|
||||
levels,
|
||||
selectedPath,
|
||||
onSelect,
|
||||
}) => {
|
||||
return (
|
||||
<div className='flex flex-col gap-3'>
|
||||
{levels.map((level) => {
|
||||
const items = getCategoriesAtLevel(categories, level, selectedPath)
|
||||
|
||||
if (!items.length) return null
|
||||
|
||||
const activeId = selectedPath[level] ?? ''
|
||||
|
||||
return (
|
||||
<div
|
||||
key={level}
|
||||
className='-mx-4 overflow-x-auto px-4 md:mx-0 md:px-0'
|
||||
>
|
||||
<div className='flex w-max gap-2 md:gap-3'>
|
||||
{items.map((category) => (
|
||||
<button
|
||||
key={category.id}
|
||||
type='button'
|
||||
onClick={() => onSelect(level, category.id)}
|
||||
className={clx(
|
||||
'shrink-0 rounded-full px-4 py-2 text-xs transition-colors md:text-sm',
|
||||
activeId === category.id
|
||||
? 'bg-primary text-[#292D32]'
|
||||
: 'bg-[#F5F5F7] text-[#7B7E8B] hover:bg-[#EBEBEF]',
|
||||
)}
|
||||
>
|
||||
{category.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CategoryTabs
|
||||
@@ -0,0 +1,34 @@
|
||||
import { clx } from '@/helpers/utils'
|
||||
import { type FC, type ReactNode } from 'react'
|
||||
|
||||
type HomeSectionProps = {
|
||||
title?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
}
|
||||
|
||||
const HomeSection: FC<HomeSectionProps> = ({
|
||||
title,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
}) => {
|
||||
return (
|
||||
<section
|
||||
className={clx(
|
||||
'rounded-2xl bg-white p-4 md:rounded-3xl md:p-6',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{title ? (
|
||||
<h2 className='text-base font-light md:text-lg'>{title}</h2>
|
||||
) : null}
|
||||
<div className={clx(title && 'mt-4 md:mt-6', contentClassName)}>
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomeSection
|
||||
@@ -0,0 +1,14 @@
|
||||
import { type FC } from 'react'
|
||||
import NewOrderCard from './NewOrderCard'
|
||||
import SpecialSolutionCard from './SpecialSolutionCard'
|
||||
|
||||
const HomeSidebar: FC = () => {
|
||||
return (
|
||||
<aside className='flex w-full shrink-0 flex-col gap-4 md:gap-6 xl:mt-5 xl:w-[267px]'>
|
||||
<NewOrderCard />
|
||||
<SpecialSolutionCard />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
export default HomeSidebar
|
||||
@@ -0,0 +1,33 @@
|
||||
import NewOrderImage from '@/assets/images/new_order.png'
|
||||
import Button from '@/components/Button'
|
||||
import { Paths } from '@/config/Paths'
|
||||
import { t } from '@/locale'
|
||||
import { AddSquare } from 'iconsax-react'
|
||||
import { type FC } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import HomeSection from './HomeSection'
|
||||
|
||||
const NewOrderCard: FC = () => {
|
||||
return (
|
||||
<HomeSection contentClassName='flex flex-col items-center'>
|
||||
<img
|
||||
src={NewOrderImage}
|
||||
alt=''
|
||||
className='mx-auto w-20 md:w-[98px]'
|
||||
/>
|
||||
<h3 className='mt-3 text-center text-sm font-medium md:mt-4 md:text-base'>
|
||||
{t('home.submitNewOrder')}
|
||||
</h3>
|
||||
<Link to={Paths.newRequest} className='mt-2.5 w-full'>
|
||||
<Button>
|
||||
<span className='flex items-center justify-center gap-1'>
|
||||
<AddSquare size={20} color='#292D32' />
|
||||
{t('home.submitNewOrderButton')}
|
||||
</span>
|
||||
</Button>
|
||||
</Link>
|
||||
</HomeSection>
|
||||
)
|
||||
}
|
||||
|
||||
export default NewOrderCard
|
||||
@@ -1,83 +1,30 @@
|
||||
import Table from '@/components/Table'
|
||||
import { type FC } from 'react'
|
||||
import { type ColumnType } from '@/components/types/TableTypes'
|
||||
import { type Order } from './types/OrderTypes'
|
||||
import { useGetMyOrders } from '@/pages/order/hooks/useOrderData'
|
||||
import { t } from '@/locale'
|
||||
import { TabMyOrdersEnum } from '@/pages/order/enum/OrderEnum'
|
||||
import { orderStatusLabels } from '@/pages/order/constants/statusLabels'
|
||||
import moment from 'moment-jalaali'
|
||||
import {
|
||||
OrderNumberRenderer,
|
||||
OrderTitleRenderer,
|
||||
OrderDateRenderer,
|
||||
OrderStatusRenderer,
|
||||
OrderMessagesRenderer,
|
||||
OrderViewRenderer
|
||||
} from './OrderTableComponents'
|
||||
import type { OrderType } from '@/pages/order/type/Types'
|
||||
|
||||
const mapOrderToRow = (order: OrderType): Order => ({
|
||||
id: order.id,
|
||||
number: String(order.orderNumber ?? order.id),
|
||||
title: order.title,
|
||||
creationDate: {
|
||||
gregorian: moment(order.createdAt).format('YYYY-MM-DD'),
|
||||
persian: moment(order.createdAt).format('jYYYY/jMM/jDD')
|
||||
},
|
||||
status: orderStatusLabels[order.status] ?? order.status,
|
||||
unreadMessages: 0
|
||||
})
|
||||
import { useGetMyOrders } from '@/pages/order/hooks/useOrderData'
|
||||
import { type FC } from 'react'
|
||||
import HomeSection from './HomeSection'
|
||||
import { mapOrderToRow, orderColumns } from './orderColumns'
|
||||
import { type Order } from './types/OrderTypes'
|
||||
|
||||
const Orders: FC = () => {
|
||||
const { data, isLoading } = useGetMyOrders(1, TabMyOrdersEnum.IN_PROGRESS)
|
||||
const orders: Order[] = (data?.data ?? []).map(mapOrderToRow)
|
||||
|
||||
const columns: ColumnType<Order>[] = [
|
||||
{
|
||||
title: 'شماره',
|
||||
key: 'number',
|
||||
render: (order) => <OrderNumberRenderer order={order} />
|
||||
},
|
||||
{
|
||||
title: 'عنوان سفارش',
|
||||
key: 'title',
|
||||
render: (order) => <OrderTitleRenderer order={order} />
|
||||
},
|
||||
{
|
||||
title: 'تاریخ ایجاد',
|
||||
key: 'creationDate',
|
||||
render: (order) => <OrderDateRenderer order={order} />
|
||||
},
|
||||
{
|
||||
title: 'وضعیت سفارش',
|
||||
key: 'status',
|
||||
render: (order) => <OrderStatusRenderer order={order} />
|
||||
},
|
||||
{
|
||||
title: 'پیام ها',
|
||||
key: 'unreadMessages',
|
||||
render: (order) => <OrderMessagesRenderer order={order} />
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
render: (order) => <OrderViewRenderer order={order} />,
|
||||
width: '60px'
|
||||
}
|
||||
]
|
||||
|
||||
return (
|
||||
<div className='mt-6 bg-white p-6 rounded-3xl'>
|
||||
<h4 className='text-lg font-light'>
|
||||
سفارش های در حال انجام
|
||||
</h4>
|
||||
|
||||
<Table<Order>
|
||||
columns={columns}
|
||||
data={orders}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
<HomeSection
|
||||
title={t('home.inProgressOrders')}
|
||||
className='mt-4 md:mt-6'
|
||||
contentClassName='mt-0 -mx-2 md:mx-0'
|
||||
>
|
||||
<div className='mt-4 overflow-x-auto md:mt-6'>
|
||||
<Table<Order>
|
||||
columns={orderColumns}
|
||||
data={orders}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
</HomeSection>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import { type FC } from 'react'
|
||||
import { type ProductType } from '@/pages/request/type/Types'
|
||||
import PresignedImage from '@/components/PresignedImage'
|
||||
import { type ProductType } from '@/pages/request/type/Types'
|
||||
import { type FC } from 'react'
|
||||
|
||||
const ServiceItem: FC<{ product: ProductType }> = ({ product }) => {
|
||||
return (
|
||||
<div>
|
||||
<div className='size-20 rounded-full bg-gray-200 border-[3px] border-primary overflow-hidden'>
|
||||
<div className='flex w-[88px] shrink-0 snap-start flex-col items-center gap-2'>
|
||||
<div className='size-20 overflow-hidden rounded-full border-[3px] border-primary bg-gray-200'>
|
||||
{product.images?.[0] ? (
|
||||
<PresignedImage
|
||||
src={product.images[0]}
|
||||
alt={product.title}
|
||||
className='w-full h-full object-cover'
|
||||
className='h-full w-full object-cover'
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<span className='line-clamp-2 w-full text-center text-xs font-light text-[#292D32]'>
|
||||
{product.title}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,40 +1,120 @@
|
||||
import { t } from '@/locale'
|
||||
import { type FC } from 'react'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import {
|
||||
useGetCategories,
|
||||
useGetProducts,
|
||||
} from '@/pages/request/hooks/useRequestData'
|
||||
import type { CategoryType } from '@/pages/request/type/Types'
|
||||
import {
|
||||
buildPathToFirstLeaf,
|
||||
extractList,
|
||||
getLevelsToShow,
|
||||
} from '@/pages/request/utils/categoryUtils'
|
||||
import { type FC, useEffect, useMemo, useState } from 'react'
|
||||
import CategoryTabs from './CategoryTabs'
|
||||
import HomeSection from './HomeSection'
|
||||
import ServiceItem from './ServiceItem'
|
||||
import { useGetProducts } from '@/pages/request/hooks/useRequestData'
|
||||
|
||||
const SKELETON_COUNT = 6
|
||||
|
||||
const CategoryTabsSkeleton: FC = () => (
|
||||
<div className='flex gap-2 overflow-hidden'>
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='h-9 w-24 shrink-0 animate-pulse rounded-full bg-gray-100'
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const ServicesSkeleton: FC = () => (
|
||||
<div className='flex w-max gap-4'>
|
||||
{Array.from({ length: SKELETON_COUNT }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='flex w-[88px] shrink-0 flex-col items-center gap-2'
|
||||
>
|
||||
<div className='size-20 animate-pulse rounded-full bg-gray-200' />
|
||||
<div className='h-8 w-full animate-pulse rounded bg-gray-100' />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
const Services: FC = () => {
|
||||
const { data } = useGetProducts();
|
||||
const { data: categoriesData, isLoading: isCategoriesLoading } =
|
||||
useGetCategories()
|
||||
const categories = useMemo(
|
||||
() => extractList<CategoryType>(categoriesData),
|
||||
[categoriesData],
|
||||
)
|
||||
|
||||
const [selectedPath, setSelectedPath] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!categories.length || selectedPath.length) return
|
||||
|
||||
setSelectedPath(
|
||||
buildPathToFirstLeaf(categories, categories[0].id),
|
||||
)
|
||||
}, [categories, selectedPath.length])
|
||||
|
||||
const levelsToShow = useMemo(
|
||||
() => getLevelsToShow(categories, selectedPath),
|
||||
[categories, selectedPath],
|
||||
)
|
||||
|
||||
const activeCategoryId = selectedPath.at(-1) ?? ''
|
||||
|
||||
const { data: productsData, isLoading: isProductsLoading } = useGetProducts(
|
||||
activeCategoryId || undefined,
|
||||
{ enabled: !!activeCategoryId },
|
||||
)
|
||||
const products = productsData?.data ?? []
|
||||
|
||||
const handleCategorySelect = (level: number, categoryId: string) => {
|
||||
setSelectedPath(
|
||||
buildPathToFirstLeaf(
|
||||
categories,
|
||||
categoryId,
|
||||
selectedPath.slice(0, level),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const isLoading = isCategoriesLoading || isProductsLoading
|
||||
|
||||
return (
|
||||
<div className='mt-6 bg-white p-6 rounded-3xl'>
|
||||
<h4 className='text-lg font-light'>
|
||||
{t('home.services')}
|
||||
</h4>
|
||||
<HomeSection title={t('home.products')} className='mt-4 md:mt-6' contentClassName='mt-0'>
|
||||
<div className='mt-4 space-y-4 md:mt-6 md:space-y-6'>
|
||||
{isCategoriesLoading ? (
|
||||
<CategoryTabsSkeleton />
|
||||
) : categories.length > 0 ? (
|
||||
<CategoryTabs
|
||||
categories={categories}
|
||||
levels={levelsToShow}
|
||||
selectedPath={selectedPath}
|
||||
onSelect={handleCategorySelect}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className='mt-6'>
|
||||
<Swiper
|
||||
spaceBetween={16}
|
||||
slidesPerView={'auto'}
|
||||
className='h-full'
|
||||
breakpoints={{
|
||||
640: {
|
||||
spaceBetween: 24,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{
|
||||
data?.data.map((item) => (
|
||||
<SwiperSlide className='w-auto!' key={item.id}>
|
||||
<ServiceItem product={item} />
|
||||
</SwiperSlide>
|
||||
))
|
||||
}
|
||||
</Swiper>
|
||||
<div className='-mx-4 overflow-x-auto px-4 pb-1 md:mx-0 md:px-0'>
|
||||
{isLoading ? (
|
||||
<ServicesSkeleton />
|
||||
) : products.length === 0 ? (
|
||||
<p className='text-sm font-light text-[#7B7E8B]'>
|
||||
{t('home.noProducts')}
|
||||
</p>
|
||||
) : (
|
||||
<div className='flex w-max snap-x snap-mandatory gap-4 md:gap-6'>
|
||||
{products.map((item) => (
|
||||
<ServiceItem key={item.id} product={item} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</HomeSection>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import SupportImage from '@/assets/images/support1.png'
|
||||
import Button from '@/components/Button'
|
||||
import { COLORS } from '@/constants/colors'
|
||||
import { t } from '@/locale'
|
||||
import { MessageQuestion } from 'iconsax-react'
|
||||
import { type FC } from 'react'
|
||||
import HomeSection from './HomeSection'
|
||||
|
||||
const FEATURE_KEYS = [
|
||||
'home.specialSolutionFeature1',
|
||||
'home.specialSolutionFeature2',
|
||||
'home.specialSolutionFeature3',
|
||||
] as const
|
||||
|
||||
const SpecialSolutionCard: FC = () => {
|
||||
return (
|
||||
<HomeSection>
|
||||
<h3 className='text-sm font-medium md:text-base'>
|
||||
{t('home.specialSolution')}
|
||||
</h3>
|
||||
<p className='mt-2 text-xs text-[#7B7E8B]'>
|
||||
{t('home.specialSolutionDescription')}
|
||||
</p>
|
||||
|
||||
<ul className='mt-3 flex flex-col gap-2'>
|
||||
{FEATURE_KEYS.map((key) => (
|
||||
<li key={key} className='flex items-center gap-2 text-xs'>
|
||||
<span className='size-1.5 shrink-0 rounded-full bg-primary' />
|
||||
{t(key)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<Button
|
||||
label={t('home.freeConsultation')}
|
||||
className='mt-8 md:mt-12'
|
||||
/>
|
||||
|
||||
<div className='mt-3 flex items-end gap-3 md:mt-4'>
|
||||
<img
|
||||
src={SupportImage}
|
||||
alt=''
|
||||
className='w-10 shrink-0 md:w-[50px]'
|
||||
/>
|
||||
<div className='flex-1 rounded-[13px] bg-primary/11 px-4 py-4 md:py-6'>
|
||||
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<MessageQuestion size={20} color={COLORS.primary} />
|
||||
<span className='text-xs font-light'>
|
||||
{t('home.contactUs')}
|
||||
</span>
|
||||
</div>
|
||||
<span className='text-sm font-medium' dir='ltr'>
|
||||
{t('home.phoneNumber')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</HomeSection>
|
||||
)
|
||||
}
|
||||
|
||||
export default SpecialSolutionCard
|
||||
@@ -4,34 +4,35 @@ import { type FC, type ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
type Props = {
|
||||
icon: ReactNode,
|
||||
count: number | undefined,
|
||||
description: string,
|
||||
to: string,
|
||||
icon: ReactNode
|
||||
count: number | undefined
|
||||
description: string
|
||||
to: string
|
||||
}
|
||||
|
||||
const StatCard: FC<Props> = (props) => {
|
||||
const { icon, count, description, to } = props
|
||||
const StatCard: FC<Props> = ({ icon, count, description, to }) => {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className='flex-1 bg-white rounded-3xl p-6 block transition-shadow hover:shadow-md'
|
||||
className='block flex-1 rounded-2xl bg-white p-4 transition-shadow hover:shadow-md md:rounded-3xl md:p-6'
|
||||
>
|
||||
<div className='flex justify-between items-center'>
|
||||
<div className='flex items-center justify-between'>
|
||||
{icon}
|
||||
|
||||
<div className='size-8 bg-[#FFF1D7] rounded-full flex justify-center items-center'>
|
||||
<ArrowLeft size={16} color={COLORS.primary} className='rotate-45' />
|
||||
<div className='flex size-7 items-center justify-center rounded-full bg-[#FFF1D7] md:size-8'>
|
||||
<ArrowLeft
|
||||
size={16}
|
||||
color={COLORS.primary}
|
||||
className='rotate-45'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-2 text-2xl font-semibold'>
|
||||
{count}
|
||||
<div className='mt-2 text-xl font-semibold md:text-2xl'>
|
||||
{count ?? 0}
|
||||
</div>
|
||||
|
||||
<div className='mt-2 text-sm'>
|
||||
{description}
|
||||
</div>
|
||||
<div className='mt-1 text-xs md:mt-2 md:text-sm'>{description}</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const Stats: FC = () => {
|
||||
mobile={2}
|
||||
gapDesktop={24}
|
||||
gapMobile={12}
|
||||
className='mt-5'
|
||||
className='mt-4 md:mt-5'
|
||||
>
|
||||
<StatCard
|
||||
count={data?.data.requestsCount}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { type ColumnType } from '@/components/types/TableTypes'
|
||||
import moment from 'moment-jalaali'
|
||||
import { orderStatusLabels } from '@/pages/order/constants/statusLabels'
|
||||
import type { OrderType } from '@/pages/order/type/Types'
|
||||
import {
|
||||
OrderDateRenderer,
|
||||
OrderMessagesRenderer,
|
||||
OrderNumberRenderer,
|
||||
OrderStatusRenderer,
|
||||
OrderTitleRenderer,
|
||||
OrderViewRenderer,
|
||||
} from './OrderTableComponents'
|
||||
import { type Order } from './types/OrderTypes'
|
||||
|
||||
export const mapOrderToRow = (order: OrderType): Order => ({
|
||||
id: order.id,
|
||||
number: String(order.orderNumber ?? order.id),
|
||||
title: order.title,
|
||||
creationDate: {
|
||||
gregorian: moment(order.createdAt).format('YYYY-MM-DD'),
|
||||
persian: moment(order.createdAt).format('jYYYY/jMM/jDD'),
|
||||
},
|
||||
status: orderStatusLabels[order.status] ?? order.status,
|
||||
unreadMessages: 0,
|
||||
})
|
||||
|
||||
export const orderColumns: ColumnType<Order>[] = [
|
||||
{
|
||||
title: 'شماره',
|
||||
key: 'number',
|
||||
render: (order) => <OrderNumberRenderer order={order} />,
|
||||
},
|
||||
{
|
||||
title: 'عنوان سفارش',
|
||||
key: 'title',
|
||||
render: (order) => <OrderTitleRenderer order={order} />,
|
||||
},
|
||||
{
|
||||
title: 'تاریخ ایجاد',
|
||||
key: 'creationDate',
|
||||
render: (order) => <OrderDateRenderer order={order} />,
|
||||
},
|
||||
{
|
||||
title: 'وضعیت سفارش',
|
||||
key: 'status',
|
||||
render: (order) => <OrderStatusRenderer order={order} />,
|
||||
},
|
||||
{
|
||||
title: 'پیام ها',
|
||||
key: 'unreadMessages',
|
||||
render: (order) => <OrderMessagesRenderer order={order} />,
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'actions',
|
||||
render: (order) => <OrderViewRenderer order={order} />,
|
||||
width: '60px',
|
||||
},
|
||||
]
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, type FC } from 'react'
|
||||
import { useEffect, useRef, useState, type FC } from 'react'
|
||||
import Request from './components/Request'
|
||||
import RequestItemsList from './components/RequestItemsList'
|
||||
import { useRequestStore } from './store/RequestStore'
|
||||
@@ -9,6 +9,10 @@ import { Paths } from '@/config/Paths'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import { ArrowRight2 } from 'iconsax-react'
|
||||
import ModalConfirm from '@/components/ModalConfirm'
|
||||
import ChatComposer, {
|
||||
type ChatComposerHandle,
|
||||
type ChatComposerSubmitPayload,
|
||||
} from '@/pages/chat/components/ChatComposer'
|
||||
|
||||
const NewRequest: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
@@ -17,6 +21,9 @@ const NewRequest: FC = () => {
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
||||
const [formKey, setFormKey] = useState(0)
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false)
|
||||
const composerRef = useRef<ChatComposerHandle>(null)
|
||||
const [pendingNote, setPendingNote] =
|
||||
useState<ChatComposerSubmitPayload | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setItems([])
|
||||
@@ -51,23 +58,50 @@ const NewRequest: FC = () => {
|
||||
toast('حداقل یک قلم باید به درخواست اضافه کنید.', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (composerRef.current?.hasPendingUploads()) {
|
||||
toast('لطفاً تا پایان آپلود فایلها صبر کنید', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (composerRef.current?.hasUploadErrors()) {
|
||||
toast(
|
||||
'برخی فایلها آپلود نشدند. آنها را حذف یا دوباره انتخاب کنید',
|
||||
'error',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const note = composerRef.current?.getPayload() ?? null
|
||||
setPendingNote(note)
|
||||
setShowConfirmModal(true)
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
submitRequest(items, {
|
||||
onSuccess: () => {
|
||||
setShowConfirmModal(false)
|
||||
setItems([])
|
||||
setEditingIndex(null)
|
||||
setFormKey((k) => k + 1)
|
||||
toast('سفارش با موفقیت ثبت شد', 'success')
|
||||
navigate(Paths.myRequests, { state: { refresh: true } })
|
||||
submitRequest(
|
||||
{
|
||||
items,
|
||||
...(pendingNote?.content ? { description: pendingNote.content } : {}),
|
||||
...(pendingNote?.attachments?.length
|
||||
? { attachments: pendingNote.attachments }
|
||||
: {}),
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowConfirmModal(false)
|
||||
setPendingNote(null)
|
||||
composerRef.current?.reset()
|
||||
setItems([])
|
||||
setEditingIndex(null)
|
||||
setFormKey((k) => k + 1)
|
||||
toast('سفارش با موفقیت ثبت شد', 'success')
|
||||
navigate(Paths.myRequests, { state: { refresh: true } })
|
||||
},
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -84,7 +118,9 @@ const NewRequest: FC = () => {
|
||||
</div>
|
||||
|
||||
<p className='text-description text-xs mt-2 leading-5'>
|
||||
برای هر محصول یک قلم اضافه کنید، سپس درخواست را ثبت نهایی کنید.
|
||||
برای هر محصول یک قلم اضافه کنید. در صورت نیاز توضیحات یا فایل را
|
||||
در بخش پایین وارد کنید و از دکمه «ثبت نهایی درخواست» در کنار لیست
|
||||
استفاده کنید.
|
||||
</p>
|
||||
|
||||
<div className='flex flex-col-reverse xl:flex-row gap-6 xl:mt-8 mt-4'>
|
||||
@@ -117,6 +153,22 @@ const NewRequest: FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className='bg-white rounded-3xl p-6 mt-6'>
|
||||
<h2 className='text-sm font-light mb-1'>توضیحات درخواست</h2>
|
||||
<p className='text-description text-xs mb-6 leading-5'>
|
||||
توضیحات، فایل یا پیام صوتی خود را اینجا بنویسید. برای ارسال
|
||||
درخواست از دکمه کنار لیست اقلام استفاده کنید.
|
||||
</p>
|
||||
<ChatComposer
|
||||
ref={composerRef}
|
||||
onSubmit={async () => undefined}
|
||||
showSubmitButton={false}
|
||||
label='پیام شما'
|
||||
placeholder='توضیحات یا درخواست خود را بنویسید...'
|
||||
allowEmptySubmit
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ModalConfirm
|
||||
open={showConfirmModal}
|
||||
onClose={() => setShowConfirmModal(false)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useRef, type FC } from 'react'
|
||||
import type { AttributeType, RequestType } from '../type/Types'
|
||||
import type { AttributeType, RequestItemType } from '../type/Types'
|
||||
import { clx } from '@/helpers/utils'
|
||||
import { FieldTypeEnum } from '../enum/RequestEnum'
|
||||
import Select from '@/components/Select'
|
||||
@@ -12,7 +12,7 @@ import type { FormikProps } from 'formik'
|
||||
|
||||
type Props = {
|
||||
attributes?: AttributeType[],
|
||||
formik: FormikProps<RequestType>
|
||||
formik: FormikProps<RequestItemType>
|
||||
}
|
||||
|
||||
type AttributeFieldProps = {
|
||||
|
||||
@@ -2,67 +2,18 @@ import { type ChangeEvent, type FC, type SelectHTMLAttributes, useEffect, useMem
|
||||
import { useGetCategories, useGetProducts } from '../hooks/useRequestData'
|
||||
import Select from '@/components/Select'
|
||||
import type { CategoryType, ProductType } from '../type/Types'
|
||||
import {
|
||||
extractList,
|
||||
findCategoryById,
|
||||
findCategoryPath,
|
||||
getCategoriesAtLevel,
|
||||
} from '../utils/categoryUtils'
|
||||
|
||||
type Props = {
|
||||
error_text?: string,
|
||||
onProductSelect?: (product: ProductType | undefined) => void,
|
||||
} & SelectHTMLAttributes<HTMLSelectElement>
|
||||
|
||||
const findCategoryById = (categories: CategoryType[], targetId: string): CategoryType | null => {
|
||||
for (const category of categories) {
|
||||
if (category.id === targetId) {
|
||||
return category
|
||||
}
|
||||
|
||||
if (category.children?.length) {
|
||||
const found = findCategoryById(category.children, targetId)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const findCategoryPath = (categories: CategoryType[], targetId: string): string[] | null => {
|
||||
for (const category of categories) {
|
||||
if (category.id === targetId) {
|
||||
return [category.id]
|
||||
}
|
||||
|
||||
if (category.children?.length) {
|
||||
const childPath = findCategoryPath(category.children, targetId)
|
||||
if (childPath) {
|
||||
return [category.id, ...childPath]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const getCategoriesAtLevel = (
|
||||
categories: CategoryType[],
|
||||
level: number,
|
||||
selectedPath: string[],
|
||||
): CategoryType[] => {
|
||||
if (level === 0) return categories
|
||||
|
||||
const parentId = selectedPath[level - 1]
|
||||
if (!parentId) return []
|
||||
|
||||
const parent = findCategoryById(categories, parentId)
|
||||
return parent?.children ?? []
|
||||
}
|
||||
|
||||
const extractList = <T,>(payload: unknown): T[] => {
|
||||
if (Array.isArray(payload)) return payload
|
||||
if (payload && typeof payload === 'object' && 'data' in payload) {
|
||||
const maybeData = (payload as { data?: unknown }).data
|
||||
return Array.isArray(maybeData) ? (maybeData as T[]) : []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const ProductsSelect: FC<Props> = (props) => {
|
||||
const { error_text, onProductSelect, value, onChange, ...rest } = props
|
||||
const [selectedPath, setSelectedPath] = useState<string[]>([])
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import { useCallback, useState, type ChangeEvent, type FC } from 'react'
|
||||
import Button from '@/components/Button'
|
||||
import UploadBox from '@/components/UploadBox'
|
||||
import VoiceRecorder from '@/components/VoiceRecorder'
|
||||
import { COLORS } from '@/constants/colors'
|
||||
import { AddSquare, CloseCircle, Edit } from 'iconsax-react'
|
||||
import ProductsSelect from './ProductsSelect'
|
||||
import { useFormik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import type { AttachmentsType, RequestType, ProductType } from '../type/Types'
|
||||
import type { RequestItemType, ProductType } from '../type/Types'
|
||||
import { useGetAttributes } from '../hooks/useRequestData'
|
||||
import ManageAttribute from './ManageAttribute'
|
||||
import { useMultiUpload, useSingleUpload } from '@/pages/uploader/hooks/useUploader'
|
||||
import { useRequestStore } from '../store/RequestStore'
|
||||
import { clx } from '@/helpers/utils'
|
||||
|
||||
const emptyValues: RequestType = {
|
||||
const emptyValues: RequestItemType = {
|
||||
productId: undefined,
|
||||
attachments: [],
|
||||
description: '',
|
||||
attributes: [],
|
||||
}
|
||||
|
||||
type Props = {
|
||||
editIndex: number | null
|
||||
initialItem?: RequestType
|
||||
initialItem?: RequestItemType
|
||||
onSaved: () => void
|
||||
onCancelEdit?: () => void
|
||||
}
|
||||
@@ -33,57 +28,24 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
|
||||
|
||||
const setItems = useRequestStore((state) => state.setItems)
|
||||
const [productSelected, setProductSelected] = useState<ProductType>()
|
||||
const [voiceFile, setVoiceFile] = useState<File>()
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const singleUpload = useSingleUpload()
|
||||
const multiUpload = useMultiUpload()
|
||||
|
||||
const formik = useFormik<RequestType>({
|
||||
const formik = useFormik<RequestItemType>({
|
||||
initialValues: initialItem ?? emptyValues,
|
||||
enableReinitialize: true,
|
||||
validationSchema: Yup.object({
|
||||
productId: Yup.string().required('این فیلد اجباری می باشد'),
|
||||
}),
|
||||
onSubmit: async (values) => {
|
||||
const attachments: AttachmentsType[] = [...(values.attachments ?? [])]
|
||||
|
||||
if (files.length) {
|
||||
await multiUpload.mutateAsync(files, {
|
||||
onSuccess: (data) => {
|
||||
data?.data?.forEach((item) => {
|
||||
attachments.push({
|
||||
type: 'uploads_attach',
|
||||
url: item.key,
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
if (voiceFile) {
|
||||
await singleUpload.mutateAsync(voiceFile, {
|
||||
onSuccess: (data) => {
|
||||
attachments.push({
|
||||
type: 'voice',
|
||||
url: data?.data?.key,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const payload: RequestType = { ...values, attachments }
|
||||
|
||||
onSubmit: (values) => {
|
||||
if (isEditing && editIndex !== null) {
|
||||
const items = useRequestStore.getState().items
|
||||
const updated = [...items]
|
||||
updated[editIndex] = payload
|
||||
updated[editIndex] = values
|
||||
setItems(updated)
|
||||
} else {
|
||||
const items = useRequestStore.getState().items
|
||||
setItems([...items, payload])
|
||||
setItems([...items, values])
|
||||
}
|
||||
|
||||
setFiles([])
|
||||
setVoiceFile(undefined)
|
||||
onSaved()
|
||||
},
|
||||
})
|
||||
@@ -92,36 +54,25 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
|
||||
|
||||
const { data: attributes } = useGetAttributes(resolvedProduct?.id)
|
||||
|
||||
const handleProductChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
const productId = e.target.value
|
||||
const handleProductChange = useCallback(
|
||||
(e: ChangeEvent<HTMLSelectElement>) => {
|
||||
const productId = e.target.value
|
||||
|
||||
if (!productId) {
|
||||
setProductSelected(undefined)
|
||||
formik.setFieldValue('productId', undefined)
|
||||
if (!productId) {
|
||||
setProductSelected(undefined)
|
||||
formik.setFieldValue('productId', undefined)
|
||||
formik.setFieldValue('attributes', [])
|
||||
return
|
||||
}
|
||||
|
||||
formik.setFieldValue('productId', productId)
|
||||
formik.setFieldValue('attributes', [])
|
||||
return
|
||||
}
|
||||
|
||||
formik.setFieldValue('productId', productId)
|
||||
formik.setFieldValue('attributes', [])
|
||||
}
|
||||
|
||||
const handleProductSelect = (product: ProductType | undefined) => {
|
||||
setProductSelected(product)
|
||||
}
|
||||
|
||||
const isUploading = singleUpload.isPending || multiUpload.isPending
|
||||
|
||||
const handleDescriptionChange = useCallback(
|
||||
(value: string) => {
|
||||
formik.setFieldValue('description', value)
|
||||
},
|
||||
[formik.setFieldValue]
|
||||
[formik]
|
||||
)
|
||||
|
||||
const handleRecordingComplete = useCallback((blob: Blob) => {
|
||||
const file = new File([blob], 'recording.wav', { type: blob.type })
|
||||
setVoiceFile(file)
|
||||
const handleProductSelect = useCallback((product: ProductType | undefined) => {
|
||||
setProductSelected(product)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
@@ -163,25 +114,6 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-6'>
|
||||
<VoiceRecorder
|
||||
value={formik.values.description ?? ''}
|
||||
onChange={handleDescriptionChange}
|
||||
label='پیام صوتی'
|
||||
onRecordingComplete={handleRecordingComplete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-6'>
|
||||
<UploadBox label='آپلود فایل' isMultiple onChange={setFiles} />
|
||||
</div>
|
||||
|
||||
{isEditing && (initialItem?.attachments?.length ?? 0) > 0 && files.length === 0 && (
|
||||
<p className='text-description text-xs mt-3'>
|
||||
{initialItem!.attachments.length} پیوست قبلی حفظ میشود. برای جایگزینی، فایل جدید آپلود کنید.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className='mt-6 flex justify-end'>
|
||||
<Button
|
||||
className={clx(
|
||||
@@ -191,7 +123,6 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
|
||||
: 'bg-transparent border border-primary text-primary'
|
||||
)}
|
||||
onClick={() => formik.handleSubmit()}
|
||||
isLoading={isUploading}
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
{isEditing ? (
|
||||
|
||||
@@ -2,11 +2,11 @@ import { type FC } from 'react'
|
||||
import Button from '@/components/Button'
|
||||
import { Edit, ShoppingCart, TickSquare, Trash } from 'iconsax-react'
|
||||
import { useGetProducts } from '../hooks/useRequestData'
|
||||
import type { RequestType } from '../type/Types'
|
||||
import type { RequestItemType } from '../type/Types'
|
||||
import { clx } from '@/helpers/utils'
|
||||
|
||||
type Props = {
|
||||
items: RequestType[]
|
||||
items: RequestItemType[]
|
||||
editingIndex: number | null
|
||||
onEdit: (index: number) => void
|
||||
onRemove: (index: number) => void
|
||||
@@ -58,11 +58,6 @@ const RequestItemsList: FC<Props> = ({
|
||||
<div className='text-sm font-medium truncate'>
|
||||
{getProductTitle(item.productId)}
|
||||
</div>
|
||||
{item.attachments.length > 0 && (
|
||||
<div className='text-description text-xs mt-1'>
|
||||
{item.attachments.length} پیوست
|
||||
</div>
|
||||
)}
|
||||
<div className='flex gap-2 mt-3'>
|
||||
<button
|
||||
type='button'
|
||||
|
||||
@@ -2,9 +2,9 @@ import axios from "@/config/axios";
|
||||
import type {
|
||||
AttributeResponseType,
|
||||
CategoryType,
|
||||
CreateRequestPayload,
|
||||
MyRequestsResponseType,
|
||||
RequestDetailResponseType,
|
||||
RequestType,
|
||||
ProductsResponseType,
|
||||
} from "../type/Types";
|
||||
|
||||
@@ -26,8 +26,24 @@ export const getAttributes = async (productId?: string) => {
|
||||
return data;
|
||||
};
|
||||
|
||||
export const submitRequest = async (params: RequestType[]) => {
|
||||
const { data } = await axios.post(`/public/request`, { items: params });
|
||||
export const submitRequest = async (payload: CreateRequestPayload) => {
|
||||
const { data } = await axios.post(`/public/request`, {
|
||||
items: payload.items.map((item) => ({
|
||||
productId: item.productId,
|
||||
...(item.attributes?.length
|
||||
? {
|
||||
attributes: item.attributes.map((attr) => ({
|
||||
fieldId: attr.fieldId,
|
||||
value: String(attr.value),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
...(payload.description ? { description: payload.description } : {}),
|
||||
...(payload.attachments?.length
|
||||
? { attachments: payload.attachments }
|
||||
: {}),
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -18,14 +18,18 @@ export type AttachmentsType = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type RequestType = {
|
||||
export type RequestItemType = {
|
||||
productId?: string;
|
||||
attributes: {
|
||||
fieldId: string;
|
||||
value: string | number;
|
||||
}[];
|
||||
attachments: AttachmentsType[];
|
||||
};
|
||||
|
||||
export type CreateRequestPayload = {
|
||||
items: RequestItemType[];
|
||||
description?: string;
|
||||
attachments?: AttachmentsType[];
|
||||
};
|
||||
|
||||
export type AttributeValueType = {
|
||||
@@ -64,8 +68,8 @@ export type ProductsResponseType = BaseResponse<ProductType[]>;
|
||||
export type CategoriesResponseType = BaseResponse<CategoryType[]>;
|
||||
|
||||
export type StoreType = {
|
||||
items: RequestType[];
|
||||
setItems: (value: RequestType[]) => void;
|
||||
items: RequestItemType[];
|
||||
setItems: (value: RequestItemType[]) => void;
|
||||
};
|
||||
|
||||
export interface MyRequestType extends RowDataType {
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { CategoryType } from '../type/Types'
|
||||
|
||||
export const extractList = <T,>(payload: unknown): T[] => {
|
||||
if (Array.isArray(payload)) return payload
|
||||
if (payload && typeof payload === 'object' && 'data' in payload) {
|
||||
const maybeData = (payload as { data?: unknown }).data
|
||||
return Array.isArray(maybeData) ? (maybeData as T[]) : []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export const findCategoryById = (
|
||||
categories: CategoryType[],
|
||||
targetId: string,
|
||||
): CategoryType | null => {
|
||||
for (const category of categories) {
|
||||
if (category.id === targetId) {
|
||||
return category
|
||||
}
|
||||
|
||||
if (category.children?.length) {
|
||||
const found = findCategoryById(category.children, targetId)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const findCategoryPath = (
|
||||
categories: CategoryType[],
|
||||
targetId: string,
|
||||
): string[] | null => {
|
||||
for (const category of categories) {
|
||||
if (category.id === targetId) {
|
||||
return [category.id]
|
||||
}
|
||||
|
||||
if (category.children?.length) {
|
||||
const childPath = findCategoryPath(category.children, targetId)
|
||||
if (childPath) {
|
||||
return [category.id, ...childPath]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export const getCategoriesAtLevel = (
|
||||
categories: CategoryType[],
|
||||
level: number,
|
||||
selectedPath: string[],
|
||||
): CategoryType[] => {
|
||||
if (level === 0) return categories
|
||||
|
||||
const parentId = selectedPath[level - 1]
|
||||
if (!parentId) return []
|
||||
|
||||
const parent = findCategoryById(categories, parentId)
|
||||
return parent?.children ?? []
|
||||
}
|
||||
|
||||
export const getLevelsToShow = (
|
||||
categories: CategoryType[],
|
||||
selectedPath: string[],
|
||||
): number[] => {
|
||||
const levels = [0]
|
||||
|
||||
for (let level = 0; level < selectedPath.length; level++) {
|
||||
const category = findCategoryById(categories, selectedPath[level])
|
||||
if (category?.children?.length) {
|
||||
levels.push(level + 1)
|
||||
}
|
||||
}
|
||||
|
||||
return levels
|
||||
}
|
||||
|
||||
export const buildPathToFirstLeaf = (
|
||||
categories: CategoryType[],
|
||||
categoryId: string,
|
||||
prefixPath: string[] = [],
|
||||
): string[] => {
|
||||
const path = [...prefixPath, categoryId]
|
||||
let current = findCategoryById(categories, categoryId)
|
||||
|
||||
while (current?.children?.length) {
|
||||
path.push(current.children[0].id)
|
||||
current = current.children[0]
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
Reference in New Issue
Block a user