feat: enhance contact form with toast notifications and error handling
This commit is contained in:
@@ -15,5 +15,8 @@
|
|||||||
"dotenv": "^16.5.0",
|
"dotenv": "^16.5.0",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"nodemailer": "^7.0.3"
|
"nodemailer": "^7.0.3"
|
||||||
|
}, "scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,30 +6,26 @@ router.post('/send', async (req, res) => {
|
|||||||
const { name, email, message } = req.body;
|
const { name, email, message } = req.body;
|
||||||
|
|
||||||
if (!name || !email || !message) {
|
if (!name || !email || !message) {
|
||||||
return res.status(400).json({ message: 'All fields are required.' });
|
return res.status(400).json({ errorKey: 'status_error_all_fields' });
|
||||||
}
|
}
|
||||||
if (!/\S+@\S+\.\S+/.test(email)) {
|
if (!/\S+@\S+\.\S+/.test(email)) {
|
||||||
return res.status(400).json({ message: 'Invalid email address.' });
|
return res.status(400).json({ errorKey: 'status_error_invalid_email' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const transporter = nodemailer.createTransport({
|
const transporter = nodemailer.createTransport({
|
||||||
host: process.env.SMTP_HOST,
|
host: process.env.SMTP_HOST,
|
||||||
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
port: parseInt(process.env.SMTP_PORT || '587', 10),
|
||||||
secure: process.env.SMTP_SECURE === 'true', // true for 465, false for other ports
|
secure: process.env.SMTP_SECURE === 'true',
|
||||||
auth: {
|
auth: {
|
||||||
user: process.env.SMTP_USER,
|
user: process.env.SMTP_USER,
|
||||||
pass: process.env.SMTP_PASS,
|
pass: process.env.SMTP_PASS,
|
||||||
},
|
},
|
||||||
// If using self-signed certificates or having issues with TLS:
|
|
||||||
// tls: {
|
|
||||||
// rejectUnauthorized: false // Use with caution, only for development/testing
|
|
||||||
// }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mailOptions = {
|
const mailOptions = {
|
||||||
from: `"${name}" <${process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER}>`, // Use a configured FROM email or fallback
|
from: `"${name}" <${process.env.SMTP_FROM_EMAIL || process.env.SMTP_USER}>`,
|
||||||
replyTo: email,
|
replyTo: email,
|
||||||
to: process.env.YOUR_RECEIVING_EMAIL, // Your email address to receive submissions
|
to: process.env.YOUR_RECEIVING_EMAIL,
|
||||||
subject: `New Portfolio Contact: ${name}`,
|
subject: `New Portfolio Contact: ${name}`,
|
||||||
text: `Name: ${name}\nEmail: ${email}\nMessage: ${message}`,
|
text: `Name: ${name}\nEmail: ${email}\nMessage: ${message}`,
|
||||||
html: `<p><strong>Name:</strong> ${name}</p>
|
html: `<p><strong>Name:</strong> ${name}</p>
|
||||||
@@ -38,15 +34,24 @@ const mailOptions = {
|
|||||||
<p>${message.replace(/\n/g, '<br>')}</p>`,
|
<p>${message.replace(/\n/g, '<br>')}</p>`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await transporter.sendMail(mailOptions);
|
await transporter.sendMail(mailOptions);
|
||||||
res.status(200).json({ message: 'Message sent successfully!' });
|
res.status(200).json({ message: 'Message sent successfully!' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending email:', error);
|
if (error.code) {
|
||||||
// Provide a more generic error message to the client
|
switch (error.code) {
|
||||||
res.status(500).json({ message: 'Failed to send message. Please try again later.' });
|
case 'EAUTH':
|
||||||
|
return res.status(500).json({ errorKey: 'smtp_auth_failed' });
|
||||||
|
case 'ECONNECTION':
|
||||||
|
return res.status(500).json({ errorKey: 'smtp_connection_failed' });
|
||||||
|
case 'EENVELOPE':
|
||||||
|
return res.status(400).json({ errorKey: 'smtp_invalid_recipient' });
|
||||||
|
default:
|
||||||
|
return res.status(500).json({ errorKey: 'smtp_generic_error' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(500).json({ errorKey: 'server_unexpected_error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,57 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# Personal Portfolio & Blog - Frontend
|
||||||
|
|
||||||
## Getting Started
|
This is the frontend for my personal portfolio, built with [Next.js](https://nextjs.org), [Tailwind CSS](https://tailwindcss.com/), and [TypeScript](https://www.typescriptlang.org/). The project is designed to be a modern, responsive, and performant showcase of my skills and projects.
|
||||||
|
|
||||||
First, run the development server:
|
## ✨ Features
|
||||||
|
|
||||||
|
* **Internationalization (i18n)**: Supports both English and Portuguese, with language switching capabilities.
|
||||||
|
* **Dark Mode**: A sleek dark mode that can be toggled by the user, with preferences saved in local storage.
|
||||||
|
* **Component-Based Architecture**: Built with reusable React components for maintainability and scalability.
|
||||||
|
* **Responsive Design**: Fully responsive layout that looks great on all devices, from mobile phones to desktop screens.
|
||||||
|
* **Contact Form**: A functional contact form that communicates with a backend service to send emails.
|
||||||
|
|
||||||
|
## 🛠️ Tech Stack
|
||||||
|
|
||||||
|
* **Framework**: [Next.js 15](https://nextjs.org/)
|
||||||
|
* **Styling**: [Tailwind CSS 4](https://tailwindcss.com/)
|
||||||
|
* **Language**: [TypeScript](https://www.typescriptlang.org/)
|
||||||
|
* **Internationalization**: [next-intl](https://next-intl-docs.vercel.app/)
|
||||||
|
* **Icons**: [Heroicons](https://heroicons.com/) & [React Icons](https://react-icons.github.io/react-icons/)
|
||||||
|
* **Linting**: [ESLint](https://eslint.org/)
|
||||||
|
|
||||||
|
## 🚀 Getting Started
|
||||||
|
|
||||||
|
To get a local copy up and running, follow these simple steps.
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
* Node.js (v18.18 or later)
|
||||||
|
* npm, yarn, or pnpm
|
||||||
|
|
||||||
|
### Installation & Development
|
||||||
|
|
||||||
|
1. **Clone the repository:**
|
||||||
```bash
|
```bash
|
||||||
npm run dev
|
git clone <your-repository-url>
|
||||||
# or
|
cd <your-repository-folder>/frontend
|
||||||
yarn dev
|
|
||||||
# or
|
|
||||||
pnpm dev
|
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
2. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
3. **Run the development server:**
|
||||||
|
The development server uses Turbopack for faster performance.
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
4. **Open your browser:**
|
||||||
|
Navigate to [http://localhost:3000](http://localhost:3000) to see the result.
|
||||||
|
|
||||||
## Learn More
|
### Building for Production
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
To create a production-ready build, run:
|
||||||
|
```bash
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
npm run build
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
|
||||||
|
|
||||||
## Deploy on Vercel
|
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
|
||||||
28
frontend/package-lock.json
generated
28
frontend/package-lock.json
generated
@@ -13,6 +13,7 @@
|
|||||||
"next-intl": "^4.1.0",
|
"next-intl": "^4.1.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-icons": "^5.5.0"
|
"react-icons": "^5.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2443,7 +2444,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/damerau-levenshtein": {
|
"node_modules/damerau-levenshtein": {
|
||||||
@@ -3544,6 +3544,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/goober": {
|
||||||
|
"version": "2.1.16",
|
||||||
|
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.16.tgz",
|
||||||
|
"integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"csstype": "^3.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gopd": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -5203,6 +5212,23 @@
|
|||||||
"react": "^19.1.0"
|
"react": "^19.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hot-toast": {
|
||||||
|
"version": "2.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz",
|
||||||
|
"integrity": "sha512-Tun3BbCxzmXXM7C+NI4qiv6lT0uwGh4oAfeJyNOjYUejTsm35mK9iCaYLGv8cBz9L5YxZLx/2ii7zsIwPtPUdw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.1.3",
|
||||||
|
"goober": "^2.1.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16",
|
||||||
|
"react-dom": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-icons": {
|
"node_modules/react-icons": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"next-intl": "^4.1.0",
|
"next-intl": "^4.1.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
"react-hot-toast": "^2.5.2",
|
||||||
"react-icons": "^5.5.0"
|
"react-icons": "^5.5.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 223 KiB |
@@ -2,6 +2,7 @@ import { Inter } from "next/font/google";
|
|||||||
import Header from "@/app/components/Header"; // Usando o Header que forneci
|
import Header from "@/app/components/Header"; // Usando o Header que forneci
|
||||||
import Footer from "@/app/components/Footer";
|
import Footer from "@/app/components/Footer";
|
||||||
import "@/app/globals.css"; // Importando o CSS global
|
import "@/app/globals.css"; // Importando o CSS global
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
|
||||||
// Importações importantes do next-intl
|
// Importações importantes do next-intl
|
||||||
import {hasLocale, Locale, NextIntlClientProvider} from 'next-intl';
|
import {hasLocale, Locale, NextIntlClientProvider} from 'next-intl';
|
||||||
@@ -45,11 +46,37 @@ export default async function RootLayout({children, params}: Props) {
|
|||||||
<body className={`${inter.className} bg-background text-text-primary`}>
|
<body className={`${inter.className} bg-background text-text-primary`}>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<NextIntlClientProvider locale={locale}>
|
<NextIntlClientProvider locale={locale}>
|
||||||
|
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex flex-col items-center">
|
<main className="flex flex-col items-center">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
<Toaster
|
||||||
|
position="bottom-right"
|
||||||
|
toastOptions={{
|
||||||
|
// General styles for all toasts
|
||||||
|
style: {
|
||||||
|
background: 'var(--color-card)', //
|
||||||
|
color: 'var(--color-text-primary)', //
|
||||||
|
border: '1px solid var(--color-border)', //
|
||||||
|
},
|
||||||
|
// Specific styles for success toasts
|
||||||
|
success: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: 'var(--color-primary)', //
|
||||||
|
secondary: 'var(--color-card)', //
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Specific styles for error toasts
|
||||||
|
error: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#ef4444', // A standard red color for errors
|
||||||
|
secondary: 'var(--color-card)', //
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</NextIntlClientProvider>
|
</NextIntlClientProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import React, { useState, FormEvent } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
|
|
||||||
export default function ContactForm() {
|
|
||||||
const t = useTranslations('ContactForm');
|
|
||||||
const [formData, setFormData] = useState<{ name: string; email: string; message: string }>({ name: '', email: '', message: '' });
|
|
||||||
const [status, setStatus] = useState<string>('');
|
|
||||||
|
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
|
||||||
setFormData({...formData, [e.target.name]: e.target.value });
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setStatus(t('sending'));
|
|
||||||
|
|
||||||
if (!formData.name ||!formData.email ||!formData.message) {
|
|
||||||
setStatus(t('errorAllFields'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
|
||||||
setStatus(t('errorInvalidEmail'));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// The backend will run on a different port or subpath, proxied by Nginx
|
|
||||||
const response = await fetch('/api/email/send', { // Adjusted API path
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(formData),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
setStatus(t('success'));
|
|
||||||
setFormData({ name: '', email: '', message: '' });
|
|
||||||
} else {
|
|
||||||
const errorData = await response.json();
|
|
||||||
setStatus(errorData.message || t('errorFailed'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Contact form error:', error);
|
|
||||||
setStatus(t('errorGeneric'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-6 max-w-lg mx-auto p-8 bg-white dark:bg-gray-800 shadow-xl rounded-lg">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('nameLabel')}</label>
|
|
||||||
<input type="text" name="name" id="name" value={formData.name} onChange={handleChange} required
|
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm dark:bg-gray-700 dark:text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('emailLabel')}</label>
|
|
||||||
<input type="email" name="email" id="email" value={formData.email} onChange={handleChange} required
|
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm dark:bg-gray-700 dark:text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="message" className="block text-sm font-medium text-gray-700 dark:text-gray-300">{t('messageLabel')}</label>
|
|
||||||
<textarea name="message" id="message" rows={4} value={formData.message} onChange={handleChange} required
|
|
||||||
className="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary sm:text-sm dark:bg-gray-700 dark:text-white"></textarea>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button type="submit"
|
|
||||||
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-primary hover:bg-primary-dark focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-primary-dark transition-colors">
|
|
||||||
{t('sendButton')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{status && <p className={`mt-4 text-sm text-center ${status.includes('success') || status.includes('sucesso') ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>{status}</p>}
|
|
||||||
</form>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,69 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {useTranslations} from 'next-intl';
|
import {useTranslations} from 'next-intl';
|
||||||
|
import { FormEvent, useState } from 'react';
|
||||||
|
import toast from 'react-hot-toast'; // <-- Import toast
|
||||||
|
|
||||||
export default function Contact() {
|
export default function Contact() {
|
||||||
const t = useTranslations('contact');
|
const t = useTranslations('contact');
|
||||||
|
const [formData, setFormData] = useState<{ name: string; email: string; message: string }>({ name: '', email: '', message: '' });
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
setFormData({...formData, [e.target.name]: e.target.value });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
alert('Contact form submission logic needs to be implemented!');
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
if (!formData.name || !formData.email || !formData.message) {
|
||||||
|
toast.error(t('status_error_all_fields'));
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/\S+@\S+\.\S+/.test(formData.email)) {
|
||||||
|
toast.error(t('status_error_invalid_email'));
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const submissionPromise = async () => {
|
||||||
|
//const backendUrl = process.env.NEXT_PUBLIC_BACKEND_URL;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:3001/api/email/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(formData),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.json();
|
||||||
|
throw new Error(body.errorKey || 'status_error_generic');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error instanceof TypeError) {
|
||||||
|
throw new Error('status_error_generic');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
toast.promise(submissionPromise(), {
|
||||||
|
loading: t('status_sending'),
|
||||||
|
success: () => {
|
||||||
|
setFormData({ name: '', email: '', message: '' });
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return t('status_success');
|
||||||
|
},
|
||||||
|
error: (err: Error) => {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
return t(err.message as any);
|
||||||
|
},
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -19,15 +75,18 @@ export default function Contact() {
|
|||||||
<form onSubmit={handleSubmit} className="max-w-xl mx-auto">
|
<form onSubmit={handleSubmit} className="max-w-xl mx-auto">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="name" className="block mb-2 text-sm font-medium text-[var(--color-text-secondary)]">{t('form_name')}</label>
|
<label htmlFor="name" className="block mb-2 text-sm font-medium text-[var(--color-text-secondary)]">{t('form_name')}</label>
|
||||||
<input type="text" id="name" className="bg-[var(--color-card)] border border-[var(--color-border)] text-[var(--color-text-primary)] text-sm rounded-lg focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)] block w-full p-2.5" required />
|
<input type="text" name="name" id="name" value={formData.name} onChange={handleChange} required
|
||||||
|
className="bg-[var(--color-card)] border border-[var(--color-border)] text-[var(--color-text-primary)] text-sm rounded-lg focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)] block w-full p-2.5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<label htmlFor="email" className="block mb-2 text-sm font-medium text-[var(--color-text-secondary)]">{t('form_email')}</label>
|
<label htmlFor="email" className="block mb-2 text-sm font-medium text-[var(--color-text-secondary)]">{t('form_email')}</label>
|
||||||
<input type="email" id="email" className="bg-[var(--color-card)] border border-[var(--color-border)] text-[var(--color-text-primary)] text-sm rounded-lg focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)] block w-full p-2.5" required />
|
<input type="email" name="email" id="email" value={formData.email} onChange={handleChange} required
|
||||||
|
className="bg-[var(--color-card)] border border-[var(--color-border)] text-[var(--color-text-primary)] text-sm rounded-lg focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)] block w-full p-2.5" />
|
||||||
</div>
|
</div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<label htmlFor="message" className="block mb-2 text-sm font-medium text-[var(--color-text-secondary)]">{t('form_message')}</label>
|
<label htmlFor="message" className="block mb-2 text-sm font-medium text-[var(--color-text-secondary)]">{t('form_message')}</label>
|
||||||
<textarea id="message" rows={5} className="bg-[var(--color-card)] border border-[var(--color-border)] text-[var(--color-text-primary)] text-sm rounded-lg focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)] block w-full p-2.5" required></textarea>
|
<textarea name="message" id="message" rows={5} value={formData.message} onChange={handleChange} required
|
||||||
|
className="bg-[var(--color-card)] border border-[var(--color-border)] text-[var(--color-text-primary)] text-sm rounded-lg focus:ring-[var(--color-primary)] focus:border-[var(--color-primary)] block w-full p-2.5"></textarea>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<button type="submit" className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 text-white font-bold py-3 px-8 rounded-full transition-colors">
|
<button type="submit" className="bg-[var(--color-primary)] hover:bg-[var(--color-primary)]/90 text-white font-bold py-3 px-8 rounded-full transition-colors">
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 213 KiB After Width: | Height: | Size: 220 KiB |
BIN
frontend/src/app/faviconOld.ico
Normal file
BIN
frontend/src/app/faviconOld.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 213 KiB |
@@ -3,11 +3,11 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--color-background: #0A0A0A;
|
--color-background: #161616;
|
||||||
--color-card: #121212;
|
--color-card: #121212;
|
||||||
--color-primary: #0ea5e9;
|
--color-primary: #636B97;
|
||||||
--color-text-primary: #F4F4F5;
|
--color-text-primary: #FFFFFF;
|
||||||
--color-text-secondary: #A1A1AA;
|
--color-text-secondary: #EAE3DB;
|
||||||
--color-border: #27272A;
|
--color-border: #27272A;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,8 +52,17 @@
|
|||||||
"form_email": "Your Email",
|
"form_email": "Your Email",
|
||||||
"form_message": "Your Message",
|
"form_message": "Your Message",
|
||||||
"submit_button": "Send Message",
|
"submit_button": "Send Message",
|
||||||
"success_message": "Thank you! Your message has been sent successfully.",
|
"status_sending": "Sending your message...",
|
||||||
"error_message": "Oops! Something went wrong. Please try again later."
|
"status_success": "Message sent successfully. Thank you!",
|
||||||
|
"status_error_failed": "Failed to send message. Please try again.",
|
||||||
|
"status_error_all_fields": "All fields are required.",
|
||||||
|
"status_error_invalid_email": "Please enter a valid email address.",
|
||||||
|
"status_error_generic": "An unexpected error occurred. Please try again later.",
|
||||||
|
"server_unexpected_error": "An unexpected server error occurred. Please try again later.",
|
||||||
|
"smtp_auth_failed": "Authentication with the mail server failed.",
|
||||||
|
"smtp_connection_failed": "Could not connect to the mail server.",
|
||||||
|
"smtp_invalid_recipient": "Invalid recipient email address.",
|
||||||
|
"smtp_generic_error": "A mail-sending error occurred."
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"copyright": "All rights reserved.",
|
"copyright": "All rights reserved.",
|
||||||
|
|||||||
@@ -52,8 +52,17 @@
|
|||||||
"form_email": "Seu Email",
|
"form_email": "Seu Email",
|
||||||
"form_message": "Sua Mensagem",
|
"form_message": "Sua Mensagem",
|
||||||
"submit_button": "Enviar Mensagem",
|
"submit_button": "Enviar Mensagem",
|
||||||
"success_message": "Obrigado! Sua mensagem foi enviada com sucesso.",
|
"status_sending": "Enviando sua mensagem...",
|
||||||
"error_message": "Oops! Algo deu errado. Por favor, tente novamente mais tarde."
|
"status_success": "Mensagem enviada com sucesso. Obrigado!",
|
||||||
|
"status_error_failed": "Falha ao enviar a mensagem. Por favor, tente novamente.",
|
||||||
|
"status_error_all_fields": "Todos os campos são obrigatórios.",
|
||||||
|
"status_error_invalid_email": "Por favor, insira um email válido.",
|
||||||
|
"status_error_generic": "Ocorreu um erro inesperado. Tente novamente mais tarde.",
|
||||||
|
"server_unexpected_error": "Ocorreu um erro inesperado no servidor. Por favor, tente novamente mais tarde.",
|
||||||
|
"smtp_auth_failed": "Falha na autenticação com o servidor de email.",
|
||||||
|
"smtp_connection_failed": "Não foi possível conectar ao servidor de email.",
|
||||||
|
"smtp_invalid_recipient": "Endereço de email do destinatário inválido.",
|
||||||
|
"smtp_generic_error": "Ocorreu um erro ao enviar o email."
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"copyright": "Todos os direitos reservados.",
|
"copyright": "Todos os direitos reservados.",
|
||||||
|
|||||||
Reference in New Issue
Block a user