diff --git a/prisma/migrations/20260605203500_add_issue_discovery_fields/migration.sql b/prisma/migrations/20260605203500_add_issue_discovery_fields/migration.sql
new file mode 100644
index 0000000..4f11fef
--- /dev/null
+++ b/prisma/migrations/20260605203500_add_issue_discovery_fields/migration.sql
@@ -0,0 +1,7 @@
+ALTER TABLE "Bounty"
+ADD COLUMN "issueTitle" TEXT,
+ADD COLUMN "issueUrl" TEXT,
+ADD COLUMN "issueState" TEXT,
+ADD COLUMN "issueExcerpt" TEXT,
+ADD COLUMN "issueCreatedAt" TIMESTAMP(3),
+ADD COLUMN "issueUpdatedAt" TIMESTAMP(3);
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index df1171d..8750800 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -58,6 +58,12 @@ model Bounty {
repositoryId String
issueNumber Int
issueNodeId String?
+ issueTitle String?
+ issueUrl String?
+ issueState String?
+ issueExcerpt String? @db.Text
+ issueCreatedAt DateTime?
+ issueUpdatedAt DateTime?
labelName String
amount Decimal @db.Decimal(18, 6)
currency String
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..11dbe20 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,351 +1,159 @@
-import type { CSSProperties, ReactNode } from "react";
+import type { CSSProperties } 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.",
-];
+import {
+ listIssueDiscoveryItems,
+ normalizeIssueDiscoveryQuery,
+} from "@/lib/issues/discovery";
-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",
-];
+interface HomeProps {
+ searchParams?: Promise>;
+}
-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 async function Home({ searchParams }: HomeProps) {
+ const params = (await searchParams) ?? {};
+ const normalized = normalizeIssueDiscoveryQuery(params);
+ const issues = await listIssueDiscoveryItems(params);
-export default function Home() {
return (
-
-
+
-
Pvium GitHub App
-
- Reward GitHub contributors with Pvium payment links.
-
+
Pvium issue discovery
+
Find funded GitHub work across projects.
- 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.
+ Browse connected repository issues by recency or bounty amount. Each
+ row links directly to the GitHub issue so contributors can inspect the
+ scope and maintainers can keep the deployment workflow under /deploy.
-
-
-
-
-
-
-
-
-
- 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
-
- .
-
-
-
-
+
+ {issues.length} {issues.length === 1 ? "issue" : "issues"} found
-
-
-
-
- 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};
+function formatDate(value: Date) {
+ return new Intl.DateTimeFormat("en", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ }).format(value);
}
const styles: Record
= {
page: {
minHeight: "100vh",
margin: 0,
- padding: "48px 20px",
+ padding: "40px 20px",
background: "#f7f8fb",
color: "#172033",
fontFamily:
'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
},
hero: {
- maxWidth: 980,
+ maxWidth: 1080,
margin: "0 auto 24px",
- padding: "32px 0 8px",
},
- brandRow: {
+ nav: {
display: "flex",
- alignItems: "center",
- justifyContent: "space-between",
+ justifyContent: "flex-end",
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,
+ marginBottom: 34,
},
- poweredBy: {
+ deployLink: {
display: "inline-flex",
alignItems: "center",
- padding: "9px 13px",
+ padding: "10px 14px",
border: "1px solid #c8d0df",
- borderRadius: 999,
+ borderRadius: 8,
background: "#ffffff",
color: "#172033",
fontSize: 14,
- fontWeight: 600,
+ fontWeight: 700,
textDecoration: "none",
},
installLink: {
@@ -360,107 +168,139 @@ const styles: Record = {
textDecoration: "none",
},
eyebrow: {
- margin: "0 0 12px",
+ margin: "0 0 10px",
color: "#52627a",
fontSize: 14,
fontWeight: 700,
textTransform: "uppercase",
},
title: {
- maxWidth: 820,
- margin: "0 0 18px",
+ maxWidth: 760,
+ margin: "0 0 16px",
fontSize: 48,
lineHeight: 1.08,
letterSpacing: 0,
},
lede: {
maxWidth: 760,
- margin: "0 0 24px",
+ margin: 0,
color: "#46556e",
fontSize: 18,
lineHeight: 1.65,
},
- endpointGrid: {
- display: "grid",
- gridTemplateColumns: "repeat(auto-fit, minmax(230px, 1fr))",
- gap: 12,
- maxWidth: 920,
- },
- endpoint: {
+ panel: {
+ maxWidth: 1080,
+ margin: "0 auto",
+ padding: 24,
border: "1px solid #d9deea",
borderRadius: 8,
background: "#ffffff",
- padding: 16,
},
- endpointLabel: {
- display: "block",
- marginBottom: 8,
- color: "#66748a",
+ controls: {
+ display: "grid",
+ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr)) auto",
+ alignItems: "end",
+ gap: 12,
+ },
+ control: {
+ display: "grid",
+ gap: 6,
+ },
+ label: {
+ color: "#52627a",
fontSize: 13,
fontWeight: 700,
},
- endpointCode: {
+ select: {
+ height: 42,
+ border: "1px solid #c8d0df",
+ borderRadius: 8,
+ background: "#ffffff",
color: "#172033",
+ padding: "0 10px",
fontSize: 14,
- wordBreak: "break-word",
},
- section: {
- maxWidth: 980,
- margin: "18px auto",
- padding: 24,
- border: "1px solid #d9deea",
+ input: {
+ height: 40,
+ border: "1px solid #c8d0df",
borderRadius: 8,
- background: "#ffffff",
+ color: "#172033",
+ padding: "0 10px",
+ fontSize: 14,
},
- sectionTitle: {
- margin: "0 0 16px",
- fontSize: 24,
- letterSpacing: 0,
+ button: {
+ height: 42,
+ border: 0,
+ borderRadius: 8,
+ background: "#172033",
+ color: "#ffffff",
+ padding: "0 18px",
+ fontSize: 14,
+ fontWeight: 700,
+ cursor: "pointer",
},
- paragraph: {
- margin: "0 0 14px",
- color: "#46556e",
- fontSize: 15,
- lineHeight: 1.7,
+ summary: {
+ margin: "20px 0 12px",
+ color: "#52627a",
+ fontSize: 14,
+ fontWeight: 700,
},
- columns: {
+ issueList: {
display: "grid",
- gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
- gap: 18,
+ gap: 12,
},
- listBlock: {
- minWidth: 0,
+ card: {
+ display: "block",
+ border: "1px solid #d9deea",
+ borderRadius: 8,
+ padding: 18,
+ color: "inherit",
+ textDecoration: "none",
},
- listTitle: {
- margin: "0 0 10px",
- color: "#263247",
- fontSize: 16,
+ cardHeader: {
+ display: "flex",
+ alignItems: "center",
+ justifyContent: "space-between",
+ gap: 12,
+ marginBottom: 10,
},
- list: {
- margin: 0,
- paddingLeft: 22,
- color: "#46556e",
- fontSize: 15,
- lineHeight: 1.7,
+ repo: {
+ color: "#52627a",
+ fontSize: 13,
+ fontWeight: 700,
},
- listItem: {
- marginBottom: 8,
+ amount: {
+ borderRadius: 999,
+ background: "#e8f6ef",
+ color: "#11633d",
+ padding: "5px 9px",
+ fontSize: 13,
+ fontWeight: 800,
},
- codeBlock: {
- margin: "14px 0 0",
- padding: 16,
- overflowX: "auto",
- borderRadius: 8,
- background: "#141925",
- color: "#eef3ff",
+ issueTitle: {
+ margin: "0 0 8px",
+ fontSize: 20,
+ lineHeight: 1.3,
+ letterSpacing: 0,
+ },
+ excerpt: {
+ margin: "0 0 12px",
+ color: "#46556e",
+ fontSize: 14,
+ lineHeight: 1.55,
+ },
+ meta: {
+ display: "flex",
+ flexWrap: "wrap",
+ gap: 10,
+ color: "#66748a",
fontSize: 13,
- lineHeight: 1.6,
},
- inlineCode: {
- padding: "2px 5px",
- borderRadius: 5,
- background: "#eef1f6",
- color: "#263247",
- fontSize: "0.92em",
+ empty: {
+ border: "1px dashed #c8d0df",
+ borderRadius: 8,
+ color: "#52627a",
+ padding: 24,
+ textAlign: "center",
},
};
diff --git a/src/lib/github/webhook-handler.ts b/src/lib/github/webhook-handler.ts
index 46a7c5e..21e1150 100644
--- a/src/lib/github/webhook-handler.ts
+++ b/src/lib/github/webhook-handler.ts
@@ -122,11 +122,13 @@ async function handleIssueLabeled(payload: GithubWebhookPayload) {
amount: parsed.amount,
currency: parsed.currency,
status: "OPEN",
+ ...getIssueDiscoveryFields(payload.issue),
},
create: {
repositoryId: repository.id,
issueNumber: payload.issue.number,
issueNodeId: payload.issue.node_id,
+ ...getIssueDiscoveryFields(payload.issue),
labelName: parsed.raw,
amount: parsed.amount,
currency: parsed.currency,
@@ -156,6 +158,28 @@ async function handleIssueLabeled(payload: GithubWebhookPayload) {
return { bountyId: bounty.id };
}
+function getIssueDiscoveryFields(issue: any) {
+ return {
+ issueTitle: issue?.title ?? null,
+ issueUrl: issue?.html_url ?? null,
+ issueState: issue?.state ?? null,
+ issueExcerpt: summarizeIssueBody(issue?.body),
+ issueCreatedAt: issue?.created_at ? new Date(issue.created_at) : null,
+ issueUpdatedAt: issue?.updated_at ? new Date(issue.updated_at) : null,
+ };
+}
+
+function summarizeIssueBody(body: unknown) {
+ if (typeof body !== "string") return null;
+ const summary = body
+ .replace(/```[\s\S]*?```/g, " ")
+ .replace(/<[^>]+>/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+
+ return summary ? summary.slice(0, 220) : null;
+}
+
async function handlePullRequestClosed(payload: GithubWebhookPayload) {
const env = getEnv();
const pullRequest = payload.pull_request;
diff --git a/src/lib/issues/discovery-query.ts b/src/lib/issues/discovery-query.ts
new file mode 100644
index 0000000..2029c8d
--- /dev/null
+++ b/src/lib/issues/discovery-query.ts
@@ -0,0 +1,41 @@
+export type IssueSort = "recent" | "top";
+export type SortOrder = "asc" | "desc";
+
+export interface IssueDiscoveryQuery {
+ sort?: string | string[];
+ order?: string | string[];
+ minBounty?: string | string[];
+}
+
+export function normalizeIssueDiscoveryQuery(query: IssueDiscoveryQuery) {
+ const sort = first(query.sort);
+ const order = first(query.order);
+ const minBountyValue = Number(first(query.minBounty) ?? 0);
+
+ return {
+ sort: sort === "top" ? ("top" as const) : ("recent" as const),
+ order: order === "asc" ? ("asc" as const) : ("desc" as const),
+ minBounty:
+ Number.isFinite(minBountyValue) && minBountyValue > 0
+ ? minBountyValue
+ : 0,
+ };
+}
+
+export function sortIssueDiscoveryItemsForTest(
+ items: Array<{ amount: string; updatedAt: Date }>,
+ sort: IssueSort,
+ order: SortOrder,
+) {
+ const direction = order === "asc" ? 1 : -1;
+ return [...items].sort((a, b) => {
+ if (sort === "top") {
+ return (Number(a.amount) - Number(b.amount)) * direction;
+ }
+ return (a.updatedAt.getTime() - b.updatedAt.getTime()) * direction;
+ });
+}
+
+function first(value: string | string[] | undefined) {
+ return Array.isArray(value) ? value[0] : value;
+}
diff --git a/src/lib/issues/discovery.ts b/src/lib/issues/discovery.ts
new file mode 100644
index 0000000..7c31fd4
--- /dev/null
+++ b/src/lib/issues/discovery.ts
@@ -0,0 +1,59 @@
+import type { BountyStatus } from "@prisma/client";
+
+import { prisma } from "@/lib/db/prisma";
+import {
+ normalizeIssueDiscoveryQuery,
+ type IssueDiscoveryQuery,
+} from "@/lib/issues/discovery-query";
+
+export { normalizeIssueDiscoveryQuery };
+
+export interface IssueDiscoveryItem {
+ id: string;
+ title: string;
+ repository: string;
+ amount: string;
+ currency: string;
+ status: BountyStatus;
+ issueState: string;
+ issueNumber: number;
+ issueUrl: string;
+ excerpt: string;
+ updatedAt: Date;
+ createdAt: Date;
+}
+
+export async function listIssueDiscoveryItems(query: IssueDiscoveryQuery = {}) {
+ const normalized = normalizeIssueDiscoveryQuery(query);
+ const orderBy =
+ normalized.sort === "top"
+ ? [{ amount: normalized.order }, { updatedAt: "desc" as const }]
+ : [{ issueUpdatedAt: normalized.order }, { updatedAt: normalized.order }];
+
+ const bounties = await prisma.bounty.findMany({
+ where: {
+ amount: normalized.minBounty ? { gte: normalized.minBounty } : undefined,
+ },
+ include: {
+ repository: true,
+ },
+ orderBy,
+ });
+
+ return bounties.map((bounty): IssueDiscoveryItem => ({
+ id: bounty.id,
+ title: bounty.issueTitle ?? `Issue #${bounty.issueNumber}`,
+ repository: `${bounty.repository.owner}/${bounty.repository.repo}`,
+ amount: bounty.amount.toString(),
+ currency: bounty.currency,
+ status: bounty.status,
+ issueState: bounty.issueState ?? "unknown",
+ issueNumber: bounty.issueNumber,
+ issueUrl:
+ bounty.issueUrl ??
+ `https://github.com/${bounty.repository.owner}/${bounty.repository.repo}/issues/${bounty.issueNumber}`,
+ excerpt: bounty.issueExcerpt ?? "",
+ updatedAt: bounty.issueUpdatedAt ?? bounty.updatedAt,
+ createdAt: bounty.issueCreatedAt ?? bounty.createdAt,
+ }));
+}
diff --git a/tests/discovery.test.ts b/tests/discovery.test.ts
new file mode 100644
index 0000000..abd3a5d
--- /dev/null
+++ b/tests/discovery.test.ts
@@ -0,0 +1,73 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+
+import {
+ normalizeIssueDiscoveryQuery,
+ sortIssueDiscoveryItemsForTest,
+} from "../src/lib/issues/discovery-query.ts";
+
+describe("normalizeIssueDiscoveryQuery", () => {
+ it("defaults to recent descending with no minimum bounty", () => {
+ assert.deepEqual(normalizeIssueDiscoveryQuery({}), {
+ sort: "recent",
+ order: "desc",
+ minBounty: 0,
+ });
+ });
+
+ it("accepts top sorting, ascending order, and minimum bounty", () => {
+ assert.deepEqual(
+ normalizeIssueDiscoveryQuery({
+ sort: "top",
+ order: "asc",
+ minBounty: "25",
+ }),
+ {
+ sort: "top",
+ order: "asc",
+ minBounty: 25,
+ },
+ );
+ });
+
+ it("sanitizes invalid query values", () => {
+ assert.deepEqual(
+ normalizeIssueDiscoveryQuery({
+ sort: "unknown",
+ order: "sideways",
+ minBounty: "-1",
+ }),
+ {
+ sort: "recent",
+ order: "desc",
+ minBounty: 0,
+ },
+ );
+ });
+});
+
+describe("sortIssueDiscoveryItemsForTest", () => {
+ const items = [
+ { amount: "25", updatedAt: new Date("2026-01-01T00:00:00Z") },
+ { amount: "5", updatedAt: new Date("2026-03-01T00:00:00Z") },
+ { amount: "100", updatedAt: new Date("2026-02-01T00:00:00Z") },
+ ];
+
+ it("sorts top issues by amount descending", () => {
+ assert.deepEqual(
+ sortIssueDiscoveryItemsForTest(items, "top", "desc").map(
+ (item) => item.amount,
+ ),
+ ["100", "25", "5"],
+ );
+ });
+
+ it("sorts recent issues by updated date descending", () => {
+ assert.deepEqual(
+ sortIssueDiscoveryItemsForTest(items, "recent", "desc").map(
+ (item) => item.amount,
+ ),
+ ["5", "100", "25"],
+ );
+ });
+});