From 7484324f2efede9ecb300519045345850bccb38c Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Fri, 11 Oct 2024 13:40:25 -0400 Subject: [PATCH 01/17] Add UTM templates --- apps/web/app/api/utm-templates/[id]/route.ts | 40 +++ apps/web/app/api/utm-templates/route.ts | 56 ++++ apps/web/lib/types.ts | 4 +- apps/web/lib/zod/schemas/utm-templates.ts | 35 ++ apps/web/prisma/schema/link.prisma | 29 ++ apps/web/prisma/schema/schema.prisma | 1 + apps/web/prisma/schema/workspace.prisma | 1 + apps/web/ui/modals/link-builder/utm-modal.tsx | 70 ++-- .../link-builder/utm-templates-button.tsx | 313 ++++++++++++++++++ 9 files changed, 522 insertions(+), 27 deletions(-) create mode 100644 apps/web/app/api/utm-templates/[id]/route.ts create mode 100644 apps/web/app/api/utm-templates/route.ts create mode 100644 apps/web/lib/zod/schemas/utm-templates.ts create mode 100644 apps/web/ui/modals/link-builder/utm-templates-button.tsx diff --git a/apps/web/app/api/utm-templates/[id]/route.ts b/apps/web/app/api/utm-templates/[id]/route.ts new file mode 100644 index 0000000000..9ba9af5cdb --- /dev/null +++ b/apps/web/app/api/utm-templates/[id]/route.ts @@ -0,0 +1,40 @@ +import { DubApiError } from "@/lib/api/errors"; +import { withWorkspace } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { NextResponse } from "next/server"; + +// DELETE /api/utm-templates/[id] – delete a UTM template for a workspace +export const DELETE = withWorkspace( + async ({ params, workspace }) => { + const { id } = params; + try { + const response = await prisma.utmTemplate.delete({ + where: { + id, + projectId: workspace.id, + }, + }); + + if (!response) { + throw new DubApiError({ + code: "not_found", + message: "UTM template not found.", + }); + } + + return NextResponse.json({ id }); + } catch (error) { + if (error.code === "P2025") { + throw new DubApiError({ + code: "not_found", + message: "UTM template not found.", + }); + } + + throw error; + } + }, + { + requiredPermissions: ["links.write"], + }, +); diff --git a/apps/web/app/api/utm-templates/route.ts b/apps/web/app/api/utm-templates/route.ts new file mode 100644 index 0000000000..a414ef8b8b --- /dev/null +++ b/apps/web/app/api/utm-templates/route.ts @@ -0,0 +1,56 @@ +import { withWorkspace } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { createUTMTemplateBodySchema } from "@/lib/zod/schemas/utm-templates"; +import { NextResponse } from "next/server"; + +// GET /api/utm-templates - get all UTM templates for a workspace +export const GET = withWorkspace( + async ({ workspace, headers }) => { + const templates = await prisma.utmTemplate.findMany({ + where: { + projectId: workspace.id, + }, + orderBy: { + updatedAt: "desc", + }, + take: 50, + }); + + return NextResponse.json(templates, { headers }); + }, + { + requiredPermissions: ["links.read"], + }, +); + +// POST /api/utm-templates - create or update a UTM template for a workspace +export const POST = withWorkspace( + async ({ req, workspace, session, headers }) => { + const props = createUTMTemplateBodySchema.parse(await req.json()); + + const response = await prisma.utmTemplate.upsert({ + where: { + projectId_name: { + projectId: workspace.id, + name: props.name, + }, + }, + create: { + projectId: workspace.id, + userId: session?.user.id, + ...props, + }, + update: { + ...props, + }, + }); + + return NextResponse.json(response, { + headers, + status: 201, + }); + }, + { + requiredPermissions: ["links.write"], + }, +); diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index 75c5dc638a..eb63907600 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -1,7 +1,7 @@ import z from "@/lib/zod"; import { metaTagsSchema } from "@/lib/zod/schemas/metatags"; import { DirectorySyncProviders } from "@boxyhq/saml-jackson"; -import { Link, Project, Webhook } from "@prisma/client"; +import { Link, Project, UTMTemplate, Webhook } from "@prisma/client"; import { WEBHOOK_TRIGGER_DESCRIPTIONS } from "./webhook/constants"; import { trackCustomerResponseSchema } from "./zod/schemas/customers"; import { integrationSchema } from "./zod/schemas/integration"; @@ -60,6 +60,8 @@ export interface TagProps { export type TagColorProps = (typeof tagColors)[number]; +export type UTMTemplateProps = UTMTemplate; + export type PlanProps = (typeof plans)[number]; export type RoleProps = (typeof roles)[number]; diff --git a/apps/web/lib/zod/schemas/utm-templates.ts b/apps/web/lib/zod/schemas/utm-templates.ts new file mode 100644 index 0000000000..3956fadfbb --- /dev/null +++ b/apps/web/lib/zod/schemas/utm-templates.ts @@ -0,0 +1,35 @@ +import z from "@/lib/zod"; + +export const createUTMTemplateBodySchema = z.object({ + name: z.string(), + utm_source: z + .string() + .nullish() + .transform((v) => v ?? null) + .describe("The UTM source of the short link."), + utm_medium: z + .string() + .nullish() + .transform((v) => v ?? null) + .describe("The UTM medium of the short link."), + utm_campaign: z + .string() + .nullish() + .transform((v) => v ?? null) + .describe("The UTM campaign of the short link."), + utm_term: z + .string() + .nullish() + .transform((v) => v ?? null) + .describe("The UTM term of the short link."), + utm_content: z + .string() + .nullish() + .transform((v) => v ?? null) + .describe("The UTM content of the short link."), + ref: z + .string() + .nullish() + .transform((v) => v ?? null) + .describe("The ref of the short link."), +}); diff --git a/apps/web/prisma/schema/link.prisma b/apps/web/prisma/schema/link.prisma index e69056aeff..04123138a5 100644 --- a/apps/web/prisma/schema/link.prisma +++ b/apps/web/prisma/schema/link.prisma @@ -78,3 +78,32 @@ model Link { @@index(userId) @@fulltext([key, url]) } + +model UtmTemplate { + id String @id @default(cuid()) + name String + + // Parameters + utm_source String? + utm_medium String? + utm_campaign String? + utm_term String? + utm_content String? + ref String? + + // User who created the template + user User? @relation(fields: [userId], references: [id]) + userId String? + + // Project that the template belongs to + project Project? @relation(fields: [projectId], references: [id], onUpdate: Cascade, onDelete: Cascade) + projectId String? + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index(userId) + @@index(projectId) + @@unique([projectId, name]) + @@index(updatedAt(sort: Desc)) +} diff --git a/apps/web/prisma/schema/schema.prisma b/apps/web/prisma/schema/schema.prisma index f5c5a804a5..d2633e1976 100644 --- a/apps/web/prisma/schema/schema.prisma +++ b/apps/web/prisma/schema/schema.prisma @@ -39,6 +39,7 @@ model User { oAuthCodes OAuthCode[] integrations Integration[] // Integrations user created in their workspace installedIntegrations InstalledIntegration[] // Integrations user installed in their workspace + utmTemplates UtmTemplate[] @@index(source) @@index(defaultWorkspace) diff --git a/apps/web/prisma/schema/workspace.prisma b/apps/web/prisma/schema/workspace.prisma index b7c8734c94..99d6e18892 100644 --- a/apps/web/prisma/schema/workspace.prisma +++ b/apps/web/prisma/schema/workspace.prisma @@ -50,6 +50,7 @@ model Project { installedIntegrations InstalledIntegration[] // Integrations workspace installed webhooks Webhook[] registeredDomains RegisteredDomain[] + utmTemplates UtmTemplate[] @@index(usageLastChecked(sort: Asc)) } diff --git a/apps/web/ui/modals/link-builder/utm-modal.tsx b/apps/web/ui/modals/link-builder/utm-modal.tsx index 2e84556373..1ee9bd7d7f 100644 --- a/apps/web/ui/modals/link-builder/utm-modal.tsx +++ b/apps/web/ui/modals/link-builder/utm-modal.tsx @@ -24,9 +24,10 @@ import { useRef, useState, } from "react"; -import { useForm, useFormContext } from "react-hook-form"; +import { FormProvider, useForm, useFormContext } from "react-hook-form"; import { LinkFormData } from "."; import { UTM_PARAMETERS } from "./constants"; +import { UTMTemplatesButton } from "./utm-templates-button"; type UTMModalProps = { showUTMModal: boolean; @@ -52,13 +53,7 @@ function UTMModalInner({ setShowUTMModal }: UTMModalProps) { const { getValues: getValuesParent, setValue: setValueParent } = useFormContext(); - const { - watch, - setValue, - reset, - formState: { isDirty }, - handleSubmit, - } = useForm< + const form = useForm< Pick< LinkFormData, | "url" @@ -79,6 +74,14 @@ function UTMModalInner({ setShowUTMModal }: UTMModalProps) { }, }); + const { + watch, + setValue, + reset, + formState: { isDirty }, + handleSubmit, + } = form; + const url = watch("url"); const enabledParams = useMemo(() => getParamsFromURL(url), [url]); @@ -290,24 +293,39 @@ function UTMModalInner({ setShowUTMModal }: UTMModalProps) { )} -
-
); diff --git a/apps/web/ui/modals/link-builder/utm-templates-button.tsx b/apps/web/ui/modals/link-builder/utm-templates-button.tsx new file mode 100644 index 0000000000..380b5fb5b7 --- /dev/null +++ b/apps/web/ui/modals/link-builder/utm-templates-button.tsx @@ -0,0 +1,313 @@ +"use client"; + +import useWorkspace from "@/lib/swr/use-workspace"; +import { UTMTemplateProps } from "@/lib/types"; +import { AnimatedSizeContainer, Button, Popover, Xmark } from "@dub/ui"; +import { + Book2, + Download, + LoadingSpinner, + SquareLayoutGrid6, + useMediaQuery, +} from "@dub/ui/src"; +import { fetcher, getParamsFromURL, timeAgo } from "@dub/utils"; +import { ChevronUp } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { useFormContext } from "react-hook-form"; +import { toast } from "sonner"; +import useSWR, { mutate } from "swr"; + +export function UTMTemplatesButton({ + onLoad, +}: { + onLoad: (params: Record) => void; +}) { + const { isMobile } = useMediaQuery(); + const { id: workspaceId } = useWorkspace(); + + let { data, isLoading } = useSWR( + workspaceId && `/api/utm-templates?workspaceId=${workspaceId}`, + fetcher, + { + dedupingInterval: 60000, + }, + ); + + const [openPopover, setOpenPopover] = useState(false); + const [state, setState] = useState<"default" | "load" | "save">("default"); + useEffect(() => { + if (!openPopover) setState("default"); + }, [openPopover]); + + return ( + { + // Allows scrolling to work when the popover's in a modal + e.stopPropagation(); + }} + content={ + + {data ? ( +
+ {state === "default" && ( +
+ {data.length > 0 && ( + + )} + +
+ )} + {state === "save" && ( +
+ setOpenPopover(false)} + /> +
+ )} + {state === "load" && ( +
+ { + setOpenPopover(false); + onLoad(params); + }} + onDelete={() => setOpenPopover(false)} + /> +
+ )} +
+ ) : isLoading ? ( +
+ +
+ ) : ( +
+ Failed to load templates +
+ )} +
+ } + > + + + + ); +} From afe4971f41a8872d733cdf0c609b662838e00362 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Fri, 11 Oct 2024 16:42:12 -0400 Subject: [PATCH 02/17] Add UTM templates to settings --- apps/web/app/api/utm-templates/[id]/route.ts | 51 +++++ apps/web/app/api/utm-templates/route.ts | 27 ++- .../settings/utm-templates/page-client.tsx | 82 +++++++ .../[slug]/settings/utm-templates/page.tsx | 10 + .../template-card-placeholder.tsx | 18 ++ .../settings/utm-templates/template-card.tsx | 202 +++++++++++++++++ apps/web/lib/types.ts | 7 +- apps/web/lib/zod/schemas/utm-templates.ts | 2 + apps/web/ui/layout/sidebar/items.ts | 6 + apps/web/ui/links/utm-builder.tsx | 144 ++++++++++++ .../ui/modals/add-edit-utm-template.modal.tsx | 212 ++++++++++++++++++ apps/web/ui/modals/link-builder/constants.ts | 51 ----- .../modals/link-builder/targeting-modal.tsx | 2 +- apps/web/ui/modals/link-builder/utm-modal.tsx | 102 ++------- .../link-builder/utm-templates-button.tsx | 8 +- 15 files changed, 773 insertions(+), 151 deletions(-) create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/page-client.tsx create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/page.tsx create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/template-card-placeholder.tsx create mode 100644 apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/template-card.tsx create mode 100644 apps/web/ui/links/utm-builder.tsx create mode 100644 apps/web/ui/modals/add-edit-utm-template.modal.tsx diff --git a/apps/web/app/api/utm-templates/[id]/route.ts b/apps/web/app/api/utm-templates/[id]/route.ts index 9ba9af5cdb..039ce7aedd 100644 --- a/apps/web/app/api/utm-templates/[id]/route.ts +++ b/apps/web/app/api/utm-templates/[id]/route.ts @@ -1,8 +1,59 @@ import { DubApiError } from "@/lib/api/errors"; import { withWorkspace } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; +import { updateUTMTemplateBodySchema } from "@/lib/zod/schemas/utm-templates"; import { NextResponse } from "next/server"; +// PATCH /api/utm-templates/[id] – update a UTM template +export const PATCH = withWorkspace( + async ({ req, params, workspace }) => { + const { id } = params; + const props = updateUTMTemplateBodySchema.parse(await req.json()); + + const template = await prisma.utmTemplate.findFirst({ + where: { + id, + projectId: workspace.id, + }, + }); + + if (!template) { + throw new DubApiError({ + code: "not_found", + message: "Template not found.", + }); + } + + try { + const response = await prisma.utmTemplate.update({ + where: { + id, + projectId: workspace.id, + }, + data: { + ...props, + }, + }); + + return NextResponse.json(response); + } catch (error) { + if (error.code === "P2002") { + throw new DubApiError({ + code: "conflict", + message: "A template with that name already exists.", + }); + } + + throw error; + } + }, + { + requiredPermissions: ["links.write"], + }, +); + +export const PUT = PATCH; + // DELETE /api/utm-templates/[id] – delete a UTM template for a workspace export const DELETE = withWorkspace( async ({ params, workspace }) => { diff --git a/apps/web/app/api/utm-templates/route.ts b/apps/web/app/api/utm-templates/route.ts index a414ef8b8b..0a4057b3f9 100644 --- a/apps/web/app/api/utm-templates/route.ts +++ b/apps/web/app/api/utm-templates/route.ts @@ -1,3 +1,4 @@ +import { DubApiError } from "@/lib/api/errors"; import { withWorkspace } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; import { createUTMTemplateBodySchema } from "@/lib/zod/schemas/utm-templates"; @@ -13,6 +14,9 @@ export const GET = withWorkspace( orderBy: { updatedAt: "desc", }, + include: { + user: true, + }, take: 50, }); @@ -28,21 +32,26 @@ export const POST = withWorkspace( async ({ req, workspace, session, headers }) => { const props = createUTMTemplateBodySchema.parse(await req.json()); - const response = await prisma.utmTemplate.upsert({ + const existingTemplate = await prisma.utmTemplate.findFirst({ where: { - projectId_name: { - projectId: workspace.id, - name: props.name, - }, + projectId: workspace.id, + name: props.name, }, - create: { + }); + + if (existingTemplate) { + throw new DubApiError({ + code: "conflict", + message: "A template with that name already exists.", + }); + } + + const response = await prisma.utmTemplate.create({ + data: { projectId: workspace.id, userId: session?.user.id, ...props, }, - update: { - ...props, - }, }); return NextResponse.json(response, { diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/page-client.tsx new file mode 100644 index 0000000000..a6f11f832f --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/page-client.tsx @@ -0,0 +1,82 @@ +"use client"; + +import useWorkspace from "@/lib/swr/use-workspace"; +import { UtmTemplateWithUserProps } from "@/lib/types"; +import { useAddEditUtmTemplateModal } from "@/ui/modals/add-edit-utm-template.modal"; +import EmptyState from "@/ui/shared/empty-state"; +import { CardList } from "@dub/ui"; +import { DiamondTurnRight } from "@dub/ui/src"; +import { fetcher } from "@dub/utils"; +import { Dispatch, SetStateAction, createContext, useState } from "react"; +import useSWR from "swr"; +import { TemplateCard } from "./template-card"; +import { TemplateCardPlaceholder } from "./template-card-placeholder"; + +export const TemplatesListContext = createContext<{ + openMenuTemplateId: string | null; + setOpenMenuTemplateId: Dispatch>; +}>({ + openMenuTemplateId: null, + setOpenMenuTemplateId: () => {}, +}); + +export default function WorkspaceUtmTemplatesClient() { + const { id: workspaceId } = useWorkspace(); + + const { data: templates, isLoading } = useSWR( + workspaceId && `/api/utm-templates?workspaceId=${workspaceId}`, + fetcher, + { + dedupingInterval: 60000, + }, + ); + + const [openMenuTemplateId, setOpenMenuTemplateId] = useState( + null, + ); + + const { AddEditUtmTemplateModal, AddUtmTemplateButton } = + useAddEditUtmTemplateModal(); + + return ( + <> +
+
+
+

