diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/app/blog/[slug]/page.tsx b/app/blog/[slug]/page.tsx new file mode 100644 index 0000000..d8b483e --- /dev/null +++ b/app/blog/[slug]/page.tsx @@ -0,0 +1,165 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import Image from "next/image"; +import { notFound } from "next/navigation"; +import { getPostBySlug, getFeaturedImageUrl, getRelatedPosts } from "@/lib/wp-api"; +import { formatDateShamsi } from "@/lib/format-date"; +import { ChevronRight } from "lucide-react"; +import type { CollapseBlockPayload, BlockquoteBlockPayload, TipBlockPayload } from "@/lib/blog-blocks"; +import { parseContentHeadings, getReadingTimeMinutes, formatAuthorName } 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 { BlogTip } from "@/components/blog-tip"; +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 }>; +} + +export async function generateMetadata({ + params, +}: BlogPostPageProps): Promise { + const { slug } = await params; + const post = await getPostBySlug(slug); + if (!post) return { title: "پست یافت نشد" }; + const title = post.title?.rendered ?? "پست"; + const excerpt = post.excerpt?.rendered?.replace(/<[^>]*>/g, "").trim().slice(0, 160) ?? ""; + return { + title, + description: excerpt, + }; +} + +export default async function BlogPostPage({ params }: BlogPostPageProps) { + const { slug } = await params; + let post; + try { + post = await getPostBySlug(slug); + } catch { + notFound(); + } + 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) + : null; + const authorName = formatAuthorName(post._embedded?.author?.[0]?.name); + const thumbnailUrl = getFeaturedImageUrl(post, "full"); + const media = post._embedded?.["wp:featuredmedia"]?.[0]; + const altText = media?.alt_text || post.title?.rendered?.replace(/<[^>]*>/g, "") || ""; + + const { toc, segments } = parseContentHeadings(post.content.rendered); + const readingTimeMinutes = getReadingTimeMinutes(post.content.rendered); + const postTitle = post.title?.rendered?.replace(/<[^>]*>/g, "") ?? ""; + const postPath = `/blog/${slug}`; + + return ( + <> + {/* تصویر شاخص تمام‌عرض بالای صفحه */} + {thumbnailUrl && ( +
+
+ {altText} +
+
+ )} + +
+
+ {/* Single grid so sticky TOC stays for whole page scroll */} +
+ + +
+
+ +

+ +

+ +
+ {segments.map((seg, i) => + seg.type === "html" ? ( +
+ ) : seg.blockType === "collapse" ? ( + + ) : seg.blockType === "blockquote" ? ( + + ) : seg.blockType === "tip" ? ( + + ) : ( + null + ) + )} +
+
+
+
+
+ + {relatedPosts.length > 0 && ( +
+
+

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

+
+ {relatedPosts.map((p) => ( + + ))} +
+
+
+ )} + + + + ); +} \ No newline at end of file diff --git a/app/blog/page.tsx b/app/blog/page.tsx new file mode 100644 index 0000000..ff2de81 --- /dev/null +++ b/app/blog/page.tsx @@ -0,0 +1,204 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +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, Search, X } from "lucide-react"; + +const PER_PAGE = 9; + +export const metadata: Metadata = { + title: "بلاگ", + description: "مقالات و مطالب فیپ در زمینه یکپارچه‌سازی، اتوماسیون و بهره‌وری سازمانی.", +}; + +interface BlogPageProps { + searchParams: Promise<{ search?: string; category?: string; page?: string }>; +} + +export default async function BlogPage({ searchParams }: BlogPageProps) { + const params = await searchParams; + const search = params.search ?? ""; + const categoryId = params.category ? parseInt(params.category, 10) : undefined; + const page = Math.max(1, parseInt(params.page ?? "1", 10) || 1); + + let postsResult; + let categories: Awaited> = []; + + try { + [postsResult, categories] = await Promise.all([ + getPosts({ + page, + perPage: PER_PAGE, + search: search || undefined, + categoryId: Number.isFinite(categoryId) ? categoryId : undefined, + }), + getCategories(), + ]); + } catch (e) { + return ( +
+
+

بلاگ

+

+ در حال حاضر امکان بارگذاری مطالب وجود ندارد. لطفاً بعداً تلاش کنید. +

+
+
+ ); + } + + const { posts, total, totalPages } = postsResult; + const hasFilters = search || categoryId; + + function buildQuery(updates: { search?: string; category?: string; page?: number }) { + const q = new URLSearchParams(); + if (updates.search !== undefined && updates.search !== "") q.set("search", updates.search); + if (updates.category !== undefined && updates.category !== "") q.set("category", updates.category); + if (updates.page !== undefined && updates.page > 1) q.set("page", String(updates.page)); + const s = q.toString(); + return s ? `?${s}` : ""; + } + + return ( + <> + {/* Hero + search & filters, compact */} +
+
+
+
+

+ بلاگ +

+

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

+
+ +
+
+ +
+ + +
+ +
+ {hasFilters && ( + + + پاک کردن فیلتر + + )} + + {categories.length > 0 && ( +
+ + همه + + {categories.map((cat) => ( + + {cat.name} + + ))} +
+ )} +
+
+
+
+ +
+
+ {posts.length === 0 ? ( +
+

مطلبی یافت نشد.

+ + + +
+ ) : ( + <> +
    + {posts.map((post) => ( +
  • + +
  • + ))} +
+ + {totalPages > 1 && ( + + )} + + )} +
+
+ + ); +} diff --git a/app/favicon.ico b/app/favicon.ico index 718d6fe..7a4685b 100644 Binary files a/app/favicon.ico and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css index ca42454..5f16bc1 100644 --- a/app/globals.css +++ b/app/globals.css @@ -122,74 +122,77 @@ :root { --font-iran-sans: "IRANSansX", var(--font-geist-sans); --radius: 0.625rem; - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: #001BB7; + --card: oklch(1 0.006 175); + --card-foreground: oklch(0.145 0.02 175); + --popover: oklch(1 0.006 175); + --popover-foreground: oklch(0.145 0.02 175); + --primary: #01796F; --primary-foreground: oklch(0.985 0 0); - --secondary: #0046FF; + --secondary: #0D9488; --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.97 0.01 280); - --muted-foreground: oklch(0.35 0.05 250); + --muted: oklch(0.97 0.012 175); + --muted-foreground: oklch(0.35 0.05 175); --accent: #FCC61D; --accent-foreground: oklch(0.25 0.05 50); --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.9 0.01 280); - --input: oklch(0.95 0.01 280); - --ring: oklch(0.5 0.15 220); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); + --border: oklch(0.9 0.012 175); + --input: oklch(0.95 0.01 175); + --ring: oklch(0.45 0.1 175); + --chart-1: oklch(0.45 0.1 175); + --chart-2: oklch(0.6 0.12 162); + --chart-3: oklch(0.55 0.08 175); + --chart-4: oklch(0.7 0.1 170); + --chart-5: oklch(0.5 0.09 180); + --sidebar: oklch(0.985 0.008 175); + --sidebar-foreground: oklch(0.145 0.02 175); + --sidebar-primary: #01796F; --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); - --background: oklch(0.99 0.005 280); - --foreground: oklch(0.25 0.05 250); + --sidebar-accent: oklch(0.97 0.015 175); + --sidebar-accent-foreground: oklch(0.205 0.02 175); + --sidebar-border: oklch(0.922 0.01 175); + --sidebar-ring: oklch(0.45 0.1 175); + --background: oklch(0.99 0.008 175); + --foreground: oklch(0.25 0.03 175); } .dark { - --background: oklch(0.145 0.01 280); + --background: oklch(0.145 0.02 175); --foreground: oklch(0.95 0.01 280); - --card: oklch(0.22 0.02 280); + --card: oklch(0.22 0.025 175); --card-foreground: oklch(0.95 0.01 280); - --popover: oklch(0.22 0.02 280); + --popover: oklch(0.22 0.025 175); --popover-foreground: oklch(0.95 0.01 280); - --primary: #001BB7; + --primary: #01796F; --primary-foreground: oklch(0.985 0 0); - --secondary: #0046FF; - --secondary-foreground: oklch(0.95 0.01 280); - --muted: oklch(0.28 0.02 280); - --muted-foreground: oklch(0.75 0.02 280); + --secondary: #14B8A6; + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.28 0.025 175); + --muted-foreground: oklch(0.75 0.02 175); --accent: #FCC61D; --accent-foreground: oklch(0.2 0.05 50); --destructive: oklch(0.704 0.191 22.216); - --border: oklch(0.3 0.02 280); - --input: oklch(0.3 0.02 280); - --ring: oklch(0.5 0.15 220); - --chart-1: oklch(0.488 0.243 264.376); - --chart-2: oklch(0.696 0.17 162.48); - --chart-3: oklch(0.769 0.188 70.08); - --chart-4: oklch(0.627 0.265 303.9); - --chart-5: oklch(0.645 0.246 16.439); - --sidebar: oklch(0.22 0.02 280); + --border: oklch(0.3 0.025 175); + --input: oklch(0.3 0.025 175); + --ring: oklch(0.5 0.12 175); + --chart-1: oklch(0.5 0.12 175); + --chart-2: oklch(0.6 0.14 162); + --chart-3: oklch(0.55 0.1 175); + --chart-4: oklch(0.65 0.11 170); + --chart-5: oklch(0.45 0.09 180); + --sidebar: oklch(0.22 0.025 175); --sidebar-foreground: oklch(0.95 0.01 280); - --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary: #01796F; --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.3 0.02 280); + --sidebar-accent: oklch(0.3 0.03 175); --sidebar-accent-foreground: oklch(0.95 0.01 280); - --sidebar-border: oklch(0.3 0.02 280); - --sidebar-ring: oklch(0.5 0.15 220); + --sidebar-border: oklch(0.3 0.025 175); + --sidebar-ring: oklch(0.5 0.12 175); } @layer base { + html { + scroll-behavior: smooth; + } * { @apply border-border outline-ring/50; } @@ -199,17 +202,17 @@ letter-spacing: normal; line-height: 1.6; background-image: - radial-gradient(at 0% 0%, oklch(0.98 0.01 280 / 0.3) 0px, transparent 50%), - radial-gradient(at 100% 100%, oklch(0.95 0.02 220 / 0.2) 0px, transparent 50%), - radial-gradient(at 50% 50%, oklch(0.97 0.01 300 / 0.15) 0px, transparent 50%); + radial-gradient(at 0% 0%, oklch(0.98 0.012 175 / 0.3) 0px, transparent 50%), + radial-gradient(at 100% 100%, oklch(0.95 0.02 175 / 0.2) 0px, transparent 50%), + radial-gradient(at 50% 50%, oklch(0.97 0.01 170 / 0.15) 0px, transparent 50%); background-attachment: fixed; transition: background-color 0.3s ease; } .dark body { background-image: - radial-gradient(at 0% 0%, oklch(0.2 0.02 280 / 0.3) 0px, transparent 50%), - radial-gradient(at 100% 100%, oklch(0.18 0.02 220 / 0.2) 0px, transparent 50%), - radial-gradient(at 50% 50%, oklch(0.22 0.02 300 / 0.15) 0px, transparent 50%); + radial-gradient(at 0% 0%, oklch(0.2 0.025 175 / 0.3) 0px, transparent 50%), + radial-gradient(at 100% 100%, oklch(0.18 0.02 175 / 0.2) 0px, transparent 50%), + radial-gradient(at 50% 50%, oklch(0.22 0.025 170 / 0.15) 0px, transparent 50%); } h1, h2, h3, h4, h5, h6 { letter-spacing: normal; @@ -228,3 +231,68 @@ 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; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 9dddbe4..1714c82 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -2,12 +2,16 @@ import type { Metadata } from "next"; import "./globals.css"; import { ConditionalLayout } from "@/components/conditional-layout"; import { ThemeProvider } from "@/components/theme-provider"; +import { TopProgressBar } from "@/components/top-progress-bar"; export const metadata: Metadata = { title: "فیپ", description: "فیپ یک سامانه برای ایجاد و طراحی راهکارهای مناسب برای کسب و کار شما است.", + icons: { + icon: "/assets/feep-Logo.svg", + }, }; export default function RootLayout({ @@ -21,6 +25,7 @@ export default function RootLayout({ className={`antialiased`} > + {children} 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 new file mode 100644 index 0000000..925a7fc --- /dev/null +++ b/components/blog-card.tsx @@ -0,0 +1,74 @@ +import Link from "next/link"; +import Image from "next/image"; +import { Calendar, Clock, ArrowLeft } from "lucide-react"; +import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card"; + +interface BlogCardProps { + slug: string; + title: string; + excerpt: string; + date: string; + readTime: string; + category: string; + image: string; +} + +export function BlogCard({ + slug, + title, + excerpt, + date, + readTime, + category, + image, +}: BlogCardProps) { + return ( + + + {/* Image with 3:2 aspect ratio */} +
+ {title} + {/* Category Badge */} +
+ + {category} + +
+
+ + +

+ {title} +

+
+ + +

+ {excerpt} +

+
+ + +
+ + {date} +
+
+ + {readTime} دقیقه +
+
+
+ + ); +} + + + diff --git a/components/blog-collapse-section.tsx b/components/blog-collapse-section.tsx new file mode 100644 index 0000000..f589956 --- /dev/null +++ b/components/blog-collapse-section.tsx @@ -0,0 +1,60 @@ +"use client"; + +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import type { CollapseBlockPayload } from "@/lib/blog-blocks"; +import { Icon } from "@iconify/react"; + +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 ( +
+ {section.title ? ( +

+ {section.title} +

+ ) : null} + + {section.items.map((item, i) => ( + + + + + + {item.question} + + + +
+ + + ))} + +
+ ); +} diff --git a/components/blog-post-card.tsx b/components/blog-post-card.tsx new file mode 100644 index 0000000..f1b35bc --- /dev/null +++ b/components/blog-post-card.tsx @@ -0,0 +1,122 @@ +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(); +} + +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, "full"); + + if (variant === "tiny") { + return ( + + + + {thumbnailUrl ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+

+ {post.title.rendered} +

+
+ {authorName && ( + + + {authorName} + + )} + {modifiedDate && ( + + )} +
+
+
+
+ + ); + } + + return ( + + + {thumbnailUrl ? ( +
+ +
+ ) : ( +
+ +
+ )} + + + {post.title.rendered} + +
+ {authorName && ( + + + {authorName} + + )} + {modifiedDate && ( + + )} +
+
+ +

+ {excerpt} +

+
+
+ + ); +} diff --git a/components/blog-post-meta.tsx b/components/blog-post-meta.tsx new file mode 100644 index 0000000..00a956e --- /dev/null +++ b/components/blog-post-meta.tsx @@ -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