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>
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "negin-dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/node_modules
|
||||
/.next
|
||||
/out
|
||||
/build
|
||||
.DS_Store
|
||||
*.pem
|
||||
npm-debug.log*
|
||||
.env*.local
|
||||
.vercel
|
||||
next-env.d.ts
|
||||
@@ -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).
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 & 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import {
|
||||
getBlogById,
|
||||
blogTitle,
|
||||
blogExcerpt,
|
||||
blogCover,
|
||||
blogDate,
|
||||
blogContentHtml,
|
||||
faDate,
|
||||
} from "@/lib/meshkee";
|
||||
import styles from "./blog-detail.module.css";
|
||||
|
||||
export const revalidate = 120;
|
||||
|
||||
export async function generateMetadata({ params }) {
|
||||
const { id } = await params;
|
||||
const blog = await getBlogById(id);
|
||||
if (!blog) return { title: "مقاله" };
|
||||
const title = blogTitle(blog);
|
||||
const description = blogExcerpt(blog) || undefined;
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images: blogCover(blog) ? [blogCover(blog)] : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BlogDetailPage({ params }) {
|
||||
const { id } = await params;
|
||||
const blog = await getBlogById(id);
|
||||
if (!blog) notFound();
|
||||
|
||||
const title = blogTitle(blog);
|
||||
const cover = blogCover(blog);
|
||||
const html = blogContentHtml(blog);
|
||||
|
||||
return (
|
||||
<article className="section">
|
||||
<div className={`container ${styles.wrap}`}>
|
||||
<Link href="/blog" className={styles.back}>
|
||||
← بازگشت به مقالات
|
||||
</Link>
|
||||
|
||||
<h1 className={styles.title}>{title}</h1>
|
||||
{blogDate(blog) ? <p className={styles.date}>{faDate(blogDate(blog))}</p> : null}
|
||||
|
||||
{cover ? (
|
||||
<div className={styles.cover}>
|
||||
<Image src={cover} alt={title} width={1200} height={640} priority />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{html ? (
|
||||
<div className={styles.content} dangerouslySetInnerHTML={{ __html: html }} />
|
||||
) : (
|
||||
<p className={styles.content}>{blogExcerpt(blog)}</p>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
.empty {
|
||||
max-width: 620px;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty h2 {
|
||||
font-size: 28px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.empty p {
|
||||
color: var(--muted);
|
||||
margin-bottom: 28px;
|
||||
text-align: center;
|
||||
text-align-last: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-6px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.cardArt {
|
||||
display: block;
|
||||
aspect-ratio: 16 / 10;
|
||||
background: var(--bg-tint);
|
||||
}
|
||||
|
||||
.cardArt img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.cardArtFallback {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, var(--brand), var(--cyan));
|
||||
opacity: 0.16;
|
||||
}
|
||||
|
||||
.cardBody {
|
||||
padding: 22px 24px 26px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cardDate {
|
||||
font-size: 12px;
|
||||
color: var(--cyan-deep);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cardBody h3 {
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cardBody p {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cardMore {
|
||||
margin-top: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--brand);
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.cardMore::after {
|
||||
content: " ←";
|
||||
}
|
||||
|
||||
.pager {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-top: 50px;
|
||||
}
|
||||
|
||||
.pagerLink {
|
||||
min-width: 38px;
|
||||
padding: 8px 12px;
|
||||
text-align: center;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--heading);
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.pagerLink:hover {
|
||||
border-color: var(--brand);
|
||||
color: var(--brand);
|
||||
}
|
||||
|
||||
.pagerLinkActive {
|
||||
background: var(--brand);
|
||||
border-color: var(--brand);
|
||||
color: #fff;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import PageHero from "@/components/PageHero";
|
||||
import { getBlogs, blogTitle, blogExcerpt, blogCover, blogDate, faDate, slugifyFa } from "@/lib/meshkee";
|
||||
import styles from "./blog.module.css";
|
||||
|
||||
export const metadata = {
|
||||
title: "مقالات",
|
||||
description:
|
||||
"مقالات و مطالب آموزشی کلینیک دندانپزشکی نگین درباره سلامت دهان و دندان، مراقبت روزانه از دندانها، ایمپلنت، ارتودنسی و آخرین روشهای درمانی.",
|
||||
};
|
||||
|
||||
export const revalidate = 120;
|
||||
|
||||
export default async function BlogPage({ searchParams }) {
|
||||
const page = Number((await searchParams)?.page) || 1;
|
||||
const { items, total, pageSize } = await getBlogs({ page, pageSize: 12 });
|
||||
const totalPages = Math.max(1, Math.ceil((total || 0) / (pageSize || 12)));
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHero en="Blog" fa="مقالات" lead="مطالب آموزشی درباره سلامت دهان و دندان." />
|
||||
|
||||
<section className="section">
|
||||
<div className="container">
|
||||
{items.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<h2>بهزودی</h2>
|
||||
<p>
|
||||
مقالات کلینیک نگین در حال آمادهسازی است. برای دریافت مشاوره و اطلاعات
|
||||
بیشتر با ما در تماس باشید.
|
||||
</p>
|
||||
<Link href="/contact-us" className="btn">
|
||||
تماس با ما
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="visually-hidden">لیست مقالات</h2>
|
||||
<div className="grid grid--3">
|
||||
{items.map((blog) => {
|
||||
const title = blogTitle(blog);
|
||||
const cover = blogCover(blog);
|
||||
const href = `/blog/${blog.id}/${slugifyFa(title)}`;
|
||||
return (
|
||||
<article key={blog.id} className={styles.card}>
|
||||
<Link href={href} className={styles.cardArt}>
|
||||
{cover ? (
|
||||
<Image src={cover} alt={title} width={420} height={260} />
|
||||
) : (
|
||||
<span className={styles.cardArtFallback} aria-hidden="true" />
|
||||
)}
|
||||
</Link>
|
||||
<div className={styles.cardBody}>
|
||||
{blogDate(blog) ? (
|
||||
<span className={styles.cardDate}>{faDate(blogDate(blog))}</span>
|
||||
) : null}
|
||||
<h3>
|
||||
<Link href={href}>{title}</Link>
|
||||
</h3>
|
||||
{blogExcerpt(blog) ? <p>{blogExcerpt(blog)}</p> : null}
|
||||
<Link href={href} className={styles.cardMore}>
|
||||
ادامه مطلب
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 ? (
|
||||
<nav className={styles.pager} aria-label="صفحهبندی مقالات">
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
|
||||
<Link
|
||||
key={p}
|
||||
href={p === 1 ? "/blog" : `/blog?page=${p}`}
|
||||
className={`${styles.pagerLink} ${p === page ? styles.pagerLinkActive : ""}`}
|
||||
>
|
||||
{p}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 & Developed By Meshkee E-Commerce Team</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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, "");
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 626 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 526 B |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 3.6 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 753 KiB |
|
After Width: | Height: | Size: 248 KiB |
|
After Width: | Height: | Size: 404 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 272 KiB |