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);
+}
+