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:
dwindown
2025-12-26 01:11:11 +07:00
parent f1fb2758f8
commit e512956444
2 changed files with 118 additions and 31 deletions

View File

@@ -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>

View File

@@ -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>
); );