refactor user flow and add new contribution paths

This commit is contained in:
Pavlenex 2026-03-16 19:51:54 +01:00
parent a8008ba8e1
commit d4e6505600
18 changed files with 941 additions and 539 deletions

View File

@ -3,21 +3,21 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>BTCPay Contribute — Find your first issue</title>
<meta name="description" content="Discover good first issues across all BTCPay Server projects. Filter by skill — developer, writer, designer, or marketer — and start contributing today." />
<title>BTCPay Contribute - Start contributing to Bitcoin</title>
<meta name="description" content="Find your path to contributing to BTCPay Server. Developer, tester, designer, or evangelist - pick your role and get started today." />
<!-- Open Graph -->
<meta property="og:title" content="BTCPay Contribute" />
<meta property="og:description" content="Find good first issues across all BTCPay Server projects, filtered by your skills." />
<meta property="og:description" content="Pick your role - developer, tester, designer, or evangelist - and start contributing to BTCPay Server today." />
<meta property="og:type" content="website" />
<meta property="og:url" content="https://contribute.btcpayserver.org" />
<!-- Twitter Card -->
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="BTCPay Contribute" />
<meta name="twitter:description" content="Find good first issues across all BTCPay Server projects." />
<meta name="twitter:description" content="Pick your role and start contributing to BTCPay Server - developer, tester, designer, or evangelist." />
<!-- CSP GitHub Pages ignores _headers, so enforce via meta tag -->
<!-- CSP - GitHub Pages ignores _headers, so enforce via meta tag -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https://avatars.githubusercontent.com https://img.youtube.com; connect-src 'self'; frame-src https://www.youtube.com" />
<!-- Favicon -->

