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 { 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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -314,3 +314,4 @@ export const useDeleteTicketSubjectMutation = () => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -206,3 +206,4 @@ export const deleteTicketSubject = async (id: string | number) => {
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -265,3 +265,4 @@ const TicketsListPage = () => {
|
||||||
|
|
||||||
export default TicketsListPage;
|
export default TicketsListPage;
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue