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 ( + +
+ +
+ {children} +
+
+
+ ); +} 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 ( +
+ + + +
+
+
+
+

+ Changelog +

+
+
+ + {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 ( +
+
+ + View all releases on GitHub + + + +
+ + +
+ © {year} PayKit + +
+ + + + + + +
+
+
+
+
+ ); +} 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.tag_name} +

+ + +
+ +
+
+ {release.name && release.name !== release.tag_name ? ( +

{release.name}

+ ) : null} +
+
+ + {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 }) => ( +
    + {children} +
+ ), + 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 ? ( + + + {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" } }