Arbeiten an Funktionalität Kontaktformular, DSGVO

This commit is contained in:
Sascha 2025-09-08 15:47:39 +02:00
parent 146a9494f5
commit cf609fce1c
13 changed files with 409 additions and 159 deletions

1
.gitignore vendored
View File

@ -20,3 +20,4 @@ pnpm-debug.log*
.vscode/ .vscode/
.idea/ .idea/
.DS_Store .DS_Store
.vercel

BIN
backend/.gitignore vendored

Binary file not shown.

View File

@ -14,7 +14,8 @@
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"helmet": "^7.1.0", "helmet": "^7.1.0",
"joi": "^17.11.0", "joi": "^17.11.0",
"nodemailer": "^6.9.7" "nodemailer": "^6.9.7",
"resend": "^6.0.2"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2"
@ -1044,6 +1045,23 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/resend": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.0.2.tgz",
"integrity": "sha512-um08qWpSVvEVqAePEy/bsa7pqtnJK+qTCZ0Et7YE7xuqM46J0C9gnSbIJKR3LIcRVMgO9jUeot8rH0UI84eqMQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@react-email/render": "^1.1.0"
},
"peerDependenciesMeta": {
"@react-email/render": {
"optional": true
}
}
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",

View File

@ -11,13 +11,14 @@
"test": "echo 'No tests yet'" "test": "echo 'No tests yet'"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2",
"nodemailer": "^6.9.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"helmet": "^7.1.0", "dotenv": "^16.3.1",
"express": "^4.18.2",
"express-rate-limit": "^7.1.5", "express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"joi": "^17.11.0", "joi": "^17.11.0",
"dotenv": "^16.3.1" "nodemailer": "^6.9.7",
"resend": "^6.0.2"
}, },
"devDependencies": { "devDependencies": {
"nodemon": "^3.0.2" "nodemon": "^3.0.2"

View File

@ -1,74 +1,93 @@
import express from 'express'; import express from 'express';
import rateLimit from 'express-rate-limit';
import { body, validationResult } from 'express-validator';
import { sendContactEmail } from '../services/emailService.js'; import { sendContactEmail } from '../services/emailService.js';
import { validateContactForm } from '../middleware/validation.js';
import { asyncHandler } from '../middleware/asyncHandler.js';
const router = express.Router(); const router = express.Router();
// POST /api/contact - Send contact form email // GDPR compliant rate limiting
router.post( const contactLimiter = rateLimit({
'/', windowMs: 15 * 60 * 1000, // 15 minutes
validateContactForm, max: 3, // Limit each IP to 3 requests per windowMs
asyncHandler(async (req, res) => { message: {
const { firstName, lastName, email, subject, message } = req.body; error: 'Too many contact requests from this IP, please try again later.',
retryAfter: 15 * 60,
// Check for honeypot (if present in body) },
if (req.body.website && req.body.website.trim() !== '') { standardHeaders: true,
console.warn('Spam detected: honeypot field filled', { legacyHeaders: false,
ip: req.ip,
email,
}); });
// GDPR compliant validation
const validateContactForm = [
body('firstName').trim().isLength({ min: 1, max: 50 }).escape(),
body('lastName').trim().isLength({ min: 1, max: 50 }).escape(),
body('email').trim().isEmail().normalizeEmail().isLength({ max: 254 }),
body('subject').trim().isLength({ min: 1, max: 100 }).escape(),
body('message').trim().isLength({ min: 10, max: 2000 }).escape(),
body('gdprConsent').equals('true').withMessage('GDPR consent required'),
body('website').optional().isEmpty(), // Honeypot
];
router.post('/', contactLimiter, validateContactForm, async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ return res.status(400).json({
success: false, success: false,
message: 'Invalid form submission detected.', message: 'Invalid form data.',
errors: errors.array(),
}); });
} }
try { const {
// Send the email firstName,
lastName,
email,
subject,
message,
gdprConsent,
website,
} = req.body;
// Honeypot check
if (website) {
console.warn('🚨 Spam detected:', req.ip);
return res.status(400).json({
success: false,
message: 'Invalid submission detected.',
});
}
// GDPR consent check
if (gdprConsent !== 'true') {
return res.status(400).json({
success: false,
message: 'GDPR consent required.',
});
}
// Send email with minimal logging for GDPR
await sendContactEmail({ await sendContactEmail({
firstName, firstName,
lastName, lastName,
email, email,
subject: subject || 'New Portfolio Contact', subject,
message, message,
senderIP: req.ip, senderIP: req.ip,
userAgent: req.get('User-Agent'), 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({ res.json({
success: true, success: true,
message: message: "Thank you for your message! I'll get back to you soon.",
"Your message has been sent successfully! I'll get back to you soon.",
}); });
} catch (error) { } catch (error) {
console.error('Failed to send contact email:', error); console.error('❌ Contact error:', error.message); // No personal data in logs
res.status(500).json({ res.status(500).json({
success: false, success: false,
message: message: 'Failed to send message. Please try again later.',
'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 }; export { router as contactRouter };

View File

@ -24,6 +24,9 @@ async function startServer() {
const app = express(); const app = express();
const PORT = process.env.PORT || 3001; const PORT = process.env.PORT || 3001;
// Trust proxy (required for Railway, Heroku, etc.)
app.set('trust proxy', 1);
// Security middleware // Security middleware
app.use( app.use(
helmet({ helmet({
@ -34,10 +37,14 @@ async function startServer() {
// CORS configuration // CORS configuration
app.use( app.use(
cors({ cors({
origin: process.env.FRONTEND_URL || 'http://localhost:5173', origin: [
methods: ['GET', 'POST'], 'http://localhost:5173',
allowedHeaders: ['Content-Type', 'Authorization'], 'http://localhost:3000',
'https://portfolio-page-zeta-ten.vercel.app',
],
credentials: true, credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
}) })
); );
@ -53,6 +60,11 @@ async function startServer() {
}, },
standardHeaders: true, standardHeaders: true,
legacyHeaders: false, legacyHeaders: false,
// Add this for better proxy support
skip: (req) => {
// Skip rate limiting for health checks
return req.path === '/health';
},
}); });
app.use('/api/', limiter); app.use('/api/', limiter);
@ -81,7 +93,7 @@ async function startServer() {
app.use(errorHandler); app.use(errorHandler);
// Start server // Start server
app.listen(PORT, () => { app.listen(PORT, '0.0.0.0', () => {
console.log(`🚀 Server is running on port ${PORT}`); console.log(`🚀 Server is running on port ${PORT}`);
console.log( console.log(
`📧 Email service: ${process.env.EMAIL_SERVICE || 'Not configured'}` `📧 Email service: ${process.env.EMAIL_SERVICE || 'Not configured'}`

View File

@ -1,45 +1,100 @@
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { createContactEmailTemplate } from '../templates/emailTemplates.js';
// Create email transporter // Create email transporter for custom SMTP server
const createTransporter = () => { const createTransporter = () => {
const config = { console.log('🔧 Creating custom SMTP transporter with config:', {
service: process.env.EMAIL_SERVICE, host: process.env.SMTP_HOST,
auth: { port: process.env.SMTP_PORT,
user: process.env.EMAIL_USER, secure: process.env.SMTP_SECURE === 'true',
pass: process.env.EMAIL_PASS, user: process.env.SMTP_USER ? 'Set' : 'Missing',
}, pass: process.env.SMTP_PASS ? 'Set' : 'Missing',
}; });
// If not using a predefined service, use custom SMTP settings if (
if (!process.env.EMAIL_SERVICE || process.env.EMAIL_SERVICE === 'custom') { !process.env.SMTP_HOST ||
delete config.service; !process.env.SMTP_USER ||
config.host = process.env.EMAIL_HOST; !process.env.SMTP_PASS
config.port = parseInt(process.env.EMAIL_PORT) || 587; ) {
config.secure = process.env.EMAIL_SECURE === 'true'; throw new Error('Custom SMTP credentials not configured');
} }
return nodemailer.createTransport(config); return nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: parseInt(process.env.SMTP_PORT) || 587,
secure: process.env.SMTP_SECURE === 'true', // true for 465 (SSL), false for 587 (TLS)
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
tls: {
rejectUnauthorized: false, // Accept self-signed certificates if needed
ciphers: 'SSLv3', // For older servers if needed
},
timeout: 30000,
connectionTimeout: 30000,
greetingTimeout: 10000,
socketTimeout: 30000,
debug: process.env.NODE_ENV === 'development',
logger: process.env.NODE_ENV === 'development',
});
}; };
// Verify email configuration // Verify email configuration
export const verifyEmailConfig = async () => { export const verifyEmailConfig = async () => {
try { try {
console.log('🔍 Debug - Creating transporter with config:'); console.log('🔍 Verifying custom SMTP configuration...');
console.log('EMAIL_SERVICE:', process.env.EMAIL_SERVICE); console.log('SMTP_HOST:', process.env.SMTP_HOST || 'Missing');
console.log('EMAIL_USER:', process.env.EMAIL_USER);
console.log( console.log(
'EMAIL_PASS length:', 'SMTP_PORT:',
process.env.EMAIL_PASS ? process.env.EMAIL_PASS.length : 'undefined' process.env.SMTP_PORT || 'Missing (default: 587)'
);
console.log(
'SMTP_SECURE:',
process.env.SMTP_SECURE || 'Missing (default: false)'
);
console.log('SMTP_USER:', process.env.SMTP_USER ? 'Set' : 'Missing');
console.log('SMTP_PASS:', process.env.SMTP_PASS ? 'Set' : 'Missing');
console.log('FROM_EMAIL:', process.env.FROM_EMAIL ? 'Set' : 'Missing');
console.log(
'RECIPIENT_EMAIL:',
process.env.RECIPIENT_EMAIL ? 'Set' : 'Missing'
); );
if (
!process.env.SMTP_HOST ||
!process.env.SMTP_USER ||
!process.env.SMTP_PASS
) {
console.error('❌ Missing required SMTP credentials');
return false;
}
if (!process.env.FROM_EMAIL || !process.env.RECIPIENT_EMAIL) {
console.error('❌ Missing sender or recipient email');
return false;
}
// Skip verification in production to avoid Railway timeout issues
if (process.env.NODE_ENV === 'production') {
console.log('🔧 Skipping SMTP verification in production environment');
return true;
}
// Test connection in development
const transporter = createTransporter(); const transporter = createTransporter();
await transporter.verify(); await transporter.verify();
console.log('✅ Email configuration verified successfully'); console.log('✅ SMTP configuration verified successfully');
return true; return true;
} catch (error) { } catch (error) {
console.error('❌ Email configuration verification failed:', error.message); console.error('❌ SMTP configuration verification failed:', error.message);
console.error('Full error:', error);
if (process.env.NODE_ENV === 'production') {
console.log(
'🔧 Continuing in production mode despite verification failure'
);
return true;
}
return false; return false;
} }
}; };
@ -54,66 +109,86 @@ export const sendContactEmail = async ({
senderIP, senderIP,
userAgent, userAgent,
}) => { }) => {
const startTime = Date.now();
console.log('📧 Starting email send process via custom SMTP...');
try {
const transporter = createTransporter(); const transporter = createTransporter();
const fullName = `${firstName} ${lastName}`; 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 = { const mailOptions = {
from: { from: {
name: fullName, name: 'Portfolio Contact Form',
address: process.env.EMAIL_USER, // Use your email as the sender address: process.env.FROM_EMAIL, // Your verified domain email
},
to: {
name: process.env.RECIPIENT_NAME || 'Portfolio Owner',
address: process.env.RECIPIENT_EMAIL,
}, },
to: process.env.RECIPIENT_EMAIL,
replyTo: { replyTo: {
name: fullName, name: fullName,
address: email, // This allows you to reply directly to the sender address: email,
},
subject: emailSubject,
html: emailTemplate.html,
text: emailTemplate.text,
headers: {
'X-Priority': '1',
'X-MSMail-Priority': 'High',
Importance: 'high',
}, },
subject: `[Portfolio] ${subject}`,
html: `
<h2>New Contact Form Submission</h2>
<p><strong>Name:</strong> ${fullName}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Subject:</strong> ${subject}</p>
<p><strong>Message:</strong></p>
<div style="background: #f5f5f5; padding: 15px; border-radius: 5px;">
${message.replace(/\n/g, '<br>')}
</div>
<hr>
<p><small>IP: ${senderIP || 'Unknown'}</small></p>
<p><small>User Agent: ${userAgent || 'Unknown'}</small></p>
<p><small>Sent: ${new Date().toISOString()}</small></p>
`,
text: `
New Contact Form Submission
Name: ${fullName}
Email: ${email}
Subject: ${subject}
Message:
${message}
---
IP: ${senderIP || 'Unknown'}
User Agent: ${userAgent || 'Unknown'}
Sent: ${new Date().toISOString()}
`,
}; };
// Send the email
try {
const result = await transporter.sendMail(mailOptions); const result = await transporter.sendMail(mailOptions);
console.log('Email sent successfully:', result.messageId);
const duration = Date.now() - startTime;
console.log(
`✅ Email sent successfully via custom SMTP in ${duration}ms:`,
result.messageId
);
return result; return result;
} catch (error) { } catch (error) {
console.error('Failed to send email:', error); const duration = Date.now() - startTime;
console.error(`❌ Custom SMTP email send failed after ${duration}ms:`, {
message: error.message,
code: error.code,
command: error.command,
});
throw new Error(`Email sending failed: ${error.message}`); throw new Error(`Email sending failed: ${error.message}`);
} }
}; };
// Initialize email service (verify configuration on startup) // Initialize email service
export const initializeEmailService = async () => { export const initializeEmailService = async () => {
console.log('🔧 Initializing email service...'); console.log('🔧 Initializing custom SMTP email service...');
const isValid = await verifyEmailConfig(); const isValid = await verifyEmailConfig();
if (!isValid) { if (!isValid) {
console.warn( console.warn('⚠️ Custom SMTP email service not properly configured.');
'⚠️ Email service not properly configured. Check your .env file.' } else {
); console.log('✅ Custom SMTP email service initialized successfully');
} }
return isValid; return isValid;

29
package-lock.json generated
View File

@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"express-validator": "^7.2.1",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",
@ -2603,6 +2604,19 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/express-validator": {
"version": "7.2.1",
"resolved": "https://registry.npmjs.org/express-validator/-/express-validator-7.2.1.tgz",
"integrity": "sha512-CjNE6aakfpuwGaHQZ3m8ltCG2Qvivd7RHtVMS/6nVxOM7xVGqr4bhflsm4+N5FP5zI7Zxp+Hae+9RE+o8e3ZOQ==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.21",
"validator": "~13.12.0"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -2987,6 +3001,12 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
"version": "4.6.2", "version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -3716,6 +3736,15 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/validator": {
"version": "13.12.0",
"resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz",
"integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.1.3", "version": "7.1.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.3.tgz",

View File

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"@types/react-router-dom": "^5.3.3", "@types/react-router-dom": "^5.3.3",
"express-validator": "^7.2.1",
"lucide-react": "^0.542.0", "lucide-react": "^0.542.0",
"react": "^19.1.1", "react": "^19.1.1",
"react-dom": "^19.1.1", "react-dom": "^19.1.1",

View File

@ -1,8 +1,8 @@
import { Mail, Github, Linkedin } from 'lucide-react'; import { Mail, Github, Linkedin } from 'lucide-react';
import { useState } from 'react'; import { useState, useRef } 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'; import { API_CONFIG } from '../../config/api';
interface ContactSectionProps { interface ContactSectionProps {
title?: string; title?: string;
@ -30,12 +30,18 @@ export default function ContactSection({
message: string; message: string;
}>({ type: null, message: '' }); }>({ type: null, message: '' });
// GDPR consent state
const [gdprConsent, setGdprConsent] = useState(false);
// Email obfuscation function using config // Email obfuscation function using config
const handleEmailClick = (e: React.MouseEvent) => { const handleEmailClick = (e: React.MouseEvent) => {
e.preventDefault(); e.preventDefault();
window.location.href = createEmailLink(); window.location.href = createEmailLink();
}; };
// Add form ref
const formRef = useRef<HTMLFormElement>(null);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
@ -50,6 +56,15 @@ export default function ContactSection({
return; return;
} }
// GDPR consent validation
if (!gdprConsent) {
setFormStatus({
type: 'error',
message: 'Please accept the privacy policy to continue.'
});
return;
}
// Prevent rapid submissions // Prevent rapid submissions
if (isSubmitting) { if (isSubmitting) {
return; return;
@ -66,6 +81,8 @@ export default function ContactSection({
email: formData.get('email') as string, email: formData.get('email') as string,
subject: formData.get('subject') as string, subject: formData.get('subject') as string,
message: formData.get('message') as string, message: formData.get('message') as string,
gdprConsent: gdprConsent.toString(), // Add this
website: honeypot, // Anti-spam honeypot
}; };
// Basic validation // Basic validation
@ -91,8 +108,7 @@ export default function ContactSection({
try { try {
// Submit to your own backend API // Submit to your own backend API
const apiUrl = `${getApiUrl()}${ENDPOINTS.contact}`; const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONTACT}`, {
const response = await fetch(apiUrl, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -107,14 +123,29 @@ export default function ContactSection({
}), }),
}); });
const result = await response.json(); // Check if response is ok before trying to parse JSON
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// Check if response has content before parsing JSON
const text = await response.text();
if (!text) {
throw new Error('Empty response from server');
}
const result = JSON.parse(text);
if (result.success) { if (result.success) {
setFormStatus({ setFormStatus({
type: 'success', type: 'success',
message: result.message message: result.message
}); });
e.currentTarget.reset();
// Reset form using ref
if (formRef.current) {
formRef.current.reset();
}
setHoneypot(''); // Reset honeypot setHoneypot(''); // Reset honeypot
// Clear success message after 5 seconds // Clear success message after 5 seconds
@ -266,7 +297,11 @@ export default function ContactSection({
</div> </div>
)} )}
<form className="contact-section__form" onSubmit={handleSubmit}> <form
ref={formRef}
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">
<label htmlFor="website" className="contact-section__honeypot-label"> <label htmlFor="website" className="contact-section__honeypot-label">
@ -354,10 +389,30 @@ export default function ContactSection({
></textarea> ></textarea>
</div> </div>
{/* GDPR Consent Checkbox */}
<div className="contact-section__form-group contact-section__form-group--checkbox">
<label className="contact-section__checkbox-label">
<input
type="checkbox"
checked={gdprConsent}
onChange={(e) => setGdprConsent(e.target.checked)}
className="contact-section__checkbox"
required
/>
<span className="contact-section__checkbox-text">
I consent to the processing of my personal data according to the{' '}
<a href="/privacy-policy" target="_blank" rel="noopener noreferrer">
Privacy Policy
</a>{' '}
(GDPR/DSGVO compliant) *
</span>
</label>
</div>
<button <button
type="submit" type="submit"
className="contact-section__form-button" className="contact-section__form-button"
disabled={isSubmitting} disabled={isSubmitting || !gdprConsent} // Disable if no consent
> >
{isSubmitting ? 'Sending...' : 'Send Message'} {isSubmitting ? 'Sending...' : 'Send Message'}
</button> </button>

View File

@ -1,22 +1,21 @@
// API configuration for different environments const isDevelopment = import.meta.env.DEV;
type Environment = 'development' | 'production';
const API_CONFIG: Record<Environment, { baseURL: string }> = { export const API_CONFIG = {
development: { BASE_URL: isDevelopment
baseURL: 'http://localhost:3002', ? 'http://localhost:3002'
}, : 'https://portfolio-backend-production-39b3.up.railway.app',
production: { ENDPOINTS: {
baseURL: 'https://your-backend-api.herokuapp.com', // Update with your actual backend URL CONTACT: '/api/contact',
}, },
}; };
export const getApiUrl = (): string => { // Export individual items for backward compatibility
const isDev = import.meta.env.DEV; export const ENDPOINTS = API_CONFIG.ENDPOINTS;
const environment: Environment = isDev ? 'development' : 'production';
return API_CONFIG[environment].baseURL; // Helper function to get full API URL
export const getApiUrl = (endpoint: string): string => {
return `${API_CONFIG.BASE_URL}${endpoint}`;
}; };
export const ENDPOINTS = { // Default export
contact: '/api/contact', export default API_CONFIG;
health: '/health',
};

View File

@ -0,0 +1,25 @@
export default function PrivacyPolicy() {
return (
<div className="privacy-policy">
<h1>Privacy Policy (GDPR/DSGVO)</h1>
<p>Last updated: {new Date().toLocaleDateString()}</p>
<h2>Data Collection</h2>
<p>We collect only the information you provide through our contact form:</p>
<ul>
<li>Name (first and last)</li>
<li>Email address</li>
<li>Message content</li>
</ul>
<h2>Purpose</h2>
<p>Your data is used solely to respond to your inquiry.</p>
<h2>Data Retention</h2>
<p>Your data is not stored permanently. Email content is processed and forwarded immediately.</p>
<h2>Your Rights</h2>
<p>You have the right to access, rectify, or delete your personal data. Contact: info@sascha-bach.de</p>
</div>
);
}

15
vercel.json Normal file
View File

@ -0,0 +1,15 @@
{
"buildCommand": "npm run build",
"outputDirectory": "dist",
"framework": "vite",
"rewrites": [
{
"source": "/privacy-policy",
"destination": "/privacy-policy.html"
},
{
"source": "/(.*)",
"destination": "/index.html"
}
]
}