From b6c7d790eac0c5e8e60e6be6b81edfc9920533db Mon Sep 17 00:00:00 2001 From: AmirZalaghi Date: Sat, 13 Jun 2026 15:46:14 +0330 Subject: [PATCH] feat: blog pages and wp data --- blog-debug.html | 0 src/api/wp.dto.ts | 49 ++++ src/api/wp.ts | 249 ++++++++++++++++ src/components/blog/ArticleCard.astro | 83 ++++++ src/components/blog/ArticleDetail.astro | 54 ++++ src/components/blog/CategoryFilter.astro | 56 ++++ src/components/ui/lucide-icon.astro | 35 +++ src/components/ui/lucide-icon.tsx | 6 + src/layouts/Layout.astro | 71 +++-- src/lib/blog-client.ts | 345 +++++++++++++++++++++++ src/lib/blog-params.ts | 52 ++++ src/lib/blog-types.ts | 36 +++ src/pages/blog.astro | 170 ++++++++++- src/pages/blog/[slug].astro | 101 +++++++ src/styles/global.css | 41 +++ 15 files changed, 1327 insertions(+), 21 deletions(-) create mode 100644 blog-debug.html create mode 100644 src/api/wp.dto.ts create mode 100644 src/api/wp.ts create mode 100644 src/components/blog/ArticleCard.astro create mode 100644 src/components/blog/ArticleDetail.astro create mode 100644 src/components/blog/CategoryFilter.astro create mode 100644 src/components/ui/lucide-icon.astro create mode 100644 src/lib/blog-client.ts create mode 100644 src/lib/blog-params.ts create mode 100644 src/lib/blog-types.ts create mode 100644 src/pages/blog/[slug].astro diff --git a/blog-debug.html b/blog-debug.html new file mode 100644 index 0000000..e69de29 diff --git a/src/api/wp.dto.ts b/src/api/wp.dto.ts new file mode 100644 index 0000000..8e7764b --- /dev/null +++ b/src/api/wp.dto.ts @@ -0,0 +1,49 @@ +export interface WPRenderedField { + rendered: string + protected?: boolean +} + +export interface WPMedia { + id: number + source_url: string + alt_text?: string +} + +export interface WPTerm { + id: number + name: string + slug: string + taxonomy: string +} + +export interface WPPost { + id: number + date: string + modified: string + slug: string + link: string + title: WPRenderedField + content: WPRenderedField + excerpt: WPRenderedField + featured_media: number + categories: number[] + tags: number[] + _embedded?: { + "wp:featuredmedia"?: WPMedia[] + "wp:term"?: WPTerm[][] + } +} + +export interface WPCategory { + id: number + name: string + slug: string + count: number +} + +export interface WPPostsResponse { + articles: import("@/lib/blog-types").BlogArticle[] + page: number + totalPages: number + total: number +} diff --git a/src/api/wp.ts b/src/api/wp.ts new file mode 100644 index 0000000..834af6f --- /dev/null +++ b/src/api/wp.ts @@ -0,0 +1,249 @@ +import type { + BlogArticle, + BlogArticleDetail, + BlogArticleSeo, + BlogCategory, +} from "@/lib/blog-types" +import type { WPCategory, WPPost, WPPostsResponse } from "./wp.dto" + +export const DOMAIN = + import.meta.env.PUBLIC_WP_API_URL ?? "https://sixthsense-massage.com" +export const API_URL = `${DOMAIN}/wp-json/wp/v2` +export const POSTS_PER_PAGE = 9 +export const SITE_NAME = "نکستا" +export const ENDPOINTS = { + POSTS: `${API_URL}/posts`, + CATEGORIES: `${API_URL}/categories`, +} as const + +export type FetchPostsOptions = { + page?: number + perPage?: number + search?: string + categoryIds?: number[] +} + +function stripHtml(html: string) { + return html + .replace(/<[^>]*>/g, " ") + .replace(/\s+/g, " ") + .trim() +} + +function decodeHtml(text: string) { + return text + .replace(/&#(\d+);/g, (_, code) => String.fromCharCode(Number(code))) + .replace(/…/g, "…") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/’/g, "'") +} + +function formatPersianDate(isoDate: string) { + return new Date(isoDate).toLocaleDateString("fa-IR", { + year: "numeric", + month: "long", + day: "numeric", + }) +} + +function estimateReadTime(contentHtml: string) { + const words = stripHtml(contentHtml).split(/\s+/).filter(Boolean).length + return Math.max(1, Math.ceil(words / 200)) +} + +function getFeaturedImageUrl(post: WPPost) { + const embedded = post._embedded?.["wp:featuredmedia"]?.[0]?.source_url + if (embedded) return embedded + + const fromContent = post.content.rendered.match(/src="([^"]+)"/)?.[1] + return fromContent ?? null +} + +function getPostCategories(post: WPPost) { + const terms = post._embedded?.["wp:term"]?.flat() ?? [] + const categories = terms + .filter((term) => term.taxonomy === "category") + .map(({ id, name }) => ({ id, name })) + + if (categories.length > 0) return categories + + return [{ id: 0, name: "بدون دسته" }] +} + +export function mapPostToArticle(post: WPPost): BlogArticle { + const slug = decodeURIComponent(post.slug) + + return { + id: post.id, + slug, + href: `/blog/${encodeURI(slug)}`, + title: decodeHtml(stripHtml(post.title.rendered)), + excerpt: decodeHtml(stripHtml(post.excerpt.rendered)), + categories: getPostCategories(post), + date: formatPersianDate(post.date), + dateIso: post.date, + readTime: estimateReadTime(post.content.rendered), + imageUrl: getFeaturedImageUrl(post), + } +} + +export function getSiteOrigin(fallbackOrigin: string) { + return (import.meta.env.PUBLIC_SITE_URL ?? fallbackOrigin).replace(/\/$/, "") +} + +function buildSeoDescription(excerptHtml: string) { + const text = decodeHtml(stripHtml(excerptHtml)) + .replace(/…$/, "") + .trim() + + if (text.length <= 160) return text + return `${text.slice(0, 157).trim()}...` +} + +function buildArticleCanonical(slug: string, siteOrigin: string) { + return `${siteOrigin}/blog/${encodeURI(slug)}` +} + +function buildArticleSeo(post: WPPost, siteOrigin: string): BlogArticleSeo { + const slug = decodeURIComponent(post.slug) + const title = decodeHtml(stripHtml(post.title.rendered)) + + return { + title, + description: buildSeoDescription(post.excerpt.rendered), + canonical: buildArticleCanonical(slug, siteOrigin), + ogImage: getFeaturedImageUrl(post), + } +} + +export function mapPostToDetail( + post: WPPost, + siteOrigin: string, +): BlogArticleDetail { + const article = mapPostToArticle(post) + + return { + ...article, + contentHtml: post.content.rendered, + modifiedIso: post.modified ?? post.date, + seo: buildArticleSeo(post, siteOrigin), + } +} +export function buildPostsUrl({ + page = 1, + perPage = POSTS_PER_PAGE, + search, + categoryIds, +}: FetchPostsOptions) { + const params = new URLSearchParams({ + page: String(page), + per_page: String(perPage), + _embed: "wp:featuredmedia,wp:term", + }) + + if (search?.trim()) params.set("search", search.trim()) + categoryIds?.forEach((id) => params.append("categories", String(id))) + + return `${ENDPOINTS.POSTS}?${params.toString()}` +} + +export async function fetchPosts( + options: FetchPostsOptions = {}, +): Promise { + const page = options.page ?? 1 + const perPage = options.perPage ?? POSTS_PER_PAGE + + const response = await fetch(buildPostsUrl({ ...options, page, perPage })) + + if (!response.ok) { + throw new Error(`Failed to fetch posts: ${response.status}`) + } + + const posts = (await response.json()) as WPPost[] + const total = Number(response.headers.get("X-WP-Total") ?? posts.length) + const totalPages = Number(response.headers.get("X-WP-TotalPages") ?? 1) + + return { + articles: posts.map(mapPostToArticle), + page, + totalPages, + total, + } +} + +export async function fetchPostBySlug(slug: string): Promise { + const decoded = decodeURIComponent(slug) + const slugCandidates = [...new Set([decoded, slug, encodeURIComponent(decoded)])] + + for (const candidate of slugCandidates) { + const params = new URLSearchParams({ + slug: candidate, + _embed: "wp:featuredmedia,wp:term", + }) + + const response = await fetch(`${ENDPOINTS.POSTS}?${params.toString()}`) + + if (!response.ok) continue + + const posts = (await response.json()) as WPPost[] + if (posts[0]) return posts[0] + } + + return null +} + +export async function fetchAllPosts(): Promise { + const posts: WPPost[] = [] + let page = 1 + let totalPages = 1 + + while (page <= totalPages) { + const params = new URLSearchParams({ + page: String(page), + per_page: "100", + _embed: "wp:featuredmedia,wp:term", + }) + + const response = await fetch(`${ENDPOINTS.POSTS}?${params.toString()}`) + + if (!response.ok) { + throw new Error(`Failed to fetch posts: ${response.status}`) + } + + const batch = (await response.json()) as WPPost[] + posts.push(...batch) + + totalPages = Number(response.headers.get("X-WP-TotalPages") ?? 1) + page += 1 + } + + return posts +} + +export async function fetchAllPostSlugs(): Promise { + const posts = await fetchAllPosts() + return posts.map((post) => decodeURIComponent(post.slug)) +} + +export async function fetchCategories(): Promise { + const params = new URLSearchParams({ + per_page: "100", + hide_empty: "true", + orderby: "count", + order: "desc", + }) + + const response = await fetch(`${ENDPOINTS.CATEGORIES}?${params.toString()}`) + + if (!response.ok) { + throw new Error(`Failed to fetch categories: ${response.status}`) + } + + const categories = (await response.json()) as WPCategory[] + + return categories.map(({ id, name, slug }) => ({ id, name, slug })) +} diff --git a/src/components/blog/ArticleCard.astro b/src/components/blog/ArticleCard.astro new file mode 100644 index 0000000..add06df --- /dev/null +++ b/src/components/blog/ArticleCard.astro @@ -0,0 +1,83 @@ +--- +import LucideIcon from "@/components/ui/lucide-icon.astro"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import type { BlogArticle } from "@/lib/blog-types"; + +interface Props { + article: BlogArticle; +} + +const { article } = Astro.props; +const categoryIds = article.categories.map((category) => category.id).join(","); +--- + +
+
+ { + article.imageUrl ? ( + + ) : ( + + +
+
+
+ { + article.categories.map((category) => ( + + {category.name} + + )) + } +
+ +
+ +

+ {article.title} +

+ +

+ {article.excerpt} +

+ +
+ + + {article.readTime} دقیقه مطالعه + + + + مطالعه مقاله + +
+
+
diff --git a/src/components/blog/ArticleDetail.astro b/src/components/blog/ArticleDetail.astro new file mode 100644 index 0000000..0179ab0 --- /dev/null +++ b/src/components/blog/ArticleDetail.astro @@ -0,0 +1,54 @@ +--- +import LucideIcon from "@/components/ui/lucide-icon.astro"; +import type { BlogArticleDetail } from "@/lib/blog-types"; + +interface Props { + article: BlogArticleDetail; +} + +const { article } = Astro.props; +--- + +
+
+
+ { + article.categories.map((category) => ( + + {category.name} + + )) + } +
+ +

+ {article.title} +

+ +
+ + + + {article.readTime} دقیقه مطالعه + +
+
+ + { + article.imageUrl && ( +
+ {article.title} +
+ ) + } + +
+
diff --git a/src/components/blog/CategoryFilter.astro b/src/components/blog/CategoryFilter.astro new file mode 100644 index 0000000..f8abb47 --- /dev/null +++ b/src/components/blog/CategoryFilter.astro @@ -0,0 +1,56 @@ +--- +import LucideIcon from "@/components/ui/lucide-icon.astro"; +import type { BlogCategory } from "@/lib/blog-types"; + +interface Props { + categories: BlogCategory[]; + fieldClass: string; + selectedIds?: number[]; + label?: string; +} + +const { categories, fieldClass, selectedIds = [], label = "دسته‌بندی‌ها" } = + Astro.props; +--- + +
+ + + +
diff --git a/src/components/ui/lucide-icon.astro b/src/components/ui/lucide-icon.astro new file mode 100644 index 0000000..cf2e346 --- /dev/null +++ b/src/components/ui/lucide-icon.astro @@ -0,0 +1,35 @@ +--- +import { cn } from "@/lib/utils"; + +interface Props { + name: "search" | "chevronDown" | "clock"; + class?: string; +} + +const { name, class: className } = Astro.props; + +const icons = { + search: [ + '', + '', + ], + chevronDown: [''], + clock: [ + '', + '', + ], +} as const; +--- + + diff --git a/src/components/ui/lucide-icon.tsx b/src/components/ui/lucide-icon.tsx index bfc0876..35e9fba 100644 --- a/src/components/ui/lucide-icon.tsx +++ b/src/components/ui/lucide-icon.tsx @@ -6,6 +6,8 @@ import { Building2, Calendar, Check, + ChevronDown, + Clock, Cloud, Dumbbell, FileText, @@ -14,6 +16,7 @@ import { Mail, MapPin, Phone, + Search, SportShoe, Star, ThumbsUp, @@ -51,6 +54,9 @@ export const landingIcons = { star: Star, check: Check, calendar: Calendar, + chevronDown: ChevronDown, + clock: Clock, + search: Search, x: X, } as const satisfies Record diff --git a/src/layouts/Layout.astro b/src/layouts/Layout.astro index 36a6166..6b7a47f 100644 --- a/src/layouts/Layout.astro +++ b/src/layouts/Layout.astro @@ -2,30 +2,65 @@ import "../styles/global.css"; import Header from "@/components/views/Header.astro"; import Footer from "@/components/views/Footer.astro"; +import { SITE_NAME } from "@/api/wp"; interface Props { - title?: string; + title?: string; + description?: string; + canonical?: string; + ogImage?: string | null; + ogType?: string; + jsonLd?: Record; } -const { title = "نکستا" } = Astro.props; +const { + title = SITE_NAME, + description, + canonical, + ogImage, + ogType = "website", + jsonLd, +} = Astro.props; + +const pageTitle = title.includes(SITE_NAME) ? title : `${title} | ${SITE_NAME}`; --- - - - - - - - - {title} - - -
-
- -
-