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:
co-authored by
Cursor
parent
47bee1ce66
commit
112e5eb46d
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user