diff --git a/apps/web/app/api/domains/route.ts b/apps/web/app/api/domains/route.ts index af4d869c9c..09b40b913c 100644 --- a/apps/web/app/api/domains/route.ts +++ b/apps/web/app/api/domains/route.ts @@ -207,6 +207,9 @@ export const POST = withWorkspace( slug: slug, projectId: workspace.id, primary: totalDomains === 0, + ...(slug.endsWith(".dub.link") && { + verified: true, + }), ...(placeholder && { placeholder }), expiredUrl, notFoundUrl, diff --git a/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/default-domain-selector.tsx b/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/default-domain-selector.tsx index a1f000c8f4..d6f06622b8 100644 --- a/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/default-domain-selector.tsx +++ b/apps/web/app/app.dub.co/(onboarding)/onboarding/(steps)/domain/default-domain-selector.tsx @@ -1,8 +1,17 @@ "use client"; import { OnboardingStep } from "@/lib/onboarding/types"; -import { BoltFill, Button, Crown, Icon } from "@dub/ui"; -import { capitalize } from "@dub/utils"; +import { + BoltFill, + Button, + CircleCheckFill, + Crown, + CursorRays, + Flask, + Globe, + type Icon, +} from "@dub/ui"; +import { capitalize, cn } from "@dub/utils"; import { usePlausible } from "next-plausible"; import Image from "next/image"; import { useSearchParams } from "next/navigation"; @@ -19,54 +28,88 @@ export function DefaultDomainSelector() { return ( <>
+ + + Higher click-through rates + + + ), + }, + { + icon: Globe, + text: ( + <> + Requires a{" "} + + dedicated domain + + + ), + }, + ] + : undefined + } + cta="Connect domain" + /> {product === "partners" && ( - Free{" "} - - .dub.link - {" "} - subdomain - - } - description={ - <> - Get a free custom domain like{" "} - - {workspaceSlug && workspaceSlug.length < 8 - ? workspaceSlug - : "company"} - .dub.link - {" "} - for your links. + Use .dub.link subdomain } - cta="Claim .dub.link subdomain" - bannerIcon={BoltFill} - bannerText="Instant setup" + features={[ + { + icon: Flask, + text: "Best for testing purposes", + }, + { + icon: BoltFill, + text: "Instant subdomain setup", + }, + ]} + cta="Use .dub.link subdomain" /> )} - {product === "links" && ( - Claim a free{" "} - - .link - {" "} - domain + Claim a free .link domain } description={ @@ -82,8 +125,6 @@ export function DefaultDomainSelector() { } cta="Claim .link domain" - bannerIcon={Crown} - bannerText="Paid plan required" /> )}
@@ -101,60 +142,107 @@ function DomainOption({ icon, title, description, + features, cta, bannerIcon: BannerIcon, bannerText, + bannerVariant = "default", }: { step: OnboardingStep; icon: string; title: ReactNode; - description: ReactNode; + description?: ReactNode; + features?: { + icon: Icon; + text: ReactNode; + }[]; cta: string; bannerIcon?: Icon; bannerText?: string; + bannerVariant?: "default" | "recommended"; }) { const plausible = usePlausible(); const { continueTo, isLoading, isSuccessful } = useOnboardingProgress(); return ( -
- {BannerIcon && bannerText && ( -
- - {bannerText} +
+
+ {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 ( +
) => { + e.preventDefault(); + e.stopPropagation(); + if (canClaim) await claimDomain(); + }} + > +
+
+

+ Search domains +

+ +
+
+
+ { + setSlug( + e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), + ); + }} + onKeyDown={(e) => { + if (e.key === "Enter") e.preventDefault(); + }} + /> + + .dub.link + +
+ + +
+

+ {isAvailable ? ( + <> + + {domain} + {" "} + is available. + + ) : available === false && hasValidatedSlug ? ( + <> + + {domain} + {" "} + is not available. + + ) : slug.trim() && hasValidatedSlug ? ( + <> + Checking availability for{" "} + + {truncate(domain, 25)} + + + ) : ( + <>  + )} +

+ {(isChecking && hasValidatedSlug) || + (available === null && slug.trim() && hasValidatedSlug) ? ( + + ) : isAvailable ? ( + + ) : null} +
+
+
+
+
+
+
+
+
+ ); +} + 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