docs: separate documentation and specs into initial commit
Establish baseline for project documentation including BMAD specs, PRD, and system architecture notes.
This commit is contained in:
688
_bmad-output/implementation-artifacts/1-9-blog-system.story.md
Normal file
688
_bmad-output/implementation-artifacts/1-9-blog-system.story.md
Normal file
@@ -0,0 +1,688 @@
|
||||
# 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](../../research/www.enchun.tw/news.html) - Blog 列表頁
|
||||
- [Source: research/www.enchun.tw/xing-xiao-fang-da-jing/](../../research/www.enchun.tw/xing-xiao-fang-da-jing/) - Blog 文章資料夾
|
||||
- [Source: research/www.enchun.tw/wen-zhang-fen-lei](../../research/www.enchun.tw/wen-zhang-fen-lei) - Blog 分類資料夾
|
||||
|
||||
**Existing Infrastructure (Ready to Use!):**
|
||||
- Posts collection at `apps/backend/src/collections/Posts/index.ts` with fields: title, slug, heroImage, ogImage, content (richText), excerpt, categories, relatedPosts, publishedAt, authors, status
|
||||
- Categories collection at `apps/backend/src/collections/Categories.ts` with theming colors
|
||||
- Placeholder pages already exist at `news.astro` and `xing-xiao-fang-da-jing/[slug].astro`
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Blog Listing Page (`/blog` or `/news`)
|
||||
1. **AC1 - Display Published Posts**: Only posts with status='published' are displayed
|
||||
2. **AC2 - Category Filter**: 4 category filter buttons that filter posts dynamically
|
||||
3. **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)
|
||||
4. **AC4 - Pagination**: Implement pagination (12 posts per page) with page navigation
|
||||
5. **AC5 - Visual Fidelity**: Design matches Webflow news.html with 95%+ similarity
|
||||
|
||||
### Article Detail Page (`/blog/[slug]` or `/xing-xiao-fang-da-jing/[slug]`)
|
||||
1. **AC6 - Full Content Display**: Rich text content rendered with proper styling
|
||||
2. **AC7 - Meta Information**: Category badge and published date displayed
|
||||
3. **AC8 - Related Articles**: Show 3-4 related posts from same category
|
||||
4. **AC9 - Social Sharing**: OG tags configured (ogImage, ogTitle, ogDescription)
|
||||
5. **AC10 - Rich Text Rendering**: Lexical editor output matches Webflow formatting
|
||||
|
||||
### Category Page (`/blog/category/[slug]` or `/wen-zhang-fen-lei/[slug]`)
|
||||
1. **AC11 - Category Filtering**: Shows only posts belonging to selected category
|
||||
2. **AC12 - Category Description**: Displays category name and description
|
||||
3. **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):**
|
||||
```typescript
|
||||
// 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:**
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```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
|
||||
|
||||
```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)
|
||||
|
||||
```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)
|
||||
|
||||
```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)
|
||||
|
||||
```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 page
|
||||
- `page`: Page number
|
||||
- `sort`: Sort field (e.g., `-publishedAt` for descending)
|
||||
- `where`: JSON-encoded filter conditions
|
||||
- `depth`: 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 `/blog` vs `/news` URLs
|
||||
- [ ] Plan 301 redirects from Webflow URLs
|
||||
- [ ] Document routing decision
|
||||
|
||||
- [ ] **Task 1.2**: Create API utilities
|
||||
- [ ] Create `apps/frontend/src/lib/api.ts` with helper functions
|
||||
- [ ] Add type definitions for Post and Category
|
||||
- [ ] Implement error handling for API calls
|
||||
|
||||
### 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
|
||||
|
||||
- [ ] **Task 2.2**: Create CategoryFilter component
|
||||
- [ ] Create `components/blog/CategoryFilter.astro`
|
||||
- [ ] Implement filter button layout
|
||||
- [ ] Add active state styling
|
||||
|
||||
- [ ] **Task 2.3**: Create RelatedPosts component
|
||||
- [ ] Create `components/blog/RelatedPosts.astro`
|
||||
- [ ] Display 3 related posts
|
||||
- [ ] Reuse ArticleCard for consistency
|
||||
|
||||
### 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
|
||||
|
||||
- [ ] **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
|
||||
|
||||
- [ ] **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
|
||||
|
||||
- [ ] **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
|
||||
```typescript
|
||||
// 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) |
|
||||
Reference in New Issue
Block a user