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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
}finally {
} 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,42 +134,187 @@ const submit = async (e: SubmitEvent) => {
</p>
{/if}
<form class="amount_grid" onsubmit={submit}>
<div class="span3">Please choose an amount</div>
{#each amounts as a}
<button
onclick={preventDefault(() => amount = a)}
class="amount_button"
class:button_highlight={amount === a}
>
€ {a}
</button>
{/each}
<div class="text">
{#if covered}
<div class="span3 custom_amount">
<div>Custom amount €</div>
<input type="number" bind:value={amount} min="10"/>
</div>
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.
<div class="span2" style="text-align: initial;">
Total including VAT:
<Euro amount={(amount*1e6) + (amount*1e6)*(status.vat/100)}/>
</div>
{: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>
<button type="submit" class="button_highlight">
<i class="icon">paid</i> Checkout
</button>
</form>
<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>.
<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={(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>
<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;

View File

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

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

View File

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

View File

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

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">
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 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}
<div class="highlight_yellow">
<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.
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 $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. <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}
<a class="button button_highlight" href="/#pro">
<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 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>
{:else}
<a class="button button_highlight" href="/user/subscription/manage">
<i class="icon">bolt</i> Upgrade options
</a>
{/if}
<a class="button button_highlight" href="/#pro">
<i class="icon">bolt</i> Upgrade options
</a>
</div>
{/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 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,

View File

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

View File

@@ -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>
<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 billed}
{#if frac > 1}
<p>
Storage above the limit of your plan is billed separately, at
{formatEuros($user.subscription.price_per_tb_storage, 2)} per TB per
month.
</p>
{/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}
<style>
.warn_text {
font-weight: bold;
font-size: 1.5em;
}
.gauge_labels {
display: flex;
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>
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

View File

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

View File

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

View File

@@ -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>Credit</div>
<div><Euro amount={$user.balance_micro_eur}/></div>
<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);

View File

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