diff --git a/runtime/backend/src/common/services/AccountsService.ts b/runtime/backend/src/common/services/AccountsService.ts index a0d730f0..8deb9676 100644 --- a/runtime/backend/src/common/services/AccountsService.ts +++ b/runtime/backend/src/common/services/AccountsService.ts @@ -22,6 +22,7 @@ import { AccountModel, AccountQuery, } from "../models/AccountSchema"; +import { EventEmitter2 } from "@nestjs/event-emitter"; // configuration resources import { DappConfig, NetworkConfig } from "../models"; @@ -49,6 +50,7 @@ export class AccountsService { AccountDocument, AccountModel >, + private readonly emitter: EventEmitter2, ) {} /** @@ -183,6 +185,16 @@ export class AccountsService { // create a random referral code const referralCode = AccountsService.getRandomReferralCode(); + // create notification for newly created user + this.emitter.emit("notifier.users.notify", { + address: payload.address, + subjectType: "general", + title: "Welcome to Elevate!", + description: + "Welcome to Elevate! Please, integrate your account with provider.", + shortDescription: "Thanks for joining!", + }); + // store the authenticated address in `accounts` return await this.createOrUpdate(accountQuery, { referralCode, diff --git a/runtime/backend/src/notifier/NotifierModule.ts b/runtime/backend/src/notifier/NotifierModule.ts index 322baba1..59887adf 100644 --- a/runtime/backend/src/notifier/NotifierModule.ts +++ b/runtime/backend/src/notifier/NotifierModule.ts @@ -17,6 +17,7 @@ import { AbstractAppModule } from "../common/modules/AbstractAppModule"; // notifier scope import { EmailNotifierModule } from "./modules/EmailNotifierModule"; import { NotifierFactoryModule } from "./modules/NotifierFactoryModule"; +import { UserNotifierModule } from "./modules/UserNotifierModule"; import { AlertsModule } from "./modules/AlertsModule"; /** @@ -44,6 +45,7 @@ import { AlertsModule } from "./modules/AlertsModule"; EmailNotifierModule, NotifierFactoryModule, AlertsModule, + UserNotifierModule, ], }) export class NotifierModule extends AbstractAppModule {} diff --git a/runtime/backend/src/notifier/models/UserNotificationDTO.ts b/runtime/backend/src/notifier/models/UserNotificationDTO.ts new file mode 100644 index 00000000..53ac599b --- /dev/null +++ b/runtime/backend/src/notifier/models/UserNotificationDTO.ts @@ -0,0 +1,145 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Backend + * @author dHealth Network + * @license LGPL-3.0 + */ +// external dependencies +import { ApiProperty } from "@nestjs/swagger"; + +// internal dependencies +import { BaseDTO } from "../../common/models/BaseDTO"; + +/** + * @class UserNotificationDTO + * @description A DTO class that consists of notification properties + * + * @since v0.3.0 + */ +export class UserNotificationDTO extends BaseDTO { + /** + * The Address of this account on dHealth Network. The + * account's **address** typically refers to a human-readable + * series of 39 characters, starting either with a `T`, for + * TESTNET addresses, or with a `N`, for MAINNET addresses. + * + * @example `"NDAPPH6ZGD4D6LBWFLGFZUT2KQ5OLBLU32K3HNY"` + * @access public + * @var {string} + */ + @ApiProperty({ + type: "string", + example: "NDAPPH6ZGD4D6LBWFLGFZUT2KQ5OLBLU32K3HNY", + description: "The Address of the linked account on dHealth Network", + }) + public address?: string; // if no address - notification is for everyone + + /** + * Subject id field, represents identifier + * of subject which was received. Can be asset.id or activity.id + * + * @example `"6377bb780a56699ae5ca4e4c"` + * @access public + * @var {string} + */ + @ApiProperty({ + type: "string", + example: "6377bb780a56699ae5ca4e4c", + description: "Id of the asset or of activity", + }) + public subjectId?: string; // if no subjectId - notification is for general purposes, e.g. strava integrated notification, welcome notification + + /** + * Subject type field represents notification + * type assets or activities related. Can have + * "general" type if notification related to general events + * e.g. Registration, strava integration, etc. + * + * @example `"assets"` + * @access public + * @var {string} + */ + @ApiProperty({ + type: "string", + example: "assets", + description: "Type of the notification", + }) + public subjectType: "assets" | "activities" | "general"; + + /** + * Title of the notification which + * describes notification in 2-3 words. + * + * @example `Successfully integrated strava` + * @access public + * @var {string} + */ + @ApiProperty({ + type: "string", + example: "Successfully integrated strava", + description: "Title of the notification", + }) + public title: string; + + /** + * Description of the notification + * which explains notification in details. + * + * @example `You successfully integrated your strava account to your elevate profile, congrats!` + * @access public + * @var {string} + */ + @ApiProperty({ + type: "string", + example: + "You successfully integrated your strava account to your elevate profile, congrats!", + description: "Full description of the notification", + }) + public description: string; + + /** + * Short description of the notifications + * which should be displayed in notifications *preview*. + * + * @example `You successfully strava account` + * @access public + * @var {string} + */ + @ApiProperty({ + type: "string", + example: "You successfully strava account", + description: "Short description of the notification", + }) + public shortDescription: string; + + /** + * Property which represents date when user got read notification. + * + * @example `2022-11-18T17:06:00.330+00:00` + * @access public + * @var {string} + */ + @ApiProperty({ + type: "string | number", + example: "2022-11-18T17:06:00.330+00:00", + description: "Date when notification has been read by user.", + }) + public readAt?: string | number; + + /** + * Property which represents date when notification has been created. + * + * @example `2022-11-18T17:06:00.330+00:00` + * @access public + * @var {string} + */ + @ApiProperty({ + type: "string | number", + example: "2022-11-18T17:06:00.330+00:00", + description: "Date when notification has been created.", + }) + public createdAt: string | number; +} diff --git a/runtime/backend/src/notifier/models/UserNotificationSchema.ts b/runtime/backend/src/notifier/models/UserNotificationSchema.ts new file mode 100644 index 00000000..49bf28b5 --- /dev/null +++ b/runtime/backend/src/notifier/models/UserNotificationSchema.ts @@ -0,0 +1,167 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Backend + * @author dHealth Network + * @license LGPL-3.0 + */ + +// external dependencies +import { Schema, Prop, SchemaFactory } from "@nestjs/mongoose"; +import { Model } from "mongoose"; + +// internal dependencies +import { Transferable } from "../../common/concerns/Transferable"; +import { UserNotificationDTO } from "./UserNotificationDTO"; +import { Documentable } from "../../common/concerns/Documentable"; +import { Queryable, QueryParameters } from "../../common/concerns/Queryable"; + +@Schema({ + timestamps: true, +}) +export class Notification implements Transferable { + /** + * This field contains the *mongo collection name* for entries + * that are stored using {@link ActivityDocument} or the model + * {@link ActivityModel}. + *

