feat(marketplace): add review API to frontend

This commit is contained in:
senke 2026-02-22 16:09:04 +01:00
parent c6611c3d8f
commit e6797481cf
7 changed files with 167 additions and 53 deletions

View file

@ -38,6 +38,7 @@ export const ProductDetailView: React.FC<ProductDetailViewProps> = ({
showReviewModal,
setShowReviewModal,
handleReviewSubmit,
reviewsRefreshTrigger,
} = useProductDetailView(product, onAddToCart);
if (isLoading) {
@ -77,6 +78,7 @@ export const ProductDetailView: React.FC<ProductDetailViewProps> = ({
<ProductDetailViewReviews
product={product}
onWriteReview={() => setShowReviewModal(true)}
reviewsRefreshTrigger={reviewsRefreshTrigger}
/>
</div>
<ProductDetailViewSimilar

View file

@ -41,10 +41,10 @@ export function ProductDetailViewInfo({ product }: ProductDetailViewInfoProps) {
</h1>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1 text-warning font-bold">
<Star className="w-4 h-4 fill-current" /> {product.rating ?? '-'}
<Star className="w-4 h-4 fill-current" /> {product.avg_rating ?? product.rating ?? '-'}
</span>
<span></span>
<span>{product.reviewCount ?? 0} reviews</span>
<span>{product.review_count ?? product.reviewCount ?? 0} reviews</span>
<span></span>
<span className="text-foreground/80">{product.author ?? '-'}</span>
</div>

View file

@ -1,19 +1,66 @@
/**
* ProductDetailView reviews card.
* ProductDetailView reviews card (v0.403 R1).
* Fetches reviews from API, displays rating and comment.
*/
import { useState, useEffect, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state';
import { Star } from 'lucide-react';
import { Skeleton } from '@/components/ui/skeleton';
import { Star, User } from 'lucide-react';
import { marketplaceService } from '@/services/marketplaceService';
import type { Product } from '@/types';
interface ApiReview {
id: string;
buyer_id: string;
rating: number;
comment: string;
created_at: string;
}
interface ProductDetailViewReviewsProps {
product: Product;
onWriteReview: () => void;
reviewsRefreshTrigger?: number;
}
export function ProductDetailViewReviews({ product, onWriteReview }: ProductDetailViewReviewsProps) {
const reviews = product.reviews ?? [];
export function ProductDetailViewReviews({
product,
onWriteReview,
reviewsRefreshTrigger = 0,
}: ProductDetailViewReviewsProps) {
const [reviews, setReviews] = useState<ApiReview[]>([]);
const [loading, setLoading] = useState(true);
const fetchReviews = useCallback(async () => {
if (!product?.id) return;
setLoading(true);
try {
const data = await marketplaceService.listReviews(product.id);
setReviews(data);
} catch {
setReviews([]);
} finally {
setLoading(false);
}
}, [product?.id]);
useEffect(() => {
fetchReviews();
}, [fetchReviews, reviewsRefreshTrigger]);
const formatDate = (iso: string) => {
try {
return new Date(iso).toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
} catch {
return '';
}
};
return (
<Card variant="default">
@ -23,41 +70,49 @@ export function ProductDetailViewReviews({ product, onWriteReview }: ProductDeta
Write a Review
</Button>
</div>
<div className="space-y-6">
{reviews.map((review: { id: string; avatar: string; username: string; rating: number; comment: string; date: string }) => (
<div key={review.id} className="flex gap-4">
<img
src={review.avatar}
alt=""
className="w-10 h-10 rounded-full bg-muted"
/>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-bold text-foreground">{review.username}</span>
<div className="flex text-warning text-xs">
{[...Array(5)].map((_, i) => (
<Star
key={i}
className={`w-3 h-3 ${i < review.rating ? 'fill-current' : 'text-muted-foreground'}`}
/>
))}
</div>
<span className="text-xs text-muted-foreground">{review.date}</span>
{loading ? (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-16 w-full rounded-lg" />
))}
</div>
) : (
<div className="space-y-6">
{reviews.map((review) => (
<div key={review.id} className="flex gap-4">
<div className="w-10 h-10 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-bold text-foreground">User</span>
<div className="flex text-warning text-xs">
{[1, 2, 3, 4, 5].map((i) => (
<Star
key={i}
className={`w-3 h-3 ${i <= review.rating ? 'fill-current' : 'text-muted-foreground'}`}
/>
))}
</div>
<span className="text-xs text-muted-foreground">{formatDate(review.created_at)}</span>
</div>
{review.comment ? (
<p className="text-sm text-foreground">{review.comment}</p>
) : null}
</div>
<p className="text-sm text-foreground">{review.comment}</p>
</div>
</div>
))}
{reviews.length === 0 && (
<EmptyState
icon={<Star className="w-full h-full" />}
title="No reviews yet"
description="Be the first to review this product!"
size="sm"
className="border-0 shadow-none bg-transparent"
/>
)}
</div>
))}
{reviews.length === 0 && (
<EmptyState
icon={<Star className="w-full h-full" />}
title="No reviews yet"
description="Be the first to review this product!"
size="sm"
className="border-0 shadow-none bg-transparent"
/>
)}
</div>
)}
</Card>
);
}

View file

@ -1,14 +1,17 @@
/**
* ProductDetailView state and handlers.
* v0.401 M1: Playable preview via preview_url
* v0.403 R1: Review submission via API
*/
import { useState, useCallback, useRef, useEffect } from 'react';
import { useToast } from '@/components/feedback/ToastProvider';
import { marketplaceService } from '@/services/marketplaceService';
import type { Product, ProductLicense } from '@/types';
export function useProductDetailView(
product: Product,
_onAddToCart: (product: Product, license?: ProductLicense) => void,
options?: { onReviewSuccess?: () => void },
) {
const { addToast } = useToast();
const audioRef = useRef<HTMLAudioElement | null>(null);
@ -26,6 +29,7 @@ export function useProductDetailView(
);
const [showLicenseInfo, setShowLicenseInfo] = useState<ProductLicense | null>(null);
const [showReviewModal, setShowReviewModal] = useState(false);
const [reviewsRefreshTrigger, setReviewsRefreshTrigger] = useState(0);
const selectedLicense = product.licenses?.find((l) => l.id === selectedLicenseId);
@ -56,10 +60,20 @@ export function useProductDetailView(
}, [product.preview_url]);
const handleReviewSubmit = useCallback(
(_rating: number, _comment: string) => {
addToast('Review submitted for moderation', 'success');
async (rating: number, comment: string) => {
if (!product?.id) return;
try {
await marketplaceService.createReview(product.id, rating, comment);
addToast('Review submitted successfully', 'success');
setShowReviewModal(false);
setReviewsRefreshTrigger((t) => t + 1);
options?.onReviewSuccess?.();
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to submit review';
addToast(msg, 'error');
}
},
[addToast],
[product?.id, addToast, options],
);
return {
@ -77,5 +91,6 @@ export function useProductDetailView(
showReviewModal,
setShowReviewModal,
handleReviewSubmit,
reviewsRefreshTrigger,
};
}

View file

@ -89,12 +89,12 @@ export function ProductCard({
<span className="text-lg font-semibold">
{formatPrice(product.price, product.currency)}
</span>
{product.rating != null && (
{(product.avg_rating ?? product.rating) != null && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Star className="h-3.5 w-3.5 fill-warning text-warning" />
<span className="font-bold text-foreground">{product.rating}</span>
{product.reviewCount != null && (
<span>({product.reviewCount})</span>
<span className="font-bold text-foreground">{product.avg_rating ?? product.rating}</span>
{(product.review_count ?? product.reviewCount) != null && (
<span>({product.review_count ?? product.reviewCount})</span>
)}
</div>
)}

View file

@ -116,14 +116,14 @@ export const marketplaceService = {
return response.data;
},
createOrder: async (items: { product_id: string }[]) => {
createOrder: async (items: { product_id: string }[], promoCode?: string) => {
const body: { items: { product_id: string }[]; promo_code?: string } = { items };
if (promoCode?.trim()) body.promo_code = promoCode.trim();
const response = await apiClient.post<{
order: { id: string; status: string; [key: string]: unknown };
client_secret?: string;
payment_id?: string;
}>('/marketplace/orders', {
items,
});
}>('/marketplace/orders', body);
return response.data;
},
@ -188,8 +188,48 @@ export const marketplaceService = {
removeFromCart: async (itemId: string) => {
await apiClient.delete(`/commerce/cart/items/${itemId}`);
},
checkoutCart: async () => {
const response = await apiClient.post<any>('/commerce/cart/checkout');
checkoutCart: async (promoCode?: string) => {
const body = promoCode?.trim() ? { promo_code: promoCode.trim() } : {};
const response = await apiClient.post<any>('/commerce/cart/checkout', body);
return response.data;
},
// v0.403 R1: Product reviews
createReview: async (productId: string, rating: number, comment: string) => {
const response = await apiClient.post<{
id: string;
product_id: string;
buyer_id: string;
order_id: string;
rating: number;
comment: string;
created_at: string;
}>(`/marketplace/products/${productId}/reviews`, { rating, comment });
return response.data;
},
listReviews: async (productId: string, page = 1, limit = 20) => {
const response = await apiClient.get<{ reviews: Array<{
id: string;
product_id: string;
buyer_id: string;
order_id: string;
rating: number;
comment: string;
created_at: string;
}> }>(`/marketplace/products/${productId}/reviews`, {
params: { limit, offset: (page - 1) * limit },
});
return response.data?.reviews ?? [];
},
// v0.402 P2: Validate promo code
validatePromoCode: async (code: string) => {
const response = await apiClient.get<{
code: string;
discount_type: 'percent' | 'fixed';
discount_value_cents: number;
}>(`/commerce/promo/${encodeURIComponent(code.trim())}`);
return response.data;
},
};

View file

@ -51,9 +51,11 @@ export interface Product {
cover_url?: string; // Backend snake_case alias for coverUrl
isHot?: boolean; // Featured/popular flag
author?: string; // Author/seller name
// V2 Compatibility
// V2 Compatibility / v0.403 R1 from backend
rating?: number;
reviewCount?: number;
avg_rating?: number;
review_count?: number;
features?: string[];
licenses?: ProductLicense[];
images?: string[] | { url: string; sort_order?: number }[];