Initial commit: Negin Dental Clinic website

Next.js (App Router) clone of negindentalclinic.ir, wired to the
Meshkee Website API (blog, contact form, favicon), with on-page SEO
and a scroll-reveal animation sequence on the homepage.

See context.md for full project context.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 18:28:54 +03:30
co-authored by Claude Sonnet 5
commit f16b47966b
70 changed files with 4810 additions and 0 deletions
+121
View File
@@ -0,0 +1,121 @@
"use client";
// Lightweight client-side session bridge for the Meshkee customer account.
//
// This storefront has no login form of its own — signing in happens on the
// separate customer portal (CUSTOMER_PORTAL_URL). What's implemented here is
// the "come back logged in" half of that flow:
//
// 1. The customer portal sends the shopper back to this site with
// `?token=<accessToken>&refreshToken=<refreshToken>` in the URL.
// 2. On first render we pick those up, store them, and strip them from
// the address bar.
// 3. From then on we call `GET /auth/me` with the stored access token to
// know who's signed in (and to detect an admin, for "مدیریت وبسایت").
//
// NOTE: the exact hand-back mechanism isn't published in the public API
// docs (auth is plain Bearer-JWT, not a shared cookie), so this is the
// standard "redirect back with tokens in the query string" pattern. If
// Meshkee's customer portal actually bridges sessions a different way
// (shared cookie, postMessage, …), swap the implementation in this file —
// everything that reads auth state goes through the hooks below.
import { useCallback, useEffect, useState } from "react";
import { API_BASE } from "./meshkee";
const ACCESS_TOKEN_KEY = "meshkee_access_token";
const REFRESH_TOKEN_KEY = "meshkee_refresh_token";
export function getStoredAccessToken() {
if (typeof window === "undefined") return null;
try {
return window.localStorage.getItem(ACCESS_TOKEN_KEY);
} catch {
return null;
}
}
function storeTokens({ accessToken, refreshToken }) {
try {
if (accessToken) window.localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
if (refreshToken) window.localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
} catch {
/* localStorage unavailable (private mode, etc.) — session just won't persist */
}
}
export function clearStoredTokens() {
try {
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
window.localStorage.removeItem(REFRESH_TOKEN_KEY);
} catch {
/* noop */
}
}
/** Picks up `?token=&refreshToken=` from a customer-portal hand-back, once. */
function captureTokenFromUrl() {
const params = new URLSearchParams(window.location.search);
const token = params.get("token") || params.get("accessToken");
const refreshToken = params.get("refreshToken");
if (!token) return false;
storeTokens({ accessToken: token, refreshToken });
params.delete("token");
params.delete("accessToken");
params.delete("refreshToken");
const query = params.toString();
const cleanUrl = window.location.pathname + (query ? `?${query}` : "") + window.location.hash;
window.history.replaceState({}, "", cleanUrl);
return true;
}
async function fetchCurrentUser(accessToken) {
const res = await fetch(`${API_BASE}/auth/me`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!res.ok) return null;
const data = await res.json().catch(() => null);
return data?.user ?? data ?? null;
}
/** True when the account object looks like a business admin, not a regular customer. */
export function isAdminUser(user) {
if (!user) return false;
const role = String(user.role || user.type || "").toLowerCase();
return Boolean(user.isAdmin) || role.includes("admin") || role.includes("owner");
}
/**
* Session state for the header: `{ status: 'loading' | 'guest' | 'in', user, logout }`.
*/
export function useMeshkeeSession() {
const [state, setState] = useState({ status: "loading", user: null });
const load = useCallback(async () => {
captureTokenFromUrl();
const token = getStoredAccessToken();
if (!token) {
setState({ status: "guest", user: null });
return;
}
const user = await fetchCurrentUser(token);
if (user) {
setState({ status: "in", user });
} else {
clearStoredTokens();
setState({ status: "guest", user: null });
}
}, []);
useEffect(() => {
load();
}, [load]);
const logout = useCallback(() => {
clearStoredTokens();
setState({ status: "guest", user: null });
}, []);
return { ...state, logout };
}
+162
View File
@@ -0,0 +1,162 @@
// Meshkee Website API client
// Docs: https://api.meshkee.com/docs/website
// OpenAPI: https://api.meshkee.com/docs/website/openapi.json
//
// This site's apex domain is registered on Meshkee as businessId 36
// ("Negin Dental Clinic"). Public content lives under
// /tenants/{domain}/... and needs no authentication.
export const API_BASE = "https://api.meshkee.com/api/v1";
export const WEBSITE_DOMAIN =
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN || "negindentalclinic.ir";
// Customer account portal / business admin dashboard — separate Meshkee
// subdomains for this business (not implemented by this Next.js app).
export const CUSTOMER_PORTAL_URL = `https://customer.${WEBSITE_DOMAIN}`;
export const ADMIN_DASHBOARD_URL = `https://app.${WEBSITE_DOMAIN}`;
/**
* Fetch a public `/tenants/{domain}/...` endpoint.
* Returns `null` on any failure instead of throwing, so pages can render
* a sane fallback rather than crash when the backend has nothing yet.
*/
async function tenantGet(path, { searchParams, revalidate = 300 } = {}) {
const url = new URL(`${API_BASE}/tenants/${WEBSITE_DOMAIN}${path}`);
if (searchParams) {
for (const [key, value] of Object.entries(searchParams)) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, value);
}
}
}
try {
const res = await fetch(url.toString(), {
headers: { Accept: "application/json" },
next: { revalidate },
});
if (!res.ok) return null;
return await res.json();
} catch {
return null;
}
}
/** { faviconUrl, logoUrl, logoDarkUrl, hasDedicatedFavicon } */
export async function getFavicon() {
return tenantGet("/website/favicon", { revalidate: 3600 });
}
/** About, contacts, addresses, social — mostly unfilled for this tenant today. */
export async function getBusinessInfo() {
return tenantGet("/website/business-info", { revalidate: 3600 });
}
/** { items, total, page, pageSize } */
export async function getBlogs({ page = 1, pageSize = 12, tag, type, categoryId, title } = {}) {
const data = await tenantGet("/blogs", {
searchParams: { page, pageSize, tag, type, categoryId, title },
revalidate: 120,
});
return data || { items: [], total: 0, page, pageSize };
}
/** { blog } — preferred for /blog/{id}/{titleSlug} detail pages */
export async function getBlogById(blogId) {
const data = await tenantGet(`/blogs/by-id/${encodeURIComponent(blogId)}`, {
revalidate: 120,
});
return data?.blog ?? null;
}
export async function getBlogBySlug(slug) {
const data = await tenantGet(`/blogs/${encodeURIComponent(slug)}`, {
revalidate: 120,
});
return data?.blog ?? null;
}
/**
* Submit the contact form. Client-side only (called from the browser).
* Required: title, name, text. Optional: email, cellNumber.
*/
export async function submitContact({ title, name, text, email, cellNumber }) {
const res = await fetch(
`${API_BASE}/tenants/${WEBSITE_DOMAIN}/contact-submissions`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, name, text, email, cellNumber }),
}
);
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data?.message || "ارسال پیام با خطا مواجه شد.");
}
return data;
}
// ---------- blog field helpers ----------
// The public API doesn't publish a formal schema for blog items, so read
// defensively across the field-name variants Meshkee uses elsewhere
// (e.g. nameFa/titleFa-style localization) and fall back gracefully.
export function blogTitle(blog) {
return blog?.titleFa || blog?.title || blog?.nameFa || blog?.name || "بدون عنوان";
}
export function blogExcerpt(blog) {
return (
blog?.excerpt ||
blog?.summary ||
blog?.description ||
(typeof blog?.content === "string" ? stripHtml(blog.content).slice(0, 160) : "")
);
}
export function blogCover(blog) {
return (
blog?.coverUrl ||
blog?.coverImage ||
blog?.imageUrl ||
blog?.image?.url ||
blog?.thumbnailUrl ||
null
);
}
export function blogDate(blog) {
return blog?.publishedAt || blog?.createdAt || null;
}
export function blogContentHtml(blog) {
const html = blog?.content || blog?.body || blog?.html || "";
// The page already renders its own <h1> (the blog title). Demote any
// <h1> the CMS editor put in the body copy so we never end up with two
// on the same page.
return html.replace(/<(\/?)h1(\s|>)/gi, "<$1h2$2");
}
export function stripHtml(html) {
return String(html).replace(/<[^>]*>/g, " ").replace(/\s+/g, " ").trim();
}
export function faDate(iso) {
if (!iso) return "";
try {
return new Intl.DateTimeFormat("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(iso));
} catch {
return "";
}
}
/** Build the canonical `{titleSlug}` segment from a Persian title. */
export function slugifyFa(title) {
return String(title || "")
.trim()
.replace(/\s+/g, "-")
.replace(/[^؀-ۿ0-9A-Za-z-]/g, "");
}