diff --git a/src/components/__tests__/NavBarTest.spec.ts b/src/components/__tests__/NavBarTest.spec.ts index b31870d5340b4f2fe9642c8b74116ced1360c96f..ea7c2814a609abe8a0e43501fa558e7f45f70dc5 100644 --- a/src/components/__tests__/NavBarTest.spec.ts +++ b/src/components/__tests__/NavBarTest.spec.ts @@ -2,7 +2,7 @@ import { mount, VueWrapper } from '@vue/test-utils' import NavBar from '@/components/NavBarComponent.vue' import router from '@/router' import { createPinia, setActivePinia } from 'pinia' -import { describe, it, expect, beforeEach, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' vi.stubGlobal('scrollTo', vi.fn()) diff --git a/src/components/__tests__/savingsPathTest.spec.ts b/src/components/__tests__/savingsPathTest.spec.ts index af21d03c1b80df3ae2e90e8b92bd8e1782df544f..dea8650e2ad5d6ced6b43bfd45ce7bc012319875 100644 --- a/src/components/__tests__/savingsPathTest.spec.ts +++ b/src/components/__tests__/savingsPathTest.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeEach, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import { mount } from '@vue/test-utils' import { createPinia, setActivePinia } from 'pinia' import SavingsPath from '@/components/SavingsPath.vue' diff --git a/src/router/index.ts b/src/router/index.ts index c80ad8b511e430bf1f1a6bec2b83e82e929f4641..e88ce03d04665ab80cdf5a41c5724d526a36b32c 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -33,6 +33,11 @@ const router = createRouter({ name: 'profile', component: () => import('@/views/ProfileView.vue') }, + { + path: '/profil/rediger', + name: 'edit-profile', + component: () => import('@/views/EditProfileView.vue') + }, { path: '/sparemaal', name: 'goals', diff --git a/src/stores/accountStore.ts b/src/stores/accountStore.ts index d0d7715190bedd53d676fa1193145768037f2774..b80263eafa362581f6c2e235d544456ad887274c 100644 --- a/src/stores/accountStore.ts +++ b/src/stores/accountStore.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import authInterceptor from '@/services/authInterceptor' -import axios, { AxiosError } from 'axios' +import { AxiosError } from 'axios' export const useAccountStore = defineStore('account', { state: () => ({ diff --git a/src/stores/userConfigStore.ts b/src/stores/userConfigStore.ts index 3b5661531c91b403d1fb631dc6959fa513065285..6f1e0f1ccbe25f90f463aed9ef97ec9b63ac4319 100644 --- a/src/stores/userConfigStore.ts +++ b/src/stores/userConfigStore.ts @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import { ref } from 'vue' import authInterceptor from '@/services/authInterceptor' -import axios, { AxiosError } from 'axios' +import { AxiosError } from 'axios' export const useUserConfigStore = defineStore('userConfig', { state: () => ({ diff --git a/src/types/profile.ts b/src/types/profile.ts new file mode 100644 index 0000000000000000000000000000000000000000..392aee69615beff857dc324e85cc4f77ea1f7911 --- /dev/null +++ b/src/types/profile.ts @@ -0,0 +1,19 @@ +export interface Profile { + id: number + firstName: string + lastName: string + email: string + username: string + password?: string + spendingAccount: { + accNumber?: number + accountType?: string + balance?: number + } + savingAccount: { + accNumber?: number + accountType?: string + balance?: number + } + badges?: object[] +} diff --git a/src/views/CardTemplate.vue b/src/views/CardTemplate.vue new file mode 100644 index 0000000000000000000000000000000000000000..6fa731e67776d9e923ea99427b9a23893f3de385 --- /dev/null +++ b/src/views/CardTemplate.vue @@ -0,0 +1,9 @@ +<script lang="ts" setup></script> + +<template> + <div class="border rounded-xl shadow-lg overflow-hidden"> + <slot></slot> + </div> +</template> + +<style scoped></style> diff --git a/src/views/ConfigAccountNumberView.vue b/src/views/ConfigAccountNumberView.vue index ef650a7178ad5b1ec25644fbe3193b9822e83e91..652cf0d11ba68e92f47d8b6e14171faa565cf321 100644 --- a/src/views/ConfigAccountNumberView.vue +++ b/src/views/ConfigAccountNumberView.vue @@ -46,7 +46,7 @@ </template> <script setup lang="ts"> -import { ref, computed } from 'vue' +import { computed, ref } from 'vue' import { useAccountStore } from '@/stores/accountStore' import ContinueButtonComponent from '@/components/ContinueButtonComponent.vue' import router from '@/router' diff --git a/src/views/ConfigHabitChangeView.vue b/src/views/ConfigHabitChangeView.vue index 5ee730d582b413003907a7061f11affe2792a103..a1e8b37350d385d2c3f31db77f2b8c81b56b6e41 100644 --- a/src/views/ConfigHabitChangeView.vue +++ b/src/views/ConfigHabitChangeView.vue @@ -47,7 +47,7 @@ </template> <script setup lang="ts"> -import { onMounted, ref } from 'vue' +import { ref } from 'vue' import ContinueButtonComponent from '@/components/ContinueButtonComponent.vue' import router from '@/router' import { useUserConfigStore } from '@/stores/userConfigStore' diff --git a/src/views/EditProfileView.vue b/src/views/EditProfileView.vue new file mode 100644 index 0000000000000000000000000000000000000000..82da35481c774c789baffd3264b4a36cbbe45614 --- /dev/null +++ b/src/views/EditProfileView.vue @@ -0,0 +1,254 @@ +<script lang="ts" setup> +import authInterceptor from '@/services/authInterceptor' +import { computed, onMounted, ref } from 'vue' +import type { Profile } from '@/types/profile' +import CardTemplate from '@/views/CardTemplate.vue' +import router from '@/router' +import ToolTip from '@/components/ToolTip.vue' +import InteractiveSpare from '@/components/InteractiveSpare.vue' + +const profile = ref<Profile>({ + id: 0, + firstName: '', + lastName: '', + email: '', + username: '', + password: '', + spendingAccount: { + accNumber: undefined, + balance: 0 + }, + savingAccount: { + accNumber: undefined, + balance: 0 + } +}) + +const updatePassword = ref<boolean>(false) +const confirmPassword = ref<string>('') +const errorMessage = ref<string>('') + +const nameRegex = /^[æÆøØåÅa-zA-Z,.'-][æÆøØåÅa-zA-Z ,.'-]{1,29}$/ +const emailRegex = + /^[æÆøØåÅa-zA-Z0-9_+&*-]+(?:\.[æÆøØåÅa-zA-Z0-9_+&*-]+)*@(?:[æÆøØåÅa-zA-Z0-9-]+\.)+[æÆøØåÅa-zA-Z]{2,7}$/ +const usernameRegex = /^[ÆØÅæøåA-Za-z][æÆøØåÅA-Za-z0-9_]{2,29}$/ +const passwordRegex = /^(?=.*[0-9])(?=.*[a-zæøå])(?=.*[ÆØÅA-Z])(?=.*[@#$%^&+=!])(?=\S+$).{8,30}$/ +const accountNumberRegex = /^\d{11}$/ + +const isFirstNameValid = computed( + () => nameRegex.test(profile.value.firstName) && profile.value.firstName +) +const isLastNameValid = computed( + () => nameRegex.test(profile.value.lastName) && profile.value.lastName +) +const isEmailValid = computed(() => emailRegex.test(profile.value.email)) +const isUsernameValid = computed(() => usernameRegex.test(profile.value.username)) +const isPasswordValid = computed(() => passwordRegex.test(profile.value.password || '')) +const isSpendingAccountValid = computed(() => + accountNumberRegex.test(profile.value.spendingAccount.accNumber?.toString() || '') +) +const isSavingAccountValid = computed(() => + accountNumberRegex.test(profile.value.savingAccount.accNumber?.toString() || '') +) + +const isFormInvalid = computed( + () => + [ + isFirstNameValid, + isLastNameValid, + isEmailValid, + isUsernameValid, + isSpendingAccountValid, + isSavingAccountValid + ].some((v) => !v.value) || + (updatePassword.value + ? profile.value.password !== confirmPassword.value || profile.value.password === '' + : false) +) + +onMounted(async () => { + await authInterceptor('/profile') + .then((response) => { + profile.value = response.data + console.log(profile.value) + }) + .catch((error) => { + return console.log(error) + }) +}) + +const saveChanges = async () => { + if (isFormInvalid.value) { + errorMessage.value = 'Vennligst fyll ut alle feltene riktig' + return + } + + if (!updatePassword.value) { + delete profile.value.password + } + + await authInterceptor + .put('/profile', profile.value) + .then(() => { + router.back() + }) + .catch((error) => { + errorMessage.value = error.response.data.message + }) +} +</script> + +<template> + <div class="w-full flex px-10 justify-center"> + <div class="flex flex-row flex-wrap justify-center w-full max-w-screen-xl gap-20"> + <div class="flex flex-col max-w-96 w-full gap-5"> + <h1>Rediger profil</h1> + <div class="w-full flex flex-row gap-5 justify-between justify-items-end"> + <div class="flex flex-col justify-center"> + <button class="h-min bg-transparent text-4xl" v-text="'⬅ï¸'" /> + </div> + <div class="w-32 h-32 border-black border-2 rounded-full shrink-0" /> + <div class="flex flex-col justify-center"> + <button class="h-min bg-transparent text-4xl" v-text="'âž¡ï¸'" /> + </div> + </div> + + <div class="flex flex-col"> + <div class="flex flex-row justify-between mx-4"> + <p>Fornavn*</p> + <ToolTip + :message="'Must include only letters, spaces, commas, apostrophes, periods, and hyphens. 1-30 characters long'" + /> + </div> + <input + v-model="profile.firstName" + :class="{ 'bg-green-200': isFirstNameValid }" + name="firstname" + placeholder="Skriv inn fornavn" + type="text" + /> + </div> + <div class="flex flex-col"> + <div class="flex flex-row justify-between mx-4"> + <p>Etternavn*</p> + <ToolTip + :message="'Must include only letters, spaces, commas, apostrophes, periods, and hyphens. 1-30 characters long'" + /> + </div> + <input + v-model="profile.lastName" + :class="{ 'bg-green-200': isLastNameValid }" + name="lastname" + placeholder="Skriv inn etternavn" + type="text" + /> + </div> + <div class="flex flex-col"> + <div class="flex flex-row justify-between mx-4"> + <p>E-post*</p> + <ToolTip + :message="'Valid email: Starts with Norwegian letters, numbers, or special characters. Includes \@\ followed by a domain. Ends with 2-7 letters.'" + /> + </div> + <input + v-model="profile.email" + :class="{ 'bg-green-200': isEmailValid }" + name="email" + placeholder="Skriv inn e-post" + type="text" + /> + </div> + <div class="flex flex-col"> + <div class="flex flex-row justify-between mx-4"> + <p>Brukernavn*</p> + <ToolTip + :message="'Must start with a letter and can include numbers and underscores. 3-30 characters long.'" + /> + </div> + <input + v-model="profile.username" + :class="{ 'bg-green-200': isUsernameValid }" + name="username" + placeholder="Skriv inn brukernavn" + type="text" + /> + </div> + <div class="flex flex-col"> + <div class="flex flex-row justify-between mx-4"> + <div class="flex flex-row gap-2"> + <p>Endre passord</p> + <input v-model="updatePassword" type="checkbox" /> + </div> + <ToolTip + v-if="updatePassword" + :message="'Must be at least 8 characters, including at least one number, one lowercase letter, one uppercase letter, one special character (@#$%^&+=!), and no spaces.'" + /> + </div> + <input + v-if="updatePassword" + v-model="profile.password" + :class="{ 'bg-green-200': isPasswordValid }" + class="w-full" + name="password" + placeholder="Skriv inn passord" + /> + <input + v-if="updatePassword" + v-model="confirmPassword" + :class="{ 'bg-red-200': profile.password !== confirmPassword }" + class="mt-2" + name="confirm" + placeholder="Bekreft passord" + type="password" + /> + </div> + + <p v-if="errorMessage" class="text-red-500" v-text="errorMessage" /> + </div> + <div class="flex flex-col justify-end max-w-96 w-full gap-5"> + <InteractiveSpare + :png-size="10" + :speech="['Her kan du endre pÃ¥ profilen din!']" + direction="left" + /> + + <CardTemplate> + <div class="bg-red-300"> + <p class="font-bold mx-3" v-text="'Brukskonto'" /> + </div> + <input + v-model="profile.spendingAccount.accNumber" + :class="{ 'bg-green-200': isSpendingAccountValid }" + class="border-2 rounded-none rounded-b-xl w-full" + placeholder="Kontonummer" + type="number" + /> + </CardTemplate> + + <CardTemplate> + <div class="bg-red-300"> + <p class="font-bold mx-3" v-text="'Sparekonto'" /> + </div> + <input + v-model="profile.savingAccount.accNumber" + :class="{ 'bg-green-200': isSavingAccountValid }" + class="border-2 rounded-none rounded-b-xl w-full" + placeholder="Kontonummer" + type="number" + /> + </CardTemplate> + + <div class="flex flex-row justify-between"> + <button class="bg-button-other" @click="router.back()" v-text="'Avbryt'" /> + <button + :disabled="isFormInvalid" + @click="saveChanges" + v-text="'Lagre endringer'" + /> + </div> + </div> + </div> + </div> +</template> + +<style scoped></style> diff --git a/src/views/ProfileView.vue b/src/views/ProfileView.vue index 255824275b207f62108888007491f385b3b8e1d2..436e522c7c815ad6cb5725724b76a8f76779d75d 100644 --- a/src/views/ProfileView.vue +++ b/src/views/ProfileView.vue @@ -1,21 +1,115 @@ <script lang="ts" setup> import authInterceptor from '@/services/authInterceptor' -import { onMounted } from 'vue' +import { computed, onMounted, ref } from 'vue' +import type { Profile } from '@/types/profile' +import CardTemplate from '@/views/CardTemplate.vue' +import InteractiveSpare from '@/components/InteractiveSpare.vue' +import type { Challenge } from '@/types/challenge' +import type { Goal } from '@/types/goal' +import CardGoal from '@/components/CardGoal.vue' +import router from '@/router' + +const profile = ref<Profile>() +const completedGoals = ref<Goal[]>([]) +const completedChallenges = ref<Challenge[]>([]) onMounted(async () => { - await authInterceptor - .get('/config') + await authInterceptor('/profile') + .then((response) => { + profile.value = response.data + console.log(profile.value) + }) + .catch((error) => { + return console.log(error) + }) + + await authInterceptor(`/goals/completed?page=0&size=3`) .then((response) => { - console.log(response.data) + completedGoals.value = response.data.content }) .catch((error) => { - console.log(error) + return console.log(error) }) + + await authInterceptor('/challenges/completed?page=0&size=3') + .then((response) => { + completedChallenges.value = response.data.content + }) + .catch((error) => { + return console.log(error) + }) +}) + +const welcome = computed(() => { + return [`Velkommen, ${profile.value?.firstName} ${profile.value?.lastName} !`] }) </script> <template> - <h1>Din profil</h1> + <div class="w-full flex px-10 justify-center"> + <div class="flex flex-row flex-wrap justify-center w-full max-w-screen-xl gap-20"> + <div class="flex flex-col max-w-96 w-full gap-5"> + <h1>Profile</h1> + <div class="flex flex-row gap-5"> + <div class="w-32 h-32 border-black border-2 rounded-full shrink-0" /> + <div class="w-full flex flex-col justify-between"> + <h3 class="font-thin my-0">{{ profile?.username }}</h3> + <h3 class="font-thin my-0"> + {{ profile?.firstName + ' ' + profile?.lastName }} + </h3> + <h3 class="font-thin my-0">{{ profile?.email }}</h3> + </div> + </div> + + <h3 class="font-bold" v-text="'Du har spart ' + '< totalSaved >' + 'kr'" /> + + <CardTemplate> + <div class="bg-red-300"> + <p class="font-bold mx-3" v-text="'Brukskonto'" /> + </div> + <p + class="mx-3" + v-text="profile?.spendingAccount.accNumber || 'Ingen brukskonto oppkoblet'" + /> + </CardTemplate> + + <CardTemplate> + <div class="bg-red-300"> + <p class="font-bold mx-3" v-text="'Sparekonto'" /> + </div> + <p + class="mx-3" + v-text="profile?.savingAccount.accNumber || 'Ingen sparekonto oppkoblet'" + /> + </CardTemplate> + + <button @click="router.push({ name: 'edit-profile' })" v-text="'Rediger bruker'" /> + </div> + + <div class="flex flex-col"> + <InteractiveSpare :png-size="10" :speech="welcome" direction="left" /> + <div class="flex flex-row justify-between mx-4"> + <p class="font-bold">Fullførte sparemÃ¥l</p> + <a class="hover:p-0 cursor-pointer" v-text="'Se alle'" /> + </div> + <CardTemplate class="p-4 flex flex-row flex-wrap justify-center gap-2 mb-4 mt-2"> + <CardGoal v-for="goal in completedGoals" :key="goal.id" :goal-instance="goal" /> + </CardTemplate> + + <div class="flex flex-row justify-between mx-4"> + <p class="font-bold">Fullførte utfordringer</p> + <a class="hover:p-0 cursor-pointer" v-text="'Se alle'" /> + </div> + <CardTemplate class="p-4 flex flex-row flex-wrap justify-center gap-2 mb-4 mt-2"> + <CardGoal + v-for="challenge in completedChallenges" + :key="challenge.id" + :goal-instance="challenge" + /> + </CardTemplate> + </div> + </div> + </div> </template> <style scoped></style>