Add contact form function.
This commit is contained in:
parent
1de3d8e6e7
commit
c39306da9e
15 changed files with 1106 additions and 603 deletions
|
|
@ -1,4 +1,7 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.env*
|
||||
.env.local
|
||||
.env.development
|
||||
external-code/
|
||||
|
||||
|
|
|
|||
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=mark@finlion.com
|
||||
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=
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -1,6 +1,6 @@
|
|||
node_modules/
|
||||
.next/
|
||||
.env*
|
||||
.env.*
|
||||
.DS_Store
|
||||
*.pem
|
||||
npm-debug.log*
|
||||
|
|
|
|||
200
ROADMAP
200
ROADMAP
|
|
@ -1,144 +1,82 @@
|
|||
This is a portfolio website for a person named Mark Apodaca, MBA, PRP. He is a solo person who provide different services related to finances and consulting on nonprofit organizations.
|
||||
# Contact Form Implementation Guide
|
||||
|
||||
Those are his services:
|
||||
## Directory Structure
|
||||
/src
|
||||
/lib
|
||||
- email.ts
|
||||
- rate-limit.ts
|
||||
/components
|
||||
/shared
|
||||
- ContactModal.tsx
|
||||
- Toast.tsx
|
||||
/providers
|
||||
- notification-provider.tsx
|
||||
/app
|
||||
/api
|
||||
/contact
|
||||
- route.ts
|
||||
|
||||
Accounting and bookkeeping services using QuickBooks and Xero.
|
||||
Income tax services
|
||||
Financial statement analysis
|
||||
Financial budgeting and estimating
|
||||
Financial planning and analysis
|
||||
Financial modeling
|
||||
Business plans
|
||||
Business models
|
||||
Business analysis
|
||||
Strategic planning
|
||||
Small business consulting
|
||||
Nonprofit organization consulting
|
||||
## Step-by-Step Implementation
|
||||
|
||||
### 1. Create Directory Structure
|
||||
mkdir -p src/lib
|
||||
mkdir -p src/components/shared
|
||||
mkdir -p src/components/providers
|
||||
mkdir -p src/app/api/contact
|
||||
|
||||
I want to use this following as the frontend for the website:
|
||||
### 2. Install Dependencies
|
||||
npm install nodemailer @upstash/redis framer-motion lucide-react
|
||||
|
||||
Next.js 14
|
||||
TypeScript
|
||||
Tailwind CSS
|
||||
Lucide Icons
|
||||
### 3. Environment Variables
|
||||
Create .env file with:
|
||||
|
||||
There are no expected administration management or any database server needed for this website.
|
||||
SMTP_HOST=smtp.gmail.com
|
||||
SMTP_PORT=465
|
||||
SMTP_SECURE=true
|
||||
GOOGLE_EMAIL=your-email@gmail.com
|
||||
GOOGLE_APP_PASSWORD=your-app-password
|
||||
RECIPIENT_EMAIL=recipient@example.com
|
||||
UPSTASH_REDIS_REST_URL=your-redis-url
|
||||
UPSTASH_REDIS_REST_TOKEN=your-redis-token
|
||||
|
||||
There are two images that will be used for this website.
|
||||
### 4. Integration Steps
|
||||
1. Copy all required files from source project
|
||||
2. Update layout.tsx to include NotificationProvider
|
||||
3. Configure email settings
|
||||
4. Set up Redis connection
|
||||
5. Test implementation
|
||||
|
||||
First image is a photo of Mark as mark.jpg. Here is the description of the image and suggested idea for the website design:
|
||||
### 5. Testing Checklist
|
||||
- Email Sending
|
||||
- Test with valid email credentials
|
||||
- Verify email receipt
|
||||
- Check HTML formatting
|
||||
|
||||
Subject Description: The image features a professional man, seated at a desk, with an approachable and confident demeanor. He is middle-aged with a balding head, gray and dark hair on the sides, and a neatly trimmed beard and mustache. He wears rectangular black-framed glasses, which contribute to a professional and studious appearance. He has a friendly smile and rests his hands lightly clasped on the desk in front of him.
|
||||
- Rate Limiting
|
||||
- Test multiple submissions
|
||||
- Verify Redis connection
|
||||
- Check rate limit messages
|
||||
|
||||
Clothing and Accessories: He is dressed in a pale blue dress shirt with a bright red tie that has a subtle pattern, giving him a polished and formal look. Around his neck is a lanyard that appears to have the words "SCHOOL FOR" written on it, likely representing an affiliation or prior role. On his left hand, he wears a prominent gold ring with intricate detailing, suggesting achievement or membership, such as a class ring or a professional association. His wristwatch on the left hand is dark with a clean, understated design.
|
||||
- Form Validation
|
||||
- Test all form fields
|
||||
- Verify error messages
|
||||
- Check input sanitization
|
||||
|
||||
Background and Environment: The setting is an office with a professional but cozy atmosphere. Behind him are organized office shelves with binders, some labeled and standing upright. These folders add to the impression of a well-organized and methodical work ethic. The desk in front of him has a polished wood surface with a few visible items: a white and red document or calendar, adding a functional element, and part of a phone on the left edge of the image.
|
||||
- Notifications
|
||||
- Test success messages
|
||||
- Test error messages
|
||||
- Verify toast timing
|
||||
|
||||
Lighting and Composition: The room is well-lit, with soft natural light coming from a window with horizontal white blinds visible in the background. The lighting casts no harsh shadows, creating an inviting and professional tone. The subject is centered in the frame, with the focus clearly on him and his upper body.
|
||||
### 6. Debugging Guide
|
||||
- Check environment variables are properly set
|
||||
- Verify Redis connection in logs
|
||||
- Test email credentials separately
|
||||
- Monitor Network tab in browser dev tools
|
||||
- Check server logs for errors
|
||||
- Verify CORS settings if needed
|
||||
|
||||
Tone and Impression: The overall tone of the image is warm, approachable, and professional, reflecting a balance of trustworthiness and competence. This impression aligns well with the services of financial consulting and supporting nonprofit organizations, as it suggests reliability, experience, and attention to detail.
|
||||
|
||||
Second image is a logo of Mark's business and here is the visual description:
|
||||
|
||||
Text and Typography: The logo prominently features the word "FINLION" in bold, capitalized serif letters. The font style is classic and professional, with slight serifs on the edges of each letter, giving it a formal and authoritative look. The letters are evenly spaced, making the text easily readable.
|
||||
|
||||
Color Scheme: The entire logo is rendered in a deep, rich blue color. This color conveys trustworthiness, professionalism, and stability, traits often associated with financial services.
|
||||
|
||||
Graphic Element: Integrated within the word "FINLION" is a stylized depiction of a lion’s head, replacing the "O" in "LION." The lion's face is symmetrical, with sharp, clean lines. The eyes are small and almond-shaped, giving the lion a focused and determined expression. The nose is angular, and the mouth is minimalistic, further emphasizing the lion's confident and bold demeanor. Surrounding the face is a flowing mane, stylized with sharp, dynamic strokes that radiate outward, creating a sense of movement and energy. The mane adds a modern and powerful touch to the logo.
|
||||
|
||||
Trademark Symbol: To the lower right of the "N," a small ® (registered trademark symbol) is visible, indicating that this logo is legally trademarked.
|
||||
|
||||
Overall Design: The combination of the bold, serif text and the strong, dynamic lion graphic conveys a message of strength, leadership, and reliability. The lion, a universal symbol of courage and power, is fitting for a brand associated with financial consulting and nonprofit services, as it implies trust and guidance.
|
||||
|
||||
For a website designed for Mark Apodaca’s services, consider integrating these elements:
|
||||
|
||||
1. Color Palette: Build around the deep blue of the logo, pairing it with neutral tones like white and light gray for backgrounds and accents or accents of red or gold to mirror his tie and ring.
|
||||
2. Typography: Use a serif font similar to the logo for headings to maintain brand consistency, paired with a clean sans-serif font for body text.
|
||||
3. Iconography: Use lion-inspired imagery or subtle mane-like patterns for decorative elements to echo the logo's theme.
|
||||
4. Imagery Style: Include warm, welcoming photos and emphasize neat organization, reflecting the details in the office environment.
|
||||
4. Emphasis: Highlight the lion's strength and elegance in the design to reflect the brand’s values of leadership and reliability.
|
||||
5. Navigation and Layout: Keep the design structured and easy to navigate, with sections that highlight his expertise, services, and contact information. Use soft contrasts to ensure accessibility.
|
||||
|
||||
Here is a third image that will not be on the website but is to provide information about the previous website that is a screenshot. Here is the visual description:
|
||||
|
||||
Layout Overview:
|
||||
|
||||
The screenshot displays a relatively simple and traditional website layout with a fixed-width design. It features a two-column structure:
|
||||
|
||||
Left Column: Contains navigation and a welcome text.
|
||||
Right Column: Includes a header with the company logo and a tagline, along with an image of professionals in a collaborative setting.
|
||||
|
||||
Header Section:
|
||||
|
||||
Logo:
|
||||
The top-right corner of the website prominently displays the FINLION logo in blue, as described earlier.
|
||||
Below the logo is a tagline in smaller, italicized font:
|
||||
"A business & financial consulting firm since 1978."
|
||||
This adds credibility by emphasizing the firm's longstanding history.
|
||||
|
||||
Background Bar:
|
||||
A thin horizontal gray bar runs beneath the header, creating separation between the logo/tagline and the main body of the webpage.
|
||||
|
||||
Navigation Menu:
|
||||
|
||||
The left column contains a vertical navigation menu, styled as a series of blue buttons with white text. The menu includes the following links:
|
||||
|
||||
Overview
|
||||
Services
|
||||
Benefits
|
||||
Articles
|
||||
Contact
|
||||
|
||||
The blue-and-white color combination maintains a professional appearance and aligns with the branding established by the logo.
|
||||
Main Content Area:
|
||||
|
||||
The right column is divided into text and an image section:
|
||||
|
||||
Welcome Text:
|
||||
A heading in bold blue text reads:
|
||||
"Welcome to FINLION!"
|
||||
Below the heading is a paragraph in black text that provides an introduction to the firm:
|
||||
|
||||
FINLION has been serving the needs of individuals and small businesses since 1978. Originally formed as an income tax practice in Los Angeles, California, the firm specializes in business startups, accounting and income taxes, financial management, and personal financial planning. Feel welcome to explore this website and if you have any questions or would like to utilize FINLION’s services, feel free to contact us.
|
||||
|
||||
Bullet Point Links:
|
||||
|
||||
Beneath the paragraph, there are four clickable text links in gold:
|
||||
What Makes Great Organizations?
|
||||
Focus
|
||||
Proficiency
|
||||
The Power of Planning
|
||||
|
||||
These links appear to direct users to additional resources or articles.
|
||||
|
||||
Image Section:
|
||||
|
||||
To the right of the welcome text, there is a photo of three professionals working together:
|
||||
|
||||
The setting is an office environment with a bright, neutral background.
|
||||
Two women and one man are gathered around a laptop, appearing engaged in discussion. This imagery reinforces themes of collaboration and professionalism.
|
||||
|
||||
Footer:
|
||||
|
||||
At the bottom of the page, in small black text, there is a copyright notice:
|
||||
|
||||
"© 1999-2010, FINLION. All rights reserved."
|
||||
Below this, there is a credit to the website producer:
|
||||
"Produced by Design Actually, LLC."
|
||||
The text "Design Actually, LLC" is a clickable link, suggesting a reference to the original web design firm.
|
||||
|
||||
Design Notes:
|
||||
|
||||
The overall color scheme uses blue, white, gray, and gold, consistent with the logo's professional aesthetic.
|
||||
The website feels clean but slightly outdated, as it follows a late-1990s to early-2000s design approach with a heavy focus on text and simple structure.
|
||||
The layout is functional but not responsive, likely designed for desktop viewing rather than mobile devices.
|
||||
|
||||
Suggestions for Modernizing:
|
||||
|
||||
Responsive Design: Ensure the website is optimized for both desktop and mobile platforms with a flexible layout.
|
||||
Typography Update: Use more modern fonts with better readability and a mix of sans-serif for headings and body text.
|
||||
Improved Visuals: Replace static professional imagery with dynamic, authentic visuals that represent Mark’s services.
|
||||
Streamlined Navigation: Simplify the navigation menu for user-friendly access, such as dropdowns or a sticky top menu.
|
||||
Call to Action (CTA): Add prominent buttons for quick contact or consultation requests on the homepage.
|
||||
Brand Consistency: Retain the blue, white, and gold color scheme but integrate it with contemporary UI elements like gradients or subtle animations.
|
||||
### 7. Security Considerations
|
||||
- Input sanitization is implemented
|
||||
- Rate limiting prevents spam
|
||||
- Email credentials are secured
|
||||
- Form validation prevents injection
|
||||
|
|
@ -13,4 +13,10 @@ services:
|
|||
- /app/.next
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
- SMTP_HOST=smtp.gmail.com
|
||||
- SMTP_PORT=587
|
||||
- SMTP_SECURE=false
|
||||
- GOOGLE_EMAIL=${GOOGLE_EMAIL}
|
||||
- GOOGLE_APP_PASSWORD=${GOOGLE_APP_PASSWORD}
|
||||
- RECIPIENT_EMAIL=${RECIPIENT_EMAIL}
|
||||
command: pnpm dev
|
||||
|
|
|
|||
|
|
@ -8,14 +8,14 @@ services:
|
|||
pull_policy: build
|
||||
ports:
|
||||
- "805:805"
|
||||
networks:
|
||||
- caddy_network
|
||||
# networks:
|
||||
# - caddy_network
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=805
|
||||
- VERSION=1.2
|
||||
restart: unless-stopped
|
||||
|
||||
networks:
|
||||
caddy_network:
|
||||
external: true
|
||||
#networks:
|
||||
# caddy_network:
|
||||
# external: true
|
||||
|
|
|
|||
25
package.json
25
package.json
|
|
@ -12,24 +12,27 @@
|
|||
"dependencies": {
|
||||
"@emotion/is-prop-valid": "^1.3.1",
|
||||
"@headlessui/react": "^2.2.0",
|
||||
"framer-motion": "11.12.0",
|
||||
"lucide-react": "^0.462.0",
|
||||
"next": "15.0.3",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-icons": "^5.3.0"
|
||||
"@types/nodemailer": "^6.4.17",
|
||||
"@upstash/redis": "^1.34.3",
|
||||
"framer-motion": "11.13.3",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "15.0.4",
|
||||
"nodemailer": "^6.9.16",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-icons": "^5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.1",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@typescript-eslint/parser": "^8.16.0",
|
||||
"@types/react": "^19.0.1",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@typescript-eslint/parser": "^8.18.0",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.16.0",
|
||||
"eslint-config-next": "15.0.3",
|
||||
"eslint-config-next": "15.0.4",
|
||||
"eslint-plugin-import": "^2.31.0",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.15",
|
||||
"tailwindcss": "^3.4.16",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
917
pnpm-lock.yaml
generated
917
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
119
src/app/api/contact/route.ts
Normal file
119
src/app/api/contact/route.ts
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import { NextResponse } from 'next/server'
|
||||
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, '') // Basic XSS prevention
|
||||
.slice(0, 1000) // Length limitation
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
// Rate limiting check
|
||||
const rateLimitResult = await rateLimit()
|
||||
if (!rateLimitResult.success) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Too many requests. Please try again later.' },
|
||||
{ status: 429 }
|
||||
)
|
||||
}
|
||||
|
||||
const data: FormData = await req.json()
|
||||
|
||||
// Input validation
|
||||
const validation = validateInput(data)
|
||||
if (!validation.isValid) {
|
||||
return NextResponse.json(
|
||||
{ success: false, message: validation.error },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Sanitize inputs
|
||||
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: {
|
||||
name: 'NO REPLY',
|
||||
address: 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 NextResponse.json({
|
||||
success: true,
|
||||
message: 'Email sent successfully'
|
||||
})
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error sending email:', error)
|
||||
return NextResponse.json(
|
||||
{ success: false, message: 'Failed to send email' },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,106 @@
|
|||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNotification } from '@/components/providers/notification-provider'
|
||||
|
||||
export default function ContactPage() {
|
||||
const { showNotification } = useNotification()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
service: '',
|
||||
message: ''
|
||||
})
|
||||
|
||||
const [errors, setErrors] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
service: '',
|
||||
message: ''
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const validateField = (name: string, value: string) => {
|
||||
switch (name) {
|
||||
case 'name':
|
||||
return value.length < 2 || value.length > 50
|
||||
? 'Name must be between 2 and 50 characters'
|
||||
: ''
|
||||
case 'email':
|
||||
return !/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(value)
|
||||
? 'Please enter a valid email address'
|
||||
: ''
|
||||
case 'service':
|
||||
return !value ? 'Please select a service' : ''
|
||||
case 'message':
|
||||
return value.length < 10
|
||||
? 'Message must be at least 10 characters long'
|
||||
: value.length > 1000
|
||||
? 'Message must not exceed 1000 characters'
|
||||
: ''
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value } = e.target
|
||||
setFormData(prev => ({ ...prev, [name]: value }))
|
||||
setErrors(prev => ({ ...prev, [name]: validateField(name, value) }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
const newErrors = {
|
||||
name: validateField('name', formData.name),
|
||||
email: validateField('email', formData.email),
|
||||
service: validateField('service', formData.service),
|
||||
message: validateField('message', formData.message)
|
||||
}
|
||||
|
||||
setErrors(newErrors)
|
||||
|
||||
if (Object.values(newErrors).some(error => error)) {
|
||||
showNotification('Please correct the errors before submitting.', 'error')
|
||||
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) {
|
||||
showNotification('An error occurred. Please try again.', 'error')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white">
|
||||
<div className="max-w-7xl mx-auto px-6 py-24 lg:px-8">
|
||||
|
|
@ -12,7 +114,7 @@ export default function ContactPage() {
|
|||
</div>
|
||||
|
||||
<div className="mt-16 max-w-xl mx-auto">
|
||||
<form className="rounded-2xl bg-gray-50 p-10">
|
||||
<form onSubmit={handleSubmit} className="rounded-2xl bg-gray-50 p-10">
|
||||
<div className="grid grid-cols-1 gap-y-6">
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-gray-900">
|
||||
|
|
@ -22,9 +124,17 @@ export default function ContactPage() {
|
|||
type="text"
|
||||
name="name"
|
||||
id="name"
|
||||
className="mt-2 block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-800 sm:text-sm sm:leading-6"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
className={`mt-2 block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ${
|
||||
errors.name ? 'ring-red-500' : 'ring-gray-300'
|
||||
} placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-800 sm:text-sm sm:leading-6`}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-900">
|
||||
Email
|
||||
|
|
@ -33,9 +143,41 @@ export default function ContactPage() {
|
|||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
className="mt-2 block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-800 sm:text-sm sm:leading-6"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className={`mt-2 block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ${
|
||||
errors.email ? 'ring-red-500' : 'ring-gray-300'
|
||||
} placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-800 sm:text-sm sm:leading-6`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="service" className="block text-sm font-medium text-gray-900">
|
||||
Service
|
||||
</label>
|
||||
<select
|
||||
name="service"
|
||||
id="service"
|
||||
value={formData.service}
|
||||
onChange={handleChange}
|
||||
className={`mt-2 block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ${
|
||||
errors.service ? 'ring-red-500' : 'ring-gray-300'
|
||||
} focus:ring-2 focus:ring-inset focus:ring-blue-800 sm:text-sm sm:leading-6`}
|
||||
>
|
||||
<option value="">Select a service</option>
|
||||
<option value="Accounting Services">Accounting Services</option>
|
||||
<option value="Tax Services">Tax Services</option>
|
||||
<option value="Financial Analysis">Financial Analysis</option>
|
||||
<option value="Business Consulting">Business Consulting</option>
|
||||
</select>
|
||||
{errors.service && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.service}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-gray-900">
|
||||
Message
|
||||
|
|
@ -44,15 +186,27 @@ export default function ContactPage() {
|
|||
name="message"
|
||||
id="message"
|
||||
rows={4}
|
||||
className="mt-2 block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-800 sm:text-sm sm:leading-6"
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
className={`mt-2 block w-full rounded-md border-0 px-3.5 py-2 text-gray-900 shadow-sm ring-1 ring-inset ${
|
||||
errors.message ? 'ring-red-500' : 'ring-gray-300'
|
||||
} placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-800 sm:text-sm sm:leading-6`}
|
||||
/>
|
||||
{errors.message && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.message}</p>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-gray-500">
|
||||
{formData.message.length}/1000 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button
|
||||
type="submit"
|
||||
className="block w-full rounded-md bg-blue-800 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-800"
|
||||
disabled={isSubmitting || Object.values(errors).some(error => error)}
|
||||
className="block w-full rounded-md bg-blue-800 px-3.5 py-2.5 text-center text-sm font-semibold text-white shadow-sm hover:bg-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Send Message
|
||||
{isSubmitting ? 'Sending...' : 'Send Message'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -64,5 +218,5 @@ export default function ContactPage() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||
import { Playfair_Display, Inter } from "next/font/google";
|
||||
import Header from '../components/layout/Header';
|
||||
import Footer from '../components/layout/Footer';
|
||||
import { NotificationProvider } from '../components/providers/notification-provider';
|
||||
import '../styles/globals.css';
|
||||
|
||||
const playfair = Playfair_Display({
|
||||
|
|
@ -40,12 +41,14 @@ export default function RootLayout({
|
|||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<Header />
|
||||
<main className="min-h-screen pt-16">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
<NotificationProvider>
|
||||
<Header />
|
||||
<main className="min-h-screen pt-16">
|
||||
{children}
|
||||
</main>
|
||||
<Footer />
|
||||
</NotificationProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
55
src/components/providers/notification-provider.tsx
Normal file
55
src/components/providers/notification-provider.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import Toast from '../shared/Toast'
|
||||
|
||||
interface NotificationContextType {
|
||||
showNotification: (message: string, type: 'success' | 'error') => void
|
||||
}
|
||||
|
||||
const NotificationContext = createContext<NotificationContextType>({
|
||||
showNotification: () => {},
|
||||
})
|
||||
|
||||
export const useNotification = () => useContext(NotificationContext)
|
||||
|
||||
export function NotificationProvider({ children }: { children: React.ReactNode }) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [type, setType] = useState<'success' | 'error'>('success')
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const showNotification = (message: string, type: 'success' | 'error') => {
|
||||
setMessage(message)
|
||||
setType(type)
|
||||
setIsVisible(true)
|
||||
// Only auto-hide error messages
|
||||
if (type === 'error') {
|
||||
setTimeout(() => setIsVisible(false), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
const hideNotification = () => {
|
||||
setIsVisible(false)
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<NotificationContext.Provider value={{ showNotification }}>
|
||||
{children}
|
||||
<Toast
|
||||
message={message}
|
||||
type={type}
|
||||
isVisible={isVisible}
|
||||
onClose={hideNotification}
|
||||
/>
|
||||
</NotificationContext.Provider>
|
||||
)
|
||||
}
|
||||
59
src/components/shared/Toast.tsx
Normal file
59
src/components/shared/Toast.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
'use client'
|
||||
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { X } from 'lucide-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface ToastProps {
|
||||
message: string
|
||||
type: 'success' | 'error'
|
||||
isVisible: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export default function Toast({ message, type, isVisible, onClose }: ToastProps) {
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const bgColor = type === 'success'
|
||||
? 'from-blue-600 to-blue-800'
|
||||
: 'from-red-600 to-red-800'
|
||||
|
||||
if (!mounted) {
|
||||
return null
|
||||
}
|
||||
|
||||
return mounted ? (
|
||||
<AnimatePresence mode="wait">
|
||||
{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 relative"
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute right-4 top-4 text-gray-500 hover:text-gray-700 transition-colors"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</button>
|
||||
<div className={`h-2 bg-gradient-to-r ${bgColor}`} />
|
||||
<div className="p-6">
|
||||
<p className="text-lg font-medium text-gray-800 text-center">{message}</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
) : null
|
||||
}
|
||||
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
|
||||
}
|
||||
76
src/lib/rate-limit.ts
Normal file
76
src/lib/rate-limit.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { Redis } from '@upstash/redis'
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
export interface RateLimitConfig {
|
||||
interval: number // in seconds
|
||||
limit: number
|
||||
}
|
||||
|
||||
export interface RateLimitResult {
|
||||
success: boolean
|
||||
limit: number
|
||||
remaining: number
|
||||
reset: number // timestamp in seconds
|
||||
}
|
||||
|
||||
export class RateLimit {
|
||||
private redis: Redis
|
||||
private prefix: string
|
||||
|
||||
constructor() {
|
||||
this.redis = Redis.fromEnv()
|
||||
this.prefix = 'ratelimit'
|
||||
}
|
||||
|
||||
private async getIP(): Promise<string> {
|
||||
try {
|
||||
const headersList = await headers()
|
||||
const forwardedFor = headersList.get('x-forwarded-for')
|
||||
const realIP = headersList.get('x-real-ip')
|
||||
return forwardedFor?.split(',')[0] || realIP || 'development'
|
||||
} catch (error) {
|
||||
console.warn('Failed to get IP address, using fallback')
|
||||
return 'development'
|
||||
}
|
||||
}
|
||||
|
||||
private getKey(identifier: string): string {
|
||||
return `${this.prefix}:${identifier}`
|
||||
}
|
||||
|
||||
async check(config: RateLimitConfig): Promise<RateLimitResult> {
|
||||
const ip = await this.getIP()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default configuration
|
||||
const DEFAULT_CONFIG: RateLimitConfig = {
|
||||
interval: 60 * 60, // 1 hour
|
||||
limit: 5 // 5 requests per hour
|
||||
}
|
||||
|
||||
// Helper function for easy usage
|
||||
export async function rateLimit(
|
||||
config: RateLimitConfig = DEFAULT_CONFIG
|
||||
): Promise<RateLimitResult> {
|
||||
const limiter = new RateLimit()
|
||||
return limiter.check(config)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue