diff --git a/dfx.json b/dfx.json index 32b23dc8c4..68ac79de67 100644 --- a/dfx.json +++ b/dfx.json @@ -297,7 +297,18 @@ "rewards": { "type": "custom", "candid": "https://github.com/dfinity/oisy-wallet/releases/download/rc0.0.0/rewards.did", - "wasm": "https://github.com/dfinity/oisy-wallet/releases/download/rc0.0.0/rewards.wasm.gz" + "wasm": "https://github.com/dfinity/oisy-wallet/releases/download/rc0.0.0/rewards.wasm.gz", + "remote": { + "id": { + "ic": "nynz6-haaaa-aaaan-qzqda-cai", + "staging": "vi6cu-aiaaa-aaaad-aad7q-cai", + "test_be_1": "k2sla-fiaaa-aaaag-atvfa-cai", + "test_fe_1": "vi6cu-aiaaa-aaaad-aad7q-cai", + "test_fe_2": "vi6cu-aiaaa-aaaad-aad7q-cai", + "test_fe_3": "vi6cu-aiaaa-aaaad-aad7q-cai", + "test_fe_4": "vi6cu-aiaaa-aaaad-aad7q-cai" + } + } } }, "defaults": { diff --git a/e2e/activity-page.spec.ts-snapshots/should-display-activity-page-1-Google-Chrome-darwin.png b/e2e/activity-page.spec.ts-snapshots/should-display-activity-page-1-Google-Chrome-darwin.png index f8fc9976fa..f53ea4add0 100644 Binary files a/e2e/activity-page.spec.ts-snapshots/should-display-activity-page-1-Google-Chrome-darwin.png and b/e2e/activity-page.spec.ts-snapshots/should-display-activity-page-1-Google-Chrome-darwin.png differ diff --git a/e2e/activity-page.spec.ts-snapshots/should-display-activity-page-1-Google-Chrome-linux.png b/e2e/activity-page.spec.ts-snapshots/should-display-activity-page-1-Google-Chrome-linux.png index 6396c33570..dcd0f5be4e 100644 Binary files a/e2e/activity-page.spec.ts-snapshots/should-display-activity-page-1-Google-Chrome-linux.png and b/e2e/activity-page.spec.ts-snapshots/should-display-activity-page-1-Google-Chrome-linux.png differ diff --git a/e2e/explorer-page.spec.ts-snapshots/should-display-explorer-page-1-Google-Chrome-darwin.png b/e2e/explorer-page.spec.ts-snapshots/should-display-explorer-page-1-Google-Chrome-darwin.png index a1aa231dfc..e793b766b9 100644 Binary files a/e2e/explorer-page.spec.ts-snapshots/should-display-explorer-page-1-Google-Chrome-darwin.png and b/e2e/explorer-page.spec.ts-snapshots/should-display-explorer-page-1-Google-Chrome-darwin.png differ diff --git a/e2e/explorer-page.spec.ts-snapshots/should-display-explorer-page-1-Google-Chrome-linux.png b/e2e/explorer-page.spec.ts-snapshots/should-display-explorer-page-1-Google-Chrome-linux.png index d249e2a4d4..9103cbef7c 100644 Binary files a/e2e/explorer-page.spec.ts-snapshots/should-display-explorer-page-1-Google-Chrome-linux.png and b/e2e/explorer-page.spec.ts-snapshots/should-display-explorer-page-1-Google-Chrome-linux.png differ diff --git a/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-in-state-1-Google-Chrome-darwin.png b/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-in-state-1-Google-Chrome-darwin.png index 416594804f..3fd3161a34 100644 Binary files a/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-in-state-1-Google-Chrome-darwin.png and b/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-in-state-1-Google-Chrome-darwin.png differ diff --git a/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-in-state-1-Google-Chrome-linux.png b/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-in-state-1-Google-Chrome-linux.png index 6a9a184ee0..225a5bf394 100644 Binary files a/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-in-state-1-Google-Chrome-linux.png and b/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-in-state-1-Google-Chrome-linux.png differ diff --git a/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-out-state-1-Google-Chrome-darwin.png b/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-out-state-1-Google-Chrome-darwin.png index 15ab016101..209735ee2f 100644 Binary files a/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-out-state-1-Google-Chrome-darwin.png and b/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-out-state-1-Google-Chrome-darwin.png differ diff --git a/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-out-state-1-Google-Chrome-linux.png b/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-out-state-1-Google-Chrome-linux.png index db263ce7be..50a7e39153 100644 Binary files a/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-out-state-1-Google-Chrome-linux.png and b/e2e/homepage.spec.ts-snapshots/should-display-homepage-in-logged-out-state-1-Google-Chrome-linux.png differ diff --git a/e2e/receive-tokens-modal.spec.ts-snapshots/should-display-receive-tokens-modal-1-Google-Chrome-darwin.png b/e2e/receive-tokens-modal.spec.ts-snapshots/should-display-receive-tokens-modal-1-Google-Chrome-darwin.png index 0e9413c785..8f435036eb 100644 Binary files a/e2e/receive-tokens-modal.spec.ts-snapshots/should-display-receive-tokens-modal-1-Google-Chrome-darwin.png and b/e2e/receive-tokens-modal.spec.ts-snapshots/should-display-receive-tokens-modal-1-Google-Chrome-darwin.png differ diff --git a/e2e/settings-page.spec.ts-snapshots/should-display-settings-page-1-Google-Chrome-darwin.png b/e2e/settings-page.spec.ts-snapshots/should-display-settings-page-1-Google-Chrome-darwin.png index 676d467912..6e36660672 100644 Binary files a/e2e/settings-page.spec.ts-snapshots/should-display-settings-page-1-Google-Chrome-darwin.png and b/e2e/settings-page.spec.ts-snapshots/should-display-settings-page-1-Google-Chrome-darwin.png differ diff --git a/e2e/settings-page.spec.ts-snapshots/should-display-settings-page-1-Google-Chrome-linux.png b/e2e/settings-page.spec.ts-snapshots/should-display-settings-page-1-Google-Chrome-linux.png index 8a56d0629c..299ccd48b2 100644 Binary files a/e2e/settings-page.spec.ts-snapshots/should-display-settings-page-1-Google-Chrome-linux.png and b/e2e/settings-page.spec.ts-snapshots/should-display-settings-page-1-Google-Chrome-linux.png differ diff --git a/e2e/transactions-page.spec.ts-snapshots/should-display-BTC-transactions-page-1-Google-Chrome-darwin.png b/e2e/transactions-page.spec.ts-snapshots/should-display-BTC-transactions-page-1-Google-Chrome-darwin.png index b15b8ea5ee..f3c4e68553 100644 Binary files a/e2e/transactions-page.spec.ts-snapshots/should-display-BTC-transactions-page-1-Google-Chrome-darwin.png and b/e2e/transactions-page.spec.ts-snapshots/should-display-BTC-transactions-page-1-Google-Chrome-darwin.png differ diff --git a/e2e/transactions-page.spec.ts-snapshots/should-display-BTC-transactions-page-1-Google-Chrome-linux.png b/e2e/transactions-page.spec.ts-snapshots/should-display-BTC-transactions-page-1-Google-Chrome-linux.png index b6c8e0b53d..6801003604 100644 Binary files a/e2e/transactions-page.spec.ts-snapshots/should-display-BTC-transactions-page-1-Google-Chrome-linux.png and b/e2e/transactions-page.spec.ts-snapshots/should-display-BTC-transactions-page-1-Google-Chrome-linux.png differ diff --git a/e2e/transactions-page.spec.ts-snapshots/should-display-ETH-transactions-page-1-Google-Chrome-darwin.png b/e2e/transactions-page.spec.ts-snapshots/should-display-ETH-transactions-page-1-Google-Chrome-darwin.png index ca5a032cc3..d75f2798f0 100644 Binary files a/e2e/transactions-page.spec.ts-snapshots/should-display-ETH-transactions-page-1-Google-Chrome-darwin.png and b/e2e/transactions-page.spec.ts-snapshots/should-display-ETH-transactions-page-1-Google-Chrome-darwin.png differ diff --git a/e2e/transactions-page.spec.ts-snapshots/should-display-ETH-transactions-page-1-Google-Chrome-linux.png b/e2e/transactions-page.spec.ts-snapshots/should-display-ETH-transactions-page-1-Google-Chrome-linux.png index 0c1994031a..95475bd384 100644 Binary files a/e2e/transactions-page.spec.ts-snapshots/should-display-ETH-transactions-page-1-Google-Chrome-linux.png and b/e2e/transactions-page.spec.ts-snapshots/should-display-ETH-transactions-page-1-Google-Chrome-linux.png differ diff --git a/e2e/transactions-page.spec.ts-snapshots/should-display-ICP-transactions-page-1-Google-Chrome-darwin.png b/e2e/transactions-page.spec.ts-snapshots/should-display-ICP-transactions-page-1-Google-Chrome-darwin.png index a9ed4dbf64..31e5b69c27 100644 Binary files a/e2e/transactions-page.spec.ts-snapshots/should-display-ICP-transactions-page-1-Google-Chrome-darwin.png and b/e2e/transactions-page.spec.ts-snapshots/should-display-ICP-transactions-page-1-Google-Chrome-darwin.png differ diff --git a/e2e/transactions-page.spec.ts-snapshots/should-display-ICP-transactions-page-1-Google-Chrome-linux.png b/e2e/transactions-page.spec.ts-snapshots/should-display-ICP-transactions-page-1-Google-Chrome-linux.png index 934b85ac0a..4252dd47f2 100644 Binary files a/e2e/transactions-page.spec.ts-snapshots/should-display-ICP-transactions-page-1-Google-Chrome-linux.png and b/e2e/transactions-page.spec.ts-snapshots/should-display-ICP-transactions-page-1-Google-Chrome-linux.png differ diff --git a/package-lock.json b/package-lock.json index 02dc7aed7b..135bafcae3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -60,7 +60,7 @@ "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-svelte": "^3.3.2", "prettier-plugin-tailwindcss": "^0.6.9", - "sass": "^1.83.0", + "sass": "^1.83.1", "svelte": "^4.2.19", "svelte-check": "^4.1.1", "tailwindcss": "^3.4.17", @@ -10876,9 +10876,9 @@ "dev": true }, "node_modules/sass": { - "version": "1.83.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.83.0.tgz", - "integrity": "sha512-qsSxlayzoOjdvXMVLkzF84DJFc2HZEL/rFyGIKbbilYtAvlCxyuzUeff9LawTn4btVnLKg75Z8MMr1lxU1lfGw==", + "version": "1.83.1", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.83.1.tgz", + "integrity": "sha512-EVJbDaEs4Rr3F0glJzFSOvtg2/oy2V/YrGFPqPY24UqcLDWcI9ZY5sN+qyO3c/QCZwzgfirvhXvINiJCE/OLcA==", "dev": true, "dependencies": { "chokidar": "^4.0.0", diff --git a/package.json b/package.json index deb2c210b2..2de580ca45 100644 --- a/package.json +++ b/package.json @@ -101,7 +101,7 @@ "prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-svelte": "^3.3.2", "prettier-plugin-tailwindcss": "^0.6.9", - "sass": "^1.83.0", + "sass": "^1.83.1", "svelte": "^4.2.19", "svelte-check": "^4.1.1", "tailwindcss": "^3.4.17", diff --git a/scripts/deploy.backend.sh b/scripts/deploy.backend.sh index 3b61bd396c..49739149ea 100755 --- a/scripts/deploy.backend.sh +++ b/scripts/deploy.backend.sh @@ -4,6 +4,8 @@ II_CANISTER_ID="$(dfx canister id internet_identity --network "${ENV:-local}")" BACKEND_CANISTER_ID="$(dfx canister id backend --network "${ENV:-local}")" POUH_ISSUER_CANISTER_ID="$(dfx canister id pouh_issuer --network "${ENV:-local}")" SIGNER_CANISTER_ID="$(dfx canister id signer --network "${ENV:-local}")" +# Rewards canister is optional for local deployments: +REWARDS_CANISTER_ID="$(dfx canister id rewards --network "${ENV:-local}" || [[ "${ENV:-local}" == "local" ]])" case $ENV in "staging") @@ -41,6 +43,13 @@ case $ENV in ;; esac +# If the rewards canister is known, it may perform priviliged actions such as find which users are eligible for rewards. +if [[ "${REWARDS_CANISTER_ID:-}" == "" ]]; then + ALLOWED_CALLERS="vec {}" +else + ALLOWED_CALLERS="vec{ principal \"$REWARDS_CANISTER_ID\" }" +fi + # URL used by II-issuer in the id_alias-verifiable credentials (hard-coded in II) # Represents more an ID than a URL II_VC_URL="https://identity.ic0.app" @@ -51,7 +60,7 @@ if [ -n "${ENV+1}" ]; then dfx deploy backend --argument "(variant { Init = record { ecdsa_key_name = \"$ECDSA_KEY_NAME\"; - allowed_callers = vec {}; + allowed_callers = $ALLOWED_CALLERS; cfs_canister_id = opt principal \"$SIGNER_CANISTER_ID\"; derivation_origin = opt \"$DERIVATION_ORIGIN\"; supported_credentials = opt vec { @@ -71,7 +80,7 @@ else dfx deploy backend --argument "(variant { Init = record { ecdsa_key_name = \"$ECDSA_KEY_NAME\"; - allowed_callers = vec {}; + allowed_callers = $ALLOWED_CALLERS; cfs_canister_id = opt principal \"$SIGNER_CANISTER_ID\"; derivation_origin = opt \"$DERIVATION_ORIGIN\"; supported_credentials = opt vec { diff --git a/src/frontend/src/lib/api/reward.api.ts b/src/frontend/src/lib/api/reward.api.ts new file mode 100644 index 0000000000..55bbef2cef --- /dev/null +++ b/src/frontend/src/lib/api/reward.api.ts @@ -0,0 +1,58 @@ +import type { + ClaimVipRewardResponse, + NewVipRewardResponse, + UserData, + VipReward +} from '$declarations/rewards/rewards.did'; +import { RewardCanister } from '$lib/canisters/reward.canister'; +import { REWARDS_CANISTER_ID } from '$lib/constants/app.constants'; +import type { CanisterApiFunctionParams } from '$lib/types/canister'; +import { Principal } from '@dfinity/principal'; +import { assertNonNullish, isNullish, type QueryParams } from '@dfinity/utils'; + +let canister: RewardCanister | undefined = undefined; + +export const getUserInfo = async ({ + identity, + certified +}: CanisterApiFunctionParams): Promise => { + const { getUserInfo } = await rewardCanister({ identity }); + + return getUserInfo({ certified }); +}; + +export const getNewVipReward = async ({ + identity +}: CanisterApiFunctionParams): Promise => { + const { getNewVipReward } = await rewardCanister({ identity }); + + return getNewVipReward(); +}; + +export const claimVipReward = async ({ + vipReward, + identity +}: CanisterApiFunctionParams<{ + vipReward: VipReward; +}>): Promise => { + const { claimVipReward } = await rewardCanister({ identity }); + + return claimVipReward(vipReward); +}; + +const rewardCanister = async ({ + identity, + nullishIdentityErrorMessage, + canisterId = REWARDS_CANISTER_ID +}: CanisterApiFunctionParams): Promise => { + assertNonNullish(identity, nullishIdentityErrorMessage); + + if (isNullish(canister)) { + canister = await RewardCanister.create({ + identity, + canisterId: Principal.fromText(canisterId) + }); + } + + return canister; +}; diff --git a/src/frontend/src/lib/assets/failed-reward.svg b/src/frontend/src/lib/assets/failed-reward.svg new file mode 100644 index 0000000000..59867936dc --- /dev/null +++ b/src/frontend/src/lib/assets/failed-reward.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/frontend/src/lib/assets/successful-reward.svg b/src/frontend/src/lib/assets/successful-reward.svg new file mode 100644 index 0000000000..0ced10b25f --- /dev/null +++ b/src/frontend/src/lib/assets/successful-reward.svg @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/frontend/src/lib/components/qr/FailedRewardModal.svelte b/src/frontend/src/lib/components/qr/FailedRewardModal.svelte new file mode 100644 index 0000000000..d3e0691986 --- /dev/null +++ b/src/frontend/src/lib/components/qr/FailedRewardModal.svelte @@ -0,0 +1,33 @@ + + + + + {$i18n.vip.reward.text.title_failed} + + + + + +

{$i18n.vip.reward.text.reward_failed}

+ {$i18n.vip.reward.text.reward_failed_description} + + +
+
diff --git a/src/frontend/src/lib/components/qr/SuccessfulRewardModal.svelte b/src/frontend/src/lib/components/qr/SuccessfulRewardModal.svelte new file mode 100644 index 0000000000..6e80f9595b --- /dev/null +++ b/src/frontend/src/lib/components/qr/SuccessfulRewardModal.svelte @@ -0,0 +1,34 @@ + + + + + {$i18n.vip.reward.text.title_successful} + + + + + +

{$i18n.vip.reward.text.reward_received}

+ {$i18n.vip.reward.text.reward_received_description} + + +
+
diff --git a/src/frontend/src/lib/constants/app.constants.ts b/src/frontend/src/lib/constants/app.constants.ts index 04b20ae9a6..7bbfca587f 100644 --- a/src/frontend/src/lib/constants/app.constants.ts +++ b/src/frontend/src/lib/constants/app.constants.ts @@ -51,6 +51,12 @@ export const BACKEND_CANISTER_ID = LOCAL export const BACKEND_CANISTER_PRINCIPAL = Principal.fromText(BACKEND_CANISTER_ID); +export const REWARDS_CANISTER_ID = LOCAL + ? import.meta.env.VITE_LOCAL_REWARDS_CANISTER_ID + : STAGING + ? import.meta.env.VITE_STAGING_REWARDS_CANISTER_ID + : import.meta.env.VITE_IC_REWARDS_CANISTER_ID; + export const SIGNER_CANISTER_ID = LOCAL ? import.meta.env.VITE_LOCAL_SIGNER_CANISTER_ID : STAGING diff --git a/src/frontend/src/lib/derived/modal.derived.ts b/src/frontend/src/lib/derived/modal.derived.ts index cccc85a24d..eb6687357b 100644 --- a/src/frontend/src/lib/derived/modal.derived.ts +++ b/src/frontend/src/lib/derived/modal.derived.ts @@ -117,6 +117,14 @@ export const modalDAppDetails: Readable = derived( modalStore, ($modalStore) => $modalStore?.type === 'dapp-details' ); +export const modalSuccessfulRewardModal: Readable = derived( + modalStore, + ($modalStore) => $modalStore?.type === 'successful-reward' +); +export const modalFailedRewardModal: Readable = derived( + modalStore, + ($modalStore) => $modalStore?.type === 'failed-reward' +); export const modalWalletConnect: Readable = derived( [modalWalletConnectAuth, modalWalletConnectSign, modalWalletConnectSend], diff --git a/src/frontend/src/lib/i18n/en.json b/src/frontend/src/lib/i18n/en.json index 95cbd8f2c8..b37ccd0148 100644 --- a/src/frontend/src/lib/i18n/en.json +++ b/src/frontend/src/lib/i18n/en.json @@ -156,6 +156,7 @@ "no_infura_erc20_provider": "No Infura ERC20 provider for network $network", "no_infura_erc20_icp_provider": "No Infura ERC20 Icp provider for network $network", "no_solana_rpc": "No Solana RPC for network $network", + "no_solana_network": "No Solana network for network $network", "eth_address_unknown": "ETH address is unknown.", "loading_address": "Error while loading the $symbol address.", "loading_balance": "Error while loading the ETH balance.", @@ -745,6 +746,24 @@ } } }, + "vip": { + "reward": { + "text": { + "open_wallet": "Take me to the wallet", + "title_successful": "Congratulations!", + "title_failed": "Something went wrong", + "reward_received": "You've received a welcome gift!", + "reward_failed": "Oops, the link has expired", + "reward_received_description": "You've just received your welcome tokens from Oisy", + "reward_failed_description": "Request a new one and try again" + }, + "error": { + "loading_reward": "Error while loading a new reward code.", + "loading_user_data": "Failed to load user data from reward canister.", + "claiming_reward": "Error while claiming reward." + } + } + }, "signer": { "sign_in": { "text": { diff --git a/src/frontend/src/lib/services/reward-code.services.ts b/src/frontend/src/lib/services/reward-code.services.ts new file mode 100644 index 0000000000..929fb506bb --- /dev/null +++ b/src/frontend/src/lib/services/reward-code.services.ts @@ -0,0 +1,114 @@ +import type { VipReward } from '$declarations/rewards/rewards.did'; +import { + claimVipReward as claimVipRewardApi, + getNewVipReward as getNewVipRewardApi, + getUserInfo as getUserInfoApi +} from '$lib/api/reward.api'; +import { i18n } from '$lib/stores/i18n.store'; +import { toastsError } from '$lib/stores/toasts.store'; +import type { ResultSuccess } from '$lib/types/utils'; +import type { Identity } from '@dfinity/agent'; +import { fromNullable } from '@dfinity/utils'; +import { get } from 'svelte/store'; + +const queryVipUser = async ({ + identity, + certified +}: { + identity: Identity; + certified: boolean; +}): Promise => { + const userData = await getUserInfoApi({ + identity, + certified, + nullishIdentityErrorMessage: get(i18n).auth.error.no_internet_identity + }); + + return { success: fromNullable(userData.is_vip) === true }; +}; + +export const isVipUser = async ({ identity }: { identity: Identity }): Promise => { + try { + return await queryVipUser({ identity, certified: false }); + } catch (err) { + const { vip } = get(i18n); + toastsError({ + msg: { text: vip.reward.error.loading_user_data }, + err + }); + } + return { success: false }; +}; + +const updateReward = async (identity: Identity): Promise => { + const response = await getNewVipRewardApi({ + identity, + nullishIdentityErrorMessage: get(i18n).auth.error.no_internet_identity + }); + + if ('VipReward' in response) { + return response.VipReward; + } + if ('NotImportantPerson' in response) { + throw new Error('User is not VIP'); + } + throw new Error('Unknown error'); +}; + +// The call to generate a new reward code will always be an update call and cannot be a query. +export const getNewReward = async (identity: Identity): Promise => { + try { + return await updateReward(identity); + } catch (err) { + const { vip } = get(i18n); + toastsError({ + msg: { text: vip.reward.error.loading_reward }, + err + }); + } +}; + +const updateVipReward = async ({ + identity, + code +}: { + identity: Identity; + code: string; +}): Promise => { + const response = await claimVipRewardApi({ + identity, + vipReward: { code }, + nullishIdentityErrorMessage: get(i18n).auth.error.no_internet_identity + }); + + if ('Success' in response) { + return { success: true }; + } + if ('InvalidCode' in response || 'AlreadyClaimed' in response) { + return { success: false }; + } + throw new Error('Unknown error'); +}; + +// The call to claim a reward with a reward code will always be an update call and cannot be a query. +export const claimVipReward = async ({ + identity, + code +}: { + identity: Identity; + code: string; +}): Promise => { + try { + return await updateVipReward({ + identity, + code + }); + } catch (err) { + const { vip } = get(i18n); + toastsError({ + msg: { text: vip.reward.error.claiming_reward }, + err + }); + } + return { success: false }; +}; diff --git a/src/frontend/src/lib/stores/modal.store.ts b/src/frontend/src/lib/stores/modal.store.ts index bbeda45447..bf9e82518c 100644 --- a/src/frontend/src/lib/stores/modal.store.ts +++ b/src/frontend/src/lib/stores/modal.store.ts @@ -31,7 +31,9 @@ export interface Modal { | 'receive-bitcoin' | 'about-why-oisy' | 'btc-transaction' - | 'dapp-details'; + | 'dapp-details' + | 'successful-reward' + | 'failed-reward'; data?: T; id?: symbol; } @@ -68,6 +70,8 @@ export interface ModalStore extends Readable> { openReceiveBitcoin: () => void; openAboutWhyOisy: () => void; openDappDetails: (data: D) => void; + openSuccessfulReward: () => void; + openFailedReward: () => void; close: () => void; } @@ -113,6 +117,8 @@ const initModalStore = (): ModalStore => { openReceiveBitcoin: setType('receive-bitcoin'), openAboutWhyOisy: setType('about-why-oisy'), openDappDetails: setTypeWithData('dapp-details'), + openSuccessfulReward: setType('successful-reward'), + openFailedReward: setType('failed-reward'), close: () => set(null), subscribe }; diff --git a/src/frontend/src/lib/types/i18n.d.ts b/src/frontend/src/lib/types/i18n.d.ts index 1b7a29b18e..1c8bc7f441 100644 --- a/src/frontend/src/lib/types/i18n.d.ts +++ b/src/frontend/src/lib/types/i18n.d.ts @@ -135,6 +135,7 @@ interface I18nInit { no_infura_erc20_provider: string; no_infura_erc20_icp_provider: string; no_solana_rpc: string; + no_solana_network: string; eth_address_unknown: string; loading_address: string; loading_balance: string; @@ -655,6 +656,21 @@ interface I18nAbout { }; } +interface I18nVip { + reward: { + text: { + open_wallet: string; + title_successful: string; + title_failed: string; + reward_received: string; + reward_failed: string; + reward_received_description: string; + reward_failed_description: string; + }; + error: { loading_reward: string; loading_user_data: string; claiming_reward: string }; + }; +} + interface I18nSigner { sign_in: { text: { access_your_wallet: string; open_or_create: string } }; idle: { text: { waiting: string }; alt: { img_placeholder: string } }; @@ -743,6 +759,7 @@ interface I18n { transaction: I18nTransaction; transactions: I18nTransactions; about: I18nAbout; + vip: I18nVip; signer: I18nSigner; carousel: I18nCarousel; license_agreement: I18nLicense_agreement; diff --git a/src/frontend/src/sol/services/sol-balance.services.ts b/src/frontend/src/sol/services/sol-balance.services.ts new file mode 100644 index 0000000000..07384002a8 --- /dev/null +++ b/src/frontend/src/sol/services/sol-balance.services.ts @@ -0,0 +1,48 @@ +import { balancesStore } from '$lib/stores/balances.store'; +import { i18n } from '$lib/stores/i18n.store'; +import type { SolAddress } from '$lib/types/address'; +import type { Token } from '$lib/types/token'; +import type { ResultSuccess } from '$lib/types/utils'; +import { replacePlaceholders } from '$lib/utils/i18n.utils'; +import { loadSolLamportsBalance } from '$sol/api/solana.api'; +import { mapNetworkIdToNetwork } from '$sol/utils/network.utils'; +import { assertNonNullish } from '@dfinity/utils'; +import { BigNumber } from '@ethersproject/bignumber'; +import { get } from 'svelte/store'; + +export const loadSolBalance = async ({ + address, + token +}: { + address: SolAddress; + token: Token; +}): Promise => { + const { + id: tokenId, + network: { id: networkId } + } = token; + + const solNetwork = mapNetworkIdToNetwork(networkId); + + assertNonNullish( + solNetwork, + replacePlaceholders(get(i18n).init.error.no_solana_network, { + $network: networkId.description ?? '' + }) + ); + + try { + const balance = await loadSolLamportsBalance({ address, network: solNetwork }); + + balancesStore.set({ tokenId, data: { data: BigNumber.from(balance), certified: false } }); + + return { success: true }; + } catch (err: unknown) { + balancesStore.reset(tokenId); + + // We don't want to disrupt the user experience if we can't load the balance. + console.error(`Error fetching ${tokenId.description} balance data:`, err); + + return { success: false }; + } +}; diff --git a/src/frontend/src/tests/lib/components/qr/FailedRewardModal.spec.ts b/src/frontend/src/tests/lib/components/qr/FailedRewardModal.spec.ts new file mode 100644 index 0000000000..1ac83776e1 --- /dev/null +++ b/src/frontend/src/tests/lib/components/qr/FailedRewardModal.spec.ts @@ -0,0 +1,15 @@ +import FailedRewardModal from '$lib/components/qr/FailedRewardModal.svelte'; +import { i18n } from '$lib/stores/i18n.store'; +import { render } from '@testing-library/svelte'; +import { get } from 'svelte/store'; + +describe('FailedRewardModal', () => { + it('should render expected texts', () => { + const { getByText } = render(FailedRewardModal); + + expect(getByText(get(i18n).vip.reward.text.title_failed)).toBeInTheDocument(); + expect(getByText(get(i18n).vip.reward.text.reward_failed)).toBeInTheDocument(); + expect(getByText(get(i18n).vip.reward.text.reward_failed_description)).toBeInTheDocument(); + expect(getByText(get(i18n).vip.reward.text.open_wallet)).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/src/tests/lib/components/qr/SuccessfulRewardModal.spec.ts b/src/frontend/src/tests/lib/components/qr/SuccessfulRewardModal.spec.ts new file mode 100644 index 0000000000..3c98a1237e --- /dev/null +++ b/src/frontend/src/tests/lib/components/qr/SuccessfulRewardModal.spec.ts @@ -0,0 +1,15 @@ +import SuccessfulRewardModal from '$lib/components/qr/SuccessfulRewardModal.svelte'; +import { i18n } from '$lib/stores/i18n.store'; +import { render } from '@testing-library/svelte'; +import { get } from 'svelte/store'; + +describe('SuccessfulRewardModal', () => { + it('should render expected texts', () => { + const { getByText } = render(SuccessfulRewardModal); + + expect(getByText(get(i18n).vip.reward.text.title_successful)).toBeInTheDocument(); + expect(getByText(get(i18n).vip.reward.text.reward_received)).toBeInTheDocument(); + expect(getByText(get(i18n).vip.reward.text.reward_received_description)).toBeInTheDocument(); + expect(getByText(get(i18n).vip.reward.text.open_wallet)).toBeInTheDocument(); + }); +}); diff --git a/src/frontend/src/tests/lib/services/reward-code.services.spec.ts b/src/frontend/src/tests/lib/services/reward-code.services.spec.ts new file mode 100644 index 0000000000..1e194c4cea --- /dev/null +++ b/src/frontend/src/tests/lib/services/reward-code.services.spec.ts @@ -0,0 +1,144 @@ +import type { + ClaimVipRewardResponse, + NewVipRewardResponse, + UserData +} from '$declarations/rewards/rewards.did'; +import * as rewardApi from '$lib/api/reward.api'; +import { claimVipReward, getNewReward, isVipUser } from '$lib/services/reward-code.services'; +import { i18n } from '$lib/stores/i18n.store'; +import * as toastsStore from '$lib/stores/toasts.store'; +import en from '$tests/mocks/i18n.mock'; +import { mockIdentity } from '$tests/mocks/identity.mock'; +import { get } from 'svelte/store'; +import { vi } from 'vitest'; + +const nullishIdentityErrorMessage = en.auth.error.no_internet_identity; + +describe('reward-code', () => { + describe('isVip', () => { + const mockedUserData: UserData = { + is_vip: [true], + airdrops: [], + sprinkles: [] + }; + + it('should return true if user is vip', async () => { + const getUserInfoSpy = vi.spyOn(rewardApi, 'getUserInfo').mockResolvedValue(mockedUserData); + + const result = await isVipUser({ identity: mockIdentity }); + + expect(getUserInfoSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + certified: false, + nullishIdentityErrorMessage + }); + expect(result).toEqual({ success: true }); + }); + + it('should return false if user is not vip', async () => { + const userData: UserData = { ...mockedUserData, is_vip: [false] }; + const getUserInfoSpy = vi.spyOn(rewardApi, 'getUserInfo').mockResolvedValue(userData); + + const result = await isVipUser({ identity: mockIdentity }); + + expect(getUserInfoSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + certified: false, + nullishIdentityErrorMessage + }); + expect(result).toEqual({ success: false }); + }); + }); + + describe('getNewReward', () => { + const mockedNewRewardResponse: NewVipRewardResponse = { + VipReward: { + code: '1234567890' + } + }; + + it('should get a vip reward code for vip user', async () => { + const getNewVipRewardSpy = vi + .spyOn(rewardApi, 'getNewVipReward') + .mockResolvedValue(mockedNewRewardResponse); + + const vipReward = await getNewReward(mockIdentity); + + expect(getNewVipRewardSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + nullishIdentityErrorMessage + }); + expect(vipReward).toEqual(mockedNewRewardResponse.VipReward); + }); + + it('should display an error message for non vip user', async () => { + const err = new Error('test'); + const getNewVipRewardSpy = vi.spyOn(rewardApi, 'getNewVipReward').mockRejectedValue(err); + const spyToastsError = vi.spyOn(toastsStore, 'toastsError'); + + await getNewReward(mockIdentity); + + expect(getNewVipRewardSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + nullishIdentityErrorMessage + }); + expect(spyToastsError).toHaveBeenNthCalledWith(1, { + msg: { text: get(i18n).vip.reward.error.loading_reward }, + err + }); + }); + }); + + describe('claimVipReward', () => { + const mockedClaimRewardResponse: ClaimVipRewardResponse = { + Success: null + }; + + it('should return true if a valid vip reward code is used', async () => { + const claimRewardSpy = vi + .spyOn(rewardApi, 'claimVipReward') + .mockResolvedValue(mockedClaimRewardResponse); + + const result = await claimVipReward({ identity: mockIdentity, code: '1234567890' }); + + expect(claimRewardSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + vipReward: { code: '1234567890' }, + nullishIdentityErrorMessage + }); + expect(result).toEqual({ success: true }); + }); + + it('should return false if an invalid vip reward code is used', async () => { + const claimRewardResponse: ClaimVipRewardResponse = { InvalidCode: null }; + const claimRewardSpy = vi + .spyOn(rewardApi, 'claimVipReward') + .mockResolvedValue(claimRewardResponse); + + const result = await claimVipReward({ identity: mockIdentity, code: '1234567890' }); + + expect(claimRewardSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + vipReward: { code: '1234567890' }, + nullishIdentityErrorMessage + }); + expect(result).toEqual({ success: false }); + }); + + it('should return false if an already used vip reward code is used', async () => { + const claimRewardResponse: ClaimVipRewardResponse = { AlreadyClaimed: null }; + const claimRewardSpy = vi + .spyOn(rewardApi, 'claimVipReward') + .mockResolvedValue(claimRewardResponse); + + const result = await claimVipReward({ identity: mockIdentity, code: '1234567890' }); + + expect(claimRewardSpy).toHaveBeenCalledWith({ + identity: mockIdentity, + vipReward: { code: '1234567890' }, + nullishIdentityErrorMessage + }); + expect(result).toEqual({ success: false }); + }); + }); +}); diff --git a/src/frontend/src/tests/sol/services/sol-balance.services.spec.ts b/src/frontend/src/tests/sol/services/sol-balance.services.spec.ts new file mode 100644 index 0000000000..e59a435d51 --- /dev/null +++ b/src/frontend/src/tests/sol/services/sol-balance.services.spec.ts @@ -0,0 +1,23 @@ +import { SOLANA_TESTNET_TOKEN } from '$env/tokens/tokens.sol.env'; +import { balancesStore } from '$lib/stores/balances.store'; +import type { Token } from '$lib/types/token'; +import { loadSolBalance } from '$sol/services/sol-balance.services'; +import { mockSolAddress } from '$tests/mocks/sol.mock'; +import { get } from 'svelte/store'; + +describe('sol-balance.services', () => { + // TODO: change DEVNET to use the normal RPC and not alchemy, and add it to this tests + const solanaTokens: Token[] = [SOLANA_TESTNET_TOKEN]; + + describe('loadSolBalance', () => { + it.each(solanaTokens)( + 'should return the balance in SOL of the $name native token for the address', + async (token) => { + const result = await loadSolBalance({ address: mockSolAddress, token }); + + expect(result).toEqual({ success: true }); + expect(get(balancesStore)?.[token.id]?.data.toNumber()).toBeGreaterThanOrEqual(0); + } + ); + }); +});