Files
pagodera/components/auth/register-dialog.tsx
T
Evandro Fonseca Junior 55eebde0ba feat: add user authentication UI to header component
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.
2025-11-04 02:33:29 -03:00

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>
);
}