feat(products): enhance ProductDetailPage to support video previews and improve media handling
This commit is contained in:
parent
e4d5ac4736
commit
554f050941
|
|
@ -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">
|
||||
{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 cursor-pointer" onClick={() => setPreviewImage(image.url)}
|
||||
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>
|
||||
)}
|
||||
|
||||
{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)}
|
||||
{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>
|
||||
)}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -314,3 +314,4 @@ export const useDeleteTicketSubjectMutation = () => {
|
|||
});
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -206,3 +206,4 @@ export const deleteTicketSubject = async (id: string | number) => {
|
|||
return response.data;
|
||||
};
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -265,3 +265,4 @@ const TicketsListPage = () => {
|
|||
|
||||
export default TicketsListPage;
|
||||
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue