feat: blog pages and wp data

This commit is contained in:
2026-06-13 15:46:14 +03:30
parent e802abb12f
commit b6c7d790ea
15 changed files with 1327 additions and 21 deletions

0
blog-debug.html Normal file
View File

49
src/api/wp.dto.ts Normal file
View File

@@ -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
}

249
src/api/wp.ts Normal file
View File

@@ -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(/&hellip;/g, "…")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#8217;/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<WPPostsResponse> {
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<WPPost | null> {
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<WPPost[]> {
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<string[]> {
const posts = await fetchAllPosts()
return posts.map((post) => decodeURIComponent(post.slug))
}
export async function fetchCategories(): Promise<BlogCategory[]> {
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 }))
}

View File

@@ -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
class="blog-card group flex flex-col overflow-hidden rounded-2xl border border-transparent bg-card transition-colors hover:border-secondary/60"
data-categories={categoryIds}
data-title={article.title}
data-excerpt={article.excerpt}
>
<div class="relative aspect-[16/10] overflow-hidden">
{
article.imageUrl ? (
<img
src={article.imageUrl}
alt=""
class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
decoding="async"
/>
) : (
<div class="h-full w-full bg-muted" aria-hidden="true" />
)
}
</div>
<div class="flex flex-1 flex-col gap-3 p-5">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 flex-wrap gap-1.5">
{
article.categories.map((category) => (
<span class="rounded-full bg-background/70 px-3 py-1 text-xs font-medium text-primary backdrop-blur-sm">
{category.name}
</span>
))
}
</div>
<time
class="shrink-0 text-xs text-muted-foreground"
datetime={article.dateIso}
>
{article.date}
</time>
</div>
<h2 class="text-base font-bold leading-relaxed text-foreground">
{article.title}
</h2>
<p
class="line-clamp-3 flex-1 text-sm leading-relaxed text-muted-foreground"
>
{article.excerpt}
</p>
<div class="mt-2 flex items-center justify-between gap-3">
<span class="flex items-center gap-1.5 text-xs text-muted-foreground">
<LucideIcon name="clock" class="size-3.5" />
{article.readTime} دقیقه مطالعه
</span>
<a
href={article.href}
class={cn(
buttonVariants({ size: "sm" }),
"rounded-full px-4 py-1.5 text-xs",
)}
>
مطالعه مقاله
</a>
</div>
</div>
</article>

View File

@@ -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 class="mx-auto max-w-3xl">
<header class="space-y-5 text-center">
<div class="flex flex-wrap items-center justify-center gap-2">
{
article.categories.map((category) => (
<span class="rounded-full bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
{category.name}
</span>
))
}
</div>
<h1 class="text-3xl font-bold leading-tight text-foreground md:text-4xl">
{article.title}
</h1>
<div
class="flex flex-wrap items-center justify-center gap-4 text-sm text-muted-foreground"
>
<time datetime={article.dateIso}>{article.date}</time>
<span class="flex items-center gap-1.5">
<LucideIcon name="clock" class="size-4" />
{article.readTime} دقیقه مطالعه
</span>
</div>
</header>
{
article.imageUrl && (
<div class="mt-8 overflow-hidden rounded-2xl">
<img
src={article.imageUrl}
alt={article.title}
class="aspect-[16/10] w-full object-cover"
loading="eager"
decoding="async"
/>
</div>
)
}
<div class="wp-content mt-10" set:html={article.contentHtml} />
</article>

View File

@@ -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;
---
<div class="relative shrink-0 sm:w-52" id="blog-category-filter">
<button
type="button"
id="blog-category-trigger"
class:list={[
fieldClass,
"flex w-full px-4 cursor-pointer items-center justify-between gap-2 text-start",
]}
aria-haspopup="listbox"
aria-expanded="false"
>
<span id="blog-category-label" class="truncate">{label}</span>
<LucideIcon name="chevronDown" class="size-4 shrink-0 text-muted-foreground" />
</button>
<div
id="blog-category-panel"
class="absolute top-full px-10 z-20 mt-2 hidden max-h-60 w-full overflow-y-auto rounded-2xl border border-border bg-card p-2 shadow-lg"
role="listbox"
aria-multiselectable="true"
>
{
categories.length === 0 ? (
<p class="px-3 py-2 text-sm text-muted-foreground">دسته‌ای یافت نشد</p>
) : (
categories.map((category) => (
<label class="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors hover:bg-muted">
<input
type="checkbox"
class="size-4 shrink-0 accent-primary"
value={category.id}
data-category-checkbox
checked={selectedIds.includes(category.id)}
/>
<span class="min-w-0 truncate">{category.name}</span>
</label>
))
)
}
</div>
</div>

