Compare commits

..
5 Commits
Author SHA1 Message Date
Alireza HassaniandCursor f4911ccee9 Add API favicon and customer portal account menu.
Load the tenant favicon from Meshkee API and replace the header login control with shared-auth session UI for profile, logout, and admin website management.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 18:06:31 +03:30
Alireza HassaniandCursor e6404eaaec Add contact and about pages with shared CTA buttons.
Wire forms to the Meshkee contact API, tighten page-title spacing, and polish outline button hover so it fades cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 17:54:34 +03:30
Alireza HassaniandCursor b0ba7b1de8 Add instruction list and detail pages with header category menu.
Fetch tutorials from the Meshkee API with category filtering, matching the portfolio pattern and exposing آموزش‌ها in the main navigation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-01 11:51:19 +03:30
Alireza HassaniandCursor c588b249cc Publish Meshkee sitemap-config and harden on-page SEO.
Hand sitemap/robots to the Meshkee API proxy and expose build-time static routes plus clearer titles, canonicals, and image alts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 22:42:28 +03:30
Alireza HassaniandCursor 5d7da0f268 Add Meshkee brand favicon assets for browser and Apple icons.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 22:36:23 +03:30
44 changed files with 2510 additions and 218 deletions
+18
View File
@@ -0,0 +1,18 @@
### Sitemap + robots.txt
- Do NOT ship public/sitemap.xml or public/robots.txt (nginx proxies both to Meshkee API).
- Live sitemap is an index: /sitemap.xml → /sitemap-main.xml + /sitemap-products.xml.
- Publish GET /meshkee/sitemap-config.json generated at build time from app routes.
- Include public static routes automatically; exclude auth/checkout/cart/admin paths.
- Canonical detail URLs (Meshkee defaults — omit templates unless customized):
- Product: /products/{id}/{nameFaSlug} → GET /tenants/{domain}/products/by-id/{id}
- Category: /products/category/{id}/{nameFaSlug} → GET /tenants/{domain}/categories/by-id/{id} (then list products with categoryId)
- Blog: /blog/{id}/{titleSlug} → GET /tenants/{domain}/blogs/by-id/{id}
- Portfolio: /portfolios/{id}/{titleFaSlug} → GET /tenants/{domain}/portfolios/by-id/{id}
- Slugify: Farsi letters kept, spaces → -, strip other punctuation. Do not use legacy DB slug in public URLs.
- After deploy: business dashboard → Website → Settings → Sync sitemap config (optional for CMS URLs; needed for static pages).
### On-page SEO (every public page)
- Unique <title> and meta description on every crawlable page
- Exactly one H1; H2/H3 for real sections
- All content images must have meaningful alt (CMS title when available)
- Prefer CMS fields for titles/descriptions; never leave Next defaults
+2
View File
@@ -4,6 +4,8 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev",
"prebuild": "node scripts/generate-sitemap-config.mjs",
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint" "lint": "eslint"
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 660 B

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 644 B

After

Width:  |  Height:  |  Size: 968 B

+111
View File
@@ -0,0 +1,111 @@
/**
* Scans app page routes and writes src/lib/sitemap-config.generated.json
* for GET /meshkee/sitemap-config.json (Meshkee sitemap sync).
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const appDir = path.join(root, "src", "app");
const outFile = path.join(root, "src", "lib", "sitemap-config.generated.json");
const EXCLUDE_SEGMENTS = new Set([
"api",
"meshkee",
"admin",
"auth",
"login",
"checkout",
"cart",
"account",
]);
const PRIORITY = {
"/": { changefreq: "weekly", priority: 1.0 },
"/cms-website": { changefreq: "monthly", priority: 0.9 },
"/website": { changefreq: "monthly", priority: 0.9 },
"/website-qom": { changefreq: "monthly", priority: 0.9 },
"/seo": { changefreq: "monthly", priority: 0.8 },
"/dedicated-application": { changefreq: "monthly", priority: 0.8 },
"/meshkee-cms": { changefreq: "monthly", priority: 0.8 },
"/graphic-designer": { changefreq: "monthly", priority: 0.7 },
"/portfolio": { changefreq: "weekly", priority: 0.8 },
"/instruction": { changefreq: "weekly", priority: 0.8 },
"/blog": { changefreq: "weekly", priority: 0.8 },
};
function websiteDomain() {
const fromEnv =
process.env.NEXT_PUBLIC_WEBSITE_DOMAIN ||
process.env.WEBSITE_DOMAIN ||
process.env.NEXT_PUBLIC_MESHKEE_DOMAIN ||
process.env.NEXT_PUBLIC_SITE_URL;
if (fromEnv) {
return fromEnv.replace(/^https?:\/\//, "").replace(/\/$/, "");
}
return "www.meshkee.com";
}
function walkPages(dir, segments = []) {
/** @type {string[]} */
const pages = [];
if (!fs.existsSync(dir)) return pages;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (EXCLUDE_SEGMENTS.has(entry.name)) continue;
if (/^\[.+\]$/.test(entry.name) || /^\(.+\)$/.test(entry.name)) {
if (/^\(.+\)$/.test(entry.name)) {
pages.push(...walkPages(full, segments));
}
continue;
}
pages.push(...walkPages(full, [...segments, entry.name]));
continue;
}
if (
entry.name === "page.tsx" ||
entry.name === "page.ts" ||
entry.name === "page.jsx" ||
entry.name === "page.js"
) {
const routePath = segments.length ? `/${segments.join("/")}` : "/";
pages.push(routePath);
}
}
return pages;
}
function main() {
const paths = [...new Set(walkPages(appDir))].sort((a, b) => {
if (a === "/") return -1;
if (b === "/") return 1;
return a.localeCompare(b);
});
const staticPages = paths.map((routePath) => {
const meta = PRIORITY[routePath] ?? { priority: 0.5 };
return { path: routePath, ...meta };
});
const config = {
baseUrl: `https://${websiteDomain()}`,
staticPages,
};
fs.mkdirSync(path.dirname(outFile), { recursive: true });
fs.writeFileSync(outFile, `${JSON.stringify(config, null, 2)}\n`, "utf8");
console.log(
`Wrote ${path.relative(root, outFile)} (${staticPages.length} static pages)`,
);
}
main();
+207
View File
@@ -0,0 +1,207 @@
import type { Metadata } from "next";
import Link from "next/link";
import { Button } from "@/components/Button";
import { ContentPageShell } from "@/components/ContentPageShell";
import { images } from "@/data/assets";
import { services, site } from "@/data/home";
export const metadata: Metadata = {
title: "درباره ما",
description:
"آشنایی با تیم برنامه‌نویسی و تجارت الکترونیک مشکی — طراحی وبسایت، اپلیکیشن، CMS، سئو و گرافیک.",
alternates: { canonical: "/about" },
};
const highlights = [
{
title: "تیم متخصص",
body: "برای هر بخش — از گرافیک و برنامه‌نویسی تا سرور و سئو — متخصص جداگانه‌ای کنار پروژه‌تان می‌ماند.",
},
{
title: "یکپارچگی کامل",
body: "وبسایت، اپلیکیشن و سیستم‌های مدیریتی طوری طراحی می‌شوند که با هم کار کنند، نه جدا از هم.",
},
{
title: "رشد پایدار",
body: "از طراحی اولیه تا سئو و مدیریت محتوا، مسیر رشد برند آنلاین شما را همراهی می‌کنیم.",
},
];
const stats = [
{ value: "112+", label: "وبسایت" },
{ value: "28+", label: "لوگو و هویت بصری" },
{ value: "7+", label: "اپلیکیشن" },
];
export default function AboutPage() {
return (
<ContentPageShell flushTop>
<section>
<div className="page-title-band">
<div className="relative mx-auto max-w-7xl px-5 pb-9 pt-[calc(var(--header-height)+0.15rem)] lg:px-8 lg:pb-12 lg:pt-[calc(var(--header-height)+0.3rem)]">
<p
aria-hidden
className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+0.5rem)] z-0 text-[clamp(1.75rem,5.5vw,3.75rem)] font-light uppercase leading-none tracking-[0.05em] text-ink/[0.07] lg:inset-x-8 lg:top-[calc(var(--header-height)+0.65rem)]"
>
About
</p>
<div className="reveal relative z-10 max-w-2xl">
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal leading-tight tracking-tight text-ink">
درباره ما
</h1>
<p className="mt-1 text-sm leading-6 text-muted md:text-base">
تیم برنامه‌نویسی و تجارت الکترونیک مشکی — راهکارهای مدرن برای
تجارت شما.
</p>
</div>
</div>
</div>
<div className="bg-white">
<div className="mx-auto grid max-w-7xl items-center gap-12 px-5 py-16 lg:grid-cols-2 lg:gap-16 lg:px-8 lg:py-24">
<div className="reveal min-w-0">
<p className="mb-4 text-sm text-muted">ما کی هستیم؟</p>
<h2 className="mb-6 max-w-[16em] text-[clamp(1.2rem,3vw,2rem)] font-normal leading-snug tracking-tight text-ink">
{site.fullTitle}
</h2>
<p className="text-body mb-5 text-sm leading-8 text-ink-soft md:text-base md:leading-9">
مشکی یک تیم تخصصی در حوزه طراحی و توسعه محصولات دیجیتال است. ما
کنار کسب‌وکارها می‌ایستیم تا وبسایت، اپلیکیشن، سیستم مدیریتی و
هویت بصری‌شان را از ایده تا اجرا پیش ببریم.
</p>
<p className="text-body mb-8 text-sm leading-8 text-muted md:text-base md:leading-9">
دفتر ما در قم است و پروژه‌ها را با تمرکز روی کیفیت فنی، تجربه
کاربری و رشد پایدار جلو می‌بریم — نه فقط تحویل یک محصول، بلکه
ساخت مسیری برای فروش و حضور آنلاین قوی‌تر.
</p>
<div className="flex flex-wrap gap-3">
<Button href="/contact">تماس با ما</Button>
<Button href="/portfolio" variant="outline">
نمونه‌کارها
</Button>
</div>
</div>
<div className="reveal reveal-delay-1 relative mx-auto w-full max-w-md lg:max-w-none">
<div className="pointer-events-none absolute inset-8 rounded-full bg-[radial-gradient(circle,var(--glow),transparent_70%)] blur-2xl" />
<div className="relative aspect-square">
<img
src={images.hero}
alt={site.fullTitle}
className="h-full w-full object-contain drop-shadow-[0_30px_60px_rgba(14,17,22,0.14)]"
/>
</div>
</div>
</div>
<div className="border-y border-line/60 bg-[#FAFAFA]">
<div className="mx-auto grid max-w-7xl grid-cols-1 divide-y divide-line/60 sm:grid-cols-3 sm:divide-x sm:divide-y-0">
{stats.map((stat) => (
<div
key={stat.label}
className="reveal flex items-center gap-4 px-5 py-8 lg:px-8 lg:py-10"
>
<p
className="latin shrink-0 text-[clamp(2rem,4vw,2.75rem)] font-light leading-none tracking-tight text-ink"
dir="ltr"
>
{stat.value}
</p>
<span aria-hidden className="h-10 w-px shrink-0 bg-line/80" />
<p className="text-sm text-ink-soft">{stat.label}</p>
</div>
))}
</div>
</div>
<div className="mx-auto max-w-7xl px-5 py-16 lg:px-8 lg:py-24">
<div className="reveal mb-12 max-w-2xl">
<h2 className="mb-3 text-[clamp(1.2rem,3vw,2rem)] font-normal tracking-tight text-ink">
چطور کار می‌کنیم؟
</h2>
<p className="text-sm leading-8 text-muted md:text-base">
سه اصل ساده که در همه پروژه‌های مشکی ثابت می‌ماند.
</p>
</div>
<div className="grid gap-10 md:grid-cols-3 md:gap-8">
{highlights.map((item, index) => (
<div
key={item.title}
className={`reveal min-w-0 ${
index === 1
? "reveal-delay-1"
: index === 2
? "reveal-delay-2"
: ""
}`}
>
<p className="latin mb-3 text-xs tracking-[0.18em] text-muted">
0{index + 1}
</p>
<h3 className="mb-3 text-lg font-normal text-ink">
{item.title}
</h3>
<p className="text-sm leading-8 text-muted">{item.body}</p>
</div>
))}
</div>
</div>
<div className="border-t border-line/50 bg-[#FAFAFA]">
<div className="mx-auto max-w-7xl px-5 py-16 lg:px-8 lg:py-20">
<div className="reveal mb-10 max-w-2xl">
<h2 className="mb-3 text-[clamp(1.2rem,3vw,2rem)] font-normal tracking-tight text-ink">
خدمات ما
</h2>
<p className="text-sm leading-8 text-muted md:text-base">
از ایده تا اجرا، هر بخشی که برای حضور دیجیتال نیاز دارید.
</p>
</div>
<ul className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{services.map((service, index) => (
<li key={service.href}>
<Link
href={service.href}
className={`reveal group block rounded-2xl border border-line/60 bg-white px-5 py-5 transition hover:border-ink/20 ${
index % 3 === 1
? "reveal-delay-1"
: index % 3 === 2
? "reveal-delay-2"
: ""
}`}
>
<p className="mb-2 text-base text-ink group-hover:text-ink">
{service.title}
</p>
<p className="latin title-en-sm text-muted">
{service.titleEn}
</p>
</Link>
</li>
))}
</ul>
<div className="reveal mt-14 flex flex-wrap items-center justify-between gap-6 border-t border-line/60 pt-10">
<div>
<p className="mb-2 text-lg text-ink">
آماده‌اید پروژه بعدی را شروع کنید؟
</p>
<p className="text-sm text-muted">
از طریق فرم تماس یا شماره{" "}
<span className="latin" dir="ltr">
{site.phone}
</span>{" "}
با ما در ارتباط باشید.
</p>
</div>
<Button href="/contact">برو به تماس با ما</Button>
</div>
</div>
</div>
</div>
</section>
</ContentPageShell>
);
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
import { SITE_DOMAIN } from "@/lib/api";
const API_BASE_URL = "https://api.meshkee.com/api/v1";
export async function POST(request: Request) {
const body = (await request.json()) as {
title?: string;
name?: string;
email?: string;
cellNumber?: string;
text?: string;
};
if (!body.title?.trim() || !body.name?.trim() || !body.text?.trim()) {
return NextResponse.json(
{ message: "نام، موضوع و متن پیام الزامی است." },
{ status: 400 },
);
}
const response = await fetch(
`${API_BASE_URL}/tenants/${SITE_DOMAIN}/contact-submissions`,
{
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
title: body.title.trim(),
name: body.name.trim(),
email: body.email?.trim() || undefined,
cellNumber: body.cellNumber?.trim() || undefined,
text: body.text.trim(),
}),
cache: "no-store",
},
);
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.status });
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