+ * Note that this field **is not** part of document properties + * and used only internally to perform queries that refer to + * an individual collection name, e.g. `$unionWith`. + * + * @access public + * @var {string} + */ + public collectionName = "notifications"; + + /** + * The account's **address**. An address typically refers to a + * human-readable series of 39 characters, starting either with + * a `T`, for TESTNET addresses, or with a `N`, for MAINNET addresses. + *

+ * This field is **required** and *indexed*. + * + * @access public + * @readonly + * @var {string} + */ + @Prop({ required: false, index: true }) + public readonly address: string; + + /** + * Subject id field, represents identifier + * of subject which was received. Can be asset.id or activity.id + * If !subjectId -> notification is for general purpose e.g. Register, strava integration, etc. + * + * @access public + * @readonly + * @var {string} + */ + @Prop({ required: false }) + public readonly subjectId: string; + + /** + * Subject id field, represents identifier + * of subject which was received. Can be asset.id or activity.id + * If !subjectId -> notification is for general purpose e.g. Register, strava integration, etc. + * + * @access public + * @readonly + * @var {string} + */ + @Prop({ required: true }) + public readonly subjectType: "assets" | "activities" | "general"; + + /** + * Title of the notification which + * describes notification in 2-3 words. + * + * @access public + * @readonly + * @var {string} + */ + @Prop({ required: true }) + public readonly title: string; + + /** + * Description of the notification + * which explains notification in details. + * + * @access public + * @readonly + * @var {string} + */ + @Prop({ required: true }) + public readonly description: string; + + /** + * Short description of the notifications + * which should be displayed in notifications *preview*. + * + * @access public + * @readonly + * @var {string} + */ + @Prop({ required: true }) + public readonly shortDescription: string; + + /** + * Property which represents date when user got read notification. + * + * @access public + * @readonly + * @var {string} + */ + @Prop({ required: false }) + public readonly readAt?: string; + + /** + * Property which represents date when notification has been created. + * + * @access public + * @readonly + * @var {string} + */ + @Prop({ required: true }) + public readonly createdAt: string; + + public static fillDTO( + doc: UserNotificationDocument, + dto: UserNotificationDTO, + ): UserNotificationDTO { + dto.address = doc.address; + dto.subjectId = doc.subjectId; + dto.subjectType = doc.subjectType; + dto.title = doc.title; + dto.description = doc.description; + dto.shortDescription = doc.shortDescription; + dto.readAt = doc.readAt; + dto.createdAt = doc.createdAt; + return dto; + } +} + +export type UserNotificationDocument = Notification & Documentable; + +export class UserNotificationModel extends Model {} + +export class UserNotificationQuery extends Queryable { + /** + * Copy constructor for pageable queries in `notifications` collection. + * + * @see Queryable + * @param {AccountIntegrationDocument|undefined} document The *document* instance (defaults to `undefined`) (optional). + * @param {QueryParameters|undefined} queryParameters The query parameters including as defined in {@link QueryParameters} (optional). + */ + public constructor( + document?: UserNotificationDocument, + queryParams: QueryParameters = undefined, + ) { + super(document, queryParams); + } +} + +export const UserNotificationSchema = + SchemaFactory.createForClass(Notification); diff --git a/runtime/backend/src/notifier/models/index.ts b/runtime/backend/src/notifier/models/index.ts index a3995393..b5198d5e 100644 --- a/runtime/backend/src/notifier/models/index.ts +++ b/runtime/backend/src/notifier/models/index.ts @@ -14,3 +14,9 @@ export * from "./TransportConfig"; export * from "./Notifier"; export * from "./NotifierType"; export * from "./ReportNotifierStateData"; + +// schemas +export * from "./UserNotificationSchema"; + +// DTO +export * from "./UserNotificationDTO"; diff --git a/runtime/backend/src/notifier/modules/UserNotifierModule.ts b/runtime/backend/src/notifier/modules/UserNotifierModule.ts new file mode 100644 index 00000000..f7e79943 --- /dev/null +++ b/runtime/backend/src/notifier/modules/UserNotifierModule.ts @@ -0,0 +1,45 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Backend + * @author dHealth Network + * @license LGPL-3.0 + */ +// external dependencies +import { Module } from "@nestjs/common"; +import { MongooseModule } from "@nestjs/mongoose"; + +// internal dependencies +import { UserNotifier } from "../services/UserNotifier"; +import { + Notification, + UserNotificationSchema, +} from "../models/UserNotificationSchema"; +import { QueryModule } from "../../common/modules/QueryModule"; +import { UserNotificationsController } from "../routes/UserNotificationsController"; + +/** + * @label SCOPES + * @class UserNotifierModule + * @description The notifier scope's main module. This module + * is loaded by the software when `"notifier"` is present in + * the enabled scopes through configuration (config/dapp.json). + * This module implements user notifications. + *

