Skip to content

Commit

Permalink
Merge pull request #1412 from dubinc/utm-templates
Browse files Browse the repository at this point in the history
ENG-570: UTM templates
  • Loading branch information
steven-tey authored Oct 16, 2024
2 parents 58efa71 + 39f31ba commit 855967a
Show file tree
Hide file tree
Showing 41 changed files with 1,596 additions and 242 deletions.
89 changes: 89 additions & 0 deletions apps/web/app/api/utm/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -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"],
},
);
65 changes: 65 additions & 0 deletions apps/web/app/api/utm/route.ts
Original file line number Diff line number Diff line change
@@ -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"],
},
);
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export default function WorkspaceDomainsClient() {
>
<Button
variant="primary"
className="w-fit"
className="h-9 w-fit rounded-lg"
text={
<div className="flex items-center gap-2">
Add domain{" "}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"use client";

import useWorkspace from "@/lib/swr/use-workspace";
import { TabSelect } from "@dub/ui";
import { useRouter, useSelectedLayoutSegment } from "next/navigation";

export default function LibraryHeader() {
const router = useRouter();
const { slug } = useWorkspace();

const selectedLayoutSegment = useSelectedLayoutSegment();
const page = selectedLayoutSegment === null ? "" : selectedLayoutSegment;

return (
<div className="border-b border-gray-200">
<h1 className="text-2xl font-semibold tracking-tight text-black">
Library
</h1>
<TabSelect
options={[
{ id: "tags", label: "Tags" },
{ id: "utm", label: "UTM Templates" },
]}
selected={page}
onSelect={(id) => {
router.push(`/${slug}/settings/library/${id}`);
}}
/>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ReactNode } from "react";
import LibraryHeader from "./header";

export default function LibraryLayout({ children }: { children: ReactNode }) {
return (
<div className="grid gap-4">
<LibraryHeader />
{children}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { SearchBoxPersisted } from "@/ui/shared/search-box";
import { PaginationControls } from "@dub/blocks";
import { CardList, usePagination, useRouterStuff } from "@dub/ui";
import { Tag } from "@dub/ui/src/icons";
import { InfoTooltip, TooltipContent } from "@dub/ui/src/tooltip";
import { Dispatch, SetStateAction, createContext, useState } from "react";
import { TagCard } from "./tag-card";
import { TagCardPlaceholder } from "./tag-card-placeholder";
Expand Down Expand Up @@ -53,36 +52,19 @@ export default function WorkspaceTagsClient() {

return (
<>
<div className="grid gap-5 pb-10">
<div className="flex flex-wrap justify-between gap-6">
<div className="flex items-center gap-x-2">
<h1 className="text-2xl font-semibold tracking-tight text-black">
Tags
</h1>
<InfoTooltip
content={
<TooltipContent
title="Learn more about how to use tags on Dub."
href="https://dub.co/help/article/how-to-use-tags"
target="_blank"
cta="Learn more"
/>
<div className="grid gap-4 pb-10">
<div className="flex w-full flex-wrap items-center justify-between gap-3 gap-6 sm:w-auto">
<SearchBoxPersisted
loading={loading}
onChangeDebounced={(t) => {
if (t) {
queryParams({ set: { search: t }, del: "page" });
} else {
queryParams({ del: "search" });
}
/>
</div>
<div className="flex w-full flex-wrap items-center gap-3 sm:w-auto">
<SearchBoxPersisted
loading={loading}
onChangeDebounced={(t) => {
if (t) {
queryParams({ set: { search: t }, del: "page" });
} else {
queryParams({ del: "search" });
}
}}
/>
<AddTagButton />
</div>
}}
/>
<AddTagButton />
</div>
{workspaceId && <AddEditTagModal />}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,11 @@ export function TagCard({
.then(async (res) => {
if (res.ok) {
await Promise.all([
mutate(`/api/tags?workspaceId=${id}`),
mutate(
(key) => typeof key === "string" && key.startsWith("/api/tags"),
undefined,
{ revalidate: true },
),
mutate(
(key) => typeof key === "string" && key.startsWith("/api/links"),
undefined,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
"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<SetStateAction<string | null>>;
}>({
openMenuTemplateId: null,
setOpenMenuTemplateId: () => {},
});

export default function WorkspaceUtmTemplatesClient() {
const { id: workspaceId } = useWorkspace();

const { data: templates, isLoading } = useSWR<UtmTemplateWithUserProps[]>(
workspaceId && `/api/utm?workspaceId=${workspaceId}`,
fetcher,
{
dedupingInterval: 60000,
},
);

const [openMenuTemplateId, setOpenMenuTemplateId] = useState<string | null>(
null,
);

const { AddEditUtmTemplateModal, AddUtmTemplateButton } =
useAddEditUtmTemplateModal();

return (
<>
<div className="grid gap-4">
<div className="flex justify-end gap-6">
<AddUtmTemplateButton />
</div>
{workspaceId && <AddEditUtmTemplateModal />}

{isLoading || templates?.length ? (
<TemplatesListContext.Provider
value={{ openMenuTemplateId, setOpenMenuTemplateId }}
>
<CardList variant="compact" loading={isLoading}>
{templates?.length
? templates.map((template) => (
<TemplateCard key={template.id} template={template} />
))
: Array.from({ length: 6 }).map((_, idx) => (
<TemplateCardPlaceholder key={idx} />
))}
</CardList>
</TemplatesListContext.Provider>
) : (
<div className="flex flex-col items-center gap-4 rounded-xl border border-gray-200 py-10">
<EmptyState
icon={DiamondTurnRight}
title="No templates found for this workspace"
/>
<AddUtmTemplateButton />
</div>
)}
</div>
</>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Suspense } from "react";
import WorkspaceUtmTemplatesClient from "./page-client";

export default function WorkspaceUtmTemplates() {
return (
<Suspense>
<WorkspaceUtmTemplatesClient />
</Suspense>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { CardList } from "@dub/ui";

export function TemplateCardPlaceholder() {
return (
<CardList.Card>
<div className="flex h-8 items-center justify-between gap-5 sm:gap-8 md:gap-12">
<div className="flex items-center gap-3">
<div className="h-5 w-5 animate-pulse rounded-md bg-gray-200" />
<div className="h-5 w-16 animate-pulse rounded-md bg-gray-200 sm:w-32" />
</div>
<div className="flex items-center gap-5 sm:gap-8 md:gap-12">
<div className="h-5 w-12 animate-pulse rounded-md bg-gray-200" />
<div className="hidden h-5 w-16 animate-pulse rounded-md bg-gray-200 sm:block" />
<div className="w-8" />
</div>
</div>
</CardList.Card>
);
}
Loading

0 comments on commit 855967a

Please sign in to comment.