Initial GIT Veriflo - Frontend

This commit is contained in:
MOHAN 2026-03-20 12:37:44 +05:30
parent 0685b12b46
commit ceeb11c972
58 changed files with 12725 additions and 123 deletions

25
.env.example Normal file
View File

@ -0,0 +1,25 @@
VITE_API_BASE_URL=
VITE_DEV_PROXY_TARGET=http://localhost:3000
# Marketing site config
VITE_FREE_OTPS=500
VITE_SANDBOX_FREE_OTPS=500
VITE_LIVE_FREE_OTPS=100
VITE_PRICE_PER_OTP=0.20
VITE_BENCHMARK_SMS_PRICE_PER_OTP=0.45
VITE_BENCHMARK_SMS_PLATFORM_FEE=999
VITE_BENCHMARK_MANAGED_PRICE_PER_OTP=0.70
VITE_BENCHMARK_MANAGED_PLATFORM_FEE=2499
VITE_DELIVERY_RATE_PERCENT=98
VITE_OPEN_RATE_PERCENT=98
VITE_BENCHMARK_SMS_OPEN_RATE_PERCENT=20
VITE_EMAIL_OPEN_RATE_PERCENT=22
VITE_AVG_DELIVERY_TIME_SECONDS=2
VITE_OTP_SLIDER_MIN=10
VITE_OTP_SLIDER_MAX=10000
VITE_OTP_SLIDER_DEFAULT=1000
VITE_OTPS_SENT_LABEL=10,000+
VITE_BUSINESSES_LABEL=50+
VITE_RATE_LIMIT_SEND=100 req / min
VITE_RATE_LIMIT_VERIFY=120 req / min
VITE_RATE_LIMIT_RESEND=60 req / min / id

155
.gitignore vendored
View File

@ -1,139 +1,48 @@
# Logs
logs
# dependencies
node_modules/
# build outputs
dist/
build/
coverage/
# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
# environment files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
# caches
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
.parcel-cache/
.nyc_output/
.eslintcache
.stylelintcache
*.tsbuildinfo
# vuepress build output
.vuepress/dist
# editor / OS
.vscode/
.vscode-test/
.idea/
.DS_Store
Thumbs.db
# vuepress v2.x temp and cache directory
.temp
.cache
# misc
*.tmp
*.temp
*.pid
*.seed
*.pid.lock
*.tgz
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite logs files
# vite timestamp files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*

16
index.html Normal file
View File

@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Veriflo — Dashboard</title>
<!-- Nunito Font for Soft Neumorphism typography -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

3479
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "veriflo-dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port 5174",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.6",
"framer-motion": "^12.36.0",
"lucide-react": "^0.469.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.1.1",
"react-syntax-highlighter": "^16.1.1",
"recharts": "^2.15.0",
"tailwindcss": "^4.0.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"vite": "^6.0.0"
}
}

237
src/App.jsx Normal file
View File

@ -0,0 +1,237 @@
import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation } from 'react-router-dom'
import { useEffect, lazy, Suspense } from 'react'
import { clearAuthSession, getAuthSession, getAuthUser } from './utils/authSession'
import { ToastProvider } from './components/ToastProvider'
const PAGE_TITLES = {
'/': 'Veriflo — WhatsApp OTP API for Developers',
'/pricing': 'Pricing — Veriflo',
'/docs': 'Documentation — Veriflo',
'/about': 'About — Veriflo',
'/contact': 'Contact — Veriflo',
'/privacy': 'Privacy Policy — Veriflo',
'/terms': 'Terms of Service — Veriflo',
'/login': 'Sign In — Veriflo',
'/signup': 'Create Account — Veriflo',
'/forgot-password': 'Reset Password — Veriflo',
'/overview': 'Overview — Veriflo Dashboard',
'/keys': 'API Keys — Veriflo Dashboard',
'/config': 'Message Config — Veriflo Dashboard',
'/analytics': 'Analytics — Veriflo Dashboard',
'/billing': 'Billing — Veriflo Dashboard',
'/integrate': 'Integration — Veriflo Dashboard',
'/settings': 'Settings — Veriflo Dashboard',
'/admin/dashboard': 'Admin Dashboard — Veriflo',
'/admin/users': 'User Management — Veriflo Admin',
'/admin/logs': 'OTP Logs — Veriflo Admin',
'/admin/whatsapp': 'WhatsApp Status — Veriflo Admin',
'/admin/settings': 'Admin Settings — Veriflo Admin',
'/admin/payments': 'Payment Approvals — Veriflo Admin',
'/admin/payment-config': 'Payment Methods — Veriflo Admin',
}
const LOGGED_OUT_TITLE_SLOGANS = [
'WhatsApp OTP in Minutes',
'Secure Login. Better Conversion.',
'Developer-First OTP APIs',
'Fast OTP Delivery on WhatsApp',
'Ship Authentication Faster',
'Reliable OTP for Modern Apps',
'Scale Verification with Confidence',
'Simple API. Powerful OTP Flow.',
'Built for Product Teams',
'From Sandbox to Production Fast',
]
const MARKETING_PATHS = new Set([
'/',
'/pricing',
'/docs',
'/about',
'/contact',
'/privacy',
'/terms',
])
function getRandomSlogan(lastSlogan = '') {
if (LOGGED_OUT_TITLE_SLOGANS.length <= 1) return LOGGED_OUT_TITLE_SLOGANS[0] || ''
let next = lastSlogan
while (next === lastSlogan) {
next = LOGGED_OUT_TITLE_SLOGANS[Math.floor(Math.random() * LOGGED_OUT_TITLE_SLOGANS.length)]
}
return next
}
function TitleUpdater() {
const location = useLocation()
useEffect(() => {
if (MARKETING_PATHS.has(location.pathname)) {
let activeSlogan = getRandomSlogan()
document.title = activeSlogan ? `Veriflo | ${activeSlogan}` : 'Veriflo'
const rotateInterval = window.setInterval(() => {
activeSlogan = getRandomSlogan(activeSlogan)
document.title = activeSlogan ? `Veriflo | ${activeSlogan}` : 'Veriflo'
}, 4700)
return () => window.clearInterval(rotateInterval)
}
const session = getAuthSession()
if (session) {
const title = PAGE_TITLES[location.pathname] || 'Veriflo'
document.title = title
return undefined
}
document.title = PAGE_TITLES[location.pathname] || 'Veriflo'
return undefined
}, [location.pathname])
return null
}
// Layouts kept eager (small, always needed)
import DashboardLayout from './layouts/DashboardLayout'
import AuthLayout from './layouts/AuthLayout'
import AdminLayout from './layouts/AdminLayout'
// Small shell components kept eager
import Navbar from './components/Navbar'
import CursorGlow from './components/CursorGlow'
import FloatingScrollTop from './components/FloatingScrollTop'
import ScrollToTop from './components/ScrollToTop'
// Dashboard pages lazy (contain recharts, defers ~200KB on first visit)
const Overview = lazy(() => import('./pages/Overview'))
const APIKeys = lazy(() => import('./pages/APIKeys'))
const Settings = lazy(() => import('./pages/Settings'))
const MessageConfig = lazy(() => import('./pages/MessageConfig'))
const Analytics = lazy(() => import('./pages/Analytics'))
const Billing = lazy(() => import('./pages/Billing'))
const Integrate = lazy(() => import('./pages/Integrate'))
// Auth pages lazy
const Login = lazy(() => import('./pages/auth/Login'))
const Signup = lazy(() => import('./pages/auth/Signup'))
const ForgotPassword = lazy(() => import('./pages/auth/ForgotPassword'))
// Admin pages lazy (admin dashboard also uses recharts)
const AdminDashboard = lazy(() => import('./pages/admin/AdminDashboard'))
const UserManagement = lazy(() => import('./pages/admin/UserManagement'))
const OTPLogs = lazy(() => import('./pages/admin/OTPLogs'))
const WhatsAppStatus = lazy(() => import('./pages/admin/WhatsAppStatus'))
const AdminSettings = lazy(() => import('./pages/admin/AdminSettings'))
const PaymentApprovals = lazy(() => import('./pages/admin/PaymentApprovals'))
const PaymentConfig = lazy(() => import('./pages/admin/PaymentConfig'))
// Marketing pages lazy (Home eagerly triggers syntax-highlighter via Hero; defer it)
const Home = lazy(() => import('./pages/Home'))
const Docs = lazy(() => import('./pages/Docs'))
const PricingPage = lazy(() => import('./pages/PricingPage'))
const About = lazy(() => import('./pages/About'))
const Contact = lazy(() => import('./pages/Contact'))
const PrivacyPolicy = lazy(() => import('./pages/PrivacyPolicy'))
const TermsOfService = lazy(() => import('./pages/TermsOfService'))
function PageLoader() {
return (
<div className="flex items-center justify-center min-h-[40vh]">
<div className="w-8 h-8 rounded-full border-2 border-accent border-t-transparent animate-spin" />
</div>
)
}
const PrivateRoute = ({ requireAdmin }) => {
const location = useLocation();
const session = getAuthSession();
if (!session) {
clearAuthSession();
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
}
const user = getAuthUser()
if (requireAdmin && user.role !== 'admin') return <Navigate to="/overview" replace />;
return <Outlet />;
};
const PublicOnlyRoute = () => {
if (getAuthSession()) {
return <Navigate to="/overview" replace />
}
return <Outlet />
}
function MarketingLayout() {
const location = useLocation()
return (
<div className="min-h-screen relative text-text-primary">
<CursorGlow />
<FloatingScrollTop />
<Navbar />
<div key={location.pathname} className="page-fade-in">
<Outlet />
</div>
</div>
)
}
export default function App() {
return (
<ToastProvider>
<BrowserRouter>
<TitleUpdater />
<ScrollToTop />
<Suspense fallback={<PageLoader />}>
<Routes>
{/* Marketing pages */}
<Route element={<MarketingLayout />}>
<Route path="/" element={<Home />} />
<Route path="/pricing" element={<PricingPage />} />
<Route path="/docs" element={<Docs />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="/privacy" element={<PrivacyPolicy />} />
<Route path="/terms" element={<TermsOfService />} />
</Route>
{/* Auth */}
<Route element={<PublicOnlyRoute />}>
<Route element={<AuthLayout />}>
<Route path="/login" element={<Login />} />
<Route path="/signup" element={<Signup />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
</Route>
</Route>
{/* Dashboard */}
<Route element={<PrivateRoute requireAdmin={false} />}>
<Route element={<DashboardLayout />}>
<Route path="/overview" element={<Overview />} />
<Route path="/keys" element={<APIKeys />} />
<Route path="/config" element={<MessageConfig />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/billing" element={<Billing />} />
<Route path="/integrate" element={<Integrate />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Route>
{/* Admin */}
<Route element={<PrivateRoute requireAdmin={true} />}>
<Route element={<AdminLayout />}>
<Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} />
<Route path="/admin/dashboard" element={<AdminDashboard />} />
<Route path="/admin/users" element={<UserManagement />} />
<Route path="/admin/logs" element={<OTPLogs />} />
<Route path="/admin/whatsapp" element={<WhatsAppStatus />} />
<Route path="/admin/payments" element={<PaymentApprovals />} />
<Route path="/admin/payment-config" element={<PaymentConfig />} />
<Route path="/admin/settings" element={<AdminSettings />} />
</Route>
</Route>
</Routes>
</Suspense>
</BrowserRouter>
</ToastProvider>
)
}

View File

@ -0,0 +1,55 @@
import { useState, lazy, Suspense } from 'react'
import { Copy, Check } from 'lucide-react'
// react-syntax-highlighter is ~300KB load it only when a CodeBlock is rendered
const CodeHighlighter = lazy(() => import('./CodeHighlighter'))
function CodeFallback({ code }) {
return (
<pre
style={{
margin: 0,
borderRadius: '24px',
padding: '1.5rem',
fontSize: '0.85rem',
lineHeight: '1.7',
background: '#050505',
color: '#d4d4d4',
overflow: 'auto',
}}
>
<code>{code}</code>
</pre>
)
}
export function CodeBlock({ code, language = 'javascript' }) {
const [copied, setCopied] = useState(false)
const handleCopy = () => {
navigator.clipboard.writeText(code).then(() => {
setCopied(true)
setTimeout(() => setCopied(false), 2000)
})
}
return (
<div className="syntax-wrapper relative group">
<button
onClick={handleCopy}
className={`absolute top-3 right-3 z-10 rounded-lg px-2.5 py-1.5 cursor-pointer flex items-center gap-1.5 text-xs font-bold transition-all duration-200 border ${
copied
? 'bg-accent/20 border-accent/30 text-accent'
: 'bg-white/5 border-white/10 text-text-secondary hover:bg-white/10 hover:text-white opacity-0 group-hover:opacity-100'
}`}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
{copied ? 'Copied!' : 'Copy'}
</button>
<Suspense fallback={<CodeFallback code={code} />}>
<CodeHighlighter code={code} language={language} />
</Suspense>
</div>
)
}

View File

@ -0,0 +1,110 @@
import { useState } from 'react'
import { CodeBlock } from './CodeBlock'
const tabs = ['Node.js', 'Python', 'cURL']
const codes = {
'Node.js': {
language: 'javascript',
code: `const Veriflo = require('veriflo');
const client = Veriflo.init('your-api-key');
// Send OTP to any WhatsApp number
const { requestId } = await client.sendOTP('+919999999999', {
length: 6, // OTP digit length
expiry: 60, // seconds
});
// Verify OTP entered by user
const isValid = await client.verifyOTP(requestId, '482916');
console.log(isValid ? '✅ Verified!' : '❌ Invalid OTP');
// Resend if needed
await client.resendOTP(requestId);`,
},
'Python': {
language: 'python',
code: `from veriflo import Veriflo
client = Veriflo(api_key="your-api-key")
# Send OTP to any WhatsApp number
result = client.send_otp("+919999999999", length=6, expiry=60)
# Verify OTP entered by user
is_valid = client.verify_otp(result.request_id, "482916")
print("✅ Verified!" if is_valid else "❌ Invalid OTP")
# Resend if needed
client.resend_otp(result.request_id)`,
},
'cURL': {
language: 'bash',
code: `# Send OTP
curl -X POST https://api.veriflo.app/v1/otp/send \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"phone": "+919999999999", "otpLength": 6}'
# Response: { "success": true, "requestId": "req_abc123" }
# Verify OTP
curl -X POST https://api.veriflo.app/v1/otp/verify \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"requestId": "req_abc123", "otp": "482916"}'
# Response: { "success": true, "valid": true }`,
},
}
export default function CodeExamples() {
const [activeTab, setActiveTab] = useState('Node.js')
return (
<section className="section-pad relative z-10 border-t border-white/5">
<div className="max-w-[1180px] mx-auto px-4 sm:px-6 lg:px-8 animate-reveal-up">
<div className="text-center mb-12">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-white/10 bg-white/5 mb-6">
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
<span className="text-[0.8rem] font-bold text-text-primary tracking-widest uppercase">Code Examples</span>
</div>
<h2 className="text-[clamp(2rem,4vw,3rem)] font-black text-white tracking-tight leading-[1.1]">
Pick your language, <span className="text-gradient">start shipping</span>
</h2>
</div>
<div className="sm:hidden rounded-2xl border border-white/10 bg-white/5 p-5 text-left">
<div className="text-[0.82rem] font-black uppercase tracking-[0.16em] text-accent mb-3">Quick integration</div>
<div className="text-[0.95rem] text-text-secondary font-medium leading-relaxed mb-3">
Use Node.js, Python, or cURL. Send OTP, verify OTP, and resend with one API key.
</div>
<div className="text-[0.86rem] text-text-muted font-semibold">Full code examples are available in Docs.</div>
</div>
{/* Tab bar */}
<div className="hidden sm:flex justify-center mb-8 overflow-x-auto">
<div className="inline-flex bg-glass border border-glass-border rounded-full p-1 shadow-2xl backdrop-blur-md min-w-max">
{tabs.map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-6 py-2.5 rounded-full font-bold text-[0.9rem] transition-all duration-300 ${
activeTab === tab
? 'bg-surface border border-white/10 text-white shadow-md'
: 'text-text-secondary hover:text-white hover:bg-white/5 border border-transparent'
}`}
>
{tab}
</button>
))}
</div>
</div>
<div className="hidden sm:block transform translate-y-0 transition-all duration-500">
<CodeBlock code={codes[activeTab].code} language={codes[activeTab].language} />
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,22 @@
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'
export default function CodeHighlighter({ code, language }) {
return (
<SyntaxHighlighter
language={language}
style={vscDarkPlus}
customStyle={{
margin: 0,
borderRadius: '24px',
padding: '1.5rem',
fontSize: '0.85rem',
lineHeight: '1.7',
background: '#050505',
}}
showLineNumbers={false}
>
{code}
</SyntaxHighlighter>
)
}

View File

@ -0,0 +1,38 @@
import { useEffect, useState } from 'react'
export default function CursorGlow() {
const [position, setPosition] = useState({ x: 0, y: 0 })
const [isClient, setIsClient] = useState(false)
useEffect(() => {
setIsClient(true)
const handleMouseMove = (e) => {
setPosition({ x: e.clientX, y: e.clientY })
}
window.addEventListener('mousemove', handleMouseMove)
return () => window.removeEventListener('mousemove', handleMouseMove)
}, [])
if (!isClient) return null
return (
<div
className="pointer-events-none fixed inset-0 z-[6] overflow-hidden"
aria-hidden="true"
>
<div
className="absolute w-[620px] h-[620px] rounded-full mix-blend-screen opacity-25 blur-[120px] bg-accent/35 transition-transform duration-75 ease-out"
style={{
transform: `translate3d(${position.x - 310}px, ${position.y - 310}px, 0)`,
}}
/>
<div
className="absolute w-[320px] h-[320px] rounded-full mix-blend-screen opacity-18 blur-[80px] bg-[#4ade80]/45 transition-transform duration-100 ease-out"
style={{
transform: `translate3d(${position.x - 160}px, ${position.y - 160}px, 0)`,
}}
/>
</div>
)
}

114
src/components/FAQ.jsx Normal file
View File

@ -0,0 +1,114 @@
import { useState } from 'react'
import { ChevronDown } from 'lucide-react'
import { marketingConfig } from '../config/marketingConfig'
const faqs = [
{
q: 'How does Veriflo work?',
a: 'We send OTP messages via WhatsApp to the phone number you provide through our API. Your users receive a WhatsApp message with a one-time code to verify their identity.',
},
{
q: 'Is there a free trial?',
a: `Yes! Every new account gets ${marketingConfig.sandboxFreeOtps} sandbox OTPs + ${marketingConfig.liveFreeOtps} live OTPs — no credit card required. You can test the full integration before committing to any plan.`,
},
{
q: 'What if WhatsApp delivery fails?',
a: `WhatsApp delivery succeeds ${marketingConfig.deliveryRatePercent}% of the time. SMS fallback is on our roadmap. Current retry mechanisms ensure maximum delivery. Failed OTPs are not charged.`,
},
{
q: 'Can I customize the OTP message?',
a: 'Yes! Set your company name and custom greeting in the dashboard. Eg: "Hi there! Your FinApp verification code is: 482916. Expires in 60s."',
},
{
q: 'Which programming languages are supported?',
a: 'We have official SDKs for Node.js (npm) and Python (pip). Plus a raw REST API that works with any language — PHP, Go, Ruby, Java, anything.',
},
{
q: 'How is my API key secured?',
a: 'API keys are hashed and never logged in plaintext. All communication is over HTTPS. You can regenerate your key at any time from the dashboard.',
},
{
q: 'What phone number formats are accepted?',
a: 'We accept E.164 format (+91XXXXXXXXXX). The number must have WhatsApp installed. We support all countries where WhatsApp operates.',
},
{
q: 'Can users request OTP resend?',
a: 'Yes. We support OTP resend via API using the original request_id. When a resend happens, the previous OTP is superseded automatically for better security.',
},
{
q: 'What are the API rate limits?',
a: 'Default limits are optimized for production usage and anti-abuse safety. Send, verify, and resend each have dedicated limits, and account-level protections are applied to prevent spam or brute-force attempts.',
},
{
q: 'What is sandbox mode vs live mode?',
a: 'Sandbox mode is for safe testing and generally restricts delivery to your registered WhatsApp number. Live mode is for production traffic where you can send OTPs to external user numbers.',
},
{
q: 'Do you support delivery webhooks?',
a: 'Yes. You can configure webhook callbacks for delivery events, so your app gets real-time updates like sent/delivery status and can trigger custom workflows.',
},
{
q: 'How am I billed?',
a: `You start with ${marketingConfig.sandboxFreeOtps} sandbox OTPs + ${marketingConfig.liveFreeOtps} live OTPs. After that, pricing is pay-as-you-go at ${marketingConfig.pricePerOtp} INR per OTP. No monthly lock-in.`,
},
]
export default function FAQ() {
const [openIndex, setOpenIndex] = useState(null)
return (
<section className="section-pad relative z-10 border-t border-white/5">
<div className="max-w-[980px] mx-auto px-4 sm:px-6 lg:px-8 animate-reveal-up">
<div className="text-center mb-16">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-white/10 bg-white/5 mb-6">
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
<span className="text-[0.8rem] font-bold text-text-primary tracking-widest uppercase">FAQ</span>
</div>
<h2 className="text-[clamp(2rem,4vw,3rem)] font-black text-white tracking-tight leading-[1.1]">
Frequently asked <span className="text-gradient">questions</span>
</h2>
</div>
<div className="flex flex-col">
{faqs.map(({ q, a }, i) => {
const isOpen = openIndex === i
return (
<div
key={i}
className="border-b border-white/10 last:border-none"
>
<button
onClick={() => setOpenIndex(isOpen ? null : i)}
className="w-full bg-transparent border-none py-6 flex items-center justify-between cursor-pointer text-left gap-6 group hover:text-white"
>
<span className={`font-bold text-[0.98rem] sm:text-[1.05rem] flex-1 font-sans transition-colors duration-200 ${isOpen ? 'text-white' : 'text-text-primary'}`}>
{q}
</span>
<div
className={`w-8 h-8 rounded-full border border-white/10 flex items-center justify-center shrink-0 transition-all duration-300 ${
isOpen
? 'rotate-180 bg-white/5 text-white'
: 'rotate-0 text-text-muted group-hover:bg-white/5 group-hover:text-white'
}`}
>
<ChevronDown size={18} />
</div>
</button>
<div
className={`overflow-hidden transition-all duration-300 ${
isOpen ? 'max-h-60 opacity-100 pb-6' : 'max-h-0 opacity-0 pb-0'
}`}
>
<p className="text-text-secondary leading-relaxed text-[0.98rem] font-medium m-0 pl-1 border-l-2 border-accent/30 py-1 ml-1">
{a}
</p>
</div>
</div>
)
})}
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,94 @@
import {
MessageCircle, Code2, Tag, Timer, BarChart2, Gift,
Boxes, Globe, Shield, Zap, Lock, Layout, BarChart3, Users, Webhook, Settings
} from 'lucide-react'
import { marketingConfig, formatSecondsText } from '../config/marketingConfig'
const features = [
// Core API Features
{ icon: MessageCircle, title: 'WhatsApp Delivery', desc: `${marketingConfig.openRatePercent}% open rate. Users actually see your OTP in their inboxes.` },
{ icon: Code2, title: '3-Line Integration', desc: 'Import, init, send. Seriously, that\'s all the code you need for production.' },
{ icon: Tag, title: 'Custom Branding', desc: 'Add your sender name & custom greeting. OTPs feel like they\'re directly from you.' },
{ icon: Timer, title: 'Configurable Expiry', desc: 'Set OTP lifetime: 30 seconds to 10+ minutes—your users, your rules.' },
// Analytics & Dashboard Features
{ icon: BarChart2, title: 'Delivery Analytics', desc: 'Real-time dashboard with sent, delivered, failed OTPs + delivery latency metrics.' },
{ icon: BarChart3, title: 'Usage Breakdown', desc: 'Per-API-key analytics, daily/weekly/monthly usage trends, cost calculator built-in.' },
// SDKs & Integration
{ icon: Boxes, title: 'Multi-Language SDKs', desc: 'Official Node.js & Python SDKs on npm/PyPI. REST API for any language.' },
{ icon: Globe, title: 'REST API + Webhooks', desc: 'Raw HTTP API for any platform. Event webhooks for otp.delivered & otp.failed.' },
// Billing & Trial
{ icon: Gift, title: 'Free Trial', desc: `${marketingConfig.sandboxFreeOtps} sandbox OTPs + ${marketingConfig.liveFreeOtps} live OTPs on signup. No credit card. No commitments. Pay-as-you-go.` },
// Security
{ icon: Shield, title: 'Secure by Default', desc: 'SHA256 API key hashing, bcrypt password hashing, CORS + Helmet.js headers, no plaintext logging.' },
{ icon: Lock, title: 'Role-Based Access', desc: 'Role-based auth (user/admin), JWT tokens with 7-day expiry, API key rotation support.' },
// Performance
{ icon: Zap, title: `<${marketingConfig.avgDeliveryTimeSeconds}s Delivery`, desc: `Average WhatsApp delivery under ${formatSecondsText(marketingConfig.avgDeliveryTimeSeconds)} globally—lightning fast.` },
// Admin Features
{ icon: Users, title: 'Admin Dashboard', desc: 'Platform-wide user management, global OTP logs, WhatsApp engine monitoring + memory tracking.' },
// Developer Experience
{ icon: Layout, title: 'Message Templates', desc: 'Dynamic variables ({otp}, {sender_name}, {greeting}). Customize up to 320 characters per message.' },
// Integration & Automation
{ icon: Webhook, title: 'Webhook Integration', desc: 'Async event webhooks for delivery status. Build real-time workflows with 3rd-party tools.' },
// Settings & Configuration
{ icon: Settings, title: 'Sandbox Mode', desc: 'Free tier with monthly limits. Test with your registered number. Upgrade to live anytime.' },
]
function shortDescription(text) {
const firstSentence = String(text).split('. ')[0]
return firstSentence.endsWith('.') ? firstSentence : `${firstSentence}.`
}
export default function Features() {
return (
<section className="section-pad relative z-10">
<div className="fx-splash-orb w-[280px] h-[280px] bg-accent/30 -left-24 top-10" />
<div className="max-w-[1360px] 2xl:max-w-[1480px] mx-auto px-4 sm:px-6 lg:px-8 animate-reveal-up">
<div className="text-center mb-12 sm:mb-20">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-white/10 bg-white/5 mb-6">
<span className="w-1.5 h-1.5 rounded-full bg-[#4ade80]" />
<span className="text-[0.8rem] font-bold text-text-primary tracking-widest uppercase">Features</span>
</div>
<h2 className="text-[clamp(2rem,4vw,3rem)] font-black text-white tracking-tight leading-[1.1] mb-5">
Everything you need to <span className="text-gradient">ship faster</span>
</h2>
<p className="text-text-secondary text-[0.98rem] sm:text-[1.1rem] font-medium leading-relaxed max-w-[600px] mx-auto">
Built for developers who care about reliability, security, and simplicity.
</p>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(250px,1fr))] xl:grid-cols-4 gap-4 sm:gap-6">
{features.map(({ icon: Icon, title, desc }, i) => (
<div
key={title}
className={`glass-panel fx-card fx-hover-tilt p-6 sm:p-8 group ${i > 5 ? 'max-md:hidden' : ''}`}
>
<div className="w-12 h-12 rounded-[14px] bg-white/5 border border-white/10 flex items-center justify-center mb-6 group-hover:bg-accent/10 group-hover:border-accent/30 transition-colors duration-300">
<Icon size={24} color={i % 2 === 0 ? 'var(--color-accent)' : '#4ade80'} className="group-hover:scale-110 transition-transform duration-300" />
</div>
<h3 className="text-[1.1rem] font-bold text-white mb-2.5">
{title}
</h3>
<p className="text-[0.9rem] sm:text-[0.92rem] text-text-secondary leading-relaxed font-medium m-0 transition-colors group-hover:text-text-primary">
<span className="sm:hidden">{shortDescription(desc)}</span>
<span className="hidden sm:inline">{desc}</span>
</p>
</div>
))}
</div>
<div className="sm:hidden mt-5 text-center text-[0.82rem] text-text-muted font-semibold">
More advanced features are available in Docs and Dashboard.
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,32 @@
import { useState, useEffect } from 'react'
import { ArrowUp } from 'lucide-react'
export default function FloatingScrollTop() {
const [isVisible, setIsVisible] = useState(false)
const toggleVisibility = () => {
setIsVisible(window.scrollY > 300)
}
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: 'smooth' })
}
useEffect(() => {
window.addEventListener('scroll', toggleVisibility)
return () => window.removeEventListener('scroll', toggleVisibility)
}, [])
return (
<button
onClick={scrollToTop}
className={`fixed bottom-8 right-8 w-12 h-12 rounded-full bg-accent text-black flex items-center justify-center shadow-lg hover:shadow-xl hover:scale-110 transition-all duration-300 z-40 ${
isVisible ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
}`}
aria-label="Scroll to top"
title="Scroll to top"
>
<ArrowUp size={20} strokeWidth={3} />
</button>
)
}

95
src/components/Footer.jsx Normal file
View File

@ -0,0 +1,95 @@
import { Link } from 'react-router-dom'
import { MessageCircle, Github, Twitter, Linkedin } from 'lucide-react'
const footerLinks = {
Product: [
{ label: 'Home', to: '/' },
{ label: 'Pricing', to: '/pricing' },
{ label: 'Docs', to: '/docs' },
],
Developers: [
{ label: 'API Reference', to: '/docs' },
{ label: 'Node.js SDK', to: '/docs?section=sdks&sdk=node' },
{ label: 'Python SDK', to: '/docs?section=sdks&sdk=python' },
],
Company: [
{ label: 'About', to: '/about' },
{ label: 'Contact', to: '/contact' },
],
}
export default function Footer() {
return (
<footer className="pt-16 sm:pt-20 lg:pt-24 pb-12 px-4 sm:px-6 lg:px-8 border-t border-white/5 bg-base mt-12 relative overflow-hidden animate-reveal-up">
{/* Subtle bottom glow */}
<div className="absolute bottom-0 left-1/2 -translate-x-1/2 w-full max-w-[800px] h-[300px] bg-accent/5 blur-[120px] rounded-[100%] pointer-events-none" />
<div className="max-w-[1360px] 2xl:max-w-[1480px] mx-auto relative z-10">
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-12 mb-16">
{/* Brand */}
<div className="col-span-1 md:col-span-2 lg:col-span-1">
<Link to="/" className="inline-flex items-center gap-2.5 no-underline mb-5 group hover:no-underline">
<div className="w-10 h-10 rounded-xl bg-accent/10 border border-accent/20 flex items-center justify-center group-hover:bg-accent/20 transition-colors duration-300">
<MessageCircle size={20} className="text-accent" />
</div>
<span className="font-bold text-xl tracking-tight text-white group-hover:text-white transition-colors duration-300">
Veriflo
</span>
</Link>
<p className="text-[0.9rem] text-text-secondary font-medium leading-relaxed max-w-[240px] m-0 mb-6">
The simplest WhatsApp OTP API. Built for developers who move fast.
</p>
<div className="flex gap-3">
{[Github, Twitter, Linkedin].map((Icon, i) => (
<a
key={i}
href="#"
className="w-10 h-10 rounded-lg border border-white/10 bg-white/5 flex items-center justify-center text-text-secondary hover:text-white hover:bg-white/10 hover:border-white/20 transition-all duration-300 cursor-pointer"
>
<Icon size={18} />
</a>
))}
</div>
</div>
{/* Links */}
{Object.entries(footerLinks).map(([section, links]) => (
<div key={section}>
<h4 className="text-[0.85rem] font-bold text-white tracking-wide mb-5">
{section}
</h4>
<div className="flex flex-col gap-3.5">
{links.map(({ label, to }) => (
<Link
key={label}
to={to}
className="text-[0.9rem] text-text-secondary no-underline font-medium transition-colors duration-300 hover:text-white cursor-pointer"
>
{label}
</Link>
))}
</div>
</div>
))}
</div>
{/* Bottom */}
<div className="pt-8 border-t border-white/5 flex justify-between items-center flex-wrap gap-4">
<p className="text-[0.85rem] text-text-muted font-medium m-0">
© 2026 Veriflo. Built with by MetatronCube Software Solutions
</p>
<div className="flex gap-6">
{[
{ label: 'Privacy Policy', to: '/privacy' },
{ label: 'Terms of Service', to: '/terms' }
].map(({ label, to }) => (
<Link key={label} to={to} className="text-[0.85rem] text-text-muted no-underline font-medium transition-colors duration-300 hover:text-white cursor-pointer">
{label}
</Link>
))}
</div>
</div>
</div>
</footer>
)
}

238
src/components/Hero.jsx Normal file
View File

@ -0,0 +1,238 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Zap, ArrowRight, MessageCircle } from 'lucide-react'
import { CodeBlock } from './CodeBlock'
import { marketingConfig } from '../config/marketingConfig'
const heroCode = `import Veriflo from 'veriflo';
const client = Veriflo.init('YOUR_API_KEY');
await client.sendOTP('+919999999999');`
const OTP_SEQUENCES = ['4821', '738291', '92847165', '5173', '294817', '83726105', '6291', '582047', '17495820']
const HERO_HEADLINES = [
'in One Line of Code',
'with WhatsApp-First Reliability',
'and Cut SMS OTP Costs Fast',
'at Launch Speed for Any Product',
'with Production-Ready APIs',
'for Better Login Conversion',
'without OTP Delivery Headaches',
'with Real-Time Usage Visibility',
'for Secure Flows at Scale',
'in Minutes, Not Days',
]
const HERO_SLOGANS = [
'Verify users in seconds, not minutes.',
'Less OTP drop-off. More successful logins.',
'Built for launch speed and production scale.',
'WhatsApp-first authentication your users trust.',
'Cut SMS spend without cutting reliability.',
'From sandbox tests to live traffic fast.',
'Simple API. Serious delivery performance.',
'Ship secure OTP flows without backend pain.',
'Higher conversion starts with faster OTP.',
'Developer-friendly auth that feels effortless.',
]
function getRandomSloganIndex(current, total) {
if (total <= 1) return 0
let next = current
while (next === current) {
next = Math.floor(Math.random() * total)
}
return next
}
export default function Hero() {
const [bubbleVisible, setBubbleVisible] = useState(false)
const [typed, setTyped] = useState('')
const [typingDone, setTypingDone] = useState(false)
const [otpIdx, setOtpIdx] = useState(0)
const [headlineIdx, setHeadlineIdx] = useState(() => Math.floor(Math.random() * HERO_HEADLINES.length))
const [headlineVisible, setHeadlineVisible] = useState(true)
const [sloganIdx, setSloganIdx] = useState(() => Math.floor(Math.random() * HERO_SLOGANS.length))
const [sloganVisible, setSloganVisible] = useState(true)
const fullText = "Your Veriflo verification code is: "
const freeTrialLabel = `Free ${marketingConfig.trialCreditsLabel} OTPs`
useEffect(() => {
const timer = setTimeout(() => {
setBubbleVisible(true)
let i = 0
const interval = setInterval(() => {
setTyped(fullText.slice(0, i + 1))
i++
if (i >= fullText.length) { clearInterval(interval); setTypingDone(true) }
}, 44)
}, 900)
return () => clearTimeout(timer)
}, [])
useEffect(() => {
if (!typingDone) return
const cycle = setInterval(() => {
setOtpIdx(prev => (prev + 1) % OTP_SEQUENCES.length)
}, 3100)
return () => clearInterval(cycle)
}, [typingDone])
useEffect(() => {
const cycle = setInterval(() => {
setSloganVisible(false)
setTimeout(() => {
setSloganIdx((prev) => getRandomSloganIndex(prev, HERO_SLOGANS.length))
setSloganVisible(true)
}, 260)
}, 4300)
return () => clearInterval(cycle)
}, [])
useEffect(() => {
const cycle = setInterval(() => {
setHeadlineVisible(false)
setTimeout(() => {
setHeadlineIdx((prev) => getRandomSloganIndex(prev, HERO_HEADLINES.length))
setHeadlineVisible(true)
}, 420)
}, 3500)
return () => clearInterval(cycle)
}, [])
return (
<section className="min-h-[calc(100vh-72px)] flex items-center px-4 sm:px-6 lg:px-8 pt-28 pb-16 md:pt-32 md:pb-24 max-w-[1360px] 2xl:max-w-[1480px] mx-auto gap-10 lg:gap-16 flex-wrap relative z-10 animate-reveal-up">
{/* Background glow behind text */}
<div className="absolute top-1/2 left-0 -translate-y-1/2 w-[300px] h-[300px] sm:w-[440px] sm:h-[440px] bg-accent/10 blur-[120px] sm:blur-[150px] rounded-full pointer-events-none animate-drift" />
{/* Left — Text */}
<div className="flex-1 basis-[520px] min-w-0 relative z-10">
{/* Badge */}
<div className="glass-panel inline-flex items-center gap-2.5 px-4 py-1.5 rounded-full mb-8 border-accent/20 bg-accent/5">
<span className="w-2 h-2 rounded-full bg-accent animate-pulse-glow block shadow-[0_0_10px_#25D366]" />
<span className="text-[0.82rem] font-bold text-accent tracking-wide">WhatsApp OTP API Now Live</span>
</div>
<h1 className="text-[clamp(2.1rem,7vw,4rem)] font-black leading-[1.1] text-white mb-6 tracking-tight">
<span className="block">Send OTP via WhatsApp</span>
<span className="relative block min-h-[1.25em] mt-1">
<span
className={`inline-block text-gradient drop-shadow-[0_0_24px_rgba(37,211,102,0.2)] transition-all duration-900 ease-in-out ${headlineVisible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 -translate-y-2 scale-[0.985]'}`}
>
{HERO_HEADLINES[headlineIdx]}
</span>
</span>
</h1>
<div
className={`mb-6 inline-flex max-w-[560px] rounded-full border border-accent/30 bg-accent/10 px-4 py-2 text-[0.86rem] sm:text-[0.9rem] font-bold text-accent transition-all duration-500 ease-out ${sloganVisible ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-1'}`}
>
{HERO_SLOGANS[sloganIdx]}
</div>
<p className="text-base md:text-[1.15rem] text-text-secondary leading-relaxed mb-10 max-w-[560px] font-medium">
The simplest OTP API for developers. No SMS costs, {marketingConfig.deliveryRatePercent}% delivery rate.
Integrate in minutes with our Node.js &amp; Python SDKs.
</p>
{/* CTAs */}
<div className="flex gap-3 sm:gap-4 flex-wrap mb-10">
<Link
to="/signup"
className="btn-primary text-base flex items-center gap-2"
>
<Zap size={18} />
Get Started Free
</Link>
<Link
to="/docs"
className="btn-secondary text-base flex items-center gap-2"
>
View Docs <ArrowRight size={16} />
</Link>
</div>
{/* Trust badges */}
<div className="flex gap-4 sm:gap-6 flex-wrap">
{['No credit card needed', freeTrialLabel, '2-min setup'].map((t, index) => (
<span
key={t}
className={`text-[0.82rem] sm:text-[0.85rem] text-text-muted font-semibold flex items-center gap-2 ${index > 1 ? 'max-sm:hidden' : ''}`}
>
<span className="text-accent font-black"></span> {t}
</span>
))}
</div>
</div>
{/* Right — Visual */}
<div className="flex-1 basis-[460px] min-w-0 flex flex-col gap-6 items-center relative z-10">
{/* Code card */}
<div className="animate-float-slow w-full max-w-[560px]">
<div className="solid-panel overflow-hidden border-white/5">
{/* terminal titlebar */}
<div className="bg-[#0f0f0f] border-b border-white/5 px-4 py-3 pb-2.5 flex items-center gap-2 rounded-t-xl">
<div className="flex gap-1.5">
{['#ff5f57', '#febc2e', '#28c840'].map((c) => (
<span key={c} className="w-3 h-3 rounded-full block border border-black/20" style={{ background: c }} />
))}
</div>
<span className="ml-3 text-xs text-text-muted font-mono tracking-wide">
veriflo-demo.js
</span>
</div>
<CodeBlock code={heroCode} language="javascript" />
</div>
</div>
{/* WhatsApp bubble */}
{bubbleVisible && (
<div className="animate-slide-right self-start w-full max-w-[420px] p-4 sm:p-5 rounded-2xl glass-panel relative overflow-hidden group max-sm:hidden">
<div className="absolute inset-0 bg-gradient-to-br from-accent/10 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
<div className="relative z-10">
<div className="flex items-center gap-3 mb-3">
<div className="w-10 h-10 rounded-full bg-accent flex items-center justify-center shadow-[0_0_15px_rgba(37,211,102,0.3)]">
<MessageCircle size={20} color="#000" />
</div>
<div>
<div className="font-bold text-[0.88rem] text-white leading-tight mb-0.5 tracking-wide">Veriflo API</div>
<div className="text-[0.75rem] text-accent font-semibold leading-tight">Verified Business</div>
</div>
</div>
<div className="bg-[#121212] border border-white/5 rounded-[20px] rounded-tl-[4px] px-4 py-3 text-[0.95rem] text-text-primary leading-[1.6] shadow-lg">
<p className="m-0">👋 Hello! {typed}
{typed.length < fullText.length && (
<span className="animate-blink border-r-2 border-accent ml-[1px]" />
)}
</p>
{typed.length >= fullText.length && (
<div className="mt-2.5">
<strong className="text-[1.8rem] tracking-[6px] text-accent block">
{OTP_SEQUENCES[otpIdx].split('').map((digit, i) => (
<span
key={`${otpIdx}-${i}`}
className="inline-block"
style={{ animation: 'digitPop 0.52s cubic-bezier(0.22,1,0.36,1) both', animationDelay: `${i * 70}ms` }}
>
{digit}
</span>
))}
</strong>
</div>
)}
<div className="text-[0.75rem] text-text-muted mt-2 flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-yellow-500 block animate-pulse" />
Expires in 60 seconds
</div>
</div>
<div className="text-right mt-2 mr-1 text-[0.75rem] text-text-muted font-medium flex justify-end items-center gap-1">
<span className="text-accent text-xs"></span> Just now
</div>
</div>
</div>
)}
</div>
</section>
)
}

View File

@ -0,0 +1,83 @@
import { UserPlus, Key, Send } from 'lucide-react'
import { marketingConfig, formatSecondsText } from '../config/marketingConfig'
const steps = [
{
icon: UserPlus,
step: '01',
title: 'Register & Get API Key',
desc: 'Sign up in seconds. No credit card required. Get your unique API key instantly from the dashboard.',
color: 'var(--color-accent)',
},
{
icon: Key,
step: '02',
title: 'Install the SDK',
desc: 'npm install veriflo or pip install veriflo. One command and you\'re ready to integrate.',
color: '#4ade80',
},
{
icon: Send,
step: '03',
title: 'Send OTPs instantly',
desc: `Call sendOTP() with any phone number and we deliver a WhatsApp OTP within ${formatSecondsText(marketingConfig.avgDeliveryTimeSeconds)}.`,
color: 'var(--color-accent)',
},
]
export default function HowItWorks() {
return (
<section className="section-pad relative overflow-hidden border-t border-white/5">
<div className="absolute top-0 right-0 w-[600px] h-[600px] bg-accent/5 blur-[150px] rounded-full pointer-events-none -translate-y-1/2 translate-x-1/3" />
<div className="max-w-[1360px] 2xl:max-w-[1480px] mx-auto px-4 sm:px-6 lg:px-8 relative z-10 animate-reveal-up">
{/* Heading */}
<div className="text-center mb-20 max-w-[600px] mx-auto">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-white/10 bg-white/5 mb-6">
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
<span className="text-[0.8rem] font-bold text-text-primary tracking-widest uppercase">How It Works</span>
</div>
<h2 className="text-[clamp(2rem,4vw,3rem)] font-black text-white tracking-tight leading-[1.1] mb-5">
Up and running in <span className="text-gradient">under 5 minutes</span>
</h2>
<p className="text-text-secondary text-[1.1rem] font-medium leading-relaxed">
Three simple steps and your users receive WhatsApp OTPs reliably.
</p>
</div>
{/* Steps grid */}
<div className="grid grid-cols-[repeat(auto-fit,minmax(260px,1fr))] lg:grid-cols-3 gap-5 sm:gap-8">
{steps.map(({ icon: Icon, step, title, desc, color }) => (
<div
key={step}
className="glass-panel fx-card fx-hover-tilt p-6 sm:p-10 relative overflow-hidden group"
>
<div className="absolute inset-0 bg-gradient-to-br from-white/5 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500" />
{/* Step number — clean watermark */}
<div
className="absolute top-4 sm:top-6 right-5 sm:right-8 text-[3.3rem] sm:text-[5rem] font-black leading-none select-none text-white/5 transition-colors duration-300 group-hover:text-accent/10"
>
{step}
</div>
<div className="relative z-10">
{/* Icon */}
<div className="w-16 h-16 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center mb-8 group-hover:border-accent/30 group-hover:bg-accent/10 transition-all duration-300">
<Icon size={28} color={color} className="group-hover:scale-110 transition-transform duration-300" />
</div>
<h3 className="text-xl font-bold text-white mb-3">
{title}
</h3>
<p className="text-text-secondary leading-relaxed text-[0.95rem] font-medium m-0">
{desc}
</p>
</div>
</div>
))}
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,14 @@
import { motion } from 'framer-motion'
export default function MotionSection({ children, delay = 0, y = 28 }) {
return (
<motion.div
initial={{ opacity: 0, y }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, amount: 0.18 }}
transition={{ duration: 0.65, delay, ease: [0.22, 1, 0.36, 1] }}
>
{children}
</motion.div>
)
}

112
src/components/Navbar.jsx Normal file
View File

@ -0,0 +1,112 @@
import { useState, useEffect } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { MessageCircle, Menu, X, ArrowRight } from 'lucide-react'
import ThemeToggle from './ThemeToggle'
export default function Navbar() {
const [scrolled, setScrolled] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const { pathname } = useLocation()
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 10)
window.addEventListener('scroll', onScroll)
return () => window.removeEventListener('scroll', onScroll)
}, [])
const links = [
{ to: '/', label: 'Home' },
{ to: '/pricing', label: 'Pricing' },
{ to: '/docs', label: 'Docs' },
]
return (
<nav
className={`fixed top-0 left-0 right-0 z-50 transition-all duration-300 ${
scrolled
? 'bg-base/70 backdrop-blur-md border-b border-white/5 py-4'
: 'bg-transparent py-6'
}`}
>
<div className="max-w-[1360px] 2xl:max-w-[1480px] mx-auto px-4 sm:px-6 lg:px-8 flex items-center justify-between">
{/* Logo */}
<Link to="/" className="flex items-center gap-2.5 no-underline group">
<div className="w-10 h-10 rounded-xl bg-accent/10 border border-accent/20 flex items-center justify-center group-hover:bg-accent/20 transition-colors">
<MessageCircle size={20} className="text-accent" />
</div>
<span className="font-bold text-xl tracking-tight text-white">
Veriflo
</span>
</Link>
{/* Desktop Nav Links */}
<div className="hidden md:flex items-center gap-1 bg-surface/50 border border-white/5 backdrop-blur-md rounded-full px-2 py-1.5 shadow-2xl">
{links.map((l) => (
<Link
key={l.to}
to={l.to}
className={`px-4 py-1.5 rounded-full text-sm font-semibold border transition-[color,background-color,border-color,box-shadow,transform] duration-300 ease-out ${
pathname === l.to
? 'bg-glass border-glass-border text-white shadow-lg'
: 'border-transparent text-text-secondary hover:text-white hover:bg-white/5 hover:border-white/10'
}`}
>
{l.label}
</Link>
))}
</div>
{/* CTA */}
<div className="flex items-center gap-4">
<div className="hidden md:block">
<ThemeToggle />
</div>
<Link
to="/signup"
className="btn-primary text-sm flex items-center gap-1.5 max-sm:px-4 max-sm:py-2"
>
Sign Up Free <ArrowRight size={14} />
</Link>
{/* Mobile menu toggle */}
<button
onClick={() => setMenuOpen(!menuOpen)}
className="md:hidden flex items-center justify-center w-10 h-10 rounded-lg bg-surface border border-white/10 text-text-secondary hover:text-white"
>
{menuOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
</div>
{/* Mobile drawer */}
{menuOpen && (
<div className="absolute top-[100%] left-0 right-0 bg-surface/95 backdrop-blur-xl border-b border-white/10 px-4 sm:px-6 py-5 flex flex-col gap-2 md:hidden animate-reveal-up">
<div className="mb-2 flex justify-end">
<ThemeToggle compact />
</div>
{links.map((l) => (
<Link
key={l.to}
to={l.to}
onClick={() => setMenuOpen(false)}
className={`px-4 py-3 rounded-lg font-semibold transition-all duration-200 ${
pathname === l.to
? 'bg-white/5 text-white border border-white/10'
: 'text-text-secondary hover:text-white hover:bg-white/5'
}`}
>
{l.label}
</Link>
))}
<Link
to="/signup"
onClick={() => setMenuOpen(false)}
className="btn-primary text-sm flex items-center justify-center gap-1.5 mt-1"
>
Sign Up Free <ArrowRight size={14} />
</Link>
</div>
)}
</nav>
)
}

175
src/components/Pricing.jsx Normal file
View File

@ -0,0 +1,175 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { Zap, Check } from 'lucide-react'
import { marketingConfig, formatCount, formatInr } from '../config/marketingConfig'
export default function Pricing() {
const [otpCount, setOtpCount] = useState(marketingConfig.otpSliderDefault)
const cost = otpCount * marketingConfig.pricePerOtp
const sliderRange = Math.max(marketingConfig.otpSliderMax - marketingConfig.otpSliderMin, 1)
const sliderProgress = ((otpCount - marketingConfig.otpSliderMin) / sliderRange) * 100
const included = [
'WhatsApp delivery (not SMS)',
'98% delivery success rate',
'Custom company name & greeting',
'OTP auto-expiry (configurable)',
'Node.js & Python SDKs',
'Real-time delivery analytics',
'REST API + Postman collection',
'Email support',
]
return (
<section className="section-pad relative z-10 border-t border-white/5 overflow-hidden" id="pricing">
{/* Background glow */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_right_top,rgba(37,211,102,0.05),transparent_50%)] pointer-events-none" />
<div className="max-w-[1360px] 2xl:max-w-[1480px] mx-auto px-4 sm:px-6 lg:px-8 relative z-10 animate-reveal-up">
{/* Header */}
<div className="text-center mb-20 max-w-[600px] mx-auto">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-white/10 bg-white/5 mb-6">
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
<span className="text-[0.8rem] font-bold text-text-primary tracking-widest uppercase">Pricing</span>
</div>
<h2 className="text-[clamp(2rem,4vw,3.2rem)] font-black text-white tracking-tight leading-[1.1] mb-5">
Simple, <span className="text-gradient">transparent pricing</span>
</h2>
<p className="text-text-secondary text-[1.1rem] font-medium leading-relaxed">
Pay only for what you send. No monthly fees, no hidden charges.
</p>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(300px,1fr))] xl:grid-cols-2 gap-6 sm:gap-8 items-start">
{/* Pricing Card */}
<div className="solid-panel fx-card fx-hover-tilt p-8 md:p-10 relative overflow-hidden group border-white/10 hover:border-accent/30 hover:shadow-[0_0_40px_rgba(37,211,102,0.1)] transition-all duration-500">
{/* Glow blob */}
<div className="absolute -top-20 -right-20 w-[300px] h-[300px] bg-accent/10 blur-[100px] rounded-full pointer-events-none group-hover:bg-accent/20 transition-colors duration-500" />
<div className="inline-flex items-center gap-1.5 px-3.5 py-1 rounded-full border border-accent/30 bg-accent/10 mb-8">
<span className="text-[0.78rem] font-bold text-accent tracking-widest uppercase">Pay As You Go</span>
</div>
<div className="mb-1">
<span className="text-[0.95rem] text-text-secondary font-medium">Starting at just</span>
</div>
<div className="flex items-end gap-1.5 mb-2">
<span className="text-6xl font-black text-white leading-none tracking-tighter">{formatInr(marketingConfig.pricePerOtp)}</span>
<span className="text-[1.1rem] text-text-secondary font-semibold pb-1.5">/OTP</span>
</div>
{/* Free trial badge */}
<div className="glass-panel px-5 py-3 rounded-xl mb-8 flex items-center gap-3 border-accent/20 bg-accent/5">
<span className="text-[1.3rem]">🎁</span>
<span className="text-[0.95rem] font-bold text-white">
First <strong className="text-accent">{marketingConfig.sandboxFreeOtps} sandbox + {marketingConfig.liveFreeOtps} live OTPs free</strong> no credit card
</span>
</div>
<div className="flex flex-col gap-3.5 mb-10">
{included.map((item, index) => (
<div key={item} className={`flex items-start gap-3 ${index > 3 ? 'max-md:hidden' : ''}`}>
<div className="w-5 h-5 rounded-full bg-accent/20 flex items-center justify-center shrink-0 mt-[1px]">
<Check size={12} className="text-accent" strokeWidth={3} />
</div>
<span className="text-[0.95rem] text-text-secondary font-medium tracking-wide">{item}</span>
</div>
))}
</div>
<div className="md:hidden text-[0.8rem] text-text-muted font-semibold mb-8">
Includes more capabilities in docs and dashboard settings.
</div>
<Link
to="/signup"
className="btn-primary flex items-center justify-center gap-2 p-4 text-base w-full group-hover:shadow-[0_0_30px_rgba(37,211,102,0.4)]"
>
<Zap size={18} /> Start Free Trial
</Link>
</div>
{/* Interactive Calculator */}
<div className="glass-panel fx-card fx-hover-tilt p-8 md:p-10">
<h3 className="text-[1.5rem] font-bold text-white mb-2 tracking-tight">
💰 Cost Calculator
</h3>
<p className="text-text-secondary text-[1rem] font-medium mb-10">
Estimate your monthly spend based on usage
</p>
<div className="mb-10">
<div className="flex justify-between mb-5">
<span className="font-semibold text-white tracking-wide">OTPs per month</span>
<span className="font-black text-accent text-[1.2rem]">
{formatCount(otpCount)}
</span>
</div>
{/* Slider */}
<div className="relative">
<input
type="range"
min={marketingConfig.otpSliderMin}
max={marketingConfig.otpSliderMax}
step="10"
value={otpCount}
onChange={e => setOtpCount(Number(e.target.value))}
className="w-full appearance-none h-2.5 rounded-full bg-white/10 cursor-pointer outline-none slider-minimal"
style={{
background: `linear-gradient(to right, var(--color-accent) 0%, var(--color-accent) ${sliderProgress}%, rgba(255,255,255,0.1) ${sliderProgress}%, rgba(255,255,255,0.1) 100%)`
}}
/>
</div>
<div className="flex justify-between mt-2.5">
<span className="text-xs text-text-muted font-bold tracking-widest uppercase">{formatCount(marketingConfig.otpSliderMin)}</span>
<span className="text-xs text-text-muted font-bold tracking-widest uppercase">{formatCount(marketingConfig.otpSliderMax)}</span>
</div>
</div>
{/* Result */}
<div className="bg-surface border border-white/5 p-6 rounded-2xl text-center mb-8 shadow-inner">
<div className="text-[0.9rem] text-text-secondary font-semibold mb-2 tracking-wide">
Estimated monthly cost
</div>
<div className="text-[3.5rem] font-black text-white tracking-tighter leading-none mb-1">
{formatInr(cost)}
</div>
<div className="text-[0.9rem] text-text-muted font-medium">
= {formatCount(otpCount)} OTPs × {formatInr(marketingConfig.pricePerOtp)}
</div>
</div>
{/* Comparison */}
<div className="grid grid-cols-2 gap-3">
{[
{ label: 'WhatsApp', cost: formatInr(cost), emoji: '💬', accent: true },
{ label: 'Email', cost: '~₹0', emoji: '📧', accent: false },
].map(({ label, cost: c, emoji, accent }) => (
<div key={label} className={`p-4 rounded-xl text-center border transition-colors ${accent ? 'bg-accent/10 border-accent/30' : 'bg-surface border-white/5'}`}>
<div className="text-2xl mb-2">{emoji}</div>
<div className={`text-[0.75rem] font-bold tracking-wider uppercase mb-1 ${accent ? 'text-accent' : 'text-text-secondary'}`}>{label}</div>
<div className="text-[1rem] font-black text-white">{c}</div>
</div>
))}
</div>
</div>
</div>
</div>
<style>{`
input.slider-minimal[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
width: 24px; height: 24px; border-radius: 50%;
background: #fff;
border: 4px solid var(--color-accent);
box-shadow: 0 0 15px rgba(37,211,102,0.4);
cursor: pointer;
transition: transform 0.1s;
}
input.slider-minimal[type='range']::-webkit-slider-thumb:hover {
transform: scale(1.1);
}
`}</style>
</section>
)
}

View File

@ -0,0 +1,12 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
export default function ScrollToTop() {
const { pathname } = useLocation()
useEffect(() => {
window.scrollTo({ top: 0, left: 0, behavior: 'smooth' })
}, [pathname])
return null
}

View File

@ -0,0 +1,35 @@
import { TrendingUp, DollarSign, Clock } from 'lucide-react'
import { marketingConfig, formatInr, formatSecondsShort } from '../config/marketingConfig'
const stats = [
{ icon: TrendingUp, value: `${marketingConfig.deliveryRatePercent}%`, label: 'Delivery Rate', color: 'var(--color-accent)' },
{ icon: DollarSign, value: formatInr(marketingConfig.pricePerOtp), label: 'Per OTP', color: '#4ade80' },
{ icon: Clock, value: formatSecondsShort(marketingConfig.avgDeliveryTimeSeconds), label: 'Delivery Time', color: 'var(--color-accent)' },
]
export default function StatsBar() {
return (
<section className="px-4 sm:px-6 lg:px-8 pb-16 sm:pb-20 lg:pb-24 max-w-[1360px] 2xl:max-w-[1480px] mx-auto relative z-10 animate-reveal-up">
<div className="grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] xl:grid-cols-3 gap-4 sm:gap-6">
{stats.map(({ icon: Icon, value, label, color }) => (
<div
key={label}
className="glass-panel p-8 flex items-center gap-6 group hover:-translate-y-1"
>
<div className="w-14 h-14 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center shrink-0 group-hover:bg-accent/10 group-hover:border-accent/30 transition-all duration-300">
<Icon size={26} color={color} className="group-hover:scale-110 transition-transform duration-300" />
</div>
<div>
<div className="text-4xl font-black text-white leading-none tracking-tight mb-1.5">
{value}
</div>
<div className="text-[0.9rem] text-text-secondary font-semibold uppercase tracking-wider">
{label}
</div>
</div>
</div>
))}
</div>
</section>
)
}

View File

@ -0,0 +1,189 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { Star, Quote } from 'lucide-react'
import { marketingConfig, formatInr, formatSecondsShort } from '../config/marketingConfig'
const testimonials = [
{
name: 'Rajesh Kumar',
role: 'CTO, FinTech Startup',
avatar: '🧑‍💼',
text: 'Integrated Veriflo in literally 10 minutes. Our login conversion rate jumped 30% because users actually read WhatsApp messages. SMS OTPs were getting ignored.',
stars: 5,
},
{
name: 'Priya Sharma',
role: 'Lead Developer, EdTech Co.',
avatar: '👩‍💻',
text: 'The Python SDK is super clean. Three lines and we had WhatsApp OTPs working in our Django app. The documentation is excellent too.',
stars: 5,
},
{
name: 'Arjun Mehta',
role: 'Indie Developer',
avatar: '🧑‍🚀',
text: `At ${formatInr(marketingConfig.pricePerOtp)} per OTP, it's cheaper than SMS and has way better delivery. The ${marketingConfig.sandboxFreeOtps} sandbox + ${marketingConfig.liveFreeOtps} live free trial let me test everything before committing. Brilliant product.`,
stars: 5,
},
]
const trustStats = [
{ value: marketingConfig.otpsSentLabel, label: 'OTPs Sent' },
{ value: marketingConfig.businessesLabel, label: 'Businesses' },
{ value: `${marketingConfig.deliveryRatePercent}%`, label: 'Delivery Rate' },
{ value: formatSecondsShort(marketingConfig.avgDeliveryTimeSeconds), label: 'Avg Delivery' },
]
function parseAnimatedValue(rawValue) {
const source = String(rawValue)
const match = source.match(/\d+(?:\.\d+)?/)
if (!match || match.index === undefined) {
return {
target: 0,
format: () => source,
}
}
const numberText = match[0]
const target = Number(numberText.replace(/,/g, ''))
const prefix = source.slice(0, match.index)
const suffix = source.slice(match.index + numberText.length)
const decimals = (numberText.split('.')[1] || '').length
const useGrouping = numberText.includes(',') || target >= 1000
const isLessThanSeconds = prefix.trim() === '<' && suffix.trim() === 's'
return {
target,
format: (value, done) => {
const numeric = decimals > 0 ? Number(value.toFixed(decimals)) : Math.round(value)
const adjusted = !done && isLessThanSeconds ? Math.max(1, numeric) : numeric
let body
if (useGrouping) {
body = new Intl.NumberFormat('en-IN', {
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(adjusted)
} else {
body = decimals > 0 ? adjusted.toFixed(decimals) : String(adjusted)
}
return `${prefix}${body}${suffix}`
},
}
}
export default function Testimonials() {
const statsRef = useRef(null)
const [hasStarted, setHasStarted] = useState(false)
const [progress, setProgress] = useState(0)
const animatedStats = useMemo(
() => trustStats.map((stat) => ({ ...stat, ...parseAnimatedValue(stat.value) })),
[]
)
useEffect(() => {
if (!statsRef.current || hasStarted) return
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setHasStarted(true)
observer.disconnect()
}
},
{ threshold: 0.35 }
)
observer.observe(statsRef.current)
return () => observer.disconnect()
}, [hasStarted])
useEffect(() => {
if (!hasStarted) return
const durationMs = 1400
const start = performance.now()
let frameId
const tick = (now) => {
const t = Math.min((now - start) / durationMs, 1)
const eased = 1 - Math.pow(1 - t, 3)
setProgress(eased)
if (t < 1) frameId = window.requestAnimationFrame(tick)
}
frameId = window.requestAnimationFrame(tick)
return () => window.cancelAnimationFrame(frameId)
}, [hasStarted])
return (
<section className="section-pad relative z-10">
<div className="max-w-[1360px] 2xl:max-w-[1480px] mx-auto px-4 sm:px-6 lg:px-8 animate-reveal-up">
{/* Trust Stats */}
<div ref={statsRef} className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-6 mb-24">
{animatedStats.map(({ value, label, target, format }) => (
<div key={label} className="glass-panel p-8 text-center bg-white/5 border-white/10 hover:bg-white/10 transition-colors">
<div className="text-[2.5rem] font-black text-white leading-none tracking-tight mb-2">
{hasStarted ? format(target * progress, progress >= 1) : value}
</div>
<div className="text-[0.85rem] text-text-secondary font-bold tracking-widest uppercase">{label}</div>
</div>
))}
</div>
{/* Heading */}
<div className="text-center mb-16 max-w-[600px] mx-auto">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-white/10 bg-white/5 mb-6">
<span className="w-1.5 h-1.5 rounded-full bg-accent" />
<span className="text-[0.8rem] font-bold text-text-primary tracking-widest uppercase">Testimonials</span>
</div>
<h2 className="text-[clamp(2rem,4vw,3.2rem)] font-black text-white tracking-tight leading-[1.1]">
Loved by developers <span className="text-gradient">across India</span>
</h2>
</div>
{/* Cards */}
<div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] xl:grid-cols-3 gap-5 sm:gap-8">
{testimonials.map(({ name, role, avatar, text, stars }, index) => (
<div
key={name}
className={`glass-panel fx-card fx-hover-tilt p-7 sm:p-10 relative transition-all duration-300 hover:border-white/20 group ${index > 0 ? 'max-md:hidden' : ''}`}
>
{/* Quote icon */}
<div className="absolute top-8 right-8 text-white/5 group-hover:text-white/10 transition-colors duration-300">
<Quote size={48} fill="currentColor" />
</div>
{/* Stars */}
<div className="flex gap-[4px] mb-6">
{Array(stars).fill(0).map((_, i) => (
<Star key={i} size={16} fill="#fbbf24" color="#fbbf24" />
))}
</div>
<p className="text-white leading-[1.8] text-[1.05rem] font-medium mb-8">
"{text}"
</p>
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-full border border-white/10 bg-surface flex items-center justify-center text-[1.5rem]">
{avatar}
</div>
<div>
<div className="font-bold text-[1rem] text-white tracking-wide">{name}</div>
<div className="text-[0.8rem] text-accent font-semibold tracking-wide mt-0.5">{role}</div>
</div>
</div>
</div>
))}
</div>
<div className="sm:hidden mt-4 text-center text-[0.82rem] text-text-muted font-semibold">
More customer stories are available on larger screens.
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,20 @@
import { Sun, Moon } from 'lucide-react'
import { useTheme } from '../hooks/useTheme'
export default function ThemeToggle({ compact = false }) {
const { theme, toggleTheme } = useTheme()
const isLight = theme === 'light'
return (
<button
type="button"
onClick={toggleTheme}
className={`inline-flex items-center justify-center gap-2 rounded-full border border-white/10 bg-white/5 hover:bg-white/10 text-text-secondary hover:text-text-primary transition-all duration-300 ${compact ? 'w-10 h-10' : 'px-3 py-2'}`}
title={isLight ? 'Switch to dark mode' : 'Switch to light mode'}
aria-label={isLight ? 'Switch to dark mode' : 'Switch to light mode'}
>
{isLight ? <Moon size={16} /> : <Sun size={16} />}
{!compact && <span className="text-xs font-bold tracking-wide">{isLight ? 'Dark' : 'Light'}</span>}
</button>
)
}

View File

@ -0,0 +1,84 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { CheckCircle2, AlertTriangle, Info, X } from 'lucide-react'
const ToastContext = createContext(null)
const DEFAULT_DURATION = 3500
function ToastItem({ toast, onClose }) {
const icon = toast.type === 'success'
? <CheckCircle2 size={16} />
: toast.type === 'error'
? <AlertTriangle size={16} />
: <Info size={16} />
const toneStyle = toast.type === 'success'
? { border: '1px solid rgba(37,211,102,0.3)', color: '#86efac' }
: toast.type === 'error'
? { border: '1px solid rgba(239,68,68,0.35)', color: '#fda4af' }
: { border: '1px solid rgba(148,163,184,0.35)', color: 'var(--color-text-secondary)' }
return (
<div
className="min-w-[260px] max-w-[360px] rounded-xl px-3 py-2.5 flex items-start gap-2.5"
style={{
background: 'var(--color-surface)',
boxShadow: '0 12px 32px rgba(0,0,0,0.35)',
...toneStyle,
}}
>
<div className="mt-0.5 shrink-0">{icon}</div>
<div className="text-sm font-semibold leading-5 text-text-primary flex-1">{toast.message}</div>
<button
type="button"
onClick={() => onClose(toast.id)}
className="border-none bg-transparent text-text-muted hover:text-text-primary cursor-pointer p-0.5"
aria-label="Dismiss notification"
>
<X size={14} />
</button>
</div>
)
}
export function ToastProvider({ children }) {
const [toasts, setToasts] = useState([])
const dismiss = useCallback((id) => {
setToasts((prev) => prev.filter((toast) => toast.id !== id))
}, [])
const push = useCallback((message, type = 'info', duration = DEFAULT_DURATION) => {
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
setToasts((prev) => [...prev, { id, message, type }])
window.setTimeout(() => dismiss(id), duration)
}, [dismiss])
const value = useMemo(() => ({
toast: (message, type = 'info', duration = DEFAULT_DURATION) => push(message, type, duration),
success: (message, duration = DEFAULT_DURATION) => push(message, 'success', duration),
error: (message, duration = DEFAULT_DURATION) => push(message, 'error', duration),
info: (message, duration = DEFAULT_DURATION) => push(message, 'info', duration),
}), [push])
return (
<ToastContext.Provider value={value}>
{children}
<div className="fixed top-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
{toasts.map((toast) => (
<div key={toast.id} className="pointer-events-auto">
<ToastItem toast={toast} onClose={dismiss} />
</div>
))}
</div>
</ToastContext.Provider>
)
}
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within ToastProvider')
}
return context
}