View File

@@ -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: [
'<circle cx="11" cy="11" r="8"/>',
'<path d="m21 21-4.34-4.34"/>',
],
chevronDown: ['<path d="m6 9 6 6 6-6"/>'],
clock: [
'<circle cx="12" cy="12" r="10"/>',
'<path d="M12 6v6l4 2"/>',
],
} as const;
---
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class={cn("size-4 shrink-0", className)}
aria-hidden="true"
set:html={icons[name].join("")}
/>

View File

@@ -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<string, LucideIconComponent>

View File

@@ -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<string, unknown>;
}
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}`;
---
<!doctype html>
<html lang="fa" dir="rtl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/font/font.css" />
<meta name="generator" content={Astro.generator} />
<title>{title}</title>
</head>
<body>
<Header />
<main>
<slot />
</main>
<Footer />
</body>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<link rel="icon" href="/favicon.ico" />
<link rel="stylesheet" href="/font/font.css" />
<meta name="generator" content={Astro.generator} />
<title>{pageTitle}</title>
{description && <meta name="description" content={description} />}
{canonical && <link rel="canonical" href={canonical} />}
<meta property="og:locale" content="fa_IR" />
<meta property="og:site_name" content={SITE_NAME} />
<meta property="og:title" content={pageTitle} />
{description && <meta property="og:description" content={description} />}
{canonical && <meta property="og:url" content={canonical} />}
<meta property="og:type" content={ogType} />
{ogImage && <meta property="og:image" content={ogImage} />}
<meta name="twitter:card" content="summary_large_image" />
{description && <meta name="twitter:description" content={description} />}
{ogImage && <meta name="twitter:image" content={ogImage} />}
{
jsonLd && (
<script
type="application/ld+json"
set:html={JSON.stringify(jsonLd)}
/>
)
}
</head>
<body>
<Header />
<main>
<slot />
</main>
<Footer />
</body>
</html>

345
src/lib/blog-client.ts Normal file
View File

@@ -0,0 +1,345 @@
import {
POSTS_PER_PAGE,
buildPostsUrl,
mapPostToArticle,
type FetchPostsOptions,
} from "@/api/wp"
import {
buildBlogUrl,
filtersAreEqual,
parseBlogSearchParams,
readSsrFilters,
type BlogFilters,
} from "@/lib/blog-params"
import type { BlogArticle } from "@/lib/blog-types"
import type { WPPost } from "@/api/wp.dto"
export { POSTS_PER_PAGE, mapPostToArticle }
export type ClientPostsResult = {
articles: BlogArticle[]
page: number
totalPages: number
}
const SEARCH_DEBOUNCE_MS = 400
export async function fetchPostsClient(
options: FetchPostsOptions,
): Promise<ClientPostsResult> {
const response = await fetch(buildPostsUrl(options))
if (!response.ok) {
throw new Error(`Failed to fetch posts: ${response.status}`)
}
const posts = (await response.json()) as WPPost[]
return {
articles: posts.map(mapPostToArticle),
page: options.page ?? 1,
totalPages: Number(response.headers.get("X-WP-TotalPages") ?? 1),
}
}
const clockIcon = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-3.5 shrink-0" aria-hidden="true"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>`
function escapeHtml(value: string) {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
}
function renderCategoryBadges(categories: BlogArticle["categories"]) {
return categories
.map(
(category) =>
`<span class="rounded-full bg-background/70 px-3 py-1 text-xs font-medium text-primary backdrop-blur-sm">${escapeHtml(category.name)}</span>`,
)
.join("")
}
export function renderArticleCard(article: BlogArticle) {
const image = article.imageUrl
? `<img src="${escapeHtml(article.imageUrl)}" alt="" class="h-full w-full object-cover transition-transform duration-300 group-hover:scale-105" loading="lazy" decoding="async" />`
: `<div class="h-full w-full bg-muted" aria-hidden="true"></div>`
const categoryIds = article.categories.map((category) => category.id).join(",")
return `
<article
class="blog-card group flex flex-col overflow-hidden rounded-2xl border border-transparent bg-card transition-colors hover:border-secondary/60"
data-categories="${categoryIds}"
data-title="${escapeHtml(article.title)}"
data-excerpt="${escapeHtml(article.excerpt)}"
>
<div class="relative aspect-[16/10] overflow-hidden">${image}</div>
<div class="flex flex-1 flex-col gap-3 p-5">
<div class="flex items-start justify-between gap-3">
<div class="flex min-w-0 flex-wrap gap-1.5">${renderCategoryBadges(article.categories)}</div>
<time class="shrink-0 text-xs text-muted-foreground" datetime="${escapeHtml(article.dateIso)}">${escapeHtml(article.date)}</time>
</div>
<h2 class="text-base font-bold leading-relaxed text-foreground">${escapeHtml(article.title)}</h2>
<p class="line-clamp-3 flex-1 text-sm leading-relaxed text-muted-foreground">${escapeHtml(article.excerpt)}</p>
<div class="mt-2 flex items-center justify-between gap-3">
<span class="flex items-center gap-1.5 text-xs text-muted-foreground">${clockIcon}${article.readTime} دقیقه مطالعه</span>
<a href="${escapeHtml(article.href)}" class="group/button inline-flex shrink-0 items-center justify-center rounded-full border border-transparent bg-primary px-4 py-1.5 text-xs font-medium text-primary-foreground transition-all hover:bg-primary/80">مطالعه مقاله</a>
</div>
</div>
</article>
`.trim()
}
function getSelectedCategoryIds() {
return Array.from(
document.querySelectorAll<HTMLInputElement>("[data-category-checkbox]:checked"),
)
.map((input) => Number(input.value))
.filter(Boolean)
}
function readFiltersFromForm(): BlogFilters {
const searchInput = document.getElementById("blog-search") as HTMLInputElement | null
return {
q: searchInput?.value ?? "",
categoryIds: getSelectedCategoryIds(),
}
}
function readFiltersFromUrl() {
return parseBlogSearchParams(new URLSearchParams(window.location.search))
}
function syncUrl(filters: BlogFilters) {
const nextUrl = buildBlogUrl(filters, window.location.pathname)
history.replaceState(null, "", nextUrl)
}
function updateCategoryLabel() {
const label = document.getElementById("blog-category-label")
const checkboxes = document.querySelectorAll<HTMLInputElement>(
"[data-category-checkbox]",
)
if (!label) return
const selectedNames = Array.from(checkboxes)
.filter((input) => input.checked)
.map((input) => input.parentElement?.querySelector("span")?.textContent?.trim())
.filter(Boolean) as string[]
const selectedCount = selectedNames.length
if (selectedCount === 0) {
label.textContent = "دسته‌بندی‌ها"
return
}
if (selectedCount === 1) {
label.textContent = selectedNames[0] ?? "۱ دسته"
return
}
label.textContent = `${selectedCount} دسته انتخاب شده`
}
function applyFiltersToForm(filters: BlogFilters) {
const searchInput = document.getElementById("blog-search") as HTMLInputElement | null
const checkboxes = document.querySelectorAll<HTMLInputElement>(
"[data-category-checkbox]",
)
if (searchInput) searchInput.value = filters.q
checkboxes.forEach((checkbox) => {
checkbox.checked = filters.categoryIds.includes(Number(checkbox.value))
})
updateCategoryLabel()
}
function setGridLoading(loading: boolean) {
const loadingEl = document.getElementById("blog-loading")
const grid = document.getElementById("blog-grid")
const loadMoreButton = document.getElementById(
"blog-load-more",
) as HTMLButtonElement | null
loadingEl?.classList.toggle("hidden", !loading)
loadingEl?.classList.toggle("flex", loading)
grid?.classList.toggle("pointer-events-none", loading)
grid?.classList.toggle("opacity-50", loading)
if (loadMoreButton) loadMoreButton.disabled = loading
}
export function initBlogPage() {
const searchInput = document.getElementById("blog-search") as HTMLInputElement | null
const grid = document.getElementById("blog-grid") as HTMLElement | null
const emptyState = document.getElementById("blog-empty")
const loadMoreWrap = document.getElementById("blog-load-more-wrap")
const loadMoreButton = document.getElementById(
"blog-load-more",
) as HTMLButtonElement | null
const filter = document.getElementById("blog-category-filter")
const trigger = document.getElementById("blog-category-trigger")
const panel = document.getElementById("blog-category-panel")
const checkboxes = document.querySelectorAll<HTMLInputElement>(
"[data-category-checkbox]",
)
if (!grid) return
const gridEl = grid
let currentPage = Number(gridEl.dataset.page ?? 1)
let totalPages = Number(gridEl.dataset.totalPages ?? 1)
let searchTimer: ReturnType<typeof setTimeout> | undefined
let isLoading = false
let activeFilters: BlogFilters = { q: "", categoryIds: [] }
function bootstrapFromUrl() {
const urlFilters = readFiltersFromUrl()
const ssrFilters = readSsrFilters(gridEl)
applyFiltersToForm(urlFilters)
activeFilters = urlFilters
if (!filtersAreEqual(urlFilters, ssrFilters)) {
void loadPosts({ reset: true, filters: urlFilters, updateUrl: false })
}
}
function updateLoadMoreVisibility() {
loadMoreWrap?.classList.toggle("hidden", currentPage >= totalPages)
}
async function loadPosts({
reset,
filters,
updateUrl = false,
}: {
reset: boolean
filters: BlogFilters
updateUrl?: boolean
}) {
if (isLoading) return
const nextPage = reset ? 1 : currentPage + 1
if (!reset && nextPage > totalPages) return
if (updateUrl) syncUrl(filters)
isLoading = true
setGridLoading(true)
try {
const result = await fetchPostsClient({
page: nextPage,
perPage: POSTS_PER_PAGE,
search: filters.q.trim() || undefined,
categoryIds: filters.categoryIds.length ? filters.categoryIds : undefined,
})
if (reset) {
gridEl.innerHTML = ""
currentPage = 1
} else {
currentPage = nextPage
}
activeFilters = filters
totalPages = result.totalPages
gridEl.dataset.page = String(currentPage)
gridEl.dataset.totalPages = String(totalPages)
if (result.articles.length === 0 && reset) {
emptyState?.classList.remove("hidden")
if (emptyState) emptyState.textContent = "مقاله‌ای پیدا نشد."
} else {
emptyState?.classList.add("hidden")
gridEl.insertAdjacentHTML(
"beforeend",
result.articles.map(renderArticleCard).join(""),
)
}
updateLoadMoreVisibility()
} catch {
if (reset) gridEl.innerHTML = ""
emptyState?.classList.remove("hidden")
if (emptyState) emptyState.textContent = "خطا در دریافت مقالات."
loadMoreWrap?.classList.add("hidden")
} finally {
isLoading = false
setGridLoading(false)
}
}
function requestFilterChange(updateUrl: boolean) {
const filters = readFiltersFromForm()
if (filtersAreEqual(filters, activeFilters) && updateUrl) return
void loadPosts({ reset: true, filters, updateUrl })
}
if (trigger && panel && filter) {
const triggerButton = trigger
const panelElement = panel
function closePanel() {
panelElement.classList.add("hidden")
triggerButton.setAttribute("aria-expanded", "false")
}
function openPanel() {
panelElement.classList.remove("hidden")
triggerButton.setAttribute("aria-expanded", "true")
}
triggerButton.addEventListener("click", (event) => {
event.stopPropagation()
const isOpen = triggerButton.getAttribute("aria-expanded") === "true"
if (isOpen) closePanel()
else openPanel()
})
document.addEventListener("click", (event) => {
if (!filter.contains(event.target as Node)) closePanel()
})
}
checkboxes.forEach((checkbox) => {
checkbox.addEventListener("change", () => {
updateCategoryLabel()
requestFilterChange(true)
})
})
searchInput?.addEventListener("input", () => {
clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
requestFilterChange(true)
}, SEARCH_DEBOUNCE_MS)
})
loadMoreButton?.addEventListener("click", () => {
void loadPosts({ reset: false, filters: activeFilters })
})
window.addEventListener("popstate", () => {
const filters = readFiltersFromUrl()
applyFiltersToForm(filters)
if (filtersAreEqual(filters, activeFilters)) return
void loadPosts({ reset: true, filters, updateUrl: false })
})
bootstrapFromUrl()
updateLoadMoreVisibility()
}

