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.
This commit is contained in:
@@ -2,14 +2,18 @@ import type { Metadata } from "next";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { notFound } from "next/navigation";
|
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 { formatDateShamsi } from "@/lib/format-date";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import type { CollapseBlockPayload, BlockquoteBlockPayload } from "@/lib/blog-blocks";
|
||||||
import { parseContentHeadings, getReadingTimeMinutes } from "@/lib/blog-utils";
|
import { parseContentHeadings, getReadingTimeMinutes } from "@/lib/blog-utils";
|
||||||
import { BlogToc } from "@/components/blog-toc";
|
import { BlogToc } from "@/components/blog-toc";
|
||||||
import { BlogPostMeta } from "@/components/blog-post-meta";
|
import { BlogPostMeta } from "@/components/blog-post-meta";
|
||||||
import { BlogCollapseSection } from "@/components/blog-collapse-section";
|
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 { ScrollToTop } from "@/components/scroll-to-top";
|
||||||
|
import { BlogPostCard } from "@/components/blog-post-card";
|
||||||
|
|
||||||
interface BlogPostPageProps {
|
interface BlogPostPageProps {
|
||||||
params: Promise<{ slug: string }>;
|
params: Promise<{ slug: string }>;
|
||||||
@@ -39,6 +43,13 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
|
|||||||
}
|
}
|
||||||
if (!post) notFound();
|
if (!post) notFound();
|
||||||
|
|
||||||
|
let relatedPosts: Awaited<ReturnType<typeof getRelatedPosts>> = [];
|
||||||
|
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 date = post.date ? formatDateShamsi(post.date) : null;
|
||||||
const modifiedDate = post.modified
|
const modifiedDate = post.modified
|
||||||
? formatDateShamsi(post.modified)
|
? formatDateShamsi(post.modified)
|
||||||
@@ -57,8 +68,8 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
|
|||||||
<>
|
<>
|
||||||
{/* تصویر شاخص تمامعرض بالای صفحه */}
|
{/* تصویر شاخص تمامعرض بالای صفحه */}
|
||||||
{thumbnailUrl && (
|
{thumbnailUrl && (
|
||||||
<div className="pt-28 md:pt-36">
|
<div className="pt-10 md:pt-24">
|
||||||
<div className="relative w-full aspect-[21/5] min-h-[10rem] md:min-h-[10rem] bg-muted">
|
<div className="relative container mx-auto max-w-6xl rounded-xl overflow-hidden shadow-sm w-full aspect-[5/2] min-h-[10rem] md:min-h-[10rem] bg-muted">
|
||||||
<Image
|
<Image
|
||||||
src={thumbnailUrl}
|
src={thumbnailUrl}
|
||||||
alt={altText}
|
alt={altText}
|
||||||
@@ -114,11 +125,12 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
|
|||||||
) : seg.blockType === "collapse" ? (
|
) : seg.blockType === "collapse" ? (
|
||||||
<BlogCollapseSection
|
<BlogCollapseSection
|
||||||
key={i}
|
key={i}
|
||||||
section={seg.payload}
|
section={seg.payload as CollapseBlockPayload}
|
||||||
idPrefix={`blog-${slug}-${i}`}
|
idPrefix={`blog-${slug}-${i}`}
|
||||||
/>
|
/>
|
||||||
|
) : seg.blockType === "blockquote" ? (
|
||||||
|
<BlogBlockquote key={i} payload={seg.payload as BlockquoteBlockPayload} />
|
||||||
) : (
|
) : (
|
||||||
/* سایر بلاکها (blockquote, code, ...) بعداً با کامپوننت خودشان رندر میشوند */
|
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
@@ -127,6 +139,22 @@ export default async function BlogPostPage({ params }: BlogPostPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{relatedPosts.length > 0 && (
|
||||||
|
<section className="bg-muted border-t border-b">
|
||||||
|
<div className="mx-auto max-w-6xl p-4">
|
||||||
|
<h2 className="text-xl text-center mb-6 md:mb-8">
|
||||||
|
مقالات مرتبط
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{relatedPosts.map((p) => (
|
||||||
|
<BlogPostCard key={p.id} post={p} variant="tiny" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
<ScrollToTop />
|
<ScrollToTop />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
+35
-33
@@ -4,7 +4,7 @@ import { getPosts, getCategories } from "@/lib/wp-api";
|
|||||||
import { BlogPostCard } from "@/components/blog-post-card";
|
import { BlogPostCard } from "@/components/blog-post-card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { ChevronRight, ChevronLeft } from "lucide-react";
|
import { ChevronRight, ChevronLeft, Search, X } from "lucide-react";
|
||||||
|
|
||||||
const PER_PAGE = 9;
|
const PER_PAGE = 9;
|
||||||
|
|
||||||
@@ -37,7 +37,6 @@ export default async function BlogPage({ searchParams }: BlogPageProps) {
|
|||||||
getCategories(),
|
getCategories(),
|
||||||
]);
|
]);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(e)
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-background pt-32 pb-20 md:pt-40 md:pb-28">
|
<section className="bg-background pt-32 pb-20 md:pt-40 md:pb-28">
|
||||||
<div className="mx-auto max-w-4xl px-6 text-center">
|
<div className="mx-auto max-w-4xl px-6 text-center">
|
||||||
@@ -64,53 +63,56 @@ export default async function BlogPage({ searchParams }: BlogPageProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Hero + search & filters in one section */}
|
{/* Hero + search & filters — compact */}
|
||||||
<section className="bg-background pt-32 pb-10 md:pt-40 md:pb-14">
|
<section className="bg-background pt-24 pb-6 md:pt-28 md:pb-8">
|
||||||
<div className="mx-auto max-w-6xl px-6">
|
<div className="mx-auto max-w-3xl px-4 sm:px-6">
|
||||||
<div className="text-center space-y-8">
|
<div className="text-center space-y-5">
|
||||||
<div className="space-y-3">
|
<div className="space-y-1.5">
|
||||||
<h1 className="text-4xl font-bold md:text-5xl lg:text-6xl leading-tight tracking-normal">
|
<h1 className="text-3xl font-bold md:text-4xl tracking-tight">
|
||||||
بلاگ
|
بلاگ
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-lg md:text-xl leading-relaxed text-muted-foreground max-w-2xl mx-auto">
|
<p className="text-sm md:text-base text-muted-foreground max-w-xl mx-auto">
|
||||||
مقالات و مطالب فیپ در زمینه یکپارچهسازی، اتوماسیون و بهرهوری سازمانی.
|
مقالات و مطالب فیپ در زمینه یکپارچهسازی، اتوماسیون و بهرهوری سازمانی.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-5 max-w-2xl mx-auto">
|
<div className="flex flex-col gap-4">
|
||||||
<form
|
<form
|
||||||
method="get"
|
method="get"
|
||||||
action="/blog"
|
action="/blog"
|
||||||
className="flex flex-col sm:flex-row gap-2 sm:gap-2 w-full"
|
className="flex gap-2 w-full"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="category" value={categoryId ?? ""} />
|
<input type="hidden" name="category" value={categoryId ?? ""} />
|
||||||
<Input
|
<div className="relative flex-1 flex items-center">
|
||||||
type="search"
|
<Search className="absolute right-3 size-4 text-muted-foreground pointer-events-none" aria-hidden />
|
||||||
name="search"
|
<Input
|
||||||
placeholder="جستجو در مطالب..."
|
type="search"
|
||||||
defaultValue={search}
|
name="search"
|
||||||
className="flex-1 w-full sm:min-w-[14rem] text-base"
|
placeholder="جستجو در مطالب..."
|
||||||
aria-label="جستجو در مطالب"
|
defaultValue={search}
|
||||||
/>
|
className="w-full pr-10 pl-3 h-9 text-sm"
|
||||||
<div className="flex gap-2 flex-shrink-0">
|
aria-label="جستجو در مطالب"
|
||||||
<Button type="submit" size="default" className="flex-1 sm:flex-none">
|
/>
|
||||||
جستجو
|
|
||||||
</Button>
|
|
||||||
{hasFilters && (
|
|
||||||
<Link href="/blog" className="flex-1 sm:flex-none">
|
|
||||||
<Button type="button" variant="outline" size="default" className="w-full">
|
|
||||||
پاک کردن فیلتر
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button type="submit" size="default" className="h-9 px-4 shrink-0">
|
||||||
|
جستجو
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
{hasFilters && (
|
||||||
|
<Link
|
||||||
|
href="/blog"
|
||||||
|
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
پاک کردن فیلتر
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
{categories.length > 0 && (
|
{categories.length > 0 && (
|
||||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
<div className="flex flex-wrap items-center justify-center gap-1.5">
|
||||||
<Link
|
<Link
|
||||||
href="/blog"
|
href="/blog"
|
||||||
className={`text-sm rounded-full px-4 py-2 transition-colors ${!categoryId
|
className={`text-xs rounded-full px-3 py-1.5 transition-colors ${!categoryId
|
||||||
? "bg-primary text-primary-foreground shadow-sm"
|
? "bg-primary text-primary-foreground shadow-sm"
|
||||||
: "bg-muted/80 hover:bg-muted text-muted-foreground hover:text-foreground"
|
: "bg-muted/80 hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
@@ -121,7 +123,7 @@ export default async function BlogPage({ searchParams }: BlogPageProps) {
|
|||||||
<Link
|
<Link
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
href={`/blog${buildQuery({ search, category: String(cat.id), page: 1 })}`}
|
href={`/blog${buildQuery({ search, category: String(cat.id), page: 1 })}`}
|
||||||
className={`text-sm rounded-full px-4 py-2 transition-colors ${categoryId === cat.id
|
className={`text-xs rounded-full px-3 py-1.5 transition-colors ${categoryId === cat.id
|
||||||
? "bg-primary text-primary-foreground shadow-sm"
|
? "bg-primary text-primary-foreground shadow-sm"
|
||||||
: "bg-muted/80 hover:bg-muted text-muted-foreground hover:text-foreground"
|
: "bg-muted/80 hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -231,3 +231,68 @@
|
|||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* WordPress table block (figure.wp-block-table > 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;
|
||||||
|
}
|
||||||
@@ -8,6 +8,9 @@ export const metadata: Metadata = {
|
|||||||
title: "فیپ",
|
title: "فیپ",
|
||||||
description:
|
description:
|
||||||
"فیپ یک سامانه برای ایجاد و طراحی راهکارهای مناسب برای کسب و کار شما است.",
|
"فیپ یک سامانه برای ایجاد و طراحی راهکارهای مناسب برای کسب و کار شما است.",
|
||||||
|
icons: {
|
||||||
|
icon: "/assets/feep-Logo.svg",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<figure className="my-8 not-prose">
|
||||||
|
<blockquote className="relative rounded-xl border border-border bg-muted/40 px-6 py-2 pr-14 text-foreground shadow-sm">
|
||||||
|
<Quote
|
||||||
|
className="absolute right-4 top-4 size-8 text-muted-foreground/40"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="blog-blockquote-content text-lg leading-relaxed text-justify [&_p]:my-2 [&_a]:text-primary [&_a]:underline hover:[&_a]:no-underline"
|
||||||
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
|
/>
|
||||||
|
{citation ? (
|
||||||
|
<footer className="mt-4 border-t border-border/60 pt-4">
|
||||||
|
<cite className="text-sm text-muted-foreground not-italic">
|
||||||
|
— {citation}
|
||||||
|
</cite>
|
||||||
|
</footer>
|
||||||
|
) : null}
|
||||||
|
</blockquote>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ export function BlogCard({
|
|||||||
<Link href={`/blog/${slug}`} className="group block h-full">
|
<Link href={`/blog/${slug}`} className="group block h-full">
|
||||||
<Card className="h-full flex flex-col overflow-hidden transition-all duration-300 hover:shadow-lg hover:-translate-y-1 dark:border-transparent">
|
<Card className="h-full flex flex-col overflow-hidden transition-all duration-300 hover:shadow-lg hover:-translate-y-1 dark:border-transparent">
|
||||||
{/* Image with 3:2 aspect ratio */}
|
{/* Image with 3:2 aspect ratio */}
|
||||||
<div className="relative aspect-[3/2] w-full overflow-hidden bg-muted">
|
<div className="relative aspect-[5/3] w-full overflow-hidden bg-muted">
|
||||||
<Image
|
<Image
|
||||||
src={image}
|
src={image}
|
||||||
alt={title}
|
alt={title}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@/components/ui/accordion";
|
} from "@/components/ui/accordion";
|
||||||
import type { CollapseBlockPayload } from "@/lib/blog-blocks";
|
import type { CollapseBlockPayload } from "@/lib/blog-blocks";
|
||||||
|
import { Icon } from "@iconify/react";
|
||||||
|
|
||||||
interface BlogCollapseSectionProps {
|
interface BlogCollapseSectionProps {
|
||||||
section: CollapseBlockPayload;
|
section: CollapseBlockPayload;
|
||||||
@@ -14,6 +15,7 @@ interface BlogCollapseSectionProps {
|
|||||||
idPrefix?: string;
|
idPrefix?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function BlogCollapseSection({
|
export function BlogCollapseSection({
|
||||||
section,
|
section,
|
||||||
idPrefix = "collapse",
|
idPrefix = "collapse",
|
||||||
@@ -21,24 +23,34 @@ export function BlogCollapseSection({
|
|||||||
if (!section.items.length) return null;
|
if (!section.items.length) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="my-8 space-y-4 border rounded-md p-6">
|
<div className="my-8 space-y-4 border rounded-xl p-6">
|
||||||
{section.title ? (
|
{section.title ? (
|
||||||
<h2 className="text-2xl font-bold !mt-0 mb-4 scroll-mt-24">
|
<h2 className="text-2xl font-bold !mt-0 mb-4 scroll-mt-24">
|
||||||
{section.title}
|
{section.title}
|
||||||
</h2>
|
</h2>
|
||||||
) : null}
|
) : null}
|
||||||
<Accordion type="single" collapsible className="w-full">
|
<Accordion type="single" collapsible className="w-full gap-1">
|
||||||
{section.items.map((item, i) => (
|
{section.items.map((item, i) => (
|
||||||
<AccordionItem
|
<AccordionItem
|
||||||
key={i}
|
key={i}
|
||||||
value={`${idPrefix}-${i}`}
|
value={`${idPrefix}-${i}`}
|
||||||
className="border-b last:border-b-0 !m-0"
|
className="border-none !m-0 hover:bg-muted has-[[data-state=open]]:bg-muted px-4 rounded-md"
|
||||||
>
|
>
|
||||||
<AccordionTrigger className="text-right hover:no-underline py-4 [&>svg]:shrink-0">
|
<AccordionTrigger className="group/trigger text-right hover:no-underline cursor-pointer py-4 [&>svg]:shrink-0">
|
||||||
{item.question}
|
<span className="flex gap-1 items-center">
|
||||||
|
<Icon
|
||||||
|
icon="lucide:circle-small"
|
||||||
|
className="size-5 group-data-[state=open]/trigger:hidden"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
icon="lucide:dot"
|
||||||
|
className="size-5 hidden group-data-[state=open]/trigger:inline-block"
|
||||||
|
/>
|
||||||
|
{item.question}
|
||||||
|
</span>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
<AccordionContent className="text-muted-foreground leading-relaxed text-justify">
|
<AccordionContent className="text-muted-foreground leading-relaxed text-justify">
|
||||||
{item.answer}
|
<div dangerouslySetInnerHTML={{ __html: item.answer }} />
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -9,12 +9,67 @@ function stripHtml(html: string): string {
|
|||||||
return html.replace(/<[^>]*>/g, "").trim();
|
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 excerpt = stripHtml(post.excerpt.rendered) || stripHtml(post.content.rendered).slice(0, 160);
|
||||||
const modifiedDate = post.modified ? formatDateShamsi(post.modified) : null;
|
const modifiedDate = post.modified ? formatDateShamsi(post.modified) : null;
|
||||||
const authorName = post._embedded?.author?.[0]?.name ?? null;
|
const authorName = post._embedded?.author?.[0]?.name ?? null;
|
||||||
const thumbnailUrl = getFeaturedImageUrl(post, "medium");
|
const thumbnailUrl = getFeaturedImageUrl(post, "medium");
|
||||||
|
|
||||||
|
if (variant === "tiny") {
|
||||||
|
return (
|
||||||
|
<Link href={`/blog/${encodeURIComponent(post.slug)}`} className="block h-full">
|
||||||
|
<Card className="h-full overflow-hidden transition-all rounded-md duration-300 hover:shadow-md flex flex-col p-2">
|
||||||
|
<CardContent className="p-2 grid grid-cols-3 gap-4">
|
||||||
|
{thumbnailUrl ? (
|
||||||
|
<div className="relative rounded-md aspect-[4/3] w-full overflow-hidden bg-muted shrink-0 col-span-1">
|
||||||
|
<Image
|
||||||
|
src={thumbnailUrl}
|
||||||
|
alt=""
|
||||||
|
fill
|
||||||
|
className="object-cover"
|
||||||
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="relative rounded-md flex aspect-[4/3] w-full items-center justify-center overflow-hidden bg-muted shrink-0"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<ImageIcon className="size-8 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="col-span-2">
|
||||||
|
<h4 className="text-sm font-semibold leading-snug line-clamp-2">
|
||||||
|
{post.title.rendered}
|
||||||
|
</h4>
|
||||||
|
<div className="flex flex-wrap w-full items-center gap-x-4 gap-y-1 text-xs text-muted-foreground bg-muted inline-flex p-1 mt-2 rounded-sm">
|
||||||
|
{authorName && (
|
||||||
|
<span className="inline-flex flex-1 items-center gap-1.5">
|
||||||
|
<User className="size-3.5 shrink-0" aria-hidden />
|
||||||
|
{authorName}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{modifiedDate && (
|
||||||
|
<time dateTime={post.modified}>
|
||||||
|
{modifiedDate}
|
||||||
|
</time>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link href={`/blog/${encodeURIComponent(post.slug)}`} className="block h-full">
|
<Link href={`/blog/${encodeURIComponent(post.slug)}`} className="block h-full">
|
||||||
<Card className="h-full overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-lg pt-0 gap-1">
|
<Card className="h-full overflow-hidden transition-all duration-300 hover:-translate-y-1 hover:shadow-lg pt-0 gap-1">
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<figure className="my-10 not-prose">
|
||||||
|
<blockquote className="relative rounded-xl border border-border bg-muted/40 px-6 py-5 text-center text-foreground shadow-sm">
|
||||||
|
<div
|
||||||
|
className=""
|
||||||
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
|
/>
|
||||||
|
{citation ? (
|
||||||
|
<footer className="mt-4">
|
||||||
|
<cite className="text-sm text-muted-foreground not-italic">
|
||||||
|
— {citation}
|
||||||
|
</cite>
|
||||||
|
</footer>
|
||||||
|
) : null}
|
||||||
|
</blockquote>
|
||||||
|
</figure>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -176,7 +176,7 @@ export function BlogToc({ items, className }: BlogTocProps) {
|
|||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden lg:block shrink-0",
|
"hidden lg:block shrink-0",
|
||||||
"sticky top-24 self-start max-h-[calc(100vh-6rem)]",
|
"sticky top-24 self-start",
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
+4
-14
@@ -10,11 +10,10 @@ import { ThemeToggle } from '@/components/theme-toggle'
|
|||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ name: 'صفحه اصلی', href: '/' },
|
{ name: 'صفحه اصلی', href: '/' },
|
||||||
{ name: 'درباره ما', href: '/about' },
|
|
||||||
{ name: 'راه حلها', href: '/solutions' },
|
|
||||||
{ name: 'بلاگ', href: '/blog' },
|
{ name: 'بلاگ', href: '/blog' },
|
||||||
{ name: 'قیمتگذاری', href: '/pricing' },
|
{ name: 'راه حلها', href: '/solutions' },
|
||||||
{ name: 'وبلاگ', href: '/blog' },
|
// { name: 'قیمتگذاری', href: '/pricing' },
|
||||||
|
{ name: 'درباره ما', href: '/about' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function isLinkActive(pathname: string, href: string): boolean {
|
function isLinkActive(pathname: string, href: string): boolean {
|
||||||
@@ -104,16 +103,7 @@ export const HeroHeader = () => {
|
|||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col space-y-3 sm:flex-row sm:gap-3 sm:space-y-0 md:w-fit">
|
|
||||||
<Button
|
|
||||||
asChild
|
|
||||||
size="sm"
|
|
||||||
className={cn(isScrolled ? 'lg:inline-flex' : 'hidden', 'transition-all duration-300 hover:scale-105')}>
|
|
||||||
<Link href="tel:09162949770">
|
|
||||||
<span>تماس با فیپ</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+81
-8
@@ -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). */
|
/** Payload بلاک collapse (سوالات متداول / accordion). */
|
||||||
export interface CollapseBlockPayload {
|
export interface CollapseBlockPayload {
|
||||||
@@ -20,14 +20,21 @@ export interface CollapseBlockPayload {
|
|||||||
items: { question: string; answer: string }[];
|
items: { question: string; answer: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Payload بلاک blockquote — بعداً پیادهسازی میشود. */
|
/** Payload بلاک blockquote (نقلقول و منبع). content به صورت HTML است و باید با dangerouslySetInnerHTML رندر شود. */
|
||||||
// export interface BlockquoteBlockPayload { ... }
|
export interface BlockquoteBlockPayload {
|
||||||
|
/** HTML محتوای نقلقول (بدون تگ cite). */
|
||||||
|
content: string;
|
||||||
|
citation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** Payload بلاک code — بعداً پیادهسازی میشود. */
|
/** Payload بلاک code — بعداً پیادهسازی میشود. */
|
||||||
// export interface CodeBlockPayload { ... }
|
// export interface CodeBlockPayload { ... }
|
||||||
|
|
||||||
/** یونیون همهٔ payloadها برای تایپامن رندر. */
|
/** یونیون همهٔ payloadها برای تایپامن رندر. */
|
||||||
export type BlockPayload = CollapseBlockPayload; // | BlockquoteBlockPayload | CodeBlockPayload
|
export type BlockPayload =
|
||||||
|
| CollapseBlockPayload
|
||||||
|
| BlockquoteBlockPayload;
|
||||||
|
// | CodeBlockPayload
|
||||||
|
|
||||||
// ─── سگمنت خروجی برای لایه رندر ─────────────────────────────────────────────
|
// ─── سگمنت خروجی برای لایه رندر ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -84,6 +91,58 @@ function extractCollapsePayload(
|
|||||||
return { title, items };
|
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) فقط یک مورد جدید به این آرایه اضافه کنید.
|
* برای بلاکهای جدید (مثلاً blockquote, code) فقط یک مورد جدید به این آرایه اضافه کنید.
|
||||||
@@ -91,11 +150,24 @@ function extractCollapsePayload(
|
|||||||
export const BLOCK_DEFINITIONS: BlockDefinition[] = [
|
export const BLOCK_DEFINITIONS: BlockDefinition[] = [
|
||||||
{
|
{
|
||||||
blockType: "collapse",
|
blockType: "collapse",
|
||||||
selector: ".collpase-section",
|
selector: "div.wp-block-group.collapse-section",
|
||||||
extract: extractCollapsePayload as BlockDefinition["extract"],
|
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 },
|
// { blockType: "code", selector: "pre.wp-block-code", extract: extractCodePayload },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -142,11 +214,12 @@ export function discoverBlocks(html: string): DiscoveredContent {
|
|||||||
const blocksByType: Record<string, BlockPayload[]> = {
|
const blocksByType: Record<string, BlockPayload[]> = {
|
||||||
collapse: [],
|
collapse: [],
|
||||||
blockquote: [],
|
blockquote: [],
|
||||||
|
pullquote: [],
|
||||||
code: [],
|
code: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const def of BLOCK_DEFINITIONS) {
|
for (const def of BLOCK_DEFINITIONS) {
|
||||||
const elements = $('.collpase-section').toArray();
|
const elements = $(def.selector).toArray().filter(isElement);
|
||||||
for (const el of elements) {
|
for (const el of elements) {
|
||||||
const payload = def.extract($, el) as BlockPayload;
|
const payload = def.extract($, el) as BlockPayload;
|
||||||
const blockId = blocksByType[def.blockType].length;
|
const blockId = blocksByType[def.blockType].length;
|
||||||
|
|||||||
@@ -132,6 +132,30 @@ export async function getPostBySlug(slug: string): Promise<WpPost | null> {
|
|||||||
return posts.length > 0 ? posts[0] : null;
|
return posts.length > 0 ? posts[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getRelatedPosts(
|
||||||
|
postId: number,
|
||||||
|
categoryIds: number[],
|
||||||
|
limit = 3
|
||||||
|
): Promise<WpPost[]> {
|
||||||
|
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<WpCategory[]> {
|
export async function getCategories(): Promise<WpCategory[]> {
|
||||||
const url = `${WP_API_BASE}/categories?per_page=100`;
|
const url = `${WP_API_BASE}/categories?per_page=100`;
|
||||||
const res = await fetchWithTimeout(url);
|
const res = await fetchWithTimeout(url);
|
||||||
|
|||||||
Reference in New Issue
Block a user