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
@@ -0,0 +1,109 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
|
||||
/**
|
||||
* Shared bitcoin sync (IBD) tracker with a live time-remaining estimate.
|
||||
*
|
||||
* Polls `bitcoin.getinfo` while at least one consumer holds an acquire()
|
||||
* lease, samples the sync rate, and exposes a ticking countdown so setup
|
||||
* screens can show "~2h 14m remaining" that visibly counts down between
|
||||
* polls. Module-level singleton — every consumer sees the same state.
|
||||
*/
|
||||
|
||||
/** Sync fraction (as percent) at which we consider IBD done, matching the Home tile */
|
||||
export const IBD_SYNCED_AT = 99.9
|
||||
|
||||
const POLL_MS = 15_000
|
||||
const TICK_MS = 1_000
|
||||
/** Ignore rate samples older than this when estimating */
|
||||
const SAMPLE_WINDOW_MS = 10 * 60_000
|
||||
|
||||
export const bitcoinSyncPercent = ref(0)
|
||||
export const bitcoinBlockHeight = ref(0)
|
||||
export const bitcoinSyncAvailable = ref(false)
|
||||
export const bitcoinSyncLoaded = ref(false)
|
||||
export const bitcoinSynced = computed(() => bitcoinSyncLoaded.value && bitcoinSyncPercent.value >= IBD_SYNCED_AT)
|
||||
|
||||
const etaSeconds = ref<number | null>(null)
|
||||
|
||||
/** Human countdown like "2h 14m" / "5m 12s" / "less than a minute", or '' while estimating */
|
||||
export const bitcoinSyncEtaText = computed(() => {
|
||||
const s = etaSeconds.value
|
||||
if (s === null) return ''
|
||||
if (s < 60) return 'less than a minute'
|
||||
const h = Math.floor(s / 3600)
|
||||
const m = Math.floor((s % 3600) / 60)
|
||||
if (h > 0) return `${h}h ${m}m`
|
||||
const sec = Math.floor(s % 60)
|
||||
return `${m}m ${sec}s`
|
||||
})
|
||||
|
||||
let samples: { t: number; p: number }[] = []
|
||||
let etaBase: { at: number; secs: number } | null = null
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null
|
||||
let leases = 0
|
||||
|
||||
async function poll() {
|
||||
try {
|
||||
const btc = await rpcClient.call<{ block_height: number; sync_progress: number }>({
|
||||
method: 'bitcoin.getinfo',
|
||||
timeout: 8000,
|
||||
})
|
||||
const pct = (btc.sync_progress ?? 0) * 100
|
||||
bitcoinSyncPercent.value = pct
|
||||
bitcoinBlockHeight.value = btc.block_height ?? 0
|
||||
bitcoinSyncAvailable.value = true
|
||||
bitcoinSyncLoaded.value = true
|
||||
|
||||
const now = Date.now()
|
||||
samples.push({ t: now, p: pct })
|
||||
samples = samples.filter((s) => now - s.t <= SAMPLE_WINDOW_MS).slice(-50)
|
||||
|
||||
if (pct >= IBD_SYNCED_AT) {
|
||||
etaBase = null
|
||||
etaSeconds.value = 0
|
||||
return
|
||||
}
|
||||
const first = samples[0]
|
||||
if (first && now - first.t >= 10_000 && pct > first.p) {
|
||||
const ratePerSec = (pct - first.p) / ((now - first.t) / 1000)
|
||||
etaBase = { at: now, secs: (IBD_SYNCED_AT - pct) / ratePerSec }
|
||||
}
|
||||
} catch {
|
||||
bitcoinSyncAvailable.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
if (!etaBase) {
|
||||
if (!bitcoinSynced.value) etaSeconds.value = null
|
||||
return
|
||||
}
|
||||
etaSeconds.value = Math.max(0, etaBase.secs - (Date.now() - etaBase.at) / 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold a polling lease. Returns a release function — call it on unmount.
|
||||
* Polling only runs while at least one lease is held.
|
||||
*/
|
||||
export function acquireBitcoinSync(): () => void {
|
||||
leases++
|
||||
if (leases === 1) {
|
||||
void poll()
|
||||
pollTimer = setInterval(() => void poll(), POLL_MS)
|
||||
tickTimer = setInterval(tick, TICK_MS)
|
||||
}
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) return
|
||||
released = true
|
||||
leases = Math.max(0, leases - 1)
|
||||
if (leases === 0) {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
if (tickTimer) clearInterval(tickTimer)
|
||||
pollTimer = null
|
||||
tickTimer = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { computed, watch, watchEffect, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { GOALS } from '@/data/goals'
|
||||
import { useGoalStore } from '@/stores/goals'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
acquireBitcoinSync,
|
||||
bitcoinSynced,
|
||||
bitcoinSyncLoaded,
|
||||
} from '@/composables/useBitcoinSync'
|
||||
|
||||
// Session-level guard: the "finish setup" toast fires at most once per page load.
|
||||
let firedThisSession = false
|
||||
|
||||
/**
|
||||
* Watches for Bitcoin IBD completing while a Lightning setup goal is mid-flight
|
||||
* and pops a "Finish setup" toast linking back to that goal's wizard (which is
|
||||
* sitting on the fund-wallet / open-channel steps). Mount once in the
|
||||
* dashboard layout.
|
||||
*/
|
||||
export function useIbdFinishWatcher() {
|
||||
const goalStore = useGoalStore()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
// A goal qualifies while it's in progress and its manual fund/channel steps
|
||||
// aren't done yet. If several qualify, the first wins — finishing the shared
|
||||
// fund + channel steps completes the lightning part of any of them.
|
||||
const pendingLightningGoalId = computed<string | null>(() => {
|
||||
if (firedThisSession) return null
|
||||
for (const goal of GOALS) {
|
||||
const hasFundStep = goal.steps.some((s) => s.action === 'fund')
|
||||
if (!hasFundStep) continue
|
||||
if (goalStore.getGoalStatus(goal.id) !== 'in-progress') continue
|
||||
const done = goalStore.progress[goal.id]?.completedSteps ?? []
|
||||
const manualPending = goal.steps.some(
|
||||
(s) => s.action !== 'install' && !done.includes(s.id),
|
||||
)
|
||||
if (manualPending) return goal.id
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
// Only poll the chain while there's actually a goal waiting on it.
|
||||
let release: (() => void) | null = null
|
||||
watchEffect(() => {
|
||||
const shouldWatch = pendingLightningGoalId.value !== null && !bitcoinSynced.value
|
||||
if (shouldWatch && !release) {
|
||||
release = acquireBitcoinSync()
|
||||
} else if (!shouldWatch && release) {
|
||||
// Goal finished/reset or the chain synced — stop polling.
|
||||
release()
|
||||
release = null
|
||||
}
|
||||
})
|
||||
|
||||
// Fire only on a REAL transition: we must have observed the chain unsynced
|
||||
// at least once this session, so a node that's already synced at page load
|
||||
// doesn't toast.
|
||||
let sawUnsynced = false
|
||||
watch([bitcoinSynced, bitcoinSyncLoaded], ([synced, loaded]) => {
|
||||
if (!loaded) return
|
||||
if (!synced) {
|
||||
sawUnsynced = true
|
||||
return
|
||||
}
|
||||
if (!sawUnsynced || firedThisSession) return
|
||||
const goalId = pendingLightningGoalId.value
|
||||
if (!goalId) return
|
||||
firedThisSession = true
|
||||
toast.action(
|
||||
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.',
|
||||
{
|
||||
label: 'Finish setup',
|
||||
onClick: () => { router.push(`/dashboard/goals/${goalId}`) },
|
||||
},
|
||||
)
|
||||
if (release) {
|
||||
release()
|
||||
release = null
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (release) {
|
||||
release()
|
||||
release = null
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -2,19 +2,25 @@ import { ref, readonly } from 'vue'
|
||||
|
||||
export type ToastVariant = 'success' | 'error' | 'info'
|
||||
|
||||
export interface ToastAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface ToastItem {
|
||||
id: number
|
||||
message: string
|
||||
variant: ToastVariant
|
||||
dismissing: boolean
|
||||
action?: ToastAction
|
||||
}
|
||||
|
||||
const toasts = ref<ToastItem[]>([])
|
||||
let nextId = 0
|
||||
|
||||
function addToast(message: string, variant: ToastVariant = 'info', duration = 3000) {
|
||||
function addToast(message: string, variant: ToastVariant = 'info', duration = 3000, action?: ToastAction) {
|
||||
const id = nextId++
|
||||
toasts.value.push({ id, message, variant, dismissing: false })
|
||||
toasts.value.push({ id, message, variant, dismissing: false, action })
|
||||
|
||||
// Auto-dismiss
|
||||
if (duration > 0) {
|
||||
@@ -42,6 +48,9 @@ export function useToast() {
|
||||
success: (msg: string) => addToast(msg, 'success'),
|
||||
error: (msg: string) => addToast(msg, 'error'),
|
||||
info: (msg: string) => addToast(msg, 'info'),
|
||||
/** Toast with an action link (e.g. "Finish setup"). Sticks around longer. */
|
||||
action: (msg: string, action: ToastAction, opts?: { variant?: ToastVariant; duration?: number }) =>
|
||||
addToast(msg, opts?.variant ?? 'success', opts?.duration ?? 15000, action),
|
||||
dismiss: dismissToast,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user