From f78dd07c38d5191fa6c7bf47b60577a11130e947 Mon Sep 17 00:00:00 2001 From: Henry Date: Thu, 23 Jan 2025 22:22:29 +0000 Subject: [PATCH] feat: custom workspace slugs --- apps/web/src/components/NewWorkspaceForm.tsx | 6 +++--- .../pages/api/stripe/create_checkout_session.ts | 16 ++++++++-------- apps/web/src/pages/api/stripe/webhook.ts | 4 ++-- apps/web/src/providers/workspace.tsx | 6 +++--- apps/web/src/views/board/index.tsx | 2 -- .../src/views/boards/components/BoardsList.tsx | 10 +++++----- apps/web/src/views/boards/index.tsx | 13 ++++++------- apps/web/src/views/card/index.tsx | 4 ++-- apps/web/src/views/members/index.tsx | 2 +- ...onfirmation.tsx => CustomURLConfirmation.tsx} | 8 ++++---- .../components/UpdateWorkspaceUrlForm.tsx | 14 +++++++------- apps/web/src/views/settings/index.tsx | 12 +++++------- packages/api/src/routers/workspace.ts | 12 +++++++----- packages/db/src/repository/workspace.repo.ts | 6 ++++-- 14 files changed, 57 insertions(+), 58 deletions(-) rename apps/web/src/views/settings/components/{PremiumUsernameConfirmation.tsx => CustomURLConfirmation.tsx} (85%) diff --git a/apps/web/src/components/NewWorkspaceForm.tsx b/apps/web/src/components/NewWorkspaceForm.tsx index d40bbe0a..14b7c2c3 100644 --- a/apps/web/src/components/NewWorkspaceForm.tsx +++ b/apps/web/src/components/NewWorkspaceForm.tsx @@ -25,9 +25,9 @@ export function NewWorkspaceForm() { switchWorkspace({ publicId: values.publicId, name: values.name, - // description: values.description, - // slug: values.slug, - // plan: values.plan, + description: values.description, + slug: values.slug, + plan: values.plan, }); closeModal(); } diff --git a/apps/web/src/pages/api/stripe/create_checkout_session.ts b/apps/web/src/pages/api/stripe/create_checkout_session.ts index 942dae36..bd298d7d 100644 --- a/apps/web/src/pages/api/stripe/create_checkout_session.ts +++ b/apps/web/src/pages/api/stripe/create_checkout_session.ts @@ -17,7 +17,7 @@ const stripe = new Stripe(stripeSecretKey, { apiVersion: "2024-12-18.acacia", }); -const usernameSchema = z +const workspaceSlugSchema = z .string() .min(3) .max(24) @@ -26,7 +26,7 @@ const usernameSchema = z interface CheckoutSessionRequest { successUrl: string; cancelUrl: string; - username: string; + slug: string; workspacePublicId: string; stripeCustomerId: string; } @@ -63,9 +63,9 @@ export default async function handler(req: NextRequest) { } const body = (await req.json()) as CheckoutSessionRequest; - const { successUrl, cancelUrl, username, workspacePublicId } = body; + const { successUrl, cancelUrl, slug, workspacePublicId } = body; - if (!successUrl || !cancelUrl || !username || !workspacePublicId) { + if (!successUrl || !cancelUrl || !slug || !workspacePublicId) { return new Response( JSON.stringify({ error: "Missing required fields" }), { @@ -75,10 +75,10 @@ export default async function handler(req: NextRequest) { ); } - const usernameResult = usernameSchema.safeParse(username); + const slugResult = workspaceSlugSchema.safeParse(slug); - if (!usernameResult.success) { - return new Response(JSON.stringify({ error: "Invalid username" }), { + if (!slugResult.success) { + return new Response(JSON.stringify({ error: "Invalid workspace slug" }), { status: 400, headers: { "Content-Type": "application/json" }, }); @@ -109,7 +109,7 @@ export default async function handler(req: NextRequest) { cancel_url: `${process.env.WEBSITE_URL}${cancelUrl}`, customer: user.stripeCustomerId ?? undefined, metadata: { - username, + workspaceSlug: slug, workspacePublicId, }, }); diff --git a/apps/web/src/pages/api/stripe/webhook.ts b/apps/web/src/pages/api/stripe/webhook.ts index f68836e2..41b59f3d 100644 --- a/apps/web/src/pages/api/stripe/webhook.ts +++ b/apps/web/src/pages/api/stripe/webhook.ts @@ -54,9 +54,9 @@ export default async function handler(req: NextRequest) { const metaData = checkoutSession.metadata; - if (metaData?.workspacePublicId && metaData.username) { + if (metaData?.workspacePublicId && metaData.workspaceSlug) { await workspaceRepo.update(db, metaData.workspacePublicId, { - slug: metaData.username, + slug: metaData.workspaceSlug, plan: "pro", }); } diff --git a/apps/web/src/providers/workspace.tsx b/apps/web/src/providers/workspace.tsx index ec84db5a..58a5b46a 100644 --- a/apps/web/src/providers/workspace.tsx +++ b/apps/web/src/providers/workspace.tsx @@ -13,10 +13,10 @@ interface WorkspaceContextProps { interface Workspace { name: string; - description: string | null; + description: string | null | undefined; publicId: string; - slug: string; - plan: "free" | "pro" | "enterprise"; + slug: string | undefined; + plan: "free" | "pro" | "enterprise" | undefined; } const initialWorkspace: Workspace = { diff --git a/apps/web/src/views/board/index.tsx b/apps/web/src/views/board/index.tsx index e1642a65..1b095cf9 100644 --- a/apps/web/src/views/board/index.tsx +++ b/apps/web/src/views/board/index.tsx @@ -21,7 +21,6 @@ import { useModal } from "~/providers/modal"; import { useWorkspace } from "~/providers/workspace"; import { api } from "~/utils/api"; import { formatToArray } from "~/utils/helpers"; -import createClient from "~/utils/supabase/client"; import { getPublicUrl } from "~/utils/supabase/getPublicUrl"; import BoardDropdown from "./components/BoardDropdown"; import { DeleteBoardConfirmation } from "./components/DeleteBoardConfirmation"; @@ -36,7 +35,6 @@ import VisibilityButton from "./components/VisibilityButton"; type PublicListId = string; export default function BoardPage() { - const supabase = createClient(); const params = useParams() as { boardId: string[] } | null; const router = useRouter(); const { boardData, setBoardData, updateCard, updateList } = useBoard(); diff --git a/apps/web/src/views/boards/components/BoardsList.tsx b/apps/web/src/views/boards/components/BoardsList.tsx index 22a94aba..c4f5a682 100644 --- a/apps/web/src/views/boards/components/BoardsList.tsx +++ b/apps/web/src/views/boards/components/BoardsList.tsx @@ -1,15 +1,15 @@ import Link from "next/link"; -import { api } from "~/utils/api"; -import { useWorkspace } from "~/providers/workspace"; import PatternedBackground from "~/components/PatternedBackground"; +import { useWorkspace } from "~/providers/workspace"; +import { api } from "~/utils/api"; export function BoardsList() { const { workspace } = useWorkspace(); const { data, isLoading } = api.board.all.useQuery( - { workspacePublicId: workspace?.publicId }, - { enabled: workspace?.publicId ? true : false }, + { workspacePublicId: workspace.publicId }, + { enabled: workspace.publicId ? true : false }, ); if (isLoading) @@ -29,7 +29,7 @@ export function BoardsList() {
-

+

{board.name}

diff --git a/apps/web/src/views/boards/index.tsx b/apps/web/src/views/boards/index.tsx index d4192bfb..d3764d4c 100644 --- a/apps/web/src/views/boards/index.tsx +++ b/apps/web/src/views/boards/index.tsx @@ -1,14 +1,13 @@ import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2"; -import { BoardsList } from "./components/BoardsList"; +import Modal from "~/components/modal"; +import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; +import { PageHead } from "~/components/PageHead"; import { useModal } from "~/providers/modal"; import { useWorkspace } from "~/providers/workspace"; -import Modal from "~/components/modal"; - -import { PageHead } from "~/components/PageHead"; +import { BoardsList } from "./components/BoardsList"; import { ImportBoardsForm } from "./components/ImportBoardsForm"; import { NewBoardForm } from "./components/NewBoardForm"; -import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; export default function BoardsPage() { const { openModal, modalContentType } = useModal(); @@ -16,10 +15,10 @@ export default function BoardsPage() { return ( <> - +
-

+

Boards

diff --git a/apps/web/src/views/card/index.tsx b/apps/web/src/views/card/index.tsx index 808d0f65..a6e7ac97 100644 --- a/apps/web/src/views/card/index.tsx +++ b/apps/web/src/views/card/index.tsx @@ -128,7 +128,7 @@ export default function CardPage() { ) : ( <> {board?.name} @@ -147,7 +147,7 @@ export default function CardPage() { id="title" {...register("title")} onBlur={handleSubmit(onSubmit)} - className="block w-full border-0 bg-transparent p-0 py-0 font-medium tracking-tight text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]" + className="block w-full border-0 bg-transparent p-0 py-0 font-bold tracking-tight text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]" />
diff --git a/apps/web/src/views/members/index.tsx b/apps/web/src/views/members/index.tsx index 227907f0..476c5a7b 100644 --- a/apps/web/src/views/members/index.tsx +++ b/apps/web/src/views/members/index.tsx @@ -161,7 +161,7 @@ export default function MembersPage() {
-

+

Members

diff --git a/apps/web/src/views/settings/components/PremiumUsernameConfirmation.tsx b/apps/web/src/views/settings/components/CustomURLConfirmation.tsx similarity index 85% rename from apps/web/src/views/settings/components/PremiumUsernameConfirmation.tsx rename to apps/web/src/views/settings/components/CustomURLConfirmation.tsx index 29baa9f2..f8159eec 100644 --- a/apps/web/src/views/settings/components/PremiumUsernameConfirmation.tsx +++ b/apps/web/src/views/settings/components/CustomURLConfirmation.tsx @@ -1,7 +1,7 @@ import Button from "~/components/Button"; import { useModal } from "~/providers/modal"; -export function PremiumUsernameConfirmation({ +export function CustomURLConfirmation({ workspacePublicId, }: { workspacePublicId: string; @@ -16,7 +16,7 @@ export function PremiumUsernameConfirmation({ "Content-Type": "application/json", }, body: JSON.stringify({ - username: entityId, + slug: entityId, workspacePublicId: workspacePublicId, cancelUrl: "/settings", successUrl: "/settings", @@ -37,11 +37,11 @@ export function PremiumUsernameConfirmation({

- {`Confirm username change`} + {`Confirm URL change`}

{ - "As you are changing from a standard to a premium username, you will be taken to the checkout to upgrade." + "Custom URLs are a premium feature. You'll be directed to upgrade your account." }

diff --git a/apps/web/src/views/settings/components/UpdateWorkspaceUrlForm.tsx b/apps/web/src/views/settings/components/UpdateWorkspaceUrlForm.tsx index 2ea989b8..4b1ec3a1 100644 --- a/apps/web/src/views/settings/components/UpdateWorkspaceUrlForm.tsx +++ b/apps/web/src/views/settings/components/UpdateWorkspaceUrlForm.tsx @@ -14,11 +14,11 @@ const schema = z.object({ slug: z .string() .min(3, { - message: "Username must be at least 3 characters long", + message: "URL must be at least 3 characters long", }) - .max(24, { message: "Username cannot exceed 24 characters" }) + .max(24, { message: "URL cannot exceed 24 characters" }) .regex(/^(?![-]+$)[a-zA-Z0-9-]+$/, { - message: "Username can only contain letters, numbers, and hyphens", + message: "URL can only contain letters, numbers, and hyphens", }), }); @@ -62,7 +62,7 @@ const UpdateWorkspaceUrlForm = ({ }, onError: () => { showPopup({ - header: "Error updating workspace username", + header: "Error updating workspace URL", message: "Please try again later, or contact customer support.", icon: "error", }); @@ -87,8 +87,8 @@ const UpdateWorkspaceUrlForm = ({ const isWorkspaceSlugAvailable = checkWorkspaceSlugAvailability.data; const onSubmit = (data: FormValues) => { - if (isWorkspaceSlugAvailable?.isPremium && workspacePlan !== "pro") - return openModal("PREMIUM_USERNAME", data.slug); + if (isWorkspaceSlugAvailable?.isAvailable && workspacePlan !== "pro") + return openModal("UPDATE_WORKSPACE_URL", data.slug); updateWorkspaceSlug.mutate({ workspacePublicId, @@ -115,7 +115,7 @@ const UpdateWorkspaceUrlForm = ({ } prefix="kan.bn/" iconRight={ - isWorkspaceSlugAvailable?.isPremium || + isWorkspaceSlugAvailable?.isAvailable || (workspacePlan === "pro" && slug === workspaceUrl) ? ( ) : isWorkspaceSlugAvailable?.isAvailable ? ( diff --git a/apps/web/src/views/settings/index.tsx b/apps/web/src/views/settings/index.tsx index 80152228..5a5acb07 100644 --- a/apps/web/src/views/settings/index.tsx +++ b/apps/web/src/views/settings/index.tsx @@ -8,8 +8,8 @@ import { useModal } from "~/providers/modal"; import { useWorkspace } from "~/providers/workspace"; import { api } from "~/utils/api"; import Avatar from "./components/Avatar"; +import { CustomURLConfirmation } from "./components/CustomURLConfirmation"; import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation"; -import { PremiumUsernameConfirmation } from "./components/PremiumUsernameConfirmation"; import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm"; import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm"; import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm"; @@ -46,7 +46,7 @@ export default function SettingsPage() {
-

+

Settings

@@ -68,7 +68,7 @@ export default function SettingsPage() { />

- Workspace username + Workspace URL

)} - {modalContentType === "PREMIUM_USERNAME" && ( - + {modalContentType === "UPDATE_WORKSPACE_URL" && ( + )}
diff --git a/packages/api/src/routers/workspace.ts b/packages/api/src/routers/workspace.ts index 3abf14a1..3bf34ee1 100644 --- a/packages/api/src/routers/workspace.ts +++ b/packages/api/src/routers/workspace.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import * as workspaceRepo from "@kan/db/repository/workspace.repo"; import * as workspaceSlugRepo from "@kan/db/repository/workspaceSlug.repo"; +import { generateUID } from "@kan/utils"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; @@ -123,9 +124,12 @@ export const workspaceRouter = createTRPCRouter({ code: "UNAUTHORIZED", }); - const result = await workspaceRepo.create(ctx.db, { + const workspacePublicId = generateUID(); + + const result = await workspaceRepo.create(ctx.adminDb, { + publicId: workspacePublicId, name: input.name, - slug: input.name, + slug: workspacePublicId, createdBy: userId, }); @@ -251,12 +255,11 @@ export const workspaceRouter = createTRPCRouter({ z.object({ isAvailable: z.boolean(), isReserved: z.boolean(), - isPremium: z.boolean(), }), ) .query(async ({ ctx, input }) => { const slug = input.workspaceSlug.toLowerCase(); - // check list of reserved or premium slugs + // check slug is not reserved const workspaceSlug = await workspaceSlugRepo.getWorkspaceSlug( ctx.adminDb, slug, @@ -270,7 +273,6 @@ export const workspaceRouter = createTRPCRouter({ isAvailable: isWorkspaceSlugAvailable && workspaceSlug?.type !== "reserved", isReserved: workspaceSlug?.type === "reserved", - isPremium: workspaceSlug?.type === "premium", }; }), }); diff --git a/packages/db/src/repository/workspace.repo.ts b/packages/db/src/repository/workspace.repo.ts index 010a7d4d..a842fd9a 100644 --- a/packages/db/src/repository/workspace.repo.ts +++ b/packages/db/src/repository/workspace.repo.ts @@ -6,6 +6,7 @@ import { generateUID } from "@kan/utils"; export const create = async ( db: SupabaseClient, workspaceInput: { + publicId?: string; name: string; slug: string; createdBy: string; @@ -14,9 +15,9 @@ export const create = async ( const { data } = await db .from("workspace") .insert({ - publicId: generateUID(), + publicId: workspaceInput.publicId ?? generateUID(), name: workspaceInput.name, - slug: workspaceInput.name.toLowerCase(), + slug: workspaceInput.slug, createdBy: workspaceInput.createdBy, }) .select(`id, publicId, name, slug, description, plan`) @@ -30,6 +31,7 @@ export const create = async ( workspaceId: data.id, createdBy: workspaceInput.createdBy, role: "admin", + status: "active", }); const newWorkspace = { ...data };