700 lines
24 KiB
TypeScript
700 lines
24 KiB
TypeScript
"use client";
|
|
|
|
import { ChevronDown, X } from "lucide-react";
|
|
import { useTranslations } from "next-intl";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { useServers } from "@/lib/hooks/use-servers";
|
|
import { useSession } from "@/lib/hooks/use-session";
|
|
import {
|
|
COUNTRIES,
|
|
type Country,
|
|
DEFAULT_COUNTRY,
|
|
} from "@/lib/utils/countries";
|
|
import {
|
|
checkPasswordStrength,
|
|
combinePhoneWithDialCode,
|
|
formatInternationalPhone,
|
|
getPhonePlaceholder,
|
|
unformatPhone,
|
|
validatePasswordRequirements,
|
|
} from "@/lib/utils/formatters";
|
|
|
|
interface RegisterDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
}
|
|
|
|
interface ServerSelection {
|
|
serverName: string;
|
|
characterName: string;
|
|
isMain: boolean;
|
|
}
|
|
|
|
export function RegisterDialog({ open, onOpenChange }: RegisterDialogProps) {
|
|
const t = useTranslations("auth.register");
|
|
const { refreshSession } = useSession();
|
|
const {
|
|
servers,
|
|
isLoading: serversLoading,
|
|
error: serversError,
|
|
} = useServers();
|
|
|
|
// Form fields
|
|
const [username, setUsername] = useState("");
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [confirmPassword, setConfirmPassword] = useState("");
|
|
const [firstName, setFirstName] = useState("");
|
|
const [lastName, setLastName] = useState("");
|
|
const [phone, setPhone] = useState("");
|
|
const [selectedCountry, setSelectedCountry] =
|
|
useState<Country>(DEFAULT_COUNTRY);
|
|
const [countrySearch, setCountrySearch] = useState("");
|
|
|
|
// Servers selection
|
|
const [selectedServers, setSelectedServers] = useState<ServerSelection[]>([]);
|
|
const [serverSearch, setServerSearch] = useState("");
|
|
const [showServerDropdown, setShowServerDropdown] = useState(false);
|
|
|
|
// UI state
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
|
const [passwordStrength, setPasswordStrength] = useState<
|
|
"weak" | "medium" | "strong"
|
|
>("weak");
|
|
|
|
// Filtered servers based on search
|
|
const filteredServers = useMemo(() => {
|
|
if (!serverSearch.trim()) return servers;
|
|
const searchLower = serverSearch.toLowerCase();
|
|
return servers.filter(
|
|
(s) =>
|
|
s.serverName.toLowerCase().includes(searchLower) &&
|
|
!selectedServers.some((sel) => sel.serverName === s.serverName),
|
|
);
|
|
}, [serverSearch, servers, selectedServers]);
|
|
|
|
// Update password strength
|
|
useEffect(() => {
|
|
if (password) {
|
|
setPasswordStrength(checkPasswordStrength(password));
|
|
} else {
|
|
setPasswordStrength("weak");
|
|
}
|
|
}, [password]);
|
|
|
|
// Reset form when dialog closes
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setUsername("");
|
|
setEmail("");
|
|
setPassword("");
|
|
setConfirmPassword("");
|
|
setFirstName("");
|
|
setLastName("");
|
|
setPhone("");
|
|
setSelectedCountry(DEFAULT_COUNTRY);
|
|
setCountrySearch("");
|
|
setSelectedServers([]);
|
|
setServerSearch("");
|
|
setError(null);
|
|
setFieldErrors({});
|
|
setPasswordStrength("weak");
|
|
}
|
|
}, [open]);
|
|
|
|
const addServer = (serverName: string) => {
|
|
if (selectedServers.some((s) => s.serverName === serverName)) {
|
|
return;
|
|
}
|
|
|
|
const isFirst = selectedServers.length === 0;
|
|
setSelectedServers([
|
|
...selectedServers,
|
|
{
|
|
serverName,
|
|
characterName: "",
|
|
isMain: isFirst,
|
|
},
|
|
]);
|
|
setServerSearch("");
|
|
setShowServerDropdown(false);
|
|
};
|
|
|
|
const removeServer = (index: number) => {
|
|
const newServers = selectedServers.filter((_, i) => i !== index);
|
|
// If we removed the main character and there are still servers, make first one main
|
|
if (selectedServers[index].isMain && newServers.length > 0) {
|
|
newServers[0].isMain = true;
|
|
}
|
|
setSelectedServers(newServers);
|
|
};
|
|
|
|
const updateServerCharacter = (index: number, characterName: string) => {
|
|
const newServers = [...selectedServers];
|
|
newServers[index].characterName = characterName;
|
|
setSelectedServers(newServers);
|
|
};
|
|
|
|
const setMainServer = (index: number) => {
|
|
const newServers = selectedServers.map((s, i) => ({
|
|
...s,
|
|
isMain: i === index,
|
|
}));
|
|
setSelectedServers(newServers);
|
|
};
|
|
|
|
const handlePhoneChange = (value: string) => {
|
|
const formatted = formatInternationalPhone(
|
|
value,
|
|
selectedCountry.dialCode,
|
|
selectedCountry.code,
|
|
);
|
|
setPhone(formatted);
|
|
};
|
|
|
|
// Filtrar países para busca
|
|
const filteredCountries = useMemo(() => {
|
|
if (!countrySearch.trim()) return COUNTRIES;
|
|
const searchLower = countrySearch.toLowerCase();
|
|
return COUNTRIES.filter(
|
|
(c) =>
|
|
c.name.toLowerCase().includes(searchLower) ||
|
|
c.dialCode.includes(searchLower) ||
|
|
c.code.toLowerCase().includes(searchLower),
|
|
);
|
|
}, [countrySearch]);
|
|
|
|
const validateForm = (): boolean => {
|
|
const errors: Record<string, string> = {};
|
|
|
|
if (!username.trim()) {
|
|
errors.username = "Username é obrigatório";
|
|
} else if (username.length < 3 || username.length > 50) {
|
|
errors.username = "Username deve ter entre 3 e 50 caracteres";
|
|
}
|
|
|
|
if (!email.trim()) {
|
|
errors.email = "Email é obrigatório";
|
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
errors.email = "Email inválido";
|
|
}
|
|
|
|
const passwordValidation = validatePasswordRequirements(password);
|
|
if (!password) {
|
|
errors.password = "Senha é obrigatória";
|
|
} else if (!passwordValidation.isValid) {
|
|
errors.password = passwordValidation.errors[0];
|
|
}
|
|
|
|
if (password !== confirmPassword) {
|
|
errors.confirmPassword = t("errors.passwordMismatch");
|
|
}
|
|
|
|
if (!firstName.trim()) {
|
|
errors.firstName = "Nome é obrigatório";
|
|
}
|
|
|
|
if (!lastName.trim()) {
|
|
errors.lastName = "Sobrenome é obrigatório";
|
|
}
|
|
|
|
if (selectedServers.length === 0) {
|
|
errors.servers = t("errors.noServers");
|
|
}
|
|
|
|
selectedServers.forEach((server, index) => {
|
|
if (!server.characterName.trim()) {
|
|
errors[`server_${index}`] = t("errors.characterNameRequired");
|
|
}
|
|
});
|
|
|
|
setFieldErrors(errors);
|
|
return Object.keys(errors).length === 0;
|
|
};
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setFieldErrors({});
|
|
|
|
if (!validateForm()) {
|
|
return;
|
|
}
|
|
|
|
setIsLoading(true);
|
|
|
|
try {
|
|
const response = await fetch("/api/auth/register", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
},
|
|
credentials: "include",
|
|
body: JSON.stringify({
|
|
username: username.trim().toLowerCase(),
|
|
email: email.trim().toLowerCase(),
|
|
password,
|
|
firstName: firstName.trim(),
|
|
lastName: lastName.trim(),
|
|
phone: phone
|
|
? combinePhoneWithDialCode(selectedCountry.dialCode, phone)
|
|
: undefined,
|
|
servers: selectedServers.map((s) => ({
|
|
serverName: s.serverName,
|
|
characterName: s.characterName.trim(),
|
|
isMain: s.isMain,
|
|
})),
|
|
}),
|
|
});
|
|
|
|
// Verificar se a resposta é JSON antes de fazer parse
|
|
const contentType = response.headers.get("content-type");
|
|
if (!contentType || !contentType.includes("application/json")) {
|
|
const text = await response.text();
|
|
console.error(
|
|
"Resposta não é JSON do registro:",
|
|
text.substring(0, 200),
|
|
);
|
|
setError("Erro: resposta inválida do servidor");
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
await refreshSession();
|
|
onOpenChange(false);
|
|
window.location.reload();
|
|
} else {
|
|
if (data.details) {
|
|
// Field-specific errors
|
|
const detailsErrors: Record<string, string> = {};
|
|
data.details.forEach((detail: { field: string; message: string }) => {
|
|
detailsErrors[detail.field] = detail.message;
|
|
});
|
|
setFieldErrors(detailsErrors);
|
|
} else {
|
|
setError(data.error || "Erro ao criar conta");
|
|
}
|
|
}
|
|
} catch (_err) {
|
|
setError("Erro ao conectar com o servidor");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const getPasswordStrengthColor = () => {
|
|
switch (passwordStrength) {
|
|
case "weak":
|
|
return "bg-red-500";
|
|
case "medium":
|
|
return "bg-yellow-500";
|
|
case "strong":
|
|
return "bg-green-500";
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-2xl max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("title")}</DialogTitle>
|
|
<DialogDescription>{t("description")}</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Credentials Section */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold">Credenciais</h3>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="username">{t("username")}</Label>
|
|
<Input
|
|
id="username"
|
|
type="text"
|
|
placeholder={t("usernamePlaceholder")}
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
disabled={isLoading}
|
|
className={fieldErrors.username ? "border-destructive" : ""}
|
|
/>
|
|
{fieldErrors.username && (
|
|
<p className="text-sm text-destructive">
|
|
{fieldErrors.username}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="email">{t("email")}</Label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
placeholder={t("emailPlaceholder")}
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
disabled={isLoading}
|
|
className={fieldErrors.email ? "border-destructive" : ""}
|
|
/>
|
|
{fieldErrors.email && (
|
|
<p className="text-sm text-destructive">{fieldErrors.email}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="password">{t("password")}</Label>
|
|
<Input
|
|
id="password"
|
|
type="password"
|
|
placeholder={t("passwordPlaceholder")}
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
disabled={isLoading}
|
|
className={fieldErrors.password ? "border-destructive" : ""}
|
|
/>
|
|
{password && (
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
|
<div
|
|
className={`h-full ${getPasswordStrengthColor()} transition-all`}
|
|
style={{
|
|
width:
|
|
passwordStrength === "weak"
|
|
? "33%"
|
|
: passwordStrength === "medium"
|
|
? "66%"
|
|
: "100%",
|
|
}}
|
|
/>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
{t(`passwordStrength.${passwordStrength}`)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{fieldErrors.password && (
|
|
<p className="text-sm text-destructive">
|
|
{fieldErrors.password}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="confirmPassword">{t("confirmPassword")}</Label>
|
|
<Input
|
|
id="confirmPassword"
|
|
type="password"
|
|
placeholder={t("confirmPasswordPlaceholder")}
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
disabled={isLoading}
|
|
className={
|
|
fieldErrors.confirmPassword ? "border-destructive" : ""
|
|
}
|
|
/>
|
|
{fieldErrors.confirmPassword && (
|
|
<p className="text-sm text-destructive">
|
|
{fieldErrors.confirmPassword}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Personal Info Section */}
|
|
<div className="space-y-4">
|
|
<h3 className="text-sm font-semibold">Dados Pessoais</h3>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="firstName">{t("firstName")}</Label>
|
|
<Input
|
|
id="firstName"
|
|
type="text"
|
|
placeholder={t("firstNamePlaceholder")}
|
|
value={firstName}
|
|
onChange={(e) => setFirstName(e.target.value)}
|
|
disabled={isLoading}
|
|
className={fieldErrors.firstName ? "border-destructive" : ""}
|
|
/>
|
|
{fieldErrors.firstName && (
|
|
<p className="text-sm text-destructive">
|
|
{fieldErrors.firstName}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="lastName">{t("lastName")}</Label>
|
|
<Input
|
|
id="lastName"
|
|
type="text"
|
|
placeholder={t("lastNamePlaceholder")}
|
|
value={lastName}
|
|
onChange={(e) => setLastName(e.target.value)}
|
|
disabled={isLoading}
|
|
className={fieldErrors.lastName ? "border-destructive" : ""}
|
|
/>
|
|
{fieldErrors.lastName && (
|
|
<p className="text-sm text-destructive">
|
|
{fieldErrors.lastName}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="phone">{t("phone")}</Label>
|
|
<div className="flex gap-2">
|
|
{/* Seletor de País */}
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="w-[140px] justify-between shrink-0"
|
|
disabled={isLoading}
|
|
>
|
|
<span className="flex items-center gap-2">
|
|
<span>{selectedCountry.flag}</span>
|
|
<span className="text-sm">
|
|
{selectedCountry.dialCode}
|
|
</span>
|
|
</span>
|
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent
|
|
align="start"
|
|
className="w-[300px] max-h-[300px] overflow-y-auto"
|
|
>
|
|
<div className="p-2">
|
|
<Input
|
|
type="text"
|
|
placeholder={t("searchCountry") || "Buscar país..."}
|
|
value={countrySearch}
|
|
onChange={(e) => setCountrySearch(e.target.value)}
|
|
className="mb-2"
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</div>
|
|
{filteredCountries.map((country) => (
|
|
<DropdownMenuItem
|
|
key={country.code}
|
|
onClick={() => {
|
|
setSelectedCountry(country);
|
|
setCountrySearch("");
|
|
// Reformatar telefone com novo país
|
|
if (phone) {
|
|
const unformatted = unformatPhone(phone);
|
|
const reformatted = formatInternationalPhone(
|
|
unformatted,
|
|
country.dialCode,
|
|
country.code,
|
|
);
|
|
setPhone(reformatted);
|
|
}
|
|
}}
|
|
className="cursor-pointer"
|
|
>
|
|
<span className="flex items-center gap-2 w-full">
|
|
<span>{country.flag}</span>
|
|
<span className="flex-1">{country.name}</span>
|
|
<span className="text-muted-foreground text-sm">
|
|
{country.dialCode}
|
|
</span>
|
|
</span>
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
|
|
{/* Campo de Telefone */}
|
|
<Input
|
|
id="phone"
|
|
type="text"
|
|
placeholder={getPhonePlaceholder(selectedCountry.code)}
|
|
value={phone}
|
|
onChange={(e) => handlePhoneChange(e.target.value)}
|
|
disabled={isLoading}
|
|
maxLength={20}
|
|
className="flex-1"
|
|
/>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">{t("phoneHint")}</p>
|
|
{fieldErrors.phone && (
|
|
<p className="text-sm text-destructive">{fieldErrors.phone}</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Servers Section */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h3 className="text-sm font-semibold">{t("servers")}</h3>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("serversDescription")}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Server Search/Add */}
|
|
<div className="relative">
|
|
<div className="flex gap-2">
|
|
<div className="flex-1 relative">
|
|
<Input
|
|
type="text"
|
|
placeholder={t("selectServer")}
|
|
value={serverSearch}
|
|
onChange={(e) => {
|
|
setServerSearch(e.target.value);
|
|
setShowServerDropdown(true);
|
|
}}
|
|
onFocus={() => setShowServerDropdown(true)}
|
|
disabled={isLoading || serversLoading}
|
|
/>
|
|
{showServerDropdown && filteredServers.length > 0 && (
|
|
<div className="absolute z-10 w-full mt-1 bg-popover border rounded-md shadow-lg max-h-60 overflow-y-auto">
|
|
{filteredServers.slice(0, 10).map((server) => (
|
|
<button
|
|
key={server.serverId}
|
|
type="button"
|
|
className="w-full text-left px-3 py-2 hover:bg-accent rounded-sm text-sm"
|
|
onClick={() => addServer(server.serverName)}
|
|
>
|
|
{server.serverName}
|
|
{server.serverType && (
|
|
<span className="text-xs text-muted-foreground ml-2">
|
|
({server.serverType})
|
|
</span>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Selected Servers */}
|
|
{selectedServers.length > 0 && (
|
|
<div className="space-y-3">
|
|
{selectedServers.map((server, index) => (
|
|
<div
|
|
key={index}
|
|
className="p-3 border rounded-md space-y-2 bg-muted/30"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="secondary">{server.serverName}</Badge>
|
|
{server.isMain && (
|
|
<Badge variant="default" className="text-xs">
|
|
{t("mainCharacter")}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{!server.isMain && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setMainServer(index)}
|
|
disabled={isLoading}
|
|
>
|
|
{t("mainCharacter")}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeServer(index)}
|
|
disabled={isLoading}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor={`character-${index}`}>
|
|
{t("characterName")}
|
|
</Label>
|
|
<Input
|
|
id={`character-${index}`}
|
|
type="text"
|
|
placeholder={t("characterNamePlaceholder")}
|
|
value={server.characterName}
|
|
onChange={(e) =>
|
|
updateServerCharacter(index, e.target.value)
|
|
}
|
|
disabled={isLoading}
|
|
className={
|
|
fieldErrors[`server_${index}`]
|
|
? "border-destructive"
|
|
: ""
|
|
}
|
|
/>
|
|
{fieldErrors[`server_${index}`] && (
|
|
<p className="text-sm text-destructive">
|
|
{fieldErrors[`server_${index}`]}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{fieldErrors.servers && (
|
|
<p className="text-sm text-destructive">{fieldErrors.servers}</p>
|
|
)}
|
|
{serversError && (
|
|
<p className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
|
Erro ao carregar servidores: {serversError}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="text-sm text-destructive bg-destructive/10 p-3 rounded-md">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
<Button
|
|
type="submit"
|
|
className="w-full"
|
|
disabled={isLoading || serversLoading}
|
|
>
|
|
{isLoading ? t("submitting") : t("submit")}
|
|
</Button>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|