archy/neode-ui/src/views/GoalDetail.vue
archipelago 73d181abea
Some checks failed
Demo images / Build & push demo images (push) Failing after 1m56s
fix(setup): wizard navigation round-trips — back to Setup tab, goal-aware channels back button, configure launches the app
- "Back to Goals" now returns to Home's Setup tab (?tab=setup) instead of
  landing on the dashboard tab.
- The channels screen remembers when a setup wizard sent you there
  (?from=goal) — its back button reads "Back to Setup" and returns to the
  wizard; it also now uses the shared BackButton pill instead of a bare
  link.
- "Open & Configure" steps launch the actual app via the app launcher —
  iframe apps overlay on top of the wizard, tab-only apps (BTCPay,
  Nextcloud) open a tab, mobile uses the in-app browser — instead of
  routing to the app-details page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 22:16:58 -04:00

509 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="pb-6">
<BackButton :label="t('goalDetail.backToGoals')" desktop-margin="mb-6" @click="goBack" />
<!-- Goal not found -->
<div v-if="!goal" class="glass-card p-12 text-center">
<p class="text-white/70">{{ t('goalDetail.notFound') }}</p>
</div>
<!-- Goal wizard -->
<template v-else>
<div class="mb-8">
<h1 class="text-3xl font-bold text-white mb-2 drop-shadow-[0_2px_8px_rgba(0,0,0,0.6)]">{{ goal.title }}</h1>
<p class="text-white/70">{{ goal.subtitle }}</p>
</div>
<!-- Progress bar -->
<div class="mb-8">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-white/60">{{ t('goalDetail.stepOf', { current: currentStepDisplay, total: goal.steps.length }) }}</span>
<span class="goal-status-badge" :class="statusBadgeClass">{{ statusLabel }}</span>
</div>
<div class="w-full h-2 bg-white/10 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all duration-500 ease-out"
:class="overallStatus === 'completed' ? 'bg-green-400' : 'bg-orange-400'"
:style="{ width: `${progressPercent}%` }"
/>
</div>
</div>
<!-- Sovereignty patience message for bitcoin-dependent goals -->
<div
v-if="showSyncMessage"
class="glass-card p-6 mb-6 border-l-4 border-orange-400"
>
<h3 class="text-lg font-semibold text-white mb-1">{{ t('goalDetail.syncTitle') }}</h3>
<p class="text-white/60 text-sm leading-relaxed">
{{ t('goalDetail.syncMessage') }}
</p>
</div>
<!-- Steps -->
<div class="space-y-3">
<div
v-for="(step, idx) in goal.steps"
:key="step.id"
class="glass-card p-0 overflow-hidden"
>
<div
class="goal-step"
:class="{
'goal-step-completed': isStepCompleted(step),
'goal-step-active': idx === activeStepIndex && overallStatus !== 'completed',
'goal-step-pending': idx > activeStepIndex && !isStepCompleted(step),
}"
>
<div class="flex items-start gap-4">
<!-- Step indicator -->
<div class="mt-0.5 shrink-0">
<div v-if="isStepCompleted(step)" class="w-6 h-6 rounded-full bg-green-500/20 flex items-center justify-center">
<svg class="w-4 h-4 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</div>
<div v-else-if="idx === activeStepIndex && isInstalling" class="w-6 h-6 rounded-full bg-orange-500/20 flex items-center justify-center">
<svg class="w-4 h-4 text-orange-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div>
<div v-else class="w-6 h-6 rounded-full bg-white/10 flex items-center justify-center">
<span class="text-xs text-white/40 font-medium">{{ idx + 1 }}</span>
</div>
</div>
<!-- App icon -->
<img
v-if="stepIconUrl(step)"
:src="stepIconUrl(step)"
:alt="step.title"
class="w-7 h-7 rounded-md object-contain shrink-0 mt-0.5"
@error="($event.target as HTMLImageElement).style.display = 'none'"
/>
<!-- Step content -->
<div class="flex-1 min-w-0">
<h3 class="text-base font-semibold text-white/90 mb-1">{{ step.title }}</h3>
<p class="text-sm text-white/55 leading-relaxed">{{ step.description }}</p>
<!-- Action button for active step -->
<div v-if="idx === activeStepIndex && overallStatus !== 'completed'" class="mt-4">
<button
v-if="step.action === 'install' && step.appId && !isAppInstalled(step.appId)"
@click="installApp(step)"
:disabled="isInstalling"
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
>
{{ 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,0001,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"
>
{{ step.ctaLabel ?? t('goalDetail.openAndConfigure') }}
</button>
<button
v-else-if="step.action === 'verify'"
@click="completeVerifyStep(step)"
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
>
{{ t('goalDetail.checkAndContinue') }}
</button>
<button
v-else-if="step.action === 'info'"
@click="completeInfoStep(step)"
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium"
>
{{ t('goalDetail.iveDoneThis') }}
</button>
<button
v-else-if="isStepCompleted(step) || isAppInstalled(step.appId || '')"
disabled
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium opacity-50"
>
{{ t('goalDetail.complete') }}
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Completion -->
<div v-if="overallStatus === 'completed'" class="glass-card p-8 mt-6 text-center">
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-green-500/20 flex items-center justify-center">
<svg class="w-8 h-8 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
</svg>
</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>
<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">
<div class="alert-error backdrop-blur-sm rounded-lg px-4 py-3 text-sm flex items-center justify-between gap-3">
<span>{{ actionError }}</span>
<button @click="actionError = ''" class="text-red-300 hover:text-white shrink-0">&times;</button>
</div>
</div>
</Transition>
</div>
</template>
<script setup lang="ts">
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 { useAppLauncherStore } from '@/stores/appLauncher'
import { getGoalById, ZEUS_CHANNEL_MIN_SATS } from '@/data/goals'
import type { GoalStep } from '@/types/goals'
import { goalStepRouteOverride } 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> = {
'bitcoin-knots': '/assets/img/app-icons/bitcoin-knots.webp',
lnd: '/assets/img/app-icons/lnd.png',
'btcpay-server': '/assets/img/app-icons/btcpay-server.png',
immich: '/assets/img/app-icons/immich.png',
nextcloud: '/assets/img/app-icons/nextcloud.webp',
fedimint: '/assets/img/app-icons/fedimint.png',
mempool: '/assets/img/app-icons/mempool.webp',
electrs: '/assets/img/app-icons/electrumx.png',
electrumx: '/assets/img/app-icons/electrumx.png',
}
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()
const appStore = useAppStore()
const goalStore = useGoalStore()
const goalId = computed(() => route.params.goalId as string)
const goal = computed(() => getGoalById(goalId.value))
const isInstalling = ref(false)
const actionError = ref('')
let errorTimer: ReturnType<typeof setTimeout> | undefined
function showActionError(msg: string) {
actionError.value = msg
if (errorTimer) clearTimeout(errorTimer)
errorTimer = setTimeout(() => { actionError.value = '' }, 5000)
}
const overallStatus = computed(() => goalStore.getGoalStatus(goalId.value))
const completedSteps = computed(() => {
if (!goal.value) return new Set<string>()
const completed = new Set<string>()
for (const step of goal.value.steps) {
// 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)) {
completed.add(step.id)
}
}
return completed
})
const activeStepIndex = computed(() => {
if (!goal.value) return 0
const steps = goal.value.steps
for (let i = 0; i < steps.length; i++) {
const step = steps[i]
if (step && !completedSteps.value.has(step.id)) return i
}
return steps.length - 1
})
const currentStepDisplay = computed(() => Math.min(activeStepIndex.value + 1, goal.value?.steps.length || 1))
const progressPercent = computed(() => {
if (!goal.value) return 0
return Math.round((completedSteps.value.size / goal.value.steps.length) * 100)
})
const statusLabel = computed(() => {
if (overallStatus.value === 'completed') return t('goalDetail.completed')
if (overallStatus.value === 'in-progress') return t('goalDetail.inProgress')
return t('goalDetail.notStarted')
})
const statusBadgeClass = computed(() => {
if (overallStatus.value === 'completed') return 'goal-status-badge-completed'
if (overallStatus.value === 'in-progress') return 'goal-status-badge-in-progress'
return 'goal-status-badge-not-started'
})
const showSyncMessage = computed(() => {
if (!goal.value) return false
const hasBitcoin = goal.value.requiredApps.includes('bitcoin-knots')
const bitcoinNotRunning = !isAppRunning('bitcoin-knots')
return hasBitcoin && bitcoinNotRunning && overallStatus.value !== 'completed'
})
function isStepCompleted(step: GoalStep): boolean {
return completedSteps.value.has(step.id)
}
/** App ID aliases — backend may register under variant names */
const APP_ALIASES: Record<string, string[]> = {
immich: ['immich-server', 'immich-app', 'immich_server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
'bitcoin-knots': ['bitcoin', 'bitcoin-core'],
}
function matchesAppId(pkgId: string, appId: string): boolean {
if (pkgId === appId) return true
const aliases = APP_ALIASES[appId]
return aliases ? aliases.includes(pkgId) : false
}
function isAppInstalled(appId: string): boolean {
return Object.keys(appStore.packages).some((pkgId) => matchesAppId(pkgId, appId))
}
function isAppRunning(appId: string): boolean {
return Object.entries(appStore.packages).some(
([pkgId, pkg]) => matchesAppId(pkgId, appId) && pkg.state === 'running',
)
}
async function installApp(step: GoalStep) {
if (!step.appId) return
isInstalling.value = true
ensureGoalStarted()
try {
await appStore.installPackage(step.appId, '', 'latest')
goalStore.completeStep(goalId.value, step.id)
} catch (err) {
showActionError(`Install failed: ${err instanceof Error ? err.message : 'Unknown error'}`)
} finally {
isInstalling.value = false
}
}
function openConfigureStep(step: GoalStep) {
ensureGoalStarted()
goalStore.completeStep(goalId.value, step.id)
const override = goalStepRouteOverride(step)
if (override) {
// Internal screens (channels, web5, settings) — tag where we came from so
// their back button returns to this wizard.
router.push({ path: override, query: { from: 'goal', goal: goalId.value } })
} else if (step.appId) {
// Launch the app itself: iframe apps overlay on top of the wizard,
// tab-only apps open a tab (mobile: the in-app browser) — the app
// launcher handles every case.
useAppLauncherStore().openSession(step.appId)
}
}
function completeVerifyStep(step: GoalStep) {
ensureGoalStarted()
goalStore.completeStep(goalId.value, step.id)
}
function completeInfoStep(step: GoalStep) {
ensureGoalStarted()
goalStore.completeStep(goalId.value, step.id)
}
function ensureGoalStarted() {
if (!goalStore.progress[goalId.value]) {
goalStore.startGoal(goalId.value)
}
}
function goBack() {
// The goal cards live on Home's Setup tab — return there, not the dashboard.
router.push({ path: '/dashboard', query: { tab: 'setup' } })
}
// ── 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>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>