Modify the checkout widget to support subscriptions. Add usage-based billing toggle to sharing settings.
This commit is contained in:
@@ -4,14 +4,38 @@ import PickAmount from "./PickAmount.svelte";
|
||||
import PickCountry from "./PickCountry.svelte";
|
||||
import { payment_providers, type CheckoutState } from "./CheckoutLib";
|
||||
import PickName from "./PickName.svelte";
|
||||
import { get_misc_vat_rate, get_user } from "lib/NovaAPI";
|
||||
import { get_misc_subscriptions, get_misc_vat_rate, get_user, subscription_periods } from "lib/NovaAPI";
|
||||
import { countries } from "country-data-list";
|
||||
import PickProvider from "./PickProvider.svelte";
|
||||
import PickPlan from "./PickPlan.svelte";
|
||||
|
||||
let status: CheckoutState = $state({country: null, provider: null, amount: 0, vat: 0, name: ""})
|
||||
let status: CheckoutState = $state({
|
||||
subscription: undefined,
|
||||
months: 1,
|
||||
country: null,
|
||||
provider: null,
|
||||
amount: 0,
|
||||
vat: 0,
|
||||
name: "",
|
||||
})
|
||||
|
||||
onMount(async () => {
|
||||
try {
|
||||
// The plan links on the home page point here with the plan in the URL
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
if (params.get("plan") !== null) {
|
||||
const plan = (await get_misc_subscriptions())
|
||||
.find(p => p.id === params.get("plan"))
|
||||
|
||||
if (plan !== undefined) {
|
||||
const months = Number(params.get("months"))
|
||||
status.subscription = plan
|
||||
status.months = months >= plan.min_months &&
|
||||
subscription_periods.some(p => p.months === months) ?
|
||||
months : plan.min_months
|
||||
}
|
||||
}
|
||||
|
||||
const user = await get_user()
|
||||
|
||||
// Get the country from the user profile
|
||||
@@ -39,9 +63,18 @@ onMount(async () => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="highlight_border">
|
||||
{#if status.country !== null}
|
||||
<div class="highlight_border checkout">
|
||||
{#if status.subscription !== undefined}
|
||||
<div class="navbar">
|
||||
<button onclick={() => status.subscription = undefined}>
|
||||
{#if status.subscription === null}
|
||||
Plan: Credit deposit only
|
||||
{:else}
|
||||
Plan: {status.subscription.name}, {status.months}
|
||||
month{status.months === 1 ? "" : "s"}
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if status.country !== null}
|
||||
<button onclick={() => status.country = null}>
|
||||
Country:
|
||||
@@ -71,7 +104,9 @@ onMount(async () => {
|
||||
<hr/>
|
||||
{/if}
|
||||
|
||||
{#if status.country === null}
|
||||
{#if status.subscription === undefined}
|
||||
<PickPlan bind:status/>
|
||||
{:else if status.country === null}
|
||||
<PickCountry bind:status/>
|
||||
{:else if status.provider === null}
|
||||
<PickProvider bind:status/>
|
||||
@@ -88,9 +123,25 @@ onMount(async () => {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 0.5em;
|
||||
gap: 0.25em;
|
||||
}
|
||||
.provider_icon {
|
||||
height: 1.5em;
|
||||
}
|
||||
/* The widget has no padding on the sides, that way the separators between the
|
||||
steps span its full width. All the children add the padding back, except the
|
||||
separators and the plan list, which scrolls sideways */
|
||||
.checkout {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
.checkout > :global(*:not(hr, .horizontal_scroll)) {
|
||||
padding-left: 0.5em;
|
||||
padding-right: 0.5em;
|
||||
}
|
||||
|
||||
/* Running text reads better left aligned. Titles and buttons stay centred */
|
||||
.checkout :global(.text) {
|
||||
text-align: initial;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { countries } from "country-data-list";
|
||||
import { get_endpoint } from "lib/NovaAPI";
|
||||
import { get_endpoint, subscription_periods, type Subscription } from "lib/NovaAPI";
|
||||
|
||||
export type CheckoutState = {
|
||||
// undefined means the user has not picked yet, null means they chose to
|
||||
// only deposit credit without starting a subscription
|
||||
subscription: Subscription | null | undefined
|
||||
months: number
|
||||
country: typeof countries.all[0]
|
||||
provider: PaymentProvider
|
||||
amount: number
|
||||
@@ -9,6 +13,16 @@ export type CheckoutState = {
|
||||
name: string
|
||||
}
|
||||
|
||||
// The price of the chosen subscription period in euro. The deposit can't be
|
||||
// lower than this
|
||||
export const plan_price = (state: CheckoutState): number => {
|
||||
if (!state.subscription) {
|
||||
return 0
|
||||
}
|
||||
return state.subscription.monthly_fee * state.months *
|
||||
subscription_periods.find(p => p.months === state.months).discount / 1e6
|
||||
}
|
||||
|
||||
export type PaymentProvider = {
|
||||
icon: string
|
||||
name: string
|
||||
@@ -77,6 +91,8 @@ export const payment_providers: PaymentProvider[] = [
|
||||
]
|
||||
|
||||
export const checkout = async (state: CheckoutState) => {
|
||||
// Whether the deposit covers the subscription is checked by the server,
|
||||
// which knows how much credit is already on the account
|
||||
if (state.amount < 10) {
|
||||
alert("Amount needs to be at least €10")
|
||||
return
|
||||
@@ -94,6 +110,12 @@ export const checkout = async (state: CheckoutState) => {
|
||||
form.set("name", state.name)
|
||||
}
|
||||
|
||||
// The server activates the subscription when the invoice is paid
|
||||
if (state.subscription) {
|
||||
form.set("subscription", state.subscription.id)
|
||||
form.set("subscription_months", String(state.months))
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
get_endpoint() + "/user/invoice",
|
||||
|
||||
@@ -1,31 +1,129 @@
|
||||
<script lang="ts">
|
||||
import { preventDefault } from 'svelte/legacy';
|
||||
import Euro from "util/Euro.svelte";
|
||||
import { checkout, type CheckoutState } from "./CheckoutLib";
|
||||
import { checkout, plan_price, type CheckoutState } from "./CheckoutLib";
|
||||
import { loading_finish, loading_start } from "lib/Loading";
|
||||
import { days_per_month, put_user, subscription_periods, type SubscriptionPeriod } from "lib/NovaAPI";
|
||||
import { user } from "lib/UserStore";
|
||||
import { formatDate } from "util/Formatting";
|
||||
|
||||
let { status = $bindable() }: {
|
||||
status: CheckoutState;
|
||||
} = $props();
|
||||
|
||||
// The credit already on the account counts towards the subscription, so the
|
||||
// deposit only has to cover the rest of it
|
||||
let credit = $derived($user === null ? 0 : $user.balance_micro_eur / 1e6)
|
||||
let missing = $derived(Math.max(0, plan_price(status) - credit))
|
||||
|
||||
// Extending the plan the user is already on is always a credit deposit, there
|
||||
// is nothing to activate
|
||||
let extending = $derived(
|
||||
status.subscription !== null &&
|
||||
$user !== null &&
|
||||
status.subscription.id === $user.subscription.id &&
|
||||
status.months === $user.subscription_months
|
||||
)
|
||||
|
||||
// Monthly price of the data stored above the chosen plan's limit. The server
|
||||
// bills this on top of the subscription. It is left out when extending, the
|
||||
// user is already paying it
|
||||
let excess_storage = $derived(
|
||||
status.subscription === null || $user === null || extending ? 0 :
|
||||
Math.max(0, $user.storage_space_used - status.subscription.storage_limit) *
|
||||
status.subscription.price_per_tb_storage / 1e12,
|
||||
)
|
||||
|
||||
// The account credit already pays for the whole subscription period, so there
|
||||
// is nothing to check out. The user can activate the plan right away. Covered
|
||||
// is false when the current plan is being extended because otherwise the amount
|
||||
// picker would be hidden
|
||||
let covered = $derived(
|
||||
status.subscription !== null &&
|
||||
missing === 0 &&
|
||||
!extending
|
||||
)
|
||||
|
||||
// Estimate of how long the credit lasts at the daily rate of the plan. Charges
|
||||
// for usage outside of the plan are not included, so the real date comes sooner
|
||||
let daily_price = $derived(status.subscription === null ? 0 :
|
||||
status.subscription.monthly_fee * (
|
||||
subscription_periods.find(p => p.months === status.months)?.discount ?? 1
|
||||
) / days_per_month / 1e6,
|
||||
)
|
||||
let runs_until = $derived.by(() => {
|
||||
if (daily_price <= 0) {
|
||||
return null
|
||||
}
|
||||
const date = new Date()
|
||||
date.setDate(date.getDate() + Math.floor(
|
||||
(credit + (covered ? 0 : amount)) / daily_price,
|
||||
))
|
||||
return formatDate(date, false, false, false)
|
||||
})
|
||||
|
||||
// We can't process payments below €10
|
||||
let minimum = $derived(Math.max(10, Math.ceil(missing)))
|
||||
const amounts = [10, 20, 50, 100, 200, 500, 1000, 2000, 5000]
|
||||
let amount = $state(20)
|
||||
let amount = $state(10)
|
||||
|
||||
// Picking a longer period can raise the minimum above the chosen amount. We
|
||||
// never lower it again, the user may have typed a custom amount
|
||||
$effect(() => {
|
||||
if (amount < minimum) {
|
||||
amount = minimum
|
||||
}
|
||||
})
|
||||
|
||||
let periods = $derived(status.subscription === null ? [] : subscription_periods.filter(
|
||||
p => p.months >= status.subscription.min_months,
|
||||
))
|
||||
|
||||
// Price of a whole period, and what it would cost without the discount
|
||||
const period_price = (months: number, discount: number) =>
|
||||
status.subscription.monthly_fee * months * discount
|
||||
const period_saving = (months: number, discount: number) =>
|
||||
status.subscription.monthly_fee * months * (1 - discount)
|
||||
const switch_period = (period: SubscriptionPeriod) => {
|
||||
status.months = period.months
|
||||
amount = minimum
|
||||
}
|
||||
|
||||
const submit = async (e: SubmitEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
status.amount = amount
|
||||
loading_start()
|
||||
try {
|
||||
if (covered) {
|
||||
// Nothing to pay, so we activate the subscription directly instead
|
||||
// of sending the user to a payment provider
|
||||
await put_user({
|
||||
subscription: status.subscription.id,
|
||||
subscription_months: status.months,
|
||||
})
|
||||
window.location.href = "/user/subscription"
|
||||
return
|
||||
}
|
||||
|
||||
status.amount = amount
|
||||
await checkout(status)
|
||||
}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;
|
||||
|
||||
@@ -22,7 +22,7 @@ const submit = async (e: SubmitEvent) => {
|
||||
</script>
|
||||
|
||||
<form class="name_form" onsubmit={submit}>
|
||||
<div>
|
||||
<div class="text">
|
||||
This payment provider requires a name for checkout. Please enter your
|
||||
full name below
|
||||
</div>
|
||||
@@ -34,11 +34,11 @@ const submit = async (e: SubmitEvent) => {
|
||||
</button>
|
||||
</form>
|
||||
<hr/>
|
||||
<p style="text-align: initial;">
|
||||
<p class="text">
|
||||
This Nova premium plan costs €1 per month, but due to processing fees we
|
||||
can't accept payments less than €10. So your deposit will give roughly 10
|
||||
months of premium service depending on usage. You can track your spending on
|
||||
the <a href="/user/prepaid/transactions">transactions page</a>.
|
||||
the <a href="/user/subscription/transactions">transactions page</a>.
|
||||
</p>
|
||||
|
||||
<style>
|
||||
|
||||
92
svelte/src/layout/checkout/PickPlan.svelte
Normal file
92
svelte/src/layout/checkout/PickPlan.svelte
Normal file
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
import SubscriptionPlans from "./SubscriptionPlans.svelte";
|
||||
import { put_user, type Subscription } from "lib/NovaAPI";
|
||||
import { loading_finish, loading_start } from "lib/Loading";
|
||||
import { user } from "lib/UserStore";
|
||||
import SuccessMessage from "util/SuccessMessage.svelte";
|
||||
import type { CheckoutState } from "./CheckoutLib";
|
||||
import Euro from "util/Euro.svelte";
|
||||
|
||||
let { status = $bindable() }: {
|
||||
status: CheckoutState;
|
||||
} = $props();
|
||||
|
||||
let cancel_message: SuccessMessage = $state()
|
||||
|
||||
const pick = (plan: Subscription, months: number) => {
|
||||
status.subscription = plan
|
||||
status.months = months
|
||||
}
|
||||
|
||||
// Extending keeps the plan the user is on, the deposit only adds credit to it
|
||||
const extend = () => {
|
||||
status.subscription = $user.subscription
|
||||
status.months = $user.subscription_months || $user.subscription.min_months
|
||||
}
|
||||
|
||||
const cancel = async () => {
|
||||
if (!confirm(
|
||||
"This cancels your subscription and moves your account back to the "+
|
||||
"Free plan, which is limited to 50 GB of storage. Any credit stays "+
|
||||
"on your account. Are you sure?"
|
||||
)) {
|
||||
return
|
||||
}
|
||||
|
||||
loading_start()
|
||||
try {
|
||||
const plan = $user.subscription.name
|
||||
await put_user({subscription: ""})
|
||||
|
||||
// put_user refreshes the user store, so the button below disappears on
|
||||
// its own once the subscription is gone
|
||||
cancel_message.set(true,
|
||||
"Your "+plan+" subscription has been canceled. Your account is "+
|
||||
"back on the Free plan")
|
||||
} catch (err) {
|
||||
cancel_message.set(false,
|
||||
"Failed to cancel subscription: "+(err?.message ?? err))
|
||||
} finally {
|
||||
loading_finish()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div>Please choose a subscription plan</div>
|
||||
|
||||
<SubscriptionPlans onpick={pick}/>
|
||||
|
||||
<div class="text">
|
||||
Excess usage outside of the subscription plan will be billed at <Euro
|
||||
amount={4e6}/> per TB per month for storage and <Euro amount={1e6}/> per TB
|
||||
for data egress.
|
||||
</div>
|
||||
|
||||
{#if $user !== null && $user.subscription.id !== ""}
|
||||
<hr/>
|
||||
<SuccessMessage bind:this={cancel_message}/>
|
||||
<div class="options">
|
||||
<button onclick={extend}>
|
||||
<i class="icon">more_time</i>
|
||||
Extend current plan (credit deposit)
|
||||
</button>
|
||||
<button onclick={cancel}>
|
||||
<i class="icon">cancel</i>
|
||||
Cancel current plan and return to Free
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<style>
|
||||
.options {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.options > button {
|
||||
flex-direction: column;
|
||||
}
|
||||
</style>
|
||||
124
svelte/src/layout/checkout/SubscriptionPlans.svelte
Normal file
124
svelte/src/layout/checkout/SubscriptionPlans.svelte
Normal file
@@ -0,0 +1,124 @@
|
||||
<script lang="ts">
|
||||
import Euro from "util/Euro.svelte";
|
||||
import { formatDataVolume } from "util/Formatting";
|
||||
import { get_misc_subscriptions, subscription_periods, type Subscription } from "lib/NovaAPI";
|
||||
import { user } from "lib/UserStore";
|
||||
|
||||
// When onpick is set the period buttons call it instead of linking to the
|
||||
// subscription page. The checkout widget uses this to pick a plan in place
|
||||
let { onpick = undefined }: {
|
||||
onpick?: (plan: Subscription, months: number) => void,
|
||||
} = $props()
|
||||
|
||||
let plans: Subscription[] = $state([])
|
||||
|
||||
// Only the plan the user is on is highlighted. When they have no subscription
|
||||
// there is nothing to compare against, so we highlight all of them
|
||||
let active = $derived($user === null ? "" : $user.subscription.id)
|
||||
|
||||
get_misc_subscriptions().then(v => plans = v).catch(err => {
|
||||
console.error("Failed to load subscription plans", err)
|
||||
})
|
||||
</script>
|
||||
|
||||
{#snippet price(plan: Subscription, period: typeof subscription_periods[0])}
|
||||
<div class="bold">{period.label}</div>
|
||||
<div>
|
||||
<Euro amount={plan.monthly_fee*period.months*period.discount}/>
|
||||
/ {period.months} month{period.months === 1 ? "" : "s"}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
<div class="horizontal_scroll">
|
||||
<div class="feat_table">
|
||||
{#each plans as plan (plan.id)}
|
||||
<div class="plan" class:highlighted={active === "" || active === plan.id}>
|
||||
<div>
|
||||
<span class="title">{plan.name}</span>
|
||||
<br/>
|
||||
<Euro amount={plan.monthly_fee}/> / month
|
||||
</div>
|
||||
<div class="features">
|
||||
<div>
|
||||
<span class="bold">{formatDataVolume(plan.storage_limit, 0)}</span>
|
||||
storage space
|
||||
</div>
|
||||
<div>
|
||||
<span class="bold">{formatDataVolume(plan.monthly_transfer_cap, 0)}</span>
|
||||
egress traffic per month
|
||||
</div>
|
||||
</div>
|
||||
<div class="order">
|
||||
{#each subscription_periods as period (period.months)}
|
||||
{#if period.months < plan.min_months}
|
||||
<span>{period.name} plan not available</span>
|
||||
{:else if onpick !== undefined}
|
||||
<button class="button" onclick={() => onpick(plan, period.months)}>
|
||||
{@render price(plan, period)}
|
||||
</button>
|
||||
{:else}
|
||||
<a href="/user/subscription?plan={plan.id}&months={period.months}" class="button">
|
||||
{@render price(plan, period)}
|
||||
</a>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.horizontal_scroll {
|
||||
overflow-x: scroll;
|
||||
overflow-y: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
/* max-content makes the row as wide as the cards, so the right padding ends
|
||||
up after the last card instead of at the scroll container's edge */
|
||||
.feat_table {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: max-content;
|
||||
margin: auto;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
}
|
||||
.plan {
|
||||
flex: 0 0 18em;
|
||||
display: flex;
|
||||
width: 18em;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--separator);
|
||||
border-radius: 8px;
|
||||
background-color: var(--body_color);
|
||||
}
|
||||
.plan.highlighted {
|
||||
border-color: var(--highlight_color);
|
||||
}
|
||||
.plan > div {
|
||||
flex: 1 1 0;
|
||||
margin: 0.2em;
|
||||
padding: 0.5em;
|
||||
text-align: center;
|
||||
word-wrap: break-word;
|
||||
hyphens: auto;
|
||||
}
|
||||
.title {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.plan > .order {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.plan > .order > .button {
|
||||
flex-direction: column;
|
||||
}
|
||||
.plan > .order > .button > div {
|
||||
width: 100%;
|
||||
}
|
||||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user