x
This commit is contained in:
1 parent
2729f88f45
commit
406cad801b
9 files changed
+1103
-6
No files matched your search
@@ -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 />} />
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
Reference in new issue
Block a user