feat: searchable province/city selects, zip code support, fix trust-seals header, responsive mobile logo & auth, cart scroll lock & safe image fallback, toast font
Some checks failed
Deploy Canina / deploy (push) Failing after 18s

This commit is contained in:
parsa aghaei 2026-08-01 12:41:35 +03:30
parent 8e9ac246cb
commit f8e9ebfc44
9 changed files with 201 additions and 86 deletions

View File

@ -137,6 +137,11 @@
}
}
/* Sonner Toasts Vazirmatn Font */
[data-sonner-toaster] {
font-family: "Vazirmatn", sans-serif !important;
}
/* Custom scrollbar for a professional look */
::-webkit-scrollbar {
width: 6px;

View File

@ -1,8 +1,6 @@
import React from 'react';
import { Metadata } from 'next';
import EnamadBadge from '@/components/EnamadBadge';
import Header from '@/components/Header';
import Footer from '@/components/Footer';
import { ShieldCheck, Award, FileCheck, CheckCircle2, Building2, ExternalLink } from 'lucide-react';
export const metadata: Metadata = {
@ -47,9 +45,7 @@ export default function TrustSealsPage() {
];
return (
<div className="min-h-screen bg-slate-50 flex flex-col font-vazir">
<Header />
<div className="bg-slate-50 flex flex-col font-vazir">
<main className="flex-1 max-w-7xl w-full mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Banner Section */}
<div className="relative bg-gradient-to-r from-teal-700 via-teal-600 to-emerald-700 rounded-3xl p-8 md:p-12 text-white shadow-xl overflow-hidden mb-12">
@ -129,8 +125,6 @@ export default function TrustSealsPage() {
</div>
</section>
</main>
<Footer />
</div>
);
}

View File

@ -5,6 +5,7 @@ import { X, MapPin, User, Phone, Hash, Save, Check } from "lucide-react";
import { toPersian, cn } from "../lib/utils";
import { Address } from "../lib/store/userStore";
import { IRAN_PROVINCES, PROVINCE_CITIES } from "../lib/data/provinces";
import SearchableSelect from "./SearchableSelect";
import { toast } from "sonner";
interface AddressModalProps {
@ -206,44 +207,35 @@ export default function AddressModal({ isOpen, onClose, onSave, editingAddress }
{/* Row 3: Province & City */}
<div className="space-y-1">
<label htmlFor="addr-province" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">استان</label>
<select
<SearchableSelect
id="addr-province"
name="address-level1"
options={IRAN_PROVINCES}
value={formData.province}
onChange={e => {
const newProv = e.target.value;
placeholder="انتخاب استان..."
searchPlaceholder="جستجوی استان..."
onChange={(newProv) => {
const cities = PROVINCE_CITIES[newProv] || [];
setFormData({
...formData,
...formData,
province: newProv,
city: cities[0] || ""
});
}}
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20 bg-white", errors.province ? "border-red-300" : "border-medical-gray-100")}
>
<option value="">انتخاب استان...</option>
{IRAN_PROVINCES.map(prov => (
<option key={prov} value={prov}>{prov}</option>
))}
</select>
/>
{errors.province && <p className="text-[10px] text-red-500 font-bold pr-2">{errors.province}</p>}
</div>
<div className="space-y-1">
<label htmlFor="addr-city" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block pr-2">شهر</label>
{formData.province && PROVINCE_CITIES[formData.province] ? (
<select
<SearchableSelect
id="addr-city"
name="address-level2"
options={PROVINCE_CITIES[formData.province]}
value={formData.city}
onChange={e => setFormData({...formData, city: e.target.value})}
className={cn("w-full bg-medical-gray-50 border rounded-2xl py-3 px-5 outline-none font-bold transition-all focus:ring-2 focus:ring-canina-blue/20 bg-white", errors.city ? "border-red-300" : "border-medical-gray-100")}
>
<option value="">انتخاب شهر...</option>
{PROVINCE_CITIES[formData.province].map(c => (
<option key={c} value={c}>{c}</option>
))}
</select>
placeholder="انتخاب شهر..."
searchPlaceholder="جستجوی شهر..."
onChange={(c) => setFormData({...formData, city: c})}
/>
) : (
<input
id="addr-city"

View File

@ -50,6 +50,18 @@ export default function CartDrawer({ isOpen, onClose, onCheckout, onShopNavigate
setSuggestedProduct(null);
}
}, [hasLachsOl, hasCanhydrox]);
// Lock background body scroll when cart drawer is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "unset";
}
return () => {
document.body.style.overflow = "unset";
};
}, [isOpen]);
return (
<AnimatePresence>

View File

@ -26,6 +26,7 @@ import { useSettingsStore } from "../lib/store/settingsStore";
import { productService } from "../lib/services/productService";
import TopUpModal from "./TopUpModal";
import AddressModal from "./AddressModal";
import SearchableSelect from "./SearchableSelect";
import { IRAN_PROVINCES, PROVINCE_CITIES } from "../lib/data/provinces";
import { toast } from "sonner";
import { useRouter } from 'next/navigation';
@ -50,6 +51,7 @@ export default function CheckoutPage() {
const [manualPhone, setManualPhone] = useState('');
const [manualProvince, setManualProvince] = useState('');
const [manualCity, setManualCity] = useState('');
const [manualZipCode, setManualZipCode] = useState('');
const [manualAddress, setManualAddress] = useState('');
const handleCopy = (text: string, title: string) => {
@ -120,10 +122,10 @@ export default function CheckoutPage() {
const selectedAddr = profile.addresses?.find(a => a.id === selectedAddressId);
let shippingAddressStr = '';
if (selectedAddr) {
shippingAddressStr = `${selectedAddr.province}، ${selectedAddr.city}، ${selectedAddr.detail} (گیرنده: ${selectedAddr.receptorName} - ${toPersian(selectedAddr.phone)})`;
shippingAddressStr = `${selectedAddr.province}، ${selectedAddr.city}، ${selectedAddr.detail}${selectedAddr.zipCode ? ` (کد پستی: ${selectedAddr.zipCode})` : ''} (گیرنده: ${selectedAddr.receptorName} - ${toPersian(selectedAddr.phone)})`;
} else if (manualAddress) {
const provCity = [manualProvince, manualCity].filter(Boolean).join('، ');
shippingAddressStr = `${provCity ? provCity + '، ' : ''}${manualAddress}${manualName ? ` (گیرنده: ${manualName}${manualPhone ? ` - ${toPersian(manualPhone)}` : ''})` : ''}`;
shippingAddressStr = `${provCity ? provCity + '، ' : ''}${manualAddress}${manualZipCode ? ` (کد پستی: ${manualZipCode})` : ''}${manualName ? ` (گیرنده: ${manualName}${manualPhone ? ` - ${toPersian(manualPhone)}` : ''})` : ''}`;
}
const orderId = await addOrder({
@ -314,37 +316,30 @@ export default function CheckoutPage() {
</div>
<div className="space-y-2">
<label htmlFor="chk-province" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block">استان *</label>
<select
<SearchableSelect
id="chk-province"
options={IRAN_PROVINCES}
value={manualProvince}
onChange={(e) => {
const prov = e.target.value;
placeholder="انتخاب استان..."
searchPlaceholder="جستجوی استان..."
onChange={(prov) => {
const cities = PROVINCE_CITIES[prov] || [];
setManualProvince(prov);
setManualCity(cities[0] || '');
}}
className="w-full bg-white border border-medical-gray-200 rounded-2xl py-3 sm:py-4 px-4 sm:px-6 text-xs sm:text-sm focus:outline-none focus:ring-2 focus:ring-canina-blue/20 font-bold"
>
<option value="">انتخاب استان...</option>
{IRAN_PROVINCES.map((prov) => (
<option key={prov} value={prov}>{prov}</option>
))}
</select>
/>
</div>
<div className="space-y-2">
<label htmlFor="chk-city" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block">شهر *</label>
{manualProvince && PROVINCE_CITIES[manualProvince] ? (
<select
<SearchableSelect
id="chk-city"
options={PROVINCE_CITIES[manualProvince]}
value={manualCity}
onChange={(e) => setManualCity(e.target.value)}
className="w-full bg-white border border-medical-gray-200 rounded-2xl py-3 sm:py-4 px-4 sm:px-6 text-xs sm:text-sm focus:outline-none focus:ring-2 focus:ring-canina-blue/20 font-bold"
>
<option value="">انتخاب شهر...</option>
{PROVINCE_CITIES[manualProvince].map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
placeholder="انتخاب شهر..."
searchPlaceholder="جستجوی شهر..."
onChange={(c) => setManualCity(c)}
/>
) : (
<input
id="chk-city"
@ -368,6 +363,18 @@ export default function CheckoutPage() {
placeholder="خیابان، کوچه، پلاک، واحد..."
/>
</div>
<div className="md:col-span-2 space-y-2">
<label htmlFor="chk-zipcode" className="text-[10px] font-black text-medical-gray-400 uppercase tracking-widest block">کد پستی ۱۰ رقمی</label>
<input
id="chk-zipcode"
type="text"
maxLength={10}
value={manualZipCode}
onChange={(e) => setManualZipCode(e.target.value.replace(/[^0-9]/g, ''))}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-2xl py-3 sm:py-4 px-4 sm:px-6 text-xs sm:text-sm focus:outline-none focus:ring-2 focus:ring-canina-blue/20 dir-ltr text-right"
placeholder="۱۲۳۴۵۶۷۸۹۰"
/>
</div>
</div>
</div>
)}

View File

@ -152,16 +152,16 @@ export default function Header({
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
<Link href="/" className="flex items-center gap-3 group">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-canina-blue rounded-2xl flex items-center justify-center text-white font-bold text-xl sm:text-2xl group-hover:bg-medical-gray-900 transition-all shadow-md italic">C</div>
<div className="flex flex-col justify-center">
<span className="text-canina-blue font-black text-lg sm:text-2xl tracking-tighter italic font-sans flex items-center gap-1 leading-none">
<Link href="/" className="flex items-center gap-2 sm:gap-3 group shrink-0 min-w-0">
<div className="w-10 h-10 sm:w-12 sm:h-12 min-w-[40px] min-h-[40px] sm:min-w-[48px] sm:min-h-[48px] bg-canina-blue rounded-2xl flex items-center justify-center text-white font-bold text-xl sm:text-2xl group-hover:bg-medical-gray-900 transition-all shadow-md italic shrink-0">C</div>
<div className="flex flex-col justify-center min-w-0">
<span className="text-canina-blue font-black text-base sm:text-2xl tracking-tighter italic font-sans flex items-center gap-1 leading-none truncate">
Canina
<span className="text-xs sm:text-sm not-italic font-medium border-r border-medical-gray-300 pr-1.5 font-vazir text-medical-gray-700">
<span className="text-[11px] sm:text-sm not-italic font-medium border-r border-medical-gray-300 pr-1.5 font-vazir text-medical-gray-700">
{getText('brand_name_fa', "ایران")}
</span>
</span>
<span className="text-[9px] text-medical-gray-400 font-bold uppercase tracking-wider leading-tight mt-0.5">
<span className="hidden sm:block text-[9px] text-medical-gray-400 font-bold uppercase tracking-wider leading-tight mt-0.5 truncate">
نماینده رسمی CANINA PHARMA GMBH GERMANY
</span>
</div>
@ -185,7 +185,7 @@ export default function Header({
</div>
{/* Left Action Buttons */}
<div className="flex items-center gap-2 sm:gap-3">
<div className="flex items-center gap-1.5 sm:gap-3 shrink-0">
{/* Prescription Upload Button */}
<button
onClick={() => setIsPrescriptionModalOpen(true)}
@ -199,7 +199,8 @@ export default function Header({
{isLoggedIn ? (
<Link
href="/dashboard"
className="flex items-center gap-2 px-3 py-2 bg-medical-gray-50 hover:bg-medical-gray-100 border border-medical-gray-200 rounded-xl text-xs font-black text-medical-gray-900 transition-all"
className="flex items-center justify-center p-2.5 sm:px-3 sm:py-2 bg-medical-gray-50 hover:bg-medical-gray-100 border border-medical-gray-200 rounded-xl text-xs font-black text-medical-gray-900 transition-all"
title="حساب کاربری"
>
<User className="w-4 h-4 text-canina-blue" />
<span className="hidden sm:inline">{profile.firstName || 'حساب کاربری'}</span>
@ -207,10 +208,11 @@ export default function Header({
) : (
<button
onClick={() => setIsAuthModalOpen(true)}
className="flex items-center gap-2 px-3.5 py-2 bg-medical-gray-900 text-white hover:bg-canina-blue rounded-xl text-xs font-black transition-all shadow-xs"
className="flex items-center gap-1.5 px-2.5 py-2 sm:px-3.5 sm:py-2 bg-medical-gray-900 text-white hover:bg-canina-blue rounded-xl text-[11px] sm:text-xs font-black transition-all shadow-xs whitespace-nowrap"
>
<LogIn className="w-4 h-4" />
<span>ورود / ثبتنام</span>
<LogIn className="w-4 h-4 shrink-0" />
<span className="hidden xs:inline sm:inline">ورود / ثبتنام</span>
<span className="xs:hidden sm:hidden">ورود</span>
</button>
)}

View File

@ -185,16 +185,16 @@ export default function ProductPage({ productSlug }: { productSlug: string }) {
<div className="min-h-screen bg-medical-gray-50 pt-2 pb-36 sm:pb-20 px-4 sm:px-6 lg:px-8 font-vazir" dir="rtl">
<div className="max-w-7xl mx-auto w-full">
{/* Standard Breadcrumb Navigation & Integrated Back Button */}
<div className="flex items-center justify-between mb-8 pb-4 border-b border-medical-gray-200/60 font-vazir" dir="rtl">
<div className="flex items-center gap-2 text-xs font-bold text-medical-gray-400 whitespace-nowrap">
<span className="cursor-pointer hover:text-canina-blue transition-colors" onClick={() => router.push('/')}>خانه</span>
<ChevronLeft className="w-3 h-3 text-medical-gray-300" />
<span className="cursor-pointer hover:text-canina-blue transition-colors" onClick={() => router.push('/shop')}>فروشگاه</span>
<ChevronLeft className="w-3 h-3 text-medical-gray-300" />
<span className="text-canina-blue font-black">{product.nameFa || product.name}</span>
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-4 mb-8 pb-4 border-b border-medical-gray-200/60 font-vazir" dir="rtl">
<div className="flex items-center gap-2 text-xs font-bold text-medical-gray-400 overflow-x-auto py-1 sleek-hscroll">
<span className="cursor-pointer hover:text-canina-blue transition-colors shrink-0" onClick={() => router.push('/')}>خانه</span>
<ChevronLeft className="w-3 h-3 text-medical-gray-300 shrink-0" />
<span className="cursor-pointer hover:text-canina-blue transition-colors shrink-0" onClick={() => router.push('/shop')}>فروشگاه</span>
<ChevronLeft className="w-3 h-3 text-medical-gray-300 shrink-0" />
<span className="text-canina-blue font-black truncate">{product.nameFa || product.name}</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center justify-end gap-2 shrink-0">
<button
onClick={() => {
if (typeof window !== 'undefined' && navigator.clipboard) {
@ -202,14 +202,14 @@ export default function ProductPage({ productSlug }: { productSlug: string }) {
toast.success("لینک مشخصات و دوز دارویی محصول کپی شد");
}
}}
className="px-3.5 py-1.5 bg-canina-blue/5 border border-canina-blue/20 rounded-lg text-canina-blue shadow-xs flex items-center gap-1.5 font-bold text-xs hover:bg-canina-blue hover:text-white transition-all whitespace-nowrap"
className="flex-1 sm:flex-none px-3.5 py-2 bg-canina-blue/5 border border-canina-blue/20 rounded-xl text-canina-blue shadow-xs flex items-center justify-center gap-1.5 font-bold text-xs hover:bg-canina-blue hover:text-white transition-all whitespace-nowrap"
>
<Share2 className="w-3.5 h-3.5" />
<span>اشتراکگذاری دوز</span>
</button>
<button
onClick={() => router.back()}
className="px-3.5 py-1.5 bg-white border border-medical-gray-200 rounded-lg text-medical-gray-600 shadow-xs flex items-center gap-1.5 font-bold text-xs hover:border-canina-blue hover:text-canina-blue transition-all whitespace-nowrap"
className="flex-1 sm:flex-none px-3.5 py-2 bg-white border border-medical-gray-200 rounded-xl text-medical-gray-600 shadow-xs flex items-center justify-center gap-1.5 font-bold text-xs hover:border-canina-blue hover:text-canina-blue transition-all whitespace-nowrap"
>
<ChevronRight className="w-3.5 h-3.5" />
<span>بازگشت</span>

View File

@ -26,24 +26,13 @@ export default function SafeImage({
if (!src || error) {
return (
<div className={cn(
"flex flex-col items-center justify-center bg-white border border-medical-gray-100 p-4 text-center min-h-[120px] rounded-3xl shadow-inner relative overflow-hidden",
"flex flex-col items-center justify-center bg-medical-gray-50/80 border border-medical-gray-100 p-1.5 text-center w-full h-full rounded-2xl relative overflow-hidden select-none",
className
)}>
{/* Subtle Brand Pattern Background */}
<div className="absolute inset-0 opacity-[0.03] pointer-events-none">
<div className="grid grid-cols-6 h-full w-full">
{Array.from({length: 24}).map((_, i) => (
<div key={i} className="border border-canina-blue" />
))}
</div>
<div className="w-8 h-8 sm:w-10 sm:h-10 bg-canina-blue rounded-xl flex items-center justify-center text-white font-black text-base sm:text-lg shadow-sm italic shrink-0">
C
</div>
<div className="w-16 h-16 bg-canina-blue rounded-2xl flex items-center justify-center text-white font-black text-3xl mb-4 shadow-xl shadow-canina-blue/20 italic transform -rotate-6">C</div>
<div className="relative">
<ImageOff className="w-5 h-5 text-medical-gray-200 mb-2" />
<Sparkles className="w-3 h-3 text-canina-blue absolute -top-1 -right-1 animate-pulse" />
</div>
<span className="text-[9px] font-black text-canina-blue/60 uppercase tracking-[0.2em] font-vazir leading-relaxed">
<span className="text-[8px] font-black text-canina-blue/70 font-vazir leading-tight mt-1 line-clamp-1">
{fallbackText}
</span>
</div>

View File

@ -0,0 +1,114 @@
"use client";
import React, { useState, useRef, useEffect } from "react";
import { ChevronDown, Search, X } from "lucide-react";
import { cn } from "../lib/utils";
interface SearchableSelectProps {
id?: string;
options: string[];
value: string;
onChange: (value: string) => void;
placeholder?: string;
searchPlaceholder?: string;
disabled?: boolean;
className?: string;
}
export default function SearchableSelect({
id,
options,
value,
onChange,
placeholder = "انتخاب کنید...",
searchPlaceholder = "جستجو...",
disabled = false,
className
}: SearchableSelectProps) {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, []);
const filteredOptions = options.filter(opt =>
opt.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div ref={containerRef} className={cn("relative w-full", className)}>
<button
id={id}
type="button"
disabled={disabled}
onClick={() => setIsOpen(!isOpen)}
className={cn(
"w-full bg-white border border-medical-gray-200 rounded-2xl py-3 px-4 text-xs sm:text-sm font-bold flex items-center justify-between transition-all outline-none focus:ring-2 focus:ring-canina-blue/20",
disabled && "bg-medical-gray-50 opacity-60 cursor-not-allowed",
value ? "text-medical-gray-900" : "text-medical-gray-400"
)}
>
<span className="truncate">{value || placeholder}</span>
<ChevronDown className={cn("w-4 h-4 text-medical-gray-400 transition-transform", isOpen && "rotate-180")} />
</button>
{isOpen && (
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-white border border-medical-gray-200 rounded-2xl shadow-xl overflow-hidden max-h-60 flex flex-col font-vazir animate-in fade-in slide-in-from-top-2 duration-150">
<div className="p-2 border-b border-medical-gray-100 relative flex items-center">
<Search className="w-3.5 h-3.5 text-medical-gray-400 absolute right-4" />
<input
type="text"
autoFocus
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={searchPlaceholder}
className="w-full bg-medical-gray-50 border border-medical-gray-200 rounded-xl py-1.5 pr-8 pl-3 text-xs font-bold text-medical-gray-900 outline-none focus:border-canina-blue"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute left-4 text-medical-gray-400 hover:text-medical-gray-600"
>
<X className="w-3 h-3" />
</button>
)}
</div>
<div className="overflow-y-auto flex-1 p-1">
{filteredOptions.length === 0 ? (
<div className="p-3 text-center text-xs text-medical-gray-400 font-bold">
موردی یافت نشد
</div>
) : (
filteredOptions.map((opt) => (
<button
key={opt}
type="button"
onClick={() => {
onChange(opt);
setIsOpen(false);
setSearchQuery("");
}}
className={cn(
"w-full text-right px-3 py-2 rounded-xl text-xs font-bold transition-colors flex items-center justify-between",
value === opt ? "bg-canina-blue/10 text-canina-blue" : "hover:bg-medical-gray-50 text-medical-gray-700"
)}
>
<span>{opt}</span>
{value === opt && <span className="w-1.5 h-1.5 rounded-full bg-canina-blue" />}
</button>
))
)}
</div>
</div>
)}
</div>
);
}