View File

@ -0,0 +1,71 @@
const getEnvNumber = (key, fallback) => {
const rawValue = import.meta.env[key]
const parsedValue = Number(rawValue)
return Number.isFinite(parsedValue) ? parsedValue : fallback
}
const getEnvString = (key, fallback) => {
const rawValue = import.meta.env[key]
return typeof rawValue === 'string' && rawValue.trim() ? rawValue.trim() : fallback
}
const otpSliderMin = getEnvNumber('VITE_OTP_SLIDER_MIN', 10)
const otpSliderMax = getEnvNumber('VITE_OTP_SLIDER_MAX', 10000)
const otpSliderDefault = getEnvNumber('VITE_OTP_SLIDER_DEFAULT', 1000)
const sandboxFreeOtps = getEnvNumber('VITE_SANDBOX_FREE_OTPS', getEnvNumber('VITE_FREE_OTPS', 500))
const liveFreeOtps = getEnvNumber('VITE_LIVE_FREE_OTPS', 100)
export const marketingConfig = {
freeOtps: sandboxFreeOtps,
sandboxFreeOtps,
liveFreeOtps,
trialCreditsLabel: `${sandboxFreeOtps} Sandbox + ${liveFreeOtps} Live`,
pricePerOtp: getEnvNumber('VITE_PRICE_PER_OTP', 0.2),
benchmarkSmsPricePerOtp: getEnvNumber('VITE_BENCHMARK_SMS_PRICE_PER_OTP', 0.45),
benchmarkSmsPlatformFee: getEnvNumber('VITE_BENCHMARK_SMS_PLATFORM_FEE', 999),
benchmarkManagedPricePerOtp: getEnvNumber('VITE_BENCHMARK_MANAGED_PRICE_PER_OTP', 0.7),
benchmarkManagedPlatformFee: getEnvNumber('VITE_BENCHMARK_MANAGED_PLATFORM_FEE', 2499),
deliveryRatePercent: getEnvNumber('VITE_DELIVERY_RATE_PERCENT', 98),
openRatePercent: getEnvNumber('VITE_OPEN_RATE_PERCENT', 98),
benchmarkSmsOpenRatePercent: getEnvNumber('VITE_BENCHMARK_SMS_OPEN_RATE_PERCENT', 20),
emailOpenRatePercent: getEnvNumber('VITE_EMAIL_OPEN_RATE_PERCENT', 22),
avgDeliveryTimeSeconds: getEnvNumber('VITE_AVG_DELIVERY_TIME_SECONDS', 2),
otpSliderMin,
otpSliderMax,
otpSliderDefault: Math.min(Math.max(otpSliderDefault, otpSliderMin), otpSliderMax),
otpsSentLabel: getEnvString('VITE_OTPS_SENT_LABEL', '10,000+'),
businessesLabel: getEnvString('VITE_BUSINESSES_LABEL', '50+'),
dashboardUrl: getEnvString('VITE_DASHBOARD_URL', 'http://localhost:5174'),
}
export const marketingRateLimits = [
{
endpoint: '/v1/otp/send',
limit: getEnvString('VITE_RATE_LIMIT_SEND', '10 req / min'),
},
{
endpoint: '/v1/otp/verify',
limit: getEnvString('VITE_RATE_LIMIT_VERIFY', '30 req / min'),
},
{
endpoint: '/v1/otp/resend',
limit: getEnvString('VITE_RATE_LIMIT_RESEND', '5 req / min / id'),
},
]
export const formatCount = (value) => new Intl.NumberFormat('en-IN').format(value)
export const formatInr = (value, fractionDigits = 2) => (
new Intl.NumberFormat('en-IN', {
style: 'currency',
currency: 'INR',
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}).format(value)
)
export const formatSecondsShort = (value) => `<${value}s`
export const formatSecondsText = (value) => `${value} seconds`

26
src/hooks/useTheme.js Normal file
View File

@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
const THEME_STORAGE_KEY = 'veriflo_theme'
function getPreferredTheme() {
const stored = localStorage.getItem(THEME_STORAGE_KEY)
if (stored === 'light' || stored === 'dark') return stored
// Default to dark to keep the combined marketing+dashboard experience visually consistent.
return 'dark'
}
export function useTheme() {
const [theme, setTheme] = useState(() => getPreferredTheme())
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem(THEME_STORAGE_KEY, theme)
}, [theme])
const toggleTheme = () => {
setTheme((current) => (current === 'dark' ? 'light' : 'dark'))
}
return { theme, setTheme, toggleTheme }
}

485
src/index.css Normal file
View File

