Split checkout widget into separate components

This commit is contained in:
2025-05-15 17:00:20 +02:00
parent ec87ae59fc
commit 732fdb8687
23 changed files with 884 additions and 644 deletions

View File

@@ -0,0 +1,100 @@
<script lang="ts">
import { onMount } from "svelte";
import LoadingIndicator from "util/LoadingIndicator.svelte";
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/PixeldrainAPI";
import { countries } from "country-data-list";
import PickProvider from "./PickProvider.svelte";
let loading = false
let state: CheckoutState = {country: null, provider: null, amount: 0, vat: 0, name: ""}
onMount(async () => {
try {
const user = await get_user()
// Get the country from the user profile
if (user.checkout_country !== "" && countries[user.checkout_country] !== undefined) {
const vat_rate = await get_misc_vat_rate(user.checkout_country)
state.vat = vat_rate.vat*100
state.country = countries[user.checkout_country]
}
// Get the provider from the user profile
for (const p of payment_providers) {
if (p.name === user.checkout_provider) {
state.provider = p
break
}
}
// Get the checkout name from the user profile
if (user.checkout_name !== "") {
state.name = user.checkout_name
}
}catch (err) {
alert("Failed to get user:"+err)
}
})
</script>
<div class="highlight_border">
<LoadingIndicator loading={loading}/>
{#if state.country !== null}
<div class="navbar">
{#if state.country !== null}
<button on:click={() => state.country = null}>
Country:
<span style="font-size: 1.5em; line-height: 1em;">{state.country.emoji}</span>
{state.country.name}
</button>
{/if}
{#if state.provider !== null}
<button on:click={() => state.provider = null}>
Provider:
<img
class="provider_icon"
src="/res/img/payment_providers/{state.provider.icon}.svg"
alt={state.provider.label}
title={state.provider.label}/>
{state.provider.label}
</button>
{/if}
{#if state.name !== ""}
<button on:click={() => state.name = ""}>
Name: {state.name}
</button>
{/if}
</div>
<hr/>
{/if}
{#if state.country === null}
<PickCountry bind:state bind:loading={loading}/>
{:else if state.provider === null}
<PickProvider bind:state/>
{:else if state.provider.need_name === true && state.name === ""}
<PickName bind:state/>
{:else}
<PickAmount bind:state bind:loading/>
{/if}
</div>
<style>
.navbar {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
gap: 0.5em;
}
.provider_icon {
height: 1.5em;
}
</style>

View File

@@ -0,0 +1,106 @@
import type { countries } from "country-data-list";
import { get_endpoint } from "lib/PixeldrainAPI";
export type CheckoutState = {
country: typeof countries.all[0]
provider: PaymentProvider
amount: number
vat: number
name: string
}
export type PaymentProvider = {
icon: string
name: string
label: string
crypto?: boolean
need_name?: boolean
country_filter?: string[]
};
export const payment_providers: PaymentProvider[] = [
{
icon: "paypal",
name: "paypal",
label: "PayPal",
}, {
icon: "bancontact",
name: "bancontact",
label: "Bancontact",
need_name: true,
country_filter: ["BE"],
}, {
icon: "eps",
name: "eps",
label: "EPS",
need_name: true,
country_filter: ["AT"],
}, {
icon: "ideal",
name: "ideal",
label: "iDEAL",
need_name: true,
country_filter: ["NL"],
}, {
icon: "p24",
name: "p24",
label: "Przelewy24",
need_name: true,
country_filter: ["PL"],
}, {
// icon: "trustly",
// name: "trustly",
// label: "Trustly",
// need_name: true,
// country_filter: ["AT", "DE", "DK", "EE", "ES", "FI", "GB", "LT", "LV", "NL", "NO", "SE"]
// }, {
icon: "bitcoin",
name: "btc",
label: "Bitcoin",
crypto: true,
}, {
icon: "dogecoin",
name: "doge",
label: "Dogecoin",
crypto: true,
}, {
icon: "monero",
name: "xmr",
label: "Monero",
crypto: true,
},
]
export const checkout = async (state: CheckoutState) => {
if (state.amount < 10) {
alert("Amount needs to be at least €10")
return
} else if (state.provider.need_name && !state.name) {
alert("Name is required for this provider")
return
}
const form = new FormData()
form.set("amount", String(state.amount * 1e6))
form.set("network", state.provider.name)
form.set("country", state.country.alpha2)
if (state.provider.need_name) {
form.set("name", state.name)
}
try {
const resp = await fetch(
get_endpoint() + "/user/invoice",
{ method: "POST", body: form },
)
if (resp.status >= 400) {
let json = await resp.json()
throw json.message
}
window.location = (await resp.json()).checkout_url
} catch (err) {
alert(err)
}
}

View File

@@ -0,0 +1,97 @@
<script lang="ts">
import Euro from "util/Euro.svelte";
import { checkout, type CheckoutState } from "./CheckoutLib";
export let state: CheckoutState
export let loading: boolean
const amounts = [10, 20, 50, 100, 200, 500, 1000, 2000, 5000]
let amount = 20
const submit = async (e: SubmitEvent) => {
e.preventDefault()
loading = true
state.amount = amount
await checkout(state)
loading = false
}
</script>
{#if state.provider.crypto === true}
<p style="text-align: initial;" class="highlight_blue">
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
credit will not be added to your account. Pay close attention
when sending a payment from an online exchange, sometimes they
will subtract the fees from the amount sent which will cause the
payment to fail.
</p>
{/if}
<form class="amount_grid" on:submit={submit}>
<div class="span3">Please choose an amount</div>
{#each amounts as a}
<button
on:click|preventDefault={() => amount = a}
class="amount_button"
class:button_highlight={amount === a}
>
€ {a}
</button>
{/each}
<div class="span3 custom_amount">
<div>Custom amount €</div>
<input type="number" bind:value={amount} min="10"/>
</div>
<div class="span2" style="text-align: initial;">
Total including VAT:
<Euro amount={(amount*1e6) + (amount*1e6)*(state.vat/100)}/>
</div>
<button type="submit" class="button_highlight">
<i class="icon">paid</i> Checkout
</button>
</form>
<hr/>
<p style="text-align: initial;">
This Pixeldrain 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>.
</p>
<style>
.amount_grid {
max-width: 500px;
gap: 4px;
display: inline-grid;
align-items: center;
grid-template-columns: 1fr 1fr 1fr;
}
.amount_button {
font-size: 1.1em;
line-height: 1.6em;
justify-content: center;
}
.span2 { grid-column: span 2; }
.span3 { grid-column: span 3; }
.custom_amount {
display: flex;
flex-direction: row;
}
.custom_amount > div {
display: flex;
flex: 0 0 auto;
align-items: center;
}
.custom_amount > input {
flex: 1 1 auto;
}
</style>

View File

@@ -0,0 +1,122 @@
<script lang="ts">
import { countries } from "country-data-list";
import { get_misc_vat_rate, put_user } from "lib/PixeldrainAPI";
import { payment_providers, type CheckoutState } from "./CheckoutLib";
export let state: CheckoutState
export let loading: boolean
let country_input = ""
const set_country = async (e?: Event) => {
loading = true
if (e !== undefined) {
e.preventDefault()
}
if (countries[country_input] === undefined) {
alert("Please enter a valid country code")
return
}
const c = countries[country_input]
// We always clear the provider field after picking a country, as providers
// vary per country. If we did not clear the provider then we may end up
// with an invalid combination
state.provider = null
try {
// Save the user's country for later use
await put_user({checkout_country: c.alpha3})
const vat_rate = await get_misc_vat_rate(c.alpha3)
state.vat = vat_rate.vat*100
state.country = c
} catch (err) {
alert(err)
} finally {
loading = false
}
}
const format_country = (c: typeof countries.all[0]) => {
let str = ""
if (c.emoji !== undefined) {
str += c.emoji + " "
} else {
str += "🌐 "
}
str += c.name+" "
str += "("+c.alpha2+", "
str += c.alpha3+")"
return str
}
</script>
<div>
Please pick your country of residence
</div>
<div>
<form on:submit|preventDefault={set_country} class="country_form">
<div class="country_search">
<datalist id="country_picker">
{#each countries.all.filter(c => c.status === "assigned") as c}
<option value={c.alpha2}>{format_country(c)}</option>
{/each}
</datalist>
<input
bind:value={country_input}
type="text"
list="country_picker"
placeholder="Search for country"
style="flex: 1 1 auto;">
<button type="submit" class="button_highlight" style="flex: 0 0 auto;">
<i class="icon">send</i>
Continue
</button>
</div>
<select bind:value={country_input} on:dblclick={set_country} style="padding: 0;" size="10">
{#each countries.all.filter(c => c.status === "assigned") as c}
<option value={c.alpha2}>{format_country(c)}</option>
{/each}
</select>
</form>
</div>
<hr/>
<div>
We support the following payment processors
</div>
<div class="processors">
{#each payment_providers as p (p.name)}
<div>
<img class="bankicon" src="/res/img/payment_providers/{p.icon}.svg" alt={p.label} title={p.label}/>
{p.label}
</div>
{/each}
</div>
<style>
.country_form {
display: flex;
flex-direction: column;
margin: auto;
width: 600px;
max-width: 100%;
}
.country_search {
display: flex;
flex-direction: row;
}
.processors {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
}
.processors > div {
display: flex;
align-items: center;
gap: 5px;
margin: 3px;
}
.bankicon {
width: 2em;
}
</style>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
import { put_user } from "lib/PixeldrainAPI";
import { type CheckoutState } from "./CheckoutLib";
export let state: CheckoutState
let name: string
const submit = async (e: SubmitEvent) => {
e.preventDefault()
try {
await put_user({checkout_name: name})
} catch(err) {
alert("Failed to update user:"+ err)
}
state.name = name
}
</script>
<form class="name_form" on:submit={submit}>
<div>
This payment provider requires a name for checkout. Please enter your
full name below
</div>
<input bind:value={name} type="text" autocomplete="name"/>
<button type="submit" class="button_highlight" style="align-self: end;">
Continue
<i class="icon">chevron_right</i>
</button>
</form>
<hr/>
<p style="text-align: initial;">
This Pixeldrain 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>.
</p>
<style>
.name_form {
display: flex;
flex-direction: column;
margin: auto;
width: 500px;
max-width: 100%;
}
</style>

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import { put_user } from "lib/PixeldrainAPI";
import { payment_providers, type CheckoutState, type PaymentProvider } from "./CheckoutLib";
export let state: CheckoutState
const set_provider = async (p: PaymentProvider) => {
try {
await put_user({checkout_provider: p.name})
} catch(err) {
alert("Failed to update user:"+ err)
}
state.provider = p
}
</script>
<span>Please select a payment provider</span>
<div class="providers">
{#each payment_providers as p (p.name)}
{#if p.country_filter === undefined || p.country_filter.includes(state.country.alpha2)}
<button on:click={() => set_provider(p)}>
<img src="/res/img/payment_providers/{p.icon}.svg" alt={p.label} title={p.label}/>
{p.label}
</button>
{/if}
{/each}
</div>
<style>
.providers {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(8em, 1fr));
}
.providers > button {
flex-direction: column;
justify-content: space-around;
}
.providers > button > img {
max-width: 3em;
max-height: 3em;
}
</style>