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/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}
|
||||
<div class="separator"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import Tooltip from "layout/Tooltip.svelte";
|
||||
import Euro from "util/Euro.svelte";
|
||||
import OtherPlans from "./SubscriptionPlans.svelte";
|
||||
import OtherPlans from "layout/checkout/SubscriptionPlans.svelte";
|
||||
import { user } from "lib/UserStore";
|
||||
</script>
|
||||
|
||||
@@ -244,12 +244,12 @@ import { user } from "lib/UserStore";
|
||||
{#if $user === null}
|
||||
<!-- User is not logged in -->
|
||||
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>
|
||||
Log in
|
||||
</a>
|
||||
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>
|
||||
Register
|
||||
</a>
|
||||
@@ -259,7 +259,7 @@ import { user } from "lib/UserStore";
|
||||
{:else}
|
||||
<!-- User is logged in -->
|
||||
{#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
|
||||
</a>
|
||||
to activate Prepaid
|
||||
@@ -269,7 +269,7 @@ import { user } from "lib/UserStore";
|
||||
Prepaid subscription is active<br/>
|
||||
Current balance <Euro amount={$user.balance_micro_eur}/>
|
||||
<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
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
@@ -8,24 +8,21 @@ import Euro from "util/Euro.svelte";
|
||||
{#if $user !== null}
|
||||
<div class="page_content">
|
||||
<section>
|
||||
{#if $user.subscription.type === "patreon"}
|
||||
{#if $user.subscription.id !== ""}
|
||||
<p>
|
||||
You already have a Patreon subscription active. You cannot use
|
||||
Prepaid while that subscription is active.
|
||||
You already have the {$user.subscription.name} plan 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>
|
||||
{: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}
|
||||
<p>
|
||||
Pick one of the subscription plans above to upgrade your
|
||||
account to premium.
|
||||
Pick a subscription plan to upgrade your account. Your
|
||||
account balance is <Euro amount={$user.balance_micro_eur}/>.
|
||||
</p>
|
||||
{/if}
|
||||
<Checkout/>
|
||||
</section>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -4,14 +4,38 @@ import PickAmount from "./PickAmount.svelte";
|
||||
import PickCountry from "./PickCountry.svelte";
|
||||
import { payment_providers, type CheckoutState } from "./CheckoutLib";
|
||||
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 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 () => {
|
||||
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()
|
||||
|
||||
// Get the country from the user profile
|
||||
@@ -39,9 +63,18 @@ onMount(async () => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="highlight_border">
|
||||
{#if status.country !== null}
|
||||
<div class="highlight_border checkout">
|
||||
{#if status.subscription !== undefined}
|
||||
<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}
|
||||
<button onclick={() => status.country = null}>
|
||||
Country:
|
||||
@@ -71,7 +104,9 @@ onMount(async () => {
|
||||
<hr/>
|
||||
{/if}
|
||||
|
||||
{#if status.country === null}
|
||||
{#if status.subscription === undefined}
|
||||
<PickPlan bind:status/>
|
||||
{:else if status.country === null}
|
||||
<PickCountry bind:status/>
|
||||
{:else if status.provider === null}
|
||||
<PickProvider bind:status/>
|
||||
@@ -88,9 +123,25 @@ onMount(async () => {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5em;
|
||||
gap: 0.25em;
|
||||
}
|
||||
.provider_icon {
|
||||
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>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
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 = {
|
||||
// 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]
|
||||
provider: PaymentProvider
|
||||
amount: number
|
||||
@@ -9,6 +13,16 @@ export type CheckoutState = {
|
||||
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 = {
|
||||
icon: string
|
||||
name: string
|
||||
@@ -77,6 +91,8 @@ export const payment_providers: PaymentProvider[] = [
|
||||
]
|
||||
|
||||
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) {
|
||||
alert("Amount needs to be at least €10")
|
||||
return
|
||||
@@ -94,6 +110,12 @@ export const checkout = async (state: CheckoutState) => {
|
||||
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 {
|
||||
const resp = await fetch(
|
||||
get_endpoint() + "/user/invoice",
|
||||
|
||||
@@ -1,31 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { preventDefault } from 'svelte/legacy';
|
||||
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 { 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() }: {
|
||||
status: CheckoutState;
|
||||
} = $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]
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
|
||||
status.amount = amount
|
||||
loading_start()
|
||||
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)
|
||||
} catch (err) {
|
||||
alert("Failed to activate subscription: "+(err?.message ?? err))
|
||||
} finally {
|
||||
loading_finish()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if status.provider.crypto === true}
|
||||
<p style="text-align: initial;" class="highlight_blue">
|
||||
{#if excess_storage > 0}
|
||||
<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
|
||||
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
|
||||
@@ -36,13 +134,57 @@ const submit = async (e: SubmitEvent) => {
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<form class="amount_grid" onsubmit={submit}>
|
||||
<div class="span3">Please choose an amount</div>
|
||||
<div class="text">
|
||||
{#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}
|
||||
<button
|
||||
onclick={preventDefault(() => amount = a)}
|
||||
onclick={(e: MouseEvent) => {e.preventDefault(); amount = a}}
|
||||
class="amount_button"
|
||||
class:button_highlight={amount === a}
|
||||
disabled={a < minimum}
|
||||
>
|
||||
€ {a}
|
||||
</button>
|
||||
@@ -50,28 +192,129 @@ const submit = async (e: SubmitEvent) => {
|
||||
|
||||
<div class="span3 custom_amount">
|
||||
<div>Custom amount €</div>
|
||||
<input type="number" bind:value={amount} min="10"/>
|
||||
<input type="number" bind:value={amount} min={minimum}/>
|
||||
</div>
|
||||
|
||||
<div class="span2" style="text-align: initial;">
|
||||
Total including VAT:
|
||||
<div class="span2 text">
|
||||
VAT:
|
||||
<Euro amount={(amount*1e6)*(status.vat/100)}/>,
|
||||
total:
|
||||
<Euro amount={(amount*1e6) + (amount*1e6)*(status.vat/100)}/>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button_highlight">
|
||||
<i class="icon">paid</i> Checkout
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
{#if runs_until !== null}
|
||||
<hr/>
|
||||
<p style="text-align: initial;">
|
||||
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 months of premium service depending on
|
||||
usage. You can track your spending on the <a
|
||||
href="/user/prepaid/transactions">transactions page</a>.
|
||||
<div class="text">
|
||||
{#if extending}
|
||||
|
||||
This credit deposit will extend your current subscription until
|
||||
about <span class="date">{runs_until}</span>, assuming usage within
|
||||
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>
|
||||
|
||||
<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 {
|
||||
max-width: 500px;
|
||||
gap: 4px;
|
||||
|
||||
@@ -22,7 +22,7 @@ const submit = async (e: SubmitEvent) => {
|
||||
</script>
|
||||
|
||||
<form class="name_form" onsubmit={submit}>
|
||||
<div>
|
||||
<div class="text">
|
||||
This payment provider requires a name for checkout. Please enter your
|
||||
full name below
|
||||
</div>
|
||||
@@ -34,11 +34,11 @@ const submit = async (e: SubmitEvent) => {
|
||||
</button>
|
||||
</form>
|
||||
<hr/>
|
||||
<p style="text-align: initial;">
|
||||
<p class="text">
|
||||
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
|
||||
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>
|
||||
|
||||
<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,
|
||||
is_admin: boolean,
|
||||
balance_micro_eur: number,
|
||||
subscription_months: number,
|
||||
subscription_period_end: string,
|
||||
monthly_transfer_cap: number,
|
||||
monthly_transfer_used: number,
|
||||
file_viewer_branding: Map<string, string>,
|
||||
@@ -29,12 +31,15 @@ export type User = {
|
||||
checkout_country: string,
|
||||
checkout_name: string,
|
||||
checkout_provider: string,
|
||||
usage_based_billing: boolean,
|
||||
}
|
||||
|
||||
export type Subscription = {
|
||||
id: string,
|
||||
name: string,
|
||||
type: string,
|
||||
monthly_fee: number,
|
||||
min_months: number,
|
||||
file_size_limit: number,
|
||||
storage_limit: number,
|
||||
price_per_tb_storage: number,
|
||||
@@ -156,13 +161,10 @@ export const put_user = async (data: Object) => {
|
||||
{ method: "PUT", body: dict_to_form(data) },
|
||||
))
|
||||
|
||||
// Update the user store
|
||||
user.update((user: User) => {
|
||||
for (let key of Object.keys(data)) {
|
||||
user[key] = data[key]
|
||||
}
|
||||
return user
|
||||
})
|
||||
// Fetch the user again to update the store. The submitted fields don't
|
||||
// always match the fields on the user object, and the server can change or
|
||||
// ignore them, so we can't apply the submitted values locally
|
||||
await request_user()
|
||||
}
|
||||
|
||||
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) => {
|
||||
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 (
|
||||
params.get("link_login_user_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)
|
||||
|
||||
let login_redirect = params.get("redirect")
|
||||
if (typeof login_redirect === "string" && login_redirect.startsWith("/")) {
|
||||
console.debug("redirecting user to requested path", login_redirect)
|
||||
window.location.pathname = login_redirect
|
||||
window.location.href = login_redirect
|
||||
} else {
|
||||
window.location.pathname = "/user"
|
||||
window.location.href = "/user"
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { onMount } from "svelte";
|
||||
import CopyButton from "layout/CopyButton.svelte";
|
||||
import { loading_finish, loading_start } from "lib/Loading";
|
||||
|
||||
let app_name = $state("")
|
||||
let api_key = $state("")
|
||||
const create_key = async () => {
|
||||
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")
|
||||
if (app) {
|
||||
app_name = app
|
||||
consoke.log(app)
|
||||
return app
|
||||
}
|
||||
return null
|
||||
})
|
||||
</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">
|
||||
import ProgressBar from "util/ProgressBar.svelte";
|
||||
import { formatDataVolume } from "util/Formatting"
|
||||
import { formatDataVolume, formatEuros } from "util/Formatting"
|
||||
import { user } from "lib/UserStore";
|
||||
|
||||
let { total = 0, used = 0 }: {
|
||||
@@ -9,6 +9,10 @@ let { total = 0, used = 0 }: {
|
||||
} = $props();
|
||||
|
||||
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>
|
||||
|
||||
<ProgressBar total={total} used={used}></ProgressBar>
|
||||
@@ -25,48 +29,54 @@ let frac = $derived(used / total)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if frac > 1}
|
||||
<div class="highlight_yellow">
|
||||
{#if billed && included > 0 && used > included}
|
||||
<p>
|
||||
You have used all of your data cap. People can still download your
|
||||
files, but premium features are 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>
|
||||
|
||||
{#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.
|
||||
Data transfer above the {formatDataVolume(included, 3)} included in your
|
||||
plan is billed separately, at
|
||||
{formatEuros($user.subscription.price_per_tb_bandwidth, 2)} per TB.
|
||||
</p>
|
||||
{/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
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if frac > 0.8}
|
||||
<div class="highlight_blue">
|
||||
<p>
|
||||
You have used {(frac*100).toFixed(0)}% of your data cap. If your
|
||||
data runs out the premium features related to downloading will be
|
||||
disabled. This means that the download page shows Nova
|
||||
branding, people who download your files have a daily download limit
|
||||
and hotlinking is disabled.
|
||||
You have used {(frac*100).toFixed(0)}% of your data transfer. When
|
||||
it runs out your files can't be downloaded anymore.
|
||||
</p>
|
||||
|
||||
{#if $user !== null && $user.monthly_transfer_cap > 0}
|
||||
{#if bill_shock}
|
||||
<p>
|
||||
You have a billshock limit configured. <a
|
||||
href="/user/sharing">increase or disable the limit</a> to
|
||||
continue sharing files.
|
||||
This is your bill shock limit, you can change it in the
|
||||
<a href="/user/sharing">sharing settings</a>.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<a class="button button_highlight" href="/#pro">
|
||||
{:else}
|
||||
<a class="button button_highlight" href="/user/subscription/manage">
|
||||
<i class="icon">bolt</i> Upgrade options
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
{/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 APIKeys from "./APIKeys.svelte";
|
||||
import Transactions from "./Transactions.svelte";
|
||||
import Subscription from "./Subscription.svelte";
|
||||
import ConnectApp from "./ConnectApp.svelte";
|
||||
import ActivityLog from "./ActivityLog.svelte";
|
||||
import DepositCredit from "./DepositCredit.svelte";
|
||||
import TabMenu, { type Tab } from "util/TabMenu.svelte";
|
||||
import Dashboard from "./dashboard/Dashboard.svelte";
|
||||
import AffiliatePrompt from "./AffiliatePrompt.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { get_user, type User } from "lib/NovaAPI";
|
||||
import SharingSettings from "./SharingSettings.svelte";
|
||||
import SubscriptionManage from "./SubscriptionManage.svelte";
|
||||
|
||||
let pages: Tab[] = [
|
||||
{
|
||||
@@ -31,17 +30,12 @@ let pages: Tab[] = [
|
||||
icon: "shopping_cart",
|
||||
subpages: [
|
||||
{
|
||||
path: "/user/subscription",
|
||||
path: "/user/subscription/manage",
|
||||
title: "Manage",
|
||||
icon: "shopping_cart",
|
||||
component: Subscription,
|
||||
component: SubscriptionManage,
|
||||
}, {
|
||||
path: "/user/prepaid/deposit",
|
||||
title: "Deposit Credit",
|
||||
icon: "account_balance_wallet",
|
||||
component: DepositCredit,
|
||||
}, {
|
||||
path: "/user/prepaid/transactions",
|
||||
path: "/user/subscription/transactions",
|
||||
title: "Transactions",
|
||||
icon: "receipt",
|
||||
component: Transactions,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import { formatDataVolume } from "util/Formatting";
|
||||
import { formatDataVolume, formatEuros } from "util/Formatting";
|
||||
import ProgressBar from "util/ProgressBar.svelte";
|
||||
import { loading_run } from "lib/Loading";
|
||||
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 load_transfer_used = () => {
|
||||
let today = new Date()
|
||||
@@ -75,6 +88,23 @@ onMount(async () => {
|
||||
<section>
|
||||
<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>
|
||||
<p>
|
||||
Billshock limit in terabytes per month (1 TB = 1000 GB). Set to 0 to
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { formatDataVolume } from "util/Formatting"
|
||||
import { formatDataVolume, formatEuros } from "util/Formatting"
|
||||
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;
|
||||
used?: number;
|
||||
disable_warnings?: boolean;
|
||||
} = $props();
|
||||
|
||||
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>
|
||||
|
||||
<ProgressBar total={total} used={used}></ProgressBar>
|
||||
@@ -25,58 +27,40 @@ let frac = $derived(total <= 0 ? 0 : used / total)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !disable_warnings}
|
||||
{#if frac > 2.0}
|
||||
<div class="highlight_red">
|
||||
<span class="warn_text">
|
||||
You are using more than 200% of your allowed storage space!
|
||||
</span>
|
||||
{#if billed}
|
||||
{#if frac > 1}
|
||||
<p>
|
||||
We have started deleting your files to free up space. If you do not
|
||||
want to lose any more files please upgrade to a subscription which
|
||||
supports the volume of storage which you need.
|
||||
Storage above the limit of your plan is billed separately, at
|
||||
{formatEuros($user.subscription.price_per_tb_storage, 2)} per TB per
|
||||
month.
|
||||
</p>
|
||||
<a class="button button_highlight" href="/#pro">
|
||||
<i class="icon">bolt</i> Upgrade options
|
||||
</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}
|
||||
{/if}
|
||||
{:else if frac > 1}
|
||||
<div class="highlight_yellow">
|
||||
<p>
|
||||
You have used {(frac*100).toFixed(0)}% of your
|
||||
storage space. If your storage space runs out you won't be able
|
||||
to upload new files anymore. Please upgrade to a higher support
|
||||
tier to continue uploading files.
|
||||
You are storing more than your plan allows, so you can't upload new
|
||||
files. Your existing files stay available. Delete some files or
|
||||
upgrade your plan to continue uploading{#if billable}, or enable
|
||||
<a href="/user/sharing">usage based billing</a> to pay for the
|
||||
storage above the limit{/if}.
|
||||
</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
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.warn_text {
|
||||
font-weight: bold;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.gauge_labels {
|
||||
display: flex;
|
||||
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>
|
||||
Supporter level: {$user.subscription.name}<br/>
|
||||
<i class="icon">shopping_cart</i>
|
||||
<a href="/user/subscription">Manage subscriptions</a><br/>
|
||||
<i class="icon">add_link</i>
|
||||
<a href="/api/patreon_auth/start">Link Patreon subscription</a>
|
||||
<a href="/user/subscription">Manage subscriptions</a>
|
||||
</li>
|
||||
|
||||
{#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/>
|
||||
|
||||
<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>
|
||||
<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 === ""}
|
||||
<br/>
|
||||
@@ -36,19 +34,31 @@ import { formatDataVolume } from "util/Formatting";
|
||||
Max file size: {formatDataVolume($user.subscription.file_size_limit, 3)}
|
||||
</li>
|
||||
<li>
|
||||
{#if $user.subscription.storage_limit > 0}
|
||||
Storage limit: {formatDataVolume($user.subscription.storage_limit, 3)}
|
||||
{:else}
|
||||
{#if $user.subscription.storage_limit <= 0}
|
||||
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}
|
||||
</li>
|
||||
<li>
|
||||
{#if $user.subscription.monthly_transfer_cap > 0}
|
||||
Data transfer limit: {formatDataVolume($user.subscription.monthly_transfer_cap, 3)}
|
||||
{:else}
|
||||
{#if $user.subscription.monthly_transfer_cap <= 0}
|
||||
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}
|
||||
</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>
|
||||
{#if $user.subscription.file_sharing === true}
|
||||
File sharing enabled
|
||||
|
||||
@@ -34,13 +34,14 @@ let user: User = $state()
|
||||
onMount(async () => {
|
||||
user = await get_user()
|
||||
|
||||
if (user.monthly_transfer_cap > 0) {
|
||||
transfer_cap = user.monthly_transfer_cap
|
||||
} else if (user.subscription.monthly_transfer_cap > 0) {
|
||||
transfer_cap = user.subscription.monthly_transfer_cap
|
||||
} else {
|
||||
transfer_cap = -1
|
||||
// The lowest limit which blocks downloads. With usage based billing the
|
||||
// plan's limit is billed instead, only the bill shock limit blocks
|
||||
let caps = [user.monthly_transfer_cap]
|
||||
if (!user.usage_based_billing || user.subscription.price_per_tb_bandwidth <= 0) {
|
||||
caps.push(user.subscription.monthly_transfer_cap)
|
||||
}
|
||||
caps = caps.filter(cap => cap > 0)
|
||||
transfer_cap = caps.length > 0 ? Math.min(...caps) : -1
|
||||
|
||||
storage_limit = user.subscription.storage_limit
|
||||
|
||||
|
||||
@@ -17,19 +17,19 @@ let {
|
||||
style?: string;
|
||||
} = $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(() => {
|
||||
// Avoid division by 0
|
||||
if (total === 0) {
|
||||
total = 1
|
||||
}
|
||||
|
||||
// Don't allow more than 100% progress
|
||||
if (used / total > 1) {
|
||||
percent = 100
|
||||
} else {
|
||||
percent = (used / total) * 100
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -37,6 +37,7 @@ $effect(() => {
|
||||
<div
|
||||
class="progress_bar_inner"
|
||||
class:no_animation
|
||||
class:red={percent > 90}
|
||||
style="width: {percent}%; transition-timing-function: {animation}; transition-duration: {speed}ms;">
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,6 +59,9 @@ $effect(() => {
|
||||
border-radius: 6px;
|
||||
transition-property: width;
|
||||
}
|
||||
.red {
|
||||
background: var(--danger_color);
|
||||
}
|
||||
.no_animation {
|
||||
transition-property: none;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import Tree from "./Tree.svelte";
|
||||
import MenuEntry from "./MenuEntry.svelte";
|
||||
import { writable } from "svelte/store";
|
||||
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
|
||||
// screen edge
|
||||
@@ -176,18 +177,37 @@ const set_offset = (off: number) => {
|
||||
|
||||
{#snippet body()}
|
||||
<div class="stats_table">
|
||||
<div>Subscription</div>
|
||||
<div>{$user.subscription.name}</div>
|
||||
<div>Sub</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><Euro amount={$user.balance_micro_eur}/></div>
|
||||
{/if}
|
||||
|
||||
<div>Storage used</div>
|
||||
<div>{formatDataVolume($user.filesystem_storage_used, 3)}</div>
|
||||
<div>Transfer used</div>
|
||||
<div>{formatDataVolume($user.monthly_transfer_used, 3)}</div>
|
||||
<div>Storage</div>
|
||||
<div>
|
||||
{formatDataVolume($user.filesystem_storage_used, 3)} /
|
||||
{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>
|
||||
{/snippet}
|
||||
</MenuEntry>
|
||||
@@ -305,7 +325,7 @@ const set_offset = (off: number) => {
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 15em;
|
||||
width: 16em;
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
backdrop-filter: blur(3px);
|
||||
|
||||
@@ -57,42 +57,39 @@ let pages: Tab[] = [
|
||||
]
|
||||
|
||||
onMount(async () => {
|
||||
await load_page(window.location.pathname, false)
|
||||
await load_page(window.location.href, false)
|
||||
})
|
||||
|
||||
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(
|
||||
"Page router: Navigating to page", pathname,
|
||||
"Page router: Navigating to page", url.pathname,
|
||||
"current path", window.location.pathname,
|
||||
"log history:", history,
|
||||
)
|
||||
|
||||
let user: User
|
||||
try {
|
||||
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 {
|
||||
const page = find_page(url.pathname)
|
||||
if (page === null) {
|
||||
current_page = {
|
||||
path: "",
|
||||
title: "Not Found",
|
||||
@@ -101,19 +98,37 @@ const load_page = async (pathname: string, history: boolean): Promise<boolean> =
|
||||
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
|
||||
// redirect the user to the login page
|
||||
if (current_page.login === true && (user === null || user === undefined)) {
|
||||
// redirect the user to the login page. The page is deliberately not mounted
|
||||
// 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)
|
||||
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) {
|
||||
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) {
|
||||
breadcrumbs_store.set(breadcrumbs)
|
||||
}
|
||||
@@ -129,29 +144,53 @@ const load_page = async (pathname: string, history: boolean): Promise<boolean> =
|
||||
return true
|
||||
}
|
||||
|
||||
const click = async (e: MouseEvent) => {
|
||||
const origin = (e.target as Element).closest("a");
|
||||
const click = (e: MouseEvent) => {
|
||||
// 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) {
|
||||
return
|
||||
}
|
||||
|
||||
const url = URL.parse(origin.href)
|
||||
if (window.location.host !== url.host) {
|
||||
// Links which explicitly ask for browser behaviour: a new tab, a download,
|
||||
// or an external destination
|
||||
if (origin.target !== "" || origin.hasAttribute("download") ||
|
||||
origin.rel === "external") {
|
||||
return
|
||||
}
|
||||
|
||||
console.log("Caught link click to", url.pathname);
|
||||
|
||||
// Try to load the page, if the page was found we cancel the browser
|
||||
// navigation event so we can handle it ourselves
|
||||
if (await load_page(url.pathname, true) === true) {
|
||||
e.preventDefault()
|
||||
// Other hosts, and protocols like mailto: and magnet:, have no host match
|
||||
const url = URL.parse(origin.href)
|
||||
if (url === null || url.host !== window.location.host) {
|
||||
return
|
||||
}
|
||||
|
||||
// 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) => {
|
||||
load_page(window.location.pathname, false)
|
||||
load_page(window.location.href, false)
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user