feat: add product review and rating system with schema update
This commit is contained in:
1 parent
091eeef7d8
commit
e4f7951b34
5 files changed
+230
-1
No files matched your search
@@ -38,6 +38,7 @@ model Product {
|
|||||||
scent Scent @relation(fields: [scentId], references: [id])
|
scent Scent @relation(fields: [scentId], references: [id])
|
||||||
variants ProductVariant[]
|
variants ProductVariant[]
|
||||||
images ProductImage[]
|
images ProductImage[]
|
||||||
|
reviews Review[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ model Customer {
|
|||||||
email String?
|
email String?
|
||||||
phone String?
|
phone String?
|
||||||
orders Order[]
|
orders Order[]
|
||||||
|
reviews Review[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
@@ -372,3 +374,21 @@ model LazadaOrderItem {
|
|||||||
shippingFee Float @default(0)
|
shippingFee Float @default(0)
|
||||||
status String?
|
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])
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { tiktokOrderRoute } from "./routes/tiktokOrder";
|
|||||||
import { lazadaOrderRoute } from "./routes/lazadaOrder";
|
import { lazadaOrderRoute } from "./routes/lazadaOrder";
|
||||||
import { customersRoute } from "./routes/customers";
|
import { customersRoute } from "./routes/customers";
|
||||||
import { couponsRoute } from "./routes/coupons";
|
import { couponsRoute } from "./routes/coupons";
|
||||||
|
import { reviewsRoute } from "./routes/reviews";
|
||||||
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
const PORT = process.env.PORT ? parseInt(process.env.PORT) : 3000;
|
||||||
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
const CORS_ORIGIN = process.env.CORS_ORIGIN || "http://localhost:5173";
|
||||||
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
|
const JWT_SECRET = process.env.JWT_SECRET || "homaroma_secret_2026_scent";
|
||||||
@@ -81,7 +82,7 @@ const app = new Elysia()
|
|||||||
return file;
|
return file;
|
||||||
})
|
})
|
||||||
.group("/api", (app) =>
|
.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);
|
.listen(PORT);
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
})
|
||||||
|
});
|
||||||
@@ -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<Review[]>([])
|
||||||
|
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 (
|
||||||
|
<div className="pt-16 border-t border-spa-stone/10 space-y-12">
|
||||||
|
<div className="flex flex-col md:flex-row gap-12 items-start">
|
||||||
|
{/* Summary */}
|
||||||
|
<div className="w-full md:w-1/3 bg-spa-light p-8 rounded-3xl text-center space-y-4">
|
||||||
|
<h2 className="text-3xl font-bold text-pop-black">Reviews</h2>
|
||||||
|
<div className="text-6xl font-black text-spa-forest">{avgRating}</div>
|
||||||
|
<div className="flex justify-center gap-1">
|
||||||
|
{[1, 2, 3, 4, 5].map(star => (
|
||||||
|
<Star key={star} className={`w-5 h-5 ${star <= Number(avgRating) ? 'fill-orange-400 text-orange-400' : 'text-spa-stone/30 fill-spa-stone/10'}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-spa-stone text-sm">{reviews.length} reviews</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List & Form */}
|
||||||
|
<div className="w-full md:w-2/3 space-y-8">
|
||||||
|
{customer ? (
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white p-6 rounded-3xl border border-spa-stone/10 shadow-sm space-y-4">
|
||||||
|
<h3 className="font-bold text-pop-black">Write a Review</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{[1, 2, 3, 4, 5].map(star => (
|
||||||
|
<button type="button" key={star} onClick={() => setRating(star)} className="focus:outline-none">
|
||||||
|
<Star className={`w-8 h-8 ${star <= rating ? 'fill-orange-400 text-orange-400' : 'text-spa-stone/30 fill-spa-stone/10 hover:text-orange-300'}`} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={comment}
|
||||||
|
onChange={e => setComment(e.target.value)}
|
||||||
|
placeholder="Share your experience with this scent..."
|
||||||
|
className="w-full bg-spa-light/50 border border-spa-stone/20 rounded-xl p-4 text-sm focus:outline-none focus:border-spa-forest/50 focus:ring-2 focus:ring-spa-forest/20 transition-all"
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting}
|
||||||
|
className="px-6 py-2.5 bg-spa-forest text-white text-sm font-bold tracking-widest uppercase rounded-full hover:bg-spa-stone transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting ? 'Submitting...' : 'Submit Review'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : (
|
||||||
|
<div className="bg-spa-light p-6 rounded-3xl text-center border border-dashed border-spa-stone/30">
|
||||||
|
<p className="text-spa-stone text-sm mb-2">Login to write a review</p>
|
||||||
|
<button onClick={() => window.location.href='/track'} className="text-spa-forest font-bold text-sm tracking-widest uppercase hover:underline">Go to Login</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-spa-stone animate-pulse text-sm">Loading reviews...</p>
|
||||||
|
) : reviews.length === 0 ? (
|
||||||
|
<p className="text-spa-stone/60 text-sm italic">No reviews yet. Be the first to share your thoughts!</p>
|
||||||
|
) : (
|
||||||
|
reviews.map(review => (
|
||||||
|
<div key={review.id} className="border-b border-spa-stone/10 pb-6 last:border-0">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
{review.customer?.pictureUrl ? (
|
||||||
|
<img src={review.customer.pictureUrl} className="w-10 h-10 rounded-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="w-10 h-10 rounded-full bg-spa-stone/20 flex items-center justify-center text-white font-bold">
|
||||||
|
{(review.customerName || 'A')[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<p className="font-bold text-pop-black text-sm">{review.customerName}</p>
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map(star => (
|
||||||
|
<Star key={star} className={`w-3 h-3 ${star <= review.rating ? 'fill-orange-400 text-orange-400' : 'text-spa-stone/30'}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto text-xs text-spa-stone/50">{new Date(review.createdAt).toLocaleDateString()}</span>
|
||||||
|
</div>
|
||||||
|
{review.comment && <p className="text-spa-stone text-sm ml-13 leading-relaxed">{review.comment}</p>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { useParams, Link } from 'react-router-dom'
|
|||||||
import Navbar from '../components/Navbar'
|
import Navbar from '../components/Navbar'
|
||||||
import Footer from '../components/Footer'
|
import Footer from '../components/Footer'
|
||||||
import ProductCard from '../components/ProductCard'
|
import ProductCard from '../components/ProductCard'
|
||||||
|
import ProductReviews from '../components/ProductReviews'
|
||||||
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product } from '../types'
|
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product } from '../types'
|
||||||
import { useCartStore } from '../store/cartStore'
|
import { useCartStore } from '../store/cartStore'
|
||||||
|
|
||||||
@@ -231,6 +232,9 @@ export default function ProductDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Reviews Section */}
|
||||||
|
<ProductReviews productId={product.id} />
|
||||||
|
|
||||||
{/* Related Products */}
|
{/* Related Products */}
|
||||||
{related.length > 0 && (
|
{related.length > 0 && (
|
||||||
<div className="space-y-8 border-t border-spa-stone/10 pt-16">
|
<div className="space-y-8 border-t border-spa-stone/10 pt-16">
|
||||||
|
|||||||
Reference in new issue
Block a user