diff --git a/portal/app/account/ChangeHandleModal.tsx b/portal/app/account/ChangeHandleModal.tsx new file mode 100644 index 0000000..d149426 --- /dev/null +++ b/portal/app/account/ChangeHandleModal.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { getMe, updateHandle, ApiError } from "../../lib/api"; + +interface Props { + currentHandle: string; + token: string; + onClose: () => void; + onSuccess: (newHandle: string) => void; +} + +const HANDLE_PATTERN = /^[a-z0-9](?:[a-z0-9_-]*[a-z0-9])?$/; + +function isValidHandleFormat(h: string): boolean { + return h.length >= 3 && h.length <= 30 && HANDLE_PATTERN.test(h); +} + +export default function ChangeHandleModal({ currentHandle, token, onClose, onSuccess }: Props) { + const [newHandle, setNewHandle] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [done, setDone] = useState(false); + const [savedHandle, setSavedHandle] = useState(""); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + const trimmed = newHandle.trim().toLowerCase().replace(/^@+/, ""); + if (!isValidHandleFormat(trimmed)) { + setError("3-30 characters: lowercase letters, digits, hyphens, or underscores. Cannot start or end with a separator."); + return; + } + + setLoading(true); + setError(""); + try { + await updateHandle(trimmed, token); + } catch (err) { + if (err instanceof ApiError && err.status === 402) { + setError("Custom handles require Pro or above."); + } else if (err instanceof ApiError && err.status === 409) { + setError("That handle is taken."); + } else if (err instanceof ApiError && err.status === 422) { + setError("That handle isn't valid."); + } else if (err instanceof ApiError && err.status === 429) { + setError("You can only rename your handle once every 30 days."); + } else { + setError(err instanceof Error ? err.message : "Failed to update handle."); + } + setLoading(false); + return; + } + + // The rename already committed above. This refetch is best-effort display + // polish — its failure must not be reported as the rename having failed. + let handle = trimmed; + try { + const me = await getMe(token); + handle = me.handle; + } catch { + // fall back to the trimmed input we just saved + } + setSavedHandle(handle); + setDone(true); + setLoading(false); + onSuccess(handle); + } + + return ( +
Current: @{currentHandle}
+ + {done ? ( +Handle updated to @{savedHandle}.
+ +— account
Display name
- {editingDisplayName ? ( -Handle
+{accountHandle ? `@${accountHandle}` : "—"}
+ {displayTier === "free" && ( +Custom handles are a Pro feature.
+Others can still reach you at this handle.
{accountDisplayName || "—"}
)} - {displayNameError &&{displayNameError}
}