+ * + * @since v0.3.2 + */ +@Module({ + imports: [ + QueryModule, + MongooseModule.forFeature([ + { name: Notification.name, schema: UserNotificationSchema }, + ]), + ], + controllers: [UserNotificationsController], + providers: [UserNotifier], + exports: [UserNotifier], +}) +export class UserNotifierModule {} diff --git a/runtime/backend/src/notifier/routes/UserNotificationsController.ts b/runtime/backend/src/notifier/routes/UserNotificationsController.ts new file mode 100644 index 00000000..4e97fce4 --- /dev/null +++ b/runtime/backend/src/notifier/routes/UserNotificationsController.ts @@ -0,0 +1,44 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Backend + * @author dHealth Network + * @license LGPL-3.0 + */ + +// external dependencies +import { Get, Controller, Param, Put, UseGuards, Body } from "@nestjs/common"; + +// internal dependencies +import { UserNotifier } from "../services/UserNotifier"; +import { AuthGuard } from "../../common/traits/AuthGuard"; + +@Controller("notifications") +export class UserNotificationsController { + constructor(private readonly notifier: UserNotifier) {} + + @UseGuards(AuthGuard) + @Get(":address") + async getNotificationsByAddress(@Param("address") address: string) { + try { + return await this.notifier.findAllByAddress(address); + } catch (err) { + throw err; + } + } + + @UseGuards(AuthGuard) + @Put("read") + async handleRead(@Body() notificationPayload: any) { + console.log({ notificationPayload }); + + try { + await this.notifier.markAsRead(notificationPayload.id); + return await this.notifier.findAllByAddress(notificationPayload.address); + } catch (err) { + throw err; + } + } +} diff --git a/runtime/backend/src/notifier/services/UserNotifier.ts b/runtime/backend/src/notifier/services/UserNotifier.ts new file mode 100644 index 00000000..1e500057 --- /dev/null +++ b/runtime/backend/src/notifier/services/UserNotifier.ts @@ -0,0 +1,104 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Backend + * @author dHealth Network + * @license LGPL-3.0 + */ +// external dependencies +import { HttpException, HttpStatus, Injectable } from "@nestjs/common"; +import { OnEvent } from "@nestjs/event-emitter"; + +// internal dependencies +import { UserNotificationDTO } from "../models/UserNotificationDTO"; +import { QueryService } from "../../common/services/QueryService"; +import { + UserNotificationModel, + UserNotificationQuery, + UserNotificationSchema, + UserNotificationDocument, + Notification, +} from "../models/UserNotificationSchema"; +import { InjectModel } from "@nestjs/mongoose"; + +/** + * @class UserNotifier + * @description The main service of the UserNotifier module. + * + * @since v0.3.2 + */ +@Injectable() +export class UserNotifier { + /** + * The constructor of the service. + * + * @constructor + * @param {UserNotificationModel} model + * @param {QueriesService} queriesService + */ + constructor( + @InjectModel(Notification.name) + private readonly model: UserNotificationModel, + private readonly queryService: QueryService< + UserNotificationDocument, + UserNotificationModel + >, + ) {} + /** + * This method handles starting of challenge validation. + * Gets trigged by "auth.open" emit from handleConnection(). + * Calls .startCronJob from validateChallengeScheduler. + * + * @param {any} payload Contains challenge string + * @returns {void} Emits "auth.open" event which triggers validating of the received challenge + */ + @OnEvent("notifier.users.notify", { async: true }) + public async createNotification(notification: UserNotificationDTO) { + this.queryService.createOrUpdate( + new UserNotificationQuery({ + address: notification.address, + subjectId: notification.subjectId, + subjectType: notification.subjectType, + title: notification.title, + description: notification.description, + shortDescription: notification.shortDescription, + readAt: notification.readAt, + } as UserNotificationDocument), + this.model, + {}, + ); + } + + public async findAllByAddress(address: string) { + return await this.queryService.find( + new UserNotificationQuery({ address } as UserNotificationDocument), + this.model, + ); + } + + public async markAsRead(notificationId: string) { + const existingNotification = await this.queryService.find( + new UserNotificationQuery({ + _id: notificationId, + } as UserNotificationDocument), + this.model, + ); + + if (!existingNotification) { + throw new HttpException("Not found", HttpStatus.NOT_FOUND); + } + + await this.queryService.createOrUpdate( + new UserNotificationQuery({ + _id: existingNotification.data[0]._id, + address: existingNotification.data[0].address, + } as UserNotificationDocument), + this.model, + { + readAt: `${new Date()}`, + }, + ); + } +} diff --git a/runtime/backend/src/oauth/routes/OAuthController.ts b/runtime/backend/src/oauth/routes/OAuthController.ts index 61002a56..589ffd88 100644 --- a/runtime/backend/src/oauth/routes/OAuthController.ts +++ b/runtime/backend/src/oauth/routes/OAuthController.ts @@ -28,6 +28,7 @@ import { getSchemaPath, } from "@nestjs/swagger"; import { Request, Response } from "express"; +import { EventEmitter2 } from "@nestjs/event-emitter"; // internal dependencies import { AuthGuard } from "../../common/traits/AuthGuard"; @@ -109,6 +110,7 @@ export class OAuthController { public constructor( private readonly oauthService: OAuthService, private readonly authService: AuthService, + private readonly emitter: EventEmitter2, ) {} /** @@ -198,6 +200,15 @@ export class OAuthController { // requests an access token from the OAuth provider await this.oauthService.oauthCallback(provider, account, query); + // create notification for successful account integration + this.emitter.emit("notifier.users.notify", { + address: account.address, + subjectType: "general", + title: `Successfully ${provider}`, + description: `You successfully integrated ${provider}, you can now post an activities!`, + shortDescription: `You have now ${provider} integrated`, + }); + // create a "success" status response return StatusDTO.create(200); } catch (e) { diff --git a/runtime/dapp-frontend-vue/resources/i18n/en/Medals.json b/runtime/dapp-frontend-vue/resources/i18n/en/Medals.json new file mode 100644 index 00000000..02c524cf --- /dev/null +++ b/runtime/dapp-frontend-vue/resources/i18n/en/Medals.json @@ -0,0 +1,15 @@ +{ + "medals": { + "badge_title": "My Badge", + "badge_description": "Keep recording your exercise efforts", + "medal_condition": "Condition:", + "medal_activities": "Eligible Activities:", + "medal_board": " Medal Board", + "referrals_title": "Referral", + "referrals_description": "Invite your friends to join and earn more medal!", + "distance_title": "Distance", + "distance_description": "Start workout and reach the target distance!", + "month_title": "Breast Cancer Month", + "month_description": "workout during the Breast Cancer Month to get special medals!" + } +} \ No newline at end of file diff --git a/runtime/dapp-frontend-vue/resources/i18n/index.ts b/runtime/dapp-frontend-vue/resources/i18n/index.ts index cf226872..23f881b0 100644 --- a/runtime/dapp-frontend-vue/resources/i18n/index.ts +++ b/runtime/dapp-frontend-vue/resources/i18n/index.ts @@ -15,6 +15,7 @@ import dashboardTranslations from "./en/Dashboard.json"; import settingsTranslations from "./en/Settings.json"; import activitiesTranslations from "./en/Activities.json"; import legalTranslations from "./en/Legal.json"; +import medalsTranslations from "./en/Medals.json"; // bundle all .JSON together export default { @@ -26,4 +27,5 @@ export default { ...settingsTranslations, ...activitiesTranslations, ...legalTranslations, + ...medalsTranslations, }; diff --git a/runtime/dapp-frontend-vue/src/App.ts b/runtime/dapp-frontend-vue/src/App.ts index f2b38b1b..5a1002e6 100644 --- a/runtime/dapp-frontend-vue/src/App.ts +++ b/runtime/dapp-frontend-vue/src/App.ts @@ -57,7 +57,11 @@ export default class App extends MetaView { text: "Dashboard", icon: "icons/menu-dashboard.svg", }, - { path: "#1", text: "Rewards", icon: "icons/menu-rewards.svg" }, + { + path: { name: "app.medals" }, + text: "Rewards", + icon: "icons/menu-rewards.svg", + }, { path: { name: "app.settings" }, text: "Settings", diff --git a/runtime/dapp-frontend-vue/src/_vars.scss b/runtime/dapp-frontend-vue/src/_vars.scss index bd2bf087..71bc45ea 100644 --- a/runtime/dapp-frontend-vue/src/_vars.scss +++ b/runtime/dapp-frontend-vue/src/_vars.scss @@ -13,6 +13,7 @@ $white: #ffffff; $black: #000000; $base-grey: #f6f6f6; $base-grey-lighter: #f7f7f7; +$base-grey-darker: #F9F8F8; $grey-darker: #d7dbe8; $system-grey-60: #7e7b93; $button-text-dark: #09090a; @@ -42,6 +43,9 @@ $trendline-positive: #3d7773; $trendline-negative: #f8503e; $tooltip-dark: #181818; $tooltip-grey-light: #f5f5f5; +$blue-1: #6EE7EE; + +$notifications-border-grey: #D9D9D9; // overlays $modal-overlay: rgba(19, 30, 25, 0.7); diff --git a/runtime/dapp-frontend-vue/src/assets/dhealth-notifications-icon.svg b/runtime/dapp-frontend-vue/src/assets/dhealth-notifications-icon.svg new file mode 100644 index 00000000..4af604bc --- /dev/null +++ b/runtime/dapp-frontend-vue/src/assets/dhealth-notifications-icon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/runtime/dapp-frontend-vue/src/assets/medals/10.svg b/runtime/dapp-frontend-vue/src/assets/medals/10.svg new file mode 100644 index 00000000..022714ad --- /dev/null +++ b/runtime/dapp-frontend-vue/src/assets/medals/10.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/runtime/dapp-frontend-vue/src/assets/medals/100.svg b/runtime/dapp-frontend-vue/src/assets/medals/100.svg new file mode 100644 index 00000000..e149f813 --- /dev/null +++ b/runtime/dapp-frontend-vue/src/assets/medals/100.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/runtime/dapp-frontend-vue/src/assets/medals/50.svg b/runtime/dapp-frontend-vue/src/assets/medals/50.svg new file mode 100644 index 00000000..eb4f58ec --- /dev/null +++ b/runtime/dapp-frontend-vue/src/assets/medals/50.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/runtime/dapp-frontend-vue/src/assets/notifications-new.svg b/runtime/dapp-frontend-vue/src/assets/notifications-new.svg new file mode 100644 index 00000000..158f179c --- /dev/null +++ b/runtime/dapp-frontend-vue/src/assets/notifications-new.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/runtime/dapp-frontend-vue/src/assets/notifications.svg b/runtime/dapp-frontend-vue/src/assets/notifications.svg new file mode 100644 index 00000000..0f536c99 --- /dev/null +++ b/runtime/dapp-frontend-vue/src/assets/notifications.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/dapp-frontend-vue/src/assets/rewards-tip.svg b/runtime/dapp-frontend-vue/src/assets/rewards-tip.svg new file mode 100644 index 00000000..e1457368 --- /dev/null +++ b/runtime/dapp-frontend-vue/src/assets/rewards-tip.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/dapp-frontend-vue/src/assets/share-rewards.svg b/runtime/dapp-frontend-vue/src/assets/share-rewards.svg new file mode 100644 index 00000000..696427df --- /dev/null +++ b/runtime/dapp-frontend-vue/src/assets/share-rewards.svg @@ -0,0 +1,3 @@ + + + diff --git a/runtime/dapp-frontend-vue/src/components/AppHeader/AppHeader.ts b/runtime/dapp-frontend-vue/src/components/AppHeader/AppHeader.ts index 3e2a6b54..4b9716e6 100644 --- a/runtime/dapp-frontend-vue/src/components/AppHeader/AppHeader.ts +++ b/runtime/dapp-frontend-vue/src/components/AppHeader/AppHeader.ts @@ -22,6 +22,9 @@ import MobileNavigationButton from "../MobileNavigationButton/MobileNavigationBu import Dropdown from "../Dropdown/Dropdown.vue"; import UserBalance from "../UserBalance/UserBalance.vue"; import UiButton from "../UiButton/UiButton.vue"; +import Notifications from "../Notifications/Notifications.vue"; + +import { Notification } from "../Notifications/Notifications"; // style resource import "./AppHeader.scss"; @@ -40,10 +43,13 @@ export interface HeaderLink { Dropdown, UserBalance, UiButton, + Notifications, }, computed: { ...mapGetters({ isAuthenticated: "auth/isAuthenticated", + notifications: "notifications/getNotifications", + currentUserAddress: "auth/getCurrentUserAddress", }), }, }) @@ -65,6 +71,20 @@ export default class AppHeader extends MetaView { */ @Prop({ default: true }) protected showIcons?: boolean; + /** + * This property contains the authenticated user's dHealth Account + * Address. This field is populated using the Vuex Store after a + * successful request to the backend API's `/me` endpoint. + *

