Establish baseline for project documentation including BMAD specs, PRD, and system architecture notes.
22 KiB
Story 1-9: Blog System Implementation (Blog System Implementation)
Status: ready-for-dev Epic: Epic 1 - Webflow to Payload CMS + Astro Migration Priority: P1 (High - Content Marketing Core) Estimated Time: 16 hours
Story
作為一位 訪客 (Visitor), 我想要 瀏覽行銷文章和見解, 以便 我可以從恩群數位的專業知識中學習。
Context
Yo 各位開發者!這是我們部落格系統的實作故事!Payload CMS 的 Posts 集合已經準備好了,Categories 也設置完成了,現在是時候讓它們在前台發光發亮啦!Webflow 的 /news 頁面就是你的設計藍圖,目標是 95%+ 的視覺相似度!
Story Source:
docs/prd/05-epic-stories.md- Story 1.9- sprint-status.yaml - "1-9-blog-system"
- 依賴:Story 1-2 (Collections Definition), Story 1-4 (Global Layout)
原始 HTML 參考:
- Source: research/www.enchun.tw/news.html - Blog 列表頁
- Source: research/www.enchun.tw/xing-xiao-fang-da-jing/ - Blog 文章資料夾
- Source: research/www.enchun.tw/wen-zhang-fen-lei - Blog 分類資料夾
Existing Infrastructure (Ready to Use!):
- Posts collection at
apps/backend/src/collections/Posts/index.tswith fields: title, slug, heroImage, ogImage, content (richText), excerpt, categories, relatedPosts, publishedAt, authors, status - Categories collection at
apps/backend/src/collections/Categories.tswith theming colors - Placeholder pages already exist at
news.astroandxing-xiao-fang-da-jing/[slug].astro
Acceptance Criteria
Blog Listing Page (/blog or /news)
- AC1 - Display Published Posts: Only posts with status='published' are displayed
- AC2 - Category Filter: 4 category filter buttons that filter posts dynamically
- AC3 - Article Cards: Each card displays:
- Featured image (heroImage)
- Title (linked to detail page)
- Excerpt (truncated to ~150 chars)
- Category badge with category color theming
- Published date (formatted in Traditional Chinese)
- AC4 - Pagination: Implement pagination (12 posts per page) with page navigation
- AC5 - Visual Fidelity: Design matches Webflow news.html with 95%+ similarity
Article Detail Page (/blog/[slug] or /xing-xiao-fang-da-jing/[slug])
- AC6 - Full Content Display: Rich text content rendered with proper styling
- AC7 - Meta Information: Category badge and published date displayed
- AC8 - Related Articles: Show 3-4 related posts from same category
- AC9 - Social Sharing: OG tags configured (ogImage, ogTitle, ogDescription)
- AC10 - Rich Text Rendering: Lexical editor output matches Webflow formatting
Category Page (/blog/category/[slug] or /wen-zhang-fen-lei/[slug])
- AC11 - Category Filtering: Shows only posts belonging to selected category
- AC12 - Category Description: Displays category name and description
- AC13 - Color Theming: Page uses category's textColor and backgroundColor
Dev Technical Guidance
URL Structure Decision
Important: Choose between these URL patterns:
- Option A:
/blog+/blog/[slug]+/blog/category/[slug](Clean, SEO-friendly) - Option B:
/news+/xing-xiao-fang-da-jing/[slug]+/wen-zhang-fen-lei/[slug](Matches Webflow)
Recommendation: Use Option A for new SEO, set up 301 redirects from Webflow URLs.
Architecture Patterns
Data Fetching Pattern (Astro SSR):
// apps/frontend/src/pages/blog/index.astro
---
import Layout from '../../layouts/Layout.astro'
import { payload } from '@payload/client' // Or use API endpoint
const PAGE_SIZE = 12
const page = Astro.url.searchParams.get('page') || '1'
const category = Astro.url.searchParams.get('category')
// Fetch from Payload API
const response = await fetch(`${import.meta.env.PAYLOAD_CMS_URL}/api/posts?where[status][equals]=published&page=${page}&limit=${PAGE_SIZE}&depth=1`)
const data = await response.json()
const posts = data.docs
const totalPages = data.totalPages
---
Rich Text Rendering Pattern:
// Payload Lexical to HTML converter needed
import { serializeLexical } from '@/utilities/serializeLexical'
// In template
<div set:html={serializeLexical(post.content)} />
File Structure
apps/frontend/src/
├── pages/
│ ├── blog/
│ │ ├── index.astro ← Blog listing page (CREATE)
│ │ ├── [slug].astro ← Article detail page (CREATE/UPDATE)
│ │ └── category/
│ │ └── [slug].astro ← Category page (CREATE/UPDATE)
├── components/
│ └── blog/
│ ├── ArticleCard.astro ← Reusable article card (CREATE)
│ ├── CategoryFilter.astro ← Category filter buttons (CREATE)
│ ├── RelatedPosts.astro ← Related posts section (CREATE)
│ └── ShareButtons.astro ← Social sharing buttons (CREATE - optional)
├── lib/
│ └── api.ts ← API client utilities (UPDATE)
└── utilities/
└── serializeLexical.ts ← Lexical to HTML converter (CREATE)
Component Specifications
1. ArticleCard.astro
---
interface Props {
post: {
title: string
slug: string
heroImage: { url: string } | null
excerpt: string
categories: Array<{ title: string, slug: string, backgroundColor: string, textColor: string }>
publishedAt: string
}
}
const { post } = Astro.props
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
const category = post.categories?.[0]
---
<article class="blog-card group">
<a href={`/blog/${post.slug}`} class="block">
{post.heroImage && (
<div class="blog-card-image">
<img src={post.heroImage.url} alt={post.title} loading="lazy" />
</div>
)}
<div class="blog-card-content">
{category && (
<span
class="category-badge"
style={`background-color: ${category.backgroundColor}; color: ${category.textColor}`}
>
{category.title}
</span>
)}
<h3 class="blog-card-title">{post.title}</h3>
<p class="blog-card-excerpt">{post.excerpt?.slice(0, 150)}...</p>
<time class="blog-card-date">{formatDate(post.publishedAt)}</time>
</div>
</a>
</article>
<style>
.blog-card { @apply bg-white rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow; }
.blog-card-image { @apply aspect-video overflow-hidden; }
.blog-card-image img { @apply w-full h-full object-cover group-hover:scale-105 transition-transform duration-300; }
.blog-card-content { @apply p-6; }
.category-badge { @apply inline-block px-3 py-1 rounded-full text-xs font-medium mb-3; }
.blog-card-title { @apply text-xl font-semibold text-gray-900 mb-2 line-clamp-2; }
.blog-card-excerpt { @apply text-gray-600 mb-4 line-clamp-2; }
.blog-card-date { @apply text-sm text-gray-500; }
</style>
2. CategoryFilter.astro
---
interface Props {
categories: Array<{ title: string, slug: string }>
activeCategory?: string
}
const { categories, activeCategory } = Astro.props
---
<nav class="category-filter" aria-label="文章分類篩選">
<a
href="/blog"
class:active={!activeCategory}
class="filter-button"
>
全部文章
</a>
{categories.map(category => (
<a
href={`/blog/category/${category.slug}`}
class:active={activeCategory === category.slug}
class="filter-button"
>
{category.title}
</a>
))}
</nav>
<style>
.category-filter { @apply flex flex-wrap gap-3 justify-center mb-8; }
.filter-button {
@apply px-5 py-2 rounded-full border border-gray-300 text-gray-700
hover:border-blue-500 hover:text-blue-500 transition-colors;
}
.filter-button.active {
@apply bg-blue-500 text-white border-blue-500;
}
</style>
3. Blog Listing Page (blog/index.astro)
---
import Layout from '../../layouts/Layout.astro'
import ArticleCard from '../../components/blog/ArticleCard.astro'
import CategoryFilter from '../../components/blog/CategoryFilter.astro'
const PAGE_SIZE = 12
const page = Math.max(1, parseInt(Astro.url.searchParams.get('page') || '1'))
const categorySlug = Astro.url.searchParams.get('category')
// Fetch posts from Payload API
const postsQuery = new URLSearchParams({
where: categorySlug
? JSON.stringify({
status: { equals: 'published' },
'categories.slug': { equals: categorySlug }
})
: JSON.stringify({ status: { equals: 'published' } }),
sort: '-publishedAt',
limit: PAGE_SIZE.toString(),
page: page.toString(),
depth: '1'
})
const response = await fetch(`${import.meta.env.PAYLOAD_CMS_URL}/api/posts?${postsQuery}`)
const data = await response.json()
const posts = data.docs || []
const totalPages = data.totalPages || 1
// Fetch categories
const catsResponse = await fetch(`${import.meta.env.PAYLOAD_CMS_URL}/api/categories?sort=order&limit=10`)
const catsData = await catsResponse.json()
const categories = catsData.docs || []
const { title = '行銷放大鏡' } = Astro.props
---
<Layout title={title}>
<section class="blog-section">
<div class="container">
<h1 class="blog-title">{title}</h1>
<CategoryFilter categories={categories} activeCategory={categorySlug} />
<div class="blog-grid">
{posts.map(post => (
<ArticleCard post={post} />
))}
</div>
{posts.length === 0 && (
<p class="no-posts">沒有找到文章</p>
)}
{totalPages > 1 && (
<nav class="pagination" aria-label="分頁導航">
{page > 1 && (
<a href={`?page=${page - 1}${categorySlug ? `&category=${categorySlug}` : ''}`} class="pagination-link">
上一頁
</a>
)}
<span class="pagination-info">
第 {page} 頁,共 {totalPages} 頁
</span>
{page < totalPages && (
<a href={`?page=${page + 1}${categorySlug ? `&category=${categorySlug}` : ''}`} class="pagination-link">
下一頁
</a>
)}
</nav>
)}
</div>
</section>
</Layout>
<style>
.blog-section { @apply py-16; }
.container { @apply max-w-7xl mx-auto px-4 sm:px-6 lg:px-8; }
.blog-title { @apply text-4xl font-bold text-center mb-12; }
.blog-grid {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-12;
}
.no-posts { @apply text-center text-gray-500 py-12; }
.pagination { @apply flex justify-center items-center gap-4 mt-12; }
.pagination-link { @apply px-4 py-2 border border-gray-300 rounded hover:bg-gray-50; }
</style>
4. Article Detail Page (blog/[slug].astro)
---
import Layout from '../../layouts/Layout.astro'
import RelatedPosts from '../../components/blog/RelatedPosts.astro'
import { getPayload } from 'payload'
import config from '@backend/payload.config'
const { slug } = Astro.params
// Fetch post
const payload = await getPayload({ config })
const post = await payload.find({
collection: 'posts',
slug,
depth: 2,
})
if (!post || post.status !== 'published') {
return Astro.redirect('/404')
}
// Fetch related posts
const relatedPosts = await payload.find({
collection: 'posts',
where: {
and: [
{ status: { equals: 'published' } },
{ id: { not_equals: post.id } },
{ 'categories.slug': { in: post.categories?.map(c => c.slug) || [] } }
]
},
limit: 3,
depth: 1,
})
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('zh-TW', {
year: 'numeric',
month: 'long',
day: 'numeric'
})
}
// SEO meta
const category = post.categories?.[0]
const metaImage = post.ogImage?.url || post.heroImage?.url
---
<Layout title={post.title}>
<article class="article">
<header class="article-header">
<div class="container">
{category && (
<span
class="article-category"
style={`background-color: ${category.backgroundColor}; color: ${category.textColor}`}
>
{category.title}
</span>
)}
<h1 class="article-title">{post.title}</h1>
<time class="article-date">{formatDate(post.publishedAt)}</time>
</div>
</header>
{post.heroImage && (
<div class="article-hero-image">
<img src={post.heroImage.url} alt={post.title} />
</div>
)}
<div class="article-content">
<div class="container">
<div class="prose" set:html={post.content} />
</div>
</div>
<section class="related-section">
<div class="container">
<h2>相關文章</h2>
<RelatedPosts posts={relatedPosts.docs} />
</div>
</section>
</article>
</Layout>
<!-- Open Graph tags -->
<script define:vars={{ metaImage, post }}>
if (typeof document !== 'undefined') {
document.querySelector('meta[property="og:image"]')?.setAttribute('content', metaImage)
document.querySelector('meta[property="og:title"]')?.setAttribute('content', post.title)
document.querySelector('meta[property="og:description"]')?.setAttribute('content', post.excerpt || '')
}
</script>
<style>
.article { @apply pb-16; }
.article-header { @apply py-8 text-center; }
.article-category { @apply inline-block px-4 py-1 rounded-full text-sm font-medium mb-4; }
.article-title { @apply text-3xl md:text-4xl font-bold mb-4; }
.article-date { @apply text-gray-500; }
.article-hero-image { @apply aspect-video overflow-hidden mb-12; }
.article-hero-image img { @apply w-full h-full object-cover; }
.article-content { @apply py-8; }
.prose {
@apply max-w-3xl mx-auto prose-headings:font-semibold prose-a:text-blue-600 prose-img:rounded-lg;
}
.related-section { @apply py-12 bg-gray-50; }
</style>
5. Category Page (blog/category/[slug].astro)
---
import Layout from '../../../layouts/Layout.astro'
import ArticleCard from '../../../components/blog/ArticleCard.astro'
const { slug } = Astro.params
// Fetch category
const catResponse = await fetch(`${import.meta.env.PAYLOAD_CMS_URL}/api/categories?where[slug][equals]=${slug}&depth=1`)
const catData = await catResponse.json()
const category = catData.docs?.[0]
if (!category) {
return Astro.redirect('/404')
}
// Fetch category posts
const postsResponse = await fetch(`${import.meta.env.PAYLOAD_CMS_URL}/api/posts?where[status][equals]=published&where[categories][equals]=${category.id}&sort=-publishedAt&limit=100&depth=1`)
const postsData = await postsResponse.json()
const posts = postsData.docs || []
---
<Layout title={category.title}>
<section class="category-page" style={`background-color: ${category.backgroundColor}20`}>
<div class="container">
<header class="category-header">
<h1 class="category-title" style={`color: ${category.textColor}`}>
{category.title}
</h1>
{category.nameEn && (
<p class="category-subtitle">{category.nameEn}</p>
)}
</header>
<div class="blog-grid">
{posts.map(post => (
<ArticleCard post={post} />
))}
</div>
{posts.length === 0 && (
<p class="no-posts">此分類暫無文章</p>
)}
</div>
</section>
</Layout>
<style>
.category-page { @apply py-16 min-h-screen; }
.category-header { @apply text-center mb-12; }
.category-title { @apply text-3xl md:text-4xl font-bold mb-2; }
.category-subtitle { @apply text-xl text-gray-600; }
.blog-grid {
@apply grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8;
}
.no-posts { @apply text-center text-gray-500 py-12; }
</style>
API Integration Notes
Payload CMS API Endpoints:
GET /api/posts - List all posts
GET /api/posts?where[status][equals]=published - Filter by status
GET /api/posts?slug=xxx - Get post by slug
GET /api/categories - List all categories
GET /api/categories?slug=xxx - Get category by slug
Query Parameters:
limit: Number of items per pagepage: Page numbersort: Sort field (e.g.,-publishedAtfor descending)where: JSON-encoded filter conditionsdepth: Populate related documents (1 for immediate relations)
Tasks / Subtasks
Phase 1: Architecture & Setup (2h)
-
Task 1.1: Design blog URL structure and routing
- Decide between
/blogvs/newsURLs - Plan 301 redirects from Webflow URLs
- Document routing decision
- Decide between
-
Task 1.2: Create API utilities
- Create
apps/frontend/src/lib/api.tswith helper functions - Add type definitions for Post and Category
- Implement error handling for API calls
- Create
Phase 2: Components (3h)
-
Task 2.1: Create ArticleCard component
- Create
components/blog/ArticleCard.astro - Implement image, title, excerpt, category badge, date display
- Add hover effects and responsive design
- Create
-
Task 2.2: Create CategoryFilter component
- Create
components/blog/CategoryFilter.astro - Implement filter button layout
- Add active state styling
- Create
-
Task 2.3: Create RelatedPosts component
- Create
components/blog/RelatedPosts.astro - Display 3 related posts
- Reuse ArticleCard for consistency
- Create
Phase 3: Blog Listing Page (2.5h)
-
Task 3.1: Create blog listing page
- Create
pages/blog/index.astro - Implement data fetching from Payload API
- Add category filter integration
- Implement pagination
- Create
-
Task 3.2: Style listing page
- Match Webflow design as closely as possible
- Ensure responsive behavior
- Add loading states
Phase 4: Article Detail Page (3h)
-
Task 4.1: Create article detail page
- Create/update
pages/blog/[slug].astro - Fetch post by slug from Payload API
- Handle 404 for non-existent posts
- Create/update
-
Task 4.2: Render rich text content
- Implement Lexical to HTML conversion
- Style content with Tailwind typography
- Ensure responsive images
-
Task 4.3: Add Open Graph tags
- Configure OG meta tags
- Use ogImage if available, fallback to heroImage
- Test social sharing preview
-
Task 4.4: Add related posts section
- Fetch related posts by category
- Display RelatedPosts component
- Handle case with no related posts
Phase 5: Category Page (2h)
-
Task 5.1: Create category listing page
- Create
pages/blog/category/[slug].astro - Fetch category and posts
- Apply category theming colors
- Create
-
Task 5.2: Style category page
- Match Webflow design
- Ensure responsive behavior
Phase 6: Extend Posts Collection (1.5h)
- Task 6.1: Review Posts collection fields
- Verify all required fields exist
- Add missing fields if needed
- Configure admin UI columns
Phase 7: Performance & Testing (2h)
-
Task 7.1: Performance optimization
- Implement image lazy loading
- Add pagination to reduce initial load
- Consider caching strategies
-
Task 7.2: Testing
- Test with 35+ migrated articles
- Verify category filtering works
- Test social sharing on Facebook/LINE
- Run Lighthouse audit (target 90+)
- Cross-browser testing
Testing Requirements
Unit Tests
// apps/frontend/src/lib/__tests__/api.spec.ts
import { fetchPosts, fetchPostBySlug } from '../api'
describe('Blog API', () => {
it('should fetch published posts only', async () => {
const posts = await fetchPosts()
posts.forEach(post => {
expect(post.status).toBe('published')
})
})
it('should fetch post by slug', async () => {
const post = await fetchPostBySlug('test-slug')
expect(post).toBeDefined()
expect(post.slug).toBe('test-slug')
})
})
Manual Testing Checklist
- Blog listing page displays all published posts
- Category filter buttons work correctly
- Article cards display all required information
- Pagination works (next/prev page)
- Article detail page loads for valid slug
- 404 page shows for invalid slug
- Rich text content renders correctly
- Category badges show correct colors
- Related posts are from same category
- Social sharing preview works
- Category page filters posts correctly
- Page loads quickly (< 2s LCP)
Visual Comparison Checklist
- Article card spacing matches Webflow
- Typography matches Webflow
- Color scheme matches Webflow
- Responsive breakpoints match Webflow
Risk Assessment
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| Lexical HTML conversion issues | Medium | High | Test with existing posts early |
| Performance with 35+ posts | Low | Medium | Implement pagination |
| Category theming complexity | Low | Low | Use inline styles for colors |
| Social sharing OG tag issues | Low | Medium | Test with Facebook debugger |
| URL structure mismatch | Low | High | Document 301 redirect plan |
Definition of Done
- All three pages (listing, detail, category) implemented
- ArticleCard component with all required fields
- CategoryFilter component working
- RelatedPosts component working
- Pagination implemented
- Open Graph tags configured
- Category theming working
- Visual fidelity 95%+ compared to Webflow
- Lighthouse score 90+ achieved
- All 35+ articles accessible
- Cross-browser tested
- Mobile responsive
- sprint-status.yaml updated to mark story as in-progress
Dev Agent Record
Agent Model Used
To be filled by Dev Agent
Debug Log References
To be filled by Dev Agent
Completion Notes
To be filled by Dev Agent
File List
To be filled by Dev Agent
Change Log
| Date | Action | Author |
|---|---|---|
| 2026-01-31 | Story created (ready-for-dev) | SM Agent (Bob) |