@ -0,0 +1,485 @@
@import "tailwindcss";
@theme {
/* ── Dark Base (Aligned with marketing look) ───── */
--color-base: #05070c;
--color-surface: #0b111c;
--color-sidebar: #080d17;
/* Shadows */
--color-shadow-dark: #02050b;
--color-shadow-light: #111a2a;
/* Brand */
--color-accent: #25D366;
--color-accent-dark: #1aab52;
/* Text */
--color-text-primary: #edf3ff;
--color-text-secondary:#93a2bb;
--color-text-muted: #627189;
/* Radii */
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 24px;
--radius-pill:9999px;
--font-sans: 'Nunito', system-ui, -apple-system, sans-serif;
}
[data-theme='light'] {
--color-base: #f5f7fb;
--color-surface: #eef2f8;
--color-sidebar: #e8edf6;
--color-shadow-dark: #d4dcec;
--color-shadow-light: #ffffff;
--color-accent: #149c4a;
--color-accent-dark: #0f7e3a;
--color-text-primary: #0f172a;
--color-text-secondary: #334155;
--color-text-muted: #64748b;
}
@layer base {
*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
@apply bg-base text-text-primary font-sans antialiased;
background-color: var(--color-base);
}
input,
textarea,
select {
color: var(--color-text-primary);
caret-color: var(--color-text-primary);
}
input:-webkit-autofill,
input:-webkit-autofill:hover,
input:-webkit-autofill:focus,
textarea:-webkit-autofill,
textarea:-webkit-autofill:hover,
textarea:-webkit-autofill:focus,
select:-webkit-autofill,
select:-webkit-autofill:hover,
select:-webkit-autofill:focus {
-webkit-text-fill-color: var(--color-text-primary);
-webkit-box-shadow: 0 0 0 1000px var(--color-base) inset;
box-shadow: 0 0 0 1000px var(--color-base) inset;
transition: background-color 9999s ease-in-out 0s;
}
}
@layer utilities {
/* ── Core Neumorphic Surfaces ─────────────────── */
.neu-raised {
background: var(--color-base);
box-shadow:
8px 8px 20px var(--color-shadow-dark),
-8px -8px 20px var(--color-shadow-light);
}
.neu-raised-sm {
background: var(--color-base);
box-shadow:
4px 4px 10px var(--color-shadow-dark),
-4px -4px 10px var(--color-shadow-light);
}
.neu-inset {
background: var(--color-base);
box-shadow:
inset 5px 5px 12px var(--color-shadow-dark),
inset -5px -5px 12px var(--color-shadow-light);
}
.neu-inset-sm {
background: var(--color-base);
box-shadow:
inset 2px 2px 6px var(--color-shadow-dark),
inset -2px -2px 6px var(--color-shadow-light);
}
/* ── Buttons ──────────────────────────────────── */
.neu-btn {
background: var(--color-base);
box-shadow:
4px 4px 10px var(--color-shadow-dark),
-4px -4px 10px var(--color-shadow-light);
@apply rounded-lg flex items-center justify-center font-bold text-text-secondary transition-all text-sm;
cursor: pointer;
border: none;
outline: none;
}
.neu-btn:hover {
box-shadow:
6px 6px 14px var(--color-shadow-dark),
-6px -6px 14px var(--color-shadow-light);
@apply text-text-primary;
transform: translateY(-1px);
}
.neu-btn:active {
box-shadow:
inset 2px 2px 6px var(--color-shadow-dark),
inset -2px -2px 6px var(--color-shadow-light);
transform: translateY(0);
}
.neu-btn-accent {
background: linear-gradient(135deg, var(--color-accent) 0%, var(--color-accent-dark) 100%);
box-shadow:
0 4px 22px rgba(37,211,102,0.35),
4px 4px 12px var(--color-shadow-dark),
-2px -2px 8px var(--color-shadow-light);
@apply rounded-lg flex items-center justify-center font-bold text-white transition-all text-sm border-none outline-none cursor-pointer;
}
.neu-btn-accent:hover {
box-shadow:
0 6px 30px rgba(37,211,102,0.5),
4px 4px 14px var(--color-shadow-dark),
-2px -2px 10px var(--color-shadow-light);
transform: translateY(-1px);
}
.neu-btn-accent:active {
box-shadow:
inset 2px 2px 6px var(--color-accent-dark),
inset -2px -2px 6px rgba(255,255,255,0.1);
transform: translateY(0);
}
.neu-btn-accent:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.neu-btn-danger {
background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%);
box-shadow:
0 4px 22px rgba(239,68,68,0.35),
4px 4px 12px var(--color-shadow-dark),
-2px -2px 8px var(--color-shadow-light);
@apply rounded-lg flex items-center justify-center font-bold text-white transition-all text-sm border-none outline-none cursor-pointer;
}
.neu-btn-danger:hover {
box-shadow:
0 6px 30px rgba(239,68,68,0.5),
4px 4px 14px var(--color-shadow-dark),
-2px -2px 10px var(--color-shadow-light);
transform: translateY(-1px);
}
.neu-btn-danger:active {
box-shadow:
inset 2px 2px 6px #b91c1c,
inset -2px -2px 6px rgba(255,255,255,0.1);
transform: translateY(0);
}
.neu-btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
/* ── Form Inputs ──────────────────────────────── */
.neu-input {
background: var(--color-base);
box-shadow:
inset 4px 4px 8px var(--color-shadow-dark),
inset -4px -4px 8px var(--color-shadow-light);
@apply rounded-xl px-4 py-2.5 w-full text-text-primary text-sm font-semibold border-none outline-none transition-all;
}
.neu-input::placeholder {
color: var(--color-text-muted);
font-weight: 500;
}
.neu-input:focus {
box-shadow:
inset 4px 4px 8px var(--color-shadow-dark),
inset -4px -4px 8px var(--color-shadow-light),
0 0 0 2px rgba(37,211,102,0.3);
}
.neu-input:-webkit-autofill,
.neu-input:-webkit-autofill:hover,
.neu-input:-webkit-autofill:focus,
.neu-input:-webkit-autofill:active {
-webkit-text-fill-color: var(--color-text-primary) !important;
caret-color: var(--color-text-primary) !important;
-webkit-box-shadow: 0 0 0 1000px var(--color-base) inset !important;
box-shadow: 0 0 0 1000px var(--color-base) inset !important;
background-color: var(--color-base) !important;
border-radius: 12px;
transition: background-color 9999s ease-in-out 0s;
}
/* ── Status Badges ────────────────────────────── */
.badge-success {
@apply inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-black uppercase tracking-wider;
background: rgba(37,211,102,0.12);
color: #4ade80;
border: 1px solid rgba(37,211,102,0.2);
}
.badge-error {
@apply inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-black uppercase tracking-wider;
background: rgba(239,68,68,0.12);
color: #f87171;
border: 1px solid rgba(239,68,68,0.2);
}
.badge-warning {
@apply inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-black uppercase tracking-wider;
background: rgba(251,191,36,0.12);
color: #fbbf24;
border: 1px solid rgba(251,191,36,0.2);
}
.badge-neutral {
@apply inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-black uppercase tracking-wider;
background: rgba(255,255,255,0.06);
color: var(--color-text-secondary);
border: 1px solid rgba(255,255,255,0.08);
}
.badge-verified {
@apply inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[11px] font-black uppercase tracking-wider;
background: rgba(99,102,241,0.12);
color: #a5b4fc;
border: 1px solid rgba(99,102,241,0.2);
}
/* ── Divider ──────────────────────────────────── */
.neu-divider {
border: none;
border-top: 1px solid rgba(255,255,255,0.05);
}
/* ── Select Options ───────────────────────────── */
select option { background: var(--color-surface); color: var(--color-text-primary); }
/* ── Scrollbar ────────────────────────────────── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: var(--color-base); }
::-webkit-scrollbar-thumb { background: var(--color-shadow-light); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: var(--color-text-muted); }
/* ── Marketing: Glass primitives ─────────────── */
.glass-panel {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.1);
@apply backdrop-blur-xl rounded-xl transition-all duration-300;
}
.glass-panel:hover {
background: rgba(255,255,255,0.05);
border-color: rgba(255,255,255,0.2);
}
.solid-panel {
background: #111111;
border: 1px solid #222222;
@apply rounded-xl shadow-2xl shadow-black/50;
}
/* ── Marketing: Buttons ──────────────────────── */
.btn-primary {
@apply bg-accent hover:bg-[#20bd5a] text-black font-bold px-6 py-2.5 rounded-[9999px] transition-all duration-300 hover:-translate-y-0.5 shadow-[0_0_20px_rgba(37,211,102,0.3)] hover:shadow-[0_0_30px_rgba(37,211,102,0.5)] cursor-pointer no-underline inline-flex items-center;
}
.btn-secondary {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.1);
@apply text-text-primary font-bold px-6 py-2.5 rounded-[9999px] transition-all duration-300 shadow-lg hover:bg-white/5 cursor-pointer no-underline inline-flex items-center;
}
.btn-icon {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.1);
@apply text-text-secondary hover:text-white transition-all duration-200 rounded-lg flex items-center justify-center cursor-pointer;
}
/* ── Marketing: Typography ───────────────────── */
.text-gradient {
background: linear-gradient(135deg, #fff 0%, #a1a1aa 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.text-gradient-accent {
background: linear-gradient(135deg, var(--color-accent) 0%, #4ade80 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* ── Marketing: Code blocks ──────────────────── */
.syntax-wrapper {
background: rgba(255,255,255,0.03);
border: 1px solid rgba(255,255,255,0.1);
@apply rounded-xl overflow-hidden shadow-2xl;
}
.syntax-wrapper pre {
margin: 0 !important;
border-radius: var(--radius-xl) !important;
font-size: 0.85rem !important;
line-height: 1.7 !important;
background: #050505 !important;
}
/* ── Marketing: Layout ───────────────────────── */
.section-pad { @apply py-16 sm:py-20 lg:py-24 px-4 sm:px-6 lg:px-8; }
}
[data-theme='light'] .text-white {
color: var(--color-text-primary) !important;
}
[data-theme='light'] .text-gradient {
background: linear-gradient(135deg, #0f172a 0%, #334155 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
[data-theme='light'] .glass-panel,
[data-theme='light'] .solid-panel,
[data-theme='light'] .neu-raised,
[data-theme='light'] .neu-raised-sm,
[data-theme='light'] .neu-inset,
[data-theme='light'] .neu-inset-sm {
border-color: rgba(15, 23, 42, 0.08) !important;
}
[data-theme='light'] .bg-white\/5,
[data-theme='light'] .bg-white\/8,
[data-theme='light'] .bg-white\/10 {
background-color: rgba(15, 23, 42, 0.04) !important;
}
/* Animations */
@keyframes float {
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
}
@keyframes pulseGlow {
0%, 100% { box-shadow: 0 0 0 0 rgba(37,211,102,0.4); }
50% { box-shadow: 0 0 0 10px rgba(37,211,102,0); }
}
@keyframes slideRight {
0% { opacity: 0; transform: translate3d(-14px, 0, 0); }
100% { opacity: 1; transform: translate3d(0, 0, 0); }
}
@keyframes blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
@keyframes revealUp {
0% { opacity: 0; transform: translate3d(0, 18px, 0); }
100% { opacity: 1; transform: translate3d(0, 0, 0); }
}
@keyframes drift {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(8px, -10px, 0); }
}
@keyframes pageFadeIn {
from { opacity: 0; transform: translateY(14px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes digitPop {
0% { opacity: 0; transform: translateY(10px) scale(0.65); }
65% { transform: translateY(-2px) scale(1.1); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@utility animate-float { animation: float 4s ease-in-out infinite; }
@utility animate-float-slow { animation: float 6s ease-in-out infinite; }
@utility animate-pulse-glow { animation: pulseGlow 2s infinite; }
@utility animate-slide-right { animation: slideRight 420ms cubic-bezier(0.2, 0.8, 0.2, 1) both; }
@utility animate-blink { animation: blink 1s steps(1, end) infinite; }
@utility animate-reveal-up { animation: revealUp 520ms cubic-bezier(0.2, 0.8, 0.2, 1) both; }
@utility animate-drift { animation: drift 8s ease-in-out infinite; }
.page-fade-in {
animation: pageFadeIn 0.32s cubic-bezier(0.22, 1, 0.36, 1) both;
}
.fx-card {
position: relative;
overflow: hidden;
transition: transform 320ms cubic-bezier(0.22, 1, 0.36, 1), border-color 280ms ease, box-shadow 320ms ease, background-color 320ms ease;
}
.fx-card::before {
content: '';
position: absolute;
inset: -120% -40%;
background: linear-gradient(115deg, transparent 30%, rgba(255,255,255,0.12) 48%, transparent 66%);
transform: translateX(-35%) rotate(8deg);
transition: transform 620ms cubic-bezier(0.22, 1, 0.36, 1), opacity 340ms ease;
opacity: 0;
pointer-events: none;
}
.fx-card:hover {
transform: translateY(-5px) scale(1.008);
box-shadow: 0 22px 56px rgba(0,0,0,0.34), 0 0 0 1px rgba(255,255,255,0.06) inset;
}
.fx-card:hover::before {
transform: translateX(70%) rotate(8deg);
opacity: 1;
}
.fx-splash-orb {
position: absolute;
border-radius: 9999px;
pointer-events: none;
filter: blur(90px);
opacity: 0.28;
animation: drift 12s ease-in-out infinite;
}
.fx-hover-tilt {
transition: transform 280ms cubic-bezier(0.22, 1, 0.36, 1);
transform-style: preserve-3d;
}
.fx-hover-tilt:hover {
transform: translateY(-4px) rotateX(2deg) rotateY(-2deg);
}
.animate-float,
.animate-float-slow,
.animate-slide-right,
.animate-reveal-up,
.animate-drift {
will-change: transform, opacity;
}
@media (max-width: 767px) {
.fx-splash-orb {
display: none;
}
.fx-card::before {
display: none;
}
.fx-hover-tilt,
.fx-hover-tilt:hover {
transform: none;
}
.glass-panel,
.solid-panel {
backdrop-filter: blur(8px);
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

102
src/layouts/AdminLayout.jsx Normal file
View File

@ -0,0 +1,102 @@
import { Link, useLocation, Outlet, useNavigate } from 'react-router-dom'
import { LayoutDashboard, Users, FileText, Smartphone, Settings, LogOut, Wallet, Landmark } from 'lucide-react'
import ThemeToggle from '../components/ThemeToggle'
import { clearAuthSession } from '../utils/authSession'
// Super Admin Specific Navigation
const adminNavItems = [
{ path: '/admin/dashboard', label: 'Platform Overview', icon: LayoutDashboard },
{ path: '/admin/users', label: 'User Management', icon: Users },
{ path: '/admin/logs', label: 'Global OTP Logs', icon: FileText },
{ path: '/admin/payments', label: 'Payment Approvals', icon: Wallet },
{ path: '/admin/payment-config', label: 'Payment Methods', icon: Landmark },
{ path: '/admin/whatsapp', label: 'WhatsApp Status', icon: Smartphone },
{ path: '/admin/settings', label: 'Platform Settings', icon: Settings },
]
export default function AdminLayout() {
const { pathname } = useLocation()
const navigate = useNavigate()
const handleLogout = () => {
clearAuthSession()
navigate('/login')
}
return (
<div className="flex h-screen bg-base overflow-hidden">
{/* Super Admin Sidebar */}
<aside className="w-[280px] flex flex-col p-6 shrink-0 relative z-10" style={{ background: 'var(--color-sidebar)', borderRight: '1px solid rgba(255,255,255,0.04)', boxShadow: '4px 0 24px rgba(0,0,0,0.35)' }}>
{/* Logo area with Admin Badge */}
<Link to="/admin/dashboard" className="flex flex-col gap-1 mb-10 no-underline px-2">
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg neu-raised-sm flex items-center justify-center bg-red-500/10">
<div className="w-3 h-3 bg-red-500 rounded-full shadow-[0_0_8px_rgba(239,68,68,0.8)]" />
</div>
<span className="font-extrabold text-xl text-text-primary tracking-tight">Veriflo</span>
</div>
<span className="text-[10px] font-black uppercase tracking-widest text-red-500 bg-red-500/10 w-fit px-2 py-0.5 rounded-sm ml-[40px]">
Super Admin
</span>
</Link>
{/* Navigation */}
<nav className="flex-1 flex flex-col gap-3">
{adminNavItems.map(({ path, label, icon: Icon }) => {
const isActive = pathname === path
return (
<Link
key={path}
to={path}
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-200 no-underline font-bold text-sm ${
isActive
? 'neu-inset text-red-500' /* Using red for admin accent */
: 'text-text-secondary hover:text-text-primary hover:bg-black/5'
}`}
>
<Icon size={18} className={isActive ? 'text-red-500' : 'text-text-muted'} />
{label}
</Link>
)
})}
</nav>
{/* Return to User View / Logout */}
<div className="flex flex-col gap-3">
<Link to="/overview" className="flex items-center justify-center gap-2 px-4 py-3 rounded-xl text-text-secondary hover:text-accent hover:neu-raised-sm transition-all text-sm font-bold bg-transparent no-underline">
Return to User App
</Link>
<button
onClick={handleLogout}
className="flex items-center gap-3 px-4 py-3 rounded-xl text-text-secondary hover:text-red-500 hover:neu-raised-sm transition-all text-sm font-bold bg-transparent border-none cursor-pointer w-full text-left"
>
<LogOut size={18} />
Sign Out
</button>
</div>
</aside>
{/* Main Content Area */}
<div className="flex-1 flex flex-col overflow-hidden relative z-0 bg-base lg:rounded-tl-3xl lg:border-t lg:border-l border-white/5">
{/* Top Header */}
<header className="h-[72px] flex items-center justify-between px-8 shrink-0 border-b border-white/5">
<h1 className="text-xl font-black text-text-primary m-0 capitalize flex items-center gap-3">
{pathname.split('/')[2] ? pathname.split('/')[2].replace('-', ' ') : 'Admin Dashboard'}
</h1>
<div className="flex items-center gap-3">
<ThemeToggle compact />
<div className="text-xs font-bold text-text-secondary">Owner Session Active</div>
</div>
</header>
{/* Page Content */}
<main className="flex-1 overflow-auto p-5 xl:p-8 2xl:p-10 pb-20">
<Outlet />
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,56 @@
import { Outlet, Link, useLocation } from 'react-router-dom'
import { Terminal } from 'lucide-react'
export default function AuthLayout() {
const location = useLocation()
const isWideAuthPage = location.pathname === '/signup' || location.pathname === '/forgot-password'
return (
<div
className="min-h-screen flex flex-col items-center justify-center p-6 relative overflow-hidden"
style={{ background: 'var(--color-sidebar)' }}
>
{/* Grid background */}
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage: 'linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px)',
backgroundSize: '40px 40px',
}}
/>
{/* Accent glow */}
<div
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] rounded-full pointer-events-none"
style={{ background: 'radial-gradient(circle, rgba(37,211,102,0.06) 0%, transparent 70%)' }}
/>
{/* Brand Header */}
<Link to="/" className="flex items-center gap-2.5 mb-8 no-underline relative z-10">
<div
className="w-10 h-10 rounded-xl flex items-center justify-center"
style={{ background: 'rgba(37,211,102,0.15)', border: '1px solid rgba(37,211,102,0.3)' }}
>
<Terminal size={20} className="text-accent" />
</div>
<span className="font-extrabold text-2xl tracking-tight text-text-primary">Veriflo</span>
</Link>
{/* Auth Card */}
<div
className={`w-full rounded-2xl relative z-10 ${isWideAuthPage ? 'max-w-[980px] p-5 md:p-7 lg:p-8' : 'max-w-[420px] p-8'}`}
style={{
background: 'var(--color-base)',
boxShadow: '0 20px 60px rgba(0,0,0,0.5), 0 0 0 1px rgba(255,255,255,0.04)',
}}
>
<Outlet />
</div>
{/* Bottom label */}
<p className="mt-8 text-[11px] font-bold text-text-muted relative z-10 tracking-widest uppercase">
Secure · Encrypted · Developer First
</p>
</div>
)
}

View File

@ -0,0 +1,650 @@
import { Link, useLocation, Outlet, useNavigate } from 'react-router-dom'
import { useEffect, useState } from 'react'
import { LayoutDashboard, Key, Settings, CreditCard, LogOut, Bell, User, MessageSquare, BarChart2, Code, Terminal, PanelLeftClose, PanelLeftOpen, AlertTriangle, Info, CheckCircle2 } from 'lucide-react'
import api from '../services/api'
import ThemeToggle from '../components/ThemeToggle'
import { clearAuthSession, getAuthUser, updateStoredUser } from '../utils/authSession'
const navItems = [
{ path: '/overview', label: 'Overview', icon: LayoutDashboard },
{ path: '/keys', label: 'API Keys', icon: Key },
{ path: '/config', label: 'Message Config', icon: MessageSquare },
{ path: '/analytics', label: 'Analytics', icon: BarChart2 },
{ path: '/billing', label: 'Billing', icon: CreditCard },
{ path: '/integrate', label: 'Integration', icon: Code },
{ path: '/settings', label: 'Settings', icon: Settings },
]
const PAGE_LABELS = {
overview: 'Overview',
keys: 'API Keys',
config: 'Message Config',
analytics: 'Analytics',
billing: 'Billing',
integrate: 'Integration',
settings: 'Settings',
}
export default function DashboardLayout() {
const { pathname } = useLocation()
const navigate = useNavigate()
const page = pathname.split('/')[1] || 'overview'
const [notificationOpen, setNotificationOpen] = useState(false)
const [profile, setProfile] = useState(() => getAuthUser())
const [environmentMode, setEnvironmentMode] = useState('sandbox')
const [planInfo, setPlanInfo] = useState({ trial_status: 'not_applied', active: false, trial_activated_at: null, started_at: null })
const [quotaInfo, setQuotaInfo] = useState({
sandbox_remaining: 0,
sandbox_total: 0,
live_remaining: 0,
live_total: 0,
})
const [systemConnected, setSystemConnected] = useState(true)
const [systemNotifications, setSystemNotifications] = useState([])
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const trialStartDate = planInfo.trial_activated_at || planInfo.started_at
const trialDays = trialStartDate
? Math.max(1, Math.ceil((Date.now() - new Date(trialStartDate).getTime()) / (1000 * 60 * 60 * 24)))
: null
const hasImportantAlert = systemNotifications.some((item) => item.level === 'critical' || item.level === 'warning')
const getDaysLeftInMonth = () => {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0)
return Math.max(0, end.getDate() - now.getDate())
}
const buildNotifications = ({ profileData, analyticsData, keysData, statusData, paymentsData }) => {
const items = []
const mode = profileData?.settings?.environment_mode || 'sandbox'
const plan = profileData?.plan || {}
const settings = profileData?.settings || {}
const stats = profileData?.stats || {}
const remaining = mode === 'live' ? (stats.live_remaining_credits || 0) : (stats.trial_remaining_credits || 0)
const total = mode === 'live' ? (stats.live_total_credits || 0) : (stats.trial_total_credits || 0)
const used = Math.max(0, total - remaining)
const usagePercent = total > 0 ? Math.round((used / total) * 100) : 0
const daysLeftInMonth = getDaysLeftInMonth()
const keys = Array.isArray(keysData?.keys) ? keysData.keys : []
const sandboxKeyCount = keys.filter((k) => (k.mode || 'sandbox') === 'sandbox').length
const liveKeyCount = keys.filter((k) => k.mode === 'live').length
const totalKeyCount = keys.length
if (!plan.active) {
items.push({
id: 'trial-inactive',
level: 'critical',
title: 'Free trial is not activated',
detail: 'Apply and activate trial to unlock key generation and OTP traffic.',
actionPath: '/billing',
actionLabel: 'Open Billing',
})
}
if (plan.trial_status === 'applied') {
items.push({
id: 'trial-awaiting-activation',
level: 'warning',
title: 'Free trial is waiting for activation',
detail: 'Complete activation to unlock API keys and OTP traffic.',
actionPath: '/billing',
actionLabel: 'Activate Trial',
})
}
if (remaining <= 0) {
items.push({
id: 'quota-exhausted',
level: 'critical',
title: `${mode === 'live' ? 'Live' : 'Sandbox'} quota exhausted`,
detail: `0/${total} credits remaining for this month.`,
actionPath: '/billing',
actionLabel: 'Manage Quota',
})
} else if (remaining <= Math.max(10, Math.round(total * 0.1))) {
items.push({
id: 'quota-low',
level: 'warning',
title: `${mode === 'live' ? 'Live' : 'Sandbox'} quota running low`,
detail: `${remaining}/${total} credits remaining.`,
actionPath: '/billing',
actionLabel: 'View Billing',
})
}
if (total > 0) {
const milestones = [90, 70, 50, 30]
const hit = milestones.find((point) => usagePercent >= point)
if (hit) {
items.push({
id: `quota-usage-${mode}-${hit}`,
level: hit >= 90 ? 'warning' : 'info',
title: `${mode === 'live' ? 'Live' : 'Sandbox'} usage reached ${hit}%`,
detail: `${used}/${total} consumed • ${remaining} left.`,
actionPath: '/billing',
actionLabel: 'Open Billing',
})
}
if (usagePercent >= 100) {
items.push({
id: `quota-usage-${mode}-100`,
level: 'critical',
title: `${mode === 'live' ? 'Live' : 'Sandbox'} quota reached 100%`,
detail: 'No credits left for the current month.',
actionPath: '/billing',
actionLabel: 'Add Credits',
})
}
if (usagePercent >= 70 && daysLeftInMonth >= 0) {
items.push({
id: `quota-time-left-${mode}`,
level: usagePercent >= 90 ? 'warning' : 'info',
title: `${daysLeftInMonth} day${daysLeftInMonth === 1 ? '' : 's'} left this month`,
detail: `${usagePercent}% quota already consumed in ${mode} mode.`,
actionPath: '/billing',
actionLabel: 'Plan Usage',
})
}
}
if (totalKeyCount === 0) {
items.push({
id: 'no-keys',
level: 'warning',
title: 'No API keys configured',
detail: 'Create sandbox/live keys to start integration traffic.',
actionPath: '/keys',
actionLabel: 'Create Key',
})
}
if (mode === 'live' && liveKeyCount === 0) {
items.push({
id: 'live-no-keys',
level: 'warning',
title: 'Live mode has no live API key',
detail: 'Switch to live keyset to avoid integration failures.',
actionPath: '/keys',
actionLabel: 'Create Live Key',
})
}
if (mode === 'sandbox' && sandboxKeyCount === 0) {
items.push({
id: 'sandbox-no-keys',
level: 'info',
title: 'No sandbox API key found',
detail: 'Create sandbox key to test your OTP flow safely.',
actionPath: '/keys',
actionLabel: 'Create Sandbox Key',
})
}
if (totalKeyCount >= 3) {
items.push({
id: 'keys-near-limit',
level: 'info',
title: 'API key capacity almost full',
detail: `${totalKeyCount}/4 keys are active. Clean up unused keys if needed.`,
actionPath: '/keys',
actionLabel: 'Manage Keys',
})
}
const failed24h = analyticsData?.windows?.last_24h?.failed || 0
if (failed24h >= 5) {
items.push({
id: 'failed-otp-critical',
level: 'critical',
title: 'High OTP failure volume detected',
detail: `${failed24h} failed requests in the last 24h.`,
actionPath: '/analytics',
actionLabel: 'Investigate',
})
} else if (failed24h > 0) {
items.push({
id: 'failed-otp-warning',
level: 'warning',
title: 'Failed OTP deliveries detected',
detail: `${failed24h} failed requests in the last 24h.`,
actionPath: '/analytics',
actionLabel: 'Open Analytics',
})
}
const latestStatus = analyticsData?.operational?.latest_request?.status || ''
if (latestStatus === 'failed' || latestStatus === 'failed_attempt') {
items.push({
id: 'latest-request-failed',
level: 'warning',
title: 'Latest OTP request ended in failure',
detail: 'Check recipient format, mode restriction, and logs.',
actionPath: '/analytics',
actionLabel: 'Open Logs',
})
}
const deliveryRate = Number(String(analyticsData?.lifetime?.delivery_rate || '0').replace('%', ''))
const totalSent = Number(analyticsData?.lifetime?.total_sent || 0)
if (Number.isFinite(deliveryRate) && totalSent >= 20 && deliveryRate < 70) {
items.push({
id: 'delivery-rate-low',
level: 'warning',
title: 'Delivery rate dropped below 70%',
detail: `Current delivery rate is ${deliveryRate.toFixed(1)}%.`,
actionPath: '/analytics',
actionLabel: 'Review Health',
})
}
if (mode === 'live' && !settings.webhook_url) {
items.push({
id: 'live-no-webhook',
level: 'info',
title: 'Webhook is not configured in live mode',
detail: 'Enable delivery callbacks for production monitoring.',
actionPath: '/config',
actionLabel: 'Configure Webhook',
})
}
const payments = Array.isArray(paymentsData?.payments) ? paymentsData.payments : []
const submittedCount = payments.filter((p) => p.status === 'submitted').length
const rejectedCount = payments.filter((p) => p.status === 'rejected').length
if (submittedCount > 0) {
items.push({
id: 'payment-awaiting-review',
level: 'info',
title: 'Payment request is waiting for admin review',
detail: `${submittedCount} submitted request(s) are in queue.`,
actionPath: '/billing',
actionLabel: 'Open Billing',
})
}
if (rejectedCount > 0) {
items.push({
id: 'payment-rejected',
level: 'warning',
title: 'One or more payment requests were rejected',
detail: `${rejectedCount} rejected request(s). Check admin note and retry if needed.`,
actionPath: '/billing',
actionLabel: 'Review Payments',
})
}
if (statusData && statusData.connected === false) {
items.push({
id: 'wa-offline',
level: 'critical',
title: 'WhatsApp engine offline',
detail: 'Message delivery engine is currently disconnected.',
actionPath: '/admin/whatsapp',
actionLabel: 'Check Status',
})
}
const priority = { critical: 3, warning: 2, info: 1 }
items.sort((a, b) => (priority[b.level] || 0) - (priority[a.level] || 0))
if (items.length === 0) {
items.push({
id: 'all-good',
level: 'info',
title: 'System looks healthy',
detail: 'No urgent issues detected right now.',
})
}
return items.slice(0, 10)
}
useEffect(() => {
let mounted = true
const syncProfile = async () => {
const [profileRes, analyticsRes, keysRes, statusRes, paymentsRes] = await Promise.allSettled([
api.get('/api/user/profile'),
api.get('/api/user/analytics/summary'),
api.get('/api/user/api-keys'),
api.get('/status'),
api.get('/api/user/billing/payments'),
])
if (!mounted) return
const profileData = profileRes.status === 'fulfilled' ? profileRes.value.data : null
const analyticsData = analyticsRes.status === 'fulfilled' ? analyticsRes.value.data : null
const keysData = keysRes.status === 'fulfilled' ? keysRes.value.data : null
const statusData = statusRes.status === 'fulfilled' ? statusRes.value.data : null
const paymentsData = paymentsRes.status === 'fulfilled' ? paymentsRes.value.data : null
if (profileData) {
setProfile(profileData.user || {})
setEnvironmentMode(profileData.settings?.environment_mode || 'sandbox')
setPlanInfo(profileData.plan || {})
setQuotaInfo({
sandbox_remaining: profileData.stats?.trial_remaining_credits || 0,
sandbox_total: profileData.stats?.trial_total_credits || 0,
live_remaining: profileData.stats?.live_remaining_credits || 0,
live_total: profileData.stats?.live_total_credits || 0,
})
updateStoredUser(profileData.user || {})
}
if (statusData && typeof statusData.connected === 'boolean') {
setSystemConnected(statusData.connected)
}
setSystemNotifications(buildNotifications({ profileData, analyticsData, keysData, statusData, paymentsData }))
}
const handleSettingsUpdated = (event) => {
if (!mounted) return
if (event.detail?.environment_mode) {
setEnvironmentMode(event.detail.environment_mode)
}
syncProfile()
}
const handleUsageUpdated = () => {
if (!mounted) return
syncProfile()
}
syncProfile().catch(() => {})
window.addEventListener('veriflo-settings-updated', handleSettingsUpdated)
window.addEventListener('veriflo-usage-updated', handleUsageUpdated)
return () => {
mounted = false
window.removeEventListener('veriflo-settings-updated', handleSettingsUpdated)
window.removeEventListener('veriflo-usage-updated', handleUsageUpdated)
}
}, [])
const handleLogout = () => {
clearAuthSession()
navigate('/login')
}
return (
<div className="flex h-screen overflow-hidden" style={{ background: 'var(--color-base)' }}>
{/* ── Sidebar ──────────────────────────────── */}
<aside
className={`shrink-0 flex flex-col py-6 transition-all duration-300 ${sidebarCollapsed ? 'w-[72px] px-2' : 'w-[220px] xl:w-[256px] px-4'}`}
style={{
background: 'var(--color-sidebar)',
borderRight: '1px solid rgba(255,255,255,0.04)',
boxShadow: '4px 0 24px rgba(0,0,0,0.35)',
}}
>
{/* Logo */}
<div className={`flex items-center mb-8 ${sidebarCollapsed ? 'justify-center' : 'justify-between px-2'}`}>
<Link to="/" className="flex items-center gap-2.5 no-underline">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: 'rgba(37,211,102,0.15)', border: '1px solid rgba(37,211,102,0.25)' }}
>
<Terminal size={15} className="text-accent" />
</div>
{!sidebarCollapsed && <span className="font-extrabold text-lg tracking-tight text-text-primary">Veriflo</span>}
</Link>
{!sidebarCollapsed && (
<button
type="button"
onClick={() => setSidebarCollapsed(true)}
className="w-8 h-8 neu-btn rounded-lg"
title="Collapse sidebar"
>
<PanelLeftClose size={15} />
</button>
)}
</div>
{sidebarCollapsed && (
<button
type="button"
onClick={() => setSidebarCollapsed(false)}
className="w-10 h-10 neu-btn rounded-xl mx-auto mb-6"
title="Expand sidebar"
>
<PanelLeftOpen size={15} />
</button>
)}
{/* Section label */}
{!sidebarCollapsed && (
<div className="px-2 mb-2">
<span className="text-[10px] font-black uppercase tracking-[0.15em] text-text-muted">Menu</span>
</div>
)}
{/* Nav */}
<nav className="flex-1 flex flex-col gap-0.5">
{navItems.map(({ path, label, icon: Icon }) => {
const isActive = pathname === path
return (
<Link
key={path}
to={path}
title={sidebarCollapsed ? label : undefined}
className={`flex items-center rounded-xl transition-all duration-200 no-underline text-sm font-bold ${sidebarCollapsed ? 'justify-center px-2 py-3' : 'gap-3 px-3 py-2.5'}`}
style={isActive ? {
background: 'rgba(37,211,102,0.1)',
color: '#25D366',
boxShadow: 'inset 2px 2px 5px rgba(0,0,0,0.3)',
borderLeft: '2px solid rgba(37,211,102,0.7)',
} : {
color: 'var(--color-text-secondary)',
}}
>
<Icon size={15} style={{ color: isActive ? '#25D366' : 'var(--color-text-muted)', flexShrink: 0 }} />
{!sidebarCollapsed && label}
{!sidebarCollapsed && isActive && <span className="ml-auto w-1.5 h-1.5 rounded-full bg-accent" style={{ boxShadow: '0 0 6px #25D366' }} />}
</Link>
)
})}
</nav>
{/* Environment Status */}
<div className={`mb-4 ${sidebarCollapsed ? '' : 'px-1'}`}>
{!sidebarCollapsed ? (
<div
className="rounded-2xl px-3 py-3"
style={{
background: environmentMode === 'live' ? 'rgba(37,211,102,0.1)' : 'rgba(255,255,255,0.04)',
border: environmentMode === 'live' ? '1px solid rgba(37,211,102,0.2)' : '1px solid rgba(255,255,255,0.06)',
}}
>
<div className="text-[10px] font-black text-text-muted mb-1 uppercase tracking-[0.15em]">Environment</div>
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-black text-text-primary">
{environmentMode === 'live' ? 'Live' : 'Sandbox'}
</div>
<div className="text-[11px] font-semibold text-text-secondary mt-0.5">
{environmentMode === 'live' ? 'External delivery enabled' : 'Registered number only'}
</div>
</div>
<div
className="px-2.5 py-1 rounded-full text-[10px] font-black uppercase tracking-[0.16em]"
style={{
background: environmentMode === 'live' ? 'rgba(37,211,102,0.14)' : 'rgba(255,255,255,0.06)',
color: environmentMode === 'live' ? '#25D366' : 'var(--color-text-secondary)',
border: environmentMode === 'live' ? '1px solid rgba(37,211,102,0.2)' : '1px solid rgba(255,255,255,0.08)',
}}
>
{environmentMode === 'live' ? 'Active' : 'Restricted'}
</div>
</div>
</div>
) : (
<div
title={`Environment: ${environmentMode === 'live' ? 'Live' : 'Sandbox'}`}
className="w-10 h-10 rounded-xl mx-auto flex items-center justify-center text-[10px] font-black uppercase"
style={{
background: environmentMode === 'live' ? 'rgba(37,211,102,0.1)' : 'rgba(255,255,255,0.05)',
color: environmentMode === 'live' ? '#25D366' : 'var(--color-text-secondary)',
border: environmentMode === 'live' ? '1px solid rgba(37,211,102,0.2)' : '1px solid rgba(255,255,255,0.08)',
}}
>
{environmentMode === 'live' ? 'L' : 'S'}
</div>
)}
</div>
{/* Logout */}
<button
onClick={handleLogout}
title={sidebarCollapsed ? 'Sign Out' : undefined}
className={`rounded-xl text-text-muted hover:text-red-400 transition-all text-sm font-bold bg-transparent border-none cursor-pointer w-full ${sidebarCollapsed ? 'flex justify-center px-2 py-3' : 'flex items-center gap-3 px-3 py-2.5'}`}
>
<LogOut size={15} />
{!sidebarCollapsed && 'Sign Out'}
</button>
</aside>
{/* ── Main Area ────────────────────────────── */}
<div className="flex-1 flex flex-col overflow-hidden">
{/* Top Header */}
<header
className="h-[60px] flex items-center justify-between px-8 shrink-0"
style={{ borderBottom: '1px solid rgba(255,255,255,0.05)' }}
>
<div className="flex items-center gap-2.5">
<span className="text-text-muted text-sm font-mono opacity-50">/</span>
<h1 className="text-sm font-black text-text-primary m-0 tracking-tight">
{PAGE_LABELS[page] || 'Dashboard'}
</h1>
<span
className="text-[10px] font-black px-2 py-0.5 rounded-full uppercase tracking-wider"
style={{ background: 'rgba(37,211,102,0.1)', color: '#25D366', border: '1px solid rgba(37,211,102,0.2)' }}
>
{environmentMode}
</span>
</div>
<div className="flex items-center gap-3 relative">
<ThemeToggle compact />
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-full"
style={{ background: 'rgba(37,211,102,0.08)', border: '1px solid rgba(37,211,102,0.15)' }}
>
<span className="w-1.5 h-1.5 rounded-full bg-accent" style={{ boxShadow: '0 0 6px #25D366' }} />
<span className="text-[11px] font-black text-accent">API Online</span>
</div>
<div
className="flex items-center gap-2 px-3 py-1.5 rounded-full"
style={{
background: systemConnected ? 'rgba(37,211,102,0.08)' : 'rgba(239,68,68,0.12)',
border: systemConnected ? '1px solid rgba(37,211,102,0.15)' : '1px solid rgba(239,68,68,0.22)',
}}
title="WhatsApp client connection status"
>
<span
className={`w-1.5 h-1.5 rounded-full ${systemConnected ? 'bg-accent' : 'bg-red-400'}`}
style={{ boxShadow: systemConnected ? '0 0 6px #25D366' : '0 0 6px #f87171' }}
/>
<span className={`text-[11px] font-black ${systemConnected ? 'text-accent' : 'text-red-400'}`}>
WhatsApp {systemConnected ? 'Connected' : 'Offline'}
</span>
</div>
<div className="hidden xl:flex items-center gap-2 px-3 py-1.5 rounded-full" style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)' }}>
<span className="text-[10px] font-black uppercase tracking-wider text-text-muted">Plan</span>
<span className={`text-[11px] font-black ${planInfo.active ? 'text-accent' : 'text-red-400'}`}>
{planInfo.active ? 'Trial Active' : 'Not Active'}
</span>
</div>
<div className="hidden xl:flex items-center gap-2 px-3 py-1.5 rounded-full" style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)' }}>
<span className="text-[10px] font-black uppercase tracking-wider text-text-muted">Quota</span>
<span className="text-[11px] font-black text-text-primary">
{environmentMode === 'live'
? `${quotaInfo.live_remaining}/${quotaInfo.live_total}`
: `${quotaInfo.sandbox_remaining}/${quotaInfo.sandbox_total}`}
</span>
</div>
<div className="hidden xl:flex items-center gap-2 px-3 py-1.5 rounded-full" style={{ background: 'rgba(255,255,255,0.05)', border: '1px solid rgba(255,255,255,0.08)' }}>
<span className="text-[10px] font-black uppercase tracking-wider text-text-muted">Trial</span>
<span className="text-[11px] font-black text-text-primary">
{trialDays ? `Day ${trialDays}` : 'Not started'}
</span>
</div>
<button
type="button"
onClick={() => setNotificationOpen((current) => !current)}
className="w-9 h-9 neu-btn rounded-full relative"
>
<Bell size={15} />
{hasImportantAlert ? <span className="absolute top-1.5 right-2 w-1.5 h-1.5 rounded-full bg-red-400" /> : null}
</button>
<button
type="button"
onClick={() => navigate('/settings')}
className="w-9 h-9 neu-btn rounded-full"
title="Open profile settings"
>
<User size={15} />
</button>
{notificationOpen && (
<div
className="absolute right-0 top-[52px] w-[320px] rounded-2xl p-3 z-20"
style={{
background: 'var(--color-surface)',
boxShadow: '0 20px 48px rgba(0,0,0,0.45), 0 0 0 1px rgba(255,255,255,0.06)',
}}
>
<div className="px-2 py-1 text-[11px] font-black uppercase tracking-[0.18em] text-text-muted">Notifications</div>
<div className="mt-2 flex flex-col gap-2 max-h-[340px] overflow-auto pr-1">
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>
<div className="text-sm font-bold text-text-primary">System Status</div>
<div className="text-xs font-semibold text-text-secondary mt-1">
API: <span className="text-text-primary">Online</span> WhatsApp: <span className={systemConnected ? 'text-accent' : 'text-red-400'}>{systemConnected ? 'Connected' : 'Disconnected'}</span>
</div>
<div className="text-xs font-semibold text-text-secondary mt-1">
Plan: <span className="text-text-primary">{planInfo.active ? 'Trial Active' : 'Not Active'}</span> Trial: <span className="text-text-primary">{trialDays ? `Day ${trialDays}` : 'Not started'}</span>
</div>
<div className="text-xs font-semibold text-text-secondary mt-1">
Quota: <span className="text-text-primary">
{environmentMode === 'live'
? `${quotaInfo.live_remaining}/${quotaInfo.live_total}`
: `${quotaInfo.sandbox_remaining}/${quotaInfo.sandbox_total}`}
</span>
</div>
</div>
{systemNotifications.map((item) => (
<div key={item.id} className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>
<div className="flex items-start gap-2">
{item.level === 'critical' ? <AlertTriangle size={14} className="text-red-400 mt-0.5" /> : null}
{item.level === 'warning' ? <Info size={14} className="text-yellow-400 mt-0.5" /> : null}
{item.level === 'info' ? <CheckCircle2 size={14} className="text-accent mt-0.5" /> : null}
<div className="min-w-0">
<div className={`text-sm font-bold ${item.level === 'critical' ? 'text-red-400' : 'text-text-primary'}`}>{item.title}</div>
<div className="text-xs font-semibold text-text-secondary mt-1">{item.detail}</div>
{item.actionPath ? (
<button
type="button"
onClick={() => {
setNotificationOpen(false)
navigate(item.actionPath)
}}
className="mt-2 text-xs font-bold text-accent bg-transparent border-none cursor-pointer p-0"
>
{item.actionLabel || 'Open'}
</button>
) : null}
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</header>
{/* Page Content */}
<main className="flex-1 overflow-auto p-5 xl:p-8 2xl:p-10 pb-20">
<Outlet />
</main>
</div>
</div>
)
}

10
src/main.jsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

569
src/pages/APIKeys.jsx Normal file
View File

@ -0,0 +1,569 @@
import { useEffect, useState } from 'react'
import { Plus, Copy, Check, Trash2, Loader2, AlertCircle, Download, KeyRound, CircleHelp, X } from 'lucide-react'
import api from '../services/api'
import { useNavigate } from 'react-router-dom'
import { useToast } from '../components/ToastProvider'
function formatDateTime(value) {
if (!value) return 'Never'
return new Date(value).toLocaleString()
}
function downloadKeyFile(name, apiKey) {
const content = `Veriflo API Key\nName: ${name}\nKey: ${apiKey}\n`
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `${name.replace(/[^a-z0-9-_]+/gi, '-').toLowerCase() || 'veriflo-key'}.txt`
document.body.appendChild(link)
link.click()
link.remove()
URL.revokeObjectURL(url)
}
function CodeSnippet({ code }) {
return (
<div className="neu-inset rounded-2xl p-4 font-mono text-sm text-text-primary overflow-auto">
<pre className="m-0 whitespace-pre-wrap">{code}</pre>
</div>
)
}
const HELP_TOPICS = [
{
id: 'how-keys-work',
title: 'How Keys Work',
subtitle: 'Header format and secret visibility',
contentType: 'list',
content: [
'Use your API key as Authorization: Bearer <key>.',
'Only the key hash is stored in database.',
'The full key secret is shown only once at creation.',
],
},
{
id: 'recommended-naming',
title: 'Recommended Naming',
subtitle: 'Keep keys easy to identify',
contentType: 'list',
content: [
'Production Backend',
'Staging API',
'Mobile App',
'Use names mapped to real environments/clients so revoking is easy.',
],
},
{
id: 'quick-start',
title: 'Quick Start',
subtitle: 'Minimal send request example',
contentType: 'code',
content: `curl -X POST http://localhost:3000/v1/otp/send \\
-H "Authorization: Bearer YOUR_API_KEY" \\
-H "Content-Type: application/json" \\
-d '{"phone":"+919999999999"}'`,
},
{
id: 'best-practices',
title: 'Best Practices',
subtitle: 'Naming, storage, and safety',
contentType: 'list',
content: [
'Use separate keys for production and staging.',
'Delete keys that are no longer mapped to an active integration.',
'Download the secret once and store it in server environment variables.',
'Watch per-key usage spikes to catch misconfigured clients early.',
],
},
{
id: 'tracking',
title: 'Key Tracking',
subtitle: 'How usage is attributed',
contentType: 'list',
content: [
'Each request maps to the exact API key from Authorization header.',
'Last used IP and last used timestamp are recorded per key.',
'OTP usage is aggregated per key for today, week, and month.',
'Cost is derived from OTP counts tied to each key.',
],
},
{
id: 'where-to-use',
title: 'Where To Use',
subtitle: 'Common key mapping patterns',
contentType: 'list',
content: [
'Production Backend: real customer OTP flows.',
'Staging / QA: pre-release verification flows.',
'Mobile Backend: app-specific auth pipelines.',
'Partner Integration: isolate partner traffic.',
],
},
{
id: 'integration-examples',
title: 'Integration Examples',
subtitle: 'SDK + HTTP usage',
contentType: 'multi-code',
content: [
{
label: 'Node.js SDK',
code: `const Veriflo = require('veriflo');
const client = Veriflo.init('YOUR_API_KEY');
const result = await client.sendOTP('+919999999999', { length: 6 });
console.log(result.request_id);`,
},
{
label: 'Python SDK',
code: `from veriflo import Veriflo
client = Veriflo(api_key="YOUR_API_KEY")
result = client.send_otp("+919999999999", length=6)
print(result.request_id)`,
},
{
label: 'Raw HTTP Header',
code: `Authorization: Bearer YOUR_API_KEY
Content-Type: application/json`,
},
{
label: 'Fetch / REST',
code: `await fetch('http://localhost:3000/v1/otp/send', {
method: 'POST',
headers: {
Authorization: 'Bearer YOUR_API_KEY',
'Content-Type': 'application/json'
},
body: JSON.stringify({ phone: '+919999999999' })
});`,
},
],
},
]
export default function APIKeys() {
const navigate = useNavigate()
const toast = useToast()
const [keys, setKeys] = useState([])
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [deletingId, setDeletingId] = useState(null)
const [copied, setCopied] = useState('')
const [newKey, setNewKey] = useState(null)
const [error, setError] = useState('')
const [keyName, setKeyName] = useState('')
const [keyMode, setKeyMode] = useState('sandbox')
const [keyLimitPerMode, setKeyLimitPerMode] = useState(2)
const [keyLimitTotal, setKeyLimitTotal] = useState(4)
const [trialStatus, setTrialStatus] = useState('not_applied')
const [activeHelpId, setActiveHelpId] = useState('')
const [showCreateModal, setShowCreateModal] = useState(false)
const totals = keys.reduce((acc, key) => {
acc.today += key.usage_today || 0
acc.week += key.usage_week || 0
acc.month += key.usage_month || 0
acc.costMonth += key.cost_month || 0
return acc
}, { today: 0, week: 0, month: 0, costMonth: 0 })
const sandboxKeys = keys.filter((key) => key.mode === 'sandbox')
const liveKeys = keys.filter((key) => key.mode === 'live')
useEffect(() => {
fetchKeys()
}, [])
const fetchKeys = async () => {
setLoading(true)
try {
const profileRes = await api.get('/api/user/profile')
setTrialStatus(profileRes.data.plan?.trial_status || 'not_applied')
const res = await api.get('/api/user/api-keys')
setKeys(res.data.keys || [])
setKeyLimitPerMode(res.data.limit_per_mode || 2)
setKeyLimitTotal(res.data.limit_total || 4)
} catch (err) {
const message = err.response?.data?.error || 'Failed to load keys'
setError(message)
toast.error(message)
} finally {
setLoading(false)
}
}
const handleGenerate = async () => {
if (!keyName.trim()) {
const message = 'Key name is required'
setError(message)
toast.error(message)
return false
}
setGenerating(true)
setError('')
try {
const res = await api.post('/api/user/api-key', { name: keyName.trim(), mode: keyMode })
setNewKey({
name: res.data.key.name,
value: res.data.apiKey,
mode: res.data.key.mode,
})
setKeyName('')
toast.success('API key generated successfully')
await fetchKeys()
return true
} catch (err) {
const message = err.response?.data?.error || 'Failed to generate key'
setError(message)
toast.error(message)
return false
} finally {
setGenerating(false)
}
}
const handleDelete = async (id) => {
setDeletingId(id)
setError('')
try {
await api.delete(`/api/user/api-key/${id}`)
if (newKey && keys.some((key) => key.id === id && key.name === newKey.name)) {
setNewKey(null)
}
toast.success('API key deleted successfully')
await fetchKeys()
} catch (err) {
const message = err.response?.data?.error || 'Failed to delete key'
setError(message)
toast.error(message)
} finally {
setDeletingId(null)
}
}
const handleCopy = async (value, id) => {
await navigator.clipboard.writeText(value)
setCopied(id)
toast.success('Copied to clipboard')
setTimeout(() => setCopied(''), 2000)
}
const activeHelp = HELP_TOPICS.find((topic) => topic.id === activeHelpId)
const currentModeCount = keyMode === 'sandbox' ? sandboxKeys.length : liveKeys.length
const createDisabled = generating || keys.length >= keyLimitTotal || currentModeCount >= keyLimitPerMode || trialStatus !== 'active'
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-accent" /></div>
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-text-primary tracking-tight m-0 mb-1">API Keys</h2>
<p className="text-text-secondary text-sm font-semibold m-0">
Separate sandbox/live keysets. Up to {keyLimitPerMode} per mode and {keyLimitTotal} total.
</p>
</div>
<button
type="button"
onClick={() => {
setError('')
setShowCreateModal(true)
}}
className="neu-btn-accent px-5 py-2.5 gap-2 flex items-center self-start md:self-auto"
>
<Plus size={16} />
Create New Key
</button>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(210px,1fr))] gap-4">
{[
{ label: 'Active Keys', value: `${keys.length}/${keyLimitTotal}`, hint: `${sandboxKeys.length} sandbox + ${liveKeys.length} live` },
{ label: 'OTP Usage Today', value: totals.today, hint: 'Requests tied to active keys' },
{ label: 'OTP Usage This Week', value: totals.week, hint: 'Rolling 7-day total' },
{ label: 'Monthly Key Cost', value: `${totals.costMonth.toFixed(2)}`, hint: 'Based on active key usage' },
].map((card) => (
<div key={card.label} className="neu-raised rounded-2xl p-5">
<div className="text-[11px] font-black uppercase tracking-[0.16em] text-text-muted mb-3">{card.label}</div>
<div className="text-3xl font-black text-text-primary tracking-tight">{card.value}</div>
<div className="text-xs font-semibold text-text-secondary mt-2">{card.hint}</div>
</div>
))}
</div>
<div className="grid gap-6 min-w-0">
<div className="flex flex-col gap-6 min-w-0">
{newKey && (
<div className="neu-raised rounded-2xl p-6" style={{ border: '1px solid rgba(37,211,102,0.2)' }}>
<div className="flex items-center gap-2 text-accent font-black mb-4 uppercase tracking-wider text-xs">
<Check size={16} /> New Key Generated
</div>
<div className="text-sm font-bold text-text-primary mb-3">{newKey.name}</div>
<div className="text-[11px] font-black uppercase tracking-[0.18em] text-accent mb-3">{newKey.mode || 'sandbox'}</div>
<div className="p-4 neu-inset rounded-xl font-mono text-sm break-all mb-4 text-text-primary">
{newKey.value}
</div>
<div className="flex flex-wrap gap-3">
<button type="button" onClick={() => handleCopy(newKey.value, 'new-key')} className="neu-btn px-4 py-2 gap-2">
{copied === 'new-key' ? <Check size={14} className="text-accent" /> : <Copy size={14} />}
Copy
</button>
<button type="button" onClick={() => downloadKeyFile(newKey.name, newKey.value)} className="neu-btn px-4 py-2 gap-2">
<Download size={14} />
Download .txt
</button>
</div>
<p className="text-xs font-bold text-red-500 m-0 mt-4 flex items-center gap-2">
<AlertCircle size={14} /> This key secret is shown only once.
</p>
</div>
)}
<div className="neu-raised rounded-2xl p-6 min-w-0">
<div className="flex items-center justify-between gap-3 mb-5">
<div>
<h3 className="text-lg font-black text-text-primary m-0 mb-1">Active Keys</h3>
<p className="text-sm font-semibold text-text-secondary m-0">Track name, creation, usage, last used time, and cost by key.</p>
</div>
<div className="text-xs font-black uppercase tracking-[0.18em] text-text-muted">
{keys.length} / {keyLimitTotal} active
</div>
</div>
{keys.length === 0 ? (
<div className="rounded-2xl p-6 text-center" style={{ background: 'rgba(255,255,255,0.03)' }}>
<KeyRound size={18} className="text-text-muted mx-auto mb-3" />
<div className="text-sm font-bold text-text-primary">No API keys yet</div>
<div className="text-xs font-semibold text-text-secondary mt-1">Create your first named key to start sending OTPs.</div>
</div>
) : (
<div className="overflow-x-auto overflow-y-hidden">
<div className="min-w-[980px]">
<div className="grid grid-cols-[1.4fr_0.8fr_1.1fr_1.1fr_0.8fr_0.8fr_0.8fr_0.8fr_0.8fr] gap-4 px-3 py-3 text-[11px] font-black uppercase tracking-[0.16em] text-text-muted border-b border-white/5">
<div>Key</div>
<div>Mode</div>
<div>Created</div>
<div>Last Used</div>
<div>Today</div>
<div>Week</div>
<div>Month</div>
<div>Cost</div>
<div>Actions</div>
</div>
{keys.map((key) => (
<div key={key.id} className="grid grid-cols-[1.4fr_0.8fr_1.1fr_1.1fr_0.8fr_0.8fr_0.8fr_0.8fr_0.8fr] gap-4 px-3 py-4 border-b border-white/5 items-center">
<div>
<div className="text-sm font-black text-text-primary">{key.name}</div>
<div className="text-xs font-semibold text-text-secondary mt-1">{key.prefix}</div>
<div className="text-[11px] font-semibold text-text-muted mt-1">Last IP: {key.last_used_ip || 'Never used'}</div>
</div>
<div>
<span className={key.mode === 'live' ? 'badge-success' : 'badge-neutral'}>{key.mode || 'sandbox'}</span>
</div>
<div className="text-xs font-semibold text-text-secondary">{formatDateTime(key.created_at)}</div>
<div className="text-xs font-semibold text-text-secondary">{formatDateTime(key.last_used_at)}</div>
<div className="text-sm font-black text-text-primary">{key.usage_today}</div>
<div className="text-sm font-black text-text-primary">{key.usage_week}</div>
<div className="text-sm font-black text-text-primary">{key.usage_month}</div>
<div className="text-xs font-bold text-accent">
{key.cost_today.toFixed(2)} / {key.cost_week.toFixed(2)} / {key.cost_month.toFixed(2)}
</div>
<div>
<button
type="button"
onClick={() => handleDelete(key.id)}
disabled={deletingId === key.id}
className="neu-btn px-3 py-2 gap-2 text-red-400 hover:text-red-300"
>
{deletingId === key.id ? <Loader2 size={14} className="animate-spin" /> : <Trash2 size={14} />}
Delete
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
<div className="neu-raised rounded-2xl p-6">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<div className="text-lg font-black text-text-primary">Help Topics</div>
<div className="text-sm font-semibold text-text-secondary mt-1">
Clean view by default. Open details only when needed.
</div>
</div>
<span className="badge-neutral">{HELP_TOPICS.length} topics</span>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{HELP_TOPICS.map((topic) => (
<button
key={topic.id}
type="button"
onClick={() => setActiveHelpId(topic.id)}
className="rounded-xl px-4 py-3 text-left border-none cursor-pointer transition-all"
style={{ background: 'rgba(255,255,255,0.03)', boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.05)' }}
>
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-black text-text-primary">{topic.title}</div>
<CircleHelp size={14} className="text-accent shrink-0" />
</div>
<div className="text-xs font-semibold text-text-secondary mt-1">{topic.subtitle}</div>
</button>
))}
</div>
</div>
</div>
</div>
{activeHelp ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button
type="button"
onClick={() => setActiveHelpId('')}
className="absolute inset-0 bg-black/70 border-none cursor-pointer"
aria-label="Close help"
/>
<div className="relative w-full max-w-[900px] max-h-[85vh] overflow-auto rounded-2xl p-6 neu-raised" style={{ background: 'var(--color-base)' }}>
<div className="flex items-start justify-between gap-4 mb-5">
<div>
<div className="text-xl font-black text-text-primary">{activeHelp.title}</div>
<div className="text-sm font-semibold text-text-secondary mt-1">{activeHelp.subtitle}</div>
</div>
<button type="button" onClick={() => setActiveHelpId('')} className="neu-btn p-2" aria-label="Close">
<X size={16} />
</button>
</div>
{activeHelp.contentType === 'code' && <CodeSnippet code={activeHelp.content} />}
{activeHelp.contentType === 'list' && (
<div className="grid gap-3">
{activeHelp.content.map((item) => (
<div key={item} className="rounded-xl px-4 py-3 text-sm font-semibold text-text-secondary" style={{ background: 'rgba(255,255,255,0.03)' }}>
{item}
</div>
))}
</div>
)}
{activeHelp.contentType === 'multi-code' && (
<div className="grid gap-5 lg:grid-cols-2">
{activeHelp.content.map((item) => (
<div key={item.label} className="flex flex-col gap-2">
<div className="text-sm font-black text-text-primary">{item.label}</div>
<CodeSnippet code={item.code} />
</div>
))}
</div>
)}
</div>
</div>
) : null}
{showCreateModal ? (
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="absolute inset-0 bg-black/70 border-none cursor-pointer"
aria-label="Close create key modal"
/>
<div className="relative w-full max-w-[560px] rounded-2xl p-6 neu-raised max-h-[88vh] overflow-auto" style={{ background: 'var(--color-base)' }}>
<div className="flex items-start justify-between gap-3 mb-4">
<div>
<h3 className="text-lg font-black text-text-primary m-0">Create New Key</h3>
<p className="text-sm font-semibold text-text-secondary m-0 mt-1">
The secret is shown only once. Copy it or download it immediately.
</p>
</div>
<button type="button" onClick={() => setShowCreateModal(false)} className="neu-btn p-2" aria-label="Close">
<X size={16} />
</button>
</div>
<div className="flex flex-col gap-2 mb-4">
<label className="text-xs font-black uppercase tracking-wider text-text-secondary px-1">Key Name</label>
<input
type="text"
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
placeholder="e.g. Production Backend"
className="neu-input"
/>
</div>
<div className="flex flex-col gap-2 mb-5">
<label className="text-xs font-black uppercase tracking-wider text-text-secondary px-1">Key Mode</label>
<div className="neu-inset-sm rounded-lg p-1 flex gap-1">
{['sandbox', 'live'].map((mode) => (
<button
key={mode}
type="button"
onClick={() => setKeyMode(mode)}
className={`flex-1 py-2 text-xs font-black uppercase rounded-md border-none cursor-pointer ${
keyMode === mode ? 'neu-raised text-accent' : 'bg-transparent text-text-muted'
}`}
>
{mode}
</button>
))}
</div>
</div>
<button
type="button"
onClick={async () => {
const ok = await handleGenerate()
if (ok) setShowCreateModal(false)
}}
disabled={createDisabled}
className="neu-btn-accent px-6 py-3 gap-2 flex items-center justify-center font-bold w-full"
>
{generating ? <Loader2 size={18} className="animate-spin" /> : <Plus size={18} />}
Generate {keyMode} Key
</button>
{trialStatus !== 'active' && (
<div className="rounded-xl p-3 text-sm font-bold mt-4" style={{ background: 'rgba(239,68,68,0.1)', color: '#f87171', border: '1px solid rgba(239,68,68,0.2)' }}>
Activate free trial first to create API keys.
<button
type="button"
className="ml-2 underline bg-transparent border-none cursor-pointer text-inherit font-black"
onClick={() => {
setShowCreateModal(false)
navigate('/billing')
}}
>
Open Billing
</button>
</div>
)}
{keys.length >= keyLimitTotal && (
<div className="rounded-xl p-3 text-sm font-bold mt-4" style={{ background: 'rgba(251,191,36,0.1)', color: '#fbbf24', border: '1px solid rgba(251,191,36,0.2)' }}>
You already have {keyLimitTotal} active keys. Delete one before creating another.
</div>
)}
{currentModeCount >= keyLimitPerMode && (
<div className="rounded-xl p-3 text-sm font-bold mt-4" style={{ background: 'rgba(251,191,36,0.1)', color: '#fbbf24', border: '1px solid rgba(251,191,36,0.2)' }}>
{keyMode === 'sandbox' ? 'Sandbox' : 'Live'} keys reached limit ({keyLimitPerMode}). Delete one to continue.
</div>
)}
{error && (
<div className="rounded-xl p-3 text-sm font-bold mt-4" style={{ background: 'rgba(239,68,68,0.1)', color: '#f87171', border: '1px solid rgba(239,68,68,0.2)' }}>
{error}
</div>
)}
</div>
</div>
) : null}
</div>
)
}

88
src/pages/About.jsx Normal file
View File

@ -0,0 +1,88 @@
import Footer from '../components/Footer'
export default function About() {
return (
<>
<div className="min-h-screen pt-[72px] bg-base">
<div className="max-w-[900px] mx-auto px-6 md:px-12 lg:px-20 py-20">
<h1 className="text-[2.5rem] font-black text-white mb-4 tracking-tight">About Veriflo</h1>
<p className="text-text-secondary text-[1.1rem] mb-12">Making OTP verification simple, fast, and reliable</p>
<div className="space-y-12 text-text-secondary leading-relaxed">
<section>
<h2 className="text-xl font-bold text-white mb-4">Our Story</h2>
<p>
Veriflo was built by MetatronCube Software Solutions with one goal: to make WhatsApp OTP verification as simple as possible for developers. We noticed that most authentication solutions were over-engineered, expensive, or unreliable. We decided to change that.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">Why Veriflo?</h2>
<p className="mb-4">We built Veriflo because developers deserve better:</p>
<ul className="list-disc list-inside space-y-2 ml-2">
<li><strong className="text-white">Simple Integration</strong> Ship in minutes, not hours. REST API or SDKs in Node.js, Python, and more.</li>
<li><strong className="text-white">Affordable</strong> Pay only for what you send. No monthly fees, no minimum commitments.</li>
<li><strong className="text-white">Reliable</strong> 98% delivery rate with WhatsApp's direct integration ensures your users always get their codes.</li>
<li><strong className="text-white">Fast</strong> Average delivery in under 2 seconds means better user experience.</li>
<li><strong className="text-white">Developer-First</strong> Clear documentation, ready-made SDKs, and live analytics so you always know what's happening.</li>
</ul>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">Our Values</h2>
<div className="grid gap-6 md:grid-cols-2 mt-6">
<div className="glass-panel p-6 rounded-xl border border-white/10">
<h3 className="text-white font-bold mb-2 text-lg">Simplicity First</h3>
<p className="text-[0.95rem]">
We believe difficult problems should have simple solutions. Every decision we make is guided by how it affects the developer experience.
</p>
</div>
<div className="glass-panel p-6 rounded-xl border border-white/10">
<h3 className="text-white font-bold mb-2 text-lg">Transparent Pricing</h3>
<p className="text-[0.95rem]">
No hidden fees, no surprise charges. You know exactly what you're paying for, and we keep it affordable at scale.
</p>
</div>
<div className="glass-panel p-6 rounded-xl border border-white/10">
<h3 className="text-white font-bold mb-2 text-lg">Reliability Matters</h3>
<p className="text-[0.95rem]">
Your users depend on OTP codes. We take 98% delivery rates seriously and work tirelessly to maintain them.
</p>
</div>
<div className="glass-panel p-6 rounded-xl border border-white/10">
<h3 className="text-white font-bold mb-2 text-lg">Always Available</h3>
<p className="text-[0.95rem]">
Our team is here to help. Fast support, detailed documentation, and real humans who understand your needs.
</p>
</div>
</div>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">Built for Developers, By Developers</h2>
<p>
The Veriflo team consists of experienced engineers who've built authentication systems at scale. We know the pain points because we've lived them. Every feature we add, every API endpoint we design, and every documentation page we write is done with developers in mind.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">Getting Started</h2>
<p>
Ready to integrate WhatsApp OTP verification? Get {" "}
<a href="/docs" className="text-accent font-bold hover:underline no-underline">
started with our documentation
</a>
, grab a starter file for your language, and ship in minutes. Questions? {" "}
<a href="/contact" className="text-accent font-bold hover:underline no-underline">
reach out to our team
</a>
.
</p>
</section>
</div>
</div>
</div>
<Footer />
</>
)
}

186
src/pages/Analytics.jsx Normal file
View File

@ -0,0 +1,186 @@
import { useState, useEffect } from 'react'
import { LineChart, Line, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
import { Download, RefreshCw, AlertCircle, Loader2 } from 'lucide-react'
import api from '../services/api'
export default function Analytics() {
const [data, setData] = useState(null)
const [logs, setLogs] = useState([])
const [pagination, setPagination] = useState({ total: 0, page: 1, page_size: 20, total_pages: 1, has_prev: false, has_next: false })
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetchData()
}, [page, pageSize])
const fetchData = async () => {
setLoading(true)
try {
const [summaryRes, logsRes] = await Promise.all([
api.get('/api/user/analytics/summary'),
api.get(`/api/user/analytics/logs?page=${page}&page_size=${pageSize}`)
])
setData(summaryRes.data)
setLogs(logsRes.data.logs)
setPagination(logsRes.data.pagination || { total: 0, page: 1, page_size: pageSize, total_pages: 1, has_prev: false, has_next: false })
} catch (err) {
console.error("Failed to fetch analytics", err)
} finally {
setLoading(false)
}
}
if (loading || !data) {
return (
<div className="flex justify-center items-center min-h-[50vh]">
<Loader2 className="animate-spin text-accent" size={32} />
</div>
)
}
return (
<div className="flex flex-col gap-8">
{/* Header */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-text-primary tracking-tight m-0 mb-1">Usage & Analytics</h2>
<p className="text-text-secondary text-sm font-semibold m-0">
Monitor API performance and OTP delivery rates over time.
</p>
</div>
<div className="flex gap-3">
<button onClick={fetchData} className="neu-btn px-4 py-2 gap-2 flex items-center">
<RefreshCw size={16} className={loading ? 'animate-spin' : ''} /> Refresh
</button>
<button className="neu-btn px-4 py-2 gap-2 text-text-primary flex items-center hover:text-accent">
<Download size={16} /> Export CSV
</button>
</div>
</div>
{/* Main Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* OTPs Sent Line Chart */}
<div className="neu-raised rounded-2xl p-6 lg:p-8">
<h3 className="text-base font-black text-text-primary mb-6">Volume Sent (Last 7 Days)</h3>
<div className="h-[280px] w-full">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data.chart_data} margin={{ top: 5, right: 0, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-shadow-dark)" opacity={0.2} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12, fontWeight: 700 }} dy={10} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12, fontWeight: 700 }} />
<Tooltip
contentStyle={{ backgroundColor: 'var(--color-base)', borderRadius: '12px', border: 'none', boxShadow: '8px 8px 16px var(--color-shadow-dark)' }}
itemStyle={{ fontWeight: 800 }}
labelStyle={{ color: 'var(--color-text-muted)', marginBottom: '4px', fontWeight: 700, fontSize: '12px' }}
/>
<Line type="monotone" name="Total Sent" dataKey="total" stroke="var(--color-accent)" strokeWidth={4} dot={{ r: 4, fill: 'var(--color-accent)', strokeWidth: 2, stroke: 'var(--color-base)' }} activeDot={{ r: 6, stroke: 'var(--color-base)', strokeWidth: 3 }} />
</LineChart>
</ResponsiveContainer>
</div>
</div>
{/* Success vs Failed Bar Chart */}
<div className="neu-raised rounded-2xl p-6 lg:p-8">
<h3 className="text-base font-black text-text-primary mb-6">Delivery Status Breakdown</h3>
<div className="h-[280px] w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data.chart_data} margin={{ top: 5, right: 0, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-shadow-dark)" opacity={0.2} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12, fontWeight: 700 }} dy={10} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12, fontWeight: 700 }} />
<Tooltip
cursor={{ fill: 'rgba(0,0,0,0.03)' }}
contentStyle={{ backgroundColor: 'var(--color-base)', borderRadius: '12px', border: 'none', boxShadow: '8px 8px 16px var(--color-shadow-dark)' }}
itemStyle={{ fontWeight: 800 }}
/>
<Legend iconType="circle" wrapperStyle={{ fontSize: '12px', fontWeight: 700, paddingTop: '10px' }} />
<Bar dataKey="delivered" name="Delivered" stackId="a" fill="var(--color-accent)" radius={[0, 0, 4, 4]} />
<Bar dataKey="failed" name="Failed" stackId="a" fill="#ef4444" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
{/* Detailed Log Table */}
<div className="neu-raised rounded-2xl p-6 lg:p-8">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 mb-6">
<h3 className="text-base font-black text-text-primary m-0">Recent Request Logs</h3>
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-text-muted uppercase tracking-wider">Rows</span>
<select
value={pageSize}
onChange={(e) => {
setPage(1)
setPageSize(Number(e.target.value))
}}
className="neu-input !w-[92px] !py-1.5 !px-2 text-xs"
>
{[10, 20, 50].map((size) => (
<option key={size} value={size}>{size}</option>
))}
</select>
</div>
</div>
<div className="flex flex-col gap-3">
<div className="grid grid-cols-12 px-4 py-2 text-xs font-bold text-text-muted uppercase tracking-widest border-b border-white/5">
<div className="col-span-3">Request ID</div>
<div className="col-span-3">Phone Number</div>
<div className="col-span-3">Timestamp</div>
<div className="col-span-3 text-right">Status</div>
</div>
{logs.length === 0 ? (
<div className="text-center py-10 text-text-muted font-bold">No requests found yet.</div>
) : (
logs.map((log) => (
<div key={log.request_id} className="grid grid-cols-12 items-center px-4 py-3 neu-inset-sm rounded-xl bg-base text-sm font-semibold text-text-secondary">
<div className="col-span-3 font-mono text-xs">{log.request_id}</div>
<div className="col-span-3 text-text-primary">{log.phone}</div>
<div className="col-span-3 text-xs">{new Date(log.created_at).toLocaleString()}</div>
<div className="col-span-3 flex justify-end">
<span className={log.status === 'failed' ? 'badge-error' : 'badge-success'}>
{log.status === 'failed' ? <AlertCircle size={12} /> : null}
{log.status}
</span>
</div>
</div>
))
)}
</div>
<div className="mt-5 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div className="text-xs font-semibold text-text-secondary">
Showing page <span className="text-text-primary font-black">{pagination.page}</span> of <span className="text-text-primary font-black">{pagination.total_pages}</span> Total logs: <span className="text-text-primary font-black">{pagination.total}</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPage((current) => Math.max(1, current - 1))}
disabled={!pagination.has_prev || loading}
className="neu-btn px-3 py-1.5 text-xs disabled:opacity-40"
>
Previous
</button>
<button
type="button"
onClick={() => setPage((current) => current + 1)}
disabled={!pagination.has_next || loading}
className="neu-btn px-3 py-1.5 text-xs disabled:opacity-40"
>
Next
</button>
</div>
</div>
</div>
</div>
)
}

398
src/pages/Billing.jsx Normal file
View File

@ -0,0 +1,398 @@
import { useState, useEffect } from 'react'
import { CreditCard, Loader2, CircleHelp, X } from 'lucide-react'
import api from '../services/api'
import { useToast } from '../components/ToastProvider'
import { getAuthUser } from '../utils/authSession'
import { useNavigate } from 'react-router-dom'
export default function Billing() {
const navigate = useNavigate()
const toast = useToast()
const [stats, setStats] = useState(null)
const [plan, setPlan] = useState(null)
const [limits, setLimits] = useState(null)
const [billingConfig, setBillingConfig] = useState(null)
const [payments, setPayments] = useState([])
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState(false)
const [payingCode, setPayingCode] = useState('')
const [selectedPackageCode, setSelectedPackageCode] = useState('growth')
const [showQuotaHelp, setShowQuotaHelp] = useState(false)
const [showPurchaseHelp, setShowPurchaseHelp] = useState(false)
const [showHistoryHelp, setShowHistoryHelp] = useState(false)
useEffect(() => {
fetchBillingInfo()
}, [])
const fetchBillingInfo = async () => {
try {
const [profileRes, paymentsRes, billingConfigRes] = await Promise.all([
api.get('/api/user/profile'),
api.get('/api/user/billing/payments'),
api.get('/api/user/billing/config'),
])
setStats(profileRes.data.stats)
setPlan(profileRes.data.plan)
setLimits(profileRes.data.limits)
setBillingConfig(billingConfigRes.data.config || null)
setPayments(paymentsRes.data.payments || [])
} catch (err) {
console.error("Failed to fetch billing info", err)
} finally {
setLoading(false)
}
}
const handleApply = async () => {
const confirmed = window.confirm(
[
'Activate Free Trial?',
'',
'By continuing, you agree to the free trial rules:',
`1. Sandbox trial quota: ${stats?.trial_total_credits || 500} OTPs/month (self WhatsApp number only).`,
`2. Live starter quota: ${stats?.live_total_credits || 100} OTPs/month (can send to other valid numbers).`,
'3. Sandbox and Live use separate API key sets and separate usage counters.',
'4. Misuse, spam, or policy violations may suspend trial access.',
'',
'Click OK to agree and activate now.'
].join('\n')
)
if (!confirmed) {
return
}
setActionLoading(true)
try {
await api.post('/api/user/trial/apply')
const res = await api.post('/api/user/trial/activate')
toast.success(res.data.message || 'Free trial activated successfully')
await fetchBillingInfo()
} catch (err) {
toast.error(err.response?.data?.error || 'Failed to activate free trial')
} finally {
setActionLoading(false)
}
}
const handleActivate = async () => {
setActionLoading(true)
try {
const res = await api.post('/api/user/trial/activate')
toast.success(res.data.message || 'Free trial activated')
await fetchBillingInfo()
} catch (err) {
toast.error(err.response?.data?.error || 'Failed to activate free trial')
} finally {
setActionLoading(false)
}
}
const packages = [
{ code: 'starter', label: 'Starter Kit', credits: 100, amount: 20, save: '', popular: false },
{ code: 'growth', label: 'Growth Bundle', credits: 500, amount: 90, save: '10% off', popular: true },
{ code: 'volume', label: 'Volume Pack', credits: 1000, amount: 170, save: '15% off', popular: false },
{ code: 'enterprise', label: 'Enterprise', credits: 5000, amount: 800, save: '20% off', popular: false },
]
const selectedPackage = packages.find((pkg) => pkg.code === selectedPackageCode) || null
const handlePayViaUpi = async (pkg) => {
if (!billingConfig?.payments_enabled) {
toast.error('Payments are disabled by admin right now')
return
}
if (!billingConfig?.upi_available) {
toast.error('UPI is not configured by admin yet')
return
}
setPayingCode(pkg.code)
try {
const res = await api.post('/api/user/billing/payment-request', {
package_name: pkg.label,
credits: pkg.credits,
amount_inr: pkg.amount,
})
const payment = res.data.payment
if (payment?.upi_link) {
window.open(payment.upi_link, '_blank', 'noopener,noreferrer')
}
toast.info('UPI intent opened. Submit UTR after payment.')
const utr = window.prompt(`Enter UTR for ${payment.request_ref} after payment (optional now):`, '')
if (utr !== null) {
await api.post(`/api/user/billing/payment-request/${payment.request_ref}/submit`, { utr: utr || '' })
toast.success('Payment submitted for admin approval')
}
await fetchBillingInfo()
} catch (err) {
toast.error(err.response?.data?.error || 'Failed to start UPI payment')
} finally {
setPayingCode('')
}
}
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-accent" /></div>
const isLiveMode = plan?.mode === 'live'
const activeTotal = isLiveMode ? (stats?.live_total_credits || 0) : (stats?.trial_total_credits || 0)
const activeRemaining = isLiveMode ? (stats?.live_remaining_credits || 0) : (stats?.trial_remaining_credits || 0)
const activeUsed = isLiveMode ? (stats?.live_used_this_month || 0) : (stats?.trial_used_credits || 0)
const activeLabel = isLiveMode ? 'Live Credits Remaining' : 'Sandbox Credits Remaining'
const sandboxBase = limits?.sandbox_monthly_message_limit_base || 500
const liveBase = limits?.live_monthly_message_limit_base || 100
const liveBonus = stats?.live_bonus_credits || 0
const activeProgress = activeTotal ? (activeUsed / activeTotal) * 100 : 0
const currentUser = getAuthUser()
const isAdminUser = currentUser?.role === 'admin'
const formatDate = (value) => {
if (!value) return '-'
const parsed = new Date(value)
if (Number.isNaN(parsed.getTime())) return '-'
return parsed.toLocaleString()
}
return (
<div className="flex flex-col gap-6">
<div>
<h2 className="text-2xl font-black text-text-primary tracking-tight m-0 mb-1">Plans & Billing</h2>
<p className="text-text-secondary text-sm font-semibold m-0">
Manage quotas, purchase live credits, and track approvals in one place.
</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-12 gap-6 items-start">
<div className="xl:col-span-8 flex flex-col gap-6">
<div className="neu-raised rounded-2xl p-6">
<div className="flex flex-wrap items-center justify-between gap-3 mb-5">
<div>
<div className="text-xs font-black uppercase tracking-widest text-text-muted">Current Plan</div>
<div className="text-2xl font-black text-text-primary mt-1">{plan?.name || 'Free Trial'}</div>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setShowQuotaHelp(true)} className="neu-btn w-8 h-8 rounded-full" title="Quota help">
<CircleHelp size={13} />
</button>
<span className="badge-neutral">{plan?.label || 'Sandbox / Free Trial'}</span>
<span className="badge-neutral">{isLiveMode ? 'Live mode' : 'Sandbox mode'}</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="neu-inset rounded-xl p-4">
<div className="text-[11px] font-black uppercase tracking-wider text-text-muted mb-2">{activeLabel}</div>
<div className="text-2xl font-black text-accent">{activeRemaining} / {activeTotal}</div>
<div className="text-[11px] font-semibold text-text-secondary mt-1">Used: {activeUsed}</div>
<div className="mt-3 h-2 rounded-full overflow-hidden" style={{ background: 'rgba(255,255,255,0.06)' }}>
<div className="h-full" style={{ width: `${activeProgress}%`, background: 'linear-gradient(90deg, var(--color-accent) 0%, var(--color-accent-dark) 100%)' }} />
</div>
</div>
<div className="neu-inset rounded-xl p-4">
<div className="text-[11px] font-black uppercase tracking-wider text-text-muted mb-2">Sandbox Bucket</div>
<div className="text-xl font-black text-text-primary">{stats?.trial_remaining_credits || 0} / {stats?.trial_total_credits || 0}</div>
<div className="text-[11px] font-semibold text-text-secondary mt-1">Base: {sandboxBase} Used: {stats?.trial_used_credits || 0}</div>
</div>
<div className="neu-inset rounded-xl p-4">
<div className="text-[11px] font-black uppercase tracking-wider text-text-muted mb-2">Live Bucket</div>
<div className="text-xl font-black text-text-primary">{stats?.live_remaining_credits || 0} / {stats?.live_total_credits || 0}</div>
<div className="text-[11px] font-semibold text-text-secondary mt-1">Base: {liveBase} + Purchased: {liveBonus}</div>
</div>
</div>
<div className="text-xs font-semibold text-text-secondary mt-4">
Sandbox and Live are separate counters. Sandbox is self-number only. Purchased packs add credits only to Live.
</div>
<div className="mt-5">
{plan?.trial_status === 'not_applied' && (
<button onClick={handleApply} disabled={actionLoading} className="neu-btn-accent px-6 py-3 w-full md:w-auto">
{actionLoading ? <Loader2 size={16} className="animate-spin" /> : 'Apply Free Trial'}
</button>
)}
{plan?.trial_status === 'applied' && (
<button onClick={handleActivate} disabled={actionLoading} className="neu-btn-accent px-6 py-3 w-full md:w-auto">
{actionLoading ? <Loader2 size={16} className="animate-spin" /> : 'Activate Free Trial'}
</button>
)}
{plan?.trial_status === 'active' && (
<div className="flex flex-wrap items-center gap-2">
<button className="neu-btn px-6 py-3 w-full md:w-auto">Free Trial Active</button>
{isAdminUser ? (
<button
type="button"
onClick={() => navigate('/admin/payment-config')}
className="neu-btn px-6 py-3 w-full md:w-auto"
>
Manage Payment Methods
</button>
) : null}
</div>
)}
</div>
</div>
</div>
<div className="xl:col-span-4">
<div className="neu-raised rounded-2xl p-6 xl:sticky xl:top-6">
<div className="flex items-center justify-between gap-2 mb-2">
<h3 className="text-base font-black text-text-primary m-0">Buy Credits</h3>
<button type="button" onClick={() => setShowPurchaseHelp(true)} className="neu-btn w-8 h-8 rounded-full" title="Purchase help">
<CircleHelp size={13} />
</button>
</div>
<p className="text-sm text-text-secondary font-semibold mb-5">Select bundle and pay via UPI.</p>
<div className={`rounded-xl p-3 mb-4 ${billingConfig?.payments_enabled ? 'bg-accent/5' : 'bg-red-500/10'}`}>
<div className={`text-xs font-black uppercase tracking-wider ${billingConfig?.payments_enabled ? 'text-accent' : 'text-red-400'}`}>
{billingConfig?.payments_enabled ? 'Payments Enabled' : 'Payments Disabled'}
</div>
<div className="text-[11px] font-semibold text-text-secondary mt-1">
{billingConfig?.payment_note || 'Pay via UPI and submit UTR for admin approval.'}
</div>
</div>
<div className="neu-inset rounded-xl overflow-hidden">
<div className="grid grid-cols-[20px_minmax(0,1fr)_70px_70px] gap-2 px-3 py-2 border-b border-white/5 text-[10px] font-black uppercase tracking-wider text-text-muted">
<div />
<div>Package</div>
<div className="text-right">OTPs</div>
<div className="text-right">Price</div>
</div>
<div className="flex flex-col">
{packages.map((pkg) => {
const isSelected = selectedPackageCode === pkg.code
return (
<button
type="button"
key={pkg.code}
onClick={() => setSelectedPackageCode(pkg.code)}
className={`grid grid-cols-[20px_minmax(0,1fr)_70px_70px] gap-2 px-3 py-2.5 text-left border-none cursor-pointer border-b last:border-b-0 border-white/5 ${
isSelected ? 'bg-accent/10' : 'bg-transparent hover:bg-white/5'
}`}
>
<div className="flex items-center justify-center">
<span className={`w-3 h-3 rounded-full border ${isSelected ? 'border-accent bg-accent' : 'border-white/40'}`} />
</div>
<div className="min-w-0">
<div className="text-sm font-black text-text-primary truncate">{pkg.label}</div>
<div className="text-[10px] font-bold text-text-muted">
{pkg.popular ? 'Most Popular' : pkg.save || 'Standard'}
</div>
</div>
<div className="text-sm font-black text-text-primary text-right">{pkg.credits}</div>
<div className="text-sm font-black text-accent text-right">{pkg.amount}</div>
</button>
)
})}
</div>
</div>
<div className="text-xs font-semibold text-text-secondary mt-3">
Selected: <span className="font-black text-text-primary">{selectedPackage?.label || '-'}</span>
{selectedPackage ? `${selectedPackage.credits} OTP credits • ₹${selectedPackage.amount}` : ''}
</div>
<button
type="button"
disabled={!selectedPackage || payingCode === selectedPackage?.code}
onClick={() => selectedPackage && handlePayViaUpi(selectedPackage)}
className="neu-btn-accent py-3 px-4 w-full mt-4"
>
{selectedPackage && payingCode === selectedPackage.code ? (
<span className="inline-flex items-center gap-2"><Loader2 size={14} className="animate-spin" /> Processing...</span>
) : (
`Purchase Selected${selectedPackage ? ` • ₹${selectedPackage.amount}` : ''}`
)}
</button>
</div>
</div>
</div>
<div className="neu-raised rounded-2xl p-6">
<div className="flex items-center justify-between gap-2 mb-4">
<div className="flex items-center gap-2">
<CreditCard size={16} className="text-accent" />
<h3 className="text-base font-black text-text-primary m-0">Payment Requests & History</h3>
</div>
<button type="button" onClick={() => setShowHistoryHelp(true)} className="neu-btn w-8 h-8 rounded-full" title="History help">
<CircleHelp size={13} />
</button>
</div>
<div className="max-h-[320px] overflow-auto flex flex-col gap-2">
{payments.length === 0 ? (
<div className="text-sm font-semibold text-text-muted py-6 text-center">No payment requests yet.</div>
) : payments.map((payment) => (
<div key={payment.request_ref} className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-black text-text-primary">{payment.request_ref}</div>
<span className={payment.status === 'approved' ? 'badge-success' : payment.status === 'rejected' ? 'badge-error' : 'badge-warning'}>{payment.status}</span>
</div>
<div className="text-xs font-semibold text-text-secondary mt-1">
{payment.package_name} {payment.credits} credits {Number(payment.amount_inr).toFixed(2)}
</div>
<div className="text-[11px] font-semibold text-text-muted mt-1">Created: {formatDate(payment.created_at)}</div>
{payment.utr ? <div className="text-[11px] font-semibold text-text-muted mt-1">UTR: {payment.utr}</div> : null}
{payment.admin_note ? <div className="text-[11px] font-semibold text-text-secondary mt-1">Note: {payment.admin_note}</div> : null}
</div>
))}
</div>
</div>
{showQuotaHelp ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={() => setShowQuotaHelp(false)} className="absolute inset-0 bg-black/70 border-none cursor-pointer" />
<div className="relative w-full max-w-[720px] rounded-2xl p-6 neu-raised" style={{ background: 'var(--color-base)' }}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-black text-text-primary m-0">Quota Help</h2>
<button type="button" onClick={() => setShowQuotaHelp(false)} className="neu-btn p-2"><X size={14} /></button>
</div>
<div className="grid gap-3 text-sm font-semibold text-text-secondary">
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Sandbox bucket: self number only, separate from live.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Live bucket: any valid number, separate from sandbox.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Purchased packs add credits only to live bucket.</div>
</div>
</div>
</div>
) : null}
{showPurchaseHelp ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={() => setShowPurchaseHelp(false)} className="absolute inset-0 bg-black/70 border-none cursor-pointer" />
<div className="relative w-full max-w-[720px] rounded-2xl p-6 neu-raised" style={{ background: 'var(--color-base)' }}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-black text-text-primary m-0">Purchase Flow</h2>
<button type="button" onClick={() => setShowPurchaseHelp(false)} className="neu-btn p-2"><X size={14} /></button>
</div>
<div className="grid gap-3 text-sm font-semibold text-text-secondary">
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>1. Select package.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>2. Click Purchase Selected.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>3. Complete UPI payment and submit UTR.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>4. Admin approves payment and credits move to live bucket.</div>
</div>
</div>
</div>
) : null}
{showHistoryHelp ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={() => setShowHistoryHelp(false)} className="absolute inset-0 bg-black/70 border-none cursor-pointer" />
<div className="relative w-full max-w-[720px] rounded-2xl p-6 neu-raised" style={{ background: 'var(--color-base)' }}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-black text-text-primary m-0">Request Status</h2>
<button type="button" onClick={() => setShowHistoryHelp(false)} className="neu-btn p-2"><X size={14} /></button>
</div>
<div className="grid gap-3 text-sm font-semibold text-text-secondary">
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}><b>pending:</b> created but proof not submitted.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}><b>submitted:</b> UTR submitted, waiting admin review.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}><b>approved:</b> credits added to live bucket.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}><b>rejected:</b> not approved by admin.</div>
</div>
</div>
</div>
) : null}
</div>
)
}

124
src/pages/Contact.jsx Normal file
View File

@ -0,0 +1,124 @@
import { Mail, MessageSquare, MapPin } from 'lucide-react'
import Footer from '../components/Footer'
export default function Contact() {
return (
<>
<div className="min-h-screen pt-[72px] bg-base">
<div className="max-w-[900px] mx-auto px-6 md:px-12 lg:px-20 py-20">
<h1 className="text-[2.5rem] font-black text-white mb-4 tracking-tight">Get in Touch</h1>
<p className="text-text-secondary text-[1.1rem] mb-12">Have questions or feedback? We'd love to hear from you.</p>
<div className="grid gap-12 md:grid-cols-2 mb-16">
{/* Contact Methods */}
<div className="space-y-6">
<h2 className="text-xl font-bold text-white mb-8">Contact Methods</h2>
<div className="glass-panel p-6 rounded-xl border border-white/10">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center shrink-0">
<Mail className="text-accent" size={20} />
</div>
<div>
<h3 className="text-white font-bold mb-1">Email Support</h3>
<p className="text-text-secondary text-[0.95rem] mb-3">For general inquiries and support</p>
<a href="mailto:support@veriflo.app" className="text-accent font-bold hover:underline no-underline">
support@veriflo.app
</a>
</div>
</div>
</div>
<div className="glass-panel p-6 rounded-xl border border-white/10">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center shrink-0">
<MessageSquare className="text-accent" size={20} />
</div>
<div>
<h3 className="text-white font-bold mb-1">Live Chat</h3>
<p className="text-text-secondary text-[0.95rem] mb-3">Chat with our support team in real-time</p>
<button className="text-accent font-bold hover:underline cursor-pointer">
Start Chat
</button>
</div>
</div>
</div>
<div className="glass-panel p-6 rounded-xl border border-white/10">
<div className="flex items-start gap-4">
<div className="w-12 h-12 rounded-lg bg-accent/10 flex items-center justify-center shrink-0">
<Mail className="text-accent" size={20} />
</div>
<div>
<h3 className="text-white font-bold mb-1">Sales Inquiries</h3>
<p className="text-text-secondary text-[0.95rem] mb-3">Questions about pricing or enterprise plans?</p>
<a href="mailto:sales@veriflo.app" className="text-accent font-bold hover:underline no-underline">
sales@veriflo.app
</a>
</div>
</div>
</div>
</div>
{/* FAQ Section */}
<div className="space-y-6">
<h2 className="text-xl font-bold text-white mb-8">Common Questions</h2>
<div className="space-y-4">
<div className="glass-panel p-5 rounded-xl border border-white/10">
<h3 className="text-white font-bold mb-2">What are your support hours?</h3>
<p className="text-text-secondary text-[0.95rem]">
We provide 24/7 support via email. Response times are typically within 2 hours during business hours (9 AM - 6 PM IST).
</p>
</div>
<div className="glass-panel p-5 rounded-xl border border-white/10">
<h3 className="text-white font-bold mb-2">How quickly can I integrate Veriflo?</h3>
<p className="text-text-secondary text-[0.95rem]">
Most developers can integrate Veriflo in under 5 minutes. We provide SDKs, starter files, and clear documentation to get you up and running fast.
</p>
</div>
<div className="glass-panel p-5 rounded-xl border border-white/10">
<h3 className="text-white font-bold mb-2">Do you offer enterprise support?</h3>
<p className="text-text-secondary text-[0.95rem]">
Yes! For high-volume users and enterprise needs, email sales@veriflo.app to discuss custom plans and dedicated support.
</p>
</div>
<div className="glass-panel p-5 rounded-xl border border-white/10">
<h3 className="text-white font-bold mb-2">What payment methods do you accept?</h3>
<p className="text-text-secondary text-[0.95rem]">
We accept all major credit cards, UPI, and bank transfers. For enterprise customers, we can discuss alternative arrangements.
</p>
</div>
</div>
</div>
</div>
<div className="glass-panel p-8 rounded-xl border border-accent/20 bg-accent/5">
<h2 className="text-xl font-bold text-white mb-4">Didn't find what you're looking for?</h2>
<p className="text-text-secondary mb-6">
Check out our comprehensive documentation, or reach out to our team directly. We're always happy to help.
</p>
<div className="flex flex-col sm:flex-row gap-4">
<a
href="/docs"
className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/10 bg-white/5 px-6 py-3 text-sm font-bold text-white no-underline hover:bg-white/10 transition-colors duration-300"
>
View Documentation
</a>
<a
href="mailto:support@veriflo.app"
className="inline-flex items-center justify-center gap-2 rounded-xl border border-accent/20 bg-accent/10 px-6 py-3 text-sm font-bold text-accent no-underline hover:bg-accent/15 transition-colors duration-300"
>
Email Us
</a>
</div>
</div>
</div>
</div>
<Footer />
</>
)
}

582
src/pages/Docs.jsx Normal file
View File

@ -0,0 +1,582 @@
import { useState, useEffect, useRef } from 'react'
import { useSearchParams } from 'react-router-dom'
import { CodeBlock } from '../components/CodeBlock'
import Footer from '../components/Footer'
import {
BookOpen, Key, Send, CheckCircle, RefreshCw,
AlertTriangle, Package, Gauge, ChevronRight, Download, ExternalLink, Boxes,
} from 'lucide-react'
import { marketingConfig, marketingRateLimits } from '../config/marketingConfig'
const sdkCatalog = [
{
id: 'node',
label: 'Node.js',
type: 'Official SDK',
install: 'npm install veriflo',
setupLanguage: 'javascript',
setup: `import Veriflo from 'veriflo'\n\nconst client = Veriflo.init(process.env.VERTIFLO_API_KEY)\n\nconst otpRequest = await client.sendOTP('+919999999999', {\n length: 6,\n expiry: 60,\n})`,
packageUrl: 'https://www.npmjs.com/package/veriflo',
packageLabel: 'npm package',
downloadUrl: '/downloads/veriflo-node-starter.js',
downloadLabel: 'Download JS starter',
notes: [
'Best choice for Express, Next.js, NestJS, and serverless Node runtimes.',
'Includes the cleanest setup for send, verify, and resend flows.',
'Use your API key from the dashboard as VERTIFLO_API_KEY.',
],
},
{
id: 'python',
label: 'Python',
type: 'Official SDK',
install: 'pip install veriflo',
setupLanguage: 'python',
setup: `from veriflo import Veriflo\nimport os\n\nclient = Veriflo(api_key=os.environ['VERTIFLO_API_KEY'])\n\nresult = client.send_otp('+919999999999', length=6, expiry=60)`,
packageUrl: 'https://pypi.org/project/veriflo',
packageLabel: 'PyPI package',
downloadUrl: '/downloads/veriflo-python-starter.py',
downloadLabel: 'Download Python starter',
notes: [
'Works well in Django, Flask, FastAPI, and background workers.',
'Recommended when your backend already uses Python services.',
'Use environment variables to keep API keys out of source control.',
],
},
{
id: 'php',
label: 'PHP',
type: 'REST Starter',
install: 'No SDK required. Use built-in cURL support or your preferred HTTP client.',
setupLanguage: 'php',
setup: `<?php\n\n$apiKey = getenv('VERTIFLO_API_KEY');\n$payload = json_encode([\n 'phone' => '+919999999999',\n 'otpLength' => 6,\n]);`,
packageUrl: 'https://api.veriflo.app/v1/otp/send',
packageLabel: 'REST endpoint',
downloadUrl: '/downloads/veriflo-php-starter.php',
downloadLabel: 'Download PHP starter',
notes: [
'Useful for Laravel, Symfony, CodeIgniter, or plain PHP applications.',
'Start with the REST flow even if you do not need a dedicated package.',
'The starter file shows the exact Authorization and JSON payload format.',
],
},
{
id: 'go',
label: 'Go',
type: 'REST Starter',
install: 'No SDK required. Uses the Go standard library net/http package.',
setupLanguage: 'go',
setup: `package main\n\nimport (\n \"bytes\"\n \"net/http\"\n \"os\"\n)\n\nfunc main() {\n apiKey := os.Getenv(\"VERTIFLO_API_KEY\")\n _ = apiKey\n}`,
packageUrl: 'https://api.veriflo.app/v1/otp/send',
packageLabel: 'REST endpoint',
downloadUrl: '/downloads/veriflo-go-starter.go',
downloadLabel: 'Download Go starter',
notes: [
'Good fit for high-throughput OTP services and worker processes.',
'No external dependency is needed for the starter example.',
'You can wrap the endpoint in your own internal Go package later.',
],
},
{
id: 'ruby',
label: 'Ruby',
type: 'REST Starter',
install: 'No SDK required. Uses Ruby standard libraries plus JSON.',
setupLanguage: 'ruby',
setup: `curl -X POST https://api.veriflo.app/v1/otp/send \\\n+ -H \"Authorization: Bearer \${VERTIFLO_API_KEY}\" \\\n+ -H \"Content-Type: application/json\" \\\n+ -d '{\"phone\":\"+919999999999\",\"otpLength\":6}'`,
packageUrl: 'https://api.veriflo.app/v1/otp/send',
packageLabel: 'REST endpoint',
downloadUrl: '/downloads/veriflo-ruby-starter.rb',
downloadLabel: 'Download Ruby starter',
notes: [
'Suitable for Rails apps and background jobs using Sidekiq or Resque.',
'Uses standard library requests to keep the starter simple.',
'You can replace the HTTP layer with Faraday or HTTParty later.',
],
},
{
id: 'java',
label: 'Java',
type: 'REST Starter',
install: 'No SDK required. Uses Java HttpClient from the standard JDK.',
setupLanguage: 'java',
setup: `HttpRequest request = HttpRequest.newBuilder()\n .uri(URI.create(\"https://api.veriflo.app/v1/otp/send\"))\n .header(\"Authorization\", \"Bearer \" + System.getenv(\"VERTIFLO_API_KEY\"))\n .header(\"Content-Type\", \"application/json\")\n .build();`,
packageUrl: 'https://api.veriflo.app/v1/otp/send',
packageLabel: 'REST endpoint',
downloadUrl: '/downloads/veriflo-java-starter.java',
downloadLabel: 'Download Java starter',
notes: [
'Works for Spring Boot, Micronaut, Quarkus, and plain Java services.',
'The starter is built around the standard JDK HTTP client.',
'You can adapt the same flow to OkHttp, RestTemplate, or WebClient.',
],
},
{
id: 'curl',
label: 'cURL',
type: 'HTTP Starter',
install: 'No SDK required. Works anywhere you can issue HTTPS requests.',
setupLanguage: 'bash',
setup: `curl -X POST https://api.veriflo.app/v1/otp/send \\\n+ -H \"Authorization: Bearer \${VERTIFLO_API_KEY}\" \\\n+ -H \"Content-Type: application/json\" \\\n+ -d '{\"phone\":\"+919999999999\",\"otpLength\":6}'`,
packageUrl: 'https://api.veriflo.app/v1/otp/send',
packageLabel: 'REST endpoint',
downloadUrl: '/downloads/veriflo-curl-starter.sh',
downloadLabel: 'Download shell starter',
notes: [
'Best for testing quickly from terminals, CI jobs, or Postman alternatives.',
'Use this when you want to validate requests before wiring application code.',
'The same headers and JSON body structure apply across all languages.',
],
},
]
const sections = [
{ id: 'getting-started', label: 'Getting Started', icon: BookOpen },
{ id: 'authentication', label: 'Authentication', icon: Key },
{ id: 'send-otp', label: 'Send OTP', icon: Send },
{ id: 'verify-otp', label: 'Verify OTP', icon: CheckCircle },
{ id: 'resend-otp', label: 'Resend OTP', icon: RefreshCw },
{ id: 'error-codes', label: 'Error Codes', icon: AlertTriangle },
{ id: 'sdks', label: 'SDKs', icon: Package },
{ id: 'rate-limits', label: 'Rate Limits', icon: Gauge },
]
export default function Docs() {
const [searchParams] = useSearchParams()
const initialSection = searchParams.get('section') || 'getting-started'
const initialSdk = searchParams.get('sdk') || 'node'
const [active, setActive] = useState(initialSection)
const [activeSdk, setActiveSdk] = useState(initialSdk)
const sectionRefs = useRef({})
useEffect(() => {
const section = searchParams.get('section')
const sdk = searchParams.get('sdk')
if (section) setActive(section)
if (sdk) setActiveSdk(sdk)
}, [searchParams])
// Intersection Observer for auto-scrolling sidebar
useEffect(() => {
const observerOptions = {
root: null,
rootMargin: '-50% 0px -50% 0px',
threshold: 0,
}
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
setActive(entry.target.id)
}
})
}, observerOptions)
// Observe all sections
sections.forEach(({ id }) => {
const element = document.getElementById(id)
if (element) {
observer.observe(element)
}
})
return () => observer.disconnect()
}, [])
return (
<>
<div className="flex min-h-screen pt-[72px] bg-base">
{/* Sidebar */}
<aside className="w-[280px] shrink-0 py-10 px-6 sticky top-[72px] h-[calc(100vh-72px)] overflow-y-auto hidden lg:block border-r border-white/5">
<div className="mb-8 px-3">
<p className="text-[0.75rem] font-bold text-text-muted uppercase tracking-widest">
Documentation
</p>
</div>
<nav className="flex flex-col gap-1.5">
{sections.map(({ id, label, icon: Icon }) => {
const isActive = active === id
return (
<button
key={id}
onClick={() => setActive(id)}
className={`flex items-center gap-3 px-4 py-2.5 rounded-lg border cursor-pointer font-sans font-semibold text-[0.9rem] text-left w-full transition-all duration-200 ${
isActive
? 'bg-accent/10 border-accent/20 text-accent'
: 'bg-transparent border-transparent text-text-secondary hover:text-white hover:bg-white/5'
}`}
>
<Icon size={16} className={isActive ? 'text-accent' : 'text-text-muted'} />
{label}
{isActive && <ChevronRight size={14} className="ml-auto opacity-50" />}
</button>
)
})}
</nav>
</aside>
{/* Content */}
<main className="flex-1 py-12 px-6 md:px-12 lg:px-20 max-w-[900px]">
{active === 'getting-started' && <GettingStarted />}
{active === 'authentication' && <Authentication />}
{active === 'send-otp' && <SendOTP />}
{active === 'verify-otp' && <VerifyOTP />}
{active === 'resend-otp' && <ResendOTP />}
{active === 'error-codes' && <ErrorCodes />}
{active === 'sdks' && <SDKs activeSdk={activeSdk} setActiveSdk={setActiveSdk} />}
{active === 'rate-limits' && <RateLimits />}
</main>
</div>
<Footer />
</>
)
}
function DocSection({ id, title, children }) {
return (
<div id={id} className="animate-fade-up">
<h1 className="text-[2.5rem] font-black text-white mb-8 tracking-tight">{title}</h1>
<div className="text-text-secondary leading-relaxed space-y-8 max-w-[700px]">
{children}
</div>
</div>
)
}
function GettingStarted() {
return (
<DocSection id="getting-started" title="Getting Started">
<p className="text-[1.1rem]">
Veriflo lets you send OTP verification codes via WhatsApp. Follow these steps to integrate in under 5 minutes.
</p>
<div className="mt-10">
<h3 className="font-bold text-white text-xl mb-3 flex items-center gap-3">
<span className="w-8 h-8 rounded-full bg-surface border border-white/10 flex items-center justify-center text-sm font-black text-accent">1</span>
Sign up
</h3>
<p className="mb-6 ml-11">
Create an account at <strong className="text-white">app.veriflo.app</strong>. You'll get {marketingConfig.sandboxFreeOtps} sandbox OTPs + {marketingConfig.liveFreeOtps} live OTPs instantly.
</p>
</div>
<div>
<h3 className="font-bold text-white text-xl mb-3 flex items-center gap-3">
<span className="w-8 h-8 rounded-full bg-surface border border-white/10 flex items-center justify-center text-sm font-black text-accent">2</span>
Install the SDK
</h3>
<div className="ml-11">
<CodeBlock code={`npm install veriflo\n# or\npip install veriflo`} language="bash" />
</div>
</div>
<div className="mt-8">
<h3 className="font-bold text-white text-xl mb-3 flex items-center gap-3">
<span className="w-8 h-8 rounded-full bg-surface border border-white/10 flex items-center justify-center text-sm font-black text-accent">3</span>
Send your first OTP
</h3>
<div className="ml-11">
<CodeBlock code={`import Veriflo from 'veriflo';\nconst client = Veriflo.init('YOUR_API_KEY');\nawait client.sendOTP('+919999999999');`} language="javascript" />
</div>
</div>
</DocSection>
)
}
function Authentication() {
return (
<DocSection id="authentication" title="Authentication">
<p className="text-[1.1rem]">
All API requests require a Bearer token in the Authorization header.
</p>
<CodeBlock code={`Authorization: Bearer YOUR_API_KEY`} language="bash" />
<div className="glass-panel px-6 py-5 rounded-xl border-accent/20 bg-accent/5 mt-8">
<p className="text-white text-[1rem] m-0 flex items-start gap-4">
<span className="text-2xl mt-0.5">🔑</span>
<span>
Get your API key from the dashboard at <br />
<strong className="text-accent tracking-wide mt-1 block">app.veriflo.app/dashboard/api-key</strong>
</span>
</p>
</div>
</DocSection>
)
}
function SendOTP() {
return (
<DocSection id="send-otp" title="Send OTP">
<p className="text-[1.1rem] mb-6">
Send an OTP to any WhatsApp number.
</p>
<div className="glass-panel px-5 py-3.5 mb-6 flex items-center gap-4">
<span className="font-black bg-[#4ade80]/20 text-[#4ade80] px-3 py-1 rounded text-[0.85rem] tracking-wider">POST</span>
<code className="text-[1rem] text-white font-mono tracking-wide">/v1/otp/send</code>
</div>
<CodeBlock code={`curl -X POST https://api.veriflo.app/v1/otp/send \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{"phone": "+919999999999", "otpLength": 6}'`} language="bash" />
<h3 className="font-bold text-white text-xl mt-10 mb-4 border-b border-white/10 pb-2">Response</h3>
<CodeBlock code={`{\n "success": true,\n "requestId": "req_abc123",\n "expiresAt": 1700000060\n}`} language="json" />
</DocSection>
)
}
function VerifyOTP() {
return (
<DocSection id="verify-otp" title="Verify OTP">
<p className="text-[1.1rem]">
Verify the OTP entered by your user against the <code className="font-mono text-[0.9em] bg-surface px-1.5 py-0.5 rounded border border-white/10 text-white">requestId</code>.
</p>
<div className="glass-panel px-5 py-3.5 mb-6 flex items-center gap-4">
<span className="font-black text-white bg-[#4ade80]/20 text-[#4ade80] px-3 py-1 rounded text-[0.85rem] tracking-wider">POST</span>
<code className="text-[1rem] text-white font-mono tracking-wide">/v1/otp/verify</code>
</div>
<CodeBlock code={`curl -X POST https://api.veriflo.app/v1/otp/verify \\\n -H "Authorization: Bearer YOUR_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"requestId": "req_abc123", "otp": "482916"}'`} language="bash" />
<h3 className="font-bold text-white text-xl mt-10 mb-4 border-b border-white/10 pb-2">Response</h3>
<CodeBlock code={`{\n "success": true,\n "valid": true\n}`} language="json" />
</DocSection>
)
}
function ResendOTP() {
return (
<DocSection id="resend-otp" title="Resend OTP">
<p className="text-[1.1rem]">
Resend an OTP using an existing requestId (resets expiry timer).
</p>
<div className="glass-panel px-5 py-3.5 mb-6 flex items-center gap-4">
<span className="font-black text-white bg-[#4ade80]/20 text-[#4ade80] px-3 py-1 rounded text-[0.85rem] tracking-wider">POST</span>
<code className="text-[1rem] text-white font-mono tracking-wide">/v1/otp/resend</code>
</div>
<CodeBlock code={`curl -X POST https://api.veriflo.app/v1/otp/resend \\\n -H "Authorization: Bearer YOUR_API_KEY" \\\n -H "Content-Type: application/json" \\\n -d '{"requestId": "req_abc123"}'`} language="bash" />
</DocSection>
)
}
function ErrorCodes() {
const codes = [
{ code: '401', title: 'Unauthorized', desc: 'Invalid or missing API key.' },
{ code: '400', title: 'Bad Request', desc: 'Missing required fields (phone, requestId, etc).' },
{ code: '404', title: 'Not Found', desc: 'requestId expired or does not exist.' },
{ code: '429', title: 'Rate Limited', desc: 'Too many requests. See rate limits.' },
{ code: '500', title: 'Server Error', desc: 'Something went wrong on our end. Retry.' },
]
return (
<DocSection id="error-codes" title="Error Codes">
<div className="flex flex-col gap-4">
{codes.map(({ code, title, desc }) => (
<div key={code} className="glass-panel p-5 flex gap-5 items-start">
<span className={`font-black min-w-[48px] font-mono text-[1.1rem] py-1 px-2 rounded-md bg-white/5 border border-white/5 text-center ${code.startsWith('2') ? 'text-accent' : 'text-[#ef4444]'}`}>
{code}
</span>
<div>
<strong className="text-white text-[1.05rem] block mb-1">{title}</strong>
<p className="text-text-muted m-0 text-[0.95rem]">{desc}</p>
</div>
</div>
))}
</div>
</DocSection>
)
}
function SDKs({ activeSdk, setActiveSdk }) {
const currentSdk = sdkCatalog.find((sdk) => sdk.id === activeSdk) || sdkCatalog[0]
const handleDownload = () => {
// File extensions based on language
const extensionMap = {
javascript: 'js',
python: 'py',
php: 'php',
go: 'go',
ruby: 'rb',
java: 'java',
bash: 'sh',
}
const extension = extensionMap[currentSdk.setupLanguage] || 'txt'
const filename = `${currentSdk.id}-starter.${extension}`
// Create blob with setup code
const blob = new Blob([currentSdk.setup], { type: 'text/plain' })
const url = window.URL.createObjectURL(blob)
// Create and trigger download
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
}
return (
<DocSection id="sdks" title="SDKs">
<p className="text-[1.1rem] mb-8">
Install fast, copy a starter, and ship with the language your backend already uses.
</p>
<div className="mb-8">
<div className="flex items-center gap-2 mb-3">
<span className="text-[0.72rem] font-black text-accent tracking-[0.18em] uppercase">SDK Catalog</span>
</div>
<p className="text-[0.95rem] text-text-secondary font-medium">
Official SDKs for Node.js and Python. REST starter downloads for other backend languages.
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3 mb-8">
{sdkCatalog.map((sdk) => (
<button
key={sdk.id}
type="button"
onClick={() => setActiveSdk(sdk.id)}
className={`rounded-2xl p-5 text-left border transition-all duration-200 ${sdk.id === currentSdk.id ? 'bg-accent/10 border-accent/30' : 'bg-white/5 border-white/10 hover:bg-white/8'}`}
>
<div className="flex items-center justify-between gap-3 mb-3">
<div className="text-white text-[1.05rem] font-bold">{sdk.label}</div>
<div className={`text-[0.68rem] font-black uppercase tracking-[0.18em] ${sdk.id === currentSdk.id ? 'text-accent' : 'text-text-muted'}`}>
{sdk.type}
</div>
</div>
<div className="text-[0.88rem] text-text-secondary font-medium leading-relaxed">
{sdk.install}
</div>
</button>
))}
</div>
<div className="glass-panel p-6 md:p-8 border-white/10">
<div className="flex flex-col gap-8 mb-8">
<div>
<div className="flex flex-col sm:flex-row sm:items-center gap-3 mb-4">
<h3 className="text-2xl font-black text-white tracking-tight">{currentSdk.label}</h3>
<span className="px-2.5 py-1 rounded-full bg-accent/10 border border-accent/20 text-[0.72rem] font-black text-accent uppercase tracking-[0.18em] w-fit">
{currentSdk.type}
</span>
</div>
<p className="text-[0.98rem] text-text-secondary font-medium leading-relaxed max-w-[580px]">
Installation, setup, and download links are always available here so you can start from the package manager or grab a ready-made starter file.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<a
href={currentSdk.packageUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 rounded-xl border border-white/10 bg-white/5 px-4 py-2.5 text-sm font-bold text-white no-underline hover:bg-white/10 transition-colors duration-300"
>
<ExternalLink size={14} /> {currentSdk.packageLabel}
</a>
<button
onClick={handleDownload}
className="inline-flex items-center gap-2 rounded-xl border border-accent/20 bg-accent/10 px-4 py-2.5 text-sm font-bold text-accent no-underline hover:bg-accent/15 transition-colors duration-300 cursor-pointer"
>
<Download size={14} /> {currentSdk.downloadLabel}
</button>
</div>
</div>
{/* Installation — single line, full width */}
<div className="mt-8">
<div className="text-[0.78rem] font-black uppercase tracking-[0.18em] text-text-muted mb-3">Installation</div>
<CodeBlock code={currentSdk.install} language="bash" />
</div>
{/* Setup code — full width so the code block never squashes */}
<div className="mt-6">
<div className="text-[0.78rem] font-black uppercase tracking-[0.18em] text-text-muted mb-3">Setup</div>
<CodeBlock code={currentSdk.setup} language={currentSdk.setupLanguage} />
</div>
{/* Checklist + Language Notes side by side */}
<div className="grid gap-4 sm:grid-cols-2 mt-6">
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="flex items-center gap-2 mb-3">
<Boxes size={16} className="text-accent" />
<div className="text-[0.82rem] font-black uppercase tracking-[0.18em] text-accent">Setup checklist</div>
</div>
<div className="flex flex-col gap-3">
{[
'Install the package or choose the REST starter file.',
'Save your API key as VERTIFLO_API_KEY in your environment.',
'Use the send endpoint first, then wire verify and resend flows.',
'Keep rate limits in mind while testing send and resend behavior.',
].map((item) => (
<div key={item} className="flex items-start gap-2.5">
<span className="w-5 h-5 rounded-full bg-accent/15 text-accent flex items-center justify-center shrink-0 mt-px text-xs"></span>
<span className="text-[0.9rem] text-text-secondary font-medium leading-relaxed">{item}</span>
</div>
))}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-[0.82rem] font-black uppercase tracking-[0.18em] text-text-muted mb-3">Language notes</div>
<div className="flex flex-col gap-3">
{currentSdk.notes.map((note) => (
<div key={note} className="text-[0.9rem] text-text-secondary font-medium leading-relaxed">
{note}
</div>
))}
</div>
</div>
</div>
{/* Download button — full width at bottom */}
<div className="mt-6 rounded-2xl border border-accent/20 bg-accent/5 p-5 flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex-1">
<div className="text-[0.82rem] font-black uppercase tracking-[0.18em] text-accent mb-1">Download starter</div>
<div className="text-[0.9rem] text-text-secondary font-medium">
Ready-made starter file with setup code for quick integration.
</div>
</div>
<button
onClick={handleDownload}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-accent px-5 py-2.5 text-sm font-black text-black hover:bg-[#45de7f] transition-colors duration-300 shrink-0 cursor-pointer"
>
<Download size={14} /> {currentSdk.downloadLabel}
</button>
</div>
</div>
</DocSection>
)
}
function RateLimits() {
return (
<DocSection id="rate-limits" title="Rate Limits">
<p className="text-[1.1rem]">
Rate limits are applied per API key to ensure fair usage and prevent spam.
</p>
<div className="glass-panel overflow-hidden border-white/10 my-8">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-white/10 bg-white/5">
<th className="p-4 font-bold text-white text-[0.9rem] uppercase tracking-wider">Endpoint</th>
<th className="p-4 font-bold text-white text-[0.9rem] uppercase tracking-wider text-right">Limit</th>
</tr>
</thead>
<tbody>
{marketingRateLimits.map(({ endpoint, limit }) => (
<tr key={endpoint} className="border-b border-white/5 last:border-0 hover:bg-white/[0.02]">
<td className="p-4 font-mono text-[0.9rem] text-white/90">{endpoint}</td>
<td className="p-4 text-right font-bold text-accent">{limit}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="glass-panel px-6 py-5 border-white/10 bg-surface/50">
<p className="m-0 text-[0.95rem] text-text-secondary">
💡 Rate limit headers (<code className="font-mono text-xs text-white bg-white/10 px-1 py-0.5 rounded mx-1">X-RateLimit-Limit</code>, <code className="font-mono text-xs text-white bg-white/10 px-1 py-0.5 rounded mx-1">X-RateLimit-Remaining</code>) are included in every API response.
</p>
</div>
</DocSection>
)
}

26
src/pages/Home.jsx Normal file
View File

@ -0,0 +1,26 @@
import Hero from '../components/Hero'
import StatsBar from '../components/StatsBar'
import HowItWorks from '../components/HowItWorks'
import Features from '../components/Features'
import CodeExamples from '../components/CodeExamples'
import Pricing from '../components/Pricing'
import Testimonials from '../components/Testimonials'
import FAQ from '../components/FAQ'
import Footer from '../components/Footer'
import MotionSection from '../components/MotionSection'
export default function Home() {
return (
<>
<Hero />
<MotionSection delay={0.04}><StatsBar /></MotionSection>
<MotionSection delay={0.06}><HowItWorks /></MotionSection>
<MotionSection delay={0.08}><Features /></MotionSection>
<MotionSection delay={0.1}><CodeExamples /></MotionSection>
<MotionSection delay={0.12}><Pricing /></MotionSection>
<MotionSection delay={0.14}><Testimonials /></MotionSection>
<MotionSection delay={0.16}><FAQ /></MotionSection>
<Footer />
</>
)
}

295
src/pages/Integrate.jsx Normal file
View File

@ -0,0 +1,295 @@
import { useState, useEffect } from 'react'
import { Terminal, Send, Check, AlertCircle, Loader2, CircleHelp, X } from 'lucide-react'
import api from '../services/api'
import { useToast } from '../components/ToastProvider'
export default function Integrate() {
const toast = useToast()
const [activeTab, setActiveTab] = useState('Node.js')
const [testPhone, setTestPhone] = useState('')
const [sending, setSending] = useState(false)
const [sent, setSent] = useState(false)
const [error, setError] = useState('')
const [apiKey, setApiKey] = useState('wp_live_xxxxxxxxxxxxxxxxxxxxxxxx')
const [showQuickHelp, setShowQuickHelp] = useState(false)
const [showSecurityHelp, setShowSecurityHelp] = useState(false)
const [showTestHelp, setShowTestHelp] = useState(false)
const [testOtpUserLimit, setTestOtpUserLimit] = useState(8)
useEffect(() => {
let mounted = true
const fetchLimits = async () => {
try {
const res = await api.get('/api/user/profile')
if (!mounted) return
const limit = Number(res.data?.limits?.test_otp_user_per_min)
if (Number.isFinite(limit) && limit > 0) {
setTestOtpUserLimit(limit)
}
} catch (err) {
// Keep fallback value
}
}
fetchLimits()
return () => { mounted = false }
}, [])
const handleTestSend = async (e) => {
e.preventDefault()
setSending(true)
setError('')
setSent(false)
try {
await api.post('/api/user/test-otp', { phone: '91' + testPhone })
setSent(true)
window.dispatchEvent(new CustomEvent('veriflo-usage-updated'))
toast.success('Test OTP sent successfully')
setTimeout(() => setSent(false), 5000)
} catch (err) {
const message = err.response?.data?.error || 'Failed to send test OTP'
setError(message)
toast.error(message)
} finally {
setSending(false)
}
}
const getCodeForTab = (tab) => {
const key = apiKey;
if(tab === 'Node.js') return `npm install veriflo
const Veriflo = require('veriflo');
const client = Veriflo.init('${key}');
// Send OTP
const { requestId } = await client.sendOTP('+91${testPhone || '9999999999'}', { length: 6 });
// Verify OTP later
const verified = await client.verifyOTP(requestId, '123456');
console.log('OTP Sent!', requestId);
console.log('Verified?', verified);`
if(tab === 'Python') return `pip install veriflo
from veriflo import Veriflo
client = Veriflo(api_key="${key}")
# Send OTP
result = client.send_otp("+91${testPhone || '9999999999'}", length=6)
is_valid = client.verify_otp(result.request_id, "123456")
print('OTP Sent!', result.request_id)
print('Verified?', is_valid)`
if(tab === 'HTTP') return `curl -X POST http://localhost:3000/v1/otp/send \\
-H "Authorization: Bearer ${key}" \\
-H "Content-Type: application/json" \\
-d '{"phone": "+91${testPhone || '9999999999'}"}'
curl -X POST http://localhost:3000/v1/otp/verify \\
-H "Authorization: Bearer ${key}" \\
-H "Content-Type: application/json" \\
-d '{"request_id":"req_xxx","otp":"123456"}'`
}
return (
<div className="flex flex-col gap-6">
<div>
<h2 className="text-2xl font-black text-text-primary tracking-tight m-0 mb-1">Integration Guide</h2>
<p className="text-text-secondary text-sm font-semibold m-0">
Minimal setup and test flow for OTP integration.
</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-12 gap-6 items-start">
<div className="xl:col-span-8 neu-raised rounded-2xl p-6 flex flex-col gap-5">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Terminal size={19} className="text-text-primary" />
<h3 className="text-lg font-black text-text-primary m-0">Code Quick Start</h3>
</div>
<div className="flex items-center gap-2">
<button type="button" onClick={() => setShowQuickHelp(true)} className="neu-btn w-9 h-9 rounded-full" title="Quick help">
<CircleHelp size={14} />
</button>
<button type="button" onClick={() => setShowSecurityHelp(true)} className="neu-btn w-9 h-9 rounded-full" title="Security notes">
<CircleHelp size={14} />
</button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>
<div className="text-[11px] font-black uppercase tracking-wider text-text-muted mb-1">Required Header</div>
<div className="font-mono text-xs text-text-secondary break-all">Authorization: Bearer YOUR_API_KEY</div>
</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>
<div className="text-[11px] font-black uppercase tracking-wider text-text-muted mb-1">Endpoints</div>
<div className="font-mono text-xs text-text-secondary break-all">POST /v1/otp/send POST /v1/otp/verify</div>
</div>
</div>
<div className="neu-inset-sm rounded-lg p-1.5 flex flex-wrap gap-1.5">
{['Node.js', 'Python', 'HTTP'].map(tab => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex-1 min-w-[92px] py-2 text-xs font-bold rounded-md border-none cursor-pointer transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/55 focus-visible:ring-offset-1 focus-visible:ring-offset-base ${
activeTab === tab ? 'neu-raised text-accent' : 'bg-transparent text-text-muted hover:text-text-primary hover:bg-white/5'
}`}
aria-pressed={activeTab === tab}
>
{tab}
</button>
))}
</div>
<div className="neu-inset rounded-xl p-4 bg-black/5 relative overflow-hidden min-h-[320px]">
<span className="absolute top-2 right-2 text-[9px] font-black uppercase tracking-widest text-[#25D366] bg-[#25D366]/10 px-2 py-0.5 rounded-full">
Key Injected
</span>
<pre className="font-mono text-xs font-semibold text-text-secondary overflow-x-auto m-0 pt-5 leading-6 text-left">
{getCodeForTab(activeTab)}
</pre>
</div>
</div>
<div className="xl:col-span-4">
<div className="neu-raised rounded-2xl p-6 xl:sticky xl:top-6">
<div className="flex items-center justify-between gap-2 border-b border-[#ffffff30] pb-4 mb-6">
<div className="flex items-center gap-2">
<Send size={20} className="text-accent" />
<h3 className="text-lg font-black text-text-primary m-0">Send Test OTP</h3>
</div>
<button type="button" onClick={() => setShowTestHelp(true)} className="neu-btn w-8 h-8 rounded-full" title="Test help">
<CircleHelp size={13} />
</button>
</div>
<p className="text-sm font-semibold text-text-secondary mb-5 leading-relaxed">
Fast check from dashboard. In sandbox, only your registered number is allowed.
</p>
<div className="rounded-xl p-3 mb-4" style={{ background: 'rgba(255,255,255,0.03)' }}>
<div className="text-[11px] font-black uppercase tracking-wider text-text-muted">Strict Limit</div>
<div className="text-xs font-semibold text-text-secondary mt-1">
Max <span className="text-text-primary font-black">{testOtpUserLimit}</span> test OTP requests per minute per account.
</div>
</div>
{error && (
<div className="mb-4 p-3 neu-inset-sm bg-red-400/10 text-red-500 rounded-lg text-xs font-bold flex items-center gap-2">
<AlertCircle size={14} /> {error}
</div>
)}
<form onSubmit={handleTestSend} className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-wider text-text-secondary px-1">Recipient Phone Number</label>
<div className="grid grid-cols-[88px_minmax(0,1fr)] gap-3">
<input
type="text"
value="+91"
readOnly
className="neu-input text-center text-text-muted font-black tracking-wide"
/>
<input
type="text"
required
placeholder="98765 43210"
value={testPhone}
onChange={e => setTestPhone(e.target.value)}
className="neu-input min-w-0 tracking-[0.12em] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/55"
aria-label="Recipient phone number"
inputMode="numeric"
autoComplete="tel-national"
/>
</div>
</div>
<button
type="submit"
disabled={sending || sent || !testPhone}
className={`py-3.5 gap-2 font-bold shadow-lg transition-all flex items-center justify-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/55 focus-visible:ring-offset-1 focus-visible:ring-offset-base ${
sent ? 'neu-raised text-accent pointer-events-none border-none' : 'neu-btn-accent'
}`}
>
{sending ? (
<Loader2 size={18} className="animate-spin" />
) : sent ? (
<><Check size={18} /> Sent Successfully</>
) : (
<><Send size={18} /> Fire Test OTP</>
)}
</button>
</form>
</div>
</div>
</div>
{showQuickHelp ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={() => setShowQuickHelp(false)} className="absolute inset-0 bg-black/70 border-none cursor-pointer" />
<div className="relative w-full max-w-[760px] rounded-2xl p-6 neu-raised" style={{ background: 'var(--color-base)' }}>
<div className="flex items-center justify-between gap-3 mb-4">
<h2 className="text-lg font-black text-text-primary m-0">Quick Start Help</h2>
<button type="button" onClick={() => setShowQuickHelp(false)} className="neu-btn p-2" aria-label="Close">
<X size={14} />
</button>
</div>
<div className="grid gap-3">
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}><span className="text-accent font-black text-xs">1.</span> Generate API key in API Keys page.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}><span className="text-accent font-black text-xs">2.</span> Keep key in server env vars only.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}><span className="text-accent font-black text-xs">3.</span> Call send OTP, keep `request_id`.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}><span className="text-accent font-black text-xs">4.</span> Verify OTP with `request_id` + code.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}><span className="text-accent font-black text-xs">5.</span> Track usage and delivery in dashboard.</div>
</div>
</div>
</div>
) : null}
{showSecurityHelp ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={() => setShowSecurityHelp(false)} className="absolute inset-0 bg-black/70 border-none cursor-pointer" />
<div className="relative w-full max-w-[760px] rounded-2xl p-6 neu-raised" style={{ background: 'var(--color-base)' }}>
<div className="flex items-center justify-between gap-3 mb-4">
<h2 className="text-lg font-black text-text-primary m-0">Security Notes</h2>
<button type="button" onClick={() => setShowSecurityHelp(false)} className="neu-btn p-2" aria-label="Close">
<X size={14} />
</button>
</div>
<div className="grid gap-3 text-sm font-semibold text-text-secondary">
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Never expose API key in client apps.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Sandbox mode sends only to your registered WhatsApp number.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Use separate keys for sandbox and live integrations.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Rotate keys if leaked and delete unused keys.</div>
</div>
</div>
</div>
) : null}
{showTestHelp ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={() => setShowTestHelp(false)} className="absolute inset-0 bg-black/70 border-none cursor-pointer" />
<div className="relative w-full max-w-[700px] rounded-2xl p-6 neu-raised" style={{ background: 'var(--color-base)' }}>
<div className="flex items-center justify-between gap-3 mb-4">
<h2 className="text-lg font-black text-text-primary m-0">Test OTP Notes</h2>
<button type="button" onClick={() => setShowTestHelp(false)} className="neu-btn p-2" aria-label="Close">
<X size={14} />
</button>
</div>
<div className="grid gap-3 text-sm font-semibold text-text-secondary">
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Sandbox mode: only your registered WhatsApp number is allowed.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Live mode: can send to any valid number under your live quota.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Rate limit: max {testOtpUserLimit} test requests per minute per account.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Country code is fixed as +91 in this quick tester.</div>
</div>
</div>
</div>
) : null}
</div>
)
}

524
src/pages/MessageConfig.jsx Normal file
View File

@ -0,0 +1,524 @@
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>
)
}

465
src/pages/Overview.jsx Normal file
View File

@ -0,0 +1,465 @@
import { useState, useEffect } from 'react'
import { Send, CheckCircle, Clock, Key, CreditCard, Loader2, AlertTriangle, Activity, ShieldCheck, Users, TrendingUp, CircleHelp, X } from 'lucide-react'
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, AreaChart, Area } from 'recharts'
import api from '../services/api'
function getRelativeTime(value) {
const timestamp = new Date(value).getTime()
const diffInMinutes = Math.max(1, Math.floor((Date.now() - timestamp) / 60000))
if (diffInMinutes < 60) return `${diffInMinutes} min${diffInMinutes === 1 ? '' : 's'} ago`
const diffInHours = Math.floor(diffInMinutes / 60)
if (diffInHours < 24) return `${diffInHours} hour${diffInHours === 1 ? '' : 's'} ago`
const diffInDays = Math.floor(diffInHours / 24)
return `${diffInDays} day${diffInDays === 1 ? '' : 's'} ago`
}
function getActivityPresentation(type) {
const map = {
api_key_generated: { icon: Key, isError: false },
api_key_deleted: { icon: Key, isError: false },
otp_verification_failed: { icon: AlertTriangle, isError: true },
otp_sent: { icon: Send, isError: false },
otp_verified: { icon: CheckCircle, isError: false },
profile_updated: { icon: CreditCard, isError: false },
settings_updated: { icon: Clock, isError: false },
test_otp_sent: { icon: Send, isError: false },
webhook_test_sent: { icon: Activity, isError: false },
signup_completed: { icon: CheckCircle, isError: false },
login: { icon: ShieldCheck, isError: false },
}
return map[type] || { icon: Clock, isError: false }
}
function percentage(numerator, denominator) {
if (!denominator) return '0.0%'
return `${((numerator / denominator) * 100).toFixed(1)}%`
}
export default function Overview() {
const [stats, setStats] = useState(null)
const [loading, setLoading] = useState(true)
const [period, setPeriod] = useState('7D')
const [showPerformanceHelp, setShowPerformanceHelp] = useState(false)
const [showAllActivityModal, setShowAllActivityModal] = useState(false)
const [activityLogs, setActivityLogs] = useState([])
const [activityPage, setActivityPage] = useState(1)
const [activityHasNext, setActivityHasNext] = useState(false)
const [activityLoading, setActivityLoading] = useState(false)
useEffect(() => {
let isMounted = true
const fetchData = async () => {
try {
const res = await api.get('/api/user/analytics/summary')
if (isMounted) setStats(res.data)
} catch (err) {
console.error('Failed to fetch analytics summary', err)
} finally {
if (isMounted) setLoading(false)
}
}
fetchData()
return () => { isMounted = false }
}, [period])
useEffect(() => {
if (!showAllActivityModal) return
let mounted = true
const fetchActivityPage = async () => {
setActivityLoading(true)
try {
const res = await api.get('/api/user/analytics/activity?page=1&page_size=25')
if (!mounted) return
setActivityLogs(res.data.activity || [])
setActivityPage(1)
setActivityHasNext(Boolean(res.data.pagination?.has_next))
} catch (err) {
if (!mounted) return
setActivityLogs([])
setActivityPage(1)
setActivityHasNext(false)
} finally {
if (mounted) setActivityLoading(false)
}
}
fetchActivityPage()
return () => { mounted = false }
}, [showAllActivityModal])
const loadMoreActivity = async () => {
if (activityLoading || !activityHasNext) return
const nextPage = activityPage + 1
setActivityLoading(true)
try {
const res = await api.get(`/api/user/analytics/activity?page=${nextPage}&page_size=25`)
const nextItems = res.data.activity || []
setActivityLogs((prev) => [...prev, ...nextItems])
setActivityPage(nextPage)
setActivityHasNext(Boolean(res.data.pagination?.has_next))
} catch (err) {
setActivityHasNext(false)
} finally {
setActivityLoading(false)
}
}
if (loading || !stats) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 className="animate-spin text-text-muted" size={32} />
</div>
)
}
const windows = stats.windows || {}
const operational = stats.operational || {}
const lifetime = stats.lifetime || {}
const plan = stats.plan || {}
const latestRequest = operational.latest_request
const recentActivity = stats.recent_activity || []
const recentActivityPreview = recentActivity.slice(0, 3)
const topCards = [
{ label: 'Total OTPs Sent', value: lifetime.total_sent || 0, icon: Send, subtext: 'Lifetime volume', accent: 'Lifetime' },
{ label: 'Delivery Rate', value: lifetime.delivery_rate || '0%', icon: CheckCircle, subtext: 'Delivered or reached expiry', accent: 'Quality' },
{ label: 'Verification Rate', value: lifetime.verification_rate || '0%', icon: ShieldCheck, subtext: 'Verified against all sends', accent: 'Trust' },
{ label: 'Avg Delivery Time', value: `${lifetime.avg_latency_ms || 0}ms`, icon: Clock, subtext: 'Average end-to-end latency', accent: 'Speed' },
]
const analysisCards = [
{ label: 'Active API Keys', value: operational.active_keys || 0, icon: Key, detail: `${plan.label || 'Sandbox / Free Trial'}${operational.top_key?.name ? ` • Top key: ${operational.top_key.name}` : ''}` },
{ label: 'Unique Recipients', value: lifetime.unique_recipients || 0, icon: Users, detail: 'Distinct WhatsApp numbers reached' },
{ label: 'Estimated Spend', value: `${(operational.estimated_cost_inr || 0).toFixed(2)}`, icon: CreditCard, detail: 'Derived from total OTP volume' },
{ label: 'Peak Day Volume', value: operational.top_day?.total || 0, icon: TrendingUp, detail: operational.top_day?.date || 'No traffic yet' },
]
const windowCards = [
{ title: 'Last 24 Hours', data: windows.last_24h || {} },
{ title: 'Last 7 Days', data: windows.last_7d || {} },
{ title: 'Last 30 Days', data: windows.last_30d || {} },
]
return (
<div className="flex flex-col gap-8">
<div className="neu-raised rounded-2xl p-5 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<div className="text-[11px] font-black uppercase tracking-[0.16em] text-text-muted mb-2">Usage Plan</div>
<div className="text-lg font-black text-text-primary">{plan.label || 'Sandbox / Free Trial'}</div>
<div className="text-sm font-semibold text-text-secondary mt-1">
Free trial is active by default. Metrics below are being tracked under your current plan and environment.
</div>
</div>
<div className="flex items-center gap-3">
<span className="badge-neutral">{plan.name || 'Free Trial'}</span>
<span className="badge-neutral">{plan.mode || 'sandbox'}</span>
</div>
</div>
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4 xl:gap-6">
{topCards.map(({ label, value, icon: Icon, subtext, accent }) => (
<div key={label} className="neu-raised rounded-2xl p-6">
<div className="flex justify-between items-start mb-4">
<div className="w-12 h-12 neu-inset-sm rounded-xl flex items-center justify-center">
<Icon size={20} className="text-accent" />
</div>
<span className="text-xs font-bold px-2.5 py-1 rounded-full text-text-secondary bg-white/5">
{accent}
</span>
</div>
<div className="text-3xl font-black text-text-primary mb-1 tracking-tight">{value}</div>
<div className="text-sm font-bold text-text-muted">{label}</div>
<div className="text-xs font-semibold text-text-secondary mt-2">{subtext}</div>
</div>
))}
</div>
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4 xl:gap-6">
{analysisCards.map(({ label, value, icon: Icon, detail }) => (
<div key={label} className="neu-raised rounded-2xl p-5">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 neu-inset-sm rounded-xl flex items-center justify-center">
<Icon size={18} className="text-accent" />
</div>
<div className="text-[11px] font-black uppercase tracking-[0.16em] text-text-muted">{label}</div>
</div>
<div className="text-2xl font-black text-text-primary tracking-tight">{value}</div>
<div className="text-xs font-semibold text-text-secondary mt-2 leading-5">{detail}</div>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 neu-raised rounded-2xl p-6">
<div className="flex justify-between items-center mb-6">
<h2 className="text-lg font-black text-text-primary m-0">Traffic Analysis</h2>
<div className="neu-inset-sm rounded-lg p-1 flex shadow-inner">
{['7D', '30D', '1Y'].map((t) => (
<button
key={t}
onClick={() => setPeriod(t)}
className={`px-3 py-1 font-bold text-xs rounded-md border-none cursor-pointer transition-all ${
period === t ? 'neu-raised text-accent' : 'bg-transparent text-text-muted hover:text-text-primary'
}`}
>
{t}
</button>
))}
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="h-[280px] w-full">
<div className="text-sm font-black text-text-primary mb-4">Total OTP Volume</div>
<ResponsiveContainer width="100%" height="100%">
<LineChart data={stats.chart_data || []} margin={{ top: 5, right: 0, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-shadow-dark)" opacity={0.2} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12, fontWeight: 700 }} dy={10} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12, fontWeight: 700 }} />
<Tooltip
contentStyle={{ backgroundColor: 'var(--color-base)', borderRadius: '12px', border: 'none', boxShadow: '8px 8px 16px var(--color-shadow-dark)' }}
itemStyle={{ color: 'var(--color-text-primary)', fontWeight: 800 }}
labelStyle={{ color: 'var(--color-text-muted)', marginBottom: '4px', fontWeight: 700, fontSize: '12px' }}
/>
<Line type="monotone" name="OTPs" dataKey="total" stroke="var(--color-accent)" strokeWidth={4} dot={{ r: 4, fill: 'var(--color-accent)', strokeWidth: 2, stroke: 'var(--color-base)' }} activeDot={{ r: 6, stroke: 'var(--color-base)', strokeWidth: 3 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div className="h-[280px] w-full">
<div className="text-sm font-black text-text-primary mb-4">Delivered vs Failed</div>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={stats.chart_data || []} margin={{ top: 5, right: 0, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="deliveredGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#25D366" stopOpacity={0.35} />
<stop offset="95%" stopColor="#25D366" stopOpacity={0.02} />
</linearGradient>
<linearGradient id="failedGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0.02} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-shadow-dark)" opacity={0.2} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12, fontWeight: 700 }} dy={10} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 12, fontWeight: 700 }} />
<Tooltip
contentStyle={{ backgroundColor: 'var(--color-base)', borderRadius: '12px', border: 'none', boxShadow: '8px 8px 16px var(--color-shadow-dark)' }}
itemStyle={{ color: 'var(--color-text-primary)', fontWeight: 800 }}
labelStyle={{ color: 'var(--color-text-muted)', marginBottom: '4px', fontWeight: 700, fontSize: '12px' }}
/>
<Area type="monotone" name="Delivered" dataKey="delivered" stroke="#25D366" fill="url(#deliveredGradient)" strokeWidth={3} />
<Area type="monotone" name="Failed" dataKey="failed" stroke="#ef4444" fill="url(#failedGradient)" strokeWidth={3} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
</div>
<div className="neu-raised rounded-2xl p-6 flex flex-col gap-5">
<div>
<h2 className="text-lg font-black text-text-primary m-0 mb-1">Operational Snapshot</h2>
<p className="text-xs font-semibold text-text-secondary m-0">Current health, latency envelope, and latest request state.</p>
</div>
<div className="grid gap-3">
<div className="rounded-2xl p-4" style={{ background: 'rgba(255,255,255,0.03)' }}>
<div className="text-[11px] font-black uppercase tracking-[0.16em] text-text-muted mb-2">Latency Envelope</div>
<div className="text-sm font-bold text-text-primary">Min {operational.latency?.min_ms || 0}ms Avg {operational.latency?.avg_ms || 0}ms Max {operational.latency?.max_ms || 0}ms</div>
</div>
<div className="rounded-2xl p-4" style={{ background: 'rgba(255,255,255,0.03)' }}>
<div className="text-[11px] font-black uppercase tracking-[0.16em] text-text-muted mb-2">Verification Funnel</div>
<div className="text-sm font-bold text-text-primary">
{lifetime.total_verified || 0} verified {lifetime.total_expired || 0} expired {lifetime.total_failed_attempts || 0} failed attempts
</div>
</div>
<div className="rounded-2xl p-4" style={{ background: 'rgba(255,255,255,0.03)' }}>
<div className="text-[11px] font-black uppercase tracking-[0.16em] text-text-muted mb-2">Latest Request</div>
{latestRequest ? (
<>
<div className="text-sm font-bold text-text-primary">{latestRequest.request_id}</div>
<div className="text-xs font-semibold text-text-secondary mt-1">{latestRequest.phone} {latestRequest.status}</div>
<div className="text-xs font-semibold text-text-muted mt-1">{getRelativeTime(latestRequest.created_at)}</div>
</>
) : (
<div className="text-sm font-semibold text-text-secondary">No OTP requests yet.</div>
)}
</div>
</div>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-[1.2fr_0.8fr] gap-6">
<div className="neu-raised rounded-2xl p-6">
<h2 className="text-lg font-black text-text-primary m-0 mb-5">Advanced Window Analysis</h2>
<div className="grid gap-4 md:grid-cols-3">
{windowCards.map(({ title, data }) => (
<div key={title} className="rounded-2xl p-4" style={{ background: 'rgba(255,255,255,0.03)' }}>
<div className="text-sm font-black text-text-primary mb-3">{title}</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs font-semibold text-text-secondary">
<span>Total</span>
<span className="text-text-primary font-black">{data.total || 0}</span>
</div>
<div className="flex items-center justify-between text-xs font-semibold text-text-secondary">
<span>Delivered</span>
<span className="text-text-primary font-black">{data.delivered || 0}</span>
</div>
<div className="flex items-center justify-between text-xs font-semibold text-text-secondary">
<span>Verified</span>
<span className="text-text-primary font-black">{data.verified || 0}</span>
</div>
<div className="flex items-center justify-between text-xs font-semibold text-text-secondary">
<span>Failed</span>
<span className="text-red-400 font-black">{data.failed || 0}</span>
</div>
<div className="pt-2 mt-2 border-t border-white/5 flex items-center justify-between text-xs font-semibold text-text-secondary">
<span>Verification Rate</span>
<span className="text-accent font-black">{percentage(data.verified || 0, data.total || 0)}</span>
</div>
</div>
</div>
))}
</div>
</div>
<div className="neu-raised rounded-2xl p-6 flex flex-col">
<h2 className="text-lg font-black text-text-primary m-0 mb-6">Recent Activity</h2>
<div className="flex-1 flex flex-col gap-4">
{recentActivity.length === 0 ? (
<div className="text-sm font-semibold text-text-secondary">No activity has been logged yet.</div>
) : (
recentActivityPreview.map((activity, i) => {
const presentation = getActivityPresentation(activity.type)
const Icon = presentation.icon
return (
<div key={`${activity.type}-${activity.created_at}-${i}`} className="flex gap-4 items-start pb-4 border-b border-white/5 last:border-0 last:pb-0">
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 neu-inset-sm ${presentation.isError ? 'text-red-400' : 'text-text-secondary'}`}>
<Icon size={14} />
</div>
<div>
<div className={`text-sm font-bold ${presentation.isError ? 'text-red-500' : 'text-text-primary'}`}>{activity.title}</div>
{activity.meta ? <div className="text-xs font-semibold text-text-secondary mt-0.5">{activity.meta}</div> : null}
<div className="text-xs font-semibold text-text-muted mt-0.5">{getRelativeTime(activity.created_at)}</div>
</div>
</div>
)
})
)}
</div>
<button
type="button"
onClick={() => setShowAllActivityModal(true)}
className="neu-btn py-3 w-full mt-auto"
>
View All Activity
</button>
</div>
</div>
<div className="neu-raised rounded-2xl p-5 flex items-center justify-between gap-4">
<div>
<div className="text-sm font-black text-text-primary">Performance Notes</div>
<div className="text-xs font-semibold text-text-secondary mt-1">Open focused insights via info button.</div>
</div>
<button
type="button"
onClick={() => setShowPerformanceHelp(true)}
className="neu-btn w-9 h-9 rounded-full"
title="Open performance notes"
>
<CircleHelp size={15} />
</button>
</div>
{showPerformanceHelp ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={() => setShowPerformanceHelp(false)} className="absolute inset-0 bg-black/70 border-none cursor-pointer" />
<div className="relative w-full max-w-[860px] rounded-2xl p-6 neu-raised" style={{ background: 'var(--color-base)' }}>
<div className="flex items-center justify-between gap-3 mb-5">
<h2 className="text-lg font-black text-text-primary m-0">Performance Notes</h2>
<button type="button" onClick={() => setShowPerformanceHelp(false)} className="neu-btn p-2" aria-label="Close">
<X size={14} />
</button>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl p-4" style={{ background: 'rgba(255,255,255,0.03)' }}>
<div className="text-sm font-black text-text-primary mb-2">Delivery Health</div>
<div className="text-xs font-semibold text-text-secondary leading-5">
Lifetime delivery is {lifetime.delivery_rate || '0%'}. Use this alongside failure and expiry counts to detect routing or formatting issues.
</div>
</div>
<div className="rounded-2xl p-4" style={{ background: 'rgba(255,255,255,0.03)' }}>
<div className="text-sm font-black text-text-primary mb-2">Conversion Quality</div>
<div className="text-xs font-semibold text-text-secondary leading-5">
Verification rate is {lifetime.verification_rate || '0%'}. If this drops while delivery stays stable, the OTP UX or expiry window may need adjustment.
</div>
</div>
<div className="rounded-2xl p-4" style={{ background: 'rgba(255,255,255,0.03)' }}>
<div className="text-sm font-black text-text-primary mb-2">Traffic Concentration</div>
<div className="text-xs font-semibold text-text-secondary leading-5">
Peak observed day is {operational.top_day?.date || 'not available'} with {operational.top_day?.total || 0} sends. Track this against key usage and webhook events when scaling.
</div>
</div>
</div>
</div>
</div>
) : null}
{showAllActivityModal ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={() => setShowAllActivityModal(false)} className="absolute inset-0 bg-black/70 border-none cursor-pointer" />
<div className="relative w-full max-w-[860px] max-h-[80vh] rounded-2xl p-6 neu-raised flex flex-col" style={{ background: 'var(--color-base)' }}>
<div className="flex items-center justify-between gap-3 mb-4">
<h2 className="text-lg font-black text-text-primary m-0">All Recent Activity</h2>
<button type="button" onClick={() => setShowAllActivityModal(false)} className="neu-btn p-2" aria-label="Close">
<X size={14} />
</button>
</div>
<div className="overflow-auto flex flex-col gap-3 pr-1">
{activityLoading && activityLogs.length === 0 ? (
<div className="text-sm font-semibold text-text-secondary">Loading activity logs...</div>
) : activityLogs.length === 0 ? (
<div className="text-sm font-semibold text-text-secondary">No activity has been logged yet.</div>
) : activityLogs.map((activity, i) => {
const presentation = getActivityPresentation(activity.type)
const Icon = presentation.icon
return (
<div key={`all-${activity.type}-${activity.created_at}-${i}`} className="flex gap-4 items-start p-3 rounded-xl" style={{ background: 'rgba(255,255,255,0.03)' }}>
<div className={`w-8 h-8 rounded-full flex items-center justify-center shrink-0 neu-inset-sm ${presentation.isError ? 'text-red-400' : 'text-text-secondary'}`}>
<Icon size={14} />
</div>
<div>
<div className={`text-sm font-bold ${presentation.isError ? 'text-red-500' : 'text-text-primary'}`}>{activity.title}</div>
{activity.meta ? <div className="text-xs font-semibold text-text-secondary mt-0.5">{activity.meta}</div> : null}
<div className="text-xs font-semibold text-text-muted mt-0.5">{getRelativeTime(activity.created_at)}</div>
</div>
</div>
)
})}
</div>
{activityHasNext ? (
<button
type="button"
onClick={loadMoreActivity}
disabled={activityLoading}
className="neu-btn py-2 mt-4 w-full"
>
{activityLoading ? 'Loading...' : 'Load More'}
</button>
) : null}
</div>
</div>
) : null}
</div>
)
}

498
src/pages/PricingPage.jsx Normal file
View File

@ -0,0 +1,498 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import {
ArrowRight,
Check,
Sparkles,
ShieldCheck,
Gauge,
Zap,
Rocket,
Crown,
Building2,
Activity,
MessageCircleMore,
Orbit,
} from 'lucide-react'
import Pricing from '../components/Pricing'
import Footer from '../components/Footer'
import { marketingConfig, marketingRateLimits, formatCount, formatInr, formatSecondsText } from '../config/marketingConfig'
export default function PricingPage() {
const [comparisonUsage, setComparisonUsage] = useState(marketingConfig.otpSliderDefault)
const comparisonRows = useMemo(() => {
const verifloTotal = comparisonUsage * marketingConfig.pricePerOtp
const smsTotal = comparisonUsage * marketingConfig.benchmarkSmsPricePerOtp + marketingConfig.benchmarkSmsPlatformFee
const managedTotal = comparisonUsage * marketingConfig.benchmarkManagedPricePerOtp + marketingConfig.benchmarkManagedPlatformFee
return [
{
provider: 'Veriflo WhatsApp OTP',
perOtp: marketingConfig.pricePerOtp,
platformFee: 0,
total: verifloTotal,
deliveryRate: `${marketingConfig.deliveryRatePercent}%`,
note: 'No monthly platform fee',
featured: true,
},
{
provider: 'Typical SMS OTP Provider',
perOtp: marketingConfig.benchmarkSmsPricePerOtp,
platformFee: marketingConfig.benchmarkSmsPlatformFee,
total: smsTotal,
deliveryRate: '~85%',
note: 'Per-message + monthly platform fee',
},
{
provider: 'Managed OTP Platform',
perOtp: marketingConfig.benchmarkManagedPricePerOtp,
platformFee: marketingConfig.benchmarkManagedPlatformFee,
total: managedTotal,
deliveryRate: '~90%',
note: 'Higher monthly base + managed tooling',
},
]
}, [comparisonUsage])
const [verifloRow, smsRow, managedRow] = comparisonRows
const savingsVsSms = Math.max(0, smsRow.total - verifloRow.total)
const savingsVsManaged = Math.max(0, managedRow.total - verifloRow.total)
const plans = [
{
name: 'Starter',
eyebrow: 'For evaluation',
icon: Rocket,
price: `${marketingConfig.sandboxFreeOtps} + ${marketingConfig.liveFreeOtps} free OTPs`,
detail: `Then ${formatInr(marketingConfig.pricePerOtp)} per OTP`,
cta: 'Start Free',
to: marketingConfig.dashboardUrl,
external: true,
featured: false,
accent: 'from-white/12 via-white/6 to-transparent',
halo: 'bg-white/8',
audience: 'Solo builders and product tests',
volume: '0 to 2,000 OTPs / month',
support: 'Docs-first onboarding',
points: [
'No credit card required',
'Full API and SDK access',
'WhatsApp delivery preview before launch',
],
},
{
name: 'Growth',
eyebrow: 'Best Seller',
icon: Crown,
price: formatInr(marketingConfig.pricePerOtp),
detail: 'Pay only for delivered OTPs',
cta: 'View Integration',
to: '/docs',
featured: true,
accent: 'from-accent/30 via-accent/12 to-transparent',
halo: 'bg-accent/18',
audience: 'Live apps handling real user auth',
volume: '2,000 to 25,000 OTPs / month',
support: 'Fast rollout with analytics visibility',
points: [
'Ideal for live production traffic',
'Delivery analytics and branded messages',
`Lower TCO than typical SMS pricing at ${formatInr(marketingConfig.benchmarkSmsPricePerOtp)}`,
],
},
{
name: 'Scale',
eyebrow: 'High-volume teams',
icon: Building2,
price: 'Custom',
detail: 'Volume pricing for larger workloads',
cta: 'Start With Docs',
to: '/docs',
featured: false,
accent: 'from-[#3dd6c6]/16 via-white/4 to-transparent',
halo: 'bg-[#3dd6c6]/12',
audience: 'Platforms, banks, marketplaces',
volume: '25,000+ OTPs / month',
support: 'Dedicated rollout planning',
points: [
'Planning for 25,000+ OTPs per month',
'Custom rollout and support workflow',
'Prepared for dedicated rate-limit planning',
],
},
]
return (
<>
<div className="min-h-screen pt-20">
<section className="max-w-[1200px] mx-auto px-6 pt-16 pb-18 relative overflow-hidden">
<div className="absolute inset-x-0 top-0 h-[420px] pointer-events-none">
<div className="absolute left-[-5%] top-16 w-[280px] h-[280px] rounded-full bg-accent/10 blur-[120px]" />
<div className="absolute right-[-2%] top-10 w-[340px] h-[340px] rounded-full bg-white/6 blur-[140px]" />
<div className="absolute left-1/2 top-0 -translate-x-1/2 w-[520px] h-[220px] rounded-full bg-[linear-gradient(180deg,rgba(37,211,102,0.12),transparent)] blur-[80px]" />
</div>
<div className="text-center max-w-[760px] mx-auto mb-14">
<div className="inline-flex items-center gap-2 px-3 py-1 rounded-full border border-accent/20 bg-accent/5 mb-6">
<Sparkles size={14} className="text-accent" />
<span className="text-[0.8rem] font-bold text-accent tracking-widest uppercase">Pricing</span>
</div>
<h1 className="text-[clamp(2.4rem,5vw,4rem)] font-black text-white tracking-tight leading-[1.05] mb-5">
Clear plans for shipping <span className="text-gradient">WhatsApp OTP</span>
</h1>
<p className="text-[1.08rem] text-text-secondary font-medium leading-relaxed">
Start free, switch to pay-as-you-go in production, and scale volume without hidden platform fees.
Delivery stays fast at under {formatSecondsText(marketingConfig.avgDeliveryTimeSeconds)} and pricing stays transparent.
</p>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-4 mb-8 relative z-10">
{[
{
icon: Activity,
label: 'Live-ready pricing',
value: 'No retainers',
},
{
icon: MessageCircleMore,
label: 'WhatsApp-first delivery',
value: `${marketingConfig.deliveryRatePercent}% delivery rate`,
},
{
icon: Orbit,
label: 'Scale path',
value: 'Free trial to custom volume',
},
].map(({ icon: Icon, label, value }) => (
<div key={label} className="glass-panel p-4 md:p-5 border-white/10 flex items-center gap-4">
<div className="w-11 h-11 rounded-2xl bg-white/5 border border-white/10 flex items-center justify-center shrink-0">
<Icon size={18} className="text-accent" />
</div>
<div>
<div className="text-[0.72rem] font-black tracking-[0.18em] text-text-muted uppercase mb-1">{label}</div>
<div className="text-[0.98rem] font-bold text-white">{value}</div>
</div>
</div>
))}
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-6 mb-16 relative z-10 items-stretch">
{plans.map((plan) => (
<article
key={plan.name}
className={`relative rounded-[30px] p-7 border overflow-hidden transition-all duration-300 ${
plan.featured
? 'bg-[linear-gradient(180deg,rgba(37,211,102,0.14),rgba(255,255,255,0.04))] border-accent/30 shadow-[0_0_60px_rgba(37,211,102,0.18)] md:-translate-y-2'
: 'bg-[linear-gradient(180deg,rgba(255,255,255,0.06),rgba(255,255,255,0.02))] border-white/10 hover:border-white/18 hover:-translate-y-1'
}`}
>
<div className={`absolute inset-0 bg-gradient-to-b ${plan.accent} pointer-events-none`} />
<div className={`absolute -right-10 -top-10 w-[180px] h-[180px] rounded-full ${plan.halo} blur-[90px] pointer-events-none`} />
{plan.featured && (
<div className="absolute top-4 right-4 px-3 py-1 rounded-full bg-accent text-black text-[0.72rem] font-black tracking-widest uppercase shadow-[0_10px_25px_rgba(37,211,102,0.3)]">
Best Seller
</div>
)}
<div className="relative z-10 flex flex-col h-full">
<div className="mb-7">
<div className="flex items-start justify-between gap-4 mb-5">
<div>
<div className={`text-[0.78rem] font-black tracking-[0.22em] uppercase mb-3 ${plan.featured ? 'text-accent' : 'text-text-muted'}`}>
{plan.eyebrow}
</div>
<h2 className="text-[1.8rem] font-black text-white mb-2 tracking-tight">{plan.name}</h2>
</div>
<div className={`w-13 h-13 rounded-2xl border flex items-center justify-center shrink-0 ${plan.featured ? 'bg-accent/14 border-accent/25' : 'bg-white/5 border-white/10'}`}>
<plan.icon size={22} className={plan.featured ? 'text-accent' : 'text-white'} />
</div>
</div>
<div className="mb-3 flex items-end gap-2">
<div className="text-[2.9rem] font-black text-white tracking-tighter leading-none">{plan.price}</div>
</div>
<div className="text-[1rem] text-text-secondary font-medium leading-relaxed">{plan.detail}</div>
</div>
<div className={`rounded-[24px] p-4 mb-6 border ${plan.featured ? 'bg-black/20 border-accent/15' : 'bg-black/20 border-white/8'}`}>
<div className="grid grid-cols-1 gap-3">
<div>
<div className="text-[0.7rem] font-black tracking-[0.18em] uppercase text-text-muted mb-1">Ideal for</div>
<div className="text-[0.95rem] font-bold text-white">{plan.audience}</div>
</div>
<div>
<div className="text-[0.7rem] font-black tracking-[0.18em] uppercase text-text-muted mb-1">Volume</div>
<div className="text-[0.95rem] font-bold text-white">{plan.volume}</div>
</div>
<div>
<div className="text-[0.7rem] font-black tracking-[0.18em] uppercase text-text-muted mb-1">Support motion</div>
<div className="text-[0.95rem] font-bold text-white">{plan.support}</div>
</div>
</div>
</div>
<div className="flex flex-col gap-3 mb-8">
{plan.points.map((point) => (
<div key={point} className="flex items-start gap-3">
<div className={`w-5 h-5 rounded-full flex items-center justify-center shrink-0 mt-px ${plan.featured ? 'bg-accent text-black' : 'bg-accent/15 text-accent'}`}>
<Check size={12} strokeWidth={3} />
</div>
<div className="text-[0.95rem] text-text-secondary font-medium leading-relaxed">{point}</div>
</div>
))}
</div>
<div className="mt-auto">
{plan.external ? (
<a
href={plan.to}
className={`w-full inline-flex items-center justify-center gap-2 rounded-2xl px-5 py-3.5 font-bold no-underline transition-colors ${plan.featured ? 'bg-accent text-black hover:bg-[#45de7f]' : 'bg-white/8 text-white hover:bg-white/12 border border-white/10'}`}
>
{plan.cta} <ArrowRight size={16} />
</a>
) : (
<Link
to={plan.to}
className={`w-full inline-flex items-center justify-center gap-2 rounded-2xl px-5 py-3.5 font-bold no-underline transition-colors ${plan.featured ? 'bg-accent text-black hover:bg-[#45de7f]' : 'bg-white/8 text-white hover:bg-white/12 border border-white/10'}`}
>
{plan.cta} <ArrowRight size={16} />
</Link>
)}
<div className="text-[0.76rem] text-text-muted font-bold tracking-[0.14em] uppercase text-center mt-4">
{plan.featured ? 'Balanced for production rollouts' : plan.name === 'Scale' ? 'Designed for heavier traffic planning' : 'Fastest path to evaluate the API'}
</div>
</div>
</div>
</article>
))}
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-4 mb-16 relative z-10">
{[
'Start in sandbox, then move to live traffic without changing pricing logic.',
'You only pay when you are actually sending OTP volume, not for empty seats.',
'Scale conversations stay grounded in throughput, delivery, and integration shape.',
].map((item) => (
<div key={item} className="rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-4 text-[0.92rem] text-text-secondary font-medium leading-relaxed">
{item}
</div>
))}
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] gap-5 mb-18">
{[
{
icon: ShieldCheck,
title: 'Free Trial',
value: `${marketingConfig.sandboxFreeOtps} Sandbox + ${marketingConfig.liveFreeOtps} Live`,
desc: 'Use the full product before going live.',
},
{
icon: Zap,
title: 'Per OTP Cost',
value: formatInr(marketingConfig.pricePerOtp),
desc: `Compared to typical SMS benchmarks around ${formatInr(marketingConfig.benchmarkSmsPricePerOtp)}.`,
},
{
icon: Gauge,
title: 'Suggested Monthly Example',
value: `${formatCount(marketingConfig.otpSliderDefault)} OTPs`,
desc: `${formatInr(marketingConfig.otpSliderDefault * marketingConfig.pricePerOtp)} estimated monthly spend.`,
},
].map(({ icon: Icon, title, value, desc }) => (
<div key={title} className="glass-panel p-6 border-white/10">
<div className="w-12 h-12 rounded-2xl bg-accent/10 border border-accent/20 flex items-center justify-center mb-5">
<Icon size={22} className="text-accent" />
</div>
<div className="text-[0.8rem] font-black text-text-muted tracking-[0.18em] uppercase mb-2">{title}</div>
<div className="text-[1.8rem] font-black text-white tracking-tight mb-2">{value}</div>
<div className="text-[0.95rem] text-text-secondary font-medium leading-relaxed">{desc}</div>
</div>
))}
</div>
</section>
<Pricing />
<section className="max-w-[1100px] mx-auto px-6 pb-18">
<div className="glass-panel p-8 md:p-10 border-white/10">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between mb-8">
<div>
<div className="text-[0.8rem] font-black text-accent tracking-[0.18em] uppercase mb-2">Business cost analysis</div>
<h2 className="text-[clamp(1.8rem,4vw,2.6rem)] font-black text-white tracking-tight leading-[1.1]">Platform fee + usage comparison</h2>
</div>
<div className="text-[0.9rem] text-text-secondary font-medium max-w-[400px]">
Transparent model for the same monthly OTP volume across providers. Assumptions are configurable via env variables.
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-5 md:p-6 mb-6">
<div className="flex items-center justify-between gap-4 mb-4">
<div className="text-[0.88rem] font-black tracking-[0.16em] text-text-muted uppercase">Monthly OTP usage</div>
<div className="text-[1.3rem] font-black text-accent">{formatCount(comparisonUsage)}</div>
</div>
<input
type="range"
min={marketingConfig.otpSliderMin}
max={marketingConfig.otpSliderMax}
step="10"
value={comparisonUsage}
onChange={(event) => setComparisonUsage(Number(event.target.value))}
className="w-full appearance-none h-2.5 rounded-full bg-white/10 cursor-pointer outline-none slider-minimal"
style={{
background: `linear-gradient(to right, var(--color-accent) 0%, var(--color-accent) ${((comparisonUsage - marketingConfig.otpSliderMin) / Math.max(marketingConfig.otpSliderMax - marketingConfig.otpSliderMin, 1)) * 100}%, rgba(255,255,255,0.1) ${((comparisonUsage - marketingConfig.otpSliderMin) / Math.max(marketingConfig.otpSliderMax - marketingConfig.otpSliderMin, 1)) * 100}%, rgba(255,255,255,0.1) 100%)`
}}
/>
<div className="flex justify-between mt-2.5 text-xs text-text-muted font-bold tracking-widest uppercase">
<span>{formatCount(marketingConfig.otpSliderMin)}</span>
<span>{formatCount(marketingConfig.otpSliderMax)}</span>
</div>
</div>
<div className="overflow-x-auto rounded-2xl border border-white/10">
<table className="w-full min-w-[720px] border-collapse text-left">
<thead>
<tr className="border-b border-white/10 bg-surface/60">
<th className="px-5 py-4 text-[0.78rem] font-black tracking-[0.16em] uppercase text-text-muted">Provider</th>
<th className="px-5 py-4 text-[0.78rem] font-black tracking-[0.16em] uppercase text-text-muted text-right">Per OTP</th>
<th className="px-5 py-4 text-[0.78rem] font-black tracking-[0.16em] uppercase text-text-muted text-right">Platform Fee</th>
<th className="px-5 py-4 text-[0.78rem] font-black tracking-[0.16em] uppercase text-text-muted text-right">Total Monthly</th>
<th className="px-5 py-4 text-[0.78rem] font-black tracking-[0.16em] uppercase text-text-muted text-right">Delivery</th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{comparisonRows.map((row) => (
<tr key={row.provider} className={row.featured ? 'bg-accent/[0.07]' : 'bg-transparent'}>
<td className="px-5 py-4">
<div className={`text-[0.96rem] font-bold ${row.featured ? 'text-accent' : 'text-white'}`}>{row.provider}</div>
<div className="text-[0.82rem] text-text-secondary font-medium mt-1">{row.note}</div>
</td>
<td className="px-5 py-4 text-right text-[0.95rem] font-bold text-white">{formatInr(row.perOtp)}</td>
<td className="px-5 py-4 text-right text-[0.95rem] font-bold text-white">{formatInr(row.platformFee, 0)}</td>
<td className={`px-5 py-4 text-right text-[1rem] font-black ${row.featured ? 'text-accent' : 'text-white'}`}>{formatInr(row.total)}</td>
<td className="px-5 py-4 text-right text-[0.95rem] font-bold text-white">{row.deliveryRate}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-4 mt-6">
<div className="rounded-2xl border border-accent/20 bg-accent/10 p-4">
<div className="text-[0.78rem] font-black tracking-[0.16em] uppercase text-accent mb-1">Savings vs SMS benchmark</div>
<div className="text-[1.7rem] font-black text-white">{formatInr(savingsVsSms)}</div>
</div>
<div className="rounded-2xl border border-accent/20 bg-accent/10 p-4">
<div className="text-[0.78rem] font-black tracking-[0.16em] uppercase text-accent mb-1">Savings vs managed OTP</div>
<div className="text-[1.7rem] font-black text-white">{formatInr(savingsVsManaged)}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-4">
<div className="text-[0.78rem] font-black tracking-[0.16em] uppercase text-text-muted mb-1">Veriflo monthly total</div>
<div className="text-[1.7rem] font-black text-white">{formatInr(verifloRow.total)}</div>
</div>
</div>
<p className="text-[0.82rem] text-text-muted font-medium mt-5 leading-relaxed">
Benchmarks are illustrative market assumptions for same-volume business comparisons. Update env values to reflect your exact competitor quotes.
</p>
</div>
</section>
<section className="max-w-[1000px] mx-auto px-6 pb-18">
<div className="glass-panel p-8 md:p-10 border-white/10">
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between mb-8">
<div>
<div className="text-[0.8rem] font-black text-accent tracking-[0.18em] uppercase mb-2">API limits</div>
<h2 className="text-[clamp(1.8rem,4vw,2.6rem)] font-black text-white tracking-tight leading-[1.1]">Rate limits for safe production usage</h2>
</div>
<div className="text-[0.95rem] text-text-secondary font-medium max-w-[360px]">
Keep send, verify, and resend traffic predictable while protecting the API from abuse.
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(220px,1fr))] gap-4">
{marketingRateLimits.map(({ endpoint, limit }) => (
<div key={endpoint} className="rounded-2xl border border-white/10 bg-white/5 p-5">
<div className="text-[0.78rem] font-black text-text-muted tracking-[0.18em] uppercase mb-2">{endpoint}</div>
<div className="text-[1.2rem] font-black text-white mb-1">{limit}</div>
<div className="text-[0.9rem] text-text-secondary font-medium">Adjust this in your env when you want the site copy to reflect backend policy.</div>
</div>
))}
</div>
</div>
</section>
<section className="max-w-[1000px] mx-auto px-6 pb-32">
<div className="text-center mb-16 max-w-[600px] mx-auto">
<h2 className="text-[clamp(2rem,4vw,3.2rem)] font-black text-white tracking-tight leading-[1.1] mb-5">
Why WhatsApp OTP vs <span className="text-gradient">alternatives?</span>
</h2>
</div>
<div className="glass-panel overflow-hidden p-0 border-white/10 shadow-2xl">
<div className="overflow-x-auto">
<table className="w-full border-collapse font-sans text-left min-w-[600px]">
<thead>
<tr className="border-b border-white/10">
{['Feature', 'Veriflo (WhatsApp)', 'SMS OTP', 'Email OTP'].map((h, i) => (
<th
key={h}
className={`p-5 md:px-6 font-bold text-[0.9rem] tracking-wider uppercase ${
i === 1 ? 'bg-accent/10 text-accent' : 'bg-surface text-white'
}`}
>
{h}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{[
['Delivery Rate', `${marketingConfig.deliveryRatePercent}%`, '~85%', '~70%'],
['Open Rate', `${marketingConfig.openRatePercent}%`, `~${marketingConfig.benchmarkSmsOpenRatePercent}%`, `~${marketingConfig.emailOpenRatePercent}%`],
['Avg. Cost/OTP', formatInr(marketingConfig.pricePerOtp), formatInr(marketingConfig.benchmarkSmsPricePerOtp), '~₹0'],
['Delivery Speed', `<${formatSecondsText(marketingConfig.avgDeliveryTimeSeconds)}`, '530 seconds', '15 minutes'],
['Spam/Block Risk', 'Very Low', 'High', 'High'],
['User Familiarity', 'Very High (WhatsApp)', 'Medium (SMS)', 'Low (check inbox?)'],
['Requires App Install', 'WhatsApp (2.5B users)', 'No', 'Email client'],
['Fake Number Risk', 'Low (WA verified)', 'Medium', 'High'],
].map(([feature, ...vals], i) => (
<tr
key={feature}
className="hover:bg-white/[0.02] transition-colors"
>
<td className="p-5 md:px-6 font-semibold text-white text-[0.95rem]">
{feature}
</td>
{vals.map((val, j) => (
<td
key={j}
className={`p-5 md:px-6 text-[0.95rem] font-medium ${
j === 0 ? 'text-accent bg-accent/[0.02]' : 'text-text-muted'
}`}
>
{val}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</div>
</section>
<Footer />
</div>
</>
)
}

View File

@ -0,0 +1,89 @@
import Footer from '../components/Footer'
export default function PrivacyPolicy() {
return (
<>
<div className="min-h-screen pt-[72px] bg-base">
<div className="max-w-[900px] mx-auto px-6 md:px-12 lg:px-20 py-20">
<h1 className="text-[2.5rem] font-black text-white mb-4 tracking-tight">Privacy Policy</h1>
<p className="text-text-secondary mb-12">Last updated: March 2026</p>
<div className="space-y-12 text-text-secondary leading-relaxed">
<section>
<h2 className="text-xl font-bold text-white mb-4">1. Introduction</h2>
<p>
MetatronCube Software Solutions ("we", "our", or "us") operates the Veriflo service. This Privacy Policy explains our practices regarding the collection, use, and protection of your personal information.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">2. Information We Collect</h2>
<p className="mb-4">We collect information you provide directly, including:</p>
<ul className="list-disc list-inside space-y-2 ml-2">
<li>Account registration details (email, phone number, company name)</li>
<li>API keys and authentication credentials</li>
<li>Phone numbers for OTP delivery</li>
<li>Usage data and billing information</li>
<li>Communications you send to us</li>
</ul>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">3. How We Use Your Information</h2>
<p className="mb-4">We use the information we collect to:</p>
<ul className="list-disc list-inside space-y-2 ml-2">
<li>Provide, maintain, and improve the Veriflo service</li>
<li>Send OTP verification codes to your end users</li>
<li>Process payments and manage billing</li>
<li>Communicate service updates and support</li>
<li>Monitor and analyze service usage and performance</li>
<li>Detect and prevent fraud and security issues</li>
</ul>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">4. Data Security</h2>
<p>
We implement industry-standard security measures to protect your information, including encryption, secure data storage, and access controls. However, no method of transmission over the internet is 100% secure.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">5. Data Retention</h2>
<p>
We retain your personal information for as long as necessary to provide our services and fulfill the purposes outlined in this policy, or as required by law. OTP logs are retained for operational and compliance purposes.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">6. Third-Party Sharing</h2>
<p>
We do not sell your personal information. We may share data with service providers who assist in operating our service, including payment processors and hosting providers, under strict confidentiality agreements.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">7. Your Rights</h2>
<p className="mb-4">You have the right to:</p>
<ul className="list-disc list-inside space-y-2 ml-2">
<li>Access your personal information</li>
<li>Correct inaccurate data</li>
<li>Request deletion of your data</li>
<li>Opt-out of marketing communications</li>
<li>Export your data</li>
</ul>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">8. Contact Us</h2>
<p>
If you have questions about this Privacy Policy or our privacy practices, please contact us at <strong className="text-white">privacy@veriflo.app</strong>.
</p>
</section>
</div>
</div>
</div>
<Footer />
</>
)
}

268
src/pages/Settings.jsx Normal file
View File

@ -0,0 +1,268 @@
import { useState, useEffect } from 'react'
import { Save, User, Mail, Building, Lock, Loader2, CheckCircle, LogOut, Zap, CircleHelp, X } from 'lucide-react'
import { useNavigate } from 'react-router-dom'
import api from '../services/api'
import { clearAuthSession, getAuthUser, updateStoredUser } from '../utils/authSession'
import { useToast } from '../components/ToastProvider'
export default function Settings() {
const toast = useToast()
const navigate = useNavigate()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState('')
const [loggingOut, setLoggingOut] = useState(false)
const [showProfileHelp, setShowProfileHelp] = useState(false)
const [showSessionHelp, setShowSessionHelp] = useState(false)
const [billingConfig, setBillingConfig] = useState(null)
const [formData, setFormData] = useState({
name: '',
email: '',
company: '',
new_password: ''
})
useEffect(() => {
fetchProfile()
}, [])
const fetchProfile = async () => {
try {
const [profileRes, billingRes] = await Promise.all([
api.get('/api/user/profile'),
api.get('/api/user/billing/config')
])
const { name, email, company } = profileRes.data.user
setFormData({
name: name || '',
email: email || '',
company: company || '',
new_password: ''
})
setBillingConfig(billingRes.data?.config || null)
} catch (err) {
console.error("Failed to fetch profile", err)
setError('Failed to load profile')
} finally {
setLoading(false)
}
}
const handleSave = async (e) => {
e.preventDefault()
setSaving(true)
setError('')
setSaved(false)
try {
const res = await api.put('/api/user/profile', formData)
const updatedUser = res.data.user
updateStoredUser(updatedUser)
setFormData({
name: updatedUser.name || '',
email: updatedUser.email || '',
company: updatedUser.company || '',
new_password: ''
})
setSaved(true)
toast.success('Profile updated successfully')
setTimeout(() => setSaved(false), 3000)
} catch (err) {
const message = err.response?.data?.error || 'Failed to update profile'
setError(message)
toast.error(message)
} finally {
setSaving(false)
}
}
const handleLogout = async () => {
setLoggingOut(true)
try {
await api.post('/api/auth/logout')
} catch (err) {
console.error('Logout request failed:', err)
} finally {
clearAuthSession()
toast.info('Signed out')
navigate('/login')
}
}
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-accent" /></div>
const currentUser = getAuthUser()
const isAdminUser = currentUser?.role === 'admin'
return (
<div className="flex flex-col gap-6 w-full">
<div>
<h2 className="text-2xl font-black text-text-primary tracking-tight m-0 mb-1">Account Settings</h2>
<p className="text-text-secondary text-sm font-semibold m-0">
Manage your personal information and security preferences.
</p>
</div>
<div className="grid grid-cols-1 xl:grid-cols-12 gap-6 items-start">
<form onSubmit={handleSave} className="xl:col-span-8 neu-raised rounded-2xl p-6 md:p-8 flex flex-col gap-6">
<div className="flex items-center justify-between gap-3 pb-2 border-b border-white/5">
<div>
<h3 className="text-lg font-black text-text-primary m-0">Profile Information</h3>
<p className="text-xs font-semibold text-text-secondary m-0 mt-1">Update identity and account details used across the dashboard.</p>
</div>
<button type="button" onClick={() => setShowProfileHelp(true)} className="neu-btn w-8 h-8 rounded-full" title="Profile help">
<CircleHelp size={13} />
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-wider text-text-secondary px-1 flex items-center gap-2">
<User size={12} /> Full Name
</label>
<input
type="text"
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
className="neu-input"
placeholder="Your Name"
/>
</div>
<div className="flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-wider text-text-secondary px-1 flex items-center gap-2">
<Building size={12} /> Company Name
</label>
<input
type="text"
value={formData.company}
onChange={e => setFormData({ ...formData, company: e.target.value })}
className="neu-input"
placeholder="e.g. Acme Inc."
/>
</div>
<div className="flex flex-col gap-2 md:col-span-2">
<label className="text-xs font-black uppercase tracking-wider text-text-secondary px-1 flex items-center gap-2">
<Mail size={12} /> Email Address
</label>
<input
type="email"
value={formData.email}
readOnly
className="neu-input opacity-60 cursor-not-allowed"
/>
<p className="text-[10px] text-text-muted font-bold px-1">Email cannot be changed directly.</p>
</div>
</div>
<div className="rounded-xl p-4" style={{ background: 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)' }}>
<div className="text-xs font-black uppercase tracking-wider text-text-secondary mb-3 flex items-center gap-2 text-red-500/80">
<Lock size={12} /> Password Update
</div>
<input
type="password"
value={formData.new_password}
onChange={e => setFormData({ ...formData, new_password: e.target.value })}
className="neu-input"
placeholder="Leave blank to keep current password"
/>
</div>
{error && <div className="text-red-500 text-xs font-bold">{error}</div>}
<div className="pt-1 flex justify-end">
<button
type="submit"
disabled={saving}
className={`neu-btn-accent px-8 py-3 gap-2 flex items-center min-w-[200px] justify-center ${saved ? 'bg-green-500/10 text-green-600' : ''}`}
>
{saving ? <Loader2 size={18} className="animate-spin" /> : saved ? <><CheckCircle size={18} /> Profile Updated!</> : <><Save size={18} /> Save Changes</>}
</button>
</div>
</form>
<div className="xl:col-span-4 flex flex-col gap-6 xl:sticky xl:top-6">
<div className="neu-raised rounded-2xl p-5">
<div className="text-xs font-black uppercase tracking-wider text-text-muted mb-2">Payment Methods</div>
<div className={`text-sm font-black ${billingConfig?.payments_enabled ? 'text-accent' : 'text-red-400'}`}>
{billingConfig?.payments_enabled ? 'UPI Payments Enabled' : 'Payments Disabled'}
</div>
<div className="text-xs font-semibold text-text-secondary mt-1">
{billingConfig?.payment_note || 'Pay via UPI and submit UTR for admin approval.'}
</div>
<button
type="button"
onClick={() => navigate(isAdminUser ? '/admin/payment-config' : '/billing')}
className="neu-btn px-4 py-2 mt-3 w-full"
>
{isAdminUser ? 'Manage Payment Methods' : 'Open Billing'}
</button>
</div>
<div className="neu-raised rounded-2xl p-6 md:p-7 flex flex-col gap-4">
<div className="flex items-center justify-between gap-2">
<h3 className="text-lg font-black text-text-primary tracking-tight m-0 flex items-center gap-2">
<Zap size={18} /> Session & Security
</h3>
<button type="button" onClick={() => setShowSessionHelp(true)} className="neu-btn w-8 h-8 rounded-full" title="Session help">
<CircleHelp size={13} />
</button>
</div>
<p className="text-text-secondary text-sm font-semibold m-0">
You are currently logged in. Sign out to close your current dashboard session on this device.
</p>
<button
type="button"
onClick={handleLogout}
disabled={loggingOut}
className="neu-btn-danger px-6 py-3 gap-2 flex items-center justify-center mt-1"
>
{loggingOut ? <Loader2 size={18} className="animate-spin" /> : <><LogOut size={18} /> Sign Out</>}
</button>
</div>
<div className="neu-raised rounded-2xl p-5">
<div className="text-xs font-black uppercase tracking-wider text-text-muted mb-2">Account Snapshot</div>
<div className="text-sm font-bold text-text-primary">{formData.name || 'Unnamed user'}</div>
<div className="text-xs font-semibold text-text-secondary mt-1">{formData.email || '-'}</div>
<div className="text-xs font-semibold text-text-secondary mt-1">{formData.company || 'No company set'}</div>
</div>
</div>
</div>
{showProfileHelp ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={() => setShowProfileHelp(false)} className="absolute inset-0 bg-black/70 border-none cursor-pointer" />
<div className="relative w-full max-w-[700px] rounded-2xl p-6 neu-raised" style={{ background: 'var(--color-base)' }}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-black text-text-primary m-0">Profile Help</h2>
<button type="button" onClick={() => setShowProfileHelp(false)} className="neu-btn p-2"><X size={14} /></button>
</div>
<div className="grid gap-3 text-sm font-semibold text-text-secondary">
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Name and company update your account identity across dashboard.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Email is locked and cannot be edited directly.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Password is optional. Keep blank to retain current one.</div>
</div>
</div>
</div>
) : null}
{showSessionHelp ? (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<button type="button" onClick={() => setShowSessionHelp(false)} className="absolute inset-0 bg-black/70 border-none cursor-pointer" />
<div className="relative w-full max-w-[700px] rounded-2xl p-6 neu-raised" style={{ background: 'var(--color-base)' }}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-black text-text-primary m-0">Session Help</h2>
<button type="button" onClick={() => setShowSessionHelp(false)} className="neu-btn p-2"><X size={14} /></button>
</div>
<div className="grid gap-3 text-sm font-semibold text-text-secondary">
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Sign out closes your current device session immediately.</div>
<div className="rounded-xl p-3" style={{ background: 'rgba(255,255,255,0.03)' }}>Use strong password updates regularly for admin or production accounts.</div>
</div>
</div>
</div>
) : null}
</div>
)
}

View File

@ -0,0 +1,109 @@
import Footer from '../components/Footer'
export default function TermsOfService() {
return (
<>
<div className="min-h-screen pt-[72px] bg-base">
<div className="max-w-[900px] mx-auto px-6 md:px-12 lg:px-20 py-20">
<h1 className="text-[2.5rem] font-black text-white mb-4 tracking-tight">Terms of Service</h1>
<p className="text-text-secondary mb-12">Last updated: March 2026</p>
<div className="space-y-12 text-text-secondary leading-relaxed">
<section>
<h2 className="text-xl font-bold text-white mb-4">1. Acceptance of Terms</h2>
<p>
By accessing and using the Veriflo service (the "Service"), you agree to be bound by these Terms of Service. If you do not agree to these terms, please do not use the Service.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">2. Service Description</h2>
<p>
Veriflo provides WhatsApp OTP (One-Time Password) delivery services for authentication and security purposes. Our service allows you to send OTP verification codes to your users via WhatsApp.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">3. Account Responsibility</h2>
<p className="mb-4">You are responsible for:</p>
<ul className="list-disc list-inside space-y-2 ml-2">
<li>Maintaining the confidentiality of your API keys and account credentials</li>
<li>All activity that occurs under your account</li>
<li>Ensuring your use complies with all applicable laws</li>
<li>Obtaining proper consent from users before sending OTPs</li>
<li>Paying all fees associated with your usage</li>
</ul>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">4. Acceptable Use</h2>
<p className="mb-4">You agree not to use the Service to:</p>
<ul className="list-disc list-inside space-y-2 ml-2">
<li>Send unsolicited or spam messages</li>
<li>Violate any laws or regulations</li>
<li>Engage in harassment, threats, or abuse</li>
<li>Distribute malware or harmful code</li>
<li>Attempt to gain unauthorized access to our systems</li>
<li>Circumvent rate limits or security measures</li>
</ul>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">5. Rate Limits and Fair Use</h2>
<p className="mb-4">Your account is subject to rate limits:</p>
<ul className="list-disc list-inside space-y-2 ml-2">
<li>Send: 100 requests per minute</li>
<li>Verify: 120 requests per minute</li>
<li>Resend: 60 requests per minute per user ID</li>
</ul>
<p className="mt-4">Excessive or abusive usage may result in account suspension.</p>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">6. Billing</h2>
<p className="mb-4">
You agree to pay all fees according to your chosen plan. We bill on a pay-as-you-go basis unless otherwise agreed. Payment failure may result in service suspension.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">7. Disclaimer of Warranties</h2>
<p>
The Service is provided "as is" without warranties of any kind, express or implied. We do not guarantee uninterrupted service, error-free delivery, or specific results. OTP delivery depends on WhatsApp availability and user connectivity.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">8. Limitation of Liability</h2>
<p>
To the fullest extent permitted by law, MetatronCube Software Solutions shall not be liable for indirect, incidental, special, or consequential damages arising from your use of the Service.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">9. Termination</h2>
<p>
We may suspend or terminate your account if you violate these terms, engage in fraudulent activity, or misuse the Service. You may terminate your account at any time.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">10. Changes to Terms</h2>
<p>
We may update these Terms of Service at any time. Continued use of the Service after changes constitutes acceptance of the new terms.
</p>
</section>
<section>
<h2 className="text-xl font-bold text-white mb-4">11. Contact Us</h2>
<p>
If you have questions about these Terms of Service, please contact us at <strong className="text-white">legal@veriflo.app</strong>.
</p>
</section>
</div>
</div>
</div>
<Footer />
</>
)
}

View File

@ -0,0 +1,135 @@
import { useState, useEffect } from 'react'
import { Users, Server, IndianRupee, Activity, Loader2, AlertTriangle } from 'lucide-react'
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'
import api from '../../services/api'
export default function AdminDashboard() {
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
const [status, setStatus] = useState(null)
useEffect(() => {
fetchAdminData()
}, [])
const fetchAdminData = async () => {
try {
const [dashRes, statusRes] = await Promise.all([
api.get('/api/admin/dashboard'),
api.get('/api/admin/whatsapp/status')
])
setData(dashRes.data)
setStatus(statusRes.data)
} catch (err) {
console.error("Admin data fetch failed", err)
} finally {
setLoading(false)
}
}
if (loading || !data) {
return (
<div className="flex justify-center items-center min-h-[60vh]">
<Loader2 className="animate-spin text-accent" size={40} />
</div>
)
}
const kpis = [
{ label: 'Total Registered Users', value: data.kpis.total_registered_users, icon: Users, color: 'text-blue-500' },
{ label: 'Platform OTPs Sent', value: data.kpis.total_platform_otps < 1000 ? data.kpis.total_platform_otps : (data.kpis.total_platform_otps / 1000).toFixed(1) + 'k', icon: Server, color: 'text-purple-500' },
{ label: 'Failed Deliveries', value: data.kpis.global_failed_otps, icon: AlertTriangle, color: 'text-red-500' },
{ label: 'WA Engine Status', value: status?.connected ? 'Online' : 'Offline', icon: Activity, color: status?.connected ? 'text-green-500' : 'text-gray-400' },
]
return (
<div className="flex flex-col gap-8">
{/* KPI Cards */}
<div className="grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] gap-6">
{kpis.map(({ label, value, icon: Icon, color }) => (
<div key={label} className="neu-raised rounded-2xl p-6">
<div className="flex justify-between items-start mb-4">
<div className="w-12 h-12 neu-inset-sm rounded-xl flex items-center justify-center">
<Icon size={20} className={color} />
</div>
</div>
<div>
<div className="text-3xl font-black text-text-primary mb-1 tracking-tight">{value}</div>
<div className="text-sm font-bold text-text-muted">{label}</div>
</div>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Usage Growth Chart */}
<div className="lg:col-span-2 neu-raised rounded-2xl p-6">
<h2 className="text-lg font-black text-text-primary m-0 mb-6">Global OTP Volume (Last 30 Days)</h2>
<div className="h-[280px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data.chart_data} margin={{ top: 5, right: 0, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="colorUsage" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--color-accent)" stopOpacity={0.3}/>
<stop offset="95%" stopColor="var(--color-accent)" stopOpacity={0}/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="var(--color-shadow-dark)" opacity={0.2} />
<XAxis dataKey="date" axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 10, fontWeight: 700 }} dy={10} />
<YAxis axisLine={false} tickLine={false} tick={{ fill: 'var(--color-text-muted)', fontSize: 10, fontWeight: 700 }} />
<Tooltip
contentStyle={{ backgroundColor: 'var(--color-base)', borderRadius: '12px', border: 'none', boxShadow: '8px 8px 16px var(--color-shadow-dark)' }}
itemStyle={{ fontWeight: 800, color: 'var(--color-accent)' }}
labelStyle={{ color: 'var(--color-text-muted)', marginBottom: '4px', fontWeight: 700, fontSize: '12px' }}
/>
<Area type="monotone" name="Total OTPs" dataKey="volume" stroke="var(--color-accent)" strokeWidth={4} fillOpacity={1} fill="url(#colorUsage)" />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
{/* System Health Widget */}
<div className="neu-raised rounded-2xl p-6 flex flex-col">
<h2 className="text-lg font-black text-text-primary m-0 mb-6">System Health</h2>
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between border-b border-white/5 pb-4">
<div>
<div className="text-sm font-bold text-text-primary">WhatsApp Core</div>
<div className="text-xs font-semibold text-text-muted">whatsapp-web.js session</div>
</div>
<div className={`px-3 py-1 text-[10px] font-black uppercase tracking-widest rounded-full flex items-center gap-1.5 ${status?.connected ? 'bg-accent/10 text-accent border border-accent/20' : 'bg-red-500/10 text-red-400 border border-red-500/20'}`}>
<div className={`w-2 h-2 rounded-full ${status?.connected ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`} />
{status?.connected ? 'Attached' : 'Disconnected'}
</div>
</div>
<div className="flex items-center justify-between border-b border-white/5 pb-4">
<div>
<div className="text-sm font-bold text-text-primary">Server Uptime</div>
<div className="text-xs font-semibold text-text-muted">Node.js process</div>
</div>
<div className="text-xs font-black text-text-primary">
{status ? (status.uptime / 3600).toFixed(1) + ' hrs' : '...'}
</div>
</div>
<div className="flex items-center justify-between pb-4">
<div>
<div className="text-sm font-bold text-text-primary">Memory Usage</div>
<div className="text-xs font-semibold text-text-muted">Heap Used</div>
</div>
<div className="text-sm font-black text-text-primary">
{status ? (status.memory.heapUsed / 1024 / 1024).toFixed(0) + 'MB' : '...'}
</div>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,79 @@
import { useState } from 'react'
import { Save, CheckCircle, Loader2 } from 'lucide-react'
export default function AdminSettings() {
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const handleSave = async (e) => {
e.preventDefault()
setSaving(true)
setSaved(false)
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000))
setSaving(false)
setSaved(true)
setTimeout(() => setSaved(false), 3000)
}
return (
<div className="flex flex-col gap-8 max-w-[800px]">
<div>
<h2 className="text-2xl font-black text-text-primary tracking-tight m-0 mb-1">Platform Settings</h2>
<p className="text-text-secondary text-sm font-semibold m-0">
Configure business rules, global limits, and pricing structures.
</p>
</div>
<form onSubmit={handleSave} className="neu-raised rounded-2xl p-6 md:p-8 flex flex-col gap-8">
<div className="flex flex-col gap-6">
<h3 className="text-base font-black text-text-primary border-b border-white/5 pb-2">Global Defaults</h3>
<div className="grid grid-cols-2 gap-6">
<div className="flex flex-col gap-2">
<label className="text-xs font-black uppercase tracking-wider text-text-secondary px-1">Free Trial Credits</label>
<input type="number" defaultValue={10} 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">Max OTP Length limit</label>
<div className="neu-inset rounded-lg overflow-hidden flex bg-base">
<select 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>6 Digits</option>
<option>8 Digits</option>
<option>10 Digits</option>
</select>
</div>
</div>
</div>
</div>
<div className="flex flex-col gap-6">
<h3 className="text-base font-black text-text-primary border-b border-white/5 pb-2">Pricing Baseline</h3>
<div className="flex flex-col gap-2 max-w-[250px]">
<label className="text-xs font-black uppercase tracking-wider text-text-secondary px-1">Cost per OTP (INR)</label>
<div className="neu-input flex items-center gap-2 px-4 py-0">
<span className="font-black text-text-muted shrink-0"></span>
<input type="number" step="0.01" defaultValue={0.20} className="flex-1 bg-transparent border-none outline-none py-2.5 text-sm font-semibold text-text-primary" />
</div>
<p className="text-[10px] font-bold text-text-muted mt-1 px-1">Used to calculate bulk package discounts.</p>
</div>
</div>
<div className="pt-4 flex justify-end">
<button
type="submit"
disabled={saving}
className={`neu-btn-accent px-8 py-3 gap-2 flex items-center min-w-[220px] justify-center ${saved ? 'bg-green-500/10 text-green-600' : ''}`}
>
{saving ? <Loader2 size={18} className="animate-spin" /> : saved ? <><CheckCircle size={18} /> Settings Updated</> : <><Save size={18} /> Update Platform Config</>}
</button>
</div>
</form>
</div>
)
}

View File

@ -0,0 +1,99 @@
import { useState, useEffect } from 'react'
import { AlertCircle, Search, Filter, Loader2 } from 'lucide-react'
import api from '../../services/api'
export default function OTPLogs() {
const [logs, setLogs] = useState([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
useEffect(() => {
fetchLogs()
}, [])
const fetchLogs = async () => {
try {
const res = await api.get('/api/admin/logs')
setLogs(res.data.logs)
} catch (err) {
console.error("Failed to fetch admin logs", err)
} finally {
setLoading(false)
}
}
const filteredLogs = logs.filter(log =>
log.request_id?.toLowerCase().includes(search.toLowerCase()) ||
log.phone?.includes(search) ||
log.user_company?.toLowerCase().includes(search.toLowerCase())
)
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-accent" size={32} /></div>
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-text-primary tracking-tight m-0 mb-1">Global OTP Logs</h2>
<p className="text-text-secondary text-sm font-semibold m-0">
Platform-wide trace of all OTP delivery attempts.
</p>
</div>
<div className="flex gap-4 w-full md:w-auto">
<div className="relative flex-1 md:w-[250px]">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
placeholder="Search Request ID..."
value={search}
onChange={e => setSearch(e.target.value)}
className="neu-input pl-10 w-full"
/>
</div>
<button className="neu-btn px-4 gap-2">
<Filter size={16} /> Filter
</button>
</div>
</div>
<div className="neu-raised rounded-2xl p-6 md:p-8">
<div className="flex flex-col gap-2">
<div className="grid grid-cols-12 px-4 py-3 text-[11px] font-black text-text-muted uppercase tracking-widest border-b border-white/5">
<div className="col-span-2">Req ID</div>
<div className="col-span-3">User / Tenant</div>
<div className="col-span-3">Phone Number</div>
<div className="col-span-2">Timestamp</div>
<div className="col-span-2 text-right">Status</div>
</div>
{filteredLogs.length === 0 ? (
<div className="text-center py-10 text-text-muted font-bold">No logs found.</div>
) : (
filteredLogs.map((log) => (
<div key={log.request_id} className="grid grid-cols-12 items-center px-4 py-3 neu-inset-sm rounded-xl bg-base text-sm font-semibold text-text-secondary transition-all hover:bg-white/5">
<div className="col-span-2 font-mono text-[11px] text-text-muted">{log.request_id}</div>
<div className="col-span-3 text-xs font-bold text-text-primary truncate pr-2">
{log.user_company || log.user_name}
</div>
<div className="col-span-3 text-text-primary tracking-wide">{log.phone}</div>
<div className="col-span-2 text-[10px] font-bold">
{new Date(log.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</div>
<div className="col-span-2 flex justify-end">
<span className={log.status === 'success' ? 'badge-success' : 'badge-error'}>
{log.status === 'failed' ? <AlertCircle size={10} /> : null}
{log.status}
</span>
</div>
</div>
))
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,131 @@
import { useEffect, useState } from 'react'
import { Loader2, CheckCircle2, XCircle, RefreshCw } from 'lucide-react'
import api from '../../services/api'
import { useToast } from '../../components/ToastProvider'
export default function PaymentApprovals() {
const toast = useToast()
const [payments, setPayments] = useState([])
const [loading, setLoading] = useState(true)
const [filter, setFilter] = useState('submitted')
const [actingId, setActingId] = useState(null)
const fetchPayments = async () => {
setLoading(true)
try {
const res = await api.get(`/api/admin/payments?status=${filter}&limit=200`)
setPayments(res.data.payments || [])
} catch (err) {
toast.error(err.response?.data?.error || 'Failed to load payment approvals')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchPayments()
}, [filter])
const handleApprove = async (payment) => {
setActingId(payment.id)
try {
await api.post(`/api/admin/payments/${payment.id}/approve`, {})
toast.success(`Approved ${payment.request_ref}`)
fetchPayments()
} catch (err) {
toast.error(err.response?.data?.error || 'Failed to approve payment')
} finally {
setActingId(null)
}
}
const handleReject = async (payment) => {
setActingId(payment.id)
try {
await api.post(`/api/admin/payments/${payment.id}/reject`, {})
toast.success(`Rejected ${payment.request_ref}`)
fetchPayments()
} catch (err) {
toast.error(err.response?.data?.error || 'Failed to reject payment')
} finally {
setActingId(null)
}
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between gap-3">
<h2 className="text-2xl font-black text-text-primary m-0">Payment Approvals</h2>
<div className="flex items-center gap-2">
<select value={filter} onChange={(e) => setFilter(e.target.value)} className="neu-input !w-[140px] !py-2 text-xs">
<option value="submitted">Submitted</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="all">All</option>
</select>
<button type="button" onClick={fetchPayments} className="neu-btn px-3 py-2 gap-2">
<RefreshCw size={14} /> Refresh
</button>
</div>
</div>
<div className="neu-raised rounded-2xl p-5">
{loading ? (
<div className="flex justify-center py-12"><Loader2 className="animate-spin text-accent" /></div>
) : payments.length === 0 ? (
<div className="text-center py-12 text-text-muted font-bold">No payment requests found.</div>
) : (
<div className="overflow-auto">
<div className="min-w-[980px]">
<div className="grid grid-cols-[1fr_1fr_0.7fr_0.8fr_0.9fr_0.7fr_1fr] gap-3 px-3 py-2 text-[11px] font-black uppercase tracking-wider text-text-muted border-b border-white/5">
<div>User</div>
<div>Request</div>
<div>Credits</div>
<div>Amount</div>
<div>UTR</div>
<div>Status</div>
<div>Actions</div>
</div>
{payments.map((payment) => (
<div key={payment.id} className="grid grid-cols-[1fr_1fr_0.7fr_0.8fr_0.9fr_0.7fr_1fr] gap-3 px-3 py-3 border-b border-white/5 items-center">
<div>
<div className="text-sm font-bold text-text-primary">{payment.user_name}</div>
<div className="text-xs text-text-muted">{payment.user_email}</div>
</div>
<div>
<div className="text-sm font-bold text-text-primary">{payment.request_ref}</div>
<div className="text-xs text-text-muted">{payment.package_name || 'Custom Pack'}</div>
</div>
<div className="text-sm font-black text-text-primary">{payment.credits}</div>
<div className="text-sm font-black text-text-primary">{Number(payment.amount_inr || 0).toFixed(2)}</div>
<div className="text-xs font-semibold text-text-secondary">{payment.utr || '-'}</div>
<div><span className={payment.status === 'approved' ? 'badge-success' : payment.status === 'rejected' ? 'badge-error' : 'badge-warning'}>{payment.status}</span></div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={actingId === payment.id || !['submitted', 'pending'].includes(payment.status)}
onClick={() => handleApprove(payment)}
className="neu-btn px-2.5 py-1.5 text-xs gap-1 disabled:opacity-40"
>
<CheckCircle2 size={12} /> Approve
</button>
<button
type="button"
disabled={actingId === payment.id || !['submitted', 'pending'].includes(payment.status)}
onClick={() => handleReject(payment)}
className="neu-btn px-2.5 py-1.5 text-xs gap-1 text-red-400 disabled:opacity-40"
>
<XCircle size={12} /> Reject
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,116 @@
import { useEffect, useState } from 'react'
import { Loader2, Save } from 'lucide-react'
import api from '../../services/api'
import { useToast } from '../../components/ToastProvider'
export default function PaymentConfig() {
const toast = useToast()
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [form, setForm] = useState({
payments_enabled: true,
upi_id: '',
upi_name: 'Veriflo',
payment_note: 'Pay via UPI and submit UTR for admin approval.',
})
const fetchConfig = async () => {
setLoading(true)
try {
const res = await api.get('/api/admin/payment-config')
const cfg = res.data?.config || {}
setForm({
payments_enabled: cfg.payments_enabled === true,
upi_id: cfg.upi_id || '',
upi_name: cfg.upi_name || 'Veriflo',
payment_note: cfg.payment_note || 'Pay via UPI and submit UTR for admin approval.',
})
} catch (err) {
toast.error(err.response?.data?.error || 'Failed to load payment configuration')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchConfig()
}, [])
const handleSave = async (e) => {
e.preventDefault()
setSaving(true)
try {
const payload = {
payments_enabled: form.payments_enabled,
upi_id: form.upi_id.trim(),
upi_name: form.upi_name.trim() || 'Veriflo',
payment_note: form.payment_note.trim(),
}
const res = await api.put('/api/admin/payment-config', payload)
toast.success(res.data?.message || 'Payment configuration saved')
await fetchConfig()
} catch (err) {
toast.error(err.response?.data?.error || 'Failed to save payment configuration')
} finally {
setSaving(false)
}
}
if (loading) {
return <div className="flex justify-center py-16"><Loader2 className="animate-spin text-accent" /></div>
}
return (
<div className="flex flex-col gap-6 max-w-[960px]">
<div>
<h2 className="text-2xl font-black text-text-primary m-0">Payment Methods</h2>
<p className="text-sm font-semibold text-text-secondary mt-1 mb-0">Configure billing collection details used by all users in Billing page.</p>
</div>
<form onSubmit={handleSave} className="neu-raised rounded-2xl p-6 flex flex-col gap-5">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={form.payments_enabled}
onChange={(e) => setForm((prev) => ({ ...prev, payments_enabled: e.target.checked }))}
/>
<span className="text-sm font-bold text-text-primary">Enable user payments</span>
</label>
<div>
<div className="text-xs font-black uppercase tracking-wider text-text-muted mb-2">UPI ID</div>
<input
className="neu-input"
placeholder="your-upi-id@bank"
value={form.upi_id}
onChange={(e) => setForm((prev) => ({ ...prev, upi_id: e.target.value }))}
/>
</div>
<div>
<div className="text-xs font-black uppercase tracking-wider text-text-muted mb-2">Payee Name</div>
<input
className="neu-input"
placeholder="Veriflo"
value={form.upi_name}
onChange={(e) => setForm((prev) => ({ ...prev, upi_name: e.target.value }))}
/>
</div>
<div>
<div className="text-xs font-black uppercase tracking-wider text-text-muted mb-2">User Billing Note</div>
<textarea
className="neu-input min-h-[90px] resize-y"
placeholder="Pay via UPI and submit UTR for admin approval."
value={form.payment_note}
onChange={(e) => setForm((prev) => ({ ...prev, payment_note: e.target.value }))}
/>
</div>
<button type="submit" disabled={saving} className="neu-btn-accent px-5 py-3 w-fit">
{saving ? <Loader2 size={14} className="animate-spin" /> : <span className="inline-flex items-center gap-2"><Save size={14} /> Save Payment Config</span>}
</button>
</form>
</div>
)
}

View File

@ -0,0 +1,141 @@
import { useState, useEffect } from 'react'
import { Ban, Gift, Search, Loader2 } from 'lucide-react'
import api from '../../services/api'
import { useToast } from '../../components/ToastProvider'
export default function UserManagement() {
const toast = useToast()
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [creditingUserId, setCreditingUserId] = useState(null)
useEffect(() => {
fetchUsers()
}, [])
const fetchUsers = async () => {
try {
const res = await api.get('/api/admin/users')
setUsers(res.data.users)
} catch (err) {
console.error("Failed to fetch users", err)
} finally {
setLoading(false)
}
}
const filteredUsers = users.filter(u =>
u.name?.toLowerCase().includes(search.toLowerCase()) ||
u.email?.toLowerCase().includes(search.toLowerCase()) ||
u.company?.toLowerCase().includes(search.toLowerCase())
)
const handleAddTrialCredits = async (user) => {
const input = window.prompt(`Add trial credits for ${user.company || user.name} (${user.email})`, '10')
if (input === null) return
const credits = Number(input)
if (!Number.isFinite(credits) || credits <= 0) {
toast.error('Please enter a valid positive credits value.')
return
}
setCreditingUserId(user.id)
try {
const res = await api.post(`/api/admin/users/${user.id}/trial-credits`, { credits })
toast.success(res.data?.message || 'Trial credits added successfully')
await fetchUsers()
} catch (err) {
toast.error(err.response?.data?.error || 'Failed to add trial credits')
} finally {
setCreditingUserId(null)
}
}
if (loading) return <div className="flex justify-center py-20"><Loader2 className="animate-spin text-accent" size={32} /></div>
return (
<div className="flex flex-col gap-8">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 className="text-2xl font-black text-text-primary tracking-tight m-0 mb-1">User Management</h2>
<p className="text-text-secondary text-sm font-semibold m-0">
View all registered users, suspend accounts, and grant credits.
</p>
</div>
<div className="relative max-w-sm w-full">
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2 text-text-muted" />
<input
type="text"
placeholder="Search users..."
value={search}
onChange={e => setSearch(e.target.value)}
className="neu-input pl-10 w-full"
/>
</div>
</div>
<div className="neu-raised rounded-2xl p-6 md:p-8">
<div className="flex flex-col gap-3">
<div className="grid grid-cols-12 px-4 py-3 text-xs font-bold text-text-muted uppercase tracking-widest border-b border-white/5">
<div className="col-span-3">Business / User</div>
<div className="col-span-2">Role</div>
<div className="col-span-2 text-right">OTPs Sent</div>
<div className="col-span-2 text-center">Joined</div>
<div className="col-span-3 text-right">Actions</div>
</div>
{filteredUsers.length === 0 ? (
<div className="text-center py-10 text-text-muted font-bold">No users found.</div>
) : (
filteredUsers.map((u) => (
<div key={u.id} className="grid grid-cols-12 items-center px-4 py-4 neu-raised-sm rounded-xl bg-base">
<div className="col-span-3 flex flex-col justify-center pr-2">
<span className="text-sm font-bold text-text-primary truncate">{u.company || u.name}</span>
<span className="text-xs font-semibold text-text-muted truncate">{u.email}</span>
<span className="text-[10px] font-bold text-text-muted mt-1">
Trial: {u.trial_remaining_credits ?? 0} / {u.trial_total_credits ?? 0} remaining
</span>
</div>
<div className="col-span-2 text-xs font-bold text-text-secondary">
<span className={`px-2 py-1 rounded-md bg-white/5 uppercase tracking-wider ${u.role === 'admin' ? 'text-accent' : ''}`}>
{u.role}
</span>
</div>
<div className="col-span-2 text-right font-mono text-sm font-black text-text-primary">
{u.otps_sent?.toLocaleString() || 0}
</div>
<div className="col-span-2 text-center text-[10px] font-black uppercase text-text-muted">
{new Date(u.created_at).toLocaleDateString()}
</div>
<div className="col-span-3 flex justify-end gap-2">
<button
className="h-8 px-3 text-xs gap-1 neu-btn"
onClick={() => handleAddTrialCredits(u)}
disabled={creditingUserId === u.id}
>
{creditingUserId === u.id ? <Loader2 size={12} className="animate-spin" /> : <Gift size={12} />}
Credits
</button>
<button className="w-8 h-8 neu-btn text-red-400 hover:text-red-500 hover:neu-raised-sm">
<Ban size={14} />
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,78 @@
import { Smartphone, RefreshCw, LogOut, CheckCircle2 } from 'lucide-react'
export default function WhatsAppStatus() {
return (
<div className="flex flex-col gap-8 max-w-[900px]">
<div>
<h2 className="text-2xl font-black text-text-primary tracking-tight m-0 mb-1">WhatsApp Core Status</h2>
<p className="text-text-secondary text-sm font-semibold m-0">
Monitor and manage the <code className="text-xs bg-black/5 px-1 py-0.5 rounded">whatsapp-web.js</code> puppeteer session.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 items-start">
{/* Connection Widget */}
<div className="neu-raised rounded-2xl p-6 flex flex-col items-center justify-center text-center relative overflow-hidden h-[340px]">
<div className="absolute inset-0 opacity-10 pointer-events-none" style={{ backgroundImage: 'radial-gradient(circle at center, #25D366 1px, transparent 1px)', backgroundSize: '20px 20px' }} />
<div className="relative mb-6">
<div className="absolute inset-0 bg-green-500 rounded-full blur-[40px] opacity-20 animate-pulse" />
<div className="w-24 h-24 neu-raised rounded-full flex items-center justify-center relative z-10 text-green-500">
<Smartphone size={40} />
<div className="absolute bottom-1 right-1 w-6 h-6 bg-base rounded-full flex items-center justify-center">
<CheckCircle2 size={20} className="text-green-500" />
</div>
</div>
</div>
<h3 className="text-2xl font-black text-text-primary mb-1">Session Active</h3>
<p className="text-sm font-bold text-green-600 mb-8 px-4 py-1 bg-green-500/10 rounded-full">Connected to WhatsApp Web API</p>
<div className="flex gap-4 w-full">
<button className="flex-1 neu-btn py-3 text-red-500 hover:text-red-600 gap-2 text-xs">
<LogOut size={14} /> Force Disconnect
</button>
<button className="flex-1 neu-btn py-3 gap-2 text-xs">
<RefreshCw size={14} /> Restart Client
</button>
</div>
</div>
{/* QR Code / Reconnect Widget (Simulated Hidden state because it's active) */}
<div className="neu-raised rounded-2xl p-6 h-[340px] flex flex-col opacity-60 pointer-events-none">
<h3 className="text-base font-black text-text-primary mb-4 border-b border-white/5 pb-2">Link New Device</h3>
<div className="flex-1 flex flex-col items-center justify-center relative">
<div className="absolute inset-0 flex items-center justify-center z-20">
<div className="bg-base/80 backdrop-blur-sm px-4 py-2 rounded-lg text-sm font-bold shadow-lg">
Disconnect active session first
</div>
</div>
{/* Mock QR graphic */}
<div className="w-40 h-40 bg-text-muted/20 rounded-xl relative overflow-hidden blur-sm">
<div className="absolute inset-4 bg-text-muted/30 grid grid-cols-4 grid-rows-4 gap-1">
{Array.from({length:16}).map((_,i) => <div key={i} className={`bg-text-secondary rounded-sm ${i%3===0 ? 'opacity-0' : 'opacity-100'}`} />)}
</div>
</div>
</div>
<p className="text-xs font-bold text-center text-text-muted mt-4">
Scan with WhatsApp to link Node backend.
</p>
</div>
</div>
<div className="neu-inset rounded-2xl p-6 max-h-75 overflow-y-auto font-mono text-xs font-bold text-text-secondary leading-relaxed flex flex-col gap-2">
<div>[2026-03-07 02:22:10] Client is ready!</div>
<div>[2026-03-07 02:22:11] Restored session from auth folder.</div>
<div>[2026-03-07 02:25:40] Message sent to 919876543210 (ACK: 1)</div>
<div>[2026-03-07 02:45:12] Message sent to 15550123456 (ACK: 1)</div>
<div className="text-green-600"> Puppeteer core heartbeat healthy...</div>
</div>
</div>
)
}

View File

@ -0,0 +1,302 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Mail, ArrowLeft, Loader2, Lock, MessageCircle, ShieldCheck, Smartphone } from 'lucide-react'
import api from '../../services/api'
function formatPhoneInput(phone) {
const digits = phone.replace(/\D/g, '').slice(0, 15)
if (!digits) return ''
return `+${digits}`
}
function getPasswordStrength(password) {
let score = 0
if (password.length >= 8) score += 1
if (/[A-Z]/.test(password)) score += 1
if (/[a-z]/.test(password) && /\d/.test(password)) score += 1
if (/[^A-Za-z0-9]/.test(password)) score += 1
if (!password) return { score: 0, label: 'Add a password', color: 'var(--color-text-muted)' }
if (score <= 1) return { score, label: 'Weak', color: '#f87171' }
if (score <= 2) return { score, label: 'Fair', color: '#fbbf24' }
if (score === 3) return { score, label: 'Strong', color: '#4ade80' }
return { score, label: 'Very strong', color: '#25D366' }
}
export default function ForgotPassword() {
const navigate = useNavigate()
const [formData, setFormData] = useState({
email: '',
phone: '',
otp: '',
password: '',
confirmPassword: '',
})
const [requestId, setRequestId] = useState('')
const [otpSent, setOtpSent] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const passwordStrength = getPasswordStrength(formData.password)
const handleChange = (e) => {
const { name, value } = e.target
setFormData((previous) => ({
...previous,
[name]: name === 'phone' ? formatPhoneInput(value) : value,
}))
}
const handleSendOtp = async (e) => {
e.preventDefault()
setLoading(true)
setError('')
setNotice('')
try {
const response = await api.post('/api/auth/password-reset/send-otp', {
email: formData.email,
phone: formData.phone,
})
setRequestId(response.data.request_id)
setOtpSent(true)
setNotice('We verified the account details and sent a WhatsApp OTP for password recovery.')
} catch (err) {
setError(err.response?.data?.error || 'Could not send password reset OTP.')
} finally {
setLoading(false)
}
}
const handleResetPassword = async (e) => {
e.preventDefault()
setError('')
setNotice('')
if (formData.password !== formData.confirmPassword) {
setError('Passwords do not match.')
return
}
setLoading(true)
try {
await api.post('/api/auth/password-reset/verify', {
request_id: requestId,
email: formData.email,
phone: formData.phone,
otp: formData.otp,
password: formData.password,
})
navigate('/login')
} catch (err) {
setError(err.response?.data?.error || 'Could not verify OTP and reset password.')
} finally {
setLoading(false)
}
}
return (
<div className="grid gap-6 lg:grid-cols-[0.92fr_1.08fr] lg:items-start">
<div className="space-y-5 lg:pr-4">
<Link to="/login" className="inline-flex items-center gap-2 text-sm font-bold text-text-secondary no-underline hover:text-text-primary transition-colors">
<ArrowLeft size={16} />
Back to login
</Link>
<div>
<h1 className="text-2xl md:text-3xl font-black text-text-primary m-0 mb-2">Reset Password</h1>
<p className="text-sm md:text-[15px] font-semibold text-text-secondary m-0 leading-6">Confirm the account email, verify the WhatsApp OTP, and choose a new password.</p>
</div>
<div
className="rounded-[28px] p-5 md:p-6"
style={{
background: 'linear-gradient(180deg, rgba(37,211,102,0.1) 0%, rgba(37,211,102,0.04) 100%)',
border: '1px solid rgba(37,211,102,0.16)',
boxShadow: 'inset 0 1px 0 rgba(255,255,255,0.04)',
}}
>
<div className="flex items-start gap-3 mb-5">
<div
className="w-10 h-10 rounded-2xl flex items-center justify-center shrink-0"
style={{ background: 'rgba(37,211,102,0.14)', color: '#25D366' }}
>
<ShieldCheck size={18} />
</div>
<div className="space-y-1">
<div className="text-sm font-black text-text-primary">Recovery checks</div>
<div className="text-xs md:text-sm font-semibold text-text-secondary leading-6">
We match the typed email with the WhatsApp number on the account, send a recovery OTP to WhatsApp, and only then allow the password change.
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{[
{ icon: Mail, title: 'Email', text: 'Type the account email for verification.' },
{ icon: MessageCircle, title: 'WhatsApp OTP', text: 'Receive and enter the OTP on the same screen.' },
{ icon: Lock, title: 'New Password', text: 'Set a new password after successful verification.' },
].map(({ icon: Icon, title, text }) => (
<div
key={title}
className="rounded-2xl p-4"
style={{ background: 'rgba(255,255,255,0.035)', border: '1px solid rgba(255,255,255,0.05)' }}
>
<div className="w-8 h-8 rounded-xl flex items-center justify-center mb-3" style={{ background: 'rgba(255,255,255,0.06)' }}>
<Icon size={15} className="text-accent" />
</div>
<div className="text-xs font-black uppercase tracking-[0.16em] text-accent mb-2">{title}</div>
<div className="text-xs md:text-sm font-semibold text-text-secondary leading-5">{text}</div>
</div>
))}
</div>
</div>
</div>
<div
className="rounded-[28px] p-5 md:p-6"
style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)' }}
>
{error && (
<div className="mb-4 p-3 rounded-xl text-sm font-bold text-center" style={{ background: 'rgba(239,68,68,0.1)', color: '#f87171', border: '1px solid rgba(239,68,68,0.2)' }}>
{error}
</div>
)}
{notice && (
<div className="mb-4 p-3 rounded-xl text-sm font-bold text-center" style={{ background: 'rgba(37,211,102,0.1)', color: '#86efac', border: '1px solid rgba(37,211,102,0.2)' }}>
{notice}
</div>
)}
<form onSubmit={otpSent ? handleResetPassword : handleSendOtp} className="flex flex-col gap-5">
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-text-secondary px-1">Email Address</label>
<div className="neu-input flex items-center gap-3 px-4 py-0">
<Mail size={15} className="text-text-muted shrink-0" />
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
required
placeholder="you@company.com"
className="flex-1 bg-transparent border-none outline-none py-3 text-sm font-semibold text-text-primary placeholder:text-text-muted"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-text-secondary px-1">WhatsApp Number</label>
<div className="neu-input flex items-center gap-3 px-4 py-0">
<Smartphone size={15} className="text-text-muted shrink-0" />
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleChange}
required
placeholder="+919999999999"
className="flex-1 bg-transparent border-none outline-none py-3 text-sm font-semibold text-text-primary placeholder:text-text-muted"
/>
</div>
</div>
</div>
{otpSent && (
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-text-secondary px-1">Recovery OTP</label>
<div className="neu-input flex items-center gap-3 px-4 py-0">
<ShieldCheck size={15} className="text-text-muted shrink-0" />
<input
type="text"
name="otp"
value={formData.otp}
onChange={handleChange}
required
inputMode="numeric"
maxLength={6}
placeholder="Enter 6-digit OTP"
className="flex-1 bg-transparent border-none outline-none py-3 text-sm font-semibold tracking-[0.3em] text-text-primary placeholder:tracking-normal placeholder:text-text-muted"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-text-secondary px-1">New Password</label>
<div className="neu-input flex items-center gap-3 px-4 py-0">
<Lock size={15} className="text-text-muted shrink-0" />
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
required
placeholder="••••••••"
className="flex-1 bg-transparent border-none outline-none py-3 text-sm font-semibold text-text-primary placeholder:text-text-muted"
/>
</div>
<div className="flex flex-col gap-2 px-1 pt-1">
<div className="flex gap-2">
{[1, 2, 3, 4].map((bar) => (
<div
key={bar}
className="h-1.5 flex-1 rounded-full transition-all duration-200"
style={{
background: passwordStrength.score >= bar ? passwordStrength.color : 'rgba(255,255,255,0.08)',
boxShadow: passwordStrength.score >= bar ? `0 0 10px ${passwordStrength.color}33` : 'none',
}}
/>
))}
</div>
<div className="flex items-center justify-between gap-3 text-[11px] font-semibold">
<span style={{ color: passwordStrength.color }}>{passwordStrength.label}</span>
<span className="text-text-muted text-right">Use 8+ chars, upper, number, symbol</span>
</div>
</div>
</div>
<div className="flex flex-col gap-1.5 md:col-span-2">
<label className="text-xs font-bold text-text-secondary px-1">Confirm New Password</label>
<div className="neu-input flex items-center gap-3 px-4 py-0">
<Lock size={15} className="text-text-muted shrink-0" />
<input
type="password"
name="confirmPassword"
value={formData.confirmPassword}
onChange={handleChange}
required
placeholder="Retype new password"
className="flex-1 bg-transparent border-none outline-none py-3 text-sm font-semibold text-text-primary placeholder:text-text-muted"
/>
</div>
</div>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<button type="submit" disabled={loading} className={`neu-btn-accent py-3.5 text-base w-full ${otpSent ? '' : 'sm:col-span-2'}`}>
{loading
? <Loader2 size={18} className="animate-spin" />
: otpSent ? 'Verify OTP & Change Password' : 'Verify Email & Send WhatsApp OTP'}
</button>
{otpSent && (
<button
type="button"
disabled={loading}
onClick={handleSendOtp}
className="neu-btn py-3 text-sm w-full"
>
Resend OTP
</button>
)}
</div>
</form>
</div>
</div>
)
}

122
src/pages/auth/Login.jsx Normal file
View File

@ -0,0 +1,122 @@
import { useState } from 'react'
import { Link, useNavigate, useLocation } from 'react-router-dom'
import { Mail, Lock, Loader2, Eye, EyeOff } from 'lucide-react'
import api from '../../services/api'
import { setAuthSession } from '../../utils/authSession'
export default function Login() {
const navigate = useNavigate()
const location = useLocation()
const [formData, setFormData] = useState({ email: '', password: '', remember_me: false })
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [showPassword, setShowPassword] = useState(false)
const handleLogin = async (e) => {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await api.post('/api/auth/login', formData)
setAuthSession({
token: res.data.token,
user: res.data.user,
remember: formData.remember_me,
})
const destination = location.state?.from || '/overview'
navigate(destination, { replace: true })
} catch (err) {
setError(err.response?.data?.error || 'Invalid credentials')
} finally {
setLoading(false)
}
}
const handleChange = (e) => {
setFormData(prev => ({ ...prev, [e.target.name]: e.target.value }))
}
return (
<div className="flex flex-col">
<div className="text-center mb-8">
<h1 className="text-2xl font-black text-text-primary m-0 mb-2">Welcome back</h1>
<p className="text-sm font-semibold text-text-secondary m-0">Sign in to your dashboard</p>
</div>
{error && (
<div className="mb-6 p-3 neu-inset-sm bg-red-400/10 text-red-500 rounded-lg text-sm font-bold text-center">
{error}
</div>
)}
<form onSubmit={handleLogin} className="flex flex-col gap-5">
<div className="flex flex-col gap-2">
<label className="text-sm font-bold text-text-primary px-1">Email Address</label>
<div className="neu-input flex items-center gap-3 px-4 py-0">
<Mail size={18} className="text-text-muted shrink-0" />
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
required
placeholder="you@company.com"
className="flex-1 bg-transparent border-none outline-none py-3 text-sm font-semibold text-text-primary placeholder:text-text-muted"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center px-1">
<label className="text-sm font-bold text-text-primary">Password</label>
<Link to="/forgot-password" className="text-xs font-bold text-accent no-underline hover:text-accent-dark">
Forgot password?
</Link>
</div>
<div className="neu-input flex items-center gap-3 px-4 py-0">
<Lock size={18} className="text-text-muted shrink-0" />
<input
type={showPassword ? 'text' : 'password'}
name="password"
value={formData.password}
onChange={handleChange}
required
placeholder="••••••••"
className="flex-1 bg-transparent border-none outline-none py-3 text-sm font-semibold text-text-primary placeholder:text-text-muted"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="text-text-muted hover:text-text-primary transition-colors shrink-0 cursor-pointer"
aria-label={showPassword ? "Hide password" : "Show password"}
>
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
</button>
</div>
</div>
<label className="flex items-center gap-2 text-xs font-bold text-text-secondary cursor-pointer select-none">
<input
type="checkbox"
name="remember_me"
checked={formData.remember_me}
onChange={(e) => setFormData(prev => ({ ...prev, remember_me: e.target.checked }))}
/>
Remember this device for 3 days
</label>
<button type="submit" disabled={loading} className="neu-btn-accent py-3.5 mt-2 text-base shadow-lg disabled:opacity-70">
{loading ? <Loader2 size={18} className="animate-spin" /> : 'Sign In'}
</button>
</form>
<div className="mt-8 text-center text-sm font-semibold text-text-secondary">
Don't have an account?{' '}
<Link to="/signup" className="text-accent font-bold no-underline hover:underline">
Sign up
</Link>
</div>
</div>
)
}

389
src/pages/auth/Signup.jsx Normal file
View File

@ -0,0 +1,389 @@
import { useEffect, useRef, useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { Mail, Lock, User, ChevronDown, Check, Loader2, ShieldCheck, Smartphone } from 'lucide-react'
import api from '../../services/api'
import { setAuthSession } from '../../utils/authSession'
const OCCUPATIONS = [
{ value: 'Student', label: 'Student', emoji: '🎓' },
{ value: 'Freelancer', label: 'Freelancer', emoji: '💼' },
{ value: 'Employee', label: 'Employee', emoji: '🏢' },
{ value: 'Developer', label: 'Independent Developer', emoji: '💻' },
{ value: 'Startup', label: 'Startup', emoji: '🚀' },
{ value: 'Business', label: 'Business', emoji: '🏬' },
{ value: 'Other', label: 'Other', emoji: '✨' },
]
function OccupationPicker({ value, onChange }) {
const [open, setOpen] = useState(false)
const ref = useRef(null)
useEffect(() => {
const handler = (e) => {
if (ref.current && !ref.current.contains(e.target)) setOpen(false)
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
const selected = OCCUPATIONS.find((occupation) => occupation.value === value)
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((previous) => !previous)}
className="neu-input flex items-center justify-between px-4 py-0 w-full cursor-pointer"
style={{ minHeight: '44px' }}
>
<span className={`flex items-center gap-2 text-sm font-semibold py-3 ${selected ? 'text-text-primary' : 'text-text-muted'}`}>
{selected ? <><span className="text-base leading-none">{selected.emoji}</span>{selected.label}</> : 'Select role'}
</span>
<ChevronDown
size={14}
className="text-text-muted transition-transform duration-200 shrink-0"
style={{ transform: open ? 'rotate(180deg)' : 'rotate(0deg)' }}
/>
</button>
{open && (
<div
className="absolute top-[calc(100%+8px)] left-0 right-0 z-50 rounded-2xl p-2 flex flex-col gap-1"
style={{
background: 'var(--color-surface)',
boxShadow: '0 16px 48px rgba(0,0,0,0.6), 0 0 0 1px rgba(255,255,255,0.06)',
}}
>
{OCCUPATIONS.map((occupation) => {
const isActive = value === occupation.value
return (
<button
key={occupation.value}
type="button"
onClick={() => { onChange(occupation.value); setOpen(false) }}
className="flex items-center gap-3 px-3 py-2.5 rounded-xl text-left transition-all duration-150 w-full border-none cursor-pointer"
style={{
background: isActive ? 'rgba(37,211,102,0.12)' : 'transparent',
color: isActive ? '#25D366' : 'var(--color-text-secondary)',
}}
onMouseEnter={(e) => { if (!isActive) e.currentTarget.style.background = 'rgba(255,255,255,0.04)' }}
onMouseLeave={(e) => { if (!isActive) e.currentTarget.style.background = 'transparent' }}
>
<span className="text-base w-5 text-center leading-none shrink-0">{occupation.emoji}</span>
<span className="text-sm font-bold flex-1">{occupation.label}</span>
{isActive && <Check size={14} className="shrink-0" style={{ color: '#25D366' }} />}
</button>
)
})}
</div>
)}
</div>
)
}
function formatPhoneInput(phone) {
const digits = phone.replace(/\D/g, '').slice(0, 15)
if (!digits) return ''
return `+${digits}`
}
function getPasswordStrength(password) {
let score = 0
if (password.length >= 8) score += 1
if (/[A-Z]/.test(password)) score += 1
if (/[a-z]/.test(password) && /\d/.test(password)) score += 1
if (/[^A-Za-z0-9]/.test(password)) score += 1
if (!password) {
return { score: 0, label: 'Add a password', color: 'var(--color-text-muted)' }
}
if (score <= 1) return { score, label: 'Weak', color: '#f87171' }
if (score <= 2) return { score, label: 'Fair', color: '#fbbf24' }
if (score === 3) return { score, label: 'Strong', color: '#4ade80' }
return { score, label: 'Very strong', color: '#25D366' }
}
export default function Signup() {
const navigate = useNavigate()
const [formData, setFormData] = useState({
phone: '',
otp: '',
name: '',
company: '',
email: '',
password: '',
})
const [requestId, setRequestId] = useState('')
const [otpSent, setOtpSent] = useState(false)
const [sendingOtp, setSendingOtp] = useState(false)
const [verifying, setVerifying] = useState(false)
const [error, setError] = useState('')
const [notice, setNotice] = useState('')
const passwordStrength = getPasswordStrength(formData.password)
const handleChange = (e) => {
const { name, value } = e.target
setFormData((previous) => ({
...previous,
[name]: name === 'phone' ? formatPhoneInput(value) : value,
}))
}
const handleSendOtp = async (e) => {
e.preventDefault()
setSendingOtp(true)
setError('')
setNotice('')
try {
const response = await api.post('/api/auth/signup/send-otp', { phone: formData.phone })
setRequestId(response.data.request_id)
setOtpSent(true)
setNotice('OTP sent to WhatsApp.')
} catch (err) {
setError(err.response?.data?.error || 'Could not send OTP right now.')
} finally {
setSendingOtp(false)
}
}
const handleVerifyAndSignup = async (e) => {
e.preventDefault()
if (!formData.company) {
setError('Please select your occupation.')
return
}
setVerifying(true)
setError('')
setNotice('')
try {
const response = await api.post('/api/auth/signup/verify', {
request_id: requestId,
otp: formData.otp,
phone: formData.phone,
name: formData.name,
company: formData.company,
email: formData.email,
password: formData.password,
})
setAuthSession({
token: response.data.token,
user: response.data.user,
remember: true,
})
navigate('/overview')
} catch (err) {
setError(err.response?.data?.error || 'Could not verify OTP and create account.')
} finally {
setVerifying(false)
}
}
const currentStep = otpSent ? 2 : 1
return (
<div className="max-w-[760px] mx-auto">
<div className="mb-6 md:mb-8">
<div className="flex items-center justify-between gap-4 mb-4">
<div>
<h1 className="text-2xl md:text-3xl font-black text-text-primary m-0">Create account</h1>
<p className="text-sm font-semibold text-text-secondary m-0 mt-2">Minimal setup. Verify WhatsApp, then finish the account.</p>
</div>
<div className="hidden sm:flex items-center gap-2">
{[1, 2].map((step) => (
<div
key={step}
className="h-2.5 rounded-full transition-all duration-200"
style={{
width: step === currentStep ? 44 : 24,
background: step <= currentStep ? '#25D366' : 'rgba(255,255,255,0.08)',
boxShadow: step <= currentStep ? '0 0 16px rgba(37,211,102,0.28)' : 'none',
}}
/>
))}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div
className="rounded-2xl px-4 py-3"
style={{ background: currentStep === 1 ? 'rgba(37,211,102,0.08)' : 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)' }}
>
<div className="text-[11px] font-black uppercase tracking-[0.16em] text-text-muted mb-1">Step 1</div>
<div className="text-sm font-bold text-text-primary">Verify WhatsApp</div>
</div>
<div
className="rounded-2xl px-4 py-3"
style={{ background: currentStep === 2 ? 'rgba(37,211,102,0.08)' : 'rgba(255,255,255,0.03)', border: '1px solid rgba(255,255,255,0.05)' }}
>
<div className="text-[11px] font-black uppercase tracking-[0.16em] text-text-muted mb-1">Step 2</div>
<div className="text-sm font-bold text-text-primary">Finish account</div>
</div>
</div>
</div>
<div
className="rounded-[28px] p-5 md:p-7"
style={{ background: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.05)' }}
>
{error && (
<div className="mb-4 p-3 rounded-xl text-sm font-bold text-center" style={{ background: 'rgba(239,68,68,0.1)', color: '#f87171', border: '1px solid rgba(239,68,68,0.2)' }}>
{error}
</div>
)}
{notice && (
<div className="mb-4 p-3 rounded-xl text-sm font-bold text-center" style={{ background: 'rgba(37,211,102,0.1)', color: '#86efac', border: '1px solid rgba(37,211,102,0.2)' }}>
{notice}
</div>
)}
<form onSubmit={otpSent ? handleVerifyAndSignup : handleSendOtp} className="flex flex-col gap-5">
<div className="grid gap-4 md:grid-cols-2">
<div className={`flex flex-col gap-1.5 ${otpSent ? '' : 'md:col-span-2'}`}>
<label className="text-xs font-bold text-text-secondary px-1">WhatsApp Number</label>
<div className="neu-input flex items-center gap-3 px-4 py-0">
<Smartphone size={15} className="text-text-muted shrink-0" />
<input
type="tel"
name="phone"
value={formData.phone}
onChange={handleChange}
required
placeholder="+919999999999"
className="flex-1 bg-transparent border-none outline-none py-3 text-sm font-semibold text-text-primary placeholder:text-text-muted"
/>
</div>
</div>
{otpSent && (
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-text-secondary px-1">OTP Code</label>
<div className="neu-input flex items-center gap-3 px-4 py-0">
<ShieldCheck size={15} className="text-text-muted shrink-0" />
<input
type="text"
name="otp"
value={formData.otp}
onChange={handleChange}
required
inputMode="numeric"
maxLength={6}
placeholder="6-digit OTP"
className="flex-1 bg-transparent border-none outline-none py-3 text-sm font-semibold tracking-[0.3em] text-text-primary placeholder:tracking-normal placeholder:text-text-muted"
/>
</div>
</div>
)}
</div>
{otpSent && (
<div className="grid gap-4 md:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-text-secondary px-1">Full Name</label>
<div className="neu-input flex items-center gap-3 px-4 py-0">
<User size={15} className="text-text-muted shrink-0" />
<input
type="text"
name="name"
value={formData.name}
onChange={handleChange}
required
placeholder="Your name"
className="flex-1 bg-transparent border-none outline-none py-3 text-sm font-semibold text-text-primary placeholder:text-text-muted"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-text-secondary px-1">Role</label>
<OccupationPicker
value={formData.company}
onChange={(value) => setFormData((previous) => ({ ...previous, company: value }))}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-text-secondary px-1">Email</label>
<div className="neu-input flex items-center gap-3 px-4 py-0">
<Mail size={15} className="text-text-muted shrink-0" />
<input
type="email"
name="email"
value={formData.email}
onChange={handleChange}
required
placeholder="you@company.com"
className="flex-1 bg-transparent border-none outline-none py-3 text-sm font-semibold text-text-primary placeholder:text-text-muted"
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-xs font-bold text-text-secondary px-1">Password</label>
<div className="neu-input flex items-center gap-3 px-4 py-0">
<Lock size={15} className="text-text-muted shrink-0" />
<input
type="password"
name="password"
value={formData.password}
onChange={handleChange}
required
placeholder="••••••••"
className="flex-1 bg-transparent border-none outline-none py-3 text-sm font-semibold text-text-primary placeholder:text-text-muted"
/>
</div>
<div className="flex flex-col gap-2 px-1 pt-1">
<div className="flex gap-2">
{[1, 2, 3, 4].map((bar) => (
<div
key={bar}
className="h-1.5 flex-1 rounded-full transition-all duration-200"
style={{
background: passwordStrength.score >= bar ? passwordStrength.color : 'rgba(255,255,255,0.08)',
boxShadow: passwordStrength.score >= bar ? `0 0 10px ${passwordStrength.color}33` : 'none',
}}
/>
))}
</div>
<div className="flex items-center justify-between gap-3 text-[11px] font-semibold">
<span style={{ color: passwordStrength.color }}>{passwordStrength.label}</span>
<span className="text-text-muted text-right">8+ chars, upper, number, symbol</span>
</div>
</div>
</div>
</div>
)}
<div className="grid gap-3 sm:grid-cols-2">
<button type="submit" disabled={sendingOtp || verifying} className={`neu-btn-accent py-3.5 text-base w-full ${otpSent ? '' : 'sm:col-span-2'}`}>
{sendingOtp || verifying
? <Loader2 size={18} className="animate-spin" />
: otpSent ? 'Create Account' : 'Send OTP'}
</button>
{otpSent && (
<button
type="button"
disabled={sendingOtp || verifying}
onClick={handleSendOtp}
className="neu-btn py-3 text-sm w-full"
>
Resend OTP
</button>
)}
</div>
</form>
<div className="mt-6 text-center text-sm font-semibold text-text-secondary">
Already have an account?{' '}
<Link to="/login" className="text-accent font-bold no-underline hover:underline">Sign in</Link>
</div>
</div>
</div>
)
}

45
src/services/api.js Normal file
View File

@ -0,0 +1,45 @@
import axios from 'axios';
import { clearAuthSession, getAuthToken } from '../utils/authSession';
const configuredBaseUrl = import.meta.env.VITE_API_BASE_URL?.trim();
const baseURL = configuredBaseUrl || '/';
const api = axios.create({
baseURL,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to attach JWT token
api.interceptors.request.use(
(config) => {
const token = getAuthToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
// Response interceptor to handle global errors (e.g., Token expired)
api.interceptors.response.use(
(response) => {
return response;
},
(error) => {
if (error.response && error.response.status === 401) {
// Clear token and redirect to login if unauthorized
clearAuthSession();
if (window.location.pathname !== '/login' && window.location.pathname !== '/signup') {
window.location.href = '/login';
}
}
return Promise.reject(error);
}
);
export default api;

79
src/utils/authSession.js Normal file
View File

@ -0,0 +1,79 @@
const TOKEN_KEY = 'jwt_token'
const USER_KEY = 'user_data'
function parseTokenPayload(token) {
try {
const part = String(token).split('.')[1]
const normalized = part.replace(/-/g, '+').replace(/_/g, '/')
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4)
const payload = JSON.parse(atob(padded))
return payload && typeof payload === 'object' ? payload : null
} catch {
return null
}
}
export function isTokenValid(token) {
if (!token) return false
const payload = parseTokenPayload(token)
if (!payload?.exp) return false
return payload.exp * 1000 > Date.now()
}
function readStorage(storage) {
const token = storage.getItem(TOKEN_KEY)
if (!isTokenValid(token)) return null
return {
token,
user: storage.getItem(USER_KEY),
scope: storage === localStorage ? 'local' : 'session',
}
}
export function getAuthSession() {
const local = readStorage(localStorage)
if (local) return local
const session = readStorage(sessionStorage)
if (session) return session
return null
}
export function getAuthToken() {
return getAuthSession()?.token || null
}
export function getAuthUser() {
const session = getAuthSession()
if (!session?.user) return {}
try {
return JSON.parse(session.user)
} catch {
return {}
}
}
export function setAuthSession({ token, user, remember }) {
const primary = remember ? localStorage : sessionStorage
const secondary = remember ? sessionStorage : localStorage
secondary.removeItem(TOKEN_KEY)
secondary.removeItem(USER_KEY)
primary.setItem(TOKEN_KEY, token)
primary.setItem(USER_KEY, JSON.stringify(user || {}))
}
export function updateStoredUser(user) {
const session = getAuthSession()
if (!session) return
const storage = session.scope === 'local' ? localStorage : sessionStorage
storage.setItem(USER_KEY, JSON.stringify(user || {}))
}
export function clearAuthSession() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(USER_KEY)
sessionStorage.removeItem(TOKEN_KEY)
sessionStorage.removeItem(USER_KEY)
}

48
vite.config.js Normal file
View File

@ -0,0 +1,48 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('react-syntax-highlighter') || id.includes('refractor') || id.includes('prismjs')) {
return 'vendor-highlight'
}
if (id.includes('recharts') || id.includes('d3-') || id.includes('victory-vendor')) {
return 'vendor-charts'
}
if (id.includes('framer-motion') || id.includes('/node_modules/motion/')) {
return 'vendor-motion'
}
if (id.includes('node_modules')) {
return 'vendor'
}
},
},
},
},
server: {
port: 5174,
proxy: {
'/api': {
target: process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000',
changeOrigin: true,
},
'/v1': {
target: process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000',
changeOrigin: true,
},
'/status': {
target: process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000',
changeOrigin: true,
},
},
},
})