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
@@ -31,6 +31,7 @@ const PRIORITY = {
|
||||
"/meshkee-cms": { changefreq: "monthly", priority: 0.8 },
|
||||
"/graphic-designer": { changefreq: "monthly", priority: 0.7 },
|
||||
"/portfolio": { changefreq: "weekly", priority: 0.8 },
|
||||
"/instruction": { changefreq: "weekly", priority: 0.8 },
|
||||
"/blog": { changefreq: "weekly", priority: 0.8 },
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { BlogCoverImage } from "@/components/BlogCoverImage";
|
||||
import { ContentPageShell } from "@/components/ContentPageShell";
|
||||
import { HtmlContent } from "@/components/HtmlContent";
|
||||
import {
|
||||
findInstructionCategory,
|
||||
getRootInstructionCategories,
|
||||
instructionCategoryHref,
|
||||
instructionCategoryLabelFa,
|
||||
topInstructionCategories,
|
||||
type InstructionCategory,
|
||||
} from "@/data/instructionCategories";
|
||||
import {
|
||||
ApiNotFoundError,
|
||||
authorName,
|
||||
formatFaDate,
|
||||
getInstruction,
|
||||
getInstructionCategories,
|
||||
getInstructionCoverImage,
|
||||
} from "@/lib/api";
|
||||
|
||||
type InstructionDetailPageProps = {
|
||||
params: Promise<{ slug: 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,
|
||||
}));
|
||||
}
|
||||
|
||||
function findRootForCategory(
|
||||
categoryId: string | number | null | undefined,
|
||||
categories: InstructionCategory[],
|
||||
): InstructionCategory | 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({
|
||||
params,
|
||||
}: InstructionDetailPageProps): Promise<Metadata> {
|
||||
const { slug } = await params;
|
||||
|
||||
try {
|
||||
const instruction = await getInstruction(slug);
|
||||
const title = instruction.titleFa || instruction.title;
|
||||
return {
|
||||
title: `${title} | مشکی`,
|
||||
description: instruction.abstract ?? undefined,
|
||||
alternates: { canonical: `/instruction/${instruction.slug}` },
|
||||
openGraph: {
|
||||
title,
|
||||
description: instruction.abstract ?? undefined,
|
||||
type: "article",
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return { title: "آموزش | مشکی" };
|
||||
}
|
||||
}
|
||||
|
||||
export default async function InstructionDetailPage({
|
||||
params,
|
||||
}: InstructionDetailPageProps) {
|
||||
const { slug } = await params;
|
||||
|
||||
let instruction;
|
||||
try {
|
||||
instruction = await getInstruction(slug);
|
||||
} catch (error) {
|
||||
if (error instanceof ApiNotFoundError) notFound();
|
||||
throw error;
|
||||
}
|
||||
|
||||
const cover = getInstructionCoverImage(instruction);
|
||||
const date = formatFaDate(instruction.publishedAt);
|
||||
const author = authorName(instruction.author);
|
||||
const title = instruction.titleFa || instruction.title;
|
||||
|
||||
let allCategories: InstructionCategory[] = topInstructionCategories;
|
||||
try {
|
||||
allCategories = mapApiCategories(await getInstructionCategories());
|
||||
} catch {
|
||||
allCategories = topInstructionCategories;
|
||||
}
|
||||
|
||||
const categoryFa = instructionCategoryLabelFa(
|
||||
instruction.categoryName,
|
||||
allCategories,
|
||||
);
|
||||
const rootCategory =
|
||||
findRootForCategory(instruction.categoryId, allCategories) ??
|
||||
findInstructionCategory(
|
||||
instruction.categoryName,
|
||||
getRootInstructionCategories(allCategories),
|
||||
);
|
||||
|
||||
const titleEn =
|
||||
instruction.titleEn ??
|
||||
(instruction.titleFa && instruction.title !== instruction.titleFa
|
||||
? instruction.title
|
||||
: null);
|
||||
const categoryHref = rootCategory
|
||||
? instructionCategoryHref(rootCategory.slug)
|
||||
: "/instruction";
|
||||
|
||||
return (
|
||||
<ContentPageShell flushTop>
|
||||
<article 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-4 pt-[calc(var(--header-height)+0.75rem)] lg:px-8 lg:pb-5 lg:pt-[calc(var(--header-height)+1rem)]">
|
||||
{titleEn ? (
|
||||
<p
|
||||
aria-hidden
|
||||
className="latin pointer-events-none absolute inset-x-5 top-[calc(var(--header-height)+2.4rem)] 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)+2.6rem)]"
|
||||
>
|
||||
{titleEn}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="reveal relative z-10 max-w-3xl">
|
||||
<nav
|
||||
aria-label="مسیر صفحه"
|
||||
className="mb-3 flex flex-wrap items-center gap-1.5 text-xs text-muted"
|
||||
>
|
||||
<Link
|
||||
href="/instruction"
|
||||
className="link-hover transition hover:text-ink"
|
||||
>
|
||||
آموزشها
|
||||
</Link>
|
||||
{rootCategory || categoryFa ? (
|
||||
<>
|
||||
<span aria-hidden className="text-line">
|
||||
/
|
||||
</span>
|
||||
<Link
|
||||
href={categoryHref}
|
||||
className="link-hover transition hover:text-ink"
|
||||
>
|
||||
{rootCategory?.nameFa ?? categoryFa}
|
||||
</Link>
|
||||
</>
|
||||
) : null}
|
||||
<span aria-hidden className="text-line">
|
||||
/
|
||||
</span>
|
||||
<span className="line-clamp-1 text-ink-soft">{title}</span>
|
||||
</nav>
|
||||
|
||||
<h1 className="whitespace-normal text-[clamp(1.35rem,3.8vw,2.5rem)] font-normal leading-tight tracking-tight text-ink">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
{instruction.abstract ? (
|
||||
<p className="mt-2 text-justify text-sm leading-7 text-muted md:text-base">
|
||||
{instruction.abstract}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto max-w-3xl px-5 lg:px-8">
|
||||
{cover ? (
|
||||
<div className="mb-5 overflow-hidden rounded-2xl">
|
||||
<BlogCoverImage
|
||||
src={cover}
|
||||
alt={title}
|
||||
className="mx-auto max-h-[28rem]"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(date || author || categoryFa) ? (
|
||||
<div className="mb-8 flex flex-wrap items-center justify-between gap-4 border-b border-line/70 pb-5 text-xs text-muted">
|
||||
<span>
|
||||
{author ? `نویسنده: ${author}` : categoryFa ?? ""}
|
||||
</span>
|
||||
{date ? (
|
||||
<time dateTime={instruction.publishedAt ?? undefined}>
|
||||
{date}
|
||||
</time>
|
||||
) : null}
|
||||
</div>
|
||||
) : cover ? (
|
||||
<div className="mb-8 border-b border-line/70" />
|
||||
) : null}
|
||||
|
||||
{instruction.descriptionHtml ? (
|
||||
<div className="reveal reveal-delay-2">
|
||||
<HtmlContent html={instruction.descriptionHtml} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
</ContentPageShell>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +49,7 @@ function DesktopNavItem({ item }: { item: NavItem }) {
|
||||
href={item.href}
|
||||
className="block rounded-xl px-3 py-2.5 text-sm text-ink-soft transition hover:bg-[#FAFAFA] hover:text-ink"
|
||||
>
|
||||
همه نمونهکارها
|
||||
{item.allLabel ?? `همه ${item.label}`}
|
||||
</Link>
|
||||
{item.children.map((child) => (
|
||||
<Link
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { images } from "./assets";
|
||||
import {
|
||||
instructionCategoryHref,
|
||||
topInstructionCategories,
|
||||
} from "./instructionCategories";
|
||||
import {
|
||||
portfolioCategoryHref,
|
||||
topPortfolioCategories,
|
||||
@@ -19,6 +23,7 @@ export const site = {
|
||||
export type NavItem = {
|
||||
label: string;
|
||||
href: string;
|
||||
allLabel?: string;
|
||||
children?: { label: string; href: string }[];
|
||||
};
|
||||
|
||||
@@ -31,11 +36,21 @@ export const nav: NavItem[] = [
|
||||
{
|
||||
label: "نمونهکارها",
|
||||
href: "/portfolio",
|
||||
allLabel: "همه نمونهکارها",
|
||||
children: topPortfolioCategories.map((category) => ({
|
||||
label: category.nameFa,
|
||||
href: portfolioCategoryHref(category.slug),
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: "آموزشها",
|
||||
href: "/instruction",
|
||||
allLabel: "همه آموزشها",
|
||||
children: topInstructionCategories.map((category) => ({
|
||||
label: category.nameFa,
|
||||
href: instructionCategoryHref(category.slug),
|
||||
})),
|
||||
},
|
||||
{ label: "مقالات", href: "/blog" },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
export type InstructionCategory = {
|
||||
id: string;
|
||||
slug: string;
|
||||
nameEn: string;
|
||||
nameFa: string;
|
||||
parentId?: string | null;
|
||||
sortOrder?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Top-level instruction categories from Meshkee API
|
||||
* (`/tenants/meshkee.com/categories?entityType=instruction`, parentId = null).
|
||||
*/
|
||||
export const topInstructionCategories: InstructionCategory[] = [
|
||||
{
|
||||
id: "1296",
|
||||
slug: "email",
|
||||
nameEn: "Email",
|
||||
nameFa: "ایمیل",
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
},
|
||||
];
|
||||
|
||||
export const instructionCategories = topInstructionCategories;
|
||||
|
||||
export function findInstructionCategory(
|
||||
value: string | null | undefined,
|
||||
categories: InstructionCategory[] = topInstructionCategories,
|
||||
): InstructionCategory | undefined {
|
||||
if (!value) return undefined;
|
||||
const key = value.trim().toLowerCase();
|
||||
return categories.find(
|
||||
(category) =>
|
||||
category.slug === key ||
|
||||
category.id === key ||
|
||||
category.nameEn.toLowerCase() === key,
|
||||
);
|
||||
}
|
||||
|
||||
export function instructionCategoryHref(slug?: string) {
|
||||
if (!slug) return "/instruction";
|
||||
return `/instruction?category=${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
export function getRootInstructionCategories(categories: InstructionCategory[]) {
|
||||
return categories
|
||||
.filter((category) => !category.parentId)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
(a.sortOrder ?? 0) - (b.sortOrder ?? 0) ||
|
||||
a.nameEn.localeCompare(b.nameEn),
|
||||
);
|
||||
}
|
||||
|
||||
export function getDescendantInstructionCategoryIds(
|
||||
categories: InstructionCategory[],
|
||||
rootId: string,
|
||||
): string[] {
|
||||
const ids = [String(rootId)];
|
||||
const children = categories.filter(
|
||||
(category) => String(category.parentId) === String(rootId),
|
||||
);
|
||||
|
||||
for (const child of children) {
|
||||
ids.push(...getDescendantInstructionCategoryIds(categories, child.id));
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function instructionCategoryLabelFa(
|
||||
categoryName: string | null | undefined,
|
||||
categories: InstructionCategory[] = topInstructionCategories,
|
||||
): string | null {
|
||||
if (!categoryName) return null;
|
||||
const match = categories.find(
|
||||
(category) =>
|
||||
category.nameEn.toLowerCase() === categoryName.toLowerCase(),
|
||||
);
|
||||
return match?.nameFa ?? categoryName;
|
||||
}
|
||||
+111
@@ -34,6 +34,23 @@ export type PortfolioGalleryItem = {
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type Instruction = {
|
||||
id: string;
|
||||
title: string;
|
||||
titleFa?: string | null;
|
||||
titleEn?: string | null;
|
||||
slug: string;
|
||||
abstract: string | null;
|
||||
descriptionHtml: string | null;
|
||||
coverImageUrl: string | null;
|
||||
publishedAt: string | null;
|
||||
categoryId?: string | number | null;
|
||||
categoryName?: string | null;
|
||||
author?: BlogAuthor | null;
|
||||
videoProvider?: string | null;
|
||||
embedCode?: string | null;
|
||||
};
|
||||
|
||||
export type Portfolio = {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -70,6 +87,13 @@ type GetPortfoliosOptions = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type GetInstructionsOptions = {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
categoryId?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
type ApiFetchInit = RequestInit & {
|
||||
next?: {
|
||||
revalidate?: number | false;
|
||||
@@ -155,6 +179,15 @@ export async function getPortfolioCategories(): Promise<PortfolioCategoryApi[]>
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function getInstructionCategories(): Promise<
|
||||
PortfolioCategoryApi[]
|
||||
> {
|
||||
const data = await apiFetch<{ items: PortfolioCategoryApi[] }>(
|
||||
`/tenants/${SITE_DOMAIN}/categories${toQuery({ entityType: "instruction" })}`,
|
||||
);
|
||||
return data.items ?? [];
|
||||
}
|
||||
|
||||
export async function getPortfolios(
|
||||
options: GetPortfoliosOptions = {},
|
||||
): Promise<Paginated<Portfolio>> {
|
||||
@@ -241,6 +274,73 @@ export async function getPortfolio(slug: string): Promise<Portfolio> {
|
||||
return data.portfolio;
|
||||
}
|
||||
|
||||
export async function getInstructions(
|
||||
options: GetInstructionsOptions = {},
|
||||
): Promise<Paginated<Instruction>> {
|
||||
return apiFetch<Paginated<Instruction>>(
|
||||
`/tenants/${SITE_DOMAIN}/instructions${toQuery({
|
||||
page: options.page,
|
||||
pageSize: options.pageSize,
|
||||
categoryId: options.categoryId,
|
||||
title: options.title,
|
||||
})}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function getAllInstructions(): Promise<Instruction[]> {
|
||||
const pageSize = 100;
|
||||
let page = 1;
|
||||
let total = Infinity;
|
||||
const items: Instruction[] = [];
|
||||
|
||||
while (items.length < total) {
|
||||
const data = await getInstructions({ page, pageSize });
|
||||
total = data.total;
|
||||
items.push(...data.items);
|
||||
if (!data.items.length) break;
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
export async function getInstructionsByCategory(options: {
|
||||
categoryId: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
categoryIds?: string[];
|
||||
}): Promise<Paginated<Instruction>> {
|
||||
const page = Math.max(1, options.page ?? 1);
|
||||
const pageSize = Math.max(1, options.pageSize ?? 12);
|
||||
|
||||
let allowedIds = options.categoryIds?.map(String);
|
||||
if (!allowedIds?.length) {
|
||||
const categories = await getInstructionCategories();
|
||||
allowedIds = collectDescendantIds(categories, options.categoryId);
|
||||
}
|
||||
|
||||
const allowed = new Set(allowedIds);
|
||||
const all = await getAllInstructions();
|
||||
const filtered = all.filter((item) =>
|
||||
allowed.has(String(item.categoryId)),
|
||||
);
|
||||
const start = (page - 1) * pageSize;
|
||||
|
||||
return {
|
||||
items: filtered.slice(start, start + pageSize),
|
||||
total: filtered.length,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getInstruction(slug: string): Promise<Instruction> {
|
||||
const data = await apiFetch<{ instruction: Instruction }>(
|
||||
`/tenants/${SITE_DOMAIN}/instructions/${encodeURIComponent(slug)}`,
|
||||
);
|
||||
return data.instruction;
|
||||
}
|
||||
|
||||
export function getBlogCoverImage(
|
||||
blog: Pick<Blog, "titleImageUrl" | "mainTextHtml">,
|
||||
): string | null {
|
||||
@@ -279,6 +379,17 @@ export function getPortfolioCoverImage(
|
||||
return match?.[1] ?? null;
|
||||
}
|
||||
|
||||
export function getInstructionCoverImage(
|
||||
instruction: Pick<Instruction, "coverImageUrl" | "descriptionHtml">,
|
||||
): string | null {
|
||||
if (instruction.coverImageUrl) return instruction.coverImageUrl;
|
||||
|
||||
const urls = extractImageUrlsFromHtml(instruction.descriptionHtml);
|
||||
const preferred = urls.find((url) => url.includes("/meshkee/businesses/"));
|
||||
|
||||
return preferred ?? urls[0] ?? null;
|
||||
}
|
||||
|
||||
export function formatFaDate(value: string | null | undefined): string | null {
|
||||
if (!value) return null;
|
||||
|
||||
|
||||
@@ -26,6 +26,11 @@
|
||||
"changefreq": "monthly",
|
||||
"priority": 0.7
|
||||
},
|
||||
{
|
||||
"path": "/instruction",
|
||||
"changefreq": "weekly",
|
||||
"priority": 0.8
|
||||
},
|
||||
{
|
||||
"path": "/meshkee-cms",
|
||||
"changefreq": "monthly",
|
||||
|
||||
Reference in New Issue
Block a user