+ * The `!`-operator tells TypeScript that this value is required + * and the *public* access permits the Vuex Store to mutate this + * value when it is necessary. + * + * @access public + * @var {string} + */ + public currentUserAddress!: string; + /** * @todo ask the user for confirmation */ @@ -138,6 +158,15 @@ export default class AppHeader extends MetaView { ]; } + /** + * This computed defines *temporary* items + * for the notifications component. + * @todo remove once implement notifications on backend + * + * @access public + */ + public notifications?: any[]; + /** * Watcher that sets overflowY hidden, * to prevent scrolling of body when mobile menu opened @@ -160,4 +189,29 @@ export default class AppHeader extends MetaView { this.isMenuOpen = false; } } + + public handleNotificationView(notification: any) { + this.$root.$emit("modal", { + type: "in-app-notification", + overlayColor: "rgba(0, 0, 0, 0.2)", + width: 720, + modalBg: "#FFFFFF", + title: notification.title, + description: notification.description, + }); + + if (!notification.readAt) { + this.$store.dispatch("notifications/markNotificationAsRead", { + address: this.currentUserAddress, + id: notification._id, + }); + } + } + + // public mounted() { + // this.notiifcationItems = this.notifications.map((notifcation: any) => ({ + // ...notifcation, + // icon: "dhealth-notifications-icon.svg", + // })); + // } } diff --git a/runtime/dapp-frontend-vue/src/components/AppHeader/AppHeader.vue b/runtime/dapp-frontend-vue/src/components/AppHeader/AppHeader.vue index c1fb47e9..3e314179 100644 --- a/runtime/dapp-frontend-vue/src/components/AppHeader/AppHeader.vue +++ b/runtime/dapp-frontend-vue/src/components/AppHeader/AppHeader.vue @@ -121,6 +121,10 @@
diff --git a/runtime/dapp-frontend-vue/src/components/Notifications/Notifications.scss b/runtime/dapp-frontend-vue/src/components/Notifications/Notifications.scss new file mode 100644 index 00000000..9f1c34d7 --- /dev/null +++ b/runtime/dapp-frontend-vue/src/components/Notifications/Notifications.scss @@ -0,0 +1,74 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Vue Frontend + * @author dHealth Network + * @license LGPL-3.0 + */ + + @import "../../vars.scss"; + + .dapp-notifications { + cursor: pointer; + display: inline-block; + margin-right: 10px; + position: relative; + + &__list { + position: absolute; + top: 100%; + right: 0; + width: 326px; + background-color: $base-grey-balance; + padding: 34px 25px 24px 25px; + box-shadow: 3px 3px 0px $black; + border: 1px solid $black; + border-radius: 8px; + text-align: left; + max-height: 238px; + overflow-y: scroll; + + .notification-item { + padding-bottom: 11px; + margin-bottom: 8px; + border-bottom: 1px solid $notifications-border-grey; + + &:last-child { + border-bottom: none; + padding-bottom: 0; + margin-bottom: 0; + } + + h3 { + font-size: 13px; + font-weight: 700; + font-size: 13px; + line-height: 130%; + } + + p { + font-weight: 400; + font-size: 11px; + line-height: 130%; + } + + .icon-wrapper { + flex: 1 0 44px; + } + + .state { + text-align: center; + + .unread-circle { + width: 6px; + height: 6px; + display: inline-block; + border-radius: 50%; + background-color: $grey-system-dark; + } + } + } + } + } \ No newline at end of file diff --git a/runtime/dapp-frontend-vue/src/components/Notifications/Notifications.ts b/runtime/dapp-frontend-vue/src/components/Notifications/Notifications.ts new file mode 100644 index 00000000..6e8c66d1 --- /dev/null +++ b/runtime/dapp-frontend-vue/src/components/Notifications/Notifications.ts @@ -0,0 +1,91 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Vue Frontend + * @author dHealth Network + * @license LGPL-3.0 + */ +// external dependencies +import { Component, Prop } from "vue-property-decorator"; +import InlineSvg from "vue-inline-svg"; + +// internal dependencies +import { MetaView } from "@/views/MetaView"; +import { MedalItem } from "../RewardsList/RewardsList"; + +// style resource +import "./Notifications.scss"; + +export interface Notification { + createdAt: string | number; + title: string; + description: string; + icon: string; + viewed: boolean; + medal?: MedalItem; + id: number | string; +} + +/* + * @class Notifications + * @description This class implements a Vue component to display + * latest notifications received by user + * + * @since v0.3.0 + */ +@Component({ + components: { + InlineSvg, + }, + methods: {}, + computed: {}, +}) +export default class Notifications extends MetaView { + /** + * Prop which defines items for available notifications. + * + * @access protected + * @var {Notification[]} + */ + @Prop({ default: () => [] }) protected items?: Notification[]; + + isOpen = false; + + /** + * This computed checks if list contains + * item with "viewed: false" prop. + * + * @access protected + * @returns boolean + */ + public get unreadExist(): boolean { + const unread = this.items?.filter( + (notification: any) => !notification.readAt + ); + return !!unread && unread.length > 0; + } + + public viewNotification(medalNotification: Notification) { + this.$emit("notification-viewed", medalNotification); + if (medalNotification.medal) { + const relatedMedal = medalNotification.medal; + this.$root.$emit("modal", { + type: "medal", + overlayColor: "rgba(0, 0, 0, 0.2)", + width: 720, + modalBg: "#FFFFFF", + medal: relatedMedal.image, + condition: relatedMedal.condition, + activities: relatedMedal.relatedActivities, + }); + } + } + + hideNotifications() { + if (this.isOpen) { + this.isOpen = false; + } + } +} diff --git a/runtime/dapp-frontend-vue/src/components/Notifications/Notifications.vue b/runtime/dapp-frontend-vue/src/components/Notifications/Notifications.vue new file mode 100644 index 00000000..8f9ec2e4 --- /dev/null +++ b/runtime/dapp-frontend-vue/src/components/Notifications/Notifications.vue @@ -0,0 +1,59 @@ + + + + diff --git a/runtime/dapp-frontend-vue/src/components/RewardsList/RewardsList.scss b/runtime/dapp-frontend-vue/src/components/RewardsList/RewardsList.scss new file mode 100644 index 00000000..ff6c420e --- /dev/null +++ b/runtime/dapp-frontend-vue/src/components/RewardsList/RewardsList.scss @@ -0,0 +1,113 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Vue Frontend + * @author dHealth Network + * @license LGPL-3.0 + */ + + @import "../../vars.scss"; + + .dapp-rewards-list { + padding: 46px 106px; + box-shadow: 5px 5px 0px $black; + border-radius: 32px; + background-color: $base-grey-darker; + text-align: left; + + &__title, &__description { + color: $black; + } + + &__title { + font-weight: 600; + font-size: 42px; + line-height: 140%; + margin-bottom: 20px; + } + + &__description { + font-weight: 500; + font-size: 30px; + line-height: 140%; + } + + &__share { + font-weight: 500; + font-size: 30px; + line-height: 140%; + cursor: pointer; + + img { + display: inline-block; + } + } + + &__items { + display: flex; + flex-wrap: wrap; + justify-content: space-around; + + .medal { + flex: 1 0 21%; + text-align: center; + margin-bottom: 58px; + cursor: pointer; + position: relative; + + &:hover { + .medal__tip { + display: block; + } + } + + .medal__content { + opacity: 0.5; + } + + &.received { + .medal__content { + opacity: 1; + } + + &:hover { + .medal__tip { + display: none; + } + } + } + + + &__tip { + position: absolute; + width: 100%; + top: -115px; + background-color: $blue-1; + border-radius: 9px; + padding: 10px 28px; + color: $login-dark; + font-weight: 500; + font-size: 23.5311px; + line-height: 140%; + display: none; + opacity: 1; + + .triangle { + position: absolute; + display: inline-block; + bottom: -30px; + left: 50%; + transform: translateX(-50%); + } + } + + img { + display: inline-block; + width: 201px; + height: 218px; + } + } + } + } \ No newline at end of file diff --git a/runtime/dapp-frontend-vue/src/components/RewardsList/RewardsList.ts b/runtime/dapp-frontend-vue/src/components/RewardsList/RewardsList.ts new file mode 100644 index 00000000..3e00d88e --- /dev/null +++ b/runtime/dapp-frontend-vue/src/components/RewardsList/RewardsList.ts @@ -0,0 +1,109 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Vue Frontend + * @author dHealth Network + * @license LGPL-3.0 + */ +// external dependencies +import { Component, Prop } from "vue-property-decorator"; +import InlineSvg from "vue-inline-svg"; + +// internal dependencies +import { MetaView } from "@/views/MetaView"; +import Card from "@/components/Card/Card.vue"; + +// style resource +import "./RewardsList.scss"; + +export interface MedalItem { + image: string; + condition: string; + relatedActivities?: string; + received: boolean; + assetId: string; +} + +/* + * @class RewardsList + * @description This class implements a Vue component to display + * basic list of available medals, based on props value + * + * @since v0.3.0 + */ +@Component({ + components: { + Card, + InlineSvg, + }, + methods: { + onMedalClick: () => ({}), + }, +}) +export default class RewardsList extends MetaView { + /** + * Prop which defines title of current rewards list, + * example: "Referral". + * Defaults to "". + * + * @access protected + * @var {string} + */ + @Prop({ default: "" }) protected title?: string; + + /** + * Prop which defines description of current rewards list, + * example: "Keep recording your exercise efforts". + * Defaults to "". + * + * @access protected + * @var {string} + */ + @Prop({ default: "" }) protected description?: string; + + /** + * Prop which defines list of the rewards and their state + * + * @access protected + * @var {MedalItem[]} + */ + @Prop({ default: () => [] }) protected medals?: MedalItem[]; + + /** + * This method opens "share" popup + * + * @access protected + * @returns void + */ + public handleShare() { + // display a custom modal popup + this.$root.$emit("modal", { + type: "share", + overlayColor: "rgba(0, 0, 0, .50)", + width: 518, + modalBg: "#FFFFFF", + }); + } + + /** + * This method handles opening of medal popup with data received in argument + * + * @access protected + * @returns void + */ + public onMedalClick(medal: MedalItem) { + if (medal.received) { + this.$root.$emit("modal", { + type: "medal", + overlayColor: "rgba(0, 0, 0, 0.2)", + width: 720, + modalBg: "#FFFFFF", + medal: medal.image, + condition: medal.condition, + activities: medal.relatedActivities, + }); + } + } +} diff --git a/runtime/dapp-frontend-vue/src/components/RewardsList/RewardsList.vue b/runtime/dapp-frontend-vue/src/components/RewardsList/RewardsList.vue new file mode 100644 index 00000000..fe518b7b --- /dev/null +++ b/runtime/dapp-frontend-vue/src/components/RewardsList/RewardsList.vue @@ -0,0 +1,43 @@ + + + + diff --git a/runtime/dapp-frontend-vue/src/components/UiPopup/UiPopup.scss b/runtime/dapp-frontend-vue/src/components/UiPopup/UiPopup.scss index db06d478..644a0787 100644 --- a/runtime/dapp-frontend-vue/src/components/UiPopup/UiPopup.scss +++ b/runtime/dapp-frontend-vue/src/components/UiPopup/UiPopup.scss @@ -164,5 +164,48 @@ } } } + + &__medal { + padding: 130px 56px 66px 56px; + border-radius: 41px; + position: relative; + border: 2px solid $black; + + .medal-image { + position: absolute; + top: -18%; + left: 50%; + transform: translateX(-50%); + width: 235px; + height: auto; + } + + .medal-details { + background-color: $stats-teal-light; + padding: 23px 52px; + border-radius: 20px; + + .details-item { + margin-bottom: 21px; + + .title { + font-weight: 700; + font-size: 35.7795px; + line-height: 140%; + } + + .value { + font-weight: 400; + font-size: 30.6681px; + line-height: 130%; + padding: 0 52px; + } + + &:last-child { + margin-bottom: 0; + } + } + } + } } } diff --git a/runtime/dapp-frontend-vue/src/components/UiPopup/UiPopup.ts b/runtime/dapp-frontend-vue/src/components/UiPopup/UiPopup.ts index 53fb71ad..d251de74 100644 --- a/runtime/dapp-frontend-vue/src/components/UiPopup/UiPopup.ts +++ b/runtime/dapp-frontend-vue/src/components/UiPopup/UiPopup.ts @@ -25,7 +25,7 @@ export interface ModalConfig { keepOnBgClick?: boolean; overlayColor?: string; modalBg?: string; - type: "form" | "notification" | "share"; + type: "form" | "notification" | "share" | "medal" | "in-app-notification"; title?: string; width: number; fields?: any[]; @@ -33,6 +33,9 @@ export interface ModalConfig { description?: string; illustration?: string; shareNetworks?: SocialPlatformDTO[]; + condition?: string; + activities?: string; + medal?: string; } // styles source diff --git a/runtime/dapp-frontend-vue/src/components/UiPopup/UiPopup.vue b/runtime/dapp-frontend-vue/src/components/UiPopup/UiPopup.vue index 4f18529e..b56ee168 100644 --- a/runtime/dapp-frontend-vue/src/components/UiPopup/UiPopup.vue +++ b/runtime/dapp-frontend-vue/src/components/UiPopup/UiPopup.vue @@ -117,6 +117,59 @@ :val="refCode" />
+ + +
+ + +
+
+

