mirror of
https://github.com/hex248/sprint.git
synced 2026-02-08 02:33:01 +00:00
206
bun.lock
206
bun.lock
@@ -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=="],
|
||||||
|
|||||||
@@ -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>
|
||||||
30
packages/backend/drizzle/0026_stale_shocker.sql
Normal file
30
packages/backend/drizzle/0026_stale_shocker.sql
Normal 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;
|
||||||
4
packages/backend/drizzle/0027_volatile_otto_octavius.sql
Normal file
4
packages/backend/drizzle/0027_volatile_otto_octavius.sql
Normal 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");
|
||||||
28
packages/backend/drizzle/0028_quick_supernaut.sql
Normal file
28
packages/backend/drizzle/0028_quick_supernaut.sql
Normal 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;
|
||||||
1146
packages/backend/drizzle/meta/0026_snapshot.json
Normal file
1146
packages/backend/drizzle/meta/0026_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1159
packages/backend/drizzle/meta/0027_snapshot.json
Normal file
1159
packages/backend/drizzle/meta/0027_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1354
packages/backend/drizzle/meta/0028_snapshot.json
Normal file
1354
packages/backend/drizzle/meta/0028_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
121
packages/backend/src/db/queries/email-verification.ts
Normal file
121
packages/backend/src/db/queries/email-verification.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
77
packages/backend/src/db/queries/subscriptions.ts
Normal file
77
packages/backend/src/db/queries/subscriptions.ts
Normal 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));
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
1
packages/backend/src/emails/index.ts
Normal file
1
packages/backend/src/emails/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { VerificationCode } from "./templates/VerificationCode";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export function VerificationCode({ code }: { code: string }) {
|
||||||
|
return <body>Your sprint verification code is: {code}</body>;
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
54
packages/backend/src/lib/email/service.ts
Normal file
54
packages/backend/src/lib/email/service.ts
Normal 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");
|
||||||
|
}
|
||||||
49
packages/backend/src/lib/seats.ts
Normal file
49
packages/backend/src/lib/seats.ts
Normal 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 });
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
69
packages/backend/src/routes/auth/resend-verification.ts
Normal file
69
packages/backend/src/routes/auth/resend-verification.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
32
packages/backend/src/routes/auth/verify-email.ts
Normal file
32
packages/backend/src/routes/auth/verify-email.ts
Normal 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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
68
packages/backend/src/routes/subscription/cancel.ts
Normal file
68
packages/backend/src/routes/subscription/cancel.ts
Normal 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)));
|
||||||
@@ -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)));
|
||||||
@@ -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)));
|
||||||
24
packages/backend/src/routes/subscription/get.ts
Normal file
24
packages/backend/src/routes/subscription/get.ts
Normal 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));
|
||||||
213
packages/backend/src/routes/subscription/webhook.ts
Normal file
213
packages/backend/src/routes/subscription/webhook.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
|||||||
18
packages/backend/src/stripe/client.ts
Normal file
18
packages/backend/src/stripe/client.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
93
packages/frontend/src/components/free-tier-limit.tsx
Normal file
93
packages/frontend/src/components/free-tier-limit.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
{members.length} Member{members.length !== 1 ? "s" : ""}
|
<h2 className="text-xl font-600">
|
||||||
</h2>
|
{members.length} Member{members.length !== 1 ? "s" : ""}
|
||||||
|
</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,25 +1032,46 @@ function Organisations({ trigger }: { trigger?: ReactNode }) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<AddMember
|
<>
|
||||||
organisationId={selectedOrganisation.Organisation.id}
|
{!isPro && (
|
||||||
existingMembers={members.map((m) => m.User.username)}
|
<div className="px-1">
|
||||||
onSuccess={(user) => {
|
<FreeTierLimit
|
||||||
toast.success(
|
current={memberCount}
|
||||||
`${user.name} added to ${selectedOrganisation.Organisation.name} successfully`,
|
limit={FREE_TIER_LIMITS.membersPerOrganisation}
|
||||||
{
|
itemName="member"
|
||||||
dismissible: false,
|
isPro={isPro}
|
||||||
},
|
showUpgrade={memberCount >= FREE_TIER_LIMITS.membersPerOrganisation}
|
||||||
);
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<AddMember
|
||||||
|
organisationId={selectedOrganisation.Organisation.id}
|
||||||
|
existingMembers={members.map((m) => m.User.username)}
|
||||||
|
onSuccess={(user) => {
|
||||||
|
toast.success(
|
||||||
|
`${user.name} added to ${selectedOrganisation.Organisation.name} successfully`,
|
||||||
|
{
|
||||||
|
dismissible: false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
void invalidateMembers();
|
void invalidateMembers();
|
||||||
}}
|
}}
|
||||||
trigger={
|
trigger={
|
||||||
<Button variant="outline">
|
<Button
|
||||||
Add user <Icon icon="plus" className="size-4" />
|
variant="outline"
|
||||||
</Button>
|
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" />
|
||||||
|
</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>
|
||||||
|
|||||||
127
packages/frontend/src/components/pricing-card.tsx
Normal file
127
packages/frontend/src/components/pricing-card.tsx
Normal 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,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -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>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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("/")) {
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
131
packages/frontend/src/components/ui/alert-dialog.tsx
Normal file
131
packages/frontend/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||||
|
};
|
||||||
69
packages/frontend/src/components/ui/input-otp.tsx
Normal file
69
packages/frontend/src/components/ui/input-otp.tsx
Normal 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 };
|
||||||
@@ -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,9 +99,25 @@ export function UploadAvatar({
|
|||||||
setError(message);
|
setError(message);
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
|
||||||
toast.error(`Error uploading avatar: ${message}`, {
|
// check if the error is about animated avatars for free users
|
||||||
dismissible: false,
|
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}`, {
|
||||||
|
dismissible: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
104
packages/frontend/src/components/verification-modal.tsx
Normal file
104
packages/frontend/src/components/verification-modal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
62
packages/frontend/src/lib/query/hooks/subscriptions.ts
Normal file
62
packages/frontend/src/lib/query/hooks/subscriptions.ts
Normal 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() });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
22
packages/frontend/src/lib/query/hooks/verification.ts
Normal file
22
packages/frontend/src/lib/query/hooks/verification.ts
Normal 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);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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";
|
||||||
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
122
packages/frontend/src/pages/BoringStuff.tsx
Normal file
122
packages/frontend/src/pages/BoringStuff.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,21 +370,29 @@ 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">
|
||||||
<span className="font-300 text-lg text-muted-foreground">
|
<div className="flex justify-center gap-2 items-center">
|
||||||
Built by{" "}
|
<span className="font-300 text-lg text-muted-foreground">
|
||||||
<a
|
Built by{" "}
|
||||||
href="https://ob248.com"
|
<a
|
||||||
target="_blank"
|
href="https://ob248.com"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
className="hover:text-personality font-700"
|
rel="noopener noreferrer"
|
||||||
>
|
className="hover:text-personality font-700"
|
||||||
Oliver Bryan
|
>
|
||||||
|
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>
|
</a>
|
||||||
</span>
|
</div>
|
||||||
<a href="https://ob248.com" target="_blank" rel="noopener noreferrer">
|
<Link
|
||||||
<img src="oliver-bryan.svg" alt="Oliver Bryan" className="w-4 h-4" />
|
to="/the-boring-stuff"
|
||||||
</a>
|
className="text-sm text-muted-foreground hover:text-personality transition-colors"
|
||||||
|
>
|
||||||
|
The boring stuff — Privacy Policy & ToS
|
||||||
|
</Link>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
304
packages/frontend/src/pages/Plans.tsx
Normal file
304
packages/frontend/src/pages/Plans.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user