feat(products): enhance ProductDetailPage to support video previews and improve media handling

This commit is contained in:
hossein taromi 2025-11-23 15:43:18 +03:30
parent e4d5ac4736
commit 554f050941
5 changed files with 141 additions and 61 deletions

View File

@ -9,14 +9,21 @@ import { PRODUCT_TYPE_LABELS } from '../core/_models';
import { PageContainer, PageTitle, SectionTitle } from '../../../components/ui/Typography'; import { PageContainer, PageTitle, SectionTitle } from '../../../components/ui/Typography';
import { API_GATE_WAY, API_ROUTES } from '@/constant/routes'; import { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
type NormalizedMedia = {
id: string;
url: string;
alt: string;
order: number;
type: 'image' | 'video';
};
const ProductDetailPage = () => { const ProductDetailPage = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { id = "" } = useParams(); const { id = "" } = useParams();
const { data: product, isLoading, error } = useProduct(id); const { data: product, isLoading, error } = useProduct(id);
// State for image preview must be defined before any early returns to keep hook order stable const [previewMedia, setPreviewMedia] = useState<{ url: string; type: 'image' | 'video' } | null>(null);
const [previewImage, setPreviewImage] = useState<string | null>(null);
if (isLoading) return <LoadingSpinner />; if (isLoading) return <LoadingSpinner />;
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات محصول</div>; if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات محصول</div>;
@ -31,7 +38,7 @@ const ProductDetailPage = () => {
}; };
const resolveFileUrl = (file: any) => { const resolveFileUrl = (file: any) => {
const rawUrl = file?.url || ''; const rawUrl = file?.url || file?.file_url || file?.fileUrl || '';
const serveKey = file?.serve_key || file?.serveKey; const serveKey = file?.serve_key || file?.serveKey;
if (serveKey) { if (serveKey) {
return `${API_GATE_WAY}/${API_ROUTES.DOWNLOAD_FILE(serveKey)}`; return `${API_GATE_WAY}/${API_ROUTES.DOWNLOAD_FILE(serveKey)}`;
@ -43,33 +50,60 @@ const ProductDetailPage = () => {
return ''; return '';
}; };
const normalizeFiles = (items: any[]) => const detectMediaType = (file: any, url: string): 'image' | 'video' => {
const candidates = [
file?.file_type,
file?.type,
file?.mime_type,
file?.mimeType,
file?.file?.type,
file?.file?.mime_type,
]
.filter(Boolean)
.map((value) => value.toString().toLowerCase());
if (candidates.some((value) => value.includes('video'))) {
return 'video';
}
const referenceUrl = url || file?.file_url || '';
if (/\.(mp4|mov|m4v|webm|avi|mkv)(\?|$)/i.test(referenceUrl)) {
return 'video';
}
return 'image';
};
const normalizeFiles = (items: any[] = []): NormalizedMedia[] =>
(items || []).map((file, index) => { (items || []).map((file, index) => {
if (typeof file === 'number' || typeof file === 'string') { if (typeof file === 'number' || typeof file === 'string') {
return { id: file.toString(), url: '', alt: '', order: index }; return { id: file.toString(), url: '', alt: '', order: index, type: 'image' };
} }
if (file?.file) { if (file?.file) {
const nested = file.file; const nested = file.file;
const url = resolveFileUrl(nested);
return { return {
id: (nested.id ?? nested.FileID ?? index).toString(), id: (nested.id ?? nested.FileID ?? index).toString(),
url: resolveFileUrl(nested), url,
alt: nested.original_name || '', alt: nested.original_name || '',
order: index, order: index,
type: detectMediaType(nested, url),
}; };
} }
if (file?.FileID) { if (file?.FileID) {
const url = resolveFileUrl(file);
return { return {
id: file.FileID.toString(), id: file.FileID.toString(),
url: resolveFileUrl(file), url,
alt: file.original_name || '', alt: file.original_name || '',
order: index, order: index,
type: detectMediaType(file, url),
}; };
} }
const url = resolveFileUrl(file);
return { return {
id: (file.id ?? index).toString(), id: (file.id ?? index).toString(),
url: resolveFileUrl(file), url,
alt: file.original_name || '', alt: file.original_name || '',
order: index, order: index,
type: detectMediaType(file, url),
}; };
}); });
@ -81,7 +115,7 @@ const ProductDetailPage = () => {
(product as any).explorer_files && (product as any).explorer_files.length > 0 (product as any).explorer_files && (product as any).explorer_files.length > 0
? (product as any).explorer_files ? (product as any).explorer_files
: (product as any).explorer_file_ids || []; : (product as any).explorer_file_ids || [];
const explorerImages = normalizeFiles(rawExplorerFiles).filter((item) => item.url); const explorerMedia = normalizeFiles(rawExplorerFiles).filter((item) => item.url);
// نسخه‌های محصول // نسخه‌های محصول
const variants = (product.variants && product.variants.length > 0) ? product.variants : ((product as any).product_variants || []); const variants = (product.variants && product.variants.length > 0) ? product.variants : ((product as any).product_variants || []);
@ -257,23 +291,39 @@ const ProductDetailPage = () => {
تصاویر محصول تصاویر محصول
</SectionTitle> </SectionTitle>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{images.map((image: any, index: number) => ( {images.map((image, index) => {
<div key={image.id || index} className="relative group"> const isVideo = image.type === 'video';
<img return (
src={image.url} <div
alt={image.alt || `تصویر ${index + 1}`} key={image.id || index}
className="w-full h-32 object-cover rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer" onClick={() => setPreviewImage(image.url)} className="relative group cursor-pointer"
/> onClick={() => setPreviewMedia({ url: image.url, type: isVideo ? 'video' : 'image' })}
<div className="absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center"> >
<Eye className="h-6 w-6 text-white" /> {isVideo ? (
<video
src={image.url}
className="w-full h-32 object-cover rounded-lg border border-gray-200 dark:border-gray-600"
muted
playsInline
/>
) : (
<img
src={image.url}
alt={image.alt || `تصویر ${index + 1}`}
className="w-full h-32 object-cover rounded-lg border border-gray-200 dark:border-gray-600"
/>
)}
<div className="absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center">
<Eye className="h-6 w-6 text-white" />
</div>
</div> </div>
</div> );
))} })}
</div> </div>
</div> </div>
)} )}
{explorerImages.length > 0 && ( {explorerMedia.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6"> <div className="bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-6">
<SectionTitle className="flex items-center gap-2 mb-4"> <SectionTitle className="flex items-center gap-2 mb-4">
<Image className="h-5 w-5" /> <Image className="h-5 w-5" />
@ -283,19 +333,34 @@ const ProductDetailPage = () => {
این تصاویر در بخش Explorer نمایش داده میشوند. این تصاویر در بخش Explorer نمایش داده میشوند.
</p> </p>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{explorerImages.map((image, index) => ( {explorerMedia.map((item, index) => {
<div key={image.id || index} className="relative group"> const isVideo = item.type === 'video';
<img return (
src={image.url} <div
alt={image.alt || `تصویر Explorer ${index + 1}`} key={item.id || index}
className="w-full h-32 object-cover rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer" className="relative group cursor-pointer"
onClick={() => setPreviewImage(image.url)} onClick={() => setPreviewMedia({ url: item.url, type: isVideo ? 'video' : 'image' })}
/> >
<div className="absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center"> {isVideo ? (
<Eye className="h-6 w-6 text-white" /> <video
src={item.url}
className="w-full h-32 object-cover rounded-lg border border-gray-200 dark:border-gray-600"
muted
playsInline
/>
) : (
<img
src={item.url}
alt={item.alt || `تصویر Explorer ${index + 1}`}
className="w-full h-32 object-cover rounded-lg border border-gray-200 dark:border-gray-600"
/>
)}
<div className="absolute inset-0 bg-black bg-opacity-50 opacity-0 group-hover:opacity-100 transition-opacity rounded-lg flex items-center justify-center">
<Eye className="h-6 w-6 text-white" />
</div>
</div> </div>
</div> );
))} })}
</div> </div>
</div> </div>
)} )}
@ -425,7 +490,8 @@ const ProductDetailPage = () => {
key={image.id || imgIndex} key={image.id || imgIndex}
src={image.url} src={image.url}
alt={image.alt || `تصویر نسخه ${imgIndex + 1}`} alt={image.alt || `تصویر نسخه ${imgIndex + 1}`}
className="w-full h-16 object-cover rounded border border-gray-200 dark:border-gray-600 cursor-pointer" onClick={() => setPreviewImage(image.url)} className="w-full h-16 object-cover rounded border border-gray-200 dark:border-gray-600 cursor-pointer"
onClick={() => setPreviewMedia({ url: image.url, type: 'image' })}
/> />
))} ))}
</div> </div>
@ -598,9 +664,18 @@ const ProductDetailPage = () => {
</div> </div>
</div> </div>
</PageContainer> </PageContainer>
{previewImage && ( {previewMedia && (
<Modal isOpen={true} onClose={() => setPreviewImage(null)} title="پیش‌نمایش تصویر" size="xl"> <Modal
<img src={previewImage} alt="تصویر" className="w-full h-auto object-contain" /> isOpen={true}
onClose={() => setPreviewMedia(null)}
title={previewMedia.type === 'video' ? 'پیش‌نمایش ویدیو' : 'پیش‌نمایش تصویر'}
size="xl"
>
{previewMedia.type === 'video' ? (
<video src={previewMedia.url} controls className="w-full h-auto object-contain max-h-[80vh]" />
) : (
<img src={previewMedia.url} alt="تصویر" className="w-full h-auto object-contain" />
)}
</Modal> </Modal>
)} )}
</> </>

View File

@ -40,8 +40,8 @@ const productSchema = yup.object({
}); });
const toPublicUrl = (img: any): ProductImage => { const toPublicUrl = (img: any): ProductImage => {
const rawUrl: string = img?.url || ''; const rawUrl: string = img?.url || img?.file_url || img?.fileUrl || '';
const serveKey: string | undefined = (img && img.serve_key) || undefined; const serveKey: string | undefined = img?.serve_key || img?.serveKey;
const url = serveKey const url = serveKey
? `${API_GATE_WAY}/${API_ROUTES.DOWNLOAD_FILE(serveKey)}` ? `${API_GATE_WAY}/${API_ROUTES.DOWNLOAD_FILE(serveKey)}`
: rawUrl?.startsWith('http') : rawUrl?.startsWith('http')
@ -50,11 +50,13 @@ const toPublicUrl = (img: any): ProductImage => {
? `${API_GATE_WAY}${rawUrl.startsWith('/') ? '' : '/'}${rawUrl}` ? `${API_GATE_WAY}${rawUrl.startsWith('/') ? '' : '/'}${rawUrl}`
: ''; : '';
return { return {
id: (img?.id ?? img).toString(), id: (img?.id ?? img?.file_id ?? img?.FileID ?? img).toString(),
url, url,
alt: img?.alt || '', alt: img?.alt || img?.original_name || '',
order: img?.order ?? 0, order: img?.order ?? 0,
type: img?.mime_type || img?.type, type: img?.mime_type || img?.type || img?.file_type,
mime_type: img?.mime_type || img?.file_type,
size: img?.size,
}; };
}; };
@ -110,29 +112,29 @@ const mapExplorerFiles = (entries: any[]): ProductImage[] => {
return { ...media, order: index }; return { ...media, order: index };
} }
if (entry?.file_id) { if (entry?.file_id) {
return { const normalized = toPublicUrl({
id: entry.file_id.toString(), ...entry,
url: entry.url || '', id: entry.file_id,
alt: entry.name || '', url: entry.file_url || entry.url,
order: index, file_url: entry.file_url || entry.url,
type: entry.mime_type, mime_type: entry.mime_type || entry.file_type,
}; type: entry.file_type || entry.type,
original_name: entry.name || entry.original_name,
});
return { ...normalized, order: index };
} }
if (entry?.FileID) { if (entry?.FileID) {
return { const normalized = toPublicUrl({
id: entry.FileID.toString(), ...entry,
url: entry.url || '', id: entry.FileID,
alt: '', url: entry.file_url || entry.url,
order: index, file_url: entry.file_url || entry.url,
}; });
return { ...normalized, order: index };
} }
if (typeof entry === 'number' || typeof entry === 'string') { if (typeof entry === 'number' || typeof entry === 'string') {
return { const normalized = toPublicUrl(entry);
id: entry.toString(), return { ...normalized, order: index };
url: '',
alt: '',
order: index,
};
} }
const normalized = toPublicUrl(entry); const normalized = toPublicUrl(entry);
return { ...normalized, order: index }; return { ...normalized, order: index };

View File

@ -314,3 +314,4 @@ export const useDeleteTicketSubjectMutation = () => {
}); });
}; };

View File

@ -206,3 +206,4 @@ export const deleteTicketSubject = async (id: string | number) => {
return response.data; return response.data;
}; };

View File

@ -265,3 +265,4 @@ const TicketsListPage = () => {
export default TicketsListPage; export default TicketsListPage;