diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index f822a09..3b6f09c 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -38,6 +38,7 @@ model Product { scent Scent @relation(fields: [scentId], references: [id]) variants ProductVariant[] images ProductImage[] + reviews Review[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -82,6 +83,7 @@ model Customer { email String? phone String? orders Order[] + reviews Review[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } @@ -372,3 +374,21 @@ model LazadaOrderItem { shippingFee Float @default(0) status String? } + +// ── Reviews ──────────────────────────────────────────────────────────────── +model Review { + id Int @id @default(autoincrement()) + rating Int @default(5) + comment String? + productId Int + product Product @relation(fields: [productId], references: [id], onDelete: Cascade) + customerId Int? + customer Customer? @relation(fields: [customerId], references: [id], onDelete: SetNull) + customerName String? + isPublished Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([productId]) + @@index([customerId]) +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 0e448cb..bfb9a14 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -18,6 +18,7 @@ import { tiktokOrderRoute } from "./routes/tiktokOrder"; import { lazadaOrderRoute } from "./routes/lazadaOrder"; import { customersRoute } from "./routes/customers"; import { couponsRoute } from "./routes/coupons"; +import { reviewsRoute } from "./routes/reviews"; 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"; @@ -81,7 +82,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) + 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) ) .listen(PORT); diff --git a/apps/backend/src/routes/reviews.ts b/apps/backend/src/routes/reviews.ts new file mode 100644 index 0000000..80d67d1 --- /dev/null +++ b/apps/backend/src/routes/reviews.ts @@ -0,0 +1,49 @@ +import { Elysia, t } from "elysia"; +import prisma from "../lib/prisma"; + +export const reviewsRoute = new Elysia({ prefix: "/reviews" }) + .get("/product/:productId", async ({ params: { productId } }) => { + return await prisma.review.findMany({ + where: { + productId: Number(productId), + isPublished: true + }, + orderBy: { createdAt: 'desc' }, + include: { + customer: { + select: { + displayName: true, + pictureUrl: true + } + } + } + }); + }) + .post("/", async ({ body, set }) => { + try { + const data = body as any; + const review = await prisma.review.create({ + data: { + rating: data.rating, + comment: data.comment, + productId: data.productId, + customerId: data.customerId || null, + customerName: data.customerName || "Anonymous", + isPublished: true + } + }); + return review; + } catch (err) { + console.error("[Reviews] Error:", err); + set.status = 500; + return { error: "Failed to create review" }; + } + }, { + body: t.Object({ + rating: t.Number(), + comment: t.Optional(t.String()), + productId: t.Number(), + customerId: t.Optional(t.Nullable(t.Number())), + customerName: t.Optional(t.String()) + }) + }); diff --git a/apps/spa/src/components/ProductReviews.tsx b/apps/spa/src/components/ProductReviews.tsx new file mode 100644 index 0000000..a32c578 --- /dev/null +++ b/apps/spa/src/components/ProductReviews.tsx @@ -0,0 +1,155 @@ +import { useState, useEffect } from 'react' +import { useAuthStore } from '../store/authStore' +import { API_BASE } from '../types' +import { Star } from 'lucide-react' + +interface Review { + id: number + rating: number + comment: string | null + customerName: string | null + createdAt: string + customer?: { + pictureUrl: string | null + } +} + +export default function ProductReviews({ productId }: { productId: number }) { + const [reviews, setReviews] = useState([]) + const [loading, setLoading] = useState(true) + const { customer } = useAuthStore() + const [rating, setRating] = useState(5) + const [comment, setComment] = useState('') + const [submitting, setSubmitting] = useState(false) + + const fetchReviews = () => { + fetch(`${API_BASE}/reviews/product/${productId}`) + .then(res => res.json()) + .then(data => { + if (Array.isArray(data)) setReviews(data) + }) + .catch(console.error) + .finally(() => setLoading(false)) + } + + useEffect(() => { + fetchReviews() + }, [productId]) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!customer) return + setSubmitting(true) + try { + const res = await fetch(`${API_BASE}/reviews`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + productId, + rating, + comment, + customerId: customer.id, + customerName: customer.displayName + }) + }) + if (res.ok) { + setComment('') + setRating(5) + fetchReviews() + } + } catch (err) { + console.error(err) + } finally { + setSubmitting(false) + } + } + + const avgRating = reviews.length > 0 + ? (reviews.reduce((acc, r) => acc + r.rating, 0) / reviews.length).toFixed(1) + : '0.0' + + return ( +
+
+ {/* Summary */} +
+

Reviews

+
{avgRating}
+
+ {[1, 2, 3, 4, 5].map(star => ( + + ))} +
+

{reviews.length} reviews

+
+ + {/* List & Form */} +
+ {customer ? ( +
+

Write a Review

+
+ {[1, 2, 3, 4, 5].map(star => ( + + ))} +
+