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:
2026-09-04 18:28:54 +03:30
co-authored by Claude Sonnet 5
commit f16b47966b
70 changed files with 4810 additions and 0 deletions
+131
View File
@@ -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}
</>
);
}
+190
View File
@@ -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;
}
}
+98
View File
@@ -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>
</>
);
}