feat(web5): Networking Profits → Settings page for paid services

Adds a Settings control to the Networking Profits card that opens a new page
where the operator controls what their node charges sats for and how much.
Drives the existing streaming.list-services / streaming.configure-service RPCs;
"free everything" is the default (all priced services ship disabled, surfaced
with a reassurance banner). New route web5/networking-profits + common.settings
i18n (en/es).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-17 04:48:00 -04:00
parent be3ebd7fe0
commit 75b78325e4
5 changed files with 216 additions and 0 deletions

View File

@ -26,6 +26,7 @@
"back": "Back",
"done": "Done",
"manage": "Manage",
"settings": "Settings",
"connect": "Connect",
"connecting": "Connecting...",
"disconnect": "Disconnect",

View File

@ -26,6 +26,7 @@
"back": "Volver",
"done": "Listo",
"manage": "Administrar",
"settings": "Configuración",
"connect": "Conectar",
"connecting": "Conectando...",
"disconnect": "Desconectar",

View File

@ -206,6 +206,11 @@ const router = createRouter({
name: 'credentials',
component: () => import('../views/Credentials.vue'),
},
{
path: 'web5/networking-profits',
name: 'networking-profits-settings',
component: () => import('../views/web5/Web5NetworkingProfitsSettings.vue'),
},
{
path: 'settings',
name: 'settings',

View File

@ -0,0 +1,203 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { rpcClient } from '@/api/rpc-client'
import ToggleSwitch from '@/components/ToggleSwitch.vue'
const router = useRouter()
// Mirrors `crate::streaming::pricing::ServicePricing` (Metric is rename_all =
// "lowercase", so it round-trips verbatim back to streaming.configure-service).
type Metric = 'bytes' | 'milliseconds' | 'requests'
interface ServicePricing {
service_id: string
name: string
metric: Metric
step_size: number
price_per_step: number
min_steps: number
enabled: boolean
description: string
accepted_mints: string[]
}
const services = ref<ServicePricing[]>([])
const loading = ref(true)
const loadError = ref('')
const savingId = ref<string | null>(null)
const statusMsg = ref('')
const statusIsError = ref(false)
// "Free everything" is the default every service ships disabled. The banner
// reassures the user nothing is being charged for until they opt in.
const allFree = computed(() => services.value.every((s) => !s.enabled))
function showStatus(msg: string, isError: boolean) {
statusMsg.value = msg
statusIsError.value = isError
setTimeout(() => { statusMsg.value = '' }, 5000)
}
/** Human label for one priced step, e.g. "MB", "minute", "request". */
function unitLabel(metric: Metric, stepSize: number): string {
if (metric === 'bytes') {
if (stepSize === 1_073_741_824) return 'GB'
if (stepSize === 1_048_576) return 'MB'
if (stepSize === 1024) return 'KB'
return `${stepSize.toLocaleString()} bytes`
}
if (metric === 'milliseconds') {
if (stepSize === 3_600_000) return 'hour'
if (stepSize === 60_000) return 'minute'
if (stepSize === 1000) return 'second'
return `${stepSize.toLocaleString()} ms`
}
// requests
return stepSize === 1 ? 'request' : `${stepSize.toLocaleString()} requests`
}
function minimumNote(svc: ServicePricing): string {
if (svc.min_steps <= 0) return ''
return `Minimum purchase: ${svc.min_steps.toLocaleString()} ${unitLabel(svc.metric, svc.step_size)}${svc.min_steps > 1 ? 's' : ''}`
}
async function load() {
loading.value = true
loadError.value = ''
try {
const res = await rpcClient.call<{ services: ServicePricing[] }>({ method: 'streaming.list-services' })
services.value = (res.services || []).map((s) => ({
...s,
// Price must stay >= 1 sat: the backend rejects price_per_step == 0.
price_per_step: Math.max(1, s.price_per_step),
}))
} catch (e) {
loadError.value = e instanceof Error ? e.message : 'Failed to load services'
} finally {
loading.value = false
}
}
async function saveService(svc: ServicePricing) {
if (svc.price_per_step < 1) svc.price_per_step = 1
savingId.value = svc.service_id
try {
await rpcClient.call({
method: 'streaming.configure-service',
params: {
service_id: svc.service_id,
name: svc.name,
metric: svc.metric,
step_size: svc.step_size,
price_per_step: svc.price_per_step,
min_steps: svc.min_steps,
enabled: svc.enabled,
description: svc.description,
accepted_mints: svc.accepted_mints,
},
})
showStatus(
svc.enabled
? `Charging ${svc.price_per_step} sats per ${unitLabel(svc.metric, svc.step_size)} for ${svc.name}.`
: `${svc.name} is now free.`,
false,
)
} catch (e) {
showStatus(e instanceof Error ? e.message : 'Failed to save', true)
// Reload so the UI reflects the persisted truth after a failed write.
void load()
} finally {
savingId.value = null
}
}
onMounted(load)
</script>
<template>
<div class="pb-6">
<button
type="button"
class="text-xs px-3 py-1.5 mb-4 rounded-md bg-white/5 hover:bg-white/10 text-white/70 hover:text-white transition-colors"
@click="router.push('/dashboard/web5')"
> Back to Web5</button>
<div class="mb-6">
<h1 class="text-3xl font-bold text-white mb-2">Networking Profits Settings</h1>
<p class="text-white/70">
Control what your node charges other peers for. By default everything is shared for
free turn a service on to start earning sats (ecash) for it. Payments are collected
as Cashu tokens through your node's wallet.
</p>
</div>
<!-- Status message -->
<div
v-if="statusMsg"
role="status"
aria-live="polite"
class="mb-4 p-3 rounded-lg text-sm"
:class="statusIsError ? 'bg-red-500/20 text-red-300' : 'bg-green-500/20 text-green-300'"
>
{{ statusMsg }}
</div>
<!-- Everything-free reassurance banner -->
<div
v-if="!loading && allFree"
class="mb-6 p-4 rounded-lg bg-green-500/10 border border-green-500/20 flex items-center gap-3"
>
<span class="text-xl"></span>
<p class="text-sm text-green-200">
Everything is free. Your node isn't charging for anything enable a service below to
start earning.
</p>
</div>
<div v-if="loading" class="glass-card p-6 text-white/60 text-sm">Loading services</div>
<div v-else-if="loadError" class="glass-card p-6 text-red-300 text-sm">{{ loadError }}</div>
<div v-else class="space-y-4">
<div v-for="svc in services" :key="svc.service_id" class="glass-card p-6">
<div class="flex items-start justify-between gap-4 mb-3">
<div class="min-w-0">
<h2 class="text-lg font-semibold text-white">{{ svc.name }}</h2>
<p class="text-sm text-white/60 mt-0.5">{{ svc.description }}</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<span class="text-xs" :class="svc.enabled ? 'text-orange-400' : 'text-white/40'">
{{ svc.enabled ? 'Paid' : 'Free' }}
</span>
<ToggleSwitch :model-value="svc.enabled" @update:model-value="(v) => (svc.enabled = v)" />
</div>
</div>
<div class="flex flex-col sm:flex-row sm:items-end gap-3">
<div class="flex-1" :class="{ 'opacity-40 pointer-events-none': !svc.enabled }">
<label class="text-xs text-white/50 block mb-1">Price</label>
<div class="flex items-center gap-2">
<input
v-model.number="svc.price_per_step"
type="number"
min="1"
step="1"
:disabled="!svc.enabled"
class="w-28 bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-orange-500/50"
/>
<span class="text-sm text-white/70">sats per {{ unitLabel(svc.metric, svc.step_size) }}</span>
</div>
<p v-if="minimumNote(svc)" class="text-xs text-white/40 mt-1">{{ minimumNote(svc) }}</p>
</div>
<button
@click="saveService(svc)"
:disabled="savingId === svc.service_id"
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm disabled:opacity-50"
>
{{ savingId === svc.service_id ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
</div>
</div>
</template>

View File

@ -17,6 +17,12 @@
<p v-if="profitsBreakdown.content_sales_sats > 0">Content: {{ profitsBreakdown.content_sales_sats.toLocaleString() }} sats</p>
<p v-if="profitsBreakdown.routing_fees_sats > 0">Routing: {{ profitsBreakdown.routing_fees_sats.toLocaleString() }} sats</p>
</div>
<button
@click="router.push('/dashboard/web5/networking-profits')"
class="w-full mt-auto px-3 py-1.5 glass-button glass-button-sm rounded text-xs font-medium text-white/90 hover:text-white transition-colors"
>
{{ t('common.settings') }}
</button>
</div>
<!-- DID Status -->