avatar url
deploy to danak / build_and_deploy (push) Has been cancelled

This commit is contained in:
2026-07-17 20:20:12 +03:30
parent f61a1db4a7
commit b10106fd95
21 changed files with 244 additions and 81 deletions
+18
View File
@@ -0,0 +1,18 @@
import { type FC, type ImgHTMLAttributes } from "react";
import { usePresignedUrl } from "@/pages/uploader/hooks/usePresignedUrl";
type Props = ImgHTMLAttributes<HTMLImageElement> & {
src?: string;
};
const PresignedImage: FC<Props> = ({ src, alt = "", ...props }) => {
const resolvedSrc = usePresignedUrl(src);
if (!resolvedSrc) {
return null;
}
return <img src={resolvedSrc} alt={alt} {...props} />;
};
export default PresignedImage;
+6 -4
View File
@@ -1,18 +1,20 @@
import { Pause, Play } from "iconsax-react"
import { useEffect, useRef, useState, type FC } from "react"
import { usePresignedUrl } from "@/pages/uploader/hooks/usePresignedUrl"
type VoicePlayerProps = {
url: string
}
const VoicePlayer: FC<VoicePlayerProps> = ({ url }) => {
const resolvedUrl = usePresignedUrl(url)
const audioRef = useRef<HTMLAudioElement | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [progress, setProgress] = useState(0)
useEffect(() => {
const audio = audioRef.current
if (!audio) return
if (!audio || !resolvedUrl) return
const update = () => {
if (!audio.duration) return
@@ -28,7 +30,7 @@ const VoicePlayer: FC<VoicePlayerProps> = ({ url }) => {
return () => {
audio.removeEventListener('timeupdate', update)
}
}, [])
}, [resolvedUrl])
const toggle = () => {
if (!audioRef.current) return
@@ -66,9 +68,9 @@ const VoicePlayer: FC<VoicePlayerProps> = ({ url }) => {
})}
</div>
<audio ref={audioRef} src={url} className="hidden" />
<audio ref={audioRef} src={resolvedUrl} className="hidden" />
</div>
)
}
export default VoicePlayer
export default VoicePlayer
@@ -1,10 +1,13 @@
import { type FC } from 'react'
import { useState, type FC } from 'react'
import Input from '@/components/Input'
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 { Paths } from '@/config/Paths'
@@ -14,12 +17,34 @@ import type { UpdateProfileType } from '@/pages/user/types/Types'
const CompleteProfileForm: FC = () => {
const { data: user } = 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(),
...(values.avatarUrl ? { avatarUrl: values.avatarUrl } : {}),
},
{
onSuccess() {
toast('اطلاعات شما با موفقیت ثبت شد', 'success')
appNavigate(Paths.home, { replace: true })
},
onError(error) {
toast(extractErrorMessage(error), 'error')
},
},
)
}
const formik = useFormik<UpdateProfileType>({
enableReinitialize: true,
initialValues: {
firstName: user?.firstName ?? '',
lastName: user?.lastName ?? '',
avatarUrl: user?.avatarUrl ?? undefined,
},
validationSchema: Yup.object({
firstName: Yup.string()
@@ -30,21 +55,21 @@ const CompleteProfileForm: FC = () => {
.required('نام خانوادگی اجباری است.'),
}),
onSubmit(values) {
updateProfile.mutate(
{
firstName: values.firstName.trim(),
lastName: values.lastName.trim(),
},
{
onSuccess() {
toast('اطلاعات شما با موفقیت ثبت شد', 'success')
appNavigate(Paths.home, { replace: true })
if (file) {
upload(file, {
onSuccess(data) {
handleSave({
...values,
avatarUrl: data?.data?.key,
})
},
onError(error) {
toast(extractErrorMessage(error), 'error')
},
},
)
})
} else {
handleSave(values)
}
},
})
@@ -60,6 +85,19 @@ const CompleteProfileForm: FC = () => {
</div>
<div className='mt-16 flex flex-col gap-6'>
{formik.values.avatarUrl && !file && (
<PresignedImage
src={formik.values.avatarUrl}
className='w-20 h-20 rounded-full object-cover'
alt='آواتار'
/>
)}
<UploadBox
label='آواتار (اختیاری)'
onChange={(files) => setFile(files[0])}
/>
<div>
<Input
label='نام'
@@ -94,7 +132,7 @@ const CompleteProfileForm: FC = () => {
label='ثبت و ادامه'
className='mt-8'
onClick={() => formik.handleSubmit()}
isLoading={updateProfile.isPending}
isLoading={updateProfile.isPending || isUploading}
/>
</div>
)
+1 -1
View File
@@ -35,7 +35,7 @@ const AddCriticisms: FC = () => {
if (files.length > 0) {
await multiUpload.mutateAsync(files, {
onSuccess: async (data) => {
values.files = await data.data.map((item: { url: string }) => item?.url)
values.files = data.data.map((item) => item.key)
},
onError: (error: ErrorType) => {
toast(error.response?.data?.error?.message[0], 'error')
+10 -3
View File
@@ -1,14 +1,21 @@
import { type FC } from 'react'
import { type ProductType } from '@/pages/request/type/Types'
import PresignedImage from '@/components/PresignedImage'
const ServiceItem: FC<{ product: ProductType }> = ({ product }) => {
return (
<div>
<div className='size-20 rounded-full bg-gray-200 border-[3px] border-primary'>
<img src={product.images?.[0]} alt={product.title} className='w-full h-full object-cover' />
<div className='size-20 rounded-full bg-gray-200 border-[3px] border-primary overflow-hidden'>
{product.images?.[0] ? (
<PresignedImage
src={product.images[0]}
alt={product.title}
className='w-full h-full object-cover'
/>
) : null}
</div>
</div>
)
}
export default ServiceItem
export default ServiceItem
+13 -5
View File
@@ -15,6 +15,7 @@ import { Paths } from '@/config/Paths'
import { useGetPaymentInvoice } from '../payment/hooks/usePaymentData'
import InvoicePaymentList from './InvoicePaymentList'
import { formatItemDiscountDisplay } from './utils/invoiceItem'
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
interface TableInvoiceItem extends RowDataType {
id: string
@@ -237,13 +238,20 @@ const InvoiceDetail: FC = () => {
<span className='text-[#8C90A3]'>پیوستها:</span>
<ul className='text-black leading-6 list-disc list-inside space-y-1'>
{invoice.attachments.map((att: unknown, index: number) => {
const url = typeof att === 'string' ? att : (att as { url?: string })?.url
const name = typeof att === 'object' && att && 'name' in (att as object) ? (att as { name?: string }).name : url || `پیوست ${index + 1}`
return url ? (
const key = typeof att === 'string' ? att : (att as { url?: string })?.url
const name = typeof att === 'object' && att && 'name' in (att as object) ? (att as { name?: string }).name : key || `پیوست ${index + 1}`
return key ? (
<li key={index}>
<a href={url} target='_blank' rel='noopener noreferrer' className='text-[#0047FF] hover:underline'>
<button
type='button'
onClick={async () => {
const url = await getPresignedUrl(key)
window.open(url, '_blank', 'noopener,noreferrer')
}}
className='text-[#0047FF] hover:underline'
>
{name}
</a>
</button>
</li>
) : null
})}
+10 -7
View File
@@ -6,11 +6,14 @@ import PageLoading from '../../components/PageLoading'
import { Calendar2, Clock, Element3 } from 'iconsax-react'
import moment from 'moment-jalaali'
import { type LearningItemType } from './types/LearningTypes'
import { usePresignedUrl } from '@/pages/uploader/hooks/usePresignedUrl'
const LearningDetail: FC = () => {
const { id } = useParams()
const getLearningDetail = useGetLearningDetail(id ? id : '')
const learning = (getLearningDetail.data as { data: { learning: LearningItemType } })?.data?.learning
const videoUrl = usePresignedUrl(learning?.videoUrl)
return (
<div className='mt-4'>
@@ -23,13 +26,13 @@ const LearningDetail: FC = () => {
<div className='bg-white p-8 rounded-3xl'>
<video className='w-full rounded-3xl' controls>
<source src={(getLearningDetail.data as { data: { learning: LearningItemType } })?.data?.learning?.videoUrl} />
<video className='w-full rounded-3xl' controls key={videoUrl}>
{videoUrl ? <source src={videoUrl} /> : null}
</video>
<div className='mt-7 flex xl:flex-row flex-col gap-4 justify-between'>
<div className='xl:text-lg'>
{(getLearningDetail.data as { data: { learning: LearningItemType } })?.data?.learning?.title}
{learning?.title}
</div>
<div className='flex xl:flex-row flex-col xl:max-h-5 gap-3 text-description text-xs'>
@@ -40,7 +43,7 @@ const LearningDetail: FC = () => {
color={'#888888'}
/>
<div>
{(getLearningDetail.data as { data: { learning: LearningItemType } })?.data?.learning?.videoDuration + ' دقیقه'}
{(learning?.videoDuration ?? '') + ' دقیقه'}
</div>
</div>
@@ -50,7 +53,7 @@ const LearningDetail: FC = () => {
color={'#888888'}
/>
<div>
{(getLearningDetail.data as { data: { learning: LearningItemType } })?.data?.learning?.category?.name}
{learning?.category?.name}
</div>
</div>
@@ -60,7 +63,7 @@ const LearningDetail: FC = () => {
color={'#888888'}
/>
<div>
{moment((getLearningDetail.data as { data: { learning: LearningItemType } })?.data?.learning?.createdAt).format('jYYYY-jMM-jDD')}
{moment(learning?.createdAt).format('jYYYY-jMM-jDD')}
</div>
</div>
@@ -69,7 +72,7 @@ const LearningDetail: FC = () => {
</div>
<div className='mt-6 text-description'>
{(getLearningDetail.data as { data: { learning: LearningItemType } })?.data?.learning?.description}
{learning?.description}
</div>
</div>
+2 -1
View File
@@ -7,6 +7,7 @@ import PageLoading from '../../components/PageLoading'
import { type LearningItemType } from './types/LearningTypes'
import moment from 'moment-jalaali'
import { Link } from 'react-router-dom'
import PresignedImage from '@/components/PresignedImage'
// import { Pages } from '../../config/Pages'
const LearningList: FC = () => {
@@ -55,7 +56,7 @@ const LearningList: FC = () => {
(getLearnings.data as { data: { learnings: LearningItemType[] } })?.data?.learnings?.map((item: LearningItemType) => {
return (
<Link to={`/learning/${item.id}`} className='bg-white p-4 rounded-3xl xl:min-w-[30%] min-w-full flex-1'>
<img src={item.coverUrl} className='w-full rounded-[20px]' />
<PresignedImage src={item.coverUrl} className='w-full rounded-[20px]' alt={item.title} />
<div className='mt-4'>
{item.title}
</div>
+12 -7
View File
@@ -6,14 +6,17 @@ import { useGetOrderDetails } from './hooks/useOrderData'
import TicketSection from './components/TicketSection'
import { getFileNameAndExtensionFromUrl } from '@/config/func'
import { orderStatusLabels } from './constants/statusLabels'
import PresignedImage from '@/components/PresignedImage'
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
const OrderDetail: FC = () => {
const { id } = useParams()
const { data } = useGetOrderDetails(id!)
const order = data?.data
const handleOpenLink = (url: string) => {
window.open(url, '_blank')
const handleOpenLink = async (key: string) => {
const url = await getPresignedUrl(key)
window.open(url, '_blank', 'noopener,noreferrer')
}
return (
@@ -36,11 +39,13 @@ const OrderDetail: FC = () => {
<div className="flex items-center gap-6">
{order?.product && (
<div className="size-[86px] rounded-lg overflow-hidden shrink-0">
<img
src={order.product.images?.[0]}
className="size-full object-cover"
alt=""
/>
{order.product.images?.[0] ? (
<PresignedImage
src={order.product.images[0]}
className="size-full object-cover"
alt=""
/>
) : null}
</div>
)}
<div className="flex-1 flex gap-x-16 gap-y-4 pr-4 flex-wrap">
+6 -4
View File
@@ -11,6 +11,7 @@ import { useParams } from 'react-router-dom'
import { toast } from '@/shared/toast'
import { extractErrorMessage, getFileNameAndExtensionFromUrl } from '@/config/func'
import VoicePlayer from '@/components/VoicePlayer'
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
const TicketSection: FC = () => {
const { id } = useParams()
@@ -44,7 +45,7 @@ const TicketSection: FC = () => {
onSuccess: (data) => {
params.attachments.push({
type: 'voice',
url: data?.data?.url,
url: data?.data?.key,
})
},
})
@@ -55,7 +56,7 @@ const TicketSection: FC = () => {
data?.data?.map((item) => {
params.attachments.push({
type: 'uploads_attach',
url: item.url,
url: item.key,
})
})
},
@@ -79,8 +80,9 @@ const TicketSection: FC = () => {
)
}
const handleOpenLink = (url: string) => {
window.open(url, '_blank')
const handleOpenLink = async (key: string) => {
const url = await getPresignedUrl(key)
window.open(url, '_blank', 'noopener,noreferrer')
}
return (
+18 -1
View File
@@ -33,6 +33,23 @@ export type OrderUserType = {
phone: string
}
export type OrderDesignerAdminType = {
id: string
createdAt?: string
deletedAt?: string | null
firstName?: string | null
lastName?: string | null
phone?: string | null
role?: string
}
export type OrderDesignerType = {
id: string
createdAt: string
deletedAt: string | null
designer: OrderDesignerAdminType
}
export interface OrderType extends RowDataType {
id: string
createdAt: string
@@ -43,7 +60,7 @@ export interface OrderType extends RowDataType {
title: string
type: string
user: OrderUserType
designer: unknown
designers: OrderDesignerType[]
creator: string
orderNumber: number
status: string
+1 -1
View File
@@ -90,7 +90,7 @@ const PayInvoice: FC = () => {
try {
const uploadResult = await multiUpload.mutateAsync(attachmentFiles)
attachments = (uploadResult?.data ?? []).map(
(item: { url?: string }) => item?.url ?? ''
(item) => item.key
).filter(Boolean)
} catch (error) {
toast((error as ErrorType)?.response?.data?.error?.message?.[0] ?? 'خطا در آپلود فایل‌ها', 'error')
+2 -2
View File
@@ -53,7 +53,7 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
data?.data?.forEach((item) => {
attachments.push({
type: 'uploads_attach',
url: item.url,
url: item.key,
})
})
},
@@ -64,7 +64,7 @@ const Request: FC<Props> = ({ editIndex, initialItem, onSaved, onCancelEdit }) =
onSuccess: (data) => {
attachments.push({
type: 'voice',
url: data?.data?.url,
url: data?.data?.key,
})
},
})
@@ -3,6 +3,8 @@ import { Paperclip2 } from 'iconsax-react'
import placeholderProductImage from '@/assets/images/placeholder-product.svg'
import { getFileNameAndExtensionFromUrl } from '@/config/func'
import VoicePlayer from '@/components/VoicePlayer'
import PresignedImage from '@/components/PresignedImage'
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
import type { RequestDetailItemType } from '../type/Types'
type Props = {
@@ -18,7 +20,8 @@ const RequestDetailItem: FC<Props> = ({ item, index, total }) => {
const voiceAttachments = item.attachments?.filter((a) => a.type === 'voice') ?? []
const fileAttachments = item.attachments?.filter((a) => a.type !== 'voice') ?? []
const handleOpenLink = (url: string) => {
const handleOpenLink = async (key: string) => {
const url = await getPresignedUrl(key)
window.open(url, '_blank', 'noopener,noreferrer')
}
@@ -26,14 +29,19 @@ const RequestDetailItem: FC<Props> = ({ item, index, total }) => {
<article className={index > 0 ? 'pt-8 mt-8 border-t border-[#EBEDF5]' : ''}>
<div className="flex items-start gap-5">
<div className="size-20 rounded-xl overflow-hidden bg-[#F5F7FC] shrink-0">
<img
src={productImage || placeholderProductImage}
alt={item.product?.title ?? 'محصول'}
className="size-full object-cover"
onError={(e) => {
e.currentTarget.src = placeholderProductImage
}}
/>
{productImage ? (
<PresignedImage
src={productImage}
alt={item.product?.title ?? 'محصول'}
className="size-full object-cover"
/>
) : (
<img
src={placeholderProductImage}
alt={item.product?.title ?? 'محصول'}
className="size-full object-cover"
/>
)}
</div>
<div className="flex-1 min-w-0">
@@ -86,19 +94,18 @@ const RequestDetailItem: FC<Props> = ({ item, index, total }) => {
<div className="flex flex-wrap gap-3">
{fileAttachments.map((att) =>
isImageUrl(att.url) ? (
<a
<button
key={att.url}
href={att.url}
target="_blank"
rel="noopener noreferrer"
type="button"
onClick={() => handleOpenLink(att.url)}
className="block size-20 rounded-xl overflow-hidden border border-[#EBEDF5] hover:opacity-90 transition-opacity"
>
<img
<PresignedImage
src={att.url}
alt="پیوست"
className="size-full object-cover"
/>
</a>
</button>
) : (
<button
key={att.url}
@@ -2,6 +2,7 @@ import { type FC } from 'react'
import moment from 'moment-jalaali'
import { Paperclip2 } from 'iconsax-react'
import { getFileNameAndExtensionFromUrl } from '@/config/func'
import { getPresignedUrl } from '@/pages/uploader/service/UploaderService'
import VoicePlayer from '@/components/VoicePlayer'
import type { AttachmentsType } from '../type/Types'
@@ -25,7 +26,8 @@ const TicketMessage: FC<Props> = ({
const fileAttachments = attachments.filter((a) => a.type !== 'voice')
const voiceAttachments = attachments.filter((a) => a.type === 'voice')
const handleOpenLink = (url: string) => {
const handleOpenLink = async (key: string) => {
const url = await getPresignedUrl(key)
window.open(url, '_blank', 'noopener,noreferrer')
}
@@ -53,7 +53,7 @@ const TicketSection: FC = () => {
onSuccess: (data) => {
params.attachments.push({
type: 'voice',
url: data?.data?.url,
url: data?.data?.key,
})
},
})
@@ -65,7 +65,7 @@ const TicketSection: FC = () => {
data?.data?.forEach((item) => {
params.attachments.push({
type: 'uploads_attach',
url: item.url,
url: item.key,
})
})
},
+11 -5
View File
@@ -4,11 +4,18 @@ import { clx } from "@/helpers/utils";
import { t } from "@/locale";
import type { TicketThreadItemType } from "./types/TicketTypes";
import { formatDate } from "./detailUtils";
import { getPresignedUrl } from "@/pages/uploader/service/UploaderService";
export type MessageBubbleProps = { item: TicketThreadItemType };
const MessageBubble: FC<MessageBubbleProps> = ({ item }) => {
const isUser = item.isFromUser;
const handleOpenLink = async (key: string) => {
const url = await getPresignedUrl(key);
window.open(url, "_blank", "noopener,noreferrer");
};
return (
<div
className={clx(
@@ -30,16 +37,15 @@ const MessageBubble: FC<MessageBubbleProps> = ({ item }) => {
{item.attachments && item.attachments.length > 0 && (
<div className="flex gap-2 flex-wrap text-sm mt-2">
{item.attachments.map((att, index) => (
<a
<button
key={att.id}
href={att.attachmentUrl}
target="_blank"
rel="noreferrer"
type="button"
onClick={() => handleOpenLink(att.attachmentUrl)}
className="text-[#0047FF] flex gap-1 items-center"
>
<Paperclip2 size={20} color="#0047FF" />
{t("attach")} {index + 1}
</a>
</button>
))}
</div>
)}
@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import { getPresignedUrl } from "../service/UploaderService";
export const usePresignedUrl = (key?: string) => {
const [url, setUrl] = useState<string>();
useEffect(() => {
if (!key) {
setUrl(undefined);
return;
}
let cancelled = false;
getPresignedUrl(key)
.then((resolvedUrl) => {
if (!cancelled) {
setUrl(resolvedUrl);
}
})
.catch(() => {
if (!cancelled) {
setUrl(undefined);
}
});
return () => {
cancelled = true;
};
}, [key]);
return url;
};
@@ -1,6 +1,7 @@
import axios from "@/config/axios";
import type {
MultiUploadResponseType,
PresignedUrlResponseType,
SingleUploadResponseType,
} from "../types/Types";
@@ -25,3 +26,11 @@ export const multiUpload = async (files: File[]) => {
);
return data;
};
export const getPresignedUrl = async (key: string) => {
const { data } = await axios.get<PresignedUrlResponseType>(
"/public/presigned-url",
{ params: { key } },
);
return data.data.url;
};
+12 -9
View File
@@ -1,13 +1,16 @@
import type { BaseResponse } from "@/shared/types/Types";
export type SingleUploadType = {
url: string;
file: {
url: string;
key: string;
bucket: string;
};
export type UploadResultType = {
key: string;
bucket: string;
};
export type SingleUploadResponseType = BaseResponse<SingleUploadType>;
export type MultiUploadResponseType = BaseResponse<SingleUploadType[]>;
export type PresignedUrlResultType = {
key: string;
url: string;
expiresIn: number;
};
export type SingleUploadResponseType = BaseResponse<UploadResultType>;
export type MultiUploadResponseType = BaseResponse<UploadResultType[]>;
export type PresignedUrlResponseType = BaseResponse<PresignedUrlResultType>;
+2
View File
@@ -11,6 +11,7 @@ export interface UserMeType {
maxCredit: number;
addresse: string | null;
phone: string;
avatarUrl?: string | null;
}
export type GetMeResponseType = BaseResponse<UserMeType>;
@@ -18,4 +19,5 @@ export type GetMeResponseType = BaseResponse<UserMeType>;
export type UpdateProfileType = {
firstName: string;
lastName: string;
avatarUrl?: string;
};