+13 -3
View File
@@ -48,6 +48,12 @@ export async function generateMetadata({
return { return {
title: `${blog.title} | مشکی`, title: `${blog.title} | مشکی`,
description: blog.abstract ?? undefined, description: blog.abstract ?? undefined,
alternates: { canonical: `/blog/${blog.slug}` },
openGraph: {
title: blog.title,
description: blog.abstract ?? undefined,
type: "article",
},
}; };
} catch { } catch {
return { title: "مقاله | مشکی" }; return { title: "مقاله | مشکی" };
@@ -78,10 +84,10 @@ export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
<ContentPageShell flushTop> <ContentPageShell flushTop>
<article className="pb-20 lg:pb-28"> <article className="pb-20 lg:pb-28">
<div className="page-title-band mb-8 md:mb-10"> <div className="page-title-band mb-8 md:mb-10">
<div className="relative mx-auto max-w-7xl px-5 pb-4 pt-[calc(var(--header-height)+0.75rem)] lg:px-8 lg:pb-5 lg:pt-[calc(var(--header-height)+1rem)]"> <div className="relative mx-auto max-w-7xl px-5 pb-10 pt-[calc(var(--header-height)+0.15rem)] lg:px-8 lg:pb-12 lg:pt-[calc(var(--header-height)+0.3rem)]">
<p <p
aria-hidden aria-hidden
className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+2.4rem)] z-0 text-[clamp(1.75rem,5.5vw,3.75rem)] font-light uppercase leading-none tracking-[0.05em] text-ink/[0.07] lg:inset-x-8 lg:top-[calc(var(--header-height)+2.6rem)]" className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+1.75rem)] z-0 text-[clamp(1.75rem,5.5vw,3.75rem)] font-light uppercase leading-none tracking-[0.05em] text-ink/[0.07] lg:inset-x-8 lg:top-[calc(var(--header-height)+1.95rem)]"
> >
{titleEn} {titleEn}
</p> </p>
@@ -126,7 +132,11 @@ export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
<div className="mx-auto max-w-3xl px-5 lg:px-8"> <div className="mx-auto max-w-3xl px-5 lg:px-8">
{cover ? ( {cover ? (
<div className="mb-5 overflow-hidden rounded-2xl"> <div className="mb-5 overflow-hidden rounded-2xl">
<BlogCoverImage src={cover} className="mx-auto max-h-[28rem]" /> <BlogCoverImage
src={cover}
alt={blog.title}
className="mx-auto max-h-[28rem]"
/>
</div> </div>
) : null} ) : null}
+1 -1
View File
@@ -69,7 +69,7 @@ export default async function BlogListPage({ searchParams }: BlogPageProps) {
> >
<Link href={`/blog/${post.slug}`} className="block"> <Link href={`/blog/${post.slug}`} className="block">
<div className="relative mb-4 overflow-hidden rounded-2xl"> <div className="relative mb-4 overflow-hidden rounded-2xl">
<BlogCoverImage src={image} /> <BlogCoverImage src={image} alt={post.title} />
</div> </div>
<h2 className="group-text-hover mb-2 line-clamp-2 whitespace-normal text-lg font-normal leading-8 text-ink"> <h2 className="group-text-hover mb-2 line-clamp-2 whitespace-normal text-lg font-normal leading-8 text-ink">
{post.title} {post.title}
+147
View File
@@ -0,0 +1,147 @@
import type { Metadata } from "next";
import { ContentPageShell } from "@/components/ContentPageShell";
import { ContactForm } from "@/components/ContactForm";
import { site, socials } from "@/data/home";
export const metadata: Metadata = {
title: "تماس با ما",
description:
"با تیم مشکی در تماس باشید — فرم تماس، تلفن، ایمیل و آدرس دفتر قم.",
alternates: { canonical: "/contact" },
};
export default function ContactPage() {
return (
<ContentPageShell flushTop>
<section>
<div className="page-title-band">
<div className="relative mx-auto max-w-7xl px-5 pb-9 pt-[calc(var(--header-height)+0.15rem)] lg:px-8 lg:pb-12 lg:pt-[calc(var(--header-height)+0.3rem)]">
<p
aria-hidden
className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+0.5rem)] z-0 text-[clamp(1.75rem,5.5vw,3.75rem)] font-light uppercase leading-none tracking-[0.05em] text-ink/[0.07] lg:inset-x-8 lg:top-[calc(var(--header-height)+0.65rem)]"
>
Contact
</p>
<div className="reveal relative z-10 max-w-2xl">
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal leading-tight tracking-tight text-ink">
تماس با ما
</h1>
<p className="mt-1 text-sm leading-6 text-muted md:text-base">
سوالی دارید یا می‌خواهید پروژه‌تان را شروع کنید؟ با تیم مشکی در
تماس باشید.
</p>
</div>
</div>
</div>
<div className="relative flex min-h-[calc(100svh-var(--header-height)-7.5rem)] items-center overflow-hidden bg-[linear-gradient(180deg,#fafafa_0%,#eef1f5_45%,#f3f5f7_100%)] py-16 lg:py-20">
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_80%_20%,rgba(196,77,184,0.1),transparent_50%)]" />
<div className="relative mx-auto grid w-full max-w-7xl items-center gap-14 px-5 lg:grid-cols-[1fr_1.1fr] lg:gap-16 lg:px-8">
<div className="reveal flex min-w-0 flex-col gap-8">
<div className="grid grid-cols-[auto_minmax(0,1fr)] items-baseline gap-x-4 gap-y-6">
<span className="text-sm text-muted">شماره تماس</span>
<a
href={site.phoneHref}
className="latin link-hover justify-self-start text-lg font-medium tracking-tight text-ink md:text-xl"
dir="ltr"
>
{site.phone}
</a>
<span className="text-sm text-muted">ایمیل</span>
<a
href={`mailto:${site.email}`}
className="latin link-hover justify-self-start text-sm text-ink-soft md:text-base"
dir="ltr"
>
{site.email}
</a>
<span className="text-sm text-muted">آدرس</span>
<a
href={site.mapUrl}
target="_blank"
rel="noopener noreferrer"
className="link-hover justify-self-start text-start text-sm leading-7 text-ink-soft md:text-base md:leading-8"
>
{site.address}
</a>
<span className="pt-1 text-sm text-muted">شبکه‌های اجتماعی</span>
<div className="flex flex-wrap items-center justify-start gap-3 pt-1">
{socials.map((social) => (
<a
key={social.label}
href={social.href}
target="_blank"
rel="noopener noreferrer"
aria-label={social.label}
title={social.label}
className="flex h-11 w-11 items-center justify-center rounded-full border border-line/70 bg-white/60 text-ink-soft shadow-[0_8px_20px_rgba(14,17,22,0.04)] backdrop-blur-sm transition hover:border-ink/20 hover:text-ink"
>
<svg
viewBox="0 0 24 24"
fill="currentColor"
className="h-4 w-4"
aria-hidden
>
<path d={social.icon} />
</svg>
</a>
))}
</div>
</div>
<div className="relative overflow-hidden rounded-[1.5rem] border border-line/70 bg-white/50 shadow-[0_16px_40px_rgba(14,17,22,0.05)] backdrop-blur-sm">
<div className="relative aspect-[16/10] w-full overflow-hidden sm:aspect-[2/1]">
<iframe
title="موقعیت دفتر مشکی روی نقشه"
src={`https://maps.google.com/maps?q=${site.mapLat},${site.mapLng}&z=16&hl=fa&output=embed`}
className="absolute inset-0 h-full w-full border-0"
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
allowFullScreen
/>
</div>
<a
href={site.mapUrl}
target="_blank"
rel="noopener noreferrer"
className="absolute bottom-3 inset-inline-3 inline-flex max-w-max items-center gap-2 rounded-full border border-white/70 bg-white/90 px-3.5 py-2 text-sm text-ink shadow-sm backdrop-blur-md transition hover:bg-white"
>
<svg
viewBox="0 0 24 24"
fill="currentColor"
className="h-4 w-4 shrink-0 text-accent"
aria-hidden
>
<path d="M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5a2.5 2.5 0 110-5 2.5 2.5 0 010 5z" />
</svg>
مشاهده روی نقشه
</a>
</div>
</div>
<div className="reveal reveal-delay-1 relative min-w-0 overflow-hidden rounded-[1.75rem] border border-white/70 bg-white/45 p-5 shadow-[0_20px_50px_rgba(14,17,22,0.06)] backdrop-blur-xl sm:p-6 md:p-8">
<div
aria-hidden
className="pointer-events-none absolute inset-0 bg-[linear-gradient(145deg,rgba(255,255,255,0.75)_0%,rgba(255,255,255,0.2)_40%,transparent_70%)]"
/>
<div className="relative">
<h2 className="mb-2 text-xl font-normal text-ink md:text-2xl">
فرم تماس
</h2>
<p className="mb-8 text-sm text-muted">
ما همیشه دنبال دوستای جدید هستیم.
</p>
<ContactForm variant="light" />
</div>
</div>
</div>
</div>
</section>
</ContentPageShell>
);
}
+112 -5
View File
@@ -117,20 +117,127 @@ h4 {
} }
/* Gradient hover utilities */ /* Gradient hover utilities */
/* Simple fade for primary CTAs */
.btn-primary { .btn-primary {
position: relative;
isolation: isolate;
background: var(--ink); background: var(--ink);
color: var(--surface); color: var(--surface);
transition: cursor: pointer;
background 0.25s ease, transition: color 0.3s ease;
color 0.25s ease,
box-shadow 0.25s ease;
} }
.btn-primary:hover { .btn-primary::before {
content: "";
position: absolute;
inset: 0;
z-index: -1;
border-radius: inherit;
background: var(--brand-gradient); background: var(--brand-gradient);
opacity: 0;
transition: opacity 0.3s ease;
}
.btn-primary:hover:not(:disabled) {
color: #fff; color: #fff;
} }
.btn-primary:hover:not(:disabled)::before {
opacity: 1;
}
.btn-primary:disabled {
cursor: not-allowed;
}
/* Outline CTA: opacity-only fades (no blink on mouse-out) */
.btn-outline {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
isolation: isolate;
border: 1px solid transparent;
background-color: #fff;
color: var(--ink-soft);
cursor: pointer;
}
.btn-outline::before,
.btn-outline::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
padding: 1px;
pointer-events: none;
transition: opacity 0.3s ease;
-webkit-mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
mask:
linear-gradient(#fff 0 0) content-box,
linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
}
.btn-outline::after {
background: var(--line);
opacity: 1;
}
.btn-outline::before {
background: var(--brand-gradient);
opacity: 0;
}
.btn-outline:hover::after {
opacity: 0;
}
.btn-outline:hover::before {
opacity: 1;
}
.btn-outline-label {
position: relative;
z-index: 1;
display: inline-flex;
align-items: center;
gap: 0.375rem;
}
.btn-outline-label-base {
transition: opacity 0.3s ease;
}
.btn-outline-label-grad {
position: absolute;
inset: 0;
display: inline-flex;
align-items: center;
justify-content: inherit;
gap: 0.375rem;
background-image: var(--brand-gradient);
background-clip: text;
-webkit-background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease;
}
.btn-outline:hover .btn-outline-label-base {
opacity: 0;
}
.btn-outline:hover .btn-outline-label-grad {
opacity: 1;
}
.link-hover { .link-hover {
display: inline-block; display: inline-block;
width: fit-content; width: fit-content;
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

+218
View File
@@ -0,0 +1,218 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { BlogCoverImage } from "@/components/BlogCoverImage";
import { ContentPageShell } from "@/components/ContentPageShell";
import { HtmlContent } from "@/components/HtmlContent";
import {
findInstructionCategory,
getRootInstructionCategories,
instructionCategoryHref,
instructionCategoryLabelFa,
topInstructionCategories,
type InstructionCategory,
} from "@/data/instructionCategories";
import {
ApiNotFoundError,
authorName,
formatFaDate,
getInstruction,
getInstructionCategories,
getInstructionCoverImage,
} from "@/lib/api";
type InstructionDetailPageProps = {
params: Promise<{ slug: string }>;
};
function mapApiCategories(
items: Awaited<ReturnType<typeof getInstructionCategories>>,
): InstructionCategory[] {
return items.map((item) => ({
id: String(item.id),
slug: item.slug,
nameEn: item.name,
nameFa: item.nameFa || item.name,
parentId: item.parentId,
sortOrder: item.sortOrder ?? 0,
}));
}
function findRootForCategory(
categoryId: string | number | null | undefined,
categories: InstructionCategory[],
): InstructionCategory | undefined {
if (categoryId == null) return undefined;
let current = categories.find((c) => c.id === String(categoryId));
if (!current) return undefined;
while (current?.parentId) {
const parent = categories.find((c) => c.id === String(current?.parentId));
if (!parent) break;
current = parent;
}
return current;
}
export async function generateMetadata({
params,
}: InstructionDetailPageProps): Promise<Metadata> {
const { slug } = await params;
try {
const instruction = await getInstruction(slug);
const title = instruction.titleFa || instruction.title;
return {
title: `${title} | مشکی`,
description: instruction.abstract ?? undefined,
alternates: { canonical: `/instruction/${instruction.slug}` },
openGraph: {
title,
description: instruction.abstract ?? undefined,
type: "article",
},
};
} catch {
return { title: "آموزش | مشکی" };
}
}
export default async function InstructionDetailPage({
params,
}: InstructionDetailPageProps) {
const { slug } = await params;
let instruction;
try {
instruction = await getInstruction(slug);
} catch (error) {
if (error instanceof ApiNotFoundError) notFound();
throw error;
}
const cover = getInstructionCoverImage(instruction);
const date = formatFaDate(instruction.publishedAt);
const author = authorName(instruction.author);
const title = instruction.titleFa || instruction.title;
let allCategories: InstructionCategory[] = topInstructionCategories;
try {
allCategories = mapApiCategories(await getInstructionCategories());
} catch {
allCategories = topInstructionCategories;
}
const categoryFa = instructionCategoryLabelFa(
instruction.categoryName,
allCategories,
);
const rootCategory =
findRootForCategory(instruction.categoryId, allCategories) ??
findInstructionCategory(
instruction.categoryName,
getRootInstructionCategories(allCategories),
);
const titleEn =
instruction.titleEn ??
(instruction.titleFa && instruction.title !== instruction.titleFa
? instruction.title
: null);
const categoryHref = rootCategory
? instructionCategoryHref(rootCategory.slug)
: "/instruction";
return (
<ContentPageShell flushTop>
<article className="pb-20 lg:pb-28">
<div className="page-title-band mb-8 md:mb-10">
<div className="relative mx-auto max-w-7xl px-5 pb-10 pt-[calc(var(--header-height)+0.15rem)] lg:px-8 lg:pb-12 lg:pt-[calc(var(--header-height)+0.3rem)]">
{titleEn ? (
<p
aria-hidden
className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+1.75rem)] z-0 text-[clamp(1.75rem,5.5vw,3.75rem)] font-light uppercase leading-none tracking-[0.05em] text-ink/[0.07] lg:inset-x-8 lg:top-[calc(var(--header-height)+1.95rem)]"
>
{titleEn}
</p>
) : null}
<div className="reveal relative z-10 max-w-3xl">
<nav
aria-label="مسیر صفحه"
className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-muted"
>
<Link
href="/instruction"
className="link-hover transition hover:text-ink"
>
آموزش‌ها
</Link>
{rootCategory || categoryFa ? (
<>
<span aria-hidden className="text-line">
/
</span>
<Link
href={categoryHref}
className="link-hover transition hover:text-ink"
>
{rootCategory?.nameFa ?? categoryFa}
</Link>
</>
) : null}
<span aria-hidden className="text-line">
/
</span>
<span className="line-clamp-1 text-ink-soft">{title}</span>
</nav>
<h1 className="whitespace-normal text-[clamp(1.35rem,3.8vw,2.5rem)] font-normal leading-tight tracking-tight text-ink">
{title}
</h1>
{instruction.abstract ? (
<p className="mt-2 text-justify text-sm leading-7 text-muted md:text-base">
{instruction.abstract}
</p>
) : null}
</div>
</div>
</div>
<div className="mx-auto max-w-3xl px-5 lg:px-8">
{cover ? (
<div className="mb-5 overflow-hidden rounded-2xl">
<BlogCoverImage
src={cover}
alt={title}
className="mx-auto max-h-[28rem]"
/>
</div>
) : null}
{(date || author || categoryFa) ? (
<div className="mb-8 flex flex-wrap items-center justify-between gap-4 border-b border-line/70 pb-5 text-xs text-muted">
<span>
{author ? `نویسنده: ${author}` : categoryFa ?? ""}
</span>
{date ? (
<time dateTime={instruction.publishedAt ?? undefined}>
{date}
</time>
) : null}
</div>
) : cover ? (
<div className="mb-8 border-b border-line/70" />
) : null}
{instruction.descriptionHtml ? (
<div className="reveal reveal-delay-2">
<HtmlContent html={instruction.descriptionHtml} />
</div>
) : null}
</div>
</article>
</ContentPageShell>
);
}
+227
View File
@@ -0,0 +1,227 @@
import type { Metadata } from "next";
import Link from "next/link";
import { BlogCoverImage } from "@/components/BlogCoverImage";
import { ContentPageShell } from "@/components/ContentPageShell";
import { Pagination } from "@/components/Pagination";
import {
findInstructionCategory,
getDescendantInstructionCategoryIds,
getRootInstructionCategories,
instructionCategoryHref,
instructionCategoryLabelFa,
topInstructionCategories,
type InstructionCategory,
} from "@/data/instructionCategories";
import {
getInstructionCategories,
getInstructionCoverImage,
getInstructions,
getInstructionsByCategory,
} from "@/lib/api";
export const metadata: Metadata = {
title: "آموزش‌ها",
description: "راهنماها و آموزش‌های کاربردی تیم مشکی.",
alternates: { canonical: "/instruction" },
};
const PAGE_SIZE = 12;
type InstructionPageProps = {
searchParams: Promise<{ page?: string; category?: string }>;
};
function mapApiCategories(
items: Awaited<ReturnType<typeof getInstructionCategories>>,
): InstructionCategory[] {
return items.map((item) => ({
id: String(item.id),
slug: item.slug,
nameEn: item.name,
nameFa: item.nameFa || item.name,
parentId: item.parentId,
sortOrder: item.sortOrder ?? 0,
}));
}
export default async function InstructionListPage({
searchParams,
}: InstructionPageProps) {
const { page: pageParam, category: categoryParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
let allCategories: InstructionCategory[] = topInstructionCategories;
try {
allCategories = mapApiCategories(await getInstructionCategories());
} catch {
allCategories = topInstructionCategories;
}
const rootCategories = getRootInstructionCategories(allCategories);
const activeCategory =
findInstructionCategory(categoryParam, allCategories) ??
findInstructionCategory(categoryParam, rootCategories);
let items: Awaited<ReturnType<typeof getInstructions>>["items"] = [];
let total = 0;
try {
if (activeCategory) {
const data = await getInstructionsByCategory({
categoryId: activeCategory.id,
categoryIds: getDescendantInstructionCategoryIds(
allCategories,
activeCategory.id,
),
page,
pageSize: PAGE_SIZE,
});
items = data.items;
total = data.total;
} else {
const data = await getInstructions({ page, pageSize: PAGE_SIZE });
items = data.items;
total = data.total;
}
} catch {
items = [];
total = 0;
}
const paginationQuery = activeCategory
? { category: activeCategory.slug }
: undefined;
const filterCategories =
rootCategories.length > 0 ? rootCategories : topInstructionCategories;
return (
<ContentPageShell flushTop>
<section className="pb-20 lg:pb-28">
<div className="page-title-band mb-8 md:mb-10">
<div className="relative mx-auto max-w-7xl px-5 pb-9 pt-[calc(var(--header-height)+0.15rem)] lg:px-8 lg:pb-12 lg:pt-[calc(var(--header-height)+0.3rem)]">
<p
aria-hidden
className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+0.5rem)] z-0 text-[clamp(1.75rem,5.5vw,3.75rem)] font-light uppercase leading-none tracking-[0.05em] text-ink/[0.07] lg:inset-x-8 lg:top-[calc(var(--header-height)+0.65rem)]"
>
Instructions
</p>
<div className="reveal relative z-10 max-w-2xl">
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal leading-tight tracking-tight text-ink">
آموزش‌ها
</h1>
<p className="mt-1 text-sm leading-6 text-muted md:text-base">
راهنماها و آموزش‌های کاربردی برای استفاده از خدمات مشکی.
</p>
</div>
</div>
</div>
<div className="mx-auto max-w-7xl px-5 lg:px-8">
<div className="reveal mb-10 flex flex-wrap gap-2 md:mb-12">
<Link
href="/instruction"
className={`rounded-full border px-4 py-2 text-sm transition ${
!activeCategory
? "border-ink bg-ink text-surface"
: "border-line/70 text-ink-soft hover:border-ink/30 hover:text-ink"
}`}
>
همه
</Link>
{filterCategories.map((category) => {
const active = activeCategory?.id === category.id;
return (
<Link
key={category.id}
href={instructionCategoryHref(category.slug)}
className={`rounded-full border px-4 py-2 text-sm transition ${
active
? "border-ink bg-ink text-surface"
: "border-line/70 text-ink-soft hover:border-ink/30 hover:text-ink"
}`}
>
{category.nameFa}
</Link>
);
})}
</div>
{items.length === 0 ? (
<p className="text-sm text-muted">
آموزشی برای نمایش وجود ندارد.
</p>
) : (
<div
key={`${activeCategory?.slug ?? "all"}-${page}`}
className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3"
>
{items.map((item, index) => {
const image = getInstructionCoverImage(item);
const categoryFa = instructionCategoryLabelFa(
item.categoryName,
allCategories,
);
const titleFa = item.titleFa || item.title;
const titleEn =
item.titleEn ??
(item.titleFa && item.title !== item.titleFa
? item.title
: null);
return (
<article
key={item.id}
className={`reveal group overflow-hidden rounded-2xl border border-line/70 ${
index % 3 === 1
? "reveal-delay-1"
: index % 3 === 2
? "reveal-delay-2"
: ""
}`}
>
<Link href={`/instruction/${item.slug}`} className="block">
<div className="relative overflow-hidden bg-paper-deep">
{image ? (
<BlogCoverImage src={image} alt={titleFa} />
) : null}
</div>
<div className="px-4 py-4">
{categoryFa ? (
<p className="mb-2 text-[11px] text-muted">
{categoryFa}
</p>
) : null}
<h2 className="group-text-hover line-clamp-2 whitespace-normal text-lg font-normal leading-7 text-ink">
{titleFa}
</h2>
{titleEn ? (
<p className="latin text-[12px] font-light uppercase tracking-[0.22em] text-ink-soft/70">
{titleEn}
</p>
) : null}
{item.abstract ? (
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted">
{item.abstract}
</p>
) : null}
</div>
</Link>
</article>
);
})}
</div>
)}
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={total}
basePath="/instruction"
query={paginationQuery}
/>
</div>
</section>
</ContentPageShell>
);
}
+33 -10
View File
@@ -1,5 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Montserrat, Vazirmatn } from "next/font/google"; import { Montserrat, Vazirmatn } from "next/font/google";
import { Providers } from "@/components/Providers";
import { getFavicon, getTenant } from "@/lib/api";
import "./globals.css"; import "./globals.css";
const vazirmatn = Vazirmatn({ const vazirmatn = Vazirmatn({
@@ -15,8 +17,18 @@ const montserrat = Montserrat({
}); });
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.meshkee.com"; const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.meshkee.com";
const fallbackIcon = "/images/brand/favicon.png";
export const metadata: Metadata = { export async function generateMetadata(): Promise<Metadata> {
let icon = fallbackIcon;
try {
const favicon = await getFavicon();
icon = favicon.faviconUrl || favicon.logoUrl || fallbackIcon;
} catch {
// keep local brand favicon
}
return {
metadataBase: new URL(siteUrl), metadataBase: new URL(siteUrl),
title: { title: {
default: "تیم برنامه‌نویسی و تجارت الکترونیک مشکی | Meshkee", default: "تیم برنامه‌نویسی و تجارت الکترونیک مشکی | Meshkee",
@@ -39,12 +51,12 @@ export const metadata: Metadata = {
], ],
icons: { icons: {
icon: [ icon: [
{ url: "/images/brand/meshkee-mark.svg", type: "image/svg+xml" }, { url: icon },
{ url: "/favicon.ico", sizes: "any" }, { url: "/favicon.ico", sizes: "any" },
{ url: "/images/brand/favicon.png", type: "image/png", sizes: "512x512" }, { url: "/favicon.png", type: "image/png", sizes: "48x48" },
], ],
apple: [{ url: "/images/brand/favicon.png", type: "image/png", sizes: "512x512" }], apple: [{ url: icon }],
shortcut: ["/favicon.ico"], shortcut: [icon],
}, },
openGraph: { openGraph: {
type: "website", type: "website",
@@ -56,7 +68,7 @@ export const metadata: Metadata = {
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستم‌های مدیریتی، سئو و گرافیک توسط تیم مشکی.", "راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستم‌های مدیریتی، سئو و گرافیک توسط تیم مشکی.",
images: [ images: [
{ {
url: "/images/brand/favicon.png", url: icon,
width: 512, width: 512,
height: 512, height: 512,
alt: "لوگوی مشکی", alt: "لوگوی مشکی",
@@ -68,7 +80,7 @@ export const metadata: Metadata = {
title: "تیم برنامه‌نویسی و تجارت الکترونیک مشکی | Meshkee", title: "تیم برنامه‌نویسی و تجارت الکترونیک مشکی | Meshkee",
description: description:
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستم‌های مدیریتی، سئو و گرافیک توسط تیم مشکی.", "راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستم‌های مدیریتی، سئو و گرافیک توسط تیم مشکی.",
images: ["/images/brand/favicon.png"], images: [icon],
}, },
robots: { robots: {
index: true, index: true,
@@ -84,20 +96,31 @@ export const metadata: Metadata = {
alternates: { alternates: {
canonical: "/", canonical: "/",
}, },
}; };
}
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
let businessId: string | null = null;
try {
const tenant = await getTenant();
businessId = String(tenant.id);
} catch {
businessId = null;
}
return ( return (
<html <html
lang="fa" lang="fa"
dir="rtl" dir="rtl"
className={`${vazirmatn.variable} ${montserrat.variable} h-full antialiased`} className={`${vazirmatn.variable} ${montserrat.variable} h-full antialiased`}
> >
<body className="min-h-full font-sans text-ink">{children}</body> <body className="min-h-full font-sans text-ink">
<Providers businessId={businessId}>{children}</Providers>
</body>
</html> </html>
); );
} }
@@ -0,0 +1,17 @@
import sitemapConfig from "@/lib/sitemap-config.generated.json";
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Cache-Control": "public, max-age=60",
"Content-Type": "application/json; charset=utf-8",
};
export async function OPTIONS() {
return new Response(null, { status: 204, headers });
}
export async function GET() {
return Response.json(sitemapConfig, { headers });
}
+10
View File
@@ -1,3 +1,4 @@
import type { Metadata } from "next";
import { AppSection } from "@/components/AppSection"; import { AppSection } from "@/components/AppSection";
import { BlogSection } from "@/components/BlogSection"; import { BlogSection } from "@/components/BlogSection";
import { CmsSection } from "@/components/CmsSection"; import { CmsSection } from "@/components/CmsSection";
@@ -10,6 +11,15 @@ import { Portfolio } from "@/components/Portfolio";
import { RevealObserver } from "@/components/RevealObserver"; import { RevealObserver } from "@/components/RevealObserver";
import { Services } from "@/components/Services"; import { Services } from "@/components/Services";
export const metadata: Metadata = {
title: {
absolute: "تیم برنامه‌نویسی و تجارت الکترونیک مشکی | Meshkee",
},
description:
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستم‌های مدیریتی، سئو و گرافیک توسط تیم مشکی.",
alternates: { canonical: "/" },
};
export default function HomePage() { export default function HomePage() {
return ( return (
<> <>
+9 -2
View File
@@ -66,6 +66,12 @@ export async function generateMetadata({
return { return {
title: `${title} | مشکی`, title: `${title} | مشکی`,
description: portfolio.abstract ?? undefined, description: portfolio.abstract ?? undefined,
alternates: { canonical: `/portfolio/${portfolio.slug}` },
openGraph: {
title,
description: portfolio.abstract ?? undefined,
type: "article",
},
}; };
} catch { } catch {
return { title: "نمونه‌کار | مشکی" }; return { title: "نمونه‌کار | مشکی" };
@@ -117,11 +123,11 @@ export default async function PortfolioDetailPage({
<ContentPageShell flushTop> <ContentPageShell flushTop>
<article className="pb-20 lg:pb-28"> <article className="pb-20 lg:pb-28">
<div className="page-title-band mb-8 md:mb-10"> <div className="page-title-band mb-8 md:mb-10">
<div className="relative mx-auto max-w-7xl px-5 pb-4 pt-[calc(var(--header-height)+0.75rem)] lg:px-8 lg:pb-5 lg:pt-[calc(var(--header-height)+1rem)]"> <div className="relative mx-auto max-w-7xl px-5 pb-10 pt-[calc(var(--header-height)+0.15rem)] lg:px-8 lg:pb-12 lg:pt-[calc(var(--header-height)+0.3rem)]">
{titleEn ? ( {titleEn ? (
<p <p
aria-hidden aria-hidden
className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+2.4rem)] z-0 text-[clamp(1.75rem,5.5vw,3.75rem)] font-light uppercase leading-none tracking-[0.05em] text-ink/[0.07] lg:inset-x-8 lg:top-[calc(var(--header-height)+2.6rem)]" className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+1.75rem)] z-0 text-[clamp(1.75rem,5.5vw,3.75rem)] font-light uppercase leading-none tracking-[0.05em] text-ink/[0.07] lg:inset-x-8 lg:top-[calc(var(--header-height)+1.95rem)]"
> >
{titleEn} {titleEn}
</p> </p>
@@ -180,6 +186,7 @@ export default async function PortfolioDetailPage({
category={categoryFa} category={categoryFa}
date={date} date={date}
dateTime={portfolio.publishedAt} dateTime={portfolio.publishedAt}
title={title}
/> />
{portfolio.mainTextHtml ? ( {portfolio.mainTextHtml ? (
+3 -3
View File
@@ -95,10 +95,10 @@ export default async function PortfolioListPage({
<ContentPageShell flushTop> <ContentPageShell flushTop>
<section className="pb-20 lg:pb-28"> <section className="pb-20 lg:pb-28">
<div className="page-title-band mb-8 md:mb-10"> <div className="page-title-band mb-8 md:mb-10">
<div className="relative mx-auto max-w-7xl px-5 pb-3 pt-[calc(var(--header-height)+0.75rem)] lg:px-8 lg:pb-4 lg:pt-[calc(var(--header-height)+1rem)]"> <div className="relative mx-auto max-w-7xl px-5 pb-9 pt-[calc(var(--header-height)+0.15rem)] lg:px-8 lg:pb-12 lg:pt-[calc(var(--header-height)+0.3rem)]">
<p <p
aria-hidden aria-hidden
className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+1.15rem)] z-0 text-[clamp(1.75rem,5.5vw,3.75rem)] font-light uppercase leading-none tracking-[0.05em] text-ink/[0.07] lg:inset-x-8 lg:top-[calc(var(--header-height)+1.35rem)]" className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+0.5rem)] z-0 text-[clamp(1.75rem,5.5vw,3.75rem)] font-light uppercase leading-none tracking-[0.05em] text-ink/[0.07] lg:inset-x-8 lg:top-[calc(var(--header-height)+0.65rem)]"
> >
Portfolio Portfolio
</p> </p>
@@ -181,7 +181,7 @@ export default async function PortfolioListPage({
{image ? ( {image ? (
<img <img
src={image} src={image}
alt="" alt={item.titleFa || item.title}
className="h-auto w-full transition duration-700 group-hover:scale-105" className="h-auto w-full transition duration-700 group-hover:scale-105"
/> />
) : null} ) : null}
-14
View File
@@ -1,14 +0,0 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.meshkee.com";
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: `${siteUrl}/sitemap.xml`,
host: siteUrl,
};
}
-27
View File
@@ -1,27 +0,0 @@
import type { MetadataRoute } from "next";
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.meshkee.com";
const routes = [
"/",
"/cms-website",
"/website-qom",
"/website",
"/seo",
"/dedicated-application",
"/meshkee-cms",
"/graphic-designer",
"/portfolio",
"/blog",
];
export default function sitemap(): MetadataRoute.Sitemap {
const lastModified = new Date();
return routes.map((path) => ({
url: `${siteUrl}${path === "/" ? "" : path}`,
lastModified,
changeFrequency: path === "/" ? "weekly" : "monthly",
priority: path === "/" ? 1 : path === "/website-qom" || path === "/cms-website" ? 0.9 : 0.7,
}));
}
+1 -1
View File
@@ -24,7 +24,7 @@ export function AppSection() {
بیشتر کاربران را به همراه دارد. بیشتر کاربران را به همراه دارد.
</p> </p>
<a <a
href="#contact" href="/contact"
className="btn-primary inline-flex rounded-full px-6 py-3.5 text-sm" className="btn-primary inline-flex rounded-full px-6 py-3.5 text-sm"
> >
با ما تماس بگیرید با ما تماس بگیرید
+140
View File
@@ -0,0 +1,140 @@
"use client";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import {
getMe,
isWebsiteAdminForBusiness,
refreshTokens,
type AuthUser,
} from "@/lib/api";
import {
clearLocalAuthTokens,
hydrateLocalTokensFromSharedCookies,
readLocalAccessToken,
readLocalRefreshToken,
writeLocalTokens,
} from "@/lib/auth-cookies";
type AuthContextValue = {
user: AuthUser | null;
ready: boolean;
isAuthenticated: boolean;
isWebsiteAdmin: boolean;
getAccessToken: () => string | null;
logout: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({
children,
businessId = null,
}: {
children: ReactNode;
businessId?: string | null;
}) {
const [user, setUser] = useState<AuthUser | null>(null);
const [ready, setReady] = useState(false);
const logout = useCallback(() => {
clearLocalAuthTokens();
setUser(null);
}, []);
const getAccessToken = useCallback(() => readLocalAccessToken(), []);
const refreshUser = useCallback(async () => {
hydrateLocalTokensFromSharedCookies();
let accessToken = readLocalAccessToken();
const refreshToken = readLocalRefreshToken();
if (!accessToken && !refreshToken) {
setUser(null);
return;
}
try {
if (!accessToken && refreshToken) {
const tokens = await refreshTokens(refreshToken);
writeLocalTokens(tokens.accessToken, tokens.refreshToken);
accessToken = tokens.accessToken;
}
if (!accessToken) {
setUser(null);
return;
}
const me = await getMe(accessToken);
setUser(me);
} catch {
if (refreshToken) {
try {
const tokens = await refreshTokens(refreshToken);
writeLocalTokens(tokens.accessToken, tokens.refreshToken);
const me = await getMe(tokens.accessToken);
setUser(me);
return;
} catch {
clearLocalAuthTokens();
setUser(null);
}
} else {
clearLocalAuthTokens();
setUser(null);
}
}
}, []);
useEffect(() => {
let cancelled = false;
(async () => {
try {
await refreshUser();
} catch {
if (!cancelled) {
clearLocalAuthTokens();
setUser(null);
}
} finally {
if (!cancelled) setReady(true);
}
})().catch(() => undefined);
return () => {
cancelled = true;
};
}, [refreshUser]);
const isWebsiteAdmin = useMemo(
() => isWebsiteAdminForBusiness(user, businessId),
[user, businessId],
);
const value = useMemo<AuthContextValue>(
() => ({
user,
ready,
isAuthenticated: Boolean(user),
isWebsiteAdmin,
getAccessToken,
logout,
}),
[user, ready, isWebsiteAdmin, getAccessToken, logout],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
return ctx;
}
+7 -2
View File
@@ -4,10 +4,15 @@ import { useState } from "react";
type BlogCoverImageProps = { type BlogCoverImageProps = {
src: string | null; src: string | null;
alt?: string;
className?: string; className?: string;
}; };
export function BlogCoverImage({ src, className = "" }: BlogCoverImageProps) { export function BlogCoverImage({
src,
alt = "",
className = "",
}: BlogCoverImageProps) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
if (!src || failed) { if (!src || failed) {
@@ -25,7 +30,7 @@ export function BlogCoverImage({ src, className = "" }: BlogCoverImageProps) {
return ( return (
<img <img
src={src} src={src}
alt="" alt={alt}
onError={() => setFailed(true)} onError={() => setFailed(true)}
className={`h-auto w-full object-contain transition duration-700 group-hover:scale-105 ${className}`.trim()} className={`h-auto w-full object-contain transition duration-700 group-hover:scale-105 ${className}`.trim()}
/> />
+1 -1
View File
@@ -53,7 +53,7 @@ export async function BlogSection() {
> >
<Link href={`/blog/${post.slug}`} className="block"> <Link href={`/blog/${post.slug}`} className="block">
<div className="relative mb-4 overflow-hidden rounded-2xl"> <div className="relative mb-4 overflow-hidden rounded-2xl">
<BlogCoverImage src={image} /> <BlogCoverImage src={image} alt={post.title} />
</div> </div>
<h3 className="group-text-hover mb-2 line-clamp-2 text-lg font-normal leading-8 text-ink"> <h3 className="group-text-hover mb-2 line-clamp-2 text-lg font-normal leading-8 text-ink">
{post.title} {post.title}
+108
View File
@@ -0,0 +1,108 @@
import Link from "next/link";
import type { ComponentPropsWithoutRef, ReactNode } from "react";
type ButtonVariant = "primary" | "outline";
type ButtonSize = "sm" | "md";
type CommonProps = {
variant?: ButtonVariant;
size?: ButtonSize;
className?: string;
children: ReactNode;
};
type ButtonAsLink = CommonProps &
Omit<ComponentPropsWithoutRef<typeof Link>, "className" | "children"> & {
href: string;
};
type ButtonAsAnchor = CommonProps &
Omit<ComponentPropsWithoutRef<"a">, "className" | "children" | "href"> & {
href: string;
external?: true;
};
type ButtonAsButton = CommonProps &
Omit<ComponentPropsWithoutRef<"button">, "className" | "children"> & {
href?: undefined;
};
export type ButtonProps = ButtonAsLink | ButtonAsAnchor | ButtonAsButton;
const sizeClass: Record<ButtonSize, string> = {
sm: "px-3.5 py-2 text-xs",
md: "px-6 py-3.5 text-sm",
};
function cx(...parts: Array<string | false | null | undefined>) {
return parts.filter(Boolean).join(" ");
}
function renderLabel(variant: ButtonVariant, children: ReactNode) {
if (variant !== "outline") return children;
return (
<span className="btn-outline-label">
<span className="btn-outline-label-base inline-flex items-center gap-1.5">
{children}
</span>
<span className="btn-outline-label-grad" aria-hidden>
{children}
</span>
</span>
);
}
export function Button(props: ButtonProps) {
const {
variant = "primary",
size = "md",
className,
children,
...rest
} = props;
const classes = cx(
variant === "primary" ? "btn-primary" : "btn-outline",
"inline-flex items-center justify-center gap-1.5 rounded-full font-medium",
sizeClass[size],
className,
);
const content = renderLabel(variant, children);
if ("href" in props && props.href) {
const { href, external, ...linkRest } = rest as Omit<
ButtonAsLink & ButtonAsAnchor,
keyof CommonProps
> & { external?: true };
if (external || href.startsWith("http") || href.startsWith("tel:") || href.startsWith("mailto:")) {
return (
<a
href={href}
className={classes}
{...(external
? { target: "_blank", rel: "noopener noreferrer" }
: {})}
{...(linkRest as ComponentPropsWithoutRef<"a">)}
>
{content}
</a>
);
}
return (
<Link href={href} className={classes} {...(linkRest as Omit<ComponentPropsWithoutRef<typeof Link>, "href" | "className" | "children">)}>
{content}
</Link>
);
}
const buttonRest = rest as Omit<ButtonAsButton, keyof CommonProps>;
return (
<button type={buttonRest.type ?? "button"} className={classes} {...buttonRest}>
{content}
</button>
);
}
+2 -2
View File
@@ -35,13 +35,13 @@ export function CmsSection() {
</p> </p>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
<a <a
href="#contact" href="/contact"
className="btn-primary rounded-full px-6 py-3.5 text-sm" className="btn-primary rounded-full px-6 py-3.5 text-sm"
> >
با ما تماس بگیرید با ما تماس بگیرید
</a> </a>
<a <a
href="#contact" href="/contact"
className="link-hover rounded-full px-5 py-3.5 text-sm text-ink-soft" className="link-hover rounded-full px-5 py-3.5 text-sm text-ink-soft"
> >
دوست دارم بیشتر بدونم دوست دارم بیشتر بدونم
+155
View File
@@ -0,0 +1,155 @@
"use client";
import { FormEvent, useState } from "react";
type ContactFormProps = {
variant?: "light" | "dark";
};
export function ContactForm({ variant = "dark" }: ContactFormProps) {
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [sending, setSending] = useState(false);
const isLight = variant === "light";
const labelClass = isLight ? "text-ink-soft" : "text-white/70";
const hintClass = isLight ? "text-muted" : "text-white/40";
const inputClass = isLight
? "rounded-xl border border-white/80 bg-white/55 px-4 py-3 text-ink outline-none shadow-[inset_0_1px_0_rgba(255,255,255,0.7)] backdrop-blur-sm transition placeholder:text-muted focus:border-ink/25 focus:bg-white/80"
: "rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-surface outline-none transition placeholder:text-white/30 focus:border-accent";
const errorClass = isLight ? "text-sm text-red-600" : "text-sm text-red-300";
const successClass = isLight
? "text-sm text-emerald-700"
: "text-sm text-emerald-300";
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const formElement = event.currentTarget;
const form = new FormData(formElement);
const name = String(form.get("name") ?? "").trim();
const phone = String(form.get("phone") ?? "").trim();
const email = String(form.get("email") ?? "").trim();
const subject = String(form.get("subject") ?? "").trim();
const message = String(form.get("message") ?? "").trim();
if (!name || !phone || !subject || !message) {
setError("لطفا نام، شماره تماس، موضوع و متن پیام را کامل کنید.");
setSuccess("");
return;
}
setError("");
setSuccess("");
setSending(true);
try {
const response = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: subject,
name,
cellNumber: phone,
email: email || undefined,
text: message,
}),
});
if (!response.ok) {
throw new Error("Contact submission failed");
}
formElement.reset();
setSuccess(
"پیام شما با موفقیت ثبت شد. همکاران ما به‌زودی با شما تماس می‌گیرند.",
);
} catch {
setError("ارسال پیام انجام نشد. لطفا دوباره تلاش کنید.");
} finally {
setSending(false);
}
}
return (
<form className="grid gap-4" onSubmit={submit} noValidate>
<label className="grid gap-2 text-sm">
<span className={labelClass}>نام</span>
<input
name="name"
type="text"
placeholder="نام شما"
autoComplete="name"
required
className={inputClass}
/>
</label>
<label className="grid gap-2 text-sm">
<span className={labelClass}>شماره تماس</span>
<input
name="phone"
type="tel"
placeholder="۰۹۱۲…"
inputMode="tel"
autoComplete="tel"
required
className={inputClass}
dir="ltr"
/>
</label>
<label className="grid gap-2 text-sm">
<span className={labelClass}>
ایمیل <span className={hintClass}>(اختیاری)</span>
</span>
<input
name="email"
type="email"
placeholder="you@example.com"
autoComplete="email"
className={inputClass}
dir="ltr"
/>
</label>
<label className="grid gap-2 text-sm">
<span className={labelClass}>موضوع</span>
<input
name="subject"
type="text"
placeholder="موضوع پیام"
required
className={inputClass}
/>
</label>
<label className="grid gap-2 text-sm">
<span className={labelClass}>پیام</span>
<textarea
name="message"
rows={4}
placeholder="چطور می‌تونیم کمکتون کنیم؟"
required
className={`resize-none ${inputClass}`}
/>
</label>
{error ? (
<p className={errorClass} role="alert">
{error}
</p>
) : null}
{success ? (
<p className={successClass} role="status">
{success}
</p>
) : null}
<button
type="submit"
disabled={sending}
className="btn-primary mt-2 w-full cursor-pointer rounded-full px-6 py-3.5 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-60"
>
{sending ? "در حال ارسال…" : "منتظر تماس هستم"}
</button>
</form>
);
}
+11 -39
View File
@@ -1,6 +1,5 @@
"use client";
import { site } from "@/data/home"; import { site } from "@/data/home";
import { ContactForm } from "@/components/ContactForm";
export function ContactSection() { export function ContactSection() {
return ( return (
@@ -17,6 +16,14 @@ export function ContactSection() {
> >
{site.phone} {site.phone}
</a> </a>
<p className="mb-2 text-sm text-white/55">ایمیل</p>
<a
href={`mailto:${site.email}`}
className="latin link-hover mb-8 block text-sm text-white/80 md:text-base"
dir="ltr"
>
{site.email}
</a>
<p className="mb-2 text-sm text-white/55">آدرس</p> <p className="mb-2 text-sm text-white/55">آدرس</p>
<p className="max-w-sm text-sm leading-7 text-white/80 md:text-base md:leading-8"> <p className="max-w-sm text-sm leading-7 text-white/80 md:text-base md:leading-8">
{site.address} {site.address}
@@ -28,45 +35,10 @@ export function ContactSection() {
تماس با ما تماس با ما
</h2> </h2>
<p className="mb-8 text-sm text-white/60"> <p className="mb-8 text-sm text-white/60">
ما همیشه دنبال دوستای جدید هستیم — یا ما خدمتتون تماس بگیریم. ما همیشه دنبال دوستای جدید هستیم.
</p> </p>
<form className="grid gap-4" onSubmit={(e) => e.preventDefault()}> <ContactForm />
<label className="grid gap-2 text-sm">
<span className="text-white/70">نام</span>
<input
name="name"
type="text"
placeholder="نام شما"
className="rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-surface outline-none transition placeholder:text-white/30 focus:border-accent"
/>
</label>
<label className="grid gap-2 text-sm">
<span className="text-white/70">شماره تماس</span>
<input
name="phone"
type="tel"
placeholder="۰۹۱۲…"
className="rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-surface outline-none transition placeholder:text-white/30 focus:border-accent"
dir="ltr"
/>
</label>
<label className="grid gap-2 text-sm">
<span className="text-white/70">پیام</span>
<textarea
name="message"
rows={4}
placeholder="چطور می‌تونیم کمکتون کنیم؟"
className="resize-none rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-surface outline-none transition placeholder:text-white/30 focus:border-accent"
/>
</label>
<button
type="submit"
className="btn-primary mt-2 rounded-full px-6 py-3.5 text-sm font-medium"
>
منتظر تماس هستم
</button>
</form>
</div> </div>
</div> </div>
</section> </section>
+10 -2
View File
@@ -101,14 +101,22 @@ export function Footer() {
<p className="mt-3 max-w-[16rem] text-sm leading-7 text-muted"> <p className="mt-3 max-w-[16rem] text-sm leading-7 text-muted">
{site.address} {site.address}
</p> </p>
<div className="mt-5 flex flex-wrap gap-2">
<Link <Link
href="#contact" href="/about"
className="mt-5 inline-flex rounded-full border border-line px-4 py-2 text-xs text-ink-soft transition hover:border-ink/30 hover:text-ink" className="inline-flex rounded-full border border-line px-4 py-2 text-xs text-ink-soft transition hover:border-ink/30 hover:text-ink"
>
درباره ما
</Link>
<Link
href="/contact"
className="inline-flex rounded-full border border-line px-4 py-2 text-xs text-ink-soft transition hover:border-ink/30 hover:text-ink"
> >
تماس با ما تماس با ما
</Link> </Link>
</div> </div>
</div> </div>
</div>
<div className="footer-bottom-bar"> <div className="footer-bottom-bar">
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-3 px-5 py-4 sm:flex-row lg:px-8"> <div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-3 px-5 py-4 sm:flex-row lg:px-8">
+22 -36
View File
@@ -2,17 +2,11 @@
import Link from "next/link"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { nav, site, type NavItem } from "@/data/home"; import { Button } from "@/components/Button";
import { UserMenu } from "@/components/UserMenu";
import { nav, site, socials, type NavItem } from "@/data/home";
import { images } from "@/data/assets"; import { images } from "@/data/assets";
const socials = [
{ label: "اینستاگرام", href: "https://instagram.com", icon: "M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" },
{ label: "یوتیوب", href: "https://youtube.com", icon: "M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" },
{ label: "لینکدین", href: "https://linkedin.com", icon: "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" },
{ label: "فیسبوک", href: "https://facebook.com", icon: "M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" },
{ label: "آپارات", href: "https://aparat.com", icon: "M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 4a8 8 0 110 16A8 8 0 0112 4zm0 2a6 6 0 100 12A6 6 0 0012 6zm-1 3l5 3-5 3V9z" },
];
function DesktopNavItem({ item }: { item: NavItem }) { function DesktopNavItem({ item }: { item: NavItem }) {
if (!item.children?.length) { if (!item.children?.length) {
return ( return (
@@ -49,7 +43,7 @@ function DesktopNavItem({ item }: { item: NavItem }) {
href={item.href} href={item.href}
className="block rounded-xl px-3 py-2.5 text-sm text-ink-soft transition hover:bg-[#FAFAFA] hover:text-ink" className="block rounded-xl px-3 py-2.5 text-sm text-ink-soft transition hover:bg-[#FAFAFA] hover:text-ink"
> >
همه نمونه‌کارها {item.allLabel ?? `همه ${item.label}`}
</Link> </Link>
{item.children.map((child) => ( {item.children.map((child) => (
<Link <Link
@@ -101,7 +95,7 @@ export function Header() {
<Link href="/about" className="link-hover text-xs text-muted"> <Link href="/about" className="link-hover text-xs text-muted">
درباره ما درباره ما
</Link> </Link>
<Link href="#contact" className="link-hover text-xs text-muted"> <Link href="/contact" className="link-hover text-xs text-muted">
تماس با ما تماس با ما
</Link> </Link>
<a <a
@@ -139,22 +133,10 @@ export function Header() {
{/* Login + CTA on the left */} {/* Login + CTA on the left */}
<div className="hidden items-center gap-2 md:flex"> <div className="hidden items-center gap-2 md:flex">
<a <UserMenu />
href="/login" <Button href="/contact" size="sm" className="px-4">
className="flex items-center gap-1.5 rounded-full border border-line px-3.5 py-2 text-xs text-ink-soft transition hover:border-ink hover:text-ink"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" className="h-3.5 w-3.5" aria-hidden>
<circle cx="12" cy="8" r="4"/>
<path d="M4 20c0-4 3.582-7 8-7s8 3 8 7"/>
</svg>
ورود
</a>
<a
href="#contact"
className="btn-primary rounded-full px-4 py-2 text-sm"
>
تماس با ما تماس با ما
</a> </Button>
</div> </div>
<button <button
@@ -238,21 +220,25 @@ export function Header() {
)} )}
</div> </div>
))} ))}
<div className="mt-3 flex gap-2"> <div className="mt-3 flex flex-col gap-2">
<a <div className="flex gap-2">
href="/login" <Button
className="flex flex-1 items-center justify-center gap-1.5 rounded-full border border-line py-3 text-sm text-ink-soft" href="/about"
variant="outline"
className="flex-1 py-3"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
ورود درباره ما
</a> </Button>
<a <Button
href="#contact" href="/contact"
className="btn-primary flex-1 rounded-full py-3 text-center text-sm" className="flex-1 py-3"
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
> >
تماس با ما تماس با ما
</a> </Button>
</div>
<UserMenu compact />
</div> </div>
</nav> </nav>
</div> </div>
+13 -3
View File
@@ -13,6 +13,7 @@ type PortfolioGalleryProps = {
category?: string | null; category?: string | null;
date?: string | null; date?: string | null;
dateTime?: string | null; dateTime?: string | null;
title?: string | null;
}; };
export function PortfolioGallery({ export function PortfolioGallery({
@@ -21,6 +22,7 @@ export function PortfolioGallery({
category, category,
date, date,
dateTime, dateTime,
title,
}: PortfolioGalleryProps) { }: PortfolioGalleryProps) {
const slides: GalleryImage[] = [ const slides: GalleryImage[] = [
...(cover ? [{ id: "cover", url: cover }] : []), ...(cover ? [{ id: "cover", url: cover }] : []),
@@ -77,7 +79,7 @@ export function PortfolioGallery({
> >
<img <img
src={cover} src={cover}
alt="" alt={title || "تصویر نمونه‌کار"}
className="h-auto max-h-[28rem] w-auto max-w-full object-contain transition duration-500 group-hover:scale-[1.01]" className="h-auto max-h-[28rem] w-auto max-w-full object-contain transition duration-500 group-hover:scale-[1.01]"
/> />
<span className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-ink/55 to-transparent px-4 py-3 text-xs text-white opacity-0 transition group-hover:opacity-100"> <span className="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-ink/55 to-transparent px-4 py-3 text-xs text-white opacity-0 transition group-hover:opacity-100">
@@ -118,7 +120,11 @@ export function PortfolioGallery({
> >
<img <img
src={image.url} src={image.url}
alt="" alt={
title
? `${title} — تصویر ${index + 1}`
: `تصویر گالری ${index + 1}`
}
className="aspect-square h-auto w-full object-cover transition duration-500 group-hover:scale-[1.03]" className="aspect-square h-auto w-full object-cover transition duration-500 group-hover:scale-[1.03]"
/> />
</button> </button>
@@ -175,7 +181,11 @@ export function PortfolioGallery({
<img <img
src={activeImage.url} src={activeImage.url}
alt="" alt={
title
? `${title} — تصویر ${(activeIndex ?? 0) + 1}`
: `تصویر ${(activeIndex ?? 0) + 1}`
}
className="max-h-[90vh] max-w-[min(96vw,72rem)] rounded-xl object-contain shadow-[0_24px_80px_rgba(0,0,0,0.35)]" className="max-h-[90vh] max-w-[min(96vw,72rem)] rounded-xl object-contain shadow-[0_24px_80px_rgba(0,0,0,0.35)]"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
/> />
+13
View File
@@ -0,0 +1,13 @@
"use client";
import { AuthProvider } from "@/components/AuthProvider";
export function Providers({
children,
businessId = null,
}: {
children: React.ReactNode;
businessId?: string | null;
}) {
return <AuthProvider businessId={businessId}>{children}</AuthProvider>;
}
+2 -2
View File
@@ -48,7 +48,7 @@ function SolutionFeatureCard({ feature }: { feature: Feature }) {
{feature.icon ? ( {feature.icon ? (
<img <img
src={feature.icon} src={feature.icon}
alt="" alt={feature.title}
width={36} width={36}
height={36} height={36}
className="mt-0.5 h-9 w-9 shrink-0 object-contain" className="mt-0.5 h-9 w-9 shrink-0 object-contain"
@@ -78,7 +78,7 @@ function TeamFeatureCard({ feature }: { feature: Feature }) {
{feature.icon ? ( {feature.icon ? (
<img <img
src={feature.icon} src={feature.icon}
alt="" alt={feature.title}
width={43} width={43}
height={43} height={43}
className="h-[43px] w-[43px] shrink-0 object-contain" className="h-[43px] w-[43px] shrink-0 object-contain"
+1 -1
View File
@@ -30,7 +30,7 @@ export function Services() {
<div className="mb-5 flex items-center gap-3"> <div className="mb-5 flex items-center gap-3">
<img <img
src={service.icon} src={service.icon}
alt="" alt={service.title}
width={48} width={48}
height={48} height={48}
className="h-12 w-12 shrink-0 object-contain" className="h-12 w-12 shrink-0 object-contain"
+196
View File
@@ -0,0 +1,196 @@
"use client";
import { useEffect, useRef, useState, type MouseEvent } from "react";
import { useAuth } from "@/components/AuthProvider";
import { Button } from "@/components/Button";
import {
businessDashboardUrl,
createAuthHandoff,
customerLoginUrl,
customerProfileUrl,
displayName,
} from "@/lib/api";
export function UserMenu({ compact = false }: { compact?: boolean }) {
const { user, ready, isAuthenticated, isWebsiteAdmin, logout, getAccessToken } =
useAuth();
const [open, setOpen] = useState(false);
const [handingOff, setHandingOff] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onClick = (event: Event) => {
if (
rootRef.current &&
event.target instanceof Node &&
!rootRef.current.contains(event.target)
) {
setOpen(false);
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onClick);
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("mousedown", onClick);
document.removeEventListener("keydown", onKeyDown);
};
}, [open]);
if (!ready || !isAuthenticated || !user) {
return (
<Button
href={customerLoginUrl("/")}
variant="outline"
size={compact ? "md" : "sm"}
className={compact ? "w-full py-3" : undefined}
aria-label="ورود"
>
<UserIcon className={compact ? "h-4 w-4" : "h-3.5 w-3.5"} />
ورود
</Button>
);
}
const name = displayName(user);
async function openBusinessAdmin(event: MouseEvent<HTMLAnchorElement>) {
if (
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey ||
event.button !== 0
) {
return;
}
event.preventDefault();
if (handingOff) return;
const target = businessDashboardUrl();
const popup = window.open("about:blank", "_blank");
setHandingOff(true);
try {
const token = getAccessToken();
const { ticket } = await createAuthHandoff(token || "");
const url = new URL(target);
if (ticket) url.searchParams.set("handoff", ticket);
if (popup) {
popup.opener = null;
popup.location.replace(url.toString());
} else {
window.open(url.toString(), "_blank", "noopener,noreferrer");
}
} catch {
if (popup) popup.location.replace(target);
else window.open(target, "_blank", "noopener,noreferrer");
} finally {
setHandingOff(false);
setOpen(false);
}
}
return (
<div ref={rootRef} className={`relative ${compact ? "w-full" : ""}`}>
<button
type="button"
className={`btn-outline inline-flex max-w-[14rem] items-center justify-center gap-1.5 rounded-full font-medium ${
compact ? "w-full px-6 py-3 text-sm" : "px-3.5 py-2 text-xs"
}`}
aria-expanded={open}
aria-haspopup="menu"
onClick={() => setOpen((value) => !value)}
>
<span className="btn-outline-label">
<span className="btn-outline-label-base inline-flex min-w-0 items-center gap-1.5">
<UserIcon className={compact ? "h-4 w-4" : "h-3.5 w-3.5"} />
<span className="truncate">{name} عزیز</span>
<ChevronIcon open={open} />
</span>
<span className="btn-outline-label-grad" aria-hidden>
<UserIcon className={compact ? "h-4 w-4" : "h-3.5 w-3.5"} />
<span className="truncate">{name} عزیز</span>
<ChevronIcon open={open} />
</span>
</span>
</button>
{open ? (
<div
role="menu"
className={`absolute z-50 mt-2 min-w-[12rem] overflow-hidden rounded-2xl border border-line/70 bg-white py-1 shadow-[0_16px_40px_rgba(14,17,22,0.08)] ${
compact ? "inset-inline-0" : "inset-inline-end-0"
}`}
>
<a
role="menuitem"
href={customerProfileUrl()}
className="block px-4 py-2.5 text-sm text-ink-soft transition hover:bg-[#FAFAFA] hover:text-ink"
onClick={() => setOpen(false)}
>
پروفایل من
</a>
{isWebsiteAdmin ? (
<a
role="menuitem"
href={businessDashboardUrl()}
className="block px-4 py-2.5 text-sm text-ink-soft transition hover:bg-[#FAFAFA] hover:text-ink"
onClick={(event) => void openBusinessAdmin(event)}
>
{handingOff ? "در حال انتقال…" : "مدیریت وبسایت"}
</a>
) : null}
<button
type="button"
role="menuitem"
className="block w-full px-4 py-2.5 text-right text-sm text-ink-soft transition hover:bg-[#FAFAFA] hover:text-ink"
onClick={() => {
logout();
setOpen(false);
}}
>
خروج
</button>
</div>
) : null}
</div>
);
}
function UserIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
className={className}
aria-hidden
>
<circle cx="12" cy="8" r="4" />
<path d="M4 20c0-4 3.582-7 8-7s8 3 8 7" />
</svg>
);
}
function ChevronIcon({ open }: { open: boolean }) {
return (
<svg
viewBox="0 0 12 12"
className={`h-2.5 w-2.5 shrink-0 transition ${open ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth="1.6"
aria-hidden
>
<path d="M2.5 4.5 6 8l3.5-3.5" />
</svg>
);
}
+46
View File
@@ -1,4 +1,8 @@
import { images } from "./assets"; import { images } from "./assets";
import {
instructionCategoryHref,
topInstructionCategories,
} from "./instructionCategories";
import { import {
portfolioCategoryHref, portfolioCategoryHref,
topPortfolioCategories, topPortfolioCategories,
@@ -14,11 +18,43 @@ export const site = {
phoneHref: "tel:+989024200240", phoneHref: "tel:+989024200240",
email: "info@meshkee.com", email: "info@meshkee.com",
address: "قم، نبش کریمی ۳۴، ساختمان ظفر، طبقهٔ چهارم", address: "قم، نبش کریمی ۳۴، ساختمان ظفر، طبقهٔ چهارم",
mapUrl: "https://maps.app.goo.gl/GdDdMfMFzjobcggv5",
mapLat: 34.598146,
mapLng: 50.87917,
}; };
export const socials = [
{
label: "اینستاگرام",
href: "https://instagram.com",
icon: "M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z",
},
{
label: "یوتیوب",
href: "https://youtube.com",
icon: "M23.498 6.186a3.016 3.016 0 00-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 00.502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 002.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 002.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z",
},
{
label: "لینکدین",
href: "https://linkedin.com",
icon: "M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.064 2.064 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z",
},
{
label: "فیسبوک",
href: "https://facebook.com",
icon: "M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z",
},
{
label: "آپارات",
href: "https://aparat.com",
icon: "M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 4a8 8 0 110 16A8 8 0 0112 4zm0 2a6 6 0 100 12A6 6 0 0012 6zm-1 3l5 3-5 3V9z",
},
];
export type NavItem = { export type NavItem = {
label: string; label: string;
href: string; href: string;
allLabel?: string;
children?: { label: string; href: string }[]; children?: { label: string; href: string }[];
}; };
@@ -31,11 +67,21 @@ export const nav: NavItem[] = [
{ {
label: "نمونه‌کارها", label: "نمونه‌کارها",
href: "/portfolio", href: "/portfolio",
allLabel: "همه نمونه‌کارها",
children: topPortfolioCategories.map((category) => ({ children: topPortfolioCategories.map((category) => ({
label: category.nameFa, label: category.nameFa,
href: portfolioCategoryHref(category.slug), href: portfolioCategoryHref(category.slug),
})), })),
}, },
{
label: "آموزش‌ها",
href: "/instruction",
allLabel: "همه آموزش‌ها",
children: topInstructionCategories.map((category) => ({
label: category.nameFa,
href: instructionCategoryHref(category.slug),
})),
},
{ label: "مقالات", href: "/blog" }, { label: "مقالات", href: "/blog" },
]; ];
+82
View File
@@ -0,0 +1,82 @@
export type InstructionCategory = {
id: string;
slug: string;
nameEn: string;
nameFa: string;
parentId?: string | null;
sortOrder?: number;
};
/**
* Top-level instruction categories from Meshkee API
* (`/tenants/meshkee.com/categories?entityType=instruction`, parentId = null).
*/
export const topInstructionCategories: InstructionCategory[] = [
{
id: "1296",
slug: "email",
nameEn: "Email",
nameFa: "ایمیل",
parentId: null,
sortOrder: 0,
},
];
export const instructionCategories = topInstructionCategories;
export function findInstructionCategory(
value: string | null | undefined,
categories: InstructionCategory[] = topInstructionCategories,
): InstructionCategory | undefined {
if (!value) return undefined;
const key = value.trim().toLowerCase();
return categories.find(
(category) =>
category.slug === key ||
category.id === key ||
category.nameEn.toLowerCase() === key,
);
}
export function instructionCategoryHref(slug?: string) {
if (!slug) return "/instruction";
return `/instruction?category=${encodeURIComponent(slug)}`;
}
export function getRootInstructionCategories(categories: InstructionCategory[]) {
return categories
.filter((category) => !category.parentId)
.sort(
(a, b) =>
(a.sortOrder ?? 0) - (b.sortOrder ?? 0) ||
a.nameEn.localeCompare(b.nameEn),
);
}
export function getDescendantInstructionCategoryIds(
categories: InstructionCategory[],
rootId: string,
): string[] {
const ids = [String(rootId)];
const children = categories.filter(
(category) => String(category.parentId) === String(rootId),
);
for (const child of children) {
ids.push(...getDescendantInstructionCategoryIds(categories, child.id));
}
return ids;
}
export function instructionCategoryLabelFa(
categoryName: string | null | undefined,
categories: InstructionCategory[] = topInstructionCategories,
): string | null {
if (!categoryName) return null;
const match = categories.find(
(category) =>
category.nameEn.toLowerCase() === categoryName.toLowerCase(),
);
return match?.nameFa ?? categoryName;
}
+244
View File
@@ -1,6 +1,8 @@
const API_BASE_URL = "https://api.meshkee.com/api/v1"; const API_BASE_URL = "https://api.meshkee.com/api/v1";
export const SITE_DOMAIN = "meshkee.com"; export const SITE_DOMAIN = "meshkee.com";
export const CUSTOMER_APP_URL = `https://customer.${SITE_DOMAIN}`;
export const BUSINESS_APP_URL = `https://business.${SITE_DOMAIN}`;
export class ApiNotFoundError extends Error { export class ApiNotFoundError extends Error {
constructor(path: string) { constructor(path: string) {
@@ -9,6 +11,38 @@ export class ApiNotFoundError extends Error {
} }
} }
export type Tenant = {
id: string | number;
name?: string | null;
nameFa?: string | null;
domain?: string | null;
logoUrl?: string | null;
faviconUrl?: string | null;
};
export type FaviconInfo = {
faviconUrl?: string | null;
logoUrl?: string | null;
logoDarkUrl?: string | null;
hasDedicatedFavicon?: boolean;
};
export type AuthUser = {
id: string;
firstName?: string | null;
lastName?: string | null;
cellNumber?: string | null;
email?: string | null;
roles?: string[];
isSuperAdmin?: boolean;
businesses?: Array<{
id: string;
name?: string;
isOwner?: boolean;
teamRole?: string | null;
}>;
};
export type BlogAuthor = { export type BlogAuthor = {
id: string; id: string;
firstName: string | null; firstName: string | null;
@@ -34,6 +68,23 @@ export type PortfolioGalleryItem = {
url: string; url: string;
}; };
export type Instruction = {
id: string;
title: string;
titleFa?: string | null;
titleEn?: string | null;
slug: string;
abstract: string | null;
descriptionHtml: string | null;
coverImageUrl: string | null;
publishedAt: string | null;
categoryId?: string | number | null;
categoryName?: string | null;
author?: BlogAuthor | null;
videoProvider?: string | null;
embedCode?: string | null;
};
export type Portfolio = { export type Portfolio = {
id: string; id: string;
title: string; title: string;
@@ -70,6 +121,13 @@ type GetPortfoliosOptions = {
title?: string; title?: string;
}; };
type GetInstructionsOptions = {
page?: number;
pageSize?: number;
categoryId?: string;
title?: string;
};
type ApiFetchInit = RequestInit & { type ApiFetchInit = RequestInit & {
next?: { next?: {
revalidate?: number | false; revalidate?: number | false;
@@ -155,6 +213,15 @@ export async function getPortfolioCategories(): Promise<PortfolioCategoryApi[]>
return data.items ?? []; return data.items ?? [];
} }
export async function getInstructionCategories(): Promise<
PortfolioCategoryApi[]
> {
const data = await apiFetch<{ items: PortfolioCategoryApi[] }>(
`/tenants/${SITE_DOMAIN}/categories${toQuery({ entityType: "instruction" })}`,
);
return data.items ?? [];
}
export async function getPortfolios( export async function getPortfolios(
options: GetPortfoliosOptions = {}, options: GetPortfoliosOptions = {},
): Promise<Paginated<Portfolio>> { ): Promise<Paginated<Portfolio>> {
@@ -241,6 +308,73 @@ export async function getPortfolio(slug: string): Promise<Portfolio> {
return data.portfolio; return data.portfolio;
} }
export async function getInstructions(
options: GetInstructionsOptions = {},
): Promise<Paginated<Instruction>> {
return apiFetch<Paginated<Instruction>>(
`/tenants/${SITE_DOMAIN}/instructions${toQuery({
page: options.page,
pageSize: options.pageSize,
categoryId: options.categoryId,
title: options.title,
})}`,
);
}
async function getAllInstructions(): Promise<Instruction[]> {
const pageSize = 100;
let page = 1;
let total = Infinity;
const items: Instruction[] = [];
while (items.length < total) {
const data = await getInstructions({ page, pageSize });
total = data.total;
items.push(...data.items);
if (!data.items.length) break;
page += 1;
}
return items;
}
export async function getInstructionsByCategory(options: {
categoryId: string;
page?: number;
pageSize?: number;
categoryIds?: string[];
}): Promise<Paginated<Instruction>> {
const page = Math.max(1, options.page ?? 1);
const pageSize = Math.max(1, options.pageSize ?? 12);
let allowedIds = options.categoryIds?.map(String);
if (!allowedIds?.length) {
const categories = await getInstructionCategories();
allowedIds = collectDescendantIds(categories, options.categoryId);
}
const allowed = new Set(allowedIds);
const all = await getAllInstructions();
const filtered = all.filter((item) =>
allowed.has(String(item.categoryId)),
);
const start = (page - 1) * pageSize;
return {
items: filtered.slice(start, start + pageSize),
total: filtered.length,
page,
pageSize,
};
}
export async function getInstruction(slug: string): Promise<Instruction> {
const data = await apiFetch<{ instruction: Instruction }>(
`/tenants/${SITE_DOMAIN}/instructions/${encodeURIComponent(slug)}`,
);
return data.instruction;
}
export function getBlogCoverImage( export function getBlogCoverImage(
blog: Pick<Blog, "titleImageUrl" | "mainTextHtml">, blog: Pick<Blog, "titleImageUrl" | "mainTextHtml">,
): string | null { ): string | null {
@@ -279,6 +413,17 @@ export function getPortfolioCoverImage(
return match?.[1] ?? null; return match?.[1] ?? null;
} }
export function getInstructionCoverImage(
instruction: Pick<Instruction, "coverImageUrl" | "descriptionHtml">,
): string | null {
if (instruction.coverImageUrl) return instruction.coverImageUrl;
const urls = extractImageUrlsFromHtml(instruction.descriptionHtml);
const preferred = urls.find((url) => url.includes("/meshkee/businesses/"));
return preferred ?? urls[0] ?? null;
}
export function formatFaDate(value: string | null | undefined): string | null { export function formatFaDate(value: string | null | undefined): string | null {
if (!value) return null; if (!value) return null;
@@ -298,3 +443,102 @@ export function authorName(author: BlogAuthor | null | undefined): string | null
const name = [author.firstName, author.lastName].filter(Boolean).join(" "); const name = [author.firstName, author.lastName].filter(Boolean).join(" ");
return name || null; return name || null;
} }
export async function getTenant(): Promise<Tenant> {
return apiFetch<Tenant>(`/tenants/${SITE_DOMAIN}`, {
next: { revalidate: 3600 },
});
}
export async function getFavicon(): Promise<FaviconInfo> {
return apiFetch<FaviconInfo>(`/tenants/${SITE_DOMAIN}/website/favicon`, {
next: { revalidate: 300 },
});
}
export function customerLoginUrl(redirectPath = "/"): string {
const url = new URL("/login", CUSTOMER_APP_URL);
url.searchParams.set(
"redirect",
redirectPath.startsWith("/") ? redirectPath : `/${redirectPath}`,
);
return url.toString();
}
export function customerProfileUrl(): string {
return `${CUSTOMER_APP_URL}/profile`;
}
export function businessDashboardUrl(): string {
return `${BUSINESS_APP_URL}/`;
}
export function displayName(user: Pick<AuthUser, "firstName" | "lastName" | "cellNumber">): string {
const first = user.firstName?.trim();
if (first) return first;
const last = user.lastName?.trim();
if (last) return last;
return user.cellNumber?.trim() || "کاربر";
}
export function isWebsiteAdminForBusiness(
user: AuthUser | null | undefined,
businessId: string | null | undefined,
): boolean {
if (!user || !businessId) return false;
if (user.isSuperAdmin || user.roles?.includes("super_admin")) return true;
return Boolean(
user.businesses?.some(
(business) =>
String(business.id) === String(businessId) &&
(business.isOwner || Boolean(business.teamRole)),
),
);
}
async function authRequest<T>(
path: string,
init: RequestInit & { token?: string | null } = {},
): Promise<T> {
const { token, headers, ...rest } = init;
const response = await fetch(`${API_BASE_URL}${path}`, {
...rest,
headers: {
Accept: "application/json",
...(rest.body ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers,
},
cache: "no-store",
});
if (!response.ok) {
throw new Error(`Meshkee auth ${response.status}: ${path}`);
}
return response.json() as Promise<T>;
}
export async function getMe(token: string): Promise<AuthUser> {
const data = await authRequest<{ user?: AuthUser } & AuthUser>("/auth/me", {
token,
});
return data.user ?? data;
}
export async function refreshTokens(refreshToken: string) {
return authRequest<{ accessToken: string; refreshToken: string }>(
"/auth/refresh",
{
method: "POST",
body: JSON.stringify({ refreshToken }),
},
);
}
export async function createAuthHandoff(token: string) {
return authRequest<{ ticket?: string }>("/auth/handoff", {
method: "POST",
token,
});
}
+138
View File
@@ -0,0 +1,138 @@
/** Shared auth cookies so meshkee.com + customer.meshkee.com share one login. */
export const ACCESS_TOKEN_KEY = "meshkee_customer_access_token";
export const REFRESH_TOKEN_KEY = "meshkee_customer_refresh_token";
const CHUNK_SIZE = 2800;
const MAX_CHUNKS = 24;
function cookieDomain(): string | undefined {
if (typeof window === "undefined") return undefined;
const host = window.location.hostname.toLowerCase();
if (host === "localhost" || host === "127.0.0.1") return undefined;
const parts = host.split(".").filter(Boolean);
if (parts.length < 2) return undefined;
return `.${parts.slice(-2).join(".")}`;
}
function readRawCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const prefix = `${name}=`;
const match = document.cookie.split("; ").find((row) => row.startsWith(prefix));
if (!match) return null;
return match.slice(prefix.length);
}
function writeRawCookie(name: string, value: string | null) {
if (typeof document === "undefined") return;
const domain = cookieDomain();
if (value == null) {
let cookie = `${name}=; path=/; max-age=0; SameSite=Lax`;
if (domain) cookie += `; domain=${domain}`;
document.cookie = cookie;
return;
}
const maxAge = 60 * 60 * 24 * 30;
let cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax`;
if (domain) cookie += `; domain=${domain}`;
document.cookie = cookie;
}
function clearChunkedCookie(name: string) {
writeRawCookie(name, null);
const n = Number(readRawCookie(`${name}_n`) || "0");
writeRawCookie(`${name}_n`, null);
for (let i = 0; i < Math.max(n, MAX_CHUNKS); i++) {
writeRawCookie(`${name}_${i}`, null);
}
}
function writeCookie(name: string, value: string | null) {
clearChunkedCookie(name);
if (value == null || value === "") return;
const encoded = encodeURIComponent(value);
if (encoded.length <= 3500) {
writeRawCookie(name, encoded);
return;
}
const chunks: string[] = [];
for (let i = 0; i < encoded.length; i += CHUNK_SIZE) {
chunks.push(encoded.slice(i, i + CHUNK_SIZE));
}
writeRawCookie(`${name}_n`, String(chunks.length));
chunks.forEach((chunk, i) => writeRawCookie(`${name}_${i}`, chunk));
}
function readCookie(name: string): string | null {
const n = Number(readRawCookie(`${name}_n`) || "0");
if (n > 0) {
let encoded = "";
for (let i = 0; i < n; i++) {
const part = readRawCookie(`${name}_${i}`);
if (part == null) return null;
encoded += part;
}
try {
return decodeURIComponent(encoded);
} catch {
return null;
}
}
const raw = readRawCookie(name);
if (raw == null) return null;
try {
return decodeURIComponent(raw);
} catch {
return null;
}
}
export function clearSharedAuthCookies() {
writeCookie(ACCESS_TOKEN_KEY, null);
writeCookie(REFRESH_TOKEN_KEY, null);
}
export function readSharedAccessToken(): string | null {
return readCookie(ACCESS_TOKEN_KEY);
}
export function readSharedRefreshToken(): string | null {
return readCookie(REFRESH_TOKEN_KEY);
}
export function hydrateLocalTokensFromSharedCookies(): boolean {
if (typeof window === "undefined") return false;
if (localStorage.getItem(ACCESS_TOKEN_KEY)) return false;
const access = readSharedAccessToken();
if (!access) return false;
localStorage.setItem(ACCESS_TOKEN_KEY, access);
const refresh = readSharedRefreshToken();
if (refresh) localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
return true;
}
export function clearLocalAuthTokens() {
if (typeof window === "undefined") return;
localStorage.removeItem(ACCESS_TOKEN_KEY);
localStorage.removeItem(REFRESH_TOKEN_KEY);
clearSharedAuthCookies();
}
export function readLocalAccessToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(ACCESS_TOKEN_KEY);
}
export function readLocalRefreshToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(REFRESH_TOKEN_KEY);
}
export function writeLocalTokens(accessToken: string, refreshToken?: string) {
if (typeof window === "undefined") return;
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken);
if (refreshToken) localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Build a URL path segment from a title (Persian / Arabic / Latin).
* Keeps letters and digits; spaces → hyphens; strips other punctuation.
*/
export function slugifyForUrl(value: string, fallback = "item"): string {
const slug = value
.trim()
.normalize("NFC")
.replace(/\s+/g, "-")
.replace(
/[^\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\uFB50-\uFDFF\uFE70-\uFEFFa-zA-Z0-9-]+/g,
"",
)
.replace(/-+/g, "-")
.replace(/^-|-$/g, "");
return slug || fallback;
}
export function detailPath(base: string, id: string | number, title: string) {
const slug = slugifyForUrl(title);
return `${base}/${encodeURIComponent(String(id))}/${encodeURIComponent(slug)}`;
}
+60
View File
@@ -0,0 +1,60 @@
{
"baseUrl": "https://www.meshkee.com",
"staticPages": [
{
"path": "/",
"changefreq": "weekly",
"priority": 1
},
{
"path": "/blog",
"changefreq": "weekly",
"priority": 0.8
},
{
"path": "/cms-website",
"changefreq": "monthly",
"priority": 0.9
},
{
"path": "/dedicated-application",
"changefreq": "monthly",
"priority": 0.8
},
{
"path": "/graphic-designer",
"changefreq": "monthly",
"priority": 0.7
},
{
"path": "/instruction",
"changefreq": "weekly",
"priority": 0.8
},
{
"path": "/meshkee-cms",
"changefreq": "monthly",
"priority": 0.8
},
{
"path": "/portfolio",
"changefreq": "weekly",
"priority": 0.8
},
{
"path": "/seo",
"changefreq": "monthly",
"priority": 0.8
},
{
"path": "/website",
"changefreq": "monthly",
"priority": 0.9
},
{
"path": "/website-qom",
"changefreq": "monthly",
"priority": 0.9
}
]
}