Skip to content

Commit

Permalink
GIX-2094: Buy ICP Modal (#3796)
Browse files Browse the repository at this point in the history
# Motivation

Allow users to easily buy ICPs from the NNS Dapp.

The purchase won't happen in the NNS Dapp though. Instead, the user will
be redirected to an external provider with the destination account
prefilled with their NNS Dapp main account.

# Changes

* New button in NnsAccountsFooter.
* Adapt Footer to display three buttons.
* New modal BuyIcpModal.
* Add new modal in AccountsModal.
* New mixing for a button with icon.

# Tests

* Add modals within the Accounts route TestIdWrapper. Otherwise, they
are not accessible with the page object.
* Test BuyIcpModal
* Test in Accounts route that user can use the new modal.
* Add PO and methods to perform all tests with POs.

# Todos

- [x] Add entry to changelog (if necessary).
  • Loading branch information
lmuntaner authored Nov 22, 2023
1 parent 734437d commit 7b6b4e7
Show file tree
Hide file tree
Showing 20 changed files with 317 additions and 11 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG-Nns-Dapp-unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ proposal is successful, the changes it released will be moved from this file to

#### Added

* Button to buy ICP with an external provider.

#### Changed

#### Deprecated
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/lib/assets/banxa-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
32 changes: 30 additions & 2 deletions frontend/src/lib/components/accounts/NnsAccountsFooter.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,26 @@
import { nonNullish } from "@dfinity/utils";
import ReceiveButton from "$lib/components/accounts/ReceiveButton.svelte";
import { syncAccounts } from "$lib/services/icp-accounts.services";
import { openAccountsModal } from "$lib/utils/modals.utils";
import { IconAdd } from "@dfinity/gix-components";
let modal: "NewTransaction" | undefined = undefined;
const openNewTransaction = () => (modal = "NewTransaction");
const closeModal = () => (modal = undefined);
// TODO: for performance reason use `loadBalance` to reload specific account
const reload = async () => await syncAccounts();
const openBuyIcpModal = () => {
openAccountsModal({
type: "buy-icp",
data: {
account: $icpAccountsStore.main,
reload,
canSelectAccount: false,
},
});
};
</script>

<TestIdWrapper testId="nns-accounts-footer-component">
Expand All @@ -22,14 +35,29 @@
{/if}

{#if nonNullish($icpAccountsStore)}
<Footer>
<Footer columns={3}>
<button
class="primary full-width"
class="secondary full-width"
on:click={openNewTransaction}
data-tid="open-new-transaction">{$i18n.accounts.send}</button
>

<ReceiveButton type="nns-receive" canSelectAccount {reload} />

<button
class="primary full-width with-icon"
on:click={openBuyIcpModal}
data-tid="buy-icp-button"
>
<IconAdd />{$i18n.accounts.buy_icp}
</button>
</Footer>
{/if}
</TestIdWrapper>

<style lang="scss">
@use "../../themes/mixins/button";
button {
@include button.with-icon;
}
</style>
14 changes: 12 additions & 2 deletions frontend/src/lib/components/layout/Footer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,15 @@
export let testId: string | undefined = undefined;
export let columns = 2;
let horizontalSpacing: string = "15%";
$: horizontalSpacing = columns <= 2 ? "15%" : "5%";
</script>

<footer data-tid={testId} style={`--footer-columns: ${columns}`}>
<footer
data-tid={testId}
style={`--footer-columns: ${columns}; --horizontal-spacing: ${horizontalSpacing}`}
>
<Toolbar>
<slot />
</Toolbar>
Expand All @@ -27,6 +33,7 @@
:global(.toolbar) {
align-items: end;
margin: 0 auto max(env(safe-area-inset-bottom), var(--padding-2x));
--actions-width: var(--horizontal-spacing);
}
:global(.main) {
Expand All @@ -48,7 +55,10 @@
@include media.min-width(small) {
grid-template-columns: repeat(
var(--footer-columns),
minmax(calc(var(--footer-main-inner-width) / 2), 180px)
minmax(
calc(var(--footer-main-inner-width) / var(--footer-columns)),
180px
)
);
min-width: auto;
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,13 @@
"main": "Main",
"balance": "Available Balance",
"send": "Send",
"buy_icp": "Buy ICP",
"buy_icp_banxa": "Buy ICP with Banxa",
"receiving_icp_address": "Receiving ICP Address",
"icp_token_utility": "ICP Token Utility",
"buy_icp_description": "<a href=\"https://internetcomputer.org/icp-tokens\" rel=\"noopener noreferrer\" aria-label=\"more info ICP token\" target=\"_blank\">ICP</a> is the native utility token of the Internet Computer Protocol. You can lock ICP into neurons to participate in the governance of the Internet Computer, swap it in SNS decentralization swaps or convert it into 'cycles' that are used to pay for resources of canister smart contracts.",
"buy_icp_note": "<strong>Disclaimer:</strong> Clicking the button below will take you to a third party website to complete your ICP transaction at your own risk.",
"banxa_logo_alt": "Banxa logo",
"icp_transaction_description": "ICP Transaction",
"sns_transaction_description": "$token Transaction",
"ckbtc_transaction_description": "$token Transaction",
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/lib/modals/accounts/AccountsModals.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import { snsOnlyProjectStore } from "$lib/derived/sns/sns-selected-project.derived";
import { selectedUniverseStore } from "$lib/derived/selected-universe.derived";
import IC_LOGO from "$lib/assets/icp.svg";
import BuyIcpModal from "./BuyIcpModal.svelte";
import type { Account } from "$lib/types/account";
let modal: AccountsModal | undefined;
const close = () => (modal = undefined);
Expand All @@ -20,12 +22,19 @@
let data: AccountsReceiveModalData | undefined;
$: data = (modal as AccountsModal | undefined)?.data;
let account: Account | undefined;
$: account = (modal as AccountsModal | undefined)?.data?.account;
const onNnsAccountsModal = ({ detail }: CustomEvent<AccountsModal>) =>
(modal = detail);
</script>

<svelte:window on:nnsAccountsModal={onNnsAccountsModal} />

{#if type === "buy-icp" && nonNullish(account)}
<BuyIcpModal on:nnsClose={close} {account} />
{/if}

{#if type === "nns-receive" && nonNullish(data)}
<NnsReceiveModal on:nnsClose={close} {data} />
{/if}
Expand Down
116 changes: 116 additions & 0 deletions frontend/src/lib/modals/accounts/BuyIcpModal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<script lang="ts">
import { Html, Modal } from "@dfinity/gix-components";
import type { Account } from "$lib/types/account";
import { i18n } from "$lib/stores/i18n";
import IdentifierHash from "$lib/components/ui/IdentifierHash.svelte";
import BANXA_LOGO from "$lib/assets/banxa-logo.svg";
export let account: Account;
// TODO: Move this to a util function? Or a service?
// Wait until we have the final URL to do this.
let queryParams: Record<string, string | number>;
$: queryParams = {
fiatAmount: 100,
fiatType: "USD",
coinAmount: 0.00244394,
coinType: "ICP",
lockFiat: "true",
blockchain: "BTC",
orderMode: "BUY",
backgroundColor: "2a1a47",
primaryColor: "9b6ef7",
secondaryColor: "8b55f6",
textColor: "ffffff",
walletAddress: account?.identifier,
};
let url: string;
// TODO: Move base url to an env variable https://dfinity.atlassian.net/browse/GIX-2095
$: url = `https://checkout.banxa.com/?${Object.entries(queryParams)
.map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
.join("&")}`;
</script>

<Modal testId="buy-icp-modal-component" on:nnsClose>
<span slot="title">{$i18n.accounts.buy_icp}</span>

<div class="content">
<div>
<h2>{$i18n.accounts.icp_token_utility}</h2>
<p><Html text={$i18n.accounts.buy_icp_description} /></p>
</div>

<p class="highlight"><Html text={$i18n.accounts.buy_icp_note} /></p>

<div>
<h3>{$i18n.accounts.receiving_icp_address}</h3>
<IdentifierHash identifier={account?.identifier} />
</div>
</div>

<div class="toolbar">
<a
class="button primary full-width with-icon"
href={url}
target="_blank"
data-tid="buy-icp-banxa-button"
rel="noreferrer noopener"
><img
loading="lazy"
src={BANXA_LOGO}
alt={$i18n.accounts.banxa_logo_alt}
draggable="false"
/>{$i18n.accounts.buy_icp_banxa}</a
>
</div>
</Modal>

<style lang="scss">
@use "../../themes/mixins/button";
.content {
display: flex;
flex-direction: column;
gap: var(--padding-2x);
}
.highlight {
background: var(--card-background-disabled);
color: var(--text-description);
padding: var(--padding-2x);
border-radius: var(--border-radius);
}
a.button {
box-sizing: border-box;
padding: var(--padding) var(--padding-2x);
border-radius: var(--border-radius);
border-top: 1px solid transparent;
border-bottom: 1px solid transparent;
position: relative;
min-height: var(--button-min-height);
font-weight: var(--font-weight-bold);
text-decoration: none;
&.primary {
background: var(--primary);
color: var(--primary-contrast);
&:hover,
&:focus {
background: var(--primary-shade);
}
}
&.full-width {
width: 100%;
}
&.with-icon {
@include button.with-icon;
}
}
</style>
12 changes: 6 additions & 6 deletions frontend/src/lib/routes/Accounts.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,13 @@
{:else if nonNullish($snsProjectSelectedStore)}
<SnsAccountsFooter />
{/if}
</TestIdWrapper>

{#if $isCkBTCUniverseStore}
<CkBTCAccountsModals />
{:else}
<AccountsModals />
{/if}
{#if $isCkBTCUniverseStore}
<CkBTCAccountsModals />
{:else}
<AccountsModals />
{/if}
</TestIdWrapper>

<style lang="scss">
main {
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/lib/themes/mixins/_button.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// TODO: Move to gix-components https://dfinity.atlassian.net/browse/GIX-2108
@mixin with-icon {
display: flex;
align-items: center;
justify-content: center;
gap: var(--padding-0_5x);
}
2 changes: 1 addition & 1 deletion frontend/src/lib/types/accounts.modal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { Account } from "$lib/types/account";

export type AccountsModalType = "nns-receive" | "sns-receive";
export type AccountsModalType = "nns-receive" | "sns-receive" | "buy-icp";

export interface AccountsModal {
type: AccountsModalType;
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/lib/types/i18n.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,13 @@ interface I18nAccounts {
main: string;
balance: string;
send: string;
buy_icp: string;
buy_icp_banxa: string;
receiving_icp_address: string;
icp_token_utility: string;
buy_icp_description: string;
buy_icp_note: string;
banxa_logo_alt: string;
icp_transaction_description: string;
sns_transaction_description: string;
ckbtc_transaction_description: string;
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 39 additions & 0 deletions frontend/src/tests/lib/modals/accounts/BuyIcpModal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import BuyIcpModal from "$lib/modals/accounts/BuyIcpModal.svelte";
import type { Account } from "$lib/types/account";
import { mockMainAccount } from "$tests/mocks/icp-accounts.store.mock";
import { BuyICPModalPo } from "$tests/page-objects/BuyICPModal.page-object";
import { JestPageObjectElement } from "$tests/page-objects/jest.page-object";
import { render } from "@testing-library/svelte";

describe("BuyIcpModal", () => {
const identifier =
"d4685b31b51450508aff0331584df7692a84467b680326f5c5f7d30ae711682f";
const account = {
...mockMainAccount,
identifier,
};

const renderModal = (account: Account = mockMainAccount) => {
const { container } = render(BuyIcpModal, { props: { account } });

return BuyICPModalPo.under(new JestPageObjectElement(container));
};

beforeEach(() => {
vi.clearAllMocks();
});

it("renders the account's identifier", async () => {
const po = renderModal(account);

expect(await po.getAccountIdentifier()).toEqual(identifier);
});

it("renders an anchor tag with URL to banxa with account identifier", async () => {
const po = renderModal();

expect(await po.getBanxaUrl()).toEqual(
`https://checkout.banxa.com/?fiatAmount=100&fiatType=USD&coinAmount=0.00244394&coinType=ICP&lockFiat=true&blockchain=BTC&orderMode=BUY&backgroundColor=2a1a47&primaryColor=9b6ef7&secondaryColor=8b55f6&textColor=ffffff&walletAddress=${identifier}`
);
});
});
Loading

0 comments on commit 7b6b4e7

Please sign in to comment.