diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md
index 26a485e3..a52c6458 100644
--- a/.claude/CLAUDE.md
+++ b/.claude/CLAUDE.md
@@ -124,7 +124,7 @@ See `@.claude/DESIGN.md` for container conventions, CSS/typography rules, and fu
|-----------------------|----------------------------------|--------------------------------------------------------------------------------|
| `/write-post` | `commands/write-post.md` | Write a new blog post |
| `/generate-images` | `commands/generate-images.md` | Add images to blog posts + Gemini prompts |
-| `/add-changelog` | `commands/add-changelog.md` | Add a changelog entry |
+| `/roadmap-changelog` | `commands/roadmap-changelog.md` | Add/move/delete roadmap items and changelog entries in GitHub Projects |
| `/persona-page` | `commands/persona-page.md` | Create or update persona page (scaffold + intro + FAQs from live API data) |
| `/create-story` | `commands/create-story.md` | Create Storybook story for a component |
| `/add-ow-ui` | `commands/add-ow-ui.md` | Move component to `ow-ui/`, rename to `Ow*`, create story, report usages |
diff --git a/.claude/commands/add-changelog.md b/.claude/commands/add-changelog.md
deleted file mode 100644
index 4323e22f..00000000
--- a/.claude/commands/add-changelog.md
+++ /dev/null
@@ -1,38 +0,0 @@
-# Add a changelog entry
-
-## File
-
-Single file: `content/changelog.mdx`
-
-Entries are appended at the **top** (newest first), below the `# Changelog` heading.
-
-## Format
-
-```mdx
-## YYYY-MM-DD | Tiêu đề ngắn gọn, rõ ràng
-
-- Bullet mô tả WHAT và WHY, không chỉ liệt kê tính năng
-- Technical terms giữ tiếng Anh (API, IndexedDB, MCP, SSR, ISR, WebP...)
-- Tối đa 4 bullets mỗi entry
-```
-
-## Rules
-
-- Vietnamese, professional tone — no marketing words ("đột phá", "tuyệt vời")
-- No em dashes in bullet text — dùng dấu phẩy, dấu hai chấm
-- Numbers must be verified from code, not from memory (card count, bank count, etc.)
-- Only add entries for: card/bank data updates, new features, API changes, infra decisions, technical optimizations with clear impact
-- Do NOT add entries for: pure UI changes (color/spacing), minor bug fixes, marketing page content, internal config
-
-## When to add
-
-After completing:
-- Card or bank data changes (even small, if paired with pipeline improvement)
-- New feature launch
-- Significant API change (new endpoint, new filter)
-- Infrastructure decision (hosting, framework, test suite)
-- Technical optimization with measurable impact (WebP, cache, ISR)
-
-## Full guidelines
-
-See `.claude/docs/changelog.md` for tone examples, good vs bad entries, and edge cases.
diff --git a/.claude/commands/roadmap-changelog.md b/.claude/commands/roadmap-changelog.md
new file mode 100644
index 00000000..8e29b84d
--- /dev/null
+++ b/.claude/commands/roadmap-changelog.md
@@ -0,0 +1,119 @@
+# /roadmap — Manage GitHub Projects Roadmap + Changelog
+
+`/roadmap` is the SSOT for both the public roadmap and changelog at `/roadmap`.
+Edit in GitHub Projects UI → ISR revalidates within 60 min.
+
+## Project IDs (do not change)
+
+```
+Project number : 1
+Owner : openwalletvn
+Project ID : PVT_kwDOD5xEPM4BamAq
+Status field ID: PVTSSF_lADOD5xEPM4BamAqzhVcfVc
+Shipped field ID: PVTF_lADOD5xEPM4BamAqzhVcoYs
+```
+
+## Status column option IDs
+
+| Column | ID |
+|-------------|------------|
+| Todo | f75ad846 |
+| In Progress | 47fc9ee4 |
+| Done | 98236657 |
+
+## Page sections
+
+| Section | Source |
+|---------|--------|
+| Kanban (Todo / Planned / Future Ideas) | GH Projects non-Done items |
+| Recently Shipped timeline | GH Projects Done items, sorted by Shipped date desc |
+
+## Adding a roadmap item (future work)
+
+```sh
+# 1. Create draft item
+gh api graphql -f query='
+ mutation($projectId: ID!, $title: String!) {
+ addProjectV2DraftIssue(input: {projectId: $projectId, title: $title}) {
+ projectItem { id }
+ }
+ }' \
+ -f projectId="PVT_kwDOD5xEPM4BamAq" \
+ -f title="
"
+
+# 2. Move to correct column
+gh project item-edit \
+ --id \
+ --project-id PVT_kwDOD5xEPM4BamAq \
+ --field-id PVTSSF_lADOD5xEPM4BamAqzhVcfVc \
+ --single-select-option-id
+```
+
+## Adding a changelog entry (shipped feature)
+
+1. Create draft item with title in English
+2. Set body (markdown bullets, Vietnamese) — this is the changelog body
+3. Set Status → Done (option ID: `98236657`)
+4. Set Shipped date field via GraphQL:
+
+```sh
+gh api graphql -f query='
+ mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $date: Date!) {
+ updateProjectV2ItemFieldValue(input: {
+ projectId: $projectId itemId: $itemId fieldId: $fieldId value: { date: $date }
+ }) { projectV2Item { id } }
+ }' \
+ -f projectId="PVT_kwDOD5xEPM4BamAq" \
+ -f itemId="" \
+ -f fieldId="PVTF_lADOD5xEPM4BamAqzhVcoYs" \
+ -f date="YYYY-MM-DD"
+```
+
+## Changelog body rules
+
+- Vietnamese, professional tone — no marketing words ("đột phá", "tuyệt vời")
+- No em dashes in bullet text — dùng dấu phẩy, dấu hai chấm
+- Technical terms stay English (API, IndexedDB, MCP, SSR, ISR, WebP...)
+- Max 4 bullets per entry
+- Numbers must be verified from code, not from memory
+- Only add for: card/bank data updates, new features, API changes, infra decisions, technical optimizations with measurable impact
+- Do NOT add for: pure UI changes, minor bug fixes, marketing page content, internal config
+
+## Moving item to Done (when work ships)
+
+```sh
+# Get item ID
+gh project item-list 1 --owner openwalletvn --format json | jq '.items[] | select(.title == "") | .id'
+
+# Move to Done
+gh project item-edit \
+ --id \
+ --project-id PVT_kwDOD5xEPM4BamAq \
+ --field-id PVTSSF_lADOD5xEPM4BamAqzhVcfVc \
+ --single-select-option-id 98236657
+
+# Set Shipped date (see above)
+
+# Update body with changelog bullets if not already set
+gh api graphql -f query='
+ mutation($id: ID!, $body: String!) {
+ updateProjectV2DraftIssue(input: { draftIssueId: $id, body: $body }) {
+ draftIssue { id }
+ }
+ }' \
+ -f id="" \
+ -f body=""
+```
+
+## List all items with status
+
+```sh
+gh project item-list 1 --owner openwalletvn --format json | jq '.items[] | {title: .title, status: .status}'
+```
+
+## Notes
+
+- `/roadmap` page ISR revalidates every 60 min. Changes appear within 60 min.
+- `GITHUB_TOKEN` env var must have `read:project` scope for the page to fetch data.
+- `gh` CLI needs `read:project` scope: `gh auth refresh -s read:project`
+- Done item `draftIssueId` ≠ project `itemId` — use GraphQL to get `DraftIssue.id` for body updates.
diff --git a/.storybook/__mocks__/mdx-components.tsx b/.storybook/__mocks__/mdx-components.tsx
new file mode 100644
index 00000000..02a3a21e
--- /dev/null
+++ b/.storybook/__mocks__/mdx-components.tsx
@@ -0,0 +1,2 @@
+// Storybook mock — avoids pulling in Node-only deps from mdx-components
+export const mdxComponents = {};
diff --git a/.storybook/__mocks__/next-mdx-remote-rsc.tsx b/.storybook/__mocks__/next-mdx-remote-rsc.tsx
new file mode 100644
index 00000000..2f349f31
--- /dev/null
+++ b/.storybook/__mocks__/next-mdx-remote-rsc.tsx
@@ -0,0 +1,6 @@
+import React from 'react';
+
+// Storybook mock — renders markdown source as plain text (no Node deps)
+export function MDXRemote({source}: {source: string}) {
+ return
+ );
+}
diff --git a/content/changelog.mdx b/content/changelog.mdx
deleted file mode 100644
index 845dca94..00000000
--- a/content/changelog.mdx
+++ /dev/null
@@ -1,92 +0,0 @@
-# Changelog
-
-## 2026-06-06 | Cải tiến Owie Chat
-
-- Model selector: chọn model AI trực tiếp trong cửa sổ chat, lưu lại giữa các phiên
-- Context window ring: hiển thị mức sử dụng context window theo thời gian thực
-- Giao diện mới: border động, custom scrollbar, placeholder xoay vòng theo ngữ cảnh câu hỏi
-- Nháy đôi vào placeholder để điền nhanh câu hỏi mẫu
-- Gợi ý trang liên quan sau khi xếp hạng hoặc so sánh thẻ
-- Danh sách ngân hàng được tích hợp vào system prompt, giúp AI nhận biết chính xác các ngân hàng trong hệ thống
-
-## 2026-05-25 | Cải tiến Card Match
-
-- Hiển thị cashback rate chính xác theo từng mục chi tiêu, không dùng rate tổng hợp chung
-- Bộ lọc intent mới: giao diện phẳng, nhóm theo danh mục, luôn hiển thị cashback breakdown
-- Toggle sắp xếp: theo cashback hoặc phí thường niên, đồng bộ với URL và localStorage
-- API trở thành nguồn duy nhất cho toàn bộ logic xếp hạng, web chỉ hiển thị kết quả
-
-## 2026-05-24 | Observability cho Owie Chat với Langfuse
-
-- Mỗi cuộc hội thoại AI được trace đầy đủ: model, số token, latency, lý do kết thúc, số bước gọi tool
-- System prompt được quản lý trong Langfuse Prompt Management, phiên bản được ghi nhận theo từng trace để theo dõi tác động của thay đổi prompt đến chất lượng
-- LLM-as-judge tự động chấm điểm phản hồi AI trên traffic thực, phát hiện hallucination theo thời gian thực
-- Kết quả eval chuyển từ GitHub JSONL sang Langfuse Scores, liên kết trực tiếp với phiên bản prompt
-
-## 2026-05-23 | Hệ thống Evals cho Owie Chat
-
-- Bộ test case đánh giá chất lượng Owie Chat
-- Kết quả được lưu lên GitHub để so sánh giữa các lần chạy theo thời gian
-- Nền tảng kiểm soát chất lượng độc lập với model cho tính năng AI đang trong giai đoạn beta
-
-## 2026-05-22 | Owie Chat (beta)
-
-- Hỏi AI về thẻ tín dụng trực tiếp trên trang, được xây dựng với Vercel AI SDK v6, LLM và assistant-ui
-- AI gọi trực tiếp OpenWallet API qua MCP để trả lời dựa trên dữ liệu thực, không phỏng đoán
-- System prompt tiếng Việt, scope-locked: chỉ trả lời về thẻ tín dụng, từ chối các câu hỏi ngoài phạm vi
-
-## 2026-05-21 | MCP server (beta) + Nâng cấp hạ tầng API v2
-
-- MCP server với 8 tools: Claude và các AI agent có thể dùng OpenWallet làm nguồn dữ liệu thẻ tín dụng
-- Thử nghiệm tại [inspector.openwallet.vn](https://inspector.openwallet.vn)
-- API refactor từ Next.js sang Hono + Cloudflare Workers, tối ưu cho Edge Runtime với bundle size nghiêm ngặt
-- Bổ sung full test suite: unit, integration và contract tests để đảm bảo tính nhất quán của API
-
-## 2026-05-20 | Chuyển sang Vercel
-
-- Trang `/card-battle` cần `dynamicParams=true` kết hợp ISR để render mọi cặp thẻ tùy ý theo yêu cầu
-- Cloudflare static export không hỗ trợ dynamic params, các URL chưa được pre-render sẽ trả về 404
-- Chuyển sang Vercel SSR/ISR: cặp thẻ bất kỳ đều render được, cache tự động sau lần đầu
-
-## 2026-05-19 | Hệ thống xếp hạng cashback
-
-- Xếp hạng thẻ theo cashback ước tính dựa trên mức chi tiêu thực tế người dùng nhập vào
-- Bộ lọc chi tiêu tương tác: thay đổi số tiền, bảng xếp hạng cập nhật ngay lập tức
-- Single source of truth cho toàn bộ logic xếp hạng, có thể kiểm chứng và audit độc lập với UI
-
-## 2026-03-22 | So sánh thẻ tín dụng
-
-- So sánh tối đa 3 thẻ cùng lúc tại `/card-battle` với bảng phân tích chi tiết
-- Phân tích cashback và lợi ích theo từng thẻ, OG image động cho từng cặp so sánh
-
-## 2026-03-20 | Ứng dụng ví cá nhân (WIP)
-
-Tính năng đang trong giai đoạn phát triển
-
-- Quản lý thẻ tín dụng cá nhân trực tiếp trên trình duyệt, không cần tạo tài khoản
-- Theo dõi hạn thanh toán, lịch sao kê, trạng thái từng thẻ
-- Local-first: dữ liệu lưu hoàn toàn trên thiết bị với IndexedDB, không đồng bộ lên server
-
-## 2026-03-04 | Tối ưu ảnh thẻ + Pipeline dữ liệu tự động
-
-- Toàn bộ ảnh thẻ chuyển sang AVIF/WEBP với LQIP placeholder, API ảnh với cache headers chuẩn
-- Pipeline dữ liệu khép kín: Python scraper thu thập thông tin từ website ngân hàng, LLM trích xuất thành JSON có cấu trúc, Zod validation, admin dashboard để review và publish
-- Hệ thống pipeline này là nền tảng đằng sau 320+ thẻ hiện có trong hệ thống
-
-## 2026-02-27 | Mở rộng dữ liệu và bộ lọc nâng cao
-
-- Thêm dữ liệu 7 ngân hàng: BIDV, SeABank, OCB, LPBank, VietinBank, Sacombank, MB Bank
-- Bộ lọc đa giá trị cùng lúc, card tier rank, bộ lọc thẻ kim loại, network tier
-- Data quality scoring cho từng thẻ trong hệ thống
-
-## 2026-02-18 | Nền tảng dữ liệu thẻ
-
-- Hệ thống dữ liệu đầy đủ: banks, cards, networks, brands, contactless methods
-- Bộ lọc thẻ
-- Admin UI nội bộ để quản lý và cập nhật dữ liệu
-
-## 2026-02-16 | Ra mắt OpenWallet API v1
-
-- Phiên bản đầu tiên với OpenAPI 3.1 spec đầy đủ
-- REST API chuẩn cho dữ liệu thẻ tín dụng Việt Nam
-- Nền tảng cho toàn bộ hệ sinh thái OpenWallet
diff --git a/lib/api-types.generated.ts b/lib/api-types.generated.ts
index dcc5fce3..e794981d 100644
--- a/lib/api-types.generated.ts
+++ b/lib/api-types.generated.ts
@@ -1641,6 +1641,8 @@ export interface operations {
rule_intent?: "shopee" | "lazada" | "tiktok-shop" | "tiki" | "ecommerce" | "grab" | "transport" | "dining" | "shopee-food" | "grab-food" | "vietnam-airlines" | "bamboo-airways" | "agoda" | "travel" | "groceries" | "shopping" | "digital" | "insurance" | "education" | "health" | "cinema" | "entertainment" | "golf" | "ads" | "telecom" | "fashion" | "pets" | "books" | "all";
/** @description Filter by cashback benefit. "true" returns only cards with ≥1 cashback rule. "false" returns only cards with no cashback rules. */
has_cashback?: "true" | "false";
+ /** @description Filter by annual fee. "true" returns only cards with no annual fee (amount === 0 or unset). "false" returns only cards with a non-zero annual fee. */
+ annual_fee_free?: "true" | "false";
};
header?: never;
path?: never;
diff --git a/lib/changelog.ts b/lib/changelog.ts
deleted file mode 100644
index 6876bf55..00000000
--- a/lib/changelog.ts
+++ /dev/null
@@ -1,44 +0,0 @@
-import fs from 'fs';
-import path from 'path';
-
-const CHANGELOG_PATH = path.join(process.cwd(), 'content/changelog.mdx');
-
-export interface ChangelogEntry {
- date: string;
- title: string;
- content: string;
-}
-
-/**
- * Parse a single changelog.mdx file.
- * Each entry is an `## YYYY-MM-DD | Title` heading followed by a bullet list.
- */
-export function getAllChangelogs(): ChangelogEntry[] {
- if (!fs.existsSync(CHANGELOG_PATH)) return [];
-
- const raw = fs.readFileSync(CHANGELOG_PATH, 'utf-8');
- const entries: ChangelogEntry[] = [];
-
- // Split on ## headings, keeping the heading text
- const sections = raw.split(/^## /m).slice(1); // skip content before first ##
-
- for (const section of sections) {
- const newlineIdx = section.indexOf('\n');
- if (newlineIdx === -1) continue;
-
- const heading = section.slice(0, newlineIdx).trim();
- const content = section.slice(newlineIdx + 1).trim();
-
- // Parse "YYYY-MM-DD | Title"
- const match = heading.match(/^(\d{4}-\d{2}-\d{2})\s*\|\s*(.+)$/);
- if (!match) continue;
-
- entries.push({
- date: match[1],
- title: match[2].trim(),
- content,
- });
- }
-
- return entries;
-}
diff --git a/lib/github-project.ts b/lib/github-project.ts
new file mode 100644
index 00000000..24bd1891
--- /dev/null
+++ b/lib/github-project.ts
@@ -0,0 +1,222 @@
+export interface GithubProjectLabel {
+ name: string;
+ color: string;
+}
+
+export interface GithubProjectItem {
+ id: string;
+ title: string;
+ body: string;
+ url: string | null;
+ state: 'OPEN' | 'CLOSED' | null;
+ labels: GithubProjectLabel[];
+ isDraft: boolean;
+ shippedDate: string | null;
+}
+
+export interface GithubProjectColumn {
+ id: string;
+ name: string;
+ color: string;
+ items: GithubProjectItem[];
+}
+
+export interface GithubProjectBoard {
+ id: string;
+ title: string;
+ url: string;
+ columns: GithubProjectColumn[];
+}
+
+const GITHUB_GRAPHQL = 'https://api.github.com/graphql';
+
+const STATUS_COLOR_MAP: Record = {
+ GREEN: '#2da44e',
+ YELLOW: '#bf8700',
+ ORANGE: '#e16f24',
+ RED: '#cf222e',
+ PINK: '#bf3989',
+ PURPLE: '#8250df',
+ BLUE: '#0969da',
+ GRAY: '#6e7781',
+};
+
+const QUERY = `
+query($org: String!, $number: Int!) {
+ organization(login: $org) {
+ projectV2(number: $number) {
+ id
+ title
+ url
+ fields(first: 20) {
+ nodes {
+ ... on ProjectV2SingleSelectField {
+ id
+ name
+ options { id name color }
+ }
+ }
+ }
+ items(first: 100) {
+ nodes {
+ id
+ content {
+ ... on Issue {
+ title
+ body
+ url
+ state
+ labels(first: 5) { nodes { name color } }
+ }
+ ... on DraftIssue {
+ title
+ body
+ }
+ }
+ fieldValues(first: 10) {
+ nodes {
+ ... on ProjectV2ItemFieldSingleSelectValue {
+ name
+ field { ... on ProjectV2SingleSelectField { name } }
+ }
+ ... on ProjectV2ItemFieldDateValue {
+ date
+ field { ... on ProjectV2Field { name } }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
+`;
+
+interface RawFieldNode {
+ id?: string;
+ name?: string;
+ options?: { id: string; name: string; color: string }[];
+}
+
+interface RawItemContent {
+ title?: string;
+ body?: string;
+ url?: string;
+ state?: 'OPEN' | 'CLOSED';
+ labels?: { nodes: { name: string; color: string }[] };
+}
+
+interface RawFieldValue {
+ name?: string;
+ date?: string;
+ field?: { name?: string };
+}
+
+interface RawItem {
+ id: string;
+ content: RawItemContent | null;
+ fieldValues: { nodes: RawFieldValue[] };
+}
+
+interface RawResponse {
+ data: {
+ organization: {
+ projectV2: {
+ id: string;
+ title: string;
+ url: string;
+ fields: { nodes: RawFieldNode[] };
+ items: { nodes: RawItem[] };
+ };
+ };
+ };
+ errors?: { message: string }[];
+}
+
+export async function fetchGitHubProject(
+ org: string,
+ projectNumber: number,
+): Promise {
+ const token = process.env.GITHUB_TOKEN;
+ if (!token) throw new Error('GITHUB_TOKEN env var not set');
+
+ const res = await fetch(GITHUB_GRAPHQL, {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${token}`,
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ query: QUERY, variables: { org, number: projectNumber } }),
+ next: { revalidate: 3600 },
+ });
+
+ if (!res.ok) throw new Error(`GitHub GraphQL error: ${res.status}`);
+
+ const json = (await res.json()) as RawResponse;
+ if (json.errors?.length) throw new Error(json.errors[0].message);
+
+ const project = json.data.organization.projectV2;
+
+ const statusField = project.fields.nodes.find(
+ (f) => f.name === 'Status' && f.options,
+ );
+ const options = statusField?.options ?? [];
+
+ const columnMap = new Map();
+ for (const opt of options) {
+ columnMap.set(opt.name, {
+ id: opt.id,
+ name: opt.name,
+ color: STATUS_COLOR_MAP[opt.color] ?? '#6e7781',
+ items: [],
+ });
+ }
+ const untriagedColumn: GithubProjectColumn = {
+ id: 'untriaged',
+ name: 'Untriaged',
+ color: STATUS_COLOR_MAP['GRAY'],
+ items: [],
+ };
+
+ for (const rawItem of project.items.nodes) {
+ if (!rawItem.content) continue;
+ const content = rawItem.content;
+ const isDraft = !('url' in content);
+
+ const statusValue = rawItem.fieldValues.nodes.find(
+ (fv) => fv.field?.name === 'Status',
+ );
+ const shippedValue = rawItem.fieldValues.nodes.find(
+ (fv) => fv.field?.name === 'Shipped',
+ );
+
+ const item: GithubProjectItem = {
+ id: rawItem.id,
+ title: content.title ?? '',
+ body: content.body ?? '',
+ url: 'url' in content ? (content.url ?? null) : null,
+ state: 'state' in content ? (content.state ?? null) : null,
+ labels: 'labels' in content ? (content.labels?.nodes ?? []) : [],
+ isDraft,
+ shippedDate: shippedValue?.date ?? null,
+ };
+ const colName = statusValue?.name;
+ const col = colName ? columnMap.get(colName) : null;
+
+ if (col) {
+ col.items.push(item);
+ } else {
+ untriagedColumn.items.push(item);
+ }
+ }
+
+ const columns = [...columnMap.values()];
+ if (untriagedColumn.items.length > 0) columns.push(untriagedColumn);
+
+ return {
+ id: project.id,
+ title: project.title,
+ url: project.url,
+ columns,
+ };
+}
diff --git a/lib/routes.ts b/lib/routes.ts
index 85e6ab69..c230d722 100644
--- a/lib/routes.ts
+++ b/lib/routes.ts
@@ -16,7 +16,8 @@ export const ROUTES = {
openwalletApp: '/openwallet-app',
// Info pages
- changelog: '/changelog',
+ changelog: '/roadmap',
+ roadmap: '/roadmap',
contact: '/lien-he',
terms: '/dieu-khoan',
privacy: '/chinh-sach-bao-mat',
diff --git a/next.config.ts b/next.config.ts
index 3fcd2881..a251af2e 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -90,6 +90,7 @@ const nextConfig: NextConfig = {
destination: '/ngan-hang/:slug*',
permanent: true,
},
+ { source: '/changelog', destination: '/roadmap', permanent: true },
{ source: '/openwallet-chat', destination: '/owie-chat', permanent: true },
{ source: '/openwallet-mcp', destination: '/mcp', permanent: true },
{ source: '/blog', destination: '/tin-tuc', permanent: true },