Make contact form functional
This commit is contained in:
parent
d20a4f74ed
commit
185c70e76e
23 changed files with 2092 additions and 46 deletions
9
.env
Normal file
9
.env
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=587
|
||||
SMTP_SECURE=false
|
||||
RECIPIENT_EMAIL=chris@sigd.net
|
||||
GOOGLE_EMAIL=system@deafgain.org
|
||||
GOOGLE_APP_PASSWORD=ojvlysraxwjriwzy
|
||||
UPSTASH_REDIS_REST_URL=https://gusc1-resolved-jaybird-30779.upstash.io
|
||||
UPSTASH_REDIS_REST_TOKEN=AXg7ASQgNDMzMDE0MTYtNWE0Ni00OTc3LThhYjktM2IxMzMxNGMyMzMxNWRiNGMyOGU2MjE0NGUyNTkwMTQyNmUxZTU2NzE4NDI=
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -10,12 +10,6 @@ coverage
|
|||
dist
|
||||
build
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
|
|
|
|||
19
Dockerfile.api
Normal file
19
Dockerfile.api
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
FROM node:20-alpine
|
||||
|
||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install
|
||||
|
||||
COPY server.ts ./
|
||||
COPY src/api ./src/api
|
||||
COPY src/lib ./src/lib
|
||||
COPY tsconfig.json tsconfig.server.json ./
|
||||
|
||||
RUN pnpm tsc -p tsconfig.server.json
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "dist/server.js"]
|
||||
|
|
@ -6,8 +6,8 @@ services:
|
|||
pull_policy: build
|
||||
ports:
|
||||
- "804:804"
|
||||
networks:
|
||||
- caddy_network
|
||||
depends_on:
|
||||
- api
|
||||
volumes:
|
||||
- type: bind
|
||||
source: /docker/websites/deafgain/video_content
|
||||
|
|
@ -16,7 +16,31 @@ services:
|
|||
- NODE_ENV=production
|
||||
- PORT=804
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- caddy_network
|
||||
- default
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_SECURE=${SMTP_SECURE}
|
||||
- RECIPIENT_EMAIL=${RECIPIENT_EMAIL}
|
||||
- GOOGLE_EMAIL=${GOOGLE_EMAIL}
|
||||
- GOOGLE_APP_PASSWORD=${GOOGLE_APP_PASSWORD}
|
||||
- UPSTASH_REDIS_REST_URL=${UPSTASH_REDIS_REST_URL}
|
||||
- UPSTASH_REDIS_REST_TOKEN=${UPSTASH_REDIS_REST_TOKEN}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- default
|
||||
|
||||
networks:
|
||||
default:
|
||||
driver: bridge
|
||||
caddy_network:
|
||||
external: true
|
||||
46
docker-compose.yml-backup
Normal file
46
docker-compose.yml-backup
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
services:
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
pull_policy: build
|
||||
ports:
|
||||
- "804:804"
|
||||
depends_on:
|
||||
- api
|
||||
volumes:
|
||||
- ./logs:/var/log/nginx
|
||||
- type: bind
|
||||
source: /docker/websites/deafgain/video_content
|
||||
target: /usr/share/nginx/html/videos
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=804
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- caddy_network
|
||||
|
||||
api:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.api
|
||||
volumes:
|
||||
- ./logs:/app/logs
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_SECURE=${SMTP_SECURE}
|
||||
- RECIPIENT_EMAIL=${RECIPIENT_EMAIL}
|
||||
- GOOGLE_EMAIL=${GOOGLE_EMAIL}
|
||||
- GOOGLE_APP_PASSWORD=${GOOGLE_APP_PASSWORD}
|
||||
- UPSTASH_REDIS_REST_URL=${UPSTASH_REDIS_REST_URL}
|
||||
- UPSTASH_REDIS_REST_TOKEN=${UPSTASH_REDIS_REST_TOKEN}
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- caddy_network
|
||||
|
||||
networks:
|
||||
caddy_network:
|
||||
external: true
|
||||
|
|
@ -3,7 +3,6 @@
|
|||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<link rel="preload" href="https://unpkg.com/react-icons/fa/index.js" as="script" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DeafGain LLC</title>
|
||||
</head>
|
||||
|
|
|
|||
39
nginx.conf
39
nginx.conf
|
|
@ -4,6 +4,16 @@ server {
|
|||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Production logging configuration
|
||||
access_log /var/log/nginx/api_access.log combined buffer=512k flush=1m;
|
||||
error_log /var/log/nginx/api_error.log error;
|
||||
|
||||
# Increased buffer sizes
|
||||
large_client_header_buffers 8 32k;
|
||||
client_header_buffer_size 32k;
|
||||
client_max_body_size 10M;
|
||||
client_body_buffer_size 128k;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN";
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
|
|
@ -15,6 +25,35 @@ server {
|
|||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
|
||||
|
||||
# API endpoint
|
||||
location /api/contact {
|
||||
proxy_pass http://api:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
access_log /var/log/nginx/api_access.log combined buffer=512k flush=1m;
|
||||
error_log /var/log/nginx/api_error.log error;
|
||||
|
||||
# CORS headers
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
|
||||
# Handle OPTIONS method
|
||||
if ($request_method = 'OPTIONS') {
|
||||
add_header 'Access-Control-Allow-Origin' '*';
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range';
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
add_header 'Content-Type' 'text/plain; charset=utf-8';
|
||||
add_header 'Content-Length' 0;
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
expires 1h;
|
||||
|
|
|
|||
12
package.json
12
package.json
|
|
@ -4,17 +4,23 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "concurrently \"vite\" \"tsx watch server.ts\"",
|
||||
"build": "ROLLUP_SKIP_NODE_RESOLVE=true tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-simple-maps": "^3.0.6",
|
||||
"@upstash/redis": "^1.28.4",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"framer-motion": "^11.13.5",
|
||||
"next": "^14.1.0",
|
||||
"nodemailer": "^6.9.9",
|
||||
"postcss": "^8.4.49",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
|
|
@ -28,12 +34,16 @@
|
|||
"devDependencies": {
|
||||
"@eslint/js": "^9.15.0",
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^9.15.0",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.13.0",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript-eslint": "^8.18.0"
|
||||
},
|
||||
|
|
|
|||
1376
pnpm-lock.yaml
generated
1376
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
44
server.ts
Normal file
44
server.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import express from 'express'
|
||||
import dotenv from 'dotenv'
|
||||
import { POST as handleContact } from './src/api/contact.js'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { dirname, join } from 'path'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const app = express()
|
||||
app.use(express.json())
|
||||
|
||||
app.use((req, res, next) => {
|
||||
res.header('Access-Control-Allow-Origin', '*')
|
||||
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept')
|
||||
res.header('Access-Control-Allow-Methods', 'POST, GET, OPTIONS')
|
||||
next()
|
||||
})
|
||||
|
||||
app.use(express.static(join(__dirname, 'dist')))
|
||||
|
||||
app.post('/api/contact', async (req, res) => {
|
||||
try {
|
||||
const response = await handleContact(req)
|
||||
res.status(response.status).json(response.body)
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
message: 'Internal server error'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(join(__dirname, 'dist', 'index.html'))
|
||||
})
|
||||
|
||||
const PORT = process.env.PORT || 804
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.info(`Server running on port ${PORT}`)
|
||||
})
|
||||
|
|
@ -5,9 +5,11 @@ import Services from './pages/Services'
|
|||
import About from './pages/About'
|
||||
import Contact from './pages/Contact'
|
||||
import Resources from './pages/Resources'
|
||||
import { NotificationProvider } from './context/NotificationProvider'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<NotificationProvider>
|
||||
<Router>
|
||||
<Layout>
|
||||
<Routes>
|
||||
|
|
@ -19,6 +21,8 @@ function App() {
|
|||
</Routes>
|
||||
</Layout>
|
||||
</Router>
|
||||
</NotificationProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
106
src/api/contact.ts
Normal file
106
src/api/contact.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
import { Request } from 'express'
|
||||
import { rateLimit } from '../lib/rate-limit.js'
|
||||
import { sendEmail } from '../lib/email.js'
|
||||
|
||||
interface FormData {
|
||||
name: string
|
||||
email: string
|
||||
service: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const validateEmail = (email: string): boolean => {
|
||||
const pattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
|
||||
return pattern.test(email)
|
||||
}
|
||||
|
||||
const validateInput = (data: FormData): { isValid: boolean; error?: string } => {
|
||||
if (!data.name || data.name.length < 2 || data.name.length > 50) {
|
||||
return { isValid: false, error: 'Invalid name length' }
|
||||
}
|
||||
|
||||
if (!data.email || !validateEmail(data.email)) {
|
||||
return { isValid: false, error: 'Invalid email format' }
|
||||
}
|
||||
|
||||
if (!data.service) {
|
||||
return { isValid: false, error: 'Service selection required' }
|
||||
}
|
||||
|
||||
if (!data.message || data.message.length < 10 || data.message.length > 1000) {
|
||||
return { isValid: false, error: 'Invalid message length' }
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
const sanitizeInput = (input: string): string => {
|
||||
return input
|
||||
.trim()
|
||||
.replace(/[<>]/g, '')
|
||||
.slice(0, 1000)
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
if (!req.body) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
success: false,
|
||||
message: 'Request body is missing'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rateLimitResult = await rateLimit(req)
|
||||
if (!rateLimitResult.success) {
|
||||
return {
|
||||
status: 429,
|
||||
body: {
|
||||
success: false,
|
||||
message: 'Too many requests. Please try again later.'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const data: FormData = req.body
|
||||
const validation = validateInput(data)
|
||||
|
||||
if (!validation.isValid) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
success: false,
|
||||
message: validation.error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizedData = {
|
||||
name: sanitizeInput(data.name),
|
||||
email: sanitizeInput(data.email),
|
||||
service: sanitizeInput(data.service),
|
||||
message: sanitizeInput(data.message)
|
||||
}
|
||||
|
||||
await sendEmail(sanitizedData)
|
||||
|
||||
return {
|
||||
status: 200,
|
||||
body: {
|
||||
success: true,
|
||||
message: 'Message sent successfully'
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
success: false,
|
||||
message: 'Failed to send message. Please try again later.'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
113
src/api/route.ts
Normal file
113
src/api/route.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import { Request, Response } from 'express'
|
||||
import nodemailer from 'nodemailer'
|
||||
import { rateLimit } from '../lib/rate-limit'
|
||||
|
||||
interface FormData {
|
||||
name: string
|
||||
email: string
|
||||
service: string
|
||||
message: string
|
||||
}
|
||||
|
||||
const validateEmail = (email: string): boolean => {
|
||||
const pattern = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
|
||||
return pattern.test(email)
|
||||
}
|
||||
|
||||
const validateInput = (data: FormData): { isValid: boolean; error?: string } => {
|
||||
if (!data.name || data.name.length < 2 || data.name.length > 50) {
|
||||
return { isValid: false, error: 'Invalid name length' }
|
||||
}
|
||||
|
||||
if (!data.email || !validateEmail(data.email)) {
|
||||
return { isValid: false, error: 'Invalid email format' }
|
||||
}
|
||||
|
||||
if (!data.service) {
|
||||
return { isValid: false, error: 'Service selection required' }
|
||||
}
|
||||
|
||||
if (!data.message || data.message.length < 10 || data.message.length > 1000) {
|
||||
return { isValid: false, error: 'Invalid message length' }
|
||||
}
|
||||
|
||||
return { isValid: true }
|
||||
}
|
||||
|
||||
const sanitizeInput = (input: string): string => {
|
||||
return input
|
||||
.trim()
|
||||
.replace(/[<>]/g, '')
|
||||
.slice(0, 1000)
|
||||
}
|
||||
|
||||
export async function POST(req: Request, res: Response) {
|
||||
try {
|
||||
const rateLimitResult = await rateLimit(req)
|
||||
if (!rateLimitResult.success) {
|
||||
return res.status(429).json({
|
||||
success: false,
|
||||
message: 'Too many requests. Please try again later.'
|
||||
})
|
||||
}
|
||||
|
||||
const data: FormData = req.body
|
||||
|
||||
const validation = validateInput(data)
|
||||
if (!validation.isValid) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
message: validation.error
|
||||
})
|
||||
}
|
||||
|
||||
const sanitizedData = {
|
||||
name: sanitizeInput(data.name),
|
||||
email: sanitizeInput(data.email),
|
||||
service: sanitizeInput(data.service),
|
||||
message: sanitizeInput(data.message)
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.GOOGLE_EMAIL,
|
||||
pass: process.env.GOOGLE_APP_PASSWORD,
|
||||
},
|
||||
})
|
||||
|
||||
const mailOptions = {
|
||||
from: process.env.GOOGLE_EMAIL,
|
||||
to: process.env.RECIPIENT_EMAIL,
|
||||
subject: `New Contact Form Submission - ${sanitizedData.service}`,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #2563eb;">New Contact Form Submission</h2>
|
||||
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px;">
|
||||
<p><strong>Name:</strong> ${sanitizedData.name}</p>
|
||||
<p><strong>Email:</strong> ${sanitizedData.email}</p>
|
||||
<p><strong>Service:</strong> ${sanitizedData.service}</p>
|
||||
<p><strong>Message:</strong></p>
|
||||
<p style="white-space: pre-wrap;">${sanitizedData.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
await transporter.sendMail(mailOptions)
|
||||
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
message: 'Email sent successfully'
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error)
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
message: 'Failed to send email'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ const VideoPlayer = ({ src, poster, vttSrc }: VideoPlayerProps) => {
|
|||
const [playbackRate, setPlaybackRate] = useState(1)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>()
|
||||
const timeoutRef = useRef<ReturnType<typeof setTimeout>>(null)
|
||||
|
||||
const playbackRates = [0.5, 1, 2, 3]
|
||||
|
||||
|
|
|
|||
48
src/components/shared/Toast.tsx
Normal file
48
src/components/shared/Toast.tsx
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { FaTimes } from 'react-icons/fa'
|
||||
|
||||
interface ToastProps {
|
||||
message: string
|
||||
type: 'success' | 'error'
|
||||
isVisible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function Toast({ message, type, isVisible, onClose }: ToastProps) {
|
||||
const bgColor = type === 'success'
|
||||
? 'from-primary to-secondary'
|
||||
: 'from-red-600 to-red-800'
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="w-full max-w-md bg-white rounded-xl shadow-2xl overflow-hidden mx-4"
|
||||
>
|
||||
<div className={`h-2 bg-gradient-to-r ${bgColor}`} />
|
||||
<div className="p-6 relative">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-2 right-2 text-accent-mountain hover:text-secondary p-2 rounded-full transition-colors"
|
||||
>
|
||||
<FaTimes size={24} />
|
||||
</button>
|
||||
<p className="text-lg font-medium text-accent-mountain text-center mt-2">{message}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
)
|
||||
}
|
||||
32
src/context/NotificationProvider.tsx
Normal file
32
src/context/NotificationProvider.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
'use client'
|
||||
import { useState } from 'react'
|
||||
import Toast from "../components/shared/Toast"
|
||||
import { NotificationContext } from './notification-context'
|
||||
|
||||
export function NotificationProvider({ children }: { children: React.ReactNode }) {
|
||||
const [message, setMessage] = useState('')
|
||||
const [type, setType] = useState<'success' | 'error'>('success')
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
const showNotification = (message: string, type: 'success' | 'error') => {
|
||||
setMessage(message)
|
||||
setType(type)
|
||||
setIsVisible(true)
|
||||
}
|
||||
|
||||
const hideNotification = () => {
|
||||
setIsVisible(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={{ showNotification, hideNotification }}>
|
||||
{children}
|
||||
<Toast
|
||||
message={message}
|
||||
type={type}
|
||||
isVisible={isVisible}
|
||||
onClose={hideNotification}
|
||||
/>
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
13
src/context/notification-context.ts
Normal file
13
src/context/notification-context.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { createContext, useContext } from 'react'
|
||||
|
||||
interface NotificationContextType {
|
||||
showNotification: (message: string, type: 'success' | 'error') => void
|
||||
hideNotification: () => void
|
||||
}
|
||||
|
||||
export const NotificationContext = createContext<NotificationContextType>({
|
||||
showNotification: () => {},
|
||||
hideNotification: () => {}
|
||||
})
|
||||
|
||||
export const useNotification = () => useContext(NotificationContext)
|
||||
41
src/lib/email.ts
Normal file
41
src/lib/email.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import nodemailer from 'nodemailer'
|
||||
|
||||
interface EmailData {
|
||||
name: string
|
||||
email: string
|
||||
service: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export async function sendEmail(data: EmailData) {
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: process.env.SMTP_HOST,
|
||||
port: Number(process.env.SMTP_PORT),
|
||||
secure: process.env.SMTP_SECURE === 'true',
|
||||
auth: {
|
||||
user: process.env.GOOGLE_EMAIL,
|
||||
pass: process.env.GOOGLE_APP_PASSWORD,
|
||||
},
|
||||
})
|
||||
|
||||
const mailOptions = {
|
||||
from: process.env.GOOGLE_EMAIL,
|
||||
to: process.env.RECIPIENT_EMAIL,
|
||||
subject: `New Contact Form Submission - ${data.service}`,
|
||||
html: `
|
||||
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
|
||||
<h2 style="color: #2563eb;">New Contact Form Submission</h2>
|
||||
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px;">
|
||||
<p><strong>Name:</strong> ${data.name}</p>
|
||||
<p><strong>Email:</strong> ${data.email}</p>
|
||||
<p><strong>Service:</strong> ${data.service}</p>
|
||||
<p><strong>Message:</strong></p>
|
||||
<p style="white-space: pre-wrap;">${data.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}
|
||||
|
||||
const info = await transporter.sendMail(mailOptions)
|
||||
return info
|
||||
}
|
||||
62
src/lib/rate-limit.ts
Normal file
62
src/lib/rate-limit.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { Redis } from '@upstash/redis'
|
||||
import { Request } from 'express'
|
||||
|
||||
export interface RateLimitConfig {
|
||||
interval: number
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
success: boolean
|
||||
limit: number
|
||||
remaining: number
|
||||
reset: number
|
||||
}
|
||||
|
||||
export class RateLimit {
|
||||
private redis: Redis
|
||||
private prefix: string
|
||||
|
||||
constructor() {
|
||||
this.redis = new Redis({
|
||||
url: process.env.UPSTASH_REDIS_REST_URL || '',
|
||||
token: process.env.UPSTASH_REDIS_REST_TOKEN || ''
|
||||
})
|
||||
this.prefix = 'ratelimit'
|
||||
}
|
||||
|
||||
private getIP(req: Request): string {
|
||||
return req.ip || req.socket.remoteAddress || 'unknown'
|
||||
}
|
||||
|
||||
private getKey(identifier: string): string {
|
||||
return `${this.prefix}:${identifier}`
|
||||
}
|
||||
|
||||
async check(req: Request, config: RateLimitConfig): Promise<RateLimitResult> {
|
||||
const ip = this.getIP(req)
|
||||
const key = this.getKey(ip)
|
||||
const now = Math.floor(Date.now() / 1000)
|
||||
const windowStart = now - (now % config.interval)
|
||||
const windowKey = `${key}:${windowStart}`
|
||||
|
||||
const pipeline = this.redis.pipeline()
|
||||
pipeline.incr(windowKey)
|
||||
pipeline.expire(windowKey, config.interval)
|
||||
|
||||
const [count] = await pipeline.exec()
|
||||
const remaining = Math.max(0, config.limit - (count as number))
|
||||
|
||||
return {
|
||||
success: (count as number) <= config.limit,
|
||||
limit: config.limit,
|
||||
remaining,
|
||||
reset: windowStart + config.interval
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function rateLimit(req: Request, config: RateLimitConfig = { interval: 60 * 60, limit: 5 }): Promise<RateLimitResult> {
|
||||
const limiter = new RateLimit()
|
||||
return limiter.check(req, config)
|
||||
}
|
||||
|
|
@ -1,18 +1,50 @@
|
|||
import { useState } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { FaEnvelope, FaMapMarkerAlt } from 'react-icons/fa'
|
||||
import { useNotification } from '../context/notification-context'
|
||||
|
||||
const Contact = () => {
|
||||
const { showNotification } = useNotification()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
subject: '',
|
||||
service: '',
|
||||
message: ''
|
||||
})
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
// Form submission logic will be implemented here
|
||||
|
||||
if (isSubmitting) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (data.success) {
|
||||
showNotification('Message sent successfully!', 'success')
|
||||
setFormData({ name: '', email: '', service: '', message: '' })
|
||||
} else {
|
||||
showNotification(data.message || 'Failed to send message', 'error')
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'An error occurred. Please try again later.'
|
||||
showNotification(errorMessage, 'error')
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
setIsSubmitting(false)
|
||||
}, 1000)
|
||||
}
|
||||
}
|
||||
|
||||
const contactInfo = [
|
||||
|
|
@ -34,6 +66,7 @@ const Contact = () => {
|
|||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.8 }}
|
||||
className="text-center mb-16"
|
||||
>
|
||||
<h1 className="text-4xl font-bold text-secondary mb-4">Get in Touch</h1>
|
||||
|
|
@ -74,6 +107,8 @@ const Contact = () => {
|
|||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-accent-lake rounded-lg focus:outline-none focus:border-primary"
|
||||
required
|
||||
minLength={2}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -88,21 +123,27 @@ const Contact = () => {
|
|||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-accent-lake rounded-lg focus:outline-none focus:border-primary"
|
||||
required
|
||||
pattern="[^@\s]+@[^@\s]+\.[^@\s]+"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<label htmlFor="subject" className="block text-accent-mountain mb-2">
|
||||
Subject
|
||||
<label htmlFor="service" className="block text-accent-mountain mb-2">
|
||||
Service Interested In
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="subject"
|
||||
value={formData.subject}
|
||||
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
|
||||
<select
|
||||
id="service"
|
||||
value={formData.service}
|
||||
onChange={(e) => setFormData({ ...formData, service: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-accent-lake rounded-lg focus:outline-none focus:border-primary"
|
||||
required
|
||||
/>
|
||||
>
|
||||
<option value="">Select a service</option>
|
||||
<option value="ADA-Compliant Transcripts">ADA-Compliant Transcripts</option>
|
||||
<option value="Universal Website Design">Universal Website Design</option>
|
||||
<option value="Board Communication">Board Communication</option>
|
||||
<option value="Training & Workshops">Training & Workshops</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
|
|
@ -115,6 +156,8 @@ const Contact = () => {
|
|||
onChange={(e) => setFormData({ ...formData, message: e.target.value })}
|
||||
className="w-full px-4 py-2 border border-accent-lake rounded-lg focus:outline-none focus:border-primary h-32"
|
||||
required
|
||||
minLength={10}
|
||||
maxLength={1000}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -122,9 +165,14 @@ const Contact = () => {
|
|||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
type="submit"
|
||||
className="w-full bg-primary text-accent-snow py-3 rounded-lg font-semibold hover:bg-secondary transition-colors"
|
||||
disabled={isSubmitting}
|
||||
className={`w-full py-3 rounded-lg font-semibold transition-colors ${
|
||||
isSubmitting
|
||||
? 'bg-accent-mountain text-accent-snow cursor-not-allowed'
|
||||
: 'bg-primary text-accent-snow hover:bg-secondary'
|
||||
}`}
|
||||
>
|
||||
Send Message
|
||||
{isSubmitting ? 'Sending...' : 'Send Message'}
|
||||
</motion.button>
|
||||
</form>
|
||||
</motion.div>
|
||||
|
|
|
|||
|
|
@ -18,4 +18,7 @@ export default {
|
|||
},
|
||||
},
|
||||
plugins: [],
|
||||
corePlugins: {
|
||||
columns: false // This will disable the column utilities
|
||||
}
|
||||
}
|
||||
|
|
|
|||
13
tsconfig.server.json
Normal file
13
tsconfig.server.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"allowJs": true,
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["server.ts"]
|
||||
}
|
||||
|
|
@ -12,5 +12,26 @@ export default defineConfig({
|
|||
port: 804,
|
||||
host: true,
|
||||
strictPort: true
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:804',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
},
|
||||
define: {
|
||||
'process.env': {
|
||||
SMTP_HOST: process.env.SMTP_HOST,
|
||||
SMTP_PORT: process.env.SMTP_PORT,
|
||||
SMTP_SECURE: process.env.SMTP_SECURE,
|
||||
RECIPIENT_EMAIL: process.env.RECIPIENT_EMAIL,
|
||||
GOOGLE_EMAIL: process.env.GOOGLE_EMAIL,
|
||||
GOOGLE_APP_PASSWORD: process.env.GOOGLE_APP_PASSWORD,
|
||||
UPSTASH_REDIS_REST_URL: process.env.UPSTASH_REDIS_REST_URL,
|
||||
UPSTASH_REDIS_REST_TOKEN: process.env.UPSTASH_REDIS_REST_TOKEN
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
Add table
Reference in a new issue