Initial commit: Negin Dental Clinic website
Next.js (App Router) clone of negindentalclinic.ir, wired to the Meshkee Website API (blog, contact form, favicon), with on-page SEO and a scroll-reveal animation sequence on the homepage. See context.md for full project context. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { submitContact } from "@/lib/meshkee";
|
||||
import styles from "./contact.module.css";
|
||||
|
||||
const EMPTY = { name: "", email: "", phone: "", subject: "", message: "" };
|
||||
|
||||
export default function ContactForm() {
|
||||
const [values, setValues] = useState(EMPTY);
|
||||
const [status, setStatus] = useState("idle"); // idle | sending | error
|
||||
const [error, setError] = useState("");
|
||||
const [toast, setToast] = useState(null); // { text, kind } | null
|
||||
const toastTimer = useRef(null);
|
||||
|
||||
useEffect(() => () => clearTimeout(toastTimer.current), []);
|
||||
|
||||
const showToast = (text, kind = "success") => {
|
||||
setToast({ text, kind });
|
||||
clearTimeout(toastTimer.current);
|
||||
toastTimer.current = setTimeout(() => setToast(null), 4500);
|
||||
};
|
||||
|
||||
const onChange = (e) =>
|
||||
setValues((v) => ({ ...v, [e.target.name]: e.target.value }));
|
||||
|
||||
const onSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setStatus("sending");
|
||||
setError("");
|
||||
try {
|
||||
await submitContact({
|
||||
title: values.subject,
|
||||
name: values.name,
|
||||
text: values.message,
|
||||
email: values.email || undefined,
|
||||
cellNumber: values.phone || undefined,
|
||||
});
|
||||
setValues(EMPTY);
|
||||
setStatus("idle");
|
||||
showToast("پیام شما با موفقیت ارسال شد. همکاران ما در اسرع وقت با شما تماس خواهند گرفت.");
|
||||
} catch (err) {
|
||||
setStatus("error");
|
||||
setError(err.message || "ارسال پیام با خطا مواجه شد. لطفاً دوباره تلاش کنید.");
|
||||
showToast("ارسال پیام ناموفق بود. لطفاً دوباره تلاش کنید.", "error");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<form className={styles.form} onSubmit={onSubmit} noValidate>
|
||||
<div className={styles.row}>
|
||||
<label>
|
||||
<span>نام و نام خانوادگی</span>
|
||||
<input
|
||||
name="name"
|
||||
value={values.name}
|
||||
onChange={onChange}
|
||||
required
|
||||
autoComplete="name"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>آدرس الکترونیکی</span>
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={values.email}
|
||||
onChange={onChange}
|
||||
autoComplete="email"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={styles.row}>
|
||||
<label>
|
||||
<span>شماره تماس</span>
|
||||
<input
|
||||
name="phone"
|
||||
value={values.phone}
|
||||
onChange={onChange}
|
||||
autoComplete="tel"
|
||||
inputMode="tel"
|
||||
dir="ltr"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>موضوع</span>
|
||||
<input
|
||||
name="subject"
|
||||
value={values.subject}
|
||||
onChange={onChange}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>پیام شما</span>
|
||||
<textarea
|
||||
name="message"
|
||||
rows={6}
|
||||
value={values.message}
|
||||
onChange={onChange}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button type="submit" className="btn" disabled={status === "sending"}>
|
||||
{status === "sending" ? "در حال ارسال…" : "ارسال پیام"}
|
||||
</button>
|
||||
|
||||
{status === "error" && error ? (
|
||||
<p className={styles.formError} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
|
||||
{toast ? (
|
||||
<div
|
||||
className={`${styles.toast} ${toast.kind === "error" ? styles.toastError : ""}`}
|
||||
role="status"
|
||||
>
|
||||
{toast.text}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
.infoGrid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
|
||||
@media (min-width: 760px) {
|
||||
.infoGrid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.infoCard {
|
||||
text-align: center;
|
||||
padding: 34px 24px;
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-tint);
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.infoCard:hover {
|
||||
background-color: #e2eff9;
|
||||
}
|
||||
|
||||
.infoCard h3 {
|
||||
font-size: 18px;
|
||||
margin: 4px 0 10px;
|
||||
}
|
||||
|
||||
.infoCard p,
|
||||
.infoCard a {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 15px;
|
||||
text-align: center;
|
||||
text-align-last: center;
|
||||
}
|
||||
|
||||
.infoCard a:hover {
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.infoIcon {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
margin: 0 auto 16px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
/* ---------- form ---------- */
|
||||
.formWrap {
|
||||
/* same width as the info-card row above, and the same 60px rhythm
|
||||
on top (gap from the row) and bottom (gap to what follows) */
|
||||
margin: 0 0 60px;
|
||||
background: var(--bg-tint);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 44px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
@media (min-width: 620px) {
|
||||
.row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.form label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: var(--heading);
|
||||
}
|
||||
|
||||
.form input,
|
||||
.form textarea {
|
||||
font: inherit;
|
||||
color: var(--text);
|
||||
padding: 13px 16px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form input:focus,
|
||||
.form textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
}
|
||||
|
||||
.form textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form :global(.btn) {
|
||||
align-self: flex-end;
|
||||
justify-content: center;
|
||||
padding: 11px 28px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.formError {
|
||||
margin: 0;
|
||||
padding: 14px 18px;
|
||||
border-radius: 10px;
|
||||
background: #fdecec;
|
||||
color: #b3261e;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
text-align-last: center;
|
||||
}
|
||||
|
||||
/* ---------- success / error toast ---------- */
|
||||
.toast {
|
||||
position: fixed;
|
||||
inset-inline: 0;
|
||||
bottom: 26px;
|
||||
margin-inline: auto;
|
||||
width: fit-content;
|
||||
max-width: calc(100% - 40px);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px 24px;
|
||||
border-radius: 999px;
|
||||
background: #1f7a4d;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow-md);
|
||||
animation: toast-in 0.25s ease;
|
||||
}
|
||||
|
||||
.toastError {
|
||||
background: #b3261e;
|
||||
}
|
||||
|
||||
@keyframes toast-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- map ---------- */
|
||||
.mapSection {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.mapSection iframe {
|
||||
width: 100%;
|
||||
height: 420px;
|
||||
border: 0;
|
||||
filter: grayscale(0.15);
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
.infoGrid {
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.formWrap {
|
||||
margin-bottom: 32px;
|
||||
padding: 26px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import PageHero from "@/components/PageHero";
|
||||
import ContactForm from "./ContactForm";
|
||||
import styles from "./contact.module.css";
|
||||
|
||||
export const metadata = {
|
||||
title: "تماس با ما",
|
||||
description:
|
||||
"راههای ارتباطی کلینیک دندانپزشکی نگین در قم: آدرس بلوار امین، تلفن ۰۲۵-۳۲۹۴۵۰۷۷ و فرم تماس آنلاین.",
|
||||
};
|
||||
|
||||
const INFO = [
|
||||
{
|
||||
en: "Address",
|
||||
fa: "آدرس",
|
||||
value: "قم، بلوار امین، کوچه ۲۱، شماره ۵ (روبروی صدا و سیما)",
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11z" />
|
||||
<circle cx="12" cy="10" r="2.6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
en: "Phone",
|
||||
fa: "تلفن تماس",
|
||||
value: "۰۲۵ - ۳۲۹۴۵۰۷۷",
|
||||
href: "tel:+982532945077",
|
||||
ltr: true,
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<path d="M4 5c0-.6.4-1 1-1h3l2 5-2.5 1.5a12 12 0 0 0 5.5 5.5L18 18l5 2v3c0 .6-.4 1-1 1A17 17 0 0 1 4 5z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
en: "Email",
|
||||
fa: "آدرس الکترونیکی",
|
||||
value: "info@negindentalclinic.ir",
|
||||
href: "mailto:info@negindentalclinic.ir",
|
||||
icon: (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" stroke="currentColor" strokeWidth="1.8">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="m4 7 8 6 8-6" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export default function ContactPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHero
|
||||
en="Contact Us"
|
||||
fa="ارتباط با ما"
|
||||
lead="نظرات خود را با ما در میان بگذارید…"
|
||||
/>
|
||||
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
<h2 className="visually-hidden">راههای ارتباطی</h2>
|
||||
<div className={styles.infoGrid}>
|
||||
{INFO.map((i) => (
|
||||
<article key={i.en} className={styles.infoCard}>
|
||||
<span className={styles.infoIcon}>{i.icon}</span>
|
||||
<span className="card__en">{i.en}</span>
|
||||
<h3>{i.fa}</h3>
|
||||
{i.href ? (
|
||||
<a href={i.href} dir={i.ltr ? "ltr" : undefined}>
|
||||
{i.value}
|
||||
</a>
|
||||
) : (
|
||||
<p dir={i.ltr ? "ltr" : undefined}>{i.value}</p>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.formWrap}>
|
||||
<div className="sec-head" style={{ marginBottom: 32 }}>
|
||||
<span className="sec-head__en">Share your comments with us</span>
|
||||
<h2 className="sec-head__fa">ارسال پیام</h2>
|
||||
</div>
|
||||
<ContactForm />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className={styles.mapSection}>
|
||||
<iframe
|
||||
title="نقشه کلینیک دندانپزشکی نگین"
|
||||
src="https://www.google.com/maps?q=%D8%A8%D9%84%D9%88%D8%A7%D8%B1%20%D8%A7%D9%85%DB%8C%D9%86%20%D9%82%D9%85&output=embed"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer-when-downgrade"
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user