refactor home

This commit is contained in:
2026-07-21 11:16:33 +03:30
parent 17cb4afbca
commit fff5cd9146
15 changed files with 524 additions and 250 deletions
@@ -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 md:w-auto'>
<div className='size-16 overflow-hidden rounded-full border-[3px] border-primary bg-gray-200 md:size-20'>
{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>
)
}
+110 -30
View File
@@ -1,41 +1,121 @@
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 gap-4 md:grid md:grid-cols-4 md:gap-6 lg:grid-cols-6 xl:grid-cols-8'>
{Array.from({ length: SKELETON_COUNT }).map((_, index) => (
<div
key={index}
className='flex w-[88px] shrink-0 flex-col items-center gap-2 md:w-auto'
>
<div className='size-16 animate-pulse rounded-full bg-gray-200 md:size-20' />
<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:overflow-visible 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:grid md:w-full md:grid-cols-4 md:gap-6 md:snap-none lg:grid-cols-6 xl:grid-cols-8'>
{products.map((item) => (
<ServiceItem key={item.id} product={item} />
))}
</div>
)}
</div>
</div>
</div>
</HomeSection>
)
}
export default Services
export default Services
@@ -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',
},
]