+ UTM Templates +

+
+
+ +
+
+ {workspaceId && } + + {isLoading || templates?.length ? ( + + + {templates?.length + ? templates.map((template) => ( + + )) + : Array.from({ length: 6 }).map((_, idx) => ( + + ))} + + + ) : ( +
+ + +
+ )} +
+ + ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/page.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/page.tsx new file mode 100644 index 0000000000..56a5d78fd6 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/page.tsx @@ -0,0 +1,10 @@ +import { Suspense } from "react"; +import WorkspaceUtmTemplatesClient from "./page-client"; + +export default function WorkspaceUtmTemplates() { + return ( + + + + ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/template-card-placeholder.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/template-card-placeholder.tsx new file mode 100644 index 0000000000..301f4cc899 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/template-card-placeholder.tsx @@ -0,0 +1,18 @@ +import { CardList } from "@dub/ui"; + +export function TemplateCardPlaceholder() { + return ( + +
+
+
+
+
+
+
+
+
+
+ + ); +} diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/template-card.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/template-card.tsx new file mode 100644 index 0000000000..0577c59545 --- /dev/null +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/utm-templates/template-card.tsx @@ -0,0 +1,202 @@ +"use client"; + +import useWorkspace from "@/lib/swr/use-workspace"; +import { UtmTemplateWithUserProps } from "@/lib/types"; +import { useAddEditUtmTemplateModal } from "@/ui/modals/add-edit-utm-template.modal"; +import { Delete, ThreeDots } from "@/ui/shared/icons"; +import { + Avatar, + Button, + CardList, + Popover, + Tooltip, + useKeyboardShortcut, +} from "@dub/ui"; +import { + DiamondTurnRight, + LoadingSpinner, + PenWriting, +} from "@dub/ui/src/icons"; +import { cn, formatDate } from "@dub/utils"; +import { useContext, useState } from "react"; +import { toast } from "sonner"; +import { mutate } from "swr"; +import { TemplatesListContext } from "./page-client"; + +export function TemplateCard({ + template, +}: { + template: UtmTemplateWithUserProps; +}) { + const { id } = useWorkspace(); + + const { openMenuTemplateId, setOpenMenuTemplateId } = + useContext(TemplatesListContext); + const openPopover = openMenuTemplateId === template.id; + const setOpenPopover = (open: boolean) => { + setOpenMenuTemplateId(open ? template.id : null); + }; + + const [processing, setProcessing] = useState(false); + + const { AddEditUtmTemplateModal, setShowAddEditUtmTemplateModal } = + useAddEditUtmTemplateModal({ + props: template, + }); + + const handleDelete = async () => { + if (!confirm("Are you sure you want to delete this template?")) return; + + setProcessing(true); + fetch(`/api/utm-templates/${template.id}?workspaceId=${id}`, { + method: "DELETE", + }) + .then(async (res) => { + if (res.ok) { + await mutate(`/api/utm-templates?workspaceId=${id}`); + toast.success("Template deleted"); + } else { + const { error } = await res.json(); + toast.error(error.message); + } + }) + .finally(() => setProcessing(false)); + }; + + return ( + <> + + + setShowAddEditUtmTemplateModal(true)} + innerClassName={cn( + "flex items-center justify-between gap-5 sm:gap-8 md:gap-12 text-sm transition-opacity", + processing && "opacity-50", + )} + > +
+
+ + + {template.name} + +
+ +
+ +
+ {formatDate(template.updatedAt, { month: "short" })} +
+ +
+ +
+ } + align="end" + openPopover={openPopover} + setOpenPopover={setOpenPopover} + > +
+ + {/* Use consumer + separate component to use hovered state from CardList.Card context */} + + {({ hovered }) => ( + { + setOpenPopover(false); + switch (e.key) { + case "e": + setShowAddEditUtmTemplateModal(true); + break; + case "x": + handleDelete(); + break; + } + }} + /> + )} + + + + ); +} + +function TemplateCardKeyboardShortcuts({ + enabled, + onKeyDown, +}: { + enabled: boolean; + onKeyDown: (e: KeyboardEvent) => void; +}) { + useKeyboardShortcut(["e", "x"], onKeyDown, { + enabled, + }); + + return null; +} + +function UserAvatar({ template }: { template: UtmTemplateWithUserProps }) { + const { user } = template; + + return ( + + +
+

+ {user?.name || user?.email || "Anonymous User"} +

+
+
+ {user?.name && user.email &&

{user.email}

} +
+
+ } + > +
+ +
+ + ); +} diff --git a/apps/web/lib/types.ts b/apps/web/lib/types.ts index eb63907600..821272200e 100644 --- a/apps/web/lib/types.ts +++ b/apps/web/lib/types.ts @@ -1,7 +1,7 @@ import z from "@/lib/zod"; import { metaTagsSchema } from "@/lib/zod/schemas/metatags"; import { DirectorySyncProviders } from "@boxyhq/saml-jackson"; -import { Link, Project, UTMTemplate, Webhook } from "@prisma/client"; +import { Link, Project, UtmTemplate, Webhook } from "@prisma/client"; import { WEBHOOK_TRIGGER_DESCRIPTIONS } from "./webhook/constants"; import { trackCustomerResponseSchema } from "./zod/schemas/customers"; import { integrationSchema } from "./zod/schemas/integration"; @@ -60,7 +60,10 @@ export interface TagProps { export type TagColorProps = (typeof tagColors)[number]; -export type UTMTemplateProps = UTMTemplate; +export type UtmTemplateProps = UtmTemplate; +export type UtmTemplateWithUserProps = UtmTemplateProps & { + user?: UserProps; +}; export type PlanProps = (typeof plans)[number]; diff --git a/apps/web/lib/zod/schemas/utm-templates.ts b/apps/web/lib/zod/schemas/utm-templates.ts index 3956fadfbb..60539ac47d 100644 --- a/apps/web/lib/zod/schemas/utm-templates.ts +++ b/apps/web/lib/zod/schemas/utm-templates.ts @@ -33,3 +33,5 @@ export const createUTMTemplateBodySchema = z.object({ .transform((v) => v ?? null) .describe("The ref of the short link."), }); + +export const updateUTMTemplateBodySchema = createUTMTemplateBodySchema; diff --git a/apps/web/ui/layout/sidebar/items.ts b/apps/web/ui/layout/sidebar/items.ts index c3017aef68..9efcfe43bd 100644 --- a/apps/web/ui/layout/sidebar/items.ts +++ b/apps/web/ui/layout/sidebar/items.ts @@ -4,6 +4,7 @@ import { CircleInfo, ConnectedDots, CubeSettings, + DiamondTurnRight, Gear2, Gift, Globe, @@ -115,6 +116,11 @@ export const ITEMS: Record< }, ] : []), + { + name: "UTM Templates", + icon: DiamondTurnRight, + href: `/${slug}/settings/utm-templates`, + }, ], }, { diff --git a/apps/web/ui/links/utm-builder.tsx b/apps/web/ui/links/utm-builder.tsx new file mode 100644 index 0000000000..8f822c6753 --- /dev/null +++ b/apps/web/ui/links/utm-builder.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { Tooltip, useMediaQuery } from "@dub/ui"; +import { + Flag6, + Gift, + GlobePointer, + InputSearch, + Page2, + SatelliteDish, +} from "@dub/ui/src/icons"; +import { cn } from "@dub/utils"; +import { useEffect, useId, useRef, useState } from "react"; + +export const UTM_PARAMETERS = [ + { + key: "utm_source", + icon: GlobePointer, + label: "Source", + placeholder: "google", + description: "Where the traffic is coming from", + }, + { + key: "utm_medium", + icon: SatelliteDish, + label: "Medium", + placeholder: "cpc", + description: "How the traffic is coming", + }, + { + key: "utm_campaign", + icon: Flag6, + label: "Campaign", + placeholder: "summer_sale", + description: "The name of the campaign", + }, + { + key: "utm_term", + icon: InputSearch, + label: "Term", + placeholder: "running shoes", + description: "The term of the campaign", + }, + { + key: "utm_content", + icon: Page2, + label: "Content", + placeholder: "logolink", + description: "The content of the campaign", + }, + { + key: "ref", + icon: Gift, + label: "Referral", + placeholder: "yoursite.com", + description: "The referral of the campaign", + }, +] as const; + +export function UTMBuilder({ + values, + onChange, + disabled, + autoFocus, +}: { + values: Record< + (typeof UTM_PARAMETERS)[number]["key"], + string | null | undefined + >; + onChange: ( + key: (typeof UTM_PARAMETERS)[number]["key"], + value: string, + ) => void; + disabled?: boolean; + autoFocus?: boolean; +}) { + const { isMobile } = useMediaQuery(); + + const id = useId(); + const [showParams, setShowParams] = useState(false); + + const inputRef = useRef(null); + + // Hacky fix to focus the input automatically in modals where normally it doesn't work + useEffect(() => { + if (inputRef.current && !isMobile && autoFocus) + setTimeout(() => inputRef.current?.focus(), 10); + }, []); + + return ( +
+ {UTM_PARAMETERS.map( + ({ key, icon: Icon, label, placeholder, description }, idx) => { + return ( +
+
+ +

{description}

+ {key} +
+ } + sideOffset={4} + disableHoverableContent + > +
setShowParams((s) => !s)} + > + + +
+ + onChange(key, e.target.value)} + /> +
+
+ ); + }, + )} +
+ ); +} diff --git a/apps/web/ui/modals/add-edit-utm-template.modal.tsx b/apps/web/ui/modals/add-edit-utm-template.modal.tsx new file mode 100644 index 0000000000..8660b3fc45 --- /dev/null +++ b/apps/web/ui/modals/add-edit-utm-template.modal.tsx @@ -0,0 +1,212 @@ +import useWorkspace from "@/lib/swr/use-workspace"; +import { UtmTemplateProps } from "@/lib/types"; +import { Button, Modal, useMediaQuery } from "@dub/ui"; +import posthog from "posthog-js"; +import { + Dispatch, + SetStateAction, + useCallback, + useMemo, + useState, +} from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import { mutate } from "swr"; +import { UTMBuilder } from "../links/utm-builder"; + +function AddEditUtmTemplateModal({ + showAddEditUtmTemplateModal, + setShowAddEditUtmTemplateModal, + props, +}: { + showAddEditUtmTemplateModal: boolean; + setShowAddEditUtmTemplateModal: Dispatch>; + props?: UtmTemplateProps; +}) { + const { id } = props || {}; + const { id: workspaceId } = useWorkspace(); + const { isMobile } = useMediaQuery(); + + const { + register, + handleSubmit, + setValue, + setError, + formState: { isSubmitting, isSubmitSuccessful, dirtyFields }, + watch, + } = useForm< + Pick< + UtmTemplateProps, + | "name" + | "utm_campaign" + | "utm_content" + | "utm_medium" + | "utm_source" + | "utm_term" + | "ref" + > + >({ + values: props, + }); + + const values = watch(); + + const endpoint = useMemo( + () => + id + ? { + method: "PATCH", + url: `/api/utm-templates/${id}?workspaceId=${workspaceId}`, + successMessage: "Successfully updated template!", + } + : { + method: "POST", + url: `/api/utm-templates?workspaceId=${workspaceId}`, + successMessage: "Successfully added template!", + }, + [id], + ); + + return ( + +
{ + try { + const res = await fetch(endpoint.url, { + method: endpoint.method, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(data), + }); + + if (!res.ok) { + const { error } = await res.json(); + toast.error(error.message); + setError("root", { message: error.message }); + return; + } + + posthog.capture( + props ? "utm-template_edited" : "utm-template_created", + { + utmTemplateId: id, + utmTemplateName: data.name, + }, + ); + await mutate(`/api/utm-templates?workspaceId=${workspaceId}`); + toast.success(endpoint.successMessage); + setShowAddEditUtmTemplateModal(false); + } catch (e) { + toast.error("Failed to save template"); + setError("root", { message: "Failed to save template" }); + } + })} + className="px-5 py-4" + > +
+

