Add instruction list and detail pages with header category menu.

Fetch tutorials from the Meshkee API with category filtering, matching the portfolio pattern and exposing آموزش‌ها in the main navigation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-01 11:51:19 +03:30
co-authored by Cursor
parent c588b249cc
commit b0ba7b1de8
8 changed files with 660 additions and 1 deletions
+227
View File
@@ -0,0 +1,227 @@
import type { Metadata } from "next";
import Link from "next/link";
import { BlogCoverImage } from "@/components/BlogCoverImage";
import { ContentPageShell } from "@/components/ContentPageShell";
import { Pagination } from "@/components/Pagination";
import {
findInstructionCategory,
getDescendantInstructionCategoryIds,
getRootInstructionCategories,
instructionCategoryHref,
instructionCategoryLabelFa,
topInstructionCategories,
type InstructionCategory,
} from "@/data/instructionCategories";
import {
getInstructionCategories,
getInstructionCoverImage,
getInstructions,
getInstructionsByCategory,
} from "@/lib/api";
export const metadata: Metadata = {
title: "آموزش‌ها",
description: "راهنماها و آموزش‌های کاربردی تیم مشکی.",
alternates: { canonical: "/instruction" },
};
const PAGE_SIZE = 12;
type InstructionPageProps = {
searchParams: Promise<{ page?: string; category?: string }>;
};
function mapApiCategories(
items: Awaited<ReturnType<typeof getInstructionCategories>>,
): InstructionCategory[] {
return items.map((item) => ({
id: String(item.id),
slug: item.slug,
nameEn: item.name,
nameFa: item.nameFa || item.name,
parentId: item.parentId,
sortOrder: item.sortOrder ?? 0,
}));
}
export default async function InstructionListPage({
searchParams,
}: InstructionPageProps) {
const { page: pageParam, category: categoryParam } = await searchParams;
const page = Math.max(1, Number(pageParam) || 1);
let allCategories: InstructionCategory[] = topInstructionCategories;
try {
allCategories = mapApiCategories(await getInstructionCategories());
} catch {
allCategories = topInstructionCategories;
}
const rootCategories = getRootInstructionCategories(allCategories);
const activeCategory =
findInstructionCategory(categoryParam, allCategories) ??
findInstructionCategory(categoryParam, rootCategories);
let items: Awaited<ReturnType<typeof getInstructions>>["items"] = [];
let total = 0;
try {
if (activeCategory) {
const data = await getInstructionsByCategory({
categoryId: activeCategory.id,
categoryIds: getDescendantInstructionCategoryIds(
allCategories,
activeCategory.id,
),
page,
pageSize: PAGE_SIZE,
});
items = data.items;
total = data.total;
} else {
const data = await getInstructions({ page, pageSize: PAGE_SIZE });
items = data.items;
total = data.total;
}
} catch {
items = [];
total = 0;
}
const paginationQuery = activeCategory
? { category: activeCategory.slug }
: undefined;
const filterCategories =
rootCategories.length > 0 ? rootCategories : topInstructionCategories;
return (
<ContentPageShell flushTop>
<section className="pb-20 lg:pb-28">
<div className="page-title-band mb-8 md:mb-10">
<div className="relative mx-auto max-w-7xl px-5 pb-3 pt-[calc(var(--header-height)+0.75rem)] lg:px-8 lg:pb-4 lg:pt-[calc(var(--header-height)+1rem)]">
<p
aria-hidden
className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+1.15rem)] z-0 text-[clamp(1.75rem,5.5vw,3.75rem)] font-light uppercase leading-none tracking-[0.05em] text-ink/[0.07] lg:inset-x-8 lg:top-[calc(var(--header-height)+1.35rem)]"
>
Instructions
</p>
<div className="reveal relative z-10 max-w-2xl">
<h1 className="whitespace-normal text-[clamp(1.35rem,3.6vw,2.75rem)] font-normal leading-tight tracking-tight text-ink">
آموزش‌ها
</h1>
<p className="mt-1 text-sm leading-6 text-muted md:text-base">
راهنماها و آموزش‌های کاربردی برای استفاده از خدمات مشکی.
</p>
</div>
</div>
</div>
<div className="mx-auto max-w-7xl px-5 lg:px-8">
<div className="reveal mb-10 flex flex-wrap gap-2 md:mb-12">
<Link
href="/instruction"
className={`rounded-full border px-4 py-2 text-sm transition ${
!activeCategory
? "border-ink bg-ink text-surface"
: "border-line/70 text-ink-soft hover:border-ink/30 hover:text-ink"
}`}
>
همه
</Link>
{filterCategories.map((category) => {
const active = activeCategory?.id === category.id;
return (
<Link
key={category.id}
href={instructionCategoryHref(category.slug)}
className={`rounded-full border px-4 py-2 text-sm transition ${
active
? "border-ink bg-ink text-surface"
: "border-line/70 text-ink-soft hover:border-ink/30 hover:text-ink"
}`}
>
{category.nameFa}
</Link>
);
})}
</div>
{items.length === 0 ? (
<p className="text-sm text-muted">
آموزشی برای نمایش وجود ندارد.
</p>
) : (
<div
key={`${activeCategory?.slug ?? "all"}-${page}`}
className="grid gap-8 sm:grid-cols-2 lg:grid-cols-3"
>
{items.map((item, index) => {
const image = getInstructionCoverImage(item);
const categoryFa = instructionCategoryLabelFa(
item.categoryName,
allCategories,
);
const titleFa = item.titleFa || item.title;
const titleEn =
item.titleEn ??
(item.titleFa && item.title !== item.titleFa
? item.title
: null);
return (
<article
key={item.id}
className={`reveal group overflow-hidden rounded-2xl border border-line/70 ${
index % 3 === 1
? "reveal-delay-1"
: index % 3 === 2
? "reveal-delay-2"
: ""
}`}
>
<Link href={`/instruction/${item.slug}`} className="block">
<div className="relative overflow-hidden bg-paper-deep">
{image ? (
<BlogCoverImage src={image} alt={titleFa} />
) : null}
</div>
<div className="px-4 py-4">
{categoryFa ? (
<p className="mb-2 text-[11px] text-muted">
{categoryFa}
</p>
) : null}
<h2 className="group-text-hover line-clamp-2 whitespace-normal text-lg font-normal leading-7 text-ink">
{titleFa}
</h2>
{titleEn ? (
<p className="latin text-[12px] font-light uppercase tracking-[0.22em] text-ink-soft/70">
{titleEn}
</p>
) : null}
{item.abstract ? (
<p className="mt-2 line-clamp-3 text-sm leading-6 text-muted">
{item.abstract}
</p>
) : null}
</div>
</Link>
</article>
);
})}
</div>
)}
<Pagination
page={page}
pageSize={PAGE_SIZE}
total={total}
basePath="/instruction"
query={paginationQuery}
/>
</div>
</section>
</ContentPageShell>
);
}