From 8b3a81a62f4f87fc17b4d5fef15d15f2b8e7bd15 Mon Sep 17 00:00:00 2001 From: y3drk Date: Fri, 9 Jan 2026 17:42:12 +0100 Subject: [PATCH 1/7] Copying shared components from ENSAdmin & ENSAwards, pt.1 --- packages/namehash-ui/biome.jsonc | 1 + packages/namehash-ui/components.json | 21 +++ packages/namehash-ui/package.json | 21 ++- .../src/components/chains/ChainIcon.tsx | 70 ++++++++++ .../components/chains/icons/ArbitrumIcon.tsx | 42 ++++++ .../chains/icons/ArbitrumTestnetIcon.tsx | 42 ++++++ .../src/components/chains/icons/BaseIcon.tsx | 18 +++ .../chains/icons/BaseTestnetIcon.tsx | 28 ++++ .../components/chains/icons/EthereumIcon.tsx | 22 ++++ .../chains/icons/EthereumLocalIcon.tsx | 28 ++++ .../chains/icons/EthereumTestnetIcon.tsx | 28 ++++ .../src/components/chains/icons/LineaIcon.tsx | 34 +++++ .../chains/icons/LineaTestnetIcon.tsx | 42 ++++++ .../components/chains/icons/OptimismIcon.tsx | 28 ++++ .../chains/icons/OptimismTestnetIcon.tsx | 28 ++++ .../components/chains/icons/ScrollIcon.tsx | 40 ++++++ .../chains/icons/ScrollTestnetIcon.tsx | 40 ++++++ .../chains/icons/UnrecognizedChainIcon.tsx | 20 +++ .../src/components/datetime/AbsoluteTime.tsx | 24 ++++ .../components/datetime/DisplayDuration.tsx | 23 ++++ .../components/icons/ChainExplorerIcon.tsx | 29 +++++ .../src/components/icons/InfoIcon.tsx | 27 ++++ .../src/components/identity/EnsAvatar.tsx | 76 +++++++++++ .../namehash-ui/src/components/ui/avatar.tsx | 45 +++++++ packages/namehash-ui/src/index.ts | 6 + packages/namehash-ui/src/styles.css | 123 +++++++++++++++++- packages/namehash-ui/src/utils/cn.ts | 6 + packages/namehash-ui/src/utils/namespace.ts | 32 +++++ packages/namehash-ui/tsconfig.json | 6 +- pnpm-lock.yaml | 72 +++++++--- 30 files changed, 998 insertions(+), 24 deletions(-) create mode 100644 packages/namehash-ui/components.json create mode 100644 packages/namehash-ui/src/components/chains/ChainIcon.tsx create mode 100644 packages/namehash-ui/src/components/chains/icons/ArbitrumIcon.tsx create mode 100644 packages/namehash-ui/src/components/chains/icons/ArbitrumTestnetIcon.tsx create mode 100644 packages/namehash-ui/src/components/chains/icons/BaseIcon.tsx create mode 100644 packages/namehash-ui/src/components/chains/icons/BaseTestnetIcon.tsx create mode 100644 packages/namehash-ui/src/components/chains/icons/EthereumIcon.tsx create mode 100644 packages/namehash-ui/src/components/chains/icons/EthereumLocalIcon.tsx create mode 100644 packages/namehash-ui/src/components/chains/icons/EthereumTestnetIcon.tsx create mode 100644 packages/namehash-ui/src/components/chains/icons/LineaIcon.tsx create mode 100644 packages/namehash-ui/src/components/chains/icons/LineaTestnetIcon.tsx create mode 100644 packages/namehash-ui/src/components/chains/icons/OptimismIcon.tsx create mode 100644 packages/namehash-ui/src/components/chains/icons/OptimismTestnetIcon.tsx create mode 100644 packages/namehash-ui/src/components/chains/icons/ScrollIcon.tsx create mode 100644 packages/namehash-ui/src/components/chains/icons/ScrollTestnetIcon.tsx create mode 100644 packages/namehash-ui/src/components/chains/icons/UnrecognizedChainIcon.tsx create mode 100644 packages/namehash-ui/src/components/datetime/AbsoluteTime.tsx create mode 100644 packages/namehash-ui/src/components/datetime/DisplayDuration.tsx create mode 100644 packages/namehash-ui/src/components/icons/ChainExplorerIcon.tsx create mode 100644 packages/namehash-ui/src/components/icons/InfoIcon.tsx create mode 100644 packages/namehash-ui/src/components/identity/EnsAvatar.tsx create mode 100644 packages/namehash-ui/src/components/ui/avatar.tsx create mode 100644 packages/namehash-ui/src/utils/cn.ts create mode 100644 packages/namehash-ui/src/utils/namespace.ts diff --git a/packages/namehash-ui/biome.jsonc b/packages/namehash-ui/biome.jsonc index 887097697..280d05b4d 100644 --- a/packages/namehash-ui/biome.jsonc +++ b/packages/namehash-ui/biome.jsonc @@ -3,6 +3,7 @@ "extends": "//", "files": { "includes": [ + "src/**/*", "!src/styles.css" ] } diff --git a/packages/namehash-ui/components.json b/packages/namehash-ui/components.json new file mode 100644 index 000000000..b8c8d7c42 --- /dev/null +++ b/packages/namehash-ui/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/styles.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "nhui" + }, + "aliases": { + "components": "@/components", + "utils": "@/utils/cn", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} \ No newline at end of file diff --git a/packages/namehash-ui/package.json b/packages/namehash-ui/package.json index 2686591bb..029bacf8c 100644 --- a/packages/namehash-ui/package.json +++ b/packages/namehash-ui/package.json @@ -57,16 +57,29 @@ }, "devDependencies": { "@ensnode/shared-configs": "workspace:*", + "@tailwindcss/postcss": "^4.1.18", "@testing-library/react": "catalog:", "@types/node": "catalog:", "@types/react": "19.2.7", "@types/react-dom": "19.2.3", + "postcss": "^8.5.6", "react": "19.2.1", + "tailwindcss": "^4.1.18", "tsup": "^8.3.6", "typescript": "catalog:", - "@tailwindcss/postcss": "^4.1.18", - "postcss": "^8.5.6", - "tailwindcss": "^4.1.18" + "viem": "catalog:" }, - "dependencies": {} + "dependencies": { + "@ensnode/datasources": "workspace:*", + "@ensnode/ensnode-sdk": "workspace:*", + "@radix-ui/react-avatar": "^1.1.10", + "boring-avatars": "^2.0.4", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "date-fns": "^4.1.0", + "lucide-react": "^0.548.0", + "tailwind-merge": "^3.4.0", + "tailwindcss-animate": "^1.0.7", + "tw-animate-css": "^1.4.0" + } } diff --git a/packages/namehash-ui/src/components/chains/ChainIcon.tsx b/packages/namehash-ui/src/components/chains/ChainIcon.tsx new file mode 100644 index 000000000..2020d5764 --- /dev/null +++ b/packages/namehash-ui/src/components/chains/ChainIcon.tsx @@ -0,0 +1,70 @@ +import { + arbitrum, + arbitrumSepolia, + base, + baseSepolia, + linea, + lineaSepolia, + mainnet, + optimism, + optimismSepolia, + scroll, + scrollSepolia, + sepolia, +} from "viem/chains"; + +import { ensTestEnvL1Chain } from "@ensnode/datasources"; + +import { ArbitrumIcon } from "./icons/ArbitrumIcon.tsx"; +import { ArbitrumTestnetIcon } from "./icons/ArbitrumTestnetIcon.tsx"; +import { BaseIcon } from "./icons/BaseIcon.tsx"; +import { BaseTestnetIcon } from "./icons/BaseTestnetIcon.tsx"; +import { EthereumIcon } from "./icons/EthereumIcon.tsx"; +import { EthereumLocalIcon } from "./icons/EthereumLocalIcon.tsx"; +import { EthereumTestnetIcon } from "./icons/EthereumTestnetIcon.tsx"; +import { LineaIcon } from "./icons/LineaIcon.tsx"; +import { LineaTestnetIcon } from "./icons/LineaTestnetIcon.tsx"; +import { OptimismIcon } from "./icons/OptimismIcon.tsx"; +import { OptimismTestnetIcon } from "./icons/OptimismTestnetIcon.tsx"; +import { ScrollIcon } from "./icons/ScrollIcon.tsx"; +import { ScrollTestnetIcon } from "./icons/ScrollTestnetIcon.tsx"; +import { UnrecognizedChainIcon } from "./icons/UnrecognizedChainIcon.tsx"; + +export interface ChainIconProps { + chainId: number; + width?: number; + height?: number; +} + +/** + * Mapping of chain id to chain icon. + * Chain id standards are organized by the Ethereum Community @ https://github.com/ethereum-lists/chains + */ +const chainIcons = new Map>>([ + // mainnet + [mainnet.id, EthereumIcon], + [base.id, BaseIcon], + [linea.id, LineaIcon], + [optimism.id, OptimismIcon], + [arbitrum.id, ArbitrumIcon], + [scroll.id, ScrollIcon], + + // sepolia + [sepolia.id, EthereumTestnetIcon], + [baseSepolia.id, BaseTestnetIcon], + [lineaSepolia.id, LineaTestnetIcon], + [optimismSepolia.id, OptimismTestnetIcon], + [arbitrumSepolia.id, ArbitrumTestnetIcon], + [scrollSepolia.id, ScrollTestnetIcon], + + // ens-test-env + [ensTestEnvL1Chain.id, EthereumLocalIcon], +]); + +/** + * Renders an icon for the provided chain ID. + */ +export function ChainIcon({ chainId, width = 20, height = 20 }: ChainIconProps) { + const Icon = chainIcons.get(chainId) || UnrecognizedChainIcon; + return ; +} diff --git a/packages/namehash-ui/src/components/chains/icons/ArbitrumIcon.tsx b/packages/namehash-ui/src/components/chains/icons/ArbitrumIcon.tsx new file mode 100644 index 000000000..0518a4316 --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/ArbitrumIcon.tsx @@ -0,0 +1,42 @@ +import type React from "react"; + +export const ArbitrumIcon = (props: React.SVGProps) => ( + + + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/ArbitrumTestnetIcon.tsx b/packages/namehash-ui/src/components/chains/icons/ArbitrumTestnetIcon.tsx new file mode 100644 index 000000000..fe5beb5ee --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/ArbitrumTestnetIcon.tsx @@ -0,0 +1,42 @@ +import type React from "react"; + +export const ArbitrumTestnetIcon = (props: React.SVGProps) => ( + + + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/BaseIcon.tsx b/packages/namehash-ui/src/components/chains/icons/BaseIcon.tsx new file mode 100644 index 000000000..a0fed3979 --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/BaseIcon.tsx @@ -0,0 +1,18 @@ +import type React from "react"; + +export const BaseIcon = (props: React.SVGProps) => ( + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/BaseTestnetIcon.tsx b/packages/namehash-ui/src/components/chains/icons/BaseTestnetIcon.tsx new file mode 100644 index 000000000..841bf885e --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/BaseTestnetIcon.tsx @@ -0,0 +1,28 @@ +import type React from "react"; + +export const BaseTestnetIcon = (props: React.SVGProps) => ( + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/EthereumIcon.tsx b/packages/namehash-ui/src/components/chains/icons/EthereumIcon.tsx new file mode 100644 index 000000000..77a1ac8de --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/EthereumIcon.tsx @@ -0,0 +1,22 @@ +import type React from "react"; + +// +// Creator: CorelDRAW 2019 (64-Bit) +// +// xmlns:Xodm={"http://www.corel.com/coreldraw/odm/2003"} + +export const EthereumIcon = (props: React.SVGProps) => ( + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/EthereumLocalIcon.tsx b/packages/namehash-ui/src/components/chains/icons/EthereumLocalIcon.tsx new file mode 100644 index 000000000..a2231c729 --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/EthereumLocalIcon.tsx @@ -0,0 +1,28 @@ +import type React from "react"; + +export const EthereumLocalIcon = (props: React.SVGProps) => ( + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/EthereumTestnetIcon.tsx b/packages/namehash-ui/src/components/chains/icons/EthereumTestnetIcon.tsx new file mode 100644 index 000000000..15c6e7fce --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/EthereumTestnetIcon.tsx @@ -0,0 +1,28 @@ +import type React from "react"; + +export const EthereumTestnetIcon = (props: React.SVGProps) => ( + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/LineaIcon.tsx b/packages/namehash-ui/src/components/chains/icons/LineaIcon.tsx new file mode 100644 index 000000000..d4c268d76 --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/LineaIcon.tsx @@ -0,0 +1,34 @@ +import type React from "react"; + +export const LineaIcon = (props: React.SVGProps) => ( + + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/LineaTestnetIcon.tsx b/packages/namehash-ui/src/components/chains/icons/LineaTestnetIcon.tsx new file mode 100644 index 000000000..ef4ebe483 --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/LineaTestnetIcon.tsx @@ -0,0 +1,42 @@ +import type React from "react"; + +export const LineaTestnetIcon = (props: React.SVGProps) => ( + + + + + + + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/OptimismIcon.tsx b/packages/namehash-ui/src/components/chains/icons/OptimismIcon.tsx new file mode 100644 index 000000000..e231c4eba --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/OptimismIcon.tsx @@ -0,0 +1,28 @@ +import type React from "react"; + +export const OptimismIcon = (props: React.SVGProps) => ( + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/OptimismTestnetIcon.tsx b/packages/namehash-ui/src/components/chains/icons/OptimismTestnetIcon.tsx new file mode 100644 index 000000000..793e1669d --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/OptimismTestnetIcon.tsx @@ -0,0 +1,28 @@ +import type React from "react"; + +export const OptimismTestnetIcon = (props: React.SVGProps) => ( + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/ScrollIcon.tsx b/packages/namehash-ui/src/components/chains/icons/ScrollIcon.tsx new file mode 100644 index 000000000..1e6f00617 --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/ScrollIcon.tsx @@ -0,0 +1,40 @@ +import type React from "react"; + +export const ScrollIcon = (props: React.SVGProps) => ( + + + + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/ScrollTestnetIcon.tsx b/packages/namehash-ui/src/components/chains/icons/ScrollTestnetIcon.tsx new file mode 100644 index 000000000..ef752793c --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/ScrollTestnetIcon.tsx @@ -0,0 +1,40 @@ +import type React from "react"; + +export const ScrollTestnetIcon = (props: React.SVGProps) => ( + + + + + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/chains/icons/UnrecognizedChainIcon.tsx b/packages/namehash-ui/src/components/chains/icons/UnrecognizedChainIcon.tsx new file mode 100644 index 000000000..5125ea551 --- /dev/null +++ b/packages/namehash-ui/src/components/chains/icons/UnrecognizedChainIcon.tsx @@ -0,0 +1,20 @@ +import type React from "react"; + +export const UnrecognizedChainIcon = (props: React.SVGProps) => ( + + + +); diff --git a/packages/namehash-ui/src/components/datetime/AbsoluteTime.tsx b/packages/namehash-ui/src/components/datetime/AbsoluteTime.tsx new file mode 100644 index 000000000..4a568ecd2 --- /dev/null +++ b/packages/namehash-ui/src/components/datetime/AbsoluteTime.tsx @@ -0,0 +1,24 @@ +import { fromUnixTime, intlFormat } from "date-fns"; +import { useEffect, useState } from "react"; + +import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; + +/** + * Client-only absolute time component + */ +export function AbsoluteTime({ + timestamp, + options, +}: { + timestamp: UnixTimestamp; + options: Intl.DateTimeFormatOptions; +}) { + const date = fromUnixTime(timestamp); + const [absoluteTime, setAbsoluteTime] = useState(""); + + useEffect(() => { + setAbsoluteTime(intlFormat(date, options)); + }, [date, options]); + + return <>{absoluteTime}; +} diff --git a/packages/namehash-ui/src/components/datetime/DisplayDuration.tsx b/packages/namehash-ui/src/components/datetime/DisplayDuration.tsx new file mode 100644 index 000000000..134cade25 --- /dev/null +++ b/packages/namehash-ui/src/components/datetime/DisplayDuration.tsx @@ -0,0 +1,23 @@ +import { formatDistanceStrict, fromUnixTime } from "date-fns"; +import { useEffect, useState } from "react"; + +import type { Duration } from "@ensnode/ensnode-sdk"; + +/** + * Display Duration component + */ +export function DisplayDuration({ duration }: { duration: Duration }) { + const [timeDistance, setTimeDistance] = useState(""); + + // formatDistanceStrict needs two UnixTimestamp values + // so we create `beginsAt` and `endsAt` timestamps + // where `beginsAt = endsAt - duration` + const beginsAt = fromUnixTime(0); + const endsAt = fromUnixTime(duration); + + useEffect(() => { + setTimeDistance(formatDistanceStrict(beginsAt, endsAt)); + }, [beginsAt, endsAt]); + + return timeDistance; +} diff --git a/packages/namehash-ui/src/components/icons/ChainExplorerIcon.tsx b/packages/namehash-ui/src/components/icons/ChainExplorerIcon.tsx new file mode 100644 index 000000000..97f7e473a --- /dev/null +++ b/packages/namehash-ui/src/components/icons/ChainExplorerIcon.tsx @@ -0,0 +1,29 @@ +import type { LucideIcon, LucideProps } from "lucide-react"; +import * as React from "react"; + +export const ChainExplorerIcon: LucideIcon = React.forwardRef( + (props, ref) => { + return ( + + + + + ); + }, +); + +ChainExplorerIcon.displayName = "Chain Explorer Icon"; diff --git a/packages/namehash-ui/src/components/icons/InfoIcon.tsx b/packages/namehash-ui/src/components/icons/InfoIcon.tsx new file mode 100644 index 000000000..8738f982f --- /dev/null +++ b/packages/namehash-ui/src/components/icons/InfoIcon.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from "react"; + +export const InfoIcon = (props: SVGProps) => ( + + + + + + + + + + +); diff --git a/packages/namehash-ui/src/components/identity/EnsAvatar.tsx b/packages/namehash-ui/src/components/identity/EnsAvatar.tsx new file mode 100644 index 000000000..326e63957 --- /dev/null +++ b/packages/namehash-ui/src/components/identity/EnsAvatar.tsx @@ -0,0 +1,76 @@ +import BoringAvatar from "boring-avatars"; +import * as React from "react"; + +import type { ENSNamespaceId } from "@ensnode/datasources"; +import type { Name } from "@ensnode/ensnode-sdk"; + +import { Avatar, AvatarImage } from "@/components/ui/avatar.tsx"; +import { cn } from "@/utils/cn.ts"; +import { buildEnsMetadataServiceAvatarUrl } from "@/utils/namespace.ts"; + +interface EnsAvatarProps { + name: Name; + namespaceId: ENSNamespaceId; + className?: string; + isSquare?: boolean; +} + +type ImageLoadingStatus = Parameters< + NonNullable["onLoadingStatusChange"]> +>[0]; + +export const EnsAvatar = ({ name, namespaceId, className, isSquare = false }: EnsAvatarProps) => { + const [loadingStatus, setLoadingStatus] = React.useState("idle"); + const avatarUrl = buildEnsMetadataServiceAvatarUrl(name, namespaceId); + + if (avatarUrl === null) { + return ( + + + + ); + } + + return ( + + { + setLoadingStatus(status); + }} + /> + {loadingStatus === "error" && } + {(loadingStatus === "idle" || loadingStatus === "loading") && ( + + )} + + ); +}; + +interface EnsAvatarFallbackProps { + name: Name; + isSquare: boolean; +} + +const avatarFallbackColors = ["#000000", "#bedbff", "#5191c1", "#1e6495", "#0a4b75"]; + +const EnsAvatarFallback = ({ name, isSquare }: EnsAvatarFallbackProps) => ( + +); + +type EnsAvatarLoadingProps = Omit; +const AvatarLoading = ({ className }: EnsAvatarLoadingProps) => ( +
+); diff --git a/packages/namehash-ui/src/components/ui/avatar.tsx b/packages/namehash-ui/src/components/ui/avatar.tsx new file mode 100644 index 000000000..2fbf2fea6 --- /dev/null +++ b/packages/namehash-ui/src/components/ui/avatar.tsx @@ -0,0 +1,45 @@ +import * as AvatarPrimitive from "@radix-ui/react-avatar"; +import type * as React from "react"; + +import { cn } from "@/utils/cn"; + +function Avatar({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarImage({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { Avatar, AvatarImage, AvatarFallback }; diff --git a/packages/namehash-ui/src/index.ts b/packages/namehash-ui/src/index.ts index 58d5bf00e..18e2d2e8d 100644 --- a/packages/namehash-ui/src/index.ts +++ b/packages/namehash-ui/src/index.ts @@ -1,3 +1,9 @@ import "./styles.css"; +export * from "./components/chains/ChainIcon"; +export * from "./components/datetime/AbsoluteTime"; +export * from "./components/datetime/DisplayDuration"; +export * from "./components/icons/ChainExplorerIcon"; +export * from "./components/icons/InfoIcon"; +export * from "./components/identity/EnsAvatar"; export * from "./components/placeholder/Placeholder"; diff --git a/packages/namehash-ui/src/styles.css b/packages/namehash-ui/src/styles.css index fa881cfa9..a0284e51d 100644 --- a/packages/namehash-ui/src/styles.css +++ b/packages/namehash-ui/src/styles.css @@ -1,2 +1,123 @@ @import "tailwindcss" prefix(nhui); -/*directly following https://tailwindcss.com/docs/upgrade-guide#using-a-prefix*/ +@import "tw-animate-css"; +@plugin "tailwindcss-animate"; +@custom-variant dark (&:is(.dark *)); + +:root { + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --destructive-foreground: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --chart-1: oklch(0.646 0.222 41.116); + --chart-2: oklch(0.6 0.118 184.704); + --chart-3: oklch(0.398 0.07 227.392); + --chart-4: oklch(0.828 0.189 84.429); + --chart-5: oklch(0.769 0.188 70.08); + --radius: 0.625rem; + --sidebar: oklch(0.985 0 0); + --sidebar-foreground: oklch(0.145 0 0); + --sidebar-primary: oklch(0.205 0 0); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.97 0 0); + --sidebar-accent-foreground: oklch(0.205 0 0); + --sidebar-border: oklch(0.922 0 0); + --sidebar-ring: oklch(0.708 0 0); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.145 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.145 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.985 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.396 0.141 25.723); + --destructive-foreground: oklch(0.637 0.237 25.331); + --border: oklch(0.269 0 0); + --input: oklch(0.269 0 0); + --ring: oklch(0.439 0 0); + --chart-1: oklch(0.488 0.243 264.376); + --chart-2: oklch(0.696 0.17 162.48); + --chart-3: oklch(0.769 0.188 70.08); + --chart-4: oklch(0.627 0.265 303.9); + --chart-5: oklch(0.645 0.246 16.439); + --sidebar: oklch(0.205 0 0); + --sidebar-foreground: oklch(0.985 0 0); + --sidebar-primary: oklch(0.488 0.243 264.376); + --sidebar-primary-foreground: oklch(0.985 0 0); + --sidebar-accent: oklch(0.269 0 0); + --sidebar-accent-foreground: oklch(0.985 0 0); + --sidebar-border: oklch(0.269 0 0); + --sidebar-ring: oklch(0.439 0 0); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + } + body { + @apply bg-background text-foreground; + } +} diff --git a/packages/namehash-ui/src/utils/cn.ts b/packages/namehash-ui/src/utils/cn.ts new file mode 100644 index 000000000..365058ceb --- /dev/null +++ b/packages/namehash-ui/src/utils/cn.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/packages/namehash-ui/src/utils/namespace.ts b/packages/namehash-ui/src/utils/namespace.ts new file mode 100644 index 000000000..de504d81c --- /dev/null +++ b/packages/namehash-ui/src/utils/namespace.ts @@ -0,0 +1,32 @@ +import type { ENSNamespaceId } from "@ensnode/datasources"; +import { ENSNamespaceIds, type Name } from "@ensnode/ensnode-sdk"; + +/** + * Build the avatar image URL for a name on the given ENS Namespace that (once fetched) would + * load the avatar image for the given name from the ENS Metadata Service + * (https://metadata.ens.domains/docs). + * + * The returned URL is dynamically built based on the provided ENS namespace. Not all ENS + * namespaces are supported by the ENS Metadata Service. Therefore, the returned URL may + * be null. + * + * @param {Name} name - ENS name to build the avatar image URL for + * @param {ENSNamespaceId} namespaceId - ENS Namespace identifier + * @returns avatar image URL for the name on the given ENS Namespace, or null if the given + * ENS namespace is not supported by the ENS Metadata Service + */ +export function buildEnsMetadataServiceAvatarUrl( + name: Name, + namespaceId: ENSNamespaceId, +): URL | null { + switch (namespaceId) { + case ENSNamespaceIds.Mainnet: + return new URL(name, `https://metadata.ens.domains/mainnet/avatar/`); + case ENSNamespaceIds.Sepolia: + return new URL(name, `https://metadata.ens.domains/sepolia/avatar/`); + case ENSNamespaceIds.EnsTestEnv: + // ens-test-env runs on a local chain and is not supported by metadata.ens.domains + // TODO: Above comment is not true. Details at https://github.com/namehash/ensnode/issues/1078 + return null; + } +} diff --git a/packages/namehash-ui/tsconfig.json b/packages/namehash-ui/tsconfig.json index 3e92d51dd..ec1ef20e1 100644 --- a/packages/namehash-ui/tsconfig.json +++ b/packages/namehash-ui/tsconfig.json @@ -3,7 +3,11 @@ "compilerOptions": { "jsx": "react-jsx", "rootDir": ".", // necessary for 'The project root is ambiguous' - "types": ["react"] + "types": ["react"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } }, "include": ["./**/*.ts", "./**/*.tsx"], "exclude": ["dist", "**/__tests__/**", "**/examples/**"] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b72c3b10f..f692ac47c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -891,12 +891,45 @@ importers: packages/namehash-ui: dependencies: + '@ensnode/datasources': + specifier: workspace:* + version: link:../datasources '@ensnode/ensnode-react': specifier: workspace:* version: link:../ensnode-react + '@ensnode/ensnode-sdk': + specifier: workspace:* + version: link:../ensnode-sdk + '@radix-ui/react-avatar': + specifier: ^1.1.10 + version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + boring-avatars: + specifier: ^2.0.4 + version: 2.0.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + class-variance-authority: + specifier: ^0.7.1 + version: 0.7.1 + clsx: + specifier: ^2.1.1 + version: 2.1.1 + date-fns: + specifier: ^4.1.0 + version: 4.1.0 + lucide-react: + specifier: ^0.548.0 + version: 0.548.0(react@19.2.1) react-dom: specifier: ^19.0.0 version: 19.2.1(react@19.2.1) + tailwind-merge: + specifier: ^3.4.0 + version: 3.4.0 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7(tailwindcss@4.1.18) + tw-animate-css: + specifier: ^1.4.0 + version: 1.4.0 devDependencies: '@ensnode/shared-configs': specifier: workspace:* @@ -931,6 +964,9 @@ importers: typescript: specifier: 'catalog:' version: 5.9.3 + viem: + specifier: 'catalog:' + version: 2.38.5(typescript@5.9.3)(zod@3.25.76) packages/ponder-metadata: dependencies: @@ -4601,6 +4637,12 @@ packages: boring-avatars@1.11.2: resolution: {integrity: sha512-3+wkwPeObwS4R37FGXMYViqc4iTrIRj5yzfX9Qy4mnpZ26sX41dGMhsAgmKks1r/uufY1pl4vpgzMWHYfJRb2A==} + boring-avatars@2.0.4: + resolution: {integrity: sha512-xhZO/w/6aFmRfkaWohcl2NfyIy87gK5SBbys8kctZeTGF1Apjpv/10pfUuv+YEfVPkESU/h2Y6tt/Dwp+bIZPw==} + peerDependencies: + react: '>=18.0.0' + react-dom: '>=18.0.0' + bowser@2.13.1: resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} @@ -7986,6 +8028,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tw-animate-css@1.4.0: + resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} + type-fest@0.7.1: resolution: {integrity: sha512-Ne2YiiGN8bmrmJJEuTWTLJR32nh/JdL1+PSicowtNb0WFpn59GK8/lfD61bVtzguz7b3PBt74nxpv/Pw5po5Rg==} engines: {node: '>=8'} @@ -12633,22 +12678,6 @@ snapshots: chai: 6.2.0 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.5(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': - dependencies: - '@vitest/spy': 4.0.5 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) - - '@vitest/mocker@4.0.5(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': - dependencies: - '@vitest/spy': 4.0.5 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) - '@vitest/mocker@4.0.5(vite@7.1.12(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.5 @@ -13130,6 +13159,11 @@ snapshots: boring-avatars@1.11.2: {} + boring-avatars@2.0.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1): + dependencies: + react: 19.2.1 + react-dom: 19.2.1(react@19.2.1) + bowser@2.13.1: {} boxen@7.0.0: @@ -17198,6 +17232,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tw-animate-css@1.4.0: {} + type-fest@0.7.1: {} type-fest@2.19.0: {} @@ -17592,7 +17628,7 @@ snapshots: vitest@4.0.5(@types/debug@4.1.12)(@types/node@20.19.24)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.5 - '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.5 '@vitest/runner': 4.0.5 '@vitest/snapshot': 4.0.5 @@ -17632,7 +17668,7 @@ snapshots: vitest@4.0.5(@types/debug@4.1.12)(@types/node@22.18.13)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.5 - '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.5 '@vitest/runner': 4.0.5 '@vitest/snapshot': 4.0.5 From 0a734ff0c4289b0e1991bb414bfe0550ad66b671 Mon Sep 17 00:00:00 2001 From: y3drk Date: Fri, 9 Jan 2026 17:53:11 +0100 Subject: [PATCH 2/7] Fix the prepublish error --- packages/namehash-ui/src/styles.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/namehash-ui/src/styles.css b/packages/namehash-ui/src/styles.css index a0284e51d..ac65af0a0 100644 --- a/packages/namehash-ui/src/styles.css +++ b/packages/namehash-ui/src/styles.css @@ -115,9 +115,9 @@ @layer base { * { - @apply border-border outline-ring/50; + @apply nhui:border-border nhui:outline-ring/50; } body { - @apply bg-background text-foreground; + @apply nhui:bg-background nhui:text-foreground; } } From 7b80b70380b7e8a269158a8427b27dcdc66d5672 Mon Sep 17 00:00:00 2001 From: y3drk Date: Mon, 12 Jan 2026 14:40:54 +0100 Subject: [PATCH 3/7] Copying shared components from ENSAdmin & ENSAwards, pt.2 --- packages/namehash-ui/README.md | 4 +- packages/namehash-ui/package.json | 5 +- .../src/components/chains/ChainName.tsx | 13 + .../src/components/datetime/RelativeTime.tsx | 144 +++++++ .../src/components/footer/Footer.tsx | 196 ++++++++++ .../src/components/icons/InfoIcon.tsx | 2 +- .../src/components/icons/ens/EnsIcon.tsx | 39 ++ .../icons/ens/EnsServiceProviderIcon.tsx | 108 ++++++ .../icons/namehash/NameHashLabsIcon.tsx | 67 ++++ .../src/components/icons/socials/EfpIcon.tsx | 30 ++ .../components/icons/socials/EmailIcon.tsx | 24 ++ .../icons/socials/FarcasterIcon.tsx | 27 ++ .../components/icons/socials/GitHubIcon.tsx | 21 + .../components/icons/socials/TelegramIcon.tsx | 19 + .../components/icons/socials/TwitterIcon.tsx | 12 + .../src/components/identity/EnsAvatar.tsx | 2 + .../identity/ResolveAndDisplayIdentity.tsx | 234 +++++++++++ .../src/components/identity/utils.tsx | 193 +++++++++ .../components/placeholder/Placeholder.tsx | 7 - .../registrar-actions/RegistrarActionCard.tsx | 365 ++++++++++++++++++ .../components/special-buttons/CopyButton.tsx | 73 ++++ .../namehash-ui/src/components/ui/button.tsx | 63 +++ .../src/components/ui/skeleton.tsx | 13 + .../namehash-ui/src/components/ui/tooltip.tsx | 57 +++ packages/namehash-ui/src/hooks/useMobile.tsx | 19 + packages/namehash-ui/src/index.ts | 14 +- packages/namehash-ui/src/utils/namespace.ts | 154 +++++++- pnpm-lock.yaml | 9 + 28 files changed, 1901 insertions(+), 13 deletions(-) create mode 100644 packages/namehash-ui/src/components/chains/ChainName.tsx create mode 100644 packages/namehash-ui/src/components/datetime/RelativeTime.tsx create mode 100644 packages/namehash-ui/src/components/footer/Footer.tsx create mode 100644 packages/namehash-ui/src/components/icons/ens/EnsIcon.tsx create mode 100644 packages/namehash-ui/src/components/icons/ens/EnsServiceProviderIcon.tsx create mode 100644 packages/namehash-ui/src/components/icons/namehash/NameHashLabsIcon.tsx create mode 100644 packages/namehash-ui/src/components/icons/socials/EfpIcon.tsx create mode 100644 packages/namehash-ui/src/components/icons/socials/EmailIcon.tsx create mode 100644 packages/namehash-ui/src/components/icons/socials/FarcasterIcon.tsx create mode 100644 packages/namehash-ui/src/components/icons/socials/GitHubIcon.tsx create mode 100644 packages/namehash-ui/src/components/icons/socials/TelegramIcon.tsx create mode 100644 packages/namehash-ui/src/components/icons/socials/TwitterIcon.tsx create mode 100644 packages/namehash-ui/src/components/identity/ResolveAndDisplayIdentity.tsx create mode 100644 packages/namehash-ui/src/components/identity/utils.tsx delete mode 100644 packages/namehash-ui/src/components/placeholder/Placeholder.tsx create mode 100644 packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx create mode 100644 packages/namehash-ui/src/components/special-buttons/CopyButton.tsx create mode 100644 packages/namehash-ui/src/components/ui/button.tsx create mode 100644 packages/namehash-ui/src/components/ui/skeleton.tsx create mode 100644 packages/namehash-ui/src/components/ui/tooltip.tsx create mode 100644 packages/namehash-ui/src/hooks/useMobile.tsx diff --git a/packages/namehash-ui/README.md b/packages/namehash-ui/README.md index 59f8a4879..3e976e353 100644 --- a/packages/namehash-ui/README.md +++ b/packages/namehash-ui/README.md @@ -7,10 +7,10 @@ For UI component libraries intended for the general public, we recommend [ensnod ## Installation ```bash -npm install @namehash/namehash-ui @ensnode/ensnode-react +npm install @namehash/namehash-ui @ensnode/ensnode-react sonner ``` -Note: `@ensnode/ensnode-react` is necessary only for some components. It might happen that you won't need it. +Note: `@ensnode/ensnode-react` is necessary only for some components. It might happen that you won't need it. Same goes for `sonner` it's only necessary for `CopyButton` component. ## Setup diff --git a/packages/namehash-ui/package.json b/packages/namehash-ui/package.json index 029bacf8c..128054299 100644 --- a/packages/namehash-ui/package.json +++ b/packages/namehash-ui/package.json @@ -53,7 +53,8 @@ "peerDependencies": { "@ensnode/ensnode-react": "workspace:*", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "sonner": "^2.0.3" }, "devDependencies": { "@ensnode/shared-configs": "workspace:*", @@ -73,6 +74,8 @@ "@ensnode/datasources": "workspace:*", "@ensnode/ensnode-sdk": "workspace:*", "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tooltip": "^1.2.8", "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/packages/namehash-ui/src/components/chains/ChainName.tsx b/packages/namehash-ui/src/components/chains/ChainName.tsx new file mode 100644 index 000000000..9499d932d --- /dev/null +++ b/packages/namehash-ui/src/components/chains/ChainName.tsx @@ -0,0 +1,13 @@ +import { getChainName } from "@/utils/namespace.ts"; + +export interface ChainNameProps { + chainId: number; + className: string; +} + +/** + * Renders a prettified chain name for the provided chain ID. + */ +export const ChainName = ({ chainId, className }: ChainNameProps) => ( +

{getChainName(chainId)}

+); diff --git a/packages/namehash-ui/src/components/datetime/RelativeTime.tsx b/packages/namehash-ui/src/components/datetime/RelativeTime.tsx new file mode 100644 index 000000000..32df4c3e2 --- /dev/null +++ b/packages/namehash-ui/src/components/datetime/RelativeTime.tsx @@ -0,0 +1,144 @@ +import { formatDistance, formatDistanceStrict, fromUnixTime } from "date-fns"; +import { millisecondsInSecond } from "date-fns/constants"; +import type * as React from "react"; +import { useEffect, useState } from "react"; + +import type { UnixTimestamp } from "@ensnode/ensnode-sdk"; + +import { AbsoluteTime } from "@/components/datetime/AbsoluteTime.tsx"; +import { Tooltip, TooltipArrow, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx"; +import { cn } from "@/utils/cn.ts"; + +/** + * Formats a Unix timestamp as its relative distance with now + * + * @param timestamp - the timestamp to format as a relative time + * @param enforcePast - if true, enforces that the return value won't relate to the future. + * Helpful for UI contexts where it's nonsensical for a value to relate to the future. Ex: how long ago an event happened. + * Note how different systems may have misaligned clocks. `enforcePast` aims to protect from UI confusion when + * the client's clock is set incorrectly in the past, such that events happening "now" might otherwise appear to + * be coming from the future. + * @param includeSeconds - if true includes seconds in the result + * @param conciseFormatting - if true removes special prefixes / suffixes such as "about ..." or "in almost ..." + * @param relativeTo - if defined represents the timestamp to compare with. Otherwise, the timestamp param is compared with the present. + */ +export function formatRelativeTime( + timestamp: UnixTimestamp, + enforcePast = false, + includeSeconds = false, + conciseFormatting = false, + relativeTo?: UnixTimestamp, +): string { + const date = fromUnixTime(timestamp); + const compareWith = typeof relativeTo !== "undefined" ? fromUnixTime(relativeTo) : new Date(); + + if ( + (enforcePast && date >= compareWith) || + (!includeSeconds && + !conciseFormatting && + Math.abs(date.getTime() - compareWith.getTime()) < 60 * millisecondsInSecond) + ) { + return "just now"; + } + + if (conciseFormatting) { + return formatDistanceStrict(date, compareWith, { addSuffix: true }); + } + + return formatDistance(date, compareWith, { + addSuffix: true, + includeSeconds, + }); +} + +/** + * Client-only relative time component + */ +export function RelativeTime({ + timestamp, + enforcePast = false, + includeSeconds = false, + conciseFormatting = false, + tooltipPosition = "top", + tooltipStyles, + relativeTo, + prefix, + contentWrapper, +}: { + timestamp: UnixTimestamp; + enforcePast?: boolean; + includeSeconds?: boolean; + conciseFormatting?: boolean; + tooltipPosition?: React.ComponentProps["side"]; + tooltipStyles?: string; + relativeTo?: UnixTimestamp; + prefix?: string; + /** + * A component to be rendered as a wrapper for the Relative Time component content. + */ + contentWrapper?: ({ children }: React.PropsWithChildren) => React.ReactNode; +}) { + const [relativeTime, setRelativeTime] = useState(""); + + useEffect(() => { + setRelativeTime( + formatRelativeTime(timestamp, enforcePast, includeSeconds, conciseFormatting, relativeTo), + ); + }, [timestamp, conciseFormatting, enforcePast, includeSeconds, relativeTo]); + + const tooltipTriggerContent = ( + <> + {prefix} + {relativeTime} + + ); + + return ( + + + {typeof contentWrapper === "function" + ? contentWrapper({ children: tooltipTriggerContent }) + : tooltipTriggerContent} + + + {" "} + (Local time) +
+ {" "} + (UTC) + +
+
+ ); +} + +// TODO: Copied from ENSAwards - some alignment made but further changes may be needed diff --git a/packages/namehash-ui/src/components/footer/Footer.tsx b/packages/namehash-ui/src/components/footer/Footer.tsx new file mode 100644 index 000000000..0108d5d05 --- /dev/null +++ b/packages/namehash-ui/src/components/footer/Footer.tsx @@ -0,0 +1,196 @@ +import { EnsServiceProviderIcon } from "@/components/icons/ens/EnsServiceProviderIcon.tsx"; +import { NameHashLabsIcon } from "@/components/icons/namehash/NameHashLabsIcon.tsx"; +import { EfpIcon } from "@/components/icons/socials/EfpIcon.tsx"; +import { EmailIcon } from "@/components/icons/socials/EmailIcon.tsx"; +import { FarcasterIcon } from "@/components/icons/socials/FarcasterIcon.tsx"; +import { GitHubIcon } from "@/components/icons/socials/GitHubIcon.tsx"; +import { TelegramIcon } from "@/components/icons/socials/TelegramIcon.tsx"; +import { TwitterIcon } from "@/components/icons/socials/TwitterIcon.tsx"; + +const footerProducts = [ + { + name: "ENSNode", + href: "https://ensnode.io/", + }, + { + name: "ENSRainbow", + href: "https://ensrainbow.io", + }, + { + name: "ENSAdmin", + href: "https://ensadmin.io", + }, + { + name: "ENS Referral Program", + href: "/ens-referral-awards", + }, + { + name: "ENSAwards", + href: "https://ensawards.org/", + }, + { + name: "NameGraph", + href: "https://namegraph.dev", + }, + { + name: "NameAI", + href: "https://nameai.io/", + }, + { + name: "NameGuard", + href: "https://nameguard.io", + }, + { + name: "NameKit", + href: "https://namekit.io", + }, +]; + +const footerResources = [ + { + name: "Contact us", + href: "https://namehashlabs.org/contact", + }, + { + name: "Careers", + href: "https://namehashlabs.org/careers", + }, + { + name: "Partners", + href: "https://namehashlabs.org/partners", + }, + { + name: "Brand assets", + href: "https://namehashlabs.org/brand-assets", + }, +]; + +export function Footer() { + return ( +
+
+
+
+ + +

+ Founded in 2022, Namehash Labs is dedicated to developing open source infrastructure + that helps the Ethereum Name Service (ENS) Protocol grow. +

+ + +
+ +
+
+ Products + +
+
+ Resources + +
+
+
+ +
+

+ © NameHash Labs. All Rights Reserved +

+ + + +
+ + Made with + {"❤️"} + by + + + NameHash Labs + +
+
+
+
+ ); +} diff --git a/packages/namehash-ui/src/components/icons/InfoIcon.tsx b/packages/namehash-ui/src/components/icons/InfoIcon.tsx index 8738f982f..2964ad049 100644 --- a/packages/namehash-ui/src/components/icons/InfoIcon.tsx +++ b/packages/namehash-ui/src/components/icons/InfoIcon.tsx @@ -12,7 +12,7 @@ export const InfoIcon = (props: SVGProps) => ( ((props, ref) => { + return ( + + + + + + + ); +}); + +EnsIcon.displayName = "EnsIcon"; diff --git a/packages/namehash-ui/src/components/icons/ens/EnsServiceProviderIcon.tsx b/packages/namehash-ui/src/components/icons/ens/EnsServiceProviderIcon.tsx new file mode 100644 index 000000000..1733d0468 --- /dev/null +++ b/packages/namehash-ui/src/components/icons/ens/EnsServiceProviderIcon.tsx @@ -0,0 +1,108 @@ +import type React from "react"; + +export const EnsServiceProviderIcon = (props: React.SVGAttributes) => { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/namehash-ui/src/components/icons/namehash/NameHashLabsIcon.tsx b/packages/namehash-ui/src/components/icons/namehash/NameHashLabsIcon.tsx new file mode 100644 index 000000000..22018e0ac --- /dev/null +++ b/packages/namehash-ui/src/components/icons/namehash/NameHashLabsIcon.tsx @@ -0,0 +1,67 @@ +import type React from "react"; + +export const NameHashLabsIcon = (props: React.SVGProps) => { + return ( + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/namehash-ui/src/components/icons/socials/EfpIcon.tsx b/packages/namehash-ui/src/components/icons/socials/EfpIcon.tsx new file mode 100644 index 000000000..77b18e584 --- /dev/null +++ b/packages/namehash-ui/src/components/icons/socials/EfpIcon.tsx @@ -0,0 +1,30 @@ +import type React from "react"; + +export const EfpIcon = (props: React.SVGProps) => { + return ( + + + + + + + + + + + + ); +}; diff --git a/packages/namehash-ui/src/components/icons/socials/EmailIcon.tsx b/packages/namehash-ui/src/components/icons/socials/EmailIcon.tsx new file mode 100644 index 000000000..8441cd67b --- /dev/null +++ b/packages/namehash-ui/src/components/icons/socials/EmailIcon.tsx @@ -0,0 +1,24 @@ +import type React from "react"; + +export const EmailIcon = (props: React.SVGProps) => { + return ( + + + + + ); +}; diff --git a/packages/namehash-ui/src/components/icons/socials/FarcasterIcon.tsx b/packages/namehash-ui/src/components/icons/socials/FarcasterIcon.tsx new file mode 100644 index 000000000..6f80800bd --- /dev/null +++ b/packages/namehash-ui/src/components/icons/socials/FarcasterIcon.tsx @@ -0,0 +1,27 @@ +import type React from "react"; + +export const FarcasterIcon = (props: React.SVGProps) => { + return ( + + + + + + ); +}; diff --git a/packages/namehash-ui/src/components/icons/socials/GitHubIcon.tsx b/packages/namehash-ui/src/components/icons/socials/GitHubIcon.tsx new file mode 100644 index 000000000..3331767c5 --- /dev/null +++ b/packages/namehash-ui/src/components/icons/socials/GitHubIcon.tsx @@ -0,0 +1,21 @@ +import type React from "react"; + +export const GitHubIcon = (props: React.SVGProps) => { + return ( + + + + ); +}; diff --git a/packages/namehash-ui/src/components/icons/socials/TelegramIcon.tsx b/packages/namehash-ui/src/components/icons/socials/TelegramIcon.tsx new file mode 100644 index 000000000..316c9bc97 --- /dev/null +++ b/packages/namehash-ui/src/components/icons/socials/TelegramIcon.tsx @@ -0,0 +1,19 @@ +import type React from "react"; + +export const TelegramIcon = (props: React.SVGProps) => { + return ( + + + + ); +}; diff --git a/packages/namehash-ui/src/components/icons/socials/TwitterIcon.tsx b/packages/namehash-ui/src/components/icons/socials/TwitterIcon.tsx new file mode 100644 index 000000000..171446c53 --- /dev/null +++ b/packages/namehash-ui/src/components/icons/socials/TwitterIcon.tsx @@ -0,0 +1,12 @@ +import type React from "react"; + +export const TwitterIcon = (props: React.SVGProps) => { + return ( + + + + ); +}; diff --git a/packages/namehash-ui/src/components/identity/EnsAvatar.tsx b/packages/namehash-ui/src/components/identity/EnsAvatar.tsx index 326e63957..ecb9ca533 100644 --- a/packages/namehash-ui/src/components/identity/EnsAvatar.tsx +++ b/packages/namehash-ui/src/components/identity/EnsAvatar.tsx @@ -74,3 +74,5 @@ const AvatarLoading = ({ className }: EnsAvatarLoadingProps) => ( )} /> ); + +// TODO: Copied from ENSAwards (as a newer version) - further alignment might be needed diff --git a/packages/namehash-ui/src/components/identity/ResolveAndDisplayIdentity.tsx b/packages/namehash-ui/src/components/identity/ResolveAndDisplayIdentity.tsx new file mode 100644 index 000000000..bed548222 --- /dev/null +++ b/packages/namehash-ui/src/components/identity/ResolveAndDisplayIdentity.tsx @@ -0,0 +1,234 @@ +import type * as React from "react"; + +import { useResolvedIdentity } from "@ensnode/ensnode-react"; +import { + type ENSNamespaceId, + type Identity, + isResolvedIdentity, + ResolutionStatusIds, + translateDefaultableChainIdToChainId, + type UnresolvedIdentity, +} from "@ensnode/ensnode-sdk"; + +import { ChainIcon } from "@/components/chains/ChainIcon.tsx"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useIsMobile } from "@/hooks/useMobile.tsx"; +import { cn } from "@/utils/cn.ts"; + +import { EnsAvatar } from "./EnsAvatar.tsx"; +import { + AddressDisplay, + IdentityLink, + type IdentityLinkDetails, + IdentityTooltip, + NameDisplay, +} from "./utils"; + +export interface ResolveAndDisplayIdentityProps { + identity: UnresolvedIdentity; + namespaceId: ENSNamespaceId; + accelerate?: boolean; + withLink?: boolean; + identityLinkDetails?: IdentityLinkDetails; + withTooltip?: boolean; + withAvatar?: boolean; + withIdentifier?: boolean; + className?: string; +} + +/** + * Resolves the provided `UnresolvedIdentity` through ENSNode and displays the result. + * + * @param identity - The `UnresolvedIdentity` to resolve and display. + * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'holesky', + * 'ens-test-env') + * @param accelerate - Whether to attempt Protocol Acceleration (default: false) + * when resolving the primary name. + * @param withLink - Whether to wrap the displayed identity in an `IdentityLink` component. + * @param identityLinkDetails - If the `withLink` is true, provides info on where should it lead and should it be an external link. + * @param withTooltip - Whether to wrap the displayed identity in an `IdentityInfoTooltip` component. + * @param withAvatar - Whether to display an avatar image. + * @param withIdentifier - Whether to display identity's textual identifier (address or name). + * @param className - The class name to apply to the displayed identity. + */ +export function ResolveAndDisplayIdentity({ + identity, + namespaceId, + accelerate = true, + withLink = true, + identityLinkDetails, + withTooltip = true, + withAvatar = false, + withIdentifier = true, + className, +}: ResolveAndDisplayIdentityProps) { + // resolve the primary name for `identity` using ENSNode + // TODO: extract out the concept of resolving an `Identity` into a provider that child + // components can then hook into. + const { identity: identityResult } = useResolvedIdentity({ + identity, + accelerate, + }); + + return ( + + ); +} + +interface DisplayIdentityProps { + identity: Identity; + namespaceId: ENSNamespaceId; + withLink?: boolean; + identityLinkDetails?: IdentityLinkDetails; + withTooltip?: boolean; + withAvatar?: boolean; + withIdentifier?: boolean; + className?: string; +} + +/** + * Displays the provided `Identity`. + * + * Performs _NO_ resolution if the provided `identity` is not already a `ResolvedIdentity`. + * + * @param identity - The identity to display. May be a `ResolvedIdentity` or an `UnresolvedIdentity`. + * If not a `ResolvedIdentity` (and therefore just an `UnresolvedIdentity`) then displays a loading state. + * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'holesky', + * 'ens-test-env') + * @param withLink - Whether to wrap the displayed identity in an `IdentityLink` component. + * @param identityLinkDetails - If the `withLink` is true, provides info on where should it lead and should it be an external link. + * @param withTooltip - Whether to wrap the displayed identity in an `IdentityInfoTooltip` component. + * @param withAvatar - Whether to display an avatar image. + * @param withIdentifier - Whether to display identity's textual identifier (address or name). + * @param className - The class name to apply to the displayed identity. + * + * @throws Error - if `withLink` is true, but no `identityLinkDetails` are provided. + */ +export function DisplayIdentity({ + identity, + namespaceId, + withLink = true, + identityLinkDetails, + withTooltip = true, + withAvatar = false, + withIdentifier = true, + className, +}: DisplayIdentityProps) { + let avatar: React.ReactElement; + let identifier: React.ReactElement; + + const isMobile = useIsMobile(); + + if (!isResolvedIdentity(identity)) { + // identity is an `UnresolvedIdentity` which represents that it hasn't been resolved yet + // display loading state + avatar = ( + + ); + identifier = ( + + ); + } else if ( + identity.resolutionStatus === ResolutionStatusIds.Unnamed || + identity.resolutionStatus === ResolutionStatusIds.Unknown + ) { + avatar = ( +
+ +
+ ); + identifier = ( + + ); + } else { + avatar = ( + + ); + identifier = ( + + ); + } + + let result = ( +
+ {/* TODO: extract the `EnsAvatar` / `ChainIcon` out of this component and remove the + `withAvatar` prop. */} + {withAvatar && avatar} + {withIdentifier && identifier} +
+ ); + + // TODO: extract the `IdentityInfoTooltip` out of this component and remove the `withTooltip` prop. + if (withTooltip) { + result = ( + + {result} + + ); + } + + // TODO: extract the `IdentityLink` out of this component and remove the `withLink` prop. + if (withLink) { + if (identityLinkDetails === undefined) { + throw new Error("Invariant(ResolveAndDisplayIdentity): Expected identity link details"); + } + result = ( + + {result} + + ); + } + + return result; +} + +// TODO: Copied from ENSAwards (as a newer version) - performed some refactor actions +// to make the component usable across all our apps, but further alignment might be needed diff --git a/packages/namehash-ui/src/components/identity/utils.tsx b/packages/namehash-ui/src/components/identity/utils.tsx new file mode 100644 index 000000000..d42e15a57 --- /dev/null +++ b/packages/namehash-ui/src/components/identity/utils.tsx @@ -0,0 +1,193 @@ +import type { PropsWithChildren } from "react"; +import { type Address, getAddress } from "viem"; + +import { + beautifyName, + DEFAULT_EVM_CHAIN_ID, + type ENSNamespaceId, + type Identity, + isResolvedIdentity, + type Name, + ResolutionStatusIds, + translateDefaultableChainIdToChainId, +} from "@ensnode/ensnode-sdk"; + +import { ChainIcon } from "@/components/chains/ChainIcon.tsx"; +import { ChainExplorerIcon } from "@/components/icons/ChainExplorerIcon.tsx"; +import { EnsIcon } from "@/components/icons/ens/EnsIcon.tsx"; +import { CopyButton } from "@/components/special-buttons/CopyButton.tsx"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { cn } from "@/utils/cn.ts"; +import { + getAddressDetailsUrl, + getBlockExplorerUrlForAddress, + getChainName, +} from "@/utils/namespace.ts"; + +interface NameDisplayProps { + name: Name; + className?: string; +} + +/** + * Displays an ENS name in beautified form. + * + * @param name - The name to display in beautified form. + * + */ +export function NameDisplay({ name, className = "nhui:font-medium" }: NameDisplayProps) { + const beautifiedName = beautifyName(name); + return {beautifiedName}; +} + +interface AddressDisplayProps { + address: Address; + className?: string; +} + +/** + * Displays a truncated checksummed address without any navigation. + * Pure display component for showing addresses. + */ +export function AddressDisplay({ address, className }: AddressDisplayProps) { + const checksummedAddress = getAddress(address); + const truncatedAddress = `${checksummedAddress.slice(0, 6)}...${checksummedAddress.slice(-4)}`; + return {truncatedAddress}; +} + +export interface IdentityLinkDetails { + isExternal: boolean; + link: URL; +} +interface IdentityLinkProps { + linkDetails: IdentityLinkDetails; + className?: string; +} + +/** + * Displays an identifier (address or name) with a link to the identity details URL. + * If the ENS namespace has a known ENS Manager App, + * includes a link to the view details of the address within that ENS namespace. + * + * Can take other components (ex.ChainIcon) as children + * and display them alongside the link as one common interaction area. + */ +export function IdentityLink({ + linkDetails, + className, + children, +}: PropsWithChildren) { + return ( + + {children} + + ); +} + +export interface IdentityTooltipProps { + identity: Identity; + namespaceId: ENSNamespaceId; +} + +/** + * On hover displays details on how the primary name for + * the address of the identity was resolved. + */ +export const IdentityTooltip = ({ + identity, + namespaceId, + children, +}: PropsWithChildren) => { + if (!isResolvedIdentity(identity)) { + // identity is still loading, don't build any tooltip components yet. + return children; + } + + const chainDescription = + identity.chainId === DEFAULT_EVM_CHAIN_ID + ? 'the "default" EVM Chain' + : getChainName(identity.chainId); + + let header: string; + + switch (identity.resolutionStatus) { + case ResolutionStatusIds.Named: + header = `Primary name on ${chainDescription} for address:`; + break; + case ResolutionStatusIds.Unnamed: + header = `Unnamed address on ${chainDescription}:`; + break; + case ResolutionStatusIds.Unknown: + header = `Error resolving address on ${chainDescription}:`; + break; + } + + const ensAppAddressDetailsUrl = getAddressDetailsUrl(identity.address, namespaceId); + + const body = ( + + + + ); + + const effectiveChainId = translateDefaultableChainIdToChainId(identity.chainId, namespaceId); + const chainExplorerUrl = getBlockExplorerUrlForAddress(effectiveChainId, identity.address); + + return ( + + {children} + +
+
+ +
+
+ {header} +
+ {body} +
+
+ + {chainExplorerUrl && ( + + + + )} + {ensAppAddressDetailsUrl && ( + + + + )} +
+
+
+
+ ); +}; + +// TODO: Copied from ENSAwards - some alignment made but further changes may be needed diff --git a/packages/namehash-ui/src/components/placeholder/Placeholder.tsx b/packages/namehash-ui/src/components/placeholder/Placeholder.tsx deleted file mode 100644 index 16f400cbb..000000000 --- a/packages/namehash-ui/src/components/placeholder/Placeholder.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export const Placeholder = () => { - return ( -

