fix(ux): refine hero slider, update shop copy, fix admin product save validation, add soundcloud waveform & fix player controls, improve reviews layout
All checks were successful
Deploy Canina / deploy (push) Successful in 1m39s

This commit is contained in:
parsa aghaei 2026-08-18 12:00:54 +03:30
parent f9dad098ef
commit f0f50c75cc
7 changed files with 386 additions and 246 deletions

View File

@ -308,16 +308,76 @@ export default function Products() {
const handleSave = async (e: React.FormEvent) => { const handleSave = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// Explicit Tab-Aware Client-Side Validation
if (!formData.artNo?.trim()) {
setActiveTab('general');
toast.error('لطفاً کد محصول (Art No) را وارد نمایید');
return;
}
if (!formData.nameFa?.trim()) {
setActiveTab('general');
toast.error('لطفاً نام فارسی محصول را وارد نمایید');
return;
}
if (!formData.nameEn?.trim()) {
setActiveTab('general');
toast.error('لطفاً نام انگلیسی محصول را وارد نمایید');
return;
}
if (!formData.categoryId?.trim()) {
setActiveTab('general');
toast.error('لطفاً دسته‌بندی محصول را انتخاب نمایید');
return;
}
if (formData.priceValue === undefined || formData.priceValue === null || isNaN(Number(formData.priceValue))) {
setActiveTab('pricing');
toast.error('لطفاً قیمت فروش محصول را وارد نمایید');
return;
}
try { try {
const derivedSlug = formData.slug.trim() || slugify(formData.nameEn); const derivedSlug = formData.slug.trim() || slugify(formData.nameEn);
const finalPayload = { const finalPayload: Record<string, unknown> = {
...formData, 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, buyPrice: formData.buyPrice ? Number(formData.buyPrice) : 0,
wholesalePrice: formData.wholesalePrice ? Number(formData.wholesalePrice) : null, priceValue: Number(formData.priceValue || 0),
slug: derivedSlug, 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,
packageSize: Number(formData.packageSize) || 0,
dosageLogic: formData.dosageLogic.trim() || undefined,
suitableFor: formData.suitableFor || 'سگ و گربه',
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 || 'نام محصول'} | خرید و قیمت مکمل کنینا`, metaTitle: formData.metaTitle.trim() || `${formData.nameFa || 'نام محصول'} | خرید و قیمت مکمل کنینا`,
metaDescription: formData.metaDescription.trim() || formData.shortDescription || 'خرید آنلاین مکمل اصل کنینا آلمان با بالاترین کیفیت بالینی...', metaDescription: formData.metaDescription.trim() || formData.shortDescription || 'خرید آنلاین مکمل اصل کنینا آلمان با بالاترین کیفیت بالینی...',
canonicalUrl: formData.canonicalUrl.trim() || `/shop/${derivedSlug}` keywords: formData.keywords.trim() || undefined,
canonicalUrl: formData.canonicalUrl.trim() || `/shop/${derivedSlug}`,
slug: derivedSlug,
symptoms: formData.symptoms || [],
isPreorder: Boolean(formData.isPreorder),
preorderDeposit: formData.preorderDeposit ? String(formData.preorderDeposit) : undefined
}; };
if (editingProduct) { if (editingProduct) {
@ -338,9 +398,12 @@ export default function Products() {
setIsModalOpen(false); setIsModalOpen(false);
setImageErrors({}); setImageErrors({});
await fetchData(); await fetchData();
} catch (error) { } catch (error: any) {
console.error('Save failed', error); console.error('Save failed', error);
toast.error('خطا در ذخیره محصول'); const serverMsg = error.response?.data?.message || error.message;
if (serverMsg && !error._toastShown) {
toast.error(`خطا در ذخیره محصول: ${serverMsg}`);
}
} }
}; };

View File

@ -365,16 +365,16 @@ export default function Header({
onMouseLeave={() => setActiveDropdown(null)} onMouseLeave={() => setActiveDropdown(null)}
> >
<button className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-medical-gray-700 hover:bg-medical-gray-100 hover:text-canina-blue transition-all"> <button className="flex items-center gap-1.5 px-3 py-2 rounded-xl text-medical-gray-700 hover:bg-medical-gray-100 hover:text-canina-blue transition-all">
<span>محصولات و کاتالوگ</span> <span>فروشگاه و محصولات</span>
<ChevronDown className="w-3.5 h-3.5" /> <ChevronDown className="w-3.5 h-3.5" />
</button> </button>
{activeDropdown === 'products' && ( {activeDropdown === 'products' && (
<div className="absolute top-full right-0 w-52 bg-white border border-medical-gray-200 shadow-xl rounded-xl p-2 z-50 space-y-1 text-xs"> <div className="absolute top-full right-0 w-56 bg-white border border-medical-gray-200 shadow-xl rounded-xl p-2 z-50 space-y-1 text-xs">
<Link href="/shop" className="block px-3 py-2 hover:bg-canina-blue/5 hover:text-canina-blue rounded-lg font-bold"> <Link href="/shop" className="block px-3 py-2 hover:bg-canina-blue/5 hover:text-canina-blue rounded-lg font-bold">
کاتالوگ جامع محصولات فروشگاه تخصصی محصولات کنینا
</Link> </Link>
<Link href="/catalog" className="block px-3 py-2 hover:bg-canina-blue/5 hover:text-canina-blue rounded-lg font-bold"> <Link href="/catalog" className="block px-3 py-2 hover:bg-canina-blue/5 hover:text-canina-blue rounded-lg font-bold">
کاتالوگ آنلاین و دوز مصرفی کاتالوگ دیجیتال و راهنمای بالینی
</Link> </Link>
</div> </div>
)} )}

View File

@ -70,7 +70,7 @@ export default function Hero({ banners = [], onShopNavigate }: { banners?: Banne
const subtitle = (mounted && isInitialized ? getText('hero_desc', '') : '') || defaultSubtitle; const subtitle = (mounted && isInitialized ? getText('hero_desc', '') : '') || defaultSubtitle;
const titleParts = title.split('\n'); const titleParts = title.split('\n');
const activeImageUrl = activeBanner?.imageUrl || (mounted && isInitialized ? getText('hero_image_url', '') : '') || (banners.length > 0 && banners[0].imageUrl ? banners[0].imageUrl : "/assets/images/hero-section-image.png"); const activeImageUrl = activeBanner?.imageUrl || (mounted && isInitialized ? getText('hero_image_url', '') : '') || (banners.length > 0 && banners[0].imageUrl ? banners[0].imageUrl : "");
const handleNextSlide = () => { const handleNextSlide = () => {
if (totalSlides > 1) { if (totalSlides > 1) {
@ -193,6 +193,7 @@ export default function Hero({ banners = [], onShopNavigate }: { banners?: Banne
onTouchEnd={onTouchEnd} onTouchEnd={onTouchEnd}
> >
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
{activeImageUrl && (
<motion.img <motion.img
key={activeImageUrl} key={activeImageUrl}
src={activeImageUrl} src={activeImageUrl}
@ -205,6 +206,7 @@ export default function Hero({ banners = [], onShopNavigate }: { banners?: Banne
loading="eager" loading="eager"
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
)}
</AnimatePresence> </AnimatePresence>
{/* Gradient overlay and slide caption */} {/* Gradient overlay and slide caption */}

View File

@ -1,5 +1,5 @@
"use client"; "use client";
import React, { useState, useRef, useEffect, useCallback } from "react"; import React, { useState, useRef, useEffect, useCallback, useMemo } from "react";
import { motion, AnimatePresence } from "motion/react"; import { motion, AnimatePresence } from "motion/react";
import { import {
Play, Play,
@ -32,8 +32,13 @@ interface PodcastPlayerModalProps {
const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5]; const PLAYBACK_RATES = [0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 2.5];
// Generate dynamic waveform bars for Soundcloud-like UI
const WAVEFORM_BAR_COUNT = 48;
export default function PodcastPlayerModal({ isOpen, onClose, podcast }: PodcastPlayerModalProps) { export default function PodcastPlayerModal({ isOpen, onClose, podcast }: PodcastPlayerModalProps) {
const audioRef = useRef<HTMLAudioElement>(null); const audioRef = useRef<HTMLAudioElement>(null);
const waveformRef = useRef<HTMLDivElement>(null);
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0); const [duration, setDuration] = useState(0);
@ -44,6 +49,16 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast
const [isBuffering, setIsBuffering] = useState(false); const [isBuffering, setIsBuffering] = useState(false);
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
// 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;
const heightPercent = Math.max(20, Math.min(95, Math.floor(pseudo * 80 + 15)));
return heightPercent;
});
}, [podcast?.title]);
// Reset states on open/close // Reset states on open/close
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@ -67,14 +82,22 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast
} }
}, []); }, []);
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => { const seekToPercent = (percent: number) => {
const time = Number(e.target.value); if (audioRef.current && duration > 0) {
if (audioRef.current) { const newTime = (percent / 100) * duration;
audioRef.current.currentTime = time; audioRef.current.currentTime = Math.max(0, Math.min(duration, newTime));
setCurrentTime(time); setCurrentTime(newTime);
} }
}; };
const handleWaveformClick = (e: React.MouseEvent<HTMLDivElement>) => {
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) => { const skipTime = (seconds: number) => {
if (audioRef.current) { if (audioRef.current) {
audioRef.current.currentTime = Math.max(0, Math.min(audioRef.current.duration || 0, audioRef.current.currentTime + seconds)); audioRef.current.currentTime = Math.max(0, Math.min(audioRef.current.duration || 0, audioRef.current.currentTime + seconds));
@ -116,7 +139,7 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast
if (navigator.share) { if (navigator.share) {
try { try {
await navigator.share({ await navigator.share({
title: podcast?.title || 'پادکست و بررسی تخصصی کنینا', title: podcast?.title || 'پادکست و بررسی بالینی مکمل کنینا',
text: podcast?.description || '', text: podcast?.description || '',
url: shareUrl url: shareUrl
}); });
@ -156,6 +179,8 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast
if (!isOpen || !podcast) return null; if (!isOpen || !podcast) return null;
const displayCover = podcast.cover || podcast.fallbackCover || '/images/default-podcast.png'; 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 ( return (
<AnimatePresence> <AnimatePresence>
@ -178,10 +203,10 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast
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" 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) */} {/* Top Bar (Header + Close) */}
<div className="w-full flex items-center justify-between mb-6"> <div className="w-full flex items-center justify-between mb-4">
<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"> <div className="flex items-center gap-2.5 text-xs font-black text-purple-400 bg-purple-500/10 px-3.5 py-1.5 rounded-full border border-purple-500/20">
<Headphones className="w-4 h-4 animate-pulse" /> <Headphones className="w-4 h-4 animate-pulse" />
<span>پادکست و بررسی صوتی کنینا</span> <span>پادکست و تحلیل صوتی بالینی</span>
</div> </div>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
@ -235,7 +260,7 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast
{/* Cover Art Artwork */} {/* Cover Art Artwork */}
<div className="relative group my-2"> <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"> <div className="w-40 h-40 sm:w-48 sm:h-48 rounded-3xl overflow-hidden shadow-2xl border-2 border-white/20 relative bg-neutral-800 flex items-center justify-center">
<img <img
src={displayCover} src={displayCover}
alt={podcast.title || "کاور پادکست"} alt={podcast.title || "کاور پادکست"}
@ -255,43 +280,60 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast
</div> </div>
{/* Title and Metadata */} {/* Title and Metadata */}
<div className="w-full text-center mt-5 mb-4 space-y-1.5"> <div className="w-full text-center mt-3 mb-3 space-y-1">
<h3 className="text-lg sm:text-xl font-black text-white line-clamp-1"> <h3 className="text-base sm:text-lg font-black text-white line-clamp-1">
{podcast.title || "بررسی بالینی و نحوه مصرف مکمل"} {podcast.title || "بررسی بالینی و نحوه مصرف مکمل"}
</h3> </h3>
{podcast.productName && ( {podcast.productName && (
<p className="text-canina-blue text-xs font-bold flex items-center justify-center gap-1"> <p className="text-purple-400 text-xs font-bold flex items-center justify-center gap-1">
<Sparkles className="w-3.5 h-3.5" /> <Sparkles className="w-3.5 h-3.5" />
<span>محصول: {podcast.productName}</span> <span>محصول: {podcast.productName}</span>
</p> </p>
)} )}
{podcast.description && ( {podcast.description && (
<p className="text-neutral-300 text-xs line-clamp-2 px-2 pt-1 font-medium leading-relaxed"> <p className="text-neutral-300 text-xs line-clamp-2 px-2 pt-0.5 font-medium leading-relaxed">
{podcast.description} {podcast.description}
</p> </p>
)} )}
</div> </div>
{/* Seeker / Progress Bar */} {/* Soundcloud-like Waveform Visualizer & Seeker */}
<div className="w-full space-y-1.5 mt-2 mb-4"> <div className="w-full space-y-2 mt-2 mb-3">
<div className="flex items-center gap-3 dir-ltr" dir="ltr"> <div
<span className="text-[11px] font-mono font-bold text-white/60 w-10 text-right"> ref={waveformRef}
{formatTime(currentTime)} onClick={handleWaveformClick}
</span> className="w-full h-12 flex items-center justify-between gap-[3px] px-2 py-1 bg-white/5 hover:bg-white/10 rounded-2xl cursor-pointer transition-all relative overflow-hidden group/wave border border-white/5"
<div className="relative flex-1 flex items-center"> title="برای رفتن به بخش مورد نظر کلیک کنید"
<input dir="ltr"
type="range" >
min={0} {waveformHeights.map((h, i) => {
max={duration || 100} const barPercent = (i / (WAVEFORM_BAR_COUNT - 1)) * 100;
step={0.1} const isPassed = barPercent <= progressPercent;
value={currentTime} return (
onChange={handleSeek} <div
className="w-full h-2 bg-white/20 rounded-lg appearance-none cursor-pointer accent-canina-blue hover:h-2.5 transition-all" key={i}
className="flex-1 flex items-center justify-center h-full"
>
<div
className={`w-full rounded-full transition-all duration-150 ${
isPassed
? "bg-gradient-to-t from-purple-500 to-canina-blue shadow-xs"
: "bg-white/20 group-hover/wave:bg-white/30"
} ${isPlaying && isPassed ? "opacity-100" : "opacity-85"}`}
style={{
height: `${h}%`,
transform: isPlaying && isPassed ? "scaleY(1.05)" : "scaleY(1)"
}}
/> />
</div> </div>
<span className="text-[11px] font-mono font-bold text-white/60 w-10 text-left"> );
{formatTime(duration)} })}
</span> </div>
{/* Time Stamp Row */}
<div className="flex items-center justify-between text-[11px] font-mono font-bold text-white/60 px-1 dir-ltr" dir="ltr">
<span>{formatTime(currentTime)}</span>
<span>{formatTime(duration)}</span>
</div> </div>
</div> </div>
@ -302,7 +344,7 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast
<button <button
type="button" type="button"
onClick={() => setShowRateMenu(!showRateMenu)} 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" className="px-2.5 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="سرعت پخش" title="سرعت پخش"
> >
<span>{playbackRate}x</span> <span>{playbackRate}x</span>
@ -317,7 +359,7 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast
onClick={() => handleRateChange(rate)} onClick={() => handleRateChange(rate)}
className={`px-3 py-1 text-xs font-mono font-bold rounded-xl text-center transition-colors cursor-pointer ${ className={`px-3 py-1 text-xs font-mono font-bold rounded-xl text-center transition-colors cursor-pointer ${
playbackRate === rate playbackRate === rate
? "bg-canina-blue text-white" ? "bg-purple-600 text-white"
: "text-white/70 hover:bg-white/10 hover:text-white" : "text-white/70 hover:bg-white/10 hover:text-white"
}`} }`}
> >
@ -328,51 +370,50 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast
)} )}
</div> </div>
{/* Central Controls: -15s, Play/Pause, +15s */} {/* Central Controls: -15s on Left, Play/Pause in Middle, +15s on Right in RTL */}
<div className="flex items-center gap-3 sm:gap-4"> <div className="flex items-center gap-2 sm:gap-3" dir="rtl">
{/* -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 */} {/* Play / Pause button */}
<button <button
type="button" type="button"
onClick={togglePlay} 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" className="w-12 h-12 rounded-full bg-purple-600 hover:bg-purple-500 text-white flex items-center justify-center transition-transform hover:scale-105 active:scale-95 cursor-pointer shadow-xl shadow-purple-600/40 border border-white/20"
title={isPlaying ? "توقف" : "پخش"} title={isPlaying ? "توقف" : "پخش"}
> >
{isPlaying ? <Pause className="w-6 h-6 fill-white" /> : <Play className="w-6 h-6 fill-white ml-0.5" />} {isPlaying ? <Pause className="w-5 h-5 fill-white" /> : <Play className="w-5 h-5 fill-white ml-0.5" />}
</button> </button>
{/* +15s */} {/* -15s button (left) */}
<button
type="button"
onClick={() => skipTime(-15)}
className="px-2.5 py-1.5 text-white/80 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-1 text-xs font-mono font-bold border border-white/10"
title="۱۵ ثانیه عقب"
>
<RotateCcw className="w-3.5 h-3.5" />
<span>-15s</span>
</button>
{/* +15s button (right) */}
<button <button
type="button" type="button"
onClick={() => skipTime(15)} 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" className="px-2.5 py-1.5 text-white/80 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-1 text-xs font-mono font-bold border border-white/10"
title="۱۵ ثانیه جلو" title="۱۵ ثانیه جلو"
> >
<RotateCw className="w-5 h-5" /> <RotateCw className="w-3.5 h-3.5" />
<span className="text-[10px]">15s</span> <span>+15s</span>
</button> </button>
</div> </div>
{/* Volume Control */} {/* Volume Control: Increases towards the speaker icon */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-2" dir="ltr">
<button <div className="relative w-16 hidden sm:flex items-center h-4">
type="button" <div className="absolute inset-x-0 h-1.5 bg-white/20 rounded-lg overflow-hidden pointer-events-none">
onClick={toggleMute} <div
className="p-2 text-white/80 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer" className="h-full bg-purple-500 transition-all"
title={isMuted ? "صدا وصل" : "بی‌صدا"} style={{ width: `${volumePercent}%` }}
> />
{isMuted || volume === 0 ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />} </div>
</button>
<input <input
type="range" type="range"
min={0} min={0}
@ -380,8 +421,21 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast
step={0.05} step={0.05}
value={isMuted ? 0 : volume} value={isMuted ? 0 : volume}
onChange={handleVolumeChange} onChange={handleVolumeChange}
className="w-14 h-1.5 bg-white/20 rounded-lg appearance-none cursor-pointer accent-canina-blue hidden sm:block" className="w-full h-4 opacity-0 absolute inset-0 cursor-pointer z-10"
/> />
<div
className="absolute w-2.5 h-2.5 bg-white rounded-full shadow pointer-events-none -translate-x-1/2"
style={{ left: `${volumePercent}%` }}
/>
</div>
<button
type="button"
onClick={toggleMute}
className="p-1.5 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>
</div> </div>
</div> </div>
</motion.div> </motion.div>

View File

@ -692,23 +692,14 @@ export default function ProductPage({ productSlug }: { productSlug: string }) {
</div> </div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <div className="space-y-6">
{/* Video Player Card */} {/* Video Player Row */}
{product.videoUrl && ( {product.videoUrl && (
<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="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 md:flex-row items-center gap-6 text-white 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-white/50 font-mono">Video Guide</span>
</div>
{/* Thumbnail / Cover Box */} {/* Thumbnail / Cover Box */}
<div <div
onClick={() => setIsVideoModalOpen(true)} 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" className="relative w-full md:w-80 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 shrink-0"
> >
<img <img
src={product.videoCover || product.image} src={product.videoCover || product.image}
@ -719,48 +710,46 @@ export default function ProductPage({ productSlug }: { productSlug: string }) {
}} }}
/> />
<div className="absolute inset-0 bg-black/40 group-hover:bg-black/20 transition-colors flex items-center justify-center"> <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"> <div className="w-12 h-12 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" /> <Play className="w-5 h-5 fill-white ml-0.5" />
</div> </div>
</div> </div>
</div> </div>
<div> <div className="flex-1 text-center md:text-right space-y-2">
<h4 className="text-base font-black text-white line-clamp-1"> <div className="flex items-center justify-center md:justify-start gap-2">
{product.videoTitle || "ویدیو معرفی و راهنمای مصرف محصول"} <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>
</div>
<h4 className="text-lg font-black text-white">
{product.videoTitle || "ویدیو راهنمای مصرف و معرفی مکمل"}
</h4> </h4>
<p className="text-xs text-white/70 font-medium line-clamp-2 mt-1 leading-relaxed"> <p className="text-xs text-white/70 font-medium leading-relaxed">
{product.videoDescription || "مشاهده راهنمای کامل، دوز مصرفی و آموزش خوراندن مکمل توسط کادر تخصصی کنینا"} {product.videoDescription || "مشاهده ویدیوی آموزشی، دوزبندی دقیق و نحوه مصرف مکمل توسط کادر علمی کنینا"}
</p> </p>
</div> <div className="pt-2 flex justify-center md:justify-start">
</div>
<button <button
type="button" type="button"
onClick={() => setIsVideoModalOpen(true)} 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" className="py-2.5 px-6 bg-canina-blue hover:bg-blue-600 text-white rounded-xl font-bold text-xs transition-all flex items-center gap-2 cursor-pointer shadow-md shadow-canina-blue/20"
> >
<Play className="w-4 h-4 fill-white" /> <Play className="w-4 h-4 fill-white" />
<span>پخش ویدیو در پلیر اختصاصی</span> <span>پخش ویدیو</span>
</button> </button>
</div> </div>
</div>
</div>
)} )}
{/* Podcast Player Card */} {/* Podcast Player Row */}
{product.podcastUrl && ( {product.podcastUrl && (
<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="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 md:flex-row items-center gap-6 text-white group">
<div className="space-y-3">
<div className="flex items-center justify-between">
<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 */} {/* Podcast Cover / Artwork Box */}
<div <div
onClick={() => setIsPodcastModalOpen(true)} 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" className="relative w-full md:w-80 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 shrink-0"
> >
<img <img
src={product.podcastCover || product.image} src={product.podcastCover || product.image}
@ -771,35 +760,39 @@ export default function ProductPage({ productSlug }: { productSlug: string }) {
}} }}
/> />
<div className="absolute inset-0 bg-black/40 group-hover:bg-black/20 transition-colors flex items-center justify-center"> <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"> <div className="w-12 h-12 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" /> <Play className="w-5 h-5 fill-white ml-0.5" />
</div> </div>
</div> </div>
</div> </div>
<div> <div className="flex-1 text-center md:text-right space-y-2">
<h4 className="text-base font-black text-white line-clamp-1"> <div className="flex items-center justify-center md:justify-start gap-2">
<span className="text-xs font-black text-purple-400 bg-purple-500/10 px-3 py-1 rounded-full border border-purple-500/20">
🎧 پادکست و بررسی صوتی بالینی
</span>
</div>
<h4 className="text-lg font-black text-white">
{product.podcastTitle || "پادکست و بررسی صوتی بالینی مکمل"} {product.podcastTitle || "پادکست و بررسی صوتی بالینی مکمل"}
</h4> </h4>
<p className="text-xs text-white/70 font-medium line-clamp-2 mt-1 leading-relaxed"> <p className="text-xs text-white/70 font-medium leading-relaxed">
{product.podcastDescription || "توضیحات صوتی دامپزشک و کادر علمی درباره اثربخشی، مکانیسم اثر و نحوه مصرف دقیق"} {product.podcastDescription || "توضیحات صوتی دامپزشک و کادر علمی درباره اثربخشی، مکانیسم اثر و نحوه مصرف دقیق"}
</p> </p>
</div> <div className="pt-2 flex justify-center md:justify-start">
</div>
<button <button
type="button" type="button"
onClick={() => setIsPodcastModalOpen(true)} 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" className="py-2.5 px-6 bg-purple-600 hover:bg-purple-700 text-white rounded-xl font-bold text-xs transition-all flex items-center gap-2 cursor-pointer shadow-md shadow-purple-600/20"
> >
<Play className="w-4 h-4 fill-white" /> <Play className="w-4 h-4 fill-white" />
<span>شنیدن پادکست در پلیر اختصاصی</span> <span>شنیدن پادکست</span>
</button> </button>
</div> </div>
)}
</div> </div>
</div>
)}
{/* PDF Catalog Download Box */} {/* PDF Catalog Download Row */}
{product.pdfUrl && ( {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="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="flex items-center gap-4 text-center sm:text-right">
@ -826,6 +819,7 @@ export default function ProductPage({ productSlug }: { productSlug: string }) {
</a> </a>
</div> </div>
)} )}
</div>
</section> </section>
)} )}

View File

@ -102,10 +102,10 @@ export default function ProductReviews({ productId, productName }: ProductReview
return ( return (
<section className="bg-white border border-medical-gray-200 rounded-[2.5rem] p-6 sm:p-10 shadow-md space-y-8 font-vazir text-right" dir="rtl"> <section className="bg-white border border-medical-gray-200 rounded-[2.5rem] p-6 sm:p-10 shadow-md space-y-8 font-vazir text-right" dir="rtl">
{/* Header & Rating Summary */} {/* Header & Rating Summary */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-6 pb-6 border-b border-medical-gray-100"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-6 pb-6 border-b border-medical-gray-100">
<div className="space-y-2"> <div className="space-y-1 text-right">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-12 h-12 bg-canina-gold/10 text-canina-gold rounded-2xl flex items-center justify-center shadow-xs"> <div className="w-12 h-12 bg-canina-gold/10 text-canina-gold rounded-2xl flex items-center justify-center shadow-xs shrink-0">
<Star className="w-6 h-6 fill-canina-gold" /> <Star className="w-6 h-6 fill-canina-gold" />
</div> </div>
<div> <div>
@ -120,13 +120,13 @@ export default function ProductReviews({ productId, productName }: ProductReview
</div> </div>
{/* Rating Score Badge & Add Review Button */} {/* Rating Score Badge & Add Review Button */}
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-3 justify-start sm:justify-end">
<div className="flex items-center gap-3 px-5 py-3 bg-medical-gray-50 border border-medical-gray-100 rounded-2xl"> <div className="flex items-center gap-2.5 px-4 py-2.5 bg-medical-gray-50 border border-medical-gray-100 rounded-2xl">
<div className="flex items-center gap-1"> <div className="flex items-center gap-0.5">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<Star <Star
key={i} key={i}
className={`w-4 h-4 ${ className={`w-3.5 h-3.5 ${
i < Math.round(stats.averageRating) i < Math.round(stats.averageRating)
? "fill-amber-400 text-amber-400" ? "fill-amber-400 text-amber-400"
: "text-medical-gray-200" : "text-medical-gray-200"
@ -134,17 +134,18 @@ export default function ProductReviews({ productId, productName }: ProductReview
/> />
))} ))}
</div> </div>
<div className="text-sm font-black text-medical-gray-900"> <div className="text-xs font-black text-medical-gray-900">
{toPersian(stats.averageRating)} از ۵ {toPersian(stats.averageRating)} از ۵
</div> </div>
<span className="text-xs text-medical-gray-400 font-bold border-r border-medical-gray-200 pr-2"> <span className="text-[11px] text-medical-gray-400 font-bold border-r border-medical-gray-200 pr-2">
({toPersian(stats.totalReviews)} دیدگاه) ({toPersian(stats.totalReviews)} دیدگاه)
</span> </span>
</div> </div>
<button <button
type="button"
onClick={() => setFormOpen(!formOpen)} onClick={() => setFormOpen(!formOpen)}
className="px-5 py-3 bg-canina-blue hover:bg-canina-dark text-white rounded-2xl text-xs font-black transition-all shadow-md shadow-canina-blue/20 flex items-center gap-2" className="px-5 py-2.5 bg-medical-gray-900 hover:bg-canina-blue text-white rounded-2xl text-xs font-black transition-all shadow-md shadow-black/10 flex items-center gap-2 cursor-pointer"
> >
<MessageSquare className="w-4 h-4" /> <MessageSquare className="w-4 h-4" />
<span>{formOpen ? "بستن فرم" : "ثبت دیدگاه جدید"}</span> <span>{formOpen ? "بستن فرم" : "ثبت دیدگاه جدید"}</span>

View File

@ -173,7 +173,7 @@ export default function VideoModalPlayer({ isOpen, onClose, video }: VideoModalP
if (navigator.share) { if (navigator.share) {
try { try {
await navigator.share({ await navigator.share({
title: video?.title || 'ویدئوی آموزشی کنینا', title: video?.title || 'ویدئوی راهنمای کنینا',
text: video?.description || '', text: video?.description || '',
url: shareUrl url: shareUrl
}); });
@ -213,6 +213,8 @@ export default function VideoModalPlayer({ isOpen, onClose, video }: VideoModalP
if (!isOpen || !video) return null; if (!isOpen || !video) return null;
const isIframe = video.videoUrl?.includes("<iframe"); const isIframe = video.videoUrl?.includes("<iframe");
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0;
const volumePercent = isMuted ? 0 : volume * 100;
return ( return (
<AnimatePresence> <AnimatePresence>
@ -370,12 +372,19 @@ export default function VideoModalPlayer({ isOpen, onClose, video }: VideoModalP
</div> </div>
)} )}
{/* Progress / Seek Bar */} {/* Progress / Seek Bar with filled progress track */}
<div className="flex items-center gap-3 dir-ltr" dir="ltr"> <div className="flex items-center gap-3 dir-ltr" dir="ltr">
<span className="text-[11px] font-mono font-bold text-white/70 w-10 text-right"> <span className="text-[11px] font-mono font-bold text-white/70 w-10 text-right">
{formatTime(currentTime)} {formatTime(currentTime)}
</span> </span>
<div className="relative flex-1 flex items-center"> <div className="relative flex-1 flex items-center h-4">
{/* Background Track */}
<div className="absolute inset-x-0 h-1.5 bg-white/20 rounded-lg overflow-hidden pointer-events-none">
<div
className="h-full bg-canina-blue transition-all"
style={{ width: `${progressPercent}%` }}
/>
</div>
<input <input
type="range" type="range"
min={0} min={0}
@ -383,7 +392,12 @@ export default function VideoModalPlayer({ isOpen, onClose, video }: VideoModalP
step={0.1} step={0.1}
value={currentTime} value={currentTime}
onChange={handleSeek} onChange={handleSeek}
className="w-full h-1.5 bg-white/20 rounded-lg appearance-none cursor-pointer accent-canina-blue hover:h-2 transition-all" className="w-full h-4 opacity-0 absolute inset-0 cursor-pointer z-10"
/>
{/* Visual Scrubber Thumb */}
<div
className="absolute w-3.5 h-3.5 bg-white rounded-full shadow-md pointer-events-none -translate-x-1/2 transition-transform hover:scale-125"
style={{ left: `${progressPercent}%` }}
/> />
</div> </div>
<span className="text-[11px] font-mono font-bold text-white/70 w-10 text-left"> <span className="text-[11px] font-mono font-bold text-white/70 w-10 text-left">
@ -393,8 +407,8 @@ export default function VideoModalPlayer({ isOpen, onClose, video }: VideoModalP
{/* Action Buttons Row */} {/* Action Buttons Row */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
{/* Left Controls (Playback & Jumps) */} {/* Left Controls (Playback & Jumps with - on Left and + on Right in RTL) */}
<div className="flex items-center gap-2 sm:gap-4"> <div className="flex items-center gap-2 sm:gap-3" dir="rtl">
{/* Play/Pause */} {/* Play/Pause */}
<button <button
type="button" type="button"
@ -405,38 +419,37 @@ export default function VideoModalPlayer({ isOpen, onClose, video }: VideoModalP
{isPlaying ? <Pause className="w-5 h-5 fill-white" /> : <Play className="w-5 h-5 fill-white ml-0.5" />} {isPlaying ? <Pause className="w-5 h-5 fill-white" /> : <Play className="w-5 h-5 fill-white ml-0.5" />}
</button> </button>
{/* -10s */} {/* -10s button */}
<button <button
type="button" type="button"
onClick={() => skipTime(-10)} onClick={() => skipTime(-10)}
className="p-2 text-white/70 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-1 text-xs font-mono font-bold" className="px-2.5 py-1.5 text-white/80 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-1 text-xs font-mono font-bold border border-white/10"
title="۱۰ ثانیه عقب" title="۱۰ ثانیه عقب"
> >
<RotateCcw className="w-4 h-4" /> <RotateCcw className="w-3.5 h-3.5" />
<span className="text-[10px]">10s</span> <span>-10s</span>
</button> </button>
{/* +10s */} {/* +10s button */}
<button <button
type="button" type="button"
onClick={() => skipTime(10)} onClick={() => skipTime(10)}
className="p-2 text-white/70 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-1 text-xs font-mono font-bold" className="px-2.5 py-1.5 text-white/80 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer flex items-center gap-1 text-xs font-mono font-bold border border-white/10"
title="۱۰ ثانیه جلو" title="۱۰ ثانیه جلو"
> >
<RotateCw className="w-4 h-4" /> <RotateCw className="w-3.5 h-3.5" />
<span className="text-[10px]">10s</span> <span>+10s</span>
</button> </button>
{/* Volume slider */} {/* Volume Slider: Increases towards the speaker icon */}
<div className="hidden sm:flex items-center gap-2 group/vol"> <div className="hidden sm:flex items-center gap-2 pr-2" dir="ltr">
<button <div className="relative w-20 flex items-center h-4">
type="button" <div className="absolute inset-x-0 h-1.5 bg-white/20 rounded-lg overflow-hidden pointer-events-none">
onClick={toggleMute} <div
className="p-2 text-white/70 hover:text-white rounded-lg hover:bg-white/10 transition-colors cursor-pointer" className="h-full bg-canina-blue transition-all"
title={isMuted ? "صدا وصل" : "بی‌صدا"} style={{ width: `${volumePercent}%` }}
> />
{isMuted || volume === 0 ? <VolumeX className="w-4 h-4" /> : <Volume2 className="w-4 h-4" />} </div>
</button>
<input <input
type="range" type="range"
min={0} min={0}
@ -444,8 +457,21 @@ export default function VideoModalPlayer({ isOpen, onClose, video }: VideoModalP
step={0.05} step={0.05}
value={isMuted ? 0 : volume} value={isMuted ? 0 : volume}
onChange={handleVolumeChange} onChange={handleVolumeChange}
className="w-16 h-1 bg-white/20 rounded-lg appearance-none cursor-pointer accent-canina-blue" className="w-full h-4 opacity-0 absolute inset-0 cursor-pointer z-10"
/> />
<div
className="absolute w-2.5 h-2.5 bg-white rounded-full shadow pointer-events-none -translate-x-1/2"
style={{ left: `${volumePercent}%` }}
/>
</div>
<button
type="button"
onClick={toggleMute}
className="p-1.5 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>
</div> </div>
</div> </div>
@ -456,7 +482,7 @@ export default function VideoModalPlayer({ isOpen, onClose, video }: VideoModalP
<button <button
type="button" type="button"
onClick={() => setShowRateMenu(!showRateMenu)} onClick={() => setShowRateMenu(!showRateMenu)}
className="px-2.5 py-1 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" className="px-2.5 py-1 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="سرعت پخش" title="سرعت پخش"
> >
<span>{playbackRate}x</span> <span>{playbackRate}x</span>