+

+

+
+

+

+

+
+
+ + +
+ +
+
+

+ +

+
+
diff --git a/runtime/dapp-frontend-vue/src/components/index.ts b/runtime/dapp-frontend-vue/src/components/index.ts index a9dfcdd6..0b50283c 100644 --- a/runtime/dapp-frontend-vue/src/components/index.ts +++ b/runtime/dapp-frontend-vue/src/components/index.ts @@ -35,6 +35,8 @@ import TopActivities from "./TopActivities/TopActivities.vue"; import UiButton from "./UiButton/UiButton.vue"; import UiPopup from "./UiPopup/UiPopup.vue"; import UserBalance from "./UserBalance/UserBalance.vue"; +import RewardsList from "./RewardsList/RewardsList.vue"; +import Notifications from "./Notifications/Notifications.vue"; // scoped export of application-level components export const AppComponents = { @@ -62,6 +64,8 @@ export const AppComponents = { UiButton, UiPopup, UserBalance, + RewardsList, + Notifications, }; // scoped export of library-level components diff --git a/runtime/dapp-frontend-vue/src/kernel/remote/HttpRequestHandler.ts b/runtime/dapp-frontend-vue/src/kernel/remote/HttpRequestHandler.ts index 733c4e56..60b3ecfe 100644 --- a/runtime/dapp-frontend-vue/src/kernel/remote/HttpRequestHandler.ts +++ b/runtime/dapp-frontend-vue/src/kernel/remote/HttpRequestHandler.ts @@ -106,6 +106,14 @@ export class HttpRequestHandler implements RequestHandler { }); } + // POST requests are supported + if (method === "PUT") { + return axios.put(url, body, { + ...options, + headers, + }); + } + // GET requests are supported return axios.get(url, { ...options, diff --git a/runtime/dapp-frontend-vue/src/models/AssetDTO.ts b/runtime/dapp-frontend-vue/src/models/AssetDTO.ts new file mode 100644 index 00000000..54b1efb6 --- /dev/null +++ b/runtime/dapp-frontend-vue/src/models/AssetDTO.ts @@ -0,0 +1,72 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Vue Frontend + * @author dHealth Network + * @license LGPL-3.0 + */ +/** + * @interface AssetDTO + * @description This interface defines the fields that are returned + * by the backend runtime for individual entries using the endpoint + * `/assets`. + * endpoint. + *