- NameHash-UI -

- ); -}; diff --git a/packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx b/packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx new file mode 100644 index 000000000..bb7226aeb --- /dev/null +++ b/packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx @@ -0,0 +1,365 @@ +import { Info as InfoIcon, CircleQuestionMark as QuestionmarkIcon } from "lucide-react"; +import { memo, type PropsWithChildren, type ReactNode } from "react"; +import { zeroAddress } from "viem"; + +import type { + DefaultableChainId, + ENSNamespaceId, + NamedRegistrarAction, + RegistrarActionReferral, + UnixTimestamp, +} from "@ensnode/ensnode-sdk"; +import { + buildUnresolvedIdentity, + isRegistrarActionReferralAvailable, + RegistrarActionTypes, + ZERO_ENCODED_REFERRER, +} from "@ensnode/ensnode-sdk"; + +import { DisplayDuration } from "@/components/datetime/DisplayDuration.tsx"; +import { RelativeTime } from "@/components/datetime/RelativeTime.tsx"; +import { + ResolveAndDisplayIdentity, + type ResolveAndDisplayIdentityProps, +} from "@/components/identity/ResolveAndDisplayIdentity.tsx"; +import { type IdentityLinkDetails, NameDisplay } from "@/components/identity/utils.tsx"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx"; +import { useIsMobile } from "@/hooks/useMobile.tsx"; +import { cn } from "@/utils/cn.ts"; +import { getBlockExplorerUrlForTransactionHash } from "@/utils/namespace.ts"; + +interface LabeledFieldProps { + fieldLabel: string; + className?: string; +} + +/** + * Display a labeled field. + */ +function LabeledField({ fieldLabel, className, children }: PropsWithChildren) { + return ( +
+

