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:
parent
be3ebd7fe0
commit
75b78325e4
@ -26,6 +26,7 @@
|
||||
"back": "Back",
|
||||
"done": "Done",
|
||||
"manage": "Manage",
|
||||
"settings": "Settings",
|
||||
"connect": "Connect",
|
||||
"connecting": "Connecting...",
|
||||
"disconnect": "Disconnect",
|
||||
|
||||
@ -26,6 +26,7 @@
|
||||
"back": "Volver",
|
||||
"done": "Listo",
|
||||
"manage": "Administrar",
|
||||
"settings": "Configuración",
|
||||
"connect": "Conectar",
|
||||
"connecting": "Conectando...",
|
||||
"disconnect": "Desconectar",
|
||||
|
||||
@ -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',
|
||||
|
||||
203
neode-ui/src/views/web5/Web5NetworkingProfitsSettings.vue
Normal file
203
neode-ui/src/views/web5/Web5NetworkingProfitsSettings.vue
Normal 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>
|
||||
@ -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 -->
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user