From aa6f3c9b3517d6261cce8f174372a10e6662edba Mon Sep 17 00:00:00 2001 From: "ar.azizan" Date: Thu, 5 Feb 2026 17:58:28 +0330 Subject: [PATCH] feat: enhance blog functionality with new components and styles - Added styles for WordPress table and button blocks in globals.css. - Updated layout.tsx to include a favicon. - Improved blog page layout and search functionality, including new search and filter icons. - Implemented related posts feature in blog post pages with new BlogBlockquote and BlogPullquote components. - Enhanced blog card display with a new variant for smaller cards. - Updated blog collapse sections for better user interaction. - Added new utility functions for extracting block data from WordPress content. --- app/blog/[slug]/page.tsx | 38 ++++++++++-- app/blog/page.tsx | 68 ++++++++++----------- app/globals.css | 65 ++++++++++++++++++++ app/layout.tsx | 3 + components/blog-blockquote.tsx | 35 +++++++++++ components/blog-card.tsx | 2 +- components/blog-collapse-section.tsx | 24 ++++++-- components/blog-post-card.tsx | 57 +++++++++++++++++- components/blog-pullquote.tsx | 30 ++++++++++ components/blog-toc.tsx | 2 +- components/header.tsx | 18 ++---- lib/blog-blocks.ts | 89 +++++++++++++++++++++++++--- lib/wp-api.ts | 24 ++++++++ 13 files changed, 386 insertions(+), 69 deletions(-) create mode 100644 components/blog-blockquote.tsx create mode 100644 components/blog-pullquote.tsx diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx index 38daa2b..25395fe 100644 --- a/app/blog/[slug]/page.tsx +++ b/app/blog/[slug]/page.tsx @@ -2,14 +2,18 @@ import type { Metadata } from "next"; import Link from "next/link"; import Image from "next/image"; import { notFound } from "next/navigation"; -import { getPostBySlug, getFeaturedImageUrl } from "@/lib/wp-api"; +import { getPostBySlug, getFeaturedImageUrl, getRelatedPosts } from "@/lib/wp-api"; import { formatDateShamsi } from "@/lib/format-date"; import { ChevronRight } from "lucide-react"; +import type { CollapseBlockPayload, BlockquoteBlockPayload } from "@/lib/blog-blocks"; import { parseContentHeadings, getReadingTimeMinutes } from "@/lib/blog-utils"; import { BlogToc } from "@/components/blog-toc"; import { BlogPostMeta } from "@/components/blog-post-meta"; import { BlogCollapseSection } from "@/components/blog-collapse-section"; +import { BlogBlockquote } from "@/components/blog-blockquote"; +import { BlogPullquote } from "@/components/blog-pullquote"; import { ScrollToTop } from "@/components/scroll-to-top"; +import { BlogPostCard } from "@/components/blog-post-card"; interface BlogPostPageProps { params: Promise<{ slug: string }>; @@ -39,6 +43,13 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) { } if (!post) notFound(); + let relatedPosts: Awaited> = []; + try { + relatedPosts = await getRelatedPosts(post.id, post.categories ?? [], 3); + } catch { + // show no related section on API error + } + const date = post.date ? formatDateShamsi(post.date) : null; const modifiedDate = post.modified ? formatDateShamsi(post.modified) @@ -57,8 +68,8 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) { <> {/* تصویر شاخص تمام‌عرض بالای صفحه */} {thumbnailUrl && ( -
-
+
+
{altText} + ) : seg.blockType === "blockquote" ? ( + ) : ( - /* سایر بلاک‌ها (blockquote, code, ...) بعداً با کامپوننت خودشان رندر می‌شوند */ null ) )} @@ -127,6 +139,22 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
+ + {relatedPosts.length > 0 && ( +
+
+

+ مقالات مرتبط +

+
+ {relatedPosts.map((p) => ( + + ))} +
+
+
+ )} + ); diff --git a/app/blog/page.tsx b/app/blog/page.tsx index 6eb7580..01af96f 100644 --- a/app/blog/page.tsx +++ b/app/blog/page.tsx @@ -4,7 +4,7 @@ import { getPosts, getCategories } from "@/lib/wp-api"; import { BlogPostCard } from "@/components/blog-post-card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { ChevronRight, ChevronLeft } from "lucide-react"; +import { ChevronRight, ChevronLeft, Search, X } from "lucide-react"; const PER_PAGE = 9; @@ -37,7 +37,6 @@ export default async function BlogPage({ searchParams }: BlogPageProps) { getCategories(), ]); } catch (e) { - console.log(e) return (
@@ -64,53 +63,56 @@ export default async function BlogPage({ searchParams }: BlogPageProps) { return ( <> - {/* Hero + search & filters in one section */} -
-
-
-
-

+ {/* Hero + search & filters — compact */} +
+
+
+
+

بلاگ

-

+

مقالات و مطالب فیپ در زمینه یکپارچه‌سازی، اتوماسیون و بهره‌وری سازمانی.

-
+
- -
- - {hasFilters && ( - - - - )} +
+ +
+ + {hasFilters && ( + + + پاک کردن فیلتر + + )} {categories.length > 0 && ( -
+
table) */ +.wp-block-table { + margin: 1.5rem 0; + overflow-x: auto; + border-radius: var(--radius); +} + +.wp-block-table table { + width: 100%; + border-collapse: collapse; + font-size: 0.9375rem; + +} + +.wp-block-table table.has-fixed-layout { + table-layout: fixed; +} + +.wp-block-table th, +.wp-block-table td { + padding: 0.75rem 1rem; + border: 1px solid var(--border); + text-align: right; + min-width: 850px; +} + +.wp-block-table tbody tr:first-child th, +.wp-block-table tbody tr:first-child td { + background: var(--muted); + font-weight: 600; + color: var(--foreground); +} + +.wp-block-table tbody tr:not(:first-child):hover td { + background: var(--muted); +} + +.wp-block-table thead th { + background: var(--muted); + font-weight: 600; + color: var(--foreground); +} + + +.wp-block-button > .wp-element-button { + @apply inline-flex bg-primary text-primary-foreground px-4 py-2 rounded-md hover:bg-primary/80 hover:text-primary-foreground/80 cursor-pointer no-underline; +} + +.wp-block-button { + @apply inline-flex; +} + +.wp-block-button.is-style-outline > .wp-element-button { + @apply bg-transparent text-foreground border border-2 border-primary hover:text-primary cursor-pointer no-underline; +} + +.cta-block { + @apply flex flex-col gap-4 border rounded-md border-primary/50 p-4 items-center my-4 shadow-sm bg-primary/5 text-primary; +} + +.wp-block-image { + @apply rounded-md overflow-hidden shadow-sm; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 9dddbe4..47452cb 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -8,6 +8,9 @@ export const metadata: Metadata = { title: "فیپ", description: "فیپ یک سامانه برای ایجاد و طراحی راهکارهای مناسب برای کسب و کار شما است.", + icons: { + icon: "/assets/feep-Logo.svg", + }, }; export default function RootLayout({ diff --git a/components/blog-blockquote.tsx b/components/blog-blockquote.tsx new file mode 100644 index 0000000..9315b23 --- /dev/null +++ b/components/blog-blockquote.tsx @@ -0,0 +1,35 @@ +"use client"; + +import type { BlockquoteBlockPayload } from "@/lib/blog-blocks"; +import { Quote } from "lucide-react"; + +interface BlogBlockquoteProps { + payload: BlockquoteBlockPayload; +} + +export function BlogBlockquote({ payload }: BlogBlockquoteProps) { + const { content, citation } = payload; + if (!content.trim()) return null; + + return ( +
+
+ +
+ {citation ? ( +
+ + — {citation} + +
+ ) : null} +
+
+ ); +} diff --git a/components/blog-card.tsx b/components/blog-card.tsx index 3fe8bcb..cedb445 100644 --- a/components/blog-card.tsx +++ b/components/blog-card.tsx @@ -26,7 +26,7 @@ export function BlogCard({ {/* Image with 3:2 aspect ratio */} -
+
{title} +
{section.title ? (

{section.title}

) : null} - + {section.items.map((item, i) => ( - - {item.question} + + + + + {item.question} + - {item.answer} +
))} diff --git a/components/blog-post-card.tsx b/components/blog-post-card.tsx index a11ef9f..457f46a 100644 --- a/components/blog-post-card.tsx +++ b/components/blog-post-card.tsx @@ -9,12 +9,67 @@ function stripHtml(html: string): string { return html.replace(/<[^>]*>/g, "").trim(); } -export function BlogPostCard({ post }: { post: WpPost }) { +type BlogPostCardVariant = "default" | "tiny"; + +export function BlogPostCard({ + post, + variant = "default", +}: { + post: WpPost; + variant?: BlogPostCardVariant; +}) { const excerpt = stripHtml(post.excerpt.rendered) || stripHtml(post.content.rendered).slice(0, 160); const modifiedDate = post.modified ? formatDateShamsi(post.modified) : null; const authorName = post._embedded?.author?.[0]?.name ?? null; const thumbnailUrl = getFeaturedImageUrl(post, "medium"); + if (variant === "tiny") { + return ( + + + + {thumbnailUrl ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+

+ {post.title.rendered} +

+
+ {authorName && ( + + + {authorName} + + )} + {modifiedDate && ( + + )} +
+
+
+
+ + ); + } + return ( diff --git a/components/blog-pullquote.tsx b/components/blog-pullquote.tsx new file mode 100644 index 0000000..5d958fd --- /dev/null +++ b/components/blog-pullquote.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { BlockquoteBlockPayload } from "@/lib/blog-blocks"; + +interface BlogPullquoteProps { + payload: BlockquoteBlockPayload; +} + +export function BlogPullquote({ payload }: BlogPullquoteProps) { + const { content, citation } = payload; + if (!content.trim()) return null; + + return ( +
+
+
+ {citation ? ( +
+ + — {citation} + +
+ ) : null} +
+
+ ); +} diff --git a/components/blog-toc.tsx b/components/blog-toc.tsx index c0d1dc9..3d5428b 100644 --- a/components/blog-toc.tsx +++ b/components/blog-toc.tsx @@ -176,7 +176,7 @@ export function BlogToc({ items, className }: BlogTocProps) {
-
- -
+
diff --git a/lib/blog-blocks.ts b/lib/blog-blocks.ts index a9bb31b..8bfb916 100644 --- a/lib/blog-blocks.ts +++ b/lib/blog-blocks.ts @@ -12,7 +12,7 @@ import type { Element } from "domhandler"; // ─── انواع بلاک (برای گسترش بعدی) ─────────────────────────────────────────── /** نام نوع بلاک؛ با افزودن بلاک جدید این یونیون را گسترش دهید. */ -export type BlockType = "collapse" | "blockquote" | "code"; +export type BlockType = "collapse" | "blockquote" | "pullquote" | "code"; /** Payload بلاک collapse (سوالات متداول / accordion). */ export interface CollapseBlockPayload { @@ -20,14 +20,21 @@ export interface CollapseBlockPayload { items: { question: string; answer: string }[]; } -/** Payload بلاک blockquote — بعداً پیاده‌سازی می‌شود. */ -// export interface BlockquoteBlockPayload { ... } +/** Payload بلاک blockquote (نقل‌قول و منبع). content به صورت HTML است و باید با dangerouslySetInnerHTML رندر شود. */ +export interface BlockquoteBlockPayload { + /** HTML محتوای نقل‌قول (بدون تگ cite). */ + content: string; + citation?: string; +} /** Payload بلاک code — بعداً پیاده‌سازی می‌شود. */ // export interface CodeBlockPayload { ... } /** یونیون همهٔ payloadها برای تایپ‌امن رندر. */ -export type BlockPayload = CollapseBlockPayload; // | BlockquoteBlockPayload | CodeBlockPayload +export type BlockPayload = + | CollapseBlockPayload + | BlockquoteBlockPayload; +// | CodeBlockPayload // ─── سگمنت خروجی برای لایه رندر ───────────────────────────────────────────── @@ -84,6 +91,58 @@ function extractCollapsePayload( return { title, items }; } +/** + * استخراج از بلاک آکاردئون وردپرس (core/accordion): + * div.wp-block-accordion > div.wp-block-accordion-item با + * .wp-block-accordion-heading__toggle-title (سؤال) و .wp-block-accordion-panel (جواب به صورت HTML). + */ +function extractAccordionPayload( + $: cheerio.CheerioAPI, + el: Element +): CollapseBlockPayload { + const $el = $(el); + const items: { question: string; answer: string }[] = []; + + $el.find(".wp-block-accordion-item").each((_: number, itemEl: Element) => { + const $item = $(itemEl); + const question = $item.find(".wp-block-accordion-heading__toggle-title").first().text().trim(); + const $panel = $item.find(".wp-block-accordion-panel").first(); + const answer = ($panel.html() ?? $panel.text() ?? "").trim(); + if (question || answer) items.push({ question, answer }); + }); + + return { title: "", items }; +} + +function extractBlockquotePayload( + $: cheerio.CheerioAPI, + el: Element +): BlockquoteBlockPayload { + const $el = $(el); + const citation = $el.find("cite").first().text().trim() || undefined; + const $body = $el.clone(); + $body.find("cite").remove(); + const content = ($body.html() ?? "").trim(); + return { content, citation }; +} + +/** + * استخراج از بلاک pullquote وردپرس: figure.wp-block-pullquote > blockquote > p + cite. + * همان شکل blockquote (content HTML + citation). + */ +function extractPullquotePayload( + $: cheerio.CheerioAPI, + el: Element +): BlockquoteBlockPayload { + const $el = $(el); + const $blockquote = $el.find("blockquote").first(); + const citation = $blockquote.find("cite").first().text().trim() || undefined; + const $body = $blockquote.clone(); + $body.find("cite").remove(); + const content = ($body.html() ?? "").trim(); + return { content, citation }; +} + /** * ثبت همهٔ بلاک‌های شناخته‌شده. * برای بلاک‌های جدید (مثلاً blockquote, code) فقط یک مورد جدید به این آرایه اضافه کنید. @@ -91,11 +150,24 @@ function extractCollapsePayload( export const BLOCK_DEFINITIONS: BlockDefinition[] = [ { blockType: "collapse", - selector: ".collpase-section", + selector: "div.wp-block-group.collapse-section", extract: extractCollapsePayload as BlockDefinition["extract"], }, - // بعداً: - // { blockType: "blockquote", selector: "blockquote.wp-block-quote", extract: extractBlockquotePayload }, + { + blockType: "collapse", + selector: "div.wp-block-accordion", + extract: extractAccordionPayload as BlockDefinition["extract"], + }, + { + blockType: "blockquote", + selector: "blockquote.wp-block-quote", + extract: extractBlockquotePayload as BlockDefinition["extract"], + }, + { + blockType: "pullquote", + selector: "figure.wp-block-pullquote", + extract: extractPullquotePayload as BlockDefinition["extract"], + }, // { blockType: "code", selector: "pre.wp-block-code", extract: extractCodePayload }, ]; @@ -142,11 +214,12 @@ export function discoverBlocks(html: string): DiscoveredContent { const blocksByType: Record = { collapse: [], blockquote: [], + pullquote: [], code: [], }; for (const def of BLOCK_DEFINITIONS) { - const elements = $('.collpase-section').toArray(); + const elements = $(def.selector).toArray().filter(isElement); for (const el of elements) { const payload = def.extract($, el) as BlockPayload; const blockId = blocksByType[def.blockType].length; diff --git a/lib/wp-api.ts b/lib/wp-api.ts index 9ffb18e..287e0b6 100644 --- a/lib/wp-api.ts +++ b/lib/wp-api.ts @@ -132,6 +132,30 @@ export async function getPostBySlug(slug: string): Promise { return posts.length > 0 ? posts[0] : null; } +export async function getRelatedPosts( + postId: number, + categoryIds: number[], + limit = 3 +): Promise { + const params = new URLSearchParams(); + params.set("exclude", String(postId)); + params.set("per_page", String(limit)); + params.set("_embed", "1"); + if (categoryIds.length > 0) { + params.set("categories", String(categoryIds[0])); + } + + const url = `${WP_API_BASE}/posts?${params.toString()}`; + const res = await fetchWithTimeout(url); + + if (!res.ok) { + throw new Error(`WP API error: ${res.status} ${res.statusText}`); + } + + const posts: WpPost[] = await res.json(); + return posts; +} + export async function getCategories(): Promise { const url = `${WP_API_BASE}/categories?per_page=100`; const res = await fetchWithTimeout(url);