diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..8e950a23 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,19 @@ +## Describe your changes + +Briefly describe the changes you made and their purpose. + +## Issue number + +Mention the issue number(s) this PR addresses (e.g., #123). + +## Please ensure all items are checked off before requesting a review: + +- [ ] I deployed the code locally. +- [ ] I have performed a self-review of my code. +- [ ] I have included the issue # in the PR. +- [ ] I have labelled the PR correctly. +- [ ] The issue I am working on is assigned to me. +- [ ] I didn't use any hardcoded values (otherwise it will not scale, and will make it difficult to maintain consistency across the application). +- [ ] I made sure font sizes, color choices etc are all referenced from the theme. +- [ ] My PR is granular and targeted to one specific feature. +- [ ] I took a screenshot or a video and attached to this PR if there is a UI change. diff --git a/README.md b/README.md index a7bf2ff9..2906a297 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,87 @@ This is a work-in-progress application. The source code is available under GNU A - [Node.js](https://nodejs.org/en) - [PostgreSQL](https://postgresql.org) + +## Installation + +1. Make sure Docker is installed to your machine where the server will run. +2. Make sure git is installed to your machine Git. +3. Make sure nginx is installed. + +4. Clone GitHub Repository + +``` +cd ~ +git clone https://github.com/bluewave-labs/bluewave-onboarding.git +cd bluewave-onboarding +``` + +5. Configure Nginx + +Open the Nginx configuration file: + +``sudo nano /etc/nginx/sites-available/onboarding-demo`` + +Add the following configuration. Change YOUR_DOMAIN_NAME with your domain name: + +```server { + listen 80; + server_name YOUR_DOMAIN_NAME; + return 301 https://$host$request_uri; + } + +server { + listen 443 ssl; + server_name YOUR_DOMAIN_NAME; + ssl_certificate /etc/letsencrypt/live/YOUR_DOMAIN_NAME/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/YOUR_DOMAIN_NAME/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; + + location / { + proxy_pass http://localhost:4173; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location /api/ { + proxy_pass http://localhost:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + + +6. Create a symbolic link to enable the configuration: + +``sudo ln -s /etc/nginx/sites-available/onboarding-demo /etc/nginx/sites-enabled/`` + +7. Install Certbot and its Nginx plugin: + +``sudo apt install certbot python3-certbot-nginx`` + +8. Obtain SSL Certificate. Run Certbot to obtain a certificate for your domain: + +``sudo certbot --nginx`` + +9. Verify the Nginx configuration: + +``sudo nginx -t`` + +10. Restart Nginx to apply the changes: + +``sudo systemctl restart nginx`` + +11. Start the project + +``cd ~/bluewave-onboarding +docker compose up -d`` + ## Contributing Here's how you can contribute to the Onboarding product. diff --git a/backend/.env b/backend/.env index 3cdfaaac..b1057599 100644 --- a/backend/.env +++ b/backend/.env @@ -19,6 +19,7 @@ TEST_DB_NAME=onboarding_db_test TEST_DB_HOST=localhost TEST_DB_PORT=5432 +ENABLE_IP_CHECK=false # Allowed IP range for the API "baseIp/rangeStart-rangeEnd" (e.g. 192.168.1/1-255) separated by comma ALLOWED_IP_RANGE=11.22.33/10-200, 192.168.65/1-255 # Allowed IP addresses for the API separated by comma diff --git a/backend/.env.production b/backend/.env.production index ec404c01..7de5ae9b 100644 --- a/backend/.env.production +++ b/backend/.env.production @@ -10,6 +10,7 @@ PROD_DB_PORT=5432 # JWT Secret Key JWT_SECRET=your_prod_jwt_secret_key_here +ENABLE_IP_CHECK=false # Allowed IP range for the API "baseIp/rangeStart-rangeEnd" (e.g. 192.168.1/1-255) separated by comma ALLOWED_IP_RANGE=11.22.33/10-200 # Allowed IP addresses for the API separated by comma diff --git a/backend/.env.test b/backend/.env.test index 09be3c11..7507a9c6 100644 --- a/backend/.env.test +++ b/backend/.env.test @@ -14,6 +14,7 @@ POSTGRES_DB=onboarding_db_test # JWT Secret Key JWT_SECRET=your_test_jwt_secret_key_here +ENABLE_IP_CHECK=false # Allowed IP range for the API "baseIp/rangeStart-rangeEnd" (e.g. 192.168.1/1-255) separated by comma ALLOWED_IP_RANGE=11.22.33/10-200, 192.168.65/1-255 # Allowed IP addresses for the API separated by comma diff --git a/backend/migrations/20241219181037-add-index-guidelog.js b/backend/migrations/20241219181037-add-index-guidelog.js new file mode 100644 index 00000000..c4b45874 --- /dev/null +++ b/backend/migrations/20241219181037-add-index-guidelog.js @@ -0,0 +1,28 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + /** + * Add altering commands here. + * + * Example: + * await queryInterface.createTable('users', { id: Sequelize.INTEGER }); + */ + await queryInterface.addIndex("guide_logs", ["showingTime"], { + name: "idx_guide_logs_showingTime", + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeIndex("guide_logs", ["showingTime"], { + name: "idx_guide_logs_showingTime", + }); + /** + * Add reverting commands here. + * + * Example: + * await queryInterface.dropTable('users'); + */ + }, +}; diff --git a/backend/src/controllers/guide.controller.js b/backend/src/controllers/guide.controller.js index e1f432cf..965347e9 100644 --- a/backend/src/controllers/guide.controller.js +++ b/backend/src/controllers/guide.controller.js @@ -2,7 +2,7 @@ const bannerService = require("../service/banner.service"); const guidelogService = require("../service/guidelog.service"); const helperLinkService = require("../service/helperLink.service"); const popupService = require("../service/popup.service"); - +const { internalServerError } = require("../utils/errors.helper"); class GuideController { async getGuidesByUrl(req, res) { try { diff --git a/backend/src/controllers/statistics.controller.js b/backend/src/controllers/statistics.controller.js new file mode 100644 index 00000000..1696694f --- /dev/null +++ b/backend/src/controllers/statistics.controller.js @@ -0,0 +1,23 @@ +const statisticsService = require("../service/statistics.service"); +const { internalServerError } = require("../utils/errors.helper"); + +class StatisticsController { + async getStatistics(req, res) { + try { + const userId = req.user.id; + const statistics = await statisticsService.generateStatistics({ + userId: userId.toString(), + }); + res.status(200).json(statistics); + } catch (e) { + console.log(e) + const { statusCode, payload } = internalServerError( + "GET_STATISTICS_ERROR", + e.message + ); + res.status(statusCode).json(payload); + } + } +} + +module.exports = new StatisticsController(); diff --git a/backend/src/models/GuideLog.js b/backend/src/models/GuideLog.js index c007fddc..d4841ef6 100644 --- a/backend/src/models/GuideLog.js +++ b/backend/src/models/GuideLog.js @@ -1,58 +1,64 @@ -const { GuideType } = require('../utils/guidelog.helper'); +const { GuideType } = require("../utils/guidelog.helper"); module.exports = (sequelize, DataTypes) => { - const GuideLog = sequelize.define('GuideLog', { - guideType: { - type: DataTypes.INTEGER, - allowNull: false, - validate: { - isIn: { - args: [ - Object.values(GuideType), - ], - msg: 'guideType must be a valid value.', - }, - }, + const GuideLog = sequelize.define( + "GuideLog", + { + guideType: { + type: DataTypes.INTEGER, + allowNull: false, + validate: { + isIn: { + args: [Object.values(GuideType)], + msg: "guideType must be a valid value.", + }, }, - guideId: { - type: DataTypes.INTEGER, - allowNull: false, + }, + guideId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + userId: { + type: DataTypes.STRING, + allowNull: false, + }, + showingTime: { + type: DataTypes.DATE, + defaultValue: DataTypes.NOW, + }, + completed: { + type: DataTypes.BOOLEAN, + defaultValue: false, + }, + }, + { + timestamps: false, + tableName: "guide_logs", + indexes: [ + { + name: "idx_guide_logs_userId", + fields: ["userId"], }, - userId: { - type: DataTypes.STRING, - allowNull: false, + { + name: "idx_guide_logs_guideId", + fields: ["guideId"], }, - showingTime: { - type: DataTypes.DATE, - defaultValue: DataTypes.NOW, + { + name: "idx_guide_logs_guideType", + fields: ["guideType"], }, - completed: { - type: DataTypes.BOOLEAN, - defaultValue: false, + { + name: "idx_guide_logs_userId_guideId_guideType", + fields: ["userId", "guideId", "guideType"], + unique: false, }, - }, { - timestamps: false, - tableName: 'guide_logs', - indexes: [ - { - name: 'idx_guide_logs_userId', - fields: ['userId'], - }, - { - name: 'idx_guide_logs_guideId', - fields: ['guideId'], - }, - { - name: 'idx_guide_logs_guideType', - fields: ['guideType'], - }, - { - name: 'idx_guide_logs_userId_guideId_guideType', - fields: ['userId', 'guideId', 'guideType'], - unique: false, - }, - ], - }); + { + name: "idx_guide_logs_showingTime", + fields: ["showingTime"], + }, + ], + } + ); - return GuideLog; + return GuideLog; }; diff --git a/backend/src/routes/statistics.routes.js b/backend/src/routes/statistics.routes.js new file mode 100644 index 00000000..7022c551 --- /dev/null +++ b/backend/src/routes/statistics.routes.js @@ -0,0 +1,9 @@ +const express = require("express"); +const statisticsController = require("../controllers/statistics.controller"); +const authenticateJWT = require("../middleware/auth.middleware"); + +const router = express.Router(); +router.use(authenticateJWT) +router.get("/", statisticsController.getStatistics); + +module.exports = router; diff --git a/backend/src/server.js b/backend/src/server.js index e85dfe51..356e709b 100644 --- a/backend/src/server.js +++ b/backend/src/server.js @@ -23,6 +23,7 @@ const tourRoutes = require("./routes/tour.routes"); const linkRoutes = require("./routes/link.routes"); const helperLinkRoutes = require("./routes/helperLink.routes"); const guideRoutes = require("./routes/guide.routes"); +const statisticsRoutes = require("./routes/statistics.routes"); const app = express(); @@ -30,7 +31,9 @@ app.use(cors()); app.use(helmet()); app.use(bodyParser.json({ limit: MAX_FILE_SIZE })); app.use(jsonErrorMiddleware); -app.use(ipFilter); +if (process.env.ENABLE_IP_CHECK === 'true') { + app.use(ipFilter); +} // app.use(fileSizeValidator); const { sequelize } = require("./models"); @@ -57,6 +60,7 @@ app.use("/api/hint", hint); app.use("/api/tour", tourRoutes); app.use("/api/link", linkRoutes); app.use("/api/helper-link", helperLinkRoutes); +app.use("/api/statistics", statisticsRoutes); app.use((err, req, res, next) => { console.error(err.stack); diff --git a/backend/src/service/statistics.service.js b/backend/src/service/statistics.service.js new file mode 100644 index 00000000..d008e6d4 --- /dev/null +++ b/backend/src/service/statistics.service.js @@ -0,0 +1,58 @@ +const db = require("../models"); +const { GuideType } = require("../utils/guidelog.helper"); +const GuideLog = db.GuideLog; + +class StatisticsService { + async generateStatistics({ userId }) { + try { + const now = new Date(); + const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1); + const twoMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 2, 1); + const views = []; + for (const [guideName, guideType] of Object.entries(GuideType)) { + const logs = await GuideLog.findAll({ + where: { + guideType: Number(guideType), + userId, + showingTime: { + [db.Sequelize.Op.gte]: twoMonthsAgo, + }, + }, + }); + const { thisMonthViews, lastMonthViews } = logs.reduce( + (acc, log) => { + if (log.guideType !== guideType) { + return acc; + } + if (log.showingTime >= thisMonth) { + acc.thisMonthViews += 1; + } else if (log.showingTime >= lastMonth) { + acc.lastMonthViews += 1; + } + return acc; + }, + { thisMonthViews: 0, lastMonthViews: 0 } + ); + const percentageDifference = + lastMonthViews === 0 + ? 0 + : Math.round( + ((thisMonthViews - lastMonthViews) / lastMonthViews) * 100 + ); + const result = { + views: thisMonthViews, + change: percentageDifference, + guideType: guideName.toLowerCase(), + }; + views.push(result); + } + return views.sort((a, b) => b.views - a.views); + } catch (error) { + console.log(error); + throw new Error(`Failed to generate statistics: ${error.message}`); + } + } +} + +module.exports = new StatisticsService(); diff --git a/backend/src/test/e2e/statistics.test.mjs b/backend/src/test/e2e/statistics.test.mjs new file mode 100644 index 00000000..3b968692 --- /dev/null +++ b/backend/src/test/e2e/statistics.test.mjs @@ -0,0 +1,90 @@ +import { expect } from "chai"; +import { after, afterEach, before, beforeEach, describe } from "mocha"; +import waitOn from "wait-on"; +import db from "../../models/index.js"; +import app from "../../server.js"; +import mocks from "../mocks/guidelog.mock.js"; +import { UserBuilder, validList } from "../mocks/user.mock.js"; +import chai from "./index.mjs"; + +const user = UserBuilder.user; +const dbReadyOptions = { + resources: ["tcp:localhost:5432"], + delay: 1000, + timeout: 30000, + interval: 1000, +}; + +const guideLogList = mocks.guideLogList; + +const setupTestDatabase = () => { + before(async () => { + db.sequelize.connectionManager.initPools(); + }); + + after(async () => { + try { + const conn = await db.sequelize.connectionManager.getConnection(); + db.sequelize.connectionManager.releaseConnection(conn); + } catch (error) { + console.error("Failed to release database connection:", error); + throw error; + } + }); +}; + +const populateGuideLogs = async (token) => { + for (const guideLog of guideLogList) { + await chai.request + .execute(app) + .post("/api/guide_log/add_guide_log") + .set("Authorization", `Bearer ${token}`) + .send(guideLog); + } +} + +describe("E2e tests statistics", () => { + describe("GET /api/statistics", () => { + setupTestDatabase(); + let token; + + beforeEach(async () => { + process.env.NODE_ENV = "test"; + try { + await waitOn(dbReadyOptions); + } catch (err) { + console.error("Database not ready in time:", err); + throw err; + } + const login = await chai.request + .execute(app) + .post("/api/auth/register") + .send(user().build()); + token = login.body.token; + }); + afterEach(async () => { + await db.sequelize.sync({ force: true, match: /_test$/ }); + }); + it("should return 401 if token is not passed", async () => { + const response = await chai.request.execute(app).get("/api/statistics"); + expect(response).to.have.status(401); + expect(response.body).to.be.deep.equal({ error: "No token provided" }); + }) + it("should return 200 if token is passed", async () => { + populateGuideLogs(token); + const response = await chai.request + .execute(app) + .get("/api/statistics") + .set("Authorization", `Bearer ${token}`); + expect(response).to.have.status(200); + expect(response.body).not.to.deep.equal(guideLogList); + expect(response.body).to.be.an("array"); + expect(response.body).to.have.lengthOf(6); + response.body.forEach((statistic) => { + expect(statistic).to.have.property("views"); + expect(statistic).to.have.property("change"); + expect(statistic).to.have.property("guideType"); + }) + }) + }); +}); diff --git a/backend/src/test/mocks/guidelog.mock.js b/backend/src/test/mocks/guidelog.mock.js index 2de5ac73..43d561d0 100644 --- a/backend/src/test/mocks/guidelog.mock.js +++ b/backend/src/test/mocks/guidelog.mock.js @@ -1,16 +1,18 @@ +const { GuideType } = require("../../utils/guidelog.helper"); + class GuideLogBuilder { - constructor(id) { + constructor(id, guideType) { this.guideLog = { - guideType: 3, + guideType, guideId: id, - userId: '1', + userId: "1", showingTime: new Date("2024-11-29T00:00:00.000Z"), completed: false, }; } - static guideLog(id = 1) { - return new GuideLogBuilder(id); + static guideLog(id = 1, guideType = 1) { + return new GuideLogBuilder(id, guideType); } invalidGuideType() { @@ -44,7 +46,20 @@ class GuideLogBuilder { } const guideLogList = Array.from({ length: 10 }, (_, i) => { - return GuideLogBuilder.guideLog(i + 1).build(); + const values = Object.values(GuideType); + let index = 0; + if (i % 2 === 0 && i % 3 === 0) { + index = 1; + } else if (i % 3 === 0) { + index = 2; + } else if (i % 2 === 0 && i % 5 === 0) { + index = 3; + } else if (i % 2 === 0) { + index = 4; + } else if (i % 5 === 0) { + index = 5; + } + return GuideLogBuilder.guideLog(i + 1, values[index]).build(); }).map((guideLog, i) => (i % 2 === 0 ? guideLog : { ...guideLog, userId: 2 })); module.exports = { GuideLogBuilder, guideLogList }; diff --git a/backend/src/test/unit/controllers/statistics.test.js b/backend/src/test/unit/controllers/statistics.test.js new file mode 100644 index 00000000..74ffc6e1 --- /dev/null +++ b/backend/src/test/unit/controllers/statistics.test.js @@ -0,0 +1,43 @@ +const { describe, it, afterEach } = require("mocha"); +const { expect } = require("chai"); +const sinon = require("sinon"); +const service = require("../../../service/statistics.service.js"); +const { guideLogList } = require("../../mocks/guidelog.mock.js"); +const controller = require("../../../controllers/statistics.controller.js"); + +describe("Test Statistics controller", () => { + const serviceMock = {}; + const req = {}; + const res = {}; + beforeEach(() => { + res.status = sinon.stub().returns(res); + res.json = sinon.stub().returns(res); + }); + afterEach(sinon.restore); + describe("getStatistics", () => { + req.user = { id: 1 }; + it("should return status 200 and the statistics to all guides", async () => { + serviceMock.generateStatistics = sinon + .stub(service, "generateStatistics") + .resolves(guideLogList); + await controller.getStatistics(req, res); + const status = res.status.firstCall.args[0]; + const json = res.json.firstCall.args[0]; + expect(status).to.be.equal(200); + expect(json).to.be.equal(guideLogList); + }); + it("should return status 500 if something goes wrong", async () => { + serviceMock.generateStatistics = sinon + .stub(service, "generateStatistics") + .rejects(`Failed to generate statistics:`); + await controller.getStatistics(req, res); + const status = res.status.firstCall.args[0]; + const json = res.json.firstCall.args[0]; + expect(status).to.be.equal(500); + expect(json).to.be.deep.equal({ + error: "Internal Server Error", + errorCode: "GET_STATISTICS_ERROR", + }); + }); + }); +}); diff --git a/backend/src/test/unit/services/statistics.test.js b/backend/src/test/unit/services/statistics.test.js new file mode 100644 index 00000000..8ca765aa --- /dev/null +++ b/backend/src/test/unit/services/statistics.test.js @@ -0,0 +1,29 @@ +const { describe, it, beforeEach, afterEach } = require("mocha"); +const sinon = require("sinon"); +const { expect } = require("chai"); +const db = require("../../../models/index.js"); +const mocks = require("../../mocks/guidelog.mock.js"); +const statisticsService = require("../../../service/statistics.service.js"); + +const GuideLog = db.GuideLog; + +describe("Test statistics service", () => { + const GuideLogMock = {}; + afterEach(sinon.restore); + it("should return statistics", async () => { + const guideLogs = mocks.guideLogList; + GuideLogMock.findAll = sinon.stub(GuideLog, "findAll").resolves(guideLogs); + const statistics = await statisticsService.generateStatistics({ + userId: 1, + }); + + expect(GuideLogMock.findAll.called).to.equal(true); + expect(statistics).to.be.an("array"); + expect(statistics).to.have.lengthOf(6); + statistics.forEach((statistic) => { + expect(statistic).to.have.property("guideType"); + expect(statistic).to.have.property("views"); + expect(statistic).to.have.property("change"); + }); + }); +}); diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 90f92f9f..8d42b4b9 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -5,8 +5,8 @@ Bluewave Onboarding - - + + diff --git a/frontend/src/assets/theme.jsx b/frontend/src/assets/theme.jsx index 15742d75..7332de6d 100644 --- a/frontend/src/assets/theme.jsx +++ b/frontend/src/assets/theme.jsx @@ -29,6 +29,24 @@ export const lightTheme = createTheme({ }, }, }, + MuiTabPanel: { + styleOverrides: { + root: { + padding: 0, + }, + }, + }, + MuiMenuItem: { + styleOverrides: { + root: { + margin: "0px 5px !important", + "&.Mui-selected": { + backgroundColor: "#F9FAFB !important", + borderRadius: "8px !important", + }, + }, + }, + }, MuiDrawer: { styleOverrides: { paper: { @@ -38,6 +56,19 @@ export const lightTheme = createTheme({ }, }, }, + MuiOutlinedInput: { + styleOverrides: { + root: { + borderRadius: "8px", + "&:hover .MuiOutlinedInput-notchedOutline": { + borderColor: "var(--main-purple)", + }, + "&.Mui-focused .MuiOutlinedInput-notchedOutline": { + borderColor: "var(--main-purple)", + }, + }, + }, + }, MuiButtonBase: { defaultProps: { disableRipple: true, @@ -69,6 +100,33 @@ export const darkTheme = createTheme({ }, }, }, + MuiTab: { + styleOverrides: { + root: { + textTransform: "none", + fontSize: "14px", + padding: "3px 9px", + }, + }, + }, + MuiTabPanel: { + styleOverrides: { + root: { + padding: 0, + }, + }, + }, + MuiMenuItem: { + styleOverrides: { + root: { + margin: "0px 5px !important", + "&.Mui-selected": { + backgroundColor: "#F9FAFB !important", + borderRadius: "8px !important", + }, + }, + }, + }, MuiDrawer: { styleOverrides: { paper: { @@ -78,12 +136,16 @@ export const darkTheme = createTheme({ }, }, }, - MuiTab: { + MuiOutlinedInput: { styleOverrides: { root: { - textTransform: "none", - fontSize: "14px", - padding: "3px 9px", + borderRadius: "8px", + "&:hover .MuiOutlinedInput-notchedOutline": { + borderColor: "var(--main-purple)", + }, + "&.Mui-focused .MuiOutlinedInput-notchedOutline": { + borderColor: "var(--main-purple)", + }, }, }, }, diff --git a/frontend/src/components/Button/ButtonStyles.css b/frontend/src/components/Button/ButtonStyles.css index 1c025b24..bd61451f 100644 --- a/frontend/src/components/Button/ButtonStyles.css +++ b/frontend/src/components/Button/ButtonStyles.css @@ -3,6 +3,7 @@ border-radius: 4px; text-transform: none; box-shadow: none; + font-weight: 400; } /* .primary */ diff --git a/frontend/src/components/ColorTextField/ColorTextField.module.scss b/frontend/src/components/ColorTextField/ColorTextField.module.scss index 05932322..39c2eaf6 100644 --- a/frontend/src/components/ColorTextField/ColorTextField.module.scss +++ b/frontend/src/components/ColorTextField/ColorTextField.module.scss @@ -2,22 +2,6 @@ .colorTextField { - :global { - .MuiOutlinedInput-root { - &:hover fieldset { - border-color: var(--main-purple); - } - - &.Mui-focused fieldset { - border-color: var(--main-purple); - } - - } - .MuiOutlinedInput-root { - border-radius: 8px; - } - } - input { font-size: var(--font-regular); height: 34px; diff --git a/frontend/src/components/DropdownList/DropdownList.css b/frontend/src/components/DropdownList/DropdownList.css index 5eb41c45..9774f5a5 100644 --- a/frontend/src/components/DropdownList/DropdownList.css +++ b/frontend/src/components/DropdownList/DropdownList.css @@ -13,11 +13,3 @@ font-size: var(--font-regular) !important; } -.MuiMenuItem-root.Mui-selected { - background-color: #F9FAFB !important; - border-radius: 8px !important; -} - -.MuiMenuItem-root { - margin: 0px 5px !important; -} \ No newline at end of file diff --git a/frontend/src/components/HeadingTabs/HeadingTabs.jsx b/frontend/src/components/HeadingTabs/HeadingTabs.jsx deleted file mode 100644 index 245aff79..00000000 --- a/frontend/src/components/HeadingTabs/HeadingTabs.jsx +++ /dev/null @@ -1,25 +0,0 @@ -import React, { useState } from 'react'; -import { Tab, Tabs } from '@mui/material'; -import DataTable from "../Table/Table"; -import { demoData } from '../../data/demoData'; -import './TabStyles.css'; - -const HeadingTabs = () => { - const [value, setValue] = useState(0); - - const handleChange = (event, newValue) => { - setValue(newValue); - }; - - return ( -
- - - - - -
- ); -}; - -export default HeadingTabs; diff --git a/frontend/src/components/HeadingTabs/TabStyles.css b/frontend/src/components/HeadingTabs/TabStyles.css deleted file mode 100644 index bc966f45..00000000 --- a/frontend/src/components/HeadingTabs/TabStyles.css +++ /dev/null @@ -1,36 +0,0 @@ -.MuiTab-root { - font-family: 'Inter', sans-serif; - font-size: var(--font-regular); - font-style: normal; - font-weight: 400; - line-height: 20px; - } -.MuiTabs-root .MuiTab-root.Mui-selected { - color: var(--main-purple); - } - -.MuiTabs-root .MuiTab-root:not(.Mui-selected) { -color: var(--main-text-color); -border-bottom: 1px solid var(--Colors-Foreground-fg-brand-primary_alt, #EAECF0); -} - -.MuiTabs-root .MuiTabs-indicator { -background-color: var(--main-purple); -} -.container-tabs{ - padding: 45px; - width: 980px; - height: 1067px; - flex-shrink: 0; - border-radius: 5px; - border: 1px solid #EBEBEB; - background: #FFF; -} -.tabs-row{ - display: flex; -width: 980px; -flex-direction: column; -align-items: flex-start; -gap: var(--spacing-md, 8px); -border-bottom: 1px solid var(--Colors-Border-border-secondary, #EAECF0); -} \ No newline at end of file diff --git a/frontend/src/components/LeftMenu/LeftMenu.jsx b/frontend/src/components/LeftMenu/LeftMenu.jsx index 6a9895de..9d666726 100644 --- a/frontend/src/components/LeftMenu/LeftMenu.jsx +++ b/frontend/src/components/LeftMenu/LeftMenu.jsx @@ -14,7 +14,7 @@ import { import SettingsOutlinedIcon from '@mui/icons-material/SettingsOutlined'; import './LeftMenu.css'; import Logo from '../Logo/Logo'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useLocation } from 'react-router-dom'; import UserProfileSidebar from '../UserProfileSidebar/UserProfileSidebar'; @@ -37,6 +37,7 @@ const menuItems = [ function LeftMenu() { const navigate = useNavigate(); + const location = useLocation(); const handleNavigation = (route) => { if (route && route.startsWith('/')) { @@ -58,6 +59,9 @@ function LeftMenu() { handleNavigation(item.route)} > {item.icon} diff --git a/frontend/src/components/Links/Popup/Popup.jsx b/frontend/src/components/Links/Popup/Popup.jsx index 6951b762..a774a681 100644 --- a/frontend/src/components/Links/Popup/Popup.jsx +++ b/frontend/src/components/Links/Popup/Popup.jsx @@ -20,12 +20,9 @@ const Popup = () => { const handleClosePopup = async () => { setIsPopupOpen(false); - setLinks( - links.filter( - (it) => it.title !== itemToDelete.title && it.id !== itemToDelete.id - ) - ); - setDeletedLinks((prev) => [...prev, itemToDelete]); + setLinks(links.filter((it) => it.id !== itemToDelete.id)); + typeof itemToDelete.id === "number" && + setDeletedLinks((prev) => [...prev, itemToDelete]); setItemToDelete(null); }; diff --git a/frontend/src/components/Links/Settings/Settings.jsx b/frontend/src/components/Links/Settings/Settings.jsx index fa9babcf..c9177f73 100644 --- a/frontend/src/components/Links/Settings/Settings.jsx +++ b/frontend/src/components/Links/Settings/Settings.jsx @@ -2,7 +2,7 @@ import CloseOutlinedIcon from "@mui/icons-material/CloseOutlined"; import { useContext, useEffect, useRef, useState } from "react"; import { HelperLinkContext } from "../../../services/linksProvider"; import Switch from "../../Switch/Switch"; -import s from "./Settings.module.scss"; +import style from "./Settings.module.scss"; const defaultState = { title: "", @@ -38,7 +38,10 @@ const Settings = () => { }; setState(newState); } else { - setState({ ...defaultState, id: Math.floor(Date.now() * Math.random()) }); + setState({ + ...defaultState, + id: `${Date.now()}-${Math.random().toString(36).substring(2, 11)}`, + }); } window.addEventListener("mousedown", handleMouseDown); @@ -78,84 +81,84 @@ const Settings = () => { setLinkToEdit(null); toggleSettings(e); } else { - setLinks((prev) => [...prev, { ...info, id: +info.id }]); + setLinks((prev) => [...prev, { ...info, id: info.id }]); toggleSettings(e); } }; return (
-
- Add new link -
- Auto-saved +
+ Add new link +
+ Auto-saved
-
+
-
- ); }; -export default Settings; \ No newline at end of file +export default Settings; diff --git a/frontend/src/components/RichTextEditor/EditorLinkDialog/DialogStyles.js b/frontend/src/components/RichTextEditor/EditorLinkDialog/DialogStyles.js new file mode 100644 index 00000000..31beddb6 --- /dev/null +++ b/frontend/src/components/RichTextEditor/EditorLinkDialog/DialogStyles.js @@ -0,0 +1,18 @@ +export const dialogStyles = { + paper: { + padding: 2, + borderRadius: "4px", + }, + title: { + padding: 0, + }, + content: { + padding: 0, + paddingBottom: 2, + overflow: "hidden", + }, + actions: { + paddingBottom: 0, + paddingRight: 0, + }, +}; diff --git a/frontend/src/components/RichTextEditor/EditorLinkDialog/LinkDialog.jsx b/frontend/src/components/RichTextEditor/EditorLinkDialog/LinkDialog.jsx index ba2240e0..e13067be 100644 --- a/frontend/src/components/RichTextEditor/EditorLinkDialog/LinkDialog.jsx +++ b/frontend/src/components/RichTextEditor/EditorLinkDialog/LinkDialog.jsx @@ -7,6 +7,7 @@ import { } from "@mui/material"; import Button from "../../Button/Button"; import CustomTextField from "../../TextFieldComponents/CustomTextField/CustomTextField"; +import { dialogStyles } from "./DialogStyles"; const LinkDialog = ({ open, @@ -18,9 +19,17 @@ const LinkDialog = ({ handleOpenLink = () => {}, }) => { return ( - - {isLinkActive ? "Edit Link" : "Add Link"} - + + + {isLinkActive ? "Edit link" : "Add link"} + + setUrl(e.target.value)} /> - + {isLinkActive && ( ); diff --git a/frontend/src/components/RichTextEditor/RichTextEditor.css b/frontend/src/components/RichTextEditor/RichTextEditor.css index f2aa6ab4..66d84abc 100644 --- a/frontend/src/components/RichTextEditor/RichTextEditor.css +++ b/frontend/src/components/RichTextEditor/RichTextEditor.css @@ -3,9 +3,11 @@ color: var(--main-text-color); font-size: var(--font-regular); border-radius: 8px; - padding-top: 1rem; + padding-top: 0.5rem; margin-top: 1rem; width: 400px; + padding-left: 0.5rem; + box-sizing: border-box; } .ProseMirror { diff --git a/frontend/src/components/RichTextEditor/Toolbar/EditorToolbar.jsx b/frontend/src/components/RichTextEditor/Toolbar/EditorToolbar.jsx index e477b800..c043b2c2 100644 --- a/frontend/src/components/RichTextEditor/Toolbar/EditorToolbar.jsx +++ b/frontend/src/components/RichTextEditor/Toolbar/EditorToolbar.jsx @@ -56,12 +56,18 @@ const Toolbar = ({ editor }) => { @@ -70,6 +76,10 @@ const Toolbar = ({ editor }) => { disabled={ !editor.can().chain().focus().toggleHeading({ level: 2 }).run() } + style = {{ + backgroundColor: editor.isActive("heading", {level:3}) + ? "#e0e0e0" : "transparent", + }} > </Button> @@ -79,12 +89,20 @@ const Toolbar = ({ editor }) => { <Button onClick={() => editor.chain().focus().toggleBulletList().run()} disabled={!editor.can().chain().focus().toggleBulletList().run()} + style = {{ + backgroundColor : editor.isActive("bulletList") ? "#e0e0e0" : "transparent", + + }} > <FormatListBulleted /> </Button> <Button onClick={() => editor.chain().focus().toggleOrderedList().run()} disabled={!editor.can().chain().focus().toggleOrderedList().run()} + + style = {{ + backgroundColor : editor.isActive("orderedList") ? "#e0e0e0" : "transparent", + }} > <FormatListNumbered /> </Button> diff --git a/frontend/src/components/TextFieldComponents/CustomTextField/CustomTextFieldStyles.css b/frontend/src/components/TextFieldComponents/CustomTextField/CustomTextFieldStyles.css index f1458a3c..78e43269 100644 --- a/frontend/src/components/TextFieldComponents/CustomTextField/CustomTextFieldStyles.css +++ b/frontend/src/components/TextFieldComponents/CustomTextField/CustomTextFieldStyles.css @@ -24,14 +24,4 @@ } border-radius: 8px !important; -} - -.textField .MuiButton-root { - font-size: var(--font-regular); - color: var(--second-text-color); -} - -.MuiBox-root .MuiInputLabel-root { - font-size: var(--font-regular); - color: var(--second-text-color); -} +} \ No newline at end of file diff --git a/frontend/src/components/Toast/Toast.module.scss b/frontend/src/components/Toast/Toast.module.scss index d026fd3d..f2619ae6 100644 --- a/frontend/src/components/Toast/Toast.module.scss +++ b/frontend/src/components/Toast/Toast.module.scss @@ -3,8 +3,8 @@ .toastContainer { position: fixed; - top: 110px; - right: 20px; + bottom: 60px; + right: 48px; z-index: 9999; display: flex; flex-direction: column; diff --git a/frontend/src/products/Hint/HintComponent.css b/frontend/src/products/Hint/HintComponent.css index 724b8f96..40d3acb2 100644 --- a/frontend/src/products/Hint/HintComponent.css +++ b/frontend/src/products/Hint/HintComponent.css @@ -1,6 +1,6 @@ .preview-container { width: 100%; - height: 250px; + min-height: 250px; border: 1px solid var(--light-border-color); margin-top: 1rem; overflow: auto; @@ -17,25 +17,33 @@ font-weight: 600; line-height: 30px; text-align: left; - padding: 0 2rem 0 0; + padding: 0 2rem; + color: var(--preview-header-color); + margin-bottom: 8px; + margin-top: 24px; } .preview-content-container { - color: var(--second-text-color); + color: var(--main-text-color); justify-content: space-between; display: flex; flex-direction: column; box-sizing: border-box; - height: calc(100% - 20px); + min-height: 170px; padding: 0 2rem; font-size: 13px; } .preview-content p { - margin: 0.1rem 0; + margin: 0; line-height: 24px; } +.preview-content { + word-wrap: break-word; + overflow-wrap: break-word; +} + .preview-button-container { margin-top: 0.5rem; display: flex; diff --git a/frontend/src/products/Hint/HintComponent.jsx b/frontend/src/products/Hint/HintComponent.jsx index 41f59fc6..d3c2623d 100644 --- a/frontend/src/products/Hint/HintComponent.jsx +++ b/frontend/src/products/Hint/HintComponent.jsx @@ -29,7 +29,7 @@ const HintComponent = ({ </div> )} <div className="preview-content-container" style={{ color: textColor }}> - <div> + <div className="preview-content"> <ReactMarkdown>{content}</ReactMarkdown> </div> <div className="preview-button-container"> @@ -38,7 +38,8 @@ const HintComponent = ({ style={{ backgroundColor: buttonBackgroundColor, color: buttonTextColor, - margin: "1rem", + marginBottom: "1rem", + borderRadius: '8px' }} text={previewBtnText} ></Button> diff --git a/frontend/src/products/Popup/PopupComponent.jsx b/frontend/src/products/Popup/PopupComponent.jsx index 94ce743d..af82cbbe 100644 --- a/frontend/src/products/Popup/PopupComponent.jsx +++ b/frontend/src/products/Popup/PopupComponent.jsx @@ -23,6 +23,9 @@ const PopupComponent = ({ const sizeClass = validSizes.includes(popupSize.toLowerCase()) ? styles[popupSize.toLowerCase()] : ""; + const sizeClassContent = validSizes.includes(popupSize.toLowerCase()) + ? styles[popupSize.toLowerCase() + 'Content'] + : ""; const centeredClass = isReal ? styles.centered : ""; const handleClose = () => { @@ -63,7 +66,7 @@ const PopupComponent = ({ /> </div> )} - <div className={`${styles.popupContentContainer}`}> + <div className={`${styles.popupContentContainer} ${sizeClassContent}`}> <div className={styles.popupContent} style={{ color: textColor }}> <ReactMarkdown>{content}</ReactMarkdown> </div> @@ -73,7 +76,7 @@ const PopupComponent = ({ style={{ backgroundColor: buttonBackgroundColor, color: buttonTextColor, - marginRight: "1rem", + borderRadius: '8px' }} text={previewBtnText} onClick={handleButtonClick} // Add onClick handler diff --git a/frontend/src/products/Popup/PopupComponent.module.css b/frontend/src/products/Popup/PopupComponent.module.css index 67caf286..be56a5af 100644 --- a/frontend/src/products/Popup/PopupComponent.module.css +++ b/frontend/src/products/Popup/PopupComponent.module.css @@ -8,9 +8,10 @@ } .popupContent { - padding: 20px 25px; color: var(--second-text-color); font-size: var(--font-regular); + word-wrap: break-word; + overflow-wrap: break-word; } .centered { @@ -38,7 +39,8 @@ display: flex; flex-direction: column; box-sizing: border-box; - height: calc(100% - 30px); + min-height: 177px; + padding: 18px 25px 25px 25px; } .popupButtonContainer { @@ -59,16 +61,28 @@ } .small { - min-width: 400px; + width: 400px; min-height: 250px; } .medium { - min-width: 500px; + width: 500px; min-height: 300px; } .large { - min-width: 700px; + width: 700px; min-height: 350px; } + +.smallContent { + min-height: 177px; +} + +.mediumContent { + min-height: 227px; +} + +.largeContent { + min-height: 277px; +} diff --git a/frontend/src/scenes/banner/BannerDefaultPage.jsx b/frontend/src/scenes/banner/BannerDefaultPage.jsx index f143dc85..9c11c738 100644 --- a/frontend/src/scenes/banner/BannerDefaultPage.jsx +++ b/frontend/src/scenes/banner/BannerDefaultPage.jsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import {useLocation} from "react-router-dom" import DefaultPageTemplate from '../../templates/DefaultPageTemplate/DefaultPageTemplate'; import { getBanners, deleteBanner } from '../../services/bannerServices'; import { ACTIVITY_TYPES_INFO } from '../../data/guideMainPageData'; @@ -8,6 +9,7 @@ const BannerDefaultPage = () => { const [itemsUpdated, setItemsUpdated] = useState(false); const [isEdit, setIsEdit] = useState(false); const [itemId, setItemId] = useState(null); + const locationData = useLocation() const getBannerDetails = (banner) => ({ title: `Banner ${banner.id}`, @@ -27,6 +29,7 @@ const BannerDefaultPage = () => { itemsUpdated={itemsUpdated} /> <BannerPage + autoOpen= {locationData.state?.autoOpen} isEdit={isEdit} itemId={itemId} setItemsUpdated={setItemsUpdated} diff --git a/frontend/src/scenes/banner/CreateBannerPage.jsx b/frontend/src/scenes/banner/CreateBannerPage.jsx index ca2dcc30..bb79b9ad 100644 --- a/frontend/src/scenes/banner/CreateBannerPage.jsx +++ b/frontend/src/scenes/banner/CreateBannerPage.jsx @@ -12,7 +12,7 @@ import BannerLeftContent from "./BannerPageComponents/BannerLeftContent/BannerLe import BannerPreview from "./BannerPageComponents/BannerPreview/BannerPreview"; import { useDialog } from "../../templates/GuideTemplate/GuideTemplateContext"; -const BannerPage = ({isEdit, itemId, setItemsUpdated}) => { +const BannerPage = ({ autoOpen = false, isEdit, itemId, setItemsUpdated }) => { const [backgroundColor, setBackgroundColor] = useState("#F9F5FF"); const [fontColor, setFontColor] = useState("#344054"); const [activeButton, setActiveButton] = useState(0); @@ -21,12 +21,17 @@ const BannerPage = ({isEdit, itemId, setItemsUpdated}) => { const [url, setUrl] = useState(""); const [actionUrl, setActionUrl] = useState(""); const [buttonAction, setButtonAction] = useState("No action"); - const { closeDialog } = useDialog(); + const { openDialog, closeDialog } = useDialog(); const handleButtonClick = (index) => { setActiveButton(index); }; + + useEffect(() => { + if (autoOpen) openDialog(); + }, [autoOpen, openDialog]); + useEffect(() => { if (isEdit) { const fetchBannerData = async () => { diff --git a/frontend/src/scenes/dashboard/Dashboard.jsx b/frontend/src/scenes/dashboard/Dashboard.jsx index 0be3ac50..b12b9a1a 100644 --- a/frontend/src/scenes/dashboard/Dashboard.jsx +++ b/frontend/src/scenes/dashboard/Dashboard.jsx @@ -1,47 +1,84 @@ -import React from "react"; -import DateDisplay from "./HomePageComponents/DateDisplay/DateDisplay"; -import UserTitle from "./HomePageComponents/UserTitle/UserTitle"; +import React, { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import LoadingArea from "../../components/LoadingPage/LoadingArea"; +import { getStatistics } from "../../services/statisticsService"; import styles from "./Dashboard.module.scss"; -import StatisticCardList from "./HomePageComponents/StatisticCardsList/StatisticCardsList"; import CreateActivityButtonList from "./HomePageComponents/CreateActivityButtonList/CreateActivityButtonList"; -import { useNavigate } from "react-router-dom"; +import DateDisplay from "./HomePageComponents/DateDisplay/DateDisplay"; +import StatisticCardList from "./HomePageComponents/StatisticCardsList/StatisticCardsList"; +import UserTitle from "./HomePageComponents/UserTitle/UserTitle"; +import BannerSkeleton from "./HomePageComponents/Skeletons/BannerSkeleton"; +import BaseSkeleton from "./HomePageComponents/Skeletons/BaseSkeleton"; + +const mapMetricName = (guideType) => { + switch (guideType) { + case "popup": + return "Popup views"; + case "hint": + return "Hint views"; + case "banner": + return "Banner views"; + case "link": + return "Link views"; + case "tour": + return "Tour views"; + case "checklist": + return "Checklist views"; + default: + return "Unknown"; + } +}; const Dashboard = ({ name }) => { const navigate = useNavigate(); - const metrics = [ - { metricName: "Popup views", metricValue: 5000, changeRate: 5 }, - { metricName: "Hint views", metricValue: 2000, changeRate: -20 }, - { metricName: "Banner Views", metricValue: 3000, changeRate: 15 }, - ]; + const [isLoading, setIsLoading] = useState(true); + const [metrics, setMetrics] = useState([]); + + const metricNames = ['popup', 'banner', 'link'] + + useEffect(() => { + getStatistics().then((data) => { + setMetrics( + data + ?.filter((metric) => metricNames.includes(metric.guideType)) + ?.map((metric) => ({ + metricName: mapMetricName(metric.guideType), + metricValue: metric.views, + changeRate: metric.change, + })) + ); + setIsLoading(false); + }); + }, []); const buttons = [ { + skeletonType: <BaseSkeleton guideType="popup" />, placeholder: "Create a popup", - onClick: () => navigate("/popup/create"), + onClick: () => navigate("/popup", { state: { autoOpen: true } }), }, { - placeholder: "Add a hint to your app", - onClick: () => navigate("/hint/create"), + skeletonType: <BannerSkeleton />, + placeholder: "Create a new banner", + onClick: () => navigate("/banner", { state: { autoOpen: true } }), }, { - placeholder: "Create a new banner", - onClick: () => navigate("/banner/create"), + skeletonType: <BaseSkeleton guideType="helperLink" />, + placeholder: "Create a new helper link", + onClick: () => navigate("/hint", { state: { autoOpen: true } }), }, ]; - return ( - - <div className={styles.container}> - <div className={styles.top}> - <UserTitle name={name} /> - <DateDisplay /> - </div> - <div className={styles.text}> - Start with a popular onboarding process - </div> - <CreateActivityButtonList buttons={buttons} /> - <StatisticCardList metrics={metrics} /> + return ( + <div className={styles.container}> + <div className={styles.top}> + <UserTitle name={name} /> + <DateDisplay /> </div> + <div className={styles.text}>Start with a popular onboarding process</div> + <CreateActivityButtonList buttons={buttons} /> + {isLoading ? <LoadingArea /> : <StatisticCardList metrics={metrics} />} + </div> ); }; diff --git a/frontend/src/scenes/dashboard/HomePageComponents/CreateActivityButton/ActivityButtonStyles.js b/frontend/src/scenes/dashboard/HomePageComponents/CreateActivityButton/ActivityButtonStyles.js index 627a9181..8499d9b3 100644 --- a/frontend/src/scenes/dashboard/HomePageComponents/CreateActivityButton/ActivityButtonStyles.js +++ b/frontend/src/scenes/dashboard/HomePageComponents/CreateActivityButton/ActivityButtonStyles.js @@ -1,36 +1,22 @@ - - // Define color constants - const iconColor = '#667085'; - const hoverColor = 'orange'; - const textColor = '#344054'; - const borderColor = '#FFD8C7'; - const buttonBackgroundColor = '#FFFAFA'; - - export const activityButtonStyles = { - - button: { - backgroundColor: buttonBackgroundColor, - color: textColor, - border: '1px solid ' + borderColor, - fontSize: '16px', - fontWeight: 400, - lineHeight: '24px', - textTransform: 'none', - padding: '1.3rem 3rem', - display: 'flex', - alignItems: 'center', - flexDirection: 'column', - width: 'fit-content', - boxShadow: 'none', - borderRadius: '10px', - gap: '1rem', - width: '100%', - ':hover': { - backgroundColor: hoverColor - }, +export const activityButtonStyles = { + display: "flex", + fontWeight: 400, + backgroundColor: "var(--gray-50)", + width: "100%", + border: "1px solid var(--grey-border)", + borderRadius: "10px", + color: "var(--gray-400)", + padding: "1.3rem 3rem", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + transition: "box-shadow 0.3s ease", + textTransform: "none", + "&:hover": { + border: "1px solid var(--gray-250)", + backgroundColor: "var(--gray-100)", + ".childSkeleton": { + border: "1px solid var(--blue-300)", }, - icon: { - color: iconColor, - fontSize: '2rem' } - }; - \ No newline at end of file + }, +}; diff --git a/frontend/src/scenes/dashboard/HomePageComponents/CreateActivityButton/CreateActivityButton.jsx b/frontend/src/scenes/dashboard/HomePageComponents/CreateActivityButton/CreateActivityButton.jsx index 4a1b9dd2..8faf9b15 100644 --- a/frontend/src/scenes/dashboard/HomePageComponents/CreateActivityButton/CreateActivityButton.jsx +++ b/frontend/src/scenes/dashboard/HomePageComponents/CreateActivityButton/CreateActivityButton.jsx @@ -1,33 +1,25 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Button from '@mui/material/Button'; -import WbIncandescentOutlinedIcon from '@mui/icons-material/WbIncandescentOutlined'; -import DirectionsBusFilledOutlinedIcon from '@mui/icons-material/DirectionsBusFilledOutlined'; -import { activityButtonStyles } from './ActivityButtonStyles'; - -const CreateActivityButton = ({ placeholder = '', onButtonClick = () => {} }) => { - - const icon = placeholder === 'Create a welcome tour' - ? <DirectionsBusFilledOutlinedIcon style={activityButtonStyles.icon} /> - : <WbIncandescentOutlinedIcon style={activityButtonStyles.icon} />; +import React from "react"; +import PropTypes from "prop-types"; +import Button from "@mui/material/Button"; +import { activityButtonStyles } from "./ActivityButtonStyles"; +const CreateActivityButton = ({ + placeholder = "", + skeletonType, + onClick = () => {}, +}) => { return ( - <Button - variant="contained" - startIcon={icon} - onClick={onButtonClick} - sx={{ - ...activityButtonStyles.button - }} - > + <Button variant="text" onClick={onClick} sx={activityButtonStyles}> + {skeletonType} {placeholder} </Button> ); }; CreateActivityButton.propTypes = { + skeletonType: PropTypes.node, placeholder: PropTypes.string, - onButtonClick: PropTypes.func.isRequired, + onClick: PropTypes.func.isRequired, }; export default CreateActivityButton; diff --git a/frontend/src/scenes/dashboard/HomePageComponents/CreateActivityButtonList/CreateActivityButtonList.jsx b/frontend/src/scenes/dashboard/HomePageComponents/CreateActivityButtonList/CreateActivityButtonList.jsx index c50abb27..716f0c6e 100644 --- a/frontend/src/scenes/dashboard/HomePageComponents/CreateActivityButtonList/CreateActivityButtonList.jsx +++ b/frontend/src/scenes/dashboard/HomePageComponents/CreateActivityButtonList/CreateActivityButtonList.jsx @@ -1,19 +1,14 @@ -import React from 'react'; -import CreateActivityButton from '../CreateActivityButton/CreateActivityButton'; -import styles from './CreateActivityButtonList.module.scss' +import CreateActivityButton from "../CreateActivityButton/CreateActivityButton"; +import styles from "./CreateActivityButtonList.module.scss"; const CreateActivityButtonList = ({ buttons }) => { - return ( - <div className={styles.activityButtons}> - {buttons.map((button, index) => ( - <CreateActivityButton - key={index} - placeholder={button.placeholder} - onButtonClick={button.onClick} - /> - ))} - </div> - ); + return ( + <div className={styles.activityButtons}> + {buttons.map((button, index) => ( + <CreateActivityButton key={index} {...button} /> + ))} + </div> + ); }; export default CreateActivityButtonList; diff --git a/frontend/src/scenes/dashboard/HomePageComponents/Skeletons/BannerSkeleton.jsx b/frontend/src/scenes/dashboard/HomePageComponents/Skeletons/BannerSkeleton.jsx new file mode 100644 index 00000000..97c46dd9 --- /dev/null +++ b/frontend/src/scenes/dashboard/HomePageComponents/Skeletons/BannerSkeleton.jsx @@ -0,0 +1,49 @@ +import { Skeleton } from "@mui/material"; +import styles from "./Skeleton.module.scss"; + +const BannerSkeleton = () => { + const skeletonStyles = { bgcolor: "var(--gray-200)", borderRadius: "3.12px" }; + + return ( + <div className={styles.bannerSkeletonContainer}> + <Skeleton + variant="rounded" + width={80} + height={10} + sx={{ bgcolor: "var(--gray-300", borderRadius: "3.12px" }} + animation={false} + /> + <Skeleton + variant="rectangular" + width={210} + height={28} + sx={skeletonStyles} + animation={false} + /> + <Skeleton + variant="rectangular" + width={210} + height={20} + sx={skeletonStyles} + animation={false} + /> + + <Skeleton + className="childSkeleton" + sx={{ + position: "absolute", + top: 20, + left: "50%", + transform: "translate(-50%, -50%)", + bgcolor: "var(--blue-50)", + }} + variant="rounded" + width={260} + height={20} + animation={false} + /> + </div> + ); +}; + +export default BannerSkeleton; diff --git a/frontend/src/scenes/dashboard/HomePageComponents/Skeletons/BaseSkeleton.jsx b/frontend/src/scenes/dashboard/HomePageComponents/Skeletons/BaseSkeleton.jsx new file mode 100644 index 00000000..755a567b --- /dev/null +++ b/frontend/src/scenes/dashboard/HomePageComponents/Skeletons/BaseSkeleton.jsx @@ -0,0 +1,37 @@ +import { Skeleton } from "@mui/material"; +import styles from "./Skeleton.module.scss"; +import { baseSkeletonStyles } from "./BaseSkeletonStyles"; + +const BaseSkeleton = ({ guideType, items = 4 }) => { + const skeletonStyles = { bgcolor: "var(--gray-200)", borderRadius: "3.12px" }; + const guideTypeStyles = baseSkeletonStyles[guideType] || {}; + + return ( + <div className={styles.skeletonContainer}> + {[...Array(items)].map((_, index) => ( + <Skeleton + key={index} + variant="rounded" + width={210} + height={18} + animation={false} + sx={skeletonStyles} + /> + ))} + + <Skeleton + className="childSkeleton" + variant="rounded" + width={guideTypeStyles.width || 100} + height={guideTypeStyles.height || 50} + animation={false} + sx={{ + ...guideTypeStyles, + bgcolor: "var(--blue-50)", + }} + /> + </div> + ); +}; + +export default BaseSkeleton; diff --git a/frontend/src/scenes/dashboard/HomePageComponents/Skeletons/BaseSkeletonStyles.js b/frontend/src/scenes/dashboard/HomePageComponents/Skeletons/BaseSkeletonStyles.js new file mode 100644 index 00000000..967f4777 --- /dev/null +++ b/frontend/src/scenes/dashboard/HomePageComponents/Skeletons/BaseSkeletonStyles.js @@ -0,0 +1,16 @@ +export const baseSkeletonStyles = { + popup: { + position: "absolute", + top: 25, + left: 30, + width: 80, + height: 35, + }, + helperLink: { + position: "absolute", + bottom: "1.3rem", + right: "1rem", + width: 75, + height: 60, + }, +}; diff --git a/frontend/src/scenes/dashboard/HomePageComponents/Skeletons/Skeleton.module.scss b/frontend/src/scenes/dashboard/HomePageComponents/Skeletons/Skeleton.module.scss new file mode 100644 index 00000000..bb309053 --- /dev/null +++ b/frontend/src/scenes/dashboard/HomePageComponents/Skeletons/Skeleton.module.scss @@ -0,0 +1,20 @@ +.skeletonContainer { + position: relative; + width: 256; + height: 152; + background-color: white; + border-radius: 7.8px; + border: 1.23px solid var(--grey-border); + padding: 1.3rem 1rem; + display: flex; + justify-self: center; + flex-direction: column; + gap: 8px; + width: auto; + margin-bottom: 24px; +} + +.bannerSkeletonContainer { + @extend .skeletonContainer; + padding-top: 40px; +} diff --git a/frontend/src/scenes/dashboard/HomePageComponents/StatisticCards/StatisticCards.jsx b/frontend/src/scenes/dashboard/HomePageComponents/StatisticCards/StatisticCards.jsx index f847f696..edaafcec 100644 --- a/frontend/src/scenes/dashboard/HomePageComponents/StatisticCards/StatisticCards.jsx +++ b/frontend/src/scenes/dashboard/HomePageComponents/StatisticCards/StatisticCards.jsx @@ -1,20 +1,29 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styles from './StatisticCards.module.scss'; -import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward'; -import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward'; +import ArrowDownwardRoundedIcon from '@mui/icons-material/ArrowDownwardRounded'; +import ArrowUpwardRoundedIcon from '@mui/icons-material/ArrowUpwardRounded'; +import PropTypes from "prop-types"; +import React from "react"; +import styles from "./StatisticCards.module.scss"; const StatisticCard = ({ metricName, metricValue = 0, changeRate = 0 }) => { + const getChangeRate = () => { + if (changeRate === 0) return "N/A"; + return Math.abs(changeRate) + "%"; + }; + + const getRateColor = () => { + if (changeRate === 0) return "inherit"; + return changeRate >= 0 ? "var(--green-400)" : "var(--red-500)"; + }; return ( <div className={styles.statisticCard}> <div className={styles.metricName}>{metricName}</div> <div className={styles.metricValue}>{metricValue}</div> <div className={styles.changeRate}> - - <span style={{color: changeRate >= 0 ? 'green' : 'red'}} className={styles.change}> - {changeRate >= 0 ? <ArrowUpwardIcon /> : <ArrowDownwardIcon />} - {Math.abs(changeRate)}% + <span style={{ color: getRateColor() }} className={styles.change}> + {changeRate !== 0 && + (changeRate >= 0 ? <ArrowUpwardRoundedIcon /> : <ArrowDownwardRoundedIcon />)} + {getChangeRate()} </span>  vs last month </div> diff --git a/frontend/src/scenes/dashboard/HomePageComponents/StatisticCards/StatisticCards.module.scss b/frontend/src/scenes/dashboard/HomePageComponents/StatisticCards/StatisticCards.module.scss index b04bb056..a05e8a04 100644 --- a/frontend/src/scenes/dashboard/HomePageComponents/StatisticCards/StatisticCards.module.scss +++ b/frontend/src/scenes/dashboard/HomePageComponents/StatisticCards/StatisticCards.module.scss @@ -2,7 +2,7 @@ .statisticCard { border: 1px solid var(--light-gray); - border-radius: 12px; + border-radius: 10px; padding: 24px; gap:10px; display: flex; @@ -13,12 +13,14 @@ .metricName { font-size: var(--font-header); - font-weight: 600; + color: var(--gray-350); + font-weight: 400; line-height: 24px; margin-bottom: 13px; } .metricValue { + color: var(--gray-500); font-size: 36px; font-weight: 600; line-height: 44px; diff --git a/frontend/src/scenes/hints/CreateHintPage.jsx b/frontend/src/scenes/hints/CreateHintPage.jsx index 11b8687c..18d20f7f 100644 --- a/frontend/src/scenes/hints/CreateHintPage.jsx +++ b/frontend/src/scenes/hints/CreateHintPage.jsx @@ -10,9 +10,8 @@ import toastEmitter, { TOAST_EMITTER_KEY } from "../../utils/toastEmitter"; import { emitToastError } from "../../utils/guideHelper"; import { useDialog } from "../../templates/GuideTemplate/GuideTemplateContext"; -const HintPage = ({ isEdit, itemId, setItemsUpdated }) => { - const { closeDialog } = useDialog(); - +const HintPage = ({ autoOpen = false, isEdit, itemId, setItemsUpdated }) => { + const { openDialog, closeDialog } = useDialog(); const [activeButton, setActiveButton] = useState(0); @@ -60,6 +59,10 @@ const HintPage = ({ isEdit, itemId, setItemsUpdated }) => { }, ]; + useEffect(() => { + if (autoOpen) openDialog(); + }, [autoOpen, openDialog]); + useEffect(() => { if (isEdit) { const fetchHintData = async () => { diff --git a/frontend/src/scenes/hints/HintDefaultPage.jsx b/frontend/src/scenes/hints/HintDefaultPage.jsx index 4fe562d9..851baae0 100644 --- a/frontend/src/scenes/hints/HintDefaultPage.jsx +++ b/frontend/src/scenes/hints/HintDefaultPage.jsx @@ -1,4 +1,5 @@ import React, { useState } from "react"; +import {useLocation} from "react-router-dom" import DefaultPageTemplate from "../../templates/DefaultPageTemplate/DefaultPageTemplate"; import CreateHintPage from "./CreateHintPage"; import { ACTIVITY_TYPES_INFO } from "../../data/guideMainPageData"; @@ -8,6 +9,7 @@ const HintDefaultPage = () => { const [itemsUpdated, setItemsUpdated] = useState(false); const [isEdit, setIsEdit] = useState(false); const [itemId, setItemId] = useState(null); + const locationData = useLocation() const getHintDetails = (hint) => ({ title: `Hint ${hint.id}`, @@ -27,6 +29,7 @@ const HintDefaultPage = () => { itemsUpdated={itemsUpdated} /> <CreateHintPage + autoOpen={locationData.state?.autoOpen} isEdit={isEdit} itemId={itemId} setItemsUpdated={setItemsUpdated} diff --git a/frontend/src/scenes/links/LinksDefaultPage.jsx b/frontend/src/scenes/links/LinksDefaultPage.jsx index 1c1d7276..f5d35603 100644 --- a/frontend/src/scenes/links/LinksDefaultPage.jsx +++ b/frontend/src/scenes/links/LinksDefaultPage.jsx @@ -1,10 +1,10 @@ import React, { useState } from "react"; import { ACTIVITY_TYPES_INFO } from "../../data/guideMainPageData"; -import { deleteHelper, getHelpers} from "../../services/helperLinkService"; +import { deleteHelper, getHelpers } from "../../services/helperLinkService"; import HelperLinkProvider from "../../services/linksProvider"; import DefaultPageTemplate from "../../templates/DefaultPageTemplate/DefaultPageTemplate"; -import NewLinksPopup from "./NewLinksPopup"; import styles from "./LinkPage.module.scss"; +import NewLinksPopup from "./NewLinksPopup"; const LinksDefaultPage = () => { const [itemsUpdated, setItemsUpdated] = useState(false); @@ -16,31 +16,29 @@ const LinksDefaultPage = () => { headerBackgroundColor: helper.headerBackgroundColor, linkFontColor: helper.linkFontColor, iconColor: helper.iconColor, - }) + }); return ( - <> - <HelperLinkProvider> - <div className={styles.container}> - <NewLinksPopup - isEdit={isEdit} - itemId={itemId} - setItemsUpdated={setItemsUpdated} - /> - <DefaultPageTemplate - getItems={getHelpers} - deleteItem={deleteHelper} - itemsUpdated={itemsUpdated} - setIsEdit={setIsEdit} - setItemId={setItemId} - itemType={ACTIVITY_TYPES_INFO.HELPERLINKS} - itemTypeInfo={ACTIVITY_TYPES_INFO.HELPERLINKS} - getItemDetails={getItemDetails} - /> - </div> - </HelperLinkProvider> - </> + <HelperLinkProvider> + <div className={styles.container}> + <NewLinksPopup + isEdit={isEdit} + itemId={itemId} + setItemsUpdated={setItemsUpdated} + /> + <DefaultPageTemplate + getItems={getHelpers} + deleteItem={deleteHelper} + itemsUpdated={itemsUpdated} + setIsEdit={setIsEdit} + setItemId={setItemId} + itemType={ACTIVITY_TYPES_INFO.HELPERLINKS} + itemTypeInfo={ACTIVITY_TYPES_INFO.HELPERLINKS} + getItemDetails={getItemDetails} + /> + </div> + </HelperLinkProvider> ); }; -export default LinksDefaultPage; \ No newline at end of file +export default LinksDefaultPage; diff --git a/frontend/src/scenes/links/NewLinksPopup.jsx b/frontend/src/scenes/links/NewLinksPopup.jsx index be5cb9e5..bdb0d973 100644 --- a/frontend/src/scenes/links/NewLinksPopup.jsx +++ b/frontend/src/scenes/links/NewLinksPopup.jsx @@ -7,7 +7,7 @@ import { getHelperById, updateHelper, } from "../../services/helperLinkService"; -import { deleteLink, getLinkById } from "../../services/linkService"; +import { deleteLink } from "../../services/linkService"; import { HelperLinkContext } from "../../services/linksProvider"; import GuideTemplate from "../../templates/GuideTemplate/GuideTemplate"; import { useDialog } from "../../templates/GuideTemplate/GuideTemplateContext"; @@ -17,6 +17,13 @@ import styles from "./LinkPage.module.scss"; import LinkAppearance from "./LinkPageComponents/LinkAppearance"; import LinkContent from "./LinkPageComponents/LinkContent"; +const DEFAULT_VALUES = { + title: "", + headerBackgroundColor: "#F8F9F8", + linkFontColor: "#344054", + iconColor: "#7F56D9", +}; + const NewLinksPopup = ({ autoOpen = false, isEdit, @@ -31,35 +38,37 @@ const NewLinksPopup = ({ links, deletedLinks, setLinks, - helperToEdit, setHelperToEdit, + setDeletedLinks, } = useContext(HelperLinkContext); - const DEFAULT_VALUES = { - title: "", - headerBackgroundColor: "#F8F9F8", - linkFontColor: "#344054", - iconColor: "#7F56D9", - } const { openDialog, closeDialog, isOpen } = useDialog(); + + const resetHelper = (close = true) => { + close && closeDialog(); + setHelper({}); + setLinks([]); + setHelperToEdit(null); + setDeletedLinks([]); + }; + const fetchHelperData = async () => { try { const { links, ...data } = await getHelperById(itemId); setHelper(data); setLinks(links.sort((a, b) => a.order - b.order)); setHelperToEdit(itemId); - } - catch (error) { + } catch (error) { emitToastError(buildToastError(error)); - closeDialog(); + resetHelper(); } - } + }; useEffect(() => { if (autoOpen) { openDialog(); } - }, [autoOpen, openDialog]); + }, [autoOpen]); useEffect(() => { if (isEdit) { @@ -69,18 +78,16 @@ const NewLinksPopup = ({ setLinks([]); } if (!isOpen) { - setLinks([]); - setHelper({}); - setHelperToEdit(null); + resetHelper(false); } - }, [openDialog, isOpen]); + }, [isOpen]); const buildToastError = (msg) => msg.response ? msg : { - response: { data: { errors: [{ msg }] } }, - }; + response: { data: { errors: [{ msg }] } }, + }; const handleLinks = async (item) => { const { id, ...link } = item; @@ -89,8 +96,7 @@ const NewLinksPopup = ({ return null; } try { - const exists = await getLinkById(id); - if (exists?.id) return { ...link, id }; + if (typeof id === "number") return item; return { ...link }; } catch (err) { emitToastError(err); @@ -105,21 +111,20 @@ const NewLinksPopup = ({ if (formattedLinks.some((it) => !it)) { return null; } - newHelper = await (helperToEdit + newHelper = await (isEdit ? updateHelper(helper, formattedLinks) : createHelper(helper, formattedLinks)); setHelper(newHelper); setItemsUpdated((prevState) => !prevState); - closeDialog(); } catch (err) { emitToastError(buildToastError(err)); return null; } - if (helperToEdit && deletedLinks.length) { + if (isEdit && deletedLinks.length) { await Promise.all( deletedLinks.map(async (it) => { try { - return await deleteLink({ ...it, helperId: helperToEdit }); + return await deleteLink(it.id); } catch (err) { emitToastError(err); return null; @@ -128,14 +133,11 @@ const NewLinksPopup = ({ ); } if (newHelper) { - const toastMessage = helper.id + const toastMessage = isEdit ? "You edited this Helper Link" : "New Helper Link saved"; toastEmitter.emit(TOAST_EMITTER_KEY, toastMessage); - setShowNewLinksPopup(false); - setHelper({}); - setLinks([]); - setHelperToEdit(null); + resetHelper(); } }; diff --git a/frontend/src/scenes/popup/PopupDefaultPage.jsx b/frontend/src/scenes/popup/PopupDefaultPage.jsx index 83fdd542..bc24e706 100644 --- a/frontend/src/scenes/popup/PopupDefaultPage.jsx +++ b/frontend/src/scenes/popup/PopupDefaultPage.jsx @@ -1,4 +1,5 @@ import React, { useState } from 'react'; +import {useLocation} from "react-router-dom" import DefaultPageTemplate from '../../templates/DefaultPageTemplate/DefaultPageTemplate'; import CreatePopupPage from './CreatePopupPage'; import { getPopups, deletePopup } from '../../services/popupServices'; @@ -8,6 +9,7 @@ const PopupDefaultPage = () => { const [itemsUpdated, setItemsUpdated] = useState(false); const [isEdit, setIsEdit] = useState(false); const [itemId, setItemId] = useState(null); + const locationData = useLocation() const getPopupDetails = (popup) => ({ title: `Popup ${popup.id}`, @@ -28,6 +30,7 @@ const PopupDefaultPage = () => { itemsUpdated={itemsUpdated} /> <CreatePopupPage + autoOpen= {locationData.state?.autoOpen} isEdit={isEdit} itemId={itemId} setItemsUpdated={setItemsUpdated} diff --git a/frontend/src/scenes/progressSteps/ProgressSteps/ProgressSteps.jsx b/frontend/src/scenes/progressSteps/ProgressSteps/ProgressSteps.jsx index 147e1c96..7c5f1703 100644 --- a/frontend/src/scenes/progressSteps/ProgressSteps/ProgressSteps.jsx +++ b/frontend/src/scenes/progressSteps/ProgressSteps/ProgressSteps.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import styles from './ProgressSteps.module.scss'; import Step from './Step'; diff --git a/frontend/src/scenes/progressSteps/ProgressStepsMain.jsx b/frontend/src/scenes/progressSteps/ProgressStepsMain.jsx index 03106343..61cc096a 100644 --- a/frontend/src/scenes/progressSteps/ProgressStepsMain.jsx +++ b/frontend/src/scenes/progressSteps/ProgressStepsMain.jsx @@ -2,7 +2,6 @@ import React, { useState } from 'react'; import ProgressSteps from './ProgressSteps/ProgressSteps'; import styles from './ProgressStepsMain.module.scss'; import Button from '@components/Button/Button'; -import CheckboxHRM from '@components/Checkbox/CheckboxHRM'; import TeamMembersList from './ProgressSteps/TeamMemberList/TeamMembersList'; import { useNavigate } from "react-router-dom"; import { setOrganisation } from '../../services/teamServices'; @@ -12,7 +11,6 @@ import { CircularProgress } from '@mui/material'; const ProgressStepsMain = () => { const navigate = useNavigate(); - const NUMBER_OF_STEPS = 4; const [step, setStep] = useState(1); const [teamMembersEmails, setTeamMembersEmails] = useState([]); const [organizationName, setOrganizationName] = useState(''); @@ -103,18 +101,6 @@ const ProgressStepsMain = () => { } ]; - const increaseStep = () => { - if (step < NUMBER_OF_STEPS) { - setStep(step => step + 1); - } - } - - const decreaseStep = () => { - if (step > 1) { - setStep(step => step - 1); - } - } - const handleOrgNameEmpty = () => { if (!organizationName.trim()) { setOrgError(true); @@ -209,13 +195,24 @@ const ProgressStepsMain = () => { ) } - const pages = [firstPage, secondPage, thirdPage, fourthPage]; + const pages = [firstPage, thirdPage, fourthPage]; //second page is removed + const increaseStep = () => { + if (step < pages.length) { + setStep(step => step + 1); + } + } + + const decreaseStep = () => { + if (step > 1) { + setStep(step => step - 1); + } + } return ( <div className={styles.container}> <div className={styles.skeleton}> <h2>{content[step - 1].title}</h2> - <ProgressSteps stepData={NUMBER_OF_STEPS} completed={step} /> + <ProgressSteps stepData={pages.length} completed={step} /> </div> <div className={styles.content}> <h3 dangerouslySetInnerHTML={{ __html: content[step - 1].explanation }} /> diff --git a/frontend/src/scenes/settings/CodeTab/CodeTab.jsx b/frontend/src/scenes/settings/CodeTab/CodeTab.jsx index 56e5e854..6eb8c419 100644 --- a/frontend/src/scenes/settings/CodeTab/CodeTab.jsx +++ b/frontend/src/scenes/settings/CodeTab/CodeTab.jsx @@ -1,7 +1,6 @@ import React, { useState, useEffect } from "react"; import styles from "./CodeTab.module.css"; import CustomTextField from "@components/TextFieldComponents/CustomTextField/CustomTextField"; -import DeleteOutlinedIcon from '@mui/icons-material/DeleteOutlined'; import Button from "@components/Button/Button"; import ContentCopyOutlinedIcon from '@mui/icons-material/ContentCopyOutlined'; import { emitToastError } from "../../../utils/guideHelper"; @@ -78,6 +77,32 @@ const CodeTab = () => { } }; + const codeToCopy = ` + <!-- Client-side HTML/JS Snippet to be integrated into their website --> + <script> + (function() { + const apiUrl = '${serverUrl}'; + + var s=document.createElement("script"); + s.type="text/javascript"; + s.async=false; + s.onerror=()=>{console.log("onboard not loaded");}; + s.src = 'http://localhost:8082/main.js'; + (document.getElementsByTagName("head")[0] || document.getElementsByTagName("body")[0]).appendChild(s); + })(); + </script> + `; + + const handleCopy = () => { + navigator.clipboard.writeText(codeToCopy) + .then(() => { + toastEmitter.emit(TOAST_EMITTER_KEY, 'Code copied to clipboard'); + }) + .catch((err) => { + toastEmitter.emit(TOAST_EMITTER_KEY, err); + }); + }; + return ( <section className={styles.container}> <h2>API key management</h2> @@ -100,26 +125,17 @@ const CodeTab = () => { <p className={styles.content}> Code snippet to copy in your web page between {"<head>"} and {"</head>"}. Make sure you edit the API URL. </p> - <ContentCopyOutlinedIcon style={{ cursor: 'pointer', fontSize: '20px', color: 'var(--main-text-color)' }} /> + <ContentCopyOutlinedIcon + onClick={handleCopy} + style={{ + cursor: 'pointer', + fontSize: '20px', + color: 'var(--main-text-color)', + }} + /> </div> - - <pre><code> - {`<!-- Client-side HTML/JS Snippet to be integrated into their website --> - <script> - (function() { - const apiUrl = '${serverUrl}'; - - var s=document.createElement("script"); - s.type="text/javascript"; - s.async=false; - s.onerror=()=>{console.log("onboard not loaded");}; - s.src = 'http://localhost:8082; - (document.getElementsByTagName("head")[0] || document.getElementsByTagName("body")[0]).appendChild(script); - })(); - </script> - `} - </code></pre> + <pre><code>{codeToCopy}</code></pre> </section> ) } diff --git a/frontend/src/scenes/settings/TeamTab/TeamTab.module.css b/frontend/src/scenes/settings/TeamTab/TeamTab.module.css index 07935243..d8e2ae43 100644 --- a/frontend/src/scenes/settings/TeamTab/TeamTab.module.css +++ b/frontend/src/scenes/settings/TeamTab/TeamTab.module.css @@ -39,10 +39,6 @@ h6 { font-weight: 600; } -.MuiTabPanel-root { - padding: 0 -} - .pencil { cursor: pointer; } diff --git a/frontend/src/services/statisticsService.js b/frontend/src/services/statisticsService.js new file mode 100644 index 00000000..4fc5c0e6 --- /dev/null +++ b/frontend/src/services/statisticsService.js @@ -0,0 +1,18 @@ +import { emitToastError } from "../utils/guideHelper"; +import { apiClient } from "./apiClient"; + +const getStatistics = async () => { + try { + const response = await apiClient.get(`/statistics/`); + return response.data; + } catch (error) { + const errorMessage = + error.response?.data?.errors?.[0]?.msg || error.message; + emitToastError({ + response: { data: { errors: [{ msg: errorMessage }] } }, + }); + return null; + } +}; + +export { getStatistics }; diff --git a/frontend/src/styles/variables.css b/frontend/src/styles/variables.css index 85ecdd4c..b95e7b81 100644 --- a/frontend/src/styles/variables.css +++ b/frontend/src/styles/variables.css @@ -7,6 +7,7 @@ --main-text-color: #344054; --second-text-color: #667085; --third-text-color: #475467; + --preview-header-color: #484848; --main-purple: #7f56d9; --light-purple: #f3e5f5; --light-gray: #EAECF0; @@ -32,6 +33,18 @@ --dark-background: #121212; --light-surface: #f5f5f5; --dark-surface: #1e1e1e; + --gray-50: #fbfbfb; + --gray-100: #f8f8f8; + --gray-200: #f3f3f3; + --gray-250: #c6c6c7; + --gray-300: #d9d9d9; + --gray-350: #868c98; + --gray-400: #73787f; + --gray-500: #545454; + --blue-50: #e3ebff; + --blue-300: #95b3ff; + --green-400: #079455; + --red-500: #ef4444; /* Custom label tag component */ --label-orange-bg: #fff3e0; diff --git a/frontend/src/templates/GuideMainPageTemplate/GuideMainPageComponents/ConfirmationPopup/ConfirmationPopup.jsx b/frontend/src/templates/GuideMainPageTemplate/GuideMainPageComponents/ConfirmationPopup/ConfirmationPopup.jsx index b02a919d..6a055229 100644 --- a/frontend/src/templates/GuideMainPageTemplate/GuideMainPageComponents/ConfirmationPopup/ConfirmationPopup.jsx +++ b/frontend/src/templates/GuideMainPageTemplate/GuideMainPageComponents/ConfirmationPopup/ConfirmationPopup.jsx @@ -1,17 +1,28 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle, Button } from '@mui/material'; +import React from "react"; +import PropTypes from "prop-types"; +import { + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Button, +} from "@mui/material"; const ConfirmationPopup = ({ open, onConfirm, onCancel }) => { return ( - <Dialog open={open} onClose={onCancel}> + <Dialog open={open} onClose={onCancel} closeAfterTransition={open}> <DialogTitle>Confirm Action</DialogTitle> <DialogContent> - <DialogContentText>Are you sure you want to perform this action?</DialogContentText> + <DialogContentText> + Are you sure you want to perform this action? + </DialogContentText> </DialogContent> <DialogActions> <Button onClick={onCancel}>Cancel</Button> - <Button onClick={onConfirm} color="primary">Confirm</Button> + <Button onClick={onConfirm} color="primary"> + Confirm + </Button> </DialogActions> </Dialog> ); diff --git a/frontend/src/templates/GuideTemplate/GuideTemplate.jsx b/frontend/src/templates/GuideTemplate/GuideTemplate.jsx index b60ed6b5..d02a0a4d 100644 --- a/frontend/src/templates/GuideTemplate/GuideTemplate.jsx +++ b/frontend/src/templates/GuideTemplate/GuideTemplate.jsx @@ -6,6 +6,7 @@ import { React } from "react"; import Button from "../../components/Button/Button"; import styles from "./GuideTemplate.module.scss"; import { useDialog } from "./GuideTemplateContext"; +import { useLocation, useNavigate } from "react-router"; const GuideTemplate = ({ title = "", @@ -17,13 +18,22 @@ const GuideTemplate = ({ onSave = () => null, }) => { const { isOpen, closeDialog } = useDialog(); + const location = useLocation(); + const navigate = useNavigate(); const buttons = ["Content", "Appearance"]; + const onCloseHandler = () => { + if (location.state?.autoOpen) navigate("/", { state: {} }); + + closeDialog(); + }; + return ( <Dialog + closeAfterTransition={isOpen} open={isOpen} onClose={closeDialog} - maxWidth='lg' + maxWidth="lg" PaperProps={{ style: { position: "static" } }} > <div className={styles.container}> @@ -36,7 +46,7 @@ const GuideTemplate = ({ fontSize: "20px", cursor: "pointer", }} - onClick={closeDialog} + onClick={onCloseHandler} /> </div> <div className={styles.content}> @@ -60,13 +70,11 @@ const GuideTemplate = ({ </div> <div className={styles.optionButtons}> <Button - text='Cancel' - buttonType='secondary-grey' - onClick={() => { - closeDialog(); - }} + text="Cancel" + buttonType="secondary-grey" + onClick={onCloseHandler} /> - <Button text='Save' onClick={onSave} /> + <Button text="Save" onClick={onSave} /> </div> </div> </div> diff --git a/jsAgent/links.js b/jsAgent/links.js index ffcd3320..8bb1f139 100644 --- a/jsAgent/links.js +++ b/jsAgent/links.js @@ -1,45 +1,125 @@ -console.log('link.js is loaded'); +console.log('bw-link.js is loaded'); const linksDefaultOptions = { "url": "https://www.google.com", "order": 1, - "target": true + "target": true, + headerBackgroundColor: "#F8F9F8", + iconColor: "#7F56D9", + linkFontColor: "#344054" }; - - +const global_content_html=` + <li class="bw-links-li" style="display: flex; align-items: center; height:24px;"> + <svg viewBox="0 0 16 16" width="16" height="16" style="padding-right:16px" fill="none" xmlns="http://www.w3.org/2000/svg"> + <g clip-path="url(#clip0_601_3829)"> + <path d="M6.6668 8.66666C6.9531 9.04942 7.31837 9.36612 7.73783 9.59529C8.1573 9.82446 8.62114 9.96074 9.0979 9.99489C9.57466 10.029 10.0532 9.96024 10.501 9.79319C10.9489 9.62613 11.3555 9.36471 11.6935 9.02666L13.6935 7.02666C14.3007 6.39799 14.6366 5.55598 14.629 4.68199C14.6215 3.808 14.2709 2.97196 13.6529 2.35394C13.0348 1.73591 12.1988 1.38535 11.3248 1.37775C10.4508 1.37016 9.60881 1.70614 8.98013 2.31333L7.83347 3.45333M9.33347 7.33333C9.04716 6.95058 8.68189 6.63388 8.26243 6.4047C7.84297 6.17553 7.37913 6.03925 6.90237 6.00511C6.4256 5.97096 5.94708 6.03975 5.49924 6.20681C5.0514 6.37387 4.64472 6.63528 4.3068 6.97333L2.3068 8.97333C1.69961 9.602 1.36363 10.444 1.37122 11.318C1.37881 12.192 1.72938 13.028 2.3474 13.6461C2.96543 14.2641 3.80147 14.6147 4.67546 14.6222C5.54945 14.6298 6.39146 14.2939 7.02013 13.6867L8.16013 12.5467" + stroke="{{strokeColor}}" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> + </path> + </g> + <defs> + <clipPath> + <rect width="16" height="16" fill="white"></rect> + </clipPath> + </defs> + </svg> + <a href="{{link}}" target="_blank" style="color:{{linkFontColor}}; text-decoration: none; font-family: Inter; font-size: 1rem; font-weight: 400;">{{title}}</a> + </li> + `; bw.links={ init:function(){ - bw.links.putIcon(); - bw.links.putPlaceHolder(); + bw.links.putHtml(); + bw.links.putHeader(); bw.links.bindClick(); }, - putIcon:function(){ + putHtml:function(){ + const options = window.bwonboarddata.helperLink[0]; + let option = { + ...linksDefaultOptions, + ...options + }; + console.log(option); let temp_html = ` - <svg id="bw-links" style="position: fixed; bottom: 20px; right: 30px; z-index: 99; border: none; outline: none; cursor: pointer;" width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"> - <path d="M24 28V21M24 14H24.02M19.8 38.4L22.72 42.2933C23.1542 42.8723 23.3714 43.1618 23.6375 43.2653C23.8707 43.356 24.1293 43.356 24.3625 43.2653C24.6286 43.1618 24.8458 42.8723 25.28 42.2933L28.2 38.4C28.7863 37.6183 29.0794 37.2274 29.437 36.929C29.9138 36.5311 30.4766 36.2497 31.081 36.107C31.5343 36 32.0228 36 33 36C35.7956 36 37.1935 36 38.2961 35.5433C39.7663 34.9343 40.9343 33.7663 41.5433 32.2961C42 31.1935 42 29.7956 42 27V15.6C42 12.2397 42 10.5595 41.346 9.27606C40.7708 8.14708 39.8529 7.2292 38.7239 6.65396C37.4405 6 35.7603 6 32.4 6H15.6C12.2397 6 10.5595 6 9.27606 6.65396C8.14708 7.2292 7.2292 8.14708 6.65396 9.27606C6 10.5595 6 12.2397 6 15.6V27C6 29.7956 6 31.1935 6.45672 32.2961C7.06569 33.7663 8.23373 34.9343 9.7039 35.5433C10.8065 36 12.2044 36 15 36C15.9772 36 16.4657 36 16.919 36.107C17.5234 36.2497 18.0862 36.5311 18.563 36.929C18.9206 37.2274 19.2137 37.6183 19.8 38.4Z" stroke="#7F56D9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> - </svg>`; + <div style="position: fixed; bottom: 20px; right: 30px; z-index: 9999999;"> + <div id="bw-links" style=" box-shadow: 0px 8px 8px -4px rgba(16, 24, 40, 0.031372549), 0px 20px 24px -4px rgba(16, 24, 40, 0.0784313725); width: 330px; display: flex; flex-direction: column; justify-content: space-between; "> + ${bw.links.putHeader(option.title, option.headerBackgroundColor)} + ${bw.links.putContent(option.links, option.linkFontColor, option.iconColor)} + ${bw.links.putFooter()} + </div> + <div style="display: flex; justify-content: flex-end;" > + <svg id="bw-link-icon" width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"> + <path d="M24 28V21M24 14H24.02M19.8 38.4L22.72 42.2933C23.1542 42.8723 23.3714 43.1618 23.6375 43.2653C23.8707 43.356 24.1293 43.356 24.3625 43.2653C24.6286 43.1618 24.8458 42.8723 25.28 42.2933L28.2 38.4C28.7863 37.6183 29.0794 37.2274 29.437 36.929C29.9138 36.5311 30.4766 36.2497 31.081 36.107C31.5343 36 32.0228 36 33 36C35.7956 36 37.1935 36 38.2961 35.5433C39.7663 34.9343 40.9343 33.7663 41.5433 32.2961C42 31.1935 42 29.7956 42 27V15.6C42 12.2397 42 10.5595 41.346 9.27606C40.7708 8.14708 39.8529 7.2292 38.7239 6.65396C37.4405 6 35.7603 6 32.4 6H15.6C12.2397 6 10.5595 6 9.27606 6.65396C8.14708 7.2292 7.2292 8.14708 6.65396 9.27606C6 10.5595 6 12.2397 6 15.6V27C6 29.7956 6 31.1935 6.45672 32.2961C7.06569 33.7663 8.23373 34.9343 9.7039 35.5433C10.8065 36 12.2044 36 15 36C15.9772 36 16.4657 36 16.919 36.107C17.5234 36.2497 18.0862 36.5311 18.563 36.929C18.9206 37.2274 19.2137 37.6183 19.8 38.4Z" stroke="#7F56D9" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> + </svg> + </div> + + </div>`; document.body.insertAdjacentHTML('beforeend', temp_html); }, - putPlaceHolder: function(){ - let temp_html = `<div id="bw-links-placeholder" style="display:none; position: fixed; bottom: 70px; right: 30px; z-index: 99; border: none; outline: none; cursor: pointer;">place holder</div>`; - document.getElementById('bw-links').insertAdjacentHTML('beforebegin', temp_html); + putHeader: function(title, headerBackgroundColor){ + const temp_header_html =` + <div id="bw-links-header" style="padding: 18px 30px; background-color:${headerBackgroundColor}; display: flex; justify-content: space-around; align-items: center;"> + <svg style="height: 24px; width: 24px; color: rgb(102, 112, 133); font-size: 24px;"> + <path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m7.46 7.12-2.78 1.15c-.51-1.36-1.58-2.44-2.95-2.94l1.15-2.78c2.1.8 3.77 2.47 4.58 4.57M12 15c-1.66 0-3-1.34-3-3s1.34-3 3-3 3 1.34 3 3-1.34 3-3 3M9.13 4.54l1.17 2.78c-1.38.5-2.47 1.59-2.98 2.97L4.54 9.13c.81-2.11 2.48-3.78 4.59-4.59M4.54 14.87l2.78-1.15c.51 1.38 1.59 2.46 2.97 2.96l-1.17 2.78c-2.1-.81-3.77-2.48-4.58-4.59m10.34 4.59-1.15-2.78c1.37-.51 2.45-1.59 2.95-2.97l2.78 1.17c-.81 2.1-2.48 3.77-4.58 4.58"> + </path> + </svg> + <span style="margin:auto; flex-grow: 1; margin: 0 12px; font-family: Inter; font-size: 20px; font-weight: 600; line-height: 30px;"> + ${title} + </span> + <svg id="bw-links-close" viewBox="0 0 24 24" style="height: 20px; width: 20px; fill: rgb(152, 162, 179); font-size: 20px;"> + <path d="M18.3 5.71a.996.996 0 0 0-1.41 0L12 10.59 7.11 5.7a.996.996 0 0 0-1.41 0c-.39.39-.39 1.02 0 1.41L10.59 12 5.7 16.89c-.39.39-.39 1.02 0 1.41s1.02.39 1.41 0L12 13.41l4.89 4.89c.39.39 1.02.39 1.41 0s.39-1.02 0-1.41L13.41 12l4.89-4.89c.38-.38.38-1.02 0-1.4"> + </path> + </svg> + </div> + `; + return temp_header_html; }, - bindClick : function(){ - bw.util.bindLive("#bw-links", "click", function(){ - bw.links.toggle(); - }) + putContent: function(links, linkFontColor, strokeColor){ + let temp_content_html=` + <div id="bw-links-content" style="margin: 0;padding: 26px 34px 44px; background: white;"> + <ul > + {{links}} + </ul> + </div> + `; + let li_html=""; + for (let i = 0; i < links.length; i++) { + const link = links[i]; + let content_link = global_content_html.replace(new RegExp('{{link}}', 'g'), link.url ); + content_link = content_link.replace(new RegExp('{{title}}', 'g'), link.title ); + content_link = content_link.replace(new RegExp('{{linkFontColor}}', 'g'), linkFontColor ); + content_link = content_link.replace(new RegExp('{{strokeColor}}', 'g'), strokeColor ); + + li_html+=content_link; + } + temp_content_html = temp_content_html.replace(new RegExp('{{links}}', 'g'), li_html ); + + return temp_content_html; }, - show : function(){ - document.getElementById('bw-links-placeholder').style.display = 'block'; + putFooter: function(){ + return '<p style="margin-bottom: 0px; margin-top: 0px; background: white;; padding: 14px 0 11px;border-top: 1px solid #ebebeb; font-family: Inter; font-size: 0.688rem; font-weight: 400; line-height: 2.12; text-align: center;">Powered by BlueWave Onboarding</p>'; }, - hide : function(){ - document.getElementById('bw-links-placeholder').style.display = 'none'; + bindClick : function(){ + bw.util.bindLive("#bw-link-icon", "click", function(){ + bw.links.toggle(); + }); + bw.util.bindLive("#bw-links-close", "click", function(){ + document.getElementById('bw-links').style.display = 'none'; + }); + + document.querySelectorAll(".bw-links-li a").forEach(function(item){ + item.addEventListener("mouseover", function(e){ + e.target.style.textDecoration = 'underline'; + }); + item.addEventListener("mouseout", function(e){ + e.target.style.textDecoration = 'none'; + }); + }); + }, toggle: function(){ - document.getElementById('bw-links-placeholder').style.display = document.getElementById('bw-links-placeholder').style.display=='none'? 'block':'none'; + const element = document.getElementById('bw-links'); + element.style.display = element.style.display=='none' ? 'block':'none'; } - }; (async function () { diff --git a/jsAgent/main.js b/jsAgent/main.js index fbc192d6..b5b9944a 100644 --- a/jsAgent/main.js +++ b/jsAgent/main.js @@ -168,7 +168,7 @@ bw.init = (cb) => { if (onBoardConfig.banner?.length > 0) { bw.util.loadScriptAsync(BW_BANNER_JS_URL); } - if (onBoardConfig.link?.length > 0) { + if (onBoardConfig.helperLink?.length > 0) { bw.util.loadScriptAsync(BW_LINKS_JS_URL); } } catch (error) { diff --git a/jsAgent/popupRender.js b/jsAgent/popupRender.js deleted file mode 100644 index 6c8cb366..00000000 --- a/jsAgent/popupRender.js +++ /dev/null @@ -1,52 +0,0 @@ -const showPopup = (popupData) => { - if (!popupData || popupData.length === 0) { - console.warn('No popup data available'); - return; - } - - popupData.forEach(popup => { - // Create popup container - const popupContainer = document.createElement('div'); - Object.assign(popupContainer.style, { - position: 'fixed', - bottom: '20px', - right: '20px', - backgroundColor: popup.headerBg || '#FFFFFF', - padding: '20px', - border: '1px solid #000', - zIndex: 1000, - }); - - // Create header - const header = document.createElement('h2'); - header.textContent = popup.headerText; - header.style.color = popup.headerTextColor || '#000'; - popupContainer.appendChild(header); - - // Create content - const content = document.createElement('div'); - content.innerHTML = popup.contentHtml; - Object.assign(content.style, { - color: popup.fontColor || '#000', - fontSize: popup.font || '14px', - }); - popupContainer.appendChild(content); - - // Create action button - const actionButton = document.createElement('button'); - actionButton.textContent = popup.actionButtonText || 'Close'; - Object.assign(actionButton.style, { - backgroundColor: popup.actionButtonColor || '#CCC', - }); - actionButton.addEventListener('click', () => { - document.body.removeChild(popupContainer); // Remove popup when button is clicked - }); - popupContainer.appendChild(actionButton); - - // Append the popup to the document body - document.body.appendChild(popupContainer); - }); - }; - - export default showPopup; - \ No newline at end of file