diff --git a/frontend/admin-panel/src/pages/Products.tsx b/frontend/admin-panel/src/pages/Products.tsx index a43557d..00fa3d1 100644 --- a/frontend/admin-panel/src/pages/Products.tsx +++ b/frontend/admin-panel/src/pages/Products.tsx @@ -308,16 +308,76 @@ export default function Products() { const handleSave = async (e: React.FormEvent) => { 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 { const derivedSlug = formData.slug.trim() || slugify(formData.nameEn); - const finalPayload = { - ...formData, + 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(), buyPrice: formData.buyPrice ? Number(formData.buyPrice) : 0, - wholesalePrice: formData.wholesalePrice ? Number(formData.wholesalePrice) : null, - slug: derivedSlug, + 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, + 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 || 'نام محصول'} | خرید و قیمت مکمل کنینا`, 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) { @@ -338,9 +398,12 @@ export default function Products() { setIsModalOpen(false); setImageErrors({}); await fetchData(); - } catch (error) { + } catch (error: any) { console.error('Save failed', error); - toast.error('خطا در ذخیره محصول'); + const serverMsg = error.response?.data?.message || error.message; + if (serverMsg && !error._toastShown) { + toast.error(`خطا در ذخیره محصول: ${serverMsg}`); + } } }; diff --git a/frontend/application/components/Header.tsx b/frontend/application/components/Header.tsx index 46458c2..220f6b3 100644 --- a/frontend/application/components/Header.tsx +++ b/frontend/application/components/Header.tsx @@ -365,16 +365,16 @@ export default function Header({ onMouseLeave={() => setActiveDropdown(null)} > {activeDropdown === 'products' && ( -
+
- کاتالوگ جامع محصولات + فروشگاه تخصصی محصولات کنینا - کاتالوگ آنلاین و دوز مصرفی + کاتالوگ دیجیتال و راهنمای بالینی
)} diff --git a/frontend/application/components/Hero.tsx b/frontend/application/components/Hero.tsx index d82e5c0..2daef11 100644 --- a/frontend/application/components/Hero.tsx +++ b/frontend/application/components/Hero.tsx @@ -70,7 +70,7 @@ export default function Hero({ banners = [], onShopNavigate }: { banners?: Banne const subtitle = (mounted && isInitialized ? getText('hero_desc', '') : '') || defaultSubtitle; 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 = () => { if (totalSlides > 1) { @@ -193,18 +193,20 @@ export default function Hero({ banners = [], onShopNavigate }: { banners?: Banne onTouchEnd={onTouchEnd} > - + {activeImageUrl && ( + + )} {/* Gradient overlay and slide caption */} diff --git a/frontend/application/components/PodcastPlayerModal.tsx b/frontend/application/components/PodcastPlayerModal.tsx index c119990..c424007 100644 --- a/frontend/application/components/PodcastPlayerModal.tsx +++ b/frontend/application/components/PodcastPlayerModal.tsx @@ -1,5 +1,5 @@ "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 { Play, @@ -32,8 +32,13 @@ interface PodcastPlayerModalProps { 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) { const audioRef = useRef(null); + const waveformRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); const [currentTime, setCurrentTime] = 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 [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 useEffect(() => { if (isOpen) { @@ -67,14 +82,22 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast } }, []); - const handleSeek = (e: React.ChangeEvent) => { - const time = Number(e.target.value); - if (audioRef.current) { - audioRef.current.currentTime = time; - setCurrentTime(time); + 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)); @@ -116,7 +139,7 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast if (navigator.share) { try { await navigator.share({ - title: podcast?.title || 'پادکست و بررسی تخصصی کنینا', + title: podcast?.title || 'پادکست و بررسی بالینی مکمل کنینا', text: podcast?.description || '', url: shareUrl }); @@ -156,6 +179,8 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast if (!isOpen || !podcast) return null; 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 ( @@ -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" > {/* Top Bar (Header + Close) */} -
-
+
+
- پادکست و بررسی صوتی کنینا + پادکست و تحلیل صوتی بالینی
@@ -235,7 +260,7 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast {/* Cover Art Artwork */}
-
+
{podcast.title {/* Title and Metadata */} -
-

+
+

{podcast.title || "بررسی بالینی و نحوه مصرف مکمل"}

{podcast.productName && ( -

+

محصول: {podcast.productName}

)} {podcast.description && ( -

+

{podcast.description}

)}
- {/* Seeker / Progress Bar */} -
-
- - {formatTime(currentTime)} - -
- -
- - {formatTime(duration)} - + {/* Soundcloud-like Waveform Visualizer & Seeker */} +
+
+ {waveformHeights.map((h, i) => { + const barPercent = (i / (WAVEFORM_BAR_COUNT - 1)) * 100; + const isPassed = barPercent <= progressPercent; + return ( +
+
+
+ ); + })} +
+ + {/* Time Stamp Row */} +
+ {formatTime(currentTime)} + {formatTime(duration)}
@@ -302,7 +344,7 @@ export default function PodcastPlayerModal({ isOpen, onClose, podcast }: Podcast
- {/* Central Controls: -15s, Play/Pause, +15s */} -
- {/* -15s */} - - + {/* Central Controls: -15s on Left, Play/Pause in Middle, +15s on Right in RTL */} +
{/* Play / Pause button */} - {/* +15s */} + {/* -15s button (left) */} + + + {/* +15s button (right) */}
- {/* Volume Control */} -
+ {/* Volume Control: Increases towards the speaker icon */} +
+
+
+
+
+ +
+
-
diff --git a/frontend/application/components/ProductPage.tsx b/frontend/application/components/ProductPage.tsx index 2e1bcc0..df43eba 100644 --- a/frontend/application/components/ProductPage.tsx +++ b/frontend/application/components/ProductPage.tsx @@ -692,140 +692,134 @@ export default function ProductPage({ productSlug }: { productSlug: string }) {
-
- {/* Video Player Card */} +
+ {/* Video Player Row */} {product.videoUrl && ( -
-
-
- - - ویدیو راهنما - - Video Guide -
- - {/* Thumbnail / Cover Box */} -
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" - > - {product.videoTitle { - (e.target as HTMLImageElement).src = product.image; - }} - /> -
-
- -
+
+ {/* Thumbnail / Cover Box */} +
setIsVideoModalOpen(true)} + 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" + > + {product.videoTitle { + (e.target as HTMLImageElement).src = product.image; + }} + /> +
+
+
- -
-

- {product.videoTitle || "ویدیو معرفی و راهنمای مصرف محصول"} -

-

- {product.videoDescription || "مشاهده راهنمای کامل، دوز مصرفی و آموزش خوراندن مکمل توسط کادر تخصصی کنینا"} -

-
- +
+
+ + + ویدیو راهنما و معرفی + +
+

+ {product.videoTitle || "ویدیو راهنمای مصرف و معرفی مکمل"} +

+

+ {product.videoDescription || "مشاهده ویدیوی آموزشی، دوزبندی دقیق و نحوه مصرف مکمل توسط کادر علمی کنینا"} +

+
+ +
+
)} - {/* Podcast Player Card */} + {/* Podcast Player Row */} {product.podcastUrl && ( -
-
-
- - 🎧 پادکست اختصاصی - - Audio Analysis -
- - {/* Podcast Cover / Artwork Box */} -
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" - > - {product.podcastTitle { - (e.target as HTMLImageElement).src = product.image; - }} - /> -
-
- -
+
+ {/* Podcast Cover / Artwork Box */} +
setIsPodcastModalOpen(true)} + 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" + > + {product.podcastTitle { + (e.target as HTMLImageElement).src = product.image; + }} + /> +
+
+
- -
-

- {product.podcastTitle || "پادکست و بررسی صوتی بالینی مکمل"} -

-

- {product.podcastDescription || "توضیحات صوتی دامپزشک و کادر علمی درباره اثربخشی، مکانیسم اثر و نحوه مصرف دقیق"} -

-
- +
+
+
+ )} + + {/* PDF Catalog Download Row */} + {product.pdfUrl && ( +
+
+
+ PDF +
+
+

+ {product.pdfTitle || "بروشور و کاتالوگ علمی Canina آلمان"} +

+

+ {product.pdfDescription || "شامل مقالات رفرنس، جدول ترکیبات دقیق و سرتیفیکیت رسمی"} +

+
+
+ - - شنیدن پادکست در پلیر اختصاصی - + + دانلود مستقیم فایل PDF +
)}
- - {/* PDF Catalog Download Box */} - {product.pdfUrl && ( -
-
-
- PDF -
-
-

- {product.pdfTitle || "بروشور و کاتالوگ علمی Canina آلمان"} -

-

- {product.pdfDescription || "شامل مقالات رفرنس، جدول ترکیبات دقیق و سرتیفیکیت رسمی"} -

-
-
- - - دانلود مستقیم فایل PDF - -
- )} )} diff --git a/frontend/application/components/ProductReviews.tsx b/frontend/application/components/ProductReviews.tsx index b76141b..ce1628d 100644 --- a/frontend/application/components/ProductReviews.tsx +++ b/frontend/application/components/ProductReviews.tsx @@ -102,10 +102,10 @@ export default function ProductReviews({ productId, productName }: ProductReview return (
{/* Header & Rating Summary */} -
-
+
+
-
+
@@ -120,13 +120,13 @@ export default function ProductReviews({ productId, productName }: ProductReview
{/* Rating Score Badge & Add Review Button */} -
-
-
+
+
+
{Array.from({ length: 5 }).map((_, i) => ( ))}
-
+
{toPersian(stats.averageRating)} از ۵
- + ({toPersian(stats.totalReviews)} دیدگاه)
)} - {/* Progress / Seek Bar */} + {/* Progress / Seek Bar with filled progress track */}
{formatTime(currentTime)} -
+
+ {/* Background Track */} +
+
+
+ {/* Visual Scrubber Thumb */} +
@@ -393,8 +407,8 @@ export default function VideoModalPlayer({ isOpen, onClose, video }: VideoModalP {/* Action Buttons Row */}
- {/* Left Controls (Playback & Jumps) */} -
+ {/* Left Controls (Playback & Jumps with - on Left and + on Right in RTL) */} +
{/* Play/Pause */} - {/* -10s */} + {/* -10s button */} - {/* +10s */} + {/* +10s button */} - {/* Volume slider */} -
+ {/* Volume Slider: Increases towards the speaker icon */} +
+
+
+
+
+ +
+
-
@@ -456,7 +482,7 @@ export default function VideoModalPlayer({ isOpen, onClose, video }: VideoModalP