up
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-07-21 12:17:54 +03:30
parent 9f006b36ab
commit cd8677648e
7 changed files with 189 additions and 149 deletions
+64 -22
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,6 +564,7 @@ const ChatComposer: FC<Props> = ({
</div> </div>
</div> </div>
{showSubmitButton && (
<div className="mt-6 flex justify-end"> <div className="mt-6 flex justify-end">
<Button <Button
label={submitLabel} label={submitLabel}
@@ -534,8 +573,11 @@ const ChatComposer: FC<Props> = ({
isLoading={isBusy} isLoading={isBusy}
/> />
</div> </div>
)}
</div> </div>
) )
} })
ChatComposer.displayName = 'ChatComposer'
export default ChatComposer export default ChatComposer
+56 -4
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,9 @@ 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([])
@@ -51,13 +58,39 @@ const NewRequest: FC = () => {
toast('حداقل یک قلم باید به درخواست اضافه کنید.', 'error') toast('حداقل یک قلم باید به درخواست اضافه کنید.', 'error')
return return
} }
if (composerRef.current?.hasPendingUploads()) {
toast('لطفاً تا پایان آپلود فایل‌ها صبر کنید', 'error')
return
}
if (composerRef.current?.hasUploadErrors()) {
toast(
'برخی فایل‌ها آپلود نشدند. آن‌ها را حذف یا دوباره انتخاب کنید',
'error',
)
return
}
const note = composerRef.current?.getPayload() ?? null
setPendingNote(note)
setShowConfirmModal(true) setShowConfirmModal(true)
} }
const onSubmit = () => { const onSubmit = () => {
submitRequest(items, { submitRequest(
{
items,
...(pendingNote?.content ? { description: pendingNote.content } : {}),
...(pendingNote?.attachments?.length
? { attachments: pendingNote.attachments }
: {}),
},
{
onSuccess: () => { onSuccess: () => {
setShowConfirmModal(false) setShowConfirmModal(false)
setPendingNote(null)
composerRef.current?.reset()
setItems([]) setItems([])
setEditingIndex(null) setEditingIndex(null)
setFormKey((k) => k + 1) setFormKey((k) => k + 1)
@@ -67,7 +100,8 @@ const NewRequest: FC = () => {
onError: (error) => { onError: (error) => {
toast(extractErrorMessage(error), 'error') toast(extractErrorMessage(error), 'error')
}, },
}) },
)
} }
return ( return (
@@ -84,7 +118,9 @@ const NewRequest: FC = () => {
</div> </div>
<p className='text-description text-xs mt-2 leading-5'> <p className='text-description text-xs mt-2 leading-5'>
برای هر محصول یک قلم اضافه کنید، سپس درخواست را ثبت نهایی کنید. برای هر محصول یک قلم اضافه کنید. در صورت نیاز توضیحات یا فایل را
در بخش پایین وارد کنید و از دکمه «ثبت نهایی درخواست» در کنار لیست
استفاده کنید.
</p> </p>
<div className='flex flex-col-reverse xl:flex-row gap-6 xl:mt-8 mt-4'> <div className='flex flex-col-reverse xl:flex-row gap-6 xl:mt-8 mt-4'>
@@ -117,6 +153,22 @@ const NewRequest: FC = () => {
/> />
</div> </div>
<section className='bg-white rounded-3xl p-6 mt-6'>
<h2 className='text-sm font-light mb-1'>توضیحات درخواست</h2>
<p className='text-description text-xs mb-6 leading-5'>
توضیحات، فایل یا پیام صوتی خود را اینجا بنویسید. برای ارسال
درخواست از دکمه کنار لیست اقلام استفاده کنید.
</p>
<ChatComposer
ref={composerRef}
onSubmit={async () => undefined}
showSubmitButton={false}
label='پیام شما'
placeholder='توضیحات یا درخواست خود را بنویسید...'
allowEmptySubmit
/>
</section>
<ModalConfirm <ModalConfirm
open={showConfirmModal} open={showConfirmModal}
onClose={() => setShowConfirmModal(false)} onClose={() => setShowConfirmModal(false)}
@@ -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 = {
+12 -81
View File
@@ -1,29 +1,24 @@
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, Edit } 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
} }
@@ -33,57 +28,24 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
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()
}, },
}) })
@@ -92,7 +54,8 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
const { data: attributes } = useGetAttributes(resolvedProduct?.id) const { data: attributes } = useGetAttributes(resolvedProduct?.id)
const handleProductChange = (e: ChangeEvent<HTMLSelectElement>) => { const handleProductChange = useCallback(
(e: ChangeEvent<HTMLSelectElement>) => {
const productId = e.target.value const productId = e.target.value
if (!productId) { if (!productId) {
@@ -104,24 +67,12 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
formik.setFieldValue('productId', productId) formik.setFieldValue('productId', productId)
formik.setFieldValue('attributes', []) 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 (
@@ -163,25 +114,6 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
/> />
</div> </div>
<div className='mt-6'>
<VoiceRecorder
value={formik.values.description ?? ''}
onChange={handleDescriptionChange}
label='پیام صوتی'
onRecordingComplete={handleRecordingComplete}
/>
</div>
<div className='mt-6'>
<UploadBox label='آپلود فایل' isMultiple onChange={setFiles} />
</div>
{isEditing && (initialItem?.attachments?.length ?? 0) > 0 && files.length === 0 && (
<p className='text-description text-xs mt-3'>
{initialItem!.attachments.length} پیوست قبلی حفظ میشود. برای جایگزینی، فایل جدید آپلود کنید.
</p>
)}
<div className='mt-6 flex justify-end'> <div className='mt-6 flex justify-end'>
<Button <Button
className={clx( className={clx(
@@ -191,7 +123,6 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
: 'bg-transparent border border-primary text-primary' : 'bg-transparent border border-primary text-primary'
)} )}
onClick={() => formik.handleSubmit()} onClick={() => formik.handleSubmit()}
isLoading={isUploading}
> >
<div className='flex gap-1 items-center'> <div className='flex gap-1 items-center'>
{isEditing ? ( {isEditing ? (
@@ -2,11 +2,11 @@ import { type FC } from 'react'
import Button from '@/components/Button' import Button from '@/components/Button'
import { Edit, ShoppingCart, TickSquare, Trash } from 'iconsax-react' import { Edit, 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
@@ -58,11 +58,6 @@ const RequestItemsList: FC<Props> = ({
<div className='text-sm font-medium truncate'> <div className='text-sm font-medium truncate'>
{getProductTitle(item.productId)} {getProductTitle(item.productId)}
</div> </div>
{item.attachments.length > 0 && (
<div className='text-description text-xs mt-1'>
{item.attachments.length} پیوست
</div>
)}
<div className='flex gap-2 mt-3'> <div className='flex gap-2 mt-3'>
<button <button
type='button' type='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 {