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 { API_GATE_WAY, API_ROUTES } from '@/constant/routes';
type NormalizedMedia = {
id: string;
url: string;
alt: string;
order: number;
type: 'image' | 'video';
};
const ProductDetailPage = () => {
const navigate = useNavigate();
const { id = "" } = useParams();
const { data: product, isLoading, error } = useProduct(id);
// State for image preview must be defined before any early returns to keep hook order stable
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewMedia, setPreviewMedia] = useState<{ url: string; type: 'image' | 'video' } | null>(null);
if (isLoading) return <LoadingSpinner />;
if (error) return <div className="text-red-600">خطا در بارگذاری اطلاعات محصول</div>;
@ -31,7 +38,7 @@ const ProductDetailPage = () => {
};
const resolveFileUrl = (file: any) => {
const rawUrl = file?.url || '';
const rawUrl = file?.url || file?.file_url || file?.fileUrl || '';
const serveKey = file?.serve_key || file?.serveKey;
if (serveKey) {
return `${API_GATE_WAY}/${API_ROUTES.DOWNLOAD_FILE(serveKey)}`;
@ -43,33 +50,60 @@ const ProductDetailPage = () => {
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) => {
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) {
const nested = file.file;
const url = resolveFileUrl(nested);
return {
id: (nested.id ?? nested.FileID ?? index).toString(),
url: resolveFileUrl(nested),
url,
alt: nested.original_name || '',
order: index,
type: detectMediaType(nested, url),
};
}
if (file?.FileID) {
const url = resolveFileUrl(file);
return {
id: file.FileID.toString(),
url: resolveFileUrl(file),
url,
alt: file.original_name || '',
order: index,
type: detectMediaType(file, url),
};
}
const url = resolveFileUrl(file);
return {
id: (file.id ?? index).toString(),
url: resolveFileUrl(file),
url,
alt: file.original_name || '',
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_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 || []);
@ -257,23 +291,39 @@ const ProductDetailPage = () => {
تصاویر محصول
</SectionTitle>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{images.map((image: any, index: number) => (
<div key={image.id || index} className="relative group">
<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 cursor-pointer" onClick={() => setPreviewImage(image.url)}
/>
<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" />
{images.map((image, index) => {
const isVideo = image.type === 'video';
return (
<div
key={image.id || index}
className="relative group cursor-pointer"
onClick={() => setPreviewMedia({ url: image.url, type: isVideo ? 'video' : 'image' })}
>
{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>
)}
{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">
<SectionTitle className="flex items-center gap-2 mb-4">
<Image className="h-5 w-5" />
@ -283,19 +333,34 @@ const ProductDetailPage = () => {
این تصاویر در بخش Explorer نمایش داده میشوند.
</p>
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{explorerImages.map((image, index) => (
<div key={image.id || index} className="relative group">
<img
src={image.url}
alt={image.alt || `تصویر Explorer ${index + 1}`}
className="w-full h-32 object-cover rounded-lg border border-gray-200 dark:border-gray-600 cursor-pointer"
onClick={() => setPreviewImage(image.url)}
/>
<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" />
{explorerMedia.map((item, index) => {
const isVideo = item.type === 'video';
return (
<div
key={item.id || index}
className="relative group cursor-pointer"
onClick={() => setPreviewMedia({ url: item.url, type: isVideo ? 'video' : 'image' })}
>
{isVideo ? (
<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>
)}
@ -425,7 +490,8 @@ const ProductDetailPage = () => {
key={image.id || imgIndex}
src={image.url}
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>
@ -598,9 +664,18 @@ const ProductDetailPage = () => {
</div>
</div>
</PageContainer>
{previewImage && (
<Modal isOpen={true} onClose={() => setPreviewImage(null)} title="پیش‌نمایش تصویر" size="xl">
<img src={previewImage} alt="تصویر" className="w-full h-auto object-contain" />
{previewMedia && (
<Modal
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>
)}
</>

View File

@ -40,8 +40,8 @@ const productSchema = yup.object({
});
const toPublicUrl = (img: any): ProductImage => {
const rawUrl: string = img?.url || '';
const serveKey: string | undefined = (img && img.serve_key) || undefined;
const rawUrl: string = img?.url || img?.file_url || img?.fileUrl || '';
const serveKey: string | undefined = img?.serve_key || img?.serveKey;
const url = serveKey
? `${API_GATE_WAY}/${API_ROUTES.DOWNLOAD_FILE(serveKey)}`
: rawUrl?.startsWith('http')
@ -50,11 +50,13 @@ const toPublicUrl = (img: any): ProductImage => {
? `${API_GATE_WAY}${rawUrl.startsWith('/') ? '' : '/'}${rawUrl}`
: '';
return {
id: (img?.id ?? img).toString(),
id: (img?.id ?? img?.file_id ?? img?.FileID ?? img).toString(),
url,
alt: img?.alt || '',
alt: img?.alt || img?.original_name || '',
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 };
}
if (entry?.file_id) {
return {
id: entry.file_id.toString(),
url: entry.url || '',
alt: entry.name || '',
order: index,
type: entry.mime_type,
};
const normalized = toPublicUrl({
...entry,
id: entry.file_id,
url: entry.file_url || entry.url,
file_url: entry.file_url || entry.url,
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) {
return {
id: entry.FileID.toString(),
url: entry.url || '',
alt: '',
order: index,
};
const normalized = toPublicUrl({
...entry,
id: entry.FileID,
url: entry.file_url || entry.url,
file_url: entry.file_url || entry.url,
});
return { ...normalized, order: index };
}
if (typeof entry === 'number' || typeof entry === 'string') {
return {
id: entry.toString(),
url: '',
alt: '',
order: index,
};
const normalized = toPublicUrl(entry);
return { ...normalized, order: index };
}
const normalized = toPublicUrl(entry);
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;
};

View File

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