52
src/lib/blog-params.ts Normal file
View File

@@ -0,0 +1,52 @@
export type BlogFilters = {
q: string
categoryIds: number[]
}
export function parseBlogSearchParams(
searchParams: URLSearchParams,
): BlogFilters {
const q = searchParams.get("q") ?? ""
const categoryIds = parseCategoryIds(searchParams.get("categories") ?? undefined)
return { q, categoryIds }
}
export function buildBlogQueryString({ q, categoryIds }: BlogFilters) {
const params = new URLSearchParams()
const trimmed = q.trim()
if (trimmed) params.set("q", trimmed)
if (categoryIds.length > 0) params.set("categories", categoryIds.join(","))
return params.toString()
}
export function buildBlogUrl(filters: BlogFilters, pathname = "/blog") {
const query = buildBlogQueryString(filters)
return query ? `${pathname}?${query}` : pathname
}
export function filtersAreEqual(a: BlogFilters, b: BlogFilters) {
if (a.q.trim() !== b.q.trim()) return false
if (a.categoryIds.length !== b.categoryIds.length) return false
const sortedA = [...a.categoryIds].sort((x, y) => x - y)
const sortedB = [...b.categoryIds].sort((x, y) => x - y)
return sortedA.every((id, index) => id === sortedB[index])
}
export function parseCategoryIds(value: string | undefined) {
return (value ?? "")
.split(",")
.map((id) => Number(id.trim()))
.filter((id) => id > 0)
}
export function readSsrFilters(element: HTMLElement): BlogFilters {
return {
q: element.dataset.ssrQ ?? "",
categoryIds: parseCategoryIds(element.dataset.ssrCategories),
}
}

