From 76d913ed96c941f5b2e78ba9520d6c67974af263 Mon Sep 17 00:00:00 2001 From: Henry Date: Wed, 10 Sep 2025 21:54:39 +0100 Subject: [PATCH] feat: set workspace url on creation --- apps/web/src/components/NewWorkspaceForm.tsx | 267 ++++++++++++++++++- packages/api/src/routers/workspace.ts | 39 ++- 2 files changed, 293 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/NewWorkspaceForm.tsx b/apps/web/src/components/NewWorkspaceForm.tsx index 23ce462a..dd2545c7 100644 --- a/apps/web/src/components/NewWorkspaceForm.tsx +++ b/apps/web/src/components/NewWorkspaceForm.tsx @@ -1,30 +1,98 @@ +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 { 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; export function NewWorkspaceForm() { const { closeModal } = useModal(); const { showPopup } = usePopup(); const { switchWorkspace } = useWorkspace(); - const { register, handleSubmit } = useForm(); + const { + register, + handleSubmit, + formState: { errors }, + watch, + trigger, + clearErrors, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + name: "", + slug: "", + }, + mode: "onSubmit", + }); const utils = api.useUtils(); + const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud"; + + const slug = watch("slug"); + const [debouncedSlug] = useDebounce(slug, 500); + const isTyping = slug !== debouncedSlug; + + // 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 +101,47 @@ export function NewWorkspaceForm() { plan: values.plan, role: "admin", }); + + // If in cloud and user provided a valid slug, create checkout session for pro + if ( + env("NEXT_PUBLIC_KAN_ENV") === "cloud" && + slug && + isWorkspaceSlugAvailable?.isAvailable + ) { + try { + const response = await fetch( + "/api/stripe/create_checkout_session", + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + slug, + workspacePublicId: values.publicId, + cancelUrl: "/boards", + 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 +160,46 @@ export function NewWorkspaceForm() { if (nameElement) nameElement.focus(); }, []); + const [shouldShowBenefits, setShouldShowBenefits] = useState(false); + + const isValidSlug = slug && slug.length >= 3 && !errors.slug; + + useEffect(() => { + if (!checkWorkspaceSlugAvailability.isPending && isValidSlug) { + setShouldShowBenefits(isWorkspaceSlugAvailable?.isAvailable === true); + } + }, [ + isValidSlug, + isWorkspaceSlugAvailable?.isAvailable, + checkWorkspaceSlugAvailability.isPending, + ]); + + // Reset benefits when slug becomes invalid + useEffect(() => { + if (!isValidSlug) { + setShouldShowBenefits(false); + } + }, [isValidSlug]); + + const showProBenefits = shouldShowBenefits; + 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 (
@@ -80,6 +223,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,11 +231,110 @@ export function NewWorkspaceForm() { } }} /> + +
+ = 3 && !errors.slug ? ( + isWorkspaceSlugAvailable?.isAvailable ? ( + + ) : checkWorkspaceSlugAvailability.isPending || isTyping ? ( + + ) : null + ) : null + } + onKeyDown={async (e) => { + if (e.key === "Enter") { + e.preventDefault(); + await handleSubmit(onSubmit)(); + } + }} + /> + + {showProBenefits && ( +
+ +

+ {t`Custom URLs require Pro plan ($29/month)`} +

+
+ )} +
+ + {showProBenefits && ( +
+
+
+
+ +
+ + {t`Unlimited members`} + + + {t`Launch offer`} + +
+
+
+ + + {t`Custom workspace URL`} + +
+
+ + + {t`Board analytics (coming soon)`} + +
+
+
+
+ )}
-
-
-
diff --git a/packages/api/src/routers/workspace.ts b/packages/api/src/routers/workspace.ts index e8dda1ee..f0e7d23b 100644 --- a/packages/api/src/routers/workspace.ts +++ b/packages/api/src/routers/workspace.ts @@ -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>>()) @@ -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, });