Modify the checkout widget to support subscriptions. Add usage-based billing toggle to sharing settings.

This commit is contained in:
2026-09-30 19:26:21 +02:00
parent 4b9434ce0a
commit 7b2e4b4240
25 changed files with 1060 additions and 684 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
}finally { } catch (err) {
alert("Failed to activate subscription: "+(err?.message ?? err))
} 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,42 +134,187 @@ 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}
{#each amounts as a}
<button
onclick={preventDefault(() => amount = a)}
class="amount_button"
class:button_highlight={amount === a}
>
€ {a}
</button>
{/each}
<div class="span3 custom_amount"> Subscription {status.subscription.name} costs
<div>Custom amount €</div> <Euro amount={plan_price(status)*1e6}/>
<input type="number" bind:value={amount} min="10"/> for {status.months} month{status.months === 1 ? "" : "s"},
</div> which your account credit of <Euro amount={credit*1e6}/> already
covers. You don't have to pay anything now.
<div class="span2" style="text-align: initial;"> {:else if status.subscription !== null}
Total including VAT:
<Euro amount={(amount*1e6) + (amount*1e6)*(status.vat/100)}/> Subscription {status.subscription.name} costs
</div> <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>
<button type="submit" class="button_highlight">
<i class="icon">paid</i> Checkout
</button>
</form>
<hr/> <hr/>
<p style="text-align: initial;">
This Nova premium plan costs €1 per month, but due to <form onsubmit={submit}>
processing fees we can't accept payments less than €10. So your {#if covered}
deposit will give roughly 10 months of premium service depending on
usage. You can track your spending on the <a <button type="submit" class="button_highlight" style="flex-direction: column;">
href="/user/prepaid/transactions">transactions page</a>. <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={(e: MouseEvent) => {e.preventDefault(); amount = a}}
class="amount_button"
class:button_highlight={amount === a}
disabled={a < minimum}
>
€ {a}
</button>
{/each}
<div class="span3 custom_amount">
<div>Custom amount €</div>
<input type="number" bind:value={amount} min={minimum}/>
</div>
<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/>
<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> </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;

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 billed && included > 0 && used > included}
<p>
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}
{#if frac > 1} {#if frac > 1}
<div class="highlight_yellow"> <div class="highlight_yellow">
<p> <p>
You have used all of your data cap. People can still download your You have used all of your data transfer, so your files can't be
files, but premium features are disabled. This means that the downloaded until your usage over the last 30 days drops below the
download page shows Nova branding, people who download your limit. Logged in users with data transfer left on their own plan
files have a daily download limit and hotlinking is disabled. can still download them.
</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. <a href="/user/sharing">Increase
href="/user/sharing">increase or disable the limit</a> to or disable the limit</a> to continue sharing files.
continue sharing files.
</p> </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} {/if}
<a class="button button_highlight" href="/#pro">
<i class="icon">bolt</i> Upgrade options
</a>
</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>
{:else}
<a class="button button_highlight" href="/user/subscription/manage">
<i class="icon">bolt</i> Upgrade options
</a>
{/if} {/if}
<a class="button button_highlight" href="/#pro">
<i class="icon">bolt</i> Upgrade options
</a>
</div> </div>
{/if} {/if}

View File

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

View File

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

View File

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

View File

@@ -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"> <p>
<span class="warn_text"> Storage above the limit of your plan is billed separately, at
You are using more than 200% of your allowed storage space! {formatEuros($user.subscription.price_per_tb_storage, 2)} per TB per
</span> month.
<p> </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.
</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}
<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.
</p>
<a class="button button_highlight" href="/#pro">
<i class="icon">bolt</i> Upgrade options
</a>
</div>
{/if} {/if}
{:else if frac > 1}
<div class="highlight_yellow">
<p>
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="/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> <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;

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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