Add contact form function.

This commit is contained in:
TheMaddax 2024-12-09 22:40:36 -06:00
parent 1de3d8e6e7
commit c39306da9e
15 changed files with 1106 additions and 603 deletions

View file

@ -1,4 +1,7 @@
node_modules
.next
.git
.env*
.env.local
.env.development
external-code/

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

@ -1,6 +1,6 @@
node_modules/
.next/
.env*
.env.*
.DS_Store
*.pem
npm-debug.log*

200
ROADMAP
View file

@ -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 lions 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 Apodacas 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 brands 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 FINLIONs 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 Marks 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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

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

View file

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

View file

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

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

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

76
src/lib/rate-limit.ts Normal file
View 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)
}