-
Notifications
You must be signed in to change notification settings - Fork 25
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(frontend): adds vip qr code modal (#3991)
# 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
1 parent
0750e76
commit cefc22b
Showing
9 changed files
with
279 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
134 changes: 134 additions & 0 deletions
134
src/frontend/src/lib/components/qr/VipQrCodeModal.svelte
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
106 changes: 106 additions & 0 deletions
106
src/frontend/src/tests/lib/components/qr/VipQrCodeModal.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); | ||
}); | ||
}); | ||
}); |