Files
carmanagement/apps/dashboard/src/app/sign-up/[[...sign-up]]/SignUpForm.tsx
T
root 057d41e1c2
Build & Push / Pipeline Tests (push) Failing after 1m5s
Build & Push / Build & Push Docker Image (push) Has been skipped
Test / Type Check (all packages) (push) Failing after 52s
Test / API Unit Tests (push) Has been skipped
Test / Homepage Unit Tests (push) Has been skipped
Test / Carplace Unit Tests (push) Has been skipped
Test / Admin Unit Tests (push) Has been skipped
Test / Dashboard Unit Tests (push) Has been skipped
Test / API Integration Tests (push) Has been skipped
add text input validation
2026-08-31 22:51:44 -04:00

569 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import Image from "next/image";
import { useEffect, useRef, useState } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { apiFetch } from "@/lib/api";
import PublicShell from "@/components/layout/PublicShell";
import { useDashboardI18n } from "@/components/I18nProvider";
import { buildHomepageSignInPath } from "@/lib/dashboardPaths";
import { websiteUrl } from "@/lib/urls";
const DASHBOARD_LOGO_SRC = "/dashboard/rentaldrivego.png";
const SUBSCRIPTION_PLANS = ["STARTER", "GROWTH", "PRO"] as const;
type SubscriptionPlan = (typeof SUBSCRIPTION_PLANS)[number];
const PLAN_LABELS: Record<SubscriptionPlan, string> = {
STARTER: "Launch",
GROWTH: "Growth",
PRO: "Pro",
};
function isSubscriptionPlan(value: string | null): value is SubscriptionPlan {
return SUBSCRIPTION_PLANS.some((plan) => plan === value);
}
function notifyParent(message: Record<string, unknown>) {
if (typeof window === "undefined" || window.parent === window) return;
window.parent.postMessage(message, "*");
}
function withAuthQuery(
params: { embedded: boolean; language: "en" | "fr" | "ar"; theme: string },
) {
const href = buildHomepageSignInPath(undefined, {
locale: params.language,
theme: params.theme,
});
const url = new URL(href, "http://localhost");
if (params.embedded) url.searchParams.set("embedded", "1");
return `${url.pathname}${url.search}`;
}
export default function SignUpForm({
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 requestedPlan = searchParams.get("plan");
const selectedPlan = isSubscriptionPlan(requestedPlan) ? requestedPlan : null;
const initializedFromQuery = useRef(false);
const initialLanguage =
requestedLanguage === "en" ||
requestedLanguage === "fr" ||
requestedLanguage === "ar"
? requestedLanguage
: language;
const [firstName, setFirstName] = useState("");
const [lastName, setLastName] = useState("");
const [companyName, setCompanyName] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = 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);
useEffect(() => {
if (initializedFromQuery.current) return;
if (
(requestedLanguage === "en" ||
requestedLanguage === "fr" ||
requestedLanguage === "ar") &&
requestedLanguage !== language
) {
setLanguage(requestedLanguage);
setPreferredLanguage(requestedLanguage);
}
if (
(requestedTheme === "light" || requestedTheme === "dark") &&
requestedTheme !== theme
) {
setTheme(requestedTheme);
}
initializedFromQuery.current = true;
}, [
language,
requestedLanguage,
requestedTheme,
setLanguage,
setTheme,
theme,
]);
useEffect(() => {
if (!initializedFromQuery.current) return;
if (language !== preferredLanguage) setPreferredLanguage(language);
}, [language, preferredLanguage]);
useEffect(() => {
if (!initializedFromQuery.current) return;
const params = new URLSearchParams(searchParams.toString());
let changed = false;
if (params.get("lang") !== preferredLanguage) {
params.set("lang", preferredLanguage);
changed = true;
}
if (params.get("theme") !== theme) {
params.set("theme", theme);
changed = true;
}
if (embedded && params.get("embedded") !== "1") {
params.set("embedded", "1");
changed = true;
}
if (!changed) return;
const nextQuery = params.toString();
router.replace(nextQuery ? `${pathname}?${nextQuery}` : pathname, {
scroll: false,
});
}, [embedded, pathname, preferredLanguage, router, searchParams, theme]);
useEffect(() => {
const currentPath =
window.location.pathname + (window.location.search || "");
notifyParent({ type: "rentaldrivego:embedded-path", path: currentPath });
}, [pathname, searchParams]);
const dict = {
en: {
title: "Create account",
subtitle: "Enter your details to create your workspace.",
firstName: "First name",
firstNamePlaceholder: "Aya",
lastName: "Last name",
lastNamePlaceholder: "Benali",
company: "Company",
companyPlaceholder: "Atlas Cars",
email: "Email",
emailPlaceholder: "you@company.com",
password: "Password",
passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
confirmPassword: "Confirm password",
passwordHint: "Minimum 8 characters",
languageLabel: "Preferred language",
create: "Create account",
subscribeToPlan: "Subscribe to {plan}",
creating: "Creating\u2026",
alreadyHave: "Already have an account?",
signIn: "Sign in",
errorEmailTaken: "An account with this email already exists.",
errorPasswordMismatch: "Passwords do not match.",
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\u00e9er un compte",
subtitle: "Saisissez vos informations pour cr\u00e9er votre espace.",
firstName: "Pr\u00e9nom",
firstNamePlaceholder: "Aya",
lastName: "Nom",
lastNamePlaceholder: "Benali",
company: "Entreprise",
companyPlaceholder: "Atlas Cars",
email: "Email",
emailPlaceholder: "vous@entreprise.com",
password: "Mot de passe",
passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
confirmPassword: "Confirmer le mot de passe",
passwordHint: "Minimum 8 caract\u00e8res",
languageLabel: "Langue pr\u00e9f\u00e9r\u00e9e",
create: "Cr\u00e9er un compte",
subscribeToPlan: "Sabonner à {plan}",
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.",
errorPasswordMismatch: "Les mots de passe ne correspondent pas.",
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 \u062d\u0633\u0627\u0628\u064b\u0627",
subtitle:
"\u0623\u062f\u062e\u0644 \u0628\u064a\u0627\u0646\u0627\u062a\u0643 \u0644\u0625\u0646\u0634\u0627\u0621 \u0645\u0633\u0627\u062d\u0629 \u0639\u0645\u0644\u0643.",
firstName: "\u0627\u0644\u0627\u0633\u0645 \u0627\u0644\u0623\u0648\u0644",
firstNamePlaceholder: "Aya",
lastName: "\u0627\u0633\u0645 \u0627\u0644\u0639\u0627\u0626\u0644\u0629",
lastNamePlaceholder: "Benali",
company: "\u0627\u0644\u0634\u0631\u0643\u0629",
companyPlaceholder: "Atlas Cars",
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",
confirmPassword:
"\u062a\u0623\u0643\u064a\u062f \u0643\u0644\u0645\u0629 \u0627\u0644\u0645\u0631\u0648\u0631",
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 \u062d\u0633\u0627\u0628\u064b\u0627",
subscribeToPlan:
"\u0627\u0634\u062a\u0631\u0643 \u0641\u064a {plan}",
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.",
errorPasswordMismatch:
"\u0643\u0644\u0645\u062a\u0627 \u0627\u0644\u0645\u0631\u0648\u0631 \u063a\u064a\u0631 \u0645\u062a\u0637\u0627\u0628\u0642\u062a\u064a\u0646.",
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];
const signInHref = withAuthQuery({
embedded,
language: preferredLanguage,
theme,
});
const submitLabel = selectedPlan
? dict.subscribeToPlan.replace("{plan}", PLAN_LABELS[selectedPlan])
: dict.create;
const textPattern = {
en: "[A-Za-z\\s'-]+",
fr: "[A-Za-zÀ-ÖØ-öø-ÿŒœ\\s'-]+",
ar: "[\\u0600-\\u06FF\\u0750-\\u077F\\s،؛؟ـ'-]+",
}[preferredLanguage];
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (password !== confirmPassword) {
setError(dict.errorPasswordMismatch);
return;
}
setLoading(true);
setError(null);
try {
await apiFetch("/auth/account/start", {
method: "POST",
body: JSON.stringify({
firstName: firstName.trim(),
lastName: lastName.trim(),
companyName: companyName.trim(),
email: email.trim(),
password,
preferredLanguage,
...(selectedPlan ? { subscriptionPlan: selectedPlan } : {}),
}),
});
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);
} else {
setError(dict.errorGeneric);
}
} finally {
setLoading(false);
}
}
if (success) {
return (
<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 text-center">
<div className="flex justify-center mb-6">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={104}
height={104}
unoptimized
className="h-24 w-24 rounded-[1.75rem] border border-stone-200/80 bg-white/85 p-1.5 shadow-sm transition-colors dark:border-blue-800 dark:bg-blue-950/80"
/>
</div>
<div className="rounded-[2rem] border border-stone-200/80 bg-white/82 p-8 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur dark:border-blue-900 dark:bg-blue-950/78">
<h1 className="text-2xl font-black tracking-[-0.03em] text-blue-950 dark:text-stone-50">
{dict.successTitle}
</h1>
<p className="mt-4 text-sm leading-7 text-stone-600 dark:text-stone-300">
{dict.successMessage.replace("{email}", email)}
</p>
<a
href={signInHref}
target={embedded ? "_self" : undefined}
className="mt-6 inline-flex justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 dark:bg-orange-500 dark:hover:bg-orange-400"
>
{dict.successSignIn}
</a>
</div>
</div>
</main>
</PublicShell>
);
}
return (
<PublicShell embedded={embedded} hideFooter hideHeaderActions>
<main className="flex flex-1 items-center justify-center px-4 py-12">
<div className="w-full max-w-lg">
<div className="mb-8 text-center">
<div className="flex justify-center">
<a href={websiteUrl} target="_top">
<Image
src={DASHBOARD_LOGO_SRC}
alt="RentalDriveGo"
width={104}
height={104}
unoptimized
className="h-24 w-24 rounded-[1.75rem] border border-stone-200/80 bg-white/85 p-1.5 shadow-sm transition-colors dark:border-blue-800 dark:bg-blue-950/80"
/>
</a>
</div>
<h1 className="mt-3 text-3xl font-black tracking-tight text-stone-900 dark:text-stone-100">
{dict.title}
</h1>
<p className="mt-2 text-sm text-stone-600 dark:text-stone-300">
{dict.subtitle}
</p>
</div>
<section className="rounded-[2rem] border border-stone-200/80 bg-white/82 p-8 shadow-[0_30px_80px_rgba(28,25,23,0.08)] backdrop-blur transition-colors dark:border-blue-900 dark:bg-blue-950/78 dark:shadow-[0_30px_80px_rgba(0,0,0,0.26)]">
{error ? (
<div className="mb-5 rounded-[1.5rem] border border-red-200/80 bg-red-50/90 px-4 py-3 text-sm text-red-700 dark:border-red-900/60 dark:bg-red-950/40 dark:text-red-300">
{error}
</div>
) : null}
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.firstName} <span className="text-red-500">*</span>
</label>
<input
type="text"
required
maxLength={50}
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder={dict.firstNamePlaceholder}
autoFocus
dir={preferredLanguage === "ar" ? "rtl" : "ltr"}
pattern={textPattern}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 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"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.lastName} <span className="text-red-500">*</span>
</label>
<input
type="text"
required
maxLength={50}
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder={dict.lastNamePlaceholder}
dir={preferredLanguage === "ar" ? "rtl" : "ltr"}
pattern={textPattern}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 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"
/>
</div>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.company} <span className="text-red-500">*</span>
</label>
<input
type="text"
required
minLength={2}
maxLength={120}
value={companyName}
onChange={(e) => setCompanyName(e.target.value)}
placeholder={dict.companyPlaceholder}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 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"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.email} <span className="text-red-500">*</span>
</label>
<input
type="email"
required
maxLength={254}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={dict.emailPlaceholder}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 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"
/>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.languageLabel} <span className="text-red-500">*</span>
</label>
<select
required
value={preferredLanguage}
onChange={(e) => setPreferredLanguage(e.target.value as "en" | "fr" | "ar")}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 text-sm text-stone-900 transition-colors 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"
>
<option value="en">EN</option>
<option value="fr">FR</option>
<option value="ar">عربي</option>
</select>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{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>
</div>
<p className="mt-1 text-xs text-stone-400 dark:text-stone-500">
{dict.passwordHint}
</p>
</div>
<div>
<label className="mb-1.5 block text-sm font-medium text-stone-700 dark:text-stone-200">
{dict.confirmPassword} <span className="text-red-500">*</span>
</label>
<input
type={showPassword ? "text" : "password"}
required
minLength={8}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder={dict.passwordPlaceholder}
className="w-full rounded-2xl border border-stone-200 bg-white px-4 py-3 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"
/>
</div>
<button
type="submit"
disabled={loading}
className="inline-flex w-full justify-center rounded-full bg-orange-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-orange-700 disabled:cursor-not-allowed disabled:opacity-60 dark:bg-orange-500 dark:text-white dark:hover:bg-orange-400"
>
{loading ? dict.creating : submitLabel}
</button>
</form>
<p className="mt-6 text-center text-sm text-stone-500 dark:text-stone-400">
{dict.alreadyHave}{" "}
<a
href={signInHref}
className="font-semibold text-orange-700 hover:text-orange-800 dark:text-orange-300 dark:hover:text-orange-200"
>
{dict.signIn}
</a>
</p>
</section>
</div>
</main>
</PublicShell>
);
}