"use client"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator } from "@/components/ui/command"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { useEnvContext } from "@/hooks/useEnvContext"; import { toast } from "@/hooks/useToast"; import { createApiClient } from "@/lib/api"; import { cn } from "@/lib/cn"; import { finalizeSubdomainSanitize, isValidSubdomainStructure, sanitizeInputRaw, validateByDomainType } from "@/lib/subdomain-utils"; import { orgQueries } from "@app/lib/queries"; import { build } from "@server/build"; import { CheckDomainAvailabilityResponse } from "@server/routers/domain/types"; import { useQuery } from "@tanstack/react-query"; import { AxiosResponse } from "axios"; import { AlertCircle, Building2, Check, CheckCircle2, ChevronsUpDown, Zap } from "lucide-react"; import { useTranslations } from "next-intl"; import { toUnicode } from "punycode"; import { useCallback, useEffect, useMemo, useState } from "react"; type AvailableOption = { domainNamespaceId: string; fullDomain: string; domainId: string; }; type DomainOption = { id: string; domain: string; type: "organization" | "provided" | "provided-search"; verified?: boolean; domainType?: "ns" | "cname" | "wildcard"; domainId?: string; domainNamespaceId?: string; }; interface DomainPickerProps { orgId: string; onDomainChange?: ( domainInfo: { domainId: string; domainNamespaceId?: string; type: "organization" | "provided"; subdomain?: string; fullDomain: string; baseDomain: string; } | null ) => void; cols?: number; hideFreeDomain?: boolean; defaultFullDomain?: string | null; defaultSubdomain?: string | null; defaultDomainId?: string | null; } export default function DomainPicker({ orgId, onDomainChange, cols = 2, hideFreeDomain = false, defaultSubdomain, defaultFullDomain, defaultDomainId }: DomainPickerProps) { const { env } = useEnvContext(); const api = createApiClient({ env }); const t = useTranslations(); console.log({ defaultFullDomain, defaultSubdomain, defaultDomainId }); const { data = [], isLoading: loadingDomains } = useQuery( orgQueries.domains({ orgId }) ); if (!env.flags.usePangolinDns) { hideFreeDomain = true; } const [subdomainInput, setSubdomainInput] = useState( defaultSubdomain ?? "" ); const [selectedBaseDomain, setSelectedBaseDomain] = useState(null); const [availableOptions, setAvailableOptions] = useState( [] ); // memoized to prevent reruning the effect that selects the initial domain indefinitely // removing this will break and cause an infinite rerender const organizationDomains = useMemo(() => { return data .filter( (domain) => domain.type === "ns" || domain.type === "cname" || domain.type === "wildcard" ) .map((domain) => ({ ...domain, baseDomain: toUnicode(domain.baseDomain), type: domain.type as "ns" | "cname" | "wildcard" })); }, [data]); const [open, setOpen] = useState(false); // Provided domain search states const [userInput, setUserInput] = useState(defaultSubdomain ?? ""); const [isChecking, setIsChecking] = useState(false); const [providedDomainsShown, setProvidedDomainsShown] = useState(3); const [selectedProvidedDomain, setSelectedProvidedDomain] = useState(null); useEffect(() => { if (!loadingDomains) { let domainOptionToSelect: DomainOption | null = null; if (organizationDomains.length > 0) { // Select the first organization domain or the one provided from props let firstOrExistingDomain = organizationDomains.find( (domain) => domain.domainId === defaultDomainId ); // if no default Domain if (!defaultDomainId) { firstOrExistingDomain = organizationDomains[0]; } if (firstOrExistingDomain) { domainOptionToSelect = { id: `org-${firstOrExistingDomain.domainId}`, domain: firstOrExistingDomain.baseDomain, type: "organization", verified: firstOrExistingDomain.verified, domainType: firstOrExistingDomain.type, domainId: firstOrExistingDomain.domainId }; onDomainChange?.({ domainId: firstOrExistingDomain.domainId, type: "organization", subdomain: firstOrExistingDomain.type !== "cname" ? defaultSubdomain || undefined : undefined, fullDomain: firstOrExistingDomain.baseDomain, baseDomain: firstOrExistingDomain.baseDomain }); } } if ( !domainOptionToSelect && build !== "oss" && !hideFreeDomain && defaultDomainId !== undefined ) { // If no organization domains, select the provided domain option const domainOptionText = build === "enterprise" ? t("domainPickerProvidedDomain") : t("domainPickerFreeProvidedDomain"); // free domain option domainOptionToSelect = { id: "provided-search", domain: domainOptionText, type: "provided-search" }; } setSelectedBaseDomain(domainOptionToSelect); } }, [ loadingDomains, organizationDomains, defaultSubdomain, hideFreeDomain, defaultDomainId ]); const checkAvailability = useCallback( async (input: string) => { if (!input.trim()) { setAvailableOptions([]); setIsChecking(false); return; } setIsChecking(true); try { const checkSubdomain = input .toLowerCase() .replace(/\./g, "-") .replace(/[^a-z0-9-]/g, "") .replace(/-+/g, "-") // Replace multiple consecutive dashes with single dash .replace(/^-|-$/g, ""); // Remove leading/trailing dashes if (build != "oss") { const response = await api.get< AxiosResponse >( `/domain/check-namespace-availability?subdomain=${encodeURIComponent(checkSubdomain)}` ); if (response.status === 200) { const { options } = response.data.data; setAvailableOptions(options); } } } catch (error) { console.error("Failed to check domain availability:", error); setAvailableOptions([]); toast({ variant: "destructive", title: t("domainPickerError"), description: t("domainPickerErrorCheckAvailability") }); } finally { setIsChecking(false); } }, [api] ); const debouncedCheckAvailability = useCallback( debounce(checkAvailability, 500), [checkAvailability] ); useEffect(() => { if (selectedBaseDomain?.type === "provided-search") { setProvidedDomainsShown(3); setSelectedProvidedDomain(null); if (userInput.trim()) { setIsChecking(true); debouncedCheckAvailability(userInput); } else { setAvailableOptions([]); setIsChecking(false); } } }, [userInput, debouncedCheckAvailability, selectedBaseDomain]); const finalizeSubdomain = (sub: string, base: DomainOption): string => { const sanitized = finalizeSubdomainSanitize(sub); if (!sanitized) { toast({ variant: "destructive", title: t("domainPickerInvalidSubdomain"), description: t("domainPickerInvalidSubdomainRemoved", { sub }) }); return ""; } const ok = validateByDomainType(sanitized, { type: base.type === "provided-search" ? "provided-search" : "organization", domainType: base.domainType }); if (!ok) { toast({ variant: "destructive", title: t("domainPickerInvalidSubdomain"), description: t("domainPickerInvalidSubdomainCannotMakeValid", { sub, domain: base.domain }) }); return ""; } if (sub !== sanitized) { toast({ title: t("domainPickerSubdomainSanitized"), description: t("domainPickerSubdomainCorrected", { sub, sanitized }) }); } return sanitized; }; const handleSubdomainChange = (value: string) => { const raw = sanitizeInputRaw(value); setSubdomainInput(raw); setSelectedProvidedDomain(null); if (selectedBaseDomain?.type === "organization") { const fullDomain = raw ? `${raw}.${selectedBaseDomain.domain}` : selectedBaseDomain.domain; onDomainChange?.({ domainId: selectedBaseDomain.domainId!, type: "organization", subdomain: raw || undefined, fullDomain, baseDomain: selectedBaseDomain.domain }); } }; const handleProvidedDomainInputChange = (value: string) => { setUserInput(value); if (selectedProvidedDomain) { setSelectedProvidedDomain(null); onDomainChange?.({ domainId: "", type: "provided", subdomain: undefined, fullDomain: "", baseDomain: "" }); } }; const handleBaseDomainSelect = (option: DomainOption) => { let sub = subdomainInput; if (sub && sub.trim() !== "") { sub = finalizeSubdomain(sub, option) || ""; setSubdomainInput(sub); } else { sub = ""; setSubdomainInput(""); } if (option.type === "provided-search") { setUserInput(""); setAvailableOptions([]); setSelectedProvidedDomain(null); } console.log({ setSelectedBaseDomain: option }); setSelectedBaseDomain(option); setOpen(false); if (option.domainType === "cname") { sub = ""; setSubdomainInput(""); } const fullDomain = sub ? `${sub}.${option.domain}` : option.domain; if (option.type === "provided-search") { onDomainChange?.(null); // prevent the modal from closing with `.Free Provided domain` } else { onDomainChange?.({ domainId: option.domainId || "", domainNamespaceId: option.domainNamespaceId, type: "organization", subdomain: option.domainType !== "cname" ? sub || undefined : undefined, fullDomain, baseDomain: option.domain }); } }; const handleProvidedDomainSelect = (option: AvailableOption) => { setSelectedProvidedDomain(option); const parts = option.fullDomain.split("."); const subdomain = parts[0]; const baseDomain = parts.slice(1).join("."); onDomainChange?.({ domainId: option.domainId, domainNamespaceId: option.domainNamespaceId, type: "provided", subdomain, fullDomain: option.fullDomain, baseDomain }); }; const isSubdomainValid = selectedBaseDomain && subdomainInput ? validateByDomainType(subdomainInput, { type: selectedBaseDomain.type === "provided-search" ? "provided-search" : "organization", domainType: selectedBaseDomain.domainType }) : true; const showSubdomainInput = selectedBaseDomain && selectedBaseDomain.type === "organization" && selectedBaseDomain.domainType !== "cname"; const showProvidedDomainSearch = selectedBaseDomain?.type === "provided-search"; const sortedAvailableOptions = [...availableOptions].sort((a, b) => { return a.fullDomain.localeCompare(b.fullDomain); }); const displayedProvidedOptions = sortedAvailableOptions.slice( 0, providedDomainsShown ); console.log({ displayedProvidedOptions }); const selectedDomainNamespaceId = selectedProvidedDomain?.domainNamespaceId ?? displayedProvidedOptions.find( (opt) => opt.fullDomain === defaultFullDomain )?.domainNamespaceId; const hasMoreProvided = sortedAvailableOptions.length > providedDomainsShown; return (
{ if (showProvidedDomainSearch) { handleProvidedDomainInputChange(e.target.value); } else { handleSubdomainChange(e.target.value); } }} /> {showSubdomainInput && subdomainInput && !isValidSubdomainStructure(subdomainInput) && (

{t("domainPickerInvalidSubdomainStructure")}

)}
{t("domainPickerNoDomainsFound")}
{organizationDomains.length > 0 && ( <> {organizationDomains.map( (orgDomain) => ( handleBaseDomainSelect( { id: `org-${orgDomain.domainId}`, domain: orgDomain.baseDomain, type: "organization", verified: orgDomain.verified, domainType: orgDomain.type, domainId: orgDomain.domainId } ) } className="mx-2 rounded-md" disabled={ !orgDomain.verified } >
{ orgDomain.baseDomain } {orgDomain.type.toUpperCase()}{" "} •{" "} {orgDomain.verified ? t( "domainPickerVerified" ) : t( "domainPickerUnverified" )}
) )}
{(build === "saas" || build === "enterprise") && !hideFreeDomain && ( )} )} {(build === "saas" || build === "enterprise") && !hideFreeDomain && ( handleBaseDomainSelect({ id: "provided-search", domain: build === "enterprise" ? t( "domainPickerProvidedDomain" ) : t( "domainPickerFreeProvidedDomain" ), type: "provided-search" }) } className="mx-2 rounded-md" >
{build === "enterprise" ? t( "domainPickerProvidedDomain" ) : t( "domainPickerFreeProvidedDomain" )} {t( "domainPickerSearchForAvailableDomains" )}
)}
{/*showProvidedDomainSearch && build === "saas" && ( {t("domainPickerNotWorkSelfHosted")} )*/} {showProvidedDomainSearch && (
{isChecking && (
{t("domainPickerCheckingAvailability")}
)} {!isChecking && sortedAvailableOptions.length === 0 && userInput.trim() && ( {t("domainPickerNoMatchingDomains")} )} {!isChecking && sortedAvailableOptions.length > 0 && (
{ const option = displayedProvidedOptions.find( (opt) => opt.domainNamespaceId === value ); if (option) { handleProvidedDomainSelect(option); } }} style={{ // @ts-expect-error CSS variable "--cols": `repeat(${cols}, minmax(0, 1fr))` }} className="grid gap-2 grid-cols-1 sm:grid-cols-(--cols)" > {displayedProvidedOptions.map((option) => { const isSelected = selectedDomainNamespaceId === option.domainNamespaceId; return ( ); })} {hasMoreProvided && ( )}
)}
)}
); } function debounce any>( func: T, wait: number ): (...args: Parameters) => void { let timeout: NodeJS.Timeout | null = null; return (...args: Parameters) => { if (timeout) clearTimeout(timeout); timeout = setTimeout(() => { func(...args); }, wait); }; }