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