diff --git a/Makefile b/Makefile index 693ef9b2aaec37942c81b422312ea0b5f0751ac4..c483e0b12516af8ea4d80e3cf72efbf340d9a9a1 100644 --- a/Makefile +++ b/Makefile @@ -1,10 +1,19 @@ -.PHONY: build run +.PHONY: build run run-dev unit e2e clean-docker build-docker: docker build -t sparesti_frontend . run-docker: - docker run -p 5173:5173 sparesti_frontend + docker run --rm --name sparesti_frontend_container -p 5173:5173 sparesti_frontend + +clean-docker: + -docker stop sparesti_frontend_container + -docker rm sparesti_frontend_container + +run: + make build-docker + make clean-docker + make run-docker run-dev: npm run dev @@ -13,4 +22,4 @@ unit: npm run test:unit e2e: - npm run test:e2e \ No newline at end of file + npm run test:e2e diff --git a/cypress/e2e/homeView.cy.ts b/cypress/e2e/homeView.cy.ts index c36a497515644ec32fde9cce49a6e3f78850b3ae..8ddc7675b8e2f8c39782ff114013650c1f9a9138 100644 --- a/cypress/e2e/homeView.cy.ts +++ b/cypress/e2e/homeView.cy.ts @@ -14,6 +14,13 @@ describe('Goals and Challenges Page Load', () => { ], }, }).as('fetchGoals'); + // Mock the POST request for renewing the token if it's not implemented in the backend + cy.intercept('POST', '/auth/renewToken', { + statusCode: 200, + body: { + accessToken: 'newlyRenewedAccessToken' + } + }).as('renewToken'); cy.intercept('GET', '/challenges', { statusCode: 200, @@ -24,6 +31,14 @@ describe('Goals and Challenges Page Load', () => { }, }).as('fetchChallenges'); + cy.intercept('GET', '/profile/streak', { + statusCode: 200, + body: { + content: [ + { streak: 1, startDate: "2026-04-29T12:10:38.308Z" }, + ], + }, + }).as('fetchChallenges'); // Visit the component that triggers these requests in `onMounted` cy.visit('/hjem'); }); @@ -31,6 +46,13 @@ describe('Goals and Challenges Page Load', () => { it('loads and displays goals and challenges after onMounted', () => { // Wait for API calls made during `onMounted` to complete cy.wait(['@fetchGoals', '@fetchChallenges']); + // Mock the POST request for renewing the token if it's not implemented in the backend + cy.intercept('POST', '/auth/renewToken', { + statusCode: 200, + body: { + accessToken: 'newlyRenewedAccessToken' + } + }).as('renewToken'); // Check console logs for any errors or warnings that might indicate issues cy.window().then((win) => { diff --git a/package-lock.json b/package-lock.json index 962f0ccbb36ff69aa08fc85c6ff4ba24d66f855f..cdbcb6f750f1327fdc33e3e3d5fc21a2a8970dbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "pinia": "^2.1.7", "vue": "^3.4.21", "vue-router": "^4.3.1", + "vue3-flip-countdown": "^0.1.6", "vuedraggable": "^4.1.0" }, "devDependencies": { @@ -7252,6 +7253,14 @@ "typescript": "*" } }, + "node_modules/vue3-flip-countdown": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/vue3-flip-countdown/-/vue3-flip-countdown-0.1.6.tgz", + "integrity": "sha512-RRz+iZ7Zvr1U9mrZRya7I5815jboDyRJz9vzgILq8ZCc2fQ6SxZPYwOr3pD5oWCDBprAEsPF9x4fsTtEitSmXw==", + "dependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vuedraggable": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", diff --git a/package.json b/package.json index be13c2a5d3e4a0451de2749309b751fd1a3e17b4..3d1c8853a603f9b7c4fb8d0e0f161bdd8d57c335 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "pinia": "^2.1.7", "vue": "^3.4.21", "vue-router": "^4.3.1", + "vue3-flip-countdown": "^0.1.6", "vuedraggable": "^4.1.0" }, "devDependencies": { diff --git a/src/assets/archerSpare.gif b/src/assets/archerSpare.gif new file mode 100644 index 0000000000000000000000000000000000000000..ef0e3fa6fd0fcdd700e0e8cb233c4195b90b1355 Binary files /dev/null and b/src/assets/archerSpare.gif differ diff --git a/src/assets/backgroundSavingsPath.png b/src/assets/backgroundSavingsPath.png new file mode 100644 index 0000000000000000000000000000000000000000..43cfde5161f862f862f14f8612077b10bbcfb4e2 Binary files /dev/null and b/src/assets/backgroundSavingsPath.png differ diff --git a/src/assets/bioAuthFace.png b/src/assets/bioAuthFace.png new file mode 100644 index 0000000000000000000000000000000000000000..b03bb5d400029bcbc6d7bea9f5657da31e3e63a0 Binary files /dev/null and b/src/assets/bioAuthFace.png differ diff --git a/src/assets/bioAuthTouch.png b/src/assets/bioAuthTouch.png new file mode 100644 index 0000000000000000000000000000000000000000..363627b9c744b2a8dcabc9d2a69485d055045f43 Binary files /dev/null and b/src/assets/bioAuthTouch.png differ diff --git a/src/assets/boatSpare.gif b/src/assets/boatSpare.gif new file mode 100644 index 0000000000000000000000000000000000000000..f0aaaa991168e07de3f56e5b129ae082094499f3 Binary files /dev/null and b/src/assets/boatSpare.gif differ diff --git a/src/assets/borderImage.png b/src/assets/borderImage.png new file mode 100644 index 0000000000000000000000000000000000000000..1c0ac6cdc5e2ef366679ecd0fb85f51c73ccc65f Binary files /dev/null and b/src/assets/borderImage.png differ diff --git a/src/assets/farmerSpare.gif b/src/assets/farmerSpare.gif new file mode 100644 index 0000000000000000000000000000000000000000..0f471d607f720952491c22b815486d4b58425b76 Binary files /dev/null and b/src/assets/farmerSpare.gif differ diff --git a/src/assets/finishLine.png b/src/assets/finishLine.png index f30b5a30213062013638f6e275673d65d0640777..9394bd3c85fed058ab7862e207667d0c9df00ff7 100644 Binary files a/src/assets/finishLine.png and b/src/assets/finishLine.png differ diff --git a/src/assets/flower.png b/src/assets/flower.png new file mode 100644 index 0000000000000000000000000000000000000000..c7481c94d5c7f137f0bfaad68fbf20b4d9396f9c Binary files /dev/null and b/src/assets/flower.png differ diff --git a/src/assets/infoIcon.png b/src/assets/infoIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..1aef35a260e16d428c804b38c584651fe42a1487 Binary files /dev/null and b/src/assets/infoIcon.png differ diff --git a/src/assets/pengesekkStreak.png b/src/assets/pengesekkStreak.png new file mode 100644 index 0000000000000000000000000000000000000000..54565d0fd1ad2795edeaab7eeb614e538d618dc2 Binary files /dev/null and b/src/assets/pengesekkStreak.png differ diff --git a/src/assets/savingsPathBg.png b/src/assets/savingsPathBg.png new file mode 100644 index 0000000000000000000000000000000000000000..fb1994e981fffba4df9736cd50ccd71d00e70340 Binary files /dev/null and b/src/assets/savingsPathBg.png differ diff --git a/src/components/ButtonDIsplayStreak.vue b/src/components/ButtonDIsplayStreak.vue deleted file mode 100644 index e5043e4fb746882e19dac98ed5cb8c42de707c1e..0000000000000000000000000000000000000000 --- a/src/components/ButtonDIsplayStreak.vue +++ /dev/null @@ -1,76 +0,0 @@ -<template> - <div class="flex flex-col items-center"> - <Span class="text-sm text-bold">STREAK</Span> - <button @click="toggleStreakCard" class="bg-transparent"> - <img src="@/assets/streak.png" alt="streak" class="mx-auto w-12 h-12" /> - </button> - - <div - v-if="displayStreakCard" - class="w-96 h-64 duration-500 group overflow-hidden absolute top-32 rounded bg-white-800 text-neutral-50 p-4 flex flex-col justify-evenly" - > - <div - class="absolute blur opacity-40 duration-500 group-hover:blur-none w-72 h-72 rounded-full group-hover:translate-x-12 group-hover:translate-y-12 bg-green-100 right-1 -bottom-24" - ></div> - <div - class="absolute blur opacity-40 duration-500 group-hover:blur-none w-12 h-12 rounded-full group-hover:translate-x-12 group-hover:translate-y-2 bg-green-300 right-12 bottom-12" - ></div> - <div - class="absolute blur opacity-40 duration-500 group-hover:blur-none w-36 h-36 rounded-full group-hover:translate-x-12 group-hover:-translate-y-12 bg-green-500 right-1 -top-12" - ></div> - <div - class="absolute blur opacity-40 duration-500 group-hover:blur-none w-24 h-24 bg-green-400 rounded-full group-hover:-translate-x-12" - ></div> - <div class="z-10 flex flex-col justify-evenly w-full h-full px-4"> - <span class="text-2xl font-bold text-black" - >{{ currentStreak }}{{ currentStreak === 1 ? ' dag' : ' dager' }} streak</span - > - <p class="text-black text-1xl font-bold"> - {{ - currentStreak > 0 - ? 'Bra jobba du har spart i ' + currentStreak + ' dager!' - : 'Du har ikke gjort noe i dag. Gjør noe nÃ¥ for Ã¥ starte en streak!' - }} - </p> - <!-- Row component with horizontal padding and auto margins for centering --> - <div - class="flex flex-row justify-content-between items-center h-20 w-full mx-auto bg-black-400 gap-4" - > - <div class="flex flex-1 overflow-x-auto"> - <div v-for="index in 6" :key="index" class="min-w-max mx-auto"> - <div class="flex flex-col items-center"> - <span class="text-black" - >Dag {{ currentStreak - ((currentStreak % 7) - index) }}</span - > - <!-- Conditional rendering for streak images --> - <img - v-if="index - 1 < currentStreak % 7" - src="@/assets/streak.png" - alt="challenge completed" - class="max-h-8 max-w-8" - /> - <img - v-else - src="@/assets/streak.png" - alt="challenge not completed" - class="max-h-8 max-w-8 grayscale" - /> - </div> - </div> - </div> - </div> - </div> - </div> - </div> -</template> - -<script setup lang="ts"> -import { ref } from 'vue' - -const displayStreakCard = ref(false) -const currentStreak = ref(20) - -function toggleStreakCard() { - displayStreakCard.value = !displayStreakCard.value -} -</script> diff --git a/src/components/ButtonDisplayStreak.vue b/src/components/ButtonDisplayStreak.vue new file mode 100644 index 0000000000000000000000000000000000000000..50ff4e445c0db7b234538d27824d34a65954824c --- /dev/null +++ b/src/components/ButtonDisplayStreak.vue @@ -0,0 +1,134 @@ +<template> + <div class="flex flex-col items-center absolute"> + <span class="text-sm text-bold">STREAK</span> + <button + @mouseover="display" + @mouseleave="hide" + class="cursor-pointer bg-transparent hover:bg-transparent hover:scale-150" + > + <img + src="@/assets/pengesekkStreak.png" + alt="streak" + class="mx-auto w-6 h-6 md:w-12 md:h-12" + /> + </button> + + <div + v-if="displayStreakCard" + class="w-[30vh] h-[20vh] md:w-auto md:h-auto group z-50 bg-opacity-50 overflow-hidden absolute left-0 top-14 md:top-20 flex flex-col justify-evenly text-wrap" + > + <div + class="flex flex-col justify-evenly w-full h-full py-2 px-4 md:py-0 bg-white rounded-2xl border-4 border-green-300" + > + <span class="text-xs md:text-2xl font-bold text-black" + >{{ currentStreak + }}{{ + currentStreak === 1 ? ' utfodring fullført' : ' utfodringer fullført' + }} + streak</span + > + <p class="text-black text-xs md:text-1xl md:font-bold"> + {{ + currentStreak! > 0 + ? 'Bra jobba du har fullført ' + currentStreak + ' utfordringer pÃ¥ rad!' + : 'Du har ikke fullført en utfordring det siste. Fullfør en nÃ¥ for Ã¥ starte en streak!' + }} + </p> + <Countdown + v-if="screenSize > 768 && currentStreak! > 0" + class="flex flex-row" + countdownSize="1rem" + labelSize=".5rem" + mainColor="white" + secondFlipColor="white" + mainFlipBackgroundColor="#30ab0e" + secondFlipBackgroundColor="#9af781" + :labels="{ days: 'dager', hours: 'timer', minutes: 'min', seconds: 'sek' }" + :deadlineISO="deadline" + ></Countdown> + <!-- Row component with horizontal padding and auto margins for centering --> + <div + class="flex flex-row items-center mx-auto h-20 w-4/5 md:w-full bg-black-400 gap-4" + > + <div class="flex flex-1 overflow-x-auto"> + <div v-for="index in 6" :key="index" class="min-w-max mx-auto"> + <div class="flex flex-col justify-around items-center"> + <span class="text-black text-xs md:text-1xl font-bold">{{ + currentStreak! - ((currentStreak! % 7) - index) + }}</span> + <!-- Conditional rendering for streak images --> + <img + v-if="index - 1 < currentStreak! % 7" + src="@/assets/pengesekkStreak.png" + alt="challenge completed" + class="max-h-6 max-w-6 md:max-h-10 md:max-w-10" + /> + <img + v-else + src="@/assets/pengesekkStreak.png" + alt="challenge not completed" + class="max-h-6 max-w-6 md:max-h-10 md:max-w-10 grayscale" + /> + </div> + </div> + </div> + </div> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { onMounted, onUnmounted, ref, watch } from 'vue' +import { useUserStore } from '@/stores/userStore' +// @ts-ignore +import { Countdown } from 'vue3-flip-countdown' + +const userStore = useUserStore() +const currentStreak = ref<number>() +const streakStart = ref<string>() +const deadline = ref<string>() +onMounted(async () => { + await userStore.getUserStreak() + if (userStore.streak) { + currentStreak.value = userStore.streak?.streak + streakStart.value = userStore.streak?.streakStart + deadline.value = userStore.streak?.streakStart + } + console.log('Streak:', currentStreak.value) + if (typeof window !== 'undefined') { + window.addEventListener('resize', handleWindowSizeChange) + } + handleWindowSizeChange() +}) + +const screenSize = ref<number>(window.innerWidth) + +onUnmounted(() => { + window.removeEventListener('resize', handleWindowSizeChange) +}) +const handleWindowSizeChange = () => { + screenSize.value = window.innerWidth +} + +watch( + () => currentStreak.value, + (newStreak, oldStreak) => { + if (newStreak !== oldStreak) { + currentStreak.value = newStreak + console.log('Updated Steak:', currentStreak) + } + }, + { immediate: true } +) + +const displayStreakCard = ref(false) + +const display = () => { + displayStreakCard.value = true +} + +const hide = () => { + displayStreakCard.value = false +} +</script> diff --git a/src/components/DisplayInfoForChallengeOrGoal.vue b/src/components/DisplayInfoForChallengeOrGoal.vue new file mode 100644 index 0000000000000000000000000000000000000000..b20221e248f67478e41e6df76fd9e8459645cc7d --- /dev/null +++ b/src/components/DisplayInfoForChallengeOrGoal.vue @@ -0,0 +1,87 @@ +<template> + <button @click="display" class="bg-transparent relative p-0 hover:bg-transparent"> + <img src="@/assets/infoIcon.png" alt="i" class="max-h-4 max-w-4 ml-1" /> + </button> + <div + v-if="displayInfoCard" + class="w-[40vh] h-[20vh]md:w-60 md:h-40 group z-50 bg-opacity-50 overflow-hidden absolute mt-8 md:mt-4 md:mr-0 flex flex-col justify-evenly text-wrap" + > + <div + class="flex flex-col justify-around w-3/4 md:w-full h-[80%] py-2 px-4 md:py-0 bg-white rounded-2xl border-4 border-green-300 overflow-auto" + > + <p class="text-base md:text-lg text-wrap text-bold">{{ title.toUpperCase() }}</p> + <p class="text-xs md:text-sm text-wrap mb-2">Beskrivelse: {{ description }}</p> + <p v-if="completion !== 100" class="text-xs md:text-sm text-nowrap text-green-800"> + Utløper om: + </p> + <Countdown + v-if="completion !== 100 && screenSize > 763" + class="flex flex-row" + countdownSize="1.3rem" + labelSize=".8rem" + mainColor="white" + secondFlipColor="white" + mainFlipBackgroundColor="#30ab0e" + secondFlipBackgroundColor="#9af781" + :labels="{ days: 'dager', hours: 'timer', minutes: 'min', seconds: 'sek' }" + :deadlineISO="deadline" + ></Countdown> + <Countdown + v-else-if="completion !== 100 && screenSize <= 763" + class="flex flex-row" + countdownSize="1.0rem" + labelSize=".6rem" + mainColor="white" + secondFlipColor="white" + mainFlipBackgroundColor="#30ab0e" + secondFlipBackgroundColor="#9af781" + :labels="{ days: 'dager', hours: 'timer', minutes: 'min', seconds: 'sek' }" + :deadlineISO="deadline" + ></Countdown> + <p class="text-nowrap text-xs md:text.sm" v-else> + Utfordring fullført.<br /> + Totalt spart: {{ amountSaved }}kr + </p> + </div> + </div> +</template> + +<script setup lang="ts"> +import type { Challenge } from '@/types/challenge' +import type { Goal } from '@/types/goal' +import { onUnmounted, ref } from 'vue' +// @ts-ignore +import { Countdown } from 'vue3-flip-countdown' + +interface Props { + challenge: Challenge | null | undefined + goal: Goal | null | undefined + isChallenge: boolean +} +const props = defineProps<Props>() + +const description = ref<string>( + props.isChallenge ? props.challenge!.description : props.goal!.description +) +const title = ref<string>(props.isChallenge ? props.challenge!.title : props.goal!.title) +const amountSaved = ref<number>(props.isChallenge ? props.challenge!.saved : props.goal!.saved) +const completion = ref<number>( + props.isChallenge ? props.challenge?.completion ?? 0 : props.goal?.completion ?? 0 +) +const deadline = ref<string>(props.isChallenge ? props.challenge!.due : props.goal!.due) + +const displayInfoCard = ref(false) + +const display = () => { + displayInfoCard.value = !displayInfoCard.value +} + +const screenSize = ref<number>(window.innerWidth) + +onUnmounted(() => { + window.removeEventListener('resize', handleWindowSizeChange) +}) +const handleWindowSizeChange = () => { + screenSize.value = window.innerWidth +} +</script> diff --git a/src/components/FormLogin.vue b/src/components/FormLogin.vue index 731e224e722324e37fb36ffd35f3bfe4ee2344f7..363580072249635f9a74c6a5e1e9ec0eac6cbdc3 100644 --- a/src/components/FormLogin.vue +++ b/src/components/FormLogin.vue @@ -15,11 +15,18 @@ const emailRegex = /^[a-zA-Z0-9_+&*-]+(?:\.[a-zA-Z0-9_+&*-]+)*@(?:[a-zA-Z0-9-]+\ const userStore = useUserStore() const isEmailValid = computed(() => emailRegex.test(resetEmail.value)) +const isSendingEmail = ref(false) +const successMessage = ref<string>('') +const modalErrorMessage = ref<string>('') const submitForm = () => { userStore.login(username.value, password.value) } +const bioLogin = () => { + userStore.bioLogin(username.value) +} + const toggleShowPassword = () => { showPassword.value = !showPassword.value } @@ -30,17 +37,36 @@ const openForgotPasswordModal = (event: MouseEvent) => { } const submitReset = async () => { - await axios.post('http://localhost:8080/forgotPassword/changePasswordRequest', { - email: resetEmail.value - }) - - resetEmail.value = '' - isModalOpen.value = false + isSendingEmail.value = true + modalErrorMessage.value = '' + successMessage.value = '' + try { + const response = await axios.post( + 'http://localhost:8080/forgotPassword/changePasswordRequest', + { + email: resetEmail.value + } + ) + successMessage.value = + 'E-posten er sendt. Vennligst sjekk innboksen din for instrukser. OBS: E-posten kan havne i spam-mappen' + isSendingEmail.value = false + setTimeout(() => { + isModalOpen.value = false + successMessage.value = '' + }, 5000) + } catch (error) { + console.error(error) + modalErrorMessage.value = 'Noe gikk galt. Vennligst prøv igjen.' + isSendingEmail.value = false + } } const closeModal = () => { - resetEmail.value = '' isModalOpen.value = false + isSendingEmail.value = false + modalErrorMessage.value = '' + resetEmail.value = '' + successMessage.value = '' } watch( @@ -95,36 +121,58 @@ watch( Logg inn </button> <p>{{ errorMessage }}</p> + <button @click="bioLogin">biologin</button> </div> </div> <modal-component :title="'Glemt passord'" :message="'Vennligst skriv inn e-posten din for Ã¥ endre passordet.'" :is-modal-open="isModalOpen" - @close="isModalOpen = false" + @close="closeModal" > <template v-slot:input> - <input - type="email" - v-model="resetEmail" - class="border border-gray-300 p-2 w-full mb-7" - placeholder="Skriv e-postadressen din her" - /> - </template> - <template v-slot:buttons> - <button - :disabled="!isEmailValid" - @click="submitReset" - class="active-button font-bold py-2 px-4 w-1/2 border-2 disabled:border-transparent" - > - Send mail - </button> - <button - @click="closeModal" - class="active-button font-bold py-2 px-4 w-1/2 border-2 disabled:border-transparent" - > - Lukk - </button> + <div v-if="isSendingEmail" class="flex justify-center items-center"> + <div + class="p-3 animate-spin drop-shadow-2xl bg-gradient-to-r from-lime-500 from-30% to-green-600 to-90% md:w-18 md:h-20 h-20 w-20 aspect-square rounded-full" + > + <div class="rounded-full h-full w-full bg-slate-100 background-blur-md"></div> + </div> + </div> + <div v-else-if="successMessage"> + <p class="text-green-500 text-center">{{ successMessage }}</p> + </div> + <div v-else-if="modalErrorMessage"> + <p class="text-red-500 text-center">{{ modalErrorMessage }}</p> + <button + @click="closeModal" + class="active-button font-bold py-2 px-4 w-1/2 mt-4 border-2 disabled:border-transparent" + > + Lukk + </button> + </div> + <div v-else> + <input + type="email" + v-model="resetEmail" + class="border border-gray-300 p-2 w-full mb-7" + placeholder="Skriv e-postadressen din her" + /> + <div class="flex gap-5 mt-4"> + <button + :disabled="!isEmailValid" + @click="submitReset" + class="active-button font-bold py-2 px-4 w-1/2 border-2 disabled:border-transparent" + > + Send mail + </button> + <button + @click="closeModal" + class="active-button font-bold py-2 px-4 w-1/2 border-2 disabled:border-transparent" + > + Lukk + </button> + </div> + </div> </template> </modal-component> </template> diff --git a/src/components/ImgGifTemplate.vue b/src/components/ImgGifTemplate.vue new file mode 100644 index 0000000000000000000000000000000000000000..46fecc79efe8cca7f8427a1a1e851e0c0ca15b79 --- /dev/null +++ b/src/components/ImgGifTemplate.vue @@ -0,0 +1,20 @@ +<template> + <div class="hover:scale-125"> + <img + v-if="index % 6 === modValue" + :src="url" + alt="could not load" + class="h-32 w-32 border-2 rounded-lg border-stale-400 shadow-md shadow-black" + /> + </div> +</template> + +<script setup lang="ts"> +interface Props { + url: string + index: number + modValue: number +} + +defineProps<Props>() +</script> diff --git a/src/components/NavBarComponent.vue b/src/components/NavBarComponent.vue index f73a9e94cf8f39d898f8641c099b9ccc91f0506c..d801c739ea7ab26801c7215d1fee8fe05070c038 100644 --- a/src/components/NavBarComponent.vue +++ b/src/components/NavBarComponent.vue @@ -10,8 +10,7 @@ </router-link> <div class="flex flex-row justify-center"> - <img alt="streak" class="w-8 h-8" src="@/assets/streakFlame.png" /> - <p class="font-bold">Streak</p> + <ButtonDisplayStreak></ButtonDisplayStreak> </div> </div> <div v-if="!isHamburger" class="flex flex-row gap-10"> @@ -68,6 +67,7 @@ import { RouterLink } from 'vue-router' import { onMounted, ref } from 'vue' import { useUserStore } from '@/stores/userStore' import ModalComponent from '@/components/ModalComponent.vue' +import ButtonDisplayStreak from '@/components/ButtonDisplayStreak.vue' const userStore = useUserStore() @@ -95,7 +95,9 @@ const updateWindowWidth = () => { } onMounted(() => { - window.addEventListener('resize', updateWindowWidth) + if (typeof window !== 'undefined') { + window.addEventListener('resize', updateWindowWidth) + } updateWindowWidth() }) diff --git a/src/components/SavingsPath.vue b/src/components/SavingsPath.vue index b71a11bb1b657160eea30453ff91600acded5f0f..5e82a770cc22e2a37bab3fa8752ce7a4b0e858b1 100644 --- a/src/components/SavingsPath.vue +++ b/src/components/SavingsPath.vue @@ -1,6 +1,6 @@ <template> <div - class="flex flex-col basis-2/3 max-h-full mx-auto max-w-5/6 md:basis-3/4 md:pr-20 md:max-mr-20" + class="flex flex-col basis-2/3 max-h-full mx-auto md:ml-20 md:mr-2 max-w-5/6 md:basis-3/4 md:max-pr-20 md:pr-10 md:max-mr-20" > <div class="flex justify-center align-center"> <span @@ -9,18 +9,28 @@ Din Sparesti </span> </div> + <button + class="h-auto w-auto absolute flex text-center self-end mr-10 md:mr-20 text-wrap shadow-sm shadow-black sm:top-50 sm:text-xs sm:mr-20 lg:mr-32 top-60 z-50 p-2 text-xs md:text-sm" + @click="scrollToFirstUncompleted" + v-show="!isAtFirstUncompleted" + > + Ufullførte utfordringer<br />↓ + </button> <div class="h-1 w-4/6 mx-auto my-2 opacity-10"></div> <div ref="containerRef" - class="container relative mx-auto pt-6 w-4/5 md:w-3/5 no-scrollbar h-full max-h-[60vh] md:max-h-[60v] overflow-y-auto border-2 border-slate-300 rounded-lg bg-white shadow-lg" + class="container relative pt-6 w-4/5 bg-cover bg-[center] md:[background-position: center;] mx-auto md:w-4/5 no-scrollbar h-full max-h-[60vh] md:max-h-[60vh] md:min-w-2/5 overflow-y-auto border-2 border-transparent rounded-xl bg-white shadow-lg shadow-slate-400" + style="background-image: url('src/assets/backgroundSavingsPath.png')" > <div> <img src="@/assets/start.png" alt="Spare" class="md:w-1/6 md:h-auto h-20" /> </div> + <div v-for="(challenge, index) in challenges" - :key="challenge.title" + :key="challenge.id" class="flex flex-col items-center" + :ref="(el) => assignRef(el, challenge, index)" > <!-- Challenge Row --> <div @@ -31,31 +41,44 @@ class="flex flex-row w-4/5 gap-8" > <div class="right-auto just"> - <img - v-if="index === 3" - src="@/assets/sleepingSpare.gif" - alt="could not load" - class="w-32 h-32 border-2 rounded-lg border-stale-400" - /> - <img - v-else-if="index === 1" - src="@/assets/golfSpare.gif" - alt="could not load" - class="w-32 h-32 border-2 rounded-lg border-stale-400" - /> + <img-gif-template + :index="index" + :mod-value="1" + url="src/assets/golfSpare.gif" + ></img-gif-template> + <img-gif-template + :index="index" + :mod-value="3" + url="src/assets/sleepingSpare.gif" + ></img-gif-template> + <img-gif-template + :index="index" + :mod-value="5" + url="src/assets/archerSpare.gif" + ></img-gif-template> </div> <!-- Challenge Icon and Details --> <div class="flex"> <!-- Challenge Icon --> - <div class="flex flex-col items-center"> - <p class="text-center" data-cy="challenge-title"> - {{ challenge.title }} - </p> + <div class="flex flex-col items-center gap-4"> + <div class="flex flex-row flex-nowrap"> + <p + class="text-center text-wrap text-xs md:text-lg" + data-cy="challenge-title" + > + {{ challenge.title }} + </p> + <display-info-for-challenge-or-goal + :goal="goal" + :challenge="challenge" + :is-challenge="true" + ></display-info-for-challenge-or-goal> + </div> <img @click="editChallenge(challenge)" :data-cy="'challenge-icon-' + challenge.id" :src="getChallengeIcon(challenge)" - class="max-w-20 max-h-20 cursor-pointer" + class="max-w-20 max-h-20 cursor-pointer hover:scale-125" :alt="challenge.title" /> <!-- Progress Bar, if the challenge is not complete --> @@ -65,7 +88,7 @@ " class="flex-grow w-full mt-2" > - <div class="flex flex-row"> + <div class="flex flex-row ml-5 md:ml-10 justify-center"> <div class="flex flex-col"> <div class="bg-gray-200 rounded-full h-2.5 dark:bg-gray-700" @@ -80,7 +103,7 @@ }" ></div> </div> - <div class="text-center"> + <div class="text-center text-xs md:text-base"> {{ challenge.saved }}kr / {{ challenge.target }}kr </div> </div> @@ -95,12 +118,14 @@ </button> </div> </div> - <span v-else class="text-center">Ferdig: {{ challenge.saved }}</span> + <span v-else class="text-center text-xs md:text-base" + >Ferdig: {{ challenge.saved }}</span + > </div> <!-- Check Icon --> <div v-if="challenge.completion !== undefined && challenge.completion >= 100" - class="max-w-10 max-h-10" + class="md:max-w-10 min-w-4 max-w-6 max-h-6 w-full h-auto md:max-h-10 min-h-4" > <img src="@/assets/completed.png" alt="" />ï¸ </div> @@ -109,18 +134,21 @@ </div> </div> <div class=""> - <img - v-if="index === 0" - src="@/assets/cowboySpare.gif" - alt="could not load" - class="h-32 w-32 border-2 rounded-lg border-stale-400" - /> - <img - v-else-if="index === 2" - src="@/assets/hotAirBalloonSpare.gif" - class="h-32 w-32 border-stale-400 border-2 rounded-lg" - alt="could not load" - /> + <img-gif-template + :index="index" + :mod-value="0" + url="src/assets/cowboySpare.gif" + ></img-gif-template> + <img-gif-template + :index="index" + :mod-value="2" + url="src/assets/hotAirBalloonSpare.gif" + ></img-gif-template> + <img-gif-template + :index="index" + :mod-value="4" + url="src/assets/farmerSpare.gif" + ></img-gif-template> </div> </div> <!-- Piggy Steps, centered --> @@ -155,9 +183,17 @@ </div> <!-- Goal --> <div v-if="goal" class="flex flex-row justify-around m-t-2 pt-6 w-full mx-auto"> - <div class="flex flex-col items-start cursor-pointer" @click="editGoal(goal)"> - <img :src="getGoalIcon(goal)" class="w-12 h-12 mx-auto" :alt="goal.title" /> - <div class="text-lg font-bold" data-cy="goal-title">{{ goal.title }}</div> + <div class="grid grid-rows-2 grid-flow-col gap 4"> + <div class="row-span-3 cursor-pointer" @click="editGoal(goal)"> + <img :src="getGoalIcon(goal)" class="w-12 h-12 mx-auto" :alt="goal.title" /> + <div class="text-lg font-bold" data-cy="goal-title">{{ goal.title }}</div> + </div> + <display-info-for-challenge-or-goal + class="col-span-2" + :goal="goal" + :challenge="null" + :is-challenge="false" + ></display-info-for-challenge-or-goal> </div> <div class="flex flex-col items-end"> <div @click="goToEditGoal" class="cursor-pointer"> @@ -182,7 +218,16 @@ </template> <script setup lang="ts"> -import { nextTick, onMounted, type Ref, ref, watch } from 'vue' +import { + type ComponentPublicInstance, + nextTick, + onMounted, + onUnmounted, + reactive, + type Ref, + ref, + watch +} from 'vue' import anime from 'animejs' import type { Challenge } from '@/types/challenge' import type { Goal } from '@/types/goal' @@ -190,6 +235,8 @@ import confetti from 'canvas-confetti' import { useRouter } from 'vue-router' import { useGoalStore } from '@/stores/goalStore' import { useChallengeStore } from '@/stores/challengeStore' +import DisplayInfoForChallengeOrGoal from '@/components/DisplayInfoForChallengeOrGoal.vue' +import ImgGifTemplate from '@/components/ImgGifTemplate.vue' const router = useRouter() const goalStore = useGoalStore() @@ -204,6 +251,110 @@ const props = defineProps<Props>() const challenges = ref<Challenge[]>(props.challenges) const goal = ref<Goal | null | undefined>(props.goal) +onMounted(async () => { + await goalStore.getUserGoals() + window.addEventListener('resize', handleWindowSizeChange) + handleWindowSizeChange() + sortChallenges() +}) + +const sortChallenges = () => { + challenges.value.sort((a, b) => { + // First, sort by completion status: non-completed (less than 100) before completed (100) + if (a.completion !== 100 && b.completion === 100) { + return 1 // 'a' is not completed and 'b' is completed, 'a' should come first + } else if (a.completion === 100 && b.completion !== 100) { + return -1 // 'a' is completed and 'b' is not, 'b' should come first + } else { + // Explicitly convert dates to numbers for subtraction + const dateA = new Date(a.due).getTime() + const dateB = new Date(b.due).getTime() + return dateA - dateB + } + }) +} + +const screenSize = ref<number>(window.innerWidth) + +onUnmounted(() => { + window.removeEventListener('resize', handleWindowSizeChange) +}) +const handleWindowSizeChange = () => { + screenSize.value = window.innerWidth +} + +interface ElementRefs { + [key: string]: HTMLElement | undefined +} + +const elementRefs = reactive<ElementRefs>({}) + +const isAtFirstUncompleted = ref(false) // This state tracks visibility of the button +const firstUncompletedRef: Ref<HTMLElement | undefined> = ref() + +function scrollToFirstUncompleted() { + let found = false + for (let i = 0; i < challenges.value.length; i++) { + if (challenges.value[i].completion! < 100) { + const refKey = `uncompleted-${i}` + if (elementRefs[refKey]) { + elementRefs[refKey]!.scrollIntoView({ behavior: 'smooth', block: 'start' }) + firstUncompletedRef.value = elementRefs[refKey] // Store the reference + found = true + isAtFirstUncompleted.value = true + break + } + } + } + if (!found) { + isAtFirstUncompleted.value = false + } +} + +onMounted(() => { + const container = containerRef.value + if (container) { + container.addEventListener('scroll', () => { + if (!firstUncompletedRef.value) return + const containerRect = container.getBoundingClientRect() + const firstUncompletedRect = firstUncompletedRef.value.getBoundingClientRect() + isAtFirstUncompleted.value = !( + firstUncompletedRect.top > containerRect.bottom || + firstUncompletedRect.bottom < containerRect.top + ) + }) + } + scrollToFirstUncompleted() +}) + +onUnmounted(() => { + const container = containerRef.value + if (container) { + container.removeEventListener('scroll', () => { + // Clean up the scroll listener + }) + } +}) + +const assignRef = ( + el: Element | ComponentPublicInstance | null, + challenge: Challenge, + index: number +) => { + const refKey = `uncompleted-${index}` + if (el instanceof HTMLElement) { + // Ensure that el is an HTMLElement + if (challenge.completion! < 100) { + elementRefs[refKey] = el + } + } else { + // Cleanup if the element is unmounted or not an HTMLElement + if (elementRefs[refKey]) { + delete elementRefs[refKey] + } + } +} + // Utilizing watch to specifically monitor for changes in the props watch( () => props.goal, @@ -221,6 +372,7 @@ watch( (newChallenges, oldChallenges) => { if (newChallenges !== oldChallenges) { challenges.value = newChallenges + sortChallenges() console.log('Updated challenges:', challenges.value) } }, @@ -243,15 +395,13 @@ const addSpareUtfordring = () => { // Increment saved amount const incrementSaved = async (challenge: Challenge) => { - // Set a default increment amount per purchase - challenge.perPurchase = 20 - // Safely increment the saved amount, ensuring it exists challenge.saved += challenge.perPurchase // Check if the saved amount meets or exceeds the target if (challenge.saved >= challenge.target) { challenge.completion = 100 + await challengeStore.completeUserChallenge(challenge) } console.log('Incrementing saved amount for:', challenge) diff --git a/src/components/__tests__/NavBarTest.spec.ts b/src/components/__tests__/NavBarTest.spec.ts index b31870d5340b4f2fe9642c8b74116ced1360c96f..a04985617f5d080bd2997f743beab7723f2a47e5 100644 --- a/src/components/__tests__/NavBarTest.spec.ts +++ b/src/components/__tests__/NavBarTest.spec.ts @@ -2,9 +2,30 @@ 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' +import { nextTick } from 'vue' vi.stubGlobal('scrollTo', vi.fn()) +// Mocking Axios correctly using `importOriginal` +const mocks = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn() +})) + +vi.mock('axios', async (importActual) => { + const actual = await importActual<typeof import('axios')>() + + return { + default: { + ...actual.default, + create: vi.fn(() => ({ + ...actual.default.create(), + get: mocks.get, + post: mocks.post + })) + } + } +}) describe('NavBar Routing', () => { let wrapper: VueWrapper<any> @@ -21,6 +42,7 @@ describe('NavBar Routing', () => { await router.push('/') await router.isReady() + await nextTick() }) it('renders without errors', () => { diff --git a/src/components/__tests__/savingsPathTest.spec.ts b/src/components/__tests__/savingsPathTest.spec.ts index af21d03c1b80df3ae2e90e8b92bd8e1782df544f..40739f5a4e0a9b042239fadd17c40fe3ae9c6a61 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' @@ -11,12 +11,32 @@ vi.mock('canvas-confetti', () => ({ clear: vi.fn() })) })) +const mocks = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn() +})) + +vi.mock('axios', async (importActual) => { + const actual = await importActual<typeof import('axios')>() + + return { + default: { + ...actual.default, + create: vi.fn(() => ({ + ...actual.default.create(), + get: mocks.get, + post: mocks.post + })) + } + } +}) describe('SavingsPath Component', () => { let wrapper: any const pinia = createPinia() beforeEach(() => { + window.HTMLElement.prototype.scrollIntoView = function () {} setActivePinia(pinia) wrapper = mount(SavingsPath, { global: { @@ -94,6 +114,7 @@ describe('SavingsPath Component', () => { ] }) await wrapper.vm.$nextTick() + await wrapper.vm.$nextTick() progressBar = wrapper.find('.bg-green-600') expect(progressBar.element.style.width).toBe('90%') diff --git a/src/router/index.ts b/src/router/index.ts index c80ad8b511e430bf1f1a6bec2b83e82e929f4641..5f32e18fa4bcb61cf7ad48446e11dea346712c49 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', @@ -117,6 +122,11 @@ const router = createRouter({ path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/views/NotFoundView.vue') + }, + { + path: '/addAlternativeLogin', + name: 'addAlternativeLogin', + component: () => import('@/views/AddAlternativeLogin.vue') } ], scrollBehavior() { diff --git a/src/stores/challengeStore.ts b/src/stores/challengeStore.ts index 8e4529c5cd004d75a741cd132bc35fa7d7e80310..a8417aa62bf51495a6a06b401145b0fe06f34a06 100644 --- a/src/stores/challengeStore.ts +++ b/src/stores/challengeStore.ts @@ -41,10 +41,31 @@ export const useChallengeStore = defineStore('challenge', () => { console.error('Error updating challenge:', error) } } + const completeUserChallenge = async (challenge: Challenge) => { + try { + const response = await authInterceptor.put( + `/challenges/${challenge.id}/complete`, + challenge + ) + if (response.data) { + // Update local challenge state to reflect changes + const index = challenges.value.findIndex((c) => c.id === challenge.id) + if (index !== -1) { + challenges.value[index] = { ...challenges.value[index], ...response.data } + console.log('Updated Challenge:', response.data) + } + } else { + console.error('No challenge content found in response data') + } + } catch (error) { + console.error('Error updating challenge:', error) + } + } return { challenges, getUserChallenges, - editUserChallenge + editUserChallenge, + completeUserChallenge } }) diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts index fe31bd1a782e8feadd79d12e5fc3338ade7f9f93..eff4fd1479cd1bf9b70c10e6650a054ae0b6321c 100644 --- a/src/stores/userStore.ts +++ b/src/stores/userStore.ts @@ -4,6 +4,11 @@ import type { User } from '@/types/user' import router from '@/router' import type { AxiosError } from 'axios' import axios from 'axios' +import authInterceptor from '@/services/authInterceptor' +import type { Streak } from '@/types/streak' +import type { CredentialRequestOptions } from '@/types/CredentialRequestOptions' +import { base64urlToUint8array, initialCheckStatus, uint8arrayToBase64url } from '@/util' +import type { CredentialCreationOptions } from '@/types/CredentialCreationOptions' export const useUserStore = defineStore('user', () => { const defaultUser: User = { @@ -14,6 +19,7 @@ export const useUserStore = defineStore('user', () => { const user = ref<User>(defaultUser) const errorMessage = ref<string>('') + const streak = ref<Streak>() const register = async ( firstname: string, @@ -38,7 +44,7 @@ export const useUserStore = defineStore('user', () => { user.value.lastname = lastname user.value.username = username - router.push({ name: 'configurations1' }) + router.push({ name: 'addAlternativeLogin' }) }) .catch((error) => { const axiosError = error as AxiosError @@ -75,11 +81,163 @@ export const useUserStore = defineStore('user', () => { user.value = defaultUser router.push({ name: 'login' }) } + const getUserStreak = async () => { + try { + const response = await authInterceptor('/profile/streak') + if (response.data) { + streak.value = response.data + console.log('Fetched Challenges:', streak.value) + } else { + streak.value = undefined + console.error('No challenge content found:', response.data) + } + } catch (error) { + console.error('Error fetching challenges:', error) + streak.value = undefined // Ensure challenges is always an array + } + } + + const bioRegister = async () => { + try { + const response = await authInterceptor.post('/auth/bioRegistration') + initialCheckStatus(response) + + const credentialCreateJson: CredentialCreationOptions = response.data + + const credentialCreateOptions: CredentialCreationOptions = { + publicKey: { + ...credentialCreateJson.publicKey, + challenge: base64urlToUint8array( + credentialCreateJson.publicKey.challenge as unknown as string + ), + user: { + ...credentialCreateJson.publicKey.user, + id: base64urlToUint8array( + credentialCreateJson.publicKey.user.id as unknown as string + ) + }, + excludeCredentials: credentialCreateJson.publicKey.excludeCredentials?.map( + (credential) => ({ + ...credential, + id: base64urlToUint8array(credential.id as unknown as string) + }) + ), + extensions: credentialCreateJson.publicKey.extensions + } + } + + const publicKeyCredential = (await navigator.credentials.create( + credentialCreateOptions + )) as PublicKeyCredential + + const publicKeyResponse = + publicKeyCredential.response as AuthenticatorAttestationResponse + const encodedResult = { + type: publicKeyCredential.type, + id: publicKeyCredential.id, + response: { + attestationObject: uint8arrayToBase64url(publicKeyResponse.attestationObject), + clientDataJSON: uint8arrayToBase64url(publicKeyResponse.clientDataJSON), + transports: publicKeyResponse.getTransports?.() || [] + }, + clientExtensionResults: publicKeyCredential.getClientExtensionResults() + } + + await authInterceptor + .post('/auth/finishBioRegistration', { credential: JSON.stringify(encodedResult) }) + .then((response) => { + router.push({ name: 'configurations1' }) + }) + } catch (error) { + router.push({ name: 'configurations1' }) + console.error(error) + } + } + + const bioLogin = async (username: string) => { + try { + const request = await axios.post(`http://localhost:8080/auth/bioLogin/${username}`) + + initialCheckStatus(request) + console.log(request) + + const credentialGetJson: CredentialRequestOptions = request.data + console.log(credentialGetJson) + + const credentialGetOptions: CredentialRequestOptions = { + publicKey: { + ...credentialGetJson.publicKey, + allowCredentials: + credentialGetJson.publicKey.allowCredentials && + credentialGetJson.publicKey.allowCredentials.map((credential) => ({ + ...credential, + id: base64urlToUint8array(credential.id as unknown as string) + })), + challenge: base64urlToUint8array( + credentialGetJson.publicKey.challenge as unknown as string + ), + extensions: credentialGetJson.publicKey.extensions + } + } + + const publicKeyCredential = (await navigator.credentials.get( + credentialGetOptions + )) as PublicKeyCredential + + // Extract response data based on the type of credential + const response = publicKeyCredential.response as AuthenticatorAssertionResponse + + const encodedResult = { + type: publicKeyCredential.type, + id: publicKeyCredential.id, + response: { + authenticatorData: + response.authenticatorData && + uint8arrayToBase64url(response.authenticatorData), + clientDataJSON: + response.clientDataJSON && uint8arrayToBase64url(response.clientDataJSON), + + signature: response.signature && uint8arrayToBase64url(response.signature), + userHandle: response.userHandle && uint8arrayToBase64url(response.userHandle) + }, + clientExtensionResults: publicKeyCredential.getClientExtensionResults() + } + console.log(encodedResult) + + await axios + .post(`http://localhost:8080/auth/finishBioLogin/${username}`, { + credential: JSON.stringify(encodedResult) + }) + .then((response) => { + sessionStorage.setItem('accessToken', response.data.accessToken) + localStorage.setItem('refreshToken', response.data.refreshToken) + + user.value.firstname = response.data.firstName + user.value.lastname = response.data.lastName + user.value.username = response.data.username + + router.push({ name: 'home' }) + }) + .catch((error) => { + const axiosError = error as AxiosError + errorMessage.value = + (axiosError.response?.data as string) || 'An error occurred' + console.log('hei :' + errorMessage.value) + }) + } catch (error) { + // Handle errors + console.log(error) + } + } return { register, login, logout, - errorMessage + bioLogin, + bioRegister, + errorMessage, + getUserStreak, + streak } }) diff --git a/src/types/CredentialCreationOptions.ts b/src/types/CredentialCreationOptions.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6fdd7a76510ca4033a1c763a2ca9545797ab1b5 --- /dev/null +++ b/src/types/CredentialCreationOptions.ts @@ -0,0 +1,3 @@ +export interface CredentialCreationOptions { + publicKey: PublicKeyCredentialCreationOptions +} diff --git a/src/types/CredentialRequestOptions.ts b/src/types/CredentialRequestOptions.ts new file mode 100644 index 0000000000000000000000000000000000000000..4349a83cdfde9e60fe294adeef87dd4f1792087c --- /dev/null +++ b/src/types/CredentialRequestOptions.ts @@ -0,0 +1,3 @@ +export interface CredentialRequestOptions { + publicKey: PublicKeyCredentialRequestOptions +} 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/types/streak.ts b/src/types/streak.ts new file mode 100644 index 0000000000000000000000000000000000000000..61dafae24c954c7ca99a3596ca43a659f1ef122c --- /dev/null +++ b/src/types/streak.ts @@ -0,0 +1,4 @@ +export interface Streak { + streakStart?: string + streak?: number +} diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f6cbfc818ab356135d6acf232024019ea4fafe0 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,33 @@ +import base64js from 'base64-js' +import { type AxiosResponse } from 'axios' + +export function base64urlToUint8array(base64Bytes: string): Uint8Array { + const padding = '===='.substring(0, (4 - (base64Bytes.length % 4)) % 4) + return base64js.toByteArray((base64Bytes + padding).replace(/\//g, '_').replace(/\+/g, '-')) +} + +export function uint8arrayToBase64url(bytes: Uint8Array | ArrayBuffer | number[]): string { + if (bytes instanceof Uint8Array) { + return base64js + .fromByteArray(bytes) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') + } else { + return uint8arrayToBase64url(new Uint8Array(bytes)) + } +} + +export function checkStatus(response: AxiosResponse<any>): AxiosResponse<any> | undefined { + if (response.status !== 200) { + console.log('an error occurred: ', response.statusText) + return undefined + } else { + return response + } +} + +export function initialCheckStatus(response: AxiosResponse<any>): any { + checkStatus(response) + return response.data +} diff --git a/src/utilo.js b/src/utilo.js new file mode 100644 index 0000000000000000000000000000000000000000..4125c85af520a7d429032a90f80c9a4611f07740 --- /dev/null +++ b/src/utilo.js @@ -0,0 +1,28 @@ +import base64js from 'base64-js' + +export function base64urlToUint8array(base64Bytes) { + const padding = '===='.substring(0, (4 - (base64Bytes.length % 4)) % 4) + return base64js.toByteArray((base64Bytes + padding).replace(/\//g, '_').replace(/\+/g, '-')) +} +export function uint8arrayToBase64url(bytes) { + if (bytes instanceof Uint8Array) { + return base64js + .fromByteArray(bytes) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') + } else { + return uint8arrayToBase64url(new Uint8Array(bytes)) + } +} +export function checkStatus(response) { + if (response.status !== 200) { + console.log('an error occurred: ', response.body) + } else { + return response + } +} +export function initialCheckStatus(response) { + checkStatus(response) + return response.data +} diff --git a/src/views/AddAlternativeLogin.vue b/src/views/AddAlternativeLogin.vue new file mode 100644 index 0000000000000000000000000000000000000000..554f09f25915282f5675244784a6bb212457d036 --- /dev/null +++ b/src/views/AddAlternativeLogin.vue @@ -0,0 +1,48 @@ +<template> + <div class="alt-login-main"> + <h1>Alternativ innlogging</h1> + <div class="img-div"> + <img src="@/assets/bioAuthTouch.png" alt="bioAuthTouch" /> + <img src="@/assets/bioAuthFace.png" alt="bioAuthFace" /> + </div> + <h2>Vil du logge pÃ¥ med touch eller face id?</h2> + <div class="btn-div"> + <button @click="router.push('konfigurasjonSteg1')">Senere</button> + <button @click="userStore.bioRegister()">OK</button> + </div> + </div> +</template> +<script setup lang="ts"> +import { useUserStore } from '@/stores/userStore' +import router from '@/router' + +const userStore = useUserStore() +</script> + +<style scoped> +.alt-login-main { + max-width: 800px; + display: flex; + flex-direction: column; + align-items: center; +} + +.img-div { + display: flex; + justify-content: center; +} + +img { + width: 30%; +} + +img:first-child { + margin-right: 20px; +} + +button { + margin: 10px; + width: 100px; + height: 40px; +} +</style> 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/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>