commit f16b47966b333dc68dbc13f172d9d7c9a4130c1b Author: Alireza Hassani Date: Fri Sep 4 18:28:54 2026 +0330 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 diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..2770b0c --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "negin-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev"], + "port": 3000 + } + ] +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18e72cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +/node_modules +/.next +/out +/build +.DS_Store +*.pem +npm-debug.log* +.env*.local +.vercel +next-env.d.ts diff --git a/README.md b/README.md new file mode 100644 index 0000000..c620a17 --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# کلینیک دندانپزشکی نگین — Negin Dental Clinic + +A Next.js (App Router) clone of [negindentalclinic.ir](https://www.negindentalclinic.ir) — +Persian, right‑to‑left, blue/cyan medical palette taken from the original site. + +## Stack + +- **Next.js 15** (App Router, React Server Components) + **React 19** +- Plain **CSS + CSS Modules** +- **Vazirmatn** (Persian body text) & **Cinzel** (Latin/English titles) via `next/font/google` +- `next/image` with local assets under `public/assets/` + remote blog/branding images +- Backed by the **Meshkee Website API** (blog, contact form, favicon) — see `lib/meshkee.js` and [`context.md` §9](./context.md#9-meshkee-website-api-integration) + +## Getting started + +```bash +npm install +npm run dev # http://localhost:3000 +``` + +```bash +npm run build && npm run start # production +``` + +## Pages + +| Route | صفحه | Notes | +| ---------------- | -------------- | --------------------------------------- | +| `/` | صفحه اصلی | hero, feature cards, weekly schedule, services, CTA | +| `/services` | خدمات کلینیک | 12 services + consultation section | +| `/about-us` | درباره ما | clinic intro, advantages, insurances, dental care | +| `/contact-us` | تماس با ما | contact info cards, form (posts to the Meshkee Contact API) | +| `/blog` | مقالات | list, live from the Meshkee Blog API (empty state until posts are published) | +| `/blog/[id]/[slug]` | — | blog detail page | +| `/virtual-tour` | تور مجازی | iframes the original tour URL — **note:** that tour is gone (404, unrecoverable), see `context.md` §1 | + +## Project docs + +See [`context.md`](./context.md) for the design system, content data, known gaps +and conventions (kept updated for other tools/agents). diff --git a/app/about-us/about.module.css b/app/about-us/about.module.css new file mode 100644 index 0000000..b00bcfd --- /dev/null +++ b/app/about-us/about.module.css @@ -0,0 +1,74 @@ +.intro, +.care { + display: grid; + gap: 40px; + align-items: center; +} + +@media (min-width: 860px) { + .intro, + .care { + grid-template-columns: 1fr 1fr; + } +} + +.intro h2, +.care h2 { + font-size: 28px; + margin: 6px 0 16px; +} + +.intro > div:not(.introArt), +.care > div:not(.careArt) { + display: flex; + flex-direction: column; +} + +/* push the section CTA to the left edge of the text column */ +.intro :global(.btn), +.care :global(.btn) { + align-self: flex-end; +} + +.introArt img, +.careArt img { + width: 100%; + height: auto; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); +} + +.adv { + background: #fff; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 28px; + border-inline-start: 4px solid var(--cyan); +} + +.adv h3 { + font-size: 19px; + margin: 0 0 10px; +} + +.adv p { + margin: 0; + color: var(--muted); + font-size: 15px; +} + +.insurances { + display: flex; + flex-wrap: wrap; + gap: 14px; + justify-content: center; +} + +.insurances li { + background: var(--bg-tint); + color: var(--brand); + font-weight: 700; + padding: 12px 26px; + border-radius: 999px; + font-size: 15px; +} diff --git a/app/about-us/page.js b/app/about-us/page.js new file mode 100644 index 0000000..cd98fad --- /dev/null +++ b/app/about-us/page.js @@ -0,0 +1,151 @@ +import Image from "next/image"; +import Link from "next/link"; +import PageHero from "@/components/PageHero"; +import styles from "./about.module.css"; + +export const metadata = { + title: "درباره ما", + description: + "درباره کلینیک دندانپزشکی نگین در قم؛ کادر مجرب، مواد باکیفیت، دسترسی آسان و قرارداد با بیمه‌های معتبر و بانک‌ها.", +}; + +const INSURANCES = [ + "دانا", + "نوین", + "ایران", + "بانک ملت", + "معلم", + "کوثر", + "بانک تجارت", + "دی", + "سامان", +]; + +const ADVANTAGES = [ + { + title: "دسترسی آسان", + text: "قرار گرفتن کلینیک در یکی از اصلی‌ترین خیابان‌های قم و دسترسی آسان از تمامی نقاط شهر.", + }, + { + title: "مواد با کیفیت", + text: "استفاده از مواد و تجهیزات دندانپزشکی با کیفیت و مطابق با استانداردهای روز دنیا.", + }, + { + title: "کادر مجرب", + text: "کادر مجرب دندانپزشکی و متخصصان عضو هیئت علمی دانشکده دندانپزشکی.", + }, + { + title: "ویژه بانوان و کودکان", + text: "محیطی آرام و مناسب برای بانوان و کودکان جهت ارائه خدمات تخصصی و عمومی.", + }, +]; + +export default function AboutPage() { + return ( + <> + + +
+
+
+ کلینیک دندانپزشکی نگین +
+
+ More About Our Clinic +

