Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4911ccee9 | ||
|
|
e6404eaaec | ||
|
|
b0ba7b1de8 | ||
|
|
c588b249cc | ||
|
|
5d7da0f268 | ||
|
|
908d86939c | ||
|
|
37d8e4be91 |
@@ -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
|
||||||
@@ -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"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 660 B After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 644 B After Width: | Height: | Size: 968 B |
|
After Width: | Height: | Size: 10 KiB |
@@ -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();
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 4.0 KiB |
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.4 KiB |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Montserrat, Vazirmatn } from "next/font/google";
|
import { Montserrat, Vazirmatn } from "next/font/google";
|
||||||
|
import { Providers } from "@/components/Providers";
|
||||||
|
import { getFavicon, getTenant } from "@/lib/api";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const vazirmatn = Vazirmatn({
|
const vazirmatn = Vazirmatn({
|
||||||
@@ -15,8 +17,18 @@ const montserrat = Montserrat({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.meshkee.com";
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.meshkee.com";
|
||||||
|
const fallbackIcon = "/images/brand/favicon.png";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
|
let icon = fallbackIcon;
|
||||||
|
try {
|
||||||
|
const favicon = await getFavicon();
|
||||||
|
icon = favicon.faviconUrl || favicon.logoUrl || fallbackIcon;
|
||||||
|
} catch {
|
||||||
|
// keep local brand favicon
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
metadataBase: new URL(siteUrl),
|
metadataBase: new URL(siteUrl),
|
||||||
title: {
|
title: {
|
||||||
default: "تیم برنامهنویسی و تجارت الکترونیک مشکی | Meshkee",
|
default: "تیم برنامهنویسی و تجارت الکترونیک مشکی | Meshkee",
|
||||||
@@ -39,12 +51,12 @@ export const metadata: Metadata = {
|
|||||||
],
|
],
|
||||||
icons: {
|
icons: {
|
||||||
icon: [
|
icon: [
|
||||||
{ url: "/images/brand/meshkee-mark.svg", type: "image/svg+xml" },
|
{ url: icon },
|
||||||
{ url: "/favicon.ico", sizes: "any" },
|
{ url: "/favicon.ico", sizes: "any" },
|
||||||
{ url: "/images/brand/favicon.png", type: "image/png", sizes: "512x512" },
|
{ url: "/favicon.png", type: "image/png", sizes: "48x48" },
|
||||||
],
|
],
|
||||||
apple: [{ url: "/images/brand/favicon.png", type: "image/png", sizes: "512x512" }],
|
apple: [{ url: icon }],
|
||||||
shortcut: ["/favicon.ico"],
|
shortcut: [icon],
|
||||||
},
|
},
|
||||||
openGraph: {
|
openGraph: {
|
||||||
type: "website",
|
type: "website",
|
||||||
@@ -56,7 +68,7 @@ export const metadata: Metadata = {
|
|||||||
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستمهای مدیریتی، سئو و گرافیک توسط تیم مشکی.",
|
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستمهای مدیریتی، سئو و گرافیک توسط تیم مشکی.",
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/images/brand/favicon.png",
|
url: icon,
|
||||||
width: 512,
|
width: 512,
|
||||||
height: 512,
|
height: 512,
|
||||||
alt: "لوگوی مشکی",
|
alt: "لوگوی مشکی",
|
||||||
@@ -68,7 +80,7 @@ export const metadata: Metadata = {
|
|||||||
title: "تیم برنامهنویسی و تجارت الکترونیک مشکی | Meshkee",
|
title: "تیم برنامهنویسی و تجارت الکترونیک مشکی | Meshkee",
|
||||||
description:
|
description:
|
||||||
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستمهای مدیریتی، سئو و گرافیک توسط تیم مشکی.",
|
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستمهای مدیریتی، سئو و گرافیک توسط تیم مشکی.",
|
||||||
images: ["/images/brand/favicon.png"],
|
images: [icon],
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
index: true,
|
index: true,
|
||||||
@@ -84,20 +96,31 @@ export const metadata: Metadata = {
|
|||||||
alternates: {
|
alternates: {
|
||||||
canonical: "/",
|
canonical: "/",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
let businessId: string | null = null;
|
||||||
|
try {
|
||||||
|
const tenant = await getTenant();
|
||||||
|
businessId = String(tenant.id);
|
||||||
|
} catch {
|
||||||
|
businessId = null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="fa"
|
lang="fa"
|
||||||
dir="rtl"
|
dir="rtl"
|
||||||
className={`${vazirmatn.variable} ${montserrat.variable} h-full antialiased`}
|
className={`${vazirmatn.variable} ${montserrat.variable} h-full antialiased`}
|
||||||
>
|
>
|
||||||
<body className="min-h-full font-sans text-ink">{children}</body>
|
<body className="min-h-full font-sans text-ink">
|
||||||
|
<Providers businessId={businessId}>{children}</Providers>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import sitemapConfig from "@/lib/sitemap-config.generated.json";
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
"Access-Control-Allow-Origin": "*",
|
||||||
|
"Access-Control-Allow-Methods": "GET, OPTIONS",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type",
|
||||||
|
"Cache-Control": "public, max-age=60",
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function OPTIONS() {
|
||||||
|
return new Response(null, { status: 204, headers });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return Response.json(sitemapConfig, { headers });
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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 |
@@ -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"
|
||||||
>
|
>
|
||||||
با ما تماس بگیرید
|
با ما تماس بگیرید
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
>
|
>
|
||||||
دوست دارم بیشتر بدونم
|
دوست دارم بیشتر بدونم
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||