Initial commit
This commit is contained in:
@@ -0,0 +1,292 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { SiGitea } from "react-icons/si";
|
||||
import {
|
||||
ArrowRight,
|
||||
Mail,
|
||||
MapPin,
|
||||
Clock,
|
||||
Send,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
} from "lucide-react";
|
||||
import GlassCard from "@/components/ui/GlassCard";
|
||||
import GlowButton from "@/components/ui/GlowButton";
|
||||
import { fadeUp, stagger, viewportConfig } from "@/lib/animations";
|
||||
import type { Dictionary } from "@/lib/dictionaries";
|
||||
|
||||
interface ContactFormProps {
|
||||
dict: Dictionary;
|
||||
}
|
||||
|
||||
export default function ContactForm({ dict }: ContactFormProps) {
|
||||
const t = dict.contactPage;
|
||||
const [status, setStatus] = useState<"idle" | "sending" | "sent" | "error">(
|
||||
"idle",
|
||||
);
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setStatus("sending");
|
||||
setErrorMsg("");
|
||||
|
||||
const form = e.currentTarget;
|
||||
const data = {
|
||||
name: (form.elements.namedItem("name") as HTMLInputElement).value,
|
||||
email: (form.elements.namedItem("email") as HTMLInputElement).value,
|
||||
subject: (form.elements.namedItem("subject") as HTMLInputElement).value,
|
||||
message: (form.elements.namedItem("message") as HTMLTextAreaElement)
|
||||
.value,
|
||||
// Honeypot — hidden from real users, bots fill it in
|
||||
company: (form.elements.namedItem("company") as HTMLInputElement).value,
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json();
|
||||
throw new Error(err.error || "Failed to send");
|
||||
}
|
||||
|
||||
setStatus("sent");
|
||||
form.reset();
|
||||
} catch (err) {
|
||||
setStatus("error");
|
||||
setErrorMsg(
|
||||
err instanceof Error ? err.message : "Something went wrong",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const contactInfo = [
|
||||
{
|
||||
icon: Mail,
|
||||
label: t.emailLabel,
|
||||
value: "contact@ksan.dev",
|
||||
href: "mailto:contact@ksan.dev",
|
||||
},
|
||||
{
|
||||
icon: SiGitea,
|
||||
label: "Source Code",
|
||||
value: "git.ksan.dev/ksan",
|
||||
href: "https://git.ksan.dev/ksan",
|
||||
},
|
||||
{
|
||||
icon: Clock,
|
||||
label: t.responseTime,
|
||||
value: t.responseValue,
|
||||
},
|
||||
{
|
||||
icon: MapPin,
|
||||
label: t.location,
|
||||
value: t.locationValue,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial="hidden"
|
||||
whileInView="visible"
|
||||
viewport={viewportConfig}
|
||||
variants={stagger(0.1)}
|
||||
className="mx-auto grid max-w-6xl grid-cols-1 gap-6 lg:grid-cols-5"
|
||||
>
|
||||
{/* Form */}
|
||||
<motion.div variants={fadeUp} className="lg:col-span-3">
|
||||
<GlassCard variant="strong" hover={false} className="p-8 sm:p-10">
|
||||
{status === "sent" ? (
|
||||
<div className="flex min-h-[400px] flex-col items-center justify-center text-center">
|
||||
<div className="mb-6 flex h-16 w-16 items-center justify-center rounded-full border border-emerald-400/30 bg-emerald-500/10 text-emerald-300">
|
||||
<CheckCircle2 className="h-8 w-8" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h3 className="font-display text-2xl font-medium text-gradient-fade">
|
||||
{t.successTitle}
|
||||
</h3>
|
||||
<p className="mt-3 max-w-md text-text-secondary">
|
||||
{t.successMessage}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStatus("idle")}
|
||||
className="mt-6 text-sm text-purple-300 underline decoration-purple-400/40 underline-offset-2 hover:text-purple-200"
|
||||
>
|
||||
{t.sendAnother}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Honeypot field — invisible to humans, catches spam bots */}
|
||||
<input
|
||||
type="text"
|
||||
name="company"
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
aria-hidden="true"
|
||||
className="absolute -left-[9999px] h-0 w-0 opacity-0"
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
|
||||
<Field
|
||||
label={t.nameLabel}
|
||||
id="name"
|
||||
placeholder={t.namePlaceholder}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={t.emailLabel}
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder={t.emailPlaceholder}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label={t.subjectLabel}
|
||||
id="subject"
|
||||
placeholder={t.subjectPlaceholder}
|
||||
required
|
||||
/>
|
||||
<Field
|
||||
label={t.messageLabel}
|
||||
id="message"
|
||||
placeholder={t.messagePlaceholder}
|
||||
multiline
|
||||
required
|
||||
/>
|
||||
|
||||
{status === "error" && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-red-400/30 bg-red-500/10 px-4 py-3 text-sm text-red-300">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-4 pt-2">
|
||||
<p className="font-mono text-xs text-text-secondary">
|
||||
{t.avgResponse}
|
||||
</p>
|
||||
<GlowButton
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="md"
|
||||
disabled={status === "sending"}
|
||||
icon={
|
||||
status === "sending" ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)
|
||||
}
|
||||
>
|
||||
{status === "sending" ? t.sending : t.send}
|
||||
</GlowButton>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</GlassCard>
|
||||
</motion.div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<motion.div variants={fadeUp} className="space-y-3 lg:col-span-2">
|
||||
{contactInfo.map((item) => {
|
||||
const inner = (
|
||||
<GlassCard className="flex items-center gap-4 py-5">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border border-purple-400/20 bg-purple-500/10 text-purple-300">
|
||||
<item.icon className="h-4 w-4" strokeWidth={1.5} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-mono text-[11px] uppercase tracking-widest text-text-secondary">
|
||||
{item.label}
|
||||
</div>
|
||||
<div className="mt-0.5 truncate text-sm font-medium text-text-primary">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
{item.href && (
|
||||
<ArrowRight
|
||||
className="h-4 w-4 shrink-0 text-text-secondary transition-transform group-hover:translate-x-0.5"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)}
|
||||
</GlassCard>
|
||||
);
|
||||
|
||||
return item.href ? (
|
||||
<a
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
target={item.href.startsWith("http") ? "_blank" : undefined}
|
||||
rel={
|
||||
item.href.startsWith("http")
|
||||
? "noopener noreferrer"
|
||||
: undefined
|
||||
}
|
||||
className="group block"
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
<div key={item.label}>{inner}</div>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
id,
|
||||
type = "text",
|
||||
placeholder,
|
||||
required,
|
||||
multiline,
|
||||
}: {
|
||||
label: string;
|
||||
id: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
required?: boolean;
|
||||
multiline?: boolean;
|
||||
}) {
|
||||
const className =
|
||||
"w-full rounded-xl border border-border/60 bg-surface/60 px-4 py-3 text-sm text-text-primary placeholder:text-text-secondary/40 transition-all focus:border-purple-400/60 focus:bg-surface/80 focus:outline-none focus:ring-2 focus:ring-purple-500/20";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="mb-2 block font-mono text-[11px] uppercase tracking-widest text-text-secondary"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
{multiline ? (
|
||||
<textarea
|
||||
id={id}
|
||||
name={id}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
rows={5}
|
||||
className={className}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
id={id}
|
||||
name={id}
|
||||
type={type}
|
||||
required={required}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user