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
+132
View File
@@ -0,0 +1,132 @@
import Image from "next/image";
import Link from "next/link";
import styles from "./Footer.module.css";
const PHONES = ["۰۲۵ - ۳۲۹۴۵۰۸۸", "۰۲۵ - ۳۲۹۴۵۳۵۰", "۰۲۵ - ۳۲۹۴۵۰۷۷"];
const faDigitsToLatin = (s) =>
s.replace(/[۰-۹]/g, (d) => "۰۱۲۳۴۵۶۷۸۹".indexOf(d)).replace(/\D/g, "");
const INSURANCES = [
"بیمه بانک تجارت",
"بیمه بانک ملت",
"بیمه سامان",
"بیمه معلم",
"بیمه نوین",
"بیمه ایران",
"بیمه کوثر",
"بیمه دی",
"بیمه دانا",
];
const SOCIAL = [
{ label: "اینستاگرام", href: "https://www.instagram.com/neginclinicc", icon: "instagram" },
{ label: "آپارات", href: "https://www.aparat.com/v/3zqwp", icon: "aparat" },
{ label: "تلگرام", href: "https://telegram.me/negin_clinic", icon: "telegram" },
];
function Icon({ name }) {
if (name === "instagram") {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.8">
<rect x="3" y="3" width="18" height="18" rx="5" />
<circle cx="12" cy="12" r="4" />
<circle cx="17.5" cy="6.5" r="1" fill="currentColor" stroke="none" />
</svg>
);
}
if (name === "telegram") {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M21.9 4.3 18.7 19c-.2 1-.9 1.3-1.8.8l-4.9-3.6-2.4 2.3c-.3.3-.5.5-1 .5l.4-5 9-8.1c.4-.3-.1-.5-.6-.2L6.7 13 1.9 11.5c-1-.3-1-1 .2-1.5L20.6 3c.9-.3 1.6.2 1.3 1.3z" />
</svg>
);
}
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-1.8 13.6V8.4L16 12l-5.8 3.6z" />
</svg>
);
}
export default function Footer() {
return (
<footer className={styles.footer}>
<div className={`container ${styles.grid}`}>
<div className={styles.col}>
<Image
src="/assets/files/sJcexIa9R4.png"
alt="کلینیک دندانپزشکی نگین"
width={200}
height={60}
className={styles.logo}
/>
<p className={styles.tag}>
کلینیک دندانپزشکی نگین، ارائه‌دهنده کلیه خدمات دندانپزشکی از جمله:
ارتودنسی نامرئی، ایمپلنت و زیبایی.
</p>
<div className={styles.social}>
{SOCIAL.map((s) => (
<a
key={s.icon}
href={s.href}
target="_blank"
rel="noopener noreferrer"
aria-label={s.label}
title={s.label}
>
<Icon name={s.icon} />
</a>
))}
</div>
</div>
<div className={styles.col}>
<h6 className={styles.head}>تلفن</h6>
<ul className={styles.list}>
{PHONES.map((p) => (
<li key={p}>
<a dir="ltr" href={`tel:+98${faDigitsToLatin(p).replace(/^0/, "")}`}>
{p}
</a>
</li>
))}
</ul>
<h6 className={styles.head}>آدرس</h6>
<p className={styles.addr}>قم، بلوار امین، کوچه ۲۱، شماره ۵ (روبروی صدا و سیما)</p>
</div>
<div className={styles.col}>
<h6 className={styles.head}>بیمه‌های طرف قرارداد</h6>
<ul className={`${styles.list} ${styles.insList}`}>
{INSURANCES.map((i) => (
<li key={i}>{i}</li>
))}
</ul>
</div>
<div className={styles.col}>
<h6 className={styles.head}>دسترسی سریع</h6>
<ul className={styles.list}>
<li><Link href="/services">خدمات کلینیک</Link></li>
<li><Link href="/about-us">درباره ما</Link></li>
<li><Link href="/contact-us">تماس با ما</Link></li>
<li><Link href="/blog">مقالات</Link></li>
<li><Link href="/virtual-tour">تور مجازی</Link></li>
</ul>
</div>
</div>
{/* bottom row — mirrors the Meshkee footer structure */}
<div className={styles.copyRow}>
<div className={`container ${styles.copyInner}`}>
<span dir="ltr">© {new Date().getFullYear()} Meshkee — مشکی</span>
<a href="https://www.meshkee.com" target="_blank" rel="noopener noreferrer">
طراحی و توسعه توسط تیم تجارت الکترونیک مشکی
<span className={styles.copyEn}>Designed &amp; Developed By Meshkee E-Commerce Team</span>
</a>
</div>
</div>
</footer>
);
}
+150
View File
@@ -0,0 +1,150 @@
.footer {
background: #f4f8fb;
border-top: 1px solid var(--border);
margin-top: 20px;
}
.grid {
display: grid;
gap: 40px;
padding: 70px 24px 50px;
}
@media (min-width: 620px) {
.grid {
grid-template-columns: repeat(2, 1fr);
}
}
@media (min-width: 980px) {
.grid {
grid-template-columns: 1.4fr 1fr 1fr 1fr;
}
}
.logo {
width: auto;
height: 56px;
margin-bottom: 18px;
}
.tag {
color: var(--muted);
font-size: 14px;
line-height: 2.1;
margin: 0 0 18px;
max-width: 320px;
}
.social {
display: flex;
gap: 10px;
}
.social a {
width: 40px;
height: 40px;
display: grid;
place-items: center;
border-radius: 50%;
background: #fff;
border: 1px solid var(--border);
color: var(--brand);
transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.social a:hover {
background: var(--brand);
color: #fff;
transform: translateY(-3px);
}
.head {
font-size: 16px;
color: var(--heading);
margin: 0 0 16px;
position: relative;
padding-bottom: 10px;
}
.head::after {
content: "";
position: absolute;
inset-inline-start: 0;
bottom: 0;
width: 36px;
height: 2px;
background: var(--cyan);
}
.list {
display: flex;
flex-direction: column;
gap: 10px;
color: var(--muted);
font-size: 14px;
}
.list a:hover {
color: var(--brand);
}
.insList {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 16px;
}
.addr {
color: var(--muted);
font-size: 14px;
margin: 0;
line-height: 2;
}
.copyRow {
background: #fff;
border-top: 1px solid var(--border);
padding: 16px 0;
}
.copyInner {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px 24px;
font-size: 12px;
color: var(--muted);
}
.copyInner a {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--heading);
font-weight: 500;
}
.copyInner a:hover {
color: var(--brand);
}
.copyEn {
font-family: var(--font-en);
font-size: 10px;
letter-spacing: 0.08em;
color: var(--muted);
text-transform: uppercase;
}
@media (max-width: 620px) {
.copyInner {
flex-direction: column;
text-align: center;
}
.copyInner a {
flex-direction: column;
gap: 2px;
}
}
+193
View File
@@ -0,0 +1,193 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Image from "next/image";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { CUSTOMER_PORTAL_URL, ADMIN_DASHBOARD_URL } from "@/lib/meshkee";
import { useMeshkeeSession, isAdminUser } from "@/lib/auth-client";
import styles from "./Header.module.css";
const NAV = [
{ href: "/services", label: "خدمات کلینیک" },
{ href: "/blog", label: "مقالات" },
{ href: "/virtual-tour", label: "تور مجازی" },
{ href: "/about-us", label: "درباره ما" },
{ href: "/contact-us", label: "تماس با ما" },
];
const CLINIC_PHONE_LABEL = "۰۲۵ - ۳۲۹۴۵۰۷۷";
const CLINIC_PHONE_TEL = "tel:+982532945077";
function PhoneIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 5c0-.6.4-1 1-1h3l2 5-2.5 1.5a12 12 0 0 0 5.5 5.5L16 15l5 2v3c0 .6-.4 1-1 1A17 17 0 0 1 4 5z" />
</svg>
);
}
function UserIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="8" r="4" />
<path d="M4 21c0-4.4 3.6-8 8-8s8 3.6 8 8" />
</svg>
);
}
function ChevronIcon() {
return (
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m6 9 6 6 6-6" />
</svg>
);
}
function userDisplayName(user) {
return (
user?.firstName ||
user?.name ||
user?.fullName ||
(user?.cellNumber ? user.cellNumber : "کاربر")
);
}
function AccountMenu() {
const { status, user, logout } = useMeshkeeSession();
const [open, setOpen] = useState(false);
const rootRef = useRef(null);
useEffect(() => {
if (!open) return;
const onDocClick = (e) => {
if (rootRef.current && !rootRef.current.contains(e.target)) setOpen(false);
};
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
}, [open]);
if (status !== "in") {
// Signed out (or still loading): the icon just sends the shopper to
// the customer account portal to sign in / register.
return (
<a
className={styles.userBtn}
href={CUSTOMER_PORTAL_URL}
aria-label="ورود کاربران"
title="ورود کاربران"
>
<UserIcon />
</a>
);
}
const admin = isAdminUser(user);
return (
<div className={styles.account} ref={rootRef}>
<button
type="button"
className={styles.accountBtn}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<span className={styles.accountAvatar}>
<UserIcon />
</span>
<span className={styles.accountName}>{userDisplayName(user)} عزیز</span>
<ChevronIcon />
</button>
{open ? (
<div className={styles.accountMenu} role="menu">
<a href={`${CUSTOMER_PORTAL_URL}/profile`} role="menuitem">
پروفایل من
</a>
{admin ? (
<a href={ADMIN_DASHBOARD_URL} role="menuitem">
مدیریت وبسایت
</a>
) : null}
<button type="button" role="menuitem" onClick={logout}>
خروج
</button>
</div>
) : null}
</div>
);
}
export default function Header() {
const pathname = usePathname();
const [open, setOpen] = useState(false);
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
useEffect(() => {
setOpen(false);
}, [pathname]);
return (
<header className={`${styles.header} ${scrolled ? styles.headerScrolled : ""}`}>
<div className={`container ${styles.inner}`}>
<Link href="/" className={styles.brand} aria-label="کلینیک دندانپزشکی نگین">
<Image
src="/assets/img/Header/header.png"
alt="کلینیک دندانپزشکی نگین"
width={190}
height={57}
priority
/>
</Link>
<nav className={`${styles.nav} ${open ? styles.navOpen : ""}`}>
{NAV.map((item) => {
const active = pathname.startsWith(item.href);
return (
<Link
key={item.href}
href={item.href}
className={`${styles.link} ${active ? styles.linkActive : ""}`}
>
{item.label}
</Link>
);
})}
</nav>
<div className={styles.actions}>
<a
className={styles.phone}
href={CLINIC_PHONE_TEL}
dir="ltr"
aria-label={`تماس با کلینیک ${CLINIC_PHONE_LABEL}`}
>
<PhoneIcon />
<span className={styles.phoneNum}>{CLINIC_PHONE_LABEL}</span>
</a>
<AccountMenu />
<button
type="button"
className={styles.burger}
aria-expanded={open}
aria-label="باز و بسته کردن منو"
onClick={() => setOpen((v) => !v)}
>
<span />
<span />
<span />
</button>
</div>
</div>
</header>
);
}
+267
View File
@@ -0,0 +1,267 @@
.header {
position: sticky;
top: 0;
z-index: 50;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
border-bottom: 1px solid transparent;
transition: box-shadow 0.25s ease, border-color 0.25s ease, background 0.25s ease;
}
.headerScrolled {
box-shadow: 0 8px 30px rgba(43, 67, 128, 0.08);
border-bottom-color: var(--border);
}
.inner {
display: flex;
align-items: center;
justify-content: space-between;
min-height: var(--header-h);
gap: 20px;
}
.brand img {
width: auto;
height: 52px;
}
.nav {
display: flex;
align-items: center;
gap: 6px;
}
.link {
position: relative;
padding: 10px 14px;
font-size: 15px;
font-weight: 500;
color: var(--heading);
border-radius: 8px;
}
.link:hover {
color: var(--brand);
}
.linkActive {
color: var(--brand);
}
.linkActive::after {
content: "";
position: absolute;
inset-inline: 14px;
bottom: 2px;
height: 2px;
border-radius: 2px;
background: var(--brand);
}
.actions {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.phone {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 16px;
border-radius: 999px;
background: var(--bg-tint);
color: var(--brand);
font-weight: 700;
font-size: 15px;
letter-spacing: 0.02em;
}
.phone:hover {
background: var(--brand);
color: #fff;
}
.phone svg {
flex-shrink: 0;
}
.userBtn {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 50%;
border: 1px solid var(--border);
color: var(--heading);
flex-shrink: 0;
}
.userBtn:hover {
border-color: var(--brand);
color: var(--brand);
background: var(--bg-tint);
}
/* ---------- signed-in account menu ---------- */
.account {
position: relative;
flex-shrink: 0;
}
.accountBtn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 6px 14px 6px 10px;
border-radius: 999px;
border: 1px solid var(--border);
background: #fff;
color: var(--heading);
cursor: pointer;
}
.accountBtn:hover {
border-color: var(--brand);
}
.accountAvatar {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border-radius: 50%;
background: var(--bg-tint);
color: var(--brand);
flex-shrink: 0;
}
.accountName {
font-size: 14px;
font-weight: 600;
white-space: nowrap;
}
.accountMenu {
position: absolute;
inset-inline-end: 0;
top: calc(100% + 10px);
min-width: 190px;
background: #fff;
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow-md);
padding: 8px;
display: flex;
flex-direction: column;
gap: 2px;
z-index: 60;
}
.accountMenu a,
.accountMenu button {
display: block;
width: 100%;
text-align: start;
padding: 10px 12px;
border-radius: 8px;
font: inherit;
font-size: 14px;
color: var(--heading);
background: none;
border: 0;
cursor: pointer;
}
.accountMenu a:hover,
.accountMenu button:hover {
background: var(--bg-tint);
color: var(--brand);
}
@media (max-width: 520px) {
.accountName {
display: none;
}
.accountBtn {
padding: 6px;
}
}
.userBtn:hover {
border-color: var(--brand);
color: var(--brand);
background: var(--bg-tint);
}
.burger {
display: none;
flex-direction: column;
gap: 5px;
width: 44px;
height: 44px;
align-items: center;
justify-content: center;
background: transparent;
border: 1px solid var(--border);
border-radius: 10px;
cursor: pointer;
}
.burger span {
display: block;
width: 22px;
height: 2px;
border-radius: 2px;
background: var(--heading);
}
@media (max-width: 960px) {
.burger {
display: flex;
}
.nav {
position: absolute;
inset-inline: 0;
top: 100%;
flex-direction: column;
align-items: stretch;
gap: 2px;
padding: 14px 24px 22px;
background: #fff;
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow-md);
transform: translateY(-12px);
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease, transform 0.2s ease;
}
.navOpen {
transform: translateY(0);
opacity: 1;
pointer-events: auto;
}
.link {
padding: 13px 8px;
border-bottom: 1px solid var(--border);
}
.linkActive::after {
display: none;
}
}
@media (max-width: 520px) {
.phoneNum {
display: none;
}
.phone {
padding: 10px;
}
}
+13
View File
@@ -0,0 +1,13 @@
import styles from "./PageHero.module.css";
export default function PageHero({ fa, en, lead }) {
return (
<section className={styles.hero}>
<div className="container">
<span className={styles.en}>{en}</span>
<h1 className={styles.fa}>{fa}</h1>
{lead ? <p className={styles.lead}>{lead}</p> : null}
</div>
</section>
);
}
+50
View File
@@ -0,0 +1,50 @@
.hero {
position: relative;
padding: 46px 0 38px;
text-align: center;
overflow: hidden;
background-color: #f6fafd;
background-repeat: no-repeat, repeat;
background-position: center, center;
background-size: cover, 26px 26px;
background-image: radial-gradient(
120% 160% at 15% -20%,
rgba(59, 85, 154, 0.05),
transparent 55%
),
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='26' height='26'%3E%3Ccircle cx='2' cy='2' r='1.5' fill='%2322356b' fill-opacity='0.22'/%3E%3C/svg%3E");
}
.en {
display: block;
font-family: var(--font-en);
text-transform: uppercase;
letter-spacing: 0.18em;
font-size: 14px;
color: var(--en-accent);
margin-bottom: 6px;
}
.fa {
color: var(--heading);
font-size: 27px;
margin: 0;
}
.lead {
margin: 10px auto 0;
max-width: 560px;
font-size: 15px;
color: var(--muted);
text-align: center;
text-align-last: center;
}
@media (max-width: 720px) {
.fa {
font-size: 22px;
}
.hero {
padding: 34px 0 28px;
}
}
+63
View File
@@ -0,0 +1,63 @@
"use client";
import { useEffect, useRef, useState } from "react";
/**
* Fades/slides a block into place once it scrolls into view (or immediately
* if it's already on screen at load — e.g. the hero). Pass `delay` (ms) to
* stage a sequence, and `from` to pick the entry direction.
*
* Pure CSS transition under the hood (`.reveal` / `.reveal--in` in
* globals.css) — this component only toggles the class at the right time,
* so it stays cheap and respects `prefers-reduced-motion` for free.
*/
export default function Reveal({
as: Tag = "div",
from = "up", // "up" | "down" | "left" | "right" | "scale"
delay = 0,
once = true,
className = "",
style,
children,
...rest
}) {
const ref = useRef(null);
const [visible, setVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (typeof IntersectionObserver === "undefined") {
setVisible(true);
return;
}
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
setVisible(true);
if (once) io.unobserve(entry.target);
} else if (!once) {
setVisible(false);
}
}
},
{ threshold: 0.18, rootMargin: "0px 0px -10% 0px" }
);
io.observe(el);
return () => io.disconnect();
}, [once]);
return (
<Tag
ref={ref}
className={`reveal reveal--${from} ${visible ? "reveal--in" : ""} ${className}`}
style={{ "--reveal-delay": `${delay}ms`, ...style }}
{...rest}
>
{children}
</Tag>
);
}