-
Notifications
You must be signed in to change notification settings - Fork 22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(frontend): adds vip qr code modal #3991
Changes from 130 commits
01eb37a
cee9183
a4db206
cd54a85
3580e27
7ca84a2
64cfb2e
e12f611
1d7f8c1
1bb1789
872a275
011897c
f97e89b
25f50e2
8c3e70a
b2b46e9
401fe5f
3db52c7
cb60999
0ed7b08
903d651
446e377
b7c0133
55f3efd
6da0dea
ba5e7aa
1fd8a1f
ba49b3b
7ab28ed
0cec4de
27b4373
ebe723e
d0725a5
983518f
3c63f59
af68afa
f58477b
93addf3
7560091
6d394e9
63d18aa
a05b56c
0b4e930
6e66f5c
9a1e806
e24552c
063b64e
b4f8a50
056ad5f
516e50c
04a52c0
da24239
228348c
a33c282
9d4ae15
c876584
4e22b1f
9aea3d7
bb5470d
e8fbeaf
6c76bd2
411aba2
cfa31b6
978428f
7d56943
128805b
15e6a1f
5b0643c
49d0f2b
60c50d3
cd4ee86
33b3124
067779d
a8b9ef5
5d1a290
e365bef
c82c209
61d03a8
7164bc1
dcc7665
25a91e9
5bf2b59
4b8fc89
61de068
06b40fc
fb48e03
4f93f50
220af6d
e143ab7
ef35d7f
362862e
3623b6b
be16955
ecf90d2
886864b
f12318b
ce19d3e
5fcc81b
9cdb053
bae43d8
d66eeb2
5e1b2f1
16cf4e3
3fd3317
67d0479
1bcb260
8f84839
1acf902
5833036
93e784f
7766f8e
03d2249
dac8cf2
709372f
fee9a2e
3cb6545
7499b43
f25aea8
d38c314
6dbf8fb
c40dea1
c0ae70e
10c3fe0
9125b9e
3b67b73
d085779
5861429
c35b523
81adf43
ea258c4
9ada8ae
5230570
075c640
643d0ac
71ec264
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) { | ||
peterpeterparker marked this conversation as resolved.
Show resolved
Hide resolved
|
||
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}`; | ||
peterpeterparker marked this conversation as resolved.
Show resolved
Hide resolved
|
||
</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"> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. shouldn't <div slot="logo" class="mx-auto mb-4 aspect-square h-80 max-h-[44vh] max-w-full p-4">
{#if nonNullish(code)}
<QRCode value={qrCodeUrl}>
<div class="flex items-center justify-center rounded-lg bg-white p-2"> There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @AntonioVentilii No it is a part of the QRCode |
||
<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>{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"> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. i think There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No, it's not default |
||
{#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 styleClass="flex-col sm:flex-row" slot="toolbar"> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. not really sure about this: we don't do it in any of the modals, nor in any usage of the Buttons... if we are going to change it, better to do it in another PR for all the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @AntonioVentilii What exactly do you mean with
I mean like this we just enable to add some custom styling to the element There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. indeed, but why only for this modal? why not for all? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's defined like this in the design. The buttons should swap the alignment if the page gets smaller. To achieve this on this modal we need something like this. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @AntonioVentilii @artkorotkikh-dfinity just wrote me that he updated the designs. |
||
<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> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,8 @@ | ||
<script lang="ts"> | ||
export let testId: string | undefined = undefined; | ||
export let styleClass = ''; | ||
</script> | ||
|
||
<div class="mb-2 flex w-full gap-3" data-tid={testId}> | ||
<div class={`mb-2 flex w-full gap-3 ${styleClass}`} data-tid={testId}> | ||
<slot /> | ||
</div> |
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)); | ||
}); | ||
}); | ||
}); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
shouldn't the retries be asserted here? With the current implementation, we might indeed prevent any further calls, but the countdown would still proceed. Not a strong opinion, just a thought.