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