Changes
This commit is contained in:
@@ -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,6 +39,7 @@ const App = () => (
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<BrandingProvider>
|
||||||
<CartProvider>
|
<CartProvider>
|
||||||
<Toaster />
|
<Toaster />
|
||||||
<Sonner />
|
<Sonner />
|
||||||
@@ -55,6 +58,7 @@ const App = () => (
|
|||||||
<Route path="/dashboard" element={<MemberDashboard />} />
|
<Route path="/dashboard" element={<MemberDashboard />} />
|
||||||
<Route path="/access" element={<MemberAccess />} />
|
<Route path="/access" element={<MemberAccess />} />
|
||||||
<Route path="/orders" element={<MemberOrders />} />
|
<Route path="/orders" element={<MemberOrders />} />
|
||||||
|
<Route path="/orders/:id" element={<OrderDetail />} />
|
||||||
<Route path="/profile" element={<MemberProfile />} />
|
<Route path="/profile" element={<MemberProfile />} />
|
||||||
|
|
||||||
{/* Admin routes */}
|
{/* Admin routes */}
|
||||||
@@ -71,6 +75,7 @@ const App = () => (
|
|||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</CartProvider>
|
</CartProvider>
|
||||||
|
</BrandingProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</CardContent>
|
||||||
|
</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">
|
<Button onClick={saveSettings} disabled={saving} className="shadow-sm">
|
||||||
{saving ? 'Menyimpan...' : 'Simpan Pengaturan'}
|
{saving ? 'Menyimpan...' : 'Simpan Pengaturan'}
|
||||||
</Button>
|
</Button>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
104
src/hooks/useBranding.tsx
Normal file
104
src/hooks/useBranding.tsx
Normal 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);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div key={index} className="border-2 border-border p-8 shadow-sm">
|
||||||
|
<IconComponent className="w-12 h-12 mb-4" />
|
||||||
|
<h3 className="text-2xl font-bold mb-2">{feature.title}</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
One-on-one sessions with industry experts to solve your specific challenges.
|
{feature.description}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="border-2 border-border p-8 shadow-sm">
|
|
||||||
<Video className="w-12 h-12 mb-4" />
|
|
||||||
<h3 className="text-2xl font-bold mb-2">Webinars</h3>
|
|
||||||
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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,7 +97,8 @@ 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}`}>
|
||||||
|
<Card className="border-2 border-border hover:border-primary transition-colors cursor-pointer">
|
||||||
<CardContent className="py-4">
|
<CardContent className="py-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -111,10 +113,12 @@ export default function MemberOrders() {
|
|||||||
{getPaymentStatusLabel(order.payment_status || order.status)}
|
{getPaymentStatusLabel(order.payment_status || order.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
<span className="font-bold">{formatIDR(order.total_amount)}</span>
|
||||||
|
<ChevronRight className="w-5 h-5 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
258
src/pages/member/OrderDetail.tsx
Normal file
258
src/pages/member/OrderDetail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
126
supabase/functions/create-meet-link/index.ts
Normal file
126
supabase/functions/create-meet-link/index.ts
Normal 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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
173
supabase/functions/send-consultation-reminder/index.ts
Normal file
173
supabase/functions/send-consultation-reminder/index.ts
Normal 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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user