diff --git a/package-lock.json b/package-lock.json
index cd9f570..fe6664d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -12,7 +12,7 @@
"@octokit/rest": "^21.1.1",
"@octokit/webhooks": "^13.9.0",
"@prisma/client": "^6.17.1",
- "@pvium/sdk": "file:../sdks/node",
+ "@pvium/sdk": "0.2.3",
"next": "^15.5.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
@@ -28,20 +28,11 @@
"typescript": "^5.8.3"
}
},
- "../sdks/node": {
- "name": "@pvium/sdk",
- "version": "0.2.2",
- "license": "MIT",
- "dependencies": {
- "crypto-js": "^4.2.0",
- "ethers": "^6.16.0",
- "keccak256": "^1.0.6",
- "merkletreejs": "^0.6.0"
- },
- "devDependencies": {
- "@types/crypto-js": "^4.2.2",
- "typescript": "^5.8.2"
- }
+ "node_modules/@adraffy/ens-normalize": {
+ "version": "1.10.1",
+ "resolved": "https://registry.npmjs.org/@adraffy/ens-normalize/-/ens-normalize-1.10.1.tgz",
+ "integrity": "sha512-96Z2IP3mYmF1Xg2cDm8f1gWGf/HUVedQ3FMifV4kG/PQ4yEP51xDtRAEfhVNt5f/uzpNkZHwWQuUcu6D6K+Ekw==",
+ "license": "MIT"
},
"node_modules/@emnapi/core": {
"version": "1.10.0",
@@ -969,6 +960,30 @@
"node": ">= 10"
}
},
+ "node_modules/@noble/curves": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz",
+ "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==",
+ "license": "MIT",
+ "dependencies": {
+ "@noble/hashes": "1.3.2"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
+ "node_modules/@noble/hashes": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz",
+ "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://paulmillr.com/funding/"
+ }
+ },
"node_modules/@nodelib/fs.scandir": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1471,8 +1486,16 @@
}
},
"node_modules/@pvium/sdk": {
- "resolved": "../sdks/node",
- "link": true
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/@pvium/sdk/-/sdk-0.2.3.tgz",
+ "integrity": "sha512-nsnUAma89tuhHZAg8eOLt4Qojeo+F6tTvQhwYOHauFzW2WUGfm+DttPXJKBWCPf+awWPmZzX3Vikfc5wo3B31A==",
+ "license": "MIT",
+ "dependencies": {
+ "crypto-js": "^4.2.0",
+ "ethers": "^6.16.0",
+ "keccak256": "^1.0.6",
+ "merkletreejs": "^0.6.0"
+ }
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
@@ -2176,6 +2199,12 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
+ "node_modules/aes-js": {
+ "version": "4.0.0-beta.5",
+ "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-4.0.0-beta.5.tgz",
+ "integrity": "sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==",
+ "license": "MIT"
+ },
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -2446,12 +2475,38 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/before-after-hook": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz",
"integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==",
"license": "Apache-2.0"
},
+ "node_modules/bn.js": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.3.tgz",
+ "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==",
+ "license": "MIT"
+ },
"node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
@@ -2476,6 +2531,36 @@
"node": ">=8"
}
},
+ "node_modules/buffer": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
+ "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.2.1"
+ }
+ },
+ "node_modules/buffer-reverse": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-reverse/-/buffer-reverse-1.0.1.tgz",
+ "integrity": "sha512-M87YIUBsZ6N924W57vDwT/aOu8hw7ZgdByz6ijksLjmHJELBASmYTTlNHRgjE+pTsT9oJXGaDSgqqwfdHotDUg==",
+ "license": "MIT"
+ },
"node_modules/c12": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/c12/-/c12-3.1.0.tgz",
@@ -2693,6 +2778,12 @@
"node": ">= 8"
}
},
+ "node_modules/crypto-js": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
+ "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
+ "license": "MIT"
+ },
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
@@ -3522,6 +3613,49 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ethers": {
+ "version": "6.16.0",
+ "resolved": "https://registry.npmjs.org/ethers/-/ethers-6.16.0.tgz",
+ "integrity": "sha512-U1wulmetNymijEhpSEQ7Ct/P/Jw9/e7R1j5XIbPRydgV2DjLVMsULDlNksq3RQnFgKoLlZf88ijYtWEXcPa07A==",
+ "funding": [
+ {
+ "type": "individual",
+ "url": "https://github.com/sponsors/ethers-io/"
+ },
+ {
+ "type": "individual",
+ "url": "https://www.buymeacoffee.com/ricmoo"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@adraffy/ens-normalize": "1.10.1",
+ "@noble/curves": "1.2.0",
+ "@noble/hashes": "1.3.2",
+ "@types/node": "22.7.5",
+ "aes-js": "4.0.0-beta.5",
+ "tslib": "2.7.0",
+ "ws": "8.17.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/ethers/node_modules/@types/node": {
+ "version": "22.7.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.7.5.tgz",
+ "integrity": "sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==",
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.19.2"
+ }
+ },
+ "node_modules/ethers/node_modules/undici-types": {
+ "version": "6.19.8",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz",
+ "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==",
+ "license": "MIT"
+ },
"node_modules/exsolve": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz",
@@ -3998,6 +4132,26 @@
"node": ">= 0.4"
}
},
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4035,6 +4189,12 @@
"node": ">=0.8.19"
}
},
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
"node_modules/internal-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz",
@@ -4564,6 +4724,32 @@
"node": ">=4.0"
}
},
+ "node_modules/keccak": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/keccak/-/keccak-3.0.4.tgz",
+ "integrity": "sha512-3vKuW0jV8J3XNTzvfyicFR5qvxrSAGl7KIhvgOu5cmWwM7tZRj3fMbj/pfIf4be7aznbc+prBWGjywox/g2Y6Q==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "node-addon-api": "^2.0.0",
+ "node-gyp-build": "^4.2.0",
+ "readable-stream": "^3.6.0"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/keccak256": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/keccak256/-/keccak256-1.0.6.tgz",
+ "integrity": "sha512-8GLiM01PkdJVGUhR1e6M/AvWnSqYS0HaERI+K/QtStGDGlSTx2B1zTqZk4Zlqu5TxHJNTxWAdP9Y+WI50OApUw==",
+ "license": "MIT",
+ "dependencies": {
+ "bn.js": "^5.2.0",
+ "buffer": "^6.0.3",
+ "keccak": "^3.0.2"
+ }
+ },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -4664,6 +4850,20 @@
"node": ">= 8"
}
},
+ "node_modules/merkletreejs": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/merkletreejs/-/merkletreejs-0.6.0.tgz",
+ "integrity": "sha512-cyiratjG7fyHsa4DVfYVPxcoAh3zmUuOPItIfZex8f0pUVptNEmiiTOoeS0JnDDTWy+n3FKnI0K1gCzti7rGMg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-reverse": "^1.0.1",
+ "crypto-js": "^4.2.0",
+ "treeify": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 7.6.0"
+ }
+ },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -4801,6 +5001,12 @@
}
}
},
+ "node_modules/node-addon-api": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-2.0.2.tgz",
+ "integrity": "sha512-Ntyt4AIXyaLIuMHF6IOoTakB3K+RWxwtsHNRxllEoA6vPwP9o4866g6YWDLUdnucilZhmkxiHwHr11gAENw+QA==",
+ "license": "MIT"
+ },
"node_modules/node-exports-info": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz",
@@ -4837,6 +5043,17 @@
"devOptional": true,
"license": "MIT"
},
+ "node_modules/node-gyp-build": {
+ "version": "4.8.4",
+ "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
+ "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
+ "license": "MIT",
+ "bin": {
+ "node-gyp-build": "bin.js",
+ "node-gyp-build-optional": "optional.js",
+ "node-gyp-build-test": "build-test.js"
+ }
+ },
"node_modules/nypm": {
"version": "0.6.6",
"resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.6.tgz",
@@ -5318,6 +5535,20 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -5475,6 +5706,26 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
"node_modules/safe-push-apply": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -5752,6 +6003,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
"node_modules/string.prototype.includes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz",
@@ -6017,6 +6277,15 @@
"node": ">=12"
}
},
+ "node_modules/treeify": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/treeify/-/treeify-1.1.0.tgz",
+ "integrity": "sha512-1m4RA7xVAJrSGrrXGs0L3YTwyvBs2S8PbRHaLZAkFw7JR8oIFwYtysxlBZhYIa7xSyiYJKZ3iGrrk55cGA3i9A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -6047,8 +6316,7 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
"integrity": "sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==",
- "license": "0BSD",
- "optional": true
+ "license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
@@ -6238,6 +6506,12 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -6353,6 +6627,27 @@
"node": ">=0.10.0"
}
},
+ "node_modules/ws": {
+ "version": "8.17.1",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
+ "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": ">=5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
diff --git a/prisma/migrations/20260525083633_issue_discovery_metadata/migration.sql b/prisma/migrations/20260525083633_issue_discovery_metadata/migration.sql
new file mode 100644
index 0000000..a867150
--- /dev/null
+++ b/prisma/migrations/20260525083633_issue_discovery_metadata/migration.sql
@@ -0,0 +1,6 @@
+ALTER TABLE "Bounty" ADD COLUMN "issueTitle" TEXT;
+ALTER TABLE "Bounty" ADD COLUMN "issueUrl" TEXT;
+ALTER TABLE "Bounty" ADD COLUMN "issueState" TEXT;
+ALTER TABLE "Bounty" ADD COLUMN "issueExcerpt" TEXT;
+ALTER TABLE "Bounty" ADD COLUMN "issueCreatedAt" TIMESTAMP(3);
+ALTER TABLE "Bounty" 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..40b221c 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -1,466 +1,537 @@
-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 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"`;
-
-const githubPermissions = [
- "Issues: read and write",
- "Pull requests: read and write",
- "Metadata: read-only",
-];
+type PageProps = {
+ searchParams?: Promise;
+};
-const githubEvents = ["issues", "pull_request"];
+const statuses = {
+ OPEN: "Open",
+ RESERVED: "Reserved",
+ INVOICE_CREATED: "Invoice created",
+ PAID: "Paid",
+ CANCELLED: "Cancelled",
+};
-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.",
-];
+export default async function Home({ searchParams }: PageProps) {
+ const params = (await searchParams) ?? {};
+ const mode = getParam(params, "view") === "top" ? "top" : "recent";
+ const order = getParam(params, "order") === "asc" ? "asc" : "desc";
+ const minimumBounty = parseMinimumBounty(getParam(params, "min"));
-const pviumEvents = [
- "oauth.invite.accepted",
- "invoice.paid",
- "invoice.payment_completed",
- "invoice.payment.succeeded",
- "payment.attached",
- "batch.funded",
- "batch.payment_completed",
- "batch.payment.succeeded",
-];
+ const bounties = await prisma.bounty.findMany({
+ where: {
+ status: {
+ not: "CANCELLED",
+ },
+ ...(minimumBounty > 0 ? { amount: { gte: minimumBounty } } : {}),
+ },
+ include: {
+ repository: true,
+ },
+ orderBy:
+ mode === "top"
+ ? [{ amount: order }, { updatedAt: "desc" }]
+ : [{ updatedAt: order }, { createdAt: order }],
+ take: 100,
+ });
-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.",
-];
+ const totalValue = bounties.reduce(
+ (sum, bounty) => sum + Number(bounty.amount),
+ 0,
+ );
-export default function Home() {
return (
-
-
-
Pvium GitHub App
-
- Reward GitHub contributors with Pvium payment links.
-
+
+
Pvium bounty discovery
+
Browse payable GitHub issues in one place.
- 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.
+ Find registered Pvium bounties across connected repositories, switch
+ between recent activity and highest payouts, and jump straight to the
+ source issue on GitHub.
-
-
-
-
+
+
+
+
+ 0 ? `${minimumBounty} USDC` : "Any"}
+ />
-
-
-
-
- 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
-
- .
-
-
-
-
+
+
+
+ Recent Issues
+
+
+ Top Issues
+
-
-
-
- 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.
-
-
+
+
-
+
+ {bounties.length ? (
+ bounties.map((bounty) => {
+ const repoName = `${bounty.repository.owner}/${bounty.repository.repo}`;
+ const issueUrl =
+ bounty.issueUrl ??
+ `https://github.com/${repoName}/issues/${bounty.issueNumber}`;
+ const issueTitle =
+ bounty.issueTitle ?? `GitHub issue #${bounty.issueNumber}`;
+ const issueExcerpt =
+ bounty.issueExcerpt ??
+ `Registered from label ${bounty.labelName}. Open the source issue on GitHub for full details.`;
+ const statusLabel =
+ statuses[bounty.status as keyof typeof statuses] ?? bounty.status;
+ const issueState = bounty.issueState
+ ? `GitHub ${bounty.issueState}`
+ : "GitHub issue";
+ const createdAt = bounty.issueCreatedAt ?? bounty.createdAt;
+ const updatedAt = bounty.issueUpdatedAt ?? bounty.updatedAt;
-
-
-
- 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 (
+
+
+
+
{repoName}
+
{issueTitle}
+
+
+ {formatAmount(Number(bounty.amount))} {bounty.currency}
+
+
+ {issueExcerpt}
+
+ {statusLabel}
+ {issueState}
+ Created {formatDate(createdAt)}
+ Updated {formatDate(updatedAt)}
+
+
+ );
+ })
+ ) : (
+
+
No matching bounties yet
+
+ Install the GitHub App on repositories and label issues with
+ pvium bounty labels to populate this discovery feed.
+
+
+ View deployment guide
+
+
+ )}
+
);
}
-function Section({ title, children }: { title: string; children: ReactNode }) {
+function Stat({ label, value }: { label: string; value: string }) {
return (
-
+
+ {value}
+ {label}
+
);
}
-function Endpoint({ label, value }: { label: string; value: string }) {
+function ViewLink({
+ active,
+ href,
+ children,
+}: {
+ active: boolean;
+ href: string;
+ children: string;
+}) {
return (
-
- {label}
- {value}
-
+
+ {children}
+
);
}
-function ListBlock({ title, items }: { title: string; items: string[] }) {
- return (
-
-
{title}
-
-
- );
+function getParam(params: SearchParams, key: string) {
+ const value = params[key];
+ return Array.isArray(value) ? value[0] : value;
}
-function BulletList({ items }: { items: string[] }) {
- return (
-
- {items.map((item) => (
- -
- {item}
-
- ))}
-
- );
+function parseMinimumBounty(value: string | undefined) {
+ if (!value) return 0;
+ const parsed = Number(value);
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
}
-function NumberedList({ items }: { items: string[] }) {
- return (
-
- {items.map((item) => (
- -
- {item}
-
- ))}
-
- );
+function buildHref(params: {
+ view: "recent" | "top";
+ order: "asc" | "desc";
+ min: number;
+}) {
+ const query = new URLSearchParams({
+ view: params.view,
+ order: params.order,
+ });
+
+ if (params.min > 0) {
+ query.set("min", String(params.min));
+ }
+
+ return `/?${query.toString()}`;
+}
+
+function formatAmount(value: number) {
+ return new Intl.NumberFormat("en-US", {
+ maximumFractionDigits: 6,
+ }).format(value);
}
-function CodeBlock({ value }: { value: string }) {
- return
{value};
+function formatDate(value: Date) {
+ return new Intl.DateTimeFormat("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ }).format(value);
}
const styles: Record
= {
page: {
minHeight: "100vh",
margin: 0,
- padding: "48px 20px",
- background: "#f7f8fb",
+ background: "#f7f9fb",
color: "#172033",
fontFamily:
- 'Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
+ '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
},
hero: {
- maxWidth: 980,
- margin: "0 auto 24px",
- padding: "32px 0 8px",
+ padding: "32px clamp(20px, 5vw, 72px) 40px",
+ background: "#ffffff",
+ borderBottom: "1px solid #dfe7ef",
},
- brandRow: {
+ topBar: {
display: "flex",
- alignItems: "center",
justifyContent: "space-between",
- gap: 12,
- marginBottom: 18,
- },
- topLinks: {
- display: "flex",
alignItems: "center",
- flexWrap: "wrap",
- gap: 10,
+ gap: 16,
+ marginBottom: 48,
},
logo: {
- width: 96,
- height: 96,
- borderRadius: 8,
+ width: 44,
+ height: 44,
objectFit: "contain",
},
- logoLink: {
- display: "inline-flex",
- lineHeight: 0,
- },
- poweredBy: {
- display: "inline-flex",
+ navLinks: {
+ display: "flex",
alignItems: "center",
- padding: "9px 13px",
- border: "1px solid #c8d0df",
- borderRadius: 999,
- background: "#ffffff",
- color: "#172033",
+ gap: 12,
+ flexWrap: "wrap",
+ },
+ navLink: {
+ color: "#40506a",
+ textDecoration: "none",
fontSize: 14,
fontWeight: 600,
- textDecoration: "none",
},
- installLink: {
+ navButton: {
display: "inline-flex",
alignItems: "center",
- padding: "10px 14px",
- borderRadius: 8,
+ minHeight: 38,
+ padding: "0 14px",
+ borderRadius: 6,
background: "#172033",
color: "#ffffff",
+ textDecoration: "none",
fontSize: 14,
fontWeight: 700,
- textDecoration: "none",
},
eyebrow: {
- margin: "0 0 12px",
- color: "#52627a",
- fontSize: 14,
- fontWeight: 700,
+ margin: "0 0 10px",
+ color: "#0e7a5f",
+ fontSize: 13,
+ fontWeight: 800,
textTransform: "uppercase",
},
title: {
- maxWidth: 820,
- margin: "0 0 18px",
- fontSize: 48,
- lineHeight: 1.08,
+ maxWidth: 760,
+ margin: 0,
+ fontSize: "clamp(36px, 7vw, 68px)",
+ lineHeight: 1,
letterSpacing: 0,
},
lede: {
- maxWidth: 760,
- margin: "0 0 24px",
- color: "#46556e",
+ maxWidth: 720,
+ margin: "20px 0 0",
+ color: "#4e5d73",
fontSize: 18,
lineHeight: 1.65,
},
- endpointGrid: {
+ statsGrid: {
display: "grid",
- gridTemplateColumns: "repeat(auto-fit, minmax(230px, 1fr))",
- gap: 12,
- maxWidth: 920,
+ gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))",
+ gap: 14,
+ marginTop: 32,
+ maxWidth: 760,
},
- endpoint: {
- border: "1px solid #d9deea",
- borderRadius: 8,
- background: "#ffffff",
+ stat: {
+ display: "grid",
+ gap: 4,
padding: 16,
+ border: "1px solid #dfe7ef",
+ borderRadius: 8,
+ background: "#f9fbfd",
+ },
+ statValue: {
+ fontSize: 22,
+ fontWeight: 800,
},
- endpointLabel: {
- display: "block",
- marginBottom: 8,
- color: "#66748a",
+ statLabel: {
+ color: "#64748b",
fontSize: 13,
+ },
+ controlsPanel: {
+ display: "flex",
+ alignItems: "end",
+ justifyContent: "space-between",
+ gap: 20,
+ padding: "22px clamp(20px, 5vw, 72px)",
+ borderBottom: "1px solid #dfe7ef",
+ background: "#eef4f8",
+ flexWrap: "wrap",
+ },
+ tabRow: {
+ display: "flex",
+ gap: 8,
+ },
+ tab: {
+ display: "inline-flex",
+ alignItems: "center",
+ minHeight: 38,
+ padding: "0 14px",
+ border: "1px solid #ccd8e4",
+ borderRadius: 6,
+ color: "#40506a",
+ background: "#ffffff",
+ textDecoration: "none",
+ fontSize: 14,
fontWeight: 700,
},
- endpointCode: {
+ activeTab: {
+ display: "inline-flex",
+ alignItems: "center",
+ minHeight: 38,
+ padding: "0 14px",
+ border: "1px solid #172033",
+ borderRadius: 6,
+ color: "#ffffff",
+ background: "#172033",
+ textDecoration: "none",
+ fontSize: 14,
+ fontWeight: 700,
+ },
+ filterForm: {
+ display: "flex",
+ alignItems: "end",
+ gap: 12,
+ flexWrap: "wrap",
+ },
+ fieldLabel: {
+ display: "grid",
+ gap: 6,
+ color: "#526178",
+ fontSize: 12,
+ fontWeight: 800,
+ textTransform: "uppercase",
+ },
+ input: {
+ width: 160,
+ height: 38,
+ padding: "0 10px",
+ border: "1px solid #ccd8e4",
+ borderRadius: 6,
+ background: "#ffffff",
color: "#172033",
fontSize: 14,
- wordBreak: "break-word",
},
- section: {
- maxWidth: 980,
- margin: "18px auto",
- padding: 24,
- border: "1px solid #d9deea",
- borderRadius: 8,
+ select: {
+ width: 150,
+ height: 40,
+ padding: "0 10px",
+ border: "1px solid #ccd8e4",
+ borderRadius: 6,
background: "#ffffff",
+ color: "#172033",
+ fontSize: 14,
},
- sectionTitle: {
- margin: "0 0 16px",
- fontSize: 24,
- letterSpacing: 0,
+ applyButton: {
+ height: 40,
+ padding: "0 16px",
+ border: 0,
+ borderRadius: 6,
+ background: "#0e7a5f",
+ color: "#ffffff",
+ fontSize: 14,
+ fontWeight: 800,
+ cursor: "pointer",
},
- paragraph: {
- margin: "0 0 14px",
- color: "#46556e",
- fontSize: 15,
- lineHeight: 1.7,
+ issueList: {
+ display: "grid",
+ gap: 14,
+ padding: "28px clamp(20px, 5vw, 72px) 56px",
},
- columns: {
+ issueCard: {
display: "grid",
- gridTemplateColumns: "repeat(auto-fit, minmax(250px, 1fr))",
- gap: 18,
+ gap: 12,
+ padding: 20,
+ border: "1px solid #dfe7ef",
+ borderRadius: 8,
+ background: "#ffffff",
+ color: "inherit",
+ textDecoration: "none",
},
- listBlock: {
- minWidth: 0,
+ cardHeader: {
+ display: "flex",
+ justifyContent: "space-between",
+ gap: 16,
+ alignItems: "flex-start",
},
- listTitle: {
- margin: "0 0 10px",
- color: "#263247",
- fontSize: 16,
+ repoName: {
+ margin: "0 0 6px",
+ color: "#0e7a5f",
+ fontSize: 13,
+ fontWeight: 800,
+ },
+ issueTitle: {
+ margin: 0,
+ fontSize: 22,
+ lineHeight: 1.2,
+ letterSpacing: 0,
+ },
+ amount: {
+ flexShrink: 0,
+ display: "inline-flex",
+ alignItems: "center",
+ minHeight: 34,
+ padding: "0 12px",
+ borderRadius: 6,
+ background: "#e6f6ef",
+ color: "#0e7a5f",
+ fontSize: 14,
+ fontWeight: 900,
},
- list: {
+ description: {
margin: 0,
- paddingLeft: 22,
- color: "#46556e",
+ color: "#526178",
fontSize: 15,
- lineHeight: 1.7,
+ lineHeight: 1.55,
},
- listItem: {
- marginBottom: 8,
+ metaRow: {
+ display: "flex",
+ gap: 10,
+ alignItems: "center",
+ flexWrap: "wrap",
+ color: "#697891",
+ fontSize: 13,
},
- codeBlock: {
- margin: "14px 0 0",
- padding: 16,
- overflowX: "auto",
+ statusPill: {
+ display: "inline-flex",
+ alignItems: "center",
+ minHeight: 24,
+ padding: "0 8px",
+ borderRadius: 6,
+ background: "#edf2f7",
+ color: "#26364d",
+ fontWeight: 800,
+ },
+ emptyState: {
+ display: "grid",
+ gap: 12,
+ maxWidth: 560,
+ padding: 28,
+ border: "1px solid #dfe7ef",
borderRadius: 8,
- background: "#141925",
- color: "#eef3ff",
- fontSize: 13,
+ background: "#ffffff",
+ },
+ emptyTitle: {
+ margin: 0,
+ fontSize: 24,
+ },
+ emptyText: {
+ margin: 0,
+ color: "#526178",
lineHeight: 1.6,
},
- inlineCode: {
- padding: "2px 5px",
- borderRadius: 5,
- background: "#eef1f6",
- color: "#263247",
- fontSize: "0.92em",
+ emptyLink: {
+ color: "#0e7a5f",
+ fontWeight: 800,
+ textDecoration: "none",
},
};
diff --git a/src/lib/github/webhook-handler.ts b/src/lib/github/webhook-handler.ts
index 46a7c5e..615246c 100644
--- a/src/lib/github/webhook-handler.ts
+++ b/src/lib/github/webhook-handler.ts
@@ -110,6 +110,7 @@ async function handleIssueLabeled(payload: GithubWebhookPayload) {
}
const repository = await upsertRepository(payload);
+ const issueMetadata = buildIssueMetadata(payload.issue);
const bounty = await prisma.bounty.upsert({
where: {
repositoryId_issueNumber_labelName: {
@@ -122,11 +123,12 @@ async function handleIssueLabeled(payload: GithubWebhookPayload) {
amount: parsed.amount,
currency: parsed.currency,
status: "OPEN",
+ ...issueMetadata,
},
create: {
repositoryId: repository.id,
issueNumber: payload.issue.number,
- issueNodeId: payload.issue.node_id,
+ ...issueMetadata,
labelName: parsed.raw,
amount: parsed.amount,
currency: parsed.currency,
@@ -156,6 +158,25 @@ async function handleIssueLabeled(payload: GithubWebhookPayload) {
return { bountyId: bounty.id };
}
+function buildIssueMetadata(issue: GithubWebhookPayload["issue"]) {
+ return {
+ issueNodeId: issue?.node_id ?? null,
+ issueTitle: issue?.title ?? null,
+ issueUrl: issue?.html_url ?? null,
+ issueState: issue?.state ?? null,
+ issueExcerpt: buildIssueExcerpt(issue?.body),
+ issueCreatedAt: issue?.created_at ? new Date(issue.created_at) : null,
+ issueUpdatedAt: issue?.updated_at ? new Date(issue.updated_at) : null,
+ };
+}
+
+function buildIssueExcerpt(body: unknown) {
+ if (typeof body !== "string") return null;
+
+ const excerpt = body.replace(/\s+/g, " ").trim();
+ return excerpt ? excerpt.slice(0, 280) : null;
+}
+
async function handlePullRequestClosed(payload: GithubWebhookPayload) {
const env = getEnv();
const pullRequest = payload.pull_request;