Compare commits

...

11 Commits

Author SHA1 Message Date
dwindown
4b5dfc6557 Configure for self-hosted deployment
- Add environment variable support for Supabase and Pakasir configurations
- Create Docker configuration with Nginx for production deployment
- Add .env.example with all required environment variables
- Remove hardcoded URLs from Supabase client and Checkout component
- Add Docker and Nginx configuration files for Coolify deployment

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-21 23:01:39 +07:00
gpt-engineer-app[bot]
ef19864985 Refactor review UX to use name
- Align all review-related components to use profiles.name instead of profiles.full_name
- Update AdminReviews, TestimonialsSection, ProductReviews to fetch and display name via name field
- Adjust related admin member and consultant views to reference name
- Update MemberProfile editing flow placeholders to reflect name field
- Ensure public reviews still render with approved reviews only and inline summaries where applicable

X-Lovable-Edit-ID: edt-81d7dcc8-ea28-4072-9da0-5a7d623fb1ed
2025-12-21 15:38:23 +00:00
gpt-engineer-app[bot]
ed0d97bb4b Changes 2025-12-21 15:38:22 +00:00
gpt-engineer-app[bot]
f4a1dee9bd Refine review UX and fix admin
- Fix admin reviews queries to use proper profiles/user_id relation
- Update product/testimonials reviews components to reference correct profiles relation
- Ensure is_approved filtering remains for public displays
- Keep review UX focused on consulting/webinar/bootcamp entry points, not orders
- Prepare for inline review prompts without order-centric UI

X-Lovable-Edit-ID: edt-16853854-bdfa-46e3-8812-64bb44bd281f
2025-12-21 15:30:01 +00:00
gpt-engineer-app[bot]
891faa73f0 Changes 2025-12-21 15:30:01 +00:00
gpt-engineer-app[bot]
2906c94c14 Improve experience reviews flow
Implement an experience-centric review system:
- Add review modal/form and integrate with consulting, webinar, bootcamp flows
- Enable consultants to submit one review per slot after completion
- Extend reviews data model (order_id, type, etc.) with RLS coverage
- Admin reviews page wrapped in AppLayout with proper fetch and moderation
- Webinar/bootcamp prompts added inline on respective pages
- UI components for reviews reused across sections
- Add reusable ReviewModal and ConsultingHistory wiring
- Ensure only approved reviews display publicly

X-Lovable-Edit-ID: edt-26eb5d98-1267-4188-94f9-5a81716f484a
2025-12-21 15:09:52 +00:00
gpt-engineer-app[bot]
8a1ccb7acc Changes 2025-12-21 15:09:52 +00:00
gpt-engineer-app[bot]
d3f7544536 Enhance order & reviews features
- Fix: skip bucket creation when content already exists; add logic to handle existing bucket gracefully.
- Debug: investigate and resolve member order detail data fetch error; ensure proper data mapping and navigation.
- Add WhatsApp integration prompts, reviews system scaffolding, and frontend wiring for branding-driven content.
- Implement image handling improvements in RichTextEditor and ensure HTML rendering in descriptions.
- Enable ElasticEmail adapter and multi-provider email flow, plus daily/reminder capabilities.

X-Lovable-Edit-ID: edt-b568a0fb-5175-44a5-a6d0-52e2c9936894
2025-12-19 16:37:02 +00:00
gpt-engineer-app[bot]
cc7c330e83 Changes 2025-12-19 16:37:01 +00:00
gpt-engineer-app[bot]
461a14dfdc Continue remaining tasks
Implement frontend features for LMS gaps, branding, and admin consulting, plus enhancements to rich text and member order detail. This includes Google Meet webhook frontend stub, ElasticEmail adapter, HTML in descriptions, improved navigation, and improved UI/UX for consulting management and notifications.

X-Lovable-Edit-ID: edt-5031906b-9c9f-4b2f-8526-fe8a75340c65
2025-12-19 16:09:43 +00:00
gpt-engineer-app[bot]
04cae4fc54 Changes 2025-12-19 16:09:43 +00:00
26 changed files with 1968 additions and 145 deletions

79
.dockerignore Normal file
View File

@@ -0,0 +1,79 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Build outputs
dist
dist-ssr
build
# Development
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Cache
.cache
.parcel-cache
.temp
.tmp
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS
Thumbs.db
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage
*.lcov
# Git
.git
.gitignore
README.md
# Documentation
docs
# Tools
.eslintrc.cjs
.gitlab-ci.yml
.github
# Testing
__tests__
tests
*.test.js
*.test.ts
*.test.tsx
*.spec.js
*.spec.ts
*.spec.tsx
# Misc
*.md
!README.md

25
.env.example Normal file
View File

@@ -0,0 +1,25 @@
# Supabase Configuration
VITE_SUPABASE_URL=your_supabase_url_here
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key_here
VITE_SUPABASE_EDGE_URL=your_supabase_url_here/functions/v1
# Application Configuration
VITE_APP_NAME=Access Hub
VITE_APP_ENV=production
# Third-party Integrations
VITE_PAKASIR_API_KEY=your_pakasir_api_key_here
VITE_PAKASIR_PROJECT_SLUG=your_pakasir_project_slug
# Payment Configuration (if needed)
# VITE_MIDTRANS_CLIENT_KEY=your_midtrans_client_key
# Email Configuration (for edge functions)
# These will be set in Supabase Edge Function secrets
# SMTP_HOST=your_smtp_host
# SMTP_USER=your_smtp_user
# SMTP_PASS=your_smtp_password
# Other Configuration
VITE_ENABLE_ANALYTICS=false
VITE_DEBUG=false

47
Dockerfile Normal file
View File

@@ -0,0 +1,47 @@
# Build stage
FROM node:18-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (including dev dependencies for build)
RUN npm ci
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine AS production
# Copy custom nginx configuration
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built assets from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Create non-root user (optional but recommended for security)
RUN addgroup -g 1001 -S nodejs
RUN adduser -S nextjs -u 1001
# Change ownership of the nginx directory
RUN chown -R nextjs:nodejs /usr/share/nginx/html
RUN chown -R nextjs:nodejs /var/cache/nginx
RUN chown -R nextjs:nodejs /var/log/nginx
RUN chown -R nextjs:nodejs /etc/nginx/conf.d
RUN touch /var/run/nginx.pid
RUN chown -R nextjs:nodejs /var/run/nginx.pid
# Switch to non-root user
USER nextjs
# Expose port 80
EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

64
nginx.conf Normal file
View File

@@ -0,0 +1,64 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private must-revalidate auth;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/javascript
application/xml+rss
application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
access_log off;
}
# Cache HTML files for shorter time
location ~* \.html$ {
expires 1h;
add_header Cache-Control "public, must-revalidate";
}
# Handle React Router - try to serve file, fallback to index.html
location / {
try_files $uri $uri/ /index.html;
}
# API proxy (if needed for local development)
# location /api/ {
# proxy_pass http://backend:3000/;
# proxy_http_version 1.1;
# proxy_set_header Upgrade $http_upgrade;
# proxy_set_header Connection 'upgrade';
# proxy_set_header Host $host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# proxy_set_header X-Forwarded-Proto $scheme;
# proxy_cache_bypass $http_upgrade;
# }
# Error pages
error_page 404 /index.html;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View File

