Compare commits

...

9 Commits

Author SHA1 Message Date
morteza 18c0c721a1 zustand
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-24 13:23:40 +03:30
morteza f1d0a661e7 orders
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-23 19:40:17 +03:30
morteza 1bea0ed3ec count 2026-07-23 18:47:48 +03:30
morteza cea411b912 invoice 2026-07-23 18:13:39 +03:30
morteza 04687b855d profile
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-21 16:05:15 +03:30
morteza 830e229cf3 refactor new req
deploy to danak / build_and_deploy (push) Has been cancelled
2026-07-21 12:44:16 +03:30
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
40 changed files with 1325 additions and 665 deletions
+2
View File
@@ -1,5 +1,6 @@
import { create } from "zustand"; import { create } from "zustand";
import { getToken, removeRefreshToken, removeToken } from "./func"; import { getToken, removeRefreshToken, removeToken } from "./func";
import { useAuthStore } from "@/pages/auth/store/AuthStore";
type SessionAuthStore = { type SessionAuthStore = {
isAuthenticated: boolean; isAuthenticated: boolean;
@@ -14,6 +15,7 @@ export const useSessionAuth = create<SessionAuthStore>((set) => ({
removeToken(); removeToken();
removeRefreshToken(); removeRefreshToken();
window.isRefreshTokenExpired = false; window.isRefreshTokenExpired = false;
useAuthStore.getState().reset();
set({ isAuthenticated: false }); set({ isAuthenticated: false });
}, },
})); }));
+7 -5
View File
@@ -17,13 +17,15 @@ export type StatusConfig = {
*/ */
export const statusRegistry: Record<string, StatusConfig> = { export const statusRegistry: Record<string, StatusConfig> = {
// --- ProformaInvoiceStatusEnum --- // --- ProformaInvoiceStatusEnum ---
[ProformaInvoiceStatusEnum.ALL]: { label: 'همه', variant: 'neutral' }, [ProformaInvoiceStatusEnum.PENDING]: { label: 'در انتظار تایید', variant: 'warning' },
[ProformaInvoiceStatusEnum.NOT_CONFIRMED]: { label: 'تایید نشده', variant: 'warning' },
[ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED]: { label: 'تایید جزئی', variant: 'info' },
[ProformaInvoiceStatusEnum.CONFIRMED]: { label: 'تایید شده', variant: 'success' }, [ProformaInvoiceStatusEnum.CONFIRMED]: { label: 'تایید شده', variant: 'success' },
[ProformaInvoiceStatusEnum.ARCHIVED]: { label: 'آرشیو شده', variant: 'neutral' },
// --- Invoice confirm status (from API; partially_confirmed/confirmed covered by enum above) --- // --- Invoice confirm status (from API) ---
pending: { label: 'تایید نشده', variant: 'warning' }, pending: { label: 'در انتظار تایید', variant: 'warning' },
partially_confirmed: { label: 'تایید جزئی', variant: 'info' },
confirmed: { label: 'تایید شده', variant: 'success' },
archived: { label: 'آرشیو شده', variant: 'neutral' },
// --- OrderStatusEnum --- // --- OrderStatusEnum ---
[OrderStatusEnum.CREATED]: { label: 'ایجاد شده', variant: 'info' }, [OrderStatusEnum.CREATED]: { label: 'ایجاد شده', variant: 'info' },
+3 -1
View File
@@ -35,7 +35,7 @@ export const fa = {
factureCount: "پیش فاکتور تایید نشده", factureCount: "پیش فاکتور تایید نشده",
orderCount: "سفارش های در حال انجام", orderCount: "سفارش های در حال انجام",
orderDoneCount: "سفارشات انجام شده", orderDoneCount: "سفارشات انجام شده",
services: "خدمات", products: حصولات",
submitNewOrder: "درخواست خودرا ثبت کنید", submitNewOrder: "درخواست خودرا ثبت کنید",
submitNewOrderButton: "درخواست جدید", submitNewOrderButton: "درخواست جدید",
specialSolution: "راه‌حل اختصاصی برای شما در نگاره!", specialSolution: "راه‌حل اختصاصی برای شما در نگاره!",
@@ -47,6 +47,8 @@ export const fa = {
freeConsultation: "دریافت مشاوره رایگان", freeConsultation: "دریافت مشاوره رایگان",
contactUs: "با ما تماس بگیرید", contactUs: "با ما تماس بگیرید",
phoneNumber: "۰۸۶۹۱۰۰۹۰۰۱", phoneNumber: "۰۸۶۹۱۰۰۹۰۰۱",
noProducts: "خدماتی برای نمایش وجود ندارد.",
inProgressOrders: "سفارش های در حال انجام",
}, },
rial: "ریال", rial: "ریال",
uploadBox: { uploadBox: {
+11 -4
View File
@@ -1,21 +1,28 @@
import { create } from "zustand"; import { create } from "zustand";
import { type AuthStoreType } from "../../auth/types/AuthTypes"; import { type AuthStoreType } from "../../auth/types/AuthTypes";
export const useAuthStore = create<AuthStoreType>((set) => ({ const initialState = {
phone: "", phone: "",
email: "",
stepLogin: 1,
devOtpCode: "",
};
export const useAuthStore = create<AuthStoreType>((set) => ({
...initialState,
setPhone(value) { setPhone(value) {
set({ phone: value }); set({ phone: value });
}, },
email: "",
setEmail(value) { setEmail(value) {
set({ email: value }); set({ email: value });
}, },
stepLogin: 1,
setStepLogin(value) { setStepLogin(value) {
set({ stepLogin: value }); set({ stepLogin: value });
}, },
devOtpCode: "",
setDevOtpCode(value) { setDevOtpCode(value) {
set({ devOtpCode: value }); set({ devOtpCode: value });
}, },
reset() {
set({ ...initialState });
},
})); }));
+1
View File
@@ -15,6 +15,7 @@ export type AuthStoreType = {
/** TODO: remove before production — dev-only OTP from API response */ /** TODO: remove before production — dev-only OTP from API response */
devOtpCode: string; devOtpCode: string;
setDevOtpCode: (value: string) => void; setDevOtpCode: (value: string) => void;
reset: () => void;
}; };
export type LoginWithPasswordType = { export type LoginWithPasswordType = {
+72 -30
View File
@@ -12,9 +12,11 @@ import {
Play, Play,
} from 'iconsax-react' } from 'iconsax-react'
import { import {
forwardRef,
useCallback, useCallback,
useEffect, useEffect,
useId, useId,
useImperativeHandle,
useRef, useRef,
useState, useState,
type ChangeEvent, type ChangeEvent,
@@ -47,12 +49,21 @@ type PendingVoice = {
status: 'uploading' | 'done' | 'error' status: 'uploading' | 'done' | 'error'
} }
export type ChatComposerHandle = {
getPayload: () => ChatComposerSubmitPayload
hasPendingUploads: () => boolean
hasUploadErrors: () => boolean
reset: () => void
}
type Props = { type Props = {
onSubmit: (payload: ChatComposerSubmitPayload) => void | Promise<void> onSubmit: (payload: ChatComposerSubmitPayload) => void | Promise<void>
isSubmitting?: boolean isSubmitting?: boolean
submitLabel?: string submitLabel?: string
label?: string label?: string
placeholder?: string placeholder?: string
allowEmptySubmit?: boolean
showSubmitButton?: boolean
replyTo?: { replyTo?: {
id: string id: string
content: string content: string
@@ -163,15 +174,17 @@ const VoicePreviewChip: FC<{
) )
} }
const ChatComposer: FC<Props> = ({ const ChatComposer = forwardRef<ChatComposerHandle, Props>(({
onSubmit, onSubmit,
isSubmitting = false, isSubmitting = false,
submitLabel = 'ارسال پیام', submitLabel = 'ارسال پیام',
label = 'پیام شما', label = 'پیام شما',
placeholder = 'متن پیام خود را بنویسید...', placeholder = 'متن پیام خود را بنویسید...',
allowEmptySubmit = false,
showSubmitButton = true,
replyTo = null, replyTo = null,
onCancelReply, onCancelReply,
}) => { }, ref) => {
const inputId = useId() const inputId = useId()
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null)
const [message, setMessage] = useState('') const [message, setMessage] = useState('')
@@ -318,7 +331,7 @@ const ChatComposer: FC<Props> = ({
}) })
} }
const handleSubmit = async () => { const buildPayload = useCallback((): ChatComposerSubmitPayload => {
const uploadedFiles = pendingFiles.filter( const uploadedFiles = pendingFiles.filter(
(item) => item.status === 'done' && item.key, (item) => item.status === 'done' && item.key,
) )
@@ -326,10 +339,49 @@ const ChatComposer: FC<Props> = ({
(item) => item.status === 'done' && item.key, (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 ( if (
!message.trim() && !allowEmptySubmit &&
uploadedFiles.length === 0 && !payload.content &&
uploadedVoices.length === 0 payload.attachments.length === 0
) { ) {
toast('لطفاً پیام یا فایل ضمیمه وارد کنید', 'error') toast('لطفاً پیام یا فایل ضمیمه وارد کنید', 'error')
return return
@@ -351,22 +403,8 @@ const ChatComposer: FC<Props> = ({
return return
} }
const attachments: ChatComposerAttachment[] = [
...uploadedFiles.map((item) => ({
type: 'uploads_attach',
url: item.key!,
})),
...uploadedVoices.map((item) => ({
type: 'voice',
url: item.key!,
})),
]
try { try {
await onSubmit({ await onSubmit(payload)
content: message.trim(),
attachments,
})
resetComposer() resetComposer()
} catch (error) { } catch (error) {
toast(extractErrorMessage(error), 'error') toast(extractErrorMessage(error), 'error')
@@ -526,16 +564,20 @@ const ChatComposer: FC<Props> = ({
</div> </div>
</div> </div>
<div className="mt-6 flex justify-end"> {showSubmitButton && (
<Button <div className="mt-6 flex justify-end">
label={submitLabel} <Button
onClick={handleSubmit} label={submitLabel}
className="w-[150px]" onClick={handleSubmit}
isLoading={isBusy} className="w-[150px]"
/> isLoading={isBusy}
</div> />
</div>
)}
</div> </div>
) )
} })
ChatComposer.displayName = 'ChatComposer'
export default ChatComposer export default ChatComposer
+5 -4
View File
@@ -10,7 +10,7 @@ import { getChatSenderName } from '../type/Types'
type Props = { type Props = {
content: string content: string
attachments?: ChatAttachmentType[] attachments?: ChatAttachmentType[] | null
senderName?: string senderName?: string
senderLabel?: string senderLabel?: string
createdAt?: string createdAt?: string
@@ -25,7 +25,7 @@ type Props = {
const ChatMessage: FC<Props> = ({ const ChatMessage: FC<Props> = ({
content, content,
attachments = [], attachments,
senderName, senderName,
senderLabel = 'پشتیبان', senderLabel = 'پشتیبان',
createdAt, createdAt,
@@ -37,8 +37,9 @@ const ChatMessage: FC<Props> = ({
parentSenderLabel, parentSenderLabel,
onReply, onReply,
}) => { }) => {
const fileAttachments = attachments.filter((a) => a.type !== 'voice') const safeAttachments = attachments ?? []
const voiceAttachments = attachments.filter((a) => a.type === 'voice') const fileAttachments = safeAttachments.filter((a) => a.type !== 'voice')
const voiceAttachments = safeAttachments.filter((a) => a.type === 'voice')
const handleOpenLink = async (key: string) => { const handleOpenLink = async (key: string) => {
const url = await getPresignedUrl(key) const url = await getPresignedUrl(key)
+1 -1
View File
@@ -29,7 +29,7 @@ export type ChatParentMessageType = {
export type ChatMessageType = { export type ChatMessageType = {
admin?: ChatParticipantType | null admin?: ChatParticipantType | null
user?: ChatParticipantType | null user?: ChatParticipantType | null
attachments: ChatAttachmentType[] attachments?: ChatAttachmentType[] | null
content: string content: string
createdAt: string createdAt: string
id: string id: string
+6 -70
View File
@@ -1,82 +1,18 @@
import { type FC } from 'react' import { type FC } from 'react'
import Stats from './components/Stats' import HomeSidebar from './components/HomeSidebar'
import Services from './components/Services'
import Orders from './components/Orders' import Orders from './components/Orders'
import NewOrderImage from '@/assets/images/new_order.png' import Services from './components/Services'
import { t } from '@/locale' import Stats from './components/Stats'
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'
const Home: FC = () => { const Home: FC = () => {
return ( return (
<div className='flex gap-6'> <div className='flex flex-col gap-4 md:gap-6 xl:flex-row'>
<div className='flex-1'> <div className='min-w-0 flex-1'>
<Stats /> <Stats />
<Services /> <Services />
<Orders /> <Orders />
</div> </div>
<div className='min-w-[267px] mt-5'> <HomeSidebar />
<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>
</div> </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 Table from '@/components/Table'
import { type FC } from 'react' import { t } from '@/locale'
import { type ColumnType } from '@/components/types/TableTypes'
import { type Order } from './types/OrderTypes'
import { useGetMyOrders } from '@/pages/order/hooks/useOrderData'
import { TabMyOrdersEnum } from '@/pages/order/enum/OrderEnum' import { TabMyOrdersEnum } from '@/pages/order/enum/OrderEnum'
import { orderStatusLabels } from '@/pages/order/constants/statusLabels' import { useGetMyOrders } from '@/pages/order/hooks/useOrderData'
import moment from 'moment-jalaali' import { type FC } from 'react'
import { import HomeSection from './HomeSection'
OrderNumberRenderer, import { mapOrderToRow, orderColumns } from './orderColumns'
OrderTitleRenderer, import { type Order } from './types/OrderTypes'
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
})
const Orders: FC = () => { const Orders: FC = () => {
const { data, isLoading } = useGetMyOrders(1, TabMyOrdersEnum.IN_PROGRESS) const { data, isLoading } = useGetMyOrders(1, TabMyOrdersEnum.IN_PROGRESS)
const orders: Order[] = (data?.data ?? []).map(mapOrderToRow) 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 ( return (
<div className='mt-6 bg-white p-6 rounded-3xl'> <HomeSection
<h4 className='text-lg font-light'> title={t('home.inProgressOrders')}
سفارش های در حال انجام className='mt-4 md:mt-6'
</h4> contentClassName='mt-0 -mx-2 md:mx-0'
>
<Table<Order> <div className='mt-4 overflow-x-auto md:mt-6'>
columns={columns} <Table<Order>
data={orders} columns={orderColumns}
isLoading={isLoading} data={orders}
/> isLoading={isLoading}
</div> />
</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 PresignedImage from '@/components/PresignedImage'
import { type ProductType } from '@/pages/request/type/Types'
import { type FC } from 'react'
const ServiceItem: FC<{ product: ProductType }> = ({ product }) => { const ServiceItem: FC<{ product: ProductType }> = ({ product }) => {
return ( return (
<div> <div className='flex w-[88px] shrink-0 snap-start flex-col items-center gap-2'>
<div className='size-20 rounded-full bg-gray-200 border-[3px] border-primary overflow-hidden'> <div className='size-20 overflow-hidden rounded-full border-[3px] border-primary bg-gray-200'>
{product.images?.[0] ? ( {product.images?.[0] ? (
<PresignedImage <PresignedImage
src={product.images[0]} src={product.images[0]}
alt={product.title} alt={product.title}
className='w-full h-full object-cover' className='h-full w-full object-cover'
/> />
) : null} ) : null}
</div> </div>
<span className='line-clamp-2 w-full text-center text-xs font-light text-[#292D32]'>
{product.title}
</span>
</div> </div>
) )
} }
+109 -29
View File
@@ -1,40 +1,120 @@
import { t } from '@/locale' import { t } from '@/locale'
import { type FC } from 'react' import {
import { Swiper, SwiperSlide } from 'swiper/react' 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 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 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 ( return (
<div className='mt-6 bg-white p-6 rounded-3xl'> <HomeSection title={t('home.products')} className='mt-4 md:mt-6' contentClassName='mt-0'>
<h4 className='text-lg font-light'> <div className='mt-4 space-y-4 md:mt-6 md:space-y-6'>
{t('home.services')} {isCategoriesLoading ? (
</h4> <CategoryTabsSkeleton />
) : categories.length > 0 ? (
<CategoryTabs
categories={categories}
levels={levelsToShow}
selectedPath={selectedPath}
onSelect={handleCategorySelect}
/>
) : null}
<div className='mt-6'> <div className='-mx-4 overflow-x-auto px-4 pb-1 md:mx-0 md:px-0'>
<Swiper {isLoading ? (
spaceBetween={16} <ServicesSkeleton />
slidesPerView={'auto'} ) : products.length === 0 ? (
className='h-full' <p className='text-sm font-light text-[#7B7E8B]'>
breakpoints={{ {t('home.noProducts')}
640: { </p>
spaceBetween: 24, ) : (
}, <div className='flex w-max snap-x snap-mandatory gap-4 md:gap-6'>
}} {products.map((item) => (
> <ServiceItem key={item.id} product={item} />
{ ))}
data?.data.map((item) => ( </div>
<SwiperSlide className='w-auto!' key={item.id}> )}
<ServiceItem product={item} /> </div>
</SwiperSlide>
))
}
</Swiper>
</div> </div>
</HomeSection>
</div>
) )
} }
@@ -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' import { Link } from 'react-router-dom'
type Props = { type Props = {
icon: ReactNode, icon: ReactNode
count: number | undefined, count: number | undefined
description: string, description: string
to: string, to: string
} }
const StatCard: FC<Props> = (props) => { const StatCard: FC<Props> = ({ icon, count, description, to }) => {
const { icon, count, description, to } = props
return ( return (
<Link <Link
to={to} 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} {icon}
<div className='size-8 bg-[#FFF1D7] rounded-full flex justify-center items-center'> <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' /> <ArrowLeft
size={16}
color={COLORS.primary}
className='rotate-45'
/>
</div> </div>
</div> </div>
<div className='mt-2 text-2xl font-semibold'> <div className='mt-2 text-xl font-semibold md:text-2xl'>
{count} {count ?? 0}
</div> </div>
<div className='mt-2 text-sm'> <div className='mt-1 text-xs md:mt-2 md:text-sm'>{description}</div>
{description}
</div>
</Link> </Link>
) )
} }
+2 -2
View File
@@ -19,7 +19,7 @@ const Stats: FC = () => {
mobile={2} mobile={2}
gapDesktop={24} gapDesktop={24}
gapMobile={12} gapMobile={12}
className='mt-5' className='mt-4 md:mt-5'
> >
<StatCard <StatCard
count={data?.data.requestsCount} count={data?.data.requestsCount}
@@ -33,7 +33,7 @@ const Stats: FC = () => {
<StatCard <StatCard
count={data?.data.invoicesCount} count={data?.data.invoicesCount}
description={t('home.factureCount')} description={t('home.factureCount')}
to={`${Paths.proformaInvoice}?tab=${ProformaInvoiceStatusEnum.NOT_CONFIRMED}`} to={`${Paths.proformaInvoice}?tab=${ProformaInvoiceStatusEnum.PENDING}`}
icon={<ReceiptText icon={<ReceiptText
size={27} size={27}
color={COLORS.primary} color={COLORS.primary}
@@ -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',
},
]
+54 -45
View File
@@ -1,10 +1,14 @@
import Tabs from '@/components/Tabs' import Tabs from '@/components/Tabs'
import { useMemo, useState, type FC } from 'react' import { useMemo, useState, type FC } from 'react'
import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { Link, useNavigate, useSearchParams } from 'react-router-dom'
import { ProformaInvoiceStatusEnum } from './enum/InvoiceEnum' import {
invoiceListTabs,
invoiceListTabStatuses,
ProformaInvoiceStatusEnum,
} from './enum/InvoiceEnum'
import Filters from '@/components/Filters' import Filters from '@/components/Filters'
import Table from '@/components/Table' import Table from '@/components/Table'
import { useGetInvoices } from './hooks/useInvoiceData' import { useGetInvoices, useGetInvoiceTabCounts } from './hooks/useInvoiceData'
import type { Invoice } from './types/InvoiceTypes' import type { Invoice } from './types/InvoiceTypes'
import type { ColumnType } from '@/components/types/TableTypes' import type { ColumnType } from '@/components/types/TableTypes'
import type { FilterValues } from '@/components/Filters' import type { FilterValues } from '@/components/Filters'
@@ -14,11 +18,10 @@ import moment from 'moment-jalaali'
import StatusWithText from '@/components/StatusWithText' import StatusWithText from '@/components/StatusWithText'
import { Eye } from 'iconsax-react' import { Eye } from 'iconsax-react'
const INVOICE_TABS = invoiceListTabs.map((tab) => tab.value)
const isInvoiceTab = (value: string | null): value is ProformaInvoiceStatusEnum => const isInvoiceTab = (value: string | null): value is ProformaInvoiceStatusEnum =>
value === ProformaInvoiceStatusEnum.ALL || value !== null && INVOICE_TABS.includes(value as ProformaInvoiceStatusEnum)
value === ProformaInvoiceStatusEnum.NOT_CONFIRMED ||
value === ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED ||
value === ProformaInvoiceStatusEnum.CONFIRMED
const getServiceNames = (invoice: Invoice): string => { const getServiceNames = (invoice: Invoice): string => {
const names = [...new Set(invoice.items.map((item) => item.product.title))] const names = [...new Set(invoice.items.map((item) => item.product.title))]
@@ -27,29 +30,53 @@ const getServiceNames = (invoice: Invoice): string => {
const ProformaInvoice: FC = () => { const ProformaInvoice: FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
const [searchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const tabFromQuery = searchParams.get('tab') const tabFromQuery = searchParams.get('tab')
const [activeTab, setActiveTab] = useState<ProformaInvoiceStatusEnum>( const activeTab = isInvoiceTab(tabFromQuery)
isInvoiceTab(tabFromQuery) ? tabFromQuery : ProformaInvoiceStatusEnum.ALL, ? tabFromQuery
) : ProformaInvoiceStatusEnum.PENDING
const [filters, setFilters] = useState<FilterValues>({}) const [filters, setFilters] = useState<FilterValues>({})
const { data, isPending } = useGetInvoices() const statuses = invoiceListTabStatuses[activeTab]
const setActiveTab = (tab: ProformaInvoiceStatusEnum) => {
setSearchParams(
(current) => {
const next = new URLSearchParams(current)
next.set('tab', tab)
return next
},
{ replace: true },
)
}
const { data, isPending } = useGetInvoices({ statuses })
const { data: tabCounts } = useGetInvoiceTabCounts()
const invoices = useMemo(() => data?.data ?? [], [data?.data]) const invoices = useMemo(() => data?.data ?? [], [data?.data])
const tabsWithCount = useMemo(
() =>
invoiceListTabs.map((tab) => {
const total =
tab.value === ProformaInvoiceStatusEnum.PENDING
? (tabCounts?.pending ?? 0)
: tab.value === ProformaInvoiceStatusEnum.CONFIRMED
? (tabCounts?.confirmed ?? 0)
: (tabCounts?.archived ?? 0)
return {
...tab,
label:
total > 0
? `${tab.label} (${total.toLocaleString('fa-IR')})`
: tab.label,
}
}),
[tabCounts],
)
const filteredInvoices = useMemo(() => { const filteredInvoices = useMemo(() => {
let result = invoices let result = invoices
// فیلتر تب
if (activeTab === ProformaInvoiceStatusEnum.NOT_CONFIRMED) {
result = result.filter((inv) => inv.confirmStatus === 'pending')
} else if (activeTab === ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED) {
result = result.filter((inv) => inv.confirmStatus === 'partially_confirmed')
} else if (activeTab === ProformaInvoiceStatusEnum.CONFIRMED) {
result = result.filter((inv) => inv.confirmStatus === 'confirmed')
}
// فیلتر جستجو
const search = filters.search?.toString().trim() const search = filters.search?.toString().trim()
if (search) { if (search) {
const lower = search.toLowerCase() const lower = search.toLowerCase()
@@ -62,7 +89,6 @@ const ProformaInvoice: FC = () => {
) )
} }
// فیلتر تاریخ
const dateFilter = filters.date?.toString() const dateFilter = filters.date?.toString()
if (dateFilter) { if (dateFilter) {
result = result.filter((inv) => { result = result.filter((inv) => {
@@ -72,7 +98,7 @@ const ProformaInvoice: FC = () => {
} }
return result return result
}, [invoices, activeTab, filters]) }, [invoices, filters])
const columns: ColumnType<Invoice>[] = [ const columns: ColumnType<Invoice>[] = [
{ {
@@ -101,15 +127,16 @@ const ProformaInvoice: FC = () => {
render: (item) => <span>{NumberFormat(item.total)} ریال</span>, render: (item) => <span>{NumberFormat(item.total)} ریال</span>,
}, },
{ {
key: 'confirmStatus', key: 'status',
title: 'وضعیت تایید', title: 'وضعیت تایید',
render: (item) => { render: (item) => {
const statusMap = { const statusMap = {
confirmed: { variant: 'success' as const, text: 'تایید شده' }, confirmed: { variant: 'success' as const, text: 'تایید شده' },
partially_confirmed: { variant: 'info' as const, text: 'تایید جزئی' }, partially_confirmed: { variant: 'info' as const, text: 'تایید جزئی' },
pending: { variant: 'warning' as const, text: 'تایید نشده' }, pending: { variant: 'warning' as const, text: 'در انتظار تایید' },
archived: { variant: 'info' as const, text: 'آرشیو شده' },
} }
const status = statusMap[item.confirmStatus] ?? statusMap.pending const status = statusMap[item.status] ?? statusMap.pending
return ( return (
<StatusWithText <StatusWithText
variant={status.variant} variant={status.variant}
@@ -118,19 +145,6 @@ const ProformaInvoice: FC = () => {
) )
}, },
}, },
{
key: 'paymentStatus',
title: 'وضعیت پرداخت',
render: (item) => {
const isPaid = item.paidAmount >= item.total && item.total > 0
return (
<StatusWithText
variant={isPaid ? 'success' : 'info'}
text={isPaid ? 'پرداخت شده' : 'پرداخت نشده'}
/>
)
},
},
{ {
key: 'action', key: 'action',
title: '', title: '',
@@ -157,12 +171,7 @@ const ProformaInvoice: FC = () => {
<div className="mt-8"> <div className="mt-8">
<Tabs <Tabs
items={[ items={tabsWithCount}
{ label: 'همه', value: ProformaInvoiceStatusEnum.ALL },
{ label: 'تایید نشده', value: ProformaInvoiceStatusEnum.NOT_CONFIRMED },
{ label: 'تایید جزئی', value: ProformaInvoiceStatusEnum.PARTIALLY_CONFIRMED },
{ label: 'تایید شده', value: ProformaInvoiceStatusEnum.CONFIRMED },
]}
activeTab={activeTab} activeTab={activeTab}
onTabChange={(tab) => setActiveTab(tab as ProformaInvoiceStatusEnum)} onTabChange={(tab) => setActiveTab(tab as ProformaInvoiceStatusEnum)}
/> />
+23 -3
View File
@@ -1,6 +1,26 @@
export const enum ProformaInvoiceStatusEnum { export const enum ProformaInvoiceStatusEnum {
ALL = 'all', PENDING = 'pending',
NOT_CONFIRMED = 'not_confirmed',
PARTIALLY_CONFIRMED = 'partially_confirmed',
CONFIRMED = 'confirmed', CONFIRMED = 'confirmed',
ARCHIVED = 'archived',
} }
export type InvoiceConfirmStatus =
| 'pending'
| 'partially_confirmed'
| 'confirmed'
| 'archived'
export const invoiceListTabStatuses: Record<
ProformaInvoiceStatusEnum,
InvoiceConfirmStatus[]
> = {
[ProformaInvoiceStatusEnum.PENDING]: ['pending'],
[ProformaInvoiceStatusEnum.CONFIRMED]: ['partially_confirmed', 'confirmed'],
[ProformaInvoiceStatusEnum.ARCHIVED]: ['archived'],
}
export const invoiceListTabs = [
{ label: 'در انتظار تایید', value: ProformaInvoiceStatusEnum.PENDING },
{ label: 'تایید شده', value: ProformaInvoiceStatusEnum.CONFIRMED },
{ label: 'آرشیو شده', value: ProformaInvoiceStatusEnum.ARCHIVED },
] as const
+19 -4
View File
@@ -1,10 +1,18 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import * as api from "../service/InvoiceService"; import * as api from "../service/InvoiceService";
export const useGetInvoices = () => { export const useGetInvoices = (params: api.GetInvoicesParams) => {
return useQuery({ return useQuery({
queryKey: ["invoices"], queryKey: ["invoices", params.statuses?.join(",") ?? ""],
queryFn: api.getInvoices, queryFn: () => api.getInvoices(params),
});
};
export const useGetInvoiceTabCounts = () => {
return useQuery({
queryKey: ["invoice-tab-counts"],
queryFn: () => api.getInvoiceTabCounts(),
refetchInterval: 20_000,
}); });
}; };
@@ -17,7 +25,14 @@ export const useGetInvoiceDetail = (id: string) => {
}; };
export const useConfirmInvoiceItem = () => { export const useConfirmInvoiceItem = () => {
const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: api.confrimInvoiceItem, mutationFn: api.confrimInvoiceItem,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["invoices"] });
queryClient.invalidateQueries({ queryKey: ["invoice"] });
queryClient.invalidateQueries({ queryKey: ["invoice-tab-counts"] });
},
}); });
}; };
+28 -2
View File
@@ -3,12 +3,38 @@ import type {
InvoicesResponseType, InvoicesResponseType,
InvoiceDetailResponseType, InvoiceDetailResponseType,
} from "../types/InvoiceTypes"; } from "../types/InvoiceTypes";
import type { InvoiceConfirmStatus } from "../enum/InvoiceEnum";
import type { BaseResponse } from "@/shared/types/Types";
export const getInvoices = async (): Promise<InvoicesResponseType> => { export type GetInvoicesParams = {
const { data } = await axios.get<InvoicesResponseType>("/public/invoice"); statuses?: InvoiceConfirmStatus[];
};
export type InvoiceTabCountsResponseType = {
pending: number;
confirmed: number;
archived: number;
};
export const getInvoices = async (
params: GetInvoicesParams = {},
): Promise<InvoicesResponseType> => {
const { statuses } = params;
const { data } = await axios.get<InvoicesResponseType>("/public/invoice", {
params: {
...(statuses?.length ? { statuses: statuses.join(",") } : {}),
},
});
return data; return data;
}; };
export const getInvoiceTabCounts = async (): Promise<InvoiceTabCountsResponseType> => {
const { data } = await axios.get<BaseResponse<InvoiceTabCountsResponseType>>(
"/public/invoice/tab-counts",
);
return data.data;
};
export const getInvoiceDetail = async ( export const getInvoiceDetail = async (
id: string, id: string,
): Promise<InvoiceDetailResponseType> => { ): Promise<InvoiceDetailResponseType> => {
+1 -1
View File
@@ -55,7 +55,7 @@ export type Invoice = {
total: number; total: number;
paidAmount: number; paidAmount: number;
balance: number; balance: number;
confirmStatus: 'pending' | 'partially_confirmed' | 'confirmed'; status: 'pending' | 'partially_confirmed' | 'confirmed' | 'archived';
enableTax: boolean; enableTax: boolean;
approvalDeadline: string; approvalDeadline: string;
attachments: unknown[]; attachments: unknown[];
+1 -1
View File
@@ -34,7 +34,7 @@ const MyOrders: FC = () => {
items={[ items={[
{ label: 'همه', value: TabMyOrdersEnum.ALL }, { label: 'همه', value: TabMyOrdersEnum.ALL },
{ label: 'در حال انجام', value: TabMyOrdersEnum.IN_PROGRESS }, { label: 'در حال انجام', value: TabMyOrdersEnum.IN_PROGRESS },
{ label: 'تکمیل داده شده', value: TabMyOrdersEnum.DELIVERED }, { label: 'تکمیل شده', value: TabMyOrdersEnum.DELIVERED },
{ label: 'کنسل شده', value: TabMyOrdersEnum.CANCELLED }, { label: 'کنسل شده', value: TabMyOrdersEnum.CANCELLED },
]} ]}
activeTab={activeTab} activeTab={activeTab}
+168
View File
@@ -0,0 +1,168 @@
import { useState, type FC } from 'react'
import Input from '@/components/Input'
import Textarea from '@/components/Textarea'
import UploadBox from '@/components/UploadBox'
import PresignedImage from '@/components/PresignedImage'
import { useFormik } from 'formik'
import * as Yup from 'yup'
import Button from '@/components/Button'
import Error from '@/components/Error'
import { useGetMe, useUpdateProfile } from '@/pages/user/hooks/useUserData'
import { useSingleUpload } from '@/pages/uploader/hooks/useUploader'
import { toast } from '@/shared/toast'
import { extractErrorMessage } from '@/config/func'
import type { UpdateProfileType } from '@/pages/user/types/Types'
import { Edit } from 'iconsax-react'
const Profile: FC = () => {
const { data: user, isLoading } = useGetMe()
const updateProfile = useUpdateProfile()
const { mutate: upload, isPending: isUploading } = useSingleUpload()
const [file, setFile] = useState<File>()
const handleSave = (values: UpdateProfileType) => {
updateProfile.mutate(
{
firstName: values.firstName.trim(),
lastName: values.lastName.trim(),
address: values.address?.trim() || undefined,
...(values.avatarUrl ? { avatarUrl: values.avatarUrl } : {}),
},
{
onSuccess() {
toast('پروفایل با موفقیت ویرایش شد', 'success')
},
onError(error) {
toast(extractErrorMessage(error), 'error')
},
},
)
}
const formik = useFormik<UpdateProfileType>({
enableReinitialize: true,
initialValues: {
firstName: user?.firstName ?? '',
lastName: user?.lastName ?? '',
address: user?.addresse ?? '',
avatarUrl: user?.avatarUrl ?? undefined,
},
validationSchema: Yup.object({
firstName: Yup.string()
.trim()
.required('نام اجباری است.'),
lastName: Yup.string()
.trim()
.required('نام خانوادگی اجباری است.'),
}),
onSubmit(values) {
if (file) {
upload(file, {
onSuccess(data) {
handleSave({
...values,
avatarUrl: data?.data?.key,
})
},
onError(error) {
toast(extractErrorMessage(error), 'error')
},
})
} else {
handleSave(values)
}
},
})
if (isLoading) {
return (
<div className='mt-5 flex h-64 items-center justify-center'>
<div className='text-lg'>در حال بارگذاری...</div>
</div>
)
}
return (
<div className='mt-5'>
<div className='flex items-center justify-between'>
<h1 className='text-lg font-light'>پروفایل</h1>
<Button
className='w-fit px-6'
onClick={() => formik.handleSubmit()}
isLoading={updateProfile.isPending || isUploading}
>
<div className='flex gap-1.5'>
<Edit size={18} color='black' />
<div className='text-[13px] font-light'>ذخیره تغییرات</div>
</div>
</Button>
</div>
<div className='mt-8 flex-1 rounded-3xl bg-white p-6'>
{formik.values.avatarUrl && !file && (
<PresignedImage
src={formik.values.avatarUrl}
className='mb-4 h-20 w-20 rounded-full object-cover'
alt='آواتار'
/>
)}
<UploadBox
label='آواتار (اختیاری)'
onChange={(files) => setFile(files[0])}
/>
<div className='rowTwoInput mt-6'>
<div>
<Input
label='نام'
placeholder='نام خود را وارد کنید'
name='firstName'
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.firstName}
/>
{formik.touched.firstName && formik.errors.firstName && (
<Error errorText={formik.errors.firstName} />
)}
</div>
<div>
<Input
label='نام خانوادگی'
placeholder='نام خانوادگی خود را وارد کنید'
name='lastName'
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.lastName}
/>
{formik.touched.lastName && formik.errors.lastName && (
<Error errorText={formik.errors.lastName} />
)}
</div>
</div>
<div className='mt-6'>
<Input
label='شماره موبایل'
value={user?.phone ? `0${user.phone}` : ''}
disabled
/>
</div>
<div className='mt-6'>
<Textarea
label='آدرس'
placeholder='آدرس خود را وارد کنید'
name='address'
onChange={formik.handleChange}
onBlur={formik.handleBlur}
value={formik.values.address ?? ''}
/>
</div>
</div>
</div>
)
}
export default Profile
-21
View File
@@ -1,21 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import axios from "@/config/axios";
export interface ProfileData {
user: {
firstName: string;
lastName: string;
email: string;
profilePic?: string;
};
}
export const useGetProfile = () => {
return useQuery({
queryKey: ["profile"],
queryFn: async (): Promise<{ data: ProfileData }> => {
const response = await axios.get("/profile");
return response.data;
},
});
};
+68 -26
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 Request from './components/Request'
import RequestItemsList from './components/RequestItemsList' import RequestItemsList from './components/RequestItemsList'
import { useRequestStore } from './store/RequestStore' import { useRequestStore } from './store/RequestStore'
@@ -9,6 +9,10 @@ import { Paths } from '@/config/Paths'
import { extractErrorMessage } from '@/config/func' import { extractErrorMessage } from '@/config/func'
import { ArrowRight2 } from 'iconsax-react' import { ArrowRight2 } from 'iconsax-react'
import ModalConfirm from '@/components/ModalConfirm' import ModalConfirm from '@/components/ModalConfirm'
import ChatComposer, {
type ChatComposerHandle,
type ChatComposerSubmitPayload,
} from '@/pages/chat/components/ChatComposer'
const NewRequest: FC = () => { const NewRequest: FC = () => {
const navigate = useNavigate() const navigate = useNavigate()
@@ -17,6 +21,8 @@ const NewRequest: FC = () => {
const [editingIndex, setEditingIndex] = useState<number | null>(null) const [editingIndex, setEditingIndex] = useState<number | null>(null)
const [formKey, setFormKey] = useState(0) const [formKey, setFormKey] = useState(0)
const [showConfirmModal, setShowConfirmModal] = useState(false) const [showConfirmModal, setShowConfirmModal] = useState(false)
const composerRef = useRef<ChatComposerHandle>(null)
const [pendingNote, setPendingNote] = useState<ChatComposerSubmitPayload | null>(null)
useEffect(() => { useEffect(() => {
setItems([]) setItems([])
@@ -42,59 +48,79 @@ const NewRequest: FC = () => {
} }
} }
const handleEdit = (index: number) => { const handleEdit = (index: number) => setEditingIndex(index)
setEditingIndex(index)
}
const openSubmitConfirm = () => { const openSubmitConfirm = () => {
if (items.length === 0) { if (items.length === 0) {
toast('حداقل یک قلم باید به درخواست اضافه کنید.', 'error') toast('حداقل یک قلم باید به درخواست اضافه کنید.', 'error')
return return
} }
if (composerRef.current?.hasPendingUploads()) {
toast('لطفاً تا پایان آپلود فایل‌ها صبر کنید', 'error')
return
}
if (composerRef.current?.hasUploadErrors()) {
toast('برخی فایل‌ها آپلود نشدند. آن‌ها را حذف یا دوباره انتخاب کنید', 'error')
return
}
setPendingNote(composerRef.current?.getPayload() ?? null)
setShowConfirmModal(true) setShowConfirmModal(true)
} }
const onSubmit = () => { const onSubmit = () => {
submitRequest(items, { submitRequest(
onSuccess: () => { {
setShowConfirmModal(false) items,
setItems([]) ...(pendingNote?.content ? { description: pendingNote.content } : {}),
setEditingIndex(null) ...(pendingNote?.attachments?.length ? { attachments: pendingNote.attachments } : {}),
setFormKey((k) => k + 1)
toast('سفارش با موفقیت ثبت شد', 'success')
navigate(Paths.myRequests, { state: { refresh: true } })
}, },
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 ( return (
<div className='mt-4 pb-12'> <div className='pb-16'>
<div className='flex items-center justify-between gap-4'> {/* Page header */}
<h1 className='text-lg font-light'>درخواست جدید</h1> <div className='flex items-center justify-between gap-4 mb-1'>
<h1 className='text-lg font-semibold'>درخواست جدید</h1>
<Link <Link
to={Paths.myRequests} to={Paths.myRequests}
className='flex items-center gap-1 text-sm text-description hover:text-black shrink-0' className='flex items-center gap-1 text-sm text-description hover:text-black transition-colors shrink-0'
> >
<ArrowRight2 size={18} color='currentColor' /> <ArrowRight2 size={18} color='currentColor' />
بازگشت به لیست بازگشت به لیست
</Link> </Link>
</div> </div>
<p className='text-description text-xs mt-2 leading-5'> <p className='text-description text-xs leading-6 mt-1 max-w-xl'>
برای هر محصول یک قلم اضافه کنید، سپس درخواست را ثبت نهایی کنید. برای هر محصول یک قلم اضافه کنید. در صورت نیاز توضیحات یا فایل را در بخش پایین وارد کنید و از دکمه «ثبت نهایی درخواست» استفاده کنید.
</p> </p>
<div className='flex flex-col-reverse xl:flex-row gap-6 xl:mt-8 mt-4'> {/* Main content */}
<div className='flex-1 min-w-0'> <div className='flex flex-col xl:flex-row gap-6 mt-6'>
{/* Left column: form + notes */}
<div className='flex-1 min-w-0 flex flex-col gap-6'>
<Request <Request
key={editingIndex !== null ? `edit-${editingIndex}` : `new-${formKey}`} key={editingIndex !== null ? `edit-${editingIndex}` : `new-${formKey}`}
editIndex={editingIndex} editIndex={editingIndex}
initialItem={ initialItem={editingIndex !== null ? items[editingIndex] : undefined}
editingIndex !== null ? items[editingIndex] : undefined
}
onSaved={handleSaved} onSaved={handleSaved}
onCancelEdit={ onCancelEdit={
editingIndex !== null editingIndex !== null
@@ -105,8 +131,24 @@ const NewRequest: FC = () => {
: undefined : undefined
} }
/> />
<section className='bg-white rounded-3xl p-6'>
<h2 className='text-sm font-medium mb-1'>توضیحات درخواست</h2>
<p className='text-description text-xs mb-5 leading-6'>
توضیحات، فایل یا پیام صوتی خود را اینجا بنویسید.
</p>
<ChatComposer
ref={composerRef}
onSubmit={async () => undefined}
showSubmitButton={false}
label='پیام شما'
placeholder='توضیحات یا درخواست خود را بنویسید...'
allowEmptySubmit
/>
</section>
</div> </div>
{/* Right column: items list (sticky on xl) */}
<RequestItemsList <RequestItemsList
items={items} items={items}
editingIndex={editingIndex} editingIndex={editingIndex}
@@ -0,0 +1,112 @@
import { type FC } from 'react'
import { clx } from '@/helpers/utils'
import PresignedImage from '@/components/PresignedImage'
import placeholderImage from '@/assets/images/placeholder-product.svg'
export type AvatarSelectionOption = {
id: string
title: string
imageUrl?: string
}
type Props = {
label: string
items: AvatarSelectionOption[]
selectedId?: string
onSelect: (id: string) => void
isLoading?: boolean
emptyMessage?: string
error_text?: string
}
const SKELETON_COUNT = 6
const AvatarSelectionGrid: FC<Props> = ({
label,
items,
selectedId,
onSelect,
isLoading = false,
emptyMessage = 'موردی یافت نشد',
error_text,
}) => {
return (
<div className='w-full'>
<label className='text-sm font-medium text-primary-content'>{label}</label>
<div className='mt-3 w-full overflow-x-auto scrollbar-thin scrollbar-thumb-gray-200 scrollbar-track-transparent pb-1'>
{isLoading ? (
<div className='flex flex-nowrap gap-3'>
{Array.from({ length: SKELETON_COUNT }).map((_, index) => (
<div key={index} className='flex w-[72px] shrink-0 flex-col items-center gap-2'>
<div className='size-14 animate-pulse rounded-full bg-gray-200' />
<div className='h-6 w-full animate-pulse rounded-md bg-gray-100' />
</div>
))}
</div>
) : items.length === 0 ? (
<p className='text-sm font-light text-[#7B7E8B] py-2'>{emptyMessage}</p>
) : (
<div className='flex flex-nowrap gap-3'>
{items.map((item) => {
const isSelected = selectedId === item.id
return (
<button
key={item.id}
type='button'
onClick={() => onSelect(item.id)}
className={clx(
'flex w-[72px] shrink-0 flex-col items-center gap-2 text-center rounded-2xl p-1.5 transition-all',
isSelected
? 'bg-primary/8'
: 'hover:bg-gray-50',
)}
>
<div
className={clx(
'size-14 overflow-hidden rounded-full border-2 bg-[#F5F7FC] transition-all',
isSelected
? 'border-primary shadow-sm shadow-primary/20'
: 'border-transparent',
)}
>
{item.imageUrl ? (
<PresignedImage
src={item.imageUrl}
alt={item.title}
className='size-full object-cover'
/>
) : (
<img
src={placeholderImage}
alt={item.title}
className='size-full object-cover'
/>
)}
</div>
<span
className={clx(
'line-clamp-2 w-full text-[11px] leading-4',
isSelected ? 'text-primary font-medium' : 'text-[#292D32] font-light',
)}
>
{item.title}
</span>
</button>
)
})}
</div>
)}
</div>
{error_text && (
<p className='mt-2 mr-1 text-right text-xs font-medium text-red-500'>
{error_text}
</p>
)}
</div>
)
}
export default AvatarSelectionGrid
@@ -1,5 +1,5 @@
import { memo, useCallback, useRef, type FC } from 'react' 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 { clx } from '@/helpers/utils'
import { FieldTypeEnum } from '../enum/RequestEnum' import { FieldTypeEnum } from '../enum/RequestEnum'
import Select from '@/components/Select' import Select from '@/components/Select'
@@ -12,7 +12,7 @@ import type { FormikProps } from 'formik'
type Props = { type Props = {
attributes?: AttributeType[], attributes?: AttributeType[],
formik: FormikProps<RequestType> formik: FormikProps<RequestItemType>
} }
type AttributeFieldProps = { type AttributeFieldProps = {
+58 -108
View File
@@ -1,95 +1,39 @@
import { type ChangeEvent, type FC, type SelectHTMLAttributes, useEffect, useMemo, useState } from 'react' import { type ChangeEvent, type FC, type SelectHTMLAttributes, useEffect, useMemo, useState } from 'react'
import { useGetCategories, useGetProducts } from '../hooks/useRequestData' import { useGetCategories, useGetProducts } from '../hooks/useRequestData'
import Select from '@/components/Select'
import type { CategoryType, ProductType } from '../type/Types' import type { CategoryType, ProductType } from '../type/Types'
import {
extractList,
findCategoryById,
findCategoryPath,
getCategoriesAtLevel,
getLevelsToShow,
} from '../utils/categoryUtils'
import AvatarSelectionGrid from './AvatarSelectionGrid'
type Props = { type Props = {
error_text?: string, error_text?: string
onProductSelect?: (product: ProductType | undefined) => void, onProductSelect?: (product: ProductType | undefined) => void
} & SelectHTMLAttributes<HTMLSelectElement> } & 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 ProductsSelect: FC<Props> = (props) => {
const { error_text, onProductSelect, value, onChange, ...rest } = props const { error_text, onProductSelect, value, onChange } = props
const [selectedPath, setSelectedPath] = useState<string[]>([]) const [selectedPath, setSelectedPath] = useState<string[]>([])
const { data: categoriesData } = useGetCategories() const { data: categoriesData, isLoading: isCategoriesLoading } = useGetCategories()
const categories = useMemo( const categories = useMemo(
() => extractList<CategoryType>(categoriesData), () => extractList<CategoryType>(categoriesData),
[categoriesData], [categoriesData],
) )
const levelsToShow = useMemo(() => { const levelsToShow = useMemo(
const levels = [0] () => getLevelsToShow(categories, selectedPath),
[categories, selectedPath],
for (let level = 0; level < selectedPath.length; level++) { )
const category = findCategoryById(categories, selectedPath[level])
if (category?.children?.length) {
levels.push(level + 1)
}
}
return levels
}, [categories, selectedPath])
const activeCategoryId = selectedPath.at(-1) ?? '' const activeCategoryId = selectedPath.at(-1) ?? ''
const canLoadProducts = !!activeCategoryId const canLoadProducts = !!activeCategoryId
const { data: productsData } = useGetProducts(activeCategoryId || undefined, { const { data: productsData, isLoading: isProductsLoading } = useGetProducts(activeCategoryId || undefined, {
enabled: canLoadProducts, enabled: canLoadProducts,
}) })
@@ -108,14 +52,7 @@ const ProductsSelect: FC<Props> = (props) => {
[resolveProductsData], [resolveProductsData],
) )
const productItems = useMemo( // Resolve initial selectedPath when editing an existing item
() => products.map((product) => ({
label: product.title,
value: product.id,
})),
[products],
)
useEffect(() => { useEffect(() => {
if (!value || selectedPath.length || !categories.length) return if (!value || selectedPath.length || !categories.length) return
@@ -132,6 +69,7 @@ const ProductsSelect: FC<Props> = (props) => {
setSelectedPath(path) setSelectedPath(path)
}, [value, resolveProducts, categories, selectedPath.length]) }, [value, resolveProducts, categories, selectedPath.length])
// Emit onProductSelect whenever the selected product changes
useEffect(() => { useEffect(() => {
if (!value) { if (!value) {
onProductSelect?.(undefined) onProductSelect?.(undefined)
@@ -150,47 +88,59 @@ const ProductsSelect: FC<Props> = (props) => {
} as ChangeEvent<HTMLSelectElement>) } as ChangeEvent<HTMLSelectElement>)
} }
const handleCategoryChange = (level: number, nextValue: string) => { const handleCategorySelect = (level: number, categoryId: string) => {
setSelectedPath((prev) => [...prev.slice(0, level), nextValue]) setSelectedPath((prev) => [...prev.slice(0, level), categoryId])
emitProductChange('') emitProductChange('')
// If the selected category has children, keep drilling; don't auto-select product
const category = findCategoryById(categories, categoryId)
if (category?.children?.length) return
} }
const handleProductChange = (e: ChangeEvent<HTMLSelectElement>) => { const handleProductSelect = (productId: string) => {
onChange?.(e) emitProductChange(productId)
const product = products.find((item) => item.id === productId)
const product = products.find((item) => item.id === e.target.value)
onProductSelect?.(product) onProductSelect?.(product)
} }
return ( return (
<div className='space-y-4'> <div className='space-y-6'>
{levelsToShow.map((level) => { {levelsToShow.map((level) => {
const items = getCategoriesAtLevel(categories, level, selectedPath).map((category) => ({ const levelCategories = getCategoriesAtLevel(categories, level, selectedPath)
label: category.title, const levelLabel = level === 0 ? 'دسته‌بندی' : 'زیر دسته‌بندی'
value: category.id,
}))
return ( return (
<Select <AvatarSelectionGrid
key={level} key={level}
items={items} label={levelLabel}
label={level === 0 ? 'دسته‌بندی' : 'زیر دسته‌بندی'} isLoading={isCategoriesLoading}
placeholder={level === 0 ? 'انتخاب دسته‌بندی' : 'انتخاب زیر دسته‌بندی'} items={levelCategories.map((cat) => ({
value={selectedPath[level] ?? ''} id: cat.id,
onChange={(e) => handleCategoryChange(level, e.target.value)} title: cat.title,
imageUrl: cat.avatarUrl,
}))}
selectedId={selectedPath[level]}
onSelect={(id) => handleCategorySelect(level, id)}
emptyMessage='دسته‌بندی یافت نشد'
/> />
) )
})} })}
<Select
items={productItems} {canLoadProducts && (
label='محصول' <AvatarSelectionGrid
placeholder='انتخاب محصول' label='محصول'
value={value} isLoading={isProductsLoading}
onChange={handleProductChange} items={products.map((product) => ({
error_text={error_text} id: product.id,
disabled={!activeCategoryId} title: product.title,
{...rest} imageUrl: product.images?.[0],
/> }))}
selectedId={typeof value === 'string' ? value : undefined}
onSelect={handleProductSelect}
error_text={error_text}
emptyMessage='محصولی برای این دسته‌بندی یافت نشد'
/>
)}
</div> </div>
) )
} }
+57 -119
View File
@@ -1,148 +1,108 @@
import { useCallback, useState, type ChangeEvent, type FC } from 'react' import { useCallback, useState, type ChangeEvent, type FC } from 'react'
import Button from '@/components/Button' import Button from '@/components/Button'
import UploadBox from '@/components/UploadBox'
import VoiceRecorder from '@/components/VoiceRecorder'
import { COLORS } from '@/constants/colors' import { COLORS } from '@/constants/colors'
import { AddSquare, CloseCircle, Edit } from 'iconsax-react' import { AddSquare, CloseCircle, Edit2 } from 'iconsax-react'
import ProductsSelect from './ProductsSelect' import ProductsSelect from './ProductsSelect'
import { useFormik } from 'formik' import { useFormik } from 'formik'
import * as Yup from 'yup' 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 { useGetAttributes } from '../hooks/useRequestData'
import ManageAttribute from './ManageAttribute' import ManageAttribute from './ManageAttribute'
import { useMultiUpload, useSingleUpload } from '@/pages/uploader/hooks/useUploader'
import { useRequestStore } from '../store/RequestStore' import { useRequestStore } from '../store/RequestStore'
import { clx } from '@/helpers/utils' import { clx } from '@/helpers/utils'
const emptyValues: RequestType = { const emptyValues: RequestItemType = {
productId: undefined, productId: undefined,
attachments: [],
description: '',
attributes: [], attributes: [],
} }
type Props = { type Props = {
editIndex: number | null editIndex: number | null
initialItem?: RequestType initialItem?: RequestItemType
onSaved: () => void onSaved: () => void
onCancelEdit?: () => void onCancelEdit?: () => void
} }
const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) => { const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) => {
const isEditing = editIndex !== null const isEditing = editIndex !== null
const setItems = useRequestStore((state) => state.setItems) const setItems = useRequestStore((state) => state.setItems)
const [productSelected, setProductSelected] = useState<ProductType>() 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, initialValues: initialItem ?? emptyValues,
enableReinitialize: true, enableReinitialize: true,
validationSchema: Yup.object({ validationSchema: Yup.object({
productId: Yup.string().required('این فیلد اجباری می باشد'), productId: Yup.string().required('این فیلد اجباری می باشد'),
}), }),
onSubmit: async (values) => { onSubmit: (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 }
if (isEditing && editIndex !== null) { if (isEditing && editIndex !== null) {
const items = useRequestStore.getState().items const items = useRequestStore.getState().items
const updated = [...items] const updated = [...items]
updated[editIndex] = payload updated[editIndex] = values
setItems(updated) setItems(updated)
} else { } else {
const items = useRequestStore.getState().items const items = useRequestStore.getState().items
setItems([...items, payload]) setItems([...items, values])
} }
setFiles([])
setVoiceFile(undefined)
onSaved() onSaved()
}, },
}) })
const resolvedProduct = productSelected const { data: attributes } = useGetAttributes(productSelected?.id)
const { data: attributes } = useGetAttributes(resolvedProduct?.id) const handleProductChange = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const productId = e.target.value
const handleProductChange = (e: ChangeEvent<HTMLSelectElement>) => { if (!productId) {
const productId = e.target.value setProductSelected(undefined)
formik.setFieldValue('productId', undefined)
formik.setFieldValue('attributes', [])
return
}
if (!productId) { formik.setFieldValue('productId', productId)
setProductSelected(undefined)
formik.setFieldValue('productId', undefined)
formik.setFieldValue('attributes', []) 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 handleProductSelect = useCallback((product: ProductType | undefined) => {
const file = new File([blob], 'recording.wav', { type: blob.type }) setProductSelected(product)
setVoiceFile(file)
}, []) }, [])
return ( return (
<div className='bg-white rounded-3xl p-6'> <div className='bg-white rounded-3xl p-6'>
<div className='flex items-center justify-between'> {/* Section header */}
<div className='font-light'> <div className='flex items-center justify-between mb-6'>
{isEditing ? 'ویرایش قلم' : 'افزودن قلم جدید'} <div className='flex items-center gap-2'>
<div
className={clx(
'size-8 rounded-full flex items-center justify-center text-white text-xs font-bold',
isEditing ? 'bg-blue-500' : 'bg-primary',
)}
>
{isEditing ? <Edit2 size={15} color='white' /> : <AddSquare size={15} color='white' />}
</div>
<span className='text-sm font-medium'>
{isEditing ? 'ویرایش قلم' : 'افزودن قلم جدید'}
</span>
</div> </div>
{isEditing && onCancelEdit && ( {isEditing && onCancelEdit && (
<button <button
type='button' type='button'
onClick={onCancelEdit} onClick={onCancelEdit}
className='flex items-center gap-1 text-xs text-description hover:text-black' className='flex items-center gap-1 text-xs text-description hover:text-black transition-colors'
> >
<CloseCircle size={18} color='currentColor' /> <CloseCircle size={16} color='currentColor' />
انصراف انصراف
</button> </button>
)} )}
</div> </div>
<div className='mt-6 rowTwoInput'> {/* Product selector */}
<div className='rowTwoInput'>
<ProductsSelect <ProductsSelect
value={formik.values.productId ?? ''} value={formik.values.productId ?? ''}
onChange={handleProductChange} onChange={handleProductChange}
@@ -155,52 +115,30 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
/> />
</div> </div>
<div> {/* Attributes */}
<ManageAttribute <ManageAttribute
key={`${resolvedProduct?.id ?? 'none'}-${editIndex ?? 'new'}`} key={`${productSelected?.id ?? 'none'}-${editIndex ?? 'new'}`}
attributes={attributes?.data} attributes={attributes?.data}
formik={formik} formik={formik}
/> />
</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>
)}
{/* Action button */}
<div className='mt-6 flex justify-end'> <div className='mt-6 flex justify-end'>
<Button <Button
className={clx( className={clx(
'w-fit px-6', 'w-fit px-8 gap-2',
isEditing isEditing
? 'bg-transparent border border-[#3B82F6] text-[#3B82F6]' ? 'bg-transparent border border-blue-500 text-blue-500 hover:bg-blue-50'
: 'bg-transparent border border-primary text-primary' : 'bg-transparent border border-primary text-primary hover:bg-primary/5',
)} )}
onClick={() => formik.handleSubmit()} onClick={() => formik.handleSubmit()}
isLoading={isUploading}
> >
<div className='flex gap-1 items-center'> {isEditing ? (
{isEditing ? ( <Edit2 color='currentColor' size={18} />
<Edit color='#3B82F6' size={20} /> ) : (
) : ( <AddSquare color={COLORS.primary} size={18} />
<AddSquare color={COLORS.primary} size={20} /> )}
)} <span>{isEditing ? 'ذخیره تغییرات' : 'افزودن به لیست'}</span>
<span>{isEditing ? 'ذخیره تغییرات' : 'افزودن به لیست'}</span>
</div>
</Button> </Button>
</div> </div>
</div> </div>
@@ -1,12 +1,12 @@
import { type FC } from 'react' import { type FC } from 'react'
import Button from '@/components/Button' import Button from '@/components/Button'
import { Edit, ShoppingCart, TickSquare, Trash } from 'iconsax-react' import { Edit2, ShoppingCart, TickSquare, Trash } from 'iconsax-react'
import { useGetProducts } from '../hooks/useRequestData' import { useGetProducts } from '../hooks/useRequestData'
import type { RequestType } from '../type/Types' import type { RequestItemType } from '../type/Types'
import { clx } from '@/helpers/utils' import { clx } from '@/helpers/utils'
type Props = { type Props = {
items: RequestType[] items: RequestItemType[]
editingIndex: number | null editingIndex: number | null
onEdit: (index: number) => void onEdit: (index: number) => void
onRemove: (index: number) => void onRemove: (index: number) => void
@@ -28,65 +28,75 @@ const RequestItemsList: FC<Props> = ({
productsData?.data?.find((p) => p.id === productId)?.title ?? 'محصول' productsData?.data?.find((p) => p.id === productId)?.title ?? 'محصول'
return ( return (
<div className='bg-white w-full xl:w-[320px] shrink-0 py-6 px-5 h-fit rounded-3xl xl:sticky xl:top-6'> <div className='bg-white w-full xl:w-[340px] shrink-0 rounded-3xl xl:sticky xl:top-6 h-fit'>
<div className='flex items-center gap-2'> {/* Header */}
<ShoppingCart size={20} color='black' /> <div className='flex items-center gap-2 px-5 pt-5 pb-4 border-b border-[#f0f2f8]'>
<span className='text-sm font-medium'>اقلام درخواست</span> <ShoppingCart size={20} color='black' variant='Bold' />
<span className='text-sm font-semibold'>اقلام درخواست</span>
{items.length > 0 && ( {items.length > 0 && (
<span className='text-xs bg-primary/20 text-black rounded-full px-2 py-0.5 mr-auto'> <span className='mr-auto text-xs bg-primary/15 text-primary font-medium rounded-full px-2.5 py-0.5 tabular-nums'>
{items.length} {items.length} قلم
</span> </span>
)} )}
</div> </div>
{items.length === 0 ? ( {/* Items */}
<p className='text-description text-xs mt-6 leading-6'> <div className='px-5 py-4'>
هنوز قلمی اضافه نشده. فرم را پر کنید و روی «افزودن به لیست» بزنید. {items.length === 0 ? (
</p> <div className='flex flex-col items-center justify-center gap-3 py-8 text-center'>
) : ( <div className='size-12 rounded-full bg-gray-50 flex items-center justify-center'>
<ul className='mt-4 space-y-3 max-h-[360px] overflow-y-auto'> <ShoppingCart size={22} color='#c0c4d0' />
{items.map((item, index) => ( </div>
<li <p className='text-description text-xs leading-6 max-w-[200px]'>
key={index} هنوز قلمی اضافه نشده. فرم را پر کنید و روی «افزودن به لیست» بزنید.
className={clx( </p>
'border rounded-2xl p-3 transition-colors', </div>
editingIndex === index ) : (
? 'border-primary bg-primary/5' <ul className='space-y-2 max-h-[380px] overflow-y-auto -mx-1 px-1'>
: 'border-[#f0f2f8]' {items.map((item, index) => (
)} <li
> key={index}
<div className='text-sm font-medium truncate'> className={clx(
{getProductTitle(item.productId)} 'border rounded-2xl px-4 py-3 transition-all',
</div> editingIndex === index
{item.attachments.length > 0 && ( ? 'border-primary bg-primary/5 shadow-sm'
<div className='text-description text-xs mt-1'> : 'border-[#eef0f6] hover:border-gray-300',
{item.attachments.length} پیوست )}
>
<div className='flex items-start justify-between gap-2'>
<span className='text-sm font-medium truncate leading-6'>
{getProductTitle(item.productId)}
</span>
<span className='text-[10px] text-description shrink-0 bg-gray-100 rounded-full px-2 py-0.5'>
#{index + 1}
</span>
</div> </div>
)} <div className='flex gap-3 mt-2'>
<div className='flex gap-2 mt-3'> <button
<button type='button'
type='button' onClick={() => onEdit(index)}
onClick={() => onEdit(index)} className='flex items-center gap-1 text-xs text-primary hover:opacity-75 transition-opacity'
className='flex items-center gap-1 text-xs text-[#3B82F6] hover:opacity-80' >
> <Edit2 size={14} color='currentColor' />
<Edit size={16} color='#3B82F6' /> ویرایش
ویرایش </button>
</button> <button
<button type='button'
type='button' onClick={() => onRemove(index)}
onClick={() => onRemove(index)} className='flex items-center gap-1 text-xs text-red-500 hover:opacity-75 transition-opacity mr-auto'
className='flex items-center gap-1 text-xs text-red-500 hover:opacity-80 mr-auto' >
> <Trash size={14} color='currentColor' />
<Trash size={16} color='#ef4444' /> حذف
حذف </button>
</button> </div>
</div> </li>
</li> ))}
))} </ul>
</ul> )}
)} </div>
<div className='mt-6 pt-4 border-t border-[#f0f2f8]'> {/* Submit footer */}
<div className='px-5 pb-5 pt-3 border-t border-[#f0f2f8]'>
<Button <Button
className='w-full' className='w-full'
onClick={onSubmit} onClick={onSubmit}
@@ -94,7 +104,7 @@ const RequestItemsList: FC<Props> = ({
disabled={items.length === 0} disabled={items.length === 0}
> >
<div className='flex gap-2 items-center justify-center'> <div className='flex gap-2 items-center justify-center'>
<TickSquare size={20} color='black' /> <TickSquare size={20} color='currentColor' />
<span>ثبت نهایی درخواست</span> <span>ثبت نهایی درخواست</span>
</div> </div>
</Button> </Button>
+19 -3
View File
@@ -2,9 +2,9 @@ import axios from "@/config/axios";
import type { import type {
AttributeResponseType, AttributeResponseType,
CategoryType, CategoryType,
CreateRequestPayload,
MyRequestsResponseType, MyRequestsResponseType,
RequestDetailResponseType, RequestDetailResponseType,
RequestType,
ProductsResponseType, ProductsResponseType,
} from "../type/Types"; } from "../type/Types";
@@ -26,8 +26,24 @@ export const getAttributes = async (productId?: string) => {
return data; return data;
}; };
export const submitRequest = async (params: RequestType[]) => { export const submitRequest = async (payload: CreateRequestPayload) => {
const { data } = await axios.post(`/public/request`, { items: params }); 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; return data;
}; };
+8 -4
View File
@@ -18,14 +18,18 @@ export type AttachmentsType = {
type: string; type: string;
}; };
export type RequestType = { export type RequestItemType = {
productId?: string; productId?: string;
attributes: { attributes: {
fieldId: string; fieldId: string;
value: string | number; value: string | number;
}[]; }[];
attachments: AttachmentsType[]; };
export type CreateRequestPayload = {
items: RequestItemType[];
description?: string; description?: string;
attachments?: AttachmentsType[];
}; };
export type AttributeValueType = { export type AttributeValueType = {
@@ -64,8 +68,8 @@ export type ProductsResponseType = BaseResponse<ProductType[]>;
export type CategoriesResponseType = BaseResponse<CategoryType[]>; export type CategoriesResponseType = BaseResponse<CategoryType[]>;
export type StoreType = { export type StoreType = {
items: RequestType[]; items: RequestItemType[];
setItems: (value: RequestType[]) => void; setItems: (value: RequestItemType[]) => void;
}; };
export interface MyRequestType extends RowDataType { 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
}
+1
View File
@@ -19,5 +19,6 @@ export type GetMeResponseType = BaseResponse<UserMeType>;
export type UpdateProfileType = { export type UpdateProfileType = {
firstName: string; firstName: string;
lastName: string; lastName: string;
address?: string;
avatarUrl?: string; avatarUrl?: string;
}; };
+2
View File
@@ -19,6 +19,7 @@ import LearningList from '@/pages/learning/List'
import LearningDetail from '@/pages/learning/Detail' import LearningDetail from '@/pages/learning/Detail'
import InvoiceDetail from '@/pages/invoice/Detail' import InvoiceDetail from '@/pages/invoice/Detail'
import PayInvoice from '@/pages/payment/PayInvoice' import PayInvoice from '@/pages/payment/PayInvoice'
import Profile from '@/pages/profile/Profile'
const MainRouter: FC = () => { const MainRouter: FC = () => {
return ( return (
@@ -26,6 +27,7 @@ const MainRouter: FC = () => {
<Routes> <Routes>
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path={Paths.home} element={<Home />} /> <Route path={Paths.home} element={<Home />} />
<Route path={Paths.profile} element={<Profile />} />
<Route path={Paths.myRequests} element={<MyRequests />} /> <Route path={Paths.myRequests} element={<MyRequests />} />
<Route path={Paths.order.myOrders} element={<MyOrders />} /> <Route path={Paths.order.myOrders} element={<MyOrders />} />
<Route path={Paths.proformaInvoice} element={<ProformaInvoice />} /> <Route path={Paths.proformaInvoice} element={<ProformaInvoice />} />
+18 -28
View File
@@ -1,12 +1,14 @@
import { type FC } from 'react' import { type FC } from 'react'
import Input from '@/components/Input' import Input from '@/components/Input'
import PresignedImage from '@/components/PresignedImage' import UserAvatar from '@/components/UserAvatar'
import { HambergerMenu, Wallet } from 'iconsax-react' import { HambergerMenu, Wallet } from 'iconsax-react'
import { Link } from 'react-router-dom'
import Notifications from '@/pages/notification/Notification' import Notifications from '@/pages/notification/Notification'
import { useSharedStore } from './store/useSharedStore' import { useSharedStore } from './store/useSharedStore'
import { t } from '@/locale' import { t } from '@/locale'
import { useGetMe } from '@/pages/user/hooks/useUserData' import { useGetMe } from '@/pages/user/hooks/useUserData'
import { NumberFormat } from '@/config/func' import { NumberFormat } from '@/config/func'
import { Paths } from '@/config/Paths'
const Header: FC = () => { const Header: FC = () => {
@@ -14,7 +16,6 @@ const Header: FC = () => {
const { setOpenSidebar, openSidebar } = useSharedStore() const { setOpenSidebar, openSidebar } = useSharedStore()
const displayName = [data?.firstName, data?.lastName].filter(Boolean).join(' ').trim() const displayName = [data?.firstName, data?.lastName].filter(Boolean).join(' ').trim()
const initials = `${String(data?.firstName ?? '').charAt(0)}${String(data?.lastName ?? '').charAt(0)}`
return ( return (
<div className='fixed z-10 left-[var(--layout-frame)] right-[var(--layout-frame)] top-[var(--layout-frame)] flex h-[var(--layout-header-height)] items-center justify-between rounded-[32px] bg-white px-6 xl:left-auto xl:right-[calc(var(--layout-frame)+var(--layout-main-offset))] xl:h-[var(--layout-header-height-xl)] xl:w-[calc(100%-(var(--layout-frame)*2)-var(--layout-main-offset))]'> <div className='fixed z-10 left-[var(--layout-frame)] right-[var(--layout-frame)] top-[var(--layout-frame)] flex h-[var(--layout-header-height)] items-center justify-between rounded-[32px] bg-white px-6 xl:left-auto xl:right-[calc(var(--layout-frame)+var(--layout-main-offset))] xl:h-[var(--layout-header-height-xl)] xl:w-[calc(100%-(var(--layout-frame)*2)-var(--layout-main-offset))]'>
@@ -29,15 +30,7 @@ const Header: FC = () => {
<div onClick={() => setOpenSidebar(!openSidebar)} className='xl:hidden block'> <div onClick={() => setOpenSidebar(!openSidebar)} className='xl:hidden block'>
<HambergerMenu size={24} color='black' /> <HambergerMenu size={24} color='black' />
</div> </div>
{/* <img src={LogoImage} className='h-6 xl:hidden block absolute right-0 left-0 mx-auto' /> */}
<div className='flex xl:gap-6 gap-4 items-center'> <div className='flex xl:gap-6 gap-4 items-center'>
{/* <Link to={Pages.services.other}>
<Element3 color='black' className='xl:size-[18px] size-[17px]' />
</Link>
<Link className='xl:hidden' to={Pages.wallet}>
<Wallet className='xl:size-[18px] size-[17px]' color='black' />
</Link>
<Link className='hidden xl:block' to={Pages.wallet}> */}
<div className='flex items-center h-8 pl-2 rounded-full bg-[#EEF0F7]'> <div className='flex items-center h-8 pl-2 rounded-full bg-[#EEF0F7]'>
<div className='px-3 text-xs'> <div className='px-3 text-xs'>
{NumberFormat(data?.maxCredit) + ' ' + t('rial')} {NumberFormat(data?.maxCredit) + ' ' + t('rial')}
@@ -46,25 +39,22 @@ const Header: FC = () => {
<Wallet className='xl:size-[18px] size-[17px]' color='black' /> <Wallet className='xl:size-[18px] size-[17px]' color='black' />
</div> </div>
</div> </div>
{/* </Link> */}
<Notifications /> <Notifications />
{displayName && ( {data && (
<div className='flex gap-2 items-center'> <Link to={Paths.profile} className='flex gap-2 items-center'>
<div className='size-6 rounded-full bg-description overflow-hidden flex items-center justify-center text-white text-xs font-medium'> <UserAvatar
{data?.avatarUrl ? ( src={data.avatarUrl}
<PresignedImage firstName={data.firstName}
src={data.avatarUrl} lastName={data.lastName}
className='size-full object-cover' className='size-6'
alt='' textClassName='text-xs'
/> />
) : ( {displayName && (
initials <div className='xl:block hidden text-xs'>
)} {displayName}
</div> </div>
<div className='xl:block hidden text-xs'> )}
{displayName} </Link>
</div>
</div>
)} )}
</div> </div>
</div> </div>