Skip to content

Commit d3ba3f2

Browse files
authored
Merge pull request #2224 from dubinc/link-page
Link page
2 parents ff3951f + 02b415b commit d3ba3f2

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

54 files changed

+1700
-909
lines changed

apps/web/app/admin.dub.co/(dashboard)/links/page.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import AdminLinksClient from "app/app.dub.co/(dashboard)/[slug]/page-client";
1+
import AdminLinksClient from "app/app.dub.co/(dashboard)/[slug]/links/page-client";
22
import { Suspense } from "react";
33

44
export default function AdminLinks() {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
"use client";
2+
3+
import useLink from "@/lib/swr/use-link";
4+
import useWorkspace from "@/lib/swr/use-workspace";
5+
import { ExpandedLinkProps } from "@/lib/types";
6+
import { LinkAnalyticsBadge } from "@/ui/links/link-analytics-badge";
7+
import { LinkBuilderDestinationUrlInput } from "@/ui/links/link-builder/controls/link-builder-destination-url-input";
8+
import { LinkBuilderShortLinkInput } from "@/ui/links/link-builder/controls/link-builder-short-link-input";
9+
import { LinkCommentsInput } from "@/ui/links/link-builder/controls/link-comments-input";
10+
import { ConversionTrackingToggle } from "@/ui/links/link-builder/conversion-tracking-toggle";
11+
import {
12+
DraftControls,
13+
DraftControlsHandle,
14+
} from "@/ui/links/link-builder/draft-controls";
15+
import { LinkActionBar } from "@/ui/links/link-builder/link-action-bar";
16+
import { LinkBuilderHeader } from "@/ui/links/link-builder/link-builder-header";
17+
import {
18+
LinkBuilderProvider,
19+
LinkFormData,
20+
} from "@/ui/links/link-builder/link-builder-provider";
21+
import { LinkFeatureButtons } from "@/ui/links/link-builder/link-feature-buttons";
22+
import { LinkPreview } from "@/ui/links/link-builder/link-preview";
23+
import { OptionsList } from "@/ui/links/link-builder/options-list";
24+
import { QRCodePreview } from "@/ui/links/link-builder/qr-code-preview";
25+
import { TagSelect } from "@/ui/links/link-builder/tag-select";
26+
import { useLinkBuilderSubmit } from "@/ui/links/link-builder/use-link-builder-submit";
27+
import { useMetatags } from "@/ui/links/link-builder/use-metatags";
28+
import { useKeyboardShortcut, useMediaQuery } from "@dub/ui";
29+
import { cn } from "@dub/utils";
30+
import { notFound, useParams, useRouter } from "next/navigation";
31+
import { useEffect, useRef, useState } from "react";
32+
import { useFormContext, useFormState } from "react-hook-form";
33+
34+
export function LinkPageClient() {
35+
const params = useParams<{ link: string | string[] }>();
36+
37+
const linkParts = Array.isArray(params.link) ? params.link : null;
38+
if (!linkParts) notFound();
39+
40+
const domain = linkParts[0];
41+
const slug = linkParts.length > 1 ? linkParts.slice(1).join("/") : "_root";
42+
43+
const router = useRouter();
44+
const workspace = useWorkspace();
45+
46+
const { link } = useLink(
47+
{
48+
domain,
49+
slug,
50+
},
51+
{
52+
keepPreviousData: true,
53+
// doing onErrorRetry to avoid race condiition for when a link's domain / key is updated
54+
onErrorRetry: (error, _key, _config, revalidate, { retryCount }) => {
55+
if (error.status === 401 || error.status === 404) {
56+
if (retryCount > 1) {
57+
router.push(`/${workspace.slug}/links`);
58+
return;
59+
}
60+
}
61+
// Default retry behavior for other errors
62+
setTimeout(() => revalidate({ retryCount }), 5000);
63+
},
64+
},
65+
);
66+
67+
return link ? (
68+
<LinkBuilderProvider props={link} workspace={workspace}>
69+
<LinkBuilder link={link} />
70+
</LinkBuilderProvider>
71+
) : (
72+
<LoadingSkeleton />
73+
);
74+
}
75+
76+
function LinkBuilder({ link }: { link: ExpandedLinkProps }) {
77+
const router = useRouter();
78+
const workspace = useWorkspace();
79+
80+
const { isDesktop } = useMediaQuery();
81+
82+
const { control, handleSubmit, reset, getValues } =
83+
useFormContext<LinkFormData>();
84+
const { isSubmitSuccessful, isDirty } = useFormState({ control });
85+
86+
const draftControlsRef = useRef<DraftControlsHandle>(null);
87+
88+
const onSubmitSuccess = (data: LinkFormData) => {
89+
draftControlsRef.current?.onSubmitSuccessful();
90+
91+
router.replace(`/${workspace.slug}/links/${data.domain}/${data.key}`, {
92+
scroll: false,
93+
});
94+
};
95+
96+
useEffect(() => {
97+
if (isSubmitSuccessful)
98+
reset(getValues(), { keepValues: true, keepDirty: false });
99+
}, [isSubmitSuccessful, reset, getValues]);
100+
101+
const onSubmit = useLinkBuilderSubmit({
102+
onSuccess: onSubmitSuccess,
103+
});
104+
105+
useMetatags();
106+
107+
// Go back to `/links` when ESC is pressed
108+
useKeyboardShortcut("Escape", () => router.push(`/${workspace.slug}/links`), {
109+
enabled: !isDirty,
110+
});
111+
112+
const [isChangingLink, setIsChangingLink] = useState(false);
113+
114+
return (
115+
<div className="flex min-h-[calc(100vh-8px)] flex-col rounded-t-[inherit] bg-white">
116+
<div className="py-2 pl-4 pr-5">
117+
<LinkBuilderHeader
118+
onSelectLink={(selectedLink) => {
119+
if (selectedLink.id === link.id) return;
120+
121+
if (isDirty) {
122+
if (
123+
!confirm(
124+
"You have unsaved changes. Are you sure you want to continue?",
125+
)
126+
)
127+
return;
128+
}
129+
130+
setIsChangingLink(true);
131+
router.push(
132+
`/${workspace.slug}/links/${selectedLink.domain}/${selectedLink.key}`,
133+
);
134+
}}
135+
className="p-0"
136+
foldersEnabled={!!workspace.flags?.linkFolders}
137+
>
138+
<div
139+
className={cn(
140+
"flex min-w-0 items-center gap-2 transition-opacity",
141+
isChangingLink && "opacity-50",
142+
)}
143+
>
144+
<DraftControls
145+
ref={draftControlsRef}
146+
props={link}
147+
workspaceId={workspace.id!}
148+
/>
149+
<div className="shrink-0">
150+
<LinkAnalyticsBadge link={link} />
151+
</div>
152+
</div>
153+
</LinkBuilderHeader>
154+
</div>
155+
<form
156+
className={cn(
157+
"grid grow grid-cols-1 transition-opacity lg:grid-cols-[minmax(0,1fr)_300px]",
158+
"divide-neutral-200 border-t border-neutral-200 lg:divide-x lg:divide-y",
159+
isChangingLink && "opacity-50",
160+
)}
161+
onSubmit={handleSubmit(onSubmit)}
162+
>
163+
<div className="relative flex min-h-full flex-col px-4 md:px-6">
164+
<div className="relative mx-auto flex w-full max-w-xl flex-col gap-7 pb-4 pt-10 lg:pb-10">
165+
<LinkBuilderDestinationUrlInput />
166+
167+
<LinkBuilderShortLinkInput />
168+
169+
<TagSelect />
170+
171+
<LinkCommentsInput />
172+
173+
<ConversionTrackingToggle />
174+
175+
{isDesktop && (
176+
<LinkFeatureButtons className="mt-1 flex-wrap" variant="page" />
177+
)}
178+
179+
<OptionsList />
180+
</div>
181+
182+
{isDesktop && (
183+
<>
184+
<div className="grow" />
185+
<LinkActionBar />
186+
</>
187+
)}
188+
</div>
189+
<div className="px-4 md:px-6 lg:bg-neutral-50 lg:px-0">
190+
<div className="mx-auto max-w-xl divide-neutral-200 lg:divide-y">
191+
<div className="py-4 lg:px-4 lg:py-6">
192+
<QRCodePreview />
193+
</div>
194+
<div className="py-4 lg:px-4 lg:py-6">
195+
<LinkPreview />
196+
</div>
197+
</div>
198+
</div>
199+
{!isDesktop && (
200+
<LinkActionBar>
201+
<LinkFeatureButtons variant="page" />
202+
</LinkActionBar>
203+
)}
204+
</form>
205+
</div>
206+
);
207+
}
208+
209+
function LoadingSkeleton() {
210+
return (
211+
<div className="flex min-h-[calc(100vh-8px)] flex-col rounded-t-[inherit] bg-white">
212+
<div className="flex items-center justify-between gap-4 py-3 pl-4 pr-5">
213+
<div className="h-8 w-64 max-w-full animate-pulse rounded-md bg-neutral-100" />
214+
<div className="h-7 w-32 max-w-full animate-pulse rounded-md bg-neutral-100" />
215+
</div>
216+
<div
217+
className={cn(
218+
"grid grow grid-cols-1 lg:grid-cols-[minmax(0,1fr)_300px]",
219+
"divide-neutral-200 border-t border-neutral-200 lg:divide-x lg:divide-y lg:divide-y-0",
220+
)}
221+
>
222+
<div className="relative flex min-h-full flex-col px-4 md:px-6">
223+
<div className="relative mx-auto flex w-full max-w-xl flex-col gap-7 pb-4 pt-10 lg:pb-10">
224+
{["h-[66px]", "h-[66px]", "h-[64px]", "h-[104px]"].map(
225+
(className, idx) => (
226+
<div key={idx} className={cn("flex flex-col gap-2", className)}>
227+
<div className="h-5 w-24 animate-pulse rounded-md bg-neutral-100" />
228+
<div className="grow animate-pulse rounded-md bg-neutral-100" />
229+
</div>
230+
),
231+
)}
232+
</div>
233+
</div>
234+
<div className="px-4 md:px-6 lg:bg-neutral-50 lg:px-0">
235+
<div className="mx-auto max-w-xl divide-neutral-200 lg:divide-y"></div>
236+
</div>
237+
</div>
238+
</div>
239+
);
240+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { PageContent } from "@/ui/layout/page-content";
2+
import { LinkPageClient } from "./page-client";
3+
4+
export default function LinkPage() {
5+
return (
6+
<PageContent
7+
className="mt-0 md:mt-0 md:bg-transparent md:py-0"
8+
contentWrapperClassName="pt-0 md:rounded-tl-2xl"
9+
>
10+
<LinkPageClient />
11+
</PageContent>
12+
);
13+
}

apps/web/lib/middleware/app.ts

+1
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export default async function AppMiddleware(req: NextRequest) {
8383
"/login",
8484
"/register",
8585
"/workspaces",
86+
"/links",
8687
"/analytics",
8788
"/events",
8889
"/programs",

apps/web/lib/middleware/utils/app-redirect.ts

+4
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ export const appRedirect = (path: string) => {
99
return APP_REDIRECTS[path];
1010
}
1111

12+
// Redirect "/[slug]" to "/[slug]/links"
13+
const rootRegex = /^\/([^\/]+)$/;
14+
if (rootRegex.test(path)) return path.replace(rootRegex, "/$1/links");
15+
1216
// Redirect "programs/[programId]/settings" to "programs/[programId]/settings/rewards" (first tab)
1317
const programSettingsRegex = /\/programs\/([^\/]+)\/settings$/;
1418
if (programSettingsRegex.test(path))

apps/web/lib/swr/use-folder.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,17 @@ import { fetcher } from "@dub/utils";
33
import useSWR from "swr";
44
import useWorkspace from "./use-workspace";
55

6-
export default function useFolder({ folderId }: { folderId?: string | null }) {
6+
export default function useFolder({
7+
folderId,
8+
enabled,
9+
}: {
10+
folderId?: string | null;
11+
enabled?: boolean;
12+
}) {
713
const { id: workspaceId, plan, flags } = useWorkspace();
814

9-
const enabled =
15+
const swrEnabled =
16+
enabled &&
1017
folderId &&
1118
folderId !== "unsorted" &&
1219
workspaceId &&
@@ -18,7 +25,7 @@ export default function useFolder({ folderId }: { folderId?: string | null }) {
1825
isValidating,
1926
isLoading,
2027
} = useSWR<Folder>(
21-
enabled ? `/api/folders/${folderId}?workspaceId=${workspaceId}` : null,
28+
swrEnabled ? `/api/folders/${folderId}?workspaceId=${workspaceId}` : null,
2229
fetcher,
2330
{
2431
dedupingInterval: 60000,

apps/web/lib/swr/use-link-info.ts

Whitespace-only changes.

apps/web/lib/swr/use-link.ts

+11-3
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
import { fetcher } from "@dub/utils";
2-
import useSWR from "swr";
2+
import useSWR, { SWRConfiguration } from "swr";
33
import { ExpandedLinkProps } from "../types";
44
import useWorkspace from "./use-workspace";
55

6-
export default function useLink(linkId: string) {
6+
export default function useLink(
7+
linkIdOrLink: string | { domain: string; slug: string },
8+
swrOptions?: SWRConfiguration,
9+
) {
710
const { id: workspaceId } = useWorkspace();
811

912
const { data: link, error } = useSWR<ExpandedLinkProps>(
10-
workspaceId && linkId && `/api/links/${linkId}?workspaceId=${workspaceId}`,
13+
workspaceId &&
14+
linkIdOrLink &&
15+
(typeof linkIdOrLink === "string"
16+
? `/api/links/${linkIdOrLink}?workspaceId=${workspaceId}`
17+
: `/api/links/info?domain=${linkIdOrLink.domain}&key=${linkIdOrLink.slug}&workspaceId=${workspaceId}`),
1118
fetcher,
19+
swrOptions,
1220
);
1321

1422
return {

apps/web/ui/analytics/events/events-table.tsx

+12-11
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"use client";
22

33
import { editQueryString } from "@/lib/analytics/utils";
4+
import useWorkspace from "@/lib/swr/use-workspace";
45
import { ClickEvent, Customer, LeadEvent, SaleEvent } from "@/lib/types";
56
import { CustomerDetailsSheet } from "@/ui/partners/customer-details-sheet";
67
import EmptyState from "@/ui/shared/empty-state";
@@ -26,6 +27,7 @@ import {
2627
} from "@dub/utils";
2728
import { Cell, ColumnDef } from "@tanstack/react-table";
2829
import { Link2 } from "lucide-react";
30+
import Link from "next/link";
2931
import { ReactNode, useContext, useEffect, useMemo, useState } from "react";
3032
import useSWR from "swr";
3133
import { AnalyticsContext } from "../analytics-provider";
@@ -54,6 +56,7 @@ export default function EventsTable({
5456
requiresUpgrade?: boolean;
5557
upgradeOverlay?: ReactNode;
5658
}) {
59+
const { slug } = useWorkspace();
5760
const { searchParams, queryParams } = useRouterStuff();
5861
const { setExportQueryString } = useContext(EventsContext);
5962
const {
@@ -131,21 +134,19 @@ export default function EventsTable({
131134
}),
132135
},
133136
cell: ({ getValue }) => (
134-
<div className="flex items-center gap-3">
137+
<Link
138+
href={`/${slug}/links/${getValue().domain}/${getValue().key}`}
139+
target="_blank"
140+
className="flex cursor-alias items-center gap-3 decoration-dotted hover:underline"
141+
>
135142
<LinkLogo
136143
apexDomain={getApexDomain(getValue().url)}
137144
className="size-4 shrink-0 sm:size-4"
138145
/>
139-
<CopyText
140-
value={getValue().shortLink}
141-
successMessage="Copied link to clipboard!"
142-
className="truncate"
143-
>
144-
<span className="truncate" title={getValue().shortLink}>
145-
{getPrettyUrl(getValue().shortLink)}
146-
</span>
147-
</CopyText>
148-
</div>
146+
<span className="truncate" title={getValue().shortLink}>
147+
{getPrettyUrl(getValue().shortLink)}
148+
</span>
149+
</Link>
149150
),
150151
},
151152
{

0 commit comments

Comments
 (0)