+ * @example Using the `AssetDTO` interface + * ```typescript + * const entry = { + * data: [{ + * transactionHash: "4288A7ACF51A04AEFFBAA3DC96BCB96F20BA95671C19C3EE9E0443BC0FB79A61", + * userAddress: "NDAPPH6ZGD4D6LBWFLGFZUT2KQ5OLBLU32K3HNY", + creationBlock: 123456, + assetId: "39E0C49FA322A459", + amount: 123 + * }], + pagination: {} + * ``` + *

+ * #### Properties + * + * @param {string} data Contains array of asset entries. + * @param {string} pagination Contains pagination data. + * + * @since v0.5.0 + */ +export interface AssetDTO { + data: AssetEntry[]; + pagination: any; +} + +/** + * @interface AssetEntry + * @description This interface defines the fields that are included + * into `data` field in `/assets` endpoint response. + * endpoint. + *

+ * @example Using the `AssetDTO` interface + * ```typescript + * const entry = { + * transactionHash: "4288A7ACF51A04AEFFBAA3DC96BCB96F20BA95671C19C3EE9E0443BC0FB79A61", + * userAddress: "NDAPPH6ZGD4D6LBWFLGFZUT2KQ5OLBLU32K3HNY", + creationBlock: 123456, + assetId: "39E0C49FA322A459", + amount: 123 + } + * ``` + *

