420 lines
15 KiB
TypeScript
420 lines
15 KiB
TypeScript
"use client"
|
|
|
|
import { useState, useEffect, useRef, Suspense } from "react"
|
|
import { useSearchParams, useRouter } from "next/navigation"
|
|
import { Eye, EyeOff, Loader2 } from "lucide-react"
|
|
|
|
const WEBSITE_THEME_KEY = "crm-website-theme"
|
|
|
|
const waves = [
|
|
{ a: 16, f: 0.011, s: 0.018, y: 0.38, fill: "rgba(27,176,206,0.18)" },
|
|
{ a: 20, f: 0.008, s: 0.013, y: 0.52, fill: "rgba(27,176,206,0.25)" },
|
|
{ a: 12, f: 0.015, s: 0.025, y: 0.28, fill: "rgba(180,192,210,0.06)" },
|
|
{ a: 26, f: 0.006, s: 0.010, y: 0.65, fill: "rgba(180,192,210,0.15)" },
|
|
{ a: 10, f: 0.019, s: 0.030, y: 0.20, fill: "rgba(27,176,206,0.10)" },
|
|
]
|
|
|
|
export default function LoginPage() {
|
|
return (
|
|
<Suspense fallback={
|
|
<div className="flex min-h-screen bg-[#0a0a0f] items-center justify-center">
|
|
<div className="w-8 h-8 border-2 border-[#1BB0CE] border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
}>
|
|
<LoginForm />
|
|
</Suspense>
|
|
)
|
|
}
|
|
|
|
function LoginForm() {
|
|
const router = useRouter()
|
|
const searchParams = useSearchParams()
|
|
const [email, setEmail] = useState("")
|
|
const [password, setPassword] = useState("")
|
|
const [showPassword, setShowPassword] = useState(false)
|
|
const [loading, setLoading] = useState(false)
|
|
const [remember, setRemember] = useState(false)
|
|
const [error, setError] = useState("")
|
|
const [counts, setCounts] = useState({ leads: 0, conversion: 0, users: 0 })
|
|
const counterStarted = useRef(false)
|
|
const [testimonialIndex, setTestimonialIndex] = useState(0)
|
|
|
|
const testimonials = [
|
|
{
|
|
text: "This CRM transformed how we manage our sales pipeline. We've seen a 40% increase in lead conversion.",
|
|
author: "Marcus Johnson, Sales Lead at Black Cipher",
|
|
},
|
|
{
|
|
text: "When you're not sure, flip a coin, because when the coin is in the air, you realize which option you're actually hoping for.",
|
|
author: "Dillen van der Merwe, Madman of Black Cipher",
|
|
},
|
|
]
|
|
|
|
useEffect(() => {
|
|
const timer = setInterval(() => {
|
|
setTestimonialIndex((prev) => (prev + 1) % testimonials.length)
|
|
}, 5000)
|
|
return () => clearInterval(timer)
|
|
}, [testimonials.length])
|
|
const canvasRef = useRef<HTMLCanvasElement>(null)
|
|
const starsCanvasRightRef = useRef<HTMLCanvasElement>(null)
|
|
|
|
useEffect(() => {
|
|
const canvas = canvasRef.current
|
|
if (!canvas) return
|
|
const ctx = canvas.getContext("2d")
|
|
if (!ctx) return
|
|
|
|
let animId: number
|
|
let time = 0
|
|
|
|
const resize = () => {
|
|
const parent = canvas.parentElement!
|
|
const rect = parent.getBoundingClientRect()
|
|
const dpr = window.devicePixelRatio || 1
|
|
canvas.width = rect.width * dpr
|
|
canvas.height = 200 * dpr
|
|
canvas.style.width = rect.width + "px"
|
|
canvas.style.height = "200px"
|
|
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
}
|
|
|
|
resize()
|
|
window.addEventListener("resize", resize)
|
|
|
|
const draw = () => {
|
|
const w = canvas.width / (window.devicePixelRatio || 1)
|
|
const h = 200
|
|
ctx!.clearRect(0, 0, w, h)
|
|
|
|
for (const wave of waves) {
|
|
ctx!.beginPath()
|
|
ctx!.moveTo(0, h)
|
|
for (let x = 0; x <= w; x++) {
|
|
const y = wave.y * h + Math.sin(x * wave.f + time * wave.s) * wave.a
|
|
ctx!.lineTo(x, y)
|
|
}
|
|
ctx!.lineTo(w, h)
|
|
ctx!.closePath()
|
|
ctx!.fillStyle = wave.fill
|
|
ctx!.fill()
|
|
}
|
|
|
|
time += 1
|
|
animId = requestAnimationFrame(draw)
|
|
}
|
|
|
|
animId = requestAnimationFrame(draw)
|
|
|
|
return () => {
|
|
cancelAnimationFrame(animId)
|
|
window.removeEventListener("resize", resize)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
const canvases = [starsCanvasRightRef.current].filter(Boolean) as HTMLCanvasElement[]
|
|
if (canvases.length === 0) return
|
|
|
|
const stars = Array.from({ length: 312 }, () => ({
|
|
x: Math.random(),
|
|
y: Math.random(),
|
|
r: 0.2 + Math.random() * 0.6,
|
|
baseOpacity: 0.12 + Math.random() * 0.43,
|
|
speed: 0.003 + Math.random() * 0.015,
|
|
phase: Math.random() * Math.PI * 2,
|
|
driftX: (Math.random() - 0.5) * 0.00003,
|
|
driftY: (Math.random() - 0.5) * 0.00003,
|
|
}))
|
|
|
|
let animId: number
|
|
let time = 0
|
|
|
|
const resize = () => {
|
|
for (const c of canvases) {
|
|
const dpr = window.devicePixelRatio || 1
|
|
const rect = c.getBoundingClientRect()
|
|
c.width = rect.width * dpr
|
|
c.height = rect.height * dpr
|
|
const ctx = c.getContext("2d")
|
|
if (ctx) ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
|
}
|
|
}
|
|
|
|
resize()
|
|
const ros = canvases.map((c) => {
|
|
const ro = new ResizeObserver(() => resize())
|
|
ro.observe(c.parentElement!)
|
|
return ro
|
|
})
|
|
window.addEventListener("resize", resize)
|
|
|
|
const draw = () => {
|
|
time += 1
|
|
for (const c of canvases) {
|
|
const dpr = window.devicePixelRatio || 1
|
|
const w = c.width / dpr
|
|
const h = c.height / dpr
|
|
const ctx = c.getContext("2d")
|
|
if (!ctx) continue
|
|
ctx.clearRect(0, 0, w, h)
|
|
|
|
for (const star of stars) {
|
|
const x = ((star.x + time * star.driftX) % 1) * w
|
|
const y = ((star.y + time * star.driftY) % 1) * h
|
|
const opacity = star.baseOpacity * (0.5 + 0.5 * Math.sin(time * star.speed + star.phase))
|
|
|
|
ctx.beginPath()
|
|
ctx.arc(x, y, star.r, 0, Math.PI * 2)
|
|
ctx.fillStyle = `rgba(255,255,255,${opacity})`
|
|
ctx.fill()
|
|
|
|
if (star.r > 0.5) {
|
|
ctx.beginPath()
|
|
ctx.arc(x, y, star.r * 1.8, 0, Math.PI * 2)
|
|
ctx.fillStyle = `rgba(255,255,255,${opacity * 0.06})`
|
|
ctx.fill()
|
|
}
|
|
}
|
|
}
|
|
|
|
animId = requestAnimationFrame(draw)
|
|
}
|
|
|
|
animId = requestAnimationFrame(draw)
|
|
|
|
return () => {
|
|
cancelAnimationFrame(animId)
|
|
ros.forEach((ro) => ro.disconnect())
|
|
window.removeEventListener("resize", resize)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (counterStarted.current) return
|
|
counterStarted.current = true
|
|
const targets = { leads: 1247, conversion: 40, users: 83 }
|
|
const steps = 150
|
|
|
|
const delayTimer = setTimeout(() => {
|
|
let step = 0
|
|
const interval = setInterval(() => {
|
|
step++
|
|
if (step >= steps) {
|
|
clearInterval(interval)
|
|
setCounts(targets)
|
|
return
|
|
}
|
|
setCounts({
|
|
leads: Math.min(Math.floor((targets.leads / steps) * step), targets.leads),
|
|
conversion: Math.min(Math.floor((targets.conversion / steps) * step), targets.conversion),
|
|
users: Math.min(Math.floor((targets.users / steps) * step), targets.users),
|
|
})
|
|
}, 15)
|
|
}, 400)
|
|
|
|
return () => clearTimeout(delayTimer)
|
|
}, [])
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
setError("")
|
|
setLoading(true)
|
|
|
|
try {
|
|
const res = await fetch("/api/auth/login", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ email, password }),
|
|
})
|
|
|
|
if (res.ok) {
|
|
localStorage.removeItem(WEBSITE_THEME_KEY)
|
|
const root = document.documentElement
|
|
root.className = root.className.split(" ").filter((c) => !c.startsWith("theme-")).join(" ").trim()
|
|
const redirectTo = searchParams.get("redirect") || "/dashboard"
|
|
router.push(redirectTo)
|
|
} else {
|
|
const data = await res.json().catch(() => ({}))
|
|
setError(data.error || "Invalid email or password.")
|
|
}
|
|
} catch {
|
|
setError("Connection error. Please try again.")
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex min-h-screen bg-[#0a0a0f]">
|
|
<div className="left-panel flex flex-1 flex-col p-12">
|
|
<div className="relative z-10 flex-1 flex items-center justify-center">
|
|
<div className="text-center">
|
|
<div className="bc-logo mb-8" style={{"--theme-primary":"#1BB0CE"} as React.CSSProperties}>
|
|
Black <span className="accent">Cipher</span>
|
|
</div>
|
|
<h1 className="text-[34px] font-extrabold text-[#e8e8ef] leading-tight tracking-[-0.6px]">
|
|
Your Agency's{" "}
|
|
<span className="growth-word">Growth</span>{" "}
|
|
Starts Here
|
|
</h1>
|
|
<p className="mt-4 text-[13px] leading-[1.7] max-w-[360px] mx-auto body-text">
|
|
Manage leads, track conversions, and grow your web development business with our CRM.
|
|
</p>
|
|
<div className="stats-row">
|
|
<div className="stat">
|
|
<div className="stat-number">{counts.leads.toLocaleString()}</div>
|
|
<div className="stat-label">leads tracked</div>
|
|
</div>
|
|
<div className="stat">
|
|
<div className="stat-number">{counts.conversion}%</div>
|
|
<div className="stat-label">conversion lift</div>
|
|
</div>
|
|
<div className="stat">
|
|
<div className="stat-number">{counts.users}</div>
|
|
<div className="stat-label">active users</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="relative z-10 testimonial" style={{ background: "rgba(255,255,255,0.7)", padding: "16px 20px 16px 16px", clipPath: "url(#testimonialClip)" }}>
|
|
<svg width="0" height="0" style={{ position: "absolute" }}>
|
|
<defs>
|
|
<clipPath id="testimonialClip" clipPathUnits="objectBoundingBox">
|
|
<path d="M0,0 L0.92,0 C0.96,0 1,0.03 1,0.08 C1,0.14 0.97,0.22 0.9,0.3 C0.85,0.36 0.83,0.42 0.83,0.5 C0.83,0.58 0.85,0.64 0.9,0.7 C0.97,0.78 1,0.86 1,0.92 C1,0.97 0.96,1 0.92,1 L0,1 Z" />
|
|
</clipPath>
|
|
</defs>
|
|
</svg>
|
|
<div className="transition-opacity duration-500" key={testimonialIndex}>
|
|
<p className="text-sm font-semibold leading-relaxed" style={{ color: "#0a0a0f" }}>
|
|
“{testimonials[testimonialIndex].text}”
|
|
</p>
|
|
<p className="text-xs font-bold mt-2" style={{ color: "#0a0a0f" }}>
|
|
{testimonials[testimonialIndex].author}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center justify-center gap-1.5 mt-3">
|
|
{testimonials.map((_, i) => (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
onClick={() => setTestimonialIndex(i)}
|
|
className={`h-1.5 rounded-full transition-all duration-300 ${i === testimonialIndex ? "w-5 bg-[#1BB0CE]" : "w-1.5 bg-[#1BB0CE]/30"}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<canvas ref={canvasRef} className="wave-canvas" />
|
|
</div>
|
|
|
|
<div className="panel-divider" />
|
|
|
|
<div className="right-panel flex items-center justify-center p-10">
|
|
<canvas ref={starsCanvasRightRef} className="stars-canvas" />
|
|
<div className="accent-line" />
|
|
|
|
<div className="w-full" style={{ maxWidth: 340 }}>
|
|
<h2 className="text-[24px] font-bold text-[#e8e8ef] tracking-[-0.3px] mb-[5px]">
|
|
Welcome back
|
|
</h2>
|
|
<p className="text-[13px] mb-[26px] subheading-text">
|
|
Sign in to your account to continue
|
|
</p>
|
|
|
|
{error && (
|
|
<div className="mb-4 rounded bg-red-500/10 px-4 py-2 text-sm text-red-400">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-5">
|
|
<div>
|
|
<label htmlFor="email" className="login-label">
|
|
Email
|
|
</label>
|
|
<div className="input-sheen">
|
|
<input
|
|
id="email"
|
|
type="email"
|
|
placeholder="name@company.com"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
autoComplete="email"
|
|
className="login-input"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex items-center justify-between mb-[6px]">
|
|
<label htmlFor="password" className="login-label" style={{ marginBottom: 0 }}>
|
|
Password
|
|
</label>
|
|
<button type="button" className="text-[12px] text-[#1BB0CE] hover:underline" onClick={() => alert("Please contact your administrator to reset your password.")}>
|
|
Forgot password?
|
|
</button>
|
|
</div>
|
|
<div className="input-sheen input-sheen-delayed">
|
|
<div className="relative">
|
|
<input
|
|
id="password"
|
|
type={showPassword ? "text" : "password"}
|
|
placeholder="Enter your password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required
|
|
autoComplete="current-password"
|
|
className="login-input"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowPassword(!showPassword)}
|
|
className="password-toggle"
|
|
>
|
|
{showPassword ? (
|
|
<EyeOff className="h-4 w-4" />
|
|
) : (
|
|
<Eye className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={remember}
|
|
onChange={(e) => setRemember(e.target.checked)}
|
|
className="login-checkbox"
|
|
/>
|
|
<span className="text-[13px] checkbox-text">
|
|
Remember me
|
|
</span>
|
|
</label>
|
|
|
|
<button type="submit" className="auth-btn" disabled={loading}>
|
|
{loading && <Loader2 className="h-4 w-4 animate-spin" />}
|
|
{loading ? "Signing in..." : "Sign in"}
|
|
</button>
|
|
</form>
|
|
|
|
<p className="text-center text-[11px] mt-4 footer-text">
|
|
By signing in, you agree to our{" "}
|
|
<button type="button" className="text-[#1BB0CE] hover:underline">
|
|
Terms of Service
|
|
</button>{" "}
|
|
and{" "}
|
|
<button type="button" className="text-[#1BB0CE] hover:underline">
|
|
Privacy Policy
|
|
</button>
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|