Add celebratory review UI to Bootcamp page & fix access page
Bootcamp page changes: - Add UserReview interface to store full review data - Fetch review data with is_approved status - Add celebratory UI when review is approved: - Gradient background with brand accent - "Ulasan Anda Terbit!" heading with "Disetujui" badge - Display user's review with stars, title, body - Publication date - Show pending state with clock icon while waiting approval - Update onSuccess callback to refresh review data MemberAccess page changes: - Change "Lanjutkan Bootcamp" to "Mulai Bootcamp" (clearer) - Fix webinar action buttons: - Check if event_start has passed - Only show "Gabung Webinar" if webinar hasn't ended - Show "Tonton Rekaman" button if recording_url exists - Show "Rekaman segera tersedia" badge for passed webinars without recording 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -40,6 +40,15 @@ interface Progress {
|
|||||||
completed_at: string;
|
completed_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface UserReview {
|
||||||
|
id: string;
|
||||||
|
rating: number;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
is_approved: boolean;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default function Bootcamp() {
|
export default function Bootcamp() {
|
||||||
const { slug } = useParams<{ slug: string }>();
|
const { slug } = useParams<{ slug: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -52,7 +61,7 @@ export default function Bootcamp() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||||
const [hasReviewed, setHasReviewed] = useState(false);
|
const [userReview, setUserReview] = useState<UserReview | null>(null);
|
||||||
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
const [reviewModalOpen, setReviewModalOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -135,12 +144,17 @@ export default function Bootcamp() {
|
|||||||
// Check if user has already reviewed this bootcamp
|
// Check if user has already reviewed this bootcamp
|
||||||
const { data: reviewData } = await supabase
|
const { data: reviewData } = await supabase
|
||||||
.from('reviews')
|
.from('reviews')
|
||||||
.select('id')
|
.select('id, rating, title, body, is_approved, created_at')
|
||||||
.eq('user_id', user!.id)
|
.eq('user_id', user!.id)
|
||||||
.eq('product_id', productData.id)
|
.eq('product_id', productData.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
setHasReviewed(!!(reviewData && reviewData.length > 0));
|
if (reviewData && reviewData.length > 0) {
|
||||||
|
setUserReview(reviewData[0] as UserReview);
|
||||||
|
} else {
|
||||||
|
setUserReview(null);
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
};
|
};
|
||||||
@@ -435,14 +449,59 @@ export default function Bootcamp() {
|
|||||||
|
|
||||||
{/* Bootcamp completion review prompt */}
|
{/* Bootcamp completion review prompt */}
|
||||||
{isBootcampCompleted && (
|
{isBootcampCompleted && (
|
||||||
<Card className="border-2 border-primary/20 mt-6">
|
<Card className={`border-2 mt-6 ${userReview?.is_approved ? 'bg-gradient-to-br from-brand-accent/10 to-primary/10 border-brand-accent/30' : 'border-primary/20'}`}>
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-6">
|
||||||
{hasReviewed ? (
|
{userReview ? (
|
||||||
<div className="flex items-center gap-2 text-muted-foreground">
|
userReview.is_approved ? (
|
||||||
<CheckCircle className="w-5 h-5 text-accent" />
|
// Approved review - celebratory display
|
||||||
<span>Terima kasih atas ulasan Anda (menunggu moderasi)</span>
|
<div className="space-y-4">
|
||||||
</div>
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="rounded-full bg-brand-accent p-2">
|
||||||
|
<CheckCircle className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<h3 className="text-lg font-bold">Ulasan Anda Terbit!</h3>
|
||||||
|
<Badge className="bg-brand-accent text-white rounded-full">Disetujui</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">Terima kasih telah berbagi pengalaman Anda. Ulasan Anda membantu peserta lain!</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User's review display */}
|
||||||
|
<div className="bg-background/50 backdrop-blur rounded-lg p-4 border border-brand-accent/20">
|
||||||
|
<div className="flex gap-0.5 mb-2">
|
||||||
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
|
<Star
|
||||||
|
key={i}
|
||||||
|
className={`w-5 h-5 ${i <= userReview.rating ? 'fill-brand-accent text-brand-accent' : 'text-muted-foreground'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h4 className="font-semibold text-base mb-1">{userReview.title}</h4>
|
||||||
|
{userReview.body && (
|
||||||
|
<p className="text-sm text-muted-foreground">{userReview.body}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Diterbitkan pada {new Date(userReview.created_at).toLocaleDateString('id-ID', { day: 'numeric', month: 'long', year: 'numeric' })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Pending review
|
||||||
|
<div className="flex items-center gap-3 text-muted-foreground">
|
||||||
|
<div className="rounded-full bg-amber-500/10 p-2">
|
||||||
|
<Clock className="w-5 h-5 text-amber-500" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-foreground">Ulasan Anda sedang ditinjau</p>
|
||||||
|
<p className="text-sm">Terima kasih! Ulasan akan muncul setelah disetujui admin.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
|
// No review yet - prompt to review
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
<div className="flex items-center justify-between gap-4 flex-wrap">
|
||||||
<div>
|
<div>
|
||||||
<p className="font-medium">🎉 Selamat menyelesaikan bootcamp!</p>
|
<p className="font-medium">🎉 Selamat menyelesaikan bootcamp!</p>
|
||||||
@@ -482,7 +541,22 @@ export default function Bootcamp() {
|
|||||||
productId={product.id}
|
productId={product.id}
|
||||||
type="bootcamp"
|
type="bootcamp"
|
||||||
contextLabel={product.title}
|
contextLabel={product.title}
|
||||||
onSuccess={() => setHasReviewed(true)}
|
onSuccess={() => {
|
||||||
|
// Refresh review data
|
||||||
|
const refreshReview = async () => {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('reviews')
|
||||||
|
.select('id, rating, title, body, is_approved, created_at')
|
||||||
|
.eq('user_id', user.id)
|
||||||
|
.eq('product_id', product.id)
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.limit(1);
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
setUserReview(data[0] as UserReview);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
refreshReview();
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -93,30 +93,43 @@ export default function MemberAccess() {
|
|||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
case 'webinar':
|
case 'webinar':
|
||||||
return (
|
// Check if webinar has ended
|
||||||
<div className="flex gap-2 flex-wrap">
|
const webinarEnded = item.product.event_start && new Date(item.product.event_start) <= new Date();
|
||||||
{item.product.meeting_link && (
|
|
||||||
<Button asChild variant="outline" className="border-2">
|
// If recording exists, show it
|
||||||
<a href={item.product.meeting_link} target="_blank" rel="noopener noreferrer">
|
if (item.product.recording_url) {
|
||||||
<Video className="w-4 h-4 mr-2" />
|
return (
|
||||||
Gabung Webinar
|
<Button onClick={() => navigate(`/webinar/${item.product.slug}`)} className="shadow-sm">
|
||||||
</a>
|
<Video className="w-4 h-4 mr-2" />
|
||||||
</Button>
|
Tonton Rekaman
|
||||||
)}
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
{item.product.recording_url && (
|
</Button>
|
||||||
<Button onClick={() => navigate(`/webinar/${item.product.slug}`)} className="shadow-sm">
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show join link if webinar hasn't ended
|
||||||
|
if (!webinarEnded && item.product.meeting_link) {
|
||||||
|
return (
|
||||||
|
<Button asChild variant="outline" className="border-2">
|
||||||
|
<a href={item.product.meeting_link} target="_blank" rel="noopener noreferrer">
|
||||||
<Video className="w-4 h-4 mr-2" />
|
<Video className="w-4 h-4 mr-2" />
|
||||||
Tonton Rekaman
|
Gabung Webinar
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
</a>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
);
|
||||||
</div>
|
}
|
||||||
);
|
|
||||||
|
// Webinar ended but no recording yet
|
||||||
|
if (webinarEnded) {
|
||||||
|
return <Badge className="bg-muted text-primary">Rekaman segera tersedia</Badge>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
case 'bootcamp':
|
case 'bootcamp':
|
||||||
return (
|
return (
|
||||||
<Button onClick={() => navigate(`/bootcamp/${item.product.slug}`)} className="shadow-sm">
|
<Button onClick={() => navigate(`/bootcamp/${item.product.slug}`)} className="shadow-sm">
|
||||||
<BookOpen className="w-4 h-4 mr-2" />
|
<BookOpen className="w-4 h-4 mr-2" />
|
||||||
Lanjutkan Bootcamp
|
Mulai Bootcamp
|
||||||
<ArrowRight className="w-4 h-4 ml-2" />
|
<ArrowRight className="w-4 h-4 ml-2" />
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user