Ship local assets, portfolio filters, favicon, and UI polish.
Host CDN images locally, add top-level portfolio category filtering with nav sublinks, refresh the Meshkee favicon, and tighten title/gallery/footer presentation across the site. Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -3,11 +3,6 @@ import type { NextConfig } from "next";
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{
|
|
||||||
protocol: "https",
|
|
||||||
hostname: "meshkee-templates.sas.amin.parminstorage.ir",
|
|
||||||
pathname: "/**",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "meshkee-storage.sas.amin.parminstorage.ir",
|
hostname: "meshkee-storage.sas.amin.parminstorage.ir",
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
../../src/assets/images
|
||||||
|
After Width: | Height: | Size: 660 B |
|
After Width: | Height: | Size: 644 B |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128" width="128" height="128">
|
||||||
|
<rect width="128" height="128" fill="#000000"/>
|
||||||
|
<g fill="none" stroke="#FFFFFF" stroke-width="8" stroke-linecap="round">
|
||||||
|
<!-- top: ring left + line right -->
|
||||||
|
<circle cx="36" cy="46" r="10"/>
|
||||||
|
<line x1="46" y1="46" x2="96" y2="46"/>
|
||||||
|
<!-- bottom: line left + ring right -->
|
||||||
|
<line x1="32" y1="82" x2="82" y2="82"/>
|
||||||
|
<circle cx="92" cy="82" r="10"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 512 B |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.9 KiB |
@@ -59,7 +59,7 @@ export default async function BlogDetailPage({ params }: BlogDetailPageProps) {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{blog.categoryName ? (
|
{blog.categoryName ? (
|
||||||
<p className="latin mb-3 text-[11px] uppercase tracking-widest text-muted">
|
<p className="latin title-en mb-3 text-muted">
|
||||||
{blog.categoryName}
|
{blog.categoryName}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { Pagination } from "@/components/Pagination";
|
|||||||
import { getBlogCoverImage, getBlogs } from "@/lib/api";
|
import { getBlogCoverImage, getBlogs } from "@/lib/api";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "مقالات | مشکی",
|
title: "مقالات",
|
||||||
description: "آخرین مقالات آموزشی و انگیزشی تیم مشکی.",
|
description: "آخرین مقالات آموزشی و انگیزشی تیم مشکی.",
|
||||||
|
alternates: { canonical: "/blog" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 12;
|
const PAGE_SIZE = 12;
|
||||||
@@ -39,7 +40,7 @@ export default async function BlogListPage({ searchParams }: BlogPageProps) {
|
|||||||
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal tracking-tight text-ink">
|
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal tracking-tight text-ink">
|
||||||
رشدنامه، آموزشی و انگیزشی
|
رشدنامه، آموزشی و انگیزشی
|
||||||
</h1>
|
</h1>
|
||||||
<p className="latin mt-3 text-xs uppercase tracking-[0.2em] text-muted">
|
<p className="latin title-en text-muted">
|
||||||
Our Blog
|
Our Blog
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-5 text-sm leading-8 text-muted md:text-base">
|
<p className="mt-5 text-sm leading-8 text-muted md:text-base">
|
||||||
|
|||||||
@@ -9,14 +9,23 @@ import {
|
|||||||
import { images } from "@/data/assets";
|
import { images } from "@/data/assets";
|
||||||
|
|
||||||
const CDN =
|
const CDN =
|
||||||
"https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets/files";
|
"/assets/images/files";
|
||||||
const ASSETS =
|
const ASSETS =
|
||||||
"https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets";
|
"/assets/images";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "وبسایت CMS | مشکی",
|
title: "طراحی وبسایت با Meshkee CMS",
|
||||||
description:
|
description:
|
||||||
"طراحی وبسایت روی سیستم مدیریت محتوای مشکی؛ ویرایش زنده، اتصال به پلتفرمها و کدنویسی اختصاصی.",
|
"طراحی وبسایت روی سیستم مدیریت محتوای مشکی؛ ویرایش زنده، اتصال به پلتفرمها و کدنویسی اختصاصی.",
|
||||||
|
alternates: { canonical: "/cms-website" },
|
||||||
|
openGraph: {
|
||||||
|
title: "طراحی وبسایت با Meshkee CMS",
|
||||||
|
description:
|
||||||
|
"طراحی وبسایت روی سیستم مدیریت محتوای مشکی؛ ویرایش زنده، اتصال به پلتفرمها و کدنویسی اختصاصی.",
|
||||||
|
url: "/cms-website",
|
||||||
|
type: "website",
|
||||||
|
locale: "fa_IR",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CmsWebsitePage() {
|
export default function CmsWebsitePage() {
|
||||||
|
|||||||
@@ -8,11 +8,12 @@ import {
|
|||||||
import { images } from "@/data/assets";
|
import { images } from "@/data/assets";
|
||||||
|
|
||||||
const CDN =
|
const CDN =
|
||||||
"https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets/files";
|
"/assets/images/files";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "طراحی اختصاصی اپلیکیشن | مشکی",
|
title: "طراحی اختصاصی اپلیکیشن",
|
||||||
description: "طراحی و توسعه اپلیکیشن موبایل iOS و Android توسط تیم اختصاصی مشکی.",
|
description: "طراحی و توسعه اپلیکیشن موبایل iOS و Android توسط تیم اختصاصی مشکی.",
|
||||||
|
alternates: { canonical: "/dedicated-application" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DedicatedApplicationPage() {
|
export default function DedicatedApplicationPage() {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 660 B |
@@ -85,6 +85,25 @@ h4 {
|
|||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* English subtitle under FA titles */
|
||||||
|
:is(h1, h2):has(+ .title-en) {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-en {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
letter-spacing: 0.28em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-en-sm {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
letter-spacing: 0.22em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
.text-body {
|
.text-body {
|
||||||
text-align: justify;
|
text-align: justify;
|
||||||
text-justify: inter-word;
|
text-justify: inter-word;
|
||||||
@@ -267,6 +286,8 @@ a.group-border-hover:hover {
|
|||||||
color: var(--ink-soft);
|
color: var(--ink-soft);
|
||||||
font-size: 0.975rem;
|
font-size: 0.975rem;
|
||||||
line-height: 2;
|
line-height: 2;
|
||||||
|
text-align: justify;
|
||||||
|
text-justify: inter-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.prose-content > *:first-child {
|
.prose-content > *:first-child {
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ import {
|
|||||||
import { images } from "@/data/assets";
|
import { images } from "@/data/assets";
|
||||||
|
|
||||||
const CDN =
|
const CDN =
|
||||||
"https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets/files";
|
"/assets/images/files";
|
||||||
const ASSETS =
|
const ASSETS =
|
||||||
"https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets";
|
"/assets/images";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "طراحی گرافیک و هویت بصری | مشکی",
|
title: "طراحی گرافیک و هویت بصری",
|
||||||
description: "طراحی لوگو، هویت بصری، کاتالوگ، بروشور و گرافیک شبکههای اجتماعی توسط تیم مشکی.",
|
description: "طراحی لوگو، هویت بصری، کاتالوگ، بروشور و گرافیک شبکههای اجتماعی توسط تیم مشکی.",
|
||||||
|
alternates: { canonical: "/graphic-designer" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function GraphicDesignerPage() {
|
export default function GraphicDesignerPage() {
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 2.7 KiB |
@@ -14,13 +14,75 @@ const montserrat = Montserrat({
|
|||||||
display: "swap",
|
display: "swap",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.meshkee.com";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
|
metadataBase: new URL(siteUrl),
|
||||||
|
title: {
|
||||||
|
default: "تیم برنامهنویسی و تجارت الکترونیک مشکی | Meshkee",
|
||||||
|
template: "%s | مشکی",
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستمهای مدیریتی، سئو و گرافیک توسط تیم مشکی.",
|
||||||
|
applicationName: "Meshkee",
|
||||||
|
authors: [{ name: "Meshkee", url: siteUrl }],
|
||||||
|
creator: "Meshkee",
|
||||||
|
publisher: "Meshkee",
|
||||||
|
keywords: [
|
||||||
|
"مشکی",
|
||||||
|
"Meshkee",
|
||||||
|
"طراحی وبسایت",
|
||||||
|
"طراحی اپلیکیشن",
|
||||||
|
"سئو",
|
||||||
|
"CMS",
|
||||||
|
"قم",
|
||||||
|
],
|
||||||
|
icons: {
|
||||||
|
icon: [
|
||||||
|
{ url: "/images/brand/meshkee-mark.svg", type: "image/svg+xml" },
|
||||||
|
{ url: "/favicon.ico", sizes: "any" },
|
||||||
|
{ url: "/images/brand/favicon.png", type: "image/png", sizes: "512x512" },
|
||||||
|
],
|
||||||
|
apple: [{ url: "/images/brand/favicon.png", type: "image/png", sizes: "512x512" }],
|
||||||
|
shortcut: ["/favicon.ico"],
|
||||||
|
},
|
||||||
|
openGraph: {
|
||||||
|
type: "website",
|
||||||
|
locale: "fa_IR",
|
||||||
|
url: siteUrl,
|
||||||
|
siteName: "Meshkee",
|
||||||
title: "تیم برنامهنویسی و تجارت الکترونیک مشکی | Meshkee",
|
title: "تیم برنامهنویسی و تجارت الکترونیک مشکی | Meshkee",
|
||||||
description:
|
description:
|
||||||
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستمهای مدیریتی، سئو و گرافیک توسط تیم مشکی.",
|
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستمهای مدیریتی، سئو و گرافیک توسط تیم مشکی.",
|
||||||
icons: {
|
images: [
|
||||||
icon: "/images/brand/mark-black.png",
|
{
|
||||||
apple: "/images/brand/mark-black.png",
|
url: "/images/brand/favicon.png",
|
||||||
|
width: 512,
|
||||||
|
height: 512,
|
||||||
|
alt: "لوگوی مشکی",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
twitter: {
|
||||||
|
card: "summary",
|
||||||
|
title: "تیم برنامهنویسی و تجارت الکترونیک مشکی | Meshkee",
|
||||||
|
description:
|
||||||
|
"راهکارهای مدرن برای تجارت شما — طراحی وبسایت، اپلیکیشن، سیستمهای مدیریتی، سئو و گرافیک توسط تیم مشکی.",
|
||||||
|
images: ["/images/brand/favicon.png"],
|
||||||
|
},
|
||||||
|
robots: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
googleBot: {
|
||||||
|
index: true,
|
||||||
|
follow: true,
|
||||||
|
"max-image-preview": "large",
|
||||||
|
"max-snippet": -1,
|
||||||
|
"max-video-preview": -1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
alternates: {
|
||||||
|
canonical: "/",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,15 @@ import { GraphicBrands, cmsBrands } from "@/components/GraphicBrands";
|
|||||||
import { images } from "@/data/assets";
|
import { images } from "@/data/assets";
|
||||||
|
|
||||||
const CDN =
|
const CDN =
|
||||||
"https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets/files";
|
"/assets/images/files";
|
||||||
const ASSETS =
|
const ASSETS =
|
||||||
"https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets";
|
"/assets/images";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "سیستم مدیریت یکپارچه مشکی | Meshkee CMS",
|
title: "سیستم مدیریت یکپارچه مشکی",
|
||||||
description:
|
description:
|
||||||
"سیستم مدیریت یکپارچه مشکی شامل فروشگاه، مالی، انبار، باشگاه مشتریان و محتوا.",
|
"سیستم مدیریت یکپارچه مشکی شامل فروشگاه، مالی، انبار، باشگاه مشتریان و محتوا.",
|
||||||
|
alternates: { canonical: "/meshkee-cms" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MeshkeeCmsPage() {
|
export default function MeshkeeCmsPage() {
|
||||||
|
|||||||
@@ -3,10 +3,20 @@ import Link from "next/link";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ContentPageShell } from "@/components/ContentPageShell";
|
import { ContentPageShell } from "@/components/ContentPageShell";
|
||||||
import { HtmlContent } from "@/components/HtmlContent";
|
import { HtmlContent } from "@/components/HtmlContent";
|
||||||
|
import { PortfolioGallery } from "@/components/PortfolioGallery";
|
||||||
|
import {
|
||||||
|
categoryLabelFa,
|
||||||
|
findPortfolioCategory,
|
||||||
|
getRootCategories,
|
||||||
|
portfolioCategoryHref,
|
||||||
|
topPortfolioCategories,
|
||||||
|
type PortfolioCategory,
|
||||||
|
} from "@/data/portfolioCategories";
|
||||||
import {
|
import {
|
||||||
ApiNotFoundError,
|
ApiNotFoundError,
|
||||||
formatFaDate,
|
formatFaDate,
|
||||||
getPortfolio,
|
getPortfolio,
|
||||||
|
getPortfolioCategories,
|
||||||
getPortfolioCoverImage,
|
getPortfolioCoverImage,
|
||||||
} from "@/lib/api";
|
} from "@/lib/api";
|
||||||
|
|
||||||
@@ -14,6 +24,36 @@ type PortfolioDetailPageProps = {
|
|||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function mapApiCategories(
|
||||||
|
items: Awaited<ReturnType<typeof getPortfolioCategories>>,
|
||||||
|
): PortfolioCategory[] {
|
||||||
|
return items.map((item) => ({
|
||||||
|
id: String(item.id),
|
||||||
|
slug: item.slug,
|
||||||
|
nameEn: item.name,
|
||||||
|
nameFa: item.nameFa || item.name,
|
||||||
|
parentId: item.parentId,
|
||||||
|
sortOrder: item.sortOrder ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findRootForCategory(
|
||||||
|
categoryId: string | number | null | undefined,
|
||||||
|
categories: PortfolioCategory[],
|
||||||
|
): PortfolioCategory | undefined {
|
||||||
|
if (categoryId == null) return undefined;
|
||||||
|
let current = categories.find((c) => c.id === String(categoryId));
|
||||||
|
if (!current) return undefined;
|
||||||
|
|
||||||
|
while (current?.parentId) {
|
||||||
|
const parent = categories.find((c) => c.id === String(current?.parentId));
|
||||||
|
if (!parent) break;
|
||||||
|
current = parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params,
|
params,
|
||||||
}: PortfolioDetailPageProps): Promise<Metadata> {
|
}: PortfolioDetailPageProps): Promise<Metadata> {
|
||||||
@@ -47,6 +87,18 @@ export default async function PortfolioDetailPage({
|
|||||||
const cover = getPortfolioCoverImage(portfolio);
|
const cover = getPortfolioCoverImage(portfolio);
|
||||||
const date = formatFaDate(portfolio.publishedAt);
|
const date = formatFaDate(portfolio.publishedAt);
|
||||||
const title = portfolio.titleFa || portfolio.title;
|
const title = portfolio.titleFa || portfolio.title;
|
||||||
|
|
||||||
|
let allCategories: PortfolioCategory[] = topPortfolioCategories;
|
||||||
|
try {
|
||||||
|
allCategories = mapApiCategories(await getPortfolioCategories());
|
||||||
|
} catch {
|
||||||
|
allCategories = topPortfolioCategories;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryFa = categoryLabelFa(portfolio.categoryName, allCategories);
|
||||||
|
const rootCategory =
|
||||||
|
findRootForCategory(portfolio.categoryId, allCategories) ??
|
||||||
|
findPortfolioCategory(portfolio.categoryName, getRootCategories(allCategories));
|
||||||
const gallery = (portfolio.gallery ?? []).filter(
|
const gallery = (portfolio.gallery ?? []).filter(
|
||||||
(item) => item.url !== cover,
|
(item) => item.url !== cover,
|
||||||
);
|
);
|
||||||
@@ -57,16 +109,18 @@ export default async function PortfolioDetailPage({
|
|||||||
<div className="mx-auto max-w-5xl px-5 lg:px-8">
|
<div className="mx-auto max-w-5xl px-5 lg:px-8">
|
||||||
<div className="reveal mb-10 max-w-3xl">
|
<div className="reveal mb-10 max-w-3xl">
|
||||||
<Link
|
<Link
|
||||||
href="/portfolio"
|
href={
|
||||||
|
rootCategory
|
||||||
|
? portfolioCategoryHref(rootCategory.slug)
|
||||||
|
: "/portfolio"
|
||||||
|
}
|
||||||
className="link-hover mb-6 inline-flex text-sm text-muted"
|
className="link-hover mb-6 inline-flex text-sm text-muted"
|
||||||
>
|
>
|
||||||
بازگشت به نمونهکارها
|
بازگشت به نمونهکارها
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{portfolio.categoryName ? (
|
{categoryFa ? (
|
||||||
<p className="latin mb-3 text-[11px] uppercase tracking-widest text-muted">
|
<p className="mb-3 text-[11px] text-muted">{categoryFa}</p>
|
||||||
{portfolio.categoryName}
|
|
||||||
</p>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<h1 className="whitespace-normal text-[clamp(1.35rem,3.8vw,2.5rem)] font-normal leading-tight tracking-tight text-ink">
|
<h1 className="whitespace-normal text-[clamp(1.35rem,3.8vw,2.5rem)] font-normal leading-tight tracking-tight text-ink">
|
||||||
@@ -74,13 +128,13 @@ export default async function PortfolioDetailPage({
|
|||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{portfolio.titleEn ? (
|
{portfolio.titleEn ? (
|
||||||
<p className="latin mt-3 text-xs uppercase tracking-widest text-muted">
|
<p className="latin title-en text-muted">
|
||||||
{portfolio.titleEn}
|
{portfolio.titleEn}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{portfolio.abstract ? (
|
{portfolio.abstract ? (
|
||||||
<p className="mt-5 text-sm leading-8 text-muted md:text-base">
|
<p className="mt-5 text-justify text-sm leading-8 text-muted md:text-base">
|
||||||
{portfolio.abstract}
|
{portfolio.abstract}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -95,32 +149,13 @@ export default async function PortfolioDetailPage({
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{cover ? (
|
<PortfolioGallery
|
||||||
<div className="reveal reveal-delay-1 mb-10 overflow-hidden rounded-2xl bg-paper-deep">
|
cover={cover}
|
||||||
<img
|
images={gallery.map((item) => ({
|
||||||
src={cover}
|
id: item.mediaId,
|
||||||
alt=""
|
url: item.url,
|
||||||
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 ? (
|
{portfolio.mainTextHtml ? (
|
||||||
<div className="reveal reveal-delay-2 mx-auto max-w-3xl">
|
<div className="reveal reveal-delay-2 mx-auto max-w-3xl">
|
||||||
|
|||||||
@@ -2,46 +2,104 @@ import type { Metadata } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ContentPageShell } from "@/components/ContentPageShell";
|
import { ContentPageShell } from "@/components/ContentPageShell";
|
||||||
import { Pagination } from "@/components/Pagination";
|
import { Pagination } from "@/components/Pagination";
|
||||||
import { getPortfolioCoverImage, getPortfolios } from "@/lib/api";
|
import {
|
||||||
|
categoryLabelFa,
|
||||||
|
findPortfolioCategory,
|
||||||
|
getDescendantCategoryIds,
|
||||||
|
getRootCategories,
|
||||||
|
portfolioCategoryHref,
|
||||||
|
topPortfolioCategories,
|
||||||
|
type PortfolioCategory,
|
||||||
|
} from "@/data/portfolioCategories";
|
||||||
|
import {
|
||||||
|
getPortfolioCategories,
|
||||||
|
getPortfolioCoverImage,
|
||||||
|
getPortfolios,
|
||||||
|
getPortfoliosByCategory,
|
||||||
|
} from "@/lib/api";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "نمونهکارها | مشکی",
|
title: "نمونهکارها",
|
||||||
description: "نمونه پروژهها و کارهای انجامشده توسط تیم مشکی.",
|
description: "نمونه پروژهها و کارهای انجامشده توسط تیم مشکی.",
|
||||||
|
alternates: { canonical: "/portfolio" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAGE_SIZE = 12;
|
const PAGE_SIZE = 12;
|
||||||
|
|
||||||
type PortfolioPageProps = {
|
type PortfolioPageProps = {
|
||||||
searchParams: Promise<{ page?: string }>;
|
searchParams: Promise<{ page?: string; category?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function mapApiCategories(
|
||||||
|
items: Awaited<ReturnType<typeof getPortfolioCategories>>,
|
||||||
|
): PortfolioCategory[] {
|
||||||
|
return items.map((item) => ({
|
||||||
|
id: String(item.id),
|
||||||
|
slug: item.slug,
|
||||||
|
nameEn: item.name,
|
||||||
|
nameFa: item.nameFa || item.name,
|
||||||
|
parentId: item.parentId,
|
||||||
|
sortOrder: item.sortOrder ?? 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
export default async function PortfolioListPage({
|
export default async function PortfolioListPage({
|
||||||
searchParams,
|
searchParams,
|
||||||
}: PortfolioPageProps) {
|
}: PortfolioPageProps) {
|
||||||
const { page: pageParam } = await searchParams;
|
const { page: pageParam, category: categoryParam } = await searchParams;
|
||||||
const page = Math.max(1, Number(pageParam) || 1);
|
const page = Math.max(1, Number(pageParam) || 1);
|
||||||
|
|
||||||
|
let allCategories: PortfolioCategory[] = topPortfolioCategories;
|
||||||
|
try {
|
||||||
|
allCategories = mapApiCategories(await getPortfolioCategories());
|
||||||
|
} catch {
|
||||||
|
allCategories = topPortfolioCategories;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootCategories = getRootCategories(allCategories);
|
||||||
|
const activeCategory =
|
||||||
|
findPortfolioCategory(categoryParam, allCategories) ??
|
||||||
|
findPortfolioCategory(categoryParam, rootCategories);
|
||||||
|
|
||||||
let items: Awaited<ReturnType<typeof getPortfolios>>["items"] = [];
|
let items: Awaited<ReturnType<typeof getPortfolios>>["items"] = [];
|
||||||
let total = 0;
|
let total = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (activeCategory) {
|
||||||
|
const data = await getPortfoliosByCategory({
|
||||||
|
categoryId: activeCategory.id,
|
||||||
|
categoryIds: getDescendantCategoryIds(allCategories, activeCategory.id),
|
||||||
|
page,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
});
|
||||||
|
items = data.items;
|
||||||
|
total = data.total;
|
||||||
|
} else {
|
||||||
const data = await getPortfolios({ page, pageSize: PAGE_SIZE });
|
const data = await getPortfolios({ page, pageSize: PAGE_SIZE });
|
||||||
items = data.items;
|
items = data.items;
|
||||||
total = data.total;
|
total = data.total;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
items = [];
|
items = [];
|
||||||
total = 0;
|
total = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const paginationQuery = activeCategory
|
||||||
|
? { category: activeCategory.slug }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const filterCategories =
|
||||||
|
rootCategories.length > 0 ? rootCategories : topPortfolioCategories;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentPageShell>
|
<ContentPageShell>
|
||||||
<section className="pb-20 lg:pb-28">
|
<section className="pb-20 lg:pb-28">
|
||||||
<div className="mx-auto max-w-7xl px-5 lg:px-8">
|
<div className="mx-auto max-w-7xl px-5 lg:px-8">
|
||||||
<div className="reveal mb-12 max-w-2xl md:mb-16">
|
<div className="reveal mb-10 max-w-2xl md:mb-12">
|
||||||
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal tracking-tight text-ink">
|
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal tracking-tight text-ink">
|
||||||
نمونهکارها
|
نمونهکارها
|
||||||
</h1>
|
</h1>
|
||||||
<p className="latin mt-3 text-xs uppercase tracking-[0.2em] text-muted">
|
<p className="latin title-en text-muted">
|
||||||
Portfolio
|
Portfolio
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-5 text-sm leading-8 text-muted md:text-base">
|
<p className="mt-5 text-sm leading-8 text-muted md:text-base">
|
||||||
@@ -49,19 +107,55 @@ export default async function PortfolioListPage({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="reveal mb-10 flex flex-wrap gap-2 md:mb-12">
|
||||||
|
<Link
|
||||||
|
href="/portfolio"
|
||||||
|
className={`rounded-full border px-4 py-2 text-sm transition ${
|
||||||
|
!activeCategory
|
||||||
|
? "border-ink bg-ink text-surface"
|
||||||
|
: "border-line/70 text-ink-soft hover:border-ink/30 hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
همه
|
||||||
|
</Link>
|
||||||
|
{filterCategories.map((category) => {
|
||||||
|
const active = activeCategory?.id === category.id;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={category.id}
|
||||||
|
href={portfolioCategoryHref(category.slug)}
|
||||||
|
className={`rounded-full border px-4 py-2 text-sm transition ${
|
||||||
|
active
|
||||||
|
? "border-ink bg-ink text-surface"
|
||||||
|
: "border-line/70 text-ink-soft hover:border-ink/30 hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{category.nameFa}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
نمونهکاری برای نمایش وجود ندارد.
|
نمونهکاری برای نمایش وجود ندارد.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div key={page} className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
<div
|
||||||
|
key={`${activeCategory?.slug ?? "all"}-${page}`}
|
||||||
|
className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3"
|
||||||
|
>
|
||||||
{items.map((item, index) => {
|
{items.map((item, index) => {
|
||||||
const image = getPortfolioCoverImage(item);
|
const image = getPortfolioCoverImage(item);
|
||||||
|
const categoryFa = categoryLabelFa(
|
||||||
|
item.categoryName,
|
||||||
|
allCategories,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
key={item.id}
|
key={item.id}
|
||||||
className={`reveal group ${
|
className={`reveal group overflow-hidden rounded-2xl border border-line/70 ${
|
||||||
index % 3 === 1
|
index % 3 === 1
|
||||||
? "reveal-delay-1"
|
? "reveal-delay-1"
|
||||||
: index % 3 === 2
|
: index % 3 === 2
|
||||||
@@ -70,7 +164,7 @@ export default async function PortfolioListPage({
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Link href={`/portfolio/${item.slug}`} className="block">
|
<Link href={`/portfolio/${item.slug}`} className="block">
|
||||||
<div className="relative mb-4 overflow-hidden rounded-2xl bg-paper-deep">
|
<div className="relative overflow-hidden bg-paper-deep">
|
||||||
{image ? (
|
{image ? (
|
||||||
<img
|
<img
|
||||||
src={image}
|
src={image}
|
||||||
@@ -79,19 +173,21 @@ export default async function PortfolioListPage({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{item.categoryName ? (
|
<div className="px-4 py-4">
|
||||||
<p className="latin mb-2 text-[10px] uppercase tracking-widest text-muted">
|
{categoryFa ? (
|
||||||
{item.categoryName}
|
<p className="mb-2 text-[11px] text-muted">
|
||||||
|
{categoryFa}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
<h2 className="group-text-hover mb-2 line-clamp-2 whitespace-normal text-lg font-normal leading-8 text-ink">
|
<h2 className="group-text-hover mb-0.5 line-clamp-2 whitespace-normal text-lg font-normal leading-7 text-ink">
|
||||||
{item.titleFa || item.title}
|
{item.titleFa || item.title}
|
||||||
</h2>
|
</h2>
|
||||||
{item.abstract ? (
|
{item.abstract ? (
|
||||||
<p className="line-clamp-3 text-sm leading-7 text-muted">
|
<p className="line-clamp-3 text-sm leading-6 text-muted">
|
||||||
{item.abstract}
|
{item.abstract}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</article>
|
</article>
|
||||||
);
|
);
|
||||||
@@ -104,6 +200,7 @@ export default async function PortfolioListPage({
|
|||||||
pageSize={PAGE_SIZE}
|
pageSize={PAGE_SIZE}
|
||||||
total={total}
|
total={total}
|
||||||
basePath="/portfolio"
|
basePath="/portfolio"
|
||||||
|
query={paginationQuery}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
export default function robots(): MetadataRoute.Robots {
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.meshkee.com";
|
||||||
|
|
||||||
|
return {
|
||||||
|
rules: {
|
||||||
|
userAgent: "*",
|
||||||
|
allow: "/",
|
||||||
|
},
|
||||||
|
sitemap: `${siteUrl}/sitemap.xml`,
|
||||||
|
host: siteUrl,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,11 +5,12 @@ import { ProjectsShowcase, seoProjects } from "@/components/WebsiteProjects";
|
|||||||
import { images } from "@/data/assets";
|
import { images } from "@/data/assets";
|
||||||
|
|
||||||
const CDN =
|
const CDN =
|
||||||
"https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets/files";
|
"/assets/images/files";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "سئو و مدیریت وبسایت | مشکی",
|
title: "سئو و مدیریت وبسایت",
|
||||||
description: "خدمات سئو حرفهای، بهینهسازی موتور جستجو و مدیریت وبسایت توسط تیم مشکی.",
|
description: "خدمات سئو حرفهای، بهینهسازی موتور جستجو و مدیریت وبسایت توسط تیم مشکی.",
|
||||||
|
alternates: { canonical: "/seo" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function SeoPage() {
|
export default function SeoPage() {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import type { MetadataRoute } from "next";
|
||||||
|
|
||||||
|
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.meshkee.com";
|
||||||
|
|
||||||
|
const routes = [
|
||||||
|
"/",
|
||||||
|
"/cms-website",
|
||||||
|
"/website-qom",
|
||||||
|
"/website",
|
||||||
|
"/seo",
|
||||||
|
"/dedicated-application",
|
||||||
|
"/meshkee-cms",
|
||||||
|
"/graphic-designer",
|
||||||
|
"/portfolio",
|
||||||
|
"/blog",
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function sitemap(): MetadataRoute.Sitemap {
|
||||||
|
const lastModified = new Date();
|
||||||
|
|
||||||
|
return routes.map((path) => ({
|
||||||
|
url: `${siteUrl}${path === "/" ? "" : path}`,
|
||||||
|
lastModified,
|
||||||
|
changeFrequency: path === "/" ? "weekly" : "monthly",
|
||||||
|
priority: path === "/" ? 1 : path === "/website-qom" || path === "/cms-website" ? 0.9 : 0.7,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -8,12 +8,28 @@ import {
|
|||||||
import { images } from "@/data/assets";
|
import { images } from "@/data/assets";
|
||||||
|
|
||||||
const CDN =
|
const CDN =
|
||||||
"https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets/files";
|
"/assets/images/files";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "طراحی سایت در قم توسط تیم حرفهای مشکی",
|
title: "طراحی سایت در قم توسط تیم حرفهای مشکی",
|
||||||
description:
|
description:
|
||||||
"طراحی وبسایت در قم توسط تیم مشکی با استفاده از جدیدترین متدهای کدنویسی و CMS اختصاصی با بهترین قیمت.",
|
"طراحی وبسایت در قم توسط تیم مشکی با استفاده از جدیدترین متدهای کدنویسی و CMS اختصاصی با بهترین قیمت.",
|
||||||
|
keywords: [
|
||||||
|
"طراحی سایت در قم",
|
||||||
|
"طراحی وبسایت قم",
|
||||||
|
"شرکت طراحی سایت قم",
|
||||||
|
"مشکی",
|
||||||
|
"Meshkee",
|
||||||
|
],
|
||||||
|
alternates: { canonical: "/website-qom" },
|
||||||
|
openGraph: {
|
||||||
|
title: "طراحی سایت در قم توسط تیم حرفهای مشکی",
|
||||||
|
description:
|
||||||
|
"طراحی وبسایت در قم توسط تیم مشکی با استفاده از جدیدترین متدهای کدنویسی و CMS اختصاصی با بهترین قیمت.",
|
||||||
|
url: "/website-qom",
|
||||||
|
type: "website",
|
||||||
|
locale: "fa_IR",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function WebsiteQomPage() {
|
export default function WebsiteQomPage() {
|
||||||
|
|||||||
@@ -5,13 +5,14 @@ import { WebsiteCountries } from "@/components/WebsiteCountries";
|
|||||||
import { images } from "@/data/assets";
|
import { images } from "@/data/assets";
|
||||||
|
|
||||||
const CDN =
|
const CDN =
|
||||||
"https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets/files";
|
"/assets/images/files";
|
||||||
const ICONS =
|
const ICONS =
|
||||||
"https://meshkee-templates.sas.amin.parminstorage.ir/sites/user_themes/meshkee_SEEpb1zypk/assets/img/icons";
|
"/assets/images/img/icons";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "طراحی اختصاصی وبسایت | مشکی",
|
title: "طراحی اختصاصی وبسایت",
|
||||||
description: "طراحی و توسعه وبسایت اختصاصی، فروشگاهی، شرکتی و خاص توسط تیم مشکی.",
|
description: "طراحی و توسعه وبسایت اختصاصی، فروشگاهی، شرکتی و خاص توسط تیم مشکی.",
|
||||||
|
alternates: { canonical: "/website" },
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function WebsitePage() {
|
export default function WebsitePage() {
|
||||||
|
|||||||
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 136 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 586 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 105 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 291 KiB |
|
After Width: | Height: | Size: 145 KiB |
|
After Width: | Height: | Size: 164 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 265 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 182 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 4.9 KiB |