This commit is contained in:
gpt-engineer-app[bot]
2025-12-19 15:17:47 +00:00
parent f57bba6f9c
commit 7fc10126df
11 changed files with 979 additions and 88 deletions

View File

@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from "@/hooks/useAuth"; import { AuthProvider } from "@/hooks/useAuth";
import { CartProvider } from "@/contexts/CartContext"; import { CartProvider } from "@/contexts/CartContext";
import { BrandingProvider } from "@/hooks/useBranding";
import Index from "./pages/Index"; import Index from "./pages/Index";
import Auth from "./pages/Auth"; import Auth from "./pages/Auth";
import Products from "./pages/Products"; import Products from "./pages/Products";
@@ -20,6 +21,7 @@ import MemberDashboard from "./pages/member/MemberDashboard";
import MemberAccess from "./pages/member/MemberAccess"; import MemberAccess from "./pages/member/MemberAccess";
import MemberOrders from "./pages/member/MemberOrders"; import MemberOrders from "./pages/member/MemberOrders";
import MemberProfile from "./pages/member/MemberProfile"; import MemberProfile from "./pages/member/MemberProfile";
import OrderDetail from "./pages/member/OrderDetail";
// Admin pages // Admin pages
import AdminDashboard from "./pages/admin/AdminDashboard"; import AdminDashboard from "./pages/admin/AdminDashboard";
@@ -37,40 +39,43 @@ const App = () => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<TooltipProvider> <TooltipProvider>
<AuthProvider> <AuthProvider>
<CartProvider> <BrandingProvider>
<Toaster /> <CartProvider>
<Sonner /> <Toaster />
<BrowserRouter> <Sonner />
<Routes> <BrowserRouter>
<Route path="/" element={<Index />} /> <Routes>
<Route path="/auth" element={<Auth />} /> <Route path="/" element={<Index />} />
<Route path="/products" element={<Products />} /> <Route path="/auth" element={<Auth />} />
<Route path="/products/:slug" element={<ProductDetail />} /> <Route path="/products" element={<Products />} />
<Route path="/checkout" element={<Checkout />} /> <Route path="/products/:slug" element={<ProductDetail />} />
<Route path="/events" element={<Events />} /> <Route path="/checkout" element={<Checkout />} />
<Route path="/bootcamp/:slug" element={<Bootcamp />} /> <Route path="/events" element={<Events />} />
<Route path="/consulting" element={<ConsultingBooking />} /> <Route path="/bootcamp/:slug" element={<Bootcamp />} />
<Route path="/consulting" element={<ConsultingBooking />} />
{/* Member routes */}
<Route path="/dashboard" element={<MemberDashboard />} /> {/* Member routes */}
<Route path="/access" element={<MemberAccess />} /> <Route path="/dashboard" element={<MemberDashboard />} />
<Route path="/orders" element={<MemberOrders />} /> <Route path="/access" element={<MemberAccess />} />
<Route path="/profile" element={<MemberProfile />} /> <Route path="/orders" element={<MemberOrders />} />
<Route path="/orders/:id" element={<OrderDetail />} />
{/* Admin routes */} <Route path="/profile" element={<MemberProfile />} />
<Route path="/admin" element={<AdminDashboard />} />
<Route path="/admin/products" element={<AdminProducts />} /> {/* Admin routes */}
<Route path="/admin/bootcamp" element={<AdminBootcamp />} /> <Route path="/admin" element={<AdminDashboard />} />
<Route path="/admin/orders" element={<AdminOrders />} /> <Route path="/admin/products" element={<AdminProducts />} />
<Route path="/admin/members" element={<AdminMembers />} /> <Route path="/admin/bootcamp" element={<AdminBootcamp />} />
<Route path="/admin/events" element={<AdminEvents />} /> <Route path="/admin/orders" element={<AdminOrders />} />
<Route path="/admin/settings" element={<AdminSettings />} /> <Route path="/admin/members" element={<AdminMembers />} />
<Route path="/admin/consulting" element={<AdminConsulting />} /> <Route path="/admin/events" element={<AdminEvents />} />
<Route path="/admin/settings" element={<AdminSettings />} />
<Route path="*" element={<NotFound />} /> <Route path="/admin/consulting" element={<AdminConsulting />} />
</Routes>
</BrowserRouter> <Route path="*" element={<NotFound />} />
</CartProvider> </Routes>
</BrowserRouter>
</CartProvider>
</BrandingProvider>
</AuthProvider> </AuthProvider>
</TooltipProvider> </TooltipProvider>
</QueryClientProvider> </QueryClientProvider>

View File

