feat(setup): Zeus lightning journey — fund-wallet step, IBD timer + finish-setup toast, channel suggestions

Every Lightning setup (Open a Shop, Accept Payments, Run a Lightning Node)
gains a guided path to a working channel:

- New "Fund Your Bitcoin Wallet" step, gated on the blockchain finishing
  its initial sync: while syncing it shows a live progress bar with a
  counting-down time-remaining estimate; once synced it shows the on-chain
  balance and a "Fund Wallet" button that opens the receive modal with a
  fresh address, QR, and the Zeus channel limits (min 150,000 / max
  1,500,000 sats) noted.
- When the sync finishes while a Lightning setup is mid-flight, a toast
  pops with a "Finish setup" link straight back to the wizard (toasts now
  support action links). If several setups are in flight, one is chosen —
  the shared steps complete the lightning part of any of them.
- Channel steps are Zeus-branded (logo + copy) and land on the Lightning
  Channels screen, which now carries an "Open a channel with Zeus" card
  that prefills the open-channel modal with the Olympus peer URI, 150k
  sats, and private-channel checked. "Get Zeus" links to zeusln.com.
- Setup completion now actually requires walking the manual steps (fund,
  open channel, configure) — previously any step whose app was installed
  was silently auto-ticked and running apps marked the whole goal done.