36
src/lib/blog-types.ts Normal file
View File

@@ -0,0 +1,36 @@
export type BlogCategoryItem = {
id: number
name: string
}
export type BlogArticle = {
id: number
slug: string
href: string
title: string
excerpt: string
categories: BlogCategoryItem[]
date: string
dateIso: string
readTime: number
imageUrl: string | null
}
export type BlogCategory = {
id: number
name: string
slug: string
}
export type BlogArticleSeo = {
title: string
description: string
canonical: string
ogImage: string | null
}
export type BlogArticleDetail = BlogArticle & {
contentHtml: string
modifiedIso: string
seo: BlogArticleSeo
}

View File

@@ -1,9 +1,173 @@
---
import Layout from "../layouts/Layout.astro";
import Layout from "@/layouts/Layout.astro";
import ArticleCard from "@/components/blog/ArticleCard.astro";
import CategoryFilter from "@/components/blog/CategoryFilter.astro";
import LucideIcon from "@/components/ui/lucide-icon.astro";
import { fetchCategories, fetchPosts, POSTS_PER_PAGE } from "@/api/wp";
import { parseBlogSearchParams } from "@/lib/blog-params";
const fieldClass =
"h-11 w-full rounded-full border border-border bg-card text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/20";
const initialFilters = parseBlogSearchParams(Astro.url.searchParams);
let articles: Awaited<ReturnType<typeof fetchPosts>>["articles"] = [];
let categories: Awaited<ReturnType<typeof fetchCategories>> = [];
let totalPages = 1;
let fetchError: string | null = null;
try {
const [postsResult, categoriesResult] = await Promise.all([
fetchPosts({
page: 1,
perPage: POSTS_PER_PAGE,
search: initialFilters.q || undefined,
categoryIds: initialFilters.categoryIds.length
? initialFilters.categoryIds
: undefined,
}),
fetchCategories(),
]);
articles = postsResult.articles;
totalPages = postsResult.totalPages;
categories = categoriesResult;
} catch (error) {
fetchError =
error instanceof Error ? error.message : "خطا در دریافت مقالات";
}
function getCategoryFilterLabel(
categoryIds: number[],
allCategories: typeof categories,
) {
if (categoryIds.length === 0) return "دسته‌بندی‌ها";
if (categoryIds.length === 1) {
return (
allCategories.find((category) => category.id === categoryIds[0])?.name ??
"۱ دسته"
);
}
return `${categoryIds.length} دسته انتخاب شده`;
}
const categoryFilterLabel = getCategoryFilterLabel(
initialFilters.categoryIds,
categories,
);
---
<Layout title="بلاگ | نکستا">
<section class="mx-auto max-w-7xl px-6 py-16 lg:px-8">
<h1 class="text-3xl font-bold text-foreground">بلاگ</h1>
<section
class="relative overflow-hidden bg-background px-4 pb-16 pt-12 md:px-6 md:pb-20 md:pt-16 lg:px-8"
>
<div
class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(165,255,1,0.06),transparent_55%)]"
aria-hidden="true"
>
</div>
<div class="relative mx-auto max-w-7xl">
<header class="mx-auto max-w-3xl text-center">
<h1 class="text-3xl font-bold text-foreground md:text-4xl">
دانش و تجربه‌ای برای
<span class="text-primary">رشد باشگاه شما</span>
</h1>
<p
class="mt-4 text-sm leading-relaxed text-muted-foreground md:text-base"
>
از نکات مدیریتی تا ترندهای روز صنعت فیتنس؛ هرآنچه برای توسعه باشگاه و
افزایش رضایت اعضا نیاز دارید.
</p>
</header>
<div
class="relative mx-auto mt-10 flex max-w-3xl flex-col gap-3 sm:flex-row sm:items-center sm:gap-4 md:mt-12"
>
<div class="relative min-w-0 flex-1">
<input
id="blog-search"
type="search"
placeholder="جستجو کنید"
value={initialFilters.q}
class:list={[fieldClass, "pe-4 ps-11"]}
autocomplete="off"
/>
<LucideIcon
name="search"
class="pointer-events-none absolute start-4 top-1/2 z-10 -translate-y-1/2 text-muted-foreground"
/>
</div>
<CategoryFilter
categories={categories}
fieldClass={fieldClass}
selectedIds={initialFilters.categoryIds}
label={categoryFilterLabel}
/>
</div>
{
fetchError && (
<p class="mt-10 text-center text-sm text-destructive">{fetchError}</p>
)
}
<div class="relative mt-10 md:mt-12">
<div
id="blog-loading"
class="absolute inset-0 z-10 hidden items-center justify-center rounded-2xl bg-background/70 backdrop-blur-sm"
aria-live="polite"
aria-busy="true"
>
<div class="flex items-center gap-3 text-sm text-muted-foreground">
<span
class="size-5 animate-spin rounded-full border-2 border-primary border-t-transparent"
aria-hidden="true"></span>
در حال دریافت مقالات...
</div>
</div>
<div
id="blog-grid"
class="grid grid-cols-1 gap-6 transition-opacity sm:grid-cols-2 lg:grid-cols-3"
data-page="1"
data-total-pages={totalPages}
data-ssr-q={initialFilters.q}
data-ssr-categories={initialFilters.categoryIds.join(",")}
>
{articles.map((article) => <ArticleCard article={article} />)}
</div>
</div>
<p
id="blog-empty"
class:list={[
"mt-10 text-center text-sm text-muted-foreground",
articles.length > 0 && "hidden",
]}
>
{fetchError ? "مقاله‌ای برای نمایش وجود ندارد." : "مقاله‌ای پیدا نشد."}
</p>
<div
id="blog-load-more-wrap"
class:list={["mt-10 flex justify-center", totalPages <= 1 && "hidden"]}
>
<button
id="blog-load-more"
type="button"
class="rounded-full border border-primary bg-transparent px-8 py-2.5 text-sm font-medium text-primary transition-colors hover:bg-primary/10 disabled:cursor-not-allowed disabled:opacity-50"
>
مشاهده بیشتر
</button>
</div>
</div>
</section>
</Layout>
<script>
import { initBlogPage } from "@/lib/blog-client";
initBlogPage();
</script>

