diff --git a/apps/web/app/api/utm/[id]/route.ts b/apps/web/app/api/utm/[id]/route.ts new file mode 100644 index 0000000000..d58ec51b09 --- /dev/null +++ b/apps/web/app/api/utm/[id]/route.ts @@ -0,0 +1,89 @@ +import { DubApiError } from "@/lib/api/errors"; +import { withWorkspace } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { updateUTMTemplateBodySchema } from "@/lib/zod/schemas/utm"; +import { NextResponse } from "next/server"; + +// PATCH /api/utm/[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"], + }, +); + +// DELETE /api/utm/[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/route.ts b/apps/web/app/api/utm/route.ts new file mode 100644 index 0000000000..49f5e22f08 --- /dev/null +++ b/apps/web/app/api/utm/route.ts @@ -0,0 +1,65 @@ +import { DubApiError } from "@/lib/api/errors"; +import { withWorkspace } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { createUTMTemplateBodySchema } from "@/lib/zod/schemas/utm"; +import { NextResponse } from "next/server"; + +// GET /api/utm - 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", + }, + include: { + user: true, + }, + take: 50, + }); + + return NextResponse.json(templates, { headers }); + }, + { + requiredPermissions: ["links.read"], + }, +); + +// 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()); + + const existingTemplate = await prisma.utmTemplate.findFirst({ + where: { + projectId: workspace.id, + name: props.name, + }, + }); + + 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, + }, + }); + + return NextResponse.json(response, { + headers, + status: 201, + }); + }, + { + requiredPermissions: ["links.write"], + }, +); diff --git a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/page-client.tsx b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/page-client.tsx index e94b440fcb..f86e4eee80 100644 --- a/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/page-client.tsx +++ b/apps/web/app/app.dub.co/(dashboard)/[slug]/settings/domains/page-client.tsx @@ -183,7 +183,7 @@ export default function WorkspaceDomainsClient() { > + + ); +} 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..fcd7a989f0 --- /dev/null +++ b/apps/web/ui/modals/link-builder/utm-templates-combo.tsx @@ -0,0 +1,145 @@ +"use client"; + +import useWorkspace from "@/lib/swr/use-workspace"; +import { UtmTemplateProps } from "@/lib/types"; +import { UTM_PARAMETERS } from "@/ui/links/utm-builder"; +import { Button, Combobox, Tooltip } from "@dub/ui"; +import { DiamondTurnRight } from "@dub/ui/src/icons"; +import { fetcher, getParamsFromURL } from "@dub/utils"; +import { useRouter } from "next/navigation"; +import { Fragment } from "react"; +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?workspaceId=${workspaceId}`, + fetcher, + { + dedupingInterval: 60000, + }, + ); + + return ( + { + 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, + }))} + 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={} + icon={DiamondTurnRight} + createLabel={(search) => `Save new template: "${search}"`} + onCreate={async (search) => { + try { + 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?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" }} + inputClassName="md:min-w-[200px]" + optionClassName="md:min-w-[250px] md:max-w-[350px]" + caret + /> + ); +} + +const NoUTMTemplatesFound = () => { + const router = useRouter(); + const { slug } = useWorkspace(); + + return ( +
+
+ +
+

No UTM templates found

+

+ Add a UTM template to easily create links with the same UTM parameters. +

+
+
+
+ ); +}; diff --git a/apps/web/ui/modals/link-builder/webhook-select.tsx b/apps/web/ui/modals/link-builder/webhook-select.tsx index 0e07ea0332..0885e76046 100644 --- a/apps/web/ui/modals/link-builder/webhook-select.tsx +++ b/apps/web/ui/modals/link-builder/webhook-select.tsx @@ -1,6 +1,6 @@ import useWebhooks from "@/lib/swr/use-webhooks"; import useWorkspace from "@/lib/swr/use-workspace"; -import { Button, Combobox, Globe, useKeyboardShortcut, Webhook } from "@dub/ui"; +import { Button, Combobox, useKeyboardShortcut, Webhook } from "@dub/ui"; import { cn } from "@dub/utils"; import { useRouter } from "next/navigation"; import { useMemo, useState } from "react"; @@ -85,7 +85,7 @@ const NoWebhooksFound = () => { return (
- +

No webhooks found

diff --git a/packages/ui/src/combobox/index.tsx b/packages/ui/src/combobox/index.tsx index 86adf760c7..7aaee14914 100644 --- a/packages/ui/src/combobox/index.tsx +++ b/packages/ui/src/combobox/index.tsx @@ -41,8 +41,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; @@ -58,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; }>; @@ -88,6 +90,8 @@ export function Combobox({ onOpenChange, onSearchChange, shouldFilter = true, + inputClassName, + optionRight, optionClassName, matchTriggerWidth, children, @@ -195,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" || @@ -228,6 +235,7 @@ export function Combobox({ ({ value }) => value === option.value, )} onSelect={() => handleSelect(option)} + right={optionRight?.(option)} className={optionClassName} /> ))} @@ -309,7 +317,7 @@ export function Combobox({ isReactNode(Icon) ? ( Icon ) : ( - + ) ) : undefined } @@ -346,12 +354,14 @@ function Option({ onSelect, multiple, selected, + right, className, }: { option: ComboboxOption; onSelect: () => void; multiple: boolean; selected: boolean; + right?: ReactNode; className?: string; }) { return ( @@ -385,6 +395,7 @@ function Option({ )} {option.label}

+ {right} {!multiple && selected && ( )} diff --git a/packages/ui/src/icons/nucleo/books2.tsx b/packages/ui/src/icons/nucleo/books2.tsx new file mode 100644 index 0000000000..058bedb78b --- /dev/null +++ b/packages/ui/src/icons/nucleo/books2.tsx @@ -0,0 +1,94 @@ +import { SVGProps } from "react"; + +export function Books2(props: SVGProps) { + return ( + + + + + + + + + + + + ); +} diff --git a/packages/ui/src/icons/nucleo/index.ts b/packages/ui/src/icons/nucleo/index.ts index 97d9ac67fc..ce505d8d28 100644 --- a/packages/ui/src/icons/nucleo/index.ts +++ b/packages/ui/src/icons/nucleo/index.ts @@ -7,6 +7,7 @@ export * from "./arrows-opposite-direction-y"; export * from "./blog"; export * from "./bolt"; export * from "./book2"; +export * from "./books2"; export * from "./box-archive"; export * from "./bullet-list"; export * from "./calendar-days"; @@ -72,6 +73,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 ( + + + + + + + + + ); +}