This commit is contained in:
Your Name committed 2026-07-27 20:07:16 -04:00
1 parent 2729f88f45
commit 406cad801b
9 files changed
+1103 -6

No files matched your search

+2
View File
@@ -8,6 +8,7 @@ import ProductCmsPage from './pages/ProductCmsPage'
import CategoriesPage from './pages/CategoriesPage' import CategoriesPage from './pages/CategoriesPage'
import CustomersPage from './pages/CustomersPage' import CustomersPage from './pages/CustomersPage'
import PageBuilderPage from './pages/PageBuilderPage' import PageBuilderPage from './pages/PageBuilderPage'
import CmsMockupPage from './pages/CmsMockupPage'
import RawMaterialsPage from './pages/RawMaterialsPage' import RawMaterialsPage from './pages/RawMaterialsPage'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import ProtectedRoute from './components/ProtectedRoute' import ProtectedRoute from './components/ProtectedRoute'
@@ -64,6 +65,7 @@ function App() {
<Route path="/categories" element={<CategoriesPage />} /> <Route path="/categories" element={<CategoriesPage />} />
<Route path="/customers" element={<CustomersPage />} /> <Route path="/customers" element={<CustomersPage />} />
<Route path="/page-builder" element={<PageBuilderPage />} /> <Route path="/page-builder" element={<PageBuilderPage />} />
<Route path="/cms-mockup" element={<CmsMockupPage />} />
<Route path="/orders" element={<OrdersPage />} /> <Route path="/orders" element={<OrdersPage />} />
<Route path="/orders/:id" element={<OrderDetailPage />} /> <Route path="/orders/:id" element={<OrderDetailPage />} />
<Route path="/media" element={<MediaLibraryPage />} /> <Route path="/media" element={<MediaLibraryPage />} />
+2
View File
@@ -26,6 +26,7 @@ import {
FileText, FileText,
Wrench, Wrench,
ChevronDown, ChevronDown,
Sparkles,
} from 'lucide-react' } from 'lucide-react'
const MAIN_MENU = [ const MAIN_MENU = [
@@ -39,6 +40,7 @@ const MAIN_MENU = [
{ icon: Ticket, label: 'Promotions', to: '/promotions' }, { icon: Ticket, label: 'Promotions', to: '/promotions' },
{ icon: Ticket, label: 'Coupons', to: '/coupons' }, { icon: Ticket, label: 'Coupons', to: '/coupons' },
{ icon: Layers, label: 'Page Builder (CMS)', to: '/page-builder' }, { icon: Layers, label: 'Page Builder (CMS)', to: '/page-builder' },
{ icon: Sparkles, label: 'CMS Block Showcase', to: '/cms-mockup' },
{ icon: ImageIcon, label: 'Media Library', to: '/media' }, { icon: ImageIcon, label: 'Media Library', to: '/media' },
{ icon: BookOpen, label: 'Scent Guide', to: '/articles' }, { icon: BookOpen, label: 'Scent Guide', to: '/articles' },
{ icon: TrendingUp, label: 'TikTok Data', to: '/tiktok' }, { icon: TrendingUp, label: 'TikTok Data', to: '/tiktok' },
@@ -78,6 +78,24 @@ export default function EditorialTileForm({
<option value="right">Right</option> <option value="right">Right</option>
</select> </select>
</div> </div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Image Ratio (สัดส่วนรูปภาพ)</label>
<select
value={editingSection.payload?.imageAspectRatio || 'square'}
onChange={(e) =>
setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), imageAspectRatio: e.target.value }
})
}
className="admin-input w-full bg-slate-900 border-white/10"
>
<option value="square">Square 1:1 (จัตุรัส)</option>
<option value="portrait">Portrait 3:4 (แนวสูง 3:4)</option>
<option value="tall">Tall Fashion 2:3 (แนวสูง 2:3)</option>
<option value="auto">Natural / Original (รูปต้นฉบับ)</option>
</select>
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Color</label> <label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Background Color</label>
<input <input
File diff suppressed because it is too large. Load diff
+33 -1
View File
@@ -13,7 +13,9 @@ import {
X, X,
Settings2, Settings2,
CheckCircle2, CheckCircle2,
ExternalLink ExternalLink,
Copy,
Check
} from 'lucide-react'; } from 'lucide-react';
import { API_BASE, type Page, type PageSection, type Category } from '@homaroma/types'; import { API_BASE, type Page, type PageSection, type Category } from '@homaroma/types';
import { getAdminDisplayString } from '../utils/localization'; import { getAdminDisplayString } from '../utils/localization';
@@ -84,6 +86,7 @@ export default function PageBuilderPage() {
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide' | 'gallery'; index?: number } | null>(null); const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide' | 'gallery'; index?: number } | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [isSavingPageSettings, setIsSavingPageSettings] = useState(false); const [isSavingPageSettings, setIsSavingPageSettings] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const [isReorderModalOpen, setIsReorderModalOpen] = useState(false); const [isReorderModalOpen, setIsReorderModalOpen] = useState(false);
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false); const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
@@ -943,6 +946,35 @@ export default function PageBuilderPage() {
/> />
</div> </div>
<div className="flex flex-col gap-2">
<label className="text-[10px] font-bold uppercase tracking-widest text-slate-400">
Page URL
</label>
<div className="flex items-center gap-2">
<input
type="text"
readOnly
value={getPageUrl(page.slug)}
className="bg-slate-900/50 border border-white/10 text-slate-300 rounded-xl px-4 py-2 text-xs focus:outline-none transition-colors flex-1 select-all font-mono"
onClick={(e) => (e.target as HTMLInputElement).select()}
/>
<button
type="button"
onClick={() => {
navigator.clipboard.writeText(getPageUrl(page.slug));
setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
}}
className={`p-2.5 rounded-xl border border-white/10 text-white transition-all flex items-center justify-center ${
isCopied ? 'bg-emerald-500/20 border-emerald-500/30 text-emerald-400' : 'bg-white/5 hover:bg-white/10'
}`}
title="Copy page URL"
>
{isCopied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<label className="inline-flex items-center gap-3 cursor-pointer"> <label className="inline-flex items-center gap-3 cursor-pointer">
<input <input
+4 -3
View File
@@ -155,11 +155,11 @@ const PAGE_BLOCK_METADATA: Record<string, BlockMetadata> = {
EDITORIAL_TILE: { EDITORIAL_TILE: {
type: "EDITORIAL_TILE", type: "EDITORIAL_TILE",
requiredFields: ["title", "description", "imageUrl", "imagePosition"], requiredFields: ["title", "description", "imageUrl", "imagePosition"],
optionalFields: ["buttonText", "buttonLink", "backgroundColor", "textColor"], optionalFields: ["buttonText", "buttonLink", "backgroundColor", "textColor", "imageAspectRatio"],
renderedFields: ["title", "description", "imageUrl", "imagePosition", "buttonText", "buttonLink", "backgroundColor", "textColor"], renderedFields: ["title", "description", "imageUrl", "imagePosition", "imageAspectRatio", "buttonText", "buttonLink", "backgroundColor", "textColor"],
storedOnlyFields: [], storedOnlyFields: [],
discoveryToolsNeeded: [], discoveryToolsNeeded: [],
warnings: ["imagePosition must be left or right."], warnings: ["imagePosition must be left or right.", "imageAspectRatio can be square, portrait, tall, or auto."],
minimalPayload: { minimalPayload: {
title: localizedObjectExample, title: localizedObjectExample,
description: localizedObjectExample, description: localizedObjectExample,
@@ -970,6 +970,7 @@ const SECTION_TOOL_CONFIGS = [
sectionId: { type: "number", description: "The numeric ID of the section to update" }, sectionId: { type: "number", description: "The numeric ID of the section to update" },
imageUrl: { type: "string", description: "Plain string main image URL" }, imageUrl: { type: "string", description: "Plain string main image URL" },
imagePosition: { type: "string", description: "Plain string image position ('left' or 'right')" }, imagePosition: { type: "string", description: "Plain string image position ('left' or 'right')" },
imageAspectRatio: { type: "string", description: "Plain string image aspect ratio ('square', 'portrait', 'tall', or 'auto')" },
title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` }, title: { ...localizedStringSchema, description: `Localized title, for example ${localizedStringExample}` },
description: { ...localizedStringSchema, description: `Localized description, for example ${localizedStringExample}` }, description: { ...localizedStringSchema, description: `Localized description, for example ${localizedStringExample}` },
buttonText: { ...localizedStringSchema, description: `Localized button text, for example ${localizedStringExample}` }, buttonText: { ...localizedStringSchema, description: `Localized button text, for example ${localizedStringExample}` },
@@ -13,12 +13,27 @@ export default function DynamicEditorialTile({ payload }: DynamicEditorialTilePr
const { const {
imageUrl, imageUrl,
imagePosition = 'left', imagePosition = 'left',
imageAspectRatio = 'square',
buttonLink buttonLink
} = payload; } = payload;
const title = getLocalizedField(payload.title, lang); const title = getLocalizedField(payload.title, lang);
const description = getLocalizedField(payload.description, lang); const description = getLocalizedField(payload.description, lang);
const buttonText = getLocalizedField(payload.buttonText, lang); const buttonText = getLocalizedField(payload.buttonText, lang);
const getAspectClass = (ratio?: string) => {
switch (ratio) {
case 'portrait':
return 'aspect-[3/4]';
case 'tall':
return 'aspect-[2/3]';
case 'auto':
return 'aspect-auto';
case 'square':
default:
return 'aspect-square';
}
};
const renderTitle = (text: string) => { const renderTitle = (text: string) => {
if (!text) return null; if (!text) return null;
return text.split('[').map((part, i) => { return text.split('[').map((part, i) => {
@@ -43,7 +58,7 @@ export default function DynamicEditorialTile({ payload }: DynamicEditorialTilePr
style={{ backgroundColor: payload.backgroundColor, color: payload.textColor }} style={{ backgroundColor: payload.backgroundColor, color: payload.textColor }}
> >
<div className="ui-section-inner grid grid-cols-1 items-center gap-12 md:grid-cols-2 md:gap-20"> <div className="ui-section-inner grid grid-cols-1 items-center gap-12 md:grid-cols-2 md:gap-20">
<div className={`group relative aspect-square w-full overflow-hidden rounded-3xl shadow-xl ${ <div className={`group relative ${getAspectClass(imageAspectRatio)} w-full overflow-hidden rounded-3xl shadow-xl ${
imagePosition === 'left' ? 'md:order-1' : 'md:order-2' imagePosition === 'left' ? 'md:order-1' : 'md:order-2'
}`}> }`}>
<img <img
@@ -893,14 +893,29 @@ function EditorialTileSection({ payload, lang }: { payload: Record<string, unkno
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : '' const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : ''
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '' const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : ''
const imagePosition = payload.imagePosition === 'right' ? 'right' : 'left' const imagePosition = payload.imagePosition === 'right' ? 'right' : 'left'
const imageAspectRatio = typeof payload.imageAspectRatio === 'string' ? payload.imageAspectRatio : 'square'
const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined const backgroundColor = typeof payload.backgroundColor === 'string' ? payload.backgroundColor : undefined
const textColor = typeof payload.textColor === 'string' ? payload.textColor : undefined const textColor = typeof payload.textColor === 'string' ? payload.textColor : undefined
const getAspectClass = (ratio: string) => {
switch (ratio) {
case 'portrait':
return 'aspect-[3/4]'
case 'tall':
return 'aspect-[2/3]'
case 'auto':
return 'aspect-auto'
case 'square':
default:
return 'aspect-square'
}
}
return ( return (
<section className="overflow-hidden bg-surface-page ui-section-band" style={{ backgroundColor, color: textColor }}> <section className="overflow-hidden bg-surface-page ui-section-band" style={{ backgroundColor, color: textColor }}>
<div className="mx-auto grid max-w-7xl gap-12 md:grid-cols-2 md:items-center"> <div className="mx-auto grid max-w-7xl gap-12 md:grid-cols-2 md:items-center">
{imageUrl && ( {imageUrl && (
<div className={`group relative aspect-square overflow-hidden rounded-3xl shadow-card ${imagePosition === 'left' ? 'md:order-1' : 'md:order-2'}`}> <div className={`group relative ${getAspectClass(imageAspectRatio)} overflow-hidden rounded-3xl shadow-card ${imagePosition === 'left' ? 'md:order-1' : 'md:order-2'}`}>
<Image <Image
src={imageUrl} src={imageUrl}
alt={title || 'Editorial'} alt={title || 'Editorial'}
+1
View File
@@ -402,6 +402,7 @@ export const EditorialTileSectionPayloadSchema = z.object({
description: LocalizedStringSchema, description: LocalizedStringSchema,
imageUrl: z.string().min(1), imageUrl: z.string().min(1),
imagePosition: z.enum(['left', 'right']), imagePosition: z.enum(['left', 'right']),
imageAspectRatio: z.enum(['square', 'portrait', 'tall', 'auto']).optional(),
buttonText: LocalizedStringSchema.optional(), buttonText: LocalizedStringSchema.optional(),
buttonLink: z.string().optional(), buttonLink: z.string().optional(),
backgroundColor: z.string().optional(), backgroundColor: z.string().optional(),