+ {fieldLabel} +

+ {children} +
+ ); +} + +interface ResolveAndDisplayReferrerIdentityProps + extends Omit { + chainId: DefaultableChainId; + referral: RegistrarActionReferral; +} + +/** + * Resolve and Display Referrer Identity + * + * Resolves and displays the identity of the decoded referrer, or a fallback UI. + */ +function ResolveAndDisplayReferrerIdentity({ + namespaceId, + chainId, + referral, + accelerate = true, + withLink = true, + identityLinkDetails, + withTooltip = true, + withAvatar = false, + withIdentifier = true, + className, +}: ResolveAndDisplayReferrerIdentityProps) { + // if encoded referrer is not available or is the zero encoded referrer then + if ( + !isRegistrarActionReferralAvailable(referral) || + referral.encodedReferrer === ZERO_ENCODED_REFERRER + ) { + // when we only want to display avatar (without textual identifier) don't display anything (return en empty placeholder). + // Otherwise, display a hyphen with no avatar + return withAvatar && !withIdentifier ? ( +
+ ) : ( +

-

+ ); + } + + // if the encoded referrer was not the zeroEncodedReferrer but couldn't be + // decoded according to the subjective interpretation rules of + // the current ENS Referral Awards program then display a tooltip with details + if (referral.decodedReferrer === zeroAddress) { + // when we only want to display avatar (without textual identifier) use a dedicated placeholder. + // Otherwise, display "unknown" plus the placeholder. + const tooltipContent = ( +

