Radish alpha
r
Radicle web interface
Radicle
Git (anonymous pull)
Log in to clone via SSH
Refactor forms
Rūdolfs Ošiņš committed 3 years ago
commit 3ce7aada0f8202aedbe717d093d71ed6fb3a4621
parent f215f9f4fd54587fa0e5ba21f1289314e6e2a954
11 files changed +619 -481
modified src/Header.svelte
@@ -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}
modified src/Loading.svelte
@@ -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>
modified src/Search.svelte
@@ -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;
-
      }
+
  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;
+
    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>
modified src/TextInput.svelte
@@ -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>
modified src/base/faucet/Index.svelte
@@ -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);
+
      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;
+
      }
+

      // 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.
+
      // 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")}`,
-
        ];
+
        validationMessage = `Not ready to withdraw, return after ${new Date(
+
          nextAvailableWithdraw.toNumber(),
+
        ).toLocaleString("en-GB")}`;
+
        return;
      }

-
      return [true];
-
    } catch (e: any) {
-
      console.error(e);
-
      error = e.message;
-

-
      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;
+
    gap: 1.5rem;
+
    height: 100%;
+
    justify-content: center;
+
    padding-bottom: 24vh;
+
    padding-top: 5rem;
+
    width: 28rem;
  }
-
  .error {
-
    padding-left: 1rem;
-
    padding-top: 1rem;
-
  }
-
  .description :global(p) {
-
    padding: 0;
-
    margin: 0;
+
  .title {
+
    color: var(--color-secondary);
+
    font-size: var(--font-size-medium);
  }
-
  .description.invalid {
-
    color: var(--color-negative) !important;
+
  .subtitle {
+
    color: var(--color-secondary);
  }
-
  .name {
+
  .form {
    display: flex;
-
    gap: 1.5rem;
+
    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>
modified src/base/registrations/Index.svelte
@@ -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;
-
    color: var(--color-secondary);
+
    flex-direction: column;
+
    gap: 1.5rem;
+
    height: 100%;
+
    justify-content: center;
+
    padding-bottom: 24vh;
+
    padding-top: 5rem;
+
    width: 32rem;
  }
-
  .name {
-
    margin: 1rem;
-
    width: 22rem;
+
  .title {
+
    color: var(--color-secondary);
+
    font-size: var(--font-size-medium);
  }
-
  .input-info {
-
    position: absolute;
-
    font-style: italic;
-
    margin-top: 0.1rem;
+
  .subtitle {
+
    color: var(--color-secondary);
  }
-
  .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>
+

+
  <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={!label || errors.length !== 0}
-
        variant="primary"
-
        size="regular"
-
        on:click={register}>
-
        Check
-
      </Button>
-
    </div>
+
    <Button
+
      disabled={!valid}
+
      variant="primary"
+
      size="regular"
+
      on:click={register}>
+
      Check
+
    </Button>
  </div>
</main>
modified src/base/vesting/Index.svelte
@@ -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;
+
  main {
+
    display: flex;
+
    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);
  }
-
  .input-main {
+
  .form {
    display: flex;
-
    align-items: center;
-
    flex-direction: row;
-
    margin-left: 1.5rem;
-
    color: var(--color-secondary);
    gap: 1rem;
-
    justify-content: center;
  }
  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}
deleted src/base/vesting/state.ts
@@ -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);
-
});
modified src/base/vesting/vesting.ts
@@ -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),
  };
}
modified src/components/TransferOwnership.svelte
@@ -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,
-

-
    // Single sig states.
-
    Signing,
-
    Pending,
-
    Success,
+
  const dispatch = createEventDispatcher<{ close: boolean }>();

-
    // Multi sig states.
-
    Proposing,
-
    Proposed,
+
  let state:
+
    | "idle"
+
    // Single sig.
+
    | "signing"
+
    | "pending"
+
    | "success"
+
    // Multi sig.
+
    | "proposing"
+
    | "proposed"
+
    | "failed" = "idle";

-
    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 if state === "failed"}
+
  <Modal floating error small>
+
    <div slot="title">
+
      🔑
+
      <div>Transfer ownership</div>
+
    </div>
+

+
    <div slot="subtitle">
+
      <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 error={state === State.Failed} small={state === State.Failed}>
+
  <Modal floating>
    <div slot="title">
      🔑
      <div>Transfer ownership</div>
    </div>

    <div slot="subtitle">
-
      {#if state === State.Signing}
+
      {#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>

modified src/ens/SetName.svelte
@@ -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")}>