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