Skip to content

Commit 027bb34

Browse files
authored
Merge pull request #273 from OlufunbiIK/feat/contact-us-popup-form
Feat/contact us popup form
2 parents 0084d95 + fdca35c commit 027bb34

File tree

4 files changed

+484
-57
lines changed

4 files changed

+484
-57
lines changed
Lines changed: 365 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,365 @@
1+
// src/components/ContactDialog.tsx
2+
// ─────────────────────────────────────────────────────────────────────────────
3+
// Wraps the existing <ContactForm> in a Radix Dialog (shadcn/ui).
4+
// Usage:
5+
// <ContactDialog /> ← renders its own "Contact us" trigger button
6+
// <ContactDialog trigger={<button>} /> ← use a custom trigger element
7+
//
8+
// To wire up a real backend later, update the handleSubmit mock in ContactForm.tsx
9+
// (look for "Mock API delay") and replace with your fetch/API call.
10+
// ─────────────────────────────────────────────────────────────────────────────
11+
12+
"use client";
13+
14+
import { useState } from "react";
15+
import {
16+
MessageSquare,
17+
Loader2,
18+
X,
19+
CheckCircle2,
20+
AlertCircle,
21+
} from "lucide-react";
22+
import {
23+
Dialog,
24+
DialogContent,
25+
DialogHeader,
26+
DialogTitle,
27+
DialogDescription,
28+
} from "@/components/ui/dialog";
29+
30+
// ─── Inline form (self-contained so the modal owns its own state) ─────────────
31+
32+
type FormData = {
33+
name: string;
34+
email: string;
35+
subject: string;
36+
message: string;
37+
};
38+
type Status = "idle" | "loading" | "success" | "error";
39+
40+
function ContactModalForm({ onSuccess }: { onSuccess?: () => void }) {
41+
const [formData, setFormData] = useState<FormData>({
42+
name: "",
43+
email: "",
44+
subject: "",
45+
message: "",
46+
});
47+
const [errors, setErrors] = useState<Partial<FormData>>({});
48+
const [status, setStatus] = useState<Status>("idle");
49+
const [feedback, setFeedback] = useState("");
50+
const [honeypot, setHoneypot] = useState("");
51+
52+
const validate = (): boolean => {
53+
const e: Partial<FormData> = {};
54+
if (!formData.name.trim()) e.name = "Name is required.";
55+
if (!formData.email.trim()) {
56+
e.email = "Email is required.";
57+
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
58+
e.email = "Enter a valid email address.";
59+
}
60+
if (!formData.message.trim()) e.message = "Message is required.";
61+
if (formData.message.length > 500)
62+
e.message = "Message cannot exceed 500 characters.";
63+
setErrors(e);
64+
return Object.keys(e).length === 0;
65+
};
66+
67+
const handleChange = (
68+
e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
69+
) => {
70+
const { name, value } = e.target;
71+
setFormData((prev) => ({ ...prev, [name]: value }));
72+
// Clear field error on change
73+
if (errors[name as keyof FormData]) {
74+
setErrors((prev) => ({ ...prev, [name]: undefined }));
75+
}
76+
};
77+
78+
const handleSubmit = async (e: React.FormEvent) => {
79+
e.preventDefault();
80+
if (honeypot) {
81+
setStatus("success");
82+
setFeedback("Message sent!");
83+
return;
84+
}
85+
if (!validate()) return;
86+
87+
setStatus("loading");
88+
setFeedback("");
89+
90+
try {
91+
// ── TODO: replace with real API call ──────────────────────────────────
92+
// await fetch("/api/contact", {
93+
// method: "POST",
94+
// headers: { "Content-Type": "application/json" },
95+
// body: JSON.stringify(formData),
96+
// });
97+
await new Promise((res) => setTimeout(res, 1500));
98+
// ─────────────────────────────────────────────────────────────────────
99+
100+
setStatus("success");
101+
setFeedback("Thank you! We'll get back to you soon.");
102+
setFormData({ name: "", email: "", subject: "", message: "" });
103+
onSuccess?.();
104+
} catch {
105+
setStatus("error");
106+
setFeedback("Something went wrong. Please try again later.");
107+
}
108+
};
109+
110+
const field =
111+
"w-full rounded-lg border bg-background px-4 py-2.5 text-sm text-foreground placeholder:text-muted-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-1 focus:ring-offset-background disabled:opacity-50";
112+
const fieldError = "border-destructive focus:ring-destructive";
113+
const fieldNormal = "border-border";
114+
115+
if (status === "success") {
116+
return (
117+
<div className="flex flex-col items-center justify-center gap-4 py-10 text-center">
118+
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-primary/10">
119+
<CheckCircle2 className="h-7 w-7 text-primary" />
120+
</div>
121+
<div>
122+
<p className="font-display font-bold text-foreground text-lg">
123+
Message sent!
124+
</p>
125+
<p className="mt-1 text-sm text-muted-foreground font-body">
126+
{feedback}
127+
</p>
128+
</div>
129+
</div>
130+
);
131+
}
132+
133+
return (
134+
<form onSubmit={handleSubmit} noValidate className="flex flex-col gap-5">
135+
{/* Honeypot */}
136+
<div className="hidden" aria-hidden="true">
137+
<input
138+
type="text"
139+
name="website"
140+
value={honeypot}
141+
onChange={(e) => setHoneypot(e.target.value)}
142+
tabIndex={-1}
143+
autoComplete="off"
144+
/>
145+
</div>
146+
147+
{/* Name + Email */}
148+
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
149+
<div className="flex flex-col gap-1.5">
150+
<label
151+
htmlFor="modal-name"
152+
className="text-xs font-semibold text-foreground font-display"
153+
>
154+
Name <span className="text-destructive">*</span>
155+
</label>
156+
<input
157+
id="modal-name"
158+
name="name"
159+
type="text"
160+
value={formData.name}
161+
onChange={handleChange}
162+
placeholder="Your name"
163+
disabled={status === "loading"}
164+
aria-invalid={!!errors.name}
165+
aria-describedby={errors.name ? "modal-name-err" : undefined}
166+
className={`${field} ${errors.name ? fieldError : fieldNormal}`}
167+
/>
168+
{errors.name && (
169+
<p
170+
id="modal-name-err"
171+
className="text-xs text-destructive flex items-center gap-1"
172+
>
173+
<AlertCircle className="h-3 w-3 flex-shrink-0" /> {errors.name}
174+
</p>
175+
)}
176+
</div>
177+
178+
<div className="flex flex-col gap-1.5">
179+
<label
180+
htmlFor="modal-email"
181+
className="text-xs font-semibold text-foreground font-display"
182+
>
183+
Email <span className="text-destructive">*</span>
184+
</label>
185+
<input
186+
id="modal-email"
187+
name="email"
188+
type="email"
189+
value={formData.email}
190+
onChange={handleChange}
191+
placeholder="you@example.com"
192+
disabled={status === "loading"}
193+
aria-invalid={!!errors.email}
194+
aria-describedby={errors.email ? "modal-email-err" : undefined}
195+
className={`${field} ${errors.email ? fieldError : fieldNormal}`}
196+
/>
197+
{errors.email && (
198+
<p
199+
id="modal-email-err"
200+
className="text-xs text-destructive flex items-center gap-1"
201+
>
202+
<AlertCircle className="h-3 w-3 flex-shrink-0" /> {errors.email}
203+
</p>
204+
)}
205+
</div>
206+
</div>
207+
208+
{/* Subject (optional) */}
209+
<div className="flex flex-col gap-1.5">
210+
<label
211+
htmlFor="modal-subject"
212+
className="text-xs font-semibold text-foreground font-display"
213+
>
214+
Subject{" "}
215+
<span className="text-muted-foreground font-normal">(optional)</span>
216+
</label>
217+
<input
218+
id="modal-subject"
219+
name="subject"
220+
type="text"
221+
value={formData.subject}
222+
onChange={handleChange}
223+
placeholder="How can we help?"
224+
disabled={status === "loading"}
225+
className={`${field} ${fieldNormal}`}
226+
/>
227+
</div>
228+
229+
{/* Message */}
230+
<div className="flex flex-col gap-1.5">
231+
<label
232+
htmlFor="modal-message"
233+
className="text-xs font-semibold text-foreground font-display"
234+
>
235+
Message <span className="text-destructive">*</span>
236+
</label>
237+
<textarea
238+
id="modal-message"
239+
name="message"
240+
value={formData.message}
241+
onChange={handleChange}
242+
rows={4}
243+
placeholder="Tell us more..."
244+
disabled={status === "loading"}
245+
maxLength={500}
246+
aria-invalid={!!errors.message}
247+
aria-describedby={
248+
errors.message ? "modal-message-err" : "modal-message-count"
249+
}
250+
className={`${field} resize-none ${errors.message ? fieldError : fieldNormal}`}
251+
/>
252+
<div className="flex items-center justify-between">
253+
{errors.message ? (
254+
<p
255+
id="modal-message-err"
256+
className="text-xs text-destructive flex items-center gap-1"
257+
>
258+
<AlertCircle className="h-3 w-3 flex-shrink-0" /> {errors.message}
259+
</p>
260+
) : (
261+
<span />
262+
)}
263+
<p
264+
id="modal-message-count"
265+
className={`text-xs ml-auto ${formData.message.length >= 480 ? "text-destructive" : "text-muted-foreground"}`}
266+
>
267+
{formData.message.length}/500
268+
</p>
269+
</div>
270+
</div>
271+
272+
{/* Global error */}
273+
{status === "error" && feedback && (
274+
<div
275+
role="alert"
276+
className="flex items-center gap-2 rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
277+
>
278+
<AlertCircle className="h-4 w-4 flex-shrink-0" />
279+
{feedback}
280+
</div>
281+
)}
282+
283+
{/* Submit */}
284+
<button
285+
type="submit"
286+
disabled={status === "loading"}
287+
className="btn-primary w-full mt-1"
288+
>
289+
{status === "loading" ? (
290+
<>
291+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
292+
Sending…
293+
</>
294+
) : (
295+
"Send Message"
296+
)}
297+
</button>
298+
</form>
299+
);
300+
}
301+
302+
// ─── Dialog wrapper ───────────────────────────────────────────────────────────
303+
304+
type Props = {
305+
/** Optional custom trigger. Defaults to a "Contact us" button. */
306+
trigger?: React.ReactNode;
307+
/** Extra classes on the default trigger button */
308+
triggerClassName?: string;
309+
};
310+
311+
export function ContactDialog({ trigger, triggerClassName }: Props) {
312+
const [open, setOpen] = useState(false);
313+
314+
const defaultTrigger = (
315+
<button
316+
onClick={() => setOpen(true)}
317+
className={`text-sm text-muted-foreground transition-colors hover:text-foreground font-body ${triggerClassName ?? ""}`}
318+
>
319+
Contact us
320+
</button>
321+
);
322+
323+
return (
324+
<>
325+
{/* Trigger */}
326+
<span onClick={() => setOpen(true)} style={{ cursor: "pointer" }}>
327+
{trigger ?? defaultTrigger}
328+
</span>
329+
330+
{/* Modal */}
331+
<Dialog open={open} onOpenChange={setOpen}>
332+
<DialogContent className="w-full max-w-lg max-h-[90vh] overflow-y-auto rounded-2xl border-border bg-background p-0">
333+
{/* Header */}
334+
<div className="flex items-start justify-between gap-4 border-b border-border px-6 py-5">
335+
<div className="flex items-center gap-3">
336+
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-primary/10">
337+
<MessageSquare className="h-4 w-4 text-primary" />
338+
</div>
339+
<div>
340+
<DialogHeader>
341+
<DialogTitle className="text-base font-display font-bold text-foreground leading-none">
342+
Contact us
343+
</DialogTitle>
344+
<DialogDescription className="text-xs text-muted-foreground font-body mt-0.5">
345+
We typically reply within 1–2 business days.
346+
</DialogDescription>
347+
</DialogHeader>
348+
</div>
349+
</div>
350+
</div>
351+
352+
{/* Form body */}
353+
<div className="px-6 py-6">
354+
<ContactModalForm
355+
onSuccess={() => {
356+
// Auto-close after 2.5 s on success
357+
setTimeout(() => setOpen(false), 2500);
358+
}}
359+
/>
360+
</div>
361+
</DialogContent>
362+
</Dialog>
363+
</>
364+
);
365+
}

0 commit comments

Comments
 (0)