feat: wire up paidreviews (#3656)

Co-authored-by: Vlad Stan <stan.v.vlad@gmail.com>
This commit is contained in:
Arc
2026-01-12 13:17:39 +02:00
committed by GitHub
co-authored by Vlad Stan
parent 5490367179
commit 93f1006d3a
12 changed files with 622 additions and 43 deletions
+27 -1
View File
@@ -12,7 +12,7 @@ from typing import Any
import httpx import httpx
from loguru import logger from loguru import logger
from pydantic import BaseModel from pydantic import BaseModel, Field
from lnbits.helpers import ( from lnbits.helpers import (
download_url, download_url,
@@ -777,6 +777,32 @@ class ExtensionDetailsRequest(BaseModel):
version: str version: str
class ExtensionReviewsStatus(BaseModel):
tag: str
avg_rating: float
review_count: int
class CreateExtensionReview(BaseModel):
tag: str
name: str | None = Field(None)
rating: int = Field(..., ge=0, le=1000)
comment: str | None = Field(None)
class ExtensionReviewPaymentRequest(BaseModel):
payment_hash: str
payment_request: str
class ExtensionReview(BaseModel):
id: str
name: str | None = Field(default=None)
tag: str | None = Field(default=None)
rating: int = Field(default=0, ge=0, le=1000)
comment: str | None = Field(default=None)
async def github_api_get(url: str, error_msg: str | None) -> Any: async def github_api_get(url: str, error_msg: str | None) -> Any:
headers = {"User-Agent": settings.user_agent} headers = {"User-Agent": settings.user_agent}
if settings.lnbits_ext_github_token: if settings.lnbits_ext_github_token:
+53
View File
@@ -2,8 +2,10 @@ import sys
import traceback import traceback
from http import HTTPStatus from http import HTTPStatus
import httpx
from bolt11 import decode as bolt11_decode from bolt11 import decode as bolt11_decode
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.requests import Request
from loguru import logger from loguru import logger
from lnbits.core.crud.extensions import get_user_extensions from lnbits.core.crud.extensions import get_user_extensions
@@ -14,10 +16,14 @@ from lnbits.core.models import (
) )
from lnbits.core.models.extensions import ( from lnbits.core.models.extensions import (
CreateExtension, CreateExtension,
CreateExtensionReview,
Extension, Extension,
ExtensionConfig, ExtensionConfig,
ExtensionMeta, ExtensionMeta,
ExtensionRelease, ExtensionRelease,
ExtensionReview,
ExtensionReviewPaymentRequest,
ExtensionReviewsStatus,
InstallableExtension, InstallableExtension,
PayToEnableInfo, PayToEnableInfo,
ReleasePaymentInfo, ReleasePaymentInfo,
@@ -34,6 +40,7 @@ from lnbits.core.services.extensions import (
install_extension, install_extension,
uninstall_extension, uninstall_extension,
) )
from lnbits.db import Page
from lnbits.decorators import ( from lnbits.decorators import (
check_account_exists, check_account_exists,
check_account_id_exists, check_account_id_exists,
@@ -589,3 +596,49 @@ async def extensions(account_id: AccountId = Depends(check_account_id_exists)):
for ext in installable_exts for ext in installable_exts
] ]
return extension_data return extension_data
@extension_router.get(
"/reviews/tags",
dependencies=[Depends(check_account_exists)],
)
async def get_extension_reviews_tags() -> list[ExtensionReviewsStatus]:
async with httpx.AsyncClient() as client:
resp = await client.get(settings.lnbits_extensions_reviews_url + "/tags")
resp.raise_for_status()
data = resp.json()
return [ExtensionReviewsStatus(**item) for item in data]
@extension_router.get(
"/reviews/{ext_id}",
dependencies=[Depends(check_account_exists)],
)
async def get_extension_reviews(ext_id: str, request: Request) -> Page[ExtensionReview]:
async with httpx.AsyncClient() as client:
query_string = str(request.query_params)
resp = await client.get(
settings.lnbits_extensions_reviews_url + f"/reviews/{ext_id}?{query_string}"
)
resp.raise_for_status()
reviews = resp.json()
return Page(
data=[ExtensionReview(**item) for item in reviews["data"]],
total=reviews["total"],
)
@extension_router.put(
"/reviews",
dependencies=[Depends(check_account_exists)],
)
async def create_extension_review(
data: CreateExtensionReview,
) -> ExtensionReviewPaymentRequest:
async with httpx.AsyncClient() as client:
resp = await client.post(
settings.lnbits_extensions_reviews_url + "/reviews", json=data.dict()
)
resp.raise_for_status()
payment_request = resp.json()
return ExtensionReviewPaymentRequest(**payment_request)
+1
View File
@@ -107,6 +107,7 @@ def template_renderer(additional_folders: list | None = None) -> Jinja2Templates
"LNBITS_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage, "LNBITS_DEFAULT_BGIMAGE": settings.lnbits_default_bgimage,
"VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet", "VOIDWALLET": settings.lnbits_backend_wallet_class == "VoidWallet",
"WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey, "WEBPUSH_PUBKEY": settings.lnbits_webpush_pubkey,
"LNBITS_EXTENSIONS_REVIEWS_URL": settings.lnbits_extensions_reviews_url,
"LNBITS_DENOMINATION": settings.lnbits_denomination, "LNBITS_DENOMINATION": settings.lnbits_denomination,
"has_holdinvoice": settings.has_holdinvoice, "has_holdinvoice": settings.has_holdinvoice,
"LNBITS_NOSTR_CONFIGURED": settings.is_nostr_notifications_configured(), "LNBITS_NOSTR_CONFIGURED": settings.is_nostr_notifications_configured(),
+7
View File
@@ -52,6 +52,13 @@ class ExtensionsSettings(LNbitsSettings):
lnbits_user_default_extensions: list[str] = Field(default=[]) lnbits_user_default_extensions: list[str] = Field(default=[])
lnbits_extensions_deactivate_all: bool = Field(default=False) lnbits_extensions_deactivate_all: bool = Field(default=False)
lnbits_extensions_builder_activate_non_admins: bool = Field(default=False) lnbits_extensions_builder_activate_non_admins: bool = Field(default=False)
lnbits_extensions_reviews_url: str = Field(
default="https://demo.lnbits.com/paidreviews/api/v1/AdFzLjzuKFLsdk4Bcnff6r",
description="""
URL for the paid reviews.
Regular users can view this URL (not secret).
""",
)
lnbits_extensions_manifests: list[str] = Field( lnbits_extensions_manifests: list[str] = Field(
default=[ default=[
"https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json" "https://raw.githubusercontent.com/lnbits/lnbits-extensions/main/extensions.json"
File diff suppressed because one or more lines are too long
+10 -10
View File
File diff suppressed because one or more lines are too long
+19
View File
@@ -187,6 +187,25 @@ window.localisation.en = {
'Only admin accounts can create extensions', 'Only admin accounts can create extensions',
admin_only: 'Admin Only', admin_only: 'Admin Only',
new_version: 'New Version', new_version: 'New Version',
reviews_url: 'Reviews URL',
reviews_url_label: 'Reviews server URL',
reviews_url_hint:
'Full PaidReviews URL including the settings id (e.g. https://example.com/paidreviews/SETTINGS_ID)',
reviews_open: 'View reviews',
reviews_leave: 'Leave a review',
reviews_name: 'Your name',
reviews_comment: 'Your review',
reviews_rating: 'Rating',
reviews_submit: 'Submit review',
reviews_loading: 'Loading reviews...',
reviews_refresh: 'Refresh reviews',
reviews_error_load: 'Could not load reviews',
reviews_url_not_configured: 'Reviews URL not configured',
reviews_pay_invoice: 'Pay invoice',
reviews_invoice_paid: 'Invoice paid',
reviews_invoice_title: 'Pay this invoice to submit your review',
reviews_count: 'Reviews',
no_reviews: 'No reviews yet',
extension_has_free_release: 'Has free releases', extension_has_free_release: 'Has free releases',
extension_has_paid_release: 'Has paid releases', extension_has_paid_release: 'Has paid releases',
extension_depends_on: 'Depends on:', extension_depends_on: 'Depends on:',
+28 -1
View File
@@ -3,7 +3,34 @@ window.app.component(QrcodeVue)
window.app.component('lnbits-extension-rating', { window.app.component('lnbits-extension-rating', {
template: '#lnbits-extension-rating', template: '#lnbits-extension-rating',
name: 'lnbits-extension-rating', name: 'lnbits-extension-rating',
props: ['rating'] props: {
rating: {
type: Number,
default: 0
},
count: {
type: Number,
default: null
},
clickable: {
type: Boolean,
default: false
}
},
computed: {
displayRating() {
return Math.round((this.rating || 0) * 2) / 2
},
hasData() {
return this.count !== null && this.count !== undefined
}
},
methods: {
handleClick() {
if (!this.clickable) return
this.$emit('click')
}
}
}) })
window.app.component('lnbits-fsat', { window.app.component('lnbits-fsat', {
+231 -22
View File
@@ -27,7 +27,62 @@ window.PageExtensions = {
uninstallAndDropDb: false, uninstallAndDropDb: false,
maxStars: 5, maxStars: 5,
paylinkWebsocket: null, paylinkWebsocket: null,
searchToggle: false searchToggle: false,
reviewsUrl: null,
reviewsDialog: {
show: false,
extension: null,
loading: false,
submitting: false,
form: {
name: '',
rating: 0,
comment: ''
},
error: null
},
reviews: [],
reviewsTable: {
loading: false,
columns: [
{
name: 'name',
align: 'left',
label: this.$t('Name'),
field: 'name',
sortable: true
},
{
name: 'comment',
align: 'left',
label: this.$t('Comment'),
field: 'comment'
},
{
name: 'created_at',
align: 'left',
label: this.$t('Date'),
field: 'created_at'
},
{
name: 'rating',
align: 'right',
label: 'Rating',
field: 'rating'
}
],
pagination: {
rowsPerPage: 5,
sortBy: 'created_at',
descending: true,
page: 1
}
},
paymentDialog: {
show: false,
invoice: '',
hash: ''
}
} }
}, },
watch: { watch: {
@@ -72,7 +127,6 @@ window.PageExtensions = {
// the install logic has been triggered one way or another // the install logic has been triggered one way or another
this.unsubscribeFromPaylinkWs() this.unsubscribeFromPaylinkWs()
const extension = this.selectedExtension
this.selectedExtension.inProgress = true this.selectedExtension.inProgress = true
this.showManageExtensionDialog = false this.showManageExtensionDialog = false
release.payment_hash = release.payment_hash =
@@ -286,8 +340,7 @@ window.PageExtensions = {
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
`/api/v1/extension/${extension.id}/releases`, `/api/v1/extension/${extension.id}/releases`
this.g.user.wallets[0].adminkey
) )
this.selectedExtensionRepos = data.reduce((repos, release) => { this.selectedExtensionRepos = data.reduce((repos, release) => {
@@ -325,6 +378,9 @@ window.PageExtensions = {
if (!detailsLink) { if (!detailsLink) {
return return
} }
// keep a reference to the extension so we can show rating stats
this.selectedExtension =
this.extensions.find(ext => ext.id === extId) || this.selectedExtension
this.selectedExtensionDetails = null this.selectedExtensionDetails = null
this.showExtensionDetailsDialog = true this.showExtensionDetailsDialog = true
this.slide = 0 this.slide = 0
@@ -333,8 +389,7 @@ window.PageExtensions = {
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
`/api/v1/extension/${extId}/details?details_link=${detailsLink}`, `/api/v1/extension/${extId}/details?details_link=${detailsLink}`
this.g.user.wallets[0].inkey
) )
this.selectedExtensionDetails = data this.selectedExtensionDetails = data
@@ -455,7 +510,7 @@ window.PageExtensions = {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'PUT', 'PUT',
`/api/v1/extension/${extId}/invoice/install`, `/api/v1/extension/${extId}/invoice/install`,
this.g.user.wallets[0].adminkey, null,
{ {
ext_id: extId, ext_id: extId,
archive: release.archive, archive: release.archive,
@@ -471,7 +526,7 @@ window.PageExtensions = {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'PUT', 'PUT',
`/api/v1/extension/${extId}/invoice/enable`, `/api/v1/extension/${extId}/invoice/enable`,
this.g.user.wallets[0].adminkey, null,
{ {
amount amount
} }
@@ -562,8 +617,7 @@ window.PageExtensions = {
try { try {
const {data} = await LNbits.api.request( const {data} = await LNbits.api.request(
'GET', 'GET',
`/api/v1/extension/release/${org}/${repo}/${release.version}`, `/api/v1/extension/release/${org}/${repo}/${release.version}`
this.g.user.wallets[0].adminkey
) )
release.loaded = true release.loaded = true
release.is_version_compatible = data.is_version_compatible release.is_version_compatible = data.is_version_compatible
@@ -588,18 +642,13 @@ window.PageExtensions = {
continue continue
} }
ext.inProgress = true ext.inProgress = true
await LNbits.api.request( await LNbits.api.request('POST', `/api/v1/extension`, null, {
'POST', ext_id: ext.id,
`/api/v1/extension`, archive: ext.latestRelease.archive,
this.g.user.wallets[0].adminkey, source_repo: ext.latestRelease.source_repo,
{ payment_hash: ext.latestRelease.payment_hash,
ext_id: ext.id, version: ext.latestRelease.version
archive: ext.latestRelease.archive, })
source_repo: ext.latestRelease.source_repo,
payment_hash: ext.latestRelease.payment_hash,
version: ext.latestRelease.version
}
)
count++ count++
ext.isAvailable = true ext.isAvailable = true
ext.isInstalled = true ext.isInstalled = true
@@ -624,6 +673,163 @@ window.PageExtensions = {
}) })
this.showUpdateAllDialog = false this.showUpdateAllDialog = false
}, },
formatAvg(raw) {
const val = Number(raw || 0)
return Math.round((val / 2 / 100) * 2) / 2
},
async loadReviewStats() {
if (!this.reviewsUrl) {
console.info('Extension reviews are not configured')
return
}
try {
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/reviews/tags`
)
const map = {}
data.forEach(stat => {
map[stat.tag] = stat
})
this.extensions.forEach(ext => {
ext.reviewStats = map[ext.id] || null
})
this.filterExtensions(this.searchTerm, this.tab)
} catch (error) {
console.warn(error)
}
},
async openReviews(extension) {
const targetExtension =
extension ||
(this.selectedExtensionDetails
? this.extensions.find(e => e.id === this.selectedExtensionDetails.id)
: null)
if (!targetExtension) {
return
}
if (!this.reviewsUrl) {
Quasar.Notify.create({
type: 'warning',
message: this.$t('reviews_url_not_configured')
})
return
}
this.reviewsDialog.extension = targetExtension
this.selectedExtension = extension
this.reviewsDialog.show = true
await this.getTagReviews()
},
async getTagReviews(props) {
if (!this.reviewsUrl) {
Quasar.Notify.create({
type: 'warning',
message: this.$t('reviews_url_not_configured')
})
return
}
try {
this.reviewsTable.loading = true
const params = LNbits.utils.prepareFilterQuery(this.reviewsTable, props)
const {data} = await LNbits.api.request(
'GET',
`/api/v1/extension/reviews/${this.selectedExtension.id}?${params}`
)
this.reviews = data.data
this.reviewsTable.pagination.rowsNumber = data.total
} catch (e) {
LNbits.utils.notifyApiError(e)
} finally {
this.reviewsTable.loading = false
}
},
formatReviewDate(val) {
if (!val) return ''
const numeric = Number(val)
if (!Number.isNaN(numeric)) {
return this.utils.formatTimestamp(numeric)
}
return this.utils.formatDate(val)
},
async submitReview() {
if (!this.reviewsDialog.extension || !this.reviewsUrl) {
return
}
this.reviewsDialog.submitting = true
try {
const payload = {
tag: this.reviewsDialog.extension.id,
name: this.reviewsDialog.form.name,
rating: this.reviewsDialog.form.rating * 100,
comment: this.reviewsDialog.form.comment
}
const {data} = await LNbits.api.request(
'PUT',
`/api/v1/extension/reviews`,
null,
payload
)
if (data.payment_request) {
this.openInvoiceDialog(data.payment_request, data.payment_hash)
} else {
Quasar.Notify.create({
type: 'positive',
message: 'Review submitted'
})
this.resetReviewForm()
await this.getTagReviews()
await this.loadReviewStats()
}
} catch (error) {
LNbits.utils.notifyApiError(error)
} finally {
this.reviewsDialog.submitting = false
}
},
openInvoiceDialog(invoice, hash) {
this.paymentDialog.invoice = invoice
this.paymentDialog.hash = hash
this.paymentDialog.show = true
this.listenForPayment(hash)
},
resetReviewForm() {
this.reviewsDialog.form = {
name: '',
rating: 0,
comment: ''
}
this.paymentDialog = {
show: false,
invoice: '',
hash: ''
}
},
listenForPayment(hash) {
try {
const base = new URL(this.reviewsUrl)
base.protocol = base.protocol === 'https:' ? 'wss:' : 'ws:'
base.pathname = `/api/v1/ws/${hash}`
const ws = new WebSocket(base)
ws.addEventListener('message', async () => {
Quasar.Notify.create({
type: 'positive',
message: this.$t('reviews_invoice_paid')
})
this.paymentDialog.show = false
this.resetReviewForm()
setTimeout(async () => {
await this.getTagReviews()
}, 1000)
await this.loadReviewStats()
ws.close()
})
} catch (error) {
console.warn(error)
}
},
async fetchAllExtensions() { async fetchAllExtensions() {
try { try {
const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`) const {data} = await LNbits.api.request('GET', `/api/v1/extension/all`)
@@ -638,6 +844,7 @@ window.PageExtensions = {
async created() { async created() {
this.extensions = await this.fetchAllExtensions() this.extensions = await this.fetchAllExtensions()
this.extbuilderEnabled = this.g.user.admin || this.LNBITS_EXT_BUILDER this.extbuilderEnabled = this.g.user.admin || this.LNBITS_EXT_BUILDER
this.reviewsUrl = this.LNBITS_EXTENSIONS_REVIEWS_URL
if (this.g.user.extensions.length === 0) { if (this.g.user.extensions.length === 0) {
this.tab = 'all' this.tab = 'all'
@@ -654,6 +861,8 @@ window.PageExtensions = {
this.hasNewVersion(ext) this.hasNewVersion(ext)
) )
await this.loadReviewStats()
this.filterExtensions(this.searchTerm, this.tab) this.filterExtensions(this.searchTerm, this.tab)
} }
} }
+26 -5
View File
@@ -561,11 +561,32 @@ include('components/lnbits-error.vue') %}
</template> </template>
<template id="lnbits-extension-rating"> <template id="lnbits-extension-rating">
<div style="margin-bottom: 3px"> <div class="row items-center q-gutter-xs" style="margin-bottom: 3px">
<q-rating v-model="rating" size="1.5em" :max="5" color="primary" <q-rating
><q-tooltip> :model-value="displayRating"
<span v-text="$t('extension_rating_soon')"></span> </q-tooltip size="1.4em"
></q-rating> :max="5"
color="primary"
icon="star_border"
icon-selected="star"
icon-half="star_half"
readonly
:class="clickable ? 'cursor-pointer' : ''"
@click="handleClick"
>
<q-tooltip v-if="!hasData">
<span v-text="$t('extension_rating_soon')"></span>
</q-tooltip>
<q-tooltip v-else-if="clickable">
<span v-text="$t('reviews_open')"></span>
</q-tooltip>
</q-rating>
<div
class="text-caption text-grey"
v-if="count !== null && count !== undefined"
>
<span v-text="`(${count})`"></span>
</div>
</div> </div>
</template> </template>
@@ -137,6 +137,19 @@
:hint="$t('extension_builder_manifest_url_hint')" :hint="$t('extension_builder_manifest_url_hint')"
></q-input> ></q-input>
</div> </div>
<div class="col-12 col-md-6">
<p>
<span v-text="$t('reviews_url')"></span>
</p>
<q-input
filled
v-model="formData.lnbits_extensions_reviews_url"
:label="$t('reviews_url_label')"
:hint="$t('reviews_url_hint')"
type="url"
autocomplete="off"
></q-input>
</div>
</div> </div>
</div> </div>
</q-card-section> </q-card-section>
+206 -3
View File
@@ -163,7 +163,22 @@
v-text="extension.name" v-text="extension.name"
></div> ></div>
<div style="justify-content: space-between; display: flex"> <div style="justify-content: space-between; display: flex">
<lnbits-extension-rating :rating="0" /> <lnbits-extension-rating
:rating="
formatAvg(
extension.reviewStats
? extension.reviewStats.avg_rating
: 0
)
"
:count="
extension.reviewStats
? extension.reviewStats.review_count
: null
"
:clickable="!!reviewsUrl"
@click="openReviews(extension)"
/>
<q-btn-group size="xs" style="margin: 5px 0"> <q-btn-group size="xs" style="margin: 5px 0">
<q-btn <q-btn
v-if="extension.hasFreeRelease" v-if="extension.hasFreeRelease"
@@ -998,8 +1013,20 @@
</div> </div>
<div class="col-sm-12 col-md-4 q-pl-sm"> <div class="col-sm-12 col-md-4 q-pl-sm">
<lnbits-extension-rating <lnbits-extension-rating
:rating="0" :rating="
size="2.5em" formatAvg(
selectedExtension && selectedExtension.reviewStats
? selectedExtension.reviewStats.avg_rating
: 0
)
"
:count="
selectedExtension && selectedExtension.reviewStats
? selectedExtension.reviewStats.review_count
: null
"
:clickable="!!reviewsUrl"
@click="openReviews(selectedExtension)"
></lnbits-extension-rating> ></lnbits-extension-rating>
<div class="q-mt-md"> <div class="q-mt-md">
<b> <b>
@@ -1057,6 +1084,182 @@
</q-card-section> </q-card-section>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-dialog v-model="reviewsDialog.show" position="top">
<q-card
class="q-pa-md lnbits__dialog-card"
style="width: 900px; max-width: 95vw"
>
<q-card-section class="q-gutter-y-sm">
<div class="row items-center">
<div class="col">
<div
class="text-h6"
v-text="
(reviewsDialog.extension && reviewsDialog.extension.name) ||
(reviewsDialog.extension && reviewsDialog.extension.id)
"
></div>
<div
class="text-caption text-grey"
v-text="reviewsDialog.extension && reviewsDialog.extension.id"
></div>
</div>
<div class="col-auto">
<lnbits-extension-rating
:rating="
formatAvg(
reviewsDialog.extension && reviewsDialog.extension.reviewStats
? reviewsDialog.extension.reviewStats.avg_rating
: 0
)
"
:count="
reviewsDialog.extension && reviewsDialog.extension.reviewStats
? reviewsDialog.extension.reviewStats.review_count
: null
"
></lnbits-extension-rating>
</div>
</div>
</q-card-section>
<q-separator></q-separator>
<q-card-section v-if="!reviewsUrl">
<div
class="text-negative"
v-text="$t('reviews_url_not_configured')"
></div>
</q-card-section>
<q-card-section v-else>
<div v-if="reviewsDialog.loading" class="row justify-center q-pa-lg">
<q-spinner-bars color="primary" size="2.55em"></q-spinner-bars>
</div>
<div v-else>
<div v-if="reviewsDialog.error" class="text-negative q-mb-md">
<span v-text="reviewsDialog.error"></span>
</div>
<q-form @submit="submitReview" class="q-gutter-md">
<div class="row q-col-gutter-lg">
<div class="col-12 col-md-6">
<q-input
dense
filled
v-model="reviewsDialog.form.name"
:label="$t('reviews_name')"
></q-input>
</div>
<div class="col-12 col-md-6">
<q-rating
v-model="reviewsDialog.form.rating"
max="10"
icon="star_border"
icon-selected="star"
icon-half="star_half"
color="primary"
class="float-right"
></q-rating>
</div>
<div class="col-12 col-md-6">
<q-input
dense
filled
type="textarea"
autogrow
v-model="reviewsDialog.form.comment"
:label="$t('reviews_comment')"
></q-input>
</div>
<div class="col-12 col-md-6">
<q-btn
type="submit"
color="primary"
class="float-right"
unelevated
:loading="reviewsDialog.submitting"
:disable="!reviewsDialog.form.rating"
:label="$t('reviews_submit')"
></q-btn>
</div>
</div>
</q-form>
<q-separator class="q-my-md"></q-separator>
<div class="row q-col-gutter-lg">
<div class="col-12">
<div v-if="reviews.length === 0" class="text-grey q-mb-md">
<span v-text="$t('no_reviews')"></span>
</div>
<div v-else>
<q-table
:rows="reviews"
:columns="reviewsTable.columns"
v-model:pagination="reviewsTable.pagination"
@request="getTagReviews"
:loading="reviewsTable.loading"
row-key="name"
:filter="reviewsTable.search"
>
<template v-slot:body="props">
<q-tr :props="props">
<q-td key="name" :props="props">
<span v-text="props.row.name"></span>
</q-td>
<q-td key="comment" :props="props">
<span v-text="props.row.comment"></span>
</q-td>
<q-td key="created_at" :props="props">
<span
v-text="formatReviewDate(props.row.created_at)"
></span>
</q-td>
<q-td key="rating" :props="props">
<q-rating
class="float-right q-pt-xs"
readonly
icon="star_border"
icon-selected="star"
icon-half="star_half"
:model-value="formatAvg(props.row.rating)"
size="sm"
color="secondary"
></q-rating>
</q-td>
</q-tr>
</template>
</q-table>
</div>
</div>
</div>
</div>
</q-card-section>
<q-separator v-if="reviews.length"></q-separator>
<q-card-section>
<q-btn
@click="reviewsDialog.show = false"
color="grey"
outline
class="float-right"
:label="$t('close')"
></q-btn>
</q-card-section>
</q-card>
</q-dialog>
<q-dialog v-model="paymentDialog.show" position="top">
<q-card class="q-pa-md lnbits__dialog-card">
<q-card-section>
<q-responsive :ratio="1" class="q-mx-xl q-mb-xl">
<lnbits-qrcode
:value="paymentDialog.invoice"
:options="{width: 800}"
class="rounded-borders"
></lnbits-qrcode>
</q-responsive>
</q-card-section>
<q-card-actions align="between">
<q-btn v-close-popup flat color="grey" :label="$t('close')"></q-btn>
</q-card-actions>
</q-card>
</q-dialog>
<q-dialog v-model="showUpdateAllDialog" position="top"> <q-dialog v-model="showUpdateAllDialog" position="top">
<q-card class="q-pa-md q-pt-md lnbits__dialog-card"> <q-card class="q-pa-md q-pt-md lnbits__dialog-card">
<div class="row"> <div class="row">