Refactor forms

- add a link to the faucet page from `Rinkeby` badge in header
- refactor the `<TextInput>` component to include
  - a loading indicator
  - a keyboard shortcut to submit the input by pressing the `enter` key
  - add a keyboard shortcut hint <kbd>⏎</kbd> that shows up whenever the
    input is ready to be submitted
  - a validation message
- align all pages with a single input form to have a similar look and feel
- improve validation messages and error handling in all forms

Signed-off-by: Rūdolfs Ošiņš <rudolfs@osins.org>
This commit is contained in:
Rūdolfs Ošiņš
2022-10-08 10:00:19 +02:00
parent f215f9f4fd
commit 3ce7aada0f
11 changed files with 629 additions and 491 deletions
+3 -1
View File
@@ -190,7 +190,9 @@
<div class="right">
{#if config && config.network.name === "rinkeby"}
<span class="network">Rinkeby</span>
<a use:link href="/faucet">
<span class="network">Rinkeby</span>
</a>
{:else if config && config.network.name === "homestead"}
<!-- Don't show anything -->
{:else}
+6 -7
View File
@@ -1,21 +1,20 @@
<script lang="ts">
import { onDestroy } from "svelte";
import debounce from "lodash/debounce";
export let small = false;
export let center = false;
export let fadeIn = false;
export let margins = false;
export let condensed = false;
export let noDelay = false;
let show: boolean = false;
const timeout = window.setTimeout(() => {
if (noDelay) {
show = true;
}, 200);
onDestroy(() => {
window.clearTimeout(timeout);
});
} else {
debounce(() => (show = true), 200)();
}
</script>
<style>
+46 -48
View File
@@ -128,7 +128,6 @@
import { createEventDispatcher } from "svelte";
import { navigate } from "svelte-routing";
import Loading from "@app/Loading.svelte";
import TextInput from "@app/TextInput.svelte";
import { unreachable } from "@app/utils";
@@ -148,49 +147,52 @@
debounce(() => (shaking = false), 500)();
}
async function search(event: KeyboardEvent) {
if (event.key === "Enter") {
if (input === "") {
return;
}
loading = true;
const query = input;
const searchResult = await searchProjectsAndProfiles(input, config);
if (searchResult.type === "nothing") {
shake();
} else if (searchResult.type === "error") {
// TODO: show some kind of notification to the user.
shake();
} else if (searchResult.type === "singleProfile") {
input = "";
navigate(`/${searchResult.id}`, { replace: true });
dispatch("finished");
} else if (searchResult.type === "singleProject") {
input = "";
navigate(`/seeds/${searchResult.seedHost}/${searchResult.id}`, {
replace: true,
});
dispatch("finished");
} else if (searchResult.type === "projectsAndProfiles") {
// TODO: show some kind of notification about any errors to the user.
input = "";
dispatch("search", {
query,
results: searchResult.projectsAndProfiles,
});
dispatch("finished");
} else {
unreachable(searchResult);
}
loading = false;
async function search() {
if (!valid) {
return;
}
loading = true;
const query = input;
const searchResult = await searchProjectsAndProfiles(input, config);
if (searchResult.type === "nothing") {
shake();
} else if (searchResult.type === "error") {
// TODO: show some kind of notification to the user.
shake();
} else if (searchResult.type === "singleProfile") {
input = "";
navigate(`/${searchResult.id}`, { replace: true });
dispatch("finished");
} else if (searchResult.type === "singleProject") {
input = "";
navigate(`/seeds/${searchResult.seedHost}/${searchResult.id}`, {
replace: true,
});
dispatch("finished");
} else if (searchResult.type === "projectsAndProfiles") {
// TODO: show some kind of notification about any errors to the user.
input = "";
dispatch("search", {
query,
results: searchResult.projectsAndProfiles,
});
dispatch("finished");
} else {
unreachable(searchResult);
}
loading = false;
}
$: valid = input !== "";
</script>
<style>
.search {
display: flex;
}
.shaking {
animation: horizontal-shaking 0.35s;
}
@@ -213,17 +215,13 @@
}
</style>
<div class:shaking>
<div class="search" class:shaking>
<TextInput
variant="dashed"
valid={input !== ""}
{loading}
disabled={loading}
bind:value={input}
on:keydown={search}
placeholder="Search a name or address…">
<svelte:fragment slot="right">
{#if loading}
<Loading small />
{/if}
</svelte:fragment>
</TextInput>
on:submit={search}
placeholder="Search a name or address…" />
</div>
+75 -20
View File
@@ -1,6 +1,9 @@
<script lang="ts">
<script lang="ts" strictEvents>
import { createEventDispatcher } from "svelte";
import { onMount } from "svelte";
import Loading from "@app/Loading.svelte";
export let name: string | undefined = undefined;
export let placeholder: string | undefined = undefined;
export let value: string | undefined = undefined;
@@ -9,15 +12,29 @@
export let autofocus: boolean = false;
export let disabled: boolean = false;
export let loading: boolean = false;
export let valid: boolean = false;
export let validationMessage: string | undefined = undefined;
const dispatch = createEventDispatcher<{
submit: boolean;
}>();
let rightContainerWidth: number;
let inputElement: HTMLInputElement | undefined = undefined;
onMount(() => {
if (autofocus && inputElement) {
// We set preventScroll to true for Svelte animations to work.
inputElement.focus({ preventScroll: true });
}
});
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Enter") {
dispatch("submit");
}
}
</script>
<style>
@@ -26,6 +43,8 @@
flex-direction: column;
margin: 0;
position: relative;
flex: 1;
height: 2.5rem;
}
input {
background: transparent;
@@ -37,6 +56,7 @@
margin: 0;
outline: none;
text-overflow: ellipsis;
width: 100%;
}
input::placeholder {
color: var(--color-secondary);
@@ -66,30 +86,65 @@
height: var(--button-regular-height);
padding-right: 1rem;
padding-left: 0.5rem;
gap: 0.5rem;
}
.validation-message {
color: var(--color-negative);
font-size: var(--font-size-small);
margin-left: 1rem;
position: relative;
margin-top: 0.5rem;
}
.validation-wrapper {
position: absolute;
width: 100%;
}
.key-hint {
border-radius: 0.25rem;
color: var(--color-secondary);
background-color: var(--color-secondary-2);
padding: 0 0.5rem;
}
</style>
<div class="wrapper">
<input
class:regular={variant === "regular"}
class:dashed={variant === "dashed"}
style:padding-right={rightContainerWidth
? `${rightContainerWidth}px`
: "auto"}
bind:this={inputElement}
type="text"
{name}
{placeholder}
{disabled}
bind:value
on:input
on:keydown|stopPropagation
on:click
on:change />
<div class="validation-wrapper">
<input
class:regular={variant === "regular"}
class:dashed={variant === "dashed"}
style:padding-right={rightContainerWidth
? `${rightContainerWidth}px`
: "auto"}
bind:this={inputElement}
type="text"
{name}
{placeholder}
{disabled}
bind:value
on:input
on:keydown|stopPropagation={handleKeydown}
on:click
on:change />
{#if $$slots.right}
<div class="right-container" bind:clientWidth={rightContainerWidth}>
<slot name="right" />
{#if $$slots.right}
<slot name="right" />
{/if}
{#if loading}
<Loading small noDelay />
{/if}
{#if valid && !loading}
<div class="key-hint">⏎</div>
{/if}
</div>
{/if}
{#if validationMessage}
<div class="validation-message">
{validationMessage}
</div>
{/if}
</div>
</div>
+123 -110
View File
@@ -1,24 +1,24 @@
<script lang="ts">
import type { Config } from "@app/config";
import type { BigNumber } from "@ethersproject/bignumber";
import { session } from "@app/session";
import { setOpenGraphMetaTag, toWei } from "@app/utils";
import { formatEther } from "@ethersproject/units";
import { navigate } from "svelte-routing";
import {
calculateTimeLock,
getMaxWithdrawAmount,
lastWithdrawalByUser,
calculateTimeLock,
} from "./lib";
import Button from "@app/Button.svelte";
import TextInput from "@app/TextInput.svelte";
export let config: Config;
let amount: string;
let maxWithdrawAmount: BigNumber;
let lastWithdrawal: BigNumber;
let error: string | undefined;
let amount: string = "";
let loading: boolean = false;
let validationMessage: string | undefined = undefined;
let valid: boolean = false;
setOpenGraphMetaTag([
{ prop: "og:title", content: "Radicle Faucet" },
@@ -26,132 +26,145 @@
{ prop: "og:url", content: window.location.href },
]);
async function withdraw() {
const [state, message] = await isAbleToWithdraw(amount);
if (state === true) navigate("/faucet/withdraw", { state: { amount } });
else error = message;
}
async function withdraw(amount: string) {
if (!valid || !$session) {
return;
}
async function isAbleToWithdraw(amount: string): Promise<[boolean, string?]> {
loading = true;
try {
if (!$session) {
return [false];
}
if (!amount || amount === "0") {
return [false, "Not able to withdraw zero tokens"];
}
if (toWei(amount).gt(maxWithdrawAmount)) {
return [
false,
`Reduce amount, max withdrawal is ${formatEther(maxWithdrawAmount)}`,
];
}
const currentTime = new Date().getTime();
const timelock = await calculateTimeLock(amount, $session.signer, config);
// Converting a 10 digit to 13 digit timestamp by multiplying by 1000
// since JS doesn't display a correct Date string when passing a 10 digit timestamp.
const nextAvailableWithdraw = lastWithdrawal.add(timelock).mul(1000);
if (nextAvailableWithdraw.gt(currentTime)) {
return [
false,
`Not ready to withdraw, return after ${new Date(
nextAvailableWithdraw.toNumber(),
).toLocaleString("en-GB")}`,
];
const lastWithdrawal = await lastWithdrawalByUser(
$session.signer,
config,
);
const maxWithdrawAmount = await getMaxWithdrawAmount(
$session.signer,
config,
);
if (toWei(amount).gt(maxWithdrawAmount)) {
validationMessage = `Reduce amount, max withdrawal is ${formatEther(
maxWithdrawAmount,
)}.`;
return;
}
return [true];
} catch (e: any) {
console.error(e);
error = e.message;
// Converting a 10 digit to 13 digit timestamp by multiplying by 1000
// since JS doesn't display a correct Date string when passing a 10 digit
// timestamp.
const nextAvailableWithdraw = lastWithdrawal.add(timelock).mul(1000);
if (nextAvailableWithdraw.gt(currentTime)) {
validationMessage = `Not ready to withdraw, return after ${new Date(
nextAvailableWithdraw.toNumber(),
).toLocaleString("en-GB")}`;
return;
}
return [false];
navigate("/faucet/withdraw", { state: { amount } });
} catch (error) {
validationMessage = "There was an error, check the dev console.";
console.error(error);
} finally {
loading = false;
}
}
$: if ($session) {
getMaxWithdrawAmount($session.signer, config).then(
x => (maxWithdrawAmount = x),
);
lastWithdrawalByUser($session.signer, config).then(
x => (lastWithdrawal = x),
);
function validate(amount: string) {
if (amount === "") {
return { valid: false };
}
if (isNaN(Number(amount)) || Number(amount) <= 0) {
return {
valid: false,
validationMessage: "Please enter a positive number.",
};
}
return { valid: true };
}
$: ({ valid, validationMessage } = validate(amount));
</script>
<style>
.input-caption {
font-size: var(--font-size-medium);
text-align: left;
color: var(--color-secondary);
margin-bottom: 2rem;
margin-left: 0.5rem;
}
.input-main {
main {
display: flex;
align-items: flex-start;
color: var(--color-secondary);
flex-direction: column;
}
.error {
padding-left: 1rem;
padding-top: 1rem;
}
.description :global(p) {
padding: 0;
margin: 0;
}
.description.invalid {
color: var(--color-negative) !important;
}
.name {
display: flex;
gap: 1.5rem;
height: 100%;
justify-content: center;
padding-bottom: 24vh;
padding-top: 5rem;
width: 28rem;
}
.title {
color: var(--color-secondary);
font-size: var(--font-size-medium);
}
.subtitle {
color: var(--color-secondary);
}
.form {
display: flex;
gap: 1rem;
}
</style>
<svelte:head>
<title>Radicle: Faucet</title>
<title>Radicle &ndash; Faucet</title>
</svelte:head>
<main class="off-centered">
<div>
{#if config.network.name === "homestead"}
<div class="input-caption">
To get RAD tokens on <span class="txt-bold">
{config.network.name}
</span>
, please check the known exchanges.
</div>
{:else if !$session}
<div class="input-caption">
To get RAD tokens on <span class="txt-bold">
{config.network.name}
</span>
, please connect your wallet.
</div>
{:else}
<div class="input-caption">
Obtain RAD tokens on <span class="txt-bold">
{config.network.name}
</span>
</div>
<div class="input-main">
<div class="name">
<div style="width: 14.5rem;">
<TextInput
placeholder="Set amount to withdraw"
bind:value={amount}
on:input={() => (error = "")} />
</div>
<Button variant="primary" on:click={withdraw}>Withdraw</Button>
</div>
{#if error}
<div class="error description invalid txt-small faded">
{error}
</div>
{/if}
</div>
{/if}
<main>
<div class="title">
Obtain RAD tokens on <span class="txt-bold">
{config.network.name}
</span>
</div>
{#if config.network.name === "homestead"}
<div class="subtitle">
To get RAD tokens on <span class="txt-bold">
{config.network.name},
</span>
please
<br />
check
<a class="link" href="https://docs.radicle.xyz/get-involved/obtain-rad">
popular exchanges
</a>
&#8203;.
</div>
{:else if !$session}
<div class="subtitle">
To get RAD tokens on <span class="txt-bold">
{config.network.name}
</span>
&#8203;,
<br />
please connect your wallet.
</div>
{:else}
<div class="form">
<TextInput
autofocus
placeholder="Enter amount to withdraw"
{validationMessage}
on:submit={() => {
withdraw(amount);
}}
bind:value={amount}
{valid}
{loading} />
<Button
variant="primary"
on:click={() => withdraw(amount)}
disabled={!valid || loading}>
Withdraw
</Button>
</div>
{/if}
</main>
+76 -70
View File
@@ -8,102 +8,108 @@
export let config: Config;
let input = "";
let valid: boolean = false;
let validationMessage: string | undefined = undefined;
function register() {
navigate(`/registrations/${label}/form`);
if (!valid) {
return;
}
navigate(`/registrations/${ensName}/form`);
}
function validate(input: string): string[] {
const errors: string[] = [];
function validate(input: string) {
if (input === "") {
return { valid: false };
}
if (input && input.includes(".")) {
errors.push("Please do not use dots as separators.");
return {
valid: false,
validationMessage: "Please do not use dots as separators.",
};
}
if (input && input.length < 2) {
errors.push("Please enter a minimum of 2 characters.");
return {
valid: false,
validationMessage: "Please enter a minimum of 2 characters.",
};
}
if (input && input.length > 128) {
errors.push("Please enter a maximum of 128 characters.");
return {
valid: false,
validationMessage: "Please enter a maximum of 128 characters.",
};
}
return errors;
return { valid: true };
}
$: label = input.trim();
$: errors = validate(label);
$: ensName = input.trim();
$: ({ valid, validationMessage } = validate(ensName));
</script>
<style>
div.input-caption {
font-size: var(--font-size-medium);
text-align: left;
margin-left: 1.5rem;
padding-left: 1.5rem;
color: var(--color-secondary);
}
div.input-main {
main {
display: flex;
align-items: center;
flex-direction: row;
margin-left: 1.5rem;
flex-direction: column;
gap: 1.5rem;
height: 100%;
justify-content: center;
padding-bottom: 24vh;
padding-top: 5rem;
width: 32rem;
}
.title {
color: var(--color-secondary);
font-size: var(--font-size-medium);
}
.subtitle {
color: var(--color-secondary);
}
.name {
margin: 1rem;
width: 22rem;
}
.input-info {
position: absolute;
font-style: italic;
margin-top: 0.1rem;
}
.explainer {
width: 28rem;
margin: 1rem 0;
.form {
display: flex;
gap: 1rem;
}
</style>
<svelte:head>
<title>Radicle: Register</title>
<title>Radicle &ndash; Register</title>
</svelte:head>
<main class="off-centered">
<div>
<div class="input-caption">
Register a <span class="txt-bold">{config.registrar.domain}</span>
name
<div class="txt-small explainer">
Register a unique name with our ENS registrar, under the <span
class="txt-bold">
radicle.eth
</span>
domain (e.g. cloudhead.radicle.eth). Radicle names never expire and free
to register.
</div>
</div>
<div class="input-main">
<span class="name">
<TextInput bind:value={input} autofocus>
<svelte:fragment slot="right">
.{config.registrar.domain}
</svelte:fragment>
</TextInput>
{#if errors}
<div class="input-info">
{#each errors as error}
<div>{error}</div>
{/each}
</div>
{/if}
</span>
<main>
<div class="title">
Register a <span class="txt-bold">{config.registrar.domain}</span>
name
</div>
<Button
disabled={!label || errors.length !== 0}
variant="primary"
size="regular"
on:click={register}>
Check
</Button>
</div>
<div class="subtitle">
Register a unique name with our ENS registrar, under <br />
the
<span class="txt-bold">radicle.eth</span>
domain (e.g. cloudhead.radicle.eth).
<br />
Radicle names never expire and free to register.
</div>
<div class="form">
<TextInput
bind:value={input}
autofocus
on:submit={register}
{valid}
{validationMessage}>
<svelte:fragment slot="right">
.{config.registrar.domain}
</svelte:fragment>
</TextInput>
<Button
disabled={!valid}
variant="primary"
size="regular"
on:click={register}>
Check
</Button>
</div>
</main>
+153 -118
View File
@@ -1,52 +1,78 @@
<script lang="ts">
import { State, state } from "./state";
import { getInfo, withdrawVested } from "./vesting";
import type { VestingInfo } from "./vesting";
import type { Session } from "@app/session";
import type { Config } from "@app/config";
import Modal from "@app/Modal.svelte";
import type { VestingInfo } from "./vesting";
import * as utils from "@app/utils";
import Address from "@app/Address.svelte";
import { formatAddress, isAddressEqual } from "@app/utils";
import Button from "@app/Button.svelte";
import Modal from "@app/Modal.svelte";
import TextInput from "@app/TextInput.svelte";
import { state, getInfo, withdrawVested } from "./vesting";
export let config: Config;
export let session: Session | null;
let contractAddress = "";
let info: VestingInfo | null = null;
let validationMessage: string | undefined = undefined;
let valid: boolean = false;
async function loadContract(config: Config) {
state.set(State.Loading);
info = await getInfo(contractAddress, config);
state.set(State.Idle);
}
if (!valid) {
return;
}
function reset() {
info = null;
state.set(State.Idle);
state.set("loading");
try {
info = await getInfo(contractAddress, config);
} catch (error) {
validationMessage =
"Couldn't load contract, check dev console for details.";
console.error(error);
}
state.set("idle");
}
$: isBeneficiary =
info && session && isAddressEqual(info.beneficiary, session.address);
info && session && utils.isAddressEqual(info.beneficiary, session.address);
function validate(address: string) {
if (address === "") {
return { valid: false };
}
if (!utils.isAddress(address)) {
return {
valid: false,
validationMessage: "Please enter a valid Ethereum address.",
};
}
return { valid: true };
}
$: ({ valid, validationMessage } = validate(contractAddress));
</script>
<style>
.input-caption {
font-size: var(--font-size-medium);
text-align: left;
margin-left: 1.5rem;
margin-bottom: 2rem;
color: var(--color-secondary);
}
.input-main {
main {
display: flex;
align-items: center;
flex-direction: row;
margin-left: 1.5rem;
color: var(--color-secondary);
gap: 1rem;
flex-direction: column;
gap: 1.5rem;
height: 100%;
justify-content: center;
padding-bottom: 24vh;
padding-top: 5rem;
width: 38rem;
}
.title {
color: var(--color-secondary);
font-size: var(--font-size-medium);
}
.form {
display: flex;
gap: 1rem;
}
table {
table-layout: fixed;
@@ -63,95 +89,104 @@
<title>Radicle &ndash; Vesting</title>
</svelte:head>
<main class="off-centered">
<div>
{#if info}
<Modal>
<span slot="title">
{contractAddress}
</span>
<span slot="body">
{#if $state === State.Withdrawn}
Tokens successfully withdrawn to {formatAddress(info.beneficiary)}.
{:else}
<table>
<tr>
<td class="label">Beneficiary</td>
<td>
<Address
{config}
address={info.beneficiary}
compact
resolve />
</td>
</tr>
<tr>
<td class="label">Allocation</td>
<td>
{info.totalVesting}
<span class="txt-bold">{info.symbol}</span>
</td>
</tr>
<tr>
<td class="label">Withdrawn</td>
<td>
{info.withdrawn}
<span class="txt-bold">{info.symbol}</span>
</td>
</tr>
<tr>
<td class="label">Withdrawable</td>
<td>
{info.withdrawableBalance}
<span class="txt-bold">{info.symbol}</span>
</td>
</tr>
</table>
{/if}
</span>
<span slot="actions">
{#if isBeneficiary}
{#if $state === State.WithdrawingSign}
<Button disabled waiting={true} variant="primary">
Waiting for signature…
</Button>
{:else if $state === State.Withdrawing}
<Button disabled waiting={true} variant="primary">
Withdrawing…
</Button>
{:else if $state === State.Idle}
<Button
on:click={() => withdrawVested(contractAddress, config)}
variant="primary">
Withdraw
</Button>
{/if}
{/if}
<Button on:click={reset} variant="primary">Back</Button>
</span>
</Modal>
{:else}
<div class="input-caption">
Enter your Radicle <span class="txt-bold">vesting contract</span>
address
</div>
<div class="input-main">
<span class="name">
<div style="width: 25rem;">
<TextInput
autofocus
disabled={$state === State.Loading}
bind:value={contractAddress} />
</div>
</span>
<Button
on:click={() => loadContract(config)}
variant="primary"
waiting={$state === State.Loading}
disabled={$state === State.Loading}>
Load
</Button>
</div>
{/if}
</div>
</main>
{#if info}
<Modal>
<span slot="title">
{contractAddress}
</span>
<span slot="body">
{#if $state === "withdrawn"}
Tokens successfully withdrawn to {utils.formatAddress(
info.beneficiary,
)}.
{:else}
<table>
<tr>
<td class="label">Beneficiary</td>
<td>
<Address {config} address={info.beneficiary} compact resolve />
</td>
</tr>
<tr>
<td class="label">Allocation</td>
<td>
{info.totalVesting}
<span class="txt-bold">{info.symbol}</span>
</td>
</tr>
<tr>
<td class="label">Withdrawn</td>
<td>
{info.withdrawn}
<span class="txt-bold">{info.symbol}</span>
</td>
</tr>
<tr>
<td class="label">Withdrawable</td>
<td>
{info.withdrawableBalance}
<span class="txt-bold">{info.symbol}</span>
</td>
</tr>
</table>
{/if}
</span>
<span slot="actions">
{#if isBeneficiary}
{#if $state === "withdrawingSign"}
<Button disabled waiting={true} variant="primary">
Waiting for signature…
</Button>
{:else if $state === "withdrawing"}
<Button disabled waiting={true} variant="primary">
Withdrawing…
</Button>
{:else if $state === "idle"}
<Button
on:click={() => withdrawVested(contractAddress, config)}
variant="primary">
Withdraw
</Button>
{/if}
{/if}
<Button
on:click={() => {
info = null;
state.set("idle");
}}
variant="primary">
Back
</Button>
</span>
</Modal>
{:else}
<main>
<div class="title">
Your Radicle <span class="txt-bold">vesting contract</span>
</div>
<div class="form">
<TextInput
autofocus
placeholder="Enter vesting contract address"
{valid}
{validationMessage}
loading={$state === "loading"}
disabled={$state === "loading"}
on:submit={() => {
loadContract(config);
}}
bind:value={contractAddress} />
<Button
on:click={() => loadContract(config)}
variant="primary"
waiting={$state === "loading"}
disabled={!valid || $state === "loading"}>
Load
</Button>
</div>
</main>
{/if}
-18
View File
@@ -1,18 +0,0 @@
import { writable } from "svelte/store";
export enum State {
Error = -1,
Idle = 0,
Loading = 1,
Found = 2,
NotFound = 3,
WithdrawingSign = 4,
Withdrawing = 5,
Withdrawn = 6,
}
export const state = writable(State.Idle);
state.subscribe(s => {
console.debug("vesting.state", s);
});
+16 -10
View File
@@ -1,9 +1,11 @@
import { ethers } from "ethers";
import { formatBalance } from "@app/utils";
import * as session from "@app/session";
import { State, state } from "./state";
import type { Config } from "@app/config";
import { ethers } from "ethers";
import { assert } from "@app/error";
import { writable } from "svelte/store";
import * as session from "@app/session";
import * as utils from "@app/utils";
export interface VestingInfo {
token: string;
@@ -14,6 +16,10 @@ export interface VestingInfo {
withdrawn: string;
}
export const state = writable<
"idle" | "loading" | "withdrawingSign" | "withdrawing" | "withdrawn"
>("idle");
export async function withdrawVested(
address: string,
config: Config,
@@ -27,14 +33,14 @@ export async function withdrawVested(
);
const signer = config.signer;
state.set(State.WithdrawingSign);
state.set("withdrawingSign");
const tx = await contract.connect(signer).withdrawVested();
state.set(State.Withdrawing);
state.set("withdrawing");
await tx.wait();
session.state.refreshBalance(config);
state.set(State.Withdrawn);
state.set("withdrawn");
}
export async function getInfo(
@@ -63,8 +69,8 @@ export async function getInfo(
token: token,
symbol: symbol,
beneficiary: beneficiary,
totalVesting: formatBalance(total),
withdrawableBalance: formatBalance(withdrawable),
withdrawn: formatBalance(withdrawn),
totalVesting: utils.formatBalance(total),
withdrawableBalance: utils.formatBalance(withdrawable),
withdrawn: utils.formatBalance(withdrawn),
};
}
+110 -73
View File
@@ -1,72 +1,86 @@
<script lang="ts">
import { createEventDispatcher } from "svelte";
import Modal from "@app/Modal.svelte";
<script lang="ts" strictEvents>
import type { Org } from "@app/base/orgs/Org";
import type { Config } from "@app/config";
import { formatAddress, isAddress } from "@app/utils";
import Loading from "@app/Loading.svelte";
import { assert } from "@app/error";
import { createEventDispatcher } from "svelte";
import * as utils from "@app/utils";
import Address from "@app/Address.svelte";
import Button from "@app/Button.svelte";
import Loading from "@app/Loading.svelte";
import Modal from "@app/Modal.svelte";
import TextInput from "@app/TextInput.svelte";
import type { Org } from "@app/base/orgs/Org";
const dispatch = createEventDispatcher();
import { formatAddress, isAddress } from "@app/utils";
export let org: Org;
export let config: Config;
enum State {
Idle,
const dispatch = createEventDispatcher<{ close: boolean }>();
// Single sig states.
Signing,
Pending,
Success,
let state:
| "idle"
// Single sig.
| "signing"
| "pending"
| "success"
// Multi sig.
| "proposing"
| "proposed"
| "failed" = "idle";
// Multi sig states.
Proposing,
Proposed,
Failed,
}
let newOwner: string | undefined = undefined;
let state = State.Idle;
let error: string | null = null;
const resetForm = () => {
state = State.Idle;
};
let newOwner: string = "";
let errorMessage: string | null = null;
let valid: boolean = false;
let validationMessage: string | undefined = undefined;
const onSubmit = async () => {
assert(newOwner);
if (!isAddress(newOwner)) {
state = State.Failed;
error = `"${newOwner}" is not a valid Ethereum address.`;
if (!valid) {
return;
}
try {
if (org && (await utils.isSafe(org.owner, config))) {
state = State.Proposing;
state = "proposing";
await org.setOwnerMultisig(newOwner, config);
state = State.Proposed;
state = "proposed";
} else {
state = State.Signing;
state = "signing";
const tx = await org.setOwner(newOwner, config);
state = State.Pending;
state = "pending";
await tx.wait();
state = State.Success;
state = "success";
}
} catch (e: any) {
console.error(e);
state = State.Failed;
error = e.message;
} catch (error: unknown) {
if (error instanceof Error) {
errorMessage = error.message;
} else {
errorMessage = "Unknown error.";
}
console.error(error);
state = "failed";
}
};
function validate(address: string) {
if (address === "") {
return { valid: false };
}
if (state !== "idle") {
return { valid: false };
}
if (!isAddress(address)) {
return {
valid: false,
validationMessage: "Please enter a valid Ethereum address.",
};
}
return { valid: true };
}
$: ({ valid, validationMessage } = validate(newOwner));
</script>
<style>
@@ -74,10 +88,16 @@
gap: 1rem;
display: flex;
justify-content: center;
margin-top: 2rem;
}
.message {
padding-left: 1rem;
padding-top: 1rem;
word-break: break-all;
}
</style>
{#if state === State.Success && newOwner}
{#if state === "success" && newOwner}
<Modal floating small>
<div slot="title">✅</div>
@@ -96,7 +116,7 @@
</Button>
</div>
</Modal>
{:else if state === State.Proposed && org}
{:else if state === "proposed" && org}
<Modal floating>
<div slot="title">🪴</div>
@@ -118,63 +138,80 @@
</Button>
</div>
</Modal>
{:else}
<Modal floating error={state === State.Failed} small={state === State.Failed}>
{:else if state === "failed"}
<Modal floating error small>
<div slot="title">
🔑
<div>Transfer ownership</div>
</div>
<div slot="subtitle">
{#if state === State.Signing}
<div class="message">
{errorMessage}
</div>
</div>
<div slot="actions" class="actions">
<Button
variant="negative"
on:click={() => {
state = "idle";
}}>
Back
</Button>
</div>
</Modal>
{:else}
<Modal floating>
<div slot="title">
🔑
<div>Transfer ownership</div>
</div>
<div slot="subtitle">
{#if state === "signing"}
Please confirm the transaction in your wallet.
{:else if state === State.Pending}
{:else if state === "pending"}
Waiting for transaction to be processed…
{:else if state === State.Proposing && org}
{:else if state === "proposing" && org}
Proposal is being submitted to the safe
<span class="txt-bold">{formatAddress(org.owner)}</span>
, please sign the transaction in your wallet.
{:else if state === State.Idle}
{:else if state === "idle"}
Transfer the ownership of Org <span class="txt-bold">
{formatAddress(org.address)}
</span>
to a new address.
{:else if state === State.Failed}
<div class="error">
{error}
</div>
{/if}
</div>
<div slot="body" style="display: flex;justify-content: center;">
{#if state === State.Idle}
<div style="width: 25rem;">
<TextInput
autofocus
disabled={state !== State.Idle}
bind:value={newOwner} />
{#if state === "idle"}
<div style="position: absolute; text-align: left;">
<div style="width: 31rem;">
<TextInput
autofocus
{valid}
{validationMessage}
on:submit={onSubmit}
disabled={state !== "idle"}
bind:value={newOwner} />
</div>
</div>
{:else if state === State.Pending || state === State.Proposing || state === State.Signing}
{:else if state === "pending" || state === "proposing" || state === "signing"}
<Loading small center />
{:else if state === State.Failed}
<!-- ... -->
{/if}
</div>
<div slot="actions" class="actions">
{#if state === State.Signing}
{#if state === "signing"}
<Button variant="text" on:click={() => dispatch("close")}>
Cancel
</Button>
{:else if state === State.Pending}
{:else if state === "pending"}
<Button variant="text" on:click={() => dispatch("close")}>Close</Button>
{:else if state === State.Failed}
<Button variant="negative" on:click={resetForm}>Back</Button>
{:else}
<Button
variant="primary"
on:click={onSubmit}
disabled={!newOwner || state !== State.Idle}>
<Button variant="primary" on:click={onSubmit} disabled={!valid}>
Submit
</Button>
+21 -16
View File
@@ -6,12 +6,12 @@
import { formatAddress, isAddressEqual } from "@app/utils";
import { Org } from "@app/base/orgs/Org";
import type { User } from "@app/base/users/User";
import Loading from "@app/Loading.svelte";
import Error from "@app/Error.svelte";
import ErrorModal from "@app/Error.svelte";
import Address from "@app/Address.svelte";
import * as utils from "@app/utils";
import Button from "@app/Button.svelte";
import TextInput from "@app/TextInput.svelte";
import Loading from "@app/Loading.svelte";
const dispatch = createEventDispatcher();
@@ -44,6 +44,9 @@
let error: string | null = null;
const onSubmit = async () => {
if (!valid) {
return;
}
state = State.Checking;
const domain = `${name}.${config.registrar.domain}`;
@@ -62,15 +65,21 @@
await tx.wait();
state = State.Success;
}
} catch (e: any) {
} catch (e) {
console.error(e);
state = State.Failed;
error = e.message;
if (e instanceof Error) {
error = e.message;
} else {
error = "Unknown error. Check dev console for details.";
}
}
} else {
state = State.Mismatch;
}
};
$: valid = name !== "" && state === State.Idle;
</script>
<style>
@@ -120,7 +129,7 @@
</div>
</Modal>
{:else if state === State.Mismatch}
<Error floating title="🧣" on:close>
<ErrorModal floating title="🧣" on:close>
The name <span class="txt-bold">{name}.{config.registrar.domain}</span>
does not resolve to
<span class="txt-bold">{entity.address}</span>
@@ -137,9 +146,9 @@
Close
</Button>
</div>
</Error>
</ErrorModal>
{:else if state === State.Failed && error}
<Error floating title="Transaction failed" message={error} on:close />
<ErrorModal floating title="Transaction failed" message={error} on:close />
{:else}
<Modal floating>
<div slot="title">
@@ -172,6 +181,9 @@
<TextInput
autofocus
disabled={state !== State.Idle}
on:submit={onSubmit}
loading={state === State.Checking}
{valid}
bind:value={name}>
<svelte:fragment slot="right">
.{config.registrar.domain}
@@ -193,15 +205,8 @@
Close
</Button>
{:else}
<Button
variant="primary"
on:click={onSubmit}
disabled={!name || state !== State.Idle}>
{#if state === State.Checking}
Checking…
{:else}
Submit
{/if}
<Button variant="primary" on:click={onSubmit} disabled={!valid}>
Submit
</Button>
<Button variant="text" on:click={() => dispatch("close")}>