feat: create workspace on successful activation
This commit is contained in:
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user