import { Elysia, t } from "elysia"; import prisma from "../lib/prisma"; import { adminOnly } from "../middleware/auth"; export const settingsRoute = new Elysia({ prefix: "/settings" }) .get("/", async ({ set, query }) => { try { let settings = await prisma.storeSettings.findFirst({ include: { translations: true } }); if (!settings) { settings = await prisma.storeSettings.create({ data: {}, include: { translations: true } }); } const { translations, ...settingsData } = settings; const requestedLang = typeof query?.lang === "string" ? query.lang : undefined; const localizedDescription = requestedLang ? translations.find((translation) => translation.langCode === requestedLang)?.footerDescription : undefined; return localizedDescription ? { ...settingsData, footerDescription: localizedDescription } : settingsData; } catch (err) { console.error("[Settings] Error:", err); set.status = 500; return { error: "Failed to fetch settings" }; } }) /** * Dedicated Footer Brand contract. This keeps the editable Brand block * separate from footer link menus while reusing the existing StoreSettings * columns and StoreSettingsTranslation rows. */ .get("/footer-brand", async ({ set }) => { try { const settings = await prisma.storeSettings.findFirst({ include: { translations: true } }); const base = settings ?? await prisma.storeSettings.create({ data: {}, include: { translations: true } }); const descriptions: Record = {}; for (const translation of base.translations ?? []) { if (translation.footerDescription) descriptions[translation.langCode] = translation.footerDescription; } if (base.footerDescription && !descriptions.th) descriptions.th = base.footerDescription; return { version: base.updatedAt.toISOString(), brand: { brandName: base.brandName, logoUrl: base.logoUrl ?? null, description: descriptions, socialLinks: { facebookUrl: base.facebookUrl ?? null, instagramUrl: base.instagramUrl ?? null, tiktokUrl: base.tiktokUrl ?? null, }, }, }; } catch (err) { console.error("[Settings] Footer brand fetch error:", err); set.status = 500; return { error: "Failed to fetch footer brand" }; } }) .use(adminOnly) .patch("/footer-brand", async ({ body, set }) => { try { const payload = body as any; const settings = await prisma.storeSettings.findFirst({ include: { translations: true } }); const current = settings ?? await prisma.storeSettings.create({ data: {}, include: { translations: true } }); const version = current.updatedAt.toISOString(); if (payload.expectedVersion && payload.expectedVersion !== version) { set.status = 409; return { error: "Footer brand has changed since it was read", code: "VERSION_CONFLICT", expectedVersion: payload.expectedVersion, currentVersion: version }; } const updates: any = {}; if (payload.brandName !== undefined) { if (typeof payload.brandName !== "string" || !payload.brandName.trim()) throw new Error("brandName must be a non-empty string"); updates.brandName = payload.brandName.trim(); } for (const field of ["logoUrl", "facebookUrl", "instagramUrl", "tiktokUrl"]) { if (payload[field] !== undefined) { if (payload[field] !== null && typeof payload[field] !== "string") throw new Error(`${field} must be a string or null`); updates[field] = payload[field]; } } const description = payload.description ?? payload.footerDescription; if (description !== undefined && (typeof description !== "object" || Array.isArray(description))) throw new Error("description must be an object keyed by locale"); if (payload.dryRun) { return { dryRun: true, version, wouldUpdate: { ...updates, description: description ?? undefined } }; } const updated = Object.keys(updates).length ? await prisma.storeSettings.update({ where: { id: current.id }, data: updates }) : current; if (description) { for (const [langCode, value] of Object.entries(description)) { if (typeof langCode !== "string" || typeof value !== "string") throw new Error("description values must be strings"); const existing = await prisma.storeSettingsTranslation.findUnique({ where: { storeSettingsId_langCode: { storeSettingsId: current.id, langCode } } }); if (existing) await prisma.storeSettingsTranslation.update({ where: { id: existing.id }, data: { footerDescription: value } }); else await prisma.storeSettingsTranslation.create({ data: { storeSettingsId: current.id, langCode, footerDescription: value } }); if (langCode === "th") await prisma.storeSettings.update({ where: { id: current.id }, data: { footerDescription: value } }); } } const result = await prisma.storeSettings.findUnique({ where: { id: current.id }, include: { translations: true } }); const descriptions: Record = {}; for (const translation of result?.translations ?? []) if (translation.footerDescription) descriptions[translation.langCode] = translation.footerDescription; if (result?.footerDescription && !descriptions.th) descriptions.th = result.footerDescription; return { success: true, version: result?.updatedAt.toISOString(), brand: { brandName: result?.brandName, logoUrl: result?.logoUrl ?? null, description: descriptions, socialLinks: { facebookUrl: result?.facebookUrl ?? null, instagramUrl: result?.instagramUrl ?? null, tiktokUrl: result?.tiktokUrl ?? null } } }; } catch (err: any) { console.error("[Settings] Footer brand update error:", err); set.status = 400; return { error: err?.message || "Failed to update footer brand" }; } }, { body: t.Any() }) .put("/", async ({ body, set }) => { try { const data = body as any; let settings = await prisma.storeSettings.findFirst(); if (!settings) { settings = await prisma.storeSettings.create({ data }); } else { settings = await prisma.storeSettings.update({ where: { id: settings.id }, data }); } return settings; } catch (err) { console.error("[Settings] Error:", err); set.status = 500; return { error: "Failed to update settings" }; } }, { body: t.Object({ brandName: t.Optional(t.String()), shippingFee: t.Optional(t.Number()), bankName: t.Optional(t.String()), bankAccountName: t.Optional(t.String()), bankAccountNumber: t.Optional(t.String()), pointsEarnRate: t.Optional(t.Number()), logoUrl: t.Optional(t.String()), themePreset: t.Optional(t.String()), themeTokens: t.Optional(t.Any()), facebookUrl: t.Optional(t.String()), instagramUrl: t.Optional(t.String()), tiktokUrl: t.Optional(t.String()), footerDescription: t.Optional(t.String()), newsletterTitle: t.Optional(t.String()), newsletterDescription: t.Optional(t.String()), newsletterPlaceholder: t.Optional(t.String()), newsletterButtonText: t.Optional(t.String()), footerSupportHeading: t.Optional(t.Any()), footerCopyrightText: t.Optional(t.Any()), scrollRevealEnabled: t.Optional(t.Boolean()), scrollRevealStyle: t.Optional(t.String()), scrollRevealDuration: t.Optional(t.Number()), }) }) .patch("/translations", async ({ body, set }) => { try { let settings = await prisma.storeSettings.findFirst(); if (!settings) { settings = await prisma.storeSettings.create({ data: {} }); } const payload = body as any; const results: any[] = []; const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it']; for (const lang of LANGS) { const footerDescription = typeof payload.footerDescription === 'string' ? (lang === 'th' ? payload.footerDescription : null) : payload.footerDescription?.[lang]; const newsletterTitle = typeof payload.newsletterTitle === 'string' ? (lang === 'th' ? payload.newsletterTitle : null) : payload.newsletterTitle?.[lang]; const newsletterDescription = typeof payload.newsletterDescription === 'string' ? (lang === 'th' ? payload.newsletterDescription : null) : payload.newsletterDescription?.[lang]; const newsletterPlaceholder = typeof payload.newsletterPlaceholder === 'string' ? (lang === 'th' ? payload.newsletterPlaceholder : null) : payload.newsletterPlaceholder?.[lang]; const newsletterButtonText = typeof payload.newsletterButtonText === 'string' ? (lang === 'th' ? payload.newsletterButtonText : null) : payload.newsletterButtonText?.[lang]; if (!footerDescription && !newsletterTitle && !newsletterDescription && !newsletterPlaceholder && !newsletterButtonText) continue; const existing = await prisma.storeSettingsTranslation.findUnique({ where: { storeSettingsId_langCode: { storeSettingsId: settings.id, langCode: lang } } }); const updateData: any = {}; if (footerDescription) updateData.footerDescription = footerDescription; if (newsletterTitle) updateData.newsletterTitle = newsletterTitle; if (newsletterDescription) updateData.newsletterDescription = newsletterDescription; if (newsletterPlaceholder) updateData.newsletterPlaceholder = newsletterPlaceholder; if (newsletterButtonText) updateData.newsletterButtonText = newsletterButtonText; if (existing) { const updated = await prisma.storeSettingsTranslation.update({ where: { id: existing.id }, data: updateData }); results.push(updated); } else { const created = await prisma.storeSettingsTranslation.create({ data: { storeSettingsId: settings.id, langCode: lang, ...updateData } }); results.push(created); } } const updatedSettings = await prisma.storeSettings.findFirst({ include: { translations: true } }); return { success: true, translations: results, settings: updatedSettings }; } catch (err) { console.error("[Settings] Translation Error:", err); set.status = 500; return { error: "Failed to update settings translation" }; } }) .post("/seed-pages", async ({ set }) => { type SectionInput = { type: string; order: number; isActive: boolean; payload: object }; type PageDef = { slug: string; title: string; sections: SectionInput[] }; const ANNOUNCEMENT = (slides: string[]) => ({ type: "ANNOUNCEMENT_BAR", order: 1, isActive: true, payload: { slides, backgroundColor: "#1F3D2D", textColor: "#FDFDF9", speed: 3500 } }); const NEWSLETTER = (title: string, description: string, order = 2) => ({ type: "NEWSLETTER", order, isActive: true, payload: { title, description, buttonText: "Subscribe", placeholderText: "Enter your email address", showPrivacyCheckbox: true, privacyText: "I agree to the privacy policy." } }); const EDITORIAL = (title: string, description: string, imagePosition: string, buttonText: string, buttonLink: string, order = 2) => ({ type: "EDITORIAL_TILE", order, isActive: true, payload: { title, description, imageUrl: "/images/oil_ozone.png", imagePosition, buttonText, buttonLink, backgroundColor: "#FDFDF9", textColor: "#1F3D2D" } }); const PAGES: PageDef[] = [ { slug: "home", title: "Home Page", sections: [ { type: "HERO", order: 1, isActive: true, payload: { tagline: "VIBRANT ELEGANCE COLLECTION", title: "Inhale [Serenity]", description: "Crafting premium sensory experiences that transform your space into a sanctuary of peace.", imageUrl: "/images/hero.png", buttonText: "Explore Collection", buttonLink: "/shop" } }, { type: "CATEGORIES", order: 2, isActive: true, payload: { title: "Our Collections", subtitle: "Discover the perfect scent for every moment" } }, { type: "PRODUCTS", order: 3, isActive: true, payload: { title: "Featured Picks", subtitle: "Hand-picked fragrances recommended just for you", type: "featured", limit: 3 } }, { type: "BANNER", order: 4, isActive: true, payload: { imageUrl: "/images/oil_ozone.png", linkUrl: "/category/1", title: "Special Offer", description: "Get 10% off on your first purchase of Oil Ozone collection", buttonText: "Shop Now" } }, { type: "TEXT", order: 5, isActive: true, payload: { title: "About HomAroma", content: "HomAroma is dedicated to sourcing the finest raw materials to formulate high-quality essential oils, reed diffusers, and room sprays.", textAlign: "center" } }, ] }, { slug: "shop-layout", title: "Shop Page Layout", sections: [ ANNOUNCEMENT(["[FREE SHIPPING] on orders over ฿1,500", "New Premium Collection is out now!"]), { type: "HERO", order: 2, isActive: true, payload: { tagline: "CURATED BOTANICALS", title: "The [Shop] Collection", description: "Transform your daily space into a luxury escape with our natural aroma range.", imageUrl: "/images/hero.png", buttonText: "Learn More", buttonLink: "/about" } }, EDITORIAL("Crafted with [Sensory Passion]", "Every formulation at HomAroma is a tribute to pure botanical essences, hand-blended to capture nature's quiet intelligence.", "right", "Read Our Story", "/about", 3), NEWSLETTER("Begin Your Wellness Journey", "Subscribe to receive 10% off your first purchase and exclusive updates.", 4), ] }, { slug: "scent-guide-layout", title: "Scent Guide Layout", sections: [ ANNOUNCEMENT(["Explore our Scent Guide to find your signature fragrance", "Read wellness articles crafted by experts"]), { type: "HERO", order: 2, isActive: true, payload: { tagline: "JOURNAL & INSIGHTS", title: "The [Scent] Guide", description: "Welcome to the sensory archive of HomAroma. Explore the art and science of botanical aromatherapy.", imageUrl: "/images/hero.png", buttonText: "Read Story", buttonLink: "/about" } }, NEWSLETTER("Stay Inspired", "Subscribe to our journal and get regular wellness guides, blending tips, and exclusive offers.", 3), ] }, { slug: "category-layout", title: "Category Page Layout", sections: [ ANNOUNCEMENT(["Explore our specialized collections for your sanctuary", "Free premium shipping on orders over ฿1,500"]), NEWSLETTER("Join [HomAroma] Circle", "Subscribe to receive announcements of new single-origin botanical releases."), ] }, { slug: "product-detail-layout", title: "Product Detail Layout", sections: [ ANNOUNCEMENT(["Special Offer: Get 10% off your first purchase of single-origin oils", "Hand-formulated with pure botanical extracts"]), { type: "PRODUCT_MAIN", order: 2, isActive: true, payload: {} }, EDITORIAL("The [Botanical] Sourcing", "All our raw materials are sustainably harvested at peak potency from verified organic micro-farms worldwide.", "left", "Sourcing Map", "/about", 3), { type: "PRODUCT_DETAILS", order: 4, isActive: true, payload: { title: "Product Details", showHeading: true } }, { type: "PRODUCT_SPECS", order: 5, isActive: true, payload: { title: "Specifications", showHeading: true } }, { type: "RELATED_PRODUCTS", order: 6, isActive: true, payload: { title: "Related Products", showHeading: true, showViewAllLink: true } }, ] }, { slug: "faq-layout", title: "FAQ Page Layout", sections: [ ANNOUNCEMENT(["Need instant help? Reach out on LINE @homaroma", "FAQ updated for Summer 2026 delivery guidelines"]), NEWSLETTER("Stay informed with [HomAroma]", "Sign up to receive shipping updates, care guides, and exclusive collection invites."), ] }, { slug: "wishlist-layout", title: "Wishlist Page Layout", sections: [ ANNOUNCEMENT(["Sign up to HomAroma Circle to get 10% off your wishlist items", "Hand-crafted botanicals shipped directly to you"]), NEWSLETTER("Don't lose your favorites", "Subscribe to receive price drops, back-in-stock alerts, and exclusive discounts on items in your wishlist."), ] }, { slug: "search-layout", title: "Search Page Layout", sections: [ ANNOUNCEMENT(["Need help finding something? Live Chat with our scent experts on LINE @homaroma"]), EDITORIAL("Find Your [Signature Scent]", "Aromatherapy is the art of tuning your environment. Discover our curation of room sprays, reed diffusers, and essential oils designed for modern sanctuaries.", "right", "Explore Guide", "/scent-guide"), ] }, { slug: "coupons-layout", title: "Coupons Page Layout", sections: [ ANNOUNCEMENT(["Enjoy limited-time discount codes for your sensory sanctuary"]), NEWSLETTER("Get Exclusive Secret Codes", "Become a subscriber to get weekly private discount codes and early access to new collections."), ] }, { slug: "contact-layout", title: "Contact Page Layout", sections: [ ANNOUNCEMENT(["Customer support is available Mon-Sat, 9:00 - 18:00 on LINE @homaroma"]), EDITORIAL("Our [Bangkok Studio]", "Visit our flagship boutique to experience custom blending sessions and smell our full botanical library in person.", "left", "Book Session", "/about"), ] }, { slug: "order-success-layout", title: "Order Success Page Layout", sections: [ ANNOUNCEMENT(["Thank you for shopping with HomAroma! Your package is preparing.", "Your order tracking status will be updated here."]), EDITORIAL("Crafting Your [Sanctuary]", "Every order is carefully packaged by hand using sustainable, biodegradable materials. We ensure your botanicals arrive fresh and ready to enrich your space.", "right", "Our Sustainability Commitments", "/about"), ] }, { slug: "track-order-layout", title: "Track Order Page Layout", sections: [ ANNOUNCEMENT(["Check shipping status live. For any delivery queries, reach LINE @homaroma"]), EDITORIAL("Scenting Your [Daily Rituals]", "While waiting for your order, explore how different botanical notes impact focus, sleep, and emotional balance in our Scent Guide.", "left", "Read Scent Guide", "/scent-guide"), ] }, { slug: "checkout-layout", title: "Checkout Page Layout", sections: [ ANNOUNCEMENT(["Secure checkout — all payments are encrypted and protected", "Free shipping on orders over ฿1,500"]), EDITORIAL("Crafted for Your [Sanctuary]", "Every HomAroma order is hand-packed with care using eco-conscious materials, so your botanicals arrive in perfect condition.", "right", "Our Sustainability Promise", "/about"), ] }, { slug: "profile-layout", title: "Profile Page Layout", sections: [ ANNOUNCEMENT(["Welcome back to HomAroma — manage your orders and wishlist from your profile", "Earn points with every purchase"]), NEWSLETTER("Exclusive Member Benefits", "Subscribe to receive early access to new collections, member-only discounts, and scent guides curated just for you."), ] }, ]; try { const results: { slug: string; status: "created" | "exists"; sectionsCreated: number }[] = []; for (const pageDef of PAGES) { let page = await prisma.page.findUnique({ where: { slug: pageDef.slug } }); let sectionsCreated = 0; if (!page) { page = await prisma.page.create({ data: { slug: pageDef.slug, title: pageDef.title, isPublished: true } }); // New page — create all sections for (const section of pageDef.sections) { await prisma.pageSection.create({ data: { pageId: page.id, ...section } }); sectionsCreated++; } results.push({ slug: pageDef.slug, status: "created", sectionsCreated }); } else { // Existing page — only fill sections if completely empty const existingCount = await prisma.pageSection.count({ where: { pageId: page.id } }); if (existingCount === 0) { for (const section of pageDef.sections) { await prisma.pageSection.create({ data: { pageId: page.id, ...section } }); sectionsCreated++; } } results.push({ slug: pageDef.slug, status: "exists", sectionsCreated }); } } const created = results.filter(r => r.status === "created").length; const existed = results.filter(r => r.status === "exists").length; const totalSections = results.reduce((sum, r) => sum + r.sectionsCreated, 0); return { success: true, message: `Seed complete: ${created} pages created, ${existed} pages already existed, ${totalSections} sections created.`, results }; } catch (err) { console.error("[Settings] Seed Pages Error:", err); set.status = 500; return { error: "Failed to seed pages" }; } });