Skip to content

Commit

Permalink
feat(frontend): adds vip qr code modal (#3991)
Browse files Browse the repository at this point in the history
# Motivation

Vip users should be able to create reward codes and get qr codes to show
them to other clients.

# Changes

- adds vip reward modal

# Tests

**qr code modal:**
<img width="431" alt="image"
src="https://github.com/user-attachments/assets/e7f23b6b-d43c-41a0-b62a-a1301a6f9ca2"
/>

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
  • Loading branch information
BonomoAlessandro and github-actions[bot] authored Jan 9, 2025
1 parent 0750e76 commit cefc22b
Show file tree
Hide file tree
Showing 9 changed files with 279 additions and 2 deletions.
10 changes: 8 additions & 2 deletions src/frontend/src/lib/components/core/Menu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import IconlyUfo from '$lib/components/icons/iconly/IconlyUfo.svelte';
import LicenseLink from '$lib/components/license-agreement/LicenseLink.svelte';
import ChangelogLink from '$lib/components/navigation/ChangelogLink.svelte';
import VipQrCodeModal from '$lib/components/qr/VipQrCodeModal.svelte';
import ButtonIcon from '$lib/components/ui/ButtonIcon.svelte';
import ButtonMenu from '$lib/components/ui/ButtonMenu.svelte';
import ExternalLink from '$lib/components/ui/ExternalLink.svelte';
Expand All @@ -31,9 +32,11 @@
NAVIGATION_MENU_VIP_BUTTON
} from '$lib/constants/test-ids.constants';
import { authIdentity } from '$lib/derived/auth.derived';
import { modalVipQrCode } from '$lib/derived/modal.derived';
import { networkId } from '$lib/derived/network.derived';
import { isVipUser } from '$lib/services/reward-code.services';
import { i18n } from '$lib/stores/i18n.store';
import { modalStore } from '$lib/stores/modal.store';
import {
isRouteActivity,
isRouteDappExplorer,
Expand Down Expand Up @@ -158,11 +161,10 @@
{/if}

{#if isVip}
<!-- TODO: implements on:click function -->
<ButtonMenu
ariaLabel={$i18n.navigation.alt.vip_qr_code}
testId={NAVIGATION_MENU_VIP_BUTTON}
on:click={() => {}}
on:click={modalStore.openVipQrCode}
>
<IconVipQr size="20" />
{$i18n.navigation.text.vip_qr_code}
Expand Down Expand Up @@ -204,3 +206,7 @@
</span>
</div>
</Popover>

{#if $modalVipQrCode}
<VipQrCodeModal />
{/if}
134 changes: 134 additions & 0 deletions src/frontend/src/lib/components/qr/VipQrCodeModal.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<script lang="ts">
import { Modal, QRCode } from '@dfinity/gix-components';
import { isNullish, nonNullish } from '@dfinity/utils';
import { onDestroy, onMount } from 'svelte';
import IconAstronautHelmet from '$lib/components/icons/IconAstronautHelmet.svelte';
import ReceiveCopy from '$lib/components/receive/ReceiveCopy.svelte';
import Button from '$lib/components/ui/Button.svelte';
import ButtonCloseModal from '$lib/components/ui/ButtonCloseModal.svelte';
import ButtonGroup from '$lib/components/ui/ButtonGroup.svelte';
import ContentWithToolbar from '$lib/components/ui/ContentWithToolbar.svelte';
import SkeletonText from '$lib/components/ui/SkeletonText.svelte';
import { VIP_CODE_REGENERATE_INTERVAL_IN_SECONDS } from '$lib/constants/app.constants';
import {
VIP_CODE_REGENERATE_BUTTON,
VIP_QR_CODE_COPY_BUTTON
} from '$lib/constants/test-ids.constants';
import { authIdentity } from '$lib/derived/auth.derived';
import { nullishSignOut } from '$lib/services/auth.services';
import { getNewReward } from '$lib/services/reward-code.services';
import { i18n } from '$lib/stores/i18n.store';
import { modalStore } from '$lib/stores/modal.store';
import { replacePlaceholders } from '$lib/utils/i18n.utils';
let counter = VIP_CODE_REGENERATE_INTERVAL_IN_SECONDS;
let countdown: NodeJS.Timeout | undefined;
const maxRetriesToGetRewardCode = 3;
let retriesToGetRewardCode = 0;
let code: string;
const generateCode = async () => {
if (isNullish($authIdentity)) {
await nullishSignOut();
return;
}
const vipReward = await getNewReward($authIdentity);
if (nonNullish(vipReward)) {
code = vipReward.code;
} else {
retriesToGetRewardCode++;
}
};
const regenerateCode = async () => {
clearInterval(countdown);
if (retriesToGetRewardCode >= maxRetriesToGetRewardCode) {
return;
}
await generateCode();
counter = VIP_CODE_REGENERATE_INTERVAL_IN_SECONDS;
countdown = setInterval(intervalFunction, 1000);
};
const intervalFunction = async () => {
counter--;
if (counter === 0) {
await regenerateCode();
}
};
const onVisibilityChange = () => {
if (document.hidden) {
clearInterval(countdown);
} else {
countdown = setInterval(intervalFunction, 1000);
}
};
onMount(regenerateCode);
onDestroy(() => clearInterval(countdown));
let qrCodeUrl;
$: qrCodeUrl = `${window.location.origin}/?code=${code}`;
</script>

<svelte:window on:visibilitychange={onVisibilityChange} />

<Modal on:nnsClose={modalStore.close}>
<svelte:fragment slot="title"
><span class="text-xl">{$i18n.vip.invitation.text.title}</span>
</svelte:fragment>

<ContentWithToolbar>
<div class="mx-auto mb-4 aspect-square h-80 max-h-[44vh] max-w-full p-4">
{#if nonNullish(code)}
<QRCode value={qrCodeUrl}>
<div slot="logo" class="flex items-center justify-center rounded-lg bg-white p-2">
<IconAstronautHelmet />
</div>
</QRCode>
{/if}
</div>

{#if nonNullish(code)}
<div class="flex items-center justify-between gap-4 rounded-lg bg-brand-subtle px-3 py-2">
<output class="break-all">{qrCodeUrl}</output>
<ReceiveCopy
address={qrCodeUrl}
copyAriaLabel={$i18n.vip.invitation.text.invitation_link_copied}
testId={VIP_QR_CODE_COPY_BUTTON}
/>
</div>

<span class="mb-4 block w-full pt-3 text-center text-sm text-tertiary">
{#if 0 >= counter}
<span class="animate-pulse">{$i18n.vip.invitation.text.generating_new_code}</span>
{:else}
{replacePlaceholders($i18n.vip.invitation.text.regenerate_countdown_text, {
$counter: counter.toString()
})}
{/if}
</span>
{:else}
<span class="w-full"><SkeletonText /></span>
{/if}

<ButtonGroup slot="toolbar">
<ButtonCloseModal />
<Button
paddingSmall
colorStyle="primary"
type="button"
fullWidth
on:click={regenerateCode}
testId={VIP_CODE_REGENERATE_BUTTON}
>
{$i18n.vip.invitation.text.generate_new_link}
</Button>
</ButtonGroup>
</ContentWithToolbar>
</Modal>
3 changes: 3 additions & 0 deletions src/frontend/src/lib/constants/app.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,3 +115,6 @@ export const ZERO = BigNumber.from(0n);
// Wallets
export const WALLET_TIMER_INTERVAL_MILLIS = (SECONDS_IN_MINUTE / 2) * 1000; // 30 seconds in milliseconds
export const WALLET_PAGINATION = 10n;

// VIP
export const VIP_CODE_REGENERATE_INTERVAL_IN_SECONDS = 45;
3 changes: 3 additions & 0 deletions src/frontend/src/lib/constants/test-ids.constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,6 @@ export const TOKEN_MENU_ETH = 'token-menu-eth';
export const TOKEN_MENU_ETH_BUTTON = 'token-menu-eth-button';
export const TOKEN_MENU_BTC = 'token-menu-btc';
export const TOKEN_MENU_BTC_BUTTON = 'token-menu-btc-button';

export const VIP_QR_CODE_COPY_BUTTON = 'vip-qr-code-copy-button';
export const VIP_CODE_REGENERATE_BUTTON = 'vip-code-regenerate-button';
4 changes: 4 additions & 0 deletions src/frontend/src/lib/derived/modal.derived.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ export const modalAboutWhyOisy: Readable<boolean> = derived(
modalStore,
($modalStore) => $modalStore?.type === 'about-why-oisy'
);
export const modalVipQrCode: Readable<boolean> = derived(
modalStore,
($modalStore) => $modalStore?.type === 'vip-qr-code'
);
export const modalDAppDetails: Readable<boolean> = derived(
modalStore,
($modalStore) => $modalStore?.type === 'dapp-details'
Expand Down
9 changes: 9 additions & 0 deletions src/frontend/src/lib/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,15 @@
"loading_user_data": "Failed to load user data from reward canister.",
"claiming_reward": "Error while claiming reward."
}
},
"invitation": {
"text": {
"title": "Generate invitation link",
"invitation_link_copied": "Invitation link copied to clipboard.",
"generate_new_link": "Generate new link",
"generating_new_code": "Generating new code",
"regenerate_countdown_text": "New link will be generated in $counter sec"
}
}
},
"signer": {
Expand Down
3 changes: 3 additions & 0 deletions src/frontend/src/lib/stores/modal.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export interface Modal<T> {
| 'ic-token'
| 'receive-bitcoin'
| 'about-why-oisy'
| 'vip-qr-code'
| 'dapp-details'
| 'successful-reward'
| 'failed-reward';
Expand Down Expand Up @@ -73,6 +74,7 @@ export interface ModalStore<T> extends Readable<ModalData<T>> {
openIcToken: () => void;
openReceiveBitcoin: () => void;
openAboutWhyOisy: () => void;
openVipQrCode: () => void;
openDappDetails: <D extends T>(data: D) => void;
openSuccessfulReward: () => void;
openFailedReward: () => void;
Expand Down Expand Up @@ -122,6 +124,7 @@ const initModalStore = <T>(): ModalStore<T> => {
openIcToken: setType('ic-token'),
openReceiveBitcoin: setType('receive-bitcoin'),
openAboutWhyOisy: setType('about-why-oisy'),
openVipQrCode: setType('vip-qr-code'),
openDappDetails: setTypeWithData('dapp-details'),
openSuccessfulReward: setType('successful-reward'),
openFailedReward: setType('failed-reward'),
Expand Down
9 changes: 9 additions & 0 deletions src/frontend/src/lib/types/i18n.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -675,6 +675,15 @@ interface I18nVip {
};
error: { loading_reward: string; loading_user_data: string; claiming_reward: string };
};
invitation: {
text: {
title: string;
invitation_link_copied: string;
generate_new_link: string;
generating_new_code: string;
regenerate_countdown_text: string;
};
};
}

interface I18nSigner {
Expand Down
106 changes: 106 additions & 0 deletions src/frontend/src/tests/lib/components/qr/VipQrCodeModal.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import type { NewVipRewardResponse } from '$declarations/rewards/rewards.did';
import * as rewardApi from '$lib/api/reward.api';
import VipQrCodeModal from '$lib/components/qr/VipQrCodeModal.svelte';
import {
VIP_CODE_REGENERATE_BUTTON,
VIP_QR_CODE_COPY_BUTTON
} from '$lib/constants/test-ids.constants';
import * as authStore from '$lib/derived/auth.derived';
import { mockIdentity } from '$tests/mocks/identity.mock';
import type { Identity } from '@dfinity/agent';
import { render, waitFor } from '@testing-library/svelte';
import { readable } from 'svelte/store';
import { vi } from 'vitest';

describe('VipQrCodeModal', () => {
const qrCodeSelector = `div[data-tid="qr-code"]`;
const urlSelector = `output`;
const copyButtonSelector = `button[data-tid=${VIP_QR_CODE_COPY_BUTTON}]`;
const regenerateButtonSelector = `button[data-tid=${VIP_CODE_REGENERATE_BUTTON}]`;

const mockAuthStore = (value: Identity | null = mockIdentity) =>
vi.spyOn(authStore, 'authIdentity', 'get').mockImplementation(() => readable(value));

const mockedNewRewardResponse: NewVipRewardResponse = {
VipReward: {
code: '1234567890'
}
};

it('should render the vip qr code modal items', async () => {
mockAuthStore();
vi.spyOn(rewardApi, 'getNewVipReward').mockResolvedValue(mockedNewRewardResponse);

const { container } = render(VipQrCodeModal);

await waitFor(() => {
const qrCode: HTMLDivElement | null = container.querySelector(qrCodeSelector);
const qrCodeURL: HTMLOutputElement | null = container.querySelector(urlSelector);
const copyButton: HTMLButtonElement | null = container.querySelector(copyButtonSelector);
const regenerateButton: HTMLButtonElement | null =
container.querySelector(regenerateButtonSelector);

if (
qrCode === null ||
qrCodeURL === null ||
copyButton === null ||
regenerateButton === null
) {
throw new Error('one of the elements is not yet loaded.');
}

expect(qrCode).toBeInTheDocument();

expect(qrCodeURL).toBeInTheDocument();
expect(qrCodeURL?.textContent?.includes(mockedNewRewardResponse.VipReward.code));

expect(copyButton).toBeInTheDocument();

expect(regenerateButton).toBeInTheDocument();
});
});

it('should regenerate reward code', async () => {
const regeneratedNewRewardResponse: NewVipRewardResponse = {
VipReward: {
code: '0987654321'
}
};

mockAuthStore();
vi.spyOn(rewardApi, 'getNewVipReward').mockResolvedValue(mockedNewRewardResponse);

const { container } = render(VipQrCodeModal);

await waitFor(() => {
const qrCodeURL: HTMLOutputElement | null = container.querySelector(urlSelector);
const regenerateButton: HTMLButtonElement | null =
container.querySelector(regenerateButtonSelector);

if (qrCodeURL === null || regenerateButton === null) {
throw new Error('one of the elements is not yet loaded.');
}

expect(qrCodeURL).toBeInTheDocument();
expect(qrCodeURL?.textContent?.includes(mockedNewRewardResponse.VipReward.code));

expect(regenerateButton).toBeInTheDocument();

vi.spyOn(rewardApi, 'getNewVipReward').mockResolvedValue(regeneratedNewRewardResponse);
regenerateButton.click();
});

await waitFor(() => {
const reloadedQrCodeUrl: HTMLOutputElement | null = container.querySelector(urlSelector);

if (
reloadedQrCodeUrl === null ||
!reloadedQrCodeUrl?.textContent?.includes(regeneratedNewRewardResponse.VipReward.code)
) {
throw new Error('reward code not yet reloaded.');
}

expect(reloadedQrCodeUrl?.textContent?.includes(regeneratedNewRewardResponse.VipReward.code));
});
});
});

0 comments on commit cefc22b

Please sign in to comment.