first commit
This commit is contained in:
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useLocale } from '@/lib/i18n/locale-context';
|
||||
import { SectionHeader } from '@/components/ui/SectionHeader';
|
||||
import { SERVICE_IDS } from '@/lib/i18n/dictionaries';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type Status = 'idle' | 'sending' | 'sent' | 'error';
|
||||
|
||||
export function Contact() {
|
||||
const { t, locale } = useLocale();
|
||||
const [status, setStatus] = useState<Status>('idle');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setStatus('sending');
|
||||
setError(null);
|
||||
const form = e.currentTarget;
|
||||
const data = Object.fromEntries(new FormData(form).entries());
|
||||
try {
|
||||
const res = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ ...data, locale }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body?.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
setStatus('sent');
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
setStatus('error');
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="contact" className="relative px-5 py-28 sm:px-8">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<SectionHeader
|
||||
align="center"
|
||||
eyebrow={t.contact.eyebrow}
|
||||
title={t.contact.title}
|
||||
sub={t.contact.sub}
|
||||
/>
|
||||
|
||||
<motion.form
|
||||
onSubmit={onSubmit}
|
||||
initial={{ opacity: 0, y: 30 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true, margin: '-60px' }}
|
||||
transition={{ duration: 0.6, ease: [0.22, 1, 0.36, 1] }}
|
||||
className="glass mt-14 grid grid-cols-1 gap-5 p-7 sm:grid-cols-2 sm:p-9"
|
||||
noValidate
|
||||
>
|
||||
<Field label={t.contact.fields.name} htmlFor="name">
|
||||
<input
|
||||
id="name"
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder={t.contact.placeholders.name}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t.contact.fields.company} htmlFor="company">
|
||||
<input
|
||||
id="company"
|
||||
name="company"
|
||||
type="text"
|
||||
placeholder={t.contact.placeholders.company}
|
||||
className={inputCls}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={t.contact.fields.service} htmlFor="service">
|
||||
<select id="service" name="service" defaultValue="" className={inputCls} required>
|
||||
<option value="" disabled>
|
||||
—
|
||||
</option>
|
||||
{t.services.items.map((s, i) => (
|
||||
<option key={SERVICE_IDS[i]} value={SERVICE_IDS[i]}>
|
||||
{s.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label={t.contact.fields.budget} htmlFor="budget">
|
||||
<select id="budget" name="budget" defaultValue="" className={inputCls} required>
|
||||
<option value="" disabled>
|
||||
—
|
||||
</option>
|
||||
{t.contact.budgets.map((b) => (
|
||||
<option key={b} value={b}>
|
||||
{b}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label={t.contact.fields.message}
|
||||
htmlFor="message"
|
||||
className="sm:col-span-2"
|
||||
>
|
||||
<textarea
|
||||
id="message"
|
||||
name="message"
|
||||
rows={5}
|
||||
required
|
||||
placeholder={t.contact.placeholders.message}
|
||||
className={cn(inputCls, 'resize-y')}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<div className="sm:col-span-2 flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="font-mono text-[0.72rem] uppercase tracking-wider text-slate-500">
|
||||
{t.contact.note}
|
||||
</p>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'sending'}
|
||||
className="btn-primary disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{status === 'sending' ? '…' : t.contact.submit}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status === 'sent' && (
|
||||
<p className="sm:col-span-2 rounded-lg border border-emerald/30 bg-emerald/5 px-4 py-3 text-sm text-emerald">
|
||||
✓ {locale === 'fa' ? 'پیام شما ارسال شد.' : 'Your message was sent.'}
|
||||
</p>
|
||||
)}
|
||||
{status === 'error' && (
|
||||
<p className="sm:col-span-2 rounded-lg border border-magenta/30 bg-magenta/5 px-4 py-3 text-sm text-magenta">
|
||||
{locale === 'fa' ? 'خطا در ارسال:' : 'Send failed:'} {error}
|
||||
</p>
|
||||
)}
|
||||
</motion.form>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'w-full rounded-xl border border-white/10 bg-base-800/60 px-4 py-3 text-sm text-slate-100 placeholder:text-slate-500 outline-none transition-colors focus:border-electric/60 focus:bg-base-800';
|
||||
|
||||
function Field({
|
||||
label,
|
||||
htmlFor,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<label htmlFor={htmlFor} className={cn('flex flex-col gap-2', className)}>
|
||||
<span className="label-mono">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user