This commit is contained in:
Your Name committed 2026-06-07 17:17:01 +07:00
1 parent d325cd8763
commit c6bf2f70ac
45 files changed
+5195 -338

No files matched your search

@@ -0,0 +1,61 @@
-- AlterTable
ALTER TABLE "Customer" ADD COLUMN "points" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Order" ADD COLUMN "pointsDiscount" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN "pointsEarned" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "pointsUsed" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "promoLevel1Disc" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN "promoLevel1Name" TEXT,
ADD COLUMN "promoLevel2Disc" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN "promoLevel2Name" TEXT,
ADD COLUMN "subtotal" DOUBLE PRECISION NOT NULL DEFAULT 0,
ADD COLUMN "trackingNo" TEXT;
-- AlterTable
ALTER TABLE "StoreSettings" ADD COLUMN "pointsEarnRate" DOUBLE PRECISION NOT NULL DEFAULT 1.0,
ALTER COLUMN "bankAccountName" SET DEFAULT 'HomAroma Co., Ltd.';
-- CreateTable
CREATE TABLE "Promotion" (
"id" SERIAL NOT NULL,
"name" TEXT NOT NULL,
"level" INTEGER NOT NULL,
"type" TEXT NOT NULL,
"value" DOUBLE PRECISION NOT NULL,
"minPurchase" DOUBLE PRECISION NOT NULL DEFAULT 0,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"startDate" TIMESTAMP(3),
"endDate" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Promotion_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Review" (
"id" SERIAL NOT NULL,
"rating" INTEGER NOT NULL DEFAULT 5,
"comment" TEXT,
"productId" INTEGER NOT NULL,
"customerId" INTEGER,
"customerName" TEXT,
"isPublished" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Review_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Review_productId_idx" ON "Review"("productId");
-- CreateIndex
CREATE INDEX "Review_customerId_idx" ON "Review"("customerId");
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Review" ADD CONSTRAINT "Review_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,31 @@
-- CreateTable
CREATE TABLE "Page" (
"id" SERIAL NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"isPublished" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Page_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PageSection" (
"id" SERIAL NOT NULL,
"pageId" INTEGER NOT NULL,
"type" TEXT NOT NULL,
"order" INTEGER NOT NULL,
"payload" JSONB NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PageSection_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Page_slug_key" ON "Page"("slug");
-- AddForeignKey
ALTER TABLE "PageSection" ADD CONSTRAINT "PageSection_pageId_fkey" FOREIGN KEY ("pageId") REFERENCES "Page"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "NewsletterSubscription" (
"id" SERIAL NOT NULL,
"email" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "NewsletterSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "NewsletterSubscription_email_key" ON "NewsletterSubscription"("email");
+31
View File
@@ -165,6 +165,7 @@ model Order {
totalAmount Float
status String @default("PENDING_PAYMENT")
paymentSlipUrl String?
trackingNo String?
customerId Int?
customer Customer? @relation(fields: [customerId], references: [id])
@@ -420,3 +421,33 @@ model Review {
@@index([productId])
@@index([customerId])
}
model Page {
id Int @id @default(autoincrement())
slug String @unique // e.g., "home", "about-us"
title String
sections PageSection[]
isPublished Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model PageSection {
id Int @id @default(autoincrement())
pageId Int
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
type String // e.g., "HERO", "CATEGORIES", "PRODUCTS", "BANNER", "TEXT"
order Int // Display order
payload Json // Dynamic JSON payload
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model NewsletterSubscription {
id Int @id @default(autoincrement())
email String @unique
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+712
View File
@@ -0,0 +1,712 @@
import { PrismaClient } from '@prisma/client';
import { Pool } from 'pg';
import { PrismaPg } from '@prisma/adapter-pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
console.log("🌱 Seeding Dynamic Page Builder...");
// 1. Create the HomePage
const page = await prisma.page.upsert({
where: { slug: "home" },
update: {},
create: {
slug: "home",
title: "Home Page",
isPublished: true,
}
});
console.log(`✅ Created page: ${page.title} (slug: ${page.slug})`);
// Clear existing sections for home page so we don't duplicate
await prisma.pageSection.deleteMany({
where: { pageId: page.id }
});
// 2. Create default sections: HERO, CATEGORIES, PRODUCTS, BANNER, TEXT
// Section 1: Hero
await prisma.pageSection.create({
data: {
pageId: page.id,
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"
}
}
});
// Section 2: Categories
await prisma.pageSection.create({
data: {
pageId: page.id,
type: "CATEGORIES",
order: 2,
isActive: true,
payload: {
title: "Our Collections",
subtitle: "Discover the perfect scent for every moment"
}
}
});
// Section 3: Products (Featured Picks)
await prisma.pageSection.create({
data: {
pageId: page.id,
type: "PRODUCTS",
order: 3,
isActive: true,
payload: {
title: "Featured Picks",
subtitle: "Hand-picked fragrances recommended just for you",
type: "featured",
limit: 3
}
}
});
// Section 4: Banner
await prisma.pageSection.create({
data: {
pageId: page.id,
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"
}
}
});
// Section 5: Text info
await prisma.pageSection.create({
data: {
pageId: page.id,
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. Our goal is to bring nature's purity directly into your life.",
textAlign: "center"
}
}
});
console.log("✅ Seeded 5 default home page sections successfully!");
// 3. Create the Shop Page Layout
const shopPage = await prisma.page.upsert({
where: { slug: "shop-layout" },
update: {},
create: {
slug: "shop-layout",
title: "Shop Page Layout",
isPublished: true,
}
});
console.log(`✅ Created page: ${shopPage.title} (slug: ${shopPage.slug})`);
// Clear existing sections for shop page so we don't duplicate
await prisma.pageSection.deleteMany({
where: { pageId: shopPage.id }
});
// Shop Section 1: Announcement Bar
await prisma.pageSection.create({
data: {
pageId: shopPage.id,
type: "ANNOUNCEMENT_BAR",
order: 1,
isActive: true,
payload: {
slides: ["[FREE SHIPPING] on orders over ฿1,500", "New Premium Collection is out now!"],
backgroundColor: "#1F3D2D",
textColor: "#FDFDF9",
speed: 3000
}
}
});
// Shop Section 2: Hero Banner (Set to active by default to showcase the header capabilities)
await prisma.pageSection.create({
data: {
pageId: shopPage.id,
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"
}
}
});
// Shop Section 3: Editorial Tile
await prisma.pageSection.create({
data: {
pageId: shopPage.id,
type: "EDITORIAL_TILE",
order: 3,
isActive: true,
payload: {
title: "Crafted with [Sensory Passion]",
description: "Every formulation at HomAroma is a tribute to pure botanical essences, hand-blended to capture nature’s quiet intelligence.",
imageUrl: "/images/oil_ozone.png",
imagePosition: "right",
buttonText: "Read Our Story",
buttonLink: "/about",
backgroundColor: "#FDFDF9",
textColor: "#1F3D2D"
}
}
});
// Shop Section 4: Newsletter
await prisma.pageSection.create({
data: {
pageId: shopPage.id,
type: "NEWSLETTER",
order: 4,
isActive: true,
payload: {
title: "Begin Your Wellness Journey",
description: "Subscribe to receive 10% off your first purchase and exclusive updates.",
buttonText: "Subscribe",
placeholderText: "Enter your email address",
showPrivacyCheckbox: true,
privacyText: "I agree to the privacy policy."
}
}
});
console.log("✅ Seeded default shop-layout page sections successfully!");
// 4. Create the Scent Guide Page Layout
const scentGuidePage = await prisma.page.upsert({
where: { slug: "scent-guide-layout" },
update: {},
create: {
slug: "scent-guide-layout",
title: "Scent Guide Layout",
isPublished: true,
}
});
console.log(`✅ Created page: ${scentGuidePage.title} (slug: ${scentGuidePage.slug})`);
// Clear existing sections for scent guide page so we don't duplicate
await prisma.pageSection.deleteMany({
where: { pageId: scentGuidePage.id }
});
// Scent Guide Section 1: Announcement Bar
await prisma.pageSection.create({
data: {
pageId: scentGuidePage.id,
type: "ANNOUNCEMENT_BAR",
order: 1,
isActive: true,
payload: {
slides: ["Explore our Scent Guide to find your signature fragrance", "Read wellness articles crafted by experts"],
backgroundColor: "#1F3D2D",
textColor: "#FDFDF9",
speed: 3000
}
}
});
// Scent Guide Section 2: Hero Banner
await prisma.pageSection.create({
data: {
pageId: scentGuidePage.id,
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"
}
}
});
// Scent Guide Section 3: Newsletter
await prisma.pageSection.create({
data: {
pageId: scentGuidePage.id,
type: "NEWSLETTER",
order: 3,
isActive: true,
payload: {
title: "Stay Inspired",
description: "Subscribe to our journal and get regular wellness guides, blending tips, and exclusive offers.",
buttonText: "Subscribe",
placeholderText: "Enter your email address",
showPrivacyCheckbox: true,
privacyText: "I agree to the privacy policy."
}
}
});
console.log("✅ Seeded default scent-guide-layout page sections successfully!");
// 5. Create Category Page Layout
const categoryPage = await prisma.page.upsert({
where: { slug: "category-layout" },
update: {},
create: {
slug: "category-layout",
title: "Category Page Layout",
isPublished: true,
}
});
console.log(`✅ Created page: ${categoryPage.title} (slug: ${categoryPage.slug})`);
await prisma.pageSection.deleteMany({
where: { pageId: categoryPage.id }
});
// Announcement Bar for Categories
await prisma.pageSection.create({
data: {
pageId: categoryPage.id,
type: "ANNOUNCEMENT_BAR",
order: 1,
isActive: true,
payload: {
slides: ["Explore our specialized collections for your sanctuary", "Free premium shipping on orders over ฿1,500"],
backgroundColor: "#1F3D2D",
textColor: "#FDFDF9",
speed: 3000
}
}
});
// Newsletter at the bottom of Categories
await prisma.pageSection.create({
data: {
pageId: categoryPage.id,
type: "NEWSLETTER",
order: 2,
isActive: true,
payload: {
title: "Join [HomAroma] Circle",
description: "Subscribe to receive announcements of new single-origin botanical releases.",
buttonText: "Subscribe",
placeholderText: "Enter your email address",
showPrivacyCheckbox: true,
privacyText: "I agree to the privacy policy."
}
}
});
// 6. Create Product Detail Page Layout
const productDetailPage = await prisma.page.upsert({
where: { slug: "product-detail-layout" },
update: {},
create: {
slug: "product-detail-layout",
title: "Product Detail Layout",
isPublished: true,
}
});
console.log(`✅ Created page: ${productDetailPage.title} (slug: ${productDetailPage.slug})`);
await prisma.pageSection.deleteMany({
where: { pageId: productDetailPage.id }
});
// Announcement Bar for Product Details
await prisma.pageSection.create({
data: {
pageId: productDetailPage.id,
type: "ANNOUNCEMENT_BAR",
order: 1,
isActive: true,
payload: {
slides: ["Special Offer: Get 10% off your first purchase of single-origin oils", "Hand-formulated with pure botanical extracts"],
backgroundColor: "#1F3D2D",
textColor: "#FDFDF9",
speed: 3000
}
}
});
// Editorial banner at the bottom of Product Details
await prisma.pageSection.create({
data: {
pageId: productDetailPage.id,
type: "EDITORIAL_TILE",
order: 2,
isActive: true,
payload: {
title: "The [Botanical] Sourcing",
description: "All our raw materials are sustainably harvested at peak potency from verified organic micro-farms worldwide.",
imageUrl: "/images/oil_ozone.png",
imagePosition: "left",
buttonText: "Sourcing Map",
buttonLink: "/about",
backgroundColor: "#FDFDF9",
textColor: "#1F3D2D"
}
}
});
// 7. Create FAQ Page Layout
const faqPage = await prisma.page.upsert({
where: { slug: "faq-layout" },
update: {},
create: {
slug: "faq-layout",
title: "FAQ Page Layout",
isPublished: true,
}
});
console.log(`✅ Created page: ${faqPage.title} (slug: ${faqPage.slug})`);
await prisma.pageSection.deleteMany({
where: { pageId: faqPage.id }
});
// Announcement Bar for FAQ
await prisma.pageSection.create({
data: {
pageId: faqPage.id,
type: "ANNOUNCEMENT_BAR",
order: 1,
isActive: true,
payload: {
slides: ["Need instant help? Reach out on LINE @homaroma", "FAQ updated for Summer 2026 delivery guidelines"],
backgroundColor: "#1F3D2D",
textColor: "#FDFDF9",
speed: 4000
}
}
});
// Newsletter at the bottom of FAQ
await prisma.pageSection.create({
data: {
pageId: faqPage.id,
type: "NEWSLETTER",
order: 2,
isActive: true,
payload: {
title: "Stay informed with [HomAroma]",
description: "Sign up to receive shipping updates, care guides, and exclusive collection invites.",
buttonText: "Subscribe",
placeholderText: "Enter your email address",
showPrivacyCheckbox: true,
privacyText: "I agree to the privacy policy."
}
}
});
console.log("✅ Seeded default category, product-detail, and FAQ layout sections successfully!");
// 8. Create Wishlist Page Layout
const wishlistPage = await prisma.page.upsert({
where: { slug: "wishlist-layout" },
update: {},
create: {
slug: "wishlist-layout",
title: "Wishlist Page Layout",
isPublished: true,
}
});
console.log(`✅ Created page: ${wishlistPage.title} (slug: ${wishlistPage.slug})`);
await prisma.pageSection.deleteMany({ where: { pageId: wishlistPage.id } });
await prisma.pageSection.create({
data: {
pageId: wishlistPage.id,
type: "ANNOUNCEMENT_BAR",
order: 1,
isActive: true,
payload: {
slides: ["Sign up to HomAroma Circle to get 10% off your wishlist items", "Hand-crafted botanicals shipped directly to you"],
backgroundColor: "#1F3D2D",
textColor: "#FDFDF9",
speed: 3000
}
}
});
await prisma.pageSection.create({
data: {
pageId: wishlistPage.id,
type: "NEWSLETTER",
order: 2,
isActive: true,
payload: {
title: "Don't lose your favorites",
description: "Subscribe to receive price drops, back-in-stock alerts, and exclusive discounts on items in your wishlist.",
buttonText: "Subscribe",
placeholderText: "Enter your email address",
showPrivacyCheckbox: true,
privacyText: "I agree to the privacy policy."
}
}
});
// 9. Create Search Page Layout
const searchPage = await prisma.page.upsert({
where: { slug: "search-layout" },
update: {},
create: {
slug: "search-layout",
title: "Search Page Layout",
isPublished: true,
}
});
console.log(`✅ Created page: ${searchPage.title} (slug: ${searchPage.slug})`);
await prisma.pageSection.deleteMany({ where: { pageId: searchPage.id } });
await prisma.pageSection.create({
data: {
pageId: searchPage.id,
type: "ANNOUNCEMENT_BAR",
order: 1,
isActive: true,
payload: {
slides: ["Need help finding something? Live Chat with our scent experts on LINE @homaroma"],
backgroundColor: "#1F3D2D",
textColor: "#FDFDF9",
speed: 3000
}
}
});
await prisma.pageSection.create({
data: {
pageId: searchPage.id,
type: "EDITORIAL_TILE",
order: 2,
isActive: true,
payload: {
title: "Find Your [Signature Scent]",
description: "Aromatherapy is the art of tuning your environment. Discover our curation of room sprays, reed diffusers, and essential oils designed for modern sanctuaries.",
imageUrl: "/images/hero.png",
imagePosition: "right",
buttonText: "Explore Guide",
buttonLink: "/scent-guide",
backgroundColor: "#FDFDF9",
textColor: "#1F3D2D"
}
}
});
// 10. Create Coupons Page Layout
const couponsPage = await prisma.page.upsert({
where: { slug: "coupons-layout" },
update: {},
create: {
slug: "coupons-layout",
title: "Coupons Page Layout",
isPublished: true,
}
});
console.log(`✅ Created page: ${couponsPage.title} (slug: ${couponsPage.slug})`);
await prisma.pageSection.deleteMany({ where: { pageId: couponsPage.id } });
await prisma.pageSection.create({
data: {
pageId: couponsPage.id,
type: "ANNOUNCEMENT_BAR",
order: 1,
isActive: true,
payload: {
slides: ["Enjoy limited-time discount codes for your sensory sanctuary"],
backgroundColor: "#1F3D2D",
textColor: "#FDFDF9",
speed: 3000
}
}
});
await prisma.pageSection.create({
data: {
pageId: couponsPage.id,
type: "NEWSLETTER",
order: 2,
isActive: true,
payload: {
title: "Get Exclusive Secret Codes",
description: "Become a subscriber to get weekly private discount codes and early access to new collections.",
buttonText: "Subscribe",
placeholderText: "Enter your email address",
showPrivacyCheckbox: true,
privacyText: "I agree to the privacy policy."
}
}
});
// 11. Create Contact Page Layout
const contactPage = await prisma.page.upsert({
where: { slug: "contact-layout" },
update: {},
create: {
slug: "contact-layout",
title: "Contact Page Layout",
isPublished: true,
}
});
console.log(`✅ Created page: ${contactPage.title} (slug: ${contactPage.slug})`);
await prisma.pageSection.deleteMany({ where: { pageId: contactPage.id } });
await prisma.pageSection.create({
data: {
pageId: contactPage.id,
type: "ANNOUNCEMENT_BAR",
order: 1,
isActive: true,
payload: {
slides: ["Customer support is available Mon-Sat, 9:00 - 18:00 on LINE @homaroma"],
backgroundColor: "#1F3D2D",
textColor: "#FDFDF9",
speed: 4000
}
}
});
await prisma.pageSection.create({
data: {
pageId: contactPage.id,
type: "EDITORIAL_TILE",
order: 2,
isActive: true,
payload: {
title: "Our [Bangkok Studio]",
description: "Visit our flagship boutique to experience custom blending sessions and smell our full botanical library in person.",
imageUrl: "/images/hero.png",
imagePosition: "left",
buttonText: "Book Session",
buttonLink: "/about",
backgroundColor: "#FDFDF9",
textColor: "#1F3D2D"
}
}
});
// 12. Create Order Success Page Layout
const orderSuccessPage = await prisma.page.upsert({
where: { slug: "order-success-layout" },
update: {},
create: {
slug: "order-success-layout",
title: "Order Success Page Layout",
isPublished: true,
}
});
console.log(`✅ Created page: ${orderSuccessPage.title} (slug: ${orderSuccessPage.slug})`);
await prisma.pageSection.deleteMany({ where: { pageId: orderSuccessPage.id } });
await prisma.pageSection.create({
data: {
pageId: orderSuccessPage.id,
type: "ANNOUNCEMENT_BAR",
order: 1,
isActive: true,
payload: {
slides: ["Thank you for shopping with HomAroma! Your package is preparing.", "Your order tracking status will be updated here."],
backgroundColor: "#1F3D2D",
textColor: "#FDFDF9",
speed: 3000
}
}
});
await prisma.pageSection.create({
data: {
pageId: orderSuccessPage.id,
type: "EDITORIAL_TILE",
order: 2,
isActive: true,
payload: {
title: "Crafting Your [Sanctuary]",
description: "Every order is carefully packaged by hand using sustainable, biodegradable materials. We ensure your botanicals arrive fresh and ready to enrich your space.",
imageUrl: "/images/oil_ozone.png",
imagePosition: "right",
buttonText: "Our Sustainability Commitments",
buttonLink: "/about",
backgroundColor: "#FDFDF9",
textColor: "#1F3D2D"
}
}
});
// 13. Create Track Order Page Layout
const trackOrderPage = await prisma.page.upsert({
where: { slug: "track-order-layout" },
update: {},
create: {
slug: "track-order-layout",
title: "Track Order Page Layout",
isPublished: true,
}
});
console.log(`✅ Created page: ${trackOrderPage.title} (slug: ${trackOrderPage.slug})`);
await prisma.pageSection.deleteMany({ where: { pageId: trackOrderPage.id } });
await prisma.pageSection.create({
data: {
pageId: trackOrderPage.id,
type: "ANNOUNCEMENT_BAR",
order: 1,
isActive: true,
payload: {
slides: ["Check shipping status live. For any delivery queries, reach LINE @homaroma"],
backgroundColor: "#1F3D2D",
textColor: "#FDFDF9",
speed: 3000
}
}
});
await prisma.pageSection.create({
data: {
pageId: trackOrderPage.id,
type: "EDITORIAL_TILE",
order: 2,
isActive: true,
payload: {
title: "Scenting Your [Daily Rituals]",
description: "While waiting for your order, explore how different botanical notes impact focus, sleep, and emotional balance in our Scent Guide.",
imageUrl: "/images/hero.png",
imagePosition: "left",
buttonText: "Read Scent Guide",
buttonLink: "/scent-guide",
backgroundColor: "#FDFDF9",
textColor: "#1F3D2D"
}
}
});
console.log("✅ Seeded default Category, Product Detail, FAQ, Wishlist, Search, Coupons, Contact, Order Success, and Track Order layouts successfully!");
}
main()
.catch((e) => {
console.error("❌ Error seeding page:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await pool.end();
});
+2 -1
View File
@@ -20,6 +20,7 @@ import { customersRoute } from "./routes/customers";
import { couponsRoute } from "./routes/coupons";
import { reviewsRoute } from "./routes/reviews";
import { promotionsRoute } from "./routes/promotions";
import { pagesRoute } from "./routes/pages";
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
@@ -83,7 +84,7 @@ const app = new Elysia()
return file;
})
.group("/api", (app) =>
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute)
app.use(authRoute).use(categoriesRoute).use(productsRoute).use(uploadRoute).use(heroRoute).use(ordersRoute).use(settingsRoute).use(articlesRoute).use(tiktokRoute).use(rawMaterialsRoute).use(shopeeRoute).use(tiktokOrderRoute).use(lazadaOrderRoute).use(customersRoute).use(couponsRoute).use(reviewsRoute).use(promotionsRoute).use(pagesRoute)
)
.listen(PORT);
+46 -2
View File
@@ -1,9 +1,21 @@
import { Elysia } from "elysia";
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
export const customersRoute = new Elysia({ prefix: "/customers" })
.get("/", async () => {
.get("/", async ({ query }) => {
const search = query.search as string | undefined;
const whereClause = search ? {
OR: [
{ displayName: { contains: search, mode: "insensitive" as any } },
{ email: { contains: search, mode: "insensitive" as any } },
{ phone: { contains: search, mode: "insensitive" as any } },
{ lineUserId: { contains: search, mode: "insensitive" as any } }
]
} : {};
return await prisma.customer.findMany({
where: whereClause,
orderBy: { createdAt: 'desc' },
include: {
_count: {
@@ -22,4 +34,36 @@ export const customersRoute = new Elysia({ prefix: "/customers" })
return { error: "Customer not found" };
}
return customer;
})
.patch("/:id/points", async ({ params: { id }, body, set }) => {
try {
const { pointsDelta, reason } = body as { pointsDelta: number, reason?: string };
const customer = await prisma.customer.findUnique({
where: { id: Number(id) }
});
if (!customer) {
set.status = 404;
return { error: "Customer not found" };
}
const newPoints = Math.max(0, customer.points + pointsDelta);
const updatedCustomer = await prisma.customer.update({
where: { id: Number(id) },
data: { points: newPoints }
});
return updatedCustomer;
} catch (err) {
console.error("[Customers] Adjust Points Error:", err);
set.status = 500;
return { error: "Failed to adjust points" };
}
}, {
body: t.Object({
pointsDelta: t.Number(),
reason: t.Optional(t.String())
})
});
+17
View File
@@ -176,6 +176,23 @@ export const ordersRoute = new Elysia({ prefix: "/orders" })
paymentSlipUrl: t.String()
})
})
.patch("/:id/tracking", async ({ params: { id }, body, set }) => {
try {
const { trackingNo } = body as any;
return await prisma.order.update({
where: { id: Number(id) },
data: { trackingNo },
include: { items: true }
});
} catch (err) {
set.status = 500;
return { error: "Failed to update tracking number" };
}
}, {
body: t.Object({
trackingNo: t.String()
})
})
.get("/customer/:customerId", async ({ params: { customerId } }) => {
return await prisma.order.findMany({
where: { customerId: Number(customerId) },
+381
View File
@@ -0,0 +1,381 @@
import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
export const pagesRoute = new Elysia({ prefix: "/pages" })
.get("/", async () => {
return await prisma.page.findMany({
include: {
sections: {
orderBy: { order: "asc" }
}
}
});
})
.get("/:slug", async ({ params: { slug }, set }) => {
const page = await prisma.page.findUnique({
where: { slug },
include: {
sections: {
where: { isActive: true },
orderBy: { order: "asc" }
}
}
});
if (!page) {
set.status = 404;
return { error: "Page not found" };
}
const populatedSections = await Promise.all(
page.sections.map(async (section) => {
const payload = section.payload as any;
if (!payload) return section;
const populatedPayload = { ...payload };
if (section.type === 'PRODUCTS') {
let products: any[] = [];
const limit = payload.limit || 3;
const productInclude = {
category: true,
scent: true,
variants: {
include: {
bomItems: { include: { rawMaterial: true } }
},
orderBy: { price: "asc" as const }
},
images: {
orderBy: { order: "asc" as const }
}
};
if (payload.type === 'featured') {
products = await prisma.product.findMany({
where: { categoryId: 1 },
include: productInclude,
take: limit
});
} else if (payload.type === 'scent' && payload.scentId) {
products = await prisma.product.findMany({
where: { scentId: Number(payload.scentId) },
include: productInclude,
take: limit
});
} else if (payload.type === 'custom' && Array.isArray(payload.productIds)) {
const ids = payload.productIds.map(Number);
products = await prisma.product.findMany({
where: { id: { in: ids } },
include: productInclude
});
products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
}
populatedPayload.products = products;
} else if (section.type === 'CATEGORIES') {
let categories: any[] = [];
if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) {
const ids = payload.categoryIds.map(Number);
categories = await prisma.category.findMany({
where: { id: { in: ids } },
include: {
_count: {
select: { products: true }
}
}
});
categories.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
} else {
categories = await prisma.category.findMany({
include: {
_count: {
select: { products: true }
}
}
});
}
populatedPayload.categories = categories;
}
return {
...section,
payload: populatedPayload
};
})
);
return {
...page,
sections: populatedSections
};
})
.post("/", async ({ body, set }) => {
try {
const data = body as any;
const { id, slug, title, isPublished } = data;
if (id) {
return await prisma.page.update({
where: { id: Number(id) },
data: { slug, title, isPublished: isPublished !== false }
});
} else {
return await prisma.page.create({
data: { slug, title, isPublished: isPublished !== false }
});
}
} catch (err) {
console.error("[Pages] Error:", err);
set.status = 500;
return { error: "Failed to save page" };
}
}, {
body: t.Object({
id: t.Optional(t.Number()),
slug: t.String(),
title: t.String(),
isPublished: t.Optional(t.Boolean())
})
})
.delete("/:slug", async ({ params: { slug }, set }) => {
try {
await prisma.page.delete({
where: { id: Number(slug) }
});
return { success: true };
} catch (err) {
set.status = 500;
return { error: "Failed to delete page" };
}
})
.get("/:slug/sections", async ({ params: { slug }, set }) => {
try {
return await prisma.pageSection.findMany({
where: { pageId: Number(slug) },
orderBy: { order: "asc" }
});
} catch (err) {
set.status = 500;
return { error: "Failed to fetch page sections" };
}
})
.post("/:slug/sections", async ({ params: { slug }, body, set }) => {
try {
const pageId = Number(slug);
const { type, order, payload, isActive } = body as any;
const validationError = validateSectionPayload(type, payload);
if (validationError) {
set.status = 400;
return { error: validationError };
}
return await prisma.pageSection.create({
data: {
pageId,
type: type.toUpperCase(),
order,
payload,
isActive: isActive !== false
}
});
} catch (err) {
console.error("[PageSections] Error:", err);
set.status = 500;
return { error: "Failed to add section" };
}
}, {
body: t.Object({
type: t.String(),
order: t.Number(),
payload: t.Any(),
isActive: t.Optional(t.Boolean())
})
})
.put("/sections/:sectionId", async ({ params: { sectionId }, body, set }) => {
try {
const id = Number(sectionId);
const { type, order, payload, isActive } = body as any;
if (payload && type) {
const validationError = validateSectionPayload(type, payload);
if (validationError) {
set.status = 400;
return { error: validationError };
}
}
return await prisma.pageSection.update({
where: { id },
data: {
type: type ? type.toUpperCase() : undefined,
order,
payload,
isActive
}
});
} catch (err) {
console.error("[PageSections] Update Error:", err);
set.status = 500;
return { error: "Failed to update section" };
}
}, {
body: t.Object({
type: t.Optional(t.String()),
order: t.Optional(t.Number()),
payload: t.Optional(t.Any()),
isActive: t.Optional(t.Boolean())
})
})
.put("/:slug/sections/reorder", async ({ params: { slug }, body, set }) => {
try {
const pageId = Number(slug);
const { sectionOrders } = body as { sectionOrders: { id: number; order: number }[] };
await prisma.$transaction(
sectionOrders.map((item) =>
prisma.pageSection.update({
where: { id: item.id, pageId },
data: { order: item.order }
})
)
);
return { success: true };
} catch (err) {
console.error("[PageSections] Reorder Error:", err);
set.status = 500;
return { error: "Failed to reorder sections" };
}
}, {
body: t.Object({
sectionOrders: t.Array(t.Object({
id: t.Number(),
order: t.Number()
}))
})
})
.post("/newsletter", async ({ body, set }) => {
try {
const { email } = body as { email: string };
if (!email || !email.includes("@")) {
set.status = 400;
return { error: "Invalid email address" };
}
const sub = await prisma.newsletterSubscription.upsert({
where: { email },
update: {},
create: { email }
});
return { success: true, data: sub };
} catch (err) {
console.error("[Newsletter] Error:", err);
set.status = 500;
return { error: "Failed to subscribe" };
}
}, {
body: t.Object({
email: t.String()
})
})
.delete("/sections/:sectionId", async ({ params: { sectionId }, set }) => {
try {
await prisma.pageSection.delete({
where: { id: Number(sectionId) }
});
return { success: true };
} catch (err) {
set.status = 500;
return { error: "Failed to delete section" };
}
});
function validateSectionPayload(type: string, payload: any): string | null {
if (!payload || typeof payload !== 'object') {
return "Payload must be a JSON object";
}
switch (type.toUpperCase()) {
case 'HERO':
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Hero payload must contain a non-empty string title";
if (typeof payload.description !== 'string') return "Hero payload must contain a string description";
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim()) return "Hero payload must contain a non-empty string imageUrl";
if (typeof payload.tagline !== 'string') return "Hero payload must contain a string tagline";
if (typeof payload.buttonText !== 'string') return "Hero payload must contain a string buttonText";
if (typeof payload.buttonLink !== 'string') return "Hero payload must contain a string buttonLink";
break;
case 'CATEGORIES':
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Categories payload must contain a non-empty string title";
if (payload.subtitle !== undefined && typeof payload.subtitle !== 'string') return "Subtitle must be a string";
if (payload.categoryIds !== undefined && !Array.isArray(payload.categoryIds)) {
return "categoryIds must be an array of numbers";
}
break;
case 'PRODUCTS':
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Products payload must contain a non-empty string title";
if (payload.subtitle !== undefined && typeof payload.subtitle !== 'string') return "Subtitle must be a string";
if (!['featured', 'scent', 'custom'].includes(payload.type)) {
return "Products type must be 'featured', 'scent', or 'custom'";
}
if (payload.type === 'scent' && typeof payload.scentId !== 'number') {
return "Scent products section requires a numeric scentId";
}
if (payload.type === 'custom' && !Array.isArray(payload.productIds)) {
return "Custom products section requires an array of productIds";
}
if (payload.limit !== undefined && typeof payload.limit !== 'number') {
return "Limit must be a number";
}
if (payload.layout !== undefined && !['grid', 'carousel'].includes(payload.layout)) {
return "layout must be 'grid' or 'carousel'";
}
if (payload.enableQuickShop !== undefined && typeof payload.enableQuickShop !== 'boolean') {
return "enableQuickShop must be a boolean";
}
break;
case 'BANNER':
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim()) return "Banner payload must contain a non-empty string imageUrl";
if (payload.linkUrl !== undefined && typeof payload.linkUrl !== 'string') return "linkUrl must be a string";
if (payload.title !== undefined && typeof payload.title !== 'string') return "title must be a string";
if (payload.description !== undefined && typeof payload.description !== 'string') return "description must be a string";
if (payload.buttonText !== undefined && typeof payload.buttonText !== 'string') return "buttonText must be a string";
break;
case 'TEXT':
if (typeof payload.content !== 'string' || !payload.content.trim()) return "Text payload must contain a non-empty string content";
if (payload.title !== undefined && typeof payload.title !== 'string') return "title must be a string";
if (payload.textAlign !== undefined && !['left', 'center', 'right'].includes(payload.textAlign)) {
return "textAlign must be 'left', 'center', or 'right'";
}
break;
case 'ANNOUNCEMENT_BAR':
if (!Array.isArray(payload.slides) || payload.slides.length === 0) return "Announcement Bar requires an array of slides";
if (payload.slides.some((s: any) => typeof s !== 'string')) return "All slides must be strings";
break;
case 'NEWSLETTER':
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Newsletter payload must contain a non-empty string title";
if (typeof payload.description !== 'string') return "Newsletter description must be a string";
if (typeof payload.buttonText !== 'string' || !payload.buttonText.trim()) return "Newsletter buttonText must be a non-empty string";
if (typeof payload.showPrivacyCheckbox !== 'boolean') return "showPrivacyCheckbox must be a boolean";
break;
case 'EDITORIAL_TILE':
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Editorial Tile payload must contain a non-empty string title";
if (typeof payload.description !== 'string') return "Editorial Tile description must be a string";
if (typeof payload.imageUrl !== 'string' || !payload.imageUrl.trim()) return "Editorial Tile imageUrl must be a non-empty string";
if (!['left', 'right'].includes(payload.imagePosition)) return "imagePosition must be 'left' or 'right'";
break;
case 'EDITORIAL_CAROUSEL':
if (typeof payload.title !== 'string' || !payload.title.trim()) return "Editorial Carousel payload must contain a non-empty string title";
if (!Array.isArray(payload.slides) || payload.slides.length === 0) return "Editorial Carousel requires an array of slides";
for (const [idx, slide] of payload.slides.entries()) {
if (typeof slide.title !== 'string' || !slide.title.trim()) return `Slide #${idx+1} must contain a non-empty title`;
if (typeof slide.description !== 'string') return `Slide #${idx+1} must contain a description string`;
if (typeof slide.imageUrl !== 'string' || !slide.imageUrl.trim()) return `Slide #${idx+1} must contain a non-empty imageUrl`;
if (typeof slide.category !== 'string') return `Slide #${idx+1} must contain a category string`;
}
break;
default:
return `Unknown section type: ${type}`;
}
return null;
}
+5
View File
@@ -2,6 +2,11 @@ import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
export const productsRoute = new Elysia({ prefix: "/products" })
.get("/scents", async () => {
return await prisma.scent.findMany({
orderBy: { name: "asc" }
});
})
.get("/", async ({ query }) => {
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
const scentId = query.scentId ? parseInt(query.scentId) : undefined;