diff --git a/backend/migrations/20241210225728-add-action-url-banner.js b/backend/migrations/20241210225728-add-action-url-banner.js new file mode 100644 index 00000000..949f5c67 --- /dev/null +++ b/backend/migrations/20241210225728-add-action-url-banner.js @@ -0,0 +1,18 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn("banners", "actionUrl", { + type: Sequelize.STRING, + allowNull: true, + validate: { + isUrl: true, + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn("banners", "actionUrl"); + }, +}; diff --git a/backend/migrations/20241210225734-add-action-url-popup.js b/backend/migrations/20241210225734-add-action-url-popup.js new file mode 100644 index 00000000..cd07d11a --- /dev/null +++ b/backend/migrations/20241210225734-add-action-url-popup.js @@ -0,0 +1,18 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.addColumn("popup", "actionUrl", { + type: Sequelize.STRING, + allowNull: true, + validate: { + isUrl: true, + }, + }); + }, + + async down(queryInterface, Sequelize) { + await queryInterface.removeColumn("popup", "actionUrl"); + }, +}; diff --git a/backend/src/controllers/banner.controller.js b/backend/src/controllers/banner.controller.js index ad6994c2..ccc92855 100644 --- a/backend/src/controllers/banner.controller.js +++ b/backend/src/controllers/banner.controller.js @@ -1,33 +1,61 @@ const bannerService = require("../service/banner.service.js"); const { internalServerError } = require("../utils/errors.helper"); const { validateCloseButtonAction } = require("../utils/guide.helper"); -const { validatePosition } = require("../utils/banner.helper"); +const { + validatePosition, + validateUrl, + validateRelativeUrl, +} = require("../utils/banner.helper"); const { checkColorFieldsFail } = require("../utils/guide.helper"); class BannerController { async addBanner(req, res) { const userId = req.user.id; - const { position, closeButtonAction, fontColor, backgroundColor } = req.body; + const { + position, + closeButtonAction, + fontColor, + backgroundColor, + actionUrl, + url, + } = req.body; if (!position || !closeButtonAction) { - return res - .status(400) - .json({ - errors: [{ msg: "position and closeButtonAction are required" }], - }); + return res.status(400).json({ + errors: [{ msg: "position and closeButtonAction are required" }], + }); } - if (!validatePosition(position) || !validateCloseButtonAction(closeButtonAction)) { - return res - .status(400) - .json({ - errors: [{ msg: "Invalid value entered" }], - }); + if ( + !validatePosition(position) || + !validateCloseButtonAction(closeButtonAction) + ) { + return res.status(400).json({ + errors: [{ msg: "Invalid value entered" }], + }); + } + + if (actionUrl) { + try { + validateUrl(actionUrl, "actionUrl"); + } catch (err) { + return res.status(400).json({ errors: [{ msg: err.message }] }); + } + } + + if (url) { + try { + validateRelativeUrl(url, "url"); + } catch (err) { + return res.status(400).json({ errors: [{ msg: err.message }] }); + } } const colorFields = { fontColor, backgroundColor }; - const colorCheck = checkColorFieldsFail(colorFields, res) - if (colorCheck) { return colorCheck }; + const colorCheck = checkColorFieldsFail(colorFields, res); + if (colorCheck) { + return colorCheck; + } try { const newBannerData = { ...req.body, createdBy: userId }; @@ -37,7 +65,7 @@ class BannerController { console.log(err); const { statusCode, payload } = internalServerError( "CREATE_BANNER_ERROR", - err.message, + err.message ); res.status(statusCode).json(payload); } @@ -54,11 +82,9 @@ class BannerController { const deletionResult = await bannerService.deleteBanner(id); if (!deletionResult) { - return res - .status(400) - .json({ - errors: [{ msg: "Banner with the specified id does not exist" }], - }); + return res.status(400).json({ + errors: [{ msg: "Banner with the specified id does not exist" }], + }); } res @@ -67,7 +93,7 @@ class BannerController { } catch (err) { const { statusCode, payload } = internalServerError( "DELETE_BANNER_ERROR", - err.message, + err.message ); res.status(statusCode).json(payload); } @@ -76,14 +102,19 @@ class BannerController { async editBanner(req, res) { try { const { id } = req.params; - const { fontColor, backgroundColor, url, position, closeButtonAction, bannerText } = req.body; + const { + fontColor, + backgroundColor, + url, + position, + closeButtonAction, + actionUrl, + } = req.body; if (!position || !closeButtonAction) { - return res - .status(400) - .json({ - errors: [{ msg: "position and closeButtonAction are required" }], - }); + return res.status(400).json({ + errors: [{ msg: "position and closeButtonAction are required" }], + }); } if (!validatePosition(position)) { @@ -98,16 +129,34 @@ class BannerController { .json({ errors: [{ msg: "Invalid value for closeButtonAction" }] }); } + if (actionUrl) { + try { + validateUrl(actionUrl, "actionUrl"); + } catch (err) { + return res.status(400).json({ errors: [{ msg: err.message }] }); + } + } + + if (url) { + try { + validateRelativeUrl(url, "url"); + } catch (err) { + return res.status(400).json({ errors: [{ msg: err.message }] }); + } + } + const colorFields = { fontColor, backgroundColor }; - const colorCheck = checkColorFieldsFail(colorFields, res) - if (colorCheck) { return colorCheck }; + const colorCheck = checkColorFieldsFail(colorFields, res); + if (colorCheck) { + return colorCheck; + } const updatedBanner = await bannerService.updateBanner(id, req.body); res.status(200).json(updatedBanner); } catch (err) { const { statusCode, payload } = internalServerError( "EDIT_BANNER_ERROR", - err.message, + err.message ); res.status(statusCode).json(payload); } @@ -120,7 +169,7 @@ class BannerController { } catch (err) { const { statusCode, payload } = internalServerError( "GET_ALL_BANNERS_ERROR", - err.message, + err.message ); res.status(statusCode).json(payload); } @@ -133,8 +182,8 @@ class BannerController { res.status(200).json(banners); } catch (err) { const { statusCode, payload } = internalServerError( - "GET\_BANNERS_ERROR", - err.message, + "GET_BANNERS_ERROR", + err.message ); res.status(statusCode).json(payload); } @@ -158,7 +207,7 @@ class BannerController { } catch (err) { const { statusCode, payload } = internalServerError( "GET_BANNER_BY_ID_ERROR", - err.message, + err.message ); res.status(statusCode).json(payload); } @@ -167,19 +216,22 @@ class BannerController { try { const { url } = req.body; - if (!url || typeof url !== 'string' ) { - return res.status(400).json({ errors: [{ msg: "URL is missing or invalid" }] }); + if (!url || typeof url !== "string") { + return res + .status(400) + .json({ errors: [{ msg: "URL is missing or invalid" }] }); } const banner = await bannerService.getBannerByUrl(url); - res.status(200).json({banner}); + res.status(200).json({ banner }); } catch (error) { - internalServerError( + const { payload, statusCode } = internalServerError( "GET_BANNER_BY_URL_ERROR", - error.message, + error.message ); + res.status(statusCode).json(payload); } - }; + } } module.exports = new BannerController(); diff --git a/backend/src/controllers/popup.controller.js b/backend/src/controllers/popup.controller.js index d1c6f050..0a566696 100644 --- a/backend/src/controllers/popup.controller.js +++ b/backend/src/controllers/popup.controller.js @@ -1,8 +1,12 @@ const popupService = require("../service/popup.service"); const { internalServerError } = require("../utils/errors.helper"); -const {validateCloseButtonAction } = require("../utils/guide.helper"); -const { validatePopupSize } = require("../utils/popup.helper"); -const { checkColorFieldsFail } =require("../utils/guide.helper"); +const { validateCloseButtonAction } = require("../utils/guide.helper"); +const { + validatePopupSize, + validateUrl, + validateRelativeUrl, +} = require("../utils/popup.helper"); +const { checkColorFieldsFail } = require("../utils/guide.helper"); class PopupController { async addPopup(req, res) { @@ -15,27 +19,42 @@ class PopupController { textColor, buttonBackgroundColor, buttonTextColor, + actionUrl, + url, } = req.body; if (!popupSize || !closeButtonAction) { - return res - .status(400) - .json({ - errors: [{ msg: "popupSize and closeButtonAction are required" }], - }); + return res.status(400).json({ + errors: [{ msg: "popupSize and closeButtonAction are required" }], + }); } if ( !validatePopupSize(popupSize) || !validateCloseButtonAction(closeButtonAction) ) { - return res - .status(400) - .json({ - errors: [{ msg: "Invalid value for popupSize or closeButtonAction" }], - }); + return res.status(400).json({ + errors: [{ msg: "Invalid value for popupSize or closeButtonAction" }], + }); } + if (actionUrl) { + try { + validateUrl(actionUrl, "actionUrl"); + } catch (err) { + return res.status(400).json({ errors: [{ msg: err.message }] }); + } + } + + if (url) { + try { + validateRelativeUrl(url, "url"); + } catch (err) { + return res.status(400).json({ errors: [{ msg: err.message }] }); + } + } + + const colorFields = { headerBackgroundColor, headerColor, @@ -43,8 +62,10 @@ class PopupController { buttonBackgroundColor, buttonTextColor, }; - const colorCheck = checkColorFieldsFail(colorFields, res) - if(colorCheck){return colorCheck}; + const colorCheck = checkColorFieldsFail(colorFields, res); + if (colorCheck) { + return colorCheck; + } try { const newPopupData = { ...req.body, createdBy: userId }; @@ -54,7 +75,7 @@ class PopupController { console.log(err); const { statusCode, payload } = internalServerError( "CREATE_POPUP_ERROR", - err.message, + err.message ); res.status(statusCode).json(payload); } @@ -64,18 +85,16 @@ class PopupController { try { const { id } = req.params; - if (Number.isNaN(Number(id)) || id.trim() === "") { + if (Number.isNaN(Number(id)) || id.trim() === "") { return res.status(400).json({ errors: [{ msg: "Invalid id" }] }); } const deletionResult = await popupService.deletePopup(id); if (!deletionResult) { - return res - .status(400) - .json({ - errors: [{ msg: "Popup with the specified id does not exist" }], - }); + return res.status(400).json({ + errors: [{ msg: "Popup with the specified id does not exist" }], + }); } res @@ -84,7 +103,7 @@ class PopupController { } catch (err) { const { statusCode, payload } = internalServerError( "DELETE_POPUP_ERROR", - err.message, + err.message ); res.status(statusCode).json(payload); } @@ -93,14 +112,22 @@ class PopupController { async editPopup(req, res) { try { const { id } = req.params; - const { popupSize, closeButtonAction, headerBackgroundColor, headerColor, textColor, buttonBackgroundColor, buttonTextColor } = req.body; + const { + popupSize, + closeButtonAction, + headerBackgroundColor, + headerColor, + textColor, + buttonBackgroundColor, + buttonTextColor, + actionUrl, + url, + } = req.body; if (!popupSize || !closeButtonAction) { - return res - .status(400) - .json({ - errors: [{ msg: "popupSize and closeButtonAction are required" }], - }); + return res.status(400).json({ + errors: [{ msg: "popupSize and closeButtonAction are required" }], + }); } if (!validatePopupSize(popupSize)) { @@ -115,6 +142,22 @@ class PopupController { .json({ errors: [{ msg: "Invalid value for closeButtonAction" }] }); } + if (actionUrl) { + try { + validateUrl(actionUrl, "actionUrl"); + } catch (err) { + return res.status(400).json({ errors: [{ msg: err.message }] }); + } + } + + if (url) { + try { + validateRelativeUrl(url, "url"); + } catch (err) { + return res.status(400).json({ errors: [{ msg: err.message }] }); + } + } + const colorFields = { headerBackgroundColor, headerColor, @@ -122,21 +165,22 @@ class PopupController { buttonBackgroundColor, buttonTextColor, }; - const colorCheck = checkColorFieldsFail(colorFields, res) - if(colorCheck){return colorCheck}; + const colorCheck = checkColorFieldsFail(colorFields, res); + if (colorCheck) { + return colorCheck; + } const updatedPopup = await popupService.updatePopup(id, req.body); res.status(200).json(updatedPopup); } catch (err) { const { statusCode, payload } = internalServerError( "EDIT_POPUP_ERROR", - err.message, + err.message ); res.status(statusCode).json(payload); } } - async getAllPopups(req, res) { try { const popups = await popupService.getAllPopups(); @@ -144,7 +188,7 @@ class PopupController { } catch (err) { const { statusCode, payload } = internalServerError( "GET_ALL_POPUPS_ERROR", - err.message, + err.message ); res.status(statusCode).json(payload); } @@ -158,7 +202,7 @@ class PopupController { } catch (err) { const { statusCode, payload } = internalServerError( "GET_POPUPS_ERROR", - err.message, + err.message ); res.status(statusCode).json(payload); } @@ -168,23 +212,21 @@ class PopupController { try { const { id } = req.params; - if (Number.isNaN(Number(id)) || id.trim() === "") { + if (Number.isNaN(Number(id)) || id.trim() === "") { return res.status(400).json({ errors: [{ msg: "Invalid popup ID" }] }); } const popup = await popupService.getPopupById(id); if (!popup) { - return res - .status(404) - .json({ errors: [{ msg: "Popup not found" }] }); + return res.status(404).json({ errors: [{ msg: "Popup not found" }] }); } res.status(200).json(popup); } catch (err) { const { statusCode, payload } = internalServerError( "GET_POPUP_BY_ID_ERROR", - err.message, + err.message ); res.status(statusCode).json(payload); } @@ -193,20 +235,22 @@ class PopupController { try { const { url } = req.body; - if (!url || typeof url !== 'string' ) { - return res.status(400).json({ errors: [{ msg: "URL is missing or invalid" }] }); + if (!url || typeof url !== "string") { + return res + .status(400) + .json({ errors: [{ msg: "URL is missing or invalid" }] }); } const popup = await popupService.getPopupByUrl(url); - res.status(200).json({popup}); + res.status(200).json({ popup }); } catch (error) { - internalServerError( + const { payload, statusCode } = internalServerError( "GET_POPUP_BY_URL_ERROR", - error.message, + error.message ); + res.status(statusCode).json(payload); } - }; - + } } -module.exports = new PopupController(); \ No newline at end of file +module.exports = new PopupController(); diff --git a/backend/src/models/Banner.js b/backend/src/models/Banner.js index 23d9124b..6655ab5d 100644 --- a/backend/src/models/Banner.js +++ b/backend/src/models/Banner.js @@ -1,74 +1,96 @@ -const { validateHexColor, validateActionButton } = require('../utils/guide.helper'); -const { validatePositionWrapper } = require('../utils/banner.helper'); +const { + validateHexColor, + validateActionButton, +} = require("../utils/guide.helper"); +const { + validatePositionWrapper, + validateUrl, + validateRelativeUrl, +} = require("../utils/banner.helper"); module.exports = (sequelize, DataTypes) => { - const Banner = sequelize.define('Banner', { - closeButtonAction: { - type: DataTypes.STRING, - allowNull: false, - validate: { - isValidAction(value) { - validateActionButton(value); - }, - }, + const Banner = sequelize.define( + "Banner", + { + closeButtonAction: { + type: DataTypes.STRING, + allowNull: false, + validate: { + isValidAction(value) { + validateActionButton(value); }, - position: { - type: DataTypes.STRING, - allowNull: false, - validate: { - isValidPosition(value) { - validatePositionWrapper(value); - }, - }, + }, + }, + position: { + type: DataTypes.STRING, + allowNull: false, + validate: { + isValidPosition(value) { + validatePositionWrapper(value); }, - url: { - type: DataTypes.STRING, - allowNull: true, + }, + }, + url: { + type: DataTypes.STRING, + allowNull: true, + validate: { + isUrl(value) { + validateRelativeUrl(value, "url"); }, - fontColor: { - type: DataTypes.STRING, - allowNull: false, - defaultValue: "#FFFFFF", - validate: { - isHexColor(value) { - validateHexColor(value, 'fontColor'); - }, - }, + }, + }, + fontColor: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: "#FFFFFF", + validate: { + isHexColor(value) { + validateHexColor(value, "fontColor"); }, - backgroundColor: { - type: DataTypes.STRING, - allowNull: false, - defaultValue: "#FFFFFF", - validate: { - isHexColor(value) { - validateHexColor(value, 'backgroundColor'); - }, - }, + }, + }, + backgroundColor: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: "#FFFFFF", + validate: { + isHexColor(value) { + validateHexColor(value, "backgroundColor"); }, - bannerText: { - type: DataTypes.STRING, - allowNull: false, - defaultValue: "", + }, + }, + bannerText: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: "", + }, + actionUrl: { + type: DataTypes.STRING, + allowNull: true, + validate: { + isUrl(value) { + validateUrl(value, "actionUrl"); }, + }, + }, - createdBy: { - type: DataTypes.INTEGER, - allowNull: false, - references: { - model: "users", - key: "id", - }, + createdBy: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: "users", + key: "id", }, + }, }, - { - tableName: "banners", - timestamps: false, - }, - ); + { + tableName: "banners", + timestamps: false, + } + ); - Banner.associate = (models) => { - Banner.belongsTo(models.User, { foreignKey: "createdBy", as: "creator" }); - }; - return Banner; + Banner.associate = (models) => { + Banner.belongsTo(models.User, { foreignKey: "createdBy", as: "creator" }); + }; + return Banner; }; - diff --git a/backend/src/models/Popup.js b/backend/src/models/Popup.js index f1b9719f..0e81dffa 100644 --- a/backend/src/models/Popup.js +++ b/backend/src/models/Popup.js @@ -1,5 +1,12 @@ -const { validateHexColor, validateActionButton } = require('../utils/guide.helper'); -const { validatePopupSizeWrapper } = require('../utils/popup.helper'); +const { + validateHexColor, + validateActionButton, +} = require("../utils/guide.helper"); +const { + validatePopupSizeWrapper, + validateUrl, + validateRelativeUrl, +} = require("../utils/popup.helper"); module.exports = (sequelize, DataTypes) => { const Popup = sequelize.define( @@ -15,7 +22,7 @@ module.exports = (sequelize, DataTypes) => { allowNull: false, validate: { isValidAction(value) { - validateActionButton(value); + validateActionButton(value); }, }, }, @@ -24,13 +31,18 @@ module.exports = (sequelize, DataTypes) => { allowNull: false, validate: { isValidPopupSize(value) { - validatePopupSizeWrapper(value); + validatePopupSizeWrapper(value); }, }, }, url: { type: DataTypes.STRING, allowNull: true, + validate: { + isUrl(value) { + validateRelativeUrl(value, "url"); + }, + }, }, actionButtonText: { type: DataTypes.STRING, @@ -42,7 +54,7 @@ module.exports = (sequelize, DataTypes) => { defaultValue: "#FFFFFF", validate: { isHexColor(value) { - validateHexColor(value, 'headerBackgroundColor'); + validateHexColor(value, "headerBackgroundColor"); }, }, }, @@ -52,7 +64,7 @@ module.exports = (sequelize, DataTypes) => { defaultValue: "#FFFFFF", validate: { isHexColor(value) { - validateHexColor(value, 'headerColor'); + validateHexColor(value, "headerColor"); }, }, }, @@ -62,7 +74,7 @@ module.exports = (sequelize, DataTypes) => { defaultValue: "#FFFFFF", validate: { isHexColor(value) { - validateHexColor(value, 'textColor'); + validateHexColor(value, "textColor"); }, }, }, @@ -72,7 +84,7 @@ module.exports = (sequelize, DataTypes) => { defaultValue: "#FFFFFF", validate: { isHexColor(value) { - validateHexColor(value, 'buttonBackgroundColor'); + validateHexColor(value, "buttonBackgroundColor"); }, }, }, @@ -82,7 +94,7 @@ module.exports = (sequelize, DataTypes) => { defaultValue: "#FFFFFF", validate: { isHexColor(value) { - validateHexColor(value, 'buttonTextColor'); + validateHexColor(value, "buttonTextColor"); }, }, }, @@ -96,6 +108,15 @@ module.exports = (sequelize, DataTypes) => { allowNull: false, defaultValue: "", }, + actionUrl: { + type: DataTypes.STRING, + allowNull: true, + validate: { + isUrl(value) { + validateUrl(value, "actionUrl"); + }, + }, + }, createdBy: { type: DataTypes.INTEGER, allowNull: false, @@ -108,7 +129,7 @@ module.exports = (sequelize, DataTypes) => { { tableName: "popup", timestamps: false, - }, + } ); Popup.associate = (models) => { diff --git a/backend/src/test/mocks/banner.mock.js b/backend/src/test/mocks/banner.mock.js index 1a88364c..d82af968 100644 --- a/backend/src/test/mocks/banner.mock.js +++ b/backend/src/test/mocks/banner.mock.js @@ -9,6 +9,7 @@ class BannerBuilder { backgroundColor: "#FFFFFF", bannerText: "banner 1", createdBy: 1, + actionUrl: "https://www.google.com", }; } diff --git a/backend/src/test/mocks/popup.mock.js b/backend/src/test/mocks/popup.mock.js index 12957914..75495073 100644 --- a/backend/src/test/mocks/popup.mock.js +++ b/backend/src/test/mocks/popup.mock.js @@ -14,6 +14,7 @@ class PopupBuilder { header: "header", content: "content", createdBy: 1, + actionUrl: "https://www.google.com", }; } diff --git a/backend/src/test/unit/controllers/popup.test.js b/backend/src/test/unit/controllers/popup.test.js index 990ec7b7..4eba0f6e 100644 --- a/backend/src/test/unit/controllers/popup.test.js +++ b/backend/src/test/unit/controllers/popup.test.js @@ -349,6 +349,7 @@ describe("Test popup controller", () => { await popupController.editPopup(req, res); const status = res.status.getCall(0).args[0]; const body = res.json.getCall(0).args[0]; + console.log(body) expect(status).to.be.equal(500); expect(body).to.be.deep.equal({ error: "Internal Server Error", diff --git a/backend/src/utils/banner.helper.js b/backend/src/utils/banner.helper.js index 5c23eb8c..93c42be8 100644 --- a/backend/src/utils/banner.helper.js +++ b/backend/src/utils/banner.helper.js @@ -5,12 +5,37 @@ const validatePosition = (value) => { const validatePositionWrapper = (value) => { if (!validatePosition(value)) { - throw new Error('Invalid position'); + throw new Error("Invalid position"); } }; - - module.exports = { - validatePosition, - validatePositionWrapper - }; \ No newline at end of file +const validateRelativeUrl = (value, fieldName) => { + if (!value) return; + try { + new URL(value); + } catch (error) { + if (value.startsWith('/')) { + return + } + throw new Error(`Invalid URL for ${fieldName}: ${error.message}`); + } +} + +const validateUrl = (value, fieldName) => { + if (!value) return; + try { + const url = new URL(value); + if (!["http:", "https:"].includes(url.protocol)) { + throw new Error("URL must use HTTP or HTTPS protocol"); + } + } catch (error) { + throw new Error(`Invalid URL for ${fieldName}: ${error.message}`); + } +}; + +module.exports = { + validatePosition, + validatePositionWrapper, + validateUrl, + validateRelativeUrl, +}; diff --git a/backend/src/utils/popup.helper.js b/backend/src/utils/popup.helper.js index 41d258a8..aff9cf03 100644 --- a/backend/src/utils/popup.helper.js +++ b/backend/src/utils/popup.helper.js @@ -9,9 +9,33 @@ const validatePopupSizeWrapper = (value) => { } }; +const validateRelativeUrl = (value, fieldName) => { + if (!value) return; + try { + new URL(value); + } catch (error) { + if (value.startsWith('/')) { + return + } + throw new Error(`Invalid URL for ${fieldName}: ${error.message}`); + } +} +const validateUrl = (value, fieldName) => { + if (!value) return; + try { + const url = new URL(value); + if (!['http:', 'https:'].includes(url.protocol)) { + throw new Error('URL must use HTTP or HTTPS protocol'); + } + } catch (error) { + throw new Error(`Invalid URL for ${fieldName}: ${error.message}`); + } +}; module.exports = { validatePopupSize, - validatePopupSizeWrapper + validatePopupSizeWrapper, + validateUrl, + validateRelativeUrl }; diff --git a/frontend/src/scenes/banner/BannerPageComponents/BannerLeftContent/BannerLeftContent.jsx b/frontend/src/scenes/banner/BannerPageComponents/BannerLeftContent/BannerLeftContent.jsx index 79a336d6..600d083c 100644 --- a/frontend/src/scenes/banner/BannerPageComponents/BannerLeftContent/BannerLeftContent.jsx +++ b/frontend/src/scenes/banner/BannerPageComponents/BannerLeftContent/BannerLeftContent.jsx @@ -1,54 +1,85 @@ -import React from 'react'; -import styles from './BannerLeftContent.module.scss'; -import DropdownList from '@components/DropdownList/DropdownList'; -import CustomTextField from '@components/TextFieldComponents/CustomTextField/CustomTextField'; -import RadioButton from '@components/RadioButton/RadioButton'; - -const BannerLeftContent = ({ setIsTopPosition, url, setUrl, setButtonAction, isTopPosition, buttonAction }) => { - const handleSetUrl = (event) => { - setUrl(event.target.value); - }; - - const handleActionChange = (newAction) => { - setButtonAction(newAction); - }; - - const handlePositionChange = (newPosition) => { - setIsTopPosition(newPosition); - }; - - return ( -
-

Action

- -

Position

-
- handlePositionChange(true)} - /> -
-
- handlePositionChange(false)} - /> -
- -

URL

- -
- ); +import DropdownList from "@components/DropdownList/DropdownList"; +import RadioButton from "@components/RadioButton/RadioButton"; +import CustomTextField from "@components/TextFieldComponents/CustomTextField/CustomTextField"; +import PropTypes from "prop-types"; +import React from "react"; +import styles from "./BannerLeftContent.module.scss"; + +const BannerLeftContent = ({ + setIsTopPosition, + url, + setUrl, + setButtonAction, + isTopPosition, + buttonAction, + actionUrl, + setActionUrl, +}) => { + const handleSetUrl = (event) => { + setUrl(event.target.value); + }; + + const handleSetActionUrl = (event) => { + setActionUrl(event.target.value); + }; + + const handleActionChange = (newAction) => { + setButtonAction(newAction); + }; + + const handlePositionChange = (newPosition) => { + setIsTopPosition(newPosition); + }; + + return ( +
+

Action

+ +

Position

+
+ handlePositionChange(true)} + /> +
+
+ handlePositionChange(false)} + /> +
+ +

URL

+ + +

Action URL

+ +
+ ); }; export default BannerLeftContent; +BannerLeftContent.propTypes = { + setIsTopPosition: PropTypes.func, + url: PropTypes.string, + setUrl: PropTypes.func, + setButtonAction: PropTypes.func, + isTopPosition: PropTypes.bool, + buttonAction: PropTypes.string, + actionUrl: PropTypes.string, + setActionUrl: PropTypes.func, +}; diff --git a/frontend/src/scenes/banner/CreateBannerPage.jsx b/frontend/src/scenes/banner/CreateBannerPage.jsx index 678717c9..59ad75fd 100644 --- a/frontend/src/scenes/banner/CreateBannerPage.jsx +++ b/frontend/src/scenes/banner/CreateBannerPage.jsx @@ -1,106 +1,144 @@ -import { React, useState, useEffect } from 'react'; -import GuideTemplate from '../../templates/GuideTemplate/GuideTemplate'; -import BannerLeftContent from './BannerPageComponents/BannerLeftContent/BannerLeftContent'; -import BannerLeftAppearance from './BannerPageComponents/BannerLeftAppearance/BannerLeftApperance'; -import BannerPreview from './BannerPageComponents/BannerPreview/BannerPreview'; -import { addBanner, getBannerById, editBanner } from '../../services/bannerServices'; -import { useNavigate, useLocation } from 'react-router-dom'; -import toastEmitter, { TOAST_EMITTER_KEY } from '../../utils/toastEmitter'; -import {emitToastError} from '../../utils/guideHelper' +import { React, useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { + addBanner, + editBanner, + getBannerById, +} from "../../services/bannerServices"; +import GuideTemplate from "../../templates/GuideTemplate/GuideTemplate"; +import { emitToastError } from "../../utils/guideHelper"; +import toastEmitter, { TOAST_EMITTER_KEY } from "../../utils/toastEmitter"; +import BannerLeftAppearance from "./BannerPageComponents/BannerLeftAppearance/BannerLeftApperance"; +import BannerLeftContent from "./BannerPageComponents/BannerLeftContent/BannerLeftContent"; +import BannerPreview from "./BannerPageComponents/BannerPreview/BannerPreview"; const BannerPage = () => { - const navigate = useNavigate(); - const location = useLocation(); + const navigate = useNavigate(); + const location = useLocation(); - const [backgroundColor, setBackgroundColor] = useState("#F9F5FF"); - const [fontColor, setFontColor] = useState("#344054"); - const [activeButton, setActiveButton] = useState(0); - const [isTopPosition, setIsTopPosition] = useState(true); - const [bannerText, setBannerText] = useState(''); - const [url, setUrl] = useState(''); - const [buttonAction, setButtonAction] = useState('No action'); + const [backgroundColor, setBackgroundColor] = useState("#F9F5FF"); + const [fontColor, setFontColor] = useState("#344054"); + const [activeButton, setActiveButton] = useState(0); + const [isTopPosition, setIsTopPosition] = useState(true); + const [bannerText, setBannerText] = useState(""); + const [url, setUrl] = useState(""); + const [actionUrl, setActionUrl] = useState(""); + const [buttonAction, setButtonAction] = useState("No action"); - const handleButtonClick = (index) => { - setActiveButton(index); - }; - - useEffect(() => { - if (location.state?.isEdit) { - const fetchBannerData = async () => { - try { - const bannerData = await getBannerById(location.state.id); + const handleButtonClick = (index) => { + setActiveButton(index); + }; - // Update the state with the fetched data - setBackgroundColor(bannerData.backgroundColor || '#F9F5FF'); - setFontColor(bannerData.fontColor || '#344054'); - setBannerText(bannerData.bannerText || ''); - setUrl(bannerData.url || ''); - setButtonAction(bannerData.closeButtonAction || 'No action'); - setIsTopPosition(bannerData.position === 'top'); + useEffect(() => { + if (location.state?.isEdit) { + const fetchBannerData = async () => { + try { + const bannerData = await getBannerById(location.state.id); - console.log('Get banner successful:', bannerData); - } catch (error) { - emitToastError(error) - } - }; + // Update the state with the fetched data + setBackgroundColor(bannerData.backgroundColor || "#F9F5FF"); + setFontColor(bannerData.fontColor || "#344054"); + setBannerText(bannerData.bannerText || ""); + setUrl(bannerData.url || ""); + setActionUrl(bannerData.actionUrl || ""); + setButtonAction(bannerData.closeButtonAction || "No action"); + setIsTopPosition(bannerData.position === "top"); - fetchBannerData(); - } - }, [location.state]); - - const onSave = async () => { - const bannerData = { - backgroundColor: backgroundColor, - fontColor: fontColor, - url: url, - position: isTopPosition ? 'top' : 'bottom', - closeButtonAction: buttonAction.toLowerCase(), - bannerText: bannerText - }; - try { - const response = location.state?.isEdit - ? await editBanner(location.state?.id, bannerData) - : await addBanner(bannerData); - const toastMessage = location.state?.isEdit ? 'You edited this banner' : 'New banner saved' - toastEmitter.emit(TOAST_EMITTER_KEY, toastMessage); - navigate('/banner'); + console.log("Get banner successful:", bannerData); } catch (error) { - emitToastError(error) + emitToastError(error); } + }; + + fetchBannerData(); } + }, [location.state]); - return ( + const validateUrl = (url) => { + try { + new URL(url); + return null; + } catch (err) { + return "Invalid URL format"; + } + }; + + const onSave = async () => { + if (actionUrl && actionUrl !== "https://") { + const urlError = validateUrl(actionUrl); + if (urlError) { + emitToastError(urlError); + return; + } + } + if (url && url !== "https://") { + const urlError = validateUrl(url); + if (urlError) { + emitToastError(urlError); + return; + } + } + + const bannerData = { + backgroundColor, + fontColor, + url, + actionUrl, + position: isTopPosition ? "top" : "bottom", + closeButtonAction: buttonAction.toLowerCase(), + bannerText, + }; + try { + const response = location.state?.isEdit + ? await editBanner(location.state?.id, bannerData) + : await addBanner(bannerData); + const toastMessage = location.state?.isEdit + ? "You edited this banner" + : "New banner saved"; + toastEmitter.emit(TOAST_EMITTER_KEY, toastMessage); + navigate("/banner"); + } catch (error) { + emitToastError(error); + } + }; - - } - leftContent={() => - } - leftAppearance={() => ( - - )} /> - ); + return ( + ( + + )} + leftContent={() => ( + + )} + leftAppearance={() => ( + + )} + /> + ); }; export default BannerPage; diff --git a/frontend/src/scenes/popup/CreatePopupPage.jsx b/frontend/src/scenes/popup/CreatePopupPage.jsx index cde5ec25..5d68f3ac 100644 --- a/frontend/src/scenes/popup/CreatePopupPage.jsx +++ b/frontend/src/scenes/popup/CreatePopupPage.jsx @@ -1,140 +1,203 @@ -import React, { useState, useEffect } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; -import GuideTemplate from '../../templates/GuideTemplate/GuideTemplate'; -import RichTextEditor from '@components/RichTextEditor/RichTextEditor'; -import PopupAppearance from './PopupPageComponents/PopupAppearance/PopupAppearance'; -import PopupContent from './PopupPageComponents/PopupContent/PopupContent'; -import { addPopup, getPopupById, editPopup } from '../../services/popupServices'; -import toastEmitter, { TOAST_EMITTER_KEY } from '../../utils/toastEmitter'; -import { emitToastError } from '../../utils/guideHelper'; +import RichTextEditor from "@components/RichTextEditor/RichTextEditor"; +import React, { useEffect, useState } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; +import { + addPopup, + editPopup, + getPopupById, +} from "../../services/popupServices"; +import GuideTemplate from "../../templates/GuideTemplate/GuideTemplate"; +import { emitToastError } from "../../utils/guideHelper"; +import toastEmitter, { TOAST_EMITTER_KEY } from "../../utils/toastEmitter"; +import PopupAppearance from "./PopupPageComponents/PopupAppearance/PopupAppearance"; +import PopupContent from "./PopupPageComponents/PopupContent/PopupContent"; const CreatePopupPage = () => { - const navigate = useNavigate(); - const location = useLocation(); - - const [activeButton, setActiveButton] = useState(0); - - const [headerBackgroundColor, setHeaderBackgroundColor] = useState('#F8F9F8'); - const [headerColor, setHeaderColor] = useState('#101828'); - const [textColor, setTextColor] = useState('#344054'); - const [buttonBackgroundColor, setButtonBackgroundColor] = useState('#7F56D9'); - const [buttonTextColor, setButtonTextColor] = useState('#FFFFFF'); - - const [header, setHeader] = useState(''); - const [content, setContent] = useState(''); - - const [actionButtonUrl, setActionButtonUrl] = useState("https://"); - const [actionButtonText, setActionButtonText] = useState("Take me to subscription page"); - const [buttonAction, setButtonAction] = useState('No action'); - const [popupSize, setPopupSize] = useState('Small'); - - const stateList = [ - { stateName: 'Header Background Color', state: headerBackgroundColor, setState: setHeaderBackgroundColor }, - { stateName: 'Header Color', state: headerColor, setState: setHeaderColor }, - { stateName: 'Text Color', state: textColor, setState: setTextColor }, - { stateName: 'Button Background Color', state: buttonBackgroundColor, setState: setButtonBackgroundColor }, - { stateName: 'Button Text Color', state: buttonTextColor, setState: setButtonTextColor }, - ]; - - useEffect(() => { - if (location.state?.isEdit) { - const fetchPopupData = async () => { - try { - const popupData = await getPopupById(location.state.id); - - // Update the state with the fetched data - setHeaderBackgroundColor(popupData.headerBackgroundColor || '#F8F9F8'); - setHeaderColor(popupData.headerColor || '#101828'); - setTextColor(popupData.textColor || '#344054'); - setButtonBackgroundColor(popupData.buttonBackgroundColor || '#7F56D9'); - setButtonTextColor(popupData.buttonTextColor || '#FFFFFF'); - setHeader(popupData.header || ''); - setContent(popupData.content || ''); - setActionButtonUrl(popupData.url || 'https://'); - setActionButtonText(popupData.actionButtonText || 'Take me to subscription page'); - setButtonAction(popupData.closeButtonAction || 'No action'); - setPopupSize(popupData.popupSize || 'Small'); - } catch (error) { - emitToastError(error); - } - }; - - fetchPopupData(); - } - }, [location.state]); - - const onSave = async () => { - const popupData = { - popupSize: popupSize.toLowerCase(), - url: actionButtonUrl, - actionButtonText: actionButtonText, - headerBackgroundColor: headerBackgroundColor, - headerColor: headerColor, - textColor: textColor, - buttonBackgroundColor: buttonBackgroundColor, - buttonTextColor: buttonTextColor, - closeButtonAction: buttonAction.toLowerCase(), - header: header, - content: content - }; - try { - const response = location.state?.isEdit - ? await editPopup(location.state?.id, popupData) - : await addPopup(popupData); + const navigate = useNavigate(); + const location = useLocation(); + + const [activeButton, setActiveButton] = useState(0); + + const [headerBackgroundColor, setHeaderBackgroundColor] = useState("#F8F9F8"); + const [headerColor, setHeaderColor] = useState("#101828"); + const [textColor, setTextColor] = useState("#344054"); + const [buttonBackgroundColor, setButtonBackgroundColor] = useState("#7F56D9"); + const [buttonTextColor, setButtonTextColor] = useState("#FFFFFF"); + + const [header, setHeader] = useState(""); + const [content, setContent] = useState(""); - const toastMessage = location.state?.isEdit ? 'You edited this popup' : 'New popup Saved' + const [actionButtonUrl, setActionButtonUrl] = useState("https://"); + const [url, setUrl] = useState("https://"); + const [actionButtonText, setActionButtonText] = useState( + "Take me to subscription page" + ); + const [buttonAction, setButtonAction] = useState("No action"); + const [popupSize, setPopupSize] = useState("Small"); - toastEmitter.emit(TOAST_EMITTER_KEY, toastMessage) - navigate('/popup'); + const stateList = [ + { + stateName: "Header Background Color", + state: headerBackgroundColor, + setState: setHeaderBackgroundColor, + }, + { stateName: "Header Color", state: headerColor, setState: setHeaderColor }, + { stateName: "Text Color", state: textColor, setState: setTextColor }, + { + stateName: "Button Background Color", + state: buttonBackgroundColor, + setState: setButtonBackgroundColor, + }, + { + stateName: "Button Text Color", + state: buttonTextColor, + setState: setButtonTextColor, + }, + ]; + + useEffect(() => { + if (location.state?.isEdit) { + const fetchPopupData = async () => { + try { + const popupData = await getPopupById(location.state.id); + + // Update the state with the fetched data + setHeaderBackgroundColor( + popupData.headerBackgroundColor || "#F8F9F8" + ); + setHeaderColor(popupData.headerColor || "#101828"); + setTextColor(popupData.textColor || "#344054"); + setButtonBackgroundColor( + popupData.buttonBackgroundColor || "#7F56D9" + ); + setButtonTextColor(popupData.buttonTextColor || "#FFFFFF"); + setHeader(popupData.header || ""); + setContent(popupData.content || ""); + setActionButtonUrl(popupData.actionUrl || "https://"); + setUrl(popupData.url || "https://"); + setActionButtonText( + popupData.actionButtonText || "Take me to subscription page" + ); + setButtonAction(popupData.closeButtonAction || "No action"); + setPopupSize(popupData.popupSize || "Small"); } catch (error) { - const errorMessage = error.response?.data?.message - ? `Error: ${error.response.data.message}` - : 'An unexpected error occurred. Please try again.'; - toastEmitter.emit(TOAST_EMITTER_KEY, errorMessage); + emitToastError(error); } + }; + + fetchPopupData(); } + }, [location.state]); - const handleButtonClick = (index) => { - setActiveButton(index); + const validateUrl = (url) => { + try { + new URL(url); + return null; + } catch (err) { + return "Invalid URL format"; + } + }; + + const onSave = async () => { + if (actionButtonUrl && actionButtonUrl !== "https://") { + const urlError = validateUrl(actionButtonUrl); + if (urlError) { + emitToastError(urlError); + return; + } + } + if (url && url !== "https://") { + const urlError = validateUrl(url); + if (urlError) { + emitToastError(urlError); + return; + } + } + const popupData = { + popupSize: popupSize.toLowerCase(), + url, + actionUrl: actionButtonUrl, + actionButtonText, + headerBackgroundColor, + headerColor, + textColor, + buttonBackgroundColor, + buttonTextColor, + closeButtonAction: buttonAction.toLowerCase(), + header, + content, }; + try { + const response = location.state?.isEdit + ? await editPopup(location.state?.id, popupData) + : await addPopup(popupData); + + const toastMessage = location.state?.isEdit + ? "You edited this popup" + : "New popup Saved"; + + toastEmitter.emit(TOAST_EMITTER_KEY, toastMessage); + navigate("/popup"); + } catch (error) { + const errorMessage = error.response?.data?.message + ? `Error: ${error.response.data.message}` + : "An unexpected error occurred. Please try again."; + toastEmitter.emit(TOAST_EMITTER_KEY, errorMessage); + } + }; + + const handleButtonClick = (index) => { + setActiveButton(index); + }; - return ( - - } - leftContent={() => - } - leftAppearance={() => ( - - )} /> - ); + return ( + ( + + )} + leftContent={() => ( + + )} + leftAppearance={() => ( + + )} + /> + ); }; export default CreatePopupPage; diff --git a/frontend/src/scenes/popup/PopupPageComponents/PopupContent/PopupContent.jsx b/frontend/src/scenes/popup/PopupPageComponents/PopupContent/PopupContent.jsx index a63923d6..18a04e31 100644 --- a/frontend/src/scenes/popup/PopupPageComponents/PopupContent/PopupContent.jsx +++ b/frontend/src/scenes/popup/PopupPageComponents/PopupContent/PopupContent.jsx @@ -1,36 +1,69 @@ -import { React } from 'react'; -import styles from './PopupContent.module.scss'; -import DropdownList from '@components/DropdownList/DropdownList'; -import CustomTextField from '@components/TextFieldComponents/CustomTextField/CustomTextField'; +import DropdownList from "@components/DropdownList/DropdownList"; +import CustomTextField from "@components/TextFieldComponents/CustomTextField/CustomTextField"; +import { React } from "react"; +import styles from "./PopupContent.module.scss"; +import PropTypes from "prop-types"; -const PopupContent = ({ actionButtonText, setActionButtonText, setActionButtonUrl, buttonAction, actionButtonUrl, setButtonAction }) => { - const handleActionButtonText = (event) => { - setActionButtonText(event.target.value); - }; - const handleActionButtonUrl = (event) => { - setActionButtonUrl(event.target.value); - }; - const handleActionChange = (newAction) => { - setButtonAction(newAction); - }; - return ( -
-

Action

- -

Action button url (can be relative)

- -

Action button text

- -
- ); +const PopupContent = ({ + actionButtonText, + setActionButtonText, + setActionButtonUrl, + buttonAction, + actionButtonUrl, + setButtonAction, + url, + setUrl, +}) => { + const handleActionButtonText = (event) => { + setActionButtonText(event.target.value); + }; + const handleActionButtonUrl = (event) => { + setActionButtonUrl(event.target.value); + }; + const handleActionChange = (newAction) => { + setButtonAction(newAction); + }; + const handleUrlChange = (event) => { + setUrl(event.target.value); + }; + return ( +
+

Action

+ +

URL

+ +

Action button URL (can be relative)

+ +

Action button text

+ +
+ ); }; export default PopupContent; +PopupContent.propTypes = { + actionButtonText: PropTypes.string, + setActionButtonText: PropTypes.func, + setActionButtonUrl: PropTypes.func, + buttonAction: PropTypes.string, + actionButtonUrl: PropTypes.string, + setButtonAction: PropTypes.func, + url: PropTypes.string, + setUrl: PropTypes.func, +} diff --git a/frontend/src/tests/scenes/popup/CreatePopupPage.test.jsx b/frontend/src/tests/scenes/popup/CreatePopupPage.test.jsx index e5d86b09..726de2b6 100644 --- a/frontend/src/tests/scenes/popup/CreatePopupPage.test.jsx +++ b/frontend/src/tests/scenes/popup/CreatePopupPage.test.jsx @@ -105,7 +105,7 @@ describe('CreatePopupPage component', () => { // Check initial state of form fields const headerBackgroundColor = screen.getByDisplayValue('No action'); - const headerColor = screen.getByDisplayValue('https://'); + const headerColor = screen.getAllByDisplayValue('https://')[0]; expect(headerBackgroundColor).not.toBeNull(); // Example for headerBackgroundColor expect(headerColor).not.toBeNull(); // Example for headerColor