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:
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
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 { 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
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]
|
||||
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