Integrate branding settings

Implement Google Meet integration, reminders, HTML in descriptions, admin order detail, fix test email, add homepage branding settings, wire branding to frontend, and ensure all settings wired. Also add admin branding homepage tab, and routing/frontend updates.

X-Lovable-Edit-ID: edt-7e1950f8-765a-4f0e-8ae7-12854ef4d2f7
This commit is contained in:
gpt-engineer-app[bot]
2025-12-19 15:17:47 +00:00
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 { AuthProvider } from "@/hooks/useAuth";
import { CartProvider } from "@/contexts/CartContext";
import { BrandingProvider } from "@/hooks/useBranding";
import Index from "./pages/Index";
import Auth from "./pages/Auth";
import Products from "./pages/Products";
@@ -20,6 +21,7 @@ import MemberDashboard from "./pages/member/MemberDashboard";
import MemberAccess from "./pages/member/MemberAccess";
import MemberOrders from "./pages/member/MemberOrders";
import MemberProfile from "./pages/member/MemberProfile";
import OrderDetail from "./pages/member/OrderDetail";
// Admin pages
import AdminDashboard from "./pages/admin/AdminDashboard";
@@ -37,6 +39,7 @@ const App = () => (
<QueryClientProvider client={queryClient}>
<TooltipProvider>
<AuthProvider>
<BrandingProvider>
<CartProvider>
<Toaster />
<Sonner />
@@ -55,6 +58,7 @@ const App = () => (
<Route path="/dashboard" element={<MemberDashboard />} />
<Route path="/access" element={<MemberAccess />} />
<Route path="/orders" element={<MemberOrders />} />
<Route path="/orders/:id" element={<OrderDetail />} />
<Route path="/profile" element={<MemberProfile />} />
{/* Admin routes */}
@@ -71,6 +75,7 @@ const App = () => (
</Routes>
</BrowserRouter>
</CartProvider>
</BrandingProvider>
</AuthProvider>
</TooltipProvider>
</QueryClientProvider>

View File

@@ -2,6 +2,7 @@ import { ReactNode, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useAuth } from '@/hooks/useAuth';
import { useCart } from '@/contexts/CartContext';
import { useBranding } from '@/hooks/useBranding';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
@@ -69,6 +70,7 @@ interface AppLayoutProps {
export function AppLayout({ children }: AppLayoutProps) {
const { user, isAdmin, signOut } = useAuth();
const { items } = useCart();
const branding = useBranding();
const location = useLocation();
const navigate = useNavigate();
const [moreOpen, setMoreOpen] = useState(false);
@@ -93,13 +95,22 @@ export function AppLayout({ children }: AppLayoutProps) {
// Get additional items for "More" menu
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) {
// Public layout for non-authenticated pages
return (
<div className="min-h-screen bg-background">
<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">
<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">
<Link to="/products" className="hover:underline font-medium">Produk</Link>
<Link to="/events" className="hover:underline font-medium">Kalender</Link>
@@ -132,7 +143,13 @@ export function AppLayout({ children }: AppLayoutProps) {
{/* Desktop Sidebar */}
<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">
<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>
<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">
{/* 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">
<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">
<Link to="/checkout" className="relative p-2">
<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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
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 {
id?: string;
@@ -16,8 +23,17 @@ interface PlatformSettings {
brand_primary_color: string;
brand_accent_color: 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 = {
brand_name: '',
brand_tagline: '',
@@ -26,8 +42,13 @@ const emptySettings: PlatformSettings = {
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: defaultFeatures,
};
const iconOptions = ['Users', 'Video', 'BookOpen', 'Star', 'Award', 'Target', 'Zap', 'Heart', 'Shield', 'Rocket'];
export function BrandingTab() {
const [settings, setSettings] = useState<PlatformSettings>(emptySettings);
const [loading, setLoading] = useState(true);
@@ -44,6 +65,17 @@ export function BrandingTab() {
.single();
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({
id: data.id,
brand_name: data.brand_name || '',
@@ -53,6 +85,9 @@ export function BrandingTab() {
brand_primary_color: data.brand_primary_color || '#111827',
brand_accent_color: data.brand_accent_color || '#0F766E',
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);
@@ -60,8 +95,18 @@ export function BrandingTab() {
const saveSettings = async () => {
setSaving(true);
const payload = { ...settings };
delete payload.id;
const payload = {
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) {
const { error } = await supabase
@@ -87,10 +132,33 @@ export function BrandingTab() {
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" />;
return (
<div className="space-y-6">
{/* Brand Identity */}
<Card className="border-2 border-border">
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -228,12 +296,119 @@ export function BrandingTab() {
Digunakan jika SMTP from_name kosong
</p>
</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">
{saving ? 'Menyimpan...' : 'Simpan Pengaturan'}
</Button>
</CardContent>
</Card>
</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 { Layout } from '@/components/Layout';
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() {
const branding = useBranding();
return (
<Layout>
<section className="container mx-auto px-4 py-16 text-center">
<h1 className="text-5xl md:text-6xl font-bold mb-6">
Learn. Grow. Succeed.
{branding.homepage_headline}
</h1>
<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>
<div className="flex gap-4 justify-center">
<Link to="/products">
@@ -30,27 +46,18 @@ export default function Index() {
<section className="container mx-auto px-4 py-16">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="border-2 border-border p-8 shadow-sm">
<Users className="w-12 h-12 mb-4" />
<h3 className="text-2xl font-bold mb-2">Consulting</h3>
{branding.homepage_features.map((feature, index) => {
const IconComponent = iconMap[feature.icon] || Users;
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">
One-on-one sessions with industry experts to solve your specific challenges.
</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.
{feature.description}
</p>
</div>
);
})}
</div>
</section>
</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 (
<AppLayout>
<div className="container mx-auto px-4 py-8">
@@ -149,7 +156,10 @@ export default function Products() {
<CardTitle className="text-xl">{product.title}</CardTitle>
<Badge className="bg-secondary">{getTypeLabel(product.type)}</Badge>
</div>
<CardDescription className="line-clamp-2">{product.description}</CardDescription>
<CardDescription
className="line-clamp-2"
dangerouslySetInnerHTML={{ __html: product.description }}
/>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 mb-4">

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, Link } from "react-router-dom";
import { AppLayout } from "@/components/AppLayout";
import { useAuth } from "@/hooks/useAuth";
import { supabase } from "@/integrations/supabase/client";
@@ -7,6 +7,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { formatIDR, formatDate } from "@/lib/format";
import { ChevronRight } from "lucide-react";
interface Order {
id: string;
@@ -96,7 +97,8 @@ export default function MemberOrders() {
) : (
<div className="space-y-4">
{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">
<div className="flex items-center justify-between">
<div>
@@ -111,10 +113,12 @@ export default function MemberOrders() {
{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>
</CardContent>
</Card>
</Link>
))}
</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]
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" } }
);
}
});