1110 lines
46 KiB
TypeScript
1110 lines
46 KiB
TypeScript
import { useState, useEffect, useRef } from 'react';
|
||
import {
|
||
Languages,
|
||
Save,
|
||
Search,
|
||
Loader2,
|
||
Layout,
|
||
Sliders,
|
||
Link as LinkIcon,
|
||
FileText,
|
||
Check,
|
||
Upload,
|
||
HelpCircle,
|
||
Eye,
|
||
Layers,
|
||
ChevronDown,
|
||
Sparkles,
|
||
Home,
|
||
ShoppingBag,
|
||
BookOpen,
|
||
PhoneCall,
|
||
Building2,
|
||
Heart,
|
||
Smile,
|
||
Globe,
|
||
Settings,
|
||
Pill,
|
||
ShieldCheck,
|
||
HeartPulse,
|
||
Activity,
|
||
Award,
|
||
Video,
|
||
Info,
|
||
} from 'lucide-react';
|
||
import { toast } from 'react-hot-toast';
|
||
import api, { BASE_DOMAIN } from '../services/api';
|
||
import Spinner from '../components/ui/Spinner';
|
||
import ToggleSwitch from '../components/ui/ToggleSwitch';
|
||
import ImagePreviewModal from '../components/ui/ImagePreviewModal';
|
||
import MediaSelector from '../components/ui/MediaSelector';
|
||
import RichTextEditor from '../components/ui/RichTextEditor';
|
||
import IconPickerModal, { ICON_REGISTRY } from '../components/ui/IconPickerModal';
|
||
|
||
// Classic v1 Groupings
|
||
const V1_PAGE_TABS = [
|
||
{ id: 'all', label: 'همه بخشها', icon: Layout },
|
||
{ id: 'hero', label: 'بنر اصلی (Hero)', icon: Sliders },
|
||
{ id: 'header', label: 'هدر و دکمهها', icon: LinkIcon },
|
||
{ id: 'advisor', label: 'دستیار سلامت', icon: HelpCircle },
|
||
{ id: 'videos', label: 'ویدئوها & CTA', icon: FileText },
|
||
{ id: 'footer', label: 'فوتور و ارتباطات', icon: Languages },
|
||
];
|
||
|
||
const V1_GROUPS: Record<string, string[]> = {
|
||
'تنظیمات کاتالوگمود و حالت صیانت': [
|
||
'maintenance_mode',
|
||
'maintenance_title',
|
||
'maintenance_desc',
|
||
'maintenance_eta',
|
||
'maintenance_contact_phone',
|
||
'maintenance_badge',
|
||
'catalog_mode',
|
||
'catalog_hide_prices',
|
||
'catalog_disable_cart',
|
||
'catalog_disable_checkout',
|
||
],
|
||
'هدر و ناوبری': [
|
||
'shipping_notice',
|
||
'brand_name_fa',
|
||
'brand_subtitle',
|
||
'nav_solutions',
|
||
'nav_products',
|
||
'nav_wiki',
|
||
'nav_blog',
|
||
'nav_pet_profiles',
|
||
'nav_search_placeholder',
|
||
'nav_search_button',
|
||
'nav_pet_switcher_title',
|
||
'nav_register_pet',
|
||
'nav_wallet_orders',
|
||
'nav_logout',
|
||
'nav_login',
|
||
'nav_cart',
|
||
],
|
||
'بنر اصلی (Hero)': [
|
||
'hero_badge',
|
||
'hero_title',
|
||
'hero_desc',
|
||
'hero_btn_advisor',
|
||
'hero_btn_products',
|
||
'hero_image_badge',
|
||
'hero_image_title',
|
||
'hero_quality_standard',
|
||
'hero_image_url',
|
||
'hero_stat_founded',
|
||
'hero_stat_agencies',
|
||
'hero_stat_german_formula',
|
||
],
|
||
'دستیار سلامت': [
|
||
'advisor_badge',
|
||
'advisor_title',
|
||
'advisor_subtitle',
|
||
'advisor_step1_title',
|
||
'advisor_step1_desc',
|
||
'advisor_pet_name_label',
|
||
'advisor_pet_name_placeholder',
|
||
'advisor_pet_breed_label',
|
||
'advisor_pet_breed_placeholder',
|
||
'advisor_submit_btn',
|
||
'advisor_validation_warning',
|
||
'advisor_step2_title',
|
||
'advisor_step2_desc',
|
||
'advisor_age_label',
|
||
'advisor_weight_label',
|
||
'advisor_activity_label',
|
||
'advisor_back',
|
||
'advisor_next_final',
|
||
'advisor_step3_title',
|
||
'advisor_step3_desc',
|
||
'advisor_complete',
|
||
],
|
||
'محصولات برگزیده': [
|
||
'featured_badge',
|
||
'featured_title_line1',
|
||
'featured_title_line2',
|
||
'featured_desc',
|
||
'featured_view_all',
|
||
'product_view_details',
|
||
'featured_no_products_title',
|
||
'featured_no_products_desc',
|
||
],
|
||
'ویدئوها': [
|
||
'videos_badge',
|
||
'videos_title',
|
||
'videos_title_highlight',
|
||
'videos_desc',
|
||
'videos_view_all',
|
||
],
|
||
'کارتهای ناوبری': [
|
||
'nav_card_shop_title',
|
||
'nav_card_shop_desc',
|
||
'nav_card_shop_cta',
|
||
'nav_card_wiki_title',
|
||
'nav_card_wiki_desc',
|
||
'nav_card_wiki_cta',
|
||
],
|
||
'درباره ما': [
|
||
'about_label',
|
||
'about_title',
|
||
'about_item1_title',
|
||
'about_item1_desc',
|
||
'about_item2_title',
|
||
'about_item2_desc',
|
||
'about_item3_title',
|
||
'about_item3_desc',
|
||
],
|
||
'CTA': [
|
||
'cta_title',
|
||
'cta_desc',
|
||
'cta_btn_primary',
|
||
'cta_btn_secondary',
|
||
],
|
||
'فوتر': [
|
||
'footer_brand_title',
|
||
'footer_brand_subtitle',
|
||
'footer_brand_desc',
|
||
'footer_links_title',
|
||
'footer_link_products',
|
||
'footer_link_blog',
|
||
'footer_link_profiles',
|
||
'footer_link_wiki',
|
||
'footer_link_b2b',
|
||
'footer_contact_title',
|
||
'footer_phone_label',
|
||
'footer_phone',
|
||
'footer_email_label',
|
||
'footer_email',
|
||
'footer_address_label',
|
||
'footer_address',
|
||
'footer_auth_title',
|
||
'footer_auth_desc',
|
||
'footer_auth_badge',
|
||
'footer_copyright',
|
||
'footer_about',
|
||
'footer_contact',
|
||
'footer_privacy',
|
||
],
|
||
};
|
||
|
||
const V1_GROUP_PAGE_MAP: Record<string, string> = {
|
||
'بنر اصلی (Hero)': 'hero',
|
||
'هدر و ناوبری': 'header',
|
||
'دستیار سلامت': 'advisor',
|
||
'ویدئوها': 'videos',
|
||
'CTA': 'videos',
|
||
'کارتهای ناوبری': 'hero',
|
||
'فوتر': 'footer',
|
||
};
|
||
|
||
// Structured Page Builder v2 Definitions
|
||
interface SectionField {
|
||
key: string;
|
||
label: string;
|
||
type: 'rich-text' | 'text' | 'image' | 'icon' | 'boolean';
|
||
description?: string;
|
||
defaultVal?: string;
|
||
}
|
||
|
||
interface PageSection {
|
||
id: string;
|
||
title: string;
|
||
icon: React.ComponentType<{ className?: string }>;
|
||
fields: SectionField[];
|
||
}
|
||
|
||
interface AppSitePage {
|
||
id: string;
|
||
title: string;
|
||
path: string;
|
||
icon: React.ComponentType<{ className?: string }>;
|
||
sections: PageSection[];
|
||
}
|
||
|
||
const SITE_PAGES: AppSitePage[] = [
|
||
{
|
||
id: 'home',
|
||
title: 'صفحه اصلی (Home Page)',
|
||
path: '/',
|
||
icon: Home,
|
||
sections: [
|
||
{
|
||
id: 'hero',
|
||
title: 'بنر اصلی (Hero Banner)',
|
||
icon: Sliders,
|
||
fields: [
|
||
{ key: 'hero_badge', label: 'بج بالای تیتر بنر', type: 'text' },
|
||
{ key: 'hero_title', label: 'عنوان اصلی بنر (H1 سئو)', type: 'rich-text', description: 'تگهای H1 یا رنگ کلمات کلیدی را مشخص کنید' },
|
||
{ key: 'hero_desc', label: 'متن توضیحات بنر', type: 'rich-text' },
|
||
{ key: 'hero_btn_advisor', label: 'متن دکمه دستیار سلامت', type: 'text' },
|
||
{ key: 'hero_btn_products', label: 'متن دکمه کاتالوگ محصولات', type: 'text' },
|
||
{ key: 'hero_image_url', label: 'تصویر پسزمینه یا گرافیک بنر اصلی', type: 'image' },
|
||
{ key: 'hero_image_title', label: 'متن روی کارت تصویر', type: 'text' },
|
||
{ key: 'hero_image_badge', label: 'بج روی کارت تصویر', type: 'text' },
|
||
{ key: 'hero_icon_badge', label: 'آیکون بج بنر', type: 'icon' },
|
||
{ key: 'hero_stat_founded', label: 'آمار قدمت کنینا (سال تأسیس)', type: 'text' },
|
||
{ key: 'hero_stat_agencies', label: 'آمار نمایندگیها', type: 'text' },
|
||
{ key: 'hero_stat_german_formula', label: 'آمار فرمولاسیون آلمانی', type: 'text' },
|
||
],
|
||
},
|
||
{
|
||
id: 'featured',
|
||
title: 'محصولات برگزیده (Featured Products)',
|
||
icon: ShoppingBag,
|
||
fields: [
|
||
{ key: 'featured_badge', label: 'بج بالای سکشن', type: 'text' },
|
||
{ key: 'featured_title_line1', label: 'عنوان سطر اول محصولات برگزیده (H2)', type: 'rich-text' },
|
||
{ key: 'featured_title_line2', label: 'عنوان سطر دوم محصولات برگزیده', type: 'rich-text' },
|
||
{ key: 'featured_desc', label: 'توضیحات معرفی مکملها', type: 'rich-text' },
|
||
{ key: 'featured_view_all', label: 'متن دکمه مشاهده همه محصولات', type: 'text' },
|
||
],
|
||
},
|
||
{
|
||
id: 'nav_cards',
|
||
title: 'کارتهای ناوبری سریع (Navigation Cards)',
|
||
icon: Layout,
|
||
fields: [
|
||
{ key: 'nav_card_shop_title', label: 'عنوان کارت فروشگاه', type: 'text' },
|
||
{ key: 'nav_card_shop_desc', label: 'توضیح کارت فروشگاه', type: 'text' },
|
||
{ key: 'nav_card_shop_cta', label: 'دکمه اقدام کارت فروشگاه', type: 'text' },
|
||
{ key: 'nav_card_wiki_title', label: 'عنوان کارت دانشنامه', type: 'text' },
|
||
{ key: 'nav_card_wiki_desc', label: 'توضیح کارت دانشنامه', type: 'text' },
|
||
{ key: 'nav_card_wiki_cta', label: 'دکمه اقدام کارت دانشنامه', type: 'text' },
|
||
],
|
||
},
|
||
{
|
||
id: 'videos',
|
||
title: 'ویدئوها و آموزشها (Videos Showcase)',
|
||
icon: Video,
|
||
fields: [
|
||
{ key: 'videos_badge', label: 'بج بالای بخش ویدئوها', type: 'text' },
|
||
{ key: 'videos_title', label: 'عنوان بخش ویدئوها (H2)', type: 'rich-text' },
|
||
{ key: 'videos_title_highlight', label: 'متن هایلایتشده عنوان ویدئو', type: 'text' },
|
||
{ key: 'videos_desc', label: 'توضیحات کوتاه بخش ویدئوها', type: 'rich-text' },
|
||
{ key: 'videos_view_all', label: 'متن دکمه مشاهده همه ویدئوها', type: 'text' },
|
||
],
|
||
},
|
||
{
|
||
id: 'about',
|
||
title: 'درباره اصالت و کیفیت (About & Standards)',
|
||
icon: Award,
|
||
fields: [
|
||
{ key: 'about_label', label: 'برچسب بالای بخش', type: 'text' },
|
||
{ key: 'about_title', label: 'عنوان اصلی درباره کنینا', type: 'rich-text' },
|
||
{ key: 'about_item1_title', label: 'عنوان ویژگی اول (استاندارد دارویی)', type: 'text' },
|
||
{ key: 'about_item1_desc', label: 'توضیحات ویژگی اول', type: 'rich-text' },
|
||
{ key: 'about_item2_title', label: 'عنوان ویژگی دوم (فرمولاسیون تخصصی)', type: 'text' },
|
||
{ key: 'about_item2_desc', label: 'توضیحات ویژگی دوم', type: 'rich-text' },
|
||
{ key: 'about_item3_title', label: 'عنوان ویژگی سوم (تاییدیه دامپزشکان)', type: 'text' },
|
||
{ key: 'about_item3_desc', label: 'توضیحات ویژگی سوم', type: 'rich-text' },
|
||
],
|
||
},
|
||
{
|
||
id: 'cta',
|
||
title: 'باکس دعوت به اقدام (Call To Action)',
|
||
icon: Sparkles,
|
||
fields: [
|
||
{ key: 'cta_title', label: 'عنوان اصلی کادر دعوت به خرید (H2)', type: 'rich-text' },
|
||
{ key: 'cta_desc', label: 'متن توضیحات و ترغیب', type: 'rich-text' },
|
||
{ key: 'cta_btn_primary', label: 'متن دکمه اصلی (خرید مکمل)', type: 'text' },
|
||
{ key: 'cta_btn_secondary', label: 'متن دکمه ثانویه (مشاوره دامپزشک)', type: 'text' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: 'shop',
|
||
title: 'فروشگاه و کاتالوگ (Shop & Catalog)',
|
||
path: '/shop',
|
||
icon: ShoppingBag,
|
||
sections: [
|
||
{
|
||
id: 'shop_header',
|
||
title: 'هدر و فیلترهای فروشگاه',
|
||
icon: Sliders,
|
||
fields: [
|
||
{ key: 'shop_page_title', label: 'عنوان صفحه فروشگاه (H1)', type: 'rich-text' },
|
||
{ key: 'shop_page_desc', label: 'توضیحات سئو بالای فروشگاه', type: 'rich-text' },
|
||
{ key: 'shop_filter_categories', label: 'عنوان فیلتر دستهبندیها', type: 'text' },
|
||
{ key: 'shop_filter_price', label: 'عنوان فیلتر محدوده قیمت', type: 'text' },
|
||
{ key: 'shop_search_placeholder', label: 'متن کادر جستجوی محصولات', type: 'text' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: 'advisor',
|
||
title: 'مشاور هوشمند سلامت پت (Smart Advisor)',
|
||
path: '/#canino-advisor',
|
||
icon: HelpCircle,
|
||
sections: [
|
||
{
|
||
id: 'advisor_general',
|
||
title: 'عناوین و مراحل دستیار هوشمند',
|
||
icon: HelpCircle,
|
||
fields: [
|
||
{ key: 'advisor_badge', label: 'بج بالای مشاور هوشمند', type: 'text' },
|
||
{ key: 'advisor_title', label: 'عنوان مشاور هوشمند (H2)', type: 'rich-text' },
|
||
{ key: 'advisor_subtitle', label: 'زیرعنوان و شعار تشخیصی', type: 'rich-text' },
|
||
{ key: 'advisor_step1_title', label: 'عنوان مرحله ۱ (مشخصات پت)', type: 'text' },
|
||
{ key: 'advisor_step1_desc', label: 'توضیحات مرحله ۱', type: 'text' },
|
||
{ key: 'advisor_pet_name_label', label: 'برچسب نام پت', type: 'text' },
|
||
{ key: 'advisor_pet_breed_label', label: 'برچسب نژاد پت', type: 'text' },
|
||
{ key: 'advisor_step2_title', label: 'عنوان مرحله ۲ (سن، وزن و تحرک)', type: 'text' },
|
||
{ key: 'advisor_step2_desc', label: 'توضیحات مرحله ۲', type: 'text' },
|
||
{ key: 'advisor_step3_title', label: 'عنوان مرحله ۳ (علائم و پیشنهاد)', type: 'text' },
|
||
{ key: 'advisor_step3_desc', label: 'توضیحات مرحله ۳', type: 'text' },
|
||
{ key: 'advisor_submit_btn', label: 'متن دکمه مشاهده راهکار پیشنهادی', type: 'text' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: 'wiki_blog',
|
||
title: 'دانشنامه و وبلاگ تخصصی (Wiki & Blog)',
|
||
path: '/wiki',
|
||
icon: BookOpen,
|
||
sections: [
|
||
{
|
||
id: 'wiki_general',
|
||
title: 'عناوین و صفحات دانشنامه',
|
||
icon: BookOpen,
|
||
fields: [
|
||
{ key: 'wiki_page_title', label: 'عنوان صفحه دانشنامه (H1)', type: 'rich-text' },
|
||
{ key: 'wiki_page_desc', label: 'توضیحات سئو دانشنامه دارویی', type: 'rich-text' },
|
||
{ key: 'blog_page_title', label: 'عنوان صفحه مقالات و وبلاگ', type: 'rich-text' },
|
||
{ key: 'blog_page_desc', label: 'توضیحات سئو وبلاگ', type: 'rich-text' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: 'contact_about',
|
||
title: 'درباره ما و تماس با ما (About & Contact)',
|
||
path: '/contact',
|
||
icon: PhoneCall,
|
||
sections: [
|
||
{
|
||
id: 'contact_info',
|
||
title: 'اطلاعات تماس و پیامها',
|
||
icon: PhoneCall,
|
||
fields: [
|
||
{ key: 'contact_page_title', label: 'عنوان صفحه تماس با ما', type: 'rich-text' },
|
||
{ key: 'contact_page_desc', label: 'توضیحات بالای فرم تماس', type: 'rich-text' },
|
||
{ key: 'about_full_history', label: 'تاریخچه کامل برند و شرکت (H2/P)', type: 'rich-text' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: 'b2b',
|
||
title: 'پرتال همکاران عمدهفروشی (B2B Portal)',
|
||
path: '/b2b',
|
||
icon: Building2,
|
||
sections: [
|
||
{
|
||
id: 'b2b_landing',
|
||
title: 'صفحه معرفی و شرایط همکاری B2B',
|
||
icon: Building2,
|
||
fields: [
|
||
{ key: 'b2b_hero_title', label: 'عنوان بنر همکاری B2B (H1)', type: 'rich-text' },
|
||
{ key: 'b2b_hero_desc', label: 'توضیحات و مزایای همکاری تجاری', type: 'rich-text' },
|
||
{ key: 'b2b_form_title', label: 'عنوان فرم درخواست نمایندگی', type: 'text' },
|
||
{ key: 'b2b_form_desc', label: 'راهنمای ارسال مدارک داروخانه/کلینیک', type: 'rich-text' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
{
|
||
id: 'pet_profiles',
|
||
title: 'پرونده سلامت همدم (Pet Profiles)',
|
||
path: '/profile/pets',
|
||
icon: Heart,
|
||
sections: [
|
||
{
|
||
id: 'pet_profiles_info',
|
||
title: 'عناوین پرونده سلامت و یادآورها',
|
||
icon: Heart,
|
||
fields: [
|
||
{ key: 'pet_profile_title', label: 'عنوان بخش پرونده سلامت پت', type: 'rich-text' },
|
||
{ key: 'pet_profile_desc', label: 'توضیحات پرونده و برنامه تغذیه', type: 'rich-text' },
|
||
],
|
||
},
|
||
],
|
||
},
|
||
];
|
||
|
||
const SHARED_HEADER_FIELDS: SectionField[] = [
|
||
{ key: 'shipping_notice', label: 'متن نوار متحرک بالای سایت (Ticker Announcement)', type: 'rich-text', description: 'نمایش در بالاترین نوار زرد تمام صفحات' },
|
||
{ key: 'brand_name_fa', label: 'نام فارسی برند در هدر', type: 'text' },
|
||
{ key: 'brand_subtitle', label: 'زیرعنوان نمایندگی در هدر', type: 'text' },
|
||
{ key: 'nav_solutions', label: 'عنوان منوی راهکارهای درمانی', type: 'text' },
|
||
{ key: 'nav_products', label: 'عنوان منوی کاتالوگ محصولات', type: 'text' },
|
||
{ key: 'nav_wiki', label: 'عنوان منوی دانشنامه', type: 'text' },
|
||
{ key: 'nav_blog', label: 'عنوان منوی وبلاگ', type: 'text' },
|
||
{ key: 'nav_pet_profiles', label: 'عنوان منوی پرونده سلامت', type: 'text' },
|
||
{ key: 'nav_search_placeholder', label: 'متن پیشفرض کادر جستجو', type: 'text' },
|
||
{ key: 'nav_cart', label: 'متن دکمه سبد خرید', type: 'text' },
|
||
{ key: 'header_logo_icon', label: 'آیکون نماد هدر', type: 'icon' },
|
||
];
|
||
|
||
const SHARED_FOOTER_FIELDS: SectionField[] = [
|
||
{ key: 'footer_brand_title', label: 'عنوان برند در فوتر', type: 'text' },
|
||
{ key: 'footer_brand_subtitle', label: 'زیرعنوان برند در فوتر', type: 'text' },
|
||
{ key: 'footer_brand_desc', label: 'متن معرفی شرکت در فوتر (سئو فوتر)', type: 'rich-text' },
|
||
{ key: 'footer_links_title', label: 'عنوان ستون دسترسی سریع', type: 'text' },
|
||
{ key: 'footer_contact_title', label: 'عنوان ستون راههای ارتباطی', type: 'text' },
|
||
{ key: 'footer_phone_label', label: 'برچسب تلفن پشتیبانی', type: 'text' },
|
||
{ key: 'footer_email_label', label: 'برچسب ایمیل', type: 'text' },
|
||
{ key: 'footer_address_label', label: 'برچسب آدرس دفتر', type: 'text' },
|
||
{ key: 'footer_copyright', label: 'متن حق نشر و کپیرایت انتهای فوتر', type: 'rich-text' },
|
||
{ key: 'footer_auth_badge', label: 'متن بج اصالت آلمان', type: 'text' },
|
||
];
|
||
|
||
export default function UITexts() {
|
||
const [versionTab, setVersionTab] = useState<'v2' | 'v1'>('v2');
|
||
const [v2SubTab, setV2SubTab] = useState<'header' | 'footer' | 'pages'>('pages');
|
||
const [openPageId, setOpenPageId] = useState<string>('home');
|
||
const [openSectionId, setOpenSectionId] = useState<string>('hero');
|
||
|
||
// State
|
||
const [texts, setTexts] = useState<Record<string, string>>({});
|
||
const [edits, setEdits] = useState<Record<string, string>>({});
|
||
const [search, setSearch] = useState('');
|
||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||
const [savedKey, setSavedKey] = useState<string | null>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [isSavingAll, setIsSavingAll] = useState(false);
|
||
|
||
// Modals
|
||
const [previewModalUrl, setPreviewModalUrl] = useState<string | null>(null);
|
||
const [mediaSelectorKey, setMediaSelectorKey] = useState<string | null>(null);
|
||
const [iconPickerKey, setIconPickerKey] = useState<string | null>(null);
|
||
|
||
// Legacy v1 State
|
||
const [v1ActiveTab, setV1ActiveTab] = useState('all');
|
||
|
||
useEffect(() => {
|
||
let isSubscribed = true;
|
||
api.get('/settings/ui-texts')
|
||
.then((res) => {
|
||
if (!isSubscribed) return;
|
||
const textsObj: Record<string, string> = {};
|
||
if (Array.isArray(res.data)) {
|
||
res.data.forEach((item: { key?: string; value?: string }) => {
|
||
if (item && item.key) {
|
||
textsObj[item.key] = typeof item.value === 'string' ? item.value : (item.value ? JSON.stringify(item.value) : '');
|
||
}
|
||
});
|
||
} else if (typeof res.data === 'object' && res.data !== null) {
|
||
Object.assign(textsObj, res.data);
|
||
}
|
||
setTexts(textsObj);
|
||
})
|
||
.catch((e) => {
|
||
console.error('Failed to load UI texts:', e);
|
||
})
|
||
.finally(() => {
|
||
if (isSubscribed) setLoading(false);
|
||
});
|
||
return () => {
|
||
isSubscribed = false;
|
||
};
|
||
}, []);
|
||
|
||
const handleSave = async (key: string) => {
|
||
const value = edits[key] ?? texts[key] ?? '';
|
||
setSavingKey(key);
|
||
try {
|
||
await api.put(`/settings/ui-texts/${encodeURIComponent(key)}`, { value });
|
||
setTexts((prev) => ({ ...prev, [key]: value }));
|
||
setSavedKey(key);
|
||
toast.success('محتوا با موفقیت ذخیره شد');
|
||
setTimeout(() => setSavedKey(null), 2000);
|
||
} catch {
|
||
try {
|
||
await api.patch(`/settings/ui-texts/${encodeURIComponent(key)}`, { value });
|
||
setTexts((prev) => ({ ...prev, [key]: value }));
|
||
setSavedKey(key);
|
||
toast.success('محتوا با موفقیت ذخیره شد');
|
||
setTimeout(() => setSavedKey(null), 2000);
|
||
} catch (err: any) {
|
||
const errorMsg = err?.response?.data?.message || 'خطا در ذخیرهسازی محتوا';
|
||
toast.error(errorMsg);
|
||
}
|
||
} finally {
|
||
setSavingKey(null);
|
||
}
|
||
};
|
||
|
||
const handleSaveAll = async () => {
|
||
const dirtyKeys = Object.keys(edits);
|
||
if (dirtyKeys.length === 0) {
|
||
toast.info('هیچ تغییری برای ذخیره وجود ندارد');
|
||
return;
|
||
}
|
||
setIsSavingAll(true);
|
||
try {
|
||
await api.put('/settings/ui-texts', edits);
|
||
setTexts((prev) => ({ ...prev, ...edits }));
|
||
setEdits({});
|
||
toast.success(`${dirtyKeys.length} تغییر با موفقیت ذخیره شد`);
|
||
} catch {
|
||
try {
|
||
const promises = dirtyKeys.map((k) =>
|
||
api.put(`/settings/ui-texts/${encodeURIComponent(k)}`, { value: edits[k] })
|
||
);
|
||
await Promise.all(promises);
|
||
setTexts((prev) => ({ ...prev, ...edits }));
|
||
setEdits({});
|
||
toast.success(`${dirtyKeys.length} تغییر با موفقیت ذخیره شد`);
|
||
} catch {
|
||
toast.error('خطا در ذخیرهسازی گروهی تغییرات');
|
||
}
|
||
} finally {
|
||
setIsSavingAll(false);
|
||
}
|
||
};
|
||
|
||
const isImageKey = (k: string) => k.includes('image') || k.includes('logo') || k.includes('img') || k.includes('avatar') || k.includes('url');
|
||
|
||
if (loading) {
|
||
return (
|
||
<div className="flex items-center justify-center min-h-[400px]">
|
||
<Spinner size="lg" className="text-purple-600" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const hasUnsavedChanges = Object.keys(edits).length > 0;
|
||
|
||
// Render a Single Field in Page Builder
|
||
const renderField = (field: SectionField) => {
|
||
const val = edits[field.key] ?? texts[field.key] ?? field.defaultVal ?? '';
|
||
const isSaved = savedKey === field.key;
|
||
const isSaving = savingKey === field.key;
|
||
|
||
return (
|
||
<div key={field.key} className="bg-gray-50/70 p-5 rounded-2xl border border-gray-200/80 space-y-3">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<span className="text-sm font-black text-gray-900 block">{field.label}</span>
|
||
{field.description && (
|
||
<span className="text-[11px] text-gray-500 font-medium block mt-0.5">{field.description}</span>
|
||
)}
|
||
<code className="text-[10px] text-purple-600 font-mono font-bold block mt-0.5" dir="ltr">
|
||
{field.key}
|
||
</code>
|
||
</div>
|
||
|
||
{isSaved && (
|
||
<span className="text-xs text-emerald-600 font-black flex items-center gap-1 bg-emerald-50 px-2.5 py-1 rounded-lg border border-emerald-200">
|
||
<Check className="w-3.5 h-3.5" /> ذخیره شد
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
{/* Input by Type */}
|
||
{field.type === 'rich-text' ? (
|
||
<RichTextEditor
|
||
value={val}
|
||
onChange={(html) => setEdits({ ...edits, [field.key]: html })}
|
||
placeholder="متن خود را وارد کنید..."
|
||
/>
|
||
) : field.type === 'image' ? (
|
||
<div className="space-y-3">
|
||
<div className="flex gap-2">
|
||
<input
|
||
type="text"
|
||
value={val}
|
||
onChange={(e) => setEdits({ ...edits, [field.key]: e.target.value })}
|
||
placeholder="https://... یا انتخاب از گالری"
|
||
className="flex-1 px-4 py-2.5 bg-white border border-gray-200 rounded-xl text-xs font-mono font-bold outline-none focus:ring-2 focus:ring-purple-500"
|
||
dir="ltr"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => setMediaSelectorKey(field.key)}
|
||
className="px-4 py-2.5 bg-purple-600 hover:bg-purple-700 text-white rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 shadow-sm cursor-pointer"
|
||
>
|
||
<Upload className="w-4 h-4" />
|
||
<span>گالری و آپلود</span>
|
||
</button>
|
||
</div>
|
||
|
||
{val && (
|
||
<div className="flex items-center gap-3 p-3 bg-white rounded-xl border border-gray-200">
|
||
<div className="w-16 h-16 rounded-lg bg-gray-50 border border-gray-100 overflow-hidden shrink-0 flex items-center justify-center p-1">
|
||
<img
|
||
src={val.startsWith('http') ? val : `${BASE_DOMAIN}${val}`}
|
||
alt="Preview"
|
||
className="max-w-full max-h-full object-contain"
|
||
/>
|
||
</div>
|
||
<div className="flex-1 truncate text-xs text-gray-600 font-mono" dir="ltr">
|
||
{val}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPreviewModalUrl(val.startsWith('http') ? val : `${BASE_DOMAIN}${val}`)}
|
||
className="p-2 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-lg transition-colors cursor-pointer"
|
||
title="مشاهده بزرگتر"
|
||
>
|
||
<Eye className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : field.type === 'icon' ? (
|
||
<div className="flex items-center gap-3 bg-white p-3 rounded-xl border border-gray-200">
|
||
<div className="w-12 h-12 rounded-xl bg-purple-50 text-purple-600 border border-purple-200 flex items-center justify-center font-black shrink-0">
|
||
{ICON_REGISTRY[val] ? (
|
||
React.createElement(ICON_REGISTRY[val], { className: 'w-6 h-6' })
|
||
) : (
|
||
<Sparkles className="w-6 h-6 text-gray-400" />
|
||
)}
|
||
</div>
|
||
|
||
<div className="flex-1">
|
||
<span className="text-xs font-bold text-gray-900 block">
|
||
{val ? `آیکون انتخابشده: ${val}` : 'هیچ آیکونی انتخاب نشده است'}
|
||
</span>
|
||
<span className="text-[11px] text-gray-400 font-mono block">از کتابخانه Lucide Icons</span>
|
||
</div>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setIconPickerKey(field.key)}
|
||
className="px-4 py-2 bg-purple-50 hover:bg-purple-100 text-purple-700 border border-purple-200 rounded-xl text-xs font-black transition-all cursor-pointer"
|
||
>
|
||
انتخاب آیکون
|
||
</button>
|
||
</div>
|
||
) : field.type === 'boolean' ? (
|
||
<div className="flex items-center justify-between p-3 bg-white rounded-xl border border-gray-200">
|
||
<span className="text-xs font-bold text-gray-700">
|
||
وضعیت: {val === 'true' ? <strong className="text-green-600">فعال (روشن)</strong> : <strong className="text-gray-400">غیرفعال (خاموش)</strong>}
|
||
</span>
|
||
<ToggleSwitch
|
||
checked={val === 'true'}
|
||
onChange={(c) => setEdits({ ...edits, [field.key]: String(c) })}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<input
|
||
type="text"
|
||
value={val}
|
||
onChange={(e) => setEdits({ ...edits, [field.key]: e.target.value })}
|
||
className="w-full px-4 py-2.5 bg-white border border-gray-200 rounded-xl text-xs font-bold text-gray-900 outline-none focus:ring-2 focus:ring-purple-500"
|
||
/>
|
||
)}
|
||
|
||
<div className="flex justify-end pt-1">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleSave(field.key)}
|
||
disabled={isSaving}
|
||
className="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-xl text-xs font-black transition-all flex items-center gap-1.5 shadow-sm cursor-pointer"
|
||
>
|
||
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||
<span>ذخیره این فیلد</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-6 font-vazir text-right" dir="rtl">
|
||
{/* Top Header */}
|
||
<div className="bg-white p-6 rounded-3xl border border-gray-200 shadow-xs flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||
<div>
|
||
<h1 className="text-2xl font-black text-gray-900 flex items-center gap-3">
|
||
<Languages className="w-7 h-7 text-purple-600" />
|
||
مدیریت محتوا و ویرایشگر صفحات (Page Builder v2)
|
||
</h1>
|
||
<p className="text-xs text-gray-500 font-bold mt-1">
|
||
ویرایشگر جامع و زنده متون سئو، تگهای عناوین، تصاویر بنرها و آیکونهای لوسید در تمام بخشها
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={handleSaveAll}
|
||
disabled={isSavingAll || !hasUnsavedChanges}
|
||
className={`px-5 py-2.5 rounded-xl text-xs font-black flex items-center gap-2 transition-all shadow-sm ${
|
||
hasUnsavedChanges
|
||
? 'bg-purple-600 hover:bg-purple-700 text-white shadow-purple-200 cursor-pointer animate-pulse'
|
||
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
{isSavingAll ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||
<span>ذخیره تمامی تغییرات {hasUnsavedChanges ? `(${Object.keys(edits).length})` : ''}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Main Version Switcher: v2 (New Structure) vs v1 (Legacy) */}
|
||
<div className="flex items-center justify-between bg-white p-2 rounded-2xl border border-gray-200 shadow-xs">
|
||
<div className="flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setVersionTab('v2')}
|
||
className={`px-5 py-2.5 rounded-xl text-xs font-black transition-all flex items-center gap-2 cursor-pointer ${
|
||
versionTab === 'v2'
|
||
? 'bg-gradient-to-r from-purple-600 to-indigo-600 text-white shadow-md shadow-purple-200'
|
||
: 'text-gray-600 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
<Sparkles className="w-4 h-4 text-amber-300" />
|
||
<span>ویرایشگر پیشرفته صفحات (Page Builder v2)</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setVersionTab('v1')}
|
||
className={`px-4 py-2.5 rounded-xl text-xs font-bold transition-all flex items-center gap-2 cursor-pointer ${
|
||
versionTab === 'v1'
|
||
? 'bg-gray-800 text-white shadow-sm'
|
||
: 'text-gray-500 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
<span>نمای کلاسیک (v1 Legacy)</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div className="text-[11px] text-gray-400 font-bold px-3">
|
||
{versionTab === 'v2' ? 'نسخه مدرن آکاردئونی تفکیکشده' : 'نسخه پشتیبان فهرستی'}
|
||
</div>
|
||
</div>
|
||
|
||
{versionTab === 'v2' ? (
|
||
<div className="space-y-6">
|
||
{/* Sub Tabs: Header, Footer, Site Pages */}
|
||
<div className="flex items-center gap-2 bg-white p-2 rounded-2xl border border-gray-200 shadow-xs overflow-x-auto no-scrollbar">
|
||
<button
|
||
type="button"
|
||
onClick={() => setV2SubTab('pages')}
|
||
className={`px-6 py-3 rounded-xl text-xs font-black transition-all flex items-center gap-2 cursor-pointer shrink-0 ${
|
||
v2SubTab === 'pages'
|
||
? 'bg-purple-600 text-white shadow-md shadow-purple-200'
|
||
: 'text-gray-600 hover:bg-gray-50 hover:text-purple-700'
|
||
}`}
|
||
>
|
||
<Layout className="w-4 h-4" />
|
||
<span>صفحات سایت (Site Pages Accordion)</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setV2SubTab('header')}
|
||
className={`px-6 py-3 rounded-xl text-xs font-black transition-all flex items-center gap-2 cursor-pointer shrink-0 ${
|
||
v2SubTab === 'header'
|
||
? 'bg-purple-600 text-white shadow-md shadow-purple-200'
|
||
: 'text-gray-600 hover:bg-gray-50 hover:text-purple-700'
|
||
}`}
|
||
>
|
||
<LinkIcon className="w-4 h-4" />
|
||
<span>هدر و ناوبری مشترک (Header)</span>
|
||
</button>
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => setV2SubTab('footer')}
|
||
className={`px-6 py-3 rounded-xl text-xs font-black transition-all flex items-center gap-2 cursor-pointer shrink-0 ${
|
||
v2SubTab === 'footer'
|
||
? 'bg-purple-600 text-white shadow-md shadow-purple-200'
|
||
: 'text-gray-600 hover:bg-gray-50 hover:text-purple-700'
|
||
}`}
|
||
>
|
||
<Languages className="w-4 h-4" />
|
||
<span>فوتر و کپیرایت مشترک (Footer)</span>
|
||
</button>
|
||
</div>
|
||
|
||
{/* Sub Tab: Shared Header */}
|
||
{v2SubTab === 'header' && (
|
||
<div className="bg-white p-6 sm:p-8 rounded-3xl border border-gray-200 shadow-sm space-y-6">
|
||
<div className="flex items-center justify-between border-b border-gray-100 pb-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-2xl bg-purple-50 text-purple-600 flex items-center justify-center font-black">
|
||
<LinkIcon className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-base font-black text-gray-900">تنظیمات هدر، اعلان بالا و منوها</h3>
|
||
<p className="text-xs text-gray-500 font-medium">محتوای مشترک بالای تمام صفحات فروشگاه</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-6 md:grid-cols-2">
|
||
{SHARED_HEADER_FIELDS.map((f) => renderField(f))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Sub Tab: Shared Footer */}
|
||
{v2SubTab === 'footer' && (
|
||
<div className="bg-white p-6 sm:p-8 rounded-3xl border border-gray-200 shadow-sm space-y-6">
|
||
<div className="flex items-center justify-between border-b border-gray-100 pb-4">
|
||
<div className="flex items-center gap-3">
|
||
<div className="w-10 h-10 rounded-2xl bg-purple-50 text-purple-600 flex items-center justify-center font-black">
|
||
<Languages className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-base font-black text-gray-900">تنظیمات فوتر، کپیرایت و ستونهای پیوند</h3>
|
||
<p className="text-xs text-gray-500 font-medium">محتوای مشترک انتهای تمام صفحات سایت</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid gap-6 md:grid-cols-2">
|
||
{SHARED_FOOTER_FIELDS.map((f) => renderField(f))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Sub Tab: Site Pages Accordion */}
|
||
{v2SubTab === 'pages' && (
|
||
<div className="space-y-4">
|
||
{SITE_PAGES.map((page) => {
|
||
const PageIcon = page.icon;
|
||
const isOpen = openPageId === page.id;
|
||
const activeSection = page.sections.find((s) => s.id === openSectionId) || page.sections[0];
|
||
|
||
return (
|
||
<div
|
||
key={page.id}
|
||
className="bg-white rounded-3xl border border-gray-200 shadow-xs overflow-hidden transition-all"
|
||
>
|
||
{/* Collapsible Page Header */}
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpenPageId(isOpen ? '' : page.id)}
|
||
className={`w-full p-5 sm:p-6 flex items-center justify-between transition-colors cursor-pointer text-right ${
|
||
isOpen ? 'bg-purple-50/70 border-b border-purple-100' : 'hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
<div className="flex items-center gap-3.5">
|
||
<div
|
||
className={`w-10 h-10 rounded-2xl flex items-center justify-center transition-all ${
|
||
isOpen
|
||
? 'bg-purple-600 text-white shadow-md shadow-purple-200'
|
||
: 'bg-gray-100 text-gray-600'
|
||
}`}
|
||
>
|
||
<PageIcon className="w-5 h-5" />
|
||
</div>
|
||
<div>
|
||
<h3 className="text-base font-black text-gray-900">{page.title}</h3>
|
||
<span className="text-xs text-purple-600 font-mono font-bold" dir="ltr">
|
||
{page.path}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-xs font-bold px-3 py-1 bg-white rounded-xl border border-gray-200 text-gray-600">
|
||
{page.sections.length} بخش
|
||
</span>
|
||
<ChevronDown
|
||
className={`w-5 h-5 text-gray-400 transition-transform duration-200 ${
|
||
isOpen ? 'rotate-180 text-purple-600' : ''
|
||
}`}
|
||
/>
|
||
</div>
|
||
</button>
|
||
|
||
{/* Page Content & Section Tabs */}
|
||
{isOpen && (
|
||
<div className="p-6 sm:p-8 space-y-6">
|
||
{/* Section Tabs */}
|
||
{page.sections.length > 1 && (
|
||
<div className="flex items-center gap-2 overflow-x-auto pb-2 border-b border-gray-100 no-scrollbar">
|
||
{page.sections.map((sec) => {
|
||
const SecIcon = sec.icon;
|
||
const isSecActive = (openSectionId || page.sections[0].id) === sec.id;
|
||
|
||
return (
|
||
<button
|
||
key={sec.id}
|
||
type="button"
|
||
onClick={() => setOpenSectionId(sec.id)}
|
||
className={`px-4 py-2 rounded-xl text-xs font-black transition-all flex items-center gap-2 cursor-pointer shrink-0 border ${
|
||
isSecActive
|
||
? 'bg-purple-600 text-white border-purple-600 shadow-sm'
|
||
: 'bg-gray-50 text-gray-700 border-gray-200 hover:bg-gray-100'
|
||
}`}
|
||
>
|
||
<SecIcon className="w-3.5 h-3.5" />
|
||
<span>{sec.title}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Active Section Fields Grid */}
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-2 pb-2">
|
||
<span className="w-2 h-2 rounded-full bg-purple-600" />
|
||
<h4 className="text-sm font-black text-gray-800">
|
||
فیلدهای محتوایی: {activeSection.title}
|
||
</h4>
|
||
</div>
|
||
|
||
<div className="grid gap-6 md:grid-cols-2">
|
||
{activeSection.fields.map((f) => renderField(f))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : (
|
||
/* Legacy v1 Interface */
|
||
<div className="space-y-6">
|
||
{/* Search */}
|
||
<div className="bg-white p-4 rounded-2xl border border-gray-200 shadow-xs flex items-center gap-3">
|
||
<Search className="w-5 h-5 text-gray-400 shrink-0" />
|
||
<input
|
||
type="text"
|
||
placeholder="جستجو در متون نسخه ۱..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
className="w-full bg-transparent border-none text-xs focus:outline-none font-bold"
|
||
/>
|
||
</div>
|
||
|
||
{/* Section Tabs v1 */}
|
||
<div className="flex items-center gap-2 overflow-x-auto pb-2 no-scrollbar">
|
||
{V1_PAGE_TABS.map((tab) => {
|
||
const Icon = tab.icon;
|
||
const isActive = v1ActiveTab === tab.id;
|
||
return (
|
||
<button
|
||
key={tab.id}
|
||
onClick={() => setV1ActiveTab(tab.id)}
|
||
className={`px-4 py-2.5 rounded-xl text-xs font-black transition-all shrink-0 flex items-center gap-2 border cursor-pointer ${
|
||
isActive
|
||
? 'bg-purple-600 text-white border-purple-600 shadow-md'
|
||
: 'bg-white text-gray-700 border-gray-200 hover:bg-gray-50'
|
||
}`}
|
||
>
|
||
<Icon className="w-4 h-4" />
|
||
<span>{tab.label}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* Content Groups v1 */}
|
||
<div className="space-y-6">
|
||
{Object.entries(V1_GROUPS).map(([groupTitle, keys]) => {
|
||
const mappedTab = V1_GROUP_PAGE_MAP[groupTitle] || 'other';
|
||
if (v1ActiveTab !== 'all' && mappedTab !== v1ActiveTab) return null;
|
||
|
||
const filteredKeys = keys.filter((key) => {
|
||
if (!search) return true;
|
||
const currentVal = edits[key] ?? texts[key] ?? '';
|
||
return key.toLowerCase().includes(search.toLowerCase()) || currentVal.includes(search);
|
||
});
|
||
|
||
if (filteredKeys.length === 0) return null;
|
||
|
||
return (
|
||
<div
|
||
key={groupTitle}
|
||
className="bg-white rounded-3xl border border-gray-200 p-6 space-y-4 shadow-xs"
|
||
>
|
||
<h3 className="text-base font-black text-gray-900 border-b border-gray-100 pb-3 flex items-center gap-2">
|
||
<span className="w-2.5 h-2.5 rounded-full bg-purple-600 inline-block" />
|
||
{groupTitle}
|
||
</h3>
|
||
|
||
<div className="grid gap-4 md:grid-cols-2">
|
||
{filteredKeys.map((key) => {
|
||
const val = edits[key] ?? texts[key] ?? '';
|
||
const isImg = isImageKey(key);
|
||
|
||
return (
|
||
<div key={key} className="space-y-2 p-4 bg-gray-50/70 rounded-2xl border border-gray-100">
|
||
<label className="text-[11px] font-mono text-purple-700 font-bold block" dir="ltr">
|
||
{key}
|
||
</label>
|
||
|
||
<div className="flex items-center gap-2">
|
||
{val.length > 60 && !isImg ? (
|
||
<textarea
|
||
value={val}
|
||
onChange={(e) => setEdits({ ...edits, [key]: e.target.value })}
|
||
className="w-full bg-white border border-gray-200 rounded-xl p-3 text-xs font-bold text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-500/20 h-20"
|
||
/>
|
||
) : (
|
||
<input
|
||
type="text"
|
||
value={val}
|
||
onChange={(e) => setEdits({ ...edits, [key]: e.target.value })}
|
||
className="w-full bg-white border border-gray-200 rounded-xl px-3 py-2 text-xs font-bold text-gray-900 focus:outline-none focus:ring-2 focus:ring-purple-500/20"
|
||
/>
|
||
)}
|
||
|
||
<button
|
||
type="button"
|
||
onClick={() => handleSave(key)}
|
||
className="px-3.5 py-2 bg-purple-600 text-white hover:bg-purple-700 rounded-xl text-xs font-black transition-all shrink-0 shadow-xs cursor-pointer"
|
||
>
|
||
ذخیره
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Media Selector Modal */}
|
||
{mediaSelectorKey && (
|
||
<MediaSelector
|
||
isOpen={!!mediaSelectorKey}
|
||
onClose={() => setMediaSelectorKey(null)}
|
||
onSelect={(url) => {
|
||
setEdits({ ...edits, [mediaSelectorKey]: url });
|
||
setMediaSelectorKey(null);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Icon Picker Modal */}
|
||
{iconPickerKey && (
|
||
<IconPickerModal
|
||
isOpen={!!iconPickerKey}
|
||
onClose={() => setIconPickerKey(null)}
|
||
currentIcon={edits[iconPickerKey] ?? texts[iconPickerKey]}
|
||
onSelect={(iconName) => {
|
||
setEdits({ ...edits, [iconPickerKey]: iconName });
|
||
setIconPickerKey(null);
|
||
}}
|
||
/>
|
||
)}
|
||
|
||
{/* Image Preview Modal */}
|
||
<ImagePreviewModal
|
||
isOpen={!!previewModalUrl}
|
||
imageUrl={previewModalUrl || ''}
|
||
onClose={() => setPreviewModalUrl(null)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|