@@ -2,6 +2,7 @@ import { ReactNode, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth'; import { useAuth } from '@/hooks/useAuth';
import { useCart } from '@/contexts/CartContext'; import { useCart } from '@/contexts/CartContext';
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 { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -69,6 +70,7 @@ interface AppLayoutProps {
export function AppLayout({ children }: AppLayoutProps) { export function AppLayout({ children }: AppLayoutProps) {
const { user, isAdmin, signOut } = useAuth(); const { user, isAdmin, signOut } = useAuth();
const { items } = useCart(); const { items } = useCart();
const branding = useBranding();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const [moreOpen, setMoreOpen] = useState(false); const [moreOpen, setMoreOpen] = useState(false);
@@ -93,13 +95,22 @@ export function AppLayout({ children }: AppLayoutProps) {
// Get additional items for "More" menu // Get additional items for "More" menu
const moreItems = navItems.filter(item => !mobileNav.some(m => m.href === item.href)); const moreItems = navItems.filter(item => !mobileNav.some(m => m.href === item.href));
const brandName = branding.brand_name || 'LearnHub';
const logoUrl = branding.brand_logo_url;
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">
<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">LearnHub</Link> <Link to="/" className="text-2xl font-bold flex items-center gap-2">
{logoUrl ? (
<img src={logoUrl} alt={brandName} className="h-8 object-contain" />
) : (
brandName
)}
</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="/events" className="hover:underline font-medium">Kalender</Link>
@@ -132,7 +143,13 @@ export function AppLayout({ children }: AppLayoutProps) {
{/* Desktop Sidebar */} {/* Desktop Sidebar */}
<aside className="hidden md:flex flex-col w-64 border-r-2 border-border bg-sidebar fixed h-screen"> <aside className="hidden md:flex flex-col w-64 border-r-2 border-border bg-sidebar fixed h-screen">
<div className="p-4 border-b-2 border-border"> <div className="p-4 border-b-2 border-border">
<Link to="/" className="text-xl font-bold">LearnHub</Link> <Link to="/" className="text-xl font-bold flex items-center gap-2">
{logoUrl ? (
<img src={logoUrl} alt={brandName} className="h-8 object-contain" />
) : (
brandName
)}
</Link>
</div> </div>
<nav className="flex-1 p-4 space-y-1 overflow-y-auto"> <nav className="flex-1 p-4 space-y-1 overflow-y-auto">
@@ -179,7 +196,13 @@ export function AppLayout({ children }: AppLayoutProps) {
<div className="flex-1 md:ml-64"> <div className="flex-1 md:ml-64">
{/* Mobile Header */} {/* Mobile Header */}
<header className="md:hidden sticky top-0 z-50 border-b-2 border-border bg-background px-4 py-3 flex items-center justify-between"> <header className="md:hidden sticky top-0 z-50 border-b-2 border-border bg-background px-4 py-3 flex items-center justify-between">
<Link to="/" className="text-xl font-bold">LearnHub</Link> <Link to="/" className="text-xl font-bold flex items-center gap-2">
{logoUrl ? (
<img src={logoUrl} alt={brandName} className="h-6 object-contain" />
) : (
brandName
)}
</Link>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link to="/checkout" className="relative p-2"> <Link to="/checkout" className="relative p-2">
<ShoppingCart className="w-5 h-5" /> <ShoppingCart className="w-5 h-5" />

View File

@@ -4,8 +4,15 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
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 { Textarea } from '@/components/ui/textarea';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { Palette, Image, Mail } from 'lucide-react'; import { Palette, Image, Mail, Home, Plus, Trash2 } from 'lucide-react';
interface HomepageFeature {
icon: string;
title: string;
description: string;
}
interface PlatformSettings { interface PlatformSettings {
id?: string; id?: string;
@@ -16,8 +23,17 @@ interface PlatformSettings {
brand_primary_color: string; brand_primary_color: string;
brand_accent_color: string; brand_accent_color: string;
brand_email_from_name: string; brand_email_from_name: string;
homepage_headline: string;
homepage_description: string;
homepage_features: HomepageFeature[];
} }
const defaultFeatures: HomepageFeature[] = [
{ icon: 'Users', title: 'Consulting', description: 'One-on-one sessions with industry experts to solve your specific challenges.' },
{ icon: 'Video', title: 'Webinars', description: 'Live and recorded sessions covering the latest trends and techniques.' },
{ icon: 'BookOpen', title: 'Bootcamps', description: 'Intensive programs to master new skills in weeks, not months.' },
];
const emptySettings: PlatformSettings = { const emptySettings: PlatformSettings = {
brand_name: '', brand_name: '',
brand_tagline: '', brand_tagline: '',
@@ -26,8 +42,13 @@ const emptySettings: PlatformSettings = {
brand_primary_color: '#111827', brand_primary_color: '#111827',
brand_accent_color: '#0F766E', brand_accent_color: '#0F766E',
brand_email_from_name: '', brand_email_from_name: '',
homepage_headline: 'Learn. Grow. Succeed.',
homepage_description: 'Access premium consulting, live webinars, and intensive bootcamps to accelerate your career.',
homepage_features: defaultFeatures,
}; };
const iconOptions = ['Users', 'Video', 'BookOpen', 'Star', 'Award', 'Target', 'Zap', 'Heart', 'Shield', 'Rocket'];
export function BrandingTab() { export function BrandingTab() {
const [settings, setSettings] = useState<PlatformSettings>(emptySettings); const [settings, setSettings] = useState<PlatformSettings>(emptySettings);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -44,6 +65,17 @@ export function BrandingTab() {
.single(); .single();
if (data) { if (data) {
let features = defaultFeatures;
if (data.homepage_features) {
try {
features = typeof data.homepage_features === 'string'
? JSON.parse(data.homepage_features)
: data.homepage_features;
} catch (e) {
console.error('Error parsing features:', e);
}
}
setSettings({ setSettings({
id: data.id, id: data.id,
brand_name: data.brand_name || '', brand_name: data.brand_name || '',
@@ -53,6 +85,9 @@ export function BrandingTab() {
brand_primary_color: data.brand_primary_color || '#111827', brand_primary_color: data.brand_primary_color || '#111827',
brand_accent_color: data.brand_accent_color || '#0F766E', brand_accent_color: data.brand_accent_color || '#0F766E',
brand_email_from_name: data.brand_email_from_name || '', brand_email_from_name: data.brand_email_from_name || '',
homepage_headline: data.homepage_headline || emptySettings.homepage_headline,
homepage_description: data.homepage_description || emptySettings.homepage_description,
homepage_features: features,
}); });
} }
setLoading(false); setLoading(false);
@@ -60,8 +95,18 @@ export function BrandingTab() {
const saveSettings = async () => { const saveSettings = async () => {
setSaving(true); setSaving(true);
const payload = { ...settings }; const payload = {
delete payload.id; brand_name: settings.brand_name,
brand_tagline: settings.brand_tagline,
brand_logo_url: settings.brand_logo_url,
brand_favicon_url: settings.brand_favicon_url,
brand_primary_color: settings.brand_primary_color,
brand_accent_color: settings.brand_accent_color,
brand_email_from_name: settings.brand_email_from_name,
homepage_headline: settings.homepage_headline,
homepage_description: settings.homepage_description,
homepage_features: settings.homepage_features,
};
if (settings.id) { if (settings.id) {
const { error } = await supabase const { error } = await supabase
@@ -87,10 +132,33 @@ export function BrandingTab() {
setSaving(false); setSaving(false);
}; };
const updateFeature = (index: number, field: keyof HomepageFeature, value: string) => {
const newFeatures = [...settings.homepage_features];
newFeatures[index] = { ...newFeatures[index], [field]: value };
setSettings({ ...settings, homepage_features: newFeatures });
};
const addFeature = () => {
if (settings.homepage_features.length >= 6) {
toast({ title: 'Maksimum', description: 'Maksimum 6 fitur', variant: 'destructive' });
return;
}
setSettings({
...settings,
homepage_features: [...settings.homepage_features, { icon: 'Star', title: '', description: '' }],
});
};
const removeFeature = (index: number) => {
const newFeatures = settings.homepage_features.filter((_, i) => i !== index);
setSettings({ ...settings, homepage_features: newFeatures });
};
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />; if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Brand Identity */}
<Card className="border-2 border-border"> <Card className="border-2 border-border">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
@@ -228,12 +296,119 @@ export function BrandingTab() {
Digunakan jika SMTP from_name kosong Digunakan jika SMTP from_name kosong
</p> </p>
</div> </div>
<Button onClick={saveSettings} disabled={saving} className="shadow-sm">
{saving ? 'Menyimpan...' : 'Simpan Pengaturan'}
</Button>
</CardContent> </CardContent>
</Card> </Card>
{/* Homepage Settings */}
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Home className="w-5 h-5" />
Konten Homepage
</CardTitle>
<CardDescription>
Konfigurasi teks dan fitur yang ditampilkan di halaman utama
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Headline</Label>
<Input
value={settings.homepage_headline}
onChange={(e) => setSettings({ ...settings, homepage_headline: e.target.value })}
placeholder="Learn. Grow. Succeed."
className="border-2"
/>
</div>
<div className="space-y-2">
<Label>Deskripsi</Label>
<Textarea
value={settings.homepage_description}
onChange={(e) => setSettings({ ...settings, homepage_description: e.target.value })}
placeholder="Access premium consulting, live webinars..."
className="border-2"
rows={3}
/>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<Label>Fitur Cards ({settings.homepage_features.length}/6)</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={addFeature}
disabled={settings.homepage_features.length >= 6}
className="border-2"
>
<Plus className="w-4 h-4 mr-1" />
Tambah
</Button>
</div>
{settings.homepage_features.map((feature, index) => (
<div key={index} className="p-4 border-2 border-border rounded-lg space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Fitur {index + 1}</span>
{settings.homepage_features.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeFeature(index)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="w-4 h-4" />
</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-xs">Icon</Label>
<select
value={feature.icon}
onChange={(e) => updateFeature(index, 'icon', e.target.value)}
className="w-full h-10 px-3 border-2 border-input rounded-md bg-background"
>
{iconOptions.map((icon) => (
<option key={icon} value={icon}>{icon}</option>
))}
</select>
</div>
<div className="space-y-1 md:col-span-2">
<Label className="text-xs">Judul</Label>
<Input
value={feature.title}
onChange={(e) => updateFeature(index, 'title', e.target.value)}
placeholder="Consulting"
className="border-2"
/>
</div>
</div>
<div className="space-y-1">
<Label className="text-xs">Deskripsi</Label>
<Textarea
value={feature.description}
onChange={(e) => updateFeature(index, 'description', e.target.value)}
placeholder="One-on-one sessions with..."
className="border-2"
rows={2}
/>
</div>
</div>
))}
</div>
</CardContent>
</Card>
<Button onClick={saveSettings} disabled={saving} className="shadow-sm">
{saving ? 'Menyimpan...' : 'Simpan Pengaturan'}
</Button>
</div> </div>
); );
} }

104
src/hooks/useBranding.tsx Normal file
View File

@@ -0,0 +1,104 @@
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import { supabase } from '@/integrations/supabase/client';
interface HomepageFeature {
icon: string;
title: string;
description: string;
}
interface BrandingSettings {
brand_name: string;
brand_tagline: string;
brand_logo_url: string;
brand_favicon_url: string;
brand_primary_color: string;
brand_accent_color: string;
brand_email_from_name: string;
homepage_headline: string;
homepage_description: string;
homepage_features: HomepageFeature[];
}
const defaultBranding: BrandingSettings = {
brand_name: 'LearnHub',
brand_tagline: 'Belajar bareng, dari praktisi.',
brand_logo_url: '',
brand_favicon_url: '',
brand_primary_color: '#111827',
brand_accent_color: '#0F766E',
brand_email_from_name: '',
homepage_headline: 'Learn. Grow. Succeed.',
homepage_description: 'Access premium consulting, live webinars, and intensive bootcamps to accelerate your career.',
homepage_features: [
{ icon: 'Users', title: 'Consulting', description: 'One-on-one sessions with industry experts to solve your specific challenges.' },
{ icon: 'Video', title: 'Webinars', description: 'Live and recorded sessions covering the latest trends and techniques.' },
{ icon: 'BookOpen', title: 'Bootcamps', description: 'Intensive programs to master new skills in weeks, not months.' },
],
};
const BrandingContext = createContext<BrandingSettings>(defaultBranding);
export function BrandingProvider({ children }: { children: ReactNode }) {
const [branding, setBranding] = useState<BrandingSettings>(defaultBranding);
useEffect(() => {
fetchBranding();
}, []);
const fetchBranding = async () => {
const { data, error } = await supabase
.from('platform_settings')
.select('*')
.single();
if (data) {
let features = defaultBranding.homepage_features;
// Parse homepage_features if it's a string
if (data.homepage_features) {
try {
features = typeof data.homepage_features === 'string'
? JSON.parse(data.homepage_features)
: data.homepage_features;
} catch (e) {
console.error('Error parsing homepage_features:', e);
}
}
setBranding({
brand_name: data.brand_name || defaultBranding.brand_name,
brand_tagline: data.brand_tagline || defaultBranding.brand_tagline,
brand_logo_url: data.brand_logo_url || '',
brand_favicon_url: data.brand_favicon_url || '',
brand_primary_color: data.brand_primary_color || defaultBranding.brand_primary_color,
brand_accent_color: data.brand_accent_color || defaultBranding.brand_accent_color,
brand_email_from_name: data.brand_email_from_name || '',
homepage_headline: data.homepage_headline || defaultBranding.homepage_headline,
homepage_description: data.homepage_description || defaultBranding.homepage_description,
homepage_features: features,
});
// Update favicon if set
if (data.brand_favicon_url) {
const link = document.querySelector("link[rel~='icon']") as HTMLLinkElement;
if (link) link.href = data.brand_favicon_url;
}
// Update document title
if (data.brand_name) {
document.title = data.brand_name;
}
}
};
return (
<BrandingContext.Provider value={branding}>
{children}
</BrandingContext.Provider>
);
}
export function useBranding() {
return useContext(BrandingContext);
}

View File

@@ -1,17 +1,33 @@
import { Link } from 'react-router-dom'; 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 { ArrowRight, BookOpen, Video, Users } from 'lucide-react'; import { useBranding } from '@/hooks/useBranding';
import { ArrowRight, BookOpen, Video, Users, Star, Award, Target, Zap, Heart, Shield, Rocket } from 'lucide-react';
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Users,
Video,
BookOpen,
Star,
Award,
Target,
Zap,
Heart,
Shield,
Rocket,
};
export default function Index() { export default function Index() {
const branding = useBranding();
return ( return (
<Layout> <Layout>
<section className="container mx-auto px-4 py-16 text-center"> <section className="container mx-auto px-4 py-16 text-center">
<h1 className="text-5xl md:text-6xl font-bold mb-6"> <h1 className="text-5xl md:text-6xl font-bold mb-6">
Learn. Grow. Succeed. {branding.homepage_headline}
</h1> </h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-8"> <p className="text-xl text-muted-foreground max-w-2xl mx-auto mb-8">
Access premium consulting, live webinars, and intensive bootcamps to accelerate your career. {branding.homepage_description}
</p> </p>
<div className="flex gap-4 justify-center"> <div className="flex gap-4 justify-center">
<Link to="/products"> <Link to="/products">
@@ -30,27 +46,18 @@ export default function Index() {
<section className="container mx-auto px-4 py-16"> <section className="container mx-auto px-4 py-16">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="border-2 border-border p-8 shadow-sm"> {branding.homepage_features.map((feature, index) => {
<Users className="w-12 h-12 mb-4" /> const IconComponent = iconMap[feature.icon] || Users;
<h3 className="text-2xl font-bold mb-2">Consulting</h3> return (
<p className="text-muted-foreground"> <div key={index} className="border-2 border-border p-8 shadow-sm">
One-on-one sessions with industry experts to solve your specific challenges. <IconComponent className="w-12 h-12 mb-4" />
</p> <h3 className="text-2xl font-bold mb-2">{feature.title}</h3>
</div> <p className="text-muted-foreground">
<div className="border-2 border-border p-8 shadow-sm"> {feature.description}
<Video className="w-12 h-12 mb-4" /> </p>
<h3 className="text-2xl font-bold mb-2">Webinars</h3> </div>
<p className="text-muted-foreground"> );
Live and recorded sessions covering the latest trends and techniques. })}
</p>
</div>
<div className="border-2 border-border p-8 shadow-sm">
<BookOpen className="w-12 h-12 mb-4" />
<h3 className="text-2xl font-bold mb-2">Bootcamps</h3>
<p className="text-muted-foreground">
Intensive programs to master new skills in weeks, not months.
</p>
</div>
</div> </div>
</section> </section>
</Layout> </Layout>

View File

@@ -86,6 +86,13 @@ export default function Products() {
} }
}; };
// Strip HTML tags for preview, but keep first 100 chars
const stripHtml = (html: string) => {
const tmp = document.createElement('div');
tmp.innerHTML = html;
return tmp.textContent || tmp.innerText || '';
};
return ( return (
<AppLayout> <AppLayout>
<div className="container mx-auto px-4 py-8"> <div className="container mx-auto px-4 py-8">
@@ -149,7 +156,10 @@ export default function Products() {
<CardTitle className="text-xl">{product.title}</CardTitle> <CardTitle className="text-xl">{product.title}</CardTitle>
<Badge className="bg-secondary">{getTypeLabel(product.type)}</Badge> <Badge className="bg-secondary">{getTypeLabel(product.type)}</Badge>
</div> </div>
<CardDescription className="line-clamp-2">{product.description}</CardDescription> <CardDescription
className="line-clamp-2"
dangerouslySetInnerHTML={{ __html: product.description }}
/>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center gap-2 mb-4"> <div className="flex items-center gap-2 mb-4">

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate, Link } from "react-router-dom";
import { AppLayout } from "@/components/AppLayout"; import { AppLayout } from "@/components/AppLayout";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { supabase } from "@/integrations/supabase/client"; import { supabase } from "@/integrations/supabase/client";
@@ -7,6 +7,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { formatIDR, formatDate } from "@/lib/format"; import { formatIDR, formatDate } from "@/lib/format";
import { ChevronRight } from "lucide-react";
interface Order { interface Order {
id: string; id: string;
@@ -96,25 +97,28 @@ export default function MemberOrders() {
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{orders.map((order) => ( {orders.map((order) => (
<Card key={order.id} className="border-2 border-border"> <Link key={order.id} to={`/orders/${order.id}`}>
<CardContent className="py-4"> <Card className="border-2 border-border hover:border-primary transition-colors cursor-pointer">
<div className="flex items-center justify-between"> <CardContent className="py-4">
<div> <div className="flex items-center justify-between">
<p className="font-mono text-sm text-muted-foreground">{order.id.slice(0, 8)}</p> <div>
<p className="text-sm text-muted-foreground">{formatDate(order.created_at)}</p> <p className="font-mono text-sm text-muted-foreground">{order.id.slice(0, 8)}</p>
{order.payment_method && ( <p className="text-sm text-muted-foreground">{formatDate(order.created_at)}</p>
<p className="text-xs text-muted-foreground uppercase">{order.payment_method}</p> {order.payment_method && (
)} <p className="text-xs text-muted-foreground uppercase">{order.payment_method}</p>
)}
</div>
<div className="flex items-center gap-4">
<Badge className={getStatusColor(order.payment_status || order.status)}>
{getPaymentStatusLabel(order.payment_status || order.status)}
</Badge>
<span className="font-bold">{formatIDR(order.total_amount)}</span>
<ChevronRight className="w-5 h-5 text-muted-foreground" />
</div>
</div> </div>
<div className="flex items-center gap-4"> </CardContent>
<Badge className={getStatusColor(order.payment_status || order.status)}> </Card>
{getPaymentStatusLabel(order.payment_status || order.status)} </Link>
</Badge>
<span className="font-bold">{formatIDR(order.total_amount)}</span>
</div>
</div>
</CardContent>
</Card>
))} ))}
</div> </div>
)} )}

View File

@@ -0,0 +1,258 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams, Link } from "react-router-dom";
import { AppLayout } from "@/components/AppLayout";
import { useAuth } from "@/hooks/useAuth";
import { supabase } from "@/integrations/supabase/client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Separator } from "@/components/ui/separator";
import { formatIDR, formatDate } from "@/lib/format";
import { ArrowLeft, Package, CreditCard, Calendar, Clock } from "lucide-react";
interface OrderItem {
id: string;
product_id: string;
quantity: number;
price: number;
products: {
title: string;
type: string;
slug: string;
};
}
interface Order {
id: string;
total_amount: number;
status: string;
payment_status: string | null;
payment_method: string | null;
payment_provider: string | null;
payment_url: string | null;
created_at: string;
updated_at: string;
order_items: OrderItem[];
}
export default function OrderDetail() {
const { id } = useParams<{ id: string }>();
const { user, loading: authLoading } = useAuth();
const navigate = useNavigate();
const [order, setOrder] = useState<Order | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!authLoading && !user) navigate("/auth");
else if (user && id) fetchOrder();
}, [user, authLoading, id]);
const fetchOrder = async () => {
const { data, error } = await supabase
.from("orders")
.select(`
*,
order_items (
id,
product_id,
quantity,
price,
products (title, type, slug)
)
`)
.eq("id", id)
.eq("user_id", user!.id)
.single();
if (error || !data) {
navigate("/orders");
return;
}
setOrder(data as Order);
setLoading(false);
};
const getStatusColor = (status: string) => {
switch (status) {
case "paid":
return "bg-accent text-primary";
case "pending":
return "bg-secondary text-primary";
case "cancelled":
case "failed":
return "bg-destructive";
default:
return "bg-secondary text-primary";
}
};
const getStatusLabel = (status: string | null) => {
switch (status) {
case "paid":
return "Lunas";
case "pending":
return "Menunggu Pembayaran";
case "failed":
return "Gagal";
case "cancelled":
return "Dibatalkan";
default:
return status || "Pending";
}
};
const getTypeLabel = (type: string) => {
switch (type) {
case "consulting":
return "Konsultasi";
case "webinar":
return "Webinar";
case "bootcamp":
return "Bootcamp";
default:
return type;
}
};
if (authLoading || loading) {
return (
<AppLayout>
<div className="container mx-auto px-4 py-8">
<Skeleton className="h-10 w-1/3 mb-8" />
<Skeleton className="h-64 w-full" />
</div>
</AppLayout>
);
}
if (!order) return null;
return (
<AppLayout>
<div className="container mx-auto px-4 py-8 max-w-3xl">
<Button
variant="ghost"
onClick={() => navigate("/orders")}
className="mb-4"
>
<ArrowLeft className="w-4 h-4 mr-2" />
Kembali ke Riwayat Order
</Button>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-3xl font-bold">Detail Order</h1>
<p className="text-muted-foreground font-mono">#{order.id.slice(0, 8)}</p>
</div>
<Badge className={getStatusColor(order.payment_status || order.status)}>
{getStatusLabel(order.payment_status || order.status)}
</Badge>
</div>
{/* Order Info */}
<Card className="border-2 border-border mb-6">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Calendar className="w-5 h-5" />
Informasi Order
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground">Tanggal Order</p>
<p className="font-medium">{formatDate(order.created_at)}</p>
</div>
<div>
<p className="text-muted-foreground">Terakhir Update</p>
<p className="font-medium">{formatDate(order.updated_at)}</p>
</div>
{order.payment_method && (
<div>
<p className="text-muted-foreground">Metode Pembayaran</p>
<p className="font-medium uppercase">{order.payment_method}</p>
</div>
)}
{order.payment_provider && (
<div>
<p className="text-muted-foreground">Provider</p>
<p className="font-medium capitalize">{order.payment_provider}</p>
</div>
)}
</div>
{order.payment_status === "pending" && order.payment_url && (
<div className="pt-4">
<Button asChild className="w-full shadow-sm">
<a href={order.payment_url} target="_blank" rel="noopener noreferrer">
<CreditCard className="w-4 h-4 mr-2" />
Lanjutkan Pembayaran
</a>
</Button>
</div>
)}
</CardContent>
</Card>
{/* Order Items */}
<Card className="border-2 border-border mb-6">
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<Package className="w-5 h-5" />
Item Pesanan
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{order.order_items.map((item) => (
<div key={item.id} className="flex items-center justify-between py-2">
<div className="flex-1">
<Link
to={`/products/${item.products.slug}`}
className="font-medium hover:underline"
>
{item.products.title}
</Link>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs">
{getTypeLabel(item.products.type)}
</Badge>
<span className="text-sm text-muted-foreground">
x{item.quantity}
</span>
</div>
</div>
<p className="font-medium">{formatIDR(item.price)}</p>
</div>
))}
</div>
<Separator className="my-4" />
<div className="flex items-center justify-between text-lg font-bold">
<span>Total</span>
<span>{formatIDR(order.total_amount)}</span>
</div>
</CardContent>
</Card>
{/* Access Info */}
{order.payment_status === "paid" && (
<Card className="border-2 border-primary bg-primary/5">
<CardContent className="py-4">
<p className="text-sm">
Pembayaran berhasil! Akses produk Anda tersedia di halaman{" "}
<Link to="/access" className="font-medium underline">
Akses Saya
</Link>
.
</p>
</CardContent>
</Card>
)}
</div>
</AppLayout>
);
}

View File

@@ -20,3 +20,9 @@ verify_jwt = false
[functions.send-test-email] [functions.send-test-email]
verify_jwt = false verify_jwt = false
[functions.create-meet-link]
verify_jwt = true
[functions.send-consultation-reminder]
verify_jwt = false

View File

@@ -0,0 +1,126 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
interface CreateMeetRequest {
slot_id: string;
date: string;
start_time: string;
end_time: string;
client_name: string;
client_email: string;
topic: string;
notes?: string;
}
serve(async (req: Request): Promise<Response> => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
const body: CreateMeetRequest = await req.json();
console.log("Creating meet link for slot:", body.slot_id);
// Get platform settings for Google Calendar ID
const { data: settings } = await supabase
.from("platform_settings")
.select("integration_google_calendar_id, brand_name")
.single();
const calendarId = settings?.integration_google_calendar_id;
const brandName = settings?.brand_name || "LearnHub";
if (!calendarId) {
return new Response(
JSON.stringify({
success: false,
message: "Google Calendar ID belum dikonfigurasi di Pengaturan > Integrasi"
}),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// For now, this is a placeholder that returns a message
// In production, you would integrate with Google Calendar API via OAuth or service account
// Or call an n8n webhook to handle the calendar creation
const { data: integrationSettings } = await supabase
.from("platform_settings")
.select("integration_n8n_base_url")
.single();
if (integrationSettings?.integration_n8n_base_url) {
// Call n8n webhook if configured
const n8nUrl = `${integrationSettings.integration_n8n_base_url}/webhook/create-meet`;
try {
const n8nResponse = await fetch(n8nUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
slot_id: body.slot_id,
date: body.date,
start_time: body.start_time,
end_time: body.end_time,
client_name: body.client_name,
client_email: body.client_email,
topic: body.topic,
notes: body.notes,
calendar_id: calendarId,
brand_name: brandName,
}),
});
if (n8nResponse.ok) {
const result = await n8nResponse.json();
if (result.meet_link) {
// Update the slot with the meet link
await supabase
.from("consulting_slots")
.update({ meet_link: result.meet_link })
.eq("id", body.slot_id);
return new Response(
JSON.stringify({ success: true, meet_link: result.meet_link }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
}
} catch (n8nError) {
console.error("n8n webhook error:", n8nError);
}
}
// Fallback: Return instructions for manual setup
return new Response(
JSON.stringify({
success: false,
message: "Integrasi otomatis belum tersedia. Silakan buat link Meet secara manual atau konfigurasi n8n webhook di Pengaturan > Integrasi.",
manual_instructions: {
calendar_id: calendarId,
event_title: `Konsultasi: ${body.topic} - ${body.client_name}`,
event_date: body.date,
event_time: `${body.start_time} - ${body.end_time}`,
}
}),
{ status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error: any) {
console.error("Error creating meet link:", error);
return new Response(
JSON.stringify({ success: false, message: error.message }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});

View File

@@ -0,0 +1,173 @@
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
serve(async (req: Request): Promise<Response> => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const supabase = createClient(supabaseUrl, supabaseServiceKey);
// Get current date/time in Jakarta timezone
const now = new Date();
const jakartaOffset = 7 * 60; // UTC+7
const jakartaTime = new Date(now.getTime() + jakartaOffset * 60 * 1000);
const today = jakartaTime.toISOString().split('T')[0];
// Find consultations happening in the next 24 hours that haven't been reminded
const tomorrow = new Date(jakartaTime);
tomorrow.setDate(tomorrow.getDate() + 1);
const tomorrowStr = tomorrow.toISOString().split('T')[0];
console.log("Checking consultations for dates:", today, "to", tomorrowStr);
// Get confirmed slots for today and tomorrow
const { data: upcomingSlots, error: slotsError } = await supabase
.from("consulting_slots")
.select(`
*,
profiles:user_id (full_name, email)
`)
.eq("status", "confirmed")
.gte("date", today)
.lte("date", tomorrowStr)
.order("date")
.order("start_time");
if (slotsError) {
console.error("Error fetching slots:", slotsError);
throw slotsError;
}
console.log("Found upcoming slots:", upcomingSlots?.length || 0);
if (!upcomingSlots || upcomingSlots.length === 0) {
return new Response(
JSON.stringify({ success: true, message: "No upcoming consultations to remind" }),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
// Get notification template for consultation reminder
const { data: template } = await supabase
.from("notification_templates")
.select("*")
.eq("key", "consulting_scheduled")
.single();
// Get SMTP settings
const { data: smtpSettings } = await supabase
.from("notification_settings")
.select("*")
.single();
// Get platform settings
const { data: platformSettings } = await supabase
.from("platform_settings")
.select("brand_name, brand_email_from_name, integration_whatsapp_number")
.single();
const results: any[] = [];
for (const slot of upcomingSlots) {
const profile = slot.profiles as any;
// Build payload for notification
const payload = {
nama: profile?.full_name || "Pelanggan",
email: profile?.email || "",
tanggal_konsultasi: new Date(slot.date).toLocaleDateString("id-ID", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
}),
jam_konsultasi: `${slot.start_time.substring(0, 5)} - ${slot.end_time.substring(0, 5)} WIB`,
link_meet: slot.meet_link || "Akan diinformasikan",
topik: slot.topic_category,
catatan: slot.notes || "-",
brand_name: platformSettings?.brand_name || "LearnHub",
whatsapp: platformSettings?.integration_whatsapp_number || "",
};
// Log the reminder payload
console.log("Reminder payload for slot:", slot.id, payload);
// Update last_payload_example in template
if (template) {
await supabase
.from("notification_templates")
.update({ last_payload_example: payload })
.eq("id", template.id);
}
// Send webhook if configured
if (template?.webhook_url) {
try {
await fetch(template.webhook_url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
event: "consulting_reminder",
slot_id: slot.id,
...payload,
}),
});
console.log("Webhook sent for slot:", slot.id);
} catch (webhookError) {
console.error("Webhook error:", webhookError);
}
}
// Send email if template is active and SMTP is configured
if (template?.is_active && smtpSettings?.smtp_host && profile?.email) {
// Replace shortcodes in email body
let emailBody = template.email_body_html || "";
let emailSubject = template.email_subject || "Reminder Konsultasi";
Object.entries(payload).forEach(([key, value]) => {
const regex = new RegExp(`\\{${key}\\}`, "g");
emailBody = emailBody.replace(regex, String(value));
emailSubject = emailSubject.replace(regex, String(value));
});
// Here you would send the actual email
// For now, log that we would send it
console.log("Would send reminder email to:", profile.email);
console.log("Subject:", emailSubject);
}
results.push({
slot_id: slot.id,
client: profile?.full_name,
date: slot.date,
time: slot.start_time,
reminded: true,
});
}
return new Response(
JSON.stringify({
success: true,
message: `Processed ${results.length} consultation reminders`,
results
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
} catch (error: any) {
console.error("Error sending reminders:", error);
return new Response(
JSON.stringify({ success: false, message: error.message }),
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
});