18
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "btcpay-contribute",
"version": "0.0.0",
"dependencies": {
"@octokit/rest": "^22.0.1",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
@ -21,7 +22,6 @@
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@octokit/rest": "^22.0.1",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.10.1",
@ -1064,7 +1064,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz",
"integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
@ -1074,7 +1073,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz",
"integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/auth-token": "^6.0.0",
@ -1093,7 +1091,6 @@
"version": "11.0.3",
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz",
"integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/types": "^16.0.0",
@ -1107,7 +1104,6 @@
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz",
"integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/request": "^10.0.6",
@ -1122,14 +1118,12 @@
"version": "27.0.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz",
"integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==",
"dev": true,
"license": "MIT"
},
"node_modules/@octokit/plugin-paginate-rest": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz",
"integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/types": "^16.0.0"
@ -1145,7 +1139,6 @@
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz",
"integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 20"
@ -1158,7 +1151,6 @@
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz",
"integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/types": "^16.0.0"
@ -1174,7 +1166,6 @@
"version": "10.0.8",
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz",
"integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/endpoint": "^11.0.3",
@ -1192,7 +1183,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz",
"integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/types": "^16.0.0"
@ -1205,7 +1195,6 @@
"version": "22.0.1",
"resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz",
"integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/core": "^7.0.6",
@ -1221,7 +1210,6 @@
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz",
"integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@octokit/openapi-types": "^27.0.0"
@ -2974,7 +2962,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz",
"integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/brace-expansion": {
@ -3584,7 +3571,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz",
"integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==",
"dev": true,
"funding": [
{
"type": "github",
@ -4028,7 +4014,6 @@
"version": "3.5.7",
"resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.7.tgz",
"integrity": "sha512-7ei3MdAI5+fJPVnKlW77TKNKwQ5ppSzWvhPuSuINT/GYW9ZOC1eRKOuhV9yHG5aEsUPj9BBx5JIekkmoLHxZOw==",
"dev": true,
"license": "MIT"
},
"node_modules/json5": {
@ -5817,7 +5802,6 @@
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz",
"integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==",
"dev": true,
"license": "ISC"
},
"node_modules/update-browserslist-db": {

View File

@ -1,7 +1,7 @@
{
"lastUpdated": "2026-03-16T12:55:36.956Z",
"totalIssues": 14,
"repoCount": 5,
"lastUpdated": "2026-03-16T18:31:59.173Z",
"totalIssues": 15,
"repoCount": 6,
"repos": [
{
"id": 100711978,
@ -66,12 +66,57 @@
"language": "HTML",
"topics": [],
"stars": 23
},
{
"id": 1179862494,
"name": "btcpay-contribute",
"fullName": "btcpayserver/btcpay-contribute",
"description": "Website that guides new contributors to contribute to Bitcoin and BTCPay Server",
"url": "https://github.com/btcpayserver/btcpay-contribute",
"language": "TypeScript",
"topics": [],
"stars": 1
}
],
"issues": [
{
"id": 4084024455,
"number": 4,
"type": "issue",
"title": "User test the website",
"body": "",
"url": "https://github.com/btcpayserver/btcpay-contribute/issues/4",
"createdAt": "2026-03-16T18:13:10Z",
"updatedAt": "2026-03-16T18:13:15Z",
"commentsCount": 0,
"reactionCount": 0,
"labels": [
{
"name": "good first issue",
"color": "7057ff"
},
{
"name": "User testing",
"color": "82e5ea"
}
],
"repo": {
"name": "btcpay-contribute",
"fullName": "btcpayserver/btcpay-contribute",
"language": "TypeScript",
"url": "https://github.com/btcpayserver/btcpay-contribute"
},
"assignees": [],
"author": {
"login": "pavlenex",
"avatarUrl": "https://avatars.githubusercontent.com/u/36959754?v=4",
"url": "https://github.com/pavlenex"
}
},
{
"id": 4081311843,
"number": 7246,
"type": "issue",
"title": "[Bug]: store dashboard: wallet balance switch broken",
"body": "### What is your BTCPay version?\n\nBTCPay Server v2.3.6 (at least also in 2.3.5)\n\n### How did you deploy BTCPay Server?\n\ndocker\n\n### What happened?\n\nOn the store dashboard wallet balance we have a toggle between 1W 1M and 1Y. When you switch to 1W or 1Y it always shows 21. Jan, 21. Jan on the timeline. Also when you click back to 1W it does not update the screen to show March dates again but stays broken. Only reload fixes screen display.\n\nThis also happens on stores with more transactions than what I have in the screenshots below.\n\nImportant: the graphic updates just fine, only the x-axis date",
"url": "https://github.com/btcpayserver/btcpayserver/issues/7246",
@ -96,17 +141,12 @@
"login": "ndeet",
"avatarUrl": "https://avatars.githubusercontent.com/u/1136761?v=4",
"url": "https://github.com/ndeet"
},
"skills": [
"developer"
],
"tags": [
"C#"
]
}
},
{
"id": 4077245413,
"number": 545,
"type": "issue",
"title": "New entry submission - Way of Bitcoin",
"body": "New submission:\n\nName: Way of Bitcoin\nUrl: https://wayofbitcoin.com\nTwitter: @alanbwt\nType: merchants\nSubType: books\nDescription: A philosophical guide to Bitcoin as a discipline and a way of life. Published by Hyperborean Press. Pay on-chain or with lightning via BTCPay Server Shopify V2.",
"url": "https://github.com/btcpayserver/directory.btcpayserver.org/issues/545",
@ -135,17 +175,12 @@
"login": "alanbwt",
"avatarUrl": "https://avatars.githubusercontent.com/u/167029568?v=4",
"url": "https://github.com/alanbwt"
},
"skills": [
"developer"
],
"tags": [
"TypeScript"
]
}
},
{
"id": 4067414699,
"number": 166,
"type": "issue",
"title": "[Feature] Cleanup stale unconfirmed users",
"body": "## Description \n\nAdd a cleanup job similar to the existing stale plugin cleanup, but for users who registered and never confirmed their email.\n\nScope:\n- delete only users with `EmailConfirmed = false`\n- only delete users older than a defined threshold\n- never delete users with roles, plugin ownership, reviews, or other linked data\n- run on a schedule, like the current plugin cleanup\n- add tests for delete vs keep scenarios\n\nNote:\nAspNetUsers currently does not seem to store account creation date, so this likely needs a CreatedAt field first to define what is stale safely.",
"url": "https://github.com/btcpayserver/btcpayserver-plugin-builder/issues/166",
@ -170,17 +205,12 @@
"login": "thgO-O",
"avatarUrl": "https://avatars.githubusercontent.com/u/107907441?v=4",
"url": "https://github.com/thgO-O"
},
"skills": [
"developer"
],
"tags": [
"C#"
]
}
},
{
"id": 4054026700,
"number": 542,
"type": "issue",
"title": "New entry submission - MAGIC Grants",
"body": "New submission:\n\nName: MAGIC Grants\nUrl: https://donate.magicgrants.org\nTwitter: @MagicGrants\nType: non-profits\nDescription: MAGIC Grants provides scholarships for students interested in cryptocurrencies and privacy, supports public cryptocurrency infrastructure, and promotes privacy.",
"url": "https://github.com/btcpayserver/directory.btcpayserver.org/issues/542",
@ -209,23 +239,18 @@
"login": "SamsungGalaxyPlayer",
"avatarUrl": "https://avatars.githubusercontent.com/u/12520755?v=4",
"url": "https://github.com/SamsungGalaxyPlayer"
},
"skills": [
"developer"
],
"tags": [
"TypeScript"
]
}
},
{
"id": 4046354134,
"number": 163,
"type": "issue",
"title": "[Feature] Add health check endpoint to Plugin Builder",
"body": "Plugin Builder currently has no dedicated health check endpoint.\n\nSince startup depends on Postgres, Docker, and Azure Storage, it would be useful to expose a simple probe endpoint for deployments and monitoring.\n\nSuggested scope:\n- add `/health`\n- return `200 OK` when the app is ready to serve requests\n- return `503` when startup is incomplete or a critical dependency is unavailable\n- keep the check lightweight",
"url": "https://github.com/btcpayserver/btcpayserver-plugin-builder/issues/163",
"createdAt": "2026-03-09T15:44:59Z",
"updatedAt": "2026-03-12T22:50:51Z",
"commentsCount": 8,
"updatedAt": "2026-03-16T15:25:25Z",
"commentsCount": 9,
"reactionCount": 0,
"labels": [
{
@ -239,28 +264,17 @@
"language": "C#",
"url": "https://github.com/btcpayserver/btcpayserver-plugin-builder"
},
"assignees": [
{
"login": "Abdullah-Albayati",
"avatarUrl": "https://avatars.githubusercontent.com/u/93524185?v=4",
"url": "https://github.com/Abdullah-Albayati"
}
],
"assignees": [],
"author": {
"login": "thgO-O",
"avatarUrl": "https://avatars.githubusercontent.com/u/107907441?v=4",
"url": "https://github.com/thgO-O"
},
"skills": [
"developer"
],
"tags": [
"C#"
]
}
},
{
"id": 4018455059,
"number": 160,
"type": "issue",
"title": "Require Video URL filled out in order to request listing in the directory",
"body": "Now that @teamssUTXO has [added support for a video URL](https://github.com/btcpayserver/btcpayserver-plugin-builder/pull/150), we should require all newly listed plugins to include an explainer video.\n\nIt does not need to be high production quality - even a basic screen recording of the plugin in action would go a long way in demonstrating that it deserves to be listed.\n\nThe change should be straightforward, likely just an update to this page:\n\n<img width=\"2013\" height=\"642\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/43febf2a-b6a4-481d-b8fc-446021f0f40d\" />",
"url": "https://github.com/btcpayserver/btcpayserver-plugin-builder/issues/160",
@ -285,17 +299,12 @@
"login": "rockstardev",
"avatarUrl": "https://avatars.githubusercontent.com/u/5191402?v=4",
"url": "https://github.com/rockstardev"
},
"skills": [
"developer"
],
"tags": [
"C#"
]
}
},
{
"id": 3974150655,
"number": 158,
"type": "issue",
"title": "Port and unify error page system from BTCPayServer (404 and 500 for start)",
"body": "It would be great to have a default 404 error page instead of this one :\n\n<img width=\"2877\" height=\"1464\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/9a35e86f-74f4-458b-9a90-a8487c8055ec\" />",
"url": "https://github.com/btcpayserver/btcpayserver-plugin-builder/issues/158",
@ -320,17 +329,12 @@
"login": "teamssUTXO",
"avatarUrl": "https://avatars.githubusercontent.com/u/183613235?v=4",
"url": "https://github.com/teamssUTXO"
},
"skills": [
"developer"
],
"tags": [
"C#"
]
}
},
{
"id": 3820273373,
"number": 7109,
"type": "issue",
"title": "[Feature]: Improve label system to work when we have 10+ labels",
"body": "Now that we are integrating the label system into Payment Requests as well (https://github.com/btcpayserver/btcpayserver/pull/7050), we should improve label UX so it scales to 10, 100, or 1,000 labels.\n\nThe current wallet UI that dumps all labels into the filter dropdown will not scale:\n\nhttps://github.com/user-attachments/assets/a9de89de-b877-472e-a776-921a46da87c9\n\nFrom the video, it is clear the labels filter dropdown should never exceed page height. A good approach would be to reuse the dropdown we already use in transaction rows for applying labels:\n\n<img width=\"453\" height=\"275\" alt=\"Ima",
"url": "https://github.com/btcpayserver/btcpayserver/issues/7109",
@ -365,17 +369,12 @@
"login": "rockstardev",
"avatarUrl": "https://avatars.githubusercontent.com/u/5191402?v=4",
"url": "https://github.com/rockstardev"
},
"skills": [
"developer"
],
"tags": [
"C#"
]
}
},
{
"id": 3799539831,
"number": 7092,
"type": "issue",
"title": "Standardize Wallet View: Add Date Filter for Transactions",
"body": "**Context:**\nAs mentioned in [Issue #3954](https://github.com/btcpayserver/btcpayserver/issues/3954), there is a need to standardize the Wallet view so that it offers feature parity with other views in BTCPay Server.\n\n<img width=\"2297\" height=\"262\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/2e36b1fd-c0ec-42dc-b64f-48f0eabf8ed3\" />\n\n\n**Request:**\nEnhance the Wallet transaction view to allow users to filter transactions by date in the same way as the Payment Requests and Invoices views. This would help users more easily locate relevant transactions and improve consistency across",
"url": "https://github.com/btcpayserver/btcpayserver/issues/7092",
@ -404,17 +403,12 @@
"login": "pavlenex",
"avatarUrl": "https://avatars.githubusercontent.com/u/36959754?v=4",
"url": "https://github.com/pavlenex"
},
"skills": [
"developer"
],
"tags": [
"C#"
]
}
},
{
"id": 3799531574,
"number": 7091,
"type": "issue",
"title": "Add support for search, specifically for TX id inside the wallet view",
"body": "As mentioned in [issue #3954](https://github.com/btcpayserver/btcpayserver/issues/3954), the wallet transactions view should be standardized line invoice or payment requests views and provide a search functionality, allowing users to immediately find a transaction based on criteria such as the transaction ID.\n\n- Implement a search bar in the wallet transactions view so users can filter or rapidly locate transactions using their transaction ID.\n",
"url": "https://github.com/btcpayserver/btcpayserver/issues/7091",
@ -443,17 +437,12 @@
"login": "pavlenex",
"avatarUrl": "https://avatars.githubusercontent.com/u/36959754?v=4",
"url": "https://github.com/pavlenex"
},
"skills": [
"developer"
],
"tags": [
"C#"
]
}
},
{
"id": 3579096842,
"number": 1531,
"type": "issue",
"title": "Remove Wabisabi Plugin documentation",
"body": "This page refers to a plugin not available anymore: https://docs.btcpayserver.org/Wabisabi\n\nAre there any plans to bring the plugin back or should this page better be removed?",
"url": "https://github.com/btcpayserver/btcpayserver-doc/issues/1531",
@ -478,17 +467,12 @@
"login": "petzsch",
"avatarUrl": "https://avatars.githubusercontent.com/u/1374810?v=4",
"url": "https://github.com/petzsch"
},
"skills": [
"writer"
],
"tags": [
"docs"
]
}
},
{
"id": 3051345641,
"number": 202,
"type": "issue",
"title": "Poor `Pending` status contrast in Light mode > Withdraw > Pending status",
"body": "Good first issue for new devs wanting to contribute, pretty straightforward.\n\n<img width=\"1043\" alt=\"Image\" src=\"https://github.com/user-attachments/assets/eda85646-eef7-4ab6-a04d-fcb5c7c56906\" />\n",
"url": "https://github.com/btcpayserver/app/issues/202",
@ -517,17 +501,12 @@
"login": "pavlenex",
"avatarUrl": "https://avatars.githubusercontent.com/u/36959754?v=4",
"url": "https://github.com/pavlenex"
},
"skills": [
"developer"
],
"tags": [
"HTML"
]
}
},
{
"id": 2950017622,
"number": 169,
"type": "issue",
"title": "Startup Icon Appears Low Resolution and Pixelated",
"body": "The app's startup/splash screen icon appears in low resolution and pixelated when launching the app. \n\nIt may not be visible from the screenshot, but if you deploy on the device, I am sure you can clearly tell.\n\n![Image](https://github.com/user-attachments/assets/105abca3-28fe-4076-928a-cb1e121ab688)",
"url": "https://github.com/btcpayserver/app/issues/169",
@ -566,17 +545,12 @@
"login": "pavlenex",
"avatarUrl": "https://avatars.githubusercontent.com/u/36959754?v=4",
"url": "https://github.com/pavlenex"
},
"skills": [
"design"
],
"tags": [
"design"
]
}
},
{
"id": 389532105,
"number": 444,
"type": "issue",
"title": "RTL Language Support",
"body": "Right-to-Left languages have display issues in the checkout page. Arabic translation is available for anyone who wants to work on this issue. \r\n\r\nOpen RTL Issue to close outdated #305 \r\n",
"url": "https://github.com/btcpayserver/btcpayserver/issues/444",
@ -613,13 +587,141 @@
"login": "britttttk",
"avatarUrl": "https://avatars.githubusercontent.com/u/39231115?v=4",
"url": "https://github.com/britttttk"
},
"skills": [
"writer"
}
}
],
"tags": [
"docs"
]
"testerItems": [
{
"id": 4051110731,
"number": 7226,
"type": "pr",
"title": "show payment method on receipt",
"body": "Resolve #7174 \r\n\r\n<img width=\"697\" height=\"581\" alt=\"image\" src=\"https://github.com/user-attachments/assets/9718d2e2-af0d-486c-bc76-d3feeedfe54a\" />\r\n\r\n\r\n@dstrukt what do you think?\r\n",
"url": "https://github.com/btcpayserver/btcpayserver/pull/7226",
"createdAt": "2026-03-10T11:22:40Z",
"updatedAt": "2026-03-16T14:39:34Z",
"commentsCount": 3,
"reactionCount": 1,
"labels": [
{
"name": "User Testing",
"color": "F8ADDA"
}
],
"repo": {
"name": "btcpayserver",
"fullName": "btcpayserver/btcpayserver",
"language": "C#",
"url": "https://github.com/btcpayserver/btcpayserver"
},
"assignees": [
{
"login": "dstrukt",
"avatarUrl": "https://avatars.githubusercontent.com/u/6250771?v=4",
"url": "https://github.com/dstrukt"
}
],
"author": {
"login": "TChukwuleta",
"avatarUrl": "https://avatars.githubusercontent.com/u/47084273?v=4",
"url": "https://github.com/TChukwuleta"
}
},
{
"id": 3941153156,
"number": 7183,
"type": "pr",
"title": "Refactor navigation: topbar global actions and unified search",
"body": "This PR is a prototype for #6902 \r\n\r\nhttps://github.com/user-attachments/assets/43281d64-b667-448b-ade9-29a0648a8288\r\n\r\nIt adds:\r\n\r\n- Global search (local nav index + server-backexd results).\r\n- Global top-right actions (notifications/account/server operations moved from sidebar context).\r\n- Navigation restructuring to keep store-focused items in the sidebar and global actions in the top bar.\r\n- A dismissible migration hint in the sidebar to guide users to the new top-right location for server/account navigation.\r\n- Mobile navigation adjustments, including moving store selection into the hambu",
"url": "https://github.com/btcpayserver/btcpayserver/pull/7183",
"createdAt": "2026-02-14T11:15:48Z",
"updatedAt": "2026-03-16T14:39:24Z",
"commentsCount": 3,
"reactionCount": 0,
"labels": [
{
"name": "User Testing",
"color": "F8ADDA"
}
],
"repo": {
"name": "btcpayserver",
"fullName": "btcpayserver/btcpayserver",
"language": "C#",
"url": "https://github.com/btcpayserver/btcpayserver"
},
"assignees": [],
"author": {
"login": "pavlenex",
"avatarUrl": "https://avatars.githubusercontent.com/u/36959754?v=4",
"url": "https://github.com/pavlenex"
}
},
{
"id": 4084024455,
"number": 4,
"type": "issue",
"title": "User test the website",
"body": "",
"url": "https://github.com/btcpayserver/btcpay-contribute/issues/4",
"createdAt": "2026-03-16T18:13:10Z",
"updatedAt": "2026-03-16T18:13:15Z",
"commentsCount": 0,
"reactionCount": 0,
"labels": [
{
"name": "good first issue",
"color": "7057ff"
},
{
"name": "User testing",
"color": "82e5ea"
}
],
"repo": {
"name": "btcpay-contribute",
"fullName": "btcpayserver/btcpay-contribute",
"language": "TypeScript",
"url": "https://github.com/btcpayserver/btcpay-contribute"
},
"assignees": [],
"author": {
"login": "pavlenex",
"avatarUrl": "https://avatars.githubusercontent.com/u/36959754?v=4",
"url": "https://github.com/pavlenex"
}
}
],
"writerIssues": [
{
"id": 3579096842,
"number": 1531,
"type": "issue",
"title": "Remove Wabisabi Plugin documentation",
"body": "This page refers to a plugin not available anymore: https://docs.btcpayserver.org/Wabisabi\n\nAre there any plans to bring the plugin back or should this page better be removed?",
"url": "https://github.com/btcpayserver/btcpayserver-doc/issues/1531",
"createdAt": "2025-11-02T07:59:22Z",
"updatedAt": "2026-03-11T09:03:28Z",
"commentsCount": 1,
"reactionCount": 0,
"labels": [
{
"name": "good first issue",
"color": "19b542"
}
],
"repo": {
"name": "btcpayserver-doc",
"fullName": "btcpayserver/btcpayserver-doc",
"language": "Shell",
"url": "https://github.com/btcpayserver/btcpayserver-doc"
},
"assignees": [],
"author": {
"login": "petzsch",
"avatarUrl": "https://avatars.githubusercontent.com/u/1374810?v=4",
"url": "https://github.com/petzsch"
}
}
]
}

