diff --git a/README.md b/README.md
index 3d20414..3d8d9cf 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,14 @@ Reward GitHub contributors with Pvium payment links. Maintainers label bounty
issues, contributors close them with pull requests, and Pvium handles the
invite, payment link, funded webhook, and paid status updates.
+## Issue Discovery
+
+The homepage at `/` lists connected bounty issues across installed repositories.
+Use it to sort by recent activity or highest payout, filter by minimum bounty,
+and open the source GitHub issue.
+
+The deployment and webhook setup guide is available at `/deploy`.
+
## Flow
1. A repository owner installs the GitHub App.
diff --git a/src/app/deploy/page.tsx b/src/app/deploy/page.tsx
new file mode 100644
index 0000000..659e33f
--- /dev/null
+++ b/src/app/deploy/page.tsx
@@ -0,0 +1,466 @@
+import type { CSSProperties, ReactNode } from "react";
+
+const flowSteps = [
+ "A repository owner installs the GitHub App.",
+ "A maintainer labels an issue with a Pvium bounty label, for example pvium:20USDC or pvium:10.",
+ "When a pull request is merged into a configured reward target branch and closes that issue, the app checks the PR author.",
+ "If the PR author is already linked to a Pvium account, the app creates a Pvium payment link and comments a Pay reward link on the PR.",
+ "If the PR author is not linked, the app generates a signed Pvium OAuth invite for type: github and comments the invite link on the PR.",
+ "Once the user accepts the invite and connects the matching GitHub account in Pvium, the Pvium webhook creates the payment link and comments the Pay reward link on the PR.",
+ "When Pvium sends a paid or funded webhook, the app marks the reward and bounty as PAID.",
+];
+
+const localSetupCommands = [
+ "cd /Users/Projects/Javascript/paytrack/sdks/node",
+ "npm install",
+ "npm run build",
+ "",
+ "cd /Users/Projects/Javascript/paytrack/github-app",
+ "npm install",
+ "cp .env.example .env",
+ "npm run prisma:generate",
+ "npm run prisma:migrate",
+ "npm run dev",
+];
+
+const envExample = `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/pvium_github_app"
+
+GITHUB_APP_ID=""
+GITHUB_APP_PRIVATE_KEY=""
+GITHUB_WEBHOOK_SECRET=""
+GITHUB_REWARD_TARGET_BRANCHES="main,master"
+PVIUM_BOUNTY_LABEL_PREFIX="pvium:"
+
+PVIUM_ENVIRONMENT="sandbox"
+PVIUM_API_BASE_URL=""
+PVIUM_CONSENT_HOST=""
+PVIUM_SDK_LOG_REQUESTS="false"
+PVIUM_API_KEY=""
+PVIUM_CLIENT_ID=""
+PVIUM_WEBHOOK_SECRET=""
+PVIUM_INVITE_SIGNER_PRIVATE_KEY=""
+PVIUM_OAUTH_REDIRECT_URI="http://localhost:3000/api/pvium/oauth/callback"
+PVIUM_REWARD_PAYMENT_MODEL="instant-batch"
+PVIUM_REWARD_PAYMENT_SIGNER_PRIVATE_KEY=""
+PVIUM_REWARD_PAYMENT_CHAIN="base"
+PVIUM_REWARD_PAYMENT_CHAIN_ID="8453"
+PVIUM_REWARD_PAYMENT_CURRENCY="USDC"
+PVIUM_REWARD_PAYMENT_TOKEN_ADDRESS=""
+PVIUM_REWARD_PAYMENT_TOKEN_DECIMALS="6"
+PVIUM_REWARD_PLATFORM_FEE_WALLET=""
+PVIUM_REWARD_PLATFORM_FEE_BASIS_POINTS="0"
+PVIUM_REWARD_MAX_FEE_AMOUNT="0"
+PVIUM_INVOICE_REDIRECT_URI="http://localhost:3000/api/pvium/oauth/callback"
+
+APP_BASE_URL="http://localhost:3000"`;
+
+const githubPermissions = [
+ "Issues: read and write",
+ "Pull requests: read and write",
+ "Metadata: read-only",
+];
+
+const githubEvents = ["issues", "pull_request"];
+
+const configItems = [
+ "GITHUB_REWARD_TARGET_BRANCHES is a comma-separated list of base branches that can trigger reward processing when a PR is merged.",
+ "PVIUM_BOUNTY_LABEL_PREFIX controls the GitHub issue label prefix used to detect bounties. It defaults to pvium: when unset or empty.",
+ "PVIUM_ENVIRONMENT resolves Pvium hosts: test uses localhost, sandbox uses api-sandbox.pvium.com, and production uses api.pvium.com.",
+ "PVIUM_API_BASE_URL and PVIUM_CONSENT_HOST override the resolved Pvium hosts when needed.",
+ "PVIUM_SDK_LOG_REQUESTS=true logs SDK request method, host, path, status, duration, and network errors without logging full URLs or secrets.",
+ "PVIUM_REWARD_PAYMENT_MODEL controls the payment artifact. Use instant-batch for finalized instant batch payment links or invoice for the legacy invoice flow.",
+ "PVIUM_REWARD_PAYMENT_CHAIN is the chain used by both invoice payment channels and instant batch links.",
+ "PVIUM_REWARD_PAYMENT_CURRENCY is the invoice payment currency.",
+ "PVIUM_REWARD_PAYMENT_SIGNER_PRIVATE_KEY signs instant batches. If omitted, the invite signer is used.",
+ "PVIUM_REWARD_PAYMENT_CHAIN_ID is the chain id used to finalize instant batches.",
+ "PVIUM_REWARD_PAYMENT_TOKEN_ADDRESS and PVIUM_REWARD_PAYMENT_TOKEN_DECIMALS define the instant batch payout token.",
+ "PVIUM_REWARD_PLATFORM_FEE_WALLET receives the platform fee. If omitted, no platform fee payee is added.",
+ "PVIUM_REWARD_PLATFORM_FEE_BASIS_POINTS sets the fee. For example, 100 is 1% and 250 is 2.5%.",
+ "PVIUM_REWARD_MAX_FEE_AMOUNT caps the computed platform fee. Use 0 for no cap.",
+];
+
+const pviumEvents = [
+ "oauth.invite.accepted",
+ "invoice.paid",
+ "invoice.payment_completed",
+ "invoice.payment.succeeded",
+ "payment.attached",
+ "batch.funded",
+ "batch.payment_completed",
+ "batch.payment.succeeded",
+];
+
+const usageSteps = [
+ "Install the GitHub App on a repository.",
+ "Add a bounty label to an issue, such as pvium:20USDC or pvium:20. If PVIUM_BOUNTY_LABEL_PREFIX is changed, use that prefix instead.",
+ "Merge a PR into a configured reward target branch with a closing reference like Closes #123.",
+ "The app comments on the merged PR.",
+ "If the contributor needs to link Pvium, they use the invite link in the comment.",
+ "Pvium redirects back to /api/pvium/oauth/callback with an OAuth code.",
+ "The app exchanges the code through the local Pvium SDK, verifies the accepted GitHub handle, saves the OAuth token set, creates the payment link, and comments a Pay reward link.",
+ "The maintainer clicks Pay reward and completes payment in Pvium.",
+];
+
+export default function Home() {
+ return (
+
+
+
+ Pvium GitHub App
+
+ Reward GitHub contributors with Pvium payment links.
+
+
+ Turn merged pull requests into payable rewards. Maintainers label
+ bounty issues, contributors close them with PRs, and Pvium handles the
+ invite, payment link, funded webhook, and paid status updates.
+
+
+
+
+
+
+
+
+
+
+
+
+ The reward automation uses the local Pvium SDK at{" "}
+
+ /Users/Projects/Javascript/paytrack/sdks/node
+
+ . The package points @pvium/sdk{" "}
+ at file:../sdks/node, so
+ rebuild the SDK after changing it.
+
+
+
+
+
+
+ Required values are documented in .env.example:
+
+
+
+
+
+
+ Generate GITHUB_APP_PRIVATE_KEY{" "}
+ from the GitHub App settings page under Private keys, then copy the
+ full PEM contents into the environment with line breaks replaced by{" "}
+ \n.
+
+
+ Configure the webhook URL as{" "}
+
+ https://<your-host>/api/github/webhook
+
+ .
+
+
+
+
+
+
+
+
+
+ Configure the Pvium webhook URL as{" "}
+
+ https://<your-host>/api/pvium/webhook
+
+ . Set PVIUM_WEBHOOK_SECRET to
+ the same secret configured on the Pvium client app.
+
+
+
+ When{" "}
+
+ PVIUM_REWARD_PLATFORM_FEE_WALLET
+ {" "}
+ is set and the fee basis points are greater than zero, instant batches
+ include the platform fee as the first payee with memo{" "}
+ platform fee. The contributor
+ reward amount is not reduced by the fee.
+
+
+
+
+
+
+
+
+ The app stores Pvium OAuth access and refresh tokens on the GitHub
+ user link so future merged PRs for the same contributor can create
+ rewards without asking the contributor to authorize again. Treat these
+ OAuth tokens as secrets; production deployments should encrypt them at
+ rest and restrict database access.
+
+
+
+ );
+}
+
+function Section({ title, children }: { title: string; children: ReactNode }) {
+ return (
+
+ );
+}
+
+function Endpoint({ label, value }: { label: string; value: string }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+}
+
+function ListBlock({ title, items }: { title: string; items: string[] }) {
+ return (
+
+
{title}
+
+
+ );
+}
+
+function BulletList({ items }: { items: string[] }) {
+ return (
+
+ {items.map((item) => (
+ -
+ {item}
+
+ ))}
+
+ );
+}
+
+function NumberedList({ items }: { items: string[] }) {
+ return (
+
+ {items.map((item) => (
+ -
+ {item}
+
+ ))}
+
+ );
+}
+
+function CodeBlock({ value }: { value: string }) {
+ return {value};
+}
+
+const styles: Record = {
+ page: {
+ minHeight: "100vh",
+ margin: 0,
+ padding: "48px 20px",
+ background: "#f7f8fb",
+ color: "#172033",
+ fontFamily:
+ 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
+ },
+ hero: {
+ maxWidth: 980,
+ margin: "0 auto 24px",
+ padding: "32px 0 8px",
+ },
+ brandRow: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: 12,
+ marginBottom: 18,
+ },
+ topLinks: {
+ display: "flex",
+ alignItems: "center",
+ flexWrap: "wrap",
+ gap: 10,
+ },
+ logo: {
+ width: 96,
+ height: 96,
+ borderRadius: 8,
+ objectFit: "contain",
+ },
+ logoLink: {
+ display: "inline-flex",
+ lineHeight: 0,
+ },
+ poweredBy: {
+ display: "inline-flex",
+ alignItems: "center",
+ padding: "9px 13px",
+ border: "1px solid #c8d0df",
+ borderRadius: 999,
+ background: "#ffffff",
+ color: "#172033",
+ fontSize: 14,
+ fontWeight: 600,
+ textDecoration: "none",
+ },
+ installLink: {
+ display: "inline-flex",
+ alignItems: "center",
+ padding: "10px 14px",
+ borderRadius: 8,
+ background: "#172033",
+ color: "#ffffff",
+ fontSize: 14,
+ fontWeight: 700,
+ textDecoration: "none",
+ },
+ eyebrow: {
+ margin: "0 0 12px",
+ color: "#52627a",
+ fontSize: 14,
+ fontWeight: 700,
+ textTransform: "uppercase",
+ },
+ title: {
+ maxWidth: 820,
+ margin: "0 0 18px",
+ fontSize: 48,
+ lineHeight: 1.08,
+ letterSpacing: 0,
+ },
+ lede: {
+ maxWidth: 760,
+ margin: "0 0 24px",
+ color: "#46556e",
+ fontSize: 18,
+ lineHeight: 1.65,
+ },
+ endpointGrid: {
+ display: "grid",
+ gridTemplateColumns: "repeat(auto-fit, minmax(230px, 1fr))",
+ gap: 12,
+ maxWidth: 920,
+ },
+ endpoint: {
+ border: "1px solid #d9deea",
+ borderRadius: 8,
+ background: "#ffffff",
+ padding: 16,
+ },
+ endpointLabel: {
+ display: "block",
+ marginBottom: 8,
+ color: "#66748a",
+ fontSize: 13,
+ fontWeight: 700,
+ },
+ endpointCode: {
+ color: "#172033",
+ fontSize: 14,
+ wordBreak: "break-word",
+ },
+ section: {
+ maxWidth: 980,
+ margin: "18px auto",
+ padding: 24,
+ border: "1px solid #d9deea",
+ borderRadius: 8,
+ background: "#ffffff",
+ },
+ sectionTitle: {
+ margin: "0 0 16px",
+ fontSize: 24,
+ letterSpacing: 0,
+ },
+ paragraph: {
+ margin: "0 0 14px",
+ color: "#46556e",
+ fontSize: 15,
+ lineHeight: 1.7,
+ },
+ columns: {
+ display: "grid",
+ gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
+ gap: 18,
+ },
+ listBlock: {
+ minWidth: 0,
+ },
+ listTitle: {
+ margin: "0 0 10px",
+ color: "#263247",
+ fontSize: 16,
+ },
+ list: {
+ margin: 0,
+ paddingLeft: 22,
+ color: "#46556e",
+ fontSize: 15,
+ lineHeight: 1.7,
+ },
+ listItem: {
+ marginBottom: 8,
+ },
+ codeBlock: {
+ margin: "14px 0 0",
+ padding: 16,
+ overflowX: "auto",
+ borderRadius: 8,
+ background: "#141925",
+ color: "#eef3ff",
+ fontSize: 13,
+ lineHeight: 1.6,
+ },
+ inlineCode: {
+ padding: "2px 5px",
+ borderRadius: 5,
+ background: "#eef1f6",
+ color: "#263247",
+ fontSize: "0.92em",
+ },
+};
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 659e33f..c5c7425 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,356 +1,293 @@
-import type { CSSProperties, ReactNode } from "react";
+import type { CSSProperties } from "react";
+import { prisma } from "@/lib/db/prisma";
-const flowSteps = [
- "A repository owner installs the GitHub App.",
- "A maintainer labels an issue with a Pvium bounty label, for example pvium:20USDC or pvium:10.",
- "When a pull request is merged into a configured reward target branch and closes that issue, the app checks the PR author.",
- "If the PR author is already linked to a Pvium account, the app creates a Pvium payment link and comments a Pay reward link on the PR.",
- "If the PR author is not linked, the app generates a signed Pvium OAuth invite for type: github and comments the invite link on the PR.",
- "Once the user accepts the invite and connects the matching GitHub account in Pvium, the Pvium webhook creates the payment link and comments the Pay reward link on the PR.",
- "When Pvium sends a paid or funded webhook, the app marks the reward and bounty as PAID.",
-];
+export const dynamic = "force-dynamic";
-const localSetupCommands = [
- "cd /Users/Projects/Javascript/paytrack/sdks/node",
- "npm install",
- "npm run build",
- "",
- "cd /Users/Projects/Javascript/paytrack/github-app",
- "npm install",
- "cp .env.example .env",
- "npm run prisma:generate",
- "npm run prisma:migrate",
- "npm run dev",
-];
+type SortMode = "recent" | "top";
+type SortOrder = "asc" | "desc";
+type SearchParams = Record;
-const envExample = `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/pvium_github_app"
-
-GITHUB_APP_ID=""
-GITHUB_APP_PRIVATE_KEY=""
-GITHUB_WEBHOOK_SECRET=""
-GITHUB_REWARD_TARGET_BRANCHES="main,master"
-PVIUM_BOUNTY_LABEL_PREFIX="pvium:"
-
-PVIUM_ENVIRONMENT="sandbox"
-PVIUM_API_BASE_URL=""
-PVIUM_CONSENT_HOST=""
-PVIUM_SDK_LOG_REQUESTS="false"
-PVIUM_API_KEY=""
-PVIUM_CLIENT_ID=""
-PVIUM_WEBHOOK_SECRET=""
-PVIUM_INVITE_SIGNER_PRIVATE_KEY=""
-PVIUM_OAUTH_REDIRECT_URI="http://localhost:3000/api/pvium/oauth/callback"
-PVIUM_REWARD_PAYMENT_MODEL="instant-batch"
-PVIUM_REWARD_PAYMENT_SIGNER_PRIVATE_KEY=""
-PVIUM_REWARD_PAYMENT_CHAIN="base"
-PVIUM_REWARD_PAYMENT_CHAIN_ID="8453"
-PVIUM_REWARD_PAYMENT_CURRENCY="USDC"
-PVIUM_REWARD_PAYMENT_TOKEN_ADDRESS=""
-PVIUM_REWARD_PAYMENT_TOKEN_DECIMALS="6"
-PVIUM_REWARD_PLATFORM_FEE_WALLET=""
-PVIUM_REWARD_PLATFORM_FEE_BASIS_POINTS="0"
-PVIUM_REWARD_MAX_FEE_AMOUNT="0"
-PVIUM_INVOICE_REDIRECT_URI="http://localhost:3000/api/pvium/oauth/callback"
-
-APP_BASE_URL="http://localhost:3000"`;
+interface IssueRow {
+ id: string;
+ title: string;
+ repository: string;
+ amount: number;
+ currency: string;
+ status: string;
+ createdAt: Date;
+ updatedAt: Date;
+ excerpt: string;
+ url: string;
+}
-const githubPermissions = [
- "Issues: read and write",
- "Pull requests: read and write",
- "Metadata: read-only",
-];
+interface GitHubIssue {
+ title?: string;
+ state?: string;
+ body?: string | null;
+ html_url?: string;
+ created_at?: string;
+ updated_at?: string;
+}
-const githubEvents = ["issues", "pull_request"];
+function valueOfParam(value: string | string[] | undefined) {
+ return Array.isArray(value) ? value[0] : value;
+}
-const configItems = [
- "GITHUB_REWARD_TARGET_BRANCHES is a comma-separated list of base branches that can trigger reward processing when a PR is merged.",
- "PVIUM_BOUNTY_LABEL_PREFIX controls the GitHub issue label prefix used to detect bounties. It defaults to pvium: when unset or empty.",
- "PVIUM_ENVIRONMENT resolves Pvium hosts: test uses localhost, sandbox uses api-sandbox.pvium.com, and production uses api.pvium.com.",
- "PVIUM_API_BASE_URL and PVIUM_CONSENT_HOST override the resolved Pvium hosts when needed.",
- "PVIUM_SDK_LOG_REQUESTS=true logs SDK request method, host, path, status, duration, and network errors without logging full URLs or secrets.",
- "PVIUM_REWARD_PAYMENT_MODEL controls the payment artifact. Use instant-batch for finalized instant batch payment links or invoice for the legacy invoice flow.",
- "PVIUM_REWARD_PAYMENT_CHAIN is the chain used by both invoice payment channels and instant batch links.",
- "PVIUM_REWARD_PAYMENT_CURRENCY is the invoice payment currency.",
- "PVIUM_REWARD_PAYMENT_SIGNER_PRIVATE_KEY signs instant batches. If omitted, the invite signer is used.",
- "PVIUM_REWARD_PAYMENT_CHAIN_ID is the chain id used to finalize instant batches.",
- "PVIUM_REWARD_PAYMENT_TOKEN_ADDRESS and PVIUM_REWARD_PAYMENT_TOKEN_DECIMALS define the instant batch payout token.",
- "PVIUM_REWARD_PLATFORM_FEE_WALLET receives the platform fee. If omitted, no platform fee payee is added.",
- "PVIUM_REWARD_PLATFORM_FEE_BASIS_POINTS sets the fee. For example, 100 is 1% and 250 is 2.5%.",
- "PVIUM_REWARD_MAX_FEE_AMOUNT caps the computed platform fee. Use 0 for no cap.",
-];
+function parseSearchParams(params: SearchParams) {
+ const sortParam = valueOfParam(params.sort);
+ const orderParam = valueOfParam(params.order);
+ const minParam = valueOfParam(params.minBounty);
-const pviumEvents = [
- "oauth.invite.accepted",
- "invoice.paid",
- "invoice.payment_completed",
- "invoice.payment.succeeded",
- "payment.attached",
- "batch.funded",
- "batch.payment_completed",
- "batch.payment.succeeded",
-];
+ const sort: SortMode = sortParam === "top" ? "top" : "recent";
+ const order: SortOrder = orderParam === "asc" ? "asc" : "desc";
+ const minBounty = Math.max(0, Number(minParam ?? 0) || 0);
-const usageSteps = [
- "Install the GitHub App on a repository.",
- "Add a bounty label to an issue, such as pvium:20USDC or pvium:20. If PVIUM_BOUNTY_LABEL_PREFIX is changed, use that prefix instead.",
- "Merge a PR into a configured reward target branch with a closing reference like Closes #123.",
- "The app comments on the merged PR.",
- "If the contributor needs to link Pvium, they use the invite link in the comment.",
- "Pvium redirects back to /api/pvium/oauth/callback with an OAuth code.",
- "The app exchanges the code through the local Pvium SDK, verifies the accepted GitHub handle, saves the OAuth token set, creates the payment link, and comments a Pay reward link.",
- "The maintainer clicks Pay reward and completes payment in Pvium.",
-];
+ return { sort, order, minBounty };
+}
-export default function Home() {
- return (
-
-
-
- Pvium GitHub App
-
- Reward GitHub contributors with Pvium payment links.
-
-
- Turn merged pull requests into payable rewards. Maintainers label
- bounty issues, contributors close them with PRs, and Pvium handles the
- invite, payment link, funded webhook, and paid status updates.
-
-
-
-
-
-
-
+async function loadIssues() {
+ try {
+ const bounties = await prisma.bounty.findMany({
+ include: { repository: true },
+ orderBy: [{ updatedAt: "desc" }],
+ take: 200,
+ });
-
+ return Promise.all(
+ bounties.map>(async (bounty) => {
+ const repository = `${bounty.repository.owner}/${bounty.repository.repo}`;
+ const amount = Number(bounty.amount);
+ const githubIssue = await fetchGitHubIssue(
+ repository,
+ bounty.issueNumber,
+ );
+ const bodyExcerpt = githubIssue?.body
+ ? githubIssue.body.replace(/\s+/g, " ").trim().slice(0, 160)
+ : "";
-
-
- The reward automation uses the local Pvium SDK at{" "}
-
- /Users/Projects/Javascript/paytrack/sdks/node
-
- . The package points @pvium/sdk{" "}
- at file:../sdks/node, so
- rebuild the SDK after changing it.
-
-
-
+ return {
+ id: bounty.id,
+ title: githubIssue?.title ?? `Issue #${bounty.issueNumber}`,
+ repository,
+ amount,
+ currency: bounty.currency,
+ status: (githubIssue?.state ?? bounty.status).replaceAll("_", " "),
+ createdAt: githubIssue?.created_at
+ ? new Date(githubIssue.created_at)
+ : bounty.createdAt,
+ updatedAt: githubIssue?.updated_at
+ ? new Date(githubIssue.updated_at)
+ : bounty.updatedAt,
+ excerpt:
+ bodyExcerpt || `Bounty detected from label ${bounty.labelName}.`,
+ url:
+ githubIssue?.html_url ??
+ `https://github.com/${repository}/issues/${bounty.issueNumber}`,
+ };
+ }),
+ );
+ } catch {
+ return [];
+ }
+}
-
-
- Required values are documented in .env.example:
-
-
-
+async function fetchGitHubIssue(repository: string, issueNumber: number) {
+ try {
+ const response = await fetch(
+ `https://api.github.com/repos/${repository}/issues/${issueNumber}`,
+ {
+ headers: {
+ Accept: "application/vnd.github+json",
+ "User-Agent": "pvium-github-app",
+ },
+ next: { revalidate: 60 },
+ },
+ );
-
-
- Generate GITHUB_APP_PRIVATE_KEY{" "}
- from the GitHub App settings page under Private keys, then copy the
- full PEM contents into the environment with line breaks replaced by{" "}
- \n.
-
-
- Configure the webhook URL as{" "}
-
- https://<your-host>/api/github/webhook
-
- .
-
-
-
-
-
-
+ if (!response.ok) return null;
+ return (await response.json()) as GitHubIssue;
+ } catch {
+ return null;
+ }
+}
-
-
- Configure the Pvium webhook URL as{" "}
-
- https://<your-host>/api/pvium/webhook
-
- . Set PVIUM_WEBHOOK_SECRET to
- the same secret configured on the Pvium client app.
-
-
-
- When{" "}
-
- PVIUM_REWARD_PLATFORM_FEE_WALLET
- {" "}
- is set and the fee basis points are greater than zero, instant batches
- include the platform fee as the first payee with memo{" "}
- platform fee. The contributor
- reward amount is not reduced by the fee.
-
-
+function sortIssues(issues: IssueRow[], sort: SortMode, order: SortOrder) {
+ const direction = order === "asc" ? 1 : -1;
-
+ return [...issues].sort((left, right) => {
+ if (sort === "top") {
+ return (left.amount - right.amount) * direction;
+ }
-
-
-
- The app stores Pvium OAuth access and refresh tokens on the GitHub
- user link so future merged PRs for the same contributor can create
- rewards without asking the contributor to authorize again. Treat these
- OAuth tokens as secrets; production deployments should encrypt them at
- rest and restrict database access.
-
-
-
- );
+ return (left.updatedAt.getTime() - right.updatedAt.getTime()) * direction;
+ });
}
-function Section({ title, children }: { title: string; children: ReactNode }) {
- return (
-
- );
+function formatDate(date: Date) {
+ return new Intl.DateTimeFormat("en", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ }).format(date);
}
-function Endpoint({ label, value }: { label: string; value: string }) {
- return (
-
- {label}
- {value}
-
- );
+function formatBounty(issue: IssueRow) {
+ return `${issue.amount.toLocaleString("en", {
+ maximumFractionDigits: 6,
+ })} ${issue.currency}`;
}
-function ListBlock({ title, items }: { title: string; items: string[] }) {
- return (
-
-
{title}
-
-
+export default async function Home({
+ searchParams,
+}: {
+ searchParams?: SearchParams | Promise;
+}) {
+ const resolvedParams = searchParams ? await searchParams : {};
+ const { sort, order, minBounty } = parseSearchParams(resolvedParams);
+ const issues = await loadIssues();
+ const visibleIssues = sortIssues(
+ issues.filter((issue) => issue.amount >= minBounty),
+ sort,
+ order,
);
-}
-function BulletList({ items }: { items: string[] }) {
return (
-
- {items.map((item) => (
- -
- {item}
-
- ))}
-
- );
-}
+
+
-function NumberedList({ items }: { items: string[] }) {
- return (
-
- {items.map((item) => (
- -
- {item}
-
- ))}
-
- );
-}
+
+
+
+
+ Showing {visibleIssues.length} of {issues.length} connected bounty
+ issues
+
-function CodeBlock({ value }: { value: string }) {
- return {value};
+ {visibleIssues.length > 0 ? (
+
+
+
+
+ | Issue |
+ Repository |
+ Bounty |
+ Status |
+ Updated |
+
+
+
+ {visibleIssues.map((issue) => (
+
+ |
+
+ {issue.title}
+
+ {issue.excerpt}
+ |
+ {issue.repository} |
+ {formatBounty(issue)} |
+ {issue.status} |
+
+ {formatDate(issue.updatedAt)}
+
+ Created {formatDate(issue.createdAt)}
+
+ |
+
+ ))}
+
+
+
+ ) : (
+
+ No connected bounty issues match the current filters.
+
+ )}
+
+
+ );
}
const styles: Record = {
page: {
minHeight: "100vh",
margin: 0,
- padding: "48px 20px",
- background: "#f7f8fb",
+ padding: "42px 20px",
+ background: "#f6f7f9",
color: "#172033",
fontFamily:
'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
},
- hero: {
- maxWidth: 980,
- margin: "0 auto 24px",
- padding: "32px 0 8px",
- },
- brandRow: {
+ header: {
+ maxWidth: 1120,
+ margin: "0 auto 18px",
display: "flex",
- alignItems: "center",
+ alignItems: "flex-end",
justifyContent: "space-between",
- gap: 12,
- marginBottom: 18,
- },
- topLinks: {
- display: "flex",
- alignItems: "center",
- flexWrap: "wrap",
- gap: 10,
+ gap: 20,
},
- logo: {
- width: 96,
- height: 96,
- borderRadius: 8,
- objectFit: "contain",
+ eyebrow: {
+ margin: "0 0 10px",
+ color: "#58667a",
+ fontSize: 13,
+ fontWeight: 700,
+ letterSpacing: 0,
+ textTransform: "uppercase",
},
- logoLink: {
- display: "inline-flex",
- lineHeight: 0,
+ title: {
+ margin: "0 0 10px",
+ fontSize: 40,
+ lineHeight: 1.1,
+ letterSpacing: 0,
},
- poweredBy: {
- display: "inline-flex",
- alignItems: "center",
- padding: "9px 13px",
- border: "1px solid #c8d0df",
- borderRadius: 999,
- background: "#ffffff",
- color: "#172033",
- fontSize: 14,
- fontWeight: 600,
- textDecoration: "none",
+ lede: {
+ maxWidth: 720,
+ margin: 0,
+ color: "#4a596d",
+ fontSize: 16,
+ lineHeight: 1.6,
},
- installLink: {
- display: "inline-flex",
- alignItems: "center",
+ deployLink: {
+ flex: "0 0 auto",
padding: "10px 14px",
borderRadius: 8,
background: "#172033",
@@ -359,108 +296,120 @@ const styles: Record = {
fontWeight: 700,
textDecoration: "none",
},
- eyebrow: {
- margin: "0 0 12px",
- color: "#52627a",
- fontSize: 14,
- fontWeight: 700,
- textTransform: "uppercase",
- },
- title: {
- maxWidth: 820,
- margin: "0 0 18px",
- fontSize: 48,
- lineHeight: 1.08,
- letterSpacing: 0,
- },
- lede: {
- maxWidth: 760,
- margin: "0 0 24px",
- color: "#46556e",
- fontSize: 18,
- lineHeight: 1.65,
+ panel: {
+ maxWidth: 1120,
+ margin: "0 auto",
+ padding: 20,
+ border: "1px solid #d9dee8",
+ borderRadius: 8,
+ background: "#ffffff",
},
- endpointGrid: {
+ controls: {
display: "grid",
- gridTemplateColumns: "repeat(auto-fit, minmax(230px, 1fr))",
+ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
+ alignItems: "end",
gap: 12,
- maxWidth: 920,
},
- endpoint: {
- border: "1px solid #d9deea",
- borderRadius: 8,
- background: "#ffffff",
- padding: 16,
+ control: {
+ display: "grid",
+ gap: 6,
},
- endpointLabel: {
- display: "block",
- marginBottom: 8,
- color: "#66748a",
+ label: {
+ color: "#526176",
fontSize: 13,
fontWeight: 700,
},
- endpointCode: {
+ select: {
+ minHeight: 40,
+ border: "1px solid #cbd3df",
+ borderRadius: 8,
+ padding: "0 10px",
+ background: "#ffffff",
color: "#172033",
fontSize: 14,
- wordBreak: "break-word",
},
- section: {
- maxWidth: 980,
- margin: "18px auto",
- padding: 24,
- border: "1px solid #d9deea",
+ input: {
+ minHeight: 38,
+ border: "1px solid #cbd3df",
borderRadius: 8,
- background: "#ffffff",
+ padding: "0 10px",
+ color: "#172033",
+ fontSize: 14,
},
- sectionTitle: {
- margin: "0 0 16px",
- fontSize: 24,
- letterSpacing: 0,
+ button: {
+ minHeight: 40,
+ border: 0,
+ borderRadius: 8,
+ background: "#0f766e",
+ color: "#ffffff",
+ fontSize: 14,
+ fontWeight: 700,
+ cursor: "pointer",
},
- paragraph: {
- margin: "0 0 14px",
- color: "#46556e",
- fontSize: 15,
- lineHeight: 1.7,
+ summary: {
+ margin: "18px 0 10px",
+ color: "#526176",
+ fontSize: 14,
},
- columns: {
- display: "grid",
- gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
- gap: 18,
+ tableWrap: {
+ overflowX: "auto",
+ border: "1px solid #e0e5ee",
+ borderRadius: 8,
},
- listBlock: {
- minWidth: 0,
+ table: {
+ width: "100%",
+ borderCollapse: "collapse",
+ minWidth: 760,
},
- listTitle: {
- margin: "0 0 10px",
- color: "#263247",
- fontSize: 16,
+ th: {
+ padding: "12px 14px",
+ borderBottom: "1px solid #e0e5ee",
+ background: "#f8fafc",
+ color: "#526176",
+ fontSize: 12,
+ textAlign: "left",
+ textTransform: "uppercase",
},
- list: {
- margin: 0,
- paddingLeft: 22,
- color: "#46556e",
- fontSize: 15,
- lineHeight: 1.7,
+ tr: {
+ borderBottom: "1px solid #edf0f5",
},
- listItem: {
- marginBottom: 8,
+ td: {
+ padding: "14px",
+ color: "#344258",
+ fontSize: 14,
+ verticalAlign: "top",
},
- codeBlock: {
- margin: "14px 0 0",
- padding: 16,
- overflowX: "auto",
- borderRadius: 8,
- background: "#141925",
- color: "#eef3ff",
+ tdStrong: {
+ padding: "14px",
+ color: "#172033",
+ fontSize: 14,
+ fontWeight: 800,
+ verticalAlign: "top",
+ whiteSpace: "nowrap",
+ },
+ issueLink: {
+ color: "#155e75",
+ fontWeight: 800,
+ textDecoration: "none",
+ },
+ excerpt: {
+ margin: "6px 0 0",
+ color: "#64748b",
fontSize: 13,
- lineHeight: 1.6,
+ lineHeight: 1.45,
+ },
+ created: {
+ display: "block",
+ marginTop: 5,
+ color: "#718096",
+ fontSize: 12,
},
- inlineCode: {
- padding: "2px 5px",
- borderRadius: 5,
- background: "#eef1f6",
- color: "#263247",
- fontSize: "0.92em",
+ emptyState: {
+ marginTop: 16,
+ padding: 24,
+ border: "1px dashed #cbd3df",
+ borderRadius: 8,
+ color: "#526176",
+ textAlign: "center",
},
};