diff --git a/apps/admin/src/components/ContentLanguageTabs.tsx b/apps/admin/src/components/ContentLanguageTabs.tsx index 782acd4..1b68e54 100644 --- a/apps/admin/src/components/ContentLanguageTabs.tsx +++ b/apps/admin/src/components/ContentLanguageTabs.tsx @@ -12,6 +12,10 @@ export const DEFAULT_CONTENT_LANGUAGES: LanguageOption[] = [ { code: 'en', label: 'EN', nativeName: 'English', flag: '🇎🇧' }, { code: 'ja', label: 'JA', nativeName: 'æ—ĨæœŽčŠž', flag: 'ðŸ‡ŊðŸ‡ĩ' }, { code: 'zh', label: 'ZH', nativeName: 'įŪ€ä―“äļ­æ–‡', flag: 'ðŸ‡ĻðŸ‡ģ' }, + { code: 'vi', label: 'VI', nativeName: 'Tiášŋng Viáŧ‡t', flag: 'ðŸ‡ŧðŸ‡ģ' }, + { code: 'ko', label: 'KO', nativeName: '한ęĩ­ė–ī', flag: '🇰🇷' }, + { code: 'lo', label: 'LO', nativeName: 'āšžāšēāšŠāšēāšĨāšēāš§', flag: 'ðŸ‡ąðŸ‡Ķ' }, + { code: 'it', label: 'IT', nativeName: 'Italiano', flag: 'ðŸ‡ŪðŸ‡đ' }, ] interface ContentLanguageTabsProps { diff --git a/apps/admin/src/components/page-builder/PageSeoModal.tsx b/apps/admin/src/components/page-builder/PageSeoModal.tsx new file mode 100644 index 0000000..12068f8 --- /dev/null +++ b/apps/admin/src/components/page-builder/PageSeoModal.tsx @@ -0,0 +1,539 @@ +import { useState } from 'react'; +import { + X, + Globe, + Search, + Share2, + Sparkles, + Image as ImageIcon, + UploadCloud, + CheckCircle2, + Loader2, + Save, + Tag +} from 'lucide-react'; +import { type Page, type PageTranslation } from '@homaroma/types'; + +interface PageSeoModalProps { + isOpen: boolean; + onClose: () => void; + page: Page | null; + onUpdatePage: (updatedPage: Page) => void; + onSave: () => Promise; + isSaving: boolean; + onOpenMediaLibrary: () => void; + frontendBaseUrl?: string; +} + +const SUPPORTED_LOCALES = [ + { code: 'th', label: 'TH', full: 'āļ āļēāļĐāļēāđ„āļ—āļĒ (Default)' }, + { code: 'en', label: 'EN', full: 'English' }, + { code: 'ja', label: 'JA', full: 'æ—ĨæœŽčŠž' }, + { code: 'zh', label: 'ZH', full: 'äļ­æ–‡' }, +]; + +export default function PageSeoModal({ + isOpen, + onClose, + page, + onUpdatePage, + onSave, + isSaving, + onOpenMediaLibrary, + frontendBaseUrl = 'https://homaroma.co.th', +}: PageSeoModalProps) { + const [activeLang, setActiveLang] = useState('th'); + const [activeTab, setActiveTab] = useState<'editor' | 'preview'>('editor'); + + if (!isOpen || !page) return null; + + // Read current SEO value for specific language + const getSeoValue = (field: 'seoTitle' | 'seoDescription' | 'seoKeywords'): string => { + if (activeLang === 'th') { + const thTrans = page.translations?.find((t) => t.langCode === 'th'); + return (thTrans?.[field] ?? page[field] ?? '') || ''; + } + const trans = page.translations?.find((t) => t.langCode === activeLang); + return trans?.[field] ?? ''; + }; + + // Update SEO value for specific language + const handleUpdateField = ( + field: 'seoTitle' | 'seoDescription' | 'seoKeywords', + value: string + ) => { + const currentTranslations = [...(page.translations || [])]; + const transIndex = currentTranslations.findIndex((t) => t.langCode === activeLang); + + if (transIndex >= 0) { + currentTranslations[transIndex] = { + ...currentTranslations[transIndex], + [field]: value || null, + }; + } else { + currentTranslations.push({ + id: 0, + pageId: page.id, + langCode: activeLang, + title: page.title || '', + [field]: value || null, + } as PageTranslation); + } + + const updatedPage: Page = { + ...page, + translations: currentTranslations, + }; + + if (activeLang === 'th') { + updatedPage[field] = value || null; + } + + onUpdatePage(updatedPage); + }; + + const handleUpdateOgImage = (url: string) => { + onUpdatePage({ + ...page, + ogImageUrl: url || null, + }); + }; + + const currentTitle = getSeoValue('seoTitle'); + const currentDesc = getSeoValue('seoDescription'); + const currentKeywords = getSeoValue('seoKeywords'); + const currentOgImage = page.ogImageUrl || ''; + + const pageSlug = page.slug === 'home' ? '' : page.slug; + const canonicalUrl = `${frontendBaseUrl}/${pageSlug}`; + + // Title character count status (~50-60 chars) + const titleLen = currentTitle.length; + const getTitleStatus = () => { + if (titleLen === 0) return { color: 'text-slate-400', badge: 'bg-slate-800 text-slate-400', msg: 'Recommended: 50-60 chars' }; + if (titleLen < 30) return { color: 'text-amber-400', badge: 'bg-amber-500/10 text-amber-400 border border-amber-500/20', msg: 'Too short (optimal: 50-60 chars)' }; + if (titleLen <= 60) return { color: 'text-emerald-400', badge: 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20', msg: 'Ideal length' }; + return { color: 'text-amber-400', badge: 'bg-amber-500/10 text-amber-400 border border-amber-500/20', msg: 'May be truncated (>60 chars)' }; + }; + + // Description character count status (~150-160 chars) + const descLen = currentDesc.length; + const getDescStatus = () => { + if (descLen === 0) return { color: 'text-slate-400', badge: 'bg-slate-800 text-slate-400', msg: 'Recommended: 150-160 chars' }; + if (descLen < 100) return { color: 'text-amber-400', badge: 'bg-amber-500/10 text-amber-400 border border-amber-500/20', msg: 'Short snippet (optimal: 150-160 chars)' }; + if (descLen <= 160) return { color: 'text-emerald-400', badge: 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20', msg: 'Ideal length' }; + return { color: 'text-rose-400', badge: 'bg-rose-500/10 text-rose-400 border border-rose-500/20', msg: 'Too long, will truncate (>160 chars)' }; + }; + + const titleStatus = getTitleStatus(); + const descStatus = getDescStatus(); + + // Preview fallbacks + const previewDisplayTitle = currentTitle || page.title || 'HomAroma'; + const previewDisplayDesc = currentDesc || 'Discover handcrafted pure essential oils, reed diffusers, and natural home fragrances by HomAroma.'; + + return ( +
+
+ {/* Header */} +
+
+
+ +
+
+
+

+ SEO & Social Meta Settings +

+ + /{page.slug} + +
+

+ Configure search engine tags, metadata, and social preview cards for "{page.title}" +

+
+
+ +
+ + {/* Locale & View Tabs Subheader */} +
+ {/* Multi-language Selector */} +
+ + Language: + +
+ {SUPPORTED_LOCALES.map((locale) => { + const trans = page.translations?.find((t) => t.langCode === locale.code); + const hasCustomSeo = locale.code === 'th' + ? Boolean(page.seoTitle || page.seoDescription) + : Boolean(trans?.seoTitle || trans?.seoDescription); + + return ( + + ); + })} +
+
+ + {/* Mode Tabs (Editor vs SERP Preview) */} +
+ + +
+
+ + {/* Modal Body */} +
+ {activeTab === 'editor' ? ( +
+ {/* Language Notice banner */} +
+ +
+

+ Editing SEO for locale:{' '} + + {SUPPORTED_LOCALES.find((l) => l.code === activeLang)?.full} + +

+

+ Search engines will index these localized meta tags when visitors access the page in this language. +

+
+
+ + {/* 1. SEO Title */} +
+
+ +
+ + {titleLen} / 60 chars â€Ē {titleStatus.msg} + +
+
+ handleUpdateField('seoTitle', e.target.value)} + placeholder={`${page.title} | HomAroma Aromatherapy & Essential Oils`} + className="admin-input w-full font-medium" + /> +
+
+
+

+ The main title displayed on Google search results and browser tabs. Recommended ~50-60 characters. +

+
+ + {/* 2. SEO Description */} +
+
+ +
+ + {descLen} / 160 chars â€Ē {descStatus.msg} + +
+
+