+ Encoded referrer + {referral.encodedReferrer} does not follow the + formatting requirements of incentive programs. +

+ ); + + const unknownAvatarPlaceholder = (className?: string, iconSize = 24) => ( +
+ +
+ ); + + return withAvatar && !withIdentifier ? ( + unknownAvatarPlaceholder("nhui:w-10 nhui:h-10", 24) + ) : ( + + {unknownAvatarPlaceholder("nhui:w-5 nhui:h-5", 16)} + Unknown + + + + + + Encoded referrer + {referral.encodedReferrer} does not follow the formatting + requirements of ENS Referral Programs. + + + + ); + } + + // resolve and display identity for the decodedReferrer address + const referrerIdentity = buildUnresolvedIdentity(referral.decodedReferrer, namespaceId, chainId); + + return ( + + ); +} + +export interface RegistrarActionCardLoadingProps { + showReferrer?: boolean; +} + +/** + * Display Registrar Action Card loading state + */ +export function RegistrarActionCardLoading({ + showReferrer = true, +}: RegistrarActionCardLoadingProps) { + const isMobile = useIsMobile(); + + return ( +
+ +
+ + + +
+ + + +
+ + +
+ {!isMobile && ( +
+ )} + +
+ {isMobile && ( +
+ )} +
+
+ +
+ + {showReferrer && ( +
+ {!isMobile && ( +
+ )} + +
+ {isMobile && ( +
+ )} +
+
+ +
+ )} + + +
+ +
+ ); +} + +export interface RegistrarActionCardProps { + namespaceId: ENSNamespaceId; + namedRegistrarAction: NamedRegistrarAction; + now: UnixTimestamp; + links: { + name: IdentityLinkDetails; + registrant: IdentityLinkDetails; + referrer?: IdentityLinkDetails; + }; + showReferrer?: boolean; + referralProgramField?: ReactNode; +} + +/** + * Display a single Registrar Action + */ +export function RegistrarActionCard({ + namespaceId, + namedRegistrarAction, + now, + links, + showReferrer = true, + referralProgramField, +}: RegistrarActionCardProps) { + const isMobile = useIsMobile(); + const { registrant, registrationLifecycle, type, referral, transactionHash } = + namedRegistrarAction.action; + const { chainId } = registrationLifecycle.subregistry.subregistryId; + + const transactionDetailUrl = getBlockExplorerUrlForTransactionHash(chainId, transactionHash); + const withTransactionLink = ({ children }: PropsWithChildren) => + // wrap `children` content with a transaction link only if the URL is defined + transactionDetailUrl ? ( + + {children} + + ) : ( + children + ); + + const registrantIdentity = buildUnresolvedIdentity(registrant, namespaceId, chainId); + + return ( +
+ + + + + + + +

+ +

+
+ + +

+ +

+
+ +
+ {!isMobile && ( + + )} + + + +
+ + {showReferrer && ( +
+ {!isMobile && ( + + )} + + + +
+ )} + + {referralProgramField !== undefined && referralProgramField} +
+ ); +} + +export const RegistrarActionCardMemo = memo(RegistrarActionCard); + +// TODO: Copied from ENSAwards (as a newer version) - performed some refactor actions +// to make the component usable across all our apps, but further alignment might be needed diff --git a/packages/namehash-ui/src/components/special-buttons/CopyButton.tsx b/packages/namehash-ui/src/components/special-buttons/CopyButton.tsx new file mode 100644 index 000000000..1de9777ea --- /dev/null +++ b/packages/namehash-ui/src/components/special-buttons/CopyButton.tsx @@ -0,0 +1,73 @@ +import * as React from "react"; +import { toast } from "sonner"; + +import { Button, type ButtonProps } from "@/components/ui/button"; +import { cn } from "@/utils/cn.ts"; + +export interface CopyButtonProps extends Omit { + value: string; + message?: string; + showToast?: boolean; + icon?: React.ReactNode; + successIcon?: React.ReactNode; +} + +export function CopyButton({ + value, + message = "Copied to clipboard", + variant = "ghost", + size = "icon", + showToast = false, + icon, + successIcon, + className, + children, + ...props +}: CopyButtonProps) { + const [hasCopied, setHasCopied] = React.useState(false); + const [isCopying, setIsCopying] = React.useState(false); + + React.useEffect(() => { + if (hasCopied) { + const timeout = setTimeout(() => setHasCopied(false), 2000); + return () => clearTimeout(timeout); + } + }, [hasCopied]); + + async function copyToClipboard() { + if (isCopying) return; + + try { + setIsCopying(true); + await navigator.clipboard.writeText(value); + setHasCopied(true); + + if (showToast) { + toast.success(message); + } + } catch (error) { + console.error("Failed to copy text:", error); + + if (showToast) { + toast.error("Failed to copy text to clipboard"); + } + } finally { + setIsCopying(false); + } + } + + return ( + + ); +} diff --git a/packages/namehash-ui/src/components/ui/button.tsx b/packages/namehash-ui/src/components/ui/button.tsx new file mode 100644 index 000000000..852b01443 --- /dev/null +++ b/packages/namehash-ui/src/components/ui/button.tsx @@ -0,0 +1,63 @@ +import { Slot } from "@radix-ui/react-slot"; +import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; + +import { cn } from "@/utils/cn"; + +const buttonVariants = cva( + "nhui:inline-flex nhui:items-center nhui:justify-center nhui:gap-2 nhui:whitespace-nowrap nhui:rounded-md nhui:text-sm nhui:font-medium nhui:transition-all nhui:disabled:pointer-events-none nhui:disabled:opacity-50 nhui:[&_svg]:pointer-events-none nhui:[&_svg:not([class*=size-])]:size-4 nhui:shrink-0 nhui:[&_svg]:shrink-0 nhui:outline-none nhui:focus-visible:border-ring nhui:focus-visible:ring-ring/50 nhui:focus-visible:ring-[3px] nhui:aria-invalid:ring-destructive/20 nhui:dark:aria-invalid:ring-destructive/40 nhui:aria-invalid:border-destructive", + { + variants: { + variant: { + default: "nhui:bg-primary nhui:text-primary-foreground nhui:hover:bg-primary/90", + destructive: + "nhui:bg-destructive nhui:text-white nhui:hover:bg-destructive/90 nhui:focus-visible:ring-destructive/20 nhui:dark:focus-visible:ring-destructive/40 nhui:dark:bg-destructive/60", + outline: + "nhui:border nhui:bg-background nhui:shadow-xs nhui:hover:bg-accent nhui:hover:text-accent-foreground nhui:dark:bg-input/30 nhui:dark:border-input nhui:dark:hover:bg-input/50", + secondary: "nhui:bg-secondary nhui:text-secondary-foreground nhui:hover:bg-secondary/80", + ghost: + "nhui:hover:bg-accent nhui:hover:text-accent-foreground nhui:dark:hover:bg-accent/50", + link: "nhui:text-primary nhui:underline-offset-4 nhui:hover:underline", + }, + size: { + default: "nhui:h-9 nhui:px-4 nhui:py-2 nhui:has-[>svg]:px-3", + sm: "nhui:h-8 nhui:rounded-md nhui:gap-1.5 nhui:px-3 nhui:has-[>svg]:px-2.5", + lg: "nhui:h-10 nhui:rounded-md nhui:px-6 nhui:has-[>svg]:px-4", + icon: "nhui:size-9", + "icon-sm": "nhui:size-8", + "icon-lg": "nhui:size-10", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + }, +); + +export interface ButtonProps + extends React.ComponentProps<"button">, + VariantProps { + asChild?: boolean; +} +function Button({ + className, + variant = "default", + size = "default", + asChild = false, + ...props +}: ButtonProps) { + const Comp = asChild ? Slot : "button"; + + return ( + + ); +} + +export { Button, buttonVariants }; diff --git a/packages/namehash-ui/src/components/ui/skeleton.tsx b/packages/namehash-ui/src/components/ui/skeleton.tsx new file mode 100644 index 000000000..008625c72 --- /dev/null +++ b/packages/namehash-ui/src/components/ui/skeleton.tsx @@ -0,0 +1,13 @@ +import { cn } from "@/utils/cn"; + +function Skeleton({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +export { Skeleton }; diff --git a/packages/namehash-ui/src/components/ui/tooltip.tsx b/packages/namehash-ui/src/components/ui/tooltip.tsx new file mode 100644 index 000000000..765a95d44 --- /dev/null +++ b/packages/namehash-ui/src/components/ui/tooltip.tsx @@ -0,0 +1,57 @@ +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import type * as React from "react"; + +import { cn } from "@/utils/cn"; + +const TooltipArrow = TooltipPrimitive.Arrow; + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function Tooltip({ ...props }: React.ComponentProps) { + return ( + + + + ); +} + +function TooltipTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ); +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, TooltipArrow }; diff --git a/packages/namehash-ui/src/hooks/useMobile.tsx b/packages/namehash-ui/src/hooks/useMobile.tsx new file mode 100644 index 000000000..39b38c10d --- /dev/null +++ b/packages/namehash-ui/src/hooks/useMobile.tsx @@ -0,0 +1,19 @@ +import * as React from "react"; + +const MOBILE_BREAKPOINT = 640; + +export function useIsMobile() { + const [isMobile, setIsMobile] = React.useState(undefined); + + React.useEffect(() => { + const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); + const onChange = () => { + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + }; + mql.addEventListener("change", onChange); + setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); + return () => mql.removeEventListener("change", onChange); + }, []); + + return !!isMobile; +} diff --git a/packages/namehash-ui/src/index.ts b/packages/namehash-ui/src/index.ts index 18e2d2e8d..e3fc25863 100644 --- a/packages/namehash-ui/src/index.ts +++ b/packages/namehash-ui/src/index.ts @@ -1,9 +1,21 @@ import "./styles.css"; export * from "./components/chains/ChainIcon"; +export * from "./components/chains/ChainName"; export * from "./components/datetime/AbsoluteTime"; export * from "./components/datetime/DisplayDuration"; +export * from "./components/datetime/RelativeTime"; +export * from "./components/footer/Footer"; export * from "./components/icons/ChainExplorerIcon"; +export * from "./components/icons/ens/EnsIcon.tsx"; +export * from "./components/icons/ens/EnsServiceProviderIcon"; export * from "./components/icons/InfoIcon"; +export * from "./components/icons/socials/EfpIcon"; +export * from "./components/icons/socials/EmailIcon"; +export * from "./components/icons/socials/FarcasterIcon"; +export * from "./components/icons/socials/GitHubIcon"; +export * from "./components/icons/socials/TelegramIcon"; +export * from "./components/icons/socials/TwitterIcon"; export * from "./components/identity/EnsAvatar"; -export * from "./components/placeholder/Placeholder"; +export * from "./components/identity/ResolveAndDisplayIdentity"; +export * from "./components/registrar-actions/RegistrarActionCard"; diff --git a/packages/namehash-ui/src/utils/namespace.ts b/packages/namehash-ui/src/utils/namespace.ts index de504d81c..34bec6cb1 100644 --- a/packages/namehash-ui/src/utils/namespace.ts +++ b/packages/namehash-ui/src/utils/namespace.ts @@ -1,5 +1,91 @@ +import type { Address, Hash } from "viem"; +import { + arbitrum, + arbitrumSepolia, + base, + baseSepolia, + linea, + lineaSepolia, + mainnet, + optimism, + optimismSepolia, + scroll, + scrollSepolia, + sepolia, +} from "viem/chains"; + import type { ENSNamespaceId } from "@ensnode/datasources"; -import { ENSNamespaceIds, type Name } from "@ensnode/ensnode-sdk"; +import { ensTestEnvL1Chain } from "@ensnode/datasources"; +import { type ChainId, ENSNamespaceIds, type Name } from "@ensnode/ensnode-sdk"; + +const SUPPORTED_CHAINS = [ + ensTestEnvL1Chain, + mainnet, + sepolia, + base, + baseSepolia, + linea, + lineaSepolia, + optimism, + optimismSepolia, + arbitrum, + arbitrumSepolia, + scroll, + scrollSepolia, +]; + +/** + * Mapping of chain id to prettified chain name. + * + * NOTE: We prefer our custom names here, rather than those provided by default in `Chain#name`. + */ +const CUSTOM_CHAIN_NAMES = new Map([ + [ensTestEnvL1Chain.id, "Ethereum Local (ens-test-env)"], + [mainnet.id, "Ethereum"], + [sepolia.id, "Ethereum Sepolia"], + [base.id, "Base"], + [baseSepolia.id, "Base Sepolia"], + [linea.id, "Linea"], + [lineaSepolia.id, "Linea Sepolia"], + [optimism.id, "Optimism"], + [optimismSepolia.id, "Optimism Sepolia"], + [arbitrum.id, "Arbitrum"], + [arbitrumSepolia.id, "Arbitrum Sepolia"], + [scroll.id, "Scroll"], + [scrollSepolia.id, "Scroll Sepolia"], +]); + +/** + * Get the ENS Manager App URL for the provided namespace. + * + * @param {ENSNamespaceId} namespaceId - ENS Namespace identifier + * @returns ENS Manager App URL for the provided namespace, or null if the provided namespace + * doesn't have a known ENS Manager App + */ +export function getEnsManagerAppUrl(namespaceId: ENSNamespaceId): URL | null { + switch (namespaceId) { + case ENSNamespaceIds.Mainnet: + return new URL(`https://app.ens.domains/`); + case ENSNamespaceIds.Sepolia: + return new URL(`https://sepolia.app.ens.domains/`); + case ENSNamespaceIds.EnsTestEnv: + // ens-test-env runs on a local chain and is not supported by app.ens.domains + return null; + } +} + +/** + * Builds the URL of the external ENS Manager App Profile page for a given name and ENS Namespace. + * + * @returns URL to the Profile page in the external ENS Manager App for a given name and ENS Namespace, + * or null if this URL is not known + */ +export function buildExternalEnsAppProfileUrl(name: Name, namespaceId: ENSNamespaceId): URL | null { + const baseUrl = getEnsManagerAppUrl(namespaceId); + if (!baseUrl) return null; + + return new URL(name, baseUrl); +} /** * Build the avatar image URL for a name on the given ENS Namespace that (once fetched) would @@ -30,3 +116,69 @@ export function buildEnsMetadataServiceAvatarUrl( return null; } } + +/** + * Get the URL of the address details page in ENS Manager App for a given address and ENS Namespace. + * + * @returns URL to the address details page in the ENS Manager App for a given address and ENS + * Namespace, or null if this URL is not known + */ +export function getAddressDetailsUrl(address: Address, namespaceId: ENSNamespaceId): URL | null { + const baseUrl = getEnsManagerAppUrl(namespaceId); + if (!baseUrl) return null; + + return new URL(address, baseUrl); +} + +/** + * Gets the base block explorer URL for a given chainId + * + * @returns default block explorer URL for the chain with the provided id, + * or null if the referenced chain doesn't have a known block explorer + */ +export const getChainBlockExplorerUrl = (chainId: ChainId): URL | null => { + const chain = SUPPORTED_CHAINS.find((chain) => chain.id === chainId); + if (!chain) return null; + + // NOTE: anvil/ens-test-env chain does not have a blockExplorer + if (!chain.blockExplorers) return null; + + return new URL(chain.blockExplorers.default.url); +}; + +/** + * Gets the block explorer URL for a specific address on a specific chainId + * + * @returns complete block explorer URL for a specific address on a specific chainId, + * or null if the referenced chain doesn't have a known block explorer + */ +export const getBlockExplorerUrlForAddress = (chainId: ChainId, address: Address): URL | null => { + const chainBlockExplorer = getChainBlockExplorerUrl(chainId); + if (!chainBlockExplorer) return null; + + return new URL(`address/${address}`, chainBlockExplorer.toString()); +}; + +/** + * Gets the block explorer URL for a specific transaction hash on a specific chainId + * + * @returns complete block explorer URL for a specific transaction hash on a specific chainId, + * or null if the referenced chain doesn't have a known block explorer + */ +export const getBlockExplorerUrlForTransactionHash = ( + chainId: ChainId, + transactionHash: Hash, +): URL | null => { + const chainBlockExplorer = getChainBlockExplorerUrl(chainId); + if (!chainBlockExplorer) return null; + + return new URL(`tx/${transactionHash}`, chainBlockExplorer.toString()); +}; + +/** + * Returns a prettified chain name for the provided chain id. + */ +export function getChainName(chainId: ChainId): string { + const name = CUSTOM_CHAIN_NAMES.get(chainId); + return name || `Unknown Chain (${chainId})`; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f692ac47c..729e8fca1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -903,6 +903,12 @@ importers: '@radix-ui/react-avatar': specifier: ^1.1.10 version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + '@radix-ui/react-slot': + specifier: ^1.2.3 + version: 1.2.3(@types/react@19.2.7)(react@19.2.1) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1) boring-avatars: specifier: ^2.0.4 version: 2.0.4(react-dom@19.2.1(react@19.2.1))(react@19.2.1) @@ -921,6 +927,9 @@ importers: react-dom: specifier: ^19.0.0 version: 19.2.1(react@19.2.1) + sonner: + specifier: ^2.0.3 + version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) tailwind-merge: specifier: ^3.4.0 version: 3.4.0 From f222a6833d8821fbe6e8c23aae695b47f0991afb Mon Sep 17 00:00:00 2001 From: y3drk Date: Mon, 12 Jan 2026 14:43:35 +0100 Subject: [PATCH 4/7] Minor fix in --- .../src/components/identity/ResolveAndDisplayIdentity.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/namehash-ui/src/components/identity/ResolveAndDisplayIdentity.tsx b/packages/namehash-ui/src/components/identity/ResolveAndDisplayIdentity.tsx index bed548222..7a7121b3a 100644 --- a/packages/namehash-ui/src/components/identity/ResolveAndDisplayIdentity.tsx +++ b/packages/namehash-ui/src/components/identity/ResolveAndDisplayIdentity.tsx @@ -219,7 +219,6 @@ export function DisplayIdentity({ result = ( {result} From e54acede9c76db9a00893750692a118474885fc0 Mon Sep 17 00:00:00 2001 From: y3drk Date: Mon, 12 Jan 2026 14:44:32 +0100 Subject: [PATCH 5/7] docs(changeset): Populates `namehash-ui` package with shared UI components. --- .changeset/dry-feet-go.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/dry-feet-go.md diff --git a/.changeset/dry-feet-go.md b/.changeset/dry-feet-go.md new file mode 100644 index 000000000..7c47752b2 --- /dev/null +++ b/.changeset/dry-feet-go.md @@ -0,0 +1,5 @@ +--- +"@namehash/namehash-ui": minor +--- + +Populates `namehash-ui` package with shared UI components. From 174de64ea65f83d31702bc87ff8e8b85a417237d Mon Sep 17 00:00:00 2001 From: y3drk Date: Mon, 12 Jan 2026 15:38:41 +0100 Subject: [PATCH 6/7] Apply github-code-quality CI suggestion --- .../registrar-actions/RegistrarActionCard.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx b/packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx index bb7226aeb..7717c07bf 100644 --- a/packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx +++ b/packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx @@ -95,14 +95,6 @@ function ResolveAndDisplayReferrerIdentity({ if (referral.decodedReferrer === zeroAddress) { // when we only want to display avatar (without textual identifier) use a dedicated placeholder. // Otherwise, display "unknown" plus the placeholder. - const tooltipContent = ( -