View File

@ -19,12 +19,13 @@ import { Octokit } from '@octokit/rest'
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
import { mapSkills } from './skill-mapper.js'
const __dirname = dirname(fileURLToPath(import.meta.url))
const ORG = 'btcpayserver'
const LABEL = 'good first issue'
const TESTER_LABEL = 'User Testing'
const WRITER_REPOS = ['btcpayserver-doc', 'btcpayserver-blog']
const COPYWRITING_LABEL = 'copywriting'
const OUT = resolve(__dirname, '../public/data/issues.json')
const BODY_MAX = 600
@ -70,14 +71,10 @@ async function main() {
// Skip pull requests (GitHub returns PRs in issue list)
if (raw.pull_request) continue
const { skills, tags } = mapSkills(
{ labels: raw.labels.map((l) => (typeof l === 'string' ? { name: l } : l)) },
{ name: repo.name, language: repo.language },
)
issues.push({
id: raw.id,
number: raw.number,
type: 'issue',
title: raw.title,
body: (raw.body ?? '').slice(0, BODY_MAX),
url: raw.html_url,
@ -104,8 +101,6 @@ async function main() {
avatarUrl: raw.user?.avatar_url ?? '',
url: raw.user?.html_url ?? '',
},
skills,
tags,
})
}
@ -117,6 +112,195 @@ async function main() {
// ── 3. Sort by creation date desc ─────────────────────────────────────────
issues.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
// ── 3b. Fetch tester items: open PRs + "User Testing" issues ──────────────
/** @type {any[]} */
const testerItems = []
// PRs and issues labeled "User Testing" across all org repos
console.log(`Fetching "${TESTER_LABEL}" items across org`)
for (const repo of repos) {
let page = 1
while (true) {
const { data } = await octokit.rest.issues.listForRepo({
owner: ORG,
repo: repo.name,
labels: TESTER_LABEL,
state: 'open',
per_page: 100,
page,
})
if (data.length === 0) break
for (const raw of data) {
const isPR = !!raw.pull_request
testerItems.push({
id: raw.id,
number: raw.number,
type: isPR ? 'pr' : 'issue',
title: raw.title,
body: (raw.body ?? '').slice(0, BODY_MAX),
url: raw.html_url,
createdAt: raw.created_at,
updatedAt: raw.updated_at ?? raw.created_at,
commentsCount: raw.comments,
reactionCount: raw.reactions?.total_count ?? 0,
labels: raw.labels
.filter((l) => typeof l === 'object')
.map((l) => ({ name: l.name ?? '', color: l.color ?? '888888' })),
repo: {
name: repo.name,
fullName: repo.full_name,
language: repo.language ?? null,
url: repo.html_url,
},
assignees: (raw.assignees ?? []).map((a) => ({
login: a.login,
avatarUrl: a.avatar_url,
url: a.html_url,
})),
author: {
login: raw.user?.login ?? 'unknown',
avatarUrl: raw.user?.avatar_url ?? '',
url: raw.user?.html_url ?? '',
},
})
}
if (data.length < 100) break
page++
}
}
console.log(`Found ${testerItems.filter((i) => i.type === 'pr').length} PRs and ${testerItems.filter((i) => i.type === 'issue').length} issues with "${TESTER_LABEL}" label`)
// Sort tester items: PRs first, then issues, newest first within each group
testerItems.sort((a, b) => {
if (a.type !== b.type) return a.type === 'pr' ? -1 : 1
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
})
// ── 3c. Writer issues: all open issues from doc + blog repos ─────────────
/** @type {any[]} */
const writerIssues = []
console.log(`Fetching all open issues from writer repos: ${WRITER_REPOS.join(', ')}`)
for (const repoName of WRITER_REPOS) {
const repo = repos.find((r) => r.name === repoName)
if (!repo) { console.warn(` Writer repo not found: ${repoName}`); continue }
let page = 1
while (true) {
const { data } = await octokit.rest.issues.listForRepo({
owner: ORG,
repo: repoName,
state: 'open',
per_page: 100,
page,
})
if (data.length === 0) break
for (const raw of data) {
if (raw.pull_request) continue // skip PRs
writerIssues.push({
id: raw.id,
number: raw.number,
type: 'issue',
title: raw.title,
body: (raw.body ?? '').slice(0, BODY_MAX),
url: raw.html_url,
createdAt: raw.created_at,
updatedAt: raw.updated_at ?? raw.created_at,
commentsCount: raw.comments,
reactionCount: raw.reactions?.total_count ?? 0,
labels: raw.labels
.filter((l) => typeof l === 'object')
.map((l) => ({ name: l.name ?? '', color: l.color ?? '888888' })),
repo: {
name: repo.name,
fullName: repo.full_name,
language: repo.language ?? null,
url: repo.html_url,
},
assignees: (raw.assignees ?? []).map((a) => ({
login: a.login,
avatarUrl: a.avatar_url,
url: a.html_url,
})),
author: {
login: raw.user?.login ?? 'unknown',
avatarUrl: raw.user?.avatar_url ?? '',
url: raw.user?.html_url ?? '',
},
})
}
if (data.length < 100) break
page++
}
}
// Also fetch "copywriting" labeled issues across all org repos
const seenWriterIds = new Set(writerIssues.map((i) => i.id))
console.log(`Fetching "${COPYWRITING_LABEL}" issues across org`)
for (const repo of repos) {
let page = 1
while (true) {
const { data } = await octokit.rest.issues.listForRepo({
owner: ORG,
repo: repo.name,
labels: COPYWRITING_LABEL,
state: 'open',
per_page: 100,
page,
})
if (data.length === 0) break
for (const raw of data) {
if (raw.pull_request) continue
if (seenWriterIds.has(raw.id)) continue // already included
seenWriterIds.add(raw.id)
writerIssues.push({
id: raw.id,
number: raw.number,
type: 'issue',
title: raw.title,
body: (raw.body ?? '').slice(0, BODY_MAX),
url: raw.html_url,
createdAt: raw.created_at,
updatedAt: raw.updated_at ?? raw.created_at,
commentsCount: raw.comments,
reactionCount: raw.reactions?.total_count ?? 0,
labels: raw.labels
.filter((l) => typeof l === 'object')
.map((l) => ({ name: l.name ?? '', color: l.color ?? '888888' })),
repo: {
name: repo.name,
fullName: repo.full_name,
language: repo.language ?? null,
url: repo.html_url,
},
assignees: (raw.assignees ?? []).map((a) => ({
login: a.login,
avatarUrl: a.avatar_url,
url: a.html_url,
})),
author: {
login: raw.user?.login ?? 'unknown',
avatarUrl: raw.user?.avatar_url ?? '',
url: raw.user?.html_url ?? '',
},
})
}
if (data.length < 100) break
page++
}
}
// Sort writer issues: unassigned first, then newest
writerIssues.sort((a, b) => {
if (a.assignees.length !== b.assignees.length) return a.assignees.length - b.assignees.length
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
})
console.log(`Found ${writerIssues.length} open writer issues (doc/blog repos + "${COPYWRITING_LABEL}" label)`)
// ── 4. Build repo list ─────────────────────────────────────────────────────
const reposWithIssues = repos
.filter((r) => issues.some((i) => i.repo.name === r.name))
@ -137,6 +321,8 @@ async function main() {
repoCount: reposWithIssues.length,
repos: reposWithIssues,
issues,
testerItems,
writerIssues,
}
// ── 5. Diff check — skip write if unchanged ────────────────────────────────
@ -152,7 +338,7 @@ async function main() {
mkdirSync(dirname(OUT), { recursive: true })
writeFileSync(OUT, json, 'utf8')
console.log(`✓ Wrote ${issues.length} issues from ${reposWithIssues.length} repos to ${OUT}`)
console.log(`✓ Wrote ${issues.length} issues, ${testerItems.length} tester items, ${writerIssues.length} writer issues from ${reposWithIssues.length} repos to ${OUT}`)
}
main().catch((err) => {

View File

@ -1,48 +0,0 @@
// @ts-check
/**
* Maps a GitHub issue + repo to skill categories.
*
* Rules (an issue can match multiple skills):
* writer repo is btcpayserver-doc OR label is docs/writing/translation
* design label is design/ui/ux
* marketing label is marketing/community/growth/seo
* developer default when none of the above match
*
* Mirrors src/lib/skill-map.ts keep both in sync.
*/
const WRITER_REPOS = new Set(['btcpayserver-doc'])
const WRITER_LABELS = new Set(['writing', 'documentation', 'docs', 'translation', 'content writing'])
const DESIGN_LABELS = new Set(['design', 'ui', 'ux', 'ui/ux'])
const MARKETING_LABELS = new Set(['marketing', 'community', 'growth', 'seo'])
/**
* @param {{ labels: { name: string }[] }} issue
* @param {{ name: string, language: string | null }} repo
* @returns {{ skills: string[], tags: string[] }}
*/
export function mapSkills(issue, repo) {
const labelNames = issue.labels.map((l) => l.name.toLowerCase())
const isWriter = WRITER_REPOS.has(repo.name) || labelNames.some((l) => WRITER_LABELS.has(l))
const isDesign = labelNames.some((l) => DESIGN_LABELS.has(l))
const isMarketing = labelNames.some((l) => MARKETING_LABELS.has(l))
/** @type {string[]} */
const skills = []
if (isWriter) skills.push('writer')
if (isDesign) skills.push('design')
if (isMarketing) skills.push('marketing')
if (skills.length === 0) skills.push('developer') // default
// Tags: repo language for developer issues
/** @type {string[]} */
const tags = []
if (skills.includes('developer') && repo.language) tags.push(repo.language)
if (isWriter) tags.push('docs')
if (isDesign) tags.push('design')
if (isMarketing) tags.push('marketing')
return { skills, tags }
}

View File

@ -1,4 +1,4 @@
import { useState, lazy, Suspense } from 'react'
import { useState, lazy, Suspense, useCallback } from 'react'
import type React from 'react'
import Navbar from '@/components/Navbar'
import Hero from '@/components/Hero'
@ -9,40 +9,72 @@ import Footer from '@/components/Footer'
const IssueModal = lazy(() => import('@/components/IssueModal'))
const preloadIssueModal = () => import('@/components/IssueModal')
import { useFilters } from '@/hooks/useFilters'
import { useIssues } from '@/hooks/useIssues'
import type { Issue } from '@/types'
import type { Issue, Role } from '@/types'
const ROLE_SECTION_TITLE: Record<Role, { heading: string; sub: string }> = {
developer: {
heading: 'Jump right in',
sub: 'Pick a good first issue and start shipping.',
},
tester: {
heading: 'Jump right in',
sub: 'The best testers use BTCPay Server daily. Test PRs, try every release, and report every bug you find.',
},
writer: {
heading: 'Jump right in',
sub: 'Pick an open issue in the docs or blog and start writing.',
},
}
export default function App() {
const { filters, setSkill, setQuery, clearAll } = useFilters()
const { filtered, status } = useIssues(filters)
const [selectedRole, setSelectedRole] = useState<Role>('developer')
const { filters, setQuery } = useFilters()
const { filtered, testerFiltered, writerFiltered, status } = useIssues(filters)
const [selectedIssue, setSelectedIssue] = useState<Issue | null>(null)
const [slideFrom, setSlideFrom] = useState<'left' | 'bottom' | 'right'>('bottom')
const handleRoleSelect = useCallback((role: Role) => {
setSelectedRole(role)
setQuery('')
}, [setQuery])
const handleIssueClick = (e: React.MouseEvent, issue: Issue) => {
const { left, width } = (e.currentTarget as HTMLElement).getBoundingClientRect()
const third = window.innerWidth / 3
const cardCenter = left + width / 2
const direction = cardCenter < third ? 'left' : cardCenter > third * 2 ? 'right' : 'bottom'
setSlideFrom(direction)
setSelectedIssue(issue)
}
const { heading, sub } = ROLE_SECTION_TITLE[selectedRole]
function getIssues() {
if (selectedRole === 'tester') return testerFiltered
if (selectedRole === 'writer') return writerFiltered
return filtered
}
return (
<>
<Navbar />
<Navbar selectedRole={selectedRole} onRoleSelect={handleRoleSelect} />
<main className="max-w-7xl mx-auto px-4 sm:px-6 pt-[4.5rem] pb-8">
<Hero />
<Hero
selectedRole={selectedRole}
onRoleSelect={handleRoleSelect}
/>
<div id="issues" className="border-t border-border/60 pt-20 sm:pt-28 pb-10 text-center">
<h2 className="font-display font-bold text-3xl sm:text-4xl lg:text-5xl text-foreground">
Pick an issue
{heading}
</h2>
<p className="mt-4 text-muted-foreground max-w-sm mx-auto text-sm sm:text-base">
Filter by skill and find something that excites you.
{sub}
</p>
</div>
@ -51,23 +83,20 @@ export default function App() {
<div className="max-w-7xl mx-auto px-0">
<FilterBar
filters={filters}
setSkill={setSkill}
setQuery={setQuery}
clearAll={clearAll}
/>
</div>
</div>
<IssueGrid
issues={filtered}
issues={getIssues()}
loading={status === 'loading'}
onIssueClick={handleIssueClick}
onIssueHover={preloadIssueModal}
/>
</div>
<ResourcesSection />
<ResourcesSection role={selectedRole} />
</main>
<Footer />

View File

@ -1,18 +1,14 @@
import { Search, X } from 'lucide-react'
import { useCallback, useRef } from 'react'
import { cn } from '@/lib/utils'
import { ALL_SKILLS, SKILL_META } from '@/lib/skill-map'
import type { FilterState, SkillCategory } from '@/types'
import { hasActiveFilters } from '@/lib/filter-engine'
import type { FilterState } from '@/types'
interface FilterBarProps {
filters: FilterState
setSkill: (s: SkillCategory | null) => void
setQuery: (q: string) => void
clearAll: () => void
}
export default function FilterBar({ filters, setSkill, setQuery, clearAll }: FilterBarProps) {
export default function FilterBar({ filters, setQuery }: FilterBarProps) {
const searchRef = useRef<HTMLInputElement>(null)
const handleSearch = useCallback(
@ -22,57 +18,29 @@ export default function FilterBar({ filters, setSkill, setQuery, clearAll }: Fil
[setQuery],
)
const active = hasActiveFilters(filters)
const hasQuery = !!filters.query.trim()
return (
<div className="min-w-0 overflow-hidden">
<div className="flex flex-col md:flex-row md:items-center gap-2">
<div className="flex flex-wrap items-center gap-1.5 min-w-0">
{ALL_SKILLS.map((skill) => {
const meta = SKILL_META[skill]
const on = filters.skill === skill
return (
<button
key={skill}
type="button"
onClick={() => setSkill(on ? null : skill)}
aria-pressed={on}
className={cn(
'flex items-center gap-1.5 px-3.5 py-1.5 rounded-full text-sm font-medium',
'transition-all duration-150 cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
on
? 'bg-primary text-white shadow-sm shadow-primary/20'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground',
)}
>
<span aria-hidden="true">{meta.icon}</span>
{meta.label}
</button>
)
})}
</div>
<div className="flex items-center gap-2 md:ml-auto w-full md:w-auto">
{active && (
<div className="flex items-center gap-2">
{hasQuery && (
<button
type="button"
onClick={clearAll}
aria-label="Clear all filters"
onClick={() => setQuery('')}
aria-label="Clear search"
className={cn(
'flex items-center justify-center rounded-full shrink-0',
'bg-muted/70 text-muted-foreground hover:text-foreground hover:bg-muted',
'transition-all duration-150 cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'h-8 w-8',
'md:w-auto md:px-3 md:gap-1.5 md:text-sm md:font-medium',
'h-8 w-8 sm:w-auto sm:px-3 sm:gap-1.5 sm:text-sm sm:font-medium',
)}
>
<X size={14} aria-hidden="true" />
<span className="hidden sm:inline">Clear</span>
</button>
)}
<div className="relative flex-1 md:flex-none min-w-0">
<div className="relative flex-1 min-w-0">
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground pointer-events-none" aria-hidden="true" />
<input
ref={searchRef}
@ -93,6 +61,5 @@ export default function FilterBar({ filters, setSkill, setQuery, clearAll }: Fil
</div>
</div>
</div>
</div>
)
}

View File

@ -1,41 +1,111 @@
import type React from 'react'
import { Code2, FlaskConical, PenLine } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { Role } from '@/types'
export default function Hero() {
interface RoleMeta {
id: Role
icon: React.ReactElement
label: string
description: string
}
const ROLES: RoleMeta[] = [
{
id: 'developer',
icon: <Code2 size={28} />,
label: 'Developer',
description: 'Write code, fix bugs, ship features in C#, TypeScript, and more.',
},
{
id: 'tester',
icon: <FlaskConical size={28} />,
label: 'Tester',
description: 'Test pull requests, hunt bugs, and give quality feedback.',
},
{
id: 'writer',
icon: <PenLine size={28} />,
label: 'Writer',
description: 'Improve docs, write blog posts, and help users understand BTCPay Server.',
},
]
interface HeroProps {
selectedRole: Role
onRoleSelect: (role: Role) => void
}
export default function Hero({ selectedRole, onRoleSelect }: HeroProps) {
return (
<section aria-label="Overview" className="py-16 sm:py-24">
<section id="role-selector" aria-label="Choose your contribution path" className="py-16 sm:py-24">
<div
className="absolute inset-0 pointer-events-none"
style={{ background: 'radial-gradient(ellipse at 50% 0%, hsl(var(--primary) / 0.08) 0%, transparent 60%)' }}
aria-hidden="true"
/>
<div className="relative text-center max-w-2xl mx-auto space-y-6">
<div className="relative text-center max-w-2xl mx-auto mb-12 space-y-4">
<h1 className="text-5xl sm:text-6xl xl:text-7xl font-display font-bold tracking-tight leading-[1.05] text-foreground">
Start contributing<br />to{' '}
Start contributing to{' '}
<span className="bg-gradient-to-r from-primary to-emerald-600 bg-clip-text text-transparent">
itcoin.
itcoin
</span>
</h1>
<p className="text-lg sm:text-xl leading-relaxed text-muted-foreground max-w-xl mx-auto">
Find your first issue across the entire BTCPay ecosystem.
Developer, writer, designer, or marketer. There's a spot for you.
Find a task matching your skillset and make a difference.
</p>
</div>
<div className="flex flex-wrap items-center justify-center gap-3 pt-2">
<a
href="#issues"
className="inline-flex items-center gap-2 rounded-full px-8 h-11 sm:h-12 text-sm sm:text-base font-semibold text-primary-foreground bg-primary shadow-lg shadow-primary/20 hover:shadow-primary/40 hover:bg-primary/90 transition-all duration-300 hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
{/* Role selector */}
<div className="relative grid grid-cols-1 sm:grid-cols-3 gap-3 max-w-4xl mx-auto">
{ROLES.map((role) => {
const selected = selectedRole === role.id
return (
<button
key={role.id}
type="button"
onClick={() => onRoleSelect(role.id)}
aria-pressed={selected}
className={cn(
'group relative flex flex-col items-start gap-4 rounded-2xl border p-6 text-left',
'transition-all duration-200 cursor-pointer',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
selected
? 'border-primary bg-primary/[0.06] shadow-lg shadow-primary/10'
: 'border-border/60 bg-card/50 hover:border-border hover:bg-card',
)}
>
Pick an issue
</a>
<a
href="#how-it-works"
className="inline-flex items-center gap-2 rounded-full px-8 h-11 sm:h-12 text-sm sm:text-base font-semibold border border-border/60 bg-background/50 backdrop-blur-sm hover:bg-background/80 transition-all duration-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
{selected && (
<span
className="absolute top-3 right-3 w-2 h-2 rounded-full bg-primary"
aria-hidden="true"
/>
)}
<div
className={cn(
'flex items-center justify-center w-12 h-12 rounded-xl transition-colors duration-200',
selected
? 'bg-primary/15 text-primary'
: 'bg-muted text-muted-foreground group-hover:text-foreground group-hover:bg-muted/80',
)}
>
How it works
</a>
{role.icon}
</div>
<div className="space-y-1">
<p className="font-display font-bold text-base leading-tight text-foreground">
{role.label}
</p>
<p className="text-xs leading-relaxed text-muted-foreground">
{role.description}
</p>
</div>
</button>
)
})}
</div>
</section>
)
}

View File

@ -1,10 +1,9 @@
import { GitBranch, Clock } from 'lucide-react'
import { GitBranch, GitPullRequest, Clock } from 'lucide-react'
import type React from 'react'
import { Badge } from '@/components/ui/badge'
import IssueLabel from '@/components/IssueLabel'
import type { Issue } from '@/types'
import { cn, timeAgo, stripMarkdown } from '@/lib/utils'
import { SKILL_META } from '@/lib/skill-map'
interface IssueCardProps {
issue: Issue
@ -12,8 +11,7 @@ interface IssueCardProps {
}
export default function IssueCard({ issue, onClick }: IssueCardProps) {
const skill = issue.skills[0]
const meta = skill ? SKILL_META[skill] : null
const isPR = issue.type === 'pr'
return (
<button
@ -30,9 +28,9 @@ export default function IssueCard({ issue, onClick }: IssueCardProps) {
<GitBranch size={11} aria-hidden="true" />
{issue.repo.name}
</span>
{meta && (
<Badge variant="skill" className="text-[10px]">
<span aria-hidden="true">{meta.icon}</span> {meta.label}
{isPR && (
<Badge variant="skill" className="text-[10px] gap-1">
<GitPullRequest size={10} aria-hidden="true" /> PR
</Badge>
)}
</div>

View File

@ -2,12 +2,10 @@ import { lazy, Suspense } from 'react'
import { ExternalLink, MessageCircle, Clock, GitBranch } from 'lucide-react'
const ReactMarkdown = lazy(() => import('react-markdown'))
import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import IssueLabel from '@/components/IssueLabel'
import type { Issue } from '@/types'
import { timeAgo } from '@/lib/utils'
import { SKILL_META } from '@/lib/skill-map'
interface IssueModalProps {
issue: Issue | null
@ -85,23 +83,6 @@ export default function IssueModal({ issue, onClose, slideFrom }: IssueModalProp
</div>
</div>
{issue.skills.length > 0 && (
<div className="flex flex-wrap gap-1.5 pt-4 pb-4 border-t border-border">
{issue.skills.map((s) => {
const meta = SKILL_META[s]
return (
<Badge key={s} variant="skill">
<span aria-hidden="true">{meta.icon}</span> {meta.label}
</Badge>
)
})}
{issue.tags.map((tag) => (
<Badge key={tag} variant="default" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
<div className="pt-4 border-t border-border">
<Button size="lg" className="w-full" asChild>

View File

@ -1,6 +1,10 @@
import { useState, useRef, useEffect } from 'react'
import type React from 'react'
import ThemeToggle from '@/components/ThemeToggle'
import { Code2, FlaskConical, PenLine, ChevronDown } from 'lucide-react'
import type { Role } from '@/types'
/** BTCPay logo mark — paths extracted from directory.btcpayserver.org logo SVG */
/** BTCPay logo mark - paths extracted from directory.btcpayserver.org logo SVG */
function BTCPayMark({ className }: { className?: string }) {
return (
<svg
@ -19,7 +23,40 @@ function BTCPayMark({ className }: { className?: string }) {
)
}
export default function Navbar() {
const ROLES: Role[] = ['developer', 'tester', 'writer']
const ROLE_ICON: Record<Role, React.ReactElement> = {
developer: <Code2 size={13} />,
tester: <FlaskConical size={13} />,
writer: <PenLine size={13} />,
}
const ROLE_LABEL: Record<Role, string> = {
developer: 'Developer',
tester: 'Tester',
writer: 'Writer',
}
interface NavbarProps {
selectedRole: Role
onRoleSelect: (role: Role) => void
}
export default function Navbar({ selectedRole, onRoleSelect }: NavbarProps) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
}, [open])
return (
<header className="fixed top-0 left-0 right-0 z-50 bg-background/80 backdrop-blur-xl border-b border-border/40 transition-all duration-300">
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16 flex items-center gap-6">
@ -44,7 +81,7 @@ export default function Navbar() {
href="#issues"
className="px-3 py-1.5 rounded-lg text-sm text-muted-foreground hover:text-foreground hover:bg-muted transition-all duration-150"
>
Pick an issue
Contribute
</a>
<a
href="#how-it-works"
@ -55,8 +92,54 @@ export default function Navbar() {
</nav>
<div className="flex items-center gap-1.5 ml-auto">
<div ref={ref} className="relative hidden sm:block">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label="Change contribution role"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-all duration-150 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span className="text-primary">{ROLE_ICON[selectedRole]}</span>
{ROLE_LABEL[selectedRole]}
<ChevronDown
size={12}
className={`text-muted-foreground transition-transform duration-150 ${open ? 'rotate-180' : ''}`}
/>
</button>
{open && (
<div
role="listbox"
aria-label="Select role"
className="absolute right-0 top-full mt-1.5 w-40 rounded-xl border border-border/60 bg-background/95 backdrop-blur-xl shadow-lg overflow-hidden"
>
{ROLES.map((role) => (
<button
key={role}
type="button"
role="option"
aria-selected={role === selectedRole}
onClick={() => { onRoleSelect(role); setOpen(false) }}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors duration-100 hover:bg-muted ${
role === selectedRole
? 'text-primary font-medium'
: 'text-muted-foreground'
}`}
>
<span className={role === selectedRole ? 'text-primary' : 'text-muted-foreground'}>
{ROLE_ICON[role]}
</span>
{ROLE_LABEL[role]}
</button>
))}
</div>
)}
</div>
<a
href="https://github.com/btcpayserver"
href="https://github.com/btcpayserver/btcpay-contribute"
target="_blank"
rel="noopener noreferrer"
aria-label="BTCPay Server on GitHub"

View File

@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from 'react'
import { Play, ArrowUpRight, Code2, Terminal, MonitorPlay, FlaskConical, PenLine, BookOpen } from 'lucide-react'
import { Play, ArrowUpRight, Code2, Terminal, MonitorPlay, FlaskConical, PenLine, BookOpen, MessageSquare, FileText, Languages } from 'lucide-react'
import { cn } from '@/lib/utils'
import type { Role } from '@/types'
function useScrollReveal() {
const ref = useRef<HTMLDivElement>(null)
@ -34,14 +35,6 @@ function MattermostIcon({ className }: { className?: string }) {
)
}
function GitHubIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
<path fillRule="evenodd" clipRule="evenodd" d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
</svg>
)
}
function XIcon({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" fill="currentColor" className={className} aria-hidden="true">
@ -118,7 +111,7 @@ const DOC_VIDEO: VideoMeta = {
id: 'Z78ZbPcsc3g',
meta: 'Documentary · 42 min',
title: 'My Trust in You Is Broken',
ariaLabel: 'My Trust in You Is Broken BTCPay Server documentary',
ariaLabel: 'My Trust in You Is Broken - BTCPay Server documentary',
}
const DEV_VIDEO: VideoMeta = {
@ -129,6 +122,7 @@ const DEV_VIDEO: VideoMeta = {
start: 408,
}
function ToolRow({ href, icon, label, meta }: {
href?: string
icon: React.ReactNode
@ -166,39 +160,106 @@ function InlineLinks({ links }: { links: { href: string; label: string }[] }) {
)
}
// ── Step visuals ──────────────────────────────────────────────────────────────
function DevToolRows() {
return (
<div className="flex flex-col gap-1.5">
<ToolRow
icon={<GitHubIcon className="w-4 h-4 text-foreground" />}
icon={<svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 text-foreground" aria-hidden="true"><path fillRule="evenodd" clipRule="evenodd" d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" /></svg>}
label="Git Client"
meta={<InlineLinks links={[{ href: 'https://desktop.github.com', label: 'GitHub Desktop' }, { href: 'https://www.sourcetreeapp.com', label: 'SourceTree' }]} />}
/>
<ToolRow
href="https://dotnet.microsoft.com/download/dotnet/10.0"
icon={<Terminal size={15} className="text-foreground" />}
label=".NET 10.0 SDK"
/>
<ToolRow
href="https://www.docker.com/get-started/"
icon={<DockerIcon className="w-4 h-4 text-foreground" />}
label="Docker Desktop"
/>
<ToolRow
href="https://www.jetbrains.com/rider/"
icon={<Code2 size={15} className="text-foreground" />}
label={<>JetBrains Rider <span className="text-xs font-normal text-muted-foreground">community edition</span></>}
/>
<ToolRow
icon={<BookOpen size={15} className="text-foreground" />}
label="Read the docs"
meta={<InlineLinks links={[{ href: 'https://desktop.github.com', label: 'Dev Docs' }, { href: 'https://www.sourcetreeapp.com', label: 'Playlist' }]} />}
/>
<ToolRow href="https://dotnet.microsoft.com/download/dotnet/10.0" icon={<Terminal size={15} className="text-foreground" />} label=".NET 10.0 SDK" />
<ToolRow href="https://www.docker.com/get-started/" icon={<DockerIcon className="w-4 h-4 text-foreground" />} label="Docker Desktop" />
<ToolRow href="https://www.jetbrains.com/rider/" icon={<Code2 size={15} className="text-foreground" />} label={<>JetBrains Rider <span className="text-xs font-normal text-muted-foreground">community edition</span></>} />
<ToolRow icon={<BookOpen size={15} className="text-foreground" />} label="Read the docs" meta={<InlineLinks links={[{ href: 'https://docs.btcpayserver.org/Contribute/DevCode/', label: 'Dev Docs' }, { href: 'https://www.youtube.com/channel/UCpG9WL6TJuoNfFVkaDMp9ug/', label: 'Playlist' }]} />} />
</div>
)
}
const STEPS = [
function CommunityRows() {
return (
<div className="flex flex-col gap-1.5">
<ToolRow href="https://t.me/btcpayserver" icon={<TelegramIcon className="w-4 h-4 text-foreground" />} label="Telegram" />
<ToolRow href="https://chat.btcpayserver.org" icon={<MattermostIcon className="w-4 h-4 text-foreground" />} label="Mattermost" />
<ToolRow href="https://x.com/BtcpayServer" icon={<XIcon className="w-4 h-4 text-foreground" />} label="Follow on X" />
</div>
)
}
function TesterStep4() {
return (
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-1">Day to day</p>
<ToolRow href="https://github.com/btcpayserver/btcpayserver/releases" icon={<FlaskConical size={15} className="text-foreground" />} label="Try every new release as soon as it drops" />
<ToolRow href="https://github.com/btcpayserver/btcpayserver/issues/new/choose" icon={<PenLine size={15} className="text-foreground" />} label="Report bugs on GitHub with clear repro steps" />
</div>
<div className="flex flex-col gap-1.5">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-1">When testing a PR</p>
<ToolRow icon={<MessageSquare size={15} className="text-foreground" />} label="Leave a comment with your findings on the PR" />
<ToolRow icon={<MonitorPlay size={15} className="text-foreground" />} label="Record a video or provide a screenshot for context" />
<ToolRow icon={<PenLine size={15} className="text-foreground" />} label="Include exact steps to reproduce so the dev can follow" />
<ToolRow icon={<FlaskConical size={15} className="text-foreground" />} label="Approve the PR and state it has been user-tested" />
</div>
</div>
)
}
function WriterStep2() {
return (
<div className="flex flex-col gap-1.5">
<ToolRow href="https://github.com/btcpayserver/btcpayserver-doc/issues?q=is:open+is:issue+label:%22good+first+issue%22" icon={<BookOpen size={15} className="text-foreground" />} label="Docs repo - open good first issues" />
<ToolRow href="https://github.com/btcpayserver/btcpayserver-blog/issues?q=is:open+is:issue+label:%22good+first+issue%22" icon={<FileText size={15} className="text-foreground" />} label="Blog repo - open good first issues" />
<ToolRow href="https://docs.btcpayserver.org/Contribute/Write/" icon={<Languages size={15} className="text-foreground" />} label="Writing contribution guide" />
</div>
)
}
function WriterStep3() {
return (
<div className="flex flex-col gap-1.5">
<ToolRow href="https://chat.btcpayserver.org" icon={<MessageSquare size={15} className="text-foreground" />} label="Join #documentation on Mattermost" />
<ToolRow href="https://chat.btcpayserver.org" icon={<MattermostIcon className="w-4 h-4 text-foreground" />} label="Join #content-creation on Mattermost" />
<ToolRow href="https://desktop.github.com" icon={<svg viewBox="0 0 24 24" fill="currentColor" className="w-4 h-4 text-foreground" aria-hidden="true"><path fillRule="evenodd" clipRule="evenodd" d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" /></svg>} label="GitHub Desktop to fork and submit PRs" />
</div>
)
}
function WriterStep4() {
return (
<div className="flex flex-col gap-1.5">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-1">When submitting</p>
<ToolRow icon={<PenLine size={15} className="text-foreground" />} label="Write in English, clear and concise" />
<ToolRow icon={<BookOpen size={15} className="text-foreground" />} label="Follow the existing tone and formatting style" />
<ToolRow icon={<MessageSquare size={15} className="text-foreground" />} label="Reference the issue you are addressing in the PR" />
</div>
)
}
function DevStep4() {
return (
<div className="flex flex-col gap-1.5">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-1">When submitting a PR</p>
<ToolRow icon={<MonitorPlay size={15} className="text-foreground" />} label="Include a screen recording in description" />
<ToolRow icon={<FlaskConical size={15} className="text-foreground" />} label="Test locally before requesting review" />
<ToolRow icon={<PenLine size={15} className="text-foreground" />} label="Write a human-readable description" />
</div>
)
}
// ── Step data ────────────────────────────────────────────────────────────────
interface StepDef {
label: string
title: string
description: string
cta?: { href: string; label: string }
}
const STEPS: Record<Role, StepDef[]> = {
developer: [
{
label: 'Documentary',
title: 'Watch the documentary',
@ -212,47 +273,99 @@ const STEPS = [
{
label: 'Community',
title: 'Join the community',
description: 'Introduce yourself, ask questions, and connect with contributors who have shipped real features. Everyone started exactly where you are.',
description: 'Introduce yourself, ask questions, and connect with contributors who have shipped real features.',
},
{
label: 'Find Issue',
label: 'Ship It',
title: 'Pick an issue and ship it',
description: 'Filter by your skill and grab a good-first-issue that fits your experience level.',
cta: { href: '#issues', label: 'Pick an issue' },
},
]
function StepVisual({ index }: { index: number }) {
if (index === 0) return <YoutubeThumbnail video={DOC_VIDEO} priority />
if (index === 1) return <YoutubeThumbnail video={DEV_VIDEO} />
if (index === 2) {
return (
<div className="flex flex-col gap-1.5">
<ToolRow href="https://t.me/btcpayserver" icon={<TelegramIcon className="w-4 h-4 text-foreground" />} label="Telegram" />
<ToolRow href="https://chat.btcpayserver.org" icon={<MattermostIcon className="w-4 h-4 text-foreground" />} label="Mattermost" />
<ToolRow href="https://x.com/BtcpayServer" icon={<XIcon className="w-4 h-4 text-foreground" />} label="Follow on X" />
</div>
)
],
tester: [
{
label: 'Documentary',
title: 'Watch the documentary',
description: 'Understand the mission and meet the contributors who built BTCPay Server. A 42-minute film that shows why this project matters.',
},
{
label: 'Dev Setup',
title: 'Deploy a local dev environment',
description: 'Testers need the same local setup as developers to run BTCPay and reproduce issues accurately.',
},
{
label: 'Community',
title: 'Join the community',
description: 'Introduce yourself, ask questions, and find out what the team is currently working on.',
},
{
label: 'Test',
title: 'Pick a PR and test it',
description: 'Use BTCPay Server regularly, test every new release, and report bugs. For PRs, leave a thorough comment with video and repro steps.',
cta: { href: '#issues', label: 'Test a PR' },
},
],
writer: [
{
label: 'Documentary',
title: 'Watch the documentary',
description: 'Understand the mission and meet the contributors who built BTCPay Server. A 42-minute film that shows why this project matters.',
},
{
label: 'Find an issue',
title: 'Find something to write',
description: 'Browse open issues in the docs and blog repos. Both repos tag writing tasks with "good first issue" to help you get started.',
},
{
label: 'Community',
title: 'Join the community',
description: 'Introduce yourself in #documentation or #content-creation on Mattermost. Ask what is most needed before you start.',
},
{
label: 'Write',
title: 'Write and submit a PR',
description: 'Fork the repo, write your contribution, and open a pull request. Keep it focused and reference the issue you are addressing.',
cta: { href: '#issues', label: 'Pick an issue' },
},
],
}
return (
<div className="flex flex-col gap-1.5">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-1">
When submitting a PR
</p>
<ToolRow icon={<MonitorPlay size={15} className="text-foreground" />} label="Include a screen recording in description" />
<ToolRow icon={<FlaskConical size={15} className="text-foreground" />} label="Test locally before requesting review" />
<ToolRow icon={<PenLine size={15} className="text-foreground" />} label="Write a human-readable description" />
</div>
)
// ── Step visual resolver ─────────────────────────────────────────────────────
function StepVisual({ role, stepIndex }: { role: Role; stepIndex: number }) {
// Step 1 (index 0): always documentary
if (stepIndex === 0) return <YoutubeThumbnail video={DOC_VIDEO} priority />
// Step 2 (index 1): dev/tester = dev env setup; writer = find an issue
if (stepIndex === 1) {
if (role === 'developer' || role === 'tester') return <YoutubeThumbnail video={DEV_VIDEO} />
if (role === 'writer') return <WriterStep2 />
}
function StepRow({ step, index }: { step: typeof STEPS[number]; index: number }) {
// Step 3 (index 2)
if (stepIndex === 2) {
if (role === 'developer' || role === 'tester') return <CommunityRows />
if (role === 'writer') return <WriterStep3 />
}
// Step 4 (index 3)
if (role === 'developer') return <DevStep4 />
if (role === 'tester') return <TesterStep4 />
if (role === 'writer') return <WriterStep4 />
return null
}
// ── Step row ─────────────────────────────────────────────────────────────────
function StepRow({ step, index, role }: { step: StepDef; index: number; role: Role }) {
const { ref, visible } = useScrollReveal()
const flip = index % 2 !== 0
if (index === 1) {
// Step 2 for dev/tester gets the wide two-column layout with video + tools side by side
const isDevSetupStep = index === 1 && (role === 'developer' || role === 'tester')
if (isDevSetupStep) {
return (
<div
ref={ref}
@ -274,6 +387,9 @@ function StepRow({ step, index }: { step: typeof STEPS[number]; index: number })
<h3 className="font-display font-bold text-2xl sm:text-3xl text-foreground leading-tight -mt-3 sm:-mt-5">
{step.title}
</h3>
{step.description && (
<p className="text-muted-foreground leading-relaxed max-w-sm pt-2">{step.description}</p>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 lg:gap-12 items-center">
@ -307,13 +423,13 @@ function StepRow({ step, index }: { step: typeof STEPS[number]; index: number })
{step.description && (
<p className="text-muted-foreground leading-relaxed max-w-sm">{step.description}</p>
)}
{index === 3 && (
{step.cta && (
<div className="pt-1">
<a
href="#issues"
href={step.cta.href}
className="inline-flex items-center gap-2 rounded-full px-8 h-12 text-sm font-semibold text-primary-foreground bg-primary shadow-lg shadow-primary/20 hover:shadow-primary/40 hover:bg-primary/90 transition-all duration-300 hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
Pick an issue
{step.cta.label}
</a>
</div>
)}
@ -321,34 +437,44 @@ function StepRow({ step, index }: { step: typeof STEPS[number]; index: number })
</div>
<div className={cn(flip && 'lg:order-1')}>
<StepVisual index={index} />
<StepVisual role={role} stepIndex={index} />
</div>
</div>
)
}
export default function ResourcesSection() {
// ── Section subtitles per role ───────────────────────────────────────────────
const SECTION_SUB: Record<Role, string> = {
developer: 'New to open source? Follow these steps to land your first merged PR.',
tester: 'New to testing? Follow these steps to file your first quality report.',
writer: 'New to contributing docs or content? Follow these steps to get your first PR merged.',
}
// ── Export ───────────────────────────────────────────────────────────────────
export default function ResourcesSection({ role }: { role: Role }) {
const steps = STEPS[role]
return (
<section id="how-it-works" aria-label="Getting started" className="border-t border-border/60">
<div className="text-center pt-20 sm:pt-28 pb-6">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-4">
How it works
</p>
<h2 className="font-display font-bold text-3xl sm:text-4xl lg:text-5xl text-foreground">
Your path to first contribution
New here? Learn step by step
</h2>
<p className="mt-4 text-muted-foreground max-w-sm mx-auto text-sm sm:text-base">
Four steps from curious to your first merged PR.
{SECTION_SUB[role]}
</p>
</div>
<div className="divide-y divide-border/60">
{STEPS.map((step, i) => (
<StepRow key={step.label} step={step} index={i} />
{steps.map((step, i) => (
<StepRow key={step.label} step={step} index={i} role={role} />
))}
</div>
</section>
)
}

View File

@ -1,46 +1,21 @@
import { useCallback, useEffect, useState } from 'react'
import type { FilterState, SkillCategory } from '@/types'
import { sanitizeFilters } from '@/lib/filter-engine'
function readFromURL(): FilterState {
const params = new URLSearchParams(window.location.search)
return sanitizeFilters({
skill: params.get('skill') ?? undefined,
tags: params.get('tags')?.split(',').filter(Boolean) ?? [],
repos: params.get('repos')?.split(',').filter(Boolean) ?? [],
query: params.get('q') ?? '',
})
}
function writeToURL(filters: FilterState) {
const params = new URLSearchParams()
if (filters.skill) params.set('skill', filters.skill)
if (filters.tags.length) params.set('tags', filters.tags.join(','))
if (filters.repos.length) params.set('repos', filters.repos.join(','))
if (filters.query.trim()) params.set('q', filters.query.trim())
const search = params.toString()
const url = search ? `?${search}` : window.location.pathname
window.history.replaceState(null, '', url)
}
import type { FilterState } from '@/types'
export function useFilters() {
const [filters, setFilters] = useState<FilterState>(readFromURL)
const [filters, setFilters] = useState<FilterState>(() => ({
query: new URLSearchParams(window.location.search).get('q') ?? '',
}))
// Sync to URL whenever filters change
useEffect(() => { writeToURL(filters) }, [filters])
const setSkill = useCallback((skill: SkillCategory | null) => {
setFilters((prev) => ({ ...prev, skill, tags: [] })) // reset tags on skill change
document.getElementById('issues')?.scrollIntoView({ behavior: 'smooth' })
}, [])
// Sync search query to URL
useEffect(() => {
const q = filters.query.trim()
const url = q ? `?q=${encodeURIComponent(q)}` : window.location.pathname
window.history.replaceState(null, '', url)
}, [filters.query])
const setQuery = useCallback((query: string) => {
setFilters((prev) => ({ ...prev, query }))
}, [])
const clearAll = useCallback(() => {
setFilters({ skill: null, tags: [], repos: [], query: '' })
}, [])
return { filters, setSkill, setQuery, clearAll }
return { filters, setQuery }
}

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'
import type { IssuesData, FilterState } from '@/types'
import { filterIssues } from '@/lib/filter-engine'
import { filterIssues, filterByQuery } from '@/lib/filter-engine'
type Status = 'idle' | 'loading' | 'success' | 'error'
@ -24,5 +24,15 @@ export function useIssues(filters: FilterState) {
[data, filters],
)
return { filtered, status }
const testerFiltered = useMemo(
() => (data ? filterByQuery(data.testerItems ?? [], filters.query) : []),
[data, filters.query],
)
const writerFiltered = useMemo(
() => (data ? filterIssues(data.writerIssues ?? [], filters) : []),
[data, filters],
)
return { filtered, testerFiltered, writerFiltered, status }
}

View File

@ -1,42 +1,8 @@
import type { Issue, FilterState, SkillCategory } from '@/types'
import { ALL_SKILLS } from '@/lib/skill-map'
import type { Issue, FilterState } from '@/types'
const ALLOWED_SKILLS = new Set<string>(ALL_SKILLS)
/** Loose input accepted by sanitizeFilters — raw strings from URL params before validation. */
interface RawFilterInput {
skill?: string | null
tags?: string[]
repos?: string[]
query?: string
}
/** Sanitize raw URL-param input — only valid, allowlisted values pass through. */
export function sanitizeFilters(raw: RawFilterInput): FilterState {
return {
skill: raw.skill && ALLOWED_SKILLS.has(raw.skill) ? (raw.skill as SkillCategory) : null,
tags: Array.isArray(raw.tags) ? raw.tags.filter((t) => /^[\w/. -]+$/.test(t)) : [],
repos: Array.isArray(raw.repos) ? raw.repos.filter((r) => /^[\w.-]+$/.test(r)) : [],
query: typeof raw.query === 'string' ? raw.query.slice(0, 200) : '',
}
}
/** Apply all active filters to the full issue list */
export function filterIssues(issues: Issue[], filters: FilterState): Issue[] {
let result = issues.filter((i) => i.assignees.length === 0)
if (filters.skill) {
result = result.filter((i) => i.skills.includes(filters.skill!))
}
if (filters.tags.length > 0) {
result = result.filter((i) => filters.tags.every((tag) => i.tags.includes(tag)))
}
if (filters.repos.length > 0) {
result = result.filter((i) => filters.repos.includes(i.repo.name))
}
if (filters.query.trim()) {
const q = filters.query.trim().toLowerCase()
result = result.filter(
@ -50,6 +16,13 @@ export function filterIssues(issues: Issue[], filters: FilterState): Issue[] {
return result
}
export function hasActiveFilters(filters: FilterState): boolean {
return !!(filters.skill || filters.tags.length || filters.repos.length || filters.query.trim())
export function filterByQuery(items: Issue[], query: string): Issue[] {
if (!query.trim()) return items
const q = query.trim().toLowerCase()
return items.filter(
(i) =>
i.title.toLowerCase().includes(q) ||
i.body.toLowerCase().includes(q) ||
i.repo.name.toLowerCase().includes(q),
)
}

View File

@ -1,32 +0,0 @@
import type { SkillCategory } from '@/types'
export interface SkillMeta {
label: string
icon: string
description: string
}
export const SKILL_META: Record<SkillCategory, SkillMeta> = {
developer: {
label: 'Developer',
icon: '⚡',
description: 'Code contributions: C#, TypeScript, APIs, Lightning',
},
writer: {
label: 'Writer',
icon: '✍️',
description: 'Docs, tutorials, translations',
},
design: {
label: 'Design',
icon: '🎨',
description: 'UI/UX, graphic design, CSS',
},
marketing: {
label: 'Marketing',
icon: '📣',
description: 'Content, community, social media, SEO',
},
}
export const ALL_SKILLS: SkillCategory[] = ['developer', 'writer', 'design', 'marketing']

View File

@ -15,11 +15,11 @@ export function stripMarkdown(md: string): string {
return md
// Collapse CRLF to spaces
.replace(/\r\n/g, ' ')
// Fenced code blocks replace with a placeholder so they don't bleed
// Fenced code blocks - replace with a placeholder so they don't bleed
.replace(/```[\s\S]*?```/g, '[code]')
// Images ![alt](url) keep alt text
// Images ![alt](url) - keep alt text
.replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
// Links [text](url) keep text
// Links [text](url) - keep text
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
// Bare angle-bracket URLs <https://...>
.replace(/<https?:\/\/[^>]*>/g, '')

View File

@ -1,4 +1,4 @@
export type SkillCategory = 'developer' | 'writer' | 'design' | 'marketing'
export type Role = 'developer' | 'tester' | 'writer'
export interface Repository {
id: number
@ -25,6 +25,7 @@ export interface IssueAuthor {
export interface Issue {
id: number
number: number
type: 'issue' | 'pr'
title: string
body: string // truncated to 600 chars
url: string
@ -36,8 +37,6 @@ export interface Issue {
repo: Pick<Repository, 'name' | 'fullName' | 'language' | 'url'>
assignees: IssueAuthor[]
author: IssueAuthor
skills: SkillCategory[]
tags: string[]
}
export interface IssuesData {
@ -46,11 +45,10 @@ export interface IssuesData {
repoCount: number
repos: Repository[]
issues: Issue[]
testerItems: Issue[]
writerIssues: Issue[]
}
export interface FilterState {
skill: SkillCategory | null
tags: string[]
repos: string[]
query: string
}