# 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
``` ### 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] ---
{post.heroImage && (
{post.title}
)}
{category && ( {category.title} )}

{post.title}

{post.excerpt?.slice(0, 150)}...

``` #### 2. CategoryFilter.astro ```astro --- interface Props { categories: Array<{ title: string, slug: string }> activeCategory?: string } const { categories, activeCategory } = Astro.props --- ``` #### 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 ---

{title}

{posts.map(post => ( ))}
{posts.length === 0 && (

沒有找到文章

)} {totalPages > 1 && ( )}
``` #### 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 ---
{category && ( )}

{post.title}

{post.heroImage && (
{post.title}
)}
``` #### 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 || [] ---

{category.title}

{category.nameEn && (

{category.nameEn}

)}
{posts.map(post => ( ))}
{posts.length === 0 && (

此分類暫無文章

)}
``` ### 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) |