RLS policies implementeren, Demo auth flow
This commit is contained in:
326
app/(marketing)/contact/contact-form.tsx
Normal file
326
app/(marketing)/contact/contact-form.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Send, CheckCircle, XCircle } from 'lucide-react'
|
||||
import type { FormSection } from '@/content/schemas/manifesto'
|
||||
import type { LeadFormData } from '@/app/api/leads/route'
|
||||
|
||||
interface ContactFormProps {
|
||||
content: FormSection
|
||||
}
|
||||
|
||||
type FormStatus = 'idle' | 'submitting' | 'success' | 'error'
|
||||
|
||||
export function ContactForm({ content }: ContactFormProps) {
|
||||
const [status, setStatus] = useState<FormStatus>('idle')
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [formData, setFormData] = useState<LeadFormData>({
|
||||
name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
projectType: '',
|
||||
budget: '',
|
||||
message: '',
|
||||
})
|
||||
|
||||
const validateField = (name: keyof LeadFormData, value: string): string | null => {
|
||||
const field = content.fields[name]
|
||||
|
||||
if (field.required && !value.trim()) {
|
||||
return field.error || `${field.label} is verplicht`
|
||||
}
|
||||
|
||||
if (name === 'email' && value) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(value)) {
|
||||
return 'Ongeldig email adres'
|
||||
}
|
||||
}
|
||||
|
||||
if (name === 'message' && value && field.minLength) {
|
||||
if (value.length < field.minLength) {
|
||||
return `Minimaal ${field.minLength} karakters vereist`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value } = e.target
|
||||
setFormData(prev => ({ ...prev, [name]: value }))
|
||||
|
||||
// Clear error on change
|
||||
if (errors[name]) {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev }
|
||||
delete newErrors[name]
|
||||
return newErrors
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleBlur = (
|
||||
e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>
|
||||
) => {
|
||||
const { name, value } = e.target
|
||||
const error = validateField(name as keyof LeadFormData, value)
|
||||
|
||||
if (error) {
|
||||
setErrors(prev => ({ ...prev, [name]: error }))
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
// Validate all fields
|
||||
const newErrors: Record<string, string> = {}
|
||||
Object.keys(formData).forEach(key => {
|
||||
const error = validateField(key as keyof LeadFormData, formData[key as keyof LeadFormData] || '')
|
||||
if (error) {
|
||||
newErrors[key] = error
|
||||
}
|
||||
})
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors)
|
||||
return
|
||||
}
|
||||
|
||||
// Submit form
|
||||
setStatus('submitting')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/leads', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(formData),
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || 'Er ging iets mis')
|
||||
}
|
||||
|
||||
setStatus('success')
|
||||
// Reset form
|
||||
setFormData({
|
||||
name: '',
|
||||
email: '',
|
||||
company: '',
|
||||
projectType: '',
|
||||
budget: '',
|
||||
message: '',
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Form submission error:', error)
|
||||
setStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div className="bg-green-50 border-2 border-green-200 rounded-lg p-8 text-center">
|
||||
<CheckCircle className="w-16 h-16 text-green-600 mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold text-slate-900 mb-2">
|
||||
{content.success.title}
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-6">
|
||||
{content.success.message}
|
||||
</p>
|
||||
<a
|
||||
href="/"
|
||||
className="inline-block px-6 py-3 bg-green-600 hover:bg-green-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{content.success.cta}
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (status === 'error') {
|
||||
return (
|
||||
<div className="bg-red-50 border-2 border-red-200 rounded-lg p-8 text-center">
|
||||
<XCircle className="w-16 h-16 text-red-600 mx-auto mb-4" />
|
||||
<h3 className="text-2xl font-bold text-slate-900 mb-2">
|
||||
{content.error.title}
|
||||
</h3>
|
||||
<p className="text-slate-600 mb-6">
|
||||
{content.error.message}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setStatus('idle')}
|
||||
className="inline-block px-6 py-3 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors"
|
||||
>
|
||||
{content.error.retry}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{content.fields.name.label} {content.fields.name.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={content.fields.name.placeholder}
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors ${
|
||||
errors.name ? 'border-red-500' : 'border-slate-300'
|
||||
}`}
|
||||
disabled={status === 'submitting'}
|
||||
/>
|
||||
{errors.name && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{content.fields.email.label} {content.fields.email.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={content.fields.email.placeholder}
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors ${
|
||||
errors.email ? 'border-red-500' : 'border-slate-300'
|
||||
}`}
|
||||
disabled={status === 'submitting'}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Company */}
|
||||
<div>
|
||||
<label htmlFor="company" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{content.fields.company.label}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="company"
|
||||
name="company"
|
||||
value={formData.company}
|
||||
onChange={handleChange}
|
||||
placeholder={content.fields.company.placeholder}
|
||||
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors"
|
||||
disabled={status === 'submitting'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Project Type */}
|
||||
<div>
|
||||
<label htmlFor="projectType" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{content.fields.projectType.label} {content.fields.projectType.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<select
|
||||
id="projectType"
|
||||
name="projectType"
|
||||
value={formData.projectType}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors ${
|
||||
errors.projectType ? 'border-red-500' : 'border-slate-300'
|
||||
}`}
|
||||
disabled={status === 'submitting'}
|
||||
>
|
||||
<option value="">{content.fields.projectType.placeholder}</option>
|
||||
{content.fields.projectType.options?.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
{errors.projectType && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.projectType}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Budget */}
|
||||
<div>
|
||||
<label htmlFor="budget" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{content.fields.budget.label}
|
||||
</label>
|
||||
<select
|
||||
id="budget"
|
||||
name="budget"
|
||||
value={formData.budget}
|
||||
onChange={handleChange}
|
||||
className="w-full px-4 py-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors"
|
||||
disabled={status === 'submitting'}
|
||||
>
|
||||
<option value="">{content.fields.budget.placeholder}</option>
|
||||
{content.fields.budget.options?.map(option => (
|
||||
<option key={option} value={option}>{option}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<label htmlFor="message" className="block text-sm font-medium text-slate-700 mb-2">
|
||||
{content.fields.message.label} {content.fields.message.required && <span className="text-red-500">*</span>}
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={content.fields.message.placeholder}
|
||||
rows={6}
|
||||
className={`w-full px-4 py-3 border rounded-lg focus:ring-2 focus:ring-green-500 focus:border-transparent transition-colors ${
|
||||
errors.message ? 'border-red-500' : 'border-slate-300'
|
||||
}`}
|
||||
disabled={status === 'submitting'}
|
||||
/>
|
||||
{errors.message && (
|
||||
<p className="mt-1 text-sm text-red-600">{errors.message}</p>
|
||||
)}
|
||||
<p className="mt-1 text-sm text-slate-500">
|
||||
{formData.message.length} / {content.fields.message.minLength} minimum
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'submitting'}
|
||||
className="w-full px-8 py-4 bg-green-600 hover:bg-green-700 disabled:bg-slate-400 text-white font-medium rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{status === 'submitting' ? (
|
||||
<>
|
||||
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
{content.buttons.submitting}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Send className="w-5 h-5" />
|
||||
{content.buttons.submit}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
146
app/(marketing)/contact/page.tsx
Normal file
146
app/(marketing)/contact/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Contact Page
|
||||
*
|
||||
* Lead capture form with benefits and FAQ
|
||||
*/
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { getContent } from '@/lib/content/loader'
|
||||
import type { ContactContent } from '@/content/schemas/manifesto'
|
||||
import { Clock, Euro, Code, Eye, ChevronDown } from 'lucide-react'
|
||||
import { ContactForm } from './contact-form'
|
||||
|
||||
// Generate metadata for SEO
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const siteUrl = process.env.NEXT_PUBLIC_APP_URL || 'https://aispeedrun.vercel.app'
|
||||
|
||||
return {
|
||||
title: 'Contact - AI Speedrun',
|
||||
description: 'Van idee naar werkend prototype in 4 weken voor €200. Start je speedrun vandaag.',
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
title: 'Contact - AI Speedrun',
|
||||
description: 'Van idee naar werkend prototype in 4 weken voor €200',
|
||||
images: [`${siteUrl}/og-image.png`],
|
||||
siteName: 'AI Speedrun',
|
||||
locale: 'nl_NL',
|
||||
},
|
||||
alternates: {
|
||||
canonical: `${siteUrl}/contact`,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ContactPage() {
|
||||
const content = await getContent<ContactContent>('nl', 'contact')
|
||||
|
||||
const iconMap = {
|
||||
clock: Clock,
|
||||
euro: Euro,
|
||||
code: Code,
|
||||
eye: Eye,
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hero Section */}
|
||||
<section className="relative min-h-[40vh] flex items-center justify-center bg-gradient-to-br from-green-50 to-white px-4 pt-32 pb-16">
|
||||
<div className="max-w-3xl mx-auto text-center">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-slate-900 mb-4">
|
||||
{content.hero.title}
|
||||
</h1>
|
||||
<p className="text-xl md:text-2xl text-green-600 font-medium mb-4">
|
||||
{content.hero.subtitle}
|
||||
</p>
|
||||
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
|
||||
{content.hero.description}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main Content - Two Column Layout */}
|
||||
<section className="py-16 px-4">
|
||||
<div className="max-w-6xl mx-auto grid md:grid-cols-2 gap-12">
|
||||
{/* Left Column - Form */}
|
||||
<div>
|
||||
<h2 className="text-3xl font-bold text-slate-900 mb-6">
|
||||
{content.form.title}
|
||||
</h2>
|
||||
<ContactForm content={content.form} />
|
||||
</div>
|
||||
|
||||
{/* Right Column - Benefits & FAQ */}
|
||||
<div className="space-y-12">
|
||||
{/* Benefits */}
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-slate-900 mb-6">
|
||||
{content.benefits.title}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{content.benefits.items.map((benefit, index) => {
|
||||
const Icon = iconMap[benefit.icon as keyof typeof iconMap] || Clock
|
||||
return (
|
||||
<div key={index} className="flex gap-4">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-green-100 rounded-lg flex items-center justify-center">
|
||||
<Icon className="w-6 h-6 text-green-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-semibold text-slate-900 mb-1">
|
||||
{benefit.title}
|
||||
</h4>
|
||||
<p className="text-slate-600 text-sm">
|
||||
{benefit.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ */}
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-slate-900 mb-6">
|
||||
{content.faq.title}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{content.faq.items.map((item, index) => (
|
||||
<details
|
||||
key={index}
|
||||
className="group bg-slate-50 rounded-lg border border-slate-200 overflow-hidden"
|
||||
>
|
||||
<summary className="flex justify-between items-center cursor-pointer px-6 py-4 hover:bg-slate-100 transition-colors">
|
||||
<span className="font-medium text-slate-900">
|
||||
{item.question}
|
||||
</span>
|
||||
<ChevronDown className="w-5 h-5 text-slate-500 group-open:rotate-180 transition-transform" />
|
||||
</summary>
|
||||
<div className="px-6 py-4 border-t border-slate-200 text-slate-600">
|
||||
{item.answer}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Additional CTA */}
|
||||
<div className="bg-gradient-to-br from-green-50 to-white border-2 border-green-200 rounded-lg p-6 text-center">
|
||||
<p className="text-slate-700 mb-4">
|
||||
<strong>Nog vragen?</strong> Stuur een email naar{' '}
|
||||
<a
|
||||
href="mailto:contact@speedrun.nl"
|
||||
className="text-green-600 hover:text-green-700 underline"
|
||||
>
|
||||
contact@speedrun.nl
|
||||
</a>
|
||||
</p>
|
||||
<p className="text-sm text-slate-600">
|
||||
We reageren binnen 24 uur
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user