feat: blog section

This commit is contained in:
2026-02-05 14:54:19 +03:30
parent 5e6c788b54
commit 6dc068076d
18 changed files with 1917 additions and 93 deletions
+48
View File
@@ -0,0 +1,48 @@
"use client";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import type { CollapseBlockPayload } from "@/lib/blog-blocks";
interface BlogCollapseSectionProps {
section: CollapseBlockPayload;
/** Optional prefix for accordion item ids (e.g. for multiple sections on one page). */
idPrefix?: string;
}
export function BlogCollapseSection({
section,
idPrefix = "collapse",
}: BlogCollapseSectionProps) {
if (!section.items.length) return null;
return (
<div className="my-8 space-y-4 border rounded-md p-6">
{section.title ? (
<h2 className="text-2xl font-bold !mt-0 mb-4 scroll-mt-24">
{section.title}
</h2>
) : null}
<Accordion type="single" collapsible className="w-full">
{section.items.map((item, i) => (
<AccordionItem
key={i}
value={`${idPrefix}-${i}`}
className="border-b last:border-b-0 !m-0"
>
<AccordionTrigger className="text-right hover:no-underline py-4 [&>svg]:shrink-0">
{item.question}
</AccordionTrigger>
<AccordionContent className="text-muted-foreground leading-relaxed text-justify">
{item.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
);
}
+65
View File
@@ -0,0 +1,65 @@
import Link from "next/link";
import Image from "next/image";
import { ImageIcon, User } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { getFeaturedImageUrl, type WpPost } from "@/lib/wp-api";
import { formatDateShamsi } from "@/lib/format-date";
function stripHtml(html: string): string {
return html.replace(/<[^>]*>/g, "").trim();
}
export function BlogPostCard({ post }: { post: WpPost }) {
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");
return (
<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">
{thumbnailUrl ? (
<div className="relative aspect-video w-full overflow-hidden bg-muted">
<Image
src={thumbnailUrl}
alt=""
fill
className="object-cover"
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
/>
</div>
) : (
<div
className="relative flex aspect-video w-full items-center justify-center overflow-hidden bg-muted"
aria-hidden
>
<ImageIcon className="size-12 text-muted-foreground/50" />
</div>
)}
<CardHeader className="pb-2">
<CardTitle className="text-xl font-bold leading-tight line-clamp-2 mt-2">
{post.title.rendered}
</CardTitle>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground bg-muted inline-flex p-1 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>
</CardHeader>
<CardContent className="pt-0">
<p className="text-muted-foreground text-sm leading-relaxed line-clamp-3">
{excerpt}
</p>
</CardContent>
</Card>
</Link>
);
}
+182
View File
@@ -0,0 +1,182 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import {
Link2,
Clock,
Calendar,
User,
Share2,
Check,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
export interface BlogPostMetaProps {
/** Path (e.g. /blog/slug). Full URL is built on client for share/copy. */
url: string;
title: string;
readingTimeMinutes: number;
/** Shamsi publish date (e.g. "۱۴۰۳/۰۸/۱۵") */
date?: string | null;
/** ISO date for <time dateTime> */
dateTime?: string | null;
modifiedDate?: string | null;
authorName?: string | null;
className?: string;
}
export function BlogPostMeta({
url,
title,
readingTimeMinutes,
date,
dateTime,
modifiedDate,
authorName,
className,
}: BlogPostMetaProps) {
const [copied, setCopied] = useState(false);
const [shared, setShared] = useState(false);
const [fullUrl, setFullUrl] = useState(url);
const [canNativeShare, setCanNativeShare] = useState(false);
useEffect(() => {
if (url.startsWith("/")) {
setFullUrl(window.location.origin + url);
} else {
setFullUrl(url);
}
setCanNativeShare(Boolean(typeof navigator !== "undefined" && navigator.share));
}, [url]);
const copyLink = useCallback(async () => {
try {
await navigator.clipboard.writeText(fullUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
setCopied(false);
}
}, [fullUrl]);
const handleShare = useCallback(async () => {
if (typeof navigator !== "undefined" && navigator.share) {
try {
await navigator.share({
title,
url: fullUrl,
text: title,
});
setShared(true);
setTimeout(() => setShared(false), 2000);
} catch (err) {
if ((err as Error).name !== "AbortError") copyLink();
}
} else {
copyLink();
}
}, [title, fullUrl, copyLink]);
return (
<TooltipProvider delayDuration={300}>
<div
className={cn(
"flex flex-wrap items-center gap-x-5 gap-y-2 text-sm text-muted-foreground",
className
)}
>
{/* Reading time */}
<span className="inline-flex items-center gap-1.5">
<Clock className="size-4 shrink-0" aria-hidden />
<span>
{readingTimeMinutes === 1
? "۱ دقیقه مطالعه"
: `${readingTimeMinutes.toLocaleString("fa-IR")} دقیقه مطالعه`}
</span>
</span>
{/* Publish date */}
{date && (
<time
className="inline-flex items-center gap-1.5"
dateTime={dateTime ?? undefined}
>
<Calendar className="size-4 shrink-0" aria-hidden />
<span>انتشار: {date}</span>
</time>
)}
{/* Last updated */}
{modifiedDate && (
<span className="inline-flex items-center gap-1.5">
<Calendar className="size-4 shrink-0" aria-hidden />
<span>آخرین بروزرسانی: {modifiedDate}</span>
</span>
)}
{/* Author */}
{authorName && (
<span className="inline-flex items-center gap-1.5">
<User className="size-4 shrink-0" aria-hidden />
<span>{authorName}</span>
</span>
)}
{/* Divider + actions */}
<span className="mr-auto inline-flex items-center gap-1">
<span
className="hidden sm:inline h-4 w-px bg-border ml-2"
aria-hidden
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="size-8 text-muted-foreground hover:text-foreground"
onClick={copyLink}
aria-label="کپی لینک"
>
{copied ? (
<Check className="size-4 text-green-600" />
) : (
<Link2 className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{copied ? "کپی شد" : "کپی لینک"}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-sm"
className="size-8 text-muted-foreground hover:text-foreground"
onClick={handleShare}
aria-label="اشتراک‌گذاری"
>
{shared ? (
<Check className="size-4 text-green-600" />
) : (
<Share2 className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
{shared ? "اشتراک گذاشته شد" : canNativeShare ? "اشتراک‌گذاری" : "کپی لینک"}
</TooltipContent>
</Tooltip>
</span>
</div>
</TooltipProvider>
);
}
+222
View File
@@ -0,0 +1,222 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { ChevronDown, List } from "lucide-react";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ScrollArea } from "@/components/ui/scroll-area";
import { cn } from "@/lib/utils";
import type { TocItem } from "@/lib/blog-utils";
interface TocGroup {
heading: TocItem;
children: TocItem[];
}
function groupByLevel2(items: TocItem[]): TocGroup[] {
const groups: TocGroup[] = [];
let current: TocGroup | null = null;
for (const item of items) {
if (item.level === 2) {
current = { heading: item, children: [] };
groups.push(current);
} else if (current) {
current.children.push(item);
}
}
return groups;
}
interface BlogTocProps {
items: TocItem[];
className?: string;
}
const tocLinkClass = (
level: number,
isActive: boolean
) =>
cn(
"block rounded-md border-r-2 rounded-s-none border-transparent py-1.5 text-sm transition-colors hover:text-foreground hover:bg-muted/60",
level === 2 && "pr-3",
level === 3 && "pr-5",
level === 4 && "pr-7",
isActive
? "font-medium text-foreground border-primary bg-muted/50"
: "text-muted-foreground"
);
export function BlogToc({ items, className }: BlogTocProps) {
const [activeId, setActiveId] = useState<string | null>(items[0]?.id ?? null);
const [mobileOpen, setMobileOpen] = useState(false);
const [openSections, setOpenSections] = useState<Set<string>>(() => new Set());
const groups = useMemo(() => groupByLevel2(items), [items]);
useEffect(() => {
setOpenSections(new Set(groups.map((g) => g.heading.id)));
}, [groups]);
useEffect(() => {
if (items.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = entry.target.getAttribute("id");
if (id) setActiveId(id);
}
}
},
{ rootMargin: "-80px 0% -70% 0%", threshold: 0 }
);
items.forEach(({ id }) => {
const el = document.getElementById(id);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, [items]);
useEffect(() => {
if (activeId && groups.length > 0) {
const group = groups.find(
(g) => g.heading.id === activeId || g.children.some((c) => c.id === activeId)
);
if (group) {
setOpenSections((prev) => new Set(prev).add(group.heading.id));
}
}
}, [activeId, groups]);
if (items.length === 0) return null;
const renderTocLink = (item: TocItem, onNav?: () => void) => (
<Link
key={item.id}
href={`#${item.id}`}
onClick={() => {
setMobileOpen(false);
onNav?.();
}}
className={tocLinkClass(item.level, activeId === item.id)}
>
<span className="line-clamp-2">{item.text}</span>
</Link>
);
const tocList = (
<nav aria-label="فهرست مطالب" className="space-y-1">
{groups.map(({ heading, children }) => {
const hasChildren = children.length > 0;
const isOpen = openSections.has(heading.id);
if (!hasChildren) {
return renderTocLink(heading);
}
return (
<Collapsible
key={heading.id}
open={isOpen}
onOpenChange={(open) => {
setOpenSections((prev) => {
const next = new Set(prev);
if (open) next.add(heading.id);
else next.delete(heading.id);
return next;
});
}}
>
<div
className={cn(
"flex items-center gap-0.5 rounded-md border-r-2 rounded-s-none py-1.5 pr-3",
activeId === heading.id ? "border-primary bg-muted/50" : "border-transparent"
)}
>
<Link
href={`#${heading.id}`}
onClick={() => setMobileOpen(false)}
className={cn(
"min-w-0 flex-1 text-sm transition-colors hover:text-foreground hover:bg-muted/60 rounded-md py-0.5 -my-0.5",
activeId === heading.id ? "font-medium text-foreground" : "text-muted-foreground"
)}
>
<span className="line-clamp-2">{heading.text}</span>
</Link>
<CollapsibleTrigger
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-muted/60 hover:text-foreground transition-colors"
aria-label={isOpen ? "بستن" : "باز کردن"}
>
<ChevronDown
className={cn("size-4 transition-transform", isOpen && "rotate-180")}
/>
</CollapsibleTrigger>
</div>
<CollapsibleContent>
<div className="space-y-1 pt-0.5 pr-2 border-r border-border/50">
{children.map((item) => renderTocLink(item))}
</div>
</CollapsibleContent>
</Collapsible>
);
})}
</nav>
);
return (
<>
{/* Desktop: sticky sidebar left */}
<aside
className={cn(
"hidden lg:block shrink-0",
"sticky top-24 self-start max-h-[calc(100vh-6rem)]",
className
)}
>
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
فهرست مطالب
</p>
<ScrollArea className="h-full pb-8" dir="rtl">
{tocList}
</ScrollArea>
</aside>
{/* Mobile: collapsible, sticky when scrolling */}
<Collapsible
open={mobileOpen}
onOpenChange={setMobileOpen}
className={cn(
"lg:hidden w-full sticky top-20 z-10 bg-background",
className
)}
>
<CollapsibleTrigger
className={cn(
"flex w-full items-center justify-between gap-2 rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm font-medium transition-colors hover:bg-muted/50",
mobileOpen && "rounded-b-none border-b-0"
)}
>
<span className="flex items-center gap-2">
<List className="size-4 text-muted-foreground" />
فهرست مطالب
</span>
<ChevronDown
className={cn("size-4 text-muted-foreground transition-transform", mobileOpen && "rotate-180")}
/>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="max-h-64 overflow-y-auto rounded-b-lg border border-t-0 border-border bg-muted/20 p-3">
{tocList}
</div>
</CollapsibleContent>
</Collapsible>
</>
);
}
+1 -3
View File
@@ -16,9 +16,7 @@ export default function FooterSection() {
return (
<footer className="py-16 md:py-32">
<div className="mx-auto max-w-5xl px-6">
<Link href="/" aria-label="go home" className="mx-auto block size-fit">
<Logo />
</Link>
<Logo />
<div className="my-8 flex flex-wrap justify-center gap-6 text-sm">
{links.map((link, index) => (
+47 -27
View File
@@ -1,5 +1,6 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Logo } from '@/components/logo'
import { Menu, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
@@ -11,10 +12,17 @@ const menuItems = [
{ name: 'صفحه اصلی', href: '/' },
{ name: 'درباره ما', href: '/about' },
{ name: 'راه حل‌ها', href: '/solutions' },
{ name: 'بلاگ', href: '/blog' },
{ name: 'قیمت‌گذاری', href: '/pricing' },
]
function isLinkActive(pathname: string, href: string): boolean {
if (href === '/') return pathname === '/'
return pathname === href || pathname.startsWith(href + '/')
}
export const HeroHeader = () => {
const pathname = usePathname()
const [menuState, setMenuState] = React.useState(false)
const [isScrolled, setIsScrolled] = React.useState(false)
@@ -33,13 +41,7 @@ export const HeroHeader = () => {
<div className={cn('mx-auto mt-2 max-w-6xl px-6 transition-all duration-300 lg:px-12 bg-background/80 backdrop-blur-md rounded-2xl border border-border/50', isScrolled && 'bg-background/95 max-w-4xl shadow-lg lg:px-5')}>
<div className="relative flex flex-wrap items-center justify-between gap-6 py-2 lg:gap-0 lg:py-3">
<div className="flex w-full justify-between lg:w-auto">
<Link
href="/"
aria-label="home"
className="flex items-center space-x-2">
<Logo />
</Link>
<Logo />
<button
onClick={() => setMenuState(!menuState)}
aria-label={menuState == true ? 'بستن منو' : 'باز کردن منو'}
@@ -51,16 +53,25 @@ export const HeroHeader = () => {
<div className="absolute inset-0 m-auto hidden size-fit lg:flex items-center gap-8">
<ul className="flex gap-8 text-sm">
{menuItems.map((item, index) => (
<li key={index}>
<Link
href={item.href}
className="text-muted-foreground hover:text-foreground transition-all duration-300 relative group">
<span className="relative z-10">{item.name}</span>
<span className="absolute bottom-0 right-0 w-0 h-0.5 bg-accent transition-all duration-300 group-hover:w-full"></span>
</Link>
</li>
))}
{menuItems.map((item, index) => {
const active = isLinkActive(pathname ?? '', item.href)
return (
<li key={index}>
<Link
href={item.href}
className={cn(
'text-muted-foreground hover:text-foreground transition-all duration-300 relative group',
active && 'text-foreground'
)}>
<span className="relative z-10">{item.name}</span>
<span className={cn(
'absolute bottom-0 right-0 h-0.5 bg-accent transition-all duration-300 group-hover:w-full',
active ? 'w-full' : 'w-0'
)} />
</Link>
</li>
)
})}
</ul>
<ThemeToggle />
</div>
@@ -68,16 +79,25 @@ export const HeroHeader = () => {
<div className="bg-background in-data-[state=active]:block lg:in-data-[state=active]:flex mb-6 hidden w-full flex-wrap items-center justify-end space-y-8 rounded-3xl border p-6 shadow-2xl shadow-zinc-300/20 md:flex-nowrap lg:m-0 lg:flex lg:w-fit lg:gap-6 lg:space-y-0 lg:border-transparent lg:bg-transparent lg:p-0 lg:shadow-none dark:shadow-none dark:lg:bg-transparent">
<div className="lg:hidden w-full">
<ul className="space-y-6 text-base">
{menuItems.map((item, index) => (
<li key={index}>
<Link
href={item.href}
className="text-muted-foreground hover:text-foreground transition-all duration-300 relative group block">
<span className="relative z-10">{item.name}</span>
<span className="absolute bottom-0 right-0 w-0 h-0.5 bg-accent transition-all duration-300 group-hover:w-full"></span>
</Link>
</li>
))}
{menuItems.map((item, index) => {
const active = isLinkActive(pathname ?? '', item.href)
return (
<li key={index}>
<Link
href={item.href}
className={cn(
'text-muted-foreground hover:text-foreground transition-all duration-300 relative group block',
active && 'text-foreground'
)}>
<span className="relative z-10">{item.name}</span>
<span className={cn(
'absolute bottom-0 right-0 h-0.5 bg-accent transition-all duration-300 group-hover:w-full',
active ? 'w-full' : 'w-0'
)} />
</Link>
</li>
)
})}
</ul>
<div className="mt-6 flex justify-end">
<ThemeToggle />
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useEffect, useState } from "react";
import { ChevronUp } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const SCROLL_THRESHOLD = 0.4; // show after 40% scroll
export function ScrollToTop({ className }: { className?: string }) {
const [visible, setVisible] = useState(false);
useEffect(() => {
function onScroll() {
const maxScroll =
document.documentElement.scrollHeight - window.innerHeight;
if (maxScroll <= 0) {
setVisible(false);
return;
}
const ratio = window.scrollY / maxScroll;
setVisible(ratio >= SCROLL_THRESHOLD);
}
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" });
};
if (!visible) return null;
return (
<div
className={cn(
"fixed bottom-6 start-6 z-50 transition-opacity duration-300",
className
)}
>
<Button
size="icon"
variant="outline"
onClick={scrollToTop}
aria-label="برگشت به بالا"
className="size-10 rounded-md shadow-md"
>
<ChevronUp className="size-5" />
</Button>
</div>
);
}