This commit is contained in:
gpt-engineer-app[bot]
2025-12-19 16:37:01 +00:00
parent 461a14dfdc
commit cc7c330e83
13 changed files with 756 additions and 14 deletions

View File

@@ -23,6 +23,7 @@ import {
MoreHorizontal,
X,
Video,
Star,
} from 'lucide-react';
interface NavItem {
@@ -46,6 +47,7 @@ const adminNavItems: NavItem[] = [
{ label: 'Konsultasi', href: '/admin/consulting', icon: Video },
{ label: 'Order', href: '/admin/orders', icon: Receipt },
{ label: 'Member', href: '/admin/members', icon: Users },
{ label: 'Ulasan', href: '/admin/reviews', icon: Star },
{ label: 'Kalender', href: '/admin/events', icon: Calendar },
{ label: 'Pengaturan', href: '/admin/settings', icon: Settings },
];

View File

@@ -0,0 +1,17 @@
import { Link } from 'react-router-dom';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Phone } from 'lucide-react';
export function WhatsAppBanner() {
return (
<Alert className="border-2 border-primary/20 bg-primary/5 mb-6">
<Phone className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between">
<span>Lengkapi nomor WhatsApp Anda untuk pengingat konsultasi & bootcamp.</span>
<Link to="/profile" className="font-medium underline ml-2 whitespace-nowrap">
Atur Sekarang
</Link>
</AlertDescription>
</Alert>
);
}

View File

@@ -0,0 +1,84 @@
import { useEffect, useState } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { ReviewCard } from './ReviewCard';
import { Star } from 'lucide-react';
interface Review {
id: string;
rating: number;
title: string;
body: string;
created_at: string;
profiles: { full_name: string | null } | null;
}
interface ProductReviewsProps {
productId: string;
type?: string;
}
export function ProductReviews({ productId, type }: ProductReviewsProps) {
const [reviews, setReviews] = useState<Review[]>([]);
const [stats, setStats] = useState({ avg: 0, count: 0 });
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchReviews();
}, [productId, type]);
const fetchReviews = async () => {
let query = supabase
.from('reviews')
.select('id, rating, title, body, created_at, profiles (full_name)')
.eq('is_approved', true);
if (productId) {
query = query.eq('product_id', productId);
} else if (type) {
query = query.eq('type', type);
}
const { data } = await query.order('created_at', { ascending: false }).limit(3);
if (data && data.length > 0) {
const typedData = data as unknown as Review[];
setReviews(typedData);
const avg = typedData.reduce((sum, r) => sum + r.rating, 0) / typedData.length;
setStats({ avg: Math.round(avg * 10) / 10, count: typedData.length });
}
setLoading(false);
};
if (loading || reviews.length === 0) return null;
return (
<div className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((i) => (
<Star
key={i}
className={`w-5 h-5 ${
i <= Math.round(stats.avg) ? 'fill-primary text-primary' : 'text-muted-foreground'
}`}
/>
))}
</div>
<span className="font-bold">{stats.avg}</span>
<span className="text-muted-foreground">({stats.count} ulasan)</span>
</div>
<div className="grid gap-4">
{reviews.map((review) => (
<ReviewCard
key={review.id}
rating={review.rating}
title={review.title}
body={review.body}
authorName={review.profiles?.full_name || 'Anonymous'}
date={review.created_at}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
import { Star } from 'lucide-react';
interface ReviewCardProps {
rating: number;
title: string;
body: string;
authorName: string;
date: string;
}
export function ReviewCard({ rating, title, body, authorName, date }: ReviewCardProps) {
return (
<div className="border-2 border-border p-6 space-y-3">
<div className="flex gap-0.5">
{[1, 2, 3, 4, 5].map((i) => (
<Star
key={i}
className={`w-4 h-4 ${
i <= rating ? 'fill-primary text-primary' : 'text-muted-foreground'
}`}
/>
))}
</div>
<h4 className="font-bold">{title}</h4>
{body && <p className="text-muted-foreground text-sm">{body}</p>}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{authorName}</span>
<span>{new Date(date).toLocaleDateString('id-ID')}</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,112 @@
import { useState } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/hooks/use-toast';
import { Star } from 'lucide-react';
interface ReviewFormProps {
userId: string;
productId?: string;
type: 'consulting' | 'bootcamp' | 'webinar' | 'general';
onSuccess?: () => void;
}
export function ReviewForm({ userId, productId, type, onSuccess }: ReviewFormProps) {
const [rating, setRating] = useState(0);
const [hoverRating, setHoverRating] = useState(0);
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async () => {
if (rating === 0) {
toast({ title: 'Error', description: 'Pilih rating terlebih dahulu', variant: 'destructive' });
return;
}
if (!title.trim()) {
toast({ title: 'Error', description: 'Judul tidak boleh kosong', variant: 'destructive' });
return;
}
setSubmitting(true);
const { error } = await supabase.from('reviews').insert({
user_id: userId,
product_id: productId || null,
type,
rating,
title: title.trim(),
body: body.trim(),
is_approved: false,
});
if (error) {
toast({ title: 'Error', description: 'Gagal mengirim ulasan', variant: 'destructive' });
} else {
toast({ title: 'Berhasil', description: 'Ulasan Anda akan ditinjau oleh admin' });
setRating(0);
setTitle('');
setBody('');
onSuccess?.();
}
setSubmitting(false);
};
return (
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="text-lg">Beri Ulasan</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Rating</label>
<div className="flex gap-1">
{[1, 2, 3, 4, 5].map((i) => (
<button
key={i}
type="button"
onClick={() => setRating(i)}
onMouseEnter={() => setHoverRating(i)}
onMouseLeave={() => setHoverRating(0)}
className="p-1 transition-transform hover:scale-110"
>
<Star
className={`w-6 h-6 ${
i <= (hoverRating || rating)
? 'fill-primary text-primary'
: 'text-muted-foreground'
}`}
/>
</button>
))}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Judul</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Ringkasan pengalaman Anda"
className="border-2"
maxLength={100}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Ulasan (Opsional)</label>
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder="Ceritakan pengalaman Anda..."
className="border-2 min-h-[80px]"
maxLength={500}
/>
</div>
<Button onClick={handleSubmit} disabled={submitting} className="w-full">
{submitting ? 'Mengirim...' : 'Kirim Ulasan'}
</Button>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,55 @@
import { useEffect, useState } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { ReviewCard } from './ReviewCard';
interface Review {
id: string;
rating: number;
title: string;
body: string;
created_at: string;
profiles: { full_name: string | null } | null;
}
export function TestimonialsSection() {
const [reviews, setReviews] = useState<Review[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchReviews();
}, []);
const fetchReviews = async () => {
const { data } = await supabase
.from('reviews')
.select('id, rating, title, body, created_at, profiles (full_name)')
.eq('is_approved', true)
.order('created_at', { ascending: false })
.limit(6);
if (data) {
setReviews(data as unknown as Review[]);
}
setLoading(false);
};
if (loading || reviews.length === 0) return null;
return (
<section className="container mx-auto px-4 py-16">
<h2 className="text-3xl font-bold text-center mb-8">Apa Kata Mereka</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{reviews.map((review) => (
<ReviewCard
key={review.id}
rating={review.rating}
title={review.title}
body={review.body}
authorName={review.profiles?.full_name || 'Anonymous'}
date={review.created_at}
/>
))}
</div>
</section>
);
}