diff --git a/scripts/generate-sitemap-config.mjs b/scripts/generate-sitemap-config.mjs index eb6e036..da63386 100644 --- a/scripts/generate-sitemap-config.mjs +++ b/scripts/generate-sitemap-config.mjs @@ -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 }, }; diff --git a/src/app/instruction/[slug]/page.tsx b/src/app/instruction/[slug]/page.tsx new file mode 100644 index 0000000..b270e04 --- /dev/null +++ b/src/app/instruction/[slug]/page.tsx @@ -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>, +): 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 { + 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 ( + +
+
+
+ {titleEn ? ( +

+ {titleEn} +

+ ) : null} + +
+ + +

+ {title} +

+ + {instruction.abstract ? ( +

+ {instruction.abstract} +

+ ) : null} +
+
+
+ +
+ {cover ? ( +
+ +
+ ) : null} + + {(date || author || categoryFa) ? ( +
+ + {author ? `نویسنده: ${author}` : categoryFa ?? ""} + + {date ? ( + + ) : null} +
+ ) : cover ? ( +
+ ) : null} + + {instruction.descriptionHtml ? ( +
+ +
+ ) : null} +
+
+
+ ); +} diff --git a/src/app/instruction/page.tsx b/src/app/instruction/page.tsx new file mode 100644 index 0000000..fb23eb9 --- /dev/null +++ b/src/app/instruction/page.tsx @@ -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>, +): 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>["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 ( + +
+
+
+

+ Instructions +

+
+

+ آموزش‌ها +

+

+ راهنماها و آموزش‌های کاربردی برای استفاده از خدمات مشکی. +

+
+
+
+ +
+
+ + همه + + {filterCategories.map((category) => { + const active = activeCategory?.id === category.id; + return ( + + {category.nameFa} + + ); + })} +
+ + {items.length === 0 ? ( +

+ آموزشی برای نمایش وجود ندارد. +

+ ) : ( +
+ {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 ( +
+ +
+ {image ? ( + + ) : null} +
+
+ {categoryFa ? ( +

+ {categoryFa} +

+ ) : null} +

+ {titleFa} +

+ {titleEn ? ( +

+ {titleEn} +

+ ) : null} + {item.abstract ? ( +

+ {item.abstract} +

+ ) : null} +
+ +
+ ); + })} +
+ )} + + +
+
+
+ ); +} diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 648216e..1bef0d6 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -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}`} {item.children.map((child) => ( ({ 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" }, ]; diff --git a/src/data/instructionCategories.ts b/src/data/instructionCategories.ts new file mode 100644 index 0000000..b0a10e6 --- /dev/null +++ b/src/data/instructionCategories.ts @@ -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; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 70c1d18..f073811 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 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> { @@ -241,6 +274,73 @@ export async function getPortfolio(slug: string): Promise { return data.portfolio; } +export async function getInstructions( + options: GetInstructionsOptions = {}, +): Promise> { + return apiFetch>( + `/tenants/${SITE_DOMAIN}/instructions${toQuery({ + page: options.page, + pageSize: options.pageSize, + categoryId: options.categoryId, + title: options.title, + })}`, + ); +} + +async function getAllInstructions(): Promise { + 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> { + 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 { + const data = await apiFetch<{ instruction: Instruction }>( + `/tenants/${SITE_DOMAIN}/instructions/${encodeURIComponent(slug)}`, + ); + return data.instruction; +} + export function getBlogCoverImage( blog: Pick, ): string | null { @@ -279,6 +379,17 @@ export function getPortfolioCoverImage( return match?.[1] ?? null; } +export function getInstructionCoverImage( + instruction: Pick, +): 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; diff --git a/src/lib/sitemap-config.generated.json b/src/lib/sitemap-config.generated.json index e5c5a1a..e284091 100644 --- a/src/lib/sitemap-config.generated.json +++ b/src/lib/sitemap-config.generated.json @@ -26,6 +26,11 @@ "changefreq": "monthly", "priority": 0.7 }, + { + "path": "/instruction", + "changefreq": "weekly", + "priority": 0.8 + }, { "path": "/meshkee-cms", "changefreq": "monthly",