feat: enhance reverse share upload functionality with improved localization and error handling

- Updated localization files to include new strings for the reverse share upload process, enhancing user experience in multiple languages.
- Implemented a new layout for reverse share uploads, integrating file upload sections and status messages for better feedback.
- Added error handling for various upload scenarios, including password protection, link expiration, and file validation.
- Refactored components to utilize hooks for managing upload state and responses, improving code organization and maintainability.
- Introduced a new password modal for handling protected links, enhancing security during file uploads.
- Enhanced the user interface with dynamic status messages and visual feedback during the upload process.
This commit is contained in:
Daniel Luiz Alves
2025-06-06 13:43:16 -03:00
parent b549aef45f
commit 6a08874267
14 changed files with 1136 additions and 402 deletions

View File

@@ -1183,7 +1183,7 @@
}, },
"allowedFileTypes": { "allowedFileTypes": {
"label": "Tipos de Arquivo Permitidos", "label": "Tipos de Arquivo Permitidos",
"placeholder": "Ex: .pdf,.jpg,.png,.docx", "placeholder": "Ex: pdf, jpg, png, docx",
"description": "Opcional. Especifique as extensões permitidas separadas por vírgula." "description": "Opcional. Especifique as extensões permitidas separadas por vírgula."
}, },
"pageLayout": { "pageLayout": {
@@ -1227,6 +1227,89 @@
"confirmButton": "Excluir Link", "confirmButton": "Excluir Link",
"cancelButton": "Cancelar", "cancelButton": "Cancelar",
"deleting": "Excluindo..." "deleting": "Excluindo..."
},
"upload": {
"metadata": {
"title": "Enviar Arquivos - Palmr",
"description": "Envie arquivos através do link compartilhado"
},
"layout": {
"defaultTitle": "Enviar Arquivos",
"importantInfo": "Informações importantes:",
"maxFiles": "Máximo de {count} arquivo(s)",
"maxFileSize": "Tamanho máximo por arquivo: {size}MB",
"allowedTypes": "Tipos permitidos: {types}",
"loading": "Carregando..."
},
"password": {
"title": "Link Protegido",
"description": "Este link está protegido por senha. Digite a senha para continuar.",
"label": "Senha",
"placeholder": "Digite a senha",
"cancel": "Cancelar",
"submit": "Continuar",
"verifying": "Verificando..."
},
"errors": {
"loadFailed": "Falha ao carregar informações. Tente novamente.",
"passwordIncorrect": "Senha incorreta. Tente novamente.",
"linkNotFound": "Link não encontrado ou expirado.",
"linkInactive": "Este link está inativo.",
"linkExpired": "Este link expirou.",
"uploadFailed": "Erro ao enviar arquivo",
"fileTooLarge": "Arquivo muito grande. Tamanho máximo: {maxSize}",
"fileTypeNotAllowed": "Tipo de arquivo não permitido. Tipos aceitos: {allowedTypes}",
"maxFilesExceeded": "Máximo de {maxFiles} arquivos permitidos",
"selectAtLeastOneFile": "Selecione pelo menos um arquivo",
"provideNameOrEmail": "Informe seu nome ou e-mail"
},
"fileDropzone": {
"dragActive": "Solte os arquivos aqui",
"dragInactive": "Arraste arquivos aqui ou clique para selecionar",
"acceptedTypes": "Tipos aceitos: {types}",
"maxFileSize": "Tamanho máximo: {size}",
"maxFiles": "Máximo de {count} arquivos",
"remainingFiles": "Restam {remaining} de {max} arquivos"
},
"fileList": {
"title": "Arquivos selecionados:",
"statusUploaded": "Enviado",
"statusError": "Erro"
},
"form": {
"nameLabel": "Nome",
"namePlaceholder": "Seu nome",
"emailLabel": "E-mail",
"emailPlaceholder": "seu@email.com",
"descriptionLabel": "Descrição (opcional)",
"descriptionPlaceholder": "Adicione uma descrição aos arquivos...",
"uploadButton": "Enviar {count} arquivo(s)",
"uploading": "Enviando..."
},
"success": {
"title": "Arquivos enviados com sucesso! 🎉",
"description": "Você pode fechar esta página.",
"countMessage": "{count} arquivo(s) enviado(s) com sucesso!"
},
"maxFilesReached": {
"title": "Limite de arquivos atingido",
"description": "Este link já recebeu o número máximo de {maxFiles} arquivo(s) permitido(s).",
"contactOwner": "Se houve algum erro ou você precisa enviar mais arquivos, entre em contato com o proprietário do link."
},
"linkInactive": {
"title": "Link inativo",
"description": "Este link de recebimento está temporariamente inativo.",
"contactOwner": "Entre em contato com o proprietário do link para mais informações."
},
"linkNotFound": {
"title": "Link não encontrado",
"description": "Este link pode ter sido removido ou nunca existiu."
},
"linkExpired": {
"title": "Link expirado",
"description": "Este link de recebimento expirou e não está mais aceitando arquivos.",
"contactOwner": "Entre em contato com o proprietário do link se precisar enviar arquivos."
}
} }
} }
} }

View File

@@ -1,24 +1,122 @@
"use client"; "use client";
import Link from "next/link"; import Link from "next/link";
import { IconAlertTriangle, IconCheck, IconClock, IconInfoCircle } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { LanguageSwitcher } from "@/components/general/language-switcher"; import { LanguageSwitcher } from "@/components/general/language-switcher";
import { ModeToggle } from "@/components/general/mode-toggle"; import { ModeToggle } from "@/components/general/mode-toggle";
import { DefaultFooter } from "@/components/ui/default-footer"; import { DefaultFooter } from "@/components/ui/default-footer";
import { useAppInfo } from "@/contexts/app-info-context"; import { useAppInfo } from "@/contexts/app-info-context";
import type { GetReverseShareForUploadResult } from "@/http/endpoints/reverse-shares/types"; import type { DefaultLayoutProps } from "../types";
import { FileUploadSection } from "./file-upload-section"; import { FileUploadSection } from "./file-upload-section";
import { StatusMessage } from "./shared/status-message";
type ReverseShareInfo = GetReverseShareForUploadResult["data"]["reverseShare"]; export function DefaultLayout({
reverseShare,
interface DefaultLayoutProps { password,
reverseShare: ReverseShareInfo; alias,
password: string; isMaxFilesReached,
alias: string; hasUploadedSuccessfully,
} onUploadSuccess,
isLinkInactive,
export function DefaultLayout({ reverseShare, password, alias }: DefaultLayoutProps) { isLinkNotFound,
isLinkExpired,
}: DefaultLayoutProps) {
const { appName, appLogo } = useAppInfo(); const { appName, appLogo } = useAppInfo();
const t = useTranslations();
const getUploadStatus = () => {
if (hasUploadedSuccessfully) {
return {
component: (
<StatusMessage
icon={IconCheck}
title={t("reverseShares.upload.success.title")}
description={t("reverseShares.upload.success.description")}
variant="success"
/>
),
};
}
if (isLinkInactive) {
return {
component: (
<StatusMessage
icon={IconAlertTriangle}
title={t("reverseShares.upload.linkInactive.title")}
description={t("reverseShares.upload.linkInactive.description")}
additionalText={t("reverseShares.upload.linkInactive.contactOwner")}
variant="error"
/>
),
};
}
if (isLinkNotFound || !reverseShare) {
return {
component: (
<StatusMessage
icon={IconAlertTriangle}
title={t("reverseShares.upload.linkNotFound.title")}
description={t("reverseShares.upload.linkNotFound.description")}
variant="neutral"
/>
),
};
}
if (isLinkExpired) {
return {
component: (
<StatusMessage
icon={IconClock}
title={t("reverseShares.upload.linkExpired.title")}
description={t("reverseShares.upload.linkExpired.description")}
additionalText={t("reverseShares.upload.linkExpired.contactOwner")}
variant="info"
/>
),
};
}
if (isMaxFilesReached) {
return {
component: (
<StatusMessage
icon={IconInfoCircle}
title={t("reverseShares.upload.maxFilesReached.title")}
description={t("reverseShares.upload.maxFilesReached.description", {
maxFiles: reverseShare?.maxFiles || 0,
})}
additionalText={t("reverseShares.upload.maxFilesReached.contactOwner")}
variant="warning"
/>
),
};
}
return {
component: (
<FileUploadSection
reverseShare={reverseShare}
password={password}
alias={alias}
onUploadSuccess={onUploadSuccess}
/>
),
};
};
const showUploadLimits =
!hasUploadedSuccessfully &&
!isMaxFilesReached &&
!isLinkInactive &&
!isLinkNotFound &&
!isLinkExpired &&
reverseShare &&
(reverseShare.maxFiles || reverseShare.maxFileSize || reverseShare.allowedFileTypes);
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
@@ -42,9 +140,9 @@ export function DefaultLayout({ reverseShare, password, alias }: DefaultLayoutPr
{/* Header da página */} {/* Header da página */}
<div className="text-center space-y-4"> <div className="text-center space-y-4">
<h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-foreground"> <h1 className="text-3xl md:text-4xl lg:text-5xl font-bold text-foreground">
{reverseShare.name || "Enviar Arquivos"} {reverseShare?.name || t("reverseShares.upload.layout.defaultTitle")}
</h1> </h1>
{reverseShare.description && ( {reverseShare?.description && (
<p className="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed"> <p className="text-lg md:text-xl text-muted-foreground max-w-2xl mx-auto leading-relaxed">
{reverseShare.description} {reverseShare.description}
</p> </p>
@@ -53,17 +151,23 @@ export function DefaultLayout({ reverseShare, password, alias }: DefaultLayoutPr
{/* Seção de upload */} {/* Seção de upload */}
<div className="bg-card rounded-xl shadow-sm border border-border p-6 md:p-8 lg:p-10"> <div className="bg-card rounded-xl shadow-sm border border-border p-6 md:p-8 lg:p-10">
<FileUploadSection reverseShare={reverseShare} password={password} alias={alias} /> {getUploadStatus().component}
</div> </div>
{/* Informações adicionais (se houver limites) */} {/* Informações adicionais */}
{(reverseShare.maxFiles || reverseShare.maxFileSize || reverseShare.allowedFileTypes) && ( {showUploadLimits && (
<div className="bg-muted/30 rounded-lg p-4 space-y-2"> <div className="bg-muted/30 rounded-lg p-4 space-y-2">
<h3 className="text-sm font-medium text-foreground">Informações importantes:</h3> <h3 className="text-sm font-medium text-foreground">{t("reverseShares.upload.layout.importantInfo")}</h3>
<div className="text-xs text-muted-foreground space-y-1"> <div className="text-xs text-muted-foreground space-y-1">
{reverseShare.maxFiles && <p> Máximo de {reverseShare.maxFiles} arquivo(s)</p>} {reverseShare?.maxFiles && (
{reverseShare.maxFileSize && <p> Tamanho máximo por arquivo: {reverseShare.maxFileSize}MB</p>} <p> {t("reverseShares.upload.layout.maxFiles", { count: reverseShare.maxFiles })}</p>
{reverseShare.allowedFileTypes && <p> Tipos permitidos: {reverseShare.allowedFileTypes}</p>} )}
{reverseShare?.maxFileSize && (
<p> {t("reverseShares.upload.layout.maxFileSize", { size: reverseShare.maxFileSize })}</p>
)}
{reverseShare?.allowedFileTypes && (
<p> {t("allowedTypes", { types: reverseShare.allowedFileTypes })}</p>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -1,7 +1,8 @@
"use client"; "use client";
import { useCallback, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { IconCheck, IconFile, IconMail, IconUpload, IconUser, IconX } from "@tabler/icons-react"; import { IconCheck, IconFile, IconMail, IconUpload, IconUser, IconX } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { useDropzone } from "react-dropzone"; import { useDropzone } from "react-dropzone";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -12,79 +13,86 @@ import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { getPresignedUrlForUploadByAlias, registerFileUploadByAlias } from "@/http/endpoints"; import { getPresignedUrlForUploadByAlias, registerFileUploadByAlias } from "@/http/endpoints";
import type { GetReverseShareForUploadResult } from "@/http/endpoints/reverse-shares/types";
import { formatFileSize } from "@/utils/format-file-size"; import { formatFileSize } from "@/utils/format-file-size";
import { FILE_STATUS, UPLOAD_CONFIG, UPLOAD_PROGRESS } from "../constants";
import { FileUploadSectionProps, FileWithProgress } from "../types";
type ReverseShareInfo = GetReverseShareForUploadResult["data"]["reverseShare"]; export function FileUploadSection({ reverseShare, password, alias, onUploadSuccess }: FileUploadSectionProps) {
interface FileUploadSectionProps {
reverseShare: ReverseShareInfo;
password: string;
alias: string;
}
interface FileWithProgress {
file: File;
progress: number;
status: "pending" | "uploading" | "success" | "error";
error?: string;
}
export function FileUploadSection({ reverseShare, password, alias }: FileUploadSectionProps) {
const [files, setFiles] = useState<FileWithProgress[]>([]); const [files, setFiles] = useState<FileWithProgress[]>([]);
const [uploaderName, setUploaderName] = useState(""); const [uploaderName, setUploaderName] = useState("");
const [uploaderEmail, setUploaderEmail] = useState(""); const [uploaderEmail, setUploaderEmail] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [isUploading, setIsUploading] = useState(false); const [isUploading, setIsUploading] = useState(false);
const validateFile = (file: File): string | null => { const t = useTranslations();
// Check file size
if (reverseShare.maxFileSize) {
const maxSize = reverseShare.maxFileSize;
if (file.size > maxSize) {
return `Arquivo muito grande. Tamanho máximo: ${formatFileSize(maxSize)}`;
}
}
// Check file type const validateFileSize = (file: File): string | null => {
if (reverseShare.allowedFileTypes) { if (!reverseShare.maxFileSize) return null;
const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
const fileExtension = file.name.split(".").pop()?.toLowerCase();
if (fileExtension && !allowedTypes.includes(fileExtension)) {
return `Tipo de arquivo não permitido. Tipos aceitos: ${reverseShare.allowedFileTypes}`;
}
}
// Check file count if (file.size > reverseShare.maxFileSize) {
if (reverseShare.maxFiles) { return t("reverseShares.upload.errors.fileTooLarge", {
const totalFiles = files.length + 1 + reverseShare.currentFileCount; maxSize: formatFileSize(reverseShare.maxFileSize),
if (totalFiles > reverseShare.maxFiles) { });
return `Máximo de ${reverseShare.maxFiles} arquivos permitidos`;
}
} }
return null; return null;
}; };
const validateFileType = (file: File): string | null => {
if (!reverseShare.allowedFileTypes) return null;
const allowedTypes = reverseShare.allowedFileTypes.split(",").map((type) => type.trim().toLowerCase());
const fileExtension = file.name.split(".").pop()?.toLowerCase();
if (fileExtension && !allowedTypes.includes(fileExtension)) {
return t("reverseShares.upload.errors.fileTypeNotAllowed", {
allowedTypes: reverseShare.allowedFileTypes,
});
}
return null;
};
const validateFileCount = (): string | null => {
if (!reverseShare.maxFiles) return null;
const totalFiles = files.length + 1 + reverseShare.currentFileCount;
if (totalFiles > reverseShare.maxFiles) {
return t("reverseShares.upload.errors.maxFilesExceeded", {
maxFiles: reverseShare.maxFiles,
});
}
return null;
};
const validateFile = (file: File): string | null => {
return validateFileSize(file) || validateFileType(file) || validateFileCount();
};
const createFileWithProgress = (file: File): FileWithProgress => ({
file,
progress: UPLOAD_PROGRESS.INITIAL,
status: FILE_STATUS.PENDING,
});
const processAcceptedFiles = (acceptedFiles: File[]): FileWithProgress[] => {
const validFiles: FileWithProgress[] = [];
for (const file of acceptedFiles) {
const validationError = validateFile(file);
if (validationError) {
toast.error(validationError);
continue;
}
validFiles.push(createFileWithProgress(file));
}
return validFiles;
};
const onDrop = useCallback( const onDrop = useCallback(
(acceptedFiles: File[]) => { (acceptedFiles: File[]) => {
const newFiles: FileWithProgress[] = []; const newFiles = processAcceptedFiles(acceptedFiles);
setFiles((previousFiles) => [...previousFiles, ...newFiles]);
for (const file of acceptedFiles) {
const error = validateFile(file);
if (error) {
toast.error(error);
continue;
}
newFiles.push({
file,
progress: 0,
status: "pending",
});
}
setFiles((prev) => [...prev, ...newFiles]);
}, },
[files, reverseShare] [files, reverseShare]
); );
@@ -96,98 +104,132 @@ export function FileUploadSection({ reverseShare, password, alias }: FileUploadS
}); });
const removeFile = (index: number) => { const removeFile = (index: number) => {
setFiles((prev) => prev.filter((_, i) => i !== index)); setFiles((previousFiles) => previousFiles.filter((_, i) => i !== index));
};
const updateFileStatus = (index: number, updates: Partial<FileWithProgress>) => {
setFiles((previousFiles) => previousFiles.map((file, i) => (i === index ? { ...file, ...updates } : file)));
};
const generateObjectName = (fileName: string): string => {
const timestamp = Date.now();
return `reverse-shares/${alias}/${timestamp}-${fileName}`;
};
const getFileExtension = (fileName: string): string => {
return fileName.split(".").pop() || "";
};
const uploadFileToStorage = async (file: File, presignedUrl: string): Promise<void> => {
const response = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!response.ok) {
throw new Error("Failed to upload file to storage");
}
};
const registerUploadedFile = async (file: File, objectName: string): Promise<void> => {
const fileExtension = getFileExtension(file.name);
await registerFileUploadByAlias(
alias,
{
name: file.name,
description: description || undefined,
extension: fileExtension,
size: file.size,
objectName,
uploaderEmail: uploaderEmail || undefined,
uploaderName: uploaderName || undefined,
},
password ? { password } : undefined
);
}; };
const uploadFile = async (fileWithProgress: FileWithProgress, index: number): Promise<void> => { const uploadFile = async (fileWithProgress: FileWithProgress, index: number): Promise<void> => {
const { file } = fileWithProgress; const { file } = fileWithProgress;
try { try {
// Update status to uploading // Start upload
setFiles((prev) => prev.map((f, i) => (i === index ? { ...f, status: "uploading" as const, progress: 0 } : f))); updateFileStatus(index, {
status: FILE_STATUS.UPLOADING,
progress: UPLOAD_PROGRESS.INITIAL,
});
// Generate object name for the file // Generate object name and get presigned URL
const timestamp = Date.now(); const objectName = generateObjectName(file.name);
const fileExtension = file.name.split(".").pop() || "";
const objectName = `reverse-shares/${alias}/${timestamp}-${file.name}`;
// Get presigned URL
const presignedResponse = await getPresignedUrlForUploadByAlias( const presignedResponse = await getPresignedUrlForUploadByAlias(
alias, alias,
{ objectName }, { objectName },
password ? { password } : undefined password ? { password } : undefined
); );
const { url } = presignedResponse.data; // Upload to storage
await uploadFileToStorage(file, presignedResponse.data.url);
// Upload file to presigned URL // Update progress
const uploadResponse = await fetch(url, { updateFileStatus(index, { progress: UPLOAD_PROGRESS.COMPLETE });
method: "PUT",
body: file,
headers: {
"Content-Type": file.type,
},
});
if (!uploadResponse.ok) {
throw new Error("Failed to upload file to storage");
}
// Update progress to 100%
setFiles((prev) => prev.map((f, i) => (i === index ? { ...f, progress: 100 } : f)));
// Register file upload // Register file upload
await registerFileUploadByAlias( await registerUploadedFile(file, objectName);
alias,
{
name: file.name,
description: description || undefined,
extension: fileExtension,
size: file.size,
objectName,
uploaderEmail: uploaderEmail || undefined,
uploaderName: uploaderName || undefined,
},
password ? { password } : undefined
);
// Update status to success // Mark as successful
setFiles((prev) => prev.map((f, i) => (i === index ? { ...f, status: "success" as const } : f))); updateFileStatus(index, { status: FILE_STATUS.SUCCESS });
} catch (error: any) { } catch (error: any) {
console.error("Upload error:", error); console.error("Upload error:", error);
const errorMessage = error.response?.data?.error || "Erro ao enviar arquivo"; const errorMessage = error.response?.data?.error || t("reverseShares.upload.errors.uploadFailed");
setFiles((prev) => updateFileStatus(index, {
prev.map((f, i) => (i === index ? { ...f, status: "error" as const, error: errorMessage } : f)) status: FILE_STATUS.ERROR,
); error: errorMessage,
});
toast.error(errorMessage); toast.error(errorMessage);
} }
}; };
const handleUpload = async () => { const validateUploadRequirements = (): boolean => {
if (files.length === 0) { if (files.length === 0) {
toast.error("Selecione pelo menos um arquivo"); toast.error(t("reverseShares.upload.errors.selectAtLeastOneFile"));
return; return false;
} }
if (!uploaderName && !uploaderEmail) { if (!uploaderName.trim() && !uploaderEmail.trim()) {
toast.error("Informe seu nome ou e-mail"); toast.error(t("reverseShares.upload.errors.provideNameOrEmail"));
return; return false;
} }
return true;
};
const processAllUploads = async (): Promise<void> => {
const uploadPromises = files.map((fileWithProgress, index) => uploadFile(fileWithProgress, index));
await Promise.all(uploadPromises);
const successfulUploads = files.filter((file) => file.status === FILE_STATUS.SUCCESS);
if (successfulUploads.length > 0) {
toast.success(
t("reverseShares.upload.success.countMessage", {
count: successfulUploads.length,
})
);
}
};
const handleUpload = async () => {
if (!validateUploadRequirements()) return;
setIsUploading(true); setIsUploading(true);
try { try {
// Upload all files await processAllUploads();
const uploadPromises = files.map((fileWithProgress, index) => uploadFile(fileWithProgress, index));
await Promise.all(uploadPromises);
const successCount = files.filter((f) => f.status === "success").length;
if (successCount > 0) {
toast.success(`${successCount} arquivo(s) enviado(s) com sucesso!`);
}
} catch (error) { } catch (error) {
console.error("Upload error:", error); console.error("Upload error:", error);
} finally { } finally {
@@ -196,83 +238,130 @@ export function FileUploadSection({ reverseShare, password, alias }: FileUploadS
}; };
const canUpload = files.length > 0 && (uploaderName.trim() || uploaderEmail.trim()) && !isUploading; const canUpload = files.length > 0 && (uploaderName.trim() || uploaderEmail.trim()) && !isUploading;
const allFilesProcessed = files.every((f) => f.status === "success" || f.status === "error"); const allFilesProcessed = files.every(
(file) => file.status === FILE_STATUS.SUCCESS || file.status === FILE_STATUS.ERROR
);
const hasSuccessfulUploads = files.some((file) => file.status === FILE_STATUS.SUCCESS);
// Call onUploadSuccess when all files are processed and there are successful uploads
useEffect(() => {
if (allFilesProcessed && hasSuccessfulUploads && files.length > 0) {
onUploadSuccess?.();
}
}, [allFilesProcessed, hasSuccessfulUploads, files.length, onUploadSuccess]);
const getDragActiveStyles = () => {
if (isDragActive) {
return "border-green-500 bg-blue-50 dark:bg-green-950/20";
}
return "border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500";
};
const getDropzoneStyles = () => {
const baseStyles = "border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors";
const dragStyles = getDragActiveStyles();
const disabledStyles = isUploading ? "opacity-50 cursor-not-allowed" : "";
return `${baseStyles} ${dragStyles} ${disabledStyles}`.trim();
};
const renderFileRestrictions = () => {
// Calculate remaining files that can be uploaded
const calculateRemainingFiles = (): number => {
if (!reverseShare.maxFiles) return 0;
const currentTotal = reverseShare.currentFileCount + files.length;
const remaining = reverseShare.maxFiles - currentTotal;
return Math.max(0, remaining);
};
const remainingFiles = calculateRemainingFiles();
return (
<p className="text-sm text-gray-500 dark:text-gray-400">
{reverseShare.allowedFileTypes && (
<>
{t("reverseShares.upload.fileDropzone.acceptedTypes", { types: reverseShare.allowedFileTypes })}
<br />
</>
)}
{reverseShare.maxFileSize && (
<>
{t("reverseShares.upload.fileDropzone.maxFileSize", { size: formatFileSize(reverseShare.maxFileSize) })}
<br />
</>
)}
{reverseShare.maxFiles && (
<>
{t("reverseShares.upload.fileDropzone.remainingFiles", {
remaining: remainingFiles,
max: reverseShare.maxFiles,
})}
</>
)}
</p>
);
};
const renderFileStatusBadge = (fileWithProgress: FileWithProgress) => {
if (fileWithProgress.status === FILE_STATUS.SUCCESS) {
return (
<Badge variant="default" className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<IconCheck className="h-3 w-3 mr-1" />
{t("reverseShares.upload.fileList.statusUploaded")}
</Badge>
);
}
if (fileWithProgress.status === FILE_STATUS.ERROR) {
return <Badge variant="destructive">{t("reverseShares.upload.fileList.statusError")}</Badge>;
}
return null;
};
const renderFileItem = (fileWithProgress: FileWithProgress, index: number) => (
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<IconFile className="h-5 w-5 text-gray-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">{fileWithProgress.file.name}</p>
<p className="text-xs text-gray-500">{formatFileSize(fileWithProgress.file.size)}</p>
{fileWithProgress.status === FILE_STATUS.UPLOADING && (
<Progress value={fileWithProgress.progress} className="mt-2 h-2" />
)}
{fileWithProgress.status === FILE_STATUS.ERROR && (
<p className="text-xs text-red-500 mt-1">{fileWithProgress.error}</p>
)}
</div>
<div className="flex items-center gap-2">
{renderFileStatusBadge(fileWithProgress)}
{fileWithProgress.status === FILE_STATUS.PENDING && (
<Button size="sm" variant="ghost" onClick={() => removeFile(index)} disabled={isUploading}>
<IconX className="h-4 w-4" />
</Button>
)}
</div>
</div>
);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* File Drop Zone */} {/* File Drop Zone */}
<div <div {...getRootProps()} className={getDropzoneStyles()}>
{...getRootProps()}
className={`
border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
${
isDragActive
? "border-green-500 bg-blue-50 dark:bg-green-950/20"
: "border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500"
}
${isUploading ? "opacity-50 cursor-not-allowed" : ""}
`}
>
<input {...getInputProps()} /> <input {...getInputProps()} />
<IconUpload className="mx-auto h-12 w-12 text-gray-400 mb-4" /> <IconUpload className="mx-auto h-12 w-12 text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2"> <h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
{isDragActive ? "Solte os arquivos aqui" : "Arraste arquivos aqui ou clique para selecionar"} {isDragActive
? t("reverseShares.upload.fileDropzone.dragActive")
: t("reverseShares.upload.fileDropzone.dragInactive")}
</h3> </h3>
<p className="text-sm text-gray-500 dark:text-gray-400"> {renderFileRestrictions()}
{reverseShare.allowedFileTypes && (
<>
Tipos aceitos: {reverseShare.allowedFileTypes}
<br />
</>
)}
{reverseShare.maxFileSize && (
<>
Tamanho máximo: {formatFileSize(reverseShare.maxFileSize)}
<br />
</>
)}
{reverseShare.maxFiles && <>Máximo de {reverseShare.maxFiles} arquivos</>}
</p>
</div> </div>
{/* File List */} {/* File List */}
{files.length > 0 && ( {files.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<h4 className="font-medium text-gray-900 dark:text-white">Arquivos selecionados:</h4> <h4 className="font-medium text-gray-900 dark:text-white">{t("reverseShares.upload.fileList.title")}</h4>
{files.map((fileWithProgress, index) => ( {files.map(renderFileItem)}
<div key={index} className="flex items-center gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<IconFile className="h-5 w-5 text-gray-500 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{fileWithProgress.file.name}
</p>
<p className="text-xs text-gray-500">{formatFileSize(fileWithProgress.file.size)}</p>
{fileWithProgress.status === "uploading" && (
<Progress value={fileWithProgress.progress} className="mt-2 h-2" />
)}
{fileWithProgress.status === "error" && (
<p className="text-xs text-red-500 mt-1">{fileWithProgress.error}</p>
)}
</div>
<div className="flex items-center gap-2">
{fileWithProgress.status === "success" && (
<Badge
variant="default"
className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200"
>
<IconCheck className="h-3 w-3 mr-1" />
Enviado
</Badge>
)}
{fileWithProgress.status === "error" && <Badge variant="destructive">Erro</Badge>}
{fileWithProgress.status === "pending" && (
<Button size="sm" variant="ghost" onClick={() => removeFile(index)} disabled={isUploading}>
<IconX className="h-4 w-4" />
</Button>
)}
</div>
</div>
))}
</div> </div>
)} )}
@@ -282,11 +371,11 @@ export function FileUploadSection({ reverseShare, password, alias }: FileUploadS
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name"> <Label htmlFor="name">
<IconUser className="inline h-4 w-4" /> <IconUser className="inline h-4 w-4" />
Nome {t("reverseShares.upload.form.nameLabel")}
</Label> </Label>
<Input <Input
id="name" id="name"
placeholder="Seu nome" placeholder={t("reverseShares.upload.form.namePlaceholder")}
value={uploaderName} value={uploaderName}
onChange={(e) => setUploaderName(e.target.value)} onChange={(e) => setUploaderName(e.target.value)}
disabled={isUploading} disabled={isUploading}
@@ -295,12 +384,12 @@ export function FileUploadSection({ reverseShare, password, alias }: FileUploadS
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="email"> <Label htmlFor="email">
<IconMail className="inline h-4 w-4" /> <IconMail className="inline h-4 w-4" />
E-mail {t("reverseShares.upload.form.emailLabel")}
</Label> </Label>
<Input <Input
id="email" id="email"
type="email" type="email"
placeholder="seu@email.com" placeholder={t("reverseShares.upload.form.emailPlaceholder")}
value={uploaderEmail} value={uploaderEmail}
onChange={(e) => setUploaderEmail(e.target.value)} onChange={(e) => setUploaderEmail(e.target.value)}
disabled={isUploading} disabled={isUploading}
@@ -308,29 +397,31 @@ export function FileUploadSection({ reverseShare, password, alias }: FileUploadS
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Descrição (opcional)</Label> <Label htmlFor="description">{t("reverseShares.upload.form.descriptionLabel")}</Label>
<Textarea <Textarea
id="description" id="description"
placeholder="Adicione uma descrição aos arquivos..." placeholder={t("reverseShares.upload.form.descriptionPlaceholder")}
value={description} value={description}
onChange={(e) => setDescription(e.target.value)} onChange={(e) => setDescription(e.target.value)}
disabled={isUploading} disabled={isUploading}
rows={3} rows={UPLOAD_CONFIG.TEXTAREA_ROWS}
/> />
</div> </div>
</div> </div>
{/* Upload Button */} {/* Upload Button */}
<Button onClick={handleUpload} disabled={!canUpload} className="w-full text-white" size="lg" variant="default"> <Button onClick={handleUpload} disabled={!canUpload} className="w-full text-white" size="lg" variant="default">
{isUploading ? "Enviando..." : `Enviar ${files.length} arquivo(s)`} {isUploading
? t("reverseShares.upload.form.uploading")
: t("reverseShares.upload.form.uploadButton", { count: files.length })}
</Button> </Button>
{/* Success Message */} {/* Success Message */}
{allFilesProcessed && files.some((f) => f.status === "success") && ( {allFilesProcessed && hasSuccessfulUploads && (
<div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg"> <div className="text-center p-4 bg-green-50 dark:bg-green-950/20 rounded-lg">
<p className="text-green-800 dark:text-green-200 font-medium">Arquivos enviados com sucesso! 🎉</p> <p className="text-green-800 dark:text-green-200 font-medium">{t("reverseShares.upload.success.title")}</p>
<p className="text-sm text-green-600 dark:text-green-300 mt-1"> <p className="text-sm text-green-600 dark:text-green-300 mt-1">
Você pode fechar esta página ou enviar mais arquivos. {t("reverseShares.upload.success.description")}
</p> </p>
</div> </div>
)} )}

View File

@@ -0,0 +1,6 @@
export { DefaultLayout } from "./default-layout";
export { WeTransferLayout } from "./we-transfer-layout";
export { PasswordModal } from "./password-modal";
export { FileUploadSection } from "./file-upload-section";
export { StatusMessage, WeTransferStatusMessage } from "./shared/status-message";
export { TransparentFooter } from "./transparent-footer";

View File

@@ -2,21 +2,18 @@
import { useState } from "react"; import { useState } from "react";
import { IconLock } from "@tabler/icons-react"; import { IconLock } from "@tabler/icons-react";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { PasswordModalProps } from "../types";
interface PasswordModalProps {
isOpen: boolean;
onSubmit: (password: string) => void;
onClose: () => void;
}
export function PasswordModal({ isOpen, onSubmit, onClose }: PasswordModalProps) { export function PasswordModal({ isOpen, onSubmit, onClose }: PasswordModalProps) {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const t = useTranslations();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -43,17 +40,17 @@ export function PasswordModal({ isOpen, onSubmit, onClose }: PasswordModalProps)
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900/20"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100 dark:bg-yellow-900/20">
<IconLock className="h-6 w-6 text-yellow-600 dark:text-yellow-400" /> <IconLock className="h-6 w-6 text-yellow-600 dark:text-yellow-400" />
</div> </div>
<DialogTitle>Link Protegido</DialogTitle> <DialogTitle>{t("reverseShares.upload.password.title")}</DialogTitle>
<DialogDescription>Este link está protegido por senha. Digite a senha para continuar.</DialogDescription> <DialogDescription>{t("reverseShares.upload.password.description")}</DialogDescription>
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="password">Senha</Label> <Label htmlFor="password">{t("reverseShares.upload.password.label")}</Label>
<Input <Input
id="password" id="password"
type="password" type="password"
placeholder="Digite a senha" placeholder={t("reverseShares.upload.password.placeholder")}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
@@ -64,10 +61,10 @@ export function PasswordModal({ isOpen, onSubmit, onClose }: PasswordModalProps)
<div className="flex gap-2"> <div className="flex gap-2">
<Button type="button" variant="outline" className="flex-1" onClick={onClose} disabled={isSubmitting}> <Button type="button" variant="outline" className="flex-1" onClick={onClose} disabled={isSubmitting}>
Cancelar {t("reverseShares.upload.password.cancel")}
</Button> </Button>
<Button type="submit" className="flex-1" disabled={!password.trim() || isSubmitting}> <Button type="submit" className="flex-1" disabled={!password.trim() || isSubmitting}>
{isSubmitting ? "Verificando..." : "Continuar"} {isSubmitting ? t("reverseShares.upload.password.verifying") : t("reverseShares.upload.password.submit")}
</Button> </Button>
</div> </div>
</form> </form>

View File

@@ -0,0 +1,101 @@
"use client";
import { useTranslations } from "next-intl";
import { MESSAGE_TYPES, STATUS_VARIANTS } from "../../constants";
import type { ReverseShareInfo } from "../../types";
interface StatusMessageProps {
variant: "success" | "warning" | "error" | "info" | "neutral";
icon: React.ComponentType<{ className?: string }>;
title: string;
description: string;
additionalText?: string;
size?: "default" | "compact";
}
interface WeTransferStatusMessageProps {
type: keyof typeof MESSAGE_TYPES;
icon: React.ComponentType<{ className?: string }>;
titleKey: string;
descriptionKey: string;
showContactOwner?: boolean;
reverseShare?: ReverseShareInfo | null;
}
export function StatusMessage({
icon: Icon,
title,
description,
additionalText,
variant,
size = "default",
}: StatusMessageProps) {
const styles = STATUS_VARIANTS[variant];
const isCompact = size === "compact";
return (
<div className={`text-center space-y-4 ${isCompact ? "py-6" : "py-8"}`}>
<div className="flex justify-center">
<div className={`${styles.iconBg} p-3 rounded-full`}>
<Icon className={`${isCompact ? "h-6 w-6" : "h-8 w-8"} ${styles.iconColor}`} />
</div>
</div>
<div className="space-y-2">
<h3 className={`${isCompact ? "text-lg" : "text-xl"} font-semibold ${styles.titleColor}`}>{title}</h3>
<p
className={`${styles.descriptionColor} ${isCompact ? "text-sm" : ""} ${isCompact ? "" : "max-w-md mx-auto"}`}
>
{description}
</p>
{additionalText && (
<p className={`${isCompact ? "text-xs" : "text-sm"} text-muted-foreground`}>{additionalText}</p>
)}
</div>
</div>
);
}
export function WeTransferStatusMessage({
type,
icon: Icon,
titleKey,
descriptionKey,
showContactOwner = false,
reverseShare,
}: WeTransferStatusMessageProps) {
const t = useTranslations();
// Map message types to variants
const getVariant = (): "success" | "warning" | "error" | "info" | "neutral" => {
switch (type) {
case MESSAGE_TYPES.SUCCESS:
return "success";
case MESSAGE_TYPES.MAX_FILES:
return "warning";
case MESSAGE_TYPES.INACTIVE:
return "error";
case MESSAGE_TYPES.EXPIRED:
return "info";
case MESSAGE_TYPES.NOT_FOUND:
default:
return "neutral";
}
};
const description =
type === MESSAGE_TYPES.MAX_FILES ? t(descriptionKey, { maxFiles: reverseShare?.maxFiles || 0 }) : t(descriptionKey);
const additionalText = showContactOwner ? t("reverseShares.upload.maxFilesReached.contactOwner") : undefined;
return (
<StatusMessage
variant={getVariant()}
icon={Icon}
title={t(titleKey)}
description={description}
additionalText={additionalText}
size="compact"
/>
);
}

View File

@@ -0,0 +1,27 @@
import Link from "next/link";
import { useTranslations } from "next-intl";
import { version } from "../../../../../../package.json";
export function TransparentFooter() {
const t = useTranslations();
return (
<footer className="absolute bottom-0 left-0 right-0 z-50 w-full flex items-center justify-center py-3 h-16 pointer-events-none">
<div className="flex flex-col items-center pointer-events-auto">
<Link
target="_blank"
className="flex items-center gap-1 text-white/80 hover:text-primary transition-colors"
href="https://kyantech.com.br"
title={t("footer.kyanHomepage")}
>
<span className="text-white/70 text-xs sm:text-sm">{t("footer.poweredBy")}</span>
<p className="text-white text-xs sm:text-sm font-medium cursor-pointer hover:text-primary">
Kyantech Solutions
</p>
</Link>
<span className="text-white text-[11px] mt-1">v{version}</span>
</div>
</footer>
);
}

View File

@@ -1,153 +1,194 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link"; import { IconAlertTriangle, IconCheck, IconClock, IconInfoCircle } from "@tabler/icons-react";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { LanguageSwitcher } from "@/components/general/language-switcher"; import { LanguageSwitcher } from "@/components/general/language-switcher";
import { ModeToggle } from "@/components/general/mode-toggle"; import { ModeToggle } from "@/components/general/mode-toggle";
import type { GetReverseShareForUploadResult } from "@/http/endpoints/reverse-shares/types"; import { BACKGROUND_IMAGES, MESSAGE_TYPES } from "../constants";
import { version } from "../../../../../../package.json"; import { WeTransferLayoutProps } from "../types";
import { FileUploadSection } from "./file-upload-section"; import { FileUploadSection } from "./file-upload-section";
import { WeTransferStatusMessage } from "./shared/status-message";
type ReverseShareInfo = GetReverseShareForUploadResult["data"]["reverseShare"]; import { TransparentFooter } from "./transparent-footer";
interface WeTransferLayoutProps {
reverseShare: ReverseShareInfo;
password: string;
alias: string;
}
// Lista de imagens de background
const backgroundImages = [
"/assets/wetransfer-bgs/1.jpg",
"/assets/wetransfer-bgs/2.jpg",
"/assets/wetransfer-bgs/3.jpg",
"/assets/wetransfer-bgs/4.jpg",
"/assets/wetransfer-bgs/5.jpg",
"/assets/wetransfer-bgs/6.jpg",
"/assets/wetransfer-bgs/7.jpg",
"/assets/wetransfer-bgs/8.jpg",
];
// Função para escolher uma imagem aleatória // Função para escolher uma imagem aleatória
function getRandomImage(images: string[]): string { const getRandomBackgroundImage = (): string => {
const randomIndex = Math.floor(Math.random() * images.length); const randomIndex = Math.floor(Math.random() * BACKGROUND_IMAGES.length);
const selectedImage = images[randomIndex]; return BACKGROUND_IMAGES[randomIndex];
};
return selectedImage; // Hook para gerenciar a imagem de background
} const useBackgroundImage = () => {
// Footer transparente para o layout WeTransfer
function TransparentFooter() {
const t = useTranslations();
return (
<footer className="absolute bottom-0 left-0 right-0 z-50 w-full flex items-center justify-center py-3 h-16 pointer-events-none">
<div className="flex flex-col items-center pointer-events-auto">
<Link
target="_blank"
className="flex items-center gap-1 text-white/80 hover:text-primary transition-colors"
href="https://kyantech.com.br"
title={t("footer.kyanHomepage")}
>
<span className="text-white/70 text-xs sm:text-sm">{t("footer.poweredBy")}</span>
<p className="text-white text-xs sm:text-sm font-medium cursor-pointer hover:text-primary">
Kyantech Solutions
</p>
</Link>
<span className="text-white text-[11px] mt-1">v{version}</span>
</div>
</footer>
);
}
export function WeTransferLayout({ reverseShare, password, alias }: WeTransferLayoutProps) {
const [selectedImage, setSelectedImage] = useState<string>(""); const [selectedImage, setSelectedImage] = useState<string>("");
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
// Escolher uma imagem aleatória no início
useEffect(() => { useEffect(() => {
const randomImage = getRandomImage(backgroundImages); setSelectedImage(getRandomBackgroundImage());
setSelectedImage(randomImage);
}, []); }, []);
// Precarregar a imagem selecionada
useEffect(() => { useEffect(() => {
if (!selectedImage) return; if (!selectedImage) return;
const preloadImage = () => { const img = new Image();
const img = new Image(); img.onload = () => setImageLoaded(true);
img.onload = () => setImageLoaded(true); img.onerror = () => {
img.onerror = () => { console.error("Error loading background image:", selectedImage);
console.error("Erro ao carregar imagem de background:", selectedImage); setImageLoaded(true);
setImageLoaded(true);
};
img.src = selectedImage;
}; };
img.src = selectedImage;
preloadImage();
}, [selectedImage]); }, [selectedImage]);
return { selectedImage, imageLoaded };
};
// Componente para controles do header
const HeaderControls = () => (
<div className="absolute top-4 right-4 md:top-6 md:right-6 z-40 flex items-center gap-2">
<div className="bg-white/10 dark:bg-black/20 backdrop-blur-xs border border-white/20 dark:border-white/10 rounded-lg p-1">
<LanguageSwitcher />
</div>
<div className="bg-white/10 dark:bg-black/20 backdrop-blur-xs border border-white/20 dark:border-white/10 rounded-lg p-1">
<ModeToggle />
</div>
</div>
);
// Componente para o fundo com imagem
const BackgroundLayer = ({ selectedImage, imageLoaded }: { selectedImage: string; imageLoaded: boolean }) => (
<>
<div className="absolute inset-0 z-0 bg-background" />
{imageLoaded && selectedImage && (
<div
className="absolute inset-0 z-10"
style={{
backgroundImage: `url(${selectedImage})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
/>
)}
<div className="absolute inset-0 bg-black/40 z-20" />
</>
);
export function WeTransferLayout({
reverseShare,
password,
alias,
isMaxFilesReached,
hasUploadedSuccessfully,
onUploadSuccess,
isLinkInactive,
isLinkNotFound,
isLinkExpired,
}: WeTransferLayoutProps) {
const { selectedImage, imageLoaded } = useBackgroundImage();
const t = useTranslations();
const getUploadSectionContent = () => {
if (hasUploadedSuccessfully) {
return (
<WeTransferStatusMessage
type={MESSAGE_TYPES.SUCCESS}
icon={IconCheck}
titleKey="reverseShares.upload.success.title"
descriptionKey="reverseShares.upload.success.description"
/>
);
}
if (isLinkInactive) {
return (
<WeTransferStatusMessage
type={MESSAGE_TYPES.INACTIVE}
icon={IconAlertTriangle}
titleKey="reverseShares.upload.linkInactive.title"
descriptionKey="reverseShares.upload.linkInactive.description"
showContactOwner
/>
);
}
if (isLinkNotFound || !reverseShare) {
return (
<WeTransferStatusMessage
type={MESSAGE_TYPES.NOT_FOUND}
icon={IconAlertTriangle}
titleKey="reverseShares.upload.linkNotFound.title"
descriptionKey="reverseShares.upload.linkNotFound.description"
/>
);
}
if (isLinkExpired) {
return (
<WeTransferStatusMessage
type={MESSAGE_TYPES.EXPIRED}
icon={IconClock}
titleKey="reverseShares.upload.linkExpired.title"
descriptionKey="reverseShares.upload.linkExpired.description"
showContactOwner
/>
);
}
if (isMaxFilesReached) {
return (
<WeTransferStatusMessage
type={MESSAGE_TYPES.MAX_FILES}
icon={IconInfoCircle}
titleKey="reverseShares.upload.maxFilesReached.title"
descriptionKey="reverseShares.upload.maxFilesReached.description"
showContactOwner
reverseShare={reverseShare}
/>
);
}
return (
<FileUploadSection
reverseShare={reverseShare}
password={password}
alias={alias}
onUploadSuccess={onUploadSuccess}
/>
);
};
return ( return (
<div className="min-h-screen relative overflow-hidden"> <div className="min-h-screen relative overflow-hidden">
{/* Fallback gradient background */} <BackgroundLayer selectedImage={selectedImage} imageLoaded={imageLoaded} />
<div className="absolute inset-0 z-0 bg-background" /> <HeaderControls />
{/* Background Image - imagem única aleatória */} {/* Loading indicator */}
{imageLoaded && selectedImage && (
<div
className="absolute inset-0 z-10"
style={{
backgroundImage: `url(${selectedImage})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundRepeat: "no-repeat",
}}
/>
)}
{/* Dark overlay para melhor legibilidade */}
<div className="absolute inset-0 bg-black/40 z-20" />
{/* Controles - Topo Direito */}
<div className="absolute top-4 right-4 md:top-6 md:right-6 z-40 flex items-center gap-2">
<div className="bg-white/10 dark:bg-black/20 backdrop-blur-xs border border-white/20 dark:border-white/10 rounded-lg p-1">
<LanguageSwitcher />
</div>
<div className="bg-white/10 dark:bg-black/20 backdrop-blur-xs border border-white/20 dark:border-white/10 rounded-lg p-1">
<ModeToggle />
</div>
</div>
{/* Loading indicator para a imagem */}
{!imageLoaded && ( {!imageLoaded && (
<div className="absolute inset-0 z-30 flex items-center justify-center"> <div className="absolute inset-0 z-30 flex items-center justify-center">
<div className="animate-pulse text-white/70 text-sm">Carregando...</div> <div className="animate-pulse text-white/70 text-sm">{t("reverseShares.upload.layout.loading")}</div>
</div> </div>
)} )}
{/* Content - Alinhado à esquerda */} {/* Main Content */}
<div className="relative z-30 min-h-screen flex items-center justify-start p-4 md:p-8 lg:p-12 xl:p-16"> <div className="relative z-30 min-h-screen flex items-center justify-start p-4 md:p-8 lg:p-12 xl:p-16">
<div className="w-full max-w-md lg:max-w-lg xl:max-w-xl"> <div className="w-full max-w-md lg:max-w-lg xl:max-w-xl">
<div className="bg-white dark:bg-black rounded-2xl shadow-2xl p-6 md:p-8 backdrop-blur-sm border border-white/20"> <div className="bg-white dark:bg-black rounded-2xl shadow-2xl p-6 md:p-8 backdrop-blur-sm border border-white/20">
{/* Header */} {/* Header */}
<div className="text-left mb-6 md:mb-8"> <div className="text-left mb-6 md:mb-8">
<h1 className="text-xl md:text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h1 className="text-xl md:text-2xl lg:text-3xl font-bold text-gray-900 dark:text-white mb-2">
{reverseShare.name || "Enviar Arquivos"} {reverseShare?.name || t("reverseShares.upload.layout.defaultTitle")}
</h1> </h1>
{reverseShare.description && ( {reverseShare?.description && (
<p className="text-gray-600 dark:text-gray-300 text-sm md:text-base">{reverseShare.description}</p> <p className="text-gray-600 dark:text-gray-300 text-sm md:text-base">{reverseShare.description}</p>
)} )}
</div> </div>
{/* Upload Section */} {/* Upload Section */}
<FileUploadSection reverseShare={reverseShare} password={password} alias={alias} /> {getUploadSectionContent()}
</div> </div>
</div> </div>
</div> </div>
{/* Footer transparente */}
<TransparentFooter /> <TransparentFooter />
</div> </div>
); );

View File

@@ -0,0 +1,84 @@
// HTTP Status Constants
export const HTTP_STATUS = {
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
GONE: 410,
} as const;
// Error Messages
export const ERROR_MESSAGES = {
PASSWORD_REQUIRED: "Password required",
INVALID_PASSWORD: "Invalid password",
} as const;
// Error types
export type ErrorType = "inactive" | "notFound" | "expired" | "generic" | null;
export const STATUS_VARIANTS = {
success: {
iconBg: "bg-green-100 dark:bg-green-900/20",
iconColor: "text-green-600 dark:text-green-400",
titleColor: "text-green-800 dark:text-green-200",
descriptionColor: "text-green-600 dark:text-green-300",
},
warning: {
iconBg: "bg-amber-100 dark:bg-amber-900/20",
iconColor: "text-amber-600 dark:text-amber-400",
titleColor: "text-foreground",
descriptionColor: "text-muted-foreground",
},
error: {
iconBg: "bg-red-100 dark:bg-red-900/20",
iconColor: "text-red-600 dark:text-red-400",
titleColor: "text-foreground",
descriptionColor: "text-muted-foreground",
},
info: {
iconBg: "bg-orange-100 dark:bg-orange-900/20",
iconColor: "text-orange-600 dark:text-orange-400",
titleColor: "text-foreground",
descriptionColor: "text-muted-foreground",
},
neutral: {
iconBg: "bg-gray-100 dark:bg-gray-900/20",
iconColor: "text-gray-600 dark:text-gray-400",
titleColor: "text-foreground",
descriptionColor: "text-muted-foreground",
},
};
export const UPLOAD_PROGRESS = {
INITIAL: 0,
COMPLETE: 100,
} as const;
export const FILE_STATUS = {
PENDING: "pending",
UPLOADING: "uploading",
SUCCESS: "success",
ERROR: "error",
} as const;
export const UPLOAD_CONFIG = {
TEXTAREA_ROWS: 3,
} as const;
export const MESSAGE_TYPES = {
SUCCESS: "SUCCESS",
MAX_FILES: "MAX_FILES",
INACTIVE: "INACTIVE",
NOT_FOUND: "NOT_FOUND",
EXPIRED: "EXPIRED",
} as const;
export const BACKGROUND_IMAGES = [
"/assets/wetransfer-bgs/1.jpg",
"/assets/wetransfer-bgs/2.jpg",
"/assets/wetransfer-bgs/3.jpg",
"/assets/wetransfer-bgs/4.jpg",
"/assets/wetransfer-bgs/5.jpg",
"/assets/wetransfer-bgs/6.jpg",
"/assets/wetransfer-bgs/7.jpg",
"/assets/wetransfer-bgs/8.jpg",
] as const;

View File

@@ -0,0 +1,150 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { toast } from "sonner";
import { getReverseShareForUploadByAlias } from "@/http/endpoints";
import { ERROR_MESSAGES, HTTP_STATUS, type ErrorType } from "../constants";
import type { ReverseShareInfo } from "../types";
interface UseReverseShareUploadProps {
alias: string;
}
export function useReverseShareUpload({ alias }: UseReverseShareUploadProps) {
const router = useRouter();
const t = useTranslations();
// States
const [reverseShare, setReverseShare] = useState<ReverseShareInfo | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false);
const [currentPassword, setCurrentPassword] = useState("");
const [hasUploadedSuccessfully, setHasUploadedSuccessfully] = useState(false);
const [error, setError] = useState<{ type: ErrorType }>({ type: null });
// Utility functions
const redirectToHome = () => router.push("/");
const checkIfMaxFilesReached = (reverseShareData: ReverseShareInfo): boolean => {
if (!reverseShareData.maxFiles) return false;
return reverseShareData.currentFileCount >= reverseShareData.maxFiles;
};
const handleErrorResponse = (responseError: any) => {
const status = responseError.response?.status;
const errorMessage = responseError.response?.data?.error;
switch (status) {
case HTTP_STATUS.UNAUTHORIZED:
if (errorMessage === ERROR_MESSAGES.PASSWORD_REQUIRED) {
setIsPasswordModalOpen(true);
} else if (errorMessage === ERROR_MESSAGES.INVALID_PASSWORD) {
setIsPasswordModalOpen(true);
toast.error(t("reverseShares.upload.errors.passwordIncorrect"));
}
break;
case HTTP_STATUS.NOT_FOUND:
setError({ type: "notFound" });
break;
case HTTP_STATUS.FORBIDDEN:
setError({ type: "inactive" });
break;
case HTTP_STATUS.GONE:
setError({ type: "expired" });
break;
default:
setError({ type: "generic" });
toast.error(t("reverseShares.upload.errors.loadFailed"));
break;
}
};
const loadReverseShare = async (passwordAttempt?: string) => {
try {
setIsLoading(true);
setError({ type: null });
const response = await getReverseShareForUploadByAlias(
alias,
passwordAttempt ? { password: passwordAttempt } : undefined
);
setReverseShare(response.data.reverseShare);
setIsPasswordModalOpen(false);
setCurrentPassword(passwordAttempt || "");
} catch (responseError: any) {
console.error("Failed to load reverse share:", responseError);
handleErrorResponse(responseError);
} finally {
setIsLoading(false);
}
};
const handlePasswordSubmit = (passwordValue: string) => {
loadReverseShare(passwordValue);
};
const handlePasswordModalClose = () => {
redirectToHome();
};
const handleUploadSuccess = () => {
setHasUploadedSuccessfully(true);
};
const resetUploadSuccess = () => {
setHasUploadedSuccessfully(false);
};
useEffect(() => {
if (alias) {
loadReverseShare();
}
}, [alias]);
// Computed values
const isMaxFilesReached = reverseShare ? checkIfMaxFilesReached(reverseShare) : false;
const isWeTransferLayout = reverseShare?.pageLayout === "WETRANSFER";
const hasError = error.type !== null || (!reverseShare && !isLoading && !isPasswordModalOpen);
// Error state booleans for backward compatibility
const isLinkInactive = error.type === "inactive";
const isLinkNotFound = error.type === "notFound" || (!reverseShare && !isLoading && !isPasswordModalOpen);
const isLinkExpired = error.type === "expired";
return {
// Data
reverseShare,
currentPassword,
alias,
// States
isLoading,
isPasswordModalOpen,
hasUploadedSuccessfully,
error: error.type,
isMaxFilesReached,
isWeTransferLayout,
hasError,
// Error states (for backward compatibility)
isLinkInactive,
isLinkNotFound,
isLinkExpired,
// Actions
handlePasswordSubmit,
handlePasswordModalClose,
handleUploadSuccess,
resetUploadSuccess,
loadReverseShare,
redirectToHome,
};
}

View File

@@ -1,9 +1,14 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { getTranslations } from "next-intl/server";
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
title: "Enviar Arquivos - Palmr", const t = await getTranslations();
description: "Envie arquivos através do link compartilhado",
}; return {
title: t("reverseShares.upload.metadata.title"),
description: t("reverseShares.upload.metadata.description"),
};
}
export default function ReverseShareLayout({ children }: { children: React.ReactNode }) { export default function ReverseShareLayout({ children }: { children: React.ReactNode }) {
return children; return children;

View File

@@ -1,93 +1,89 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { toast } from "sonner";
import { LoadingScreen } from "@/components/layout/loading-screen"; import { LoadingScreen } from "@/components/layout/loading-screen";
import { getReverseShareForUploadByAlias } from "@/http/endpoints"; import { DefaultLayout, PasswordModal, WeTransferLayout } from "./components";
import type { GetReverseShareForUploadResult } from "@/http/endpoints/reverse-shares/types"; import { useReverseShareUpload } from "./hooks/use-reverse-share-upload";
import { DefaultLayout } from "./components/default-layout";
import { PasswordModal } from "./components/password-modal";
import { WeTransferLayout } from "./components/we-transfer-layout";
type ReverseShareInfo = GetReverseShareForUploadResult["data"]["reverseShare"];
export default function ReverseShareUploadPage() { export default function ReverseShareUploadPage() {
const params = useParams(); const params = useParams();
const router = useRouter(); const shareAlias = params?.alias as string;
const alias = params?.alias as string;
const [reverseShare, setReverseShare] = useState<ReverseShareInfo | null>(null); const {
const [isLoading, setIsLoading] = useState(true); reverseShare,
const [showPasswordModal, setShowPasswordModal] = useState(false); currentPassword,
const [password, setPassword] = useState(""); isLoading,
isPasswordModalOpen,
const loadReverseShare = async (passwordAttempt?: string) => { hasUploadedSuccessfully,
try { isMaxFilesReached,
setIsLoading(true); isWeTransferLayout,
const response = await getReverseShareForUploadByAlias( hasError,
alias, isLinkInactive,
passwordAttempt ? { password: passwordAttempt } : undefined isLinkNotFound,
); isLinkExpired,
setReverseShare(response.data.reverseShare); handlePasswordSubmit,
setShowPasswordModal(false); handlePasswordModalClose,
setPassword(passwordAttempt || ""); handleUploadSuccess,
} catch (error: any) { } = useReverseShareUpload({ alias: shareAlias });
console.error("Failed to load reverse share:", error);
if (error.response?.status === 401 && error.response?.data?.error === "Password required") {
setShowPasswordModal(true);
} else if (error.response?.status === 401 && error.response?.data?.error === "Invalid password") {
setShowPasswordModal(true);
toast.error("Senha incorreta. Tente novamente.");
} else if (error.response?.status === 404) {
toast.error("Link não encontrado ou expirado.");
router.push("/");
} else if (error.response?.status === 403) {
toast.error("Este link está inativo.");
router.push("/");
} else if (error.response?.status === 410) {
toast.error("Este link expirou.");
router.push("/");
} else {
toast.error("Erro ao carregar informações. Tente novamente.");
router.push("/");
}
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (alias) {
loadReverseShare();
}
}, [alias]);
const handlePasswordSubmit = (passwordValue: string) => {
loadReverseShare(passwordValue);
};
// Loading state
if (isLoading) { if (isLoading) {
return <LoadingScreen />; return <LoadingScreen />;
} }
if (showPasswordModal) { // Password required state
if (isPasswordModalOpen) {
return ( return (
<PasswordModal isOpen={showPasswordModal} onSubmit={handlePasswordSubmit} onClose={() => router.push("/")} /> <PasswordModal isOpen={isPasswordModalOpen} onSubmit={handlePasswordSubmit} onClose={handlePasswordModalClose} />
); );
} }
if (!reverseShare) { // Error states or missing data - always use DefaultLayout for simplicity
return <LoadingScreen />; if (hasError) {
return (
<DefaultLayout
reverseShare={reverseShare}
password={currentPassword}
alias={shareAlias}
isMaxFilesReached={false}
hasUploadedSuccessfully={false}
onUploadSuccess={handleUploadSuccess}
isLinkInactive={isLinkInactive}
isLinkNotFound={isLinkNotFound}
isLinkExpired={isLinkExpired}
/>
);
} }
// Escolher o layout baseado no pageLayout // Render appropriate layout for normal states
if (reverseShare.pageLayout === "WETRANSFER") { if (isWeTransferLayout) {
return <WeTransferLayout reverseShare={reverseShare} password={password} alias={alias} />; return (
<WeTransferLayout
reverseShare={reverseShare}
password={currentPassword}
alias={shareAlias}
isMaxFilesReached={isMaxFilesReached}
hasUploadedSuccessfully={hasUploadedSuccessfully}
onUploadSuccess={handleUploadSuccess}
isLinkInactive={false}
isLinkNotFound={false}
isLinkExpired={false}
/>
);
} }
// Layout padrão return (
return <DefaultLayout reverseShare={reverseShare} password={password} alias={alias} />; <DefaultLayout
reverseShare={reverseShare}
password={currentPassword}
alias={shareAlias}
isMaxFilesReached={isMaxFilesReached}
hasUploadedSuccessfully={hasUploadedSuccessfully}
onUploadSuccess={handleUploadSuccess}
isLinkInactive={false}
isLinkNotFound={false}
isLinkExpired={false}
/>
);
} }

View File

@@ -0,0 +1,49 @@
import { GetReverseShareForUploadResult } from "@/http/endpoints/reverse-shares/types";
import { FILE_STATUS } from "../constants";
export type ReverseShareInfo = GetReverseShareForUploadResult["data"]["reverseShare"];
export type FileStatus = (typeof FILE_STATUS)[keyof typeof FILE_STATUS];
export interface DefaultLayoutProps {
reverseShare: ReverseShareInfo | null;
password: string;
alias: string;
isMaxFilesReached: boolean;
hasUploadedSuccessfully: boolean;
onUploadSuccess: () => void;
isLinkInactive: boolean;
isLinkNotFound: boolean;
isLinkExpired: boolean;
}
export interface FileUploadSectionProps {
reverseShare: ReverseShareInfo;
password: string;
alias: string;
onUploadSuccess?: () => void;
}
export interface FileWithProgress {
file: File;
progress: number;
status: FileStatus;
error?: string;
}
export interface PasswordModalProps {
isOpen: boolean;
onSubmit: (password: string) => void;
onClose: () => void;
}
export interface WeTransferLayoutProps {
reverseShare: ReverseShareInfo | null;
password: string;
alias: string;
isMaxFilesReached: boolean;
hasUploadedSuccessfully: boolean;
onUploadSuccess: () => void;
isLinkInactive: boolean;
isLinkNotFound: boolean;
isLinkExpired: boolean;
}