feat: add dedicated podcast modal player, integrate video modal player in product page, and add media metadata management in admin
All checks were successful
Deploy Canina / deploy (push) Successful in 1m50s

This commit is contained in:
parsa aghaei 2026-08-18 11:34:04 +03:30
parent 27a1517464
commit f9dad098ef
8 changed files with 954 additions and 138 deletions

View File

@ -149,8 +149,17 @@ model Product {
imageUrl String @map("image_url") @db.Text
images String[] @default([])
podcastUrl String? @map("podcast_url") @db.Text
podcastTitle String? @map("podcast_title") @db.VarChar(250)
podcastDescription String? @map("podcast_description") @db.Text
podcastCover String? @map("podcast_cover") @db.Text
videoUrl String? @map("video_url") @db.Text
videoTitle String? @map("video_title") @db.VarChar(250)
videoDescription String? @map("video_description") @db.Text
videoCover String? @map("video_cover") @db.Text
pdfUrl String? @map("pdf_url") @db.Text
pdfTitle String? @map("pdf_title") @db.VarChar(250)
pdfDescription String? @map("pdf_description") @db.Text
pdfCover String? @map("pdf_cover") @db.Text
metaTitle String? @map("meta_title") @db.VarChar(200)
metaDescription String? @map("meta_description") @db.Text
canonicalUrl String? @map("canonical_url") @db.Text

View File

@ -379,8 +379,17 @@ export class AdminService {
? [data.images]
: [],
podcastUrl: data.podcastUrl || null,
podcastTitle: data.podcastTitle || null,
podcastDescription: data.podcastDescription || null,
podcastCover: data.podcastCover || null,
videoUrl: data.videoUrl || null,
videoTitle: data.videoTitle || null,
videoDescription: data.videoDescription || null,
videoCover: data.videoCover || null,
pdfUrl: data.pdfUrl || null,
pdfTitle: data.pdfTitle || null,
pdfDescription: data.pdfDescription || null,
pdfCover: data.pdfCover || null,
metaTitle: data.metaTitle || '',
metaDescription: data.metaDescription || '',
keywords: data.keywords || '',
@ -435,8 +444,17 @@ export class AdminService {
? [data.images]
: undefined,
podcastUrl: data.podcastUrl !== undefined ? data.podcastUrl : undefined,
podcastTitle: data.podcastTitle !== undefined ? data.podcastTitle : undefined,
podcastDescription: data.podcastDescription !== undefined ? data.podcastDescription : undefined,
podcastCover: data.podcastCover !== undefined ? data.podcastCover : undefined,
videoUrl: data.videoUrl !== undefined ? data.videoUrl : undefined,
videoTitle: data.videoTitle !== undefined ? data.videoTitle : undefined,
videoDescription: data.videoDescription !== undefined ? data.videoDescription : undefined,
videoCover: data.videoCover !== undefined ? data.videoCover : undefined,
pdfUrl: data.pdfUrl !== undefined ? data.pdfUrl : undefined,
pdfTitle: data.pdfTitle !== undefined ? data.pdfTitle : undefined,
pdfDescription: data.pdfDescription !== undefined ? data.pdfDescription : undefined,
pdfCover: data.pdfCover !== undefined ? data.pdfCover : undefined,
metaTitle: data.metaTitle,
metaDescription: data.metaDescription,
keywords: data.keywords,

View File

@ -113,16 +113,61 @@ export class ProductDto {
@IsString()
podcastUrl?: string | null;
@ApiPropertyOptional({ description: 'عنوان اختصاصی پادکست' })
@IsOptional()
@IsString()
podcastTitle?: string | null;
@ApiPropertyOptional({ description: 'توضیحات پادکست' })
@IsOptional()
@IsString()
podcastDescription?: string | null;
@ApiPropertyOptional({ description: 'تصویر کاور پادکست' })
@IsOptional()
@IsString()
podcastCover?: string | null;
@ApiPropertyOptional({ description: 'لینک ویدیو' })
@IsOptional()
@IsString()
videoUrl?: string | null;
@ApiPropertyOptional({ description: 'عنوان اختصاصی ویدیو' })
@IsOptional()
@IsString()
videoTitle?: string | null;
@ApiPropertyOptional({ description: 'توضیحات ویدیو' })
@IsOptional()
@IsString()
videoDescription?: string | null;
@ApiPropertyOptional({ description: 'تصویر کاور/پوستر ویدیو' })
@IsOptional()
@IsString()
videoCover?: string | null;
@ApiPropertyOptional({ description: 'لینک فایل PDF' })
@IsOptional()
@IsString()
pdfUrl?: string | null;
@ApiPropertyOptional({ description: 'عنوان فایل PDF' })
@IsOptional()
@IsString()
pdfTitle?: string | null;
@ApiPropertyOptional({ description: 'توضیحات فایل PDF' })
@IsOptional()
@IsString()
pdfDescription?: string | null;
@ApiPropertyOptional({ description: 'تصویر پیش‌نمایش فایل PDF' })
@IsOptional()
@IsString()
pdfCover?: string | null;
@ApiPropertyOptional({ description: 'عنوان سئو' })
@IsOptional()
@IsString()

View File

@ -41,10 +41,18 @@ export interface Product {
keywords?: string;
canonicalUrl?: string;
slug?: string;
images?: string[];
podcastUrl?: string;
podcastTitle?: string;
podcastDescription?: string;
podcastCover?: string;
videoUrl?: string;
videoTitle?: string;
videoDescription?: string;
videoCover?: string;
pdfUrl?: string;
pdfTitle?: string;
pdfDescription?: string;
pdfCover?: string;
symptoms?: Array<{ symptom: string } | string>;
isPreorder?: boolean;
preorderDeposit?: string | number;
@ -136,7 +144,7 @@ export default function Products() {
// Modal State
const [isModalOpen, setIsModalOpen] = useState(false);
const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false);
const [mediaTargetField, setMediaTargetField] = useState<'imageUrl' | 'podcastUrl' | 'videoUrl' | 'pdfUrl'>('imageUrl');
const [mediaTargetField, setMediaTargetField] = useState<'imageUrl' | 'podcastUrl' | 'podcastCover' | 'videoUrl' | 'videoCover' | 'pdfUrl' | 'pdfCover' | 'gallery'>('imageUrl');
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('general');
@ -167,8 +175,17 @@ export default function Products() {
slug: '',
images: [] as string[],
podcastUrl: '',
podcastTitle: '',
podcastDescription: '',
podcastCover: '',
videoUrl: '',
videoTitle: '',
videoDescription: '',
videoCover: '',
pdfUrl: '',
pdfTitle: '',
pdfDescription: '',
pdfCover: '',
symptoms: [] as string[],
isPreorder: false,
preorderDeposit: '' as number | string
@ -254,11 +271,19 @@ export default function Products() {
metaDescription: product.metaDescription || '',
keywords: product.keywords || '',
canonicalUrl: product.canonicalUrl || '',
slug: product.slug || '',
images: Array.isArray(product.images) ? product.images : [],
podcastUrl: product.podcastUrl || '',
podcastTitle: product.podcastTitle || '',
podcastDescription: product.podcastDescription || '',
podcastCover: product.podcastCover || '',
videoUrl: product.videoUrl || '',
videoTitle: product.videoTitle || '',
videoDescription: product.videoDescription || '',
videoCover: product.videoCover || '',
pdfUrl: product.pdfUrl || '',
pdfTitle: product.pdfTitle || '',
pdfDescription: product.pdfDescription || '',
pdfCover: product.pdfCover || '',
symptoms: product.symptoms ? product.symptoms.map((s: { symptom: string } | string) => typeof s === 'string' ? s : s.symptom) : [],
isPreorder: Boolean(product.isPreorder),
preorderDeposit: product.preorderDeposit || ''
@ -270,7 +295,10 @@ export default function Products() {
buyPrice: '', priceValue: 0, wholesalePrice: '', priceValueMarginPercent: '', wholesaleMarginPercent: '',
priceDisplay: '', unit: '', packageSize: 0, dosageLogic: '', suitableFor: 'سگ و گربه',
imageUrl: '', metaTitle: '', metaDescription: '', keywords: '', canonicalUrl: '', slug: '',
images: [], podcastUrl: '', videoUrl: '', pdfUrl: '',
images: [],
podcastUrl: '', podcastTitle: '', podcastDescription: '', podcastCover: '',
videoUrl: '', videoTitle: '', videoDescription: '', videoCover: '',
pdfUrl: '', pdfTitle: '', pdfDescription: '', pdfCover: '',
symptoms: [], isPreorder: false, preorderDeposit: ''
});
}
@ -1277,20 +1305,36 @@ export default function Products() {
)}
</div>
{/* Multimedia Attachments (TASK-2.3 & TASK-2.4) */}
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm space-y-4">
<h4 className="font-bold text-gray-800 text-sm border-b pb-2">پیوستهای مالتیمدیا و مستندات علمی</h4>
{/* Multimedia Attachments */}
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm space-y-6">
<div>
<h4 className="font-bold text-gray-800 text-sm border-b pb-2">پیوستهای مالتیمدیا و مستندات علمی تخصصی</h4>
<p className="text-[11px] text-gray-500 mt-1">مدیریت فایلهای صوتی، تصویری و کاتالوگهای اختصاصی این محصول همراه با عنوان، توضیحات و کاور اختصاصی.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 block">لینک فصلی پادکست (Podcast URL)</label>
{/* Section 1: Podcast Management Card */}
<div className="bg-purple-50/40 border border-purple-100 rounded-2xl p-4 space-y-3">
<div className="flex items-center justify-between border-b border-purple-100/80 pb-2">
<span className="text-xs font-black text-purple-900 flex items-center gap-1.5">
🎧 تنظیمات پادکست و بررسی صوتی بالینی
</span>
{formData.podcastUrl && (
<span className="text-[10px] bg-purple-200 text-purple-800 font-bold px-2 py-0.5 rounded-full">
فایل صوتی متصل است
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">لینک یا فایل پادکست (Audio/MP3 URL)</label>
<div className="flex gap-1.5">
<input
type="text"
value={formData.podcastUrl}
onChange={(e) => setFormData({ ...formData, podcastUrl: e.target.value })}
placeholder="https://... MP3 or Soundcloud"
className="w-full px-3 py-2 border rounded-xl text-xs font-mono"
placeholder="https://... /uploads/audio.mp3"
className="w-full px-3 py-2 border rounded-xl text-xs font-mono bg-white"
dir="ltr"
/>
<button
@ -1299,21 +1343,84 @@ export default function Products() {
setMediaTargetField('podcastUrl');
setIsMediaSelectorOpen(true);
}}
className="px-2 py-1 bg-purple-100 text-purple-700 hover:bg-purple-200 rounded-lg text-xs font-bold whitespace-nowrap"
className="px-2.5 py-1 bg-purple-600 text-white hover:bg-purple-700 rounded-lg text-xs font-bold whitespace-nowrap cursor-pointer"
>
انتخاب
انتخاب فایل
</button>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 block">لینک یا کدفریم ویدیو (Video URL)</label>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">تصویر کاور اختصاصی پادکست (اختیاری)</label>
<div className="flex gap-1.5">
<input
type="text"
value={formData.podcastCover}
onChange={(e) => setFormData({ ...formData, podcastCover: e.target.value })}
placeholder="در صورت خالی بودن، تصویر اصلی محصول استفاده می‌شود"
className="w-full px-3 py-2 border rounded-xl text-xs font-mono bg-white"
dir="ltr"
/>
<button
type="button"
onClick={() => {
setMediaTargetField('podcastCover');
setIsMediaSelectorOpen(true);
}}
className="px-2.5 py-1 bg-purple-100 text-purple-700 hover:bg-purple-200 rounded-lg text-xs font-bold whitespace-nowrap cursor-pointer"
>
انتخاب کاور
</button>
</div>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">عنوان پادکست</label>
<input
type="text"
value={formData.podcastTitle}
onChange={(e) => setFormData({ ...formData, podcastTitle: e.target.value })}
placeholder="مثلاً: بررسی بالینی اثر مکمل بر سلامت مفاصل سگ‌ها"
className="w-full px-3 py-2 border rounded-xl text-xs bg-white font-medium"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">توضیحات کوتاه پادکست</label>
<textarea
rows={2}
value={formData.podcastDescription}
onChange={(e) => setFormData({ ...formData, podcastDescription: e.target.value })}
placeholder="توضیحات و نکات مطرح شده در پادکست تخصصی..."
className="w-full px-3 py-2 border rounded-xl text-xs bg-white font-medium resize-none"
/>
</div>
</div>
{/* Section 2: Video Management Card */}
<div className="bg-blue-50/40 border border-blue-100 rounded-2xl p-4 space-y-3">
<div className="flex items-center justify-between border-b border-blue-100/80 pb-2">
<span className="text-xs font-black text-blue-900 flex items-center gap-1.5">
🎬 تنظیمات ویدیو و راهنمای مصرف
</span>
{formData.videoUrl && (
<span className="text-[10px] bg-blue-200 text-blue-800 font-bold px-2 py-0.5 rounded-full">
ویدیو متصل است
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">لینک مستقیم یا کدفریم ویدیو (Video URL / Embed)</label>
<div className="flex gap-1.5">
<input
type="text"
value={formData.videoUrl}
onChange={(e) => setFormData({ ...formData, videoUrl: e.target.value })}
placeholder="https://... MP4 or Aparat/YouTube iframe"
className="w-full px-3 py-2 border rounded-xl text-xs font-mono"
placeholder="https://... MP4 or Aparat iframe"
className="w-full px-3 py-2 border rounded-xl text-xs font-mono bg-white"
dir="ltr"
/>
<button
@ -1322,21 +1429,84 @@ export default function Products() {
setMediaTargetField('videoUrl');
setIsMediaSelectorOpen(true);
}}
className="px-2 py-1 bg-purple-100 text-purple-700 hover:bg-purple-200 rounded-lg text-xs font-bold whitespace-nowrap"
className="px-2.5 py-1 bg-blue-600 text-white hover:bg-blue-700 rounded-lg text-xs font-bold whitespace-nowrap cursor-pointer"
>
انتخاب
انتخاب فایل
</button>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-gray-700 block">فایل کاتالوگ / PDF علمی (PDF URL)</label>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">تصویر پوستر / کاور ویدیو (اختیاری)</label>
<div className="flex gap-1.5">
<input
type="text"
value={formData.videoCover}
onChange={(e) => setFormData({ ...formData, videoCover: e.target.value })}
placeholder="در صورت خالی بودن، تصویر محصول استفاده می‌شود"
className="w-full px-3 py-2 border rounded-xl text-xs font-mono bg-white"
dir="ltr"
/>
<button
type="button"
onClick={() => {
setMediaTargetField('videoCover');
setIsMediaSelectorOpen(true);
}}
className="px-2.5 py-1 bg-blue-100 text-blue-700 hover:bg-blue-200 rounded-lg text-xs font-bold whitespace-nowrap cursor-pointer"
>
انتخاب کاور
</button>
</div>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">عنوان ویدیو</label>
<input
type="text"
value={formData.videoTitle}
onChange={(e) => setFormData({ ...formData, videoTitle: e.target.value })}
placeholder="مثلاً: راهنمای نحوه خوراندن و دوز مصرف مکمل"
className="w-full px-3 py-2 border rounded-xl text-xs bg-white font-medium"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">توضیحات کوتاه ویدیو</label>
<textarea
rows={2}
value={formData.videoDescription}
onChange={(e) => setFormData({ ...formData, videoDescription: e.target.value })}
placeholder="توضیحات ویدیوی آموزشی و نکات کلیدی..."
className="w-full px-3 py-2 border rounded-xl text-xs bg-white font-medium resize-none"
/>
</div>
</div>
{/* Section 3: PDF / Document Management Card */}
<div className="bg-amber-50/40 border border-amber-100 rounded-2xl p-4 space-y-3">
<div className="flex items-center justify-between border-b border-amber-100/80 pb-2">
<span className="text-xs font-black text-amber-900 flex items-center gap-1.5">
📄 تنظیمات کاتالوگ و مستندات PDF
</span>
{formData.pdfUrl && (
<span className="text-[10px] bg-amber-200 text-amber-800 font-bold px-2 py-0.5 rounded-full">
فایل PDF متصل است
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">لینک یا فایل کاتالوگ (PDF URL)</label>
<div className="flex gap-1.5">
<input
type="text"
value={formData.pdfUrl}
onChange={(e) => setFormData({ ...formData, pdfUrl: e.target.value })}
placeholder="https://... PDF catalog"
className="w-full px-3 py-2 border rounded-xl text-xs font-mono"
placeholder="https://... /uploads/catalog.pdf"
className="w-full px-3 py-2 border rounded-xl text-xs font-mono bg-white"
dir="ltr"
/>
<button
@ -1345,12 +1515,59 @@ export default function Products() {
setMediaTargetField('pdfUrl');
setIsMediaSelectorOpen(true);
}}
className="px-2 py-1 bg-purple-100 text-purple-700 hover:bg-purple-200 rounded-lg text-xs font-bold whitespace-nowrap"
className="px-2.5 py-1 bg-amber-600 text-white hover:bg-amber-700 rounded-lg text-xs font-bold whitespace-nowrap cursor-pointer"
>
انتخاب
انتخاب فایل
</button>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">تصویر پیشنمایش کاتالوگ (اختیاری)</label>
<div className="flex gap-1.5">
<input
type="text"
value={formData.pdfCover}
onChange={(e) => setFormData({ ...formData, pdfCover: e.target.value })}
placeholder="در صورت خالی بودن، تصویر محصول استفاده می‌شود"
className="w-full px-3 py-2 border rounded-xl text-xs font-mono bg-white"
dir="ltr"
/>
<button
type="button"
onClick={() => {
setMediaTargetField('pdfCover');
setIsMediaSelectorOpen(true);
}}
className="px-2.5 py-1 bg-amber-100 text-amber-800 hover:bg-amber-200 rounded-lg text-xs font-bold whitespace-nowrap cursor-pointer"
>
انتخاب کاور
</button>
</div>
</div>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">عنوان فایل PDF</label>
<input
type="text"
value={formData.pdfTitle}
onChange={(e) => setFormData({ ...formData, pdfTitle: e.target.value })}
placeholder="مثلاً: کاتالوگ جامع مشخصات فنی و مطالعات بالینی"
className="w-full px-3 py-2 border rounded-xl text-xs bg-white font-medium"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-bold text-gray-700 block">توضیحات فایل PDF</label>
<textarea
rows={2}
value={formData.pdfDescription}
onChange={(e) => setFormData({ ...formData, pdfDescription: e.target.value })}
placeholder="توضیحات و محتویات کاتالوگ و بروشور رسمی..."
className="w-full px-3 py-2 border rounded-xl text-xs bg-white font-medium resize-none"
/>
</div>
</div>
</div>
</div>

View File

@ -0,0 +1,391 @@
"use client";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { motion, AnimatePresence } from "motion/react";
import {
Play,
Pause,
RotateCcw,
RotateCw,
Volume2,
VolumeX,
Download,
Share2,
X,
Check,
Headphones,
Loader2,
Sparkles
} from "lucide-react";
interface PodcastPlayerModalProps {
isOpen: boolean;
onClose: () => void;
podcast: {
audioUrl?: string;
title?: string;
description?: string;
cover?: string;
productName?: string;
fallbackCover?: string;
} | null;
}
const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5];
export default function PodcastPlayerModal({ isOpen, onClose, podcast }: PodcastPlayerModalProps) {
const audioRef = useRef<HTMLAudioElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
const [playbackRate, setPlaybackRate] = useState(1);
const [showRateMenu, setShowRateMenu] = useState(false);
const [isBuffering, setIsBuffering] = useState(false);
const [isCopied, setIsCopied] = useState(false);
// Reset states on open/close
useEffect(() => {
if (isOpen) {
setIsPlaying(true);
setShowRateMenu(false);
setIsBuffering(false);
} else {
setIsPlaying(false);
setCurrentTime(0);
}
}, [isOpen, podcast]);
const togglePlay = useCallback(() => {
if (!audioRef.current) return;
if (audioRef.current.paused) {
audioRef.current.play().catch(console.error);
setIsPlaying(true);
} else {
audioRef.current.pause();
setIsPlaying(false);
}
}, []);
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const time = Number(e.target.value);
if (audioRef.current) {
audioRef.current.currentTime = time;
setCurrentTime(time);
}
};
const skipTime = (seconds: number) => {
if (audioRef.current) {
audioRef.current.currentTime = Math.max(0, Math.min(audioRef.current.duration || 0, audioRef.current.currentTime + seconds));
}
};
const handleRateChange = (rate: number) => {
if (audioRef.current) {
audioRef.current.playbackRate = rate;
setPlaybackRate(rate);
setShowRateMenu(false);
}
};
const toggleMute = () => {
if (audioRef.current) {
audioRef.current.muted = !isMuted;
setIsMuted(!isMuted);
}
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = Number(e.target.value);
setVolume(val);
if (audioRef.current) {
audioRef.current.volume = val;
if (val === 0) {
setIsMuted(true);
audioRef.current.muted = true;
} else {
setIsMuted(false);
audioRef.current.muted = false;
}
}
};
const handleShare = async () => {
const shareUrl = window.location.href;
if (navigator.share) {
try {
await navigator.share({
title: podcast?.title || 'پادکست و بررسی تخصصی کنینا',
text: podcast?.description || '',
url: shareUrl
});
} catch {
// Fallback or dismissed
}
} else {
try {
await navigator.clipboard.writeText(shareUrl);
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2500);
} catch (err) {
console.error('Copy failed:', err);
}
}
};
const handleDownload = () => {
if (!podcast?.audioUrl) return;
const a = document.createElement('a');
a.href = podcast.audioUrl;
a.download = `${podcast.title || 'canina-podcast'}.mp3`;
a.target = '_blank';
a.rel = 'noopener noreferrer';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const formatTime = (secs: number) => {
if (isNaN(secs) || secs < 0) return "00:00";
const mins = Math.floor(secs / 60);
const remainingSecs = Math.floor(secs % 60);
return `${mins.toString().padStart(2, "0")}:${remainingSecs.toString().padStart(2, "0")}`;
};
if (!isOpen || !podcast) return null;
const displayCover = podcast.cover || podcast.fallbackCover || '/images/default-podcast.png';
return (
<AnimatePresence>
<div className="fixed inset-0 z-[120] flex items-center justify-center p-3 sm:p-6 font-vazir" dir="rtl">
{/* Backdrop */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-black/85 backdrop-blur-xl"
/>
{/* Modal Content */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 20 }}
transition={{ duration: 0.25 }}
className="relative bg-gradient-to-b from-neutral-900 via-neutral-900 to-black w-full max-w-lg rounded-[2.5rem] overflow-hidden shadow-2xl border border-white/15 p-6 sm:p-8 flex flex-col items-center select-none text-white z-10"
>
{/* Top Bar (Header + Close) */}
<div className="w-full flex items-center justify-between mb-6">
<div className="flex items-center gap-2.5 text-xs font-black text-canina-blue bg-canina-blue/10 px-3.5 py-1.5 rounded-full border border-canina-blue/20">
<Headphones className="w-4 h-4 animate-pulse" />
<span>پادکست و بررسی صوتی کنینا</span>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={handleShare}
className="p-2.5 rounded-full bg-white/10 hover:bg-white/20 text-white transition-all cursor-pointer"
title="اشتراک‌گذاری"
>
{isCopied ? <Check className="w-4 h-4 text-emerald-400" /> : <Share2 className="w-4 h-4" />}
</button>
<button
type="button"
onClick={handleDownload}
className="p-2.5 rounded-full bg-white/10 hover:bg-white/20 text-white transition-all cursor-pointer"
title="دانلود فایل صوتی"
>
<Download className="w-4 h-4" />
</button>
<button
type="button"
onClick={onClose}
className="p-2.5 rounded-full bg-white/10 hover:bg-red-500/80 text-white transition-all cursor-pointer mr-1"
title="بستن"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Hidden Audio Tag */}
<audio
ref={audioRef}
src={podcast.audioUrl}
autoPlay
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onTimeUpdate={() => {
if (audioRef.current) setCurrentTime(audioRef.current.currentTime);
}}
onLoadedMetadata={() => {
if (audioRef.current) setDuration(audioRef.current.duration);
setIsBuffering(false);
}}
onWaiting={() => setIsBuffering(true)}
onPlaying={() => setIsBuffering(false)}
onEnded={() => setIsPlaying(false)}
/>
{/* Cover Art Artwork */}
<div className="relative group my-2">
<div className="w-48 h-48 sm:w-56 sm:h-56 rounded-3xl overflow-hidden shadow-2xl border-2 border-white/20 relative bg-neutral-800 flex items-center justify-center">
<img
src={displayCover}
alt={podcast.title || "کاور پادکست"}
className={`w-full h-full object-cover transition-transform duration-700 ${isPlaying ? "scale-105" : "scale-100"}`}
onError={(e) => {
(e.target as HTMLImageElement).src = '/images/default-podcast.png';
}}
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 via-transparent to-transparent pointer-events-none" />
{isBuffering && (
<div className="absolute inset-0 bg-black/40 backdrop-blur-xs flex items-center justify-center">
<Loader2 className="w-10 h-10 text-white animate-spin" />
</div>
)}
</div>
</div>
{/* Title and Metadata */}
<div className="w-full text-center mt-5 mb-4 space-y-1.5">
<h3 className="text-lg sm:text-xl font-black text-white line-clamp-1">
{podcast.title || "بررسی بالینی و نحوه مصرف مکمل"}
</h3>
{podcast.productName && (
<p className="text-canina-blue text-xs font-bold flex items-center justify-center gap-1">
<Sparkles className="w-3.5 h-3.5" />
<span>محصول: {podcast.productName}</span>
</p>
)}
{podcast.description && (
<p className="text-neutral-300 text-xs line-clamp-2 px-2 pt-1 font-medium leading-relaxed">
{podcast.description}
</p>
)}
</div>
{/* Seeker / Progress Bar */}
<div className="w-full space-y-1.5 mt-2 mb-4">
<div className="flex items-center gap-3 dir-ltr" dir="ltr">
<span className="text-[11px] font-mono font-bold text-white/60 w-10 text-right">
{formatTime(currentTime)}
</span>
<div className="relative flex-1 flex items-center">
<input
type="range"
min={0}
max={duration || 100}
step={0.1}
value={currentTime}
onChange={handleSeek}
className="w-full h-2 bg-white/20 rounded-lg appearance-none cursor-pointer accent-canina-blue hover:h-2.5 transition-all"
/>
</div>
<span className="text-[11px] font-mono font-bold text-white/60 w-10 text-left">
{formatTime(duration)}
</span>
</div>
</div>
{/* Main Controls Row */}
<div className="w-full flex items-center justify-between pt-2">
{/* Speed Selector Button */}
<div className="relative">
<button
type="button"
onClick={() => setShowRateMenu(!showRateMenu)}
className="px-3 py-1.5 rounded-xl bg-white/10 hover:bg-white/20 text-white text-xs font-mono font-bold transition-all cursor-pointer flex items-center gap-1 border border-white/10"
title="سرعت پخش"
>
<span>{playbackRate}x</span>
</button>
{showRateMenu && (
<div className="absolute bottom-full right-0 mb-2 bg-neutral-900/95 backdrop-blur-md border border-white/15 rounded-2xl p-1.5 shadow-2xl z-30 flex flex-col gap-1 min-w-[80px]">
{PLAYBACK_RATES.map((rate) => (
<button
key={rate}
type="button"
onClick={() => handleRateChange(rate)}
className={`px-3 py-1 text-xs font-mono font-bold rounded-xl text-center transition-colors cursor-pointer ${
playbackRate === rate
? "bg-canina-blue text-white"
: "text-white/70 hover:bg-white/10 hover:text-white"
}`}
>
{rate}x
</button>
))}
</div>
)}
</div>
{/* Central Controls: -15s, Play/Pause, +15s */}
<div className="flex items-center gap-3 sm:gap-4">
{/* -15s */}
<button
type="button"
onClick={() => skipTime(-15)}
className="p-2.5 text-white/80 hover:text-white rounded-full hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-0.5 text-xs font-mono font-bold"
title="۱۵ ثانیه عقب"
>
<RotateCcw className="w-5 h-5" />
<span className="text-[10px]">15s</span>
</button>
{/* Play / Pause button */}
<button
type="button"
onClick={togglePlay}
className="w-14 h-14 rounded-full bg-canina-blue hover:bg-canina-blue/90 text-white flex items-center justify-center transition-transform hover:scale-105 active:scale-95 cursor-pointer shadow-xl shadow-canina-blue/40 border border-white/20"
title={isPlaying ? "توقف" : "پخش"}
>
{isPlaying ? <Pause className="w-6 h-6 fill-white" /> : <Play className="w-6 h-6 fill-white ml-0.5" />}
</button>
{/* +15s */}
<button
type="button"
onClick={() => skipTime(15)}
className="p-2.5 text-white/80 hover:text-white rounded-full hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-0.5 text-xs font-mono font-bold"
title="۱۵ ثانیه جلو"
>
<RotateCw className="w-5 h-5" />
<span className="text-[10px]">15s</span>
</button>
</div>
{/* Volume Control */}
<div className="flex items-center gap-1.5">
<button
type="button"
onClick={toggleMute}
className="p-2 text-white/80 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer"
title={isMuted ? "صدا وصل" : "بی‌صدا"}
>
{isMuted || volume === 0 ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />}
</button>
<input
type="range"
min={0}
max={1}
step={0.05}
value={isMuted ? 0 : volume}
onChange={handleVolumeChange}
className="w-14 h-1.5 bg-white/20 rounded-lg appearance-none cursor-pointer accent-canina-blue hidden sm:block"
/>
</div>
</div>
</motion.div>
</div>
</AnimatePresence>
);
}

View File

@ -28,7 +28,8 @@ import {
AlertTriangle,
Share2,
Phone,
Download
Download,
Play
} from "lucide-react";
const ICON_MAP: Record<string, React.ComponentType<{ className?: string }>> = {
@ -68,6 +69,8 @@ const useCalculatorStore = create<CalculatorState>((set) => ({
import Tooltip from "./Tooltip";
import SafeImage from "./SafeImage";
import ProductReviews from "./ProductReviews";
import VideoModalPlayer from "./VideoModalPlayer";
import PodcastPlayerModal from "./PodcastPlayerModal";
import { useRouter } from 'next/navigation';
@ -83,6 +86,8 @@ export default function ProductPage({ productSlug }: { productSlug: string }) {
const [activeImage, setActiveImage] = useState<string | null>(null);
const [isImageZoomOpen, setIsImageZoomOpen] = useState(false);
const [isVideoModalOpen, setIsVideoModalOpen] = useState(false);
const [isPodcastModalOpen, setIsPodcastModalOpen] = useState(false);
useEffect(() => {
Promise.resolve().then(() => setIsMounted(true));
@ -687,84 +692,140 @@ export default function ProductPage({ productSlug }: { productSlug: string }) {
</div>
</div>
<div className="grid grid-cols-1 gap-6">
{/* Video Player Box */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Video Player Card */}
{product.videoUrl && (
<div className="bg-gray-900 rounded-3xl overflow-hidden shadow-xl border border-gray-800 p-4 space-y-3">
<div className="flex items-center justify-between px-2 text-white">
<span className="text-sm font-bold flex items-center gap-2">
<span className="w-2.5 h-2.5 rounded-full bg-red-500 animate-pulse"></span>
ویدیو معرفی و راهنمای محصول
<div className="bg-gradient-to-br from-neutral-900 via-neutral-900 to-black rounded-3xl overflow-hidden shadow-xl border border-white/10 p-6 flex flex-col justify-between text-white space-y-4 group">
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-black text-blue-400 bg-blue-500/10 px-3 py-1 rounded-full border border-blue-500/20 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-red-500 animate-pulse" />
ویدیو راهنما
</span>
<span className="text-[11px] text-gray-400 font-mono">Video Guide</span>
<span className="text-[11px] text-white/50 font-mono">Video Guide</span>
</div>
<div className="relative w-full aspect-video rounded-2xl overflow-hidden bg-black flex items-center justify-center">
{product.videoUrl.includes('<iframe') ? (
{/* Thumbnail / Cover Box */}
<div
className="w-full h-full [&>iframe]:w-full [&>iframe]:h-full"
dangerouslySetInnerHTML={{ __html: product.videoUrl }}
/>
) : (
<video
src={product.videoUrl}
controls
playsInline
className="w-full h-full object-contain"
poster={product.image}
onClick={() => setIsVideoModalOpen(true)}
className="relative aspect-video rounded-2xl overflow-hidden cursor-pointer bg-neutral-800 border border-white/10 group-hover:border-canina-blue/50 transition-all flex items-center justify-center"
>
مرورگر شما از پخش ویدیو پشتیبانی نمیکند.
</video>
)}
<img
src={product.videoCover || product.image}
alt={product.videoTitle || product.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
onError={(e) => {
(e.target as HTMLImageElement).src = product.image;
}}
/>
<div className="absolute inset-0 bg-black/40 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<div className="w-14 h-14 rounded-full bg-canina-blue text-white flex items-center justify-center shadow-xl shadow-canina-blue/50 group-hover:scale-110 transition-transform">
<Play className="w-6 h-6 fill-white ml-0.5" />
</div>
</div>
</div>
<div>
<h4 className="text-base font-black text-white line-clamp-1">
{product.videoTitle || "ویدیو معرفی و راهنمای مصرف محصول"}
</h4>
<p className="text-xs text-white/70 font-medium line-clamp-2 mt-1 leading-relaxed">
{product.videoDescription || "مشاهده راهنمای کامل، دوز مصرفی و آموزش خوراندن مکمل توسط کادر تخصصی کنینا"}
</p>
</div>
</div>
<button
type="button"
onClick={() => setIsVideoModalOpen(true)}
className="w-full py-3 px-4 bg-white/10 hover:bg-canina-blue text-white rounded-2xl font-black text-xs transition-all flex items-center justify-center gap-2 cursor-pointer border border-white/10"
>
<Play className="w-4 h-4 fill-white" />
<span>پخش ویدیو در پلیر اختصاصی</span>
</button>
</div>
)}
{/* Podcast / Audio Player Box */}
{/* Podcast Player Card */}
{product.podcastUrl && (
<div className="bg-gradient-to-r from-blue-50 to-indigo-50/60 border border-blue-200 rounded-3xl p-6 space-y-4 shadow-sm">
<div className="bg-gradient-to-br from-purple-950/40 via-neutral-900 to-black rounded-3xl overflow-hidden shadow-xl border border-purple-500/20 p-6 flex flex-col justify-between text-white space-y-4 group">
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-canina-blue text-white flex items-center justify-center shadow-md">
🎧
<span className="text-xs font-black text-purple-400 bg-purple-500/10 px-3 py-1 rounded-full border border-purple-500/20 flex items-center gap-1.5">
🎧 پادکست اختصاصی
</span>
<span className="text-[11px] text-white/50 font-mono">Audio Analysis</span>
</div>
{/* Podcast Cover / Artwork Box */}
<div
onClick={() => setIsPodcastModalOpen(true)}
className="relative aspect-video rounded-2xl overflow-hidden cursor-pointer bg-neutral-800 border border-white/10 group-hover:border-purple-500/50 transition-all flex items-center justify-center"
>
<img
src={product.podcastCover || product.image}
alt={product.podcastTitle || product.name}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
onError={(e) => {
(e.target as HTMLImageElement).src = product.image;
}}
/>
<div className="absolute inset-0 bg-black/40 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<div className="w-14 h-14 rounded-full bg-purple-600 text-white flex items-center justify-center shadow-xl shadow-purple-600/50 group-hover:scale-110 transition-transform">
<Play className="w-6 h-6 fill-white ml-0.5" />
</div>
</div>
</div>
<div>
<h4 className="text-sm font-black text-gray-900">پادکست و بررسی صوتی بالینی مکمل</h4>
<p className="text-[11px] text-gray-500 font-bold">توضیحات صوتی دامپزشک درباره اثربخشی و دوز مصرفی</p>
<h4 className="text-base font-black text-white line-clamp-1">
{product.podcastTitle || "پادکست و بررسی صوتی بالینی مکمل"}
</h4>
<p className="text-xs text-white/70 font-medium line-clamp-2 mt-1 leading-relaxed">
{product.podcastDescription || "توضیحات صوتی دامپزشک و کادر علمی درباره اثربخشی، مکانیسم اثر و نحوه مصرف دقیق"}
</p>
</div>
</div>
<span className="text-[10px] bg-canina-blue/10 text-canina-blue px-3 py-1 rounded-full font-bold">پادکست اختصاصی</span>
</div>
<audio controls className="w-full h-10 rounded-xl accent-canina-blue">
<source src={product.podcastUrl} />
مرورگر شما از پخش صوت پشتیبانی نمیکند.
</audio>
<button
type="button"
onClick={() => setIsPodcastModalOpen(true)}
className="w-full py-3 px-4 bg-white/10 hover:bg-purple-600 text-white rounded-2xl font-black text-xs transition-all flex items-center justify-center gap-2 cursor-pointer border border-white/10"
>
<Play className="w-4 h-4 fill-white" />
<span>شنیدن پادکست در پلیر اختصاصی</span>
</button>
</div>
)}
</div>
{/* PDF Catalog Download Box */}
{product.pdfUrl && (
<div className="bg-medical-gray-50 border border-medical-gray-200 rounded-3xl p-6 flex flex-col sm:flex-row items-center justify-between gap-4 shadow-xs">
<div className="flex items-center gap-4 text-center sm:text-right">
<div className="w-12 h-12 rounded-2xl bg-red-100 text-red-600 flex items-center justify-center font-black text-xs shadow-sm shrink-0">
<div className="w-14 h-14 rounded-2xl bg-red-100 text-red-600 flex items-center justify-center font-black text-sm shadow-sm shrink-0">
PDF
</div>
<div>
<h4 className="text-base font-black text-gray-900">بروشور و کاتالوگ علمی Canina آلمان</h4>
<p className="text-xs text-gray-500 font-medium mt-0.5">شامل مقالات رفرنس، جدول ترکیبات دقیق و سرتیفیکیت رسمی</p>
<h4 className="text-base font-black text-gray-900">
{product.pdfTitle || "بروشور و کاتالوگ علمی Canina آلمان"}
</h4>
<p className="text-xs text-gray-500 font-medium mt-0.5">
{product.pdfDescription || "شامل مقالات رفرنس، جدول ترکیبات دقیق و سرتیفیکیت رسمی"}
</p>
</div>
</div>
<a
href={product.pdfUrl}
target="_blank"
rel="noreferrer"
className="bg-canina-blue hover:bg-blue-700 text-white px-6 py-3 rounded-2xl font-black text-xs transition-all shadow-md shadow-canina-blue/20 flex items-center gap-2 shrink-0 cursor-pointer"
className="bg-canina-blue hover:bg-blue-700 text-white px-6 py-3.5 rounded-2xl font-black text-xs transition-all shadow-md shadow-canina-blue/20 flex items-center gap-2 shrink-0 cursor-pointer"
>
<Download className="w-4 h-4" />
<span>دانلود مستقیم فایل PDF</span>
</a>
</div>
)}
</div>
</section>
)}
@ -1141,6 +1202,34 @@ export default function ProductPage({ productSlug }: { productSlug: string }) {
</div>
)}
</AnimatePresence>
{/* Dedicated Video Modal Player */}
<VideoModalPlayer
isOpen={isVideoModalOpen}
onClose={() => setIsVideoModalOpen(false)}
video={product.videoUrl ? {
title: product.videoTitle || product.name,
doctor: "کادر علمی و تخصصی کنینا",
videoUrl: product.videoUrl,
thumbnail: product.videoCover || product.image,
description: product.videoDescription || product.description,
} : null}
/>
{/* Dedicated Custom Podcast Player Modal */}
<PodcastPlayerModal
isOpen={isPodcastModalOpen}
onClose={() => setIsPodcastModalOpen(false)}
podcast={product.podcastUrl ? {
audioUrl: product.podcastUrl,
title: product.podcastTitle || "پادکست و بررسی صوتی بالینی مکمل",
description: product.podcastDescription || product.description,
cover: product.podcastCover,
productName: product.name,
fallbackCover: product.image,
} : null}
/>
</div>
);
}

View File

@ -62,8 +62,17 @@ export interface Product {
image: string;
images?: string[];
podcastUrl?: string;
podcastTitle?: string;
podcastDescription?: string;
podcastCover?: string;
videoUrl?: string;
videoTitle?: string;
videoDescription?: string;
videoCover?: string;
pdfUrl?: string;
pdfTitle?: string;
pdfDescription?: string;
pdfCover?: string;
relatedProducts?: string[];
faqs?: FAQ[];
}

View File

@ -40,8 +40,17 @@ interface BackendProduct {
image?: string;
images?: string[];
podcastUrl?: string;
podcastTitle?: string;
podcastDescription?: string;
podcastCover?: string;
videoUrl?: string;
videoTitle?: string;
videoDescription?: string;
videoCover?: string;
pdfUrl?: string;
pdfTitle?: string;
pdfDescription?: string;
pdfCover?: string;
productGroup?: string;
ingredientList?: Array<{ ingredient: string }>;
ingredients?: string;
@ -157,6 +166,16 @@ export class ProductService {
if (data.podcastUrl.startsWith('/uploads/')) return `${apiBase}${data.podcastUrl}`;
return data.podcastUrl;
})(),
podcastTitle: data.podcastTitle || undefined,
podcastDescription: data.podcastDescription || undefined,
podcastCover: (() => {
const apiBase = (process.env.NEXT_PUBLIC_API_URL || '').replace(/\/api$/, '');
if (!data.podcastCover) return undefined;
if (data.podcastCover.startsWith('http://') || data.podcastCover.startsWith('https://')) return data.podcastCover;
if (data.podcastCover.startsWith('/uploads/')) return `${apiBase}${data.podcastCover}`;
return data.podcastCover;
})(),
videoUrl: (() => {
const apiBase = (process.env.NEXT_PUBLIC_API_URL || '').replace(/\/api$/, '');
if (!data.videoUrl) return undefined;
@ -164,6 +183,16 @@ export class ProductService {
if (data.videoUrl.startsWith('/uploads/')) return `${apiBase}${data.videoUrl}`;
return data.videoUrl;
})(),
videoTitle: data.videoTitle || undefined,
videoDescription: data.videoDescription || undefined,
videoCover: (() => {
const apiBase = (process.env.NEXT_PUBLIC_API_URL || '').replace(/\/api$/, '');
if (!data.videoCover) return undefined;
if (data.videoCover.startsWith('http://') || data.videoCover.startsWith('https://')) return data.videoCover;
if (data.videoCover.startsWith('/uploads/')) return `${apiBase}${data.videoCover}`;
return data.videoCover;
})(),
pdfUrl: (() => {
const apiBase = (process.env.NEXT_PUBLIC_API_URL || '').replace(/\/api$/, '');
if (!data.pdfUrl) return undefined;
@ -171,6 +200,15 @@ export class ProductService {
if (data.pdfUrl.startsWith('/uploads/')) return `${apiBase}${data.pdfUrl}`;
return data.pdfUrl;
})(),
pdfTitle: data.pdfTitle || undefined,
pdfDescription: data.pdfDescription || undefined,
pdfCover: (() => {
const apiBase = (process.env.NEXT_PUBLIC_API_URL || '').replace(/\/api$/, '');
if (!data.pdfCover) return undefined;
if (data.pdfCover.startsWith('http://') || data.pdfCover.startsWith('https://')) return data.pdfCover;
if (data.pdfCover.startsWith('/uploads/')) return `${apiBase}${data.pdfCover}`;
return data.pdfCover;
})(),
};
}