Veriflo-Dashboard/src/pages/MessageConfig.jsx

525 lines
24 KiB
JavaScript

import { useState, useEffect } from 'react'
import { Save, Eye, Smartphone, Loader2, CheckCircle, ChevronDown, CircleHelp, X } from 'lucide-react'
import api from '../services/api'
import { useToast } from '../components/ToastProvider'
const DEFAULT_TEMPLATE = `{greeting} Your {sender_name} verification code is:
*{otp}*
This code expires in {expiry_seconds} seconds. Do not share it.`
function renderTemplate(template, values) {
return String(template || DEFAULT_TEMPLATE)
.replaceAll('{greeting}', String(values.greeting || ''))
.replaceAll('{sender_name}', String(values.sender_name || ''))
.replaceAll('{otp}', String(values.otp || ''))
.replaceAll('{expiry_seconds}', String(values.expiry_seconds || ''))
}
function formatDisplayPhone(phone) {
const digits = String(phone || '').replace(/\D/g, '')
if (!digits) return 'Not available'
if (digits.length <= 4) return `+${digits}`
if (digits.length <= 10) return `+${digits}`
const countryCode = digits.slice(0, digits.length - 10)
const local = digits.slice(-10)
return `+${countryCode} ${local.slice(0, 5)} ${local.slice(5)}`
}
export default function MessageConfig() {
const toast = useToast()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState('')
const [registeredPhone, setRegisteredPhone] = useState('')
const [testingWebhook, setTestingWebhook] = useState(false)
const [webhookNotice, setWebhookNotice] = useState('')
const [messageTemplateLimit, setMessageTemplateLimit] = useState(320)
const [sandboxMonthlyLimit, setSandboxMonthlyLimit] = useState(0)
const [sandboxUsedThisMonth, setSandboxUsedThisMonth] = useState(0)
const [liveMonthlyLimit, setLiveMonthlyLimit] = useState(0)
const [liveUsedThisMonth, setLiveUsedThisMonth] = useState(0)
const [openSections, setOpenSections] = useState({
environment: true,
sender: true,
webhook: false,
})
const [showSandboxHelp, setShowSandboxHelp] = useState(false)
const [settings, setSettings] = useState({
sender_name: 'Acme Corp',
greeting: 'Hello!',
message_template: DEFAULT_TEMPLATE,
otp_length: '6',
expiry_seconds: '60',
environment_mode: 'sandbox',
webhook_url: '',
return_otp_in_response: 0
})
useEffect(() => {
fetchSettings()
}, [])
const fetchSettings = async () => {
try {
const res = await api.get('/api/user/profile')
setRegisteredPhone(res.data.user?.phone || '')
setMessageTemplateLimit(res.data.limits?.message_template_max_chars || 320)
setSandboxMonthlyLimit(res.data.limits?.sandbox_monthly_message_limit || 0)
setSandboxUsedThisMonth(res.data.stats?.sandbox_used_this_month || 0)
setLiveMonthlyLimit(res.data.limits?.live_monthly_message_limit || 0)
setLiveUsedThisMonth(res.data.stats?.live_used_this_month || 0)
if (res.data.settings) {
setSettings({
...res.data.settings,
message_template: res.data.settings.message_template || DEFAULT_TEMPLATE,
otp_length: String(res.data.settings.otp_length),
expiry_seconds: String(res.data.settings.expiry_seconds),
environment_mode: res.data.settings.environment_mode || 'sandbox'
})
}
} catch (err) {
console.error("Failed to fetch settings", err)
setError('Failed to load settings')
} finally {
setLoading(false)
}
}
const handleSave = async () => {
setSaving(true)
setError('')
setSaved(false)
try {
const res = await api.put('/api/user/settings', {
...settings,
otp_length: Number(settings.otp_length),
expiry_seconds: Number(settings.expiry_seconds)
})
window.dispatchEvent(new CustomEvent('veriflo-settings-updated', {
detail: {
environment_mode: res.data.settings?.environment_mode || settings.environment_mode
}
}))
setSaved(true)
toast.success('Configuration saved')
setTimeout(() => setSaved(false), 3000)
} catch (err) {
const message = err.response?.data?.error || 'Failed to save settings'
setError(message)
toast.error(message)
} finally {
setSaving(false)
}
}
const handleChange = (field, value) => {
setSettings(prev => ({ ...prev, [field]: value }))
}
const toggleSection = (section) => {
setOpenSections((prev) => ({ ...prev, [section]: !prev[section] }))
}
const handleTestWebhook = async () => {
setTestingWebhook(true)
setError('')
setWebhookNotice('')
try {
const res = await api.post('/api/user/test-webhook')
setWebhookNotice(res.data.message || 'Test webhook sent successfully')
toast.success(res.data.message || 'Test webhook sent successfully')
} catch (err) {
const message = err.response?.data?.error || 'Failed to send test webhook'
setError(message)
toast.error(message)
} finally {
setTestingWebhook(false)
}
}
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-accent" /></div>
// Derived mock OTP based on selected length
const mockOtp = '48291689'.slice(0, Number(settings.otp_length))
const renderedMessage = renderTemplate(settings.message_template, {
greeting: settings.greeting || 'Hello!',
sender_name: settings.sender_name || '[Company Name]',
otp: mockOtp,
expiry_seconds: settings.expiry_seconds,
})
const templateCharsUsed = String(settings.message_template || '').length
return (
<div className="flex flex-col gap-6 max-w-[1400px]">
<div>
<h2 className="text-2xl font-black text-text-primary tracking-tight m-0 mb-1">Message Configuration</h2>
<p className="text-text-secondary text-sm font-semibold m-0">
Design your WhatsApp OTP template and configure delivery webhooks.
</p>
</div>
<div className="grid gap-6 lg:grid-cols-[minmax(0,1fr)_320px] items-start">
{/* Left Column: Form Controls */}
<div className="neu-raised rounded-2xl p-5 md:p-6 flex flex-col gap-6 w-full">
<button
type="button"
onClick={() => toggleSection('environment')}
className="neu-btn w-full px-4 py-3 justify-between"
>
<span className="text-sm font-black text-text-primary">Environment + OTP Settings</span>
<ChevronDown size={16} className={`transition-transform ${openSections.environment ? 'rotate-180' : ''}`} />
</button>
{openSections.environment ? (
<div className="grid gap-5 xl:grid-cols-2">
<div className="flex flex-col gap-4">
<h3 className="text-base font-black text-text-primary border-b border-[#ffffff30] pb-2">Environment Mode</h3>
<div className="grid gap-4 md:grid-cols-2">
{[
{
id: 'sandbox',
title: 'Sandbox',
description: 'Only send OTPs to your registered WhatsApp number while testing.',
},
{
id: 'live',
title: 'Live',
description: 'Send OTPs to any valid WhatsApp number from your integration.',
}
].map((mode) => {
const isActive = settings.environment_mode === mode.id
return (
<button
key={mode.id}
type="button"
onClick={() => handleChange('environment_mode', mode.id)}
className="text-left rounded-2xl p-4 border-none cursor-pointer transition-all"
style={{
background: isActive ? 'rgba(37,211,102,0.1)' : 'rgba(255,255,255,0.03)',
boxShadow: isActive ? 'inset 0 0 0 1px rgba(37,211,102,0.25)' : 'inset 0 0 0 1px rgba(255,255,255,0.05)',
}}
>
<div className="flex items-center justify-between gap-3 mb-2">
<div className={`text-sm font-black ${isActive ? 'text-accent' : 'text-text-primary'}`}>{mode.title}</div>
<div className={`text-[10px] font-black uppercase tracking-[0.18em] ${isActive ? 'text-accent' : 'text-text-muted'}`}>
{isActive ? 'Active' : 'Select'}
</div>
</div>
<div className="text-xs font-semibold text-text-secondary leading-5">
{mode.description}
</div>
</button>
)
})}
</div>
<div className="rounded-2xl p-4" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)' }}>
<div className="flex items-center justify-between gap-2 mb-2">
<div className="text-[11px] font-black uppercase tracking-[0.18em] text-text-muted">Registered WhatsApp Number</div>
<button
type="button"
onClick={() => setShowSandboxHelp(true)}
className="neu-btn w-7 h-7 rounded-full"
title="Sandbox help"
>
<CircleHelp size={12} />
</button>
</div>
<div className="text-sm font-bold text-text-primary">{formatDisplayPhone(registeredPhone)}</div>
<div className="text-xs font-semibold text-text-secondary mt-3 leading-5">
{settings.environment_mode === 'live' ? (
<>Live monthly usage: <span className="text-text-primary font-black">{liveUsedThisMonth}</span> / <span className="text-text-primary font-black">{liveMonthlyLimit}</span></>
) : (
<>Sandbox monthly usage: <span className="text-text-primary font-black">{sandboxUsedThisMonth}</span> / <span className="text-text-primary font-black">{sandboxMonthlyLimit}</span></>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-4">
<h3 className="text-base font-black text-text-primary border-b border-[#ffffff30] pb-2">OTP Settings</h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex-1 flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-wider text-text-secondary px-1">OTP Length</label>
<div className="neu-inset rounded-lg p-1 flex">
{['4','5','6','8'].map(len => (
<button
key={len}
onClick={() => handleChange('otp_length', len)}
className={`flex-1 py-1.5 text-sm font-bold rounded-md border-none cursor-pointer transition-colors ${
settings.otp_length === len ? 'neu-raised text-accent' : 'bg-transparent text-text-muted hover:text-text-primary'
}`}
>
{len}
</button>
))}
</div>
</div>
<div className="flex-1 flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-wider text-text-secondary px-1">Expiry Timer</label>
<div className="neu-inset rounded-lg overflow-hidden flex bg-base">
<select
value={settings.expiry_seconds}
onChange={(e) => handleChange('expiry_seconds', e.target.value)}
className="w-full bg-transparent border-none outline-none text-sm font-bold text-text-primary px-3 py-2 cursor-pointer appearance-none"
>
<option value="30">30 Seconds</option>
<option value="60">60 Seconds</option>
<option value="120">2 Minutes</option>
<option value="300">5 Minutes</option>
</select>
</div>
</div>
</div>
</div>
</div>
) : null}
<button
type="button"
onClick={() => toggleSection('sender')}
className="neu-btn w-full px-4 py-3 justify-between"
>
<span className="text-sm font-black text-text-primary">Sender + Template</span>
<ChevronDown size={16} className={`transition-transform ${openSections.sender ? 'rotate-180' : ''}`} />
</button>
{openSections.sender ? (
<div className="flex flex-col gap-4">
<h3 className="text-base font-black text-text-primary border-b border-[#ffffff30] pb-2">Sender Details</h3>
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-wider text-text-secondary px-1">Display Company Name</label>
<input
type="text"
value={settings.sender_name}
onChange={(e) => handleChange('sender_name', e.target.value)}
className="neu-input"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-wider text-text-secondary px-1">Greeting Template</label>
<input
type="text"
value={settings.greeting}
onChange={(e) => handleChange('greeting', e.target.value)}
placeholder="e.g. Hi there! or Dear User,"
className="neu-input"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3 px-1">
<label className="text-xs font-black uppercase tracking-wider text-text-secondary">Full Message Template</label>
<span className={`text-[11px] font-black ${templateCharsUsed > messageTemplateLimit ? 'text-red-400' : 'text-text-muted'}`}>
{templateCharsUsed} / {messageTemplateLimit}
</span>
</div>
<textarea
value={settings.message_template}
onChange={(e) => handleChange('message_template', e.target.value.slice(0, messageTemplateLimit))}
rows={6}
className="neu-input resize-none leading-6"
placeholder={DEFAULT_TEMPLATE}
/>
<div className="rounded-2xl p-4" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)' }}>
<div className="text-[11px] font-black uppercase tracking-[0.18em] text-text-muted mb-2">Allowed Variables</div>
<div className="flex flex-wrap gap-2">
{['{greeting}', '{sender_name}', '{otp}', '{expiry_seconds}'].map((variable) => (
<button
key={variable}
type="button"
onClick={() => handleChange('message_template', `${settings.message_template || ''}${variable}`.slice(0, messageTemplateLimit))}
className="badge-neutral cursor-pointer border-none"
>
{variable}
</button>
))}
</div>
<p className="text-[11px] text-text-secondary font-bold px-1 mt-3 mb-0 leading-5">
The final WhatsApp message must stay within the max character count defined by `VERTIFLO_MESSAGE_TEMPLATE_MAX_CHARS` in your backend `.env`.
</p>
</div>
</div>
</div>
) : null}
<button
type="button"
onClick={() => toggleSection('webhook')}
className="neu-btn w-full px-4 py-3 justify-between"
>
<span className="text-sm font-black text-text-primary">Delivery Webhooks</span>
<ChevronDown size={16} className={`transition-transform ${openSections.webhook ? 'rotate-180' : ''}`} />
</button>
{openSections.webhook ? (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between border-b border-[#ffffff30] pb-2">
<h3 className="text-base font-black text-text-primary m-0">Delivery Webhooks</h3>
<button
type="button"
onClick={() => handleChange('webhook_url', settings.webhook_url ? '' : 'https://')}
className="w-14 h-7 rounded-full p-1 cursor-pointer flex items-center transition-all border-none"
style={{
background: settings.webhook_url ? 'rgba(37,211,102,0.18)' : 'rgba(255,255,255,0.08)',
boxShadow: settings.webhook_url
? 'inset 0 0 0 1px rgba(37,211,102,0.24)'
: 'inset 0 0 0 1px rgba(255,255,255,0.06)',
}}
>
<div
className="w-5 h-5 rounded-full transition-transform duration-300"
style={{
transform: settings.webhook_url ? 'translateX(28px)' : 'translateX(0)',
background: settings.webhook_url
? 'linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent-dark) 100%)'
: 'var(--color-text-muted)',
boxShadow: settings.webhook_url
? '0 0 12px rgba(37,211,102,0.35)'
: '0 2px 8px rgba(0,0,0,0.3)',
}}
/>
</button>
</div>
{settings.webhook_url !== null && settings.webhook_url !== '' && (
<div className="flex flex-col gap-2 animate-fade-up">
<label className="text-xs font-black uppercase tracking-wider text-text-secondary px-1">Webhook URL</label>
<input
type="url"
value={settings.webhook_url}
onChange={(e) => handleChange('webhook_url', e.target.value)}
placeholder="https://api.yourdomain.com/webhooks/otp"
className="neu-input"
/>
<p className="text-[11px] text-text-muted font-bold px-1 mt-1">
We will POST a JSON payload when delivery succeeds or fails.
</p>
<div className="flex flex-wrap gap-3 pt-2">
<button
type="button"
onClick={handleTestWebhook}
disabled={testingWebhook || !settings.webhook_url}
className="neu-btn px-4 py-2 gap-2"
>
{testingWebhook ? <Loader2 size={14} className="animate-spin" /> : <Eye size={14} />}
Send Test Delivery Summary
</button>
{webhookNotice ? (
<div className="text-xs font-bold text-accent self-center">{webhookNotice}</div>
) : null}
</div>
</div>
)}
</div>
) : null}
{error && <div className="text-red-500 text-xs font-bold text-center">{error}</div>}
<div className="pt-2 flex justify-end">
<button
onClick={handleSave}
disabled={saving}
className={`neu-btn-accent px-8 py-3 gap-2 flex items-center min-w-[180px] justify-center ${saved ? 'bg-green-500/10 text-green-600' : ''}`}
>
{saving ? <Loader2 size={18} className="animate-spin" /> : saved ? <><CheckCircle size={18} /> Saved!</> : <><Save size={18} /> Save Configuration</>}
</button>
</div>
</div>
{/* Right Column: Live WhatsApp Preview */}
<div className="w-full lg:w-[320px] shrink-0 sticky top-4 flex flex-col gap-3">
<div className="flex items-center gap-2 text-text-secondary px-2">
<Eye size={16} />
<h3 className="text-sm font-black m-0 uppercase tracking-widest">Live Preview</h3>
</div>
<div className="neu-raised rounded-3xl p-3 border-[6px] border-[#e0e5ec] shadow-[0_20px_40px_rgba(0,0,0,0.1),inset_0_0_0_1px_#ffffff50]">
{/* Phone Header */}
<div className="bg-[#075e54] rounded-t-2xl px-4 py-3 pb-4 text-white flex items-center gap-3">
<div className="w-10 h-10 bg-white/20 rounded-full flex items-center justify-center">
<Smartphone size={20} />
</div>
<div>
<div className="font-bold text-sm">Veriflo Verified</div>
<div className="text-[10px] text-white/80 font-medium">Official Business Account</div>
</div>
</div>
{/* Chat Body */}
<div className="bg-[#e5ddd5] px-3 py-5 rounded-b-2xl min-h-[260px] flex flex-col relative overflow-hidden">
{/* WA Background Detail */}
<div className="absolute inset-0 opacity-5 pointer-events-none" style={{ backgroundImage: 'radial-gradient(circle at center, black 1px, transparent 1px)', backgroundSize: '10px 10px' }} />
{/* Chat Bubble */}
<div className="bg-white rounded-xl rounded-tl-sm p-3 py-2.5 pb-5 w-[85%] relative shadow-sm border border-black/5 z-10">
<p className="text-sm text-[#303030] leading-relaxed m-0 font-sans whitespace-pre-line">
{renderedMessage}
</p>
{/* Timestamp */}
<div className="absolute bottom-1.5 right-2 flex items-center gap-1">
<span className="text-[10px] text-[#999]">Just now</span>
</div>
</div>
</div>
</div>
</div>
</div>
{showSandboxHelp ? (
<div className="fixed inset-0 z-[80] flex items-center justify-center p-4 bg-black/55">
<div className="w-full max-w-[560px] neu-raised rounded-2xl p-5 md:p-6">
<div className="flex items-start justify-between gap-3 mb-4">
<div>
<div className="text-xs font-black uppercase tracking-[0.18em] text-text-muted">Environment Help</div>
<h3 className="text-lg font-black text-text-primary m-0 mt-1">Registered WhatsApp Number</h3>
</div>
<button
type="button"
onClick={() => setShowSandboxHelp(false)}
className="neu-btn w-8 h-8 rounded-full"
title="Close"
>
<X size={14} />
</button>
</div>
<div className="space-y-3 text-sm text-text-secondary font-semibold leading-6">
<p className="m-0">
<span className="text-text-primary font-black">Registered Number:</span> {formatDisplayPhone(registeredPhone)}
</p>
<p className="m-0">
{settings.environment_mode === 'sandbox'
? 'Sandbox mode is locked to this number only. Any other recipient will be rejected by the backend.'
: 'Live mode removes the sandbox restriction and allows delivery to other valid WhatsApp numbers.'}
</p>
<p className="m-0">
{settings.environment_mode === 'live'
? <>Live monthly usage: <span className="text-text-primary font-black">{liveUsedThisMonth}</span> / <span className="text-text-primary font-black">{liveMonthlyLimit}</span></>
: <>Sandbox monthly usage: <span className="text-text-primary font-black">{sandboxUsedThisMonth}</span> / <span className="text-text-primary font-black">{sandboxMonthlyLimit}</span></>}
</p>
<p className="m-0 text-[12px]">
Mode changes apply only after clicking <span className="text-text-primary font-black">Save Configuration</span>.
</p>
</div>
</div>
</div>
) : null}
</div>
)
}