Modify the checkout widget to support subscriptions. Add usage-based billing toggle to sharing settings.
This commit is contained in:
@@ -76,7 +76,7 @@ const open = () => dialog.open(button.getBoundingClientRect())
|
|||||||
|
|
||||||
<Button link_href="/user/settings" link_target={target} icon="settings" label="Account Settings"/>
|
<Button link_href="/user/settings" link_target={target} icon="settings" label="Account Settings"/>
|
||||||
<Button link_href="/user/subscription" link_target={target} icon="shopping_cart" label="Subscription"/>
|
<Button link_href="/user/subscription" link_target={target} icon="shopping_cart" label="Subscription"/>
|
||||||
<Button link_href="/user/prepaid/transactions" link_target={target} icon="receipt" label="Transactions"/>
|
<Button link_href="/user/subscription/transactions" link_target={target} icon="receipt" label="Transactions"/>
|
||||||
|
|
||||||
{#if $user.is_admin}
|
{#if $user.is_admin}
|
||||||
<div class="separator"></div>
|
<div class="separator"></div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import Tooltip from "layout/Tooltip.svelte";
|
import Tooltip from "layout/Tooltip.svelte";
|
||||||
import Euro from "util/Euro.svelte";
|
import Euro from "util/Euro.svelte";
|
||||||
import OtherPlans from "./SubscriptionPlans.svelte";
|
import OtherPlans from "layout/checkout/SubscriptionPlans.svelte";
|
||||||
import { user } from "lib/UserStore";
|
import { user } from "lib/UserStore";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -244,12 +244,12 @@ import { user } from "lib/UserStore";
|
|||||||
{#if $user === null}
|
{#if $user === null}
|
||||||
<!-- User is not logged in -->
|
<!-- User is not logged in -->
|
||||||
Account required<br/>
|
Account required<br/>
|
||||||
<a href="/login?redirect=/user/prepaid/deposit" class="button button_highlight round">
|
<a href="/login?redirect=/user/subscription/manage" class="button button_highlight round">
|
||||||
<i class="icon">login</i>
|
<i class="icon">login</i>
|
||||||
Log in
|
Log in
|
||||||
</a>
|
</a>
|
||||||
or
|
or
|
||||||
<a href="/register?redirect=/user/prepaid/deposit" class="button button_highlight round">
|
<a href="/register?redirect=/user/subscription/manage" class="button button_highlight round">
|
||||||
<i class="icon">how_to_reg</i>
|
<i class="icon">how_to_reg</i>
|
||||||
Register
|
Register
|
||||||
</a>
|
</a>
|
||||||
@@ -259,7 +259,7 @@ import { user } from "lib/UserStore";
|
|||||||
{:else}
|
{:else}
|
||||||
<!-- User is logged in -->
|
<!-- User is logged in -->
|
||||||
{#if $user.subscription.type === ""}
|
{#if $user.subscription.type === ""}
|
||||||
<a href="/user/prepaid/deposit#deposit" class="button button_highlight round">
|
<a href="/user/subscription/manage#deposit" class="button button_highlight round">
|
||||||
Deposit credit
|
Deposit credit
|
||||||
</a>
|
</a>
|
||||||
to activate Prepaid
|
to activate Prepaid
|
||||||
@@ -269,7 +269,7 @@ import { user } from "lib/UserStore";
|
|||||||
Prepaid subscription is active<br/>
|
Prepaid subscription is active<br/>
|
||||||
Current balance <Euro amount={$user.balance_micro_eur}/>
|
Current balance <Euro amount={$user.balance_micro_eur}/>
|
||||||
<br/>
|
<br/>
|
||||||
<a href="/user/prepaid/deposit#deposit" class="button button_highlight round">
|
<a href="/user/subscription/manage#deposit" class="button button_highlight round">
|
||||||
Top up my credit
|
Top up my credit
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -8,24 +8,21 @@ import Euro from "util/Euro.svelte";
|
|||||||
{#if $user !== null}
|
{#if $user !== null}
|
||||||
<div class="page_content">
|
<div class="page_content">
|
||||||
<section>
|
<section>
|
||||||
{#if $user.subscription.type === "patreon"}
|
{#if $user.subscription.id !== ""}
|
||||||
<p>
|
<p>
|
||||||
You already have a Patreon subscription active. You cannot use
|
You already have the {$user.subscription.name} plan active.
|
||||||
Prepaid while that subscription is active.
|
Your account balance is <Euro
|
||||||
|
amount={$user.balance_micro_eur}/>. Subscriptions are
|
||||||
|
charged from this balance every day, use the form below to
|
||||||
|
top it up or to switch to another plan.
|
||||||
</p>
|
</p>
|
||||||
{:else if $user.subscription.type === "prepaid"}
|
|
||||||
<p>
|
|
||||||
You already have a Prepaid subscription active. Your account
|
|
||||||
balance is <Euro amount={$user.balance_micro_eur}/>. Use
|
|
||||||
the form below to top up your balance.
|
|
||||||
</p>
|
|
||||||
<Checkout/>
|
|
||||||
{:else}
|
{:else}
|
||||||
<p>
|
<p>
|
||||||
Pick one of the subscription plans above to upgrade your
|
Pick a subscription plan to upgrade your account. Your
|
||||||
account to premium.
|
account balance is <Euro amount={$user.balance_micro_eur}/>.
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
<Checkout/>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -4,14 +4,38 @@ import PickAmount from "./PickAmount.svelte";
|
|||||||
import PickCountry from "./PickCountry.svelte";
|
import PickCountry from "./PickCountry.svelte";
|
||||||
import { payment_providers, type CheckoutState } from "./CheckoutLib";
|
import { payment_providers, type CheckoutState } from "./CheckoutLib";
|
||||||
import PickName from "./PickName.svelte";
|
import PickName from "./PickName.svelte";
|
||||||
import { get_misc_vat_rate, get_user } from "lib/NovaAPI";
|
import { get_misc_subscriptions, get_misc_vat_rate, get_user, subscription_periods } from "lib/NovaAPI";
|
||||||
import { countries } from "country-data-list";
|
import { countries } from "country-data-list";
|
||||||
import PickProvider from "./PickProvider.svelte";
|
import PickProvider from "./PickProvider.svelte";
|
||||||
|
import PickPlan from "./PickPlan.svelte";
|
||||||
|
|
||||||
let status: CheckoutState = $state({country: null, provider: null, amount: 0, vat: 0, name: ""})
|
let status: CheckoutState = $state({
|
||||||
|
subscription: undefined,
|
||||||
|
months: 1,
|
||||||
|
country: null,
|
||||||
|
provider: null,
|
||||||
|
amount: 0,
|
||||||
|
vat: 0,
|
||||||
|
name: "",
|
||||||
|
})
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
try {
|
try {
|
||||||
|
// The plan links on the home page point here with the plan in the URL
|
||||||
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
if (params.get("plan") !== null) {
|
||||||
|
const plan = (await get_misc_subscriptions())
|
||||||
|
.find(p => p.id === params.get("plan"))
|
||||||
|
|
||||||
|
if (plan !== undefined) {
|
||||||
|
const months = Number(params.get("months"))
|
||||||
|
status.subscription = plan
|
||||||
|
status.months = months >= plan.min_months &&
|
||||||
|
subscription_periods.some(p => p.months === months) ?
|
||||||
|
months : plan.min_months
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const user = await get_user()
|
const user = await get_user()
|
||||||
|
|
||||||
// Get the country from the user profile
|
// Get the country from the user profile
|
||||||
@@ -39,9 +63,18 @@ onMount(async () => {
|
|||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="highlight_border">
|
<div class="highlight_border checkout">
|
||||||
{#if status.country !== null}
|
{#if status.subscription !== undefined}
|
||||||
<div class="navbar">
|
<div class="navbar">
|
||||||
|
<button onclick={() => status.subscription = undefined}>
|
||||||
|
{#if status.subscription === null}
|
||||||
|
Plan: Credit deposit only
|
||||||
|
{:else}
|
||||||
|
Plan: {status.subscription.name}, {status.months}
|
||||||
|
month{status.months === 1 ? "" : "s"}
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
|
||||||
{#if status.country !== null}
|
{#if status.country !== null}
|
||||||
<button onclick={() => status.country = null}>
|
<button onclick={() => status.country = null}>
|
||||||
Country:
|
Country:
|
||||||
@@ -71,7 +104,9 @@ onMount(async () => {
|
|||||||
<hr/>
|
<hr/>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if status.country === null}
|
{#if status.subscription === undefined}
|
||||||
|
<PickPlan bind:status/>
|
||||||
|
{:else if status.country === null}
|
||||||
<PickCountry bind:status/>
|
<PickCountry bind:status/>
|
||||||
{:else if status.provider === null}
|
{:else if status.provider === null}
|
||||||
<PickProvider bind:status/>
|
<PickProvider bind:status/>
|
||||||
@@ -88,9 +123,25 @@ onMount(async () => {
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5em;
|
gap: 0.25em;
|
||||||
}
|
}
|
||||||
.provider_icon {
|
.provider_icon {
|
||||||
height: 1.5em;
|
height: 1.5em;
|
||||||
}
|
}
|
||||||
|
/* The widget has no padding on the sides, that way the separators between the
|
||||||
|
steps span its full width. All the children add the padding back, except the
|
||||||
|
separators and the plan list, which scrolls sideways */
|
||||||
|
.checkout {
|
||||||
|
padding-left: 0;
|
||||||
|
padding-right: 0;
|
||||||
|
}
|
||||||
|
.checkout > :global(*:not(hr, .horizontal_scroll)) {
|
||||||
|
padding-left: 0.5em;
|
||||||
|
padding-right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Running text reads better left aligned. Titles and buttons stay centred */
|
||||||
|
.checkout :global(.text) {
|
||||||
|
text-align: initial;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import type { countries } from "country-data-list";
|
import type { countries } from "country-data-list";
|
||||||
import { get_endpoint } from "lib/NovaAPI";
|
import { get_endpoint, subscription_periods, type Subscription } from "lib/NovaAPI";
|
||||||
|
|
||||||
export type CheckoutState = {
|
export type CheckoutState = {
|
||||||
|
// undefined means the user has not picked yet, null means they chose to
|
||||||
|
// only deposit credit without starting a subscription
|
||||||
|
subscription: Subscription | null | undefined
|
||||||
|
months: number
|
||||||
country: typeof countries.all[0]
|
country: typeof countries.all[0]
|
||||||
provider: PaymentProvider
|
provider: PaymentProvider
|
||||||
amount: number
|
amount: number
|
||||||
@@ -9,6 +13,16 @@ export type CheckoutState = {
|
|||||||
name: string
|
name: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The price of the chosen subscription period in euro. The deposit can't be
|
||||||
|
// lower than this
|
||||||
|
export const plan_price = (state: CheckoutState): number => {
|
||||||
|
if (!state.subscription) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return state.subscription.monthly_fee * state.months *
|
||||||
|
subscription_periods.find(p => p.months === state.months).discount / 1e6
|
||||||
|
}
|
||||||
|
|
||||||
export type PaymentProvider = {
|
export type PaymentProvider = {
|
||||||
icon: string
|
icon: string
|
||||||
name: string
|
name: string
|
||||||
@@ -77,6 +91,8 @@ export const payment_providers: PaymentProvider[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export const checkout = async (state: CheckoutState) => {
|
export const checkout = async (state: CheckoutState) => {
|
||||||
|
// Whether the deposit covers the subscription is checked by the server,
|
||||||
|
// which knows how much credit is already on the account
|
||||||
if (state.amount < 10) {
|
if (state.amount < 10) {
|
||||||
alert("Amount needs to be at least €10")
|
alert("Amount needs to be at least €10")
|
||||||
return
|
return
|
||||||
@@ -94,6 +110,12 @@ export const checkout = async (state: CheckoutState) => {
|
|||||||
form.set("name", state.name)
|
form.set("name", state.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The server activates the subscription when the invoice is paid
|
||||||
|
if (state.subscription) {
|
||||||
|
form.set("subscription", state.subscription.id)
|
||||||
|
form.set("subscription_months", String(state.months))
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resp = await fetch(
|
const resp = await fetch(
|
||||||
get_endpoint() + "/user/invoice",
|
get_endpoint() + "/user/invoice",
|
||||||
|
|||||||
@@ -1,31 +1,129 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { preventDefault } from 'svelte/legacy';
|
|
||||||
import Euro from "util/Euro.svelte";
|
import Euro from "util/Euro.svelte";
|
||||||
import { checkout, type CheckoutState } from "./CheckoutLib";
|
import { checkout, plan_price, type CheckoutState } from "./CheckoutLib";
|
||||||
import { loading_finish, loading_start } from "lib/Loading";
|
import { loading_finish, loading_start } from "lib/Loading";
|
||||||
|
import { days_per_month, put_user, subscription_periods, type SubscriptionPeriod } from "lib/NovaAPI";
|
||||||
|
import { user } from "lib/UserStore";
|
||||||
|
import { formatDate } from "util/Formatting";
|
||||||
|
|
||||||
let { status = $bindable() }: {
|
let { status = $bindable() }: {
|
||||||
status: CheckoutState;
|
status: CheckoutState;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
|
// The credit already on the account counts towards the subscription, so the
|
||||||
|
// deposit only has to cover the rest of it
|
||||||
|
let credit = $derived($user === null ? 0 : $user.balance_micro_eur / 1e6)
|
||||||
|
let missing = $derived(Math.max(0, plan_price(status) - credit))
|
||||||
|
|
||||||
|
// Extending the plan the user is already on is always a credit deposit, there
|
||||||
|
// is nothing to activate
|
||||||
|
let extending = $derived(
|
||||||
|
status.subscription !== null &&
|
||||||
|
$user !== null &&
|
||||||
|
status.subscription.id === $user.subscription.id &&
|
||||||
|
status.months === $user.subscription_months
|
||||||
|
)
|
||||||
|
|
||||||
|
// Monthly price of the data stored above the chosen plan's limit. The server
|
||||||
|
// bills this on top of the subscription. It is left out when extending, the
|
||||||
|
// user is already paying it
|
||||||
|
let excess_storage = $derived(
|
||||||
|
status.subscription === null || $user === null || extending ? 0 :
|
||||||
|
Math.max(0, $user.storage_space_used - status.subscription.storage_limit) *
|
||||||
|
status.subscription.price_per_tb_storage / 1e12,
|
||||||
|
)
|
||||||
|
|
||||||
|
// The account credit already pays for the whole subscription period, so there
|
||||||
|
// is nothing to check out. The user can activate the plan right away. Covered
|
||||||
|
// is false when the current plan is being extended because otherwise the amount
|
||||||
|
// picker would be hidden
|
||||||
|
let covered = $derived(
|
||||||
|
status.subscription !== null &&
|
||||||
|
missing === 0 &&
|
||||||
|
!extending
|
||||||
|
)
|
||||||
|
|
||||||
|
// Estimate of how long the credit lasts at the daily rate of the plan. Charges
|
||||||
|
// for usage outside of the plan are not included, so the real date comes sooner
|
||||||
|
let daily_price = $derived(status.subscription === null ? 0 :
|
||||||
|
status.subscription.monthly_fee * (
|
||||||
|
subscription_periods.find(p => p.months === status.months)?.discount ?? 1
|
||||||
|
) / days_per_month / 1e6,
|
||||||
|
)
|
||||||
|
let runs_until = $derived.by(() => {
|
||||||
|
if (daily_price <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const date = new Date()
|
||||||
|
date.setDate(date.getDate() + Math.floor(
|
||||||
|
(credit + (covered ? 0 : amount)) / daily_price,
|
||||||
|
))
|
||||||
|
return formatDate(date, false, false, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
// We can't process payments below €10
|
||||||
|
let minimum = $derived(Math.max(10, Math.ceil(missing)))
|
||||||
const amounts = [10, 20, 50, 100, 200, 500, 1000, 2000, 5000]
|
const amounts = [10, 20, 50, 100, 200, 500, 1000, 2000, 5000]
|
||||||
let amount = $state(20)
|
let amount = $state(10)
|
||||||
|
|
||||||
|
// Picking a longer period can raise the minimum above the chosen amount. We
|
||||||
|
// never lower it again, the user may have typed a custom amount
|
||||||
|
$effect(() => {
|
||||||
|
if (amount < minimum) {
|
||||||
|
amount = minimum
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
let periods = $derived(status.subscription === null ? [] : subscription_periods.filter(
|
||||||
|
p => p.months >= status.subscription.min_months,
|
||||||
|
))
|
||||||
|
|
||||||
|
// Price of a whole period, and what it would cost without the discount
|
||||||
|
const period_price = (months: number, discount: number) =>
|
||||||
|
status.subscription.monthly_fee * months * discount
|
||||||
|
const period_saving = (months: number, discount: number) =>
|
||||||
|
status.subscription.monthly_fee * months * (1 - discount)
|
||||||
|
const switch_period = (period: SubscriptionPeriod) => {
|
||||||
|
status.months = period.months
|
||||||
|
amount = minimum
|
||||||
|
}
|
||||||
|
|
||||||
const submit = async (e: SubmitEvent) => {
|
const submit = async (e: SubmitEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
|
|
||||||
status.amount = amount
|
|
||||||
loading_start()
|
loading_start()
|
||||||
try {
|
try {
|
||||||
|
if (covered) {
|
||||||
|
// Nothing to pay, so we activate the subscription directly instead
|
||||||
|
// of sending the user to a payment provider
|
||||||
|
await put_user({
|
||||||
|
subscription: status.subscription.id,
|
||||||
|
subscription_months: status.months,
|
||||||
|
})
|
||||||
|
window.location.href = "/user/subscription"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
status.amount = amount
|
||||||
await checkout(status)
|
await checkout(status)
|
||||||
|
} catch (err) {
|
||||||
|
alert("Failed to activate subscription: "+(err?.message ?? err))
|
||||||
} finally {
|
} finally {
|
||||||
loading_finish()
|
loading_finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if status.provider.crypto === true}
|
{#if excess_storage > 0}
|
||||||
<p style="text-align: initial;" class="highlight_blue">
|
<p class="highlight_yellow text">
|
||||||
|
Your current storage usage exceeds the plan's limit. After activating
|
||||||
|
this subscription plan you will be charged <Euro amount={excess_storage}/>
|
||||||
|
per month on top of the subscription for excess storage usage.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if status.provider.crypto === true && !covered}
|
||||||
|
<p class="highlight_blue text">
|
||||||
When paying with cryptocurrencies it is important that you pay
|
When paying with cryptocurrencies it is important that you pay
|
||||||
the <b>exact amount</b> stated on the order. If you pay too
|
the <b>exact amount</b> stated on the order. If you pay too
|
||||||
little, the order fails. If you pay too much then the remaining
|
little, the order fails. If you pay too much then the remaining
|
||||||
@@ -36,13 +134,57 @@ const submit = async (e: SubmitEvent) => {
|
|||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<form class="amount_grid" onsubmit={submit}>
|
<div class="text">
|
||||||
<div class="span3">Please choose an amount</div>
|
{#if covered}
|
||||||
|
|
||||||
|
Subscription {status.subscription.name} costs
|
||||||
|
<Euro amount={plan_price(status)*1e6}/>
|
||||||
|
for {status.months} month{status.months === 1 ? "" : "s"},
|
||||||
|
which your account credit of <Euro amount={credit*1e6}/> already
|
||||||
|
covers. You don't have to pay anything now.
|
||||||
|
|
||||||
|
{:else if status.subscription !== null}
|
||||||
|
|
||||||
|
Subscription {status.subscription.name} costs
|
||||||
|
<Euro amount={plan_price(status)*1e6}/>
|
||||||
|
for {status.months} month{status.months === 1 ? "" : "s"}.
|
||||||
|
|
||||||
|
{#if credit > 0}
|
||||||
|
Your account credit of <Euro amount={credit*1e6}/> is subtracted
|
||||||
|
from that, so you need to deposit at least
|
||||||
|
<Euro amount={missing*1e6}/> to activate the plan.
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
You can deposit more, the remainder will extend the runtime of your
|
||||||
|
subscription plan.
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
|
||||||
|
Please choose an amount
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr/>
|
||||||
|
|
||||||
|
<form onsubmit={submit}>
|
||||||
|
{#if covered}
|
||||||
|
|
||||||
|
<button type="submit" class="button_highlight" style="flex-direction: column;">
|
||||||
|
<i class="icon">shopping_cart</i>
|
||||||
|
Activate plan {status.subscription.name}
|
||||||
|
({status.months} month{status.months === 1 ? "" : "s"})
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
|
||||||
|
<div class="amount_grid">
|
||||||
{#each amounts as a}
|
{#each amounts as a}
|
||||||
<button
|
<button
|
||||||
onclick={preventDefault(() => amount = a)}
|
onclick={(e: MouseEvent) => {e.preventDefault(); amount = a}}
|
||||||
class="amount_button"
|
class="amount_button"
|
||||||
class:button_highlight={amount === a}
|
class:button_highlight={amount === a}
|
||||||
|
disabled={a < minimum}
|
||||||
>
|
>
|
||||||
€ {a}
|
€ {a}
|
||||||
</button>
|
</button>
|
||||||
@@ -50,28 +192,129 @@ const submit = async (e: SubmitEvent) => {
|
|||||||
|
|
||||||
<div class="span3 custom_amount">
|
<div class="span3 custom_amount">
|
||||||
<div>Custom amount €</div>
|
<div>Custom amount €</div>
|
||||||
<input type="number" bind:value={amount} min="10"/>
|
<input type="number" bind:value={amount} min={minimum}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="span2" style="text-align: initial;">
|
<div class="span2 text">
|
||||||
Total including VAT:
|
VAT:
|
||||||
|
<Euro amount={(amount*1e6)*(status.vat/100)}/>,
|
||||||
|
total:
|
||||||
<Euro amount={(amount*1e6) + (amount*1e6)*(status.vat/100)}/>
|
<Euro amount={(amount*1e6) + (amount*1e6)*(status.vat/100)}/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="button_highlight">
|
<button type="submit" class="button_highlight">
|
||||||
<i class="icon">paid</i> Checkout
|
<i class="icon">paid</i> Checkout
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{#if runs_until !== null}
|
||||||
<hr/>
|
<hr/>
|
||||||
<p style="text-align: initial;">
|
<div class="text">
|
||||||
This Nova premium plan costs €1 per month, but due to
|
{#if extending}
|
||||||
processing fees we can't accept payments less than €10. So your
|
|
||||||
deposit will give roughly 10 months of premium service depending on
|
This credit deposit will extend your current subscription until
|
||||||
usage. You can track your spending on the <a
|
about <span class="date">{runs_until}</span>, assuming usage within
|
||||||
href="/user/prepaid/transactions">transactions page</a>.
|
the subscription plan's limits.
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
|
||||||
|
This credit deposit will keep the {status.subscription.name} plan
|
||||||
|
running until about <span class="date">{runs_until}</span>, assuming
|
||||||
|
usage within the plan's limits.
|
||||||
|
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<hr/>
|
||||||
|
|
||||||
|
{#if status.subscription !== null}
|
||||||
|
<div>Save money by paying ahead</div>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Billing period</th>
|
||||||
|
<th>Price</th>
|
||||||
|
<th>Per day</th>
|
||||||
|
<th>You save</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each periods as p (p.months)}
|
||||||
|
<tr>
|
||||||
|
<td class="period">{p.name}</td>
|
||||||
|
<td data-label="Price">
|
||||||
|
<Euro amount={period_price(p.months, p.discount)}/>
|
||||||
|
</td>
|
||||||
|
<td data-label="Per day">
|
||||||
|
<Euro amount={status.subscription.monthly_fee*p.discount/days_per_month}/>
|
||||||
|
</td>
|
||||||
|
<td data-label="You save">
|
||||||
|
<Euro amount={period_saving(p.months, p.discount)}/>
|
||||||
|
({Math.round((1-p.discount)*100)}%)
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if p.months === status.months}
|
||||||
|
<i class="icon">check</i> Selected
|
||||||
|
{:else}
|
||||||
|
<button onclick={() => switch_period(p)}>
|
||||||
|
<i class="icon">swap_horiz</i> Select
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<p class="text">
|
||||||
|
Subscriptions are charged from your account credit every day. Due to
|
||||||
|
processing fees we can't accept payments less than €10. You can track your
|
||||||
|
spending on the <a href="/user/subscription/transactions">transactions page</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.date {
|
||||||
|
color: var(--highlight_color);
|
||||||
|
}
|
||||||
|
/* layout.css collapses the table borders, which makes the browser ignore any
|
||||||
|
padding on the table itself. The cells line the table up with the rest of
|
||||||
|
the widget instead */
|
||||||
|
th, td {
|
||||||
|
text-align: initial;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 0 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The table is too wide for narrow screens. There we stack each row into a
|
||||||
|
block and repeat the column headers in front of the cells */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
table, tbody, tr, td {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
thead {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
tbody tr {
|
||||||
|
border-top: 1px solid var(--separator);
|
||||||
|
padding: 0.5em 0;
|
||||||
|
}
|
||||||
|
td {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
td[data-label]::before {
|
||||||
|
content: attr(data-label) ": ";
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.period {
|
||||||
|
font-size: 1.2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
.amount_grid {
|
.amount_grid {
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const submit = async (e: SubmitEvent) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<form class="name_form" onsubmit={submit}>
|
<form class="name_form" onsubmit={submit}>
|
||||||
<div>
|
<div class="text">
|
||||||
This payment provider requires a name for checkout. Please enter your
|
This payment provider requires a name for checkout. Please enter your
|
||||||
full name below
|
full name below
|
||||||
</div>
|
</div>
|
||||||
@@ -34,11 +34,11 @@ const submit = async (e: SubmitEvent) => {
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
<hr/>
|
<hr/>
|
||||||
<p style="text-align: initial;">
|
<p class="text">
|
||||||
This Nova premium plan costs €1 per month, but due to processing fees we
|
This Nova premium plan costs €1 per month, but due to processing fees we
|
||||||
can't accept payments less than €10. So your deposit will give roughly 10
|
can't accept payments less than €10. So your deposit will give roughly 10
|
||||||
months of premium service depending on usage. You can track your spending on
|
months of premium service depending on usage. You can track your spending on
|
||||||
the <a href="/user/prepaid/transactions">transactions page</a>.
|
the <a href="/user/subscription/transactions">transactions page</a>.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
92
svelte/src/layout/checkout/PickPlan.svelte
Normal file
92
svelte/src/layout/checkout/PickPlan.svelte
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import SubscriptionPlans from "./SubscriptionPlans.svelte";
|
||||||
|
import { put_user, type Subscription } from "lib/NovaAPI";
|
||||||
|
import { loading_finish, loading_start } from "lib/Loading";
|
||||||
|
import { user } from "lib/UserStore";
|
||||||
|
import SuccessMessage from "util/SuccessMessage.svelte";
|
||||||
|
import type { CheckoutState } from "./CheckoutLib";
|
||||||
|
import Euro from "util/Euro.svelte";
|
||||||
|
|
||||||
|
let { status = $bindable() }: {
|
||||||
|
status: CheckoutState;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let cancel_message: SuccessMessage = $state()
|
||||||
|
|
||||||
|
const pick = (plan: Subscription, months: number) => {
|
||||||
|
status.subscription = plan
|
||||||
|
status.months = months
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extending keeps the plan the user is on, the deposit only adds credit to it
|
||||||
|
const extend = () => {
|
||||||
|
status.subscription = $user.subscription
|
||||||
|
status.months = $user.subscription_months || $user.subscription.min_months
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancel = async () => {
|
||||||
|
if (!confirm(
|
||||||
|
"This cancels your subscription and moves your account back to the "+
|
||||||
|
"Free plan, which is limited to 50 GB of storage. Any credit stays "+
|
||||||
|
"on your account. Are you sure?"
|
||||||
|
)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading_start()
|
||||||
|
try {
|
||||||
|
const plan = $user.subscription.name
|
||||||
|
await put_user({subscription: ""})
|
||||||
|
|
||||||
|
// put_user refreshes the user store, so the button below disappears on
|
||||||
|
// its own once the subscription is gone
|
||||||
|
cancel_message.set(true,
|
||||||
|
"Your "+plan+" subscription has been canceled. Your account is "+
|
||||||
|
"back on the Free plan")
|
||||||
|
} catch (err) {
|
||||||
|
cancel_message.set(false,
|
||||||
|
"Failed to cancel subscription: "+(err?.message ?? err))
|
||||||
|
} finally {
|
||||||
|
loading_finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>Please choose a subscription plan</div>
|
||||||
|
|
||||||
|
<SubscriptionPlans onpick={pick}/>
|
||||||
|
|
||||||
|
<div class="text">
|
||||||
|
Excess usage outside of the subscription plan will be billed at <Euro
|
||||||
|
amount={4e6}/> per TB per month for storage and <Euro amount={1e6}/> per TB
|
||||||
|
for data egress.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if $user !== null && $user.subscription.id !== ""}
|
||||||
|
<hr/>
|
||||||
|
<SuccessMessage bind:this={cancel_message}/>
|
||||||
|
<div class="options">
|
||||||
|
<button onclick={extend}>
|
||||||
|
<i class="icon">more_time</i>
|
||||||
|
Extend current plan (credit deposit)
|
||||||
|
</button>
|
||||||
|
<button onclick={cancel}>
|
||||||
|
<i class="icon">cancel</i>
|
||||||
|
Cancel current plan and return to Free
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.options {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.options > button {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
124
svelte/src/layout/checkout/SubscriptionPlans.svelte
Normal file
124
svelte/src/layout/checkout/SubscriptionPlans.svelte
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Euro from "util/Euro.svelte";
|
||||||
|
import { formatDataVolume } from "util/Formatting";
|
||||||
|
import { get_misc_subscriptions, subscription_periods, type Subscription } from "lib/NovaAPI";
|
||||||
|
import { user } from "lib/UserStore";
|
||||||
|
|
||||||
|
// When onpick is set the period buttons call it instead of linking to the
|
||||||
|
// subscription page. The checkout widget uses this to pick a plan in place
|
||||||
|
let { onpick = undefined }: {
|
||||||
|
onpick?: (plan: Subscription, months: number) => void,
|
||||||
|
} = $props()
|
||||||
|
|
||||||
|
let plans: Subscription[] = $state([])
|
||||||
|
|
||||||
|
// Only the plan the user is on is highlighted. When they have no subscription
|
||||||
|
// there is nothing to compare against, so we highlight all of them
|
||||||
|
let active = $derived($user === null ? "" : $user.subscription.id)
|
||||||
|
|
||||||
|
get_misc_subscriptions().then(v => plans = v).catch(err => {
|
||||||
|
console.error("Failed to load subscription plans", err)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet price(plan: Subscription, period: typeof subscription_periods[0])}
|
||||||
|
<div class="bold">{period.label}</div>
|
||||||
|
<div>
|
||||||
|
<Euro amount={plan.monthly_fee*period.months*period.discount}/>
|
||||||
|
/ {period.months} month{period.months === 1 ? "" : "s"}
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="horizontal_scroll">
|
||||||
|
<div class="feat_table">
|
||||||
|
{#each plans as plan (plan.id)}
|
||||||
|
<div class="plan" class:highlighted={active === "" || active === plan.id}>
|
||||||
|
<div>
|
||||||
|
<span class="title">{plan.name}</span>
|
||||||
|
<br/>
|
||||||
|
<Euro amount={plan.monthly_fee}/> / month
|
||||||
|
</div>
|
||||||
|
<div class="features">
|
||||||
|
<div>
|
||||||
|
<span class="bold">{formatDataVolume(plan.storage_limit, 0)}</span>
|
||||||
|
storage space
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="bold">{formatDataVolume(plan.monthly_transfer_cap, 0)}</span>
|
||||||
|
egress traffic per month
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="order">
|
||||||
|
{#each subscription_periods as period (period.months)}
|
||||||
|
{#if period.months < plan.min_months}
|
||||||
|
<span>{period.name} plan not available</span>
|
||||||
|
{:else if onpick !== undefined}
|
||||||
|
<button class="button" onclick={() => onpick(plan, period.months)}>
|
||||||
|
{@render price(plan, period)}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
<a href="/user/subscription?plan={plan.id}&months={period.months}" class="button">
|
||||||
|
{@render price(plan, period)}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.horizontal_scroll {
|
||||||
|
overflow-x: scroll;
|
||||||
|
overflow-y: hidden;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
/* max-content makes the row as wide as the cards, so the right padding ends
|
||||||
|
up after the last card instead of at the scroll container's edge */
|
||||||
|
.feat_table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
width: max-content;
|
||||||
|
margin: auto;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
.plan {
|
||||||
|
flex: 0 0 18em;
|
||||||
|
display: flex;
|
||||||
|
width: 18em;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--separator);
|
||||||
|
border-radius: 8px;
|
||||||
|
background-color: var(--body_color);
|
||||||
|
}
|
||||||
|
.plan.highlighted {
|
||||||
|
border-color: var(--highlight_color);
|
||||||
|
}
|
||||||
|
.plan > div {
|
||||||
|
flex: 1 1 0;
|
||||||
|
margin: 0.2em;
|
||||||
|
padding: 0.5em;
|
||||||
|
text-align: center;
|
||||||
|
word-wrap: break-word;
|
||||||
|
hyphens: auto;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 1.5em;
|
||||||
|
}
|
||||||
|
.plan > .order {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.plan > .order > .button {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.plan > .order > .button > div {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.bold {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -21,6 +21,8 @@ export type User = {
|
|||||||
filesystem_node_count: number,
|
filesystem_node_count: number,
|
||||||
is_admin: boolean,
|
is_admin: boolean,
|
||||||
balance_micro_eur: number,
|
balance_micro_eur: number,
|
||||||
|
subscription_months: number,
|
||||||
|
subscription_period_end: string,
|
||||||
monthly_transfer_cap: number,
|
monthly_transfer_cap: number,
|
||||||
monthly_transfer_used: number,
|
monthly_transfer_used: number,
|
||||||
file_viewer_branding: Map<string, string>,
|
file_viewer_branding: Map<string, string>,
|
||||||
@@ -29,12 +31,15 @@ export type User = {
|
|||||||
checkout_country: string,
|
checkout_country: string,
|
||||||
checkout_name: string,
|
checkout_name: string,
|
||||||
checkout_provider: string,
|
checkout_provider: string,
|
||||||
|
usage_based_billing: boolean,
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Subscription = {
|
export type Subscription = {
|
||||||
id: string,
|
id: string,
|
||||||
name: string,
|
name: string,
|
||||||
type: string,
|
type: string,
|
||||||
|
monthly_fee: number,
|
||||||
|
min_months: number,
|
||||||
file_size_limit: number,
|
file_size_limit: number,
|
||||||
storage_limit: number,
|
storage_limit: number,
|
||||||
price_per_tb_storage: number,
|
price_per_tb_storage: number,
|
||||||
@@ -156,13 +161,10 @@ export const put_user = async (data: Object) => {
|
|||||||
{ method: "PUT", body: dict_to_form(data) },
|
{ method: "PUT", body: dict_to_form(data) },
|
||||||
))
|
))
|
||||||
|
|
||||||
// Update the user store
|
// Fetch the user again to update the store. The submitted fields don't
|
||||||
user.update((user: User) => {
|
// always match the fields on the user object, and the server can change or
|
||||||
for (let key of Object.keys(data)) {
|
// ignore them, so we can't apply the submitted values locally
|
||||||
user[key] = data[key]
|
await request_user()
|
||||||
}
|
|
||||||
return user
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const logout_user = async (redirect_path: string) => {
|
export const logout_user = async (redirect_path: string) => {
|
||||||
@@ -183,3 +185,49 @@ export type VATRate = {
|
|||||||
export const get_misc_vat_rate = async (country_code: string) => {
|
export const get_misc_vat_rate = async (country_code: string) => {
|
||||||
return await check_response(await fetch(get_endpoint() + "/misc/vat_rate/" + country_code)) as VATRate
|
return await check_response(await fetch(get_endpoint() + "/misc/vat_rate/" + country_code)) as VATRate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The available subscription plans. The discount for a longer subscription
|
||||||
|
// period is applied by the server too, see SubscriptionDiscount in Go
|
||||||
|
export type SubscriptionPeriod = {
|
||||||
|
months: number,
|
||||||
|
discount: number,
|
||||||
|
name: string,
|
||||||
|
label: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const subscription_periods: SubscriptionPeriod[] = [
|
||||||
|
{ months: 1, discount: 1, name: "Monthly", label: "Monthly" },
|
||||||
|
{ months: 6, discount: 0.9, name: "Bi-yearly", label: "Bi-yearly (save 10%)" },
|
||||||
|
{ months: 12, discount: 0.8, name: "Yearly", label: "Yearly (save 20%)" },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Subscriptions are charged per day. This is the average length of a month, the
|
||||||
|
// same value the server divides the monthly fee by
|
||||||
|
export const days_per_month = 365.25 / 12
|
||||||
|
|
||||||
|
export const get_misc_subscriptions = async () => {
|
||||||
|
return await check_response(
|
||||||
|
await fetch(get_endpoint() + "/misc/subscriptions")
|
||||||
|
) as Subscription[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Invoice = {
|
||||||
|
id: string,
|
||||||
|
time: Date,
|
||||||
|
amount: number,
|
||||||
|
vat: number,
|
||||||
|
country: string,
|
||||||
|
payment_gateway: string,
|
||||||
|
payment_method: string,
|
||||||
|
status: "open" | "processing" | "paid" | "expired" | "canceled",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const get_user_invoices = async (): Promise<Invoice[]> => {
|
||||||
|
const invoices = await check_response(
|
||||||
|
await fetch(get_endpoint() + "/user/invoice"),
|
||||||
|
) as Invoice[]
|
||||||
|
|
||||||
|
invoices.forEach(row => row.time = new Date(row.time))
|
||||||
|
invoices.sort((a, b) => b.time.getTime() - a.time.getTime())
|
||||||
|
return invoices
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,16 +25,16 @@ onMount(async () => {
|
|||||||
if (
|
if (
|
||||||
params.get("link_login_user_id") === null &&
|
params.get("link_login_user_id") === null &&
|
||||||
params.get("link_login_id") === null &&
|
params.get("link_login_id") === null &&
|
||||||
user.username !== ""
|
user !== null && user.username !== ""
|
||||||
) {
|
) {
|
||||||
console.debug("User is already logged in, redirecting to user dashboard", user)
|
console.debug("User is already logged in, redirecting to user dashboard", user)
|
||||||
|
|
||||||
let login_redirect = params.get("redirect")
|
let login_redirect = params.get("redirect")
|
||||||
if (typeof login_redirect === "string" && login_redirect.startsWith("/")) {
|
if (typeof login_redirect === "string" && login_redirect.startsWith("/")) {
|
||||||
console.debug("redirecting user to requested path", login_redirect)
|
console.debug("redirecting user to requested path", login_redirect)
|
||||||
window.location.pathname = login_redirect
|
window.location.href = login_redirect
|
||||||
} else {
|
} else {
|
||||||
window.location.pathname = "/user"
|
window.location.href = "/user"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { onMount } from "svelte";
|
|||||||
import CopyButton from "layout/CopyButton.svelte";
|
import CopyButton from "layout/CopyButton.svelte";
|
||||||
import { loading_finish, loading_start } from "lib/Loading";
|
import { loading_finish, loading_start } from "lib/Loading";
|
||||||
|
|
||||||
let app_name = $state("")
|
|
||||||
let api_key = $state("")
|
let api_key = $state("")
|
||||||
const create_key = async () => {
|
const create_key = async () => {
|
||||||
loading_start()
|
loading_start()
|
||||||
@@ -39,11 +38,13 @@ const toggle_show_key = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
let app_name = $derived(() => {
|
||||||
let app = new URL(window.location.href).searchParams.get("app")
|
let app = new URL(window.location.href).searchParams.get("app")
|
||||||
if (app) {
|
if (app) {
|
||||||
app_name = app
|
consoke.log(app)
|
||||||
|
return app
|
||||||
}
|
}
|
||||||
|
return null
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Checkout from "layout/checkout/Checkout.svelte";
|
|
||||||
import { loading_finish, loading_start } from "lib/Loading";
|
|
||||||
import { get_endpoint } from "lib/NovaAPI";
|
|
||||||
import { user } from "lib/UserStore";
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
import Euro from "util/Euro.svelte";
|
|
||||||
import { formatDate } from "util/Formatting";
|
|
||||||
|
|
||||||
let invoices = $state([])
|
|
||||||
let unpaid_invoice = $state(0)
|
|
||||||
const load_invoices = async () => {
|
|
||||||
loading_start()
|
|
||||||
try {
|
|
||||||
const resp = await fetch(get_endpoint()+"/user/invoice")
|
|
||||||
if(resp.status >= 400) {
|
|
||||||
throw new Error((await resp.json()).message)
|
|
||||||
}
|
|
||||||
|
|
||||||
let invoices_tmp = await resp.json()
|
|
||||||
unpaid_invoice = 0
|
|
||||||
invoices_tmp.forEach(row => {
|
|
||||||
row.time = new Date(row.time)
|
|
||||||
|
|
||||||
if (row.status === "open") {
|
|
||||||
unpaid_invoice++
|
|
||||||
}
|
|
||||||
})
|
|
||||||
invoices_tmp.sort((a, b) => {
|
|
||||||
return b.time - a.time
|
|
||||||
})
|
|
||||||
invoices = invoices_tmp
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err)
|
|
||||||
alert(err)
|
|
||||||
} finally {
|
|
||||||
loading_finish()
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
load_invoices()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 id="deposit">Deposit credit</h2>
|
|
||||||
<p>
|
|
||||||
Nova credit can be used for our Prepaid subscription plan, which
|
|
||||||
is different from the Patreon plans. Instead of monthly limits, with
|
|
||||||
Prepaid there are no limits. You pay for what you use, at a rate of €4
|
|
||||||
per TB per month for storage and €1 per TB for data transfer. Your
|
|
||||||
current account balance is <Euro
|
|
||||||
amount={$user === null ? 0 : $user.balance_micro_eur}/>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if unpaid_invoice > 1}
|
|
||||||
<div class="highlight_yellow">
|
|
||||||
<p>
|
|
||||||
You still have an unpaid invoice open. Please pay that one
|
|
||||||
before requesting a new invoice. You can find unpaid
|
|
||||||
invoices at the bottom of this page. You can cancel an
|
|
||||||
invoice by clicking Pay, and then clicking the Back link at
|
|
||||||
the bottom of the page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<Checkout/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<h3 id="invoices">Past invoices</h3>
|
|
||||||
<p>
|
|
||||||
Invoices are deleted after one year of inactivity.
|
|
||||||
</p>
|
|
||||||
<div class="table_scroll">
|
|
||||||
<table style="text-align: left;">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<td>ID</td>
|
|
||||||
<td>Created</td>
|
|
||||||
<td>Amount</td>
|
|
||||||
<td>VAT</td>
|
|
||||||
<td>Country</td>
|
|
||||||
<td>Method</td>
|
|
||||||
<td>Status</td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{#each invoices as row (row.id)}
|
|
||||||
<tr>
|
|
||||||
<td>{row.id}</td>
|
|
||||||
<td>{formatDate(row.time, true, true, false)}</td>
|
|
||||||
<td><Euro amount={row.amount}/></td>
|
|
||||||
<td><Euro amount={row.vat}/></td>
|
|
||||||
<td>{row.country}</td>
|
|
||||||
<td>{row.payment_method}</td>
|
|
||||||
<td>
|
|
||||||
{#if row.status === "open"}
|
|
||||||
Waiting for payment
|
|
||||||
{:else if row.status === "processing"}
|
|
||||||
Payment received, waiting for confirmations
|
|
||||||
{:else if row.status === "paid"}
|
|
||||||
Paid
|
|
||||||
{:else if row.status === "expired"}
|
|
||||||
Expired
|
|
||||||
{:else if row.status === "canceled"}
|
|
||||||
Canceled
|
|
||||||
{:else}
|
|
||||||
{row.status}
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
{#if row.status === "open"}
|
|
||||||
<a href="/api/user/pay_invoice/{row.id}" class="button button_highlight">
|
|
||||||
<i class="icon">paid</i> Pay
|
|
||||||
</a>
|
|
||||||
{/if}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{/each}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import ProgressBar from "util/ProgressBar.svelte";
|
import ProgressBar from "util/ProgressBar.svelte";
|
||||||
import { formatDataVolume } from "util/Formatting"
|
import { formatDataVolume, formatEuros } from "util/Formatting"
|
||||||
import { user } from "lib/UserStore";
|
import { user } from "lib/UserStore";
|
||||||
|
|
||||||
let { total = 0, used = 0 }: {
|
let { total = 0, used = 0 }: {
|
||||||
@@ -9,6 +9,10 @@ let { total = 0, used = 0 }: {
|
|||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let frac = $derived(used / total)
|
let frac = $derived(used / total)
|
||||||
|
let bill_shock = $derived($user !== null && $user.monthly_transfer_cap === total)
|
||||||
|
let billable = $derived($user !== null && $user.subscription.price_per_tb_bandwidth > 0)
|
||||||
|
let billed = $derived(billable && $user.usage_based_billing)
|
||||||
|
let included = $derived($user === null ? 0 : $user.subscription.monthly_transfer_cap)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ProgressBar total={total} used={used}></ProgressBar>
|
<ProgressBar total={total} used={used}></ProgressBar>
|
||||||
@@ -25,48 +29,54 @@ let frac = $derived(used / total)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if frac > 1}
|
{#if billed && included > 0 && used > included}
|
||||||
<div class="highlight_yellow">
|
|
||||||
<p>
|
<p>
|
||||||
You have used all of your data cap. People can still download your
|
Data transfer above the {formatDataVolume(included, 3)} included in your
|
||||||
files, but premium features are disabled. This means that the
|
plan is billed separately, at
|
||||||
download page shows Nova branding, people who download your
|
{formatEuros($user.subscription.price_per_tb_bandwidth, 2)} per TB.
|
||||||
files have a daily download limit and hotlinking is disabled.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{#if $user !== null && $user.monthly_transfer_cap > 0}
|
|
||||||
<p>
|
|
||||||
You have a billshock limit configured. <a
|
|
||||||
href="/user/sharing">increase or disable the limit</a> to
|
|
||||||
continue sharing files.
|
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<a class="button button_highlight" href="/#pro">
|
{#if frac > 1}
|
||||||
|
<div class="highlight_yellow">
|
||||||
|
<p>
|
||||||
|
You have used all of your data transfer, so your files can't be
|
||||||
|
downloaded until your usage over the last 30 days drops below the
|
||||||
|
limit. Logged in users with data transfer left on their own plan
|
||||||
|
can still download them.
|
||||||
|
</p>
|
||||||
|
{#if bill_shock}
|
||||||
|
<p>
|
||||||
|
This is your bill shock limit. <a href="/user/sharing">Increase
|
||||||
|
or disable the limit</a> to continue sharing files.
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p>
|
||||||
|
Upgrade your plan{#if billable} or enable
|
||||||
|
<a href="/user/sharing">usage based billing</a>{/if} to continue
|
||||||
|
sharing files.
|
||||||
|
</p>
|
||||||
|
<a class="button button_highlight" href="/user/subscription/manage">
|
||||||
<i class="icon">bolt</i> Upgrade options
|
<i class="icon">bolt</i> Upgrade options
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else if frac > 0.8}
|
{:else if frac > 0.8}
|
||||||
<div class="highlight_blue">
|
<div class="highlight_blue">
|
||||||
<p>
|
<p>
|
||||||
You have used {(frac*100).toFixed(0)}% of your data cap. If your
|
You have used {(frac*100).toFixed(0)}% of your data transfer. When
|
||||||
data runs out the premium features related to downloading will be
|
it runs out your files can't be downloaded anymore.
|
||||||
disabled. This means that the download page shows Nova
|
|
||||||
branding, people who download your files have a daily download limit
|
|
||||||
and hotlinking is disabled.
|
|
||||||
</p>
|
</p>
|
||||||
|
{#if bill_shock}
|
||||||
{#if $user !== null && $user.monthly_transfer_cap > 0}
|
|
||||||
<p>
|
<p>
|
||||||
You have a billshock limit configured. <a
|
This is your bill shock limit, you can change it in the
|
||||||
href="/user/sharing">increase or disable the limit</a> to
|
<a href="/user/sharing">sharing settings</a>.
|
||||||
continue sharing files.
|
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{:else}
|
||||||
|
<a class="button button_highlight" href="/user/subscription/manage">
|
||||||
<a class="button button_highlight" href="/#pro">
|
|
||||||
<i class="icon">bolt</i> Upgrade options
|
<i class="icon">bolt</i> Upgrade options
|
||||||
</a>
|
</a>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
<script>
|
|
||||||
import { onMount } from "svelte";
|
|
||||||
|
|
||||||
let patreon_result = $state("")
|
|
||||||
let patreon_message = $state("")
|
|
||||||
let patreon_error = $state("")
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
if (window.location.hash.startsWith("#")) {
|
|
||||||
const hash = window.location.hash.replace("#", "");
|
|
||||||
const result = hash.split('&').reduce((res, item) => {
|
|
||||||
const parts = item.split('=')
|
|
||||||
res[parts[0]] = decodeURIComponent(parts[1])
|
|
||||||
return res;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
if (result.patreon_result) {
|
|
||||||
patreon_result = result.patreon_result
|
|
||||||
}
|
|
||||||
if (result.patreon_message) {
|
|
||||||
patreon_message = result.patreon_message
|
|
||||||
}
|
|
||||||
if (result.patreon_error) {
|
|
||||||
patreon_error = result.patreon_error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
{#if patreon_result !== ""}
|
|
||||||
{#if patreon_error === "patreon_authentication_denied"}
|
|
||||||
<div class="highlight_yellow">
|
|
||||||
Please press "Allow" when asked if Nova can access your
|
|
||||||
profile.
|
|
||||||
</div>
|
|
||||||
{:else if patreon_result === "error"}
|
|
||||||
<div class="highlight_red">
|
|
||||||
<p>
|
|
||||||
An error occurred while linking Patreon subscription. Check if
|
|
||||||
there are any Nova integrations under "Logged in with
|
|
||||||
Patreon" on this page: <a
|
|
||||||
href="https://www.patreon.com/settings/apps">https://www.patreon.com/settings/apps</a>.
|
|
||||||
Try disconnecting all Nova logins and try again.
|
|
||||||
</p>
|
|
||||||
<li>
|
|
||||||
This can also happen if you canceled your Patreon membership
|
|
||||||
before upgrading your Nova account. In that case, go to
|
|
||||||
the <a
|
|
||||||
href="https://www.patreon.com/nova/membership">memberships
|
|
||||||
page</a> and activate your membership again. If you already paid
|
|
||||||
in the last 30 days you will not be charged again.
|
|
||||||
</li>
|
|
||||||
<p>
|
|
||||||
It might also help to try linking the subscription in a
|
|
||||||
different web browser. Sometimes cookies or cached redirects can
|
|
||||||
cause issues with the authentication process. Try linking your
|
|
||||||
subscription in <a
|
|
||||||
href="https://www.mozilla.org/firefox/">Firefox</a> for example.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If the information above does not solve your issue then please
|
|
||||||
contact me on <a
|
|
||||||
href="https://www.patreon.com/messages">Patreon</a> or <a
|
|
||||||
href="https://discord.gg/UDjaBGwr4p">Discord</a>. When
|
|
||||||
contacting support please provide the following
|
|
||||||
information:<br/> Server response: {patreon_message}<br/> Server
|
|
||||||
error code: {patreon_error}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else if patreon_result === "pledge_not_found"}
|
|
||||||
<div class="highlight_yellow">
|
|
||||||
<p>
|
|
||||||
We were not able to find your payment on Patreon. There can be a
|
|
||||||
few reasons for this, which I will list below:
|
|
||||||
</p>
|
|
||||||
<ol>
|
|
||||||
<li>
|
|
||||||
Your payment is not confirmed yet. It can take up to 30
|
|
||||||
minutes before your payment is confirmed. If you paid with
|
|
||||||
PayPal it's usually instant, but with creditcard it can take
|
|
||||||
longer. If your payment is not confirmed after 30 minutes
|
|
||||||
please contact me on <a
|
|
||||||
href="https://www.patreon.com/messages">Patreon</a> or <a
|
|
||||||
href="https://discord.gg/UDjaBGwr4p">Discord</a>.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
You pledged without selecting a support tier. It happens
|
|
||||||
sometimes that people pledge an amount of money without
|
|
||||||
selecting a support tier. If you don't have a tier
|
|
||||||
Nova won't know which subscription you should get,
|
|
||||||
regardless of how much you paid. To fix this you can select
|
|
||||||
a tier from <a
|
|
||||||
href="https://www.patreon.com/nova/membership">the
|
|
||||||
memberships page</a> and proceed to checkout. If you already
|
|
||||||
pledged the right amount you will not have to pay again.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
Or you have not pledged at all yet. In that case it's
|
|
||||||
simple: <a
|
|
||||||
href="https://www.patreon.com/nova/membership">go to
|
|
||||||
Patreon</a> and purchase a membership.
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<p>
|
|
||||||
When contacting support please provide the following
|
|
||||||
information:<br/>Server response: {patreon_message}<br/>Server
|
|
||||||
error code: {patreon_error}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else if patreon_result === "success"}
|
|
||||||
<div class="highlight_green">
|
|
||||||
Success! Your Patreon pledge has been linked to your Nova
|
|
||||||
account. You are now able to use Pro features.
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
<br/>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.highlight_red, .highlight_yellow {
|
|
||||||
text-align: initial;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -2,16 +2,15 @@
|
|||||||
import AccountSettings from "./AccountSettings.svelte";
|
import AccountSettings from "./AccountSettings.svelte";
|
||||||
import APIKeys from "./APIKeys.svelte";
|
import APIKeys from "./APIKeys.svelte";
|
||||||
import Transactions from "./Transactions.svelte";
|
import Transactions from "./Transactions.svelte";
|
||||||
import Subscription from "./Subscription.svelte";
|
|
||||||
import ConnectApp from "./ConnectApp.svelte";
|
import ConnectApp from "./ConnectApp.svelte";
|
||||||
import ActivityLog from "./ActivityLog.svelte";
|
import ActivityLog from "./ActivityLog.svelte";
|
||||||
import DepositCredit from "./DepositCredit.svelte";
|
|
||||||
import TabMenu, { type Tab } from "util/TabMenu.svelte";
|
import TabMenu, { type Tab } from "util/TabMenu.svelte";
|
||||||
import Dashboard from "./dashboard/Dashboard.svelte";
|
import Dashboard from "./dashboard/Dashboard.svelte";
|
||||||
import AffiliatePrompt from "./AffiliatePrompt.svelte";
|
import AffiliatePrompt from "./AffiliatePrompt.svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { get_user, type User } from "lib/NovaAPI";
|
import { get_user, type User } from "lib/NovaAPI";
|
||||||
import SharingSettings from "./SharingSettings.svelte";
|
import SharingSettings from "./SharingSettings.svelte";
|
||||||
|
import SubscriptionManage from "./SubscriptionManage.svelte";
|
||||||
|
|
||||||
let pages: Tab[] = [
|
let pages: Tab[] = [
|
||||||
{
|
{
|
||||||
@@ -31,17 +30,12 @@ let pages: Tab[] = [
|
|||||||
icon: "shopping_cart",
|
icon: "shopping_cart",
|
||||||
subpages: [
|
subpages: [
|
||||||
{
|
{
|
||||||
path: "/user/subscription",
|
path: "/user/subscription/manage",
|
||||||
title: "Manage",
|
title: "Manage",
|
||||||
icon: "shopping_cart",
|
icon: "shopping_cart",
|
||||||
component: Subscription,
|
component: SubscriptionManage,
|
||||||
}, {
|
}, {
|
||||||
path: "/user/prepaid/deposit",
|
path: "/user/subscription/transactions",
|
||||||
title: "Deposit Credit",
|
|
||||||
icon: "account_balance_wallet",
|
|
||||||
component: DepositCredit,
|
|
||||||
}, {
|
|
||||||
path: "/user/prepaid/transactions",
|
|
||||||
title: "Transactions",
|
title: "Transactions",
|
||||||
icon: "receipt",
|
icon: "receipt",
|
||||||
component: Transactions,
|
component: Transactions,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { formatDataVolume } from "util/Formatting";
|
import { formatDataVolume, formatEuros } from "util/Formatting";
|
||||||
import ProgressBar from "util/ProgressBar.svelte";
|
import ProgressBar from "util/ProgressBar.svelte";
|
||||||
import { loading_run } from "lib/Loading";
|
import { loading_run } from "lib/Loading";
|
||||||
import { get_endpoint, put_user } from 'lib/NovaAPI';
|
import { get_endpoint, put_user } from 'lib/NovaAPI';
|
||||||
@@ -28,6 +28,19 @@ const update_user = async (e: SubmitEvent) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const set_usage_based_billing = (e: Event) => {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
loading_run(async () => {
|
||||||
|
try {
|
||||||
|
await put_user({usage_based_billing: input.checked})
|
||||||
|
success_message.set(true, "Usage based billing updated")
|
||||||
|
} catch (err) {
|
||||||
|
input.checked = !input.checked
|
||||||
|
success_message.set(false, "Failed to update user: "+err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
let transfer_used = $state(0)
|
let transfer_used = $state(0)
|
||||||
let load_transfer_used = () => {
|
let load_transfer_used = () => {
|
||||||
let today = new Date()
|
let today = new Date()
|
||||||
@@ -75,6 +88,23 @@ onMount(async () => {
|
|||||||
<section>
|
<section>
|
||||||
<SuccessMessage bind:this={success_message}/>
|
<SuccessMessage bind:this={success_message}/>
|
||||||
|
|
||||||
|
{#if $user !== null && $user.subscription.type === "subscription"}
|
||||||
|
<h2>Usage based billing</h2>
|
||||||
|
<p>
|
||||||
|
When your storage or data transfer goes over the limits of your
|
||||||
|
plan, uploads and downloads are blocked. With usage based billing
|
||||||
|
they keep working and the usage above the limits is billed at
|
||||||
|
{formatEuros($user.subscription.price_per_tb_storage, 2)} per TB per
|
||||||
|
month for storage and
|
||||||
|
{formatEuros($user.subscription.price_per_tb_bandwidth, 2)} per TB
|
||||||
|
for data transfer.
|
||||||
|
</p>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" checked={$user.usage_based_billing} onchange={set_usage_based_billing}/>
|
||||||
|
Enable usage based billing
|
||||||
|
</label>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<h2>Bill shock limit</h2>
|
<h2>Bill shock limit</h2>
|
||||||
<p>
|
<p>
|
||||||
Billshock limit in terabytes per month (1 TB = 1000 GB). Set to 0 to
|
Billshock limit in terabytes per month (1 TB = 1000 GB). Set to 0 to
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { formatDataVolume } from "util/Formatting"
|
import { formatDataVolume, formatEuros } from "util/Formatting"
|
||||||
import ProgressBar from "util/ProgressBar.svelte";
|
import ProgressBar from "util/ProgressBar.svelte";
|
||||||
|
import { user } from "lib/UserStore";
|
||||||
|
|
||||||
let { total = 0, used = 0, disable_warnings = false }: {
|
let { total = 0, used = 0 }: {
|
||||||
total?: number;
|
total?: number;
|
||||||
used?: number;
|
used?: number;
|
||||||
disable_warnings?: boolean;
|
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let frac = $derived(total <= 0 ? 0 : used / total)
|
let frac = $derived(total <= 0 ? 0 : used / total)
|
||||||
|
let billable = $derived($user !== null && $user.subscription.price_per_tb_storage > 0)
|
||||||
|
let billed = $derived(billable && $user.usage_based_billing)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ProgressBar total={total} used={used}></ProgressBar>
|
<ProgressBar total={total} used={used}></ProgressBar>
|
||||||
@@ -25,58 +27,40 @@ let frac = $derived(total <= 0 ? 0 : used / total)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if !disable_warnings}
|
{#if billed}
|
||||||
{#if frac > 2.0}
|
{#if frac > 1}
|
||||||
<div class="highlight_red">
|
|
||||||
<span class="warn_text">
|
|
||||||
You are using more than 200% of your allowed storage space!
|
|
||||||
</span>
|
|
||||||
<p>
|
<p>
|
||||||
We have started deleting your files to free up space. If you do not
|
Storage above the limit of your plan is billed separately, at
|
||||||
want to lose any more files please upgrade to a subscription which
|
{formatEuros($user.subscription.price_per_tb_storage, 2)} per TB per
|
||||||
supports the volume of storage which you need.
|
month.
|
||||||
</p>
|
</p>
|
||||||
<a class="button button_highlight" href="/#pro">
|
{/if}
|
||||||
<i class="icon">bolt</i> Upgrade options
|
{:else if frac > 1}
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{:else if frac > 1.0}
|
|
||||||
<div class="highlight_red">
|
|
||||||
<p>
|
|
||||||
You have used all of your storage space. You won't be able to
|
|
||||||
upload new files anymore. Please upgrade to a higher support
|
|
||||||
tier to continue uploading files.
|
|
||||||
</p>
|
|
||||||
<a class="button button_highlight" href="/#pro">
|
|
||||||
<i class="icon">bolt</i> Upgrade options
|
|
||||||
</a>
|
|
||||||
<p>
|
|
||||||
Your files will not be deleted any sooner than normal at this
|
|
||||||
moment. When your storage usage is over 200% we will start
|
|
||||||
deleting your files to free up the space.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else if frac > 0.8}
|
|
||||||
<div class="highlight_yellow">
|
<div class="highlight_yellow">
|
||||||
<p>
|
<p>
|
||||||
You have used {(frac*100).toFixed(0)}% of your
|
You are storing more than your plan allows, so you can't upload new
|
||||||
storage space. If your storage space runs out you won't be able
|
files. Your existing files stay available. Delete some files or
|
||||||
to upload new files anymore. Please upgrade to a higher support
|
upgrade your plan to continue uploading{#if billable}, or enable
|
||||||
tier to continue uploading files.
|
<a href="/user/sharing">usage based billing</a> to pay for the
|
||||||
|
storage above the limit{/if}.
|
||||||
</p>
|
</p>
|
||||||
<a class="button button_highlight" href="/#pro">
|
<a class="button button_highlight" href="/user/subscription/manage">
|
||||||
|
<i class="icon">bolt</i> Upgrade options
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{:else if frac > 0.8}
|
||||||
|
<div class="highlight_blue">
|
||||||
|
<p>
|
||||||
|
You have used {(frac*100).toFixed(0)}% of your storage space. When
|
||||||
|
it runs out you won't be able to upload new files anymore.
|
||||||
|
</p>
|
||||||
|
<a class="button button_highlight" href="/user/subscription/manage">
|
||||||
<i class="icon">bolt</i> Upgrade options
|
<i class="icon">bolt</i> Upgrade options
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.warn_text {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gauge_labels {
|
.gauge_labels {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
|
|||||||
@@ -1,189 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import Euro from "util/Euro.svelte"
|
|
||||||
import SuccessMessage from "util/SuccessMessage.svelte";
|
|
||||||
import PatreonActivationResult from "./PatreonActivationResult.svelte";
|
|
||||||
import { loading_finish, loading_start } from "lib/Loading";
|
|
||||||
import { user } from "lib/UserStore";
|
|
||||||
import { put_user } from "lib/NovaAPI";
|
|
||||||
|
|
||||||
let subscription: string = $state($user === null ? "" : $user.subscription.id)
|
|
||||||
let success_message: SuccessMessage = $state()
|
|
||||||
|
|
||||||
const update = async (plan: string) => {
|
|
||||||
const form = new FormData()
|
|
||||||
form.append("subscription", plan)
|
|
||||||
|
|
||||||
loading_start()
|
|
||||||
try {
|
|
||||||
await put_user({subscription: plan})
|
|
||||||
success_message.set(true, "Subscription updated")
|
|
||||||
} catch (err) {
|
|
||||||
success_message.set(false, "Failed to update subscription: "+err)
|
|
||||||
} finally {
|
|
||||||
loading_finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
{#if window.location.hash === "#checkout_complete"}
|
|
||||||
<div class="highlight_green">
|
|
||||||
<h2>Payment successful!</h2>
|
|
||||||
<p>
|
|
||||||
Thank you for supporting Nova! The credit has been added
|
|
||||||
to your account balance.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
If your account credit has not been updated, please check the
|
|
||||||
status of your invoice on <a
|
|
||||||
href="/user/prepaid/deposit#invoices">the invoices page</a>.
|
|
||||||
Depending on the payment processor you used it can take a while
|
|
||||||
before your credit is deposited. SEPA transfers can take up to
|
|
||||||
two working days for example. When the deposit is complete you
|
|
||||||
will receive an e-mail. If it takes too long, contact
|
|
||||||
support@nova.storage.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else if window.location.hash === "#order_expired"}
|
|
||||||
<div class="highlight_yellow">
|
|
||||||
<h2>Order expired</h2>
|
|
||||||
<p>
|
|
||||||
This order has expired. Please create a new order on the <a
|
|
||||||
href="/user/prepaid/deposit">credit deposit page</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{:else if window.location.hash === "#order_canceled"}
|
|
||||||
<div class="highlight_yellow">
|
|
||||||
<h2>Order canceled</h2>
|
|
||||||
<p>
|
|
||||||
You have chosen to cancel the order. If you still want to
|
|
||||||
proceed with the order you can initiate payment again from the
|
|
||||||
<a href="/user/prepaid/deposit">deposit page</a>. If not then
|
|
||||||
you can let the invoice expire.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<PatreonActivationResult/>
|
|
||||||
|
|
||||||
<h2>Manage subscription</h2>
|
|
||||||
<p>
|
|
||||||
Here you can switch between different subscription plans.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
The Prepaid subscription is charged daily based on usage. When you reach
|
|
||||||
negative balance the subscription will automatically end. You need at
|
|
||||||
least <Euro amount={1e6}/> account credit to activate prepaid. Your
|
|
||||||
current credit amount is <Euro
|
|
||||||
amount={$user.balance_micro_eur}/>. You can deposit credit on the
|
|
||||||
<a href="/user/prepaid/deposit">credit deposit page</a>.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<h3>Available subscription plans</h3>
|
|
||||||
<SuccessMessage bind:this={success_message}/>
|
|
||||||
|
|
||||||
<div class="feat_table">
|
|
||||||
<div>
|
|
||||||
<div class="feat_label" class:feat_highlight={subscription === "prepaid"}>
|
|
||||||
Prepaid<br/>
|
|
||||||
{#if subscription === "prepaid"}
|
|
||||||
Currently active
|
|
||||||
{:else}
|
|
||||||
<button onclick={() => update("prepaid")}>
|
|
||||||
<i class="icon">attach_money</i>
|
|
||||||
Activate
|
|
||||||
</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="feat_normal round_tr" class:feat_highlight={subscription === "prepaid"}>
|
|
||||||
<p>
|
|
||||||
You will need a positive account balance to activate this
|
|
||||||
plan. The plan deactivates when your credit runs out.
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
€1 / month minimum price (only charged when usage is
|
|
||||||
below €1)
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
€4 per TB per month for storage
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
€1 per TB for data transfer (with <a
|
|
||||||
href="/user/sharing">hotlinking</a>
|
|
||||||
enabled)
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
File <a href="/user/sharing">embedding control</a>
|
|
||||||
options
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<div class="feat_label" class:feat_highlight={subscription === ""}>
|
|
||||||
Free<br/>
|
|
||||||
{#if subscription === ""}
|
|
||||||
Currently active
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
<div class="feat_normal round_br" class:feat_highlight={subscription === ""}>
|
|
||||||
<ul>
|
|
||||||
<li>Storage limit: 50 GB</li>
|
|
||||||
<li>Download limit: 50 GB per 30 days (sliding window)</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.feat_table {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.feat_table > div {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
.feat_table > div > div:first-child {
|
|
||||||
flex: 0 0 25%;
|
|
||||||
border-radius: 8px 0 0 8px;
|
|
||||||
}
|
|
||||||
.feat_table > div > div {
|
|
||||||
flex: 1 1 0;
|
|
||||||
margin: 2px;
|
|
||||||
padding: 0.5em;
|
|
||||||
word-wrap: break-word;
|
|
||||||
hyphens: auto;
|
|
||||||
border-radius: 0 8px 8px 0;
|
|
||||||
border: 2px solid var(--separator)
|
|
||||||
}
|
|
||||||
.feat_table > div > .feat_label {
|
|
||||||
border-top-left-radius: 0.5em;
|
|
||||||
border-bottom-left-radius: 0.5em;
|
|
||||||
border: 2px solid var(--separator);
|
|
||||||
font-size: 1.1em;
|
|
||||||
}
|
|
||||||
.feat_table > div > .feat_normal {
|
|
||||||
background: var(--card_color);
|
|
||||||
}
|
|
||||||
.feat_table > div > .feat_highlight {
|
|
||||||
border: 2px solid var(--highlight_color)
|
|
||||||
}
|
|
||||||
|
|
||||||
/* On small screens we stack the table vertically */
|
|
||||||
@media(max-width: 800px) {
|
|
||||||
.feat_table > div {
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
.feat_table > div > div:first-child {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
border-radius: 8px 8px 0 0;
|
|
||||||
}
|
|
||||||
.feat_table > div > div {
|
|
||||||
border-radius: 0 0 8px 8px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
144
svelte/src/user_home/SubscriptionManage.svelte
Normal file
144
svelte/src/user_home/SubscriptionManage.svelte
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Checkout from "layout/checkout/Checkout.svelte";
|
||||||
|
import { loading_run } from "lib/Loading";
|
||||||
|
import { get_user_invoices, type Invoice } from "lib/NovaAPI";
|
||||||
|
import { user } from "lib/UserStore";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import Euro from "util/Euro.svelte";
|
||||||
|
import { formatDate } from "util/Formatting";
|
||||||
|
|
||||||
|
let invoices: Invoice[] = $state([])
|
||||||
|
let unpaid_invoice = $derived(
|
||||||
|
invoices.filter(row => row.status === "open").length
|
||||||
|
)
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await loading_run(async () => {
|
||||||
|
try {
|
||||||
|
invoices = await get_user_invoices()
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
alert(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
{#if window.location.hash === "#checkout_complete"}
|
||||||
|
<div class="highlight_green">
|
||||||
|
<h2>Payment successful!</h2>
|
||||||
|
<p>
|
||||||
|
Thank you for supporting Nova! The credit has been added
|
||||||
|
to your account balance.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If your account credit has not been updated, please check the
|
||||||
|
status of your invoice on <a
|
||||||
|
href="/user/subscription/manage#invoices">the invoices page</a>.
|
||||||
|
Depending on the payment processor you used it can take a while
|
||||||
|
before your credit is deposited. SEPA transfers can take up to
|
||||||
|
two working days for example. When the deposit is complete you
|
||||||
|
will receive an e-mail. If it takes too long, contact
|
||||||
|
support@nova.storage.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else if window.location.hash === "#order_expired"}
|
||||||
|
<div class="highlight_yellow">
|
||||||
|
<h2>Order expired</h2>
|
||||||
|
<p>
|
||||||
|
This order has expired. Please create a new order below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else if window.location.hash === "#order_canceled"}
|
||||||
|
<div class="highlight_yellow">
|
||||||
|
<h2>Order canceled</h2>
|
||||||
|
<p>
|
||||||
|
You have chosen to cancel the order. If you still want to
|
||||||
|
proceed with the order you can initiate payment again below. If
|
||||||
|
not then you can let the invoice expire.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h2 id="deposit">Manage subscription</h2>
|
||||||
|
<p>
|
||||||
|
Subscriptions are charged from your account credit every day. Storage and
|
||||||
|
traffic above the amounts included in your plan are charged at €4 per TB
|
||||||
|
per month for storage and €1 per TB for data transfer. Your current
|
||||||
|
account balance is <Euro
|
||||||
|
amount={$user === null ? 0 : $user.balance_micro_eur}/>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{#if unpaid_invoice > 1}
|
||||||
|
<div class="highlight_yellow">
|
||||||
|
<p>
|
||||||
|
You still have an unpaid invoice open. Please pay that one
|
||||||
|
before requesting a new invoice. You can find unpaid
|
||||||
|
invoices at the bottom of this page. You can cancel an
|
||||||
|
invoice by clicking Pay, and then clicking the Back link at
|
||||||
|
the bottom of the page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Checkout/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<h3 id="invoices">Past invoices</h3>
|
||||||
|
<p>
|
||||||
|
Invoices are deleted after one year of inactivity.
|
||||||
|
</p>
|
||||||
|
<div class="table_scroll">
|
||||||
|
<table style="text-align: left;">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<td>ID</td>
|
||||||
|
<td>Created</td>
|
||||||
|
<td>Amount</td>
|
||||||
|
<td>VAT</td>
|
||||||
|
<td>Country</td>
|
||||||
|
<td>Method</td>
|
||||||
|
<td>Status</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each invoices as row (row.id)}
|
||||||
|
<tr>
|
||||||
|
<td>{row.id}</td>
|
||||||
|
<td>{formatDate(row.time, true, true, false)}</td>
|
||||||
|
<td><Euro amount={row.amount}/></td>
|
||||||
|
<td><Euro amount={row.vat}/></td>
|
||||||
|
<td>{row.country}</td>
|
||||||
|
<td>{row.payment_method}</td>
|
||||||
|
<td>
|
||||||
|
{#if row.status === "open"}
|
||||||
|
Waiting for payment
|
||||||
|
{:else if row.status === "processing"}
|
||||||
|
Payment received, waiting for confirmations
|
||||||
|
{:else if row.status === "paid"}
|
||||||
|
Paid
|
||||||
|
{:else if row.status === "expired"}
|
||||||
|
Expired
|
||||||
|
{:else if row.status === "canceled"}
|
||||||
|
Canceled
|
||||||
|
{:else}
|
||||||
|
{row.status}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if row.status === "open"}
|
||||||
|
<a href="/api/user/invoice/{row.id}/pay" class="button button_highlight">
|
||||||
|
<i class="icon">paid</i> Pay
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
<a href="/api/user/invoice/{row.id}/pdf" target="_blank" class="button">
|
||||||
|
<i class="icon">description</i> PDF
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -9,9 +9,7 @@ import { formatDataVolume } from "util/Formatting";
|
|||||||
<li>
|
<li>
|
||||||
Supporter level: {$user.subscription.name}<br/>
|
Supporter level: {$user.subscription.name}<br/>
|
||||||
<i class="icon">shopping_cart</i>
|
<i class="icon">shopping_cart</i>
|
||||||
<a href="/user/subscription">Manage subscriptions</a><br/>
|
<a href="/user/subscription">Manage subscriptions</a>
|
||||||
<i class="icon">add_link</i>
|
|
||||||
<a href="/api/patreon_auth/start">Link Patreon subscription</a>
|
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
{#if $user.balance_micro_eur !== 0}
|
{#if $user.balance_micro_eur !== 0}
|
||||||
@@ -19,10 +17,10 @@ import { formatDataVolume } from "util/Formatting";
|
|||||||
Current account balance: <Euro amount={$user.balance_micro_eur}></Euro><br/>
|
Current account balance: <Euro amount={$user.balance_micro_eur}></Euro><br/>
|
||||||
|
|
||||||
<i class="icon">account_balance_wallet</i>
|
<i class="icon">account_balance_wallet</i>
|
||||||
<a href="/user/prepaid/deposit">Deposit credit</a><br/>
|
<a href="/user/subscription/manage">Deposit credit</a><br/>
|
||||||
|
|
||||||
<i class="icon">receipt</i>
|
<i class="icon">receipt</i>
|
||||||
<a href="/user/prepaid/transactions">Transaction log</a>
|
<a href="/user/subscription/transactions">Transaction log</a>
|
||||||
|
|
||||||
{#if $user.balance_micro_eur > 0 && $user.subscription.id === ""}
|
{#if $user.balance_micro_eur > 0 && $user.subscription.id === ""}
|
||||||
<br/>
|
<br/>
|
||||||
@@ -36,19 +34,31 @@ import { formatDataVolume } from "util/Formatting";
|
|||||||
Max file size: {formatDataVolume($user.subscription.file_size_limit, 3)}
|
Max file size: {formatDataVolume($user.subscription.file_size_limit, 3)}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{#if $user.subscription.storage_limit > 0}
|
{#if $user.subscription.storage_limit <= 0}
|
||||||
Storage limit: {formatDataVolume($user.subscription.storage_limit, 3)}
|
|
||||||
{:else}
|
|
||||||
No storage limit
|
No storage limit
|
||||||
|
{:else if $user.subscription.price_per_tb_storage > 0}
|
||||||
|
Storage included: {formatDataVolume($user.subscription.storage_limit, 3)}
|
||||||
|
({formatDataVolume($user.storage_space_used, 3)} used)
|
||||||
|
{:else}
|
||||||
|
Storage limit: {formatDataVolume($user.subscription.storage_limit, 3)}
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
{#if $user.subscription.monthly_transfer_cap > 0}
|
{#if $user.subscription.monthly_transfer_cap <= 0}
|
||||||
Data transfer limit: {formatDataVolume($user.subscription.monthly_transfer_cap, 3)}
|
|
||||||
{:else}
|
|
||||||
No data transfer limit
|
No data transfer limit
|
||||||
|
{:else if $user.subscription.price_per_tb_bandwidth > 0}
|
||||||
|
Data transfer included: {formatDataVolume($user.subscription.monthly_transfer_cap, 3)}
|
||||||
|
per month ({formatDataVolume($user.monthly_transfer_used, 3)} used)
|
||||||
|
{:else}
|
||||||
|
Data transfer limit: {formatDataVolume($user.subscription.monthly_transfer_cap, 3)}
|
||||||
{/if}
|
{/if}
|
||||||
</li>
|
</li>
|
||||||
|
{#if $user.subscription_period_end}
|
||||||
|
<li>
|
||||||
|
Subscription period: {$user.subscription_months} month{$user.subscription_months === 1 ? "" : "s"},
|
||||||
|
renews on {new Date($user.subscription_period_end).toLocaleDateString()}
|
||||||
|
</li>
|
||||||
|
{/if}
|
||||||
<li>
|
<li>
|
||||||
{#if $user.subscription.file_sharing === true}
|
{#if $user.subscription.file_sharing === true}
|
||||||
File sharing enabled
|
File sharing enabled
|
||||||
|
|||||||
@@ -34,13 +34,14 @@ let user: User = $state()
|
|||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
user = await get_user()
|
user = await get_user()
|
||||||
|
|
||||||
if (user.monthly_transfer_cap > 0) {
|
// The lowest limit which blocks downloads. With usage based billing the
|
||||||
transfer_cap = user.monthly_transfer_cap
|
// plan's limit is billed instead, only the bill shock limit blocks
|
||||||
} else if (user.subscription.monthly_transfer_cap > 0) {
|
let caps = [user.monthly_transfer_cap]
|
||||||
transfer_cap = user.subscription.monthly_transfer_cap
|
if (!user.usage_based_billing || user.subscription.price_per_tb_bandwidth <= 0) {
|
||||||
} else {
|
caps.push(user.subscription.monthly_transfer_cap)
|
||||||
transfer_cap = -1
|
|
||||||
}
|
}
|
||||||
|
caps = caps.filter(cap => cap > 0)
|
||||||
|
transfer_cap = caps.length > 0 ? Math.min(...caps) : -1
|
||||||
|
|
||||||
storage_limit = user.subscription.storage_limit
|
storage_limit = user.subscription.storage_limit
|
||||||
|
|
||||||
|
|||||||
@@ -17,19 +17,19 @@ let {
|
|||||||
style?: string;
|
style?: string;
|
||||||
} = $props();
|
} = $props();
|
||||||
|
|
||||||
let percent = $state(0)
|
let percent = $derived.by(() => {
|
||||||
|
// Don't allow more than 100% progress
|
||||||
|
if (used / total > 1) {
|
||||||
|
return 100
|
||||||
|
} else {
|
||||||
|
return (used / total) * 100
|
||||||
|
}
|
||||||
|
})
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
// Avoid division by 0
|
// Avoid division by 0
|
||||||
if (total === 0) {
|
if (total === 0) {
|
||||||
total = 1
|
total = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Don't allow more than 100% progress
|
|
||||||
if (used / total > 1) {
|
|
||||||
percent = 100
|
|
||||||
} else {
|
|
||||||
percent = (used / total) * 100
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ $effect(() => {
|
|||||||
<div
|
<div
|
||||||
class="progress_bar_inner"
|
class="progress_bar_inner"
|
||||||
class:no_animation
|
class:no_animation
|
||||||
|
class:red={percent > 90}
|
||||||
style="width: {percent}%; transition-timing-function: {animation}; transition-duration: {speed}ms;">
|
style="width: {percent}%; transition-timing-function: {animation}; transition-duration: {speed}ms;">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -58,6 +59,9 @@ $effect(() => {
|
|||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
transition-property: width;
|
transition-property: width;
|
||||||
}
|
}
|
||||||
|
.red {
|
||||||
|
background: var(--danger_color);
|
||||||
|
}
|
||||||
.no_animation {
|
.no_animation {
|
||||||
transition-property: none;
|
transition-property: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import Tree from "./Tree.svelte";
|
|||||||
import MenuEntry from "./MenuEntry.svelte";
|
import MenuEntry from "./MenuEntry.svelte";
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
import { breadcrumbs_store } from "./BreadcrumbStore";
|
import { breadcrumbs_store } from "./BreadcrumbStore";
|
||||||
|
import ProgressBar from "util/ProgressBar.svelte";
|
||||||
|
|
||||||
// The menu swipe will be detected if it was less than this much pixels from the
|
// The menu swipe will be detected if it was less than this much pixels from the
|
||||||
// screen edge
|
// screen edge
|
||||||
@@ -176,18 +177,37 @@ const set_offset = (off: number) => {
|
|||||||
|
|
||||||
{#snippet body()}
|
{#snippet body()}
|
||||||
<div class="stats_table">
|
<div class="stats_table">
|
||||||
<div>Subscription</div>
|
<div>Sub</div>
|
||||||
<div>{$user.subscription.name}</div>
|
<div>
|
||||||
|
{$user.subscription.name}
|
||||||
|
{#if $user.subscription_months === 1}
|
||||||
|
(monthly)
|
||||||
|
{:else if $user.subscription_months === 6}
|
||||||
|
(bi-yearly)
|
||||||
|
{:else if $user.subscription_months === 12}
|
||||||
|
(yearly)
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
{#if $user.subscription.type === "prepaid"}
|
|
||||||
<div>Credit</div>
|
<div>Credit</div>
|
||||||
<div><Euro amount={$user.balance_micro_eur}/></div>
|
<div><Euro amount={$user.balance_micro_eur}/></div>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div>Storage used</div>
|
<div>Storage</div>
|
||||||
<div>{formatDataVolume($user.filesystem_storage_used, 3)}</div>
|
<div>
|
||||||
<div>Transfer used</div>
|
{formatDataVolume($user.filesystem_storage_used, 3)} /
|
||||||
<div>{formatDataVolume($user.monthly_transfer_used, 3)}</div>
|
{formatDataVolume($user.subscription.storage_limit, 3)}
|
||||||
|
</div>
|
||||||
|
<div style="grid-column: span 2">
|
||||||
|
<ProgressBar used={$user.storage_space_used} total={$user.subscription.storage_limit}/>
|
||||||
|
</div>
|
||||||
|
<div>Transfer</div>
|
||||||
|
<div>
|
||||||
|
{formatDataVolume($user.monthly_transfer_used, 3)} /
|
||||||
|
{formatDataVolume($user.subscription.monthly_transfer_cap, 3)}
|
||||||
|
</div>
|
||||||
|
<div style="grid-column: span 2">
|
||||||
|
<ProgressBar used={$user.monthly_transfer_used} total={$user.subscription.monthly_transfer_cap}/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</MenuEntry>
|
</MenuEntry>
|
||||||
@@ -305,7 +325,7 @@ const set_offset = (off: number) => {
|
|||||||
.nav {
|
.nav {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
width: 15em;
|
width: 16em;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
backdrop-filter: blur(3px);
|
backdrop-filter: blur(3px);
|
||||||
|
|||||||
@@ -57,42 +57,39 @@ let pages: Tab[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
onMount(async () => {
|
onMount(async () => {
|
||||||
await load_page(window.location.pathname, false)
|
await load_page(window.location.href, false)
|
||||||
})
|
})
|
||||||
|
|
||||||
let current_page: Tab = $state(null)
|
let current_page: Tab = $state(null)
|
||||||
|
|
||||||
const load_page = async (pathname: string, history: boolean): Promise<boolean> => {
|
// find_page resolves a URL path to the page which renders it. It returns null
|
||||||
|
// when no page matches, in which case the browser has to handle the navigation
|
||||||
|
const find_page = (pathname: string): Tab => {
|
||||||
|
const path = decodeURIComponent(pathname)
|
||||||
|
let by_prefix: Tab = null
|
||||||
|
for (const page of pages) {
|
||||||
|
if (path === page.path) {
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
if (page.prefix !== undefined && path.startsWith(page.prefix)) {
|
||||||
|
by_prefix = page
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return by_prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// href can be a full URL or a path, and may contain a query string and hash.
|
||||||
|
// Those are not used for routing, but they do need to survive the navigation
|
||||||
|
const load_page = async (href: string, history: boolean): Promise<boolean> => {
|
||||||
|
const url = new URL(href, window.location.href)
|
||||||
console.debug(
|
console.debug(
|
||||||
"Page router: Navigating to page", pathname,
|
"Page router: Navigating to page", url.pathname,
|
||||||
"current path", window.location.pathname,
|
"current path", window.location.pathname,
|
||||||
"log history:", history,
|
"log history:", history,
|
||||||
)
|
)
|
||||||
|
|
||||||
let user: User
|
const page = find_page(url.pathname)
|
||||||
try {
|
if (page === null) {
|
||||||
user = await get_user()
|
|
||||||
} catch (err) {
|
|
||||||
alert("Failed to get user details: " + err)
|
|
||||||
}
|
|
||||||
|
|
||||||
const path_decoded = decodeURIComponent(pathname)
|
|
||||||
let page_by_path: Tab = null
|
|
||||||
let page_by_prefix: Tab = null
|
|
||||||
for (const page of pages) {
|
|
||||||
if (path_decoded === page.path) {
|
|
||||||
page_by_path = page
|
|
||||||
}
|
|
||||||
if (page.prefix !== undefined && path_decoded.startsWith(page.prefix)) {
|
|
||||||
page_by_prefix = page
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (page_by_path !== null) {
|
|
||||||
current_page = page_by_path
|
|
||||||
} else if (page_by_prefix !== null) {
|
|
||||||
current_page = page_by_prefix
|
|
||||||
} else {
|
|
||||||
current_page = {
|
current_page = {
|
||||||
path: "",
|
path: "",
|
||||||
title: "Not Found",
|
title: "Not Found",
|
||||||
@@ -101,19 +98,37 @@ const load_page = async (pathname: string, history: boolean): Promise<boolean> =
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let user: User
|
||||||
|
try {
|
||||||
|
user = await get_user()
|
||||||
|
} catch (err) {
|
||||||
|
alert("Failed to get user details: " + err)
|
||||||
|
}
|
||||||
|
|
||||||
// If this page requires login, and the user is not logged in, then we
|
// If this page requires login, and the user is not logged in, then we
|
||||||
// redirect the user to the login page
|
// redirect the user to the login page. The page is deliberately not mounted
|
||||||
if (current_page.login === true && (user === null || user === undefined)) {
|
// first, it would fire authenticated requests which are all bound to fail
|
||||||
|
if (page.login === true && (user === null || user === undefined)) {
|
||||||
console.debug("User is not logged in, redirecting to login page", user)
|
console.debug("User is not logged in, redirecting to login page", user)
|
||||||
return load_page("/login", true)
|
return load_page(
|
||||||
|
"/login?redirect=" + encodeURIComponent(url.pathname + url.search),
|
||||||
|
true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
window.document.title = current_page.title+" / Nova"
|
window.document.title = page.title+" / Nova"
|
||||||
|
|
||||||
|
// The address bar has to be updated before the page is mounted. Pages read
|
||||||
|
// their parameters from it when they initialize, the login page picks up
|
||||||
|
// the redirect target this way
|
||||||
if (history) {
|
if (history) {
|
||||||
window.history.pushState({}, window.document.title, pathname)
|
window.history.pushState(
|
||||||
|
{}, window.document.title, url.pathname + url.search + url.hash,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
current_page = page
|
||||||
|
|
||||||
if (current_page.custom_breadcrumbs !== true) {
|
if (current_page.custom_breadcrumbs !== true) {
|
||||||
breadcrumbs_store.set(breadcrumbs)
|
breadcrumbs_store.set(breadcrumbs)
|
||||||
}
|
}
|
||||||
@@ -129,29 +144,53 @@ const load_page = async (pathname: string, history: boolean): Promise<boolean> =
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
const click = async (e: MouseEvent) => {
|
const click = (e: MouseEvent) => {
|
||||||
const origin = (e.target as Element).closest("a");
|
// Middle clicks, right clicks and modified clicks open the link in a new
|
||||||
|
// tab or window. That's the browser's job, not ours
|
||||||
|
if (e.button !== 0 || e.ctrlKey || e.metaKey || e.shiftKey || e.altKey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = (e.target as Element).closest("a")
|
||||||
if (origin === null) {
|
if (origin === null) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = URL.parse(origin.href)
|
// Links which explicitly ask for browser behaviour: a new tab, a download,
|
||||||
if (window.location.host !== url.host) {
|
// or an external destination
|
||||||
|
if (origin.target !== "" || origin.hasAttribute("download") ||
|
||||||
|
origin.rel === "external") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("Caught link click to", url.pathname);
|
// Other hosts, and protocols like mailto: and magnet:, have no host match
|
||||||
|
const url = URL.parse(origin.href)
|
||||||
// Try to load the page, if the page was found we cancel the browser
|
if (url === null || url.host !== window.location.host) {
|
||||||
// navigation event so we can handle it ourselves
|
return
|
||||||
if (await load_page(url.pathname, true) === true) {
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// In-page anchors are left to the browser, it knows how to scroll to them
|
||||||
|
if (url.pathname === window.location.pathname && url.hash !== "") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paths without a page of their own are served by the backend, like the API
|
||||||
|
// and file downloads. Loading them here would replace the page the user is
|
||||||
|
// looking at with a Not Found error while the browser navigates away
|
||||||
|
if (find_page(url.pathname) === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("Caught link click to", url.pathname)
|
||||||
|
|
||||||
|
// The event has to be cancelled before the first await, after that the
|
||||||
|
// browser has already committed to the navigation
|
||||||
|
e.preventDefault()
|
||||||
|
load_page(origin.href, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
const popstate = (e: PopStateEvent) => {
|
const popstate = (e: PopStateEvent) => {
|
||||||
load_page(window.location.pathname, false)
|
load_page(window.location.href, false)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user