101
src/pages/blog/[slug].astro Normal file
View File

@@ -0,0 +1,101 @@
---
import Layout from "@/layouts/Layout.astro";
import ArticleDetail from "@/components/blog/ArticleDetail.astro";
import {
fetchAllPosts,
fetchPostBySlug,
getSiteOrigin,
mapPostToDetail,
SITE_NAME,
} from "@/api/wp";
import type { WPPost } from "@/api/wp.dto";
export async function getStaticPaths() {
try {
const posts = await fetchAllPosts();
return posts.map((post) => {
const slug = decodeURIComponent(post.slug);
return {
params: { slug },
props: { post },
};
});
} catch {
return [];
}
}
interface Props {
post?: WPPost;
}
const { slug } = Astro.params;
const { post: staticPost } = Astro.props as Props;
if (!slug) {
return Astro.redirect("/blog");
}
const siteOrigin = getSiteOrigin(Astro.url.origin);
const post = staticPost ?? (await fetchPostBySlug(slug));
if (!post) {
return Astro.redirect("/blog");
}
const article = mapPostToDetail(post, siteOrigin);
const jsonLd = {
"@context": "https://schema.org",
"@type": "Article",
headline: article.seo.title,
description: article.seo.description,
image: article.seo.ogImage ? [article.seo.ogImage] : undefined,
datePublished: article.dateIso,
dateModified: article.modifiedIso,
author: {
"@type": "Organization",
name: SITE_NAME,
},
publisher: {
"@type": "Organization",
name: SITE_NAME,
},
mainEntityOfPage: {
"@type": "WebPage",
"@id": article.seo.canonical,
},
};
---
<Layout
title={article.seo.title}
description={article.seo.description}
canonical={article.seo.canonical}
ogImage={article.seo.ogImage}
ogType="article"
jsonLd={jsonLd}
>
<section
class="relative overflow-hidden bg-background px-4 pb-16 pt-12 md:px-6 md:pb-20 md:pt-16 lg:px-8"
>
<div
class="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top,rgba(165,255,1,0.06),transparent_55%)]"
aria-hidden="true"
>
</div>
<div class="relative mx-auto max-w-7xl">
<nav class="mb-8">
<a
href="/blog"
class="inline-flex items-center gap-2 text-sm text-muted-foreground transition-colors hover:text-primary"
>
← بازگشت به بلاگ
</a>
</nav>
<ArticleDetail article={article} />
</div>
</section>
</Layout>

View File

@@ -175,6 +175,47 @@
.contact-icon {
@apply flex shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary;
}
.wp-content {
@apply space-y-5 text-base leading-relaxed text-muted-foreground;
}
.wp-content h2 {
@apply pt-2 text-2xl font-bold text-foreground;
}
.wp-content h3 {
@apply pt-1 text-xl font-semibold text-foreground;
}
.wp-content p {
@apply leading-relaxed;
}
.wp-content a {
@apply text-primary underline-offset-4 transition-colors hover:text-primary/80 hover:underline;
}
.wp-content ul,
.wp-content ol {
@apply space-y-2 ps-6;
}
.wp-content ul {
@apply list-disc;
}
.wp-content ol {
@apply list-decimal;
}
.wp-content img {
@apply mx-auto w-full rounded-2xl;
}
.wp-content img.aligncenter {
@apply block;
}
}
@layer utilities {