diff --git a/index.html b/index.html index 8753bb2..fac9f0b 100644 --- a/index.html +++ b/index.html @@ -14,6 +14,39 @@ document.documentElement.style.visibility = 'visible'; })(); + diff --git a/src/App.tsx b/src/App.tsx index 9f50a65..05977f5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -15,6 +15,7 @@ import { useNotificationSW } from '@/hooks/useNotificationSW'; import Schedule from '@/pages/Schedule'; import StellarSplit from '@/pages/StellarSplit'; import Names from '@/pages/Names'; +import NameProfile from '@/pages/NameProfile'; import NamesAuctions from '@/pages/NamesAuctions'; import Activity from '@/pages/Activity'; import Portfolio from '@/pages/Portfolio'; @@ -187,6 +188,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/lib/stellar/names.ts b/src/lib/stellar/names.ts index 469a88b..c0a7d37 100644 --- a/src/lib/stellar/names.ts +++ b/src/lib/stellar/names.ts @@ -19,6 +19,7 @@ export interface NameMetadata { avatar_url?: string; twitter_handle?: string; description?: string; + socials?: Record; } export interface NameRecord { @@ -48,6 +49,169 @@ export interface MetadataParams { metadata: NameMetadata; } +function asString(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (typeof value === 'string') return value; + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return String(value); + } + if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') { + const text = value.toString(); + return text && text !== '[object Object]' ? text : undefined; + } + return undefined; +} + +function asNumber(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isFinite(value)) return value; + if (typeof value === 'bigint') return Number(value); + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed)) return parsed; + } + return undefined; +} + +function normalizeMetadataValue(value: unknown): NameMetadata { + if (!value || typeof value !== 'object') return {}; + + if (value instanceof Map) { + const entries = Object.fromEntries( + Array.from(value.entries()).map(([key, entryValue]) => [String(key), entryValue]), + ); + return normalizeMetadataValue(entries); + } + + const record = value as Record; + const direct = record.metadata ?? record.meta ?? record.attributes ?? record; + const values = direct && typeof direct === 'object' ? (direct as Record) : {}; + + const metadata: NameMetadata = {}; + const fallbackValues = Object.entries(values).reduce>( + (acc, [key, entryValue]) => { + if ( + typeof entryValue === 'object' && + entryValue && + 'key' in entryValue && + 'value' in entryValue + ) { + acc[String((entryValue as { key?: unknown }).key)] = ( + entryValue as { value?: unknown } + ).value; + return acc; + } + acc[key] = entryValue; + return acc; + }, + {}, + ); + + const avatarUrl = asString( + fallbackValues.avatar_url ?? fallbackValues.avatarUrl ?? fallbackValues.avatar, + ); + const twitterHandle = asString( + fallbackValues.twitter_handle ?? fallbackValues.twitterHandle ?? fallbackValues.twitter, + ); + const description = asString( + fallbackValues.description ?? fallbackValues.bio ?? fallbackValues.summary, + ); + const socials = + typeof fallbackValues.socials === 'object' && fallbackValues.socials + ? (fallbackValues.socials as Record) + : undefined; + + if (avatarUrl) metadata.avatar_url = avatarUrl; + if (twitterHandle) metadata.twitter_handle = twitterHandle; + if (description) metadata.description = description; + if (socials) { + metadata.socials = Object.fromEntries( + Object.entries(socials) + .filter(([, v]) => typeof v !== 'undefined' && v !== null) + .map(([key, v]) => [key, asString(v) ?? String(v)]), + ); + } + + return metadata; +} + +function normalizeNameRecordResult(value: unknown, fallbackName: string): NameRecord | null { + if (value === null || value === undefined || value === false) return null; + + const root = (() => { + if (typeof value === 'object' && value && 'record' in value) + return (value as { record: unknown }).record; + if (typeof value === 'object' && value && 'result' in value) + return (value as { result: unknown }).result; + return value; + })(); + + if (!root || typeof root !== 'object') { + if (typeof root === 'string' || typeof root === 'number' || typeof root === 'bigint') { + return null; + } + return null; + } + + const obj = root as Record; + const owner = asString( + obj.owner ?? + obj.owner_address ?? + obj.ownerAddress ?? + obj.address ?? + obj.account ?? + obj.recipient, + ); + const expiresAt = asNumber( + obj.expires_at ?? obj.expiresAt ?? obj.expiration ?? obj.expiry ?? obj.expires, + ); + + const metadata = normalizeMetadataValue(obj.metadata ?? obj.meta ?? obj.attributes ?? obj); + const name = asString(obj.name) || fallbackName; + + if ( + !owner && + !expiresAt && + !metadata.avatar_url && + !metadata.description && + !metadata.twitter_handle + ) { + if (Array.isArray(root)) { + const first = root[0]; + if (first && typeof first === 'object') return normalizeNameRecordResult(first, fallbackName); + } + return null; + } + + return { + name, + owner: owner || '', + expires_at: expiresAt ? Math.floor(expiresAt) : 0, + metadata, + }; +} + +function parseSimulationReturnValue(value: unknown, fallbackName: string): NameRecord | null { + if (!value || typeof value !== 'object') return null; + + if (Array.isArray(value)) { + for (const item of value) { + const parsed = parseSimulationReturnValue(item, fallbackName); + if (parsed) return parsed; + } + return null; + } + + if (value instanceof Map) { + for (const entry of value.values()) { + const parsed = parseSimulationReturnValue(entry, fallbackName); + if (parsed) return parsed; + } + return null; + } + + return normalizeNameRecordResult(value, fallbackName); +} + export interface NameAuction { name: string; commitEnd: number; @@ -300,15 +464,14 @@ export async function checkAvailability(name: string): Promise { .build(), ); - if ('error' in result) { - // If error, name might not exist or other issue - assume unavailable to be safe - return false; - } + if ('error' in result) return false; + if (!result.result || !('retval' in result.result)) return true; - // If result exists, name is registered - return false; - } catch (error) { - // If simulation fails, assume name is available + const value = scValToNative(result.result.retval); + if (value === null || value === undefined || value === false) return true; + const owner = asString(value); + return owner !== undefined && owner !== '' && owner !== 'void'; + } catch { return true; } } @@ -473,18 +636,13 @@ export async function getNameRecord(name: string): Promise { .build(), ); - if ('error' in result) { + if ('error' in result || !result.result || !('retval' in result.result)) { return null; } - // Parse the result - this depends on actual contract return structure - // For now, return a placeholder - return { - name, - owner: '', - expires_at: 0, - metadata: {}, - }; + const nativeValue = scValToNative(result.result.retval); + const parsed = parseSimulationReturnValue(nativeValue, name); + return parsed; } catch { return null; } @@ -512,12 +670,29 @@ export async function getOwnedNames(ownerAddress: string): Promise { .build(), ); - if ('error' in result) { + if ('error' in result || !result.result || !('retval' in result.result)) { return []; } - // Parse the result - this depends on actual contract return structure - // For now, return empty array + const value = scValToNative(result.result.retval); + if (Array.isArray(value)) { + return value + .map((entry) => asString(entry)) + .filter((entry): entry is string => Boolean(entry)); + } + + if (value instanceof Set) { + return Array.from(value) + .map((entry) => asString(entry)) + .filter((entry): entry is string => Boolean(entry)); + } + + if (value instanceof Map) { + return Array.from(value.keys()) + .map((entry) => asString(entry)) + .filter((entry): entry is string => Boolean(entry)); + } + return []; } catch { return []; diff --git a/src/pages/NameProfile.tsx b/src/pages/NameProfile.tsx new file mode 100644 index 0000000..78b7e9f --- /dev/null +++ b/src/pages/NameProfile.tsx @@ -0,0 +1,299 @@ +import { useEffect, useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { getNameRecord, type NameRecord } from '@/lib/stellar/names'; +import { EmptyState } from '@/components/EmptyState'; +import { CopyButton } from '@/components/CopyButton'; + +interface SocialLink { + label: string; + href: string; + value: string; +} + +export default function NameProfile() { + const { name } = useParams<{ name: string }>(); + const navigate = useNavigate(); + const [record, setRecord] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + if (!name) return; + + const fetchRecord = async () => { + setIsLoading(true); + setError(false); + try { + const data = await getNameRecord(name); + if (!data) { + setError(true); + setRecord(null); + } else { + setRecord(data); + // Update document title + document.title = `${name} — Wraith Name`; + // Update meta tags + updateMetaTags(name, data); + } + } catch { + setError(true); + setRecord(null); + } finally { + setIsLoading(false); + } + }; + + fetchRecord(); + }, [name]); + + // Reset meta tags on unmount + useEffect(() => { + return () => { + document.title = 'Wraith Demo — Stealth Address SDK'; + resetMetaTags(); + }; + }, []); + + const isExpired = record ? record.expires_at <= Math.floor(Date.now() / 1000) : false; + + const handleSendClick = () => { + if (!record) return; + const recipient = record.name.endsWith('.wraith') ? record.name : `${record.name}.wraith`; + navigate(`/send?to=${encodeURIComponent(recipient)}`); + }; + + const socialLinks = getSocialLinks(record?.metadata ?? {}); + + if (isLoading) { + return ( +
+
+ + Wraith Names + +

+ Loading... +

+
+
+ ); + } + + if (error || !record) { + return ( +
+
+ + Wraith Names + +

+ {name} +

+
+ navigate('/names'), + }} + /> +
+ ); + } + + if (isExpired) { + return ( +
+
+ + Wraith Names + +

+ {name} +

+
+ navigate('/names'), + }} + /> +
+ ); + } + + const expiryDate = new Date(record.expires_at * 1000).toLocaleDateString(); + + return ( +
+
+ + Wraith Names + +

+ {name} +

+
+ +
+ {/* Avatar */} + {record.metadata.avatar_url && ( +
+ {`${name} +
+ )} + + {/* Description */} + {record.metadata.description && ( +
+

+ {record.metadata.description} +

+
+ )} + + {/* Social Links */} + {socialLinks.length > 0 && ( +
+ {socialLinks.map((link) => ( + + {link.label}: {link.value} + + ))} +
+ )} + + {/* Meta Address */} +
+ + Meta Address + +
+ {record.owner} + +
+
+ + {/* Expiry */} +
+ + Expires + + {expiryDate} +
+ + {/* Send CTA */} + +
+
+ ); +} + +function updateMetaTags(name: string, record: NameRecord) { + const description = record.metadata.description || `Send payments to ${name} on Wraith Protocol.`; + const profileUrl = `${window.location.origin}/n/${encodeURIComponent(name)}`; + + const ogTitle = ensureMetaTag('property', 'og:title'); + ogTitle.content = `${name} — Wraith Name`; + + const ogDescription = ensureMetaTag('property', 'og:description'); + ogDescription.content = description; + + const ogImage = ensureMetaTag('property', 'og:image'); + ogImage.content = record.metadata.avatar_url || '/og-image.png'; + + const ogUrl = ensureMetaTag('property', 'og:url'); + ogUrl.content = profileUrl; + + const twitterTitle = ensureMetaTag('name', 'twitter:title'); + twitterTitle.content = `${name} — Wraith Name`; + + const twitterDescription = ensureMetaTag('name', 'twitter:description'); + twitterDescription.content = description; + + const twitterImage = ensureMetaTag('name', 'twitter:image'); + twitterImage.content = record.metadata.avatar_url || '/og-image.png'; +} + +function resetMetaTags() { + const defaults = { + 'og:title': 'Wraith Demo — Stealth Address SDK', + 'og:description': + 'Send and receive private payments on Horizen and Stellar using stealth addresses.', + 'og:image': '/og-image.png', + 'og:url': 'https://demo.usewraith.xyz', + 'twitter:title': 'Wraith Demo — Stealth Address SDK', + 'twitter:description': + 'Send and receive private payments on Horizen and Stellar using stealth addresses.', + 'twitter:image': '/og-image.png', + }; + + Object.entries(defaults).forEach(([property, content]) => { + const meta = ensureMetaTag(property.startsWith('twitter:') ? 'name' : 'property', property); + meta.content = content; + }); +} + +function ensureMetaTag(attribute: 'name' | 'property', key: string) { + const selector = `meta[${attribute}="${key}"]`; + let meta = document.head.querySelector(selector) as HTMLMetaElement | null; + + if (!meta) { + meta = document.createElement('meta'); + meta.setAttribute(attribute, key); + document.head.appendChild(meta); + } + + return meta; +} + +function getSocialLinks(metadata: NameRecord['metadata']): SocialLink[] { + const links: SocialLink[] = []; + + if (metadata.twitter_handle) { + const handle = metadata.twitter_handle.replace(/^@/, ''); + links.push({ + label: 'Twitter', + href: `https://x.com/${handle}`, + value: metadata.twitter_handle, + }); + } + + const recordSocials = ( + metadata as Record & { + socials?: Record; + } + ).socials; + + if (recordSocials) { + Object.entries(recordSocials).forEach(([platform, value]) => { + if (!value) return; + const normalized = value.startsWith('http') ? value : `https://${value}`; + links.push({ + label: platform.charAt(0).toUpperCase() + platform.slice(1), + href: normalized, + value: value.replace(/^https?:\/\//, ''), + }); + }); + } + + return links; +}