feat(wallet): app icons, mobile layout, and a funding mode on the Lightning modal
Demo images / Build & push demo images (push) Successful in 3m18s
Demo images / Build & push demo images (push) Successful in 3m18s
Icons: each node choice now shows its app icon (lnd.png; Core Lightning's is vendored from the Umbrel gallery as core-lightning.svg). Vendored rather than hotlinked on purpose — these nodes run offline/airgapped, and a remote image would both break there and leak a request to a third-party host on every render. A missing asset falls back to a neutral bolt glyph so a row can never render a broken-image box. Mobile: the choice row keeps icon + name + blurb together and drops the action to its own full-width line under 26rem, instead of squeezing the description into a two-word column next to a button. Funding mode: a node that is running but has no funds / no inbound liquidity is neither "install one" nor "start it", so the same modal gains a third mode that explains it and routes to the run-lightning-node goal, where funding and channel-opening already live — reusing that flow rather than duplicating it. It fires where the user actually meets the problem: on a failed attempt. handleLightningFailure() maps a running node's send/receive failure onto the funding modal, matched on message text because LND surfaces "no route", "no channels" and "insufficient balance" as plain strings with no distinct code — and all three mean the same thing to a user: fund me. Verified: 5 gate tests; npm run build clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fa26c5fc56
commit
700947ea3c
@@ -5,12 +5,17 @@
|
||||
same reasoning as ExternalExplorerModal. -->
|
||||
<BaseModal
|
||||
:show="lightning.show.value"
|
||||
:title="lightning.status.value === 'stopped' ? 'Lightning node not running' : 'Lightning node required'"
|
||||
:title="modalTitle"
|
||||
max-width="max-w-md"
|
||||
z-index="z-[3600]"
|
||||
@close="onClose"
|
||||
>
|
||||
<p v-if="lightning.status.value === 'stopped'" class="text-sm text-white/70 leading-relaxed">
|
||||
<p v-if="lightning.status.value === 'no-funds'" class="text-sm text-white/70 leading-relaxed">
|
||||
Your Lightning node is running, but it has no funds or inbound liquidity
|
||||
yet — so it can't send or be paid. The Lightning setup walks you through
|
||||
funding it and opening a channel.
|
||||
</p>
|
||||
<p v-else-if="lightning.status.value === 'stopped'" class="text-sm text-white/70 leading-relaxed">
|
||||
Lightning payments need a Lightning node that's actually running. Yours is
|
||||
installed but isn't running right now — start it from My Apps and try
|
||||
again.
|
||||
@@ -21,13 +26,27 @@
|
||||
it'll be installed for you.
|
||||
</p>
|
||||
|
||||
<div v-if="lightning.status.value !== 'stopped'" class="mt-4 space-y-2">
|
||||
<div v-if="lightning.status.value === 'absent'" class="mt-4 space-y-2">
|
||||
<div
|
||||
v-for="node in nodes"
|
||||
:key="node.id"
|
||||
class="rounded-xl border border-white/10 bg-white/[0.04] p-3"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<div class="ln-node-row flex items-start gap-3">
|
||||
<div class="ln-node-icon shrink-0">
|
||||
<img
|
||||
v-if="!failedIcons.has(node.id)"
|
||||
:src="node.icon"
|
||||
:alt="node.name"
|
||||
class="w-full h-full object-contain"
|
||||
@error="failedIcons.add(node.id)"
|
||||
/>
|
||||
<!-- No packaged icon yet (Core Lightning): a neutral bolt keeps the
|
||||
row aligned instead of showing a broken-image box. -->
|
||||
<svg v-else class="w-6 h-6 text-white/40" fill="none" stroke="currentColor" stroke-width="1.8" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-medium text-white">{{ node.name }}</span>
|
||||
@@ -41,7 +60,7 @@
|
||||
<button
|
||||
v-if="node.available"
|
||||
:disabled="installing !== null"
|
||||
class="shrink-0 glass-button glass-button-warning px-3.5 py-1.5 rounded-lg text-xs font-medium disabled:opacity-50"
|
||||
class="ln-node-action glass-button glass-button-warning rounded-lg text-xs font-medium disabled:opacity-50"
|
||||
@click="install(node.id)"
|
||||
>
|
||||
{{ installing === node.id ? 'Installing…' : 'Install' }}
|
||||
@@ -49,7 +68,7 @@
|
||||
<button
|
||||
v-else
|
||||
disabled
|
||||
class="shrink-0 glass-button px-3.5 py-1.5 rounded-lg text-xs opacity-40 cursor-not-allowed"
|
||||
class="ln-node-action glass-button rounded-lg text-xs opacity-40 cursor-not-allowed"
|
||||
>Install</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -71,12 +90,17 @@
|
||||
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
|
||||
@click="openApps"
|
||||
>Open My Apps</button>
|
||||
<button
|
||||
v-else-if="lightning.status.value === 'no-funds'"
|
||||
class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium"
|
||||
@click="openLightningSetup"
|
||||
>Set up Lightning</button>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import BaseModal from '@/components/BaseModal.vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
@@ -86,6 +110,8 @@ interface NodeChoice {
|
||||
id: string
|
||||
name: string
|
||||
blurb: string
|
||||
/** Falls back to a neutral bolt when the asset is missing. */
|
||||
icon: string
|
||||
/** False renders the row as "Coming soon" with a dead Install button. */
|
||||
available: boolean
|
||||
}
|
||||
@@ -99,21 +125,32 @@ const nodes: NodeChoice[] = [
|
||||
id: 'lnd',
|
||||
name: 'LND',
|
||||
blurb: 'Lightning Network Daemon. The implementation Archipelago ships today — wallet, channels and payments are wired to it.',
|
||||
icon: '/assets/img/app-icons/lnd.png',
|
||||
available: true,
|
||||
},
|
||||
{
|
||||
id: 'core-lightning',
|
||||
name: 'Core Lightning',
|
||||
blurb: 'Blockstream\'s implementation. Not packaged yet — it will appear here as a choice once it ships.',
|
||||
icon: '/assets/img/app-icons/core-lightning.svg',
|
||||
available: false,
|
||||
},
|
||||
]
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
if (lightningStatusIs('no-funds')) return 'Lightning not funded yet'
|
||||
if (lightningStatusIs('stopped')) return 'Lightning node not running'
|
||||
return 'Lightning node required'
|
||||
})
|
||||
const appStore = useAppStore()
|
||||
const lightning = useLightningRequired()
|
||||
|
||||
/** App id currently installing, or null. */
|
||||
/** Icons that 404'd — swapped for the inline bolt. */
|
||||
const failedIcons = ref(new Set<string>())
|
||||
|
||||
const installing = ref<string | null>(null)
|
||||
const error = ref('')
|
||||
|
||||
@@ -133,6 +170,17 @@ async function install(id: string) {
|
||||
}
|
||||
}
|
||||
|
||||
function lightningStatusIs(s: string) {
|
||||
return lightning.status.value === s
|
||||
}
|
||||
|
||||
/** The Lightning goal already owns funding + channel-opening, so reuse it
|
||||
* rather than duplicating that flow inside this modal. */
|
||||
function openLightningSetup() {
|
||||
lightning.close()
|
||||
router.push('/dashboard/goals/run-lightning-node')
|
||||
}
|
||||
|
||||
function openApps() {
|
||||
lightning.close()
|
||||
router.push('/dashboard/apps')
|
||||
@@ -143,3 +191,35 @@ function onClose() {
|
||||
lightning.close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.ln-node-icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
padding: 0.35rem;
|
||||
}
|
||||
.ln-node-action {
|
||||
flex-shrink: 0;
|
||||
padding: 0.375rem 0.875rem;
|
||||
}
|
||||
/* Narrow phones: the icon + name + blurb keep the top row, and the action
|
||||
drops to its own full-width line rather than squeezing the blurb into a
|
||||
two-word column. */
|
||||
@media (max-width: 26rem) {
|
||||
.ln-node-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ln-node-action {
|
||||
width: 100%;
|
||||
margin-top: 0.625rem;
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -191,6 +191,9 @@ async function receive() {
|
||||
emit('received')
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
// A running node with no inbound liquidity is a funding problem, not a
|
||||
// failure — reuse the Lightning modal in its funding mode instead.
|
||||
if (receiveMethod.value === 'lightning' && lightning.handleLightningFailure(err)) return
|
||||
error.value = receiveMethod.value === 'onchain'
|
||||
? explainReceiveAddressFailure(err)
|
||||
: err instanceof Error ? err.message : 'Failed'
|
||||
|
||||
@@ -665,6 +665,8 @@ async function send() {
|
||||
// Success pane (or the token pane for ecash mints) takes over the modal.
|
||||
confirming.value = false
|
||||
} catch (err: unknown) {
|
||||
// Running node with nothing to pay with -> funding modal, not a raw string.
|
||||
if (effectiveMethod.value === 'lightning' && lightning.handleLightningFailure(err)) return
|
||||
error.value = err instanceof Error ? err.message : t('web5.sendFailed')
|
||||
} finally {
|
||||
processing.value = false
|
||||
|
||||
@@ -28,8 +28,10 @@ export const LIGHTNING_NODE_APP_IDS = ['lnd'] as const
|
||||
|
||||
/** `absent` — nothing installed, offer to install one.
|
||||
* `stopped` — installed but not running, point the user at My Apps.
|
||||
* `no-funds` — running, but there is nothing to pay with / no inbound
|
||||
* liquidity to be paid into; send the user to the Lightning setup goal.
|
||||
* `running` — good to go. */
|
||||
export type LightningStatus = 'absent' | 'stopped' | 'running'
|
||||
export type LightningStatus = 'absent' | 'stopped' | 'running' | 'no-funds'
|
||||
|
||||
// Module-scope: one source of truth shared by every caller and the single
|
||||
// global modal mounted in App.vue.
|
||||
@@ -73,5 +75,52 @@ export function useLightningRequired() {
|
||||
show.value = false
|
||||
}
|
||||
|
||||
return { show, status, lightningStatus, hasLightningNode, requireLightningNode, close }
|
||||
/**
|
||||
* Raise the same modal in its funding mode: the node is installed and
|
||||
* running, but has no usable balance/liquidity yet. Reuses this modal
|
||||
* rather than inventing a second one, and routes to the Lightning setup
|
||||
* goal where funding and channel-opening already live.
|
||||
*/
|
||||
function openLightningFunding() {
|
||||
status.value = 'no-funds'
|
||||
show.value = true
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a failed Lightning attempt onto the funding modal when the node is
|
||||
* running but has nothing to pay with / no inbound liquidity. Returns true
|
||||
* when it handled the error, so the caller can skip showing a raw string.
|
||||
*
|
||||
* Matched on the message because LND surfaces these as plain text: there is
|
||||
* no distinct error code for "no channels" vs "no route" vs "insufficient
|
||||
* balance", and all three mean the same thing to the user — fund me.
|
||||
*/
|
||||
function handleLightningFailure(err: unknown): boolean {
|
||||
if (lightningStatus() !== 'running') return false
|
||||
const msg = (err instanceof Error ? err.message : String(err ?? '')).toLowerCase()
|
||||
const fundingRelated = [
|
||||
'no route',
|
||||
'no routes',
|
||||
'insufficient',
|
||||
'no channel',
|
||||
'not enough',
|
||||
'balance',
|
||||
'unable to find a path',
|
||||
'no path',
|
||||
].some((needle) => msg.includes(needle))
|
||||
if (!fundingRelated) return false
|
||||
openLightningFunding()
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
show,
|
||||
status,
|
||||
lightningStatus,
|
||||
hasLightningNode,
|
||||
requireLightningNode,
|
||||
openLightningFunding,
|
||||
handleLightningFailure,
|
||||
close,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user