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,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"prebuild": "node scripts/generate-sitemap-config.mjs",
|
||||
"generate:sitemap-config": "node scripts/generate-sitemap-config.mjs",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"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 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 {
|
||||
@@ -15,6 +16,28 @@ type BlogDetailPageProps = {
|
||||
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({
|
||||
params,
|
||||
}: BlogDetailPageProps): Promise<Metadata> {
|
||||
@@ -25,6 +48,12 @@ export async function generateMetadata({
|
||||
return {
|
||||
title: `${blog.title} | مشکی`,
|
||||
description: blog.abstract ?? undefined,
|
||||
alternates: { canonical: `/blog/${blog.slug}` },
|
||||
openGraph: {
|
||||
title: blog.title,
|
||||
description: blog.abstract ?? undefined,
|
||||
type: "article",
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { title: "مقاله | مشکی" };
|
||||
@@ -45,54 +74,86 @@ export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
|
||||
const cover = getBlogCoverImage(blog);
|
||||
const date = formatFaDate(blog.publishedAt);
|
||||
const author = authorName(blog.author);
|
||||
const category = blog.categoryName;
|
||||
const bodyHtml = blog.mainTextHtml
|
||||
? stripLeadingCoverImage(blog.mainTextHtml, cover)
|
||||
: null;
|
||||
const titleEn = category || "Blog";
|
||||
|
||||
return (
|
||||
<ContentPageShell>
|
||||
<ContentPageShell flushTop>
|
||||
<article className="pb-20 lg:pb-28">
|
||||
<div className="mx-auto max-w-3xl px-5 lg:px-8">
|
||||
<div className="reveal mb-8">
|
||||
<Link
|
||||
href="/blog"
|
||||
className="link-hover mb-6 inline-flex text-sm text-muted"
|
||||
<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)]">
|
||||
<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)]"
|
||||
>
|
||||
بازگشت به مقالات
|
||||
</Link>
|
||||
|
||||
{blog.categoryName ? (
|
||||
<p className="latin title-en mb-3 text-muted">
|
||||
{blog.categoryName}
|
||||
{titleEn}
|
||||
</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}
|
||||
<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">
|
||||
{blog.title}
|
||||
</h1>
|
||||
|
||||
{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}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<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>
|
||||
|
||||
{cover && blog.titleImageUrl ? (
|
||||
<div className="reveal reveal-delay-1 mb-10 overflow-hidden rounded-2xl bg-paper-deep">
|
||||
<img
|
||||
<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=""
|
||||
className="mx-auto max-h-[28rem] w-full object-contain"
|
||||
alt={blog.title}
|
||||
className="mx-auto max-h-[28rem]"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{blog.mainTextHtml ? (
|
||||
<div className="reveal reveal-delay-2">
|
||||
<HtmlContent html={blog.mainTextHtml} />
|
||||
{(date || author) ? (
|
||||
<div className="mb-8 flex items-center justify-between gap-4 border-b border-line/70 pb-5 text-xs text-muted">
|
||||
{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>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { getBlogCoverImage, getBlogs } from "@/lib/api";
|
||||
@@ -67,14 +68,8 @@ export default async function BlogListPage({ searchParams }: BlogPageProps) {
|
||||
}`}
|
||||
>
|
||||
<Link href={`/blog/${post.slug}`} className="block">
|
||||
<div className="relative mb-4 overflow-hidden rounded-2xl bg-paper-deep">
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="h-auto w-full transition duration-700 group-hover:scale-105"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative mb-4 overflow-hidden rounded-2xl">
|
||||
<BlogCoverImage src={image} alt={post.title} />
|
||||
</div>
|
||||
<h2 className="group-text-hover mb-2 line-clamp-2 whitespace-normal text-lg font-normal leading-8 text-ink">
|
||||
{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));
|
||||
--background: var(--paper);
|
||||
--foreground: var(--ink);
|
||||
--header-height: 9rem;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
:root {
|
||||
--header-height: 11rem;
|
||||
}
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -110,30 +117,167 @@ h4 {
|
||||
}
|
||||
|
||||
/* Gradient hover utilities */
|
||||
/* Simple fade for primary CTAs */
|
||||
.btn-primary {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
background: var(--ink);
|
||||
color: var(--surface);
|
||||
transition:
|
||||
background 0.25s ease,
|
||||
color 0.25s ease,
|
||||
box-shadow 0.25s ease;
|
||||
cursor: pointer;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
.btn-primary::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
border-radius: inherit;
|
||||
background: var(--brand-gradient);
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
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 {
|
||||
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 {
|
||||
color: var(--accent);
|
||||
background: var(--brand-gradient);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-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 {
|
||||
@@ -165,11 +309,20 @@ a.group-border-hover: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 {
|
||||
color: var(--accent);
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.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%);
|
||||
}
|
||||
|
||||
.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 {
|
||||
background:
|
||||
radial-gradient(ellipse 55% 45% at 78% 35%, var(--glow), transparent 70%),
|
||||
@@ -325,8 +503,19 @@ a.group-border-hover:hover {
|
||||
|
||||
.prose-content a {
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
text-decoration: none;
|
||||
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 {
|
||||
@@ -364,3 +553,90 @@ a.group-border-hover:hover {
|
||||
padding: 0.55rem 0.75rem;
|
||||
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 { Montserrat, Vazirmatn } from "next/font/google";
|
||||
import { Providers } from "@/components/Providers";
|
||||
import { getFavicon, getTenant } from "@/lib/api";
|
||||
import "./globals.css";
|
||||
|
||||
const vazirmatn = Vazirmatn({
|
||||
@@ -15,8 +17,18 @@ const montserrat = Montserrat({
|
||||
});
|
||||
|
||||
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),
|
||||
title: {
|
||||
default: "تیم برنامهنویسی و تجارت الکترونیک مشکی | Meshkee",
|
||||
@@ -39,12 +51,12 @@ export const metadata: Metadata = {
|
||||
],
|
||||
icons: {
|
||||
icon: [
|
||||
{ url: "/images/brand/meshkee-mark.svg", type: "image/svg+xml" },
|
||||
{ url: icon },
|
||||
{ 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" }],
|
||||
shortcut: ["/favicon.ico"],
|
||||
apple: [{ url: icon }],
|
||||
shortcut: [icon],
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
@@ -56,7 +68,7 @@ export const metadata: Metadata = {
|
||||
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستمهای مدیریتی، سئو و گرافیک توسط تیم مشکی.",
|
||||
images: [
|
||||
{
|
||||
url: "/images/brand/favicon.png",
|
||||
url: icon,
|
||||
width: 512,
|
||||
height: 512,
|
||||
alt: "لوگوی مشکی",
|
||||
@@ -68,7 +80,7 @@ export const metadata: Metadata = {
|
||||
title: "تیم برنامهنویسی و تجارت الکترونیک مشکی | Meshkee",
|
||||
description:
|
||||
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستمهای مدیریتی، سئو و گرافیک توسط تیم مشکی.",
|
||||
images: ["/images/brand/favicon.png"],
|
||||
images: [icon],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
@@ -85,19 +97,30 @@ export const metadata: Metadata = {
|
||||
canonical: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
let businessId: string | null = null;
|
||||
try {
|
||||
const tenant = await getTenant();
|
||||
businessId = String(tenant.id);
|
||||
} catch {
|
||||
businessId = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<html
|
||||
lang="fa"
|
||||
dir="rtl"
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 { BlogSection } from "@/components/BlogSection";
|
||||
import { CmsSection } from "@/components/CmsSection";
|
||||
@@ -10,6 +11,15 @@ import { Portfolio } from "@/components/Portfolio";
|
||||
import { RevealObserver } from "@/components/RevealObserver";
|
||||
import { Services } from "@/components/Services";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: "تیم برنامهنویسی و تجارت الکترونیک مشکی | Meshkee",
|
||||
},
|
||||
description:
|
||||
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستمهای مدیریتی، سئو و گرافیک توسط تیم مشکی.",
|
||||
alternates: { canonical: "/" },
|
||||
};
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
|
||||
import { ContentPageShell } from "@/components/ContentPageShell";
|
||||
import { HtmlContent } from "@/components/HtmlContent";
|
||||
import { PortfolioGallery } from "@/components/PortfolioGallery";
|
||||
import { PortfolioProjectLink } from "@/components/PortfolioProjectLink";
|
||||
import {
|
||||
categoryLabelFa,
|
||||
findPortfolioCategory,
|
||||
@@ -65,6 +66,12 @@ export async function generateMetadata({
|
||||
return {
|
||||
title: `${title} | مشکی`,
|
||||
description: portfolio.abstract ?? undefined,
|
||||
alternates: { canonical: `/portfolio/${portfolio.slug}` },
|
||||
openGraph: {
|
||||
title,
|
||||
description: portfolio.abstract ?? undefined,
|
||||
type: "article",
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { title: "نمونهکار | مشکی" };
|
||||
@@ -103,58 +110,83 @@ export default async function PortfolioDetailPage({
|
||||
(item) => item.url !== cover,
|
||||
);
|
||||
|
||||
return (
|
||||
<ContentPageShell>
|
||||
<article className="pb-20 lg:pb-28">
|
||||
<div className="mx-auto max-w-5xl px-5 lg:px-8">
|
||||
<div className="reveal mb-10 max-w-3xl">
|
||||
<Link
|
||||
href={
|
||||
rootCategory
|
||||
const titleEn =
|
||||
portfolio.titleEn ??
|
||||
(portfolio.titleFa && portfolio.title !== portfolio.titleFa
|
||||
? portfolio.title
|
||||
: null);
|
||||
const categoryHref = rootCategory
|
||||
? portfolioCategoryHref(rootCategory.slug)
|
||||
: "/portfolio"
|
||||
}
|
||||
className="link-hover mb-6 inline-flex text-sm text-muted"
|
||||
>
|
||||
بازگشت به نمونهکارها
|
||||
</Link>
|
||||
: "/portfolio";
|
||||
|
||||
{categoryFa ? (
|
||||
<p className="mb-3 text-[11px] text-muted">{categoryFa}</p>
|
||||
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="/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">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{portfolio.titleEn ? (
|
||||
<p className="latin title-en text-muted">
|
||||
{portfolio.titleEn}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{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}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{date ? (
|
||||
<time
|
||||
className="mt-5 block text-xs text-muted"
|
||||
dateTime={portfolio.publishedAt ?? undefined}
|
||||
>
|
||||
{date}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-5xl px-5 lg:px-8">
|
||||
<PortfolioGallery
|
||||
cover={cover}
|
||||
images={gallery.map((item) => ({
|
||||
id: item.mediaId,
|
||||
url: item.url,
|
||||
}))}
|
||||
category={categoryFa}
|
||||
date={date}
|
||||
dateTime={portfolio.publishedAt}
|
||||
title={title}
|
||||
/>
|
||||
|
||||
{portfolio.mainTextHtml ? (
|
||||
@@ -163,6 +195,13 @@ export default async function PortfolioDetailPage({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{portfolio.projectUrl ? (
|
||||
<PortfolioProjectLink
|
||||
url={portfolio.projectUrl}
|
||||
title={title}
|
||||
/>
|
||||
) : null}
|
||||
</article>
|
||||
</ContentPageShell>
|
||||
);
|
||||
|
||||
@@ -92,21 +92,28 @@ export default async function PortfolioListPage({
|
||||
rootCategories.length > 0 ? rootCategories : topPortfolioCategories;
|
||||
|
||||
return (
|
||||
<ContentPageShell>
|
||||
<ContentPageShell flushTop>
|
||||
<section className="pb-20 lg:pb-28">
|
||||
<div className="mx-auto max-w-7xl px-5 lg:px-8">
|
||||
<div className="reveal mb-10 max-w-2xl md:mb-12">
|
||||
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal tracking-tight text-ink">
|
||||
نمونهکارها
|
||||
</h1>
|
||||
<p className="latin title-en text-muted">
|
||||
<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)]"
|
||||
>
|
||||
Portfolio
|
||||
</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>
|
||||
</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="/portfolio"
|
||||
@@ -151,6 +158,12 @@ export default async function PortfolioListPage({
|
||||
item.categoryName,
|
||||
allCategories,
|
||||
);
|
||||
const titleFa = item.titleFa || item.title;
|
||||
const titleEn =
|
||||
item.titleEn ??
|
||||
(item.titleFa && item.title !== item.titleFa
|
||||
? item.title
|
||||
: null);
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -168,7 +181,7 @@ export default async function PortfolioListPage({
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
alt={item.titleFa || item.title}
|
||||
className="h-auto w-full transition duration-700 group-hover:scale-105"
|
||||
/>
|
||||
) : null}
|
||||
@@ -179,11 +192,16 @@ export default async function PortfolioListPage({
|
||||
{categoryFa}
|
||||
</p>
|
||||
) : null}
|
||||
<h2 className="group-text-hover mb-0.5 line-clamp-2 whitespace-normal text-lg font-normal leading-7 text-ink">
|
||||
{item.titleFa || item.title}
|
||||
<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="line-clamp-3 text-sm leading-6 text-muted">
|
||||
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted">
|
||||
{item.abstract}
|
||||
</p>
|
||||
) : 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>
|
||||
<a
|
||||
href="#contact"
|
||||
href="/contact"
|
||||
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 { BlogCoverImage } from "@/components/BlogCoverImage";
|
||||
import { getBlogCoverImage, getBlogs } from "@/lib/api";
|
||||
|
||||
export async function BlogSection() {
|
||||
@@ -51,14 +52,8 @@ export async function BlogSection() {
|
||||
}`}
|
||||
>
|
||||
<Link href={`/blog/${post.slug}`} className="block">
|
||||
<div className="relative mb-4 overflow-hidden rounded-2xl bg-paper-deep">
|
||||
{image ? (
|
||||
<img
|
||||
src={image}
|
||||
alt=""
|
||||
className="h-auto w-full transition duration-700 group-hover:scale-105"
|
||||
/>
|
||||
) : null}
|
||||
<div className="relative mb-4 overflow-hidden rounded-2xl">
|
||||
<BlogCoverImage src={image} alt={post.title} />
|
||||
</div>
|
||||
<h3 className="group-text-hover mb-2 line-clamp-2 text-lg font-normal leading-8 text-ink">
|
||||
{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>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
<a
|
||||
href="#contact"
|
||||
href="/contact"
|
||||
className="btn-primary rounded-full px-6 py-3.5 text-sm"
|
||||
>
|
||||
با ما تماس بگیرید
|
||||
</a>
|
||||
<a
|
||||
href="#contact"
|
||||
href="/contact"
|
||||
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 { ContactForm } from "@/components/ContactForm";
|
||||
|
||||
export function ContactSection() {
|
||||
return (
|
||||
@@ -17,6 +16,14 @@ export function ContactSection() {
|
||||
>
|
||||
{site.phone}
|
||||
</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="max-w-sm text-sm leading-7 text-white/80 md:text-base md:leading-8">
|
||||
{site.address}
|
||||
@@ -28,45 +35,10 @@ export function ContactSection() {
|
||||
تماس با ما
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-white/60">
|
||||
ما همیشه دنبال دوستای جدید هستیم — یا ما خدمتتون تماس بگیریم.
|
||||
ما همیشه دنبال دوستای جدید هستیم.
|
||||
</p>
|
||||
|
||||
<form className="grid gap-4" onSubmit={(e) => e.preventDefault()}>
|
||||
<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>
|
||||
<ContactForm />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -4,14 +4,15 @@ import { RevealObserver } from "@/components/RevealObserver";
|
||||
|
||||
type ContentPageShellProps = {
|
||||
children: React.ReactNode;
|
||||
flushTop?: boolean;
|
||||
};
|
||||
|
||||
export function ContentPageShell({ children }: ContentPageShellProps) {
|
||||
export function ContentPageShell({ children, flushTop = false }: ContentPageShellProps) {
|
||||
return (
|
||||
<>
|
||||
<RevealObserver />
|
||||
<Header />
|
||||
<main className="pt-36 lg:pt-44">{children}</main>
|
||||
<main className={flushTop ? "" : "pt-36 lg:pt-44"}>{children}</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,50 @@
|
||||
import Link from "next/link";
|
||||
import { images } from "@/data/assets";
|
||||
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() {
|
||||
return (
|
||||
<footer className="border-t border-line/70 bg-surface">
|
||||
<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>
|
||||
<footer className="border-t border-line/70 bg-[linear-gradient(180deg,#fafafa_0%,#f3f5f7_100%)]">
|
||||
<div className="border-b border-line/60">
|
||||
<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="latin text-xs uppercase tracking-[0.18em] text-muted">
|
||||
{site.taglineEn}
|
||||
@@ -15,30 +54,103 @@ export function Footer() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 text-sm text-ink-soft">
|
||||
{nav.map((item) => (
|
||||
<div>
|
||||
<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
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="transition link-hover"
|
||||
className="link-hover transition"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted" dir="ltr">
|
||||
<a href={site.phoneHref} className="block transition hover:text-ink">
|
||||
<div>
|
||||
<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}
|
||||
</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}
|
||||
</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 className="border-t border-line/70 py-5 text-center text-xs text-muted">
|
||||
© 2026 Meshkee — مشکی
|
||||
<div className="footer-bottom-bar">
|
||||
<div className="mx-auto flex max-w-7xl flex-col items-center justify-between gap-3 px-5 py-4 sm:flex-row lg:px-8">
|
||||
<div className="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>
|
||||
</footer>
|
||||
);
|
||||
|
||||
@@ -2,23 +2,17 @@
|
||||
|
||||
import Link from "next/link";
|
||||
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";
|
||||
|
||||
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 }) {
|
||||
if (!item.children?.length) {
|
||||
return (
|
||||
<Link
|
||||
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}
|
||||
</Link>
|
||||
@@ -29,7 +23,7 @@ function DesktopNavItem({ item }: { item: NavItem }) {
|
||||
<div className="group relative">
|
||||
<Link
|
||||
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}
|
||||
<svg
|
||||
@@ -49,7 +43,7 @@ function DesktopNavItem({ item }: { item: NavItem }) {
|
||||
href={item.href}
|
||||
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>
|
||||
{item.children.map((child) => (
|
||||
<Link
|
||||
@@ -71,7 +65,7 @@ export function Header() {
|
||||
const [expanded, setExpanded] = useState<string | null>(null);
|
||||
|
||||
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 */}
|
||||
<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">
|
||||
@@ -98,15 +92,15 @@ export function Header() {
|
||||
</div>
|
||||
|
||||
<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 href="#contact" className="text-xs text-muted transition hover:text-ink">
|
||||
<Link href="/contact" className="link-hover text-xs text-muted">
|
||||
تماس با ما
|
||||
</Link>
|
||||
<a
|
||||
href={site.phoneHref}
|
||||
className="latin text-xs text-muted transition hover:text-ink"
|
||||
className="latin link-hover text-xs text-muted"
|
||||
dir="ltr"
|
||||
>
|
||||
{site.phone}
|
||||
@@ -139,22 +133,10 @@ export function Header() {
|
||||
|
||||
{/* Login + CTA on the left */}
|
||||
<div className="hidden items-center gap-2 md:flex">
|
||||
<a
|
||||
href="/login"
|
||||
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"
|
||||
>
|
||||
<UserMenu />
|
||||
<Button href="/contact" size="sm" className="px-4">
|
||||
تماس با ما
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -238,21 +220,25 @@ export function Header() {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-3 flex gap-2">
|
||||
<a
|
||||
href="/login"
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded-full border border-line py-3 text-sm text-ink-soft"
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
href="/about"
|
||||
variant="outline"
|
||||
className="flex-1 py-3"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
ورود
|
||||
</a>
|
||||
<a
|
||||
href="#contact"
|
||||
className="btn-primary flex-1 rounded-full py-3 text-center text-sm"
|
||||
درباره ما
|
||||
</Button>
|
||||
<Button
|
||||
href="/contact"
|
||||
className="flex-1 py-3"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
تماس با ما
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<UserMenu compact />
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -10,20 +10,38 @@ type GalleryImage = {
|
||||
type PortfolioGalleryProps = {
|
||||
cover?: string | null;
|
||||
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 close = useEffectEvent(() => setActiveIndex(null));
|
||||
const showPrev = useEffectEvent(() => {
|
||||
setActiveIndex((current) =>
|
||||
current == null ? current : (current - 1 + images.length) % images.length,
|
||||
current == null ? current : (current - 1 + slides.length) % slides.length,
|
||||
);
|
||||
});
|
||||
const showNext = useEffectEvent(() => {
|
||||
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]);
|
||||
|
||||
const activeImage =
|
||||
activeIndex == null ? null : images[activeIndex] ?? null;
|
||||
activeIndex == null ? null : (slides[activeIndex] ?? null);
|
||||
|
||||
return (
|
||||
<>
|
||||
{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
|
||||
src={cover}
|
||||
alt=""
|
||||
className="h-auto max-h-[28rem] w-auto max-w-full rounded-2xl object-contain"
|
||||
alt={title || "تصویر نمونهکار"}
|
||||
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>
|
||||
) : 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">
|
||||
{images.map((image, index) => (
|
||||
{thumbs.map((image) => {
|
||||
const index = slides.findIndex((slide) => slide.id === image.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={image.id}
|
||||
type="button"
|
||||
@@ -72,17 +120,22 @@ export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) {
|
||||
>
|
||||
<img
|
||||
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]"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeImage ? (
|
||||
<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"
|
||||
aria-modal="true"
|
||||
aria-label="گالری تصاویر"
|
||||
@@ -96,7 +149,7 @@ export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) {
|
||||
بستن
|
||||
</button>
|
||||
|
||||
{images.length > 1 ? (
|
||||
{slides.length > 1 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
@@ -120,13 +173,20 @@ export function PortfolioGallery({ cover, images }: PortfolioGalleryProps) {
|
||||
>
|
||||
›
|
||||
</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}
|
||||
|
||||
<img
|
||||
src={activeImage.url}
|
||||
alt=""
|
||||
className="max-h-[90vh] max-w-[min(96vw,72rem)] rounded-xl object-contain"
|
||||
alt={
|
||||
title
|
||||
? `${title} — تصویر ${(activeIndex ?? 0) + 1}`
|
||||
: `تصویر ${(activeIndex ?? 0) + 1}`
|
||||
}
|
||||
className="max-h-[90vh] max-w-[min(96vw,72rem)] rounded-xl object-contain shadow-[0_24px_80px_rgba(0,0,0,0.35)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
/>
|
||||
</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 { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
function revealElement(el: Element) {
|
||||
el.classList.add("is-visible");
|
||||
}
|
||||
|
||||
function RevealObserverInner() {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const search = searchParams.toString();
|
||||
|
||||
useEffect(() => {
|
||||
const elements = document.querySelectorAll(".reveal:not(.is-visible)");
|
||||
const elements = Array.from(
|
||||
document.querySelectorAll(".reveal:not(.is-visible)"),
|
||||
);
|
||||
if (!elements.length) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add("is-visible");
|
||||
revealElement(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();
|
||||
}, [pathname, search]);
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ function SolutionFeatureCard({ feature }: { feature: Feature }) {
|
||||
{feature.icon ? (
|
||||
<img
|
||||
src={feature.icon}
|
||||
alt=""
|
||||
alt={feature.title}
|
||||
width={36}
|
||||
height={36}
|
||||
className="mt-0.5 h-9 w-9 shrink-0 object-contain"
|
||||
@@ -78,7 +78,7 @@ function TeamFeatureCard({ feature }: { feature: Feature }) {
|
||||
{feature.icon ? (
|
||||
<img
|
||||
src={feature.icon}
|
||||
alt=""
|
||||
alt={feature.title}
|
||||
width={43}
|
||||
height={43}
|
||||
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">
|
||||
<img
|
||||
src={service.icon}
|
||||
alt=""
|
||||
alt={service.title}
|
||||
width={48}
|
||||
height={48}
|
||||
className="h-12 w-12 shrink-0 object-contain"
|
||||
/>
|
||||
<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}
|
||||
</h3>
|
||||
<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",
|
||||
brandMark: "/images/brand/mark-black.png",
|
||||
brandMarkWhite: "/images/brand/mark-white.png",
|
||||
meshkeeCreditLogo: "/images/brand/MeshkeeLogo.png",
|
||||
services: {
|
||||
web: `${CDN}/files/ecXmIxV0te.png`,
|
||||
app: `${CDN}/files/L3esyJkRFC.png`,
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { images } from "./assets";
|
||||
import {
|
||||
instructionCategoryHref,
|
||||
topInstructionCategories,
|
||||
} from "./instructionCategories";
|
||||
import {
|
||||
portfolioCategoryHref,
|
||||
topPortfolioCategories,
|
||||
@@ -13,12 +17,44 @@ export const site = {
|
||||
phone: "0902 4200 240",
|
||||
phoneHref: "tel:+989024200240",
|
||||
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 = {
|
||||
label: string;
|
||||
href: string;
|
||||
allLabel?: string;
|
||||
children?: { label: string; href: string }[];
|
||||
};
|
||||
|
||||
@@ -31,11 +67,21 @@ export const nav: NavItem[] = [
|
||||
{
|
||||
label: "نمونهکارها",
|
||||
href: "/portfolio",
|
||||
allLabel: "همه نمونهکارها",
|
||||
children: topPortfolioCategories.map((category) => ({
|
||||
label: category.nameFa,
|
||||
href: portfolioCategoryHref(category.slug),
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: "آموزشها",
|
||||
href: "/instruction",
|
||||
allLabel: "همه آموزشها",
|
||||
children: topInstructionCategories.map((category) => ({
|
||||
label: category.nameFa,
|
||||
href: instructionCategoryHref(category.slug),
|
||||
})),
|
||||
},
|
||||
{ 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";
|
||||
|
||||
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 {
|
||||
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 = {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
@@ -34,6 +68,23 @@ export type PortfolioGalleryItem = {
|
||||
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 = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -41,6 +92,7 @@ export type Portfolio = {
|
||||
titleEn?: string | null;
|
||||
slug: string;
|
||||
abstract: string | null;
|
||||
projectUrl?: string | null;
|
||||
titleImageUrl: string | null;
|
||||
mainTextHtml: string | null;
|
||||
publishedAt: string | null;
|
||||
@@ -69,6 +121,13 @@ type GetPortfoliosOptions = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type GetInstructionsOptions = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
categoryId?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type ApiFetchInit = RequestInit & {
|
||||
next?: {
|
||||
revalidate?: number | false;
|
||||
@@ -154,6 +213,15 @@ export async function getPortfolioCategories(): Promise<PortfolioCategoryApi[]>
|
||||
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(
|
||||
options: GetPortfoliosOptions = {},
|
||||
): Promise<Paginated<Portfolio>> {
|
||||
@@ -240,13 +308,97 @@ export async function getPortfolio(slug: string): Promise<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(
|
||||
blog: Pick<Blog, "titleImageUrl" | "mainTextHtml">,
|
||||
): string | null {
|
||||
if (blog.titleImageUrl) return blog.titleImageUrl;
|
||||
|
||||
const match = blog.mainTextHtml?.match(/<img[^>]+src=["']([^"']+)["']/i);
|
||||
return match?.[1] ?? null;
|
||||
const urls = extractImageUrlsFromHtml(blog.mainTextHtml);
|
||||
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(
|
||||
@@ -261,6 +413,17 @@ export function getPortfolioCoverImage(
|
||||
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 {
|
||||
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(" ");
|
||||
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
|
||||
}
|
||||
]
|
||||
}
|
||||