Add CMS website and Qom landing pages with responsive layout fixes.

Ship blog/portfolio routes, shared content components, and mobile-safe typography so the marketing site can deploy cleanly.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-07-29 21:57:44 +03:30
co-authored by Cursor
parent 47bee1ce66
commit 112e5eb46d
29 changed files with 1584 additions and 147 deletions
+5
View File
@@ -8,6 +8,11 @@ const nextConfig: NextConfig = {
hostname: "meshkee-templates.sas.amin.parminstorage.ir",
pathname: "/**",
},
{
protocol: "https",
hostname: "meshkee-storage.sas.amin.parminstorage.ir",
pathname: "/**",
},
{
protocol: "https",
hostname: "www.meshkee.com",
+102
View File
@@ -0,0 +1,102 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ContentPageShell } from "@/components/ContentPageShell";
import { HtmlContent } from "@/components/HtmlContent";
import {
ApiNotFoundError,
authorName,
formatFaDate,
getBlog,
getBlogCoverImage,
} from "@/lib/api";
type BlogDetailPageProps = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata({
params,
}: BlogDetailPageProps): Promise<Metadata> {
const { slug } = await params;
try {
const blog = await getBlog(slug);
return {
title: `${blog.title} | مشکی`,
description: blog.abstract ?? undefined,
};
} catch {
return { title: "مقاله | مشکی" };
}
}
export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
const { slug } = await params;
let blog;
try {
blog = await getBlog(slug);
} catch (error) {
if (error instanceof ApiNotFoundError) notFound();
throw error;
}
const cover = getBlogCoverImage(blog);
const date = formatFaDate(blog.publishedAt);
const author = authorName(blog.author);
return (
<ContentPageShell>
<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"
>
بازگشت به مقالات
</Link>
{blog.categoryName ? (
<p className="latin mb-3 text-[11px] uppercase tracking-widest text-muted">
{blog.categoryName}
</p>
) : null}
<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">
{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>
{cover && blog.titleImageUrl ? (
<div className="reveal reveal-delay-1 mb-10 overflow-hidden rounded-2xl bg-paper-deep">
<img
src={cover}
alt=""
className="mx-auto max-h-[28rem] w-full object-contain"
/>
</div>
) : null}
{blog.mainTextHtml ? (
<div className="reveal reveal-delay-2">
<HtmlContent html={blog.mainTextHtml} />
</div>
) : null}
</div>
</article>
</ContentPageShell>
);
}
+103
View File
@@ -0,0 +1,103 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ContentPageShell } from "@/components/ContentPageShell";
import { Pagination } from "@/components/Pagination";
import { getBlogCoverImage, getBlogs } from "@/lib/api";
export const metadata: Metadata = {
title: "مقالات | مشکی",
description: "آخرین مقالات آموزشی و انگیزشی تیم مشکی.",
};
const PAGE_SIZE = 12;
type BlogPageProps = {
searchParams: Promise<{ page?: string }>;
};
export default async function BlogListPage({ searchParams }: BlogPageProps) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
let items: Awaited<ReturnType<typeof getBlogs>>["items"] = [];
let total = 0;
try {
const data = await getBlogs({ page, pageSize: PAGE_SIZE });
items = data.items;
total = data.total;
} catch {
items = [];
total = 0;
}
return (
<ContentPageShell>
<section className="pb-20 lg:pb-28">
<div className="mx-auto max-w-7xl px-5 lg:px-8">
<div className="reveal mb-12 max-w-2xl md:mb-16">
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal tracking-tight text-ink">
رشدنامه، آموزشی و انگیزشی
</h1>
<p className="latin mt-3 text-xs uppercase tracking-[0.2em] text-muted">
Our Blog
</p>
<p className="mt-5 text-sm leading-8 text-muted md:text-base">
با محتوای مشکی همیشه به‌روز باشید.
</p>
</div>
{items.length === 0 ? (
<p className="text-sm text-muted">مقاله‌ای برای نمایش وجود ندارد.</p>
) : (
<div key={page} className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{items.map((post, index) => {
const image = getBlogCoverImage(post);
return (
<article
key={post.id}
className={`reveal group ${
index % 3 === 1
? "reveal-delay-1"
: index % 3 === 2
? "reveal-delay-2"
: ""
}`}
>
<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>
<h2 className="group-text-hover mb-2 line-clamp-2 whitespace-normal text-lg font-normal leading-8 text-ink">
{post.title}
</h2>
{post.abstract ? (
<p className="line-clamp-3 text-sm leading-7 text-muted">
{post.abstract}
</p>
) : null}
</Link>
</article>
);
})}
</div>
)}
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={total}
basePath="/blog"
/>
</div>
</section>
</ContentPageShell>
);
}
+183
View File
@@ -0,0 +1,183 @@
import type { Metadata } from "next";
import { ServicePageLayout } from "@/components/ServicePageLayout";
import { GraphicBrands, cmsWebsiteBrands } from "@/components/GraphicBrands";
import { SeoCities, cmsWebsiteCities } from "@/components/SeoCities";
import {
ProjectsShowcase,
cmsWebsiteProjects,
} from "@/components/WebsiteProjects";
import { images } from "@/data/assets";
const CDN =
"https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets/files";
const ASSETS =
"https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets";
export const metadata: Metadata = {
title: "وبسایت CMS | مشکی",
description:
"طراحی وبسایت روی سیستم مدیریت محتوای مشکی؛ ویرایش زنده، اتصال به پلتفرم‌ها و کدنویسی اختصاصی.",
};
export default function CmsWebsitePage() {
return (
<ServicePageLayout
metaTitle="وبسایت CMS | مشکی"
tag="خود را بروز و خاص معرفی کنید."
title="طراحی وبسایت"
titleEn="WEBSITE DESIGN"
lead="هر وبسایت نیازمند یک محیط ادمین برای مدیریت محصولات، مشتریان، سفارشات، اخبار و سایر مفاهیم خود می‌باشد. ما به‌جای سیستم‌های آماده مانند وردپرس، از سیستم مدیریت محتوای مشکی (Meshkee.cms) که توسط تیم خودمان از صفر تا صد برنامه‌نویسی شده است، استفاده می‌کنیم."
heroImage={images.pages.cmsWebsite}
sections={[
{
eyebrow: "هر وبسایت، نیازمند یک محیط ادمین برای مدیریت وبسایت است.",
title: "ما وبسایت شما را بر روی سیستم مدیریت محتوای مشکی طراحی می‌کنیم",
titleEn: "MESHKEE CMS",
body: "هر وبسایت نیازمند یک محیط ادمین برای مدیریت محصولات، مشتریان، سفارشات، اخبار و سایر مفاهیم خود می‌باشد. ما به‌جای سیستم‌های آماده مانند وردپرس، از سیستم مدیریت محتوای مشکی (Meshkee.cms) که توسط تیم خودمان از صفر تا صد برنامه‌نویسی شده است، استفاده می‌کنیم. استفاده یا عدم استفاده از CMS، بسته به نوع پروژه دارد. به‌عنوان مثال منطقی است که یک پروژه‌ی فروشگاهی از CMS استفاده کند زیرا با هزینه و زمان کم، امکانات بسیار خوبی در اختیار آن قرار می‌دهد؛ اما از طرف دیگر، پروژه‌ای که هدف آن محاسبه‌ی آمپر مصرفی لوازم خانگی است، بهتر است اختصاصی طراحی شود. برای درک بهتر اینکه پروژه‌ی شما با چه روشی مناسب‌تر است، با ما تماس بگیرید.",
image: `${CDN}/hlpMNIJEaX.png`,
},
{
eyebrow: "به نظر شما تجربه چقدر مهم است؟",
title: "ما ۶ سال هست به صورت تخصصی وبسایت طراحی می‌کنیم",
titleEn: "SINCE 2018",
body: "برنامه‌نویسی و تجارت الکترونیک با سرعت بسیار زیادی در حال به‌روز شدن است. یک برنامه‌نویس با ورود به این حوزه، ۲ تا ۳ سال اول کاری خود را مشغول یادگیری و سعی و خطا می‌باشد. از طرفی رقابت در این حوزه بسیار سنگین است؛ به‌نحوی که اگر وبسایت شما وبسایتی به‌روز نباشد، امکان شکست پروژه‌ی شما کاملاً وجود دارد. پس پیشنهاد ما این است یا وارد این حوزه نشوید، یا اگر شدید، با تیم‌هایی کار کنید که دارای تجربه‌ی زیاد و همچنین رزومه‌ی قوی هستند. در صورتی که ایده‌ای در سر دارید، با کارشناسان ما تماس بگیرید.",
image: `${CDN}/evqj3ChQLd.png`,
},
{
eyebrow: "به راحتی هر چه تمام‌تر، وبسایت را ویرایش کنید.",
title: "سیستم ویرایش زنده",
titleEn: "LIVE EDITING GRAPHICAL INTERFACE",
body: "وبسایت‌های بر روی بستر سیستم مدیریت محتوای مشکی، دارای سیستم ویرایش زنده هستند. به این معنا که شما می‌توانید عکس‌ها، متن‌ها، بخش‌ها و حتی صفحات وبسایت را خود ایجاد کرده و آن‌ها را ویرایش نمایید. برای این کار نیاز به دانش فنی ندارید و این کار به‌راحتی قابل انجام است. این سیستم می‌تواند در بسیاری از هزینه‌های برنامه‌نویسی شما در آینده جلوگیری کند.",
image: `${CDN}/36sxKL78cP.png`,
featureColumns: 2,
features: [
{
title: "مدیریت صفحات",
titleEn: "PAGES MANAGEMENT",
icon: `${CDN}/KT8s6hyp0r.png`,
description:
"ایجاد، ویرایش و سامان‌دهی صفحات وبسایت بدون نیاز به دانش فنی.",
},
{
title: "مدیریت بخش‌ها",
titleEn: "FRAMES MANAGEMENT",
icon: `${CDN}/dSkkS7evzH.png`,
description:
"افزودن و جابه‌جایی سکشن‌ها و فریم‌های صفحه به‌صورت بصری.",
},
{
title: "ویرایش عکس‌ها",
titleEn: "IMAGE HANDLING",
icon: `${CDN}/j7okT7OJBp.png`,
description:
"جایگزینی و مدیریت تصاویر وبسایت مستقیماً از محیط ویرایش زنده.",
},
{
title: "ویرایش متون",
titleEn: "TEXTS EDITING",
icon: `${CDN}/xGYrPJZW0k.png`,
description:
"تغییر تیترها، پاراگراف‌ها و متون صفحه با چند کلیک ساده.",
},
],
},
{
eyebrow: "همکاری با سایر پلتفرم‌ها",
title: "قابلیت اتصال به پلتفرم‌ها",
titleEn: "OTHER PLATFORMS COMMUNICATION",
body: "وبسایت‌های ما قابلیت اتصال به پلتفرم‌های مختلف را دارند. از جمله‌ی این پلتفرم‌ها می‌توان به مارکت‌پلیس‌ها (Market Places) مانند ترب و ایمالز، درگاه‌های پرداخت مانند سامان کیش، به‌پرداخت، زرین‌پال و همچنین مجوزهای سازمانی همچون اینماد، ساماندهی و … اشاره کرد.",
image: `${ASSETS}/img/torob.png`,
},
{
eyebrow: "آنچه به وبسایت شما توان می‌بخشد",
title: "سیستم مدیریت مشکی",
titleEn: "POWERED BY MESHKEE.APP",
body: "شاید بتوان گفت مهم‌ترین قسمت یک وبسایت، سیستم مدیریت آن و ویژگی‌های آن است. سیستم مدیریت وبسایت‌های ما Meshkee.app می‌باشد. این سیستم توسط تیم خود ما به‌صورت صفر تا صد برنامه‌نویسی شده است و قابلیت سفارشی‌سازی دارد. این سیستم به‌صورت بومی و فارسی طراحی شده است؛ به همین خاطر کاربری آن برای شما بسیار آسان خواهد بود. از بخش‌های مختلفی مانند مدیریت محصولات، مشتریان، فروشگاه، انبار، مالی و بسیاری بخش‌های دیگر تشکیل شده است. این بخش‌ها علاوه بر مدیریت وبسایت و اپلیکیشن شما، قادر به مدیریت داخلی سازمان شما نیز هستند. به‌عنوان مثال سیستم مشکی، یک صندوق فروشگاهی کامل قابل اتصال به دستگاه پوز، بارکدخوان و پرینتر در اختیار شما می‌گذارد.",
image: `${CDN}/keP1j4D8XU.png`,
featureColumns: 2,
features: [
{
title: "مدیریت انبار",
titleEn: "WAREHOUSE MANAGEMENT",
icon: `${CDN}/RWkgNv47kG.png`,
description:
"تعریف ساختار انبار، جایگاه کالا و رهگیری لحظه‌ای موجودی.",
},
{
title: "مدیریت مشتریان",
titleEn: "CUSTOMERS MANAGEMENT",
icon: `${CDN}/CxaE281imL.png`,
description:
"باشگاه مشتریان، سوابق خرید و ارتباط یکپارچه با فروشگاه.",
},
{
title: "مدیریت فروشگاه",
titleEn: "ONLINE STORE & CASHIER",
icon: `${ASSETS}/img/f2.png`,
description:
"محصولات، سفارشات و صندوق فروشگاهی متصل به وبسایت شما.",
},
{
title: "مدیریت کاربران",
titleEn: "USERS & ROLES",
icon: `${CDN}/KT8s6hyp0r.png`,
description:
"تعریف کاربران، نقش‌ها و سطوح دسترسی در کل سیستم.",
},
{
title: "مدیریت صفحات",
titleEn: "PAGES MANAGEMENT",
icon: `${CDN}/dSkkS7evzH.png`,
description:
"کنترل ساختار صفحات و محتوای وبسایت از پنل مدیریت مشکی.",
},
],
},
{
eyebrow: "اختصاصی برای شما",
title: "کد نویسی اختصاصی وبسایت شما",
titleEn: "DEDICATED CODING",
body: "اگرچه سیستم مدیریت وبسایت‌های ما یک سیستم از قبل طراحی‌شده است، اما وبسایت شما به‌صورت اختصاصی کدنویسی می‌شود. حتی می‌توانیم ابتدا طرح آن را برای شما در Figma بکشیم و بعد آن را پیاده کنیم. این اختصاصی بودن کدنویسی باعث سریع‌تر شدن و همین‌طور متمایز شدن وبسایت شما می‌شود. علاوه بر این موارد، باعث می‌شود در آینده بتوان آن را بهتر توسعه داد.",
image: `${CDN}/EIBhtiC5cG.png`,
},
]}
injections={[
{
after: 0,
node: (
<ProjectsShowcase
title="نمونه‌هایی از وبسایت‌های طراحی‌شده"
titleEn="WEBSITE DESIGN"
subtitle="لوگو خاص و مینیمال"
projects={cmsWebsiteProjects}
moreHref="/portfolio"
variant="carousel"
/>
),
},
{
after: 1,
node: (
<GraphicBrands
brands={cmsWebsiteBrands}
eyebrow="همکاری با سازمان‌های معتبر"
title="سازمان‌هایی که با آن‌ها همکاری کرده‌ایم"
titleEn="COOPERATION"
/>
),
},
{
after: 2,
node: (
<SeoCities
eyebrow="آماده‌ی همکاری با شما، در هر نقطه از ایران‌زمین"
title="مشتریان ما از تمام ایران می‌باشند"
titleEn="FROM ALL OVER THE IRAN"
cities={cmsWebsiteCities}
/>
),
},
]}
/>
);
}
+89 -2
View File
@@ -42,6 +42,11 @@
html {
scroll-behavior: smooth;
overflow-x: clip;
}
body {
overflow-x: clip;
}
/* Rule: all interactive elements animate smoothly on hover */
@@ -57,11 +62,12 @@ a {
opacity 0.2s ease;
}
/* Headings: consistent weight matching hero h1 — titles stay on one row */
/* Headings: consistent weight matching hero h1 */
h1,
h2 {
font-weight: 400;
white-space: nowrap;
overflow-wrap: anywhere;
word-break: break-word;
}
h3,
@@ -256,3 +262,84 @@ a.group-border-hover:hover {
transform: none !important;
}
}
.prose-content {
color: var(--ink-soft);
font-size: 0.975rem;
line-height: 2;
}
.prose-content > *:first-child {
margin-top: 0;
}
.prose-content p,
.prose-content ul,
.prose-content ol,
.prose-content blockquote,
.prose-content pre,
.prose-content table,
.prose-content figure {
margin: 1.1em 0;
}
.prose-content h1,
.prose-content h2,
.prose-content h3,
.prose-content h4 {
color: var(--ink);
font-weight: 400;
white-space: normal;
margin: 1.6em 0 0.6em;
line-height: 1.5;
}
.prose-content h2 {
font-size: 1.35rem;
}
.prose-content h3 {
font-size: 1.15rem;
}
.prose-content a {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 3px;
}
.prose-content img {
display: block;
max-width: 100%;
height: auto;
margin: 1.5rem auto;
border-radius: 1rem;
}
.prose-content ul,
.prose-content ol {
padding-inline-start: 1.4rem;
}
.prose-content li {
margin: 0.35em 0;
}
.prose-content blockquote {
border-inline-start: 3px solid var(--line);
padding-inline-start: 1rem;
color: var(--muted);
}
.prose-content table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.prose-content th,
.prose-content td {
border: 1px solid var(--line);
padding: 0.55rem 0.75rem;
text-align: start;
}
+134
View File
@@ -0,0 +1,134 @@
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { ContentPageShell } from "@/components/ContentPageShell";
import { HtmlContent } from "@/components/HtmlContent";
import {
ApiNotFoundError,
formatFaDate,
getPortfolio,
getPortfolioCoverImage,
} from "@/lib/api";
type PortfolioDetailPageProps = {
params: Promise<{ slug: string }>;
};
export async function generateMetadata({
params,
}: PortfolioDetailPageProps): Promise<Metadata> {
const { slug } = await params;
try {
const portfolio = await getPortfolio(slug);
const title = portfolio.titleFa || portfolio.title;
return {
title: `${title} | مشکی`,
description: portfolio.abstract ?? undefined,
};
} catch {
return { title: "نمونه‌کار | مشکی" };
}
}
export default async function PortfolioDetailPage({
params,
}: PortfolioDetailPageProps) {
const { slug } = await params;
let portfolio;
try {
portfolio = await getPortfolio(slug);
} catch (error) {
if (error instanceof ApiNotFoundError) notFound();
throw error;
}
const cover = getPortfolioCoverImage(portfolio);
const date = formatFaDate(portfolio.publishedAt);
const title = portfolio.titleFa || portfolio.title;
const gallery = (portfolio.gallery ?? []).filter(
(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="/portfolio"
className="link-hover mb-6 inline-flex text-sm text-muted"
>
بازگشت به نمونه‌کارها
</Link>
{portfolio.categoryName ? (
<p className="latin mb-3 text-[11px] uppercase tracking-widest text-muted">
{portfolio.categoryName}
</p>
) : null}
<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 mt-3 text-xs uppercase tracking-widest text-muted">
{portfolio.titleEn}
</p>
) : null}
{portfolio.abstract ? (
<p className="mt-5 text-sm leading-8 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>
{cover ? (
<div className="reveal reveal-delay-1 mb-10 overflow-hidden rounded-2xl bg-paper-deep">
<img
src={cover}
alt=""
className="mx-auto max-h-[32rem] w-full object-contain"
/>
</div>
) : null}
{gallery.length > 0 ? (
<div className="reveal reveal-delay-2 mb-12 grid gap-4 sm:grid-cols-2">
{gallery.map((item) => (
<div
key={item.mediaId}
className="overflow-hidden rounded-2xl bg-paper-deep"
>
<img
src={item.url}
alt=""
className="h-full w-full object-contain"
/>
</div>
))}
</div>
) : null}
{portfolio.mainTextHtml ? (
<div className="reveal reveal-delay-2 mx-auto max-w-3xl">
<HtmlContent html={portfolio.mainTextHtml} />
</div>
) : null}
</div>
</article>
</ContentPageShell>
);
}
+112
View File
@@ -0,0 +1,112 @@
import type { Metadata } from "next";
import Link from "next/link";
import { ContentPageShell } from "@/components/ContentPageShell";
import { Pagination } from "@/components/Pagination";
import { getPortfolioCoverImage, getPortfolios } from "@/lib/api";
export const metadata: Metadata = {
title: "نمونه‌کارها | مشکی",
description: "نمونه پروژه‌ها و کارهای انجام‌شده توسط تیم مشکی.",
};
const PAGE_SIZE = 12;
type PortfolioPageProps = {
searchParams: Promise<{ page?: string }>;
};
export default async function PortfolioListPage({
searchParams,
}: PortfolioPageProps) {
const { page: pageParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
let items: Awaited<ReturnType<typeof getPortfolios>>["items"] = [];
let total = 0;
try {
const data = await getPortfolios({ page, pageSize: PAGE_SIZE });
items = data.items;
total = data.total;
} catch {
items = [];
total = 0;
}
return (
<ContentPageShell>
<section className="pb-20 lg:pb-28">
<div className="mx-auto max-w-7xl px-5 lg:px-8">
<div className="reveal mb-12 max-w-2xl md:mb-16">
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal tracking-tight text-ink">
نمونه‌کارها
</h1>
<p className="latin mt-3 text-xs uppercase tracking-[0.2em] text-muted">
Portfolio
</p>
<p className="mt-5 text-sm leading-8 text-muted md:text-base">
نگاهی به پروژه‌ها و کارهای انجام‌شده توسط تیم مشکی.
</p>
</div>
{items.length === 0 ? (
<p className="text-sm text-muted">
نمونه‌کاری برای نمایش وجود ندارد.
</p>
) : (
<div key={page} className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{items.map((item, index) => {
const image = getPortfolioCoverImage(item);
return (
<article
key={item.id}
className={`reveal group ${
index % 3 === 1
? "reveal-delay-1"
: index % 3 === 2
? "reveal-delay-2"
: ""
}`}
>
<Link href={`/portfolio/${item.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>
{item.categoryName ? (
<p className="latin mb-2 text-[10px] uppercase tracking-widest text-muted">
{item.categoryName}
</p>
) : null}
<h2 className="group-text-hover mb-2 line-clamp-2 whitespace-normal text-lg font-normal leading-8 text-ink">
{item.titleFa || item.title}
</h2>
{item.abstract ? (
<p className="line-clamp-3 text-sm leading-7 text-muted">
{item.abstract}
</p>
) : null}
</Link>
</article>
);
})}
</div>
)}
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={total}
basePath="/portfolio"
/>
</div>
</section>
</ContentPageShell>
);
}
+158
View File
@@ -0,0 +1,158 @@
import type { Metadata } from "next";
import { ServicePageLayout } from "@/components/ServicePageLayout";
import { ServiceContactStrip } from "@/components/ServiceContactStrip";
import {
ProjectsShowcase,
websiteQomProjects,
} from "@/components/WebsiteProjects";
import { images } from "@/data/assets";
const CDN =
"https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets/files";
export const metadata: Metadata = {
title: "طراحی سایت در قم توسط تیم حرفه‌ای مشکی",
description:
"طراحی وبسایت در قم توسط تیم مشکی با استفاده از جدیدترین متدهای کدنویسی و CMS اختصاصی با بهترین قیمت.",
};
export default function WebsiteQomPage() {
return (
<ServicePageLayout
metaTitle="طراحی سایت در قم توسط تیم حرفه‌ای مشکی"
tag="طراحی وبسایت اختصاصی شما در قم"
title="مشکی، تیم متخصص طراحی سایت در قم"
titleEn="MESHKEE E-COMMERCE TEAM"
lead="طراحی انواع سایت‌های فروشگاهی، خدماتی، شرکتی، خبری و شخصی. اگر به دنبال یک تیم متخصص برای سفارش طراحی سایت در قم هستید، ما اینجا هستیم تا شما را در مسیر ایجاد یک وبسایت حرفه‌ای و متناسب با نیازهایتان همراهی کنیم."
heroImage={images.pages.websiteQom}
sections={[
{
eyebrow: "چقدر به توسعه وبسایت خود اهمیت می‌دهید؟",
title: "اهمیت وبسایت برای کسب‌وکارها در قم",
titleEn: "WHY IS WEBSITE DESIGN IN QOM IMPORTANT",
body: "امروزه بیشتر کسب‌وکارها رنگ و بوی اینترنتی شدن به خود گرفته‌اند و دلیل این امر، نیاز و خواسته کاربران است. پس اگر صاحب یک کسب‌وکار مانند فروشگاه لباس، فروشگاه لوازم آرایشی، ارائه‌دهنده خدمات مختلف و … در شهر قم هستید، نیاز دارید که مخاطبان شما را در سطح اینترنت پیدا کنند. برای دستیابی کاربران به شما و تبدیل آنان به مشتری ثابت، کافی است طراحی سایت شما توسط یک تیم حرفه‌ای انجام شود. تیم مشکی، به‌عنوان یک تیم متخصص طراحی سایت در قم تا انتهای این مسیر همراه شماست.",
image: `${CDN}/BvOvzV9Mdp.webp`,
},
{
eyebrow: "وجه تمایز ما با بقیه شرکت‌های طراحی وبسایت در قم چیه؟",
title: "ویژگی‌های بهترین شرکت طراحی سایت در قم | مشکی",
titleEn: "OUR SOLUTIONS",
body: "پیش از سفارش طراحی سایت، متخصصان تیم طراحی سایت مشکی تمامی نیازهای شما را می‌سنجند و متناسب با نیاز کسب‌وکار شما فرآیند طراحی سایت آغاز خواهد شد.",
featureColumns: 2,
features: [
{
title: "کدنویسی اختصاصی وبسایت شما با توجه به نیازها",
titleEn: "CUSTOM CODING OF YOUR WEBSITE",
icon: `${CDN}/xZgguHQokB.png`,
description:
"وبسایت شما متناسب با نیاز کسب‌وکارتان به‌صورت اختصاصی کدنویسی می‌شود.",
},
{
title: "قابل توسعه بودن وبسایت و اضافه کردن هر نوع قابلیت جدید",
titleEn: "EXPANDABILITY OF THE WEBSITE",
icon: `${CDN}/s7N0aFbbbM.png`,
description:
"ساختار پروژه طوری طراحی می‌شود که در آینده بتوان قابلیت‌های جدید را به‌راحتی اضافه کرد.",
},
{
title: "پنل کاربری بسیار ساده",
titleEn: "VERY SIMPLE USER PANEL",
icon: `${CDN}/LmtK7D8C3D.png`,
description:
"محیط مدیریت ساده و فارسی تا بدون دانش فنی بتوانید سایت را مدیریت کنید.",
},
{
title: "دیزاین وبسایت شما با توجه به سلیقه کاربران",
titleEn: "WEBSITE DESIGN ACCORDING TO USERS' TASTE",
icon: `${CDN}/cSpSK3yM2K.png`,
description:
"طراحی رابط کاربری بر اساس تجربه مخاطب و هویت برند شما.",
},
{
title: "طراحی و دیزاین واکنش‌گرا وبسایت شما",
titleEn: "RESPONSIVE DESIGN OF YOUR WEBSITE",
icon: `${CDN}/pWmfGK7hWU.png`,
description:
"نمایش درست و زیبا روی موبایل، تبلت و دسکتاپ.",
},
{
title: "پشتیبانی رایگان تا ۱ سال",
titleEn: "FREE SUPPORT FOR UP TO 1 YEAR",
icon: `${CDN}/YlvlN6fM25.png`,
description:
"تا یک سال پس از تحویل، پشتیبانی رایگان در کنار شما هستیم.",
},
{
title: "بهینه بودن سرعت وبسایت",
titleEn: "WEBSITE SPEED OPTIMIZATION",
icon: `${CDN}/d4FWKBPcMF.png`,
description:
"بهینه‌سازی سرعت بارگذاری برای تجربه بهتر کاربر و سئو.",
},
{
title: "سئو فرندلی بودن وبسایت",
titleEn: "SEO FRIENDLY WEBSITE",
icon: `${CDN}/0tqujfS8AF.png`,
description:
"ساختار فنی آماده برای بهینه‌سازی موتورهای جستجو از روز اول.",
},
],
},
{
eyebrow: "چقدر به منحصربه‌فرد بودن اهمیت می‌دهید",
title: "خدمات طراحی سایت در قم با رویکردی منحصربه‌فرد توسط تیم مشکی",
titleEn: "WEBSITE DESIGN SERVICES IN QOM",
body: "ما معتقدیم که هر وبسایت باید متناسب با نیازهای خاص کسب‌وکار شما طراحی شود. یعنی اگر شما دارای یک فروشگاه، شرکت یا ارائه‌دهنده یک خدمت هستید، باید وبسایت شما متناسب با نیاز کاربرانتان طراحی شود. تیم طراحی سایت مشکی در قم در این مسیر همراه و پشتیبان شماست.",
image: `${CDN}/YXkBRpuDv9.webp`,
ctaLabel: "با ما تماس بگیرید",
ctaHref: "#contact-strip",
},
{
eyebrow: "چقدر به راحتی کاربرتون اهمیت می‌دید؟",
title: "طراحی سایت فروشگاهی در قم: گسترش بازار به سراسر ایران",
titleEn: "DESIGNING A STORE WEBSITE IN QOM",
body: "آیا فروشگاه شما فقط به مشتریان محلی خدمات می‌دهد؟ یا به‌دنبال گسترش فعالیت خود به سطح ملی هستید؟ با طراحی یک وبسایت فروشگاهی حرفه‌ای توسط تیم مشکی، می‌توانید مرزهای بازار خود را گسترش دهید و به مشتریانی در سراسر ایران خدمات ارائه کنید. ویژگی‌های وبسایت‌های فروشگاهی ما: طراحی جذاب و مدرن، سیستم مدیریت محصولات، درگاه پرداخت آنلاین امن، امکانات جستجوی پیشرفته و سیستم انبارداری هوشمند.",
image: `${CDN}/gXFvLHDynC.webp`,
},
{
eyebrow: "چقدر به راحتی کاربرتون اهمیت می‌دید؟",
title: "طراحی وبسایت خدماتی در قم: افزایش اعتماد مشتریان",
titleEn: "DESIGNING A SERVICE WEBSITE IN QOM",
body: "اگر ارائه‌دهنده خدمات در قم هستید، یک وبسایت حرفه‌ای می‌تواند به شما در جلب اعتماد مشتریان کمک کند. تیم مشکی برای ارائه‌دهندگان خدمات مانند کلینیک‌ها، موسسات آموزشی و شرکت‌های مشاوره، وبسایت‌هایی طراحی می‌کند که جذاب و کاربرپسند باشند. امکانات شامل صفحات معرفی خدمات، رزرو آنلاین، بخش نظرات مشتریان، چت آنلاین و گالری تصاویر و ویدئوها است.",
image: `${CDN}/E9z3Do1CV9.webp`,
},
{
eyebrow: "چقدر به راحتی کاربرتون اهمیت می‌دید؟",
title: "طراحی سایت شرکتی در قم: نمایش حرفه‌ای برند شما",
titleEn: "CORPORATE WEBSITE DESIGN IN QOM",
body: "برای شرکت‌ها و برندهایی که می‌خواهند حضور خود را در بازار تقویت کنند، یک وبسایت حرفه‌ای ابزاری ضروری است. تیم مشکی با طراحی سایت‌های شرکتی به شما کمک می‌کند تا خدمات و محصولات خود را به‌شکل حرفه‌ای به مشتریان و شرکای تجاری معرفی کنید. امکانات شامل معرفی شرکت، نمایش محصولات و خدمات، پروژه‌های انجام‌شده، اخبار و مقالات و فرم‌های تماس است.",
image: `${CDN}/AyPufsOe2U.webp`,
},
]}
injections={[
{
after: -1,
node: (
<div id="contact-strip">
<ServiceContactStrip />
</div>
),
},
{
after: 1,
node: (
<ProjectsShowcase
title="نمونه‌هایی از وبسایت‌های طراحی‌شده"
titleEn="WEBSITE DESIGN"
subtitle="لوگو خاص و مینیمال"
projects={websiteQomProjects}
moreHref="/portfolio"
variant="carousel"
framed={false}
/>
),
},
]}
/>
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ export function AppSection() {
>
<div className="relative mx-auto grid max-w-7xl items-center gap-12 px-5 py-24 lg:grid-cols-2 lg:gap-16 lg:px-8 lg:py-32">
<div className="reveal order-2 lg:order-1">
<h2 className="mb-4 text-[clamp(1.15rem,3.2vw,2.5rem)] font-normal tracking-tight text-ink">
<h2 className="mb-4 max-w-[16em] text-[clamp(1.15rem,3.2vw,2.5rem)] font-normal leading-snug tracking-tight text-ink">
طراحی و توسعه‌ی اپلیکیشن
</h2>
<p className="latin mb-6 text-xs uppercase tracking-[0.2em] text-accent">
+59 -32
View File
@@ -1,50 +1,77 @@
import { posts } from "@/data/home";
import Link from "next/link";
import { getBlogCoverImage, getBlogs } from "@/lib/api";
export async function BlogSection() {
let posts: Awaited<ReturnType<typeof getBlogs>>["items"] = [];
try {
const data = await getBlogs({ page: 1, pageSize: 6 });
posts = data.items;
} catch {
posts = [];
}
if (posts.length === 0) return null;
export function BlogSection() {
return (
<section id="blog" className="border-t border-line/60 py-24 lg:py-32">
<div className="mx-auto max-w-7xl px-5 lg:px-8">
<div className="reveal mb-14 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<h2 className="text-[clamp(1.15rem,3.2vw,2.5rem)] font-normal tracking-tight text-ink">
<h2 className="max-w-[16em] text-[clamp(1.15rem,3.2vw,2.5rem)] font-normal leading-snug tracking-tight text-ink">
رشدنامه، آموزشی و انگیزشی
</h2>
<p className="latin mt-3 text-xs uppercase tracking-[0.2em] text-muted">
Our Blog
</p>
</div>
<p className="max-w-sm text-sm text-muted">
با محتوای مشکی همیشه به‌روز باشید.
</p>
<div className="flex flex-col items-start gap-3 md:items-end">
<p className="max-w-sm text-sm text-muted">
با محتوای مشکی همیشه به‌روز باشید.
</p>
<Link href="/blog" className="link-hover text-sm text-ink-soft">
مشاهده همه مقالات
</Link>
</div>
</div>
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((post, index) => (
<article
key={post.title}
className={`reveal group ${
index % 3 === 1
? "reveal-delay-1"
: index % 3 === 2
? "reveal-delay-2"
: ""
}`}
>
<a href="#blog" className="block">
<div className="relative mb-4 aspect-[4/3] overflow-hidden rounded-2xl bg-paper-deep">
<img
src={post.image}
alt=""
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
/>
</div>
<h3 className="group-text-hover mb-2 text-lg font-normal leading-8 text-ink">
{post.title}
</h3>
<p className="text-sm leading-7 text-muted">{post.excerpt}</p>
</a>
</article>
))}
{posts.map((post, index) => {
const image = getBlogCoverImage(post);
return (
<article
key={post.id}
className={`reveal group ${
index % 3 === 1
? "reveal-delay-1"
: index % 3 === 2
? "reveal-delay-2"
: ""
}`}
>
<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>
<h3 className="group-text-hover mb-2 line-clamp-2 text-lg font-normal leading-8 text-ink">
{post.title}
</h3>
{post.abstract ? (
<p className="text-sm leading-7 text-muted">
{post.abstract}
</p>
) : null}
</Link>
</article>
);
})}
</div>
</div>
</section>
+1 -1
View File
@@ -16,7 +16,7 @@ export function CmsSection() {
</div>
<div className="reveal reveal-delay-1">
<h2 className="mb-4 text-[clamp(1.15rem,3.2vw,2.5rem)] font-normal tracking-tight text-ink">
<h2 className="mb-4 max-w-[16em] text-[clamp(1.15rem,3.2vw,2.5rem)] font-normal leading-snug tracking-tight text-ink">
سیستم‌های مدیریت یکپارچه
</h2>
<p className="latin mb-6 text-xs uppercase tracking-[0.2em] text-muted">
+9 -5
View File
@@ -8,21 +8,25 @@ export function ContactSection() {
<div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_80%_20%,rgba(196,77,184,0.22),transparent_50%)]" />
<div className="relative mx-auto grid max-w-7xl gap-14 px-5 py-24 lg:grid-cols-[1fr_1.1fr] lg:px-8 lg:py-32">
<div className="reveal">
<div className="reveal min-w-0">
<p className="mb-3 text-sm text-white/55">شما تماس بگیرید …</p>
<a
href={site.phoneHref}
className="latin link-hover mb-8 block text-4xl font-bold tracking-tight text-white md:text-5xl"
className="latin link-hover mb-8 block text-[clamp(1.6rem,6vw,3rem)] font-bold tracking-tight text-white"
dir="ltr"
>
{site.phone}
</a>
<p className="mb-2 text-sm text-white/55">آدرس</p>
<p className="max-w-sm leading-8 text-white/80">{site.address}</p>
<p className="max-w-sm text-sm leading-7 text-white/80 md:text-base md:leading-8">
{site.address}
</p>
</div>
<div className="reveal reveal-delay-1 rounded-[1.75rem] border border-white/10 bg-white/5 p-6 backdrop-blur-sm md:p-8">
<h2 className="mb-2 text-2xl font-bold md:text-3xl">تماس با ما</h2>
<div className="reveal reveal-delay-1 min-w-0 rounded-[1.75rem] border border-white/10 bg-white/5 p-5 backdrop-blur-sm sm:p-6 md:p-8">
<h2 className="mb-2 text-xl font-normal md:text-2xl lg:text-3xl">
تماس با ما
</h2>
<p className="mb-8 text-sm text-white/60">
ما همیشه دنبال دوستای جدید هستیم — یا ما خدمتتون تماس بگیریم.
</p>
+18
View File
@@ -0,0 +1,18 @@
import { Header } from "@/components/Header";
import { Footer } from "@/components/Footer";
import { RevealObserver } from "@/components/RevealObserver";
type ContentPageShellProps = {
children: React.ReactNode;
};
export function ContentPageShell({ children }: ContentPageShellProps) {
return (
<>
<RevealObserver />
<Header />
<main className="pt-36 lg:pt-44">{children}</main>
<Footer />
</>
);
}
+4 -4
View File
@@ -10,21 +10,21 @@ export function FramedImage({
offset?: boolean;
}) {
return (
<div className="relative mx-auto w-full">
<div className="relative mx-auto w-full overflow-hidden">
{offset ? (
<div
className="absolute -bottom-3 -left-3 h-[calc(100%-12px)] w-[calc(100%-12px)] rounded-2xl bg-[#EBEDF0]"
className="absolute bottom-0 left-0 h-[calc(100%-12px)] w-[calc(100%-12px)] rounded-2xl bg-[#EBEDF0]"
aria-hidden
/>
) : null}
<img
src={src}
alt={alt}
className={`relative w-full rounded-2xl ${
className={`relative w-full max-w-full rounded-2xl ${
aspect === "square"
? "aspect-square object-cover"
: "object-contain"
}`}
} ${offset ? "mb-3 ms-3" : ""}`}
/>
</div>
);
+28
View File
@@ -66,6 +66,34 @@ export const cmsBrands = [
},
];
export const cmsWebsiteBrands = [
{
nameFa: "قنادی بلوط",
nameEn: "SHIRINIBALOUT.COM",
logo: `${CDN}/files/kp7mWiDY7m.png`,
},
{
nameFa: "طلا و جواهرات ایوبی فخیم",
nameEn: "AYOUBIFAKHIM.COM",
logo: `${CDN}/img/brands/2.png`,
},
{
nameFa: "وبسایت ژی بیوتی",
nameEn: "JICOSMETIC.COM",
logo: `${CDN}/files/LsGKZlEgBH.png`,
},
{
nameFa: "وبسایت محمدی ثابت",
nameEn: "SABETWOORD.COM",
logo: `${CDN}/files/TUY2u0GjZ2.png`,
},
{
nameFa: "وبسایت لیوتل",
nameEn: "LIOTELL",
logo: `${CDN}/files/7mB2MENL8w.png`,
},
];
interface BrandItem {
nameFa: string;
nameEn: string;
+3 -3
View File
@@ -5,7 +5,7 @@ export function Hero() {
return (
<section className="relative overflow-hidden bg-white">
<div className="mx-auto grid max-w-7xl items-center gap-8 px-5 pb-12 pt-32 lg:grid-cols-2 lg:gap-10 lg:px-8 lg:pb-16 lg:pt-36">
<div className="order-2 max-w-xl lg:order-1">
<div className="order-2 min-w-0 max-w-xl lg:order-1">
<p
className="mb-5 text-sm text-muted"
style={{ animation: "rise-in 0.9s ease both" }}
@@ -14,7 +14,7 @@ export function Hero() {
</p>
<h1
className="mb-4 text-[clamp(1.15rem,3.2vw,2rem)] font-normal tracking-tight text-ink"
className="mb-4 max-w-[16em] text-[clamp(1.15rem,3.2vw,2rem)] font-normal leading-snug tracking-tight text-ink"
style={{ animation: "rise-in 1s ease both 0.08s" }}
>
{site.fullTitle}
@@ -50,7 +50,7 @@ export function Hero() {
</div>
<div
className="order-1 relative lg:order-2"
className="order-1 relative mx-auto w-full min-w-0 max-w-sm overflow-hidden sm:max-w-md lg:order-2 lg:max-w-full"
style={{ animation: "fade-in 1.2s ease both 0.2s" }}
>
<div className="pointer-events-none absolute inset-8 rounded-full bg-[radial-gradient(circle,var(--glow),transparent_70%)] blur-2xl" />
+13
View File
@@ -0,0 +1,13 @@
type HtmlContentProps = {
html: string;
className?: string;
};
export function HtmlContent({ html, className = "" }: HtmlContentProps) {
return (
<div
className={`prose-content ${className}`.trim()}
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
+1 -1
View File
@@ -5,7 +5,7 @@ export function MediaSection() {
<section id="media" className="bg-surface py-24 lg:py-32">
<div className="mx-auto max-w-7xl px-5 lg:px-8">
<div className="reveal mb-14 max-w-2xl">
<h2 className="text-[clamp(1.15rem,3.2vw,2.5rem)] font-normal tracking-tight text-ink">
<h2 className="max-w-[16em] text-[clamp(1.15rem,3.2vw,2.5rem)] font-normal leading-snug tracking-tight text-ink">
گرافیک و رسانه
</h2>
<p className="latin mt-3 text-xs uppercase tracking-[0.2em] text-muted">
+127
View File
@@ -0,0 +1,127 @@
"use client";
import Link from "next/link";
type PaginationProps = {
page: number;
pageSize: number;
total: number;
basePath: string;
};
function pageHref(basePath: string, page: number) {
if (page <= 1) return basePath;
return `${basePath}?page=${page}`;
}
export function Pagination({
page,
pageSize,
total,
basePath,
}: PaginationProps) {
const totalPages = Math.max(1, Math.ceil(total / pageSize));
if (totalPages <= 1) return null;
const windowSize = 5;
let start = Math.max(1, page - Math.floor(windowSize / 2));
const end = Math.min(totalPages, start + windowSize - 1);
start = Math.max(1, end - windowSize + 1);
const pages = Array.from({ length: end - start + 1 }, (_, i) => start + i);
const scrollTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
return (
<nav
className="mt-14 flex flex-wrap items-center justify-center gap-2"
aria-label="صفحه‌بندی"
>
{page > 1 ? (
<Link
href={pageHref(basePath, page - 1)}
onClick={scrollTop}
scroll
className="rounded-full border border-line/70 px-4 py-2 text-sm text-ink-soft transition hover:border-ink/30 hover:text-ink"
>
قبلی
</Link>
) : (
<span className="rounded-full border border-line/40 px-4 py-2 text-sm text-muted/40">
قبلی
</span>
)}
{start > 1 ? (
<>
<Link
href={pageHref(basePath, 1)}
onClick={scrollTop}
scroll
className="rounded-full border border-line/70 px-3.5 py-2 text-sm text-ink-soft transition hover:border-ink/30 hover:text-ink"
>
۱
</Link>
{start > 2 ? (
<span className="px-1 text-muted" aria-hidden>
…
</span>
) : null}
</>
) : null}
{pages.map((n) => (
<Link
key={n}
href={pageHref(basePath, n)}
onClick={scrollTop}
scroll
aria-current={n === page ? "page" : undefined}
className={`rounded-full border px-3.5 py-2 text-sm transition ${
n === page
? "border-ink bg-ink text-surface"
: "border-line/70 text-ink-soft hover:border-ink/30 hover:text-ink"
}`}
>
{n.toLocaleString("fa-IR")}
</Link>
))}
{end < totalPages ? (
<>
{end < totalPages - 1 ? (
<span className="px-1 text-muted" aria-hidden>
…
</span>
) : null}
<Link
href={pageHref(basePath, totalPages)}
onClick={scrollTop}
scroll
className="rounded-full border border-line/70 px-3.5 py-2 text-sm text-ink-soft transition hover:border-ink/30 hover:text-ink"
>
{totalPages.toLocaleString("fa-IR")}
</Link>
</>
) : null}
{page < totalPages ? (
<Link
href={pageHref(basePath, page + 1)}
onClick={scrollTop}
scroll
className="rounded-full border border-line/70 px-4 py-2 text-sm text-ink-soft transition hover:border-ink/30 hover:text-ink"
>
بعدی
</Link>
) : (
<span className="rounded-full border border-line/40 px-4 py-2 text-sm text-muted/40">
بعدی
</span>
)}
</nav>
);
}
+5 -4
View File
@@ -1,3 +1,4 @@
import Link from "next/link";
import { portfolio } from "@/data/home";
const loopItems = [...portfolio, ...portfolio];
@@ -7,7 +8,7 @@ export function Portfolio() {
<section id="portfolio" className="relative overflow-hidden bg-white py-24 lg:py-32">
<div className="mx-auto max-w-7xl px-5 lg:px-8">
<div className="reveal mb-10 max-w-2xl md:mb-14">
<h2 className="text-[clamp(1.15rem,3.2vw,2.5rem)] font-normal tracking-tight text-ink">
<h2 className="max-w-[16em] text-[clamp(1.15rem,3.2vw,2.5rem)] font-normal leading-snug tracking-tight text-ink">
نمونه‌هایی از وبسایت‌های طراحی‌شده
</h2>
<p className="latin mt-3 text-xs uppercase tracking-[0.2em] text-muted">
@@ -46,9 +47,9 @@ export function Portfolio() {
>
دوست دارم بیشتر بدونم
</a>
<a href="#contact" className="link-hover text-sm text-muted">
تعرفه‌های طراحی
</a>
<Link href="/portfolio" className="link-hover text-sm text-muted">
مشاهده همه نمونه‌کارها
</Link>
</div>
</section>
);
+17 -4
View File
@@ -1,10 +1,15 @@
"use client";
import { useEffect } from "react";
import { Suspense, useEffect } from "react";
import { usePathname, useSearchParams } from "next/navigation";
function RevealObserverInner() {
const pathname = usePathname();
const searchParams = useSearchParams();
const search = searchParams.toString();
export function RevealObserver() {
useEffect(() => {
const elements = document.querySelectorAll(".reveal");
const elements = document.querySelectorAll(".reveal:not(.is-visible)");
if (!elements.length) return;
const observer = new IntersectionObserver(
@@ -21,7 +26,15 @@ export function RevealObserver() {
elements.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
}, [pathname, search]);
return null;
}
export function RevealObserver() {
return (
<Suspense fallback={null}>
<RevealObserverInner />
</Suspense>
);
}
+59 -4
View File
@@ -1,7 +1,7 @@
const CDN =
"https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets/files";
const cities = [
export const seoCities = [
{
nameFa: "تهران",
nameEn: "tehran",
@@ -34,16 +34,71 @@ const cities = [
},
];
export function SeoCities() {
export const cmsWebsiteCities = [
{
nameFa: "تهران",
nameEn: "tehran",
image: `${CDN}/Tjl5nlXtvN.webp`,
cols: "lg:col-span-8",
},
{
nameFa: "قم",
nameEn: "qom",
image: `${CDN}/5j3to7r5sl.webp`,
cols: "lg:col-span-4",
},
{
nameFa: "مشهد",
nameEn: "mashhad",
image: `${CDN}/OzqPZwHJ0J.jpg`,
cols: "lg:col-span-4",
},
{
nameFa: "بوشهر",
nameEn: "booshehr",
image: `${CDN}/jaxfQEDUQQ.jpg`,
cols: "lg:col-span-4",
},
{
nameFa: "ساری",
nameEn: "sari",
image: `${CDN}/RcsC6n31hO.webp`,
cols: "lg:col-span-4",
},
];
interface CityItem {
nameFa: string;
nameEn: string;
image: string;
cols: string;
}
interface SeoCitiesProps {
title?: string;
titleEn?: string;
eyebrow?: string;
cities?: CityItem[];
}
export function SeoCities({
title = "مشتریان ما از تمام ایران می‌باشند",
titleEn = "COOPERATION",
eyebrow,
cities = seoCities,
}: SeoCitiesProps) {
return (
<section className="relative border-t border-line/30 bg-[#FAFAFA] py-16 lg:py-24">
<div className="mx-auto max-w-7xl px-5 lg:px-8">
<div className="reveal mb-10 min-w-0">
{eyebrow ? (
<p className="mb-3 text-sm text-muted">{eyebrow}</p>
) : null}
<h2 className="text-[clamp(1.05rem,3vw,2rem)] font-normal tracking-tight text-ink">
مشتریان ما از تمام ایران می‌باشند
{title}
</h2>
<p className="latin mt-3 text-[11px] uppercase tracking-widest text-muted">
COOPERATION
{titleEn}
</p>
</div>
+55
View File
@@ -0,0 +1,55 @@
"use client";
interface ServiceContactStripProps {
phone?: string;
phoneHref?: string;
}
export function ServiceContactStrip({
phone = "0902 4200 240",
phoneHref = "tel:+989024200240",
}: ServiceContactStripProps) {
return (
<section className="border-t border-line/30 bg-[#F7F8F9] py-10 lg:py-12">
<div className="mx-auto grid max-w-7xl gap-10 px-5 sm:grid-cols-2 sm:items-center sm:gap-10 lg:px-8">
<div className="reveal flex min-w-0 flex-col items-center text-center">
<p className="mb-3 max-w-[14ch] text-sm leading-7 text-muted">
شما تماس بگیرید …
</p>
<a
href={phoneHref}
className="latin text-[clamp(1.35rem,5vw,2.15rem)] font-normal tracking-tight text-ink transition hover:opacity-70"
dir="ltr"
>
{phone}
</a>
</div>
<div className="reveal reveal-delay-1 flex min-w-0 flex-col items-center text-center">
<p className="mb-3 text-sm text-muted">
یا ما خدمتتون تماس بگیریم 🙂
</p>
<form
className="flex w-full max-w-md flex-col gap-2.5 sm:flex-row"
onSubmit={(e) => e.preventDefault()}
>
<input
type="tel"
name="phone"
placeholder="شماره همراه"
className="min-w-0 w-full flex-1 rounded-xl border border-line/70 bg-white px-4 py-3.5 text-sm text-ink outline-none transition placeholder:text-muted focus:border-ink/30"
dir="ltr"
/>
<button
type="submit"
className="inline-flex w-full shrink-0 items-center justify-center gap-2 rounded-xl bg-[#E8EAED] px-5 py-3.5 text-sm text-ink-soft transition hover:bg-[#DDE0E4] sm:w-auto"
>
منتظر تماس هستم
<span aria-hidden>↓</span>
</button>
</form>
</div>
</div>
</section>
);
}
+63 -29
View File
@@ -21,6 +21,8 @@ interface Section {
imageFrame?: boolean;
eyebrow?: string;
headline?: string;
ctaLabel?: string;
ctaHref?: string;
features?: Feature[];
featureLayout?: "horizontal" | "team";
featureColumns?: 2 | 3;
@@ -42,26 +44,26 @@ interface ServicePageProps {
function SolutionFeatureCard({ feature }: { feature: Feature }) {
return (
<div className="group group-border-hover flex h-full gap-3 rounded-2xl border border-line/70 bg-white/90 p-6 transition hover:shadow-[0_16px_40px_rgba(14,17,22,0.06)] md:gap-4 md:p-7">
<div className="group group-border-hover flex h-full gap-2.5 rounded-xl border border-line/70 bg-white/90 p-3.5 transition hover:shadow-[0_10px_28px_rgba(14,17,22,0.05)] md:gap-3 md:p-4">
{feature.icon ? (
<img
src={feature.icon}
alt=""
width={48}
height={48}
className="h-12 w-12 shrink-0 object-contain"
width={36}
height={36}
className="mt-0.5 h-9 w-9 shrink-0 object-contain"
/>
) : null}
<div className="min-w-0 flex-1">
<h3 className="whitespace-nowrap text-[0.95rem] font-normal text-ink md:text-base">
<h3 className="break-words text-[0.85rem] font-normal leading-snug text-ink md:text-[0.9rem]">
{feature.title}
</h3>
{feature.titleEn && (
<p className="latin mt-0.5 text-[10px] uppercase tracking-wider text-muted">
<p className="latin mt-0.5 text-[9px] uppercase tracking-wider text-muted">
{feature.titleEn}
</p>
)}
<p className="text-body mt-3 w-full text-sm leading-8 text-ink-soft">
<p className="text-body mt-1.5 w-full text-[13px] leading-6 text-ink-soft">
{feature.description}
</p>
</div>
@@ -137,24 +139,24 @@ export function ServicePageLayout({
<RevealObserver />
<Header />
<section className="relative overflow-hidden bg-white pt-40 pb-16 lg:pt-48 lg:pb-20">
<section className="relative overflow-hidden bg-white pt-36 pb-12 lg:pt-48 lg:pb-20">
<div className="mx-auto max-w-7xl px-5 lg:px-8">
<div className="grid gap-10 lg:grid-cols-2 lg:items-center lg:gap-16">
<div className="min-w-0 max-w-xl">
<h1
className="mb-3 text-[clamp(1.15rem,3.6vw,2.75rem)] font-normal tracking-tight text-ink"
className="mb-3 max-w-[16em] text-[clamp(1.1rem,2.8vw,1.85rem)] font-normal leading-snug tracking-tight text-ink"
style={{ animation: "rise-in 0.9s ease both 0.06s" }}
>
{title}
</h1>
<p
className="latin mb-8 text-xs uppercase tracking-widest text-muted"
className="latin mb-6 text-[11px] uppercase tracking-widest text-muted"
style={{ animation: "rise-in 0.9s ease both 0.12s" }}
>
{titleEn}
</p>
<p
className="text-body mb-8 text-sm leading-8 text-ink-soft md:text-base"
className="text-body mb-8 max-w-prose text-sm leading-7 text-ink-soft md:text-[0.95rem] md:leading-7"
style={{ animation: "rise-in 1s ease both 0.18s" }}
>
{lead}
@@ -171,7 +173,7 @@ export function ServicePageLayout({
{heroImage && (
<div
className="relative hidden lg:block"
className="relative mx-auto w-full min-w-0 max-w-sm overflow-hidden sm:max-w-md lg:max-w-full"
style={{ animation: "fade-in 1.2s ease both 0.2s" }}
>
<div className="pointer-events-none absolute inset-8 rounded-full bg-[radial-gradient(circle,var(--glow),transparent_70%)] blur-2xl" />
@@ -188,74 +190,106 @@ export function ServicePageLayout({
</div>
</section>
{sectionInjections
.filter((item) => item.after < 0)
.map((item, injectionIndex) => (
<div key={`inject-before-${injectionIndex}`}>{item.node}</div>
))}
{sections.map((sec, i) => (
<div key={i}>
<section
className={`relative border-t border-line/30 py-16 lg:py-24 ${
className={`relative overflow-hidden border-t border-line/30 py-12 lg:py-24 ${
i % 2 === 0 ? "bg-[#FAFAFA]" : "bg-white"
}`}
>
<div className="mx-auto max-w-7xl px-5 lg:px-8">
{sec.image ? (
<div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
<div className="reveal order-2 min-w-0 lg:order-1">
<div className="grid items-center gap-8 lg:grid-cols-2 lg:gap-12 xl:gap-16">
<div className="reveal order-2 min-w-0 max-w-full lg:order-1">
{sec.eyebrow ? (
<p className="mb-4 text-sm text-muted">{sec.eyebrow}</p>
<p className="mb-3 text-sm text-muted">{sec.eyebrow}</p>
) : null}
<h2 className="text-[clamp(1.05rem,3vw,2rem)] font-normal tracking-tight text-ink">
<h2 className="max-w-[18em] text-[clamp(1rem,2.2vw,1.45rem)] font-normal leading-snug tracking-tight text-ink">
{sec.title}
</h2>
{sec.titleEn && (
<p className="latin mt-3 text-[11px] uppercase tracking-widest text-muted">
<p className="latin mt-2.5 text-[10px] uppercase tracking-widest text-muted">
{sec.titleEn}
</p>
)}
{sec.headline ? (
<h2 className="mt-8 text-[clamp(1rem,2.5vw,1.65rem)] font-normal tracking-tight text-ink">
<h3 className="mt-6 max-w-[20em] text-[clamp(0.95rem,1.8vw,1.2rem)] font-normal leading-snug tracking-tight text-ink">
{sec.headline}
</h2>
</h3>
) : null}
<p className="text-body mt-5 text-sm leading-8 text-ink-soft md:text-base">
<p className="text-body mt-5 max-w-prose text-sm leading-7 text-ink-soft md:text-[0.95rem] md:leading-7">
{sec.body}
</p>
{sec.ctaLabel ? (
<a
href={sec.ctaHref ?? site.phoneHref}
className="mt-8 inline-flex items-center gap-2 rounded-xl bg-[#F0F1F3] px-6 py-3.5 text-sm text-ink-soft transition hover:bg-[#E6E8EB]"
>
{sec.ctaLabel}
<span aria-hidden>↓</span>
</a>
) : null}
</div>
<div className="reveal reveal-delay-1 order-1 relative mx-auto w-full lg:order-2">
<div className="reveal reveal-delay-1 order-1 relative w-full min-w-0 max-w-full overflow-hidden lg:order-2">
{sec.imageFrame ? (
<FramedImage src={sec.image} alt={sec.title} aspect="natural" />
) : (
<img
src={sec.image}
alt={sec.title}
className="mx-auto w-full max-w-xl object-contain lg:max-w-none"
className="mx-auto h-auto w-full max-w-full object-contain"
/>
)}
</div>
</div>
) : (
<div className="reveal mb-10 min-w-0">
<h2 className="text-[clamp(1.05rem,3vw,2rem)] font-normal tracking-tight text-ink">
<div className="reveal mb-10 min-w-0 max-w-full">
{sec.eyebrow ? (
<p className="mb-3 text-sm text-muted">{sec.eyebrow}</p>
) : null}
<h2 className="max-w-[18em] text-[clamp(1rem,2.2vw,1.45rem)] font-normal leading-snug tracking-tight text-ink">
{sec.title}
</h2>
{sec.titleEn && (
<p className="latin mt-3 text-[11px] uppercase tracking-widest text-muted">
<p className="latin mt-2.5 text-[10px] uppercase tracking-widest text-muted">
{sec.titleEn}
</p>
)}
<p className="text-body mt-5 w-full text-sm leading-8 text-ink-soft md:text-base">
{sec.headline ? (
<h3 className="mt-6 max-w-[20em] text-[clamp(0.95rem,1.8vw,1.2rem)] font-normal leading-snug tracking-tight text-ink">
{sec.headline}
</h3>
) : null}
<p className="text-body mt-5 max-w-prose text-sm leading-7 text-ink-soft md:text-[0.95rem] md:leading-7">
{sec.body}
</p>
{sec.ctaLabel ? (
<a
href={sec.ctaHref ?? site.phoneHref}
className="mt-8 inline-flex items-center gap-2 rounded-xl bg-[#F0F1F3] px-6 py-3.5 text-sm text-ink-soft transition hover:bg-[#E6E8EB]"
>
{sec.ctaLabel}
<span aria-hidden>↓</span>
</a>
) : null}
</div>
)}
{sec.features && sec.features.length > 0 && (
<ul
className={`grid gap-4 lg:gap-5 ${
className={`grid gap-2.5 lg:gap-3 ${
sec.featureColumns === 2
? "sm:grid-cols-2"
: sec.featureLayout === "team"
? "sm:grid-cols-2"
: "sm:grid-cols-2 lg:grid-cols-3"
} ${sec.image ? "mt-12" : ""}`}
} ${sec.image ? "mt-10" : "mt-6"}`}
>
{sec.features.map((f, fi) => (
<li
+2 -2
View File
@@ -9,7 +9,7 @@ export function Services() {
>
<div className="relative mx-auto max-w-7xl px-5 lg:px-8">
<div className="reveal mb-10 min-w-0">
<h2 className="text-[clamp(1.15rem,3.2vw,2.5rem)] font-normal tracking-tight text-ink">
<h2 className="max-w-[16em] text-[clamp(1.15rem,3.2vw,2.5rem)] font-normal leading-snug tracking-tight text-ink">
خدمات ما
</h2>
<p className="latin mt-3 text-xs uppercase tracking-[0.2em] text-muted">
@@ -36,7 +36,7 @@ export function Services() {
className="h-12 w-12 shrink-0 object-contain"
/>
<div className="min-w-0">
<h3 className="whitespace-nowrap text-[0.95rem] font-normal text-ink md:text-base">
<h3 className="text-[0.9rem] font-normal leading-snug text-ink md:text-[0.95rem]">
{service.title}
</h3>
<p className="latin mt-0.5 text-[10px] uppercase tracking-wider text-muted">
+32 -4
View File
@@ -10,6 +10,24 @@ export const websiteProjects = [
{ src: `${CDN}/ifnT6QdNsr.webp` },
];
export const cmsWebsiteProjects = [
{ src: `${CDN}/ZnpmKW4yBT.png` },
{ src: `${CDN}/rKfAyIS6EX.webp` },
{ src: `${CDN}/LNwzp6gYkX.webp` },
{ src: `${CDN}/2zPgG5sBHm.webp` },
{ src: `${CDN}/z1uI4VxQyW.jpg` },
{ src: `${CDN}/zgQpCfUAdh.jpg` },
{ src: `${CDN}/75b5YGp21c.jpg` },
];
export const websiteQomProjects = [
{ src: `${CDN}/iAWixn0pW3.jpg` },
{ src: `${CDN}/3nrFfkfSdH.webp` },
{ src: `${CDN}/wqUE5gsbR2.webp` },
{ src: `${CDN}/xWhNkbheMX.webp` },
{ src: `${CDN}/b9RQ4ivVr7.webp` },
];
export const applicationProjects = [
{ src: `${CDN}/vVBied8Og6.webp` },
{ src: `${CDN}/pXvH3hqSdS.jpg` },
@@ -56,6 +74,7 @@ interface ProjectsShowcaseProps {
projects?: ProjectItem[];
moreHref?: string;
variant?: "grid" | "carousel";
framed?: boolean;
}
export function ProjectsShowcase({
@@ -65,6 +84,7 @@ export function ProjectsShowcase({
projects = websiteProjects,
moreHref = "/#portfolio",
variant = "grid",
framed = true,
}: ProjectsShowcaseProps) {
const loopProjects = [...projects, ...projects];
@@ -76,10 +96,10 @@ export function ProjectsShowcase({
{subtitle ? (
<p className="mb-3 text-sm text-muted">{subtitle}</p>
) : null}
<h2 className="text-[clamp(0.95rem,2.8vw,1.85rem)] font-normal tracking-tight text-ink">
<h2 className="max-w-[18em] text-[clamp(0.95rem,2.2vw,1.45rem)] font-normal leading-snug tracking-tight text-ink">
{title}
</h2>
<p className="latin mt-3 text-[11px] uppercase tracking-widest text-muted">
<p className="latin mt-2.5 text-[10px] uppercase tracking-widest text-muted">
{titleEn}
</p>
</div>
@@ -96,13 +116,21 @@ export function ProjectsShowcase({
{variant === "carousel" ? (
<div className="carousel-mask overflow-hidden" dir="ltr">
<div className="carousel-track flex w-max gap-5 px-5 lg:px-[max(1.25rem,calc((100vw-80rem)/2+2rem))]">
<div className="carousel-track flex w-max gap-4 px-5 lg:px-[max(1.25rem,calc((100vw-80rem)/2+2rem))]">
{loopProjects.map((project, index) => (
<figure
key={`${project.src}-${index}`}
className="w-[min(72vw,280px)] shrink-0 sm:w-[260px] lg:w-[300px]"
>
<FramedImage src={project.src} offset />
{framed ? (
<FramedImage src={project.src} offset />
) : (
<img
src={project.src}
alt=""
className="aspect-[3/5] w-full rounded-2xl object-cover object-top"
/>
)}
</figure>
))}
</div>
+2 -8
View File
@@ -18,6 +18,8 @@ export const images = {
},
pages: {
website: `${CDN}/files/Rp3QX3m8FE.webp`,
cmsWebsite: `${CDN}/files/6T7rUEjyqu.png`,
websiteQom: `${CDN}/files/D9hcX5CHuV.png`,
app: `${CDN}/files/tdRtWz7Q2l.webp`,
cms: `${CDN}/files/SwYRnHKWPd.png`,
seo: `${CDN}/files/JXLo625dyC.webp`,
@@ -42,12 +44,4 @@ export const images = {
`${CDN}/img/portfolios/12.jpg`,
`${CDN}/img/portfolios/13.jpg`,
],
blog: [
`${CDN}/files/3xXv8TLPsX.jpg`,
`${CDN}/files/gyG38oiy8O.webp`,
`${CDN}/files/fvJfINwpLk.webp`,
`${CDN}/files/AV8xufJxJG.webp`,
`${CDN}/files/TMSkameY2t.webp`,
`${CDN}/files/LIWk9CwaR5.webp`,
],
};
+4 -43
View File
@@ -13,13 +13,13 @@ export const site = {
};
export const nav = [
{ label: "وبسایت", href: "/website" },
{ label: "وبسایت", href: "/cms-website" },
{ label: "سئو", href: "/seo" },
{ label: "اپلیکیشن", href: "/dedicated-application" },
{ label: "سیستم‌های مدیریتی", href: "/meshkee-cms" },
{ label: "گرافیک و رسانه", href: "/graphic-designer" },
{ label: "نمونه‌کارها", href: "/#portfolio" },
{ label: "مقالات", href: "/#blog" },
{ label: "نمونه‌کارها", href: "/portfolio" },
{ label: "مقالات", href: "/blog" },
];
export const services = [
@@ -29,7 +29,7 @@ export const services = [
description:
"طراحی و توسعه‌ی انواع وبسایت‌های فروشگاهی، خبری، شرکتی، گالری، ورزشی و همینطور وبسایت با مقاصد خاص. این کار به‌صورت تیمی انجام می‌شود و برای هر یک از بخش‌های گرافیک، برنامه‌نویسی، سرور، سئو و … متخصص جداگانه‌ای روی وبسایت شما فعالیت خواهد کرد.",
icon: images.services.web,
href: "/website",
href: "/cms-website",
},
{
title: "طراحی و توسعه‌ی اپلیکیشن",
@@ -85,42 +85,3 @@ export const mediaItems = [
{ title: "عکاسی معماری — بیرونی", image: images.media[7] },
{ title: "طراحی لوگو", image: images.media[8] },
];
export const posts = [
{
title: "SaaS چیست و چرا شرکت‌های بزرگ به آن مهاجرت می‌کنند؟",
excerpt:
"SaaS چیست و چگونه مدل نرم‌افزارهای اشتراکی هزینه‌ها را کاهش و رشد کسب‌وکار را سریع‌تر می‌کند؟",
image: images.blog[0],
},
{
title: "طراحی پروتوتایپ تعاملی چیست و چرا فروش شما را متحول می‌کند؟",
excerpt:
"نقش طراحی پروتوتایپ تعاملی در کاهش ریسک و افزایش فروش را اینجا بررسی کرده‌ایم.",
image: images.blog[1],
},
{
title: "تست A/B چیست؟ راهنمای کامل افزایش نرخ تبدیل و بهبود UX",
excerpt:
"چگونه با تحلیل داده‌ها، تجربه کاربری و نرخ تبدیل سایت یا اپلیکیشن را بهبود دهیم؟",
image: images.blog[2],
},
{
title: "اشتباهات رایج در طراحی دکمه CTA",
excerpt:
"اشتباهات رایج در طراحی دکمه CTA را بشناسید و نرخ تبدیل سایت را بهبود دهید.",
image: images.blog[3],
},
{
title: "بودجه خزش (Crawl Budget)",
excerpt:
"با مدیریت هوشمندانه بودجه خزش، ایندکس سریع‌تر صفحات و بهبود رتبه سایت را تجربه کنید.",
image: images.blog[4],
},
{
title: "۴ مرحله طلایی قیف فروش دیجیتال",
excerpt:
"قیف فروش دیجیتال مسیر کاربران را از آگاهی تا مشتری وفادار نشان می‌دهد.",
image: images.blog[5],
},
];
+195
View File
@@ -0,0 +1,195 @@
const API_BASE_URL = "https://api.meshkee.com/api/v1";
export const SITE_DOMAIN = "meshkee.com";
export class ApiNotFoundError extends Error {
constructor(path: string) {
super(`Not found: ${path}`);
this.name = "ApiNotFoundError";
}
}
export type BlogAuthor = {
id: string;
firstName: string | null;
lastName: string | null;
email?: string | null;
};
export type Blog = {
id: string;
title: string;
slug: string;
abstract: string | null;
titleImageUrl: string | null;
mainTextHtml: string | null;
publishedAt: string | null;
type: string;
categoryName?: string | null;
author?: BlogAuthor | null;
};
export type PortfolioGalleryItem = {
mediaId: string;
url: string;
};
export type Portfolio = {
id: string;
title: string;
titleFa?: string | null;
titleEn?: string | null;
slug: string;
abstract: string | null;
titleImageUrl: string | null;
mainTextHtml: string | null;
publishedAt: string | null;
categoryName?: string | null;
gallery?: PortfolioGalleryItem[];
};
export type Paginated<T> = {
items: T[];
total: number;
page: number;
pageSize: number;
};
type GetBlogsOptions = {
page?: number;
pageSize?: number;
type?: "news" | "article" | "blog";
};
type GetPortfoliosOptions = {
page?: number;
pageSize?: number;
categoryId?: string;
title?: string;
};
type ApiFetchInit = RequestInit & {
next?: {
revalidate?: number | false;
tags?: string[];
};
};
async function apiFetch<T>(path: string, init?: ApiFetchInit): Promise<T> {
const { next, headers, ...rest } = init ?? {};
const response = await fetch(`${API_BASE_URL}${path}`, {
...rest,
headers: {
Accept: "application/json",
...headers,
},
next: {
revalidate: 300,
...next,
},
});
if (response.status === 404) {
throw new ApiNotFoundError(path);
}
if (!response.ok) {
throw new Error(`Meshkee API ${response.status}: ${path}`);
}
return response.json() as Promise<T>;
}
function toQuery(params: Record<string, string | number | undefined>) {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== "") {
search.set(key, String(value));
}
}
const query = search.toString();
return query ? `?${query}` : "";
}
export async function getBlogs(
options: GetBlogsOptions = {},
): Promise<Paginated<Blog>> {
return apiFetch<Paginated<Blog>>(
`/tenants/${SITE_DOMAIN}/blogs${toQuery({
page: options.page,
pageSize: options.pageSize,
type: options.type,
})}`,
);
}
export async function getBlog(slug: string): Promise<Blog> {
const data = await apiFetch<{ blog: Blog }>(
`/tenants/${SITE_DOMAIN}/blogs/${encodeURIComponent(slug)}`,
);
return data.blog;
}
export async function getPortfolios(
options: GetPortfoliosOptions = {},
): Promise<Paginated<Portfolio>> {
return apiFetch<Paginated<Portfolio>>(
`/tenants/${SITE_DOMAIN}/portfolios${toQuery({
page: options.page,
pageSize: options.pageSize,
categoryId: options.categoryId,
title: options.title,
})}`,
);
}
export async function getPortfolio(slug: string): Promise<Portfolio> {
const data = await apiFetch<{ portfolio: Portfolio }>(
`/tenants/${SITE_DOMAIN}/portfolios/${encodeURIComponent(slug)}`,
);
return data.portfolio;
}
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;
}
export function getPortfolioCoverImage(
portfolio: Pick<Portfolio, "titleImageUrl" | "mainTextHtml" | "gallery">,
): string | null {
if (portfolio.titleImageUrl) return portfolio.titleImageUrl;
if (portfolio.gallery?.[0]?.url) return portfolio.gallery[0].url;
const match = portfolio.mainTextHtml?.match(
/<img[^>]+src=["']([^"']+)["']/i,
);
return match?.[1] ?? null;
}
export function formatFaDate(value: string | null | undefined): string | null {
if (!value) return null;
try {
return new Intl.DateTimeFormat("fa-IR", {
year: "numeric",
month: "long",
day: "numeric",
}).format(new Date(value));
} catch {
return null;
}
}
export function authorName(author: BlogAuthor | null | undefined): string | null {
if (!author) return null;
const name = [author.firstName, author.lastName].filter(Boolean).join(" ");
return name || null;
}