Merge pull request #6 from hex248/development

Payment plans
This commit is contained in:
Oliver Bryan
2026-01-29 11:38:10 +00:00
committed by GitHub
81 changed files with 7264 additions and 248 deletions

206
bun.lock
View File

@@ -13,13 +13,19 @@
"name": "@sprint/backend", "name": "@sprint/backend",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@react-email/components": "^1.0.6",
"@react-email/render": "^2.0.4",
"@sprint/shared": "workspace:*", "@sprint/shared": "workspace:*",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.45.0", "drizzle-orm": "^0.45.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"resend": "^6.9.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"stripe": "^20.2.0",
"zod": "^3.23.8", "zod": "^3.23.8",
}, },
"devDependencies": { "devDependencies": {
@@ -27,6 +33,8 @@
"@types/bun": "latest", "@types/bun": "latest",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/pg": "^8.15.6", "@types/pg": "^8.15.6",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"tsx": "^4.21.0", "tsx": "^4.21.0",
}, },
@@ -41,6 +49,7 @@
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@nsmr/pixelart-react": "^2.0.0", "@nsmr/pixelart-react": "^2.0.0",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
@@ -60,12 +69,13 @@
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.1.0", "react": "19.2.4",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
"react-dom": "^19.1.0", "react-dom": "19.2.4",
"react-resizable-panels": "^4.0.15", "react-resizable-panels": "^4.0.15",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.10.1",
"sonner": "^2.0.7", "sonner": "^2.0.7",
@@ -278,6 +288,8 @@
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
@@ -346,6 +358,48 @@
"@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="],
"@react-email/body": ["@react-email/body@0.2.1", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-ljDiQiJDu/Fq//vSIIP0z5Nuvt4+DX1RqGasstChDGJB/14ogd4VdNS9aacoede/ZjGy3o3Qb+cxyS+XgM6SwQ=="],
"@react-email/button": ["@react-email/button@0.2.1", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qXyj7RZLE7POy9BMKSoqQ00tOXThjOZSUnI2Yu9i29IHngPlmrNayIWBoVKtElES7OWwypUcpiajwi1mUWx6/A=="],
"@react-email/code-block": ["@react-email/code-block@0.2.1", "", { "dependencies": { "prismjs": "^1.30.0" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-M3B7JpVH4ytgn83/ujRR1k1DQHvTeABiDM61OvAbjLRPhC/5KLHU5KkzIbbuGIrjWwxAbL1kSQzU8MhLEtSxyw=="],
"@react-email/code-inline": ["@react-email/code-inline@0.0.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jfhebvv3dVsp3OdPgKXnk8+e2pBiDVZejDOBFzBa/IblrAJ9cQDkN6rBD5IyEg8hTOxwbw3iaI/yZFmDmIguIA=="],
"@react-email/column": ["@react-email/column@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-f+W+Bk2AjNO77zynE33rHuQhyqVICx4RYtGX9NKsGUg0wWjdGP0qAuIkhx9Rnmk4/hFMo1fUrtYNqca9fwJdHg=="],
"@react-email/components": ["@react-email/components@1.0.6", "", { "dependencies": { "@react-email/body": "0.2.1", "@react-email/button": "0.2.1", "@react-email/code-block": "0.2.1", "@react-email/code-inline": "0.0.6", "@react-email/column": "0.0.14", "@react-email/container": "0.0.16", "@react-email/font": "0.0.10", "@react-email/head": "0.0.13", "@react-email/heading": "0.0.16", "@react-email/hr": "0.0.12", "@react-email/html": "0.0.12", "@react-email/img": "0.0.12", "@react-email/link": "0.0.13", "@react-email/markdown": "0.0.18", "@react-email/preview": "0.0.14", "@react-email/render": "2.0.4", "@react-email/row": "0.0.13", "@react-email/section": "0.0.17", "@react-email/tailwind": "2.0.3", "@react-email/text": "0.1.6" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-3GwOeq+5yyiAcwSf7TnHi/HWKn22lXbwxQmkkAviSwZLlhsRVxvmWqRxvUVfQk/HclDUG+62+sGz9qjfb2Uxjw=="],
"@react-email/container": ["@react-email/container@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-QWBB56RkkU0AJ9h+qy33gfT5iuZknPC7Un/IjZv9B0QmMIK+WWacc0cH6y2SV5Cv/b99hU94fjEMOOO4enpkbQ=="],
"@react-email/font": ["@react-email/font@0.0.10", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-0urVSgCmQIfx5r7Xc586miBnQUVnGp3OTYUm8m5pwtQRdTRO5XrTtEfNJ3JhYhSOruV0nD8fd+dXtKXobum6tA=="],
"@react-email/head": ["@react-email/head@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-AJg6le/08Gz4tm+6MtKXqtNNyKHzmooOCdmtqmWxD7FxoAdU1eVcizhtQ0gcnVaY6ethEyE/hnEzQxt1zu5Kog=="],
"@react-email/heading": ["@react-email/heading@0.0.16", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-jmsKnQm1ykpBzw4hCYHwBkt5pW2jScXffPeEH5ZRF5tZeF5b1pvlFTO9han7C0pCkZYo1kEvWiRtx69yfCIwuw=="],
"@react-email/hr": ["@react-email/hr@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TwmOmBDibavUQpXBxpmZYi2Iks/yeZOzFYh+di9EltMSnEabH8dMZXrl+pxNXzCgZ2XE8HY7VmUL65Lenfu5PA=="],
"@react-email/html": ["@react-email/html@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-KTShZesan+UsreU7PDUV90afrZwU5TLwYlALuCSU0OT+/U8lULNNbAUekg+tGwCnOfIKYtpDPKkAMRdYlqUznw=="],
"@react-email/img": ["@react-email/img@0.0.12", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-sRCpEARNVTf3FQhZOC+JTvu5r6ubiYWkT0ucYXg8ctkyi4G8QG+jgYPiNUqVeTLA2STOfmPM/nrk1nb84y6CPQ=="],
"@react-email/link": ["@react-email/link@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-lkWc/NjOcefRZMkQoSDDbuKBEBDES9aXnFEOuPH845wD3TxPwh+QTf0fStuzjoRLUZWpHnio4z7qGGRYusn/sw=="],
"@react-email/markdown": ["@react-email/markdown@0.0.18", "", { "dependencies": { "marked": "^15.0.12" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-gSuYK5fsMbGk87jDebqQ6fa2fKcWlkf2Dkva8kMONqLgGCq8/0d+ZQYMEJsdidIeBo3kmsnHZPrwdFB4HgjUXg=="],
"@react-email/preview": ["@react-email/preview@0.0.14", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-aYK8q0IPkBXyMsbpMXgxazwHxYJxTrXrV95GFuu2HbEiIToMwSyUgb8HDFYwPqqfV03/jbwqlsXmFxsOd+VNaw=="],
"@react-email/render": ["@react-email/render@2.0.4", "", { "dependencies": { "html-to-text": "^9.0.5", "prettier": "^3.5.3" }, "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-kht2oTFQ1SwrLpd882ahTvUtNa9s53CERHstiTbzhm6aR2Hbykp/mQ4tpPvsBGkKAEvKRlDEoooh60Uk6nHK1g=="],
"@react-email/row": ["@react-email/row@0.0.13", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-bYnOac40vIKCId7IkwuLAAsa3fKfSfqCvv6epJKmPE0JBuu5qI4FHFCl9o9dVpIIS08s/ub+Y/txoMt0dYziGw=="],
"@react-email/section": ["@react-email/section@0.0.17", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-qNl65ye3W0Rd5udhdORzTV9ezjb+GFqQQSae03NDzXtmJq6sqVXNWNiVolAjvJNypim+zGXmv6J9TcV5aNtE/w=="],
"@react-email/tailwind": ["@react-email/tailwind@2.0.3", "", { "dependencies": { "tailwindcss": "^4.1.18" }, "peerDependencies": { "@react-email/body": "0.2.1", "@react-email/button": "0.2.1", "@react-email/code-block": "0.2.1", "@react-email/code-inline": "0.0.6", "@react-email/container": "0.0.16", "@react-email/heading": "0.0.16", "@react-email/hr": "0.0.12", "@react-email/img": "0.0.12", "@react-email/link": "0.0.13", "@react-email/preview": "0.0.14", "@react-email/text": "0.1.6", "react": "^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@react-email/body", "@react-email/button", "@react-email/code-block", "@react-email/code-inline", "@react-email/container", "@react-email/heading", "@react-email/hr", "@react-email/img", "@react-email/link", "@react-email/preview"] }, "sha512-URXb/T2WS4RlNGM5QwekYnivuiVUcU87H0y5sqLl6/Oi3bMmgL0Bmw/W9GeJylC+876Vw+E6NkE0uRiUFIQwGg=="],
"@react-email/text": ["@react-email/text@0.1.6", "", { "peerDependencies": { "react": "^18.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-TYqkioRS45wTR5il3dYk/SbUjjEdhSwh9BtRNB99qNH1pXAwA45H7rAuxehiu8iJQJH0IyIr+6n62gBz9ezmsw=="],
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="],
@@ -398,12 +452,16 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="], "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="],
"@selderee/plugin-htmlparser2": ["@selderee/plugin-htmlparser2@0.11.0", "", { "dependencies": { "domhandler": "^5.0.3", "selderee": "^0.11.0" } }, "sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ=="],
"@sprint/backend": ["@sprint/backend@workspace:packages/backend"], "@sprint/backend": ["@sprint/backend@workspace:packages/backend"],
"@sprint/frontend": ["@sprint/frontend@workspace:packages/frontend"], "@sprint/frontend": ["@sprint/frontend@workspace:packages/frontend"],
"@sprint/shared": ["@sprint/shared@workspace:packages/shared"], "@sprint/shared": ["@sprint/shared@workspace:packages/shared"],
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
@@ -434,11 +492,11 @@
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
"@tanstack/query-core": ["@tanstack/query-core@5.90.19", "", {}, "sha512-GLW5sjPVIvH491VV1ufddnfldyVB+teCnpPIvweEfkpRx7CfUmUGhoh9cdcUKBh/KwVxk22aNEDxeTsvmyB/WA=="], "@tanstack/query-core": ["@tanstack/query-core@5.90.20", "", {}, "sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg=="],
"@tanstack/query-devtools": ["@tanstack/query-devtools@5.92.0", "", {}, "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ=="], "@tanstack/query-devtools": ["@tanstack/query-devtools@5.92.0", "", {}, "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ=="],
"@tanstack/react-query": ["@tanstack/react-query@5.90.19", "", { "dependencies": { "@tanstack/query-core": "5.90.19" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-qTZRZ4QyTzQc+M0IzrbKHxSeISUmRB3RPGmao5bT+sI6ayxSRhn0FXEnT5Hg3as8SBFcRosrXXRFB+yAcxVxJQ=="], "@tanstack/react-query": ["@tanstack/react-query@5.90.20", "", { "dependencies": { "@tanstack/query-core": "5.90.20" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw=="],
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.2", "", { "dependencies": { "@tanstack/query-devtools": "5.92.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.14", "react": "^18 || ^19" } }, "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg=="], "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.91.2", "", { "dependencies": { "@tanstack/query-devtools": "5.92.0" }, "peerDependencies": { "@tanstack/react-query": "^5.90.14", "react": "^18 || ^19" } }, "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg=="],
@@ -482,7 +540,7 @@
"@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="], "@types/bcrypt": ["@types/bcrypt@6.0.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ=="],
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], "@types/bun": ["@types/bun@1.3.7", "", { "dependencies": { "bun-types": "1.3.7" } }, "sha512-lmNuMda+Z9b7tmhA0tohwy8ZWFSnmQm1UDWXtH5r9F7wZCfkeO3Jx7wKQ1EOiKq43yHts7ky6r8SDJQWRNupkA=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -490,16 +548,18 @@
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], "@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="],
"@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="], "@types/pg": ["@types/pg@8.16.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-RmhMd/wD+CF8Dfo+cVIy3RR5cl8CyfXQ0tGgW6XBL8L4LM/UTEbNXYRbLwU6w+CgrKBNbrQWt4FUtTfaU5jSYQ=="],
"@types/react": ["@types/react@19.2.8", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-3MbSL37jEchWZz2p2mjntRZtPt837ij10ApxKfgmXCTuHWagYg7iA5bqPw6C8BMPfwidlvfPI/fxOc42HLhcyg=="], "@types/react": ["@types/react@19.2.10", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="],
"@zone-eu/mailsplit": ["@zone-eu/mailsplit@5.4.8", "", { "dependencies": { "libbase64": "1.3.0", "libmime": "5.3.7", "libqp": "2.1.1" } }, "sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
@@ -516,7 +576,11 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], "bun-types": ["bun-types@1.3.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-qyschsA03Qz+gou+apt6HNl6HnI+sJJLL4wLDke4iugsE6584CMupOtTY1n+2YC9nGVrEKUlTs99jjRLKgWnjQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"caniuse-lite": ["caniuse-lite@1.0.30001764", "", {}, "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g=="], "caniuse-lite": ["caniuse-lite@1.0.30001764", "", {}, "sha512-9JGuzl2M+vPL+pz70gtMF9sHdMFbY9FJaQBi186cHKH3pSzDvzoUJUPV6fqiKIMyXbud9ZLg4F3Yza1vJ1+93g=="],
@@ -546,10 +610,20 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deepmerge": ["deepmerge@4.3.1", "", {}, "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="],
"dom-serializer": ["dom-serializer@2.0.0", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.2", "entities": "^4.2.0" } }, "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg=="],
"domelementtype": ["domelementtype@2.3.0", "", {}, "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw=="],
"domhandler": ["domhandler@5.0.3", "", { "dependencies": { "domelementtype": "^2.3.0" } }, "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w=="],
"domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="],
"dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="], "dotenv": ["dotenv@17.2.3", "", {}, "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w=="],
"drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="], "drizzle-kit": ["drizzle-kit@0.31.8", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "esbuild-register": "^3.5.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-O9EC/miwdnRDY10qRxM8P3Pg8hXe3LyU4ZipReKOgTwn4OqANmftj8XJz1UPUAS6NMHf0E2htjsbQujUTkncCg=="],
@@ -558,36 +632,72 @@
"drizzle-zod": ["drizzle-zod@0.5.1", "", { "peerDependencies": { "drizzle-orm": ">=0.23.13", "zod": "*" } }, "sha512-C/8bvzUH/zSnVfwdSibOgFjLhtDtbKYmkbPbUCq46QZyZCH6kODIMSOgZ8R7rVjoI+tCj3k06MRJMDqsIeoS4A=="], "drizzle-zod": ["drizzle-zod@0.5.1", "", { "peerDependencies": { "drizzle-orm": ">=0.23.13", "zod": "*" } }, "sha512-C/8bvzUH/zSnVfwdSibOgFjLhtDtbKYmkbPbUCq46QZyZCH6kODIMSOgZ8R7rVjoI+tCj3k06MRJMDqsIeoS4A=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="], "electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"encoding-japanese": ["encoding-japanese@2.2.0", "", {}, "sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A=="],
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="], "esbuild-register": ["esbuild-register@3.6.0", "", { "dependencies": { "debug": "^4.3.4" }, "peerDependencies": { "esbuild": ">=0.12 <1" } }, "sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
"htmlparser2": ["htmlparser2@8.0.2", "", { "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", "domutils": "^3.0.1", "entities": "^4.4.0" } }, "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA=="],
"iconv-lite": ["iconv-lite@0.7.0", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ=="],
"input-otp": ["input-otp@1.4.2", "", { "peerDependencies": { "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
@@ -604,6 +714,14 @@
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
"leac": ["leac@0.6.0", "", {}, "sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg=="],
"libbase64": ["libbase64@1.3.0", "", {}, "sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg=="],
"libmime": ["libmime@5.3.7", "", { "dependencies": { "encoding-japanese": "2.2.0", "iconv-lite": "0.6.3", "libbase64": "1.3.0", "libqp": "2.1.1" } }, "sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw=="],
"libqp": ["libqp@2.1.1", "", {}, "sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow=="],
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
@@ -628,6 +746,8 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
"linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="],
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
@@ -648,6 +768,12 @@
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"mailparser": ["mailparser@3.9.1", "", { "dependencies": { "@zone-eu/mailsplit": "5.4.8", "encoding-japanese": "2.2.0", "he": "1.2.0", "html-to-text": "9.0.5", "iconv-lite": "0.7.0", "libmime": "5.3.7", "linkify-it": "5.0.0", "nodemailer": "7.0.11", "punycode.js": "2.3.1", "tlds": "1.261.0" } }, "sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA=="],
"marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
@@ -660,6 +786,14 @@
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
"nodemailer": ["nodemailer@7.0.11", "", {}, "sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"parseley": ["parseley@0.12.1", "", { "dependencies": { "leac": "^0.6.0", "peberminta": "^0.9.0" } }, "sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw=="],
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
"pg": ["pg@8.17.1", "", { "dependencies": { "pg-connection-string": "^2.10.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ=="], "pg": ["pg@8.17.1", "", { "dependencies": { "pg-connection-string": "^2.10.0", "pg-pool": "^3.11.0", "pg-protocol": "^1.11.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.3.0" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-EIR+jXdYNSMOrpRp7g6WgQr7SaZNZfS7IzZIO0oTNEeibq956JxeD15t3Jk3zZH0KH8DmOIx38qJfQenoE8bXQ=="],
"pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="], "pg-cloudflare": ["pg-cloudflare@1.3.0", "", {}, "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ=="],
@@ -690,13 +824,21 @@
"postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="],
"react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="],
"punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="],
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
"react-colorful": ["react-colorful@5.6.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw=="], "react-colorful": ["react-colorful@5.6.1", "", { "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw=="],
"react-day-picker": ["react-day-picker@9.13.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ=="], "react-day-picker": ["react-day-picker@9.13.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ=="],
"react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="],
"react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="],
@@ -704,16 +846,18 @@
"react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
"react-resizable-panels": ["react-resizable-panels@4.4.1", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-dpM9oI6rGlAq7VYDeafSRA1JmkJv8aNuKySR+tZLQQLfaeqTnQLSM52EcoI/QdowzsjVUCk6jViKS0xHWITVRQ=="], "react-resizable-panels": ["react-resizable-panels@4.5.3", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-N4Uc13yM1S9nfmwH25WIpxlp4/cwh2rqj9bDSxyZJ3S6gOJ9kFsZnPalfqIeBrbUv2SoGVLAbQUaTFceUY7A5Q=="],
"react-router": ["react-router@7.12.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-kTPDYPFzDVGIIGNLS5VJykK0HfHLY5MF3b+xj0/tTyNYL1gF1qs7u67Z9jEhQk2sQ98SUaHxlG31g1JtF7IfVw=="], "react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="],
"react-router-dom": ["react-router-dom@7.12.0", "", { "dependencies": { "react-router": "7.12.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-pfO9fiBcpEfX4Tx+iTYKDtPbrSLLCbwJ5EqP+SPYQu1VYCXdy79GSj0wttR0U4cikVdlImZuEZ/9ZNCgoaxwBA=="], "react-router-dom": ["react-router-dom@7.13.0", "", { "dependencies": { "react-router": "7.13.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g=="],
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"resend": ["resend@6.9.1", "", { "dependencies": { "mailparser": "3.9.1", "svix": "1.84.1" }, "peerDependencies": { "@react-email/render": "*" }, "optionalPeers": ["@react-email/render"] }, "sha512-jFY3qPP2cith1npRXvS7PVdnhbR1CcuzHg65ty5Elv55GKiXhe+nItXuzzoOlKeYJez1iJAo2+8f6ae8sCj0iA=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
"rollup": ["rollup@4.55.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.1", "@rollup/rollup-android-arm64": "4.55.1", "@rollup/rollup-darwin-arm64": "4.55.1", "@rollup/rollup-darwin-x64": "4.55.1", "@rollup/rollup-freebsd-arm64": "4.55.1", "@rollup/rollup-freebsd-x64": "4.55.1", "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", "@rollup/rollup-linux-arm-musleabihf": "4.55.1", "@rollup/rollup-linux-arm64-gnu": "4.55.1", "@rollup/rollup-linux-arm64-musl": "4.55.1", "@rollup/rollup-linux-loong64-gnu": "4.55.1", "@rollup/rollup-linux-loong64-musl": "4.55.1", "@rollup/rollup-linux-ppc64-gnu": "4.55.1", "@rollup/rollup-linux-ppc64-musl": "4.55.1", "@rollup/rollup-linux-riscv64-gnu": "4.55.1", "@rollup/rollup-linux-riscv64-musl": "4.55.1", "@rollup/rollup-linux-s390x-gnu": "4.55.1", "@rollup/rollup-linux-x64-gnu": "4.55.1", "@rollup/rollup-linux-x64-musl": "4.55.1", "@rollup/rollup-openbsd-x64": "4.55.1", "@rollup/rollup-openharmony-arm64": "4.55.1", "@rollup/rollup-win32-arm64-msvc": "4.55.1", "@rollup/rollup-win32-ia32-msvc": "4.55.1", "@rollup/rollup-win32-x64-gnu": "4.55.1", "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A=="], "rollup": ["rollup@4.55.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.1", "@rollup/rollup-android-arm64": "4.55.1", "@rollup/rollup-darwin-arm64": "4.55.1", "@rollup/rollup-darwin-x64": "4.55.1", "@rollup/rollup-freebsd-arm64": "4.55.1", "@rollup/rollup-freebsd-x64": "4.55.1", "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", "@rollup/rollup-linux-arm-musleabihf": "4.55.1", "@rollup/rollup-linux-arm64-gnu": "4.55.1", "@rollup/rollup-linux-arm64-musl": "4.55.1", "@rollup/rollup-linux-loong64-gnu": "4.55.1", "@rollup/rollup-linux-loong64-musl": "4.55.1", "@rollup/rollup-linux-ppc64-gnu": "4.55.1", "@rollup/rollup-linux-ppc64-musl": "4.55.1", "@rollup/rollup-linux-riscv64-gnu": "4.55.1", "@rollup/rollup-linux-riscv64-musl": "4.55.1", "@rollup/rollup-linux-s390x-gnu": "4.55.1", "@rollup/rollup-linux-x64-gnu": "4.55.1", "@rollup/rollup-linux-x64-musl": "4.55.1", "@rollup/rollup-openbsd-x64": "4.55.1", "@rollup/rollup-openharmony-arm64": "4.55.1", "@rollup/rollup-win32-arm64-msvc": "4.55.1", "@rollup/rollup-win32-ia32-msvc": "4.55.1", "@rollup/rollup-win32-x64-gnu": "4.55.1", "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A=="],
@@ -722,8 +866,12 @@
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
"selderee": ["selderee@0.11.0", "", { "dependencies": { "parseley": "^0.12.0" } }, "sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
@@ -732,6 +880,14 @@
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
@@ -742,12 +898,18 @@
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"stripe": ["stripe@20.2.0", "", { "dependencies": { "qs": "^6.14.1" }, "peerDependencies": { "@types/node": ">=16" }, "optionalPeers": ["@types/node"] }, "sha512-m8niTfdm3nPP/yQswRWMwQxqEUcTtB3RTJQ9oo6NINDzgi7aPOadsH/fPXIIfL1Sc5+lqQFKSk7WiO6CXmvaeA=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="], "supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"svix": ["svix@1.84.1", "", { "dependencies": { "standardwebhooks": "1.0.0", "uuid": "^10.0.0" } }, "sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ=="],
"tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="],
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
@@ -756,6 +918,8 @@
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"tlds": ["tlds@1.261.0", "", { "bin": { "tlds": "bin.js" } }, "sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -766,6 +930,8 @@
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
"uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
@@ -774,6 +940,8 @@
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="],
"vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="], "vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], "wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
@@ -796,6 +964,8 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="], "@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@radix-ui/react-alert-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
@@ -824,8 +994,18 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@types/bcrypt/@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"@types/jsonwebtoken/@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"@types/pg/@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"bun-types/@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"libmime/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
"tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "vite/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],

View File

@@ -6,8 +6,14 @@ CORS_ORIGIN=http://localhost:1420
# openssl rand -base64 32 # openssl rand -base64 32
JWT_SECRET=jwt_secret_here JWT_SECRET=jwt_secret_here
S3_PUBLIC_URL=https://issuebucket.ob248.com S3_PUBLIC_URL=https://images.sprintpm.org
S3_ENDPOINT=https://account_id.r2.cloudflarestorage.com/issue S3_ENDPOINT=https://account_id.r2.cloudflarestorage.com/sprint
S3_ACCESS_KEY_ID=your_access_key_id S3_ACCESS_KEY_ID=your_access_key_id
S3_SECRET_ACCESS_KEY=your_secret_access_key S3_SECRET_ACCESS_KEY=your_secret_access_key
S3_BUCKET_NAME=issue S3_BUCKET_NAME=issue
STRIPE_PUBLISHABLE_KEY=your_stripe_publishable_key
STRIPE_SECRET_KEY=your_stripe_secret_key
RESEND_API_KEY=re_xxxxxxxxxxxxxxxx
EMAIL_FROM=Sprint <support@sprintpm.org>

View File

@@ -0,0 +1,30 @@
CREATE TABLE "Payment" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "Payment_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"subscriptionId" integer NOT NULL,
"stripePaymentIntentId" varchar(255),
"amount" integer NOT NULL,
"currency" varchar(3) DEFAULT 'gbp' NOT NULL,
"status" varchar(32) NOT NULL,
"createdAt" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "Subscription" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "Subscription_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"userId" integer NOT NULL,
"stripeCustomerId" varchar(255),
"stripeSubscriptionId" varchar(255),
"stripeSubscriptionItemId" varchar(255),
"stripePriceId" varchar(255),
"status" varchar(32) DEFAULT 'incomplete' NOT NULL,
"currentPeriodStart" timestamp,
"currentPeriodEnd" timestamp,
"cancelAtPeriodEnd" boolean DEFAULT false NOT NULL,
"trialEnd" timestamp,
"quantity" integer DEFAULT 1 NOT NULL,
"createdAt" timestamp DEFAULT now(),
"updatedAt" timestamp DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN "plan" varchar(32) DEFAULT 'free' NOT NULL;--> statement-breakpoint
ALTER TABLE "Payment" ADD CONSTRAINT "Payment_subscriptionId_Subscription_id_fk" FOREIGN KEY ("subscriptionId") REFERENCES "public"."Subscription"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "Subscription" ADD CONSTRAINT "Subscription_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE no action ON UPDATE no action;

View File

@@ -0,0 +1,4 @@
ALTER TABLE "User" ADD COLUMN "email" varchar(255);--> statement-breakpoint
UPDATE "User" SET "email" = 'user_' || id || '@placeholder.local' WHERE "email" IS NULL;--> statement-breakpoint
ALTER TABLE "User" ALTER COLUMN "email" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "User" ADD CONSTRAINT "User_email_unique" UNIQUE("email");

View File

@@ -0,0 +1,28 @@
CREATE TABLE "EmailJob" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "EmailJob_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"userId" integer NOT NULL,
"type" varchar(64) NOT NULL,
"scheduledFor" timestamp NOT NULL,
"sentAt" timestamp,
"failedAt" timestamp,
"errorMessage" text,
"metadata" json,
"createdAt" timestamp DEFAULT now()
);
--> statement-breakpoint
CREATE TABLE "EmailVerification" (
"id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "EmailVerification_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
"userId" integer NOT NULL,
"code" varchar(6) NOT NULL,
"attempts" integer DEFAULT 0 NOT NULL,
"maxAttempts" integer DEFAULT 5 NOT NULL,
"expiresAt" timestamp NOT NULL,
"verifiedAt" timestamp,
"createdAt" timestamp DEFAULT now()
);
--> statement-breakpoint
ALTER TABLE "User" ALTER COLUMN "email" SET DATA TYPE varchar(256);--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN "emailVerified" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "User" ADD COLUMN "emailVerifiedAt" timestamp;--> statement-breakpoint
ALTER TABLE "EmailJob" ADD CONSTRAINT "EmailJob_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "EmailVerification" ADD CONSTRAINT "EmailVerification_userId_User_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE cascade ON UPDATE no action;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -183,6 +183,27 @@
"when": 1769549697892, "when": 1769549697892,
"tag": "0025_sharp_quicksilver", "tag": "0025_sharp_quicksilver",
"breakpoints": true "breakpoints": true
},
{
"idx": 26,
"version": "7",
"when": 1769615487574,
"tag": "0026_stale_shocker",
"breakpoints": true
},
{
"idx": 27,
"version": "7",
"when": 1769635016079,
"tag": "0027_volatile_otto_octavius",
"breakpoints": true
},
{
"idx": 28,
"version": "7",
"when": 1769643481882,
"tag": "0028_quick_supernaut",
"breakpoints": true
} }
] ]
} }

View File

@@ -20,6 +20,8 @@
"@types/bun": "latest", "@types/bun": "latest",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/pg": "^8.15.6", "@types/pg": "^8.15.6",
"@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3",
"drizzle-kit": "^0.31.8", "drizzle-kit": "^0.31.8",
"tsx": "^4.21.0" "tsx": "^4.21.0"
}, },
@@ -27,13 +29,19 @@
"typescript": "^5" "typescript": "^5"
}, },
"dependencies": { "dependencies": {
"@react-email/components": "^1.0.6",
"@react-email/render": "^2.0.4",
"@sprint/shared": "workspace:*", "@sprint/shared": "workspace:*",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"drizzle-orm": "^0.45.0", "drizzle-orm": "^0.45.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"pg": "^8.16.3", "pg": "^8.16.3",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"resend": "^6.9.1",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"stripe": "^20.2.0",
"zod": "^3.23.8" "zod": "^3.23.8"
} }
} }

View File

@@ -97,15 +97,15 @@ const issueComments = [
const passwordHash = await hashPassword("a"); const passwordHash = await hashPassword("a");
const users = [ const users = [
{ name: "user 1", username: "u1", passwordHash, avatarURL: null }, { name: "user 1", username: "u1", email: "user1@example.com", passwordHash, avatarURL: null },
{ name: "user 2", username: "u2", passwordHash, avatarURL: null }, { name: "user 2", username: "u2", email: "user2@example.com", passwordHash, avatarURL: null },
// anything past here is just to have more users to assign issues to // anything past here is just to have more users to assign issues to
{ name: "user 3", username: "u3", passwordHash, avatarURL: null }, { name: "user 3", username: "u3", email: "user3@example.com", passwordHash, avatarURL: null },
{ name: "user 4", username: "u4", passwordHash, avatarURL: null }, { name: "user 4", username: "u4", email: "user4@example.com", passwordHash, avatarURL: null },
{ name: "user 5", username: "u5", passwordHash, avatarURL: null }, { name: "user 5", username: "u5", email: "user5@example.com", passwordHash, avatarURL: null },
{ name: "user 6", username: "u6", passwordHash, avatarURL: null }, { name: "user 6", username: "u6", email: "user6@example.com", passwordHash, avatarURL: null },
{ name: "user 7", username: "u7", passwordHash, avatarURL: null }, { name: "user 7", username: "u7", email: "user7@example.com", passwordHash, avatarURL: null },
{ name: "user 8", username: "u8", passwordHash, avatarURL: null }, { name: "user 8", username: "u8", email: "user8@example.com", passwordHash, avatarURL: null },
]; ];
async function seed() { async function seed() {

View File

@@ -0,0 +1,121 @@
import { EmailVerification, type EmailVerificationRecord, User } from "@sprint/shared";
import { eq, lt, sql } from "drizzle-orm";
import { db } from "../client";
const CODE_EXPIRY_MINUTES = 15;
const MAX_ATTEMPTS = 5;
export function generateVerificationCode(): string {
const bytes = new Uint8Array(4);
crypto.getRandomValues(bytes);
// 6 digit
const code = ((bytes[0] ?? 0) * 256 * 256 + (bytes[1] ?? 0) * 256 + (bytes[2] ?? 0)) % 1000000;
return code.toString().padStart(6, "0");
}
export async function createVerificationCode(userId: number): Promise<EmailVerificationRecord> {
const code = generateVerificationCode();
const expiresAt = new Date(Date.now() + CODE_EXPIRY_MINUTES * 60 * 1000);
// delete existing codes for the user
await db.delete(EmailVerification).where(eq(EmailVerification.userId, userId));
const [verification] = await db
.insert(EmailVerification)
.values({
userId,
code,
expiresAt,
attempts: 0,
maxAttempts: MAX_ATTEMPTS,
})
.returning();
if (!verification) {
throw new Error("Failed to create verification code");
}
return verification;
}
export async function getVerificationByUserId(userId: number): Promise<EmailVerificationRecord | undefined> {
const [verification] = await db
.select()
.from(EmailVerification)
.where(eq(EmailVerification.userId, userId));
return verification;
}
export async function incrementAttempts(id: number): Promise<void> {
await db
.update(EmailVerification)
.set({
attempts: sql`CASE WHEN ${EmailVerification.attempts} IS NULL THEN 1 ELSE ${EmailVerification.attempts} + 1 END`,
})
.where(eq(EmailVerification.id, id));
}
export async function markAsVerified(id: number): Promise<void> {
await db.update(EmailVerification).set({ verifiedAt: new Date() }).where(eq(EmailVerification.id, id));
}
export async function deleteVerification(id: number): Promise<void> {
await db.delete(EmailVerification).where(eq(EmailVerification.id, id));
}
export async function deleteUserVerifications(userId: number): Promise<void> {
await db.delete(EmailVerification).where(eq(EmailVerification.userId, userId));
}
export async function cleanupExpiredVerifications(): Promise<number> {
const result = await db.delete(EmailVerification).where(lt(EmailVerification.expiresAt, new Date()));
return result.rowCount ?? 0;
}
export async function verifyCode(
userId: number,
code: string,
): Promise<{ success: boolean; error?: string }> {
const verification = await getVerificationByUserId(userId);
if (!verification) {
return { success: false, error: "No verification code found" };
}
if (verification.verifiedAt) {
return { success: false, error: "Email already verified" };
}
if (new Date() > verification.expiresAt) {
await deleteVerification(verification.id);
return { success: false, error: "Verification code expired" };
}
if (verification.attempts >= verification.maxAttempts) {
await deleteVerification(verification.id);
return { success: false, error: "Too many attempts. Please request a new code." };
}
if (verification.code !== code) {
await db
.update(EmailVerification)
.set({ attempts: verification.attempts + 1 })
.where(eq(EmailVerification.id, verification.id));
const remainingAttempts = verification.maxAttempts - (verification.attempts + 1);
return {
success: false,
error: `Invalid code. ${remainingAttempts} attempts remaining.`,
};
}
await db
.update(User)
.set({ emailVerified: true, emailVerifiedAt: new Date() })
.where(eq(User.id, userId));
await deleteVerification(verification.id);
return { success: true };
}

View File

@@ -1,8 +1,19 @@
export * from "./email-verification";
export * from "./issue-comments"; export * from "./issue-comments";
export * from "./issues"; export * from "./issues";
export * from "./organisations"; export * from "./organisations";
export * from "./projects"; export * from "./projects";
export * from "./sessions"; export * from "./sessions";
export * from "./sprints"; export * from "./sprints";
export * from "./subscriptions";
export * from "./timed-sessions"; export * from "./timed-sessions";
export * from "./users"; export * from "./users";
// free tier limits
export const FREE_TIER_LIMITS = {
organisationsPerUser: 1,
projectsPerOrganisation: 1,
issuesPerOrganisation: 100,
membersPerOrganisation: 5,
sprintsPerProject: 5,
} as const;

View File

@@ -259,6 +259,25 @@ export async function getIssueAssigneeCount(issueId: number): Promise<number> {
return result?.count ?? 0; return result?.count ?? 0;
} }
export async function getOrganisationIssueCount(organisationId: number): Promise<number> {
const { Project } = await import("@sprint/shared");
const projects = await db
.select({ id: Project.id })
.from(Project)
.where(eq(Project.organisationId, organisationId));
const projectIds = projects.map((p) => p.id);
if (projectIds.length === 0) return 0;
const [result] = await db
.select({ count: sql<number>`COUNT(*)` })
.from(Issue)
.where(inArray(Issue.projectId, projectIds));
return result?.count ?? 0;
}
export async function isIssueAssignee(issueId: number, userId: number): Promise<boolean> { export async function isIssueAssignee(issueId: number, userId: number): Promise<boolean> {
const [assignee] = await db const [assignee] = await db
.select({ id: IssueAssignee.id }) .select({ id: IssueAssignee.id })

View File

@@ -1,5 +1,5 @@
import { Organisation, OrganisationMember, User } from "@sprint/shared"; import { Organisation, OrganisationMember, User } from "@sprint/shared";
import { and, eq } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { db } from "../client"; import { db } from "../client";
export async function createOrganisation(name: string, slug: string, description?: string) { export async function createOrganisation(name: string, slug: string, description?: string) {
@@ -144,3 +144,21 @@ export async function updateOrganisationMemberRole(organisationId: number, userI
.returning(); .returning();
return member; return member;
} }
export async function getUserOrganisationCount(userId: number): Promise<number> {
const [result] = await db
.select({ count: sql<number>`COUNT(*)` })
.from(OrganisationMember)
.where(eq(OrganisationMember.userId, userId));
return result?.count ?? 0;
}
export async function getOrganisationOwner(organisationId: number) {
const [owner] = await db
.select({ userId: OrganisationMember.userId })
.from(OrganisationMember)
.where(
and(eq(OrganisationMember.organisationId, organisationId), eq(OrganisationMember.role, "owner")),
);
return owner;
}

View File

@@ -1,5 +1,5 @@
import { Issue, Organisation, Project, Sprint, User } from "@sprint/shared"; import { Issue, Organisation, Project, Sprint, User } from "@sprint/shared";
import { eq } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { db } from "../client"; import { db } from "../client";
export async function createProject(key: string, name: string, creatorId: number, organisationId: number) { export async function createProject(key: string, name: string, creatorId: number, organisationId: number) {
@@ -82,3 +82,11 @@ export async function getProjectsByOrganisationId(organisationId: number) {
.leftJoin(Organisation, eq(Project.organisationId, Organisation.id)); .leftJoin(Organisation, eq(Project.organisationId, Organisation.id));
return projects; return projects;
} }
export async function getOrganisationProjectCount(organisationId: number): Promise<number> {
const [result] = await db
.select({ count: sql<number>`COUNT(*)` })
.from(Project)
.where(eq(Project.organisationId, organisationId));
return result?.count ?? 0;
}

View File

@@ -1,5 +1,5 @@
import { Issue, Sprint } from "@sprint/shared"; import { Issue, Sprint } from "@sprint/shared";
import { and, desc, eq, gte, lte, ne } from "drizzle-orm"; import { and, desc, eq, gte, lte, ne, sql } from "drizzle-orm";
import { db } from "../client"; import { db } from "../client";
export async function createSprint( export async function createSprint(
@@ -72,3 +72,11 @@ export async function deleteSprint(sprintId: number) {
await db.update(Issue).set({ sprintId: null }).where(eq(Issue.sprintId, sprintId)); await db.update(Issue).set({ sprintId: null }).where(eq(Issue.sprintId, sprintId));
await db.delete(Sprint).where(eq(Sprint.id, sprintId)); await db.delete(Sprint).where(eq(Sprint.id, sprintId));
} }
export async function getProjectSprintCount(projectId: number) {
const result = await db
.select({ count: sql<number>`count(*)::int` })
.from(Sprint)
.where(eq(Sprint.projectId, projectId));
return result[0]?.count ?? 0;
}

View File

@@ -0,0 +1,77 @@
import { Payment, Subscription } from "@sprint/shared";
import { eq } from "drizzle-orm";
import { db } from "../client";
export async function createSubscription(data: {
userId: number;
stripeCustomerId: string;
stripeSubscriptionId: string;
stripeSubscriptionItemId: string;
stripePriceId: string;
status: string;
quantity: number;
currentPeriodStart?: Date;
currentPeriodEnd?: Date;
trialEnd?: Date;
}) {
const [subscription] = await db
.insert(Subscription)
.values({
...data,
createdAt: new Date(),
updatedAt: new Date(),
})
.returning();
return subscription;
}
export async function getSubscriptionByUserId(userId: number) {
const [subscription] = await db.select().from(Subscription).where(eq(Subscription.userId, userId));
return subscription;
}
export async function getSubscriptionByStripeId(stripeSubscriptionId: string) {
const [subscription] = await db
.select()
.from(Subscription)
.where(eq(Subscription.stripeSubscriptionId, stripeSubscriptionId));
return subscription;
}
export async function updateSubscription(
id: number,
updates: Partial<{
status: string;
stripePriceId: string;
currentPeriodStart: Date;
currentPeriodEnd: Date;
cancelAtPeriodEnd: boolean;
trialEnd: Date;
quantity: number;
}>,
) {
const [subscription] = await db
.update(Subscription)
.set({
...updates,
updatedAt: new Date(),
})
.where(eq(Subscription.id, id))
.returning();
return subscription;
}
export async function createPayment(data: {
subscriptionId: number;
stripePaymentIntentId: string;
amount: number;
currency: string;
status: string;
}) {
const [payment] = await db.insert(Payment).values(data).returning();
return payment;
}
export async function deleteSubscription(id: number) {
await db.delete(Subscription).where(eq(Subscription.id, id));
}

View File

@@ -1,6 +1,45 @@
import { Issue, Project, TimedSession } from "@sprint/shared"; import { Issue, OrganisationMember, Project, TimedSession } from "@sprint/shared";
import { and, desc, eq, isNotNull, isNull } from "drizzle-orm"; import { and, desc, eq, gte, inArray, isNotNull, isNull } from "drizzle-orm";
import { db } from "../client"; import { db } from "../client"; // Import OrganisationMember and gte, inArray for the new query
export async function getOrganisationMemberTimedSessions(organisationId: number, fromDate?: Date) {
// First get all member user IDs for the organisation
const members = await db
.select({ userId: OrganisationMember.userId })
.from(OrganisationMember)
.where(eq(OrganisationMember.organisationId, organisationId));
const userIds = members.map((m) => m.userId);
if (userIds.length === 0) {
return [];
}
// Build the where clause
const conditions = [inArray(TimedSession.userId, userIds)];
if (fromDate) {
conditions.push(gte(TimedSession.createdAt, fromDate));
}
const timedSessions = await db
.select({
id: TimedSession.id,
userId: TimedSession.userId,
issueId: TimedSession.issueId,
timestamps: TimedSession.timestamps,
endedAt: TimedSession.endedAt,
createdAt: TimedSession.createdAt,
issueNumber: Issue.number,
projectKey: Project.key,
})
.from(TimedSession)
.innerJoin(Issue, eq(TimedSession.issueId, Issue.id))
.innerJoin(Project, eq(Issue.projectId, Project.id))
.where(and(...conditions))
.orderBy(desc(TimedSession.createdAt));
return timedSessions;
}
export async function createTimedSession(userId: number, issueId: number) { export async function createTimedSession(userId: number, issueId: number) {
const [timedSession] = await db const [timedSession] = await db

View File

@@ -5,10 +5,14 @@ import { db } from "../client";
export async function createUser( export async function createUser(
name: string, name: string,
username: string, username: string,
email: string,
passwordHash: string, passwordHash: string,
avatarURL?: string | null, avatarURL?: string | null,
) { ) {
const [user] = await db.insert(User).values({ name, username, passwordHash, avatarURL }).returning(); const [user] = await db
.insert(User)
.values({ name, username, email, passwordHash, avatarURL })
.returning();
return user; return user;
} }
@@ -22,6 +26,11 @@ export async function getUserByUsername(username: string) {
return user; return user;
} }
export async function getUserByEmail(email: string) {
const [user] = await db.select().from(User).where(eq(User.email, email));
return user;
}
export async function updateById( export async function updateById(
id: number, id: number,
updates: { updates: {
@@ -29,8 +38,14 @@ export async function updateById(
passwordHash?: string; passwordHash?: string;
avatarURL?: string | null; avatarURL?: string | null;
iconPreference?: IconStyle; iconPreference?: IconStyle;
plan?: string;
}, },
): Promise<UserRecord | undefined> { ): Promise<UserRecord | undefined> {
const [user] = await db.update(User).set(updates).where(eq(User.id, id)).returning(); const [user] = await db.update(User).set(updates).where(eq(User.id, id)).returning();
return user; return user;
} }
export async function updateUser(id: number, updates: { plan?: string }) {
const [user] = await db.update(User).set(updates).where(eq(User.id, id)).returning();
return user;
}

View File

@@ -0,0 +1 @@
export { VerificationCode } from "./templates/VerificationCode";

View File

@@ -0,0 +1,3 @@
export function VerificationCode({ code }: { code: string }) {
return <body>Your sprint verification code is: {code}</body>;
}

View File

@@ -41,6 +41,8 @@ const main = async () => {
"/auth/login": withGlobal(routes.authLogin), "/auth/login": withGlobal(routes.authLogin),
"/auth/logout": withGlobalAuthed(withAuth(withCSRF(routes.authLogout))), "/auth/logout": withGlobalAuthed(withAuth(withCSRF(routes.authLogout))),
"/auth/me": withGlobalAuthed(withAuth(routes.authMe)), "/auth/me": withGlobalAuthed(withAuth(routes.authMe)),
"/auth/verify-email": withGlobalAuthed(withAuth(withCSRF(routes.authVerifyEmail))),
"/auth/resend-verification": withGlobalAuthed(withAuth(withCSRF(routes.authResendVerification))),
"/user/by-username": withGlobalAuthed(withAuth(routes.userByUsername)), "/user/by-username": withGlobalAuthed(withAuth(routes.userByUsername)),
"/user/update": withGlobalAuthed(withAuth(withCSRF(routes.userUpdate))), "/user/update": withGlobalAuthed(withAuth(withCSRF(routes.userUpdate))),
@@ -68,6 +70,9 @@ const main = async () => {
"/organisation/upload-icon": withGlobalAuthed(withAuth(withCSRF(routes.organisationUploadIcon))), "/organisation/upload-icon": withGlobalAuthed(withAuth(withCSRF(routes.organisationUploadIcon))),
"/organisation/add-member": withGlobalAuthed(withAuth(withCSRF(routes.organisationAddMember))), "/organisation/add-member": withGlobalAuthed(withAuth(withCSRF(routes.organisationAddMember))),
"/organisation/members": withGlobalAuthed(withAuth(routes.organisationMembers)), "/organisation/members": withGlobalAuthed(withAuth(routes.organisationMembers)),
"/organisation/member-time-tracking": withGlobalAuthed(
withAuth(routes.organisationMemberTimeTracking),
),
"/organisation/remove-member": withGlobalAuthed( "/organisation/remove-member": withGlobalAuthed(
withAuth(withCSRF(routes.organisationRemoveMember)), withAuth(withCSRF(routes.organisationRemoveMember)),
), ),
@@ -97,6 +102,17 @@ const main = async () => {
"/timer/get": withGlobalAuthed(withAuth(withCSRF(routes.timerGet))), "/timer/get": withGlobalAuthed(withAuth(withCSRF(routes.timerGet))),
"/timer/get-inactive": withGlobalAuthed(withAuth(withCSRF(routes.timerGetInactive))), "/timer/get-inactive": withGlobalAuthed(withAuth(withCSRF(routes.timerGetInactive))),
"/timers": withGlobalAuthed(withAuth(withCSRF(routes.timers))), "/timers": withGlobalAuthed(withAuth(withCSRF(routes.timers))),
// subscription routes - webhook has no auth
"/subscription/create-checkout-session": withGlobalAuthed(
withAuth(withCSRF(routes.subscriptionCreateCheckoutSession)),
),
"/subscription/create-portal-session": withGlobalAuthed(
withAuth(withCSRF(routes.subscriptionCreatePortalSession)),
),
"/subscription/cancel": withGlobalAuthed(withAuth(withCSRF(routes.subscriptionCancel))),
"/subscription/get": withGlobalAuthed(withAuth(routes.subscriptionGet)),
"/subscription/webhook": withGlobal(routes.subscriptionWebhook),
}, },
}); });

View File

@@ -0,0 +1,54 @@
import { render } from "@react-email/render";
import type React from "react";
import { Resend } from "resend";
const resend = new Resend(process.env.RESEND_API_KEY);
const FROM_EMAIL = process.env.EMAIL_FROM || "Sprint <noreply@sprint.app>";
export interface SendEmailOptions {
to: string;
subject: string;
template: React.ReactElement;
from?: string;
}
export async function sendEmail({ to, subject, template, from }: SendEmailOptions) {
const html = await render(template);
const { data, error } = await resend.emails.send({
from: from || FROM_EMAIL,
to,
subject,
html,
});
if (error) {
console.error("Failed to send email:", error);
throw new Error(`Email send failed: ${error.message}`);
}
return data;
}
export async function sendEmailWithRetry(
options: SendEmailOptions,
maxRetries = 3,
): Promise<ReturnType<typeof sendEmail>> {
let lastError: Error | undefined;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await sendEmail(options);
} catch (error) {
lastError = error as Error;
console.warn(`Email send attempt ${attempt} failed:`, error);
if (attempt < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 1000 * 2 ** (attempt - 1)));
}
}
}
throw lastError || new Error("Email send failed after all retries");
}

View File

@@ -0,0 +1,49 @@
import { getOrganisationMembers, getOrganisationsByUserId } from "../db/queries/organisations";
import { getSubscriptionByUserId, updateSubscription } from "../db/queries/subscriptions";
import { getUserById } from "../db/queries/users";
import { stripe } from "../stripe/client";
export async function updateSeatCount(userId: number) {
const user = await getUserById(userId);
// only update if user has active pro subscription
if (!user || user.plan !== "pro") {
return;
}
const subscription = await getSubscriptionByUserId(userId);
if (!subscription || subscription.status !== "active") {
return;
}
// calculate total members across all owned organisations
const organisations = await getOrganisationsByUserId(userId);
const ownedOrgs = organisations.filter((o) => o.OrganisationMember.role === "owner");
let totalMembers = 0;
for (const org of ownedOrgs) {
const members = await getOrganisationMembers(org.Organisation.id);
totalMembers += members.length;
}
const newQuantity = Math.max(1, totalMembers - 5);
// skip if quantity hasn't changed
if (newQuantity === subscription.quantity) {
return;
}
const stripeSubscriptionItemId = subscription.stripeSubscriptionItemId;
if (!stripeSubscriptionItemId) {
return;
}
// update stripe
await stripe.subscriptionItems.update(stripeSubscriptionItemId, {
quantity: newQuantity,
proration_behavior: "always_invoice",
});
// update local record
await updateSubscription(subscription.id, { quantity: newQuantity });
}

View File

@@ -39,6 +39,7 @@ export default async function login(req: BunRequest) {
username: user.username, username: user.username,
avatarURL: user.avatarURL, avatarURL: user.avatarURL,
iconPreference: user.iconPreference, iconPreference: user.iconPreference,
emailVerified: user.emailVerified,
}, },
csrfToken: session.csrfToken, csrfToken: session.csrfToken,
}), }),

View File

@@ -13,5 +13,6 @@ export default async function me(req: AuthedRequest) {
return Response.json({ return Response.json({
user: safeUser as Omit<UserRecord, "passwordHash">, user: safeUser as Omit<UserRecord, "passwordHash">,
csrfToken: req.csrfToken, csrfToken: req.csrfToken,
emailVerified: user.emailVerified,
}); });
} }

View File

@@ -1,7 +1,10 @@
import { RegisterRequestSchema } from "@sprint/shared"; import { RegisterRequestSchema } from "@sprint/shared";
import type { BunRequest } from "bun"; import type { BunRequest } from "bun";
import { buildAuthCookie, generateToken, hashPassword } from "../../auth/utils"; import { buildAuthCookie, generateToken, hashPassword } from "../../auth/utils";
import { createSession, createUser, getUserByUsername } from "../../db/queries"; import { createSession, createUser, createVerificationCode, getUserByUsername } from "../../db/queries";
import { getUserByEmail } from "../../db/queries/users";
import { VerificationCode } from "../../emails";
import { sendEmailWithRetry } from "../../lib/email/service";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
export default async function register(req: BunRequest) { export default async function register(req: BunRequest) {
@@ -12,15 +15,20 @@ export default async function register(req: BunRequest) {
const parsed = await parseJsonBody(req, RegisterRequestSchema); const parsed = await parseJsonBody(req, RegisterRequestSchema);
if ("error" in parsed) return parsed.error; if ("error" in parsed) return parsed.error;
const { name, username, password, avatarURL } = parsed.data; const { name, username, email, password, avatarURL } = parsed.data;
const existing = await getUserByUsername(username); const existingUsername = await getUserByUsername(username);
if (existing) { if (existingUsername) {
return errorResponse("username already taken", "USERNAME_TAKEN", 400); return errorResponse("username already taken", "USERNAME_TAKEN", 400);
} }
const existingEmail = await getUserByEmail(email);
if (existingEmail) {
return errorResponse("email already registered", "EMAIL_TAKEN", 400);
}
const passwordHash = await hashPassword(password); const passwordHash = await hashPassword(password);
const user = await createUser(name, username, passwordHash, avatarURL); const user = await createUser(name, username, email, passwordHash, avatarURL);
if (!user) { if (!user) {
return errorResponse("failed to create user", "USER_CREATE_ERROR", 500); return errorResponse("failed to create user", "USER_CREATE_ERROR", 500);
} }
@@ -30,6 +38,19 @@ export default async function register(req: BunRequest) {
return errorResponse("failed to create session", "SESSION_ERROR", 500); return errorResponse("failed to create session", "SESSION_ERROR", 500);
} }
const verification = await createVerificationCode(user.id);
try {
await sendEmailWithRetry({
to: user.email,
subject: "Verify your Sprint account",
template: VerificationCode({ code: verification.code }),
});
} catch (error) {
console.error("Failed to send verification email:", error);
// don't fail registration if email fails - user can resend
}
const token = generateToken(session.id, user.id); const token = generateToken(session.id, user.id);
return new Response( return new Response(
@@ -40,6 +61,7 @@ export default async function register(req: BunRequest) {
username: user.username, username: user.username,
avatarURL: user.avatarURL, avatarURL: user.avatarURL,
iconPreference: user.iconPreference, iconPreference: user.iconPreference,
emailVerified: user.emailVerified,
}, },
csrfToken: session.csrfToken, csrfToken: session.csrfToken,
}), }),

View File

@@ -0,0 +1,69 @@
import type { BunRequest } from "bun";
import type { AuthedRequest } from "../../auth/middleware";
import { createVerificationCode } from "../../db/queries";
import { getUserById } from "../../db/queries/users";
import { VerificationCode } from "../../emails";
import { sendEmailWithRetry } from "../../lib/email/service";
import { errorResponse } from "../../validation";
const resendAttempts = new Map<number, number[]>();
const MAX_RESENDS_PER_HOUR = 3;
const HOUR_IN_MS = 60 * 60 * 1000;
function canResend(userId: number): boolean {
const now = Date.now();
const attempts = resendAttempts.get(userId) || [];
const recentAttempts = attempts.filter((time) => now - time < HOUR_IN_MS);
if (recentAttempts.length >= MAX_RESENDS_PER_HOUR) {
return false;
}
recentAttempts.push(now);
resendAttempts.set(userId, recentAttempts);
return true;
}
export default async function resendVerification(req: BunRequest | AuthedRequest) {
if (req.method !== "POST") {
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
}
const authedReq = req as AuthedRequest;
if (!authedReq.userId) {
return errorResponse("unauthorized", "UNAUTHORIZED", 401);
}
if (!canResend(authedReq.userId)) {
return errorResponse("too many resend attempts. please try again later", "RATE_LIMITED", 429);
}
const user = await getUserById(authedReq.userId);
if (!user) {
return errorResponse("user not found", "USER_NOT_FOUND", 404);
}
if (user.emailVerified) {
return errorResponse("email already verified", "ALREADY_VERIFIED", 400);
}
const verification = await createVerificationCode(user.id);
try {
await sendEmailWithRetry({
to: user.email,
subject: "Verify your Sprint account",
template: VerificationCode({ code: verification.code }),
});
} catch (error) {
console.error("Failed to send verification email:", error);
return errorResponse("failed to send verification email", "EMAIL_SEND_FAILED", 500);
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}

View File

@@ -0,0 +1,32 @@
import { VerifyEmailRequestSchema } from "@sprint/shared";
import type { BunRequest } from "bun";
import type { AuthedRequest } from "../../auth/middleware";
import { verifyCode } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation";
export default async function verifyEmail(req: BunRequest | AuthedRequest) {
if (req.method !== "POST") {
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
}
const authedReq = req as AuthedRequest;
if (!authedReq.userId) {
return errorResponse("unauthorized", "UNAUTHORIZED", 401);
}
const parsed = await parseJsonBody(req, VerifyEmailRequestSchema);
if ("error" in parsed) return parsed.error;
const { code } = parsed.data;
const result = await verifyCode(authedReq.userId, code);
if (!result.success) {
return errorResponse(result.error || "verification failed", "VERIFICATION_FAILED", 400);
}
return new Response(JSON.stringify({ success: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}

View File

@@ -2,6 +2,8 @@ import authLogin from "./auth/login";
import authLogout from "./auth/logout"; import authLogout from "./auth/logout";
import authMe from "./auth/me"; import authMe from "./auth/me";
import authRegister from "./auth/register"; import authRegister from "./auth/register";
import authResendVerification from "./auth/resend-verification";
import authVerifyEmail from "./auth/verify-email";
import issueById from "./issue/by-id"; import issueById from "./issue/by-id";
import issueCreate from "./issue/create"; import issueCreate from "./issue/create";
import issueDelete from "./issue/delete"; import issueDelete from "./issue/delete";
@@ -20,6 +22,7 @@ import organisationById from "./organisation/by-id";
import organisationsByUser from "./organisation/by-user"; import organisationsByUser from "./organisation/by-user";
import organisationCreate from "./organisation/create"; import organisationCreate from "./organisation/create";
import organisationDelete from "./organisation/delete"; import organisationDelete from "./organisation/delete";
import organisationMemberTimeTracking from "./organisation/member-time-tracking";
import organisationMembers from "./organisation/members"; import organisationMembers from "./organisation/members";
import organisationRemoveMember from "./organisation/remove-member"; import organisationRemoveMember from "./organisation/remove-member";
import organisationUpdate from "./organisation/update"; import organisationUpdate from "./organisation/update";
@@ -37,6 +40,11 @@ import sprintCreate from "./sprint/create";
import sprintDelete from "./sprint/delete"; import sprintDelete from "./sprint/delete";
import sprintUpdate from "./sprint/update"; import sprintUpdate from "./sprint/update";
import sprintsByProject from "./sprints/by-project"; import sprintsByProject from "./sprints/by-project";
import subscriptionCancel from "./subscription/cancel";
import subscriptionCreateCheckoutSession from "./subscription/create-checkout-session";
import subscriptionCreatePortalSession from "./subscription/create-portal-session";
import subscriptionGet from "./subscription/get";
import subscriptionWebhook from "./subscription/webhook";
import timerEnd from "./timer/end"; import timerEnd from "./timer/end";
import timerGet from "./timer/get"; import timerGet from "./timer/get";
import timerGetInactive from "./timer/get-inactive"; import timerGetInactive from "./timer/get-inactive";
@@ -51,6 +59,8 @@ export const routes = {
authLogin, authLogin,
authLogout, authLogout,
authMe, authMe,
authVerifyEmail,
authResendVerification,
userByUsername, userByUsername,
userUpdate, userUpdate,
@@ -77,6 +87,7 @@ export const routes = {
organisationUpdate, organisationUpdate,
organisationDelete, organisationDelete,
organisationAddMember, organisationAddMember,
organisationMemberTimeTracking,
organisationMembers, organisationMembers,
organisationRemoveMember, organisationRemoveMember,
organisationUpdateMemberRole, organisationUpdateMemberRole,
@@ -104,4 +115,10 @@ export const routes = {
timerGetInactive, timerGetInactive,
timerEnd, timerEnd,
timers, timers,
subscriptionCreateCheckoutSession,
subscriptionCreatePortalSession,
subscriptionCancel,
subscriptionGet,
subscriptionWebhook,
}; };

View File

@@ -1,6 +1,13 @@
import { IssueCreateRequestSchema } from "@sprint/shared"; import { IssueCreateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { createIssue, getOrganisationMemberRole, getProjectByID } from "../../db/queries"; import {
createIssue,
FREE_TIER_LIMITS,
getOrganisationIssueCount,
getOrganisationMemberRole,
getProjectByID,
getUserById,
} from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
export default async function issueCreate(req: AuthedRequest) { export default async function issueCreate(req: AuthedRequest) {
@@ -26,6 +33,19 @@ export default async function issueCreate(req: AuthedRequest) {
); );
} }
// check free tier limit
const user = await getUserById(req.userId);
if (user && user.plan !== "pro") {
const issueCount = await getOrganisationIssueCount(project.organisationId);
if (issueCount >= FREE_TIER_LIMITS.issuesPerOrganisation) {
return errorResponse(
`free tier is limited to ${FREE_TIER_LIMITS.issuesPerOrganisation} issues per organisation. upgrade to pro for unlimited issues.`,
"FREE_TIER_ISSUE_LIMIT",
403,
);
}
}
const issue = await createIssue( const issue = await createIssue(
project.id, project.id,
title, title,

View File

@@ -2,10 +2,13 @@ import { OrgAddMemberRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { import {
createOrganisationMember, createOrganisationMember,
FREE_TIER_LIMITS,
getOrganisationById, getOrganisationById,
getOrganisationMemberRole, getOrganisationMemberRole,
getOrganisationMembers,
getUserById, getUserById,
} from "../../db/queries"; } from "../../db/queries";
import { updateSeatCount } from "../../lib/seats";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
export default async function organisationAddMember(req: AuthedRequest) { export default async function organisationAddMember(req: AuthedRequest) {
@@ -38,7 +41,25 @@ export default async function organisationAddMember(req: AuthedRequest) {
return errorResponse("only owners and admins can add members", "PERMISSION_DENIED", 403); return errorResponse("only owners and admins can add members", "PERMISSION_DENIED", 403);
} }
// check free tier member limit
const requester = await getUserById(req.userId);
if (requester && requester.plan !== "pro") {
const members = await getOrganisationMembers(organisationId);
if (members.length >= FREE_TIER_LIMITS.membersPerOrganisation) {
return errorResponse(
`free tier is limited to ${FREE_TIER_LIMITS.membersPerOrganisation} members per organisation. upgrade to pro for unlimited members.`,
"FREE_TIER_MEMBER_LIMIT",
403,
);
}
}
const member = await createOrganisationMember(organisationId, userId, role); const member = await createOrganisationMember(organisationId, userId, role);
// update seat count if the requester is the owner
if (requesterMember.role === "owner") {
await updateSeatCount(req.userId);
}
return Response.json(member); return Response.json(member);
} }

View File

@@ -1,6 +1,12 @@
import { OrgCreateRequestSchema } from "@sprint/shared"; import { OrgCreateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { createOrganisationWithOwner, getOrganisationBySlug } from "../../db/queries"; import {
createOrganisationWithOwner,
FREE_TIER_LIMITS,
getOrganisationBySlug,
getUserById,
getUserOrganisationCount,
} from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
export default async function organisationCreate(req: AuthedRequest) { export default async function organisationCreate(req: AuthedRequest) {
@@ -14,6 +20,19 @@ export default async function organisationCreate(req: AuthedRequest) {
return errorResponse(`organisation with slug "${slug}" already exists`, "SLUG_TAKEN", 409); return errorResponse(`organisation with slug "${slug}" already exists`, "SLUG_TAKEN", 409);
} }
// check free tier limit
const user = await getUserById(req.userId);
if (user && user.plan !== "pro") {
const orgCount = await getUserOrganisationCount(req.userId);
if (orgCount >= FREE_TIER_LIMITS.organisationsPerUser) {
return errorResponse(
`free tier is limited to ${FREE_TIER_LIMITS.organisationsPerUser} organisation. upgrade to pro for unlimited organisations.`,
"FREE_TIER_ORG_LIMIT",
403,
);
}
}
const organisation = await createOrganisationWithOwner(name, slug, req.userId, description); const organisation = await createOrganisationWithOwner(name, slug, req.userId, description);
return Response.json(organisation); return Response.json(organisation);

View File

@@ -0,0 +1,70 @@
import { calculateBreakTimeMs, calculateWorkTimeMs, isTimerRunning } from "@sprint/shared";
import { z } from "zod";
import type { AuthedRequest } from "../../auth/middleware";
import {
getOrganisationById,
getOrganisationMemberRole,
getOrganisationMemberTimedSessions,
getOrganisationOwner,
getUserById,
} from "../../db/queries";
import { errorResponse, parseQueryParams } from "../../validation";
const OrgMemberTimeTrackingQuerySchema = z.object({
organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"),
fromDate: z.coerce.date().optional(),
});
// GET /organisation/member-time-tracking?organisationId=123&fromDate=2024-01-01
export default async function organisationMemberTimeTracking(req: AuthedRequest) {
const url = new URL(req.url);
const parsed = parseQueryParams(url, OrgMemberTimeTrackingQuerySchema);
if ("error" in parsed) return parsed.error;
const { organisationId, fromDate } = parsed.data;
// check organisation exists
const organisation = await getOrganisationById(organisationId);
if (!organisation) {
return errorResponse(`organisation with id ${organisationId} not found`, "ORG_NOT_FOUND", 404);
}
const memberRole = await getOrganisationMemberRole(organisationId, req.userId);
if (!memberRole) {
return errorResponse("you are not a member of this organisation", "NOT_MEMBER", 403);
}
const role = memberRole.role;
if (role !== "owner" && role !== "admin") {
return errorResponse("you must be an owner or admin to view member time tracking", "FORBIDDEN", 403);
}
// check if organisation owner has pro subscription
const owner = await getOrganisationOwner(organisationId);
const ownerUser = owner ? await getUserById(owner.userId) : null;
const isPro = ownerUser?.plan === "pro";
const sessions = await getOrganisationMemberTimedSessions(organisationId, fromDate);
const enriched = sessions.map((session) => {
const timestamps = session.timestamps.map((t) => new Date(t));
const actualWorkTimeMs = calculateWorkTimeMs(timestamps);
const actualBreakTimeMs = calculateBreakTimeMs(timestamps);
return {
id: session.id,
userId: session.userId,
issueId: session.issueId,
issueNumber: session.issueNumber,
projectKey: session.projectKey,
timestamps: isPro ? session.timestamps : [],
endedAt: isPro ? session.endedAt : null,
createdAt: isPro ? session.createdAt : null,
workTimeMs: isPro ? actualWorkTimeMs : 0,
breakTimeMs: isPro ? actualBreakTimeMs : 0,
isRunning: isPro ? session.endedAt === null && isTimerRunning(timestamps) : false,
};
});
return Response.json(enriched);
}

View File

@@ -1,6 +1,7 @@
import { OrgRemoveMemberRequestSchema } from "@sprint/shared"; import { OrgRemoveMemberRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { getOrganisationById, getOrganisationMemberRole, removeOrganisationMember } from "../../db/queries"; import { getOrganisationById, getOrganisationMemberRole, removeOrganisationMember } from "../../db/queries";
import { updateSeatCount } from "../../lib/seats";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
export default async function organisationRemoveMember(req: AuthedRequest) { export default async function organisationRemoveMember(req: AuthedRequest) {
@@ -34,5 +35,10 @@ export default async function organisationRemoveMember(req: AuthedRequest) {
await removeOrganisationMember(organisationId, userId); await removeOrganisationMember(organisationId, userId);
// update seat count if the requester is the owner
if (requesterMember.role === "owner") {
await updateSeatCount(req.userId);
}
return Response.json({ success: true }); return Response.json({ success: true });
} }

View File

@@ -1,6 +1,11 @@
import { OrgUpdateRequestSchema } from "@sprint/shared"; import { OrgUpdateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { getOrganisationById, getOrganisationMemberRole, updateOrganisation } from "../../db/queries"; import {
getOrganisationById,
getOrganisationMemberRole,
getSubscriptionByUserId,
updateOrganisation,
} from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
export default async function organisationUpdate(req: AuthedRequest) { export default async function organisationUpdate(req: AuthedRequest) {
@@ -22,6 +27,19 @@ export default async function organisationUpdate(req: AuthedRequest) {
return errorResponse("only owners and admins can edit organisations", "PERMISSION_DENIED", 403); return errorResponse("only owners and admins can edit organisations", "PERMISSION_DENIED", 403);
} }
// block free users from updating features
if (features !== undefined) {
const subscription = await getSubscriptionByUserId(req.userId);
const isPro = subscription?.status === "active";
if (!isPro) {
return errorResponse(
"Feature toggling is only available on Pro. Upgrade to customize features.",
"FEATURE_TOGGLE_PRO_ONLY",
403,
);
}
}
if (!name && !description && !slug && !statuses && !features && !issueTypes && iconURL === undefined) { if (!name && !description && !slug && !statuses && !features && !issueTypes && iconURL === undefined) {
return errorResponse( return errorResponse(
"at least one of name, description, slug, iconURL, statuses, issueTypes, or features must be provided", "at least one of name, description, slug, iconURL, statuses, issueTypes, or features must be provided",

View File

@@ -1,6 +1,13 @@
import { ProjectCreateRequestSchema } from "@sprint/shared"; import { ProjectCreateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { createProject, getOrganisationMemberRole, getProjectByKey, getUserById } from "../../db/queries"; import {
createProject,
FREE_TIER_LIMITS,
getOrganisationMemberRole,
getOrganisationProjectCount,
getProjectByKey,
getUserById,
} from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
export default async function projectCreate(req: AuthedRequest) { export default async function projectCreate(req: AuthedRequest) {
@@ -22,7 +29,19 @@ export default async function projectCreate(req: AuthedRequest) {
return errorResponse("only owners and admins can create projects", "PERMISSION_DENIED", 403); return errorResponse("only owners and admins can create projects", "PERMISSION_DENIED", 403);
} }
// check free tier limit
const creator = await getUserById(req.userId); const creator = await getUserById(req.userId);
if (creator && creator.plan !== "pro") {
const projectCount = await getOrganisationProjectCount(organisationId);
if (projectCount >= FREE_TIER_LIMITS.projectsPerOrganisation) {
return errorResponse(
`free tier is limited to ${FREE_TIER_LIMITS.projectsPerOrganisation} project per organisation. upgrade to pro for unlimited projects.`,
"FREE_TIER_PROJECT_LIMIT",
403,
);
}
}
if (!creator) { if (!creator) {
return errorResponse(`creator not found`, "CREATOR_NOT_FOUND", 404); return errorResponse(`creator not found`, "CREATOR_NOT_FOUND", 404);
} }

View File

@@ -2,8 +2,11 @@ import { SprintCreateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { import {
createSprint, createSprint,
FREE_TIER_LIMITS,
getOrganisationMemberRole, getOrganisationMemberRole,
getProjectByID, getProjectByID,
getProjectSprintCount,
getSubscriptionByUserId,
hasOverlappingSprints, hasOverlappingSprints,
} from "../../db/queries"; } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
@@ -28,6 +31,20 @@ export default async function sprintCreate(req: AuthedRequest) {
return errorResponse("Only owners and admins can create sprints", "PERMISSION_DENIED", 403); return errorResponse("Only owners and admins can create sprints", "PERMISSION_DENIED", 403);
} }
// check free tier sprint limit
const subscription = await getSubscriptionByUserId(req.userId);
const isPro = subscription?.status === "active";
if (!isPro) {
const sprintCount = await getProjectSprintCount(projectId);
if (sprintCount >= FREE_TIER_LIMITS.sprintsPerProject) {
return errorResponse(
`Free tier limited to ${FREE_TIER_LIMITS.sprintsPerProject} sprints per project. Upgrade to Pro for unlimited sprints.`,
"SPRINT_LIMIT_REACHED",
403,
);
}
}
const start = new Date(startDate); const start = new Date(startDate);
const end = new Date(endDate); const end = new Date(endDate);

View File

@@ -0,0 +1,68 @@
import { type AuthedRequest, withAuth, withCors, withCSRF } from "../../auth/middleware";
import { getSubscriptionByUserId, updateSubscription } from "../../db/queries/subscriptions";
import { stripe } from "../../stripe/client";
import { errorResponse } from "../../validation";
async function handler(req: AuthedRequest) {
if (req.method !== "POST") {
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
}
try {
const { userId } = req;
const subscription = await getSubscriptionByUserId(userId);
if (!subscription?.stripeSubscriptionId) {
return errorResponse("no active subscription found", "NOT_FOUND", 404);
}
const stripeCurrent = (await stripe.subscriptions.retrieve(
subscription.stripeSubscriptionId,
)) as unknown as {
status: string;
cancel_at_period_end: boolean | null;
current_period_end: number | null;
};
const currentPeriodEnd = stripeCurrent.current_period_end
? new Date(stripeCurrent.current_period_end * 1000)
: undefined;
if (stripeCurrent.status === "canceled" || stripeCurrent.cancel_at_period_end) {
const updated = await updateSubscription(subscription.id, {
status: stripeCurrent.status,
cancelAtPeriodEnd: stripeCurrent.cancel_at_period_end ?? subscription.cancelAtPeriodEnd,
...(currentPeriodEnd && { currentPeriodEnd }),
});
return new Response(JSON.stringify({ subscription: updated }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
const stripeSubscription = (await stripe.subscriptions.update(subscription.stripeSubscriptionId, {
cancel_at_period_end: true,
})) as unknown as {
status: string;
cancel_at_period_end: boolean | null;
current_period_end: number | null;
};
const updated = await updateSubscription(subscription.id, {
status: stripeSubscription.status,
cancelAtPeriodEnd: stripeSubscription.cancel_at_period_end ?? true,
currentPeriodEnd: stripeSubscription.current_period_end
? new Date(stripeSubscription.current_period_end * 1000)
: undefined,
});
return new Response(JSON.stringify({ subscription: updated }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("subscription cancel error:", error);
return errorResponse("failed to cancel subscription", "CANCEL_ERROR", 500);
}
}
export default withCors(withAuth(withCSRF(handler)));

View File

@@ -0,0 +1,78 @@
import { type AuthedRequest, withAuth, withCors, withCSRF } from "../../auth/middleware";
import { getOrganisationMembers, getOrganisationsByUserId } from "../../db/queries/organisations";
import { getUserById } from "../../db/queries/users";
import { STRIPE_PRICE_ANNUAL, STRIPE_PRICE_MONTHLY, stripe } from "../../stripe/client";
import { errorResponse } from "../../validation";
const BASE_URL = process.env.FRONTEND_URL || "http://localhost:1420";
async function handler(req: AuthedRequest) {
if (req.method !== "POST") {
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
}
try {
const body = await req.json();
const { billingPeriod } = body as { billingPeriod: "monthly" | "annual" | undefined };
if (!billingPeriod) {
return errorResponse("missing required fields", "VALIDATION_ERROR", 400);
}
const { userId } = req;
const user = await getUserById(userId);
if (!user) {
return errorResponse("user not found", "NOT_FOUND", 404);
}
// calculate seat quantity across all owned organisations
const organisations = await getOrganisationsByUserId(userId);
const ownedOrgs = organisations.filter((o) => o.OrganisationMember.role === "owner");
let totalMembers = 0;
for (const org of ownedOrgs) {
const members = await getOrganisationMembers(org.Organisation.id);
totalMembers += members.length;
}
const quantity = Math.max(1, totalMembers - 5);
const priceId = billingPeriod === "annual" ? STRIPE_PRICE_ANNUAL : STRIPE_PRICE_MONTHLY;
// use the user's email from the database
const customerEmail = user.email;
const session = await stripe.checkout.sessions.create({
customer_email: customerEmail,
line_items: [
{
price: priceId,
quantity: quantity,
},
],
mode: "subscription",
success_url: `${BASE_URL}/plans?success=true&session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${BASE_URL}/plans?canceled=true`,
subscription_data: {
metadata: {
userId: userId.toString(),
},
},
metadata: {
userId: userId.toString(),
priceId: priceId,
quantity: quantity.toString(),
},
});
return new Response(JSON.stringify({ url: session.url }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("checkout session error:", error);
return errorResponse("failed to create checkout session", "CHECKOUT_ERROR", 500);
}
}
export default withCors(withAuth(withCSRF(handler)));

View File

@@ -0,0 +1,35 @@
import { type AuthedRequest, withAuth, withCors, withCSRF } from "../../auth/middleware";
import { getSubscriptionByUserId } from "../../db/queries/subscriptions";
import { stripe } from "../../stripe/client";
import { errorResponse } from "../../validation";
const BASE_URL = process.env.FRONTEND_URL || "http://localhost:1420";
async function handler(req: AuthedRequest) {
if (req.method !== "POST") {
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
}
try {
const { userId } = req;
const subscription = await getSubscriptionByUserId(userId);
if (!subscription?.stripeCustomerId) {
return errorResponse("no active subscription found", "NOT_FOUND", 404);
}
const portalSession = await stripe.billingPortal.sessions.create({
customer: subscription.stripeCustomerId,
return_url: `${BASE_URL}/plans`,
});
return new Response(JSON.stringify({ url: portalSession.url }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("portal session error:", error);
return errorResponse("failed to create portal session", "PORTAL_ERROR", 500);
}
}
export default withCors(withAuth(withCSRF(handler)));

View File

@@ -0,0 +1,24 @@
import { type AuthedRequest, withAuth, withCors } from "../../auth/middleware";
import { getSubscriptionByUserId } from "../../db/queries/subscriptions";
import { errorResponse } from "../../validation";
async function handler(req: AuthedRequest) {
if (req.method !== "GET") {
return errorResponse("method not allowed", "METHOD_NOT_ALLOWED", 405);
}
try {
const { userId } = req;
const subscription = await getSubscriptionByUserId(userId);
return new Response(JSON.stringify({ subscription }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("fetch subscription error:", error);
return errorResponse("failed to fetch subscription", "FETCH_ERROR", 500);
}
}
export default withCors(withAuth(handler));

View File

@@ -0,0 +1,213 @@
import type { BunRequest } from "bun";
import type Stripe from "stripe";
import {
createPayment,
createSubscription,
getSubscriptionByStripeId,
updateSubscription,
} from "../../db/queries/subscriptions";
import { updateUser } from "../../db/queries/users";
import { stripe } from "../../stripe/client";
const webhookSecret = requireEnv("STRIPE_WEBHOOK_SECRET");
function toStripeDate(seconds: number | null | undefined, field: string) {
if (seconds === null || seconds === undefined) return undefined;
if (!Number.isFinite(seconds)) {
console.warn(`invalid ${field} timestamp:`, seconds);
return undefined;
}
return new Date(seconds * 1000);
}
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}
export default async function webhook(req: BunRequest) {
if (req.method !== "POST") {
return new Response("Method not allowed", { status: 405 });
}
const payload = await req.text();
const signature = req.headers.get("stripe-signature");
if (!signature) {
return new Response("Missing signature", { status: 400 });
}
let event: Stripe.Event;
try {
// use async version for Bun compatibility
event = await stripe.webhooks.constructEventAsync(payload, signature, webhookSecret);
} catch (err) {
console.error("webhook signature verification failed:", err);
return new Response("Invalid signature", { status: 400 });
}
try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
if (session.mode !== "subscription" || !session.subscription) {
break;
}
const userId = parseInt(session.metadata?.userId || "0", 10);
if (!userId) {
console.error("missing userId in session metadata");
break;
}
// fetch full subscription to get item id
const stripeSubscription = await stripe.subscriptions.retrieve(
session.subscription as string,
);
if (!stripeSubscription) {
console.error("failed to retrieve subscription:", session.subscription);
break;
}
if (!stripeSubscription.items.data[0]) {
console.error("subscription has no items:", stripeSubscription.id);
break;
}
// stripe types use snake_case for these fields
const sub = stripeSubscription as unknown as {
current_period_start: number | null;
current_period_end: number | null;
trial_end: number | null;
};
await createSubscription({
userId,
stripeCustomerId: session.customer as string,
stripeSubscriptionId: stripeSubscription.id,
stripeSubscriptionItemId: stripeSubscription.items.data[0].id,
stripePriceId: session.metadata?.priceId || "",
status: stripeSubscription.status,
quantity: parseInt(session.metadata?.quantity || "1", 10),
currentPeriodStart: toStripeDate(sub.current_period_start, "current_period_start"),
currentPeriodEnd: toStripeDate(sub.current_period_end, "current_period_end"),
trialEnd: toStripeDate(sub.trial_end, "trial_end"),
});
await updateUser(userId, { plan: "pro" });
console.log(`subscription activated for user ${userId}`);
break;
}
case "customer.subscription.updated": {
const subscription = event.data.object as Stripe.Subscription;
if (!subscription) {
console.error("failed to retrieve subscription (customer.subscription.updated)");
break;
}
if (!subscription.items.data[0]) {
console.error("subscription has no items:", subscription.id);
break;
}
const localSub = await getSubscriptionByStripeId(subscription.id);
if (!localSub) {
console.error("subscription not found:", subscription.id);
break;
}
// safely convert timestamps to dates
// stripe types use snake_case for these fields
const sub = subscription as unknown as {
current_period_start: number | null;
current_period_end: number | null;
};
const currentPeriodStart = toStripeDate(sub.current_period_start, "current_period_start");
const currentPeriodEnd = toStripeDate(sub.current_period_end, "current_period_end");
await updateSubscription(localSub.id, {
status: subscription.status,
...(currentPeriodStart && { currentPeriodStart }),
...(currentPeriodEnd && { currentPeriodEnd }),
cancelAtPeriodEnd: subscription.cancel_at_period_end,
quantity: subscription.items.data[0].quantity || 1,
});
console.log(`subscription updated: ${subscription.id}`);
break;
}
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
const localSub = await getSubscriptionByStripeId(subscription.id);
if (!localSub) break;
// delete subscription from database
const { deleteSubscription } = await import("../../db/queries/subscriptions");
await deleteSubscription(localSub.id);
await updateUser(localSub.userId, { plan: "free" });
console.log(`subscription deleted: ${subscription.id}`);
break;
}
case "invoice.payment_succeeded": {
const invoice = event.data.object as Stripe.Invoice;
// stripe types use snake_case for these fields
const inv = invoice as unknown as {
subscription: string | null;
payment_intent: string | null;
};
if (!inv.subscription) break;
const localSub = await getSubscriptionByStripeId(inv.subscription);
if (!localSub) break;
await createPayment({
subscriptionId: localSub.id,
stripePaymentIntentId: inv.payment_intent || "",
amount: invoice.amount_paid,
currency: invoice.currency,
status: "succeeded",
});
console.log(`payment recorded for subscription ${inv.subscription}`);
break;
}
case "invoice.payment_failed": {
const invoice = event.data.object as Stripe.Invoice;
// stripe types use snake_case for these fields
const inv = invoice as unknown as {
subscription: string | null;
};
if (!inv.subscription) break;
const localSub = await getSubscriptionByStripeId(inv.subscription);
if (!localSub) break;
await updateSubscription(localSub.id, { status: "past_due" });
console.log(`payment failed for subscription ${inv.subscription}`);
break;
}
}
return new Response(JSON.stringify({ received: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
} catch (error) {
console.error("webhook processing error:", error);
return new Response("Webhook handler failed", { status: 500 });
}
}

View File

@@ -1,7 +1,7 @@
import { UserUpdateRequestSchema } from "@sprint/shared"; import { UserUpdateRequestSchema } from "@sprint/shared";
import type { AuthedRequest } from "../../auth/middleware"; import type { AuthedRequest } from "../../auth/middleware";
import { hashPassword } from "../../auth/utils"; import { hashPassword } from "../../auth/utils";
import { getUserById } from "../../db/queries"; import { getSubscriptionByUserId, getUserById } from "../../db/queries";
import { errorResponse, parseJsonBody } from "../../validation"; import { errorResponse, parseJsonBody } from "../../validation";
export default async function update(req: AuthedRequest) { export default async function update(req: AuthedRequest) {
@@ -23,6 +23,19 @@ export default async function update(req: AuthedRequest) {
); );
} }
// block free users from changing icon preference
if (iconPreference !== undefined && iconPreference !== user.iconPreference) {
const subscription = await getSubscriptionByUserId(req.userId);
const isPro = subscription?.status === "active";
if (!isPro) {
return errorResponse(
"icon style customization is only available on Pro. Upgrade to customize your icon style.",
"ICON_STYLE_PRO_ONLY",
403,
);
}
}
let passwordHash: string | undefined; let passwordHash: string | undefined;
if (password !== undefined) { if (password !== undefined) {
passwordHash = await hashPassword(password); passwordHash = await hashPassword(password);

View File

@@ -1,13 +1,23 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { BunRequest } from "bun";
import sharp from "sharp"; import sharp from "sharp";
import type { AuthedRequest } from "../../auth/middleware";
import { getSubscriptionByUserId } from "../../db/queries";
import { s3Client, s3Endpoint, s3PublicUrl } from "../../s3"; import { s3Client, s3Endpoint, s3PublicUrl } from "../../s3";
const MAX_FILE_SIZE = 5 * 1024 * 1024; const MAX_FILE_SIZE = 5 * 1024 * 1024;
const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"]; const ALLOWED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
const TARGET_SIZE = 256; const TARGET_SIZE = 256;
export default async function uploadAvatar(req: BunRequest) { async function isAnimatedGIF(buffer: Buffer): Promise<boolean> {
try {
const metadata = await sharp(buffer).metadata();
return metadata.pages !== undefined && metadata.pages > 1;
} catch {
return false;
}
}
export default async function uploadAvatar(req: AuthedRequest) {
if (req.method !== "POST") { if (req.method !== "POST") {
return new Response("method not allowed", { status: 405 }); return new Response("method not allowed", { status: 405 });
} }
@@ -29,14 +39,31 @@ export default async function uploadAvatar(req: BunRequest) {
}); });
} }
const inputBuffer = Buffer.from(await file.arrayBuffer());
// check if user is pro
const subscription = await getSubscriptionByUserId(req.userId);
const isPro = subscription?.status === "active";
// block animated avatars for free users
if (!isPro && file.type === "image/gif") {
const animated = await isAnimatedGIF(inputBuffer);
if (animated) {
return new Response(
JSON.stringify({
error: "Animated avatars are only available on Pro. Upgrade to upload animated avatars.",
}),
{ status: 403, headers: { "Content-Type": "application/json" } },
);
}
}
const isGIF = file.type === "image/gif"; const isGIF = file.type === "image/gif";
const outputExtension = isGIF ? "gif" : "png"; const outputExtension = isGIF ? "gif" : "png";
const outputMimeType = isGIF ? "image/gif" : "image/png"; const outputMimeType = isGIF ? "image/gif" : "image/png";
let resizedBuffer: Buffer; let resizedBuffer: Buffer;
try { try {
const inputBuffer = Buffer.from(await file.arrayBuffer());
if (isGIF) { if (isGIF) {
resizedBuffer = await sharp(inputBuffer, { animated: true }) resizedBuffer = await sharp(inputBuffer, { animated: true })
.resize(TARGET_SIZE, TARGET_SIZE, { fit: "cover" }) .resize(TARGET_SIZE, TARGET_SIZE, { fit: "cover" })

View File

@@ -0,0 +1,18 @@
import Stripe from "stripe";
const stripeSecretKey = requireEnv("STRIPE_SECRET_KEY");
export const stripe = new Stripe(stripeSecretKey, {
apiVersion: "2025-12-15.clover",
});
export const STRIPE_PRICE_MONTHLY = requireEnv("STRIPE_PRICE_MONTHLY");
export const STRIPE_PRICE_ANNUAL = requireEnv("STRIPE_PRICE_ANNUAL");
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`${name} is required`);
}
return value;
}

View File

@@ -11,9 +11,9 @@
}, },
"dependencies": { "dependencies": {
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@ts-rest/core": "^3.52.1",
"@nsmr/pixelart-react": "^2.0.0", "@nsmr/pixelart-react": "^2.0.0",
"@phosphor-icons/react": "^2.1.10", "@phosphor-icons/react": "^2.1.10",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8", "@radix-ui/react-label": "^2.1.8",
@@ -25,33 +25,35 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@sprint/shared": "workspace:*", "@sprint/shared": "workspace:*",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@tanstack/react-query": "^5.90.19", "@tanstack/react-query": "^5.90.20",
"@tanstack/react-query-devtools": "^5.91.2", "@tanstack/react-query-devtools": "^5.91.2",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2.5.3",
"@ts-rest/core": "^3.52.1",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.561.0", "lucide-react": "^0.561.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "^19.1.0", "react": "19.2.4",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-day-picker": "^9.13.0", "react-day-picker": "^9.13.0",
"react-dom": "^19.1.0", "react-dom": "19.2.4",
"react-resizable-panels": "^4.0.15", "react-resizable-panels": "^4.5.3",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.13.0",
"sonner": "^2.0.7", "sonner": "^2.0.7",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.1.18" "tailwindcss": "^4.1.18"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2", "@tauri-apps/cli": "^2.9.6",
"@types/node": "^25.0.1", "@types/node": "^25.1.0",
"@types/react": "^19.1.8", "@types/react": "^19.2.10",
"@types/react-dom": "^19.1.6", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.7.0",
"tw-animate-css": "^1.4.0", "tw-animate-css": "^1.4.0",
"typescript": "~5.8.3", "typescript": "~5.8.3",
"vite": "^7.0.4" "vite": "^7.3.1"
} }
} }

View File

@@ -1,6 +1,7 @@
import type { IconStyle } from "@sprint/shared"; import type { IconStyle } from "@sprint/shared";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAuthenticatedSession } from "@/components/session-provider"; import { useAuthenticatedSession } from "@/components/session-provider";
import ThemeToggle from "@/components/theme-toggle"; import ThemeToggle from "@/components/theme-toggle";
@@ -15,6 +16,9 @@ import { useUpdateUser } from "@/lib/query/hooks";
import { parseError } from "@/lib/server"; import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
// icon style is locked to pixel for free users
const DEFAULT_ICON_STYLE: IconStyle = "pixel";
function Account({ trigger }: { trigger?: ReactNode }) { function Account({ trigger }: { trigger?: ReactNode }) {
const { user: currentUser, setUser } = useAuthenticatedSession(); const { user: currentUser, setUser } = useAuthenticatedSession();
const updateUser = useUpdateUser(); const updateUser = useUpdateUser();
@@ -34,7 +38,12 @@ function Account({ trigger }: { trigger?: ReactNode }) {
setName(currentUser.name); setName(currentUser.name);
setUsername(currentUser.username); setUsername(currentUser.username);
setAvatarUrl(currentUser.avatarURL || null); setAvatarUrl(currentUser.avatarURL || null);
setIconPreference((currentUser.iconPreference as IconStyle) ?? "pixel"); // free users are locked to pixel icon style
const effectiveIconStyle =
currentUser.plan === "pro"
? ((currentUser.iconPreference as IconStyle) ?? DEFAULT_ICON_STYLE)
: DEFAULT_ICON_STYLE;
setIconPreference(effectiveIconStyle);
setPassword(""); setPassword("");
setError(""); setError("");
@@ -50,11 +59,13 @@ function Account({ trigger }: { trigger?: ReactNode }) {
} }
try { try {
// only send iconPreference for pro users
const effectiveIconPreference = currentUser.plan === "pro" ? iconPreference : undefined;
const data = await updateUser.mutateAsync({ const data = await updateUser.mutateAsync({
name: name.trim(), name: name.trim(),
password: password.trim() || undefined, password: password.trim() || undefined,
avatarURL, avatarURL,
iconPreference, iconPreference: effectiveIconPreference,
}); });
setError(""); setError("");
setUser(data); setUser(data);
@@ -130,9 +141,22 @@ function Account({ trigger }: { trigger?: ReactNode }) {
<ThemeToggle withText /> <ThemeToggle withText />
</div> </div>
<div className="flex flex-col items-start gap-1"> <div className="flex flex-col items-start gap-1">
<Label className="text-sm">Icon Style</Label> <Label className={cn("text-sm", currentUser.plan !== "pro" && "text-muted-foreground")}>
<Select value={iconPreference} onValueChange={(v) => setIconPreference(v as IconStyle)}> Icon Style
<SelectTrigger className="w-full"> </Label>
<Select
value={iconPreference}
onValueChange={(v) => setIconPreference(v as IconStyle)}
disabled={currentUser.plan !== "pro"}
>
<SelectTrigger
className={cn("w-full", currentUser.plan !== "pro" && "cursor-not-allowed opacity-60")}
title={
currentUser.plan !== "pro"
? "icon style customization is only available on Pro"
: undefined
}
>
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent position="popper" side="bottom" align="start"> <SelectContent position="popper" side="bottom" align="start">
@@ -156,12 +180,33 @@ function Account({ trigger }: { trigger?: ReactNode }) {
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
{currentUser.plan !== "pro" && (
<span className="text-xs text-muted-foreground">
<Link to="/plans" className="text-personality hover:underline">
Upgrade to Pro
</Link>{" "}
to customize icon style
</span>
)}
</div> </div>
</div> </div>
{error !== "" && <Label className="text-destructive text-sm">{error}</Label>} {error !== "" && <Label className="text-destructive text-sm">{error}</Label>}
<div className="flex justify-end mt-4"> {/* Show subscription management link */}
<div className="pt-2">
{currentUser.plan === "pro" ? (
<Button asChild className="w-fit bg-personality hover:bg-personality/90 font-700">
<Link to="/plans">Manage subscription</Link>
</Button>
) : (
<Button asChild className="w-fit bg-personality hover:bg-personality/90 font-700">
<Link to="/plans">Upgrade to Pro</Link>
</Button>
)}
</div>
<div className="flex justify-end mt-2">
<Button variant={"outline"} type={"submit"} className="px-12"> <Button variant={"outline"} type={"submit"} className="px-12">
Save Save
</Button> </Button>

View File

@@ -0,0 +1,93 @@
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import Icon from "@/components/ui/icon";
import { cn } from "@/lib/utils";
export function FreeTierLimit({
current,
limit,
itemName,
isPro,
className,
showUpgrade = true,
}: {
current: number;
limit: number;
itemName: string;
isPro: boolean;
className?: string;
showUpgrade?: boolean;
}) {
if (isPro) return null;
const percentage = Math.min((current / limit) * 100, 100);
const isAtLimit = current >= limit;
const isNearLimit = percentage >= 80 && !isAtLimit;
return (
<div className={cn("flex flex-col gap-1.5", className)}>
<div className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">
{current} / {limit} {itemName}
{current !== 1 ? "s" : ""}
</span>
{isAtLimit && <span className="text-destructive font-medium">Limit reached</span>}
{isNearLimit && <span className="text-yellow-600 font-medium">Almost at limit</span>}
</div>
<div className="h-1.5 w-full bg-muted rounded-full overflow-hidden">
<div
className={cn(
"h-full transition-all duration-300",
isAtLimit ? "bg-destructive" : isNearLimit ? "bg-yellow-500" : "bg-personality",
)}
style={{ width: `${percentage}%` }}
/>
</div>
{isAtLimit && showUpgrade && (
<div className="flex items-center gap-2 mt-1">
<Icon icon="info" className="size-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground">Upgrade to Pro for unlimited {itemName}s</span>
<Button asChild variant="link" size="sm" className="h-auto p-0 text-xs text-personality">
<Link to="/plans">Upgrade</Link>
</Button>
</div>
)}
</div>
);
}
interface FreeTierLimitBadgeProps {
current: number;
limit: number;
itemName: string;
isPro: boolean;
className?: string;
}
export function FreeTierLimitBadge({ current, limit, itemName, isPro, className }: FreeTierLimitBadgeProps) {
if (isPro) return null;
const isAtLimit = current >= limit;
const percentage = (current / limit) * 100;
const isNearLimit = percentage >= 80 && !isAtLimit;
return (
<div
className={cn(
"inline-flex items-center gap-1.5 px-2 py-1 text-xs rounded-md border",
isAtLimit
? "bg-destructive/10 border-destructive/30 text-destructive"
: isNearLimit
? "bg-yellow-500/10 border-yellow-500/30 text-yellow-700"
: "bg-muted border-border text-muted-foreground",
className,
)}
>
<Icon icon={isAtLimit ? "alertTriangle" : isNearLimit ? "info" : "check"} className="size-3.5" />
<span>
{current}/{limit} {itemName}
{current !== 1 ? "s" : ""}
</span>
</div>
);
}

View File

@@ -2,6 +2,7 @@ import { ISSUE_DESCRIPTION_MAX_LENGTH, ISSUE_TITLE_MAX_LENGTH } from "@sprint/sh
import { type FormEvent, useEffect, useMemo, useState } from "react"; import { type FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { FreeTierLimit } from "@/components/free-tier-limit";
import { MultiAssigneeSelect } from "@/components/multi-assignee-select"; import { MultiAssigneeSelect } from "@/components/multi-assignee-select";
import { useAuthenticatedSession } from "@/components/session-provider"; import { useAuthenticatedSession } from "@/components/session-provider";
import { SprintSelect } from "@/components/sprint-select"; import { SprintSelect } from "@/components/sprint-select";
@@ -23,6 +24,7 @@ import { Label } from "@/components/ui/label";
import { SelectTrigger } from "@/components/ui/select"; import { SelectTrigger } from "@/components/ui/select";
import { import {
useCreateIssue, useCreateIssue,
useIssues,
useOrganisationMembers, useOrganisationMembers,
useSelectedOrganisation, useSelectedOrganisation,
useSelectedProject, useSelectedProject,
@@ -31,14 +33,21 @@ import {
import { parseError } from "@/lib/server"; import { parseError } from "@/lib/server";
import { cn, issueID } from "@/lib/utils"; import { cn, issueID } from "@/lib/utils";
const FREE_TIER_ISSUE_LIMIT = 100;
export function IssueForm({ trigger }: { trigger?: React.ReactNode }) { export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
const { user } = useAuthenticatedSession(); const { user } = useAuthenticatedSession();
const selectedOrganisation = useSelectedOrganisation(); const selectedOrganisation = useSelectedOrganisation();
const selectedProject = useSelectedProject(); const selectedProject = useSelectedProject();
const { data: sprints = [] } = useSprints(selectedProject?.Project.id); const { data: sprints = [] } = useSprints(selectedProject?.Project.id);
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id); const { data: membersData = [] } = useOrganisationMembers(selectedOrganisation?.Organisation.id);
const { data: issues = [] } = useIssues(selectedProject?.Project.id);
const createIssue = useCreateIssue(); const createIssue = useCreateIssue();
const isPro = user.plan === "pro";
const issueCount = issues.length;
const isAtIssueLimit = !isPro && issueCount >= FREE_TIER_ISSUE_LIMIT;
const members = useMemo(() => membersData.map((member) => member.User), [membersData]); const members = useMemo(() => membersData.map((member) => member.User), [membersData]);
const statuses = selectedOrganisation?.Organisation.statuses ?? {}; const statuses = selectedOrganisation?.Organisation.statuses ?? {};
const issueTypes = (selectedOrganisation?.Organisation.issueTypes ?? {}) as Record< const issueTypes = (selectedOrganisation?.Organisation.issueTypes ?? {}) as Record<
@@ -138,7 +147,17 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild> <DialogTrigger asChild>
{trigger || ( {trigger || (
<Button variant="outline" disabled={!selectedProject}> <Button
variant="outline"
disabled={!selectedProject || isAtIssueLimit}
title={
isAtIssueLimit
? "Free tier limited to 100 issues per organisation. Upgrade to Pro for unlimited."
: !selectedProject
? "Select a project first"
: undefined
}
>
Create Issue Create Issue
</Button> </Button>
)} )}
@@ -149,6 +168,18 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
<DialogTitle>Create Issue</DialogTitle> <DialogTitle>Create Issue</DialogTitle>
</DialogHeader> </DialogHeader>
{!isPro && selectedProject && (
<div className="mb-2">
<FreeTierLimit
current={issueCount}
limit={FREE_TIER_ISSUE_LIMIT}
itemName="issue"
isPro={isPro}
showUpgrade={isAtIssueLimit}
/>
</div>
)}
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="grid"> <div className="grid">
{(typeOptions.length > 0 || statusOptions.length > 0) && ( {(typeOptions.length > 0 || statusOptions.length > 0) && (
@@ -270,10 +301,16 @@ export function IssueForm({ trigger }: { trigger?: React.ReactNode }) {
type="submit" type="submit"
disabled={ disabled={
submitting || submitting ||
isAtIssueLimit ||
((title.trim() === "" || title.trim().length > ISSUE_TITLE_MAX_LENGTH) && ((title.trim() === "" || title.trim().length > ISSUE_TITLE_MAX_LENGTH) &&
submitAttempted) || submitAttempted) ||
(description.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH && submitAttempted) (description.trim().length > ISSUE_DESCRIPTION_MAX_LENGTH && submitAttempted)
} }
title={
isAtIssueLimit
? "Free tier limited to 100 issues per organisation. Upgrade to Pro for unlimited."
: undefined
}
> >
{submitting ? "Creating..." : "Create"} {submitting ? "Creating..." : "Create"}
</Button> </Button>

View File

@@ -1,8 +1,7 @@
/** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */ /** biome-ignore-all lint/correctness/useExhaustiveDependencies: <> */
import { USER_NAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH } from "@sprint/shared"; import { USER_EMAIL_MAX_LENGTH, USER_NAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH } from "@sprint/shared";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { useSession } from "@/components/session-provider"; import { useSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -26,9 +25,7 @@ export default function LogInForm({
showWarning: boolean; showWarning: boolean;
setShowWarning: (value: boolean) => void; setShowWarning: (value: boolean) => void;
}) { }) {
const navigate = useNavigate(); const { setUser, setEmailVerified } = useSession();
const [searchParams] = useSearchParams();
const { setUser } = useSession();
const [loginDetailsOpen, setLoginDetailsOpen] = useState(false); const [loginDetailsOpen, setLoginDetailsOpen] = useState(false);
@@ -36,6 +33,7 @@ export default function LogInForm({
const [name, setName] = useState(""); const [name, setName] = useState("");
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [avatarURL, setAvatarUrl] = useState<string | null>(null); const [avatarURL, setAvatarUrl] = useState<string | null>(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
@@ -58,8 +56,7 @@ export default function LogInForm({
const data = await res.json(); const data = await res.json();
setCsrfToken(data.csrfToken); setCsrfToken(data.csrfToken);
setUser(data.user); setUser(data.user);
const next = searchParams.get("next") || "/issues"; setEmailVerified(data.user.emailVerified);
navigate(next, { replace: true });
} }
// unauthorized // unauthorized
else if (res.status === 401) { else if (res.status === 401) {
@@ -75,7 +72,7 @@ export default function LogInForm({
}; };
const register = () => { const register = () => {
if (name.trim() === "" || username.trim() === "" || password.trim() === "") { if (name.trim() === "" || username.trim() === "" || email.trim() === "" || password.trim() === "") {
return; return;
} }
@@ -85,6 +82,7 @@ export default function LogInForm({
body: JSON.stringify({ body: JSON.stringify({
name, name,
username, username,
email,
password, password,
avatarURL, avatarURL,
}), }),
@@ -96,8 +94,7 @@ export default function LogInForm({
const data = await res.json(); const data = await res.json();
setCsrfToken(data.csrfToken); setCsrfToken(data.csrfToken);
setUser(data.user); setUser(data.user);
const next = searchParams.get("next") || "/issues"; setEmailVerified(data.user.emailVerified);
navigate(next, { replace: true });
} }
// bad request (probably a bad user input) // bad request (probably a bad user input)
else if (res.status === 400) { else if (res.status === 400) {
@@ -129,6 +126,7 @@ export default function LogInForm({
setError(""); setError("");
setSubmitAttempted(false); setSubmitAttempted(false);
setAvatarUrl(null); setAvatarUrl(null);
setEmail("");
requestAnimationFrame(() => focusFirstInput()); requestAnimationFrame(() => focusFirstInput());
}; };
@@ -249,6 +247,15 @@ export default function LogInForm({
spellcheck={false} spellcheck={false}
maxLength={USER_NAME_MAX_LENGTH} maxLength={USER_NAME_MAX_LENGTH}
/> />
<Field
label="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
validate={(v) => (v.trim() === "" ? "Cannot be empty" : undefined)}
submitAttempted={submitAttempted}
spellcheck={false}
maxLength={USER_EMAIL_MAX_LENGTH}
/>
</> </>
)} )}
<Field <Field

View File

@@ -15,21 +15,21 @@ interface LoginModalProps {
export function LoginModal({ open, onOpenChange, onSuccess, dismissible = true }: LoginModalProps) { export function LoginModal({ open, onOpenChange, onSuccess, dismissible = true }: LoginModalProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { user, isLoading } = useSession(); const { user, isLoading, emailVerified } = useSession();
const [hasRedirected, setHasRedirected] = useState(false); const [hasRedirected, setHasRedirected] = useState(false);
const [showWarning, setShowWarning] = useState(() => { const [showWarning, setShowWarning] = useState(() => {
return localStorage.getItem("hide-under-construction") !== "true"; return localStorage.getItem("hide-under-construction") !== "true";
}); });
useEffect(() => { useEffect(() => {
if (open && !isLoading && user && !hasRedirected) { if (open && !isLoading && user && emailVerified && !hasRedirected) {
setHasRedirected(true); setHasRedirected(true);
const next = searchParams.get("next") || "/issues"; const next = searchParams.get("next") || "/issues";
navigate(next, { replace: true }); navigate(next, { replace: true });
onSuccess?.(); onSuccess?.();
onOpenChange(false); onOpenChange(false);
} }
}, [open, user, isLoading, navigate, searchParams, onSuccess, onOpenChange, hasRedirected]); }, [open, user, isLoading, emailVerified, navigate, searchParams, onSuccess, onOpenChange, hasRedirected]);
useEffect(() => { useEffect(() => {
if (!open) { if (!open) {

View File

@@ -1,7 +1,9 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { FreeTierLimit } from "@/components/free-tier-limit";
import { OrganisationForm } from "@/components/organisation-form"; import { OrganisationForm } from "@/components/organisation-form";
import { useSelection } from "@/components/selection-provider"; import { useSelection } from "@/components/selection-provider";
import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Select, Select,
@@ -17,6 +19,8 @@ import { useOrganisations } from "@/lib/query/hooks";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import OrgIcon from "./org-icon"; import OrgIcon from "./org-icon";
const FREE_TIER_ORG_LIMIT = 1;
export function OrganisationSelect({ export function OrganisationSelect({
placeholder = "Select Organisation", placeholder = "Select Organisation",
contentClass, contentClass,
@@ -40,6 +44,11 @@ export function OrganisationSelect({
const [pendingOrganisationId, setPendingOrganisationId] = useState<number | null>(null); const [pendingOrganisationId, setPendingOrganisationId] = useState<number | null>(null);
const { data: organisationsData = [] } = useOrganisations(); const { data: organisationsData = [] } = useOrganisations();
const { selectedOrganisationId, selectOrganisation } = useSelection(); const { selectedOrganisationId, selectOrganisation } = useSelection();
const { user } = useAuthenticatedSession();
const isPro = user.plan === "pro";
const orgCount = organisationsData.length;
const isAtOrgLimit = !isPro && orgCount >= FREE_TIER_ORG_LIMIT;
const organisations = useMemo( const organisations = useMemo(
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)), () => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
@@ -107,9 +116,31 @@ export function OrganisationSelect({
{organisations.length > 0 && <SelectSeparator />} {organisations.length > 0 && <SelectSeparator />}
</SelectGroup> </SelectGroup>
{!isPro && (
<div className="px-2 py-2">
<FreeTierLimit
current={orgCount}
limit={FREE_TIER_ORG_LIMIT}
itemName="organisation"
isPro={isPro}
showUpgrade={isAtOrgLimit}
/>
</div>
)}
<OrganisationForm <OrganisationForm
trigger={ trigger={
<Button variant="ghost" className={"w-full"} size={"sm"}> <Button
variant="ghost"
className={"w-full"}
size={"sm"}
disabled={isAtOrgLimit}
title={
isAtOrgLimit
? "Free tier limited to 1 organisation. Upgrade to Pro for unlimited."
: undefined
}
>
Create Organisation Create Organisation
</Button> </Button>
} }

View File

@@ -8,8 +8,10 @@ import {
} from "@sprint/shared"; } from "@sprint/shared";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react"; import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { toast } from "sonner"; import { toast } from "sonner";
import { AddMember } from "@/components/add-member"; import { AddMember } from "@/components/add-member";
import { FreeTierLimit } from "@/components/free-tier-limit";
import OrgIcon from "@/components/org-icon"; import OrgIcon from "@/components/org-icon";
import { OrganisationForm } from "@/components/organisation-form"; import { OrganisationForm } from "@/components/organisation-form";
import { OrganisationSelect } from "@/components/organisation-select"; import { OrganisationSelect } from "@/components/organisation-select";
@@ -22,6 +24,7 @@ import SmallUserDisplay from "@/components/small-user-display";
import { SprintForm } from "@/components/sprint-form"; import { SprintForm } from "@/components/sprint-form";
import StatusTag from "@/components/status-tag"; import StatusTag from "@/components/status-tag";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import ColourPicker from "@/components/ui/colour-picker"; import ColourPicker from "@/components/ui/colour-picker";
import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { ConfirmDialog } from "@/components/ui/confirm-dialog";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
@@ -34,13 +37,16 @@ import {
import Icon, { type IconName, iconNames } from "@/components/ui/icon"; import Icon, { type IconName, iconNames } from "@/components/ui/icon";
import { IconButton } from "@/components/ui/icon-button"; import { IconButton } from "@/components/ui/icon-button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
useDeleteOrganisation, useDeleteOrganisation,
useDeleteProject, useDeleteProject,
useDeleteSprint, useDeleteSprint,
useIssues,
useOrganisationMembers, useOrganisationMembers,
useOrganisationMemberTimeTracking,
useOrganisations, useOrganisations,
useProjects, useProjects,
useRemoveOrganisationMember, useRemoveOrganisationMember,
@@ -52,9 +58,16 @@ import {
} from "@/lib/query/hooks"; } from "@/lib/query/hooks";
import { queryKeys } from "@/lib/query/keys"; import { queryKeys } from "@/lib/query/keys";
import { apiClient } from "@/lib/server"; import { apiClient } from "@/lib/server";
import { capitalise, unCamelCase } from "@/lib/utils"; import { capitalise, cn, formatDuration, unCamelCase } from "@/lib/utils";
import { Switch } from "./ui/switch"; import { Switch } from "./ui/switch";
const FREE_TIER_LIMITS = {
organisationsPerUser: 1,
projectsPerOrganisation: 1,
issuesPerOrganisation: 100,
membersPerOrganisation: 5,
} as const;
function Organisations({ trigger }: { trigger?: ReactNode }) { function Organisations({ trigger }: { trigger?: ReactNode }) {
const { user } = useAuthenticatedSession(); const { user } = useAuthenticatedSession();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -63,6 +76,7 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
const { data: projectsData = [] } = useProjects(selectedOrganisationId); const { data: projectsData = [] } = useProjects(selectedOrganisationId);
const { data: sprints = [] } = useSprints(selectedProjectId); const { data: sprints = [] } = useSprints(selectedProjectId);
const { data: membersData = [] } = useOrganisationMembers(selectedOrganisationId); const { data: membersData = [] } = useOrganisationMembers(selectedOrganisationId);
const { data: issues = [] } = useIssues(selectedProjectId);
const updateOrganisation = useUpdateOrganisation(); const updateOrganisation = useUpdateOrganisation();
const updateMemberRole = useUpdateOrganisationMemberRole(); const updateMemberRole = useUpdateOrganisationMemberRole();
const removeMember = useRemoveOrganisationMember(); const removeMember = useRemoveOrganisationMember();
@@ -72,6 +86,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
const replaceIssueStatus = useReplaceIssueStatus(); const replaceIssueStatus = useReplaceIssueStatus();
const replaceIssueType = useReplaceIssueType(); const replaceIssueType = useReplaceIssueType();
const isPro = user.plan === "pro";
const orgCount = organisationsData.length;
const projectCount = projectsData.length;
const issueCount = issues.length;
const memberCount = membersData.length;
const organisations = useMemo( const organisations = useMemo(
() => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)), () => [...organisationsData].sort((a, b) => a.Organisation.name.localeCompare(b.Organisation.name)),
[organisationsData], [organisationsData],
@@ -104,6 +124,15 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
); );
const invalidateSprints = () => const invalidateSprints = () =>
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.byProject(selectedProjectId ?? 0) }); queryClient.invalidateQueries({ queryKey: queryKeys.sprints.byProject(selectedProjectId ?? 0) });
// time tracking state - must be before membersWithTimeTracking useMemo
const [fromDate, setFromDate] = useState<Date>(() => {
// default to same day of previous month
const now = new Date();
const prevMonth = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
return prevMonth;
});
const { data: timeTrackingData = [] } = useOrganisationMemberTimeTracking(selectedOrganisationId, fromDate);
const members = useMemo(() => { const members = useMemo(() => {
const roleOrder: Record<string, number> = { owner: 0, admin: 1, member: 2 }; const roleOrder: Record<string, number> = { owner: 0, admin: 1, member: 2 };
return [...membersData].sort((a, b) => { return [...membersData].sort((a, b) => {
@@ -114,6 +143,118 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
}); });
}, [membersData]); }, [membersData]);
const membersWithTimeTracking = useMemo(() => {
const timePerUser = new Map<number, number>();
for (const session of timeTrackingData) {
const current = timePerUser.get(session.userId) ?? 0;
timePerUser.set(session.userId, current + (session.workTimeMs ?? 0));
}
const membersWithTime = members.map((member) => ({
...member,
totalTimeMs: timePerUser.get(member.User.id) ?? 0,
}));
const roleOrder: Record<string, number> = { owner: 0, admin: 1, member: 2 };
return membersWithTime.sort((a, b) => {
if (b.totalTimeMs !== a.totalTimeMs) {
return b.totalTimeMs - a.totalTimeMs;
}
const roleA = roleOrder[a.OrganisationMember.role] ?? 3;
const roleB = roleOrder[b.OrganisationMember.role] ?? 3;
if (roleA !== roleB) return roleA - roleB;
return a.User.name.localeCompare(b.User.name);
});
}, [members, timeTrackingData]);
const downloadTimeTrackingData = (format: "csv" | "json") => {
if (!selectedOrganisation) return;
const userData = new Map<
number,
{
userId: number;
name: string;
username: string;
totalTimeMs: number;
sessions: typeof timeTrackingData;
}
>();
for (const member of members) {
userData.set(member.User.id, {
userId: member.User.id,
name: member.User.name,
username: member.User.username,
totalTimeMs: 0,
sessions: [],
});
}
for (const session of timeTrackingData) {
const user = userData.get(session.userId);
if (user) {
user.totalTimeMs += session.workTimeMs;
user.sessions.push(session);
}
}
const data = Array.from(userData.values()).sort((a, b) => b.totalTimeMs - a.totalTimeMs);
// generate CSV or JSON
if (format === "csv") {
const headers = ["User ID", "Name", "Username", "Total Time (ms)", "Total Time (formatted)"];
const rows = data.map((user) => [
user.userId,
user.name,
user.username,
user.totalTimeMs,
formatDuration(user.totalTimeMs),
]);
const csv = [headers.join(","), ...rows.map((row) => row.map((cell) => `"${cell}"`).join(","))].join(
"\n",
);
// download
const blob = new Blob([csv], { type: "text/csv" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${selectedOrganisation.Organisation.slug}-time-tracking-${fromDate.toISOString().split("T")[0]}.csv`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} else {
const json = JSON.stringify(
{
organisation: selectedOrganisation.Organisation.name,
fromDate: fromDate.toISOString(),
generatedAt: new Date().toISOString(),
members: data.map((user) => ({
...user,
totalTimeFormatted: formatDuration(user.totalTimeMs),
})),
},
null,
2,
);
// download
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${selectedOrganisation.Organisation.slug}-time-tracking-${fromDate.toISOString().split("T")[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
toast.success(`Downloaded time tracking data as ${format.toUpperCase()}`);
};
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [activeTab, setActiveTab] = useState("info"); const [activeTab, setActiveTab] = useState("info");
@@ -699,6 +840,49 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
<p className="text-sm text-muted-foreground break-words">No description</p> <p className="text-sm text-muted-foreground break-words">No description</p>
)} )}
</div> </div>
{/* Free tier limits section */}
{!isPro && (
<div className="mt-4 pt-4 border-t border-border">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-600">Plan Limits</h3>
<Button asChild variant="link" size="sm" className="h-auto p-0 text-xs">
<Link to="/plans">Upgrade to Pro</Link>
</Button>
</div>
<div className="flex flex-col gap-3">
<FreeTierLimit
current={orgCount}
limit={FREE_TIER_LIMITS.organisationsPerUser}
itemName="organisation"
isPro={isPro}
showUpgrade={false}
/>
<FreeTierLimit
current={projectCount}
limit={FREE_TIER_LIMITS.projectsPerOrganisation}
itemName="project"
isPro={isPro}
showUpgrade={false}
/>
<FreeTierLimit
current={issueCount}
limit={FREE_TIER_LIMITS.issuesPerOrganisation}
itemName="issue"
isPro={isPro}
showUpgrade={false}
/>
<FreeTierLimit
current={memberCount}
limit={FREE_TIER_LIMITS.membersPerOrganisation}
itemName="member"
isPro={isPro}
showUpgrade={false}
/>
</div>
</div>
)}
{isAdmin && ( {isAdmin && (
<div className="flex gap-2 mt-3"> <div className="flex gap-2 mt-3">
<Button variant="outline" size="sm" onClick={() => setEditOrgOpen(true)}> <Button variant="outline" size="sm" onClick={() => setEditOrgOpen(true)}>
@@ -753,12 +937,52 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
<TabsContent value="users"> <TabsContent value="users">
<div className="border p-2 min-w-0 overflow-hidden"> <div className="border p-2 min-w-0 overflow-hidden">
<h2 className="text-xl font-600 mb-2"> <div className="flex items-center justify-between mb-2">
<h2 className="text-xl font-600">
{members.length} Member{members.length !== 1 ? "s" : ""} {members.length} Member{members.length !== 1 ? "s" : ""}
</h2> </h2>
{isAdmin && (
<div className="flex items-center gap-2">
{isPro && (
<>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" size="sm">
From: {fromDate.toLocaleDateString()}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="end">
<Calendar
mode="single"
selected={fromDate}
onSelect={(date) => date && setFromDate(date)}
autoFocus
/>
</PopoverContent>
</Popover>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
Export
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => downloadTimeTrackingData("csv")}>
Download CSV
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => downloadTimeTrackingData("json")}>
Download JSON
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)}
</div>
)}
</div>
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<div className="flex flex-col gap-2 max-h-56 overflow-y-scroll"> <div className="flex flex-col gap-2 max-h-56 overflow-y-scroll">
{members.map((member) => ( {membersWithTimeTracking.map((member) => (
<div <div
key={member.OrganisationMember.id} key={member.OrganisationMember.id}
className="flex items-center justify-between p-2 border" className="flex items-center justify-between p-2 border"
@@ -770,6 +994,11 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isAdmin && isPro && (
<span className="text-sm font-mono text-muted-foreground mr-2">
{formatDuration(member.totalTimeMs)}
</span>
)}
{isAdmin && {isAdmin &&
member.OrganisationMember.role !== "owner" && member.OrganisationMember.role !== "owner" &&
member.User.id !== user.id && ( member.User.id !== user.id && (
@@ -803,6 +1032,18 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
))} ))}
</div> </div>
{isAdmin && ( {isAdmin && (
<>
{!isPro && (
<div className="px-1">
<FreeTierLimit
current={memberCount}
limit={FREE_TIER_LIMITS.membersPerOrganisation}
itemName="member"
isPro={isPro}
showUpgrade={memberCount >= FREE_TIER_LIMITS.membersPerOrganisation}
/>
</div>
)}
<AddMember <AddMember
organisationId={selectedOrganisation.Organisation.id} organisationId={selectedOrganisation.Organisation.id}
existingMembers={members.map((m) => m.User.username)} existingMembers={members.map((m) => m.User.username)}
@@ -817,11 +1058,20 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
void invalidateMembers(); void invalidateMembers();
}} }}
trigger={ trigger={
<Button variant="outline"> <Button
variant="outline"
disabled={!isPro && memberCount >= FREE_TIER_LIMITS.membersPerOrganisation}
title={
!isPro && memberCount >= FREE_TIER_LIMITS.membersPerOrganisation
? "Free tier limited to 5 members per organisation. Upgrade to Pro for unlimited."
: undefined
}
>
Add user <Icon icon="plus" className="size-4" /> Add user <Icon icon="plus" className="size-4" />
</Button> </Button>
} }
/> />
</>
)} )}
</div> </div>
</div> </div>
@@ -1272,6 +1522,14 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
<TabsContent value="features"> <TabsContent value="features">
<div className="border p-2 min-w-0 overflow-hidden"> <div className="border p-2 min-w-0 overflow-hidden">
<h2 className="text-xl font-600 mb-2">Features</h2> <h2 className="text-xl font-600 mb-2">Features</h2>
{!isPro && (
<div className="mb-3 p-2 bg-muted/50 rounded text-sm text-muted-foreground">
Feature toggling is only available on Pro.{" "}
<Link to="/plans" className="text-personality hover:underline">
Upgrade to customize features.
</Link>
</div>
)}
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
{Object.keys(DEFAULT_FEATURES).map((feature) => ( {Object.keys(DEFAULT_FEATURES).map((feature) => (
<div key={feature} className="flex items-center gap-2 p-1"> <div key={feature} className="flex items-center gap-2 p-1">
@@ -1293,9 +1551,12 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
); );
await invalidateOrganisations(); await invalidateOrganisations();
}} }}
disabled={!isPro}
color={"#ff0000"} color={"#ff0000"}
/> />
<span className={"text-sm"}>{unCamelCase(feature)}</span> <span className={cn("text-sm", !isPro && "text-muted-foreground")}>
{unCamelCase(feature)}
</span>
</div> </div>
))} ))}
</div> </div>

View File

@@ -0,0 +1,127 @@
import { Button } from "@/components/ui/button";
import Icon from "@/components/ui/icon";
import { cn } from "@/lib/utils";
export interface PricingTier {
name: string;
price: string;
priceAnnual: string;
period: string;
periodAnnual: string;
description: string;
tagline: string;
features: string[];
cta: string;
highlighted: boolean;
}
export function PricingCard({
tier,
billingPeriod,
onCtaClick,
disabled = false,
loading = false,
}: {
tier: PricingTier;
billingPeriod: "monthly" | "annual";
onCtaClick: () => void;
disabled?: boolean;
loading?: boolean;
}) {
return (
<div
className={cn(
"flex flex-col border p-8 space-y-6 relative",
tier.highlighted ? "border-2 border-personality shadow-lg scale-105" : "border-border",
)}
>
{tier.highlighted && (
<div className="absolute -top-4 left-4 bg-personality text-background px-3 py-1 text-xs font-700">
{tier.tagline}
</div>
)}
<div className="space-y-4">
<h3 className="text-3xl font-basteleur font-700">{tier.name}</h3>
<div className="flex items-baseline gap-2">
<span className="text-4xl font-700">
{billingPeriod === "annual" ? tier.priceAnnual : tier.price}
</span>
<span className="text-sm text-muted-foreground">
{billingPeriod === "annual" ? tier.periodAnnual : tier.period}
</span>
</div>
<p className="text-muted-foreground">{tier.description}</p>
</div>
<ul className="space-y-3 flex-1">
{tier.features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-sm">
<Icon icon="check" iconStyle={"pixel"} className="size-6 -mt-0.5" color="var(--personality)" />
<span>{feature}</span>
</li>
))}
</ul>
<Button
variant={tier.highlighted ? "default" : "outline"}
className={cn(
"font-700 py-6",
tier.highlighted ? "bg-personality hover:bg-personality/90 text-background" : "",
)}
onClick={onCtaClick}
disabled={disabled}
>
{loading ? "Processing..." : tier.cta}
</Button>
</div>
);
}
export const pricingTiers: PricingTier[] = [
{
name: "Starter",
price: "£0",
priceAnnual: "£0",
period: "Free forever",
periodAnnual: "Free forever",
description: "Perfect for side projects and solo developers",
tagline: "For solo devs and small projects",
features: [
"1 organisation (owned or joined)",
"1 project",
"5 sprints",
"100 issues",
"Up to 5 team members",
"Static avatars only",
"Pixel icon style",
"Email support",
],
cta: "Get started free",
highlighted: false,
},
{
name: "Pro",
price: "£11.99",
priceAnnual: "£9.99",
period: "per user/month",
periodAnnual: "per user/month",
description: "For growing teams and professionals",
tagline: "Most Popular",
features: [
"Everything in starter",
"Unlimited organisations",
"Unlimited projects",
"Unlimited sprints",
"Unlimited issues",
"Animated avatars",
"Custom icon styles",
"Feature toggling",
"Advanced time tracking & reports",
"Custom issue statuses",
"Priority email support",
],
cta: "Upgrade to Pro",
highlighted: true,
},
];

View File

@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { FreeTierLimit } from "@/components/free-tier-limit";
import { ProjectForm } from "@/components/project-form"; import { ProjectForm } from "@/components/project-form";
import { useSelection } from "@/components/selection-provider"; import { useSelection } from "@/components/selection-provider";
import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Select, Select,
@@ -14,6 +16,8 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useProjects } from "@/lib/query/hooks"; import { useProjects } from "@/lib/query/hooks";
const FREE_TIER_PROJECT_LIMIT = 1;
export function ProjectSelect({ export function ProjectSelect({
placeholder = "Select Project", placeholder = "Select Project",
showLabel = false, showLabel = false,
@@ -29,6 +33,11 @@ export function ProjectSelect({
const [pendingProjectId, setPendingProjectId] = useState<number | null>(null); const [pendingProjectId, setPendingProjectId] = useState<number | null>(null);
const { selectedOrganisationId, selectedProjectId, selectProject } = useSelection(); const { selectedOrganisationId, selectedProjectId, selectProject } = useSelection();
const { data: projectsData = [] } = useProjects(selectedOrganisationId); const { data: projectsData = [] } = useProjects(selectedOrganisationId);
const { user } = useAuthenticatedSession();
const isPro = user.plan === "pro";
const projectCount = projectsData.length;
const isAtProjectLimit = !isPro && projectCount >= FREE_TIER_PROJECT_LIMIT;
const projects = useMemo( const projects = useMemo(
() => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)), () => [...projectsData].sort((a, b) => a.Project.name.localeCompare(b.Project.name)),
@@ -81,10 +90,35 @@ export function ProjectSelect({
))} ))}
{projects.length > 0 && <SelectSeparator />} {projects.length > 0 && <SelectSeparator />}
</SelectGroup> </SelectGroup>
{!isPro && selectedOrganisationId && (
<div className="px-2 py-2">
<FreeTierLimit
current={projectCount}
limit={FREE_TIER_PROJECT_LIMIT}
itemName="project"
isPro={isPro}
showUpgrade={isAtProjectLimit}
/>
</div>
)}
<ProjectForm <ProjectForm
organisationId={selectedOrganisationId ?? undefined} organisationId={selectedOrganisationId ?? undefined}
trigger={ trigger={
<Button size={"sm"} variant="ghost" className={"w-full"} disabled={!selectedOrganisationId}> <Button
size={"sm"}
variant="ghost"
className={"w-full"}
disabled={!selectedOrganisationId || isAtProjectLimit}
title={
isAtProjectLimit
? "Free tier limited to 1 project per organisation. Upgrade to Pro for unlimited."
: !selectedOrganisationId
? "Select an organisation first"
: undefined
}
>
Create Project Create Project
</Button> </Button>
} }

View File

@@ -7,7 +7,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { getServerURL } from "@/lib/utils"; import { getServerURL } from "@/lib/utils";
const DEFAULT_URL = "https://tnirps.ob248.com"; const DEFAULT_URL = "https://server.sprintpm.org";
const formatURL = (url: string) => { const formatURL = (url: string) => {
if (url.endsWith("/")) { if (url.endsWith("/")) {

View File

@@ -3,12 +3,16 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
import Loading from "@/components/loading"; import Loading from "@/components/loading";
import { LoginModal } from "@/components/login-modal"; import { LoginModal } from "@/components/login-modal";
import { VerificationModal } from "@/components/verification-modal";
import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils"; import { clearAuth, getServerURL, setCsrfToken } from "@/lib/utils";
interface SessionContextValue { interface SessionContextValue {
user: UserResponse | null; user: UserResponse | null;
setUser: (user: UserResponse) => void; setUser: (user: UserResponse) => void;
isLoading: boolean; isLoading: boolean;
emailVerified: boolean;
setEmailVerified: (verified: boolean) => void;
refreshUser: () => Promise<void>;
} }
const SessionContext = createContext<SessionContextValue | null>(null); const SessionContext = createContext<SessionContextValue | null>(null);
@@ -39,6 +43,7 @@ export function useAuthenticatedSession(): { user: UserResponse; setUser: (user:
export function SessionProvider({ children }: { children: React.ReactNode }) { export function SessionProvider({ children }: { children: React.ReactNode }) {
const [user, setUserState] = useState<UserResponse | null>(null); const [user, setUserState] = useState<UserResponse | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [emailVerified, setEmailVerified] = useState(true);
const fetched = useRef(false); const fetched = useRef(false);
const setUser = useCallback((user: UserResponse) => { const setUser = useCallback((user: UserResponse) => {
@@ -46,6 +51,19 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
localStorage.setItem("user", JSON.stringify(user)); localStorage.setItem("user", JSON.stringify(user));
}, []); }, []);
const refreshUser = useCallback(async () => {
const res = await fetch(`${getServerURL()}/auth/me`, {
credentials: "include",
});
if (!res.ok) {
throw new Error(`auth check failed: ${res.status}`);
}
const data = (await res.json()) as { user: UserResponse; csrfToken: string; emailVerified: boolean };
setUser(data.user);
setCsrfToken(data.csrfToken);
setEmailVerified(data.emailVerified);
}, [setUser]);
useEffect(() => { useEffect(() => {
if (fetched.current) return; if (fetched.current) return;
fetched.current = true; fetched.current = true;
@@ -57,9 +75,10 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
if (!res.ok) { if (!res.ok) {
throw new Error(`auth check failed: ${res.status}`); throw new Error(`auth check failed: ${res.status}`);
} }
const data = (await res.json()) as { user: UserResponse; csrfToken: string }; const data = (await res.json()) as { user: UserResponse; csrfToken: string; emailVerified: boolean };
setUser(data.user); setUser(data.user);
setCsrfToken(data.csrfToken); setCsrfToken(data.csrfToken);
setEmailVerified(data.emailVerified);
}) })
.catch(() => { .catch(() => {
setUserState(null); setUserState(null);
@@ -70,11 +89,17 @@ export function SessionProvider({ children }: { children: React.ReactNode }) {
}); });
}, [setUser]); }, [setUser]);
return <SessionContext.Provider value={{ user, setUser, isLoading }}>{children}</SessionContext.Provider>; return (
<SessionContext.Provider
value={{ user, setUser, isLoading, emailVerified, setEmailVerified, refreshUser }}
>
{children}
</SessionContext.Provider>
);
} }
export function RequireAuth({ children }: { children: React.ReactNode }) { export function RequireAuth({ children }: { children: React.ReactNode }) {
const { user, isLoading } = useSession(); const { user, isLoading, emailVerified } = useSession();
const [loginModalOpen, setLoginModalOpen] = useState(false); const [loginModalOpen, setLoginModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
@@ -93,5 +118,9 @@ export function RequireAuth({ children }: { children: React.ReactNode }) {
return <LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} dismissible={false} />; return <LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} dismissible={false} />;
} }
if (user && !emailVerified) {
return <VerificationModal open={true} onOpenChange={() => {}} />;
}
return <>{children}</>; return <>{children}</>;
} }

View File

@@ -1,6 +1,7 @@
import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@sprint/shared"; import { DEFAULT_SPRINT_COLOUR, type SprintRecord } from "@sprint/shared";
import { type FormEvent, useEffect, useMemo, useState } from "react"; import { type FormEvent, useEffect, useMemo, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { FreeTierLimit } from "@/components/free-tier-limit";
import { useAuthenticatedSession } from "@/components/session-provider"; import { useAuthenticatedSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar"; import { Calendar } from "@/components/ui/calendar";
@@ -21,6 +22,7 @@ import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const SPRINT_NAME_MAX_LENGTH = 64; const SPRINT_NAME_MAX_LENGTH = 64;
const FREE_TIER_SPRINT_LIMIT = 5;
const getStartOfDay = (date: Date) => { const getStartOfDay = (date: Date) => {
const next = new Date(date); const next = new Date(date);
@@ -301,6 +303,16 @@ export function SprintForm({
)} )}
</div> </div>
{!isEdit && (
<FreeTierLimit
current={sprints.length}
limit={FREE_TIER_SPRINT_LIMIT}
itemName="sprint"
isPro={user.plan === "pro"}
showUpgrade={sprints.length >= FREE_TIER_SPRINT_LIMIT}
/>
)}
<div className="flex gap-2 w-full justify-end mt-2"> <div className="flex gap-2 w-full justify-end mt-2">
<DialogClose asChild> <DialogClose asChild>
<Button variant="outline" type="button"> <Button variant="outline" type="button">
@@ -312,7 +324,13 @@ export function SprintForm({
disabled={ disabled={
submitting || submitting ||
((name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) && submitAttempted) || ((name.trim() === "" || name.trim().length > SPRINT_NAME_MAX_LENGTH) && submitAttempted) ||
(dateError !== "" && submitAttempted) (dateError !== "" && submitAttempted) ||
(!isEdit && user.plan !== "pro" && sprints.length >= FREE_TIER_SPRINT_LIMIT)
}
title={
!isEdit && user.plan !== "pro" && sprints.length >= FREE_TIER_SPRINT_LIMIT
? `Free tier limited to ${FREE_TIER_SPRINT_LIMIT} sprints per project. Upgrade to Pro for unlimited sprints.`
: undefined
} }
> >
{submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"} {submitting ? (isEdit ? "Saving..." : "Creating...") : isEdit ? "Save" : "Create"}

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo } from "react"; import { useEffect, useMemo } from "react";
import { useLocation, useNavigate } from "react-router-dom"; import { Link, useLocation, useNavigate } from "react-router-dom";
import Account from "@/components/account"; import Account from "@/components/account";
import { IssueForm } from "@/components/issue-form"; import { IssueForm } from "@/components/issue-form";
import LogOutButton from "@/components/log-out-button"; import LogOutButton from "@/components/log-out-button";
@@ -11,6 +11,7 @@ import { useSelection } from "@/components/selection-provider";
import { useAuthenticatedSession } from "@/components/session-provider"; import { useAuthenticatedSession } from "@/components/session-provider";
import SmallUserDisplay from "@/components/small-user-display"; import SmallUserDisplay from "@/components/small-user-display";
import { SprintForm } from "@/components/sprint-form"; import { SprintForm } from "@/components/sprint-form";
import { Button } from "@/components/ui/button";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -122,6 +123,11 @@ export default function TopBar({ showIssueForm = true }: { showIssueForm?: boole
)} )}
</div> </div>
<div className={`flex gap-${BREATHING_ROOM} items-center`}> <div className={`flex gap-${BREATHING_ROOM} items-center`}>
{user.plan !== "pro" && (
<Button asChild className="bg-personality hover:bg-personality/90 text-background font-600">
<Link to="/plans">Upgrade</Link>
</Button>
)}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger className="text-sm"> <DropdownMenuTrigger className="text-sm">
<SmallUserDisplay user={user} /> <SmallUserDisplay user={user} />

View File

@@ -0,0 +1,131 @@
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import type * as React from "react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
function AlertDialog({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />;
}
function AlertDialogTrigger({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return <AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />;
}
function AlertDialogPortal({ ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return <AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />;
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn("fixed inset-0 z-50 bg-black/50", className)}
{...props}
/>
);
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=closed]:zoom-out-95",
"data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%]",
"z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%]",
"gap-4 border p-4 shadow-lg duration-200 outline-none w-sm",
className,
)}
{...props}
/>
</AlertDialogPortal>
);
}
function AlertDialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
);
}
function AlertDialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
{...props}
/>
);
}
function AlertDialogTitle({ className, ...props }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
);
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
);
}
type AlertDialogActionProps = React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Omit<React.ComponentProps<typeof Button>, "asChild">;
function AlertDialogAction({ className, ...props }: AlertDialogActionProps) {
return (
<AlertDialogPrimitive.Action asChild>
<Button className={className} {...props} />
</AlertDialogPrimitive.Action>
);
}
type AlertDialogCancelProps = React.ComponentProps<typeof AlertDialogPrimitive.Cancel> &
Omit<React.ComponentProps<typeof Button>, "asChild">;
function AlertDialogCancel({ className, ...props }: AlertDialogCancelProps) {
return (
<AlertDialogPrimitive.Cancel asChild>
<Button variant="outline" className={className} {...props} />
</AlertDialogPrimitive.Cancel>
);
}
export {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogTitle,
AlertDialogTrigger,
};

View File

@@ -0,0 +1,69 @@
/** biome-ignore-all lint/a11y/useFocusableInteractive: <> */
/** biome-ignore-all lint/a11y/useAriaPropsForRole: <> */
/** biome-ignore-all lint/a11y/useSemanticElements: <> */
import { OTPInput, OTPInputContext } from "input-otp";
import { MinusIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string;
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn("flex items-center gap-2 has-disabled:opacity-50", containerClassName)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
);
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return <div data-slot="input-otp-group" className={cn("flex items-center", className)} {...props} />;
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number;
}) {
const inputOTPContext = React.useContext(OTPInputContext);
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {};
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className,
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
);
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
);
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator };

View File

@@ -1,6 +1,7 @@
import { useRef, useState } from "react"; import { useRef, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import Avatar from "@/components/avatar"; import Avatar from "@/components/avatar";
import { useSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import Icon from "@/components/ui/icon"; import Icon from "@/components/ui/icon";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -8,6 +9,37 @@ import { useUploadAvatar } from "@/lib/query/hooks";
import { parseError } from "@/lib/server"; import { parseError } from "@/lib/server";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function isAnimatedGIF(file: File): Promise<boolean> {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => {
const buffer = reader.result as ArrayBuffer;
const arr = new Uint8Array(buffer);
// check for GIF89a or GIF87a header
const header = String.fromCharCode(...arr.slice(0, 6));
if (header !== "GIF89a" && header !== "GIF87a") {
resolve(false);
return;
}
// look for multiple images (animation indicator)
// GIFs have image descriptors starting with 0x2C
// and graphic control extensions starting with 0x21 0xF9
let frameCount = 0;
let i = 6; // skip header
while (i < arr.length - 1) {
if (arr[i] === 0x21 && arr[i + 1] === 0xf9) {
// graphic control extension - indicates animation frame
frameCount++;
}
i++;
}
resolve(frameCount > 1);
};
reader.onerror = () => resolve(false);
reader.readAsArrayBuffer(file.slice(0, 1024)); // only need first 1KB for header check
});
}
export function UploadAvatar({ export function UploadAvatar({
name, name,
username, username,
@@ -24,6 +56,7 @@ export function UploadAvatar({
skipOrgCheck?: boolean; skipOrgCheck?: boolean;
className?: string; className?: string;
}) { }) {
const { user } = useSession();
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -34,6 +67,22 @@ export function UploadAvatar({
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
// check for animated GIF for free users
if (user?.plan !== "pro" && file.type === "image/gif") {
const isAnimated = await isAnimatedGIF(file);
if (isAnimated) {
setError("Animated avatars are only available on Pro. Upgrade to upload animated avatars.");
toast.error("Animated avatars are only available on Pro. Upgrade to upload animated avatars.", {
dismissible: false,
});
// reset file input
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
return;
}
}
setUploading(true); setUploading(true);
setError(null); setError(null);
@@ -50,10 +99,26 @@ export function UploadAvatar({
setError(message); setError(message);
setUploading(false); setUploading(false);
// check if the error is about animated avatars for free users
if (message.toLowerCase().includes("animated") && message.toLowerCase().includes("pro")) {
toast.error(
<div className="flex flex-col gap-2">
<span>Animated avatars are only available on Pro.</span>
<a href="/plans" className="text-personality hover:underline">
Upgrade to Pro
</a>
</div>,
{
dismissible: false,
duration: 5000,
},
);
} else {
toast.error(`Error uploading avatar: ${message}`, { toast.error(`Error uploading avatar: ${message}`, {
dismissible: false, dismissible: false,
}); });
} }
}
}; };
return ( return (

View File

@@ -0,0 +1,104 @@
import { useState } from "react";
import { useSession } from "@/components/session-provider";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import { useResendVerification, useVerifyEmail } from "@/lib/query/hooks";
interface VerificationModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function VerificationModal({ open, onOpenChange }: VerificationModalProps) {
const { refreshUser, setEmailVerified } = useSession();
const [code, setCode] = useState("");
const [error, setError] = useState<string | null>(null);
const [resendSuccess, setResendSuccess] = useState(false);
const verifyMutation = useVerifyEmail();
const resendMutation = useResendVerification();
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setResendSuccess(false);
try {
await verifyMutation.mutateAsync({ code: code.trim() });
setEmailVerified(true);
onOpenChange(false);
await refreshUser();
} catch (err) {
setError(err instanceof Error ? err.message : "Verification failed");
}
};
const handleResend = async () => {
setError(null);
setResendSuccess(false);
try {
await resendMutation.mutateAsync();
setResendSuccess(true);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to resend code");
}
};
return (
<Dialog open={open} onOpenChange={() => {}}>
<DialogContent className="sm:max-w-md" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Verify your email</DialogTitle>
<DialogDescription>
We've sent a 6-digit verification code to your email. Enter it below to complete your
registration.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleVerify} className="space-y-4">
<div className="space-y-2">
<div className="flex justify-center">
<InputOTP
maxLength={6}
value={code}
onChange={setCode}
disabled={verifyMutation.isPending}
autoFocus
className="gap-2"
>
<InputOTPGroup>
<InputOTPSlot index={0} className="w-14 h-16 text-2xl" />
<InputOTPSlot index={1} className="w-14 h-16 text-2xl" />
<InputOTPSlot index={2} className="w-14 h-16 text-2xl" />
<InputOTPSlot index={3} className="w-14 h-16 text-2xl" />
<InputOTPSlot index={4} className="w-14 h-16 text-2xl" />
<InputOTPSlot index={5} className="w-14 h-16 text-2xl" />
</InputOTPGroup>
</InputOTP>
</div>
{error && <p className="text-sm text-destructive text-center">{error}</p>}
{resendSuccess && <p className="text-sm text-green-600 text-center">Verification code sent!</p>}
</div>
<div className="flex flex-col gap-2">
<Button type="submit" disabled={code.length !== 6 || verifyMutation.isPending} className="w-full">
{verifyMutation.isPending ? "Verifying..." : "Verify email"}
</Button>
<Button
type="button"
variant="ghost"
onClick={handleResend}
disabled={resendMutation.isPending}
className="w-full"
>
{resendMutation.isPending ? "Sending..." : "Resend code"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -4,5 +4,7 @@ export * from "@/lib/query/hooks/issues";
export * from "@/lib/query/hooks/organisations"; export * from "@/lib/query/hooks/organisations";
export * from "@/lib/query/hooks/projects"; export * from "@/lib/query/hooks/projects";
export * from "@/lib/query/hooks/sprints"; export * from "@/lib/query/hooks/sprints";
export * from "@/lib/query/hooks/subscriptions";
export * from "@/lib/query/hooks/timers"; export * from "@/lib/query/hooks/timers";
export * from "@/lib/query/hooks/users"; export * from "@/lib/query/hooks/users";
export * from "@/lib/query/hooks/verification";

View File

@@ -14,6 +14,20 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys"; import { queryKeys } from "@/lib/query/keys";
import { apiClient } from "@/lib/server"; import { apiClient } from "@/lib/server";
export interface MemberTimeTrackingSession {
id: number;
userId: number;
issueId: number;
issueNumber: number;
projectKey: string;
timestamps: string[];
endedAt: string | null;
createdAt: string | null;
workTimeMs: number;
breakTimeMs: number;
isRunning: boolean;
}
export function useOrganisations() { export function useOrganisations() {
return useQuery<OrganisationResponse[]>({ return useQuery<OrganisationResponse[]>({
queryKey: queryKeys.organisations.byUser(), queryKey: queryKeys.organisations.byUser(),
@@ -39,6 +53,23 @@ export function useOrganisationMembers(organisationId?: number | null) {
}); });
} }
export function useOrganisationMemberTimeTracking(organisationId?: number | null, fromDate?: Date) {
return useQuery<MemberTimeTrackingSession[]>({
queryKey: queryKeys.organisations.memberTimeTracking(organisationId ?? 0, fromDate?.toISOString()),
queryFn: async () => {
const { data, error } = await apiClient.organisationMemberTimeTracking({
query: {
organisationId: organisationId ?? 0,
fromDate: fromDate,
},
});
if (error) throw new Error(error);
return (data ?? []) as MemberTimeTrackingSession[];
},
enabled: Boolean(organisationId),
});
}
export function useCreateOrganisation() { export function useCreateOrganisation() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();

View File

@@ -0,0 +1,62 @@
import type {
CancelSubscriptionResponse,
CreateCheckoutSessionRequest,
CreateCheckoutSessionResponse,
CreatePortalSessionResponse,
GetSubscriptionResponse,
} from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { apiClient } from "@/lib/server";
export function useSubscription() {
return useQuery<GetSubscriptionResponse>({
queryKey: queryKeys.subscription.current(),
queryFn: async () => {
const { data, error } = await apiClient.subscriptionGet();
if (error) throw new Error(error);
return (data ?? { subscription: null }) as GetSubscriptionResponse;
},
});
}
export function useCreateCheckoutSession() {
return useMutation<CreateCheckoutSessionResponse, Error, CreateCheckoutSessionRequest>({
mutationKey: ["subscription", "checkout"],
mutationFn: async (input) => {
const { data, error } = await apiClient.subscriptionCreateCheckoutSession({ body: input });
if (error) throw new Error(error);
if (!data) throw new Error("failed to create checkout session");
return data as CreateCheckoutSessionResponse;
},
});
}
export function useCreatePortalSession() {
return useMutation<CreatePortalSessionResponse, Error>({
mutationKey: ["subscription", "portal"],
mutationFn: async () => {
const { data, error } = await apiClient.subscriptionCreatePortalSession({ body: {} });
if (error) throw new Error(error);
if (!data) throw new Error("failed to create portal session");
return data as CreatePortalSessionResponse;
},
});
}
export function useCancelSubscription() {
const queryClient = useQueryClient();
return useMutation<CancelSubscriptionResponse, Error>({
mutationKey: ["subscription", "cancel"],
mutationFn: async () => {
const { data, error } = await apiClient.subscriptionCancel({ body: {} });
if (error) throw new Error(error);
if (!data) throw new Error("failed to cancel subscription");
return data as CancelSubscriptionResponse;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.subscription.current() });
},
});
}

View File

@@ -0,0 +1,22 @@
import { useMutation } from "@tanstack/react-query";
import { apiClient } from "@/lib/server";
export function useVerifyEmail() {
return useMutation<void, Error, { code: string }>({
mutationKey: ["verification", "verify"],
mutationFn: async ({ code }) => {
const { error } = await apiClient.authVerifyEmail({ body: { code } });
if (error) throw new Error(error);
},
});
}
export function useResendVerification() {
return useMutation<void, Error>({
mutationKey: ["verification", "resend"],
mutationFn: async () => {
const { error } = await apiClient.authResendVerification({ body: {} });
if (error) throw new Error(error);
},
});
}

View File

@@ -5,6 +5,8 @@ export const queryKeys = {
all: ["organisations"] as const, all: ["organisations"] as const,
byUser: () => [...queryKeys.organisations.all, "by-user"] as const, byUser: () => [...queryKeys.organisations.all, "by-user"] as const,
members: (orgId: number) => [...queryKeys.organisations.all, orgId, "members"] as const, members: (orgId: number) => [...queryKeys.organisations.all, orgId, "members"] as const,
memberTimeTracking: (orgId: number, fromDate?: string) =>
[...queryKeys.organisations.all, orgId, "member-time-tracking", fromDate ?? "all"] as const,
}, },
projects: { projects: {
all: ["projects"] as const, all: ["projects"] as const,
@@ -37,4 +39,8 @@ export const queryKeys = {
all: ["users"] as const, all: ["users"] as const,
byUsername: (username: string) => [...queryKeys.users.all, "by-username", username] as const, byUsername: (username: string) => [...queryKeys.users.all, "by-username", username] as const,
}, },
subscription: {
all: ["subscription"] as const,
current: () => [...queryKeys.subscription.all, "current"] as const,
},
}; };

View File

@@ -37,7 +37,7 @@ export function getServerURL() {
let serverURL = let serverURL =
localStorage.getItem("serverURL") || // user-defined server URL localStorage.getItem("serverURL") || // user-defined server URL
ENV_SERVER_URL || // environment variable ENV_SERVER_URL || // environment variable
"https://tnirps.ob248.com"; // fallback "https://server.sprintpm.org"; // fallback
if (serverURL.endsWith("/")) { if (serverURL.endsWith("/")) {
serverURL = serverURL.slice(0, -1); serverURL = serverURL.slice(0, -1);
} }
@@ -69,3 +69,19 @@ export const isLight = (hex: string): boolean => {
export const unCamelCase = (str: string): string => { export const unCamelCase = (str: string): string => {
return str.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (char) => char.toUpperCase()); return str.replace(/([a-z])([A-Z])/g, "$1 $2").replace(/^./, (char) => char.toUpperCase());
}; };
export const formatDuration = (ms: number): string => {
if (ms === 0) return "0s";
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
const parts: string[] = [];
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (seconds > 0 || (hours === 0 && minutes === 0)) parts.push(`${seconds}s`);
return parts.join(" ") || "0s";
};

View File

@@ -8,10 +8,12 @@ import { SelectionProvider } from "@/components/selection-provider";
import { RequireAuth, SessionProvider } from "@/components/session-provider"; import { RequireAuth, SessionProvider } from "@/components/session-provider";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner"; import { Toaster } from "@/components/ui/sonner";
import BoringStuff from "@/pages/BoringStuff";
import Font from "@/pages/Font"; import Font from "@/pages/Font";
import Issues from "@/pages/Issues"; import Issues from "@/pages/Issues";
import Landing from "@/pages/Landing"; import Landing from "@/pages/Landing";
import NotFound from "@/pages/NotFound"; import NotFound from "@/pages/NotFound";
import Plans from "@/pages/Plans";
import Test from "@/pages/Test"; import Test from "@/pages/Test";
import Timeline from "@/pages/Timeline"; import Timeline from "@/pages/Timeline";
@@ -26,8 +28,17 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
{/* public routes */} {/* public routes */}
<Route path="/" element={<Landing />} /> <Route path="/" element={<Landing />} />
<Route path="/font" element={<Font />} /> <Route path="/font" element={<Font />} />
<Route path="/the-boring-stuff" element={<BoringStuff />} />
{/* authed routes */} {/* authed routes */}
<Route
path="/plans"
element={
<RequireAuth>
<Plans />
</RequireAuth>
}
/>
<Route <Route
path="/issues" path="/issues"
element={ element={
@@ -55,9 +66,9 @@ ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<Route path="*" element={<NotFound />} /> <Route path="*" element={<NotFound />} />
</Routes> </Routes>
<ActiveTimersOverlay />
</SelectionProvider> </SelectionProvider>
</BrowserRouter> </BrowserRouter>
<ActiveTimersOverlay />
<Toaster visibleToasts={1} duration={2000} /> <Toaster visibleToasts={1} duration={2000} />
</SessionProvider> </SessionProvider>
</QueryProvider> </QueryProvider>

View File

@@ -0,0 +1,122 @@
import { Link } from "react-router-dom";
import ThemeToggle from "@/components/theme-toggle";
export default function BoringStuff() {
return (
<div className="min-h-screen flex flex-col">
<header className="sticky top-0 z-50 w-full border-b bg-background/80 backdrop-blur-md">
<div className="w-full flex h-14 items-center justify-between px-2">
<div className="flex items-center gap-2">
<img src="/favicon.svg" alt="Sprint" className="size-12 -mt-0.5" />
<span className="text-3xl font-basteleur font-700 transition-colors -mt-0.5">Sprint</span>
</div>
<nav className="flex items-center gap-6">
<Link
to="/"
className="hidden md:block text-sm font-500 hover:text-personality transition-colors"
>
Home
</Link>
<ThemeToggle />
</nav>
</div>
</header>
<main className="flex-1 px-4 py-12">
<div className="max-w-3xl mx-auto space-y-12">
<section className="space-y-6">
<h1 className="text-4xl font-basteleur font-700">The Boring Stuff</h1>
<p className="text-muted-foreground">Let's keep it short.</p>
</section>
<section className="space-y-6" id="privacy">
<h2 className="text-2xl font-basteleur font-700">Privacy Policy</h2>
<div className="space-y-4 text-muted-foreground">
<p>
<strong className="text-foreground">What we store:</strong> We store your email, name, and any
data you create (issues, projects, time tracking).
</p>
<p>
<strong className="text-foreground">How we use it:</strong> Only your email is used for
subscription alerts and newsletters (you can unsubscribe).
</p>
<p>
<strong className="text-foreground">Where it's stored:</strong> Data is stored on secure
servers.
</p>
<p>
{/* <strong className="text-foreground">Your rights:</strong> You can export or delete your data
anytime. Just email us at privacy@sprintpm.org. */}
</p>
<p>
<strong className="text-foreground">Cookies:</strong> We use essential cookies for
authentication.
</p>
</div>
</section>
<section className="space-y-6" id="terms">
<h2 className="text-2xl font-basteleur font-700">Terms of Service</h2>
<div className="space-y-4 text-muted-foreground">
<p>
<strong className="text-foreground">The basics:</strong> Sprint is a project management tool.
Use it to organise work, track issues, and manage time. Don't use it for illegal stuff.
</p>
<p>
<strong className="text-foreground">Your account:</strong> You're responsible for keeping your
login details secure. Don't share your account.
</p>
<p>
<strong className="text-foreground">Payments:</strong> Pro plans are billed monthly or
annually. Cancel anytime from your account settings. No refunds for partial months.
</p>
<p>
<strong className="text-foreground">Service availability:</strong> We aim for 99.9% uptime but
can't guarantee it. We may occasionally need downtime for maintenance.
</p>
<p>
<strong className="text-foreground">Termination:</strong> We may suspend accounts that violate
these terms.
</p>
<p>
<strong className="text-foreground">Changes:</strong> We'll notify you of significant changes
to these terms via email.
</p>
</div>
</section>
<section className="space-y-6" id="contact">
<h2 className="text-2xl font-basteleur font-700">Questions?</h2>
<p className="text-muted-foreground">
Email us at{" "}
<a href="mailto:support@sprintpm.org" className="text-personality hover:underline">
support@sprintpm.org
</a>{" "}
- we'll get back to you within 24 hours.
</p>
</section>
<div className="pt-8 border-t">
<p className="text-sm text-muted-foreground">Last updated: January 2025</p>
</div>
</div>
</main>
<footer className="flex justify-center gap-2 items-center py-1 border-t">
<span className="font-300 text-lg text-muted-foreground">
Built by{" "}
<a
href="https://ob248.com"
target="_blank"
rel="noopener noreferrer"
className="hover:text-personality font-700"
>
Oliver Bryan
</a>
</span>
<a href="https://ob248.com" target="_blank" rel="noopener noreferrer">
<img src="/oliver-bryan.svg" alt="Oliver Bryan" className="w-4 h-4" />
</a>
</footer>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { LoginModal } from "@/components/login-modal"; import { LoginModal } from "@/components/login-modal";
import { PricingCard, pricingTiers } from "@/components/pricing-card";
import { useSession } from "@/components/session-provider"; import { useSession } from "@/components/session-provider";
import ThemeToggle from "@/components/theme-toggle"; import ThemeToggle from "@/components/theme-toggle";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -8,57 +9,7 @@ import Icon from "@/components/ui/icon";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const pricingTiers = [
{
name: "Starter",
price: "£0",
priceAnnual: "£0",
period: "Free forever",
periodAnnual: "Free forever",
description: "Perfect for side projects and solo developers",
tagline: "For solo devs and small projects",
features: [
"1 organisation (owned or joined)",
"1 project",
"100 issues",
"Up to 5 team members",
"Email support",
],
cta: "Get started free",
highlighted: false,
},
{
name: "Pro",
price: "£11.99",
priceAnnual: "£9.99",
period: "per user/month",
periodAnnual: "per user/month",
description: "For growing teams and professionals",
tagline: "Most Popular",
features: [
"Everything in starter",
"Unlimited organisations",
"Unlimited projects",
"Unlimited issues",
"Advanced time tracking & reports",
"Custom issue statuses",
"Priority email support",
],
cta: "Try pro free for 14 days",
highlighted: true,
},
];
const faqs = [ const faqs = [
{
question: "Can I switch plans?",
answer:
"Yes, you can upgrade or downgrade at any time. Changes take effect immediately, and we'll prorate any charges.",
},
{
question: "Is there a free trial?",
answer: "Yes, pro plan includes a 14-day free trial with full access. No credit card required to start.",
},
{ {
question: "What payment methods do you accept?", question: "What payment methods do you accept?",
answer: "We accept all major credit cards.", answer: "We accept all major credit cards.",
@@ -68,11 +19,6 @@ const faqs = [
answer: answer:
"Pro plan pricing scales with your team. Add or remove users anytime, and we'll adjust your billing automatically.", "Pro plan pricing scales with your team. Add or remove users anytime, and we'll adjust your billing automatically.",
}, },
{
question: "What happens when my trial ends?",
answer:
"You'll automatically downgrade to the free starter plan. No charges unless you actively upgrade to pro.",
},
{ {
question: "Can I cancel anytime?", question: "Can I cancel anytime?",
answer: answer:
@@ -86,7 +32,7 @@ const faqs = [
export default function Landing() { export default function Landing() {
const { user, isLoading } = useSession(); const { user, isLoading } = useSession();
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("monthly"); const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("annual");
const [loginModalOpen, setLoginModalOpen] = useState(false); const [loginModalOpen, setLoginModalOpen] = useState(false);
return ( return (
@@ -163,7 +109,7 @@ export default function Landing() {
) : ( ) : (
<> <>
<Button size="lg" className="text-lg px-8 py-6" onClick={() => setLoginModalOpen(true)}> <Button size="lg" className="text-lg px-8 py-6" onClick={() => setLoginModalOpen(true)}>
Start free trial Get started
</Button> </Button>
<Button asChild variant="outline" size="lg" className="text-lg px-8 py-6"> <Button asChild variant="outline" size="lg" className="text-lg px-8 py-6">
<a href="#pricing">See pricing</a> <a href="#pricing">See pricing</a>
@@ -172,7 +118,7 @@ export default function Landing() {
)} )}
</div> </div>
<p className="text-sm text-muted-foreground">No credit card required · Full access for 14 days</p> <p className="text-sm text-muted-foreground">Free forever · Upgrade when you need more</p>
</div> </div>
{/* problem section */} {/* problem section */}
@@ -323,57 +269,12 @@ export default function Landing() {
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-4xl"> <div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-4xl">
{pricingTiers.map((tier) => ( {pricingTiers.map((tier) => (
<div <PricingCard
key={tier.name} key={tier.name}
className={cn( tier={tier}
"flex flex-col border p-8 space-y-6 relative", billingPeriod={billingPeriod}
tier.highlighted ? "border-2 border-personality shadow-lg scale-105" : "border-border", onCtaClick={() => setLoginModalOpen(true)}
)}
>
{tier.highlighted && (
<div className="absolute -top-4 left-4 bg-personality text-background px-3 py-1 text-xs font-700">
{tier.tagline}
</div>
)}
<div className="space-y-4">
<h3 className="text-3xl font-basteleur font-700">{tier.name}</h3>
<div className="flex items-baseline gap-2">
<span className="text-4xl font-700">
{billingPeriod === "annual" ? tier.priceAnnual : tier.price}
</span>
<span className="text-sm text-muted-foreground">
{billingPeriod === "annual" ? tier.periodAnnual : tier.period}
</span>
</div>
<p className="text-muted-foreground">{tier.description}</p>
</div>
<ul className="space-y-3 flex-1">
{tier.features.map((feature) => (
<li key={feature} className="flex items-start gap-2 text-sm">
<Icon
icon="check"
iconStyle={"pixel"}
className="size-6 -mt-0.5"
color="var(--personality)"
/> />
<span>{feature}</span>
</li>
))}
</ul>
<Button
variant={tier.highlighted ? "default" : "outline"}
className={cn(
"font-700 py-6",
tier.highlighted ? "bg-personality hover:bg-personality/90 text-background" : "",
)}
onClick={() => setLoginModalOpen(true)}
>
{tier.cta}
</Button>
</div>
))} ))}
</div> </div>
@@ -391,8 +292,8 @@ export default function Landing() {
className="size-8" className="size-8"
color="var(--personality)" color="var(--personality)"
/> />
<p className="font-700">No Card Required</p> <p className="font-700">Free Starter Plan</p>
<p className="text-sm text-muted-foreground">Start your trial instantly</p> <p className="text-sm text-muted-foreground">Get started instantly</p>
</div> </div>
<div className="flex flex-col items-center text-center gap-2"> <div className="flex flex-col items-center text-center gap-2">
<Icon icon="rotateCcw" iconStyle={"pixel"} className="size-8" color="var(--personality)" /> <Icon icon="rotateCcw" iconStyle={"pixel"} className="size-8" color="var(--personality)" />
@@ -456,12 +357,12 @@ export default function Landing() {
</Button> </Button>
) : ( ) : (
<Button size="lg" className="text-lg px-8 py-6" onClick={() => setLoginModalOpen(true)}> <Button size="lg" className="text-lg px-8 py-6" onClick={() => setLoginModalOpen(true)}>
Start your free trial Get started
</Button> </Button>
)} )}
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
No credit card required · 14-day free trial · Cancel anytime Free forever · Upgrade when you need more · Cancel anytime
</p> </p>
</div> </div>
</div> </div>
@@ -469,7 +370,8 @@ export default function Landing() {
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} /> <LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
<footer className="flex justify-center gap-2 items-center py-1 border-t"> <footer className="flex flex-col items-center gap-2 py-1 border-t">
<div className="flex justify-center gap-2 items-center">
<span className="font-300 text-lg text-muted-foreground"> <span className="font-300 text-lg text-muted-foreground">
Built by{" "} Built by{" "}
<a <a
@@ -484,6 +386,13 @@ export default function Landing() {
<a href="https://ob248.com" target="_blank" rel="noopener noreferrer"> <a href="https://ob248.com" target="_blank" rel="noopener noreferrer">
<img src="oliver-bryan.svg" alt="Oliver Bryan" className="w-4 h-4" /> <img src="oliver-bryan.svg" alt="Oliver Bryan" className="w-4 h-4" />
</a> </a>
</div>
<Link
to="/the-boring-stuff"
className="text-sm text-muted-foreground hover:text-personality transition-colors"
>
The boring stuff Privacy Policy & ToS
</Link>
</footer> </footer>
</div> </div>
); );

View File

@@ -0,0 +1,304 @@
import { format } from "date-fns";
import { useMemo, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { LoginModal } from "@/components/login-modal";
import { PricingCard, pricingTiers } from "@/components/pricing-card";
import { useSession } from "@/components/session-provider";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import Icon from "@/components/ui/icon";
import { Switch } from "@/components/ui/switch";
import {
useCancelSubscription,
useCreateCheckoutSession,
useCreatePortalSession,
useSubscription,
} from "@/lib/query/hooks";
import { cn } from "@/lib/utils";
export default function Plans() {
const { user, isLoading } = useSession();
const navigate = useNavigate();
const [billingPeriod, setBillingPeriod] = useState<"monthly" | "annual">("annual");
const [loginModalOpen, setLoginModalOpen] = useState(false);
const [processingTier, setProcessingTier] = useState<string | null>(null);
const { data: subscriptionData } = useSubscription();
const createCheckoutSession = useCreateCheckoutSession();
const createPortalSession = useCreatePortalSession();
const cancelSubscription = useCancelSubscription();
const subscription = subscriptionData?.subscription ?? null;
const isProUser =
user?.plan === "pro" || subscription?.status === "active" || subscription?.status === "trialing";
const isCancellationScheduled = Boolean(subscription?.cancelAtPeriodEnd);
const isCanceled = subscription?.status === "canceled";
const cancellationEndDate = useMemo(() => {
if (!subscription?.currentPeriodEnd) return null;
const date = new Date(subscription.currentPeriodEnd);
if (Number.isNaN(date.getTime())) return null;
return format(date, "d MMM yyyy");
}, [subscription?.currentPeriodEnd]);
const [cancelDialogOpen, setCancelDialogOpen] = useState(false);
const [cancelError, setCancelError] = useState<string | null>(null);
const handleTierAction = async (tierName: string) => {
if (!user) {
setLoginModalOpen(true);
return;
}
if (tierName === "Pro") {
if (isProUser) {
// open customer portal
setProcessingTier(tierName);
try {
const result = await createPortalSession.mutateAsync();
if (result.url) {
window.location.href = result.url;
} else {
setProcessingTier(null);
}
} catch {
setProcessingTier(null);
}
} else {
// start checkout
setProcessingTier(tierName);
try {
const result = await createCheckoutSession.mutateAsync({ billingPeriod });
if (result.url) {
window.location.href = result.url;
} else {
setProcessingTier(null);
}
} catch {
setProcessingTier(null);
}
}
}
// starter tier - just go to issues if not already there
if (tierName === "Starter") {
navigate("/issues");
}
};
const handleCancelSubscription = async () => {
setCancelError(null);
try {
await cancelSubscription.mutateAsync();
setCancelDialogOpen(false);
} catch (error) {
const message = error instanceof Error ? error.message : "failed to cancel subscription";
setCancelError(message);
}
};
// modify pricing tiers based on user's current plan
const modifiedTiers = pricingTiers.map((tier) => {
const isCurrentPlan = tier.name === "Pro" && isProUser;
const isStarterCurrent = tier.name === "Starter" && !!user && !isProUser;
return {
...tier,
highlighted: isCurrentPlan || (!isProUser && tier.name === "Pro"),
cta: isCurrentPlan
? "Manage subscription"
: isStarterCurrent
? "Current plan"
: tier.name === "Pro"
? "Upgrade to Pro"
: tier.cta,
};
});
return (
<div className="min-h-screen flex flex-col">
<header className="sticky top-0 z-50 w-full border-b bg-background/80 backdrop-blur-md">
<div className="w-full flex h-14 items-center justify-between px-2">
<div className="flex items-center gap-2">
<img src="/favicon.svg" alt="Sprint" className="size-12 -mt-0.5" />
<span className="text-3xl font-basteleur font-700 transition-colors -mt-0.5">Sprint</span>
</div>
<nav className="flex items-center gap-6">
<Link
to="/"
className="hidden md:block text-sm font-500 hover:text-personality transition-colors"
>
Home
</Link>
<div className="flex items-center gap-2">
{!isLoading && user ? (
<Button asChild variant="outline" size="sm">
<Link to="/issues">Open app</Link>
</Button>
) : (
<Button variant="outline" size="sm" onClick={() => setLoginModalOpen(true)}>
Sign in
</Button>
)}
</div>
</nav>
</div>
</header>
<main className="flex-1 flex flex-col items-center py-16 pt-14 px-4">
<div className="max-w-6xl w-full space-y-16">
<div className="text-center space-y-6">
<h1 className="text-5xl font-basteleur font-700">
{user ? "Choose your plan" : "Simple, transparent pricing"}
</h1>
<p className="text-xl text-muted-foreground max-w-2xl mx-auto">
{user
? isProUser
? "You are currently on the Pro plan. Manage your subscription or switch plans below."
: "You are currently on the Starter plan. Upgrade to Pro for unlimited access."
: "Choose the plan that fits your team. Scale as you grow."}
</p>
{/* billing toggle */}
<div className="flex items-center justify-center gap-4 pt-4">
<button
type="button"
onClick={() => setBillingPeriod("monthly")}
className={cn(
"text-lg transition-colors",
billingPeriod === "monthly" ? "text-foreground font-700" : "text-muted-foreground",
)}
>
monthly
</button>
<Switch
size="lg"
checked={billingPeriod === "annual"}
onCheckedChange={(checked) => setBillingPeriod(checked ? "annual" : "monthly")}
className="bg-border data-[state=checked]:bg-border! data-[state=unchecked]:bg-border!"
thumbClassName="bg-personality dark:bg-personality data-[state=checked]:bg-personality! data-[state=unchecked]:bg-personality!"
aria-label="toggle billing period"
/>
<button
type="button"
onClick={() => setBillingPeriod("annual")}
className={cn(
"text-lg transition-colors",
billingPeriod === "annual" ? "text-foreground font-700" : "text-muted-foreground",
)}
>
annual
</button>
<span className="text-sm px-3 py-1 bg-personality/10 text-personality rounded-full font-600">
Save 17%
</span>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 w-full max-w-4xl mx-auto">
{modifiedTiers.map((tier) => (
<PricingCard
key={tier.name}
tier={tier}
billingPeriod={billingPeriod}
onCtaClick={() => handleTierAction(tier.name)}
disabled={processingTier !== null || tier.name === "Starter"}
loading={processingTier === tier.name}
/>
))}
</div>
{user && isProUser && (
<div className="w-full max-w-4xl mx-auto border p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<p className="font-700">Cancel subscription</p>
<p className="text-sm text-muted-foreground">
{isCancellationScheduled || isCanceled
? `Cancelled, benefits end on ${cancellationEndDate ?? "your billing end date"}.`
: "Canceling will keep access until the end of your billing period."}
</p>
</div>
<AlertDialog
open={cancelDialogOpen}
onOpenChange={(open: boolean) => {
setCancelDialogOpen(open);
if (!open) setCancelError(null);
}}
>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
disabled={cancelSubscription.isPending || isCancellationScheduled || isCanceled}
>
{isCancellationScheduled || isCanceled
? "Cancellation scheduled"
: "Cancel subscription"}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Cancel subscription?</AlertDialogTitle>
<AlertDialogDescription>
You will keep Pro access until the end of your current billing period.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Keep subscription</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleCancelSubscription}>
{cancelSubscription.isPending ? "Canceling..." : "Confirm cancel"}
</AlertDialogAction>
</AlertDialogFooter>
{cancelError && <p className="text-sm text-destructive">{cancelError}</p>}
</AlertDialogContent>
</AlertDialog>
</div>
</div>
)}
{/* trust signals */}
<div className="grid md:grid-cols-3 gap-8 w-full border-t pt-16 pb-4 max-w-4xl mx-auto">
<div className="flex flex-col items-center text-center gap-2">
<Icon icon="eyeClosed" iconStyle={"pixel"} className="size-8" color="var(--personality)" />
<p className="font-700">Secure & Encrypted</p>
<p className="text-sm text-muted-foreground">Your data is safe with us</p>
</div>
<div className="flex flex-col items-center text-center gap-2">
<Icon
icon="creditCardDelete"
iconStyle={"pixel"}
className="size-8"
color="var(--personality)"
/>
<p className="font-700">Free Starter Plan</p>
<p className="text-sm text-muted-foreground">Get started instantly</p>
</div>
<div className="flex flex-col items-center text-center gap-2">
<Icon icon="rotateCcw" iconStyle={"pixel"} className="size-8" color="var(--personality)" />
<p className="font-700">Money Back Guarantee</p>
<p className="text-sm text-muted-foreground">30-day no-risk policy</p>
</div>
</div>
<div className="w-full max-w-4xl mx-auto border-t pt-4 pb-2 text-center">
<Link
to="/the-boring-stuff"
className="text-sm text-muted-foreground hover:text-personality transition-colors"
>
The boring stuff Privacy Policy & ToS
</Link>
</div>
</div>
</main>
<LoginModal open={loginModalOpen} onOpenChange={setLoginModalOpen} />
</div>
);
}

View File

@@ -22,7 +22,7 @@ export default defineConfig(async () => ({
server: { server: {
port: 1420, port: 1420,
strictPort: true, strictPort: true,
allowedHosts: ["sprint.ob248.com"], allowedHosts: ["sprint.ob248.com", "sprintpm.org"],
host: host || false, host: host || false,
hmr: host hmr: host
? { ? {

View File

@@ -9,6 +9,7 @@ import {
ORG_NAME_MAX_LENGTH, ORG_NAME_MAX_LENGTH,
ORG_SLUG_MAX_LENGTH, ORG_SLUG_MAX_LENGTH,
PROJECT_NAME_MAX_LENGTH, PROJECT_NAME_MAX_LENGTH,
USER_EMAIL_MAX_LENGTH,
USER_NAME_MAX_LENGTH, USER_NAME_MAX_LENGTH,
USER_USERNAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH,
} from "./constants"; } from "./constants";
@@ -40,6 +41,7 @@ export const RegisterRequestSchema = z.object({
.min(1, "Username is required") .min(1, "Username is required")
.max(USER_USERNAME_MAX_LENGTH) .max(USER_USERNAME_MAX_LENGTH)
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"), .regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
email: z.string().min(1, "Email is required").email("Invalid email address").max(USER_EMAIL_MAX_LENGTH),
password: z password: z
.string() .string()
.min(8, "Password must be at least 8 characters") .min(8, "Password must be at least 8 characters")
@@ -58,12 +60,21 @@ export const AuthResponseSchema = z.object({
username: z.string(), username: z.string(),
avatarURL: z.string().nullable(), avatarURL: z.string().nullable(),
iconPreference: z.enum(["lucide", "pixel", "phosphor"]), iconPreference: z.enum(["lucide", "pixel", "phosphor"]),
emailVerified: z.boolean(),
}), }),
csrfToken: z.string(), csrfToken: z.string(),
}); });
export type AuthResponse = z.infer<typeof AuthResponseSchema>; export type AuthResponse = z.infer<typeof AuthResponseSchema>;
// email verification schemas
export const VerifyEmailRequestSchema = z.object({
code: z.string().length(6, "Verification code must be 6 digits"),
});
export type VerifyEmailRequest = z.infer<typeof VerifyEmailRequestSchema>;
// issue schemas // issue schemas
export const IssueCreateRequestSchema = z.object({ export const IssueCreateRequestSchema = z.object({
@@ -227,6 +238,13 @@ export const OrgMembersQuerySchema = z.object({
export type OrgMembersQuery = z.infer<typeof OrgMembersQuerySchema>; export type OrgMembersQuery = z.infer<typeof OrgMembersQuerySchema>;
export const OrgMemberTimeTrackingQuerySchema = z.object({
organisationId: z.coerce.number().int().positive("organisationId must be a positive integer"),
fromDate: z.coerce.date().optional(),
});
export type OrgMemberTimeTrackingQuery = z.infer<typeof OrgMemberTimeTrackingQuerySchema>;
export const OrgAddMemberRequestSchema = z.object({ export const OrgAddMemberRequestSchema = z.object({
organisationId: z.number().int().positive("organisationId must be a positive integer"), organisationId: z.number().int().positive("organisationId must be a positive integer"),
userId: z.number().int().positive("userId must be a positive integer"), userId: z.number().int().positive("userId must be a positive integer"),
@@ -412,6 +430,7 @@ export const UserResponseSchema = z.object({
username: z.string(), username: z.string(),
avatarURL: z.string().nullable(), avatarURL: z.string().nullable(),
iconPreference: z.enum(["lucide", "pixel", "phosphor"]), iconPreference: z.enum(["lucide", "pixel", "phosphor"]),
plan: z.string().nullable().optional(),
createdAt: z.string().nullable().optional(), createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(), updatedAt: z.string().nullable().optional(),
}); });
@@ -594,3 +613,54 @@ export const SuccessResponseSchema = z.object({
}); });
export type SuccessResponse = z.infer<typeof SuccessResponseSchema>; export type SuccessResponse = z.infer<typeof SuccessResponseSchema>;
// subscription schemas
export const CreateCheckoutSessionRequestSchema = z.object({
billingPeriod: z.enum(["monthly", "annual"]),
});
export type CreateCheckoutSessionRequest = z.infer<typeof CreateCheckoutSessionRequestSchema>;
export const CreateCheckoutSessionResponseSchema = z.object({
url: z.string(),
});
export type CreateCheckoutSessionResponse = z.infer<typeof CreateCheckoutSessionResponseSchema>;
export const CreatePortalSessionResponseSchema = z.object({
url: z.string(),
});
export type CreatePortalSessionResponse = z.infer<typeof CreatePortalSessionResponseSchema>;
export const SubscriptionRecordSchema = z.object({
id: z.number(),
userId: z.number(),
stripeCustomerId: z.string().nullable(),
stripeSubscriptionId: z.string().nullable(),
stripeSubscriptionItemId: z.string().nullable(),
stripePriceId: z.string().nullable(),
status: z.string(),
currentPeriodStart: z.string().nullable().optional(),
currentPeriodEnd: z.string().nullable().optional(),
cancelAtPeriodEnd: z.boolean(),
trialEnd: z.string().nullable().optional(),
quantity: z.number(),
createdAt: z.string().nullable().optional(),
updatedAt: z.string().nullable().optional(),
});
export type SubscriptionRecord = z.infer<typeof SubscriptionRecordSchema>;
export const GetSubscriptionResponseSchema = z.object({
subscription: SubscriptionRecordSchema.nullable(),
});
export type GetSubscriptionResponse = z.infer<typeof GetSubscriptionResponseSchema>;
export const CancelSubscriptionResponseSchema = z.object({
subscription: SubscriptionRecordSchema,
});
export type CancelSubscriptionResponse = z.infer<typeof CancelSubscriptionResponseSchema>;

View File

@@ -1,5 +1,6 @@
export const USER_NAME_MAX_LENGTH = 64; export const USER_NAME_MAX_LENGTH = 64;
export const USER_USERNAME_MAX_LENGTH = 32; export const USER_USERNAME_MAX_LENGTH = 32;
export const USER_EMAIL_MAX_LENGTH = 256;
export const ORG_NAME_MAX_LENGTH = 64; export const ORG_NAME_MAX_LENGTH = 64;
export const ORG_DESCRIPTION_MAX_LENGTH = 1024; export const ORG_DESCRIPTION_MAX_LENGTH = 1024;

View File

@@ -3,6 +3,11 @@ import { z } from "zod";
import { import {
ApiErrorSchema, ApiErrorSchema,
AuthResponseSchema, AuthResponseSchema,
CancelSubscriptionResponseSchema,
CreateCheckoutSessionRequestSchema,
CreateCheckoutSessionResponseSchema,
CreatePortalSessionResponseSchema,
GetSubscriptionResponseSchema,
IssueByIdQuerySchema, IssueByIdQuerySchema,
IssueCommentCreateRequestSchema, IssueCommentCreateRequestSchema,
IssueCommentDeleteRequestSchema, IssueCommentDeleteRequestSchema,
@@ -29,6 +34,7 @@ import {
OrgCreateRequestSchema, OrgCreateRequestSchema,
OrgDeleteRequestSchema, OrgDeleteRequestSchema,
OrgMembersQuerySchema, OrgMembersQuerySchema,
OrgMemberTimeTrackingQuerySchema,
OrgRemoveMemberRequestSchema, OrgRemoveMemberRequestSchema,
OrgUpdateMemberRoleRequestSchema, OrgUpdateMemberRoleRequestSchema,
OrgUpdateRequestSchema, OrgUpdateRequestSchema,
@@ -171,6 +177,7 @@ export const apiContract = c.router({
responses: { responses: {
200: z.object({ avatarURL: z.string() }), 200: z.object({ avatarURL: z.string() }),
400: ApiErrorSchema, 400: ApiErrorSchema,
403: ApiErrorSchema,
}, },
headers: csrfHeaderSchema, headers: csrfHeaderSchema,
}, },
@@ -379,6 +386,30 @@ export const apiContract = c.router({
200: z.array(OrganisationMemberResponseSchema), 200: z.array(OrganisationMemberResponseSchema),
}, },
}, },
organisationMemberTimeTracking: {
method: "GET",
path: "/organisation/member-time-tracking",
query: OrgMemberTimeTrackingQuerySchema,
responses: {
200: z.array(
z.object({
id: z.number(),
userId: z.number(),
issueId: z.number(),
issueNumber: z.number(),
projectKey: z.string(),
timestamps: z.array(z.string()),
endedAt: z.string().nullable(),
createdAt: z.string().nullable(),
workTimeMs: z.number(),
breakTimeMs: z.number(),
isRunning: z.boolean(),
}),
),
403: ApiErrorSchema,
404: ApiErrorSchema,
},
},
organisationRemoveMember: { organisationRemoveMember: {
method: "POST", method: "POST",
path: "/organisation/remove-member", path: "/organisation/remove-member",
@@ -575,6 +606,73 @@ export const apiContract = c.router({
200: z.array(timerListItemResponseSchema), 200: z.array(timerListItemResponseSchema),
}, },
}, },
subscriptionCreateCheckoutSession: {
method: "POST",
path: "/subscription/create-checkout-session",
body: CreateCheckoutSessionRequestSchema,
responses: {
200: CreateCheckoutSessionResponseSchema,
400: ApiErrorSchema,
404: ApiErrorSchema,
500: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
subscriptionCreatePortalSession: {
method: "POST",
path: "/subscription/create-portal-session",
body: emptyBodySchema,
responses: {
200: CreatePortalSessionResponseSchema,
404: ApiErrorSchema,
500: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
subscriptionCancel: {
method: "POST",
path: "/subscription/cancel",
body: emptyBodySchema,
responses: {
200: CancelSubscriptionResponseSchema,
404: ApiErrorSchema,
500: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
subscriptionGet: {
method: "GET",
path: "/subscription/get",
responses: {
200: GetSubscriptionResponseSchema,
500: ApiErrorSchema,
},
},
authVerifyEmail: {
method: "POST",
path: "/auth/verify-email",
body: z.object({ code: z.string() }),
responses: {
200: SuccessResponseSchema,
400: ApiErrorSchema,
401: ApiErrorSchema,
404: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
authResendVerification: {
method: "POST",
path: "/auth/resend-verification",
body: emptyBodySchema,
responses: {
200: SuccessResponseSchema,
400: ApiErrorSchema,
401: ApiErrorSchema,
},
headers: csrfHeaderSchema,
},
}); });
export type ApiContract = typeof apiContract; export type ApiContract = typeof apiContract;

View File

@@ -1,6 +1,11 @@
export type { export type {
ApiError, ApiError,
AuthResponse, AuthResponse,
CancelSubscriptionResponse,
CreateCheckoutSessionRequest,
CreateCheckoutSessionResponse,
CreatePortalSessionResponse,
GetSubscriptionResponse,
IssueByIdQuery, IssueByIdQuery,
IssueCommentCreateRequest, IssueCommentCreateRequest,
IssueCommentDeleteRequest, IssueCommentDeleteRequest,
@@ -25,6 +30,7 @@ export type {
OrgCreateRequest, OrgCreateRequest,
OrgDeleteRequest, OrgDeleteRequest,
OrgMembersQuery, OrgMembersQuery,
OrgMemberTimeTrackingQuery,
OrgRemoveMemberRequest, OrgRemoveMemberRequest,
OrgUpdateMemberRoleRequest, OrgUpdateMemberRoleRequest,
OrgUpdateRequest, OrgUpdateRequest,
@@ -45,6 +51,7 @@ export type {
SprintsByProjectQuery, SprintsByProjectQuery,
SprintUpdateRequest, SprintUpdateRequest,
StatusCountResponse, StatusCountResponse,
SubscriptionRecord as SubscriptionResponse,
SuccessResponse, SuccessResponse,
TimerEndRequest, TimerEndRequest,
TimerGetQuery, TimerGetQuery,
@@ -56,11 +63,17 @@ export type {
UserByUsernameQuery, UserByUsernameQuery,
UserResponse, UserResponse,
UserUpdateRequest, UserUpdateRequest,
VerifyEmailRequest,
} from "./api-schemas"; } from "./api-schemas";
// API schemas // API schemas
export { export {
ApiErrorSchema, ApiErrorSchema,
AuthResponseSchema, AuthResponseSchema,
CancelSubscriptionResponseSchema,
CreateCheckoutSessionRequestSchema,
CreateCheckoutSessionResponseSchema,
CreatePortalSessionResponseSchema,
GetSubscriptionResponseSchema,
IssueByIdQuerySchema, IssueByIdQuerySchema,
IssueCommentCreateRequestSchema, IssueCommentCreateRequestSchema,
IssueCommentDeleteRequestSchema, IssueCommentDeleteRequestSchema,
@@ -87,6 +100,7 @@ export {
OrgCreateRequestSchema, OrgCreateRequestSchema,
OrgDeleteRequestSchema, OrgDeleteRequestSchema,
OrgMembersQuerySchema, OrgMembersQuerySchema,
OrgMemberTimeTrackingQuerySchema,
OrgRemoveMemberRequestSchema, OrgRemoveMemberRequestSchema,
OrgUpdateMemberRoleRequestSchema, OrgUpdateMemberRoleRequestSchema,
OrgUpdateRequestSchema, OrgUpdateRequestSchema,
@@ -108,6 +122,7 @@ export {
SprintsByProjectQuerySchema, SprintsByProjectQuerySchema,
SprintUpdateRequestSchema, SprintUpdateRequestSchema,
StatusCountResponseSchema, StatusCountResponseSchema,
SubscriptionRecordSchema as SubscriptionRecordApiSchema,
SuccessResponseSchema, SuccessResponseSchema,
TimerEndRequestSchema, TimerEndRequestSchema,
TimerGetQuerySchema, TimerGetQuerySchema,
@@ -119,6 +134,7 @@ export {
UserByUsernameQuerySchema, UserByUsernameQuerySchema,
UserResponseSchema, UserResponseSchema,
UserUpdateRequestSchema, UserUpdateRequestSchema,
VerifyEmailRequestSchema,
} from "./api-schemas"; } from "./api-schemas";
export { export {
ISSUE_COMMENT_MAX_LENGTH, ISSUE_COMMENT_MAX_LENGTH,
@@ -132,12 +148,17 @@ export {
PROJECT_DESCRIPTION_MAX_LENGTH, PROJECT_DESCRIPTION_MAX_LENGTH,
PROJECT_NAME_MAX_LENGTH, PROJECT_NAME_MAX_LENGTH,
PROJECT_SLUG_MAX_LENGTH, PROJECT_SLUG_MAX_LENGTH,
USER_EMAIL_MAX_LENGTH,
USER_NAME_MAX_LENGTH, USER_NAME_MAX_LENGTH,
USER_USERNAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH,
} from "./constants"; } from "./constants";
export type { ApiContract } from "./contract"; export type { ApiContract } from "./contract";
export { apiContract } from "./contract"; export { apiContract } from "./contract";
export type { export type {
EmailJobInsert,
EmailJobRecord,
EmailVerificationInsert,
EmailVerificationRecord,
IconStyle, IconStyle,
IssueAssigneeInsert, IssueAssigneeInsert,
IssueAssigneeRecord, IssueAssigneeRecord,
@@ -153,6 +174,8 @@ export type {
OrganisationMemberResponse as OrganisationMemberResponseRecord, OrganisationMemberResponse as OrganisationMemberResponseRecord,
OrganisationRecord, OrganisationRecord,
OrganisationResponse as OrganisationResponseRecord, OrganisationResponse as OrganisationResponseRecord,
PaymentInsert,
PaymentRecord,
ProjectInsert, ProjectInsert,
ProjectRecord, ProjectRecord,
ProjectResponse as ProjectResponseRecord, ProjectResponse as ProjectResponseRecord,
@@ -160,6 +183,8 @@ export type {
SessionRecord, SessionRecord,
SprintInsert, SprintInsert,
SprintRecord, SprintRecord,
SubscriptionInsert,
SubscriptionRecord as SubscriptionRecordType,
TimedSessionInsert, TimedSessionInsert,
TimedSessionRecord, TimedSessionRecord,
TimerState, TimerState,
@@ -172,6 +197,12 @@ export {
DEFAULT_SPRINT_COLOUR, DEFAULT_SPRINT_COLOUR,
DEFAULT_STATUS_COLOUR, DEFAULT_STATUS_COLOUR,
DEFAULT_STATUS_COLOURS, DEFAULT_STATUS_COLOURS,
EmailJob,
EmailJobInsertSchema,
EmailJobSelectSchema,
EmailVerification,
EmailVerificationInsertSchema,
EmailVerificationSelectSchema,
Issue, Issue,
IssueAssignee, IssueAssignee,
IssueAssigneeInsertSchema, IssueAssigneeInsertSchema,
@@ -188,6 +219,9 @@ export {
OrganisationMemberInsertSchema, OrganisationMemberInsertSchema,
OrganisationMemberSelectSchema, OrganisationMemberSelectSchema,
OrganisationSelectSchema, OrganisationSelectSchema,
Payment,
PaymentInsertSchema,
PaymentSelectSchema,
Project, Project,
ProjectInsertSchema, ProjectInsertSchema,
ProjectSelectSchema, ProjectSelectSchema,
@@ -197,6 +231,9 @@ export {
Sprint, Sprint,
SprintInsertSchema, SprintInsertSchema,
SprintSelectSchema, SprintSelectSchema,
Subscription,
SubscriptionInsertSchema,
SubscriptionSelectSchema,
TimedSession, TimedSession,
TimedSessionInsertSchema, TimedSessionInsertSchema,
TimedSessionSelectSchema, TimedSessionSelectSchema,

View File

@@ -1,4 +1,4 @@
import { integer, json, pgTable, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core"; import { boolean, integer, json, pgTable, text, timestamp, uniqueIndex, varchar } from "drizzle-orm/pg-core";
import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { createInsertSchema, createSelectSchema } from "drizzle-zod";
import type { z } from "zod"; import type { z } from "zod";
import { import {
@@ -11,6 +11,7 @@ import {
ORG_NAME_MAX_LENGTH, ORG_NAME_MAX_LENGTH,
ORG_SLUG_MAX_LENGTH, ORG_SLUG_MAX_LENGTH,
PROJECT_NAME_MAX_LENGTH, PROJECT_NAME_MAX_LENGTH,
USER_EMAIL_MAX_LENGTH,
USER_NAME_MAX_LENGTH, USER_NAME_MAX_LENGTH,
USER_USERNAME_MAX_LENGTH, USER_USERNAME_MAX_LENGTH,
} from "./constants"; } from "./constants";
@@ -56,9 +57,13 @@ export const User = pgTable("User", {
id: integer().primaryKey().generatedAlwaysAsIdentity(), id: integer().primaryKey().generatedAlwaysAsIdentity(),
name: varchar({ length: USER_NAME_MAX_LENGTH }).notNull(), name: varchar({ length: USER_NAME_MAX_LENGTH }).notNull(),
username: varchar({ length: USER_USERNAME_MAX_LENGTH }).notNull().unique(), username: varchar({ length: USER_USERNAME_MAX_LENGTH }).notNull().unique(),
email: varchar({ length: USER_EMAIL_MAX_LENGTH }).notNull().unique(),
passwordHash: varchar({ length: 255 }).notNull(), passwordHash: varchar({ length: 255 }).notNull(),
avatarURL: varchar({ length: 512 }), avatarURL: varchar({ length: 512 }),
iconPreference: varchar({ length: 10 }).notNull().default("pixel").$type<IconStyle>(), iconPreference: varchar({ length: 10 }).notNull().default("pixel").$type<IconStyle>(),
plan: varchar({ length: 32 }).notNull().default("free"),
emailVerified: boolean().notNull().default(false),
emailVerifiedAt: timestamp({ withTimezone: false }),
createdAt: timestamp({ withTimezone: false }).defaultNow(), createdAt: timestamp({ withTimezone: false }).defaultNow(),
updatedAt: timestamp({ withTimezone: false }).defaultNow(), updatedAt: timestamp({ withTimezone: false }).defaultNow(),
}); });
@@ -192,7 +197,6 @@ export const IssueComment = pgTable("IssueComment", {
updatedAt: timestamp({ withTimezone: false }).defaultNow(), updatedAt: timestamp({ withTimezone: false }).defaultNow(),
}); });
// Zod schemas
export const UserSelectSchema = createSelectSchema(User); export const UserSelectSchema = createSelectSchema(User);
export const UserInsertSchema = createInsertSchema(User); export const UserInsertSchema = createInsertSchema(User);
@@ -223,7 +227,6 @@ export const SessionInsertSchema = createInsertSchema(Session);
export const TimedSessionSelectSchema = createSelectSchema(TimedSession); export const TimedSessionSelectSchema = createSelectSchema(TimedSession);
export const TimedSessionInsertSchema = createInsertSchema(TimedSession); export const TimedSessionInsertSchema = createInsertSchema(TimedSession);
// Types
export type UserRecord = z.infer<typeof UserSelectSchema>; export type UserRecord = z.infer<typeof UserSelectSchema>;
export type UserInsert = z.infer<typeof UserInsertSchema>; export type UserInsert = z.infer<typeof UserInsertSchema>;
@@ -257,8 +260,6 @@ export type SessionInsert = z.infer<typeof SessionInsertSchema>;
export type TimedSessionRecord = z.infer<typeof TimedSessionSelectSchema>; export type TimedSessionRecord = z.infer<typeof TimedSessionSelectSchema>;
export type TimedSessionInsert = z.infer<typeof TimedSessionInsertSchema>; export type TimedSessionInsert = z.infer<typeof TimedSessionInsertSchema>;
// Responses
export type IssueResponse = { export type IssueResponse = {
Issue: IssueRecord; Issue: IssueRecord;
Creator: UserRecord; Creator: UserRecord;
@@ -295,3 +296,85 @@ export type TimerState = {
timestamps: string[]; timestamps: string[];
endedAt: string | null; endedAt: string | null;
} | null; } | null;
export const Subscription = pgTable("Subscription", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer()
.notNull()
.references(() => User.id),
stripeCustomerId: varchar({ length: 255 }),
stripeSubscriptionId: varchar({ length: 255 }),
stripeSubscriptionItemId: varchar({ length: 255 }),
stripePriceId: varchar({ length: 255 }),
status: varchar({ length: 32 }).notNull().default("incomplete"),
currentPeriodStart: timestamp({ withTimezone: false }),
currentPeriodEnd: timestamp({ withTimezone: false }),
cancelAtPeriodEnd: boolean().notNull().default(false),
trialEnd: timestamp({ withTimezone: false }),
quantity: integer().notNull().default(1),
createdAt: timestamp({ withTimezone: false }).defaultNow(),
updatedAt: timestamp({ withTimezone: false }).defaultNow(),
});
export const Payment = pgTable("Payment", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
subscriptionId: integer()
.notNull()
.references(() => Subscription.id),
stripePaymentIntentId: varchar({ length: 255 }),
amount: integer().notNull(),
currency: varchar({ length: 3 }).notNull().default("gbp"),
status: varchar({ length: 32 }).notNull(),
createdAt: timestamp({ withTimezone: false }).defaultNow(),
});
export const SubscriptionSelectSchema = createSelectSchema(Subscription);
export const SubscriptionInsertSchema = createInsertSchema(Subscription);
export const PaymentSelectSchema = createSelectSchema(Payment);
export const PaymentInsertSchema = createInsertSchema(Payment);
export type SubscriptionRecord = z.infer<typeof SubscriptionSelectSchema>;
export type SubscriptionInsert = z.infer<typeof SubscriptionInsertSchema>;
export type PaymentRecord = z.infer<typeof PaymentSelectSchema>;
export type PaymentInsert = z.infer<typeof PaymentInsertSchema>;
export const EmailVerification = pgTable("EmailVerification", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer()
.notNull()
.references(() => User.id, { onDelete: "cascade" }),
code: varchar({ length: 6 }).notNull(),
attempts: integer().notNull().default(0),
maxAttempts: integer().notNull().default(5),
expiresAt: timestamp({ withTimezone: false }).notNull(),
verifiedAt: timestamp({ withTimezone: false }),
createdAt: timestamp({ withTimezone: false }).defaultNow(),
});
export const EmailVerificationSelectSchema = createSelectSchema(EmailVerification);
export const EmailVerificationInsertSchema = createInsertSchema(EmailVerification);
export type EmailVerificationRecord = z.infer<typeof EmailVerificationSelectSchema>;
export type EmailVerificationInsert = z.infer<typeof EmailVerificationInsertSchema>;
export const EmailJob = pgTable("EmailJob", {
id: integer().primaryKey().generatedAlwaysAsIdentity(),
userId: integer()
.notNull()
.references(() => User.id, { onDelete: "cascade" }),
type: varchar({ length: 64 }).notNull(),
scheduledFor: timestamp({ withTimezone: false }).notNull(),
sentAt: timestamp({ withTimezone: false }),
failedAt: timestamp({ withTimezone: false }),
errorMessage: text(),
metadata: json("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp({ withTimezone: false }).defaultNow(),
});
export const EmailJobSelectSchema = createSelectSchema(EmailJob);
export const EmailJobInsertSchema = createInsertSchema(EmailJob);
export type EmailJobRecord = z.infer<typeof EmailJobSelectSchema>;
export type EmailJobInsert = z.infer<typeof EmailJobInsertSchema>;