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