Add login and register dialogs, user dropdown menu with profile link and logout option, and integrate with session management hook. Prevent hydration mismatches by conditionally rendering auth elements after mount. Update package-lock.json with new @esbuild dependencies for build tooling.
636 lines
23 KiB
TypeScript
636 lines
23 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useMemo } from "react";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { useSession } from "@/lib/hooks/use-session";
|
|
import { useServers } from "@/lib/hooks/use-servers";
|
|
import { useTranslations } from "next-intl";
|
|
import { formatPhone, formatInternationalPhone, unformatPhone, combinePhoneWithDialCode, getPhonePlaceholder, checkPasswordStrength, validatePasswordRequirements } from "@/lib/utils/formatters";
|
|
import { COUNTRIES, DEFAULT_COUNTRY, type Country } from "@/lib/utils/countries";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { X, Plus, Check, XCircle, ChevronDown } from "lucide-react";
|
|
|
|
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>
|
|
);
|
|
}
|
|
|