This commit is contained in:
Your Name committed 2026-09-29 13:13:47 +07:00
1 parent 44a613f8ec
commit e87176dba3
2 files changed
+574 -2

No files matched your search

@@ -0,0 +1,543 @@
import React, { useState, useMemo, useRef } from 'react';
import {
X,
Copy,
Check,
Download,
Upload,
FileJson,
AlertCircle,
CheckCircle2,
FileText,
ArrowRight,
Bot
} from 'lucide-react';
import type { StoreSettingsTranslation } from '@homaroma/types';
interface SeoBulkJsonModalProps {
isOpen: boolean;
onClose: () => void;
defaultSeoTitle: string;
defaultSeoDescription: string;
translations: StoreSettingsTranslation[];
supportedLocales: Array<{ code: string; label: string; full: string }>;
onApply: (data: { thTitle: string; thDesc: string; translations: StoreSettingsTranslation[] }) => void;
}
export default function SeoBulkJsonModal({
isOpen,
onClose,
defaultSeoTitle,
defaultSeoDescription,
translations,
supportedLocales,
onApply,
}: SeoBulkJsonModalProps) {
const [activeTab, setActiveTab] = useState<'export' | 'import'>('import');
const [copiedJson, setCopiedJson] = useState(false);
const [copiedPrompt, setCopiedPrompt] = useState(false);
const [importJsonText, setImportJsonText] = useState('');
const [parseError, setParseError] = useState<string | null>(null);
const [applySuccess, setApplySuccess] = useState(false);
const fileInputRef = useRef<HTMLInputElement | null>(null);
// 1. Build Export Data
const exportData = useMemo(() => {
const out: Record<string, { title: string; description: string }> = {};
for (const locale of supportedLocales) {
if (locale.code === 'th') {
const thTrans = translations.find((t) => t.langCode === 'th');
out['th'] = {
title: thTrans?.defaultSeoTitle ?? defaultSeoTitle ?? '',
description: thTrans?.defaultSeoDescription ?? defaultSeoDescription ?? '',
};
} else {
const trans = translations.find((t) => t.langCode === locale.code);
out[locale.code] = {
title: trans?.defaultSeoTitle ?? '',
description: trans?.defaultSeoDescription ?? '',
};
}
}
return out;
}, [defaultSeoTitle, defaultSeoDescription, translations, supportedLocales]);
const exportJsonString = useMemo(() => {
return JSON.stringify(exportData, null, 2);
}, [exportData]);
// AI Prompt Helper to hand off to ChatGPT/Claude
const aiTranslatePrompt = useMemo(() => {
const currentTh = exportData['th'] || { title: defaultSeoTitle, description: defaultSeoDescription };
return `Please translate and optimize the following SEO Title Template and Meta Description for our luxury aromatherapy & lifestyle brand into all supported languages (th, en, ja, zh, vi, ko, lo, it).
Return ONLY valid JSON matching this exact schema:
\`\`\`json
{
"th": {
"title": "${currentTh.title || 'HomAroma | Pure Essential Oils & Botanical Aromatherapy'}",
"description": "${currentTh.description || 'Hand-blended botanical fragrances and quiet luxury essential oils.'}"
},
"en": {
"title": "HomAroma | Pure Essential Oils & Botanical Aromatherapy",
"description": "Hand-blended botanical fragrances and quiet luxury essential oils crafted for serene modern living."
},
"ja": {
"title": "HomAroma | ピュアエッセンシャルオイル&アロマセラピー",
"description": "心身を癒す天然抽出のエッセンシャルオイルとボタニカルフレグランス。"
},
"zh": {
"title": "HomAroma | 纯精油与植物芳香疗法",
"description": "精选天然植物萃取精油与高品质香氛,为现代生活打造宁静身心庇护所。"
},
"vi": {
"title": "HomAroma | Tinh dầu nguyên chất & Liệu pháp hương thơm",
"description": "Tinh dầu thiên nhiên và hương thơm cao cấp mang lại sự thư giãn cho không gian sống."
},
"ko": {
"title": "HomAroma | 프리미엄 퓨어 에센셜 오일 & 아로마테라피",
"description": "자연에서 추출한 순수 에센셜 오일과 보태니컬 향기로 완성하는 힐링 라이프스타일."
},
"lo": {
"title": "HomAroma | ນ້ຳມັນຫອມລະເຫີຍບໍລິສຸດ & ອາໂຣມາເທຣາພີ",
"description": "ສຳຜັດຄວາມຜ່ອນຄາຍກັບນ້ຳມັນຫອມລະເຫີຍທຳມະຊາດ ແລະ ກິ່ນຫອມລະດັບພຣີມຽມ."
},
"it": {
"title": "HomAroma | Oli Essenziali Puri e Aromaterapia Botanica",
"description": "Fragranze botaniche artigianali e oli essenziali di lusso per uno stile di vita sereno."
}
}
\`\`\``;
}, [exportData, defaultSeoTitle, defaultSeoDescription]);
// 2. Parse Import Data
const parsedImportData = useMemo(() => {
if (!importJsonText.trim()) {
return null;
}
try {
const raw = JSON.parse(importJsonText);
const normalized: Record<string, { title: string; description: string }> = {};
if (Array.isArray(raw)) {
for (const item of raw) {
const lang = item.langCode || item.lang || item.code;
if (lang && typeof lang === 'string') {
normalized[lang.toLowerCase()] = {
title: item.title || item.defaultSeoTitle || '',
description: item.description || item.defaultSeoDescription || '',
};
}
}
} else if (typeof raw === 'object' && raw !== null) {
// Check if nested inside "locales" or "translations" or directly keyed by lang code
const targetObj = raw.locales || raw.translations || raw;
for (const [key, val] of Object.entries(targetObj)) {
if (val && typeof val === 'object') {
const v = val as any;
normalized[key.toLowerCase()] = {
title: v.title ?? v.defaultSeoTitle ?? '',
description: v.description ?? v.defaultSeoDescription ?? '',
};
}
}
}
const detectedLangs = Object.keys(normalized);
if (detectedLangs.length === 0) {
setParseError('No recognized language keys (e.g. "th", "en", "ja") found in JSON object.');
return null;
}
setParseError(null);
return normalized;
} catch (e: any) {
setParseError(`Invalid JSON Syntax: ${e.message}`);
return null;
}
}, [importJsonText]);
if (!isOpen) return null;
const handleCopyJson = async () => {
try {
await navigator.clipboard.writeText(exportJsonString);
setCopiedJson(true);
setTimeout(() => setCopiedJson(false), 2000);
} catch {
// ignore
}
};
const handleCopyPrompt = async () => {
try {
await navigator.clipboard.writeText(aiTranslatePrompt);
setCopiedPrompt(true);
setTimeout(() => setCopiedPrompt(false), 2000);
} catch {
// ignore
}
};
const handleDownloadJson = () => {
const blob = new Blob([exportJsonString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `homaroma_seo_defaults_${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
const content = event.target?.result as string;
if (content) {
setImportJsonText(content);
}
};
reader.readAsText(file);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleLoadSample = () => {
setImportJsonText(exportJsonString);
};
const handleApply = () => {
if (!parsedImportData) return;
let newThTitle = defaultSeoTitle;
let newThDesc = defaultSeoDescription;
const nextTranslations = [...translations];
for (const [lang, val] of Object.entries(parsedImportData)) {
if (lang === 'th') {
newThTitle = val.title;
newThDesc = val.description;
}
const idx = nextTranslations.findIndex((t) => t.langCode.toLowerCase() === lang);
if (idx >= 0) {
nextTranslations[idx] = {
...nextTranslations[idx],
defaultSeoTitle: val.title || null,
defaultSeoDescription: val.description || null,
};
} else {
nextTranslations.push({
id: 0,
storeSettingsId: 0,
langCode: lang,
defaultSeoTitle: val.title || null,
defaultSeoDescription: val.description || null,
} as StoreSettingsTranslation);
}
}
onApply({
thTitle: newThTitle,
thDesc: newThDesc,
translations: nextTranslations,
});
setApplySuccess(true);
setTimeout(() => {
setApplySuccess(false);
onClose();
}, 1200);
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-night-950/80 backdrop-blur-md animate-fade-in">
<div className="relative w-full max-w-4xl bg-night-900 border border-white/10 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
{/* Modal Header */}
<div className="p-5 sm:p-6 border-b border-white/10 flex items-center justify-between bg-night-950/60">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-cyan-glow/10 border border-cyan-glow/20 rounded-xl text-cyan-glow">
<FileJson className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-white flex items-center gap-2">
<span>Multi-Language SEO Defaults JSON</span>
<span className="text-[10px] uppercase font-bold tracking-widest px-2 py-0.5 rounded-full bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20">
Bulk All Languages
</span>
</h2>
<p className="text-xs text-slate-400">
Import & Export Default SEO Title and Meta Description across all 8 languages in one click.
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-white/10 transition-all cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Tab Navigation */}
<div className="flex border-b border-white/10 bg-night-950/40 px-6 pt-3 gap-2">
<button
onClick={() => setActiveTab('import')}
className={`pb-3 px-4 text-xs font-bold uppercase tracking-wider flex items-center gap-2 border-b-2 transition-all cursor-pointer ${
activeTab === 'import'
? 'border-cyan-glow text-cyan-glow'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Upload className="w-4 h-4" />
<span>📥 Import JSON (ทุกภาษา)</span>
</button>
<button
onClick={() => setActiveTab('export')}
className={`pb-3 px-4 text-xs font-bold uppercase tracking-wider flex items-center gap-2 border-b-2 transition-all cursor-pointer ${
activeTab === 'export'
? 'border-cyan-glow text-cyan-glow'
: 'border-transparent text-slate-400 hover:text-slate-200'
}`}
>
<Download className="w-4 h-4" />
<span>📤 Export JSON</span>
</button>
</div>
{/* Tab Content Body */}
<div className="p-6 overflow-y-auto flex-1 space-y-5">
{/* IMPORT TAB */}
{activeTab === 'import' && (
<div className="space-y-5">
{/* Actions & Helpers */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<input
type="file"
ref={fileInputRef}
accept=".json,application/json"
onChange={handleFileUpload}
className="hidden"
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="px-3.5 py-1.5 bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
>
<Upload className="w-3.5 h-3.5 text-cyan-glow" />
<span>Upload .json File</span>
</button>
<button
type="button"
onClick={handleLoadSample}
className="px-3.5 py-1.5 bg-white/5 hover:bg-white/10 text-slate-300 border border-white/10 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
>
<FileText className="w-3.5 h-3.5 text-purple-400" />
<span>Load Current / Sample JSON</span>
</button>
</div>
<div className="text-xs text-slate-400 flex items-center gap-1">
<span>รองรับคีย์:</span>
<code className="px-1.5 py-0.5 rounded bg-black/40 text-cyan-glow font-mono text-[11px]">th</code>
<code className="px-1.5 py-0.5 rounded bg-black/40 text-cyan-glow font-mono text-[11px]">en</code>
<code className="px-1.5 py-0.5 rounded bg-black/40 text-cyan-glow font-mono text-[11px]">ja</code>
<code className="px-1.5 py-0.5 rounded bg-black/40 text-cyan-glow font-mono text-[11px]">zh</code>
<span>... (title & description)</span>
</div>
</div>
{/* JSON Editor Textarea */}
<div className="relative">
<textarea
value={importJsonText}
onChange={(e) => setImportJsonText(e.target.value)}
rows={10}
className="admin-input w-full font-mono text-xs leading-relaxed p-4 bg-night-950 border-white/10 focus:border-cyan-glow/50 rounded-xl resize-y text-slate-200 placeholder:text-slate-600 outline-none transition-all shadow-inner"
placeholder={`Paste JSON here, e.g.:
{
"th": { "title": "...", "description": "..." },
"en": { "title": "...", "description": "..." },
"ja": { "title": "...", "description": "..." }
}`}
spellCheck={false}
/>
</div>
{/* Validation Status */}
{parseError && (
<div className="p-3.5 bg-rose-500/10 border border-rose-500/30 rounded-xl flex items-center gap-3 text-xs text-rose-300 animate-fade-in">
<AlertCircle className="w-4 h-4 shrink-0 text-rose-400" />
<span>{parseError}</span>
</div>
)}
{/* Live Preview of parsed languages */}
{parsedImportData && (
<div className="space-y-3 pt-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
<span className="text-xs font-bold uppercase tracking-wider text-emerald-400">
Valid JSON: Found {Object.keys(parsedImportData).length} Language(s)
</span>
</div>
<div className="flex flex-wrap gap-1">
{Object.keys(parsedImportData).map((l) => (
<span
key={l}
className="px-2 py-0.5 rounded text-[10px] font-bold uppercase bg-emerald-500/10 text-emerald-400 border border-emerald-500/20"
>
{l}
</span>
))}
</div>
</div>
{/* Preview Cards Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 max-h-56 overflow-y-auto pr-1">
{Object.entries(parsedImportData).map(([lang, val]) => {
const localeObj = supportedLocales.find((sl) => sl.code === lang);
return (
<div
key={lang}
className="p-3.5 rounded-xl bg-night-950/80 border border-white/5 space-y-1.5 text-xs"
>
<div className="flex items-center justify-between border-b border-white/5 pb-1.5">
<span className="font-bold text-cyan-glow uppercase tracking-wider">
{localeObj ? `${localeObj.label} (${localeObj.full})` : lang.toUpperCase()}
</span>
<span className="text-[10px] text-slate-500">
T: {val.title?.length || 0}c | D: {val.description?.length || 0}c
</span>
</div>
<div>
<p className="text-[11px] font-semibold text-slate-300 truncate" title={val.title}>
<span className="text-slate-500 font-normal">Title: </span>
{val.title || <span className="text-slate-600 italic">None</span>}
</p>
<p className="text-[11px] text-slate-400 line-clamp-2 mt-0.5" title={val.description}>
<span className="text-slate-500 font-normal">Desc: </span>
{val.description || <span className="text-slate-600 italic">None</span>}
</p>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
)}
{/* EXPORT TAB */}
{activeTab === 'export' && (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs text-slate-400">
Current configured SEO Title Templates and Descriptions for all 8 supported languages:
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleCopyJson}
className="px-3.5 py-1.5 bg-cyan-glow text-night-900 rounded-xl text-xs font-bold transition-all flex items-center gap-1.5 hover:shadow-lg hover:shadow-cyan-glow/20 cursor-pointer"
>
{copiedJson ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedJson ? 'Copied JSON!' : 'Copy JSON'}</span>
</button>
<button
type="button"
onClick={handleDownloadJson}
className="px-3.5 py-1.5 bg-white/5 hover:bg-white/10 text-slate-200 border border-white/10 rounded-xl text-xs font-semibold transition-all flex items-center gap-1.5 cursor-pointer"
>
<Download className="w-3.5 h-3.5 text-cyan-glow" />
<span>Download .json</span>
</button>
</div>
</div>
{/* JSON Display */}
<div className="relative">
<textarea
readOnly
value={exportJsonString}
rows={12}
className="admin-input w-full font-mono text-xs leading-relaxed p-4 bg-night-950 border-white/10 rounded-xl resize-y text-slate-200 outline-none select-all"
spellCheck={false}
/>
</div>
{/* AI Translation Prompt Box */}
<div className="p-4 rounded-xl bg-purple-500/5 border border-purple-500/20 space-y-2.5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-bold text-purple-300">
<Bot className="w-4 h-4 text-purple-400" />
<span>One-Click AI Translation Prompt (for ChatGPT / Claude)</span>
</div>
<button
type="button"
onClick={handleCopyPrompt}
className="px-3 py-1 bg-purple-500/20 hover:bg-purple-500/30 text-purple-300 border border-purple-500/30 rounded-lg text-xs font-semibold transition-all flex items-center gap-1 cursor-pointer"
>
{copiedPrompt ? <Check className="w-3.5 h-3.5" /> : <Copy className="w-3.5 h-3.5" />}
<span>{copiedPrompt ? 'Copied Prompt!' : 'Copy Prompt for AI'}</span>
</button>
</div>
<p className="text-[11px] text-slate-400 leading-relaxed">
คลิกปุ่มนี้เพื่อคัดลอก Prompt สำเร็จรูป นำไปวางใน ChatGPT หรือ Claude เพื่อให้ AI แปลงและขัดเกลา SEO ทุกภาษาพร้อม Format JSON แล้วนำกลับมาวางในแท็บ Import ได้ทันที
</p>
</div>
</div>
)}
</div>
{/* Modal Footer */}
<div className="p-5 sm:p-6 border-t border-white/10 bg-night-950/80 flex items-center justify-between">
<button
type="button"
onClick={onClose}
className="px-4 py-2 bg-white/5 hover:bg-white/10 text-slate-300 rounded-xl text-xs font-semibold transition-all cursor-pointer"
>
Cancel
</button>
{activeTab === 'import' && (
<div className="flex items-center gap-3">
{applySuccess && (
<span className="text-xs text-emerald-400 font-bold flex items-center gap-1 animate-fade-in">
<CheckCircle2 className="w-4 h-4" /> Applied successfully!
</span>
)}
<button
type="button"
disabled={!parsedImportData || Boolean(parseError)}
onClick={handleApply}
className="px-5 py-2 bg-cyan-glow text-night-900 rounded-xl text-xs font-bold transition-all flex items-center gap-2 hover:shadow-lg hover:shadow-cyan-glow/20 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
<span>Apply to All Languages</span>
<ArrowRight className="w-4 h-4" />
</button>
</div>
)}
</div>
</div>
</div>
);
}
+31 -2
View File
@@ -13,10 +13,12 @@ import {
Wand2,
Copy,
Check,
FileText
FileText,
FileJson
} from 'lucide-react';
import { useStoreSettings } from '../contexts/StoreSettingsContext';
import MediaLibraryModal from '../components/MediaLibraryModal';
import SeoBulkJsonModal from '../components/SeoBulkJsonModal';
export const DEFAULT_BRAND_PROMPT = `You are the Visual Art Director for AKÉRIA.
@@ -155,6 +157,9 @@ export default function StoreSettingsPage() {
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
const [mediaTarget, setMediaTarget] = useState<'logo' | 'ogImage' | null>(null);
// SEO Bulk JSON Import/Export Modal
const [isSeoJsonModalOpen, setIsSeoJsonModalOpen] = useState(false);
useEffect(() => {
fetch(`${API_BASE}/settings`)
.then((r) => r.json())
@@ -493,7 +498,7 @@ export default function StoreSettingsPage() {
{/* 3. Global Default SEO & Social Sharing */}
<div className="admin-card p-8 space-y-6">
<div className="flex items-center justify-between border-b border-white/10 pb-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-white/10 pb-4">
<div className="flex items-center gap-3">
<div className="p-2.5 bg-cyan-glow/10 border border-cyan-glow/20 rounded-xl">
<Globe className="w-5 h-5 text-cyan-glow" />
@@ -507,6 +512,16 @@ export default function StoreSettingsPage() {
</p>
</div>
</div>
<button
type="button"
onClick={() => setIsSeoJsonModalOpen(true)}
className="px-3.5 py-2 bg-cyan-glow/10 hover:bg-cyan-glow/20 text-cyan-glow border border-cyan-glow/30 rounded-xl text-xs font-bold transition-all flex items-center justify-center gap-2 hover:shadow-lg hover:shadow-cyan-glow/10 shrink-0 cursor-pointer"
title="Import/Export All Language SEO Defaults via JSON"
>
<FileJson className="w-4 h-4" />
<span>Import / Export JSON (ทุกภาษา)</span>
</button>
</div>
{/* Language Tabs for Global SEO */}
@@ -789,6 +804,20 @@ export default function StoreSettingsPage() {
onClose={() => setIsMediaModalOpen(false)}
onSelect={handleMediaSelect}
/>
<SeoBulkJsonModal
isOpen={isSeoJsonModalOpen}
onClose={() => setIsSeoJsonModalOpen(false)}
defaultSeoTitle={defaultSeoTitle}
defaultSeoDescription={defaultSeoDescription}
translations={translations}
supportedLocales={SUPPORTED_LOCALES}
onApply={({ thTitle, thDesc, translations: nextTranslations }) => {
setDefaultSeoTitle(thTitle);
setDefaultSeoDescription(thDesc);
setTranslations(nextTranslations);
}}
/>
</div>
);
}