Create payment page for Monero subscriptions

This commit is contained in:
silverpill 2022-09-05 15:57:09 +00:00
parent ee142eefa9
commit 86945aa057
7 changed files with 444 additions and 22 deletions

View file

@ -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`

View file

@ -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)
}
}

View file

@ -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
}
}

View file

@ -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;
}

View file

@ -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 {

View 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>

View file

@ -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>