From 907c9f802b424eecc34ae365c7ed07ba9254043a Mon Sep 17 00:00:00 2001 From: Maksym Date: Tue, 12 Mar 2024 17:02:00 +0200 Subject: [PATCH] Rewards page (#48) * Add rewards route * Display rewards tabs * Update layout * Split pages * Add points block UI * Display limited events UI * Display tasks list * Display history UI * Update history page * Add leaderboard UI * Display active task ID page * Fix points block * Add points API * Update api hooks * Update balance block * Display API data * Add withdraw modal * Add loading states * Add missing loading states * Fire confetti * Add small UI fixes * Add rewards store * Refactor leaderboard * Refactor history * Update rewards balance block * Add rewards about page * Extract back link * Add multi-page loading * Rename withdraw modal * Update claim modal * Update leaderboard styles * Add rewards auth * Add enter program & program details * Update history * Update earn history * Update components * Chore code * Update events loading * Chore types and imports * Refactor rewards components * Update enums * Fix event actions * Add credentials * Fix review issues * Update packages --- package.json | 10 +- src/api/clients/index.ts | 1 + src/api/modules/auth/helpers/authorize.ts | 10 +- src/api/modules/link/helpers/proofs.ts | 2 +- .../orgs/helpers/org-groups-requests.ts | 2 +- src/api/modules/orgs/helpers/org-groups.ts | 2 +- src/api/modules/orgs/helpers/orgs.ts | 2 +- src/api/modules/points/enums/events.ts | 30 +++ src/api/modules/points/enums/index.ts | 1 + src/api/modules/points/helpers/balance.ts | 138 ++++++++++ src/api/modules/points/helpers/events.ts | 244 ++++++++++++++++++ src/api/modules/points/helpers/index.ts | 2 + src/api/modules/points/index.ts | 3 + src/api/modules/points/types/balance.ts | 22 ++ src/api/modules/points/types/events.ts | 58 +++++ src/api/modules/points/types/index.ts | 2 + src/assets/icons/gift-icon.svg | 3 + src/assets/icons/rarimo-icon.svg | 3 + src/assets/icons/swap-icon.svg | 3 + src/assets/icons/trophy-icon.svg | 3 + src/common/AppNavbar.tsx | 1 + src/common/BackLink.tsx | 27 ++ src/common/ErrorView.tsx | 57 ++++ src/common/InfiniteList.tsx | 64 +++++ src/common/IntersectionAnchor.tsx | 47 ++++ src/common/MarkdownViewer.tsx | 35 +++ .../{NoDataViewer.tsx => NoDataView.tsx} | 2 +- src/common/ProfileMenu.tsx | 16 +- src/common/UserAvatar.tsx | 11 +- src/common/index.ts | 7 +- src/contexts/toasts-manager/index.tsx | 2 +- src/enums/api.ts | 3 +- src/enums/icons.ts | 8 + src/enums/index.ts | 2 + src/enums/loading.ts | 7 + src/enums/routes.ts | 6 + src/helpers/format.ts | 32 ++- src/hooks/index.ts | 1 + src/hooks/multi-page-loading.ts | 88 +++++++ .../Credentials/pages/CredentialsId/index.tsx | 4 +- .../pages/CredentialsList/index.tsx | 4 +- src/pages/Dashboard/index.tsx | 4 +- .../pages/OrgRoot/components/LinksBlock.tsx | 4 +- src/pages/Rewards/components/RewardChip.tsx | 29 +++ src/pages/Rewards/index.tsx | 39 +++ src/pages/Rewards/pages/About/index.tsx | 57 ++++ .../EarnHistory/components/EventItem.tsx | 39 +++ src/pages/Rewards/pages/EarnHistory/index.tsx | 69 +++++ .../pages/EventId/components/EventView.tsx | 56 ++++ src/pages/Rewards/pages/EventId/index.tsx | 81 ++++++ .../components/LeaderboardItem.tsx | 109 ++++++++ .../components/LeaderboardList.tsx | 49 ++++ src/pages/Rewards/pages/Leaderboard/index.tsx | 38 +++ .../components/ActiveEventsList.tsx | 57 ++++ .../RewardsRoot/components/BalanceBlock.tsx | 68 +++++ .../RewardsRoot/components/ClaimBalances.tsx | 35 +++ .../RewardsRoot/components/ClaimModal.tsx | 154 +++++++++++ .../RewardsRoot/components/ClaimWarning.tsx | 38 +++ .../RewardsRoot/components/EnterProgram.tsx | 47 ++++ .../RewardsRoot/components/EventActions.tsx | 62 +++++ .../RewardsRoot/components/EventItem.tsx | 51 ++++ .../RewardsRoot/components/LevelProgress.tsx | 50 ++++ .../components/LimitedEventItem.tsx | 74 ++++++ .../RewardsRoot/components/LimitedEvents.tsx | 46 ++++ .../RewardsRoot/components/ProgramDetails.tsx | 33 +++ .../pages/RewardsRoot/hooks/useConfetti.ts | 35 +++ src/pages/Rewards/pages/RewardsRoot/index.tsx | 66 +++++ src/routes.tsx | 6 + src/store/modules/index.ts | 1 + src/store/modules/rewards.module.ts | 49 ++++ src/theme/colors.ts | 16 +- src/theme/components.ts | 42 ++- src/theme/helpers.ts | 10 + src/ui/UiDrawer.tsx | 6 +- yarn.lock | 112 ++++++-- 75 files changed, 2526 insertions(+), 71 deletions(-) create mode 100644 src/api/modules/points/enums/events.ts create mode 100644 src/api/modules/points/enums/index.ts create mode 100644 src/api/modules/points/helpers/balance.ts create mode 100644 src/api/modules/points/helpers/events.ts create mode 100644 src/api/modules/points/helpers/index.ts create mode 100644 src/api/modules/points/index.ts create mode 100644 src/api/modules/points/types/balance.ts create mode 100644 src/api/modules/points/types/events.ts create mode 100644 src/api/modules/points/types/index.ts create mode 100644 src/assets/icons/gift-icon.svg create mode 100644 src/assets/icons/rarimo-icon.svg create mode 100644 src/assets/icons/swap-icon.svg create mode 100644 src/assets/icons/trophy-icon.svg create mode 100644 src/common/BackLink.tsx create mode 100644 src/common/ErrorView.tsx create mode 100644 src/common/InfiniteList.tsx create mode 100644 src/common/IntersectionAnchor.tsx create mode 100644 src/common/MarkdownViewer.tsx rename src/common/{NoDataViewer.tsx => NoDataView.tsx} (97%) create mode 100644 src/enums/loading.ts create mode 100644 src/hooks/multi-page-loading.ts create mode 100644 src/pages/Rewards/components/RewardChip.tsx create mode 100644 src/pages/Rewards/index.tsx create mode 100644 src/pages/Rewards/pages/About/index.tsx create mode 100644 src/pages/Rewards/pages/EarnHistory/components/EventItem.tsx create mode 100644 src/pages/Rewards/pages/EarnHistory/index.tsx create mode 100644 src/pages/Rewards/pages/EventId/components/EventView.tsx create mode 100644 src/pages/Rewards/pages/EventId/index.tsx create mode 100644 src/pages/Rewards/pages/Leaderboard/components/LeaderboardItem.tsx create mode 100644 src/pages/Rewards/pages/Leaderboard/components/LeaderboardList.tsx create mode 100644 src/pages/Rewards/pages/Leaderboard/index.tsx create mode 100644 src/pages/Rewards/pages/RewardsRoot/components/ActiveEventsList.tsx create mode 100644 src/pages/Rewards/pages/RewardsRoot/components/BalanceBlock.tsx create mode 100644 src/pages/Rewards/pages/RewardsRoot/components/ClaimBalances.tsx create mode 100644 src/pages/Rewards/pages/RewardsRoot/components/ClaimModal.tsx create mode 100644 src/pages/Rewards/pages/RewardsRoot/components/ClaimWarning.tsx create mode 100644 src/pages/Rewards/pages/RewardsRoot/components/EnterProgram.tsx create mode 100644 src/pages/Rewards/pages/RewardsRoot/components/EventActions.tsx create mode 100644 src/pages/Rewards/pages/RewardsRoot/components/EventItem.tsx create mode 100644 src/pages/Rewards/pages/RewardsRoot/components/LevelProgress.tsx create mode 100644 src/pages/Rewards/pages/RewardsRoot/components/LimitedEventItem.tsx create mode 100644 src/pages/Rewards/pages/RewardsRoot/components/LimitedEvents.tsx create mode 100644 src/pages/Rewards/pages/RewardsRoot/components/ProgramDetails.tsx create mode 100644 src/pages/Rewards/pages/RewardsRoot/hooks/useConfetti.ts create mode 100644 src/pages/Rewards/pages/RewardsRoot/index.tsx create mode 100644 src/store/modules/rewards.module.ts diff --git a/package.json b/package.json index 3b3884d9..935f336d 100644 --- a/package.json +++ b/package.json @@ -31,9 +31,9 @@ "rsc": "node scripts/release-sanity-check.mjs" }, "dependencies": { - "@distributedlab/jac": "^1.0.0-rc.9", - "@distributedlab/tools": "^1.0.0-rc.9", - "@distributedlab/w3p": "^1.0.0-rc.9", + "@distributedlab/jac": "^1.0.0-rc.14", + "@distributedlab/tools": "^1.0.0-rc.14", + "@distributedlab/w3p": "^1.0.0-rc.14", "@dnd-kit/core": "^6.1.0", "@dnd-kit/modifiers": "^7.0.0", "@dnd-kit/sortable": "^8.0.0", @@ -45,12 +45,15 @@ "@mui/x-date-pickers": "^6.18.7", "@rarimo/rarime-connector": "^2.1.0-rc.3", "@walletconnect/modal": "^2.6.2", + "canvas-confetti": "^1.9.2", "copy-to-clipboard": "^3.3.3", "i18next": "^22.4.3", "jdenticon": "^3.2.0", "lodash": "^4.17.21", "loglevel": "^1.8.1", + "markdown-to-jsx": "^7.4.1", "material-ui-popup-state": "^5.0.10", + "mui-markdown": "^1.1.13", "notistack": "^3.0.1", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -65,6 +68,7 @@ "devDependencies": { "@esbuild-plugins/node-globals-polyfill": "^0.2.3", "@esbuild-plugins/node-modules-polyfill": "^0.2.2", + "@types/canvas-confetti": "^1.6.4", "@types/lodash": "^4", "@types/react": "^18.2.37", "@types/react-dom": "^18.2.15", diff --git a/src/api/clients/index.ts b/src/api/clients/index.ts index 15b02acc..30493efa 100644 --- a/src/api/clients/index.ts +++ b/src/api/clients/index.ts @@ -5,6 +5,7 @@ import { config } from '@/config' export const api = new JsonApiClient({ baseUrl: config.API_URL, + credentials: 'include', }) export let zkpSnap: SnapConnector diff --git a/src/api/modules/auth/helpers/authorize.ts b/src/api/modules/auth/helpers/authorize.ts index 49fc0a45..9c3b3f87 100644 --- a/src/api/modules/auth/helpers/authorize.ts +++ b/src/api/modules/auth/helpers/authorize.ts @@ -3,7 +3,7 @@ import type { ZKProof } from '@rarimo/rarime-connector' import { api } from '@/api/clients' import { AuthTokensGroup, FillRequestDetails, InvitationDetails } from '@/api/modules/auth' import { OrgUserRoles } from '@/api/modules/orgs' -import { ApiServicePaths } from '@/enums/api' +import { ApiServicePaths } from '@/enums' export const authorizeUser = async ({ role, @@ -12,11 +12,11 @@ export const authorizeUser = async ({ groupId, zkProof, }: { - role: OrgUserRoles userDid: string - orgDid: string - groupId: string - zkProof: ZKProof + role?: OrgUserRoles + orgDid?: string + groupId?: string + zkProof?: ZKProof }) => { const { data } = await api.post(`${ApiServicePaths.Auth}/v1/authorize`, { body: { diff --git a/src/api/modules/link/helpers/proofs.ts b/src/api/modules/link/helpers/proofs.ts index b8dcb626..8c035338 100644 --- a/src/api/modules/link/helpers/proofs.ts +++ b/src/api/modules/link/helpers/proofs.ts @@ -1,6 +1,6 @@ import { api } from '@/api/clients' import { type Proof, type ProofLink } from '@/api/modules/link' -import { ApiServicePaths } from '@/enums/api' +import { ApiServicePaths } from '@/enums' // eslint-disable-next-line @typescript-eslint/no-unused-vars const DUMMY_PROOFS: Proof[] = [ diff --git a/src/api/modules/orgs/helpers/org-groups-requests.ts b/src/api/modules/orgs/helpers/org-groups-requests.ts index 059831d7..325fab1a 100644 --- a/src/api/modules/orgs/helpers/org-groups-requests.ts +++ b/src/api/modules/orgs/helpers/org-groups-requests.ts @@ -19,7 +19,7 @@ import { getTargetProperty, loadAndParseCredentialSchema, } from '@/api/modules/zkp' -import { ApiServicePaths } from '@/enums/api' +import { ApiServicePaths } from '@/enums' const fakeLoadRequestsAll = async (query?: OrgGroupRequestQueryParams) => { return DUMMY_ORG_GROUP_REQUESTS.filter(req => { diff --git a/src/api/modules/orgs/helpers/org-groups.ts b/src/api/modules/orgs/helpers/org-groups.ts index bed7392a..b4b75086 100644 --- a/src/api/modules/orgs/helpers/org-groups.ts +++ b/src/api/modules/orgs/helpers/org-groups.ts @@ -5,7 +5,7 @@ import { OrgGroupQueryParams, OrgGroupRequestStatuses, } from '@/api/modules/orgs' -import { ApiServicePaths } from '@/enums/api' +import { ApiServicePaths } from '@/enums' // eslint-disable-next-line @typescript-eslint/no-unused-vars const DUMMY_ORG_GROUP: OrgGroup[] = [ diff --git a/src/api/modules/orgs/helpers/orgs.ts b/src/api/modules/orgs/helpers/orgs.ts index 8384b668..415060f1 100644 --- a/src/api/modules/orgs/helpers/orgs.ts +++ b/src/api/modules/orgs/helpers/orgs.ts @@ -11,7 +11,7 @@ import { type OrgUser, type OrgVerificationCode, } from '@/api/modules/orgs' -import { ApiServicePaths } from '@/enums/api' +import { ApiServicePaths } from '@/enums' export const DUMMY_ORGS: Organization[] = [ { diff --git a/src/api/modules/points/enums/events.ts b/src/api/modules/points/enums/events.ts new file mode 100644 index 00000000..a5b3e291 --- /dev/null +++ b/src/api/modules/points/enums/events.ts @@ -0,0 +1,30 @@ +export enum EventsRequestFilters { + Did = 'did', + Status = 'status', + MetaStaticName = 'meta.static.name', +} + +export enum EventRequestPageProperties { + Limit = 'limit', + Cursor = 'cursor', + Order = 'order', +} + +export enum EventRequestPageOrder { + Asc = 'asc', + Desc = 'desc', +} + +export enum EventStatuses { + Open = 'open', + Fulfilled = 'fulfilled', + Claimed = 'claimed', +} + +export enum EventMetadataFrequencies { + OneTime = 'one-time', + Daily = 'daily', + Weekly = 'weekly', + Unlimited = 'unlimited', + Custom = 'custom', +} diff --git a/src/api/modules/points/enums/index.ts b/src/api/modules/points/enums/index.ts new file mode 100644 index 00000000..def94ce4 --- /dev/null +++ b/src/api/modules/points/enums/index.ts @@ -0,0 +1 @@ +export * from './events' diff --git a/src/api/modules/points/helpers/balance.ts b/src/api/modules/points/helpers/balance.ts new file mode 100644 index 00000000..0d9d56ac --- /dev/null +++ b/src/api/modules/points/helpers/balance.ts @@ -0,0 +1,138 @@ +import { api } from '@/api/clients' +import { ApiServicePaths } from '@/enums' + +import { type Balance, type Withdrawal } from '../types' + +export const BALANCE_MOCK: Balance = { + id: 'did:iden3:readonly:tTep1wgHSGULyrAgWD1SKDxtg1jAPGzZuXuHNPknH', + type: 'balance', + amount: 175, + created_at: 1628793600, + updated_at: 1628793600, + rank: 291, +} + +export const LEADERBOARD_MOCK: Balance[] = [ + { + id: 'did:iden3:readonly:mhQHvqmvimneVHCL5EufeZvfzigRt', + amount: 25345, + type: 'balance', + created_at: 1628793600, + updated_at: 1628793600, + rank: 1, + }, + { + id: 'did:iden3:readonly:mi1QKgywFcFD7d35QRaHwd6b6Cxy', + amount: 25123, + type: 'balance', + created_at: 1628793600, + updated_at: 1628793600, + rank: 2, + }, + { + id: 'did:iden3:readonly:mmaJ5kAjbGLRVcfqwsMPi7kHK1f', + amount: 23402, + type: 'balance', + created_at: 1628793600, + updated_at: 1628793600, + rank: 3, + }, + { + id: 'did:iden3:readonly:mhQHvqmvimneVHCL5EufeZvfzigRv', + amount: 21502, + type: 'balance', + created_at: 1628793600, + updated_at: 1628793600, + rank: 4, + }, + { + id: 'did:iden3:readonly:mi1QKgywFcFD7d35QRaHwd6b6Cxb', + amount: 20420, + type: 'balance', + created_at: 1628793600, + updated_at: 1628793600, + rank: 5, + }, + { + id: 'did:iden3:readonly:mmaJ5kAjbGLRVcfqwsMPi7kHK1a', + amount: 19499, + type: 'balance', + created_at: 1628793600, + updated_at: 1628793600, + rank: 6, + }, + { + id: 'did:iden3:readonly:mi1QKgywFcFD7d35QRaHwd6b6Cxe', + amount: 8992, + type: 'balance', + created_at: 1628793600, + updated_at: 1628793600, + rank: 7, + }, + { + id: 'did:iden3:readonly:ahQHvqmvimneVHCL5EufeZvfzigRt', + amount: 8150, + type: 'balance', + created_at: 1628793600, + updated_at: 1628793600, + rank: 8, + }, + { + id: 'did:iden3:readonly:whQHvqmvimneVHCL5EufeZvfzigRt', + amount: 4520, + type: 'balance', + created_at: 1628793600, + updated_at: 1628793600, + rank: 9, + }, + { + id: 'did:iden3:readonly:mhQHvqmvimneVHCL5EufeZvfzigRa', + amount: 1402, + type: 'balance', + created_at: 1628793600, + updated_at: 1628793600, + rank: 10, + }, +] + +// Balances +export const createPointsBalance = async (did: string, referredBy: string) => { + return api.post(`${ApiServicePaths.Points}/v1/public/balances`, { + body: { + data: { + id: did, + type: 'create_balance', + attributes: { + referred_by: referredBy, + }, + }, + }, + }) +} + +export const getLeaderboard = async () => { + return api.get(`${ApiServicePaths.Points}/v1/public/balances`) +} + +export const getPointsBalance = async (did: string) => { + return api.get(`${ApiServicePaths.Points}/v1/public/balances/${did}?rank=true`) +} + +// Withdrawals +export const getWithdrawalHistory = async (did: string) => { + return api.get(`${ApiServicePaths.Points}/v1/public/balances/${did}/withdrawals`) +} + +export const withdrawPoints = async (did: string, amount: number, address: string) => { + return api.post(`${ApiServicePaths.Points}/v1/public/balances/${did}/withdrawals`, { + body: { + data: { + type: 'withdraw', + attributes: { + amount, + address, + }, + }, + }, + }) +} diff --git a/src/api/modules/points/helpers/events.ts b/src/api/modules/points/helpers/events.ts new file mode 100644 index 00000000..45c4bb7a --- /dev/null +++ b/src/api/modules/points/helpers/events.ts @@ -0,0 +1,244 @@ +import { api } from '@/api/clients' +import { ApiServicePaths } from '@/enums' + +import { EventMetadataFrequencies, EventsRequestFilters, EventStatuses } from '../enums' +import { Event, EventsMeta, EventsRequestQueryParams } from '../types/events' + +export const EVENTS_MOCK: Event[] = [ + { + id: '1', + type: 'event', + status: EventStatuses.Fulfilled, + created_at: 1628793600, + updated_at: 1628793600, + meta: { + static: { + name: 'free_weekly', + title: 'Free weekly points', + reward: 50, + description: + '## Free Weekly Points\n\nThis is a weekly event where users can earn free points.\n\n### How it works\n\n- Users are eligible to participate once every week.\n- Upon participation, users will receive 100 points.\n- These points can be used for various features in the application.\n\nParticipate every week and maximize your rewards!\n', + short_description: '', + image_url: '', + frequency: EventMetadataFrequencies.Weekly, + expires_at: '2024-03-12T00:00:00Z', + no_auto_open: false, + }, + dynamic: {}, + }, + balance: { + id: 'did:example:123', + type: 'balance', + }, + }, + { + id: '2', + type: 'event', + status: EventStatuses.Fulfilled, + created_at: 1628793600, + updated_at: 1628793600, + meta: { + static: { + name: 'free_weekly', + title: 'Free weekly points', + reward: 50, + description: + '## Free Weekly Points\n\nThis is a weekly event where users can earn free points.\n\n### How it works\n\n- Users are eligible to participate once every week.\n- Upon participation, users will receive 100 points.\n- These points can be used for various features in the application.\n\nParticipate every week and maximize your rewards!\n', + short_description: '', + image_url: '', + frequency: EventMetadataFrequencies.Weekly, + no_auto_open: false, + }, + dynamic: {}, + }, + balance: { + id: 'did:example:123', + type: 'balance', + }, + }, + { + id: '3', + type: 'event', + status: EventStatuses.Fulfilled, + created_at: 1628793600, + updated_at: 1628793600, + meta: { + static: { + name: 'free_weekly', + title: 'Free weekly points', + reward: 50, + description: + '## Free Weekly Points\n\nThis is a weekly event where users can earn free points.\n\n### How it works\n\n- Users are eligible to participate once every week.\n- Upon participation, users will receive 100 points.\n- These points can be used for various features in the application.\n\nParticipate every week and maximize your rewards!\n', + short_description: '', + image_url: '', + frequency: EventMetadataFrequencies.Weekly, + no_auto_open: false, + }, + dynamic: {}, + }, + balance: { + id: 'did:example:123', + type: 'balance', + }, + }, + { + id: '4', + type: 'event', + status: EventStatuses.Open, + created_at: 1628793600, + updated_at: 1628793600, + meta: { + static: { + name: 'free_weekly', + title: 'Free weekly points', + reward: 50, + description: + '## Free Weekly Points\n\nThis is a weekly event where users can earn free points.\n\n### How it works\n\n- Users are eligible to participate once every week.\n- Upon participation, users will receive 100 points.\n- These points can be used for various features in the application.\n\nParticipate every week and maximize your rewards!\n', + expires_at: '2024-03-12T00:00:00Z', + short_description: + 'This is a weekly event where users can earn free points. Users are eligible to participate once every week. Upon participation, users will receive 100 points. These points can be used for various features in the application. Participate every week and maximize your rewards!', + image_url: 'https://placekitten.com/g/150/150', + frequency: EventMetadataFrequencies.Weekly, + no_auto_open: false, + action_url: 'https://rarime.com', + }, + dynamic: {}, + }, + balance: { + id: 'did:example:123', + type: 'balance', + }, + }, + { + id: '5', + type: 'event', + status: EventStatuses.Open, + created_at: 1628793600, + updated_at: 1628793600, + meta: { + static: { + name: 'free_weekly', + title: 'Free weekly points', + reward: 50, + description: + '## Free Weekly Points\n\nThis is a weekly event where users can earn free points.\n\n### How it works\n\n- Users are eligible to participate once every week.\n- Upon participation, users will receive 100 points.\n- These points can be used for various features in the application.\n\nParticipate every week and maximize your rewards!\n', + short_description: '', + image_url: '', + frequency: EventMetadataFrequencies.Weekly, + no_auto_open: false, + }, + dynamic: {}, + }, + balance: { + id: 'did:example:123', + type: 'balance', + }, + }, + { + id: '6', + type: 'event', + status: EventStatuses.Open, + created_at: 1628793600, + updated_at: 1628793600, + meta: { + static: { + name: 'free_weekly', + title: 'Free weekly points', + reward: 50, + description: + '## Free Weekly Points\n\nThis is a weekly event where users can earn free points.\n\n### How it works\n\n- Users are eligible to participate once every week.\n- Upon participation, users will receive 100 points.\n- These points can be used for various features in the application.\n\nParticipate every week and maximize your rewards!\n', + short_description: '', + image_url: '', + frequency: EventMetadataFrequencies.Weekly, + no_auto_open: false, + }, + dynamic: {}, + }, + balance: { + id: 'did:example:123', + type: 'balance', + }, + }, + { + id: '7', + type: 'event', + status: EventStatuses.Claimed, + created_at: 1628793600, + updated_at: 1628793600, + points_amount: 100, + meta: { + static: { + name: 'free_weekly', + title: 'Free weekly points', + reward: 50, + description: + '## Free Weekly Points\n\nThis is a weekly event where users can earn free points.\n\n### How it works\n\n- Users are eligible to participate once every week.\n- Upon participation, users will receive 100 points.\n- These points can be used for various features in the application.\n\nParticipate every week and maximize your rewards!\n', + short_description: '', + image_url: '', + frequency: EventMetadataFrequencies.Weekly, + no_auto_open: false, + }, + dynamic: {}, + }, + balance: { + id: 'did:example:123', + type: 'balance', + }, + }, + { + id: '8', + type: 'event', + status: EventStatuses.Claimed, + created_at: 1628793600, + updated_at: 1628793600, + points_amount: 100, + meta: { + static: { + name: 'free_weekly', + title: 'Free weekly points', + reward: 50, + description: + '## Free Weekly Points\n\nThis is a weekly event where users can earn free points.\n\n### How it works\n\n- Users are eligible to participate once every week.\n- Upon participation, users will receive 100 points.\n- These points can be used for various features in the application.\n\nParticipate every week and maximize your rewards!\n', + short_description: '', + image_url: '', + frequency: EventMetadataFrequencies.Weekly, + no_auto_open: false, + }, + dynamic: {}, + }, + balance: { + id: 'did:example:123', + type: 'balance', + }, + }, +] + +export const getEvents = async (query: EventsRequestQueryParams) => { + const statuses = query.filter?.[EventsRequestFilters.Status] + return api.get(`${ApiServicePaths.Points}/v1/public/events`, { + query: { + ...query, + filter: { + ...query.filter, + // JsonApiClient doesn't support nested arrays in query params, + // so we need to join them manually + ...(statuses && { [EventsRequestFilters.Status]: statuses.join(',') }), + }, + }, + }) +} + +export const getEventById = async (id: string) => { + return api.get(`${ApiServicePaths.Points}/v1/public/events/${id}`) +} + +export const claimEvent = async (id: string) => { + return api.patch(`${ApiServicePaths.Points}/v1/public/events/${id}`, { + body: { + data: { + id, + type: 'claim_event', + }, + }, + }) +} diff --git a/src/api/modules/points/helpers/index.ts b/src/api/modules/points/helpers/index.ts new file mode 100644 index 00000000..c14d4217 --- /dev/null +++ b/src/api/modules/points/helpers/index.ts @@ -0,0 +1,2 @@ +export * from './balance' +export * from './events' diff --git a/src/api/modules/points/index.ts b/src/api/modules/points/index.ts new file mode 100644 index 00000000..b6c869a7 --- /dev/null +++ b/src/api/modules/points/index.ts @@ -0,0 +1,3 @@ +export * from './enums' +export * from './helpers' +export * from './types' diff --git a/src/api/modules/points/types/balance.ts b/src/api/modules/points/types/balance.ts new file mode 100644 index 00000000..05fb25e4 --- /dev/null +++ b/src/api/modules/points/types/balance.ts @@ -0,0 +1,22 @@ +export type Balance = { + id: string + type: 'balance' + amount: number + created_at: number + updated_at: number + is_disabled?: boolean + is_verified?: boolean + rank: number +} + +export type Withdrawal = { + id: string + type: 'withdrawal' + amount: number + address: string + created_at: number + balance: { + id: string + type: 'balance' + } +} diff --git a/src/api/modules/points/types/events.ts b/src/api/modules/points/types/events.ts new file mode 100644 index 00000000..03e102fa --- /dev/null +++ b/src/api/modules/points/types/events.ts @@ -0,0 +1,58 @@ +import { + EventMetadataFrequencies, + EventRequestPageOrder, + EventRequestPageProperties, + EventsRequestFilters, + EventStatuses, +} from '../enums/events' + +export type EventsRequestFiltersMap = { + [EventsRequestFilters.Did]?: string + [EventsRequestFilters.Status]?: EventStatuses[] + [EventsRequestFilters.MetaStaticName]?: string +} + +export type EventsRequestPageMap = { + [EventRequestPageProperties.Limit]?: number + [EventRequestPageProperties.Cursor]?: number + [EventRequestPageProperties.Order]?: EventRequestPageOrder +} + +export type EventsRequestQueryParams = { + filter?: EventsRequestFiltersMap + page?: EventsRequestPageMap +} + +export type EventMetadata = { + static: { + name: string + reward: number + title: string + description: string + short_description: string + image_url: string + frequency: EventMetadataFrequencies + no_auto_open: boolean + expires_at?: string + action_url?: string + } + dynamic?: Record +} + +export type Event = { + id: string + type: 'event' + status: EventStatuses + created_at: number + updated_at: number + points_amount?: number + meta: EventMetadata + balance: { + id: string + type: string + } +} + +export type EventsMeta = { + events_count: number +} diff --git a/src/api/modules/points/types/index.ts b/src/api/modules/points/types/index.ts new file mode 100644 index 00000000..c14d4217 --- /dev/null +++ b/src/api/modules/points/types/index.ts @@ -0,0 +1,2 @@ +export * from './balance' +export * from './events' diff --git a/src/assets/icons/gift-icon.svg b/src/assets/icons/gift-icon.svg new file mode 100644 index 00000000..2dd9579a --- /dev/null +++ b/src/assets/icons/gift-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/rarimo-icon.svg b/src/assets/icons/rarimo-icon.svg new file mode 100644 index 00000000..49aa5a85 --- /dev/null +++ b/src/assets/icons/rarimo-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/swap-icon.svg b/src/assets/icons/swap-icon.svg new file mode 100644 index 00000000..70eae161 --- /dev/null +++ b/src/assets/icons/swap-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/icons/trophy-icon.svg b/src/assets/icons/trophy-icon.svg new file mode 100644 index 00000000..169e73f8 --- /dev/null +++ b/src/assets/icons/trophy-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/common/AppNavbar.tsx b/src/common/AppNavbar.tsx index 53f1c35d..bc6872fc 100644 --- a/src/common/AppNavbar.tsx +++ b/src/common/AppNavbar.tsx @@ -56,6 +56,7 @@ const AppNavbar = () => { route: RoutePaths.Credentials, iconComponent: , }, + { route: RoutePaths.Rewards, iconComponent: }, ], [], ) diff --git a/src/common/BackLink.tsx b/src/common/BackLink.tsx new file mode 100644 index 00000000..f1f62eac --- /dev/null +++ b/src/common/BackLink.tsx @@ -0,0 +1,27 @@ +import { Button, ButtonProps } from '@mui/material' +import { NavLink, useLocation } from 'react-router-dom' + +import { UiIcon } from '@/ui' + +interface Props extends ButtonProps { + to: string +} + +export default function BackLink({ to, ...rest }: Props) { + const location = useLocation() + + return ( + + ) +} diff --git a/src/common/ErrorView.tsx b/src/common/ErrorView.tsx new file mode 100644 index 00000000..6f632be5 --- /dev/null +++ b/src/common/ErrorView.tsx @@ -0,0 +1,57 @@ +import { Stack, StackProps, Typography, useTheme } from '@mui/material' +import { ReactNode } from 'react' + +import { UiIcon } from '@/ui' + +interface Props extends StackProps { + icon?: ReactNode + title?: string + description?: string + action?: ReactNode +} + +export default function ErrorView({ + icon = , + title = 'Error', + description, + action, + ...rest +}: Props) { + const { palette, spacing } = useTheme() + + return ( + + + {icon} + + + {title} + {description && ( + + {description} + + )} + + {action} + + ) +} diff --git a/src/common/InfiniteList.tsx b/src/common/InfiniteList.tsx new file mode 100644 index 00000000..07bf3cab --- /dev/null +++ b/src/common/InfiniteList.tsx @@ -0,0 +1,64 @@ +import { Button, CircularProgress, Stack, useTheme } from '@mui/material' +import { PropsWithChildren, ReactNode } from 'react' + +import { ErrorView, NoDataView } from '@/common' +import { LoadingStates } from '@/enums' + +import IntersectionAnchor from './IntersectionAnchor' + +interface Props extends PropsWithChildren { + items: D[] + loadingState: LoadingStates + errorTitle?: string + noDataTitle?: string + noDataAction?: ReactNode + onRetry: () => void + onLoadNext: () => void +} + +export default function InfiniteList({ + items, + loadingState, + errorTitle = 'Failed to load list', + noDataTitle = 'No items yet', + noDataAction, + children, + onRetry, + onLoadNext, +}: Props) { + const { spacing } = useTheme() + + return items.length ? ( + + {children} + {loadingState === LoadingStates.NextLoading ? ( + + + + ) : loadingState === LoadingStates.Error ? ( + + + + ) : ( + + )} + + ) : loadingState === LoadingStates.Loading ? ( + + + + ) : loadingState === LoadingStates.Error ? ( + + Retry + + } + /> + ) : ( + + ) +} diff --git a/src/common/IntersectionAnchor.tsx b/src/common/IntersectionAnchor.tsx new file mode 100644 index 00000000..8d4828b4 --- /dev/null +++ b/src/common/IntersectionAnchor.tsx @@ -0,0 +1,47 @@ +import { Box, BoxProps } from '@mui/material' +import { useEffect, useRef, useState } from 'react' + +interface Props extends BoxProps { + onIntersect: () => void +} + +export default function IntersectionAnchor({ onIntersect, ...rest }: Props) { + const anchorEl = useRef(null) + const [isIntersecting, setIsIntersecting] = useState(false) + + const observer = useRef( + new IntersectionObserver(([entry]) => { + setIsIntersecting(entry.isIntersecting) + }), + ) + + useEffect(() => { + if (!anchorEl?.current) return + + observer.current.observe(anchorEl.current) + // eslint-disable-next-line react-hooks/exhaustive-deps + return () => observer.current.disconnect() + }, []) + + useEffect(() => { + if (isIntersecting) { + onIntersect() + } + }, [isIntersecting, onIntersect]) + + return ( + + ) +} diff --git a/src/common/MarkdownViewer.tsx b/src/common/MarkdownViewer.tsx new file mode 100644 index 00000000..75a05962 --- /dev/null +++ b/src/common/MarkdownViewer.tsx @@ -0,0 +1,35 @@ +import { Stack, Typography } from '@mui/material' +import { getOverrides, MuiMarkdown } from 'mui-markdown' +import { type MuiMarkdownWithOverrides } from 'mui-markdown/dist/types/muiMarkdown.d' + +export default function MarkdownViewer({ overrides, ...rest }: MuiMarkdownWithOverrides) { + return ( + + ) +} diff --git a/src/common/NoDataViewer.tsx b/src/common/NoDataView.tsx similarity index 97% rename from src/common/NoDataViewer.tsx rename to src/common/NoDataView.tsx index e4cae009..9f8c313c 100644 --- a/src/common/NoDataViewer.tsx +++ b/src/common/NoDataView.tsx @@ -10,7 +10,7 @@ interface Props extends StackProps { action?: ReactNode } -export default function NoDataViewer({ +export default function NoDataView({ icon = , title = 'No data', description, diff --git a/src/common/ProfileMenu.tsx b/src/common/ProfileMenu.tsx index 75b9abfc..de674b13 100644 --- a/src/common/ProfileMenu.tsx +++ b/src/common/ProfileMenu.tsx @@ -53,7 +53,7 @@ export default function ProfileMenu({ userDid }: ProfileMenuProps) { return ( <> setAnchorEl(event.currentTarget)}> - + setAnchorEl(null)} anchorOrigin={{ horizontal: 'left', vertical: 'top' }} transformOrigin={{ vertical: 'bottom', horizontal: 'center' }} + slotProps={{ + paper: { + sx: { + p: 0, + border: 0, + zIndex: 100, + bgcolor: palette.background.paper, + }, + }, + }} MenuListProps={{ sx: { width: spacing(60), @@ -73,13 +83,13 @@ export default function ProfileMenu({ userDid }: ProfileMenuProps) { }} > - + {formatDid(userDid)} diff --git a/src/common/UserAvatar.tsx b/src/common/UserAvatar.tsx index b87a294d..1fa01d88 100644 --- a/src/common/UserAvatar.tsx +++ b/src/common/UserAvatar.tsx @@ -14,7 +14,16 @@ const UserAvatar = ({ userDid, size = 12, ...rest }: UserAvatarProps) => { return jdenticon.toSvg(userDid, parseInt(spacing(size))) }, [size, userDid, spacing]) - return + return ( + + ) } export default UserAvatar diff --git a/src/common/index.ts b/src/common/index.ts index 87851315..bb053abe 100644 --- a/src/common/index.ts +++ b/src/common/index.ts @@ -1,7 +1,12 @@ export { default as AppNavbar } from './AppNavbar' +export { default as BackLink } from './BackLink' export { default as CredentialCard } from './CredentialCard' +export { default as ErrorView } from './ErrorView' export { default as FillRequestForm } from './FillRequestForm' -export { default as NoDataViewer } from './NoDataViewer' +export { default as InfiniteList } from './InfiniteList' +export { default as IntersectionAnchor } from './IntersectionAnchor' +export { default as MarkdownViewer } from './MarkdownViewer' +export { default as NoDataView } from './NoDataView' export { default as PageListFilters } from './PageListFilters' export { default as PageTitles } from './PageTitles' export { default as ProfileMenu } from './ProfileMenu' diff --git a/src/contexts/toasts-manager/index.tsx b/src/contexts/toasts-manager/index.tsx index 343b2897..888f6193 100644 --- a/src/contexts/toasts-manager/index.tsx +++ b/src/contexts/toasts-manager/index.tsx @@ -7,7 +7,7 @@ import { bus } from '@/helpers' import { DefaultToast } from './toasts' -const STATUS_MESSAGE_AUTO_HIDE_DURATION = 30 * 1000 +const STATUS_MESSAGE_AUTO_HIDE_DURATION = 5 * 1000 type ToastPayload = { title?: string diff --git a/src/enums/api.ts b/src/enums/api.ts index 1cef28e8..6e70e707 100644 --- a/src/enums/api.ts +++ b/src/enums/api.ts @@ -1,5 +1,6 @@ export enum ApiServicePaths { Orgs = '/integrations/rarime-orgs-svc', Link = '/integrations/rarime-link-svc', - Auth = '/integrations/rarime-auth-svc', + Auth = '/integrations/auth-svc', + Points = '/integrations/rarime-points-svc', } diff --git a/src/enums/icons.ts b/src/enums/icons.ts index dcbc2d9a..27a7da48 100644 --- a/src/enums/icons.ts +++ b/src/enums/icons.ts @@ -17,6 +17,7 @@ import DriveFileRenameOutlineOutlined from '@mui/icons-material/DriveFileRenameO import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' import FingerPrint from '@mui/icons-material/Fingerprint' import FolderOff from '@mui/icons-material/FolderOff' +import History from '@mui/icons-material/History' import InfoIcon from '@mui/icons-material/Info' import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined' import Key from '@mui/icons-material/Key' @@ -30,6 +31,7 @@ import Notifications from '@mui/icons-material/Notifications' import OpenInNew from '@mui/icons-material/OpenInNew' import QrCode from '@mui/icons-material/QrCode' import Search from '@mui/icons-material/Search' +import ShareOutlined from '@mui/icons-material/ShareOutlined' import Tune from '@mui/icons-material/Tune' import Verified from '@mui/icons-material/Verified' import WarningAmberIcon from '@mui/icons-material/WarningAmber' @@ -40,10 +42,14 @@ export enum Icons { House = 'house', Instagram = 'instagram', Metamask = 'metamask', + Rarimo = 'rarimo', Rarime = 'rarime', Twitter = 'twitter', User = 'user', Wallet = 'wallet', + Gift = 'gift', + Swap = 'swap', + Trophy = 'trophy', } export const ICON_COMPONENTS = { @@ -63,6 +69,7 @@ export const ICON_COMPONENTS = { driveFileRenameOutlineOutlined: DriveFileRenameOutlineOutlined, errorOutline: ErrorOutlineIcon, folderOff: FolderOff, + history: History, info: InfoIcon, infoOutlined: InfoOutlinedIcon, key: Key, @@ -75,6 +82,7 @@ export const ICON_COMPONENTS = { openInNew: OpenInNew, qrCode: QrCode, search: Search, + shareOutlined: ShareOutlined, tune: Tune, verified: Verified, warningAmber: WarningAmberIcon, diff --git a/src/enums/index.ts b/src/enums/index.ts index bb61b737..57fba191 100644 --- a/src/enums/index.ts +++ b/src/enums/index.ts @@ -1,4 +1,6 @@ +export * from './api' export * from './bus' export * from './icons' export * from './illustrations' +export * from './loading' export * from './routes' diff --git a/src/enums/loading.ts b/src/enums/loading.ts new file mode 100644 index 00000000..b3b25604 --- /dev/null +++ b/src/enums/loading.ts @@ -0,0 +1,7 @@ +export enum LoadingStates { + Initial, + Loading, + Error, + NextLoading, + Loaded, +} diff --git a/src/enums/routes.ts b/src/enums/routes.ts index 2724d697..151a915d 100644 --- a/src/enums/routes.ts +++ b/src/enums/routes.ts @@ -29,4 +29,10 @@ export enum RoutePaths { CredentialsList = '/credentials/list', CredentialsId = '/credentials/:claimId', CredentialsRequests = '/credentials/requests', + + Rewards = '/rewards', + RewardsEventId = '/rewards/events/:id', + RewardsEarnHistory = '/rewards/earn-history', + RewardsLeaderboard = '/rewards/leaderboard', + RewardsAbout = '/rewards/about', } diff --git a/src/helpers/format.ts b/src/helpers/format.ts index 06b6bbb0..ce37a80c 100644 --- a/src/helpers/format.ts +++ b/src/helpers/format.ts @@ -1,11 +1,33 @@ -import { time } from '@distributedlab/tools' +import { time, TimeDate } from '@distributedlab/tools' -const FORMATTED_DID_MAX_LENGTH = 12 +// DID +const DID_PART_LENGTH = 8 +const DID_SHORT_PART_LENGTH = 12 -export function formatDid(did: string) { - return did.length > FORMATTED_DID_MAX_LENGTH ? did.slice(0, 8) + '...' + did.slice(-4) : did +export function formatDid(did: string, partLength = DID_PART_LENGTH) { + return did.length > partLength * 2 + ? did.slice(0, partLength) + '...' + did.slice(-partLength) + : did } -export function formatDateMY(date: string) { +export function formatDidShort(value: string) { + return formatDid(value.split(':').pop() ?? value, DID_SHORT_PART_LENGTH) +} + +// Date +export function formatDateMY(date: TimeDate) { return time(date).format('MM / YYYY') } + +export function formatDateDMY(date: TimeDate) { + return time(date).format('DD MMM, YYYY') +} + +export function formatDateTime(date: TimeDate) { + return time(date).format('DD MMM, YYYY, h:mm A') +} + +// Number +export function formatNumber(value: number) { + return new Intl.NumberFormat().format(value) +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 92efdc42..17c9588a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -3,6 +3,7 @@ export * from './copy-to-clipboard' export * from './form' export * from './interval' export * from './loading' +export * from './multi-page-loading' export * from './provider' export * from './router' export * from './stepper' diff --git a/src/hooks/multi-page-loading.ts b/src/hooks/multi-page-loading.ts new file mode 100644 index 00000000..781ef95f --- /dev/null +++ b/src/hooks/multi-page-loading.ts @@ -0,0 +1,88 @@ +import { JsonApiLinkFields, JsonApiResponse } from '@distributedlab/jac' +import { useCallback, useEffect, useMemo, useState } from 'react' + +import { LoadingStates } from '@/enums' +import { ErrorHandler } from '@/helpers' + +export const useMultiPageLoading = ( + loadFn: () => Promise>, + opts?: { loadOnMount?: boolean; pageLimit: number }, +): { + data: D[] + meta?: M + loadingState: LoadingStates + load: () => Promise + reload: () => Promise + loadNext: () => Promise +} => { + const [response, setResponse] = useState>() + const [data, setData] = useState([]) + const [meta, setMeta] = useState() + const [loadingState, setLoadingState] = useState(LoadingStates.Initial) + const [hasNext, setHasNext] = useState(true) + + const optsWithDefaults = useMemo(() => { + return { + loadOnMount: true, + pageLimit: 100, + ...opts, + } + }, [opts]) + + const load = useCallback(async () => { + setLoadingState(LoadingStates.Loading) + try { + const res = await loadFn() + setResponse(res) + setData(res.data) + setMeta(res.meta) + setHasNext(res.data.length >= optsWithDefaults.pageLimit) + setLoadingState(LoadingStates.Loaded) + } catch (error) { + setLoadingState(LoadingStates.Error) + ErrorHandler.process(error) + } + }, [loadFn, optsWithDefaults.pageLimit]) + + const loadNext = useCallback(async () => { + if (!response || !hasNext || loadingState === LoadingStates.NextLoading) return + + setLoadingState(LoadingStates.NextLoading) + try { + const res = await response?.fetchPage(JsonApiLinkFields.next) + setResponse(res) + setData(prev => prev.concat(res.data)) + setHasNext(res.data.length >= optsWithDefaults.pageLimit) + setLoadingState(LoadingStates.Loaded) + } catch (error) { + setLoadingState(LoadingStates.Error) + ErrorHandler.process(error) + } + }, [response, hasNext, loadingState, optsWithDefaults.pageLimit]) + + const reset = useCallback(() => { + setData([]) + setMeta(undefined) + setLoadingState(LoadingStates.Initial) + setHasNext(true) + }, []) + + const reload = useCallback(async () => { + reset() + await load() + }, [load, reset]) + + useEffect(() => { + if (optsWithDefaults.loadOnMount) load() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + return { + data, + meta, + loadingState, + load, + reload, + loadNext, + } +} diff --git a/src/pages/Credentials/pages/CredentialsId/index.tsx b/src/pages/Credentials/pages/CredentialsId/index.tsx index 7501722c..06d08fdb 100644 --- a/src/pages/Credentials/pages/CredentialsId/index.tsx +++ b/src/pages/Credentials/pages/CredentialsId/index.tsx @@ -4,7 +4,7 @@ import { generatePath, NavLink, useLocation, useParams } from 'react-router-dom' import { zkpSnap } from '@/api/clients' import { getClaimIdFromVC } from '@/api/modules/zkp' -import { CredentialCard, NoDataViewer } from '@/common' +import { CredentialCard, NoDataView } from '@/common' import { RoutePaths } from '@/enums' import { ErrorHandler } from '@/helpers' import { useCredentialsState } from '@/store' @@ -161,7 +161,7 @@ export default function CredentialsId() { ) : ( - diff --git a/src/pages/Credentials/pages/CredentialsList/index.tsx b/src/pages/Credentials/pages/CredentialsList/index.tsx index bd665d1b..fef4f2c0 100644 --- a/src/pages/Credentials/pages/CredentialsList/index.tsx +++ b/src/pages/Credentials/pages/CredentialsList/index.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next' import { generatePath, NavLink } from 'react-router-dom' import { getClaimIdFromVC } from '@/api/modules/zkp' -import { CredentialCard, NoDataViewer, PageTitles } from '@/common' +import { CredentialCard, NoDataView, PageTitles } from '@/common' import { RoutePaths } from '@/enums' import { useLoading } from '@/hooks' import { credentialsStore, useCredentialsState } from '@/store' @@ -43,7 +43,7 @@ export default function CredentialsList({ ...rest }: Props) { ) : isLoadingError ? ( {`There's an error occurred, please, reload page`} ) : !vcs.length || isEmpty(issuersDetails) ? ( - Load Credentials} /> diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index a28032d7..96142f2a 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -14,7 +14,7 @@ import { useMemo } from 'react' import { generatePath, NavLink, useLocation } from 'react-router-dom' import { getClaimIdFromVC } from '@/api/modules/zkp' -import { CredentialCard, NoDataViewer, PageTitles } from '@/common' +import { CredentialCard, NoDataView, PageTitles } from '@/common' import { RoutePaths } from '@/enums' import { useLoading } from '@/hooks' import { credentialsStore, useCredentialsState } from '@/store' @@ -92,7 +92,7 @@ export default function Dashboard() { // TODO: create ErrorMessage component {`There's an error occurred, please, reload page`} ) : !lastVCsDesc.length || isEmpty(issuersDetails) ? ( - Load Credentials} /> diff --git a/src/pages/Orgs/pages/OrgsId/pages/OrgRoot/components/LinksBlock.tsx b/src/pages/Orgs/pages/OrgsId/pages/OrgRoot/components/LinksBlock.tsx index 6f843ccf..1ad6f037 100644 --- a/src/pages/Orgs/pages/OrgsId/pages/OrgRoot/components/LinksBlock.tsx +++ b/src/pages/Orgs/pages/OrgsId/pages/OrgRoot/components/LinksBlock.tsx @@ -1,7 +1,7 @@ import { Stack, Typography, useTheme } from '@mui/material' import { useState } from 'react' -import { NoDataViewer } from '@/common' +import { NoDataView } from '@/common' import { useOrgDetails } from '@/pages/Orgs/pages/OrgsId/hooks' import { UiButton, UiIcon } from '@/ui' @@ -41,7 +41,7 @@ export default function LinksBlock() { {links.length ? ( links.map((link, index) => ) ) : ( - + {`+${reward}`} + + + ) +} diff --git a/src/pages/Rewards/index.tsx b/src/pages/Rewards/index.tsx new file mode 100644 index 00000000..414772b1 --- /dev/null +++ b/src/pages/Rewards/index.tsx @@ -0,0 +1,39 @@ +import { Navigate } from 'react-router-dom' + +import { RoutePaths } from '@/enums' +import { useNestedRoutes } from '@/hooks' + +import About from './pages/About' +import EarnHistory from './pages/EarnHistory' +import EventId from './pages/EventId' +import Leaderboard from './pages/Leaderboard' +import RewardsRoot from './pages/RewardsRoot' + +export default function Rewards() { + return useNestedRoutes(RoutePaths.Rewards, [ + { + index: true, + element: , + }, + { + path: RoutePaths.RewardsEarnHistory, + element: , + }, + { + path: RoutePaths.RewardsEventId, + element: , + }, + { + path: RoutePaths.RewardsLeaderboard, + element: , + }, + { + path: RoutePaths.RewardsAbout, + element: , + }, + { + path: '*', + element: , + }, + ]) +} diff --git a/src/pages/Rewards/pages/About/index.tsx b/src/pages/Rewards/pages/About/index.tsx new file mode 100644 index 00000000..a8f4dbb6 --- /dev/null +++ b/src/pages/Rewards/pages/About/index.tsx @@ -0,0 +1,57 @@ +import { Paper, Stack, Typography, useTheme } from '@mui/material' + +import { BackLink } from '@/common' +import { RoutePaths } from '@/enums' + +export default function About() { + const { palette } = useTheme() + const questionBlocks = [ + { + question: 'How can I get this code?', + answer: + 'You must be invited by someone or receive a code that we post on our social channels', + }, + { + question: 'Question title 2', + answer: + 'You must be invited by someone or receive a code that we post on our social channels', + }, + { + question: 'Question title 3', + answer: + 'You must be invited by someone or receive a code that we post on our social channels', + }, + ] + + return ( + + + + + About Reward Program + + + + It is a long established fact that a reader will be distracted by the readable content + of a page when looking at its layout. The point of using Lorem Ipsum is that it has a + more-or-less normal distribution of letters, as opposed to using + {'\n\n'} + Content here, content here, making it look like readable English. Many desktop + publishing packages and web page editors now use Lorem Ipsum as their default model + text, and a search for lorem ipsum will uncover many web sites still in their infancy. + Various versions have evolved over the years, sometimes by accident, sometimes on + purpose (injected humour and the like). + + {questionBlocks.map(({ question, answer }, index) => ( + + + {question} + + {answer} + + ))} + + + + ) +} diff --git a/src/pages/Rewards/pages/EarnHistory/components/EventItem.tsx b/src/pages/Rewards/pages/EarnHistory/components/EventItem.tsx new file mode 100644 index 00000000..32722392 --- /dev/null +++ b/src/pages/Rewards/pages/EarnHistory/components/EventItem.tsx @@ -0,0 +1,39 @@ +import { Stack, Typography, useTheme } from '@mui/material' + +import { Event } from '@/api/modules/points' +import { Icons } from '@/enums' +import { formatDateDMY } from '@/helpers' +import RewardChip from '@/pages/Rewards/components/RewardChip' +import { UiIcon } from '@/ui' + +interface Props { + event: Event +} + +export default function EventItem({ event }: Props) { + const { palette } = useTheme() + + return ( + + + + + + + + {event.meta.static.title} + + + {formatDateDMY(event.created_at)} + + + + + + ) +} diff --git a/src/pages/Rewards/pages/EarnHistory/index.tsx b/src/pages/Rewards/pages/EarnHistory/index.tsx new file mode 100644 index 00000000..29cde8bf --- /dev/null +++ b/src/pages/Rewards/pages/EarnHistory/index.tsx @@ -0,0 +1,69 @@ +import { UnauthorizedError } from '@distributedlab/jac' +import { Button, Divider, Paper, Stack, Typography } from '@mui/material' +import { NavLink, useNavigate } from 'react-router-dom' + +import { EventsRequestFilters, EventStatuses, getEvents } from '@/api/modules/points' +import { BackLink, InfiniteList } from '@/common' +import { RoutePaths } from '@/enums' +import { useMultiPageLoading } from '@/hooks' +import { useIdentityState } from '@/store' + +import EventItem from './components/EventItem' + +const EVENTS_LIMIT = 20 + +export default function EarnHistory() { + const navigate = useNavigate() + const { userDid } = useIdentityState() + + const loadEvents = async () => { + try { + return await getEvents({ + filter: { + [EventsRequestFilters.Status]: [EventStatuses.Claimed], + [EventsRequestFilters.Did]: userDid, + }, + page: { limit: EVENTS_LIMIT }, + }) + } catch (error) { + if (error instanceof UnauthorizedError) { + navigate(RoutePaths.Rewards) + } + + throw error + } + } + + const { data, loadingState, load, loadNext } = useMultiPageLoading(loadEvents, { + pageLimit: EVENTS_LIMIT, + }) + + return ( + + + + Earn History + + View active events + + } + onLoadNext={loadNext} + onRetry={load} + > + {data.map((event, index) => ( + + + {index < data.length - 1 && } + + ))} + + + + ) +} diff --git a/src/pages/Rewards/pages/EventId/components/EventView.tsx b/src/pages/Rewards/pages/EventId/components/EventView.tsx new file mode 100644 index 00000000..1e118f74 --- /dev/null +++ b/src/pages/Rewards/pages/EventId/components/EventView.tsx @@ -0,0 +1,56 @@ +import { Box, Button, Divider, Stack, Typography, useTheme } from '@mui/material' + +import { Event } from '@/api/modules/points' +import { MarkdownViewer } from '@/common' +import { formatDateTime } from '@/helpers' +import RewardChip from '@/pages/Rewards/components/RewardChip' + +interface Props { + event: Event +} + +export default function EventView({ event }: Props) { + const { palette, spacing } = useTheme() + + return ( + + + + {event.meta.static.title} + + + {event.meta.static.expires_at && ( + + Exp: {formatDateTime(event.meta.static.expires_at)} + + )} + + + + + + {event?.meta.static.description ?? ''} + {event.meta.static.action_url && ( + + )} + + ) +} diff --git a/src/pages/Rewards/pages/EventId/index.tsx b/src/pages/Rewards/pages/EventId/index.tsx new file mode 100644 index 00000000..750c4791 --- /dev/null +++ b/src/pages/Rewards/pages/EventId/index.tsx @@ -0,0 +1,81 @@ +import { UnauthorizedError } from '@distributedlab/jac' +import { Button, CircularProgress, Paper, Stack } from '@mui/material' +import { NavLink, useNavigate, useParams } from 'react-router-dom' + +import { getEventById } from '@/api/modules/points' +import { BackLink, ErrorView, NoDataView } from '@/common' +import { RoutePaths } from '@/enums' +import { useCopyToClipboard, useLoading } from '@/hooks' +import { UiIcon } from '@/ui' + +import EventView from './components/EventView' + +export default function EventId() { + const { id = '' } = useParams<{ id: string }>() + const { copy, isCopied } = useCopyToClipboard() + const navigate = useNavigate() + + const loadEvent = async () => { + try { + const { data } = await getEventById(id) + return data + } catch (error) { + if (error instanceof UnauthorizedError) { + navigate(RoutePaths.Rewards) + return null + } + + throw error + } + } + + const { data: event, isLoading, isLoadingError, reload } = useLoading(null, loadEvent) + + return ( + + + + {event && ( + + )} + + + + {isLoading ? ( + + + + ) : isLoadingError ? ( + + Retry + + } + /> + ) : event ? ( + + ) : ( + + View all events + + } + /> + )} + + + ) +} diff --git a/src/pages/Rewards/pages/Leaderboard/components/LeaderboardItem.tsx b/src/pages/Rewards/pages/Leaderboard/components/LeaderboardItem.tsx new file mode 100644 index 00000000..f7a556ad --- /dev/null +++ b/src/pages/Rewards/pages/Leaderboard/components/LeaderboardItem.tsx @@ -0,0 +1,109 @@ +import { Box, BoxProps, Grid, Stack, StackProps, Typography, useTheme } from '@mui/material' +import { useMemo } from 'react' + +import { Balance } from '@/api/modules/points' +import { UserAvatar } from '@/common' +import { formatDidShort, formatNumber } from '@/helpers' +import { useIdentityState } from '@/store' + +interface Props { + balance: Balance + rank: number +} + +export default function LeaderboardItem({ balance, rank }: Props) { + const { palette, spacing } = useTheme() + const { userDid } = useIdentityState() + + const leaderIcon = useMemo(() => { + return ['', '🥇', '🥈', '🥉'][rank] ?? '' + }, [rank]) + + const isMyDid = useMemo(() => userDid === balance.id, [userDid, balance.id]) + + const wrapperProps = useMemo(() => { + return isMyDid + ? { + bgcolor: palette.action.active, + px: 6, + mx: -6, + borderRadius: 2, + sx: { + '& + .MuiBox-root': { + borderTop: 0, + }, + }, + } + : { + borderTop: 1, + borderColor: palette.divider, + } + }, [isMyDid, palette]) + + const rankProps = useMemo(() => { + return rank > 3 + ? { + bgcolor: isMyDid ? palette.background.paper : undefined, + color: palette.text.primary, + borderColor: palette.action.active, + } + : { + bgcolor: palette.primary.main, + color: palette.common.black, + borderColor: palette.primary.main, + } + }, [isMyDid, palette, rank]) + + return ( + + + + + = 100 ? 'subtitle5' : 'subtitle4'}>{rank} + + + + + + + + {`${formatDidShort(balance.id)} ${leaderIcon}`} + + + {isMyDid && ( + + You + + )} + + + + + {formatNumber(balance.amount)} + + + + + ) +} diff --git a/src/pages/Rewards/pages/Leaderboard/components/LeaderboardList.tsx b/src/pages/Rewards/pages/Leaderboard/components/LeaderboardList.tsx new file mode 100644 index 00000000..dc776326 --- /dev/null +++ b/src/pages/Rewards/pages/Leaderboard/components/LeaderboardList.tsx @@ -0,0 +1,49 @@ +import { Grid, Stack, Typography, useTheme } from '@mui/material' +import { useMemo } from 'react' + +import { Balance } from '@/api/modules/points' +import { useRewardsState } from '@/store' + +import LeaderboardItem from './LeaderboardItem' + +interface Props { + leaderboard: Balance[] +} + +export default function LeaderboardList({ leaderboard }: Props) { + const { palette } = useTheme() + const { balance } = useRewardsState() + + const hasMyBalance = useMemo(() => { + return leaderboard.some(participant => participant.id === balance?.id) + }, [balance, leaderboard]) + + return ( + + + + + Place + + + + + User + + + + + Reserved + + + + + {leaderboard.map((participant, index) => ( + + ))} + + {balance && !hasMyBalance && } + + + ) +} diff --git a/src/pages/Rewards/pages/Leaderboard/index.tsx b/src/pages/Rewards/pages/Leaderboard/index.tsx new file mode 100644 index 00000000..1fb12699 --- /dev/null +++ b/src/pages/Rewards/pages/Leaderboard/index.tsx @@ -0,0 +1,38 @@ +import { Paper, Stack, Typography } from '@mui/material' +import { useEffect } from 'react' + +import { getLeaderboard } from '@/api/modules/points' +import { BackLink, InfiniteList } from '@/common' +import { RoutePaths } from '@/enums' +import { useMultiPageLoading } from '@/hooks' +import { rewardsStore } from '@/store' + +import LeaderboardList from './components/LeaderboardList' + +export default function Leaderboard() { + const { data: leaderboard, loadingState, load } = useMultiPageLoading(() => getLeaderboard()) + + useEffect(() => { + rewardsStore.loadBalance() + }, []) + + return ( + + + + Leaderboard + {}} + onRetry={load} + > + + + + + ) +} diff --git a/src/pages/Rewards/pages/RewardsRoot/components/ActiveEventsList.tsx b/src/pages/Rewards/pages/RewardsRoot/components/ActiveEventsList.tsx new file mode 100644 index 00000000..d6bea0c9 --- /dev/null +++ b/src/pages/Rewards/pages/RewardsRoot/components/ActiveEventsList.tsx @@ -0,0 +1,57 @@ +import { Divider, Stack, Typography, useTheme } from '@mui/material' + +import { EventsRequestFilters, EventStatuses, getEvents } from '@/api/modules/points' +import { InfiniteList } from '@/common' +import { useMultiPageLoading } from '@/hooks' +import { useIdentityState } from '@/store' + +import EventItem from './EventItem' + +const EVENTS_LIMIT = 20 + +export default function ActiveEventsList() { + const { palette } = useTheme() + const { userDid } = useIdentityState() + + const { data, loadingState, load, loadNext } = useMultiPageLoading( + () => + getEvents({ + filter: { + [EventsRequestFilters.Did]: userDid, + [EventsRequestFilters.Status]: [EventStatuses.Open, EventStatuses.Fulfilled], + }, + page: { limit: EVENTS_LIMIT }, + }), + { pageLimit: EVENTS_LIMIT }, + ) + + return ( + + Earn RMO + + + {data.map((event, index) => ( + + + {index < data.length - 1 && } + + ))} + + + + ) +} diff --git a/src/pages/Rewards/pages/RewardsRoot/components/BalanceBlock.tsx b/src/pages/Rewards/pages/RewardsRoot/components/BalanceBlock.tsx new file mode 100644 index 00000000..cf773891 --- /dev/null +++ b/src/pages/Rewards/pages/RewardsRoot/components/BalanceBlock.tsx @@ -0,0 +1,68 @@ +import { Button, Divider, Paper, Stack, Typography, useTheme } from '@mui/material' +import { useState } from 'react' +import { NavLink } from 'react-router-dom' + +import { Icons, RoutePaths } from '@/enums' +import { useRewardsState } from '@/store' +import { UiIcon } from '@/ui' + +import ClaimModal from './ClaimModal' + +export default function BalanceBlock() { + const { palette, spacing } = useTheme() + const { balance } = useRewardsState() + + const [isClaimModalOpen, setIsClaimModalOpen] = useState(false) + + return ( + + + + + Reserved RMO + + {balance?.amount ?? '–'} + + + + + + + + + {/* TODO: update claim end date */} + Please claim before 14 Dec 2023, Or you lose this RMO + + + + setIsClaimModalOpen(false)} + onClaim={() => setIsClaimModalOpen(false)} + /> + + ) +} diff --git a/src/pages/Rewards/pages/RewardsRoot/components/ClaimBalances.tsx b/src/pages/Rewards/pages/RewardsRoot/components/ClaimBalances.tsx new file mode 100644 index 00000000..ffb9da76 --- /dev/null +++ b/src/pages/Rewards/pages/RewardsRoot/components/ClaimBalances.tsx @@ -0,0 +1,35 @@ +import { Divider, Stack, Typography, useTheme } from '@mui/material' + +import { useRewardsState } from '@/store' + +export default function ClaimBalances() { + const { palette, spacing } = useTheme() + const { balance } = useRewardsState() + // TODO: replace with real wallet balance + const walletBalance = 0 + + const balances = [ + { label: 'From', title: 'Reserved', value: balance?.amount ?? 0 }, + { label: 'To', title: 'Balance', value: walletBalance }, + ] + + return ( + + {balances.map((balance, index) => ( + + + + + {balance.label} + + + {balance.title} + + {balance.value} RMO + + {index !== balances.length - 1 && } + + ))} + + ) +} diff --git a/src/pages/Rewards/pages/RewardsRoot/components/ClaimModal.tsx b/src/pages/Rewards/pages/RewardsRoot/components/ClaimModal.tsx new file mode 100644 index 00000000..0267f333 --- /dev/null +++ b/src/pages/Rewards/pages/RewardsRoot/components/ClaimModal.tsx @@ -0,0 +1,154 @@ +import { + Button, + CircularProgress, + Dialog, + DialogProps, + Divider, + FormControl, + Stack, + Typography, + useTheme, +} from '@mui/material' +import { useCallback } from 'react' +import { Controller } from 'react-hook-form' + +import { withdrawPoints } from '@/api/modules/points' +import { BusEvents } from '@/enums' +import { bus, ErrorHandler } from '@/helpers' +import { useForm } from '@/hooks' +import { useIdentityState, useRewardsState } from '@/store' +import { UiDrawerActions, UiDrawerContent, UiDrawerTitle, UiTextField } from '@/ui' + +import ClaimBalances from './ClaimBalances' +import ClaimWarning from './ClaimWarning' + +interface Props extends DialogProps { + onClaim: () => void +} + +enum FieldNames { + Amount = 'amount', +} + +const DEFAULT_VALUES = { + [FieldNames.Amount]: '', +} + +export default function ClaimModal({ onClaim, ...rest }: Props) { + const { palette, spacing } = useTheme() + const { userDid } = useIdentityState() + const { balance } = useRewardsState() + + // TODO: Replace with real level check + const isLevelReached = false + + const { handleSubmit, control, isFormDisabled, getErrorMessage, disableForm, enableForm } = + useForm(DEFAULT_VALUES, yup => + yup.object().shape({ + [FieldNames.Amount]: yup + .number() + .required() + .min(1) + .max(balance?.amount ?? 0), + }), + ) + + const submit = useCallback( + async (formData: typeof DEFAULT_VALUES) => { + disableForm() + + try { + await withdrawPoints( + userDid, + Number(formData[FieldNames.Amount]), + // TODO: Replace with real Rarimo address + 'rarimo', + ) + bus.emit(BusEvents.success, { + message: `${formData[FieldNames.Amount]} RMO claimed`, + }) + onClaim() + } catch (error) { + ErrorHandler.process(error) + } + + enableForm() + }, + [disableForm, enableForm, onClaim, userDid], + ) + + return ( + + Claim RMO + + + {!isLevelReached && rest.onClose?.(e, 'escapeKeyDown')} />} + + ( + + + + + + ), + }} + /> + + )} + /> + + + + + + + After claiming you will be{' '} + downgraded in Leaderboard + + + + + {isFormDisabled && ( + theme.palette.background.light} + > + + + )} + + ) +} diff --git a/src/pages/Rewards/pages/RewardsRoot/components/ClaimWarning.tsx b/src/pages/Rewards/pages/RewardsRoot/components/ClaimWarning.tsx new file mode 100644 index 00000000..f86506f4 --- /dev/null +++ b/src/pages/Rewards/pages/RewardsRoot/components/ClaimWarning.tsx @@ -0,0 +1,38 @@ +import { Button, Stack, StackProps, Typography, useTheme } from '@mui/material' +import { MouseEvent } from 'react' + +import { UiIcon } from '@/ui' + +interface Props extends StackProps { + onAction?: (event: MouseEvent) => void +} + +export default function ClaimWarning({ onAction, ...rest }: Props) { + const { palette } = useTheme() + + return ( + + + + You have to reach Level 2 to claim + + + + ) +} diff --git a/src/pages/Rewards/pages/RewardsRoot/components/EnterProgram.tsx b/src/pages/Rewards/pages/RewardsRoot/components/EnterProgram.tsx new file mode 100644 index 00000000..36bed61c --- /dev/null +++ b/src/pages/Rewards/pages/RewardsRoot/components/EnterProgram.tsx @@ -0,0 +1,47 @@ +import { + Button, + CircularProgress, + Divider, + Paper, + Stack, + Typography, + useTheme, +} from '@mui/material' + +import { Icons } from '@/enums' +import { useLoading } from '@/hooks' +import { rewardsStore } from '@/store' +import { UiIcon } from '@/ui' + +export default function EnterProgram() { + const { palette, spacing } = useTheme() + const { isLoading, reload } = useLoading(undefined, rewardsStore.authorize, { + loadOnMount: false, + }) + + return ( + + + + + + + Enter into rewards program + + + Claim airdrops & earn RMO + + + + + + ) +} diff --git a/src/pages/Rewards/pages/RewardsRoot/components/EventActions.tsx b/src/pages/Rewards/pages/RewardsRoot/components/EventActions.tsx new file mode 100644 index 00000000..09d5b0f2 --- /dev/null +++ b/src/pages/Rewards/pages/RewardsRoot/components/EventActions.tsx @@ -0,0 +1,62 @@ +import { Button, ButtonProps, useTheme } from '@mui/material' +import { useMemo, useRef } from 'react' +import { generatePath, NavLink } from 'react-router-dom' + +import { claimEvent, Event, EventStatuses } from '@/api/modules/points' +import { BusEvents, RoutePaths } from '@/enums' +import { bus } from '@/helpers' +import { useLoading } from '@/hooks' +import { rewardsStore } from '@/store' + +import { useConfetti } from '../hooks/useConfetti' + +interface Props { + event: Event + onClaim: () => Promise +} + +export default function EventActions({ event, onClaim }: Props) { + const { spacing } = useTheme() + const { fireConfetti } = useConfetti() + + const claimRef = useRef(null) + const commonButtonProps: ButtonProps = useMemo(() => { + return { + size: 'medium', + sx: { + width: spacing(19), + height: spacing(8), + }, + } + }, [spacing]) + + const handleClaim = async () => { + await claimEvent(event.id) + await onClaim() + rewardsStore.loadBalance() + + fireConfetti(claimRef.current!) + bus.emit(BusEvents.success, { + message: `${event.meta.static.reward} RMO claimed!`, + }) + } + + const { isLoading, reload } = useLoading(undefined, () => handleClaim(), { + loadOnMount: false, + }) + + return event.status === EventStatuses.Fulfilled ? ( + + ) : ( + + ) +} diff --git a/src/pages/Rewards/pages/RewardsRoot/components/EventItem.tsx b/src/pages/Rewards/pages/RewardsRoot/components/EventItem.tsx new file mode 100644 index 00000000..e5eb173a --- /dev/null +++ b/src/pages/Rewards/pages/RewardsRoot/components/EventItem.tsx @@ -0,0 +1,51 @@ +import { Stack, Typography, useTheme } from '@mui/material' +import { generatePath, NavLink } from 'react-router-dom' + +import { Event } from '@/api/modules/points' +import { Icons, RoutePaths } from '@/enums' +import RewardChip from '@/pages/Rewards/components/RewardChip' +import { lineClamp } from '@/theme/helpers' +import { UiIcon } from '@/ui' + +import EventActions from './EventActions' + +interface Props { + event: Event + onClaim: () => Promise +} + +export default function EventItem({ event, onClaim }: Props) { + const { palette } = useTheme() + + return ( + + + + + + + + {event.meta.static.title} + + + {event.meta.static.short_description} + + + + + + + + + ) +} diff --git a/src/pages/Rewards/pages/RewardsRoot/components/LevelProgress.tsx b/src/pages/Rewards/pages/RewardsRoot/components/LevelProgress.tsx new file mode 100644 index 00000000..4a9b9312 --- /dev/null +++ b/src/pages/Rewards/pages/RewardsRoot/components/LevelProgress.tsx @@ -0,0 +1,50 @@ +import { LinearProgress, Stack, StackProps, Typography, useTheme } from '@mui/material' +import { useMemo } from 'react' + +const levels = [0, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000] + +function getLevel(amount: number): { rank: number; progress: number } { + for (let i = 0; i < levels.length; i++) { + if (amount < levels[i]) { + const delta = levels[i] - levels[i - 1] + return { + rank: i, + progress: Math.floor(((amount - levels[i - 1]) / delta) * 100), + } + } + } + + return { + rank: levels.length, + progress: 100, + } +} + +interface Props extends StackProps { + balance: number +} + +export default function LevelProgress({ balance, ...props }: Props) { + const { palette } = useTheme() + + const level = useMemo(() => { + return getLevel(balance ?? 0) + }, [balance]) + + return ( + + + + Level {level.rank} + + Level {level.rank + 1} ({levels[level.rank]}) + + + + ) +} diff --git a/src/pages/Rewards/pages/RewardsRoot/components/LimitedEventItem.tsx b/src/pages/Rewards/pages/RewardsRoot/components/LimitedEventItem.tsx new file mode 100644 index 00000000..eb08e41a --- /dev/null +++ b/src/pages/Rewards/pages/RewardsRoot/components/LimitedEventItem.tsx @@ -0,0 +1,74 @@ +import { Box, Stack, Typography, useTheme } from '@mui/material' +import { generatePath, NavLink } from 'react-router-dom' + +import { Event } from '@/api/modules/points' +import { Icons, RoutePaths } from '@/enums' +import { formatDateTime } from '@/helpers' +import RewardChip from '@/pages/Rewards/components/RewardChip' +import { lineClamp } from '@/theme/helpers' +import { UiIcon } from '@/ui' + +import EventActions from './EventActions' + +interface Props { + event: Event + onClaim: () => Promise +} + +export default function LimitedEventItem({ event, onClaim }: Props) { + const { palette, spacing } = useTheme() + + return ( + + + + {event.meta.static.image_url ? ( + + ) : ( + + + + )} + + + + {event.meta.static.title} + + + {event.meta.static.short_description} + + + + + Exp: {formatDateTime(event.meta.static.expires_at!)} + + + + + + + ) +} diff --git a/src/pages/Rewards/pages/RewardsRoot/components/LimitedEvents.tsx b/src/pages/Rewards/pages/RewardsRoot/components/LimitedEvents.tsx new file mode 100644 index 00000000..79e11184 --- /dev/null +++ b/src/pages/Rewards/pages/RewardsRoot/components/LimitedEvents.tsx @@ -0,0 +1,46 @@ +import { Paper, Skeleton, Stack, Typography, useTheme } from '@mui/material' + +import { EventsRequestFilters, EventStatuses, getEvents } from '@/api/modules/points' +import { useLoading } from '@/hooks' +import { useIdentityState } from '@/store' + +import LimitedEventItem from './LimitedEventItem' + +export default function LimitedEvents() { + const { spacing } = useTheme() + const { userDid } = useIdentityState() + + const loadEvents = async () => { + const { data } = await getEvents({ + filter: { + // TODO: Replace with limited time events filter + [EventsRequestFilters.Status]: [EventStatuses.Fulfilled], + [EventsRequestFilters.Did]: userDid, + }, + }) + + return data + } + + const { + data: events, + isLoading, + update, + } = useLoading([], loadEvents, { + loadOnMount: true, + loadArgs: [], + }) + + return events.length ? ( + + 🔥 Limited time events + {isLoading ? ( + + ) : ( + + )} + + ) : ( + <> + ) +} diff --git a/src/pages/Rewards/pages/RewardsRoot/components/ProgramDetails.tsx b/src/pages/Rewards/pages/RewardsRoot/components/ProgramDetails.tsx new file mode 100644 index 00000000..9687fd54 --- /dev/null +++ b/src/pages/Rewards/pages/RewardsRoot/components/ProgramDetails.tsx @@ -0,0 +1,33 @@ +import { Button, Divider, Paper, Stack, Typography, useTheme } from '@mui/material' + +import { Icons } from '@/enums' +import { UiIcon } from '@/ui' + +export default function ProgramDetails() { + const { palette, spacing } = useTheme() + + return ( + + + + + + + Rarime rewards program + + + Get invited to the Rarime rewards program and earn RMO + + + + + + ) +} diff --git a/src/pages/Rewards/pages/RewardsRoot/hooks/useConfetti.ts b/src/pages/Rewards/pages/RewardsRoot/hooks/useConfetti.ts new file mode 100644 index 00000000..b0c0e75f --- /dev/null +++ b/src/pages/Rewards/pages/RewardsRoot/hooks/useConfetti.ts @@ -0,0 +1,35 @@ +import confetti from 'canvas-confetti' +import { useCallback } from 'react' + +declare module 'canvas-confetti' { + interface Options { + // canvas-confetti does not have a type definition for flat + flat?: boolean + } +} + +export function useConfetti() { + const fireConfetti = useCallback((target: HTMLElement) => { + const { x, y, width, height } = target.getBoundingClientRect() + + const commonOpts: confetti.Options = { + spread: 360, + ticks: 50, + gravity: 0.4, + decay: 0.95, + startVelocity: 6, + shapes: ['circle', 'star'], + scalar: 0.5, + origin: { + x: (x + width / 2) / window.innerWidth, + y: (y + height / 2) / window.innerHeight, + }, + } + + confetti({ ...commonOpts, particleCount: 20 }) + confetti({ ...commonOpts, particleCount: 10, scalar: 0.15 }) + confetti({ ...commonOpts, particleCount: 30, scalar: 0.25 }) + }, []) + + return { fireConfetti } +} diff --git a/src/pages/Rewards/pages/RewardsRoot/index.tsx b/src/pages/Rewards/pages/RewardsRoot/index.tsx new file mode 100644 index 00000000..cffde4bc --- /dev/null +++ b/src/pages/Rewards/pages/RewardsRoot/index.tsx @@ -0,0 +1,66 @@ +import { Button, Skeleton, Stack, useTheme } from '@mui/material' +import { useMemo } from 'react' +import { NavLink } from 'react-router-dom' + +import { PageTitles } from '@/common' +import { RoutePaths } from '@/enums' +import { useLoading } from '@/hooks' +import { rewardsStore, useRewardsState } from '@/store' +import { UiIcon } from '@/ui' + +import ActiveEventsList from './components/ActiveEventsList' +import BalanceBlock from './components/BalanceBlock' +import EnterProgram from './components/EnterProgram' +import LimitedEvents from './components/LimitedEvents' +import ProgramDetails from './components/ProgramDetails' + +export default function RewardsRoot() { + const { spacing } = useTheme() + const { balance, isAuthorized } = useRewardsState() + const { isLoading } = useLoading(undefined, rewardsStore.loadBalance, { + loadArgs: [isAuthorized], + }) + + const isBalanceActive = useMemo(() => { + return !!balance && !balance.is_disabled + }, [balance]) + + return ( + + + + {isBalanceActive && ( + + )} + + + {isBalanceActive ? ( + <> + + + + + ) : isLoading ? ( + <> + + + + + ) : isAuthorized ? ( + + ) : ( + + )} + + + ) +} diff --git a/src/routes.tsx b/src/routes.tsx index 5a57dadd..8dbc43d4 100644 --- a/src/routes.tsx +++ b/src/routes.tsx @@ -22,6 +22,7 @@ export const AppRoutes = () => { const UiKit = lazy(() => import('@/pages/UiKit')) const VerifyProofAlias = lazy(() => import('@/pages/VerifyProofAlias')) const AcceptInvitation = lazy(() => import('@/pages/AcceptInvitation')) + const Rewards = lazy(() => import('@/pages/Rewards')) const { isAuthorized, logout } = useAuth() @@ -83,6 +84,11 @@ export const AppRoutes = () => { loader: authProtectedGuard, element: , }, + { + path: createDeepPath(RoutePaths.Rewards), + loader: authProtectedGuard, + element: , + }, { path: createDeepPath(RoutePaths.UiKit), element: , diff --git a/src/store/modules/index.ts b/src/store/modules/index.ts index 40047c35..ff8f9b6f 100644 --- a/src/store/modules/index.ts +++ b/src/store/modules/index.ts @@ -1,5 +1,6 @@ export * from './auth.module' export * from './credentials.module' export * from './identity.module' +export * from './rewards.module' export * from './ui.module' export * from './web3.module' diff --git a/src/store/modules/rewards.module.ts b/src/store/modules/rewards.module.ts new file mode 100644 index 00000000..d0fe1697 --- /dev/null +++ b/src/store/modules/rewards.module.ts @@ -0,0 +1,49 @@ +import { NotFoundError, UnauthorizedError } from '@distributedlab/jac' + +import { authorizeUser } from '@/api/modules/auth' +import { Balance, getPointsBalance } from '@/api/modules/points' +import { createStore } from '@/helpers' + +import { identityStore } from './identity.module' + +type RewardsState = { + balance: Balance | null + isAuthorized: boolean +} + +const [rewardsStore, useRewardsState] = createStore( + 'rewards', + { + balance: null, + isAuthorized: false, + } as RewardsState, + state => ({ + loadBalance: async () => { + try { + const { data } = await getPointsBalance(identityStore.userDid) + state.balance = data + state.isAuthorized = true + } catch (error) { + state.balance = null + if (error instanceof UnauthorizedError) { + state.isAuthorized = false + return + } + + if (error instanceof NotFoundError) { + state.isAuthorized = true + return + } + + throw error + } + }, + authorize: async () => { + await authorizeUser({ userDid: identityStore.userDid }) + state.isAuthorized = true + }, + }), + { isPersist: false }, +) + +export { rewardsStore, useRewardsState } diff --git a/src/theme/colors.ts b/src/theme/colors.ts index d2631880..ad5b16ba 100644 --- a/src/theme/colors.ts +++ b/src/theme/colors.ts @@ -42,8 +42,8 @@ export const lightPalette: PaletteOptions = { darker: '#A4541E', dark: '#CA6725', main: '#F17B2C', - light: '#FCE5D5', - lighter: '#FEF6F0', + light: 'rgba(241, 123, 44, 0.10)', + lighter: 'rgba(241, 123, 44, 0.05)', contrastText: '#FFFFFF', }, // DesignSystem: text & icons @@ -106,24 +106,24 @@ export const darkPalette: PaletteOptions = { darker: '#78D9B6', dark: '#58D0A4', main: '#38C793', - light: '#0D3023', - lighter: '#071812', + light: 'rgba(56, 199, 147, 0.10)', + lighter: 'rgba(56, 199, 147, 0.05)', contrastText: '#FFFFFF', }, error: { darker: '#E9657E', dark: '#E4405F', main: '#DF1C41', - light: '#360710', - lighter: '#1B0308', + light: 'rgba(223, 28, 65, 0.10)', + lighter: 'rgba(223, 28, 65, 0.05)', contrastText: '#FFFFFF', }, warning: { darker: '#F5A570', dark: '#F3904E', main: '#F17B2C', - light: '#3A1E0B', - lighter: '#2A2521', + light: 'rgba(241, 123, 44, 0.10)', + lighter: 'rgba(241, 123, 44, 0.05)', contrastText: '#FFFFFF', }, // DesignSystem: text & icons diff --git a/src/theme/components.ts b/src/theme/components.ts index 3f9754ce..5d84d2ad 100644 --- a/src/theme/components.ts +++ b/src/theme/components.ts @@ -112,6 +112,13 @@ export const components: Components> = { backgroundColor: theme.palette.action.hover, }, }), + containedWarning: ({ theme }) => ({ + color: theme.palette.warning.darker, + backgroundColor: theme.palette.warning.lighter, + '&:hover': { + backgroundColor: theme.palette.warning.light, + }, + }), }, }, MuiButtonBase: { @@ -261,10 +268,12 @@ export const components: Components> = { MuiSkeleton: { defaultProps: { animation: 'wave', + variant: 'rectangular', }, styleOverrides: { root: ({ theme }) => ({ backgroundColor: theme.palette.divider, + borderRadius: theme.spacing(2), }), }, }, @@ -400,8 +409,9 @@ export const components: Components> = { root: ({ theme }) => ({ width: '100%', borderRadius: theme.spacing(4), - backgroundColor: theme.palette.additional.pureBlack, - color: alpha(theme.palette.common.white, 0.7), + backgroundColor: theme.palette.background.paper, + color: alpha(theme.palette.text.primary, 0.7), + boxShadow: '0px 8px 16px 0px rgba(0, 0, 0, 0.04)', }), icon: ({ ownerState, theme }) => { const severityToBgColor: Record = { @@ -430,7 +440,33 @@ export const components: Components> = { styleOverrides: { root: ({ theme }) => ({ ...typography.subtitle4, - color: theme.palette.common.white, + color: theme.palette.text.primary, + }), + }, + }, + MuiLinearProgress: { + defaultProps: { + variant: 'determinate', + }, + styleOverrides: { + root: ({ theme }) => ({ + borderRadius: 250, + height: theme.spacing(2), + backgroundColor: theme.palette.action.active, + }), + barColorPrimary: ({ theme }) => ({ + borderRadius: 250, + backgroundColor: theme.palette.primary.dark, + }), + }, + }, + MuiDialog: { + styleOverrides: { + paper: ({ theme }) => ({ + padding: 0, + borderRadius: theme.spacing(3), + backgroundColor: theme.palette.background.paper, + boxShadow: `inset 0 0 0 1px ${theme.palette.action.active}`, }), }, }, diff --git a/src/theme/helpers.ts b/src/theme/helpers.ts index 1aecc439..3ae28edd 100644 --- a/src/theme/helpers.ts +++ b/src/theme/helpers.ts @@ -5,3 +5,13 @@ export function toRem(value: number) { export function vh(value: number) { return `calc(var(--vh, 1vh) * ${value})` } + +export function lineClamp(lines: number) { + return { + display: '-webkit-box', + WebkitBoxOrient: 'vertical', + WebkitLineClamp: lines, + overflow: 'hidden', + textOverflow: 'ellipsis', + } +} diff --git a/src/ui/UiDrawer.tsx b/src/ui/UiDrawer.tsx index 2aa63244..26572f64 100644 --- a/src/ui/UiDrawer.tsx +++ b/src/ui/UiDrawer.tsx @@ -23,22 +23,22 @@ export function UiDrawerTitle({ children, onClose, ...rest }: UiDrawerTitleProps return ( {children} onClose?.(e, 'backdropClick')} - sx={{ color: palette.text.secondary }} > diff --git a/yarn.lock b/yarn.lock index ae9b800e..be33963f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -76,53 +76,52 @@ __metadata: languageName: node linkType: hard -"@distributedlab/fetcher@npm:1.0.0-rc.10": - version: 1.0.0-rc.10 - resolution: "@distributedlab/fetcher@npm:1.0.0-rc.10" +"@distributedlab/fetcher@npm:1.0.0-rc.14": + version: 1.0.0-rc.14 + resolution: "@distributedlab/fetcher@npm:1.0.0-rc.14" dependencies: uuid: "npm:^9.0.0" - checksum: 9931562ea2b25deb881d9aa72a302bd4d4454b4f532c0300ef023aa090420244c2acf8b88b673a039ba3d6ea475e2993a8e63536962bc889f8b6856b2c45fc90 + checksum: 1784e4bec58984174a502d63e9c4ca92cc2ca2a1b9d927fd2831514e70712aa2c403f66e61cc3236654067bddeae7b0644e57f73a486515d401b595288f74eca languageName: node linkType: hard -"@distributedlab/jac@npm:^1.0.0-rc.9": - version: 1.0.0-rc.10 - resolution: "@distributedlab/jac@npm:1.0.0-rc.10" +"@distributedlab/jac@npm:^1.0.0-rc.14": + version: 1.0.0-rc.14 + resolution: "@distributedlab/jac@npm:1.0.0-rc.14" dependencies: - "@distributedlab/fetcher": "npm:1.0.0-rc.10" + "@distributedlab/fetcher": "npm:1.0.0-rc.14" lodash: "npm:^4.17.21" - checksum: 3e7efcf0c7813601b7dabf46669daf07476ee30bcb70e80a0a131ca76c5e16a83344e5b66909740126e5c3eb96fc4df588ca536ce00ac17fa526f432e33c6b71 + checksum: 3a9abd428d10fc937401751571247d14ea8d6dc2edfbd8b528f564758085a892797fc6589abc25573cd22ca4c5e83fa1d9d3d5be19c2fabcb6dcfc519c86a0f3 languageName: node linkType: hard -"@distributedlab/tools@npm:1.0.0-rc.10, @distributedlab/tools@npm:^1.0.0-rc.9": - version: 1.0.0-rc.10 - resolution: "@distributedlab/tools@npm:1.0.0-rc.10" +"@distributedlab/tools@npm:1.0.0-rc.14, @distributedlab/tools@npm:^1.0.0-rc.14": + version: 1.0.0-rc.14 + resolution: "@distributedlab/tools@npm:1.0.0-rc.14" dependencies: bignumber.js: "npm:^9.1.1" dayjs: "npm:^1.11.7" tslib: "npm:^2.5.0" - checksum: 4d762b55080ecb1b1e2fa70d1fe4e18218aac03355416ef3b144d4d4c1b828970724ccac19f2d1f3fa612cc1ee62a92a7709819683c13f23479d5847341fbf43 + checksum: 37c17513ba4cd80f7222c7e789af68ebceb5b4a718f8d364a828f4424ea50694af7aaddc5f6240ae157c177e0cd4b11f3546a06ad31ebd12151baec4ca1c0007 languageName: node linkType: hard -"@distributedlab/w3p@npm:^1.0.0-rc.9": - version: 1.0.0-rc.10 - resolution: "@distributedlab/w3p@npm:1.0.0-rc.10" +"@distributedlab/w3p@npm:^1.0.0-rc.14": + version: 1.0.0-rc.14 + resolution: "@distributedlab/w3p@npm:1.0.0-rc.14" dependencies: - "@distributedlab/tools": "npm:1.0.0-rc.10" + "@distributedlab/tools": "npm:1.0.0-rc.14" "@ethersproject/abstract-provider": "npm:^5.7.0" "@ethersproject/properties": "npm:^5.7.0" "@near-wallet-selector/core": "npm:^7.8.2" "@near-wallet-selector/my-near-wallet": "npm:^7.8.2" "@solana/web3.js": "npm:^1.73.2" + "@walletconnect/modal": "npm:^2.6.1" "@walletconnect/universal-provider": "npm:^2.10.0" bs58: "npm:^5.0.0" ethers: "npm:^5.7.2" near-api-js: "npm:^1.1.0" - peerDependencies: - "@walletconnect/modal": ^2.6.1 - checksum: 86f07e56b60621275dda555e08d5e2173451bfb3ace64554c1806c29b949669bab9711ca0ebf4056a4c82a5053e159a719d114841f9c5ccfe45556fad8d3b750 + checksum: 227587b13d4d96b38f1ab0949fcc80c09e4b2323921a8d1e820816e35646f63b0f51864a82a1dcc05cc6a88bd9c6f0f27a81f19ad85e4eaf585fc98f50758a47 languageName: node linkType: hard @@ -2240,6 +2239,13 @@ __metadata: languageName: node linkType: hard +"@types/canvas-confetti@npm:^1.6.4": + version: 1.6.4 + resolution: "@types/canvas-confetti@npm:1.6.4" + checksum: 4a821690f0b9cdd81b0d02c0946c5b4e9e80f189e868fd8a159884d5eb9da0bdf0bfc380a61c971cc5d7b9ca2521625d46bcb5e4653988375118f57523ebe5a4 + languageName: node + linkType: hard + "@types/connect@npm:^3.4.33": version: 3.4.38 resolution: "@types/connect@npm:3.4.38" @@ -2307,6 +2313,13 @@ __metadata: languageName: node linkType: hard +"@types/prismjs@npm:^1.26.0": + version: 1.26.3 + resolution: "@types/prismjs@npm:1.26.3" + checksum: 3e8a64bcf0ab5f9a47ec2590938c5a8a20ac849b4949a95ed96e73e64cb890fc56e9c9b724286914717458267b28405f965709e1b9f80db5d68817a7ce5a18a9 + languageName: node + linkType: hard + "@types/prop-types@npm:*, @types/prop-types@npm:^15.7.11": version: 15.7.11 resolution: "@types/prop-types@npm:15.7.11" @@ -2688,7 +2701,7 @@ __metadata: languageName: node linkType: hard -"@walletconnect/modal@npm:^2.6.2": +"@walletconnect/modal@npm:^2.6.1, @walletconnect/modal@npm:^2.6.2": version: 2.6.2 resolution: "@walletconnect/modal@npm:2.6.2" dependencies: @@ -3659,6 +3672,13 @@ __metadata: languageName: node linkType: hard +"canvas-confetti@npm:^1.9.2": + version: 1.9.2 + resolution: "canvas-confetti@npm:1.9.2" + checksum: 6247eb95bf710522f5822728dc29d74d0747b7ca0a85dfa90fd159f0f405527fbaedbdbb0e6917f6cd1560cf1355286c2c94bd3e89bc2147fb0fcf8e1c64126a + languageName: node + linkType: hard + "canvas-renderer@npm:~2.2.0": version: 2.2.1 resolution: "canvas-renderer@npm:2.2.1" @@ -7504,6 +7524,15 @@ __metadata: languageName: node linkType: hard +"markdown-to-jsx@npm:^7.4.1": + version: 7.4.1 + resolution: "markdown-to-jsx@npm:7.4.1" + peerDependencies: + react: ">= 0.14.0" + checksum: f40d9ab632a659ef7fd3afcae9c40e11ce77d57ae856275eb9439bf6762738eefb9897cfb65fc0495347cf8fc3b1cfef6cb9dcfe96959ded3ebd122375155323 + languageName: node + linkType: hard + "material-ui-popup-state@npm:^5.0.10": version: 5.0.10 resolution: "material-ui-popup-state@npm:5.0.10" @@ -7823,6 +7852,25 @@ __metadata: languageName: node linkType: hard +"mui-markdown@npm:^1.1.13": + version: 1.1.13 + resolution: "mui-markdown@npm:1.1.13" + dependencies: + prism-react-renderer: "npm:^2.0.3" + peerDependencies: + "@emotion/react": ^11.10.8 + "@emotion/styled": ^11.10.8 + "@mui/material": ^5.12.2 + markdown-to-jsx: ^7.3.0 + react: ">= 17.0.2" + react-dom: ">= 17.0.2" + dependenciesMeta: + prism-react-renderer: + optional: true + checksum: a786ca94e763f93d352ebd9eac3366d7eb0a9c2b926c7d3c662fabb538bf13ff33968b21032d74d4973a4c9161579d15bdf5df767acf75157ffdb7adbe30da75 + languageName: node + linkType: hard + "multiformats@npm:^9.4.2": version: 9.9.0 resolution: "multiformats@npm:9.9.0" @@ -8746,6 +8794,18 @@ __metadata: languageName: node linkType: hard +"prism-react-renderer@npm:^2.0.3": + version: 2.3.1 + resolution: "prism-react-renderer@npm:2.3.1" + dependencies: + "@types/prismjs": "npm:^1.26.0" + clsx: "npm:^2.0.0" + peerDependencies: + react: ">=16.0.0" + checksum: 566932127ca18049a651aa038a8f8c7c1ca15950d21b659c2ce71fd95bd03bef2b5d40c489e7aa3453eaf15d984deef542a609d7842e423e6a13427dd90bd371 + languageName: node + linkType: hard + "proc-log@npm:^3.0.0": version: 3.0.0 resolution: "proc-log@npm:3.0.0" @@ -8940,9 +9000,9 @@ __metadata: version: 0.0.0-use.local resolution: "rarime-app@workspace:." dependencies: - "@distributedlab/jac": "npm:^1.0.0-rc.9" - "@distributedlab/tools": "npm:^1.0.0-rc.9" - "@distributedlab/w3p": "npm:^1.0.0-rc.9" + "@distributedlab/jac": "npm:^1.0.0-rc.14" + "@distributedlab/tools": "npm:^1.0.0-rc.14" + "@distributedlab/w3p": "npm:^1.0.0-rc.14" "@dnd-kit/core": "npm:^6.1.0" "@dnd-kit/modifiers": "npm:^7.0.0" "@dnd-kit/sortable": "npm:^8.0.0" @@ -8955,6 +9015,7 @@ __metadata: "@mui/material": "npm:^5.14.20" "@mui/x-date-pickers": "npm:^6.18.7" "@rarimo/rarime-connector": "npm:^2.1.0-rc.3" + "@types/canvas-confetti": "npm:^1.6.4" "@types/lodash": "npm:^4" "@types/react": "npm:^18.2.37" "@types/react-dom": "npm:^18.2.15" @@ -8963,6 +9024,7 @@ __metadata: "@typescript-eslint/parser": "npm:^6.10.0" "@vitejs/plugin-react-swc": "npm:^3.5.0" "@walletconnect/modal": "npm:^2.6.2" + canvas-confetti: "npm:^1.9.2" copy-to-clipboard: "npm:^3.3.3" dotenv: "npm:^16.3.1" dotenv-cli: "npm:^7.2.1" @@ -8981,7 +9043,9 @@ __metadata: jdenticon: "npm:^3.2.0" lodash: "npm:^4.17.21" loglevel: "npm:^1.8.1" + markdown-to-jsx: "npm:^7.4.1" material-ui-popup-state: "npm:^5.0.10" + mui-markdown: "npm:^1.1.13" notistack: "npm:^3.0.1" postcss: "npm:^8.4.24" prettier: "npm:^2.7.1"