feat: create workspace on successful activation

This commit is contained in:
Henry
2026-04-10 15:28:27 +01:00
parent 6dfee580f7
commit cb76926041
6 changed files with 96 additions and 38 deletions

View File

@@ -7,15 +7,17 @@ const Toggle = ({
label, label,
disabled, disabled,
showLabel = true, showLabel = true,
labelPosition = "before",
}: { }: {
isChecked: boolean; isChecked: boolean;
onChange: () => void; onChange: () => void;
label: string; label: string;
disabled?: boolean; disabled?: boolean;
showLabel?: boolean; showLabel?: boolean;
labelPosition?: "before" | "after";
}) => ( }) => (
<div className="mr-4 flex items-center justify-end"> <div className="flex items-center">
{showLabel && ( {showLabel && labelPosition === "before" && (
<span className="mr-2 text-xs text-light-900 dark:text-dark-900"> <span className="mr-2 text-xs text-light-900 dark:text-dark-900">
{label} {label}
</span> </span>
@@ -38,6 +40,11 @@ const Toggle = ({
)} )}
/> />
</Switch> </Switch>
{showLabel && labelPosition === "after" && (
<span className="ml-2 text-xs text-light-900 dark:text-dark-900">
{label}
</span>
)}
</div> </div>
); );

View File

@@ -113,18 +113,23 @@ export default withRateLimit(
resolvedWorkspacePublicId = generateUID(); resolvedWorkspacePublicId = generateUID();
} }
const priceId = const isTeam = body.plan === "team";
billing === "annual" const annualPriceId = isTeam
? (process.env.STRIPE_PRO_PLAN_ANNUAL_PRICE_ID ?? ? (process.env.STRIPE_TEAM_PLAN_ANNUAL_PRICE_ID ??
process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID) process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID)
: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID; : (process.env.STRIPE_PRO_PLAN_ANNUAL_PRICE_ID ??
process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID);
const monthlyPriceId = isTeam
? process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID
: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID;
const priceId = billing === "annual" ? annualPriceId : monthlyPriceId;
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({
mode: "subscription", mode: "subscription",
payment_method_collection: "always", payment_method_collection: "always",
line_items: [{ price: priceId, quantity: 1 }], line_items: [{ price: priceId, quantity: 1 }],
subscription_data: { trial_period_days: 14 }, subscription_data: { trial_period_days: 14 },
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`, success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}?workspacePublicId=${resolvedWorkspacePublicId}`,
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`, cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
client_reference_id: resolvedWorkspacePublicId, client_reference_id: resolvedWorkspacePublicId,
customer: user.stripeCustomerId ?? undefined, customer: user.stripeCustomerId ?? undefined,

View File

@@ -3,6 +3,7 @@ import type { NextApiRequest, NextApiResponse } from "next";
import type { Readable } from "node:stream"; import type { Readable } from "node:stream";
import { createNextApiContext } from "@kan/api/trpc"; 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 * as workspaceRepo from "@kan/db/repository/workspace.repo";
import { createLogger } from "@kan/logger"; import { createLogger } from "@kan/logger";
import { createStripeClient } from "@kan/stripe"; import { createStripeClient } from "@kan/stripe";
@@ -55,34 +56,47 @@ export default async function handler(
if (!meta?.workspacePublicId) break; if (!meta?.workspacePublicId) break;
const plan = meta.plan === "team" ? "team" : ("pro" as const);
if ( if (
meta.isNewWorkspace === "true" && meta.isNewWorkspace === "true" &&
meta.workspaceName && meta.workspaceName &&
meta.userId && meta.userId &&
meta.userEmail meta.userEmail
) { ) {
const slug = meta.workspaceSlug ?? meta.workspacePublicId; const existing = await workspaceRepo.getByPublicId(db, meta.workspacePublicId);
await workspaceRepo.create(db, { if (!existing) {
publicId: meta.workspacePublicId, const slug = meta.workspaceSlug ?? meta.workspacePublicId;
name: meta.workspaceName,
slug, await workspaceRepo.create(db, {
createdBy: meta.userId, publicId: meta.workspacePublicId,
createdByEmail: meta.userEmail, name: meta.workspaceName,
...(meta.workspaceDescription && { slug,
description: meta.workspaceDescription, plan,
}), createdBy: meta.userId,
createdByEmail: meta.userEmail,
...(meta.workspaceDescription && {
description: meta.workspaceDescription,
}),
});
await subscriptionRepo.create(db, {
plan,
referenceId: meta.workspacePublicId,
userId: meta.userId,
stripeCustomerId: checkoutSession.customer as string,
status: "active",
});
}
} else {
// Existing workspace upgrade — update plan (and slug for pro)
await workspaceRepo.update(db, meta.workspacePublicId, {
plan,
...(plan === "pro" && meta.workspaceSlug && { slug: meta.workspaceSlug }),
}); });
} }
const plan = meta.plan === "team" ? "team" : "pro";
await workspaceRepo.update(db, meta.workspacePublicId, {
plan,
...(plan === "pro" &&
meta.workspaceSlug &&
meta.isNewWorkspace !== "true" && { slug: meta.workspaceSlug }),
});
break; break;
} }
default: default:

View File

@@ -17,7 +17,7 @@ interface Workspace {
description: string | null | undefined; description: string | null | undefined;
publicId: string; publicId: string;
slug: string | undefined; slug: string | undefined;
plan: "free" | "pro" | "enterprise" | undefined; plan: "free" | "team" | "pro" | "enterprise" | undefined;
role: "admin" | "member" | "guest"; role: "admin" | "member" | "guest";
weekStartDay: 0 | 1 | 6; weekStartDay: 0 | 1 | 6;
} }
@@ -27,7 +27,7 @@ const initialWorkspace: Workspace = {
description: null, description: null,
publicId: "", publicId: "",
slug: "", slug: "",
plan: "free", plan: "free" as const,
role: "member", role: "member",
weekStartDay: 1, weekStartDay: 1,
}; };
@@ -50,7 +50,15 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
const workspacePublicId = useSearchParams().get("workspacePublicId"); const workspacePublicId = useSearchParams().get("workspacePublicId");
const { data, isLoading } = api.workspace.all.useQuery(); const [pendingWorkspaceId, setPendingWorkspaceId] = useState<string | null>(
workspacePublicId,
);
const pollAttemptsRef = React.useRef(0);
const MAX_POLL_ATTEMPTS = 5;
const { data, isLoading } = api.workspace.all.useQuery(undefined, {
refetchInterval: pendingWorkspaceId ? 2000 : false,
});
const utils = api.useUtils(); const utils = api.useUtils();
const switchWorkspace = (_workspace: Workspace) => { const switchWorkspace = (_workspace: Workspace) => {
@@ -94,7 +102,16 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
({ workspace }) => workspace.publicId === storedWorkspaceId, ({ workspace }) => workspace.publicId === storedWorkspaceId,
); );
if (!selectedWorkspace?.workspace) return; if (!selectedWorkspace?.workspace) {
pollAttemptsRef.current += 1;
if (pollAttemptsRef.current >= MAX_POLL_ATTEMPTS) {
setPendingWorkspaceId(null);
}
return;
}
pollAttemptsRef.current = 0;
setPendingWorkspaceId(null);
setWorkspace({ setWorkspace({
publicId: selectedWorkspace.workspace.publicId, publicId: selectedWorkspace.workspace.publicId,

View File

@@ -225,6 +225,17 @@ export default function WorkspaceNameView() {
/> />
</div> </div>
{plan !== "pro" && (
<div className="pb-2">
<Toggle
isChecked={isProToggle}
onChange={() => setIsProToggle((v) => !v)}
label={t`Upgrade to Pro ($29/month)`}
labelPosition="after"
/>
</div>
)}
<div> <div>
<textarea <textarea
value={description} value={description}
@@ -242,15 +253,15 @@ export default function WorkspaceNameView() {
</div> </div>
<div className="mt-6 flex flex-wrap items-center justify-between gap-3"> <div className="mt-6 flex flex-wrap items-center justify-between gap-3">
{plan !== "pro" && (
<Toggle
isChecked={isProToggle}
onChange={() => setIsProToggle((v) => !v)}
label={t`Upgrade to Pro ($29/month)`}
/>
)}
<div className="ml-auto flex gap-2"> <div className="ml-auto flex gap-2">
<Button variant="ghost" onClick={() => router.back()}> <Button
variant="ghost"
onClick={() =>
router.push(
`/onboarding/select-plan?plan=${plan}&billing=${billing}`,
)
}
>
{t`Back`} {t`Back`}
</Button> </Button>
<Button <Button

View File

@@ -64,6 +64,8 @@ export const create = async (
slug: string; slug: string;
createdBy: string; createdBy: string;
createdByEmail: string; createdByEmail: string;
description?: string;
plan?: "free" | "team" | "pro" | "enterprise";
}, },
) => { ) => {
const [workspace] = await db const [workspace] = await db
@@ -73,6 +75,8 @@ export const create = async (
name: workspaceInput.name, name: workspaceInput.name,
slug: workspaceInput.slug, slug: workspaceInput.slug,
createdBy: workspaceInput.createdBy, createdBy: workspaceInput.createdBy,
...(workspaceInput.description && { description: workspaceInput.description }),
...(workspaceInput.plan && { plan: workspaceInput.plan }),
}) })
.returning({ .returning({
id: workspaces.id, id: workspaces.id,