درباره کلینیک بیشتر بدانید

+

+ دسترسی آسان از تمامی نقاط قم به کلینیک و قرار گرفتن آن در یکی از + اصلی‌ترین خیابان‌های شهر، از مزیت‌های بارز این کلینیک است. استفاده + از مواد با کیفیت و کادر مجرب دندانپزشکی از دیگر مزیت‌های کلینیک + دندانپزشکی نگین است. +

+

+ کلینیک نگین با بهره‌گیری از متخصصین مجرب و محیطی آرام و مناسب برای + بانوان و کودکان، آماده پاسخگویی و ارائه خدمت به مراجعین محترم می‌باشد. +

+ + با ما در تماس باشید + +
+
+
+ +
+
+
+ Why Negin +

مزیت‌های کلینیک نگین

+
+
+ {ADVANTAGES.map((a) => ( +
+

{a.title}

+

{a.text}

+
+ ))} +
+
+
+ +
+
+
+ Insurance & Banks +

طرف قرارداد با بیمه‌های معتبر و بانک‌ها

+

از جمله بیمه‌ها و بانک‌های طرف قرارداد کلینیک نگین:

+
+
    + {INSURANCES.map((i) => ( +
  • {i}
  • + ))} +
+
+
+ +
+
+
+ Dental Care +

مراقبت از دندان

+

+ یکی از مهم‌ترین اعضای بدن انسان دندان‌ها هستند. بسیاری از افراد با + کوتاهی کردن، سلامت دندان‌هایشان را به خطر می‌اندازند یا پس از خراب + شدن یک دندان خیلی دیر به دندانپزشک مراجعه می‌کنند که باعث مشکلات و + بیماری‌های دیگر دندان و لثه می‌شود. +

+

+ به مرور زمان ترک‌ (شکاف)‌های ریزی روی مینای دندان ایجاد می‌شود؛ این + شکاف‌ها فشار ایجادشده هنگام جویدن را خنثی کرده و از شکسته شدن دندان + جلوگیری می‌کنند. +

+

+ خمیردندان‌های معمولی موجود در بازار معمولاً به حد کافی فلوراید دارند + که استفاده از آن‌ها در وعده‌های مسواک زدن، به سفیدی دندان و محافظت از + مینای دندان کمک قابل‌توجهی می‌کند. با این حال، دندان‌ها همیشه نمی‌توانند + در برابر باکتری‌های موجود در دهان پایداری کنند؛ این باکتری‌ها اسیدهایی + ترشح می‌کنند که کلسیم و فسفات مینای دندان را از بین برده و باعث پوسیدگی + دندان‌ها می‌شوند. +