+ * #### Properties + * + * @param {string} data Contains array of asset entries. + * @param {string} pagination Contains pagination data. + * + * @since v0.5.0 + */ +export interface AssetEntry { + transactionHash: string; + userAddress: string; + creationBlock: number; + assetId: string; + amount: string; +} diff --git a/runtime/dapp-frontend-vue/src/router.ts b/runtime/dapp-frontend-vue/src/router.ts index 71fdffa4..79e66ebd 100644 --- a/runtime/dapp-frontend-vue/src/router.ts +++ b/runtime/dapp-frontend-vue/src/router.ts @@ -117,6 +117,16 @@ export const createRouter = ($store: any): VueRouter => { }, component: () => import("./views/Dashboard/Dashboard.vue"), }, + + { + path: "/medals", + name: "app.medals", + meta: { + layout: "app/default", + middleware: [auth], + }, + component: () => import("./views/Medals/Medals.vue"), + }, { path: "/settings", name: "app.settings", diff --git a/runtime/dapp-frontend-vue/src/services/NotificationsService.ts b/runtime/dapp-frontend-vue/src/services/NotificationsService.ts new file mode 100644 index 00000000..efa9ed29 --- /dev/null +++ b/runtime/dapp-frontend-vue/src/services/NotificationsService.ts @@ -0,0 +1,76 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Vue Frontend + * @author dHealth Network + * @license LGPL-3.0 + */ + +// internal dependencies +import { BackendService } from "./BackendService"; +import { HttpRequestHandler } from "../kernel/remote/HttpRequestHandler"; + +/** + * @class NotificationsService + * @description This class handles backend requests for the `/notifications` + * namespace and endpoints related to *notifications*. + * + * @since v0.5.0 + */ +export class NotificationsService extends BackendService { + /** + * This property sets the request handler used for the implemented + * requests. This handler forwards the execution of the request to + * `axios`. + * + * @access protected + * @returns {HttpRequestHandler} + */ + protected get handler(): HttpRequestHandler { + return new HttpRequestHandler(); + } + + /** + * This method fetches the social platform configuration objects + * from the backend runtime using the `/social/platforms` API. + * + * @access public + * @async + * @returns {Promise} An array of social platform configuration objects. + */ + public async getNotificationsByAddress(address: string): Promise { + // fetch from backend runtime + const response = await this.handler.call( + this.getUrl(`notifications/${address}`), + "GET", + undefined, // no-body + { withCredentials: true, credentials: "include" } // no-headers + ); + + // return the list of identifiers + return response.data; + } + + /** + * This method fetches updated notifications list + * once notification has been read. + * + * @access public + * @async + * @returns {Promise} An array of social platform configuration objects. + */ + public async patchNotificationAsRead(body: any): Promise { + // fetch from backend runtime + const response = await this.handler.call( + this.getUrl(`notifications/read`), + "PUT", + body, + { withCredentials: true, credentials: "include" } // no-headers + ); + + // return the list of identifiers + return response.data; + } +} diff --git a/runtime/dapp-frontend-vue/src/services/RewardsService.ts b/runtime/dapp-frontend-vue/src/services/RewardsService.ts new file mode 100644 index 00000000..2f6a50b2 --- /dev/null +++ b/runtime/dapp-frontend-vue/src/services/RewardsService.ts @@ -0,0 +1,56 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Vue Frontend + * @author dHealth Network + * @license LGPL-3.0 + */ + +// internal dependencies +import { BackendService } from "./BackendService"; +import { HttpRequestHandler } from "../kernel/remote/HttpRequestHandler"; +import { AssetDTO } from "../models/AssetDTO"; + +/** + * @class RewardsService + * @description This class handles backend requests for the `/assets` + * namespace and endpoints related to *social platforms*. + * + * @since v0.5.0 + */ +export class RewardsService extends BackendService { + /** + * This property sets the request handler used for the implemented + * requests. This handler forwards the execution of the request to + * `axios`. + * + * @access protected + * @returns {HttpRequestHandler} + */ + protected get handler(): HttpRequestHandler { + return new HttpRequestHandler(); + } + + /** + * This method fetches the social platform configuration objects + * from the backend runtime using the `/social/platforms` API. + * + * @access public + * @async + * @returns {Promise} An array of social platform configuration objects. + */ + public async getAssetsByAddress(address: string): Promise { + // fetch from backend runtime + const response = await this.handler.call( + this.getUrl(`assets/${address}`), + "GET", + undefined, // no-body + { withCredentials: true, credentials: "include" } // no-headers + ); + + // return the list of identifiers + return response.data; + } +} diff --git a/runtime/dapp-frontend-vue/src/state/index.ts b/runtime/dapp-frontend-vue/src/state/index.ts index f87f64cc..b6c4a549 100644 --- a/runtime/dapp-frontend-vue/src/state/index.ts +++ b/runtime/dapp-frontend-vue/src/state/index.ts @@ -15,3 +15,4 @@ export * from "./store/AuthModule"; export * from "./store/LeaderboardModule"; export * from "./store/OAuthModule"; export * from "./store/StatisticsModule"; +export * from "./store/NotificationsModule"; diff --git a/runtime/dapp-frontend-vue/src/state/store/NotificationsModule.ts b/runtime/dapp-frontend-vue/src/state/store/NotificationsModule.ts new file mode 100644 index 00000000..daee8f43 --- /dev/null +++ b/runtime/dapp-frontend-vue/src/state/store/NotificationsModule.ts @@ -0,0 +1,130 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Vuex Store + * @author dHealth Network + * @license LGPL-3.0 + */ + +// external dependencies +import { ActionContext } from "vuex"; + +// internal dependencies +import { RootState } from "./Store"; +import { AwaitLock } from "../AwaitLock"; +import { NotificationsService } from "../../services/NotificationsService"; +import moment from "moment"; + +// creates an "async"-lock for state of pending initialization +// this will be kept *locally* to this store module implementation +const Lock = AwaitLock.create(); + +moment.relativeTimeThreshold("d", 30 * 12); +moment.updateLocale("en", { + relativeTime: { + future: "in %s", + past: "%s ", + s: "sec", + m: "%dm", + mm: "%dm", + h: "%dh", + hh: "%dh", + d: "%dd", + dd: "%dd", + M: "amth", + MM: "%dmths", + y: "y", + yy: "%dy", + }, +}); + +export interface NotificationsModuleState { + initialized: boolean; + notifications: any[]; +} + +/** + * + */ +export type NotificationsModuleContext = ActionContext< + NotificationsModuleState, + RootState +>; + +export const NotificationsModule = { + // this store module is namespaced, meaning the + // module name must be included when calling a + // mutation, getter or action, i.e. "integrations/getIntegrations". + namespaced: true, + state: (): NotificationsModuleState => ({ + initialized: false, + notifications: [], + }), + + getters: { + getNotifications: (state: NotificationsModuleState): any[] => + state.notifications, + }, + + mutations: { + /** + * + */ + setInitialized: (state: NotificationsModuleState, payload: boolean) => + (state.initialized = payload), + + /** + * + */ + setNotifications: (state: NotificationsModuleState, payload: any[]) => { + const notifications = payload.map((notification: any) => ({ + ...notification, + icon: "dhealth-notifications-icon.svg", + createdAt: moment(new Date(notification.createdAt)).fromNow(true), + })); + state.notifications = notifications; + }, + }, + + actions: { + /** + * + */ + async fetchNotifications( + context: NotificationsModuleContext, + address: string + ): Promise { + try { + const service = new NotificationsService(); + const response = await service.getNotificationsByAddress(address); + + context.commit("setNotifications", response.data.reverse()); + + return response.data; + } catch (err) { + console.log("Error fetchNotifications()", err); + } + }, + + /** + * + */ + async markNotificationAsRead( + context: NotificationsModuleContext, + payload: any + ): Promise { + try { + const service = new NotificationsService(); + const response = await service.patchNotificationAsRead(payload); + + context.commit("setNotifications", response.data.reverse()); + + return response.data; + } catch (err) { + console.log("Error fetchNotifications()", err); + } + }, + }, +}; diff --git a/runtime/dapp-frontend-vue/src/state/store/RewardsModule.ts b/runtime/dapp-frontend-vue/src/state/store/RewardsModule.ts new file mode 100644 index 00000000..de852706 --- /dev/null +++ b/runtime/dapp-frontend-vue/src/state/store/RewardsModule.ts @@ -0,0 +1,79 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Vuex Store + * @author dHealth Network + * @license LGPL-3.0 + */ + +// external dependencies +import { ActionContext } from "vuex"; + +// internal dependencies +import { RootState } from "./Store"; +import { AwaitLock } from "../AwaitLock"; +import { RewardsService } from "../../services/RewardsService"; +import { AssetEntry } from "@/models/AssetDTO"; + +// creates an "async"-lock for state of pending initialization +// this will be kept *locally* to this store module implementation +const Lock = AwaitLock.create(); + +export interface AssetsModuleState { + initialized: boolean; + userAssets: AssetEntry[]; +} + +/** + * + */ +export type AssetsModuleContext = ActionContext; + +export const AssetsModule = { + // this store module is namespaced, meaning the + // module name must be included when calling a + // mutation, getter or action, i.e. "integrations/getIntegrations". + namespaced: true, + state: (): AssetsModuleState => ({ + initialized: false, + userAssets: [], + }), + + getters: { + isLoading: (state: AssetsModuleState): boolean => !state.initialized, + getAssets: (state: AssetsModuleState): AssetEntry[] => state.userAssets, + }, + + mutations: { + /** + * + */ + setInitialized: (state: AssetsModuleState, payload: boolean): boolean => + (state.initialized = payload), + + setAssets: (state: AssetsModuleState, payload: AssetEntry[]) => + (state.userAssets = payload), + }, + + actions: { + /** + * + */ + async fetchRewards( + context: AssetsModuleContext, + address: string + ): Promise { + const service = new RewardsService(); + const response = await service.getAssetsByAddress(address); + const assets = response.data; + + console.log({ response }); + + context.commit("setAssets", assets); + + return assets; + }, + }, +}; diff --git a/runtime/dapp-frontend-vue/src/state/store/Store.ts b/runtime/dapp-frontend-vue/src/state/store/Store.ts index 3137b97b..3a1877a7 100644 --- a/runtime/dapp-frontend-vue/src/state/store/Store.ts +++ b/runtime/dapp-frontend-vue/src/state/store/Store.ts @@ -18,6 +18,8 @@ import { OAuthModule } from "./OAuthModule"; import { LeaderboardModule } from "./LeaderboardModule"; import { StatisticsModule } from "./StatisticsModule"; import { ActivitiesModule } from "./ActivitiesModule"; +import { AssetsModule } from "./RewardsModule"; +import { NotificationsModule } from "./NotificationsModule"; /** * @todo missing interface documentation @@ -58,6 +60,8 @@ export const createStore = () => { leaderboard: LeaderboardModule, statistics: StatisticsModule, activities: ActivitiesModule, + assets: AssetsModule, + notifications: NotificationsModule, }, state: (): RootState => ({ initialized: false, diff --git a/runtime/dapp-frontend-vue/src/views/Dashboard/Dashboard.ts b/runtime/dapp-frontend-vue/src/views/Dashboard/Dashboard.ts index 8a343bc7..45fceec1 100644 --- a/runtime/dapp-frontend-vue/src/views/Dashboard/Dashboard.ts +++ b/runtime/dapp-frontend-vue/src/views/Dashboard/Dashboard.ts @@ -341,6 +341,10 @@ export default class Dashboard extends MetaView { * @returns {Promise} */ protected async mounted(): Promise { + await this.$store.dispatch( + "notifications/fetchNotifications", + this.currentUserAddress + ); // in case we came here from log-in screen, we may // not have a profile in the Vuex Store yet, fill now. if (!this.currentUserAddress) { diff --git a/runtime/dapp-frontend-vue/src/views/Medals/Medals.scss b/runtime/dapp-frontend-vue/src/views/Medals/Medals.scss new file mode 100644 index 00000000..53bb5363 --- /dev/null +++ b/runtime/dapp-frontend-vue/src/views/Medals/Medals.scss @@ -0,0 +1,34 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Vue Frontend + * @author dHealth Network + * @license LGPL-3.0 + */ + + @import "../../vars.scss"; + + .dapp-medals { + .boards-title { + display: inline-block; + background-color: $accent-yellow; + color: $black; + font-weight: 600; + font-size: 42px; + line-height: 140%; + margin-top: 120px; + margin-bottom: 50px; + border-radius: 8px; + padding: 4px 54px; + } + + .dapp-rewards-list { + margin-bottom: 105px; + + &:first-child { + margin-bottom: 0; + } + } + } \ No newline at end of file diff --git a/runtime/dapp-frontend-vue/src/views/Medals/Medals.ts b/runtime/dapp-frontend-vue/src/views/Medals/Medals.ts new file mode 100644 index 00000000..8762381c --- /dev/null +++ b/runtime/dapp-frontend-vue/src/views/Medals/Medals.ts @@ -0,0 +1,149 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth dApps Framework + * @subpackage Vue Frontend + * @author dHealth Network + * @license LGPL-3.0 + */ +// external dependencies +import { Component } from "vue-property-decorator"; +import { mapGetters } from "vuex"; + +// internal dependencies +import { MetaView } from "@/views/MetaView"; +import RewardsList from "@/components/RewardsList/RewardsList.vue"; +import { MedalItem } from "@/components/RewardsList/RewardsList"; +import { AssetEntry } from "@/models/AssetDTO"; + +// style resource +import "./Medals.scss"; + +/* + * @class Medals + * @description This class implements a Vue component to display + * rewards page wit + * + * @since v0.3.0 + */ +@Component({ + components: { + RewardsList, + }, + computed: { + ...mapGetters({ + currentUserAddress: "auth/getCurrentUserAddress", + availableAssets: "assets/getAssets", + }), + }, +}) +export default class Medals extends MetaView { + /** + * This property contains the authenticated user's dHealth Account + * Address. This field is populated using the Vuex Store after a + * successful request to the backend API's `/me` endpoint. + *

