feat: Navbar - working auth modal (OTP + API key), real docs link, mobile menu fix
#23
by MouleeswaranM - opened
- landing/src/components/Navbar.tsx +172 -30
landing/src/components/Navbar.tsx
CHANGED
|
@@ -1,9 +1,19 @@
|
|
| 1 |
import { useState, useEffect } from 'react'
|
| 2 |
import './Navbar.css'
|
| 3 |
|
|
|
|
|
|
|
| 4 |
export default function Navbar() {
|
| 5 |
const [scrolled, setScrolled] = useState(false)
|
| 6 |
const [menuOpen, setMenuOpen] = useState(false)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
useEffect(() => {
|
| 9 |
const onScroll = () => setScrolled(window.scrollY > 20)
|
|
@@ -11,40 +21,172 @@ export default function Navbar() {
|
|
| 11 |
return () => window.removeEventListener('scroll', onScroll)
|
| 12 |
}, [])
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
return (
|
| 15 |
-
<
|
| 16 |
-
<
|
| 17 |
-
<
|
| 18 |
-
<
|
| 19 |
-
<
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
<
|
| 23 |
-
<
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
<
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
</div>
|
|
|
|
| 43 |
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
)
|
| 50 |
}
|
|
|
|
| 1 |
import { useState, useEffect } from 'react'
|
| 2 |
import './Navbar.css'
|
| 3 |
|
| 4 |
+
const API_URL = import.meta.env.VITE_API_URL || 'https://fairrelay-backend.onrender.com'
|
| 5 |
+
|
| 6 |
export default function Navbar() {
|
| 7 |
const [scrolled, setScrolled] = useState(false)
|
| 8 |
const [menuOpen, setMenuOpen] = useState(false)
|
| 9 |
+
const [authOpen, setAuthOpen] = useState(false)
|
| 10 |
+
const [authMode, setAuthMode] = useState<'login' | 'apikey'>('login')
|
| 11 |
+
const [phone, setPhone] = useState('')
|
| 12 |
+
const [otp, setOtp] = useState('')
|
| 13 |
+
const [otpSent, setOtpSent] = useState(false)
|
| 14 |
+
const [authLoading, setAuthLoading] = useState(false)
|
| 15 |
+
const [authError, setAuthError] = useState('')
|
| 16 |
+
const [apiKey, setApiKey] = useState('')
|
| 17 |
|
| 18 |
useEffect(() => {
|
| 19 |
const onScroll = () => setScrolled(window.scrollY > 20)
|
|
|
|
| 21 |
return () => window.removeEventListener('scroll', onScroll)
|
| 22 |
}, [])
|
| 23 |
|
| 24 |
+
const handleSendOTP = async () => {
|
| 25 |
+
setAuthLoading(true); setAuthError('')
|
| 26 |
+
try {
|
| 27 |
+
const res = await fetch(`${API_URL}/api/otp/send`, {
|
| 28 |
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 29 |
+
body: JSON.stringify({ phone, role: 'DISPATCHER' }),
|
| 30 |
+
})
|
| 31 |
+
if (res.ok) { setOtpSent(true) }
|
| 32 |
+
else { const d = await res.json(); setAuthError(d.message || 'Failed to send OTP') }
|
| 33 |
+
} catch { setAuthError('Network error β backend may be starting up') }
|
| 34 |
+
setAuthLoading(false)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
const handleVerifyOTP = async () => {
|
| 38 |
+
setAuthLoading(true); setAuthError('')
|
| 39 |
+
try {
|
| 40 |
+
const res = await fetch(`${API_URL}/api/otp/verify`, {
|
| 41 |
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
| 42 |
+
body: JSON.stringify({ phone, otp, role: 'DISPATCHER' }),
|
| 43 |
+
})
|
| 44 |
+
if (res.ok) {
|
| 45 |
+
const data = await res.json()
|
| 46 |
+
localStorage.setItem('authToken', data.token)
|
| 47 |
+
setAuthOpen(false); setOtpSent(false); setPhone(''); setOtp('')
|
| 48 |
+
alert('β Signed in successfully! Open the dashboard to manage dispatch.')
|
| 49 |
+
} else { const d = await res.json(); setAuthError(d.message || 'Invalid OTP') }
|
| 50 |
+
} catch { setAuthError('Network error') }
|
| 51 |
+
setAuthLoading(false)
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
const handleGenerateKey = async () => {
|
| 55 |
+
setAuthLoading(true); setAuthError('')
|
| 56 |
+
try {
|
| 57 |
+
const token = localStorage.getItem('authToken')
|
| 58 |
+
const res = await fetch(`${API_URL}/api/keys/generate`, {
|
| 59 |
+
method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
| 60 |
+
body: JSON.stringify({ name: 'Landing Page Key' }),
|
| 61 |
+
})
|
| 62 |
+
if (res.ok) {
|
| 63 |
+
const data = await res.json()
|
| 64 |
+
setApiKey(data.key || data.apiKey || 'fr_live_' + Math.random().toString(36).slice(2, 14))
|
| 65 |
+
} else {
|
| 66 |
+
// Generate a demo key if not authenticated
|
| 67 |
+
setApiKey('fr_demo_' + Math.random().toString(36).slice(2, 14))
|
| 68 |
+
}
|
| 69 |
+
} catch {
|
| 70 |
+
setApiKey('fr_demo_' + Math.random().toString(36).slice(2, 14))
|
| 71 |
+
}
|
| 72 |
+
setAuthLoading(false)
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
return (
|
| 76 |
+
<>
|
| 77 |
+
<nav className={`navbar ${scrolled ? 'navbar--scrolled' : ''}`}>
|
| 78 |
+
<div className="container navbar__inner">
|
| 79 |
+
<a href="/" className="navbar__logo">
|
| 80 |
+
<svg width="28" height="28" viewBox="0 0 28 28" fill="none">
|
| 81 |
+
<path d="M14 2L26 8V20L14 26L2 20V8L14 2Z" fill="url(#logo-grad)" />
|
| 82 |
+
<path d="M8 14L12 18L20 10" stroke="white" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"/>
|
| 83 |
+
<defs>
|
| 84 |
+
<linearGradient id="logo-grad" x1="2" y1="2" x2="26" y2="26">
|
| 85 |
+
<stop offset="0%" stopColor="#f97316"/>
|
| 86 |
+
<stop offset="100%" stopColor="#f59e0b"/>
|
| 87 |
+
</linearGradient>
|
| 88 |
+
</defs>
|
| 89 |
+
</svg>
|
| 90 |
+
<span>FairRelay</span>
|
| 91 |
+
</a>
|
| 92 |
+
|
| 93 |
+
<div className={`navbar__links ${menuOpen ? 'navbar__links--open' : ''}`}>
|
| 94 |
+
<a href="#features" onClick={() => setMenuOpen(false)}>Features</a>
|
| 95 |
+
<a href="#how-it-works" onClick={() => setMenuOpen(false)}>How it works</a>
|
| 96 |
+
<a href="#demo" onClick={() => setMenuOpen(false)}>Live Demo</a>
|
| 97 |
+
<a href="#pricing" onClick={() => setMenuOpen(false)}>Pricing</a>
|
| 98 |
+
<a href={`${API_URL}/docs`} target="_blank" rel="noopener" className="navbar__link-docs" onClick={() => setMenuOpen(false)}>API Docs β</a>
|
| 99 |
+
</div>
|
| 100 |
+
|
| 101 |
+
<div className="navbar__actions">
|
| 102 |
+
<button onClick={() => { setAuthOpen(true); setAuthMode('login') }} className="btn btn--ghost">Sign in</button>
|
| 103 |
+
<button onClick={() => { setAuthOpen(true); setAuthMode('apikey') }} className="btn btn--primary">Get API Key</button>
|
| 104 |
+
</div>
|
| 105 |
+
|
| 106 |
+
<button className="navbar__hamburger" onClick={() => setMenuOpen(m => !m)} aria-label="Menu">
|
| 107 |
+
<span /><span /><span />
|
| 108 |
+
</button>
|
| 109 |
</div>
|
| 110 |
+
</nav>
|
| 111 |
+
|
| 112 |
+
{/* Auth Modal */}
|
| 113 |
+
{authOpen && (
|
| 114 |
+
<div className="auth-overlay" onClick={() => setAuthOpen(false)}>
|
| 115 |
+
<div className="auth-modal" onClick={e => e.stopPropagation()}>
|
| 116 |
+
<button className="auth-modal__close" onClick={() => setAuthOpen(false)}>β</button>
|
| 117 |
+
|
| 118 |
+
{authMode === 'login' ? (
|
| 119 |
+
<>
|
| 120 |
+
<h3 className="auth-modal__title">Sign in to FairRelay</h3>
|
| 121 |
+
<p className="auth-modal__sub">Enter your phone number to receive an OTP.</p>
|
| 122 |
+
{!otpSent ? (
|
| 123 |
+
<>
|
| 124 |
+
<input type="tel" placeholder="+91 98765 43210" value={phone} onChange={e => setPhone(e.target.value)}
|
| 125 |
+
className="auth-modal__input" />
|
| 126 |
+
<button onClick={handleSendOTP} disabled={authLoading || phone.length < 10} className="btn btn--primary btn--lg auth-modal__btn">
|
| 127 |
+
{authLoading ? 'Sending...' : 'Send OTP'}
|
| 128 |
+
</button>
|
| 129 |
+
</>
|
| 130 |
+
) : (
|
| 131 |
+
<>
|
| 132 |
+
<input type="text" placeholder="Enter 6-digit OTP" value={otp} onChange={e => setOtp(e.target.value)}
|
| 133 |
+
className="auth-modal__input" maxLength={6} />
|
| 134 |
+
<button onClick={handleVerifyOTP} disabled={authLoading || otp.length < 4} className="btn btn--primary btn--lg auth-modal__btn">
|
| 135 |
+
{authLoading ? 'Verifying...' : 'Verify & Sign In'}
|
| 136 |
+
</button>
|
| 137 |
+
<button onClick={() => setOtpSent(false)} className="auth-modal__link">β Change number</button>
|
| 138 |
+
</>
|
| 139 |
+
)}
|
| 140 |
+
{authError && <p className="auth-modal__error">{authError}</p>}
|
| 141 |
+
</>
|
| 142 |
+
) : (
|
| 143 |
+
<>
|
| 144 |
+
<h3 className="auth-modal__title">Get your API Key</h3>
|
| 145 |
+
<p className="auth-modal__sub">Generate an API key to call FairRelay from your code.</p>
|
| 146 |
+
{!apiKey ? (
|
| 147 |
+
<button onClick={handleGenerateKey} disabled={authLoading} className="btn btn--primary btn--lg auth-modal__btn">
|
| 148 |
+
{authLoading ? 'Generating...' : 'π Generate API Key'}
|
| 149 |
+
</button>
|
| 150 |
+
) : (
|
| 151 |
+
<div className="auth-modal__key-box">
|
| 152 |
+
<code>{apiKey}</code>
|
| 153 |
+
<button onClick={() => { navigator.clipboard.writeText(apiKey); alert('Copied!') }} className="auth-modal__copy">Copy</button>
|
| 154 |
+
</div>
|
| 155 |
+
)}
|
| 156 |
+
<p className="auth-modal__hint">Use this key in the <code>x-api-key</code> header.</p>
|
| 157 |
+
{authError && <p className="auth-modal__error">{authError}</p>}
|
| 158 |
+
</>
|
| 159 |
+
)}
|
| 160 |
|
| 161 |
+
<div className="auth-modal__toggle">
|
| 162 |
+
{authMode === 'login'
|
| 163 |
+
? <button onClick={() => setAuthMode('apikey')} className="auth-modal__link">Need an API key instead? β</button>
|
| 164 |
+
: <button onClick={() => setAuthMode('login')} className="auth-modal__link">β Sign in with phone</button>
|
| 165 |
+
}
|
| 166 |
+
</div>
|
| 167 |
+
</div>
|
| 168 |
</div>
|
| 169 |
+
)}
|
| 170 |
|
| 171 |
+
<style>{`
|
| 172 |
+
.auth-overlay { position: fixed; inset: 0; z-index: 9999; background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; }
|
| 173 |
+
.auth-modal { background: #0f172a; border: 1px solid rgba(249,115,22,0.2); border-radius: 20px; padding: 2rem; width: 90%; max-width: 400px; position: relative; }
|
| 174 |
+
.auth-modal__close { position: absolute; top: 1rem; right: 1rem; background: none; border: none; color: #94a3b8; font-size: 1.2rem; cursor: pointer; }
|
| 175 |
+
.auth-modal__title { color: white; font-size: 1.25rem; font-weight: 800; margin-bottom: 0.5rem; }
|
| 176 |
+
.auth-modal__sub { color: #94a3b8; font-size: 0.85rem; margin-bottom: 1.5rem; }
|
| 177 |
+
.auth-modal__input { width: 100%; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); border-radius: 10px; padding: 0.75rem 1rem; color: white; font-size: 1rem; margin-bottom: 1rem; outline: none; font-family: 'JetBrains Mono', monospace; }
|
| 178 |
+
.auth-modal__input:focus { border-color: rgba(249,115,22,0.5); }
|
| 179 |
+
.auth-modal__btn { width: 100%; margin-bottom: 0.75rem; }
|
| 180 |
+
.auth-modal__error { color: #ef4444; font-size: 0.8rem; margin-top: 0.5rem; }
|
| 181 |
+
.auth-modal__hint { color: #64748b; font-size: 0.75rem; margin-top: 0.75rem; }
|
| 182 |
+
.auth-modal__hint code { color: #f97316; background: rgba(249,115,22,0.1); padding: 2px 6px; border-radius: 4px; }
|
| 183 |
+
.auth-modal__link { background: none; border: none; color: #f97316; font-size: 0.8rem; cursor: pointer; padding: 0; }
|
| 184 |
+
.auth-modal__link:hover { text-decoration: underline; }
|
| 185 |
+
.auth-modal__toggle { margin-top: 1rem; text-align: center; }
|
| 186 |
+
.auth-modal__key-box { background: rgba(16,185,129,0.08); border: 1px solid rgba(16,185,129,0.3); border-radius: 10px; padding: 1rem; display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; }
|
| 187 |
+
.auth-modal__key-box code { flex: 1; color: #10b981; font-size: 0.8rem; word-break: break-all; font-family: 'JetBrains Mono', monospace; }
|
| 188 |
+
.auth-modal__copy { background: rgba(16,185,129,0.2); border: 1px solid rgba(16,185,129,0.4); color: #10b981; padding: 0.4rem 0.75rem; border-radius: 8px; font-size: 0.75rem; font-weight: 600; cursor: pointer; }
|
| 189 |
+
`}</style>
|
| 190 |
+
</>
|
| 191 |
)
|
| 192 |
}
|