+ {props ? "Edit UTM Template" : "Add UTM Template"} +

+
+
+ +
+ +
+ + Parameters + + { + setValue(key, value, { shouldDirty: true }); + }} + /> +
+ +
+
+
+
+ ); +} + +function AddUtmTemplateButton({ + setShowAddEditUtmTemplateModal, +}: { + setShowAddEditUtmTemplateModal: Dispatch>; +}) { + return ( +
+
+ ); +} + +export function useAddEditUtmTemplateModal({ + props, +}: { props?: UtmTemplateProps } = {}) { + const [showAddEditUtmTemplateModal, setShowAddEditUtmTemplateModal] = + useState(false); + + const AddEditUtmTemplateModalCallback = useCallback(() => { + return ( + + ); + }, [showAddEditUtmTemplateModal, setShowAddEditUtmTemplateModal]); + + const AddUtmTemplateButtonCallback = useCallback(() => { + return ( + + ); + }, [setShowAddEditUtmTemplateModal]); + + return useMemo( + () => ({ + setShowAddEditUtmTemplateModal, + AddEditUtmTemplateModal: AddEditUtmTemplateModalCallback, + AddUtmTemplateButton: AddUtmTemplateButtonCallback, + }), + [ + setShowAddEditUtmTemplateModal, + AddEditUtmTemplateModalCallback, + AddUtmTemplateButtonCallback, + ], + ); +} diff --git a/apps/web/ui/modals/link-builder/constants.ts b/apps/web/ui/modals/link-builder/constants.ts index 50537a502f..c7e8d2364f 100644 --- a/apps/web/ui/modals/link-builder/constants.ts +++ b/apps/web/ui/modals/link-builder/constants.ts @@ -2,14 +2,8 @@ import { LinkWithTagsProps } from "@/lib/types"; import { CircleHalfDottedClock, Crosshairs3, - Flag6, - Gift, - GlobePointer, Incognito, InputPassword, - InputSearch, - Page2, - SatelliteDish, SquareChart, WindowSearch, } from "@dub/ui/src/icons"; @@ -105,48 +99,3 @@ export const MOBILE_MORE_ITEMS = [ type: "modal", }, ]; - -export const UTM_PARAMETERS = [ - { - key: "utm_source", - icon: GlobePointer, - label: "Source", - placeholder: "google", - description: "Where the traffic is coming from", - }, - { - key: "utm_medium", - icon: SatelliteDish, - label: "Medium", - placeholder: "cpc", - description: "How the traffic is coming", - }, - { - key: "utm_campaign", - icon: Flag6, - label: "Campaign", - placeholder: "summer_sale", - description: "The name of the campaign", - }, - { - key: "utm_term", - icon: InputSearch, - label: "Term", - placeholder: "running shoes", - description: "The term of the campaign", - }, - { - key: "utm_content", - icon: Page2, - label: "Content", - placeholder: "logolink", - description: "The content of the campaign", - }, - { - key: "ref", - icon: Gift, - label: "Referral", - placeholder: "yoursite.com", - description: "The referral of the campaign", - }, -] as const; diff --git a/apps/web/ui/modals/link-builder/targeting-modal.tsx b/apps/web/ui/modals/link-builder/targeting-modal.tsx index a54318fc70..d8def93a33 100644 --- a/apps/web/ui/modals/link-builder/targeting-modal.tsx +++ b/apps/web/ui/modals/link-builder/targeting-modal.tsx @@ -1,3 +1,4 @@ +import { UTM_PARAMETERS } from "@/ui/links/utm-builder"; import { ProBadgeTooltip } from "@/ui/shared/pro-badge-tooltip"; import { Button, @@ -26,7 +27,6 @@ import { } from "react"; import { useForm, useFormContext } from "react-hook-form"; import { LinkFormData } from "."; -import { UTM_PARAMETERS } from "./constants"; function TargetingModal({ showTargetingModal, diff --git a/apps/web/ui/modals/link-builder/utm-modal.tsx b/apps/web/ui/modals/link-builder/utm-modal.tsx index 1ee9bd7d7f..b8d9d6dc9f 100644 --- a/apps/web/ui/modals/link-builder/utm-modal.tsx +++ b/apps/web/ui/modals/link-builder/utm-modal.tsx @@ -1,3 +1,4 @@ +import { UTM_PARAMETERS, UTMBuilder } from "@/ui/links/utm-builder"; import { Button, InfoTooltip, @@ -5,7 +6,6 @@ import { SimpleTooltipContent, Tooltip, useKeyboardShortcut, - useMediaQuery, } from "@dub/ui"; import { DiamondTurnRight } from "@dub/ui/src"; import { @@ -18,15 +18,11 @@ import { Dispatch, SetStateAction, useCallback, - useEffect, - useId, useMemo, - useRef, useState, } from "react"; import { FormProvider, useForm, useFormContext } from "react-hook-form"; import { LinkFormData } from "."; -import { UTM_PARAMETERS } from "./constants"; import { UTMTemplatesButton } from "./utm-templates-button"; type UTMModalProps = { @@ -47,9 +43,6 @@ function UTMModal(props: UTMModalProps) { } function UTMModalInner({ setShowUTMModal }: UTMModalProps) { - const { isMobile } = useMediaQuery(); - const id = useId(); - const { getValues: getValuesParent, setValue: setValueParent } = useFormContext(); @@ -85,20 +78,6 @@ function UTMModalInner({ setShowUTMModal }: UTMModalProps) { const url = watch("url"); const enabledParams = useMemo(() => getParamsFromURL(url), [url]); - const inputRef = useRef(null); - - // Hacky fix to focus the input automatically, not sure why autoFocus doesn't work here - useEffect(() => { - if (inputRef.current && !isMobile) { - setTimeout(() => { - inputRef.current?.focus(); - }, 10); - } - }, []); - - // Whether to display actual URL parameters instead of labels - const [showParams, setShowParams] = useState(false); - // Update targeting URL params if they previously matched the same params of the destination URL const updateTargeting = useCallback( ( @@ -217,69 +196,24 @@ function UTMModalInner({ setShowUTMModal }: UTMModalProps) {
-
- {UTM_PARAMETERS.map( - ({ key, icon: Icon, label, placeholder, description }, idx) => { - return ( -
-
- -

{description}

- {key} -
- } - sideOffset={4} - disableHoverableContent - > -
setShowParams((s) => !s)} - > - - -
- - { - if (key !== "ref") - setValue(key, e.target.value, { shouldDirty: true }); - - setValue( - "url", - constructURLFromUTMParams(url, { - ...enabledParams, - [key]: e.target.value, - }), - { shouldDirty: true }, - ); - }} - /> -
-
+
+ { + if (key !== "ref") setValue(key, value, { shouldDirty: true }); + + setValue( + "url", + constructURLFromUTMParams(url, { + ...enabledParams, + [key]: value, + }), + { shouldDirty: true }, ); - }, - )} + }} + disabled={!isValidUrl(url)} + autoFocus + />
{isValidUrl(url) && ( diff --git a/apps/web/ui/modals/link-builder/utm-templates-button.tsx b/apps/web/ui/modals/link-builder/utm-templates-button.tsx index 380b5fb5b7..a656f7169f 100644 --- a/apps/web/ui/modals/link-builder/utm-templates-button.tsx +++ b/apps/web/ui/modals/link-builder/utm-templates-button.tsx @@ -1,7 +1,7 @@ "use client"; import useWorkspace from "@/lib/swr/use-workspace"; -import { UTMTemplateProps } from "@/lib/types"; +import { UtmTemplateProps } from "@/lib/types"; import { AnimatedSizeContainer, Button, Popover, Xmark } from "@dub/ui"; import { Book2, @@ -25,7 +25,7 @@ export function UTMTemplatesButton({ const { isMobile } = useMediaQuery(); const { id: workspaceId } = useWorkspace(); - let { data, isLoading } = useSWR( + const { data, isLoading } = useSWR( workspaceId && `/api/utm-templates?workspaceId=${workspaceId}`, fetcher, { @@ -200,7 +200,7 @@ function UTMTemplateList({ onLoad, onDelete, }: { - data: UTMTemplateProps[]; + data: UtmTemplateProps[]; onLoad: (params: Record) => void; onDelete: () => void; }) { @@ -262,7 +262,7 @@ function UTMTemplateOption({ onClick, onDelete, }: { - template: UTMTemplateProps; + template: UtmTemplateProps; onClick: () => void; onDelete: () => Promise; }) { From 86ae61b33dab810b61781d51f9a937c327a6a873 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Fri, 11 Oct 2024 16:44:14 -0400 Subject: [PATCH 03/17] Update utm-templates-button.tsx --- apps/web/ui/modals/link-builder/utm-templates-button.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/ui/modals/link-builder/utm-templates-button.tsx b/apps/web/ui/modals/link-builder/utm-templates-button.tsx index a656f7169f..17953552c2 100644 --- a/apps/web/ui/modals/link-builder/utm-templates-button.tsx +++ b/apps/web/ui/modals/link-builder/utm-templates-button.tsx @@ -156,7 +156,11 @@ function SaveUTMTemplateForm({ onSuccess }: { onSuccess: () => void }) { }), }, ); - if (!res.ok) throw new Error("UTM template save request failed"); + if (!res.ok) { + const { error } = await res.json(); + toast.error(error.message); + return; + } mutate(`/api/utm-templates?workspaceId=${workspaceId}`); toast.success("Template saved successfully"); From 7d2737185f40fdb6eac3611cddc05f30618a6e6e Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Mon, 14 Oct 2024 11:00:01 -0400 Subject: [PATCH 04/17] Tweaks --- .../onboarding/(steps)/link/form.tsx | 10 +- apps/web/ui/links/destination-url-input.tsx | 12 +- apps/web/ui/modals/link-builder/index.tsx | 21 ++- .../link-builder/utm-templates-button.tsx | 141 +++++++++++------- 4 files changed, 118 insertions(+), 66 deletions(-) diff --git a/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/link/form.tsx b/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/link/form.tsx index e3610c96a1..0747389ccb 100644 --- a/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/link/form.tsx +++ b/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/link/form.tsx @@ -141,7 +141,15 @@ export function Form() { await continueTo("domain"); })} > - + + press Enter ↵ to submit + + } + {...register("url")} + /> ; export const DestinationUrlInput = forwardRef< @@ -24,7 +24,7 @@ export const DestinationUrlInput = forwardRef< domain, domains, error, - showEnterToSubmit = true, + right, ...inputProps }: DestinationUrlInputProps, ref, @@ -64,11 +64,7 @@ export const DestinationUrlInput = forwardRef< /> )} - {showEnterToSubmit && ( -
- press Enter ↵ to submit -
- )} + {right}
+ {isValidUrl(url) && ( + { + setValue( + "url", + constructURLFromUTMParams(url, params), + { + shouldDirty: true, + }, + ); + }} + /> + )} +
+ } /> )} /> diff --git a/apps/web/ui/modals/link-builder/utm-templates-button.tsx b/apps/web/ui/modals/link-builder/utm-templates-button.tsx index 17953552c2..d993127acc 100644 --- a/apps/web/ui/modals/link-builder/utm-templates-button.tsx +++ b/apps/web/ui/modals/link-builder/utm-templates-button.tsx @@ -2,24 +2,33 @@ import useWorkspace from "@/lib/swr/use-workspace"; import { UtmTemplateProps } from "@/lib/types"; -import { AnimatedSizeContainer, Button, Popover, Xmark } from "@dub/ui"; +import { + AnimatedSizeContainer, + Button, + ButtonTooltip, + Popover, + Xmark, +} from "@dub/ui"; import { Book2, + DiamondTurnRight, Download, LoadingSpinner, SquareLayoutGrid6, useMediaQuery, } from "@dub/ui/src"; -import { fetcher, getParamsFromURL, timeAgo } from "@dub/utils"; +import { fetcher, getParamsFromURL } from "@dub/utils"; import { ChevronUp } from "lucide-react"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useFormContext } from "react-hook-form"; import { toast } from "sonner"; import useSWR, { mutate } from "swr"; export function UTMTemplatesButton({ + variant = "default", onLoad, }: { + variant?: "default" | "load-icon"; onLoad: (params: Record) => void; }) { const { isMobile } = useMediaQuery(); @@ -34,16 +43,18 @@ export function UTMTemplatesButton({ ); const [openPopover, setOpenPopover] = useState(false); - const [state, setState] = useState<"default" | "load" | "save">("default"); + const [state, setState] = useState<"default" | "load" | "save">( + variant === "load-icon" ? "load" : "default", + ); useEffect(() => { - if (!openPopover) setState("default"); - }, [openPopover]); + if (!openPopover) setState(variant === "load-icon" ? "load" : "default"); + }, [openPopover, variant]); - return ( + return variant === "default" || (data && data.length > 0) ? ( { // Allows scrolling to work when the popover's in a modal @@ -88,7 +99,11 @@ export function UTMTemplatesButton({ setOpenPopover(false); onLoad(params); }} - onDelete={() => setOpenPopover(false)} + onDelete={ + variant === "default" + ? () => setOpenPopover(false) + : undefined + } /> )} @@ -105,22 +120,36 @@ export function UTMTemplatesButton({ } > - - + setDeleting(true); + await onDelete(); + setDeleting(false); + }} + className="absolute right-1.5 top-2 rounded-md p-1 text-gray-400 outline-none transition-colors hover:text-gray-500 focus-visible:ring-2 focus-visible:ring-gray-500" + title="Delete template" + > + {deleting ? ( + + ) : ( + + )} + + )} ); } From 97f67ac3af52f8945962d15430f1074e5a10dcae Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Mon, 14 Oct 2024 11:30:33 -0400 Subject: [PATCH 05/17] Add combobox for UTM templates --- apps/web/ui/modals/link-builder/index.tsx | 1 - apps/web/ui/modals/link-builder/utm-modal.tsx | 28 +- .../link-builder/utm-templates-button.tsx | 257 ++---------------- .../link-builder/utm-templates-combo.tsx | 88 ++++++ packages/ui/src/combobox/index.tsx | 6 +- packages/ui/src/icons/nucleo/index.ts | 1 + packages/ui/src/icons/nucleo/note.tsx | 62 +++++ 7 files changed, 197 insertions(+), 246 deletions(-) create mode 100644 apps/web/ui/modals/link-builder/utm-templates-combo.tsx create mode 100644 packages/ui/src/icons/nucleo/note.tsx diff --git a/apps/web/ui/modals/link-builder/index.tsx b/apps/web/ui/modals/link-builder/index.tsx index 6ec239787f..60135be315 100644 --- a/apps/web/ui/modals/link-builder/index.tsx +++ b/apps/web/ui/modals/link-builder/index.tsx @@ -393,7 +393,6 @@ function LinkBuilderInner({
{isValidUrl(url) && ( { setValue( "url", diff --git a/apps/web/ui/modals/link-builder/utm-modal.tsx b/apps/web/ui/modals/link-builder/utm-modal.tsx index b8d9d6dc9f..355b2ccad5 100644 --- a/apps/web/ui/modals/link-builder/utm-modal.tsx +++ b/apps/web/ui/modals/link-builder/utm-modal.tsx @@ -23,7 +23,7 @@ import { } from "react"; import { FormProvider, useForm, useFormContext } from "react-hook-form"; import { LinkFormData } from "."; -import { UTMTemplatesButton } from "./utm-templates-button"; +import { UTMTemplatesCombo } from "./utm-templates-combo"; type UTMModalProps = { showUTMModal: boolean; @@ -228,19 +228,19 @@ function UTMModalInner({ setShowUTMModal }: UTMModalProps) { )}
- {isValidUrl(url) ? ( - - { - setValue("url", constructURLFromUTMParams(url, params), { - shouldDirty: true, - }); - }} - /> - - ) : ( -
- )} +
+ {isValidUrl(url) && ( + + { + setValue("url", constructURLFromUTMParams(url, params), { + shouldDirty: true, + }); + }} + /> + + )} +
- )} - -
- )} - {state === "save" && ( -
- setOpenPopover(false)} - /> -
- )} - {state === "load" && ( -
- { - setOpenPopover(false); - onLoad(params); - }} - onDelete={ - variant === "default" - ? () => setOpenPopover(false) - : undefined - } - /> -
- )} +
+ { + setOpenPopover(false); + onLoad(params); + }} + /> +
) : isLoading ? (
@@ -120,145 +68,30 @@ export function UTMTemplatesButton({ } > - {variant === "default" ? ( -
-

- Your saved template will be available to all users in this workspace. -

- - ); -} - function UTMTemplateList({ data, onLoad, - onDelete, }: { data: UtmTemplateProps[]; onLoad: (params: Record) => void; - onDelete?: () => void; }) { - const { id: workspaceId } = useWorkspace(); const { setValue } = useFormContext(); - const handleDelete = async (id: string) => { - try { - const res = await fetch( - `/api/utm-templates/${id}?workspaceId=${workspaceId}`, - { - method: "DELETE", - }, - ); - if (!res.ok) throw new Error("UTM template delete failed"); - - mutate(`/api/utm-templates?workspaceId=${workspaceId}`); - toast.success("Template deleted successfully"); - onDelete?.(); - } catch (e) { - console.error(e); - toast.error("Failed to delete template"); - } - }; - return data.length ? (
@@ -279,9 +112,6 @@ function UTMTemplateList({ onLoad(Object.fromEntries(paramEntries)); }} - onDelete={ - onDelete ? async () => await handleDelete(template.id) : undefined - } /> ))}
@@ -295,11 +125,9 @@ function UTMTemplateList({ function UTMTemplateOption({ template, onClick, - onDelete, }: { template: UtmTemplateProps; onClick: () => void; - onDelete?: () => Promise; }) { const [deleting, setDeleting] = useState(false); @@ -309,38 +137,11 @@ function UTMTemplateOption({ onClick={onClick} className="flex w-full items-center justify-between gap-2 rounded-md p-2 text-gray-700 outline-none hover:bg-gray-100 focus-visible:ring-2 focus-visible:ring-gray-500 active:bg-gray-200 group-hover:bg-gray-100" > - - + + {template.name} -
- {onDelete !== undefined &&
} -
- {onDelete !== undefined && ( - - )}
); } diff --git a/apps/web/ui/modals/link-builder/utm-templates-combo.tsx b/apps/web/ui/modals/link-builder/utm-templates-combo.tsx new file mode 100644 index 0000000000..09fcf7a4bc --- /dev/null +++ b/apps/web/ui/modals/link-builder/utm-templates-combo.tsx @@ -0,0 +1,88 @@ +"use client"; + +import useWorkspace from "@/lib/swr/use-workspace"; +import { UtmTemplateProps } from "@/lib/types"; +import { Combobox } from "@dub/ui"; +import { Note } from "@dub/ui/src/icons"; +import { fetcher, getParamsFromURL } from "@dub/utils"; +import { useFormContext } from "react-hook-form"; +import { toast } from "sonner"; +import useSWR, { mutate } from "swr"; + +export function UTMTemplatesCombo({ + onLoad, +}: { + onLoad: (params: Record) => void; +}) { + const { id: workspaceId } = useWorkspace(); + + const { setValue, getValues } = useFormContext(); + + const { data } = useSWR( + workspaceId && `/api/utm-templates?workspaceId=${workspaceId}`, + fetcher, + { + dedupingInterval: 60000, + }, + ); + + return data && data.length > 0 ? ( + { + if (!option) return; + const template = data.find((template) => template.id === option.value); + if (!template) return; + + const paramEntries = Object.entries(template) + .filter(([key]) => key === "ref" || key.startsWith("utm_")) + .map(([key, value]) => [key, (value || "").toString()]); + + paramEntries.forEach(([key, value]) => + setValue(key, value, { shouldDirty: true }), + ); + + onLoad(Object.fromEntries(paramEntries)); + }} + options={data.map((template) => ({ + label: template.name, + value: template.id, + }))} + placeholder="Templates" + searchPlaceholder="Load or save a template..." + icon={Note} + createLabel={(search) => `Save new template: "${search}"`} + onCreate={async (search) => { + try { + const res = await fetch( + `/api/utm-templates?workspaceId=${workspaceId}`, + { + method: "POST", + body: JSON.stringify({ + name: search, + ...getParamsFromURL(getValues("url")), + }), + }, + ); + if (!res.ok) { + const { error } = await res.json(); + toast.error(error.message); + return false; + } + + mutate(`/api/utm-templates?workspaceId=${workspaceId}`); + toast.success("Template saved successfully"); + return true; + } catch (e) { + console.error(e); + toast.error("Failed to save UTM template"); + } + + return false; + }} + buttonProps={{ className: "w-fit px-2" }} + optionClassName="sm:min-w-[200px] sm:max-w-[350px] animate-fade-in" + caret + /> + ) : null; +} diff --git a/packages/ui/src/combobox/index.tsx b/packages/ui/src/combobox/index.tsx index dac4ab2a92..af7694aa71 100644 --- a/packages/ui/src/combobox/index.tsx +++ b/packages/ui/src/combobox/index.tsx @@ -39,8 +39,8 @@ export type ComboboxProps< ? ComboboxOption[] : ComboboxOption | null; setSelected: TMultiple extends true - ? (tags: ComboboxOption[]) => void - : (tag: ComboboxOption | null) => void; + ? (options: ComboboxOption[]) => void + : (option: ComboboxOption | null) => void; options?: ComboboxOption[]; icon?: Icon | ReactNode; placeholder?: ReactNode; @@ -307,7 +307,7 @@ export function Combobox({ isReactNode(Icon) ? ( Icon ) : ( - + ) ) : undefined } diff --git a/packages/ui/src/icons/nucleo/index.ts b/packages/ui/src/icons/nucleo/index.ts index 9f3fb7ba2b..783f279d76 100644 --- a/packages/ui/src/icons/nucleo/index.ts +++ b/packages/ui/src/icons/nucleo/index.ts @@ -70,6 +70,7 @@ export * from "./magnifier"; export * from "./map-position"; export * from "./menu3"; export * from "./mobile-phone"; +export * from "./note"; export * from "./office-building"; export * from "./page2"; export * from "./paintbrush"; diff --git a/packages/ui/src/icons/nucleo/note.tsx b/packages/ui/src/icons/nucleo/note.tsx new file mode 100644 index 0000000000..88c506b86f --- /dev/null +++ b/packages/ui/src/icons/nucleo/note.tsx @@ -0,0 +1,62 @@ +import { SVGProps } from "react"; + +export function Note(props: SVGProps) { + return ( + + + + + + + + + ); +} From 9eafcaf3066fe579986bc9b697398f1e01319ad3 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Mon, 14 Oct 2024 12:09:39 -0400 Subject: [PATCH 06/17] Improve template combobox --- .../link-builder/utm-templates-combo.tsx | 41 +++++++++++++++++-- packages/ui/src/combobox/index.tsx | 33 +++++++++++++-- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/apps/web/ui/modals/link-builder/utm-templates-combo.tsx b/apps/web/ui/modals/link-builder/utm-templates-combo.tsx index 09fcf7a4bc..b0cc43b9ea 100644 --- a/apps/web/ui/modals/link-builder/utm-templates-combo.tsx +++ b/apps/web/ui/modals/link-builder/utm-templates-combo.tsx @@ -2,9 +2,11 @@ import useWorkspace from "@/lib/swr/use-workspace"; import { UtmTemplateProps } from "@/lib/types"; -import { Combobox } from "@dub/ui"; +import { UTM_PARAMETERS } from "@/ui/links/utm-builder"; +import { Combobox, Tooltip } from "@dub/ui"; import { Note } from "@dub/ui/src/icons"; import { fetcher, getParamsFromURL } from "@dub/utils"; +import { Fragment } from "react"; import { useFormContext } from "react-hook-form"; import { toast } from "sonner"; import useSWR, { mutate } from "swr"; @@ -26,7 +28,7 @@ export function UTMTemplatesCombo({ }, ); - return data && data.length > 0 ? ( + return data ? ( { @@ -48,8 +50,40 @@ export function UTMTemplatesCombo({ label: template.name, value: template.id, }))} + optionRight={({ value }) => { + const template = data.find((template) => template.id === value); + if (!template) return null; + + const includedParams = UTM_PARAMETERS.filter( + ({ key }) => template[key], + ); + + return ( + + {includedParams.map(({ key, label, icon: Icon }) => ( + + {label} + + {template[key]} + + + ))} +
+ } + > +
+ {includedParams.map(({ icon: Icon }) => ( + + ))} +
+ + ); + }} placeholder="Templates" searchPlaceholder="Load or save a template..." + emptyState="No templates found" icon={Note} createLabel={(search) => `Save new template: "${search}"`} onCreate={async (search) => { @@ -81,7 +115,8 @@ export function UTMTemplatesCombo({ return false; }} buttonProps={{ className: "w-fit px-2" }} - optionClassName="sm:min-w-[200px] sm:max-w-[350px] animate-fade-in" + inputClassName="md:min-w-[200px]" + optionClassName="md:min-w-[250px] md:max-w-[350px] animate-fade-in" caret /> ) : null; diff --git a/packages/ui/src/combobox/index.tsx b/packages/ui/src/combobox/index.tsx index af7694aa71..891d832a11 100644 --- a/packages/ui/src/combobox/index.tsx +++ b/packages/ui/src/combobox/index.tsx @@ -1,7 +1,9 @@ import { cn } from "@dub/utils"; -import { Command, CommandEmpty, CommandInput, CommandItem } from "cmdk"; +import { Command, CommandInput, CommandItem, useCommandState } from "cmdk"; import { ChevronDown } from "lucide-react"; import { + forwardRef, + HTMLProps, isValidElement, PropsWithChildren, ReactNode, @@ -56,6 +58,8 @@ export type ComboboxProps< onOpenChange?: (open: boolean) => void; onSearchChange?: (search: string) => void; shouldFilter?: boolean; + inputClassName?: string; + optionRight?: (option: ComboboxOption) => ReactNode; optionClassName?: string; matchTriggerWidth?: boolean; }>; @@ -86,6 +90,8 @@ export function Combobox({ onOpenChange, onSearchChange, shouldFilter = true, + inputClassName, + optionRight, optionClassName, matchTriggerWidth, children, @@ -193,7 +199,10 @@ export function Combobox({ placeholder={searchPlaceholder} value={search} onValueChange={setSearch} - className="grow border-0 py-3 pl-4 pr-2 outline-none placeholder:text-gray-400 focus:ring-0 sm:text-sm" + className={cn( + "grow border-0 py-3 pl-4 pr-2 outline-none placeholder:text-gray-400 focus:ring-0 sm:text-sm", + inputClassName, + )} onKeyDown={(e) => { if ( e.key === "Escape" || @@ -226,6 +235,7 @@ export function Combobox({ ({ value }) => value === option.value, )} onSelect={() => handleSelect(option)} + right={optionRight?.(option)} className={optionClassName} /> ))} @@ -257,9 +267,9 @@ export function Combobox({ )} {shouldFilter ? ( - + {emptyState ? emptyState : "No matches"} - + ) : sortedOptions.length === 0 ? (
{emptyState ? emptyState : "No matches"} @@ -344,12 +354,14 @@ function Option({ onSelect, multiple, selected, + right, className, }: { option: ComboboxOption; onSelect: () => void; multiple: boolean; selected: boolean; + right?: ReactNode; className?: string; }) { return ( @@ -383,6 +395,7 @@ function Option({ )} {option.label}
+ {right} {!multiple && selected && ( )} @@ -392,3 +405,15 @@ function Option({ const isReactNode = (element: any): element is ReactNode => isValidElement(element); + +// Custom Empty component because our current cmdk version has an issue with first render (https://github.com/pacocoursey/cmdk/issues/149) +const Empty = forwardRef>( + (props, forwardedRef) => { + const render = useCommandState((state) => state.filtered.count === 0); + + if (!render) return null; + return ( +
+ ); + }, +); From fd7946f18fd6f4ad52e2402ba9cfa43f970e11a4 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Mon, 14 Oct 2024 13:36:41 -0400 Subject: [PATCH 07/17] Improve loading state --- .../web/ui/modals/link-builder/utm-templates-combo.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/ui/modals/link-builder/utm-templates-combo.tsx b/apps/web/ui/modals/link-builder/utm-templates-combo.tsx index b0cc43b9ea..18cbfca2b8 100644 --- a/apps/web/ui/modals/link-builder/utm-templates-combo.tsx +++ b/apps/web/ui/modals/link-builder/utm-templates-combo.tsx @@ -28,12 +28,12 @@ export function UTMTemplatesCombo({ }, ); - return data ? ( + return ( { if (!option) return; - const template = data.find((template) => template.id === option.value); + const template = data?.find((template) => template.id === option.value); if (!template) return; const paramEntries = Object.entries(template) @@ -46,12 +46,12 @@ export function UTMTemplatesCombo({ onLoad(Object.fromEntries(paramEntries)); }} - options={data.map((template) => ({ + options={data?.map((template) => ({ label: template.name, value: template.id, }))} optionRight={({ value }) => { - const template = data.find((template) => template.id === value); + const template = data?.find((template) => template.id === value); if (!template) return null; const includedParams = UTM_PARAMETERS.filter( @@ -119,5 +119,5 @@ export function UTMTemplatesCombo({ optionClassName="md:min-w-[250px] md:max-w-[350px] animate-fade-in" caret /> - ) : null; + ); } From a78207f890de4e6f6c390f4bda86a8cda4b140a9 Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Mon, 14 Oct 2024 13:41:11 -0400 Subject: [PATCH 08/17] Update create labels/headings --- apps/web/ui/modals/add-edit-tag-modal.tsx | 6 ++++-- apps/web/ui/modals/add-edit-utm-template.modal.tsx | 6 +++--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/apps/web/ui/modals/add-edit-tag-modal.tsx b/apps/web/ui/modals/add-edit-tag-modal.tsx index a1c117ce9d..099d5f3704 100644 --- a/apps/web/ui/modals/add-edit-tag-modal.tsx +++ b/apps/web/ui/modals/add-edit-tag-modal.tsx @@ -83,7 +83,9 @@ function AddEditTagModal({
-

{props ? "Edit" : "Add"} tag

+

+ {props ? "Edit" : "Create"} tag +

Use tags to organize your links.{" "}

+
+ ); +}; From dad8c30763c7fcdf0771bd2ad4f601abd7bfde0a Mon Sep 17 00:00:00 2001 From: Steven Tey Date: Tue, 15 Oct 2024 19:22:12 -0700 Subject: [PATCH 16/17] update API endpoints --- .../api/{utm-templates => utm}/[id]/route.ts | 8 +++---- .../app/api/{utm-templates => utm}/route.ts | 6 +++--- .../settings/library/utm/page-client.tsx | 2 +- .../settings/library/utm/template-card.tsx | 4 ++-- .../zod/schemas/{utm-templates.ts => utm.ts} | 0 .../ui/modals/add-edit-utm-template.modal.tsx | 6 +++--- apps/web/ui/modals/archive-domain-modal.tsx | 14 +++++++------ apps/web/ui/modals/link-builder/index.tsx | 9 ++++++++ .../link-builder/utm-templates-button.tsx | 2 +- .../link-builder/utm-templates-combo.tsx | 21 ++++++++----------- 10 files changed, 39 insertions(+), 33 deletions(-) rename apps/web/app/api/{utm-templates => utm}/[id]/route.ts (91%) rename apps/web/app/api/{utm-templates => utm}/route.ts (90%) rename apps/web/lib/zod/schemas/{utm-templates.ts => utm.ts} (100%) diff --git a/apps/web/app/api/utm-templates/[id]/route.ts b/apps/web/app/api/utm/[id]/route.ts similarity index 91% rename from apps/web/app/api/utm-templates/[id]/route.ts rename to apps/web/app/api/utm/[id]/route.ts index 039ce7aedd..d58ec51b09 100644 --- a/apps/web/app/api/utm-templates/[id]/route.ts +++ b/apps/web/app/api/utm/[id]/route.ts @@ -1,10 +1,10 @@ import { DubApiError } from "@/lib/api/errors"; import { withWorkspace } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; -import { updateUTMTemplateBodySchema } from "@/lib/zod/schemas/utm-templates"; +import { updateUTMTemplateBodySchema } from "@/lib/zod/schemas/utm"; import { NextResponse } from "next/server"; -// PATCH /api/utm-templates/[id] – update a UTM template +// PATCH /api/utm/[id] – update a UTM template export const PATCH = withWorkspace( async ({ req, params, workspace }) => { const { id } = params; @@ -52,9 +52,7 @@ export const PATCH = withWorkspace( }, ); -export const PUT = PATCH; - -// DELETE /api/utm-templates/[id] – delete a UTM template for a workspace +// DELETE /api/utm/[id] – delete a UTM template for a workspace export const DELETE = withWorkspace( async ({ params, workspace }) => { const { id } = params; diff --git a/apps/web/app/api/utm-templates/route.ts b/apps/web/app/api/utm/route.ts similarity index 90% rename from apps/web/app/api/utm-templates/route.ts rename to apps/web/app/api/utm/route.ts index 0a4057b3f9..49f5e22f08 100644 --- a/apps/web/app/api/utm-templates/route.ts +++ b/apps/web/app/api/utm/route.ts @@ -1,10 +1,10 @@ import { DubApiError } from "@/lib/api/errors"; import { withWorkspace } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; -import { createUTMTemplateBodySchema } from "@/lib/zod/schemas/utm-templates"; +import { createUTMTemplateBodySchema } from "@/lib/zod/schemas/utm"; import { NextResponse } from "next/server"; -// GET /api/utm-templates - get all UTM templates for a workspace +// GET /api/utm - get all UTM templates for a workspace export const GET = withWorkspace( async ({ workspace, headers }) => { const templates = await prisma.utmTemplate.findMany({ @@ -27,7 +27,7 @@ export const GET = withWorkspace( }, ); -// POST /api/utm-templates - create or update a UTM template for a workspace +// POST /api/utm - create a new UTM template for a workspace export const POST = withWorkspace( async ({ req, workspace, session, headers }) => { const props = createUTMTemplateBodySchema.parse(await req.json()); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/utm/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/utm/page-client.tsx index d05a832b5a..d1922ada51 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/utm/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/utm/page-client.tsx @@ -24,7 +24,7 @@ export default function WorkspaceUtmTemplatesClient() { const { id: workspaceId } = useWorkspace(); const { data: templates, isLoading } = useSWR( - workspaceId && `/api/utm-templates?workspaceId=${workspaceId}`, + workspaceId && `/api/utm?workspaceId=${workspaceId}`, fetcher, { dedupingInterval: 60000, diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/utm/template-card.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/utm/template-card.tsx index 03c32c532f..c555884b86 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/utm/template-card.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/utm/template-card.tsx @@ -49,12 +49,12 @@ export function TemplateCard({ if (!confirm("Are you sure you want to delete this template?")) return; setProcessing(true); - fetch(`/api/utm-templates/${template.id}?workspaceId=${id}`, { + fetch(`/api/utm/${template.id}?workspaceId=${id}`, { method: "DELETE", }) .then(async (res) => { if (res.ok) { - await mutate(`/api/utm-templates?workspaceId=${id}`); + await mutate(`/api/utm?workspaceId=${id}`); toast.success("Template deleted"); } else { const { error } = await res.json(); diff --git a/apps/web/lib/zod/schemas/utm-templates.ts b/apps/web/lib/zod/schemas/utm.ts similarity index 100% rename from apps/web/lib/zod/schemas/utm-templates.ts rename to apps/web/lib/zod/schemas/utm.ts diff --git a/apps/web/ui/modals/add-edit-utm-template.modal.tsx b/apps/web/ui/modals/add-edit-utm-template.modal.tsx index 2523eec883..1dd2924326 100644 --- a/apps/web/ui/modals/add-edit-utm-template.modal.tsx +++ b/apps/web/ui/modals/add-edit-utm-template.modal.tsx @@ -56,12 +56,12 @@ function AddEditUtmTemplateModal({ id ? { method: "PATCH", - url: `/api/utm-templates/${id}?workspaceId=${workspaceId}`, + url: `/api/utm/${id}?workspaceId=${workspaceId}`, successMessage: "Successfully updated template!", } : { method: "POST", - url: `/api/utm-templates?workspaceId=${workspaceId}`, + url: `/api/utm?workspaceId=${workspaceId}`, successMessage: "Successfully added template!", }, [id], @@ -97,7 +97,7 @@ function AddEditUtmTemplateModal({ utmTemplateName: data.name, }, ); - await mutate(`/api/utm-templates?workspaceId=${workspaceId}`); + await mutate(`/api/utm?workspaceId=${workspaceId}`); toast.success(endpoint.successMessage); setShowAddEditUtmTemplateModal(false); } catch (e) { diff --git a/apps/web/ui/modals/archive-domain-modal.tsx b/apps/web/ui/modals/archive-domain-modal.tsx index 542b43d1a7..d9852c5626 100644 --- a/apps/web/ui/modals/archive-domain-modal.tsx +++ b/apps/web/ui/modals/archive-domain-modal.tsx @@ -64,9 +64,11 @@ function ArchiveDomainModal({ } await mutate( - (key) => - typeof key === "string" && - key.startsWith(`/api/domains?workspaceId=${workspaceId}`), + (key) => typeof key === "string" && key.startsWith("/api/domains"), + undefined, + { + revalidate: true, + }, ); setShowArchiveDomainModal(false); toastWithUndo({ @@ -89,9 +91,9 @@ function ArchiveDomainModal({ error: "Failed to roll back changes. An error occurred.", success: async () => { await mutate( - (key) => - typeof key === "string" && - key.startsWith(`/api/domains?workspaceId=${workspaceId}`), + (key) => typeof key === "string" && key.startsWith("/api/domains"), + undefined, + { revalidate: true }, ); return "Undo successful! Changes reverted."; }, diff --git a/apps/web/ui/modals/link-builder/index.tsx b/apps/web/ui/modals/link-builder/index.tsx index 1a01235d76..fa4749ee11 100644 --- a/apps/web/ui/modals/link-builder/index.tsx +++ b/apps/web/ui/modals/link-builder/index.tsx @@ -273,6 +273,15 @@ function LinkBuilderInner({ ), // Mutate workspace to update usage stats mutate(`/api/workspaces/${slug}`), + // if updating root domain link, mutate domains as well + key === "_root" && + mutate( + (key) => + typeof key === "string" && + key.startsWith("/api/domains"), + undefined, + { revalidate: true }, + ), ]); const data = await res.json(); posthog.capture( diff --git a/apps/web/ui/modals/link-builder/utm-templates-button.tsx b/apps/web/ui/modals/link-builder/utm-templates-button.tsx index 32a908890e..aa7cf70015 100644 --- a/apps/web/ui/modals/link-builder/utm-templates-button.tsx +++ b/apps/web/ui/modals/link-builder/utm-templates-button.tsx @@ -23,7 +23,7 @@ export function UTMTemplatesButton({ const { id: workspaceId } = useWorkspace(); const { data, isLoading } = useSWR( - workspaceId && `/api/utm-templates?workspaceId=${workspaceId}`, + workspaceId && `/api/utm?workspaceId=${workspaceId}`, fetcher, { dedupingInterval: 60000, diff --git a/apps/web/ui/modals/link-builder/utm-templates-combo.tsx b/apps/web/ui/modals/link-builder/utm-templates-combo.tsx index 9d494aca23..fcd7a989f0 100644 --- a/apps/web/ui/modals/link-builder/utm-templates-combo.tsx +++ b/apps/web/ui/modals/link-builder/utm-templates-combo.tsx @@ -22,7 +22,7 @@ export function UTMTemplatesCombo({ const { setValue, getValues } = useFormContext(); const { data } = useSWR( - workspaceId && `/api/utm-templates?workspaceId=${workspaceId}`, + workspaceId && `/api/utm?workspaceId=${workspaceId}`, fetcher, { dedupingInterval: 60000, @@ -89,23 +89,20 @@ export function UTMTemplatesCombo({ createLabel={(search) => `Save new template: "${search}"`} onCreate={async (search) => { try { - const res = await fetch( - `/api/utm-templates?workspaceId=${workspaceId}`, - { - method: "POST", - body: JSON.stringify({ - name: search, - ...getParamsFromURL(getValues("url")), - }), - }, - ); + const res = await fetch(`/api/utm?workspaceId=${workspaceId}`, { + method: "POST", + body: JSON.stringify({ + name: search, + ...getParamsFromURL(getValues("url")), + }), + }); if (!res.ok) { const { error } = await res.json(); toast.error(error.message); return false; } - mutate(`/api/utm-templates?workspaceId=${workspaceId}`); + mutate(`/api/utm?workspaceId=${workspaceId}`); toast.success("Template saved successfully"); return true; } catch (e) { From 6ab663e293bbd5d8b912a75f544542a6d319ad6e Mon Sep 17 00:00:00 2001 From: Tim Wilson Date: Wed, 16 Oct 2024 10:29:24 -0400 Subject: [PATCH 17/17] Update template-card.tsx --- .../(dashboard)/[slug]/settings/library/utm/template-card.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/utm/template-card.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/utm/template-card.tsx index c555884b86..09573539ea 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/utm/template-card.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/library/utm/template-card.tsx @@ -48,6 +48,7 @@ export function TemplateCard({ const handleDelete = async () => { if (!confirm("Are you sure you want to delete this template?")) return; + setOpenPopover(false); setProcessing(true); fetch(`/api/utm/${template.id}?workspaceId=${id}`, { method: "DELETE",