feat: prompt user to create subscription if inactive
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
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 { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
@@ -15,7 +17,20 @@ import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
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 [isCreateAnotherEnabled, setIsCreateAnotherEnabled] = useState(false);
|
||||
const { closeModal } = useModal();
|
||||
@@ -68,24 +83,53 @@ export function InviteMemberForm() {
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (_data: InviteMemberInput) => {
|
||||
const { data, error } = await authClient.subscription.upgrade({
|
||||
plan: "team",
|
||||
// subscriptionId: "sub_123",
|
||||
metadata: { userId: "123" },
|
||||
seats: 1,
|
||||
successUrl: "/members",
|
||||
cancelUrl: "/members",
|
||||
returnUrl: "/members",
|
||||
disableRedirect: true,
|
||||
});
|
||||
let isYearly = false;
|
||||
let price = t`$10/month`;
|
||||
let billingType = t`monthly billing`;
|
||||
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
if (
|
||||
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(() => {
|
||||
@@ -122,6 +166,30 @@ 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">
|
||||
{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 className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
|
||||
@@ -11,6 +11,7 @@ import FeedbackModal from "~/components/FeedbackModal";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { env } from "~/env";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -27,6 +28,14 @@ export default function MembersPage() {
|
||||
// { 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 = ({
|
||||
memberPublicId,
|
||||
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]">
|
||||
{t`Members`}
|
||||
</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
|
||||
onClick={() => openModal("INVITE_MEMBER")}
|
||||
iconLeft={<HiOutlinePlusSmall className="h-4 w-4" />}
|
||||
@@ -241,7 +264,9 @@ export default function MembersPage() {
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "INVITE_MEMBER"}
|
||||
>
|
||||
<InviteMemberForm />
|
||||
<InviteMemberForm
|
||||
activeTeamSubscription={activeTeamSubscription as any}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
|
||||
@@ -11,6 +11,7 @@ import { env } from "next-runtime-env";
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.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";
|
||||
@@ -176,6 +177,25 @@ export const initAuth = (db: dbClient) => {
|
||||
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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
CREATE TABLE IF NOT EXISTS "subscription" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"plan" varchar(255) NOT NULL,
|
||||
"referenceId" uuid,
|
||||
"referenceId" varchar(12) NOT NULL,
|
||||
"stripeCustomerId" varchar(255),
|
||||
"stripeSubscriptionId" varchar(255),
|
||||
"status" varchar(255) NOT NULL,
|
||||
@@ -17,7 +17,7 @@ CREATE TABLE IF NOT EXISTS "subscription" (
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "subscription" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
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
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"id": "35e9d75a-9dad-4f25-8471-a94132fa12e1",
|
||||
"id": "d16ab16a-b4af-4d34-abb9-e663f4e63307",
|
||||
"prevId": "5d713202-07aa-46e3-abc2-83d0eb6d0858",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
@@ -2339,9 +2339,9 @@
|
||||
},
|
||||
"referenceId": {
|
||||
"name": "referenceId",
|
||||
"type": "uuid",
|
||||
"type": "varchar(12)",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
"notNull": true
|
||||
},
|
||||
"stripeCustomerId": {
|
||||
"name": "stripeCustomerId",
|
||||
@@ -2414,17 +2414,17 @@
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"subscription_referenceId_user_id_fk": {
|
||||
"name": "subscription_referenceId_user_id_fk",
|
||||
"subscription_referenceId_workspace_publicId_fk": {
|
||||
"name": "subscription_referenceId_workspace_publicId_fk",
|
||||
"tableFrom": "subscription",
|
||||
"tableTo": "user",
|
||||
"tableTo": "workspace",
|
||||
"columnsFrom": [
|
||||
"referenceId"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
"publicId"
|
||||
],
|
||||
"onDelete": "set null",
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
@@ -68,8 +68,8 @@
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1756757589852,
|
||||
"tag": "20250901201309_concerned_doctor_octopus",
|
||||
"when": 1756803246096,
|
||||
"tag": "20250902085406_AddSubscriptions",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -121,6 +121,16 @@ export const getByPublicIdWithMembers = (
|
||||
},
|
||||
},
|
||||
},
|
||||
subscriptions: {
|
||||
columns: {
|
||||
id: true,
|
||||
plan: true,
|
||||
status: true,
|
||||
seats: true,
|
||||
periodStart: true,
|
||||
periodEnd: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: and(
|
||||
eq(workspaces.publicId, workspacePublicId),
|
||||
|
||||
@@ -5,18 +5,17 @@ import {
|
||||
integer,
|
||||
pgTable,
|
||||
timestamp,
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { users } from "./users";
|
||||
import { workspaces } from "./workspaces";
|
||||
|
||||
export const subscription = pgTable("subscription", {
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
plan: varchar("plan", { length: 255 }).notNull(),
|
||||
referenceId: uuid("referenceId").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
referenceId: varchar("referenceId", { length: 12 })
|
||||
.notNull()
|
||||
.references(() => workspaces.publicId),
|
||||
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
|
||||
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
|
||||
status: varchar("status", { length: 255 }).notNull(),
|
||||
@@ -31,8 +30,8 @@ export const subscription = pgTable("subscription", {
|
||||
}).enableRLS();
|
||||
|
||||
export const subscriptionsRelations = relations(subscription, ({ one }) => ({
|
||||
user: one(users, {
|
||||
workspace: one(workspaces, {
|
||||
fields: [subscription.referenceId],
|
||||
references: [users.id],
|
||||
references: [workspaces.publicId],
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -11,6 +11,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 +61,7 @@ export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
|
||||
}),
|
||||
members: many(workspaceMembers),
|
||||
boards: many(boards),
|
||||
subscriptions: many(subscription),
|
||||
}));
|
||||
|
||||
export const workspaceMembers = pgTable("workspace_members", {
|
||||
|
||||
Reference in New Issue
Block a user