Make contact form functional

This commit is contained in:
TheMaddax 2024-12-10 19:21:09 -06:00
parent d20a4f74ed
commit 185c70e76e
23 changed files with 2092 additions and 46 deletions

9
.env Normal file
View 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
View file

@ -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
View 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"]

View file

@ -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
View 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

View file

@ -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>

View file

@ -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;

View file

@ -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

File diff suppressed because it is too large Load diff

44
server.ts Normal file
View 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}`)
})

View file

@ -5,20 +5,24 @@ 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 (
<Router>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/services" element={<Services />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="/resources" element={<Resources />} />
</Routes>
</Layout>
</Router>
<NotificationProvider>
<Router>
<Layout>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/services" element={<Services />} />
<Route path="/about" element={<About />} />
<Route path="/contact" element={<Contact />} />
<Route path="/resources" element={<Resources />} />
</Routes>
</Layout>
</Router>
</NotificationProvider>
)
}
export default App

106
src/api/contact.ts Normal file
View 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
View 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'
})
}
}

View file

@ -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]

View 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>
)
}

View 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>
)
}

View 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
View 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
View 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)
}

View file

@ -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>

View file

@ -18,4 +18,7 @@ export default {
},
},
plugins: [],
corePlugins: {
columns: false // This will disable the column utilities
}
}

13
tsconfig.server.json Normal file
View 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"]
}

View file

@ -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
}
}
})