+
+
+ مراقبت از دندان +
+
+
+ + ); +} diff --git a/app/blog/[id]/[slug]/blog-detail.module.css b/app/blog/[id]/[slug]/blog-detail.module.css new file mode 100644 index 0000000..b709d0d --- /dev/null +++ b/app/blog/[id]/[slug]/blog-detail.module.css @@ -0,0 +1,76 @@ +.wrap { + max-width: 820px; + margin: 0 auto; +} + +.back { + display: inline-block; + margin-bottom: 24px; + font-size: 14px; + font-weight: 600; + color: var(--brand); +} + +.title { + font-size: 32px; + margin: 0 0 10px; +} + +.date { + color: var(--muted); + font-size: 14px; + margin: 0 0 28px; +} + +.cover { + border-radius: var(--radius-lg); + overflow: hidden; + margin-bottom: 34px; + box-shadow: var(--shadow-sm); +} + +.cover img { + width: 100%; + height: auto; +} + +.content { + color: var(--text); + font-size: 16px; + line-height: 2.1; +} + +.content :global(p) { + text-align: justify; + text-align-last: right; + margin: 0 0 1.4em; +} + +.content :global(h2), +.content :global(h3) { + color: var(--heading); + margin: 1.6em 0 0.7em; +} + +.content :global(img) { + max-width: 100%; + border-radius: var(--radius); + margin: 1.2em 0; +} + +.content :global(a) { + color: var(--brand); + text-decoration: underline; +} + +.content :global(ul), +.content :global(ol) { + padding-inline-start: 1.4em; + margin: 0 0 1.4em; +} + +@media (max-width: 720px) { + .title { + font-size: 25px; + } +} diff --git a/app/blog/[id]/[slug]/page.js b/app/blog/[id]/[slug]/page.js new file mode 100644 index 0000000..9be3903 --- /dev/null +++ b/app/blog/[id]/[slug]/page.js @@ -0,0 +1,67 @@ +import Image from "next/image"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { + getBlogById, + blogTitle, + blogExcerpt, + blogCover, + blogDate, + blogContentHtml, + faDate, +} from "@/lib/meshkee"; +import styles from "./blog-detail.module.css"; + +export const revalidate = 120; + +export async function generateMetadata({ params }) { + const { id } = await params; + const blog = await getBlogById(id); + if (!blog) return { title: "مقاله" }; + const title = blogTitle(blog); + const description = blogExcerpt(blog) || undefined; + return { + title, + description, + openGraph: { + title, + description, + images: blogCover(blog) ? [blogCover(blog)] : undefined, + }, + }; +} + +export default async function BlogDetailPage({ params }) { + const { id } = await params; + const blog = await getBlogById(id); + if (!blog) notFound(); + + const title = blogTitle(blog); + const cover = blogCover(blog); + const html = blogContentHtml(blog); + + return ( +
+
+ + ← بازگشت به مقالات + + +

{title}

+ {blogDate(blog) ?

{faDate(blogDate(blog))}

: null} + + {cover ? ( +
+ {title} +
+ ) : null} + + {html ? ( +
+ ) : ( +

{blogExcerpt(blog)}

+ )} +
+
+ ); +} diff --git a/app/blog/blog.module.css b/app/blog/blog.module.css new file mode 100644 index 0000000..e1eea10 --- /dev/null +++ b/app/blog/blog.module.css @@ -0,0 +1,119 @@ +.empty { + max-width: 620px; + margin: 0 auto; + text-align: center; +} + +.empty h2 { + font-size: 28px; + margin-bottom: 14px; +} + +.empty p { + color: var(--muted); + margin-bottom: 28px; + text-align: center; + text-align-last: center; +} + +.card { + background: #fff; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + display: flex; + flex-direction: column; + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.card:hover { + transform: translateY(-6px); + box-shadow: var(--shadow-md); +} + +.cardArt { + display: block; + aspect-ratio: 16 / 10; + background: var(--bg-tint); +} + +.cardArt img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.cardArtFallback { + display: block; + width: 100%; + height: 100%; + background: linear-gradient(135deg, var(--brand), var(--cyan)); + opacity: 0.16; +} + +.cardBody { + padding: 22px 24px 26px; + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; +} + +.cardDate { + font-size: 12px; + color: var(--cyan-deep); + font-weight: 700; +} + +.cardBody h3 { + font-size: 18px; + margin: 0; +} + +.cardBody p { + color: var(--muted); + font-size: 14px; + margin: 0; + flex: 1; +} + +.cardMore { + margin-top: 6px; + font-size: 13px; + font-weight: 700; + color: var(--brand); + align-self: flex-start; +} + +.cardMore::after { + content: " ←"; +} + +.pager { + display: flex; + justify-content: center; + gap: 8px; + margin-top: 50px; +} + +.pagerLink { + min-width: 38px; + padding: 8px 12px; + text-align: center; + border-radius: 10px; + border: 1px solid var(--border); + color: var(--heading); + font-weight: 600; + font-size: 14px; +} + +.pagerLink:hover { + border-color: var(--brand); + color: var(--brand); +} + +.pagerLinkActive { + background: var(--brand); + border-color: var(--brand); + color: #fff; +} diff --git a/app/blog/page.js b/app/blog/page.js new file mode 100644 index 0000000..264ef3e --- /dev/null +++ b/app/blog/page.js @@ -0,0 +1,90 @@ +import Image from "next/image"; +import Link from "next/link"; +import PageHero from "@/components/PageHero"; +import { getBlogs, blogTitle, blogExcerpt, blogCover, blogDate, faDate, slugifyFa } from "@/lib/meshkee"; +import styles from "./blog.module.css"; + +export const metadata = { + title: "مقالات", + description: + "مقالات و مطالب آموزشی کلینیک دندانپزشکی نگین درباره سلامت دهان و دندان، مراقبت روزانه از دندان‌ها، ایمپلنت، ارتودنسی و آخرین روش‌های درمانی.", +}; + +export const revalidate = 120; + +export default async function BlogPage({ searchParams }) { + const page = Number((await searchParams)?.page) || 1; + const { items, total, pageSize } = await getBlogs({ page, pageSize: 12 }); + const totalPages = Math.max(1, Math.ceil((total || 0) / (pageSize || 12))); + + return ( + <> + + +
+
+ {items.length === 0 ? ( +
+

به‌زودی

+

+ مقالات کلینیک نگین در حال آماده‌سازی است. برای دریافت مشاوره و اطلاعات + بیشتر با ما در تماس باشید. +

+ + تماس با ما + +
+ ) : ( + <> +

لیست مقالات

+
+ {items.map((blog) => { + const title = blogTitle(blog); + const cover = blogCover(blog); + const href = `/blog/${blog.id}/${slugifyFa(title)}`; + return ( +
+ + {cover ? ( + {title} + ) : ( +
+ ); + })} +
+ + {totalPages > 1 ? ( + + ) : null} + + )} +
+
+ + ); +} diff --git a/app/contact-us/ContactForm.js b/app/contact-us/ContactForm.js new file mode 100644 index 0000000..b04a8d3 --- /dev/null +++ b/app/contact-us/ContactForm.js @@ -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 ( + <> +
+
+ + +
+ +
+ + +
+ +