redesign the homepage
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
Build & Deploy / Build & Push Docker Image (push) Failing after 47s
Build & Deploy / Deploy to VPS (push) Has been skipped
Test / API Unit Tests (push) Failing after 5m4s
Test / Marketplace Unit Tests (push) Failing after 4m55s
Test / Admin Unit Tests (push) Successful in 9m37s
Test / Dashboard Unit Tests (push) Successful in 9m37s
Test / API Integration Tests (push) Successful in 9m54s
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/noto-sans-arabic": "^5.2.10",
|
||||
"@rentaldrivego/types": "*",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"dayjs": "^1.11.11",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 448 KiB |
@@ -657,7 +657,7 @@ export default function SettingsPage() {
|
||||
<div className="mt-5 space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.subdomain}</label>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.FleetOS.com</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-700">{brand.subdomain}.RentalDriveGo.com</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-slate-700">{copy.customDomainLabel}</label>
|
||||
|
||||
@@ -1,90 +1,98 @@
|
||||
'use client'
|
||||
"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'
|
||||
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'
|
||||
const DASHBOARD_LOGO_SRC = "/dashboard/rentalcardrive.png";
|
||||
|
||||
export default function ForgotPasswordPageClient({ embedded = false }: { embedded?: boolean }) {
|
||||
const { language } = useDashboardI18n()
|
||||
export default function ForgotPasswordPageClient({
|
||||
embedded = false,
|
||||
}: {
|
||||
embedded?: boolean;
|
||||
}) {
|
||||
const { language } = useDashboardI18n();
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Forgot your password?',
|
||||
title: "Forgot your password?",
|
||||
subtitle: "Enter your work email and we'll send you a reset link.",
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'owner@company.com',
|
||||
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.',
|
||||
email: "Email",
|
||||
emailPlaceholder: "owner@company.com",
|
||||
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',
|
||||
emailPlaceholder: 'owner@company.com',
|
||||
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.',
|
||||
title: "Mot de passe oublié ?",
|
||||
subtitle:
|
||||
"Entrez votre email professionnel et nous vous enverrons un lien de réinitialisation.",
|
||||
email: "Email",
|
||||
emailPlaceholder: "owner@company.com",
|
||||
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: 'البريد الإلكتروني',
|
||||
emailPlaceholder: 'owner@company.com',
|
||||
submit: 'إرسال رابط إعادة التعيين',
|
||||
submitting: 'جارٍ الإرسال…',
|
||||
backToLogin: 'العودة إلى تسجيل الدخول',
|
||||
successTitle: 'تحقق من بريدك الإلكتروني',
|
||||
successBody: 'إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.',
|
||||
error: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
title: "نسيت كلمة المرور؟",
|
||||
subtitle: "أدخل بريدك الإلكتروني وسنرسل لك رابط إعادة التعيين.",
|
||||
email: "البريد الإلكتروني",
|
||||
emailPlaceholder: "owner@company.com",
|
||||
submit: "إرسال رابط إعادة التعيين",
|
||||
submitting: "جارٍ الإرسال…",
|
||||
backToLogin: "العودة إلى تسجيل الدخول",
|
||||
successTitle: "تحقق من بريدك الإلكتروني",
|
||||
successBody:
|
||||
"إذا كان البريد الإلكتروني مسجلاً، فقد تم إرسال رابط إعادة التعيين. تنتهي صلاحيته خلال 60 دقيقة.",
|
||||
error: "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
|
||||
},
|
||||
}[language]
|
||||
}[language];
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
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)
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Try employee first, then admin — mirrors the sign-in page which handles both
|
||||
const [empRes, adminRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/auth/employee/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
fetch(`${API_BASE}/admin/auth/forgot-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email }),
|
||||
}),
|
||||
])
|
||||
if (!empRes.ok && !adminRes.ok) throw new Error()
|
||||
setSent(true)
|
||||
]);
|
||||
if (!empRes.ok && !adminRes.ok) throw new Error();
|
||||
setSent(true);
|
||||
} catch {
|
||||
setError(dict.error)
|
||||
setError(dict.error);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell embedded={embedded}>
|
||||
<PublicShell embedded={embedded} hideFooter hideHeaderActions>
|
||||
<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">
|
||||
@@ -92,7 +100,7 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
|
||||
<a href={marketplaceUrl} target="_top">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="FleetOS"
|
||||
alt="RentalDriveGo"
|
||||
width={104}
|
||||
height={104}
|
||||
priority
|
||||
@@ -100,28 +108,50 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
<h1 className="mt-4 text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">{dict.title}</h1>
|
||||
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{dict.subtitle}</p>
|
||||
<h1 className="mt-4 text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">
|
||||
{dict.title}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-stone-500 dark:text-stone-400">
|
||||
{dict.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section className="rounded-[2rem] border border-stone-200 bg-white p-8 shadow-sm transition-colors dark:border-blue-900 dark:bg-blue-950">
|
||||
{sent ? (
|
||||
<div className="space-y-4 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-950/50">
|
||||
<svg className="h-7 w-7 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
||||
<svg
|
||||
className="h-7 w-7 text-emerald-600 dark:text-emerald-400"
|
||||
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-stone-900 dark:text-stone-100">{dict.successTitle}</h2>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">{dict.successBody}</p>
|
||||
<h2 className="text-xl font-bold text-stone-900 dark:text-stone-100">
|
||||
{dict.successTitle}
|
||||
</h2>
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">
|
||||
{dict.successBody}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{error && (
|
||||
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400">{error}</div>
|
||||
<div className="rounded-2xl border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700 dark:border-rose-800 dark:bg-rose-950/40 dark:text-rose-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">{dict.email}</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-300">
|
||||
{dict.email}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
@@ -142,7 +172,10 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
|
||||
)}
|
||||
|
||||
<div className="mt-6 border-t border-stone-200 pt-6 text-center text-sm text-stone-500 dark:border-blue-900 dark:text-stone-400">
|
||||
<Link href="/sign-in" className="font-semibold text-stone-900 underline decoration-stone-300 underline-offset-4 dark:text-stone-100 dark:decoration-stone-700">
|
||||
<Link
|
||||
href="/sign-in"
|
||||
className="font-semibold text-stone-900 underline decoration-stone-300 underline-offset-4 dark:text-stone-100 dark:decoration-stone-700"
|
||||
>
|
||||
{dict.backToLogin}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -150,5 +183,5 @@ export default function ForgotPasswordPageClient({ embedded = false }: { embedde
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import * as React from "react";
|
||||
import ForgotPasswordPageClient from './ForgotPasswordPageClient'
|
||||
import ForgotPasswordPageClient from "./ForgotPasswordPageClient";
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return <ForgotPasswordPageClient embedded={true} />
|
||||
export default async function ForgotPasswordPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string | string[] | undefined>>;
|
||||
}) {
|
||||
const params = await searchParams;
|
||||
const embedded = params.embedded === "1";
|
||||
|
||||
return <ForgotPasswordPageClient embedded={embedded} />;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { cookies } from 'next/headers'
|
||||
import { JetBrains_Mono } from 'next/font/google'
|
||||
import '@fontsource-variable/inter/index.css'
|
||||
import '@fontsource-variable/noto-sans-arabic/index.css'
|
||||
import { DashboardI18nProvider } from '@/components/I18nProvider'
|
||||
import { SHARED_LANGUAGE_COOKIE } from '@/lib/preferences'
|
||||
import './globals.css'
|
||||
@@ -12,7 +14,7 @@ const jetbrainsMono = JetBrains_Mono({
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FleetOS Dashboard',
|
||||
title: 'RentalDriveGo Dashboard',
|
||||
description: 'Manage your rental car business',
|
||||
icons: {
|
||||
icon: '/dashboard/icon.svg',
|
||||
|
||||
@@ -96,7 +96,7 @@ export default function OnboardingPage() {
|
||||
<main className="flex items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-lg">
|
||||
<div className="mb-8 text-center">
|
||||
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-widest text-blue-600">FleetOS</a>
|
||||
<a href={marketplaceUrl} className="text-xs font-semibold uppercase tracking-widest text-blue-600">RentalDriveGo</a>
|
||||
<h1 className="mt-2 text-3xl font-bold text-slate-900">Set up your account</h1>
|
||||
<p className="mt-1 text-sm text-slate-500">Step {step} of 3</p>
|
||||
</div>
|
||||
@@ -251,7 +251,7 @@ export default function OnboardingPage() {
|
||||
checked={payments.isListedOnMarketplace}
|
||||
onChange={(e) => setPayments({ ...payments, isListedOnMarketplace: e.target.checked })}
|
||||
/>
|
||||
<span className="text-sm text-slate-700">List my company on the FleetOS marketplace</span>
|
||||
<span className="text-sm text-slate-700">List my company on the RentalDriveGo marketplace</span>
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={() => setStep(2)} className="btn-secondary flex-1 justify-center">Back</button>
|
||||
|
||||
@@ -1,57 +1,79 @@
|
||||
import React, { isValidElement } from 'react'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import ForgotPasswordPageClient from './forgot-password/ForgotPasswordPageClient'
|
||||
import ResetPasswordPageClient from './reset-password/ResetPasswordPageClient'
|
||||
import ForgotPasswordPage from './forgot-password/page'
|
||||
import ResetPasswordPage from './reset-password/page'
|
||||
import AcceptInvitePage from './onboarding/accept-invite/page'
|
||||
import React, { isValidElement } from "react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import ForgotPasswordPageClient from "./forgot-password/ForgotPasswordPageClient";
|
||||
import ResetPasswordPageClient from "./reset-password/ResetPasswordPageClient";
|
||||
import ForgotPasswordPage from "./forgot-password/page";
|
||||
import ResetPasswordPage from "./reset-password/page";
|
||||
import AcceptInvitePage from "./onboarding/accept-invite/page";
|
||||
|
||||
function collectText(node: unknown): string[] {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') return []
|
||||
if (typeof node === 'string' || typeof node === 'number') return [String(node)]
|
||||
if (Array.isArray(node)) return node.flatMap(collectText)
|
||||
if (isValidElement<{ children?: React.ReactNode }>(node)) return collectText(node.props.children)
|
||||
return []
|
||||
if (node === null || node === undefined || typeof node === "boolean")
|
||||
return [];
|
||||
if (typeof node === "string" || typeof node === "number")
|
||||
return [String(node)];
|
||||
if (Array.isArray(node)) return node.flatMap(collectText);
|
||||
if (isValidElement<{ children?: React.ReactNode }>(node))
|
||||
return collectText(node.props.children);
|
||||
return [];
|
||||
}
|
||||
|
||||
function findElement(node: unknown, predicate: (element: React.ReactElement) => boolean): React.ReactElement | null {
|
||||
if (node === null || node === undefined || typeof node === 'boolean') return null
|
||||
function findElement(
|
||||
node: unknown,
|
||||
predicate: (element: React.ReactElement) => boolean,
|
||||
): React.ReactElement | null {
|
||||
if (node === null || node === undefined || typeof node === "boolean")
|
||||
return null;
|
||||
if (Array.isArray(node)) {
|
||||
for (const child of node) {
|
||||
const found = findElement(child, predicate)
|
||||
if (found) return found
|
||||
const found = findElement(child, predicate);
|
||||
if (found) return found;
|
||||
}
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
if (!isValidElement<{ children?: React.ReactNode }>(node)) return null
|
||||
if (predicate(node)) return node
|
||||
return findElement(node.props.children, predicate)
|
||||
if (!isValidElement<{ children?: React.ReactNode }>(node)) return null;
|
||||
if (predicate(node)) return node;
|
||||
return findElement(node.props.children, predicate);
|
||||
}
|
||||
|
||||
describe('dashboard public auth pages', () => {
|
||||
it('renders forgot-password in embedded mode for the public shell route', () => {
|
||||
const page = ForgotPasswordPage()
|
||||
describe("dashboard public auth pages", () => {
|
||||
it("renders forgot-password with public chrome by default", async () => {
|
||||
const page = await ForgotPasswordPage({
|
||||
searchParams: Promise.resolve({}),
|
||||
});
|
||||
|
||||
expect(isValidElement(page)).toBe(true)
|
||||
expect(page.type).toBe(ForgotPasswordPageClient)
|
||||
expect(page.props.embedded).toBe(true)
|
||||
})
|
||||
expect(isValidElement(page)).toBe(true);
|
||||
expect(page.type).toBe(ForgotPasswordPageClient);
|
||||
expect(page.props.embedded).toBe(false);
|
||||
});
|
||||
|
||||
it('renders reset-password in embedded mode for the public shell route', () => {
|
||||
const page = ResetPasswordPage()
|
||||
it("renders forgot-password in embedded mode when requested", async () => {
|
||||
const page = await ForgotPasswordPage({
|
||||
searchParams: Promise.resolve({ embedded: "1" }),
|
||||
});
|
||||
|
||||
expect(isValidElement(page)).toBe(true)
|
||||
expect(page.type).toBe(ResetPasswordPageClient)
|
||||
expect(page.props.embedded).toBe(true)
|
||||
})
|
||||
expect(isValidElement(page)).toBe(true);
|
||||
expect(page.type).toBe(ForgotPasswordPageClient);
|
||||
expect(page.props.embedded).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps accept-invite copy and sign-in handoff visible', () => {
|
||||
const page = AcceptInvitePage()
|
||||
const text = collectText(page).join(' ')
|
||||
const signInLink = findElement(page, (element) => element.props.href === '/sign-in')
|
||||
it("renders reset-password in embedded mode for the public shell route", () => {
|
||||
const page = ResetPasswordPage();
|
||||
|
||||
expect(text).toContain('Invitation accepted')
|
||||
expect(text).toContain('Sign in to dashboard')
|
||||
expect(signInLink).not.toBeNull()
|
||||
})
|
||||
})
|
||||
expect(isValidElement(page)).toBe(true);
|
||||
expect(page.type).toBe(ResetPasswordPageClient);
|
||||
expect(page.props.embedded).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps accept-invite copy and sign-in handoff visible", () => {
|
||||
const page = AcceptInvitePage();
|
||||
const text = collectText(page).join(" ");
|
||||
const signInLink = findElement(
|
||||
page,
|
||||
(element) => element.props.href === "/sign-in",
|
||||
);
|
||||
|
||||
expect(text).toContain("Invitation accepted");
|
||||
expect(text).toContain("Sign in to dashboard");
|
||||
expect(signInLink).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -240,7 +240,7 @@ function ResetShell({
|
||||
<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-orange-700 dark:text-orange-400">FleetOS</span>
|
||||
<span className="text-xs font-semibold uppercase tracking-[0.24em] text-orange-700 dark:text-orange-400">RentalDriveGo</span>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">{title}</h1>
|
||||
{subtitle && <p className="mt-2 text-sm text-stone-500 dark:text-stone-400">{subtitle}</p>}
|
||||
</div>
|
||||
|
||||
@@ -1,156 +1,178 @@
|
||||
'use client'
|
||||
"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 } from '@/lib/api'
|
||||
import { toPublicDashboardPath } from '@/lib/dashboardPaths'
|
||||
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 } from "@/lib/api";
|
||||
import { toPublicDashboardPath } from "@/lib/dashboardPaths";
|
||||
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
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, '*')
|
||||
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)
|
||||
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: 'FleetOS',
|
||||
title: 'Sign in',
|
||||
subtitle: 'Enter your credentials to access your account.',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'owner@company.com',
|
||||
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.',
|
||||
totpPlaceholder: '000000 or XXXX-XXXX-XXXX',
|
||||
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?"',
|
||||
tooManyRequests: 'Too many attempts. Please try again later.',
|
||||
emailNotVerified: 'Please verify your email first. Check your inbox for the verification link.',
|
||||
unexpectedError: 'Something went wrong. Please try again.',
|
||||
brandName: "RentalDriveGo",
|
||||
title: "Sign in",
|
||||
subtitle: "Enter your credentials to access your account.",
|
||||
email: "Email",
|
||||
emailPlaceholder: "owner@company.com",
|
||||
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.",
|
||||
totpPlaceholder: "000000 or XXXX-XXXX-XXXX",
|
||||
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?"',
|
||||
tooManyRequests: "Too many attempts. Please try again later.",
|
||||
emailNotVerified:
|
||||
"Please verify your email first. Check your inbox for the verification link.",
|
||||
unexpectedError: "Something went wrong. Please try again.",
|
||||
},
|
||||
fr: {
|
||||
brandName: 'FleetOS',
|
||||
title: 'Connexion',
|
||||
subtitle: 'Saisissez vos identifiants pour accéder à votre compte.',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'owner@company.com',
|
||||
password: 'Mot de passe',
|
||||
signIn: 'Connexion',
|
||||
signingIn: 'Connexion…',
|
||||
verify: 'Vérifier le code',
|
||||
verifying: 'Vérification…',
|
||||
brandName: "RentalDriveGo",
|
||||
title: "Connexion",
|
||||
subtitle: "Saisissez vos identifiants pour accéder à votre compte.",
|
||||
email: "Email",
|
||||
emailPlaceholder: "owner@company.com",
|
||||
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.",
|
||||
totpPlaceholder: '000000 ou XXXX-XXXX-XXXX',
|
||||
back: 'Retour aux identifiants',
|
||||
forgotPassword: 'Mot de passe oublié ?',
|
||||
invalidCredentials: 'Adresse e-mail ou mot de passe invalide.',
|
||||
enterCode:
|
||||
"Entrez le code à 6 chiffres de votre application d'authentification.",
|
||||
totpPlaceholder: "000000 ou XXXX-XXXX-XXXX",
|
||||
back: "Retour aux identifiants",
|
||||
forgotPassword: "Mot de passe oublié ?",
|
||||
invalidCredentials: "Adresse e-mail 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é ? »`,
|
||||
tooManyRequests: 'Trop de tentatives. Veuillez réessayer plus tard.',
|
||||
emailNotVerified: 'Veuillez vérifier votre adresse email. Consultez votre boîte de réception.',
|
||||
unexpectedError: 'Une erreur est survenue. Veuillez réessayer.',
|
||||
tooManyRequests: "Trop de tentatives. Veuillez réessayer plus tard.",
|
||||
emailNotVerified:
|
||||
"Veuillez vérifier votre adresse email. Consultez votre boîte de réception.",
|
||||
unexpectedError: "Une erreur est survenue. Veuillez réessayer.",
|
||||
},
|
||||
ar: {
|
||||
brandName: 'FleetOS',
|
||||
title: 'تسجيل الدخول',
|
||||
subtitle: 'أدخل بياناتك للوصول إلى حسابك.',
|
||||
email: 'البريد الإلكتروني',
|
||||
emailPlaceholder: 'owner@company.com',
|
||||
password: 'كلمة المرور',
|
||||
signIn: 'تسجيل الدخول',
|
||||
signingIn: 'جارٍ تسجيل الدخول…',
|
||||
verify: 'تحقق من الرمز',
|
||||
verifying: 'جارٍ التحقق…',
|
||||
authCode: 'رمز المصادقة',
|
||||
enterCode: 'أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.',
|
||||
totpPlaceholder: '000000 أو XXXX-XXXX-XXXX',
|
||||
back: 'العودة إلى بيانات الدخول',
|
||||
forgotPassword: 'نسيت كلمة المرور؟',
|
||||
invalidCredentials: 'البريد الإلكتروني أو كلمة المرور غير صحيحة.',
|
||||
passwordNotSet: 'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
|
||||
tooManyRequests: 'محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.',
|
||||
emailNotVerified: 'يرجى التحقق من بريدك الإلكتروني أولاً. تحقق من صندوق الوارد.',
|
||||
unexpectedError: 'حدث خطأ ما. يرجى المحاولة مرة أخرى.',
|
||||
brandName: "RentalDriveGo",
|
||||
title: "تسجيل الدخول",
|
||||
subtitle: "أدخل بياناتك للوصول إلى حسابك.",
|
||||
email: "البريد الإلكتروني",
|
||||
emailPlaceholder: "owner@company.com",
|
||||
password: "كلمة المرور",
|
||||
signIn: "تسجيل الدخول",
|
||||
signingIn: "جارٍ تسجيل الدخول…",
|
||||
verify: "تحقق من الرمز",
|
||||
verifying: "جارٍ التحقق…",
|
||||
authCode: "رمز المصادقة",
|
||||
enterCode: "أدخل الرمز المكون من 6 أرقام من تطبيق المصادقة.",
|
||||
totpPlaceholder: "000000 أو XXXX-XXXX-XXXX",
|
||||
back: "العودة إلى بيانات الدخول",
|
||||
forgotPassword: "نسيت كلمة المرور؟",
|
||||
invalidCredentials: "البريد الإلكتروني أو كلمة المرور غير صحيحة.",
|
||||
passwordNotSet:
|
||||
'لم يتم تعيين كلمة مرور بعد. تحقق من بريد الدعوة أو استخدم "نسيت كلمة المرور؟"',
|
||||
tooManyRequests: "محاولات كثيرة جدًا. يرجى المحاولة لاحقًا.",
|
||||
emailNotVerified:
|
||||
"يرجى التحقق من بريدك الإلكتروني أولاً. تحقق من صندوق الوارد.",
|
||||
unexpectedError: "حدث خطأ ما. يرجى المحاولة مرة أخرى.",
|
||||
},
|
||||
}[language]
|
||||
}[language];
|
||||
|
||||
useEffect(() => {
|
||||
if (initializedFromQuery.current) return
|
||||
if (initializedFromQuery.current) return;
|
||||
|
||||
if (
|
||||
(requestedLanguage === 'en' || requestedLanguage === 'fr' || requestedLanguage === 'ar') &&
|
||||
(requestedLanguage === "en" ||
|
||||
requestedLanguage === "fr" ||
|
||||
requestedLanguage === "ar") &&
|
||||
requestedLanguage !== language
|
||||
) {
|
||||
setLanguage(requestedLanguage)
|
||||
setLanguage(requestedLanguage);
|
||||
}
|
||||
|
||||
if (
|
||||
(requestedTheme === 'light' || requestedTheme === 'dark') &&
|
||||
(requestedTheme === "light" || requestedTheme === "dark") &&
|
||||
requestedTheme !== theme
|
||||
) {
|
||||
setTheme(requestedTheme)
|
||||
setTheme(requestedTheme);
|
||||
}
|
||||
|
||||
initializedFromQuery.current = true
|
||||
}, [language, requestedLanguage, requestedTheme, setLanguage, setTheme, theme])
|
||||
initializedFromQuery.current = true;
|
||||
}, [
|
||||
language,
|
||||
requestedLanguage,
|
||||
requestedTheme,
|
||||
setLanguage,
|
||||
setTheme,
|
||||
theme,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initializedFromQuery.current) return
|
||||
if (!initializedFromQuery.current) return;
|
||||
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
let changed = false
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
let changed = false;
|
||||
|
||||
if (params.get('lang') !== language) {
|
||||
params.set('lang', language)
|
||||
changed = true
|
||||
if (params.get("lang") !== language) {
|
||||
params.set("lang", language);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (params.get('theme') !== theme) {
|
||||
params.set('theme', theme)
|
||||
changed = true
|
||||
if (params.get("theme") !== theme) {
|
||||
params.set("theme", theme);
|
||||
changed = true;
|
||||
}
|
||||
|
||||
// useSearchParams() can return empty params during hydration without a Suspense boundary.
|
||||
// Always preserve embedded=1 when the component was server-rendered as embedded so
|
||||
// the router.replace doesn't strip it and trigger an unintended redirect.
|
||||
if (embedded && params.get('embedded') !== '1') {
|
||||
params.set('embedded', '1')
|
||||
changed = true
|
||||
if (embedded && params.get("embedded") !== "1") {
|
||||
params.set("embedded", "1");
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return
|
||||
if (!changed) return;
|
||||
|
||||
const nextQuery = params.toString()
|
||||
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, { scroll: false })
|
||||
}, [embedded, language, pathname, router, searchParams, theme])
|
||||
const nextQuery = params.toString();
|
||||
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, {
|
||||
scroll: false,
|
||||
});
|
||||
}, [embedded, language, pathname, router, searchParams, theme]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentPath = window.location.pathname + (window.location.search || '')
|
||||
notifyParent({ type: 'rentaldrivego:embedded-path', path: currentPath })
|
||||
}, [pathname, searchParams])
|
||||
const currentPath =
|
||||
window.location.pathname + (window.location.search || "");
|
||||
notifyParent({ type: "rentaldrivego:embedded-path", path: currentPath });
|
||||
}, [pathname, searchParams]);
|
||||
|
||||
return (
|
||||
<PublicShell embedded={embedded}>
|
||||
<PublicShell embedded={embedded} hideFooter hideHeaderActions>
|
||||
<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">
|
||||
@@ -158,7 +180,7 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
|
||||
<a href={marketplaceUrl} target="_top">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="FleetOS"
|
||||
alt="RentalDriveGo"
|
||||
width={104}
|
||||
height={104}
|
||||
priority
|
||||
@@ -167,24 +189,21 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
|
||||
</a>
|
||||
</div>
|
||||
<p className="mt-5 text-xs font-bold uppercase tracking-[0.28em] text-orange-700 dark:text-orange-300">
|
||||
FleetOS
|
||||
RentalDriveGo
|
||||
</p>
|
||||
<h1 className="mt-3 text-3xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">
|
||||
{dict.brandName}
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-stone-600 dark:text-stone-300">
|
||||
{dict.subtitle}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-center gap-2">
|
||||
{(['en', 'fr', 'ar'] as const).map((lang) => (
|
||||
{(["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-orange-700 dark:text-orange-300 ${
|
||||
language === lang
|
||||
? 'ring-1 ring-current opacity-100'
|
||||
: 'opacity-45 hover:opacity-80'
|
||||
? "ring-1 ring-current opacity-100"
|
||||
: "opacity-45 hover:opacity-80"
|
||||
}`}
|
||||
>
|
||||
{lang}
|
||||
@@ -199,168 +218,174 @@ export default function SignInPageClient({ embedded = false }: { embedded?: bool
|
||||
</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
|
||||
tooManyRequests: string
|
||||
emailPlaceholder: string
|
||||
totpPlaceholder: string
|
||||
emailNotVerified: string
|
||||
unexpectedError: string
|
||||
}
|
||||
email: string;
|
||||
password: string;
|
||||
signIn: string;
|
||||
signingIn: string;
|
||||
verify: string;
|
||||
verifying: string;
|
||||
authCode: string;
|
||||
enterCode: string;
|
||||
back: string;
|
||||
forgotPassword: string;
|
||||
invalidCredentials: string;
|
||||
passwordNotSet: string;
|
||||
tooManyRequests: string;
|
||||
emailPlaceholder: string;
|
||||
totpPlaceholder: string;
|
||||
emailNotVerified: string;
|
||||
unexpectedError: string;
|
||||
};
|
||||
}) {
|
||||
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 requestedPortal = searchParams.get('portal')
|
||||
const employeeRedirect = searchParams.get('redirect') || '/dashboard'
|
||||
const preferAdminAuth = requestedPortal === 'admin'
|
||||
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 requestedPortal = searchParams.get("portal");
|
||||
const employeeRedirect = searchParams.get("redirect") || "/dashboard";
|
||||
const preferAdminAuth = requestedPortal === "admin";
|
||||
|
||||
async function handleCredentials(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const tryAdminLogin = async () => {
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const adminJson = await adminRes.json()
|
||||
});
|
||||
const adminJson = await adminRes.json();
|
||||
|
||||
if (adminRes.ok && adminJson?.data?.admin) {
|
||||
window.location.href = `${adminUrl}/dashboard`
|
||||
return true
|
||||
window.location.href = `${adminUrl}/dashboard`;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (adminRes.status === 401 && adminJson?.error === 'totp_required') {
|
||||
setStep('totp')
|
||||
return true
|
||||
if (adminRes.status === 401 && adminJson?.error === "totp_required") {
|
||||
setStep("totp");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (adminRes.status === 429) {
|
||||
setError(dict.tooManyRequests)
|
||||
return true
|
||||
setError(dict.tooManyRequests);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const tryEmployeeLogin = async () => {
|
||||
const empRes = await fetch(`${API_BASE}/auth/employee/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, password }),
|
||||
})
|
||||
const empJson = await empRes.json()
|
||||
});
|
||||
const empJson = await empRes.json();
|
||||
|
||||
if (empRes.ok && empJson?.data?.employee) {
|
||||
const targetPath = toPublicDashboardPath(employeeRedirect)
|
||||
const targetPath = toPublicDashboardPath(employeeRedirect);
|
||||
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`
|
||||
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`;
|
||||
}
|
||||
}
|
||||
window.dispatchEvent(new CustomEvent('rentaldrivego:auth-changed'))
|
||||
notifyParent({ type: 'rentaldrivego:employee-login', path: targetPath })
|
||||
window.dispatchEvent(new CustomEvent("rentaldrivego:auth-changed"));
|
||||
notifyParent({
|
||||
type: "rentaldrivego:employee-login",
|
||||
path: targetPath,
|
||||
});
|
||||
// Use a document navigation here so the authenticated dashboard bootstraps
|
||||
// from a fresh request after the token cookie and localStorage are set.
|
||||
window.location.replace(targetPath)
|
||||
return true
|
||||
window.location.replace(targetPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (empJson?.error === 'password_not_set') {
|
||||
setError(dict.passwordNotSet)
|
||||
return true
|
||||
if (empJson?.error === "password_not_set") {
|
||||
setError(dict.passwordNotSet);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (empJson?.error === 'email_not_verified') {
|
||||
setError(dict.emailNotVerified)
|
||||
return true
|
||||
if (empJson?.error === "email_not_verified") {
|
||||
setError(dict.emailNotVerified);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (empRes.status === 429) {
|
||||
setError(dict.tooManyRequests)
|
||||
return true
|
||||
setError(dict.tooManyRequests);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
if (preferAdminAuth) {
|
||||
if (await tryAdminLogin()) return
|
||||
setError(dict.invalidCredentials)
|
||||
return
|
||||
if (await tryAdminLogin()) return;
|
||||
setError(dict.invalidCredentials);
|
||||
return;
|
||||
}
|
||||
|
||||
if (await tryEmployeeLogin()) return
|
||||
if (await tryEmployeeLogin()) return;
|
||||
|
||||
setError(dict.invalidCredentials)
|
||||
setError(dict.invalidCredentials);
|
||||
} catch {
|
||||
setError(dict.unexpectedError)
|
||||
setError(dict.unexpectedError);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTotp(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const normalizedCode = totpCode.trim().toUpperCase()
|
||||
const normalizedCode = totpCode.trim().toUpperCase();
|
||||
const secondFactor = /^\d{6}$/.test(normalizedCode)
|
||||
? { totpCode: normalizedCode }
|
||||
: { recoveryCode: normalizedCode }
|
||||
: { recoveryCode: normalizedCode };
|
||||
|
||||
const adminRes = await fetch(`${API_BASE}/admin/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ email, password, ...secondFactor }),
|
||||
})
|
||||
const adminJson = await adminRes.json()
|
||||
});
|
||||
const adminJson = await adminRes.json();
|
||||
|
||||
if (adminRes.ok && adminJson?.data?.admin) {
|
||||
window.location.href = `${adminUrl}/dashboard`
|
||||
return
|
||||
window.location.href = `${adminUrl}/dashboard`;
|
||||
return;
|
||||
}
|
||||
|
||||
setError(dict.invalidCredentials)
|
||||
setError(dict.invalidCredentials);
|
||||
} catch {
|
||||
setError(dict.unexpectedError)
|
||||
setError(dict.unexpectedError);
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,10 +397,12 @@ function LocalSignInForm({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 'credentials' ? (
|
||||
{step === "credentials" ? (
|
||||
<form onSubmit={handleCredentials} className="space-y-5">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.email}</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
|
||||
{dict.email}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
@@ -387,10 +414,12 @@ function LocalSignInForm({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.password}</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
|
||||
{dict.password}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
@@ -404,13 +433,39 @@ function LocalSignInForm({
|
||||
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
|
||||
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
|
||||
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>
|
||||
@@ -426,7 +481,10 @@ function LocalSignInForm({
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<Link href="/forgot-password" className="text-sm text-stone-500 underline decoration-stone-300 underline-offset-4 hover:text-stone-900 dark:text-stone-400 dark:decoration-stone-600 dark:hover:text-stone-100">
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
className="text-sm text-stone-500 underline decoration-stone-300 underline-offset-4 hover:text-stone-900 dark:text-stone-400 dark:decoration-stone-600 dark:hover:text-stone-100"
|
||||
>
|
||||
{dict.forgotPassword}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -438,7 +496,9 @@ function LocalSignInForm({
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">{dict.authCode}</label>
|
||||
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
|
||||
{dict.authCode}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
inputMode="text"
|
||||
@@ -446,7 +506,11 @@ function LocalSignInForm({
|
||||
maxLength={14}
|
||||
required
|
||||
value={totpCode}
|
||||
onChange={(e) => setTotpCode(e.target.value.replace(/[^0-9A-Za-z-]/g, '').toUpperCase())}
|
||||
onChange={(e) =>
|
||||
setTotpCode(
|
||||
e.target.value.replace(/[^0-9A-Za-z-]/g, "").toUpperCase(),
|
||||
)
|
||||
}
|
||||
placeholder={dict.totpPlaceholder}
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-center text-xl tracking-[0.45em] text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
|
||||
/>
|
||||
@@ -463,9 +527,9 @@ function LocalSignInForm({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setStep('credentials')
|
||||
setTotpCode('')
|
||||
setError(null)
|
||||
setStep("credentials");
|
||||
setTotpCode("");
|
||||
setError(null);
|
||||
}}
|
||||
className="w-full text-sm text-stone-500 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100"
|
||||
>
|
||||
@@ -474,5 +538,5 @@ function LocalSignInForm({
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,117 +1,147 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
import { apiFetch } from '@/lib/api'
|
||||
import PublicShell from '@/components/layout/PublicShell'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
import Image from "next/image";
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import PublicShell from "@/components/layout/PublicShell";
|
||||
import { useDashboardI18n } from "@/components/I18nProvider";
|
||||
import { marketplaceUrl } from "@/lib/urls";
|
||||
|
||||
export default function SignUpForm() {
|
||||
const { language, setLanguage } = useDashboardI18n()
|
||||
const { language, setLanguage } = useDashboardI18n();
|
||||
const searchParams = useSearchParams();
|
||||
const requestedLanguage = searchParams.get("lang");
|
||||
const initialLanguage =
|
||||
requestedLanguage === "en" ||
|
||||
requestedLanguage === "fr" ||
|
||||
requestedLanguage === "ar"
|
||||
? requestedLanguage
|
||||
: language;
|
||||
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [preferredLanguage, setPreferredLanguage] = useState<'en' | 'fr' | 'ar'>(language)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [email, setEmail] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [preferredLanguage, setPreferredLanguage] = useState<
|
||||
"en" | "fr" | "ar"
|
||||
>(initialLanguage);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
const dict = {
|
||||
en: {
|
||||
title: 'Create your workspace',
|
||||
subtitle: 'Enter your email and password to get started.',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'you@company.com',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
|
||||
passwordHint: 'Minimum 8 characters',
|
||||
languageLabel: 'Preferred language',
|
||||
create: 'Create workspace',
|
||||
creating: 'Creating\u2026',
|
||||
alreadyHave: 'Already have an account?',
|
||||
signIn: 'Sign in',
|
||||
errorEmailTaken: 'An account with this email already exists.',
|
||||
errorGeneric: 'Something went wrong. Please try again.',
|
||||
successTitle: 'Check your email',
|
||||
successMessage: 'We sent a verification link to {email}. Click the link to activate your account, then sign in.',
|
||||
successSignIn: 'Go to sign in',
|
||||
title: "Create your workspace",
|
||||
subtitle: "Enter your email and password to get started.",
|
||||
email: "Email",
|
||||
emailPlaceholder: "you@company.com",
|
||||
password: "Password",
|
||||
passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
|
||||
passwordHint: "Minimum 8 characters",
|
||||
languageLabel: "Preferred language",
|
||||
create: "Create workspace",
|
||||
creating: "Creating\u2026",
|
||||
alreadyHave: "Already have an account?",
|
||||
signIn: "Sign in",
|
||||
errorEmailTaken: "An account with this email already exists.",
|
||||
errorGeneric: "Something went wrong. Please try again.",
|
||||
successTitle: "Check your email",
|
||||
successMessage:
|
||||
"We sent a verification link to {email}. Click the link to activate your account, then sign in.",
|
||||
successSignIn: "Go to sign in",
|
||||
},
|
||||
fr: {
|
||||
title: 'Cr\u00e9ez votre espace',
|
||||
subtitle: 'Saisissez votre email et mot de passe pour commencer.',
|
||||
email: 'Email',
|
||||
emailPlaceholder: 'vous@entreprise.com',
|
||||
password: 'Mot de passe',
|
||||
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
|
||||
passwordHint: 'Minimum 8 caract\u00e8res',
|
||||
languageLabel: 'Langue pr\u00e9f\u00e9r\u00e9e',
|
||||
create: 'Cr\u00e9er mon espace',
|
||||
creating: 'Cr\u00e9ation\u2026',
|
||||
alreadyHave: 'Vous avez d\u00e9j\u00e0 un compte ?',
|
||||
signIn: 'Se connecter',
|
||||
errorEmailTaken: 'Un compte avec cet email existe d\u00e9j\u00e0.',
|
||||
errorGeneric: 'Une erreur est survenue. Veuillez r\u00e9essayer.',
|
||||
successTitle: 'V\u00e9rifiez votre email',
|
||||
successMessage: 'Nous avons envoy\u00e9 un lien de v\u00e9rification \u00e0 {email}. Cliquez sur le lien pour activer votre compte, puis connectez-vous.',
|
||||
successSignIn: 'Aller \u00e0 la connexion',
|
||||
title: "Cr\u00e9ez votre espace",
|
||||
subtitle: "Saisissez votre email et mot de passe pour commencer.",
|
||||
email: "Email",
|
||||
emailPlaceholder: "vous@entreprise.com",
|
||||
password: "Mot de passe",
|
||||
passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
|
||||
passwordHint: "Minimum 8 caract\u00e8res",
|
||||
languageLabel: "Langue pr\u00e9f\u00e9r\u00e9e",
|
||||
create: "Cr\u00e9er mon espace",
|
||||
creating: "Cr\u00e9ation\u2026",
|
||||
alreadyHave: "Vous avez d\u00e9j\u00e0 un compte ?",
|
||||
signIn: "Se connecter",
|
||||
errorEmailTaken: "Un compte avec cet email existe d\u00e9j\u00e0.",
|
||||
errorGeneric: "Une erreur est survenue. Veuillez r\u00e9essayer.",
|
||||
successTitle: "V\u00e9rifiez votre email",
|
||||
successMessage:
|
||||
"Nous avons envoy\u00e9 un lien de v\u00e9rification \u00e0 {email}. Cliquez sur le lien pour activer votre compte, puis connectez-vous.",
|
||||
successSignIn: "Aller \u00e0 la connexion",
|
||||
},
|
||||
ar: {
|
||||
title: '\u0623\u0646\u0634\u0626 \u0645\u0633\u0627\u062d\u0629 \u0639\u0645\u0644\u0643',
|
||||
subtitle: '\u0623\u062f\u062e\u0644 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0648\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631 \u0644\u0644\u0628\u062f\u0621.',
|
||||
email: '\u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a',
|
||||
emailPlaceholder: 'you@company.com',
|
||||
password: '\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631',
|
||||
passwordPlaceholder: '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022',
|
||||
passwordHint: '\u0627\u0644\u062d\u062f \u0627\u0644\u0623\u062f\u0646\u0649 8 \u0623\u062d\u0631\u0641',
|
||||
languageLabel: '\u0627\u0644\u0644\u063a\u0629 \u0627\u0644\u0645\u0641\u0636\u0644\u0629',
|
||||
create: '\u0623\u0646\u0634\u0626 \u0627\u0644\u0645\u0633\u0627\u062d\u0629',
|
||||
creating: '\u062c\u0627\u0631\u064d \u0627\u0644\u0625\u0646\u0634\u0627\u0621\u2026',
|
||||
alreadyHave: '\u0647\u0644 \u0644\u062f\u064a\u0643 \u062d\u0633\u0627\u0628 \u0628\u0627\u0644\u0641\u0639\u0644\u061f',
|
||||
signIn: '\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644',
|
||||
errorEmailTaken: '\u064a\u0648\u062c\u062f \u062d\u0633\u0627\u0628 \u0628\u0647\u0630\u0627 \u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0628\u0627\u0644\u0641\u0639\u0644.',
|
||||
errorGeneric: '\u062d\u062f\u062b \u062e\u0637\u0623 \u0645\u0627. \u064a\u0631\u062c\u0649 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649.',
|
||||
successTitle: '\u062a\u062d\u0642\u0642 \u0645\u0646 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a',
|
||||
successMessage: '\u0623\u0631\u0633\u0644\u0646\u0627 \u0631\u0627\u0628\u0637 \u062a\u062d\u0642\u0642 \u0625\u0644\u0649 {email}. \u0627\u0646\u0642\u0631 \u0639\u0644\u0649 \u0627\u0644\u0631\u0627\u0628\u0637 \u0644\u062a\u0641\u0639\u064a\u0644 \u062d\u0633\u0627\u0628\u0643\u060c \u062b\u0645 \u0642\u0645 \u0628\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644.',
|
||||
successSignIn: '\u0627\u0644\u0630\u0647\u0627\u0628 \u0625\u0644\u0649 \u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644',
|
||||
title:
|
||||
"\u0623\u0646\u0634\u0626 \u0645\u0633\u0627\u062d\u0629 \u0639\u0645\u0644\u0643",
|
||||
subtitle:
|
||||
"\u0623\u062f\u062e\u0644 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0648\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631 \u0644\u0644\u0628\u062f\u0621.",
|
||||
email:
|
||||
"\u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a",
|
||||
emailPlaceholder: "you@company.com",
|
||||
password: "\u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631",
|
||||
passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
|
||||
passwordHint:
|
||||
"\u0627\u0644\u062d\u062f \u0627\u0644\u0623\u062f\u0646\u0649 8 \u0623\u062d\u0631\u0641",
|
||||
languageLabel:
|
||||
"\u0627\u0644\u0644\u063a\u0629 \u0627\u0644\u0645\u0641\u0636\u0644\u0629",
|
||||
create:
|
||||
"\u0623\u0646\u0634\u0626 \u0627\u0644\u0645\u0633\u0627\u062d\u0629",
|
||||
creating:
|
||||
"\u062c\u0627\u0631\u064d \u0627\u0644\u0625\u0646\u0634\u0627\u0621\u2026",
|
||||
alreadyHave:
|
||||
"\u0647\u0644 \u0644\u062f\u064a\u0643 \u062d\u0633\u0627\u0628 \u0628\u0627\u0644\u0641\u0639\u0644\u061f",
|
||||
signIn:
|
||||
"\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644",
|
||||
errorEmailTaken:
|
||||
"\u064a\u0648\u062c\u062f \u062d\u0633\u0627\u0628 \u0628\u0647\u0630\u0627 \u0627\u0644\u0628\u0631\u064a\u062f \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a \u0628\u0627\u0644\u0641\u0639\u0644.",
|
||||
errorGeneric:
|
||||
"\u062d\u062f\u062b \u062e\u0637\u0623 \u0645\u0627. \u064a\u0631\u062c\u0649 \u0627\u0644\u0645\u062d\u0627\u0648\u0644\u0629 \u0645\u0631\u0629 \u0623\u062e\u0631\u0649.",
|
||||
successTitle:
|
||||
"\u062a\u062d\u0642\u0642 \u0645\u0646 \u0628\u0631\u064a\u062f\u0643 \u0627\u0644\u0625\u0644\u0643\u062a\u0631\u0648\u0646\u064a",
|
||||
successMessage:
|
||||
"\u0623\u0631\u0633\u0644\u0646\u0627 \u0631\u0627\u0628\u0637 \u062a\u062d\u0642\u0642 \u0625\u0644\u0649 {email}. \u0627\u0646\u0642\u0631 \u0639\u0644\u0649 \u0627\u0644\u0631\u0627\u0628\u0637 \u0644\u062a\u0641\u0639\u064a\u0644 \u062d\u0633\u0627\u0628\u0643\u060c \u062b\u0645 \u0642\u0645 \u0628\u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644.",
|
||||
successSignIn:
|
||||
"\u0627\u0644\u0630\u0647\u0627\u0628 \u0625\u0644\u0649 \u062a\u0633\u062c\u064a\u0644 \u0627\u0644\u062f\u062e\u0648\u0644",
|
||||
},
|
||||
}[preferredLanguage]
|
||||
}[preferredLanguage];
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await apiFetch('/auth/account/start', {
|
||||
method: 'POST',
|
||||
await apiFetch("/auth/account/start", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email, password, preferredLanguage }),
|
||||
})
|
||||
});
|
||||
|
||||
setLanguage(preferredLanguage)
|
||||
document.cookie = `rentaldrivego-language=${preferredLanguage}; path=/; max-age=31536000; samesite=lax`
|
||||
setSuccess(true)
|
||||
setLanguage(preferredLanguage);
|
||||
document.cookie = `rentaldrivego-language=${preferredLanguage}; path=/; max-age=31536000; samesite=lax`;
|
||||
setSuccess(true);
|
||||
} catch (err: any) {
|
||||
if (err?.message?.includes('email_taken') || err?.code === 'email_taken') {
|
||||
setError(dict.errorEmailTaken)
|
||||
if (
|
||||
err?.message?.includes("email_taken") ||
|
||||
err?.code === "email_taken"
|
||||
) {
|
||||
setError(dict.errorEmailTaken);
|
||||
} else {
|
||||
setError(dict.errorGeneric)
|
||||
setError(dict.errorGeneric);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<PublicShell>
|
||||
<PublicShell hideHeaderActions>
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-16">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="flex justify-center mb-6">
|
||||
<Image
|
||||
src="/dashboard/rentalcardrive.png"
|
||||
alt="FleetOS"
|
||||
alt="RentalDriveGo"
|
||||
width={80}
|
||||
height={80}
|
||||
priority
|
||||
@@ -123,7 +153,7 @@ export default function SignUpForm() {
|
||||
{dict.successTitle}
|
||||
</h1>
|
||||
<p className="mt-4 text-sm leading-7 text-stone-600 dark:text-stone-300">
|
||||
{dict.successMessage.replace('{email}', email)}
|
||||
{dict.successMessage.replace("{email}", email)}
|
||||
</p>
|
||||
<a
|
||||
href="/sign-in"
|
||||
@@ -135,11 +165,11 @@ export default function SignUpForm() {
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PublicShell>
|
||||
<PublicShell hideHeaderActions>
|
||||
<main className="flex flex-1 items-center justify-center px-4 py-16">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
@@ -147,7 +177,7 @@ export default function SignUpForm() {
|
||||
<a href={marketplaceUrl} target="_top">
|
||||
<Image
|
||||
src="/dashboard/rentalcardrive.png"
|
||||
alt="FleetOS"
|
||||
alt="RentalDriveGo"
|
||||
width={80}
|
||||
height={80}
|
||||
priority
|
||||
@@ -191,34 +221,64 @@ export default function SignUpForm() {
|
||||
{dict.password} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={'\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'}
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
|
||||
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>
|
||||
<input
|
||||
type={showPassword ? "text" : "password"}
|
||||
required
|
||||
minLength={8}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={
|
||||
"\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022"
|
||||
}
|
||||
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 pr-10 text-sm text-stone-900 transition-colors placeholder:text-stone-400 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-orange-500 dark:border-blue-800 dark:bg-blue-950/80 dark:text-stone-100 dark:placeholder:text-stone-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((v) => !v)}
|
||||
className="absolute inset-y-0 right-3 flex items-center text-stone-400 hover:text-stone-700 dark:text-stone-500 dark:hover:text-stone-200"
|
||||
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>
|
||||
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">{dict.passwordHint}</p>
|
||||
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">
|
||||
{dict.passwordHint}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -226,18 +286,22 @@ export default function SignUpForm() {
|
||||
{dict.languageLabel} <span className="text-red-500">*</span>
|
||||
</span>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{(['en', 'fr', 'ar'] as const).map((lang) => (
|
||||
{(["en", "fr", "ar"] as const).map((lang) => (
|
||||
<button
|
||||
key={lang}
|
||||
type="button"
|
||||
onClick={() => setPreferredLanguage(lang)}
|
||||
className={`rounded-2xl border py-3 text-sm font-semibold transition ${
|
||||
preferredLanguage === lang
|
||||
? 'border-blue-900 bg-blue-900 text-white dark:bg-orange-500 dark:border-orange-400'
|
||||
: 'border-stone-200 bg-white text-stone-600 hover:border-stone-300 hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/50 dark:text-stone-300 dark:hover:border-blue-600'
|
||||
? "border-blue-900 bg-blue-900 text-white dark:bg-orange-500 dark:border-orange-400"
|
||||
: "border-stone-200 bg-white text-stone-600 hover:border-stone-300 hover:bg-stone-50 dark:border-blue-800 dark:bg-blue-950/50 dark:text-stone-300 dark:hover:border-blue-600"
|
||||
}`}
|
||||
>
|
||||
{lang === 'en' ? 'English' : lang === 'fr' ? 'Fran\u00e7ais' : '\u0627\u0644\u0639\u0631\u0628\u064a\u0629'}
|
||||
{lang === "en"
|
||||
? "English"
|
||||
: lang === "fr"
|
||||
? "Fran\u00e7ais"
|
||||
: "\u0627\u0644\u0639\u0631\u0628\u064a\u0629"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -253,7 +317,7 @@ export default function SignUpForm() {
|
||||
</form>
|
||||
|
||||
<p className="mt-6 text-center text-sm text-stone-500 dark:text-stone-400">
|
||||
{dict.alreadyHave}{' '}
|
||||
{dict.alreadyHave}{" "}
|
||||
<a
|
||||
href="/sign-in"
|
||||
className="font-semibold text-orange-700 hover:text-orange-800 dark:text-orange-300 dark:hover:text-orange-200"
|
||||
@@ -265,5 +329,5 @@ export default function SignUpForm() {
|
||||
</div>
|
||||
</main>
|
||||
</PublicShell>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export default function VerifyEmailPage() {
|
||||
<div className="flex justify-center mb-6">
|
||||
<Image
|
||||
src="/dashboard/rentalcardrive.png"
|
||||
alt="FleetOS"
|
||||
alt="RentalDriveGo"
|
||||
width={80}
|
||||
height={80}
|
||||
priority
|
||||
|
||||
@@ -165,7 +165,7 @@ export default function PublicFooter() {
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-stone-500 dark:text-stone-400">
|
||||
© {new Date().getFullYear()} FleetOS. {footerContent.rightsLabel}
|
||||
© {new Date().getFullYear()} RentalDriveGo. {footerContent.rightsLabel}
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,209 +1,212 @@
|
||||
'use client'
|
||||
"use client";
|
||||
|
||||
import { ChevronDown } from 'lucide-react'
|
||||
import Image from 'next/image'
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useDashboardI18n } from '@/components/I18nProvider'
|
||||
import { marketplaceUrl } from '@/lib/urls'
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useDashboardI18n } from "@/components/I18nProvider";
|
||||
import { marketplaceUrl } from "@/lib/urls";
|
||||
|
||||
const DASHBOARD_LOGO_SRC = '/dashboard/rentalcardrive.png'
|
||||
const BRAND_LOGO_SRC = "/dashboard/rentaldrivego.jpeg";
|
||||
|
||||
const languageMeta = {
|
||||
en: { flag: '🇺🇸', shortLabel: 'EN' },
|
||||
fr: { flag: '🇫🇷', shortLabel: 'FR' },
|
||||
ar: { flag: '🇲🇦', shortLabel: 'AR' },
|
||||
} as const
|
||||
en: { flag: "🇺🇸", shortLabel: "EN" },
|
||||
fr: { flag: "🇫🇷", shortLabel: "FR" },
|
||||
ar: { flag: "🇲🇦", shortLabel: "AR" },
|
||||
} as const;
|
||||
|
||||
export default function PublicHeader() {
|
||||
const { language, setLanguage, theme, setTheme } = useDashboardI18n()
|
||||
const searchParams = useSearchParams()
|
||||
const localeMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [localeMenuOpen, setLocaleMenuOpen] = useState(false)
|
||||
const themeMenuRef = useRef<HTMLDivElement | null>(null)
|
||||
const [themeMenuOpen, setThemeMenuOpen] = useState(false)
|
||||
const currentLanguage = languageMeta[language]
|
||||
const availableLanguages = (['en', 'fr', 'ar'] as const)
|
||||
export default function PublicHeader({
|
||||
hideActions = false,
|
||||
}: {
|
||||
hideActions?: boolean;
|
||||
}) {
|
||||
const { language, setLanguage, theme, setTheme } = useDashboardI18n();
|
||||
const localeMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [localeMenuOpen, setLocaleMenuOpen] = useState(false);
|
||||
const themeMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
const [themeMenuOpen, setThemeMenuOpen] = useState(false);
|
||||
const currentLanguage = languageMeta[language];
|
||||
const availableLanguages = (["en", "fr", "ar"] as const)
|
||||
.filter((value) => value !== language)
|
||||
.map((value) => ({ value, ...languageMeta[value] }))
|
||||
.map((value) => ({ value, ...languageMeta[value] }));
|
||||
const dict = {
|
||||
en: {
|
||||
home: 'Home',
|
||||
marketplace: 'Marketplace',
|
||||
signIn: 'Sign in',
|
||||
createAccount: 'Create Agency Space',
|
||||
theme: 'Theme',
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
signIn: "Sign in",
|
||||
createAccount: "Get Started",
|
||||
theme: "Theme",
|
||||
light: "Light",
|
||||
dark: "Dark",
|
||||
},
|
||||
fr: {
|
||||
home: 'Accueil',
|
||||
marketplace: 'Marketplace',
|
||||
signIn: 'Connexion',
|
||||
createAccount: 'Créer un espace d’agence',
|
||||
theme: 'Mode',
|
||||
light: 'Clair',
|
||||
dark: 'Sombre',
|
||||
signIn: "Connexion",
|
||||
createAccount: "Démarrer",
|
||||
theme: "Mode",
|
||||
light: "Clair",
|
||||
dark: "Sombre",
|
||||
},
|
||||
ar: {
|
||||
home: 'الرئيسية',
|
||||
marketplace: 'السوق',
|
||||
signIn: 'تسجيل الدخول',
|
||||
createAccount: 'إنشاء مساحة الوكالة',
|
||||
theme: 'الوضع',
|
||||
light: 'فاتح',
|
||||
dark: 'داكن',
|
||||
signIn: "تسجيل الدخول",
|
||||
createAccount: "ابدأ الآن",
|
||||
theme: "الوضع",
|
||||
light: "فاتح",
|
||||
dark: "داكن",
|
||||
},
|
||||
}[language]
|
||||
}[language];
|
||||
const themeOptions = [
|
||||
{ value: 'light' as const, label: dict.light },
|
||||
{ value: 'dark' as const, label: dict.dark },
|
||||
]
|
||||
const currentTheme = themeOptions.find((option) => option.value === theme) ?? themeOptions[0]
|
||||
{ value: "light" as const, label: dict.light },
|
||||
{ value: "dark" as const, label: dict.dark },
|
||||
];
|
||||
const currentTheme =
|
||||
themeOptions.find((option) => option.value === theme) ?? themeOptions[0];
|
||||
const localeMenuPositionClass =
|
||||
language === 'ar'
|
||||
? 'right-0 sm:right-0'
|
||||
: 'left-0 sm:left-auto sm:right-0'
|
||||
language === "ar" ? "right-0 sm:right-0" : "left-0 sm:left-auto sm:right-0";
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
if (!localeMenuRef.current?.contains(event.target as Node)) {
|
||||
setLocaleMenuOpen(false)
|
||||
setLocaleMenuOpen(false);
|
||||
}
|
||||
if (!themeMenuRef.current?.contains(event.target as Node)) {
|
||||
setThemeMenuOpen(false)
|
||||
setThemeMenuOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown)
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown)
|
||||
}, [])
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
return () => document.removeEventListener("mousedown", handlePointerDown);
|
||||
}, []);
|
||||
|
||||
function toggleLocaleMenu() {
|
||||
setThemeMenuOpen(false)
|
||||
setLocaleMenuOpen((open) => !open)
|
||||
setThemeMenuOpen(false);
|
||||
setLocaleMenuOpen((open) => !open);
|
||||
}
|
||||
|
||||
function toggleThemeMenu() {
|
||||
setLocaleMenuOpen(false)
|
||||
setThemeMenuOpen((open) => !open)
|
||||
setLocaleMenuOpen(false);
|
||||
setThemeMenuOpen((open) => !open);
|
||||
}
|
||||
|
||||
const signInParams = new URLSearchParams()
|
||||
signInParams.set('lang', language)
|
||||
signInParams.set('theme', theme)
|
||||
const next = searchParams.get('next')
|
||||
const redirect = searchParams.get('redirect')
|
||||
if (next) signInParams.set('next', next)
|
||||
if (redirect) signInParams.set('redirect', redirect)
|
||||
const signInHref = `${marketplaceUrl}/sign-in?${signInParams.toString()}`
|
||||
const signInHref = `${marketplaceUrl}/${language}#signin`;
|
||||
|
||||
const signUpParams = new URLSearchParams()
|
||||
signUpParams.set('lang', language)
|
||||
signUpParams.set('theme', theme)
|
||||
const signUpHref = `/dashboard/sign-up?${signUpParams.toString()}`
|
||||
const signUpHref = `${marketplaceUrl}/${language}#demo`;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.06)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 dark:shadow-[0_10px_30px_rgba(0,0,0,0.28)]">
|
||||
<div className="mx-auto flex max-w-7xl flex-col gap-1.5 px-4 py-1.5 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:px-8 lg:py-2">
|
||||
<a href={marketplaceUrl} className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-blue-950 dark:text-slate-100 sm:text-sm sm:tracking-[0.2em]">
|
||||
<Image
|
||||
src={DASHBOARD_LOGO_SRC}
|
||||
alt="FleetOS"
|
||||
width={36}
|
||||
height={36}
|
||||
priority
|
||||
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
|
||||
/>
|
||||
<span>FleetOS</span>
|
||||
</a>
|
||||
<header className="sticky top-0 z-50 border-b border-stone-200/80 bg-white/78 backdrop-blur-xl shadow-[0_10px_30px_rgba(15,23,42,0.08)] transition-colors dark:border-blue-900 dark:bg-blue-950/76 dark:shadow-[0_10px_30px_rgba(0,0,0,0.32)]">
|
||||
<div className="mx-auto flex min-h-14 max-w-7xl flex-col gap-1.5 px-4 py-1.5 sm:px-6 lg:flex-row lg:items-center lg:justify-between lg:gap-3 lg:px-8 lg:py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<a
|
||||
href={`${marketplaceUrl}/${language}`}
|
||||
className="flex items-center gap-1.5 text-[11px] font-bold uppercase tracking-[0.14em] text-stone-900 no-underline sm:text-sm sm:tracking-[0.2em] dark:text-stone-100"
|
||||
aria-label="RentalDriveGo home"
|
||||
>
|
||||
<Image
|
||||
src={BRAND_LOGO_SRC}
|
||||
alt="RentalDriveGo"
|
||||
width={36}
|
||||
height={36}
|
||||
priority
|
||||
className="h-8 w-8 rounded-md object-contain sm:h-9 sm:w-9"
|
||||
/>
|
||||
<span>RentalDriveGo</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 lg:flex-row lg:items-center lg:gap-3">
|
||||
<nav className="flex items-center gap-0.5 overflow-x-auto">
|
||||
<a href={marketplaceUrl} className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm">
|
||||
{dict.home}
|
||||
</a>
|
||||
<a href={`${marketplaceUrl}/explore`} className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm">
|
||||
{dict.marketplace}
|
||||
</a>
|
||||
<a href={signInHref} className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100 sm:px-4 sm:py-2 sm:text-sm">
|
||||
{dict.signIn}
|
||||
</a>
|
||||
<a href={signUpHref} className="ml-1 rounded-full bg-blue-900 px-3 py-1 text-[12px] font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-400 dark:text-white dark:hover:bg-orange-300 sm:ml-2 sm:px-5 sm:py-2 sm:text-sm">
|
||||
{dict.createAccount}
|
||||
</a>
|
||||
</nav>
|
||||
<div className="shrink-0">
|
||||
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm dark:border-blue-800 dark:bg-blue-950/85 sm:self-auto sm:p-1">
|
||||
<div ref={localeMenuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLocaleMenu}
|
||||
className="inline-flex min-w-[4.75rem] items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 text-[11px] font-semibold text-stone-700 transition hover:bg-stone-100 dark:text-stone-200 dark:hover:bg-blue-900/40 sm:min-w-[5.25rem] sm:px-3 sm:py-2 sm:text-xs"
|
||||
aria-expanded={localeMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
<div className="flex items-center self-start rounded-full border border-stone-200/80 bg-white/95 p-0.5 shadow-sm sm:self-auto sm:p-1 dark:border-blue-800 dark:bg-blue-950/85">
|
||||
<div ref={localeMenuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleLocaleMenu}
|
||||
className="inline-flex min-w-[4.75rem] items-center justify-center gap-1.5 rounded-full px-2.5 py-1.5 text-[11px] font-semibold text-stone-700 transition hover:bg-stone-100 sm:min-w-[5.25rem] sm:px-3 sm:py-2 sm:text-xs dark:text-stone-200 dark:hover:bg-blue-900/40"
|
||||
aria-expanded={localeMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span aria-hidden="true">{currentLanguage.flag}</span>
|
||||
<span>{currentLanguage.shortLabel}</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 transition-transform ${localeMenuOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{localeMenuOpen ? (
|
||||
<div
|
||||
className={`absolute top-full z-30 mt-2 w-48 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${localeMenuPositionClass}`}
|
||||
>
|
||||
<span aria-hidden="true">{currentLanguage.flag}</span>
|
||||
<span>{currentLanguage.shortLabel}</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 transition-transform ${localeMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{localeMenuOpen ? (
|
||||
<div className={`absolute top-full z-30 mt-2 w-48 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${localeMenuPositionClass}`}>
|
||||
{availableLanguages.map((option) => (
|
||||
{availableLanguages.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLanguage(option.value);
|
||||
setLocaleMenuOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
|
||||
>
|
||||
<span aria-hidden="true">{option.flag}</span>
|
||||
<span>{option.shortLabel}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span
|
||||
className="mx-1 h-6 w-px bg-stone-200 dark:bg-stone-700"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div ref={themeMenuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleThemeMenu}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2 py-1 transition hover:bg-stone-100 sm:px-2.5 sm:py-1.5 dark:hover:bg-blue-900/40"
|
||||
aria-expanded={themeMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span className="rounded-full bg-blue-900 px-3 py-1 text-[11px] font-semibold text-white sm:px-3.5 sm:py-1.5 sm:text-xs dark:bg-orange-400 dark:text-white">
|
||||
{currentTheme.label}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`h-3.5 w-3.5 text-stone-500 transition-transform dark:text-stone-400 ${themeMenuOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
{themeMenuOpen ? (
|
||||
<div
|
||||
className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${localeMenuPositionClass}`}
|
||||
>
|
||||
{themeOptions
|
||||
.filter((option) => option.value !== theme)
|
||||
.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLanguage(option.value)
|
||||
setLocaleMenuOpen(false)
|
||||
setTheme(option.value);
|
||||
setThemeMenuOpen(false);
|
||||
}}
|
||||
className="flex w-full items-center gap-3 px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
|
||||
>
|
||||
<span aria-hidden="true">{option.flag}</span>
|
||||
<span>{option.shortLabel}</span>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="mx-1 h-6 w-px bg-stone-200 dark:bg-stone-700" aria-hidden="true" />
|
||||
<div ref={themeMenuRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleThemeMenu}
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-2 py-1 transition hover:bg-stone-100 dark:hover:bg-blue-900/40 sm:px-2.5 sm:py-1.5"
|
||||
aria-expanded={themeMenuOpen}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
<span className="rounded-full bg-blue-900 px-3 py-1 text-[11px] font-semibold text-white dark:bg-orange-400 dark:text-white sm:px-3.5 sm:py-1.5 sm:text-xs">
|
||||
{currentTheme.label}
|
||||
</span>
|
||||
<ChevronDown className={`h-3.5 w-3.5 text-stone-500 transition-transform dark:text-stone-400 ${themeMenuOpen ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
{themeMenuOpen ? (
|
||||
<div className={`absolute top-full z-30 mt-2 w-44 max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-stone-200 bg-white/95 text-left shadow-[0_20px_60px_rgba(0,0,0,0.12)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/95 dark:shadow-[0_20px_60px_rgba(0,0,0,0.35)] ${localeMenuPositionClass}`}>
|
||||
{themeOptions
|
||||
.filter((option) => option.value !== theme)
|
||||
.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setTheme(option.value)
|
||||
setThemeMenuOpen(false)
|
||||
}}
|
||||
className="flex w-full items-center justify-between px-4 py-3 text-sm text-stone-700 transition hover:bg-stone-100 hover:text-blue-900 dark:text-stone-200 dark:hover:bg-blue-900/40 dark:hover:text-white"
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hideActions ? null : (
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={signInHref}
|
||||
className="rounded-full px-2.5 py-1 text-[12px] font-medium text-stone-600 no-underline transition hover:bg-stone-100 hover:text-stone-900 sm:px-4 sm:py-2 sm:text-sm dark:text-stone-300 dark:hover:bg-blue-900/40 dark:hover:text-stone-100"
|
||||
>
|
||||
{dict.signIn}
|
||||
</a>
|
||||
<a
|
||||
href={signUpHref}
|
||||
className="rounded-full bg-orange-600 px-3 py-1 text-[12px] font-semibold text-white no-underline transition hover:bg-orange-700 sm:px-5 sm:py-2 sm:text-sm dark:bg-orange-500 dark:hover:bg-orange-400"
|
||||
>
|
||||
{dict.createAccount}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
'use client'
|
||||
"use client";
|
||||
import * as React from "react";
|
||||
import PublicFooter from '@/components/layout/PublicFooter'
|
||||
import PublicHeader from '@/components/layout/PublicHeader'
|
||||
import PublicFooter from "@/components/layout/PublicFooter";
|
||||
import PublicHeader from "@/components/layout/PublicHeader";
|
||||
|
||||
export default function PublicShell({
|
||||
children,
|
||||
embedded = false,
|
||||
hideFooter = false,
|
||||
hideHeaderActions = false,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
embedded?: boolean
|
||||
children: React.ReactNode;
|
||||
embedded?: boolean;
|
||||
hideFooter?: boolean;
|
||||
hideHeaderActions?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-[linear-gradient(180deg,#ffffff_0%,#f5f8ff_28%,#eef4ff_58%,#ffffff_100%)] text-blue-950 transition-colors dark:bg-[linear-gradient(180deg,#0f1f4a_0%,#112d6e_35%,#0d1f4f_100%)] dark:text-slate-100">
|
||||
{embedded ? null : <PublicHeader />}
|
||||
{embedded ? null : <PublicHeader hideActions={hideHeaderActions} />}
|
||||
<div className="flex flex-1 flex-col">{children}</div>
|
||||
{embedded ? null : <PublicFooter />}
|
||||
{embedded || hideFooter ? null : <PublicFooter />}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -401,7 +401,7 @@ export default function Sidebar() {
|
||||
)}
|
||||
</div>
|
||||
<span className="truncate bg-gradient-to-br from-blue-950 to-blue-600 bg-clip-text text-sm font-semibold tracking-[-0.01em] text-transparent dark:from-slate-100 dark:to-blue-300">
|
||||
{brand?.displayName ?? 'FleetOS'}
|
||||
{brand?.displayName ?? 'RentalDriveGo'}
|
||||
</span>
|
||||
</a>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user