diff --git a/src/app/globals.css b/src/app/globals.css index 3b35b817..c1fa8ed4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1225,6 +1225,519 @@ h1, h2, h3, h4, h5, h6 { gap: 8px; } +.username-card__domain-btn { + display: inline-flex; + align-items: center; + gap: 6px; + background: none; + border: 1px dashed var(--border-default); + border-radius: var(--radius); + padding: 8px 14px; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-accent); + cursor: pointer; + transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast); + margin-top: 4px; + width: fit-content; +} + +.username-card__domain-btn:hover { + background: rgba(96, 161, 226, 0.06); + border-color: var(--color-accent); +} + +.username-card__domain-btn:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +/* ========== Custom Domain Modal ========== */ +.domain-modal__backdrop { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + background: var(--navy-overlay-70); + backdrop-filter: blur(8px); + animation: modalFadeIn 200ms ease-out; +} + +.domain-modal { + width: 90vw; + max-width: 480px; + background: var(--color-white); + border-radius: var(--radius); + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 24px 64px var(--navy-overlay-30); + border: 1px solid var(--border-default); + animation: modalSlideUp 300ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.domain-modal__header { + display: flex; + align-items: center; + gap: 10px; + padding: 16px 24px; + border-bottom: 1px solid var(--color-light-gray); + flex-shrink: 0; +} + +.domain-modal__header-icon { + color: var(--color-accent); + flex-shrink: 0; +} + +.domain-modal__title { + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-navy); + flex: 1; + letter-spacing: 0.02em; +} + +.domain-modal__close { + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: none; + color: var(--color-mid-gray); + cursor: pointer; + border-radius: var(--radius); + transition: background var(--transition-fast), color var(--transition-fast); +} + +.domain-modal__close:hover { + background: var(--color-off-white); + color: var(--color-navy); +} + +/* Steps indicator */ +.domain-modal__steps { + display: flex; + align-items: center; + justify-content: center; + gap: 0; + padding: 16px 24px; + border-bottom: 1px solid var(--color-light-gray); + background: var(--color-off-white); +} + +.domain-modal__step { + display: flex; + align-items: center; + gap: 6px; + opacity: 0.4; + transition: opacity var(--transition-fast); +} + +.domain-modal__step--active { + opacity: 1; +} + +.domain-modal__step--done { + opacity: 0.65; +} + +.domain-modal__step-num { + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + font-size: 0.6875rem; + font-weight: 600; + background: var(--color-navy); + color: var(--color-white); +} + +.domain-modal__step--active .domain-modal__step-num { + background: var(--color-accent); +} + +.domain-modal__step--done .domain-modal__step-num { + background: var(--color-navy); +} + +.domain-modal__step-label { + font-size: 0.6875rem; + font-weight: 500; + color: var(--color-navy); + white-space: nowrap; +} + +.domain-modal__step-line { + width: 24px; + height: 1px; + background: var(--border-default); + flex-shrink: 0; + margin: 0 6px; +} + +/* Body */ +.domain-modal__body { + padding: 24px; +} + +.domain-modal__desc { + font-size: 0.8125rem; + color: var(--color-dark-gray); + line-height: 1.6; + margin-bottom: 20px; +} + +.domain-modal__desc strong { + font-weight: 600; + color: var(--color-navy); +} + +.domain-modal__label { + display: block; + font-size: 0.6875rem; + font-weight: 600; + letter-spacing: 0.06em; + color: var(--color-mid-gray); + margin-bottom: 8px; +} + +.domain-modal__input { + width: 100%; + height: 44px; + padding: 0 14px; + border: 1.5px solid var(--color-light-gray); + border-radius: var(--radius); + font-size: 0.875rem; + color: var(--color-navy); + background: var(--color-white); + transition: border-color var(--transition-fast), box-shadow var(--transition-fast); + outline: none; +} + +.domain-modal__input::placeholder { + color: var(--color-mid-gray); +} + +.domain-modal__input:focus { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(96, 161, 226, 0.15); +} + +.domain-modal__hint { + font-size: 0.75rem; + color: var(--color-mid-gray); + line-height: 1.5; + margin-top: 8px; + margin-bottom: 20px; +} + +/* DNS card */ +.domain-modal__dns-card { + background: var(--color-off-white); + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + overflow: hidden; + margin-bottom: 16px; +} + +.domain-modal__dns-row { + display: flex; + align-items: flex-start; + padding: 10px 14px; + border-bottom: 1px solid var(--border-subtle); + gap: 12px; +} + +.domain-modal__dns-row--last { + border-bottom: none; +} + +.domain-modal__dns-label { + font-size: 0.6875rem; + font-weight: 600; + color: var(--color-mid-gray); + text-transform: uppercase; + letter-spacing: 0.04em; + min-width: 44px; + padding-top: 2px; + flex-shrink: 0; +} + +.domain-modal__dns-val { + font-size: 0.8125rem; + color: var(--color-navy); + flex: 1; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 8px; +} + +.domain-modal__dns-val--mono { + font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; + font-size: 0.75rem; + word-break: break-all; +} + +.domain-modal__dns-val-break { + word-break: break-all; +} + +.domain-modal__copy-btn { + flex-shrink: 0; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: var(--color-white); + border-radius: var(--radius); + color: var(--color-mid-gray); + cursor: pointer; + transition: color var(--transition-fast), background var(--transition-fast); +} + +.domain-modal__copy-btn:hover { + color: var(--color-accent); + background: rgba(96, 161, 226, 0.1); +} + +/* Info box */ +.domain-modal__info-box { + display: flex; + gap: 10px; + padding: 12px 14px; + background: #fef9e7; + border: 1px solid #f5d880; + border-radius: var(--radius); + margin-bottom: 20px; +} + +.domain-modal__info-box p { + font-size: 0.75rem; + color: #7a6420; + line-height: 1.5; + margin: 0; +} + +.domain-modal__info-icon { + flex-shrink: 0; + color: #c5a630; + margin-top: 1px; +} + +/* Error box */ +.domain-modal__error { + display: flex; + gap: 10px; + padding: 12px 14px; + background: rgba(220, 53, 69, 0.06); + border: 1px solid rgba(220, 53, 69, 0.2); + border-radius: var(--radius); + margin-bottom: 20px; + align-items: flex-start; +} + +.domain-modal__error p { + font-size: 0.75rem; + color: var(--color-error); + line-height: 1.5; + margin: 0; +} + +.domain-modal__error svg { + flex-shrink: 0; + color: var(--color-error); + margin-top: 1px; +} + +/* Verify summary */ +.domain-modal__verify-summary { + background: var(--color-off-white); + border: 1px solid var(--border-subtle); + border-radius: var(--radius); + padding: 14px; + margin-bottom: 20px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.domain-modal__verify-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.domain-modal__verify-label { + font-size: 0.6875rem; + font-weight: 600; + color: var(--color-mid-gray); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.domain-modal__verify-value { + font-size: 0.8125rem; + color: var(--color-navy); + font-weight: 500; + text-align: right; +} + +.domain-modal__verify-value--mono { + font-family: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; + font-size: 0.75rem; + word-break: break-all; +} + +/* Success state */ +.domain-modal__success { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + padding: 24px 0; + gap: 12px; +} + +.domain-modal__success-icon { + color: #28a745; +} + +.domain-modal__success-title { + font-size: 1rem; + font-weight: 600; + color: var(--color-navy); + margin: 0; +} + +.domain-modal__success-desc { + font-size: 0.8125rem; + color: var(--color-dark-gray); + line-height: 1.5; +} + +.domain-modal__success-desc strong { + font-weight: 600; + color: var(--color-navy); +} + +/* Actions */ +.domain-modal__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +.domain-modal__btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px 18px; + border-radius: var(--radius); + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); + border: none; + min-height: 36px; +} + +.domain-modal__btn--primary { + background: var(--color-accent); + color: var(--color-white); +} + +.domain-modal__btn--primary:hover:not(:disabled) { + background: var(--color-accent-hover); +} + +.domain-modal__btn--primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.domain-modal__btn--ghost { + background: none; + color: var(--color-mid-gray); +} + +.domain-modal__btn--ghost:hover:not(:disabled) { + color: var(--color-navy); + background: var(--color-off-white); +} + +.domain-modal__btn--ghost:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.domain-modal__btn:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} + +.domain-modal__spinner { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +@media (prefers-reduced-motion: reduce) { + .domain-modal__backdrop { animation: none; } + .domain-modal { animation: none; } + .domain-modal__spinner { animation: none; } +} + +@media (max-width: 768px) { + .domain-modal { + width: 100vw; + max-width: none; + border-radius: 0; + max-height: 100vh; + overflow-y: auto; + } + + .domain-modal__steps { + padding: 12px 16px; + } + + .domain-modal__step-label { + display: none; + } + + .domain-modal__step-line { + width: 32px; + } + + .domain-modal__body { + padding: 20px 16px; + } + + .domain-modal__actions { + flex-direction: column-reverse; + } + + .domain-modal__btn { + width: 100%; + min-height: 44px; + } +} + /* ========== Connected Apps ========== */ .connected-apps__header { display: flex; diff --git a/src/components/dashboard/custom-domain-modal.tsx b/src/components/dashboard/custom-domain-modal.tsx new file mode 100644 index 00000000..63a2a9de --- /dev/null +++ b/src/components/dashboard/custom-domain-modal.tsx @@ -0,0 +1,352 @@ +"use client"; + +import React, { useState, useEffect, useRef, useCallback } from "react"; +import { Globe, Copy, Check, Loader2, AlertCircle, CheckCircle2 } from "lucide-react"; +import { authFetch } from "@/lib/auth/fetch"; +import { clearSessionCache } from "@/hooks/use-session"; + +interface CustomDomainModalProps { + isOpen: boolean; + onClose: () => void; + did: string; +} + +type Step = "enter-domain" | "dns-setup" | "verify"; + +export default function CustomDomainModal({ isOpen, onClose, did }: CustomDomainModalProps) { + const backdropRef = useRef(null); + const inputRef = useRef(null); + + const [step, setStep] = useState("enter-domain"); + const [domain, setDomain] = useState(""); + const [copied, setCopied] = useState<"host" | "value" | null>(null); + const [isVerifying, setIsVerifying] = useState(false); + const [verifyError, setVerifyError] = useState(null); + const [isSuccess, setIsSuccess] = useState(false); + + // Reset state when modal opens + useEffect(() => { + if (isOpen) { + setStep("enter-domain"); + setDomain(""); + setCopied(null); + setIsVerifying(false); + setVerifyError(null); + setIsSuccess(false); + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [isOpen]); + + // Close on Escape + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + // Prevent body scroll + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden"; + } else { + document.body.style.overflow = ""; + } + return () => { document.body.style.overflow = ""; }; + }, [isOpen]); + + const cleanDomain = useCallback((input: string) => { + let d = input.trim().toLowerCase(); + // Strip protocol if pasted + d = d.replace(/^https?:\/\//, ""); + // Strip trailing slash / path + d = d.split("/")[0]; + return d; + }, []); + + const handleContinue = () => { + const cleaned = cleanDomain(domain); + if (!cleaned) return; + setDomain(cleaned); + setStep("dns-setup"); + }; + + const handleCopy = async (text: string, which: "host" | "value") => { + try { + await navigator.clipboard.writeText(text); + setCopied(which); + setTimeout(() => setCopied(null), 2000); + } catch { + // Fallback: select text + } + }; + + const handleVerify = async () => { + setIsVerifying(true); + setVerifyError(null); + + try { + const res = await authFetch("/api/xrpc/com/atproto/identity/updateHandle", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ handle: domain }), + }); + + if (!res.ok) { + const data = await res.json().catch(() => ({})); + const errMsg = (data as { error?: string; message?: string }).message + || (data as { error?: string }).error + || res.statusText; + + if (errMsg.includes("resolve") || errMsg.includes("DNS") || errMsg.includes("not found")) { + throw new Error( + "DNS record not found yet. Make sure you added the TXT record and wait a few minutes for it to propagate." + ); + } + if (errMsg.includes("already") || errMsg.includes("taken")) { + throw new Error("This domain is already in use by another account."); + } + throw new Error(errMsg); + } + + // Success + setIsSuccess(true); + clearSessionCache(); + + // Reload after a brief moment so the user sees the success state + setTimeout(() => { + window.location.reload(); + }, 1500); + } catch (err) { + setVerifyError(err instanceof Error ? err.message : "Verification failed. Please try again."); + } finally { + setIsVerifying(false); + } + }; + + if (!isOpen) return null; + + const dnsHost = `_atproto.${domain || "your-domain.com"}`; + const dnsValue = `did=${did}`; + + return ( +
{ if (e.target === backdropRef.current) onClose(); }} + role="dialog" + aria-modal="true" + aria-label="Use your own domain as your username" + > +
+ {/* Header */} +
+ + Use your own domain + +
+ + {/* Steps indicator */} +
+
+ 1 + Enter domain +
+
+
+ 2 + Add DNS record +
+
+
+ 3 + Verify +
+
+ + {/* Body */} +
+ {step === "enter-domain" && ( + <> +

+ You can use a domain you own as your username. For example, if you own alice.com, your + username will become @alice.com. +

+ + setDomain(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleContinue(); }} + /> +

+ Enter the domain you want to use as your username. You must have access to its DNS settings. +

+
+ + +
+ + )} + + {step === "dns-setup" && ( + <> +

+ Add the following DNS record to {domain} using your domain registrar or DNS provider (e.g. Cloudflare, Namecheap, GoDaddy). +

+ +
+
+
Type
+
TXT
+
+
+
Host
+
+ {dnsHost} + +
+
+
+
Value
+
+ {dnsValue} + +
+
+
+ +
+ +

DNS changes can take a few minutes to propagate. If verification fails, wait a moment and try again.

+
+ +
+ + +
+ + )} + + {step === "verify" && ( + <> + {isSuccess ? ( +
+ +

Domain verified!

+

+ Your username is now @{domain}. The page will reload momentarily. +

+
+ ) : ( + <> +

+ We'll check that {domain} has the correct DNS record pointing to your identity. +

+ +
+
+ Domain + {domain} +
+
+ DNS record + {dnsHost} +
+
+ + {verifyError && ( +
+ +

{verifyError}

+
+ )} + +
+ + +
+ + )} + + )} +
+
+
+ ); +} diff --git a/src/components/dashboard/username-card.tsx b/src/components/dashboard/username-card.tsx index 09ba6720..d5076c12 100644 --- a/src/components/dashboard/username-card.tsx +++ b/src/components/dashboard/username-card.tsx @@ -1,15 +1,17 @@ "use client"; import React, { useState } from "react"; -import { Pencil } from "lucide-react"; +import { Pencil, Globe } from "lucide-react"; import Button from "@/components/ui/button"; import Input from "@/components/ui/input"; import { authFetch } from "@/lib/auth/fetch"; import { clearSessionCache } from "@/hooks/use-session"; +import CustomDomainModal from "@/components/dashboard/custom-domain-modal"; interface UsernameCardProps { handle: string | null; pdsUrl?: string; + did?: string; } function isOurHandle(handle: string | null, pdsUrl?: string): boolean { @@ -25,12 +27,13 @@ function isOurHandle(handle: string | null, pdsUrl?: string): boolean { return handle.endsWith(".certified.app"); } -export default function UsernameCard({ handle, pdsUrl }: UsernameCardProps) { +export default function UsernameCard({ handle, pdsUrl, did }: UsernameCardProps) { const canEdit = isOurHandle(handle, pdsUrl); const [isEditing, setIsEditing] = useState(false); const [newHandle, setNewHandle] = useState(handle || ""); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); + const [isDomainModalOpen, setIsDomainModalOpen] = useState(false); const handleEdit = () => { setNewHandle(handle || ""); @@ -78,39 +81,62 @@ export default function UsernameCard({ handle, pdsUrl }: UsernameCardProps) { }; return ( -
-
-
-

Username

- {canEdit && !isEditing && ( - - )} -
- {isEditing ? ( -
- setNewHandle(e.target.value)} - placeholder="your-username.certified.app" - error={error ?? undefined} - /> -
- - -
+ )}
- ) : ( -

@{handle || "..."}

- )} + {isEditing ? ( +
+ setNewHandle(e.target.value)} + placeholder="your-username.certified.app" + error={error ?? undefined} + /> +
+ + +
+
+ ) : ( +

@{handle || "..."}

+ )} + + {/* Use my own domain button — always visible when not editing */} + {!isEditing && did && ( + + )} +
-
+ + {/* Custom domain modal */} + {did && ( + setIsDomainModalOpen(false)} + did={did} + /> + )} + ); } diff --git a/src/components/landing/home-client.tsx b/src/components/landing/home-client.tsx index 88b2780d..396776b9 100644 --- a/src/components/landing/home-client.tsx +++ b/src/components/landing/home-client.tsx @@ -87,7 +87,7 @@ export default function HomeClient() {
{/* Username card */} - + {/* Account Details card */}