diff --git a/next.config.ts b/next.config.ts
index 145c047..09c5cde 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -10,7 +10,11 @@ const nextConfig: NextConfig = {
reactCompiler: true,
images: {
- remotePatterns: [new URL("https://lh3.googleusercontent.com/**")],
+ remotePatterns: [
+ new URL("https://lh3.googleusercontent.com/**"),
+ { protocol: "https", hostname: "**" },
+ { protocol: "http", hostname: "**" },
+ ],
},
devIndicators: {
position: "bottom-right",
diff --git a/src/features/payers/components/details/payer-header-card.tsx b/src/features/payers/components/details/payer-header-card.tsx
index 383a9aa..acb9ec5 100644
--- a/src/features/payers/components/details/payer-header-card.tsx
+++ b/src/features/payers/components/details/payer-header-card.tsx
@@ -52,6 +52,7 @@ export function PayerHeaderCard({
const [confirmOpen, setConfirmOpen] = useState(false);
const avatarSrc = getAvatarSrc(payer.avatarUrl);
+ const isDataUrl = avatarSrc.startsWith("data:");
const createdAtLabel = formatDate(payer.createdAt);
const isAdmin = payer.role === PAYER_ROLE_ADMIN;
@@ -109,6 +110,7 @@ export function PayerHeaderCard({
{
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const img = new window.Image();
+ img.onload = () => {
+ let { width, height } = img;
+ if (width > height) {
+ if (width > AVATAR_MAX_SIZE) {
+ height = Math.round((height * AVATAR_MAX_SIZE) / width);
+ width = AVATAR_MAX_SIZE;
+ }
+ } else {
+ if (height > AVATAR_MAX_SIZE) {
+ width = Math.round((width * AVATAR_MAX_SIZE) / height);
+ height = AVATAR_MAX_SIZE;
+ }
+ }
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ reject(new Error("Canvas não disponível"));
+ return;
+ }
+ ctx.drawImage(img, 0, 0, width, height);
+ resolve(canvas.toDataURL("image/jpeg", 0.85));
+ };
+ img.onerror = () => reject(new Error("Falha ao carregar imagem"));
+ img.src = e.target?.result as string;
+ };
+ reader.onerror = () => reject(new Error("Falha ao ler arquivo"));
+ reader.readAsDataURL(file);
+ });
+}
+
type PayerCreatePayload = Parameters[0];
interface PayerDialogProps {
@@ -77,8 +117,10 @@ export function PayerDialog({
}: PayerDialogProps) {
const [errorMessage, setErrorMessage] = useState(null);
const [isPending, startTransition] = useTransition();
+ const [uploadedAvatar, setUploadedAvatar] = useState(null);
+ const [isProcessingImage, setIsProcessingImage] = useState(false);
+ const fileInputRef = useRef(null);
- // Use controlled state hook for dialog open state
const [dialogOpen, setDialogOpen] = useControlledState(
open,
false,
@@ -90,26 +132,47 @@ export function PayerDialog({
[payer, avatarOptions],
);
- // Use form state hook for form management
const { formState, resetForm, updateField } =
useFormState(initialState);
+ // Avatares da biblioteca excluem data URLs (que ficam no círculo de upload)
const availableAvatars = useMemo(() => {
- const set = new Set([
- ...avatarOptions,
- initialState.avatarUrl,
- DEFAULT_PAYER_AVATAR,
- ]);
+ const set = new Set([...avatarOptions, DEFAULT_PAYER_AVATAR]);
+ if (initialState.avatarUrl && !initialState.avatarUrl.startsWith("data:")) {
+ set.add(initialState.avatarUrl);
+ }
return Array.from(set).sort((a, b) =>
a.localeCompare(b, "pt-BR", { sensitivity: "base" }),
);
}, [avatarOptions, initialState.avatarUrl]);
- // Reset form when dialog opens
+ const handleFileChange = async (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (!file) return;
+ setIsProcessingImage(true);
+ try {
+ const base64 = await resizeImageToBase64(file);
+ setUploadedAvatar(base64);
+ updateField("avatarUrl", base64);
+ } catch {
+ toast.error("Não foi possível processar a imagem.");
+ } finally {
+ setIsProcessingImage(false);
+ if (fileInputRef.current) fileInputRef.current.value = "";
+ }
+ };
+
useEffect(() => {
if (dialogOpen) {
resetForm(initialState);
setErrorMessage(null);
+ setIsProcessingImage(false);
+ // Se o avatar atual for um upload anterior, restaura no círculo
+ setUploadedAvatar(
+ initialState.avatarUrl.startsWith("data:")
+ ? initialState.avatarUrl
+ : null,
+ );
}
}, [dialogOpen, initialState, resetForm]);
@@ -161,6 +224,9 @@ export function PayerDialog({
const submitLabel =
mode === "create" ? "Salvar pagador" : "Atualizar pagador";
+ const isUploadSelected =
+ uploadedAvatar !== null && formState.avatarUrl === uploadedAvatar;
+
return (
diff --git a/src/shared/components/navigation/navbar/navbar-user.tsx b/src/shared/components/navigation/navbar/navbar-user.tsx
index fe1743c..d5523cd 100644
--- a/src/shared/components/navigation/navbar/navbar-user.tsx
+++ b/src/shared/components/navigation/navbar/navbar-user.tsx
@@ -17,11 +17,6 @@ import { version } from "@/package.json";
import { FeedbackDialogBody } from "@/shared/components/navigation/navbar/feedback-dialog";
import { Badge } from "@/shared/components/ui/badge";
import { Dialog, DialogTrigger } from "@/shared/components/ui/dialog";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/shared/components/ui/tooltip";
import {
DropdownMenu,
DropdownMenuContent,
@@ -30,6 +25,11 @@ import {
DropdownMenuTrigger,
} from "@/shared/components/ui/dropdown-menu";
import { Spinner } from "@/shared/components/ui/spinner";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/shared/components/ui/tooltip";
import { authClient } from "@/shared/lib/auth/client";
import { getAvatarSrc } from "@/shared/lib/payers/utils";
import type { UpdateCheckResult } from "@/shared/lib/version/check-update";
@@ -68,6 +68,7 @@ export function NavbarUser({
const avatarSrc = pagadorAvatarUrl
? getAvatarSrc(pagadorAvatarUrl)
: user.image || getAvatarSrc(null);
+ const isDataUrl = avatarSrc.startsWith("data:");
async function handleLogout() {
await authClient.signOut({
@@ -91,6 +92,7 @@ export function NavbarUser({
-
{user.name}
+
+ {user.name}
+