Compare commits
22 Commits
fix/kan-15
...
fix/worksp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3bd3684b6 | ||
|
|
c2ccb6b13b | ||
|
|
4ee1d6f2d5 | ||
|
|
2f88366418 | ||
|
|
4f7fa1a228 | ||
|
|
308cb22729 | ||
|
|
6c04a10b1f | ||
|
|
99459415d5 | ||
|
|
5a4835091f | ||
|
|
061a54dadd | ||
|
|
a532fa1ce2 | ||
|
|
2a355238a4 | ||
|
|
874353e8b8 | ||
|
|
18e60333ae | ||
|
|
d729785802 | ||
|
|
abbbb4b928 | ||
|
|
3cea9377e6 | ||
|
|
06c268bed1 | ||
|
|
e3d7ace8bd | ||
|
|
fecc6cf742 | ||
|
|
f7c2e7979e | ||
|
|
cd70526b93 |
@@ -24,9 +24,11 @@ mintlify dev
|
||||
|
||||
### Publishing Changes
|
||||
|
||||
Install our Github App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
|
||||
Install our Github App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
|
||||
- Page loads as a 404 - Make sure you are running in a folder with `mint.json`
|
||||
|
||||
#BUMP
|
||||
|
||||
@@ -6,6 +6,8 @@ checksums:
|
||||
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
|
||||
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
|
||||
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
|
||||
"%2410%2Fmonth/singular": cab5e752ed83096e34fca780d90e1fb9
|
||||
"%248%2Fmonth/singular": 4667034934bb2bc3d569c70b94205b51
|
||||
1%20user/singular: 3b547431ab12f0fba84307e6a81109d8
|
||||
A%20powerful%2C%20flexible%20kanban%20app%20that%20helps%20you%20organise%20work%2C%20track%20progress%2C%20and%20deliver%20results%E2%80%94all%20in%20one%20place./singular: d405b83b0d631cb72f4347c10bcbb643
|
||||
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
|
||||
@@ -26,8 +28,8 @@ checksums:
|
||||
added%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: fb6a852c50c1629f47c6f155fc93fdee
|
||||
added%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: bdd202da20b1fffbec21792c5453f90c
|
||||
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
|
||||
Adding%20a%20new%20member%20will%20cost%20an%20additional%20%7Bprice%7D%20(%7BbillingType%7D)%20per%20seat./singular: 12e88573028306110fbc15ef1e714892
|
||||
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
|
||||
All%20seats%20are%20free%20during%20the%20beta%20until%20Sept%201st!/singular: 015cec3899cf99e37c3cf231d432e31d
|
||||
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
|
||||
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
|
||||
An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
|
||||
@@ -47,10 +49,13 @@ checksums:
|
||||
Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457
|
||||
Backlog/singular: 35919fd5b10ad212839a75e72829fe4e
|
||||
Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7
|
||||
billed%20annually/singular: c782a8085ffee456bfc56c34ccc5e71c
|
||||
billed%20monthly/singular: 793f2728acf06e9f06d6cdc26dfcf0cd
|
||||
Billing/singular: b01dbdd049ebbd4a349fa64d6ce65a3b
|
||||
Billing%20portal/singular: dc3afa14ffe19f5920992a0c9d49d525
|
||||
Blog%20Post/singular: 38e027f212be445dc530e427fc642a20
|
||||
Board/singular: 101ff39aab674c033c15ab978d0d8bac
|
||||
Board%20analytics/singular: 336a8c70a91adc49c15266167ee84cc0
|
||||
Board%20name%20cannot%20exceed%20100%20characters/singular: 985c0177744ec56d8dac84f66ec284ca
|
||||
Board%20name%20is%20required/singular: dbd0c6cc945ab19e702fc27f970a023b
|
||||
Board%20not%20found/singular: 5af9844595fa8aed5985066620435515
|
||||
@@ -83,7 +88,6 @@ checksums:
|
||||
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
||||
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
|
||||
completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673
|
||||
Confirm%20URL%20change/singular: e14b4ce081d1ef6194b37e1814e97b39
|
||||
Confirm%20your%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416
|
||||
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
|
||||
Connect%20your%20favorite%20tools%20to%20streamline%20your%20workflow./singular: 033c5dcdb059ccb634aef7fe63f2f45d
|
||||
@@ -109,7 +113,8 @@ checksums:
|
||||
Critical/singular: eb327cd411b50aee954f8d1d215d003a
|
||||
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
|
||||
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
||||
Custom%20URLs%20are%20a%20premium%20feature.%20You'll%20be%20directed%20to%20upgrade%20your%20account./singular: b52e4f6122cf739c9551d29beb89b25e
|
||||
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
|
||||
Custom%20workspace%20URL/singular: 7ba841d0946eb04fa3d17d74365be37b
|
||||
Customer%20Support/singular: 50e3c77e22e41061ca85ea2f02625a2e
|
||||
Dark/singular: 73e6e208ba628b26e90fcf6dce15e1b2
|
||||
Delete/singular: 8bcf303dd10a645b5baacb02b47d72c9
|
||||
@@ -161,6 +166,8 @@ checksums:
|
||||
Error%20updating%20workspace%20description/singular: 7a9fd7c69b90ab848ee1f1d4a4995381
|
||||
Error%20updating%20workspace%20name/singular: 46025f42a7d5eff689e9cb8bed14a1e4
|
||||
Error%20updating%20workspace%20URL/singular: 9da6c012e1b512d8c7fbd812626c4675
|
||||
Error%20upgrading%20subscription/singular: 57cc47581c7faaffb3b1721b6352b576
|
||||
Error%20upgrading%20to%20Pro/singular: 934c6867d9bdbe6099b2c0f5a3107f78
|
||||
Error%20uploading%20profile%20image/singular: 0ba02753a797a74c6ea7b622c9f69c71
|
||||
Everything%20in%20the%20free%20plan%2C%20plus%3A/singular: 62b44c4973b92b806c69a4b15e0256dc
|
||||
Everything%20you%20need%2C%20free%20forever.%20Unlimited%20boards%2C%20unlimited%20lists%2C%20unlimited%20cards.%20Upgrade%20any%20time./singular: fa21632ab1468edf10acda2fe7b71323
|
||||
@@ -176,6 +183,7 @@ checksums:
|
||||
Find%20answers%20to%20common%20questions%20about%20the%20project.%20Can't%20find%20what%20you're%20looking%20for%3F%20Feel%20free%20to%20%3C0%3Econtact%20us%3C%2F0%3E./singular: 4d99230dad703bf2d71ba670c2f79d3d
|
||||
For%20long-term%20sustainability%2C%20we%20recognise%20all%20good%20open%20source%20projects%20need%20a%20source%20of%20revenue.%20Ours%20comes%20from%20the%20paid%20cloud%20offering%20for%20workspaces%20with%20multiple%20users%20(free%20during%20the%20beta)%20and%20for%20custom%20workspace%20slugs.%20There's%20no%20obligation%20to%20use%20it%2C%20and%20you%20can%20self-host%20the%20software%20on%20your%20own%20infrastructure%20free%20of%20charge./singular: 4eedacd4311ff69bf0fd87083c90a268
|
||||
Free/singular: 0326365539c004f6088656f692602078
|
||||
Free%20Plan/singular: c995acccc1e3cba379786892a5b4d4e5
|
||||
Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b
|
||||
Full-time/singular: fe6e201f6676ae354111435cf6c76910
|
||||
Fun/singular: 395bdc48e943762b6cde649f4a96771d
|
||||
@@ -216,6 +224,7 @@ checksums:
|
||||
Invite/singular: 181884cea804cbde665f160811ee7ad0
|
||||
Invite%20another/singular: acb543563dab7edbcf46060a53ade3a3
|
||||
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
|
||||
Inviting%20members%20requires%20a%20Team%20Plan.%20You'll%20be%20redirected%20to%20upgrade%20your%20workspace./singular: 03eeed2d715e770259f722ba48a61ab3
|
||||
Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f
|
||||
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
|
||||
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
|
||||
@@ -223,6 +232,9 @@ checksums:
|
||||
Labels/singular: 6f15627a90002323eac018274b6922d6
|
||||
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
|
||||
Language/singular: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
Launch%20offer/singular: 624592ba2ecf647a3fe60d493c1ee71a
|
||||
Launch%20offer%3A%20Get%20unlimited%20members%20with%20Pro/singular: 8c1be80f5b2fcc112922282afbe30287
|
||||
Launch%20offer%3A%20unlimited%20seats%20for%20just%20%2429%2Fmonth%20with%20Pro/singular: 235d1cda21811c296afdc0dcc438cc24
|
||||
Learning/singular: 90e91da3f870f065f2da9aa9adf53514
|
||||
Legal/singular: 00d3f08da61a21887009819cf93fb414
|
||||
License/singular: ac2bcac75151e2fcd9eb768cea275a77
|
||||
@@ -241,6 +253,7 @@ checksums:
|
||||
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
|
||||
Monthly/singular: 818f1192e32bb855597f930d3e78806e
|
||||
monthly%20billing/singular: e228107df80015377112d41f4f155cb3
|
||||
moved%20the%20card%20from%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%3C1%3E%7B1%7D%3C%2F1%3E/singular: 11edf0427766c26db541d46379dc3c16
|
||||
moved%20the%20card%20to%20another%20list/singular: 2e8c41bfafb42fa299ce56e4300205ff
|
||||
Name/singular: 9368b5a047572b6051f334af5aa76819
|
||||
@@ -288,6 +301,8 @@ checksums:
|
||||
Priority%20email%20support/singular: 678538c912a770b1e1416ecdb8e299b1
|
||||
Privacy%20policy/singular: 462c6a536b52873e4498785c66dd48c8
|
||||
Private/singular: d740d0660e68c61db1853489356030ae
|
||||
Pro%20Plan/singular: 76a09fd843b496610f8cf1dced417b62
|
||||
Pro%20Plan%20%E2%88%9E/singular: a898be9e4610706b9b8146a209e8ba16
|
||||
Profile%20image%20updated/singular: 1ba6609b50bc217a36ab659563e78dae
|
||||
Profile%20picture/singular: d9b5e595877ffe2557486a987dd28274
|
||||
Public/singular: c834f11dab0881632aded3f01d4f8fa9
|
||||
@@ -336,9 +351,11 @@ checksums:
|
||||
Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c
|
||||
Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875
|
||||
Success/singular: c43827becada6750f7a25890905f38b9
|
||||
Supercharge%20your%20workspace%20for%20just%20%2429%2Fmonth.%20Here's%20what%20you'll%20get%3A/singular: 21af8119141afcce5f12da489f34db6a
|
||||
Support/singular: 55aab5fd0f31a9cb055a2edeeedfaf63
|
||||
Support%20the%20development%20of%20the%20project/singular: c63bab228ab2b1be4370c668a99cd278
|
||||
System/singular: 803281c327a02a2feba182f4a2b38311
|
||||
Team%20Plan/singular: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||
Teams/singular: b63448c05270497973ac4407047dae02
|
||||
Templates/singular: 8ed62f56c81fa96c0f3182acb331bf63
|
||||
Terms%20of%20service/singular: 0b8a8b23aff021a6cde79613fdb39c28
|
||||
@@ -354,6 +371,8 @@ checksums:
|
||||
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
|
||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20this%20workspace./singular: a31141558af793635c1ddd2fa0a33499
|
||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
|
||||
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
|
||||
This%20workspace%20URL%20is%20reserved/singular: 7e47c892b93d4334c1606010c06e875e
|
||||
This%20workspace%20username%20has%20already%20been%20taken/singular: b7eadb89c615874f416d9658d0428c4c
|
||||
To%20Do/singular: d60813ea824f373462471e092d136eed
|
||||
Toggle%20menu/singular: 29dea3e0b6238874f8c7a27619df8e36
|
||||
@@ -390,6 +409,7 @@ checksums:
|
||||
Unlimited%20cards/singular: f44c1050c4d6aa39c8cdf5f4a7d12577
|
||||
Unlimited%20comments/singular: 31dbcf487c49a9997142e96bb3ab0040
|
||||
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
|
||||
Unlimited%20members/singular: f2949ca2dc18b0063af92e60779abb65
|
||||
Update/singular: 079fc039262fd31b10532929685c2d1b
|
||||
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
|
||||
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
|
||||
@@ -397,6 +417,9 @@ checksums:
|
||||
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
|
||||
updated%20the%20title%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: aca196d729401d8adc5c8ca9d558840e
|
||||
Upgrade/singular: 63c3b52882e0d779859307d672c178c2
|
||||
Upgrade%20to%20Pro/singular: 972773025e763ddf53273df6d37a729a
|
||||
Upgrade%20to%20Pro%20(%2429%2Fmonth)/singular: 9a91ea70f5eb55aac96640bf0fd7feff
|
||||
Upgrade%20to%20Team%20Plan/singular: 40e2ef151266937f8bbe63b3d361f645
|
||||
Urgent/singular: e79afcd4398e52e10e7e8243a7eecabe
|
||||
URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: 875d37d2d920f98ef60a314eda8a5d9a
|
||||
URL%20cannot%20exceed%2024%20characters/singular: 395c25732b84ac1f5b63ee310da08e93
|
||||
@@ -420,6 +443,7 @@ checksums:
|
||||
When%20Trello%20launched%20in%202011%2C%20it%20blew%20everyone%20away%20with%20its%20carefully%20designed%20simplicity%2C%20but%20over%20time%20(and%20atlassification)%20it%20lost%20its%20magic%20and%20grew%20into%20something%20closer%20to%20%22Jira%20Lite%22.%20This%20project%20is%20our%20attempt%20to%20recapture%20the%20original%20magic%20of%20Trello's%20simplicity%2C%20in%20open%20source./singular: 60a55e9bd16c3d1beff1a6c7633c5911
|
||||
Why%20make%20an%20open%20source%20Trello%3F/singular: 090833338ba8c64ef7a75e0f2615b34b
|
||||
Workspace/singular: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
||||
Workspace%20created%20successfully.%20You%20can%20upgrade%20later%20in%20settings./singular: 8bbaba9f82ff076a08185ada1ee7eb94
|
||||
Workspace%20deleted/singular: bc5c267ce3f04de94ff1379cf2ca48a3
|
||||
Workspace%20description/singular: 7159d620b7ef5c5afef734989a5be08b
|
||||
Workspace%20description%20cannot%20exceed%20280%20characters/singular: d0e83b9a0304d7c36cf8c85d98783ce2
|
||||
@@ -428,10 +452,12 @@ checksums:
|
||||
Workspace%20members/singular: 1857292f9567c556c781a23bd7e10544
|
||||
Workspace%20name/singular: 855614bbf862fd6a9f6a3e76943f92d4
|
||||
Workspace%20name%20cannot%20exceed%2024%20characters/singular: c8b8f79fb83abf8329cc34c1f51fcfc9
|
||||
Workspace%20name%20is%20required/singular: b8c5162dd08c4d941bc57f9d0cbee451
|
||||
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
|
||||
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
|
||||
Workspace%20slug%20updated/singular: 527b92711d38cb35b40741df43aef047
|
||||
Workspace%20URL/singular: f4397a838da0f3a44cbd3ebe408ed6c3
|
||||
workspace-url/singular: 2d034732ec536f3a2667f956fa50d394
|
||||
Writing/singular: 4f5f3ee3320c252921f80c42653c5d49
|
||||
Yearly/singular: 87f43e016c19cb25860f456549a2f431
|
||||
Yes%2C%20we%20offer%20an%20forever%20free%20plan%20for%20individual%20use.%20No%20restrictions%2C%20no%20paywalls%2C%20no%20limits./singular: 963cee303d0ff13c1719f4575e774fe3
|
||||
@@ -441,6 +467,7 @@ checksums:
|
||||
You%20can%20self-host%20by%20following%20the%20instructions%20in%20our%20%3C0%3Erepo%3C%2F0%3E./singular: a6152a41b2d8f64d5a657e5b8bc808a9
|
||||
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
|
||||
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
|
||||
You%20have%20unlimited%20seats%20with%20your%20Pro%20Plan.%20There%20is%20no%20additional%20charge%20for%20new%20members!/singular: e3dc59a5ba7211cd3d8516b3a79d85ca
|
||||
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
|
||||
Your%20boards%20have%20been%20imported./singular: 403972e7a25afc2415762c1c2b1ec868
|
||||
Your%20display%20name%20has%20been%20updated./singular: 15e5fff36c554c16ec5214427fae1bf4
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "pnpm with-env next build",
|
||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev | pino-pretty",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"start": "pnpm with-env next start",
|
||||
|
||||
@@ -1,30 +1,106 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import {
|
||||
HiBolt,
|
||||
HiCheck,
|
||||
HiCheckBadge,
|
||||
HiInformationCircle,
|
||||
HiXMark,
|
||||
} from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { z } from "zod";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
}
|
||||
const schema = z.object({
|
||||
name: z.string().min(1, { message: t`Workspace name is required` }),
|
||||
slug: z
|
||||
.string()
|
||||
.min(3, {
|
||||
message: t`URL must be at least 3 characters long`,
|
||||
})
|
||||
.max(24, { message: t`URL cannot exceed 24 characters` })
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, {
|
||||
message: t`URL can only contain letters, numbers, and hyphens`,
|
||||
})
|
||||
.optional()
|
||||
.or(z.literal("")),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export function NewWorkspaceForm() {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { switchWorkspace } = useWorkspace();
|
||||
const { register, handleSubmit } = useForm<FormValues>();
|
||||
const { switchWorkspace, availableWorkspaces } = useWorkspace();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
watch,
|
||||
trigger,
|
||||
clearErrors,
|
||||
setValue,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
slug: "",
|
||||
},
|
||||
mode: "onSubmit",
|
||||
});
|
||||
const utils = api.useUtils();
|
||||
|
||||
const hasAvailableWorkspaces = availableWorkspaces.length > 0;
|
||||
|
||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
|
||||
const slug = watch("slug");
|
||||
const [debouncedSlug] = useDebounce(slug, 500);
|
||||
const isTyping = slug !== debouncedSlug;
|
||||
|
||||
// Pro toggle state management
|
||||
const [isProToggleEnabled, setIsProToggleEnabled] = useState(false);
|
||||
const [lastAvailableSlug, setLastAvailableSlug] = useState<string>("");
|
||||
|
||||
// Validate slug only after debounce
|
||||
useEffect(() => {
|
||||
if (isTyping) {
|
||||
// Clear errors while typing
|
||||
clearErrors("slug");
|
||||
} else if (debouncedSlug) {
|
||||
// Validate after debounce
|
||||
void trigger("slug");
|
||||
}
|
||||
}, [isTyping, debouncedSlug, trigger, clearErrors]);
|
||||
|
||||
const checkWorkspaceSlugAvailability =
|
||||
api.workspace.checkSlugAvailability.useQuery(
|
||||
{
|
||||
workspaceSlug: debouncedSlug ?? "",
|
||||
},
|
||||
{
|
||||
enabled: !!debouncedSlug && debouncedSlug.length >= 3 && !errors.slug,
|
||||
},
|
||||
);
|
||||
|
||||
const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data;
|
||||
|
||||
const createWorkspace = api.workspace.create.useMutation({
|
||||
onSuccess: (values) => {
|
||||
onSuccess: async (values, variables) => {
|
||||
if (values.publicId && values.name) {
|
||||
utils.workspace.all.invalidate();
|
||||
void utils.workspace.all.invalidate();
|
||||
switchWorkspace({
|
||||
publicId: values.publicId,
|
||||
name: values.name,
|
||||
@@ -33,6 +109,43 @@ export function NewWorkspaceForm() {
|
||||
plan: values.plan,
|
||||
role: "admin",
|
||||
});
|
||||
|
||||
// If in cloud and Pro toggle is enabled, create checkout session for pro
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud" && isProToggleEnabled) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"/api/stripe/create_checkout_session",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slug: slug || undefined,
|
||||
workspacePublicId: values.publicId,
|
||||
cancelUrl: "/settings?upgrade=pro",
|
||||
successUrl: "/boards",
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
const url = (data as { url: string }).url;
|
||||
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
return; // Don't close modal if redirecting to checkout
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout session:", error);
|
||||
showPopup({
|
||||
header: t`Error upgrading to Pro`,
|
||||
message: t`Workspace created successfully. You can upgrade later in settings.`,
|
||||
icon: "warning",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
closeModal();
|
||||
}
|
||||
},
|
||||
@@ -51,12 +164,74 @@ export function NewWorkspaceForm() {
|
||||
if (nameElement) nameElement.focus();
|
||||
}, []);
|
||||
|
||||
const [shouldShowBenefits, setShouldShowBenefits] = useState(false);
|
||||
|
||||
const isValidSlug = slug && slug.length >= 3 && !errors.slug;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isCloudEnv &&
|
||||
!checkWorkspaceSlugAvailability.isPending &&
|
||||
isValidSlug
|
||||
) {
|
||||
const isAvailable = isWorkspaceSlugAvailable?.isAvailable === true;
|
||||
setShouldShowBenefits(isAvailable);
|
||||
|
||||
// Automatically enable Pro toggle when slug is available
|
||||
if (isAvailable) {
|
||||
setIsProToggleEnabled(true);
|
||||
setLastAvailableSlug(slug);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isValidSlug,
|
||||
isWorkspaceSlugAvailable?.isAvailable,
|
||||
checkWorkspaceSlugAvailability.isPending,
|
||||
slug,
|
||||
isCloudEnv,
|
||||
]);
|
||||
|
||||
// Reset benefits when slug becomes invalid
|
||||
useEffect(() => {
|
||||
if (!isValidSlug) {
|
||||
setShouldShowBenefits(false);
|
||||
}
|
||||
}, [isValidSlug]);
|
||||
|
||||
// Handle Pro toggle changes
|
||||
const handleProToggleChange = () => {
|
||||
if (isProToggleEnabled) {
|
||||
// Turning off: clear the slug and disable Pro
|
||||
setValue("slug", "");
|
||||
setIsProToggleEnabled(false);
|
||||
} else {
|
||||
// Turning on: restore the last available slug if we have one
|
||||
if (lastAvailableSlug) {
|
||||
setValue("slug", lastAvailableSlug);
|
||||
}
|
||||
setIsProToggleEnabled(true);
|
||||
}
|
||||
};
|
||||
|
||||
const showProBenefits = isProToggleEnabled;
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
// Don't submit if slug is provided but not available
|
||||
if (values.slug && isWorkspaceSlugAvailable?.isAvailable === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
createWorkspace.mutate({
|
||||
name: values.name,
|
||||
slug: !isCloudEnv && values.slug ? values.slug : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const isSlugAvailable =
|
||||
isValidSlug &&
|
||||
isWorkspaceSlugAvailable?.isAvailable &&
|
||||
!isWorkspaceSlugAvailable?.isReserved;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
@@ -66,7 +241,10 @@ export function NewWorkspaceForm() {
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||
className={twMerge(
|
||||
"rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300",
|
||||
!hasAvailableWorkspaces && "invisible",
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
@@ -80,6 +258,7 @@ export function NewWorkspaceForm() {
|
||||
id="workspace-name"
|
||||
placeholder={t`Workspace name`}
|
||||
{...register("name")}
|
||||
errorMessage={errors.name?.message}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
@@ -87,10 +266,121 @@ export function NewWorkspaceForm() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="mt-4">
|
||||
<Input
|
||||
id="workspace-slug"
|
||||
placeholder={t`workspace-url`}
|
||||
{...register("slug")}
|
||||
className={`${
|
||||
isSlugAvailable
|
||||
? "focus:ring-green-500 dark:focus:ring-green-500"
|
||||
: ""
|
||||
}`}
|
||||
errorMessage={
|
||||
errors.slug?.message ??
|
||||
(isWorkspaceSlugAvailable?.isAvailable === false &&
|
||||
isWorkspaceSlugAvailable?.isReserved === false
|
||||
? t`This workspace URL has already been taken`
|
||||
: isWorkspaceSlugAvailable?.isReserved
|
||||
? t`This workspace URL is reserved`
|
||||
: undefined)
|
||||
}
|
||||
prefix={
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud"
|
||||
? "kan.bn/"
|
||||
: `${env("NEXT_PUBLIC_BASE_URL")}/`
|
||||
}
|
||||
iconRight={
|
||||
slug && slug.length >= 3 && !errors.slug ? (
|
||||
isWorkspaceSlugAvailable?.isAvailable ? (
|
||||
<HiCheck className="h-4 w-4 text-green-500" />
|
||||
) : checkWorkspaceSlugAvailability.isPending || isTyping ? (
|
||||
<LoadingSpinner />
|
||||
) : null
|
||||
) : null
|
||||
}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{slug && slug.length >= 3 && shouldShowBenefits && (
|
||||
<div className="mt-2 flex items-center gap-1">
|
||||
<HiInformationCircle className="h-4 w-4 text-dark-900" />
|
||||
<p className="text-xs text-gray-500 dark:text-dark-900">
|
||||
{t`Custom URLs require upgrading to a Pro plan`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showProBenefits && (
|
||||
<div className="mt-6">
|
||||
<div className="rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Unlimited members`}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400 sm:text-[10px]">
|
||||
{t`Launch offer`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Custom workspace URL`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-[18px] w-[18px] flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Board analytics`}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-gray-500/10 px-2 py-0.5 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/20 dark:text-gray-400 sm:text-[10px]">
|
||||
{t`Coming soon`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div
|
||||
className={twMerge(
|
||||
"mt-6 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600",
|
||||
!showProBenefits && "mt-12",
|
||||
)}
|
||||
>
|
||||
{/* Pro Toggle - only show in cloud environment */}
|
||||
{isCloudEnv && (
|
||||
<Toggle
|
||||
isChecked={isProToggleEnabled}
|
||||
onChange={handleProToggleChange}
|
||||
label={t`Upgrade to Pro ($29/month)`}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<Button type="submit" isLoading={createWorkspace.isPending}>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={createWorkspace.isPending}
|
||||
disabled={
|
||||
createWorkspace.isPending ||
|
||||
(!!slug &&
|
||||
(checkWorkspaceSlugAvailability.isPending ||
|
||||
isWorkspaceSlugAvailable?.isAvailable === false ||
|
||||
isTyping))
|
||||
}
|
||||
>
|
||||
{t`Create workspace`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -20,12 +20,12 @@ export default function WorkspaceMenu({
|
||||
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className={twMerge("mb-1", isCollapsed && "md:flex md:p-1.5")}>
|
||||
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}>
|
||||
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||
<div
|
||||
className={twMerge(
|
||||
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
||||
isCollapsed && "hidden md:block",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
@@ -44,7 +44,7 @@ export default function WorkspaceMenu({
|
||||
</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
||||
"ml-2 truncate text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
@@ -87,17 +87,17 @@ export default function WorkspaceMenu({
|
||||
onClick={() => switchWorkspace(availableWorkspace)}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
<div>
|
||||
<span className="inline-flex h-5 w-5 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<span className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||
<span className="text-xs font-medium leading-none text-white">
|
||||
{availableWorkspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 text-xs font-medium">
|
||||
<span className="ml-2 truncate text-xs font-medium">
|
||||
{availableWorkspace.name}
|
||||
</span>
|
||||
</div>
|
||||
{workspace.name === availableWorkspace.name && (
|
||||
{workspace.publicId === availableWorkspace.publicId && (
|
||||
<span>
|
||||
<HiCheck className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -3,6 +3,7 @@ import { env } from "next-runtime-env";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
@@ -40,17 +41,22 @@ export default async function handler(
|
||||
const body = req.body as CheckoutSessionRequest;
|
||||
const { successUrl, cancelUrl, slug, workspacePublicId } = body;
|
||||
|
||||
if (!successUrl || !cancelUrl || !slug || !workspacePublicId) {
|
||||
if (!successUrl || !cancelUrl || !workspacePublicId) {
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
}
|
||||
|
||||
const slugResult = workspaceSlugSchema.safeParse(slug);
|
||||
if (slug) {
|
||||
const slugResult = workspaceSlugSchema.safeParse(slug);
|
||||
|
||||
if (!slugResult.success) {
|
||||
return new Response(JSON.stringify({ error: "Invalid workspace slug" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!slugResult.success) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid workspace slug" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getAllByUserId(db, user.id);
|
||||
@@ -66,20 +72,37 @@ export default async function handler(
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = await subscriptionRepo.create(db, {
|
||||
plan: "pro",
|
||||
referenceId: workspacePublicId,
|
||||
userId: user.id,
|
||||
stripeCustomerId: user.stripeCustomerId ?? "",
|
||||
status: "incomplete",
|
||||
});
|
||||
|
||||
const subscriptionId = subscription?.id;
|
||||
|
||||
if (!subscriptionId) {
|
||||
return res.status(500).json({ error: "Error creating subscription" });
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
line_items: [
|
||||
{
|
||||
price: process.env.STRIPE_PRO_PLAN_PRICE_ID,
|
||||
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
|
||||
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
|
||||
client_reference_id: workspacePublicId,
|
||||
customer: user.stripeCustomerId ?? undefined,
|
||||
metadata: {
|
||||
workspaceSlug: slug,
|
||||
...(slug && { workspaceSlug: slug }),
|
||||
workspacePublicId,
|
||||
userId: user.id,
|
||||
subscriptionId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async function handler(
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
rawBody,
|
||||
sig,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
process.env.STRIPE_WEBHOOK_SECRET_LEGACY!,
|
||||
);
|
||||
|
||||
const { db } = await createNextApiContext(req);
|
||||
@@ -47,9 +47,9 @@ export default async function handler(
|
||||
|
||||
const metaData = checkoutSession.metadata;
|
||||
|
||||
if (metaData?.workspacePublicId && metaData.workspaceSlug) {
|
||||
if (metaData?.workspacePublicId) {
|
||||
await workspaceRepo.update(db, metaData.workspacePublicId, {
|
||||
slug: metaData.workspaceSlug,
|
||||
...(metaData.workspaceSlug && { slug: metaData.workspaceSlug }),
|
||||
plan: "pro",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
|
||||
interface ModalState {
|
||||
contentType: string;
|
||||
@@ -42,57 +42,59 @@ export const ModalProvider: React.FC<Props> = ({ children }) => {
|
||||
const entityId = currentModal?.entityId || "";
|
||||
const entityLabel = currentModal?.entityLabel || "";
|
||||
|
||||
const openModal = (
|
||||
contentType: string,
|
||||
entityId?: string,
|
||||
entityLabel?: string,
|
||||
) => {
|
||||
const newModal: ModalState = { contentType, entityId, entityLabel };
|
||||
setModalStack((prev) => [...prev, newModal]);
|
||||
};
|
||||
const openModal = useCallback(
|
||||
(contentType: string, entityId?: string, entityLabel?: string) => {
|
||||
const newModal: ModalState = { contentType, entityId, entityLabel };
|
||||
setModalStack((prev) => [...prev, newModal]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const closeModal = () => {
|
||||
const closeModal = useCallback(() => {
|
||||
setModalStack((prev) => {
|
||||
if (prev.length <= 1) {
|
||||
return [];
|
||||
}
|
||||
return prev.slice(0, -1);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeModals = (count: number) => {
|
||||
setModalStack(prev => {
|
||||
const closeModals = useCallback((count: number) => {
|
||||
setModalStack((prev) => {
|
||||
const newLength = Math.max(0, prev.length - count);
|
||||
return prev.slice(0, newLength);
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearAllModals = () => {
|
||||
const clearAllModals = useCallback(() => {
|
||||
setModalStack([]);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const setModalState = (modalType: string, state: any) => {
|
||||
const setModalState = useCallback((modalType: string, state: any) => {
|
||||
setModalStates((prev) => ({
|
||||
...prev,
|
||||
[modalType]: state,
|
||||
}));
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getModalState = (modalType: string) => {
|
||||
return modalStates[modalType];
|
||||
};
|
||||
const getModalState = useCallback(
|
||||
(modalType: string) => {
|
||||
return modalStates[modalType];
|
||||
},
|
||||
[modalStates],
|
||||
);
|
||||
|
||||
const clearModalState = (modalType: string) => {
|
||||
const clearModalState = useCallback((modalType: string) => {
|
||||
setModalStates((prev) => {
|
||||
const newStates = { ...prev };
|
||||
delete newStates[modalType];
|
||||
return newStates;
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearAllModalStates = () => {
|
||||
const clearAllModalStates = useCallback(() => {
|
||||
setModalStates({});
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ModalContext.Provider
|
||||
|
||||
@@ -47,12 +47,16 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
|
||||
const { data, isLoading } = api.workspace.all.useQuery();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const switchWorkspace = (_workspace: Workspace) => {
|
||||
localStorage.setItem("workspacePublicId", _workspace.publicId);
|
||||
|
||||
setWorkspace(_workspace);
|
||||
|
||||
// Refetch workspace data to ensure availableWorkspaces is up to date
|
||||
void utils.workspace.all.refetch();
|
||||
|
||||
router.push(`/boards`);
|
||||
};
|
||||
|
||||
@@ -66,21 +70,15 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
localStorage.getItem("workspacePublicId");
|
||||
|
||||
if (data.length) {
|
||||
const workspaces = data
|
||||
.map(({ workspace, role }) => {
|
||||
if (!workspace) return;
|
||||
|
||||
return {
|
||||
role,
|
||||
publicId: workspace.publicId,
|
||||
name: workspace.name,
|
||||
slug: workspace.slug,
|
||||
description: workspace.description,
|
||||
plan: workspace.plan,
|
||||
hasLoaded: true,
|
||||
};
|
||||
})
|
||||
.filter((workspace) => workspace !== null) as Workspace[];
|
||||
const workspaces = data.map(({ workspace, role }) => ({
|
||||
role,
|
||||
publicId: workspace.publicId,
|
||||
name: workspace.name,
|
||||
slug: workspace.slug,
|
||||
description: workspace.description,
|
||||
plan: workspace.plan,
|
||||
hasLoaded: true,
|
||||
})) as Workspace[];
|
||||
|
||||
if (workspaces.length) setAvailableWorkspaces(workspaces);
|
||||
}
|
||||
@@ -116,7 +114,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
role: primaryWorkspaceRole,
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
}, [data, isLoading]);
|
||||
|
||||
return (
|
||||
<WorkspaceContext.Provider
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -14,9 +15,13 @@ import { NewBoardForm } from "./components/NewBoardForm";
|
||||
|
||||
export default function BoardsPage() {
|
||||
const { openModal, modalContentType, isOpen } = useModal();
|
||||
const { workspace, hasLoaded } = useWorkspace();
|
||||
const { availableWorkspaces, workspace, hasLoaded } = useWorkspace();
|
||||
|
||||
if (hasLoaded && !workspace.publicId) openModal("NEW_WORKSPACE");
|
||||
useEffect(() => {
|
||||
if (hasLoaded && availableWorkspaces.length === 0) {
|
||||
openModal("NEW_WORKSPACE");
|
||||
}
|
||||
}, [hasLoaded, availableWorkspaces.length, openModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiMiniPlus } from "react-icons/hi2";
|
||||
|
||||
@@ -101,22 +100,19 @@ export default function LabelSelector({
|
||||
{selectedLabels.length ? (
|
||||
<div className="flex flex-wrap gap-x-0.5">
|
||||
{selectedLabels.map((label) => (
|
||||
<Menu.Button key={label.key}>
|
||||
<Badge value={label.value} iconLeft={label.leftIcon} />
|
||||
</Menu.Button>
|
||||
))}
|
||||
<Menu.Button>
|
||||
<Badge
|
||||
value={t`Add label`}
|
||||
iconLeft={<HiMiniPlus size={14} />}
|
||||
key={label.key}
|
||||
value={label.value}
|
||||
iconLeft={label.leftIcon}
|
||||
/>
|
||||
</Menu.Button>
|
||||
))}
|
||||
<Badge value={t`Add label`} iconLeft={<HiMiniPlus size={14} />} />
|
||||
</div>
|
||||
) : (
|
||||
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<HiMiniPlus size={22} className="pr-2" />
|
||||
{t`Add label`}
|
||||
</Menu.Button>
|
||||
</div>
|
||||
)}
|
||||
</CheckboxDropdown>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
@@ -79,9 +78,9 @@ export default function ListSelector({
|
||||
}}
|
||||
asChild
|
||||
>
|
||||
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
{selectedList?.value}
|
||||
</Menu.Button>
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { Menu } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiMiniPlus } from "react-icons/hi2";
|
||||
|
||||
@@ -112,11 +111,12 @@ export default function MemberSelector({
|
||||
createNewItemLabel={t`Invite member`}
|
||||
asChild
|
||||
>
|
||||
<Menu.Button className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
|
||||
{selectedMembers.length ? (
|
||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||
{selectedMembers.map(({ value, imageUrl }) => (
|
||||
<Avatar
|
||||
key={value}
|
||||
size="sm"
|
||||
name={value}
|
||||
imageUrl={imageUrl}
|
||||
@@ -130,7 +130,7 @@ export default function MemberSelector({
|
||||
{t`Add member`}
|
||||
</>
|
||||
)}
|
||||
</Menu.Button>
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import Footer from "./Footer";
|
||||
import Header from "./Header";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { theme } = useTheme();
|
||||
@@ -18,6 +20,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
<style jsx global>{`
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
overflow: auto;
|
||||
background-color: ${!isDarkMode ? "hsl(0deg 0% 97.3%)" : "#161616"};
|
||||
}
|
||||
`}</style>
|
||||
|
||||
@@ -2,7 +2,7 @@ import Link from "next/link";
|
||||
import { Radio, RadioGroup } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useState } from "react";
|
||||
import { HiCheckCircle } from "react-icons/hi2";
|
||||
import { HiBolt, HiCheckCircle } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Frequency = "monthly" | "annually";
|
||||
@@ -92,12 +92,13 @@ const Pricing = () => {
|
||||
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`}
|
||||
</p>
|
||||
|
||||
{/* Beta free pricing pill */}
|
||||
<div className="mt-4 flex items-center gap-2 rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800 dark:bg-green-700 dark:text-green-100">
|
||||
{t`All seats are free during the beta until Sept 1st!`}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 flex justify-center">
|
||||
<div className="mt-12 flex flex-col items-center justify-center">
|
||||
<div className="mb-4 flex items-center gap-2 rounded-full border bg-white px-4 py-1.5 text-center text-xs font-bold text-gray-800 dark:border-dark-300 dark:bg-white dark:text-gray-800 lg:text-sm">
|
||||
<HiBolt />
|
||||
<p>
|
||||
{t`Launch offer: unlimited seats for just $29/month with Pro`}
|
||||
</p>
|
||||
</div>
|
||||
<fieldset aria-label={t`Payment frequency`}>
|
||||
<RadioGroup
|
||||
value={frequency}
|
||||
@@ -108,7 +109,12 @@ const Pricing = () => {
|
||||
<Radio
|
||||
key={option.value}
|
||||
value={option}
|
||||
className="cursor-pointer rounded-full px-2.5 py-1 text-xs text-light-900 data-[checked]:bg-dark-50 data-[checked]:text-white dark:data-[checked]:bg-light-50 dark:data-[checked]:text-dark-50 lg:text-sm"
|
||||
className={twMerge(
|
||||
"cursor-pointer rounded-full px-2.5 py-1 text-xs transition-colors lg:text-sm",
|
||||
frequency?.value === option.value
|
||||
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50"
|
||||
: "text-light-900 hover:bg-light-100 dark:hover:bg-dark-100",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</Radio>
|
||||
@@ -165,7 +171,7 @@ const Pricing = () => {
|
||||
!tier.showPrice && "opacity-0",
|
||||
)}
|
||||
>
|
||||
{tier.price[frequency?.value || "monthly"]}
|
||||
{tier.price[frequency?.value ?? "monthly"]}
|
||||
</span>
|
||||
{tier.showPriceSuffix && (
|
||||
<span className="text-sm/6 font-semibold text-light-50 dark:text-dark-900">
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { InviteMemberInput } from "@kan/api/types";
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
import { getSubscriptionByPlan } from "@kan/shared/utils";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
@@ -15,7 +19,17 @@ import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function InviteMemberForm() {
|
||||
export function InviteMemberForm({
|
||||
numberOfMembers,
|
||||
subscriptions,
|
||||
unlimitedSeats,
|
||||
userId,
|
||||
}: {
|
||||
numberOfMembers: number;
|
||||
subscriptions: Subscription[] | undefined;
|
||||
unlimitedSeats: boolean;
|
||||
userId: string | undefined;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [isCreateAnotherEnabled, setIsCreateAnotherEnabled] = useState(false);
|
||||
const { closeModal } = useModal();
|
||||
@@ -42,7 +56,7 @@ export function InviteMemberForm() {
|
||||
|
||||
const refetchBoards = () => utils.board.all.refetch();
|
||||
|
||||
const createBoard = api.member.invite.useMutation({
|
||||
const inviteMember = api.member.invite.useMutation({
|
||||
onSuccess: async () => {
|
||||
closeModal();
|
||||
await utils.workspace.byId.refetch();
|
||||
@@ -68,8 +82,56 @@ export function InviteMemberForm() {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: InviteMemberInput) => {
|
||||
createBoard.mutate(data);
|
||||
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
|
||||
const proSubscription = getSubscriptionByPlan(subscriptions, "pro");
|
||||
|
||||
const hasTeamSubscription = !!teamSubscription;
|
||||
const hasProSubscription = !!proSubscription;
|
||||
|
||||
let isYearly = false;
|
||||
let price = t`$10/month`;
|
||||
let billingType = t`monthly billing`;
|
||||
|
||||
if (teamSubscription?.periodStart && teamSubscription?.periodEnd) {
|
||||
const periodStartDate = new Date(teamSubscription.periodStart);
|
||||
const periodEndDate = new Date(teamSubscription.periodEnd);
|
||||
const diffInDays = Math.round(
|
||||
(periodEndDate.getTime() - periodStartDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
isYearly = diffInDays > 31;
|
||||
price = isYearly ? t`$8/month` : t`$10/month`;
|
||||
billingType = isYearly ? t`billed annually` : t`billed monthly`;
|
||||
}
|
||||
|
||||
const onSubmit = (member: InviteMemberInput) => {
|
||||
inviteMember.mutate(member);
|
||||
};
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
const { data, error } = await authClient.subscription.upgrade({
|
||||
plan: "team",
|
||||
referenceId: workspace.publicId,
|
||||
metadata: { userId },
|
||||
seats: numberOfMembers,
|
||||
successUrl: "/members",
|
||||
cancelUrl: "/members",
|
||||
returnUrl: "/members",
|
||||
disableRedirect: true,
|
||||
});
|
||||
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
showPopup({
|
||||
header: t`Error upgrading subscription`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -97,6 +159,11 @@ export function InviteMemberForm() {
|
||||
<Input
|
||||
id="email"
|
||||
placeholder={t`Email`}
|
||||
disabled={
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription
|
||||
}
|
||||
{...register("email", { required: true })}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -106,22 +173,66 @@ export function InviteMemberForm() {
|
||||
}}
|
||||
errorMessage={errors.email?.message}
|
||||
/>
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
{hasTeamSubscription || hasProSubscription ? (
|
||||
<div>
|
||||
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
||||
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{unlimitedSeats
|
||||
? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!`
|
||||
: t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="font-medium text-light-950 dark:text-dark-950">
|
||||
{t`Free Plan`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{t`Inviting members requires a Team Plan. You'll be redirected to upgrade your workspace.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={t`Invite another`}
|
||||
isChecked={isCreateAnotherEnabled}
|
||||
onChange={() => setIsCreateAnotherEnabled(!isCreateAnotherEnabled)}
|
||||
/>
|
||||
{(hasTeamSubscription || hasProSubscription) &&
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
<Toggle
|
||||
label={t`Invite another`}
|
||||
isChecked={isCreateAnotherEnabled}
|
||||
onChange={() =>
|
||||
setIsCreateAnotherEnabled(!isCreateAnotherEnabled)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={createBoard.isPending}
|
||||
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
|
||||
>
|
||||
{t`Invite member`}
|
||||
</Button>
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUpgrade}
|
||||
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
|
||||
>
|
||||
{t`Upgrade to Team Plan`}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={inviteMember.isPending}
|
||||
isLoading={inviteMember.isPending}
|
||||
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
|
||||
>
|
||||
{t`Invite member`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiEllipsisHorizontal, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
import { env } from "next-runtime-env";
|
||||
import {
|
||||
HiBolt,
|
||||
HiEllipsisHorizontal,
|
||||
HiOutlinePlusSmall,
|
||||
} from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
@@ -27,6 +35,15 @@ export default function MembersPage() {
|
||||
// { enabled: workspace?.publicId ? true : false },
|
||||
);
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const subscriptions = data?.subscriptions as Subscription[] | undefined;
|
||||
|
||||
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
|
||||
const proSubscription = getSubscriptionByPlan(subscriptions, "pro");
|
||||
|
||||
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
||||
|
||||
const TableRow = ({
|
||||
memberPublicId,
|
||||
memberId,
|
||||
@@ -48,7 +65,6 @@ export default function MembersPage() {
|
||||
isLastRow?: boolean;
|
||||
showSkeleton?: boolean;
|
||||
}) => {
|
||||
const { data: session } = authClient.useSession();
|
||||
return (
|
||||
<tr className="rounded-b-lg">
|
||||
<td
|
||||
@@ -157,10 +173,46 @@ export default function MembersPage() {
|
||||
<PageHead title={t`Members | ${workspace.name ?? "Workspace"}`} />
|
||||
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
|
||||
<div className="mb-8 flex w-full justify-between">
|
||||
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`Members`}
|
||||
</h1>
|
||||
<div className="flex">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`Members`}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
<>
|
||||
{!proSubscription && (
|
||||
<Link
|
||||
href="/settings?upgrade=pro"
|
||||
className="hidden items-center rounded-full border border-emerald-300 bg-emerald-50 px-3 py-1 text-center text-xs text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400 lg:flex"
|
||||
>
|
||||
<HiBolt />
|
||||
<span className="ml-1 font-medium">
|
||||
{t`Launch offer: Get unlimited members with Pro`}
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center rounded-full border px-3 py-1 text-center text-xs",
|
||||
teamSubscription || proSubscription
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
: "border-light-300 bg-light-50 text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{proSubscription
|
||||
? t`Pro Plan`
|
||||
: teamSubscription
|
||||
? t`Team Plan`
|
||||
: t`Free Plan`}
|
||||
{proSubscription && unlimitedSeats && (
|
||||
<span className="ml-1 text-xs">∞</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => openModal("INVITE_MEMBER")}
|
||||
iconLeft={<HiOutlinePlusSmall className="h-4 w-4" />}
|
||||
@@ -241,7 +293,12 @@ export default function MembersPage() {
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "INVITE_MEMBER"}
|
||||
>
|
||||
<InviteMemberForm />
|
||||
<InviteMemberForm
|
||||
userId={session?.user.id}
|
||||
numberOfMembers={data?.members.length ?? 1}
|
||||
subscriptions={subscriptions}
|
||||
unlimitedSeats={unlimitedSeats}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
|
||||
export function CustomURLConfirmation({
|
||||
workspacePublicId,
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
}) {
|
||||
const { closeModal, entityId } = useModal();
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/create_checkout_session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slug: entityId,
|
||||
workspacePublicId: workspacePublicId,
|
||||
cancelUrl: "/settings",
|
||||
successUrl: "/settings",
|
||||
}),
|
||||
});
|
||||
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout session:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{t`Confirm URL change`}
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Custom URLs are a premium feature. You'll be directed to upgrade your account.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button onClick={() => closeModal()} variant="secondary">
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button onClick={handleUpgrade}>{t`Upgrade`}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,7 +98,7 @@ const UpdateWorkspaceUrlForm = ({
|
||||
if (!isWorkspaceSlugAvailable?.isAvailable) return;
|
||||
|
||||
if (workspacePlan !== "pro" && env("NEXT_PUBLIC_KAN_ENV") === "cloud")
|
||||
return openModal("UPDATE_WORKSPACE_URL", data.slug);
|
||||
return openModal("UPGRADE_TO_PRO", data.slug);
|
||||
|
||||
updateWorkspaceSlug.mutate({
|
||||
workspacePublicId,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiBolt, HiCheckBadge } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
|
||||
export function UpgradeToProConfirmation({
|
||||
workspacePublicId,
|
||||
}: {
|
||||
userId: string;
|
||||
workspacePublicId: string;
|
||||
}) {
|
||||
const { closeModal, entityId } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/create_checkout_session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(entityId && { slug: entityId }),
|
||||
workspacePublicId: workspacePublicId,
|
||||
cancelUrl: "/settings",
|
||||
successUrl: "/settings",
|
||||
}),
|
||||
});
|
||||
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout session:", error);
|
||||
|
||||
showPopup({
|
||||
header: t`Error upgrading subscription`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Upgrade to Pro`}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Supercharge your workspace for just $29/month. Here's what you'll get:`}
|
||||
</p>
|
||||
|
||||
<div className="rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-5 w-5 flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-neutral-900 dark:text-dark-1000">
|
||||
{t`Unlimited members`}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400 sm:text-[10px]">
|
||||
{t`Launch offer`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-5 w-5 flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<span className="text-sm text-neutral-900 dark:text-dark-1000">
|
||||
{t`Custom workspace URL`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-5 w-5 flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs text-neutral-900 dark:text-dark-1000">
|
||||
{t`Board analytics`}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-gray-500/10 px-2 py-0.5 text-[10px] font-medium text-gray-600 ring-1 ring-inset ring-gray-500/20 dark:text-gray-400 sm:text-[10px]">
|
||||
{t`Coming soon`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-5 w-5 flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<span className="text-sm text-neutral-900 dark:text-dark-1000">
|
||||
{t`Priority email support`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button
|
||||
onClick={() => {
|
||||
closeModal();
|
||||
}}
|
||||
variant="secondary"
|
||||
>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpgrade}
|
||||
iconRight={<HiBolt />}
|
||||
>{t`Upgrade`}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { HiBolt, HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { hasActiveSubscription } from "@kan/shared/utils";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
@@ -17,13 +20,13 @@ import { api } from "~/utils/api";
|
||||
import Avatar from "./components/Avatar";
|
||||
import { ChangePasswordFormConfirmation } from "./components/ChangePasswordConfirmation";
|
||||
import CreateAPIKeyForm from "./components/CreateAPIKeyForm";
|
||||
import { CustomURLConfirmation } from "./components/CustomURLConfirmation";
|
||||
import { DeleteAccountConfirmation } from "./components/DeleteAccountConfirmation";
|
||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||
import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm";
|
||||
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
||||
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
||||
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
||||
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
@@ -33,10 +36,19 @@ export default function SettingsPage() {
|
||||
const router = useRouter();
|
||||
const workspaceUrlSectionRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [hasOpenedUpgradeModal, setHasOpenedUpgradeModal] = useState(false);
|
||||
const isCredentialsEnabled =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
const { data } = api.user.getUser.useQuery();
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
|
||||
const subscriptions = workspaceData?.subscriptions as
|
||||
| Subscription[]
|
||||
| undefined;
|
||||
|
||||
const {
|
||||
data: integrations,
|
||||
refetch: refetchIntegrations,
|
||||
@@ -84,6 +96,19 @@ export default function SettingsPage() {
|
||||
}
|
||||
}, [router.query.edit]);
|
||||
|
||||
// Open upgrade modal if upgrade=pro is in URL params
|
||||
useEffect(() => {
|
||||
if (
|
||||
router.query.upgrade === "pro" &&
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasActiveSubscription(subscriptions, "pro") &&
|
||||
!hasOpenedUpgradeModal
|
||||
) {
|
||||
openModal("UPGRADE_TO_PRO");
|
||||
setHasOpenedUpgradeModal(true);
|
||||
}
|
||||
}, [router.query.upgrade, subscriptions, openModal, hasOpenedUpgradeModal]);
|
||||
|
||||
const { mutateAsync: disconnectTrello } =
|
||||
api.integration.disconnect.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -180,6 +205,18 @@ export default function SettingsPage() {
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceDescription={workspace.description ?? ""}
|
||||
/>
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasActiveSubscription(subscriptions, "pro") && (
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
iconRight={<HiBolt />}
|
||||
>
|
||||
{t`Upgrade to Pro`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
@@ -346,9 +383,12 @@ export default function SettingsPage() {
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "UPDATE_WORKSPACE_URL"}
|
||||
isVisible={isOpen && modalContentType === "UPGRADE_TO_PRO"}
|
||||
>
|
||||
<CustomURLConfirmation workspacePublicId={workspace.publicId} />
|
||||
<UpgradeToProConfirmation
|
||||
userId={data?.id ?? ""}
|
||||
workspacePublicId={workspace.publicId}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -22,6 +22,11 @@ services:
|
||||
# Stripe
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||
- STRIPE_WEBHOOK_SECRET_LEGACY=${STRIPE_WEBHOOK_SECRET_LEGACY}
|
||||
- STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID=${STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID}
|
||||
- STRIPE_TEAM_PLAN_YEARLY_PRICE_ID=${STRIPE_TEAM_PLAN_YEARLY_PRICE_ID}
|
||||
- STRIPE_PRO_PLAN_MONTHLY_PRICE_ID=${STRIPE_PRO_PLAN_MONTHLY_PRICE_ID}
|
||||
- STRIPE_PRO_PLAN_YEARLY_PRICE_ID=${STRIPE_PRO_PLAN_YEARLY_PRICE_ID}
|
||||
|
||||
# Email
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
|
||||
@@ -31,5 +31,11 @@
|
||||
"turbo": "^2.3.1",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
"prettier": "@kan/prettier-config",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/minimatch": "npm:minimatch@*",
|
||||
"@types/glob": "npm:@types/glob@^8.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,10 @@ import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const feedbackRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.meta({ enabled: false, openApi: false })
|
||||
.meta({
|
||||
enabled: false,
|
||||
openapi: { enabled: false, method: "POST", path: "/feedback" },
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
feedback: z.string().min(1),
|
||||
|
||||
@@ -2,8 +2,11 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
|
||||
import { updateSubscriptionSeats } from "@kan/stripe";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
@@ -60,6 +63,47 @@ export const memberRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
||||
const subscriptions = await subscriptionRepo.getByReferenceId(
|
||||
ctx.db,
|
||||
workspace.publicId,
|
||||
);
|
||||
|
||||
// get the active subscriptions
|
||||
const activeTeamSubscription = getSubscriptionByPlan(
|
||||
subscriptions,
|
||||
"team",
|
||||
);
|
||||
const activeProSubscription = getSubscriptionByPlan(
|
||||
subscriptions,
|
||||
"pro",
|
||||
);
|
||||
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
||||
|
||||
if (!activeTeamSubscription && !activeProSubscription) {
|
||||
throw new TRPCError({
|
||||
message: `Workspace with public ID ${workspace.publicId} does not have an active subscription`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
// Update the Stripe subscription
|
||||
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
|
||||
try {
|
||||
await updateSubscriptionSeats(
|
||||
activeTeamSubscription.stripeSubscriptionId,
|
||||
1,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to update Stripe subscription seats:", error);
|
||||
throw new TRPCError({
|
||||
message: `Failed to update subscription for the new member.`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existingUser = await userRepo.getByEmail(ctx.db, input.email);
|
||||
|
||||
const invite = await memberRepo.create(ctx.db, {
|
||||
@@ -165,6 +209,36 @@ export const memberRouter = createTRPCRouter({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
// Handle subscription seat decrement for cloud environment
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
||||
const subscriptions = await subscriptionRepo.getByReferenceId(
|
||||
ctx.db,
|
||||
workspace.publicId,
|
||||
);
|
||||
|
||||
// get the active subscriptions
|
||||
const activeTeamSubscription = getSubscriptionByPlan(
|
||||
subscriptions,
|
||||
"team",
|
||||
);
|
||||
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
||||
|
||||
// Only decrease seats if there's an active subscription and stripeSubscriptionId
|
||||
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
|
||||
try {
|
||||
await updateSubscriptionSeats(
|
||||
activeTeamSubscription.stripeSubscriptionId,
|
||||
-1,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to decrease Stripe subscription seats:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -140,6 +140,12 @@ export const workspaceRouter = createTRPCRouter({
|
||||
.input(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
slug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(24)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.create>>>())
|
||||
@@ -153,12 +159,43 @@ export const workspaceRouter = createTRPCRouter({
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
// Check if slug is provided in cloud environment
|
||||
if (input.slug && env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||
throw new TRPCError({
|
||||
message: "Custom URLs are only available for Pro workspaces",
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
const workspacePublicId = generateUID();
|
||||
const workspaceSlug = input.slug ?? workspacePublicId;
|
||||
|
||||
if (input.slug) {
|
||||
const reservedOrPremiumWorkspaceSlug =
|
||||
await workspaceSlugRepo.getWorkspaceSlug(ctx.db, input.slug);
|
||||
|
||||
const isWorkspaceSlugAvailable =
|
||||
await workspaceRepo.isWorkspaceSlugAvailable(ctx.db, input.slug);
|
||||
|
||||
if (reservedOrPremiumWorkspaceSlug) {
|
||||
throw new TRPCError({
|
||||
message: `Workspace slug '${input.slug}' is reserved or premium`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
if (!isWorkspaceSlugAvailable) {
|
||||
throw new TRPCError({
|
||||
message: `Workspace slug '${input.slug}' is already taken`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await workspaceRepo.create(ctx.db, {
|
||||
publicId: workspacePublicId,
|
||||
name: input.name,
|
||||
slug: workspacePublicId,
|
||||
slug: workspaceSlug,
|
||||
createdBy: userId,
|
||||
createdByEmail: userEmail,
|
||||
});
|
||||
@@ -334,6 +371,14 @@ export const workspaceRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const slug = input.workspaceSlug.toLowerCase();
|
||||
// check slug is not reserved
|
||||
const workspaceSlug = await workspaceSlugRepo.getWorkspaceSlug(
|
||||
@@ -345,6 +390,19 @@ export const workspaceRouter = createTRPCRouter({
|
||||
const isWorkspaceSlugAvailable =
|
||||
await workspaceRepo.isWorkspaceSlugAvailable(ctx.db, slug);
|
||||
|
||||
const isAvailable =
|
||||
isWorkspaceSlugAvailable && workspaceSlug?.type !== "reserved";
|
||||
const isReserved = workspaceSlug?.type === "reserved";
|
||||
|
||||
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
|
||||
await workspaceSlugRepo.createWorkspaceSlugCheck(ctx.db, {
|
||||
slug,
|
||||
userId,
|
||||
available: isAvailable,
|
||||
reserved: isReserved,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isAvailable:
|
||||
isWorkspaceSlugAvailable && workspaceSlug?.type !== "reserved",
|
||||
|
||||
@@ -32,6 +32,11 @@ const createAuthWithHeaders = (
|
||||
headers,
|
||||
body: { email: input.email, callbackURL: input.callbackURL },
|
||||
}),
|
||||
listActiveSubscriptions: (input: { workspacePublicId: string }) =>
|
||||
auth.api.listActiveSubscriptions({
|
||||
headers,
|
||||
query: { referenceId: input.workspacePublicId },
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
},
|
||||
"prettier": "@kan/prettier-config",
|
||||
"dependencies": {
|
||||
"better-auth": "^1.2.8"
|
||||
"@better-auth/stripe": "^1.3.7",
|
||||
"better-auth": "^1.3.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { stripe } from "@better-auth/stripe";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
||||
@@ -9,7 +10,9 @@ import { env } from "next-runtime-env";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import * as schema from "@kan/db/schema";
|
||||
import { cloudMailerClient, sendEmail } from "@kan/email";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
@@ -112,13 +115,13 @@ async function downloadImage(url: string): Promise<Buffer> {
|
||||
export const initAuth = (db: dbClient) => {
|
||||
return betterAuth({
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
baseURL: process.env.NEXT_PUBLIC_BASE_URL!,
|
||||
baseURL: env("NEXT_PUBLIC_BASE_URL"),
|
||||
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
? [
|
||||
process.env.NEXT_PUBLIC_BASE_URL!,
|
||||
env("NEXT_PUBLIC_BASE_URL") ?? "",
|
||||
...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","),
|
||||
]
|
||||
: [process.env.NEXT_PUBLIC_BASE_URL!],
|
||||
: [env("NEXT_PUBLIC_BASE_URL") ?? ""],
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: {
|
||||
@@ -153,6 +156,76 @@ export const initAuth = (db: dbClient) => {
|
||||
},
|
||||
plugins: [
|
||||
socialProvidersPlugin(),
|
||||
...(process.env.NEXT_PUBLIC_KAN_ENV === "cloud"
|
||||
? [
|
||||
stripe({
|
||||
stripeClient: createStripeClient(),
|
||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
createCustomerOnSignUp: true,
|
||||
subscription: {
|
||||
enabled: true,
|
||||
plans: [
|
||||
{
|
||||
name: "team",
|
||||
priceId: process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID!,
|
||||
annualDiscountPriceId:
|
||||
process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID!,
|
||||
},
|
||||
{
|
||||
name: "pro",
|
||||
priceId: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID!,
|
||||
annualDiscountPriceId:
|
||||
process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID!,
|
||||
},
|
||||
],
|
||||
authorizeReference: async (data) => {
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
data.referenceId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const isUserInWorkspace =
|
||||
await workspaceRepo.isUserInWorkspace(
|
||||
db,
|
||||
data.user.id,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return isUserInWorkspace;
|
||||
},
|
||||
getCheckoutSessionParams: () => {
|
||||
return {
|
||||
params: {
|
||||
allow_promotion_codes: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
onSubscriptionComplete: async ({
|
||||
subscription,
|
||||
stripeSubscription,
|
||||
}) => {
|
||||
// Set unlimited seats to true for pro plans
|
||||
if (subscription.plan === "pro") {
|
||||
await subscriptionRepo.updateByStripeSubscriptionId(
|
||||
db,
|
||||
stripeSubscription.id,
|
||||
{
|
||||
unlimitedSeats: true,
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
`Pro subscription ${stripeSubscription.id} activated with unlimited seats`,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
// @todo: hasing is disabled due to a bug in the api key plugin
|
||||
apiKey({ disableKeyHashing: true }),
|
||||
magicLink({
|
||||
@@ -270,37 +343,17 @@ export const initAuth = (db: dbClient) => {
|
||||
},
|
||||
hooks: {
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path.startsWith("/get-session")) {
|
||||
const user = ctx.context.session?.user;
|
||||
|
||||
if (
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
user &&
|
||||
!user.stripeCustomerId
|
||||
) {
|
||||
const stripe = createStripeClient();
|
||||
const stripeCustomer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await userRepo.update(db, user.id, {
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
if (
|
||||
ctx.path === "/magic-link/verify" &&
|
||||
(ctx.query?.callbackURL as string | undefined)?.includes(
|
||||
"type=invite",
|
||||
) &&
|
||||
ctx.query?.memberPublicId
|
||||
)
|
||||
) {
|
||||
const userId = ctx.context.newSession?.session.userId;
|
||||
const memberPublicId = ctx.query.memberPublicId as string;
|
||||
const callbackURL = ctx.query?.callbackURL as string | undefined;
|
||||
const memberPublicId = callbackURL?.split("memberPublicId=")[1];
|
||||
|
||||
if (userId) {
|
||||
if (userId && memberPublicId) {
|
||||
const member = await memberRepo.getByPublicId(db, memberPublicId);
|
||||
|
||||
if (member?.id) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { BetterAuthClientPlugin } from "better-auth";
|
||||
import type { BetterFetchOption } from "better-auth/react";
|
||||
import { stripeClient } from "@better-auth/stripe/client";
|
||||
import {
|
||||
apiKeyClient,
|
||||
genericOAuthClient,
|
||||
@@ -27,6 +28,9 @@ const socialProvidersPluginClient = {
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [
|
||||
stripeClient({
|
||||
subscription: true,
|
||||
}),
|
||||
magicLinkClient(),
|
||||
apiKeyClient(),
|
||||
genericOAuthClient(),
|
||||
|
||||
23
packages/db/migrations/20250902085406_AddSubscriptions.sql
Normal file
23
packages/db/migrations/20250902085406_AddSubscriptions.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE IF NOT EXISTS "subscription" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"plan" varchar(255) NOT NULL,
|
||||
"referenceId" varchar(12) NOT NULL,
|
||||
"stripeCustomerId" varchar(255),
|
||||
"stripeSubscriptionId" varchar(255),
|
||||
"status" varchar(255) NOT NULL,
|
||||
"periodStart" timestamp,
|
||||
"periodEnd" timestamp,
|
||||
"cancelAtPeriodEnd" boolean,
|
||||
"seats" integer,
|
||||
"trialStart" timestamp,
|
||||
"trialEnd" timestamp,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "subscription" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "subscription" ADD CONSTRAINT "subscription_referenceId_workspace_publicId_fk" FOREIGN KEY ("referenceId") REFERENCES "public"."workspace"("publicId") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "subscription" ADD COLUMN "unlimitedSeats" boolean DEFAULT false NOT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "subscription" ALTER COLUMN "referenceId" DROP NOT NULL;
|
||||
@@ -0,0 +1,22 @@
|
||||
CREATE TABLE IF NOT EXISTS "workspace_slug_checks" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"slug" varchar(255) NOT NULL,
|
||||
"available" boolean NOT NULL,
|
||||
"reserved" boolean NOT NULL,
|
||||
"workspaceId" bigint,
|
||||
"createdBy" uuid,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workspace_slug_checks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_slug_checks" ADD CONSTRAINT "workspace_slug_checks_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_slug_checks" ADD CONSTRAINT "workspace_slug_checks_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "subscription" DROP CONSTRAINT "subscription_referenceId_workspace_publicId_fk";
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "subscription" ADD CONSTRAINT "subscription_referenceId_workspace_publicId_fk" FOREIGN KEY ("referenceId") REFERENCES "public"."workspace"("publicId") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
2536
packages/db/migrations/meta/20250902085406_snapshot.json
Normal file
2536
packages/db/migrations/meta/20250902085406_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2543
packages/db/migrations/meta/20250907185512_snapshot.json
Normal file
2543
packages/db/migrations/meta/20250907185512_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2543
packages/db/migrations/meta/20250910195058_snapshot.json
Normal file
2543
packages/db/migrations/meta/20250910195058_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2626
packages/db/migrations/meta/20250910200416_snapshot.json
Normal file
2626
packages/db/migrations/meta/20250910200416_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2626
packages/db/migrations/meta/20250910202358_snapshot.json
Normal file
2626
packages/db/migrations/meta/20250910202358_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,41 @@
|
||||
"when": 1755094668761,
|
||||
"tag": "20250813141748_AddChecklistActivityTypes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1756803246096,
|
||||
"tag": "20250902085406_AddSubscriptions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1757271312974,
|
||||
"tag": "20250907185512_AddUnlimitedSeatsToSubscription",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 11,
|
||||
"version": "7",
|
||||
"when": 1757533858687,
|
||||
"tag": "20250910195058_RemoveNotNullConstraintFromReferenceIdOnSubsriptions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1757534656098,
|
||||
"tag": "20250910200416_AddWorkspaceSlugChecks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "7",
|
||||
"when": 1757535838766,
|
||||
"tag": "20250910202358_AddCascadeSetNullToReferenceIdOnSubsriptions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import { drizzle as drizzlePgLite } from "drizzle-orm/pglite";
|
||||
import { PGlite } from "@electric-sql/pglite";
|
||||
import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
|
||||
import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
|
||||
import { drizzle as drizzlePgLite } from "drizzle-orm/pglite";
|
||||
import { migrate } from "drizzle-orm/pglite/migrator";
|
||||
import { Pool } from "pg";
|
||||
|
||||
import * as schema from "./schema";
|
||||
import { PGlite } from "@electric-sql/pglite";
|
||||
import { migrate } from "drizzle-orm/pglite/migrator";
|
||||
|
||||
export type dbClient = NodePgDatabase<typeof schema> & {
|
||||
$client: Pool;
|
||||
@@ -18,7 +18,10 @@ export const createDrizzleClient = (): dbClient => {
|
||||
if (!connectionString) {
|
||||
console.log("POSTGRES_URL environment variable is not set, using PGLite");
|
||||
|
||||
const client = new PGlite({ dataDir: "./pgdata", extensions: { uuid_ossp }});
|
||||
const client = new PGlite({
|
||||
dataDir: "./pgdata",
|
||||
extensions: { uuid_ossp },
|
||||
});
|
||||
const db = drizzlePgLite(client, { schema });
|
||||
|
||||
migrate(db, { migrationsFolder: "../../packages/db/migrations" });
|
||||
|
||||
91
packages/db/src/repository/subscription.repo.ts
Normal file
91
packages/db/src/repository/subscription.repo.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { subscription } from "@kan/db/schema";
|
||||
|
||||
export const updateById = async (
|
||||
db: dbClient,
|
||||
subscriptionId: number,
|
||||
updates: {
|
||||
unlimitedSeats?: boolean;
|
||||
status?: string;
|
||||
seats?: number | null;
|
||||
periodStart?: Date | null;
|
||||
periodEnd?: Date | null;
|
||||
cancelAtPeriodEnd?: boolean | null;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
.update(subscription)
|
||||
.set({
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
.returning({
|
||||
id: subscription.id,
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
unlimitedSeats: subscription.unlimitedSeats,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const updateByStripeSubscriptionId = async (
|
||||
db: dbClient,
|
||||
stripeSubscriptionId: string,
|
||||
updates: {
|
||||
unlimitedSeats?: boolean;
|
||||
status?: string;
|
||||
seats?: number | null;
|
||||
periodStart?: Date | null;
|
||||
periodEnd?: Date | null;
|
||||
cancelAtPeriodEnd?: boolean | null;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
.update(subscription)
|
||||
.set({
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(subscription.stripeSubscriptionId, stripeSubscriptionId))
|
||||
.returning({
|
||||
id: subscription.id,
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
unlimitedSeats: subscription.unlimitedSeats,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getByStripeSubscriptionId = async (
|
||||
db: dbClient,
|
||||
stripeSubscriptionId: string,
|
||||
) => {
|
||||
return await db.query.subscription.findFirst({
|
||||
where: eq(subscription.stripeSubscriptionId, stripeSubscriptionId),
|
||||
});
|
||||
};
|
||||
|
||||
export const getByReferenceId = async (db: dbClient, referenceId: string) => {
|
||||
return await db.query.subscription.findMany({
|
||||
where: eq(subscription.referenceId, referenceId),
|
||||
});
|
||||
};
|
||||
|
||||
export const create = async (
|
||||
db: dbClient,
|
||||
data: {
|
||||
plan: string;
|
||||
referenceId: string;
|
||||
userId: string;
|
||||
stripeCustomerId: string;
|
||||
status: string;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db.insert(subscription).values(data).returning();
|
||||
return result;
|
||||
};
|
||||
@@ -121,6 +121,17 @@ export const getByPublicIdWithMembers = (
|
||||
},
|
||||
},
|
||||
},
|
||||
subscriptions: {
|
||||
columns: {
|
||||
id: true,
|
||||
plan: true,
|
||||
status: true,
|
||||
seats: true,
|
||||
unlimitedSeats: true,
|
||||
periodStart: true,
|
||||
periodEnd: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: and(
|
||||
eq(workspaces.publicId, workspacePublicId),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { slugs } from "@kan/db/schema";
|
||||
import { slugChecks, slugs } from "@kan/db/schema";
|
||||
|
||||
export const getWorkspaceSlug = (db: dbClient, slug: string) => {
|
||||
return db.query.slugs.findFirst({
|
||||
@@ -12,3 +12,20 @@ export const getWorkspaceSlug = (db: dbClient, slug: string) => {
|
||||
where: eq(slugs.slug, slug),
|
||||
});
|
||||
};
|
||||
|
||||
export const createWorkspaceSlugCheck = (
|
||||
db: dbClient,
|
||||
input: {
|
||||
slug: string;
|
||||
userId: string;
|
||||
available: boolean;
|
||||
reserved: boolean;
|
||||
},
|
||||
) => {
|
||||
return db.insert(slugChecks).values({
|
||||
slug: input.slug,
|
||||
available: input.available,
|
||||
reserved: input.reserved,
|
||||
createdBy: input.userId,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -10,3 +10,4 @@ export * from "./lists";
|
||||
export * from "./users";
|
||||
export * from "./integrations";
|
||||
export * from "./workspaces";
|
||||
export * from "./subscriptions";
|
||||
|
||||
39
packages/db/src/schema/subscriptions.ts
Normal file
39
packages/db/src/schema/subscriptions.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { relations } from "drizzle-orm";
|
||||
import {
|
||||
bigserial,
|
||||
boolean,
|
||||
integer,
|
||||
pgTable,
|
||||
timestamp,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { workspaces } from "./workspaces";
|
||||
|
||||
export const subscription = pgTable("subscription", {
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
plan: varchar("plan", { length: 255 }).notNull(),
|
||||
referenceId: varchar("referenceId", { length: 12 }).references(
|
||||
() => workspaces.publicId,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
|
||||
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
|
||||
status: varchar("status", { length: 255 }).notNull(),
|
||||
periodStart: timestamp("periodStart"),
|
||||
periodEnd: timestamp("periodEnd"),
|
||||
cancelAtPeriodEnd: boolean("cancelAtPeriodEnd"),
|
||||
seats: integer("seats"),
|
||||
unlimitedSeats: boolean("unlimitedSeats").default(false).notNull(),
|
||||
trialStart: timestamp("trialStart"),
|
||||
trialEnd: timestamp("trialEnd"),
|
||||
createdAt: timestamp("createdAt").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updatedAt").notNull().defaultNow(),
|
||||
}).enableRLS();
|
||||
|
||||
export const subscriptionsRelations = relations(subscription, ({ one }) => ({
|
||||
workspace: one(workspaces, {
|
||||
fields: [subscription.referenceId],
|
||||
references: [workspaces.publicId],
|
||||
}),
|
||||
}));
|
||||
@@ -2,6 +2,7 @@ import { relations } from "drizzle-orm";
|
||||
import {
|
||||
bigint,
|
||||
bigserial,
|
||||
boolean,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
@@ -11,6 +12,7 @@ import {
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { boards } from "./boards";
|
||||
import { subscription } from "./subscriptions";
|
||||
import { users } from "./users";
|
||||
|
||||
export const memberRoles = ["admin", "member", "guest"] as const;
|
||||
@@ -60,6 +62,7 @@ export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
|
||||
}),
|
||||
members: many(workspaceMembers),
|
||||
boards: many(boards),
|
||||
subscriptions: many(subscription),
|
||||
}));
|
||||
|
||||
export const workspaceMembers = pgTable("workspace_members", {
|
||||
@@ -101,3 +104,17 @@ export const slugs = pgTable("workspace_slugs", {
|
||||
slug: varchar("slug", { length: 255 }).notNull().unique(),
|
||||
type: slugTypeEnum("type").notNull(),
|
||||
});
|
||||
|
||||
export const slugChecks = pgTable("workspace_slug_checks", {
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
slug: varchar("slug", { length: 255 }).notNull(),
|
||||
available: boolean("available").notNull(),
|
||||
reserved: boolean("reserved").notNull(),
|
||||
workspaceId: bigint("workspaceId", { mode: "number" }).references(
|
||||
() => workspaces.id,
|
||||
),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
}).enableRLS();
|
||||
|
||||
@@ -45,14 +45,14 @@ export const sendEmail = async (
|
||||
html,
|
||||
};
|
||||
|
||||
if (cloudMailerClient) {
|
||||
const response = await cloudMailerClient.emails.send({
|
||||
...options,
|
||||
body: html,
|
||||
});
|
||||
// if (cloudMailerClient) {
|
||||
// const response = await cloudMailerClient.emails.send({
|
||||
// ...options,
|
||||
// body: html,
|
||||
// });
|
||||
|
||||
return response;
|
||||
}
|
||||
// return response;
|
||||
// }
|
||||
|
||||
const response = await transporter.sendMail(options);
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./generateUID";
|
||||
export * from "./generateSlug";
|
||||
export * from "./subscriptions";
|
||||
|
||||
57
packages/shared/src/utils/subscriptions.ts
Normal file
57
packages/shared/src/utils/subscriptions.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
export type SubscriptionStatus =
|
||||
| "active"
|
||||
| "trialing"
|
||||
| "past_due"
|
||||
| "canceled"
|
||||
| "unpaid";
|
||||
export type SubscriptionPlan = "team" | "pro";
|
||||
|
||||
export interface Subscription {
|
||||
id: number | null;
|
||||
plan: string;
|
||||
status: string;
|
||||
seats: number | null;
|
||||
unlimitedSeats: boolean;
|
||||
periodStart: Date | null;
|
||||
periodEnd: Date | null;
|
||||
referenceId: string | null;
|
||||
stripeSubscriptionId: string | null;
|
||||
stripeCustomerId: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export const getActiveSubscriptions = (
|
||||
subscriptions: Subscription[] | undefined,
|
||||
) => {
|
||||
if (!subscriptions) return [];
|
||||
return subscriptions.filter(
|
||||
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||
);
|
||||
};
|
||||
|
||||
export const getSubscriptionByPlan = (
|
||||
subscriptions: Subscription[] | undefined,
|
||||
plan: SubscriptionPlan,
|
||||
) => {
|
||||
if (!subscriptions) return undefined;
|
||||
return subscriptions.find(
|
||||
(sub) =>
|
||||
sub.plan === plan &&
|
||||
(sub.status === "active" || sub.status === "trialing"),
|
||||
);
|
||||
};
|
||||
|
||||
export const hasActiveSubscription = (
|
||||
subscriptions: Subscription[] | undefined,
|
||||
plan: SubscriptionPlan,
|
||||
) => {
|
||||
return getSubscriptionByPlan(subscriptions, plan) !== undefined;
|
||||
};
|
||||
|
||||
export const hasUnlimitedSeats = (
|
||||
subscriptions: Subscription[] | undefined,
|
||||
) => {
|
||||
const activeSubscriptions = getActiveSubscriptions(subscriptions);
|
||||
return activeSubscriptions.some((sub) => sub.unlimitedSeats);
|
||||
};
|
||||
@@ -10,11 +10,65 @@ const createStripeClient = () => {
|
||||
}
|
||||
|
||||
const stripe = new Stripe(stripeSecretKey, {
|
||||
apiVersion: "2025-05-28.basil",
|
||||
httpClient: Stripe.createFetchHttpClient(),
|
||||
apiVersion: "2025-08-27.basil",
|
||||
});
|
||||
|
||||
return stripe;
|
||||
};
|
||||
|
||||
export const updateSubscriptionSeats = async (
|
||||
stripeSubscriptionId: string,
|
||||
seatIncrement = 1,
|
||||
): Promise<Stripe.Subscription> => {
|
||||
const stripe = createStripeClient();
|
||||
|
||||
// First, retrieve the current subscription to get the subscription items
|
||||
const subscription = await stripe.subscriptions.retrieve(
|
||||
stripeSubscriptionId,
|
||||
{
|
||||
expand: ["items"],
|
||||
},
|
||||
);
|
||||
|
||||
if (!subscription.items.data.length) {
|
||||
throw new Error(
|
||||
`No subscription items found for subscription ${stripeSubscriptionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Get the first subscription item
|
||||
const subscriptionItem = subscription.items.data[0];
|
||||
if (!subscriptionItem) {
|
||||
throw new Error(
|
||||
`No subscription item found for subscription ${stripeSubscriptionId}`,
|
||||
);
|
||||
}
|
||||
|
||||
const currentQuantity = subscriptionItem.quantity ?? 1;
|
||||
const newQuantity = currentQuantity + seatIncrement;
|
||||
|
||||
// Ensure we don't go below 1 seat
|
||||
if (newQuantity < 1) {
|
||||
throw new Error(
|
||||
`Cannot reduce seats below 1. Current: ${currentQuantity}, Requested change: ${seatIncrement}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Update the subscription with the new quantity and immediate invoicing
|
||||
const updatedSubscription = await stripe.subscriptions.update(
|
||||
stripeSubscriptionId,
|
||||
{
|
||||
items: [
|
||||
{
|
||||
id: subscriptionItem.id,
|
||||
quantity: newQuantity,
|
||||
},
|
||||
],
|
||||
proration_behavior: "create_prorations",
|
||||
},
|
||||
);
|
||||
|
||||
return updatedSubscription;
|
||||
};
|
||||
|
||||
export { createStripeClient };
|
||||
|
||||
6783
pnpm-lock.yaml
generated
6783
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -99,7 +99,11 @@
|
||||
"NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY",
|
||||
"STRIPE_SECRET_KEY",
|
||||
"STRIPE_WEBHOOK_SECRET",
|
||||
"STRIPE_PRO_PLAN_PRICE_ID",
|
||||
"STRIPE_WEBHOOK_SECRET_LEGACY",
|
||||
"STRIPE_PRO_PLAN_MONTHLY_PRICE_ID",
|
||||
"STRIPE_PRO_PLAN_YEARLY_PRICE_ID",
|
||||
"STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID",
|
||||
"STRIPE_TEAM_PLAN_YEARLY_PRICE_ID",
|
||||
"NEXT_PUBLIC_STORAGE_DOMAIN",
|
||||
"NEXT_PUBLIC_STORAGE_URL",
|
||||
"NEXT_PUBLIC_AVATAR_BUCKET_NAME",
|
||||
|
||||
Reference in New Issue
Block a user