Create payment page for Monero subscriptions
This commit is contained in:
parent
ee142eefa9
commit
86945aa057
7 changed files with 444 additions and 22 deletions
|
@ -27,7 +27,22 @@ export async function getSearchResults(
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function searchProfileByEthereumAddress(
|
export async function searchProfilesByAcct(
|
||||||
|
acct: string,
|
||||||
|
): Promise<Profile[]> {
|
||||||
|
const url = `${BACKEND_URL}/api/v1/accounts/search`
|
||||||
|
const response = await http(url, {
|
||||||
|
method: "GET",
|
||||||
|
queryParams: { q: acct },
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(data.message)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchProfilesByEthereumAddress(
|
||||||
walletAddress: string,
|
walletAddress: string,
|
||||||
): Promise<Profile[]> {
|
): Promise<Profile[]> {
|
||||||
const url = `${BACKEND_URL}/api/v1/accounts/search_did`
|
const url = `${BACKEND_URL}/api/v1/accounts/search_did`
|
||||||
|
|
|
@ -22,3 +22,27 @@ export async function getSubscriptionOptions(
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SubscriptionDetails {
|
||||||
|
id: number,
|
||||||
|
expires_at: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSubscription(
|
||||||
|
senderId: string,
|
||||||
|
recipientId: string,
|
||||||
|
): Promise<SubscriptionDetails | null> {
|
||||||
|
const url = `${BACKEND_URL}/api/v1/subscriptions/find`
|
||||||
|
const response = await http(url, {
|
||||||
|
method: "GET",
|
||||||
|
queryParams: { sender_id: senderId, recipient_id: recipientId },
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (response.status === 200) {
|
||||||
|
return data
|
||||||
|
} else if (response.status === 404) {
|
||||||
|
return null
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -37,3 +37,43 @@ export async function enableMoneroSubscriptions(
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
id: string,
|
||||||
|
sender_id: string,
|
||||||
|
recipient_id: string,
|
||||||
|
payment_address: string,
|
||||||
|
status: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createInvoice(
|
||||||
|
senderId: string,
|
||||||
|
recipientId: string,
|
||||||
|
): Promise<Invoice> {
|
||||||
|
const url = `${BACKEND_URL}/api/v1/subscriptions/invoices`
|
||||||
|
const response = await http(url, {
|
||||||
|
method: "POST",
|
||||||
|
json: { sender_id: senderId, recipient_id: recipientId },
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(data.message)
|
||||||
|
} else {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getInvoice(
|
||||||
|
invoiceId: string,
|
||||||
|
): Promise<Invoice> {
|
||||||
|
const url = `${BACKEND_URL}/api/v1/subscriptions/invoices/${invoiceId}`
|
||||||
|
const response = await http(url, {
|
||||||
|
method: "GET",
|
||||||
|
})
|
||||||
|
const data = await response.json()
|
||||||
|
if (response.status !== 200) {
|
||||||
|
throw new Error(data.message)
|
||||||
|
} else {
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -40,6 +40,25 @@ export interface Profile {
|
||||||
statuses_count: number;
|
statuses_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function guest() {
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
username: "",
|
||||||
|
acct: "",
|
||||||
|
url: "",
|
||||||
|
display_name: "You",
|
||||||
|
note: null,
|
||||||
|
avatar: null,
|
||||||
|
header: null,
|
||||||
|
identity_proofs: [],
|
||||||
|
payment_options: [],
|
||||||
|
fields: [],
|
||||||
|
followers_count: 0,
|
||||||
|
following_count: 0,
|
||||||
|
statuses_count: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export interface User extends Profile {
|
export interface User extends Profile {
|
||||||
source: Source;
|
source: Source;
|
||||||
}
|
}
|
||||||
|
|
|
@ -109,8 +109,8 @@ import { BigNumber, FixedNumber } from "ethers"
|
||||||
import { onMounted, watch } from "vue"
|
import { onMounted, watch } from "vue"
|
||||||
import { $, $$, $computed, $ref } from "vue/macros"
|
import { $, $$, $computed, $ref } from "vue/macros"
|
||||||
|
|
||||||
import { searchProfileByEthereumAddress } from "@/api/search"
|
import { searchProfilesByEthereumAddress } from "@/api/search"
|
||||||
import { Profile, ProfileWrapper } from "@/api/users"
|
import { guest, Profile, ProfileWrapper } from "@/api/users"
|
||||||
import {
|
import {
|
||||||
cancelSubscription,
|
cancelSubscription,
|
||||||
getSubscriptionConfig,
|
getSubscriptionConfig,
|
||||||
|
@ -132,29 +132,12 @@ const props = defineProps<{
|
||||||
profile: Profile,
|
profile: Profile,
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const guest: Profile = {
|
|
||||||
id: "",
|
|
||||||
username: "",
|
|
||||||
acct: "",
|
|
||||||
url: "",
|
|
||||||
display_name: "You",
|
|
||||||
note: null,
|
|
||||||
avatar: null,
|
|
||||||
header: null,
|
|
||||||
identity_proofs: [],
|
|
||||||
payment_options: [],
|
|
||||||
fields: [],
|
|
||||||
followers_count: 0,
|
|
||||||
following_count: 0,
|
|
||||||
statuses_count: 0,
|
|
||||||
}
|
|
||||||
|
|
||||||
const { currentUser } = $(useCurrentUser())
|
const { currentUser } = $(useCurrentUser())
|
||||||
const { instance } = $(useInstanceInfo())
|
const { instance } = $(useInstanceInfo())
|
||||||
const { connectWallet: connectEthereumWallet } = useWallet()
|
const { connectWallet: connectEthereumWallet } = useWallet()
|
||||||
const recipient = new ProfileWrapper(props.profile)
|
const recipient = new ProfileWrapper(props.profile)
|
||||||
const recipientEthereumAddress = recipient.getVerifiedEthereumAddress()
|
const recipientEthereumAddress = recipient.getVerifiedEthereumAddress()
|
||||||
let sender = $ref<ProfileWrapper>(new ProfileWrapper(currentUser || guest))
|
let sender = $ref<ProfileWrapper>(new ProfileWrapper(currentUser || guest()))
|
||||||
let { walletAddress, walletError, getSigner } = $(useWallet())
|
let { walletAddress, walletError, getSigner } = $(useWallet())
|
||||||
let subscriptionsEnabled = $ref<boolean | null>(null)
|
let subscriptionsEnabled = $ref<boolean | null>(null)
|
||||||
let subscriptionConfig = $ref<SubscriptionConfig | null>(null)
|
let subscriptionConfig = $ref<SubscriptionConfig | null>(null)
|
||||||
|
@ -217,7 +200,7 @@ async function checkSubscription() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Update sender info
|
// Update sender info
|
||||||
const profiles = await searchProfileByEthereumAddress(walletAddress)
|
const profiles = await searchProfilesByEthereumAddress(walletAddress)
|
||||||
if (profiles.length === 1) {
|
if (profiles.length === 1) {
|
||||||
sender = new ProfileWrapper(profiles[0])
|
sender = new ProfileWrapper(profiles[0])
|
||||||
} else {
|
} else {
|
||||||
|
|
332
src/components/SubscriptionMonero.vue
Normal file
332
src/components/SubscriptionMonero.vue
Normal file
|
@ -0,0 +1,332 @@
|
||||||
|
<template>
|
||||||
|
<div class="subscription">
|
||||||
|
<div class="participants">
|
||||||
|
<component
|
||||||
|
class="profile-card"
|
||||||
|
:is="sender.id ? 'router-link' : 'div'"
|
||||||
|
:to="{ name: 'profile', params: { profileId: sender.id } }"
|
||||||
|
>
|
||||||
|
<avatar :profile="sender"></avatar>
|
||||||
|
<div class="display-name">{{ sender.getDisplayName() }}</div>
|
||||||
|
</component>
|
||||||
|
<div class="separator">
|
||||||
|
<img :src="require('@/assets/feather/arrow-right.svg')">
|
||||||
|
</div>
|
||||||
|
<router-link class="profile-card" :to="{ name: 'profile', params: { profileId: recipient.id }}">
|
||||||
|
<avatar :profile="recipient"></avatar>
|
||||||
|
<div class="display-name">{{ recipient.getDisplayName() }}</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<form class="sender" v-if="sender.id === ''">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="senderAcct"
|
||||||
|
placeholder="Enter your fediverse address (user@example.org)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn"
|
||||||
|
@click.prevent="identifySender()"
|
||||||
|
>
|
||||||
|
Find profile
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="info" v-if="subscriptionOption !== null && sender.id !== ''">
|
||||||
|
<template v-if="subscriptionPrice">
|
||||||
|
<div class="price">
|
||||||
|
{{ subscriptionPrice }} XMR
|
||||||
|
<span class="price-subtext">per month</span>
|
||||||
|
</div>
|
||||||
|
<div class="status">
|
||||||
|
<template v-if="!isSubscribed()">
|
||||||
|
You are not subscribed yet
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div>
|
||||||
|
Subscription expires
|
||||||
|
{{ formatDate(subscriptionDetails.expires_at) }}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Subscription is not available.
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
<form class="payment" v-if="canSubscribe()">
|
||||||
|
<div class="duration">
|
||||||
|
<label for="duration">Duration</label>
|
||||||
|
<input type="number" id="duration" v-model="paymentDuration" min="1">
|
||||||
|
<span>months</span>
|
||||||
|
</div>
|
||||||
|
<div class="payment-amount">
|
||||||
|
<label>Amount</label>
|
||||||
|
<div>{{ getPaymentAmount() }} XMR</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn primary"
|
||||||
|
:disabled="!canPay()"
|
||||||
|
@click.prevent="onCreateInvoice()"
|
||||||
|
>
|
||||||
|
<template v-if="!isSubscribed()">
|
||||||
|
Pay
|
||||||
|
</template>
|
||||||
|
<template v-else>Extend</template>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div class="invoice" v-if="invoice">
|
||||||
|
<div>Please send {{ getPaymentAmount() }} XMR to this address:</div>
|
||||||
|
<div class="payment-address">{{ invoice.payment_address }}</div>
|
||||||
|
<div class="invoice-status">
|
||||||
|
<template v-if="invoice.status === 'open'">Waiting for payment</template>
|
||||||
|
<template v-else-if="invoice.status === 'paid'">Waiting for confirmation</template>
|
||||||
|
</div>
|
||||||
|
<button class="btn" @click.prevent="checkInvoice()">Check payment</button>
|
||||||
|
</div>
|
||||||
|
<loader v-if="isLoading"></loader>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from "vue"
|
||||||
|
import { $, $ref } from "vue/macros"
|
||||||
|
import { DateTime } from "luxon"
|
||||||
|
|
||||||
|
import { searchProfilesByAcct } from "@/api/search"
|
||||||
|
import { getSubscription, SubscriptionDetails } from "@/api/subscriptions-common"
|
||||||
|
import {
|
||||||
|
createInvoice,
|
||||||
|
getInvoice,
|
||||||
|
getPricePerMonth,
|
||||||
|
Invoice,
|
||||||
|
} from "@/api/subscriptions-monero"
|
||||||
|
import { guest, Profile, ProfilePaymentOption, ProfileWrapper } from "@/api/users"
|
||||||
|
import Avatar from "@/components/Avatar.vue"
|
||||||
|
import Loader from "@/components/Loader.vue"
|
||||||
|
import { useCurrentUser } from "@/store/user"
|
||||||
|
import { formatDate } from "@/utils/dates"
|
||||||
|
|
||||||
|
/* eslint-disable-next-line no-undef */
|
||||||
|
const props = defineProps<{
|
||||||
|
profile: Profile,
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { currentUser } = $(useCurrentUser())
|
||||||
|
const recipient = new ProfileWrapper(props.profile)
|
||||||
|
const senderAcct = $ref("")
|
||||||
|
let sender = $ref<ProfileWrapper>(new ProfileWrapper(currentUser || guest()))
|
||||||
|
let subscriptionOption = $ref<ProfilePaymentOption | null>(null)
|
||||||
|
let subscriptionPrice = $ref<number | null>(null)
|
||||||
|
let subscriptionDetails = $ref<SubscriptionDetails | null>(null)
|
||||||
|
const paymentDuration = $ref<number>(1)
|
||||||
|
let invoice = $ref<Invoice | null>(null)
|
||||||
|
|
||||||
|
let isLoading = $ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
subscriptionOption = recipient.payment_options.find((option) => {
|
||||||
|
return option.type === "monero-subscription" && option.price !== undefined
|
||||||
|
}) || null
|
||||||
|
if (subscriptionOption?.price) {
|
||||||
|
subscriptionPrice = getPricePerMonth(subscriptionOption.price)
|
||||||
|
if (sender.id !== "") {
|
||||||
|
subscriptionDetails = await getSubscription(sender.id, recipient.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function identifySender() {
|
||||||
|
if (!senderAcct) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoading = true
|
||||||
|
const profiles = await searchProfilesByAcct(senderAcct)
|
||||||
|
const profile = profiles[0]
|
||||||
|
if (profile && profile.id !== recipient.id) {
|
||||||
|
sender = new ProfileWrapper(profile)
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSubscribed(): boolean {
|
||||||
|
if (subscriptionDetails === null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const expiresAt = DateTime.fromISO(subscriptionDetails.expires_at)
|
||||||
|
return DateTime.now() < expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
function canSubscribe(): boolean {
|
||||||
|
return sender.id !== "" && subscriptionPrice !== null && invoice === null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPaymentAmount(): number {
|
||||||
|
if (!subscriptionPrice) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
const amount = subscriptionPrice * paymentDuration
|
||||||
|
return amount
|
||||||
|
}
|
||||||
|
|
||||||
|
function canPay(): boolean {
|
||||||
|
return getPaymentAmount() > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCreateInvoice() {
|
||||||
|
isLoading = true
|
||||||
|
invoice = await createInvoice(
|
||||||
|
sender.id,
|
||||||
|
recipient.id,
|
||||||
|
)
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkInvoice() {
|
||||||
|
if (!invoice) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isLoading = true
|
||||||
|
invoice = await getInvoice(invoice.id)
|
||||||
|
if (invoice.status === "forwarded") {
|
||||||
|
subscriptionDetails = await getSubscription(sender.id, recipient.id)
|
||||||
|
invoice = null
|
||||||
|
}
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@import "../styles/layout";
|
||||||
|
@import "../styles/mixins";
|
||||||
|
@import "../styles/theme";
|
||||||
|
|
||||||
|
.subscription {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $block-outer-padding;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.participants {
|
||||||
|
$avatar-size: 60px;
|
||||||
|
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: $block-inner-padding;
|
||||||
|
|
||||||
|
.profile-card {
|
||||||
|
background-color: $block-background-color;
|
||||||
|
border-radius: $block-border-radius;
|
||||||
|
display: flex;
|
||||||
|
flex-basis: 50%;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $block-inner-padding / 2;
|
||||||
|
min-width: 0;
|
||||||
|
padding: $block-inner-padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator img {
|
||||||
|
height: $icon-size;
|
||||||
|
min-width: $icon-size;
|
||||||
|
object-fit: contain;
|
||||||
|
width: $icon-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
height: $avatar-size;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: $avatar-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.display-name {
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wallet-address {
|
||||||
|
font-family: monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
text-align: center;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sender {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $block-inner-padding;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
background-color: $block-background-color;
|
||||||
|
border-radius: $block-border-radius;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $block-inner-padding / 2;
|
||||||
|
padding: $block-inner-padding;
|
||||||
|
|
||||||
|
.price {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-subtext {
|
||||||
|
font-size: $text-font-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
color: $secondary-text-color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $block-inner-padding;
|
||||||
|
|
||||||
|
.duration,
|
||||||
|
.payment-amount {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
gap: $input-padding;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration {
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
label {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
input {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.payment-amount {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.invoice {
|
||||||
|
align-items: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $block-inner-padding;
|
||||||
|
|
||||||
|
.payment-address {
|
||||||
|
font-family: monospace;
|
||||||
|
max-width: 100%;
|
||||||
|
user-select: all;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -3,6 +3,7 @@
|
||||||
<template #content>
|
<template #content>
|
||||||
<h1>Subscription</h1>
|
<h1>Subscription</h1>
|
||||||
<subscription-ethereum v-if="isEthereum()" :profile="profile"></subscription-ethereum>
|
<subscription-ethereum v-if="isEthereum()" :profile="profile"></subscription-ethereum>
|
||||||
|
<subscription-monero v-if="isMonero()" :profile="profile"></subscription-monero>
|
||||||
</template>
|
</template>
|
||||||
</sidebar-layout>
|
</sidebar-layout>
|
||||||
</template>
|
</template>
|
||||||
|
@ -15,6 +16,7 @@ import { useRoute } from "vue-router"
|
||||||
import { getProfile, Profile } from "@/api/users"
|
import { getProfile, Profile } from "@/api/users"
|
||||||
import SidebarLayout from "@/components/SidebarLayout.vue"
|
import SidebarLayout from "@/components/SidebarLayout.vue"
|
||||||
import SubscriptionEthereum from "@/components/SubscriptionEthereum.vue"
|
import SubscriptionEthereum from "@/components/SubscriptionEthereum.vue"
|
||||||
|
import SubscriptionMonero from "@/components/SubscriptionMonero.vue"
|
||||||
import { useCurrentUser } from "@/store/user"
|
import { useCurrentUser } from "@/store/user"
|
||||||
import { useInstanceInfo } from "@/store/instance"
|
import { useInstanceInfo } from "@/store/instance"
|
||||||
|
|
||||||
|
@ -46,4 +48,11 @@ function isEthereum(): boolean {
|
||||||
}
|
}
|
||||||
return blockchain.chain_id.startsWith("eip155")
|
return blockchain.chain_id.startsWith("eip155")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isMonero(): boolean {
|
||||||
|
if (!blockchain) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return blockchain.chain_id.startsWith("monero")
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
Loading…
Reference in a new issue