- Completion CTAs now go to the app you just set up ("Go to my shop
  (BTCPay)" etc.) instead of the generic services list; iframe apps open
  in the on-top app overlay.
- On-chain send modal gains a "Send all funds" sweep toggle, backed by
  LND's send_all on the backend (amount no longer required when sweeping).
- Demo/mock: bitcoin.getinfo now returns the block_height/sync_progress
  contract the UI reads and simulates a ~90s IBD ramp per visitor so the
  timer, toast, and fund flow can all be demoed live; channel list data
  fixed (status/channel_point/liquidity totals — the panel previously
  crashed on the missing status field); sendcoins supports send_all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-07-16 21:51:12 -04:00
co-authored by Claude Fable 5
parent 90bedc2a25
commit 3aebbcbbb8
16 changed files with 658 additions and 63 deletions
@@ -16,6 +16,39 @@
</div>
</div>
<!-- Zeus channel suggestion -->
<div class="glass-card p-4 mb-4 border border-orange-500/25">
<div class="flex flex-col sm:flex-row sm:items-center gap-4">
<img
src="/assets/img/app-icons/zeus.webp"
alt="Zeus"
class="w-12 h-12 rounded-xl shrink-0 border border-white/10"
/>
<div class="flex-1 min-w-0">
<p class="text-white/90 text-sm font-semibold mb-0.5">Open a channel with Zeus</p>
<p class="text-white/55 text-xs leading-relaxed">
Pair your node with the Zeus mobile wallet open a channel to their Olympus node and
start sending and receiving Lightning payments from your phone.
Minimum 150,000 · maximum 1,500,000 sats.
</p>
</div>
<div class="flex sm:flex-col items-center gap-2 shrink-0">
<button
@click="openZeusChannel"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap"
>
Open Channel
</button>
<a
href="https://zeusln.com"
target="_blank"
rel="noopener noreferrer"
class="text-xs text-orange-400/80 hover:text-orange-300 whitespace-nowrap"
>Get Zeus </a>
</div>
</div>
</div>
<!-- Open Channel Button -->
<div class="flex justify-end mb-4">
<button @click="showOpenModal = true" class="glass-button px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2">
@@ -74,15 +107,15 @@
<span
class="w-2 h-2 rounded-full"
:class="{
'bg-green-400': ch.status === 'active',
'bg-yellow-400': ch.status === 'pending_open',
'bg-red-400': ch.status === 'inactive',
'bg-green-400': channelStatus(ch) === 'active',
'bg-yellow-400': channelStatus(ch) === 'pending_open',
'bg-red-400': channelStatus(ch) === 'inactive',
}"
></span>
<span class="text-white/80 text-sm font-medium capitalize">{{ ch.status.replace('_', ' ') }}</span>
<span class="text-white/80 text-sm font-medium capitalize">{{ channelStatus(ch).replace('_', ' ') }}</span>
</div>
<button
v-if="ch.status !== 'pending_open'"
v-if="channelStatus(ch) !== 'pending_open'"
@click="confirmClose(ch)"
class="text-red-400/70 hover:text-red-400 text-xs transition-colors"
>
@@ -270,8 +303,13 @@ interface Channel {
local_balance: number
remote_balance: number
active: boolean
status: string
channel_point: string
status?: string
channel_point?: string
}
/** Status with a fallback derived from `active` for backends that omit it */
function channelStatus(ch: Channel): string {
return ch.status ?? (ch.active ? 'active' : 'inactive')
}
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
@@ -288,6 +326,11 @@ const error = ref<string | null>(null)
const channels = ref<Channel[]>([])
const summary = ref({ total_inbound: 0, total_outbound: 0 })
// Olympus by ZEUS — the LSP node behind the Zeus mobile wallet.
// Channel limits: min 150,000 / max 1,500,000 sats.
const OLYMPUS_PEER_URI =
'031b301307574bbe9b9ac7b79cbe1700e31e544513eae0b5d7497483083f99e581@45.79.192.236:9735'
const showOpenModal = ref(false)
const defaultOpenForm = () => ({
peerUri: '',
@@ -297,6 +340,19 @@ const defaultOpenForm = () => ({
customConfTarget: null as number | null,
customSatPerVbyte: null as number | null,
})
/** Prefill the open-channel modal for a Zeus (Olympus) channel */
function openZeusChannel() {
openForm.value = {
...defaultOpenForm(),
peerUri: OLYMPUS_PEER_URI,
amount: 150000,
// Olympus only accepts unannounced channels
private: true,
}
openError.value = null
showOpenModal.value = true
}
const openForm = ref(defaultOpenForm())
const openingChannel = ref(false)
const openError = ref<string | null>(null)
@@ -313,7 +369,7 @@ function formatSats(sats: number): string {
}
function fundingTxid(ch: Channel): string {
const txid = ch.channel_point.split(':')[0] || ''
const txid = ch.channel_point?.split(':')[0] || ''
return /^[0-9a-fA-F]{64}$/.test(txid) ? txid : ''
}
@@ -31,6 +31,9 @@
<!-- On-chain -->
<div v-if="receiveMethod === 'onchain'">
<div v-if="note" class="mb-3 p-3 rounded-lg bg-orange-500/10 border border-orange-500/20 text-sm text-white/80 leading-relaxed">
{{ note }}
</div>
<div v-if="onchainAddress" class="mb-3 p-3 bg-white/5 rounded-lg text-center">
<canvas ref="onchainQrCanvas" class="mx-auto mb-3 rounded-lg" style="image-rendering: pixelated;"></canvas>
<p class="text-white/50 text-xs mb-2">{{ t('receiveBitcoin.yourBitcoinAddress') }}</p>
@@ -77,7 +80,7 @@
</template>
<script setup lang="ts">
import { ref, nextTick } from 'vue'
import { ref, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
@@ -85,9 +88,21 @@ import { explainReceiveAddressFailure } from '@/utils/bitcoinReceive'
const { t } = useI18n()
defineProps<{ show: boolean }>()
const props = defineProps<{
show: boolean
/** Optional info banner shown on the on-chain tab (e.g. Zeus channel limits) */
note?: string
/** Generate an on-chain address immediately when the modal opens */
autoGenerate?: boolean
}>()
const emit = defineEmits<{ close: []; received: [] }>()
watch(() => props.show, (open) => {
if (open && props.autoGenerate && receiveMethod.value === 'onchain' && !onchainAddress.value) {
void receive()
}
})
const receiveMethod = ref<'lightning' | 'onchain' | 'ecash' | 'ark'>('onchain')
const invoiceAmount = ref<number>(0)
const invoiceMemo = ref('')
+48 -6
View File
@@ -16,8 +16,30 @@
</div>
<div class="mb-3">
<label class="text-white/60 text-sm block mb-1">{{ t('sendBitcoin.amountSats') }}</label>
<input v-model.number="amount" type="number" min="1" placeholder="1000" class="w-full input-glass" />
<div class="flex items-center justify-between mb-1">
<label class="text-white/60 text-sm">{{ t('sendBitcoin.amountSats') }}</label>
<button
v-if="sendMethod === 'onchain'"
@click="toggleSendAll"
class="text-xs px-2 py-0.5 rounded border transition-colors"
:class="sendAll
? 'bg-orange-500/20 border-orange-500/40 text-orange-300'
: 'bg-white/5 border-white/15 text-white/60 hover:text-white/90'"
>
Send all funds
</button>
</div>
<input
v-model.number="amount"
type="number"
min="1"
:placeholder="sendAll ? '' : '1000'"
:disabled="sendAll"
class="w-full input-glass disabled:opacity-50"
/>
<p v-if="sendAll" class="text-xs text-white/50 mt-1">
Sweeps your entire on-chain balance{{ onchainBalance !== null ? ` (~${onchainBalance.toLocaleString()} sats)` : '' }} minus network fees.
</p>
</div>
<div v-if="effectiveMethod !== 'ecash'" class="mb-3">
@@ -47,7 +69,7 @@
<div class="flex gap-3">
<button @click="close" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">{{ t('common.close') }}</button>
<button @click="send" :disabled="processing || !amount" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
<button @click="send" :disabled="processing || (!amount && !isSweep)" class="flex-1 glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50">
{{ processing ? t('common.sending') : t('common.send') }}
</button>
</div>
@@ -55,7 +77,7 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
@@ -75,6 +97,23 @@ const resultHash = ref('')
const resultArk = ref('')
const ecashToken = ref('')
// "Send all funds" — sweeps the whole on-chain balance (explicit on-chain tab only)
const sendAll = ref(false)
const onchainBalance = ref<number | null>(null)
const isSweep = computed(() => sendMethod.value === 'onchain' && sendAll.value)
function toggleSendAll() {
sendAll.value = !sendAll.value
if (sendAll.value && onchainBalance.value === null) {
rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 5000 })
.then((res) => { onchainBalance.value = res.balance_sats || 0 })
.catch(() => { /* balance hint is best-effort */ })
}
}
// Leaving the on-chain tab disarms the sweep so it can never apply elsewhere
watch(sendMethod, (m) => { if (m !== 'onchain') sendAll.value = false })
const effectiveMethod = computed(() => {
if (sendMethod.value !== 'auto') return sendMethod.value
const amt = amount.value || 0
@@ -98,7 +137,8 @@ function copyText(text: string) {
}
async function send() {
if (!amount.value || processing.value) return
if (processing.value) return
if (!amount.value && !isSweep.value) return
processing.value = true
error.value = ''
ecashToken.value = ''
@@ -134,7 +174,9 @@ async function send() {
if (!dest.value.trim()) { error.value = t('web5.enterBitcoinAddress'); return }
const res = await rpcClient.call<{ txid: string }>({
method: 'lnd.sendcoins',
params: { addr: dest.value.trim(), amount: amount.value },
params: isSweep.value
? { addr: dest.value.trim(), send_all: true }
: { addr: dest.value.trim(), amount: amount.value },
})
resultTxid.value = res.txid
}
+14 -2
View File
@@ -23,7 +23,14 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span class="text-sm text-white/90 flex-1">{{ toast.message }}</span>
<div class="flex-1 min-w-0">
<span class="text-sm text-white/90">{{ toast.message }}</span>
<button
v-if="toast.action"
@click.stop="runAction(toast)"
class="block mt-1 text-sm font-semibold text-orange-400 hover:text-orange-300 transition-colors"
>{{ toast.action.label }} </button>
</div>
</div>
</TransitionGroup>
</div>
@@ -32,10 +39,15 @@
<script setup lang="ts">
import { useToast } from '@/composables/useToast'
import type { ToastVariant } from '@/composables/useToast'
import type { ToastItem, ToastVariant } from '@/composables/useToast'
const { toasts, dismiss } = useToast()
function runAction(toast: ToastItem | Readonly<ToastItem>) {
toast.action?.onClick()
dismiss(toast.id)
}
function variantClass(variant: ToastVariant): string {
switch (variant) {
case 'success': return 'bg-black/70 border-green-500/30 backdrop-blur-md'