@@ -35,6 +35,7 @@ import AdminMembers from "./pages/admin/AdminMembers";
import AdminEvents from "./pages/admin/AdminEvents"; import AdminEvents from "./pages/admin/AdminEvents";
import AdminSettings from "./pages/admin/AdminSettings"; import AdminSettings from "./pages/admin/AdminSettings";
import AdminConsulting from "./pages/admin/AdminConsulting"; import AdminConsulting from "./pages/admin/AdminConsulting";
import AdminReviews from "./pages/admin/AdminReviews";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -76,6 +77,7 @@ const App = () => (
<Route path="/admin/events" element={<AdminEvents />} /> <Route path="/admin/events" element={<AdminEvents />} />
<Route path="/admin/settings" element={<AdminSettings />} /> <Route path="/admin/settings" element={<AdminSettings />} />
<Route path="/admin/consulting" element={<AdminConsulting />} /> <Route path="/admin/consulting" element={<AdminConsulting />} />
<Route path="/admin/reviews" element={<AdminReviews />} />
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>

View File

@@ -5,6 +5,7 @@ import { useCart } from '@/contexts/CartContext';
import { useBranding } from '@/hooks/useBranding'; import { useBranding } from '@/hooks/useBranding';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { Footer } from '@/components/Footer';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { import {
LayoutDashboard, LayoutDashboard,
@@ -22,6 +23,7 @@ import {
MoreHorizontal, MoreHorizontal,
X, X,
Video, Video,
Star,
} from 'lucide-react'; } from 'lucide-react';
interface NavItem { interface NavItem {
@@ -45,6 +47,7 @@ const adminNavItems: NavItem[] = [
{ label: 'Konsultasi', href: '/admin/consulting', icon: Video }, { label: 'Konsultasi', href: '/admin/consulting', icon: Video },
{ label: 'Order', href: '/admin/orders', icon: Receipt }, { label: 'Order', href: '/admin/orders', icon: Receipt },
{ label: 'Member', href: '/admin/members', icon: Users }, { label: 'Member', href: '/admin/members', icon: Users },
{ label: 'Ulasan', href: '/admin/reviews', icon: Star },
{ label: 'Kalender', href: '/admin/events', icon: Calendar }, { label: 'Kalender', href: '/admin/events', icon: Calendar },
{ label: 'Pengaturan', href: '/admin/settings', icon: Settings }, { label: 'Pengaturan', href: '/admin/settings', icon: Settings },
]; ];
@@ -101,7 +104,7 @@ export function AppLayout({ children }: AppLayoutProps) {
if (!user) { if (!user) {
// Public layout for non-authenticated pages // Public layout for non-authenticated pages
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background flex flex-col">
<header className="border-b-2 border-border bg-background sticky top-0 z-50"> <header className="border-b-2 border-border bg-background sticky top-0 z-50">
<div className="container mx-auto px-4 py-4 flex items-center justify-between"> <div className="container mx-auto px-4 py-4 flex items-center justify-between">
<Link to="/" className="text-2xl font-bold flex items-center gap-2"> <Link to="/" className="text-2xl font-bold flex items-center gap-2">
@@ -113,7 +116,7 @@ export function AppLayout({ children }: AppLayoutProps) {
</Link> </Link>
<nav className="flex items-center gap-4"> <nav className="flex items-center gap-4">
<Link to="/products" className="hover:underline font-medium">Produk</Link> <Link to="/products" className="hover:underline font-medium">Produk</Link>
<Link to="/events" className="hover:underline font-medium">Kalender</Link> <Link to="/calendar" className="hover:underline font-medium">Kalender</Link>
<Link to="/auth"> <Link to="/auth">
<Button variant="outline" size="sm" className="border-2"> <Button variant="outline" size="sm" className="border-2">
<User className="w-4 h-4 mr-2" /> <User className="w-4 h-4 mr-2" />
@@ -133,7 +136,8 @@ export function AppLayout({ children }: AppLayoutProps) {
</nav> </nav>
</div> </div>
</header> </header>
<main>{children}</main> <main className="flex-1">{children}</main>
<Footer />
</div> </div>
); );
} }

67
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,67 @@
import { Link } from 'react-router-dom';
import { useBranding } from '@/hooks/useBranding';
export function Footer() {
const branding = useBranding();
const brandName = branding.brand_name || 'LearnHub';
const currentYear = new Date().getFullYear();
return (
<footer className="border-t-2 border-border bg-muted/50 mt-auto">
<div className="container mx-auto px-4 py-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8">
{/* Brand */}
<div className="md:col-span-2">
<h3 className="font-bold text-lg mb-2">{brandName}</h3>
<p className="text-sm text-muted-foreground max-w-md">
{branding.brand_tagline || 'Platform pembelajaran online untuk mengembangkan skill dan karir Anda.'}
</p>
</div>
{/* Quick Links */}
<div>
<h4 className="font-semibold mb-3">Tautan</h4>
<ul className="space-y-2 text-sm">
<li>
<Link to="/products" className="text-muted-foreground hover:text-foreground transition-colors">
Produk
</Link>
</li>
<li>
<Link to="/calendar" className="text-muted-foreground hover:text-foreground transition-colors">
Kalender Event
</Link>
</li>
<li>
<Link to="/consulting" className="text-muted-foreground hover:text-foreground transition-colors">
Konsultasi
</Link>
</li>
</ul>
</div>
{/* Legal */}
<div>
<h4 className="font-semibold mb-3">Legal</h4>
<ul className="space-y-2 text-sm">
<li>
<Link to="/privacy" className="text-muted-foreground hover:text-foreground transition-colors">
Kebijakan Privasi
</Link>
</li>
<li>
<Link to="/terms" className="text-muted-foreground hover:text-foreground transition-colors">
Syarat & Ketentuan
</Link>
</li>
</ul>
</div>
</div>
<div className="border-t border-border mt-8 pt-6 text-center text-sm text-muted-foreground">
© {currentYear} {brandName}. All rights reserved.
</div>
</div>
</footer>
);
}

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,243 @@
import { useEffect, 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 { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { Video, Calendar, Clock, Star, MessageSquare, CheckCircle } from 'lucide-react';
import { format } from 'date-fns';
import { id } from 'date-fns/locale';
import { ReviewModal } from './ReviewModal';
interface ConsultingSlot {
id: string;
date: string;
start_time: string;
end_time: string;
status: string;
topic_category: string | null;
meet_link: string | null;
order_id: string | null;
}
interface ConsultingHistoryProps {
userId: string;
}
export function ConsultingHistory({ userId }: ConsultingHistoryProps) {
const [slots, setSlots] = useState<ConsultingSlot[]>([]);
const [reviewedSlotIds, setReviewedSlotIds] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [reviewModal, setReviewModal] = useState<{
open: boolean;
slotId: string;
orderId: string | null;
label: string;
}>({ open: false, slotId: '', orderId: null, label: '' });
useEffect(() => {
fetchData();
}, [userId]);
const fetchData = async () => {
// Fetch consulting slots
const { data: slotsData } = await supabase
.from('consulting_slots')
.select('id, date, start_time, end_time, status, topic_category, meet_link, order_id')
.eq('user_id', userId)
.order('date', { ascending: false });
if (slotsData) {
setSlots(slotsData);
// Check which slots have been reviewed
// We use a combination approach: check for consulting reviews by this user
// For consulting, we'll track by order_id since that's how we link them
const orderIds = slotsData
.filter(s => s.order_id)
.map(s => s.order_id as string);
if (orderIds.length > 0) {
const { data: reviewsData } = await supabase
.from('reviews')
.select('order_id')
.eq('user_id', userId)
.eq('type', 'consulting')
.in('order_id', orderIds);
if (reviewsData) {
const reviewedOrderIds = new Set(reviewsData.map(r => r.order_id));
// Map order_id back to slot_id
const reviewedIds = new Set(
slotsData
.filter(s => s.order_id && reviewedOrderIds.has(s.order_id))
.map(s => s.id)
);
setReviewedSlotIds(reviewedIds);
}
}
}
setLoading(false);
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'done':
return <Badge className="bg-accent">Selesai</Badge>;
case 'confirmed':
return <Badge className="bg-primary">Terkonfirmasi</Badge>;
case 'pending_payment':
return <Badge className="bg-secondary">Menunggu Pembayaran</Badge>;
case 'cancelled':
return <Badge variant="destructive">Dibatalkan</Badge>;
default:
return <Badge variant="outline">{status}</Badge>;
}
};
const openReviewModal = (slot: ConsultingSlot) => {
const dateLabel = format(new Date(slot.date), 'd MMMM yyyy', { locale: id });
const timeLabel = `${slot.start_time.substring(0, 5)} - ${slot.end_time.substring(0, 5)}`;
setReviewModal({
open: true,
slotId: slot.id,
orderId: slot.order_id,
label: `Sesi konsultasi ${dateLabel}, ${timeLabel}`,
});
};
const handleReviewSuccess = () => {
// Mark this slot as reviewed
setReviewedSlotIds(prev => new Set([...prev, reviewModal.slotId]));
};
const doneSlots = slots.filter(s => s.status === 'done');
const upcomingSlots = slots.filter(s => s.status === 'confirmed');
if (loading) {
return (
<Card className="border-2 border-border">
<CardHeader>
<Skeleton className="h-6 w-40" />
</CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(2)].map((_, i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
</CardContent>
</Card>
);
}
if (slots.length === 0) {
return null;
}
return (
<>
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Video className="w-5 h-5" />
Riwayat Konsultasi
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Upcoming sessions */}
{upcomingSlots.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-muted-foreground">Sesi Mendatang</h4>
{upcomingSlots.map((slot) => (
<div key={slot.id} className="flex items-center justify-between p-3 border-2 border-border bg-muted/30">
<div className="flex items-center gap-3">
<Calendar className="w-4 h-4 text-muted-foreground" />
<div>
<p className="font-medium">
{format(new Date(slot.date), 'd MMM yyyy', { locale: id })}
</p>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}
{slot.topic_category && `${slot.topic_category}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{getStatusBadge(slot.status)}
{slot.meet_link && (
<Button asChild size="sm" variant="outline" className="border-2">
<a href={slot.meet_link} target="_blank" rel="noopener noreferrer">
Join
</a>
</Button>
)}
</div>
</div>
))}
</div>
)}
{/* Completed sessions */}
{doneSlots.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-muted-foreground">Sesi Selesai</h4>
{doneSlots.map((slot) => {
const hasReviewed = reviewedSlotIds.has(slot.id);
return (
<div key={slot.id} className="flex items-center justify-between p-3 border-2 border-border">
<div className="flex items-center gap-3">
<Calendar className="w-4 h-4 text-muted-foreground" />
<div>
<p className="font-medium">
{format(new Date(slot.date), 'd MMM yyyy', { locale: id })}
</p>
<p className="text-sm text-muted-foreground flex items-center gap-1">
<Clock className="w-3 h-3" />
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}
{slot.topic_category && `${slot.topic_category}`}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{getStatusBadge(slot.status)}
{hasReviewed ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<CheckCircle className="w-4 h-4 text-accent" />
Sudah diulas
</span>
) : (
<Button
size="sm"
variant="outline"
onClick={() => openReviewModal(slot)}
className="border-2"
>
<Star className="w-4 h-4 mr-1" />
Beri ulasan
</Button>
)}
</div>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
<ReviewModal
open={reviewModal.open}
onOpenChange={(open) => setReviewModal({ ...reviewModal, open })}
userId={userId}
productId={null}
orderId={reviewModal.orderId}
type="consulting"
contextLabel={reviewModal.label}
onSuccess={handleReviewSuccess}
/>
</>
);
}

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: { 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:user_id (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?.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,149 @@
import { useState } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
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, X } from 'lucide-react';
interface ReviewModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
userId: string;
productId?: string | null;
orderId?: string | null;
type: 'consulting' | 'bootcamp' | 'webinar' | 'general';
contextLabel?: string;
onSuccess?: () => void;
}
export function ReviewModal({
open,
onOpenChange,
userId,
productId,
orderId,
type,
contextLabel,
onSuccess,
}: ReviewModalProps) {
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,
order_id: orderId || null,
type,
rating,
title: title.trim(),
body: body.trim() || null,
is_approved: false,
});
if (error) {
console.error('Review submit error:', error);
toast({ title: 'Error', description: 'Gagal mengirim ulasan', variant: 'destructive' });
} else {
toast({ title: 'Berhasil', description: 'Terima kasih! Ulasan Anda akan ditinjau oleh admin.' });
// Reset form
setRating(0);
setTitle('');
setBody('');
onOpenChange(false);
onSuccess?.();
}
setSubmitting(false);
};
const handleClose = () => {
if (!submitting) {
onOpenChange(false);
}
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Beri Ulasan</DialogTitle>
{contextLabel && (
<DialogDescription>{contextLabel}</DialogDescription>
)}
</DialogHeader>
<div className="space-y-4 py-2">
<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-8 h-8 ${
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-[100px]"
maxLength={500}
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button variant="outline" onClick={handleClose} disabled={submitting} className="border-2">
Batal
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? 'Mengirim...' : 'Kirim Ulasan'}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

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: { 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:user_id (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?.name || 'Anonymous'}
date={review.created_at}
/>
))}
</div>
</section>
);
}

View File

@@ -1,7 +1,7 @@
import { createClient } from '@supabase/supabase-js'; import { createClient } from '@supabase/supabase-js';
const SUPABASE_URL = 'https://lovable.backoffice.biz.id'; const SUPABASE_URL = import.meta.env.VITE_SUPABASE_URL || 'https://lovable.backoffice.biz.id';
const SUPABASE_ANON_KEY = 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc2NjAzNzEyMCwiZXhwIjo0OTIxNzEwNzIwLCJyb2xlIjoiYW5vbiJ9.Sa-eECy9dgBUQy3O4X5X-3tDPmF01J5zeT-Qtb-koYc'; const SUPABASE_ANON_KEY = import.meta.env.VITE_SUPABASE_ANON_KEY || 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsImlhdCI6MTc2NjAzNzEyMCwiZXhwIjo0OTIxNzEwNzIwLCJyb2xlIjoiYW5vbiJ9.Sa-eECy9dgBUQy3O4X5X-3tDPmF01J5zeT-Qtb-koYc';
export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { export const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
auth: { auth: {

View File

@@ -7,9 +7,10 @@ import { Card, CardContent } from '@/components/ui/card';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { formatDuration } from '@/lib/format'; import { formatDuration } from '@/lib/format';
import { ChevronLeft, ChevronRight, Check, Play, BookOpen, Clock, Menu, X } from 'lucide-react'; import { ChevronLeft, ChevronRight, Check, Play, BookOpen, Clock, Menu, Star, CheckCircle } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { ReviewModal } from '@/components/reviews/ReviewModal';
interface Product { interface Product {
id: string; id: string;
@@ -51,6 +52,8 @@ 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 [reviewModalOpen, setReviewModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (!authLoading && !user) { if (!authLoading && !user) {
@@ -129,6 +132,16 @@ export default function Bootcamp() {
setProgress(progressData); setProgress(progressData);
} }
// Check if user has already reviewed this bootcamp
const { data: reviewData } = await supabase
.from('reviews')
.select('id')
.eq('user_id', user!.id)
.eq('product_id', productData.id)
.limit(1);
setHasReviewed(!!(reviewData && reviewData.length > 0));
setLoading(false); setLoading(false);
}; };
@@ -137,7 +150,7 @@ export default function Bootcamp() {
}; };
const markAsCompleted = async () => { const markAsCompleted = async () => {
if (!selectedLesson || !user) return; if (!selectedLesson || !user || !product) return;
const { error } = await supabase const { error } = await supabase
.from('lesson_progress') .from('lesson_progress')
@@ -152,7 +165,34 @@ export default function Bootcamp() {
return; return;
} }
setProgress([...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }]); const newProgress = [...progress, { lesson_id: selectedLesson.id, completed_at: new Date().toISOString() }];
setProgress(newProgress);
// Calculate completion percentage for notification
const completedCount = newProgress.length;
const completionPercent = Math.round((completedCount / totalLessons) * 100);
// Trigger progress notification at milestones
if (completionPercent === 25 || completionPercent === 50 || completionPercent === 75 || completionPercent === 100) {
try {
await supabase.functions.invoke('send-notification', {
body: {
template_key: 'bootcamp_progress',
recipient_email: user.email,
recipient_name: user.user_metadata?.name || 'Peserta',
variables: {
bootcamp_title: product.title,
progress_percent: completionPercent.toString(),
completed_lessons: completedCount.toString(),
total_lessons: totalLessons.toString(),
},
},
});
} catch (err) {
console.log('Progress notification skipped:', err);
}
}
toast({ title: 'Selesai!', description: 'Pelajaran ditandai selesai' }); toast({ title: 'Selesai!', description: 'Pelajaran ditandai selesai' });
goToNextLesson(); goToNextLesson();
}; };
@@ -185,6 +225,7 @@ export default function Bootcamp() {
const completedCount = progress.length; const completedCount = progress.length;
const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0); const totalLessons = modules.reduce((sum, m) => sum + m.lessons.length, 0);
const isBootcampCompleted = totalLessons > 0 && completedCount >= totalLessons;
const renderSidebarContent = () => ( const renderSidebarContent = () => (
<div className="p-4"> <div className="p-4">
@@ -384,6 +425,31 @@ export default function Bootcamp() {
<ChevronRight className="w-4 h-4 ml-2" /> <ChevronRight className="w-4 h-4 ml-2" />
</Button> </Button>
</div> </div>
{/* Bootcamp completion review prompt */}
{isBootcampCompleted && (
<Card className="border-2 border-primary/20 mt-6">
<CardContent className="py-4">
{hasReviewed ? (
<div className="flex items-center gap-2 text-muted-foreground">
<CheckCircle className="w-5 h-5 text-accent" />
<span>Terima kasih atas ulasan Anda (menunggu moderasi)</span>
</div>
) : (
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<p className="font-medium">🎉 Selamat menyelesaikan bootcamp!</p>
<p className="text-sm text-muted-foreground">Bagikan pengalaman Anda</p>
</div>
<Button onClick={() => setReviewModalOpen(true)} variant="outline" className="border-2">
<Star className="w-4 h-4 mr-2" />
Beri ulasan
</Button>
</div>
)}
</CardContent>
</Card>
)}
</div> </div>
) : ( ) : (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
@@ -399,6 +465,19 @@ export default function Bootcamp() {
)} )}
</main> </main>
</div> </div>
{/* Review Modal */}
{user && product && (
<ReviewModal
open={reviewModalOpen}
onOpenChange={setReviewModalOpen}
userId={user.id}
productId={product.id}
type="bootcamp"
contextLabel={product.title}
onSuccess={() => setHasReviewed(true)}
/>
)}
</div> </div>
); );
} }

View File

@@ -14,7 +14,7 @@ import { Trash2, CreditCard, Loader2, QrCode, Wallet } from "lucide-react";
import { QRCodeSVG } from "qrcode.react"; import { QRCodeSVG } from "qrcode.react";
// Pakasir configuration // Pakasir configuration
const PAKASIR_PROJECT_SLUG = "dewengoding"; const PAKASIR_PROJECT_SLUG = import.meta.env.VITE_PAKASIR_PROJECT_SLUG || "dewengoding";
const SANDBOX_API_KEY = "iP13osgh7lAzWWIPsj7TbW5M3iGEAQMo"; const SANDBOX_API_KEY = "iP13osgh7lAzWWIPsj7TbW5M3iGEAQMo";
// Centralized API key retrieval - uses env var with sandbox fallback // Centralized API key retrieval - uses env var with sandbox fallback

View File

@@ -7,6 +7,7 @@ import { AppLayout } from '@/components/AppLayout';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Calendar } from '@/components/ui/calendar'; import { Calendar } from '@/components/ui/calendar';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -43,6 +44,10 @@ interface TimeSlot {
available: boolean; available: boolean;
} }
interface Profile {
whatsapp_number: string | null;
}
export default function ConsultingBooking() { export default function ConsultingBooking() {
const { user, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -52,11 +57,13 @@ export default function ConsultingBooking() {
const [workhours, setWorkhours] = useState<Workhour[]>([]); const [workhours, setWorkhours] = useState<Workhour[]>([]);
const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]); const [confirmedSlots, setConfirmedSlots] = useState<ConfirmedSlot[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [profile, setProfile] = useState<Profile | null>(null);
const [selectedDate, setSelectedDate] = useState<Date | undefined>(addDays(new Date(), 1)); const [selectedDate, setSelectedDate] = useState<Date | undefined>(addDays(new Date(), 1));
const [selectedSlots, setSelectedSlots] = useState<string[]>([]); const [selectedSlots, setSelectedSlots] = useState<string[]>([]);
const [selectedCategory, setSelectedCategory] = useState(''); const [selectedCategory, setSelectedCategory] = useState('');
const [notes, setNotes] = useState(''); const [notes, setNotes] = useState('');
const [whatsappInput, setWhatsappInput] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
useEffect(() => { useEffect(() => {
@@ -70,13 +77,15 @@ export default function ConsultingBooking() {
}, [selectedDate]); }, [selectedDate]);
const fetchData = async () => { const fetchData = async () => {
const [settingsRes, workhoursRes] = await Promise.all([ const [settingsRes, workhoursRes, profileRes] = await Promise.all([
supabase.from('consulting_settings').select('*').single(), supabase.from('consulting_settings').select('*').single(),
supabase.from('workhours').select('*').order('weekday'), supabase.from('workhours').select('*').order('weekday'),
user ? supabase.from('profiles').select('whatsapp_number').eq('id', user.id).single() : Promise.resolve({ data: null }),
]); ]);
if (settingsRes.data) setSettings(settingsRes.data); if (settingsRes.data) setSettings(settingsRes.data);
if (workhoursRes.data) setWorkhours(workhoursRes.data); if (workhoursRes.data) setWorkhours(workhoursRes.data);
if (profileRes.data) setProfile(profileRes.data);
setLoading(false); setLoading(false);
}; };
@@ -174,6 +183,15 @@ export default function ConsultingBooking() {
setSubmitting(true); setSubmitting(true);
try { try {
// Save WhatsApp number if provided and not already saved
if (whatsappInput && !profile?.whatsapp_number) {
let normalized = whatsappInput.replace(/\D/g, '');
if (normalized.startsWith('0')) normalized = '62' + normalized.substring(1);
if (!normalized.startsWith('+')) normalized = '+' + normalized;
await supabase.from('profiles').update({ whatsapp_number: normalized }).eq('id', user.id);
}
// Create order // Create order
const { data: order, error: orderError } = await supabase const { data: order, error: orderError } = await supabase
.from('orders') .from('orders')
@@ -348,13 +366,29 @@ export default function ConsultingBooking() {
Catatan (Opsional) Catatan (Opsional)
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent className="space-y-4">
<Textarea <Textarea
value={notes} value={notes}
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
placeholder="Jelaskan topik atau pertanyaan yang ingin dibahas..." placeholder="Jelaskan topik atau pertanyaan yang ingin dibahas..."
className="border-2 min-h-[100px]" className="border-2 min-h-[100px]"
/> />
{/* WhatsApp prompt if not saved */}
{user && !profile?.whatsapp_number && (
<div className="space-y-2 pt-2 border-t border-border">
<Label className="text-sm">Nomor WhatsApp untuk pengingat sesi ini (opsional)</Label>
<Input
value={whatsappInput}
onChange={(e) => setWhatsappInput(e.target.value)}
placeholder="08123456789"
className="border-2"
/>
<p className="text-xs text-muted-foreground">
Akan otomatis tersimpan ke profil Anda
</p>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
import { Layout } from '@/components/Layout'; import { Layout } from '@/components/Layout';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useBranding } from '@/hooks/useBranding'; import { useBranding } from '@/hooks/useBranding';
import { TestimonialsSection } from '@/components/reviews/TestimonialsSection';
import { ArrowRight, BookOpen, Video, Users, Star, Award, Target, Zap, Heart, Shield, Rocket } from 'lucide-react'; import { ArrowRight, BookOpen, Video, Users, Star, Award, Target, Zap, Heart, Shield, Rocket } from 'lucide-react';
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = { const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
@@ -60,6 +61,8 @@ export default function Index() {
})} })}
</div> </div>
</section> </section>
<TestimonialsSection />
</Layout> </Layout>
); );
} }

View File

@@ -10,8 +10,10 @@ import { useAuth } from '@/hooks/useAuth';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { formatIDR, formatDuration } from '@/lib/format'; import { formatIDR, formatDuration } from '@/lib/format';
import { Video, Calendar, BookOpen, Play, Clock, ChevronDown, ChevronRight } from 'lucide-react'; import { Video, Calendar, BookOpen, Play, Clock, ChevronDown, ChevronRight, Star, CheckCircle } from 'lucide-react';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import { ReviewModal } from '@/components/reviews/ReviewModal';
import { ProductReviews } from '@/components/reviews/ProductReviews';
interface Product { interface Product {
id: string; id: string;
@@ -24,6 +26,8 @@ interface Product {
sale_price: number | null; sale_price: number | null;
meeting_link: string | null; meeting_link: string | null;
recording_url: string | null; recording_url: string | null;
event_start: string | null;
duration_minutes: number | null;
created_at: string; created_at: string;
} }
@@ -50,6 +54,8 @@ export default function ProductDetail() {
const [hasAccess, setHasAccess] = useState(false); const [hasAccess, setHasAccess] = useState(false);
const [checkingAccess, setCheckingAccess] = useState(true); const [checkingAccess, setCheckingAccess] = useState(true);
const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set()); const [expandedModules, setExpandedModules] = useState<Set<string>>(new Set());
const [hasReviewed, setHasReviewed] = useState(false);
const [reviewModalOpen, setReviewModalOpen] = useState(false);
const { addItem, items } = useCart(); const { addItem, items } = useCart();
const { user } = useAuth(); const { user } = useAuth();
@@ -60,6 +66,7 @@ export default function ProductDetail() {
useEffect(() => { useEffect(() => {
if (product && user) { if (product && user) {
checkUserAccess(); checkUserAccess();
checkUserReview();
} else { } else {
setCheckingAccess(false); setCheckingAccess(false);
} }
@@ -153,6 +160,28 @@ export default function ProductDetail() {
setCheckingAccess(false); setCheckingAccess(false);
}; };
const checkUserReview = async () => {
if (!product || !user) return;
const { data } = await supabase
.from('reviews')
.select('id')
.eq('user_id', user.id)
.eq('product_id', product.id)
.limit(1);
setHasReviewed(!!(data && data.length > 0));
};
// Check if webinar has ended (eligible for review)
const isWebinarEnded = () => {
if (!product || product.type !== 'webinar' || !product.event_start) return false;
const eventStart = new Date(product.event_start);
const durationMs = (product.duration_minutes || 60) * 60 * 1000;
const eventEnd = new Date(eventStart.getTime() + durationMs);
return new Date() > eventEnd;
};
const handleAddToCart = () => { const handleAddToCart = () => {
if (!product) return; if (!product) return;
addItem({ id: product.id, title: product.title, price: product.price, sale_price: product.sale_price, type: product.type }); addItem({ id: product.id, title: product.title, price: product.price, sale_price: product.sale_price, type: product.type });
@@ -358,11 +387,54 @@ export default function ProductDetail() {
</Card> </Card>
)} )}
<div className="flex gap-4"> <div className="flex gap-4 flex-wrap">
{renderActionButtons()} {renderActionButtons()}
</div> </div>
{/* Webinar review prompt */}
{hasAccess && product.type === 'webinar' && isWebinarEnded() && (
<Card className="border-2 border-primary/20 mt-6">
<CardContent className="py-4">
{hasReviewed ? (
<div className="flex items-center gap-2 text-muted-foreground">
<CheckCircle className="w-5 h-5 text-accent" />
<span>Terima kasih atas ulasan Anda (menunggu moderasi)</span>
</div>
) : (
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<p className="font-medium">Bagaimana pengalaman webinar ini?</p>
<p className="text-sm text-muted-foreground">Ulasan Anda membantu peserta lain</p>
</div>
<Button onClick={() => setReviewModalOpen(true)} variant="outline" className="border-2">
<Star className="w-4 h-4 mr-2" />
Beri ulasan webinar ini
</Button>
</div>
)}
</CardContent>
</Card>
)}
{/* Product reviews section */}
<div className="mt-8">
<ProductReviews productId={product.id} />
</div>
</div> </div>
</div> </div>
{/* Review Modal */}
{user && (
<ReviewModal
open={reviewModalOpen}
onOpenChange={setReviewModalOpen}
userId={user.id}
productId={product.id}
type="webinar"
contextLabel={product.title}
onSuccess={() => setHasReviewed(true)}
/>
)}
</AppLayout> </AppLayout>
); );
} }

View File

@@ -10,10 +10,12 @@ import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { formatIDR } from '@/lib/format'; import { formatIDR } from '@/lib/format';
import { Video, Calendar, Clock, User, Link as LinkIcon, ExternalLink } from 'lucide-react'; import { Video, Calendar, Clock, User, Link as LinkIcon, ExternalLink, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { format, parseISO } from 'date-fns'; import { format, parseISO, isToday, isTomorrow, isPast } from 'date-fns';
import { id } from 'date-fns/locale'; import { id } from 'date-fns/locale';
interface ConsultingSlot { interface ConsultingSlot {
@@ -29,11 +31,16 @@ interface ConsultingSlot {
meet_link: string | null; meet_link: string | null;
created_at: string; created_at: string;
profiles?: { profiles?: {
full_name: string; name: string;
email: string; email: string;
}; };
} }
interface PlatformSettings {
integration_n8n_base_url?: string;
integration_google_calendar_id?: string;
}
const statusLabels: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = { const statusLabels: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
pending_payment: { label: 'Menunggu Pembayaran', variant: 'secondary' }, pending_payment: { label: 'Menunggu Pembayaran', variant: 'secondary' },
confirmed: { label: 'Dikonfirmasi', variant: 'default' }, confirmed: { label: 'Dikonfirmasi', variant: 'default' },
@@ -46,17 +53,23 @@ export default function AdminConsulting() {
const navigate = useNavigate(); const navigate = useNavigate();
const [slots, setSlots] = useState<ConsultingSlot[]>([]); const [slots, setSlots] = useState<ConsultingSlot[]>([]);
const [settings, setSettings] = useState<PlatformSettings>({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [selectedSlot, setSelectedSlot] = useState<ConsultingSlot | null>(null); const [selectedSlot, setSelectedSlot] = useState<ConsultingSlot | null>(null);
const [meetLink, setMeetLink] = useState(''); const [meetLink, setMeetLink] = useState('');
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [creatingMeet, setCreatingMeet] = useState(false);
const [activeTab, setActiveTab] = useState('upcoming');
useEffect(() => { useEffect(() => {
if (!authLoading) { if (!authLoading) {
if (!user) navigate('/auth'); if (!user) navigate('/auth');
else if (!isAdmin) navigate('/dashboard'); else if (!isAdmin) navigate('/dashboard');
else fetchSlots(); else {
fetchSlots();
fetchSettings();
}
} }
}, [user, isAdmin, authLoading]); }, [user, isAdmin, authLoading]);
@@ -65,7 +78,7 @@ export default function AdminConsulting() {
.from('consulting_slots') .from('consulting_slots')
.select(` .select(`
*, *,
profiles:user_id (full_name, email) profiles:user_id (name, email)
`) `)
.order('date', { ascending: false }) .order('date', { ascending: false })
.order('start_time', { ascending: true }); .order('start_time', { ascending: true });
@@ -74,6 +87,15 @@ export default function AdminConsulting() {
setLoading(false); setLoading(false);
}; };
const fetchSettings = async () => {
const { data } = await supabase
.from('platform_settings')
.select('integration_n8n_base_url, integration_google_calendar_id')
.single();
if (data) setSettings(data);
};
const openMeetDialog = (slot: ConsultingSlot) => { const openMeetDialog = (slot: ConsultingSlot) => {
setSelectedSlot(slot); setSelectedSlot(slot);
setMeetLink(slot.meet_link || ''); setMeetLink(slot.meet_link || '');
@@ -96,31 +118,98 @@ export default function AdminConsulting() {
setDialogOpen(false); setDialogOpen(false);
fetchSlots(); fetchSlots();
// TODO: Trigger notification with meet link // Send notification to client with meet link
console.log('Would trigger consulting_scheduled notification with meet_link:', meetLink); if (meetLink && selectedSlot.profiles?.email) {
try {
await supabase.functions.invoke('send-notification', {
body: {
template_key: 'consulting_scheduled',
recipient_email: selectedSlot.profiles.email,
recipient_name: selectedSlot.profiles.name,
variables: {
consultation_date: format(parseISO(selectedSlot.date), 'd MMMM yyyy', { locale: id }),
consultation_time: `${selectedSlot.start_time.substring(0, 5)} - ${selectedSlot.end_time.substring(0, 5)}`,
meet_link: meetLink,
topic_category: selectedSlot.topic_category,
},
},
});
} catch (err) {
console.log('Notification skipped:', err);
}
}
} }
setSaving(false); setSaving(false);
}; };
const createMeetLink = async () => { const createMeetLink = async () => {
// Placeholder for Google Calendar API integration if (!selectedSlot) return;
const GOOGLE_CLIENT_ID = import.meta.env.VITE_GOOGLE_CLIENT_ID;
if (!GOOGLE_CLIENT_ID) { // Check if n8n webhook is configured
const webhookUrl = settings.integration_n8n_base_url;
if (!webhookUrl) {
toast({ toast({
title: 'Info', title: 'Info',
description: 'VITE_GOOGLE_CLIENT_ID belum dikonfigurasi. Masukkan link Meet secara manual.', description: 'Webhook URL belum dikonfigurasi di Pengaturan Integrasi. Masukkan link Meet secara manual.',
}); });
return; return;
} }
// TODO: Implement actual Google Calendar API call setCreatingMeet(true);
// For now, log what would happen
console.log('Would call Google Calendar API to create Meet link for slot:', selectedSlot); try {
toast({ // Call the webhook to create Google Meet link
title: 'Info', const response = await fetch(`${webhookUrl}/create-meet`, {
description: 'Integrasi Google Calendar akan tersedia setelah konfigurasi OAuth selesai.', method: 'POST',
}); headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
slot_id: selectedSlot.id,
date: selectedSlot.date,
start_time: selectedSlot.start_time,
end_time: selectedSlot.end_time,
topic: selectedSlot.topic_category,
client_name: selectedSlot.profiles?.name || 'Client',
client_email: selectedSlot.profiles?.email,
calendar_id: settings.integration_google_calendar_id,
}),
});
if (!response.ok) {
throw new Error('Webhook request failed');
}
const data = await response.json();
if (data.meet_link) {
setMeetLink(data.meet_link);
toast({ title: 'Berhasil', description: 'Link Google Meet dibuat' });
} else {
throw new Error('No meet_link in response');
}
} catch (error) {
console.error('Error creating meet link:', error);
toast({
title: 'Gagal',
description: 'Gagal membuat link Meet. Pastikan webhook sudah dikonfigurasi dengan benar.',
variant: 'destructive',
});
} finally {
setCreatingMeet(false);
}
};
const updateSlotStatus = async (slotId: string, newStatus: string) => {
const { error } = await supabase
.from('consulting_slots')
.update({ status: newStatus })
.eq('id', slotId);
if (error) {
toast({ title: 'Error', description: error.message, variant: 'destructive' });
} else {
toast({ title: 'Berhasil', description: `Status diubah ke ${statusLabels[newStatus]?.label || newStatus}` });
fetchSlots();
}
}; };
if (authLoading || loading) { if (authLoading || loading) {
@@ -134,8 +223,10 @@ export default function AdminConsulting() {
); );
} }
const confirmedSlots = slots.filter(s => s.status === 'confirmed'); const today = new Date().toISOString().split('T')[0];
const pendingSlots = slots.filter(s => s.status === 'pending_payment'); const upcomingSlots = slots.filter(s => s.date >= today && (s.status === 'confirmed' || s.status === 'pending_payment'));
const pastSlots = slots.filter(s => s.date < today || s.status === 'completed' || s.status === 'cancelled');
const todaySlots = slots.filter(s => isToday(parseISO(s.date)) && s.status === 'confirmed');
return ( return (
<AppLayout> <AppLayout>
@@ -148,113 +239,214 @@ export default function AdminConsulting() {
Kelola jadwal dan link Google Meet untuk sesi konsultasi Kelola jadwal dan link Google Meet untuk sesi konsultasi
</p> </p>
{/* Today's Sessions Alert */}
{todaySlots.length > 0 && (
<Card className="border-2 border-primary bg-primary/5 mb-6">
<CardContent className="py-4">
<h3 className="font-bold flex items-center gap-2">
<Calendar className="w-5 h-5" />
Sesi Hari Ini ({todaySlots.length})
</h3>
<div className="mt-2 space-y-2">
{todaySlots.map(slot => (
<div key={slot.id} className="flex items-center justify-between text-sm">
<span>
{slot.start_time.substring(0, 5)} - {slot.profiles?.name || 'N/A'} ({slot.topic_category})
</span>
{slot.meet_link ? (
<a href={slot.meet_link} target="_blank" rel="noopener noreferrer" className="text-primary underline flex items-center gap-1">
<ExternalLink className="w-3 h-3" /> Join
</a>
) : (
<Button size="sm" variant="outline" onClick={() => openMeetDialog(slot)}>
Tambah Link
</Button>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
<Card className="border-2 border-border"> <Card className="border-2 border-border">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="text-2xl font-bold">{confirmedSlots.length}</div> <div className="text-2xl font-bold">{todaySlots.length}</div>
<p className="text-sm text-muted-foreground">Hari Ini</p>
</CardContent>
</Card>
<Card className="border-2 border-border">
<CardContent className="pt-6">
<div className="text-2xl font-bold">{upcomingSlots.filter(s => s.status === 'confirmed').length}</div>
<p className="text-sm text-muted-foreground">Dikonfirmasi</p> <p className="text-sm text-muted-foreground">Dikonfirmasi</p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-2 border-border"> <Card className="border-2 border-border">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="text-2xl font-bold">{pendingSlots.length}</div> <div className="text-2xl font-bold">{upcomingSlots.filter(s => !s.meet_link && s.status === 'confirmed').length}</div>
<p className="text-sm text-muted-foreground">Menunggu Pembayaran</p> <p className="text-sm text-muted-foreground">Perlu Link Meet</p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-2 border-border"> <Card className="border-2 border-border">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="text-2xl font-bold"> <div className="text-2xl font-bold">{pastSlots.filter(s => s.status === 'completed').length}</div>
{confirmedSlots.filter(s => !s.meet_link).length} <p className="text-sm text-muted-foreground">Selesai</p>
</div>
<p className="text-sm text-muted-foreground">Perlu Link Meet</p>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Slots Table */} {/* Tabs */}
<Card className="border-2 border-border"> <Tabs value={activeTab} onValueChange={setActiveTab}>
<CardHeader> <TabsList className="mb-4">
<CardTitle>Jadwal Konsultasi</CardTitle> <TabsTrigger value="upcoming">Mendatang ({upcomingSlots.length})</TabsTrigger>
<CardDescription>Daftar semua booking konsultasi</CardDescription> <TabsTrigger value="past">Riwayat ({pastSlots.length})</TabsTrigger>
</CardHeader> </TabsList>
<CardContent className="p-0">
<Table> <TabsContent value="upcoming">
<TableHeader> <Card className="border-2 border-border">
<TableRow> <CardContent className="p-0">
<TableHead>Tanggal</TableHead> <Table>
<TableHead>Waktu</TableHead> <TableHeader>
<TableHead>Klien</TableHead> <TableRow>
<TableHead>Kategori</TableHead> <TableHead>Tanggal</TableHead>
<TableHead>Status</TableHead> <TableHead>Waktu</TableHead>
<TableHead>Link Meet</TableHead> <TableHead>Klien</TableHead>
<TableHead className="text-right">Aksi</TableHead> <TableHead>Kategori</TableHead>
</TableRow> <TableHead>Status</TableHead>
</TableHeader> <TableHead>Link Meet</TableHead>
<TableBody> <TableHead className="text-right">Aksi</TableHead>
{slots.map((slot) => ( </TableRow>
<TableRow key={slot.id}> </TableHeader>
<TableCell className="font-medium"> <TableBody>
{format(parseISO(slot.date), 'd MMM yyyy', { locale: id })} {upcomingSlots.map((slot) => (
</TableCell> <TableRow key={slot.id}>
<TableCell> <TableCell className="font-medium">
{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)} <div>
</TableCell> {format(parseISO(slot.date), 'd MMM yyyy', { locale: id })}
<TableCell> {isToday(parseISO(slot.date)) && <Badge className="ml-2 bg-primary">Hari Ini</Badge>}
<div> {isTomorrow(parseISO(slot.date)) && <Badge className="ml-2 bg-accent">Besok</Badge>}
<p className="font-medium">{slot.profiles?.full_name || '-'}</p> </div>
<p className="text-sm text-muted-foreground">{slot.profiles?.email}</p> </TableCell>
</div> <TableCell>
</TableCell> {slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}
<TableCell> </TableCell>
<Badge variant="outline">{slot.topic_category}</Badge> <TableCell>
</TableCell> <div>
<TableCell> <p className="font-medium">{slot.profiles?.name || '-'}</p>
<Badge variant={statusLabels[slot.status]?.variant || 'secondary'}> <p className="text-sm text-muted-foreground">{slot.profiles?.email}</p>
{statusLabels[slot.status]?.label || slot.status} </div>
</Badge> </TableCell>
</TableCell> <TableCell>
<TableCell> <Badge variant="outline">{slot.topic_category}</Badge>
{slot.meet_link ? ( </TableCell>
<a <TableCell>
href={slot.meet_link} <Badge variant={statusLabels[slot.status]?.variant || 'secondary'}>
target="_blank" {statusLabels[slot.status]?.label || slot.status}
rel="noopener noreferrer" </Badge>
className="text-primary hover:underline flex items-center gap-1" </TableCell>
> <TableCell>
<ExternalLink className="w-4 h-4" /> {slot.meet_link ? (
Buka <a
</a> href={slot.meet_link}
) : ( target="_blank"
<span className="text-muted-foreground">-</span> rel="noopener noreferrer"
)} className="text-primary hover:underline flex items-center gap-1"
</TableCell> >
<TableCell className="text-right"> <ExternalLink className="w-4 h-4" />
{slot.status === 'confirmed' && ( Buka
<Button </a>
variant="outline" ) : (
size="sm" <span className="text-muted-foreground">-</span>
onClick={() => openMeetDialog(slot)} )}
className="border-2" </TableCell>
> <TableCell className="text-right space-x-2">
<LinkIcon className="w-4 h-4 mr-2" /> {slot.status === 'confirmed' && (
{slot.meet_link ? 'Edit Link' : 'Tambah Link'} <>
</Button> <Button
)} variant="outline"
</TableCell> size="sm"
</TableRow> onClick={() => openMeetDialog(slot)}
))} className="border-2"
{slots.length === 0 && ( >
<TableRow> <LinkIcon className="w-4 h-4 mr-1" />
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground"> {slot.meet_link ? 'Edit' : 'Link'}
Belum ada booking konsultasi </Button>
</TableCell> <Button
</TableRow> variant="outline"
)} size="sm"
</TableBody> onClick={() => updateSlotStatus(slot.id, 'completed')}
</Table> className="border-2 text-green-600"
</CardContent> >
</Card> <CheckCircle className="w-4 h-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => updateSlotStatus(slot.id, 'cancelled')}
className="border-2 text-destructive"
>
<XCircle className="w-4 h-4" />
</Button>
</>
)}
</TableCell>
</TableRow>
))}
{upcomingSlots.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
Tidak ada jadwal mendatang
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="past">
<Card className="border-2 border-border">
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Tanggal</TableHead>
<TableHead>Waktu</TableHead>
<TableHead>Klien</TableHead>
<TableHead>Kategori</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pastSlots.slice(0, 20).map((slot) => (
<TableRow key={slot.id}>
<TableCell>{format(parseISO(slot.date), 'd MMM yyyy', { locale: id })}</TableCell>
<TableCell>{slot.start_time.substring(0, 5)} - {slot.end_time.substring(0, 5)}</TableCell>
<TableCell>{slot.profiles?.name || '-'}</TableCell>
<TableCell><Badge variant="outline">{slot.topic_category}</Badge></TableCell>
<TableCell>
<Badge variant={statusLabels[slot.status]?.variant || 'secondary'}>
{statusLabels[slot.status]?.label || slot.status}
</Badge>
</TableCell>
</TableRow>
))}
{pastSlots.length === 0 && (
<TableRow>
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
Belum ada riwayat konsultasi
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Meet Link Dialog */} {/* Meet Link Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> <Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
@@ -270,7 +462,7 @@ export default function AdminConsulting() {
<div className="p-3 bg-muted rounded-lg text-sm space-y-1"> <div className="p-3 bg-muted rounded-lg text-sm space-y-1">
<p><strong>Tanggal:</strong> {format(parseISO(selectedSlot.date), 'd MMMM yyyy', { locale: id })}</p> <p><strong>Tanggal:</strong> {format(parseISO(selectedSlot.date), 'd MMMM yyyy', { locale: id })}</p>
<p><strong>Waktu:</strong> {selectedSlot.start_time.substring(0, 5)} - {selectedSlot.end_time.substring(0, 5)}</p> <p><strong>Waktu:</strong> {selectedSlot.start_time.substring(0, 5)} - {selectedSlot.end_time.substring(0, 5)}</p>
<p><strong>Klien:</strong> {selectedSlot.profiles?.full_name}</p> <p><strong>Klien:</strong> {selectedSlot.profiles?.name}</p>
<p><strong>Topik:</strong> {selectedSlot.topic_category}</p> <p><strong>Topik:</strong> {selectedSlot.topic_category}</p>
{selectedSlot.notes && <p><strong>Catatan:</strong> {selectedSlot.notes}</p>} {selectedSlot.notes && <p><strong>Catatan:</strong> {selectedSlot.notes}</p>}
</div> </div>
@@ -286,13 +478,31 @@ export default function AdminConsulting() {
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={createMeetLink} variant="outline" className="flex-1 border-2"> <Button
Buat Link Meet onClick={createMeetLink}
variant="outline"
className="flex-1 border-2"
disabled={creatingMeet}
>
{creatingMeet ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Membuat...
</>
) : (
'Buat Link Meet'
)}
</Button> </Button>
<Button onClick={saveMeetLink} disabled={saving} className="flex-1 shadow-sm"> <Button onClick={saveMeetLink} disabled={saving} className="flex-1 shadow-sm">
{saving ? 'Menyimpan...' : 'Simpan'} {saving ? 'Menyimpan...' : 'Simpan'}
</Button> </Button>
</div> </div>
{!settings.integration_n8n_base_url && (
<p className="text-xs text-muted-foreground text-center">
Tip: Konfigurasi webhook di Pengaturan Integrasi untuk pembuatan otomatis
</p>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -16,7 +16,7 @@ import { toast } from "@/hooks/use-toast";
interface Member { interface Member {
id: string; id: string;
email: string | null; email: string | null;
full_name: string | null; name: string | null;
created_at: string; created_at: string;
isAdmin?: boolean; isAdmin?: boolean;
} }
@@ -118,7 +118,7 @@ export default function AdminMembers() {
{members.map((member) => ( {members.map((member) => (
<TableRow key={member.id}> <TableRow key={member.id}>
<TableCell>{member.email || "-"}</TableCell> <TableCell>{member.email || "-"}</TableCell>
<TableCell>{member.full_name || "-"}</TableCell> <TableCell>{member.name || "-"}</TableCell>
<TableCell> <TableCell>
{adminIds.has(member.id) ? ( {adminIds.has(member.id) ? (
<Badge className="bg-primary">Admin</Badge> <Badge className="bg-primary">Admin</Badge>
@@ -166,7 +166,7 @@ export default function AdminMembers() {
<span className="text-muted-foreground">Email:</span> {selectedMember.email} <span className="text-muted-foreground">Email:</span> {selectedMember.email}
</p> </p>
<p> <p>
<span className="text-muted-foreground">Nama:</span> {selectedMember.full_name || "-"} <span className="text-muted-foreground">Nama:</span> {selectedMember.name || "-"}
</p> </p>
<p> <p>
<span className="text-muted-foreground">ID:</span> {selectedMember.id} <span className="text-muted-foreground">ID:</span> {selectedMember.id}

View File

@@ -0,0 +1,363 @@
import { useEffect, useState } from "react";
import { supabase } from "@/integrations/supabase/client";
import { AppLayout } from "@/components/AppLayout";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "@/hooks/use-toast";
import { Star, Check, X, Edit, Trash2 } from "lucide-react";
interface Review {
id: string;
user_id: string;
product_id: string | null;
type: string;
rating: number;
title: string;
body: string;
is_approved: boolean;
created_at: string;
profiles: { name: string | null; email: string | null } | null;
products: { title: string } | null;
}
export default function AdminReviews() {
const [reviews, setReviews] = useState<Review[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState({ type: "all", status: "all" });
const [editReview, setEditReview] = useState<Review | null>(null);
const [editForm, setEditForm] = useState({ title: "", body: "" });
useEffect(() => {
fetchReviews();
}, []);
const fetchReviews = async () => {
setLoading(true);
const { data, error } = await supabase
.from("reviews")
.select(`
*,
profiles:user_id (name, email),
products:product_id (title)
`)
.order("created_at", { ascending: false });
if (error) {
console.error("Fetch reviews error:", error);
toast({ title: "Error", description: "Gagal mengambil data ulasan", variant: "destructive" });
setReviews([]);
} else {
setReviews((data as unknown as Review[]) || []);
}
setLoading(false);
};
const handleApprove = async (id: string, approved: boolean) => {
const { error } = await supabase
.from("reviews")
.update({ is_approved: approved })
.eq("id", id);
if (error) {
toast({ title: "Error", description: "Gagal mengubah status", variant: "destructive" });
} else {
toast({ title: "Berhasil", description: approved ? "Ulasan disetujui" : "Ulasan ditolak" });
fetchReviews();
}
};
const handleDelete = async (id: string) => {
if (!confirm("Hapus ulasan ini?")) return;
const { error } = await supabase.from("reviews").delete().eq("id", id);
if (error) {
toast({ title: "Error", description: "Gagal menghapus", variant: "destructive" });
} else {
toast({ title: "Berhasil", description: "Ulasan dihapus" });
fetchReviews();
}
};
const handleEdit = (review: Review) => {
setEditReview(review);
setEditForm({ title: review.title, body: review.body });
};
const handleSaveEdit = async () => {
if (!editReview) return;
const { error } = await supabase
.from("reviews")
.update({ title: editForm.title, body: editForm.body })
.eq("id", editReview.id);
if (error) {
toast({ title: "Error", description: "Gagal menyimpan", variant: "destructive" });
} else {
toast({ title: "Berhasil", description: "Ulasan diperbarui" });
setEditReview(null);
fetchReviews();
}
};
const filteredReviews = reviews.filter((r) => {
if (filter.type !== "all" && r.type !== filter.type) return false;
if (filter.status === "approved" && !r.is_approved) return false;
if (filter.status === "pending" && r.is_approved) return false;
return true;
});
const pendingReviews = reviews.filter((r) => !r.is_approved);
const renderStars = (rating: number) => (
<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>
);
const getTypeLabel = (type: string) => {
switch (type) {
case "consulting": return "Konsultasi";
case "bootcamp": return "Bootcamp";
case "webinar": return "Webinar";
case "general": return "Umum";
default: return type;
}
};
if (loading) {
return (
<AppLayout>
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-48 mb-4" />
<Skeleton className="h-6 w-64 mb-8" />
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-32 w-full" />
))}
</div>
</div>
</AppLayout>
);
}
return (
<AppLayout>
<div className="container mx-auto px-4 py-8 space-y-6">
<div>
<h1 className="text-3xl font-bold">Ulasan</h1>
<p className="text-muted-foreground">Kelola ulasan dari member</p>
</div>
<div className="flex gap-4 flex-wrap">
<Select value={filter.type} onValueChange={(v) => setFilter({ ...filter, type: v })}>
<SelectTrigger className="w-40 border-2">
<SelectValue placeholder="Tipe" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Tipe</SelectItem>
<SelectItem value="consulting">Konsultasi</SelectItem>
<SelectItem value="bootcamp">Bootcamp</SelectItem>
<SelectItem value="webinar">Webinar</SelectItem>
<SelectItem value="general">Umum</SelectItem>
</SelectContent>
</Select>
<Select value={filter.status} onValueChange={(v) => setFilter({ ...filter, status: v })}>
<SelectTrigger className="w-40 border-2">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">Semua Status</SelectItem>
<SelectItem value="pending">Menunggu</SelectItem>
<SelectItem value="approved">Disetujui</SelectItem>
</SelectContent>
</Select>
</div>
<Tabs defaultValue="list">
<TabsList>
<TabsTrigger value="list">Daftar ({filteredReviews.length})</TabsTrigger>
<TabsTrigger value="pending">
Menunggu ({pendingReviews.length})
</TabsTrigger>
</TabsList>
<TabsContent value="list" className="mt-4">
<div className="space-y-4">
{filteredReviews.length === 0 ? (
<Card className="border-2 border-border">
<CardContent className="py-12 text-center">
<Star className="w-12 h-12 mx-auto mb-4 text-muted-foreground" />
<h3 className="text-lg font-semibold mb-2">Belum ada ulasan</h3>
<p className="text-muted-foreground">
Ulasan dari member akan muncul di sini setelah mereka mengirimkan ulasan.
</p>
</CardContent>
</Card>
) : (
filteredReviews.map((review) => (
<Card key={review.id} className="border-2 border-border">
<CardContent className="py-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
{renderStars(review.rating)}
<Badge variant="outline">{getTypeLabel(review.type)}</Badge>
<Badge className={review.is_approved ? "bg-accent" : "bg-secondary"}>
{review.is_approved ? "Disetujui" : "Menunggu"}
</Badge>
</div>
<h3 className="font-bold">{review.title}</h3>
{review.body && <p className="text-muted-foreground text-sm">{review.body}</p>}
<div className="text-xs text-muted-foreground">
<span>{review.profiles?.name || review.profiles?.email || "Unknown"}</span>
{review.products && <span> {review.products.title}</span>}
<span> {new Date(review.created_at).toLocaleDateString("id-ID")}</span>
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
{!review.is_approved && (
<Button
size="sm"
variant="outline"
className="border-2"
onClick={() => handleApprove(review.id, true)}
>
<Check className="w-4 h-4" />
</Button>
)}
{review.is_approved && (
<Button
size="sm"
variant="outline"
className="border-2"
onClick={() => handleApprove(review.id, false)}
>
<X className="w-4 h-4" />
</Button>
)}
<Button
size="sm"
variant="outline"
className="border-2"
onClick={() => handleEdit(review)}
>
<Edit className="w-4 h-4" />
</Button>
<Button
size="sm"
variant="outline"
className="border-2 text-destructive hover:text-destructive"
onClick={() => handleDelete(review.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</TabsContent>
<TabsContent value="pending" className="mt-4">
<div className="space-y-4">
{pendingReviews.length === 0 ? (
<Card className="border-2 border-border">
<CardContent className="py-12 text-center">
<Check className="w-12 h-12 mx-auto mb-4 text-accent" />
<h3 className="text-lg font-semibold mb-2">Semua ulasan sudah dimoderasi</h3>
<p className="text-muted-foreground">
Tidak ada ulasan yang menunggu persetujuan.
</p>
</CardContent>
</Card>
) : (
pendingReviews.map((review) => (
<Card key={review.id} className="border-2 border-primary/20">
<CardContent className="py-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
{renderStars(review.rating)}
<Badge variant="outline">{getTypeLabel(review.type)}</Badge>
</div>
<h3 className="font-bold">{review.title}</h3>
{review.body && <p className="text-muted-foreground text-sm">{review.body}</p>}
<div className="text-xs text-muted-foreground">
{review.profiles?.name || review.profiles?.email}
{review.products && <span> {review.products.title}</span>}
</div>
</div>
<div className="flex gap-2 flex-shrink-0">
<Button size="sm" onClick={() => handleApprove(review.id, true)}>
<Check className="w-4 h-4 mr-1" /> Setujui
</Button>
<Button
size="sm"
variant="outline"
className="border-2 text-destructive"
onClick={() => handleDelete(review.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))
)}
</div>
</TabsContent>
</Tabs>
<Dialog open={!!editReview} onOpenChange={() => setEditReview(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Ulasan</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Judul</label>
<Input
value={editForm.title}
onChange={(e) => setEditForm({ ...editForm, title: e.target.value })}
className="border-2"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Isi</label>
<Textarea
value={editForm.body}
onChange={(e) => setEditForm({ ...editForm, body: e.target.value })}
className="border-2 min-h-[100px]"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditReview(null)}>
Batal
</Button>
<Button onClick={handleSaveEdit}>Simpan</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</AppLayout>
);
}

View File

@@ -9,6 +9,8 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { formatIDR } from "@/lib/format"; import { formatIDR } from "@/lib/format";
import { Video, Calendar, BookOpen, ArrowRight, Package, Receipt, ShoppingBag } from "lucide-react"; import { Video, Calendar, BookOpen, ArrowRight, Package, Receipt, ShoppingBag } from "lucide-react";
import { WhatsAppBanner } from "@/components/WhatsAppBanner";
import { ConsultingHistory } from "@/components/reviews/ConsultingHistory";
interface UserAccess { interface UserAccess {
id: string; id: string;
@@ -35,6 +37,7 @@ export default function MemberDashboard() {
const [access, setAccess] = useState<UserAccess[]>([]); const [access, setAccess] = useState<UserAccess[]>([]);
const [recentOrders, setRecentOrders] = useState<Order[]>([]); const [recentOrders, setRecentOrders] = useState<Order[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [hasWhatsApp, setHasWhatsApp] = useState(true);
useEffect(() => { useEffect(() => {
if (!authLoading && !user) navigate("/auth"); if (!authLoading && !user) navigate("/auth");
@@ -42,7 +45,7 @@ export default function MemberDashboard() {
}, [user, authLoading]); }, [user, authLoading]);
const fetchData = async () => { const fetchData = async () => {
const [accessRes, ordersRes, paidOrdersRes] = await Promise.all([ const [accessRes, ordersRes, paidOrdersRes, profileRes] = await Promise.all([
supabase supabase
.from("user_access") .from("user_access")
.select(`id, product:products (id, title, slug, type, meeting_link, recording_url)`) .select(`id, product:products (id, title, slug, type, meeting_link, recording_url)`)
@@ -61,6 +64,7 @@ export default function MemberDashboard() {
.eq("user_id", user!.id) .eq("user_id", user!.id)
.eq("payment_status", "paid") .eq("payment_status", "paid")
.eq("payment_provider", "pakasir"), .eq("payment_provider", "pakasir"),
supabase.from("profiles").select("whatsapp_number").eq("id", user!.id).single(),
]); ]);
// Combine access from user_access and paid orders // Combine access from user_access and paid orders
@@ -81,6 +85,7 @@ export default function MemberDashboard() {
setAccess([...directAccess, ...paidProductAccess]); setAccess([...directAccess, ...paidProductAccess]);
if (ordersRes.data) setRecentOrders(ordersRes.data); if (ordersRes.data) setRecentOrders(ordersRes.data);
if (profileRes.data) setHasWhatsApp(!!profileRes.data.whatsapp_number);
setLoading(false); setLoading(false);
}; };
@@ -118,6 +123,8 @@ export default function MemberDashboard() {
<h1 className="text-4xl font-bold mb-2">Dashboard</h1> <h1 className="text-4xl font-bold mb-2">Dashboard</h1>
<p className="text-muted-foreground mb-8">Selamat datang kembali!</p> <p className="text-muted-foreground mb-8">Selamat datang kembali!</p>
{!hasWhatsApp && <WhatsAppBanner />}
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-8"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 mb-8">
<Card className="border-2 border-border"> <Card className="border-2 border-border">
<CardContent className="pt-6"> <CardContent className="pt-6">
@@ -229,6 +236,11 @@ export default function MemberDashboard() {
</Card> </Card>
</div> </div>
)} )}
{/* Consulting History with Review Prompts */}
<div className="mt-8">
<ConsultingHistory userId={user!.id} />
</div>
</div> </div>
</AppLayout> </AppLayout>
); );

View File

@@ -7,15 +7,18 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { User, LogOut } from 'lucide-react'; import { User, LogOut, Phone } from 'lucide-react';
interface Profile { interface Profile {
id: string; id: string;
email: string | null; email: string | null;
full_name: string | null; name: string | null;
avatar_url: string | null; avatar_url: string | null;
whatsapp_number: string | null;
whatsapp_opt_in: boolean;
} }
export default function MemberProfile() { export default function MemberProfile() {
@@ -24,7 +27,12 @@ export default function MemberProfile() {
const [profile, setProfile] = useState<Profile | null>(null); const [profile, setProfile] = useState<Profile | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ full_name: '', avatar_url: '' }); const [form, setForm] = useState({
name: '',
avatar_url: '',
whatsapp_number: '',
whatsapp_opt_in: false,
});
useEffect(() => { useEffect(() => {
if (!authLoading && !user) navigate('/auth'); if (!authLoading && !user) navigate('/auth');
@@ -39,16 +47,42 @@ export default function MemberProfile() {
.single(); .single();
if (data) { if (data) {
setProfile(data); setProfile(data);
setForm({ full_name: data.full_name || '', avatar_url: data.avatar_url || '' }); setForm({
name: data.name || '',
avatar_url: data.avatar_url || '',
whatsapp_number: data.whatsapp_number || '',
whatsapp_opt_in: data.whatsapp_opt_in || false,
});
} }
setLoading(false); setLoading(false);
}; };
const normalizeWhatsApp = (number: string) => {
// Remove all non-digits
let cleaned = number.replace(/\D/g, '');
// Convert 08xx to +628xx
if (cleaned.startsWith('0')) {
cleaned = '62' + cleaned.substring(1);
}
// Add + if not present
if (cleaned && !cleaned.startsWith('+')) {
cleaned = '+' + cleaned;
}
return cleaned;
};
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
const normalizedWA = normalizeWhatsApp(form.whatsapp_number);
const { error } = await supabase const { error } = await supabase
.from('profiles') .from('profiles')
.update({ full_name: form.full_name, avatar_url: form.avatar_url || null }) .update({
name: form.name,
avatar_url: form.avatar_url || null,
whatsapp_number: normalizedWA || null,
whatsapp_opt_in: form.whatsapp_opt_in,
})
.eq('id', user!.id); .eq('id', user!.id);
if (error) { if (error) {
@@ -98,8 +132,8 @@ export default function MemberProfile() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Nama Lengkap</Label> <Label>Nama Lengkap</Label>
<Input <Input
value={form.full_name} value={form.name}
onChange={(e) => setForm({ ...form, full_name: e.target.value })} onChange={(e) => setForm({ ...form, name: e.target.value })}
className="border-2" className="border-2"
placeholder="Masukkan nama lengkap" placeholder="Masukkan nama lengkap"
/> />
@@ -113,12 +147,48 @@ export default function MemberProfile() {
placeholder="https://..." placeholder="https://..."
/> />
</div> </div>
<Button onClick={handleSave} disabled={saving} className="w-full shadow-sm">
{saving ? 'Menyimpan...' : 'Simpan Profil'}
</Button>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Phone className="w-5 h-5" />
WhatsApp
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Nomor WhatsApp</Label>
<Input
value={form.whatsapp_number}
onChange={(e) => setForm({ ...form, whatsapp_number: e.target.value })}
className="border-2"
placeholder="08123456789"
/>
<p className="text-xs text-muted-foreground">
Akan dinormalisasi ke format +62xxx
</p>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Ijinkan pengingat via WhatsApp</Label>
<p className="text-xs text-muted-foreground">
Terima notifikasi konsultasi & bootcamp
</p>
</div>
<Switch
checked={form.whatsapp_opt_in}
onCheckedChange={(checked) => setForm({ ...form, whatsapp_opt_in: checked })}
/>
</div>
</CardContent>
</Card>
<Button onClick={handleSave} disabled={saving} className="w-full shadow-sm">
{saving ? 'Menyimpan...' : 'Simpan Profil'}
</Button>
<Card className="border-2 border-border"> <Card className="border-2 border-border">
<CardContent className="pt-6"> <CardContent className="pt-6">
<Button variant="outline" onClick={handleSignOut} className="w-full border-2 text-destructive hover:text-destructive"> <Button variant="outline" onClick={handleSignOut} className="w-full border-2 text-destructive hover:text-destructive">

View File

@@ -15,11 +15,12 @@ interface OrderItem {
id: string; id: string;
product_id: string; product_id: string;
quantity: number; quantity: number;
price: number;
products: { products: {
title: string; title: string;
type: string; type: string;
slug: string; slug: string;
price: number;
sale_price: number | null;
}; };
} }
@@ -72,8 +73,7 @@ export default function OrderDetail() {
id, id,
product_id, product_id,
quantity, quantity,
price, products (title, type, slug, price, sale_price)
products (title, type, slug)
) )
`) `)
.eq("id", id) .eq("id", id)
@@ -279,7 +279,7 @@ export default function OrderDetail() {
</span> </span>
</div> </div>
</div> </div>
<p className="font-medium">{formatIDR(item.price)}</p> <p className="font-medium">{formatIDR(item.products.sale_price || item.products.price)}</p>
</div> </div>
))} ))}
</div> </div>