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:
co-authored by
Claude Fable 5
parent
90bedc2a25
commit
3aebbcbbb8
@@ -155,8 +155,12 @@ import ConnectionBanner from '@/views/dashboard/ConnectionBanner.vue'
|
||||
import HealthNotifications from '@/views/dashboard/HealthNotifications.vue'
|
||||
import CompanionIntroOverlay from '@/components/CompanionIntroOverlay.vue'
|
||||
import { useRouteTransitions, isDetailRoute, ROUTE_BACKGROUNDS } from '@/views/dashboard/useRouteTransitions'
|
||||
import { useIbdFinishWatcher } from '@/composables/useIbdFinishWatcher'
|
||||
import '@/views/dashboard/dashboard-styles.css'
|
||||
|
||||
// Pops a "Finish setup" toast when Bitcoin IBD completes mid-Lightning-setup.
|
||||
useIbdFinishWatcher()
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const store = useAppStore()
|
||||
|
||||
@@ -98,12 +98,59 @@
|
||||
>
|
||||
{{ isInstalling ? t('common.installing') : t('goalDetail.installApp', { name: step.title.replace('Install ', '') }) }}
|
||||
</button>
|
||||
|
||||
<!-- Fund the bitcoin wallet: IBD-gated, with live sync timer -->
|
||||
<div v-else-if="step.action === 'fund'" class="space-y-3">
|
||||
<div v-if="!bitcoinSynced" class="p-3 rounded-lg bg-orange-500/10 border border-orange-500/20">
|
||||
<div class="flex items-center justify-between gap-3 mb-1.5">
|
||||
<span class="text-xs text-white/75">Bitcoin is syncing — funding unlocks when it finishes</span>
|
||||
<span class="text-xs font-mono text-orange-300 shrink-0">{{ bitcoinSyncLoaded ? bitcoinSyncPercent.toFixed(1) + '%' : '…' }}</span>
|
||||
</div>
|
||||
<div class="h-1.5 bg-white/10 rounded-full overflow-hidden mb-1.5">
|
||||
<div class="h-full bg-orange-400 rounded-full transition-all duration-700" :style="{ width: `${Math.min(100, bitcoinSyncPercent)}%` }" />
|
||||
</div>
|
||||
<p class="text-xs text-white/50">
|
||||
<span v-if="bitcoinSyncEtaText" class="text-white/70 font-medium">~{{ bitcoinSyncEtaText }} remaining</span>
|
||||
<span v-else>Estimating time remaining…</span>
|
||||
<span v-if="bitcoinBlockHeight"> · Block {{ bitcoinBlockHeight.toLocaleString() }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-white/45 mt-1.5">We'll pop a notification here the moment it's done.</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="p-3 rounded-lg bg-white/5 border border-white/10">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-xs text-white/60">On-chain wallet balance</span>
|
||||
<span class="text-sm font-mono" :class="walletOnchainSats >= ZEUS_CHANNEL_MIN_SATS ? 'text-green-400' : 'text-white/85'">
|
||||
{{ walletOnchainSats.toLocaleString() }} sats
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-xs text-white/45 mt-1">Zeus channels need 150,000–1,500,000 sats.</p>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@click="showFundModal = true"
|
||||
class="glass-button glass-button-warning glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
Fund Wallet
|
||||
</button>
|
||||
<button
|
||||
@click="completeFundStep(step)"
|
||||
:disabled="walletOnchainSats <= 0"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
|
||||
>
|
||||
{{ walletOnchainSats > 0 ? 'Continue' : 'Waiting for funds…' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else-if="step.action === 'configure'"
|
||||
@click="openConfigureStep(step)"
|
||||
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
{{ t('goalDetail.openAndConfigure') }}
|
||||
{{ step.ctaLabel ?? t('goalDetail.openAndConfigure') }}
|
||||
</button>
|
||||
<button
|
||||
v-else-if="step.action === 'verify'"
|
||||
@@ -142,12 +189,27 @@
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-white mb-2">{{ t('goalDetail.allSet') }}</h2>
|
||||
<p class="text-white/60 mb-6">{{ t('goalDetail.goalReady', { title: goal.title }) }}</p>
|
||||
<RouterLink to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
|
||||
<button
|
||||
v-if="completionCta"
|
||||
@click="openCompletionTarget"
|
||||
class="glass-button rounded-lg px-6 py-3 font-medium"
|
||||
>
|
||||
{{ completionCta.label }}
|
||||
</button>
|
||||
<RouterLink v-else to="/dashboard/apps" class="glass-button rounded-lg px-6 py-3 font-medium">
|
||||
{{ t('goalDetail.viewMyServices') }}
|
||||
</RouterLink>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Fund-wallet receive modal (on-chain address + QR, Zeus limits noted) -->
|
||||
<ReceiveBitcoinModal
|
||||
:show="showFundModal"
|
||||
note="Fund your Lightning channel: Zeus channels need a minimum of 150,000 and a maximum of 1,500,000 sats."
|
||||
auto-generate
|
||||
@close="showFundModal = false"
|
||||
/>
|
||||
|
||||
<!-- Action error toast -->
|
||||
<Transition name="fade">
|
||||
<div v-if="actionError" class="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 max-w-md w-full px-4" role="alert" aria-live="assertive">
|
||||
@@ -161,15 +223,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter, RouterLink } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
import { getGoalById } from '@/data/goals'
|
||||
import { useAppLauncherStore } from '@/stores/appLauncher'
|
||||
import { getGoalById, ZEUS_CHANNEL_MIN_SATS } from '@/data/goals'
|
||||
import type { GoalStep } from '@/types/goals'
|
||||
import { goalStepTargetPath } from './goals/goalStepActions'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
import ReceiveBitcoinModal from '@/components/ReceiveBitcoinModal.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import {
|
||||
acquireBitcoinSync,
|
||||
bitcoinSynced,
|
||||
bitcoinSyncLoaded,
|
||||
bitcoinSyncPercent,
|
||||
bitcoinBlockHeight,
|
||||
bitcoinSyncEtaText,
|
||||
} from '@/composables/useBitcoinSync'
|
||||
|
||||
/** Map appId to its icon file path under /assets/img/app-icons/ */
|
||||
const APP_ICON_MAP: Record<string, string> = {
|
||||
@@ -185,10 +258,27 @@ const APP_ICON_MAP: Record<string, string> = {
|
||||
}
|
||||
|
||||
function stepIconUrl(step: GoalStep): string | undefined {
|
||||
if (step.icon) return step.icon
|
||||
if (!step.appId) return undefined
|
||||
return APP_ICON_MAP[step.appId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the completion card sends the user: the app they just set up, not the
|
||||
* generic services list. `launchAppId` opens via the app launcher (iframe apps
|
||||
* overlay on top of the current screen; X-Frame-Options apps open a tab).
|
||||
*/
|
||||
const GOAL_COMPLETION_CTA: Record<string, { label: string; route?: string; launchAppId?: string }> = {
|
||||
'open-a-shop': { label: 'Go to my shop (BTCPay)', launchAppId: 'btcpay-server' },
|
||||
'accept-payments': { label: 'Go to Lightning (LND)', route: '/dashboard/apps/lnd' },
|
||||
'run-lightning-node': { label: 'View my channels', route: '/dashboard/apps/lnd/channels' },
|
||||
'setup-fedimint': { label: 'Open Fedimint', launchAppId: 'fedimint' },
|
||||
'file-browser': { label: 'Open File Browser', launchAppId: 'filebrowser' },
|
||||
'store-files': { label: 'Open my cloud (Nextcloud)', launchAppId: 'nextcloud' },
|
||||
'create-identity': { label: 'Go to my identity', route: '/dashboard/web5' },
|
||||
'back-up-everything': { label: 'Go to backups', route: '/dashboard/settings' },
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -214,7 +304,9 @@ const completedSteps = computed(() => {
|
||||
if (!goal.value) return new Set<string>()
|
||||
const completed = new Set<string>()
|
||||
for (const step of goal.value.steps) {
|
||||
if (step.appId && isAppInstalled(step.appId)) {
|
||||
// Only install steps auto-tick from package state — manual steps (fund the
|
||||
// wallet, open a channel, configure) must be walked through.
|
||||
if (step.action === 'install' && step.appId && isAppInstalled(step.appId)) {
|
||||
completed.add(step.id)
|
||||
}
|
||||
if (goalStore.progress[goalId.value]?.completedSteps.includes(step.id)) {
|
||||
@@ -331,6 +423,69 @@ function ensureGoalStarted() {
|
||||
function goBack() {
|
||||
router.push('/dashboard')
|
||||
}
|
||||
|
||||
// ── Fund-wallet step: live sync status + on-chain balance ───────────────────
|
||||
|
||||
const showFundModal = ref(false)
|
||||
const walletOnchainSats = ref(0)
|
||||
|
||||
const hasFundStep = computed(() => goal.value?.steps.some((s) => s.action === 'fund') ?? false)
|
||||
const fundStepActive = computed(() => {
|
||||
if (!goal.value || !hasFundStep.value) return false
|
||||
const active = goal.value.steps[activeStepIndex.value]
|
||||
return active?.action === 'fund' && overallStatus.value !== 'completed'
|
||||
})
|
||||
|
||||
let releaseSync: (() => void) | null = null
|
||||
let balanceTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
async function refreshWalletBalance() {
|
||||
try {
|
||||
const res = await rpcClient.call<{ balance_sats: number }>({ method: 'lnd.getinfo', timeout: 8000 })
|
||||
walletOnchainSats.value = res.balance_sats || 0
|
||||
} catch { /* LND not up yet — balance stays at last known value */ }
|
||||
}
|
||||
|
||||
watch(fundStepActive, (active) => {
|
||||
if (active) {
|
||||
if (!releaseSync) releaseSync = acquireBitcoinSync()
|
||||
void refreshWalletBalance()
|
||||
if (!balanceTimer) balanceTimer = setInterval(() => void refreshWalletBalance(), 15000)
|
||||
} else {
|
||||
if (releaseSync) { releaseSync(); releaseSync = null }
|
||||
if (balanceTimer) { clearInterval(balanceTimer); balanceTimer = null }
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// Refresh the balance right after the receive modal closes — the user may
|
||||
// have just sent funds.
|
||||
watch(showFundModal, (open) => { if (!open) void refreshWalletBalance() })
|
||||
|
||||
onUnmounted(() => {
|
||||
if (releaseSync) { releaseSync(); releaseSync = null }
|
||||
if (balanceTimer) { clearInterval(balanceTimer); balanceTimer = null }
|
||||
})
|
||||
|
||||
function completeFundStep(step: GoalStep) {
|
||||
ensureGoalStarted()
|
||||
goalStore.completeStep(goalId.value, step.id)
|
||||
}
|
||||
|
||||
// ── Completion CTA: go to the app you just set up ────────────────────────────
|
||||
|
||||
const completionCta = computed(() => (goal.value ? GOAL_COMPLETION_CTA[goal.value.id] : undefined))
|
||||
|
||||
function openCompletionTarget() {
|
||||
const cta = completionCta.value
|
||||
if (!cta) return
|
||||
if (cta.launchAppId) {
|
||||
// Iframe apps overlay on top of the current screen; X-Frame-Options apps
|
||||
// (BTCPay, Nextcloud…) open in a new tab.
|
||||
useAppLauncherStore().openSession(cta.launchAppId)
|
||||
} else if (cta.route) {
|
||||
router.push(cta.route)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -6,9 +6,16 @@ const STEP_ROUTE_OVERRIDES: Record<string, string> = {
|
||||
'create-passphrase': '/dashboard/settings',
|
||||
'create-backup': '/dashboard/settings',
|
||||
'save-backup': '/dashboard/settings',
|
||||
// Channel steps land directly on the Lightning channels screen (which
|
||||
// carries the "open a channel with Zeus" suggestion).
|
||||
'open-channel': '/dashboard/apps/lnd/channels',
|
||||
'open-channels': '/dashboard/apps/lnd/channels',
|
||||
'open-zeus-channel': '/dashboard/apps/lnd/channels',
|
||||
}
|
||||
|
||||
export function goalStepTargetPath(step: GoalStep): string | null {
|
||||
const override = STEP_ROUTE_OVERRIDES[step.id]
|
||||
if (override) return override
|
||||
if (step.appId) return `/dashboard/apps/${step.appId}`
|
||||
return STEP_ROUTE_OVERRIDES[step.id] ?? null
|
||||
return null
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user