+ {BannerIcon && bannerText && (
+
+
+ {bannerText}
+
+ )}
+
+
- )}
-
-
-
-
-
- {title}
-
-
{description}
-
-
);
}
+
+function DomainChip({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/page.tsx b/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/page.tsx
index 5d75841dd2..beba4253ce 100644
--- a/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/page.tsx
+++ b/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/page.tsx
@@ -1,23 +1,44 @@
import { StepPage } from "../step-page";
import { DefaultDomainSelector } from "./default-domain-selector";
-export default function Domain() {
+export default async function Domain({
+ searchParams,
+}: {
+ searchParams: Promise<{ product?: string }>;
+}) {
+ const { product } = await searchParams;
+ const isPartners = product !== "links";
+
return (
- Make your links stand out and{" "}
-
- boost click-through rates by 30%
-
- >
+ isPartners ? (
+ <>
+ A{" "}
+
+ dedicated domain
+ {" "}
+ is required for Dub Partner programs, and can be changed at anytime.
+ >
+ ) : (
+ <>
+ Make your links stand out and{" "}
+
+ boost click-through rates by 30%
+
+ >
+ )
}
- className="max-w-none"
+ className="max-w-none [&>div:first-of-type]:max-w-[640px]"
>
diff --git a/apps/web/ui/partners/program-link-configuration.tsx b/apps/web/ui/partners/program-link-configuration.tsx
index 44a1fa716b..71b1c851a3 100644
--- a/apps/web/ui/partners/program-link-configuration.tsx
+++ b/apps/web/ui/partners/program-link-configuration.tsx
@@ -1,15 +1,36 @@
import { getLinkStructureOptions } from "@/lib/partners/get-link-structure-options";
+import { mutatePrefix } from "@/lib/swr/mutate";
import useDomains from "@/lib/swr/use-domains";
import useWorkspace from "@/lib/swr/use-workspace";
import { DomainVerificationStatusProps } from "@/lib/types";
import DomainConfiguration from "@/ui/domains/domain-configuration";
import { DomainSelector } from "@/ui/domains/domain-selector";
-import { AnimatedSizeContainer, InfoTooltip, Input, LinkLogo } from "@dub/ui";
+import { CheckCircleFill } from "@/ui/shared/icons";
+import {
+ AnimatedSizeContainer,
+ Button,
+ Globe,
+ InfoTooltip,
+ Input,
+ LinkLogo,
+ Modal,
+ useMediaQuery,
+ Wordmark,
+} from "@dub/ui";
import { ArrowTurnRight2, ChevronRight, LoadingSpinner } from "@dub/ui/icons";
-import { cn, fetcher, getApexDomain, getPrettyUrl } from "@dub/utils";
+import {
+ cn,
+ fetcher,
+ getApexDomain,
+ getPrettyUrl,
+ isWorkspaceBillingTrialActive,
+ truncate,
+} from "@dub/utils";
import { AnimatePresence, motion } from "motion/react";
-import { useMemo, useState } from "react";
+import { FormEvent, useEffect, useMemo, useState } from "react";
+import { toast } from "sonner";
import useSWRImmutable from "swr/immutable";
+import { useDebounce } from "use-debounce";
import { useAddEditDomainModal } from "../modals/add-edit-domain-modal";
import { useRegisterDomainModal } from "../modals/register-domain-modal";
@@ -118,45 +139,40 @@ export function ProgramLinkConfiguration({
}
function DomainOnboarding({ domain, onDomainChange }: DomainProps) {
+ const { slug, trialEndsAt } = useWorkspace();
const { allWorkspaceDomains: domains, loading: isLoadingDomains } =
useDomains();
+ const trialActive = isWorkspaceBillingTrialActive(trialEndsAt);
const [state, setState] = useState<"idle" | "select">(
domain ? "select" : "idle",
);
+ const [showSubdomainModal, setShowSubdomainModal] = useState(false);
- const { RegisterDomainModal, setShowRegisterDomainModal } =
- useRegisterDomainModal({
+ const { AddEditDomainModal, setShowAddEditDomainModal } =
+ useAddEditDomainModal({
onSuccess: (domain) => {
- onDomainChange(domain);
+ onDomainChange(domain.slug);
setState("select");
},
- setRegisteredParam: false,
});
- const { AddEditDomainModal, setShowAddEditDomainModal } =
- useAddEditDomainModal({
+ const { RegisterDomainModal, setShowRegisterDomainModal } =
+ useRegisterDomainModal({
onSuccess: (domain) => {
- onDomainChange(domain.slug);
+ onDomainChange(domain);
setState("select");
},
+ setRegisteredParam: false,
});
const idleOptions = useMemo(
() => [
{
- icon: "https://assets.dub.co/icons/crown.webp",
- title: "Claim a free .link domain",
- badge: "No setup",
+ icon:
,
+ title: "Connect a custom domain",
+ badge: "Recommended",
badgeClassName: "bg-green-100 text-green-800",
- description: "Free for one year with your paid account.",
- onSelect: () => setShowRegisterDomainModal(true),
- },
- {
- icon: "https://assets.dub.co/icons/link.webp",
- title: "Connect a domain you own",
- badge: "DNS setup required",
- badgeClassName: "bg-bg-inverted/10 text-neutral-800",
description:
"Dedicate a domain exclusively for your short links and program.",
onSelect: () => {
@@ -165,12 +181,32 @@ function DomainOnboarding({ domain, onDomainChange }: DomainProps) {
},
loading: isLoadingDomains,
},
+ trialActive
+ ? {
+ icon:
,
+ title: "Use .dub.link subdomain",
+ badge: "Instant setup",
+ badgeClassName: "bg-bg-inverted/10 text-neutral-800",
+ description:
+ "A fast way to launch. Switch to a custom domain later.",
+ onSelect: () => setShowSubdomainModal(true),
+ }
+ : {
+ icon: "https://assets.dub.co/icons/crown.webp",
+ title: "Claim a free .link domain",
+ badge: "No setup",
+ badgeClassName: "bg-green-100 text-green-800",
+ description: "Free for one year with your paid account.",
+ onSelect: () => setShowRegisterDomainModal(true),
+ },
],
[
domains,
isLoadingDomains,
+ trialActive,
setShowAddEditDomainModal,
setShowRegisterDomainModal,
+ setShowSubdomainModal,
],
);
@@ -178,6 +214,25 @@ function DomainOnboarding({ domain, onDomainChange }: DomainProps) {
<>
+
+
+ Use .dub.link subdomain
+
+ setShowSubdomainModal(false)}
+ onSuccess={(domain) => {
+ onDomainChange(domain);
+ setShowSubdomainModal(false);
+ setState("select");
+ }}
+ />
+
@@ -226,11 +281,17 @@ function DomainOnboarding({ domain, onDomainChange }: DomainProps) {
>
-

+ {typeof option.icon === "string" ? (
+

+ ) : (
+
+ {option.icon}
+
+ )}
@@ -290,10 +351,242 @@ function DomainOnboarding({ domain, onDomainChange }: DomainProps) {
);
}
+function DubLinkSubdomainForm({
+ initialSlug,
+ onCancel,
+ onSuccess,
+}: {
+ initialSlug: string;
+ onCancel: () => void;
+ onSuccess: (domain: string) => void;
+}) {
+ const workspace = useWorkspace();
+ const { isMobile } = useMediaQuery();
+ const [isChecking, setIsChecking] = useState(false);
+ const [isSubmitting, setIsSubmitting] = useState(false);
+ const [slug, setSlug] = useState(
+ initialSlug.toLowerCase().replace(/[^a-z0-9-]/g, ""),
+ );
+ const [available, setAvailable] = useState
(null);
+ const [debouncedSlug] = useDebounce(slug, 500);
+
+ const domain = `${slug}.dub.link`.toLowerCase();
+ const debouncedDomain = `${debouncedSlug}.dub.link`.toLowerCase();
+ const hasValidatedSlug = slug === debouncedSlug;
+ const isAvailable = available === true && hasValidatedSlug;
+ const canClaim = isAvailable && Boolean(workspace.id);
+
+ useEffect(() => {
+ if (!debouncedSlug.trim()) {
+ setAvailable(null);
+ return;
+ }
+
+ setIsChecking(true);
+ setAvailable(null);
+
+ const controller = new AbortController();
+
+ fetch(`/api/domains/${encodeURIComponent(debouncedDomain)}/validate`, {
+ signal: controller.signal,
+ })
+ .then(async (res) => {
+ if (controller.signal.aborted) return;
+
+ if (!res.ok) {
+ setAvailable(false);
+ return;
+ }
+
+ const data = await res.json();
+ if (!controller.signal.aborted) {
+ setAvailable(data.status === "available");
+ }
+ })
+ .catch((error) => {
+ if (error instanceof DOMException && error.name === "AbortError") {
+ return;
+ }
+
+ setAvailable(false);
+ })
+ .finally(() => {
+ if (!controller.signal.aborted) {
+ setIsChecking(false);
+ }
+ });
+
+ return () => controller.abort();
+ }, [debouncedSlug, debouncedDomain]);
+
+ const claimDomain = async () => {
+ if (!workspace.id) {
+ toast.error("Workspace is still loading. Please try again.");
+ return;
+ }
+
+ setIsSubmitting(true);
+
+ try {
+ const res = await fetch(`/api/domains?workspaceId=${workspace.id}`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ slug: domain }),
+ });
+
+ if (!res.ok) {
+ let message = "Failed to add domain.";
+
+ try {
+ const data = await res.json();
+ const error = data?.error;
+
+ if (typeof error === "string") {
+ message = error;
+ } else if (typeof error?.message === "string") {
+ message = error.message;
+ } else if (typeof data?.message === "string") {
+ message = data.message;
+ }
+ } catch {
+ message = "Failed to add domain.";
+ }
+
+ toast.error(message);
+ return;
+ }
+
+ await Promise.all([
+ mutatePrefix("/api/domains"),
+ mutatePrefix("/api/links"),
+ ]);
+ toast.success("Successfully added domain!");
+ onSuccess(domain);
+ } finally {
+ setIsSubmitting(false);
+ }
+ };
+
+ return (
+
+ );
+}
+
function DomainOnboardingSelection({
domain,
onDomainChange,
- onBack,
}: DomainProps & { onBack: () => void }) {
const { id: workspaceId } = useWorkspace();
@@ -315,7 +608,7 @@ function DomainOnboardingSelection({
/>
- This domain will be used for your program’s referral links
+ This domain will be used for your program's referral links