Compare commits

...
7 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
Alireza HassaniandCursor 908d86939c Polish portfolio, blog, and site chrome for clearer browsing.
Add patterned title bands, lightbox gallery, project link bar, footer stats/credit, gradient link hovers, and more reliable blog covers/body rendering.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 22:17:25 +03:30
Alireza HassaniandCursor 37d8e4be91 Improve portfolio list card layout with four-column grid.
Show the English title as a low-opacity backdrop behind the Farsi title and move the category label below it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 18:07:48 +03:30
50 changed files with 3197 additions and 372 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+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

+86 -25
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { BlogCoverImage } from "@/components/BlogCoverImage";
import { ContentPageShell } from "@/components/ContentPageShell"; import { ContentPageShell } from "@/components/ContentPageShell";
import { HtmlContent } from "@/components/HtmlContent"; import { HtmlContent } from "@/components/HtmlContent";
import { import {
@@ -15,6 +16,28 @@ type BlogDetailPageProps = {
params: Promise<{ slug: string }>; params: Promise<{ slug: string }>;
}; };
function stripLeadingCoverImage(html: string, cover: string | null): string {
if (!html) return html;
let next = html.replace(
/^\s*<p[^>]*>\s*<img[^>]*>\s*<\/p>/i,
"",
);
if (cover) {
const escaped = cover.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
next = next.replace(
new RegExp(
`<p[^>]*>\\s*<img[^>]*src=["']${escaped}["'][^>]*>\\s*<\\/p>`,
"i",
),
"",
);
}
return next.trim();
}
export async function generateMetadata({ export async function generateMetadata({
params, params,
}: BlogDetailPageProps): Promise<Metadata> { }: BlogDetailPageProps): Promise<Metadata> {
@@ -25,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: "مقاله | مشکی" };
@@ -45,54 +74,86 @@ export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
const cover = getBlogCoverImage(blog); const cover = getBlogCoverImage(blog);
const date = formatFaDate(blog.publishedAt); const date = formatFaDate(blog.publishedAt);
const author = authorName(blog.author); const author = authorName(blog.author);
const category = blog.categoryName;
const bodyHtml = blog.mainTextHtml
? stripLeadingCoverImage(blog.mainTextHtml, cover)
: null;
const titleEn = category || "Blog";
return ( return (
<ContentPageShell> <ContentPageShell flushTop>
<article className="pb-20 lg:pb-28"> <article className="pb-20 lg:pb-28">
<div className="mx-auto max-w-3xl px-5 lg:px-8"> <div className="page-title-band mb-8 md:mb-10">
<div className="reveal mb-8"> <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)]">
<Link <p
href="/blog" aria-hidden
className="link-hover mb-6 inline-flex text-sm text-muted" 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}
</Link>
{blog.categoryName ? (
<p className="latin title-en mb-3 text-muted">
{blog.categoryName}
</p> </p>
<div className="reveal relative z-10">
<nav
aria-label="مسیر صفحه"
className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-muted"
>
<Link href="/blog" className="link-hover">
مقالات
</Link>
{category ? (
<>
<span aria-hidden className="text-line">
/
</span>
<span className="latin uppercase tracking-[0.14em]">
{category}
</span>
</>
) : null} ) : null}
<span aria-hidden className="text-line">
/
</span>
<span className="line-clamp-1 text-ink-soft">{blog.title}</span>
</nav>
<h1 className="whitespace-normal text-[clamp(1.35rem,3.8vw,2.5rem)] font-normal leading-tight tracking-tight text-ink"> <h1 className="whitespace-normal text-[clamp(1.35rem,3.8vw,2.5rem)] font-normal leading-tight tracking-tight text-ink">
{blog.title} {blog.title}
</h1> </h1>
{blog.abstract ? ( {blog.abstract ? (
<p className="mt-5 text-sm leading-8 text-muted md:text-base"> <p className="mt-2 text-justify text-sm leading-7 text-muted md:text-base">
{blog.abstract} {blog.abstract}
</p> </p>
) : null} ) : null}
</div>
<div className="mt-5 flex flex-wrap items-center gap-x-4 gap-y-2 text-xs text-muted">
{date ? <time dateTime={blog.publishedAt ?? undefined}>{date}</time> : null}
{author ? <span>نویسنده: {author}</span> : null}
</div> </div>
</div> </div>
{cover && blog.titleImageUrl ? ( <div className="mx-auto max-w-3xl px-5 lg:px-8">
<div className="reveal reveal-delay-1 mb-10 overflow-hidden rounded-2xl bg-paper-deep"> {cover ? (
<img <div className="mb-5 overflow-hidden rounded-2xl">
<BlogCoverImage
src={cover} src={cover}
alt="" alt={blog.title}
className="mx-auto max-h-[28rem] w-full object-contain" className="mx-auto max-h-[28rem]"
/> />
</div> </div>
) : null} ) : null}
{blog.mainTextHtml ? ( {(date || author) ? (
<div className="reveal reveal-delay-2"> <div className="mb-8 flex items-center justify-between gap-4 border-b border-line/70 pb-5 text-xs text-muted">
<HtmlContent html={blog.mainTextHtml} /> {author ? <span>نویسنده: {author}</span> : <span />}
{date ? (
<time dateTime={blog.publishedAt ?? undefined}>{date}</time>
) : null}
</div>
) : cover ? (
<div className="mb-8 border-b border-line/70" />
) : null}
{bodyHtml ? (
<div className="reveal">
<HtmlContent html={bodyHtml} />
</div> </div>
) : null} ) : null}
</div> </div>
+3 -8
View File
@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import Link from "next/link"; import Link from "next/link";
import { BlogCoverImage } from "@/components/BlogCoverImage";
import { ContentPageShell } from "@/components/ContentPageShell"; import { ContentPageShell } from "@/components/ContentPageShell";
import { Pagination } from "@/components/Pagination"; import { Pagination } from "@/components/Pagination";
import { getBlogCoverImage, getBlogs } from "@/lib/api"; import { getBlogCoverImage, getBlogs } from "@/lib/api";
@@ -67,14 +68,8 @@ 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 bg-paper-deep"> <div className="relative mb-4 overflow-hidden rounded-2xl">
{image ? ( <BlogCoverImage src={image} alt={post.title} />
<img
src={image}
alt=""
className="h-auto w-full transition duration-700 group-hover:scale-105"
/>
) : null}
</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>
);
}
+290 -14
View File
@@ -16,6 +16,13 @@
--brand-gradient: linear-gradient(135deg, var(--accent-from), var(--accent-to)); --brand-gradient: linear-gradient(135deg, var(--accent-from), var(--accent-to));
--background: var(--paper); --background: var(--paper);
--foreground: var(--ink); --foreground: var(--ink);
--header-height: 9rem;
}
@media (min-width: 1024px) {
:root {
--header-height: 11rem;
}
} }
@theme inline { @theme inline {
@@ -110,30 +117,167 @@ 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 {
transition: color 0.25s ease; display: inline-block;
width: fit-content;
max-width: 100%;
background-image: var(--brand-gradient);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: currentColor;
color: inherit;
transition:
color 0.35s ease,
-webkit-text-fill-color 0.35s ease;
} }
.link-hover:hover { .link-hover:hover {
color: var(--accent);
background: var(--brand-gradient);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
color: transparent;
}
/* Keep flex layout when the link is already a flex row (nav + icon) */
a.link-hover.inline-flex,
.link-hover.inline-flex {
display: inline-flex;
}
/* Pill / button-style links: solid accent, no gradient fill */
a.link-hover.rounded-full,
.link-hover.rounded-full,
.link-hover.btn-primary {
background-image: none;
background-clip: unset;
-webkit-background-clip: unset;
-webkit-text-fill-color: unset;
}
a.link-hover.rounded-full:hover,
.link-hover.rounded-full:hover,
.link-hover.btn-primary:hover {
color: var(--accent);
-webkit-text-fill-color: var(--accent);
} }
.border-hover { .border-hover {
@@ -165,11 +309,20 @@ a.group-border-hover:hover {
} }
.group-text-hover { .group-text-hover {
transition: color 0.25s ease; display: inline;
background-image: var(--brand-gradient);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: currentColor;
color: inherit;
transition:
color 0.35s ease,
-webkit-text-fill-color 0.35s ease;
} }
.group:hover .group-text-hover { .group:hover .group-text-hover {
color: var(--accent); -webkit-text-fill-color: transparent;
color: transparent;
} }
.carousel-mask { .carousel-mask {
@@ -207,6 +360,31 @@ a.group-border-hover:hover {
mask-image: radial-gradient(ellipse 70% 60% at 50% 30%, black 20%, transparent 75%); mask-image: radial-gradient(ellipse 70% 60% at 50% 30%, black 20%, transparent 75%);
} }
.page-title-band {
border-bottom: 1px solid rgba(14, 17, 22, 0.08);
background-color: #fafafa;
background-image:
radial-gradient(rgba(14, 17, 22, 0.05) 1px, transparent 1px),
linear-gradient(rgba(14, 17, 22, 0.02) 1px, transparent 1px),
linear-gradient(90deg, rgba(14, 17, 22, 0.02) 1px, transparent 1px);
background-size: 20px 20px, 40px 40px, 40px 40px;
}
.project-link-bar {
position: fixed;
inset-inline: 0;
bottom: 0;
z-index: 40;
border-top: 1px solid rgba(255, 255, 255, 0.1);
background-color: rgba(14, 17, 22, 0.94);
background-image:
radial-gradient(rgba(255, 255, 255, 0.09) 1px, transparent 1px),
linear-gradient(rgba(255, 255, 255, 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.045) 1px, transparent 1px);
background-size: 18px 18px, 36px 36px, 36px 36px;
backdrop-filter: blur(10px);
}
.mesh-glow { .mesh-glow {
background: background:
radial-gradient(ellipse 55% 45% at 78% 35%, var(--glow), transparent 70%), radial-gradient(ellipse 55% 45% at 78% 35%, var(--glow), transparent 70%),
@@ -325,8 +503,19 @@ a.group-border-hover:hover {
.prose-content a { .prose-content a {
color: var(--accent); color: var(--accent);
text-decoration: underline; text-decoration: none;
text-underline-offset: 3px; background-image: var(--brand-gradient);
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: currentColor;
transition:
color 0.35s ease,
-webkit-text-fill-color 0.35s ease;
}
.prose-content a:hover {
-webkit-text-fill-color: transparent;
color: transparent;
} }
.prose-content img { .prose-content img {
@@ -364,3 +553,90 @@ a.group-border-hover:hover {
padding: 0.55rem 0.75rem; padding: 0.55rem 0.75rem;
text-align: start; text-align: start;
} }
.footer-bottom-bar {
border-top: 1px solid #e5e5e5;
background: #f7f7f7;
}
.meshkee-credit {
position: relative;
}
.meshkee-credit__frame {
position: absolute;
inset: 0;
pointer-events: none;
}
.meshkee-credit__line {
position: absolute;
background: #c8c8c8;
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.meshkee-credit__line--l {
top: 0;
left: 0;
width: 1px;
height: 100%;
transform: scaleY(0);
transform-origin: top;
transition-delay: 0s;
}
.meshkee-credit__line--b {
bottom: 0;
left: 0;
width: 100%;
height: 1px;
transform: scaleX(0);
transform-origin: left;
transition-delay: 0.14s;
}
.meshkee-credit__line--r {
top: 0;
right: 0;
width: 1px;
height: 100%;
transform: scaleY(0);
transform-origin: bottom;
transition-delay: 0.28s;
}
.meshkee-credit__line--t {
top: 0;
left: 0;
width: 100%;
height: 1px;
transform: scaleX(0);
transform-origin: right;
transition-delay: 0.42s;
}
.meshkee-credit:hover .meshkee-credit__line--l,
.meshkee-credit:hover .meshkee-credit__line--r {
transform: scaleY(1);
}
.meshkee-credit:hover .meshkee-credit__line--t,
.meshkee-credit:hover .meshkee-credit__line--b {
transform: scaleX(1);
}
.meshkee-credit:not(:hover) .meshkee-credit__line--l {
transition-delay: 0.42s;
}
.meshkee-credit:not(:hover) .meshkee-credit__line--b {
transition-delay: 0.28s;
}
.meshkee-credit:not(:hover) .meshkee-credit__line--r {
transition-delay: 0.14s;
}
.meshkee-credit:not(:hover) .meshkee-credit__line--t {
transition-delay: 0s;
}
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>
);
}
+32 -9
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,
@@ -85,19 +97,30 @@ export const metadata: Metadata = {
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 (
<> <>
+71 -32
View File
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
import { ContentPageShell } from "@/components/ContentPageShell"; import { ContentPageShell } from "@/components/ContentPageShell";
import { HtmlContent } from "@/components/HtmlContent"; import { HtmlContent } from "@/components/HtmlContent";
import { PortfolioGallery } from "@/components/PortfolioGallery"; import { PortfolioGallery } from "@/components/PortfolioGallery";
import { PortfolioProjectLink } from "@/components/PortfolioProjectLink";
import { import {
categoryLabelFa, categoryLabelFa,
findPortfolioCategory, findPortfolioCategory,
@@ -65,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: "نمونه‌کار | مشکی" };
@@ -103,58 +110,83 @@ export default async function PortfolioDetailPage({
(item) => item.url !== cover, (item) => item.url !== cover,
); );
return ( const titleEn =
<ContentPageShell> portfolio.titleEn ??
<article className="pb-20 lg:pb-28"> (portfolio.titleFa && portfolio.title !== portfolio.titleFa
<div className="mx-auto max-w-5xl px-5 lg:px-8"> ? portfolio.title
<div className="reveal mb-10 max-w-3xl"> : null);
<Link const categoryHref = rootCategory
href={
rootCategory
? portfolioCategoryHref(rootCategory.slug) ? portfolioCategoryHref(rootCategory.slug)
: "/portfolio" : "/portfolio";
}
className="link-hover mb-6 inline-flex text-sm text-muted"
>
بازگشت به نمونه‌کارها
</Link>
{categoryFa ? ( return (
<p className="mb-3 text-[11px] text-muted">{categoryFa}</p> <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} ) : 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="/portfolio"
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"> <h1 className="whitespace-normal text-[clamp(1.35rem,3.8vw,2.5rem)] font-normal leading-tight tracking-tight text-ink">
{title} {title}
</h1> </h1>
{portfolio.titleEn ? (
<p className="latin title-en text-muted">
{portfolio.titleEn}
</p>
) : null}
{portfolio.abstract ? ( {portfolio.abstract ? (
<p className="mt-5 text-justify text-sm leading-8 text-muted md:text-base"> <p className="mt-2 text-justify text-sm leading-7 text-muted md:text-base">
{portfolio.abstract} {portfolio.abstract}
</p> </p>
) : null} ) : null}
</div>
{date ? ( </div>
<time
className="mt-5 block text-xs text-muted"
dateTime={portfolio.publishedAt ?? undefined}
>
{date}
</time>
) : null}
</div> </div>
<div className="mx-auto max-w-5xl px-5 lg:px-8">
<PortfolioGallery <PortfolioGallery
cover={cover} cover={cover}
images={gallery.map((item) => ({ images={gallery.map((item) => ({
id: item.mediaId, id: item.mediaId,
url: item.url, url: item.url,
}))} }))}
category={categoryFa}
date={date}
dateTime={portfolio.publishedAt}
title={title}
/> />
{portfolio.mainTextHtml ? ( {portfolio.mainTextHtml ? (
@@ -163,6 +195,13 @@ export default async function PortfolioDetailPage({
</div> </div>
) : null} ) : null}
</div> </div>
{portfolio.projectUrl ? (
<PortfolioProjectLink
url={portfolio.projectUrl}
title={title}
/>
) : null}
</article> </article>
</ContentPageShell> </ContentPageShell>
); );
+30 -12
View File
@@ -92,21 +92,28 @@ export default async function PortfolioListPage({
rootCategories.length > 0 ? rootCategories : topPortfolioCategories; rootCategories.length > 0 ? rootCategories : topPortfolioCategories;
return ( return (
<ContentPageShell> <ContentPageShell flushTop>
<section className="pb-20 lg:pb-28"> <section className="pb-20 lg:pb-28">
<div className="mx-auto max-w-7xl px-5 lg:px-8"> <div className="page-title-band mb-8 md:mb-10">
<div className="reveal mb-10 max-w-2xl md:mb-12"> <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)]">
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal tracking-tight text-ink"> <p
نمونه‌کارها aria-hidden
</h1> 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)]"
<p className="latin title-en text-muted"> >
Portfolio Portfolio
</p> </p>
<p className="mt-5 text-sm leading-8 text-muted md:text-base"> <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> </p>
</div> </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"> <div className="reveal mb-10 flex flex-wrap gap-2 md:mb-12">
<Link <Link
href="/portfolio" href="/portfolio"
@@ -151,6 +158,12 @@ export default async function PortfolioListPage({
item.categoryName, item.categoryName,
allCategories, allCategories,
); );
const titleFa = item.titleFa || item.title;
const titleEn =
item.titleEn ??
(item.titleFa && item.title !== item.titleFa
? item.title
: null);
return ( return (
<article <article
@@ -168,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}
@@ -179,11 +192,16 @@ export default async function PortfolioListPage({
{categoryFa} {categoryFa}
</p> </p>
) : null} ) : null}
<h2 className="group-text-hover mb-0.5 line-clamp-2 whitespace-normal text-lg font-normal leading-7 text-ink"> <h2 className="group-text-hover line-clamp-2 whitespace-normal text-lg font-normal leading-7 text-ink">
{item.titleFa || item.title} {titleFa}
</h2> </h2>
{titleEn ? (
<p className="latin text-[12px] font-light uppercase tracking-[0.22em] text-ink-soft/70">
{titleEn}
</p>
) : null}
{item.abstract ? ( {item.abstract ? (
<p className="line-clamp-3 text-sm leading-6 text-muted"> <p className="mt-2 line-clamp-3 text-sm leading-6 text-muted">
{item.abstract} {item.abstract}
</p> </p>
) : 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,
}));
}

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+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;
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useState } from "react";
type BlogCoverImageProps = {
src: string | null;
alt?: string;
className?: string;
};
export function BlogCoverImage({
src,
alt = "",
className = "",
}: BlogCoverImageProps) {
const [failed, setFailed] = useState(false);
if (!src || failed) {
return (
<div
className={`flex min-h-[10rem] items-center justify-center rounded-2xl border border-line/60 bg-[linear-gradient(135deg,#f3f5f7_0%,#e8edf3_100%)] ${className}`.trim()}
>
<span className="latin text-[11px] tracking-[0.24em] text-muted uppercase">
Meshkee Blog
</span>
</div>
);
}
return (
<img
src={src}
alt={alt}
onError={() => setFailed(true)}
className={`h-auto w-full object-contain transition duration-700 group-hover:scale-105 ${className}`.trim()}
/>
);
}
+3 -8
View File
@@ -1,4 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { BlogCoverImage } from "@/components/BlogCoverImage";
import { getBlogCoverImage, getBlogs } from "@/lib/api"; import { getBlogCoverImage, getBlogs } from "@/lib/api";
export async function BlogSection() { export async function BlogSection() {
@@ -51,14 +52,8 @@ 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 bg-paper-deep"> <div className="relative mb-4 overflow-hidden rounded-2xl">
{image ? ( <BlogCoverImage src={image} alt={post.title} />
<img
src={image}
alt=""
className="h-auto w-full transition duration-700 group-hover:scale-105"
/>
) : null}
</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>
+3 -2
View File
@@ -4,14 +4,15 @@ import { RevealObserver } from "@/components/RevealObserver";
type ContentPageShellProps = { type ContentPageShellProps = {
children: React.ReactNode; children: React.ReactNode;
flushTop?: boolean;
}; };
export function ContentPageShell({ children }: ContentPageShellProps) { export function ContentPageShell({ children, flushTop = false }: ContentPageShellProps) {
return ( return (
<> <>
<RevealObserver /> <RevealObserver />
<Header /> <Header />
<main className="pt-36 lg:pt-44">{children}</main> <main className={flushTop ? "" : "pt-36 lg:pt-44"}>{children}</main>
<Footer /> <Footer />
</> </>
); );
+123 -11
View File
@@ -1,11 +1,50 @@
import Link from "next/link"; import Link from "next/link";
import { images } from "@/data/assets";
import { nav, site } from "@/data/home"; import { nav, site } from "@/data/home";
import {
portfolioCategoryHref,
topPortfolioCategories,
} from "@/data/portfolioCategories";
const stats = [
{ value: "112+", label: "بیش از ۱۱۲ وبسایت", labelEn: "WEBSITES" },
{ value: "28+", label: "بیش از ۲۸ لوگو", labelEn: "LOGOS" },
{ value: "7+", label: "بیش از ۷ اپلیکیشن", labelEn: "APPS" },
];
export function Footer() { export function Footer() {
return ( return (
<footer className="border-t border-line/70 bg-surface"> <footer className="border-t border-line/70 bg-[linear-gradient(180deg,#fafafa_0%,#f3f5f7_100%)]">
<div className="mx-auto flex max-w-7xl flex-col gap-10 px-5 py-12 lg:flex-row lg:items-start lg:justify-between lg:px-8"> <div className="border-b border-line/60">
<div> <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 sm:divide-line/60">
{stats.map((stat) => (
<div
key={stat.label}
className="flex items-center gap-4 px-5 py-7 lg:gap-5 lg:px-8 lg:py-9"
>
<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 sm:h-12"
/>
<div className="min-w-0">
<p className="text-sm leading-6 text-ink-soft">{stat.label}</p>
<p className="latin mt-1 text-[10px] tracking-[0.22em] text-muted uppercase">
{stat.labelEn}
</p>
</div>
</div>
))}
</div>
</div>
<div className="mx-auto grid max-w-7xl gap-10 px-5 py-12 sm:grid-cols-2 lg:grid-cols-4 lg:gap-8 lg:px-8 lg:py-14">
<div className="sm:col-span-2 lg:col-span-1">
<p className="mb-2 text-2xl font-black text-ink">{site.nameFa}</p> <p className="mb-2 text-2xl font-black text-ink">{site.nameFa}</p>
<p className="latin text-xs uppercase tracking-[0.18em] text-muted"> <p className="latin text-xs uppercase tracking-[0.18em] text-muted">
{site.taglineEn} {site.taglineEn}
@@ -15,30 +54,103 @@ export function Footer() {
</p> </p>
</div> </div>
<div className="flex flex-col gap-3 text-sm text-ink-soft"> <div>
{nav.map((item) => ( <p className="mb-4 text-xs text-muted">خدمات</p>
<div className="flex flex-col gap-2.5 text-sm text-ink-soft">
{nav
.filter((item) => item.href !== "/portfolio")
.map((item) => (
<Link <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className="transition link-hover" className="link-hover transition"
> >
{item.label} {item.label}
</Link> </Link>
))} ))}
</div> </div>
</div>
<div className="text-sm text-muted" dir="ltr"> <div>
<a href={site.phoneHref} className="block transition hover:text-ink"> <p className="mb-4 text-xs text-muted">نمونه‌کارها</p>
<div className="flex flex-col gap-2.5 text-sm text-ink-soft">
<Link href="/portfolio" className="link-hover transition">
همه نمونه‌کارها
</Link>
{topPortfolioCategories.map((category) => (
<Link
key={category.id}
href={portfolioCategoryHref(category.slug)}
className="link-hover transition"
>
{category.nameFa}
</Link>
))}
</div>
</div>
<div>
<p className="mb-4 text-xs text-muted">تماس</p>
<a
href={site.phoneHref}
className="latin block text-sm text-ink-soft transition hover:text-ink"
dir="ltr"
>
{site.phone} {site.phone}
</a> </a>
<p className="mt-2 max-w-xs text-right" dir="rtl"> <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
href="/about"
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>
</div>
</div> </div>
</div> </div>
<div className="border-t border-line/70 py-5 text-center text-xs text-muted"> <div className="footer-bottom-bar">
© 2026 Meshkee — مشکی <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="text-xs text-[#999]">
© {new Date().getFullYear()} Meshkee — مشکی
</div>
<a
href="https://meshkee.com"
target="_blank"
rel="noopener noreferrer"
dir="ltr"
className="meshkee-credit group inline-flex items-center gap-2.5 px-2.5 py-1.5 text-[#999] transition-colors duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] hover:text-[#555]"
>
<span className="meshkee-credit__frame" aria-hidden>
<span className="meshkee-credit__line meshkee-credit__line--l" />
<span className="meshkee-credit__line meshkee-credit__line--t" />
<span className="meshkee-credit__line meshkee-credit__line--r" />
<span className="meshkee-credit__line meshkee-credit__line--b" />
</span>
<img
src={images.meshkeeCreditLogo}
alt="Meshkee"
width={36}
height={32}
className="relative h-8 w-auto shrink-0 object-contain opacity-50 transition-opacity duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:opacity-100"
/>
<span className="relative flex flex-col leading-snug">
<span className="text-[11px]">Designed And Developed By</span>
<span className="text-[13px] font-semibold text-[#666] transition-colors duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:text-[#333]">
Meshkee E-Commerce Team
</span>
</span>
</a>
</div>
</div> </div>
</footer> </footer>
); );
+27 -41
View File
@@ -2,23 +2,17 @@
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 (
<Link <Link
href={item.href} href={item.href}
className="whitespace-nowrap px-2.5 py-2 text-[13px] text-ink-soft transition hover:text-ink" className="link-hover whitespace-nowrap px-2.5 py-2 text-[13px] text-ink-soft"
> >
{item.label} {item.label}
</Link> </Link>
@@ -29,7 +23,7 @@ function DesktopNavItem({ item }: { item: NavItem }) {
<div className="group relative"> <div className="group relative">
<Link <Link
href={item.href} href={item.href}
className="inline-flex items-center gap-1 whitespace-nowrap px-2.5 py-2 text-[13px] text-ink-soft transition hover:text-ink" className="link-hover inline-flex items-center gap-1 whitespace-nowrap px-2.5 py-2 text-[13px] text-ink-soft"
> >
{item.label} {item.label}
<svg <svg
@@ -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
@@ -71,7 +65,7 @@ export function Header() {
const [expanded, setExpanded] = useState<string | null>(null); const [expanded, setExpanded] = useState<string | null>(null);
return ( return (
<header className="absolute inset-x-0 top-0 z-50"> <header className="fixed inset-x-0 top-0 z-50">
{/* Top bar: social icons + quick links */} {/* Top bar: social icons + quick links */}
<div className="border-b border-line/50 bg-white/80 backdrop-blur-sm"> <div className="border-b border-line/50 bg-white/80 backdrop-blur-sm">
<div className="mx-auto flex max-w-7xl items-center justify-between px-5 py-2 lg:px-8"> <div className="mx-auto flex max-w-7xl items-center justify-between px-5 py-2 lg:px-8">
@@ -98,15 +92,15 @@ export function Header() {
</div> </div>
<div className="hidden items-center gap-4 md:flex"> <div className="hidden items-center gap-4 md:flex">
<Link href="/about" className="text-xs text-muted transition hover:text-ink"> <Link href="/about" className="link-hover text-xs text-muted">
درباره ما درباره ما
</Link> </Link>
<Link href="#contact" className="text-xs text-muted transition hover:text-ink"> <Link href="/contact" className="link-hover text-xs text-muted">
تماس با ما تماس با ما
</Link> </Link>
<a <a
href={site.phoneHref} href={site.phoneHref}
className="latin text-xs text-muted transition hover:text-ink" className="latin link-hover text-xs text-muted"
dir="ltr" dir="ltr"
> >
{site.phone} {site.phone}
@@ -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>
+75 -15
View File
@@ -10,20 +10,38 @@ type GalleryImage = {
type PortfolioGalleryProps = { type PortfolioGalleryProps = {
cover?: string | null; cover?: string | null;
images: GalleryImage[]; images: GalleryImage[];
category?: string | null;
date?: string | null;
dateTime?: string | null;
title?: string | null;
}; };
export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) { export function PortfolioGallery({
cover,
images,
category,
date,
dateTime,
title,
}: PortfolioGalleryProps) {
const slides: GalleryImage[] = [
...(cover ? [{ id: "cover", url: cover }] : []),
...images.filter((image) => image.url !== cover),
];
const thumbs = slides.filter((image) => image.id !== "cover");
const showMeta = Boolean(category || date);
const [activeIndex, setActiveIndex] = useState<number | null>(null); const [activeIndex, setActiveIndex] = useState<number | null>(null);
const close = useEffectEvent(() => setActiveIndex(null)); const close = useEffectEvent(() => setActiveIndex(null));
const showPrev = useEffectEvent(() => { const showPrev = useEffectEvent(() => {
setActiveIndex((current) => setActiveIndex((current) =>
current == null ? current : (current - 1 + images.length) % images.length, current == null ? current : (current - 1 + slides.length) % slides.length,
); );
}); });
const showNext = useEffectEvent(() => { const showNext = useEffectEvent(() => {
setActiveIndex((current) => setActiveIndex((current) =>
current == null ? current : (current + 1) % images.length, current == null ? current : (current + 1) % slides.length,
); );
}); });
@@ -47,23 +65,53 @@ export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) {
}, [activeIndex]); }, [activeIndex]);
const activeImage = const activeImage =
activeIndex == null ? null : images[activeIndex] ?? null; activeIndex == null ? null : (slides[activeIndex] ?? null);
return ( return (
<> <>
{cover ? ( {cover ? (
<div className="reveal reveal-delay-1 mb-10 flex justify-center"> <div className="reveal reveal-delay-1 mb-4 flex justify-center">
<button
type="button"
onClick={() => setActiveIndex(0)}
className="group relative max-w-full overflow-hidden rounded-2xl text-start"
aria-label="باز کردن تصویر در لایت‌باکس"
>
<img <img
src={cover} src={cover}
alt="" alt={title || "تصویر نمونه‌کار"}
className="h-auto max-h-[28rem] w-auto max-w-full rounded-2xl object-contain" 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>
</button>
</div> </div>
) : null} ) : null}
{images.length > 0 ? ( {showMeta ? (
<div className="reveal mb-5 flex items-center justify-between gap-4 text-xs text-muted">
<p className="text-ink-soft">{category}</p>
{date ? (
<time dateTime={dateTime ?? undefined} className="shrink-0">
{date}
</time>
) : (
<span />
)}
</div>
) : null}
{(showMeta || thumbs.length > 0) && (cover || showMeta) ? (
<div className="mb-8 border-t border-line/70" />
) : null}
{thumbs.length > 0 ? (
<div className="reveal reveal-delay-2 mb-12 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6"> <div className="reveal reveal-delay-2 mb-12 grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
{images.map((image, index) => ( {thumbs.map((image) => {
const index = slides.findIndex((slide) => slide.id === image.id);
return (
<button <button
key={image.id} key={image.id}
type="button" type="button"
@@ -72,17 +120,22 @@ export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) {
> >
<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>
))} );
})}
</div> </div>
) : null} ) : null}
{activeImage ? ( {activeImage ? (
<div <div
className="fixed inset-0 z-[80] flex items-center justify-center bg-ink/85 p-4 backdrop-blur-sm" className="fixed inset-0 z-[80] flex items-center justify-center bg-ink/90 p-4 backdrop-blur-md"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="گالری تصاویر" aria-label="گالری تصاویر"
@@ -96,7 +149,7 @@ export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) {
بستن بستن
</button> </button>
{images.length > 1 ? ( {slides.length > 1 ? (
<> <>
<button <button
type="button" type="button"
@@ -120,13 +173,20 @@ export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) {
> >
› ›
</button> </button>
<p className="latin absolute bottom-5 left-1/2 z-10 -translate-x-1/2 rounded-full bg-white/10 px-3 py-1 text-xs text-white/80">
{(activeIndex ?? 0) + 1} / {slides.length}
</p>
</> </>
) : null} ) : null}
<img <img
src={activeImage.url} src={activeImage.url}
alt="" alt={
className="max-h-[90vh] max-w-[min(96vw,72rem)] rounded-xl object-contain" 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)]"
onClick={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()}
/> />
</div> </div>
+49
View File
@@ -0,0 +1,49 @@
type PortfolioProjectLinkProps = {
url: string;
title?: string;
};
function displayHost(url: string): string {
try {
return new URL(url).host.replace(/^www\./, "");
} catch {
return url.replace(/^https?:\/\//, "").replace(/\/$/, "");
}
}
export function PortfolioProjectLink({
url,
title,
}: PortfolioProjectLinkProps) {
const host = displayHost(url);
return (
<>
<div aria-hidden className="h-20 sm:h-16" />
<section className="project-link-bar text-surface">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="group mx-auto flex max-w-7xl flex-col gap-2 px-5 py-3 transition sm:flex-row sm:items-center sm:justify-between sm:gap-4 lg:px-8 lg:py-3.5"
>
<div className="min-w-0">
<p className="text-base font-normal text-white sm:text-lg">
{title ? `بازدید از ${title}` : "باز کردن وبسایت پروژه"}
</p>
<p className="latin mt-1 text-xs tracking-[0.14em] text-white/45 uppercase">
{host}
</p>
</div>
<span className="inline-flex shrink-0 items-center gap-3 self-start rounded-full border border-white/20 bg-white/5 px-4 py-2.5 text-sm text-white transition group-hover:border-white/40 group-hover:bg-white/10 sm:self-auto">
رفتن به سایت
<span aria-hidden className="latin text-base leading-none">
↗
</span>
</span>
</a>
</section>
</>
);
}
+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>;
}
+21 -4
View File
@@ -3,28 +3,45 @@
import { Suspense, useEffect } from "react"; import { Suspense, useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation"; import { usePathname, useSearchParams } from "next/navigation";
function revealElement(el: Element) {
el.classList.add("is-visible");
}
function RevealObserverInner() { function RevealObserverInner() {
const pathname = usePathname(); const pathname = usePathname();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const search = searchParams.toString(); const search = searchParams.toString();
useEffect(() => { useEffect(() => {
const elements = document.querySelectorAll(".reveal:not(.is-visible)"); const elements = Array.from(
document.querySelectorAll(".reveal:not(.is-visible)"),
);
if (!elements.length) return; if (!elements.length) return;
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
(entries) => { (entries) => {
for (const entry of entries) { for (const entry of entries) {
if (entry.isIntersecting) { if (entry.isIntersecting) {
entry.target.classList.add("is-visible"); revealElement(entry.target);
observer.unobserve(entry.target); observer.unobserve(entry.target);
} }
} }
}, },
{ threshold: 0.16, rootMargin: "0px 0px -8% 0px" }, { threshold: 0.01, rootMargin: "0px 0px -4% 0px" },
); );
elements.forEach((el) => observer.observe(el)); for (const el of elements) {
const rect = el.getBoundingClientRect();
const inView =
rect.top < window.innerHeight * 0.95 && rect.bottom > 0;
if (inView) {
revealElement(el);
} else {
observer.observe(el);
}
}
return () => observer.disconnect(); return () => observer.disconnect();
}, [pathname, search]); }, [pathname, search]);
+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"
+2 -2
View File
@@ -30,13 +30,13 @@ 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"
/> />
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-[0.9rem] font-normal leading-snug text-ink md:text-[0.95rem]"> <h3 className="group-text-hover text-[0.9rem] font-normal leading-snug text-ink md:text-[0.95rem]">
{service.title} {service.title}
</h3> </h3>
<p className="latin title-en-sm text-muted"> <p className="latin title-en-sm text-muted">
+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>
);
}
+1
View File
@@ -9,6 +9,7 @@ export const images = {
brandLogoWhite: "/images/brand/logo-white.png", brandLogoWhite: "/images/brand/logo-white.png",
brandMark: "/images/brand/mark-black.png", brandMark: "/images/brand/mark-black.png",
brandMarkWhite: "/images/brand/mark-white.png", brandMarkWhite: "/images/brand/mark-white.png",
meshkeeCreditLogo: "/images/brand/MeshkeeLogo.png",
services: { services: {
web: `${CDN}/files/ecXmIxV0te.png`, web: `${CDN}/files/ecXmIxV0te.png`,
app: `${CDN}/files/L3esyJkRFC.png`, app: `${CDN}/files/L3esyJkRFC.png`,
+47 -1
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,
@@ -13,12 +17,44 @@ export const site = {
phone: "0902 4200 240", phone: "0902 4200 240",
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;
}
+264 -2
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;
@@ -41,6 +92,7 @@ export type Portfolio = {
titleEn?: string | null; titleEn?: string | null;
slug: string; slug: string;
abstract: string | null; abstract: string | null;
projectUrl?: string | null;
titleImageUrl: string | null; titleImageUrl: string | null;
mainTextHtml: string | null; mainTextHtml: string | null;
publishedAt: string | null; publishedAt: string | null;
@@ -69,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;
@@ -154,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>> {
@@ -240,13 +308,97 @@ 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 {
if (blog.titleImageUrl) return blog.titleImageUrl; if (blog.titleImageUrl) return blog.titleImageUrl;
const match = blog.mainTextHtml?.match(/<img[^>]+src=["']([^"']+)["']/i); const urls = extractImageUrlsFromHtml(blog.mainTextHtml);
return match?.[1] ?? null; const preferred = urls.find((url) => url.includes("/meshkee/businesses/"));
return preferred ?? urls[0] ?? null;
}
function extractImageUrlsFromHtml(html: string | null | undefined): string[] {
if (!html) return [];
const urls: string[] = [];
const pattern = /<img[^>]+src=["']([^"']+)["']/gi;
let match = pattern.exec(html);
while (match) {
urls.push(match[1]);
match = pattern.exec(html);
}
return urls;
} }
export function getPortfolioCoverImage( export function getPortfolioCoverImage(
@@ -261,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;
@@ -280,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
}
]
}