diff --git a/src/components/ErrorBanner.tsx b/src/components/ErrorBanner.tsx new file mode 100644 index 0000000..e4588ad --- /dev/null +++ b/src/components/ErrorBanner.tsx @@ -0,0 +1,49 @@ +import { Pressable, StyleSheet, Text, View } from "react-native"; +import { colors, spacing } from "../theme"; + +interface ErrorBannerProps { + message: string; + onRetry: () => void; +} + +export function ErrorBanner({ message, onRetry }: ErrorBannerProps) { + return ( + + {message} + + Retry + + + ); +} + +const styles = StyleSheet.create({ + container: { + borderRadius: 12, + borderWidth: 1, + borderColor: "#fecaca", + backgroundColor: "#fef2f2", + padding: spacing.md, + gap: spacing.sm, + }, + message: { + color: colors.danger, + fontSize: 14, + }, + retryButton: { + alignSelf: "flex-start", + borderRadius: 8, + backgroundColor: colors.danger, + paddingHorizontal: 12, + paddingVertical: 8, + minHeight: 44, + minWidth: 44, + justifyContent: "center", + alignItems: "center", + }, + retryText: { + color: "#ffffff", + fontWeight: "600", + fontSize: 13, + }, +}); diff --git a/src/components/RegisterModal.tsx b/src/components/RegisterModal.tsx index b1c1d88..406da01 100644 --- a/src/components/RegisterModal.tsx +++ b/src/components/RegisterModal.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { ActivityIndicator, - Linking, Modal, Pressable, ScrollView, @@ -13,6 +12,7 @@ import { import { prepareRegister, submitRegister } from "../api/resources"; import type { Resource } from "../types"; import { colors, shared, typography } from "../theme"; +import { openExternalUrl, stellarExpertTxUrl } from "../utils/stellarExpert"; interface RegisterModalProps { visible: boolean; @@ -86,11 +86,8 @@ export function RegisterModal({ function handleOpenExplorer() { if (!txHash) return; - // Stellar testnet explorer - update for mainnet if needed - const explorerUrl = `https://stellar.expert/explorer/testnet/tx/${txHash}`; - Linking.openURL(explorerUrl).catch(() => { - onError("Failed to open explorer"); - }); + const explorerUrl = stellarExpertTxUrl({ txHash }); + openExternalUrl(explorerUrl).catch(() => onError("Failed to open explorer")); } function handleClose() { @@ -148,9 +145,11 @@ export function RegisterModal({ ✓ Registration successful! Transaction Hash: - - {txHash} - + + + {txHash} + + diff --git a/src/components/TransferOwnershipModal.tsx b/src/components/TransferOwnershipModal.tsx index aef0dfe..958de82 100644 --- a/src/components/TransferOwnershipModal.tsx +++ b/src/components/TransferOwnershipModal.tsx @@ -10,6 +10,7 @@ import { import { colors, shared, spacing, typography } from "../theme"; import { useTransferOwnership } from "../hooks/useTransferOwnership"; import type { Resource } from "../types"; +import { openExternalUrl, stellarExpertTxUrl } from "../utils/stellarExpert"; interface TransferOwnershipModalProps { resource: Resource; @@ -47,6 +48,14 @@ export function TransferOwnershipModal({ const isProcessing = ["preparing", "signing", "submitting"].includes(step); const isDone = step === "done"; + function handleOpenExplorer() { + if (!txHash) return; + const url = stellarExpertTxUrl({ txHash }); + openExternalUrl(url).catch(() => { + // no-op; this modal doesn't have an error callback and should remain usable + }); + } + function handleClose() { reset(); onClose(); @@ -133,9 +142,11 @@ export function TransferOwnershipModal({ ✓ {STEP_LABELS.done} {txHash ? ( - - Tx: {txHash} - + + + Tx: {txHash} + + ) : null} API: {getApiBaseUrl()} {error ? ( - - {error} - void loadData()} style={styles.retryButton}> - Retry - - + void loadData()} /> ) : null} {loading ? ( @@ -198,30 +194,6 @@ const styles = StyleSheet.create({ fontSize: 11, color: colors.textSubtle, }, - errorBanner: { - borderRadius: 12, - borderWidth: 1, - borderColor: "#fecaca", - backgroundColor: "#fef2f2", - padding: spacing.md, - gap: spacing.sm, - }, - errorText: { - color: colors.danger, - fontSize: 14, - }, - retryButton: { - alignSelf: "flex-start", - borderRadius: 8, - backgroundColor: colors.danger, - paddingHorizontal: 12, - paddingVertical: 8, - }, - retryText: { - color: "#ffffff", - fontWeight: "600", - fontSize: 13, - }, skeletons: { gap: 12, }, diff --git a/src/screens/ResourceDetailScreen.tsx b/src/screens/ResourceDetailScreen.tsx index b38fe92..b23c58a 100644 --- a/src/screens/ResourceDetailScreen.tsx +++ b/src/screens/ResourceDetailScreen.tsx @@ -15,6 +15,7 @@ import { fetchResource } from "../api/resources"; import type { RootStackParamList } from "../navigation"; import type { Resource } from "../types"; import { colors, shared, spacing, typography } from "../theme"; +import { openExternalUrl, stellarExpertAccountUrl, stellarExpertTxUrl } from "../utils/stellarExpert"; type Props = NativeStackScreenProps; @@ -90,6 +91,18 @@ export function ResourceDetailScreen({ route, navigation }: Props) { } } + function handleOpenOwner() { + if (!resource) return; + const url = stellarExpertAccountUrl({ accountId: resource.walletAddress }); + openExternalUrl(url).catch(() => setToast("Unable to open explorer")); + } + + function handleOpenTx() { + if (!resource?.onchainTxHash) return; + const url = stellarExpertTxUrl({ txHash: resource.onchainTxHash }); + openExternalUrl(url).catch(() => setToast("Unable to open explorer")); + } + if (loading) { return ( @@ -152,11 +165,28 @@ export function ResourceDetailScreen({ route, navigation }: Props) { Owner - - {shortenAddress(resource.walletAddress)} - + + + {shortenAddress(resource.walletAddress)} + + + {resource.onchainTxHash ? ( + + Tx + + + {resource.onchainTxHash} + + + + ) : null} + Type {resource.resourceType} @@ -239,6 +269,14 @@ const styles = StyleSheet.create({ color: colors.textSubtle, lineHeight: 18, }, + linkText: { + color: colors.primary, + }, + mono: { + fontFamily: "monospace", + fontSize: 12, + maxWidth: 220, + }, toast: { position: "absolute", bottom: spacing.xl, diff --git a/src/utils/stellarExpert.ts b/src/utils/stellarExpert.ts new file mode 100644 index 0000000..f0faab0 --- /dev/null +++ b/src/utils/stellarExpert.ts @@ -0,0 +1,62 @@ +import Constants from "expo-constants"; +import { Linking } from "react-native"; + +type StellarExpertNetworkSegment = "testnet" | "public"; + +const DEFAULT_NETWORK_PASSPHRASE = + (Constants.expoConfig?.extra?.networkPassphrase as string | undefined) ?? + "Test SDF Network ; September 2015"; + +function normalizeNetwork(input?: string): string | null { + const value = input?.trim().toLowerCase(); + return value && value.length > 0 ? value : null; +} + +function networkSegmentFromNetworkName(network?: string): StellarExpertNetworkSegment | null { + const n = normalizeNetwork(network); + if (!n) return null; + + if (n.includes("test")) return "testnet"; + if (n.includes("public") || n.includes("mainnet") || n.includes("main")) return "public"; + + return null; +} + +function networkSegmentFromPassphrase(passphrase?: string): StellarExpertNetworkSegment { + const p = normalizeNetwork(passphrase) ?? normalizeNetwork(DEFAULT_NETWORK_PASSPHRASE) ?? ""; + + if (p.includes("test sdf network")) return "testnet"; + if (p.includes("public global stellar network")) return "public"; + + // Default to testnet to match current app defaults. + return "testnet"; +} + +export function stellarExpertTxUrl(opts: { + txHash: string; + network?: string; + networkPassphrase?: string; +}): string { + const segment = + networkSegmentFromNetworkName(opts.network) ?? + networkSegmentFromPassphrase(opts.networkPassphrase); + return `https://stellar.expert/explorer/${segment}/tx/${encodeURIComponent(opts.txHash)}`; +} + +export function stellarExpertAccountUrl(opts: { + accountId: string; + network?: string; + networkPassphrase?: string; +}): string { + const segment = + networkSegmentFromNetworkName(opts.network) ?? + networkSegmentFromPassphrase(opts.networkPassphrase); + return `https://stellar.expert/explorer/${segment}/account/${encodeURIComponent(opts.accountId)}`; +} + +export async function openExternalUrl(url: string): Promise { + const canOpen = await Linking.canOpenURL(url); + if (!canOpen) throw new Error("Cannot open URL"); + await Linking.openURL(url); +} +