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
@@ -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;
}
}
+67
View File
@@ -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 (
<article className="section">
<div className={`container ${styles.wrap}`}>
<Link href="/blog" className={styles.back}>
← بازگشت به مقالات
</Link>
<h1 className={styles.title}>{title}</h1>
{blogDate(blog) ? <p className={styles.date}>{faDate(blogDate(blog))}</p> : null}
{cover ? (
<div className={styles.cover}>
<Image src={cover} alt={title} width={1200} height={640} priority />
</div>
) : null}
{html ? (
<div className={styles.content} dangerouslySetInnerHTML={{ __html: html }} />
) : (
<p className={styles.content}>{blogExcerpt(blog)}</p>
)}
</div>
</article>
);
}
+119
View File
@@ -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;
}
+90
View File
@@ -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 (
<>
<PageHero en="Blog" fa="مقالات" lead="مطالب آموزشی درباره سلامت دهان و دندان." />
<section className="section">
<div className="container">
{items.length === 0 ? (
<div className={styles.empty}>
<h2>به‌زودی</h2>
<p>
مقالات کلینیک نگین در حال آماده‌سازی است. برای دریافت مشاوره و اطلاعات
بیشتر با ما در تماس باشید.
</p>
<Link href="/contact-us" className="btn">
تماس با ما
</Link>
</div>
) : (
<>
<h2 className="visually-hidden">لیست مقالات</h2>
<div className="grid grid--3">
{items.map((blog) => {
const title = blogTitle(blog);
const cover = blogCover(blog);
const href = `/blog/${blog.id}/${slugifyFa(title)}`;
return (
<article key={blog.id} className={styles.card}>
<Link href={href} className={styles.cardArt}>
{cover ? (
<Image src={cover} alt={title} width={420} height={260} />
) : (
<span className={styles.cardArtFallback} aria-hidden="true" />
)}
</Link>
<div className={styles.cardBody}>
{blogDate(blog) ? (
<span className={styles.cardDate}>{faDate(blogDate(blog))}</span>
) : null}
<h3>
<Link href={href}>{title}</Link>
</h3>
{blogExcerpt(blog) ? <p>{blogExcerpt(blog)}</p> : null}
<Link href={href} className={styles.cardMore}>
ادامه مطلب
</Link>
</div>
</article>
);
})}
</div>
{totalPages > 1 ? (
<nav className={styles.pager} aria-label="صفحه‌بندی مقالات">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<Link
key={p}
href={p === 1 ? "/blog" : `/blog?page=${p}`}
className={`${styles.pagerLink} ${p === page ? styles.pagerLinkActive : ""}`}
>
{p}
</Link>
))}
</nav>
) : null}
</>
)}
</div>
</section>
</>
);
}