@@ -12,9 +12,11 @@ import {
|
||||
Play,
|
||||
} from 'iconsax-react'
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
type ChangeEvent,
|
||||
@@ -47,12 +49,21 @@ type PendingVoice = {
|
||||
status: 'uploading' | 'done' | 'error'
|
||||
}
|
||||
|
||||
export type ChatComposerHandle = {
|
||||
getPayload: () => ChatComposerSubmitPayload
|
||||
hasPendingUploads: () => boolean
|
||||
hasUploadErrors: () => boolean
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
type Props = {
|
||||
onSubmit: (payload: ChatComposerSubmitPayload) => void | Promise<void>
|
||||
isSubmitting?: boolean
|
||||
submitLabel?: string
|
||||
label?: string
|
||||
placeholder?: string
|
||||
allowEmptySubmit?: boolean
|
||||
showSubmitButton?: boolean
|
||||
replyTo?: {
|
||||
id: string
|
||||
content: string
|
||||
@@ -163,15 +174,17 @@ const VoicePreviewChip: FC<{
|
||||
)
|
||||
}
|
||||
|
||||
const ChatComposer: FC<Props> = ({
|
||||
const ChatComposer = forwardRef<ChatComposerHandle, Props>(({
|
||||
onSubmit,
|
||||
isSubmitting = false,
|
||||
submitLabel = 'ارسال پیام',
|
||||
label = 'پیام شما',
|
||||
placeholder = 'متن پیام خود را بنویسید...',
|
||||
allowEmptySubmit = false,
|
||||
showSubmitButton = true,
|
||||
replyTo = null,
|
||||
onCancelReply,
|
||||
}) => {
|
||||
}, ref) => {
|
||||
const inputId = useId()
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const [message, setMessage] = useState('')
|
||||
@@ -318,7 +331,7 @@ const ChatComposer: FC<Props> = ({
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const buildPayload = useCallback((): ChatComposerSubmitPayload => {
|
||||
const uploadedFiles = pendingFiles.filter(
|
||||
(item) => item.status === 'done' && item.key,
|
||||
)
|
||||
@@ -326,10 +339,49 @@ const ChatComposer: FC<Props> = ({
|
||||
(item) => item.status === 'done' && item.key,
|
||||
)
|
||||
|
||||
const attachments: ChatComposerAttachment[] = [
|
||||
...uploadedFiles.map((item) => ({
|
||||
type: 'uploads_attach',
|
||||
url: item.key!,
|
||||
})),
|
||||
...uploadedVoices.map((item) => ({
|
||||
type: 'voice',
|
||||
url: item.key!,
|
||||
})),
|
||||
]
|
||||
|
||||
return {
|
||||
content: message.trim(),
|
||||
attachments,
|
||||
}
|
||||
}, [message, pendingFiles, pendingVoices])
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
getPayload: buildPayload,
|
||||
hasPendingUploads: () => isUploading,
|
||||
hasUploadErrors: () =>
|
||||
pendingFiles.some((item) => item.status === 'error') ||
|
||||
pendingVoices.some((item) => item.status === 'error'),
|
||||
reset: resetComposer,
|
||||
}),
|
||||
[
|
||||
buildPayload,
|
||||
isUploading,
|
||||
pendingFiles,
|
||||
pendingVoices,
|
||||
resetComposer,
|
||||
],
|
||||
)
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const payload = buildPayload()
|
||||
|
||||
if (
|
||||
!message.trim() &&
|
||||
uploadedFiles.length === 0 &&
|
||||
uploadedVoices.length === 0
|
||||
!allowEmptySubmit &&
|
||||
!payload.content &&
|
||||
payload.attachments.length === 0
|
||||
) {
|
||||
toast('لطفاً پیام یا فایل ضمیمه وارد کنید', 'error')
|
||||
return
|
||||
@@ -351,22 +403,8 @@ const ChatComposer: FC<Props> = ({
|
||||
return
|
||||
}
|
||||
|
||||
const attachments: ChatComposerAttachment[] = [
|
||||
...uploadedFiles.map((item) => ({
|
||||
type: 'uploads_attach',
|
||||
url: item.key!,
|
||||
})),
|
||||
...uploadedVoices.map((item) => ({
|
||||
type: 'voice',
|
||||
url: item.key!,
|
||||
})),
|
||||
]
|
||||
|
||||
try {
|
||||
await onSubmit({
|
||||
content: message.trim(),
|
||||
attachments,
|
||||
})
|
||||
await onSubmit(payload)
|
||||
resetComposer()
|
||||
} catch (error) {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
@@ -526,6 +564,7 @@ const ChatComposer: FC<Props> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showSubmitButton && (
|
||||
<div className="mt-6 flex justify-end">
|
||||
<Button
|
||||
label={submitLabel}
|
||||
@@ -534,8 +573,11 @@ const ChatComposer: FC<Props> = ({
|
||||
isLoading={isBusy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ChatComposer.displayName = 'ChatComposer'
|
||||
|
||||
export default ChatComposer
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, type FC } from 'react'
|
||||
import { useEffect, useRef, useState, type FC } from 'react'
|
||||
import Request from './components/Request'
|
||||
import RequestItemsList from './components/RequestItemsList'
|
||||
import { useRequestStore } from './store/RequestStore'
|
||||
@@ -9,6 +9,10 @@ import { Paths } from '@/config/Paths'
|
||||
import { extractErrorMessage } from '@/config/func'
|
||||
import { ArrowRight2 } from 'iconsax-react'
|
||||
import ModalConfirm from '@/components/ModalConfirm'
|
||||
import ChatComposer, {
|
||||
type ChatComposerHandle,
|
||||
type ChatComposerSubmitPayload,
|
||||
} from '@/pages/chat/components/ChatComposer'
|
||||
|
||||
const NewRequest: FC = () => {
|
||||
const navigate = useNavigate()
|
||||
@@ -17,6 +21,9 @@ const NewRequest: FC = () => {
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null)
|
||||
const [formKey, setFormKey] = useState(0)
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false)
|
||||
const composerRef = useRef<ChatComposerHandle>(null)
|
||||
const [pendingNote, setPendingNote] =
|
||||
useState<ChatComposerSubmitPayload | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setItems([])
|
||||
@@ -51,13 +58,39 @@ const NewRequest: FC = () => {
|
||||
toast('حداقل یک قلم باید به درخواست اضافه کنید.', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (composerRef.current?.hasPendingUploads()) {
|
||||
toast('لطفاً تا پایان آپلود فایلها صبر کنید', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (composerRef.current?.hasUploadErrors()) {
|
||||
toast(
|
||||
'برخی فایلها آپلود نشدند. آنها را حذف یا دوباره انتخاب کنید',
|
||||
'error',
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const note = composerRef.current?.getPayload() ?? null
|
||||
setPendingNote(note)
|
||||
setShowConfirmModal(true)
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
submitRequest(items, {
|
||||
submitRequest(
|
||||
{
|
||||
items,
|
||||
...(pendingNote?.content ? { description: pendingNote.content } : {}),
|
||||
...(pendingNote?.attachments?.length
|
||||
? { attachments: pendingNote.attachments }
|
||||
: {}),
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
setShowConfirmModal(false)
|
||||
setPendingNote(null)
|
||||
composerRef.current?.reset()
|
||||
setItems([])
|
||||
setEditingIndex(null)
|
||||
setFormKey((k) => k + 1)
|
||||
@@ -67,7 +100,8 @@ const NewRequest: FC = () => {
|
||||
onError: (error) => {
|
||||
toast(extractErrorMessage(error), 'error')
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -84,7 +118,9 @@ const NewRequest: FC = () => {
|
||||
</div>
|
||||
|
||||
<p className='text-description text-xs mt-2 leading-5'>
|
||||
برای هر محصول یک قلم اضافه کنید، سپس درخواست را ثبت نهایی کنید.
|
||||
برای هر محصول یک قلم اضافه کنید. در صورت نیاز توضیحات یا فایل را
|
||||
در بخش پایین وارد کنید و از دکمه «ثبت نهایی درخواست» در کنار لیست
|
||||
استفاده کنید.
|
||||
</p>
|
||||
|
||||
<div className='flex flex-col-reverse xl:flex-row gap-6 xl:mt-8 mt-4'>
|
||||
@@ -117,6 +153,22 @@ const NewRequest: FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className='bg-white rounded-3xl p-6 mt-6'>
|
||||
<h2 className='text-sm font-light mb-1'>توضیحات درخواست</h2>
|
||||
<p className='text-description text-xs mb-6 leading-5'>
|
||||
توضیحات، فایل یا پیام صوتی خود را اینجا بنویسید. برای ارسال
|
||||
درخواست از دکمه کنار لیست اقلام استفاده کنید.
|
||||
</p>
|
||||
<ChatComposer
|
||||
ref={composerRef}
|
||||
onSubmit={async () => undefined}
|
||||
showSubmitButton={false}
|
||||
label='پیام شما'
|
||||
placeholder='توضیحات یا درخواست خود را بنویسید...'
|
||||
allowEmptySubmit
|
||||
/>
|
||||
</section>
|
||||
|
||||
<ModalConfirm
|
||||
open={showConfirmModal}
|
||||
onClose={() => setShowConfirmModal(false)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useCallback, useRef, type FC } from 'react'
|
||||
import type { AttributeType, RequestType } from '../type/Types'
|
||||
import type { AttributeType, RequestItemType } from '../type/Types'
|
||||
import { clx } from '@/helpers/utils'
|
||||
import { FieldTypeEnum } from '../enum/RequestEnum'
|
||||
import Select from '@/components/Select'
|
||||
@@ -12,7 +12,7 @@ import type { FormikProps } from 'formik'
|
||||
|
||||
type Props = {
|
||||
attributes?: AttributeType[],
|
||||
formik: FormikProps<RequestType>
|
||||
formik: FormikProps<RequestItemType>
|
||||
}
|
||||
|
||||
type AttributeFieldProps = {
|
||||
|
||||
@@ -1,29 +1,24 @@
|
||||
import { useCallback, useState, type ChangeEvent, type FC } from 'react'
|
||||
import Button from '@/components/Button'
|
||||
import UploadBox from '@/components/UploadBox'
|
||||
import VoiceRecorder from '@/components/VoiceRecorder'
|
||||
import { COLORS } from '@/constants/colors'
|
||||
import { AddSquare, CloseCircle, Edit } from 'iconsax-react'
|
||||
import ProductsSelect from './ProductsSelect'
|
||||
import { useFormik } from 'formik'
|
||||
import * as Yup from 'yup'
|
||||
import type { AttachmentsType, RequestType, ProductType } from '../type/Types'
|
||||
import type { RequestItemType, ProductType } from '../type/Types'
|
||||
import { useGetAttributes } from '../hooks/useRequestData'
|
||||
import ManageAttribute from './ManageAttribute'
|
||||
import { useMultiUpload, useSingleUpload } from '@/pages/uploader/hooks/useUploader'
|
||||
import { useRequestStore } from '../store/RequestStore'
|
||||
import { clx } from '@/helpers/utils'
|
||||
|
||||
const emptyValues: RequestType = {
|
||||
const emptyValues: RequestItemType = {
|
||||
productId: undefined,
|
||||
attachments: [],
|
||||
description: '',
|
||||
attributes: [],
|
||||
}
|
||||
|
||||
type Props = {
|
||||
editIndex: number | null
|
||||
initialItem?: RequestType
|
||||
initialItem?: RequestItemType
|
||||
onSaved: () => void
|
||||
onCancelEdit?: () => void
|
||||
}
|
||||
@@ -33,57 +28,24 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
|
||||
|
||||
const setItems = useRequestStore((state) => state.setItems)
|
||||
const [productSelected, setProductSelected] = useState<ProductType>()
|
||||
const [voiceFile, setVoiceFile] = useState<File>()
|
||||
const [files, setFiles] = useState<File[]>([])
|
||||
const singleUpload = useSingleUpload()
|
||||
const multiUpload = useMultiUpload()
|
||||
|
||||
const formik = useFormik<RequestType>({
|
||||
const formik = useFormik<RequestItemType>({
|
||||
initialValues: initialItem ?? emptyValues,
|
||||
enableReinitialize: true,
|
||||
validationSchema: Yup.object({
|
||||
productId: Yup.string().required('این فیلد اجباری می باشد'),
|
||||
}),
|
||||
onSubmit: async (values) => {
|
||||
const attachments: AttachmentsType[] = [...(values.attachments ?? [])]
|
||||
|
||||
if (files.length) {
|
||||
await multiUpload.mutateAsync(files, {
|
||||
onSuccess: (data) => {
|
||||
data?.data?.forEach((item) => {
|
||||
attachments.push({
|
||||
type: 'uploads_attach',
|
||||
url: item.key,
|
||||
})
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
if (voiceFile) {
|
||||
await singleUpload.mutateAsync(voiceFile, {
|
||||
onSuccess: (data) => {
|
||||
attachments.push({
|
||||
type: 'voice',
|
||||
url: data?.data?.key,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const payload: RequestType = { ...values, attachments }
|
||||
|
||||
onSubmit: (values) => {
|
||||
if (isEditing && editIndex !== null) {
|
||||
const items = useRequestStore.getState().items
|
||||
const updated = [...items]
|
||||
updated[editIndex] = payload
|
||||
updated[editIndex] = values
|
||||
setItems(updated)
|
||||
} else {
|
||||
const items = useRequestStore.getState().items
|
||||
setItems([...items, payload])
|
||||
setItems([...items, values])
|
||||
}
|
||||
|
||||
setFiles([])
|
||||
setVoiceFile(undefined)
|
||||
onSaved()
|
||||
},
|
||||
})
|
||||
@@ -92,7 +54,8 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
|
||||
|
||||
const { data: attributes } = useGetAttributes(resolvedProduct?.id)
|
||||
|
||||
const handleProductChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||
const handleProductChange = useCallback(
|
||||
(e: ChangeEvent<HTMLSelectElement>) => {
|
||||
const productId = e.target.value
|
||||
|
||||
if (!productId) {
|
||||
@@ -104,24 +67,12 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
|
||||
|
||||
formik.setFieldValue('productId', productId)
|
||||
formik.setFieldValue('attributes', [])
|
||||
}
|
||||
|
||||
const handleProductSelect = (product: ProductType | undefined) => {
|
||||
setProductSelected(product)
|
||||
}
|
||||
|
||||
const isUploading = singleUpload.isPending || multiUpload.isPending
|
||||
|
||||
const handleDescriptionChange = useCallback(
|
||||
(value: string) => {
|
||||
formik.setFieldValue('description', value)
|
||||
},
|
||||
[formik.setFieldValue]
|
||||
[formik]
|
||||
)
|
||||
|
||||
const handleRecordingComplete = useCallback((blob: Blob) => {
|
||||
const file = new File([blob], 'recording.wav', { type: blob.type })
|
||||
setVoiceFile(file)
|
||||
const handleProductSelect = useCallback((product: ProductType | undefined) => {
|
||||
setProductSelected(product)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
@@ -163,25 +114,6 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-6'>
|
||||
<VoiceRecorder
|
||||
value={formik.values.description ?? ''}
|
||||
onChange={handleDescriptionChange}
|
||||
label='پیام صوتی'
|
||||
onRecordingComplete={handleRecordingComplete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className='mt-6'>
|
||||
<UploadBox label='آپلود فایل' isMultiple onChange={setFiles} />
|
||||
</div>
|
||||
|
||||
{isEditing && (initialItem?.attachments?.length ?? 0) > 0 && files.length === 0 && (
|
||||
<p className='text-description text-xs mt-3'>
|
||||
{initialItem!.attachments.length} پیوست قبلی حفظ میشود. برای جایگزینی، فایل جدید آپلود کنید.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className='mt-6 flex justify-end'>
|
||||
<Button
|
||||
className={clx(
|
||||
@@ -191,7 +123,6 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
|
||||
: 'bg-transparent border border-primary text-primary'
|
||||
)}
|
||||
onClick={() => formik.handleSubmit()}
|
||||
isLoading={isUploading}
|
||||
>
|
||||
<div className='flex gap-1 items-center'>
|
||||
{isEditing ? (
|
||||
|
||||
@@ -2,11 +2,11 @@ import { type FC } from 'react'
|
||||
import Button from '@/components/Button'
|
||||
import { Edit, ShoppingCart, TickSquare, Trash } from 'iconsax-react'
|
||||
import { useGetProducts } from '../hooks/useRequestData'
|
||||
import type { RequestType } from '../type/Types'
|
||||
import type { RequestItemType } from '../type/Types'
|
||||
import { clx } from '@/helpers/utils'
|
||||
|
||||
type Props = {
|
||||
items: RequestType[]
|
||||
items: RequestItemType[]
|
||||
editingIndex: number | null
|
||||
onEdit: (index: number) => void
|
||||
onRemove: (index: number) => void
|
||||
@@ -58,11 +58,6 @@ const RequestItemsList: FC<Props> = ({
|
||||
<div className='text-sm font-medium truncate'>
|
||||
{getProductTitle(item.productId)}
|
||||
</div>
|
||||
{item.attachments.length > 0 && (
|
||||
<div className='text-description text-xs mt-1'>
|
||||
{item.attachments.length} پیوست
|
||||
</div>
|
||||
)}
|
||||
<div className='flex gap-2 mt-3'>
|
||||
<button
|
||||
type='button'
|
||||
|
||||
@@ -2,9 +2,9 @@ import axios from "@/config/axios";
|
||||
import type {
|
||||
AttributeResponseType,
|
||||
CategoryType,
|
||||
CreateRequestPayload,
|
||||
MyRequestsResponseType,
|
||||
RequestDetailResponseType,
|
||||
RequestType,
|
||||
ProductsResponseType,
|
||||
} from "../type/Types";
|
||||
|
||||
@@ -26,8 +26,24 @@ export const getAttributes = async (productId?: string) => {
|
||||
return data;
|
||||
};
|
||||
|
||||
export const submitRequest = async (params: RequestType[]) => {
|
||||
const { data } = await axios.post(`/public/request`, { items: params });
|
||||
export const submitRequest = async (payload: CreateRequestPayload) => {
|
||||
const { data } = await axios.post(`/public/request`, {
|
||||
items: payload.items.map((item) => ({
|
||||
productId: item.productId,
|
||||
...(item.attributes?.length
|
||||
? {
|
||||
attributes: item.attributes.map((attr) => ({
|
||||
fieldId: attr.fieldId,
|
||||
value: String(attr.value),
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
...(payload.description ? { description: payload.description } : {}),
|
||||
...(payload.attachments?.length
|
||||
? { attachments: payload.attachments }
|
||||
: {}),
|
||||
});
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -18,14 +18,18 @@ export type AttachmentsType = {
|
||||
type: string;
|
||||
};
|
||||
|
||||
export type RequestType = {
|
||||
export type RequestItemType = {
|
||||
productId?: string;
|
||||
attributes: {
|
||||
fieldId: string;
|
||||
value: string | number;
|
||||
}[];
|
||||
attachments: AttachmentsType[];
|
||||
};
|
||||
|
||||
export type CreateRequestPayload = {
|
||||
items: RequestItemType[];
|
||||
description?: string;
|
||||
attachments?: AttachmentsType[];
|
||||
};
|
||||
|
||||
export type AttributeValueType = {
|
||||
@@ -64,8 +68,8 @@ export type ProductsResponseType = BaseResponse<ProductType[]>;
|
||||
export type CategoriesResponseType = BaseResponse<CategoryType[]>;
|
||||
|
||||
export type StoreType = {
|
||||
items: RequestType[];
|
||||
setItems: (value: RequestType[]) => void;
|
||||
items: RequestItemType[];
|
||||
setItems: (value: RequestItemType[]) => void;
|
||||
};
|
||||
|
||||
export interface MyRequestType extends RowDataType {
|
||||
|
||||
Reference in New Issue
Block a user