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:
co-authored by
Cursor
parent
c588b249cc
commit
b0ba7b1de8
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user