diff --git a/src/frontend/src/lib/components/core/Menu.svelte b/src/frontend/src/lib/components/core/Menu.svelte index 683348024e..4e80490868 100644 --- a/src/frontend/src/lib/components/core/Menu.svelte +++ b/src/frontend/src/lib/components/core/Menu.svelte @@ -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'; @@ -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, @@ -158,11 +161,10 @@ {/if} {#if isVip} - {}} + on:click={modalStore.openVipQrCode} > {$i18n.navigation.text.vip_qr_code} @@ -204,3 +206,7 @@ + +{#if $modalVipQrCode} + +{/if} diff --git a/src/frontend/src/lib/components/qr/VipQrCodeModal.svelte b/src/frontend/src/lib/components/qr/VipQrCodeModal.svelte new file mode 100644 index 0000000000..6d68bd035c --- /dev/null +++ b/src/frontend/src/lib/components/qr/VipQrCodeModal.svelte @@ -0,0 +1,134 @@ + + + + + + {$i18n.vip.invitation.text.title} + + + +
+ {#if nonNullish(code)} + +
+ +
+
+ {/if} +
+ + {#if nonNullish(code)} +
+ {qrCodeUrl} + +
+ + + {#if 0 >= counter} + {$i18n.vip.invitation.text.generating_new_code} + {:else} + {replacePlaceholders($i18n.vip.invitation.text.regenerate_countdown_text, { + $counter: counter.toString() + })} + {/if} + + {:else} + + {/if} + + + + + +
+
diff --git a/src/frontend/src/lib/constants/app.constants.ts b/src/frontend/src/lib/constants/app.constants.ts index fb857b2383..0e6ea290e2 100644 --- a/src/frontend/src/lib/constants/app.constants.ts +++ b/src/frontend/src/lib/constants/app.constants.ts @@ -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; diff --git a/src/frontend/src/lib/constants/test-ids.constants.ts b/src/frontend/src/lib/constants/test-ids.constants.ts index e3e7d8750c..c460e11a32 100644 --- a/src/frontend/src/lib/constants/test-ids.constants.ts +++ b/src/frontend/src/lib/constants/test-ids.constants.ts @@ -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'; diff --git a/src/frontend/src/lib/derived/modal.derived.ts b/src/frontend/src/lib/derived/modal.derived.ts index c78d4112bf..effa10146f 100644 --- a/src/frontend/src/lib/derived/modal.derived.ts +++ b/src/frontend/src/lib/derived/modal.derived.ts @@ -121,6 +121,10 @@ export const modalAboutWhyOisy: Readable = derived( modalStore, ($modalStore) => $modalStore?.type === 'about-why-oisy' ); +export const modalVipQrCode: Readable = derived( + modalStore, + ($modalStore) => $modalStore?.type === 'vip-qr-code' +); export const modalDAppDetails: Readable = derived( modalStore, ($modalStore) => $modalStore?.type === 'dapp-details' diff --git a/src/frontend/src/lib/i18n/en.json b/src/frontend/src/lib/i18n/en.json index e9b3ccc3f7..2be4fb775c 100644 --- a/src/frontend/src/lib/i18n/en.json +++ b/src/frontend/src/lib/i18n/en.json @@ -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": { diff --git a/src/frontend/src/lib/stores/modal.store.ts b/src/frontend/src/lib/stores/modal.store.ts index 63c676b32a..537b65ad4b 100644 --- a/src/frontend/src/lib/stores/modal.store.ts +++ b/src/frontend/src/lib/stores/modal.store.ts @@ -33,6 +33,7 @@ export interface Modal { | 'ic-token' | 'receive-bitcoin' | 'about-why-oisy' + | 'vip-qr-code' | 'dapp-details' | 'successful-reward' | 'failed-reward'; @@ -73,6 +74,7 @@ export interface ModalStore extends Readable> { openIcToken: () => void; openReceiveBitcoin: () => void; openAboutWhyOisy: () => void; + openVipQrCode: () => void; openDappDetails: (data: D) => void; openSuccessfulReward: () => void; openFailedReward: () => void; @@ -122,6 +124,7 @@ const initModalStore = (): ModalStore => { 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'), diff --git a/src/frontend/src/lib/types/i18n.d.ts b/src/frontend/src/lib/types/i18n.d.ts index a67ea92397..7411564643 100644 --- a/src/frontend/src/lib/types/i18n.d.ts +++ b/src/frontend/src/lib/types/i18n.d.ts @@ -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 { diff --git a/src/frontend/src/tests/lib/components/qr/VipQrCodeModal.spec.ts b/src/frontend/src/tests/lib/components/qr/VipQrCodeModal.spec.ts new file mode 100644 index 0000000000..e9937478e6 --- /dev/null +++ b/src/frontend/src/tests/lib/components/qr/VipQrCodeModal.spec.ts @@ -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)); + }); + }); +});