feat: prompt user to create subscription if inactive

This commit is contained in:
Henry
2025-09-02 13:12:43 +01:00
parent a7c68db90e
commit 5f4678ab4e
9 changed files with 162 additions and 38 deletions

View File

@@ -1,11 +1,13 @@
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { t } from "@lingui/core/macro"; import { t } from "@lingui/core/macro";
import { env } from "next-runtime-env";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2"; import { HiXMark } from "react-icons/hi2";
import { z } from "zod"; import { z } from "zod";
import type { InviteMemberInput } from "@kan/api/types"; import type { InviteMemberInput } from "@kan/api/types";
import { authClient } from "@kan/auth/client";
import Button from "~/components/Button"; import Button from "~/components/Button";
import Input from "~/components/Input"; import Input from "~/components/Input";
@@ -15,7 +17,20 @@ import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
export function InviteMemberForm() { export function InviteMemberForm({
activeTeamSubscription,
}: {
activeTeamSubscription:
| {
id: string;
plan: string;
status: string;
seats: number;
periodStart: Date;
periodEnd: Date;
}
| undefined;
}) {
const utils = api.useUtils(); const utils = api.useUtils();
const [isCreateAnotherEnabled, setIsCreateAnotherEnabled] = useState(false); const [isCreateAnotherEnabled, setIsCreateAnotherEnabled] = useState(false);
const { closeModal } = useModal(); const { closeModal } = useModal();
@@ -68,24 +83,53 @@ export function InviteMemberForm() {
}, },
}); });
const onSubmit = async (_data: InviteMemberInput) => { let isYearly = false;
const { data, error } = await authClient.subscription.upgrade({ let price = t`$10/month`;
plan: "team", let billingType = t`monthly billing`;
// subscriptionId: "sub_123",
metadata: { userId: "123" },
seats: 1,
successUrl: "/members",
cancelUrl: "/members",
returnUrl: "/members",
disableRedirect: true,
});
if (data?.url) { if (
window.location.href = data.url; activeTeamSubscription?.periodStart &&
activeTeamSubscription?.periodEnd
) {
const periodStartDate = new Date(activeTeamSubscription.periodStart);
const periodEndDate = new Date(activeTeamSubscription.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 = async (member: InviteMemberInput) => {
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud" && !activeTeamSubscription?.id) {
const { data, error } = await authClient.subscription.upgrade({
plan: "team",
referenceId: workspace.publicId,
metadata: { userId: "123" }, // @todo: add the user id
seats: 1,
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",
});
}
} else {
inviteMember.mutate(member);
} }
// console.log(data, error);
// inviteMember.mutate(data);
}; };
useEffect(() => { useEffect(() => {
@@ -122,6 +166,30 @@ export function InviteMemberForm() {
}} }}
errorMessage={errors.email?.message} 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">
{activeTeamSubscription?.id ? (
<div>
<span className="font-medium text-emerald-500 dark:text-emerald-400">
{t`Team Plan`}
</span>
<p className="mt-1">
{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>
<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="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">

View File

@@ -11,6 +11,7 @@ import FeedbackModal from "~/components/FeedbackModal";
import Modal from "~/components/modal"; import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead"; import { PageHead } from "~/components/PageHead";
import { env } from "~/env";
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace"; import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
@@ -27,6 +28,14 @@ export default function MembersPage() {
// { enabled: workspace?.publicId ? true : false }, // { enabled: workspace?.publicId ? true : false },
); );
const subscription = data?.subscriptions;
const activeTeamSubscription = subscription?.find(
(sub: any) =>
sub.status === "active" ||
(sub.status === "trialing" && sub.plan === "team"),
);
const TableRow = ({ const TableRow = ({
memberPublicId, memberPublicId,
memberId, memberId,
@@ -160,7 +169,21 @@ export default function MembersPage() {
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]"> <h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
{t`Members`} {t`Members`}
</h1> </h1>
<div className="flex"> <div className="flex items-center gap-3">
{env.NEXT_PUBLIC_KAN_ENV === "cloud" && (
<div
className={twMerge(
"flex items-center rounded-full border px-3 py-1 text-center text-xs",
activeTeamSubscription
? "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">
{activeTeamSubscription ? t`Team Plan` : t`Free Plan`}
</span>
</div>
)}
<Button <Button
onClick={() => openModal("INVITE_MEMBER")} onClick={() => openModal("INVITE_MEMBER")}
iconLeft={<HiOutlinePlusSmall className="h-4 w-4" />} iconLeft={<HiOutlinePlusSmall className="h-4 w-4" />}
@@ -241,7 +264,9 @@ export default function MembersPage() {
modalSize="sm" modalSize="sm"
isVisible={isOpen && modalContentType === "INVITE_MEMBER"} isVisible={isOpen && modalContentType === "INVITE_MEMBER"}
> >
<InviteMemberForm /> <InviteMemberForm
activeTeamSubscription={activeTeamSubscription as any}
/>
</Modal> </Modal>
<Modal <Modal

View File

@@ -11,6 +11,7 @@ import { env } from "next-runtime-env";
import type { dbClient } from "@kan/db/client"; import type { dbClient } from "@kan/db/client";
import * as memberRepo from "@kan/db/repository/member.repo"; import * as memberRepo from "@kan/db/repository/member.repo";
import * as userRepo from "@kan/db/repository/user.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 * as schema from "@kan/db/schema";
import { cloudMailerClient, sendEmail } from "@kan/email"; import { cloudMailerClient, sendEmail } from "@kan/email";
import { createStripeClient } from "@kan/stripe"; import { createStripeClient } from "@kan/stripe";
@@ -176,6 +177,25 @@ export const initAuth = (db: dbClient) => {
process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID!, 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;
},
}, },
}), }),
] ]

View File

@@ -1,7 +1,7 @@
CREATE TABLE IF NOT EXISTS "subscription" ( CREATE TABLE IF NOT EXISTS "subscription" (
"id" bigserial PRIMARY KEY NOT NULL, "id" bigserial PRIMARY KEY NOT NULL,
"plan" varchar(255) NOT NULL, "plan" varchar(255) NOT NULL,
"referenceId" uuid, "referenceId" varchar(12) NOT NULL,
"stripeCustomerId" varchar(255), "stripeCustomerId" varchar(255),
"stripeSubscriptionId" varchar(255), "stripeSubscriptionId" varchar(255),
"status" varchar(255) NOT NULL, "status" varchar(255) NOT NULL,
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS "subscription" (
--> statement-breakpoint --> statement-breakpoint
ALTER TABLE "subscription" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint ALTER TABLE "subscription" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
DO $$ BEGIN DO $$ BEGIN
ALTER TABLE "subscription" ADD CONSTRAINT "subscription_referenceId_user_id_fk" FOREIGN KEY ("referenceId") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action; 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 EXCEPTION
WHEN duplicate_object THEN null; WHEN duplicate_object THEN null;
END $$; END $$;

View File

@@ -1,5 +1,5 @@
{ {
"id": "35e9d75a-9dad-4f25-8471-a94132fa12e1", "id": "d16ab16a-b4af-4d34-abb9-e663f4e63307",
"prevId": "5d713202-07aa-46e3-abc2-83d0eb6d0858", "prevId": "5d713202-07aa-46e3-abc2-83d0eb6d0858",
"version": "7", "version": "7",
"dialect": "postgresql", "dialect": "postgresql",
@@ -2339,9 +2339,9 @@
}, },
"referenceId": { "referenceId": {
"name": "referenceId", "name": "referenceId",
"type": "uuid", "type": "varchar(12)",
"primaryKey": false, "primaryKey": false,
"notNull": false "notNull": true
}, },
"stripeCustomerId": { "stripeCustomerId": {
"name": "stripeCustomerId", "name": "stripeCustomerId",
@@ -2414,17 +2414,17 @@
}, },
"indexes": {}, "indexes": {},
"foreignKeys": { "foreignKeys": {
"subscription_referenceId_user_id_fk": { "subscription_referenceId_workspace_publicId_fk": {
"name": "subscription_referenceId_user_id_fk", "name": "subscription_referenceId_workspace_publicId_fk",
"tableFrom": "subscription", "tableFrom": "subscription",
"tableTo": "user", "tableTo": "workspace",
"columnsFrom": [ "columnsFrom": [
"referenceId" "referenceId"
], ],
"columnsTo": [ "columnsTo": [
"id" "publicId"
], ],
"onDelete": "set null", "onDelete": "no action",
"onUpdate": "no action" "onUpdate": "no action"
} }
}, },

View File

@@ -68,8 +68,8 @@
{ {
"idx": 9, "idx": 9,
"version": "7", "version": "7",
"when": 1756757589852, "when": 1756803246096,
"tag": "20250901201309_concerned_doctor_octopus", "tag": "20250902085406_AddSubscriptions",
"breakpoints": true "breakpoints": true
} }
] ]

View File

@@ -121,6 +121,16 @@ export const getByPublicIdWithMembers = (
}, },
}, },
}, },
subscriptions: {
columns: {
id: true,
plan: true,
status: true,
seats: true,
periodStart: true,
periodEnd: true,
},
},
}, },
where: and( where: and(
eq(workspaces.publicId, workspacePublicId), eq(workspaces.publicId, workspacePublicId),

View File

@@ -5,18 +5,17 @@ import {
integer, integer,
pgTable, pgTable,
timestamp, timestamp,
uuid,
varchar, varchar,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { users } from "./users"; import { workspaces } from "./workspaces";
export const subscription = pgTable("subscription", { export const subscription = pgTable("subscription", {
id: bigserial("id", { mode: "number" }).primaryKey(), id: bigserial("id", { mode: "number" }).primaryKey(),
plan: varchar("plan", { length: 255 }).notNull(), plan: varchar("plan", { length: 255 }).notNull(),
referenceId: uuid("referenceId").references(() => users.id, { referenceId: varchar("referenceId", { length: 12 })
onDelete: "set null", .notNull()
}), .references(() => workspaces.publicId),
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }), stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }), stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
status: varchar("status", { length: 255 }).notNull(), status: varchar("status", { length: 255 }).notNull(),
@@ -31,8 +30,8 @@ export const subscription = pgTable("subscription", {
}).enableRLS(); }).enableRLS();
export const subscriptionsRelations = relations(subscription, ({ one }) => ({ export const subscriptionsRelations = relations(subscription, ({ one }) => ({
user: one(users, { workspace: one(workspaces, {
fields: [subscription.referenceId], fields: [subscription.referenceId],
references: [users.id], references: [workspaces.publicId],
}), }),
})); }));

View File

@@ -11,6 +11,7 @@ import {
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { boards } from "./boards"; import { boards } from "./boards";
import { subscription } from "./subscriptions";
import { users } from "./users"; import { users } from "./users";
export const memberRoles = ["admin", "member", "guest"] as const; export const memberRoles = ["admin", "member", "guest"] as const;
@@ -60,6 +61,7 @@ export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
}), }),
members: many(workspaceMembers), members: many(workspaceMembers),
boards: many(boards), boards: many(boards),
subscriptions: many(subscription),
})); }));
export const workspaceMembers = pgTable("workspace_members", { export const workspaceMembers = pgTable("workspace_members", {