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
|
||||
}
|
||||
|
||||
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,
|
||||
): Promise<Profile[]> {
|
||||
const url = `${BACKEND_URL}/api/v1/accounts/search_did`
|
||||
|
|
|
@ -22,3 +22,27 @@ export async function getSubscriptionOptions(
|
|||
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
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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 {
|
||||
source: Source;
|
||||
}
|
||||
|
|
|
@ -109,8 +109,8 @@ import { BigNumber, FixedNumber } from "ethers"
|
|||
import { onMounted, watch } from "vue"
|
||||
import { $, $$, $computed, $ref } from "vue/macros"
|
||||
|
||||
import { searchProfileByEthereumAddress } from "@/api/search"
|
||||
import { Profile, ProfileWrapper } from "@/api/users"
|
||||
import { searchProfilesByEthereumAddress } from "@/api/search"
|
||||
import { guest, Profile, ProfileWrapper } from "@/api/users"
|
||||
import {
|
||||
cancelSubscription,
|
||||
getSubscriptionConfig,
|
||||
|
@ -132,29 +132,12 @@ const props = defineProps<{
|
|||
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 { instance } = $(useInstanceInfo())
|
||||
const { connectWallet: connectEthereumWallet } = useWallet()
|
||||
const recipient = new ProfileWrapper(props.profile)
|
||||
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 subscriptionsEnabled = $ref<boolean | null>(null)
|
||||
let subscriptionConfig = $ref<SubscriptionConfig | null>(null)
|
||||
|
@ -217,7 +200,7 @@ async function checkSubscription() {
|
|||
return
|
||||
}
|
||||
// Update sender info
|
||||
const profiles = await searchProfileByEthereumAddress(walletAddress)
|
||||
const profiles = await searchProfilesByEthereumAddress(walletAddress)
|
||||
if (profiles.length === 1) {
|
||||
sender = new ProfileWrapper(profiles[0])
|
||||
} 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>
|
||||
<h1>Subscription</h1>
|
||||
<subscription-ethereum v-if="isEthereum()" :profile="profile"></subscription-ethereum>
|
||||
<subscription-monero v-if="isMonero()" :profile="profile"></subscription-monero>
|
||||
</template>
|
||||
</sidebar-layout>
|
||||
</template>
|
||||
|
@ -15,6 +16,7 @@ import { useRoute } from "vue-router"
|
|||
import { getProfile, Profile } from "@/api/users"
|
||||
import SidebarLayout from "@/components/SidebarLayout.vue"
|
||||
import SubscriptionEthereum from "@/components/SubscriptionEthereum.vue"
|
||||
import SubscriptionMonero from "@/components/SubscriptionMonero.vue"
|
||||
import { useCurrentUser } from "@/store/user"
|
||||
import { useInstanceInfo } from "@/store/instance"
|
||||
|
||||
|
@ -46,4 +48,11 @@ function isEthereum(): boolean {
|
|||
}
|
||||
return blockchain.chain_id.startsWith("eip155")
|
||||
}
|
||||
|
||||
function isMonero(): boolean {
|
||||
if (!blockchain) {
|
||||
return false
|
||||
}
|
||||
return blockchain.chain_id.startsWith("monero")
|
||||
}
|
||||
</script>
|
||||
|
|
Loading…
Reference in a new issue