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,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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user