Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions apps/ui/src/app/[locale]/blog/categories/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { getPostCategoryMetadata } from "@/lib/metadata"
import type { SeoComponent } from "@/lib/metadata/build-from-seo"
import {
fetchBlog,
fetchBlogPostsList,
fetchBlogPostsPage,
fetchPostCategory,
} from "@/lib/strapi-api/content/server"

Expand All @@ -31,7 +31,7 @@ type CategoryWithExtras = {
children?: ({ slug?: string | null } | null)[] | null
}

export const dynamic = "force-static"
export const revalidate = 3600

export async function generateStaticParams({
params: { locale },
Expand Down Expand Up @@ -89,11 +89,13 @@ export default function BlogCategoryPage(
.filter((s): s is string => typeof s === "string" && s.length > 0)
const allSlugs: string[] = [slug, ...childSlugs]

const categoryPosts = use(fetchBlogPostsList(locale, allSlugs, 20))
const categoryPosts = use(
fetchBlogPostsPage(locale, { offset: 0, limit: 10, categorySlug: allSlugs })
)

const hubspotForm = getBlogNewsletterHubspot(blog)
const featuredPost: BlogPost | null = categoryPosts?.data[0] ?? null
const remainingPosts: BlogPost[] = categoryPosts?.data.slice(1) ?? []
const featuredPost: BlogPost | null = categoryPosts.posts[0] ?? null
const remainingPosts: BlogPost[] = categoryPosts.posts.slice(1)
const categoryName = category?.name ?? featuredPost?.category?.name ?? slug

return (
Expand All @@ -119,7 +121,14 @@ export default function BlogCategoryPage(

{featuredPost && <FeaturedBlogPost post={featuredPost} />}

<BlogPostsList posts={remainingPosts} loadMoreLabel={t("loadMore")} />
<BlogPostsList
posts={remainingPosts}
locale={locale}
initialOffset={categoryPosts.posts.length}
total={categoryPosts.total}
categorySlug={allSlugs}
loadMoreLabel={t("loadMore")}
/>
</HeroContainerContent>

<HeroContainerContent className="animate-reveal-cascade flex flex-col gap-10 [--reveal-delay:680ms]">
Expand Down
18 changes: 12 additions & 6 deletions apps/ui/src/app/[locale]/blog/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,9 @@ import { NewsletterSignup } from "@/components/newsletter/NewsletterSignup"
import { StrapiSeoStructuredDataByFullPath } from "@/components/page-builder/components/seo-utilities/StrapiSeoStructuredData"
import { getBlogNewsletterHubspot, type BlogPost } from "@/lib/blog-utils"
import { getBlogIndexMetadata } from "@/lib/metadata"
import { fetchBlog, fetchBlogPostsList } from "@/lib/strapi-api/content/server"
import { fetchBlog, fetchBlogPostsPage } from "@/lib/strapi-api/content/server"

export const dynamic = "force-static"
export const revalidate = 3600

export async function generateMetadata(props: {
params: Promise<{ locale: string }>
Expand All @@ -35,14 +35,14 @@ export default function BlogIndexPage(props: PageProps<"/[locale]/blog">) {
const [t, allPosts, blog] = use(
Promise.all([
getTranslations({ locale, namespace: "blog" }),
fetchBlogPostsList(locale, undefined, 20),
fetchBlogPostsPage(locale, { offset: 0, limit: 10 }),
fetchBlog(locale),
])
)

const hubspotForm = getBlogNewsletterHubspot(blog)
const featuredPost: BlogPost | null = allPosts?.data[0] ?? null
const remainingPosts: BlogPost[] = allPosts?.data.slice(1) ?? []
const featuredPost: BlogPost | null = allPosts.posts[0] ?? null
const remainingPosts: BlogPost[] = allPosts.posts.slice(1)

return (
<>
Expand All @@ -53,7 +53,13 @@ export default function BlogIndexPage(props: PageProps<"/[locale]/blog">) {
<HeroContainerContent className="animate-reveal-cascade border-strapi-gray-700/50 flex flex-col gap-10 border-b">
{featuredPost && <FeaturedBlogPost post={featuredPost} />}

<BlogPostsList posts={remainingPosts} loadMoreLabel={t("loadMore")} />
<BlogPostsList
posts={remainingPosts}
locale={locale}
initialOffset={allPosts.posts.length}
total={allPosts.total}
loadMoreLabel={t("loadMore")}
/>
</HeroContainerContent>

<HeroContainerContent className="animate-reveal-cascade flex flex-col gap-10 [--reveal-delay:680ms]">
Expand Down
21 changes: 15 additions & 6 deletions apps/ui/src/app/[locale]/blog/tags/[slug]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import type { SeoComponent } from "@/lib/metadata/build-from-seo"
import {
fetchAllPostTags,
fetchBlog,
fetchBlogPostsList,
fetchBlogPostsPage,
fetchPostTag,
} from "@/lib/strapi-api/content/server"

Expand All @@ -31,7 +31,7 @@ type TagWithExtras = {
seo?: SeoComponent | null
}

export const dynamic = "force-static"
export const revalidate = 3600

export async function generateStaticParams({
params: { locale },
Expand Down Expand Up @@ -75,11 +75,13 @@ export default function BlogTagPage(

const tag = tagRes?.data as TagWithExtras | undefined

const tagPosts = use(fetchBlogPostsList(locale, undefined, 20, slug))
const tagPosts = use(
fetchBlogPostsPage(locale, { offset: 0, limit: 10, tagSlug: slug })
)

const hubspotForm = getBlogNewsletterHubspot(blog)
const featuredPost: BlogPost | null = tagPosts?.data[0] ?? null
const remainingPosts: BlogPost[] = tagPosts?.data.slice(1) ?? []
const featuredPost: BlogPost | null = tagPosts.posts[0] ?? null
const remainingPosts: BlogPost[] = tagPosts.posts.slice(1)
const tagName = tag?.name ?? slug

return (
Expand All @@ -105,7 +107,14 @@ export default function BlogTagPage(

{featuredPost && <FeaturedBlogPost post={featuredPost} />}

<BlogPostsList posts={remainingPosts} loadMoreLabel={t("loadMore")} />
<BlogPostsList
posts={remainingPosts}
locale={locale}
initialOffset={tagPosts.posts.length}
total={tagPosts.total}
tagSlug={slug}
loadMoreLabel={t("loadMore")}
/>
</HeroContainerContent>

<HeroContainerContent className="animate-reveal-cascade flex flex-col gap-10 [--reveal-delay:680ms]">
Expand Down
46 changes: 39 additions & 7 deletions apps/ui/src/components/blog/BlogPostsList.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,63 @@
"use client"

import { useState } from "react"
import type { Locale } from "next-intl"
import { useState, useTransition } from "react"

import { type BlogPost, getBlogPostPublishDate } from "@/lib/blog-utils"

import { loadMoreBlogPosts } from "./blog-posts-load-more"
import { BlogPostRow } from "./BlogPostRow"
import { Button } from "../ui/button"

interface BlogPostsListProps {
readonly posts: readonly BlogPost[]
readonly locale: Locale
/**
* Number of posts already consumed from Strapi (featured post + the initial
* server-rendered list). The next "Load More" fetch starts at this offset.
*/
readonly initialOffset: number
readonly total: number
readonly pageSize?: number
readonly loadMoreLabel?: string
readonly categorySlug?: string | readonly string[]
readonly tagSlug?: string
}

export function BlogPostsList({
posts,
posts: initialPosts,
locale,
initialOffset,
total,
pageSize = 10,
loadMoreLabel = "Load More Articles",
categorySlug,
tagSlug,
}: BlogPostsListProps) {
const [visibleCount, setVisibleCount] = useState(pageSize)
const [posts, setPosts] = useState<readonly BlogPost[]>(initialPosts)
const [offset, setOffset] = useState(initialOffset)
const [hasMore, setHasMore] = useState(initialOffset < total)
const [isPending, startTransition] = useTransition()

const visiblePosts = posts.slice(0, visibleCount)
const hasMore = visibleCount < posts.length
const handleLoadMore = () => {
startTransition(async () => {
const res = await loadMoreBlogPosts({
locale,
offset,
limit: pageSize,
categorySlug,
tagSlug,
})

setPosts((prev) => [...prev, ...res.posts])
setOffset((prev) => prev + res.posts.length)
setHasMore(res.hasMore)
})
}

return (
<div className="flex flex-col gap-4">
{visiblePosts
{posts
.filter((post) => typeof post.slug === "string" && post.slug.length > 0)
.map((post) => (
<BlogPostRow
Expand All @@ -41,7 +73,7 @@ export function BlogPostsList({

{hasMore && (
<div className="flex justify-center py-10">
<Button onClick={() => setVisibleCount((c) => c + pageSize)}>
<Button onClick={handleLoadMore} disabled={isPending}>
{loadMoreLabel}
</Button>
</div>
Expand Down
39 changes: 39 additions & 0 deletions apps/ui/src/components/blog/blog-posts-load-more.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"use server"

import type { Locale } from "next-intl"

import type { BlogPost } from "@/lib/blog-utils"
import { fetchBlogPostsPage } from "@/lib/strapi-api/content/server"

export interface LoadMoreBlogPostsArgs {
readonly locale: Locale
readonly offset: number
readonly limit: number
readonly categorySlug?: string | readonly string[]
readonly tagSlug?: string
}

export interface LoadMoreBlogPostsResult {
readonly posts: BlogPost[]
readonly hasMore: boolean
}

export async function loadMoreBlogPosts({
locale,
offset,
limit,
categorySlug,
tagSlug,
}: LoadMoreBlogPostsArgs): Promise<LoadMoreBlogPostsResult> {
const { posts, total } = await fetchBlogPostsPage(locale, {
offset,
limit,
categorySlug,
tagSlug,
})

return {
posts,
hasMore: offset + posts.length < total,
}
}
84 changes: 84 additions & 0 deletions apps/ui/src/lib/strapi-api/content/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { FindFirst, UID } from "@repo/strapi-types"
import { draftMode } from "next/headers"
import type { Locale } from "next-intl"

import type { BlogPost } from "@/lib/blog-utils"
import { logNonBlockingError } from "@/lib/logging"
import { PublicStrapiClient } from "@/lib/strapi-api"
import type { CustomFetchOptions } from "@/types/general"
Expand Down Expand Up @@ -264,6 +265,89 @@ export async function fetchBlogPostsList(
}
}

export interface BlogPostsPage {
readonly posts: BlogPost[]
readonly total: number
}

/**
* Offset-paginated blog post fetch used by the blog index "Load More" flow.
* Unlike {@link fetchBlogPostsList} (which caps at a single `pageSize`), this
* returns a window `[offset, offset + limit)` plus the total count so the
* caller can decide whether more posts remain.
*/
export async function fetchBlogPostsPage(
locale: Locale,
options: {
readonly offset: number
readonly limit: number
readonly categorySlug?: string | readonly string[]
readonly tagSlug?: string
}
): Promise<BlogPostsPage> {
const { offset, limit, categorySlug, tagSlug } = options
const dm = await draftMode()

const slugs = Array.isArray(categorySlug)
? categorySlug
: categorySlug
? [categorySlug as string]
: null

const conditions: Record<string, unknown>[] = []
if (slugs && slugs.length > 0) {
conditions.push({ category: { slug: { $in: slugs } } })
}
if (tagSlug) {
conditions.push({ tags: { slug: { $eq: tagSlug } } })
}

const filters = conditions.length > 0 ? { filters: { $and: conditions } } : {}

const cacheTags = tagSlug
? [...STRAPI_TAGS.blogPost, ...STRAPI_TAGS.postTag]
: STRAPI_TAGS.blogPost
const requestInit = withCacheTags(dm.isEnabled, cacheTags)

try {
const res = await PublicStrapiClient.fetchMany(
"api::blog-post.blog-post",
{
locale,
status: dm.isEnabled ? "draft" : "published",
sort: { originalPublishedAt: "desc" },
...filters,
populate: blogListPopulate,
pagination: { start: offset, limit },
} as unknown as Parameters<typeof PublicStrapiClient.fetchMany>[1],
requestInit
)

const posts = (res.data ?? []) as unknown as BlogPost[]

return {
posts,
total: res.meta?.pagination?.total ?? offset + posts.length,
}
} catch (e: unknown) {
const scopeParts: string[] = []
if (slugs) scopeParts.push(`categories '${slugs.join(",")}'`)
if (tagSlug) scopeParts.push(`tag '${tagSlug}'`)
const scope =
scopeParts.length > 0 ? ` for ${scopeParts.join(" and ")}` : ""

logNonBlockingError({
message: `Error fetching blog posts page${scope} for locale '${locale}'`,
error: {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
},
})

return { posts: [], total: 0 }
}
}

export async function fetchAllBlogPosts(locale: Locale) {
try {
return await PublicStrapiClient.fetchAll(
Expand Down
Loading