feat(marketplace): add review API to frontend
This commit is contained in:
parent
c6611c3d8f
commit
e6797481cf
7 changed files with 167 additions and 53 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 }[];
|
||||
|
|
|
|||
Loading…
Reference in a new issue