Compare commits
11 Commits
df9dbe5cbb
...
4b5dfc6557
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b5dfc6557 | ||
|
|
ef19864985 | ||
|
|
ed0d97bb4b | ||
|
|
f4a1dee9bd | ||
|
|
891faa73f0 | ||
|
|
2906c94c14 | ||
|
|
8a1ccb7acc | ||
|
|
d3f7544536 | ||
|
|
cc7c330e83 | ||
|
|
461a14dfdc | ||
|
|
04cae4fc54 |
79
.dockerignore
Normal file
79
.dockerignore
Normal 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
25
.env.example
Normal 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
47
Dockerfile
Normal 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
64
nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
67
src/components/Footer.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/components/WhatsAppBanner.tsx
Normal file
17
src/components/WhatsAppBanner.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
243
src/components/reviews/ConsultingHistory.tsx
Normal file
243
src/components/reviews/ConsultingHistory.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
84
src/components/reviews/ProductReviews.tsx
Normal file
84
src/components/reviews/ProductReviews.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
src/components/reviews/ReviewCard.tsx
Normal file
32
src/components/reviews/ReviewCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
112
src/components/reviews/ReviewForm.tsx
Normal file
112
src/components/reviews/ReviewForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
149
src/components/reviews/ReviewModal.tsx
Normal file
149
src/components/reviews/ReviewModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
src/components/reviews/TestimonialsSection.tsx
Normal file
55
src/components/reviews/TestimonialsSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
363
src/pages/admin/AdminReviews.tsx
Normal file
363
src/pages/admin/AdminReviews.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user