update website home and dashboard

This commit is contained in:
root
2026-05-20 14:52:26 -04:00
parent d4f7de5762
commit fb15883f8e
38 changed files with 3043 additions and 1922 deletions
@@ -0,0 +1,416 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import { useDashboardI18n } from '@/components/I18nProvider'
import PublicShell from '@/components/layout/PublicShell'
import { adminUrl, marketplaceUrl } from '@/lib/urls'
import { API_BASE, EMPLOYEE_PROFILE_KEY, EMPLOYEE_TOKEN_KEY } from '@/lib/api'
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
function notifyParent(message: Record<string, unknown>) {
if (typeof window === 'undefined' || window.parent === window) return
window.parent.postMessage(message, '*')
}
export default function SignInPageClient({ embedded = false }: { embedded?: boolean }) {
const { language, theme, setLanguage, setTheme } = useDashboardI18n()
const pathname = usePathname()
const router = useRouter()
const searchParams = useSearchParams()
const requestedLanguage = searchParams.get('lang')
const requestedTheme = searchParams.get('theme')
const initializedFromQuery = useRef(false)
const dict = {
en: {
brandName: 'Space Agency',
title: 'Sign in',
subtitle: 'Enter your credentials to access your account.',
email: 'Email',
password: 'Password',
signIn: 'Sign in',
signingIn: 'Signing in…',
verify: 'Verify code',
verifying: 'Verifying…',
authCode: 'Authentication code',
enterCode: 'Enter the 6-digit code from your authenticator app.',
back: 'Back to credentials',
forgotPassword: 'Forgot your password?',
invalidCredentials: 'Invalid email or password.',
passwordNotSet: 'No password set yet. Check your invitation email or use "Forgot your password?"',
unexpectedError: 'Something went wrong. Please try again.',
},
fr: {
brandName: 'Agence Espace',
title: 'Connexion',
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
email: 'Email',
password: 'Mot de passe',
signIn: 'Connexion',
signingIn: 'Connexion…',
verify: 'Vérifier le code',
verifying: 'Vérification…',
authCode: "Code d'authentification",
enterCode: "Entrez le code à 6 chiffres de votre application d'authentification.",
back: 'Retour aux identifiants',
forgotPassword: 'Mot de passe oublié ?',
invalidCredentials: 'Email ou mot de passe invalide.',
passwordNotSet: `Aucun mot de passe défini. Vérifiez votre e-mail d'invitation ou utilisez « Mot de passe oublié ? »`,
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
},
ar: {
brandName: ' فضاء الوكالة ',
title: 'تسجيل الدخول',
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
email: 'البريد الإلكتروني',
password: 'كلمة المرور',
signIn: 'تسجيل الدخول',
signingIn: 'جارٍ تسجيل الدخول…',
verify: 'تحقق من الرمز',
verifying: 'جارٍ التحقق…',
authCode: 'رمز المصادقة',
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
back: 'العودة إلى بيانات الدخول',
forgotPassword: 'نسيت كلمة المرور؟',
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
},
}[language]
useEffect(() => {
if (initializedFromQuery.current) return
if (
(requestedLanguage === 'en' || requestedLanguage === 'fr' || requestedLanguage === 'ar') &&
requestedLanguage !== language
) {
setLanguage(requestedLanguage)
}
if (
(requestedTheme === 'light' || requestedTheme === 'medium' || requestedTheme === 'dark') &&
requestedTheme !== theme
) {
setTheme(requestedTheme)
}
initializedFromQuery.current = true
}, [language, requestedLanguage, requestedTheme, setLanguage, setTheme, theme])
useEffect(() => {
if (!initializedFromQuery.current) return
const params = new URLSearchParams(searchParams.toString())
let changed = false
if (params.get('lang') !== language) {
params.set('lang', language)
changed = true
}
if (params.get('theme') !== theme) {
params.set('theme', theme)
changed = true
}
if (!changed) return
const nextQuery = params.toString()
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false })
}, [language, pathname, router, searchParams, theme])
useEffect(() => {
const currentPath = window.location.pathname + (window.location.search || '')
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
}, [pathname, searchParams])
return (
<PublicShell embedded={embedded}>
<main className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<div className="mb-8 text-center">
<div className="flex justify-center">
<a href={marketplaceUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={104}
height={104}
priority
className="h-24 w-24 rounded-[1.75rem] border border-blue-100 bg-white p-1.5 shadow-sm transition-colors dark:border-slate-700 dark:bg-slate-900"
/>
</a>
</div>
<h1 className="mt-4 text-3xl font-black tracking-tight text-slate-900 dark:text-slate-100">
{dict.brandName}
</h1>
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">
{dict.subtitle}
</p>
<div className="mt-4 flex items-center justify-center gap-2">
{(['en', 'fr', 'ar'] as const).map((lang) => (
<button
key={lang}
type="button"
onClick={() => setLanguage(lang)}
className={`rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wider transition-colors text-blue-600 dark:text-amber-400 ${
language === lang
? 'ring-1 ring-current opacity-100'
: 'opacity-40 hover:opacity-80'
}`}
>
{lang}
</button>
))}
</div>
</div>
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm transition-colors dark:border-slate-800 dark:bg-slate-900/90">
<LocalSignInForm dict={dict} />
</section>
</div>
</main>
</PublicShell>
)
}
function LocalSignInForm({
dict,
}: {
dict: {
email: string
password: string
signIn: string
signingIn: string
verify: string
verifying: string
authCode: string
enterCode: string
back: string
forgotPassword: string
invalidCredentials: string
passwordNotSet: string
unexpectedError: string
}
}) {
const router = useRouter()
const searchParams = useSearchParams()
const { setLanguage } = useDashboardI18n()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [step, setStep] = useState<'credentials' | 'totp'>('credentials')
const [showPassword, setShowPassword] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const adminNext = searchParams.get('next') || '/dashboard'
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
function redirectAdmin(token: string) {
const hash = new URLSearchParams({ token, next: adminNext }).toString()
window.location.href = `${adminUrl}/auth-redirect#${hash}`
}
async function handleCredentials(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const empJson = await empRes.json()
if (empRes.ok && empJson?.data?.token) {
const token = empJson.data.token
localStorage.setItem(EMPLOYEE_TOKEN_KEY, token)
if (empJson?.data?.employee) {
localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(empJson.data.employee))
const prefLang = empJson.data.employee?.preferredLanguage
if (prefLang === 'en' || prefLang === 'fr' || prefLang === 'ar') {
setLanguage(prefLang)
document.cookie = `rentaldrivego-language=${prefLang}; path=/; max-age=31536000; samesite=lax`
}
}
document.cookie = `employee_token=${token}; path=/; max-age=28800; samesite=lax`
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
notifyParent({ type: 'rentaldrivego:employee-login', path: '/dashboard' + employeeRedirect })
router.push(employeeRedirect)
return
}
if (empJson?.error === 'password_not_set') {
setError(dict.passwordNotSet)
return
}
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
const adminJson = await adminRes.json()
if (adminRes.ok && adminJson?.data?.token) {
redirectAdmin(adminJson.data.token)
return
}
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
setStep('totp')
return
}
setError(dict.invalidCredentials)
} catch {
setError(dict.unexpectedError)
} finally {
setLoading(false)
}
}
async function handleTotp(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
try {
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, totpCode }),
})
const adminJson = await adminRes.json()
if (adminRes.ok && adminJson?.data?.token) {
redirectAdmin(adminJson.data.token)
return
}
setError(dict.invalidCredentials)
} catch {
setError(dict.unexpectedError)
} finally {
setLoading(false)
}
}
return (
<>
{error ? (
<div className="mb-5 rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
) : null}
{step === 'credentials' ? (
<form onSubmit={handleCredentials} className="space-y-5">
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.email}</label>
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="owner@company.com"
className="input-field"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.password}</label>
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
className="input-field pr-10"
/>
<button
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 right-3 flex items-center text-slate-400 hover:text-slate-600"
tabIndex={-1}
>
{showPassword ? (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
)}
</button>
</div>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.signingIn : dict.signIn}
</button>
<div className="text-center">
<Link href="/forgot-password" className="text-sm text-slate-500 underline decoration-slate-300 underline-offset-4 hover:text-slate-700">
{dict.forgotPassword}
</Link>
</div>
</form>
) : (
<form onSubmit={handleTotp} className="space-y-5">
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-4 text-center text-sm text-slate-600">
{dict.enterCode}
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.authCode}</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]{6}"
maxLength={6}
required
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
className="input-field text-center text-xl tracking-[0.45em]"
/>
</div>
<button
type="submit"
disabled={loading}
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
>
{loading ? dict.verifying : dict.verify}
</button>
<button
type="button"
onClick={() => {
setStep('credentials')
setTotpCode('')
setError(null)
}}
className="w-full text-sm text-slate-500 hover:text-slate-700"
>
{dict.back}
</button>
</form>
)}
</>
)
}