From d278f3610f93c443e4fc1f48b5c63f57e8bbd139 Mon Sep 17 00:00:00 2001 From: phucbm Date: Sun, 14 Jun 2026 11:25:42 +0700 Subject: [PATCH 1/7] feat(roadmap): public /roadmap page from GitHub Projects - fetchGitHubProject() GraphQL util with ISR revalidate:3600 - OwRoadmapCard, OwRoadmapColumn, OwRoadmapKanban components in owui/ - Kanban grid layout + Recently Completed section + changelog CTA - Column name/label mapping via props (flexible, not hardcoded) - Storybook stories under Roadmap UI group - /roadmap command doc with gh CLI field/option IDs - ROUTES.roadmap + footer link Co-Authored-By: Claude Sonnet 4.6 --- .claude/CLAUDE.md | 1 + .claude/commands/roadmap.md | 73 ++++++ app/(marketing)/roadmap/page.tsx | 58 +++++ components/layout/footer.tsx | 1 + components/owui/ow-roadmap-card.tsx | 53 +++++ components/owui/ow-roadmap-column.tsx | 31 +++ components/owui/ow-roadmap-kanban.stories.tsx | 111 +++++++++ components/owui/ow-roadmap-kanban.tsx | 60 +++++ lib/github-project.ts | 212 ++++++++++++++++++ lib/routes.ts | 1 + 10 files changed, 601 insertions(+) create mode 100644 .claude/commands/roadmap.md create mode 100644 app/(marketing)/roadmap/page.tsx create mode 100644 components/owui/ow-roadmap-card.tsx create mode 100644 components/owui/ow-roadmap-column.tsx create mode 100644 components/owui/ow-roadmap-kanban.stories.tsx create mode 100644 components/owui/ow-roadmap-kanban.tsx create mode 100644 lib/github-project.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 26a485e3..29200cde 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -131,6 +131,7 @@ See `@.claude/DESIGN.md` for container conventions, CSS/typography rules, and fu | `/commit-all` | `commands/commit-all.md` | Stage + commit all changes in logical groups with conventional commit messages | | `/sync-api-types` | `commands/sync-api-types.md` | Regenerate types from API schema, diff changes, scan codebase, suggest updates | | `/edit-system-prompt` | `commands/edit-system-prompt.md` | Edit Owie's system prompt, enforce invariants, push to Langfuse | +| `/roadmap` | `commands/roadmap.md` | Add/move/delete items in GitHub Projects roadmap via gh CLI | @.claude/docs/openwallet-brain.md @.claude/docs/architecture.md diff --git a/.claude/commands/roadmap.md b/.claude/commands/roadmap.md new file mode 100644 index 00000000..c08cb376 --- /dev/null +++ b/.claude/commands/roadmap.md @@ -0,0 +1,73 @@ +# /roadmap — Manage GitHub Projects Roadmap + +Manage the OpenWallet roadmap at https://github.com/orgs/openwalletvn/projects/1 via `gh` CLI. + +## Project IDs (do not change) + +``` +Project number : 1 +Owner : openwalletvn +Project ID : PVT_kwDOD5xEPM4BamAq +Status field ID: PVTSSF_lADOD5xEPM4BamAqzhVcfVc +``` + +## Status column option IDs + +| Column | ID | +|-------------|------------| +| Todo | f75ad846 | +| In Progress | 47fc9ee4 | +| Done | 98236657 | + +## Common operations + +### List all items with status +```sh +gh project item-list 1 --owner openwalletvn --format json | jq '.items[] | {title: .title, status: .status}' +``` + +### Create an issue and add to project +```sh +# 1. Create issue +gh issue create --repo openwalletvn/web --title "Feature: " --body "<description>" + +# 2. Add to project (use the issue URL from step 1) +gh project item-add 1 --owner openwalletvn --url <issue-url> +``` + +### Move item to a different column +```sh +# Get item ID first +gh project item-list 1 --owner openwalletvn --format json | jq '.items[] | select(.title == "<title>") | .id' + +# Move to column (replace ITEM_ID and OPTION_ID) +gh project item-edit \ + --id <ITEM_ID> \ + --project-id PVT_kwDOD5xEPM4BamAq \ + --field-id PVTSSF_lADOD5xEPM4BamAqzhVcfVc \ + --single-select-option-id <OPTION_ID> +``` + +### Add a draft item (no issue needed) +```sh +gh project item-add 1 --owner openwalletvn --title "<title>" +``` + +### Archive/delete an item +```sh +gh project item-delete 1 --owner openwalletvn --id <ITEM_ID> +``` + +## Workflow for "add roadmap item" requests + +1. Ask user: title, description (optional), which column (Todo/In Progress/Done) +2. If it maps to real work: create issue first, then add to project +3. If it's just a placeholder: add as draft item +4. Move to correct column +5. Confirm with: `gh project item-list 1 --owner openwalletvn --format json` + +## Notes + +- `/roadmap` page at openwallet.vn revalidates every hour (ISR). 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` diff --git a/app/(marketing)/roadmap/page.tsx b/app/(marketing)/roadmap/page.tsx new file mode 100644 index 00000000..df27a51c --- /dev/null +++ b/app/(marketing)/roadmap/page.tsx @@ -0,0 +1,58 @@ +import type {Metadata} from 'next'; +import {ROUTES} from '@/lib/routes'; +import {fetchGitHubProject} from '@/lib/github-project'; +import {OwRoadmapKanban} from '@/components/owui/ow-roadmap-kanban'; +import {MarketingPageShell} from '@/components/layout/marketing-page-shell'; +import {buildBreadcrumbJsonLd} from '@/lib/page-meta/breadcrumb'; +import {buildTitle} from '@/lib/page-meta/title'; + +export const revalidate = 3600; + +export const metadata: Metadata = { + title: buildTitle('Roadmap'), + description: 'Kế hoạch phát triển của OpenWallet: các tính năng đang được xây dựng và những gì sắp ra mắt.', + alternates: {canonical: ROUTES.roadmap}, + openGraph: { + title: 'Roadmap', + description: 'Kế hoạch phát triển của OpenWallet: các tính năng đang được xây dựng và những gì sắp ra mắt.', + }, + twitter: { + title: 'Roadmap', + description: 'Kế hoạch phát triển của OpenWallet: các tính năng đang được xây dựng và những gì sắp ra mắt.', + }, +}; + +export default async function RoadmapPage() { + const board = await fetchGitHubProject('openwalletvn', 1); + + const breadcrumbItems = [ + {label: 'Trang chủ', href: '/'}, + {label: 'Roadmap'}, + ]; + + const jsonLd = { + '@context': 'https://schema.org', + '@graph': [ + buildBreadcrumbJsonLd(breadcrumbItems), + ], + }; + + return ( + <MarketingPageShell + title="Roadmap" + description="Những tính năng chúng tôi đang xây dựng và kế hoạch sắp tới." + breadcrumbItems={breadcrumbItems} + jsonLd={jsonLd} + > + <OwRoadmapKanban + board={board} + columns={[ + {name: 'Todo', label: 'In Progress'}, + {name: 'Planned', label: 'Planned'}, + {name: 'Future Ideas', label: 'Future Ideas'}, + ]} + completedCol={{name: 'Done', label: 'Recently Completed'}} + /> + </MarketingPageShell> + ); +} diff --git a/components/layout/footer.tsx b/components/layout/footer.tsx index 28b2dd1e..18361a75 100644 --- a/components/layout/footer.tsx +++ b/components/layout/footer.tsx @@ -29,6 +29,7 @@ const LINKS = { {label: 'Về OpenWallet', href: '/ve-openwallet'}, {label: 'Liên hệ', href: ROUTES.contact}, {label: 'Changelog', href: ROUTES.changelog}, + {label: 'Roadmap', href: ROUTES.roadmap}, ], developer: [ {label: 'Dashboard', href: 'https://dash.openwallet.vn', external: true}, diff --git a/components/owui/ow-roadmap-card.tsx b/components/owui/ow-roadmap-card.tsx new file mode 100644 index 00000000..c2c454cb --- /dev/null +++ b/components/owui/ow-roadmap-card.tsx @@ -0,0 +1,53 @@ +import type {GithubProjectItem} from '@/lib/github-project'; + +function hexToRgb(hex: string): string { + const h = hex.replace('#', ''); + const r = parseInt(h.substring(0, 2), 16); + const g = parseInt(h.substring(2, 4), 16); + const b = parseInt(h.substring(4, 6), 16); + return `${r}, ${g}, ${b}`; +} + +export function OwRoadmapCard({item}: {item: GithubProjectItem}) { + const Wrapper = item.url ? 'a' : 'div'; + const wrapperProps = item.url + ? {href: item.url, target: '_blank', rel: 'noopener noreferrer'} + : {}; + + return ( + <Wrapper + {...wrapperProps} + className="block bg-white border border-border rounded-lg p-4 hover:border-border-mid transition-colors group no-underline" + > + <p className="text-sm font-medium text-text-primary leading-snug group-hover:text-text-accent transition-colors mb-3"> + {item.title} + </p> + + {item.labels.length > 0 && ( + <div className="flex flex-wrap gap-1.5"> + {item.labels.map((label) => { + const hex = label.color.startsWith('#') ? label.color : `#${label.color}`; + return ( + <span + key={label.name} + className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium" + style={{ + backgroundColor: `rgba(${hexToRgb(hex)}, 0.12)`, + color: hex, + }} + > + {label.name} + </span> + ); + })} + </div> + )} + + {item.isDraft && ( + <span className="inline-flex items-center mt-2 px-2 py-0.5 rounded-full text-xs font-medium bg-bg-muted text-text-muted"> + Draft + </span> + )} + </Wrapper> + ); +} diff --git a/components/owui/ow-roadmap-column.tsx b/components/owui/ow-roadmap-column.tsx new file mode 100644 index 00000000..64454102 --- /dev/null +++ b/components/owui/ow-roadmap-column.tsx @@ -0,0 +1,31 @@ +import type {GithubProjectColumn} from '@/lib/github-project'; +import {OwRoadmapCard} from './ow-roadmap-card'; + +export function OwRoadmapColumn({column, label}: {column: GithubProjectColumn; label: string}) { + return ( + <div className="flex flex-col"> + <div className="flex items-center gap-2 mb-3 px-1"> + <span + className="w-2.5 h-2.5 rounded-full flex-shrink-0" + style={{backgroundColor: column.color}} + /> + <span className="font-medium text-sm text-text-primary">{label}</span> + <span className="ml-auto text-xs text-text-subtle font-mono bg-bg-muted px-1.5 py-0.5 rounded"> + {column.items.length} + </span> + </div> + + <div className="flex flex-col gap-2"> + {column.items.length === 0 ? ( + <div className="border border-dashed border-border rounded-lg p-4 text-center text-text-subtle text-sm"> + Trống + </div> + ) : ( + column.items.map((item) => ( + <OwRoadmapCard key={item.id} item={item}/> + )) + )} + </div> + </div> + ); +} diff --git a/components/owui/ow-roadmap-kanban.stories.tsx b/components/owui/ow-roadmap-kanban.stories.tsx new file mode 100644 index 00000000..9c72d7a2 --- /dev/null +++ b/components/owui/ow-roadmap-kanban.stories.tsx @@ -0,0 +1,111 @@ +import type {Meta, StoryObj} from '@storybook/nextjs-vite'; +import type {GithubProjectBoard} from '@/lib/github-project'; +import {OwRoadmapKanban} from './ow-roadmap-kanban'; +import {OwStories, OwStorySection} from './ow-story-section'; + +const meta: Meta<typeof OwRoadmapKanban> = { + component: OwRoadmapKanban, + title: 'Roadmap UI/OwRoadmapKanban', + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: [ + 'Kanban board for displaying a GitHub Projects roadmap. Read-only, no actions.', + '', + '**`columns`** — ordered list of `{ name, label }` mapping GitHub column names to display labels.', + '**`completedCol`** — same shape; items shown as grid below the kanban, hidden if empty.', + '', + '```tsx', + 'const board = await fetchGitHubProject("openwalletvn", 1)', + '<OwRoadmapKanban', + ' board={board}', + ' columns={[', + ' { name: "In Progress", label: "In Progress" },', + ' { name: "Todo", label: "Planned" },', + ' { name: "Future Ideas", label: "Future Ideas" },', + ' ]}', + ' completedCol={{ name: "Done", label: "Recently Completed" }}', + '/>', + '```', + ].join('\n'), + }, + }, + }, +}; +export default meta; + +type Story = StoryObj<typeof OwRoadmapKanban>; + +const MOCK_BOARD: GithubProjectBoard = { + id: 'PVT_mock', + title: 'openwallet/roadmap', + url: 'https://github.com/orgs/openwalletvn/projects/1', + columns: [ + { + id: 'c1', + name: 'In Progress', + color: '#bf8700', + items: [ + {id: '1', title: 'Roadmap công khai tại /roadmap', body: '', url: 'https://github.com/openwalletvn/web/issues/3', state: 'OPEN', labels: [{name: 'feature', color: '#0075ca'}], isDraft: false}, + ], + }, + { + id: 'c2', + name: 'Todo', + color: '#2da44e', + items: [ + {id: '2', title: 'So sánh thẻ theo điểm thưởng', body: '', url: 'https://github.com/openwalletvn/web/issues/1', state: 'OPEN', labels: [{name: 'feature', color: '#0075ca'}], isDraft: false}, + {id: '3', title: 'Trang tổng hợp ưu đãi ngân hàng', body: '', url: null, state: null, labels: [], isDraft: true}, + ], + }, + { + id: 'c3', + name: 'Future Ideas', + color: '#0969da', + items: [ + {id: '4', title: 'App mobile OpenWallet', body: '', url: null, state: null, labels: [], isDraft: true}, + ], + }, + { + id: 'c4', + name: 'Done', + color: '#8250df', + items: [ + {id: '5', title: 'Tích hợp Owie Chat', body: '', url: 'https://github.com/openwalletvn/web/issues/4', state: 'CLOSED', labels: [{name: 'feature', color: '#0075ca'}], isDraft: false}, + {id: '6', title: 'Trang so sánh thẻ', body: '', url: 'https://github.com/openwalletvn/web/issues/5', state: 'CLOSED', labels: [], isDraft: false}, + {id: '7', title: 'SEO cơ bản cho trang thẻ', body: '', url: null, state: 'CLOSED', labels: [{name: 'seo', color: '#e4e669'}], isDraft: false}, + ], + }, + ], +}; + +const COLUMNS = [ + {name: 'In Progress', label: 'In Progress'}, + {name: 'Todo', label: 'Planned'}, + {name: 'Future Ideas', label: 'Future Ideas'}, +]; +const COMPLETED_COL = {name: 'Done', label: 'Recently Completed'}; + +const EMPTY_BOARD: GithubProjectBoard = { + ...MOCK_BOARD, + columns: MOCK_BOARD.columns.map((c) => ({...c, items: []})), +}; + +export const Overview: Story = { + render: () => ( + <OwStories> + <OwStorySection title="With items + completed section"> + <OwRoadmapKanban board={MOCK_BOARD} columns={COLUMNS} completedCol={COMPLETED_COL}/> + </OwStorySection> + </OwStories> + ), +}; + +export const WithItems: Story = { + args: {board: MOCK_BOARD, columns: COLUMNS, completedCol: COMPLETED_COL}, +}; + +export const EmptyBoard: Story = { + args: {board: EMPTY_BOARD, columns: COLUMNS, completedCol: COMPLETED_COL}, +}; diff --git a/components/owui/ow-roadmap-kanban.tsx b/components/owui/ow-roadmap-kanban.tsx new file mode 100644 index 00000000..9b98adbb --- /dev/null +++ b/components/owui/ow-roadmap-kanban.tsx @@ -0,0 +1,60 @@ +import Link from 'next/link'; +import type {GithubProjectBoard} from '@/lib/github-project'; +import {ROUTES} from '@/lib/routes'; +import {OwButton} from './ow-button'; +import {OwRoadmapCard} from './ow-roadmap-card'; +import {OwRoadmapColumn} from './ow-roadmap-column'; + +export interface OwRoadmapColConfig { + name: string; + label: string; +} + +export interface OwRoadmapKanbanProps { + board: GithubProjectBoard; + columns: OwRoadmapColConfig[]; + completedCol: OwRoadmapColConfig; +} + +export function OwRoadmapKanban({board, columns, completedCol}: OwRoadmapKanbanProps) { + const colMap = new Map(board.columns.map((c) => [c.name, c])); + + const kanbanCols = columns + .map((cfg) => ({cfg, col: colMap.get(cfg.name)})) + .filter((x): x is {cfg: OwRoadmapColConfig; col: NonNullable<typeof x.col>} => x.col !== undefined); + + const completedData = colMap.get(completedCol.name); + const completedItems = completedData?.items ?? []; + + return ( + <div className="mt-8 space-y-12"> + {/* Kanban columns */} + <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 items-start"> + {kanbanCols.map(({cfg, col}) => ( + <OwRoadmapColumn key={col.id} column={col} label={cfg.label}/> + ))} + </div> + + {/* Recently Completed */} + {completedItems.length > 0 && ( + <div> + <h2 className="mb-4">{completedCol.label}</h2> + <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> + {completedItems.map((item) => ( + <OwRoadmapCard key={item.id} item={item}/> + ))} + </div> + </div> + )} + + {/* CTA */} + <div className="border border-border rounded-xl p-8 text-center space-y-3"> + <p className="text-text-muted text-sm">Want to see everything we shipped?</p> + <p className="font-display text-xl text-text-primary">View the full changelog</p> + <OwButton color="outline" size="sm" asChild> + <Link href={ROUTES.changelog}>Xem Changelog</Link> + </OwButton> + </div> + </div> + ); +} diff --git a/lib/github-project.ts b/lib/github-project.ts new file mode 100644 index 00000000..cb0be82c --- /dev/null +++ b/lib/github-project.ts @@ -0,0 +1,212 @@ +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; +} + +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<string, string> = { + 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 } } + } + } + } + } + } + } + } +} +`; + +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; + 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<GithubProjectBoard> { + 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<string, GithubProjectColumn>(); + 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 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, + }; + + const statusValue = rawItem.fieldValues.nodes.find( + (fv) => fv.field?.name === 'Status', + ); + 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..2961600f 100644 --- a/lib/routes.ts +++ b/lib/routes.ts @@ -17,6 +17,7 @@ export const ROUTES = { // Info pages changelog: '/changelog', + roadmap: '/roadmap', contact: '/lien-he', terms: '/dieu-khoan', privacy: '/chinh-sach-bao-mat', From 9adb4d131aca3cc5353c9995a6dd66af678eb90b Mon Sep 17 00:00:00 2001 From: phucbm <hiphucbui@gmail.com> Date: Sun, 14 Jun 2026 11:41:04 +0700 Subject: [PATCH 2/7] feat(roadmap): shipped date field + sorted done items - Fetch Shipped date field from GitHub Projects via GraphQL - Add shippedDate to GithubProjectItem type - Show OwBadge with IconCircleCheckFilled + formatted date on done cards - Sort Done items latest-first in kanban Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- components/owui/ow-roadmap-card.tsx | 24 ++++++++++++------- components/owui/ow-roadmap-kanban.stories.tsx | 14 +++++------ components/owui/ow-roadmap-kanban.tsx | 7 +++++- lib/github-project.ts | 18 ++++++++++---- 4 files changed, 42 insertions(+), 21 deletions(-) diff --git a/components/owui/ow-roadmap-card.tsx b/components/owui/ow-roadmap-card.tsx index c2c454cb..0ee20b26 100644 --- a/components/owui/ow-roadmap-card.tsx +++ b/components/owui/ow-roadmap-card.tsx @@ -1,4 +1,11 @@ +import {IconCircleCheckFilled} from '@tabler/icons-react'; import type {GithubProjectItem} from '@/lib/github-project'; +import {OwBadge} from './ow-badge'; + +function formatShippedDate(dateStr: string): string { + const d = new Date(dateStr); + return d.toLocaleDateString('vi-VN', {day: '2-digit', month: '2-digit', year: 'numeric'}); +} function hexToRgb(hex: string): string { const h = hex.replace('#', ''); @@ -19,12 +26,17 @@ export function OwRoadmapCard({item}: {item: GithubProjectItem}) { {...wrapperProps} className="block bg-white border border-border rounded-lg p-4 hover:border-border-mid transition-colors group no-underline" > - <p className="text-sm font-medium text-text-primary leading-snug group-hover:text-text-accent transition-colors mb-3"> + <p className="text-sm font-medium text-text-primary leading-snug group-hover:text-text-accent transition-colors"> {item.title} </p> - {item.labels.length > 0 && ( - <div className="flex flex-wrap gap-1.5"> + {(item.shippedDate || item.labels.length > 0) && ( + <div className="flex flex-wrap gap-1.5 mt-2"> + {item.shippedDate && ( + <OwBadge small colorHex="#2da44e"> + <IconCircleCheckFilled size={12}/> {formatShippedDate(item.shippedDate)} + </OwBadge> + )} {item.labels.map((label) => { const hex = label.color.startsWith('#') ? label.color : `#${label.color}`; return ( @@ -42,12 +54,6 @@ export function OwRoadmapCard({item}: {item: GithubProjectItem}) { })} </div> )} - - {item.isDraft && ( - <span className="inline-flex items-center mt-2 px-2 py-0.5 rounded-full text-xs font-medium bg-bg-muted text-text-muted"> - Draft - </span> - )} </Wrapper> ); } diff --git a/components/owui/ow-roadmap-kanban.stories.tsx b/components/owui/ow-roadmap-kanban.stories.tsx index 9c72d7a2..89589b6b 100644 --- a/components/owui/ow-roadmap-kanban.stories.tsx +++ b/components/owui/ow-roadmap-kanban.stories.tsx @@ -47,7 +47,7 @@ const MOCK_BOARD: GithubProjectBoard = { name: 'In Progress', color: '#bf8700', items: [ - {id: '1', title: 'Roadmap công khai tại /roadmap', body: '', url: 'https://github.com/openwalletvn/web/issues/3', state: 'OPEN', labels: [{name: 'feature', color: '#0075ca'}], isDraft: false}, + {id: '1', title: 'Roadmap công khai tại /roadmap', body: '', url: 'https://github.com/openwalletvn/web/issues/3', state: 'OPEN', labels: [{name: 'feature', color: '#0075ca'}], isDraft: false, shippedDate: null}, ], }, { @@ -55,8 +55,8 @@ const MOCK_BOARD: GithubProjectBoard = { name: 'Todo', color: '#2da44e', items: [ - {id: '2', title: 'So sánh thẻ theo điểm thưởng', body: '', url: 'https://github.com/openwalletvn/web/issues/1', state: 'OPEN', labels: [{name: 'feature', color: '#0075ca'}], isDraft: false}, - {id: '3', title: 'Trang tổng hợp ưu đãi ngân hàng', body: '', url: null, state: null, labels: [], isDraft: true}, + {id: '2', title: 'So sánh thẻ theo điểm thưởng', body: '', url: 'https://github.com/openwalletvn/web/issues/1', state: 'OPEN', labels: [{name: 'feature', color: '#0075ca'}], isDraft: false, shippedDate: null}, + {id: '3', title: 'Trang tổng hợp ưu đãi ngân hàng', body: '', url: null, state: null, labels: [], isDraft: true, shippedDate: null}, ], }, { @@ -64,7 +64,7 @@ const MOCK_BOARD: GithubProjectBoard = { name: 'Future Ideas', color: '#0969da', items: [ - {id: '4', title: 'App mobile OpenWallet', body: '', url: null, state: null, labels: [], isDraft: true}, + {id: '4', title: 'App mobile OpenWallet', body: '', url: null, state: null, labels: [], isDraft: true, shippedDate: null}, ], }, { @@ -72,9 +72,9 @@ const MOCK_BOARD: GithubProjectBoard = { name: 'Done', color: '#8250df', items: [ - {id: '5', title: 'Tích hợp Owie Chat', body: '', url: 'https://github.com/openwalletvn/web/issues/4', state: 'CLOSED', labels: [{name: 'feature', color: '#0075ca'}], isDraft: false}, - {id: '6', title: 'Trang so sánh thẻ', body: '', url: 'https://github.com/openwalletvn/web/issues/5', state: 'CLOSED', labels: [], isDraft: false}, - {id: '7', title: 'SEO cơ bản cho trang thẻ', body: '', url: null, state: 'CLOSED', labels: [{name: 'seo', color: '#e4e669'}], isDraft: false}, + {id: '5', title: 'Tích hợp Owie Chat', body: '', url: 'https://github.com/openwalletvn/web/issues/4', state: 'CLOSED', labels: [{name: 'feature', color: '#0075ca'}], isDraft: false, shippedDate: '2026-05-22'}, + {id: '6', title: 'Trang so sánh thẻ', body: '', url: 'https://github.com/openwalletvn/web/issues/5', state: 'CLOSED', labels: [], isDraft: false, shippedDate: '2026-03-22'}, + {id: '7', title: 'SEO cơ bản cho trang thẻ', body: '', url: null, state: 'CLOSED', labels: [{name: 'seo', color: '#e4e669'}], isDraft: false, shippedDate: null}, ], }, ], diff --git a/components/owui/ow-roadmap-kanban.tsx b/components/owui/ow-roadmap-kanban.tsx index 9b98adbb..edfe253b 100644 --- a/components/owui/ow-roadmap-kanban.tsx +++ b/components/owui/ow-roadmap-kanban.tsx @@ -24,7 +24,12 @@ export function OwRoadmapKanban({board, columns, completedCol}: OwRoadmapKanbanP .filter((x): x is {cfg: OwRoadmapColConfig; col: NonNullable<typeof x.col>} => x.col !== undefined); const completedData = colMap.get(completedCol.name); - const completedItems = completedData?.items ?? []; + const completedItems = [...(completedData?.items ?? [])].sort((a, b) => { + if (!a.shippedDate && !b.shippedDate) return 0; + if (!a.shippedDate) return 1; + if (!b.shippedDate) return -1; + return b.shippedDate.localeCompare(a.shippedDate); + }); return ( <div className="mt-8 space-y-12"> diff --git a/lib/github-project.ts b/lib/github-project.ts index cb0be82c..24bd1891 100644 --- a/lib/github-project.ts +++ b/lib/github-project.ts @@ -11,6 +11,7 @@ export interface GithubProjectItem { state: 'OPEN' | 'CLOSED' | null; labels: GithubProjectLabel[]; isDraft: boolean; + shippedDate: string | null; } export interface GithubProjectColumn { @@ -78,6 +79,10 @@ query($org: String!, $number: Int!) { name field { ... on ProjectV2SingleSelectField { name } } } + ... on ProjectV2ItemFieldDateValue { + date + field { ... on ProjectV2Field { name } } + } } } } @@ -103,6 +108,7 @@ interface RawItemContent { interface RawFieldValue { name?: string; + date?: string; field?: { name?: string }; } @@ -177,6 +183,13 @@ export async function fetchGitHubProject( 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 ?? '', @@ -185,11 +198,8 @@ export async function fetchGitHubProject( state: 'state' in content ? (content.state ?? null) : null, labels: 'labels' in content ? (content.labels?.nodes ?? []) : [], isDraft, + shippedDate: shippedValue?.date ?? null, }; - - const statusValue = rawItem.fieldValues.nodes.find( - (fv) => fv.field?.name === 'Status', - ); const colName = statusValue?.name; const col = colName ? columnMap.get(colName) : null; From b63e0dda4f9561457ace247103e64c388c1eb597 Mon Sep 17 00:00:00 2001 From: phucbm <hiphucbui@gmail.com> Date: Sun, 14 Jun 2026 11:55:25 +0700 Subject: [PATCH 3/7] feat(roadmap): merge changelog into /roadmap page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - /roadmap now shows kanban (Todo/Planned/Future Ideas) + Recently Shipped timeline sourced from GH Projects Done items - GH Projects is now SSOT: item body = changelog markdown bullets, Shipped date field drives timeline order - Migrate 13 changelog entries from content/changelog.mdx to GH Projects draft issue bodies - /changelog → /roadmap 301 redirect (next.config.ts) - ROUTES.changelog updated to /roadmap; footer deduped - New OwRoadmapChangelog component: date col + MDXRemote body, mirrors old changelog page layout, no TOC - OwRoadmapKanban: remove Done grid + CTA block - Delete app/(marketing)/changelog, lib/changelog.ts, content/changelog.mdx - Merge /add-changelog + /roadmap commands → /roadmap-changelog Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .claude/CLAUDE.md | 3 +- .claude/commands/add-changelog.md | 38 ------ .claude/commands/roadmap-changelog.md | 119 ++++++++++++++++++ .claude/commands/roadmap.md | 73 ----------- app/(marketing)/changelog/changelog-toc.tsx | 52 -------- app/(marketing)/changelog/opengraph-image.tsx | 12 -- app/(marketing)/changelog/page.tsx | 115 ----------------- app/(marketing)/roadmap/page.tsx | 23 +++- components/layout/footer.tsx | 1 - components/owui/ow-roadmap-changelog.tsx | 51 ++++++++ components/owui/ow-roadmap-kanban.stories.tsx | 21 +--- components/owui/ow-roadmap-kanban.tsx | 39 +----- content/changelog.mdx | 92 -------------- lib/api-types.generated.ts | 2 + lib/changelog.ts | 44 ------- lib/routes.ts | 2 +- next.config.ts | 1 + 17 files changed, 200 insertions(+), 488 deletions(-) delete mode 100644 .claude/commands/add-changelog.md create mode 100644 .claude/commands/roadmap-changelog.md delete mode 100644 .claude/commands/roadmap.md delete mode 100644 app/(marketing)/changelog/changelog-toc.tsx delete mode 100644 app/(marketing)/changelog/opengraph-image.tsx delete mode 100644 app/(marketing)/changelog/page.tsx create mode 100644 components/owui/ow-roadmap-changelog.tsx delete mode 100644 content/changelog.mdx delete mode 100644 lib/changelog.ts diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 29200cde..a52c6458 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -124,14 +124,13 @@ 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 | | `/commit-all` | `commands/commit-all.md` | Stage + commit all changes in logical groups with conventional commit messages | | `/sync-api-types` | `commands/sync-api-types.md` | Regenerate types from API schema, diff changes, scan codebase, suggest updates | | `/edit-system-prompt` | `commands/edit-system-prompt.md` | Edit Owie's system prompt, enforce invariants, push to Langfuse | -| `/roadmap` | `commands/roadmap.md` | Add/move/delete items in GitHub Projects roadmap via gh CLI | @.claude/docs/openwallet-brain.md @.claude/docs/architecture.md 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="<title>" + +# 2. Move to correct column +gh project item-edit \ + --id <ITEM_ID> \ + --project-id PVT_kwDOD5xEPM4BamAq \ + --field-id PVTSSF_lADOD5xEPM4BamAqzhVcfVc \ + --single-select-option-id <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="<ITEM_ID>" \ + -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 == "<title>") | .id' + +# Move to Done +gh project item-edit \ + --id <ITEM_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="<DRAFT_ISSUE_ID>" \ + -f body="<markdown bullets>" +``` + +## 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/.claude/commands/roadmap.md b/.claude/commands/roadmap.md deleted file mode 100644 index c08cb376..00000000 --- a/.claude/commands/roadmap.md +++ /dev/null @@ -1,73 +0,0 @@ -# /roadmap — Manage GitHub Projects Roadmap - -Manage the OpenWallet roadmap at https://github.com/orgs/openwalletvn/projects/1 via `gh` CLI. - -## Project IDs (do not change) - -``` -Project number : 1 -Owner : openwalletvn -Project ID : PVT_kwDOD5xEPM4BamAq -Status field ID: PVTSSF_lADOD5xEPM4BamAqzhVcfVc -``` - -## Status column option IDs - -| Column | ID | -|-------------|------------| -| Todo | f75ad846 | -| In Progress | 47fc9ee4 | -| Done | 98236657 | - -## Common operations - -### List all items with status -```sh -gh project item-list 1 --owner openwalletvn --format json | jq '.items[] | {title: .title, status: .status}' -``` - -### Create an issue and add to project -```sh -# 1. Create issue -gh issue create --repo openwalletvn/web --title "Feature: <title>" --body "<description>" - -# 2. Add to project (use the issue URL from step 1) -gh project item-add 1 --owner openwalletvn --url <issue-url> -``` - -### Move item to a different column -```sh -# Get item ID first -gh project item-list 1 --owner openwalletvn --format json | jq '.items[] | select(.title == "<title>") | .id' - -# Move to column (replace ITEM_ID and OPTION_ID) -gh project item-edit \ - --id <ITEM_ID> \ - --project-id PVT_kwDOD5xEPM4BamAq \ - --field-id PVTSSF_lADOD5xEPM4BamAqzhVcfVc \ - --single-select-option-id <OPTION_ID> -``` - -### Add a draft item (no issue needed) -```sh -gh project item-add 1 --owner openwalletvn --title "<title>" -``` - -### Archive/delete an item -```sh -gh project item-delete 1 --owner openwalletvn --id <ITEM_ID> -``` - -## Workflow for "add roadmap item" requests - -1. Ask user: title, description (optional), which column (Todo/In Progress/Done) -2. If it maps to real work: create issue first, then add to project -3. If it's just a placeholder: add as draft item -4. Move to correct column -5. Confirm with: `gh project item-list 1 --owner openwalletvn --format json` - -## Notes - -- `/roadmap` page at openwallet.vn revalidates every hour (ISR). 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` diff --git a/app/(marketing)/changelog/changelog-toc.tsx b/app/(marketing)/changelog/changelog-toc.tsx deleted file mode 100644 index 782d8ab2..00000000 --- a/app/(marketing)/changelog/changelog-toc.tsx +++ /dev/null @@ -1,52 +0,0 @@ -'use client'; - -import {useState} from 'react'; -import {IconChevronDown} from '@tabler/icons-react'; -import {Collapsible, CollapsibleContent, CollapsibleTrigger} from '@/components/ui/collapsible'; -import {cn, fmtIsoDate} from '@/lib/utils'; - -interface ChangelogTocItem { - id: string; - title: string; - date: string; -} - -interface Props { - items: ChangelogTocItem[]; -} - - -export function ChangelogToc({items}: Props) { - const [open, setOpen] = useState(true); - - if (items.length === 0) return null; - - return ( - <Collapsible open={open} onOpenChange={setOpen}> - <div className="border border-dashed border-slate-200 rounded"> - <CollapsibleTrigger - className="flex items-center justify-between w-full px-3 py-2.5 hover:bg-slate-50/60 transition-colors group"> - <span className="text-xs font-semibold text-slate-500 uppercase tracking-wider">Mục lục</span> - <IconChevronDown - size={13} - className={cn('text-slate-400 transition-transform duration-200', open ? 'rotate-180' : '')} - /> - </CollapsibleTrigger> - <CollapsibleContent> - <nav className="border-t border-dashed border-slate-100 px-3 py-2 flex flex-col"> - {items.map((item) => ( - <a - key={item.id} - href={`#${item.id}`} - className="block py-1 text-xs leading-snug text-slate-500 hover:text-slate-900 transition-colors" - > - <span className="font-mono text-slate-400 mr-1.5">{fmtIsoDate(item.date)}</span> - {item.title} - </a> - ))} - </nav> - </CollapsibleContent> - </div> - </Collapsible> - ); -} diff --git a/app/(marketing)/changelog/opengraph-image.tsx b/app/(marketing)/changelog/opengraph-image.tsx deleted file mode 100644 index 298860ad..00000000 --- a/app/(marketing)/changelog/opengraph-image.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import {createOgImage, OG_SIZE} from '@/lib/og'; - -export const dynamic = 'force-static'; -export const size = OG_SIZE; -export const contentType = 'image/png'; - -export default function Image() { - return createOgImage({ - title: 'Changelog', - description: 'Những cập nhật mới nhất về tính năng, dữ liệu và cải tiến kỹ thuật của OpenWallet.', - }); -} diff --git a/app/(marketing)/changelog/page.tsx b/app/(marketing)/changelog/page.tsx deleted file mode 100644 index 9ba778d5..00000000 --- a/app/(marketing)/changelog/page.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import type {Metadata} from 'next'; -import {MDXRemote} from 'next-mdx-remote/rsc'; -import {ROUTES} from '@/lib/routes'; -import remarkGfm from 'remark-gfm'; -import {getAllChangelogs} from '@/lib/changelog'; -import {slugify} from '@/lib/mdx'; -import {MarketingPageShell} from '@/components/layout/marketing-page-shell'; -import {ChangelogToc} from './changelog-toc'; -import {mdxComponents} from '@/components/blog/mdx-components'; -import {remarkAutoLink} from '@/lib/remark-auto-link'; -import {buildBreadcrumbJsonLd} from '@/lib/page-meta/breadcrumb'; -import {buildTitle} from '@/lib/page-meta/title'; -import {fmtIsoDate} from '@/lib/utils'; - -export const metadata: Metadata = { - title: buildTitle('Changelog'), - description: 'Những cập nhật mới nhất về tính năng, dữ liệu và cải tiến kỹ thuật của OpenWallet.', - alternates: {canonical: ROUTES.changelog}, - openGraph: { - title: 'Changelog', - description: 'Những cập nhật mới nhất về tính năng, dữ liệu và cải tiến kỹ thuật của OpenWallet.', - }, - twitter: { - title: 'Changelog', - description: 'Những cập nhật mới nhất về tính năng, dữ liệu và cải tiến kỹ thuật của OpenWallet.', - }, -}; - - -export default function ChangelogPage() { - const entries = getAllChangelogs(); - - const breadcrumbItems = [ - {label: 'Trang chủ', href: '/'}, - {label: 'Changelog'}, - ]; - - const jsonLd = { - '@context': 'https://schema.org', - '@graph': [ - buildBreadcrumbJsonLd(breadcrumbItems), - ], - }; - - const tocItems = entries.map((entry) => ({ - id: slugify(entry.title), - title: entry.title, - date: entry.date, - })); - - return ( - <MarketingPageShell title="Changelog" breadcrumbItems={breadcrumbItems} jsonLd={jsonLd}> - <div className="flex md:gap-10 gap-6 items-start mt-8"> - {/* ── Main content ── */} - <div className="flex-1 min-w-0"> - {entries.map((entry, i) => { - const entryId = slugify(entry.title); - return ( - <div key={entry.date}> - {i > 0 && <div className="border-t border-dashed border-slate-200 md:my-12 my-6"/>} - - <div className="flex gap-8 lg:gap-12"> - {/* Date column - desktop only */} - <div className="hidden lg:block w-28 shrink-0 pt-1 sticky top-[30px] self-start"> - <time - dateTime={entry.date} - className="text-sm font-mono text-slate-400" - > - {fmtIsoDate(entry.date)} - </time> - </div> - - {/* Content column */} - <div className="flex-1 min-w-0"> - {/* Date - mobile only */} - <time - dateTime={entry.date} - className="block lg:hidden text-sm font-mono text-slate-400 mb-2" - > - {fmtIsoDate(entry.date)} - </time> - - <h4 id={entryId} className="mb-4 group"> - <a - href={`#${entryId}`} - className="no-underline hover:underline" - > - {entry.title} - <span className="opacity-0 group-hover:opacity-100 text-slate-300 ml-2 transition-opacity">#</span> - </a> - </h4> - - <article - className="prose prose-slate max-w-none prose-headings:font-bold prose-headings:text-slate-900 prose-p:text-slate-700 prose-a:text-brand-blue prose-a:no-underline hover:prose-a:underline prose-strong:text-slate-900 prose-code:text-brand-red prose-code:before:content-none prose-code:after:content-none prose-li:text-slate-700 prose-hr:border-dashed prose-hr:border-slate-200 prose-table:text-sm prose-th:text-slate-700 prose-th:bg-slate-50 prose-td:text-slate-600"> - <MDXRemote - source={entry.content} - components={mdxComponents} - options={{mdxOptions: {remarkPlugins: [remarkGfm, remarkAutoLink]}}} - /> - </article> - </div> - </div> - </div> - ); - })} - </div> - - {/* ── Sidebar ── */} - <aside className="w-72 shrink-0 hidden lg:flex flex-col sticky top-[30px] self-start"> - <ChangelogToc items={tocItems}/> - </aside> - </div> - </MarketingPageShell> - ); -} diff --git a/app/(marketing)/roadmap/page.tsx b/app/(marketing)/roadmap/page.tsx index df27a51c..3ba89227 100644 --- a/app/(marketing)/roadmap/page.tsx +++ b/app/(marketing)/roadmap/page.tsx @@ -2,6 +2,7 @@ import type {Metadata} from 'next'; import {ROUTES} from '@/lib/routes'; import {fetchGitHubProject} from '@/lib/github-project'; import {OwRoadmapKanban} from '@/components/owui/ow-roadmap-kanban'; +import {OwRoadmapChangelog} from '@/components/owui/ow-roadmap-changelog'; import {MarketingPageShell} from '@/components/layout/marketing-page-shell'; import {buildBreadcrumbJsonLd} from '@/lib/page-meta/breadcrumb'; import {buildTitle} from '@/lib/page-meta/title'; @@ -10,15 +11,15 @@ export const revalidate = 3600; export const metadata: Metadata = { title: buildTitle('Roadmap'), - description: 'Kế hoạch phát triển của OpenWallet: các tính năng đang được xây dựng và những gì sắp ra mắt.', + description: 'Kế hoạch phát triển và những tính năng đã ra mắt của OpenWallet.', alternates: {canonical: ROUTES.roadmap}, openGraph: { title: 'Roadmap', - description: 'Kế hoạch phát triển của OpenWallet: các tính năng đang được xây dựng và những gì sắp ra mắt.', + description: 'Kế hoạch phát triển và những tính năng đã ra mắt của OpenWallet.', }, twitter: { title: 'Roadmap', - description: 'Kế hoạch phát triển của OpenWallet: các tính năng đang được xây dựng và những gì sắp ra mắt.', + description: 'Kế hoạch phát triển và những tính năng đã ra mắt của OpenWallet.', }, }; @@ -37,6 +38,14 @@ export default async function RoadmapPage() { ], }; + const doneCol = board.columns.find((c) => c.name === 'Done'); + const doneItems = [...(doneCol?.items ?? [])].sort((a, b) => { + if (!a.shippedDate && !b.shippedDate) return 0; + if (!a.shippedDate) return 1; + if (!b.shippedDate) return -1; + return b.shippedDate.localeCompare(a.shippedDate); + }); + return ( <MarketingPageShell title="Roadmap" @@ -51,8 +60,14 @@ export default async function RoadmapPage() { {name: 'Planned', label: 'Planned'}, {name: 'Future Ideas', label: 'Future Ideas'}, ]} - completedCol={{name: 'Done', label: 'Recently Completed'}} /> + + {doneItems.length > 0 && ( + <div className="mt-16"> + <h2 className="mb-8">Recently Shipped</h2> + <OwRoadmapChangelog items={doneItems}/> + </div> + )} </MarketingPageShell> ); } diff --git a/components/layout/footer.tsx b/components/layout/footer.tsx index 18361a75..3e015331 100644 --- a/components/layout/footer.tsx +++ b/components/layout/footer.tsx @@ -28,7 +28,6 @@ const LINKS = { about: [ {label: 'Về OpenWallet', href: '/ve-openwallet'}, {label: 'Liên hệ', href: ROUTES.contact}, - {label: 'Changelog', href: ROUTES.changelog}, {label: 'Roadmap', href: ROUTES.roadmap}, ], developer: [ diff --git a/components/owui/ow-roadmap-changelog.tsx b/components/owui/ow-roadmap-changelog.tsx new file mode 100644 index 00000000..e0bb2ef5 --- /dev/null +++ b/components/owui/ow-roadmap-changelog.tsx @@ -0,0 +1,51 @@ +import {MDXRemote} from 'next-mdx-remote/rsc'; +import remarkGfm from 'remark-gfm'; +import {mdxComponents} from '@/components/blog/mdx-components'; +import {remarkAutoLink} from '@/lib/remark-auto-link'; +import {fmtIsoDate} from '@/lib/utils'; +import type {GithubProjectItem} from '@/lib/github-project'; + +const PROSE_CLS = 'prose prose-slate max-w-none prose-headings:font-bold prose-headings:text-slate-900 prose-p:text-slate-700 prose-a:text-brand-blue prose-a:no-underline hover:prose-a:underline prose-strong:text-slate-900 prose-code:text-brand-red prose-code:before:content-none prose-code:after:content-none prose-li:text-slate-700 prose-hr:border-dashed prose-hr:border-slate-200 prose-table:text-sm prose-th:text-slate-700 prose-th:bg-slate-50 prose-td:text-slate-600'; + +export function OwRoadmapChangelog({items}: {items: GithubProjectItem[]}) { + const entries = items.filter((item) => item.shippedDate); + + return ( + <div> + {entries.map((item, i) => ( + <div key={item.id}> + {i > 0 && <div className="border-t border-dashed border-slate-200 md:my-12 my-6"/>} + + <div className="flex gap-8 lg:gap-12"> + {/* Date column - desktop */} + <div className="hidden lg:block w-28 shrink-0 pt-1 sticky top-[30px] self-start"> + <time dateTime={item.shippedDate!} className="text-sm font-mono text-slate-400"> + {fmtIsoDate(item.shippedDate!)} + </time> + </div> + + {/* Content */} + <div className="flex-1 min-w-0"> + {/* Date - mobile */} + <time dateTime={item.shippedDate!} className="block lg:hidden text-sm font-mono text-slate-400 mb-2"> + {fmtIsoDate(item.shippedDate!)} + </time> + + <h4 className="mb-4">{item.title}</h4> + + {item.body && ( + <article className={PROSE_CLS}> + <MDXRemote + source={item.body} + components={mdxComponents} + options={{mdxOptions: {remarkPlugins: [remarkGfm, remarkAutoLink]}}} + /> + </article> + )} + </div> + </div> + </div> + ))} + </div> + ); +} diff --git a/components/owui/ow-roadmap-kanban.stories.tsx b/components/owui/ow-roadmap-kanban.stories.tsx index 89589b6b..81a814ec 100644 --- a/components/owui/ow-roadmap-kanban.stories.tsx +++ b/components/owui/ow-roadmap-kanban.stories.tsx @@ -14,7 +14,6 @@ const meta: Meta<typeof OwRoadmapKanban> = { 'Kanban board for displaying a GitHub Projects roadmap. Read-only, no actions.', '', '**`columns`** — ordered list of `{ name, label }` mapping GitHub column names to display labels.', - '**`completedCol`** — same shape; items shown as grid below the kanban, hidden if empty.', '', '```tsx', 'const board = await fetchGitHubProject("openwalletvn", 1)', @@ -25,7 +24,6 @@ const meta: Meta<typeof OwRoadmapKanban> = { ' { name: "Todo", label: "Planned" },', ' { name: "Future Ideas", label: "Future Ideas" },', ' ]}', - ' completedCol={{ name: "Done", label: "Recently Completed" }}', '/>', '```', ].join('\n'), @@ -67,16 +65,6 @@ const MOCK_BOARD: GithubProjectBoard = { {id: '4', title: 'App mobile OpenWallet', body: '', url: null, state: null, labels: [], isDraft: true, shippedDate: null}, ], }, - { - id: 'c4', - name: 'Done', - color: '#8250df', - items: [ - {id: '5', title: 'Tích hợp Owie Chat', body: '', url: 'https://github.com/openwalletvn/web/issues/4', state: 'CLOSED', labels: [{name: 'feature', color: '#0075ca'}], isDraft: false, shippedDate: '2026-05-22'}, - {id: '6', title: 'Trang so sánh thẻ', body: '', url: 'https://github.com/openwalletvn/web/issues/5', state: 'CLOSED', labels: [], isDraft: false, shippedDate: '2026-03-22'}, - {id: '7', title: 'SEO cơ bản cho trang thẻ', body: '', url: null, state: 'CLOSED', labels: [{name: 'seo', color: '#e4e669'}], isDraft: false, shippedDate: null}, - ], - }, ], }; @@ -85,7 +73,6 @@ const COLUMNS = [ {name: 'Todo', label: 'Planned'}, {name: 'Future Ideas', label: 'Future Ideas'}, ]; -const COMPLETED_COL = {name: 'Done', label: 'Recently Completed'}; const EMPTY_BOARD: GithubProjectBoard = { ...MOCK_BOARD, @@ -95,17 +82,17 @@ const EMPTY_BOARD: GithubProjectBoard = { export const Overview: Story = { render: () => ( <OwStories> - <OwStorySection title="With items + completed section"> - <OwRoadmapKanban board={MOCK_BOARD} columns={COLUMNS} completedCol={COMPLETED_COL}/> + <OwStorySection title="With items"> + <OwRoadmapKanban board={MOCK_BOARD} columns={COLUMNS}/> </OwStorySection> </OwStories> ), }; export const WithItems: Story = { - args: {board: MOCK_BOARD, columns: COLUMNS, completedCol: COMPLETED_COL}, + args: {board: MOCK_BOARD, columns: COLUMNS}, }; export const EmptyBoard: Story = { - args: {board: EMPTY_BOARD, columns: COLUMNS, completedCol: COMPLETED_COL}, + args: {board: EMPTY_BOARD, columns: COLUMNS}, }; diff --git a/components/owui/ow-roadmap-kanban.tsx b/components/owui/ow-roadmap-kanban.tsx index edfe253b..f60b3b6a 100644 --- a/components/owui/ow-roadmap-kanban.tsx +++ b/components/owui/ow-roadmap-kanban.tsx @@ -1,8 +1,4 @@ -import Link from 'next/link'; import type {GithubProjectBoard} from '@/lib/github-project'; -import {ROUTES} from '@/lib/routes'; -import {OwButton} from './ow-button'; -import {OwRoadmapCard} from './ow-roadmap-card'; import {OwRoadmapColumn} from './ow-roadmap-column'; export interface OwRoadmapColConfig { @@ -13,53 +9,22 @@ export interface OwRoadmapColConfig { export interface OwRoadmapKanbanProps { board: GithubProjectBoard; columns: OwRoadmapColConfig[]; - completedCol: OwRoadmapColConfig; } -export function OwRoadmapKanban({board, columns, completedCol}: OwRoadmapKanbanProps) { +export function OwRoadmapKanban({board, columns}: OwRoadmapKanbanProps) { const colMap = new Map(board.columns.map((c) => [c.name, c])); const kanbanCols = columns .map((cfg) => ({cfg, col: colMap.get(cfg.name)})) .filter((x): x is {cfg: OwRoadmapColConfig; col: NonNullable<typeof x.col>} => x.col !== undefined); - const completedData = colMap.get(completedCol.name); - const completedItems = [...(completedData?.items ?? [])].sort((a, b) => { - if (!a.shippedDate && !b.shippedDate) return 0; - if (!a.shippedDate) return 1; - if (!b.shippedDate) return -1; - return b.shippedDate.localeCompare(a.shippedDate); - }); - return ( - <div className="mt-8 space-y-12"> - {/* Kanban columns */} + <div className="mt-8"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 items-start"> {kanbanCols.map(({cfg, col}) => ( <OwRoadmapColumn key={col.id} column={col} label={cfg.label}/> ))} </div> - - {/* Recently Completed */} - {completedItems.length > 0 && ( - <div> - <h2 className="mb-4">{completedCol.label}</h2> - <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3"> - {completedItems.map((item) => ( - <OwRoadmapCard key={item.id} item={item}/> - ))} - </div> - </div> - )} - - {/* CTA */} - <div className="border border-border rounded-xl p-8 text-center space-y-3"> - <p className="text-text-muted text-sm">Want to see everything we shipped?</p> - <p className="font-display text-xl text-text-primary">View the full changelog</p> - <OwButton color="outline" size="sm" asChild> - <Link href={ROUTES.changelog}>Xem Changelog</Link> - </OwButton> - </div> </div> ); } 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/routes.ts b/lib/routes.ts index 2961600f..c230d722 100644 --- a/lib/routes.ts +++ b/lib/routes.ts @@ -16,7 +16,7 @@ export const ROUTES = { openwalletApp: '/openwallet-app', // Info pages - changelog: '/changelog', + changelog: '/roadmap', roadmap: '/roadmap', contact: '/lien-he', terms: '/dieu-khoan', 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 }, From 73d29f35a1bf63dc688594fbd377a3ef04654043 Mon Sep 17 00:00:00 2001 From: phucbm <hiphucbui@gmail.com> Date: Sun, 14 Jun 2026 12:00:17 +0700 Subject: [PATCH 4/7] feat(storybook): add OwRoadmapChangelog story + MDX mock - New story: Roadmap UI/OwRoadmapChangelog with 4 mock items + bodies - Mock next-mdx-remote/rsc via Vite alias in .storybook/main.ts (RSC component can't run in Vite/client context) - Mock renders source as styled plain text for visual preview Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- .storybook/__mocks__/next-mdx-remote-rsc.tsx | 11 ++ .storybook/main.ts | 11 +- .../owui/ow-roadmap-changelog.stories.tsx | 114 ++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 .storybook/__mocks__/next-mdx-remote-rsc.tsx create mode 100644 components/owui/ow-roadmap-changelog.stories.tsx diff --git a/.storybook/__mocks__/next-mdx-remote-rsc.tsx b/.storybook/__mocks__/next-mdx-remote-rsc.tsx new file mode 100644 index 00000000..6c8764d8 --- /dev/null +++ b/.storybook/__mocks__/next-mdx-remote-rsc.tsx @@ -0,0 +1,11 @@ +import React from 'react'; + +// Storybook mock for next-mdx-remote/rsc (RSC-only, can't run in Vite/client) +// Renders markdown source as styled plain text for visual preview +export function MDXRemote({source}: {source: string}) { + return ( + <div className="text-sm text-slate-600 whitespace-pre-wrap font-mono bg-slate-50 rounded p-3 border border-slate-200"> + {source} + </div> + ); +} diff --git a/.storybook/main.ts b/.storybook/main.ts index 7428222c..6831c613 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,4 +1,5 @@ import type { StorybookConfig } from '@storybook/nextjs-vite'; +import path from 'path'; const config: StorybookConfig = { "stories": [ @@ -19,6 +20,14 @@ const config: StorybookConfig = { ], "env": () => ({ NEXT_PUBLIC_API_URL: '', - }) + }), + viteFinal: (config) => { + config.resolve ??= {}; + config.resolve.alias = { + ...(config.resolve.alias ?? {}), + 'next-mdx-remote/rsc': path.resolve(__dirname, './__mocks__/next-mdx-remote-rsc.tsx'), + }; + return config; + }, }; export default config; \ No newline at end of file diff --git a/components/owui/ow-roadmap-changelog.stories.tsx b/components/owui/ow-roadmap-changelog.stories.tsx new file mode 100644 index 00000000..f02fdfca --- /dev/null +++ b/components/owui/ow-roadmap-changelog.stories.tsx @@ -0,0 +1,114 @@ +import type {Meta, StoryObj} from '@storybook/nextjs-vite'; +import type {GithubProjectItem} from '@/lib/github-project'; +import {OwRoadmapChangelog} from './ow-roadmap-changelog'; +import {OwStories, OwStorySection} from './ow-story-section'; + +const meta: Meta<typeof OwRoadmapChangelog> = { + component: OwRoadmapChangelog, + title: 'Roadmap UI/OwRoadmapChangelog', + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: [ + 'Timeline layout for shipped items from GitHub Projects Done column.', + '', + 'Renders each item with: sticky date column (desktop), title, and MDX body.', + 'Items without `shippedDate` are filtered out.', + '', + '> **Note:** MDX body is mocked in Storybook (plain text preview). Live site uses `MDXRemote` from `next-mdx-remote/rsc`.', + '', + '```tsx', + '<OwRoadmapChangelog items={doneItems} />', + '```', + ].join('\n'), + }, + }, + }, +}; +export default meta; + +type Story = StoryObj<typeof OwRoadmapChangelog>; + +const MOCK_ITEMS: GithubProjectItem[] = [ + { + id: '1', + title: 'Trang /roadmap công khai từ GitHub Projects', + body: `- Roadmap công khai tại \`/roadmap\`, lấy dữ liệu trực tiếp từ GitHub Projects +- ISR revalidate mỗi 60 phút, không cần deploy lại khi cập nhật roadmap +- Hiển thị kanban (Todo, In Progress, Future Ideas) và timeline "Recently Shipped" +- Trường Shipped date trên GitHub Projects điều khiển thứ tự timeline`, + url: null, + state: null, + labels: [], + isDraft: true, + shippedDate: '2026-06-14', + }, + { + id: '2', + title: 'Merge changelog vào trang /roadmap', + body: `- \`/changelog\` redirect 301 về \`/roadmap\` +- GitHub Projects là SSOT cho cả roadmap lẫn changelog +- Body của draft issue = nội dung changelog (tiếng Việt, tối đa 4 bullet) +- Xóa \`content/changelog.mdx\`, \`lib/changelog.ts\`, toàn bộ route \`/changelog\``, + url: null, + state: null, + labels: [], + isDraft: true, + shippedDate: '2026-06-13', + }, + { + id: '3', + title: 'Trang so sánh thẻ tín dụng Card Battle', + body: `- Cho phép so sánh 2 thẻ trực tiếp, hiển thị điểm mạnh/yếu từng thẻ +- URL dạng \`/card-battle?compare=X,Y\` hỗ trợ chia sẻ`, + url: 'https://github.com/openwalletvn/web/issues/5', + state: 'CLOSED', + labels: [{name: 'feature', color: '#0075ca'}], + isDraft: false, + shippedDate: '2026-05-20', + }, + { + id: '4', + title: 'Tích hợp OpenWallet MCP Server', + body: `- MCP server cung cấp dữ liệu thẻ cho AI assistants (Claude, Cursor, v.v.) +- Hỗ trợ 10 tools: tìm thẻ, so sánh, tính cashback, lọc theo tiêu chí`, + url: null, + state: null, + labels: [], + isDraft: true, + shippedDate: '2026-04-10', + }, +]; + +export const Overview: Story = { + render: () => ( + <OwStories> + <OwStorySection title="With body"> + <OwRoadmapChangelog items={MOCK_ITEMS}/> + </OwStorySection> + <OwStorySection title="No body"> + <OwRoadmapChangelog items={MOCK_ITEMS.map((item) => ({...item, body: ''}))}/> + </OwStorySection> + </OwStories> + ), +}; + +export const Default: Story = { + args: {items: MOCK_ITEMS}, +}; + +export const SingleItem: Story = { + args: {items: [MOCK_ITEMS[0]]}, +}; + +export const Empty: Story = { + args: {items: []}, +}; + +export const NoShippedDate: Story = { + name: 'Filters items without shippedDate', + args: { + items: MOCK_ITEMS.map((item) => ({...item, shippedDate: null})), + }, +}; From f3aee4199517c10fa873a6c6d161d95bf3bea0ae Mon Sep 17 00:00:00 2001 From: phucbm <hiphucbui@gmail.com> Date: Sun, 14 Jun 2026 12:01:21 +0700 Subject: [PATCH 5/7] refactor(storybook): simplify MDX mock to plain pre tag --- .storybook/__mocks__/next-mdx-remote-rsc.tsx | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.storybook/__mocks__/next-mdx-remote-rsc.tsx b/.storybook/__mocks__/next-mdx-remote-rsc.tsx index 6c8764d8..7d315cbf 100644 --- a/.storybook/__mocks__/next-mdx-remote-rsc.tsx +++ b/.storybook/__mocks__/next-mdx-remote-rsc.tsx @@ -1,11 +1,6 @@ import React from 'react'; -// Storybook mock for next-mdx-remote/rsc (RSC-only, can't run in Vite/client) -// Renders markdown source as styled plain text for visual preview +// Storybook mock for next-mdx-remote/rsc — renders source as plain text export function MDXRemote({source}: {source: string}) { - return ( - <div className="text-sm text-slate-600 whitespace-pre-wrap font-mono bg-slate-50 rounded p-3 border border-slate-200"> - {source} - </div> - ); + return <pre className="text-sm text-slate-600 whitespace-pre-wrap">{source}</pre>; } From d11f6570fb084f1b7791155560794e8f54d8c6a9 Mon Sep 17 00:00:00 2001 From: phucbm <hiphucbui@gmail.com> Date: Sun, 14 Jun 2026 12:03:02 +0700 Subject: [PATCH 6/7] fix(storybook): mock mdx-components + remark-auto-link to avoid Node deps --- .storybook/__mocks__/mdx-components.tsx | 2 ++ .storybook/__mocks__/next-mdx-remote-rsc.tsx | 2 +- .storybook/__mocks__/remark-auto-link.ts | 2 ++ .storybook/main.ts | 2 ++ 4 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 .storybook/__mocks__/mdx-components.tsx create mode 100644 .storybook/__mocks__/remark-auto-link.ts 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 index 7d315cbf..2f349f31 100644 --- a/.storybook/__mocks__/next-mdx-remote-rsc.tsx +++ b/.storybook/__mocks__/next-mdx-remote-rsc.tsx @@ -1,6 +1,6 @@ import React from 'react'; -// Storybook mock for next-mdx-remote/rsc — renders source as plain text +// Storybook mock — renders markdown source as plain text (no Node deps) export function MDXRemote({source}: {source: string}) { return <pre className="text-sm text-slate-600 whitespace-pre-wrap">{source}</pre>; } diff --git a/.storybook/__mocks__/remark-auto-link.ts b/.storybook/__mocks__/remark-auto-link.ts new file mode 100644 index 00000000..1cf0c43b --- /dev/null +++ b/.storybook/__mocks__/remark-auto-link.ts @@ -0,0 +1,2 @@ +// Storybook mock — avoids pulling in Node-only deps from remark-auto-link +export function remarkAutoLink() {} diff --git a/.storybook/main.ts b/.storybook/main.ts index 6831c613..99d08202 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -26,6 +26,8 @@ const config: StorybookConfig = { config.resolve.alias = { ...(config.resolve.alias ?? {}), 'next-mdx-remote/rsc': path.resolve(__dirname, './__mocks__/next-mdx-remote-rsc.tsx'), + '@/components/blog/mdx-components': path.resolve(__dirname, './__mocks__/mdx-components.tsx'), + '@/lib/remark-auto-link': path.resolve(__dirname, './__mocks__/remark-auto-link.ts'), }; return config; }, From eb9cd595cea154df5ade0d4f18e350c9e21318dc Mon Sep 17 00:00:00 2001 From: phucbm <hiphucbui@gmail.com> Date: Sun, 14 Jun 2026 12:04:34 +0700 Subject: [PATCH 7/7] fix(storybook): use import.meta.url for __dirname in ESM context --- .storybook/main.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.storybook/main.ts b/.storybook/main.ts index 99d08202..bd7e4c6f 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,5 +1,8 @@ import type { StorybookConfig } from '@storybook/nextjs-vite'; import path from 'path'; +import {fileURLToPath} from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const config: StorybookConfig = { "stories": [