525 lines
24 KiB
JavaScript
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>
|
|
)
|
|
}
|