diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..17971afa0a759b322b214514e3381c85226c478c --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,28 @@ +image: node:latest + +stages: # List of stages for jobs, and their order of execution + - build + - test + - deploy + + +build-job: # This job runs in the build stage, which runs first. + stage: build + script: + - npm install + - npm run build + +unit-test-job: + stage: test + script: + - npm install + - npm run test:unit + + +# Create e2e test job + +# Create deploy job + + + + \ No newline at end of file diff --git a/README.md b/README.md index d011822129a3b63288284127554e7ec3e71ddd54..090646411398b131e385c88825a58fd184c24d6f 100644 --- a/README.md +++ b/README.md @@ -1,34 +1,106 @@ -# frontend +# Sparesti: Frontend +Sparesti is a full-stack application developed as part of an assessment in the IDATT2106 Software Engineering course +at the Norwegian University of Science and Technology (NTNU). The application is developed with Spring Boot and Vue.js. -## Project setup -``` -npm install -``` +## Team +- Olav Sie Rotvær +- Gia Hy Nguyen +- Melissa Visnjic +- Henrik Tefre +- Hanne-Sofie Søreide +- Jeffrey Yaw Annor Tabiri +- Ramtin Forouzandehjoo Samavat +- Tobias SkipevÃ¥g Oftedal -### Compiles and hot-reloads for development -``` -npm run serve -``` +## Table of Contents +- [Overview](#overview) +- [Features](#features) +- [Installation Manual](#installation-manual) +- [Tests](#tests) +- [Acknowledgements](#acknowledgements) -### Compiles and minifies for production -``` -npm run build -``` +## Overview +Sparesti is a full-stack web application designed to provide a secure +and efficient platform for users to manage their savings. It includes +a range of features that support secure authentication, goal tracking, +and personalized challenges to encourage users to save money. -### Run your unit tests -``` +## Features +- **Secure Authentication**: Ensures that users can securely log in, register, and manage their accounts. Passwords are encrypted and stored safely to protect user data. +- **Goals And Challenges**: Enables users to set personal financial goals, invite friends or family for collaboration, and design personalized challenges to achieve these goals effectively. +- **Progress Tracking**: Utilizes interactive game elements for real-time visualization of progress, and rewards users with badges for meeting financial milestones. +- **Financial Management**: Includes functionality for uploading PDF bank statements for automated budget creation and transaction management. +- **Enhanced Financial Knowledge**: Budgets are generated using SSB statistical data and personal income, displaying expected versus actual spending values to help users compare and adjust their financial plans. Additionally, users can access a dedicated page with news related to finance. +- **Accessibility Support**: Complies with WCAG and Firefox accessibility standards to ensure usability for all users. + +The project utilizes the following technologies: +- Frontend: Vue.js with Node.js. +- Backend: Spring Boot V3 with Java 21 and Maven. +- Database: MySQL V8 for runtime and H2 for tests. + +## Installation Manual + +### Prerequisites +- Node.js + +### Setup and Run +1. Clone the repository + ```sh + git clone https://gitlab.stud.idi.ntnu.no/idatt2106_2024_04/frontend.git + ``` + +2. Navigate to the project structure + ```sh + cd frontend + ``` + +3. Install dependencies + ```sh + npm install + ``` + +4. Compile + 1. Hot-Reload for Development: + ```sh + npm run serve + ``` + 2. Or build for Production: + ```sh + npm run build + ``` + ``` + cd dist + ``` + ``` + sudo npm install -g http-server + ``` + ``` + http-server -p 8082 + ``` + + +### Test users +Test user: + - Email: admin@example.com + - Password: password + +## Tests +### Run Unit Tests with [Jest](https://jestjs.io/) +```sh npm run test:unit ``` -### Run your end-to-end tests -``` -npm run test:e2e +### Run End-to-End Tests with [Cypress](https://www.cypress.io/) +Ensure that both the backend and frontend applications are running. +```sh +npm run test:e2e:dev ``` -### Lints and fixes files -``` -npm run lint -``` +### Test users and data +#### Admin: +- Email: admin@example.com +- Password: password -### Customize configuration -See [Configuration Reference](https://cli.vuejs.org/config/). +## Acknowledgements +Special thanks to the subject teachers and the product owners for creating this assignment and providing us with the +opportunity to develop this project. diff --git a/jest.config.js b/jest.config.js index 0f9579148896b7a46684f5e561ea000ca557ba2f..60a593c8e21beb1381b2f21384404baac06a3a68 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,3 +1,16 @@ module.exports = { - preset: '@vue/cli-plugin-unit-jest' + preset: '@vue/cli-plugin-unit-jest', + transform: { + '^.+\\.vue$': '@vue/vue3-jest', + '^.+\\.js$': 'babel-jest' + }, + transformIgnorePatterns: [ + '/node_modules/(?!axios).+\\.js$' + ], + moduleNameMapper: { + '^@/(.*)$': '<rootDir>/src/$1' + }, + testMatch: [ + '**/tests/unit/**/*.spec.js' + ] } diff --git a/package-lock.json b/package-lock.json index 2190da4ca973acceebe8449b3bbef10bbcbac794..7057035f91d5ebbcd16becf7dc744b865ae49697 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,8 @@ "axios": "^1.6.8", "chart.js": "^2.9.4", "core-js": "^3.8.3", + "js-cookie": "^3.0.5", + "jwt-decode": "^4.0.0", "vue": "^3.2.13", "vue-chartjs": "^3.5.1", "vue-router": "^4.0.3", @@ -30,6 +32,7 @@ "@vue/vue3-jest": "^27.0.0-alpha.1", "babel-jest": "^27.0.6", "cypress": "^9.7.0", + "cypress-file-upload": "^5.0.8", "eslint": "^7.32.0", "eslint-plugin-vue": "^8.0.3", "jest": "^27.0.5" @@ -6684,6 +6687,18 @@ "node": ">=12.0.0" } }, + "node_modules/cypress-file-upload": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/cypress-file-upload/-/cypress-file-upload-5.0.8.tgz", + "integrity": "sha512-+8VzNabRk3zG6x8f8BWArF/xA/W0VK4IZNx3MV0jFWrJS/qKn8eHfa5nU73P9fOQAgwHFJx7zjg4lwOnljMO8g==", + "dev": true, + "engines": { + "node": ">=8.2.1" + }, + "peerDependencies": { + "cypress": ">3.0.0" + } + }, "node_modules/cypress/node_modules/@types/node": { "version": "14.18.63", "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", @@ -12617,7 +12632,6 @@ "version": "3.0.5", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", - "dev": true, "engines": { "node": ">=14" } @@ -12801,6 +12815,14 @@ "verror": "1.10.0" } }, + "node_modules/jwt-decode": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz", + "integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==", + "engines": { + "node": ">=18" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", diff --git a/package.json b/package.json index 80a8c785e6fd26d97ab5cca700fab2e6710f393b..c53f66c45f59d9e650dce9e3159cb2b8975e58a7 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "axios": "^1.6.8", "chart.js": "^2.9.4", "core-js": "^3.8.3", + "js-cookie": "^3.0.5", + "jwt-decode": "^4.0.0", "vue": "^3.2.13", "vue-chartjs": "^3.5.1", "vue-router": "^4.0.3", @@ -32,6 +34,7 @@ "@vue/vue3-jest": "^27.0.0-alpha.1", "babel-jest": "^27.0.6", "cypress": "^9.7.0", + "cypress-file-upload": "^5.0.8", "eslint": "^7.32.0", "eslint-plugin-vue": "^8.0.3", "jest": "^27.0.5" diff --git a/public/favicon.ico b/public/favicon.ico deleted file mode 100644 index df36fcfb72584e00488330b560ebcf34a41c64c2..0000000000000000000000000000000000000000 Binary files a/public/favicon.ico and /dev/null differ diff --git a/public/index.html b/public/index.html index cbd56e31eea5e5b1c70ef607c0bb8fe178f31634..badf55997de2338b85c55d8c59e6c1f700366204 100644 --- a/public/index.html +++ b/public/index.html @@ -2,9 +2,10 @@ <html lang=""> <head> <meta charset="utf-8"> - <meta http-equiv="X-UA-Compatible" content="IE=edge"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width,initial-scale=1.0"> - <link rel="icon" href="<%= BASE_URL %>favicon.ico"> + <link rel="icon" href="<%= BASE_URL %>pigrich.png"> <title>Sparesti</title> </head> <body> diff --git a/public/pigrich.png b/public/pigrich.png new file mode 100644 index 0000000000000000000000000000000000000000..d32f57f7a2c717a8e14643ff6e98414d85e072a0 Binary files /dev/null and b/public/pigrich.png differ diff --git a/s b/s new file mode 100644 index 0000000000000000000000000000000000000000..7c4e06869117eb19ad33b67300c869e70c47c77d --- /dev/null +++ b/s @@ -0,0 +1,32 @@ +[33m37fd109[m[33m ([m[1;36mHEAD -> [m[1;32mdevelopment[m[33m, [m[1;31morigin/development[m[33m, [m[1;32mCreating-verificationPage[m[33m)[m HEAD@{0}: checkout: moving from Creating-verificationPage to development +[33m37fd109[m[33m ([m[1;36mHEAD -> [m[1;32mdevelopment[m[33m, [m[1;31morigin/development[m[33m, [m[1;32mCreating-verificationPage[m[33m)[m HEAD@{1}: checkout: moving from development to Creating-verificationPage +[33m37fd109[m[33m ([m[1;36mHEAD -> [m[1;32mdevelopment[m[33m, [m[1;31morigin/development[m[33m, [m[1;32mCreating-verificationPage[m[33m)[m HEAD@{2}: checkout: moving from Contact to development +[33m6eef22b[m HEAD@{3}: commit (merge): merge: resolved merge conflict +[33mb4a4dc5[m HEAD@{4}: commit: feat: added contact view and did necessary changes regarding contact. +[33m6f0c426[m HEAD@{5}: checkout: moving from development to Contact +[33m6f0c426[m HEAD@{6}: merge origin/development: Fast-forward +[33m415aaf4[m HEAD@{7}: checkout: moving from settingss to development +[33me23fa84[m HEAD@{8}: checkout: moving from settings to settingss +[33me23fa84[m HEAD@{9}: checkout: moving from development to settings +[33m415aaf4[m HEAD@{10}: checkout: moving from settings to development +[33me23fa84[m HEAD@{11}: merge origin/settings: Fast-forward +[33mf24b5b9[m HEAD@{12}: checkout: moving from development to settings +[33m415aaf4[m HEAD@{13}: merge origin/development: Fast-forward +[33m8e0913d[m HEAD@{14}: checkout: moving from challenge to development +[33m123b273[m HEAD@{15}: checkout: moving from settings to challenge +[33mf24b5b9[m HEAD@{16}: commit: feat: added some settings in settings +[33m2a00215[m HEAD@{17}: commit: fix: fixed bleeding issue +[33m123b273[m HEAD@{18}: checkout: moving from challenge to settings +[33m123b273[m HEAD@{19}: commit: feat: created challengeComponent shell +[33mfa07c29[m HEAD@{20}: checkout: moving from development to challenge +[33mfa07c29[m HEAD@{21}: merge origin/development: Fast-forward +[33m36436cf[m HEAD@{22}: merge origin/development: Fast-forward +[33m394c69c[m HEAD@{23}: checkout: moving from news to development +[33m78df7c2[m HEAD@{24}: commit: feat: created newspage, without connection to backend +[33mcf404a9[m HEAD@{25}: checkout: moving from development to news +[33mcf404a9[m HEAD@{26}: merge origin/development: Fast-forward +[33mae9cbc9[m HEAD@{27}: checkout: moving from LogIn to development +[33mdc1c7ce[m HEAD@{28}: commit: feat: added login and register shell +[33mc8d21dc[m HEAD@{29}: checkout: moving from development to LogIn +[33mc8d21dc[m HEAD@{30}: checkout: moving from main to development +[33mfb4d17b[m[33m ([m[1;32mmain[m[33m)[m HEAD@{31}: clone: from gitlab.stud.idi.ntnu.no:idatt2106_2024_04/frontend.git diff --git a/src/App.vue b/src/App.vue index e64c3d6274ccf8f3ceaa6b4d75f37887bcf5cd1e..9894ade5a3c04edf3abc3924f113f26c2b1b5b55 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,77 +1,116 @@ <script setup> -import { computed } from 'vue'; +import {computed, onMounted, onUnmounted, ref} from 'vue'; import { useRoute } from 'vue-router'; import NavigationBar from "@/components/NavigationBar.vue"; -import ProfileView from '@/views/ProfileView.vue'; -import InfoBar from "@/components/InfoBar.vue"; +import InfoBar from "@/components/infobar/InfoBar.vue"; +import TokenChecker from './components/TokenChecker.vue'; +import StatsPreview from "@/components/infobar/StatsPreview.vue"; +import CookieService from "@/services/internal/CookieService"; +import CookieBanner from "@/components/CookieBanner.vue"; +import BadgePopup from './components/BadgePopup.vue'; const route = useRoute(); -const isProfileRoute = computed(() => route.path.includes('/profile')); -</script> +const showSidebar = computed(() => route.meta.showSidebar || false); +const hideStats = computed(() => route.meta.hideStats || false); +const showBanner = ref(false); + +const checkBanner = () => { + if (sessionStorage.getItem('showBanner') === 'true') { + showBanner.value = true; + setTimeout(() => { + sessionStorage.setItem('showBanner', 'false'); + showBanner.value = false; + }, 5000); + } +}; + +let intervalId; + +onMounted(() => { + document.documentElement.classList.add(CookieService.getCookieWithConsent('fontSize')); + document.documentElement.classList.add(CookieService.getCookieWithConsent('borderRadius')); + document.documentElement.classList.add(CookieService.getCookieWithConsent('fontFamily')); + intervalId = setInterval(checkBanner, 600); +}) + +onUnmounted(() => { + clearInterval(intervalId); +}); +</script> <template> - <div class="grid-container" :class="{ 'profile-active': isProfileRoute }"> - <NavigationBar /> - <RouterView v-if="!isProfileRoute" /> - <InfoBar v-if="!isProfileRoute"/> - <ProfileView v-else /> + <NavigationBar class="navigation-bar" /> + <div class="grid-container-app" :class="showSidebar ? 'with-sidebar' : 'without-sidebar'"> + <stats-preview v-if="!hideStats" class="stats-preview"> + </stats-preview> + <RouterView class="router-view" /> + <InfoBar class="info-bar" v-if="showSidebar" /> + <TokenChecker /> + <CookieBanner /> + <BadgePopup v-if="showBanner"/> </div> </template> <style> @import '@/assets/root.css'; +/* used in vue template */ +.without-sidebar { + grid-template-columns: 100% !important; +} * { margin: 0; padding: 0; font-size: var(--font-size-general); - font-family: var(--font-family-general); + font-family: var(--font-family-general), serif; + border-radius: var(--border-radius-general); + box-sizing: border-box; } -.grid-container { +.grid-container-app { display: grid; - grid-template-columns: 25% 50% 25%; - height: 100vh; - width: 100vw; overflow: hidden; + width: 100vw; + height: 100vh; background-color: var(--background-general); - transition: all 0.5s ease; font-size: var(--font-size-general); + border-radius: var(--border-radius-general); + grid-template-columns: 75% 25%; + grid-template-areas: + "router-view stats-preview" + "router-view info-bar"; + box-sizing: border-box; } -.profile-active { - grid-template-columns: 25% 75%; -} - -.navigationBar, .routerView, .infoBar { - height: 100%; - overflow: hidden; -} - - -.NavigationBar { - grid-area: navigationBar; -} - -.RouterView { - grid-area: routerView; - padding-top: 50px; -} - -.InfoBar { - grid-area: infoBar; +.router-view { + padding: 0 2.5% 0 2.5%; + display: flex; + text-align: center; + flex-direction: column; + height: 100vh; overflow-y: auto; scrollbar-width: none; - -ms-overflow-style: none; - padding-top: 50px; + grid-area: router-view; } -.InfoBar::-webkit-scrollbar { - display: none; +.stats-preview { + grid-area: stats-preview; } -.ProfileView { - grid-area: profileView; +.info-bar { + grid-area: info-bar; } +@media screen and (max-width: 800px) { + .grid-container-app { + grid-template-columns: 100%; + grid-template-areas: + "stats-preview" + "router-view" + } + + .info-bar { + display: none; + } +} </style> diff --git a/src/assets/css/dropBox.css b/src/assets/css/dropBox.css deleted file mode 100644 index d62c3511df4759a8af2b95eb237bcda0418fb913..0000000000000000000000000000000000000000 --- a/src/assets/css/dropBox.css +++ /dev/null @@ -1,21 +0,0 @@ -.dropdown { - width: 200px; - padding: 8px 16px; - border: 2px solid var(--dark-color); - border-radius: 5px; - background-color: #f5f5f5; - color: var(--black-text); - font-size: var(--font-size-general); - cursor: pointer; - outline: none; - transition: all 0.3s ease; -} - -.dropdown:hover { - background-color: #e2e2e2; -} - -.dropdown:focus { - border-color: var(--dark-color); - box-shadow: 0 0 8px var(--dark-color); -} \ No newline at end of file diff --git a/src/assets/img/accounts.png b/src/assets/img/accounts.png new file mode 100644 index 0000000000000000000000000000000000000000..f774cdc556d677b6adc3ccb4cb5630bfeef569b0 Binary files /dev/null and b/src/assets/img/accounts.png differ diff --git a/src/assets/img/back.png b/src/assets/img/back.png new file mode 100644 index 0000000000000000000000000000000000000000..ae7c6aed59c7edaff7d489d879bead91ba425dad Binary files /dev/null and b/src/assets/img/back.png differ diff --git a/src/assets/img/backArrow.png b/src/assets/img/backArrow.png new file mode 100644 index 0000000000000000000000000000000000000000..d602f1076ca3db03fea4168525f31d0fc2011254 Binary files /dev/null and b/src/assets/img/backArrow.png differ diff --git a/src/assets/img/banner.png b/src/assets/img/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..d72f1cd2fc3365d01a5a1aabd8f4ee8ceb886aac Binary files /dev/null and b/src/assets/img/banner.png differ diff --git a/src/assets/img/boom.gif b/src/assets/img/boom.gif new file mode 100644 index 0000000000000000000000000000000000000000..6e0e3ed1ef02d0f5bc3389efa9cef729cfb656eb Binary files /dev/null and b/src/assets/img/boom.gif differ diff --git a/src/assets/img/botIcon.png b/src/assets/img/botIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..e9d8c2bf782942f9eddfe5ed4867a2dacdaf24e9 Binary files /dev/null and b/src/assets/img/botIcon.png differ diff --git a/src/assets/img/budget.png b/src/assets/img/budget.png new file mode 100644 index 0000000000000000000000000000000000000000..ec81cf04cddb2c3386b8327aeca54a6cd5a6c3cb Binary files /dev/null and b/src/assets/img/budget.png differ diff --git a/src/assets/img/challengeIcon.png b/src/assets/img/challengeIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..4b1ce326d54f2d5302570d04f2df3d2044e979e9 Binary files /dev/null and b/src/assets/img/challengeIcon.png differ diff --git a/src/assets/img/circular_x.png b/src/assets/img/circular_x.png new file mode 100644 index 0000000000000000000000000000000000000000..5fddcc461510903012131b8a8ae913970aa7105f Binary files /dev/null and b/src/assets/img/circular_x.png differ diff --git a/src/assets/img/contact.png b/src/assets/img/contact.png new file mode 100644 index 0000000000000000000000000000000000000000..9d998a322187d4b7130300cb319fbac8cebba831 Binary files /dev/null and b/src/assets/img/contact.png differ diff --git a/src/assets/img/join.png b/src/assets/img/join.png new file mode 100644 index 0000000000000000000000000000000000000000..a55e73e83cb25d0f592331b1aac3d29964d5b5e7 Binary files /dev/null and b/src/assets/img/join.png differ diff --git a/src/assets/img/logo_banner.png b/src/assets/img/logo_banner.png new file mode 100644 index 0000000000000000000000000000000000000000..519561fe6b1c34aef77362f620ae544286500a77 Binary files /dev/null and b/src/assets/img/logo_banner.png differ diff --git a/src/assets/img/mail.png b/src/assets/img/mail.png new file mode 100644 index 0000000000000000000000000000000000000000..cc58ca35553781284cac74454fd307dcae936b74 Binary files /dev/null and b/src/assets/img/mail.png differ diff --git a/src/assets/img/menu.png b/src/assets/img/menu.png new file mode 100644 index 0000000000000000000000000000000000000000..495e9d236088010a594f11d294b956196d4dfb4b Binary files /dev/null and b/src/assets/img/menu.png differ diff --git a/src/assets/img/money.png b/src/assets/img/money.png new file mode 100644 index 0000000000000000000000000000000000000000..1851ab64dca7dc84eace1c5d955c61f93083f988 Binary files /dev/null and b/src/assets/img/money.png differ diff --git a/src/assets/img/newBadge.png b/src/assets/img/newBadge.png new file mode 100644 index 0000000000000000000000000000000000000000..7a1074c565d4dee0025f96253b885770f681a8c3 Binary files /dev/null and b/src/assets/img/newBadge.png differ diff --git a/src/assets/img/news.png b/src/assets/img/news.png new file mode 100644 index 0000000000000000000000000000000000000000..577fcd258a5a29934cd9926fc36e1755fca4b80b Binary files /dev/null and b/src/assets/img/news.png differ diff --git a/src/assets/img/pigDance.gif b/src/assets/img/pigDance.gif new file mode 100644 index 0000000000000000000000000000000000000000..8c7e547f664029493435a22b3e0c28a3ddd5df0a Binary files /dev/null and b/src/assets/img/pigDance.gif differ diff --git a/src/assets/img/pigrich.png b/src/assets/img/pigrich.png new file mode 100644 index 0000000000000000000000000000000000000000..741e81e9e1a202b329190eb1f5c6fe47e5d63150 Binary files /dev/null and b/src/assets/img/pigrich.png differ diff --git a/src/assets/img/pluss_icon.png b/src/assets/img/pluss_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..db8ffbb2fb003b6928eac0a3b75648ea1c0b6752 Binary files /dev/null and b/src/assets/img/pluss_icon.png differ diff --git a/src/assets/img/printer.png b/src/assets/img/printer.png new file mode 100644 index 0000000000000000000000000000000000000000..3b6e1ba247bcdc5b869a30e8fa112a938f09da6b Binary files /dev/null and b/src/assets/img/printer.png differ diff --git a/src/assets/img/profile.png b/src/assets/img/profile.png new file mode 100644 index 0000000000000000000000000000000000000000..ac044e856c2fd5dd4837ab42c378f5368f8e4cfb Binary files /dev/null and b/src/assets/img/profile.png differ diff --git a/src/assets/img/settings.png b/src/assets/img/settings.png new file mode 100644 index 0000000000000000000000000000000000000000..3a36b3a1ca22b0731f9d9d97303642e4a7e616cd Binary files /dev/null and b/src/assets/img/settings.png differ diff --git a/src/assets/img/snus.png b/src/assets/img/snus.png new file mode 100644 index 0000000000000000000000000000000000000000..38e217d5d088b397d210c0ca29647fd2ab726084 Binary files /dev/null and b/src/assets/img/snus.png differ diff --git a/src/assets/img/logo.png b/src/assets/img/temp.png similarity index 100% rename from src/assets/img/logo.png rename to src/assets/img/temp.png diff --git a/src/assets/img/transaction.png b/src/assets/img/transaction.png new file mode 100644 index 0000000000000000000000000000000000000000..0496c003973bab2627ce1b4800dbebd076ff42c7 Binary files /dev/null and b/src/assets/img/transaction.png differ diff --git a/src/assets/img/user.png b/src/assets/img/user.png new file mode 100644 index 0000000000000000000000000000000000000000..64cf89621fa735b02a23dfa143b7bc4bd199b1f7 Binary files /dev/null and b/src/assets/img/user.png differ diff --git a/src/assets/img/userDetails/couplewithkids.webp b/src/assets/img/userDetails/couplewithkids.webp new file mode 100644 index 0000000000000000000000000000000000000000..7c82e4f284384257eb9452134f9b7b09db4d961b Binary files /dev/null and b/src/assets/img/userDetails/couplewithkids.webp differ diff --git a/src/assets/img/userDetails/couplewithoutkids.webp b/src/assets/img/userDetails/couplewithoutkids.webp new file mode 100644 index 0000000000000000000000000000000000000000..8f510375b6d89075bee416a5ab5945f1b421ec7e Binary files /dev/null and b/src/assets/img/userDetails/couplewithoutkids.webp differ diff --git a/src/assets/img/userDetails/livingalone.webp b/src/assets/img/userDetails/livingalone.webp new file mode 100644 index 0000000000000000000000000000000000000000..db194b3a88f50a37d17f63ed75c3899f319f1429 Binary files /dev/null and b/src/assets/img/userDetails/livingalone.webp differ diff --git a/src/assets/img/userDetails/other.webp b/src/assets/img/userDetails/other.webp new file mode 100644 index 0000000000000000000000000000000000000000..242da92162019387307d8479099512ad349fdbae Binary files /dev/null and b/src/assets/img/userDetails/other.webp differ diff --git a/src/assets/img/usersIcon.png b/src/assets/img/usersIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..1103c95b6b440b19072e1d69daabfd56378e1837 Binary files /dev/null and b/src/assets/img/usersIcon.png differ diff --git a/src/assets/root.css b/src/assets/root.css index 9104bded972626fcae63074a4b7fcaa3cc1af41a..293569a49dd2f455a969f069297426fdcf8a42e3 100644 --- a/src/assets/root.css +++ b/src/assets/root.css @@ -1,32 +1,55 @@ :root { --dark-color: #304C6C; --middle-color: #336699; - --light-color: #99ccff; + --light-color: #80b3ff; --white-text: #ffffff; + --white-changing-text: #ffffff; --black-text: #000000; + --black-changing-text: #000000; --grey-text: grey; - --error-text: darkred; + --error-text: #7c0000FF; + --green-text: #026502; - /*Fjerne disse?*/ --accent-color: #f5f5f5; - --accent-color-dark: #cccaca; + --accent-color-dark: #e2e2e2; - /*Fjern disse?*/ --black-general: #000000; - --grey-general: grey; + --grey-general: #575757; --white-general: #ffffff; --background-general: #ffffff; --font-size-general: 1rem; --font-family-general: "Arial Rounded MT", sans-serif; + + --border-radius-general: 10px; } +/*HEADERS-------------------------------------------------------------------------------------------------------*/ h1, h2, h3, h4, h5{ margin: 0; padding: 0; } +h1 { + font-size: calc(2.5 * var(--font-size-general)); +} + +h2 { + font-size: calc(1.8 * var(--font-size-general)); +} + +h3 { + font-size: calc(1.6 * var(--font-size-general)); +} + +h4 { + font-size: calc(1.4 * var(--font-size-general)); +} + +h5 { + font-size: calc(1.2 * var(--font-size-general)); +} /*DARK OG LIGHT MODE------------------------------------------------------------------------------------------------*/ .white-theme { --background-general: #ffffff; @@ -34,11 +57,7 @@ h1, h2, h3, h4, h5{ .dark-theme { --background-general: #2b2929; } - /*FONTSTØRRELSTE-----------------------------------------------------------------------------------------------------*/ -.font-size-extra-small { - --font-size-general: 0.75rem; -} .font-size-small { --font-size-general: 0.875rem; } @@ -46,10 +65,28 @@ h1, h2, h3, h4, h5{ --font-size-general: 1rem; } .font-size-large { - --font-size-general: 1.25rem; + --font-size-general: 1.15rem; } -.font-size-extra-large { - --font-size-general: 1.5rem; + +/*TITLER------------------------------------------------------------------------------------------------------------*/ +.font-size-small h1, .font-size-medium h1, .font-size-large h1 { + font-size: calc(2.5 * var(--font-size-general)); +} + +.font-size-small h2, .font-size-medium h2, .font-size-large h2 { + font-size: calc(1.8 * var(--font-size-general)); +} + +.font-size-small h3, .font-size-medium h3, .font-size-large h3 { + font-size: calc(1.6 * var(--font-size-general)); +} + +.font-size-small h4, .font-size-medium h4, .font-size-large h4 { + font-size: calc(1.4 * var(--font-size-general)); +} + +.font-size-small h5, .font-size-medium h5, .font-size-large h5 { + font-size: calc(1.2 * var(--font-size-general)); } /*FONTTYPE----------------------------------------------------------------------------------------------------------*/ @@ -72,23 +109,23 @@ h1, h2, h3, h4, h5{ --font-family-general: "Papyrus,fantasy"; } - - +/*BORDER RADIUS-------------------------------------------------------------------------------------------------*/ .border-none { - border-radius: 0; + --border-radius-general: 0; } .border-small { - border-radius: 5px; + --border-radius-general: 5px; } .border-medium { - border-radius: 10px; + --border-radius-general: 10px; } .border-large { - border-radius: 20px; + --border-radius-general: 20px; } +/*OTHER--------------------------------------------------------------------------------------------------------*/ * { font-family: "Arial Rounded MT", fantasy; @@ -99,3 +136,53 @@ body { margin: 0; padding: 0; } +/*BUTTON----------------------------------------------------------------------------------------------------------*/ +button { + background-color: var(--middle-color); + color: var(--white-text); + cursor: pointer; + width: 40%; + height: auto; + min-height: 40px; + border-radius: var(--border-radius-general); + transition: transform 0.3s ease, background-color 0.3s ease; + box-shadow: 2px 2px 4px 0 rgba(0, 0, 0, 0.25); + outline: none; + border-style: none; + text-align: center; + padding: 10px; +} + +button:hover, button:focus { + background-color: var(--dark-color); + transform: translateY(-2px); +} + +button:disabled { + background-color: var(--light-color); + transform: translateY(0); + cursor: not-allowed; + color: var(--black-text); + opacity: 0.6; +} + +.dropdown { + width: 200px; + padding: 8px 16px; + border: none; + border-radius: var(--border-radius-general); + background-color: var(--accent-color); + color: var(--black-text); + font-size: var(--font-size-general); + cursor: pointer; + outline: none; + transition: all 0.3s ease; +} + +.dropdown:hover { + background-color: var(--accent-color-dark); +} + +.dropdown:focus { + box-shadow: 0 0 8px var(--dark-color); +} diff --git a/src/components/BadgePopup.vue b/src/components/BadgePopup.vue new file mode 100644 index 0000000000000000000000000000000000000000..a714f58e56fcf00d622b311239294cabce5001a5 --- /dev/null +++ b/src/components/BadgePopup.vue @@ -0,0 +1,53 @@ +<template> + <div v-if="showBadge" class="badge-container" :style="{ top: positionTop + 'px' }"> + <div class="badge">Ny badge oppnÃ¥dd</div> + </div> + </template> + + <script setup> + import { ref, onMounted } from 'vue'; + + const showBadge = ref(false); + const positionTop = ref(-40); + + onMounted(() => { + showBadge.value = true; + setTimeout(() => { + positionTop.value = 0; + setTimeout(() => { + positionTop.value = -40; + }, 4000); + }, 100); + }); + </script> + + <style scoped> + .badge-container { + position: fixed; + top: 0; + left: 0; + right: 0; + display: flex; + justify-content: center; + z-index: 9999; + } + + .badge { + background-color: #4CAF50; + color: white; + padding: 10px 20px; + border-radius: var(--border-radius-general); + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); + animation: slideDown 0.5s ease; + } + + @keyframes slideDown { + 0% { + transform: translateY(-40px); + } + 100% { + transform: translateY(0); + } + } + </style> + \ No newline at end of file diff --git a/src/components/CookieBanner.vue b/src/components/CookieBanner.vue new file mode 100644 index 0000000000000000000000000000000000000000..4c1bdbe45fbb8eca9f1addb1f582d075c5de2ed1 --- /dev/null +++ b/src/components/CookieBanner.vue @@ -0,0 +1,74 @@ +<script setup> +import { ref, onMounted } from 'vue'; +import CookieService from "@/services/internal/CookieService"; + +const showBanner = ref(true); + +onMounted(() => { + console.log(CookieService.listCookies()); + if (CookieService.getCookie('cookiesAccepted')) { + showBanner.value = false; + } +}); + +function acceptCookies() { + showBanner.value = false; + return CookieService.setCookie('cookiesAccepted', 'true', 7 ); +} + +function declineCookies() { + showBanner.value = false; + return CookieService.setCookie('cookiesAccepted', 'false', 7 ); +} +</script> + +<template> + <div v-if="showBanner" class="cookie-banner"> + <div class="cookie-content"> + <p>Dette nettstedet bruker informasjonskapsler (cookies) for Ã¥ forbedre din brukeropplevelse. Ved Ã¥ fortsette Ã¥ bruke nettstedet, godtar du vÃ¥r bruk av cookies.</p> + <div class="cookie-buttons-container"> + <button tabindex="0" @keydown="declineCookies" role="button" id="deny" @click="declineCookies">AvslÃ¥</button> + <button tabindex="0" @keydown="acceptCookies" role="button" id="accept" @click="acceptCookies">Godta</button> + </div> + </div> + </div> +</template> + +<style scoped> +p { + text-align: center; +} +.cookie-banner { + position: fixed; + bottom: 0; + left: 0; + width: 100%; + background-color: var(--black-general); + opacity: 80%; + color: white; + padding: 10px; + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.cookie-content p { + margin: 0 20px 0 0; +} + +.cookie-buttons-container { + margin-top: 20px; + display: flex; + justify-content: space-evenly; +} + +#accept { + background-color: #016701; +} + +#deny { + background-color: #770101; +} + +</style> diff --git a/src/components/InfoBar.vue b/src/components/InfoBar.vue deleted file mode 100644 index 290e7c791f033b5b6b1143ebbe20bc1a6324d301..0000000000000000000000000000000000000000 --- a/src/components/InfoBar.vue +++ /dev/null @@ -1,139 +0,0 @@ -<script setup> -import { ref } from 'vue'; -import StockService from '@/services/external/StockService'; - -const stockInfo = ref(null); -const searchQuery = ref(''); - -async function fetchStockInfo() { - if (!searchQuery.value.trim()) { - alert("Please enter a valid stock ticker."); - return; - } - try { - const result = await StockService.fetchStockInfo(searchQuery.value); - stockInfo.value = result; - } catch (error) { - console.error('Failed to fetch stock info:', error); - stockInfo.value = null; - } -} -</script> - - - - - -<template> - <div class="container"> - <div class="stats"> - <img src="@/assets/img/goalIcon.png" alt="Home" /> - <h2>2</h2> - <img src="@/assets/img/badgeIcon.png" alt="Home" /> - <h2>2</h2> - <img src="@/assets/img/fireIcon.png" alt="Home" /> - <h2>2</h2> - </div> - <div class="box"> - <h3>Total Saved: {{ totalSaved }}kr</h3> - </div> - <div class="box"> - <input type="text" v-model="searchQuery" placeholder="Enter stock ticker..."> - <button @click="fetchStockInfo()">Search</button> - <div v-if="stockInfo"> - <h3>{{ stockInfo.name }} ({{ stockInfo.ticker }}): ${{ stockInfo.price }}</h3> - <p :class="{ negative: stockInfo.change < 0, positive: stockInfo.change >= 0 }"> - Change: ${{ stockInfo.change }} ({{ stockInfo.changePercent }}%) - </p> -</div> - - </div> - <div class="box"> - <h2>Ukens rabatter:</h2> - <h3>10% pÃ¥ Bilia</h3> - <h3>10% pÃ¥ Zara</h3> - <h3>25% pÃ¥ Power</h3> - </div> - <div class="box">Box 4</div> - <div class="box">Box 5</div> - </div> -</template> - - - -<style scoped> - -.container { - display: flex; - flex-direction: column; - width: 100%; - max-height: 100vh; - scrollbar-width: none; - -ms-overflow-style: none; - overflow-y: auto; -} - -.stats { - display: flex; - flex-direction: row; - gap: 25px; -} - -.stats img { - margin-top: 25px; - width: 25px; - height: 25px; -} - -.box { - background-color: #304C6C; - width: 100%; - height: 200px; - padding: 20px; - border-radius: 10px; - margin-bottom: 10px; - color: white; - box-sizing: border-box; -} - -.box h3{ - font-size: 30px; - flex-direction: column; -} - -.positive { - color: green; -} - -.negative { - color: red; -} - - -.box h3 { - font-size: 20px; - color: white; -} - -.box p { - font-size: 20px; -} - -.box:last-child { - margin-bottom: 0; -} - -.box input[type="text"], .box button { - padding: 10px; - margin-top: 10px; -} - -.box button { - cursor: pointer; - background-color: #406882; - color: white; - border: none; - border-radius: 5px; -} - -</style> \ No newline at end of file diff --git a/src/components/NavigationBar.vue b/src/components/NavigationBar.vue index f1098fd7cf128a936363e451dcbb22cff84cf700..0e349e87a7359bec4ec5dd425bcda5a0f8bd1998 100644 --- a/src/components/NavigationBar.vue +++ b/src/components/NavigationBar.vue @@ -1,144 +1,261 @@ <script setup> -import { ref } from 'vue'; +import {computed, ref} from 'vue'; +import {useRouter} from 'vue-router'; +import {useStore} from 'vuex'; const menuOpen = ref(false); +const router = useRouter(); +const store = useStore(); +/** + * Toggles the menu + */ const toggleMenu = () => { menuOpen.value = !menuOpen.value; }; -</script> +/** + * Logs out the user + * @returns {Promise<void>} - A promise of void type + */ +const logout = async () => { + toggleMenu(); + await store.dispatch('logout'); + await router.push('/login'); +}; + +/** + * Logs in the user + */ +const login = () => { + toggleMenu(); + router.push('/login'); +}; + +const menuItems = [ + { + route: '/transactions', + imgSrc: require('@/assets/img/transaction.png'), + title: 'Transaksjoner', + description: 'Oversikt over transaksjoner' + }, + { + route: '/budget', + imgSrc: require('@/assets/img/budget.png'), + title: 'Budsjett', + description: 'FÃ¥ personlige budsjett!' + }, + { + route: '/goals', + imgSrc: require('@/assets/img/goalIcon.png'), + title: 'SparemÃ¥l', + description: 'FÃ¥ sparemÃ¥l eller utfordringer' + }, + { + route: '/profile', + imgSrc: require('@/assets/img/profile.png'), + title: 'Profil', + description: 'Endre eller se profilinfo' + }, + {route: '/news', imgSrc: require('@/assets/img/news.png'), title: 'Nyheter', subtitle: 'Dagens nyheter fra dn.no'}, + { + route: '/contact', + imgSrc: require('@/assets/img/contact.png'), + title: 'Kontakt oss!', + description: 'Send feedback eller spørsmÃ¥l' + }, +]; + +const isAuthenticated = computed(() => store.state.user.firstName); + + +</script> <template> - <nav class="nav-container"> - <div class="menu-icon" @click="toggleMenu"> - <img src="@/assets/img/burgermenu.png" alt="Menu"> + <nav class="nav-container" data-cy="navigation-component"> + <div class="menu-icon" @click="toggleMenu" @keydown.enter="toggleMenu" tabindex="0" role="button" data-cy="navigation-button"> + <img v-if="!menuOpen" src="@/assets/img/menu.png" alt="Menu"> + <img v-else src="@/assets/img/circular_x.png" alt="Close menu" class="close-image"/> </div> - <div :class="{ 'menu-content': true, active: menuOpen }"> - <div class="close-icon" @click="toggleMenu"> - ✖ - </div> - <router-link to="/" class="menu-item"> - <img src="@/assets/img/logo.png" alt="Home" /> - </router-link> - <span class="menu-item text">Sparesti</span> - <router-link to="/transactions" class="menu-item"> - <h2>Transaksjoner</h2> - </router-link> - <router-link to="/budget" class="menu-item"> - <h2>Budsjett</h2> + <div v-if="menuOpen" class="menu-content" data-cy="navigation-menu"> + <router-link to="/" class="menu-item" @click="toggleMenu"> + <div class="home-icon"> + <img src="@/assets/img/pigrich.png" alt="Home"/> + </div> </router-link> - <router-link to="/goals" class="menu-item"> - <h2>SparemÃ¥l</h2> - </router-link> - <router-link to="/profile" class="menu-item"> - <h2>Profil</h2> - </router-link> - <router-link to="/news" class="menu-item"> - <h2>Nyheter</h2> - </router-link> - <button class="menu-item" id="rounded-button">Log out</button> + <div class="route-container"> + <router-link v-for="item in menuItems" + :key="item.route" + :to="item.route" + class="menu-item" + @click="toggleMenu" + > + <img + :src="item.imgSrc" + :alt="item.title" + /> + <div class="router-item-text" + role="button" + tabindex="0" + > + <h2>{{ item.title }}</h2> + <h3>{{ item.description }}</h3> + </div> + </router-link> + </div> + <button @click="logout" v-if="isAuthenticated">Logg ut</button> + <button @click="login" v-else>Logg inn</button> </div> </nav> </template> <style scoped> + +@media (max-width: 900px) { + .menu-content { + width: 100% !important; + } +} + +/*the linter is not able to find the usage of this class, but it is used for the currently active route*/ +.router-link-active { + h2 { + text-decoration: underline; + } +} + +.close-image { + filter: invert(1); +} + .nav-container { - position: relative; - width: 100%; - height: 100%; + position: fixed; + top: 10px; + left: 10px; + display: flex; + flex-direction: column; + z-index: 60; } .menu-icon { - position: absolute; - top: 0; - left: 0; - z-index: 20; + z-index: 35; + + :hover { + transform: scale(1.1); + } + + width: 50px; } .menu-icon img { + width: inherit; cursor: pointer; - width: 30px; + transition: transform 0.3s ease-in-out; } .menu-item img { cursor: pointer; - width: 45px; + width: 35px; + filter: drop-shadow(0px 0px 10px rgba(0, 0, 0, 0.5)); } - .menu-content { position: fixed; top: 0; left: 0; - width: 25%; height: 100vh; - background-color: #304C6C; + background: linear-gradient(var(--middle-color), var(--dark-color)); display: flex; flex-direction: column; - align-items: flex-start; - padding-top: 60px; + align-items: center; transition: transform 0.3s ease-in-out; - transform: translateX(-100%); z-index: 30; + box-shadow: 8px 0 15px -3px rgba(0, 0, 0, 0.5); } -.menu-content.active { - transform: translateX(0); +.route-container { + display: flex; + flex-direction: column; + gap: 10px; + text-decoration: none; + padding-left: 30px; + padding-right: 30px; + margin-bottom: 10px; } -.close-icon { - position: absolute; - top: 10px; - right: 10px; - cursor: pointer; - font-size: 24px; +a { + text-decoration: none; + color: white; } .menu-item { display: flex; align-items: center; - justify-content: center; + justify-content: start; width: 100%; margin: 5px 0; + } -.router-link-active { - text-decoration: underline; +.menu-item:hover, +.menu-item:focus { + .router-item-text h2 { + text-decoration: underline; + } + + img { + scale: 1.1; + filter: drop-shadow(0px 0px 10px grey); + } } -#rounded-button { - background-color: #0056b3; - color: white; - border: none; - width: 60%; - padding: 10px 20px; - font-size: 16px; - border-radius: 20px; - box-shadow: 0 4px #003875; - cursor: pointer; - transition: all 0.3s ease; - margin: 40px auto; +.home-icon { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: auto; + margin-bottom: 20px; + + img { + height: 160px; + width: auto; + } + padding-top: 40px; } -#rounded-button:hover { - background-color: #023366; - box-shadow: 0 6px #003875; +button { + color: black; + background-color: var(--light-color); } -#rounded-button:active { - box-shadow: 0 2px #003875; - transform: translateY(2px); +button:hover { + color: black; + background-color: var(--light-color); + border: 2px solid white; } -.menu-content h2, p { - font-size: 19px; - color: #ffffff; +.router-item-text { + display: flex; + flex-direction: column; + justify-content: center; + align-items: start; + padding-left: 10px; +} + +.menu-content h2, .menu-content h3 { + color: #ffffff; + text-decoration: none; } .menu-content h2 { - text-decoration: underline; + font-size: 19px; + color: white; } +.menu-content h3 { + font-size: 11px; + color: var(--accent-color-dark); +} </style> diff --git a/src/components/TokenChecker.vue b/src/components/TokenChecker.vue index 23390cfa6d104ea05b0187b04d5037f620304bd2..184df3e00bea87d0ac632cf13b5ce46c15457bac 100644 --- a/src/components/TokenChecker.vue +++ b/src/components/TokenChecker.vue @@ -1,96 +1,94 @@ -<template> - <div v-if="tokenExpiresSoon" class="modal-overlay"> - <div class="modal-content"> - <p>Token expires in 1 minute. Please save your work.</p> - <button @click="closeModal">Close</button> - </div> - </div> - </template> - - <script setup> - import { ref, onMounted, onUnmounted } from 'vue'; - import { jwtDecode } from 'jwt-decode'; - import { useRouter } from 'vue-router'; - - const tokenExpiresSoon = ref(false); - const router = useRouter(); - - const checkTokenExpiration = () => { - const authToken = sessionStorage.getItem('authToken'); - if (authToken) { - try { - const decoded = jwtDecode(authToken); - const currentTime = Date.now() / 1000; - const timeRemaining = decoded.exp - currentTime; - console.log(timeRemaining); - if (timeRemaining < 0) { - console.log('Token expired. Logging out.'); - sessionStorage.removeItem('authToken'); - router.push({ name: 'login' }); - } else if (timeRemaining < 60) { - tokenExpiresSoon.value = true; - } else { - tokenExpiresSoon.value = false; - } - } catch (error) { - console.error('Error decoding token:', error); - sessionStorage.removeItem('authToken'); - router.push({ name: 'login' }); +<script setup> +import {ref, onMounted, onUnmounted} from 'vue'; +import {jwtDecode} from 'jwt-decode'; +import {useRouter} from 'vue-router'; +import {useStore} from 'vuex'; +import UserService from '@/services/internal/UserService'; + +const tokenExpiresSoon = ref(false); +const router = useRouter(); +const store = useStore(); + +/** + * Logs out the user + * @returns {Promise<void>} - A promise of void type + */ +const logout = async () => { + await store.dispatch('logout'); + await router.push('/login'); +}; + +const checkTokenExpiration = () => { + const authToken = sessionStorage.getItem('authToken'); + if (authToken) { + try { + const decoded = jwtDecode(authToken); + const currentTime = Date.now() / 1000; + const timeRemaining = decoded.exp - currentTime; + if (timeRemaining < 60) { + UserService.setAxiosAuthHeader(null); + logout(); + } else if (timeRemaining < 120) { + tokenExpiresSoon.value = true; + } else { + tokenExpiresSoon.value = false; } + } catch (error) { + UserService.setAxiosAuthHeader(null); + logout(); } - }; - - const closeModal = () => { - tokenExpiresSoon.value = false; - }; - - let intervalId; - - onMounted(() => { - checkTokenExpiration(); - intervalId = setInterval(checkTokenExpiration, 60000); - }); - - onUnmounted(() => { - clearInterval(intervalId); - }); - </script> - - <style scoped> - .modal-overlay { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: rgba(0, 0, 0, 0.5); - display: flex; - justify-content: center; - align-items: center; - z-index: 1000; - } - - .modal-content { - background-color: #fff; - padding: 20px; - border-radius: 5px; - box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); - text-align: center; - } - - button { - margin-top: 20px; - padding: 10px 20px; - font-size: 16px; - color: #fff; - background-color: #007bff; - border: none; - border-radius: 5px; - cursor: pointer; } - - button:hover { - background-color: #0056b3; - } - </style> +}; + +const closeModal = () => { + tokenExpiresSoon.value = false; +}; + +let intervalId; + +onMounted(() => { + checkTokenExpiration(); + intervalId = setInterval(checkTokenExpiration, 60000); +}); + +onUnmounted(() => { + clearInterval(intervalId); +}); +</script> + +<template> + <div v-if="tokenExpiresSoon" class="modal-overlay"> + <div class="modal-content"> + <p>Token utløper om 1 minutt. Vennligst lagre arbeidet ditt.</p> + <button @click="closeModal">Lukk</button> + </div> + </div> +</template> + + +<style scoped> +button { + margin-top: 5px; +} +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; +} + +.modal-content { + background-color: var(--accent-color); + padding: 20px; + border-radius: var(--border-radius-general); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + text-align: center; +} +</style> \ No newline at end of file diff --git a/src/components/budget/BudgetEditComponent.vue b/src/components/budget/BudgetEditComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..89221f398246ebba6f8eea4a73600ceea35e156e --- /dev/null +++ b/src/components/budget/BudgetEditComponent.vue @@ -0,0 +1,239 @@ +<script setup> +import {ref, watch, defineProps, defineEmits} from 'vue'; +import BankStatementService from '@/services/internal/BankStatementService'; +import axios from "axios"; + +const props = defineProps({ + selectedAnalysis: Object +}); + +const emits = defineEmits(['update', 'close']); + +const errorMessage = ref(''); +const newExpectedValues = ref([]); +const minValue = 0; +const maxValue = 2000000000; +const categoryMap = { + "01": {name: "Mat", englishName: "FOOD"}, + "02": {name: "Alkohol og Tobakk", englishName: "ALCOHOL_AND_TOBACCO"}, + "03": {name: "Klær og Sko", englishName: "CLOTHING_AND_SHOES"}, + "04": {name: "Bolig og Elektrisitet", englishName: "HOUSING_AND_ELECTRICITY"}, + "05": {name: "Møbler", englishName: "FURNITURE"}, + "06": {name: "Helse", englishName: "HEALTH"}, + "07": {name: "Transport", englishName: "TRANSPORT"}, + "08": {name: "Kommunikasjon", englishName: "COMMUNICATION"}, + "09": {name: "Fritid, Sport og Kultur", englishName: "LEISURE_SPORT_AND_CULTURE"}, + "10": {name: "Utdanning", englishName: "EDUCATION"}, + "11": {name: "Spise Ute", englishName: "EATING_OUT"}, + "12": {name: "Forsikring", englishName: "INSURANCE"}, + "13": {name: "Annet", englishName: "OTHER"} +}; + +/** + * Constructs the analysis DTO to be sent to the backend. + */ +const constructAnalysisDto = () => { + return { + id: props.selectedAnalysis.id, + analysisItems: newExpectedValues.value.map(item => ({ + id: item.id, + category: item.englishName, + expectedValue: item.newExpectedValue, + actualValue: item.actualValue + })) + }; +}; + +/** + * Watches for changes in the selectedAnalysis prop and updates the newExpectedValues ref accordingly. + * @param {Array} newVal - The new value of the selectedAnalysis prop. + */ +watch(() => props.selectedAnalysis?.analysisItems, (newVal) => { + if (Array.isArray(newVal)) { + newExpectedValues.value = newVal + .filter(item => item.category !== "00") + .map(item => ({ + ...item, + category: categoryMap[item.category]?.name || "Ukjent Kategori", + englishName: categoryMap[item.category]?.englishName, + newExpectedValue: item.expectedValue + })); + } +}, {immediate: true}); + +/** + * Validates the new expected values. + */ +const validateValues = () => { + let isValid = true; + errorMessage.value = ''; + + for (let item of newExpectedValues.value) { + if (item.newExpectedValue > maxValue) { + errorMessage.value = `Verdien kan ikke være mer enn ${maxValue.toLocaleString('no-NO')} kr.`; + isValid = false; + } else if (item.newExpectedValue < minValue) { + errorMessage.value = `Verdien kan ikke være negativ.`; + isValid = false; + } + + if (!isValid) { + return false; + } + } + return true; +}; + +/** + * Saves the new expected values to the backend. + */ +const saveNewValues = async () => { + if (!validateValues()) { + return; + } + const bankStatementAnalysisDTO = constructAnalysisDto(); + try { + await BankStatementService.updateAnalysis(bankStatementAnalysisDTO); + emits('update'); + errorMessage.value = ''; + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + errorMessage.value = 'Kunne ikke lagre endringene. Prøv igjen senere'; + } else { + console.error('Cannot connect to server.', error); + errorMessage.value = 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } +}; + +/** + * Closes the edit budget component. + */ +const closeEdit = () => { + emits('close'); +}; + +/** + * Formats a number to a currency string. + * @param {*} value + */ +const formatCurrency = (value) => { + return `${value.toLocaleString('no-NO')} kr`; +}; +</script> + +<template> + <div class="edit-budget-container"> + <h1>Rediger Budsjett</h1> + <div v-if="errorMessage" class="error" data-cy="error-message">{{ errorMessage }}</div> + <div class="scrollable-container"> + <table class="table-container"> + <thead> + <tr> + <th>Kategori</th> + <th>Forventet verdi</th> + <th>Ny forventet verdi</th> + </tr> + </thead> + <tbody> + <tr v-for="(item, index) in newExpectedValues" :key="index"> + <td>{{ item.category }}</td> + <td>{{ formatCurrency(item.expectedValue) }}</td> + <td> + <input type="number" v-model="item.newExpectedValue" :max="maxValue" :min="minValue" data-cy="number-input"> + </td> + </tr> + </tbody> + </table> + </div> + <div class="button-content"> + <button @click="saveNewValues" data-cy="save-button">Lagre</button> + <button @click="closeEdit" data-cy="close-button">Avbryt</button> + </div> + </div> +</template> + +<style scoped> +.edit-budget-container { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.button-content { + margin-top: 10px; + display: flex; + flex-direction: row; + justify-content: space-evenly; + align-self: center; + width: 100%; +} + +.scrollable-container { + overflow-y: auto; + max-height: 80vh; + width: 100%; + margin-top: 20px; +} + +.table-container { + width: 100%; + margin-top: 0; + border-collapse: collapse; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); +} + +th, td { + padding: 12px 15px; + text-align: left; + border-bottom: 1px solid var(--accent-color); +} + +input { + width: 100%; +} + +th { + background-color: var(--middle-color); + color: white; + cursor: pointer; +} + +th:hover { + background-color: var(--dark-color); +} + +tbody tr:hover { + background-color: var(--accent-color); +} + +.table-container th { + background-color: var(--dark-color); + color: white; + position: sticky; + top: 0; + z-index: 10; +} + +.table-container td:last-child { + display: flex; + justify-content: center; +} + +.error { + color: var(--error-text); + padding: 10px; +} + +button { + min-width: 100px; +} +@media (max-width: 480px) { + .table-container th:nth-child(2), /* Forventet verdi kolonne header */ + .table-container td:nth-child(2) { /* Forventet verdi kolonne data */ + display: none; + } + +} +</style> diff --git a/src/components/budget/BudgetMain.vue b/src/components/budget/BudgetMain.vue index 46dd2ee5285276dc1e845bcfd64ffdbcd8961ae5..844e4b41f516443f5748080808a3241775aa49c6 100644 --- a/src/components/budget/BudgetMain.vue +++ b/src/components/budget/BudgetMain.vue @@ -1,9 +1,402 @@ <script setup> +import {ref, watchEffect, computed, onMounted} from 'vue'; +import SpentChart from './SpentChart.vue'; +import MonthlySpendingChart from './MonthlySpendingChart.vue'; +import BankStatementService from '@/services/internal/BankStatementService'; +import TransactionService from '@/services/internal/TransactionService'; +import BudgetEditComponent from './BudgetEditComponent.vue'; +import router from "@/router"; +const currentDate = new Date(); +currentDate.setMonth(currentDate.getMonth() - 1); + +const currentMonthIndex = currentDate.getMonth(); +const currentYear = currentDate.getFullYear(); +const monthNames = ['Januar', 'Februar', 'Mars', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Desember']; + +const selectedMonthYear = ref(`${currentYear}-${String(currentMonthIndex + 1).padStart(2, '0')}-01`); +const selectedAccountNumber = ref(''); +const selectedAnalysis = ref([]); + +const spent = ref(0); +const budget = ref(0); +const accountNumbers = ref([]); +const financialSummary = ref([]); +const monthlyBudgetData = ref([]); +const hasData = computed(() => spent.value !== 0 || budget.value !== 0); +const isEditOpen = ref(false); +const errorMessage = ref(''); + +const categoryMap = { + "01": "Mat", + "02": "Alkohol og Tobakk", + "03": "Klær og Sko", + "04": "Bolig og Elektrisitet", + "05": "Møbler", + "06": "Helse", + "07": "Transport", + "08": "Kommunikasjon", + "09": "Fritid, Sport og Kultur", + "10": "Utdanning", + "11": "Spise Ute", + "12": "Forsikring", + "13": "Annet" +}; + +/** + * Fetches the account numbers from the backend and sets the first account number as the selected account number. + */ +const fetchAccountNumbers = async () => { + try { + const accounts = await TransactionService.getAccountNumbers(); + accountNumbers.value = accounts; + if (accounts.length > 0) { + selectedAccountNumber.value = accounts[0]; + } + } catch (error) { + errorMessage.value = 'Kunne ikke hente kontoer'; + } +}; + +/** + * Fetches the bank statements for the selected month and year. + * @param {number} month - The month number. + * @param {number} year - The year number. + * @returns {Promise} - The bank statements for the selected month and year. + */ +const fetchBankStatementsForMonth = async (month, year) => { + return await BankStatementService.retrieveBankStatementsWithMY(month, year); +}; + +/** + * Analyzes the bank statements and returns the analysis items. + * @param statements - The bank statements to analyze. + * @returns {Promise} - An array of all analysis items (excluding those in category "00") from all provided statements. + */ +const analyzeStatement = async (statements) => { + let allAnalysisItems = []; + for (let statement of statements) { + const analysis = await BankStatementService.analyzeBankStatement(statement.id); + selectedAnalysis.value = analysis; + allAnalysisItems.push(...analysis.analysisItems); + } + if (allAnalysisItems.length === 0) { + selectedAnalysis.value = null; + } + return allAnalysisItems.filter(item => item.category !== "00"); +}; + +/** + * Analyzes the bank statements for the monthly budget and returns the analysis items. + * @param statements - The bank statements to analyze. + * @returns {Promise<Array>} - An array of all analysis items (excluding those in category "00") from all provided statements. + */ +const analyzeStatementForMonthlyBudget = async (statements) => { + let allAnalysisItems = []; + for (let statement of statements) { + const analysis = await BankStatementService.analyzeBankStatement(statement.id); + allAnalysisItems.push(...analysis.analysisItems); + } + return allAnalysisItems.filter(item => item.category !== "00"); +}; + +/** + * Computes the financial summary based on the analysis items. + * The actual value and expected value for each category is summed up, and the total difference is calculated. + * @param {Array} analysisItems - The analysis items to compute the financial summary from. + * @returns {void} + */ +const computeFinancialSummary = (analysisItems) => { + let totalExpectedValue = 0; + let totalActualValue = 0; + const categoryTotals = analysisItems.reduce((acc, item) => { + const categoryName = getCategoryName(item.category); + if (!acc[categoryName]) { + acc[categoryName] = {id: item.id, category: categoryName, expectedValue: 0, actualValue: 0}; + } + acc[categoryName].expectedValue += item.expectedValue; + acc[categoryName].actualValue += item.actualValue; + totalExpectedValue += item.expectedValue; + totalActualValue += item.actualValue; + return acc; + }, {}); + + budget.value = totalExpectedValue; + spent.value = totalActualValue; + + financialSummary.value = [ + ...Object.values(categoryTotals), + { + id: 'total', + category: 'Totalt', + expectedValue: totalExpectedValue, + actualValue: totalActualValue, + difference: totalActualValue - totalExpectedValue + } + ]; +}; + +/** + * Fetches the financial data for the selected account number, month and year. + * The bank statements are fetched, then filtered by the selected account number and analyzed to compute the financial summary. + * @returns {void} + */ +const fetchFinancialData = async () => { + const [year, month] = selectedMonthYear.value.split('-'); + try { + const statements = await fetchBankStatementsForMonth(parseInt(month), parseInt(year)); + const filteredStatement = statements.filter(statement => statement.accountNumber === selectedAccountNumber.value); + const analysisItems = await analyzeStatement(filteredStatement); + computeFinancialSummary(analysisItems); + } catch (error) { + errorMessage.value = 'Kunne ikke hente budsjett'; + } +}; + +/** + * Fetches the monthly budget data for the selected account number. + * The bank statements are fetched for each month of the year, then filtered by the selected account number and analyzed to compute the total spent. + * @returns {month: string, spent: number}[] - An array of objects containing the month name and the total spent for that month. + */ +const fetchMonthlyBudgetData = async () => { + try { + const yearNumber = parseInt(selectedMonthYear.value.split('-')[0]); + const allMonths = Array.from({length: 12}, (_, i) => i + 1); + + monthlyBudgetData.value = await Promise.all(allMonths.map(async (monthNumber) => { + const statements = await fetchBankStatementsForMonth(monthNumber, yearNumber); + const filteredStatements = statements.filter(statement => statement.accountNumber === selectedAccountNumber.value); + const analysisItems = await analyzeStatementForMonthlyBudget(filteredStatements); + const totalSpent = analysisItems.reduce((acc, item) => acc + item.actualValue, 0); + return {month: monthNames[monthNumber - 1], spent: totalSpent}; + })); + } catch (err) { + errorMessage.value = 'Kunne ikke hente mÃ¥nedlig budsjett data'; + } +}; + +/** + * Returns the category name for the provided category code. + * @param {string} code - The category code. + */ +const getCategoryName = (code) => categoryMap[code] || "Ukjent Kategori"; + +/** + * Formats the provided value as a currency string. + * @param {number} value - The value to format. + */ +const formatCurrency = (value) => `${value.toLocaleString('no-NO')} kr`; + +/** + * Toggles the edit mode for the budget. + */ +const toggleEdit = () => { + isEditOpen.value = !isEditOpen.value; +}; + +/** + * Handles the update event from the BudgetEditComponent. + * Fetches the financial data after the edit is closed. + * @returns {void} + */ +const handleUpdate = () => { + isEditOpen.value = false; + fetchFinancialData(); +}; + +/** + * Watches the selected account number and fetches the financial data and monthly budget data when the account number changes. + */ +watchEffect(() => { + if (selectedAccountNumber.value) { + fetchFinancialData(); + fetchMonthlyBudgetData(); + } +}); + +/** + * Fetches the account numbers when the component is mounted. + */ +onMounted(() => { + fetchAccountNumbers(); +}); + +/** + * Routes to the BankStatements component in the profile page. + */ +function addNewBankStatement() { + router.push({name: "profile", params: {component: "BankStatements"}}); +} </script> <template> - <div> - <p>Budget</p> + <div v-if="!isEditOpen" class="budget-main-container" data-cy="budget-component"> + <div class="title"> + <h1>Budsjett</h1> + </div> + <div class="selectors"> + <div class="account-selector"> + <label for="account-dropdown">Velg konto:</label> + <select class="dropdown" id="account-dropdown" v-model="selectedAccountNumber" data-cy="account-dropdown"> + <option v-for="account in accountNumbers" :key="account" :value="account">{{ account }}</option> + </select> + </div> + <div class="year-month-selector"> + <label for="selected-month-year">Velg mÃ¥ned og Ã¥r:</label> + <input type="date" placeholder="Velg mÃ¥ned" v-model="selectedMonthYear" id="selected-month-year" + class="date-picker dropdown" data-cy="date-picker"> + </div> + </div> + <div> + <div v-if="hasData"> + <spent-chart :budget="budget" :spent="spent" data-cy="spent-chart"/> + </div> + <div v-else> + <p>Du har ikke lagt inn kontoutskrift for denne mÃ¥neden enda</p> + <button @click="addNewBankStatement">Legg til</button> + </div> + </div> + <div class="income-section" data-cy="income-section"> + <h2>Totalt budsjett</h2> + <p><strong>Denne mÃ¥neden:</strong> {{ formatCurrency(budget) }}</p> + </div> + <div class="expenses-section" data-cy="expenses-section"> + <div class="top-section"> + <h2>Utgifter</h2> + <button v-if="hasData" @click="toggleEdit" data-cy="edit-button">Rediger budsjett</button> + </div> + <table> + <thead> + <tr> + <th>Kategori</th> + <th>Forventet</th> + <th>Faktisk</th> + <th>Differanse</th> + </tr> + </thead> + <tbody> + <tr v-for="item in financialSummary" :key="item.id" :class="{ 'total-row': item.id === 'total' }"> + <td>{{ item.category }}</td> + <td>{{ formatCurrency(item.expectedValue) }}</td> + <td>{{ formatCurrency(item.actualValue) }}</td> + <td :class="{ + 'positive-difference': item.expectedValue - item.actualValue >= 0, + 'negative-difference': item.expectedValue - item.actualValue < 0 + }"> + {{ formatCurrency(item.expectedValue - item.actualValue) }} + </td> + </tr> + </tbody> + </table> + <div v-if="errorMessage" class="error" data-cy="error-message">{{ errorMessage }}</div> + </div> + <div> + <monthly-spending-chart :budget-data="monthlyBudgetData" data-cy="monthly-spending-chart"/> + </div> </div> + <budget-edit-component v-else :selected-analysis="selectedAnalysis" @update="handleUpdate" @close="toggleEdit"/> </template> + +<style scoped> +.title { + padding: 20px 0 20px 0; +} + +.budget-main-container { + display: flex; + flex-direction: column; + text-align: center; + box-sizing: border-box; + margin-bottom: 20px; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, +td { + border: 1px solid #ddd; + padding: 8px; +} + +th { + background-color: #f0f0f0; +} + +.income-section, +.expenses-section { + text-align: left; + margin-top: 1rem; + margin-bottom: 20px; +} + +.selectors { + display: flex; + align-self: center; + gap: 1rem; + margin-bottom: 1rem; + width: 100%; +} + +.account-selector, +.year-month-selector { + display: flex; + flex-direction: column; + width: 100%; +} + +.dropdown { + width: 100%; +} + +.input .year-month-selector { + width: 100%; +} + +.top-section { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; +} + +.positive-difference { + color: green; +} + +.negative-difference { + color: var(--error-text); +} + +.total-row { + font-weight: bold; +} + +.error { + color: var(--error-text); + padding: 10px; +} + +@media (max-width: 480px) { + th:nth-child(4), + td:nth-child(4) { + display: none; + } + + .top-section { + display: none; + } + + .income-section { + display: none; + } +} + +@media (max-width: 400px) { + .chart-container { + display: none; + } +} +</style> \ No newline at end of file diff --git a/src/components/budget/MonthlySpendingChart.vue b/src/components/budget/MonthlySpendingChart.vue new file mode 100644 index 0000000000000000000000000000000000000000..52418c5225e155247e4ae70d26b2657136f8e984 --- /dev/null +++ b/src/components/budget/MonthlySpendingChart.vue @@ -0,0 +1,110 @@ +<script setup> +import { ref, onMounted, onUnmounted, defineProps, watch } from 'vue'; +import Chart from 'chart.js'; + +const props = defineProps({ + budgetData: Array +}); + +const chartContainer = ref(null); +let myChart = null; + +/** + * Initializes the chart with the given data. + * @param {Array} filteredData - The data to initialize the chart with. + */ +const initializeChart = (filteredData) => { + const ctx = chartContainer.value.getContext('2d'); + myChart = new Chart(ctx, { + type: 'line', + data: { + labels: filteredData.map(data => data.month), + datasets: [{ + label: 'MÃ¥nedsforbruk', + data: filteredData.map(data => data.spent), + fill: false, + borderColor: '#304C6C', + tension: 0.1 + }] + }, + options: { + scales: { + yAxes: [{ + ticks: { + beginAtZero: true + }, + scaleLabel: { + display: true, + labelString: 'Forbruk (kr)', + fontSize: 16, + fontColor: '#304C6C' + } + }], + xAxes: [{ + scaleLabel: { + display: true, + labelString: 'MÃ¥ned', + fontSize: 16, + fontColor: '#304C6C' + } + }] + }, + legend: { + labels: { + fontSize: 16 + } + } + } + }); +}; + +/** + * Lifecycle hook that initializes the chart when the component is mounted. + * Filters out months with a spent value of 0. + */ +onMounted(() => { + const filteredData = props.budgetData.filter(data => data.spent > 0); + if (chartContainer.value) { + initializeChart(filteredData); + } +}); + +/** + * Lifecycle hook that updates the chart when the budget data changes. + * Filters out months with a spent value of 0. + * @param {Array} newData - The new budget data. + */ +watch(() => props.budgetData, (newData) => { + if (myChart) { + const filteredData = newData.filter(data => data.spent > 0); + myChart.data.labels = filteredData.map(data => data.month); + myChart.data.datasets[0].data = filteredData.map(data => data.spent); + myChart.update(); + } +}, { immediate: true }); + +/** + * Lifecycle hook that destroys the chart when the component is unmounted. + */ +onUnmounted(() => { + if (myChart) { + myChart.destroy(); + myChart = null; + } +}); +</script> + +<template> + <div class="chart-container"> + <canvas ref="chartContainer"></canvas> + </div> +</template> + +<style scoped> +.chart-container { + width: 100%; + height: auto; + padding-top: 30px; + margin-bottom: 20px; +} +</style> diff --git a/src/components/budget/SpentChart.vue b/src/components/budget/SpentChart.vue new file mode 100644 index 0000000000000000000000000000000000000000..f490512451e7cec1dee61e16f4a0129ff88748ca --- /dev/null +++ b/src/components/budget/SpentChart.vue @@ -0,0 +1,104 @@ +<script setup> +import {ref, onMounted, onUnmounted, watch, defineProps, computed} from 'vue'; +import Chart from 'chart.js'; + +const props = defineProps({ + spent: Number, + budget: Number +}); + +const chartRef = ref(null); +let myChart = null; + +/** + * Calculates the remaining budget by subtracting the spent amount from the budget. + */ +const remaining = computed(() => props.budget - props.spent); + +/** + * Returns a message based on the remaining budget. + */ +const remainingMessage = computed(() => { + return remaining.value >= 0 + ? `Du hadde ${remaining.value.toLocaleString('no-NO')} kr igjen.` + : `Du var <strong>${(-remaining.value).toLocaleString('no-NO')} kr</strong> over budsjett.`; + +}); + +/** + * Creates the chart with the given data. + */ +const createChart = () => { + const chartContext = chartRef.value.getContext('2d'); + myChart = new Chart(chartContext, { + type: 'doughnut', + data: { + labels: ['Brukt', 'GjenstÃ¥ende'], + datasets: [{ + backgroundColor: ['#304C6C', '#1fd655'], + data: [props.spent, Math.max(props.budget - props.spent, 0)] + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + legend: { + display: true, + position: 'bottom' + } + } + }); +}; + +/** + * Initializes the chart when the component is mounted. + */ +onMounted(() => { + if (chartRef.value) { + createChart(); + } else { + console.error("Chart reference is not available."); + } +}); + +/** + * Updates the chart with the new data. + */ +const updateChart = () => { + if (myChart) { + myChart.data.datasets[0].data = [props.spent, Math.max(props.budget - props.spent, 0)]; + myChart.update(); + } +}; + +/** + * Watches the spent and budget props and updates the chart when they change. + */ +watch([() => props.spent, () => props.budget], () => { + updateChart(); +}, {immediate: true}); + +/** + * Destroys the chart when the component is unmounted. + */ +onUnmounted(() => { + if (myChart) { + myChart.destroy(); + } +}); +</script> + +<template> + <div class="chart-container"> + <p v-html="remainingMessage"></p> + <canvas ref="chartRef"></canvas> + </div> +</template> + +<style scoped> +.chart-container { + width: 100%; + height: 30vh; + padding: 1rem 0; +} +</style> diff --git a/src/components/challenge/challengeComponent.vue b/src/components/challenge/challengeComponent.vue deleted file mode 100644 index 380e056cf9fe8385a3e6e1b03c32b85deec6502b..0000000000000000000000000000000000000000 --- a/src/components/challenge/challengeComponent.vue +++ /dev/null @@ -1,100 +0,0 @@ -<script setup> -//todo gjør underovserskriftene bold -//todo legg til hjerter til grisene - -</script> - -<template> - <div class="challenge-container"> - <h1>Challenges</h1> - - <div class="current-challenge"> - <h2>Current</h2> - - <div class="challenge"> - <p>Shoppestopp</p> - <p>Ikke kjøp noe annet enn matvarer i 20 dager!</p> - <div class="progress">Status : 30/30</div> - <div>Hjerte rosa</div> - <div>Hjerte rosa</div> - <div>Hjerte grÃ¥</div> - </div> - </div> - - <div class="previous-challenges"> - <h2>Previous</h2> - - <div class="challenge"> - <p>Alkohol stopp</p> - <p>Ikke kjøp alkohol pÃ¥ en uke!</p> - <span class="progress">Status : 7/7, </span> - <span class="progress">Completed </span> - <span class="indicator completed"></span> - </div> - - <div class="challenge"> - <p>Mesterkokken</p> - <p>Lag all mat den neste mÃ¥neden hjemme! Ikke kjøp mat ute, spis pÃ¥ restauranter eller bestill takeaway.</p> - <span class="progress">Status : 30/30, </span> - <span class="progress">Completed </span> - <span class="indicator completed"></span> - </div> - - <div class="challenge"> - <p>Shoppestopp</p> - <p>Ikke kjøp noe annet enn matvarer i 20 dager!</p> - <span class="progress">Status : 11/20, </span> - <span class="progress">Not completed </span> - <span class="indicator not-completed"></span> - </div> - </div> - - </div> -</template> - -<style scoped> -.challenge-container { - width: 100%; - max-height: 100vh; - overflow-y: auto; - padding: 5% 5% 20px 5%; - box-sizing: border-box; - scrollbar-width: none; /* Firefox */ -} - -.challenge-container::-webkit-scrollbar { - display: none; -} - -.challenge { - border: 1px solid var(--dark-color); - border-radius: 10px; - margin-bottom: 20px; - padding: 10px; - text-align: center; -} - -.indicator { - height: 20px; - width: 20px; - border-radius: 50%; - display: inline-block; -} - -.completed { - background-color: green; -} - -.not-completed { - background-color: red; -} - -p { - display: block; - color: black; - font-size: 1rem; - word-wrap: break-word; - overflow-wrap: break-word; - word-break: normal; -} -</style> diff --git a/src/components/contact/ContactComponent.vue b/src/components/contact/ContactComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..dc51d0cac8f4d79f5c9c9be9ae2f93d32e9ea906 --- /dev/null +++ b/src/components/contact/ContactComponent.vue @@ -0,0 +1,258 @@ +<script setup> +import {ref, watch, computed} from 'vue'; +import EmailService from "@/services/internal/EmailService"; +import UserDetailsValidationService from "@/services/internal/UserDetailsInputService"; +import axios from "axios"; + +const status = ref(""); +let selectedOptionSubject = ref(''); +let name = ref(''); +let email = ref(''); +let feedback = ref(''); +const options = ref([ + {text: 'Feil og problemer', value: '1'}, + {text: 'Tilbakemelding', value: '2'}, + {text: 'Kundeservice', value: '3'}, + {text: 'Annet', value: '4'} +]); + + +const isFormValid = computed(() => { + return name.value.trim() && email.value.trim() && feedback.value.trim() && selectedOptionSubject.value.trim(); +}); + +/** + * Watcher for changes in the selected subject option. + */ +watch(selectedOptionSubject, (newVal, oldVal) => { + console.log(`changed from ${oldVal} to ${newVal}`); +}); + +/** + * Submits the form data. + */ +async function submitForm() { + + if (!checkValidFormData()) { + return + } + + try { + const messageBody = feedback.value + "\n\n" + "Name: " + name.value + '\n' + "Email: " + email.value + const message = { + recipient: "", subject: selectedOptionSubject.value, body: messageBody + }; + status.value = "Sender melding..."; + await EmailService.sendContactMessage(message) + status.value = "Meldingen ble sendt!"; + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 500: + status.value = "Intern serverfeil. Vennligst prøv igjen senere."; + break; + default: + status.value = error.response.data.errorMessage; + break; + } + } else { + console.error('Cannot connect to server.', error); + status.value = 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + +} + +/** + * Validates the form data. + * + * @returns {boolean} True if the form data is valid, otherwise false. + */ +function checkValidFormData() { + try { + UserDetailsValidationService.validateNamePattern(name.value); + UserDetailsValidationService.validateEmailPattern(email.value); + UserDetailsValidationService.validateFeedbackPattern(feedback.value); + } catch (error) { + status.value = error.message; + return false; + } + return true; +} + + +</script> + +<template> + <div class="contact-container"> + <div class="left-info"> + <img class="side-image" src="@/assets/img/pigrich.png" alt="pig"> + <p>Vi vil høre din mening! </p> + <p>Alle bidrag blitt tatt i nøye betraktning</p> + </div> + + <div class="form-container"> + <div class="form-container-content"> + <h1> + Kontaktskjema + </h1> + <p class="confirmation-or-error-message">{{ status }}</p> + + <form class="form-element-container" @submit.prevent="handleSubmit"> + <label for="selectedOptionSubject">Tema</label> + <select id="selectedOptionSubject" class="form-element" v-model="selectedOptionSubject"> + <option disabled value="">Velg tema</option> + <option v-for="option in options" :value="option.value" :key="option.value"> + {{ option.text }} + </option> + </select> + + <label for="name">Fornavn</label> + <input id="name" class="form-element" type="text" required v-model="name" placeholder="Fornavn" + @input="validateForm"/> + + + <label for="email">E-postadresse</label> + <input id="email" class="form-element" type="email" required v-model="email" placeholder="E-postadresse" + autocomplete="email"/> + + <label for="feedback">Tilbakemelding</label> + <textarea class="form-element" id="feedback" required v-model="feedback" placeholder="Melding"></textarea> + + <button :disabled="!isFormValid" @click.prevent="submitForm" type="submit" data-cy="submit-button">Send inn!</button> + </form> + </div> + </div> + </div> +</template> + +<style scoped> +label { + text-align: left; + padding-left: 10px; + padding-bottom: 5px; + display: block; + width: 100%; +} + +h1 { + margin-top: 0; +} + +.contact-container { + display: flex; + flex-wrap: nowrap; + flex-direction: row; + padding: 20px; + justify-content: center; +} + +.left-info { + padding: 20px; + background-color: var(--dark-color); + background-size: cover; + flex: 1; + border-radius: var(--border-radius-general) 0 0 var(--border-radius-general); + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; +} + +.side-image { + width: 70%; + height: auto; + filter: drop-shadow(0px 0px 10px black); +} + +.form-container { + flex: 1; + background-color: var(--white-text); + padding: 20px; + border-radius: 0 var(--border-radius-general) var(--border-radius-general) 0; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + justify-content: center; + text-align: center; + box-sizing: border-box; + width: 100%; + overflow: auto; +} + +.form-element-container { + display: flex; + flex-direction: column; + align-items: center; + justify-items: center; + margin-top: 5%; +} + +.form-element { + box-shadow: 2px 2px 4px 0 rgba(0, 0, 0, 0.25); + box-sizing: border-box; +} + +.form-element:focus { + outline: none; +} + +textarea { + height: 150px; + resize: none; + padding-top: 10px; + flex-grow: 1; +} + +select, input, textarea, button { + width: 100%; + padding: 10px; + margin-bottom: 10px; + border-radius: var(--border-radius-general); + border: 1px solid #ccc; + box-sizing: border-box; +} + +p { + color: var(--white-text); +} + +.confirmation-or-error-message { + color: var(--middle-color); + font-size: 14px; +} + +.form-container-content { + width: 100%; + height: 100%; + box-sizing: border-box; +} + +img { + margin-bottom: 10px; +} + + +@media screen and (max-width: 760px) { + + .left-info { + display: none; + } + + .form-container { + height: auto; + } +} + +@media screen and (min-width: 760px) { + + .left-info { + display: flex; + } + + .form-container { + height: auto; + } +} +</style> \ No newline at end of file diff --git a/src/components/contact/FAQs.vue b/src/components/contact/FAQs.vue new file mode 100644 index 0000000000000000000000000000000000000000..2b855af8da7ab715bf830683b2a7e7f866bfe07a --- /dev/null +++ b/src/components/contact/FAQs.vue @@ -0,0 +1,159 @@ +<script setup> +import {ref} from 'vue'; + +const faqs = ref([ + { + question: 'Hvordan oppretter jeg en konto?', + answer: `For Ã¥ opprette en konto, trykk pÃ¥ "Registrer" og fyll ut skjemaet. + Du vil motta en e-post for Ã¥ bekrefte kontoen din. Etter bekreftelse kan du + senere logge inn med e-post og passord.`, + visible: false + }, + { + question: 'Kan jeg lage sparemÃ¥l uten Ã¥ opprette en konto?', + answer: `Det er desverre ikke mulig Ã¥ opprette sparemÃ¥l + uten en konto. Sparesti bruker dine kontoutskrifter for Ã¥ analysere + inntekter og utgifter. Dette gjør det mulig Ã¥ generere budsjett og forslag til utfordringer.`, + visible: false + }, + { + question: 'Hvorfor mÃ¥ jeg legge inn ekstra informasjon etter registrering?', + answer: `Kontoutskriftene er nødvendige for Ã¥ generere et budsjett basert pÃ¥ + din inntekt og bosituasjon. Dette gjør at vi kan komme med forslag til utfordringer + basert pÃ¥ dine utgifter.`, + visible: false + }, + { + question: 'Kan jeg opprette delte sparemÃ¥l?', + answer: `Du kan opprette bÃ¥de delte sparemÃ¥l og delte utfordringer. Delte sparemÃ¥l inneholder + en unik kode som du kan dele med venner og familie. Her kan dere sammen spare mot et felles mÃ¥l.`, + visible: false + }, + { + question: 'Hva gjør jeg om jeg vil endre passord?', + answer: `Passord og annen brukerinformasjon kan endres under profil.`, + visible: false + }, + { + question: 'Hvordan kan jeg rapportere et problem?', + answer: `Under kontaktskjema kan du sende tilbakemelding om problemer + eller feil du har oppdaget. Vi setter pris pÃ¥ all tilbakemelding!`, + visible: false + }, + { + question: 'Er min personlige informasjon trygg?', + answer: `Vi tar sikkerheten din pÃ¥ alvor og har beskyttet dataene dine i + henhold til vÃ¥re regler for personvern og sikkerhet.`, + visible: false + }, + { + question: 'Hvordan kan jeg slette kontoen min?', + answer: `For Ã¥ slette kontoen din, gÃ¥ til "Profil" og trykk pÃ¥ "Slett konto". + Du vil bli bedt om Ã¥ bekrefte slettingen av kontoen din. Vær oppmerksom pÃ¥ at + all data vil bli slettet og kan ikke gjenopprettes.`, + visible: false + } +]); + +function toggleFAQ(index) { + faqs.value[index].visible = !faqs.value[index].visible; +} +</script> + +<template> + <div class="FAQ-main-container"> + <h2>Ofte stilte spørsmÃ¥l:</h2> + <div class="gradient-line"></div> + <div class="faq-container"> + <div class="faq-item" v-for="(faq, index) in faqs" :key="index" :id="`faq-item-${index}`"> + <button class="faq-question" @click="toggleFAQ(index)"> + {{ faq.question }} + </button> + <div v-show="faq.visible" class="faq-answer"> + {{ faq.answer }} + </div> + </div> + </div> + </div> +</template> + + +<style scoped> +.faq-header { + display: flex; + justify-content: center; + align-items: center; +} + +.FAQ-main-container { + height: 100%; + padding: 20px; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + box-sizing: border-box; + min-height: 500px; +} + + +.gradient-line { + width: 90%; + height: 2px; + background-image: linear-gradient(to right, transparent, var(--dark-color), transparent); +} + +h2 { + justify-content: center; + align-items: center; +} + +.faq-container { + text-align: center; + width: 100%; + overflow-y: auto; +} + +.faq-item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} + +.faq-question { + width: 100%; + cursor: pointer; + font-weight: bold; + height: auto; + border-radius: var(--border-radius-general); + word-break: break-word; + overflow-wrap: break-word; + padding: 10px; + margin-top: 15px; + background-color: var(--dark-color); + color: var(--white-text); +} + +.faq-question:hover { + background-color: var(--middle-color); +} + +.faq-answer { + width: 90%; + padding: 10px; +} + +@media screen and (max-width: 760px) { + .faq-question { + width: 80%; + } + + .faq-answer { + width: 75%; + justify-content: left; + text-align: left; + } +} +</style> diff --git a/src/components/goals/AddChoice.vue b/src/components/goals/AddChoice.vue new file mode 100644 index 0000000000000000000000000000000000000000..5ff61354f0bf276eb1e68119e77ad961ef9e2237 --- /dev/null +++ b/src/components/goals/AddChoice.vue @@ -0,0 +1,86 @@ +<script setup> +</script> + +<template> + <div class="button-container"> + <div class="button-box"> + <img src="@/assets/img/challengeIcon.png" alt="Challenge icon" data-cy="challenge-icon"> + <button @click="$emit('createChallenge')" data-cy="start-challenge-button">Start en utfordring!</button> + </div> + <div class="button-box"> + <img src="@/assets/img/goalIcon.png" alt="Goal icon" data-cy="goal-icon"> + <button @click="$emit('createGoal')" data-cy="start-goal-button">Start et sparemÃ¥l!</button> + </div> + <div class="button-box"> + <div class="dual-img-box"> + <img src="@/assets/img/challengeIcon.png" alt="Challenge icon" data-cy="challenge-icon-shared"> + <img src="@/assets/img/usersIcon.png" alt="Users icon" data-cy="users-icon-shared"> + </div> + <button @click="$emit('competitionChoice')" data-cy="start-shared-challenge-button">Start en delt + spare-utfordring!</button> + </div> + <div class="button-box"> + <div class="dual-img-box"> + <img src="@/assets/img/goalIcon.png" alt="Goal icon" data-cy="goal-icon-shared"> + <img src="@/assets/img/usersIcon.png" alt="Users icon" data-cy="users-icon-shared"> + </div> + <button @click="$emit('sharedChoice')" data-cy="start-shared-goal-button">Start et delt sparemÃ¥l!</button> + </div> + <div class="button-box"> + <img src="@/assets/img/back.png" alt="Back icon" data-cy="back-button"> + <button @click="$emit('back')" data-cy="go-back-button">GÃ¥ tilbake</button> + </div> + </div> +</template> + + +<style scoped> +.button-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 30px; + margin-top: 100px; + box-sizing: border-box; + padding-bottom: 100px; +} + +.dual-img-box { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 30px; +} + +.button-box { + background-color: #f0f0f0; + border-radius: var(--border-radius-general); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + margin: 10px; + width: 70%; + text-align: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +button { + width: 70%; +} + +img { + width: 10%; + height: auto; + margin-bottom: 20px; +} + +@media (max-width: 800px) { + .button-container { + margin-top: 0; + } +} +</style> diff --git a/src/components/goals/ChallengeChoice.vue b/src/components/goals/ChallengeChoice.vue new file mode 100644 index 0000000000000000000000000000000000000000..69048eba10088974a710f6a3c66a1a27ddb8c356 --- /dev/null +++ b/src/components/goals/ChallengeChoice.vue @@ -0,0 +1,63 @@ +<script setup> +</script> + +<template> + <div class="button-container"> + <div class="button-box"> + <img src="@/assets/img/pluss_icon.png" alt="Image description"> + <button @click="$emit('create')">Start en utfordring</button> + </div> + <div class="button-box"> + <img src="@/assets/img/botIcon.png" alt="Image description"> + <button @click="$emit('automatic')">Generer personlige utfordringer</button> + </div> + <div class="button-box"> + <img src="@/assets/img/back.png" alt="Image description"> + <button @click="$emit('back')">GÃ¥ tilbake</button> + </div> + </div> +</template> + +<style scoped> +.button-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 30px; + margin-top: 100px; +} + +.dual-img-box { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 30px; +} + +.button-box { + background-color: #f0f0f0; + border-radius: var(--border-radius-general); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + margin: 10px; + width: 70%; + text-align: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +button { + width: 70%; + margin-top: 10px; +} + +img { + width: 10%; + height: auto; + margin-bottom: 20px; +} +</style> \ No newline at end of file diff --git a/src/components/goals/ChallengeCompetitionComponent.vue b/src/components/goals/ChallengeCompetitionComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..07ef584a2be7af32f33d96747b0d8664989aa492 --- /dev/null +++ b/src/components/goals/ChallengeCompetitionComponent.vue @@ -0,0 +1,156 @@ +/** +* This script setup initializes data for managing challenges and their states. +* It loads challenge content based on provided challenge IDs and retrieves data +* from ChallengeService to populate challenge information. +* Additionally, it provides functions to load a friends challenge content. +*/ +<script setup> +import { ref, defineProps, onMounted } from 'vue'; +import ProgressComponent from './ProgressComponent.vue'; +import ChallengeService from '@/services/internal/ChallengeService'; +import ChallengeStateManagement from './ChallengeStateManagement.vue'; +import GoalErrorService from '@/services/error/GoalErrorService'; + +const props = defineProps({ + challengeId: Number, + friendChallengeId: Number +}); +const statusMessage = ref(''); +const isContentLoaded = ref(false); +const currentChallengeId = ref(null); +const selectedChallenge = ref(null); +const title = ref(''); +const description = ref(''); +const difficulty = ref(''); +const startDate = ref(0); +const endDate = ref(0); +const state = ref(''); + +const friendCurrentChallengeId = ref(null); +const friendSelectedChallenge = ref(null); +const friendTitle = ref(''); +const friendDescription = ref(''); +const friendDifficulty = ref(''); +const friendStartDate = ref(0); +const friendEndDate = ref(0); +const friendState = ref(''); +const friendName = ref(''); + + +onMounted(async () => { + currentChallengeId.value = props.challengeId; + friendCurrentChallengeId.value = props.friendChallengeId; + await loadContent(); + await friendLoadContent(); + isContentLoaded.value = true; +}); + +/** + * Populates the content of a challenge based on the provided challengeId. + */ +const loadContent = async () => { + try { + if (currentChallengeId.value) { + selectedChallenge.value = await ChallengeService.getChallenge(currentChallengeId.value); + if (selectedChallenge.value) { + title.value = selectedChallenge.value.title; + description.value = selectedChallenge.value.description; + state.value = selectedChallenge.value.state; + difficulty.value = selectedChallenge.value.difficulty; + startDate.value = new Date(selectedChallenge.value.startDate); + endDate.value = new Date(selectedChallenge.value.endDate); + } + } + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetChallenge(error); + } +}; + +/** + * Populates the content of a friends challenge based on the provided friendChallengeId. + */ +const friendLoadContent = async () => { + try { + if (friendCurrentChallengeId.value) { + const response = await ChallengeService.getFriendChallenge(friendCurrentChallengeId.value); + if (response && response.length > 0) { + const challenge = response[0]; + friendSelectedChallenge.value = challenge; + + friendTitle.value = challenge.title; + friendDescription.value = challenge.description; + friendState.value = challenge.state; + friendDifficulty.value = challenge.difficulty; + friendStartDate.value = new Date(challenge.startDate); + friendEndDate.value = new Date(challenge.endDate); + friendName.value = challenge.firstName + " " + challenge.lastName; + } + } + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetFriendChallenge(error); + } +}; + +</script> + +<template> + <div class="button-container"> + <div class="button-box"> + <button @click="$emit('back')">GÃ¥ tilbake</button> + </div> + <div class="outer"> + <h2>Deg:</h2> + <h4>{{ statusMessage }}</h4> + <ProgressComponent v-if="isContentLoaded" :title="title" :start-date="startDate" :end-date="endDate" + :difficulty="difficulty" :description="description" :state="state" class="outer-component" /> + <ChallengeStateManagement @back="$emit('back')" v-if="isContentLoaded" :challenge-id="currentChallengeId" /> + <h2> {{ friendName }}</h2> + <ProgressComponent v-if="isContentLoaded" :title="friendTitle" :start-date="friendStartDate" + :end-date="friendEndDate" :difficulty="friendDifficulty" :description="friendDescription" :state="friendState" + class="outer-component" /> + </div> + </div> +</template> + + +<style scoped> +.button-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 30px; + margin-top: 20px; + margin-bottom: 20px; +} + + +.button-box { + background-color: #f0f0f0; + border-radius: var(--border-radius-general); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + width: 70%; + text-align: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.outer { + margin-top: 5px; + width: 85%; + border-radius: var(--border-radius-general); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + + +button { + width: 70%; + margin-top: 10px; +} +</style> diff --git a/src/components/goals/ChallengeComponent.vue b/src/components/goals/ChallengeComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..bfec8d4371086cdbd9368200b9fae13898f2765a --- /dev/null +++ b/src/components/goals/ChallengeComponent.vue @@ -0,0 +1,115 @@ +/** +* This script setup initializes data for managing a challenge and its states. +* It loads challenge content based on a provided challenge ID and retrieves data +* from ChallengeService to populate challenge information. +*/ +<script setup> +import { ref, defineProps, onMounted } from 'vue'; +import ProgressComponent from './ProgressComponent.vue'; +import ChallengeService from '@/services/internal/ChallengeService'; +import ChallengeStateManagement from './ChallengeStateManagement.vue'; +import GoalErrorService from '@/services/error/GoalErrorService'; + +const props = defineProps({ + challengeId: Number +}); + +const statusMessage = ref(''); +const isContentLoaded = ref(false); +const currentChallengeId = ref(null); +const selectedChallenge = ref(null); +const title = ref(''); +const description = ref(''); +const difficulty = ref(''); +const startDate = ref(0); +const endDate = ref(0); +const progress = ref(''); + + +onMounted(async () => { + currentChallengeId.value = props.challengeId; + await loadContent(); + isContentLoaded.value = true; +}); + +/** + * Populates the content of a challenge based on the provided challengeId. + */ +const loadContent = async () => { + try { + if (currentChallengeId.value) { + selectedChallenge.value = await ChallengeService.getChallenge(currentChallengeId.value); + if (selectedChallenge.value) { + title.value = selectedChallenge.value.title; + description.value = selectedChallenge.value.description; + progress.value = selectedChallenge.value.progress; + difficulty.value = selectedChallenge.value.difficulty; + startDate.value = new Date(selectedChallenge.value.startDate); + endDate.value = new Date(selectedChallenge.value.endDate); + } + } + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetChallenge(error); + } +}; + +</script> + +<template> + <div class="button-container"> + <div class="button-box"> + <button @click="$emit('back')">GÃ¥ tilbake</button> + </div> + <div class="outer"> + <h2>Deg:</h2> + <h4>{{ statusMessage }}</h4> + <ProgressComponent v-if="isContentLoaded" :title="title" :start-date="startDate" :end-date="endDate" + :difficulty="difficulty" :description="description" :state="state" class="outer-component" /> + <ChallengeStateManagement @back="$emit('back')" v-if="isContentLoaded" :challenge-id="currentChallengeId" /> + </div> + </div> +</template> + + + +<style scoped> +.button-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 30px; + margin-top: 20px; + margin-bottom: 20px; +} + + +.button-box { + background-color: var(--accent-color); + border-radius: var(--border-radius-general); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + width: 70%; + text-align: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +.outer { + margin-top: 5px; + width: 85%; + border-radius: var(--border-radius-general); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + + +button { + width: 70%; + margin-top: 10px; +} +</style> diff --git a/src/components/goals/ChallengeStateManagement.vue b/src/components/goals/ChallengeStateManagement.vue new file mode 100644 index 0000000000000000000000000000000000000000..d51523cd284d302db92078add9d338f33dd3cc91 --- /dev/null +++ b/src/components/goals/ChallengeStateManagement.vue @@ -0,0 +1,190 @@ +/** +* This script setup initializes data for managing the state of a challenge. +* The script provides methods for updating a challenge state. +*/ +<script setup> +import { ref, defineProps, onMounted } from 'vue'; +import ChallengeService from '@/services/internal/ChallengeService'; +import BadgeService from '@/services/internal/BadgeService'; +import { defineEmits } from 'vue'; +import GoalErrorService from '@/services/error/GoalErrorService'; + +const emit = defineEmits(['back']); + +const statusMessage = ref(''); +const challengeId = ref(0); +const selectedChallenge = ref(null); +const showModal = ref(false); +const showCompleted = ref(false); +const confirmationStage = ref(false); + +const props = defineProps({ + challengeId: Number +}); + +onMounted(async () => { + try { + challengeId.value = props.challengeId; + if (challengeId.value) { + selectedChallenge.value = await ChallengeService.getChallenge(challengeId.value); + const today = new Date(); + if (getProgress(selectedChallenge.value.startDate, selectedChallenge.value.endDate, today) > 99) { + showCompleted.value = true; + } + } + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetChallenge(error); + } +}); + +/** + * Updates a challenge state to failed. + */ +const failed = async () => { + try { + await ChallengeService.updateProgress(challengeId.value, "FAILED"); + emit('back'); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorUpdateProgress(error); + } +}; + +/** + * Updates a challenge state to completed. + */ +const completed = async () => { + try { + await ChallengeService.updateProgress(challengeId.value, "COMPLETED"); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorUpdateProgress(error); + } + try { + await BadgeService.createBadge("NUMBER_OF_CHALLENGES_COMPLETED"); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorCreateBadge(error); + } + emit('back'); +}; + +/** + * Gets the progress of the mascot based on todays date. + * @param start The start date of the challenge. + * @param end The ending date of the challenge. + * @param today Todays date. + */ +const getProgress = (start, end, today) => { + const startDt = new Date(start); + const endDt = new Date(end); + const todayDt = new Date(today); + + if (todayDt < startDt) { + return 0; + } else if (todayDt > endDt) { + return 100; + } else { + const totalDuration = endDt - startDt; + const timeElapsed = todayDt - startDt; + return Math.floor((timeElapsed / totalDuration) * 100); + } +}; + +/** + * Displays a popup. + */ +const updateProgress = async () => { + try { + if (challengeId.value) { + selectedChallenge.value = await ChallengeService.getChallenge(challengeId.value); + if (selectedChallenge.value && selectedChallenge.value.progress == "IN_PROGRESS") { + showModal.value = true; + } + } + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetChallenge(error); + } +}; + +/** + * Updates the state of a challenge to failed. + */ +const confirmFailure = async () => { + try { + await ChallengeService.updateProgress(challengeId.value, "FAILED"); + confirmationStage.value = true; + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorUpdateProgress(error); + } +}; + +/** + * Closes the popup. + */ +const closePopup = () => { + showModal.value = false; +}; + +</script> + +<template> + <div> + <button class="btn" @click="updateProgress">Jeg har feilet pÃ¥ utfordringen</button> + <h4>{{ statusMessage }}</h4> + <div v-if="showModal" class="modal-overlay"> + <div class="modal"> + <p v-if="!confirmationStage">Er du sikker?</p> + <p v-else>Feilet</p> + + <button v-if="!confirmationStage" @click="confirmFailure">Ja</button> + <button v-if="!confirmationStage" @click="closePopup">Nei</button> + <button v-if="confirmationStage" @click="$emit('back')">Ferdig</button> + </div> + </div> + <div v-if="showCompleted" class="modal-overlay"> + <div class="modal"> + <p>Gratulerer. Utfordringen er ferdig! Klarte du den?</p> + <button @click="completed">Ja</button> + <button @click="failed">Nei</button> + </div> + </div> + </div> +</template> + + + + +<style scoped> +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.modal { + background: white; + padding: 20px; + border-radius: var(--border-radius-general); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +button { + width: 100%; + margin-top: 10px; +} + +.btn { + background-color: var(--error-text); + width: 100%; + margin-top: 10px; +} +</style> diff --git a/src/components/goals/CompetitionChoice.vue b/src/components/goals/CompetitionChoice.vue new file mode 100644 index 0000000000000000000000000000000000000000..1dd9afc844aa317ec3adc7b279d50b3b85126ea1 --- /dev/null +++ b/src/components/goals/CompetitionChoice.vue @@ -0,0 +1,63 @@ +<script setup> +</script> + +<template> + <div class="button-container"> + <div class="button-box"> + <img src="@/assets/img/pluss_icon.png" alt="Image description"> + <button @click="$emit('create')">Start en delt utfordring</button> + </div> + <div class="button-box"> + <img src="@/assets/img/join.png" alt="Image description"> + <button @click="$emit('join')">Bli med i en utfordring</button> + </div> + <div class="button-box"> + <img src="@/assets/img/back.png" alt="Image description"> + <button @click="$emit('back')">GÃ¥ tilbake</button> + </div> + </div> +</template> + +<style scoped> +.button-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 30px; + margin-top: 100px; +} + +.dual-img-box { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 30px; +} + +.button-box { + background-color: #f0f0f0; + border-radius: var(--border-radius-general); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + margin: 10px; + width: 70%; + text-align: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +button { + width: 70%; + margin-top: 10px; +} + +img { + width: 10%; + height: auto; + margin-bottom: 20px; +} +</style> \ No newline at end of file diff --git a/src/components/goals/CreateAutomaticChallenges.vue b/src/components/goals/CreateAutomaticChallenges.vue new file mode 100644 index 0000000000000000000000000000000000000000..f596d6654ff58d7c86dc68682e2df7266d9814f0 --- /dev/null +++ b/src/components/goals/CreateAutomaticChallenges.vue @@ -0,0 +1,133 @@ +<script setup> +import {ref} from 'vue'; +import AutomaticChallengeService from '@/services/internal/AutomaticChallengeService.js'; +import ChallengeService from '@/services/internal/ChallengeService.js'; +import {defineEmits} from 'vue'; +import GoalErrorService from '@/services/error/GoalErrorService'; + +const title = ref(''); +const challenges = ref([]); +const emit = defineEmits(['create', 'back']); + + +const statusMessage = ref(''); +const hasAnalysis = ref(false); +const fetchedChallenges = ref([]); + + +const getChallenges = async () => { + try { + const challenges = await AutomaticChallengeService.getChallenge(); + fetchedChallenges.value = challenges; + hasAnalysis.value = true; + } catch (error) { + statusMessage.value = await GoalErrorService.handleErrorGetAutomaticChallenge(error); + } +}; + +const chooseChallenge = async (challenge) => { + const {category, goalDescription, startDate, endDate} = challenge; + title.value = category; + try { + await ChallengeService.addChallenge('challengeDto', category, goalDescription, startDate, endDate, "EASY", "IN_PROGRESS"); + } catch (error) { + statusMessage.value = await GoalErrorService.handleErrorAddChallenge(error); + } + await saveNext(); +}; + +const saveNext = async () => { + try { + challenges.value = await ChallengeService.getChallenges(20, 0); + } catch (error) { + statusMessage.value = await GoalErrorService.handleErrorGetChallenges(error); + } + let newestChallenge = challenges.value[0]; + for (let i = 1; i < challenges.value.length; i++) { + if (challenges.value[i].title === title.value) { + newestChallenge = challenges.value[i]; + } + } + emit('challengeComponent', newestChallenge.id); +}; +</script> + +<template> + <div class="createChallenge-main-container"> + <div class="createChallenge-container"> + <div class="container"> + <button id="create-goal-button" class="btn" @click="getChallenges" :disabled="hasAnalysis"> + Generer ny utfordring! + </button> + <button @click="$emit('back')">GÃ¥ tilbake</button> + <h4>{{ statusMessage }}</h4> + </div> + + <div v-if="fetchedChallenges.length > 0" class="container"> + <h3>Velg en utfordring:</h3> + <div v-for="(challenge, index) in fetchedChallenges" :key="index" class="container" id="challenge"> + <h4>{{ challenge.category }}</h4> + <p>{{ challenge.goalDescription }}</p> + <p>Start Dato: {{ challenge.startDate }}</p> + <p>Slutt Dato: {{ challenge.endDate }}</p> + <button @click="chooseChallenge(challenge)">Velg utfordring</button> + </div> + </div> + </div> + </div> +</template> + +<style scoped> +.createChallenge-main-container { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100vh; + padding: 2.5%; +} +p { + word-wrap: break-word; + overflow-wrap: break-word; +} + +#challenge { + background-color: var(--white-general); +} + +button:disabled { + cursor: not-allowed; +} + +.createChallenge-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 30px; + width: 100%; + height: 100%; +} + +.container { + background-color: var(--accent-color); + border-radius: var(--border-radius-general); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + margin: 10px; + text-align: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + width: 100%; + max-width: 800px; + box-sizing: border-box; +} + +button { + width: 70%; + margin-top: 10px; + height: auto; +} +</style> diff --git a/src/components/goals/CreateChallenge.vue b/src/components/goals/CreateChallenge.vue index dd4eb93c3a7059097d285b8e47f4303c09a02884..d33745beeeef4ed468658f0db0dc8590a8b5585e 100644 --- a/src/components/goals/CreateChallenge.vue +++ b/src/components/goals/CreateChallenge.vue @@ -1,67 +1,162 @@ +/** +* This script provides methods for creating a challenge. +*/ <script setup> +import { ref, computed } from 'vue'; +import ChallengeService from '@/services/internal/ChallengeService.js'; +import { defineEmits } from 'vue'; +import GoalErrorService from '@/services/error/GoalErrorService'; +const statusMessage = ref(''); +const title = ref(''); +const description = ref(''); +const startDate = ref(''); +const endDate = ref(''); +const challenges = ref([]); +const emit = defineEmits(['create', 'back']); + +/** + * Checks if the startDate is not a past date. + */ +const isStartDateValid = computed(() => { + const today = new Date().toISOString().slice(0, 10); + return startDate.value >= today; +}); + +/** + * Checks if the endDate comes after the startdate and is not on the same date. + */ +const isEndDateValid = computed(() => { + return endDate.value > startDate.value; +}); + +/** + * Creates and saves a challenge based on input from the user. + */ +const handleNext = async () => { + try { + await ChallengeService.addChallenge('challengeDto', title.value, description.value, startDate.value, endDate.value, "EASY", "IN_PROGRESS"); + await saveNext(); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorAddChallenge(error); + } +}; + +/** + * Fetches the new challenge and displays it. + * Also checks if the challenge was saved correctly. + */ +const saveNext = async () => { + try { + challenges.value = await ChallengeService.getChallenges(20, 0); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetChallenges(error); + } + let newestChallenge = challenges.value[0]; + for (let i = 1; i < challenges.value.length; i++) { + if (challenges.value[i].title === title.value) { + newestChallenge = challenges.value[i]; + } + } + emit('challengeComponent', newestChallenge.id); +}; </script> <template> + <div class="createChallenge-container"> <h1 id="title">Opprett ny utfordring!</h1> <div class="container"> - <div> - <h3>Hva ønsker du Ã¥ slutte med?</h3> - <input type="text" placeholder="Skriv inn det du ønsker Ã¥ slutte med her"> + <div class="challenge-and-date-container"> + <div class="input-field-container"> + <label for="title-label"> + <h3>Tittel</h3> + </label> + <input id="title-label" type="text" v-model="title" placeholder="Skriv inn utfordringen din her" maxlength="20"> + </div> + <div class="input-field-container"> + <label for="challenge-description"> + <h3>Beskrivelse</h3> + </label> + <input id="challenge-description" type="text" v-model="description" placeholder="Beskrivelse.." maxlength="255"> </div> <div class="date-container"> - <div> - <h3>Start dato</h3> - <input type="date"> - </div> - <div> - <h3>Slutt dato</h3> - <input type="date"> - </div> + <div> + <label for="start-date-label"> + <h3>Start dato</h3> + </label> + <input id="start-date-label" type="date" v-model="startDate"> + </div> + <div> + <label for="end-date-label"> + <h3>Slutt dato</h3> + </label> + <input id="end-date-label" type="date" v-model="endDate"> + </div> </div> - <button id="create-goal-button" class="btn" @click="$emit('create')">Start utfordring!</button> - <button id="create-goal-button" class="btn" @click="$emit('back')">GÃ¥ tilbake</button> + </div> + <h4>{{ statusMessage }}</h4> + <button id="create-goal-button" class="btn" @click="handleNext" :disabled="!isStartDateValid || !isEndDateValid"> + Lag utfordring! + </button> + <button @click="$emit('back')">GÃ¥ tilbake</button> </div> + </div> </template> <style scoped> +button:disabled { + cursor: not-allowed; +} + +.createChallenge-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: var(--accent-color); + padding: 20px; + border-radius: var(--border-radius-general); + margin-top: 70px; +} + +.input-field-container { + width: 100%; +} + #title { - font-style: italic; - padding-top: 3em; - padding-bottom: 1em; - text-align: center; + padding-bottom: 1em; + text-align: center; } .container { - display: flex; - flex-direction: column; - align-items: center; + display: flex; + flex-direction: column; + align-items: center; + width: 80%; + justify-content: space-around; } - .date-container { - display: flex; - flex-direction: row; - justify-content: space-evenly; + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + gap: 10px; } -#create-goal-button { - background-color: #0056b3; - color: white; - border: none; - width: 60%; - padding: 10px 20px; - font-size: 16px; - border-radius: 20px; - box-shadow: 0 4px #003875; - cursor: pointer; - transition: all 0.3s ease; - margin: 40px auto; -} -#create-goal-button:hover { - background-color: #023366; - box-shadow: 0 6px #003875; +button { + width: 70%; + margin-top: 10px; } -</style> \ No newline at end of file +input[type="text"], +input[type="date"] { + width: 100%; + padding: 10px; + margin: 5px 0; + box-sizing: border-box; + border: 1px solid var(--accent-color); + border-radius: var(--border-radius-general); +} +</style> diff --git a/src/components/goals/CreateChallengeCompetition.vue b/src/components/goals/CreateChallengeCompetition.vue new file mode 100644 index 0000000000000000000000000000000000000000..8a6ac98e7062f5d9cf9e0524798aa049e7407fd1 --- /dev/null +++ b/src/components/goals/CreateChallengeCompetition.vue @@ -0,0 +1,200 @@ +/** +* This script provides methods for creating a shared challenge. +*/ +<script setup> +import { ref, computed, defineEmits } from 'vue'; +import ChallengeService from '@/services/internal/ChallengeService.js'; +import EmailService from '@/services/internal/EmailService'; +import GoalErrorService from '@/services/error/GoalErrorService'; +import SpinningWheelComponent from "@/components/other/SpinningWheelComponent.vue"; + +const title = ref(''); +const email = ref(''); +const description = ref(''); +const startDate = ref(''); +const endDate = ref(''); +const challenges = ref([]); +const emit = defineEmits(['create', 'back']); +const selectedChallenge = ref(null); +const statusMessage = ref(''); +const isSubmitting = ref(false); + + +/** + * Checks if the startDate is not a past date. + */ +const isStartDateValid = computed(() => { + const today = new Date().toISOString().slice(0, 10); + return startDate.value >= today; +}); + +/** + * Checks if the endDate comes after the startdate and is not on the same date. + */ +const isEndDateValid = computed(() => { + return endDate.value > startDate.value; +}); + +/** + * Creates and saves a challenge based on input from the user. + */ +const handleNext = async () => { + isSubmitting.value = true; + try { + await ChallengeService.addChallenge('SharedChallengeDto', title.value, description.value, startDate.value, endDate.value, "EASY", "IN_PROGRESS"); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorAddChallenge(error); + } + + await saveNext(); + try { + await EmailService.sendChallengeCode(email.value, selectedChallenge.value.sharedChallengeId); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorSendChallengeCode(error); + } + statusMessage.value = 'Email sent successfully!'; + emit('goalSharedComponent', selectedChallenge.value.id); + isSubmitting.value = false; +}; + +/** + * Fetches the new challenge and displays it. + * Also checks if the challenge was saved correctly. + */ +const saveNext = async () => { + try { + challenges.value = await ChallengeService.getChallenges(20, 0); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetChallenges(error); + } + let newestChallenge = challenges.value[0]; + for (let i = 1; i < challenges.value.length; i++) { + if (challenges.value[i].title === title.value) { + newestChallenge = challenges.value[i]; + } + } + try { + selectedChallenge.value = await ChallengeService.getChallenge(newestChallenge.id); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetChallenge(error); + } +}; +</script> + +<template> + <div class="createChallenge-container"> + <h1 id="title">Opprett ny delt utfordring!</h1> + <div class="container"> + <div class="challenge-and-date-container"> + <div class="input-field-container"> + <h3>Tittel</h3> + <input type="text" v-model="title" placeholder="Skriv inn utfordringen din her" maxlength="20"> + </div> + <div class="input-field-container"> + <h3>Beskrivelse</h3> + <input type="text" v-model="description" placeholder="Beskrivelse.." maxlength="255"> + </div> + <div class="input-field-container"> + <h3>Email til venn</h3> + <input type="text" v-model="email" placeholder="Email..."> + </div> + <div class="date-container"> + <div> + <h3>Start dato</h3> + <input type="date" v-model="startDate"> + </div> + <div> + <h3>Slutt dato</h3> + <input type="date" v-model="endDate"> + </div> + </div> + </div> + <h1>{{ statusMessage }}</h1> + <button id="create-goal-button" class="btn" @click="handleNext" :disabled="!isStartDateValid || !isEndDateValid"> + Lag utfordring! + </button> + <button @click="$emit('back')">GÃ¥ tilbake</button> + <spinning-wheel-component :visible="isSubmitting" /> + </div> + </div> +</template> + + +<style scoped> +.popup { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + padding: 20px; + background-color: white; + border: 2px solid #ccc; + z-index: 10; +} + +.overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + z-index: 5; +} + + +button:disabled { + cursor: not-allowed; +} + +.createChallenge-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: var(--accent-color); + padding: 20px; + border-radius: var(--border-radius-general); + margin-top: 70px; +} + +.input-field-container { + width: 100%; +} + +#title { + padding-bottom: 1em; + text-align: center; +} + +.container { + display: flex; + flex-direction: column; + align-items: center; + width: 80%; + justify-content: space-around; +} + +.date-container { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + gap: 10px; +} + +button { + width: 70%; + margin-top: 10px; +} + +input[type="text"], +input[type="date"] { + width: 100%; + padding: 10px; + margin: 5px 0; + box-sizing: border-box; + border: 1px solid var(--accent-color); + border-radius: var(--border-radius-general); +} +</style> diff --git a/src/components/goals/CreateGoal.vue b/src/components/goals/CreateGoal.vue new file mode 100644 index 0000000000000000000000000000000000000000..0df29a40405de81d9707e68f4935fd51e8a01e14 --- /dev/null +++ b/src/components/goals/CreateGoal.vue @@ -0,0 +1,152 @@ +/** +* This script provides methods for creating a goal. +*/ +<script setup> +import { ref, computed } from 'vue'; +import GoalService from '@/services/internal/GoalService.js'; +import { defineEmits } from 'vue'; +import GoalErrorService from '@/services/error/GoalErrorService'; + +const statusMessage = ref(''); +const title = ref(''); +const startDate = ref(''); +const endDate = ref(''); +const amount = ref(0); +const emit = defineEmits(['next', 'back']); + +/** + * Checks if the startDate is not a past date. + */ +const isStartDateValid = computed(() => { + const today = new Date().toISOString().slice(0, 10); + return startDate.value >= today; +}); + +/** + * Checks if the endDate comes after the startdate and is not on the same date. + */ +const isEndDateValid = computed(() => { + return endDate.value > startDate.value; +}); + +/** + * Creates and saves a challenge based on input from the user. + * Fetches the new challenge and displays it. + */ +const handleNext = async () => { + try { + const goal = await GoalService.addGoal(title.value, amount.value, 3, startDate.value, endDate.value); + emit('goalComponent', goal.id); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorAddGoal(error); + } +}; + +/** + * Limits the number of digits for the goal amount to 8. + */ + const limitDigits = () => { + if (amount.value && amount.value.toString().length > 6) { + amount.value = parseFloat(amount.value.toString().slice(0, 6)); + } +}; + +</script> + +<template> + <div class="createChallenge-container"> + <h1 id="title" data-cy="title">Opprett nytt sparemÃ¥l!</h1> + <div class="container"> + <div class="challenge-and-date-container"> + <div data-cy="goal-title-container"> + <h3>Hva sparer du til?</h3> + <input type="text" v-model="title" placeholder="Skriv inn mÃ¥let ditt her" data-cy="goal-title-input" maxlength="20"> + </div> + <div data-cy="goal-amount-container"> + <h3>Hvor mye koster mÃ¥let?</h3> + <input type="number" v-model="amount" placeholder="Skriv inn beløpet her" data-cy="goal-amount-input" @input="limitDigits"> + </div> + <div class="date-container" data-cy="date-container"> + <div data-cy="start-date-container"> + <h3>Start dato</h3> + <input type="date" v-model="startDate" data-cy="start-date-input"> + </div> + <div data-cy="end-date-container"> + <h3>Slutt dato</h3> + <input type="date" v-model="endDate" data-cy="end-date-input"> + </div> + </div> + </div> + <h4>{{ statusMessage }}</h4> + <button id="create-goal-button" class="btn" @click="handleNext" :disabled="!isStartDateValid || !isEndDateValid" + data-cy="create-goal-button"> + Lag sparemÃ¥l! + </button> + <button @click="$emit('back')" data-cy="back-button">GÃ¥ tilbake</button> + </div> + </div> +</template> + + + + + + +<style scoped> +button:disabled { + cursor: not-allowed; +} + +.createChallenge-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: var(--accent-color); + padding: 20px; + border-radius: var(--border-radius-general); + margin-top: 30px; +} + +.input-field-container { + width: 100%; +} + +#title { + font-style: italic; + padding-bottom: 1em; + text-align: center; +} + +.container { + display: flex; + flex-direction: column; + align-items: center; + width: 80%; + justify-content: space-around; +} + +.date-container { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + gap: 10px; +} + +button { + width: 70%; + margin-top: 10px; +} + +input[type="text"], +input[type="date"], +input[type="number"] { + width: 100%; + padding: 10px; + margin: 5px 0; + box-sizing: border-box; + border: 1px solid var(--accent-color); + border-radius: var(--border-radius-general); +} +</style> diff --git a/src/components/goals/CreateGoals.vue b/src/components/goals/CreateGoals.vue deleted file mode 100644 index 11c6e0c916ce76d7efdafea00d4e3ef79d1116ce..0000000000000000000000000000000000000000 --- a/src/components/goals/CreateGoals.vue +++ /dev/null @@ -1,71 +0,0 @@ -<script setup> - -</script> - -<template> - <h1 id="title">Opprett nytt sparemÃ¥l!</h1> - <div class="container"> - <div> - <h3>Hva sparer du til?</h3> - <input type="text" placeholder="Skriv inn mÃ¥let ditt her"> - </div> - <div> - <h3>Hvor mye koster mÃ¥let?</h3> - <input type="number" placeholder="Skriv inn beløpet her"> - </div> - <div class="date-container"> - <div> - <h3>Start dato</h3> - <input type="date"> - </div> - <div> - <h3>Slutt dato</h3> - <input type="date"> - </div> - </div> - <button id="create-goal-button" class="btn" @click="$emit('create')">Lag sparemÃ¥l!</button> - <button id="create-goal-button" class="btn" @click="$emit('back')">GÃ¥ tilbake</button> - </div> -</template> - - -<style scoped> -#title { - font-style: italic; - padding-top: 3em; - padding-bottom: 1em; - text-align: center; -} - -.container { - display: flex; - flex-direction: column; - align-items: center; -} - - -.date-container { - display: flex; - flex-direction: row; - justify-content: space-evenly; -} - -#create-goal-button { - background-color: #0056b3; - color: white; - border: none; - width: 60%; - padding: 10px 20px; - font-size: 16px; - border-radius: 20px; - box-shadow: 0 4px #003875; - cursor: pointer; - transition: all 0.3s ease; - margin: 40px auto; -} -#create-goal-button:hover { - background-color: #023366; - box-shadow: 0 6px #003875; -} - -</style> \ No newline at end of file diff --git a/src/components/goals/CreateSharedGoal.vue b/src/components/goals/CreateSharedGoal.vue new file mode 100644 index 0000000000000000000000000000000000000000..eb268aeb9202b297a827fb2edb5d40f7916692c8 --- /dev/null +++ b/src/components/goals/CreateSharedGoal.vue @@ -0,0 +1,147 @@ +/** +* This script provides methods for creating a goal. +*/ +<script setup> +import { ref, computed } from 'vue'; +import GoalService from '@/services/internal/GoalService.js'; +import { defineEmits } from 'vue'; +import GoalErrorService from '@/services/error/GoalErrorService'; + +const statusMessage = ref(''); +const title = ref(''); +const startDate = ref(''); +const endDate = ref(''); +const amount = ref(0); +const emit = defineEmits(['next', 'back']); + +/** + * Checks if the startDate is not a past date. + */ +const isStartDateValid = computed(() => { + const today = new Date().toISOString().slice(0, 10); + return startDate.value >= today; +}); + +/** + * Checks if the endDate comes after the startdate and is not on the same date. + */ +const isEndDateValid = computed(() => { + return endDate.value > startDate.value; +}); + +/** + * Creates and saves a challenge based on input from the user. + * Fetches the new challenge and displays it. + */ +const handleNext = async () => { + try { + const goal = await GoalService.addGoal(title.value, amount.value, 3, startDate.value, endDate.value); + emit('goalComponent', goal.id); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorAddGoal(error); + } +}; + +/** + * Limits the number of digits for the goal amount to 8. + */ + const limitDigits = () => { + if (amount.value && amount.value.toString().length > 6) { + amount.value = parseFloat(amount.value.toString().slice(0, 6)); + } +}; +</script> +<template> + <div class="createChallenge-container"> + <h1 id="title">Opprett nytt delt sparemÃ¥l!</h1> + <div class="container"> + <div class="challenge-and-date-container"> + <div> + <h3>Hva sparer du til?</h3> + <input type="text" v-model="title" placeholder="Skriv inn mÃ¥let ditt her" maxlength="20"> + </div> + <div> + <h3>Hvor mye koster mÃ¥let?</h3> + <input type="number" v-model="amount" placeholder="Skriv inn beløpet her" @input="limitDigits"> + </div> + <div class="date-container"> + <div> + <h3>Start dato</h3> + <input type="date" v-model="startDate"> + </div> + <div> + <h3>Slutt dato</h3> + <input type="date" v-model="endDate"> + </div> + </div> + </div> + <h4>{{ statusMessage }}</h4> + <button id="create-goal-button" class="btn" @click="handleNext" :disabled="!isStartDateValid || !isEndDateValid"> + Lag sparemÃ¥l! + </button> + <button @click="$emit('back')">GÃ¥ tilbake</button> + </div> + </div> +</template> + + + + + +<style scoped> +button:disabled { + cursor: not-allowed; +} + +.createChallenge-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: var(--accent-color); + padding: 20px; + border-radius: var(--border-radius-general); + margin-top: 70px; +} + +.input-field-container { + width: 100%; +} + +#title { + padding-bottom: 1em; + text-align: center; +} + +.container { + display: flex; + flex-direction: column; + align-items: center; + width: 80%; + justify-content: space-around; +} + +.date-container { + display: flex; + flex-direction: row; + justify-content: space-between; + width: 100%; + gap: 10px; +} + +button { + width: 70%; + margin-top: 10px; +} + +input[type="text"], +input[type="date"], +input[type="number"] { + width: 100%; + padding: 10px; + margin: 5px 0; + box-sizing: border-box; + border: 1px solid var(--accent-color); + border-radius: var(--border-radius-general); +} +</style> \ No newline at end of file diff --git a/src/components/goals/GoalComponent.vue b/src/components/goals/GoalComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..0bf38fb12b7cde4b2023726171969898b1527f36 --- /dev/null +++ b/src/components/goals/GoalComponent.vue @@ -0,0 +1,149 @@ +/** +* Script for fetching and calculating data for displaying a goal. +*/ +<script setup> +import {ref, defineProps, onMounted} from 'vue'; +import RoadComponent from './RoadComponent.vue'; +import RoadUsersComponent from './RoadUsersComponent.vue'; +import GoalService from '@/services/internal/GoalService'; +import GoalContributeComponent from './GoalContributeComponent.vue'; +import GoalErrorService from '@/services/error/GoalErrorService'; + +const props = defineProps({ + goalId: Number, + goalTitle: String, + goalState: String +}); + +const statusMessage = ref(''); +const componentKey = ref(0); +const isContentLoaded = ref(false); +const currentGoalId = ref(null); +const selectedGoal = ref(null); +const goalName = ref(''); +const daysLost = ref(0); +const amountSaved = ref(0); +const amountPerDay = ref(0); +const amountOfCircles = ref(0); +const currentCircle = ref(0); +const startDate = ref(0); +const endDate = ref(0); +const state = ref(""); +const totalAmount = ref(0); +const joinCode = ref(0); + +onMounted(async () => { + currentGoalId.value = props.goalId; + goalName.value = props.goalTitle; + state.value = props.goalState; + await loadContent(); + isContentLoaded.value = true; +}); + +/** + * Fetches a goal based on currentGoalId and calcualtes display values. + */ +const loadContent = async () => { + if (currentGoalId.value) { + try { + selectedGoal.value = await GoalService.getGoal(currentGoalId.value); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetGoal(error); + } + try { + if (selectedGoal.value) { + goalName.value = selectedGoal.value.title; + daysLost.value = 3 - selectedGoal.value.lives; + startDate.value = new Date(selectedGoal.value.startDate); + endDate.value = new Date(selectedGoal.value.endDate); + totalAmount.value = selectedGoal.value.totalAmount; + joinCode.value = selectedGoal.value.joinCode; + const diffTime = Math.abs(endDate.value - startDate.value); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + amountOfCircles.value = diffDays; + amountSaved.value = await GoalService.getAmountSaved(currentGoalId.value, goalName.value, state.value); + amountPerDay.value = Math.floor(selectedGoal.value.totalAmount / getDaysBetweenDates(new Date(selectedGoal.value.startDate), new Date(selectedGoal.value.endDate))); + currentCircle.value = Math.floor((amountSaved.value / amountPerDay.value) - 1); + forceUpdate(); + } + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetAmountSaved(error); + } + } +}; + +/** + * Forces the component to refresh. + */ +const forceUpdate = () => { + componentKey.value++; +}; + +/** + * Returns the amount of days between to days. + * + * @param {*} startDate Date from. + * @param {*} endDate Date to. + */ +const getDaysBetweenDates = (startDate, endDate) => { + const start = new Date(startDate); + const end = new Date(endDate); + const millisecondsPerDay = 1000 * 60 * 60 * 24; + return Math.round((end - start) / millisecondsPerDay); +} +</script> + +<template> + <div :key="componentKey"> + <div class="button-container"> + <div class="button-box"> + <button @click="$emit('back')" data-cy="back-button">GÃ¥ tilbake</button> + </div> + <div class="button-box" data-cy="code-box"> + <h2>Kode: {{ joinCode }}</h2> + </div> + <div class="button-box"> + <RoadUsersComponent v-if="isContentLoaded" :goal-id="currentGoalId"/> + <GoalContributeComponent v-if="isContentLoaded" @refresh="loadContent" :goal-id="currentGoalId"/> + </div> + </div> + <h4>{{ statusMessage }}</h4> + <RoadComponent v-if="isContentLoaded" :amount-saved="amountSaved" :amount-per-day="amountPerDay" + :goal-name="goalName" :amount-of-circles="amountOfCircles" :current-circle="currentCircle" + :start-date="startDate" + :end-date="endDate" :total-amount="totalAmount" :goal-id="currentGoalId" @back="$emit('back')"/> + </div> +</template> + + +<style scoped> +.button-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 30px; + margin-top: 20px; + margin-bottom: 20px; +} + + +.button-box { + background-color: var(--accent-color); + border-radius: var(--border-radius-general); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + width: 70%; + text-align: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + + +button { + width: 70%; + margin-top: 10px; +} +</style> diff --git a/src/components/goals/GoalContributeComponent.vue b/src/components/goals/GoalContributeComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..7cf89098a2732e546fc63d5bb0796397048bccb3 --- /dev/null +++ b/src/components/goals/GoalContributeComponent.vue @@ -0,0 +1,80 @@ +/** +* Script to update the amount a user has saved in a specific goal. +*/ +<script setup> +import { defineProps, ref } from 'vue'; +import GoalService from '@/services/internal/GoalService'; +import { defineEmits } from 'vue'; +import GoalErrorService from '@/services/error/GoalErrorService'; + +const emit = defineEmits(['refresh']); +const contribution = ref(''); +const statusMessage = ref(''); + +const props = defineProps({ + goalId: Number +}); + +/** + * If the contribution has a value in the inputfield, updates the contribution amount. + */ +const addContribution = async () => { + if (!contribution.value || isNaN(contribution.value) || contribution.value <= 0) { + return; + } + try { + await GoalService.addContribution(props.goalId, parseFloat(contribution.value)); + contribution.value = ''; + updateAndRefresh(); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorAddContribution(error); + } +}; + +/** + * Refreshes the outer component. + */ +const updateAndRefresh = async () => { + emit('refresh'); +}; + +const limitDigits = () => { + if (contribution.value && contribution.value.toString().length > 6) { + contribution.value = parseFloat(contribution.value.toString().slice(0, 6)); + } +}; +</script> + +<template> + <div> + <label for="contribution"> + Legg til bidrag + </label> + <input id="contribution" data-cy="input-contribution" type="number" v-model="contribution" placeholder="f.eks: 100kr" @input="limitDigits"/> + <h4>{{ statusMessage }}</h4> + <button data-cy="add-button-contribution" @click="addContribution">Legg til!</button> + </div> +</template> + + +<style scoped> +ul { + list-style-type: none; + padding: 0; +} + +li { + margin-bottom: 8px; +} + +input[type="number"] { + margin-right: 10px; + padding: 5px; + width: 80%; +} + +button { + width: 70%; + margin-top: 10px; +} +</style> \ No newline at end of file diff --git a/src/components/goals/GoalsChoice.vue b/src/components/goals/GoalsChoice.vue deleted file mode 100644 index 2a2f90c8243b0023f1fdc66474bdc533c4175a28..0000000000000000000000000000000000000000 --- a/src/components/goals/GoalsChoice.vue +++ /dev/null @@ -1,31 +0,0 @@ -<script setup> -</script> - -<template> - <div> - <button @click="$emit('challenge')">Start en utfordring!</button> - <button @click="$emit('goal')">Start ett sparemÃ¥l!</button> - <button @click="$emit('back')">GÃ¥ tilbake</button> - </div> -</template> - -<style scoped> -button { - background-color: #0056b3; - color: white; - border: none; - width: 60%; - padding: 10px 20px; - font-size: 16px; - border-radius: 20px; - box-shadow: 0 4px #003875; - cursor: pointer; - transition: all 0.3s ease; - margin: 40px auto; -} - -button:hover { - background-color: #023366; - box-shadow: 0 6px #003875; -} -</style> \ No newline at end of file diff --git a/src/components/goals/GoalsMain.vue b/src/components/goals/GoalsMain.vue deleted file mode 100644 index fcf0b15be4eb9fb1bac01cffc5143d4483b12247..0000000000000000000000000000000000000000 --- a/src/components/goals/GoalsMain.vue +++ /dev/null @@ -1,34 +0,0 @@ -<script setup> -</script> - -<template> - <div> - <h3> - Du har ingen sparemÃ¥l eller utfordringer gÃ¥ende akkuratt nÃ¥! - Klikk under for Ã¥ sette opp ett nytt sparemÃ¥l eller lage - deg en ny utfordring! - </h3> - <button @click="$emit('switch')">Kom i gang!</button> - </div> -</template> - -<style scoped> -button { - background-color: #0056b3; - color: white; - border: none; - width: 60%; - padding: 10px 20px; - font-size: 16px; - border-radius: 20px; - box-shadow: 0 4px #003875; - cursor: pointer; - transition: all 0.3s ease; - margin: 40px auto; -} - -button:hover { - background-color: #023366; - box-shadow: 0 6px #003875; -} -</style> diff --git a/src/components/goals/GoalsRoad.vue b/src/components/goals/GoalsRoad.vue deleted file mode 100644 index 5ad124af1956025646240b219c300d70e033e0b7..0000000000000000000000000000000000000000 --- a/src/components/goals/GoalsRoad.vue +++ /dev/null @@ -1,101 +0,0 @@ -<script setup> -import { ref , computed } from 'vue'; - -const amount_of_circles = 30; -const days_lost = ref(2); -const amount_per_day = 100; -const amount_saved = 400; -const goal_name = 'Tur til Spania'; - - - - -const current_circle = computed(() => Math.floor((amount_saved / amount_per_day) - 1)); - -const circlePositions = ref([]); -circlePositions.value.push(Math.floor(Math.random() * 100)); -for (let i = 1; i < amount_of_circles; i++) { - let lastPosition = circlePositions.value[i - 1]; - let newPosition = lastPosition + (Math.random() * 40 - 20); - circlePositions.value.push(newPosition); -} - -const getCircleStyle = (index) => ({ - left: `${circlePositions.value[index]}px`, - backgroundColor: index <= current_circle.value ? 'green' : 'grey', - position: 'relative' -}); - -const getImageSrc = () => days_lost.value >= 3 - ? require("@/assets/img/pigCry.gif") - : require("@/assets/img/pigWave.gif"); -</script> - -<template> -<div class="title"> - <h1>{{ goal_name }}</h1><h2>Total saved: {{ amount_saved }}</h2> -</div> - <div class="outer"> - <div style="position: relative;"> - <div v-for="index in amount_of_circles" :key="index" class="circle" :style="getCircleStyle(index - 1)"> {{ amount_per_day }} - <div v-if="index - 1 === current_circle" class="content-container"> - <img :src="getImageSrc()" alt="Pig" class="pig-image"> - <div class="small-circles-container"> - <div v-for="n in 3" :key="n" class="small-circle" :style="{ backgroundColor: n <= days_lost ? 'gray' : 'red' }"></div> - </div> - </div> - </div> - </div> - </div> -</template> - -<style scoped> - -.title { - display: flex; - flex-direction: column; - gap: 0; - align-items: center; - justify-content: center; - margin: 10px; -} - -.outer { - margin: 30px; -} - -.circle { - width: 50px; - height: 50px; - border-radius: 50%; - margin: 10px 0; - display: flex; - align-items: center; - justify-content: center; -} -.content-container { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - display: flex; - flex-direction: column; - align-items: center; - margin-left: 75px; -} -.pig-image { - width: 60px; - height: 60px; -} -.small-circles-container { - display: flex; - justify-content: center; - margin-top: 5px; -} -.small-circle { - width: 10px; - height: 10px; - border-radius: 50%; - margin: 0 5px; -} -</style> diff --git a/src/components/goals/JoinChallengeCompetition.vue b/src/components/goals/JoinChallengeCompetition.vue new file mode 100644 index 0000000000000000000000000000000000000000..ad4ab7291fa71631880377d5bb761b2ccd964b39 --- /dev/null +++ b/src/components/goals/JoinChallengeCompetition.vue @@ -0,0 +1,82 @@ +/** +* Script to add a user to an existing shared challenge. +*/ +<script setup> +import { ref, defineEmits } from 'vue'; +import ChallengeService from '@/services/internal/ChallengeService'; +import GoalErrorService from '@/services/error/GoalErrorService'; + +const statusMessage = ref(''); +const inputData = ref(''); +const selectedChallenge = ref(null); +const challenges = ref([]); +const emit = defineEmits(['create', 'back']); + +/** + * Takes the inputted code from the user and tries to join a challenge. + */ +const saveAndEmitInput = async () => { + try { + const result = await ChallengeService.joinChallenge(inputData.value); + await saveNext(); + emit('goalSharedComponent', selectedChallenge.value.id); + console.log(result.data); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorJoinChallenge(error); + } +}; + +/** + * Finds the newly joined challenge and loads it. + */ +const saveNext = async () => { + try { + challenges.value = await ChallengeService.getChallenges(20, 0); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetChallenges(error); + } + let newestChallenge = challenges.value[0]; + for (let i = 1; i < challenges.value.length; i++) { + if (challenges.value[i].joinCode === inputData.value) { + newestChallenge = challenges.value[i]; + } + } + try { + selectedChallenge.value = await ChallengeService.getChallenge(newestChallenge.id); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetChallenge(error); + } +}; +</script> + +<template> + <div class="buttons"> + <input v-model="inputData" type="text" placeholder="Skriv inn kode her!" class="input-text" /> + <h4>{{ statusMessage }}</h4> + <button @click="saveAndEmitInput">Bli med i utfordring!</button> + <button @click="$emit('back')">GÃ¥ tilbake</button> + </div> +</template> + +<style scoped> +.buttons { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + margin-top: 100px; +} + +.input-text { + width: 60%; + padding: 10px 20px; + margin-bottom: 20px; + border-radius: var(--border-radius-general); + border: 2px solid var(--dark-color); +} + +button { + width: 70%; + margin-top: 10px; +} +</style> diff --git a/src/components/goals/JoinSharedGoal.vue b/src/components/goals/JoinSharedGoal.vue new file mode 100644 index 0000000000000000000000000000000000000000..5eeb1ebfe42dda5af9ca076fccaa8fa9b1ddba7c --- /dev/null +++ b/src/components/goals/JoinSharedGoal.vue @@ -0,0 +1,57 @@ +/** +* Scrpit for adding a user to an already existing goal based on a joinCode. +*/ +<script setup> +import { ref, defineEmits } from 'vue'; +import GoalService from '@/services/internal/GoalService'; +import GoalErrorService from '@/services/error/GoalErrorService'; + +const statusMessage = ref(''); +const inputData = ref(''); +const emit = defineEmits(['create', 'back']); + +/** + * Checks if the inputted code is valid and joins the correct challenge. + * Loads the challenge and routes to it. + */ +const saveAndEmitInput = async () => { + try { + const goal = await GoalService.joinGoal(inputData.value); + emit('goalComponent', goal.id); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorJoinGoal(error); + } +}; +</script> + +<template> + <div class="buttons"> + <input v-model="inputData" type="text" placeholder="Skriv inn kode her!" class="input-text" /> + <h4>{{ statusMessage }}</h4> + <button @click="saveAndEmitInput">Bli med i sparemÃ¥l!</button> + <button @click="$emit('back')">GÃ¥ tilbake</button> + </div> +</template> + +<style scoped> +.buttons { + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + margin-top: 100px; +} + +.input-text { + width: 60%; + padding: 10px 20px; + margin-bottom: 20px; + border-radius: var(--border-radius-general); + border: 2px solid var(--dark-color); +} + +button { + width: 70%; + margin-top: 10px; +} +</style> diff --git a/src/components/goals/MainButtons.vue b/src/components/goals/MainButtons.vue new file mode 100644 index 0000000000000000000000000000000000000000..76f126ac3fdbcbcabec0d016d0949dfd9d254e49 --- /dev/null +++ b/src/components/goals/MainButtons.vue @@ -0,0 +1,63 @@ +<script setup> +</script> + +<template> + <div class="main-button-container"> + <div class="button-container"> + <div class="button-box"> + <img src="@/assets/img/pluss_icon.png" alt="Legg til sparemÃ¥l og utfordringer icon"> + <button @click="$emit('add')" data-cy="add-goal-button">Legg til sparemÃ¥l og utfordringer</button> + </div> + <div class="button-box"> + <img src="@/assets/img/goalIcon.png" alt="Oversikt over sparemÃ¥l og utfordringer icon"> + <button @click="$emit('overview')" data-cy="overview-goals-button">Oversikt over sparemÃ¥l og utfordringer + </button> + </div> + </div> + </div> +</template> + + +<style scoped> +.main-button-container { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; +} +.button-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 30px; + box-sizing: border-box; + width: 90%; +} + +.button-box { + background-color: var(--accent-color); + border-radius: var(--border-radius-general); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + margin: 10px; + width: 70%; + text-align: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +button { + width: 70%; + white-space: normal; +} + +img { + width: 10%; + height: auto; + margin-bottom: 20px; +} +</style> diff --git a/src/components/goals/OverviewComponent.vue b/src/components/goals/OverviewComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..b7eb6ddb9ec603a0b41efde89a24c5b39dc3629f --- /dev/null +++ b/src/components/goals/OverviewComponent.vue @@ -0,0 +1,386 @@ +/** +* Component to view all goals and challenges under a user. +* Also displays status on all goals and challenges. +*/ +<script setup> +import { ref, computed, onMounted } from 'vue'; +import ChallengeService from '@/services/internal/ChallengeService.js'; +import GoalService from '@/services/internal/GoalService.js'; +import { defineEmits } from 'vue'; +import GoalErrorService from '@/services/error/GoalErrorService'; + +const emit = defineEmits(['challengeComponent', 'goalComponent', 'goalCompetitionComponent', 'goalSharedComponent']); + +const statusMessage = ref(''); +const challenges = ref([]); +const detailedChallenges = ref([]); +const currentDate = new Date(); +const selectedChallenge = ref(null); +const goals = ref([]); +const detailedGoals = ref([]); + +/** + * Routes to a selected challenge based on a challengeId. + * @param {*} id challengeId. + */ +const saveChallengeId = async (id) => { + try { + selectedChallenge.value = await ChallengeService.getChallenge(id); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetChallenge(error); + } + if (selectedChallenge.value.challengeType == 'SharedChallengeDto') { + emit('challengeCompetitionComponent', id, selectedChallenge.value.sharedChallengeId); + } else { + emit('challengeComponent', id); + } +}; + +/** + * Routes to a selected goal based on a goalId. + * @param {*} id goalId. + */ +const saveGoalId = async (id) => { + emit('goalComponent', id); +}; + + + +onMounted(async () => { + try { + challenges.value = await ChallengeService.getChallenges(15, 0); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetChallenges(error); + } + detailedChallenges.value = await Promise.all( + challenges.value.map(async (challenge) => { + try { + return await ChallengeService.getChallenge(challenge.id); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetChallenge(error); + } + }) + ); + + try { + goals.value = await GoalService.getGoals(15, 0); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorGetGoals(error); + } + try { + detailedGoals.value = await Promise.all( + goals.value.map(async (goal) => { + const detailedGoal = await GoalService.getGoal(goal.id); + const amountSaved = await GoalService.getAmountSaved(goal.id, goal.name, goal.state); + + return { + ...detailedGoal, + currentAmountSaved: amountSaved + }; + }) + ); + } catch (error) { + statusMessage.value = 'Error: finner ingen sparemÃ¥l under bruker.'; + } +}); + +/** + * Sorts all currently goiing challenges into a list. + */ +const currentChallenges = computed(() => { + return detailedChallenges.value.filter(challenge => { + const endDate = new Date(challenge.endDate); + return endDate >= currentDate; + }); +}); + +/** + * Sorts all previouslt going challenges into a list. + */ +const previousChallenges = computed(() => { + return detailedChallenges.value.filter(challenge => { + const endDate = new Date(challenge.endDate); + return endDate < currentDate; + }); +}); + +/** + * Sorts all currently going goals into a list; + */ +const currentGoals = computed(() => { + return detailedGoals.value.filter(goal => { + const endDate = new Date(goal.endDate); + return endDate >= currentDate; + }); +}); + +/** + * Sorts all previously going goals into a list; + */ +const previousGoals = computed(() => { + return detailedGoals.value.filter(goal => { + const endDate = new Date(goal.endDate); + return endDate < currentDate; + }); +}); + +/** + * Gets the amount of days inbetween two dates. + * + * @param {*} startDate From data. + * @param {*} endDate To date. + */ +const getDaysBetweenDates = (startDate, endDate) => { + const start = new Date(startDate); + const end = new Date(endDate); + const millisecondsPerDay = 1000 * 60 * 60 * 24; + return Math.round((end - start) / millisecondsPerDay); +} + +const translateState = (state) => { + if(state == "FINISHED") { + return "Fullført"; + } else { + return "Mislykket" + } +} + +const translateProgress = (progress) => { + if(progress == "COMPLETED") { + return "Fullført"; + } else { + return "Mislykket" + } +} +</script> + +<template> + <div class="button-container"> + <div class="button-box"> + <button @click="$emit('back')">GÃ¥ tilbake</button> + <button @click="$emit('add')">Legg til sparemÃ¥l og utfordringer</button> + <h4>{{ statusMessage }}</h4> + </div> + </div> + <div class="button-container" id="complete-site"> + <div class="button-box"> + <h2>Utfordringer:</h2> + + <div class="challenges" v-if="currentChallenges.length"> + <h1>NÃ¥værende</h1> + <div class="challenge" + role="button" + tabindex="0" + @keydown.enter="challenge.progress !== 'FAILED' + && challenge.progress !== 'COMPLETED' ? saveChallengeId(challenge.id) : null" + id="inner-box" + v-for="challenge in currentChallenges" :key="challenge.id" + @click="challenge.progress !== 'FAILED' + && challenge.progress !== 'COMPLETED' ? saveChallengeId(challenge.id) : null"> + <h1>{{ challenge.title }}</h1> + <div class="progress"> + <span v-if="challenge.progress === 'FAILED' || challenge.progress === 'COMPLETED'" + :class="{ 'completed': challenge.progress === 'COMPLETED', 'failed': challenge.progress === 'FAILED' }"> + Status: {{ translateProgress(challenge.progress) }} + </span> + <span v-else> + Status: {{ + (getDaysBetweenDates(challenge.startDate, new Date())) + '/' + (getDaysBetweenDates(challenge.startDate, + challenge.endDate)) + }} + </span> + </div> + </div> + </div> + + + <div class="challenges" v-if="previousChallenges.length"> + <h1>Tidligere</h1> + <div class="challenge" + role="button" + tabindex="0" + @keydown.enter="challenge.progress !== 'FAILED' && challenge.progress !== 'COMPLETED' ? saveChallengeId(challenge.id) : null" + id="inner-box" + v-for="challenge in previousChallenges" + :key="challenge.id" + @click="challenge.progress !== 'FAILED' && challenge.progress !== 'COMPLETED' ? saveChallengeId(challenge.id) : null"> + <h1>{{ challenge.title }}</h1> + <span class="progress"> + <span v-if="challenge.progress === 'FAILED' || challenge.progress === 'COMPLETED'" + :class="{ 'completed': challenge.progress === 'COMPLETED', 'failed': challenge.progress === 'FAILED' }"> + Status: {{ translateProgress(challenge.progress) }} + </span> + <span v-else> + Status: {{ + (getDaysBetweenDates(challenge.startDate, new Date())) + '/' + (getDaysBetweenDates(challenge.startDate, + challenge.endDate)) + }} + </span> + </span> + </div> + </div> + <h1></h1> + </div> + + <div class="button-box"> + <h2>SparemÃ¥l:</h2> + + + <div class="challenges" v-if="currentGoals.length"> + <h1>NÃ¥værende</h1> + <div class="challenge" + role="button" + tabindex="0" + @keydown.enter="goal.state !== 'FAILED' && goal.state !== 'FINISHED' ? saveGoalId(goal.id) : null" + id="inner-box" + v-for="goal in currentGoals" :key="goal.id" + @click="goal.state !== 'FAILED' && goal.state !== 'FINISHED' ? saveGoalId(goal.id) : null"> + <h1>{{ goal.title }}</h1> + <div class="progress"> + <span v-if="goal.state === 'FAILED' || goal.state === 'FINISHED'" + :class="{ 'completed': goal.state === 'FINISHED', 'failed': goal.state === 'FAILED' }"> + Status: {{ translateState(goal.state) }} + </span> + <span v-else> + Status: {{ goal.currentAmountSaved + '/' + goal.totalAmount }} + </span> + </div> + </div> + </div> + + + <div class="challenges" v-if="previousGoals.length"> + <h1>Tidligere</h1> + <div class="challenge" + id="inner-box" + role="button" + tabindex="0" + @keydown.enter="goal.state !== 'FAILED' && goal.state !== 'FINISHED' ? saveGoalId(goal.id) : null" + v-for="goal in previousGoals" + :key="goal.id" + @click="goal.state !== 'FAILED' && goal.state !== 'FINISHED' ? saveGoalId(goal.id) : null"> + <h1>{{ goal.title }}</h1> + <span class="progress"> + <span v-if="goal.state === 'FAILED' || goal.state === 'FINISHED'" + :class="{ 'completed': goal.state === 'FINISHED', 'failed': goal.state === 'FAILED' }"> + Status: {{ translateState(goal.state) }} + </span> + <span v-else> + Status: {{ goal.currentAmountSaved + '/' + goal.totalAmount }} + </span> + </span> + </div> + </div> + </div> + </div> +</template> + +<style scoped> +#complete-site { + box-sizing: border-box; + margin-bottom: 2.5%; +} +#inner-box { + background-color: var(--white-general); +} +.challenge-container { + width: 100%; + max-height: 100vh; + overflow-y: auto; + padding: 5% 5% 20px 5%; + box-sizing: border-box; + scrollbar-width: none; +} + +.button-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin-top: 20px; +} + +.button-box { + background-color: var(--accent-color); + border-radius: var(--border-radius-general); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + width: 70%; + text-align: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + margin-bottom: 30px; +} + +.completed { + color: var(--green-text); + background-color: white; +} + +.failed { + color: var(--error-text); +} + +.challenge-container::-webkit-scrollbar { + display: none; +} + +.challenge { + border: 1px solid var(--dark-color); + width: 100%; + padding: 10px 20px; + font-size: 16px; + border-radius: var(--border-radius-general); + box-shadow: 0 4px var(--middle-color); + margin-top: 10px; + margin-bottom: 10px; +} + +.challenges { + width: 70%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.challenge:hover, .challenge:focus { + background-color: var(--middle-color); + box-shadow: 0 6px var(--light-color); +} + +.indicator { + height: 20px; + width: 20px; + border-radius: 50%; + display: inline-block; +} + +p { + display: block; + color: black; + font-size: 1rem; + word-wrap: break-word; + overflow-wrap: break-word; + word-break: normal; +} + +h1 { + text-align: center; + font-size: large; + margin-bottom: 20px; +} + +button { + width: 70%; + margin-top: 10px; +} + +@media (max-width: 800px) { + #complete-site { + margin-bottom: 80px; + } +} +</style> diff --git a/src/components/goals/ProgressComponent.vue b/src/components/goals/ProgressComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..1eac01e70684caaf4597d4efcdf702f9ea3107b6 --- /dev/null +++ b/src/components/goals/ProgressComponent.vue @@ -0,0 +1,191 @@ +/** +* Component to show the progress of a challenge. +*/ +<script setup> +import { ref, defineProps, onMounted, computed } from 'vue'; + +const title = ref(''); +const startDate = ref(0); +const endDate = ref(0); +const difficulty = ref(''); +const description = ref(''); +const state = ref(''); +const progress = ref(0); + +const props = defineProps({ + title: String, + startDate: Number, + endDate: Number, + difficulty: String, + description: String, + state: String +}); + + +onMounted(async () => { + title.value = props.title; + startDate.value = props.startDate; + endDate.value = props.endDate; + difficulty.value = props.difficulty; + description.value = props.description; + state.value = props.state; + await updateProgress(); +}); + +/** + * Formats a date to only include month, day, and year. + * + * @param {*} dateString original date format. + */ +function formatDate(dateString) { + const date = new Date(dateString); + const options = { month: 'short', day: 'numeric', year: 'numeric' }; + return date.toLocaleDateString('no-NO', options); +} + +/** + * Updates the progress of the progressbar by fetching the progress value. + */ +const updateProgress = async () => { + const today = new Date(); + progress.value = getProgress(startDate.value, endDate.value, today); +}; + +/** + * Checks if a challenge is loaded. + */ +const isDataEmpty = computed(() => { + return !props.title && props.startDate === 0 && props.endDate === 0 && !props.difficulty && !props.description && !props.state; +}); + +/** + * Calculates the progress value based on todays date. + * + * @param {*} start Startdate of the challenge. + * @param {*} end Enddate of the challenge. + * @param {*} today Todays date. + */ +const getProgress = (start, end, today) => { + const startDt = new Date(start); + const endDt = new Date(end); + const todayDt = new Date(today); + + if (todayDt < startDt) { + return 0; + } else if (todayDt > endDt) { + return 100; + } else { + const totalDuration = endDt - startDt; + const timeElapsed = todayDt - startDt; + return Math.floor((timeElapsed / totalDuration) * 100); + } +}; +</script> + +<template> + <div v-if="!isDataEmpty" class="outer"> + <div class="header"> + <h1 class="main-title">{{ title }}</h1> + <p class="description">Beskrivelse: {{ description }}</p> + </div> + <div class="progress-bar"> + <div class="range" :style="`--p:${progress}`"></div> + </div> + <div class="date-display"> + <span class="start-date">{{ formatDate(startDate) }}</span> + <span class="divider">|</span> + <span class="end-date">{{ formatDate(endDate) }}</span> + </div> + </div> + <h1 v-else>Venter pÃ¥ att utfordringen skal bli godtatt :D</h1> +</template> + +<style scoped> +.progress-bar { + width: 100%; +} +.outer { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + justify-content: center; + background-color: var(--accent-color); + padding: 20px; + border-radius: var(--border-radius-general); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); +} + +.header { + text-align: center; +} + +.main-title { + margin: 0; + font-size: 24px; + color: var(--black-general); + font-weight: bold; +} + +.description { + margin-top: 5px; + color: var(--dark-color); + font-size: 16px; +} + +.range { + position: relative; + background-color: var(--black-text); + height: 30px; +} + +.range:before { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 100%; + background-color: #5cf580; + z-index: 0; + animation: load 1.0s forwards linear; +} + +.range:after { + content: attr(data-progress) ''; + color: var(--black-text); + position: absolute; + left: 5%; + top: 50%; + transform: translateY(-50%) skewX(-30deg); + z-index: 1; +} + +.date-display { + display: flex; + justify-content: space-between; + width: 100%; + padding: 0 20px; + font-size: 16px; + color: var(--black-text); +} + +.start-date, +.end-date { + font-weight: bold; +} + +.divider { + color: var(--black-general); +} + +h1 { + padding-top: 20px; + padding-bottom: 20px; +} + +@keyframes load { + to { + width: calc(var(--p) * 1%); + } +} +</style> diff --git a/src/components/goals/RoadComponent.vue b/src/components/goals/RoadComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..e1ab1292cdeda7dd70536313ea624262a620d182 --- /dev/null +++ b/src/components/goals/RoadComponent.vue @@ -0,0 +1,428 @@ +/** +* Component to visualise the progress of a goal. +*/ +<script setup> +import { ref, defineProps, onMounted, computed } from 'vue'; +import GoalService from '@/services/internal/GoalService'; +import BadgeService from '@/services/internal/BadgeService'; +import GoalErrorService from '@/services/error/GoalErrorService'; + +const statusMessage = ref(''); +const days_lost = ref(0); +const amount_saved = ref(0); +const amount_per_day = ref(0); +const goal_name = ref(0); +const amount_of_circles = ref(0); +const current_circle = ref(0); +const circlePositions = ref([]); +const start_date = ref(0); +const end_date = ref(0); +const total_amount = ref(0); +const goal_id = ref(0); +const showFailed = ref(false); +const showFinished = ref(false); + +const props = defineProps({ + amountSaved: Number, + amountPerDay: Number, + goalName: String, + amountOfCircles: Number, + currentCircle: Number, + startDate: Number, + endDate: Number, + totalAmount: Number, + goalId: Number +}); + +onMounted(async () => { + amount_saved.value = props.amountSaved; + amount_per_day.value = props.amountPerDay; + goal_name.value = props.goalName; + amount_of_circles.value = props.amountOfCircles; + current_circle.value = props.currentCircle; + start_date.value = props.startDate; + end_date.value = props.endDate; + total_amount.value = props.totalAmount; + goal_id.value = props.goalId; + await loadCirclePositions(); + await updateDaysLost(); + await handleState(); +}); + +/** + * Calculates how many days the user is behind or infront of the expected + * progressamount. + */ +function updateDaysLost() { + const daysFromStart = pigCircle.value; + const currentCircleValue = props.currentCircle; + + if (daysFromStart < currentCircleValue) { + days_lost.value = 0; + } else { + days_lost.value = Math.abs(daysFromStart - currentCircleValue) - 1; + } +} + +/** + * Updates the state of the goal. + * If the goal is reached it updates to FINISHED. + * If the users falls three days behind it updates to FAILED. + */ +async function handleState() { + if (total_amount.value <= amount_saved.value) { + try { + await GoalService.updateProgress(goal_id.value, "FINISHED"); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorUpdateProgress(error); + } + try { + await BadgeService.createBadge("NUMBER_OF_SAVING_GOALS_ACHIEVED"); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorCreateBadge(error); + } + try { + await BadgeService.createBadge("AMOUNT_SAVED"); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorCreateBadge(error); + } + showFinished.value = true; + } else if (days_lost.value >= 3) { + try { + GoalService.updateProgress(goal_id.value, "FAILED"); + } catch (error) { + statusMessage.value = GoalErrorService.handleErrorUpdateProgress(error); + } + showFailed.value = true; + } +} + +/** + * Formats the startDate to a shorter readable date. + */ +const formattedStartDate = computed(() => { + const date = new Date(start_date.value); + return date.toLocaleDateString('no-NO', { + month: 'short', + day: '2-digit' + }); +}); + +/** + * Formats the endDate to a shorter readable date. + */ +const formattedEndDate = computed(() => { + const date = new Date(end_date.value); + return date.toLocaleDateString('no-NO', { + month: 'short', + day: '2-digit' + }); +}); + +/** + * Computes todays date. + */ +const currentDate = computed(() => { + return new Date().toLocaleDateString('no-NO', { + month: 'long', day: 'numeric' + }); +}); + +/** + * Caluculates the positions of the circles to make them + * look like a trail. + */ +const loadCirclePositions = async () => { + circlePositions.value[0] = 50; + for (let i = 1; i < amount_of_circles.value; i++) { + let lastPosition = circlePositions.value[i - 1]; + let newPosition = lastPosition + (Math.random() * 20 - 10); + circlePositions.value.push(newPosition); + } +} + +/** + * Updates the styling of a circle to indicate progress. + * + * @param {*} index progress in amount. + */ +const getCircleStyle = (index) => ({ + left: `${circlePositions.value[index]}%`, + backgroundColor: index <= current_circle.value ? 'var(--green-text)' : 'var(--middle-color)', + position: 'relative' +}); + +/** + * Calculates the position of the mascot based on the date. + */ +const pigImagePosition = computed(() => ({ + left: `20px`, + position: 'absolute', + top: `${(pigCircle.value - 1) * 60}px`, + transform: 'translateX(-50%)' +})); + +/** + * Gets the amount of days between two days. + * + * @param {*} start From date. + * @param {*} end To date. + */ +const daysBetweenDates = (start, end) => { + const startDate = new Date(start); + const endDate = new Date(end); + const timeDiff = endDate - startDate; + return Math.floor(timeDiff / (1000 * 60 * 60 * 24)); +}; + +/** + * Helper to calculate the mascots position based on unexpected + * values. + */ +const pigCircle = computed(() => { + const today = new Date(); + const startDate = new Date(start_date.value); + const endDate = new Date(end_date.value); + + if (today < startDate) return 0; + if (today > endDate) return (daysBetweenDates(startDate, endDate) + 1); + + return (daysBetweenDates(startDate, today) + 1); +}); + +/** + * Helper to decide if pig should be hidden based on unexpected values. + */ +const showPig = computed(() => { + const today = new Date(); + const startDate = new Date(start_date.value); + return today >= startDate; +}); + +/** + * Switched the image src of the mascot based on the amount of + * days the user is behind on progress. + */ +const getImageSrc = () => days_lost.value >= 3 + ? require("@/assets/img/pigCry.gif") + : require("@/assets/img/pigWave.gif"); +</script> + +<template> + <div class="roadComponent-main-container"> + <div data-cy="amount-title" class="title"> + <h4>{{ statusMessage }}</h4> + <h1>{{ goal_name }}</h1> + <h2>Fremgang: {{ amount_saved }} / {{ total_amount }}</h2> + </div> + <div class="outer" style="position: relative;"> + <h1 class="dates">{{ formattedStartDate }}</h1> + <div v-for="index in amount_of_circles" :key="index" class="circle" :style="getCircleStyle(index - 1)"> + {{ amount_per_day }} kr + </div> + <div class="content-container" v-if="showPig" :style="pigImagePosition"> + <img :src="getImageSrc()" alt="Pig" class="pig-image"> + <div class="small-circles-container"> + <div v-for="n in 3" :key="n" class="small-circle" + :style="{ backgroundColor: n <= days_lost ? 'gray' : 'red' }"></div> + </div> + <div class="date-display">{{ currentDate }}</div> + <div class="horizontal-line"></div> + </div> + <h1 class="dates">{{ formattedEndDate }}</h1> + </div> + </div> + <div v-if="showFinished" class="modal-overlay"> + <div class="modalFinished"> + <p>Gratulerer, du klarte mÃ¥let ditt!</p> + <img src="@/assets/img/pigWave.gif" alt="Pig" class="pig-image"> + <button @click="$emit('back')">Tilbake</button> + </div> + </div> + <div v-if="showFailed" class="modal-overlay"> + <div class="modalFailed"> + <p>Beklager, du klarte ikke mÃ¥let ditt.</p> + <img src="@/assets/img/pigCry.gif" alt="Pig" class="pig-image"> + <button @click="$emit('back')">Tilbake</button> + </div> + </div> +</template> + +<style scoped> +.roadComponent-main-container { + height: 100%; + width: 100%; + box-sizing: border-box; +} + +.modal-overlay { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +button { + background-color: var(--dark-color); + color: var(--white-text); + border: none; + width: 60%; + padding: 10px 20px; + font-size: 16px; + border-radius: var(--border-radius-general); + box-shadow: 0 4px var(--middle-color); + cursor: pointer; + margin: 20px auto; + transition: background-color 0.3s ease, box-shadow 0.3s ease; + +} + +button:hover { + background-color: var(--middle-color); + box-shadow: 0 6px var(--light-color); +} + +.modalFinished { + background: white; + padding: 20px; + border-radius: var(--border-radius-general); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.modalFailed { + background: white; + padding: 20px; + border-radius: var(--border-radius-general); + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.center-circle { + left: 50%; + transform: translateX(-50%); +} + +.date-display { + position: absolute; + margin-left: 140px; + top: 20%; + width: 100%; + text-align: center; + font-size: 16px; + z-index: 3; +} + +.horizontal-line { + width: 180px; + height: 2px; + background-color: black; + position: absolute; + top: 50%; + left: 0; + transform: translateY(-50%); + z-index: 0; +} + +.dates { + + margin-right: 40px; + text-align: right; + font-size: 16px; + z-index: 3; +} + +.h1 { + font-size: large; +} + +.h2 { + font-size: medium; +} + +.title { + display: flex; + flex-direction: column; + gap: 0; + align-items: center; + justify-content: center; + margin: 10px; +} + +.outer { + margin: 30px; + box-sizing: border-box; +} + +.circle { + width: 70px; + height: 50px; + color: white; + box-shadow: 0 14px 6px -6px gray; + border-radius: 50%; + margin: 10px 0; + display: flex; + align-items: center; + justify-content: center; + transition: box-shadow 0.3s ease-in-out; + z-index: 1; +} + +.circle:hover { + box-shadow: 0 14px 6px -6px #eded9d; +} + +.content-container { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + margin-left: 75px; +} + +.pig-image { + width: 60px; + height: 60px; + border-radius: 60%; + z-index: 1; +} + +.small-circles-container { + display: flex; + justify-content: center; + margin-top: 5px; +} + +.small-circle { + width: 10px; + height: 10px; + border-radius: 50%; + margin: 0 5px; +} + +button { + margin: 10px; +} + +@media (max-width: 600px) { + .date-display, + .horizontal-line { + display: none; + } +} +</style> diff --git a/src/components/goals/RoadUsersComponent.vue b/src/components/goals/RoadUsersComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..611b370b01607594bdbed62c43efe7700c394060 --- /dev/null +++ b/src/components/goals/RoadUsersComponent.vue @@ -0,0 +1,59 @@ +/** +* Script to fetch all contributors to a goal and display their names +* and the amount they have contributed. +*/ +<script setup> +import { defineProps, onMounted, ref } from 'vue'; +import GoalService from '@/services/internal/GoalService'; +import GoalErrorService from '@/services/error/GoalErrorService'; + +const props = defineProps({ + goalId: Number +}); + +const statusMessage = ref(''); +const contributors = ref([]); + +onMounted(async () => { + if (props.goalId) { + try { + const response = await GoalService.getGoalContributors(props.goalId); + if (response && response.data && Array.isArray(response.data)) { + contributors.value = response.data; + } else { + console.error('No contributors found or bad response:', response); + if (Array.isArray(response)) { + contributors.value = response; + } + } + } catch (error) { + statusMessage.value = await GoalErrorService.handleErrorGetGoalContribution(error); + } + } +}); +</script> + +<template> + <div data-cy="title"> + <h4>Bidragsytere:</h4> + <h4>{{ statusMessage }}</h4> + </div> + <div> + <ul> + <li data-cy="list-of-names" v-for="contributor in contributors" :key="contributor.email"> + {{ contributor.firstName }} {{ contributor.lastName }}: {{ contributor.contributedAmount.toLocaleString('en-US')}} + </li> + </ul> + </div> +</template> + +<style scoped> +ul { + list-style-type: none; + padding: 0; +} + +li { + margin-bottom: 8px; +} +</style> diff --git a/src/components/goals/SharedChoice.vue b/src/components/goals/SharedChoice.vue new file mode 100644 index 0000000000000000000000000000000000000000..429da929a3eee8a085175a804ff2779d6d8a1347 --- /dev/null +++ b/src/components/goals/SharedChoice.vue @@ -0,0 +1,63 @@ +<script setup> +</script> + +<template> + <div class="button-container"> + <div class="button-box"> + <img src="@/assets/img/pluss_icon.png" alt="Image description"> + <button @click="$emit('createSharedGoal')">Start ett delt sparemÃ¥l</button> + </div> + <div class="button-box"> + <img src="@/assets/img/join.png" alt="Image description"> + <button @click="$emit('joinSharedGoal')">Bli med i ett sparemÃ¥l</button> + </div> + <div class="button-box"> + <img src="@/assets/img/back.png" alt="Image description"> + <button @click="$emit('back')">GÃ¥ tilbake</button> + </div> + </div> +</template> + +<style scoped> +.button-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 30px; + margin-top: 100px; +} + +.dual-img-box { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + gap: 30px; +} + +.button-box { + background-color: var(--accent-color); + border-radius: var(--border-radius-general); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 20px; + margin: 10px; + width: 70%; + text-align: center; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); +} + +button { + width: 70%; + margin-top: 10px; +} + +img { + width: 10%; + height: auto; + margin-bottom: 20px; +} +</style> \ No newline at end of file diff --git a/src/components/home/HomeMain.vue b/src/components/home/HomeMain.vue index 1555b7484fca9160c46c36c56c77977abc803122..f46dfc00bfc08d45c94d68e9a96810e33a6ad857 100644 --- a/src/components/home/HomeMain.vue +++ b/src/components/home/HomeMain.vue @@ -1,20 +1,25 @@ <script setup> - </script> <template> - <div class="content-container"> - <h1>Velkommen til Sparesti!</h1> - <p>Sparesti har spart sine kunder over 150.000kr! Kom i gang med din sparing her></p> - <button id="rounded-button">Log in!</button> - </div> + <div class="home-container"> + <img src="@/assets/img/pigrich.png" alt=""> + <h1>Bli rik med Sparesti!</h1> + <router-link to="/goals"> + <button >Lag nytt sparemÃ¥l</button> + </router-link> + </div> </template> <style scoped> -.content-container { +.home-container img { + filter: drop-shadow(0px 0px 10px rgba(0, 0, 0, 0.5)); +} + +.home-container { display: flex; flex-direction: column; - justify-content: space-evenly; + justify-content: center; align-items: center; height: 100vh; text-align: center; @@ -34,27 +39,21 @@ p { font-size: 30px; } -#rounded-button { - background-color: #0056b3; - color: white; - border: none; - width: 60%; - padding: 10px 20px; - font-size: 16px; - border-radius: 20px; - box-shadow: 0 4px #003875; - cursor: pointer; - transition: all 0.3s ease; - margin: 40px auto; +button { + width: 100%; } -#rounded-button:hover { - background-color: #023366; - box-shadow: 0 6px #003875; -} +@media (max-width: 420px) { + .home-container img { + width: 80%; + } + + h1 { + font-size: 32px; + } -#rounded-button:active { - box-shadow: 0 2px #003875; - transform: translateY(2px); + p { + font-size: 24px; + } } -</style> +</style> \ No newline at end of file diff --git a/src/components/home/HomeWelcome.vue b/src/components/home/HomeWelcome.vue new file mode 100644 index 0000000000000000000000000000000000000000..b27c30b7ad71e668beddc08b30d15b3a31930f9c --- /dev/null +++ b/src/components/home/HomeWelcome.vue @@ -0,0 +1,98 @@ +<script setup> +</script> + +<template> + <div class="content-container"> + <div class="title"> + <h1>Velkommen til </h1> + <img class="home-img" src="@/assets/img/logo_banner.png" alt="Home"/> + </div> + <p>Sparesti har spart sine kunder over 150.000kr! Kom i gang med din sparing her:</p> + <router-link to="/login"> + <button>Logg in / registrer!</button> + </router-link> + </div> +</template> + +<style scoped> +.title { + display: flex; + flex-direction: row; + align-items: center; + gap: 10px; +} + +.home-img { + width: 200px; + height: 40px; + z-index: 0; + border-radius: var(--border-radius-general); +} + +.content-container { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + height: 100vh; + text-align: center; + padding: 40px; + gap: 20px; +} + +h1, p { + margin: 5px 0; +} + +h1 { + color: black; + font-size: 40px; +} + +p { + font-size: 30px; + color: var(--black-text); +} + +button { + width: 100%; +} + +@media (max-width: 768px) { + .home-img { + width: 150px; + height: 30px; + } + + h1 { + font-size: 32px; + } + + p { + font-size: 24px; + } + + .content-container { + padding: 20px; + } +} + +@media (max-width: 480px) { + .home-img { + width: 120px; + height: 25px; + } + + h1 { + font-size: 28px; + } + + p { + font-size: 20px; + } + + button { + width: 80%; + } +} +</style> diff --git a/src/components/infobar/InfoBar.vue b/src/components/infobar/InfoBar.vue new file mode 100644 index 0000000000000000000000000000000000000000..2eb8f526c1d6dc600f03cc7f8da34f668a6049a4 --- /dev/null +++ b/src/components/infobar/InfoBar.vue @@ -0,0 +1,89 @@ +<script setup> +import StockInfo from "@/components/infobar/StockInfo.vue"; +import WeeklyDiscount from "@/components/infobar/WeeklyDiscount.vue"; +</script> + +<template> + <div class="info-bar-container"> + <div class="scrollable-content"> + <div class="box"> + <StockInfo></StockInfo> + </div> + <div class="box"> + <WeeklyDiscount></WeeklyDiscount> + </div> + + </div> + </div> +</template> + +<style scoped> +.info-bar-container { + display: flex; + flex-direction: column; + width: 100%; + max-height: 100%; + overflow: hidden; + box-sizing: border-box; + padding-right: 20px; +} + +.goals-dropdown-items p { + color: black; + padding: 12px 16px; + text-decoration: none; + display: block; +} + +.goals-dropdown-items p:hover { + background-color: var(--dark-color); + cursor: pointer; + color: var(--white-general); +} + +.goals-dropdown-items p:active { + background-color: var(--middle-color); +} + +.stats img { + width: 50%; + height: 50%; +} + +.scrollable-content { + margin-top: 4%; + overflow-y: auto; + flex-grow: 1; + scrollbar-width: none; +} + +.box { + background-color: var(--dark-color); + width: 100%; + padding: 20px; + border-radius: var(--border-radius-general); + margin-bottom: 10px; + color: var(--white-general); + box-sizing: border-box; +} + +.box:last-child { + margin-bottom: 0; +} + +.box input[type="text"], .box button { + padding: 10px; + margin-top: 10px; +} + + +.box button { + cursor: pointer; + background-color: #406882; + color: white; + border: none; + border-radius: var(--border-radius-general); +} + + +</style> \ No newline at end of file diff --git a/src/components/infobar/StatsPreview.vue b/src/components/infobar/StatsPreview.vue new file mode 100644 index 0000000000000000000000000000000000000000..48bf817f9b0955e6884d474afac84bf2779d145f --- /dev/null +++ b/src/components/infobar/StatsPreview.vue @@ -0,0 +1,227 @@ +<script setup> + +import {onMounted, ref, computed} from "vue"; +import StreakService from "@/services/internal/StreakService"; +import BadgeService from "@/services/internal/BadgeService"; +import GoalService from "@/services/internal/GoalService"; +import router from "@/router"; +import {useStore} from "vuex"; + +const goalLength = ref(0); +const streak = ref(0); +const totalSaved = ref(""); +const totalBadges = ref(0); +const goals = ref([]); +const store = useStore(); +const isAuthenticated = computed(() => store.state.user.isAuthenticated); + +/** + * Fetches the streak, total saved and badges for authenticated user when the component is mounted. + */ +onMounted(() => { + if (isAuthenticated.value) { + getStreak(); + getTotalSaved(); + getGoals(); + getBadges(); + } +}); + +/** + * Fetches the current streak. + */ +async function getStreak() { + try { + let unParsedStreak = await StreakService.getStreak(); + streak.value = unParsedStreak.numberOfDays; + } catch (error) { + streak.value = 0; + } +} + +/** + * Fetches the total saved amount. + */ +async function getTotalSaved() { + try { + const savedAsNumber = await GoalService.getTotalSaved(); + totalSaved.value = savedAsNumber + " kr"; + } catch (error) { + totalSaved.value = 0; + } +} + +/** + * Fetches the goals and sets the goalLength to the length of the goals array. + */ +async function getGoals() { + try { + goals.value = await GoalService.getGoals(15, 0); + goalLength.value = goals.value.length; + } catch (error) { + goals.value = []; + goalLength.value = 0; + } +} + +/** + * Fetches the badges and sets the totalBadges to the length of the badges array. + */ +async function getBadges() { + try { + const badges = await BadgeService.getBadges(15, 0); + totalBadges.value = badges.length; + } catch (error) { + totalBadges.value = 0; + } +} + +/** + * Routes to the profile page with the badges component. + */ +function handleBadgeClick() { + router.push({ name: "profile", params: { component: "Badges"} }); +} +</script> + +<template> + <div class="stats-preview"> + <div class="icons"> + <div class="icon-container goals" + tabindex="0" + role="button"> + <img src="@/assets/img/goalIcon.png" alt="goals"> + <p>{{ goalLength }}</p> + </div> + <div class="icon-container badges" + @click="handleBadgeClick" + @keydown.enter="handleBadgeClick" + tabindex="0" + role="button" + > + <img src="@/assets/img/badgeIcon.png" alt="badges"/> + <p>{{ totalBadges }}</p> + </div> + <div class="icon-container"> + <img src="@/assets/img/fireIcon.png" alt="streak"> + <p>{{ streak }}</p> + </div> + <div class="icon-container"> + <img src="@/assets/img/money.png" alt="money"> + <p class="total-saved-text">{{ totalSaved }}</p> + </div> + </div> + </div> +</template> + +<style scoped> +.stats-preview { + display: flex; + justify-content: center; + align-items: center; + background-color: white; + width: 100%; + height: 100px; + box-sizing: border-box; + +} + +.stats-preview p { + white-space: wrap; + text-overflow: ellipsis; /* Adds ellipsis for overflowed text */ +} + +.icons { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-around; + padding-right: 20px; + gap: 30px; +} + +.goal-dropdown { + width: 300px; + top: calc(100% + 10px); + display: flex; + flex-direction: column; + align-items: center; + z-index: 50; + margin: 0; + position: absolute; + left: 50%; + transform: translateX(-50%); + border: var(--black-general) solid 3px; + background-color: white; + border-radius: var(--border-radius-general); +} + +.goal-dropdown::before { + content: ""; + border-left: 10px solid transparent; + border-right: 10px solid transparent; + border-bottom: 10px solid var(--black-general); + position: absolute; + top: -12px; + +} + +.dropdown-item { + width: 100%; + height: 50px; + background-color: var(--white-general); + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--border-radius-general); + box-sizing: border-box; +} + +.dropdown-item:hover { + background-color: var(--middle-color); + color: var(--white-general); + cursor: pointer; +} + +.icon-container { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + position: relative; + text-align: center; + min-width: 0; +} + +.goals:hover, .badges:hover { + cursor: pointer; +} + + +img { + width: 100%; + height: auto; +} + +@media screen and (max-width: 800px) { + .stats-preview { + justify-content: flex-end; + width: 70%; + margin-left: 20%; + } + + .icon-container { + flex-direction: row; + width: 100%; + } + + .icons { + gap: 0; + } + + img { + width: 50%; + height: auto; + } +} +</style> \ No newline at end of file diff --git a/src/components/infobar/StockInfo.vue b/src/components/infobar/StockInfo.vue new file mode 100644 index 0000000000000000000000000000000000000000..1d8787d1b34e49e78b00d4016c1abd646f329919 --- /dev/null +++ b/src/components/infobar/StockInfo.vue @@ -0,0 +1,175 @@ +<script setup> +import {onMounted, ref, watch} from 'vue'; +import StockService from '@/services/internal/StockService'; +import CookieService from "@/services/internal/CookieService"; +import axios from "axios"; + +const stockInfo = ref(null); +const searchQuery = ref(""); +const errorMessage = ref(""); + +/** + * Watcher to reset error message and stock info when search query changes. + */ +watch([searchQuery], () => { + errorMessage.value = ""; + stockInfo.value = null; +}) + +/** + * Retrieves stock symbol from cookie and stock data from backend when the component is mounted. + */ +onMounted(async () => { + let stockSearch = CookieService.getCookieWithConsent('stockSearch') + if (stockSearch) { + searchQuery.value = stockSearch; + await fetchStockInfo(); + } +}); + +/** + * Retrieves stock data based on the search query. + */ +async function fetchStockInfo() { + if (!searchQuery.value.trim()) { + return; + } + try { + stockInfo.value = await StockService.fetchStockInfo(searchQuery.value); + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 404: + errorMessage.value = "Aksjedata ikke funnet for symbol: " + searchQuery.value; + break; + case 500: + errorMessage.value = "Intern serverfeil. Vennligst prøv igjen senere."; + break; + default: + errorMessage.value = error.response.data.errorMessage; + break; + } + } else { + console.error('Cannot connect to server.', error); + errorMessage.value = 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } +} + +/** + * Prevent entering space in input fields. + * + * @param event The keydown event object. + */ +function preventSpace(event) { + if (event.key === " " || event.code === "Space") { + event.preventDefault(); + } +} + +</script> + +<template> + <div class="stockInfo"> + <h2>Børs!</h2> + <div class="stock-elements"> + <label class="stock-input-label" for="stock-input-field">Aksje symbol:</label> + + <div class="stock-input-container"> + <input + id="stock-input-field" + class="stock-input-field" + type="text" + v-model="searchQuery" + @keydown="preventSpace" + placeholder="TSLA..."> + + <button tabindex="0" role="button" class="stock-button" @click="fetchStockInfo()" + :disabled="searchQuery === ''">Søk + </button> + + </div> + <div class="stock-info" v-if="stockInfo"> + <h5>{{ searchQuery.toUpperCase() }}: ${{ stockInfo.currentPrice }}</h5> + <p>Endring: {{ stockInfo.changePercent }}%</p> + </div> + <div class="error-message" v-if="errorMessage">{{ errorMessage }}</div> + </div> + </div> +</template> + +<style scoped> +.stockInfo { + min-height: 180px; +} + +.stock-elements { + display: flex; + flex-direction: column; + width: 100%; +} + +.stock-input-label { + margin-top: 3%; + margin-bottom: 3%; +} + +.stock-input-container { + display: flex; + flex-direction: row; +} + +.stock-input-field { + width: 100%; + padding: 10px; +} + +.stock-button { + cursor: pointer; + background-color: black; + color: white; + border: none; + border-radius: var(--border-radius-general); + margin-left: 3%; + width: 100%; +} + +.stock-button[disabled] { + background-color: #484040; + cursor: not-allowed; +} + +.stock-info { + margin-top: 5%; +} + +.stock-info p { + margin-top: 2%; +} + +.error-message { + margin-top: 5%; +} + +@media only screen and (max-width: 1000px) { + .stock-input-container { + display: flex; + flex-direction: column; + } + + .stock-input-field { + width: 100%; + padding: 10px; + } + + .stock-button { + color: white; + border: none; + border-radius: var(--border-radius-general); + margin-top: 5%; + width: 95%; + padding: 10px; + } +} + +</style> \ No newline at end of file diff --git a/src/components/infobar/WeeklyDiscount.vue b/src/components/infobar/WeeklyDiscount.vue new file mode 100644 index 0000000000000000000000000000000000000000..1ea5ae9d236fb00b3b0ef1802db7dea10c9f9a3d --- /dev/null +++ b/src/components/infobar/WeeklyDiscount.vue @@ -0,0 +1,38 @@ +<script setup> + +</script> + +<template> + <div class="weekly-discount"> + <h2>Ukens rabatter</h2> + <p>10% pÃ¥ Bilia</p> + <p>10% pÃ¥ Zara</p> + <p>25% pÃ¥ Power</p> + <p>25% pÃ¥ Power</p> + <p>25% pÃ¥ Power</p> + <p>25% pÃ¥ Power</p> + <p>25% pÃ¥ Power</p> + <p>25% pÃ¥ Power</p> + <p>25% pÃ¥ Power</p> + <p>25% pÃ¥ Power</p> + <p>25% pÃ¥ Power</p> + <p>25% pÃ¥ Power</p> + <p>25% pÃ¥ Power</p> + <p>25% pÃ¥ Power</p> + <p>25% pÃ¥ Power</p> + <p>25% pÃ¥ Power</p> + <p>25% pÃ¥ Power</p> + <p>25% pÃ¥ Power</p> + </div> +</template> + +<style scoped> +.weekly-discount { + min-height: 450px; +} + +.weekly-discount p { + margin-top: 2%; +} + +</style> \ No newline at end of file diff --git a/src/components/login/ForgotPassword.vue b/src/components/login/ForgotPassword.vue new file mode 100644 index 0000000000000000000000000000000000000000..7866279658810ab21200a14c6b85dc74cd5dc92e --- /dev/null +++ b/src/components/login/ForgotPassword.vue @@ -0,0 +1,396 @@ +<script setup> + +import SpinningWheelComponent from "@/components/other/SpinningWheelComponent.vue"; +import {ref, computed, watch} from "vue"; +import EmailService from "@/services/internal/EmailService"; +import VerificationView from "@/views/VerificationView.vue"; +import UserDetailsService from "@/services/internal/UserDetailsService"; +import ResetPasswordDTO from "@/models/user/ResetPasswordDTO"; +import UserDetailsInputService from "@/services/internal/UserDetailsInputService"; +import router from "@/router"; +import axios from "axios"; + +const newPassword = ref(""); +const email = ref(""); +const statusMessage = ref(""); +const resettingPassword = ref(false); +const confirmedReset = ref(false); +const timerDeadline = ref(null); + +/** + * Watch for changes in email and newPassword to clear statusMessage. + */ +watch([email, newPassword], () => { + statusMessage.value = ""; +}); + +/** + * Handles the forgot password functionality. + */ +const handleForgotPassword = async () => { + + if(!await verifyIfEmailExists(email.value)) { + console.log("test") + return; + } + + if(!validatePassword(newPassword.value)) { + return; + } + + confirmedReset.value = true; + + try { + const emailCodeExpirationDto = await EmailService.sendRegisterToken(email.value); + timerDeadline.value = new Date(emailCodeExpirationDto.expirationTimestamp); + resettingPassword.value = true; + } catch (error) { + handleError(error); + } + + confirmedReset.value = false; +}; + +/** + * Verifies if the email exists. + * + * @param {string} email - The email address to verify. + * @returns {boolean} - Indicates if the email exists. + */ +async function verifyIfEmailExists(email){ + try { + await EmailService.verifyEmailExistence(email); + return true; + } catch (error) { + console.log("test") + handleError(error); + return false; + } +} + +/** + * Validates the password using UserDetailsInputService. + * + * @param {string} password - The password to validate. + * @returns {boolean} - Indicates if the password is valid. + */ +function validatePassword (password) { + try { + UserDetailsInputService.validatePasswordPattern(password); + return true; + } catch (error) { + statusMessage.value = error.message; + return false; + } +} + +/** + * Handles the reset password functionality. + * Resets the password using UserDetailsService. + * Handles errors using ErrorService. + * + * @param {string} emailVerificationCode - The verification code for resetting the password. + */ +async function handleResetPassword(emailVerificationCode) { + + const resetPasswordDto = new ResetPasswordDTO(email.value, emailVerificationCode, newPassword.value) + + try { + await UserDetailsService.resetPassword(resetPasswordDto); + await router.push('/login'); + } catch (error) { + handleError(error); + } +} + +/** + * Handles errors and displaying appropriate messages to the user. + * + * @param {Error} error - The error object. + */ +function handleError(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 400: + statusMessage.value = "Ugyldig verifiseringskode"; + break; + case 404: + statusMessage.value = "Bruker med e-postadresse '"+ email.value +"' eksisterer ikke." + break; + case 500: + statusMessage.value = "Intern serverfeil. Vennligst prøv igjen senere."; + break; + default: + statusMessage.value = error.response.data.errorMessage; + break; + } + } else { + console.error('Cannot connect to server.', error); + statusMessage.value = 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } +} + +/** + * Computed property to check if the form is filled out. + */ +const isFormFilledOut = computed(() => { + return (email.value.trim() && newPassword.value.trim()); +}); + +</script> + +<template> + <div class="forgotPassword-container" v-if="!resettingPassword"> + <div class="center-element-container"> + <div class="left-info"> + <img src="@/assets/img/pigrich.png" alt="Home"/> + </div> + <div class="form-container"> + <h4>Tilbakestill passord</h4> + <form class="form-element-container" @submit.prevent="handleForgotPassword"> + <label for="email-input-filed">E-postadresse</label> + <input + id="email-input-filed" + class="form-element" + type="email" + required + v-model="email" + placeholder="eksempel@mail.no"/> + <label for="password-input-filed">Nytt passord</label> + <input + id="password-input-filed" + class="form-element" + type="password" + required + v-model="newPassword" + placeholder="passord"/> + <button + :disabled="!isFormFilledOut" + @click.prevent="handleForgotPassword" + > + Send kode for tilbakestilling + </button> + <router-link to="/login" class="router-link">Tilbake til logg inn</router-link> + <p class="status-message" v-if="statusMessage">{{ statusMessage }}</p> + </form> + </div> + </div> + <spinning-wheel-component :visible="confirmedReset"/> + </div> + + <div v-else> + <verification-view + @verification-submit="handleResetPassword" + @go-back="resettingPassword = false" + :email="email" + :timer-deadline="timerDeadline" + :error-message="statusMessage" + > + </verification-view> + </div> + +</template> + +<style scoped> + +.forgotPassword-container { + height: 100vh; + display: flex; + flex-direction: row; + justify-content: center; + box-sizing: border-box; + align-items: center; + padding: 5px; +} + +.center-element-container { + display: flex; + width: 100%; + min-height: 420px; + flex-wrap: nowrap; + flex-direction: row; + justify-content: center; + box-sizing: border-box; +} + +.left-info { + min-width: 350px; + max-width: 350px; + min-height: 280px; + display: flex; + flex: 1; + flex-direction: column; + justify-content: center; + align-items: center; + text-align: center; + padding: 20px; + background-color: var(--dark-color); + border-radius: var(--border-radius-general) 0 0 var(--border-radius-general); +} + +.form-container { + flex: 1; + display: flex; + flex-direction: column; + justify-content: space-between; + padding: 20px; + min-width: 350px; + max-width: 350px; + min-height: 280px; + border-radius: 0 var(--border-radius-general) var(--border-radius-general) 0; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + background-color: var(--accent-color); + box-sizing: border-box; +} + +.form-element-container { + display: flex; + flex-direction: column; + align-items: center; + justify-items: center; + width: 100%; + height: 100%; + margin-top: 7%; +} + +.form-element { + box-shadow: 2px 2px 4px 0 rgba(0, 0, 0, 0.25); + box-sizing: border-box; + justify-content: left; +} + +.form-element:focus { + outline: none; +} + +option:hover { + background-color: var(--light-color); +} + +select, +input, +textarea, +button { + width: 100%; + padding: 10px; + margin-bottom: 10px; + border-radius: var(--border-radius-general); + border: 1px solid #ccc; + box-sizing: border-box; +} + +.navigation span { + cursor: pointer; + font-size: 1em; + padding: 3px; + transition: color 0.3s, text-decoration 0.3s; + text-align: center; +} + +select, .register option { + font-weight: normal !important; +} + +img { + filter: drop-shadow(0px 0px 10px black); + width: 100%; +} + +label { + align-self: flex-start; + padding: 2px; + margin-left: 3px; +} + +.router-link { + color: var(--middle-color); + text-decoration: none; + margin-top: 2%; + display: inline-block; + transition: color 0.3s; + font-weight: bold; +} + +.router-link:hover { + color: var(--dark-color); + text-decoration: underline; +} + +.status-message { + margin-top: 4%; +} + +@media screen and (max-width: 919px) { + .container { + flex-direction: column; + align-items: center; + } + + .center-element-container { + width: 80%; + height: auto; + } + + .left-info { + display: none; + } + + .form-container { + width: 100%; + height: auto; + border-radius: var(--border-radius-general); + } +} + +@media screen and (max-width: 400px) { + .container { + flex-direction: column; + padding: 10px; + } + + .center-element-container { + width: 100%; + flex-direction: column; + } + + .form-container, .left-info { + width: 100%; + min-width: auto; + max-width: none; + margin: 0 auto; + padding: 15px; + border-radius: var(--border-radius-general); + } + + .form-element-container { + margin-top: 5%; + } + + .form-element, select, input, textarea, button { + margin-bottom: 5px; + } + + label { + margin-left: 0; + } + + .router-link { + margin-top: 5%; + } + + .status-message { + margin-top: 10px; + } + + img { + width: 80%; + margin: 0 auto; + } + + button { + height: auto; + padding: 8px 16px; + } +} + +</style> \ No newline at end of file diff --git a/src/components/login/LoginComponent.vue b/src/components/login/LoginComponent.vue index aada5b5a8924d1927cf85d78b67e0b929e310e15..9205bb9c5065dcb42a4979f54ca047bb765de953 100644 --- a/src/components/login/LoginComponent.vue +++ b/src/components/login/LoginComponent.vue @@ -1,106 +1,204 @@ <script setup> -import { ref, computed, onMounted, onUnmounted } from "vue"; -import UserService from '@/services/internal/UserService.js'; +import {ref, computed, defineProps} from "vue"; +import {useStore} from 'vuex'; +import {useRoute, useRouter} from "vue-router"; +import axios from "axios"; +import SpinningWheelComponent from "@/components/other/SpinningWheelComponent.vue"; -const input = ref({ email: "", password: "" }); +const input = ref({email: "", password: ""}); const statusMessage = ref(""); +const router = useRouter(); +const store = useStore(); +const route = useRoute(); -const handleResize = () => { - const newIsMobileView = window.innerWidth <= 760; - if (isMobileView.value !== newIsMobileView) { - isMobileView.value = newIsMobileView; - console.log(`Window resized, isMobileView: ${isMobileView.value}`); - } -}; +const isSubmitting = ref(false); +const isLoginActive = ref(true); + +defineProps({ + isLoginActive: Boolean +}); +/** + * Handles the login click event, validates the form and performs login. + */ const handleLoginClick = async () => { - if (input.value.email.trim() && input.value.password.trim()) { - try { - const response = await UserService.login(input.value.email, input.value.password); - - console.log('Login successful:', response); - statusMessage.value = "Login successful!"; - } catch (error) { - console.error("Login failed", error); - statusMessage.value = "Login failed. Please check your credentials."; - } - } else { + const email = input.value.email || ""; + const password = input.value.password || ""; + if (!(email.trim() && password.trim())) { statusMessage.value = "Please fill out both email and password."; + return } -}; + isSubmitting.value = true; -const isMobileView = ref(window.innerWidth <= 760); + try { + await store.dispatch('loginUser', { + email: input.value.email.trim(), + password: input.value.password.trim() + }); + await pushToRedirectOrHome(); + } catch (error) { + handleError(error); + } -const isFormFilledOut = computed(() => { - return input.value.email.trim() && input.value.password.trim(); -}); + isSubmitting.value = false; +}; -onMounted(() => { - window.addEventListener("resize", handleResize); -}); +/** + * Redirects the user to a specified route or home if no specific redirect is set. + */ +async function pushToRedirectOrHome() { + try { + await router.push(route.query.redirect); + } catch (error) { + await router.push("/"); + } +} -onUnmounted(() => { - window.removeEventListener("resize", handleResize); -}); +/** + * Handles errors during login, displaying appropriate messages to the user. + * @param {Error} error - The error object from login attempt. + */ +const handleError = (error) => { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 401: + statusMessage.value = "E-post eller passord var feil."; + break; + case 404: + statusMessage.value = "Bruker ikke funnet." + break; + case 500: + statusMessage.value = "Intern serverfeil. Vennligst prøv igjen senere."; + break; + default: + statusMessage.value = error.response.data.errorMessage; + break; + } + } else { + console.error('Cannot connect to server.', error); + statusMessage.value = 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } +}; -const isLoginActive = ref(true); +/** + * Checks whether both email and password fields are filled out. + * @returns {boolean} True if both email and password fields contain non-empty strings, false otherwise. + */ +const isFormFilledOut = computed(() => { + return input.value.email?.trim() && input.value.password?.trim(); +}); </script> + <template> - <div class="container"> + <div class="login-container"> <div class="center-element-container"> - <div - class="left-info" - v-if="!isMobileView.value"> + <div class="left-info"> + <img src="@/assets/img/pigrich.png" alt="Home"/> </div> + <div class="form-container"> <div class="navigation"> - <span - @click="$emit('toggleView', true)" - :class="{ - onFocusedLink: isLoginActive, - defaultLink: !isLoginActive, - }" - >Log In</span - > - <span - @click="$emit('toggleView', false)" - :class="{ - onFocusedLink: !isLoginActive, - defaultLink: isLoginActive, - }" - >Sign Up</span - > + <h5 + tabindex="0" + role="tab" + @keydown.enter="$emit('toggleView', true)" + @click="$emit('toggleView', true)" + :class="{ + onFocusedLink: isLoginActive, + defaultLink: !isLoginActive, + }">Logg inn</h5> + <h5 + tabindex="0" + role="tab" + @keydown.enter="$emit('toggleView', false)" + @click="$emit('toggleView', false)" + :class="{ + onFocusedLink: !isLoginActive, + defaultLink: isLoginActive, + }">Registrer bruker</h5> </div> - <form class="form-element-container" @submit.prevent="handleLoginClick"> + + <form class="form-element-container" + @submit.prevent="handleLoginClick"> + <label + for="email-input-filed"> + E-postadresse + </label> <input - class="form-element" - for="email" - type="text" - required - v-model="input.email" - placeholder="email" - /> + id="email-input-filed" + class="form-element" + type="email" + required + v-model="input.email" + placeholder="eksempel@mail.no"/> + + <label + for="password-input-filed">Passord + </label> <input - class="form-element" - for="password" - required - v-model="input.password" - type="Password" - placeholder="password" - /> - <button :disabled="!isFormFilledOut" @click="handleLoginClick"> - Log In + id="password-input-filed" + class="form-element" + required + v-model="input.password" + type="Password" + placeholder="passord"/> + + <button id="login-button" tabindex="0" + @keydown.enter="handleLoginClick" + :disabled="!isFormFilledOut" + @click.prevent="handleLoginClick"> + Logg inn </button> - <a href="/" class="forgot-password-link">Forgot password?</a> - <p v-if="statusMessage.trim().length > 0">{{ statusMessage }}</p> + <router-link to="/forgot-password" class="forgot-password-link">Glemt passord?</router-link> + <p class="status-message" v-if="statusMessage">{{ statusMessage }}</p> </form> </div> </div> + <spinning-wheel-component :visible="isSubmitting"/> </div> </template> <style scoped> +.login-container { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + width: 100vw; + padding: 5px; +} + +.onFocusedLink, +.defaultLink { + text-decoration: none; + color: var(--grey-text); +} + +.onFocusedLink { + color: var(--black-text); + text-decoration: underline; +} + +.defaultLink:hover { + text-decoration: underline; +} + +.forgot-password-link { + color: var(--middle-color); + text-decoration: none; + margin-top: 2%; + display: inline-block; + transition: color 0.3s; + font-weight: bold; +} + +.forgot-password-link:hover { + color: var(--dark-color); + text-decoration: underline; +} + + .container { height: 100vh; display: flex; @@ -112,43 +210,44 @@ const isLoginActive = ref(true); .center-element-container { display: flex; - margin: 5%; width: 100%; - min-height: 300px; - max-height: 350px; + min-height: 420px; flex-wrap: nowrap; flex-direction: row; + justify-content: center; + box-sizing: border-box; } .left-info { - padding: 20px; - background-color: var(--dark-color); - flex: 1; - border-radius: 8px 0 0 8px; + min-width: 350px; + max-width: 350px; + min-height: 280px; display: flex; + flex: 1; flex-direction: column; justify-content: center; align-items: center; text-align: center; + padding: 20px; + background-color: var(--dark-color); + border-radius: var(--border-radius-general) 0 0 var(--border-radius-general); } - .form-container { flex: 1; display: flex; flex-direction: column; justify-content: space-between; padding: 20px; - min-width: 300px; + min-width: 350px; max-width: 350px; min-height: 280px; - max-height: 450px; - border-radius: 0 8px 8px 0; + border-radius: 0 var(--border-radius-general) var(--border-radius-general) 0; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); background-color: var(--accent-color); + box-sizing: border-box; } - .form-element-container { display: flex; flex-direction: column; @@ -168,29 +267,8 @@ const isLoginActive = ref(true); outline: none; } -button { - background-color: var(--middle-color); - color: var(--white-text); - cursor: pointer; - width: 40%; - height: 40px; - border-radius: 10px; - transition: transform 0.3s ease; - box-shadow: 2px 2px 4px 0 rgba(0, 0, 0, 0.25); - outline: none; - -} - -button:hover { - background-color: var(--dark-color); - transform: translateY(-3px); -} - -button:disabled { - background-color: var(--accent-color-dark); - transform: translateY(0); - cursor: not-allowed; - color: var(--grey-text); +option:hover { + background-color: var(--light-color); } select, @@ -200,17 +278,17 @@ button { width: 100%; padding: 10px; margin-bottom: 10px; - border-radius: 4px; - border: 1px solid var(--accent-color); + border-radius: var(--border-radius-general); + border: 1px solid #ccc; box-sizing: border-box; } .navigation { display: flex; justify-content: center; - align-items: center; gap: 20px; margin-bottom: 20px; + align-items: center; } .navigation span { @@ -221,21 +299,31 @@ button { text-align: center; } +select, .register option { + font-weight: normal !important; +} + +img { + filter: drop-shadow(0px 0px 10px black); + width: 100%; +} + +label { + align-self: flex-start; + padding: 2px; + margin-left: 3px; +} + .onFocusedLink { - color: var(--black-text); - text-decoration: underline; + cursor: pointer; } .defaultLink { - color: var(--grey-text); - text-decoration: none; + cursor: pointer; } -.forgot-password-link { - text-decoration: none; - color: var(--dark-color); - margin-top: 10px; - display: inline-block; +.status-message { + margin-top: 7%; } @media screen and (max-width: 919px) { @@ -254,9 +342,59 @@ button { } .form-container { - width: 90%; + width: 100%; height: auto; - border-radius: 8px; + border-radius: var(--border-radius-general); } } + + +@media screen and (max-width: 400px) { + .center-element-container { + width: 100%; + flex-direction: column; + } + + .form-container, .left-info { + width: 100%; + min-width: auto; + max-width: none; + margin: 0 auto; + padding: 15px; + border-radius: var(--border-radius-general); + justify-content: flex-start; + } + + .form-element-container { + margin-top: 5%; + } + + .form-element, select, input, textarea, button { + margin-bottom: 5px; + } + + label { + margin-left: 0; + } + + .router-link { + margin-top: 5%; + } + + .status-message { + margin-top: 10px; + } + + img { + width: 80%; + margin: 0 auto; + } + + button { + height: auto; + padding: 8px 16px; + } +} + + </style> diff --git a/src/components/login/RegisterComponent.vue b/src/components/login/RegisterComponent.vue index 2d07cd4ad2e6ae4afed809c4405b1e9c9cffeaf4..89722cecd82e2663a57054078a04daa92f27a978 100644 --- a/src/components/login/RegisterComponent.vue +++ b/src/components/login/RegisterComponent.vue @@ -1,165 +1,345 @@ <script setup> -import {ref, computed, onMounted, onUnmounted} from "vue"; -import UserService from '@/services/internal/UserService.js'; - -const input = ref({email: "", firstname: "", lastname: "", password: ""}); -const statusMessage = ref(""); -const form = ref(null); -const isSubmitting = ref(false); - -const handleResize = () => { - const newIsMobileView = window.innerWidth <= 760; - if (isMobileView.value !== newIsMobileView) { - isMobileView.value = newIsMobileView; - console.log(`Window resized, isMobileView: ${isMobileView.value}`); +import { ref, defineProps, computed } from 'vue'; +import { useStore } from 'vuex'; +import router from '@/router'; +import UserDetailsInputService from "@/services/internal/UserDetailsInputService"; +import EmailService from '@/services/internal/EmailService'; +import VerificationView from '@/views/VerificationView.vue'; +import SpinningWheelComponent from "@/components/other/SpinningWheelComponent.vue"; +import axios from "axios"; + +const input = ref({ email: '', firstname: '', lastname: '', password: '', emailVerificationCode: '' }); +const statusMessage = ref(''); +const isSubmitted = ref(false); +const isSubmitting = ref(false) +const userStore = useStore(); +const timerDeadline = ref(null); + +defineProps({ + isLoginActive: Boolean +}); + +/** + * Checks if the provided email address does not already exist in the system. + * + * @param {string} email - The email address to check. + * @returns {Promise<boolean>} - A promise that resolves to true if the email is available, false otherwise. + */ +async function verifyEmailIsAvailable(email){ + try { + await EmailService.verifyEmailAvailability(email) + return true; + } catch (error) { + handleError(error); + return false; + } +} + +/** + * Validates the email address against a specified pattern and checks its availability. + * + * @param {string} email - The email address to validate. + * @returns {Promise<boolean>} - A promise that resolves to true if the email is valid and available, false otherwise. + */ +const validateEmail = async (email) => { + try { + UserDetailsInputService.validateEmailPattern(email); + return await verifyEmailIsAvailable(email); + } catch (error) { + statusMessage.value = error.message; + return false; + } +}; + +/** + * Validates a given name against a specified pattern. + * + * @param {string} name - The name to validate. + * @returns {boolean} - True if the name matches the pattern, + * false if it does not or if validation has not been attempted. + */ +const validateName = (name) => { + try { + UserDetailsInputService.validateNamePattern(name); + } catch (error) { + statusMessage.value = error.message; + return false; + } + return true; +}; + +/** + * Validates a given password against a specified pattern. + * + * @param {string} password - The password to validate. + * @returns {boolean} - True if the password matches the pattern, + * false if it does not or if validation has not been attempted. + */ +const validatePassword = (password) => { + try { + UserDetailsInputService.validatePasswordPattern(password); + } catch (error) { + statusMessage.value = error.message; + return false; } + return true; }; -const isMobileView = computed(() => { - return window.innerWidth <= 760; +/** + * Validates the input of all the input fields. + * + * @returns {boolean} True if all fields are successfully validated, false otherwise. + */ +const isFormValid = computed(() => { + return ( + validateEmail(input.value.email) && + validateName(input.value.firstname) && + validateName(input.value.lastname) && + validatePassword(input.value.password) + ); }); +/** + * Checks whether all input-fields are filled out. + * + * @returns {boolean} True if all fields contain non-empty strings, false otherwise. + */ const isFormFilledOut = computed(() => { - return input.value.email.trim() && input.value.firstname.trim() && input.value.password.trim(); + return ( + input.value.email.trim() && + input.value.firstname.trim() && + input.value.lastname.trim() && + input.value.password.trim() + ); }); +/** + * Handles the registration process upon clicking the register button. It validates the form and, + * if valid, proceeds to send a registration token. + * + * @returns {Promise<void>} - A promise that resolves when the registration attempt has concluded. + */ const handleRegisterClick = async () => { - if (input.value.email.trim() && input.value.firstname.trim() - && input.value.lastname.trim() && input.value.password.trim()) { + if (isFormValid.value) { + isSubmitting.value = true; try { - const response = await UserService.register(input.value.email, - input.value.firstname, input.value.lastname, input.value.password); - - console.log('Register successful:', response); - statusMessage.value = "Register successful!"; + const responseToken = await EmailService.sendRegisterToken(input.value.email); + timerDeadline.value = new Date(responseToken.expirationTimestamp) + isSubmitted.value = true; + statusMessage.value = "" + isSubmitting.value = false; } catch (error) { - console.error("Register failed", error); - statusMessage.value = "Register failed. Please check your credentials."; + handleError(error); } - } else { - statusMessage.value = "Please fill out both email and password."; } }; -onMounted(() => { +/** + * Sends user information for registration once email verification is complete. + * + * @param {string} emailVerificationCode - The verification code to confirm email ownership. + */ +const sendVerificationAndUserInfo = async (emailVerificationCode) => { + try { + await userStore.dispatch('registerUser', { + email: input.value.email, + firstName: input.value.firstname, + lastName: input.value.lastname, + password: input.value.password, + emailVerificationCode + }); + + await router.push("/userDetails"); + } catch (error) { + handleError(error); + } +}; - window.addEventListener("resize", handleResize); -}); +/** + * Triggers email validation when the email input field loses focus. + * + * @returns {Promise<void>} - A promise that resolves after the email has been validated. + */ +async function handleEmailBlur(){ + await validateEmail(input.value.email) +} -onUnmounted(() => { - window.removeEventListener("resize", handleResize); -}); +/** + * Handles errors and displays appropriate messages to the user. + * + * @param {Error} error - The error object. + */ +function handleError(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 400: + statusMessage.value = "Ugyldig verifiseringskode."; + break; + case 409: + statusMessage.value = "E-postadresse er allerede i bruk." + break; + case 500: + statusMessage.value = "Intern serverfeil. Vennligst prøv igjen senere."; + break; + default: + statusMessage.value = error.response.data.errorMessage; + break; + } + } else { + console.error('Cannot connect to server.', error); + statusMessage.value = 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } +} </script> + <template> - <div class="container register"> + <div class="register-container" v-if="!isSubmitted"> <div class="center-element-container"> - <div - class="left-info" - v-if="!isMobileView"> + <div class="left-info"> + <img src="@/assets/img/pigrich.png" alt="Home"/> </div> <div class="form-container"> <div class="navigation"> - <span - @click="$emit('toggleView', true)" - :class="{ + <h5 + tabindex="0" + role="tab" + @keydown.enter="$emit('toggleView', true)" + @click="$emit('toggleView', true)" + :class="{ onFocusedLink: isLoginActive, defaultLink: !isLoginActive, }" - >Log In</span - > - <span - @click="$emit('toggleView', false)" - :class="{ + >Logg inn</h5> + <h5 + tabindex="0" + role="tab" + @keydown.enter="$emit('toggleView', false)" + @click="$emit('toggleView', false)" + :class="{ onFocusedLink: !isLoginActive, - defaultLink: isLoginActive, - }" - >Sign Up</span - > + defaultLink: isLoginActive}" + >Registrer bruker</h5> </div> <form - ref="form" - class="form-element-container" - @submit.prevent="handleRegisterClick" + ref="form" + class="form-element-container" + @submit.prevent="handleRegisterClick" > + <label for="email-input-filed">E-postadresse</label> <input - class="form-element" - for="email" - type="email" - required - v-model="input.email" - placeholder="E-mail" + id="email-input-filed" + class="form-element" + for="email" + type="email" + required + v-model="input.email" + placeholder="eksempel@mail.no" + @blur="handleEmailBlur" /> + <label for="firstname-input-filed">Fornavn</label> <input - class="form-element" - for="firstname" - type="text" - required - v-model="input.firstname" - placeholder="firstname" + id="firstname-input-filed" + class="form-element" + for="firstname" + type="text" + required + v-model="input.firstname" + placeholder="fornavn" /> + <label for="lastname-input-filed">Etternavn</label> <input - class="form-element" - for="lastname" - type="text" - required - v-model="input.lastname" - placeholder="lastname" + id="lastname-input-filed" + class="form-element" + for="lastname" + type="text" + required + v-model="input.lastname" + placeholder="etternavn" /> + <label for="password-input-filed">Passord</label> <input - class="form-element" - for="password" - required - v-model="input.password" - type="Password" - placeholder="password" + id="password-input-filed" + class="form-element" + for="password" + required + v-model="input.password" + type="Password" + placeholder="passord" /> - <button - type="submit" - @click="handleRegisterClick" - :disabled="!isFormFilledOut || isSubmitting" - :class="{ 'is-loading': isSubmitting }" - > - Continue + <button type="submit" data-cy="submit-button" @click.prevent="handleRegisterClick" :disabled="!isFormFilledOut"> + Fortsett </button> - <p>{{ statusMessage }}</p> + <spinning-wheel-component :visible="isSubmitting"/> + <p class="status-message">{{ statusMessage }}</p> </form> </div> </div> </div> + <div v-else> + <verification-view + @verification-submit="sendVerificationAndUserInfo" + @go-back="isSubmitted = false" + :email="input.email" + :timer-deadline="timerDeadline" + :error-message="statusMessage" + > + </verification-view> + </div> + </template> <style scoped> -.container { - height: 100vh; +.register-container { display: flex; - flex-direction: row; justify-content: center; - box-sizing: border-box; - align-items: center + align-items: center; + height: 100vh; + width: 100vw; + padding: 5px; +} + +.onFocusedLink { + color: var(--black-text); +} + +.defaultLink { + color: var(--grey-text); + text-decoration: none; +} + +.defaultLink:hover, .onFocusedLink:hover { + text-decoration: underline; +} + +.defaultLink, .onFocusedLink, .navigation span { + cursor: pointer; } .center-element-container { display: flex; - margin: 5%; width: 100%; - min-height: 300px; - max-height: 350px; + min-height: 420px; flex-wrap: nowrap; flex-direction: row; + justify-content: center; + box-sizing: border-box; } .left-info { - padding: 20px; - background-color: var(--dark-color); - flex: 1; - border-radius: 8px 0 0 8px; + min-width: 350px; + max-width: 350px; + min-height: 280px; display: flex; + flex: 1; flex-direction: column; justify-content: center; align-items: center; text-align: center; + padding: 20px; + background-color: var(--dark-color); + border-radius: var(--border-radius-general) 0 0 var(--border-radius-general); } .form-container { @@ -168,13 +348,13 @@ onUnmounted(() => { flex-direction: column; justify-content: space-between; padding: 20px; - min-width: 300px; + min-width: 350px; max-width: 350px; min-height: 280px; - max-height: 450px; - border-radius: 0 8px 8px 0; + border-radius: 0 var(--border-radius-general) var(--border-radius-general) 0; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); background-color: var(--accent-color); + box-sizing: border-box; } .form-element-container { @@ -196,30 +376,6 @@ onUnmounted(() => { outline: none; } -button { - background-color: var(--middle-color); - color: var(--white-text); - border: none; - cursor: pointer; - width: 40%; - height: 40px; - border-radius: 10px; - transition: transform 0.3s ease; - box-shadow: 2px 2px 4px 0 rgba(0, 0, 0, 0.25); -} - -button:hover { - background-color: var(--dark-color); - transform: translateY(-3px); -} - -button:disabled { - background-color: var(--accent-color-dark); - transform: translateY(0); - cursor: not-allowed; - color: var(--grey-text); -} - option:hover { background-color: var(--light-color); } @@ -231,7 +387,7 @@ button { width: 100%; padding: 10px; margin-bottom: 10px; - border-radius: 4px; + border-radius: var(--border-radius-general); border: 1px solid #ccc; box-sizing: border-box; } @@ -245,51 +401,29 @@ button { } .navigation span { - cursor: pointer; font-size: 1em; padding: 3px; transition: color 0.3s, text-decoration 0.3s; text-align: center; } -.onFocusedLink { - color: var(--black-text); - text-decoration: underline; -} - -.defaultLink { - color: var(--grey-text); - text-decoration: none; -} - select, .register option { font-weight: normal !important; } -.is-loading { - cursor: progress; +img { + filter: drop-shadow(0px 0px 10px black); + width: 100%; } -@keyframes spinner { - to { - transform: rotate(360deg); - } +label { + align-self: flex-start; + padding: 2px; + margin-left: 3px; } -.is-loading::after { - content: ''; - box-sizing: border-box; - position: absolute; - top: 50%; - left: 50%; - width: 50px; - height: 50px; - margin-top: -25px; - margin-left: -25px; - border-radius: 50%; - border: 4px solid var(--accent-color-dark); - border-top-color: var(--dark-color); - animation: spinner .6s linear infinite; +.status-message { + margin-top: 3%; } @media screen and (max-width: 919px) { @@ -308,9 +442,57 @@ select, .register option { } .form-container { - width: 90%; + width: 100%; + height: auto; + border-radius: var(--border-radius-general); + } +} + + +@media screen and (max-width: 400px) { + .center-element-container { + width: 100%; + flex-direction: column; + } + + .form-container, .left-info { + width: 100%; + min-width: auto; + max-width: none; + margin: 0 auto; + padding: 15px; + border-radius: var(--border-radius-general); + justify-content: flex-start; + } + + .form-element-container { + margin-top: 5%; + } + + .form-element, select, input, textarea, button { + margin-bottom: 5px; + } + + label { + margin-left: 0; + } + + .router-link { + margin-top: 5%; + } + + .status-message { + margin-top: 10px; + } + + img { + width: 80%; + margin: 0 auto; + } + + button { height: auto; - border-radius: 8px; + padding: 8px 16px; } } </style> diff --git a/src/components/news/NewsMain.vue b/src/components/news/NewsMain.vue index 73b25ebaea17bc86a07c5c797ff8a9d8b19e4fa6..fe38644f4919c2f54c424f5bd28ee4873c1a1e83 100644 --- a/src/components/news/NewsMain.vue +++ b/src/components/news/NewsMain.vue @@ -1,123 +1,264 @@ <script setup> +import {onMounted, ref, computed} from 'vue'; +import NewsService from '@/services/internal/NewsService'; +import BadgeService from '@/services/internal/BadgeService'; +import axios from "axios"; + + +const newsItems = ref([]); +const filterCategory = ref(''); +const categories = ref([]); +let page = 0; +const pageSize = 20; +const errorMessage = ref(''); +const newsBadge = ref(null); + +/** + * Fetches news items and creates a badge + */ +onMounted(async () => { + try { + newsBadge.value = await BadgeService.createBadge("EDUCATION"); + } catch (error) { + console.error('Failed to create news badge:', error); + } + + try { + const data = await NewsService.getNews(page, pageSize); + newsItems.value = data.filter(item => item.title && item.articleUrl && item.category); + categories.value = [...new Set(newsItems.value.map(item => item.category))]; + } catch (error) { + handleError(error) + } +}); + +/** + * Handles errors when fetching news items + * @param error - Error object + */ +const handleError = (error) => { + if (axios.isAxiosError(error) && error.response) { + errorMessage.value = 'Det oppstod en feil under henting av nyheter.'; + } else { + console.error('Cannot connect to server.', error); + errorMessage.value = 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } +}; + +/** + * Load more news items + */ +async function loadNewsItems() { + try { + errorMessage.value = ''; + page++; + await NewsService.getNews(page, pageSize).then(data => { + newsItems.value = [...newsItems.value, ...data]; + }); + } catch (error) { + handleError(error) + } +} + +/** + * Filters out invalid news items, and checks that they belong to the selected category + * @type {ComputedRef<UnwrapRefSimple<*>[]>} - List of news items + */ +const filteredNews = computed(() => { + return newsItems.value.filter(item => + (filterCategory.value === '' || item.category === filterCategory.value) && + item.title && item.articleUrl && item.category && item.imageUrl + ); +}); </script> <template> <div class="news-container"> - <h1 class="news-header">Nyheter</h1> - <div class="news-item"> - <div class="news-content"> - <h2 class="news-title">Tittel</h2> - <p class="news-info">info info info info info info info info info info info info info info info info</p> - </div> - <div class="news-image">Bilde</div> - </div> - <div class="news-item"> - <div class="news-content"> - <h2 class="news-title">Tittel</h2> - <p class="news-info">info info info info info info info info info info info info info info info info</p> - </div> - <div class="news-image">Bilde</div> + <div class="news-header"> + <h1>Dagens Nyheter</h1> + <h4>Hentet fra dn.no</h4> + <p class="error-text" v-if="errorMessage" data-cy="error-text">{{ errorMessage }}</p> </div> - <div class="news-item"> - <div class="news-content"> - <h2 class="news-title">Tittel</h2> - <p class="news-info">info info info info info info info info info info info info info info info info</p> - </div> - <div class="news-image">Bilde</div> - </div> + <label for="setting-category">Kategorier:</label> + <select id="setting-category" class="dropdown" data-cy="category-dropdown" v-model="filterCategory"> + <option value="">Alle kategorier</option> + <option v-for="category in categories" :key="category" :value="category">{{ category }}</option> + </select> - <div class="news-item"> - <div class="news-content"> - <h2 class="news-title">Tittel</h2> - <p class="news-info">info info info info info info info info info info info info info info info info</p> - </div> - <div class="news-image">Bilde</div> + <div v-for="(item, index) in filteredNews" :key="index" class="main-container" data-cy="news-container"> + <a class="news-item" :href="item.articleUrl" target="_blank" data-cy="news-link"> + <div class="news-content-container"> + <p class="news-info" data-cy="news-info">{{ item.category }}</p> + <h2 class="news-title" data-cy="news-title">{{ item.title }}</h2> + </div> + <div class="news-image-container"> + <img :src="item.imageUrl" alt="News Image"> + </div> + </a> </div> - <div class="news-item"> - <div class="news-content"> - <h2 class="news-title">Tittel</h2> - <p class="news-info">info info info info info info info info info info info info info info info info</p> - </div> - <div class="news-image">Bilde</div> - </div> + <button role="button" tabindex="0" class="load-button" @click="loadNewsItems">Last inn mer...</button> - <div class="news-item"> - <div class="news-content"> - <h2 class="news-title">Tittel</h2> - <p class="news-info">info info info info info info info info info info info info info info info info</p> - </div> - <div class="news-image">Bilde</div> - </div> </div> + </template> <style scoped> +.load-button { + width: 60%; + color: var(--white-text); + background-color: var(--dark-color); + border-radius: var(--border-radius-general); + cursor: pointer; +} + +.load-button:focus, +.load-button:hover { + background-color: #172434; +} + + +.main-container { + width: 100%; +} + +.news-item { + display: flex; + background-color: var(--dark-color); + border-radius: var(--border-radius-general); + padding: 1.5%; + margin: 1em 0; + flex-direction: row; + gap: 20px; + box-sizing: border-box; +} + +.news-item:hover { + background-color: #223142; +} + +.news-item:focus { + background-color: #223142; +} + + +.news-content-container { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: flex-start; + flex: 0 0 60%; +} + +.news-image-container { + width: 100%; + display: flex; + justify-content: center; + align-items: flex-end; + box-sizing: border-box; + overflow: hidden; + border-radius: var(--border-radius-general) +} + +.news-image-container img { + object-fit: contain; + max-height: 100%; + height: 100%; + width: auto; + border-radius: var(--border-radius-general); +} + +a { + text-decoration: none; + border-radius: var(--border-radius-general); +} + +.error-text { + color: var(--error-text); +} + +.dropdown { + margin-bottom: 20px; +} + .news-container { width: 100%; - max-height: 100vh; - overflow-y: auto; display: flex; flex-direction: column; align-items: center; - padding: 5%; + padding: 2.5%; box-sizing: border-box; text-align: center; - scrollbar-width: none; /* For Firefox */ justify-content: flex-start; } -.news-container::-webkit-scrollbar { - display: none; -} - .news-header { - font-size: 2em; - margin-bottom: 0.5em; + display: flex; + flex-direction: column; + gap: 20px; + margin-bottom: 20px; } -.news-item { - background-color: var(--accent-color); - border-radius: 8px; - padding: 5%; - margin-bottom: 1em; - display: flex; - justify-content: space-between; - align-items: center; +.news-info { + color: var(--white-text); + background-color: #010127; + padding: 5px 10px 5px 10px; + border-radius: var(--border-radius-general); } .news-title { - font-size: 1.2em; - margin-bottom: 10px; + font-size: 2em; + margin-top: 1em; + margin-bottom: 1em; justify-content: left; align-items: flex-start; + color: var(--white-text); } -.news-info { - color: var(--grey-text); +.news-title a { + color: var(--white-text); + text-decoration: none; } -.news-image { - border: 2px solid var(--dark-color); - border-radius: 4px; - padding: 5%; - width: 80px; - text-align: center; - margin: 5%; +.news-title a:hover { + text-decoration: underline; } - - @media screen and (max-width: 600px) { .news-item { flex-direction: column; + align-items: stretch; + min-height: auto; } - .news-image { - margin-top: 10px; + .news-content-container, + .news-image-container { + width: 100%; } + + .news-image-container { + order: -1; + margin-bottom: 1em; + } + + .news-info { + align-self: center; + } + + .news-image-container img { + max-width: 100%; + height: auto; + object-fit: contain; + } + + .news-content-container { + overflow: auto; + text-overflow: ellipsis; + } + + } -</style> \ No newline at end of file +</style> diff --git a/src/components/other/SpinningWheelComponent.vue b/src/components/other/SpinningWheelComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..49a0b440cf7d0dc6fbd77db76d034ea4426a2a6e --- /dev/null +++ b/src/components/other/SpinningWheelComponent.vue @@ -0,0 +1,34 @@ +<template> + <div class="spinner" v-if="visible"></div> +</template> + +<script setup> +import { defineProps } from 'vue'; + +defineProps({ + visible: Boolean +}); +</script> + +<style scoped> +.spinner { + box-sizing: border-box; + position: absolute; + top: 50%; + left: 50%; + width: 50px; + height: 50px; + margin-top: -25px; + margin-left: -25px; + border-radius: 50%; + border: 4px solid var(--accent-color-dark); + border-top-color: var(--dark-color); + animation: spinner 0.6s linear infinite; +} + +@keyframes spinner { + to { + transform: rotate(360deg); + } +} +</style> diff --git a/src/components/profile/AccountsComponent.vue b/src/components/profile/AccountsComponent.vue deleted file mode 100644 index 05dbf2e52cee2bdce9fd987f508a6d1e0a8b2f25..0000000000000000000000000000000000000000 --- a/src/components/profile/AccountsComponent.vue +++ /dev/null @@ -1,92 +0,0 @@ -<script setup> -</script> - -<template> - <div class="account-container"> - <h1>Kontoer</h1> - <div class="total-container"> - <h2>Totalt: 13 000kr</h2> - <button class="add-account-button">Legg til konto eller kort</button> - </div> - <div class="account-item"> - <div class="account-details"> - <h3>BRUKSKONTO</h3> - <p>9999 99 99999</p> - </div> - <div class="account-balance"> - <span>6500 kr</span> - </div> - </div> - <div class="account-item"> - <div class="account-details"> - <h3>SPAREKONTO</h3> - <p>9999 99 99999</p> - </div> - <div class="account-balance"> - <span>6500 kr</span> - </div> - </div> - <!-- Repeat for each account... --> - </div> -</template> - -<style scoped> -.account-container { - margin: 2rem; -} - -h1 { - text-align: center; - margin: 2rem; -} - -.total-container { - display: flex; - justify-content: space-between; - align-items: center; - border-radius: 8px; -} - -.total-container h2 { - color: var(--dark-color); - font-weight: bold; -} - -.account-item { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1rem; - padding: 1rem; - background-color: #D9D9D9; - border-radius: 8px; -} - -.account-details { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.account-details h3 { - margin: 0; - color: var(--black-text); - font-size: 1rem; -} - -.account-details p { - margin: 0; - color: var(--dark-color); - font-size: 0.8rem; -} - -.account-balance { - font-size: 1.1rem; -} - -.add-account-button { - padding: 0.5rem 1rem; - color: var(--dark-color); - cursor: pointer; -} -</style> \ No newline at end of file diff --git a/src/components/profile/BadgesComponent.vue b/src/components/profile/BadgesComponent.vue index 54561f8378a12d881d29c9b58ae36cc1b24e97d1..80d0c496dd816d34f2e57969af0e189b40105a56 100644 --- a/src/components/profile/BadgesComponent.vue +++ b/src/components/profile/BadgesComponent.vue @@ -1,78 +1,218 @@ +<script setup> +import {ref, onMounted, computed} from 'vue'; +import BadgeService from '@/services/internal/BadgeService'; +import {useStore} from 'vuex'; + +const achievements = ref([]); +const badges = ref([]); +const totalBadges = ref(0); +const store = useStore(); +const isAuthenticated = computed(() => store.state.user.isAuthenticated); + +const categoryTranslation = { + 'SAVING_STREAK': 'Sparestreak', + 'AMOUNT_SAVED': 'Beløp spart', + 'NUMBER_OF_CHALLENGES_COMPLETED': 'Antall utfordringer utført', + 'NUMBER_OF_SAVING_GOALS_ACHIEVED': 'Antall sparemÃ¥l oppnÃ¥dd', + 'EDUCATION': 'Utdanning' +}; + +onMounted(() => { + if (!isAuthenticated.value) { + return; + } + getSharedBadge(); + getAchievements(); + getBadges(); + linkBadgesToAchievements(); +}); + +/** + * Fetches all possible achievements from the backend. + */ +async function getAchievements() { + try { + achievements.value = await BadgeService.getAllBadges(); + } catch (error) { + console.error(error); + } +} + +/** + * Fetches all achieved badges from the backend. + */ +async function getBadges() { + try { + badges.value = await BadgeService.getBadges(15, 0); + totalBadges.value = badges.value.length; + } catch (error) { + console.error(error); + } +} + +/** + * Checks if the user has completed any shared saving goals with another user. + */ + async function getSharedBadge() { + try { + await BadgeService.createBadge('NUMBER_OF_SAVING_GOALS_ACHIEVED'); + } catch (error) { + console.error(error); + } +} + +/** + * Finds the achievement with the given category. + * @param {string} category The category of the achievement. + * @returns The achievement with the given category. + */ +const findAchievements = (category) => { + return achievements.value.find(achievement => achievement.category === category); +} + +/** + * Links badges to the corresponding achievements. + */ +const linkBadgesToAchievements = () => { + badges.value.forEach(badge => { + const achievement = findAchievements(badge.achievement); + if (achievement) { + badge.linkedAchievements = achievement; + } else { + console.log('No achievement found for badge:', badge); + } + }) +} + +/** + * Checks if the threshold for the given category and index is achieved. + * @param category - The category of the badge. + * @param index - The index of the threshold. + */ +const isThresholdAchieved = (category, index) => { + const achievement = findAchievements(category); + if (achievement) { + const badgeWithCategory = badges.value.filter(b => b.achievement === category); + for (const badge of badgeWithCategory) { + if (badge.threshold >= achievement.thresholds[index]) { + return true; + } + } + } + return false; + +} + +/** + * Gets the date the badge was achieved. + * @param {string} category The category of the badge. + * @returns The date the badge was achieved or an empty string if badge not achieved. + */ +const getDateAchieved = (category) => { + const badge = badges.value.find(badge => badge.achievement === category); + if (badge) { + return new Date(badge.achievementDate).toLocaleDateString(); + } + return ''; +}; + +</script> + <template> <div class="badges-container"> <h1>Badges</h1> - <div class="total-count">Totalt: 4</div> + <div class="total-count">Totalt: {{ totalBadges }}</div> <div class="badges-grid"> - <div class="badge"> - <img src="@/assets/img/badgeIcon.png" alt="ET Ã…RS STREAK" /> - <p class="badge-name">ET Ã…RS STREAK</p> + <div class="badge" v-for="achievement in achievements" :key="achievement.id"> + <h3>{{ categoryTranslation[achievement.category] }}</h3> + <p>{{ achievement.description }}</p> + <div class="threshold" v-for="(threshold, index) in achievement.thresholds" :key="index"> + <img src="@/assets/img/badgeIcon.png" :class="{ 'greyed-out': !isThresholdAchieved(achievement.category, index) }" alt="Badge image"> + <template v-if="isThresholdAchieved(achievement.category, index)"> + <h6>Dato oppnÃ¥dd: {{ getDateAchieved(achievement.category) }}</h6> + <p>{{ threshold }}</p> + </template> + <template v-else> + <p>{{ threshold }}</p> + </template> + </div> </div> - - <div class="badge"> - <img src="@/assets/img/badgeIcon.png" alt="OneAndDone" /> - <p class="badge-name">OneAndDone</p> - <span class="badge-count">2/10</span> - </div> - - <div class="badge"> - <img src="@/assets/img/badgeIcon.png" alt="BUDSJETTBEIST" /> - <p class="badge-name">BUDSJETTBEIST</p> - </div> - <!-- Repeat for other badges... --> </div> </div> </template> -<script setup> -</script> - <style scoped> -.badges-container { - margin: 2rem; -} - h1 { - text-align: center; + margin-top: 20px; +} +.badges-container { + display: flex; + flex-direction: column; + align-items: center; + overflow: auto; } .total-count { - font-size: 1.2rem; - margin-bottom: 1rem; color: var(--dark-color); } .badges-grid { - display: grid; - grid-template-columns: 33% 33% 33%; - gap: 1rem; + display: flex; + flex-wrap: wrap; + justify-content: center; + flex-direction: row; } .badge { - display: flex; - flex-direction: column; - align-items: center; - text-align: center; - max-height: 300px; + max-width: 200px; border: 1px solid #ddd; - border-radius: 10px; + border-radius: var(--border-radius-general); + margin: 0.2rem; padding: 1rem; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + text-align: center; +} + +.badge h3 { + height: 4em; + border-bottom: 1px solid #ddd; + text-align: center; + align-content: center; +} + +.badge p { + height: 4em; } .badge img { - max-width: 100%; - max-height: 200px; + width: 100%; + max-height: 100px; + object-fit: contain; +} + +.greyed-out { + filter: grayscale(100%); +} + +.threshold { + align-items: center; + justify-content: center; + margin: 0.5rem; } .badge-name { - color: var(--black-text); - font-size: 1rem; - text-overflow: ellipsis; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; margin-bottom: 0.5rem; } .badge-count { color: var(--dark-color); - font-size: 0.9rem; +} + +@media screen and (max-width: 800px) { + .badges-grid { + width: 100vw; + } } </style> \ No newline at end of file diff --git a/src/components/profile/BankStatementsComponent.vue b/src/components/profile/BankStatementsComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..38ca5542643ffe1e3a3e7e67ea792e42d20521cd --- /dev/null +++ b/src/components/profile/BankStatementsComponent.vue @@ -0,0 +1,401 @@ +<script setup> +import {ref, onMounted, watch} from 'vue'; +import BankStatementService from '@/services/internal/BankStatementService'; +import axios from "axios"; + +const bankStatements = ref([]); +const loading = ref(false); +const errorMessage = ref(''); +let selectedBank = ref(''); +const selectedFile = ref(null); + +/** + * Fetches bank statements from backend on component mount. + */ +onMounted(async () => { + selectedBank.value = ''; + try { + await fetchBankStatements(); + } catch (error) { + handleError(error, `Fikk ikke hentet kontoutskrifter`); + } +}); + +/** + * Watches for changes in selectedBank and selectedFile and resets the error message if both are set. + */ +watch([selectedBank, selectedFile], () => { + if (selectedBank.value !== "Velg bank" && selectedFile.value) { + errorMessage.value = ''; + } +}); + +/** + * Fetches bank statements from backend + */ +const fetchBankStatements = async () => { + loading.value = true; + try { + bankStatements.value = await BankStatementService.retrieveBankStatements(); + } catch (error) { + handleError(error, `Fikk ikke hentet kontoutskrifter`); + } + loading.value = false; +}; + +/** + * Handles file selection event and uploads the file to backend. + * @param {Event} event - The file selection event. + */ +const handleFileSelection = async (event) => { + const file = event.target.files[0]; + if (!selectedBank.value || selectedBank.value === "") { + event.target.value = null; + selectedFile.value = null; + errorMessage.value = 'Du mÃ¥ velge en bank før du laster opp en fil. Vennligst prøv igjen.'; + return; + } + + if (file && file.type === 'application/pdf') { + selectedFile.value = file; + errorMessage.value = ''; + await uploadBankStatement(); + } else { + event.target.value = null; + selectedFile.value = null; + errorMessage.value = 'Vennligst last opp en gyldig PDF-fil.'; + } +}; + +/** + * Uploads the selected file to backend. If successful, analyzes the file. + */ +const uploadBankStatement = async () => { + if (!selectedFile.value) { + errorMessage.value = 'Ingen fil valgt for opplasting.'; + return; + } + if (!selectedBank.value) { + errorMessage.value = 'Ingen bank valgt for opplasting.'; + return; + } + + loading.value = true; + try { + const formData = new FormData(); + formData.append('file', selectedFile.value); + const statementId = await BankStatementService.addBankStatement(formData, selectedBank.value); + errorMessage.value = 'Kontoutskrift lastet opp. Behandler...'; + await analyzeBankStatement(statementId); + await fetchBankStatements(); + selectedFile.value = null; + } catch (error) { + handleError(error, `Feil ved opplasting av kontoutskrift.`); + } finally { + loading.value = false; + selectedFile.value = null; + } +}; + +/** + * Analyzes the bank statement with the given ID. + * @param {number} statementId - The ID of the bank statement to analyze. + */ +const analyzeBankStatement = async (statementId) => { + try { + await BankStatementService.analyzeBankStatement(statementId); + errorMessage.value = 'Kontoutskrift lastet opp og analysert suksessfullt.'; + } catch (error) { + handleError(error, `Filen ble lastet opp, men det var feil under analysen`); + selectedFile.value = null; + } +}; + +/** + * Deletes the bank statement with the given ID. + * @param {number} id - The ID of the bank statement to delete. + */ +const deleteBankStatement = async (id) => { + try { + await BankStatementService.deleteBankStatement(id); + errorMessage.value = 'Kontoutskrift slettet.'; + await fetchBankStatements(); + } catch (error) { + handleError(error, `Kunne ikke slette kontoutskrift`); + } +}; + +/** + * Reanalyzes the bank statement with the given ID. + * + * @param {number} statementId - The ID of the bank statement to reanalyze. + */ +const reanalyzeBankStatement = async (statementId) => { + try { + loading.value = true; + errorMessage.value = 'Analyserer kontoutskrift pÃ¥ nytt...'; + await analyzeBankStatement(statementId); + } catch (error) { + handleError(error, `Feil ved reanalyse av kontoutskrift`); + } finally { + loading.value = false; + await fetchBankStatements(); + } +}; + +/** + * Handles errors and displays appropriate messages to the user. + * + * @param {Error} error - The error object. + * @param {String} message - The error message. + */ +function handleError(error, message) { + if (axios.isAxiosError(error) && error.response) { + errorMessage.value = message; + } else { + console.error('Cannot connect to server.', error); + errorMessage.value = 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } +} + +</script> + +<template> + <div class="main-container-bankStatements"> + <h1>Kontoutskrifter</h1> + <div class="bank-statements-container"> + <div class="bankStatement-input-container"> + <h4>Legg til kontoutskrift</h4> + + <select class="dropdown" v-model="selectedBank" data-cy="bank-select"> + <option value="">Velg bank</option> + <option value="DNB">DNB</option> + <option value="HANDELSBANKEN">HANDELSBANKEN</option> + <option value="SPAREBANK1">SPAREBANK1</option> + <option value="ANNET">ANNET</option> + </select> + + <label class="button"> + Velg fil + <input type="file" @change="handleFileSelection" accept="application/pdf" multiple style="display: none;" + data-cy="file-input"> + </label> + </div> + + <div v-if="errorMessage" class="error" data-cy="error-message">{{ errorMessage }}</div> + + <div v-if="loading">Laster...</div> + <div class="scrollable-table"> + <table class="statements-table"> + <thead> + <tr> + <th>Kontonummer</th> + <th>Dato</th> + <th>Analyse</th> + <th>Slett</th> + </tr> + </thead> + + <tbody> + <tr v-for="statement in bankStatements" :key="statement.id"> + <td>{{ statement.accountNumber }}</td> + <td>{{ statement.timestamp }}</td> + <td v-if="statement.analysisIsPresent">Fullført</td> + <td v-else> + <button @click="reanalyzeBankStatement(statement.id)" class="reanalyze-button">Reanalyser</button> + </td> + <td> + <button @click="deleteBankStatement(statement.id)" class="delete-button">Slett</button> + </td> + </tr> + </tbody> + </table> + </div> + </div> + </div> +</template> + +<style scoped> +.error { + color: var(--error-text); + font-size: 14px; + text-align: center; + padding: 10px; + border-radius: var(--border-radius-general); + margin: 10px 0 0 10px; +} + +button { + width: 100%; + height: 40px; +} + +.dropdown { + height: 40px; + background-color: var(--accent-color-dark); +} + +h4 { + display: flex; + justify-content: center; + align-content: center; + text-align: left; + text-justify: distribute-center-last; + padding: 0; +} + +.input-buttons-container { + display: flex; + flex-direction: row; + width: 50%; + justify-content: space-evenly; + gap: 10px; +} + +.main-container-bankStatements { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + padding: 2.5%; + height: 100vh; + padding-top: 40px; +} + +.bankStatement-input-container { + background-color: var(--accent-color); + border-radius: var(--border-radius-general); + padding: 2.5%; + display: flex; + align-items: center; + align-content: center; + justify-content: center; + gap: 10px; + margin: 20px 0 20px 0; +} + +.bank-statements-container { + text-align: left; + height: 100%; + width: 100%; +} + +.statements-table { + width: 100%; + table-layout: fixed; +} + +.statements-table th, +.statements-table td { + padding: 12px; + text-align: left; + border: 1px solid #ddd; + word-wrap: break-word; + overflow-wrap: break-word; +} + +.statements-table th { + background-color: #f4f4f4; + color: #333; +} + +.statements-table td { + background-color: #fff; +} + +.statements-table tr:nth-child(even) { + background-color: #f9f9f9; +} + +.statements-table th { + background-color: var(--dark-color); + color: white; +} + +.delete-button { + background-color: var(--error-text); + color: white; + border: none; + padding: 4px 8px; + border-radius: var(--border-radius-general); + cursor: pointer; +} + +.delete-button:hover { + background-color: var(--error-text); +} + +.button { + background-color: var(--middle-color); + color: var(--white-text); + cursor: pointer; + width: 40%; + height: 40px; + border-radius: var(--border-radius-general); + transition: transform 0.3s ease, background-color 0.3s ease; + box-shadow: 2px 2px 4px 0 rgba(0, 0, 0, 0.25); + outline: none; + border-style: none; + text-align: center; + padding: 10px; +} + +.button:hover { + background-color: var(--dark-color); + transform: translateY(-2px); +} + +.button:disabled { + background-color: var(--light-color); + transform: translateY(0); + cursor: not-allowed; + color: var(--black-text); + opacity: 0.6; +} + +.scrollable-table { + max-height: 75%; + overflow-y: auto; + width: 100%; + margin-bottom: 20px; +} + +.button, .dropdown, h4 { + width: 100%; +} + +@media screen and (max-width: 800px) { + .main-container-bankStatements { + display: flex; + justify-content: center; + align-items: center; + flex-direction: column; + padding: 2.5%; + height: 80vh; + } + .statements-table th:nth-child(2), + .statements-table td:nth-child(2) { + display: none; + } +} + +@media screen and (max-width: 500px) { + .bankStatement-input-container h4 { + display: none; + } + + .bankStatement-input-container { + flex-direction: column; + align-items: center; + padding: 10px; + } + + .statements-table th:last-child, .statements-table td:last-child { + width: 100px; + } +} + +.statements-table td:nth-child(1) { + word-break: break-all; +} +</style> diff --git a/src/components/profile/MyProfileComponent.vue b/src/components/profile/MyProfileComponent.vue index 0a10b9a7ce42e129523564759fd3d3784a7a9428..df88c83ea456c4baf75a8056a9d7c4d9658df512 100644 --- a/src/components/profile/MyProfileComponent.vue +++ b/src/components/profile/MyProfileComponent.vue @@ -1,9 +1,569 @@ <script setup> +import {ref, computed} from 'vue'; +import {useStore} from 'vuex'; +import UserDetailsService from '@/services/internal/UserDetailsService'; +import UserDetailsValidationService from '@/services/internal/UserDetailsInputService'; +import ChangePasswordDTO from '@/models/user/ChangePasswordDTO'; +import EmailService from "@/services/internal/EmailService"; +import VerificationView from "@/views/VerificationView.vue"; +import router from "@/router"; +import SpinningWheelComponent from "@/components/other/SpinningWheelComponent.vue"; + +const store = useStore(); +const originalFirstName = computed(() => store.state.user.firstName); +const originalLastName = computed(() => store.state.user.lastName); +const originalEmail = computed(() => store.state.user.email); +const originalIncome = computed(() => store.state.user.income); +const originalSavingPercentage = computed(() => store.state.user.savingPercentage); +const originalLivingStatus = computed(() => store.state.user.livingStatus); + +const firstName = ref(originalFirstName.value); +const lastName = ref(originalLastName.value); +const income = ref(originalIncome.value); +const savingPercentage = ref(originalSavingPercentage.value) +const livingStatus = ref(originalLivingStatus.value); +const oldPassword = ref(''); +const newPassword = ref(''); +const confirmPassword = ref(''); + +const statusMessages = ref({ + userInfo: '', + changePassword: '', + deleteUser: '' +}); + +const editMode = ref(false); + +const deleteConfirmation = ref(false); +const userDeletionInProgress = ref(false); +const deletionConfirmed = ref(false); + +const timerDeadline = ref(null); + +/** + * Toggles the edit mode for user details + */ +async function toggleEdit() { + if (editMode.value) { + await saveAllChanges(); + } + editMode.value = !editMode.value; + if (!editMode.value) { + firstName.value = originalFirstName.value; + lastName.value = originalLastName.value; + income.value = originalIncome.value; + savingPercentage.value = originalSavingPercentage.value; + livingStatus.value = originalLivingStatus.value; + } +} + +/** + * Update first name if changed and not empty. + */ +async function updateFirstName() { + if (!firstName.value.trim()) { + statusMessages.value.userInfo = 'Fornavn kan ikke være tomt.'; + return; + } + if (firstName.value.trim() !== originalFirstName.value) { + try { + await store.dispatch('changeFirstName', firstName.value.trim()); + statusMessages.value.userInfo = 'Fornavn oppdatert.'; + } catch (error) { + console.error("Failed to update first name.", error); + statusMessages.value.userInfo = error.response.data.errorMessage; + } + } +} + +/** + * Update last name if changed and not empty. + */ +async function updateLastName() { + if (!lastName.value.trim()) { + statusMessages.value.userInfo = 'Etternavn kan ikke være tomt.'; + return; + } + if (lastName.value.trim() !== originalLastName.value) { + try { + await store.dispatch('changeLastName', lastName.value.trim()); + statusMessages.value.userInfo = 'Etternavn oppdatert.'; + } catch (error) { + console.error("Failed to update last name.", error); + statusMessages.value.userInfo = error.response.data.errorMessage; + } + } +} + +/** + * Update income if changed. + */ +async function updateIncome() { + if (!income.value) { + statusMessages.value.userInfo = 'Inntekt kan ikke være tom.'; + return; + } + if (income.value !== originalIncome.value) { + try { + await store.dispatch('changeIncome', income.value); + statusMessages.value.userInfo = 'Inntekt oppdatert.'; + } catch (error) { + console.error("Failed to update income.", error); + statusMessages.value.userInfo = error.response.data.errorMessage; + } + } +} + +/** + * Update saving percentage if changed. + */ +async function updateSavingPercentage() { + if (!savingPercentage.value) { + statusMessages.value.userInfo = 'Spareprosent kan ikke være tom.'; + return; + } + if (savingPercentage.value !== originalSavingPercentage.value) { + try { + await store.dispatch('changeSavingPercentage', savingPercentage.value); + statusMessages.value.userInfo = 'Spareprosent oppdatert.'; + } catch (error) { + console.error("Failed to update income.", error); + statusMessages.value.userInfo = error.response.data.errorMessage; + } + } +} + +/** + * Update living status if changed. + */ +async function updateLivingStatus() { + if (livingStatus.value !== originalLivingStatus.value) { + try { + await store.dispatch('changeLivingStatus', livingStatus.value); + statusMessages.value.userInfo = 'Bosituasjon oppdatert.'; + } catch (error) { + console.error("Failed to update living status.", error); + statusMessages.value.userInfo = error.response.data.errorMessage; + } + } +} + +/** + * Saves all changes to the user details + */ +async function saveAllChanges() { + await updateFirstName(); + await updateLastName(); + await updateIncome(); + await updateSavingPercentage() + await updateLivingStatus(); +} + +/** + * Saves the new password for the user + */ +async function saveNewPassword() { + if (!canSavePassword()) { + return; + } + + try { + const changePasswordDTO = new ChangePasswordDTO(oldPassword.value, newPassword.value); + await UserDetailsService.changePassword(changePasswordDTO); + oldPassword.value = ''; + newPassword.value = ''; + confirmPassword.value = ''; + statusMessages.value.changePassword = 'Passord endret.'; + } catch (error) { + statusMessages.value.changePassword = 'Failed to change password. ' + error.response.data.errorMessage; + } +} + +/** + * Checks if the password can be saved + */ +function canSavePassword() { + try { + UserDetailsValidationService.validateEditPassword(newPassword.value, confirmPassword.value, oldPassword.value); + return true; + } catch (err) { + statusMessages.value.changePassword = err.message; + return false; + } +} + +/** + * Initiates the user deletion confirmation process. + */ +function confirmDelete() { + deleteConfirmation.value = true; +} + +/** + * Cancels the user deletion confirmation process. + */ +function cancelDelete() { + deleteConfirmation.value = false; +} + +/** + * Handles the user deletion confirmation + */ +async function handleDeleteConfirmation() { + + deletionConfirmed.value = true; + + try { + const emailCodeExpirationDto = await EmailService.sendRegisterToken(originalEmail.value); + timerDeadline.value = new Date(emailCodeExpirationDto.expirationTimestamp); + userDeletionInProgress.value = true; + } catch (error) { + statusMessages.value.deleteUser = error.response.data.errorMessage; + } + + deleteConfirmation.value = false; + deletionConfirmed.value = false; +} + +/** + * Handles the user deletion process + */ +async function handleDeleteUser(emailVerificationCode) { + try { + await UserDetailsService.deleteUser(emailVerificationCode); + await store.dispatch("logout"); + await router.push('/login'); + } catch (error) { + statusMessages.value.deleteUser = 'Failed to delete user. ' + error.response.data.errorMessage; + } +} </script> <template> - <div> + <div class="my-profile-main-container" v-if="!userDeletionInProgress"> + <h1>Min Profil</h1> + + <div class="my-profile-container"> + + <div class="user-info"> + + <div class="title-with-button"> + <h2>Bruker informasjon</h2> + <div class="action-buttons"> + <button @click="toggleEdit" class="edit-button" data-cy="edit-button">{{ + editMode ? 'Ferdig' : 'Rediger' + }} + </button> + <button @click="confirmDelete" class="delete-button">Slett bruker</button> + </div> + </div> + + <div class="field-row"> + <label for="firstName">Fornavn:</label> + <div class="input-icon-container"> + <input type="text" id="firstName" v-model="firstName" :disabled="!editMode"> + </div> + </div> + + <div class="field-row"> + <label for="lastName">Etternavn:</label> + <div class="input-icon-container"> + <input type="text" id="lastName" v-model="lastName" :disabled="!editMode"> + </div> + </div> + + <div class="field-row"> + <label for="email">Email:</label> + <div class="input-icon-container"> + <input type="email" id="email" v-model="originalEmail" disabled> + </div> + </div> + + <div class="field-row"> + <label for="income">Inntekt (kr)</label> + <div class="input-icon-container"> + <input type="number" id="income" v-model="income" :disabled="!editMode"> + </div> + </div> + + <div class="field-row"> + <label for="savingPercentage">Prosent (%) av inntekt Ã¥ spare</label> + <div class="input-icon-container"> + <input type="number" id="savingPercentage" v-model="savingPercentage" :disabled="!editMode"> + </div> + </div> + + <div class="field-row" id="last-field-row-element"> + <label for="livingStatus">Bosituasjon:</label> + <select id="livingStatus" v-model="livingStatus" :disabled="!editMode"> + <option value="0">Annet</option> + <option value="1">Bor alene</option> + <option value="2">Par uten barn</option> + <option value="3">Par med barn</option> + </select> + </div> + + <p class="status-message" v-if="statusMessages.userInfo" data-cy="user-status-message"> + {{ statusMessages.userInfo }}</p> + + </div> + + <form @submit.prevent="saveNewPassword" class="password-form"> + + <h2>Endre passord</h2> + + <div class="field-row"> + <label for="oldPassword">Gammelt passord:</label> + <input type="password" id="oldPassword" v-model="oldPassword" data-cy="oldPassword-input"> + </div> + + <div class="field-row"> + <label for="newPassword">Nytt passord:</label> + <input type="password" id="newPassword" v-model="newPassword"> + </div> + + <div class="field-row"> + <label for="confirmPassword">Bekreft nytt passord:</label> + <input type="password" id="confirmPassword" v-model="confirmPassword"> + </div> + + <button class="form-button" type="submit" data-cy="edit-password-button">Lagre passord</button> + + <p class="status-message" v-if="statusMessages.changePassword" data-cy="password-status-message"> + {{ statusMessages.changePassword }}</p> + + </form> + + </div> + + <div v-if="deleteConfirmation" class="delete-confirmation-box"> + <div class="confirmation-content"> + <p>Er du sikker pÃ¥ at du vil slette brukeren?</p> + <button @click="cancelDelete" class="cancel-delete-button">Avbryt</button> + <button @click="handleDeleteConfirmation" class="confirm-delete-button">Slett</button> + </div> + </div> + + <spinning-wheel-component :visible="deletionConfirmed"></spinning-wheel-component> + + </div> + + <div v-else> + <verification-view + @verification-submit="handleDeleteUser" + @go-back="userDeletionInProgress = false" + :email="originalEmail" + :timer-deadline="timerDeadline" + :error-message="statusMessages.deleteUser" + > + </verification-view> </div> + </template> + +<style scoped> +h2 { + margin-bottom: 15px; +} + +button { + padding: 5px 10px; + color: white; + border: none; + border-radius: var(--border-radius-general); + transition: background-color 0.3s ease; +} + +.my-profile-main-container { + display: flex; + flex-direction: column; + gap: 20px; + align-items: center; + justify-content: center; + width: 100%; + min-height: 100vh; + padding: 2.5%; + box-sizing: border-box; + overflow-y: auto; +} + + +.my-profile-container { + text-align: left; + width: 100%; + max-height: 85vh; + overflow-y: auto; + padding: 20px; + box-sizing: border-box; + margin-bottom: 20px; +} + + +.title-with-button { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 15px; +} + +.action-buttons { + display: flex; + gap: 20px; +} + +.edit-button { + background-color: var(--middle-color); +} + +.edit-button:hover { + background-color: var(--dark-color); +} + +.delete-button { + background-color: #af0000; +} + +.delete-button:hover { + background-color: #6c0101; +} + +.field-row { + margin-bottom: 20px; +} + +.field-row label { + display: block; +} + +.input-icon-container { + display: flex; + align-items: center; +} + +.field-row input { + width: 100%; + padding: 8px; + box-sizing: border-box; +} + +.field-row button.check-button { + width: auto; + height: auto; + border: none; + cursor: pointer; + margin-left: 10px; +} + +.field-row select { + padding: 8px; + box-sizing: border-box; +} + +.user-info, +.password-form { + background-color: #f2f2f2; + padding: 20px; + border-radius: var(--border-radius-general); +} + +.user-info { + margin-bottom: 20px; +} + +#last-field-row-element { + margin: 0; +} + +.my-profile-container::-webkit-scrollbar-track { + background-color: transparent; +} + +.my-profile-container::-webkit-scrollbar-thumb { + background-color: var(--middle-color); + border-radius: var(--border-radius-general); + border: 3px solid transparent; +} + +.my-profile-container::-webkit-scrollbar { + width: 5px; +} + +.my-profile-container::-webkit-scrollbar-thumb:hover { + background-color: var(--dark-color); +} + +.form-button { + background-color: var(--middle-color); +} + +.form-button:hover { + background-color: var(--dark-color); +} + +.status-message { + margin-top: 2%; + font-weight: bold; +} + +.delete-confirmation-box { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; +} + +.confirmation-content { + background-color: white; + padding: 20px; + border-radius: var(--border-radius-general); + text-align: center; +} + +.confirmation-content button { + border-radius: var(--border-radius-general); + padding: 5px 10px; + border: none; + margin-top: 3%; +} + +.confirm-delete-button { + margin-left: 3%; + background-color: darkred; + color: white; +} + +.confirm-delete-button:hover { + background-color: #5f0101; +} + +.cancel-delete-button { + background-color: var(--middle-color); +} + +.cancel-delete-button:hover { + background-color: var(--dark-color); +} + +@media screen and (max-width: 650px) { + .action-buttons { + flex-direction: row; + } + + button { + width: 100% + } + + .title-with-button { + flex-direction: column; + } +} + +</style> + diff --git a/src/components/profile/PersonalInfoComponent.vue b/src/components/profile/PersonalInfoComponent.vue deleted file mode 100644 index dfce17d63219d978d02146a33634c0ec3b95d091..0000000000000000000000000000000000000000 --- a/src/components/profile/PersonalInfoComponent.vue +++ /dev/null @@ -1,9 +0,0 @@ -<script setup> - -</script> - -<template> - <div> - <h1>Personlige opplysninger</h1> - </div> -</template> diff --git a/src/components/profile/ProfileBar.vue b/src/components/profile/ProfileBar.vue index 663c5c76c18e8a0ae2dafa05a47e9adc2c508bc8..9b5fd6938898721bd287a8f19533f19aa13c9e63 100644 --- a/src/components/profile/ProfileBar.vue +++ b/src/components/profile/ProfileBar.vue @@ -1,55 +1,104 @@ <script setup> -import { defineEmits } from 'vue'; +import {defineEmits} from 'vue'; const emit = defineEmits(['select']); function emitSelect(componentName) { - emit('select', componentName); + emit('select', componentName); } </script> <template> - <div class="profile-bar"> - <ul> - <li class="menu-item" @click="emitSelect('MyProfile')">Min profil</li> - <li class="menu-item" @click="emitSelect('PersonalInfo')">Personlige opplysninger</li> - <li class="menu-item" @click="emitSelect('Settings')">Innstillinger</li> - <li class="menu-item" @click="emitSelect('Accounts')">Kontoer</li> - <li class="menu-item" @click="emitSelect('Badges')">Badges</li> - </ul> + <div class="profile-bar"> + <div class="profile-menu"> + <ul> + <li role="button" @keydown.enter="emitSelect('MyProfile')" + tabindex="0" class="menu-item" @click="emitSelect('MyProfile')" data-cy="my-profile-link"> + Min profil + <img src="@/assets/img/user.png" alt=""> + </li> + <li role="button" @keydown.enter="emitSelect('BankStatements')" + tabindex="0" class="menu-item" @click="emitSelect('BankStatements')" data-cy="bank-statements-link"> + Kontoutskrifter + <img src="@/assets/img/printer.png" alt=""> + </li> + <li role="button" @keydown.enter="emitSelect('Settings')" + tabindex="0" class="menu-item" @click="emitSelect('Settings')" data-cy="settings-link"> + Innstillinger + <img src="@/assets/img/settings.png" alt=""> + </li> + <li role="button" @keydown.enter="emitSelect('Badges')" + tabindex="0" class="menu-item" @click="emitSelect('Badges')" data-cy="badges-link"> + Badges + <img src="@/assets/img/newBadge.png" alt=""> + </li> + </ul> </div> + </div> </template> <style scoped> .profile-bar { - background-color: #304C6C; - border-radius: 10px; - max-width: 80%; - margin: auto; - box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + display: flex; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); + height: 100vh; +} + +.profile-menu { + background: linear-gradient(var(--middle-color), var(--dark-color)); + width: 100%; + height: 100vh; + margin: auto; + text-align: center; + display: flex; + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2); } .profile-bar ul { - list-style: none; - padding: 0; - margin: 0; + list-style: none; + padding: 0; + margin: 0; +} + +.profile-menu ul { + display: flex; + align-items: center; + flex-direction: column; + width: 100%; + padding-top: 30%; } .menu-item { - background-color: #ffffff; - border: none; - border-radius: 5px; - padding: 1em 1.5em; - margin: 1em; - width: calc(100% - 2em); - box-sizing: border-box; - text-align: center; - font-size: 1em; - cursor: pointer; - transition: background-color 0.3s; -} - -.menu-item:hover { - background-color: #e0e0e0; + background-color: #ffffff; + border: none; + border-radius: var(--border-radius-general); + padding: 1em 1.5em; + margin: 1em; + width: 80%; + box-sizing: border-box; + text-align: center; + cursor: pointer; + transition: background-color 0.3s, transform 0.3s; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); + height: 70px; + justify-content: center; + align-items: center; + display: flex; + flex-wrap: wrap; + white-space: normal; + overflow: hidden; +} + +.menu-item:hover, +.menu-item.active { + background-color: var(--light-color); + transform: translateY(-2px); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3); +} + +img { + max-height: 80%; + max-width: 80%; + margin-left: 5%; } </style> \ No newline at end of file diff --git a/src/components/profile/ProfileDropdown.vue b/src/components/profile/ProfileDropdown.vue new file mode 100644 index 0000000000000000000000000000000000000000..eba4082c7f72cd5335ecadb702e9425a0b24ab3e --- /dev/null +++ b/src/components/profile/ProfileDropdown.vue @@ -0,0 +1,110 @@ +<script setup> +import { ref, defineEmits } from 'vue'; + +const emit = defineEmits(['select']); + +const isOpen = ref(false); +const currentPage = ref('Min profil'); + +const pageTitles = { + MyProfile: 'Min profil', + BankStatements: 'Kontoutskrifter', + Settings: 'Innstillinger', + Badges: 'Badges' +}; + +function emitSelect(componentName) { + if (currentPage.value !== pageTitles[componentName] || currentPage.value === 'Min profil') { + currentPage.value = pageTitles[componentName] || componentName; + emit('select', componentName); + } + isOpen.value = false; +} + +function toggleDropdown() { + isOpen.value = !isOpen.value; +} +</script> + +<template> + <div class="profile-dropdown"> + <button class="dropdown-toggle" @click="toggleDropdown"> + {{ currentPage }} <span class="caret"></span> + </button> + <ul v-if="isOpen"> + <li class="menu-item" @click="emitSelect('MyProfile')" data-cy="my-profile-link"> + Min profil + <img src="@/assets/img/user.png" alt=""> + </li> + <li class="menu-item" @click="emitSelect('BankStatements')" data-cy="bank-statements-link"> + Kontoutskrifter + <img src="@/assets/img/printer.png" alt=""> + </li> + <li class="menu-item" @click="emitSelect('Settings')" data-cy="settings-link"> + Innstillinger + <img src="@/assets/img/settings.png" alt=""> + </li> + <li class="menu-item" @click="emitSelect('Badges')" data-cy="badges-link"> + Badges + <img src="@/assets/img/newBadge.png" alt=""> + </li> + </ul> + </div> +</template> + +<style scoped> +img { + max-height: 20px; + width: auto; + vertical-align: middle; +} + +.profile-dropdown { + border-radius: var(--border-radius-general); + max-width: 80%; + margin: auto; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + cursor: pointer; + display: inline-block; + position: relative; +} + +.dropdown-toggle { + display: flex; + justify-content: space-between; + align-items: center; + background-color: var(--dark-color); + color: var(--white-text); + border: none; + padding: 0.5em 1em; + border-radius: var(--border-radius-general); + cursor: pointer; + transition: background-color 0.3s; + width: 100%; + text-align: left; +} + +.profile-dropdown ul { + background-color: #e0e0e0; + list-style: none; + padding: 0; + margin: 0; + width: 100%; + display: block; + position: absolute; +} + +.menu-item { + border: none; + box-sizing: border-box; + text-align: center; + padding: 0.7em; + border: 1px solid #c0c0c0; +} + +.caret { + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid #000; +} +</style> diff --git a/src/components/profile/ProfileMain.vue b/src/components/profile/ProfileMain.vue index c1a586895d6b4589039225fc6438faaa73778106..4cf24e1616438275c3ff788822844af7f5422bbd 100644 --- a/src/components/profile/ProfileMain.vue +++ b/src/components/profile/ProfileMain.vue @@ -2,8 +2,7 @@ import { computed } from 'vue'; import { defineProps } from 'vue'; import MyProfile from '@/components/profile/MyProfileComponent.vue'; -import PersonalInfo from '@/components/profile/PersonalInfoComponent.vue'; -import Accounts from '@/components/profile/AccountsComponent.vue'; +import BankStatements from '@/components/profile/BankStatementsComponent.vue'; import Badges from '@/components/profile/BadgesComponent.vue'; import Settings from '@/components/profile/SettingsComponent.vue'; @@ -15,10 +14,8 @@ const currentComponent = computed(() => { switch (props.component) { case 'MyProfile': return MyProfile; - case 'PersonalInfo': - return PersonalInfo; - case 'Accounts': - return Accounts; + case 'BankStatements': + return BankStatements; case 'Badges': return Badges; case 'Settings': @@ -30,7 +27,5 @@ const currentComponent = computed(() => { </script> <template> - <div> <component :is="currentComponent" /> - </div> </template> \ No newline at end of file diff --git a/src/components/profile/SettingsComponent.vue b/src/components/profile/SettingsComponent.vue index b255e05dde6e58266c847b18afd41169da9dec58..9f8d2ec4e7fe2e56f4d1fb406329e1156726c77f 100644 --- a/src/components/profile/SettingsComponent.vue +++ b/src/components/profile/SettingsComponent.vue @@ -1,24 +1,16 @@ <script setup> -//todo lag ogsÃ¥ egne knapper som brukes pÃ¥ tvers av hele nettsiden, i steden for Ã¥ lage knapper per side -//todo bruk noe form for sessionstorage sÃ¥ stilene blir satt globalt, og blir lagret etter bruker. - -import DarkLightMode from "@/components/profile/settingsOptionComponents/DarkLightMode.vue"; import FontSize from "@/components/profile/settingsOptionComponents/FontSize.vue"; import FontFamily from "@/components/profile/settingsOptionComponents/FontFamily.vue"; -import ChangeLanguage from "@/components/profile/settingsOptionComponents/ChangeLanguage.vue"; - +import ChangeCookiePrivilege from "@/components/profile/settingsOptionComponents/ChangeCookiePrivilege.vue"; +import BorderRadius from "@/components/profile/settingsOptionComponents/BorderRadius.vue"; </script> <template> - <div class="main-container"> - <h1>Settings</h1> + <div class="settings-main-container"> + <h1>Innstillinger</h1> <div class="category"> - <h2>Design and layout:</h2> - - <div class="setting-item"> - <DarkLightMode/> - </div> + <h2>Design og layout:</h2> <div class="setting-item"> <FontSize/> @@ -29,73 +21,61 @@ import ChangeLanguage from "@/components/profile/settingsOptionComponents/Change </div> <div class="setting-item"> - <ChangeLanguage/> - </div> - - <div class="setting-item"> - <h4>Roundness</h4> - </div> - - <div class="setting-item"> - <h4>Infobar filter (evt senere)</h4> + <BorderRadius/> </div> <div class="setting-item"> - <h4>color scheme (evt senere)</h4> + <ChangeCookiePrivilege/> </div> </div> - - <div class="category"> - <h2>Account:</h2> - <h4>Change password</h4> - <h4>Change name(first and/or last)</h4> - <h4>Change mail</h4> - <h4>Notification</h4> - <h4>Activity</h4> - </div> - - <div class="category"> - <h2>Resources</h2> - <h4>User manual</h4> - <h4>Upload bankutskrift</h4> - <h4>Download oppsummering</h4> - </div> </div> </template> <style scoped> -@import "@/assets/css/dropBox.css"; - -/*Scrollbar*/ -.main-container::-webkit-scrollbar { +h1 { + display: flex; + justify-content: center; + align-content: center; + text-align: center; + margin-bottom: 20px; +} +.settings-main-container::-webkit-scrollbar { display: none; } -/*Hoved midterste skjermen*/ -.main-container { +.settings-main-container { width: 100%; max-height: 100vh; overflow-y: auto; padding: 5% 5% 20px 5%; box-sizing: border-box; - scrollbar-width: none; /* Firefox */ + scrollbar-width: none; + justify-content: center; } -/*Kategori boksene*/ .category { - background-color: var(--accent-color-dark); - border-radius: 10px; + background-color: var(--accent-color); + border-radius: var(--border-radius-general); margin-bottom: 20px; padding: 20px; text-align: left; + display: flex; + flex-direction: column; } .setting-item { display: flex; + padding-top: 10px; flex-direction: row; align-items: center; width: 100%; } - -</style> +@media (max-width: 600px) { + .category { + justify-content: center; + align-items: center; + text-align: center; + } +} +</style> \ No newline at end of file diff --git a/src/components/profile/settingsOptionComponents/BorderRadius.vue b/src/components/profile/settingsOptionComponents/BorderRadius.vue new file mode 100644 index 0000000000000000000000000000000000000000..41ce261b161de1b856373eba02b78d12dfa6a1eb --- /dev/null +++ b/src/components/profile/settingsOptionComponents/BorderRadius.vue @@ -0,0 +1,79 @@ +<script setup> +import {onMounted, ref, watch} from 'vue'; +import CookieService from "@/services/internal/CookieService"; + +const radiusOption = ref('border-medium'); +const currentBorderRadius = ref('border-medium'); + +onMounted(() => { + const borderRadiusCookie = CookieService.getCookieWithConsent('borderRadius'); + if (borderRadiusCookie) { + document.documentElement.classList.add(borderRadiusCookie); + currentBorderRadius.value = borderRadiusCookie; + } +}); + +/** + * Watches for changes in the selected border radius option and updates the DOM and cookie accordingly. + * + * @function + * @param {string} newValue - The new selected border radius option. + * @param {string} oldValue - The previous selected border radius option. + */ +watch(radiusOption, (newValue, oldValue) => { + if (oldValue) { + document.documentElement.classList.remove(oldValue); + } + if (newValue) { + document.documentElement.classList.add(newValue); + CookieService.setCookieWithConsent('borderRadius', newValue, 7); + currentBorderRadius.value = newValue; + } +}, { immediate: true }); +</script> + +<template> + <div class="border-radius-main-container"> + <label for="setting-border-styles">Ramme Stil</label> + <select id="setting-border-styles" v-model="radiusOption" class="dropdown"> + <option value="border-none">Firkantet</option> + <option value="border-small">Rund Liten</option> + <option value="border-medium">Rund Medium</option> + <option value="border-large">Rund Stor</option> + </select> + </div> +</template> + +<style scoped> +.border-radius-main-container { + display: flex; + justify-content: space-between; + padding: 0; + width: 100%; +} + +label { + font-size: 1.5em; + margin: 0; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.dropdown { + background-color: var(--accent-color-dark); +} + +.dropdown:hover { + background-color: var(--light-color); +} + +@media (max-width: 600px) { + .border-radius-main-container { + flex-direction: column; + justify-content: center; + align-items: center; + } +} +</style> diff --git a/src/components/profile/settingsOptionComponents/ChangeCookiePrivilege.vue b/src/components/profile/settingsOptionComponents/ChangeCookiePrivilege.vue new file mode 100644 index 0000000000000000000000000000000000000000..3a5db08228b58ede2e00b5c15fcf07967a2d99a2 --- /dev/null +++ b/src/components/profile/settingsOptionComponents/ChangeCookiePrivilege.vue @@ -0,0 +1,76 @@ +<script setup> +import {onMounted, ref, watch} from 'vue'; +import CookieService from "@/services/internal/CookieService"; + +const selectedCookieSetting = ref(''); + +/** + * Watches for changes in the selected cookie setting and updates the DOM and cookie accordingly. + * + * @param {string} newValue - The new selected cookie setting. + * @param {string} oldValue - The previous selected cookie setting. + */ +watch(selectedCookieSetting, (newValue, oldValue) => { + if (oldValue) { + document.documentElement.classList.remove(oldValue); + document.documentElement.classList.add(newValue); + } + if (newValue) { + document.documentElement.classList.add(newValue); + CookieService.setCookie('cookiesAccepted', newValue, 7); + }}); + +/** + * Sets the initial value for the selected cookie setting based on the existing cookie when the component is mounted. + */ +onMounted(() => { + if (CookieService.getCookie('cookiesAccepted')) { + selectedCookieSetting.value = CookieService.getCookie('cookiesAccepted'); + } +}); + +</script> + +<template> + <div class="change-cookies-main-container"> + <label for="setting-cookie">Informasjonskapsler</label> + <select id="setting-cookie" class="dropdown" v-model="selectedCookieSetting"> + <option value='true'>Aksepter</option> + <option value='false'>AvslÃ¥</option> + </select> + </div> +</template> + +<style scoped> +.change-cookies-main-container { + display: flex; + justify-content: space-between; + padding: 0; + width: 100%; +} + +label { + font-size: 1.5em; + margin: 0; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.dropdown { + background-color: var(--accent-color-dark); +} + +.dropdown:hover { + background-color: var(--light-color); +} + +@media (max-width: 600px) { + .change-cookies-main-container { + flex-direction: column; + justify-content: center; + align-items: center; + } +} +</style> \ No newline at end of file diff --git a/src/components/profile/settingsOptionComponents/ChangeLanguage.vue b/src/components/profile/settingsOptionComponents/ChangeLanguage.vue deleted file mode 100644 index ea695534a678889d2a8a87fc04d5085a6b6949e2..0000000000000000000000000000000000000000 --- a/src/components/profile/settingsOptionComponents/ChangeLanguage.vue +++ /dev/null @@ -1,44 +0,0 @@ -<script setup> -import { ref } from 'vue'; - -const selectedLanguage = ref('en'); - -/*function changeLanguage() { - console.log(`Language changed to : ${selectedLanguage.value}`); -} - -//todo fiks sÃ¥ sprÃ¥ket fakltisk endres senere*/ -</script> - -<template> - <div class="language-main-container"> - <h4>Language</h4> - <select class="dropdown" v-model="selectedLanguage"> - <option value="en">English</option> - <option value="no">Norsk</option> - <option value="fr">Français</option> - <option value="de">Deutsch</option> - <option value="srb">Srbski</option> - <option value="esp">Español</option> - </select> - </div> -</template> - -<style scoped> -.language-main-container { - display: flex; - justify-content: space-between; - padding: 0; - width: 100%; -} - -h4 { - margin: 0; - padding: 0; - display: flex; - align-items: center; - justify-content: center; -} - - -</style> \ No newline at end of file diff --git a/src/components/profile/settingsOptionComponents/DarkLightMode.vue b/src/components/profile/settingsOptionComponents/DarkLightMode.vue deleted file mode 100644 index 846b1d46ca57bc153036ea4c2757bf0a6be3061b..0000000000000000000000000000000000000000 --- a/src/components/profile/settingsOptionComponents/DarkLightMode.vue +++ /dev/null @@ -1,107 +0,0 @@ -<script setup> -//todo legg ogsÃ¥ til at nÃ¥r man bytter fra light til darkmode og tilbake, and tekstfargene ogsÃ¥ endres. AltsÃ¥ light skal ha sort skrift og motsatt. -//todo sol og mÃ¥ne, samt dott som snurrer ved toggle -import {ref, watch} from 'vue'; - -const isDarkMode = ref(false); //todo Denne mÃ¥ lagres pÃ¥ bruker, slik at endring en gang, lagres - -watch(isDarkMode, (newValue) => { - const rootClass = newValue ? 'dark-theme' : 'white-theme'; - document.documentElement.className = rootClass; -}); -</script> - -<template> - <div class="dark-light-main-container"> - <h4>Dark/light mode</h4> - <label class="switch"> - <input type="checkbox" id="dark-light-mode" v-model="isDarkMode"> - <span class="slider round"></span> - </label> - </div> -</template> - -<style scoped> -.dark-light-main-container { - display: flex; - justify-content: space-between; - padding: 0; - width: 100%; -} - -h4 { - margin: 0; - padding: 0; - display: flex; - align-items: center; - justify-content: center; -} - -.switch { - position: relative; - display: inline-block; - width: 60px; - height: 34px; - margin: 5%; -} - -.switch input { - opacity: 0; - width: 0; - height: 0; -} - -/*Bakgrunn slider ikke valgt*/ -.slider { - position: absolute; - cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: var(--accent-color); - -webkit-transition: .4s; - transition: .4s; -} - -/*selve ballen som beveger seg, todo fiks denne mer senere*/ -.slider:before { - position: absolute; - content: ""; - height: 26px; - width: 26px; - left: 4px; - bottom: 4px; - background-color: var(--white-general); - -webkit-transition: .4s; - transition: .4s; - border-width: 1px; - border-style: solid; - border-color: darkred; -} - -/*Bakgrunn slider valgt*/ -input:checked + .slider { - background-color: var(--dark-color); -} - -/*Rammen rundt radio button*/ -input:focus + .slider { - box-shadow: 0 0 3px var(--dark-color); -} - -input:checked + .slider:before { - -webkit-transform: translateX(26px); - -ms-transform: translateX(26px); - transform: translateX(26px); -} - -/* Rounded sliders */ -.slider.round { - border-radius: 34px; -} - -.slider.round:before { - border-radius: 50%; -} -</style> \ No newline at end of file diff --git a/src/components/profile/settingsOptionComponents/FontFamily.vue b/src/components/profile/settingsOptionComponents/FontFamily.vue index d47a0f26b65a896008148580b7eb941db5ad7222..19d91078b09edc7f668775a6ff3cd297a7ec39df 100644 --- a/src/components/profile/settingsOptionComponents/FontFamily.vue +++ b/src/components/profile/settingsOptionComponents/FontFamily.vue @@ -1,20 +1,42 @@ <script setup> -import {ref, watch} from 'vue'; +import {onMounted, ref, watch} from 'vue'; +import CookieService from "@/services/internal/CookieService"; const currentFontFamily = ref('font1'); +/** + * Initializes the selected font family from the cookie when the component is mounted. + */ +onMounted(() => { + const fontFamilyCookie = CookieService.getCookieWithConsent('fontFamily'); + if (fontFamilyCookie) { + currentFontFamily.value = fontFamilyCookie; + } +}); + +/** + * Watches for changes in the selected font family and updates the DOM and cookie accordingly. + * + * @param {string} newValue - The new selected font family. + * @param {string} oldValue - The previous selected font family. + */ watch(currentFontFamily, (newValue, oldValue) => { - document.documentElement.classList.remove(oldValue); - document.documentElement.classList.add(newValue); - console.log(currentFontFamily) + if (oldValue) { + document.documentElement.classList.remove(oldValue); + document.documentElement.classList.add(newValue); + } + if (newValue) { + document.documentElement.classList.add(newValue); + CookieService.setCookieWithConsent('fontFamily', newValue, 7); + } }); </script> <template> - <div class="font-size-main-container"> - <h4>Font Style</h4> - <select v-model="currentFontFamily" class="dropdown"> + <div class="font-style-main-container"> + <label for="setting-font-size">Font Stil</label> + <select id="setting-font-size" v-model="currentFontFamily" class="dropdown"> <option value="font1">Arial</option> <option value="font2">Times New Roman</option> <option value="font3">Courier New</option> @@ -26,7 +48,7 @@ watch(currentFontFamily, (newValue, oldValue) => { </template> <style scoped> -.font-size-main-container { +.font-style-main-container { display: flex; justify-content: space-between; padding: 0; @@ -41,5 +63,28 @@ h4 { justify-content: center; } +label { + font-size: 1.5em; + margin: 0; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.dropdown { + background-color: var(--accent-color-dark); +} +.dropdown:hover { + background-color: var(--light-color); +} + +@media (max-width: 600px) { + .font-style-main-container { + flex-direction: column; + justify-content: center; + align-items: center; + } +} </style> diff --git a/src/components/profile/settingsOptionComponents/FontSize.vue b/src/components/profile/settingsOptionComponents/FontSize.vue index 55c46b79d8bde7d2e2e4eab27e548eb48cb7230f..62aae3318e70b7494d6cd5a80c472690f7c6f5ea 100644 --- a/src/components/profile/settingsOptionComponents/FontSize.vue +++ b/src/components/profile/settingsOptionComponents/FontSize.vue @@ -1,23 +1,45 @@ <script setup> -//todo fiks bug at dersom man velger skrifttype og deretter størrelse, sÃ¥ forsvinner valget av skrifttype. -import {ref, watch} from 'vue'; +import {onMounted, ref, watch} from 'vue'; +import CookieService from "@/services/internal/CookieService"; const currentFontSize = ref('font-size-medium'); -watch(currentFontSize, (newValue) => { - document.documentElement.className = newValue; +/** + * Initializes the selected font size from the cookie when the component is mounted. + */ +onMounted(() => { + const fontSizeCookie = CookieService.getCookieWithConsent('fontSize'); + if (fontSizeCookie) { + currentFontSize.value = fontSizeCookie; + } +}); + +/** + * Watches for changes in the selected font size and updates the DOM and cookie accordingly. + * + * @param {string} newValue - The new selected font size. + * @param {string} oldValue - The previous selected font size. + */ +watch(currentFontSize, (newValue, oldValue) => { + if (oldValue) { + document.documentElement.classList.remove(oldValue); + document.documentElement.classList.add(newValue); + } + if (newValue) { + document.documentElement.classList.add(newValue); + CookieService.setCookieWithConsent('fontSize', newValue, 7); + } }); -</script> +</script> <template> <div class="font-size-main-container"> - <h4>Font Size</h4> - <select v-model="currentFontSize" class="dropdown"> - <option value="font-size-extra-small">Extra Small</option> - <option value="font-size-small">Small</option> + <label for="setting-header" class="setting-header">Font Størrelse</label> + + <select id="setting-header" v-model="currentFontSize" class="dropdown"> + <option value="font-size-small">Liten</option> <option value="font-size-medium">Medium</option> - <option value="font-size-large">Large</option> - <option value="font-size-extra-large">Extra Large</option> + <option value="font-size-large">Stor</option> </select> </div> </template> @@ -38,4 +60,28 @@ h4 { justify-content: center; } +label { + font-size: 1.5em; + margin: 0; + padding: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.dropdown { + background-color: var(--accent-color-dark); +} + +.dropdown:hover { + background-color: var(--light-color); +} + +@media (max-width: 600px) { + .font-size-main-container { + flex-direction: column; + justify-content: center; + align-items: center; + } +} </style> diff --git a/src/components/transactions/TransactionsMain.vue b/src/components/transactions/TransactionsMain.vue index 5f13d20aa7524a3d77ab58e7d68dc1ffe1b4e22d..d656325813bc1492625adf6cca085ae76e423fab 100644 --- a/src/components/transactions/TransactionsMain.vue +++ b/src/components/transactions/TransactionsMain.vue @@ -1,73 +1,498 @@ <script setup> +import {computed, onMounted, ref, watch} from 'vue'; +import BankStatementService from "@/services/internal/BankStatementService"; +import TransactionService from "@/services/internal/TransactionService"; +import axios from "axios"; +const transactions = ref([]); +const accountNames = ref([]); +const sortDropdown = ref(''); +const searchQuery = ref(''); +const sortDirectionTable = ref(1); +const dropdownSortDirection = ref(1); +const editMode = ref(false); +const errorMessage = ref(''); +const selectedMonth = ref(null); +const editedBankStatementIds = ref([]); +const selectedAccount = ref(''); + +/** + * Check if the selected month has changed, and update the transactions if it has. + */ +watch(selectedMonth, async () => { + await updateTransactions(); +}, {immediate: true}); + +const categoryTranslations = { + 'FOOD': {name: 'Mat', code: '01'}, + 'ALCOHOL_AND_TOBACCO': {name: 'Alkohol og tobakk', code: '02'}, + 'CLOTHING_AND_SHOES': {name: 'Klær og sko', code: '03'}, + 'HOUSING_AND_ELECTRICITY': {name: 'Bolig og elektrisitet', code: '04'}, + 'FURNITURE': {name: 'Møbler', code: '05'}, + 'HEALTH': {name: 'Helse', code: '06'}, + 'TRANSPORT': {name: 'Transport', code: '07'}, + 'COMMUNICATION': {name: 'Kommunikasjon', code: '08'}, + 'LEISURE_SPORT_AND_CULTURE': {name: 'Fritid, sport og kultur', code: '09'}, + 'EDUCATION': {name: 'Utdanning', code: '10'}, + 'EATING_OUT': {name: 'Spise ute', code: '11'}, + 'INSURANCE': {name: 'Forsikring', code: '12'}, + 'OTHER': {name: 'Annet', code: '13'} +}; + + +onMounted(async () => { + setDefaultMonthToPrevious() +}); + +/** + * Sets the default month to the current month. + */ +function setDefaultMonthToPrevious() { + try { + const date = new Date(); + const year = date.getFullYear(); + let month = date.getMonth(); //1 month behind current + if (month < 10) { + month = '0' + month; + } + selectedMonth.value = `${year}-${month}-01`; + } catch (error) { + errorMessage.value = "Klarte ikke finne transaksjoner for forrige mÃ¥ned" + } +} + +/** + * Updates the transactions array with the transactions of the selected account. + * @returns {Promise<void>} + */ +async function updateTransactions() { + try { + if (!selectedMonth.value) { + return + } + const splitDate = selectedMonth.value.split("-"); + const month = splitDate[1]; + const year = splitDate[0]; + await getAllBankStatements(month, year) + } catch (error) { + errorMessage.value = "Klarte ikke Ã¥ hente transaksjoner, prøv igjen senere" + } +} + +/** + * Fetches all bank statements for the selected month and year. + * @param month The month to fetch bank statements for. + * @param year The year to fetch bank statements for. + * @returns {Promise<void>} A promise that resolves when the bank statements have been fetched. + */ +async function getAllBankStatements(month, year) { + transactions.value = []; + try { + errorMessage.value = ''; + const bankStatements = await BankStatementService.retrieveBankStatements(month, year); + console.log("received: ", bankStatements.length) + + for (let statement of bankStatements) { + + if (!accountNames.value.includes(statement.accountName)) { + accountNames.value.push(statement.accountName); + } + for (let transaction of statement.transactions) { + transaction.visibleDate = getPrettyDateFormat(transaction, statement.timestamp); + transaction.statementId = statement.id; + transaction.accountName = statement.accountName; + transactions.value.push(transaction) + } + } + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + errorMessage.value = "Klarte ikke Ã¥ hente bankutskrifter, prøv igjen senere." + } else { + console.error('Cannot connect to server.', error); + errorMessage.value = 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } +} + +/** + * Returns a pretty date format for the transaction. + * @param transaction The transaction to get the date from. + * @param yearMonth The year and month of the transaction. + * @returns {string} The pretty date format. + */ +function getPrettyDateFormat(transaction, yearMonth) { + try { + const splitYear = yearMonth.split("-"); + const year = splitYear[0]; + const month = splitYear[1]; + const dateOfMonth = transaction.date.slice(-2); + return `${dateOfMonth}.${month}.${year}`; + } catch (err) { + console.error("Invalid data format.", err); + return "Invalid date format" + } +} + +/** + * Fetches the account numbers from the backend and adds them to the accountNames array. + */ +function switchAccountName(accountNumber) { + if (accountNumber) { + selectedAccount.value = accountNumber; + updateTransactions() + } +} + +/** + * Filters the transactions based on partial search in all queries. + */ +const filteredTransactions = computed(() => { + return transactions.value.filter((transaction) => { + try { + if (selectedAccount.value && !(selectedAccount.value === transaction.accountName)) { + return false; + } + + const searchInLowerCase = searchQuery.value.toLowerCase().replace(/\s/g, ''); + + let dateMatch = false; + const transactionDate = new Date(transaction.timestamp); + const day = transactionDate.getDate().toString(); + const month = (transactionDate.getMonth() + 1).toString(); + const year = transactionDate.getFullYear().toString(); + if (searchInLowerCase === day || searchInLowerCase === month || searchInLowerCase === year.padStart(2, '0') || searchInLowerCase === year) { + dateMatch = true; + } + + const descriptionMatch = transaction.description.toLowerCase().replace(/\s/g, '').includes(searchInLowerCase); + const categoryMatch = transaction.category && categoryTranslations[transaction.category].name.toLowerCase().replace(/\s/g, '').startsWith(searchInLowerCase); + + const amountMatch = transaction.amount.toString().replace('.', '').includes(searchInLowerCase.replace('.', '')); + + return dateMatch || descriptionMatch || categoryMatch || amountMatch; + } catch (error) { + console.error = "Failed to filter all transactions" + error; + return false; + } + } + ).sort((a, b) => { + if (!sortDropdown.value) return 0; + if (a[sortDropdown.value] < b[sortDropdown.value]) return -1 * sortDirectionTable.value; + if (a[sortDropdown.value] > b[sortDropdown.value]) return 1 * sortDirectionTable.value; + return 0; + }); +}); + +/** + * Sorts the transactions based on the selected column. + * @param column The column to sort by. + */ +function sortByForTable(column) { + if (sortDropdown.value === column) { + sortDirectionTable.value *= -1; + } else { + sortDropdown.value = column; + sortDirectionTable.value = 1; + } +} + +function sortByForDropdown(column, fromDropdown = false) { + if (sortDropdown.value === column) { + dropdownSortDirection.value *= -1; + } else { + sortDropdown.value = column; + dropdownSortDirection.value = fromDropdown ? 1 : -1; + } +} + +/** + * Toggles the edit mode. + */ +function toggleEditMode() { + editMode.value = !editMode.value; + if (!editMode.value) { + handleFinishedEditing(); + } +} + +/** + * Handles the change of category for a transaction. + * @param transaction The transaction to change the category for. + */ +function handleCategoryChange(transaction) { + transaction.hasBeenEdited = true; + if (!editedBankStatementIds.value.includes(transaction.statementId)) { + editedBankStatementIds.value.push(transaction.statementId); + } +} + +/** + * Handles the finished editing of the transactions. + * @returns {Promise<void>} + */ +async function handleFinishedEditing() { + for (const transaction of transactions.value) { + try { + if (transaction.hasBeenEdited) { + await TransactionService.updateTransaction(transaction); + } + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + errorMessage.value = "Det var problemer med Ã¥ oppdatere transaksjonene."; + } else { + console.error('Cannot connect to server.', error); + errorMessage.value = 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + } + + try { + errorMessage.value = "Analyserer kontoutskriftene"; + await reAnalyseBankStatements(editedBankStatementIds.value) + errorMessage.value = "Kontoutskriftene ble analysert" + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + errorMessage.value = "Det var problemer med Ã¥ analysere kontoutskriftene"; + } else { + console.error('Cannot connect to server.', error); + errorMessage.value = 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + clearEdited() +} + +/** + * Re-analyzes the bank statements with the given statement ids. + * @param statementIds The statement ids to re-analyze. + * @returns {Promise<void>} A promise that resolves when the bank statements have been re-analyzed. + */ +async function reAnalyseBankStatements(statementIds) { + for (const statementId of statementIds) { + errorMessage.value = "Re-analyserer statement:" + statementId; + await BankStatementService.analyzeBankStatement(statementId, true, false) + } + await updateTransactions() + errorMessage.value = "Kontoutskriftene ble re-analysert" +} + +/** + * Clears the edited flag for all transactions. + */ +function clearEdited() { + transactions.value.forEach(transaction => { + transaction.hasBeenEdited = false; + }) +} </script> <template> - <div class="header"> - <h1>Transactions</h1> - </div> - <div class="top-container"> - <h3>Siste bevegelser</h3> - <div> - <h5>Søk i kontoutskrift:</h5> - <input id="transaction-search" type="text" placeholder="Søk her"> - </div> - </div> - <div class="container"> - <div class="left-column"> - <div v-for="n in 10" :key="n" class="item1">transaksjon</div> + <div class="transactions-main-container"> + <div class="header"> + <h1>Transaksjoner</h1> </div> - <div class="right-column"> - <div v-for="n in 10" :key="n" class="item2">-1000</div> + <div class="error-container"> + <p v-if="errorMessage"> + {{ errorMessage }} + </p> + </div> + <div class="top-container"> + <input v-model="searchQuery" type="text" placeholder="Søk her" class="transaction-search"> + <input type="date" placeholder="Velg mÃ¥ned" v-model="selectedMonth" id="selected-month" + class="date-picker dropdown"> + <select class="dropdown" v-model="sortDropdown" @change="sortByForDropdown(sortDropdown, true)"> + <option value="" disabled selected>Sorter</option> + <option value="date">Dato</option> + <option value="category">Kategori</option> + <option value="description">Beskrivelse</option> + <option value="amount">Sum</option> + </select> + <select class="dropdown" v-model="selectedAccount" @change="switchAccountName(selectedAccount)"> + <option disabled value="">Filtrer etter konto</option> + <option v-for="account in accountNames" :key="account" :value="account">{{ account }}</option> + </select> + <button @click="toggleEditMode">{{ editMode ? 'Ferdig' : 'Rediger' }}</button> + </div> + + <div class="scrollable-container"> + <table class="table-container"> + <thead> + <tr> + <th tabindex="0" @click="sortByForTable('date')" @keydown.enter="sortByForTable('date')">Dato</th> + <th tabindex="0" @click="sortByForTable('category')" @keydown.enter="sortByForTable('category')">Kategori</th> + <th tabindex="0" @click="sortByForTable('description')" @keydown.enter="sortByForTable('description')"> + Beskrivelse + </th> + <th tabindex="0" @click="sortByForTable('amount')" @keydown.enter="sortByForTable('amount')">Sum</th> + </tr> + </thead> + <tbody v-if="transactions"> + <tr v-for="(transaction, index) in filteredTransactions" :key="index"> + <td>{{ transaction.visibleDate }}</td> + <td> + <template v-if="!editMode"> + {{ + categoryTranslations[transaction.category]?.name ? categoryTranslations[transaction.category].name + : "Ikke kategorisert" + }} + </template> + <template v-else> + <select v-model="transaction.category" @change="handleCategoryChange(transaction)"> + <option value="">Ikke kategorisert</option> + <option v-for="(category, key) in categoryTranslations" + :key="key" + :value="key"> + {{ category.name }} + </option> + </select> + </template> + </td> + <td>{{ transaction.description }}</td> + <td>{{ transaction.isIncoming ? '+' : '-' }}{{ transaction.amount }} kr</td> + </tr> + </tbody> + + </table> + <div v-if="!transactions.length"> + <p v-if="selectedMonth"> + Ingen tilgjengelige transaksjoner under denne kontoen for + {{ selectedMonth.split('-')[1] + '/' + selectedMonth.split('-')[0] }} + </p> + <p v-else> + Venligst velg en mÃ¥ned for Ã¥ se transaksjoner + </p> + </div> + </div> </div> </template> <style scoped> -h1 { - color: var(--dark-color); - text-align: center; - padding-bottom: 1em; +::-webkit-scrollbar { + width: 10px; } -h3 { - color: var(--middle-color); + +::-webkit-scrollbar-thumb { + background: #888; } -.container { - color: var(--light-color); - display: grid; - grid-template-columns: 1fr 1fr; - width: 95%; - border: 1px solid black; - border-radius: 2%; + +::-webkit-scrollbar-thumb:hover { + background: #555; +} + +.transactions-main-container { + width: 100%; + height: 100vh; + padding: 20px +} + +.scrollable-container { + overflow-y: auto; + max-height: 80vh; + width: 100%; + margin-top: 20px; +} + +.error-container { + color: var(--error-text); + margin: 1%; +} + +.header { + height: 90px; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 2%; +} + +.date-picker { + padding: 8px; + border-radius: var(--border-radius-general); + border: none; } + +.table-container { + width: 100%; + margin-top: 0; + border-collapse: collapse; + box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); +} + .top-container { - display: grid; + display: flex; + flex-direction: row; grid-template-columns: 1fr 1fr; - padding: 5%; justify-content: space-between; align-items: center; + width: 100%; + gap: 15px; +} + +.transaction-search { + border-radius: var(--border-radius-general); + padding: 8px; + border: none; + outline: none; + transition: all 0.3s ease; + background-color: var(--accent-color); +} + +.transaction-search:hover { + background-color: var(--accent-color-dark); +} + +.transaction-search:focus { + box-shadow: 0 0 8px var(--dark-color); +} + +th, +td { + padding: 12px 15px; + text-align: left; + border-bottom: 1px solid var(--accent-color); +} + +th { + background-color: var(--middle-color); + color: white; + cursor: pointer; +} + +th:hover { + background-color: var(--dark-color); } -.item1 { - border-bottom: 1px solid black; - grid-column: 1; - padding-top: 0.5em; - padding-left: 5%; +tbody tr:hover { + background-color: var(--accent-color); } -.item2 { - border-bottom: 1px solid black; - grid-column: 2; - padding-top: 0.5em; - text-align: end; - padding-right: 5%; +.table-container th { + background-color: var(--dark-color); + color: white; + position: sticky; + top: 0; + z-index: 10; } -#transaction-search { - width: 90%; - padding: 0.3em; - border-radius: 5px; - border: 1px solid black; +.transaction-search, .date-picker, .dropdown, button { + width: 100%; } -</style> \ No newline at end of file +@media screen and (max-width: 1300px) { + .top-container { + flex-direction: column; + } +} + +@media screen and (max-width: 650px) { + .table-container th:nth-child(3), .table-container td:nth-child(3) { + display: none; + } +} + +@media screen and (max-width: 440px) { + .table-container th:nth-child(3), .table-container td:nth-child(3) { + display: flex; + } +} +</style> diff --git a/src/components/userDetails/ImportFiles.vue b/src/components/userDetails/ImportFiles.vue new file mode 100644 index 0000000000000000000000000000000000000000..427f9760cc66c6d3a257994e5510c8299e93ea2f --- /dev/null +++ b/src/components/userDetails/ImportFiles.vue @@ -0,0 +1,231 @@ +<script setup> +import {ref} from 'vue'; +import BankStatementService from '@/services/internal/BankStatementService'; +import {useRouter} from 'vue-router'; +import axios from "axios"; + +const router = useRouter(); +const errorMessage = ref(''); + +const selectedFile = ref(null); +const isUploadValid = ref(false); +const selectedBank = ref(''); + +const handleFileSelection = async (event) => { + const file = event.target.files[0]; + if (!selectedBank.value || selectedBank.value === "") { + event.target.value = null; + selectedFile.value = null; + errorMessage.value = 'Du mÃ¥ velge en bank før du laster opp en fil. Vennligst prøv igjen.'; + return; + } + + if (file && file.type === 'application/pdf') { + selectedFile.value = file; + errorMessage.value = ''; + await uploadBankStatement(); + } else { + event.target.value = null; + selectedFile.value = null; + errorMessage.value = 'Vennligst last opp en gyldig PDF-fil.'; + } +}; + +const uploadBankStatement = async () => { + let statementId; + if (!selectedFile.value || !selectedBank.value) { + errorMessage.value = 'Vennligst velg en bank og last opp en fil.'; + return; + } + try { + errorMessage.value = 'Vi analyserer kontoutskriften din...'; + const formData = new FormData(); + formData.append('file', selectedFile.value); + statementId = await BankStatementService.addBankStatement(formData, selectedBank.value); + selectedFile.value = null; + isUploadValid.value = true; + errorMessage.value = 'Kontoutskrift lastet opp. Analyserer..'; + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + errorMessage.value = `Fikk ikke lastet opp kontoutsrift.`; + } else { + console.error('Cannot connect to server.', error); + errorMessage.value = 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + return; + } + try { + await BankStatementService.analyzeBankStatement(statementId); + errorMessage.value = 'Kontoutskrift bestod analysen. Gjerne velg en ny fil.'; + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + errorMessage.value = `Kontoutskrift ble lastet opp men analysen feilet. Gjerne last opp en ny fil.`; + } else { + console.error('Cannot connect to server.', error); + errorMessage.value = 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } +}; + +const handleNext = async () => { + if (isUploadValid.value) { + await router.push('/'); + } else { + errorMessage.value = 'Vennligst last opp minst en fil.'; + } +}; +</script> + +<template> + <div class="importFiles-main-container"> + <div class="importFiles-content-container"> + <h2>Kontoutskrifter</h2> + <p>Legg til kontoutskrifter fra de siste mÃ¥nedene. Vi anbefaler minst 3.</p> + + <label style="align-self: flex-start" for="pickBank" >Velg bank :</label> + <div class="input-buttons-container"> + <select class="full-width-input dropdown" id="pickBank" v-model="selectedBank"> + <option disabled value="">Velg bank</option> + <option value="DNB">DNB</option> + <option value="HANDELSBANKEN">HANDELSBANKEN</option> + <option value="SPAREBANK1">SPAREBANK1</option> + <option value="ANNET">ANNET</option> + </select> + <label class="upload-button full-width-input"> + Velg fil + <input + tabindex="0" + role="button" + @keydown.enter="handleFileSelection" + type="file" + @change="handleFileSelection" + accept="application/pdf" + multiple> + </label> + </div> + + <div v-if="errorMessage" class="error">{{ errorMessage }}</div> + <div class="buttons-container"> + <button class="backButton" @click="$emit('back')">GÃ¥ tilbake</button> + <button @click="handleNext">Neste</button> + </div> + </div> + </div> +</template> + + +<style scoped> +.upload-container { + display: flex; + flex-direction: row; + padding: 10px; + width: 100%; + justify-content: center; + align-items: center; + gap: 10px; +} + +input { + display: none; +} + +.upload-button, .dropdown { + width: 100%; + margin-bottom: 10px; +} + +.dropdown { + background-color: var(--accent-color-dark); +} + +p { + text-align: center; + color: var(--dark-color); + font-size: 2vw; +} + +.buttons-container { + display: flex; + flex-direction: row; + width: 100%; + gap: 10px; +} + +button { + width: 100%; +} + +.importFiles-main-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 2.5%; +} + +.importFiles-content-container { + width: 80%; + background-color: var(--accent-color); + box-shadow: 2px 2px 4px 0 rgba(0, 0, 0, 0.25); + border-radius: var(--border-radius-general); + padding: 2.5%; + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} + +.error { + color: var(--error-text); + font-size: 14px; + text-align: center; + padding: 10px; + border-radius: var(--border-radius-general); + margin-top: 10px; +} + +.upload-button { + background-color: var(--middle-color); + color: white; + border: none; + padding: 10px 20px; + border-radius: var(--border-radius-general); + cursor: pointer; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + transition: background-color 0.3s ease; + display: inline-block; +} + + +.input-buttons-container { + display: flex; + flex-direction: row; + width: 100%; + justify-content: space-between; + gap: 10px; +} + +.full-width-input { + flex: 1; +} + +.dropdown, .upload-button { + width: 100%; + margin-bottom: 10px; +} + +.backButton { + background-color: var(--grey-general); + color: white; + width: 100%; +} + + +.backButton:hover { + background-color: var(--dark-color); + color: white; +} +</style> diff --git a/src/components/userDetails/SelectHousehold.vue b/src/components/userDetails/SelectHousehold.vue new file mode 100644 index 0000000000000000000000000000000000000000..bddc249a417f590d06e0173e9d5dc23c9dfaeb4c --- /dev/null +++ b/src/components/userDetails/SelectHousehold.vue @@ -0,0 +1,280 @@ +<script setup> +import {ref, onMounted} from 'vue'; +import {defineEmits} from 'vue'; +import AddAdditionalUserDetailsDTO from "@/models/user/AddAdditionalUserDetailsDTO"; +import UserDetailsService from "@/services/internal/UserDetailsService"; +import axios from "axios"; + +const emit = defineEmits(['next']); + +const selectedOption = ref(''); +const errorMessage = ref(''); + +const checkAndSetStoredValue = () => { + const storedHouseholdValue = sessionStorage.getItem('householdValue'); + if (storedHouseholdValue) { + selectedOption.value = storedHouseholdValue; + } +}; + +onMounted(checkAndSetStoredValue); + +const handleSelect = (value) => { + selectedOption.value = value; + errorMessage.value = ''; +}; + +const isSelected = (option) => { + return selectedOption.value === option; +}; + +const updateUserDetails = async () => { + try { + const income = parseInt(sessionStorage.getItem('incomeValue')); + const savingPercentage = parseInt(sessionStorage.getItem('savingPercentage')) + const livingStatus = parseInt(sessionStorage.getItem('householdValue')); + const addAdditionalUserDetailsDTO = new AddAdditionalUserDetailsDTO(income, savingPercentage, livingStatus); + await UserDetailsService.addAdditionalUserDetails(addAdditionalUserDetailsDTO); + } catch (error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 400: + errorMessage.value = "Ugyldig inndata. Vennligst oppgi gyldige data."; + break; + case 500: + errorMessage.value = "Intern serverfeil. Vennligst prøv igjen senere."; + break; + default: + errorMessage.value = error.response.data.errorMessage; + break; + } + } else { + console.error('Cannot connect to server.', error); + errorMessage.value = 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } +}; + +const handleNext = async () => { + + if (selectedOption.value !== '') { + sessionStorage.setItem('householdValue', parseInt(selectedOption.value)); + emit('next'); + await updateUserDetails(); + } else { + errorMessage.value = 'Vennligst velg en bosituasjon før du gÃ¥r videre.'; + } +}; + +</script> + +<template> + <div class="household-main-container"> + <div class="household-content-container"> + <h2>Bosituasjon</h2> + <div class="options-container"> + <div + class="option" + :class="{ 'selected': isSelected('1') }" + tabindex="0" + role="tab" + @keydown.enter="handleSelect('1')" + @click="handleSelect('1')"> + <img src="@/assets/img/userDetails/livingalone.webp" alt="Bor alene"> + <p>Bor alene</p> + </div> + <div + class="option" + :class="{ 'selected': isSelected('2') }" + tabindex="0" + role="tab" + @keydown.enter="handleSelect('2')" + @click="handleSelect('2')"> + <img src="@/assets/img/userDetails/couplewithoutkids.webp" alt="Par uten barn"> + <p>Par uten barn</p> + </div> + <div + class="option" + :class="{ 'selected': isSelected('3') }" + tabindex="0" + role="tab" + @keydown.enter="handleSelect('3')" + @click="handleSelect('3')"> + <img src="@/assets/img/userDetails/couplewithkids.webp" alt="Par med barn"> + <p>Par med barn</p> + </div> + <div + class="option" + :class="{ 'selected': isSelected('0') }" + tabindex="0" + role="tab" + @keydown.enter="handleSelect('0')" + @click="handleSelect('0')"> + <img src="@/assets/img/userDetails/other.webp" alt="Annet"> + <p>Annet</p> + </div> + </div> + + + <div v-if="errorMessage" class="error-message">{{ errorMessage }}</div> + <div class="buttons-container"> + <button + id="backButton" + tabindex="0" + role="tab" + @keydown.enter="$emit('back')" + @click="$emit('back')"> + GÃ¥ tilbake + </button> + <button + tabindex="0" + role="tab" + @keydown.enter="handleNext" + @click="handleNext" + >Neste + </button> + </div> + </div> + </div> +</template> + +<style scoped> +.error-message { + margin-top: 5%; +} + +#backButton { + background-color: dimgrey; +} + +.buttons-container { + display: flex; + flex-direction: row; + width: 100%; + gap: 10px; + margin-top: 30px; +} + +button { + width: 100%; +} + +.household-main-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + box-sizing: border-box; + padding: 2.5%; +} + +.household-content-container { + width: 90%; + max-width: 800px; + background-color: var(--accent-color); + box-shadow: 2px 2px 4px 0 rgba(0, 0, 0, 0.25); + border-radius: var(--border-radius-general); + padding: 2.5%; + display: flex; + flex-direction: column; + align-items: center; +} + +.option { + cursor: pointer; + text-align: center; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + transition: box-shadow 0.3s ease, transform 0.3s ease; + padding: 2.5%; + width: 100%; + border-radius: var(--border-radius-general); + box-sizing: border-box; +} + +option:hover, option:focus, .option.selected { + box-shadow: 0 0 15px var(--black-general); + transform: scale(1.05); + margin: 0; +} + + +.options-container { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 5%; + padding: 2.5%; + width: 100%; + box-sizing: border-box; +} + +.option img { + max-width: 80%; + max-height: 80%; + height: auto; + border-radius: var(--border-radius-general); + margin-top: 5%; +} + +.option p { + font-size: 3vw; + margin-top: 8px; + color: var(--black-text); +} + +.error-message { + color: var(--error-text); + text-align: center; +} + +@media (min-width: 620px) { + .household-main-container { + align-items: center; + justify-content: center; + padding: 5%; + } + + .household-content-container { + max-width: 80%; + padding: 2%; + } + + .options-container { + grid-template-columns: repeat(4, 1fr); + } + + .option { + max-width: 95%; + } + + .option img { + max-height: 200px; + } + + .option p { + font-size: 1.5em; + } + + .buttons-container { + flex-direction: row; + gap: 20px; + } +} + +@media (max-width: 400px) { + .options-container { + grid-template-columns: 1fr; /* Change to one column */ + } + .option { + max-width: 100%; /* Use the full width */ + margin-bottom: 10px; /* Add some space between the options */ + } +} + + +</style> + diff --git a/src/components/userDetails/SelectIncome.vue b/src/components/userDetails/SelectIncome.vue new file mode 100644 index 0000000000000000000000000000000000000000..5d13448e78e3ca5302b2633f158c13321b2ed829 --- /dev/null +++ b/src/components/userDetails/SelectIncome.vue @@ -0,0 +1,208 @@ +<script setup> +import {ref, computed, onMounted} from 'vue'; +import {defineEmits} from 'vue'; + +const emit = defineEmits(['next']); + +const rawIncome = ref(''); +const formattedIncome = ref(''); +const savingPercentage = ref(''); + +const errorMessage = ref(''); + +const checkAndSetStoredValue = () => { + const storedIncome = sessionStorage.getItem('incomeValue'); + if (storedIncome) { + rawIncome.value = storedIncome; + formattedIncome.value = formatNumber(storedIncome); + } + + const storedSavingPercentage = sessionStorage.getItem('savingPercentage'); + if (storedSavingPercentage) { + savingPercentage.value = storedSavingPercentage; + } + + const storedConsumption = sessionStorage.getItem('calculatedConsumption'); + if (storedConsumption) { + calculatedConsumption.value = storedConsumption; + } +}; + +onMounted(checkAndSetStoredValue); + +const handleInput = (value) => { + let numericValue = value.replace(/\D/g, ''); + formattedIncome.value = formatNumber(numericValue); + rawIncome.value = numericValue; +}; + +const formatNumber = (num) => { + return num.replace(/\B(?=(\d{3})+(?!\d))/g, ' '); +}; + +const isInputValid = () => { + return rawIncome.value.trim() !== '' && + savingPercentage.value !== "" && + calculatedConsumption.value >= 0; +}; + +const handleNext = () => { + if (isInputValid()) { + errorMessage.value = ''; + sessionStorage.setItem('incomeValue', rawIncome.value); + sessionStorage.setItem('savingPercentage', savingPercentage.value); + sessionStorage.setItem('calculatedConsumption', calculatedConsumption.value); + emit('next'); + } else { + errorMessage.value = "Vennligst fyll ut netto inntekt og prosentandel du ønsker Ã¥ spare."; + } +}; + +const validatePercentage = () => { + if (savingPercentage.value > 100) { + savingPercentage.value = 100; + } else if (savingPercentage.value < 0) { + savingPercentage.value = 0; + } +}; + +const calculatedConsumption = computed(() => { + const income = parseInt(rawIncome.value || '0', 10); + const savings = parseInt(savingPercentage.value || '0', 10); + return Math.floor((1 - savings / 100) * income); +}); + +</script> + +<template> + <div class="income-main-container"> + <div class="income-content-container"> + <h2>Beregning av mÃ¥nedlige utgifter</h2> + <p>Dette skjemaet vil regne ut hvor mye av inntekten din du har til forbruk, etter skatt og sparing</p> + + <div class="input-group"> + <label for="income">MÃ¥nedlig inntekt etter skatt:</label> + <input type="text" id="income" v-model="formattedIncome" @input="handleInput($event.target.value)" + placeholder="10 000 kr"/> + </div> + + <div class="input-group horizontal"> + <div class="saving"> + <label for="savingPercentage">Spareprosent:</label> + <input type="number" + id="savingPercentage" + v-model="savingPercentage" + @input="validatePercentage" + min="0" max="100" step="1" + placeholder="eks. 35%"> + </div> + + <div class="consumption"> + <label for="consumption">Beregnet forbruks sum:</label> + <input type="number" id="consumption" :value="calculatedConsumption" readonly placeholder="3500 kr"/> + </div> + </div> + + <button @click="handleNext">Videre</button> + <div v-if="errorMessage" class="error-message">{{ errorMessage }}</div> + </div> + </div> +</template> + +<style scoped> +label { + padding-left: 1%; +} + +.income-main-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2.5%; + width: 100%; + box-sizing: border-box; + height: 100%; +} + +.income-content-container { + box-shadow: 2px 2px 4px 0 rgba(0, 0, 0, 0.25); + width: 80%; + max-width: 800px; + min-height: 400px; + background-color: var(--accent-color); + border-radius: var(--border-radius-general); + padding: 2.5%; + display: flex; + flex-direction: column; + align-items: center; +} + +.input-group { + width: 100%; + margin-bottom: 20px; + box-sizing: border-box; +} + +.horizontal { + display: flex; + width: 100%; + gap: 20px; +} + +h2, p { + text-align: center; + width: 80%; + margin-bottom: 20px; +} + +p { + color: var(--dark-color); +} + +input { + width: 100%; + height: 50px; + border-radius: var(--border-radius-general); + border: none; + padding: 10px; + box-sizing: border-box; + margin-top: 8px; +} + +.error-message { + color: var(--error-text); + font-size: 14px; + margin-top: 10px; +} + +.saving { + display: flex; + flex-direction: column; + width: 30%; +} + +.consumption { + width: 70%; +} + + +@media (max-width: 800px) { + .horizontal { + flex-direction: column; + gap: 0; + } + + .saving, .consumption { + width: 100%; + } + + .saving { + margin-bottom: 20px; + } + + .income-content-container { + width: 95%; + } +} +</style> diff --git a/src/models/errors/ErrorResponseDTO.js b/src/models/errors/ErrorResponseDTO.js new file mode 100644 index 0000000000000000000000000000000000000000..92be8b781b31a4265e5526e445460a09a68a8d42 --- /dev/null +++ b/src/models/errors/ErrorResponseDTO.js @@ -0,0 +1,15 @@ +/** + * Data transfer object for error responses. + */ +class ErrorResponseDTO { + + /** + * Constructs an instance of ErrorResponseDTO. + * @param {string} errorMessage - The error message. + */ + constructor(errorMessage) { + this.errorMessage = errorMessage; + } +} + +export default new ErrorResponseDTO(); \ No newline at end of file diff --git a/src/models/transaction/TransactionDTO.js b/src/models/transaction/TransactionDTO.js new file mode 100644 index 0000000000000000000000000000000000000000..6d6ba54789bb2c5c96e9ffb9a2a62e075a9ebca2 --- /dev/null +++ b/src/models/transaction/TransactionDTO.js @@ -0,0 +1,25 @@ +/** + * Data transfer object for representing a transaction. + */ +class TransactionDTO { + + /** + * Constructs an instance of TransactionDTO. + * @param {number} id - The ID of the transaction. + * @param {Date} date - The date of the transaction. + * @param {string} description - The description of the transaction. + * @param {number} amount - The amount of the transaction. + * @param {boolean} isIncoming - Indicates whether the transaction is incoming (true) or outgoing (false). + * @param {string} category - The category of the transaction. + */ + constructor(id, date, description, amount, isIncoming, category) { + this.id = id; + this.date = date; + this.description = description; + this.amount = amount; + this.isIncoming = isIncoming; + this.category = category; + } +} + +export default TransactionDTO; \ No newline at end of file diff --git a/src/models/user/AddAdditionalUserDetailsDTO.js b/src/models/user/AddAdditionalUserDetailsDTO.js new file mode 100644 index 0000000000000000000000000000000000000000..436b20b82d7fa52cdb6407fea25b6bfb8702cf85 --- /dev/null +++ b/src/models/user/AddAdditionalUserDetailsDTO.js @@ -0,0 +1,20 @@ + +/** + * Data transfer object for adding additional user details. + */ +class AddAdditionalUserDetailsDTO { + + /** + * Constructs an instance of AddAdditionalUserDetailsDTO. + * @param {number} income - The user's income. + * @param {number} savingPercentage - The percentage of income saved by the user. + * @param {string} livingStatus - The user's living status. + */ + constructor(income, savingPercentage, livingStatus) { + this.income = income; + this.savingPercentage = savingPercentage; + this.livingStatus = livingStatus; + } +} + +export default AddAdditionalUserDetailsDTO; \ No newline at end of file diff --git a/src/models/user/ChangeFirstNameDTO.js b/src/models/user/ChangeFirstNameDTO.js new file mode 100644 index 0000000000000000000000000000000000000000..81cafd53e2a4762fe9a317a91e593522f3727f98 --- /dev/null +++ b/src/models/user/ChangeFirstNameDTO.js @@ -0,0 +1,14 @@ +/** + * Data transfer object for changing the first name of a user. + */ +class ChangeFirstNameDTO { + /** + * Constructs an instance of ChangeFirstNameDTO. + * @param {string} newFirstName - The new first name of the user. + */ + constructor(newFirstName) { + this.newFirstName = newFirstName; + } +} + +export default ChangeFirstNameDTO; \ No newline at end of file diff --git a/src/models/user/ChangeIncomeDTO.js b/src/models/user/ChangeIncomeDTO.js new file mode 100644 index 0000000000000000000000000000000000000000..6748b5b6e4f4426ca0450a69e5180f4dc2b17655 --- /dev/null +++ b/src/models/user/ChangeIncomeDTO.js @@ -0,0 +1,14 @@ +/** + * Data transfer object for changing the income of a user. + */ +class ChangeIncomeDTO { + /** + * Constructs an instance of ChangeIncomeDTO. + * @param {number} newIncome - The new income of the user. + */ + constructor(newIncome) { + this.newIncome = newIncome; + } +} + +export default ChangeIncomeDTO; \ No newline at end of file diff --git a/src/models/user/ChangeLastNameDTO.js b/src/models/user/ChangeLastNameDTO.js new file mode 100644 index 0000000000000000000000000000000000000000..0003b37ec306185894f380d117dcf77aab8dfb2b --- /dev/null +++ b/src/models/user/ChangeLastNameDTO.js @@ -0,0 +1,14 @@ +/** + * Data transfer object for changing the last name of a user. + */ +class ChangeLastNameDTO { + /** + * Constructs an instance of ChangeLastNameDTO. + * @param {string} newLastName - The new last name of the user. + */ + constructor(newLastName) { + this.newLastName = newLastName; + } +} + +export default ChangeLastNameDTO; diff --git a/src/models/user/ChangeLivingStatusDTO.js b/src/models/user/ChangeLivingStatusDTO.js new file mode 100644 index 0000000000000000000000000000000000000000..3b8ef905fad8f448cdeb95106c58975c10317316 --- /dev/null +++ b/src/models/user/ChangeLivingStatusDTO.js @@ -0,0 +1,15 @@ +/** + * Data transfer object for changing the living status of a user. + */ +class ChangeLivingStatusDTO { + + /** + * Constructs an instance of ChangeLivingStatusDTO. + * @param {string} newLivingStatus - The new living status of the user. + */ + constructor(newLivingStatus) { + this.newLivingStatus = newLivingStatus; + } + } + + export default ChangeLivingStatusDTO; \ No newline at end of file diff --git a/src/models/user/ChangePasswordDTO.js b/src/models/user/ChangePasswordDTO.js new file mode 100644 index 0000000000000000000000000000000000000000..ce021028351a67daf408b9db785bab29d233b722 --- /dev/null +++ b/src/models/user/ChangePasswordDTO.js @@ -0,0 +1,17 @@ +/** + * Data transfer object for changing the password of a user. + */ +class ChangePasswordDTO { + + /** + * Constructs an instance of ChangePasswordDTO. + * @param {string} oldPassword - The old password of the user. + * @param {string} newPassword - The new password of the user. + */ + constructor(oldPassword, newPassword) { + this.oldPassword = oldPassword; + this.newPassword = newPassword; + } +} + +export default ChangePasswordDTO; \ No newline at end of file diff --git a/src/models/user/ChangeSavingPercentageDTO.js b/src/models/user/ChangeSavingPercentageDTO.js new file mode 100644 index 0000000000000000000000000000000000000000..45a516ec11f75ce02618bb8fc32f9a142212cd80 --- /dev/null +++ b/src/models/user/ChangeSavingPercentageDTO.js @@ -0,0 +1,15 @@ +/** + * Data transfer object for changing the saving percentage of a user. + */ +class ChangeSavingPercentageDTO { + + /** + * Constructs an instance of ChangeSavingPercentageDTO. + * @param {number} newSavingPercentage - The new saving percentage of the user. + */ + constructor(newSavingPercentage) { + this.newSavingPercentage = newSavingPercentage; + } +} + +export default ChangeSavingPercentageDTO; \ No newline at end of file diff --git a/src/models/user/EmailCodeRequestDto.js b/src/models/user/EmailCodeRequestDto.js new file mode 100644 index 0000000000000000000000000000000000000000..7cab716ce2c596ba535e2ed83502509b776000f8 --- /dev/null +++ b/src/models/user/EmailCodeRequestDto.js @@ -0,0 +1,19 @@ +/** + * Data transfer object for sending email verification code. + */ +class EmailCodeRequestDto { + email; + verificationCode; + + /** + * Constructs an instance of EmailCodeRequestDto. + * @param {string} email - The email address to send the verification code. + * @param {string} verificationCode - The verification code. + */ + constructor(email, verificationCode) { + this.email = email; + this.verificationCode = verificationCode; + } +} + +export default EmailCodeRequestDto; \ No newline at end of file diff --git a/src/models/user/EmailExistDTO.js b/src/models/user/EmailExistDTO.js new file mode 100644 index 0000000000000000000000000000000000000000..a69f2c25d58fa68bb0a17b103c0a91ce09c89116 --- /dev/null +++ b/src/models/user/EmailExistDTO.js @@ -0,0 +1,15 @@ +/** + * Data transfer object for checking if an email exists. + */ +class EmailExistDTO { + + /** + * Constructs an instance of EmailExistDTO. + * @param {string} email - The email address to check for existence. + */ + constructor(email) { + this.email = email; + } +} + +export default EmailExistDTO; \ No newline at end of file diff --git a/src/models/user/LoginRequestDTO.js b/src/models/user/LoginRequestDTO.js new file mode 100644 index 0000000000000000000000000000000000000000..011cf5f86d1fb6629851bba0f664b6639fccd6e0 --- /dev/null +++ b/src/models/user/LoginRequestDTO.js @@ -0,0 +1,17 @@ +/** + * Data transfer object for user login request. + */ +class LoginRequestDTO { + + /** + * Constructs an instance of LoginRequestDTO. + * @param {string} email - The email address of the user. + * @param {string} password - The password of the user. + */ + constructor(email, password) { + this.email = email; + this.password = password; + } +} + +export default LoginRequestDTO; \ No newline at end of file diff --git a/src/models/user/LoginResponseDTO.js b/src/models/user/LoginResponseDTO.js new file mode 100644 index 0000000000000000000000000000000000000000..3e1c1e03006f199f7dfae3204f69cf80bc2c44c7 --- /dev/null +++ b/src/models/user/LoginResponseDTO.js @@ -0,0 +1,16 @@ + +/** + * Data transfer object for user login response. + */ +class LoginResponseDTO { + + /** + * Constructs an instance of LoginResponseDTO. + * @param {string} token - The authentication token. + */ + constructor(token) { + this.token = token; + } +} + +export default LoginResponseDTO; \ No newline at end of file diff --git a/src/models/user/RegisterRequestDTO.js b/src/models/user/RegisterRequestDTO.js new file mode 100644 index 0000000000000000000000000000000000000000..88432243f1e9b014c072cab4513ef3f43a2b9f9b --- /dev/null +++ b/src/models/user/RegisterRequestDTO.js @@ -0,0 +1,23 @@ +/** + * Data transfer object for registering a new user. + */ +class RegisterUserDTO { + + /** + * Constructs an instance of RegisterUserDTO. + * @param {string} email - The email address of the user. + * @param {string} firstName - The first name of the user. + * @param {string} lastName - The last name of the user. + * @param {string} password - The password of the user. + * @param {string} emailVerificationCode - The email verification code. + */ + constructor(email, firstName, lastName, password, emailVerificationCode) { + this.email = email; + this.firstName = firstName; + this.lastName = lastName; + this.password = password; + this.emailVerificationCode = emailVerificationCode; + } +} + +export default RegisterUserDTO; \ No newline at end of file diff --git a/src/models/user/ResetPasswordDTO.js b/src/models/user/ResetPasswordDTO.js new file mode 100644 index 0000000000000000000000000000000000000000..752ae2cb6e24877bad0b39f59858a7d97ed2b762 --- /dev/null +++ b/src/models/user/ResetPasswordDTO.js @@ -0,0 +1,22 @@ +/** + * Data Transfer Object (DTO) containing information required for resetting a user's password. + * + * @author Ramitn Samavat + */ +class ResetPasswordDTO { + + /** + * Constructs a new ResetPasswordDTO instance. + * + * @param {string} email The email address of the user. + * @param {string} emailVerificationCode The verification code sent to the user's email. + * @param {string} newPassword The new password to be set for the user's account. + */ + constructor(email, emailVerificationCode, newPassword) { + this.email = email; + this.emailVerificationCode = emailVerificationCode; + this.newPassword = newPassword; + } +} + +export default ResetPasswordDTO; \ No newline at end of file diff --git a/src/router/index.js b/src/router/index.js index c177d52732d8603812fc46bd426449651e174315..ad45263f23eb00f18f1b4d50bb8b9461d880fcd1 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -5,40 +5,67 @@ const routes = [ path: "/", name: "home", component: () => import("../views/HomeView.vue"), + meta: { requiresAuth: false , showSidebar: true, hideStats: false} + }, + { + path: "/verification", + name: "verification", + component: () => import("../views/VerificationView.vue"), + meta: { requiresAuth: false, showSidebar: true, hideStats: false} }, { path: "/budget", name: "budget", component: () => import("../views/BudgetView.vue"), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, showSidebar: true, hideStats: false} }, { path: "/goals", name: "goals", component: () => import("../views/GoalsView.vue"), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, showSidebar: true, hideStats: false} }, { path: "/news", name: "news", component: () => import("../views/NewsView.vue"), + meta: { requiresAuth: false, showSidebar: true, hideStats: false } }, { - path: "/profile", + path: "/profile/:component?", name: "profile", component: () => import("../views/ProfileView.vue"), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, showSidebar: false, hideStats: true } }, { path: "/transactions", name: "transactions", component: () => import("../views/TransactionsView.vue"), - meta: { requiresAuth: true }, + meta: { requiresAuth: true, showSidebar: true, hideStats: false } }, { path: "/login", name: "login", component: () => import("../views/LoginView.vue"), + meta: { requiresAuth: false, showSidebar: false, hideStats: true} + }, + { + path: "/userDetails", + name: "userdetails", + component: () => import("../views/UserDetailsView.vue"), + meta: { requiresAuth: true, showSidebar: false, hideStats: true} + }, + { + path: "/contact", + name: "contact", + component: () => import("../views/ContactView.vue"), + meta: { requiresAuth: false, showSidebar: false, hideStats: true} + }, + { + path: "/forgot-password", + name: "forgot-password", + component: () => import("../views/ForgotPasswordView.vue"), + meta: { requiresAuth: false, showSidebar: false, hideStats: true} }, ] @@ -51,9 +78,19 @@ router.beforeEach((to, from, next) => { const requiresAuth = to.matched.some((record) => record.meta.requiresAuth); const authToken = sessionStorage.getItem("authToken"); - if (requiresAuth && !authToken) { - sessionStorage.setItem("redirectAfterLogin", to.fullPath); - next({ name: "login" }); + //alert(to.fullPath) + + if (requiresAuth) { + if (authToken) { + next(); + } else { + next( + { + name: "login", + query: { redirect: to.fullPath }, // store the route the user was trying to access + } + ); + } } else { next(); } diff --git a/src/services/error/GoalErrorService.js b/src/services/error/GoalErrorService.js new file mode 100644 index 0000000000000000000000000000000000000000..47f52596e02ebc22deb5699af29fea6f88f27eed --- /dev/null +++ b/src/services/error/GoalErrorService.js @@ -0,0 +1,338 @@ +import axios from "axios"; + +/** + * Service class for handling errors related to goals. + */ +class GoalErrorService { + + /** + * Handles errors during goal contribution fetching, displaying appropriate messages to the user. + * @param {Error} error - The error object from goal contribution fetching attempt. + * @returns {string} Error message. + */ + async handleErrorGetGoalContribution(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 500: + return 'Intern serverfeil. Vennligst prøv igjen senere.'; + case 400: + case 404: + return 'Error: Finner ikke sparemÃ¥l.'; + default: + return error.response.data.errorMessage; + } + } else { + console.error('Cannot connect to server.', error); + return 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + + /** + * Handles errors during progress update, displaying appropriate messages to the user. + * @param {Error} error - The error object from progress update attempt. + * @returns {string} Error message. + */ + async handleErrorUpdateProgress(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 500: + return 'Intern serverfeil. Vennligst prøv igjen senere.'; + case 400: + case 404: + return 'Error: Finner ikke utfordring.'; + default: + return error.response.data.errorMessage; + } + } else { + console.error('Cannot connect to server.', error); + return 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + + /** + * Handles errors during challenge addition, displaying appropriate messages to the user. + * @param {Error} error - The error object from challenge addition attempt. + * @returns {string} Error message. + */ + async handleErrorAddChallenge(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 500: + return 'Intern serverfeil. Vennligst prøv igjen senere.'; + case 400: + case 404: + return 'Error: utfordring kan ikke lages med gitte verdier.'; + default: + return error.response.data.errorMessage; + } + } else { + console.error('Cannot connect to server.', error); + return 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + + /** + * Handles errors during badge creation, displaying appropriate messages to the user. + * @param {Error} error - The error object from badge creation attempt. + * @returns {string} Error message. + */ + async handleErrorCreateBadge(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 500: + return 'Intern serverfeil. Vennligst prøv igjen senere.'; + case 400: + case 404: + return 'Error: Type pÃ¥ trofe er ikke gjenkjent.'; + default: + return error.response.data.errorMessage; + } + } else { + console.error('Cannot connect to server.', error); + return 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + + /** + * Handles errors during individual challenge fetching, displaying appropriate messages to the user. + * @param {Error} error - The error object from individual challenge fetching attempt. + * @returns {string} Error message. + */ + async handleErrorGetChallenge(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 500: + return 'Intern serverfeil. Vennligst prøv igjen senere.'; + case 400: + case 404: + return 'Error: Finner ikke utfordring.'; + default: + return error.response.data.errorMessage; + } + } else { + console.error('Cannot connect to server.', error); + return 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + + /** + * Handles errors during multiple challenge fetching, displaying appropriate messages to the user. + * @param {Error} error - The error object from multiple challenge fetching attempt. + * @returns {string} Error message. + */ + async handleErrorGetChallenges(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 500: + return 'Intern serverfeil. Vennligst prøv igjen senere.'; + case 400: + case 404: + return 'Error: Finner ingen utfordringer knyttet til bruker.'; + default: + return error.response.data.errorMessage; + } + } else { + console.error('Cannot connect to server.', error); + return 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + + /** + * Handles errors during amount saved fetching, displaying appropriate messages to the user. + * @param {Error} error - The error object from amount saved fetching attempt. + * @returns {string} Error message. + */ + async handleErrorGetAmountSaved(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 500: + return 'Intern serverfeil. Vennligst prøv igjen senere.'; + case 400: + case 404: + return 'Error: Mengde spart pÃ¥ sparemÃ¥l er for tiden ikke tiljengelig.'; + default: + return error.response.data.errorMessage; + } + } else { + console.error('Cannot connect to server.', error); + return 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + + /** + * Handles errors during individual goal fetching, displaying appropriate messages to the user. + * @param {Error} error - The error object from individual goal fetching attempt. + * @returns {string} Error message. + */ + async handleErrorGetGoal(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 500: + return 'Intern serverfeil. Vennligst prøv igjen senere.'; + case 400: + case 404: + return 'Error: Finner ikke sparemÃ¥l.'; + default: + return error.response.data.errorMessage; + } + } else { + console.error('Cannot connect to server.', error); + return 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + + /** + * Handles errors during multiple goals fetching, displaying appropriate messages to the user. + * @param {Error} error - The error object from multiple goals fetching attempt. + * @returns {string} Error message. + */ + async handleErrorGetGoals(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 500: + return 'Intern serverfeil. Vennligst prøv igjen senere.'; + case 400: + case 404: + return 'Error: Finner ingen sparemÃ¥l under bruker.'; + default: + return error.response.data.errorMessage; + } + } else { + console.error('Cannot connect to server.', error); + return 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + + /** + * Handles errors during goal joining, displaying appropriate messages to the user. + * @param {Error} error - The error object from goal joining attempt. + * @returns {string} Error message. + */ + async handleErrorJoinGoal(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 500: + return 'Intern serverfeil. Vennligst prøv igjen senere.'; + case 400: + case 404: + return 'Error: Kode eksisterer ikke.'; + default: + return error.response.data.errorMessage; + } + } else { + console.error('Cannot connect to server.', error); + return 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + + /** + * Handles errors during challenge joining, displaying appropriate messages to the user. + * @param {Error} error - The error object from challenge joining attempt. + * @returns {string} Error message. + */ + async handleErrorJoinChallenge(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 500: + return 'Intern serverfeil. Vennligst prøv igjen senere.'; + case 400: + case 404: + return 'Error: Kode eksisterer ikke.'; + default: + return error.response.data.errorMessage; + } + } else { + console.error('Cannot connect to server.', error); + return 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + + /** + * Handles errors during contribution addition, displaying appropriate messages to the user. + * @param {Error} error - The error object from contribution addition attempt. + * @returns {string} Error message. + */ + async handleErrorAddContribution(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 500: + return 'Intern serverfeil. Vennligst prøv igjen senere.'; + case 400: + case 404: + return 'Error: Ugyldig mengde.'; + default: + return error.response.data.errorMessage; + } + } else { + console.error('Cannot connect to server.', error); + return 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + + /** + * Handles errors during challenge code sending, displaying appropriate messages to the user. + * @param {Error} error - The error object from challenge code sending attempt. + * @returns {string} Error message. + */ + async handleErrorSendChallengeCode(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 500: + return 'Intern serverfeil. Vennligst prøv igjen senere.'; + case 400: + case 404: + return 'Error: Email serveren er nede for øyeblikket'; + default: + return error.response.data.errorMessage; + } + } else { + console.error('Cannot connect to server.', error); + return 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + + /** + * Handles errors during friend challenge fetching, displaying appropriate messages to the user. + * @param {Error} error - The error object from friend challenge fetching attempt. + * @returns {string} Error message. + */ + async handleErrorGetFriendChallenge(error) { + if (axios.isAxiosError(error) && error.response) { + switch (error.response.status) { + case 500: + return 'Intern serverfeil. Vennligst prøv igjen senere.'; + case 400: + case 404: + return 'Error: Finner ingen brukere knyttet til denne utfordringen.'; + default: + return error.response.data.errorMessage; + } + } else { + console.error('Cannot connect to server.', error); + return 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } + + /** + * Handles errors during automatic challenge fetching, displaying appropriate messages to the user. + * @param {Error} error - The error object from automatic challenge fetching attempt. + * @returns {string} Error message. + */ + async handleErrorGetAutomaticChallenge(error) { + if (axios.isAxiosError(error)) { + switch (error.response.status) { + case 400: + case 404: + case 500: + return 'Error: Finner ingen analyse tilknyttet bruker. GÃ¥ innom din profil og reanalyser kontoutskrifter'; + default: + return error.response.data.errorMessage; + } + } else { + console.error('Cannot connect to server.', error); + return 'Kan ikke koble til serveren. Vennligst prøv igjen senere.'; + } + } +} + +export default new GoalErrorService(); \ No newline at end of file diff --git a/src/services/external/StockService.js b/src/services/external/StockService.js deleted file mode 100644 index 55433daa0a79c2b5d0d185bea6db600dbc30541e..0000000000000000000000000000000000000000 --- a/src/services/external/StockService.js +++ /dev/null @@ -1,35 +0,0 @@ -import axios from "axios"; - -class StockService { - - async fetchStockInfo(searchQuery) { - try { - // Ensure searchQuery is a string and trim any extra whitespace - if (typeof searchQuery !== 'string' || !searchQuery.trim()) { - throw new Error('Search query must be a non-empty string'); - } - const symbol = searchQuery.trim().toUpperCase(); - const response = await axios.get(`https://finnhub.io/api/v1/quote`, { - params: { - symbol: symbol, - token: 'cof4tfhr01qj17o79vqgcof4tfhr01qj17o79vr0' // Ensure your token is securely managed - } - }); - if (response.data) { - return { - name: symbol, - ticker: symbol, - price: response.data.c, // 'c' stands for current price - change: response.data.d, // 'd' stands for daily change in price - changePercent: response.data.dp // 'dp' stands for daily percentage change - }; - } - return null; - } catch (error) { - console.error('Error fetching stock data:', error); - return null; - } - } -} - -export default new StockService(); diff --git a/src/services/internal/AccountService.js b/src/services/internal/AccountService.js index bc8a38962e2a67e80e5833d6f64d879c3a7603ea..c8f178d83698b14c2673ef648337d3524f50cda7 100644 --- a/src/services/internal/AccountService.js +++ b/src/services/internal/AccountService.js @@ -1,23 +1,39 @@ import axios from "axios"; +import UserService from "./UserService"; +/** + * Service class for managing user accounts. + */ class AccountService { baseURL = "http://localhost:8080/account"; + /** + * Constructs an instance of AccountService and sets the authentication header using the user's session token. + */ + constructor() { + UserService.setAxiosAuthHeader(sessionStorage.getItem("authToken")); + } + + /** + * Adds a new account with the provided account number. + * @param {string} accountNumber - The account number to be added. + * @returns {Promise<Object>} The response data from the server. + * @throws {Error} If an error occurs during the request. + */ async addAccount(accountNumber) { try { let formData = new FormData(); formData.append("accountNumber", accountNumber); const response = await axios.post( - `${this.baseURL}/addAccount`, - formData, - { + `${this.baseURL}/addAccount`, + formData, + { headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json' } - } + } ); - console.log(response.data); return response.data; } catch (error) { console.error("Error adding account"); @@ -25,16 +41,20 @@ class AccountService { } } + /** + * Retrieves all accounts associated with the user. + * @returns {Promise<Array>} An array of account objects. + * @throws {Error} If an error occurs during the request. + */ async getAccounts() { try { const response = await axios.get(`${this.baseURL}/getAccounts`); - console.log(response.data); return response.data; } catch (error) { + console.error("Failed to retrieve accounts.", error); throw error; } } - - } + export default new AccountService(); \ No newline at end of file diff --git a/src/services/internal/AutomaticChallengeService.js b/src/services/internal/AutomaticChallengeService.js new file mode 100644 index 0000000000000000000000000000000000000000..4fcaaa53990c7a1189eb5dc6efbbffe0084b9f7f --- /dev/null +++ b/src/services/internal/AutomaticChallengeService.js @@ -0,0 +1,34 @@ +import axios from "axios"; +import UserService from "./UserService"; + +/** + * Service class for managing automatic challenges. + */ +class AutomaticChallengeService { + + baseURL = "http://localhost:8080/api/v1/random-challenges"; + + /** + * Constructs an instance of AutomaticChallengeService and sets the authentication header using the user's session token. + */ + constructor() { + UserService.setAxiosAuthHeader(sessionStorage.getItem("authToken")); + } + + /** + * Retrieves a random challenge from the server. + * @returns {Promise} The response data containing the random challenge. + * @throws {Error} If an error occurs during the request. + */ + async getChallenge() { + try{ + const response = await axios.get( + `${this.baseURL}/`); + return response.data; + } catch (error) { + console.error("Error getting goal.", error); + throw error; + } + } +} +export default new AutomaticChallengeService(); \ No newline at end of file diff --git a/src/services/internal/BadgeService.js b/src/services/internal/BadgeService.js new file mode 100644 index 0000000000000000000000000000000000000000..3715cec6492c0743809ca23f81a94a3a7ec2e7ab --- /dev/null +++ b/src/services/internal/BadgeService.js @@ -0,0 +1,87 @@ +import axios, { HttpStatusCode } from "axios"; +import UserService from "./UserService"; + +/** + * Service class for managing badges. + */ +class BadgeService { + + /** + * Constructor for BadgeService. + */ + baseURL = "http://localhost:8080/api/v1/badge-manager"; + + constructor() { + UserService.setAxiosAuthHeader(sessionStorage.getItem("authToken")); + } + + /** + * Retrieves all possible badges. + * + * @returns {Promise} A promise that resolves to an array of possible badges. + */ + async getAllBadges() { + try { + const response = await axios.get(`${this.baseURL}/possible-badges`); + return response.data; + } catch (error) { + console.error("Error fetching possible achievements.", error); + throw error; + } + } + + /** + * Retrieves badges with pagination support. + * + * @param {number} pageSize - The number of items per page. + * @param {number} page - The page number. + * @returns {Promise} A promise that resolves to an object containing badges data. + */ + async getBadges(pageSize, page) { + try { + const response = await axios.get( + `${this.baseURL}/badges`, { + params: { + page, + pageSize, + }, + headers: { + 'Content-Type': 'application/json' + } + }); + return response.data; + } catch (error) { + console.error("Error fetching badges.", error); + throw error; + } + } + + /** + * Creates a new badge. + * + * @param {string} category - The category of the badge. + * @returns {Promise<Object>} A promise that resolves to the created badge. + */ + async createBadge(category) { + try { + const response = await axios.get( + `http://localhost:8080/api/v1/achievement-manager/stats/${category}`, { + category, + }, { + headers: { + 'Content-Type': 'application/json' + } + }); + if (response.status === HttpStatusCode.Created) { + sessionStorage.setItem('showBanner', true); + } + return response.data; + } catch (error) { + console.error("Error adding badge.", error); + throw error; + } + } + +} + +export default new BadgeService(); \ No newline at end of file diff --git a/src/services/internal/BankStatementService.js b/src/services/internal/BankStatementService.js new file mode 100644 index 0000000000000000000000000000000000000000..77fa671ebf5144792dd3e2f85f587fa5ad9ccddd --- /dev/null +++ b/src/services/internal/BankStatementService.js @@ -0,0 +1,141 @@ +import axios from "axios"; +import UserService from "./UserService"; + +/** + * Service class for handling bank statement-related API requests. + */ +class BankStatementService { + + baseURL = "http://localhost:8080/api/v1/bank-statements"; + + /** + * Creates an instance of BankStatementService. + * Sets the Axios authorization header using the authentication token from sessionStorage. + */ + constructor() { + UserService.setAxiosAuthHeader(sessionStorage.getItem("authToken")); + } + + /** + * Adds a bank statement. + * + * @param {FormData} formData - The form data containing the bank statement file. + * @param {string} bankName - The name of the bank associated with the statement. + * @returns {Promise<Object>} The response data. + */ + async addBankStatement(formData, bankName) { + const url = `${this.baseURL}/` + "?bankName=" + bankName + try { + const response = await axios.post(url, formData); + return response.data; + } catch (error) { + console.error("Error adding bank transcript", error); + throw error; + } + } + + /** + * Analyzes a bank statement. + * + * @param {number} statementId - The ID of the bank statement to analyze. + * @param {boolean} [force=false] - Whether to force a new analysis. + * @param {boolean} [categorize=true] - Whether to categorize the transactions during analysis. + * @returns {Promise<Object>} The response data. + */ + async analyzeBankStatement(statementId, force = false, categorize = true) { + try { + const response = await axios.get( + `${this.baseURL}/${statementId}/analysis?forceNewAnalysis=${force}&categorize=${categorize}`); + return response.data; + } catch (error) { + console.error("Error analyzing bank transcript", error); + throw error; + } + } + + /** + * Retrieves bank statements. + * + * @param {number} month - The month for which to retrieve statements. + * @param {number} year - The year for which to retrieve statements. + * @returns {Promise<Object[]>} The array of bank statement objects. + */ + async retrieveBankStatements(month = 0, year = 0) { + try { + const response = await axios.get(`${this.baseURL}/?month=${month}&year=${year}`); + return response.data; + } catch (error) { + console.error("Error retrieving bank transcripts", error); + throw error; + } + } + + /** + * Retrieves bank statements with specified month and year. + * + * @param {number} month - The month for which to retrieve statements. + * @param {number} year - The year for which to retrieve statements. + * @returns {Promise<Object[]>} The array of bank statement objects. + */ + async retrieveBankStatementsWithMY(month, year) { + try { + const response = await axios.get(`${this.baseURL}/` + "?month=" + month + "&year=" + year); + return response.data; + } catch (error) { + console.error("Error retrieving bank transcripts", error); + throw error; + } + } + + /** + * Retrieves analyses for bank statements. + * + * @param {Object} retrieveAnalysesDTO - The DTO object containing parameters for retrieving analyses. + * @returns {Promise<Object[]>} The array of analysis objects. + */ + async retrieveAnalyses(retrieveAnalysesDTO) { + try { + const response = await axios.get(`${this.baseURL}/analyses`, {params: retrieveAnalysesDTO}, { + headers: {'Content-Type': 'application/json'} + }); + return response.data; + } catch (error) { + console.error("Error retrieving analyses", error); + throw error; + } + } + + /** + * Deletes a bank statement. + * + * @param {number} statementId - The ID of the bank statement to delete. + * @returns {Promise<Object>} The response data. + */ + async deleteBankStatement(statementId) { + try { + const response = await axios.delete(`${this.baseURL}/${statementId}`); + return response.data; + } catch (error) { + console.error("Error deleting bank transcript", error); + throw error; + } + } + + /** + * Updates an analysis for a bank statement. + * + * @param {Object} bankStatementAnalysisDTO - The DTO object containing updated analysis data. + * @returns {Promise<Object>} The response data. + */ + async updateAnalysis(bankStatementAnalysisDTO) { + try { + const response = await axios.put(`${this.baseURL}/analyses`, bankStatementAnalysisDTO); + return response.data; + } catch (error) { + console.error("Error updating analysis", error); + throw error; + } + } +} + +export default new BankStatementService(); \ No newline at end of file diff --git a/src/services/internal/BudgetService.js b/src/services/internal/BudgetService.js deleted file mode 100644 index 35f3cdec27c313f06b621a3666f459c526b284da..0000000000000000000000000000000000000000 --- a/src/services/internal/BudgetService.js +++ /dev/null @@ -1,17 +0,0 @@ -import axios from "axios"; - -class BudgetService { - baseURL = "http://localhost:8080/budget"; - - async getBudget() { - try { - const response = await axios.get(`${this.baseURL}/getBudget`); - console.log(response.data); - return response.data; - } catch (error) { - throw error; - } - } - -} -export default new BudgetService(); \ No newline at end of file diff --git a/src/services/internal/ChallengeService.js b/src/services/internal/ChallengeService.js index dba52fc0b3d5e32236ab136045aa2a9bd47af709..b2feae06fb5a304529603d41b662fd545df3c275 100644 --- a/src/services/internal/ChallengeService.js +++ b/src/services/internal/ChallengeService.js @@ -1,123 +1,140 @@ import axios from "axios"; -import { data } from "flickity"; +import UserService from "./UserService"; +/** + * Service for managing challenges. + */ class ChallengeService { - baseURL = "http://localhost:8080/Challenge"; + baseURL = "http://localhost:8080/api/v1/challenge-management"; - async createChallenge(title, totalSum, amountSaved, pigLife, currentTile, DateFrom, DateTo) { - try { - let formData = new FormData(); - formData.append("title", title); - formData.append("totalSum", totalSum); - formData.append("amountSaved", amountSaved); - formData.append("pigLife", pigLife); - formData.append("currentTile", currentTile); - formData.append("dateFrom", DateFrom); - formData.append("dateTo", DateTo); + /** + * Creates an instance of ChallengeService. + */ + constructor() { + UserService.setAxiosAuthHeader(sessionStorage.getItem("authToken")); + } + /** + * Adds a new challenge. + * + * @param {string} challengeType - The type of challenge. + * @param {string} title - The title of the challenge. + * @param {string} description - The description of the challenge. + * @param {Date} startDate - The start date of the challenge. + * @param {Date} endDate - The end date of the challenge. + * @param {string} difficulty - The difficulty of the challenge. + * @param {number} progress - The progress of the challenge. + * @returns {Promise<Object>} A promise that resolves with the added challenge data. + */ + async addChallenge(challengeType, title, description, startDate, endDate, difficulty, progress) { + try { const response = await axios.post( - `${this.baseURL}/createChallenge`, - formData, - { + `${this.baseURL}/challenge`, { + challengeType, + title, + description, + startDate, + endDate, + difficulty, + progress, + }, { headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json' } - } - ); - console.log(response.data); + }); return response.data; } catch (error) { - console.error("Error adding account"); + console.error("Error adding account.", error); throw error; } } - async updatePigLife(challengeID) { + /** + * Retrieves challenges. + * + * @param {number} pageSize - The number of challenges per page. + * @param {number} page - The page number. + * @returns {Promise<Object>} A promise that resolves with the retrieved challenges data. + */ + async getChallenges(pageSize, page) { try { - let formData = new FormData(); - formData.append("challengeID", challengeID); - - const response = await axios.put( - `${this.baseURL}/updatePigLige`, - formData, - { - headers: { - 'Content-Type': 'application/json' - } - } - ); - console.log(response.data); + const response = await axios.get(`${this.baseURL}/challenges?page=${page}&pageSize=${pageSize}`); return response.data; } catch (error) { - console.error("Error adding account"); + console.error("Failed to retrieve challenges.", error); throw error; } } - - async updateCurrentTile(challengeID) { - try { - let formData = new FormData(); - formData.append("challengeID", challengeID); - const response = await axios.put( - `${this.baseURL}/updateCurrentTile`, - formData, - { - headers: { - 'Content-Type': 'application/json' - } - } - ); - console.log(response.data); + /** + * Retrieves a specific challenge. + * + * @param {string} challengeID - The ID of the challenge to retrieve. + * @returns {Promise<Object>} A promise that resolves with the retrieved challenge data. + */ + async getChallenge(challengeID) { + try { + const response = await axios.get(`${this.baseURL}/challenge/${challengeID}`); return response.data; } catch (error) { - console.error("Error adding account"); + console.error("Failed to retrieve challenge.", error); throw error; } } - async updateSavedAmount(challengeID, addedValue) { + /** + * Updates the progress of a challenge. + * + * @param {string} challengeId - The ID of the challenge to update. + * @param {number} progress - The new progress value. + * @returns {Promise<Object>} A promise that resolves with the updated challenge data. + */ + async updateProgress(challengeId, progress) { try { - let formData = new FormData(); - formData.append("challengeID", challengeID); - formData.append("addedValue", addedValue); - const response = await axios.put( - `${this.baseURL}/updateSavedAmount`, - formData, - { + `${this.baseURL}/challenge/${challengeId}`, { + progress: progress, + }, { headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json' } - } - ); - console.log(response.data); + }); return response.data; } catch (error) { - console.error("Error adding account"); + console.error("Failed to update progress.", error) throw error; } } - async getChallenges() { + /** + * Joins a shared challenge using a join code. + * + * @param {string} joinCode - The join code of the shared challenge. + * @returns {Promise<Object>} A promise that resolves with the joined challenge data. + */ + async joinChallenge(joinCode) { try { - const response = await axios.get(`${this.baseURL}/getChallenges`); - console.log(response.data); + const response = await axios.get(`${this.baseURL}/shared-challenge/${joinCode}`); return response.data; } catch (error) { + console.error("Failed to join challenge", error); throw error; } } - async getChallenge(challengeID) { + /** + * Retrieves a friend's challenge. + * + * @param {string} sharedChallengeId - The ID of the shared challenge. + * @returns {Promise<Object>} A promise that resolves with the retrieved challenge data. + */ + async getFriendChallenge(sharedChallengeId) { try { - const response = await axios.get(`${this.baseURL}/getChallenge`, { - params: { challengeID }, - }); - console.log(response.data); + const response = await axios.get(`${this.baseURL}/shared-challenge/users/${sharedChallengeId}?sharedChallengeId=${sharedChallengeId}`); return response.data; } catch (error) { + console.error("Failed to retrieve challenge.", error); throw error; } } diff --git a/src/services/internal/CookieService.js b/src/services/internal/CookieService.js new file mode 100644 index 0000000000000000000000000000000000000000..43cbd98eb20e6e1a2787feadc9e257c6960ff87e --- /dev/null +++ b/src/services/internal/CookieService.js @@ -0,0 +1,81 @@ +/** + * Service class for managing cookies. + */ +class CookieService { + + /** + * Retrieves the value of a cookie by its name. + * @param {string} name - The name of the cookie. + * @returns {string|undefined} The value of the cookie, or undefined if the cookie does not exist. + */ + getCookie(name) { + const value = `; ${document.cookie}`; + const parts = value.split(`; ${name}=`); + if (parts.length === 2) return parts.pop().split(';').shift(); + } + + /** + * Sets a new cookie with the specified name, value, and expiration days. + * @param {string} name - The name of the cookie. + * @param {string} value - The value of the cookie. + * @param {number} days - The number of days until the cookie expires. + */ + setCookie(name, value, days) { + let expires = ""; + if (days) { + const date = new Date(); + date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); + expires = "; expires=" + date.toUTCString(); + } + document.cookie = name + "=" + (value || "") + expires + "; path=/"; + } + + /** + * Retrieves a string containing all the cookies. + * @returns {string} A string containing all the cookies. + */ + listCookies() { + let theCookies = document.cookie.split(';'); + let aString = ''; + for (let i = 1; i <= theCookies.length; i++) { + aString += i + ' ' + theCookies[i - 1] + "\n"; + } + return aString; + } + + /** + * Checks if the cookie 'cookiesAccepted' has been set to 'true'. + * @returns {boolean} True if the cookie 'cookiesAccepted' is set to 'true', otherwise false. + */ + isCookieAccepted() { + const cookieAccepted = this.getCookie('cookiesAccepted'); + return cookieAccepted === 'true'; + } + + /** + * Retrieves the value of a cookie with consent. + * @param {string} name - The name of the cookie. + * @returns {string|undefined} Cookie value, or undefined if the cookie does not exist or consent has not been given. + */ + getCookieWithConsent(name) { + + if (this.isCookieAccepted()) { + console.log('cookie accepted') + return this.getCookie(name); + } + } + + /** + * Sets a new cookie with consent. + * @param {string} name - The name of the cookie. + * @param {string} value - The value of the cookie. + * @param {number} days - The number of days until the cookie expires. + */ + setCookieWithConsent(name, value, days) { + if (this.isCookieAccepted()) { + this.setCookie(name, value, days); + } + } +} + +export default new CookieService(); \ No newline at end of file diff --git a/src/services/internal/EmailService.js b/src/services/internal/EmailService.js index 4065aa8aa7986916869c0ec5ec8dc9913546ae13..fa5d7604bf9d55e2ff3f7afda75d6d3ec6baa44b 100644 --- a/src/services/internal/EmailService.js +++ b/src/services/internal/EmailService.js @@ -1,43 +1,92 @@ import axios from "axios"; +/** + * Service for handling email-related operations. + */ class EmailService { - baseURL = "http://localhost:8080/email"; - async sendFeedback(fromEmail, message) { + baseURL = "http://localhost:8080/api/v1/email"; + + /** + * Sends a contact message. + * + * @param {Object} emailDetailsDto - Details of the email to be sent. + * @throws {Error} If an error occurs while sending the contact message. + */ + async sendContactMessage(emailDetailsDto) { + const url = `${this.baseURL}/contact`; + try { - const response = await axios.post( - `${this.baseURL}/sendFeedback?fromEmail=${fromEmail}&message=${message}` - ); - console.log("Feedback sent successfully:", response.data); - return true; + await axios.post(url, emailDetailsDto); } catch (error) { - console.error("Failed to send feedback:", error); + console.error("Failed to send contact message.", error); throw error; } } - async sendPasswordResetToken(toEmail) { + /** + * Sends a registration token to the specified email address. + * + * @param {string} email - The email address to send the registration token to. + * @returns {Promise<Object>} A promise that resolves to the response data containing the expiration timestamp . + * @throws {Error} If an error occurs while sending the registration token. + */ + async sendRegisterToken(email) { + const url = `${this.baseURL}/verification/code?email=${email}` + try { - const response = await axios.post( - `${this.baseURL}/sendPasswordResetToken?toEmail=${toEmail}` - ); - console.log("Password reset token sent successfully"); + const response = await axios.get(url); return response.data; } catch (error) { - console.error("Failed to send password reset token:", error); + console.error("Failed to send register token.", error); throw error; } } - async sendRegisterToken(toEmail) { + /** + * Verifies if the specified email address is available. + * + * @param {string} email - The email address to verify availability for. + * @throws {Error} If an error occurs while verifying email availability. + */ + async verifyEmailAvailability(email) { + const url = `${this.baseURL}/available?email=${email}` try { - const response = await axios.post( - `${this.baseURL}/sendRegisterToken?toEmail=${toEmail}` - ); - console.log("Register token sent successfully"); - return response.data; + await axios.get(url); + } catch (error) { + console.error("Failed to verify if email is available.", error); + throw error; + } + } + + /** + * Verifies if the specified email address exists. + * + * @param {string} email - The email address to verify existence for. + * @throws {Error} If an error occurs while verifying email existence. + */ + async verifyEmailExistence(email) { + const url = `${this.baseURL}/exist?email=${email}` + try { + await axios.get(url); + } catch (error) { + console.error("Failed to verify if email exist.", error); + throw error; + } + } + + /** + * Sends a challenge code to the specified email address. + * + * @param {string} email - The email address to send the challenge code to. + * @param {string} id - The ID associated with the challenge. + * @throws {Error} If an error occurs while sending the challenge code. + */ + async sendChallengeCode(email, id) { + try { + await axios.get(`${this.baseURL}/challenge/join/${id}?email=${email}&id=${id}`); } catch (error) { - console.error("Failed to send register token:", error); + console.error("Failed to send code:", error); throw error; } } diff --git a/src/services/internal/GoalService.js b/src/services/internal/GoalService.js new file mode 100644 index 0000000000000000000000000000000000000000..ec3f19004f705958619f79d5a5c747a9684a6bf5 --- /dev/null +++ b/src/services/internal/GoalService.js @@ -0,0 +1,248 @@ +import axios from "axios"; +import UserService from "./UserService"; + +/** + * Service for managing goals. + */ +class GoalService { + + baseURL = "http://localhost:8080/api/v1/goal-manager"; + + /** + * Creates an instance of GoalService. + */ + constructor() { + UserService.setAxiosAuthHeader(sessionStorage.getItem("authToken")); + } + + /** + * Adds a new goal. + * + * @param {string} goalName - The name of the goal. + * @param {number} totalAmount - The total amount for the goal. + * @param {number} lives - The number of lives for the goal. + * @param {Date} startDate - The start date of the goal. + * @param {Date} endDate - The end date of the goal. + * @returns {Promise<Object>} A promise that resolves with the added goal data. + * @throws {Error} If an error occurs while adding the goal. + */ + async addGoal(goalName, totalAmount, lives, startDate, endDate) { + try { + const response = await axios.post( + `${this.baseURL}/goals`, { + goalName: goalName, + totalAmount: totalAmount, + lives: lives, + startDate: startDate, + endDate: endDate, + }, { + headers: { + 'Content-Type': 'application/json' + } + } + ); + return response.data; + } catch (error) { + console.error("Error adding account.", error); + throw error; + } + } + + /** + * Retrieves goals. + * + * @param {number} pageSize - The number of goals per page. + * @param {number} page - The page number. + * @returns {Promise} A promise that resolves with the retrieved goals data. + * @throws {Error} If an error occurs while retrieving goals. + */ + async getGoals(pageSize, page) { + try { + const response = await axios.get(`${this.baseURL}/goals?page=${page}&pageSize=${pageSize}`); + return response.data; + } catch (error) { + console.error("Failed to retrieve goals.", error); + throw error; + } + } + + /** + * Retrieves a specific goal. + * + * @param {string} id - The ID of the goal to retrieve. + * @returns {Promise} A promise that resolves with the retrieved goal data. + * @throws {Error} If an error occurs while retrieving the goal. + */ + async getGoal(id) { + try { + const response = await axios.get(`${this.baseURL}/goal/${id}`, { + id: id + }, { + headers: { + 'Content-Type': 'application/json' + } + } + ); + return response.data; + } catch (error) { + console.error("Error retrieving goal.", error); + throw error; + } + } + + /** + * Retrieves the amount saved for a goal. + * + * @param {string} id - The ID of the goal. + * @param {string} title - The title of the goal. + * @param {string} state - The state of the goal. + * @returns {Promise} A promise that resolves with the retrieved amount saved data. + * @throws {Error} If an error occurs while retrieving the amount saved. + */ + async getAmountSaved(id, title, state) { + try { + const response = await axios.post( + `${this.baseURL}/goal/save`, { + id: id, + title: title, + state: state, + }, { + headers: { + 'Content-Type': 'application/json' + } + } + ); + return response.data; + } catch (error) { + console.error("Error retrieving amount saved for the goal.", error); + throw error; + } + } + + /** + * Retrieves contributors for a goal. + * + * @param {string} id - The ID of the goal. + * @returns {Promise} A promise that resolves with the retrieved goal contributors data. + * @throws {Error} If an error occurs while retrieving goal contributors. + */ + async getGoalContributors(id) { + try { + const response = await axios.get( + `${this.baseURL}/goal/contributors/${id}`, { + id: id + }, { + headers: { + 'Content-Type': 'application/json' + } + } + ); + return response.data; + } catch (error) { + console.error("Error getting goal"); + throw error; + } + } + + /** + * Adds a contribution to a goal. + * + * @param {string} goalId - The ID of the goal. + * @param {number} contribution - The contribution amount. + * @returns {Promise} A promise that resolves with the added contribution data. + * @throws {Error} If an error occurs while adding the contribution. + */ + async addContribution(goalId, contribution) { + try { + const response = await axios.put( + `${this.baseURL}/goal/save`, { + goalId: goalId, + contribution: contribution + }, { + headers: { + 'Content-Type': 'application/json' + } + } + ); + return response.data; + } catch (error) { + console.error("Error adding contribution to goal.", error); + throw error; + } + } + + /** + * Updates the progress of a goal. + * + * @param {string} id - The ID of the goal to update. + * @param {string} goalState - The new state of the goal. + * @returns {Promise<Object>} A promise that resolves with the updated goal data. + */ + async updateProgress(id, goalState) { + try { + const response = await axios.put( + `${this.baseURL}/goal/state`, { + id: id, + goalState: goalState, + }, { + headers: { + 'Content-Type': 'application/json' + } + } + ); + return response.data; + } catch (error) { + console.error("Failed to update progress for goal.", error); + throw error; + } + } + + /** + * Joins a goal using a join code. + * + * @param {string} joinCode - The join code of the goal. + * @returns {Promise<Object>} A promise that resolves with the joined goal data. + * @throws {Error} If an error occurs while joining the goal. + */ + async joinGoal(joinCode) { + try { + const response = await axios.put( + `${this.baseURL}/goal`, { + joinCode: joinCode, + }, { + headers: { + 'Content-Type': 'application/json' + } + } + ); + return response.data; + } catch (error) { + console.error("Error joining goal.", error); + throw error; + } + } + + /** + * Retrieves the total saved amount. + * + * @returns {Promise<Object>} A promise that resolves with the total saved amount data. + * @throws {Error} If an error occurs while retrieving the total saved amount. + */ + async getTotalSaved() { + try { + const response = await axios.get( + `http://localhost:8080/api/v1/achievement-manager/total`, {}, { + headers: { + 'Content-Type': 'application/json' + } + } + ); + return response.data; + } catch (error) { + console.error("Error retrieving total saved.", error); + throw error; + } + } +} + +export default new GoalService(); \ No newline at end of file diff --git a/src/services/internal/GoalsService.js b/src/services/internal/GoalsService.js deleted file mode 100644 index 3a599230be77b05d2ca70368eacbbb6a347601de..0000000000000000000000000000000000000000 --- a/src/services/internal/GoalsService.js +++ /dev/null @@ -1,124 +0,0 @@ -import axios from "axios"; - -class GoalsService { - baseURL = "http://localhost:8080/goal"; - - async createGoal(title, totalSum, amountSaved, pigLife, currentTile, DateFrom, DateTo) { - try { - let formData = new FormData(); - formData.append("title", title); - formData.append("totalSum", totalSum); - formData.append("amountSaved", amountSaved); - formData.append("pigLife", pigLife); - formData.append("currentTile", currentTile); - formData.append("dateFrom", DateFrom); - formData.append("dateTo", DateTo); - - const response = await axios.post( - `${this.baseURL}/createGoal`, - formData, - { - headers: { - "Content-Type": "multipart/form-data", - }, - } - ); - console.log(response.data); - return response.data; - } catch (error) { - console.error("Error adding account"); - throw error; - } - } - - async updatePigLife(goalID) { - try { - let formData = new FormData(); - formData.append("goalID", goalID); - - const response = await axios.put( - `${this.baseURL}/updatePigLige`, - formData, - { - headers: { - "Content-Type": "multipart/form-data", - }, - } - ); - console.log(response.data); - return response.data; - } catch (error) { - console.error("Error adding account"); - throw error; - } - } - - async updateCurrentTile(goalID) { - try { - let formData = new FormData(); - formData.append("goalID", goalID); - - const response = await axios.put( - `${this.baseURL}/updateCurrentTile`, - formData, - { - headers: { - "Content-Type": "multipart/form-data", - }, - } - ); - console.log(response.data); - return response.data; - } catch (error) { - console.error("Error adding account"); - throw error; - } - } - - async updateSavedAmount(goalID, addedValue) { - try { - let formData = new FormData(); - formData.append("goalID", goalID); - formData.append("addedValue", addedValue); - - const response = await axios.put( - `${this.baseURL}/updateSavedAmount`, - formData, - { - headers: { - 'Content-Type': 'application/json' - } - } - ); - console.log(response.data); - return response.data; - } catch (error) { - console.error("Error adding account"); - throw error; - } - } - - async getGoals() { - try { - const response = await axios.get(`${this.baseURL}/getGoals`); - console.log(response.data); - return response.data; - } catch (error) { - throw error; - } - } - - async getGoal(goalID) { - try { - const response = await axios.get(`${this.baseURL}/getGoal`, { - params: { goalID }, - }); - console.log(response.data); - return response.data; - } catch (error) { - throw error; - } - } -} - -export default new GoalsService(); \ No newline at end of file diff --git a/src/services/internal/NewsService.js b/src/services/internal/NewsService.js index 2c0b6aa27d79e34807743580623adc653bc0d2f3..10621989755583d1d019d944c151e2b90a3a3f7a 100644 --- a/src/services/internal/NewsService.js +++ b/src/services/internal/NewsService.js @@ -1,8 +1,29 @@ import axios from "axios"; +/** + * Service for fetching news data. + */ class NewsService { - baseURL = "http://localhost:8080/news"; + baseURL = "http://localhost:8080/api/v1/news"; + /** + * Fetches news data from the backend. + * + * @param {number} page - The page number of news to fetch. + * @param {number} pageSize - The number of news items to fetch per page. + * @returns {Promise<object>} A Promise that resolves to the news data. + * @throws {Error} If there is an error fetching news data. + */ + async getNews(page, pageSize) { + try { + const response = await axios.get(`${this.baseURL}/?page=${page}&pageSize=${pageSize}`); + return response.data; + } catch (error) { + console.error("Error fetching news.", error); + throw error; + } + } } + export default new NewsService(); \ No newline at end of file diff --git a/src/services/internal/StockService.js b/src/services/internal/StockService.js new file mode 100644 index 0000000000000000000000000000000000000000..75eeb763e253c59daec6a5f6bb857eaf433dfbf9 --- /dev/null +++ b/src/services/internal/StockService.js @@ -0,0 +1,29 @@ +import axios from "axios"; +import CookieService from "@/services/internal/CookieService"; + +/** + * Service for fetching stock information from an external API. + */ +class StockService { + + /** + * Fetches stock information based on the provided search query. + * + * @param {string} searchQuery - The search query for the stock symbol. + * @returns {Promise<object>} A promise that resolves to the stock information object. + * @throws {Error} If an error occurs during retrieval of stock data. + */ + async fetchStockInfo(searchQuery) { + CookieService.setCookieWithConsent('stockSearch', searchQuery, 1); + const symbol = searchQuery.trim().toUpperCase(); + try { + const response = await axios.get(`http://localhost:8080/api/v1/stock/${symbol}`); + return response.data; + } catch (error) { + console.error("Failed to retrieve stock data.", error); + throw error; + } + } +} + +export default new StockService(); diff --git a/src/services/internal/StreakService.js b/src/services/internal/StreakService.js new file mode 100644 index 0000000000000000000000000000000000000000..c505afffe7fb78132d970a8fdb85d20599cece9a --- /dev/null +++ b/src/services/internal/StreakService.js @@ -0,0 +1,57 @@ +import axios from "axios"; +import UserService from "./UserService"; + +/** + * Service class for managing user streaks. + */ +class StreakService { + + baseURL = "http://localhost:8080/api/v1/streak"; + + /** + * Constructs an instance of StreakService and sets the authentication header using the user's session token. + */ + constructor() { + UserService.setAxiosAuthHeader(sessionStorage.getItem("authToken")); + } + + /** + * Retrieves the user's streak from the server. + * @returns {Promise} The response data containing the user's streak. + * @throws {Error} If an error occurs during the request. + */ + async getStreak() { + try { + const response = await axios.get(`${this.baseURL}`); + return response.data; + } catch (error) { + console.error("Error fetching streak"); + throw error; + } + } + + /** + * Changes the user's streak by the specified increment. + * @param {number} increment - The amount by which to increment or decrement the streak. + * @returns {Promise} The response data containing the updated streak. + * @throws {Error} If an error occurs during the request. + */ + async changeStreak(increment) { + try { + const response = await axios.put(`${this.baseURL}`, { + increment, + }, { + headers: { + 'Content-Type': 'application/json' + } + }); + return response.data; + } catch (error) { + console.error("Error fetching news"); + throw error; + } + } + + +} +export default new StreakService(); \ No newline at end of file diff --git a/src/services/internal/TransactionService.js b/src/services/internal/TransactionService.js index f5a3b99984a37a2955183507d9107b8470ee4479..0220a08dc45899d20c061ac50d4cc4919a0d8a35 100644 --- a/src/services/internal/TransactionService.js +++ b/src/services/internal/TransactionService.js @@ -1,23 +1,41 @@ import axios from "axios"; +import UserService from "./UserService"; +import TransactionDTO from "@/models/transaction/TransactionDTO"; +/** + * Service class for managing user transactions. + */ class TransactionService { - baseURL = "http://localhost:8080/transaction"; + baseURL = "http://localhost:8080/api/v1/bank-statements"; + + /** + * Constructs an instance of TransactionService and sets the authentication header. + */ + constructor() { + UserService.setAxiosAuthHeader(sessionStorage.getItem("authToken")); + } + + /** + * Adds transactions from a PDF file. + * @param {File} pdfFile - The PDF file containing the transactions. + * @returns {Promise} The response data from the server. + * @throws {Error} If an error occurs during the request. + */ async addTransactions(pdfFile) { try { let formData = new FormData(); formData.append("pdfFile", pdfFile); const response = await axios.post( - `${this.baseURL}/addTransactions`, - formData, - { + `${this.baseURL}/addTransactions`, + formData, + { headers: { - 'Content-Type': 'application/json' + 'Content-Type': 'application/json' } - } + } ); - console.log(response.data); return response.data; } catch (error) { console.error("Error adding account"); @@ -25,16 +43,48 @@ class TransactionService { } } - async getTransactions(accountNumber) { + /** + * Retrieves the account numbers associated with the user's transactions. + * @returns {Promise<Array>} An array of account numbers. + * @throws {Error} If an error occurs during the request. + */ + async getAccountNumbers() { + try { + const response = await axios.get(this.baseURL + '/account-numbers') + return response.data; + } catch (error) { + console.log(error) + } + } + + /** + * Updates a transaction. + * @param {TransactionDTO} transactionDTO - The transaction data transfer object. + * @returns {Promise<Object>} The response data from the server. + * @throws {Error} If an error occurs during the request. + */ + async updateTransaction(transactionDTO) { try { - const response = await axios.get(`${this.baseURL}/getTransactions`, { - params: { accountNumber }, + const dto = new TransactionDTO( + transactionDTO.id, + transactionDTO.date, + transactionDTO.description, + transactionDTO.amount, + transactionDTO.isIncoming, + transactionDTO.category + ); + + const response = await axios.put(`http://localhost:8080/api/v1/transactions/`, dto, { + headers: { + 'Content-Type': 'application/json' + } }); - console.log(response.data); return response.data; } catch (error) { + console.error(error); throw error; } } } + export default new TransactionService(); \ No newline at end of file diff --git a/src/services/internal/UserDetailsInputService.js b/src/services/internal/UserDetailsInputService.js new file mode 100644 index 0000000000000000000000000000000000000000..f2e7e397be2ad1e2459a414f76df3e9b82013e5e --- /dev/null +++ b/src/services/internal/UserDetailsInputService.js @@ -0,0 +1,163 @@ +/** + * This service is responsible for validating user input for user details. + */ +class UserDetailsValidationService { + + MAX_PASSWORD_LENGTH = 64; + MIN_PASSWORD_LENGTH = 8; + MAX_NAME_LENGTH = 64; + MIN_NAME_LENGTH = 2; + MAX_EMAIL_LENGTH = 64; + + constructor() { + } + + /** + * Validates a request to edit a password + * + * @param {string} newPassword - The new password + * @param {string} confirmPassword - The confirmed new password + * @param {string} oldPassword - The old password + * @throws {Error} Throws error if validation fails. + */ + validateEditPassword(newPassword, confirmPassword, oldPassword) { + if (!newPassword || !confirmPassword || !oldPassword) { + throw new Error('Vennligst fyll ut alle passordfeltene.'); + } + if (newPassword.length < 8) { + throw new Error('Det nye passordet mÃ¥ være minst 8 tegn langt.'); + } + if (newPassword !== confirmPassword) { + throw new Error('De nye passordene stemmer ikke overens.'); + } + if (!oldPassword) { + throw new Error('Vennligst skriv inn ditt gamle passord.'); + } + + this.validatePasswordPattern(newPassword) + } + + /** + * Validates an input email address + * + * @param {string} email - The email address to validate + * @throws {Error} Throws error if validation fails. + */ + validateEmailPattern(email) { + if (!email) { + throw new Error('E-postadresse er pÃ¥krevd.'); + } + + if (email.length > this.MAX_EMAIL_LENGTH) { + throw new Error(`E-postadressen mÃ¥ være pÃ¥ maksimalt ${this.MAX_EMAIL_LENGTH} tegn.`); + } + + if (!email.includes('@')) { + throw new Error('E-postadressen mÃ¥ inneholde @.'); + } + + if (email.startsWith('@')) { + throw new Error('E-postadressen kan ikke starte med @.'); + } + + if (!email.includes('.')) { + throw new Error('E-postadressen mÃ¥ inneholde en domene.'); + } + + let parts = email.split('@'); + if (parts[1].indexOf('.') === 0) { + throw new Error('Domene kan ikke starte med en dot.'); + } + + let domainParts = parts[1].split('.'); + if (domainParts.some(part => part.length === 0)) { + throw new Error('Domene kan ikke ha tomme segmenter (f.eks. ".." eller ".com").'); + } + + // Regex pattern for checking email has the shape: [string]@[string].[string] + const emailPattern = /^[^@]+@[^@]+\.[^@]+$/; + if (!emailPattern.test(email)) { + throw new Error('Vennligst skriv inn en gyldig e-postadresse.'); + } + } + + /** + * Validates an input name + * + * @param {string} name - The name to validate + * @throws {Error} Throws error if validation fails. + */ + validateNamePattern(name) { + if (!name) { + throw new Error('Navn er pÃ¥krevd.'); + } + + if (name.length < this.MIN_NAME_LENGTH) { + throw new Error(`Navnet mÃ¥ være pÃ¥ minst ${this.MIN_NAME_LENGTH} tegn.`); + } + + if (name.length > this.MAX_NAME_LENGTH) { + throw new Error(`Navnet mÃ¥ være pÃ¥ maksimalt ${this.MAX_NAME_LENGTH} tegn.`); + } + + if (!name.match(/^[a-zA-Z]+$/)) { + throw new Error('Navnet kan bare inneholde bokstaver.'); + } + + } + + /** + * Validates an input password + * + * @param {string} password - The password to validate + * @throws {Error} Throws error if validation fails. + */ + validatePasswordPattern(password) { + + const errors = []; + + if (!password) { + errors.push('Passord er pÃ¥krevd.'); + } + + if (password.length < this.MIN_PASSWORD_LENGTH) { + errors.push(`være minst ${this.MIN_PASSWORD_LENGTH} tegn`); + } + + if (password.length > this.MAX_PASSWORD_LENGTH) { + errors.push(`være maksimalt ${this.MAX_PASSWORD_LENGTH} tegn.`); + } + + const hasUpperCase = /[A-Z]/.test(password); + const hasLowerCase = /[a-z]/.test(password); + const hasNumbers = /\d/.test(password); + const hasSpecialChar = /[\W_]/.test(password); + + if (!hasUpperCase) errors.push('inkludere stor bokstav'); + if (!hasLowerCase) errors.push('inkludere liten bokstav'); + if (!hasNumbers) errors.push('inkludere et tall'); + if (!hasSpecialChar) errors.push('inkludere spesialtegn'); + + if (errors.length > 0) { + throw new Error(`Passordet mÃ¥ ${errors.join(', ')}.`); + } + + } + + /** + * Validates an input feedback + * + * @param {string} value - The feedback value to validate + * @throws {Error} Throws error if validation fails. + */ + validateFeedbackPattern(value) { + if (!value) { + throw new Error('Tilbakemelding er pÃ¥krevd.'); + } + if (value.isEmpty) { + throw new Error('Tilbakemelding kan ikke bare inneholde mellomrom.'); + } + } +} + +export default new UserDetailsValidationService(); \ No newline at end of file diff --git a/src/services/internal/UserDetailsService.js b/src/services/internal/UserDetailsService.js index f166017f4231ba7392cf3fca96e5b1b6eabf16e5..e1d6096d0574bffa87722052178fdab34748bd39 100644 --- a/src/services/internal/UserDetailsService.js +++ b/src/services/internal/UserDetailsService.js @@ -1,27 +1,195 @@ import axios from "axios"; +import UserService from "./UserService"; class UserDetailsService { - baseURL = "http://localhost:8080/userDetails"; - async addUserDetails(income, householdType) { + baseURL = "http://localhost:8080/api/v1/users"; + + /** + * Constructs a new instance of UserDetailsService. + */ + constructor() { + UserService.setAxiosAuthHeader(sessionStorage.getItem("authToken")); + } + + /** + * Retrieves details of the currently logged-in user. + * + * @returns {Promise<Object>} A Promise that resolves to the user details. + * @throws {Error} If an error occurs during retrieval. + */ + async retrieveUserDetails() { + try { + const response = await axios.get(`${this.baseURL}/details`); + return response.data; + } catch (error) { + console.error("Error retrieving user details.", error); + throw error; + } + } + + /** + * Changes the first name of the currently logged-in user. + * + * @param {object} changeFirstNameDTO Dto containing the new first name. + * @returns {Promise<Object>} A Promise that resolves to the updated user details. + * @throws {Error} If an error occurs during the operation. + */ + async changeFirstName(changeFirstNameDTO) { + try { + const response = await axios.put(`${this.baseURL}/first-name`, changeFirstNameDTO, { + headers: { 'Content-Type': 'application/json' } + }); + return response.data; + } catch (error) { + console.error("Error adding account.", error); + throw error; + } + } + + /** + * Changes the last name of the currently logged-in user. + * + * @param {object} changeLastNameDTO Dto containing the new last name. + * @returns {Promise<Object>} A Promise that resolves to the updated user details. + * @throws {Error} If an error occurs during the operation. + */ + async changeLastName(changeLastNameDTO) { + try { + const response = await axios.put(`${this.baseURL}/last-name`, changeLastNameDTO, { + headers: { 'Content-Type': 'application/json' } + }); + return response.data; + } catch (error) { + console.error("Error adding account.", error); + throw error; + } + } + + /** + * Changes the password of the currently logged-in user. + * + * @param {object} changePasswordDTO Dto containing the new password. + * @returns {Promise<Object>} A Promise that resolves to the updated user details. + * @throws {Error} If an error occurs during the operation. + */ + async changePassword(changePasswordDTO) { + try { + const response = await axios.put(`${this.baseURL}/password`, changePasswordDTO, { + headers: { 'Content-Type': 'application/json' } + }); + return response.data; + } catch (error) { + console.error("Error changing password.", error); + throw error; + } + } + + /** + * Adds additional details for the currently logged-in user. + * + * @param {object} addAdditionalUserDetailsDTO Dto containing additional user details. + * @returns {Promise<Object>} A Promise that resolves to the updated user details. + * @throws {Error} If an error occurs during the operation. + */ + async addAdditionalUserDetails(addAdditionalUserDetailsDTO) { + try { + const response = await axios.post(`${this.baseURL}/info`, addAdditionalUserDetailsDTO, { + headers: { 'Content-Type': 'application/json' } + }); + return response.data; + } catch (error) { + console.error("Error adding user information.", error); + throw error; + } + } + + /** + * Changes the living status of the currently logged-in user. + * + * @param {object} changeLivingStatusDTO Dto containing the new living status. + * @returns {Promise<Object>} A Promise that resolves to the updated user details. + * @throws {Error} If an error occurs during the operation. + */ + async changeLivingStatus(changeLivingStatusDTO) { + try { + const response = await axios.put(`${this.baseURL}/living-status`, changeLivingStatusDTO, { + headers: { 'Content-Type': 'application/json' } + }); + return response.data; + } catch (error) { + console.error("Error changing living status.", error); + throw error; + } + } + + /** + * Changes the income of the currently logged-in user. + * + * @returns {Promise<Object>} A Promise that resolves to the updated user details. + * @throws {Error} If an error occurs during the operation. + * @param changeIncomeDTO + */ + async changeIncome(changeIncomeDTO) { try { - let formData = new FormData(); - formData.append("income", income); - formData.append("householdType", householdType); + const response = await axios.put(`${this.baseURL}/income`, changeIncomeDTO, { + headers: { 'Content-Type': 'application/json' } + }); + return response.data; + } catch (error) { + console.error("Error changing income.", error); + throw error; + } + } - const response = await axios.post( - `${this.baseURL}/addUserDetails`, - formData, - { - headers: { - 'Content-Type': 'application/json' - } - } - ); - console.log(response.data); + /** + * Changes the saving percentage of the currently logged-in user. + * + * @returns {Promise<Object>} A Promise that resolves to the updated user details. + * @throws {Error} If an error occurs during the operation. + * @param savingPercentage + */ + + async changeSavingPercentage(savingPercentage) { + try { + const response = await axios.put(`${this.baseURL}/saving-percentage`, savingPercentage, { + headers: { 'Content-Type': 'application/json' } + }); return response.data; } catch (error) { - console.error("Error adding account"); + console.error("Error changing income.", error); + throw error; + } + } + + /** + * Deletes the currently logged-in user. + * + * @param {string} verificationCode Verification code for user deletion. + * @throws {Error} If an error occurs during deletion. + */ + async deleteUser(verificationCode) { + try { + await axios.delete(`${this.baseURL}?verificationCode=${verificationCode}`, { + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + console.error("Error deleting user.", error) + throw error; + } + } + + /** + * Resets the user's password. + * + * @param resetPasswordDto DTO with required information for resetting password. + * @throws {Error} If an error occurs during resetting. + */ + async resetPassword(resetPasswordDto) { + try { + await axios.put(`${this.baseURL}/password-reset`, resetPasswordDto) + } catch (error) { + console.error("Error resetting password.", error) throw error; } } diff --git a/src/services/internal/UserService.js b/src/services/internal/UserService.js index 89028e73af74a032207a3b159c9489f892e858b0..da4334a728090c29e2c8a0faff03065313161cce 100644 --- a/src/services/internal/UserService.js +++ b/src/services/internal/UserService.js @@ -1,166 +1,95 @@ import axios from "axios"; +/** + * Service class for user-related operations. + */ class UserService { baseURL = "http://localhost:8080/api/v1/auth"; + authInterceptor = null; - async register(email, firstName, lastName, password) { - try { - const response = await axios.post(`${this.baseURL}/register`, { - email, - firstName, - lastName, - password - }, { - headers: { - 'Content-Type': 'application/json' - } - }); - console.log(response.data); - return response.data; - } catch (error) { - console.error("Error adding account", error); - throw error; + /** + * Constructs an instance of UserService and sets the authentication header. + */ + constructor() { + const authToken = sessionStorage.getItem("authtoken"); + if (authToken) { + this.setAxiosAuthHeader(authToken); } } - - async login(email, password) { + /** + * Registers a new user. + * @param {Object} registerRequestDTO - The registration request data transfer object. + * @returns {Promise} The response data from the server. + * @throws {Error} If an error occurs during the request. + */ + async register(registerRequestDTO) { try { - let formData = new FormData(); - formData.append("email", email); - formData.append("password", password); + const response = await axios.post(`${this.baseURL}/register`, registerRequestDTO) - const response = await axios.post( - `${this.baseURL}/login`, - formData, - { - headers: { - 'Content-Type': 'application/json' - } - } - ); - const token = response.data.token; - sessionStorage.removeItem("authToken"); - sessionStorage.setItem("authToken", token); - console.log(token); - this.setAxiosAuthHeader(token); - console.log(response.data); return response.data; } catch (error) { - console.error("Error logging in"); + console.error("Error adding account", error); throw error; } } - setAxiosAuthHeader() { - axios.interceptors.request.use( - (config) => { - const token = sessionStorage.getItem("AuthToken"); - if (token) { - config.headers["Authorization"] = "Bearer " + token; - } - return config; - }, - (error) => { - return Promise.reject(error); - } - ); - } - - async changeEmail(newEmail) { + /** + * Logs in a user. + * @param {Object} loginRequestDTO - The login request data transfer object. + * @returns {Promise} The response data from the server. + * @throws {Error} If an error occurs during the request. + */ + async login(loginRequestDTO) { try { - let formData = new FormData(); - formData.append("newEmail", newEmail); - - const response = await axios.put( - `${this.baseURL}/changeEmail`, - formData, - { - headers: { - 'Content-Type': 'application/json' - } - } - ); - console.log(response.data); + const response = await axios.post(`${this.baseURL}/login`, loginRequestDTO, { + headers: {'Content-Type': 'application/json'} + }); + this.setAxiosAuthHeader(response.data.token); return response.data; } catch (error) { - console.error("Error adding account"); + console.error(error.response.data.errorMessage); throw error; } } - async changeFirstName(newFirstName) { + /** + * Checks if an email exists. + * @param {string} email - The email address to check. + * @returns {Promise} True if the email exists, otherwise false. + * @throws {Error} If an error occurs during the request. + */ + async emailExist(email) { try { - let formData = new FormData(); - formData.append("newFirstName", newFirstName); - - const response = await axios.put( - `${this.baseURL}/changeFirstName`, - formData, - { - headers: { - 'Content-Type': 'application/json' - } - } - ); - console.log(response.data); + const response = await axios.get(`${this.baseURL}/emailExist`, {params: {email}}); return response.data; } catch (error) { - console.error("Error adding account"); + console.error("Error checking email existence", error); throw error; } } - async changeLastName(newLastName) { - try { - let formData = new FormData(); - formData.append("newLastName", newLastName); - - const response = await axios.put( - `${this.baseURL}/changeLastName`, - formData, - { - headers: { - 'Content-Type': 'application/json' - } - } - ); - console.log(response.data); - return response.data; - } catch (error) { - console.error("Error adding account"); - throw error; + /** + * Sets the Axios authentication header with the provided token. + * @param {string} token - The authentication token. + */ + setAxiosAuthHeader(token) { + if (this.authInterceptor !== null) { + axios.interceptors.request.eject(this.authInterceptor); } - } - async changePassword(newPassword) { - try { - let formData = new FormData(); - formData.append("newPassword", newPassword); - - const response = await axios.put( - `${this.baseURL}/changePassword`, - formData, - { - headers: { - 'Content-Type': 'application/json' - } + this.authInterceptor = axios.interceptors.request.use( + (config) => { + if (token) { + config.headers["Authorization"] = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); } - ); - console.log(response.data); - return response.data; - } catch (error) { - console.error("Error adding account"); - throw error; - } - } - - async EmailExist(email) { - const response = await axios.get(`${this.baseURL}/emailExist`, { - params: { email }, - }); - console.log(response.data); - return response.data; + ); } } -export default new UserService(); \ No newline at end of file + +export default new UserService(); diff --git a/src/store/UserStore.js b/src/store/UserStore.js new file mode 100644 index 0000000000000000000000000000000000000000..efc045b4710c6656ff433e6a8ff94ae3ff2fd2dd --- /dev/null +++ b/src/store/UserStore.js @@ -0,0 +1,318 @@ +import axios from 'axios'; +import LoginRequestDTO from '../models/user/LoginRequestDTO'; +import RegisterRequestDTO from '../models/user/RegisterRequestDTO'; +import UserService from '@/services/internal/UserService'; +import UserDetailsService from '@/services/internal/UserDetailsService'; +import ChangeFirstNameDTO from '../models/user/ChangeFirstNameDTO'; +import ChangeLastNameDTO from '../models/user/ChangeLastNameDTO'; +import ChangeIncomeDTO from '../models/user/ChangeIncomeDTO'; +import ChangeLivingStatusDTO from '../models/user/ChangeLivingStatusDTO'; + +const userStore = { + + /** + * State for user details and authentication status. + * + * @namespace state + * @property {string} email User's email. + * @property {string} firstName User's first name. + * @property {string} lastName User's last name. + * @property {string} income User's income. + * @property {string} savingPercentage the percentage of the income that is to be saved. + * @property {string} livingStatus User's living status. + * @property {string} authToken Authentication token. + * @property {boolean} isAuthenticated Authentication status. + */ + state: () => ({ + email: sessionStorage.getItem("email") || "", + firstName: sessionStorage.getItem("firstName") || "", + lastName: sessionStorage.getItem("lastName") || "", + income: sessionStorage.getItem("income") || "", + savingPercentage: sessionStorage.getItem("savingPercentage") || "", + livingStatus: sessionStorage.getItem("livingStatus") || "", + authToken: sessionStorage.getItem("authToken") || "", + isAuthenticated: sessionStorage.getItem("isAuthenticated") === "true" + }), + + /** + * Mutations for updating state. + * + * @namespace mutations + */ + mutations: { + + /** + * Set authentication token. + * + * @param {Object} state - The Vuex state. + * @param {string} authToken - The authentication token. + */ + setAuthToken(state, authToken) { + state.authToken = authToken; + sessionStorage.setItem("authToken", authToken); + axios.defaults.headers.common['Authorization'] = `Bearer ${authToken}`; + }, + + /** + * Set user's email. + * + * @param {Object} state The Vuex state. + * @param {string} email The user's email. + */ + setEmail(state, email) { + state.email = email; + sessionStorage.setItem("email", email); + }, + + /** + * Set user's first name. + * + * @param {Object} state The Vuex state. + * @param {string} firstName The user's first name. + */ + setFirstName(state, firstName) { + state.firstName = firstName; + sessionStorage.setItem("firstName", firstName); + }, + + /** + * Set user's last name. + * + * @param {Object} state The Vuex state. + * @param {string} lastName The user's last name. + */ + setLastName(state, lastName) { + state.lastName = lastName; + sessionStorage.setItem("lastName", lastName); + }, + + /** + * Set user's income. + * + * @param {Object} state The Vuex state. + * @param {string} income The user's income. + */ + setIncome(state, income) { + state.income = income; + sessionStorage.setItem("income", income); + }, + /** + * Set user's savingPercentage. + * + * @param {Object} state The Vuex state. + * @param {string} savingPercentage The user's desired saving percentage. + */ + setSavingPercentage(state, savingPercentage) { + state.savingPercentage = savingPercentage; + sessionStorage.setItem("savingPercentage", savingPercentage); + }, + + /** + * Set user's living status. + * + * @param {Object} state The Vuex state. + * @param {string} livingStatus The user's living status. + */ + setLivingStatus(state, livingStatus) { + state.livingStatus = livingStatus; + sessionStorage.setItem("livingStatus", livingStatus); + }, + + /** + * Set authentication status. + * + * @param {Object} state The Vuex state. + * @param {boolean} isAuthenticated The authentication status. + */ + setAuthentication(state, isAuthenticated) { + state.isAuthenticated = isAuthenticated; + sessionStorage.setItem("isAuthenticated", isAuthenticated.toString()); + }, + + /** + * Reset state to initial values. + * + * @param {Object} state The Vuex state. + */ + resetState(state) { + state.email = ""; + state.firstName = ""; + state.lastName = ""; + state.income = ""; + state.savingPercentage = ""; + state.livingStatus = ""; + state.authToken = ""; + state.isAuthenticated = false; + sessionStorage.clear(); + } + }, + actions: { + + /** + * Login user. + * + * @param {Object} context The Vuex context. + * @param {Object} payload The login payload. + * @param {string} payload.email The user's email. + * @param {string} payload.password The user's password. + */ + async loginUser({commit, dispatch}, {email, password}) { + const loginRequestDTO = new LoginRequestDTO(email, password); + try { + const response = await UserService.login(loginRequestDTO); + commit('setAuthToken', response.token); + commit('setEmail', email); + await dispatch('fetchUserDetails'); + commit('setAuthentication', true); + } catch (error) { + console.error("Error logging in user:", error); + throw error; + } + }, + + /** + * Register user. + * + * @param {Object} context The Vuex context. + * @param {Object} payload The registration payload. + * @param {string} payload.email The user's email. + * @param {string} payload.firstName The user's first name. + * @param {string} payload.lastName The user's last name. + * @param {string} payload.password The user's password. + * @param {string} payload.emailVerificationCode - The email verification code. + */ + async registerUser({commit}, {email, firstName, lastName, password, emailVerificationCode}) { + const registerRequestDTO = new RegisterRequestDTO(email, firstName, + lastName, password, emailVerificationCode); + + try { + const response = await UserService.register(registerRequestDTO); + + commit('setAuthToken', response.token); + commit('setEmail', email); + commit('setFirstName', firstName); + commit('setLastName', lastName); + commit('setAuthentication', true); + + } catch (error) { + console.error("Error registering user:", error); + throw error; + } + }, + + /** + * Fetch user details. + * + * @param {Object} context The Vuex context. + */ + async fetchUserDetails({commit}) { + try { + const userDetails = await UserDetailsService.retrieveUserDetails(); + commit('setFirstName', userDetails.firstName); + commit('setLastName', userDetails.lastName); + commit('setSavingPercentage', userDetails.savingPercentage); + commit('setIncome', userDetails.income); + commit('setLivingStatus', userDetails.livingStatus); + } catch (error) { + console.error("Error retrieving user details:", error); + throw error; + } + }, + + /** + * Change user's first name. + * + * @param {Object} context The Vuex context. + * @param {string} newFirstName The new first name. + */ + async changeFirstName({commit}, newFirstName) { + const changeFirstNameDTO = new ChangeFirstNameDTO(newFirstName); + try { + await UserDetailsService.changeFirstName(changeFirstNameDTO); + commit('setFirstName', newFirstName); + } catch (error) { + console.error("Error changing first name:", error); + throw error; + } + }, + + /** + * Change user's last name. + * + * @param {Object} commit The Vuex context. + * @param {string} newLastName The new last name. + */ + async changeLastName({commit}, newLastName) { + const changeLastNameDTO = new ChangeLastNameDTO(newLastName); + try { + await UserDetailsService.changeLastName(changeLastNameDTO); + commit('setLastName', newLastName); + } catch (error) { + console.error("Error changing last name:", error); + throw error; + } + }, + + /** + * Change user's income. + * + * @param {Object} context The Vuex context. + * @param {string} newIncome The new income. + */ + async changeIncome({commit}, newIncome) { + const changeIncomeDTO = new ChangeIncomeDTO(newIncome); + console.log(changeIncomeDTO) + try { + await UserDetailsService.changeIncome(changeIncomeDTO); + commit('setIncome', newIncome); + } catch (error) { + console.error("Error changing income:", error); + throw error; + } + }, + + /** + * Change user's saving percentage. + * + * @param {Object} context The Vuex context. + * @param {string} newSavingPercentage The new income. + */ + async changeSavingPercentage({commit}, newSavingPercentage) { + try { + await UserDetailsService.changeSavingPercentage(newSavingPercentage); + commit('setSavingPercentage', newSavingPercentage); + } catch (error) { + console.error("Error changing saving percentage:", error); + throw error; + } + }, + + /** + * Change user's living status. + * + * @param {Object} context The Vuex context. + * @param {string} newLivingStatus The new living status. + */ + async changeLivingStatus({commit}, newLivingStatus) { + const changeLivingStatusDTO = new ChangeLivingStatusDTO(newLivingStatus); + try { + await UserDetailsService.changeLivingStatus(changeLivingStatusDTO); + commit('setLivingStatus', newLivingStatus); + } catch (error) { + console.error("Error changing living status:", error); + throw error; + } + }, + + /** + * Logout user. + * + * @param {Object} context - The Vuex context. + */ + async logout({commit}) { + commit('resetState'); + }, + } +}; + +export default userStore; diff --git a/src/store/index.js b/src/store/index.js index 7f5b89c73b493e8a5fdfbc3f18c7ae96068fffae..bc078d3a52e70c64df68bf8964ce24e15cd9055c 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -1,14 +1,8 @@ -import { createStore } from 'vuex' +import { createStore } from 'vuex'; +import userStore from './UserStore'; export default createStore({ - state: { - }, - getters: { - }, - mutations: { - }, - actions: { - }, modules: { + user: userStore } -}) +}); \ No newline at end of file diff --git a/src/views/BudgetView.vue b/src/views/BudgetView.vue index e3cc20c86aaceb94662de59adf7608ba99015d82..cb4d3bbb13c6ed81449f6fb59d15ecb4c6b15186 100644 --- a/src/views/BudgetView.vue +++ b/src/views/BudgetView.vue @@ -7,3 +7,10 @@ import BudgetMain from "@/components/budget/BudgetMain.vue"; <BudgetMain /> </div> </template> + +<style scoped> +.budget { + box-sizing: border-box; + overflow-y: auto; +} +</style> diff --git a/src/views/ContactView.vue b/src/views/ContactView.vue new file mode 100644 index 0000000000000000000000000000000000000000..efe92a1f05a0825a3ce28c8090752120a74bc28b --- /dev/null +++ b/src/views/ContactView.vue @@ -0,0 +1,78 @@ +<script setup> +import {onMounted, nextTick, ref} from 'vue'; +import Contact from "@/components/contact/ContactComponent.vue"; +import FAQs from "@/components/contact/FAQs.vue"; + +const contactFormRef = ref(null); + +onMounted(() => { + nextTick(() => { + setTimeout(() => { + const contactFormElement = contactFormRef.value; + if (contactFormElement) { + const contactFormHeight = contactFormElement.clientHeight; + const scrollOffset = contactFormHeight * 0.2; + const scrollTargetPosition = contactFormElement.offsetTop + scrollOffset; + + window.scrollTo({ + top: scrollTargetPosition, + behavior: 'smooth' + }); + + setTimeout(() => { + window.scrollTo({ + top: 0, + behavior: 'smooth' + }); + }, 700); + } + }, 1000); + }); +}); +</script> + +<template> + <div class="contact-wrapper"> + <div class="contact-main-container"> + <Contact class="contact-main"/> + </div> + <FAQs class="contact-sidebar"/> + </div> +</template> + +<style scoped> +.contact-wrapper { + overflow-y: scroll; +} + +.contact-main-container { + display: flex; + justify-content: center; + align-items: center; + height: 100vh; + width: 100%; +} + +@media (min-width: 900px) { + .contact-wrapper { + display: grid; + height: 100vh; + width: 100vw; + overflow: scroll; + grid-template-columns: 75% 25%; + } +} + +@media (max-width: 900px) { + .contact-wrapper { + display: flex; + flex-direction: column; + height: 100vh; + width: 100vw; + } + + .contact-sidebar { + overflow: auto; + } +} +</style> diff --git a/src/views/ForgotPasswordView.vue b/src/views/ForgotPasswordView.vue new file mode 100644 index 0000000000000000000000000000000000000000..73aec798ce38104f43be9bd2cba8fcaeacfec500 --- /dev/null +++ b/src/views/ForgotPasswordView.vue @@ -0,0 +1,15 @@ +<script setup> + +import ForgotPassword from "@/components/login/ForgotPassword.vue"; + +</script> + +<template> + <div class="forgot-password"> + <ForgotPassword /> + </div> +</template> + +<style scoped> + +</style> diff --git a/src/views/GoalsView.vue b/src/views/GoalsView.vue index 87185c42f86914de9686606f3ebcbfae2546d5c3..4a3b689c899771262f896cad87597c17f477d84d 100644 --- a/src/views/GoalsView.vue +++ b/src/views/GoalsView.vue @@ -1,22 +1,146 @@ <script setup> -import { ref } from 'vue'; -import GoalsMain from "@/components/goals/GoalsMain.vue"; -import GoalsChoice from "@/components/goals/GoalsChoice.vue"; -import CreateGoals from "@/components/goals/CreateGoals.vue"; -import CreateChallenge from "@/components/goals/CreateChallenge.vue"; - -// State to control which component is shown -const activeComponent = ref('GoalsMain'); +import { ref , onMounted} from 'vue'; +import { useRoute } from 'vue-router'; +import AddChoice from '@/components/goals/AddChoice.vue'; +import ChallengeComponent from '@/components/goals/ChallengeComponent.vue'; +import CreateChallenge from '@/components/goals/CreateChallenge.vue'; +import CreateGoal from '@/components/goals/CreateGoal.vue'; +import CreateChallengeCompetition from '@/components/goals/CreateChallengeCompetition.vue'; +import CreateSharedGoal from '@/components/goals/CreateSharedGoal.vue'; +import GoalComponent from '@/components/goals/GoalComponent.vue'; +import MainButtons from '@/components/goals/MainButtons.vue'; +import OverviewComponent from '@/components/goals/OverviewComponent.vue'; +import SharedChoice from '@/components/goals/SharedChoice.vue'; +import joinSharedGoal from '@/components/goals/JoinSharedGoal.vue'; +import ChallengeCompetitionComponent from '@/components/goals/ChallengeCompetitionComponent.vue'; +import CompetitionChoice from '@/components/goals/CompetitionChoice.vue'; +import JoinChallengeCompetition from '@/components/goals/JoinChallengeCompetition.vue'; +import ChallengeChoice from '@/components/goals/ChallengeChoice.vue'; +import CreateAutomaticChallenges from '@/components/goals/CreateAutomaticChallenges.vue'; + + +const activeComponent = ref('MainButtons'); +const challengeId = ref(null); +const goalId = ref(null); +const friendChallengeId = ref(null); + +const route = useRoute(); +const goalIdFromQuery = route.query.goalId; + +onMounted(async () => { + if(goalIdFromQuery){ + goalId.value = goalIdFromQuery + 1; + activeComponent.value = 'GoalComponent'; + } else { + activeComponent.value = 'MainButtons'; + } +}); + +function receiveChallengeId(id){ + challengeId.value = id; + activeComponent.value = 'ChallengeComponent'; +} + +function receiveGoalId(id){ + goalId.value = id; + activeComponent.value = 'GoalComponent'; +} + +function receiveChallengeCompetitionId(id, friendId){ + challengeId.value = id; + friendChallengeId.value = friendId; + activeComponent.value = 'ChallengeCompetitionComponent'; +} + </script> <template> <div class="goals"> - <GoalsMain v-if="activeComponent === 'GoalsMain'" @switch="activeComponent = 'GoalsChoice'" /> - <GoalsChoice v-if="activeComponent === 'GoalsChoice'" @back="activeComponent = 'GoalsMain'" - @challenge="activeComponent = 'CreateChallenges'" @goal="activeComponent = 'CreateGoals'" /> - <CreateGoals v-if="activeComponent === 'CreateGoals'" @back="activeComponent = 'GoalsChoice'" - @create="activeComponent = 'GoalsMain'"/> - <CreateChallenge v-if="activeComponent === 'CreateChallenges'" @back="activeComponent = 'GoalsChoice'" - @create="activeComponent = 'GoalsMain'"/> + <AddChoice v-if="activeComponent === 'AddChoice'" + @back="activeComponent = 'MainButtons'" + @createChallenge="activeComponent = 'ChallengeChoice'" + @createGoal="activeComponent = 'CreateGoal'" + @competitionChoice="activeComponent = 'CompetitionChoice'" + @sharedChoice="activeComponent = 'SharedChoice'" + /> + <CreateChallenge v-if="activeComponent === 'CreateChallenge'" + @back="activeComponent = 'ChallengeChoice'" + @challengeComponent="receiveChallengeId" + /> + <CreateGoal v-if="activeComponent === 'CreateGoal'" + @back="activeComponent = 'AddChoice'" + @goalComponent="receiveGoalId" + /> + <CreateChallengeCompetition v-if="activeComponent === 'CreateChallengeCompetition'" + @back="activeComponent = 'CompetitionChoice'" + @goalSharedComponent="receiveChallengeCompetitionId" + /> + <CreateSharedGoal v-if="activeComponent === 'CreateSharedGoal'" + @back="activeComponent = 'SharedChoice'" + @goalComponent="receiveGoalId" + /> + <GoalComponent v-if="activeComponent === 'GoalComponent'" + @back="activeComponent = 'OverviewComponent'" + @goalComponent="receiveGoalId" + :goal-id="goalId" + /> + <ChallengeComponent v-if="activeComponent === 'ChallengeComponent'" + @back="activeComponent = 'OverviewComponent'" + :challenge-id="challengeId" + /> + <ChallengeCompetitionComponent v-if="activeComponent === 'ChallengeCompetitionComponent'" + @back="activeComponent = 'OverviewComponent'" + :challenge-id="challengeId" + :friend-challenge-id="friendChallengeId" + /> + <MainButtons v-if="activeComponent === 'MainButtons'" + @overview="activeComponent = 'OverviewComponent'" + @add="activeComponent = 'AddChoice'" + /> + <OverviewComponent v-if="activeComponent === 'OverviewComponent'" + @back="activeComponent = 'MainButtons'" + @add="activeComponent = 'AddChoice'" + @challengeComponent="receiveChallengeId" + @goalComponent="receiveGoalId" + @challengeCompetitionComponent="receiveChallengeCompetitionId" + /> + <joinSharedGoal v-if="activeComponent === 'JoinSharedGoal'" + @back="activeComponent = 'SharedChoice'" + @goalComponent="receiveGoalId" + /> + <SharedChoice v-if="activeComponent === 'SharedChoice'" + @createSharedGoal="activeComponent = 'CreateSharedGoal'" + @joinSharedGoal="activeComponent = 'JoinSharedGoal'" + @back="activeComponent = 'AddChoice'" + /> + <CompetitionChoice v-if="activeComponent === 'CompetitionChoice'" + @join="activeComponent = 'JoinChallengeCompetition'" + @create="activeComponent = 'CreateChallengeCompetition'" + @back="activeComponent = 'AddChoice'" + /> + <JoinChallengeCompetition v-if="activeComponent === 'JoinChallengeCompetition'" + @goalSharedComponent="receiveChallengeCompetitionId" + @back="activeComponent = 'CompetitionChoice'" + /> + <ChallengeChoice v-if="activeComponent === 'ChallengeChoice'" + @back="activeComponent = 'AddChoice'" + @create="activeComponent = 'CreateChallenge'" + @automatic="activeComponent = 'CreateAutomaticChallenges'" + /> + <CreateAutomaticChallenges v-if="activeComponent === 'CreateAutomaticChallenges'" + @back="activeComponent = 'ChallengeChoice'" + @challengeComponent="receiveChallengeId" + /> </div> </template> + +<style scoped> +.goals { + overflow-y: auto; +} + +.goals::-webkit-scrollbar { + background: transparent; +} +</style> + diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index 456ffa1dfdd1bdbfef776f00413ac6430abab5bc..2e5500577b495f7f0be801757cceb7ce1b86d946 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -1,9 +1,18 @@ <script setup> -import HomeMain from "@/components/home/HomeMain.vue"; +import { ref } from 'vue'; +import HomeMain from '@/components/home/HomeMain.vue'; +import HomeWelcome from '@/components/home/HomeWelcome.vue'; + +const isAuthenticated = ref(sessionStorage.getItem('authToken')); + </script> <template> <div class="home"> - <HomeMain /> - </div> + <HomeMain v-if="isAuthenticated" /> + <HomeWelcome v-else /> + </div> </template> + +<style scoped> +</style> diff --git a/src/views/LoginView.vue b/src/views/LoginView.vue index 529e3bb752811093ef95d38685af613e3b7e5c1a..21cf3704d39898a9dcad40df7fcfce5b85c4d28d 100644 --- a/src/views/LoginView.vue +++ b/src/views/LoginView.vue @@ -1,27 +1,39 @@ <script setup> import { onMounted, ref } from 'vue'; -import Login from '@/components/login/LoginComponent.vue'; -import Register from '@/components/login/RegisterComponent.vue'; +import LoginComponent from '@/components/login/LoginComponent.vue'; +import RegisterComponent from '@/components/login/RegisterComponent.vue'; const isLoginActive = ref(sessionStorage.getItem('isLoginActive') === 'true' || true); -function toggleView(isActive) { - console.log('Toggle view called with:', isActive); - isLoginActive.value = isActive; - sessionStorage.setItem('isLoginActive', isActive.toString()); -} - onMounted(() => { const registerSession = sessionStorage.getItem('isLoginActive'); if (registerSession) { isLoginActive.value = registerSession === 'true'; } }); + +/** + * Toggles the active state between login and registration views. + * @param {boolean} isActive - Determines if the login is active or not. + */ +function toggleView(isActive) { + isLoginActive.value = isActive; + sessionStorage.setItem('isLoginActive', isActive.toString()); +} </script> <template> <div class="loginRegisterComponent"> - <Login v-if="isLoginActive" :isLoginActive="isLoginActive" @toggle-view="toggleView" /> - <Register v-else :isLoginActive="isLoginActive" @toggle-view="toggleView" /> + <LoginComponent v-if="isLoginActive" :is="isLoginActive" @toggle-view="toggleView" /> + <RegisterComponent v-else :is-login-active="isLoginActive" @toggle-view="toggleView" /> </div> </template> + +<style scoped> +.loginRegisterComponent { + display: flex; + justify-content: center; + align-items: center; + margin: 10px; +} +</style> \ No newline at end of file diff --git a/src/views/ProfileView.vue b/src/views/ProfileView.vue index e63eda6c61932ec8840c9a06213bcea31596404d..849400794bc211a22aea7b669886d9d26b6f198c 100644 --- a/src/views/ProfileView.vue +++ b/src/views/ProfileView.vue @@ -1,37 +1,63 @@ <script setup> -import { ref } from 'vue'; +import {onMounted, ref} from 'vue'; +import {useRoute} from "vue-router"; import ProfileMain from "@/components/profile/ProfileMain.vue"; import ProfileBar from "@/components/profile/ProfileBar.vue"; +import ProfileDropdown from "@/components/profile/ProfileDropdown.vue"; +const route = useRoute(); const activeComponent = ref('MyProfile'); const setActiveComponent = (componentName) => { activeComponent.value = componentName; }; + +onMounted(() => { + try { + if (route.params.component){ + setActiveComponent(route.params.component); + } + } catch (e) { + console.error(e); + } +}); + </script> <template> - <div class="profile-grid-container"> + <div class="profile-view"> + <ProfileDropdown class="profile-dropdown" @select="setActiveComponent" /> <ProfileMain :component="activeComponent" /> - <ProfileBar @select="setActiveComponent" /> + <ProfileBar class="profile-bar" @select="setActiveComponent" /> </div> </template> + <style scoped> -.profile-grid-container { - display: grid; - grid-template-columns: 75% 25%; - grid-template-areas: 'profile-main profile-bar'; - height: 100%; - width: 100%; - overflow: hidden; +.profile-view { + padding-right: 0; } -.profile-main { - grid-area: profile-main; +@media (min-width: 800px){ + .profile-dropdown { + display: none; + } + + .profile-view { + grid-template-columns: 3fr 1fr; + display: grid; + } } -.profile-bar { - grid-area: profile-bar; +@media screen and (max-width: 800px) { + .profile-dropdown { + width: 100%; + margin-top: 1em; + margin-bottom: 1em; + } + + .profile-bar { + display: none; + } } </style> diff --git a/src/views/TransactionsView.vue b/src/views/TransactionsView.vue index 4658d58dbd4d1430b621d8f8e295d668b0a9f5e9..cb79f06d7cfe16073afa2fce12bfceeaa84268b0 100644 --- a/src/views/TransactionsView.vue +++ b/src/views/TransactionsView.vue @@ -7,3 +7,10 @@ import TransactionsMain from "@/components/transactions/TransactionsMain.vue"; <TransactionsMain /> </div> </template> + +<style scoped> +.transactions { + display: flex; + align-items: center; +} +</style> diff --git a/src/views/UserDetailsView.vue b/src/views/UserDetailsView.vue new file mode 100644 index 0000000000000000000000000000000000000000..e52dc94a6520bdb30fdac06439bfdea29baf3fa4 --- /dev/null +++ b/src/views/UserDetailsView.vue @@ -0,0 +1,21 @@ +<script setup> +import { ref } from 'vue'; +import ImportFiles from "@/components/userDetails/ImportFiles.vue"; +import SelectHousehold from "@/components/userDetails/SelectHousehold.vue"; +import SelectIncome from "@/components/userDetails/SelectIncome.vue"; + +const activeComponent = ref('SelectIncome'); +</script> + +<template> + <SelectIncome + v-if="activeComponent === 'SelectIncome'" + @next="activeComponent = 'SelectHousehold'" /> + <SelectHousehold + v-if="activeComponent === 'SelectHousehold'" + @back="activeComponent = 'SelectIncome'" + @next="activeComponent = 'ImportFiles'" /> + <ImportFiles + v-if="activeComponent === 'ImportFiles'" + @back="activeComponent = 'SelectHousehold'"/> +</template> \ No newline at end of file diff --git a/src/views/VerificationView.vue b/src/views/VerificationView.vue new file mode 100644 index 0000000000000000000000000000000000000000..2fd2efbca98b5c783829045d54f19cfa8c0627e2 --- /dev/null +++ b/src/views/VerificationView.vue @@ -0,0 +1,384 @@ +<script setup> +import {ref, onMounted, onUnmounted} from 'vue'; +import {defineEmits, defineProps} from 'vue'; +import EmailService from "@/services/internal/EmailService"; + +const inputRefs = ref([]); +const numberOfInputs = 6; +const inputValue = ref(Array(numberOfInputs).fill(' ')); +const timerDisplay = ref(''); +const currentDeadline = ref(null) +const isResendBtnDisabled = ref(false); +const resendCountdown = ref(50); +const countdownTimer = ref(null); + +const emits = defineEmits(['verificationSubmit', 'goBack']); + +const props = defineProps({ + email: String, + timerDeadline: Date, + errorMessage: String +}); + +/** + * Handles key press events, handling the different key presses + * + * @param event The key press event + * @param index The index of the input field + */ +const handleKeyPress = (event, index) => { + console.log(event.key) + switch (event.key) { + case 'Backspace': + handleBackspace(index); + break; + case 'ArrowLeft': + handleArrowLeft(index); + break; + case 'ArrowRight': + handleArrowRight(index); + break; + case 'tab': + handleArrowRight(index); + break; + case 'Enter': + handleVerificationSubmit(); + break; + default: + handleCharacterInput(event, index); + break; + } +}; + +/** + * Handles pasting of text into the input fields, pasting it into the first part of the input fields + * + * @param event The paste event + */ +function handlePasteInput(event) { + if (event.clipboardData) { + const text = event.clipboardData.getData('text').trim(); + console.log("Text: " + text) + for (let i = 0; i < Math.min(text.length, numberOfInputs); i++) { + inputValue.value[i] = text[i]; + } + } +} + +/** + * Handles backspace key press, removing the character in the input field and moving focus to the previous input field + * + * @param index The index of the input field + */ +function handleBackspace(index) { + inputValue.value[index] = ' '; + if (index > 0) { + inputRefs.value[index - 1].focus(); + } +} + +/** + * Handles arrow key press, moving focus to the previous field if available + * + * @param index The index of the input field + */ +function handleArrowLeft(index) { + if (index > 0) { + inputRefs.value[index - 1].focus(); + } +} + +/** + * Handles arrow key press, moving focus to the next field if available + * + * @param index The index of the input field + */ +function handleArrowRight(index) { + if (index < numberOfInputs - 1) { + inputRefs.value[index + 1].focus(); + } +} + +/** + * Handles character input, adding the character to the input field and moving focus to the next field if available + * + * @param event + * @param index + */ +function handleCharacterInput(event, index) { + console.log("Character input '" + event.key + "'") + const inputChar = event.key; + if (inputChar && inputChar.length === 1) { + inputValue.value[index] = inputChar; + if (index < numberOfInputs - 1) { + inputRefs.value[index + 1].focus(); + } + } +} + +/** + * Handles the submission of the verification code + */ +const handleVerificationSubmit = async () => { + const verificationCode = inputValue.value.join('').trim(); + emits('verificationSubmit', verificationCode); +}; + +const countdown = () => { + const now = new Date(); + const diff = currentDeadline.value - now; + + if (diff > 0) { + const minutes = Math.floor((diff / 1000 / 60) % 60); + const seconds = Math.floor((diff / 1000) % 60); + timerDisplay.value = `${minutes}:${seconds < 10 ? '0' + seconds : seconds}`; + } else { + clearInterval(countdownTimer.value); + timerDisplay.value = "00:00"; + } +}; + +const resendCode = async () => { + try { + startResendCountdown(); + + const newExpirationDate = await EmailService.sendRegisterToken(props.email) + + + if (countdownTimer.value) { + currentDeadline.value = new Date(newExpirationDate.expirationTimestamp) + countdownTimer.value = setInterval(countdown, 1000); + } + + } catch (error) { + console.error("Failed to resend token: ", error); + isResendBtnDisabled.value = false; + } +}; + +const startResendCountdown = () => { + resendCountdown.value = 50; + isResendBtnDisabled.value = true; + const countdown = setInterval(() => { + resendCountdown.value -= 1; + if (resendCountdown.value <= 0) { + clearInterval(countdown); + isResendBtnDisabled.value = false; + } + }, 1000); +}; + +onMounted(() => { + currentDeadline.value = props.timerDeadline; + countdownTimer.value = setInterval(countdown, 1000); + addEventListener('paste' , handlePasteInput); +}); + +onUnmounted(() => { + removeEventListener('paste', handlePasteInput); + clearInterval(countdownTimer.value); +}); + +const returnToPreviousPage = () => { + emits('goBack'); +}; + + +</script> + + +<template> + <div class="main-container-verification"> + <div class="verification-container"> + <h2 class="verification-header"> + Verifiser e-post adressen din + <img class="icon" src="@/assets/img/mail.png" alt=""> + </h2> + <p class="verification-code-message">En verifikasjonskode har blitt sendt til <strong>{{ props.email }}</strong> + </p> + <p class="verification-timer">Koden utgÃ¥r om {{ timerDisplay }}</p> + + <div class="input-group"> + <label>Tast inn koden her:</label><br> + <input + tabindex="0" + v-for="(value, index) in inputValue" + :key="index" + type="text" + maxlength="1" + id="index" + v-model="inputValue[index]" + @keydown.prevent="event => handleKeyPress(event, index)" + @paste.prevent="event => handlePasteInput(event, index)" + :ref="element => inputRefs[index] = element" + class="input-box"/> + </div> + <button class="verify-btn" @click="handleVerificationSubmit">Verifiser</button> + <p>{{ props.errorMessage }}</p> + <p> + <span @click.prevent="resendCode" + @keydown.prevent="resendCode" + tabindex="0" + role="button" + :class="{ 'disabled-action-link': isResendBtnDisabled, 'action-links': !isResendBtnDisabled }">Resend kode </span> + <span v-if="resendCountdown">{{ `(${resendCountdown})` }}</span> + </p> + <a href="#" class="back-link" @click="returnToPreviousPage"> + <img class="icon" src="@/assets/img/backArrow.png" alt=""> + Tilbake + </a> + </div> + </div> +</template> + +<style scoped> +.main-container-verification { + display: flex; + justify-content: center; + align-items: center; + padding: 20px; +} + +.verification-container { + background-color: var(--accent-color); + padding: 2rem; + border-radius: var(--border-radius-general); + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1); + text-align: center; + width: 400px; + min-height: 400px; + margin: 30px 0 30px 0; +} + +.verification-header { + color: var(--black-text); + margin-bottom: 1rem; +} + +.verification-code-message { + font-size: 1rem; + color: var(--middle-color); + margin-bottom: 1rem; +} + +.verification-timer { + color: var(--black-text); + margin-bottom: 1.5rem; +} + +.input-group { + margin-bottom: 1rem; +} + +input[type="text"] { + padding: 0.5rem; + margin-right: 0.25rem; + text-align: center; + border: 1px solid var(--white-general); + border-radius: var(--border-radius-general); + width: 50px; +} + +.verify-btn { + background-color: var(--middle-color); + color: var(--white-text); + border: none; + padding: 0.75rem 1.5rem; + border-radius: var(--border-radius-general); + cursor: pointer; + width: 100%; + margin-bottom: 1rem; + transition: background-color 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease; + box-shadow: 2px 2px 6px rgba(0, 0, 0, 0.2); +} + +.verify-btn:hover { + background-color: var(--dark-color); + transform: translateY(-3px); + box-shadow: 2px 4px 8px rgba(0, 0, 0, 0.4); +} + +.verification-code strong { + font-weight: bold; +} + +.action-links { + position: relative; + color: var(--middle-color); + cursor: pointer; + text-decoration: none; + overflow: hidden; +} + +.action-links::after { + content: ''; + position: absolute; + left: 0; + bottom: -2px; + height: 1px; + width: 0; + background-color: var(--dark-color); + transition: width 0.3s ease; +} + +.action-links:hover::after { + width: 100%; +} + +.action-links:focus { + color: var(--dark-color); +} + +.disabled-action-link { + color: var(--grey-text); + cursor: not-allowed; + pointer-events: none; +} + +.back-link { + display: flex; + justify-content: center; + align-items: center; + color: var(--black-text); + margin-top: 1rem; + text-decoration: none; +} + +.back-link .icon { + transition: transform 0.3s ease; + height: 60%; + width: auto; +} + +.back-link:hover .icon { + transform: translateX(-5px); +} + +.icon { + height: 60%; + width: auto; +} + +.input-box { + padding: 0.5rem; + margin: 0.12rem; + text-align: center; + border-radius: var(--border-radius-general); + width: 40px; + outline: none; +} + +.input-box:hover, .input-box:focus { + scale: 1.1; +} + +div.verification-container input.input-box { + border: 1px solid var(--accent-color-dark); +} + +@media screen and (max-width: 760px) { + .verification-container { + width: 300px; + } +} +</style> diff --git a/tests/e2e/fixtures/handelsbankenExample.pdf b/tests/e2e/fixtures/handelsbankenExample.pdf new file mode 100644 index 0000000000000000000000000000000000000000..aa8122e2b83419709e79c5beb3d501bec1bca024 --- /dev/null +++ b/tests/e2e/fixtures/handelsbankenExample.pdf @@ -0,0 +1,446 @@ +%PDF-1.3 +%âãÏÓ +1 0 obj +<< /Creator (HP Exstream Version 8.0.319 64-bit) +/CreationDate (3/28/2024 03:19:09) +/Author (Registered to: EDB DRFT) +/Title (MultiC_JavaConnector_Arkiv) +>> +endobj +%PDF Font (F3) +6 0 obj +<< +/Type /Font +/Subtype /Type1 +/Encoding 4 0 R +/BaseFont /Courier-Bold +>> +endobj +%PDF Font (F7) +7 0 obj +<< +/Type /Font +/Subtype /Type1 +/Encoding 4 0 R +/BaseFont /Courier +>> +endobj +%PDF Font (F20) +8 0 obj +<< +/Type /Font +/Subtype /Type1 +/Encoding 4 0 R +/BaseFont /Times-Roman +>> +endobj +%PDF Font (F26) +9 0 obj +<< +/Type /Font +/Subtype /Type1 +/Encoding 4 0 R +/BaseFont /Times-Bold +>> +endobj +%PDF Font (F41) +10 0 obj +<< +/Type /Font +/Subtype /Type1 +/Encoding 4 0 R +/BaseFont /Times-Italic +>> +endobj +%PDF Font (F53) +11 0 obj +<< +/Type /Font +/Subtype /Type1 +/Encoding 4 0 R +/BaseFont /Helvetica +>> +endobj +%PDF Font (F56) +12 0 obj +<< +/Type /Font +/Subtype /Type1 +/Encoding 4 0 R +/BaseFont /Helvetica-Bold +>> +endobj +%PDF Font (F73) +13 0 obj +<< +/Type /Font +/Subtype /Type1 +/Encoding 4 0 R +/BaseFont /Helvetica-Oblique +>> +endobj +%PDF Font (F78) +14 0 obj +<< +/Type /Font +/Subtype /Type1 +/Encoding 4 0 R +/BaseFont /Helvetica-Bold +>> +endobj +%PDF Font (F79) +15 0 obj +<< +/Type /Font +/Subtype /Type1 +/Encoding 4 0 R +/BaseFont /Helvetica +>> +endobj +% PDF Font (F93) +% FullName (Verdana) +% FamilyName (Verdana) +% Font Version (Version 5.33) +% Notice (© 2016 Microsoft Corporation. All Rights Reserved.) +% Trademark (Verdana is a trademark of the Microsoft group of companies.) +16 0 obj +<< +/Type /Font +/Subtype /TrueType +/BaseFont /Verdana +/FirstChar 30 +/LastChar 255 +/Encoding /WinAnsiEncoding +/Widths [ 1000 1000 352 394 459 818 636 1076 727 269 454 454 636 818 364 454 +364 454 636 636 636 636 636 636 636 636 636 636 454 454 818 818 +818 545 1000 684 686 698 771 632 575 775 751 421 455 693 557 843 +748 787 603 787 695 684 616 732 684 989 685 615 685 454 454 454 +818 636 636 601 623 521 623 596 352 623 633 272 344 592 272 973 +633 607 623 623 427 521 394 633 592 818 592 592 525 635 454 635 +818 1000 636 1000 269 636 459 818 636 636 636 1521 684 454 1070 1000 +685 1000 1000 269 269 459 459 545 636 1000 636 977 521 454 981 1000 +525 615 352 394 636 636 636 636 454 636 636 1000 545 645 818 454 +1000 636 542 818 542 542 636 642 636 364 636 542 545 645 1000 1000 +1000 545 684 684 684 684 684 684 984 698 632 632 632 632 421 421 +421 421 775 748 787 787 787 787 787 818 787 732 732 732 732 615 +605 620 601 601 601 601 601 601 955 521 596 596 596 596 272 272 +272 272 612 633 607 607 607 607 607 818 607 633 633 633 633 592 +623 592 ] +/FontDescriptor 17 0 R +>> +endobj +17 0 obj +<< +/Type /FontDescriptor +/Ascent 765 +/CapHeight 0 +/Descent -207 +/Flags 42 +/FontBBox [-560 -303 1523 1051] +/FontName /Verdana +/ItalicAngle 0 +/StemV 0 +>> +endobj +% PDF Font (F102) +% FullName (Verdana_Bold) +% FamilyName (Verdana) +% Font Version (Version 5.33) +% Notice (© 2016 Microsoft Corporation. All Rights Reserved.) +% Trademark (Verdana is a trademark of the Microsoft group of companies.) +18 0 obj +<< +/Type /Font +/Subtype /TrueType +/BaseFont /Verdana-Bold +/FirstChar 30 +/LastChar 255 +/Encoding /WinAnsiEncoding +/Widths [ 1000 1000 342 402 587 867 711 1272 862 332 543 543 711 867 361 480 +361 689 711 711 711 711 711 711 711 711 711 711 402 402 867 867 +867 617 964 776 762 724 830 683 650 811 837 546 555 771 637 948 +847 850 733 850 782 710 682 812 764 1128 764 737 692 543 689 543 +867 711 711 668 699 588 699 664 422 699 712 342 403 671 342 1058 +712 687 699 699 497 593 456 712 650 980 669 651 597 711 543 711 +867 1000 711 1000 332 711 587 1049 711 711 711 1777 710 543 1135 1000 +692 1000 1000 332 332 587 587 711 711 1000 711 964 593 543 1068 1000 +597 737 342 402 711 711 711 711 543 711 711 964 598 850 867 480 +964 711 587 867 598 598 711 721 711 361 711 598 598 850 1182 1182 +1182 617 776 776 776 776 776 776 1094 724 683 683 683 683 546 546 +546 546 830 847 850 850 850 850 850 867 850 812 812 812 812 737 +735 713 668 668 668 668 668 668 1018 588 664 664 664 664 342 342 +342 342 679 712 687 687 687 687 687 867 687 712 712 712 712 651 +699 651 ] +/FontDescriptor 19 0 R +>> +endobj +19 0 obj +<< +/Type /FontDescriptor +/Ascent 765 +/CapHeight 0 +/Descent -207 +/Flags 42 +/FontBBox [-550 -303 1707 1072] +/FontName /Verdana_Bold +/ItalicAngle 0 +/StemV 0 +>> +endobj +%PDF Font (F178) +20 0 obj +<< +/Type /Font +/Subtype /Type1 +/Encoding 4 0 R +/BaseFont /Helvetica-BoldOblique +>> +endobj +%PDF Font (F211) +21 0 obj +<< +/Type /Font +/Subtype /Type1 +/Encoding 4 0 R +/BaseFont /Times-BoldItalic +>> +endobj +%PDF Font (F297) +22 0 obj +<< +/Type /Font +/Subtype /Type1 +/Encoding 23 0 R +/ToUnicode 24 0 R +/BaseFont /Symbol +>> +endobj +23 0 obj +<< +/Type /Encoding +/Differences [ 1/Sigma/epsilon/chi/omicron/nu/delta] +>> +endobj +24 0 obj +<< +/Filter [/ASCII85Decode /FlateDecode] +/Length 321 +>> +stream +8;U<-4`?!-%#/tTKu^5T"HJNq.+4AZ-&-lI[+ul[.+45G+Un;G`ds`2+g:7QZ0^)D +Bs4X>*nZ6(U-fHddaI)`<[SF_S<Scp$rRC]A5U>$>@=/8gD5ikX%*87SZpF:>eJhL +e8=ebDNF#aY]hJB.6YN3/,T;PTNeP3iJcJX$Clae0k&=BDr:C/6htDT4?E`cM2Ua) +\mA/<FdhhC'aN7ZIk@YnNa^_lj(jnWP4J\!'M$9;9*0BtUGPaL$Asc?`s[H]ic59* +!nB#t5f&Yg`ns&8LsC'#,Za)[f6G2HPDgrMpG6#`aToN?=+C;O-@gD+~> +endstream +endobj +% PDF Font (F301) +% FullName (Verdana_Italic) +% FamilyName (Verdana) +% Font Version (Version 5.33) +% Notice (© 2016 Microsoft Corporation. All Rights Reserved.) +% Trademark (Verdana is a trademark of the Microsoft group of companies.) +25 0 obj +<< +/Type /Font +/Subtype /TrueType +/BaseFont /Verdana-Italic +/FirstChar 30 +/LastChar 255 +/Encoding /WinAnsiEncoding +/Widths [ 1000 1000 352 394 459 818 636 1076 727 269 454 454 636 818 364 454 +364 454 636 636 636 636 636 636 636 636 636 636 454 454 818 818 +818 545 1000 683 686 698 766 632 575 775 751 421 455 693 557 843 +748 787 603 787 695 684 616 732 683 990 685 615 685 454 454 454 +818 636 636 601 623 521 623 596 352 622 633 274 344 587 274 973 +633 607 623 623 427 521 394 633 591 818 592 591 525 635 454 635 +818 1000 636 1000 269 636 459 818 636 636 636 1519 684 454 1070 1000 +685 1000 1000 269 269 459 459 545 636 1000 636 977 521 454 980 1000 +525 615 352 394 636 636 636 636 454 636 636 1000 545 645 818 454 +1000 636 542 818 542 542 636 642 636 364 636 542 545 645 1000 1000 +1000 545 683 683 683 683 683 683 989 698 632 632 632 632 421 421 +421 421 766 748 787 787 787 787 787 818 787 732 732 732 732 615 +605 620 601 601 601 601 601 601 955 521 596 596 596 596 274 274 +274 274 612 633 607 607 607 607 607 818 607 633 633 633 633 591 +623 591 ] +/FontDescriptor 26 0 R +>> +endobj +26 0 obj +<< +/Type /FontDescriptor +/Ascent 765 +/CapHeight 0 +/Descent -207 +/Flags 106 +/FontBBox [-453 -303 1585 1051] +/FontName /Verdana_Italic +/ItalicAngle -13 +/StemV 0 +>> +endobj +% PDF Font (F309) +% FullName (Interleaved_2of5_Text) +% FamilyName (I2OF5TXT) +% Font Version (Fontlab V2.5 8/17/98) +% Notice (Copyright 1998 by Chaos Microsystems Inc., All rights reserved.) +27 0 obj +<< +/Type /Font +/Subtype /TrueType +/BaseFont /Interleaved2of5Text +/FirstChar 30 +/LastChar 241 +/Encoding /WinAnsiEncoding +/Widths [ 742 742 720 742 742 742 742 742 742 742 160 240 742 742 742 742 +742 742 720 720 720 720 720 720 720 720 720 720 720 720 720 720 +720 720 720 720 720 720 720 720 720 720 720 720 720 720 720 720 +720 720 720 720 720 720 720 720 720 720 720 720 720 720 720 720 +720 720 720 720 742 742 742 742 742 742 742 742 742 742 742 742 +742 742 742 742 742 742 742 742 742 742 742 742 742 742 742 742 +742 742 742 742 742 742 742 742 742 742 742 742 742 742 742 742 +742 742 742 742 742 742 742 742 742 742 742 742 742 742 742 742 +742 742 742 742 742 742 742 742 742 742 742 742 742 742 742 742 +742 742 742 742 742 742 742 742 742 742 742 742 742 742 742 742 +742 742 720 720 720 720 720 720 720 720 720 720 720 720 720 720 +720 720 720 720 720 720 720 720 720 720 720 720 720 720 720 720 +720 720 720 720 720 720 720 720 720 720 720 720 720 720 720 720 +720 720 720 720 ] +/FontDescriptor 28 0 R +>> +endobj +28 0 obj +<< +/Type /FontDescriptor +/Ascent 0 +/CapHeight 0 +/Descent 0 +/Flags 42 +/FontBBox [0 -250 720 688] +/FontName /Interleaved_2of5_Text +/ItalicAngle 0 +/StemV 0 +>> +endobj +4 0 obj +<</Type/Encoding/BaseEncoding/WinAnsiEncoding/Differences[ +32 /space/exclam/quotedbl/numbersign/dollar/percent/ampersand/quotesingle/parenleft/parenright/asterisk/plus/comma/hyphen/period/slash +/zero/one/two/three/four/five/six/seven/eight/nine/colon/semicolon/less/equal/greater/question/at/A/B/C/D/E/F/G/H/I/J/K/L/M/N/O/P +/Q/R/S/T/U/V/W/X/Y/Z/bracketleft/backslash/bracketright/asciicircum/underscore/grave/a/b/c/d/e/f/g/h/i/j/k/l/m/n/o/p/q/r/s/t/u/v/w +/x/y/z/braceleft/bar/braceright/asciitilde/.notdef/Euro/.notdef/quotesinglbase/florin/quotedblbase/ellipsis/dagger/daggerdbl/circumflex +/perthousand/Scaron/guilsinglleft/OE/.notdef/Zcaron/.notdef/.notdef/quoteleft/quoteright/quotedblleft/quotedblright/bullet/endash +/emdash/tilde/trademark/scaron/guilsinglright/oe/.notdef/zcaron/Ydieresis/space/exclamdown/cent/sterling/currency/yen/brokenbar/section +/dieresis/copyright/ordfeminine/guillemotleft/logicalnot/hyphen/registered/macron/degree/plusminus/twosuperior/threesuperior/acute +/mu/paragraph/periodcentered/cedilla/onesuperior/ordmasculine/guillemotright/onequarter/onehalf/threequarters/questiondown/Agrave +/Aacute/Acircumflex/Atilde/Adieresis/Aring/AE/Ccedilla/Egrave/Eacute/Ecircumflex/Edieresis/Igrave/Iacute/Icircumflex/Idieresis/Eth +/Ntilde/Ograve/Oacute/Ocircumflex/Otilde/Odieresis/multiply/Oslash/Ugrave/Uacute/Ucircumflex/Udieresis/Yacute/Thorn/germandbls/agrave +/aacute/acircumflex/atilde/adieresis/aring/ae/ccedilla/egrave/eacute/ecircumflex/edieresis/igrave/iacute/icircumflex/idieresis/eth +/ntilde/ograve/oacute/ocircumflex/otilde/odieresis/divide/oslash/ugrave/uacute/ucircumflex/udieresis/yacute/thorn/ydieresis] +>> +endobj +31 0 obj +<</Length 2696/Filter/FlateDecode>>stream +xÚÅYl×ß/~4 #ë@h ¯´Œ9ö{ï~Gç&$a±Ö¦|1ÆÎÙ;;II»zZ±ië¢ÿŒÒN4Q‹6èDêÒ@§ÑI›´u%hPP+cŠø%Šs{ç;ÿ8ß%ظҌpÞ÷ü}ï}îó¾¿Þ{�:1 ö/ÝØÚÊ V(sûÊ\ŠrRä|Ý�104ðùÁ·›7@ÁãÀ·”5øò{±€âœŒ¦¡õ¢)€)JÐûEäx¤7)Áî8' @wD!í9 ƒœ;1Ä<‚ *)Áˆ_’DNHiiP¨¬àVzC1½ï90hƒ>Çã®mnmcùÔÔ�a¢K¦N Ä°,DÓr{ê\ÞMžF_ê9â)ãy•èW¤XLªkj[ë[Ûš›;::Š�A¾´aS]02F賆¨èbzÎ.r†N‰i]Qÿ‘µïL‰¼!ÉæitòâhãA»ŽŠ1DÉ<¯Ñ;=X\¡½²ß¬,šÍëk¼>›¦cƒŽÃ<†öoÜgVÎ#+Ù<ìqdØó²}g]Äi1j~ãïQ4£+pŸ26ý«Û¼PyC…ÌHºug€qÐÖÜ°iê‚\\3‹Wû 0 ¹[ÑŠNO£;|:èo@¥©Æ‚¦‚�]Ðì’a3Ó#¯hÖQᎨ-²Öu£¨HŠk@Ã3qE푘$wE”�ðÈò6IQ$¬O>%"û·IÁÃÁè´ýA©Ô³ì#žs:Ç4Må«r(ÕklÀk„�Í:‰ÖÆ`4[!Æ»@K¤+Kñ8hîUbÕ€(’"mHè‚%BÄB.ƒ¸3ð2¾B3Øuÿ¡Yœ¯W>$ðœ-…Ô(ôF Pë-–7ÚðV.ˆrycJàµòÆaËåóƃg„\ÞØ4o3qµzþû‚ì= ä,É0³¹¢".—ANCØ.õäè‹þXP€zqÛòVx¬ñÝ,<¾xÇäÈg Lñ†%g]¤Çå&ž;#{ÈY¶1¿¢Ð±|®Û¢Ÿ <^°>„Š‚Gçz-Êx-+^vïˆIÅòFV–Ú¯êýEdˆv¹8ˆ››ÓÎø…7Žµšº~‡"5b8&ÅfXWlŽ)CçyEF¯ p4§/¬/®˜æ¡“fœ˜!¶ÜR\Ç«uÄN£ÈDY�Q,²F‘"QŒ¥‘ +F[Qßö^IÞ+ÀÙpEú— +([h÷ik¸0[+"£g±FE¬Ö¢m\’c`VôÊRH$ "µÅÛ÷‹²4¶i¹˜Ò)“áÒå¡à¤!vp´±3‚ùšÁÂ,“ët ѱYYÞÁ[V–) +¦²´5‹JX-¢Ôœ2–ú‚!ҵĚñ ~ŸH}6Ï$V”EWTn ™5—=¶öx—…ÚÂ^qø(Sîb>W|Xã/ dL8•#ܽJ€ÍZ1ÒÒ+kÿCR@ó@¶hÔ/†GÛÅ»âÀ!Ó⦪¦µ‘i·h[BGä."´J¤ÀË>*„ÂÌ#¶IÿÅ€XÁb€©¶^ jnÄs›tQèHãKC‡MµI©%€PXX.!OçnuÑR©ê©) „%°bÙ„÷vI±_ŠÄfi¥‘JÉ ‚ÅKî§L¬e + Ðtþ®1£W6&wk„"Œbx‡P<ÄdÓ‡ÇO’ÇŽ@X$Ù†`XR⹉CûÄ¡%qd÷´ÙÄ‘Q+k¤`µEЋJ$°ýŸà0ÆÎ߉êŠÖ”¤Tª1(Ë!…$]™†™W8'õ×l½RRš¹WQ|ODÚ¹¶AœSÈ©=t_ˆ žÂjûÈôR ¨D�'‹ƒŽ›CŽbÁš¨ŠB@›v÷¸„ *¶çæ\¾âb*_ŠË01U<› +QÖÓ\L…™º¶I»b´5&Å¥7!›°ž¤$I1 vãJŸ±Ï¿¬ƒ1Ü’Gùz +¨ààÅMƒ‹³D–¶‰3'Y± ‹oÓך®2‡èy׊Yì6ߺL{Än¾ÿÀ”ù°ß|i“¹v›ïŠd³˜,¦‹ŒýÈ’ùF"O9o^¿ÝD…Ì”"mO‡˜ÍéÚXЯOé!(¤·ÃÀRtZó8£¡µm40ŸÕ mC# C™.4y‚uR»ïUªµ=—_ +ǺDRšËŽ¬Ç:¦yeF‹RN–e9݈eô»ˆz1ž’½¼œ~<Bˆõ¦Ù’LJr}Ë€hXßæõw[³p¤µóäö¶Öú5 žÑ)’´ÁHAHBˆ,|ªmO’å•Àê´¢Lq4ü\ Ð3}¡�A(ŒG§kì5dF²öIÝŠnxTGƒy;²i �À°À§ËADç±Ãkj\iáŠXchmJÀ©ÒÚ!ƒ-kAcD!,#ËŒ‘X+ÅÓxëðIa©;"W“¥ã)*]pk³ð}�€6f6äÃaò…ȌĈéoí!¢Û\›RJ®5š‚˽¾ àªóWp5’Vùª_V®t5ƒ*í§H¸·GŽéÚ#ý1ÒoÕ*à©îüú_þç{5Ã}¼÷Iïº÷::§†T»Ï'@9Щ‚“âìŠ +ìø.\=5—>1»f|òÕ…§jž{¥óß++`íG»Æ+>üjÖïü†ÓÓ|á úzåÜÊ]_©ygç’Spi|β“³:Üѱ֪ys^½¸¨v¸bàé W¬Ù¾àý#•û_›#>ñÛO÷ïYøù‹ûÞ¸t¦ÿÀ+Ozß}û±k7ÞzþýÏ;·odÀ“¬8ñÍüô“«n\ÿåX[ë´¼{÷Åùƒ©fϹƒw.Ä++~°|÷Ã_Z>ëcºíõDÿˆçÒ÷_ºtýkàÝÉÅÂ'‚ºyìðg䡵¢CÛè¨øÀ™Ë?Ù}{³úü¢Ó¡U¿þàÏýìË õZxàÓ¡ÉA<öìS5Çvú]]Ùc€Þ´¹rüôÂKËÏ+~;1¸mÎ_~zláÔÄ]WòJå…ù·ÕêÝ%/_x=9qY½z»ª_=¤N&Ô õìÇw€¨NM‘¿üfHwGUÇ“‹ß¥=N$‰]§#jÒu~òéòYɉdâàG·&&<98Þt§S]úÎØÄõªÚû§¿H.Kvª‡ÎoÛ¹´õ᫉ˉϦN_Û”LÈݪŽÞü`ôÚ¨z¾¾zõ‡®“³Ž¿²ìPrD]òÖañÙxtìèUýóïSÍKï¼™<sñ˜*—/SÕ->õJâÚNߘúWbìø–-êØgÏ_íÙ0u¶óÂ`d‰ÿÁŸ‡¶Ý +%‡ïÒWª^�/ýªü5õìSU³Þ[¾|žø¦HŸ¿:zÑ]^_7wŽÓ‹|'Ê—«â3ûvvk|¤|Aíîòoí{3:±gÏÎn=R¾ ¥¾~wÙ£_¨ø"hð€ï€ÿipòÔ +endstream +endobj +32 0 obj +<</Type/Page/Parent 33 0 R/Contents 31 0 R/Resources 5 0 R>> +endobj +35 0 obj +<</Length 2203/Filter/FlateDecode>>stream +xÚYKoÜ8¾çWÔe€™EG–¨WË·5ò°a$YÄöžr¡Gl™[jìÎîþú-Š’ÚdÓšvv�ÅbñãWOÊG$ƒØü›þÜ»|úï®îß]|"1¤i”âÜo ÉÈ3¸¯á÷Û‡8Žá¸†wïOWeITE9JII†u¨î/‡eIl65ïÒ$ŠÓˆÄ$&Ilj;^³NF@ì‚u9¾«¸ªª7lNìæר·pÏ6’Ù’õ¨r_Y-|ù‘–±¬{d²†jyWƒt“YºŒ$âû·¯®?Þ|yå3dðÓn›T¶@RRÙgw'ü–Qn¸4`3i\–á¾Óý^«VòCC +›^BkÞCçI”ÃÚz vLòÙ‚x&ÞÑY¸’ûVÙµ]ó:w'pRçÓHØÈ@oMRCi‡“áZ;œì%†á$Kíd^ʇád–ÎÝÆâN’rrïU>™»ï¸zR¦G¿×®0ug½µãñ‹‰Ž‹Ã݉Äá\a,Y0Ž™½›q^l‡dîÜÿH³Ü +”ÿ7dÅ4{åÊSÕºH6vHf`et7âlpç“è"&³äÛ°$“Ì�<×îpãzì«ð\ß™³š·Ü¦®<áÞÅ)qr×$smìl^[»ÑÏÎΤÊlÁø[–ä8,0!Á×¾EêãȈõ’¸f ƒ¿¥i ·{©.þŒ‡Ï¦È!EœZ¿Š' +PÏj\OøH9Ê1¶åáŸT29BüζÔ+ß{…O=>}¦p/û®~b|{qÚ:©%Uºª,Ĭ ¿q]Ä/!–âÇiI_ ºéº'&%«Õ¡{>²^•ÿ#>tIŸÂ+*ÙŸ{ö$-û{¾¤¬&ÿ*¾2µ&¾çân©h\*9ƒkª;`SpM;$òu4ÇWù&KÏ�sšƒŠ1$îÜ ¢n|ŠÅìÝÂÝÙnl/§3oV¹™ÚK@ȃ_B©ð¯h!q¶>ò’¬£ªÌVEq¾}‹8Y芳*[ìK.\áÝbAêƒ,¹y¹rïÔÑ<|îh‚†%ŠÇPÂí|Þlì¤^Qå¹r»÷¸^©oC¾HŠð Â]Ã[˲Û5$ø69Ì…„³L¶Ê’óÝ0ÏËP·³L 5Ë‹=+)ÃíÀÖí<ª=aÏï¼<ƒ»6ô¼° :¥ëgÄíïO›N?;¥yzb–¹¾Ï¯ÎÊ·Ùxý»éðþ´¥ê¹ï ß‚dfòb'û7ïð¹ß¶½Òï¬bóeËž}neÝ«×Î\̎׬óQ™Šòâb•-^¬ò°IEГÖáµ<dá9J«tòü¸°–u)ï:tç&¢ð]* wž¿¥,›Ò?»úrñêÜ:è©£'.ÜKIñ+e² ^¦#—˜iV¥{ü_o½In›²[칸¶î>¦À2Ÿƒ—Ào#Ç^8þ¼¡~Ow«¤Š²8Y‘âô³ÈðE7ߢg¡I‡çñ³ÈK <í,Ï'¤(דÄðü‰d–Hó£DÀAò¬œ%Ìó©Dºž‘ω´8Jàsø#P–Gëù#J&èXC?=6Ÿn›—9>8§Ög×Ûçèqêõ©ÓÒÌÑà Gö +µ•ó°u5«P!š³ƒÕœÃûÎ"Íݲ[¸ˆÛãÍCïŒNåbëjf.]ní¥®æ.ô鄤Kt͹6<›ü!3Náö54è$¯ÐuJ¯}»š—/;É2Û:œÃÂùW³G¾p3èÞEåíôŸä¼ã{Þ¥‚ßn²°;yÂ^´öAw*ÎòomãôÑUåÐãª]Œµƒ›ŽÚPë2ÇuÐúóF^*[6™]B_3J½HÕüž$Eïó<´ww.»^J¦‹±¸w³BØÓ¥ð4’…Aª`Kñ–P{-L£óZZ¬ZIYUc£Ý©v_ת¡’všŸ×²Û–ܨ஘Ôø¨¸ùJS3¡iײ¾>|lÔkÖ¶LÖÕFTs½Í–ÁMO~™>}Pôoì‚¢óÄ~”ø‚ÚÛ ª-ë˜:7’âfü@õŽIeö^™«Ó´µãõ¢o€×Œ Á — í¸¢öÁ·Í›5Áf¤6¼£âƒ$ï”æzoalçБ3KÛOµ0 +f§>¶ŠmwÂp`²ý‚>…·"üÐb^¤ÛÃ(ÞJ|«æsíXgtÌ/<\&k¾£;.ç/_u5ô‘ˆà¦«ù×ûÚ¤²—õ‘±¶Ç©Vóýlx×ìTÿÞ²xÍÛàI˜~ƒÁ³Üb¾bÂtP;Õ Éz›e/àZ-íÀˆ¶Æ &DGÞš«Ç»äì!†fôCQ£¼B™B¦ü žØàߣ-¨<¬P’µåJ1ŸÕ†ØëG¤UçÇ%T4@ðØ£bKíÕRͳ*qk4o-isÄf" +È\§šg&j0ç±!·B‡Ú0¤Ø³m¨ÐVŠÙìñû€-æA÷‰J¼~k»F¡±`Û㥱®†“@ÅA±#ªpZ˜àx˜:8(÷rÍä=.‡½ÆàjÔnßµz áyˆ]ƒ½ƒ}Ë<„ãîzVŒkÜGÁïãÑQ?BØ^èk8¨ùÈ“áE™ÚwÆüýÐ@k<WZÁþ·Äë…ýv(bêl;˜Üø\ÍÀØF¡U;ä3ìÐ]Z¶D˜ $zLk£Îª�nòïuU~™äeòã,7£÷ÎôðùdRîímSϘDÊ1xvH,Ò‡7“ÁSò6>›ÁY1Êñ.mþ^ÛËÞf|e�:çœN²Â3àšlÛ–#Ö#¢î<i½S—?þŒ,¯L3\WG]q´ÔÊ<N +endstream +endobj +36 0 obj +<</Type/Page/Parent 33 0 R/Contents 35 0 R/Resources 5 0 R>> +endobj +33 0 obj +<</Type/Pages/Count 2/Kids[32 0 R 36 0 R]/MediaBox[0 0 595 842]/Resources 5 0 R>> +endobj +5 0 obj +<</ProcSet[/PDF/ImageB/ImageC/Text]/Font<</F3 6 0 R +/F7 7 0 R /F20 8 0 R /F26 9 0 R /F41 10 0 R /F53 11 0 R /F56 12 0 R /F73 13 0 R /F78 14 0 R /F79 15 0 R /F93 16 0 R +/F102 18 0 R /F178 20 0 R /F211 21 0 R /F297 22 0 R /F301 25 0 R /F309 27 0 R >> +>> +endobj +29 0 obj +<</Title (74586: )/Dest[32 0 R/XYZ 999 999 0]/Parent 3 0 R/First 30 0 R/Last 30 0 R/Count -1>> + +endobj +30 0 obj +<</Title (KU000_Kontoutskrift_AlleTyper)/Dest[32 0 R/XYZ 999 999 0]/Parent 29 0 R/First 34 0 R/Last 37 0 R/Count -2>> + +endobj +34 0 obj +<</Title (1: KU000_Kontoutskrift_AlleTyper)/Dest[32 0 R/XYZ 999 999 0]/Parent 30 0 R/Next 37 0 R>> + +endobj +37 0 obj +<</Title (2: MultiC_Default_Flow_page)/Dest[36 0 R/XYZ 999 999 0]/Parent 30 0 R/Prev 34 0 R>> + +endobj +3 0 obj +<< /Count 1 /First 29 0 R /Last 29 0 R >> +endobj +2 0 obj +<</Type/Catalog/Pages 33 0 R/Outlines 3 0 R/PageMode/UseOutlines>> +endobj +xref +0 38 +0000000000 65535 f +0000000015 00000 n +0000015975 00000 n +0000015918 00000 n +0000008229 00000 n +0000015175 00000 n +0000000202 00000 n +0000000320 00000 n +0000000433 00000 n +0000000550 00000 n +0000000666 00000 n +0000000785 00000 n +0000000900 00000 n +0000001020 00000 n +0000001144 00000 n +0000001264 00000 n +0000001590 00000 n +0000002697 00000 n +0000003103 00000 n +0000004218 00000 n +0000004413 00000 n +0000004542 00000 n +0000004666 00000 n +0000004778 00000 n +0000004872 00000 n +0000005521 00000 n +0000006635 00000 n +0000007008 00000 n +0000008052 00000 n +0000015444 00000 n +0000015556 00000 n +0000009886 00000 n +0000012651 00000 n +0000015077 00000 n +0000015691 00000 n +0000012728 00000 n +0000015000 00000 n +0000015807 00000 n +trailer<</Size 38/Info 1 0 R/Root 2 0 R +>> +startxref +16057 +%%EOF diff --git a/tests/e2e/fixtures/pigrich.png b/tests/e2e/fixtures/pigrich.png new file mode 100644 index 0000000000000000000000000000000000000000..741e81e9e1a202b329190eb1f5c6fe47e5d63150 Binary files /dev/null and b/tests/e2e/fixtures/pigrich.png differ diff --git a/tests/e2e/specs/budget/budgetMain.cy.js b/tests/e2e/specs/budget/budgetMain.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..fd129602bd8bbfd3d7bbe8e19a5000e8c6415c5d --- /dev/null +++ b/tests/e2e/specs/budget/budgetMain.cy.js @@ -0,0 +1,42 @@ +describe('Budget Component', () => { + beforeEach(() => { + cy.login('admin@example.com', 'password'); + cy.visit('http://localhost:8082/budget'); + }); + + it('displays budget information correctly', () => { + // Assert that the budget main container is visible + cy.get('[data-cy=budget-component]').should('be.visible') + + // Assert that selectors are present + cy.get('[data-cy=account-dropdown]').should('be.visible') + cy.get('[data-cy=date-picker]').should('be.visible') + + // Assert that budget information is displayed correctly + cy.get('[data-cy=income-section]').should('be.visible') + cy.get('[data-cy=expenses-section]').should('be.visible') + + // Assert that the monthly spending chart is visible + cy.get('[data-cy=monthly-spending-chart]').should('be.visible') + }) + + it('should be present and can be interacted with', () => { + // Select a specific month and year + cy.get('[data-cy=date-picker]') + .type('2022-05-01') + .should('have.value', '2022-05-01'); + }); + + it('should display graph and edit button when a budget exists', () => { + // Select a specific month and year + cy.get('[data-cy=date-picker]') + .type('2024-03-01') + .should('have.value', '2024-03-01'); + + // Assert that the spent chart is visible + cy.get('[data-cy=spent-chart]').should('be.visible') + + // Assert that the edit button is visible + cy.get('[data-cy=edit-button]').should('be.visible') + }); +}); diff --git a/tests/e2e/specs/contact/contact.cy.js b/tests/e2e/specs/contact/contact.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..19015e0a326dc2c96cf4ed476ab2cc834e525920 --- /dev/null +++ b/tests/e2e/specs/contact/contact.cy.js @@ -0,0 +1,63 @@ +describe('Contact Component', () => { + beforeEach(() => { + cy.visit('http://localhost:8082/contact'); + }); + + it('displays the form correctly', () => { + // Ensure the form title is displayed + cy.contains('h1', 'Kontaktskjema').should('exist'); + + // Ensure the form elements are displayed + cy.get('form').within(() => { + cy.get('label[for="selectedOptionSubject"]').should('exist'); + cy.get('select#selectedOptionSubject').should('exist'); + + cy.get('label[for="name"]').should('exist'); + cy.get('input#name').should('exist').and('have.attr', 'placeholder', 'Fornavn'); + + cy.get('label[for="email"]').should('exist'); + cy.get('input#email').should('exist').and('have.attr', 'placeholder', 'E-postadresse'); + + cy.get('label[for="feedback"]').should('exist'); + cy.get('textarea#feedback').should('exist').and('have.attr', 'placeholder', 'Melding'); + + cy.get('button').should('exist').and('have.text', 'Send inn!'); + }); + }); + + it('displays error messages for empty data', () => { + // Submit the form without filling in any data + cy.get('select#selectedOptionSubject').select('Feil og problemer'); + cy.get('[data-cy=submit-button]').click({ force: true }); + + cy.contains('.confirmation-or-error-message', 'Navn er pÃ¥krevd.').should('exist'); + }); + + it('displays error message for invalid email', () => { + // Fill in form fields with invalid email + cy.get('select#selectedOptionSubject').select('Feil og problemer'); + cy.get('input#name').type('John'); + cy.get('input#email').type('invalid_email'); + cy.get('textarea#feedback').type('This is a test feedback.'); + + // Submit the form + cy.get('[data-cy=submit-button]').click({ force: true }); + + // Ensure an error message is displayed + cy.contains('.confirmation-or-error-message', 'E-postadressen mÃ¥ inneholde @.').should('exist'); + }); + + it('submits the form correctly with valid data', () => { + // Fill in form fields with valid data + cy.get('select#selectedOptionSubject').select('Feil og problemer'); + cy.get('input#name').type('John'); + cy.get('input#email').type('john.doe@example.com'); + cy.get('textarea#feedback').type('This is a test feedback.'); + + // Submit the form + cy.get('[data-cy=submit-button]').click({ force: true }); + + // Ensure a success message is displayed + cy.contains('.confirmation-or-error-message', 'Meldingen ble sendt!').should('exist'); + }); +}); diff --git a/tests/e2e/specs/goals/goal.cy.js b/tests/e2e/specs/goals/goal.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..804cc0bbe7eec65af4627bd1132814d2af8a40b4 --- /dev/null +++ b/tests/e2e/specs/goals/goal.cy.js @@ -0,0 +1,91 @@ +describe('Goal and Challenge components', () => { + beforeEach(() => { + cy.login('admin@example.com', 'password'); + cy.visit('http://localhost:8082/goals'); + }) + + it('Checks if games work', () => { + + cy.get('[data-cy=add-goal-button]').should('exist'); + cy.get('[data-cy=overview-goals-button]').should('exist'); + + cy.get('[data-cy=add-goal-button]').click(); + + cy.get('[data-cy=start-challenge-button]').should('exist'); + cy.get('[data-cy=start-goal-button]').should('exist'); + + cy.get('[data-cy=start-goal-button]').click(); + + cy.get('[data-cy=goal-title-input]').should('exist'); + cy.get('[data-cy=goal-amount-input]').should('exist'); + cy.get('[data-cy=start-date-input]').should('exist'); + cy.get('[data-cy=end-date-input]').should('exist'); + + cy.get('[data-cy=goal-title-input]').type('My Savings Goal'); + cy.get('[data-cy=goal-amount-input]').type('1000'); + cy.get('[data-cy=start-date-input]').type('2024-05-05'); + cy.get('[data-cy=end-date-input]').type('2025-05-05'); + + cy.get('[data-cy=create-goal-button]').click(); + + cy.get('[data-cy=input-contribution]').should('exist'); + cy.get('[data-cy=add-button-contribution]').should('exist'); + + cy.get('[data-cy=input-contribution]').type('1000'); + + cy.get('[data-cy=add-button-contribution]').click(); + + cy.get('.modal-overlay').should('exist'); + + cy.get('.modalFinished p').should('contain', 'Gratulerer, du klarte mÃ¥let ditt!'); + + cy.get('.modal-overlay button').should('exist'); + + cy.get('.modalFinished button').click(); + + cy.get('.button-container .button-box button').should('have.length', 2).each(($button) => { + expect($button.text().trim()).to.not.be.empty; + }); + + cy.contains('.button-container .button-box button', 'Legg til sparemÃ¥l og utfordringer').click(); + + cy.get('[data-cy=start-challenge-button]').should('exist'); + + cy.get('[data-cy=start-challenge-button]').click(); + + cy.get('.button-box button').should('have.length', 3); + + cy.get('.button-box button').eq(1).click(); + + cy.get('.container button').should('have.length', 2); + + cy.contains('.container button', 'GÃ¥ tilbake').click(); + + cy.get('.button-box button').should('have.length', 3); + + cy.get('.button-box button').eq(0).click(); + + cy.get('#title').should('exist'); + cy.get('.container .input-field-container input').should('have.length', 2); + cy.get('.date-container input').should('have.length', 2); + cy.get('#create-goal-button').should('exist'); + + cy.get('.container .input-field-container input').eq(0).type('My Challenge Title'); + cy.get('.container .input-field-container input').eq(1).type('My Challenge Description'); + cy.get('.date-container input').eq(0).type('2024-05-05'); + cy.get('.date-container input').eq(1).type('2025-05-05'); + + cy.get('#create-goal-button').click(); + + cy.contains('button', 'GÃ¥ tilbake').should('exist'); + cy.contains('button', 'Jeg har feilet pÃ¥ utfordringen').should('exist'); + + cy.get('.main-title').should('not.be.empty'); + + cy.get('.description').should('not.be.empty'); + + cy.get('.start-date').should('not.be.empty'); + + cy.get('.end-date').should('not.be.empty'); + }); +}) \ No newline at end of file diff --git a/tests/e2e/specs/home/homeMain.cy.js b/tests/e2e/specs/home/homeMain.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..fdf0ab4bac5cac8e5d186385a1b3f81da26f900f --- /dev/null +++ b/tests/e2e/specs/home/homeMain.cy.js @@ -0,0 +1,24 @@ +describe('Home Main Component', () => { + beforeEach(() => { + cy.login('admin@example.com', 'password'); + }); + + it('displays the heading correctly', () => { + cy.contains('h1', 'Bli rik med Sparesti!').should('exist'); + }); + + it('displays the image correctly', () => { + cy.get('.home-container img').should('exist'); + }); + + it('displays the button correctly and navigates to the Goals page when clicked', () => { + // Check if the button exists + cy.contains('button', 'Lag nytt sparemÃ¥l').should('exist'); + + // Click on the button + cy.contains('button', 'Lag nytt sparemÃ¥l').click({ force: true }); + + // Ensure the correct route is navigated to + cy.url().should('include', '/goals'); + }); +}); diff --git a/tests/e2e/specs/login/login.cy.js b/tests/e2e/specs/login/login.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..65337cd99500d580512d48400a253e4ec91127c1 --- /dev/null +++ b/tests/e2e/specs/login/login.cy.js @@ -0,0 +1,45 @@ +describe('Login Component', () => { + beforeEach(() => { + cy.visit('http://localhost:8082/login'); + }) + + it('displays the login form correctly', () => { + cy.get('form').should('exist') + cy.get('input[type="email"]').should('exist') + cy.get('input[type="password"]').should('exist') + cy.get('#login-button').should('contain.text', 'Logg inn') + cy.get('.forgot-password-link').should('contain.text', 'Glemt passord?') + cy.get('.status-message').should('not.exist') + }) + + it('displays error message for invalid login attempt', () => { + cy.get('input[type="email"]').type('invalid@example.com') + cy.get('input[type="password"]').type('invalidpassword') + cy.get('#login-button').click() + cy.contains('.status-message', 'E-post eller passord var feil.').should('exist') + }) + + it('disables login button when any field is empty', () => { + // Check if login button is disabled when both fields are empty + cy.get('#login-button').should('be.disabled') + + // Fill in valid email but leave password field empty + cy.get('input[type="email"]').type('valid@example.com') + cy.get('#login-button').should('be.disabled') + + // Fill in password but leave email field empty + cy.get('input[type="email"]').clear() + cy.get('input[type="password"]').type('validpassword') + cy.get('#login-button').should('be.disabled') + }) + + it('successfully logs in with valid credentials', () => { + const validEmail = 'admin@example.com'; + const validPassword = 'password'; + cy.get('input[type="email"]').type(validEmail) + cy.get('input[type="password"]').type(validPassword) + cy.get('#login-button').click() + + cy.get('.home-container').should('exist'); + }) +}) diff --git a/tests/e2e/specs/login/register.cy.js b/tests/e2e/specs/login/register.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..1da4d2ce44a608d9cc30fe2b07b8fc9b03e9d791 --- /dev/null +++ b/tests/e2e/specs/login/register.cy.js @@ -0,0 +1,85 @@ +describe('Register Component', () => { + beforeEach(() => { + cy.visit('http://localhost:8082/login'); + cy.get('.navigation') + .contains('h5', 'Registrer bruker') + .click(); + }); + + it('displays the registration form correctly', () => { + cy.get('form').should('exist'); + cy.get('input[type="email"]').should('exist'); + cy.get('input[type="text"]').should('have.length', 2); + cy.get('input[type="password"]').should('exist'); + cy.get('[data-cy=submit-button]').should('contain.text', 'Fortsett'); + }); + + it('disables submit button when any field is empty', () => { + // Check if submit button is disabled when all fields are empty + cy.get('[data-cy=submit-button]').should('be.disabled'); + + // Fill in valid email but leave other fields empty + cy.get('input[type="email"]').type('valid@example.com'); + cy.get('[data-cy=submit-button]').should('be.disabled'); + + // Fill in all fields except password + cy.get('input[type="text"]').eq(0).type('John'); + cy.get('input[type="text"]').eq(1).type('Doe'); + cy.get('[data-cy=submit-button]').should('be.disabled'); + }); + + it('displays error message for invalid email', () => { + // Test with invalid email + cy.get('input[type="text"]').eq(0).type('John'); + cy.get('input[type="text"]').eq(1).type('Doe'); + cy.get('input[type="email"]').type('invalid_email'); + cy.get('input[type="password"]').type('Password123!'); + cy.get('[data-cy=submit-button]').click(); + + cy.contains('.status-message', 'E-postadressen mÃ¥ inneholde @.').should('exist'); + }); + + it('displays error message for invalid first name', () => { + // Test with invalid first name + cy.get('input[type="text"]').eq(0).type('John36'); + cy.get('input[type="text"]').eq(1).type('Doe'); + cy.get('input[type="email"]').type('mail@example.com'); + cy.get('input[type="password"]').type('Password123!'); + cy.get('[data-cy=submit-button]').click(); + + cy.contains('.status-message', 'Navnet kan bare inneholde bokstaver.').should('exist'); + }); + + it('displays error message for invalid last name', () => { + // Test with invalid last name + cy.get('input[type="text"]').eq(0).type('John'); + cy.get('input[type="text"]').eq(1).type('Doe36'); + cy.get('input[type="email"]').type('mail@example.com'); + cy.get('input[type="password"]').type('Password123!'); + cy.get('[data-cy=submit-button]').click(); + + cy.contains('.status-message', 'Navnet kan bare inneholde bokstaver.').should('exist'); + }); + + it('displays error message for invalid password', () => { + // Test with invalid password + cy.get('input[type="text"]').eq(0).type('John'); + cy.get('input[type="text"]').eq(1).type('Doe'); + cy.get('input[type="email"]').type('mail@example.com'); + cy.get('input[type="password"]').type('password'); + cy.get('[data-cy=submit-button]').click(); + + cy.contains('.status-message', 'Passordet mÃ¥ inkludere stor bokstav, inkludere et tall, inkludere spesialtegn.').should('exist'); + }); + + it('displays error message for email already in use', () => { + // Test with email already in use + cy.get('input[type="email"]').type('admin@example.com'); + cy.get('input[type="text"]').eq(0).type('John'); + cy.get('input[type="text"]').eq(1).type('Doe'); + cy.get('input[type="password"]').type('Password123'); + cy.get('[data-cy=submit-button]').click(); + + cy.contains('.status-message', 'E-postadresse er allerede i bruk.').should('exist'); + }); +}) diff --git a/tests/e2e/specs/navigationBar.cy.js b/tests/e2e/specs/navigationBar.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..555f9d92b58ac1b5c07a7b5b6dfd5ee8b210e438 --- /dev/null +++ b/tests/e2e/specs/navigationBar.cy.js @@ -0,0 +1,51 @@ +describe('Navigation Bar', () => { + beforeEach(() => { + cy.login('admin@example.com', 'password'); + cy.visit('http://localhost:8082/'); + }) + + it('should load correctly', () => { + cy.get('[data-cy=navigation-component]').should('exist'); + }); + + it('should toggle navigationbar', () => { + cy.get('[data-cy=navigation-button]').click(); + cy.get('[data-cy=navigation-menu]').should('be.visible'); + }); + + it('should navigate to /transactions when "Transaksjoner" is clicked', () => { + cy.get('[data-cy=navigation-button]').click(); + cy.contains('Transaksjoner').click(); + cy.url().should('include', '/transactions'); + }); + + it('should navigate to /budget when "Budsjett" is clicked', () => { + cy.get('[data-cy=navigation-button]').click(); + cy.contains('Budsjett').click(); + cy.url().should('include', '/budget'); + }); + + it('should navigate to /goals when "SparemÃ¥l" is clicked', () => { + cy.get('[data-cy=navigation-button]').click(); + cy.contains('SparemÃ¥l').click(); + cy.url().should('include', '/goals'); + }); + + it('should navigate to /profile when "Profil" is clicked', () => { + cy.get('[data-cy=navigation-button]').click(); + cy.contains('Profil').click(); + cy.url().should('include', '/profile'); + }); + + it('should navigate to /news when "Nyheter" is clicked', () => { + cy.get('[data-cy=navigation-button]').click(); + cy.contains('Nyheter').click(); + cy.url().should('include', '/news'); + }); + + it('should navigate to /contact when "Kontakt oss!" is clicked', () => { + cy.get('[data-cy=navigation-button]').click(); + cy.contains('Kontakt oss!').click(); + cy.url().should('include', '/contact'); + }); +}); \ No newline at end of file diff --git a/tests/e2e/specs/news/newsMain.cy.js b/tests/e2e/specs/news/newsMain.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..ac1b56deb2cd6707b900d3771ee4f1993d33e4f9 --- /dev/null +++ b/tests/e2e/specs/news/newsMain.cy.js @@ -0,0 +1,51 @@ +// newsComponent.spec.js +describe('News Component Tests', () => { + beforeEach(() => { + cy.visit('http://localhost:8082/news'); + }); + + it('successfully loads the initial news items', () => { + cy.wait(2000); + cy.get('[data-cy=news-container]').should('have.length.at.least', 1); + }); + + it('logs all options in the select dropdown', () => { + cy.wait(2000); + cy.get('[data-cy=category-dropdown]').then($select => { + // Ensure the select is visible and interactable + cy.wrap($select).should('be.visible'); + + // Log each option's text and value + cy.wrap($select).find('option').each(($option) => { + const text = $option.text(); + const value = $option.val(); + cy.log(`Option Text: ${text}, Value: ${value}`); + }); + }); + }); + + it('filters news items by category', () => { + cy.wait(2000); + cy.get('[data-cy=category-dropdown] option').then(options => { + // Select the first category from the dropdown + const category = [...options][1].text; + cy.get('[data-cy=category-dropdown]').select(category); + + // Ensure all news items are of the selected category + cy.get('[data-cy=news-info]').each(($el) => { + cy.wrap($el).should('contain', category); + }); + }); + }); + + it('ensures news items have all required properties', () => { + cy.wait(2000); + // Ensure each news item has a link, title, info, and image + cy.get('[data-cy=news-container]').within(() => { + cy.get('[data-cy=news-link]').should('have.attr', 'href').and('include', 'http'); + cy.get('[data-cy=news-title]').should('not.be.empty'); + cy.get('[data-cy=news-info]').should('not.be.empty'); + cy.get('img').should('have.attr', 'src').and('include', 'http'); + }); + }); +}); diff --git a/tests/e2e/specs/profile/bankStatements.cy.js b/tests/e2e/specs/profile/bankStatements.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..cd707bd81ee4cc94e54dc6fdbd123999ec944990 --- /dev/null +++ b/tests/e2e/specs/profile/bankStatements.cy.js @@ -0,0 +1,64 @@ +describe('Bank Statements Component', () => { + beforeEach(() => { + cy.login('admin@example.com', 'password'); + cy.visit('http://localhost:8082/profile'); + cy.get('[data-cy="bank-statements-link"]').click(); + }); + + it('loads the initial page and displays the main container', () => { + // Check for main container visibility and title + cy.get('.main-container-bankStatements').should('be.visible'); + cy.get('h1').contains('Kontoutskrifter'); + }); + + it('logs all options in the select dropdown', () => { + cy.get('[data-cy="bank-select"]').then($select => { + // Ensure the select is visible and interactable + cy.wrap($select).should('be.visible'); + + // Log each option's text and value + cy.wrap($select).find('option').each(($option) => { + const text = $option.text(); + const value = $option.val(); + cy.log(`Option Text: ${text}, Value: ${value}`); + }); + }); + }); + + it('Should allow selecting a bank from the dropdown', () => { + // Select a bank from the dropdown + cy.get('[data-cy="bank-select"]').select('HANDELSBANKEN').should('have.value', 'HANDELSBANKEN'); + }); + + it('allows a user to select a file', () => { + // Selecting a file + cy.get('[data-cy="file-input"]').attachFile('handelsbankenExample.pdf'); + }); + + it('Should display error message for non-PDF file types', () => { + // Selecting a bank from the dropdown + cy.get('[data-cy="bank-select"]').select('HANDELSBANKEN').should('have.value', 'HANDELSBANKEN'); + + // Selecting a non-PDF file + cy.get('input[type="file"]').attachFile('pigrich.png'); + cy.get('[data-cy="error-message"]').should('contain', 'Vennligst last opp en gyldig PDF-fil.'); + }); + + it('should upload bank statement', () => { + // Intercept the POST request for adding a bank statement + cy.intercept('POST', 'http://localhost:8080/api/v1/bank-statements/?bankName=HANDELSBANKEN', { + statusCode: 200, + body: { + id: 1, + } + }).as('uploadBankStatement'); + + // Mock selecting a bank + cy.get('[data-cy="bank-select"]').select('HANDELSBANKEN').should('have.value', 'HANDELSBANKEN'); + + // Mock selecting and uploading a valid file + cy.get('[data-cy="file-input"]').attachFile('handelsbankenExample.pdf'); + + cy.wait('@uploadBankStatement').its('response.statusCode').should('eq', 200); + }); +}); \ No newline at end of file diff --git a/tests/e2e/specs/profile/myProfile.cy.js b/tests/e2e/specs/profile/myProfile.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..c2f8eead1f80961abf1635c5c4bd31d15f883c2a --- /dev/null +++ b/tests/e2e/specs/profile/myProfile.cy.js @@ -0,0 +1,150 @@ +describe('My Profile Component', () => { + beforeEach(() => { + cy.login('admin@example.com', 'password'); + cy.visit('http://localhost:8082/profile'); + cy.get('[data-cy="my-profile-link"]').click(); + }) + + it('loads correctly', () => { + cy.get('.my-profile-main-container').should('exist') + }) + + it('displays user information correctly', () => { + cy.get('#firstName').should('have.value', 'Admin') + cy.get('#lastName').should('have.value', 'Admin') + cy.get('#email').should('have.value', 'admin@example.com') + cy.get('#income').should('have.value', '10000') + cy.get('#livingStatus').should('have.value', '1') + }) + + it('allows editing first name', () => { + // Check that input is disabled + cy.get('#firstName').should('be.disabled') + + // Test changing first name + cy.get('[data-cy=edit-button]').click() + cy.get('#firstName').clear().type('Jane') + cy.get('[data-cy=edit-button]').click() + + cy.get('[data-cy=user-status-message]').should('contain.text', 'Fornavn oppdatert.') + cy.get('#firstName').should('have.value', 'Jane') + + // Reset the name + cy.get('[data-cy=edit-button]').click() + cy.get('#firstName').clear().type('Admin') + cy.get('[data-cy=edit-button]').click() + + cy.get('[data-cy=user-status-message]').should('contain.text', 'Fornavn oppdatert.') + cy.get('#firstName').should('have.value', 'Admin') + }) + + it('allows editing last name', () => { + // Check that input is disabled + cy.get('#lastName').should('be.disabled') + + // Test changing last name + cy.get('[data-cy=edit-button]').click() + cy.get('#lastName').clear().type('Doe') + cy.get('[data-cy=edit-button]').click() + + cy.get('[data-cy=user-status-message]').should('contain.text', 'Etternavn oppdatert.') + cy.get('#lastName').should('have.value', 'Doe') + + // Reset the name + cy.get('[data-cy=edit-button]').click() + cy.get('#lastName').clear().type('Admin') + cy.get('[data-cy=edit-button]').click() + + cy.get('[data-cy=user-status-message]').should('contain.text', 'Etternavn oppdatert.') + cy.get('#lastName').should('have.value', 'Admin') + }) + + it('allows editing income', () => { + // Check that input is disabled + cy.get('#income').should('be.disabled') + + // Test changing income + cy.get('[data-cy=edit-button]').click() + cy.get('#income').clear().type('70000') + cy.get('[data-cy=edit-button]').click() + + cy.get('[data-cy=user-status-message]').should('contain.text', 'Inntekt oppdatert.') + cy.get('#income').should('have.value', '70000') + + // Reset the income + cy.get('[data-cy=edit-button]').click() + cy.get('#income').clear().type('10000') + cy.get('[data-cy=edit-button]').click() + + cy.get('[data-cy=user-status-message]').should('contain.text', 'Inntekt oppdatert.') + cy.get('#income').should('have.value', '10000') + }) + + it('allows editing living status', () => { + // Check that select is disabled + cy.get('#livingStatus').should('be.disabled') + + // Test changing living status + cy.get('[data-cy=edit-button]').click() + cy.get('#livingStatus').select('Par med barn') + + cy.get('[data-cy=edit-button]').click() + cy.get('[data-cy=user-status-message]').should('contain.text', 'Bosituasjon oppdatert.') + + // Reset the living status + cy.get('[data-cy=edit-button]').click() + cy.get('#livingStatus').select('Bor alene') + cy.get('[data-cy=edit-button]').click() + + cy.get('[data-cy=user-status-message]').should('contain.text', 'Bosituasjon oppdatert.') + }) + + it('allows editing saving percentage', () => { + // Check that input is disabled + cy.get('#savingPercentage').should('be.disabled') + + // Test changing saving percentage + cy.get('[data-cy=edit-button]').click() + cy.get('#savingPercentage').clear().type('20') + cy.get('[data-cy=edit-button]').click() + + cy.get('[data-cy=user-status-message]').should('contain.text', 'Spareprosent oppdatert.') + cy.get('#savingPercentage').should('have.value', '20') + }); + + it('displays error message if input is blank', () => { + // Test changing first name + cy.get('[data-cy=edit-button]').click() + cy.get('#firstName').clear() + cy.get('[data-cy=edit-button]').click() + cy.get('[data-cy=user-status-message]').should('contain.text', 'Fornavn kan ikke være tomt.') + + // Test changing last name + cy.get('[data-cy=edit-button]').click() + cy.get('#lastName').clear() + cy.get('[data-cy=edit-button]').click() + cy.get('[data-cy=user-status-message]').should('contain.text', 'Etternavn kan ikke være tomt.') + + // Test changing income + cy.get('[data-cy=edit-button]').click() + cy.get('#income').clear() + cy.get('[data-cy=edit-button]').click() + cy.get('[data-cy=user-status-message]').should('contain.text', 'Inntekt kan ikke være tom.') + }); + +/* it('allows changing password', () => { + // Test changing password + cy.get('#oldPassword').type('password') + cy.get('#newPassword').type('password123') + cy.get('#confirmPassword').type('password123') + cy.get('[data-cy=edit-password-button]').click({force: true}); + cy.get('[data-cy=password-status-message]').should('contain.text', 'Passord endret.') + + // Reset password + cy.get('#oldPassword').type('password123') + cy.get('#newPassword').type('password') + cy.get('#confirmPassword').type('password123') + cy.get('[data-cy=edit-password-button]').click({force: true}); + cy.get('[data-cy=password-status-message]').should('contain.text', 'Passord endret.') + }) */ +}) diff --git a/tests/e2e/specs/profile/profileBar.cy.js b/tests/e2e/specs/profile/profileBar.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..472bfad41d753eea47875932b882903d69189d88 --- /dev/null +++ b/tests/e2e/specs/profile/profileBar.cy.js @@ -0,0 +1,39 @@ +describe('Profile Bar Navigation', () => { + beforeEach(() => { + cy.login('admin@example.com', 'password'); + cy.visit('http://localhost:8082/profile'); + }); + + it('navigates to My Profile component when My Profile link is clicked', () => { + // Click on My Profile link + cy.get('[data-cy=my-profile-link]').click(); + + // Ensure that the right component is displayed + cy.get('.my-profile-main-container').should('exist'); + }); + + it('navigates to Bank Statements component when Bank Statements link is clicked', () => { + // Click on Bank Statements link + cy.get('[data-cy=bank-statements-link]').click(); + + // Ensure that the right component is displayed + cy.get('.main-container-bankStatements').should('exist'); + }); + + it('navigates to Settings component when Settings link is clicked', () => { + // Click on Settings link + cy.get('[data-cy=settings-link]').click(); + + // Ensure that the right component is displayed + cy.get('.settings-main-container').should('exist'); + }); + + it('navigates to Badges component when Badges link is clicked', () => { + // Click on Badges link + cy.get('[data-cy=badges-link]').click(); + + // Ensure that the right component is displayed + cy.get('.badges-container').should('exist'); + }); + }); + diff --git a/tests/e2e/specs/test.js b/tests/e2e/specs/test.js deleted file mode 100644 index 155e3021cb5751c26f404cdf0ba29acd9fd1cc5f..0000000000000000000000000000000000000000 --- a/tests/e2e/specs/test.js +++ /dev/null @@ -1,8 +0,0 @@ -// https://docs.cypress.io/api/table-of-contents - -describe('My First Test', () => { - it('Visits the app root url', () => { - cy.visit('/') - cy.contains('h1', 'Welcome to Your Vue.js App') - }) -}) diff --git a/tests/e2e/specs/transactions/transactionsMain.cy.js b/tests/e2e/specs/transactions/transactionsMain.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..0a6a928355592f4871c1393833be1a9f5a377dac --- /dev/null +++ b/tests/e2e/specs/transactions/transactionsMain.cy.js @@ -0,0 +1,25 @@ +describe('Transactions Component', () => { + beforeEach(() => { + cy.login('admin@example.com', 'password'); + cy.visit('http://localhost:8082/transactions'); + }) + + it('loads and displays the correct initial month', () => { + const prevMonth = new Date(); + prevMonth.setMonth(prevMonth.getMonth() - 1); + const expectedDate = `${prevMonth.getFullYear()}-${prevMonth.getMonth() + 1 < 10 ? '0' + (prevMonth.getMonth() + 1) : prevMonth.getMonth() + 1}-01`; + cy.get('#selected-month').should('have.value', expectedDate); + }); + + it('allows editing of transactions when edit mode is toggled', () => { + cy.get('button').contains('Rediger').click(); + cy.get('button').contains('Ferdig').click(); + }); + +/* it('filters transactions based on search input', () => { + cy.get('.transaction-search').type('Mat'); + cy.get('.table-container tbody tr').each(($el) => { + cy.wrap($el).contains('Mat'); + }); + }); */ +}); \ No newline at end of file diff --git a/tests/e2e/specs/userDetails/importFiles.cy.js b/tests/e2e/specs/userDetails/importFiles.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..a2e8666996692412cab647c33b0f4055c8227dbb --- /dev/null +++ b/tests/e2e/specs/userDetails/importFiles.cy.js @@ -0,0 +1,73 @@ +describe('Bank Statement Import Component', () => { + beforeEach(() => { + cy.login('admin@example.com', 'password'); + cy.visit('http://localhost:8082/userDetails'); + cy.get('#income').clear().type('10000'); + cy.get('#savingPercentage').clear().type('20'); + cy.contains('button', 'Videre').click(); + cy.get('.option').first().click(); + cy.contains('button', 'Neste').click(); + }); + + it('displays bank statement import form correctly', () => { + cy.contains('h2', 'Kontoutskrifter'); + cy.contains('p', 'Legg til kontoutskrifter fra de siste mÃ¥nedene.'); + cy.contains('Velg bank'); + cy.contains('Velg fil'); + cy.contains('button', 'GÃ¥ tilbake'); + cy.contains('button', 'Neste'); + }); + + it('displays error message if no bank statement is uploaded', () => { + // Click Next button without uploading a file + cy.contains('button', 'Neste').click(); + + // Ensure error message is displayed + cy.contains('.error', 'Vennligst last opp minst en fil.'); + }); + + it('displays error message if bank statement is uploaded without selecting a bank', () => { + // Upload a PDF file without selecting a bank + cy.get('input[type="file"]').attachFile('handelsbankenExample.pdf'); + + // Ensure error message is displayed + cy.contains('.error', 'Du mÃ¥ velge en bank før du laster opp en fil. Vennligst prøv igjen.'); + }); + + it('allows selecting a bank and a PDF file', () => { + // Select a bank + cy.get('#pickBank').select('DNB'); + + // Upload a PDF file + cy.get('input[type="file"]').attachFile('handelsbankenExample.pdf'); + }); + + it('shows error message for invalid file types', () => { + // Select a bank + cy.get('#pickBank').select('DNB'); + + // Upload a non-PDF file + cy.get('input[type="file"]').attachFile('pigrich.png'); + + // Ensure error message is displayed + cy.contains('.error', 'Vennligst last opp en gyldig PDF-fil.'); + }); + + it('navigates to the previous page when back button is clicked', () => { + cy.contains('button', 'GÃ¥ tilbake').click(); + cy.get('.household-main-container').should('exist'); + }); + + it('should display error message if bank does not match the uploaded file', () => { + // Select a bank + cy.get('#pickBank').select('DNB'); + + // Upload a PDF file from a different bank + cy.get('input[type="file"]').attachFile('handelsbankenExample.pdf'); + + cy.wait(3000); + + // Ensure error message is displayed + cy.contains('.error', 'Fikk ikke lastet opp kontoutsrift.'); + }); +}); \ No newline at end of file diff --git a/tests/e2e/specs/userDetails/selectHousehold.cy.js b/tests/e2e/specs/userDetails/selectHousehold.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..734b993932de86eac676cf9975ec90156ac23459 --- /dev/null +++ b/tests/e2e/specs/userDetails/selectHousehold.cy.js @@ -0,0 +1,38 @@ +describe('Household Selection Component', () => { + beforeEach(() => { + cy.login('admin@example.com', 'password'); + cy.visit('http://localhost:8082/userDetails'); + cy.get('#income').clear().type('10000'); + cy.get('#savingPercentage').clear().type('20'); + cy.contains('button', 'Videre').click(); + }); + + it('displays household selection options correctly', () => { + cy.contains('h2', 'Bosituasjon'); + cy.get('.option').should('have.length', 4); + }); + + it('allows selecting a household option', () => { + // Click on the first household option + cy.get('.option').first().click(); + + // Ensure the selected option has the correct styling + cy.get('.option').first().should('have.class', 'selected'); + }); + + it('navigates to the previous page when back button is clicked', () => { + cy.contains('button', 'GÃ¥ tilbake').click(); + cy.get('.income-main-container').should('exist'); + }); + + it('navigates to the next page when a valid option is selected and Next button is clicked', () => { + // Click on the first household option + cy.get('.option').first().click(); + + // Click Next button + cy.contains('button', 'Neste').click(); + + // Ensure the next page is displayed + cy.get('.importFiles-main-container').should('exist'); + }); +}); \ No newline at end of file diff --git a/tests/e2e/specs/userDetails/selectIncome.cy.js b/tests/e2e/specs/userDetails/selectIncome.cy.js new file mode 100644 index 0000000000000000000000000000000000000000..9f90863561ca415d786da98a7218e635866092c3 --- /dev/null +++ b/tests/e2e/specs/userDetails/selectIncome.cy.js @@ -0,0 +1,63 @@ +describe('Income Calculation Component', () => { + beforeEach(() => { + cy.login('admin@example.com', 'password'); + cy.visit('http://localhost:8082/userDetails'); + }); + + it('displays income calculation form correctly', () => { + cy.contains('h2', 'Beregning av mÃ¥nedlige utgifter'); + cy.contains('p', 'Dette skjemaet vil regne ut hvor mye av inntekten din du har til forbruk, etter skatt og sparing'); + cy.get('label[for="income"]').should('exist'); + cy.get('label[for="savingPercentage"]').should('exist'); + cy.get('label[for="consumption"]').should('exist'); + cy.get('button').should('contain.text', 'Videre'); + }); + + it('allows entering income and saving percentage', () => { + // Enter income + cy.get('#income').clear().type('10000'); + + // Enter saving percentage + cy.get('#savingPercentage').clear().type('20'); + + // Ensure Next button is enabled + cy.get('button').should('be.enabled'); + }); + + it('calculates consumption correctly based on income and saving percentage', () => { + // Enter income + cy.get('#income').clear().type('10000'); + + // Enter saving percentage + cy.get('#savingPercentage').clear().type('20'); + + // Ensure consumption is calculated correctly + cy.get('#consumption').should('have.value', '8000'); + }); + + it('handles invalid inputs and displays error message', () => { + // Leave income input empty + cy.get('#income').clear(); + cy.get('#savingPercentage').clear().type('20'); + + // Click Next button + cy.contains('button', 'Videre').click(); + + // Ensure error message is displayed + cy.contains('.error-message', 'Vennligst fyll ut netto inntekt og prosentandel du ønsker Ã¥ spare.'); + }); + + it('navigates to the next page when valid inputs are provided and Next button is clicked', () => { + // Enter income + cy.get('#income').clear().type('10000'); + + // Enter saving percentage + cy.get('#savingPercentage').clear().type('20'); + + // Click Next button + cy.contains('button', 'Videre').click(); + + // Ensure the next page is displayed + cy.get('.household-main-container').should('exist'); + }); +}); diff --git a/tests/e2e/support/commands.js b/tests/e2e/support/commands.js index c1f5a772e2bcbd8a318fffcf7ba25e205a92dade..9f8e7afa4a48764d8ef721717ecbbc1c0822119b 100644 --- a/tests/e2e/support/commands.js +++ b/tests/e2e/support/commands.js @@ -1,25 +1,10 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add("login", (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This is will overwrite an existing command -- -// Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) +Cypress.Commands.add('login', (email, password) => { + cy.visit('http://localhost:8082/login'); + cy.get('.navigation') + .contains('h5', 'Logg inn') + .click(); + cy.get('#email-input-filed').type(email, { force: true }); + cy.get('#password-input-filed').type(password, { force: true }); + cy.get('#login-button').click({ force: true }); + cy.get('.home-container').should('exist'); +}); diff --git a/tests/e2e/support/index.js b/tests/e2e/support/index.js index d68db96df2697e0835f5c490db0c2cc81673f407..7780c9dcdab957fb3e9a0741f35bc355233f1f64 100644 --- a/tests/e2e/support/index.js +++ b/tests/e2e/support/index.js @@ -1,20 +1,2 @@ -// *********************************************************** -// This example support/index.js is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: import './commands' - -// Alternatively you can use CommonJS syntax: -// require('./commands') +import 'cypress-file-upload'; diff --git a/tests/unit/example.spec.js b/tests/unit/example.spec.js deleted file mode 100644 index 4b21ca7d9d18950c2e21e897f19aab2ce9aa1017..0000000000000000000000000000000000000000 --- a/tests/unit/example.spec.js +++ /dev/null @@ -1,12 +0,0 @@ -import { shallowMount } from '@vue/test-utils' -import HelloWorld from '@/components/HelloWorld.vue' - -describe('HelloWorld.vue', () => { - it('renders props.msg when passed', () => { - const msg = 'new message' - const wrapper = shallowMount(HelloWorld, { - props: { msg } - }) - expect(wrapper.text()).toMatch(msg) - }) -}) diff --git a/tests/unit/router/router.spec.js b/tests/unit/router/router.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..0ef315612b571a5173fe604306df24f7d284da95 --- /dev/null +++ b/tests/unit/router/router.spec.js @@ -0,0 +1,35 @@ +import router from '@/router' + +jest.mock('@/views/HomeView.vue', () => ({ default: { template: '<div></div>' } })); +jest.mock('@/views/VerificationView.vue', () => ({ default: { template: '<div></div>' } })); + +const routes = router.getRoutes(); + +describe('Router', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(Storage.prototype, 'getItem').mockReturnValue(null); + jest.spyOn(Storage.prototype, 'setItem'); + }); + + it('redirects to login if user is not authenticated', async () => { + await router.push('/budget').catch(() => {}); + await router.isReady(); + + expect(router.currentRoute.value.name).toBe('login'); + }); + + it('requires authentication for protected routes', async () => { + const authRoutes = routes.find(route => route.meta.requiresAuth && route.meta); + expect(authRoutes).toBeTruthy(); + expect(authRoutes.meta.requiresAuth).toBe(true); + }); + + it('allows access to protected routes if user is authenticated', async () => { + jest.spyOn(Storage.prototype, 'getItem').mockReturnValue('token'); + await router.push('/budget').catch(() => {}); + await router.isReady(); + + expect(router.currentRoute.value.name).toBe('budget'); + }); +}); diff --git a/tests/unit/service/accountService.spec.js b/tests/unit/service/accountService.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..118d6c393e235a720d2ea3b2c16f2f8e72d61e92 --- /dev/null +++ b/tests/unit/service/accountService.spec.js @@ -0,0 +1,40 @@ +import AccountService from "@/services/internal/AccountService"; +import axios from "axios"; + +jest.mock("axios"); + +describe("AccountService", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should add an account", async () => { + const mockResponse = { data: "Account added" }; + axios.post.mockResolvedValue(mockResponse); + + const response = await AccountService.addAccount("1234567890"); + + expect(response).toBe("Account added"); + + expect(axios.post).toHaveBeenCalledWith( + `${AccountService.baseURL}/addAccount`, + expect.any(FormData),{ + headers: { + "Content-Type": "application/json" + }, + } + ); + }); + + it("should get accounts", async () => { + const mockResponse = { data: "Accounts" }; + axios.get.mockResolvedValue(mockResponse); + + const response = await AccountService.getAccounts(); + + expect(response).toBe("Accounts"); + + expect(axios.get).toHaveBeenCalledWith(`${AccountService.baseURL}/getAccounts`); + }); + +}); \ No newline at end of file diff --git a/tests/unit/service/emailService.spec.js b/tests/unit/service/emailService.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..78bef2a2f095ebc94897e98afca2bf26bb5eb1cd --- /dev/null +++ b/tests/unit/service/emailService.spec.js @@ -0,0 +1,18 @@ +import EmailService from "@/services/internal/EmailService"; +import axios from "axios"; + +jest.mock("axios"); + +describe("EmailService", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should send register token", async () => { + const mockResponse = { success: true }; + axios.get.mockResolvedValue({ data: mockResponse }); + + const response = await EmailService.sendRegisterToken("to@mail.com"); + expect(response).toEqual(mockResponse); + }); +}); \ No newline at end of file diff --git a/tests/unit/service/newsService.spec.js b/tests/unit/service/newsService.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..2b8cf547af42d6c1212087ef9800dbe9511aaa44 --- /dev/null +++ b/tests/unit/service/newsService.spec.js @@ -0,0 +1,20 @@ +import NewsService from "@/services/internal/NewsService"; +import axios from "axios"; + +jest.mock("axios"); + +describe("NewsService", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should get news", async () => { + const mockResponse = { success: true }; + axios.get.mockResolvedValue({ data: mockResponse }); + + const response = await NewsService.getNews(1, 10); + expect(response).toEqual(mockResponse); + + expect(axios.get).toHaveBeenCalledWith(`${NewsService.baseURL}/?page=1&pageSize=10`); + }); +}); \ No newline at end of file diff --git a/tests/unit/service/streakService.spec.js b/tests/unit/service/streakService.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..9c6ccf0a6b7dc2467c25a438fb0ef7ccd078745b --- /dev/null +++ b/tests/unit/service/streakService.spec.js @@ -0,0 +1,47 @@ +import axios from "axios"; +import StreakService from "@/services/internal/StreakService"; + +// Mocking axios +jest.mock("axios"); + +describe("StreakService", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should get streak", async () => { + // Mock successful response from API + const mockResponse = { success: true }; + axios.get.mockResolvedValue({ data: mockResponse }); + + const response = await StreakService.getStreak(); + + expect(response).toEqual(mockResponse); + + // Verify that axios.get was called with the correct arguments + expect(axios.get).toHaveBeenCalledWith( + `${StreakService.baseURL}` + ); + }); + + it("should change streak", async () => { + // Mock successful response from API + const mockResponse = { success: true }; + axios.put.mockResolvedValue({ data: mockResponse }); + + const response = await StreakService.changeStreak(1); + + expect(response).toEqual(mockResponse); + + // Verify that axios.put was called with the correct arguments + expect(axios.put).toHaveBeenCalledWith( + `${StreakService.baseURL}`, + { increment: 1 }, + { + headers: { + 'Content-Type': 'application/json' + } + } + ); + }); +}); \ No newline at end of file diff --git a/tests/unit/service/transactionService.spec.js b/tests/unit/service/transactionService.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..c7de3a8ed2b8e29c2a8807a789485342f4d0ee0c --- /dev/null +++ b/tests/unit/service/transactionService.spec.js @@ -0,0 +1,31 @@ +import TransactionService from "@/services/internal/TransactionService"; +import axios from "axios"; + +jest.mock("axios"); + +describe("TransactionService", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should add transactions", async () => { + const mockResponse = { success: true }; + axios.post.mockResolvedValue({ data: mockResponse }); + + const pdfFile = new File([""], "filename.pdf", { type: "application/pdf" }); + + const response = await TransactionService.addTransactions(pdfFile); + + expect(response).toEqual(mockResponse); + + expect(axios.post).toHaveBeenCalledWith( + `${TransactionService.baseURL}/addTransactions`, + expect.any(FormData), + { + headers: { + "Content-Type": "application/json", + }, + } + ); + }); +}); \ No newline at end of file diff --git a/tests/unit/service/userDetailsService.spec.js b/tests/unit/service/userDetailsService.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..967519b843457496c625b9590a3b20bd53c8ee48 --- /dev/null +++ b/tests/unit/service/userDetailsService.spec.js @@ -0,0 +1,70 @@ +import UserDetailsService from "@/services/internal/UserDetailsService"; +import axios from "axios"; + +jest.mock("axios"); + +describe("UserDetailsService", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should change first name", async () => { + const mockResponse = { success: true }; + axios.put.mockResolvedValue({ data: mockResponse }); + + const changeFirstNameDTO = { newFirstName: "NewFirstName" }; + + const response = await UserDetailsService.changeFirstName(changeFirstNameDTO); + + expect(response).toEqual(mockResponse); + + expect(axios.put).toHaveBeenCalledWith( + `${UserDetailsService.baseURL}/first-name`, + changeFirstNameDTO, { + headers: { + "Content-Type": "application/json" + } + } + ); + }); + + it("should change last name", async () => { + const mockResponse = { success: true }; + axios.put.mockResolvedValue({ data: mockResponse }); + + const changeLastNameDTO = { newLastName: "NewLastName" }; + + const response = await UserDetailsService.changeLastName(changeLastNameDTO); + + expect(response).toEqual(mockResponse); + + expect(axios.put).toHaveBeenCalledWith( + `${UserDetailsService.baseURL}/last-name`, + changeLastNameDTO,{ + headers: { + "Content-Type": "application/json" + } + } + ); + }); + + it("should change password", async () => { + const mockResponse = { success: true }; + axios.put.mockResolvedValue({ data: mockResponse }); + + const changePasswordDTO = { newPassword: "NewPassword" }; + + const response = await UserDetailsService.changePassword(changePasswordDTO); + + expect(response).toEqual(mockResponse); + + expect(axios.put).toHaveBeenCalledWith( + `${UserDetailsService.baseURL}/password`, + changePasswordDTO, { + headers: { + "Content-Type": "application/json" + } + } + ); + }); +}); \ No newline at end of file diff --git a/tests/unit/service/userService.spec.js b/tests/unit/service/userService.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..616fc8e12e3520aca0e895d292a92ad2f71b2457 --- /dev/null +++ b/tests/unit/service/userService.spec.js @@ -0,0 +1,76 @@ +import UserService from "@/services/internal/UserService"; +import axios from "axios"; + +// Mocking axios +jest.mock("axios"); + + +describe("UserService", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it("should register a user", async () => { + // Mock successful response from API + const mockResponse = { success : true }; + axios.post.mockResolvedValue({ data: mockResponse }); + + const registerRequestDTO = { + email: "test@mail.com", + firstName: "FirstName", + lastName: "LastName", + password: "Password"}; + + const response = await UserService.register(registerRequestDTO); + + expect(response).toEqual(mockResponse); + + // Verify that axios.post was called with the correct arguments + expect(axios.post).toHaveBeenCalledWith( + `${UserService.baseURL}/register`, + registerRequestDTO + ); + }); + + it("should login a user", async () => { + // Mock successful response from API + const mockResponse = { token: "token" }; + axios.post.mockResolvedValue({ data: mockResponse }); + + const loginRequestDTO = { + email: "test@mail.com", + password: "Password" + }; + + const response = await UserService.login(loginRequestDTO); + + expect(response).toEqual(mockResponse); + + // Verify that axios.post was called with the correct arguments + expect(axios.post).toHaveBeenCalledWith( + `${UserService.baseURL}/login`, + loginRequestDTO, { + headers: { + "Content-Type": "application/json" + } + } + ); + }); + + it("should check if email exists", async () => { + // Mock successful response from API + const mockResponse = { exists: true }; + axios.get.mockResolvedValue({ data: mockResponse }); + + const email = "existing@mail.com"; + const response = await UserService.emailExist(email); + + expect(response).toEqual(mockResponse); + + // Verify that axios.get was called with the correct arguments + expect(axios.get).toHaveBeenCalledWith( + `${UserService.baseURL}/emailExist`, + { params: { email } } + ); + }); +}); \ No newline at end of file diff --git a/tests/unit/store/userStore.spec.js b/tests/unit/store/userStore.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..8589355ef8cb633e96675b753c56d8b0e862dfec --- /dev/null +++ b/tests/unit/store/userStore.spec.js @@ -0,0 +1,48 @@ +import userStore from "@/store/UserStore"; + + +describe("UserStore", () => { + afterEach(() => { + userStore.mutations.resetState(userStore.state()); + }); + + it("should have initial state", () => { + const state = userStore.state(); + expect(state.email).toBe(""); + expect(state.firstName).toBe(""); + expect(state.lastName).toBe(""); + expect(state.authToken).toBe(""); + expect(state.isAuthenticated).toBe(false); + }); + + it("should set auth token", () => { + const state = userStore.state(); + userStore.mutations.setAuthToken(state, "12345"); + expect(state.authToken).toBe("12345"); + }); + + it("should set email", () => { + const state = userStore.state(); + userStore.mutations.setEmail(state, "mail@mail.com"); + expect(state.email).toBe("mail@mail.com"); + }); + + it("should set first name", () => { + const state = userStore.state(); + userStore.mutations.setFirstName(state, "John"); + expect(state.firstName).toBe("John"); + }); + + it("should set last name", () => { + const state = userStore.state(); + userStore.mutations.setLastName(state, "Doe"); + expect(state.lastName).toBe("Doe"); + }); + + it("should set isAuthenticated", () => { + const state = userStore.state(); + userStore.mutations.setAuthentication(state, true); + expect(state.isAuthenticated).toBe(true); + }); + +}); \ No newline at end of file