Compare commits

..

22 Commits

Author SHA1 Message Date
Henry
f3bd3684b6 fix: show correct active workspace and truncate name 2025-09-11 23:02:19 +01:00
Henry
c2ccb6b13b fix: prevent card side menu selectors from closing unexpectedly (#174) 2025-09-11 22:48:53 +01:00
Henry
4ee1d6f2d5 fix: prevent new workspace modal from getting stuck in an open state (#173) 2025-09-11 22:29:48 +01:00
Henry
2f88366418 feat: set workspace url when creating a new workspace (#172)
* feat: add migration to remove not null constraint from referenceId on subscriptions

* feat(cloud): write worspace slug availability checks to db

* feat: add migration to add cascade set null to referenceId on subscription

* feat: update workspace and subscripton schema

* feat: add createWorkspaceSlugCheck repo func

* feat: set workspace url on creation

* chore: update translations

* refactor: various tweaks and fixes

* feat: add pro bolt to pricing notice

* feat: toggle pro subscription

* fix: hide pro subscription notice for self hosters

* feat: add pro upgrade message on members page

* feat: hide pro upgrade notice on small viewports

* feat: update workspace url placeholder
2025-09-11 20:32:02 +01:00
Henry
4f7fa1a228 fix: compile translations 2025-09-09 13:42:43 +01:00
Henry
308cb22729 feat(cloud): pro launch features (#170)
* fix: ensure workspace slug is updated on activation

* feat: open upgrade modal if upgrade=pro is in URL params

* fix: show correct skeleton loader for workspace menu when collapsed

* fix: reenable scroll when navigating back to marketing page

* feat: add launch offer notice
2025-09-09 13:07:39 +01:00
Henry
6c04a10b1f feat(cloud): show pro features (#169)
* feat(cloud): show pro workspace features

* feat: allow multiple subscriptions

* chore: translations
2025-09-08 23:22:12 +01:00
Henry
99459415d5 chore: translations 2025-09-07 23:09:42 +01:00
Henry
5a4835091f feat(cloud): unlimited seats (#166)
* feat: add unlimitedSeats to subscription

* feat: set unlimited seats to true for pro plans

* feat: add unlimited invites messaging

* feat: upgrade to pro via auth client

* feat: add subscription repo funcs

* chore: move subscription utils to shared

* feat: skip updating subscription if unlimited seats
2025-09-07 22:50:20 +01:00
Henry
061a54dadd fix: extract memberPublicId from callbackURL after accepting invitation (#165) 2025-09-06 22:51:39 +01:00
Henry
a532fa1ce2 feat(cloud): enable promo codes for checkout 2025-09-04 22:52:31 +01:00
Henry
2a355238a4 feat(cloud): prevent cloud mailer from sending emails 2025-09-04 22:30:21 +01:00
Henry
874353e8b8 feat(cloud): update proration behavior to invoice at next subscription cycle 2025-09-04 22:03:19 +01:00
Henry
18e60333ae fix(cloud): use next-runtime-env on members page 2025-09-03 21:08:08 +01:00
Henry
d729785802 chore(cloud): use next-runtime-env for baseURL 2025-09-02 23:22:59 +01:00
Henry
abbbb4b928 chore(cloud): remove stripe customer creation from auth hook 2025-09-02 23:02:01 +01:00
Henry
3cea9377e6 chore(cloud): update env vars 2025-09-02 22:49:36 +01:00
Henry
06c268bed1 feat(cloud): enable seat based pricing (#161)
* feat: setup subscriptions

* feat: prompt user to create subscription if inactive

* feat: update subscription when increasing/decreasing workspace members

* feat: rework upgrade

* chore: remove pricing notice

* chore: add translations

* chore: update proration comment
2025-09-02 22:36:09 +01:00
Henry
e3d7ace8bd chore: update beta end date 2025-09-02 09:10:27 +01:00
Henry
fecc6cf742 fix: correctly disable openapi for create feedback route 2025-08-31 16:44:59 +01:00
Henry
f7c2e7979e chore: bump docs 2025-08-31 16:05:39 +01:00
Henry
cd70526b93 fix: show checklist progress when no members or labels are assigned (#160) 2025-08-31 16:02:37 +01:00
66 changed files with 19529 additions and 4748 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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",

View File

@@ -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>

View File

@@ -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

View File

@@ -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,
},
});

View File

@@ -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",
});
}

View File

@@ -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

View File

@@ -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

View File

@@ -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 (
<>

View File

@@ -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>
)}

View File

@@ -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>
)}
</>

View File

@@ -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>
)}
</>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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,

View File

@@ -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>
);
}

View File

@@ -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

View File

@@ -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}

View File

@@ -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"
}
}
}

View File

@@ -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),

View File

@@ -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 };
}),
});

View File

@@ -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",

View File

@@ -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 },
}),
},
};
};

View File

@@ -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"
}
}

View File

@@ -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) {

View File

@@ -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(),

View 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 $$;

View File

@@ -0,0 +1 @@
ALTER TABLE "subscription" ADD COLUMN "unlimitedSeats" boolean DEFAULT false NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE "subscription" ALTER COLUMN "referenceId" DROP NOT NULL;

View File

@@ -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 $$;

View File

@@ -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 $$;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -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
}
]
}

View File

@@ -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" });

View 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;
};

View File

@@ -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),

View File

@@ -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,
});
};

View File

@@ -10,3 +10,4 @@ export * from "./lists";
export * from "./users";
export * from "./integrations";
export * from "./workspaces";
export * from "./subscriptions";

View 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],
}),
}));

View File

@@ -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();

View File

@@ -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);

View File

@@ -1,2 +1,3 @@
export * from "./generateUID";
export * from "./generateSlug";
export * from "./subscriptions";

View 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);
};

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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",