diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index ffc11f9..bc7b593 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -27,6 +27,7 @@ import MainNavigationPage from './pages/menu-manager/MainNavigationPage' import FooterExplorePage from './pages/menu-manager/FooterExplorePage' import FooterSupportPage from './pages/menu-manager/FooterSupportPage' import FooterBrandPage from './pages/menu-manager/FooterBrandPage' +import FooterLayoutPage from './pages/menu-manager/FooterLayoutPage' import NewsletterSubscribersPage from './pages/NewsletterSubscribersPage' import ArticlesPage from './pages/ArticlesPage' import ArticleEditPage from './pages/ArticleEditPage' @@ -125,6 +126,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/admin/src/components/Sidebar.tsx b/apps/admin/src/components/Sidebar.tsx index c449dc4..1d86aa9 100644 --- a/apps/admin/src/components/Sidebar.tsx +++ b/apps/admin/src/components/Sidebar.tsx @@ -38,6 +38,7 @@ import { Map, Target, BrainCircuit, + LayoutTemplate, } from 'lucide-react' import { useStoreSettings } from '../contexts/StoreSettingsContext' import { clearAdminAuth } from '../utils/auth' @@ -160,6 +161,7 @@ export default function Sidebar({ isCollapsed, onToggle }: SidebarProps) { { icon: FileText, label: t('nav.footerExplore', 'Footer Explore'), to: '/menu-manager/footer-explore' }, { icon: MessageSquare, label: t('nav.footerSupport', 'Footer Support'), to: '/menu-manager/footer-support' }, { icon: Store, label: t('nav.footerBrand', 'Footer Brand'), to: '/menu-manager/footer-brand' }, + { icon: LayoutTemplate, label: t('nav.footerLayout', 'Footer Layout & Presentation'), to: '/menu-manager/footer-layout' }, ] const BACKUP_MENU = [ diff --git a/apps/admin/src/pages/menu-manager/FooterLayoutPage.tsx b/apps/admin/src/pages/menu-manager/FooterLayoutPage.tsx new file mode 100644 index 0000000..c3a9bbd --- /dev/null +++ b/apps/admin/src/pages/menu-manager/FooterLayoutPage.tsx @@ -0,0 +1,311 @@ +import { useEffect, useMemo, useState } from 'react' +import type { FormEvent } from 'react' +import { + API_BASE, + DEFAULT_FOOTER_LAYOUT_SETTINGS, + FooterLayoutSettingsSchema, + type FooterLayoutSettings, +} from '@homaroma/types' +import { + AlertCircle, + CheckCircle2, + ChevronDown, + ChevronUp, + Eye, + EyeOff, + LayoutTemplate, + Loader2, + Save, +} from 'lucide-react' +import { getAuthHeaders } from '../../utils/auth' + +type FooterColumn = FooterLayoutSettings['columns'][number] +type SaveState = { type: 'success' | 'error'; message: string } | null + +const COLUMN_OPTIONS: Array<{ value: FooterColumn; label: string; description: string }> = [ + { value: 'brand', label: 'Brand', description: 'Logo, description and social links' }, + { value: 'explore', label: 'Explore', description: 'Links from Footer Explore' }, + { value: 'collections', label: 'Collections', description: 'Automatic category links' }, + { value: 'support', label: 'Support', description: 'Links from Footer Support' }, +] + +const SPACING_OPTIONS = ['sm', 'md', 'lg', 'xl'] as const +const TEXT_SIZE_OPTIONS = ['sm', 'md', 'lg'] as const +const CONTAINER_WIDTH_OPTIONS = ['narrow', 'standard', 'wide', 'full'] as const +const FINALE_MODE_OPTIONS = ['static', 'curtain'] as const +const MOBILE_HEIGHT_OPTIONS = ['compact', 'medium', 'large'] as const +const DESKTOP_HEIGHT_OPTIONS = ['compact', 'half', 'screen'] as const + +function selectedOption( + value: string, + options: Options, + fallback: Options[number], +): Options[number] { + return options.find(option => option === value) ?? fallback +} + +function getErrorMessage(payload: unknown, fallback: string): string { + if (!payload || typeof payload !== 'object') return fallback + const record = payload as Record + const message = typeof record.error === 'string' + ? record.error + : typeof record.message === 'string' + ? record.message + : fallback + if (!Array.isArray(record.issues)) return message + + const details = record.issues.flatMap(issue => { + if (!issue || typeof issue !== 'object') return [] + const issueRecord = issue as Record + const issueMessage = typeof issueRecord.message === 'string' ? issueRecord.message : null + const path = Array.isArray(issueRecord.path) ? issueRecord.path.join('.') : '' + return issueMessage ? [`${path ? `${path}: ` : ''}${issueMessage}`] : [] + }) + return details.length > 0 ? `${message} ${details.join('; ')}` : message +} + +function selectClassName(): string { + return 'admin-input w-full cursor-pointer' +} + +function Preview({ layout, mobile }: { layout: FooterLayoutSettings; mobile: boolean }) { + const visibleColumns = layout.columns + const columnCount = mobile ? layout.mobileColumns : layout.desktopColumns + const gridClass = columnCount === 1 + ? 'grid-cols-1' + : columnCount === 2 + ? 'grid-cols-2' + : columnCount === 3 + ? 'grid-cols-3' + : 'grid-cols-4' + + return ( +
+
+ {mobile ? 'Mobile' : 'Desktop'} + {columnCount} column{columnCount === 1 ? '' : 's'} +
+
+ {visibleColumns.map(column => ( +
+
+
+
+ {column} +
+ ))} +
+ {layout.finale.enabled && ( +
+ {layout.finale.mode} finale +
+ )} +
+ ) +} + +export default function FooterLayoutPage() { + const [layout, setLayout] = useState(() => structuredClone(DEFAULT_FOOTER_LAYOUT_SETTINGS)) + const [version, setVersion] = useState() + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [saveState, setSaveState] = useState(null) + + useEffect(() => { + let cancelled = false + + fetch(`${API_BASE}/settings/footer-layout`, { headers: getAuthHeaders() }) + .then(async response => { + const payload: unknown = await response.json() + if (!response.ok) throw new Error(getErrorMessage(payload, `Unable to load footer layout (${response.status})`)) + if (!payload || typeof payload !== 'object') throw new Error('Footer layout response is invalid.') + const record = payload as Record + const parsed = FooterLayoutSettingsSchema.safeParse(record.layout) + if (!parsed.success) throw new Error('Footer layout response does not match the shared contract.') + if (typeof record.version !== 'string') throw new Error('Footer layout response is missing its version.') + return { + layout: parsed.data, + version: record.version, + } + }) + .then(result => { + if (cancelled) return + setLayout(result.layout) + setVersion(result.version) + }) + .catch(error => { + if (!cancelled) setSaveState({ type: 'error', message: error instanceof Error ? error.message : 'Unable to load footer layout.' }) + }) + .finally(() => { + if (!cancelled) setLoading(false) + }) + + return () => { cancelled = true } + }, []) + + const orderedColumns = useMemo(() => { + const active = layout.columns + .map(column => COLUMN_OPTIONS.find(option => option.value === column)) + .filter((option): option is (typeof COLUMN_OPTIONS)[number] => Boolean(option)) + const inactive = COLUMN_OPTIONS.filter(option => !layout.columns.includes(option.value)) + return [...active, ...inactive] + }, [layout.columns]) + + const updateLayout = (key: Key, value: FooterLayoutSettings[Key]) => { + setLayout(current => ({ ...current, [key]: value })) + } + + const updateFinale = ( + key: Key, + value: FooterLayoutSettings['finale'][Key], + ) => { + setLayout(current => ({ ...current, finale: { ...current.finale, [key]: value } })) + } + + const toggleColumn = (column: FooterColumn) => { + setLayout(current => { + if (current.columns.includes(column)) { + if (current.columns.length === 1) return current + return { ...current, columns: current.columns.filter(item => item !== column) } + } + return { ...current, columns: [...current.columns, column] } + }) + } + + const moveColumn = (column: FooterColumn, direction: -1 | 1) => { + setLayout(current => { + const index = current.columns.indexOf(column) + const destination = index + direction + if (index < 0 || destination < 0 || destination >= current.columns.length) return current + const columns = [...current.columns] + columns.splice(index, 1) + columns.splice(destination, 0, column) + return { ...current, columns } + }) + } + + const handleSave = async (event: FormEvent) => { + event.preventDefault() + setSaveState(null) + + const parsed = FooterLayoutSettingsSchema.safeParse(layout) + if (!parsed.success) { + setSaveState({ type: 'error', message: parsed.error.issues.map(issue => `${issue.path.join('.')}: ${issue.message}`).join('; ') }) + return + } + + setSaving(true) + try { + const response = await fetch(`${API_BASE}/settings/footer-layout`, { + method: 'PATCH', + headers: getAuthHeaders(), + body: JSON.stringify({ ...parsed.data, expectedVersion: version }), + }) + const payload: unknown = await response.json() + if (!response.ok) { + if (response.status === 409) throw new Error('Footer layout changed in another session. Reload this page before saving again.') + throw new Error(getErrorMessage(payload, `Unable to save footer layout (${response.status})`)) + } + if (!payload || typeof payload !== 'object') throw new Error('Footer layout save response is invalid.') + const record = payload as Record + const savedLayout = FooterLayoutSettingsSchema.safeParse(record.layout) + if (!savedLayout.success) throw new Error('Saved footer layout does not match the shared contract.') + setLayout(savedLayout.data) + setVersion(typeof record.version === 'string' ? record.version : version) + setSaveState({ type: 'success', message: 'Footer layout saved successfully.' }) + } catch (error) { + setSaveState({ type: 'error', message: error instanceof Error ? error.message : 'Unable to save footer layout.' }) + } finally { + setSaving(false) + } + } + + if (loading) { + return
+ } + + return ( +
+
+

Footer Layout & Presentation

+

Control responsive columns, spacing, typography and the brand finale independently from footer content.

+
+ + {saveState && ( +
+ {saveState.type === 'success' ? : } + {saveState.message} +
+ )} + +
+
+
+
+
+

Column composition

Choose which columns appear and their left-to-right order.

+
+
+ {orderedColumns.map(option => { + const activeIndex = layout.columns.indexOf(option.value) + const isVisible = activeIndex >= 0 + return ( +
+
+ + +
+

{option.label}

{option.description}

+ +
+ ) + })} +
+
+ + +
+
+ +
+

Sizing & spacing

Shared presentation scales keep SPA and Website rendering consistent.

+
+ + + + + + + +
+
+ +
+
+

Brand finale

Configure the visual brand moment above the footer.

+ +
+
+ + + + +
+
+ + +
+ + +
+
+ ) +} diff --git a/apps/admin/src/pages/menu-manager/MenuEditorPage.tsx b/apps/admin/src/pages/menu-manager/MenuEditorPage.tsx index 4f0bbe5..d2708bb 100644 --- a/apps/admin/src/pages/menu-manager/MenuEditorPage.tsx +++ b/apps/admin/src/pages/menu-manager/MenuEditorPage.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import { API_BASE, type Menu } from '@homaroma/types' -import { Save, Loader2, Plus, Trash2, GripVertical, Copy, Check, Download, Upload, Star, Pencil } from 'lucide-react' +import { Save, Loader2, Plus, Trash2, ChevronUp, ChevronDown, Copy, Check, Download, Upload, Star, Pencil } from 'lucide-react' import { ICON_REGISTRY } from '../../utils/iconRegistry' import FocalPointPicker from '../../components/page-builder/FocalPointPicker' @@ -447,10 +447,25 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi } const handleRemoveItem = (idx: number) => { - const newItems = currentMenu.items.filter((_, i) => i !== idx) + const newItems = currentMenu.items + .filter((_, i) => i !== idx) + .map((item, order) => ({ ...item, order })) setMenus(menus.map(m => m.name === menuName ? { ...m, items: newItems } : m)) } + const handleMoveItem = (idx: number, direction: -1 | 1) => { + const destination = idx + direction + if (destination < 0 || destination >= currentMenu.items.length) return + + const newItems = [...currentMenu.items] + const [movedItem] = newItems.splice(idx, 1) + if (!movedItem) return + newItems.splice(destination, 0, movedItem) + + const reorderedItems = newItems.map((item, order) => ({ ...item, order })) + setMenus(menus.map(m => m.name === menuName ? { ...m, items: reorderedItems } : m)) + } + const handleExport = () => { const dataStr = JSON.stringify(currentMenu, null, 2) const blob = new Blob([dataStr], { type: 'application/json' }) @@ -644,7 +659,28 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi ) : ( currentMenu.items.map((item, idx) => (
- +
+ + +
diff --git a/apps/backend/prisma/migrations/20261001100557_add_footer_layout_settings/migration.sql b/apps/backend/prisma/migrations/20261001100557_add_footer_layout_settings/migration.sql new file mode 100644 index 0000000..6417cf7 --- /dev/null +++ b/apps/backend/prisma/migrations/20261001100557_add_footer_layout_settings/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "StoreSettings" ADD COLUMN "footerLayout" JSONB; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 774c236..d7733e2 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -301,6 +301,7 @@ model StoreSettings { newsletterButtonText String? footerSupportHeading Json? footerCopyrightText Json? + footerLayout Json? scrollRevealEnabled Boolean @default(true) scrollRevealStyle String @default("fade-up") scrollRevealDuration Int @default(700) diff --git a/apps/backend/src/lib/footerLayoutSettings.test.ts b/apps/backend/src/lib/footerLayoutSettings.test.ts new file mode 100644 index 0000000..89e3e22 --- /dev/null +++ b/apps/backend/src/lib/footerLayoutSettings.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from 'bun:test' +import { DEFAULT_FOOTER_LAYOUT_SETTINGS } from '@homaroma/types' +import { + mergeFooterLayoutSettings, + normalizeFooterLayoutSettings, + validateFooterLayoutSettingsPatch, +} from './footerLayoutSettings' + +describe('footer layout settings', () => { + test('normalizes missing and invalid persisted values to the shared default', () => { + expect(normalizeFooterLayoutSettings(null)).toEqual(DEFAULT_FOOTER_LAYOUT_SETTINGS) + expect(normalizeFooterLayoutSettings({ columns: ['brand'] })).toEqual( + DEFAULT_FOOTER_LAYOUT_SETTINGS, + ) + }) + + test('preserves a valid persisted layout', () => { + const layout = { ...DEFAULT_FOOTER_LAYOUT_SETTINGS, mobileColumns: 2 as const } + expect(normalizeFooterLayoutSettings(layout)).toEqual(layout) + }) + + test('merges a partial finale patch without losing sibling settings', () => { + expect(mergeFooterLayoutSettings(DEFAULT_FOOTER_LAYOUT_SETTINGS, { + containerWidth: 'wide', + finale: { enabled: false }, + })).toEqual({ + ...DEFAULT_FOOTER_LAYOUT_SETTINGS, + containerWidth: 'wide', + finale: { ...DEFAULT_FOOTER_LAYOUT_SETTINGS.finale, enabled: false }, + }) + }) + + test('strictly rejects unknown patch fields', () => { + const result = validateFooterLayoutSettingsPatch({ arbitraryCss: 'display:none' }) + expect(result.success).toBe(false) + }) +}) diff --git a/apps/backend/src/lib/footerLayoutSettings.ts b/apps/backend/src/lib/footerLayoutSettings.ts new file mode 100644 index 0000000..ebdf300 --- /dev/null +++ b/apps/backend/src/lib/footerLayoutSettings.ts @@ -0,0 +1,32 @@ +import { + DEFAULT_FOOTER_LAYOUT_SETTINGS, + FooterLayoutSettingsPatchSchema, + FooterLayoutSettingsSchema, + type FooterLayoutSettings, + type FooterLayoutSettingsPatch, +} from '@homaroma/types' + +export function normalizeFooterLayoutSettings(value: unknown): FooterLayoutSettings { + const result = FooterLayoutSettingsSchema.safeParse(value) + if (result.success) return result.data + + return FooterLayoutSettingsSchema.parse(DEFAULT_FOOTER_LAYOUT_SETTINGS) +} + +export function validateFooterLayoutSettingsPatch(value: unknown) { + return FooterLayoutSettingsPatchSchema.safeParse(value) +} + +export function mergeFooterLayoutSettings( + current: FooterLayoutSettings, + patch: FooterLayoutSettingsPatch, +): FooterLayoutSettings { + return FooterLayoutSettingsSchema.parse({ + ...current, + ...patch, + finale: { + ...current.finale, + ...patch.finale, + }, + }) +} diff --git a/apps/backend/src/middleware/auth.test.ts b/apps/backend/src/middleware/auth.test.ts new file mode 100644 index 0000000..ade4d3c --- /dev/null +++ b/apps/backend/src/middleware/auth.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from 'bun:test' +import { requireAdmin } from './auth' + +function context(authorization?: string, payload: unknown = null) { + return { + jwt: { verify: async () => payload }, + headers: { authorization }, + set: { status: 200 }, + } +} + +describe('requireAdmin', () => { + test('rejects a missing bearer token', async () => { + const request = context() + expect(await requireAdmin(request)).toEqual({ + error: 'Unauthorized: Missing or invalid token format', + }) + expect(request.set.status).toBe(401) + }) + + test('rejects a non-admin token', async () => { + const request = context('Bearer customer-token', { role: 'CUSTOMER' }) + expect(await requireAdmin(request)).toEqual({ + error: 'Unauthorized: Admin access required', + }) + expect(request.set.status).toBe(401) + }) + + test('allows an admin token', async () => { + const request = context('Bearer admin-token', { role: 'ADMIN' }) + expect(await requireAdmin(request)).toBeUndefined() + expect(request.set.status).toBe(200) + }) +}) diff --git a/apps/backend/src/middleware/auth.ts b/apps/backend/src/middleware/auth.ts index cafbd3f..f45efcc 100644 --- a/apps/backend/src/middleware/auth.ts +++ b/apps/backend/src/middleware/auth.ts @@ -1,6 +1,27 @@ import { Elysia } from "elysia"; import { jwt } from "@elysiajs/jwt"; +export async function requireAdmin({ jwt, headers, set }: any) { + const auth = headers.authorization; + if (!auth?.startsWith("Bearer ")) { + set.status = 401; + return { error: "Unauthorized: Missing or invalid token format" }; + } + + const token = auth.substring(7); + const payload = await jwt.verify(token); + + if (!payload) { + set.status = 401; + return { error: "Unauthorized: Invalid token" }; + } + + if ((payload as any).role !== "ADMIN") { + set.status = 401; + return { error: "Unauthorized: Admin access required" }; + } +} + export const adminOnly = new Elysia({ name: "adminOnly" }) .use( jwt({ @@ -8,23 +29,4 @@ export const adminOnly = new Elysia({ name: "adminOnly" }) secret: process.env.JWT_SECRET || "fallback-secret" }) ) - .onBeforeHandle(async ({ jwt, headers, set }) => { - const auth = headers.authorization; - if (!auth?.startsWith("Bearer ")) { - set.status = 401; - return { error: "Unauthorized: Missing or invalid token format" }; - } - - const token = auth.substring(7); - const payload = await jwt.verify(token); - - if (!payload) { - set.status = 401; - return { error: "Unauthorized: Invalid token" }; - } - - if ((payload as any).role !== "ADMIN") { - set.status = 401; - return { error: "Unauthorized: Admin access required" }; - } - }); + .onBeforeHandle(requireAdmin); diff --git a/apps/backend/src/routes/settings.footer-layout-route.test.ts b/apps/backend/src/routes/settings.footer-layout-route.test.ts new file mode 100644 index 0000000..c369230 --- /dev/null +++ b/apps/backend/src/routes/settings.footer-layout-route.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, test } from 'bun:test' +import { settingsRoute } from './settings' + +function jsonRequest(path: string, method: string, body: unknown) { + return new Request(`http://localhost${path}`, { + method, + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('footer layout route boundaries', () => { + for (const method of ['PUT', 'POST']) { + test(`${method} /settings rejects footerLayout writes`, async () => { + const response = await settingsRoute.handle(jsonRequest('/settings', method, { + footerLayout: { columns: ['brand'] }, + })) + + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ + error: 'Use PATCH /settings/footer-layout to update footer layout', + }) + }) + } + + test('PATCH /settings/footer-layout requires admin authentication', async () => { + const response = await settingsRoute.handle(jsonRequest('/settings/footer-layout', 'PATCH', { + dryRun: true, + })) + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ + error: 'Unauthorized: Missing or invalid token format', + }) + }) +}) diff --git a/apps/backend/src/routes/settings.ts b/apps/backend/src/routes/settings.ts index 58c4cd1..947836c 100644 --- a/apps/backend/src/routes/settings.ts +++ b/apps/backend/src/routes/settings.ts @@ -1,6 +1,25 @@ import { Elysia, t } from "elysia"; +import { FooterLayoutSettingsPatchSchema } from "@homaroma/types"; +import { z } from "zod"; import prisma from "../lib/prisma"; -import { adminOnly } from "../middleware/auth"; +import { adminOnly, requireAdmin } from "../middleware/auth"; +import { + mergeFooterLayoutSettings, + normalizeFooterLayoutSettings, +} from "../lib/footerLayoutSettings"; + +const FooterLayoutUpdateRequestSchema = FooterLayoutSettingsPatchSchema.extend({ + expectedVersion: z.string().optional(), + dryRun: z.boolean().optional(), +}).strict().superRefine((value, context) => { + if (!value.dryRun && !value.expectedVersion) { + context.addIssue({ + code: "custom", + path: ["expectedVersion"], + message: "expectedVersion is required when updating footer layout", + }); + } +}); export async function saveStoreSettingsTranslations(settingsId: number, payload: any) { const results: any[] = []; @@ -171,6 +190,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" }) const mobileHeaderStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center'; return { ...settingsData, + footerLayout: normalizeFooterLayoutSettings((settingsData as any).footerLayout), translations, headerBehavior, mobileHeaderBehavior, @@ -220,6 +240,21 @@ export const settingsRoute = new Elysia({ prefix: "/settings" }) return { error: "Failed to fetch footer brand" }; } }) + .get("/footer-layout", async ({ set }) => { + try { + const settings = await prisma.storeSettings.findFirst(); + const current = settings ?? await prisma.storeSettings.create({ data: {} }); + + return { + version: current.updatedAt.toISOString(), + layout: normalizeFooterLayoutSettings((current as any).footerLayout), + }; + } catch (err) { + console.error("[Settings] Footer layout fetch error:", err); + set.status = 500; + return { error: "Failed to fetch footer layout" }; + } + }) .use(adminOnly) .patch("/footer-brand", async ({ body, set }: { body: any; set: any }) => { try { @@ -271,10 +306,74 @@ export const settingsRoute = new Elysia({ prefix: "/settings" }) set.status = 400; return { error: err?.message || "Failed to update footer brand" }; } - }, { body: t.Any() }) + }, { beforeHandle: requireAdmin, body: t.Any() }) + .patch("/footer-layout", async ({ body, set }: { body: unknown; set: any }) => { + const request = FooterLayoutUpdateRequestSchema.safeParse(body); + if (!request.success) { + set.status = 400; + return { error: "Invalid footer layout", issues: request.error.issues }; + } + + try { + const { expectedVersion, dryRun, ...patch } = request.data; + const settings = await prisma.storeSettings.findFirst(); + const current = settings ?? await prisma.storeSettings.create({ data: {} }); + const version = current.updatedAt.toISOString(); + + if (expectedVersion && expectedVersion !== version) { + set.status = 409; + return { + error: "Footer layout has changed since it was read", + code: "VERSION_CONFLICT", + expectedVersion, + currentVersion: version, + }; + } + + const currentLayout = normalizeFooterLayoutSettings((current as any).footerLayout); + const layout = mergeFooterLayoutSettings(currentLayout, patch); + if (dryRun) { + return { dryRun: true, version, wouldUpdate: layout }; + } + + const updated = await prisma.$transaction(async (transaction) => { + const result = await transaction.storeSettings.updateMany({ + where: { id: current.id, updatedAt: current.updatedAt }, + data: { footerLayout: layout as any }, + }); + if (result.count === 0) return null; + return transaction.storeSettings.findUniqueOrThrow({ where: { id: current.id } }); + }); + + if (!updated) { + const latest = await prisma.storeSettings.findUnique({ where: { id: current.id } }); + set.status = 409; + return { + error: "Footer layout has changed since it was read", + code: "VERSION_CONFLICT", + expectedVersion, + currentVersion: latest?.updatedAt.toISOString() ?? version, + }; + } + + return { + success: true, + version: updated.updatedAt.toISOString(), + layout, + }; + } catch (err) { + console.error("[Settings] Footer layout update error:", err); + set.status = 500; + return { error: "Failed to update footer layout" }; + } + }, { beforeHandle: requireAdmin, body: t.Any() }) .put("/", async ({ body, set }: { body: any; set: any }) => { try { - const { headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any; + const { footerLayout, headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any; + if (footerLayout !== undefined) { + set.status = 400; + return { error: "Use PATCH /settings/footer-layout to update footer layout" }; + } const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null) ? data.themeTokens : {}; @@ -354,12 +453,17 @@ export const settingsRoute = new Elysia({ prefix: "/settings" }) defaultSeoDescription: t.Optional(t.Nullable(t.String())), defaultOgImage: t.Optional(t.Nullable(t.String())), brandPrompt: t.Optional(t.Nullable(t.String())), + footerLayout: t.Optional(t.Any()), translations: t.Optional(t.Any()), }) }) .post("/", async ({ body, set }: { body: any; set: any }) => { try { - const { headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any; + const { footerLayout, headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any; + if (footerLayout !== undefined) { + set.status = 400; + return { error: "Use PATCH /settings/footer-layout to update footer layout" }; + } const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null) ? data.themeTokens : {}; @@ -439,6 +543,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" }) defaultSeoDescription: t.Optional(t.Nullable(t.String())), defaultOgImage: t.Optional(t.Nullable(t.String())), brandPrompt: t.Optional(t.Nullable(t.String())), + footerLayout: t.Optional(t.Any()), translations: t.Optional(t.Any()), }) }) diff --git a/apps/spa/src/components/Footer.tsx b/apps/spa/src/components/Footer.tsx index de9418a..df09fef 100644 --- a/apps/spa/src/components/Footer.tsx +++ b/apps/spa/src/components/Footer.tsx @@ -1,19 +1,43 @@ import { Link } from 'react-router-dom' -import { useState, useEffect } from 'react' -import { API_BASE, type Category, type StoreSettings, type MenuItem } from '../types' +import { useState, useEffect, type ReactNode } from 'react' +import { + API_BASE, + DEFAULT_FOOTER_LAYOUT_SETTINGS, + FooterLayoutSettingsSchema, + type Category, + type FooterColumn, + type FooterLayoutSettings, + type StoreSettings, + type MenuItem, +} from '../types' import { useTranslation } from 'react-i18next' import { getLocalizedField } from '../utils/localization' + interface FooterProps { categories?: Category[] } -type MenuTranslation = { - langCode?: string - label?: string -} +type MenuTranslation = { langCode?: string; label?: string } +type MenuItemWithTranslations = MenuItem & { translations?: MenuTranslation[] } -type MenuItemWithTranslations = MenuItem & { - translations?: MenuTranslation[] +const layoutClassMaps = { + mobileColumns: { 1: 'site-footer--mobile-columns-1', 2: 'site-footer--mobile-columns-2' }, + desktopColumns: { 2: 'site-footer--desktop-columns-2', 3: 'site-footer--desktop-columns-3', 4: 'site-footer--desktop-columns-4' }, + containerWidth: { narrow: 'site-footer--container-narrow', standard: 'site-footer--container-standard', wide: 'site-footer--container-wide', full: 'site-footer--container-full' }, + verticalPadding: { sm: 'site-footer--padding-sm', md: 'site-footer--padding-md', lg: 'site-footer--padding-lg', xl: 'site-footer--padding-xl' }, + columnGap: { sm: 'site-footer--gap-sm', md: 'site-footer--gap-md', lg: 'site-footer--gap-lg', xl: 'site-footer--gap-xl' }, + headingSize: { sm: 'site-footer--heading-sm', md: 'site-footer--heading-md', lg: 'site-footer--heading-lg' }, + linkSize: { sm: 'site-footer--link-sm', md: 'site-footer--link-md', lg: 'site-footer--link-lg' }, + brandLogoSize: { sm: 'site-footer--brand-logo-sm', md: 'site-footer--brand-logo-md', lg: 'site-footer--brand-logo-lg', xl: 'site-footer--brand-logo-xl' }, + socialSize: { sm: 'site-footer--social-sm', md: 'site-footer--social-md', lg: 'site-footer--social-lg' }, + finaleMobileHeight: { compact: 'site-footer--finale-mobile-compact', medium: 'site-footer--finale-mobile-medium', large: 'site-footer--finale-mobile-large' }, + finaleDesktopHeight: { compact: 'site-footer--finale-desktop-compact', half: 'site-footer--finale-desktop-half', screen: 'site-footer--finale-desktop-screen' }, + finaleLogoSize: { sm: 'site-footer--finale-logo-sm', md: 'site-footer--finale-logo-md', lg: 'site-footer--finale-logo-lg', xl: 'site-footer--finale-logo-xl' }, +} as const + +function normalizeFooterLayout(value: StoreSettings['footerLayout']): FooterLayoutSettings { + const result = FooterLayoutSettingsSchema.safeParse(value) + return result.success ? result.data : DEFAULT_FOOTER_LAYOUT_SETTINGS } export default function Footer({ categories = [] }: FooterProps) { @@ -22,14 +46,12 @@ export default function Footer({ categories = [] }: FooterProps) { const [supportMenu, setSupportMenu] = useState([]) const [settings, setSettings] = useState(null) const brandName = settings?.brandName || '' + const lang = localStorage.getItem('app_lang') || 'en' const translateMenuLabel = (item: MenuItemWithTranslations) => { - const lang = localStorage.getItem('app_lang') || 'en' - if (lang !== 'en' && item.translations && item.translations.length > 0) { + if (lang !== 'en' && item.translations?.length) { const trans = item.translations.find((translation) => translation.langCode === lang) - if (trans && trans.label) { - return trans.label - } + if (trans?.label) return trans.label } const map: Record = { @@ -42,7 +64,7 @@ export default function Footer({ categories = [] }: FooterProps) { 'Contact': 'nav.contact', 'Contact Us': 'nav.contact', 'Track Order': 'nav.trackOrder', - 'Delivery & Returns': 'nav.delivery' + 'Delivery & Returns': 'nav.delivery', } return map[item.label] ? t(map[item.label]) : item.label } @@ -51,14 +73,14 @@ export default function Footer({ categories = [] }: FooterProps) { fetch(`${API_BASE}/menus/footer-explore`) .then(r => r.ok ? r.json() : null) .then(data => { - if (data && data.items) setExploreMenu(data.items.sort((a: MenuItem, b: MenuItem) => a.order - b.order)) + if (data?.items) setExploreMenu(data.items.sort((a: MenuItem, b: MenuItem) => a.order - b.order)) }) .catch(console.error) fetch(`${API_BASE}/menus/footer-support`) .then(r => r.ok ? r.json() : null) .then(data => { - if (data && data.items) setSupportMenu(data.items.sort((a: MenuItem, b: MenuItem) => a.order - b.order)) + if (data?.items) setSupportMenu(data.items.sort((a: MenuItem, b: MenuItem) => a.order - b.order)) }) .catch(console.error) @@ -70,109 +92,106 @@ export default function Footer({ categories = [] }: FooterProps) { .catch(console.error) }, []) - const hasBrandFinale = Boolean(settings?.logoUrl || brandName.trim()) + const footerLayout = normalizeFooterLayout(settings?.footerLayout) + const showFinale = footerLayout.finale.enabled && Boolean(settings?.logoUrl || brandName.trim()) + const useCurtain = showFinale && footerLayout.finale.mode === 'curtain' + const footerDescription = settings?.translations?.find((translation) => translation.langCode === lang)?.footerDescription + || settings?.footerDescription + || t('footer.catchphrase') + const footerClasses = [ + 'site-footer text-pop-black', + layoutClassMaps.mobileColumns[footerLayout.mobileColumns], + layoutClassMaps.desktopColumns[footerLayout.desktopColumns], + layoutClassMaps.containerWidth[footerLayout.containerWidth], + layoutClassMaps.verticalPadding[footerLayout.verticalPadding], + layoutClassMaps.columnGap[footerLayout.columnGap], + layoutClassMaps.headingSize[footerLayout.headingSize], + layoutClassMaps.linkSize[footerLayout.linkSize], + layoutClassMaps.brandLogoSize[footerLayout.brandLogoSize], + layoutClassMaps.socialSize[footerLayout.socialSize], + layoutClassMaps.finaleMobileHeight[footerLayout.finale.mobileHeight], + layoutClassMaps.finaleDesktopHeight[footerLayout.finale.desktopHeight], + layoutClassMaps.finaleLogoSize[footerLayout.finale.logoSize], + useCurtain ? 'site-footer--finale-curtain' : 'site-footer--finale-static', + ].join(' ') + + const columns: Record = { + brand: ( +
+
+ {settings?.logoUrl ? {brandName} : brandName} +
+

{footerDescription}

+ {(settings?.facebookUrl || settings?.instagramUrl || settings?.tiktokUrl) && ( +
+ {settings.facebookUrl && FB} + {settings.instagramUrl && IG} + {settings.tiktokUrl && TT} +
+ )} +
+ ), + explore: ( +
+

{t('footer.explore')}

+
+ {exploreMenu.length > 0 ? exploreMenu.map(item => ( + {translateMenuLabel(item)} + )) : ( + <> + {t('nav.home')} + {t('nav.shop')} + {t('nav.scentGuide')} + {t('nav.about')} + {t('nav.contact')} + {t('nav.faq')} + + )} +
+
+ ), + collections: ( +
+

{t('nav.collections')}

+
+ {categories.map((category) => ( + {getLocalizedField(category.name, lang)} + ))} +
+
+ ), + support: ( +
+

+ {getLocalizedField(settings?.footerSupportHeading, lang) || t('footer.support', 'Support & Legal')} +

+
+ {supportMenu.map(item => {translateMenuLabel(item)})} +
+
+

+ {getLocalizedField(settings?.footerCopyrightText, lang) || `© ${new Date().getFullYear()} ${brandName}. ${t('footer.rights')}`} +

+
+
+ ), + } return ( -