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
+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "negin-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"port": 3000
}
]
}
+10
View File
@@ -0,0 +1,10 @@
/node_modules
/.next
/out
/build
.DS_Store
*.pem
npm-debug.log*
.env*.local
.vercel
next-env.d.ts
+40
View File
@@ -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).
+74
View File
@@ -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;
}
+151
View File
@@ -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 (
<>
<PageHero
en="About Us"
fa="درباره ما"
lead="درباره کلینیک نگین بیشتر بدانید…"
/>
<section className="section">
<div className={`container ${styles.intro}`}>
<div className={styles.introArt}>
<Image
src="/assets/img/info3-2.jpg"
alt="کلینیک دندانپزشکی نگین"
width={720}
height={540}
/>
</div>
<div>
<span className="sec-head__en">More About Our Clinic</span>
<h2>درباره کلینیک بیشتر بدانید</h2>
<p>
دسترسی آسان از تمامی نقاط قم به کلینیک و قرار گرفتن آن در یکی از
اصلی‌ترین خیابان‌های شهر، از مزیت‌های بارز این کلینیک است. استفاده
از مواد با کیفیت و کادر مجرب دندانپزشکی از دیگر مزیت‌های کلینیک
دندانپزشکی نگین است.
</p>
<p>
کلینیک نگین با بهره‌گیری از متخصصین مجرب و محیطی آرام و مناسب برای
بانوان و کودکان، آماده پاسخگویی و ارائه خدمت به مراجعین محترم می‌باشد.
</p>
<Link href="/contact-us" className="btn btn--cyan">
با ما در تماس باشید
</Link>
</div>
</div>
</section>
<section className="section section--tint">
<div className="container">
<div className="sec-head">
<span className="sec-head__en">Why Negin</span>
<h2 className="sec-head__fa">مزیت‌های کلینیک نگین</h2>
</div>
<div className="grid grid--2">
{ADVANTAGES.map((a) => (
<article key={a.title} className={styles.adv}>
<h3>{a.title}</h3>
<p>{a.text}</p>
</article>
))}
</div>
</div>
</section>
<section className="section">
<div className="container">
<div className="sec-head">
<span className="sec-head__en">Insurance &amp; Banks</span>
<h2 className="sec-head__fa">طرف قرارداد با بیمه‌های معتبر و بانک‌ها</h2>
<p className="sec-head__lead">از جمله بیمه‌ها و بانک‌های طرف قرارداد کلینیک نگین:</p>
</div>
<ul className={styles.insurances}>
{INSURANCES.map((i) => (
<li key={i}>{i}</li>
))}
</ul>
</div>
</section>
<section className="section section--grey">
<div className={`container ${styles.care}`}>
<div>
<span className="sec-head__en">Dental Care</span>
<h2>مراقبت از دندان</h2>
<p>
یکی از مهم‌ترین اعضای بدن انسان دندان‌ها هستند. بسیاری از افراد با
کوتاهی کردن، سلامت دندان‌هایشان را به خطر می‌اندازند یا پس از خراب
شدن یک دندان خیلی دیر به دندانپزشک مراجعه می‌کنند که باعث مشکلات و
بیماری‌های دیگر دندان و لثه می‌شود.
</p>
<p>
به مرور زمان ترک‌ (شکاف)‌های ریزی روی مینای دندان ایجاد می‌شود؛ این
شکاف‌ها فشار ایجادشده هنگام جویدن را خنثی کرده و از شکسته شدن دندان
جلوگیری می‌کنند.
</p>
<p>
خمیردندان‌های معمولی موجود در بازار معمولاً به حد کافی فلوراید دارند
که استفاده از آن‌ها در وعده‌های مسواک زدن، به سفیدی دندان و محافظت از
مینای دندان کمک قابل‌توجهی می‌کند. با این حال، دندان‌ها همیشه نمی‌توانند
در برابر باکتری‌های موجود در دهان پایداری کنند؛ این باکتری‌ها اسیدهایی
ترشح می‌کنند که کلسیم و فسفات مینای دندان را از بین برده و باعث پوسیدگی
دندان‌ها می‌شوند.
</p>
</div>
<div className={styles.careArt}>
<Image
src="/assets/img/info3-3.jpg"
alt="مراقبت از دندان"
width={720}
height={540}
/>
</div>
</div>
</section>
</>
);
}
@@ -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>
</>
);
}
+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>
</>
);
}
+360
View File
@@ -0,0 +1,360 @@
/* ==========================================================================
Negin Dental Clinic — global styles
Palette extracted from the original site (negindentalclinic.ir)
========================================================================== */
:root {
--brand: #3b559a; /* primary blue (site accent) */
--brand-dark: #2c4380;
--cyan: #29abe2; /* logo cyan (accents/borders) */
--cyan-soft: #6ec9ef;
--cyan-deep: #1c7ba6; /* darker blue — used for the light-blue buttons */
--en-accent: #22356b; /* darker blue for Latin/English captions & titles */
--text: #5c6873; /* body copy */
--muted: #818695; /* secondary copy */
--heading: #333232; /* dark headings */
--bg: #ffffff;
--bg-tint: #eff6fa; /* pale blue section background */
--bg-tint-2: #f7fcff;
--bg-grey: #f7f7f7;
--border: #e6ebf1;
--radius: 14px;
--radius-lg: 28px;
--shadow-sm: 0 6px 18px rgba(43, 67, 128, 0.08);
--shadow-md: 0 18px 45px rgba(43, 67, 128, 0.12);
--container: 1180px;
--header-h: 92px;
--font-fa: var(--font-vazirmatn), "Vazirmatn", Tahoma, sans-serif;
--font-en: var(--font-cinzel), "Cinzel", "Times New Roman", serif;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: var(--font-fa);
font-size: 16px;
line-height: 2;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--heading);
line-height: 1.6;
margin: 0 0 0.6em;
font-weight: 700;
}
p {
margin: 0 0 1.2em;
text-align: justify;
text-align-last: right;
}
a {
color: inherit;
text-decoration: none;
transition: color 0.2s ease;
}
a:hover {
color: var(--brand);
}
img {
max-width: 100%;
height: auto;
display: block;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
/* form controls don't inherit the page font by default in most browsers */
button,
input,
textarea,
select {
font-family: inherit;
}
/* Latin type — the site brand language for English captions/titles */
.en,
.en-title {
font-family: var(--font-en);
letter-spacing: 0.06em;
text-transform: uppercase;
}
/* ---------- layout helpers ---------- */
.container {
width: 100%;
max-width: var(--container);
margin: 0 auto;
padding: 0 24px;
}
.section {
padding: 90px 0;
}
.section--tint {
background: var(--bg-tint);
}
.section--grey {
background: var(--bg-grey);
}
.grid {
display: grid;
gap: 30px;
}
@media (min-width: 720px) {
.grid--2 {
grid-template-columns: repeat(2, 1fr);
}
.grid--3 {
grid-template-columns: repeat(3, 1fr);
}
}
/* ---------- section heading (bilingual, like the original) ---------- */
.sec-head {
text-align: center;
max-width: 760px;
margin: 0 auto 54px;
}
.sec-head__en {
display: block;
font-family: var(--font-en);
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--en-accent);
font-size: 15px;
margin-bottom: 10px;
}
.sec-head__fa {
font-size: 30px;
color: var(--heading);
margin: 0 0 14px;
}
.sec-head__lead {
color: var(--muted);
margin: 0;
text-align: center;
text-align-last: center;
}
.sec-head::after {
content: "";
display: block;
width: 64px;
height: 3px;
border-radius: 3px;
background: var(--brand);
margin: 20px auto 0;
}
/* ---------- buttons ---------- */
.btn {
--_bg: var(--brand);
--_fg: #fff;
display: inline-flex;
align-items: center;
gap: 10px;
padding: 13px 30px;
border-radius: 999px;
background: var(--_bg);
color: var(--_fg);
font-weight: 700;
font-size: 15px;
border: 2px solid var(--_bg);
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
}
.btn:hover {
color: var(--_fg);
transform: translateY(-2px);
box-shadow: var(--shadow-sm);
}
.btn--cyan {
--_bg: var(--cyan-deep);
}
.btn--ghost {
--_bg: transparent;
--_fg: var(--brand);
border-color: var(--brand);
}
.btn--ghost:hover {
--_fg: #fff;
background: var(--brand);
}
/* ---------- cards ---------- */
.card {
background: #fff;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 34px 28px;
text-align: center;
height: 100%;
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.card:hover {
transform: translateY(-6px);
box-shadow: var(--shadow-md);
border-color: transparent;
}
.card__icon {
width: 96px;
height: 96px;
margin: 0 auto 14px;
display: grid;
place-items: center;
border-radius: 50%;
background: var(--bg-tint);
}
.card__icon img {
width: 60px;
height: 60px;
}
.card__en {
display: block;
font-family: var(--font-en);
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--en-accent);
font-size: 13px;
line-height: 1.4;
}
.card__title {
font-size: 20px;
margin: 2px 0 12px;
}
.card__text {
color: var(--muted);
font-size: 15px;
margin: 0;
text-align: justify;
text-align-last: right;
}
/* ---------- utilities ---------- */
.text-center {
text-align: center;
}
/* keep numeric strings (phone numbers, etc.) in left-to-right order inside RTL text */
.ltr,
[dir="ltr"] {
unicode-bidi: isolate;
}
.mt-0 {
margin-top: 0;
}
.lead {
font-size: 18px;
color: var(--muted);
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 720px) {
.section {
padding: 60px 0;
}
.sec-head__fa {
font-size: 24px;
}
}
/* ---------- scroll-in sequence animation (see components/Reveal.js) ---------- */
.reveal {
opacity: 0;
transition: opacity 0.7s cubic-bezier(0.16, 0.8, 0.24, 1) var(--reveal-delay, 0ms),
transform 0.7s cubic-bezier(0.16, 0.8, 0.24, 1) var(--reveal-delay, 0ms);
}
.reveal--up {
transform: translateY(30px);
}
.reveal--down {
transform: translateY(-30px);
}
.reveal--left {
transform: translateX(-36px);
}
.reveal--right {
transform: translateX(36px);
}
.reveal--scale {
transform: scale(0.92);
}
.reveal--in {
opacity: 1;
transform: none;
}
@media (prefers-reduced-motion: reduce) {
.reveal {
transition: none;
opacity: 1;
transform: none;
}
}
+73
View File
@@ -0,0 +1,73 @@
import { Cinzel, Vazirmatn } from "next/font/google";
import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { getFavicon } from "@/lib/meshkee";
const DEFAULT_ICON = "/assets/img/Header/header.png";
const vazirmatn = Vazirmatn({
subsets: ["arabic", "latin"],
weight: ["300", "400", "500", "700", "800"],
variable: "--font-vazirmatn",
display: "swap",
});
const cinzel = Cinzel({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-cinzel",
display: "swap",
});
export async function generateMetadata() {
// "read favicon from backend, if not exist use default"
const favicon = await getFavicon();
const icon = favicon?.faviconUrl || DEFAULT_ICON;
return {
metadataBase: new URL("https://www.negindentalclinic.ir"),
title: {
default: "کلینیک دندانپزشکی نگین | Negin Dental Clinic",
template: "%s | کلینیک دندانپزشکی نگین",
},
description:
"کلینیک دندانپزشکی نگین در قم، ارائه‌دهنده کلیه خدمات دندانپزشکی از جمله ایمپلنت، ارتودنسی نامرئی (الاینر) و خدمات زیبایی توسط دندانپزشکان مجرب و متخصص.",
keywords: [
"کلینیک دندانپزشکی نگین",
"دندانپزشکی قم",
"ایمپلنت",
"ارتودنسی نامرئی",
"الاینر",
"دندانپزشک متخصص",
],
icons: {
icon,
},
openGraph: {
type: "website",
locale: "fa_IR",
siteName: "کلینیک دندانپزشکی نگین",
title: "کلینیک دندانپزشکی نگین | Negin Dental Clinic",
description:
"ارائه کلیه خدمات دندانپزشکی توسط دندانپزشکان مجرب و متخصصان عضو هیئت علمی دانشکده دندانپزشکی.",
},
};
}
export default function RootLayout({ children }) {
return (
<html lang="fa" dir="rtl" className={`${vazirmatn.variable} ${cinzel.variable}`}>
<body>
{/* If JS never runs (blocked/failed), the scroll-reveal sequence on
Home would otherwise leave content stuck at opacity:0 forever. */}
<noscript>
<style>{`.reveal { opacity: 1 !important; transform: none !important; }`}</style>
</noscript>
<Header />
<main>{children}</main>
<Footer />
</body>
</html>
);
}
+79
View File
@@ -0,0 +1,79 @@
import fs from "node:fs";
import path from "node:path";
import { NextResponse } from "next/server";
import { WEBSITE_DOMAIN } from "@/lib/meshkee";
// Published for the Meshkee business dashboard's "Refresh" sitemap button —
// see AI_PROMPT.md ("What the website must publish"). Meshkee's own nginx
// proxy serves the actual /sitemap.xml (and the CMS-driven child sitemaps
// like /sitemap-blogs.xml) from the API; this route only lists this app's
// *static* public pages so the dashboard can fold them into the sitemap
// index. Do not add app/sitemap.ts / app/robots.ts — those paths are owned
// by the Meshkee proxy once this site is live on a Meshkee-managed domain.
export const dynamic = "force-static";
// Route segments that never map to a crawlable public page: dynamic detail
// pages (CMS-managed — Meshkee generates their own sitemap entries), and
// this "meshkee/*" integration namespace itself.
const EXCLUDE_SEGMENT = (segment) =>
segment.startsWith("[") || segment === "meshkee" || segment.startsWith("_");
// Per-path SEO weight. Anything not listed falls back to a sane default.
const PAGE_META = {
"/": { changefreq: "daily", priority: 1.0 },
"/services": { changefreq: "weekly", priority: 0.8 },
"/about-us": { changefreq: "monthly", priority: 0.6 },
"/blog": { changefreq: "daily", priority: 0.7 },
"/contact-us": { changefreq: "monthly", priority: 0.5 },
"/virtual-tour": { changefreq: "yearly", priority: 0.4 },
};
/** Scan app/ for `page.js` files and turn each into a public URL path. */
function scanStaticPages(dir, baseSegments = []) {
const pages = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return pages;
}
const hasPage = entries.some(
(e) => e.isFile() && /^page\.(js|jsx|ts|tsx)$/.test(e.name)
);
if (hasPage && !baseSegments.some(EXCLUDE_SEGMENT)) {
const urlPath = "/" + baseSegments.join("/");
pages.push(urlPath === "/" ? "/" : urlPath.replace(/\/$/, ""));
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
// route groups "(name)" don't appear in the URL
const isGroup = entry.name.startsWith("(") && entry.name.endsWith(")");
const nextSegments = isGroup
? baseSegments
: [...baseSegments, entry.name];
if (EXCLUDE_SEGMENT(entry.name)) continue;
pages.push(...scanStaticPages(path.join(dir, entry.name), nextSegments));
}
return pages;
}
export async function GET() {
const appDir = path.join(process.cwd(), "app");
const paths = scanStaticPages(appDir).sort(
(a, b) => a.split("/").length - b.split("/").length || a.localeCompare(b)
);
const staticPages = paths.map((p) => ({
path: p,
...(PAGE_META[p] || { changefreq: "monthly", priority: 0.5 }),
}));
return NextResponse.json({
baseUrl: `https://${WEBSITE_DOMAIN}`,
staticPages,
});
}
+301
View File
@@ -0,0 +1,301 @@
import Image from "next/image";
import Link from "next/link";
import { getBlogs, blogTitle, blogExcerpt, blogCover, blogDate, faDate, slugifyFa } from "@/lib/meshkee";
import Reveal from "@/components/Reveal";
import styles from "./page.module.css";
export const revalidate = 120;
const FEATURES = [
{
en: "Appointment",
fa: "نوبت‌دهی",
text: "برای گرفتن نوبت از متخصصین کلینیک با ما تماس بگیرید.",
cta: { label: "درخواست نوبت", href: "/contact-us" },
icon: "/assets/img/icons/1.png",
},
{
en: "Emergency Case",
fa: "موارد اورژانسی",
text: "در صورت بروز موارد اورژانسی با کلینیک نگین تماس بگیرید.",
cta: { label: "۰۲۵ - ۳۲۹۴۵۰۷۷", href: "tel:+982532945077" },
icon: "/assets/img/icons/2.png",
},
{
en: "Our Doctors",
fa: "پزشکان ما",
text: "ارائه خدمات توسط دندانپزشکان مجرب و متخصصان عضو هیئت علمی دانشکده دندانپزشکی.",
cta: { label: "معرفی پزشکان", href: "/about-us" },
icon: "/assets/img/icons/3.png",
},
];
const SCHEDULE = [
{ day: "شنبه", doctors: ["دکتر شجری", "دکتر صانعی‌پور", "دکتر دیناروند"] },
{ day: "یکشنبه", doctors: ["دکتر صانعی‌پور", "دکتر دیناروند", "دکتر شجری"] },
{ day: "دوشنبه", doctors: ["دکتر کریمی", "دکتر حسینی مطلق", "دکتر صانعی‌پور"] },
{ day: "سه‌شنبه", doctors: ["دکتر شجری", "دکتر دیناروند", "دکتر رسولی"] },
{ day: "چهارشنبه", doctors: ["دکتر صبری", "دکتر صانعی‌پور", "دکتر دیناروند"] },
{ day: "پنجشنبه", doctors: ["دکتر صبری", "دکتر رسولی", "دکتر حسینی مطلق"] },
];
const SERVICES = [
{ en: "Prosthesis", fa: "پروتز", text: "روکش ثابت و متحرک، روکش ایمپلنت.", icon: "/assets/img/icons/4.png" },
{ en: "Specialized", fa: "خدمات تخصصی", text: "خدمات تخصصی کودکان: کشیدن، ترمیم، عصب‌کشی، روکش استیل و فضانگهدارنده.", icon: "/assets/img/icons/5.png" },
{ en: "Public", fa: "خدمات عمومی", text: "کشیدن، ترمیم (پر کردن)، جرم‌گیری، درمان ریشه و ساخت پروتز.", icon: "/assets/img/icons/6.png" },
{ en: "Root Canal", fa: "درمان ریشه", text: "عصب‌کشی تخصصی شامل ریشه‌های عفونی، ریشه‌های سخت و کانال‌های تنگ و باریک.", icon: "/assets/img/icons/7.png" },
{ en: "Orthodontic", fa: "ارتودنسی", text: "ردیف کردن دندان‌ها و ساخت پلاک مخصوص کودکان مبتلا به شکاف کام.", icon: "/assets/img/icons/8.png" },
{ en: "Prio", fa: "پریو", text: "جراحی‌های لثه، جراحی‌های نرم و سخت، جراحی ایمپلنت و درمان بیماری‌های لثه.", icon: "/assets/img/icons/9.png" },
];
export default async function HomePage() {
const { items: latestBlogs } = await getBlogs({ page: 1, pageSize: 3 });
return (
<>
{/* ---------- hero ---------- */}
<section className={styles.hero}>
<div className={`container ${styles.heroInner}`}>
<div className={styles.heroCopy}>
<Reveal as="span" from="up" delay={0} className={`en-title ${styles.heroEn}`}>
Negin Dental Clinic
</Reveal>
<Reveal as="h1" from="up" delay={110} className={styles.heroTitle}>
کلینیک دندانپزشکی نگین
</Reveal>
<Reveal as="p" from="up" delay={220} className={styles.heroLead}>
ارائه‌دهنده کلیات خدمات دندانپزشکی از جمله ایمپلنت، ارتودنسی شفاف
(الاینر) و خدمات زیبایی، در محیطی آرام و مناسب برای بانوان و کودکان.
</Reveal>
<Reveal as="div" from="up" delay={330} className={styles.heroActions}>
<Link href="/contact-us" className="btn">
لیست نوبت‌دهی
</Link>
<Link href="/virtual-tour" className="btn btn--ghost">
سفر به کلینیک
</Link>
</Reveal>
</div>
<Reveal as="div" from="scale" delay={200} className={styles.heroArt}>
<Image
src="/assets/img/slider-1.png"
alt="خدمات دندانپزشکی کلینیک نگین"
width={754}
height={810}
priority
/>
</Reveal>
</div>
</section>
{/* ---------- feature cards ---------- */}
<section className={styles.features}>
<h2 className="visually-hidden">دسترسی سریع</h2>
<div className={`container grid grid--3`}>
{FEATURES.map((f, i) => (
<Reveal as="article" from="up" delay={i * 110} key={f.en} className="card">
<div className="card__icon">
<Image src={f.icon} alt="" width={60} height={60} />
</div>
<span className="card__en">{f.en}</span>
<h3 className="card__title">{f.fa}</h3>
<p className="card__text">{f.text}</p>
<Link
href={f.cta.href}
className={styles.cardLink}
dir={f.cta.href.startsWith("tel:") ? "ltr" : undefined}
>
{f.cta.label}
</Link>
</Reveal>
))}
</div>
</section>
{/* ---------- weekly schedule ---------- */}
<section className="section section--tint">
<div className="container">
<Reveal as="div" from="up" className="sec-head">
<span className="sec-head__en">Weekly Schedule</span>
<h2 className="sec-head__fa">خدمات ما در کلینیک</h2>
<p className="sec-head__lead">
برای درمان‌های خاص، به جدول زمان‌بندی حضور متخصصین در کلینیک توجه کنید.
</p>
</Reveal>
<Reveal as="div" from="up" delay={120} className={styles.tableWrap}>
<table className={styles.schedule}>
<thead>
<tr>
{SCHEDULE.map((s) => (
<th key={s.day}>{s.day}</th>
))}
</tr>
</thead>
<tbody>
<tr>
{SCHEDULE.map((s) => (
<td key={s.day}>
{s.doctors.map((d) => (
<span key={d}>{d}</span>
))}
</td>
))}
</tr>
</tbody>
</table>
</Reveal>
</div>
</section>
{/* ---------- info: Negin Dental Clinic ---------- */}
<section className="section">
<div className={`container ${styles.split}`}>
<Reveal as="div" from="right" className={styles.splitArt}>
<Image src="/assets/img/info3-2.jpg" alt="کلینیک دندانپزشکی نگین" width={720} height={540} />
</Reveal>
<Reveal as="div" from="left" delay={120} className={styles.splitCopy}>
<span className="sec-head__en">Negin Dental Clinic</span>
<h2>دندانپزشکی نگین</h2>
<p>
این مرکز در تمام ایام هفته با همراهی پزشکان متعهد و مجرب، در زمینه
تشخیص و درمان مشکلات و دردهای ناگهانی دندان در خدمت مادران و فرزندان
می‌باشد. لذا برای درمان‌های خاص به جدول زمان‌بندی حضور متخصصین در
کلینیک توجه کنید.
</p>
<Link href="/services" className="btn btn--cyan">
مشاهده خدمات
</Link>
</Reveal>
</div>
</section>
{/* ---------- info: About Negin Dental ---------- */}
<section className="section section--grey">
<div className={`container ${styles.split} ${styles.splitReverse}`}>
<Reveal as="div" from="left" className={styles.splitArt}>
<Image src="/assets/img/info3-3.jpg" alt="درباره کلینیک نگین" width={720} height={540} />
</Reveal>
<Reveal as="div" from="right" delay={120} className={styles.splitCopy}>
<span className="sec-head__en">About Negin Dental</span>
<h2>درباره کلینیک نگین</h2>
<p>
با شماره‌های کلینیک نگین تماس بگیرید و بدون حضور در کلینیک، نوبت خود
را با دکتر مورد نظر هماهنگ کنید. کادر مجرب دندانپزشکی و استفاده از
مواد با کیفیت، از مزیت‌های بارز کلینیک نگین است.
</p>
<Link href="/about-us" className="btn btn--ghost">
درباره ما
</Link>
</Reveal>
</div>
</section>
{/* ---------- services ---------- */}
<section className="section">
<div className="container">
<Reveal as="div" from="up" className="sec-head">
<span className="sec-head__en">Services</span>
<h2 className="sec-head__fa">خدمات ما در کلینیک</h2>
<p className="sec-head__lead">
ارائه کلیه خدمات دندانپزشکی توسط دندانپزشکان مجرب و متخصصان عضو هیئت
علمی دانشکده دندانپزشکی، با بهره‌گیری از تجهیزات پیشرفته و به‌روز دنیا.
</p>
</Reveal>
<div className="grid grid--3">
{SERVICES.map((s, i) => (
<Reveal as="article" from="up" delay={(i % 3) * 110} key={s.en} className="card">
<div className="card__icon">
<Image src={s.icon} alt="" width={60} height={60} />
</div>
<span className="card__en">{s.en}</span>
<h3 className="card__title">{s.fa}</h3>
<p className="card__text">{s.text}</p>
</Reveal>
))}
</div>
<div className="text-center" style={{ marginTop: 44 }}>
<Link href="/services" className="btn">
لیست کامل خدمات
</Link>
</div>
</div>
</section>
{/* ---------- latest blog posts ---------- */}
{latestBlogs.length > 0 ? (
<section className="section section--tint">
<div className="container">
<Reveal as="div" from="up" className="sec-head">
<span className="sec-head__en">Blog</span>
<h2 className="sec-head__fa">آخرین مقالات</h2>
</Reveal>
<div className="grid grid--3">
{latestBlogs.map((blog, i) => {
const title = blogTitle(blog);
const cover = blogCover(blog);
const href = `/blog/${blog.id}/${slugifyFa(title)}`;
return (
<Reveal as="article" from="up" delay={i * 110} key={blog.id} className={styles.blogCard}>
<Link href={href} className={styles.blogCardArt}>
{cover ? (
<Image src={cover} alt={title} width={420} height={260} />
) : (
<span className={styles.blogCardArtFallback} aria-hidden="true" />
)}
</Link>
<div className={styles.blogCardBody}>
{blogDate(blog) ? (
<span className={styles.blogCardDate}>{faDate(blogDate(blog))}</span>
) : null}
<h3>
<Link href={href}>{title}</Link>
</h3>
{blogExcerpt(blog) ? <p>{blogExcerpt(blog)}</p> : null}
</div>
</Reveal>
);
})}
</div>
<div className="text-center" style={{ marginTop: 44 }}>
<Link href="/blog" className="btn btn--ghost">
همه مقالات
</Link>
</div>
</div>
</section>
) : null}
{/* ---------- call CTA ---------- */}
<section className={styles.cta}>
<div className={`container ${styles.ctaInner}`}>
<Reveal as="div" from="right">
<span className="en-title" style={{ color: "var(--cyan-soft)" }}>
Call Us
</span>
<h2 className={styles.ctaTitle}>با ما در ارتباط باشید</h2>
<p className={styles.ctaText}>
در صورت موارد اورژانسی و نوبت‌دهی با ما تماس بگیرید. همکاران مستقر در
کلینیک، پرونده‌ای شامل اطلاعات شناسایی اولیه برای شما تشکیل می‌دهند و
سپس شما را به بخش تشخیص بیماری‌های دهان و دندان هدایت می‌کنند.
</p>
</Reveal>
<Reveal
as="a"
from="left"
delay={150}
dir="ltr"
href="tel:+982532945077"
className={styles.ctaPhone}
>
۰۲۵ - ۳۲۹۴۵۰۷۷
</Reveal>
</div>
</section>
</>
);
}
+281
View File
@@ -0,0 +1,281 @@
/* ---------- hero ---------- */
.hero {
position: relative;
overflow: hidden;
background:
radial-gradient(60% 80% at 85% 20%, rgba(41, 171, 226, 0.12), transparent 70%),
linear-gradient(180deg, #f7fcff 0%, #eef6fb 100%);
}
.heroInner {
display: grid;
gap: 40px;
align-items: center;
padding: 70px 24px 80px;
}
@media (min-width: 900px) {
.heroInner {
grid-template-columns: 1.05fr 0.95fr;
padding: 90px 24px 100px;
}
}
.heroEn {
color: var(--en-accent);
font-size: 18px;
letter-spacing: 0.16em;
}
.heroTitle {
font-size: 44px;
line-height: 1.35;
margin: 12px 0 18px;
color: var(--heading);
}
.heroLead {
font-size: 18px;
color: var(--muted);
max-width: 480px;
margin-bottom: 30px;
}
.heroActions {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.heroArt {
position: relative;
justify-self: center;
}
.heroArt img {
width: 100%;
max-width: 460px;
height: auto;
filter: drop-shadow(0 30px 50px rgba(43, 67, 128, 0.18));
}
@media (max-width: 900px) {
.heroTitle {
font-size: 32px;
}
}
/* ---------- feature cards ---------- */
.features {
margin-top: -50px;
padding-bottom: 20px;
position: relative;
z-index: 2;
}
.cardLink {
display: inline-block;
margin-top: 16px;
font-weight: 700;
font-size: 14px;
color: var(--brand);
}
.cardLink::after {
content: " ←";
}
/* ---------- schedule table ---------- */
.tableWrap {
overflow-x: auto;
border-radius: var(--radius);
box-shadow: var(--shadow-sm);
background: #fff;
}
.schedule {
width: 100%;
min-width: 720px;
border-collapse: collapse;
text-align: center;
}
.schedule th {
background: var(--brand);
color: #fff;
font-size: 15px;
padding: 16px 10px;
font-weight: 700;
}
.schedule td {
padding: 22px 10px;
border: 1px solid var(--border);
vertical-align: top;
}
.schedule td span {
display: block;
font-size: 14px;
color: var(--text);
padding: 6px 0;
}
.schedule td span + span {
border-top: 1px dashed var(--border);
}
/* ---------- split info sections ---------- */
.split {
display: grid;
gap: 40px;
align-items: center;
}
@media (min-width: 860px) {
.split {
grid-template-columns: 1fr 1fr;
}
.splitReverse .splitArt {
order: 2;
}
}
.splitArt img {
width: 100%;
height: auto;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
}
.splitCopy {
display: flex;
flex-direction: column;
}
.splitCopy h2 {
font-size: 28px;
margin: 6px 0 16px;
}
/* push the section CTA to the left edge of the text column */
.splitCopy :global(.btn) {
align-self: flex-end;
}
/* ---------- call CTA ---------- */
.cta {
background:
linear-gradient(180deg, rgba(44, 67, 128, 0.9), rgba(44, 67, 128, 0.9)),
url("/assets/img/info_items_2/bg_img.jpg") center / cover no-repeat fixed;
color: #fff;
padding: 80px 0;
}
.ctaInner {
display: grid;
gap: 30px;
align-items: center;
}
@media (min-width: 860px) {
.ctaInner {
grid-template-columns: 1.6fr 1fr;
}
}
.ctaTitle {
color: #fff;
font-size: 30px;
margin: 8px 0 14px;
}
.ctaText {
color: rgba(255, 255, 255, 0.86);
margin: 0;
max-width: 560px;
}
.ctaPhone {
justify-self: start;
font-family: var(--font-en);
font-size: 30px;
font-weight: 700;
letter-spacing: 0.04em;
color: #fff;
padding: 18px 34px;
border: 2px solid rgba(255, 255, 255, 0.5);
border-radius: 999px;
transition: background 0.2s ease, color 0.2s ease;
}
@media (min-width: 860px) {
.ctaPhone {
justify-self: end;
}
}
.ctaPhone:hover {
background: #fff;
color: var(--brand);
}
/* ---------- latest blog cards ---------- */
.blogCard {
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;
}
.blogCard:hover {
transform: translateY(-6px);
box-shadow: var(--shadow-md);
}
.blogCardArt {
display: block;
aspect-ratio: 16 / 10;
background: var(--bg-tint);
}
.blogCardArt img {
width: 100%;
height: 100%;
object-fit: cover;
}
.blogCardArtFallback {
display: block;
width: 100%;
height: 100%;
background: linear-gradient(135deg, var(--brand), var(--cyan));
opacity: 0.16;
}
.blogCardBody {
padding: 22px 24px 26px;
display: flex;
flex-direction: column;
gap: 8px;
}
.blogCardDate {
font-size: 12px;
color: var(--cyan-deep);
font-weight: 700;
}
.blogCardBody h3 {
font-size: 18px;
margin: 0;
}
.blogCardBody p {
color: var(--muted);
font-size: 14px;
margin: 0;
}
+93
View File
@@ -0,0 +1,93 @@
import Image from "next/image";
import Link from "next/link";
import PageHero from "@/components/PageHero";
import styles from "./services.module.css";
export const metadata = {
title: "خدمات کلینیک",
description:
"کلیه خدمات دندانپزشکی کلینیک نگین: پروتز، ارتودنسی، درمان ریشه، جراحی ایمپلنت، ترمیم و زیبایی، خدمات کودکان و مشاوره آنلاین.",
};
const SERVICES = [
{ en: "Prosthesis", fa: "پروتز", text: "روکش ثابت و متحرک، روکش ایمپلنت.", icon: "/assets/img/icons/1.png" },
{ en: "Specialized", fa: "خدمات تخصصی", text: "خدمات تخصصی کودکان شامل کشیدن، ترمیم، عصب‌کشی، روکش استیل و فضانگهدارنده.", icon: "/assets/img/icons/2.png" },
{ en: "Public", fa: "خدمات عمومی", text: "کشیدن، ترمیم (پر کردن)، جرم‌گیری، درمان ریشه (عصب‌کشی) و پروتز (ساخت روکش و دندان مصنوعی).", icon: "/assets/img/icons/3.png" },
{ en: "Root Canal", fa: "درمان ریشه", text: "عصب‌کشی تخصصی شامل ریشه‌های عفونی، ریشه‌های سخت و کانال‌های تنگ و باریک.", icon: "/assets/img/icons/4.png" },
{ en: "Orthodontic", fa: "ارتودنسی", text: "ردیف کردن دندان‌ها و ساخت پلاک مخصوص کودکان مبتلا به شکاف کام.", icon: "/assets/img/icons/5.png" },
{ en: "Prio", fa: "پریو", text: "جراحی‌های لثه، جراحی‌های نرم و سخت، جراحی ایمپلنت و درمان بیماری‌های لثه.", icon: "/assets/img/icons/6.png" },
{ en: "Composite Veneer", fa: "ترمیم تخصصی", text: "ترمیمی و زیبایی شامل ونیر، لمینیت، ترمیم دندان‌های آسیب‌دیده و سفید کردن دندان‌ها (بلیچینگ).", icon: "/assets/img/icons/7.png" },
{ en: "Kids Teeth", fa: "کودکان", text: "ارائه کلیه خدمات دندانپزشکی توسط متخصصین باتجربه کودکان.", icon: "/assets/img/icons/8.png" },
{ en: "Implant Surgery", fa: "جراحی ایمپلنت", text: "جراحی ایمپلنت دندان دیجیتال، جراحی با حداقل برش.", icon: "/assets/img/icons/9.png" },
];
export default function ServicesPage() {
return (
<>
<PageHero
en="Services"
fa="خدمات"
lead="با خدمات ارائه‌شده توسط کلینیک بیشتر آشنا شوید."
/>
<section className="section">
<div className="container">
<div className="sec-head">
<span className="sec-head__en">Our Services</span>
<h2 className="sec-head__fa">خدمات ما در کلینیک</h2>
<p className="sec-head__lead">
ارائه کلیه خدمات دندانپزشکی توسط دندانپزشکان مجرب و متخصصان عضو هیئت
علمی دانشکده دندانپزشکی، با بهره‌گیری از تجهیزات پیشرفته و به‌روز دنیا.
پس با خیالی آسوده به ما مراجعه کنید…
</p>
</div>
<div className="grid grid--3">
{SERVICES.map((s) => (
<article key={s.en} className="card">
<div className="card__icon">
<Image src={s.icon} alt="" width={60} height={60} />
</div>
<span className="card__en">{s.en}</span>
<h3 className="card__title">{s.fa}</h3>
<p className="card__text">{s.text}</p>
</article>
))}
</div>
</div>
</section>
<section className="section section--tint">
<div className={`container ${styles.split}`}>
<div className={styles.art}>
<Image
src="/assets/img/info3-2.jpg"
alt="مشاوره دندانپزشکی از راه دور"
width={720}
height={540}
/>
</div>
<div>
<span className="sec-head__en">Consultation</span>
<h2>مشاوره</h2>
<p>
خدمت مشاوره از راه دور یا مشاوره آنلاین، از خدماتی است که سال‌های
متمادی است در کشورهای مختلف در حال تجربه است. بسیاری از کشورها
توانسته‌اند با این امکان، بخش زیادی از مراجعات غیرضروری به پزشک‌ها و
کلینیک‌ها را حذف کنند.
</p>
<h3 style={{ marginTop: 28 }}>خدمات حضوری</h3>
<p>
کلینیک نگین با بهره‌گیری از متخصصین مجرب و محیطی آرام و مناسب برای
بانوان و کودکان، آماده پاسخگویی و ارائه خدمت به مراجعین محترم می‌باشد.
خدمات این کلینیک اعم از خدمات تخصصی و عمومی، ویژه بانوان و کودکان است.
</p>
<Link href="/contact-us" className="btn btn--cyan">
درخواست نوبت
</Link>
</div>
</div>
</section>
</>
);
}
+40
View File
@@ -0,0 +1,40 @@
.split {
display: grid;
gap: 40px;
align-items: center;
}
@media (min-width: 860px) {
.split {
grid-template-columns: 1fr 1fr;
}
.art {
order: 2;
}
}
.split > div:not(.art) {
display: flex;
flex-direction: column;
}
.split h2 {
font-size: 28px;
margin: 6px 0 16px;
}
.split h3 {
font-size: 20px;
}
/* push the section CTA to the left edge of the text column */
.split :global(.btn) {
align-self: flex-end;
}
.art img {
width: 100%;
height: auto;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-md);
}
+44
View File
@@ -0,0 +1,44 @@
import PageHero from "@/components/PageHero";
import styles from "./virtual-tour.module.css";
export const metadata = {
title: "تور مجازی",
description:
"پیش از مراجعه حضوری، فضای داخلی کلینیک دندانپزشکی نگین در قم را با تور مجازی ۳۶۰ درجه مشاهده کنید و از امکانات و محیط درمانی کلینیک آگاه شوید.",
};
export default function VirtualTourPage() {
return (
<>
<PageHero
en="Virtual Tour"
fa="تور مجازی"
lead="سفر مجازی به کلینیک دندانپزشکی نگین."
/>
<section className="section">
<div className={`container ${styles.wrap}`}>
<div className={styles.frame}>
<iframe
title="تور مجازی کلینیک نگین"
src="https://www.negindentalclinic.ir/assets/virtualtour.html"
loading="lazy"
allowFullScreen
/>
</div>
<p className={styles.note}>
در صورتی که تور مجازی بارگذاری نشد، می‌توانید آن را{" "}
<a
href="https://www.negindentalclinic.ir/assets/virtualtour.html"
target="_blank"
rel="noopener noreferrer"
>
در یک صفحه جدید
</a>{" "}
مشاهده کنید.
</p>
</div>
</section>
</>
);
}
+35
View File
@@ -0,0 +1,35 @@
.wrap {
max-width: 960px;
margin: 0 auto;
}
.frame {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-md);
background: var(--bg-tint);
}
.frame iframe {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
border: 0;
}
.note {
margin-top: 20px;
text-align: center;
text-align-last: center;
color: var(--muted);
font-size: 14px;
}
.note a {
color: var(--brand);
font-weight: 700;
}
+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>
);
}
+300
View File
@@ -0,0 +1,300 @@
# context.md — Negin Dental Clinic (کلینیک دندانپزشکی نگین)
> Interoperability context for AI systems and platforms working on this repository.
> Keep this file updated as the project evolves.
## 1. What this project is
A **Next.js (App Router)** re-implementation ("basic clone") of the Negin Dental
Clinic website, now **wired to the real Meshkee Website API backend** for this
business. The clinic is a real dental practice in **Qom, Iran**.
- **Language:** Persian (`fa`)
- **Direction:** RTL (`dir="rtl"` on `<html>`)
- **Reference site cloned:** `https://www.negindentalclinic.ir`
(built on Meshkee's web-builder; canonical host is the `.ir` domain).
- **Note on the brief:** the original prompt referenced `negindentalclinic.com`.
That `.com` domain is a **parked GoDaddy lander** with no content. The live
site is the `.ir` domain, and all content/assets/palette were taken from there.
- **Meshkee tenant:** this domain is registered as **businessId `36`**
("Negin Dental Clinic") on `api.meshkee.com`, with modules `blog`, `videos`,
`finance` enabled. See §9.
- **Virtual tour:** the original `/assets/virtualtour.html` (old Flash-era
krpano export) is **gone** — 404 on the live origin, and only 6 non-essential
files were ever archived by the Wayback Machine (no tour.xml, no panorama
tiles, no viewer engine). It is **not recoverable**; `/virtual-tour` still
iframes the dead URL pending a decision (new tour, placeholder, or drop the
nav item — ask before changing).
## 2. Tech stack
| Concern | Choice |
| -------------- | ----------------------------------------------------------- |
| Framework | Next.js `15.5.25` (App Router, RSC) |
| UI library | React `19` |
| Styling | Plain CSS + CSS Modules (no Tailwind / CSS-in-JS) |
| Fonts | `next/font/google` — **Vazirmatn** (Persian body), **Cinzel** (Latin/English titles) |
| Images | `next/image` (local files under `public/assets`, plus remote Meshkee-hosted uploads — see `next.config.mjs`) |
| Language/JS | JavaScript (no TypeScript) |
| Path alias | `@/*` → repo root (see `jsconfig.json`) |
| Backend | **Meshkee Website API** (`https://api.meshkee.com/api/v1`) — see §9 |
## 3. Directory map
```
app/
layout.js # <html lang=fa dir=rtl>, font vars, Header + Footer, dynamic favicon
globals.css # design tokens + shared component classes
page.js # Home (+ "latest blog posts" section, hidden when empty)
page.module.css
about-us/page.js # درباره ما
contact-us/page.js # ارتباط با ما (+ ContactForm.js — posts to the Contact API)
services/page.js # خدمات کلینیک
blog/page.js # مقالات — list, backed by GET /tenants/{domain}/blogs
blog/[id]/[slug]/page.js # /blog/{id}/{titleSlug} detail page (canonical Meshkee URL shape)
virtual-tour/page.js # تور مجازی — iframes the (dead) original virtualtour.html
meshkee/sitemap-config.json/route.js # static-page list for the Meshkee dashboard's sitemap importer
components/
Header.js / .module.css # sticky nav + mobile burger + phone + account menu (client component)
Footer.js / .module.css # 4-col footer + Meshkee-style bottom copyright row
PageHero.js / .module.css # bilingual sub-header band for inner pages
lib/
meshkee.js # Meshkee Website API client (tenant fetches, blog helpers, contact POST)
auth-client.js # client-side session bridge for the customer account (see §9.3)
public/assets/
img/… # banners, section photos, service icons (from the original site)
img/Header/header.png # primary logo (کلینیک دندانپزشکی نگین / NEGIN DENTAL CLINIC)
files/… # footer logo + misc images from the original site
```
## 4. Design system (tokens live in `app/globals.css` `:root`)
Palette was sampled from the live site's computed styles.
| Token | Value | Use |
| ----------------- | ---------- | ------------------------------------ |
| `--brand` | `#3b559a` | primary blue — buttons, headings, table header |
| `--brand-dark` | `#2c4380` | gradients, overlays |
| `--cyan` | `#29abe2` | logo cyan — accents, borders |
| `--cyan-deep` | `#1c7ba6` | darker blue for the light-blue buttons |
| `--en-accent` | `#22356b` | dark navy for English/Latin captions & titles |
| `--text` | `#5c6873` | body copy |
| `--muted` | `#818695` | secondary copy |
| `--heading` | `#333232` | dark headings |
| `--bg-tint` | `#eff6fa` | pale-blue section backgrounds |
| `--bg-grey` | `#f7f7f7` | alternate section background |
Typography convention (matches the original's bilingual headings and the brief's
"Cinzel for English titles"): every section shows a small **uppercase Cinzel**
English label above a **Vazirmatn** Persian heading. Helper classes:
`.sec-head`, `.sec-head__en`, `.sec-head__fa`, `.en-title`, plus `.btn`, `.card`.
Body copy (`<p>`) is **justified** by default (`text-align: justify` +
`text-align-last: right`) sitewide. Any paragraph that must stay **centered**
(section leads, card blurbs, empty-state text, …) needs **both**
`text-align: center` **and** `text-align-last: center` set explicitly on that
element/class — `text-align-last` alone governs a single-line paragraph, so
`text-align: center` by itself silently loses to the sitewide default. Grep
for `text-align-last: center` in the CSS to see every guarded spot before
adding a new centered `<p>`.
## 5. Content / data
Static/hardcoded (not from the API — see §9 for what *is* live):
- **Clinic phones:** `025-32945077`, `025-32945088`, `025-32945350`
- **Address:** قم، بلوار امین، کوچه ۲۱، شماره ۵ (روبروی صدا و سیما)
- **Social:** Instagram `neginclinicc` · Aparat `aparat.com/v/3zqwp` · Telegram `negin_clinic`
- **Insurances (طرف قرارداد):** دانا، نوین، ایران، بانک ملت، معلم، کوثر، بانک تجارت، دی، سامان
- **Doctors (weekly schedule on Home):** شجری، صانعی‌پور، دیناروند، کریمی، حسینی مطلق، رسولی، صبری
- The original site's schedule table markup was partially malformed; the
day→doctor mapping here is a best-effort reconstruction. **Verify with the
clinic before treating it as authoritative.**
- **Services (12):** پروتز، خدمات تخصصی، خدمات عمومی، درمان ریشه، ارتودنسی، پریو،
ترمیم تخصصی (ونیر/کامپوزیت)، کودکان، جراحی ایمپلنت، مشاوره، خدمات حضوری
- **Email:** `info@negindentalclinic.ir` (the original site literally showed a
placeholder `noreply@envato.com`; replaced with a sensible clinic address).
`GET /tenants/{domain}/website/business-info` (about/emails/phones/addresses/
social) is wired in `lib/meshkee.js` but **not yet used** — the client's
dashboard has it all empty right now, and the static content above is more
complete. Switch the footer/contact page over to it (with the static content
as fallback) once the dashboard is filled in.
## 6. Known gaps / TODO
1. **Appointment booking** — buttons currently link to `/contact-us`. Decide
on a real booking flow (customer portal? a dedicated form?).
2. **Doctors page** — no dedicated route yet; "معرفی پزشکان" points to `/about-us`.
3. **Virtual tour** — unrecoverable (see §1). Needs a product decision: record
a new tour, or replace `/virtual-tour` + the nav link with something else.
4. **Fonts** — using Vazirmatn as a free stand-in for the original's IRANYekan
(proprietary). Swap if a licensed IRANYekan is available.
5. **SEO — mostly done** (see §10): unique title/description per page, single
H1 with clean H1→H2→H3 nesting (incl. a couple of `visually-hidden` H2s
added purely to fix skipped levels — no visual change), all images have
real/intentionally-empty alt text, `/meshkee/sitemap-config.json` is
published. Still open: JSON-LD (`Dentist` schema). Do **not** add
`app/sitemap.ts` / `app/robots.ts` — Meshkee's nginx proxies
`/sitemap.xml` and `/robots.txt` for the live domain (see AI_PROMPT.md
rule in §9); shipping local copies would conflict with those routes once
this is deployed behind the Meshkee proxy.
6. **`meshkee/static-image-slots`** — the Meshkee AI brief also asks for a
catalog-of-image-slots route. Not implemented — this site doesn't use
static-image slots at all yet; add it if that changes.
7. **Business-info** — see §5; not wired into the UI yet.
## 7. Commands
```bash
npm install
npm run dev # http://localhost:3000
npm run build # production build
npm run start # serve the production build
```
**Don't run `npm run build` while `npm run dev` is pointed at the same
`.next/` folder** — dev and production write incompatible `.next` output and
the dev server will crash with `Cannot find module './NNN.js'`. If that
happens: stop both, `rm -rf .next node_modules/.cache`, restart dev.
## 8. Conventions for contributors / agents
- Keep everything **RTL-first** and in Persian. Latin text only for the Cinzel
accent labels and brand name.
- Use logical CSS properties (`margin-inline`, `inset-inline-*`, `padding-inline`).
- New shared styles → `globals.css` tokens/classes; page-specific → co-located
`*.module.css`.
- Prefer server components; add `"use client"` only for interactivity
(Header menu/account, ContactForm).
- Images: local site assets go in `public/assets/…`; anything fetched from
the Meshkee API (blog covers, favicon, logos) is a remote URL — both go
through `next/image` (remote hosts are wildcarded in `next.config.mjs`
since the upload CDN host isn't fixed).
- Numbers (phone numbers, prices, dates in Latin digits) need `dir="ltr"`
(see the `.ltr` / `[dir="ltr"]` rule in `globals.css`) so they don't get
bidi-reordered inside RTL text.
## 9. Meshkee Website API integration
**Docs:** hub `https://api.meshkee.com/docs/website` · OpenAPI
`https://api.meshkee.com/docs/website/openapi.json` · AI brief
`https://api.meshkee.com/docs/website/AI_PROMPT.md`. Base URL
`https://api.meshkee.com/api/v1`. This site's apex domain
(`negindentalclinic.ir`, configurable via `NEXT_PUBLIC_WEBSITE_DOMAIN`) →
businessId **36**. All client code goes through `lib/meshkee.js` —
add new endpoints there rather than calling `fetch` ad hoc from a page.
### 9.1 What's live right now
| Feature | Endpoint | Where |
|---|---|---|
| Favicon (fallback to local logo if none uploaded) | `GET /tenants/{domain}/website/favicon` | `app/layout.js` (`generateMetadata`) |
| Blog list | `GET /tenants/{domain}/blogs` | `app/blog/page.js` |
| Blog detail | `GET /tenants/{domain}/blogs/by-id/{id}` | `app/blog/[id]/[slug]/page.js` |
| Latest posts on home | `GET /tenants/{domain}/blogs?pageSize=3` | `app/page.js` (hidden when 0 posts) |
| Contact form | `POST /tenants/{domain}/contact-submissions` | `app/contact-us/ContactForm.js` — shows a success/error toast and resets the form |
As of this writing the tenant has **0 published blogs** and an **empty
business-info** (no logo/favicon/emails/phones/addresses uploaded in the
dashboard yet) — `lib/meshkee.js` fetchers return `null`/empty and every
consumer has an explicit empty state, so none of this crashes; it'll just
start showing real content the moment the dashboard is filled in. The contact
form was tested end-to-end against the live API (real `201` response).
### 9.2 Blog field names — unverified
The public API doesn't publish a formal response schema for blog items (no
`components.schemas` in the OpenAPI doc, and there are 0 posts to inspect a
real payload from). `lib/meshkee.js`'s `blogTitle()` / `blogExcerpt()` /
`blogCover()` / `blogDate()` / `blogContentHtml()` helpers read defensively
across the likely field-name variants (`titleFa`/`title`, `coverUrl`/
`coverImage`/`image.url`, `content`/`body`/`html`, …). **Once the client
publishes a real post, check the actual response shape and simplify/fix these
helpers** — they're written to degrade gracefully, not to be the final word
on field names.
### 9.3 Header auth state — best-effort, needs confirmation
The brief: user icon → `customer.{domain}` when signed out; when signed in,
show "**{name} عزیز**" with a **پروفایل من / خروج** dropdown, plus
**مدیریت وبسایت** (→ `app.{domain}`, the admin dashboard — matches the
`app.negindentalclinic.ir/auth/login` link found on the original live site)
for admins.
Auth on this API is **Bearer JWT** (`POST /auth/login` returns
`{ user, accessToken, refreshToken }` in the body — not a shared cookie), and
this storefront **has no login form of its own** (by design — login happens
on the separate customer portal). The public docs don't say how a session on
`customer.{domain}` is supposed to reach this site. `lib/auth-client.js`
implements the standard fallback for that gap: the customer portal is assumed
to redirect back here with `?token=&refreshToken=` in the URL, which gets
captured into `localStorage` and used to call `GET /auth/me`.
**This hand-back mechanism is an assumption, not a documented contract.**
If Meshkee's customer portal actually bridges sessions differently (shared
cookie on the apex domain, postMessage, etc.), update `lib/auth-client.js` —
every consumer of auth state goes through `useMeshkeeSession()` /
`isAdminUser()`, so the header and any future account-aware UI won't need to
change. Also unverified: the exact field name for "is this user an admin" on
the `/auth/me` response (`isAdminUser()` checks `isAdmin`/`role`/`type`
defensively).
### 9.4 Hard rules from the Meshkee AI brief (apply if/when this grows)
- Never invent endpoints outside `/tenants/{domain}/...` (public) and
`/businesses/{businessId}/...` + Bearer JWT (authenticated cart/orders/
favorites) — this site currently uses neither the cart nor any auth-write
endpoints.
- Don't add `app/sitemap.ts` / `app/robots.ts` or ship `public/sitemap.xml` —
Meshkee's proxy serves those from the API once this site is live on a
Meshkee-managed domain.
- Don't call `/torob_api/...` from Next.js — it's an nginx-proxied path, not
a storefront fetch.
## 10. SEO
Per the Meshkee SEO brief (unique title/description, one clean H1→H2→H3
outline per page, real `alt` text, sitemap-config publishing):
- Every route has its own `metadata` (or `generateMetadata` for the blog
detail page, pulling from CMS `title`/excerpt) — no page relies on the root
layout's default title/description except `/` itself.
- Exactly one `<h1>` per page (the hero title on Home/blog-detail, or
`PageHero`'s `<h1>` everywhere else), with no skipped levels. Two spots
(Home's quick-access card row, the contact page's info-card row) needed a
`<h2 className="visually-hidden">` added purely to fix an H1→H3 skip — they
weren't meant to have a visible heading there, so this is invisible but
keeps the outline correct. Same fix applied to the blog list's card grid.
Blog body HTML from the CMS has any `<h1>` it might contain auto-demoted to
`<h2>` (`blogContentHtml()` in `lib/meshkee.js`) so a CMS author can't
accidentally create a second H1 on a detail page.
- All images go through `next/image` with real `alt` text, except the small
service/feature-card icons, which use `alt=""` deliberately — they sit
right next to a text label that already says the same thing, so an
accessible name would just be read twice by a screen reader.
- `app/meshkee/sitemap-config.json/route.js` scans `app/` for `page.js` files
at build time (`export const dynamic = "force-static"`) and returns
`{ baseUrl, staticPages }` for the Meshkee dashboard's sitemap importer.
Dynamic/CMS routes (`blog/[id]/[slug]`) are excluded on purpose — Meshkee
generates their sitemap entries itself (`/sitemap-blogs.xml`). New static
pages are picked up automatically; give a page a non-default
`changefreq`/`priority` by adding it to `PAGE_META` in that file.
- Not done: JSON-LD structured data (a `Dentist`/`MedicalBusiness` schema on
Home and/or `about-us` would be the natural next step).
## 11. Scroll-reveal sequence animation (Home)
`components/Reveal.js` wraps a block and toggles `reveal--in` (via
`IntersectionObserver`) once it's ~18% into view; CSS in `globals.css`
(`.reveal`, `.reveal--{up,down,left,right,scale}`) does the actual
fade/slide/scale-in transition. Stagger a sequence with the `delay` prop
(ms); pick a direction with `from`. Used throughout `app/page.js`: the hero
plays a staggered entrance on load (eyebrow → title → lead → buttons → art),
the two split sections slide in from the side each element visually sits on,
and every card grid (feature cards, services, latest blog) staggers
card-by-card. Pure CSS transitions (opacity/transform only, no JS-driven
animation loop), auto-disabled under `prefers-reduced-motion: reduce`, and a
`<noscript>` override in `app/layout.js` forces everything visible if JS
never runs. Reuse it on other pages the same way if they need the same
treatment — it's not Home-specific.
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./*"]
}
}
}
+121
View File
@@ -0,0 +1,121 @@
"use client";
// Lightweight client-side session bridge for the Meshkee customer account.
//
// This storefront has no login form of its own — signing in happens on the
// separate customer portal (CUSTOMER_PORTAL_URL). What's implemented here is
// the "come back logged in" half of that flow:
//
// 1. The customer portal sends the shopper back to this site with
// `?token=<accessToken>&refreshToken=<refreshToken>` in the URL.
// 2. On first render we pick those up, store them, and strip them from
// the address bar.
// 3. From then on we call `GET /auth/me` with the stored access token to
// know who's signed in (and to detect an admin, for "مدیریت وبسایت").
//
// NOTE: the exact hand-back mechanism isn't published in the public API
// docs (auth is plain Bearer-JWT, not a shared cookie), so this is the
// standard "redirect back with tokens in the query string" pattern. If
// Meshkee's customer portal actually bridges sessions a different way
// (shared cookie, postMessage, …), swap the implementation in this file —
// everything that reads auth state goes through the hooks below.
import { useCallback, useEffect, useState } from "react";
import { API_BASE } from "./meshkee";
const ACCESS_TOKEN_KEY = "meshkee_access_token";
const REFRESH_TOKEN_KEY = "meshkee_refresh_token";
export function getStoredAccessToken() {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(ACCESS_TOKEN_KEY);
} catch {
return null;
}
}
function storeTokens({ accessToken, refreshToken }) {
try {
if (accessToken) window.localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
if (refreshToken) window.localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
} catch {
/* localStorage unavailable (private mode, etc.) — session just won't persist */
}
}
export function clearStoredTokens() {
try {
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
window.localStorage.removeItem(REFRESH_TOKEN_KEY);
} catch {
/* noop */
}
}
/** Picks up `?token=&refreshToken=` from a customer-portal hand-back, once. */
function captureTokenFromUrl() {
const params = new URLSearchParams(window.location.search);
const token = params.get("token") || params.get("accessToken");
const refreshToken = params.get("refreshToken");
if (!token) return false;
storeTokens({ accessToken: token, refreshToken });
params.delete("token");
params.delete("accessToken");
params.delete("refreshToken");
const query = params.toString();
const cleanUrl = window.location.pathname + (query ? `?${query}` : "") + window.location.hash;
window.history.replaceState({}, "", cleanUrl);
return true;
}
async function fetchCurrentUser(accessToken) {
const res = await fetch(`${API_BASE}/auth/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return null;
const data = await res.json().catch(() => null);
return data?.user ?? data ?? null;
}
/** True when the account object looks like a business admin, not a regular customer. */
export function isAdminUser(user) {
if (!user) return false;
const role = String(user.role || user.type || "").toLowerCase();
return Boolean(user.isAdmin) || role.includes("admin") || role.includes("owner");
}
/**
* Session state for the header: `{ status: 'loading' | 'guest' | 'in', user, logout }`.
*/
export function useMeshkeeSession() {
const [state, setState] = useState({ status: "loading", user: null });
const load = useCallback(async () => {
captureTokenFromUrl();
const token = getStoredAccessToken();
if (!token) {
setState({ status: "guest", user: null });
return;
}
const user = await fetchCurrentUser(token);
if (user) {
setState({ status: "in", user });
} else {
clearStoredTokens();
setState({ status: "guest", user: null });
}
}, []);
useEffect(() => {
load();
}, [load]);
const logout = useCallback(() => {
clearStoredTokens();
setState({ status: "guest", user: null });
}, []);
return { ...state, logout };
}
+162
View File
@@ -0,0 +1,162 @@
// Meshkee Website API client
// Docs: https://api.meshkee.com/docs/website
// OpenAPI: https://api.meshkee.com/docs/website/openapi.json
//
// This site's apex domain is registered on Meshkee as businessId 36
// ("Negin Dental Clinic"). Public content lives under
// /tenants/{domain}/... and needs no authentication.
export const API_BASE = "https://api.meshkee.com/api/v1";
export const WEBSITE_DOMAIN =
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || "negindentalclinic.ir";
// Customer account portal / business admin dashboard — separate Meshkee
// subdomains for this business (not implemented by this Next.js app).
export const CUSTOMER_PORTAL_URL = `https://customer.${WEBSITE_DOMAIN}`;
export const ADMIN_DASHBOARD_URL = `https://app.${WEBSITE_DOMAIN}`;
/**
* Fetch a public `/tenants/{domain}/...` endpoint.
* Returns `null` on any failure instead of throwing, so pages can render
* a sane fallback rather than crash when the backend has nothing yet.
*/
async function tenantGet(path, { searchParams, revalidate = 300 } = {}) {
const url = new URL(`${API_BASE}/tenants/${WEBSITE_DOMAIN}${path}`);
if (searchParams) {
for (const [key, value] of Object.entries(searchParams)) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, value);
}
}
}
try {
const res = await fetch(url.toString(), {
headers: { Accept: "application/json" },
next: { revalidate },
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
/** { faviconUrl, logoUrl, logoDarkUrl, hasDedicatedFavicon } */
export async function getFavicon() {
return tenantGet("/website/favicon", { revalidate: 3600 });
}
/** About, contacts, addresses, social — mostly unfilled for this tenant today. */
export async function getBusinessInfo() {
return tenantGet("/website/business-info", { revalidate: 3600 });
}
/** { items, total, page, pageSize } */
export async function getBlogs({ page = 1, pageSize = 12, tag, type, categoryId, title } = {}) {
const data = await tenantGet("/blogs", {
searchParams: { page, pageSize, tag, type, categoryId, title },
revalidate: 120,
});
return data || { items: [], total: 0, page, pageSize };
}
/** { blog } — preferred for /blog/{id}/{titleSlug} detail pages */
export async function getBlogById(blogId) {
const data = await tenantGet(`/blogs/by-id/${encodeURIComponent(blogId)}`, {
revalidate: 120,
});
return data?.blog ?? null;
}
export async function getBlogBySlug(slug) {
const data = await tenantGet(`/blogs/${encodeURIComponent(slug)}`, {
revalidate: 120,
});
return data?.blog ?? null;
}
/**
* Submit the contact form. Client-side only (called from the browser).
* Required: title, name, text. Optional: email, cellNumber.
*/
export async function submitContact({ title, name, text, email, cellNumber }) {
const res = await fetch(
`${API_BASE}/tenants/${WEBSITE_DOMAIN}/contact-submissions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, name, text, email, cellNumber }),
}
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.message || "ارسال پیام با خطا مواجه شد.");
}
return data;
}
// ---------- blog field helpers ----------
// The public API doesn't publish a formal schema for blog items, so read
// defensively across the field-name variants Meshkee uses elsewhere
// (e.g. nameFa/titleFa-style localization) and fall back gracefully.
export function blogTitle(blog) {
return blog?.titleFa || blog?.title || blog?.nameFa || blog?.name || "بدون عنوان";
}
export function blogExcerpt(blog) {
return (
blog?.excerpt ||
blog?.summary ||
blog?.description ||
(typeof blog?.content === "string" ? stripHtml(blog.content).slice(0, 160) : "")
);
}
export function blogCover(blog) {
return (
blog?.coverUrl ||
blog?.coverImage ||
blog?.imageUrl ||
blog?.image?.url ||
blog?.thumbnailUrl ||
null
);
}
export function blogDate(blog) {
return blog?.publishedAt || blog?.createdAt || null;
}
export function blogContentHtml(blog) {
const html = blog?.content || blog?.body || blog?.html || "";
// The page already renders its own <h1> (the blog title). Demote any
// <h1> the CMS editor put in the body copy so we never end up with two
// on the same page.
return html.replace(/<(\/?)h1(\s|>)/gi, "<$1h2$2");
}
export function stripHtml(html) {
return String(html).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
export function faDate(iso) {
if (!iso) return "";
try {
return new Intl.DateTimeFormat("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(iso));
} catch {
return "";
}
}
/** Build the canonical `{titleSlug}` segment from a Persian title. */
export function slugifyFa(title) {
return String(title || "")
.trim()
.replace(/\s+/g, "-")
.replace(/[^؀-ۿ0-9A-Za-z-]/g, "");
}
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
images: {
// Blog covers, favicons and logos are uploaded through the Meshkee
// business dashboard and served from whichever CDN host is
// provisioned for this tenant — not a fixed, known-in-advance domain.
remotePatterns: [{ protocol: "https", hostname: "**" }],
},
};
export default nextConfig;
+959
View File
@@ -0,0 +1,959 @@
{
"name": "negin-dental-clinic",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "negin-dental-clinic",
"version": "1.0.0",
"dependencies": {
"next": "^15.5.25",
"react": "19.0.0",
"react-dom": "19.0.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@next/env": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.25.tgz",
"integrity": "sha512-42h1lLr07vl4gawALP1hsgRZjHB1xYa58JfUfHwr0f7jG/zhPakh5GHkADHXOC9ZxUvlQFOPIrp7s6qX4DezPQ==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.25.tgz",
"integrity": "sha512-w+RR0v/QuApnWEjRGm1z6gcObKwGMb5YPA7V3bzBEVSBpMFUXprer0tS27UxjUcEnqbhL7Zuzohej79B6rYmBg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.25.tgz",
"integrity": "sha512-QiGGBUSakt8S1H4Lt9Ehsh6Ja87axiBnQQgysOObvCbI7iUfJnRGntF1P64S4/ijuHFnSB8KLsEddkY3nN26uw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.25.tgz",
"integrity": "sha512-ehLos/66zo0d/mJCU5u96a/VDcr01aaUrX0o/i16UdInxz8qPTCDSxGtjk/Lps1sIr1RJFdiX3hxc0fxJo+cPA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.25.tgz",
"integrity": "sha512-ZVMrqLiJ7DiChgmbkQwFtdhAnUkSH/4p7tB29QY+giATb0Q/XGHNRSKAb/B8XGDHRUaA67NepOW5W8u3ZRJBAA==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.25.tgz",
"integrity": "sha512-UOewtDGkTMJTiODrEdeLZ50yGb59xCZSriNpXkfPMxRRgwDkGc7i8mLWqV5076wEdb+Ca/XN7MhJyM3CupyNyQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.25.tgz",
"integrity": "sha512-UBHwA8AhkCZgtRfU1aJpunuAJe/6gZv6jDESQe4p5MjTb5V0YEeJBWCdNqx15Vj3x+5jmauRfeMJSjfQj9HGFQ==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.25.tgz",
"integrity": "sha512-QcFcPRr16djk5IqK5+e8O80eZfgWzIvVBXfitIq0tQ/uc+eyfdoZ0NmKc0cnbIJyfVwREapKuG97YcxWA9gcpA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.25.tgz",
"integrity": "sha512-zREeykps3ndWr9egJgvJKqVkkDuaw6Zrrg23cYBos0ygydFkAWYU4+PaPVwXzP1eAYQJe53ShSK45iDM529BOg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@swc/helpers": {
"version": "0.5.15",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/nanoid": {
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next": {
"version": "15.5.25",
"resolved": "https://registry.npmjs.org/next/-/next-15.5.25.tgz",
"integrity": "sha512-OMWNulIIqKM2ykvC2qMjIt0IoavB4UB2SCs4iXJ6z6847FvyH8jBmBWcvrF5iuhTu8Przh20Fo/aoszIdqx4PA==",
"license": "MIT",
"dependencies": {
"@next/env": "15.5.25",
"@swc/helpers": "0.5.15",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.4.31",
"styled-jsx": "5.1.6"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.5.25",
"@next/swc-darwin-x64": "15.5.25",
"@next/swc-linux-arm64-gnu": "15.5.25",
"@next/swc-linux-arm64-musl": "15.5.25",
"@next/swc-linux-x64-gnu": "15.5.25",
"@next/swc-linux-x64-musl": "15.5.25",
"@next/swc-win32-arm64-msvc": "15.5.25",
"@next/swc-win32-x64-msvc": "15.5.25",
"sharp": "^0.34.3 || ^0.35.4"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/react": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.0.0.tgz",
"integrity": "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.0.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.0.0.tgz",
"integrity": "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.25.0"
},
"peerDependencies": {
"react": "^19.0.0"
}
},
"node_modules/scheduler": {
"version": "0.25.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz",
"integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "negin-dental-clinic",
"version": "1.0.0",
"private": true,
"description": "Next.js clone of negindentalclinic.ir — Negin Dental Clinic (کلینیک دندانپزشکی نگین), Qom",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "^15.5.25",
"react": "19.0.0",
"react-dom": "19.0.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 526 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 753 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 272 KiB