Upgrade admin settings
- Add tabbed Pengaturan layout with Jam Kerja, Notifikasi, Konsultasi; placeholders for Branding and Integrasi - Implement Notifikasi: SMTP settings table and templates UI with sample fields and test actions - Move Konsultasi to settings with blocks and categories; groundwork for slot-based booking - Wire consulting bookings flow and Google Meet placeholder; extend Pakasir webhook for consulting slots - Add RLS policies scaffolding for new tables and branding/integration fields - Prepare branding fields in platform_settings and integrate branding into UI - Enable email/webhook toggling semantics and placeholder for email provider wiring Env changes and notes: - Requires notification_settings, notification_templates, consulting_settings, consulting_slots tables - Adds branding and integration keys to platform_settings - Adds Google Meet integration and ical/Meet placeholders - Webhook enhancements and placeholder for SMTP/email sending logic X-Lovable-Edit-ID: edt-c143f191-1012-47a4-b592-c55bb7e83319
This commit is contained in:
@@ -12,6 +12,7 @@ import ProductDetail from "./pages/ProductDetail";
|
||||
import Checkout from "./pages/Checkout";
|
||||
import Bootcamp from "./pages/Bootcamp";
|
||||
import Events from "./pages/Events";
|
||||
import ConsultingBooking from "./pages/ConsultingBooking";
|
||||
import NotFound from "./pages/NotFound";
|
||||
|
||||
// Member pages
|
||||
@@ -28,6 +29,7 @@ import AdminOrders from "./pages/admin/AdminOrders";
|
||||
import AdminMembers from "./pages/admin/AdminMembers";
|
||||
import AdminEvents from "./pages/admin/AdminEvents";
|
||||
import AdminSettings from "./pages/admin/AdminSettings";
|
||||
import AdminConsulting from "./pages/admin/AdminConsulting";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -47,6 +49,7 @@ const App = () => (
|
||||
<Route path="/checkout" element={<Checkout />} />
|
||||
<Route path="/events" element={<Events />} />
|
||||
<Route path="/bootcamp/:slug" element={<Bootcamp />} />
|
||||
<Route path="/consulting" element={<ConsultingBooking />} />
|
||||
|
||||
{/* Member routes */}
|
||||
<Route path="/dashboard" element={<MemberDashboard />} />
|
||||
@@ -62,6 +65,7 @@ const App = () => (
|
||||
<Route path="/admin/members" element={<AdminMembers />} />
|
||||
<Route path="/admin/events" element={<AdminEvents />} />
|
||||
<Route path="/admin/settings" element={<AdminSettings />} />
|
||||
<Route path="/admin/consulting" element={<AdminConsulting />} />
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
|
||||
239
src/components/admin/settings/BrandingTab.tsx
Normal file
239
src/components/admin/settings/BrandingTab.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Palette, Image, Mail } from 'lucide-react';
|
||||
|
||||
interface PlatformSettings {
|
||||
id?: string;
|
||||
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;
|
||||
}
|
||||
|
||||
const emptySettings: PlatformSettings = {
|
||||
brand_name: '',
|
||||
brand_tagline: '',
|
||||
brand_logo_url: '',
|
||||
brand_favicon_url: '',
|
||||
brand_primary_color: '#111827',
|
||||
brand_accent_color: '#0F766E',
|
||||
brand_email_from_name: '',
|
||||
};
|
||||
|
||||
export function BrandingTab() {
|
||||
const [settings, setSettings] = useState<PlatformSettings>(emptySettings);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('platform_settings')
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
setSettings({
|
||||
id: data.id,
|
||||
brand_name: data.brand_name || '',
|
||||
brand_tagline: data.brand_tagline || '',
|
||||
brand_logo_url: data.brand_logo_url || '',
|
||||
brand_favicon_url: data.brand_favicon_url || '',
|
||||
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 || '',
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
setSaving(true);
|
||||
const payload = { ...settings };
|
||||
delete payload.id;
|
||||
|
||||
if (settings.id) {
|
||||
const { error } = await supabase
|
||||
.from('platform_settings')
|
||||
.update(payload)
|
||||
.eq('id', settings.id);
|
||||
|
||||
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
else toast({ title: 'Berhasil', description: 'Pengaturan branding disimpan' });
|
||||
} else {
|
||||
const { data, error } = await supabase
|
||||
.from('platform_settings')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
else {
|
||||
setSettings({ ...settings, id: data.id });
|
||||
toast({ title: 'Berhasil', description: 'Pengaturan branding disimpan' });
|
||||
}
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Palette className="w-5 h-5" />
|
||||
Identitas Brand
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Konfigurasi nama, tagline, dan tampilan platform
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Nama Platform</Label>
|
||||
<Input
|
||||
value={settings.brand_name}
|
||||
onChange={(e) => setSettings({ ...settings, brand_name: e.target.value })}
|
||||
placeholder="LearnHub"
|
||||
className="border-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ditampilkan di sidebar, header, dan email
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Tagline</Label>
|
||||
<Input
|
||||
value={settings.brand_tagline}
|
||||
onChange={(e) => setSettings({ ...settings, brand_tagline: e.target.value })}
|
||||
placeholder="Belajar bareng, dari praktisi."
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Image className="w-4 h-4" />
|
||||
Logo Utama (URL)
|
||||
</Label>
|
||||
<Input
|
||||
value={settings.brand_logo_url}
|
||||
onChange={(e) => setSettings({ ...settings, brand_logo_url: e.target.value })}
|
||||
placeholder="https://example.com/logo.png"
|
||||
className="border-2"
|
||||
/>
|
||||
{settings.brand_logo_url && (
|
||||
<div className="mt-2 p-2 bg-muted rounded-md">
|
||||
<img
|
||||
src={settings.brand_logo_url}
|
||||
alt="Logo preview"
|
||||
className="h-12 object-contain"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Image className="w-4 h-4" />
|
||||
Favicon (URL)
|
||||
</Label>
|
||||
<Input
|
||||
value={settings.brand_favicon_url}
|
||||
onChange={(e) => setSettings({ ...settings, brand_favicon_url: e.target.value })}
|
||||
placeholder="https://example.com/favicon.ico"
|
||||
className="border-2"
|
||||
/>
|
||||
{settings.brand_favicon_url && (
|
||||
<div className="mt-2 p-2 bg-muted rounded-md">
|
||||
<img
|
||||
src={settings.brand_favicon_url}
|
||||
alt="Favicon preview"
|
||||
className="h-8 w-8 object-contain"
|
||||
onError={(e) => (e.currentTarget.style.display = 'none')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>Warna Utama (Hex)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={settings.brand_primary_color}
|
||||
onChange={(e) => setSettings({ ...settings, brand_primary_color: e.target.value })}
|
||||
className="w-16 h-10 p-1 border-2"
|
||||
/>
|
||||
<Input
|
||||
value={settings.brand_primary_color}
|
||||
onChange={(e) => setSettings({ ...settings, brand_primary_color: e.target.value })}
|
||||
placeholder="#111827"
|
||||
className="border-2 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Warna Aksen / Tombol (Hex)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="color"
|
||||
value={settings.brand_accent_color}
|
||||
onChange={(e) => setSettings({ ...settings, brand_accent_color: e.target.value })}
|
||||
className="w-16 h-10 p-1 border-2"
|
||||
/>
|
||||
<Input
|
||||
value={settings.brand_accent_color}
|
||||
onChange={(e) => setSettings({ ...settings, brand_accent_color: e.target.value })}
|
||||
placeholder="#0F766E"
|
||||
className="border-2 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4" />
|
||||
Nama Pengirim Default Email
|
||||
</Label>
|
||||
<Input
|
||||
value={settings.brand_email_from_name}
|
||||
onChange={(e) => setSettings({ ...settings, brand_email_from_name: e.target.value })}
|
||||
placeholder="LearnHub Team"
|
||||
className="border-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Digunakan jika SMTP from_name kosong
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button onClick={saveSettings} disabled={saving} className="shadow-sm">
|
||||
{saving ? 'Menyimpan...' : 'Simpan Pengaturan'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
280
src/components/admin/settings/IntegrasiTab.tsx
Normal file
280
src/components/admin/settings/IntegrasiTab.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '@/integrations/supabase/client';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Puzzle, Webhook, MessageSquare, Calendar, Mail, Link as LinkIcon } from 'lucide-react';
|
||||
|
||||
interface IntegrationSettings {
|
||||
id?: string;
|
||||
integration_n8n_base_url: string;
|
||||
integration_whatsapp_number: string;
|
||||
integration_whatsapp_url: string;
|
||||
integration_google_calendar_id: string;
|
||||
integration_email_provider: string;
|
||||
integration_email_api_base_url: string;
|
||||
integration_privacy_url: string;
|
||||
integration_terms_url: string;
|
||||
}
|
||||
|
||||
const emptySettings: IntegrationSettings = {
|
||||
integration_n8n_base_url: '',
|
||||
integration_whatsapp_number: '',
|
||||
integration_whatsapp_url: '',
|
||||
integration_google_calendar_id: '',
|
||||
integration_email_provider: 'smtp',
|
||||
integration_email_api_base_url: '',
|
||||
integration_privacy_url: '/privacy',
|
||||
integration_terms_url: '/terms',
|
||||
};
|
||||
|
||||
export function IntegrasiTab() {
|
||||
const [settings, setSettings] = useState<IntegrationSettings>(emptySettings);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
const { data, error } = await supabase
|
||||
.from('platform_settings')
|
||||
.select('*')
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
setSettings({
|
||||
id: data.id,
|
||||
integration_n8n_base_url: data.integration_n8n_base_url || '',
|
||||
integration_whatsapp_number: data.integration_whatsapp_number || '',
|
||||
integration_whatsapp_url: data.integration_whatsapp_url || '',
|
||||
integration_google_calendar_id: data.integration_google_calendar_id || '',
|
||||
integration_email_provider: data.integration_email_provider || 'smtp',
|
||||
integration_email_api_base_url: data.integration_email_api_base_url || '',
|
||||
integration_privacy_url: data.integration_privacy_url || '/privacy',
|
||||
integration_terms_url: data.integration_terms_url || '/terms',
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const saveSettings = async () => {
|
||||
setSaving(true);
|
||||
const payload = { ...settings };
|
||||
delete payload.id;
|
||||
|
||||
if (settings.id) {
|
||||
const { error } = await supabase
|
||||
.from('platform_settings')
|
||||
.update(payload)
|
||||
.eq('id', settings.id);
|
||||
|
||||
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
else toast({ title: 'Berhasil', description: 'Pengaturan integrasi disimpan' });
|
||||
} else {
|
||||
const { data, error } = await supabase
|
||||
.from('platform_settings')
|
||||
.insert(payload)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (error) toast({ title: 'Error', description: error.message, variant: 'destructive' });
|
||||
else {
|
||||
setSettings({ ...settings, id: data.id });
|
||||
toast({ title: 'Berhasil', description: 'Pengaturan integrasi disimpan' });
|
||||
}
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
if (loading) return <div className="animate-pulse h-64 bg-muted rounded-md" />;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* n8n / Webhook */}
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Webhook className="w-5 h-5" />
|
||||
n8n / Webhook
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Konfigurasi URL untuk integrasi otomatisasi
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Base URL n8n / Integrasi</Label>
|
||||
<Input
|
||||
value={settings.integration_n8n_base_url}
|
||||
onChange={(e) => setSettings({ ...settings, integration_n8n_base_url: e.target.value })}
|
||||
placeholder="https://automation.domain.com"
|
||||
className="border-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Digunakan sebagai target default untuk webhook lanjutan. webhook_url per template tetap harus URL lengkap.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* WhatsApp */}
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<MessageSquare className="w-5 h-5" />
|
||||
WhatsApp
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Nomor kontak untuk dukungan dan notifikasi
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Nomor WhatsApp Dukungan</Label>
|
||||
<Input
|
||||
value={settings.integration_whatsapp_number}
|
||||
onChange={(e) => setSettings({ ...settings, integration_whatsapp_number: e.target.value })}
|
||||
placeholder="+62812xxxx"
|
||||
className="border-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Ditampilkan di konfirmasi konsultasi
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>URL WhatsApp Click-to-Chat (Opsional)</Label>
|
||||
<Input
|
||||
value={settings.integration_whatsapp_url}
|
||||
onChange={(e) => setSettings({ ...settings, integration_whatsapp_url: e.target.value })}
|
||||
placeholder="https://wa.me/62812..."
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Google Calendar */}
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Calendar className="w-5 h-5" />
|
||||
Google Calendar
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Untuk pembuatan event konsultasi otomatis
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>ID Kalender Google untuk Konsultasi</Label>
|
||||
<Input
|
||||
value={settings.integration_google_calendar_id}
|
||||
onChange={(e) => setSettings({ ...settings, integration_google_calendar_id: e.target.value })}
|
||||
placeholder="your-calendar@gmail.com"
|
||||
className="border-2"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Backend/n8n akan menggunakan ID ini untuk membuat event
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Email Provider */}
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Mail className="w-5 h-5" />
|
||||
Provider Email (Opsional)
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Konfigurasi alternatif selain SMTP
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Provider Email Eksternal</Label>
|
||||
<Select
|
||||
value={settings.integration_email_provider}
|
||||
onValueChange={(value) => setSettings({ ...settings, integration_email_provider: value })}
|
||||
>
|
||||
<SelectTrigger className="border-2">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="smtp">SMTP (Default)</SelectItem>
|
||||
<SelectItem value="resend">Resend</SelectItem>
|
||||
<SelectItem value="mailgun">Mailgun</SelectItem>
|
||||
<SelectItem value="sendgrid">SendGrid</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>API Base URL Provider Email</Label>
|
||||
<Input
|
||||
value={settings.integration_email_api_base_url}
|
||||
onChange={(e) => setSettings({ ...settings, integration_email_api_base_url: e.target.value })}
|
||||
placeholder="https://api.resend.com"
|
||||
className="border-2"
|
||||
disabled={settings.integration_email_provider === 'smtp'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Public Links */}
|
||||
<Card className="border-2 border-border">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<LinkIcon className="w-5 h-5" />
|
||||
Link Publik
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
URL untuk halaman legal
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>URL Kebijakan Privasi</Label>
|
||||
<Input
|
||||
value={settings.integration_privacy_url}
|
||||
onChange={(e) => setSettings({ ...settings, integration_privacy_url: e.target.value })}
|
||||
placeholder="/privacy"
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>URL Syarat & Ketentuan</Label>
|
||||
<Input
|
||||
value={settings.integration_terms_url}
|
||||
onChange={(e) => setSettings({ ...settings, integration_terms_url: e.target.value })}
|
||||
placeholder="/terms"
|
||||
className="border-2"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Default: halaman internal. Bisa diganti dengan URL eksternal.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Button onClick={saveSettings} disabled={saving} className="shadow-sm">
|
||||
{saving ? 'Menyimpan...' : 'Simpan Pengaturan'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -41,14 +41,49 @@ const SHORTCODES_HELP = {
|
||||
event: ['{judul_event}', '{tanggal_event}', '{jam_event}', '{link_event}'],
|
||||
};
|
||||
|
||||
const DEFAULT_TEMPLATES = [
|
||||
{ key: 'payment_success', name: 'Pembayaran Berhasil' },
|
||||
{ key: 'access_granted', name: 'Akses Produk Diberikan' },
|
||||
{ key: 'order_created', name: 'Pesanan Dibuat' },
|
||||
{ key: 'payment_reminder', name: 'Pengingat Pembayaran' },
|
||||
{ key: 'consulting_scheduled', name: 'Konsultasi Terjadwal' },
|
||||
{ key: 'event_reminder', name: 'Reminder Webinar/Bootcamp' },
|
||||
{ key: 'bootcamp_progress', name: 'Progress Bootcamp' },
|
||||
const DEFAULT_TEMPLATES: { key: string; name: string; defaultSubject: string; defaultBody: string }[] = [
|
||||
{
|
||||
key: 'payment_success',
|
||||
name: 'Pembayaran Berhasil',
|
||||
defaultSubject: 'Pembayaran Berhasil - Order #{order_id}',
|
||||
defaultBody: '<h2>Halo {nama}!</h2><p>Terima kasih, pembayaran Anda sebesar <strong>{total}</strong> telah berhasil dikonfirmasi.</p><p><strong>Detail Pesanan:</strong></p><ul><li>Order ID: {order_id}</li><li>Tanggal: {tanggal_pesanan}</li><li>Metode: {metode_pembayaran}</li></ul><p>Produk: {produk}</p>'
|
||||
},
|
||||
{
|
||||
key: 'access_granted',
|
||||
name: 'Akses Produk Diberikan',
|
||||
defaultSubject: 'Akses Anda Sudah Aktif - {produk}',
|
||||
defaultBody: '<h2>Halo {nama}!</h2><p>Selamat! Akses Anda ke <strong>{produk}</strong> sudah aktif.</p><p><a href="{link_akses}" style="display:inline-block;padding:12px 24px;background:#0066cc;color:white;text-decoration:none;border-radius:6px;">Akses Sekarang</a></p>'
|
||||
},
|
||||
{
|
||||
key: 'order_created',
|
||||
name: 'Pesanan Dibuat',
|
||||
defaultSubject: 'Pesanan Anda #{order_id} Sedang Diproses',
|
||||
defaultBody: '<h2>Halo {nama}!</h2><p>Pesanan Anda dengan nomor <strong>{order_id}</strong> telah kami terima.</p><p>Total: <strong>{total}</strong></p><p>Silakan selesaikan pembayaran sebelum batas waktu.</p>'
|
||||
},
|
||||
{
|
||||
key: 'payment_reminder',
|
||||
name: 'Pengingat Pembayaran',
|
||||
defaultSubject: 'Jangan Lupa Bayar - Order #{order_id}',
|
||||
defaultBody: '<h2>Halo {nama}!</h2><p>Pesanan Anda dengan nomor <strong>{order_id}</strong> menunggu pembayaran.</p><p>Total: <strong>{total}</strong></p><p>Segera selesaikan pembayaran agar tidak kedaluwarsa.</p>'
|
||||
},
|
||||
{
|
||||
key: 'consulting_scheduled',
|
||||
name: 'Konsultasi Terjadwal',
|
||||
defaultSubject: 'Konsultasi Anda Sudah Terjadwal - {tanggal_konsultasi}',
|
||||
defaultBody: '<h2>Halo {nama}!</h2><p>Sesi konsultasi Anda telah dikonfirmasi:</p><ul><li>Tanggal: <strong>{tanggal_konsultasi}</strong></li><li>Jam: <strong>{jam_konsultasi}</strong></li></ul><p>Link meeting: <a href="{link_meet}">{link_meet}</a></p><p>Jika ada pertanyaan, hubungi kami.</p>'
|
||||
},
|
||||
{
|
||||
key: 'event_reminder',
|
||||
name: 'Reminder Webinar/Bootcamp',
|
||||
defaultSubject: 'Reminder: {judul_event} Dimulai {tanggal_event}',
|
||||
defaultBody: '<h2>Halo {nama}!</h2><p>Jangan lupa, <strong>{judul_event}</strong> akan dimulai:</p><ul><li>Tanggal: {tanggal_event}</li><li>Jam: {jam_event}</li></ul><p><a href="{link_event}" style="display:inline-block;padding:12px 24px;background:#0066cc;color:white;text-decoration:none;border-radius:6px;">Bergabung</a></p>'
|
||||
},
|
||||
{
|
||||
key: 'bootcamp_progress',
|
||||
name: 'Progress Bootcamp',
|
||||
defaultSubject: 'Update Progress Bootcamp Anda',
|
||||
defaultBody: '<h2>Halo {nama}!</h2><p>Ini adalah update progress bootcamp Anda.</p><p>Terus semangat belajar!</p>'
|
||||
},
|
||||
];
|
||||
|
||||
const emptySmtp: SmtpSettings = {
|
||||
@@ -94,8 +129,8 @@ export function NotifikasiTab() {
|
||||
key: t.key,
|
||||
name: t.name,
|
||||
is_active: false,
|
||||
email_subject: '',
|
||||
email_body_html: '',
|
||||
email_subject: t.defaultSubject,
|
||||
email_body_html: t.defaultBody,
|
||||
webhook_url: '',
|
||||
}));
|
||||
const { data, error } = await supabase.from('notification_templates').insert(toInsert).select();
|
||||
@@ -261,8 +296,8 @@ export function NotifikasiTab() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground p-3 bg-muted rounded-md">
|
||||
<p className="font-medium mb-2">Shortcode yang tersedia:</p>
|
||||
<div className="text-sm text-muted-foreground p-3 bg-muted rounded-md space-y-2">
|
||||
<p className="font-medium">Shortcode yang tersedia:</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
||||
<div>
|
||||
<span className="font-medium">Umum:</span> {SHORTCODES_HELP.common.join(', ')}
|
||||
@@ -277,6 +312,11 @@ export function NotifikasiTab() {
|
||||
<span className="font-medium">Event:</span> {SHORTCODES_HELP.event.join(', ')}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs mt-2 p-2 bg-background rounded border">
|
||||
<strong>Penting:</strong> Toggle "Aktifkan" hanya mengontrol pengiriman email.
|
||||
Jika <code>webhook_url</code> diisi, sistem tetap akan mengirim payload ke URL tersebut
|
||||
meskipun email dinonaktifkan.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{templates.map((template) => (
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useCart } from '@/contexts/CartContext';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { formatIDR } from '@/lib/format';
|
||||
import { Video } from 'lucide-react';
|
||||
|
||||
interface Product {
|
||||
id: string;
|
||||
@@ -21,27 +22,45 @@ interface Product {
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
interface ConsultingSettings {
|
||||
is_consulting_enabled: boolean;
|
||||
consulting_block_price: number;
|
||||
consulting_block_duration_minutes: number;
|
||||
}
|
||||
|
||||
export default function Products() {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [consultingSettings, setConsultingSettings] = useState<ConsultingSettings | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { addItem, items } = useCart();
|
||||
|
||||
useEffect(() => {
|
||||
fetchProducts();
|
||||
fetchData();
|
||||
}, []);
|
||||
|
||||
const fetchProducts = async () => {
|
||||
const { data, error } = await supabase
|
||||
const fetchData = async () => {
|
||||
const [productsRes, consultingRes] = await Promise.all([
|
||||
supabase
|
||||
.from('products')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.order('created_at', { ascending: false });
|
||||
.order('created_at', { ascending: false }),
|
||||
supabase
|
||||
.from('consulting_settings')
|
||||
.select('is_consulting_enabled, consulting_block_price, consulting_block_duration_minutes')
|
||||
.single(),
|
||||
]);
|
||||
|
||||
if (error) {
|
||||
if (productsRes.error) {
|
||||
toast({ title: 'Error', description: 'Gagal memuat produk', variant: 'destructive' });
|
||||
} else {
|
||||
setProducts(data || []);
|
||||
setProducts(productsRes.data || []);
|
||||
}
|
||||
|
||||
if (consultingRes.data) {
|
||||
setConsultingSettings(consultingRes.data);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
@@ -87,12 +106,42 @@ export default function Products() {
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-muted-foreground">Belum ada produk tersedia.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{/* Consulting Card - Only show when enabled */}
|
||||
{consultingSettings?.is_consulting_enabled && (
|
||||
<Card className="border-2 border-primary shadow-sm hover:shadow-md transition-shadow bg-primary/5">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
<Video className="w-5 h-5" />
|
||||
Konsultasi 1-on-1
|
||||
</CardTitle>
|
||||
<Badge className="bg-primary">Konsultasi</Badge>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2">
|
||||
Sesi konsultasi pribadi dengan mentor. Pilih waktu dan durasi sesuai kebutuhan Anda.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="text-2xl font-bold">
|
||||
{formatIDR(consultingSettings.consulting_block_price)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
/ {consultingSettings.consulting_block_duration_minutes} menit
|
||||
</span>
|
||||
</div>
|
||||
<Link to="/consulting">
|
||||
<Button className="w-full shadow-sm">
|
||||
Booking Sekarang
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Regular Products */}
|
||||
{products.map((product) => (
|
||||
<Card key={product.id} className="border-2 border-border shadow-sm hover:shadow-md transition-shadow">
|
||||
<CardHeader>
|
||||
@@ -128,6 +177,12 @@ export default function Products() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{products.length === 0 && !consultingSettings?.is_consulting_enabled && (
|
||||
<div className="col-span-full text-center py-12">
|
||||
<p className="text-muted-foreground">Belum ada produk tersedia.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { WorkhoursTab } from '@/components/admin/settings/WorkhoursTab';
|
||||
import { NotifikasiTab } from '@/components/admin/settings/NotifikasiTab';
|
||||
import { KonsultasiTab } from '@/components/admin/settings/KonsultasiTab';
|
||||
import { BrandingTab } from '@/components/admin/settings/BrandingTab';
|
||||
import { IntegrasiTab } from '@/components/admin/settings/IntegrasiTab';
|
||||
import { Clock, Bell, Video, Palette, Puzzle } from 'lucide-react';
|
||||
|
||||
export default function AdminSettings() {
|
||||
@@ -51,11 +53,11 @@ export default function AdminSettings() {
|
||||
<Video className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Konsultasi</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="branding" className="flex items-center gap-2" disabled>
|
||||
<TabsTrigger value="branding" className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Branding</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="integrasi" className="flex items-center gap-2" disabled>
|
||||
<TabsTrigger value="integrasi" className="flex items-center gap-2">
|
||||
<Puzzle className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Integrasi</span>
|
||||
</TabsTrigger>
|
||||
@@ -74,15 +76,11 @@ export default function AdminSettings() {
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="branding">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
Fitur Branding akan segera hadir
|
||||
</div>
|
||||
<BrandingTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="integrasi">
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
Fitur Integrasi akan segera hadir
|
||||
</div>
|
||||
<IntegrasiTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { serve } from "https://deno.land/std@0.190.0/http/server.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
||||
import { createClient, SupabaseClient } from "https://esm.sh/@supabase/supabase-js@2.49.1";
|
||||
import { SMTPClient } from "https://deno.land/x/denomailer@1.6.0/mod.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type, x-pakasir-signature, x-callback-token",
|
||||
};
|
||||
|
||||
// TODO: Set these in your Supabase Edge Function secrets
|
||||
// Environment variables
|
||||
const PAKASIR_WEBHOOK_SECRET = Deno.env.get("PAKASIR_WEBHOOK_SECRET") || "";
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
|
||||
|
||||
// Email template placeholder - will be replaced with real provider later
|
||||
const ORDER_PAID_EMAIL_TEMPLATE = Deno.env.get("ORDER_PAID_EMAIL_TEMPLATE") || JSON.stringify({
|
||||
subject: "Pembayaran Berhasil - {{order_id}}",
|
||||
body: "Terima kasih! Pembayaran untuk pesanan {{order_id}} sebesar Rp {{amount}} telah berhasil. Anda sekarang memiliki akses ke: {{products}}."
|
||||
});
|
||||
|
||||
interface PakasirWebhookPayload {
|
||||
amount: number;
|
||||
order_id: string;
|
||||
@@ -26,33 +21,201 @@ interface PakasirWebhookPayload {
|
||||
completed_at?: string;
|
||||
}
|
||||
|
||||
// Placeholder email function - logs for now, will be replaced with real provider
|
||||
async function sendOrderPaidEmail(
|
||||
userEmail: string,
|
||||
order: { id: string; total_amount: number },
|
||||
products: string[]
|
||||
): Promise<void> {
|
||||
try {
|
||||
const template = JSON.parse(ORDER_PAID_EMAIL_TEMPLATE);
|
||||
interface SmtpSettings {
|
||||
smtp_host: string;
|
||||
smtp_port: number;
|
||||
smtp_username: string;
|
||||
smtp_password: string;
|
||||
smtp_from_name: string;
|
||||
smtp_from_email: string;
|
||||
smtp_use_tls: boolean;
|
||||
}
|
||||
|
||||
const subject = template.subject
|
||||
.replace("{{order_id}}", order.id.substring(0, 8))
|
||||
.replace("{{amount}}", order.total_amount.toLocaleString("id-ID"));
|
||||
interface NotificationTemplate {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
is_active: boolean;
|
||||
email_subject: string;
|
||||
email_body_html: string;
|
||||
webhook_url: string;
|
||||
}
|
||||
|
||||
const body = template.body
|
||||
.replace("{{order_id}}", order.id.substring(0, 8))
|
||||
.replace("{{amount}}", order.total_amount.toLocaleString("id-ID"))
|
||||
.replace("{{products}}", products.join(", "));
|
||||
|
||||
console.log("[EMAIL] Would send to:", userEmail);
|
||||
console.log("[EMAIL] Subject:", subject);
|
||||
console.log("[EMAIL] Body:", body);
|
||||
|
||||
// TODO: Replace with actual email provider call (e.g., Resend, SendGrid)
|
||||
// await emailProvider.send({ to: userEmail, subject, body });
|
||||
} catch (error) {
|
||||
console.error("[EMAIL] Error preparing email:", error);
|
||||
// Replace shortcodes in template
|
||||
function replaceShortcodes(template: string, data: Record<string, string>): string {
|
||||
let result = template || "";
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), value || '');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Send email via SMTP
|
||||
async function sendEmail(
|
||||
smtp: SmtpSettings,
|
||||
to: string,
|
||||
subject: string,
|
||||
htmlBody: string,
|
||||
brandFromName?: string
|
||||
): Promise<boolean> {
|
||||
if (!smtp.smtp_host || !smtp.smtp_username || !smtp.smtp_password) {
|
||||
console.log("[EMAIL] SMTP not configured, skipping email");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const client = new SMTPClient({
|
||||
connection: {
|
||||
hostname: smtp.smtp_host,
|
||||
port: smtp.smtp_port,
|
||||
tls: smtp.smtp_use_tls,
|
||||
auth: {
|
||||
username: smtp.smtp_username,
|
||||
password: smtp.smtp_password,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const fromName = smtp.smtp_from_name || brandFromName || "Notification";
|
||||
const fromEmail = smtp.smtp_from_email || smtp.smtp_username;
|
||||
|
||||
await client.send({
|
||||
from: `${fromName} <${fromEmail}>`,
|
||||
to: to,
|
||||
subject: subject,
|
||||
content: "auto",
|
||||
html: htmlBody,
|
||||
});
|
||||
|
||||
await client.close();
|
||||
console.log("[EMAIL] Sent successfully to:", to);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("[EMAIL] Failed to send:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Send webhook notification
|
||||
async function sendWebhook(url: string, payload: Record<string, unknown>): Promise<boolean> {
|
||||
if (!url) return false;
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
console.log("[WEBHOOK] Sent to:", url, "Status:", response.status);
|
||||
return response.ok;
|
||||
} catch (error) {
|
||||
console.error("[WEBHOOK] Failed to send to:", url, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Process notification for a specific event
|
||||
async function processNotification(
|
||||
supabase: SupabaseClient,
|
||||
templateKey: string,
|
||||
recipientEmail: string,
|
||||
shortcodeData: Record<string, string>,
|
||||
webhookPayload: Record<string, unknown>
|
||||
): Promise<void> {
|
||||
console.log(`[NOTIFICATION] Processing ${templateKey} for ${recipientEmail}`);
|
||||
|
||||
// Fetch template
|
||||
const { data: templateData } = await supabase
|
||||
.from("notification_templates")
|
||||
.select("*")
|
||||
.eq("key", templateKey)
|
||||
.single();
|
||||
|
||||
if (!templateData) {
|
||||
console.log(`[NOTIFICATION] Template ${templateKey} not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const template = templateData as NotificationTemplate;
|
||||
|
||||
// ALWAYS send webhook if URL is configured (regardless of is_active)
|
||||
if (template.webhook_url) {
|
||||
await sendWebhook(template.webhook_url, webhookPayload);
|
||||
|
||||
// Update last_payload_example
|
||||
await supabase
|
||||
.from("notification_templates")
|
||||
.update({ last_payload_example: webhookPayload, updated_at: new Date().toISOString() })
|
||||
.eq("id", template.id);
|
||||
}
|
||||
|
||||
// Only send email if is_active is true
|
||||
if (!template.is_active) {
|
||||
console.log(`[NOTIFICATION] Template ${templateKey} is not active, skipping email`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch SMTP settings
|
||||
const { data: smtpData } = await supabase
|
||||
.from("notification_settings")
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
if (!smtpData) {
|
||||
console.log("[NOTIFICATION] SMTP settings not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
const smtpSettings = smtpData as SmtpSettings;
|
||||
|
||||
// Fetch brand settings for fallback from_name
|
||||
const { data: platformData } = await supabase
|
||||
.from("platform_settings")
|
||||
.select("brand_email_from_name")
|
||||
.single();
|
||||
|
||||
const brandFromName = (platformData as { brand_email_from_name?: string } | null)?.brand_email_from_name;
|
||||
|
||||
// Replace shortcodes
|
||||
const subject = replaceShortcodes(template.email_subject, shortcodeData);
|
||||
const body = replaceShortcodes(template.email_body_html, shortcodeData);
|
||||
|
||||
// Wrap body in email template
|
||||
const fullHtml = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; line-height: 1.6; color: #333; margin: 0; padding: 0; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #f8f9fa; padding: 20px; text-align: center; border-radius: 8px 8px 0 0; }
|
||||
.content { background: #ffffff; padding: 30px; border: 1px solid #e9ecef; }
|
||||
.footer { background: #f8f9fa; padding: 15px; text-align: center; font-size: 12px; color: #6c757d; border-radius: 0 0 8px 8px; }
|
||||
a { color: #0066cc; }
|
||||
.button { display: inline-block; padding: 12px 24px; background: #0066cc; color: white; text-decoration: none; border-radius: 6px; margin: 10px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="content">
|
||||
${body}
|
||||
</div>
|
||||
<div class="footer">
|
||||
Email ini dikirim otomatis. Jangan membalas email ini.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
await sendEmail(
|
||||
smtpSettings,
|
||||
recipientEmail,
|
||||
subject,
|
||||
fullHtml,
|
||||
brandFromName
|
||||
);
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
@@ -143,7 +306,67 @@ serve(async (req) => {
|
||||
|
||||
console.log("[WEBHOOK] Order updated to paid:", order.id);
|
||||
|
||||
// Get order items to grant access
|
||||
// Get user profile
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("full_name, email")
|
||||
.eq("id", order.user_id)
|
||||
.single();
|
||||
|
||||
const userEmail = (profile as { email?: string } | null)?.email || "";
|
||||
const userName = (profile as { full_name?: string } | null)?.full_name || "Pelanggan";
|
||||
|
||||
// Check for consulting slots linked to this order
|
||||
const { data: consultingSlots } = await supabase
|
||||
.from("consulting_slots")
|
||||
.select("*")
|
||||
.eq("order_id", order.id);
|
||||
|
||||
if (consultingSlots && consultingSlots.length > 0) {
|
||||
// This is a consulting order - update slot statuses
|
||||
const { error: slotUpdateError } = await supabase
|
||||
.from("consulting_slots")
|
||||
.update({ status: "confirmed" })
|
||||
.eq("order_id", order.id);
|
||||
|
||||
if (slotUpdateError) {
|
||||
console.error("[WEBHOOK] Failed to update consulting slots:", slotUpdateError);
|
||||
} else {
|
||||
console.log("[WEBHOOK] Consulting slots confirmed for order:", order.id);
|
||||
}
|
||||
|
||||
// Format consulting slot details for notification
|
||||
const slots = consultingSlots as Array<{ date: string; start_time: string; end_time: string; meet_link?: string }>;
|
||||
|
||||
const shortcodeData: Record<string, string> = {
|
||||
nama: userName,
|
||||
email: userEmail,
|
||||
order_id: order.id.substring(0, 8),
|
||||
tanggal_pesanan: new Date().toLocaleDateString("id-ID"),
|
||||
total: `Rp ${order.total_amount.toLocaleString("id-ID")}`,
|
||||
metode_pembayaran: payload.payment_method || "Pakasir",
|
||||
tanggal_konsultasi: slots[0]?.date || "",
|
||||
jam_konsultasi: slots.map(s => s.start_time.substring(0, 5)).join(", "),
|
||||
link_meet: slots[0]?.meet_link || "Akan dikirim terpisah",
|
||||
};
|
||||
|
||||
const webhookPayload: Record<string, unknown> = {
|
||||
event: "consulting_scheduled",
|
||||
order_id: order.id,
|
||||
user_id: order.user_id,
|
||||
user_email: userEmail,
|
||||
user_name: userName,
|
||||
total_amount: order.total_amount,
|
||||
payment_method: payload.payment_method,
|
||||
slots: consultingSlots,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Send consulting_scheduled notification
|
||||
await processNotification(supabase, "consulting_scheduled", userEmail, shortcodeData, webhookPayload);
|
||||
|
||||
} else {
|
||||
// Regular product order - grant access
|
||||
const { data: orderItems, error: itemsError } = await supabase
|
||||
.from("order_items")
|
||||
.select("product_id, product:products(title)")
|
||||
@@ -157,9 +380,8 @@ serve(async (req) => {
|
||||
|
||||
// Grant user_access for each product
|
||||
if (orderItems && orderItems.length > 0) {
|
||||
for (const item of orderItems) {
|
||||
for (const item of orderItems as Array<{ product_id: string; product: { title: string } | { title: string }[] | null }>) {
|
||||
const productId = item.product_id;
|
||||
// Supabase joins can return array or single object depending on relationship
|
||||
const productData = Array.isArray(item.product) ? item.product[0] : item.product;
|
||||
|
||||
// Check if access already exists
|
||||
@@ -191,16 +413,45 @@ serve(async (req) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Get user email for notification
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("email")
|
||||
.eq("id", order.user_id)
|
||||
.single();
|
||||
const shortcodeData: Record<string, string> = {
|
||||
nama: userName,
|
||||
email: userEmail,
|
||||
order_id: order.id.substring(0, 8),
|
||||
tanggal_pesanan: new Date().toLocaleDateString("id-ID"),
|
||||
total: `Rp ${order.total_amount.toLocaleString("id-ID")}`,
|
||||
metode_pembayaran: payload.payment_method || "Pakasir",
|
||||
produk: productTitles.join(", "),
|
||||
link_akses: `${Deno.env.get("SITE_URL") || ""}/access`,
|
||||
};
|
||||
|
||||
// Send email notification (placeholder)
|
||||
if (profile?.email) {
|
||||
await sendOrderPaidEmail(profile.email, order, productTitles);
|
||||
const webhookPayload: Record<string, unknown> = {
|
||||
event: "payment_success",
|
||||
order_id: order.id,
|
||||
user_id: order.user_id,
|
||||
user_email: userEmail,
|
||||
user_name: userName,
|
||||
total_amount: order.total_amount,
|
||||
payment_method: payload.payment_method,
|
||||
products: productTitles,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Send payment_success notification
|
||||
await processNotification(supabase, "payment_success", userEmail, shortcodeData, webhookPayload);
|
||||
|
||||
// Also send access_granted notification
|
||||
if (productTitles.length > 0) {
|
||||
const accessWebhookPayload: Record<string, unknown> = {
|
||||
event: "access_granted",
|
||||
order_id: order.id,
|
||||
user_id: order.user_id,
|
||||
user_email: userEmail,
|
||||
user_name: userName,
|
||||
products: productTitles,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
await processNotification(supabase, "access_granted", userEmail, shortcodeData, accessWebhookPayload);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify({ success: true, order_id: order.id }), {
|
||||
|
||||
Reference in New Issue
Block a user