+ * The `!`-operator tells TypeScript that this value is required + * and the *public* access permits the Vuex Store to mutate this + * value when it is necessary. + * + * @access public + * @var {string} + */ + public currentUserAddress!: string; + + /** + * This property represents vuex getter + * for the assets, available for user. + *

+ * It's value received from + * GET /assets/:address endpoint. + * + * @access public + * @var {AssetEntry[]} + */ + public availableAssets!: AssetEntry[]; + + /** + * This computed represents all available + * medals for the user, initially marked as + * *not received* + * + * @access protected + * @returns MedalItem[] + */ + public get knownMedals(): MedalItem[] { + return [ + { + image: "medals/10.svg", + condition: "Invite 5 users to receive medal!", + received: false, + relatedActivities: "Running, Walking, Swimming, Cycling", + assetId: process.env.VUE_APP_ASSETS_BOOST5_IDENTIFIER as string, + }, + { + image: "medals/50.svg", + condition: "Invite 10 users.", + received: false, + relatedActivities: "Running, Walking, Swimming, Cycling", + assetId: process.env.VUE_APP_ASSETS_BOOST10_IDENTIFIER as string, + }, + { + image: "medals/10.svg", + condition: "Invite 15 users", + received: false, + relatedActivities: "Running, Walking, Swimming, Cycling", + assetId: process.env.VUE_APP_ASSETS_BOOST15_IDENTIFIER as string, + }, + { + image: "medals/100.svg", + condition: "Complete one more workout to get.", + received: false, + relatedActivities: "Running, Walking, Swimming, Cycling", + assetId: "fakeIdToShowNotReceivedMedals1", + }, + { + image: "medals/50.svg", + condition: "Complete 50 kilometers to get.", + received: false, + relatedActivities: "Running, Walking, Swimming, Cycling", + assetId: "fakeIdToShowNotReceivedMedals2", + }, + { + image: "medals/100.svg", + condition: "Complete one more workout to get.", + received: false, + relatedActivities: "Running, Walking, Swimming, Cycling", + assetId: "fakeIdToShowNotReceivedMedals3", + }, + { + image: "medals/10.svg", + condition: "Finish your first 10KM in one go to get!", + received: false, + relatedActivities: "Running, Walking, Swimming, Cycling", + assetId: "fakeIdToShowNotReceivedMedals4", + }, + ]; + } + + /** + * This computed returns medals with + * *received* value based on available assets. + * + * @access protected + * @returns MedalItem[] + */ + public get validatedMedals(): MedalItem[] { + return this.knownMedals.map((medal: MedalItem) => ({ + ...medal, + received: !!this.availableAssets.find( + (asset: AssetEntry) => asset.assetId === medal.assetId + ), + })); + } + + public async mounted() { + await this.$store.dispatch("assets/fetchRewards", this.currentUserAddress); + } +} diff --git a/runtime/dapp-frontend-vue/src/views/Medals/Medals.vue b/runtime/dapp-frontend-vue/src/views/Medals/Medals.vue new file mode 100644 index 00000000..99e849d1 --- /dev/null +++ b/runtime/dapp-frontend-vue/src/views/Medals/Medals.vue @@ -0,0 +1,26 @@ + + + + diff --git a/runtime/dapp-frontend-vue/tests/unit/components/RewardsList.spec.ts b/runtime/dapp-frontend-vue/tests/unit/components/RewardsList.spec.ts new file mode 100644 index 00000000..74559585 --- /dev/null +++ b/runtime/dapp-frontend-vue/tests/unit/components/RewardsList.spec.ts @@ -0,0 +1,98 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth UI Components + * @subpackage Unit Tests + * @author dHealth Network + * @license LGPL-3.0 + */ +// external dependencies +import { expect } from "chai"; +import { mount, createLocalVue } from "@vue/test-utils"; + +// components page being tested +import RewardsList from "@/components/RewardsList/RewardsList.vue"; +import { MedalItem } from "@/components/RewardsList/RewardsList"; + +// mocks the AuthService completely +jest.mock("@/services/AuthService"); + +// creates local vue instance for tests +const localVue = createLocalVue(); + +const getImageUrl = () => "../../../src/assets/ELEVATE.svg"; + +const componentOptions = { + localVue, + stubs: ["router-link"], + mocks: { + propsData: { + title: "testTitle", + description: "testDescription", + medals: [ + { + image: "testImagePath", + condition: "Test condition", + relatedActivities: "Run, Cycle, Swim", + received: true, + assetId: "testID", + }, + { + image: "testImagePath2", + condition: "Test condition2", + relatedActivities: "Walk", + received: false, + assetId: "testID2", + }, + ], + }, + getImageUrl, + $route: { params: {} }, + $router: { + push: jest.fn(), + }, + $t: jest.fn(), + }, +}; + +describe("Medals ->", () => { + let widget: any; + + beforeEach(() => { + (console as any).log = jest.fn(); + widget = mount(RewardsList as any, componentOptions); + }); + + it("should display component", () => { + expect(widget.find(".dapp-rewards-list").exists()).to.be.true; + }); + + it("should display title", () => { + expect(widget.find(".dapp-rewards-list__title").text()).to.be.equal( + widget.props("title") + ); + }); + + it("should display description", () => { + expect(widget.find(".dapp-rewards-list__description").text()).to.be.equal( + widget.props("description") + ); + }); + + it("should display list of medals", () => { + expect(widget.findAll(".medal").length).to.be.equal( + widget.props("medals").length + ); + }); + + it("should display received medals equal to prop", () => { + const receivedMedals = widget + .props("medals") + .filter((medal: MedalItem) => medal.received); + + expect(widget.findAll(".received").length).to.be.equal( + receivedMedals.length + ); + }); +}); diff --git a/runtime/dapp-frontend-vue/tests/unit/views/Medals.spec.ts b/runtime/dapp-frontend-vue/tests/unit/views/Medals.spec.ts new file mode 100644 index 00000000..e575f57b --- /dev/null +++ b/runtime/dapp-frontend-vue/tests/unit/views/Medals.spec.ts @@ -0,0 +1,76 @@ +/** + * This file is part of dHealth dApps Framework shared under LGPL-3.0 + * Copyright (C) 2022-present dHealth Network, All rights reserved. + * + * @package dHealth UI Components + * @subpackage Unit Tests + * @author dHealth Network + * @license LGPL-3.0 + */ +// external dependencies +import { expect } from "chai"; +import { mount, createLocalVue } from "@vue/test-utils"; + +// components page being tested +import Medals from "@/views/Medals/Medals.vue"; + +// mocks the AuthService completely +jest.mock("@/services/AuthService"); + +// creates local vue instance for tests +const localVue = createLocalVue(); + +const getImageUrl = () => "../../../src/assets/ELEVATE.svg"; + +const componentOptions = { + localVue, + stubs: ["router-link"], + mocks: { + getImageUrl, + $route: { params: {} }, + $router: { + push: jest.fn(), + }, + $t: jest.fn(), + $store: { + dispatch: jest.fn(), + commit: jest.fn(), + getters: { + "auth/getCurrentUserAddress": "fakeAddress", + "assets/getAssets": [ + { + transactionHash: "fakeHash", + userAddress: "fakeAddress", + creationBlock: 704585, + assetId: "fakeId", + amount: 1156898, + }, + { + transactionHash: "fakeHash2", + userAddress: "fakeAddress", + creationBlock: 769585, + assetId: "fakeId", + amount: 1281998, + }, + ], + }, + }, + }, +}; + +describe("Medals ->", () => { + let widget: any; + + beforeEach(() => { + (console as any).log = jest.fn(); + widget = mount(Medals as any, componentOptions); + }); + + it("should display component", () => { + expect(widget.find(".dapp-medals").exists()).to.be.true; + }); + + it("should display at least one list", () => { + expect(widget.find(".dapp-rewards-list").exists()).to.be.true; + }); +});