Compare commits

...

3 Commits

Author SHA1 Message Date
morteza cd8677648e up
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-21 12:17:54 +03:30
morteza 9f006b36ab ref product section 2026-07-21 11:20:07 +03:30
morteza fff5cd9146 refactor home 2026-07-21 11:16:33 +03:30
22 changed files with 713 additions and 399 deletions
+3 -1
View File
@@ -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: {
+72 -30
View File
@@ -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
View File
@@ -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
+34
View File
@@ -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
+14
View File
@@ -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
+19 -72
View File
@@ -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>
)
}
+8 -5
View File
@@ -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>
)
}
+109 -29
View File
@@ -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
+16 -15
View File
@@ -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>
)
}
+1 -1
View File
@@ -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',
},
]
+65 -13
View File
@@ -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[]>([])
+21 -90
View File
@@ -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'
+19 -3
View File
@@ -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;
};
+8 -4
View File
@@ -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 {
+94
View File
@@ -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
}