From 36e382cf1620b17a37a409db9b9ef01d06188fba Mon Sep 17 00:00:00 2001 From: parsa aghaei Date: Tue, 18 Aug 2026 15:43:45 +0330 Subject: [PATCH] fix(media): add inline podcast player, video buffering track, click-outside speed menu and safe product edit trim --- frontend/admin-panel/src/pages/Products.tsx | 59 +- .../components/PodcastInlinePlayer.tsx | 417 + .../components/PodcastPlayerModal.tsx | 34 +- .../application/components/ProductPage.tsx | 74 +- .../components/VideoModalPlayer.tsx | 59 +- .../lib/services/productService.ts | 3 +- graphify-out/.graphify_labels.json | 83 +- graphify-out/.graphify_labels.json.sig | 2 +- graphify-out/2026-08-18/.graphify_labels.json | 55 +- graphify-out/2026-08-18/GRAPH_REPORT.md | 313 +- graphify-out/2026-08-18/graph.json | 9402 ++++++------- graphify-out/2026-08-18/manifest.json | 1132 +- graphify-out/GRAPH_REPORT.md | 355 +- graphify-out/cache/stat-index.json | 2 +- graphify-out/graph.html | 8 +- graphify-out/graph.json | 11026 ++++++++-------- graphify-out/manifest.json | 1090 +- 17 files changed, 12496 insertions(+), 11618 deletions(-) create mode 100644 frontend/application/components/PodcastInlinePlayer.tsx diff --git a/frontend/admin-panel/src/pages/Products.tsx b/frontend/admin-panel/src/pages/Products.tsx index 1fc31f2..f59c58e 100644 --- a/frontend/admin-panel/src/pages/Products.tsx +++ b/frontend/admin-panel/src/pages/Products.tsx @@ -249,7 +249,7 @@ export default function Products() { const wMargin = bPrice && wPrice ? Math.round(((wPrice - Number(bPrice)) / Number(bPrice)) * 100) : ''; setFormData({ - artNo: product.artNo, + artNo: product.artNo || '', nameFa: product.nameFa || '', nameEn: product.nameEn || '', scientificTagline: product.scientificTagline || '', @@ -263,10 +263,11 @@ export default function Products() { wholesaleMarginPercent: wMargin, priceDisplay: product.priceDisplay || '', unit: product.unit || '', - packageSize: Number(product.packageSize), + packageSize: Number(product.packageSize) || 0, dosageLogic: product.dosageLogic || '', suitableFor: product.suitableFor || 'سگ و گربه', imageUrl: product.imageUrl || '', + slug: product.slug || '', metaTitle: product.metaTitle || '', metaDescription: product.metaDescription || '', keywords: product.keywords || '', @@ -337,43 +338,43 @@ export default function Products() { } try { - const derivedSlug = formData.slug.trim() || slugify(formData.nameEn); + const derivedSlug = (formData.slug || '').trim() || slugify(formData.nameEn || ''); const finalPayload: Record = { - artNo: formData.artNo.trim(), - nameFa: formData.nameFa.trim(), - nameEn: formData.nameEn.trim(), - scientificTagline: formData.scientificTagline.trim() || undefined, - description: formData.description.trim() || undefined, - shortDescription: formData.shortDescription.trim() || undefined, - categoryId: formData.categoryId.trim(), + artNo: (formData.artNo || '').trim(), + nameFa: (formData.nameFa || '').trim(), + nameEn: (formData.nameEn || '').trim(), + scientificTagline: (formData.scientificTagline || '').trim() || undefined, + description: (formData.description || '').trim() || undefined, + shortDescription: (formData.shortDescription || '').trim() || undefined, + categoryId: (formData.categoryId || '').trim(), buyPrice: formData.buyPrice ? Number(formData.buyPrice) : 0, priceValue: Number(formData.priceValue || 0), wholesalePrice: formData.wholesalePrice ? Number(formData.wholesalePrice) : undefined, priceValueMarginPercent: formData.priceValueMarginPercent !== '' ? Number(formData.priceValueMarginPercent) : undefined, wholesaleMarginPercent: formData.wholesaleMarginPercent !== '' ? Number(formData.wholesaleMarginPercent) : undefined, priceDisplay: formData.priceDisplay || `${Number(formData.priceValue || 0).toLocaleString('fa-IR')} تومان`, - unit: formData.unit.trim() || undefined, + unit: (formData.unit || '').trim() || undefined, packageSize: Number(formData.packageSize) || 0, - dosageLogic: formData.dosageLogic.trim() || undefined, + dosageLogic: (formData.dosageLogic || '').trim() || undefined, suitableFor: formData.suitableFor || 'سگ و گربه', - imageUrl: formData.imageUrl.trim() || undefined, + imageUrl: (formData.imageUrl || '').trim() || undefined, images: formData.images || [], - podcastUrl: formData.podcastUrl.trim() || undefined, - podcastTitle: formData.podcastTitle.trim() || undefined, - podcastDescription: formData.podcastDescription.trim() || undefined, - podcastCover: formData.podcastCover.trim() || undefined, - videoUrl: formData.videoUrl.trim() || undefined, - videoTitle: formData.videoTitle.trim() || undefined, - videoDescription: formData.videoDescription.trim() || undefined, - videoCover: formData.videoCover.trim() || undefined, - pdfUrl: formData.pdfUrl.trim() || undefined, - pdfTitle: formData.pdfTitle.trim() || undefined, - pdfDescription: formData.pdfDescription.trim() || undefined, - pdfCover: formData.pdfCover.trim() || undefined, - metaTitle: formData.metaTitle.trim() || `${formData.nameFa || 'نام محصول'} | خرید و قیمت مکمل کنینا`, - metaDescription: formData.metaDescription.trim() || formData.shortDescription || 'خرید آنلاین مکمل اصل کنینا آلمان با بالاترین کیفیت بالینی...', - keywords: formData.keywords.trim() || undefined, - canonicalUrl: formData.canonicalUrl.trim() || `/shop/${derivedSlug}`, + podcastUrl: (formData.podcastUrl || '').trim() || undefined, + podcastTitle: (formData.podcastTitle || '').trim() || undefined, + podcastDescription: (formData.podcastDescription || '').trim() || undefined, + podcastCover: (formData.podcastCover || '').trim() || undefined, + videoUrl: (formData.videoUrl || '').trim() || undefined, + videoTitle: (formData.videoTitle || '').trim() || undefined, + videoDescription: (formData.videoDescription || '').trim() || undefined, + videoCover: (formData.videoCover || '').trim() || undefined, + pdfUrl: (formData.pdfUrl || '').trim() || undefined, + pdfTitle: (formData.pdfTitle || '').trim() || undefined, + pdfDescription: (formData.pdfDescription || '').trim() || undefined, + pdfCover: (formData.pdfCover || '').trim() || undefined, + metaTitle: (formData.metaTitle || '').trim() || `${formData.nameFa || 'نام محصول'} | خرید و قیمت مکمل کنینا`, + metaDescription: (formData.metaDescription || '').trim() || formData.shortDescription || 'خرید آنلاین مکمل اصل کنینا آلمان با بالاترین کیفیت بالینی...', + keywords: (formData.keywords || '').trim() || undefined, + canonicalUrl: (formData.canonicalUrl || '').trim() || `/shop/${derivedSlug}`, slug: derivedSlug, symptoms: formData.symptoms || [], isPreorder: Boolean(formData.isPreorder), diff --git a/frontend/application/components/PodcastInlinePlayer.tsx b/frontend/application/components/PodcastInlinePlayer.tsx new file mode 100644 index 0000000..de89df3 --- /dev/null +++ b/frontend/application/components/PodcastInlinePlayer.tsx @@ -0,0 +1,417 @@ +"use client"; +import React, { useState, useRef, useEffect, useCallback, useMemo } from "react"; +import { + Play, + Pause, + RotateCcw, + RotateCw, + Volume2, + VolumeX, + Download, + Share2, + Check, + Headphones, + Loader2, + Sparkles +} from "lucide-react"; + +interface PodcastInlinePlayerProps { + podcast: { + audioUrl?: string; + title?: string; + description?: string; + cover?: string; + productName?: string; + fallbackCover?: string; + }; +} + +const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5]; +const WAVEFORM_BAR_COUNT = 44; + +export default function PodcastInlinePlayer({ podcast }: PodcastInlinePlayerProps) { + const audioRef = useRef(null); + const waveformRef = useRef(null); + const rateMenuRef = useRef(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); + + // Close rate menu when clicking outside + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (rateMenuRef.current && !rateMenuRef.current.contains(e.target as Node)) { + setShowRateMenu(false); + } + } + if (showRateMenu) { + document.addEventListener("mousedown", handleClickOutside); + } + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [showRateMenu]); + + // Deterministic heights for waveform visualization + const waveformHeights = useMemo(() => { + const seed = (podcast?.title || "canina").split("").reduce((acc, char) => acc + char.charCodeAt(0), 0); + return Array.from({ length: WAVEFORM_BAR_COUNT }).map((_, i) => { + const pseudo = Math.sin((i + 1) * 0.45 + seed) * 0.5 + 0.5; + return Math.max(22, Math.min(95, Math.floor(pseudo * 75 + 20))); + }); + }, [podcast?.title]); + + 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 seekToPercent = (percent: number) => { + if (audioRef.current && duration > 0) { + const newTime = (percent / 100) * duration; + audioRef.current.currentTime = Math.max(0, Math.min(duration, newTime)); + setCurrentTime(newTime); + } + }; + + const handleWaveformClick = (e: React.MouseEvent) => { + if (!waveformRef.current || duration <= 0) return; + const rect = waveformRef.current.getBoundingClientRect(); + const clickX = e.clientX - rect.left; + const percent = Math.max(0, Math.min(100, (clickX / rect.width) * 100)); + seekToPercent(percent); + }; + + 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) => { + 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 = typeof window !== 'undefined' ? window.location.href : ''; + if (navigator.share) { + try { + await navigator.share({ + title: podcast?.title || 'پادکست و بررسی بالینی مکمل کنینا', + text: podcast?.description || '', + url: shareUrl + }); + } catch { + // Fallback + } + } 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")}`; + }; + + const displayCover = podcast.cover || podcast.fallbackCover || '/images/default-podcast.png'; + const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0; + const volumePercent = isMuted ? 0 : volume * 100; + + return ( +
+ {/* Hidden Audio Tag */} +