update website home and dashboard
This commit is contained in:
@@ -648,18 +648,9 @@ export default function FleetPage() {
|
||||
{filtered.map((vehicle) => (
|
||||
<tr key={vehicle.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-10 rounded-lg bg-slate-100 overflow-hidden flex-shrink-0">
|
||||
{vehicle.primaryPhoto ? (
|
||||
<Image src={vehicle.primaryPhoto} alt={`${vehicle.make} ${vehicle.model}`} width={48} height={40} className="w-full h-full object-cover" unoptimized={!vehicle.primaryPhoto.includes('res.cloudinary.com')} />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-slate-400 text-xs">{f.noPhoto}</div>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{vehicle.make} {vehicle.model}</p>
|
||||
<p className="text-xs text-slate-400">{vehicle.year} · {vehicle.licensePlate}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">{vehicle.make} {vehicle.model}</p>
|
||||
<p className="text-xs text-slate-400">{vehicle.year} · {vehicle.licensePlate}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useEffect, useState } from 'react'
|
||||
import { Calendar, Car, Users, DollarSign, AlertTriangle, Clock, XCircle } from 'lucide-react'
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts'
|
||||
import StatCard from '@/components/ui/StatCard'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { formatCurrency } from '@rentaldrivego/types'
|
||||
|
||||
@@ -38,6 +38,10 @@ interface DashboardData {
|
||||
}
|
||||
}
|
||||
|
||||
interface EmployeeProfile {
|
||||
role?: string
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
CONFIRMED: 'badge-blue',
|
||||
PENDING: 'badge-amber',
|
||||
@@ -108,12 +112,22 @@ export default function DashboardPage() {
|
||||
|
||||
const [data, setData] = useState<DashboardData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [error, setError] = useState<{ code: string; message: string } | null>(null)
|
||||
const [employeeRole, setEmployeeRole] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
|
||||
if (!cached) return
|
||||
const profile = JSON.parse(cached) as EmployeeProfile
|
||||
setEmployeeRole(profile.role ?? null)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<DashboardData>('/analytics/dashboard')
|
||||
.then(setData)
|
||||
.catch((err) => setError(err.message))
|
||||
.catch((err) => setError({ code: err.code ?? '', message: err.message }))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
@@ -126,15 +140,16 @@ export default function DashboardPage() {
|
||||
}
|
||||
|
||||
if (error) {
|
||||
const errorMsg = error.code === 'forbidden' ? d.forbidden : error.message
|
||||
return (
|
||||
<div className="card p-6 text-center">
|
||||
<p className="text-red-600 font-medium">{d.failedToLoad}</p>
|
||||
<p className="text-slate-500 text-sm mt-1">{error}</p>
|
||||
<p className="text-slate-500 text-sm">{errorMsg}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const kpis = data?.kpis ?? { totalBookings: 0, activeVehicles: 0, monthlyRevenue: 0, totalCustomers: 0, bookingsChange: 0, vehiclesChange: 0, revenueChange: 0, customersChange: 0 }
|
||||
const canViewRevenue = employeeRole !== 'AGENT'
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(localeCode, { month: 'short', day: 'numeric' })
|
||||
@@ -156,7 +171,7 @@ export default function DashboardPage() {
|
||||
)}
|
||||
|
||||
{/* KPI Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className={`grid grid-cols-1 gap-4 sm:grid-cols-2 ${canViewRevenue ? 'lg:grid-cols-4' : 'lg:grid-cols-3'}`}>
|
||||
<StatCard
|
||||
title={d.kpiTotalBookings}
|
||||
value={kpis.totalBookings.toLocaleString()}
|
||||
@@ -175,15 +190,17 @@ export default function DashboardPage() {
|
||||
iconColor="text-green-600"
|
||||
iconBg="bg-green-50"
|
||||
/>
|
||||
<StatCard
|
||||
title={d.kpiMonthlyRevenue}
|
||||
value={formatCurrency(kpis.monthlyRevenue, 'MAD')}
|
||||
change={kpis.revenueChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={DollarSign}
|
||||
iconColor="text-amber-600"
|
||||
iconBg="bg-amber-50"
|
||||
/>
|
||||
{canViewRevenue ? (
|
||||
<StatCard
|
||||
title={d.kpiMonthlyRevenue}
|
||||
value={formatCurrency(kpis.monthlyRevenue, 'MAD')}
|
||||
change={kpis.revenueChange}
|
||||
vsLastMonthLabel={d.vsLastMonth}
|
||||
icon={DollarSign}
|
||||
iconColor="text-amber-600"
|
||||
iconBg="bg-amber-50"
|
||||
/>
|
||||
) : null}
|
||||
<StatCard
|
||||
title={d.kpiTotalCustomers}
|
||||
value={kpis.totalCustomers.toLocaleString()}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { formatCurrency, PLAN_PRICES } from '@rentaldrivego/types'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import { EMPLOYEE_PROFILE_KEY, apiFetch } from '@/lib/api'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
|
||||
type Plan = 'STARTER' | 'GROWTH' | 'PRO'
|
||||
@@ -35,6 +36,10 @@ interface ProviderAvailability {
|
||||
paypal: boolean
|
||||
}
|
||||
|
||||
interface EmployeeProfile {
|
||||
role?: string
|
||||
}
|
||||
|
||||
const STATUS_BADGE: Record<string, string> = {
|
||||
TRIALING: 'bg-sky-100 text-sky-700',
|
||||
ACTIVE: 'bg-green-100 text-green-700',
|
||||
@@ -52,11 +57,13 @@ const INVOICE_STATUS: Record<string, string> = {
|
||||
|
||||
const PLANS: Plan[] = ['STARTER', 'GROWTH', 'PRO']
|
||||
export default function SubscriptionPage() {
|
||||
const router = useRouter()
|
||||
const { language } = useDashboardI18n()
|
||||
const [subscription, setSubscription] = useState<Subscription | null>(null)
|
||||
const [invoices, setInvoices] = useState<Invoice[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [canViewPage, setCanViewPage] = useState<boolean | null>(null)
|
||||
|
||||
const [selectedPlan, setSelectedPlan] = useState<Plan>('STARTER')
|
||||
const [billingPeriod, setBillingPeriod] = useState<BillingPeriod>('MONTHLY')
|
||||
@@ -198,6 +205,33 @@ export default function SubscriptionPage() {
|
||||
}[language]
|
||||
|
||||
useEffect(() => {
|
||||
const cached = window.localStorage.getItem(EMPLOYEE_PROFILE_KEY)
|
||||
if (cached) {
|
||||
try {
|
||||
const profile = JSON.parse(cached) as EmployeeProfile
|
||||
const allowed = profile.role === 'OWNER'
|
||||
setCanViewPage(allowed)
|
||||
if (!allowed) router.replace('/dashboard')
|
||||
return
|
||||
} catch {}
|
||||
}
|
||||
|
||||
apiFetch<{ employee: EmployeeProfile }>('/auth/employee/me')
|
||||
.then(({ employee }) => {
|
||||
window.localStorage.setItem(EMPLOYEE_PROFILE_KEY, JSON.stringify(employee))
|
||||
const allowed = employee.role === 'OWNER'
|
||||
setCanViewPage(allowed)
|
||||
if (!allowed) router.replace('/dashboard')
|
||||
})
|
||||
.catch(() => {
|
||||
setCanViewPage(false)
|
||||
router.replace('/dashboard')
|
||||
})
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
if (canViewPage !== true) return
|
||||
|
||||
Promise.all([
|
||||
apiFetch<Subscription | null>('/subscriptions/me'),
|
||||
apiFetch<Invoice[]>('/subscriptions/invoices'),
|
||||
@@ -217,7 +251,11 @@ export default function SubscriptionPage() {
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
}, [canViewPage])
|
||||
|
||||
if (canViewPage !== true) {
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleCheckout() {
|
||||
setPaying(true)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { API_BASE } from '@/lib/api'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
|
||||
export default function ForgotPasswordPageClient({ embedded = false }: { embedded?: boolean }) {
|
||||
const { language } = useDashboardI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Forgot your password?',
|
||||
subtitle: "Enter your work email and we'll send you a reset link.",
|
||||
email: 'Email',
|
||||
submit: 'Send reset link',
|
||||
submitting: 'Sending…',
|
||||
backToLogin: 'Back to sign in',
|
||||
successTitle: 'Check your email',
|
||||
successBody: 'If that email is registered, a reset link has been sent. It expires in 60 minutes.',
|
||||
error: 'Something went wrong. Please try again.',
|
||||
},
|
||||
fr: {
|
||||
title: 'Mot de passe oublié ?',
|
||||
subtitle: "Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.",
|
||||
email: 'Email',
|
||||
submit: 'Envoyer le lien',
|
||||
submitting: 'Envoi…',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
successTitle: 'Vérifiez votre email',
|
||||
successBody: "Si cet email est enregistré, un lien de réinitialisation a été envoyé. Il expire dans 60 minutes.",
|
||||
error: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
},
|
||||
ar: {
|
||||
title: 'نسيت كلمة المرور؟',
|
||||
subtitle: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.',
|
||||
email: 'البريد الإلكتروني',
|
||||
submit: 'إرسال رابط الإعادة',
|
||||
submitting: 'جارٍ الإرسال…',
|
||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
||||
successTitle: 'تحقق من بريدك الإلكتروني',
|
||||
successBody: 'إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.',
|
||||
error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/employee/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
setSent(true)
|
||||
} catch {
|
||||
setError(dict.error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
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.title}</h1>
|
||||
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{dict.subtitle}</p>
|
||||
</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">
|
||||
{sent ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900 dark:text-slate-100">{dict.successTitle}</h2>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{dict.successBody}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700 dark:text-slate-300">{dict.email}</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="owner@company.com"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.submitting : dict.submit}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500 dark:border-slate-800 dark:text-slate-400">
|
||||
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4 dark:text-slate-100 dark:decoration-slate-700">
|
||||
{dict.backToLogin}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -1,128 +1,12 @@
|
||||
'use client'
|
||||
import ForgotPasswordPageClient from './ForgotPasswordPageClient'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { API_BASE } from '@/lib/api'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
export default async function ForgotPasswordPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const params = await searchParams
|
||||
const embedded = params.embedded === '1'
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const { language } = useDashboardI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Forgot your password?',
|
||||
subtitle: "Enter your work email and we'll send you a reset link.",
|
||||
email: 'Email',
|
||||
submit: 'Send reset link',
|
||||
submitting: 'Sending…',
|
||||
backToLogin: 'Back to sign in',
|
||||
successTitle: 'Check your email',
|
||||
successBody: 'If that email is registered, a reset link has been sent. It expires in 60 minutes.',
|
||||
error: 'Something went wrong. Please try again.',
|
||||
},
|
||||
fr: {
|
||||
title: 'Mot de passe oublié ?',
|
||||
subtitle: "Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.",
|
||||
email: 'Email',
|
||||
submit: 'Envoyer le lien',
|
||||
submitting: 'Envoi…',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
successTitle: 'Vérifiez votre email',
|
||||
successBody: "Si cet email est enregistré, un lien de réinitialisation a été envoyé. Il expire dans 60 minutes.",
|
||||
error: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
},
|
||||
ar: {
|
||||
title: 'نسيت كلمة المرور؟',
|
||||
subtitle: 'أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.',
|
||||
email: 'البريد الإلكتروني',
|
||||
submit: 'إرسال رابط الإعادة',
|
||||
submitting: 'جارٍ الإرسال…',
|
||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
||||
successTitle: 'تحقق من بريدك الإلكتروني',
|
||||
successBody: 'إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.',
|
||||
error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/employee/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
if (!res.ok) throw new Error()
|
||||
setSent(true)
|
||||
} catch {
|
||||
setError(dict.error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] px-4 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">
|
||||
RentalDriveGo
|
||||
</a>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{dict.title}</h1>
|
||||
<p className="mt-2 text-sm text-slate-500">{dict.subtitle}</p>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
||||
{sent ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900">{dict.successTitle}</h2>
|
||||
<p className="text-sm text-slate-500">{dict.successBody}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<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>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.submitting : dict.submit}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500">
|
||||
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
|
||||
{dict.backToLogin}
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
return <ForgotPasswordPageClient embedded={embedded} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Suspense, useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { API_BASE } from '@/lib/api'
|
||||
|
||||
export default function ResetPasswordPageClient({ embedded = false }: { embedded?: boolean }) {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResetPasswordContent embedded={embedded} />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function ResetPasswordContent({ embedded = false }: { embedded?: boolean }) {
|
||||
const { language } = useDashboardI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Set new password',
|
||||
subtitle: 'Choose a strong password for your workspace account.',
|
||||
newPassword: 'New password',
|
||||
confirmPassword: 'Confirm password',
|
||||
submit: 'Reset password',
|
||||
submitting: 'Resetting…',
|
||||
backToLogin: 'Back to sign in',
|
||||
successTitle: 'Password updated',
|
||||
successBody: 'Your password has been reset. You can now sign in with your new password.',
|
||||
signIn: 'Sign in',
|
||||
errorMismatch: 'Passwords do not match.',
|
||||
errorShort: 'Password must be at least 8 characters.',
|
||||
errorInvalidToken: 'This reset link is invalid or has expired. Please request a new one.',
|
||||
errorGeneric: 'Something went wrong. Please try again.',
|
||||
noToken: 'No reset token found. Please request a new password reset.',
|
||||
requestNew: 'Request new reset link',
|
||||
},
|
||||
fr: {
|
||||
title: 'Nouveau mot de passe',
|
||||
subtitle: "Choisissez un mot de passe fort pour votre compte.",
|
||||
newPassword: 'Nouveau mot de passe',
|
||||
confirmPassword: 'Confirmer le mot de passe',
|
||||
submit: 'Réinitialiser',
|
||||
submitting: 'Traitement…',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
successTitle: 'Mot de passe mis à jour',
|
||||
successBody: 'Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter.',
|
||||
signIn: 'Se connecter',
|
||||
errorMismatch: 'Les mots de passe ne correspondent pas.',
|
||||
errorShort: 'Le mot de passe doit contenir au moins 8 caractères.',
|
||||
errorInvalidToken: 'Ce lien est invalide ou a expiré. Veuillez en demander un nouveau.',
|
||||
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
noToken: 'Aucun jeton trouvé. Veuillez demander une réinitialisation.',
|
||||
requestNew: 'Demander un nouveau lien',
|
||||
},
|
||||
ar: {
|
||||
title: 'تعيين كلمة مرور جديدة',
|
||||
subtitle: 'اختر كلمة مرور قوية لحساب مساحة العمل.',
|
||||
newPassword: 'كلمة المرور الجديدة',
|
||||
confirmPassword: 'تأكيد كلمة المرور',
|
||||
submit: 'إعادة تعيين',
|
||||
submitting: 'جارٍ المعالجة…',
|
||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
||||
successTitle: 'تم تحديث كلمة المرور',
|
||||
successBody: 'تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.',
|
||||
signIn: 'تسجيل الدخول',
|
||||
errorMismatch: 'كلمتا المرور غير متطابقتين.',
|
||||
errorShort: 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل.',
|
||||
errorInvalidToken: 'رابط إعادة التعيين غير صالح أو منتهي الصلاحية.',
|
||||
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
noToken: 'لم يتم العثور على رمز. يرجى طلب إعادة تعيين.',
|
||||
requestNew: 'طلب رابط جديد',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (password.length < 8) { setError(dict.errorShort); return }
|
||||
if (password !== confirm) { setError(dict.errorMismatch); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/employee/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
if (json?.error === 'invalid_token') throw new Error(dict.errorInvalidToken)
|
||||
throw new Error(dict.errorGeneric)
|
||||
}
|
||||
setDone(true)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? dict.errorGeneric)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<ResetShell embedded={embedded} title={dict.title}>
|
||||
<p className="text-center text-sm text-slate-500">{dict.noToken}</p>
|
||||
<div className="mt-4 text-center">
|
||||
<Link href="/forgot-password" className="btn-primary inline-flex">{dict.requestNew}</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<ResetShell embedded={embedded} title={dict.title}>
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900">{dict.successTitle}</h2>
|
||||
<p className="text-sm text-slate-500">{dict.successBody}</p>
|
||||
<Link href="/sign-in" className="btn-primary inline-flex justify-center">{dict.signIn}</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResetShell embedded={embedded} title={dict.title} subtitle={dict.subtitle}>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.newPassword}</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.confirmPassword}</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.submitting : dict.submit}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500">
|
||||
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
|
||||
{dict.backToLogin}
|
||||
</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
function ResetShell({
|
||||
embedded = false,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
embedded?: boolean
|
||||
title: string
|
||||
subtitle?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<PublicShell embedded={embedded}>
|
||||
<main className="flex min-h-[calc(100vh-80px)] items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</span>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{title}</h1>
|
||||
{subtitle && <p className="mt-2 text-sm text-slate-500">{subtitle}</p>}
|
||||
</div>
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
||||
{children}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
}
|
||||
@@ -1,201 +1,12 @@
|
||||
'use client'
|
||||
import ResetPasswordPageClient from './ResetPasswordPageClient'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useState, Suspense } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { API_BASE } from '@/lib/api'
|
||||
export default async function ResetPasswordPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
const params = await searchParams
|
||||
const embedded = params.embedded === '1'
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<ResetPasswordContent />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
function ResetPasswordContent() {
|
||||
const { language } = useDashboardI18n()
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Set new password',
|
||||
subtitle: 'Choose a strong password for your workspace account.',
|
||||
newPassword: 'New password',
|
||||
confirmPassword: 'Confirm password',
|
||||
submit: 'Reset password',
|
||||
submitting: 'Resetting…',
|
||||
backToLogin: 'Back to sign in',
|
||||
successTitle: 'Password updated',
|
||||
successBody: 'Your password has been reset. You can now sign in with your new password.',
|
||||
signIn: 'Sign in',
|
||||
errorMismatch: 'Passwords do not match.',
|
||||
errorShort: 'Password must be at least 8 characters.',
|
||||
errorInvalidToken: 'This reset link is invalid or has expired. Please request a new one.',
|
||||
errorGeneric: 'Something went wrong. Please try again.',
|
||||
noToken: 'No reset token found. Please request a new password reset.',
|
||||
requestNew: 'Request new reset link',
|
||||
},
|
||||
fr: {
|
||||
title: 'Nouveau mot de passe',
|
||||
subtitle: "Choisissez un mot de passe fort pour votre compte.",
|
||||
newPassword: 'Nouveau mot de passe',
|
||||
confirmPassword: 'Confirmer le mot de passe',
|
||||
submit: 'Réinitialiser',
|
||||
submitting: 'Traitement…',
|
||||
backToLogin: 'Retour à la connexion',
|
||||
successTitle: 'Mot de passe mis à jour',
|
||||
successBody: 'Votre mot de passe a été réinitialisé. Vous pouvez maintenant vous connecter.',
|
||||
signIn: 'Se connecter',
|
||||
errorMismatch: 'Les mots de passe ne correspondent pas.',
|
||||
errorShort: 'Le mot de passe doit contenir au moins 8 caractères.',
|
||||
errorInvalidToken: 'Ce lien est invalide ou a expiré. Veuillez en demander un nouveau.',
|
||||
errorGeneric: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
noToken: 'Aucun jeton trouvé. Veuillez demander une réinitialisation.',
|
||||
requestNew: 'Demander un nouveau lien',
|
||||
},
|
||||
ar: {
|
||||
title: 'تعيين كلمة مرور جديدة',
|
||||
subtitle: 'اختر كلمة مرور قوية لحساب مساحة العمل.',
|
||||
newPassword: 'كلمة المرور الجديدة',
|
||||
confirmPassword: 'تأكيد كلمة المرور',
|
||||
submit: 'إعادة تعيين',
|
||||
submitting: 'جارٍ المعالجة…',
|
||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
||||
successTitle: 'تم تحديث كلمة المرور',
|
||||
successBody: 'تم إعادة تعيين كلمة المرور. يمكنك الآن تسجيل الدخول.',
|
||||
signIn: 'تسجيل الدخول',
|
||||
errorMismatch: 'كلمتا المرور غير متطابقتين.',
|
||||
errorShort: 'يجب أن تتكون كلمة المرور من 8 أحرف على الأقل.',
|
||||
errorInvalidToken: 'رابط إعادة التعيين غير صالح أو منتهي الصلاحية.',
|
||||
errorGeneric: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
noToken: 'لم يتم العثور على رمز. يرجى طلب إعادة تعيين.',
|
||||
requestNew: 'طلب رابط جديد',
|
||||
},
|
||||
}[language]
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const token = searchParams.get('token')
|
||||
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault()
|
||||
if (password.length < 8) { setError(dict.errorShort); return }
|
||||
if (password !== confirm) { setError(dict.errorMismatch); return }
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/auth/employee/reset-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token, password }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (!res.ok) {
|
||||
if (json?.error === 'invalid_token') throw new Error(dict.errorInvalidToken)
|
||||
throw new Error(dict.errorGeneric)
|
||||
}
|
||||
setDone(true)
|
||||
} catch (err: any) {
|
||||
setError(err.message ?? dict.errorGeneric)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<ResetShell title={dict.title}>
|
||||
<p className="text-sm text-slate-500 text-center">{dict.noToken}</p>
|
||||
<div className="mt-4 text-center">
|
||||
<Link href="/forgot-password" className="btn-primary inline-flex">{dict.requestNew}</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<ResetShell title={dict.title}>
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-green-100">
|
||||
<svg className="h-7 w-7 text-green-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
<h2 className="text-xl font-bold text-slate-900">{dict.successTitle}</h2>
|
||||
<p className="text-sm text-slate-500">{dict.successBody}</p>
|
||||
<Link href="/sign-in" className="btn-primary inline-flex justify-center">{dict.signIn}</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<ResetShell title={dict.title} subtitle={dict.subtitle}>
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-2xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.newPassword}</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{dict.confirmPassword}</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
minLength={8}
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="input-field"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full justify-center disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{loading ? dict.submitting : dict.submit}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 border-t border-slate-200 pt-6 text-center text-sm text-slate-500">
|
||||
<Link href="/sign-in" className="font-semibold text-slate-900 underline decoration-slate-300 underline-offset-4">
|
||||
{dict.backToLogin}
|
||||
</Link>
|
||||
</div>
|
||||
</ResetShell>
|
||||
)
|
||||
}
|
||||
|
||||
function ResetShell({ title, subtitle, children }: { title: string; subtitle?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="flex min-h-screen items-center justify-center bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)] px-4 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">RentalDriveGo</span>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight text-slate-900">{title}</h1>
|
||||
{subtitle && <p className="mt-2 text-sm text-slate-500">{subtitle}</p>}
|
||||
</div>
|
||||
<section className="rounded-[2rem] border border-slate-200 bg-white p-8 shadow-sm">
|
||||
{children}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
return <ResetPasswordPageClient embedded={embedded} />
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,396 +1,12 @@
|
||||
'use client'
|
||||
import SignInPageClient from './SignInPageClient'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
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 SignInPage() {
|
||||
const { language, theme, setLanguage, setTheme } = useDashboardI18n()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const requestedLanguage = searchParams.get('lang')
|
||||
const requestedTheme = searchParams.get('theme')
|
||||
const dict = {
|
||||
en: {
|
||||
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: {
|
||||
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: {
|
||||
title: 'تسجيل الدخول',
|
||||
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
|
||||
email: 'البريد الإلكتروني',
|
||||
password: 'كلمة المرور',
|
||||
signIn: 'تسجيل الدخول',
|
||||
signingIn: 'جارٍ تسجيل الدخول…',
|
||||
verify: 'تحقق من الرمز',
|
||||
verifying: 'جارٍ التحقق…',
|
||||
authCode: 'رمز المصادقة',
|
||||
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
||||
back: 'العودة إلى بيانات الدخول',
|
||||
forgotPassword: 'نسيت كلمة المرور؟',
|
||||
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
|
||||
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
|
||||
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
},
|
||||
}[language]
|
||||
const themeStyles = {
|
||||
light: {
|
||||
main: 'bg-[radial-gradient(circle_at_top,#dbeafe,transparent_35%),linear-gradient(180deg,#f8fafc,white)]',
|
||||
logoFrame: 'border-blue-100 bg-white shadow-sm',
|
||||
brand: 'text-blue-600',
|
||||
title: 'text-slate-900',
|
||||
subtitle: 'text-slate-500',
|
||||
card: 'border-slate-200 bg-white shadow-sm',
|
||||
},
|
||||
medium: {
|
||||
main: 'bg-[radial-gradient(circle_at_top,rgba(148,163,184,0.32),transparent_35%),linear-gradient(180deg,#d7dee8,#f3f4f6)]',
|
||||
logoFrame: 'border-slate-300 bg-white/90 shadow-[0_12px_30px_rgba(71,85,105,0.18)]',
|
||||
brand: 'text-slate-700',
|
||||
title: 'text-slate-900',
|
||||
subtitle: 'text-slate-600',
|
||||
card: 'border-slate-300/80 bg-white/88 shadow-[0_18px_50px_rgba(51,65,85,0.14)] backdrop-blur',
|
||||
},
|
||||
dark: {
|
||||
main: 'bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.2),transparent_35%),linear-gradient(180deg,#020617,#0f172a)]',
|
||||
logoFrame: 'border-slate-700 bg-slate-900 shadow-[0_12px_30px_rgba(2,6,23,0.45)]',
|
||||
brand: 'text-amber-400',
|
||||
title: 'text-slate-100',
|
||||
subtitle: 'text-slate-400',
|
||||
card: 'border-slate-800 bg-slate-900/92 shadow-[0_18px_50px_rgba(2,6,23,0.45)] backdrop-blur',
|
||||
},
|
||||
}[theme]
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(requestedLanguage === 'en' || requestedLanguage === 'fr' || requestedLanguage === 'ar') &&
|
||||
requestedLanguage !== language
|
||||
) {
|
||||
setLanguage(requestedLanguage)
|
||||
}
|
||||
}, [language, requestedLanguage, setLanguage])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
(requestedTheme === 'light' || requestedTheme === 'medium' || requestedTheme === 'dark') &&
|
||||
requestedTheme !== theme
|
||||
) {
|
||||
setTheme(requestedTheme)
|
||||
}
|
||||
}, [requestedTheme, setTheme, theme])
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = window.location.pathname + (window.location.search || '')
|
||||
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
|
||||
}, [pathname, searchParams])
|
||||
|
||||
return (
|
||||
<main className={`flex min-h-screen items-center justify-center px-4 py-12 transition-colors ${themeStyles.main}`}>
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="flex justify-center">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={104}
|
||||
height={104}
|
||||
className={`h-24 w-24 rounded-[1.75rem] border p-1.5 transition-colors ${themeStyles.logoFrame}`}
|
||||
/>
|
||||
</div>
|
||||
<a href={marketplaceUrl} className={`text-xs font-semibold uppercase tracking-[0.24em] transition-colors ${themeStyles.brand}`}>
|
||||
RentalDriveGo
|
||||
</a>
|
||||
<h1 className={`mt-3 text-3xl font-black tracking-tight transition-colors ${themeStyles.title}`}>
|
||||
{dict.title}
|
||||
</h1>
|
||||
<p className={`mt-2 text-sm transition-colors ${themeStyles.subtitle}`}>
|
||||
{dict.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className={`rounded-[2rem] border p-8 transition-colors ${themeStyles.card}`}>
|
||||
<LocalSignInForm dict={dict} />
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
export default async function SignInPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>
|
||||
}) {
|
||||
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'
|
||||
const params = await searchParams
|
||||
const embedded = params.embedded === '1'
|
||||
|
||||
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 {
|
||||
// Try employee login first
|
||||
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))
|
||||
// Apply the employee's stored language preference immediately so the
|
||||
// dashboard renders in the correct language before any read-effect runs.
|
||||
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
|
||||
}
|
||||
|
||||
// Fall back to admin login
|
||||
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 hover:text-slate-700 underline decoration-slate-300 underline-offset-4">
|
||||
{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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
return <SignInPageClient embedded={embedded} />
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
@@ -53,6 +54,7 @@ type SignupResponse = {
|
||||
|
||||
const LEGAL_FORM_OPTIONS = ['SARL', 'SARL AU', 'SA', 'SAS', 'AUTO_ENTREPRENEUR', 'EI', 'OTHER'] as const
|
||||
const YEARS_ACTIVE_OPTIONS = ['LESS_THAN_1', 'ONE_TO_FIVE', 'MORE_THAN_5'] as const
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
|
||||
export default function SignUpPage() {
|
||||
const { language, setLanguage } = useDashboardI18n()
|
||||
@@ -405,7 +407,19 @@ export default function SignUpPage() {
|
||||
<PublicShell>
|
||||
<main className="flex-1 px-4 py-16">
|
||||
<div className="mx-auto max-w-2xl rounded-[2rem] border border-slate-200 bg-white p-10 shadow-sm">
|
||||
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
|
||||
<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>
|
||||
<a href={marketplaceUrl} className="mt-4 inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
|
||||
<h1 className="mt-4 text-4xl font-black tracking-tight text-slate-900">{dict.workspaceReady}</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">
|
||||
<span className="font-semibold text-slate-900">{completedSignup.companyName}</span> {dict.workspaceReadyBody}
|
||||
@@ -420,9 +434,9 @@ export default function SignUpPage() {
|
||||
<Link href="/dashboard" className="btn-primary justify-center">
|
||||
{dict.enterDashboard}
|
||||
</Link>
|
||||
<Link href="/sign-in" className="btn-secondary justify-center">
|
||||
<a href="/sign-in" className="btn-secondary justify-center">
|
||||
{dict.signInAnotherDevice}
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@@ -435,7 +449,19 @@ export default function SignUpPage() {
|
||||
<main className="px-4 py-12">
|
||||
<div className="mx-auto max-w-4xl space-y-8">
|
||||
<div className={`text-center ${isArabic ? 'rtl' : ''}`}>
|
||||
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
|
||||
<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>
|
||||
<a href={marketplaceUrl} className="mt-4 inline-block text-xs font-semibold uppercase tracking-[0.24em] text-blue-600">{dict.brand}</a>
|
||||
<h1 className="mt-3 text-5xl font-black tracking-tight text-slate-900">{dict.pageTitle}</h1>
|
||||
<p className="mt-4 text-base leading-7 text-slate-600">{dict.pageSubtitle}</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user