- Encoded referrer - {referral.encodedReferrer} does not follow the - formatting requirements of incentive programs. -

- ); - const unknownAvatarPlaceholder = (className?: string, iconSize = 24) => (
Encoded referrer - {referral.encodedReferrer} does not follow the formatting - requirements of ENS Referral Programs. + {referral.encodedReferrer} does not follow the + formatting requirements of incentive programs. From 58f3c92f13b5aeed9c74f61f0f90d09f582b13b5 Mon Sep 17 00:00:00 2001 From: y3drk Date: Tue, 13 Jan 2026 11:09:18 +0100 Subject: [PATCH 7/7] Application of 01/12/26 GitHub review feedback --- packages/ensnode-sdk/src/shared/types.ts | 2 + packages/namehash-ui/README.md | 2 +- packages/namehash-ui/biome.jsonc | 5 +- packages/namehash-ui/components.json | 2 +- packages/namehash-ui/package.json | 10 +- .../src/components/chains/ChainIcon.tsx | 6 +- .../src/components/chains/ChainName.tsx | 8 +- .../components/chains/icons/EthereumIcon.tsx | 5 - .../components/icons/ChainExplorerIcon.tsx | 2 - .../src/components/icons/ens/EnsIcon.tsx | 2 - .../src/components/identity/Address.tsx | 17 ++ .../src/components/identity/EnsAvatar.tsx | 4 +- .../identity/{utils.tsx => Identity.tsx} | 52 +---- .../src/components/identity/Name.tsx | 18 ++ .../identity/ResolveAndDisplayIdentity.tsx | 22 +-- .../registrar-actions/RegistrarActionCard.tsx | 11 +- .../namehash-ui/src/components/ui/README.md | 5 + .../hooks/{useMobile.tsx => useIsMobile.tsx} | 0 packages/namehash-ui/src/index.ts | 3 + .../namehash-ui/src/utils/blockExplorers.ts | 53 +++++ packages/namehash-ui/src/utils/chains.ts | 62 ++++++ packages/namehash-ui/src/utils/ensManager.ts | 52 +++++ packages/namehash-ui/src/utils/ensMetadata.ts | 32 +++ packages/namehash-ui/src/utils/namespace.ts | 184 ------------------ pnpm-lock.yaml | 42 +++- pnpm-workspace.yaml | 4 + 26 files changed, 325 insertions(+), 280 deletions(-) create mode 100644 packages/namehash-ui/src/components/identity/Address.tsx rename packages/namehash-ui/src/components/identity/{utils.tsx => Identity.tsx} (77%) create mode 100644 packages/namehash-ui/src/components/identity/Name.tsx create mode 100644 packages/namehash-ui/src/components/ui/README.md rename packages/namehash-ui/src/hooks/{useMobile.tsx => useIsMobile.tsx} (100%) create mode 100644 packages/namehash-ui/src/utils/blockExplorers.ts create mode 100644 packages/namehash-ui/src/utils/chains.ts create mode 100644 packages/namehash-ui/src/utils/ensManager.ts create mode 100644 packages/namehash-ui/src/utils/ensMetadata.ts delete mode 100644 packages/namehash-ui/src/utils/namespace.ts diff --git a/packages/ensnode-sdk/src/shared/types.ts b/packages/ensnode-sdk/src/shared/types.ts index 6d3252f63..7a35434fc 100644 --- a/packages/ensnode-sdk/src/shared/types.ts +++ b/packages/ensnode-sdk/src/shared/types.ts @@ -7,6 +7,8 @@ import type { DEFAULT_EVM_CHAIN_ID } from "../ens"; * * Represents a unique identifier for a chain. * Guaranteed to be a positive integer. + * + * Chain id standards are organized by the Ethereum Community @ https://github.com/ethereum-lists/chains **/ export type ChainId = number; diff --git a/packages/namehash-ui/README.md b/packages/namehash-ui/README.md index 3e976e353..6d50047bb 100644 --- a/packages/namehash-ui/README.md +++ b/packages/namehash-ui/README.md @@ -10,7 +10,7 @@ For UI component libraries intended for the general public, we recommend [ensnod npm install @namehash/namehash-ui @ensnode/ensnode-react sonner ``` -Note: `@ensnode/ensnode-react` is necessary only for some components. It might happen that you won't need it. Same goes for `sonner` it's only necessary for `CopyButton` component. +Note: `@ensnode/ensnode-react` and `sonner` are package's peer dependencies. The former is necessary only for some components and same goes for `sonner` which is only necessary for `CopyButton` component. It might happen that you won't need these installed, depending on which components you want to use. ## Setup diff --git a/packages/namehash-ui/biome.jsonc b/packages/namehash-ui/biome.jsonc index 280d05b4d..5cb7459a5 100644 --- a/packages/namehash-ui/biome.jsonc +++ b/packages/namehash-ui/biome.jsonc @@ -2,9 +2,6 @@ "$schema": "https://biomejs.dev/schemas/2.3.2/schema.json", "extends": "//", "files": { - "includes": [ - "src/**/*", - "!src/styles.css" - ] + "includes": ["**/*", "!src/styles.css"] } } diff --git a/packages/namehash-ui/components.json b/packages/namehash-ui/components.json index b8c8d7c42..599d3091f 100644 --- a/packages/namehash-ui/components.json +++ b/packages/namehash-ui/components.json @@ -18,4 +18,4 @@ "hooks": "@/hooks" }, "iconLibrary": "lucide" -} \ No newline at end of file +} diff --git a/packages/namehash-ui/package.json b/packages/namehash-ui/package.json index 128054299..77cbaf28c 100644 --- a/packages/namehash-ui/package.json +++ b/packages/namehash-ui/package.json @@ -65,7 +65,7 @@ "@types/react-dom": "19.2.3", "postcss": "^8.5.6", "react": "19.2.1", - "tailwindcss": "^4.1.18", + "tailwindcss": "catalog:", "tsup": "^8.3.6", "typescript": "catalog:", "viem": "catalog:" @@ -79,10 +79,10 @@ "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "date-fns": "^4.1.0", - "lucide-react": "^0.548.0", - "tailwind-merge": "^3.4.0", - "tailwindcss-animate": "^1.0.7", + "date-fns": "catalog:", + "lucide-react": "catalog:", + "tailwind-merge": "catalog:", + "tailwindcss-animate": "catalog:", "tw-animate-css": "^1.4.0" } } diff --git a/packages/namehash-ui/src/components/chains/ChainIcon.tsx b/packages/namehash-ui/src/components/chains/ChainIcon.tsx index 2020d5764..0fdfca51c 100644 --- a/packages/namehash-ui/src/components/chains/ChainIcon.tsx +++ b/packages/namehash-ui/src/components/chains/ChainIcon.tsx @@ -14,6 +14,7 @@ import { } from "viem/chains"; import { ensTestEnvL1Chain } from "@ensnode/datasources"; +import type { ChainId } from "@ensnode/ensnode-sdk"; import { ArbitrumIcon } from "./icons/ArbitrumIcon.tsx"; import { ArbitrumTestnetIcon } from "./icons/ArbitrumTestnetIcon.tsx"; @@ -31,14 +32,13 @@ import { ScrollTestnetIcon } from "./icons/ScrollTestnetIcon.tsx"; import { UnrecognizedChainIcon } from "./icons/UnrecognizedChainIcon.tsx"; export interface ChainIconProps { - chainId: number; + chainId: ChainId; width?: number; height?: number; } /** - * Mapping of chain id to chain icon. - * Chain id standards are organized by the Ethereum Community @ https://github.com/ethereum-lists/chains + * Mapping of {@link ChainId} to chain icon. */ const chainIcons = new Map>>([ // mainnet diff --git a/packages/namehash-ui/src/components/chains/ChainName.tsx b/packages/namehash-ui/src/components/chains/ChainName.tsx index 9499d932d..3dc0f3aba 100644 --- a/packages/namehash-ui/src/components/chains/ChainName.tsx +++ b/packages/namehash-ui/src/components/chains/ChainName.tsx @@ -1,12 +1,14 @@ -import { getChainName } from "@/utils/namespace.ts"; +import type { ChainId } from "@ensnode/ensnode-sdk"; + +import { getChainName } from "@/utils/chains.ts"; export interface ChainNameProps { - chainId: number; + chainId: ChainId; className: string; } /** - * Renders a prettified chain name for the provided chain ID. + * Renders a prettified chain name for the provided {@link ChainId}. */ export const ChainName = ({ chainId, className }: ChainNameProps) => (

{getChainName(chainId)}

diff --git a/packages/namehash-ui/src/components/chains/icons/EthereumIcon.tsx b/packages/namehash-ui/src/components/chains/icons/EthereumIcon.tsx index 77a1ac8de..4510ab0de 100644 --- a/packages/namehash-ui/src/components/chains/icons/EthereumIcon.tsx +++ b/packages/namehash-ui/src/components/chains/icons/EthereumIcon.tsx @@ -1,10 +1,5 @@ import type React from "react"; -// -// Creator: CorelDRAW 2019 (64-Bit) -// -// xmlns:Xodm={"http://www.corel.com/coreldraw/odm/2003"} - export const EthereumIcon = (props: React.SVGProps) => ( ( ); }); - -EnsIcon.displayName = "EnsIcon"; diff --git a/packages/namehash-ui/src/components/identity/Address.tsx b/packages/namehash-ui/src/components/identity/Address.tsx new file mode 100644 index 000000000..47cefc2dc --- /dev/null +++ b/packages/namehash-ui/src/components/identity/Address.tsx @@ -0,0 +1,17 @@ +import type { Address } from "viem"; +import { getAddress } from "viem"; + +interface AddressDisplayProps { + address: Address; + className?: string; +} + +/** + * Displays a truncated checksummed address without any navigation. + * Pure display component for showing addresses. + */ +export function AddressDisplay({ address, className }: AddressDisplayProps) { + const checksummedAddress = getAddress(address); + const truncatedAddress = `${checksummedAddress.slice(0, 6)}...${checksummedAddress.slice(-4)}`; + return {truncatedAddress}; +} diff --git a/packages/namehash-ui/src/components/identity/EnsAvatar.tsx b/packages/namehash-ui/src/components/identity/EnsAvatar.tsx index ecb9ca533..5741b6fcd 100644 --- a/packages/namehash-ui/src/components/identity/EnsAvatar.tsx +++ b/packages/namehash-ui/src/components/identity/EnsAvatar.tsx @@ -6,7 +6,7 @@ import type { Name } from "@ensnode/ensnode-sdk"; import { Avatar, AvatarImage } from "@/components/ui/avatar.tsx"; import { cn } from "@/utils/cn.ts"; -import { buildEnsMetadataServiceAvatarUrl } from "@/utils/namespace.ts"; +import { getEnsMetadataServiceAvatarUrl } from "@/utils/ensMetadata.ts"; interface EnsAvatarProps { name: Name; @@ -21,7 +21,7 @@ type ImageLoadingStatus = Parameters< export const EnsAvatar = ({ name, namespaceId, className, isSquare = false }: EnsAvatarProps) => { const [loadingStatus, setLoadingStatus] = React.useState("idle"); - const avatarUrl = buildEnsMetadataServiceAvatarUrl(name, namespaceId); + const avatarUrl = getEnsMetadataServiceAvatarUrl(name, namespaceId); if (avatarUrl === null) { return ( diff --git a/packages/namehash-ui/src/components/identity/utils.tsx b/packages/namehash-ui/src/components/identity/Identity.tsx similarity index 77% rename from packages/namehash-ui/src/components/identity/utils.tsx rename to packages/namehash-ui/src/components/identity/Identity.tsx index d42e15a57..703fff704 100644 --- a/packages/namehash-ui/src/components/identity/utils.tsx +++ b/packages/namehash-ui/src/components/identity/Identity.tsx @@ -1,13 +1,9 @@ import type { PropsWithChildren } from "react"; -import { type Address, getAddress } from "viem"; +import type { ENSNamespaceId, Identity } from "@ensnode/ensnode-sdk"; import { - beautifyName, DEFAULT_EVM_CHAIN_ID, - type ENSNamespaceId, - type Identity, isResolvedIdentity, - type Name, ResolutionStatusIds, translateDefaultableChainIdToChainId, } from "@ensnode/ensnode-sdk"; @@ -15,45 +11,13 @@ import { import { ChainIcon } from "@/components/chains/ChainIcon.tsx"; import { ChainExplorerIcon } from "@/components/icons/ChainExplorerIcon.tsx"; import { EnsIcon } from "@/components/icons/ens/EnsIcon.tsx"; +import { AddressDisplay } from "@/components/identity/Address.tsx"; import { CopyButton } from "@/components/special-buttons/CopyButton.tsx"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx"; +import { getBlockExplorerAddressDetailsUrl } from "@/utils/blockExplorers.ts"; +import { getChainName } from "@/utils/chains.ts"; import { cn } from "@/utils/cn.ts"; -import { - getAddressDetailsUrl, - getBlockExplorerUrlForAddress, - getChainName, -} from "@/utils/namespace.ts"; - -interface NameDisplayProps { - name: Name; - className?: string; -} - -/** - * Displays an ENS name in beautified form. - * - * @param name - The name to display in beautified form. - * - */ -export function NameDisplay({ name, className = "nhui:font-medium" }: NameDisplayProps) { - const beautifiedName = beautifyName(name); - return {beautifiedName}; -} - -interface AddressDisplayProps { - address: Address; - className?: string; -} - -/** - * Displays a truncated checksummed address without any navigation. - * Pure display component for showing addresses. - */ -export function AddressDisplay({ address, className }: AddressDisplayProps) { - const checksummedAddress = getAddress(address); - const truncatedAddress = `${checksummedAddress.slice(0, 6)}...${checksummedAddress.slice(-4)}`; - return {truncatedAddress}; -} +import { getEnsManagerAddressDetailsUrl } from "@/utils/ensManager.ts"; export interface IdentityLinkDetails { isExternal: boolean; @@ -129,7 +93,7 @@ export const IdentityTooltip = ({ break; } - const ensAppAddressDetailsUrl = getAddressDetailsUrl(identity.address, namespaceId); + const ensAppAddressDetailsUrl = getEnsManagerAddressDetailsUrl(identity.address, namespaceId); const body = ( @@ -138,7 +102,7 @@ export const IdentityTooltip = ({ ); const effectiveChainId = translateDefaultableChainIdToChainId(identity.chainId, namespaceId); - const chainExplorerUrl = getBlockExplorerUrlForAddress(effectiveChainId, identity.address); + const chainExplorerUrl = getBlockExplorerAddressDetailsUrl(effectiveChainId, identity.address); return ( diff --git a/packages/namehash-ui/src/components/identity/Name.tsx b/packages/namehash-ui/src/components/identity/Name.tsx new file mode 100644 index 000000000..69e5e55c9 --- /dev/null +++ b/packages/namehash-ui/src/components/identity/Name.tsx @@ -0,0 +1,18 @@ +import type { Name } from "@ensnode/ensnode-sdk"; +import { beautifyName } from "@ensnode/ensnode-sdk"; + +interface NameDisplayProps { + name: Name; + className?: string; +} + +/** + * Displays an ENS name in beautified form. + * + * @param name - The name to display in beautified form. + * + */ +export function NameDisplay({ name, className = "nhui:font-medium" }: NameDisplayProps) { + const beautifiedName = beautifyName(name); + return {beautifiedName}; +} diff --git a/packages/namehash-ui/src/components/identity/ResolveAndDisplayIdentity.tsx b/packages/namehash-ui/src/components/identity/ResolveAndDisplayIdentity.tsx index 7a7121b3a..10bd5a0f1 100644 --- a/packages/namehash-ui/src/components/identity/ResolveAndDisplayIdentity.tsx +++ b/packages/namehash-ui/src/components/identity/ResolveAndDisplayIdentity.tsx @@ -11,18 +11,18 @@ import { } from "@ensnode/ensnode-sdk"; import { ChainIcon } from "@/components/chains/ChainIcon.tsx"; -import { Skeleton } from "@/components/ui/skeleton"; -import { useIsMobile } from "@/hooks/useMobile.tsx"; -import { cn } from "@/utils/cn.ts"; - -import { EnsAvatar } from "./EnsAvatar.tsx"; +import { AddressDisplay } from "@/components/identity/Address.tsx"; import { - AddressDisplay, IdentityLink, type IdentityLinkDetails, IdentityTooltip, - NameDisplay, -} from "./utils"; +} from "@/components/identity/Identity.tsx"; +import { NameDisplay } from "@/components/identity/Name.tsx"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useIsMobile } from "@/hooks/useIsMobile.tsx"; +import { cn } from "@/utils/cn.ts"; + +import { EnsAvatar } from "./EnsAvatar.tsx"; export interface ResolveAndDisplayIdentityProps { identity: UnresolvedIdentity; @@ -40,8 +40,7 @@ export interface ResolveAndDisplayIdentityProps { * Resolves the provided `UnresolvedIdentity` through ENSNode and displays the result. * * @param identity - The `UnresolvedIdentity` to resolve and display. - * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'holesky', - * 'ens-test-env') + * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'ens-test-env') * @param accelerate - Whether to attempt Protocol Acceleration (default: false) * when resolving the primary name. * @param withLink - Whether to wrap the displayed identity in an `IdentityLink` component. @@ -102,8 +101,7 @@ interface DisplayIdentityProps { * * @param identity - The identity to display. May be a `ResolvedIdentity` or an `UnresolvedIdentity`. * If not a `ResolvedIdentity` (and therefore just an `UnresolvedIdentity`) then displays a loading state. - * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'holesky', - * 'ens-test-env') + * @param namespaceId - The ENSNamespace identifier (e.g. 'mainnet', 'sepolia', 'ens-test-env') * @param withLink - Whether to wrap the displayed identity in an `IdentityLink` component. * @param identityLinkDetails - If the `withLink` is true, provides info on where should it lead and should it be an external link. * @param withTooltip - Whether to wrap the displayed identity in an `IdentityInfoTooltip` component. diff --git a/packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx b/packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx index 7717c07bf..8b36bf17f 100644 --- a/packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx +++ b/packages/namehash-ui/src/components/registrar-actions/RegistrarActionCard.tsx @@ -18,15 +18,16 @@ import { import { DisplayDuration } from "@/components/datetime/DisplayDuration.tsx"; import { RelativeTime } from "@/components/datetime/RelativeTime.tsx"; +import type { IdentityLinkDetails } from "@/components/identity/Identity.tsx"; +import { NameDisplay } from "@/components/identity/Name.tsx"; import { ResolveAndDisplayIdentity, type ResolveAndDisplayIdentityProps, } from "@/components/identity/ResolveAndDisplayIdentity.tsx"; -import { type IdentityLinkDetails, NameDisplay } from "@/components/identity/utils.tsx"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx"; -import { useIsMobile } from "@/hooks/useMobile.tsx"; +import { useIsMobile } from "@/hooks/useIsMobile.tsx"; +import { getBlockExplorerTransactionDetailsUrl } from "@/utils/blockExplorers.ts"; import { cn } from "@/utils/cn.ts"; -import { getBlockExplorerUrlForTransactionHash } from "@/utils/namespace.ts"; interface LabeledFieldProps { fieldLabel: string; @@ -80,7 +81,7 @@ function ResolveAndDisplayReferrerIdentity({ !isRegistrarActionReferralAvailable(referral) || referral.encodedReferrer === ZERO_ENCODED_REFERRER ) { - // when we only want to display avatar (without textual identifier) don't display anything (return en empty placeholder). + // when we only want to display avatar (without textual identifier) don't display anything (return an empty placeholder). // Otherwise, display a hyphen with no avatar return withAvatar && !withIdentifier ? (
@@ -243,7 +244,7 @@ export function RegistrarActionCard({ namedRegistrarAction.action; const { chainId } = registrationLifecycle.subregistry.subregistryId; - const transactionDetailUrl = getBlockExplorerUrlForTransactionHash(chainId, transactionHash); + const transactionDetailUrl = getBlockExplorerTransactionDetailsUrl(chainId, transactionHash); const withTransactionLink = ({ children }: PropsWithChildren) => // wrap `children` content with a transaction link only if the URL is defined transactionDetailUrl ? ( diff --git a/packages/namehash-ui/src/components/ui/README.md b/packages/namehash-ui/src/components/ui/README.md new file mode 100644 index 000000000..618cfbe2c --- /dev/null +++ b/packages/namehash-ui/src/components/ui/README.md @@ -0,0 +1,5 @@ +# Attention + +All the files in this directory are part of [shadcn/ui's suite of reusable components](https://ui.shadcn.com/docs/components), and **shouldn't be modified** at all. + +If you want to create a similar component with slightly different behavior/UI, create a separate file where you use the shadcn's components as base and introduce your changes there. For an example see the [EnsAvatar](../identity/EnsAvatar.tsx) component. \ No newline at end of file diff --git a/packages/namehash-ui/src/hooks/useMobile.tsx b/packages/namehash-ui/src/hooks/useIsMobile.tsx similarity index 100% rename from packages/namehash-ui/src/hooks/useMobile.tsx rename to packages/namehash-ui/src/hooks/useIsMobile.tsx diff --git a/packages/namehash-ui/src/index.ts b/packages/namehash-ui/src/index.ts index e3fc25863..6a74f7db0 100644 --- a/packages/namehash-ui/src/index.ts +++ b/packages/namehash-ui/src/index.ts @@ -16,6 +16,9 @@ export * from "./components/icons/socials/FarcasterIcon"; export * from "./components/icons/socials/GitHubIcon"; export * from "./components/icons/socials/TelegramIcon"; export * from "./components/icons/socials/TwitterIcon"; +export * from "./components/identity/Address"; export * from "./components/identity/EnsAvatar"; +export * from "./components/identity/Identity"; +export * from "./components/identity/Name"; export * from "./components/identity/ResolveAndDisplayIdentity"; export * from "./components/registrar-actions/RegistrarActionCard"; diff --git a/packages/namehash-ui/src/utils/blockExplorers.ts b/packages/namehash-ui/src/utils/blockExplorers.ts new file mode 100644 index 000000000..d118439bd --- /dev/null +++ b/packages/namehash-ui/src/utils/blockExplorers.ts @@ -0,0 +1,53 @@ +import type { Address, Hash } from "viem"; + +import type { ChainId } from "@ensnode/ensnode-sdk"; + +import { SUPPORTED_CHAINS } from "@/utils/chains.ts"; + +/** + * Gets the "base" block explorer URL for a given {@link ChainId} + * + * @returns default block explorer URL for the chain with the provided id, + * or null if the referenced chain doesn't have a known block explorer + */ +export const getBlockExplorerUrl = (chainId: ChainId): URL | null => { + const chain = SUPPORTED_CHAINS.find((chain) => chain.id === chainId); + if (!chain) return null; + + // NOTE: anvil/ens-test-env chain does not have a blockExplorer + if (!chain.blockExplorers) return null; + + return new URL(chain.blockExplorers.default.url); +}; + +/** + * Gets the block explorer URL for a specific address on a specific chainId + * + * @returns complete block explorer URL for a specific address on a specific chainId, + * or null if the referenced chain doesn't have a known block explorer + */ +export const getBlockExplorerAddressDetailsUrl = ( + chainId: ChainId, + address: Address, +): URL | null => { + const chainBlockExplorer = getBlockExplorerUrl(chainId); + if (!chainBlockExplorer) return null; + + return new URL(`address/${address}`, chainBlockExplorer.toString()); +}; + +/** + * Gets the block explorer URL for a specific transaction hash on a specific chainId + * + * @returns complete block explorer URL for a specific transaction hash on a specific chainId, + * or null if the referenced chain doesn't have a known block explorer + */ +export const getBlockExplorerTransactionDetailsUrl = ( + chainId: ChainId, + transactionHash: Hash, +): URL | null => { + const chainBlockExplorer = getBlockExplorerUrl(chainId); + if (!chainBlockExplorer) return null; + + return new URL(`tx/${transactionHash}`, chainBlockExplorer.toString()); +}; diff --git a/packages/namehash-ui/src/utils/chains.ts b/packages/namehash-ui/src/utils/chains.ts new file mode 100644 index 000000000..178519e07 --- /dev/null +++ b/packages/namehash-ui/src/utils/chains.ts @@ -0,0 +1,62 @@ +import { + arbitrum, + arbitrumSepolia, + base, + baseSepolia, + linea, + lineaSepolia, + mainnet, + optimism, + optimismSepolia, + scroll, + scrollSepolia, + sepolia, +} from "viem/chains"; + +import { ensTestEnvL1Chain } from "@ensnode/datasources"; +import type { ChainId } from "@ensnode/ensnode-sdk"; + +export const SUPPORTED_CHAINS = [ + ensTestEnvL1Chain, + mainnet, + sepolia, + base, + baseSepolia, + linea, + lineaSepolia, + optimism, + optimismSepolia, + arbitrum, + arbitrumSepolia, + scroll, + scrollSepolia, +]; + +/** + * Mapping of {@link ChainId} to prettified chain name. + * + * NOTE: We prefer our custom names here, rather than those provided by default in `Chain#name`. + */ +const CUSTOM_CHAIN_NAMES = new Map([ + [ensTestEnvL1Chain.id, "Ethereum Local (ens-test-env)"], + [mainnet.id, "Ethereum"], + [sepolia.id, "Ethereum Sepolia"], + [base.id, "Base"], + [baseSepolia.id, "Base Sepolia"], + [linea.id, "Linea"], + [lineaSepolia.id, "Linea Sepolia"], + [optimism.id, "Optimism"], + [optimismSepolia.id, "Optimism Sepolia"], + [arbitrum.id, "Arbitrum"], + [arbitrumSepolia.id, "Arbitrum Sepolia"], + [scroll.id, "Scroll"], + [scrollSepolia.id, "Scroll Sepolia"], +]); + +/** + * Returns a prettified chain name for the provided chain id. + */ +export function getChainName(chainId: ChainId): string { + const name = CUSTOM_CHAIN_NAMES.get(chainId); + return name || `Unknown Chain (${chainId})`; +} diff --git a/packages/namehash-ui/src/utils/ensManager.ts b/packages/namehash-ui/src/utils/ensManager.ts new file mode 100644 index 000000000..bf0a310bf --- /dev/null +++ b/packages/namehash-ui/src/utils/ensManager.ts @@ -0,0 +1,52 @@ +import type { Address } from "viem"; + +import type { ENSNamespaceId } from "@ensnode/datasources"; +import { ENSNamespaceIds, type Name } from "@ensnode/ensnode-sdk"; + +/** + * Get the ENS Manager App URL for the provided namespace. + * + * @param {ENSNamespaceId} namespaceId - ENS Namespace identifier + * @returns ENS Manager App URL for the provided namespace, or null if the provided namespace + * doesn't have a known ENS Manager App + */ +export function getEnsManagerUrl(namespaceId: ENSNamespaceId): URL | null { + switch (namespaceId) { + case ENSNamespaceIds.Mainnet: + return new URL(`https://app.ens.domains/`); + case ENSNamespaceIds.Sepolia: + return new URL(`https://sepolia.app.ens.domains/`); + case ENSNamespaceIds.EnsTestEnv: + // ens-test-env runs on a local chain and is not supported by app.ens.domains + return null; + } +} + +/** + * Builds the URL of the external ENS Manager App Profile page for a given name and ENS Namespace. + * + * @returns URL to the Profile page in the external ENS Manager App for a given name and ENS Namespace, + * or null if this URL is not known + */ +export function getEnsManagerNameDetailsUrl(name: Name, namespaceId: ENSNamespaceId): URL | null { + const baseUrl = getEnsManagerUrl(namespaceId); + if (!baseUrl) return null; + + return new URL(name, baseUrl); +} + +/** + * Get the URL of the address details page in ENS Manager App for a given address and ENS Namespace. + * + * @returns URL to the address details page in the ENS Manager App for a given address and ENS + * Namespace, or null if this URL is not known + */ +export function getEnsManagerAddressDetailsUrl( + address: Address, + namespaceId: ENSNamespaceId, +): URL | null { + const baseUrl = getEnsManagerUrl(namespaceId); + if (!baseUrl) return null; + + return new URL(address, baseUrl); +} diff --git a/packages/namehash-ui/src/utils/ensMetadata.ts b/packages/namehash-ui/src/utils/ensMetadata.ts new file mode 100644 index 000000000..7b3a6ab99 --- /dev/null +++ b/packages/namehash-ui/src/utils/ensMetadata.ts @@ -0,0 +1,32 @@ +import type { ENSNamespaceId } from "@ensnode/datasources"; +import { ENSNamespaceIds, type Name } from "@ensnode/ensnode-sdk"; + +/** + * Build the avatar image URL for a name on the given ENS Namespace that (once fetched) would + * load the avatar image for the given name from the ENS Metadata Service + * (https://metadata.ens.domains/docs). + * + * The returned URL is dynamically built based on the provided ENS namespace. Not all ENS + * namespaces are supported by the ENS Metadata Service. Therefore, the returned URL may + * be null. + * + * @param {Name} name - ENS name to build the avatar image URL for + * @param {ENSNamespaceId} namespaceId - ENS Namespace identifier + * @returns avatar image URL for the name on the given ENS Namespace, or null if the given + * ENS namespace is not supported by the ENS Metadata Service + */ +export function getEnsMetadataServiceAvatarUrl( + name: Name, + namespaceId: ENSNamespaceId, +): URL | null { + switch (namespaceId) { + case ENSNamespaceIds.Mainnet: + return new URL(name, `https://metadata.ens.domains/mainnet/avatar/`); + case ENSNamespaceIds.Sepolia: + return new URL(name, `https://metadata.ens.domains/sepolia/avatar/`); + case ENSNamespaceIds.EnsTestEnv: + // ens-test-env runs on a local chain and is not supported by metadata.ens.domains + // TODO: Above comment is not true. Details at https://github.com/namehash/ensnode/issues/1078 + return null; + } +} diff --git a/packages/namehash-ui/src/utils/namespace.ts b/packages/namehash-ui/src/utils/namespace.ts deleted file mode 100644 index 34bec6cb1..000000000 --- a/packages/namehash-ui/src/utils/namespace.ts +++ /dev/null @@ -1,184 +0,0 @@ -import type { Address, Hash } from "viem"; -import { - arbitrum, - arbitrumSepolia, - base, - baseSepolia, - linea, - lineaSepolia, - mainnet, - optimism, - optimismSepolia, - scroll, - scrollSepolia, - sepolia, -} from "viem/chains"; - -import type { ENSNamespaceId } from "@ensnode/datasources"; -import { ensTestEnvL1Chain } from "@ensnode/datasources"; -import { type ChainId, ENSNamespaceIds, type Name } from "@ensnode/ensnode-sdk"; - -const SUPPORTED_CHAINS = [ - ensTestEnvL1Chain, - mainnet, - sepolia, - base, - baseSepolia, - linea, - lineaSepolia, - optimism, - optimismSepolia, - arbitrum, - arbitrumSepolia, - scroll, - scrollSepolia, -]; - -/** - * Mapping of chain id to prettified chain name. - * - * NOTE: We prefer our custom names here, rather than those provided by default in `Chain#name`. - */ -const CUSTOM_CHAIN_NAMES = new Map([ - [ensTestEnvL1Chain.id, "Ethereum Local (ens-test-env)"], - [mainnet.id, "Ethereum"], - [sepolia.id, "Ethereum Sepolia"], - [base.id, "Base"], - [baseSepolia.id, "Base Sepolia"], - [linea.id, "Linea"], - [lineaSepolia.id, "Linea Sepolia"], - [optimism.id, "Optimism"], - [optimismSepolia.id, "Optimism Sepolia"], - [arbitrum.id, "Arbitrum"], - [arbitrumSepolia.id, "Arbitrum Sepolia"], - [scroll.id, "Scroll"], - [scrollSepolia.id, "Scroll Sepolia"], -]); - -/** - * Get the ENS Manager App URL for the provided namespace. - * - * @param {ENSNamespaceId} namespaceId - ENS Namespace identifier - * @returns ENS Manager App URL for the provided namespace, or null if the provided namespace - * doesn't have a known ENS Manager App - */ -export function getEnsManagerAppUrl(namespaceId: ENSNamespaceId): URL | null { - switch (namespaceId) { - case ENSNamespaceIds.Mainnet: - return new URL(`https://app.ens.domains/`); - case ENSNamespaceIds.Sepolia: - return new URL(`https://sepolia.app.ens.domains/`); - case ENSNamespaceIds.EnsTestEnv: - // ens-test-env runs on a local chain and is not supported by app.ens.domains - return null; - } -} - -/** - * Builds the URL of the external ENS Manager App Profile page for a given name and ENS Namespace. - * - * @returns URL to the Profile page in the external ENS Manager App for a given name and ENS Namespace, - * or null if this URL is not known - */ -export function buildExternalEnsAppProfileUrl(name: Name, namespaceId: ENSNamespaceId): URL | null { - const baseUrl = getEnsManagerAppUrl(namespaceId); - if (!baseUrl) return null; - - return new URL(name, baseUrl); -} - -/** - * Build the avatar image URL for a name on the given ENS Namespace that (once fetched) would - * load the avatar image for the given name from the ENS Metadata Service - * (https://metadata.ens.domains/docs). - * - * The returned URL is dynamically built based on the provided ENS namespace. Not all ENS - * namespaces are supported by the ENS Metadata Service. Therefore, the returned URL may - * be null. - * - * @param {Name} name - ENS name to build the avatar image URL for - * @param {ENSNamespaceId} namespaceId - ENS Namespace identifier - * @returns avatar image URL for the name on the given ENS Namespace, or null if the given - * ENS namespace is not supported by the ENS Metadata Service - */ -export function buildEnsMetadataServiceAvatarUrl( - name: Name, - namespaceId: ENSNamespaceId, -): URL | null { - switch (namespaceId) { - case ENSNamespaceIds.Mainnet: - return new URL(name, `https://metadata.ens.domains/mainnet/avatar/`); - case ENSNamespaceIds.Sepolia: - return new URL(name, `https://metadata.ens.domains/sepolia/avatar/`); - case ENSNamespaceIds.EnsTestEnv: - // ens-test-env runs on a local chain and is not supported by metadata.ens.domains - // TODO: Above comment is not true. Details at https://github.com/namehash/ensnode/issues/1078 - return null; - } -} - -/** - * Get the URL of the address details page in ENS Manager App for a given address and ENS Namespace. - * - * @returns URL to the address details page in the ENS Manager App for a given address and ENS - * Namespace, or null if this URL is not known - */ -export function getAddressDetailsUrl(address: Address, namespaceId: ENSNamespaceId): URL | null { - const baseUrl = getEnsManagerAppUrl(namespaceId); - if (!baseUrl) return null; - - return new URL(address, baseUrl); -} - -/** - * Gets the base block explorer URL for a given chainId - * - * @returns default block explorer URL for the chain with the provided id, - * or null if the referenced chain doesn't have a known block explorer - */ -export const getChainBlockExplorerUrl = (chainId: ChainId): URL | null => { - const chain = SUPPORTED_CHAINS.find((chain) => chain.id === chainId); - if (!chain) return null; - - // NOTE: anvil/ens-test-env chain does not have a blockExplorer - if (!chain.blockExplorers) return null; - - return new URL(chain.blockExplorers.default.url); -}; - -/** - * Gets the block explorer URL for a specific address on a specific chainId - * - * @returns complete block explorer URL for a specific address on a specific chainId, - * or null if the referenced chain doesn't have a known block explorer - */ -export const getBlockExplorerUrlForAddress = (chainId: ChainId, address: Address): URL | null => { - const chainBlockExplorer = getChainBlockExplorerUrl(chainId); - if (!chainBlockExplorer) return null; - - return new URL(`address/${address}`, chainBlockExplorer.toString()); -}; - -/** - * Gets the block explorer URL for a specific transaction hash on a specific chainId - * - * @returns complete block explorer URL for a specific transaction hash on a specific chainId, - * or null if the referenced chain doesn't have a known block explorer - */ -export const getBlockExplorerUrlForTransactionHash = ( - chainId: ChainId, - transactionHash: Hash, -): URL | null => { - const chainBlockExplorer = getChainBlockExplorerUrl(chainId); - if (!chainBlockExplorer) return null; - - return new URL(`tx/${transactionHash}`, chainBlockExplorer.toString()); -}; - -/** - * Returns a prettified chain name for the provided chain id. - */ -export function getChainName(chainId: ChainId): string { - const name = CUSTOM_CHAIN_NAMES.get(chainId); - return name || `Unknown Chain (${chainId})`; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 90adb5e07..f515b9e23 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,6 +45,9 @@ catalogs: hono: specifier: ^4.10.2 version: 4.10.3 + lucide-react: + specifier: ^0.548.0 + version: 0.548.0 pg-connection-string: specifier: ^2.9.1 version: 2.9.1 @@ -54,6 +57,15 @@ catalogs: ponder: specifier: 0.15.17 version: 0.15.17 + tailwind-merge: + specifier: ^3.4.0 + version: 3.4.0 + tailwindcss: + specifier: ^4.1.18 + version: 4.1.18 + tailwindcss-animate: + specifier: ^1.0.7 + version: 1.0.7 tsup: specifier: ^8.3.6 version: 8.5.0 @@ -922,10 +934,10 @@ importers: specifier: ^2.1.1 version: 2.1.1 date-fns: - specifier: ^4.1.0 + specifier: 'catalog:' version: 4.1.0 lucide-react: - specifier: ^0.548.0 + specifier: 'catalog:' version: 0.548.0(react@19.2.1) react-dom: specifier: ^19.0.0 @@ -934,10 +946,10 @@ importers: specifier: ^2.0.3 version: 2.0.7(react-dom@19.2.1(react@19.2.1))(react@19.2.1) tailwind-merge: - specifier: ^3.4.0 + specifier: 'catalog:' version: 3.4.0 tailwindcss-animate: - specifier: ^1.0.7 + specifier: 'catalog:' version: 1.0.7(tailwindcss@4.1.18) tw-animate-css: specifier: ^1.4.0 @@ -968,7 +980,7 @@ importers: specifier: 19.2.1 version: 19.2.1 tailwindcss: - specifier: ^4.1.18 + specifier: 'catalog:' version: 4.1.18 tsup: specifier: ^8.3.6 @@ -12690,6 +12702,22 @@ snapshots: chai: 6.2.0 tinyrainbow: 3.0.3 + '@vitest/mocker@4.0.5(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.0.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + + '@vitest/mocker@4.0.5(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': + dependencies: + '@vitest/spy': 4.0.5 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1) + '@vitest/mocker@4.0.5(vite@7.1.12(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 4.0.5 @@ -17640,7 +17668,7 @@ snapshots: vitest@4.0.5(@types/debug@4.1.12)(@types/node@20.19.24)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.5 - '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@20.19.24)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.5 '@vitest/runner': 4.0.5 '@vitest/snapshot': 4.0.5 @@ -17680,7 +17708,7 @@ snapshots: vitest@4.0.5(@types/debug@4.1.12)(@types/node@22.18.13)(jiti@2.6.1)(jsdom@27.0.1(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1): dependencies: '@vitest/expect': 4.0.5 - '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/mocker': 4.0.5(vite@7.1.12(@types/node@22.18.13)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 4.0.5 '@vitest/runner': 4.0.5 '@vitest/snapshot': 4.0.5 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 149d7c5d5..60a779e87 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,9 +18,13 @@ catalog: date-fns: 4.1.0 drizzle-orm: 0.41.0 hono: ^4.10.2 + lucide-react: ^0.548.0 pg-connection-string: ^2.9.1 pino: 10.1.0 ponder: 0.15.17 + tailwindcss: ^4.1.18 + tailwindcss-animate: ^1.0.7 + tailwind-merge: ^3.4.0 tsup: ^8.3.6 typescript: ^5.7.3 viem: ^2.22.13