385 lines
21 KiB
TypeScript
385 lines
21 KiB
TypeScript
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<string, string> = {};
|
|
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<string, string> = {};
|
|
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" };
|
|
}
|
|
});
|