added contact form server
This commit is contained in:
parent
27f554b389
commit
350fc82533
|
|
@ -0,0 +1,22 @@
|
||||||
|
# Server Configuration
|
||||||
|
PORT=3001
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Email Configuration (Gmail example)
|
||||||
|
EMAIL_SERVICE=gmail
|
||||||
|
EMAIL_HOST=smtp.gmail.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_SECURE=false
|
||||||
|
EMAIL_USER=your-email@gmail.com
|
||||||
|
EMAIL_PASS=your-app-password
|
||||||
|
|
||||||
|
# Recipient Configuration
|
||||||
|
RECIPIENT_EMAIL=your-email@gmail.com
|
||||||
|
RECIPIENT_NAME=Your Name
|
||||||
|
|
||||||
|
# Frontend URL (for CORS)
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=5
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
|
||||||
|
# IDE/Editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
@ -0,0 +1,239 @@
|
||||||
|
# Portfolio Backend API
|
||||||
|
|
||||||
|
A secure Node.js backend API for handling contact form submissions from your portfolio website.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- 🔒 **Security**: Rate limiting, CORS protection, input validation, spam protection
|
||||||
|
- 📧 **Email Integration**: Nodemailer with Gmail/SMTP support
|
||||||
|
- ✅ **Validation**: Comprehensive form validation with Joi
|
||||||
|
- 🚀 **Performance**: Express.js with async error handling
|
||||||
|
- 🛡️ **Anti-Spam**: Honeypot fields and rate limiting
|
||||||
|
- 📝 **Logging**: Request/response logging and error tracking
|
||||||
|
- 🎨 **Email Templates**: Professional HTML email templates
|
||||||
|
|
||||||
|
## Setup Instructions
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Environment
|
||||||
|
|
||||||
|
Copy the example environment file and configure your settings:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Edit `.env` with your actual configuration:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Server Configuration
|
||||||
|
PORT=3001
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Email Configuration (Gmail example)
|
||||||
|
EMAIL_SERVICE=gmail
|
||||||
|
EMAIL_HOST=smtp.gmail.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_SECURE=false
|
||||||
|
EMAIL_USER=your-email@gmail.com
|
||||||
|
EMAIL_PASS=your-app-password
|
||||||
|
|
||||||
|
# Recipient Configuration
|
||||||
|
RECIPIENT_EMAIL=your-email@gmail.com
|
||||||
|
RECIPIENT_NAME=Your Name
|
||||||
|
|
||||||
|
# Frontend URL (for CORS)
|
||||||
|
FRONTEND_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_WINDOW_MS=900000
|
||||||
|
RATE_LIMIT_MAX_REQUESTS=5
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Gmail Setup (Recommended)
|
||||||
|
|
||||||
|
1. **Enable 2-Factor Authentication** on your Gmail account
|
||||||
|
2. **Generate App Password**:
|
||||||
|
- Go to Google Account settings
|
||||||
|
- Security → 2-Step Verification → App passwords
|
||||||
|
- Generate a password for "Mail"
|
||||||
|
- Use this as `EMAIL_PASS` in your `.env` file
|
||||||
|
|
||||||
|
### 4. Alternative Email Providers
|
||||||
|
|
||||||
|
**Outlook/Hotmail:**
|
||||||
|
|
||||||
|
```env
|
||||||
|
EMAIL_SERVICE=hotmail
|
||||||
|
EMAIL_HOST=smtp-mail.outlook.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_SECURE=false
|
||||||
|
```
|
||||||
|
|
||||||
|
**Custom SMTP:**
|
||||||
|
|
||||||
|
```env
|
||||||
|
EMAIL_SERVICE=
|
||||||
|
EMAIL_HOST=your-smtp-host.com
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_SECURE=false
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Run the Server
|
||||||
|
|
||||||
|
**Development:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
**Production:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Test the API
|
||||||
|
|
||||||
|
Visit: `http://localhost:3001/health` to verify the server is running.
|
||||||
|
|
||||||
|
For development, you can test the contact endpoint: `http://localhost:3001/api/contact/test`
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Health Check
|
||||||
|
|
||||||
|
- **GET** `/health` - Server health status
|
||||||
|
|
||||||
|
### Contact Form
|
||||||
|
|
||||||
|
- **POST** `/api/contact` - Send contact email
|
||||||
|
|
||||||
|
**Request Body:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"firstName": "John",
|
||||||
|
"lastName": "Doe",
|
||||||
|
"email": "john@example.com",
|
||||||
|
"subject": "Project Inquiry",
|
||||||
|
"message": "Hello, I'd like to discuss a project..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Your message has been sent successfully! I'll get back to you soon."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
- **Rate Limiting**: 5 requests per 15 minutes per IP
|
||||||
|
- **Input Validation**: Joi schema validation for all fields
|
||||||
|
- **CORS Protection**: Configurable origin restrictions
|
||||||
|
- **Helmet Security**: Security headers
|
||||||
|
- **Honeypot Anti-Spam**: Hidden field detection
|
||||||
|
- **Request Logging**: IP and user agent tracking
|
||||||
|
|
||||||
|
## Deployment Options
|
||||||
|
|
||||||
|
### 1. Heroku
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Heroku CLI, then:
|
||||||
|
heroku create your-portfolio-api
|
||||||
|
heroku config:set NODE_ENV=production
|
||||||
|
heroku config:set EMAIL_USER=your-email@gmail.com
|
||||||
|
# ... set other environment variables
|
||||||
|
git push heroku main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Railway
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Install Railway CLI, then:
|
||||||
|
railway login
|
||||||
|
railway new
|
||||||
|
railway add
|
||||||
|
railway up
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. DigitalOcean App Platform
|
||||||
|
|
||||||
|
- Create new app from GitHub repository
|
||||||
|
- Set environment variables in the dashboard
|
||||||
|
- Deploy automatically
|
||||||
|
|
||||||
|
### 4. Vercel (Serverless)
|
||||||
|
|
||||||
|
- Convert Express app to serverless functions
|
||||||
|
- Deploy with `vercel` CLI
|
||||||
|
|
||||||
|
## Environment Variables Reference
|
||||||
|
|
||||||
|
| Variable | Description | Example |
|
||||||
|
| ------------------------- | --------------------------- | ---------------------- |
|
||||||
|
| `PORT` | Server port | `3001` |
|
||||||
|
| `NODE_ENV` | Environment | `production` |
|
||||||
|
| `EMAIL_SERVICE` | Email service provider | `gmail` |
|
||||||
|
| `EMAIL_HOST` | SMTP host | `smtp.gmail.com` |
|
||||||
|
| `EMAIL_PORT` | SMTP port | `587` |
|
||||||
|
| `EMAIL_SECURE` | Use SSL/TLS | `false` |
|
||||||
|
| `EMAIL_USER` | Email username | `your@gmail.com` |
|
||||||
|
| `EMAIL_PASS` | Email password/app password | `app-password` |
|
||||||
|
| `RECIPIENT_EMAIL` | Where to send emails | `your@gmail.com` |
|
||||||
|
| `RECIPIENT_NAME` | Recipient display name | `Your Name` |
|
||||||
|
| `FRONTEND_URL` | Frontend URL for CORS | `https://yoursite.com` |
|
||||||
|
| `RATE_LIMIT_WINDOW_MS` | Rate limit window | `900000` (15 min) |
|
||||||
|
| `RATE_LIMIT_MAX_REQUESTS` | Max requests per window | `5` |
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Email Not Sending
|
||||||
|
|
||||||
|
1. Check Gmail app password is correct
|
||||||
|
2. Verify 2FA is enabled on Gmail
|
||||||
|
3. Check console logs for detailed errors
|
||||||
|
4. Test with `/health` endpoint first
|
||||||
|
|
||||||
|
### CORS Issues
|
||||||
|
|
||||||
|
1. Verify `FRONTEND_URL` matches your frontend domain exactly
|
||||||
|
2. Include protocol (http/https) in the URL
|
||||||
|
3. No trailing slashes
|
||||||
|
|
||||||
|
### Rate Limiting
|
||||||
|
|
||||||
|
- Default: 5 requests per 15 minutes per IP
|
||||||
|
- Adjust `RATE_LIMIT_*` variables as needed
|
||||||
|
- Check IP address in logs for debugging
|
||||||
|
|
||||||
|
## Production Checklist
|
||||||
|
|
||||||
|
- [ ] Environment variables configured
|
||||||
|
- [ ] Email service tested
|
||||||
|
- [ ] Rate limiting configured
|
||||||
|
- [ ] CORS origins restricted
|
||||||
|
- [ ] SSL/HTTPS enabled
|
||||||
|
- [ ] Error monitoring setup
|
||||||
|
- [ ] Logs configured
|
||||||
|
- [ ] Backup strategy in place
|
||||||
|
|
||||||
|
## Support
|
||||||
|
|
||||||
|
If you encounter issues:
|
||||||
|
|
||||||
|
1. Check the console logs
|
||||||
|
2. Verify your `.env` configuration
|
||||||
|
3. Test email configuration with `/health` endpoint
|
||||||
|
4. Ensure your email provider allows SMTP access
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
// Async error handler wrapper
|
||||||
|
export const asyncHandler = (fn) => (req, res, next) => {
|
||||||
|
Promise.resolve(fn(req, res, next)).catch(next);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,65 @@
|
||||||
|
// Error handling middleware
|
||||||
|
|
||||||
|
export const errorHandler = (err, req, res, next) => {
|
||||||
|
console.error('Error occurred:', {
|
||||||
|
message: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
url: req.url,
|
||||||
|
method: req.method,
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.get('User-Agent'),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Default error
|
||||||
|
let error = {
|
||||||
|
success: false,
|
||||||
|
message: 'Internal server error',
|
||||||
|
...(process.env.NODE_ENV === 'development' && {
|
||||||
|
error: err.message,
|
||||||
|
stack: err.stack,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Rate limit error
|
||||||
|
if (err.status === 429) {
|
||||||
|
error = {
|
||||||
|
success: false,
|
||||||
|
message: 'Too many requests. Please try again later.',
|
||||||
|
retryAfter: err.retryAfter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation errors
|
||||||
|
if (err.name === 'ValidationError') {
|
||||||
|
error = {
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors: Object.values(err.errors).map((e) => e.message),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON parsing errors
|
||||||
|
if (err instanceof SyntaxError && err.status === 400 && 'body' in err) {
|
||||||
|
error = {
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid JSON in request body',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(err.status || 500).json(error);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const notFoundHandler = (req, res) => {
|
||||||
|
res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: `Route ${req.method} ${req.url} not found`,
|
||||||
|
availableRoutes: {
|
||||||
|
'GET /health': 'Health check endpoint',
|
||||||
|
'POST /api/contact': 'Send contact form email',
|
||||||
|
...(process.env.NODE_ENV === 'development' && {
|
||||||
|
'GET /api/contact/test': 'Test contact API (development only)',
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
// Request logging middleware
|
||||||
|
export const requestLogger = (req, res, next) => {
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// Log request
|
||||||
|
console.log(`📥 ${req.method} ${req.url}`, {
|
||||||
|
ip: req.ip,
|
||||||
|
userAgent: req.get('User-Agent'),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Log response when finished
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = Date.now() - start;
|
||||||
|
const statusEmoji = res.statusCode >= 400 ? '❌' : '✅';
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`📤 ${statusEmoji} ${req.method} ${req.url} - ${res.statusCode} - ${duration}ms`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import Joi from 'joi';
|
||||||
|
|
||||||
|
// Validation schema for contact form
|
||||||
|
const contactSchema = Joi.object({
|
||||||
|
firstName: Joi.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(50)
|
||||||
|
.pattern(/^[a-zA-Z\s\-']+$/)
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'string.empty': 'First name is required',
|
||||||
|
'string.min': 'First name must be at least 1 character long',
|
||||||
|
'string.max': 'First name cannot exceed 50 characters',
|
||||||
|
'string.pattern.base':
|
||||||
|
'First name can only contain letters, spaces, hyphens, and apostrophes',
|
||||||
|
}),
|
||||||
|
|
||||||
|
lastName: Joi.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(50)
|
||||||
|
.pattern(/^[a-zA-Z\s\-']+$/)
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'string.empty': 'Last name is required',
|
||||||
|
'string.min': 'Last name must be at least 1 character long',
|
||||||
|
'string.max': 'Last name cannot exceed 50 characters',
|
||||||
|
'string.pattern.base':
|
||||||
|
'Last name can only contain letters, spaces, hyphens, and apostrophes',
|
||||||
|
}),
|
||||||
|
|
||||||
|
email: Joi.string()
|
||||||
|
.trim()
|
||||||
|
.email({ minDomainSegments: 2, tlds: { allow: true } })
|
||||||
|
.max(254)
|
||||||
|
.required()
|
||||||
|
.messages({
|
||||||
|
'string.email': 'Please provide a valid email address',
|
||||||
|
'string.empty': 'Email address is required',
|
||||||
|
'string.max': 'Email address is too long',
|
||||||
|
}),
|
||||||
|
|
||||||
|
subject: Joi.string().trim().max(200).allow('').messages({
|
||||||
|
'string.max': 'Subject cannot exceed 200 characters',
|
||||||
|
}),
|
||||||
|
|
||||||
|
message: Joi.string().trim().min(10).max(5000).required().messages({
|
||||||
|
'string.empty': 'Message is required',
|
||||||
|
'string.min': 'Message must be at least 10 characters long',
|
||||||
|
'string.max': 'Message cannot exceed 5000 characters',
|
||||||
|
}),
|
||||||
|
|
||||||
|
// Honeypot field (should be empty)
|
||||||
|
website: Joi.string().allow('').max(0).messages({
|
||||||
|
'string.max': 'Invalid form submission',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validation middleware
|
||||||
|
export const validateContactForm = (req, res, next) => {
|
||||||
|
const { error, value } = contactSchema.validate(req.body, {
|
||||||
|
abortEarly: false,
|
||||||
|
stripUnknown: true,
|
||||||
|
convert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const errors = error.details.map((detail) => ({
|
||||||
|
field: detail.path[0],
|
||||||
|
message: detail.message,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Validation failed',
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace req.body with validated and sanitized data
|
||||||
|
req.body = value;
|
||||||
|
next();
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,28 @@
|
||||||
|
{
|
||||||
|
"name": "portfolio-backend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Backend API for portfolio contact form",
|
||||||
|
"main": "server.js",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nodemon server.js",
|
||||||
|
"start": "node server.js",
|
||||||
|
"build": "echo 'No build step needed for Node.js'",
|
||||||
|
"test": "echo 'No tests yet'"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"nodemailer": "^6.9.7",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"helmet": "^7.1.0",
|
||||||
|
"express-rate-limit": "^7.1.5",
|
||||||
|
"joi": "^17.11.0",
|
||||||
|
"dotenv": "^16.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
import express from 'express';
|
||||||
|
import { sendContactEmail } from '../services/emailService.js';
|
||||||
|
import { validateContactForm } from '../middleware/validation.js';
|
||||||
|
import { asyncHandler } from '../middleware/asyncHandler.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// POST /api/contact - Send contact form email
|
||||||
|
router.post(
|
||||||
|
'/',
|
||||||
|
validateContactForm,
|
||||||
|
asyncHandler(async (req, res) => {
|
||||||
|
const { firstName, lastName, email, subject, message } = req.body;
|
||||||
|
|
||||||
|
// Check for honeypot (if present in body)
|
||||||
|
if (req.body.website && req.body.website.trim() !== '') {
|
||||||
|
console.warn('Spam detected: honeypot field filled', {
|
||||||
|
ip: req.ip,
|
||||||
|
email,
|
||||||
|
});
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid form submission detected.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Send the email
|
||||||
|
await sendContactEmail({
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email,
|
||||||
|
subject: subject || 'New Portfolio Contact',
|
||||||
|
message,
|
||||||
|
senderIP: req.ip,
|
||||||
|
userAgent: req.get('User-Agent'),
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Contact email sent successfully', {
|
||||||
|
from: email,
|
||||||
|
name: `${firstName} ${lastName}`,
|
||||||
|
subject: subject || 'New Portfolio Contact',
|
||||||
|
ip: req.ip,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message:
|
||||||
|
"Your message has been sent successfully! I'll get back to you soon.",
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send contact email:', error);
|
||||||
|
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message:
|
||||||
|
'Sorry, there was an error sending your message. Please try again later.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// GET /api/contact/test - Test endpoint (development only)
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
router.get('/test', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
message: 'Contact API is working!',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
environment: process.env.NODE_ENV,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { router as contactRouter };
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
import express from 'express';
|
||||||
|
import cors from 'cors';
|
||||||
|
import helmet from 'helmet';
|
||||||
|
import rateLimit from 'express-rate-limit';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { contactRouter } from './routes/contact.js';
|
||||||
|
import { errorHandler, notFoundHandler } from './middleware/errorHandlers.js';
|
||||||
|
import { requestLogger } from './middleware/logger.js';
|
||||||
|
import { initializeEmailService } from './services/emailService.js';
|
||||||
|
|
||||||
|
// Get the directory of the current module
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Load environment variables from the backend directory
|
||||||
|
dotenv.config({ path: join(__dirname, '.env') });
|
||||||
|
|
||||||
|
async function startServer() {
|
||||||
|
// Initialize email service after environment variables are loaded
|
||||||
|
await initializeEmailService();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
// Security middleware
|
||||||
|
app.use(
|
||||||
|
helmet({
|
||||||
|
crossOriginResourcePolicy: { policy: 'cross-origin' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// CORS configuration
|
||||||
|
app.use(
|
||||||
|
cors({
|
||||||
|
origin: process.env.FRONTEND_URL || 'http://localhost:5173',
|
||||||
|
methods: ['GET', 'POST'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Authorization'],
|
||||||
|
credentials: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Rate limiting
|
||||||
|
const limiter = rateLimit({
|
||||||
|
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes
|
||||||
|
max: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS) || 5, // limit each IP to 5 requests per windowMs
|
||||||
|
message: {
|
||||||
|
error: 'Too many requests from this IP, please try again later.',
|
||||||
|
retryAfter: Math.ceil(
|
||||||
|
(parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000) / 1000
|
||||||
|
),
|
||||||
|
},
|
||||||
|
standardHeaders: true,
|
||||||
|
legacyHeaders: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use('/api/', limiter);
|
||||||
|
|
||||||
|
// Body parsing middleware
|
||||||
|
app.use(express.json({ limit: '10mb' }));
|
||||||
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||||
|
|
||||||
|
// Request logging
|
||||||
|
app.use(requestLogger);
|
||||||
|
|
||||||
|
// Health check endpoint
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
environment: process.env.NODE_ENV || 'development',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use('/api/contact', contactRouter);
|
||||||
|
|
||||||
|
// Error handling middleware
|
||||||
|
app.use(notFoundHandler);
|
||||||
|
app.use(errorHandler);
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`🚀 Server is running on port ${PORT}`);
|
||||||
|
console.log(
|
||||||
|
`📧 Email service: ${process.env.EMAIL_SERVICE || 'Not configured'}`
|
||||||
|
);
|
||||||
|
console.log(`🌍 Environment: ${process.env.NODE_ENV || 'development'}`);
|
||||||
|
console.log(
|
||||||
|
`🔗 Frontend URL: ${process.env.FRONTEND_URL || 'http://localhost:5173'}`
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('SIGTERM signal received: closing HTTP server');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('SIGINT signal received: closing HTTP server');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the server
|
||||||
|
startServer().catch((error) => {
|
||||||
|
console.error('Failed to start server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,120 @@
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import { createContactEmailTemplate } from '../templates/emailTemplates.js';
|
||||||
|
|
||||||
|
// Create email transporter
|
||||||
|
const createTransporter = () => {
|
||||||
|
const config = {
|
||||||
|
service: process.env.EMAIL_SERVICE,
|
||||||
|
auth: {
|
||||||
|
user: process.env.EMAIL_USER,
|
||||||
|
pass: process.env.EMAIL_PASS,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// If not using a predefined service, use custom SMTP settings
|
||||||
|
if (!process.env.EMAIL_SERVICE || process.env.EMAIL_SERVICE === 'custom') {
|
||||||
|
delete config.service;
|
||||||
|
config.host = process.env.EMAIL_HOST;
|
||||||
|
config.port = parseInt(process.env.EMAIL_PORT) || 587;
|
||||||
|
config.secure = process.env.EMAIL_SECURE === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
|
return nodemailer.createTransport(config);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Verify email configuration
|
||||||
|
export const verifyEmailConfig = async () => {
|
||||||
|
try {
|
||||||
|
console.log('🔍 Debug - Creating transporter with config:');
|
||||||
|
console.log('EMAIL_SERVICE:', process.env.EMAIL_SERVICE);
|
||||||
|
console.log('EMAIL_USER:', process.env.EMAIL_USER);
|
||||||
|
console.log(
|
||||||
|
'EMAIL_PASS length:',
|
||||||
|
process.env.EMAIL_PASS ? process.env.EMAIL_PASS.length : 'undefined'
|
||||||
|
);
|
||||||
|
|
||||||
|
const transporter = createTransporter();
|
||||||
|
await transporter.verify();
|
||||||
|
console.log('✅ Email configuration verified successfully');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Email configuration verification failed:', error.message);
|
||||||
|
console.error('Full error:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send contact form email
|
||||||
|
export const sendContactEmail = async ({
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
email,
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
senderIP,
|
||||||
|
userAgent,
|
||||||
|
}) => {
|
||||||
|
const transporter = createTransporter();
|
||||||
|
|
||||||
|
const fullName = `${firstName} ${lastName}`;
|
||||||
|
const emailSubject = `[Portfolio Contact] ${subject}`;
|
||||||
|
|
||||||
|
// Create email content
|
||||||
|
const emailTemplate = createContactEmailTemplate({
|
||||||
|
fullName,
|
||||||
|
email,
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
senderIP,
|
||||||
|
userAgent,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Email options
|
||||||
|
const mailOptions = {
|
||||||
|
from: {
|
||||||
|
name: fullName,
|
||||||
|
address: process.env.EMAIL_USER, // Use your email as the sender
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
name: process.env.RECIPIENT_NAME || 'Portfolio Owner',
|
||||||
|
address: process.env.RECIPIENT_EMAIL,
|
||||||
|
},
|
||||||
|
replyTo: {
|
||||||
|
name: fullName,
|
||||||
|
address: email, // This allows you to reply directly to the sender
|
||||||
|
},
|
||||||
|
subject: emailSubject,
|
||||||
|
html: emailTemplate.html,
|
||||||
|
text: emailTemplate.text,
|
||||||
|
headers: {
|
||||||
|
'X-Priority': '1',
|
||||||
|
'X-MSMail-Priority': 'High',
|
||||||
|
Importance: 'high',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Send the email
|
||||||
|
try {
|
||||||
|
const result = await transporter.sendMail(mailOptions);
|
||||||
|
console.log('Email sent successfully:', result.messageId);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send email:', error);
|
||||||
|
throw new Error(`Email sending failed: ${error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize email service (verify configuration on startup)
|
||||||
|
export const initializeEmailService = async () => {
|
||||||
|
console.log('🔧 Initializing email service...');
|
||||||
|
const isValid = await verifyEmailConfig();
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
console.warn(
|
||||||
|
'⚠️ Email service not properly configured. Check your .env file.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isValid;
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,158 @@
|
||||||
|
// Email templates for contact form submissions
|
||||||
|
|
||||||
|
export const createContactEmailTemplate = ({
|
||||||
|
fullName,
|
||||||
|
email,
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
senderIP,
|
||||||
|
userAgent,
|
||||||
|
timestamp,
|
||||||
|
}) => {
|
||||||
|
// HTML template
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>New Portfolio Contact</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
background-color: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.header {
|
||||||
|
border-bottom: 3px solid #007bff;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
color: #007bff;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.field-label {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #495057;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.field-value {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
padding: 10px 15px;
|
||||||
|
border-left: 4px solid #007bff;
|
||||||
|
border-radius: 4px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.message-content {
|
||||||
|
background-color: #fff3cd;
|
||||||
|
border: 1px solid #ffeaa7;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 6px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
line-height: 1.8;
|
||||||
|
}
|
||||||
|
.metadata {
|
||||||
|
margin-top: 30px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #dee2e6;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
.reply-info {
|
||||||
|
background-color: #e7f3ff;
|
||||||
|
border: 1px solid #b3d9ff;
|
||||||
|
padding: 15px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
.reply-info strong {
|
||||||
|
color: #0056b3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>🚀 New Portfolio Contact</h1>
|
||||||
|
<p>You've received a new message through your portfolio contact form!</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">👤 From:</span>
|
||||||
|
<div class="field-value"><strong>${fullName}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">📧 Email:</span>
|
||||||
|
<div class="field-value"><a href="mailto:${email}" style="color: #007bff; text-decoration: none;">${email}</a></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">📋 Subject:</span>
|
||||||
|
<div class="field-value">${subject}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<span class="field-label">💬 Message:</span>
|
||||||
|
<div class="message-content">${message}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="reply-info">
|
||||||
|
<strong>💡 Quick Reply:</strong> You can reply directly to this email to respond to ${fullName}.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metadata">
|
||||||
|
<p><strong>📅 Received:</strong> ${new Date(
|
||||||
|
timestamp
|
||||||
|
).toLocaleString()}</p>
|
||||||
|
<p><strong>🌐 IP Address:</strong> ${senderIP}</p>
|
||||||
|
<p><strong>🔧 User Agent:</strong> ${
|
||||||
|
userAgent || 'Not provided'
|
||||||
|
}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Plain text version
|
||||||
|
const text = `
|
||||||
|
NEW PORTFOLIO CONTACT
|
||||||
|
=====================
|
||||||
|
|
||||||
|
From: ${fullName}
|
||||||
|
Email: ${email}
|
||||||
|
Subject: ${subject}
|
||||||
|
|
||||||
|
Message:
|
||||||
|
--------
|
||||||
|
${message}
|
||||||
|
|
||||||
|
Metadata:
|
||||||
|
---------
|
||||||
|
Received: ${new Date(timestamp).toLocaleString()}
|
||||||
|
IP Address: ${senderIP}
|
||||||
|
User Agent: ${userAgent || 'Not provided'}
|
||||||
|
|
||||||
|
You can reply directly to this email to respond to the sender.
|
||||||
|
`;
|
||||||
|
|
||||||
|
return { html, text };
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import nodemailer from 'nodemailer';
|
||||||
|
import dotenv from 'dotenv';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
|
||||||
|
// Get the directory of the current module
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
// Load environment variables
|
||||||
|
dotenv.config({ path: join(__dirname, '.env') });
|
||||||
|
|
||||||
|
console.log('Testing Gmail configuration...');
|
||||||
|
console.log('EMAIL_USER:', process.env.EMAIL_USER);
|
||||||
|
console.log(
|
||||||
|
'EMAIL_PASS length:',
|
||||||
|
process.env.EMAIL_PASS ? process.env.EMAIL_PASS.length : 'undefined'
|
||||||
|
);
|
||||||
|
|
||||||
|
const transporter = nodemailer.createTransport({
|
||||||
|
service: 'gmail',
|
||||||
|
auth: {
|
||||||
|
user: process.env.EMAIL_USER,
|
||||||
|
pass: process.env.EMAIL_PASS,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
transporter
|
||||||
|
.verify()
|
||||||
|
.then(() => {
|
||||||
|
console.log('✅ Gmail connection successful!');
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('❌ Gmail connection failed:');
|
||||||
|
console.error('Error code:', error.code);
|
||||||
|
console.error('Error message:', error.message);
|
||||||
|
console.error('Response:', error.response);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|
@ -2,6 +2,7 @@ import { Mail, Github, Linkedin } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { FormEvent } from 'react';
|
import type { FormEvent } from 'react';
|
||||||
import { personalConfig, getObfuscatedEmail, createEmailLink } from '../../config/personal';
|
import { personalConfig, getObfuscatedEmail, createEmailLink } from '../../config/personal';
|
||||||
|
import { getApiUrl, ENDPOINTS } from '../../config/api';
|
||||||
|
|
||||||
interface ContactSectionProps {
|
interface ContactSectionProps {
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
@ -24,6 +25,10 @@ export default function ContactSection({
|
||||||
// Anti-spam honeypot state
|
// Anti-spam honeypot state
|
||||||
const [honeypot, setHoneypot] = useState('');
|
const [honeypot, setHoneypot] = useState('');
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [formStatus, setFormStatus] = useState<{
|
||||||
|
type: 'success' | 'error' | null;
|
||||||
|
message: string;
|
||||||
|
}>({ type: null, message: '' });
|
||||||
|
|
||||||
// Email obfuscation function using config
|
// Email obfuscation function using config
|
||||||
const handleEmailClick = (e: React.MouseEvent) => {
|
const handleEmailClick = (e: React.MouseEvent) => {
|
||||||
|
|
@ -37,6 +42,11 @@ export default function ContactSection({
|
||||||
// Check honeypot - if filled, it's likely spam
|
// Check honeypot - if filled, it's likely spam
|
||||||
if (honeypot) {
|
if (honeypot) {
|
||||||
console.warn('Spam detected - honeypot field was filled');
|
console.warn('Spam detected - honeypot field was filled');
|
||||||
|
setFormStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Invalid form submission detected.'
|
||||||
|
});
|
||||||
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,6 +56,7 @@ export default function ContactSection({
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
|
setFormStatus({ type: null, message: '' }); // Clear previous status
|
||||||
|
|
||||||
// Get form data
|
// Get form data
|
||||||
const formData = new FormData(e.currentTarget);
|
const formData = new FormData(e.currentTarget);
|
||||||
|
|
@ -59,7 +70,10 @@ export default function ContactSection({
|
||||||
|
|
||||||
// Basic validation
|
// Basic validation
|
||||||
if (!data.firstName || !data.lastName || !data.email || !data.message) {
|
if (!data.firstName || !data.lastName || !data.email || !data.message) {
|
||||||
alert('Please fill in all required fields.');
|
setFormStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Please fill in all required fields.'
|
||||||
|
});
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -67,36 +81,104 @@ export default function ContactSection({
|
||||||
// Email validation
|
// Email validation
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(data.email)) {
|
if (!emailRegex.test(data.email)) {
|
||||||
alert('Please enter a valid email address.');
|
setFormStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: 'Please enter a valid email address.'
|
||||||
|
});
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Submit to Formspree (replace YOUR_FORM_ID with actual ID from Formspree)
|
// Submit to your own backend API
|
||||||
const response = await fetch('https://formspree.io/f/YOUR_FORM_ID', {
|
const apiUrl = `${getApiUrl()}${ENDPOINTS.contact}`;
|
||||||
|
const response = await fetch(apiUrl, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: `${data.firstName} ${data.lastName}`,
|
firstName: data.firstName,
|
||||||
|
lastName: data.lastName,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
subject: data.subject || 'Contact from Portfolio',
|
subject: data.subject || 'Contact from Portfolio',
|
||||||
message: data.message,
|
message: data.message,
|
||||||
_replyto: data.email, // This tells Formspree what email to reply to
|
website: honeypot, // Include honeypot for spam detection
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
const result = await response.json();
|
||||||
alert('Thank you for your message! I\'ll get back to you soon.');
|
|
||||||
|
if (result.success) {
|
||||||
|
setFormStatus({
|
||||||
|
type: 'success',
|
||||||
|
message: result.message
|
||||||
|
});
|
||||||
e.currentTarget.reset();
|
e.currentTarget.reset();
|
||||||
|
setHoneypot(''); // Reset honeypot
|
||||||
|
|
||||||
|
// Clear success message after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
setFormStatus({ type: null, message: '' });
|
||||||
|
}, 5000);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Form submission failed');
|
// Handle different types of server errors
|
||||||
|
let errorMessage = 'Form submission failed';
|
||||||
|
|
||||||
|
if (response.status === 400) {
|
||||||
|
if (result.errors && Array.isArray(result.errors)) {
|
||||||
|
// Validation errors from server
|
||||||
|
const validationErrors = result.errors.map((err: any) =>
|
||||||
|
typeof err === 'object' ? err.message : err
|
||||||
|
).join(', ');
|
||||||
|
errorMessage = `Validation errors: ${validationErrors}`;
|
||||||
|
} else if (result.message) {
|
||||||
|
errorMessage = result.message;
|
||||||
|
} else {
|
||||||
|
errorMessage = 'Please check your form data and try again.';
|
||||||
|
}
|
||||||
|
} else if (response.status === 429) {
|
||||||
|
errorMessage = 'Too many requests. Please wait a moment and try again.';
|
||||||
|
} else if (response.status === 500) {
|
||||||
|
errorMessage = 'Server error. Our team has been notified. Please try again later.';
|
||||||
|
} else if (result.message) {
|
||||||
|
errorMessage = result.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Form submission error:', error);
|
console.error('Form submission error:', error);
|
||||||
alert('Sorry, there was an error sending your message. Please try again.');
|
|
||||||
|
let userMessage = 'Sorry, there was an error sending your message.';
|
||||||
|
|
||||||
|
if (error instanceof Error) {
|
||||||
|
// Network errors
|
||||||
|
if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
|
||||||
|
userMessage = 'Network error: Please check your internet connection and try again.';
|
||||||
|
}
|
||||||
|
// Timeout errors
|
||||||
|
else if (error.message.includes('timeout')) {
|
||||||
|
userMessage = 'Request timeout: The server is taking too long to respond. Please try again.';
|
||||||
|
}
|
||||||
|
// CORS errors
|
||||||
|
else if (error.message.includes('CORS')) {
|
||||||
|
userMessage = 'Connection error: Please refresh the page and try again.';
|
||||||
|
}
|
||||||
|
// Server validation errors or custom error messages
|
||||||
|
else if (error.message.length > 0 && !error.message.includes('Form submission failed')) {
|
||||||
|
userMessage = error.message;
|
||||||
|
}
|
||||||
|
// Generic fallback
|
||||||
|
else {
|
||||||
|
userMessage = 'Unexpected error occurred. Please try again or contact us directly.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setFormStatus({
|
||||||
|
type: 'error',
|
||||||
|
message: userMessage
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
@ -175,6 +257,15 @@ export default function ContactSection({
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Status Message Display */}
|
||||||
|
{formStatus.type && (
|
||||||
|
<div className={`contact-section__status contact-section__status--${formStatus.type}`}>
|
||||||
|
<p className="contact-section__status-message">
|
||||||
|
{formStatus.message}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<form className="contact-section__form" onSubmit={handleSubmit}>
|
<form className="contact-section__form" onSubmit={handleSubmit}>
|
||||||
{/* Honeypot field - hidden from users but visible to bots */}
|
{/* Honeypot field - hidden from users but visible to bots */}
|
||||||
<div className="contact-section__honeypot">
|
<div className="contact-section__honeypot">
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
// API configuration for different environments
|
||||||
|
type Environment = 'development' | 'production';
|
||||||
|
|
||||||
|
const API_CONFIG: Record<Environment, { baseURL: string }> = {
|
||||||
|
development: {
|
||||||
|
baseURL: 'http://localhost:3002',
|
||||||
|
},
|
||||||
|
production: {
|
||||||
|
baseURL: 'https://your-backend-api.herokuapp.com', // Update with your actual backend URL
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getApiUrl = (): string => {
|
||||||
|
const isDev = import.meta.env.DEV;
|
||||||
|
const environment: Environment = isDev ? 'development' : 'production';
|
||||||
|
return API_CONFIG[environment].baseURL;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ENDPOINTS = {
|
||||||
|
contact: '/api/contact',
|
||||||
|
health: '/health',
|
||||||
|
};
|
||||||
|
|
@ -151,6 +151,32 @@
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Status Message Styles
|
||||||
|
&__status {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
&--success {
|
||||||
|
background-color: var(--contact-status-success-bg);
|
||||||
|
border: 1px solid var(--contact-status-success-border);
|
||||||
|
color: var(--contact-status-success-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--error {
|
||||||
|
background-color: var(--contact-status-error-bg);
|
||||||
|
border: 1px solid var(--contact-status-error-border);
|
||||||
|
color: var(--contact-status-error-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__status-message {
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
&__form {
|
&__form {
|
||||||
padding: 0 1.5rem 1.5rem;
|
padding: 0 1.5rem 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,14 @@ $contact-light-theme: (
|
||||||
'contact-social-text': #4b5563,
|
'contact-social-text': #4b5563,
|
||||||
'contact-social-border': rgba(79, 70, 229, 0.2),
|
'contact-social-border': rgba(79, 70, 229, 0.2),
|
||||||
'contact-social-hover-bg': #ffffff,
|
'contact-social-hover-bg': #ffffff,
|
||||||
'contact-social-hover-border': rgba(79, 70, 229, 0.3)
|
'contact-social-hover-border': rgba(79, 70, 229, 0.3),
|
||||||
|
// Status message styling (light theme)
|
||||||
|
'contact-status-success-bg': #dcfce7,
|
||||||
|
'contact-status-success-border': #16a34a,
|
||||||
|
'contact-status-success-text': #15803d,
|
||||||
|
'contact-status-error-bg': #fef2f2,
|
||||||
|
'contact-status-error-border': #dc2626,
|
||||||
|
'contact-status-error-text': #dc2626
|
||||||
);
|
);
|
||||||
|
|
||||||
$contact-dark-theme: (
|
$contact-dark-theme: (
|
||||||
|
|
@ -58,5 +65,12 @@ $contact-dark-theme: (
|
||||||
'contact-social-text': #e5e7eb,
|
'contact-social-text': #e5e7eb,
|
||||||
'contact-social-border': rgba(99, 102, 241, 0.3),
|
'contact-social-border': rgba(99, 102, 241, 0.3),
|
||||||
'contact-social-hover-bg': #374151,
|
'contact-social-hover-bg': #374151,
|
||||||
'contact-social-hover-border': rgba(99, 102, 241, 0.4)
|
'contact-social-hover-border': rgba(99, 102, 241, 0.4),
|
||||||
|
// Status message styling (dark theme)
|
||||||
|
'contact-status-success-bg': rgba(34, 197, 94, 0.1),
|
||||||
|
'contact-status-success-border': #22c55e,
|
||||||
|
'contact-status-success-text': #4ade80,
|
||||||
|
'contact-status-error-bg': rgba(239, 68, 68, 0.1),
|
||||||
|
'contact-status-error-border': #ef4444,
|
||||||
|
'contact-status-error-text': #f87171
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue