diff --git a/apps/web/src/app/changelog/layout.tsx b/apps/web/src/app/changelog/layout.tsx
new file mode 100644
index 0000000..2ee2ac7
--- /dev/null
+++ b/apps/web/src/app/changelog/layout.tsx
@@ -0,0 +1,18 @@
+import type { ReactNode } from "react";
+
+import { CommandMenuProvider } from "@/components/command-menu";
+import { NavigationBar } from "@/components/layout/navigation-bar";
+import { PageTransition } from "@/components/layout/page-transition";
+
+export default function MarketingLayout({ children }: { children: ReactNode }) {
+ return (
+
+
+
+ );
+}
diff --git a/apps/web/src/app/changelog/page.tsx b/apps/web/src/app/changelog/page.tsx
new file mode 100644
index 0000000..2a82af7
--- /dev/null
+++ b/apps/web/src/app/changelog/page.tsx
@@ -0,0 +1,51 @@
+import type { Metadata } from "next";
+
+import { ChangelogFooter } from "@/components/changelog/changelog-footer";
+import { ChangelogRelease } from "@/components/changelog/changelog-release";
+import { ChangelogSidebar } from "@/components/changelog/changelog-sidebar";
+import { SectionShell } from "@/components/layout/section";
+import { getReleases } from "@/lib/github";
+import type { GitHubRelease } from "@/lib/releases";
+
+export const metadata: Metadata = {
+ title: "Changelog – PayKit",
+ description: "Stay up to date with the latest changes to PayKit.",
+};
+
+export default async function ChangelogPage() {
+ const releases = (await getReleases()) as GitHubRelease[];
+ const latestRelease = releases[0];
+
+ return (
+
+
+
+
+
+
+
+
+
+ {releases.length === 0 ? (
+
No releases found yet.
+ ) : (
+
+ {releases.map((release) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/sitemap.ts b/apps/web/src/app/sitemap.ts
index cf2b994..b537bd9 100644
--- a/apps/web/src/app/sitemap.ts
+++ b/apps/web/src/app/sitemap.ts
@@ -19,5 +19,11 @@ export default function sitemap(): MetadataRoute.Sitemap {
priority: 1,
},
...docsRoutes,
+ {
+ url: URLs.changelog,
+ lastModified: new Date(),
+ changeFrequency: "weekly",
+ priority: 1,
+ },
];
}
diff --git a/apps/web/src/components/changelog/changelog-footer.tsx b/apps/web/src/components/changelog/changelog-footer.tsx
new file mode 100644
index 0000000..e85f040
--- /dev/null
+++ b/apps/web/src/components/changelog/changelog-footer.tsx
@@ -0,0 +1,86 @@
+"use client";
+
+import { ArrowRight, Github } from "lucide-react";
+import Link from "next/link";
+
+import { Icons } from "@/components/icons";
+import { URLs } from "@/lib/consts";
+import { cn } from "@/lib/utils";
+
+type FooterLink = { label: string; href: string; external?: boolean };
+
+const footerLinks: FooterLink[] = [
+ { label: "Docs", href: "/docs" },
+ { label: "Contact", href: "/contact" },
+ { label: "Discord", href: URLs.discord, external: true },
+ { label: "Changelog", href: "/changelog" },
+];
+
+type ChangelogFooterProps = {
+ className?: string;
+};
+
+export function ChangelogFooter({ className }: ChangelogFooterProps) {
+ const year = new Date().getFullYear();
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/changelog/changelog-release.tsx b/apps/web/src/components/changelog/changelog-release.tsx
new file mode 100644
index 0000000..0a5b0a6
--- /dev/null
+++ b/apps/web/src/components/changelog/changelog-release.tsx
@@ -0,0 +1,48 @@
+import Link from "next/link";
+
+import { ReleaseBody } from "@/lib/html-parser";
+import type { GitHubRelease } from "@/lib/releases";
+import { formatReleaseDate } from "@/lib/releases";
+
+type ChangelogReleaseProps = {
+ release: GitHubRelease;
+};
+
+export function ChangelogRelease({ release }: ChangelogReleaseProps) {
+ return (
+
+
+
+ {release.body ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/components/changelog/changelog-sidebar.tsx b/apps/web/src/components/changelog/changelog-sidebar.tsx
new file mode 100644
index 0000000..30d0ef2
--- /dev/null
+++ b/apps/web/src/components/changelog/changelog-sidebar.tsx
@@ -0,0 +1,69 @@
+import { ExternalLink, Github, HistoryIcon } from "lucide-react";
+import Link from "next/link";
+
+import { URLs } from "@/lib/consts";
+import type { GitHubRelease } from "@/lib/releases";
+import { cn } from "@/lib/utils";
+
+type ChangelogSidebarProps = {
+ latestRelease: GitHubRelease | undefined;
+ releaseCount: number;
+};
+
+export function ChangelogSidebar({ latestRelease, releaseCount }: ChangelogSidebarProps) {
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/changelog/release-contributors.tsx b/apps/web/src/components/changelog/release-contributors.tsx
new file mode 100644
index 0000000..1a9e87f
--- /dev/null
+++ b/apps/web/src/components/changelog/release-contributors.tsx
@@ -0,0 +1,34 @@
+import Link from "next/link";
+
+type ReleaseContributorsProps = {
+ contributors: string[];
+};
+
+export function ReleaseContributors({ contributors }: ReleaseContributorsProps) {
+ if (contributors.length === 0) return null;
+
+ return (
+
+ Contributors
+
+ Thanks to everyone who contributed to this release.
+
+
+ {contributors.map((username) => (
+ -
+
+
+ @{username}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/apps/web/src/components/layout/navigation-bar.tsx b/apps/web/src/components/layout/navigation-bar.tsx
index 152c0e8..a4bb727 100644
--- a/apps/web/src/components/layout/navigation-bar.tsx
+++ b/apps/web/src/components/layout/navigation-bar.tsx
@@ -51,6 +51,7 @@ function NavLink({
const navTabs: NavItem[] = [
{ name: "readme", href: "/" },
{ name: "docs", href: "/docs", path: "/docs" },
+ { name: "changelog", href: "/changelog" },
];
const dropdownLinks: NavItem[] = [
diff --git a/apps/web/src/components/sections/footer-section.tsx b/apps/web/src/components/sections/footer-section.tsx
index 0541f98..e422cef 100644
--- a/apps/web/src/components/sections/footer-section.tsx
+++ b/apps/web/src/components/sections/footer-section.tsx
@@ -11,6 +11,7 @@ const navLinks = [
{ label: "Docs", href: "/docs" },
{ label: "Contact", href: "/contact" },
{ label: "Author", href: URLs.authorX, external: true },
+ { label: "Changelog", href: "/changelog" },
];
const socialLinks = [
diff --git a/apps/web/src/env.js b/apps/web/src/env.js
index 96eac3e..b94db99 100644
--- a/apps/web/src/env.js
+++ b/apps/web/src/env.js
@@ -7,6 +7,7 @@ export const env = createEnv({
RESEND_API_KEY: z.string().min(1),
RESEND_FROM_EMAIL: z.string().email().default("contact@paykit.sh"),
RESEND_TO_EMAIL: z.string().email().default("contact@paykit.sh"),
+ GITHUB_TOKEN: z.string().min(1).optional(),
},
client: {},
runtimeEnv: {
@@ -14,6 +15,7 @@ export const env = createEnv({
RESEND_API_KEY: process.env.RESEND_API_KEY,
RESEND_FROM_EMAIL: process.env.RESEND_FROM_EMAIL,
RESEND_TO_EMAIL: process.env.RESEND_TO_EMAIL,
+ GITHUB_TOKEN: process.env.GITHUB_TOKEN,
},
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
emptyStringAsUndefined: true,
diff --git a/apps/web/src/lib/consts.ts b/apps/web/src/lib/consts.ts
index 8065ac0..eca1602 100644
--- a/apps/web/src/lib/consts.ts
+++ b/apps/web/src/lib/consts.ts
@@ -21,6 +21,7 @@ export const URLs = {
discord: "https://discord.gg/nzy9NPpFNU",
authorGitHub: "https://github.com/maxktz",
authorX: "https://x.com/maxktz",
+ changelog: "https://paykit.sh/changelog",
} as const;
export const VERSION_TEXT = "v0.1 beta";
diff --git a/apps/web/src/lib/github.ts b/apps/web/src/lib/github.ts
new file mode 100644
index 0000000..8ec9f1b
--- /dev/null
+++ b/apps/web/src/lib/github.ts
@@ -0,0 +1,17 @@
+import { env } from "@/env";
+import type { GitHubRelease } from "@/lib/releases";
+
+export async function getReleases(): Promise {
+ const res = await fetch("https://api.github.com/repos/getpaykit/paykit/releases", {
+ next: { revalidate: 3600 },
+ signal: AbortSignal.timeout(10_000),
+ headers: {
+ ...(env.GITHUB_TOKEN && { Authorization: `Bearer ${env.GITHUB_TOKEN}` }),
+ Accept: "application/vnd.github.v3+json",
+ },
+ });
+
+ if (!res.ok) throw new Error("Failed to fetch releases");
+
+ return res.json() as Promise;
+}
diff --git a/apps/web/src/lib/html-parser.tsx b/apps/web/src/lib/html-parser.tsx
new file mode 100644
index 0000000..ea8a2ef
--- /dev/null
+++ b/apps/web/src/lib/html-parser.tsx
@@ -0,0 +1,109 @@
+"use client";
+
+import { ChevronDown, ChevronUp } from "lucide-react";
+import type { ReactNode } from "react";
+import { useState } from "react";
+import ReactMarkdown from "react-markdown";
+import rehypeRaw from "rehype-raw";
+import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
+
+import { ReleaseContributors } from "@/components/changelog/release-contributors";
+import { cn } from "@/lib/utils";
+
+import { extractContributors } from "./releases";
+
+const COLLAPSE_THRESHOLD = 720;
+
+const markdownComponents = {
+ h2: ({ children }: { children?: ReactNode }) => (
+
+ {children}
+
+ ),
+ h3: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ h4: ({ children }: { children?: ReactNode }) => (
+
+ {children}
+
+ ),
+ p: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ ul: ({ children }: { children?: ReactNode }) => (
+
+ ),
+ ol: ({ children }: { children?: ReactNode }) => (
+
+ {children}
+
+ ),
+ li: ({ children }: { children?: ReactNode }) => {children},
+ a: ({ href, children }: { href?: string; children?: ReactNode }) => (
+
+ {children}
+
+ ),
+ strong: ({ children }: { children?: ReactNode }) => (
+ {children}
+ ),
+ code: ({ children }: { children?: ReactNode }) => (
+
+ {children}
+
+ ),
+ hr: () =>
,
+} as const;
+
+export function ReleaseBody({ body }: { body: string }) {
+ const [expanded, setExpanded] = useState(false);
+ const isCollapsible = body.length > COLLAPSE_THRESHOLD;
+ const contributors = extractContributors(body);
+
+ return (
+
+
+
+
+ {body}
+
+
+ {contributors.length >= 1 && }
+
+
+ {isCollapsible && !expanded ? (
+
+ ) : null}
+
+
+ {isCollapsible ? (
+
+ ) : null}
+
+ );
+}
diff --git a/apps/web/src/lib/releases.ts b/apps/web/src/lib/releases.ts
new file mode 100644
index 0000000..8baa4ce
--- /dev/null
+++ b/apps/web/src/lib/releases.ts
@@ -0,0 +1,22 @@
+export type GitHubRelease = {
+ id: number;
+ tag_name: string;
+ name: string;
+ body: string;
+ published_at: string;
+ html_url: string;
+ prerelease: boolean;
+ mentions_count: number;
+};
+
+export function extractContributors(body: string): string[] {
+ return [...new Set([...body.matchAll(/@([a-zA-Z0-9-]+)/g)].map((match) => match[1]!))];
+}
+
+export function formatReleaseDate(iso: string): string {
+ return new Date(iso).toLocaleDateString("en-US", {
+ month: "long",
+ day: "numeric",
+ year: "numeric",
+ });
+}
diff --git a/bun.lock b/bun.lock
index 1e79151..badc4dc 100644
--- a/bun.lock
+++ b/bun.lock
@@ -4,6 +4,11 @@
"workspaces": {
"": {
"name": "paykitjs-root",
+ "dependencies": {
+ "react-markdown": "^10.1.0",
+ "rehype-raw": "^7.0.0",
+ "rehype-sanitize": "^6.0.0",
+ },
"devDependencies": {
"@types/node": "catalog:",
"bumpp": "^10.4.1",
@@ -1589,6 +1594,8 @@
"hast-util-raw": ["hast-util-raw@9.1.0", "https://registry.better-npm.dev/hast-util-raw/-/hast-util-raw-9.1.0.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "hast-util-from-parse5": "^8.0.0", "hast-util-to-parse5": "^8.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "parse5": "^7.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0", "web-namespaces": "^2.0.0", "zwitch": "^2.0.0" } }, "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw=="],
+ "hast-util-sanitize": ["hast-util-sanitize@5.0.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@ungap/structured-clone": "^1.0.0", "unist-util-position": "^5.0.0" } }, "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg=="],
+
"hast-util-to-estree": ["hast-util-to-estree@3.1.3", "https://registry.better-npm.dev/hast-util-to-estree/-/hast-util-to-estree-3.1.3.tgz", { "dependencies": { "@types/estree": "^1.0.0", "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-attach-comments": "^3.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w=="],
"hast-util-to-html": ["hast-util-to-html@9.0.5", "https://registry.better-npm.dev/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="],
@@ -1609,6 +1616,8 @@
"hookable": ["hookable@6.1.1", "https://registry.better-npm.dev/hookable/-/hookable-6.1.1.tgz", {}, "sha512-U9LYDy1CwhMCnprUfeAZWZGByVbhd54hwepegYTK7Pi5NvqEj63ifz5z+xukznehT7i6NIZRu89Ay1AZmRsLEQ=="],
+ "html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
+
"html-void-elements": ["html-void-elements@3.0.0", "https://registry.better-npm.dev/html-void-elements/-/html-void-elements-3.0.0.tgz", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="],
"http-errors": ["http-errors@2.0.1", "https://registry.better-npm.dev/http-errors/-/http-errors-2.0.1.tgz", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
@@ -2163,6 +2172,8 @@
"react-is": ["react-is@16.13.1", "https://registry.better-npm.dev/react-is/-/react-is-16.13.1.tgz", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
+ "react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
+
"react-reconciler": ["react-reconciler@0.33.0", "https://registry.better-npm.dev/react-reconciler/-/react-reconciler-0.33.0.tgz", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
"react-redux": ["react-redux@9.2.0", "https://registry.better-npm.dev/react-redux/-/react-redux-9.2.0.tgz", { "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" }, "peerDependencies": { "@types/react": "^18.2.25 || ^19", "react": "^18.0 || ^19", "redux": "^5.0.0" }, "optionalPeers": ["@types/react", "redux"] }, "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g=="],
@@ -2207,6 +2218,8 @@
"rehype-recma": ["rehype-recma@1.0.0", "https://registry.better-npm.dev/rehype-recma/-/rehype-recma-1.0.0.tgz", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "hast-util-to-estree": "^3.0.0" } }, "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw=="],
+ "rehype-sanitize": ["rehype-sanitize@6.0.0", "", { "dependencies": { "@types/hast": "^3.0.0", "hast-util-sanitize": "^5.0.0" } }, "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg=="],
+
"remark": ["remark@15.0.1", "https://registry.better-npm.dev/remark/-/remark-15.0.1.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-Eht5w30ruCXgFmxVUSlNWQ9iiimq07URKeFS3hNc8cUWy1llX4KDWfyEDZRycMc+znsN9Ux5/tJ/BFdgdOwA3A=="],
"remark-gfm": ["remark-gfm@4.0.1", "https://registry.better-npm.dev/remark-gfm/-/remark-gfm-4.0.1.tgz", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
diff --git a/package.json b/package.json
index b17c5e4..31041af 100644
--- a/package.json
+++ b/package.json
@@ -74,5 +74,10 @@
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7"
}
+ },
+ "dependencies": {
+ "react-markdown": "^10.1.0",
+ "rehype-raw": "^7.0.0",
+ "rehype-sanitize": "^6.0.0"
}
}