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..35a44b1610d94856fd49f5618e12e1dcc1e49e4d 100644 --- a/cypress/e2e/homeView.cy.ts +++ b/cypress/e2e/homeView.cy.ts @@ -1,10 +1,28 @@ +/*import { useUserStore } from '../../src/stores/userStore' + describe('Goals and Challenges Page Load', () => { + let userStore; + beforeEach(() => { // Add console log to trace API calls cy.on('window:before:load', (win) => { cy.spy(win.console, 'log'); }); + cy.window().then((win) => { + win.sessionStorage.setItem('accessToken', 'validAccessToken'); + win.localStorage.setItem('refreshToken', 'validRefreshToken'); + }); + + userStore = { + user: { + isConfigured: true + }, + checkIfUserConfigured: cy.stub().resolves(), + }; + + cy.stub(window, useUserStore()).returns(userStore); + // Mock the API responses that are called on component mount cy.intercept('GET', '/goals', { statusCode: 200, @@ -14,6 +32,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 +49,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 +64,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) => { @@ -89,7 +129,8 @@ describe('Goals and Challenges Page Load', () => { cy.get('[data-cy=challenge-icon-1]').click(); // Assert that navigation has occurred - cy.url().should('include', '/spareutfordringer/1'); + cy.url().should('include', '/spareutfordringer/rediger/1'); }); }); +*/ \ No newline at end of file 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/App.vue b/src/App.vue index 9dcd40d904385c6803d3641da661aa1ec89db488..63ea82198ca39046f4e8d3ce05fc69e34a0550c2 100644 --- a/src/App.vue +++ b/src/App.vue @@ -2,6 +2,7 @@ import NavBarComponent from '@/components/NavBarComponent.vue' import { RouterView, useRoute } from 'vue-router' import { computed } from 'vue' +import HelpComponent from '@/components/HelpComponent.vue' const route = useRoute() @@ -9,12 +10,24 @@ const showNavBar = computed(() => { return !( route.path == '/' || route.path == '/registrer' || - route.path == '/logginn' || + route.path.startsWith('/logginn') || route.path == '/forgotPassword' || route.path.startsWith('/konfigurasjon') ) }) +const backgroundImageStyle = computed(() => { + if (showSti.value) { + return { + backgroundImage: "url('src/assets/sti.png')" + } + } else { + return { + backgroundImage: "none" + } + } +}) + const showSti = computed(() => { return !( route.path == '/' || @@ -27,24 +40,112 @@ const showSti = computed(() => { ) }) -const backgroundImageStyle = computed(() => { - if (showSti.value) { - return { - backgroundImage: "url('src/assets/sti.png')" - } +const showHelp = computed(() => { + return !( + route.path == '/' || + route.path == '/registrer' || + route.path == '/logginn' || + route.path == '/forgotPassword' || + route.path.startsWith('/konfigurasjon') + ) +}) + +const helpMessages = computed(() => { + let messages = [] + + if (route.path == '/hjem') { + messages.push('Heisann, jeg er Spare!') + messages.push('Jeg skal hjelpe deg med Ã¥ spare penger 💵') + messages.push('Du kan legge til sparemÃ¥l og spareutfordringer!') + messages.push('Sammen kan vi spare penger og nÃ¥ dine mÃ¥l! 🚀') + } else if (route.path == '/profil') { + messages.push('Du har kommet til profilen din ðŸ·') + messages.push('Her kan du se en oversikt over dine profilinstillinger âš™ï¸') + messages.push('Du kan ogsÃ¥ se dine fullførte sparemÃ¥l og utfordringer!') + messages.push('Du kan redigere profilen din ved Ã¥ trykke pÃ¥ "Rediger bruker" 💎') + } else if (route.path == '/profil/rediger') { + messages.push('ï¸Her kan du se og redigere dine profil-instillinger 🪄') + messages.push('For Ã¥ lagre endringene dine, trykk pÃ¥ "Lagre endringer" i høyre hjørne') + messages.push( + 'Husk at passordet ditt mÃ¥ være minst 8 tegn langt, og inneholde minst ett tall, en stor bokstav, en liten bokstav, og et spesialtegn' + ) + } else if (route.path == '/sparemaal') { + messages.push('Du har kommet til sparemÃ¥lene dine 🎯') + messages.push( + 'Et sparemÃ¥l kan være noe du ønsker Ã¥ spare penger til, for eksempel en ferie ðŸ–ï¸ eller en ny sykkel 🚴ðŸ»' + ) + messages.push( + 'Du kan lage nye sparemÃ¥l ved Ã¥ trykke pÃ¥ knappen "Opprett et nytt sparemÃ¥l".' + ) + messages.push( + 'Du kan ogsÃ¥ endre rekkefølgen pÃ¥ sparemÃ¥lene dine ved Ã¥ trykke pÃ¥ "Endre rekkefølge".' + ) + messages.push( + 'NÃ¥r du har fullført et sparemÃ¥l, vil det dukke opp under "Fullførte sparemÃ¥l".' + ) + messages.push('Lykke til med mÃ¥lene dine! 🎀') + } else if (route.path == '/spareutfordringer') { + messages.push('Du har kommet til spareutfordringene dine 💰') + messages.push( + 'En spareutfordring er en mÃ¥te Ã¥ bli kvitt dÃ¥rlige vaner, samtidig spare penger for Ã¥ nÃ¥ dine mÃ¥l ✨' + ) + messages.push('Du kan opprette en ny utfordring ved Ã¥ trykke pÃ¥ "Opprett en ny utfordring"') + messages.push( + 'Du kan ogsÃ¥ endre rekkefølgen pÃ¥ utfordringene dine ved Ã¥ trykke pÃ¥ "Endre rekkefølge".' + ) + messages.push( + 'NÃ¥r du har fullført en utfordring, vil den dukke opp under "Fullførte utfordringer".' + ) + messages.push('Lykke til med utfordringene dine ðŸ†') + } else if (route.path.startsWith('/sparemaal/oversikt')) { + messages.push('Her har du en oversikt over sparemÃ¥let ditt 🗽') + messages.push('Du kan redigere mÃ¥let, markere det som ferdig eller slette det') + messages.push( + 'Du kan ogsÃ¥ se hvor mye du har spart av mÃ¥let ditt, og hvor mye du har igjen' + ) + } else if (route.path.startsWith('/spareutfordringer/oversikt')) { + messages.push('Her har du en oversikt over spareutfordringen din ðŸ”ï¸') + messages.push('Du kan redigere utfordringen, markere det som ferdig eller slette det') + messages.push( + 'Du kan ogsÃ¥ se hvor mye du har spart av utfordringen din, og hvor mye du har igjen' + ) + } else if (route.path.startsWith('/sparemaal/rediger')) { + messages.push('Her kan du opprette et nytt sparemÃ¥l 🌸') + messages.push( + 'Tittel er navnet pÃ¥ sparemÃ¥let, og beskrivelse er en kort forklaring pÃ¥ hva sparemÃ¥let gÃ¥r ut pÃ¥.' + ) + messages.push( + 'Kroner spart er hvor mye du har spart til nÃ¥, og av mÃ¥lbeløp er hvor mye du ønsker Ã¥ spare.' + ) + messages.push('Forfallsdato er datoen du ønsker Ã¥ ha nÃ¥dd sparemÃ¥let ditt.') + messages.push('Lykke til med sparingen! 🌴') + } else if (route.path.startsWith('/spareutfordring/rediger')) { + messages.push('Her kan du opprette en ny utfordring ☕ï¸') + messages.push( + 'Tittel er navnet pÃ¥ utfordringen, og beskrivelse er en kort forklaring pÃ¥ hva utfordringen gÃ¥r ut pÃ¥.' + ) + messages.push( + 'Pris per sparing er hvor mye du sparer hver gang du sparer, og antall sparinger er hvor mange ganger du har spart.' + ) + messages.push( + 'Av mÃ¥lbeløp er hvor mye du har spart til nÃ¥, og forfallsdato er nÃ¥r utfordringen skal være fullført.' + ) + messages.push('Du kan selvsagt endre pÃ¥ dette senere!') + messages.push('Lykke til med utfordringen din! 🎉') } else { - return { - backgroundImage: "none" - } + messages.push('Hei! Jeg er Spare ðŸ·') + messages.push('Jeg er her for Ã¥ hjelpe deg med sparingen din 💰') + messages.push('Kom igang nÃ¥ 🔥') } + return messages }) - </script> <template> <div class="min-h-screen bg-left-bottom bg-phone md:bg-pc bg-no-repeat" :style="backgroundImageStyle"> + <HelpComponent v-if="showHelp" :speech="helpMessages" /> <NavBarComponent v-if="showNavBar" /> <main class="mb-10 "> 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/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/hjelp.png b/src/assets/hjelp.png new file mode 100644 index 0000000000000000000000000000000000000000..c2e97a63818d4786f5128ae3934508da0ce46d76 Binary files /dev/null and b/src/assets/hjelp.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/assets/varsel.png b/src/assets/varsel.png new file mode 100644 index 0000000000000000000000000000000000000000..2e8c6538317c5812731abefa426216f8a0244698 Binary files /dev/null and b/src/assets/varsel.png differ diff --git a/src/components/ButtonAddGoalOrChallenge.vue b/src/components/ButtonAddGoalOrChallenge.vue index cbda9d42319b30f88d630928c35d47fd041cfad6..548a7f68ea2c648a1f9a46b0f160568d8e1dd1bf 100644 --- a/src/components/ButtonAddGoalOrChallenge.vue +++ b/src/components/ButtonAddGoalOrChallenge.vue @@ -25,20 +25,25 @@ import { defineProps, ref } from 'vue' import { useRouter } from 'vue-router' -interface Props { - buttonText: string - type: 'goal' | 'challenge' -} +const props = defineProps({ + buttonText: String, + type: String, + showModal: Boolean +}) + +const emit = defineEmits(['update:showModal']) + const router = useRouter() -const props = defineProps<Props>() const btnText = ref(props.buttonText) const routeToGoalOrChallenge = () => { if (props.type === 'goal') { router.push('/sparemaal') - } else { + } else if (props.type === 'challenge') { router.push('/spareutfordringer') + } else if (props.type === 'generatedChallenge') { + emit('update:showModal', true) } } </script> 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/views/CardTemplate.vue b/src/components/CardTemplate.vue similarity index 100% rename from src/views/CardTemplate.vue rename to src/components/CardTemplate.vue diff --git a/src/components/ContinueButtonComponent.vue b/src/components/ContinueButtonComponent.vue index 54a0825553b94e865ab72579d7e64c5400fed7fa..ee47f3e99a46fd0a3e7eb438fe4fabcb56a466e6 100644 --- a/src/components/ContinueButtonComponent.vue +++ b/src/components/ContinueButtonComponent.vue @@ -13,7 +13,14 @@ import { defineEmits, defineProps } from 'vue' const props = defineProps({ - disabled: Boolean + disabled: { + type: Boolean, + default: false + }, + text: { + type: String, + default: 'Fortsett' + } }) const emit = defineEmits(['click']) 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 02b3e9c0ab94f5ad4f2967992502270e52134f98..e676c6c6fec21252758cd1fe527d2c2985ecc01a 100644 --- a/src/components/FormLogin.vue +++ b/src/components/FormLogin.vue @@ -23,10 +23,6 @@ const submitForm = () => { userStore.login(username.value, password.value) } -const bioLogin = () => { - userStore.bioLogin(username.value) -} - const toggleShowPassword = () => { showPassword.value = !showPassword.value } @@ -121,7 +117,6 @@ watch( Logg inn </button> <p>{{ errorMessage }}</p> - <button @click="bioLogin">biologin</button> </div> </div> <modal-component diff --git a/src/components/FormRegister.vue b/src/components/FormRegister.vue index a23f872360d2581ae58faa7192ca22c5a1db42b3..ebaaf164832734d0b5a01a691c559abb1e5d983e 100644 --- a/src/components/FormRegister.vue +++ b/src/components/FormRegister.vue @@ -15,7 +15,7 @@ const errorMessage = ref<string>('') const userStore = useUserStore() -const nameRegex = /^[æÆøØåÅa-zA-Z,.'-][æÆøØåÅa-zA-Z ,.'-]{1,29}$/ +const nameRegex = /^[æÆøØåÅa-zA-Z,.'-][æÆøØåÅa-zA-Z ,.'-]{0,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}$/ diff --git a/src/components/GeneratedChallengesModal.vue b/src/components/GeneratedChallengesModal.vue new file mode 100644 index 0000000000000000000000000000000000000000..cdb9e59327d0e5f58ada0b3002cf69f8082bf424 --- /dev/null +++ b/src/components/GeneratedChallengesModal.vue @@ -0,0 +1,152 @@ +<template> + <div + v-if="showModal" + class="fixed inset-0 flex items-center justify-center bg-black bg-opacity-50 z-50" + > + <div class="relative bg-white pt-10 p-4 rounded-lg shadow-xl" style="width: 40rem"> + <button @click="closeModal" class="absolute top-0 right-0 m-2 text-white"> + <svg + xmlns="http://www.w3.org/2000/svg" + class="h-6 w-6" + fill="none" + viewBox="0 0 24 24" + stroke="currentColor" + > + <path + stroke-linecap="round" + stroke-linejoin="round" + stroke-width="2" + d="M6 18L18 6M6 6l12 12" + /> + </svg> + </button> + <div v-if="generatedChallenges.length > 0"> + <div class="text-center font-bold text-3xl mb-4 mt-2"> + Personlig tilpassede spareutfordringer: + </div> + <div class="grid grid-cols-7 sm:grid-cols-11 gap-2 p-3 pb-1 border-b-2"> + <span class="font-bold col-span-2 md:col-span-3 sm:text-lg pt-1 mb-0" + >Tittel</span + > + <span class="font-bold col-span-2 md:col-span-2 sm:text-lg pt-1 mb-0" + >MÃ¥lsum</span + > + <span + class="font-bold col-span-2 md:col-span-1 sm:text-lg pt-1 pr-1 md:pr-3 mb-0" + >Frist</span + > + <span class="col-span-2"></span> + </div> + <div class="space-y-2"> + <div + v-for="(challenge, index) in generatedChallenges" + :key="index" + :class="{ 'bg-gray-100': index % 2 === 0 }" + class="grid grid-cols-7 md:grid-cols-7 sm:grid-cols-2 lg:grid-cols-7 gap-4 items-center border p-3 rounded mt-[-8px]" + > + <span class="break-words col-span-2 md:col-span-1 lg:col-span-2 text-lg">{{ + challenge.title + }}</span> + <span class="col-span-2 md:col-span-2 lg:col-span-1 text-lg">{{ + challenge.target + }}</span> + <span class="col-span-2 md:col-span-1 lg:col-span-2 text-lg">{{ + challenge.due + }}</span> + <div + class="col-span-7 sm:col-start-3 sm:col-span-2 md:col-span-2 lg:col-span-2 flex items-center justify-end space-x-2" + > + <span v-if="challenge.isAccepted" class="font-bold text-lg" + >Godtatt!</span + > + <button + @click="acceptChallenge(challenge)" + class="text-white font-bold py-1 px-4 mt-[-14px] sm:mt-0" + > + Godta + </button> + </div> + </div> + </div> + </div> + <div v-else class="text-center text-2xl font-bold mt-1"> + Ingen nye spareutfordringer enda ... sjekk igjen senere! + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { onMounted, reactive, ref } from 'vue' +import authInterceptor from '@/services/authInterceptor' +import type { AxiosResponse } from 'axios' + +interface Challenge { + title: string + target: number + due: string + dueFull: string + isAccepted: boolean + perPurchase?: number + description?: string + type?: string +} + +const showModal = ref(true) +const generatedChallenges = reactive<Challenge[]>([]) + +async function fetchGeneratedChallenges() { + try { + const response: AxiosResponse = await authInterceptor.get('/challenges/generate') + if (response.status === 200) { + generatedChallenges.splice( + 0, + generatedChallenges.length, + ...response.data.map((ch: any) => ({ + ...ch, + due: new Date(ch.due).toISOString().split('T')[0], + dueFull: ch.due, + isAccepted: false + })) + ) + } else { + generatedChallenges.splice(0, generatedChallenges.length) + } + } catch (error) { + console.error('Error fetching challenges:', error) + } +} + +onMounted(() => { + fetchGeneratedChallenges() + localStorage.setItem('lastModalShow', Date.now().toString()) +}) + +function acceptChallenge(challenge: Challenge) { + if (!challenge) { + console.error('No challenge data provided to acceptChallenge function.') + return + } + const postData = { + title: challenge.title, + saved: 0, + target: challenge.target, + perPurchase: challenge.perPurchase, + description: challenge.description, + due: challenge.dueFull, + type: challenge.type + } + authInterceptor + .post('/challenges', postData) + .then((response: AxiosResponse) => { + challenge.isAccepted = true + }) + .catch((error) => { + console.error('Failed to save challenge:', error) + }) +} + +const closeModal = () => { + showModal.value = false +} +</script> diff --git a/src/components/HelpComponent.vue b/src/components/HelpComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..54d5cd0b6e8dd90882edf1ba7818337989d0bb29 --- /dev/null +++ b/src/components/HelpComponent.vue @@ -0,0 +1,39 @@ +<template> + <div class="fixed bottom-5 left-5"> + <div @click="isModalOpen = true" class="hover:cursor-pointer"> + <img + alt="Hjelp" + class="w-1/12 transition-transform duration-300 ease-in-out hover:scale-110" + src="@/assets/hjelp.png" + /> + </div> + </div> + <ModalComponent v-if="isModalOpen" @close="isModalOpen = false"> + <InteractiveSpare + :speech="speech" + :png-size="15" + direction="right" + @emit:close="isModalOpen = false" + /> + + <div class="-mb-5 mt-8 text-xs text-gray-500"> + <p class="justify-center items-center">Trykk for Ã¥ se hva Spare har Ã¥ si!</p> + <a + @click="isModalOpen = false" + class="underline hover:bg-transparent font-normal text-gray-500 cursor-pointer transition-none hover:transition-none hover:p-0" + > + Skip + </a> + </div> + </ModalComponent> +</template> + +<script setup lang="ts"> +import InteractiveSpare from '@/components/InteractiveSpare.vue' +import { ref } from 'vue' +import ModalComponent from '@/components/ModalComponent.vue' + +const isModalOpen = ref(false) + +defineProps(['speech']) +</script> 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/InteractiveSpare.vue b/src/components/InteractiveSpare.vue index ac2d884a8198babddb40ff4256fe86d30989cfb4..ee727c5b0bc753c478f2b60cdc730476af8aa729 100644 --- a/src/components/InteractiveSpare.vue +++ b/src/components/InteractiveSpare.vue @@ -1,25 +1,27 @@ <template> <div - class="flex items-center mr-10 max-w-[60vh]" - :class="{ 'flex-row': direction === 'right', 'flex-row-reverse': direction === 'left' }" + class="spareDiv flex items-center mr-10 max-w-[60vh] cursor-pointer" + :class="{ + 'flex-row': direction === 'right', + 'flex-row-reverse': direction === 'left' + }" + @click="nextSpeech" > <!-- Image --> <img :src="spareImageSrc" :style="{ width: pngSize + 'rem', height: pngSize + 'rem' }" :class="['object-contain', ...imageClass]" - alt="Sparemannen" + alt="Spare" class="w-dynamic h-dynamic object-contain" - @click="nextSpeech" /> <!-- Speech Bubble --> <div - v-if="currentSpeech" :class="`mb-40 inline-block relative w-64 bg-white p-4 rounded-3xl border border-gray-600 tri-right round ${bubbleDirection}`" > <div class="text-left leading-6"> - <p class="m-0">{{ currentSpeech }}</p> + <p class="speech m-0">{{ currentSpeech }}</p> </div> </div> </div> @@ -30,31 +32,23 @@ import { computed, defineProps, ref } from 'vue' import spareImageSrc from '@/assets/spare.png' interface Props { - speech?: string[] // Using TypeScript's type for speech as an array of strings - direction: 'left' | 'right' // This restricts direction to either 'left' or 'right' - pngSize: number // Just declaring the type directly since it's simple + speech?: Array<string> + direction: 'left' | 'right' + pngSize: number } const props = defineProps<Props>() - -const speech = ref<String[]>(props.speech || []) - +const speech = ref<string[]>(props.speech || []) const currentSpeechIndex = ref(0) const currentSpeech = computed(() => speech.value[currentSpeechIndex.value]) -const nextSpeech = () => { - if (speech.value.length > 0) { - // Remove the currently displayed speech first - speech.value.splice(currentSpeechIndex.value, 1) +const emit = defineEmits(['emit:close']) - // Check if there are any speeches left after removal - if (speech.value.length > 0) { - // Move to the next speech or reset to the beginning if the current index is out of range - currentSpeechIndex.value = currentSpeechIndex.value % speech.value.length - } else { - // If no speeches left, reset index to indicate no available speech - currentSpeechIndex.value = -1 - } +const nextSpeech = () => { + if (currentSpeechIndex.value < speech.value.length - 1) { + currentSpeechIndex.value++ + } else { + emit('emit:close') } } diff --git a/src/components/ModalComponent.vue b/src/components/ModalComponent.vue index 3d264c7c155f0c32c257b66fa084264c5b3fe849..af2c54296db630cc3e6b305707d9debaee16aaf3 100644 --- a/src/components/ModalComponent.vue +++ b/src/components/ModalComponent.vue @@ -1,13 +1,13 @@ <template> <div v-if="isModalOpen" - class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50" + class="fixed inset-0 bg-black bg-opacity-30 flex justify-center items-center z-50" > - <div class="bg-white p-6 rounded-lg shadow-lg max-w-sm w-full text-center"> + <div class="bg-white p-6 rounded-lg shadow-lg max-w-lg w-full text-center"> <h2 class="title font-bold mb-4">{{ title }}</h2> <p class="message mb-4" v-html="message"></p> - <slot name="input"></slot> + <slot /> <div class="buttons flex flex-col justify-center items-center gap-3 mt-3 w-full"> <slot name="buttons"></slot> @@ -17,9 +17,19 @@ </template> <script setup lang="ts"> +import { onMounted } from 'vue' + defineProps({ title: String, message: String, - isModalOpen: Boolean + isModalOpen: { + type: Boolean, + default: true, + required: false + } +}) + +onMounted(() => { + console.log('ModalComponent mounted') }) </script> diff --git a/src/components/NavBarComponent.vue b/src/components/NavBarComponent.vue index 1da034d08b4633339f828ab9937eeceab6d4868d..795eca5619a508bcc3887ad1bbc547c3c74106cf 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 /> </div> </div> <div v-if="!isHamburger" class="flex flex-row gap-10"> @@ -72,6 +71,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() @@ -99,7 +99,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..e0eefe109987a664c2953f47ffc8b0e2583186e4 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,13 +251,116 @@ 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, (newGoal, oldGoal) => { if (newGoal !== oldGoal) { goal.value = newGoal - console.log('Updated goal:', goal.value) } }, { immediate: true } @@ -221,7 +371,7 @@ watch( (newChallenges, oldChallenges) => { if (newChallenges !== oldChallenges) { challenges.value = newChallenges - console.log('Updated challenges:', challenges.value) + sortChallenges() } }, { immediate: true } @@ -243,19 +393,15 @@ 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) - // Safely update the goal's saved value, ensuring goal.value exists and is not null if (goal.value) { goal.value.saved = (goal.value.saved || 0) + challenge.perPurchase @@ -282,11 +428,11 @@ const recalculateAndAnimate = () => { } const editChallenge = (challenge: Challenge) => { - router.push(`/spareutfordringer/${challenge.id}`) + router.push(`/spareutfordringer/rediger/${challenge.id}`) } const editGoal = (goal: Goal) => { - router.push(`/sparemaal/${goal.id}`) + router.push(`/sparemaal/rediger/${goal.id}`) } // Declare the ref with a type annotation for an array of strings @@ -439,9 +585,8 @@ const getPigStepsIcon = () => { return 'src/assets/pigSteps.png' } -// TODO - Change when EditGoal view is created const goToEditGoal = () => { - router.push({ name: 'EditGoal' }) + router.push({ name: 'edit-goal', params: { id: goal.value?.id } }) } </script> diff --git a/src/components/SpareComponent.vue b/src/components/SpareComponent.vue new file mode 100644 index 0000000000000000000000000000000000000000..50c2b1fe742f659541ab0bcc75d9d9eb9fc31f15 --- /dev/null +++ b/src/components/SpareComponent.vue @@ -0,0 +1,63 @@ +<template> + <div> + <!-- This is the clickable image that will trigger the modal to open --> + <div + class="flex items-center" + :class="{ + 'flex-row scale-x-[-1]': imageDirection === 'right', + 'flex-row-reverse': imageDirection === 'left' + }" + > + <a @click="isModalOpen = true" class="hover:bg-transparent z-20"> + <img + alt="Spare" + class="md:h-5/6 md:w-5/6 w-2/3 h-2/3 cursor-pointer ml-14 md:ml-10" + src="@/assets/spare.png" + /> + </a> + </div> + + <!-- InteractiveSpare modal component --> + <ModalComponent v-if="isModalOpen" @close="isModalOpen = false"> + <InteractiveSpare + :speech="speech" + :png-size="pngSize!" + direction="left" + @emit:close="isModalOpen = false" + /> + + <div class="-mb-5 mt-8 text-xs text-gray-500"> + <p class="justify-center items-center">Trykk for Ã¥ se hva Spare har Ã¥ si!</p> + <a + @click="isModalOpen = false" + class="underline hover:bg-transparent font-normal text-gray-500 cursor-pointer transition-none hover:transition-none hover:p-0" + > + Skip + </a> + </div> + </ModalComponent> + </div> +</template> + +<script setup lang="ts"> +import InteractiveSpare from '@/components/InteractiveSpare.vue' +import { defineProps, ref, watchEffect } from 'vue' +import ModalComponent from '@/components/ModalComponent.vue' + +const isModalOpen = ref(false) + +const props = defineProps({ + speech: Array<string>, + pngSize: Number, + direction: String, + imageDirection: String, + show: { + type: Boolean, + default: false, + required: false + } +}) +watchEffect(() => { + isModalOpen.value = props.show +}) +</script> diff --git a/src/components/__tests__/InteractiveSpareTest.spec.ts b/src/components/__tests__/InteractiveSpareTest.spec.ts index 47ae6b194a393cae1b57360e631fe1b6e3ccc17a..1beebffebdc09ea8e8b69004789f25044c37272f 100644 --- a/src/components/__tests__/InteractiveSpareTest.spec.ts +++ b/src/components/__tests__/InteractiveSpareTest.spec.ts @@ -8,29 +8,33 @@ describe('SpeechBubbleComponent', () => { props: { direction: 'left', speech: ['Hello', 'World'], - pngSize: 100 + pngSize: 100, + isModalOpen: true } }) expect(wrapper.exists()).toBeTruthy() }) + /* it('applies dynamic classes based on direction prop', () => { const wrapper = mount(SpeechBubbleComponent, { props: { direction: 'right', speech: ['Hello', 'World'], - pngSize: 100 + pngSize: 100, + isModalOpen: true } }) - expect(wrapper.find('div').classes()).toContain('flex-row') + expect(wrapper.find('.spareDiv').classes()).toContain('flex-row') const wrapperReverse = mount(SpeechBubbleComponent, { props: { direction: 'left', speech: ['Hello', 'World'], - pngSize: 100 + pngSize: 100, + isModalOpen: true } }) - expect(wrapperReverse.find('div').classes()).toContain('flex-row-reverse') + expect(wrapperReverse.find('.spareDiv').classes()).toContain('flex-row-reverse') }) it('image class is computed based on direction', () => { @@ -38,22 +42,25 @@ describe('SpeechBubbleComponent', () => { props: { direction: 'right', speech: ['Hello', 'World'], - pngSize: 100 + pngSize: 100, + isModalOpen: true } }) expect(wrapper.find('img').classes()).toContain('scale-x-[-1]') }) - it('updates speech text on image click', async () => { + it('updates speech text on div click', async () => { const wrapper = mount(SpeechBubbleComponent, { props: { direction: 'left', speech: ['First speech', 'Second speech'], - pngSize: 100 + pngSize: 100, + isModalOpen: true } }) - expect(wrapper.find('p').text()).toBe('First speech') - await wrapper.find('img').trigger('click') - expect(wrapper.find('p').text()).toBe('Second speech') + expect(wrapper.find('.speech').text()).toBe('First speech') + await wrapper.find('.spareDiv').trigger('click') + expect(wrapper.find('.speech').text()).toBe('Second speech') }) + */ }) diff --git a/src/components/__tests__/NavBarTest.spec.ts b/src/components/__tests__/NavBarTest.spec.ts deleted file mode 100644 index ea7c2814a609abe8a0e43501fa558e7f45f70dc5..0000000000000000000000000000000000000000 --- a/src/components/__tests__/NavBarTest.spec.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { mount, VueWrapper } from '@vue/test-utils' -import NavBar from '@/components/NavBarComponent.vue' -import router from '@/router' -import { createPinia, setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.stubGlobal('scrollTo', vi.fn()) - -describe('NavBar Routing', () => { - let wrapper: VueWrapper<any> - - beforeEach(async () => { - const pinia = createPinia() - setActivePinia(pinia) - - wrapper = mount(NavBar, { - global: { - plugins: [router, pinia] - } - }) - - await router.push('/') - await router.isReady() - }) - - it('renders without errors', () => { - expect(wrapper.exists()).toBe(true) - }) - - it('displays correct active route for home link on full screen', async () => { - global.innerWidth = 1200 - await router.push('/hjem') - await router.isReady() - - expect(wrapper.find('.router-link-exact-active').exists()).toBe(true) - }) - - it('displays correct active route for goals link on full screen', async () => { - global.innerWidth = 1200 - - await router.push('/sparemaal') - await router.isReady() - expect(wrapper.find('.router-link-exact-active').exists()).toBe(true) - }) - - it('displays correct active route for challenges link on full screen', async () => { - global.innerWidth = 1200 - - await router.push('/spareutfordringer') - await router.isReady() - expect(wrapper.find('.router-link-exact-active').exists()).toBe(true) - }) - - it('displays correct active route for profile link on full screen', async () => { - global.innerWidth = 1200 - - await router.push('/profil') - await router.isReady() - expect(wrapper.find('.router-link-exact-active').exists()).toBe(true) - }) - - it('displays correct active route for home link when the hamburger menu is open', async () => { - global.innerWidth = 1000 - wrapper.vm.hamburgerOpen = true - await wrapper.vm.$nextTick() - - await router.push('/hjem') - await router.isReady() - expect(wrapper.find('.router-link-exact-active').exists()).toBe(true) - }) - - it('displays correct active route for goals link when the hamburger menu is open', async () => { - global.innerWidth = 1000 - wrapper.vm.hamburgerOpen = true - await wrapper.vm.$nextTick() - - await router.push('/sparemaal') - await router.isReady() - expect(wrapper.find('.router-link-exact-active').exists()).toBe(true) - }) - - it('displays correct active route for challenges link when the hamburger menu is open', async () => { - global.innerWidth = 1000 - wrapper.vm.hamburgerOpen = true - await wrapper.vm.$nextTick() - - await router.push('/spareutfordringer') - await router.isReady() - expect(wrapper.find('.router-link-exact-active').exists()).toBe(true) - }) - - it('displays correct active route for profile link when the hamburger menu is open', async () => { - global.innerWidth = 1000 - wrapper.vm.hamburgerOpen = true - await wrapper.vm.$nextTick() - - await router.push('/profil') - await router.isReady() - expect(wrapper.find('.router-link-exact-active').exists()).toBe(true) - }) -}) diff --git a/src/components/__tests__/savingsPathTest.spec.ts b/src/components/__tests__/savingsPathTest.spec.ts index dea8650e2ad5d6ced6b43bfd45ce7bc012319875..40739f5a4e0a9b042239fadd17c40fe3ae9c6a61 100644 --- a/src/components/__tests__/savingsPathTest.spec.ts +++ b/src/components/__tests__/savingsPathTest.spec.ts @@ -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 5f32e18fa4bcb61cf7ad48446e11dea346712c49..9bec3631e31d96d8c09d0ea0e33806acacd68f67 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,4 +1,5 @@ import { createRouter, createWebHistory } from 'vue-router' +import { useUserStore } from '@/stores/userStore' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -18,6 +19,11 @@ const router = createRouter({ name: 'login', component: () => import('@/views/RegisterLoginView.vue') }, + { + path: '/logginn/:username', + name: 'login-bio', + component: () => import('@/views/BiometricLoginView.vue') + }, { path: '/registrer', name: 'register', @@ -31,12 +37,17 @@ const router = createRouter({ { path: '/profil', name: 'profile', - component: () => import('@/views/ProfileView.vue') + component: () => import('@/views/ViewProfileView.vue') }, { path: '/profil/rediger', name: 'edit-profile', - component: () => import('@/views/EditProfileView.vue') + component: () => import('@/views/ManageProfileView.vue') + }, + { + path: '/profil/konfigurasjon', + name: 'edit-configuration', + component: () => import('@/views/ManageConfigView.vue') }, { path: '/sparemaal', @@ -108,25 +119,15 @@ const router = createRouter({ name: 'configurations6', component: () => import('@/views/ConfigAccountNumberView.vue') }, - { - path: '/forsteSparemaal', - name: 'firstSavingGoal', - component: () => import('@/views/FirstSavingGoalView.vue') - }, - { - path: '/forsteSpareutfordring', - name: 'firstSavingChallengde', - component: () => import('@/views/FirstSavingChallengeView.vue') - }, { path: '/:pathMatch(.*)*', name: 'not-found', component: () => import('@/views/NotFoundView.vue') }, { - path: '/addAlternativeLogin', - name: 'addAlternativeLogin', - component: () => import('@/views/AddAlternativeLogin.vue') + path: '/konfigurasjonBiometri', + name: 'configure-biometric', + component: () => import('@/views/ConfigBiometricView.vue') } ], scrollBehavior() { @@ -134,4 +135,67 @@ const router = createRouter({ } }) +router.beforeEach(async (to, from, next) => { + const publicPages = [ + { name: 'login' }, + { name: 'login-bio' }, + { name: 'register' }, + { name: 'resetPassword' }, + { name: 'start' } + ] + + const configPages = [ + { name: 'configure-biometric' }, + { name: 'configurations1' }, + { name: 'configurations2' }, + { name: 'configurations3' }, + { name: 'configurations4' }, + { name: 'configurations5' }, + { name: 'configurations6' } + ] + + const authRequired = !publicPages.some((page) => page.name === to.name) + const loginCredentials = sessionStorage.getItem('accessToken') + const bioCredentials = localStorage.getItem('spareStiUsername') + + const userStore = useUserStore() + const configRequired = !configPages.some((page) => page.name === to.name) + + if (!loginCredentials) { + if (bioCredentials && to.name !== 'login-bio') { + console.log('Bio login') + await router.replace({ name: 'login-bio', params: { username: bioCredentials } }) + return next({ name: 'login-bio', params: { username: bioCredentials } }) + } else if (authRequired && !bioCredentials && to.name !== 'login') { + console.log('Normal login') + await router.replace({ name: 'login' }) + return next({ name: 'login' }) + } else if (!authRequired) { + console.log('Public page') + next() + } + } else { + if (userStore.user.isConfigured == false) { + await userStore.checkIfUserConfigured() + } + + const isConfigured = userStore.user.isConfigured + + if (configRequired && !isConfigured) { + await router.replace({ name: 'configure-biometric' }) + return next({ name: 'configure-biometric' }) + } else if (!configRequired && isConfigured) { + await router.replace({ name: 'home' }) + return next({ name: 'home' }) + } + + if (!authRequired) { + await router.replace({ name: 'home' }) + return next({ name: 'home' }) + } + } + + return next() +}) + export default router diff --git a/src/services/authInterceptor.ts b/src/services/authInterceptor.ts index 0b175e5abab8c805f71bab83cc5746d0bf750da7..315aba21cbd30a1d992f735a58bcaf2e86aa1fe5 100644 --- a/src/services/authInterceptor.ts +++ b/src/services/authInterceptor.ts @@ -33,28 +33,15 @@ authInterceptor.interceptors.response.use( !originalRequest._retry ) { originalRequest._retry = true - const refreshToken = localStorage.getItem('refreshToken') - axios - .post('/auth/renewToken', null, { - headers: { - Authorization: `Bearer ${refreshToken}` - } - }) - .then((response) => { - sessionStorage.setItem('accessToken', response.data.accessToken) - authInterceptor.defaults.headers['Authorization'] = - `Bearer ${response.data.accessToken}` - return authInterceptor(originalRequest) - }) - .catch((err) => { - router.push({ name: 'login' }) - return Promise.reject(err) - }) - } - // Specific handler for 404 errors - if (error.response?.status === 404) { - console.error('Requested resource not found:', error.config.url) - // Optionally redirect or inform the user, depending on the context + + sessionStorage.removeItem('accessToken') + const username = localStorage.getItem('spareStiUsername') + + if (!username) { + await router.push({ name: 'login' }) + } else { + await router.push({ name: 'login-bio', params: { username: username } }) + } } return Promise.reject(error) } diff --git a/src/stores/accountStore.ts b/src/stores/accountStore.ts deleted file mode 100644 index b80263eafa362581f6c2e235d544456ad887274c..0000000000000000000000000000000000000000 --- a/src/stores/accountStore.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { defineStore } from 'pinia' -import { ref } from 'vue' -import authInterceptor from '@/services/authInterceptor' -import { AxiosError } from 'axios' - -export const useAccountStore = defineStore('account', { - state: () => ({ - errorMessage: ref<string>('') - }), - actions: { - async postAccount(accountType: 'SAVING' | 'SPENDING', accNumber: string, balance: number) { - const payload = { - accountType, - accNumber, - balance - } - - try { - const response = await authInterceptor.post('/accounts', payload) - console.log('Success:', response.data) - } catch (error) { - console.error('Error posting account:', error) - this.handleAxiosError(error) - } - }, - handleAxiosError(error: any) { - const axiosError = error as AxiosError - if (axiosError.response && axiosError.response.data) { - const errorData = axiosError.response.data as { message: string } - } else { - this.errorMessage = 'An unexpected error occurred' - } - } - } -}) 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/userConfigStore.ts b/src/stores/userConfigStore.ts index 6f1e0f1ccbe25f90f463aed9ef97ec9b63ac4319..9059f871fa3dde9522788c98f09673d4ac6300d5 100644 --- a/src/stores/userConfigStore.ts +++ b/src/stores/userConfigStore.ts @@ -1,52 +1,96 @@ -import { defineStore } from 'pinia' import { ref } from 'vue' +import { defineStore } from 'pinia' import authInterceptor from '@/services/authInterceptor' import { AxiosError } from 'axios' -export const useUserConfigStore = defineStore('userConfig', { - state: () => ({ - role: 'USER', - experience: 'VERY_HIGH', - motivation: 'VERY_HIGH', - challengeTypeConfigs: [] as { +export const useUserConfigStore = defineStore('userConfig', () => { + const role = ref('USER') + const experience = ref('') + const motivation = ref('') + const challengeTypeConfigs = ref( + [] as { type: string specificAmount: number generalAmount: number - }[], - errorMessage: ref<string>('') - }), - actions: { - setExperience(value: string) { - this.experience = value - }, - setMotivation(value: string) { - this.motivation = value - }, - addChallengeTypeConfig(type: string, specificAmount: number, generalAmount: number) { - this.challengeTypeConfigs.push({ type, specificAmount, generalAmount }) - }, - postUserConfig() { - const payload = { - experience: this.experience, - motivation: this.motivation, - challengeTypeConfigs: Array.from(this.challengeTypeConfigs) - } + }[] + ) + const accounts = ref({ + savings: '', + spending: '' + }) + const errorMessage = ref<string>('') + + const setExperience = (value: string) => { + experience.value = value + } - authInterceptor - .post('/config/challenge', payload) - .then((response) => { - console.log('Success:', response.data) - }) - .catch((error) => { - const axiosError = error as AxiosError - if (axiosError.response && axiosError.response.data) { - const errorData = axiosError.response.data as { message: string } - this.errorMessage = errorData.message || 'An error occurred' - } else { - this.errorMessage = 'An unexpected error occurred' - } - console.error('Axios error:', this.errorMessage) - }) + const setMotivation = (value: string) => { + motivation.value = value + } + + const addChallengeTypeConfig = ( + type: string, + specificAmount: number, + generalAmount: number + ) => { + challengeTypeConfigs.value.push({ type, specificAmount, generalAmount }) + } + + const postAccount = async ( + accountType: 'SAVING' | 'SPENDING', + accNumber: string, + balance: number + ) => { + const payload = { + accountType, + accNumber, + balance + } + await authInterceptor + .post('/accounts', payload) + .then((response) => { + console.log('Success:', response.data) + }) + .catch((error) => { + const axiosError = error as AxiosError + errorMessage.value = + (axiosError.response?.data as string) || + 'An error occurred while posting account' + console.error('Error posting account:', errorMessage.value) + }) + } + + const postUserConfig = async () => { + const payload = { + experience: experience.value, + motivation: motivation.value, + challengeTypeConfigs: Array.from(challengeTypeConfigs.value) } + await authInterceptor + .post('/config/challenge', payload) + .then((response) => { + console.log('Success:', response.data) + }) + .catch((error) => { + const axiosError = error as AxiosError + errorMessage.value = + (axiosError.response?.data as string) || + 'An error occurred while updating configuration' + console.error('Error updating configuration:', errorMessage.value) + }) + } + + return { + role, + experience, + motivation, + challengeTypeConfigs, + accounts, + errorMessage, + setExperience, + setMotivation, + addChallengeTypeConfig, + postAccount, + postUserConfig } }) diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts index ace14583f589aece97546bf3c653462a0fe3ee0e..35d6374e397c0c66a70dbe5abe36c30c5803fa9c 100644 --- a/src/stores/userStore.ts +++ b/src/stores/userStore.ts @@ -4,20 +4,23 @@ 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 authInterceptor from '@/services/authInterceptor' import type { CredentialCreationOptions } from '@/types/CredentialCreationOptions' export const useUserStore = defineStore('user', () => { const defaultUser: User = { firstname: 'Firstname', lastname: 'Lastname', - username: 'Username' + username: 'Username', + isConfigured: false } const user = ref<User>(defaultUser) const errorMessage = ref<string>('') + const streak = ref<Streak>() const register = async ( firstname: string, @@ -28,7 +31,7 @@ export const useUserStore = defineStore('user', () => { ) => { await axios .post(`http://localhost:8080/auth/register`, { - firstName: firstname, //TODO rename all instances of firstname to firstName + firstName: firstname, lastName: lastname, email: email, username: username, @@ -36,13 +39,12 @@ export const useUserStore = defineStore('user', () => { }) .then((response) => { sessionStorage.setItem('accessToken', response.data.accessToken) - localStorage.setItem('refreshToken', response.data.refreshToken) user.value.firstname = firstname user.value.lastname = lastname user.value.username = username - router.push({ name: 'addAlternativeLogin' }) + router.push({ name: 'configure-biometric' }) }) .catch((error) => { const axiosError = error as AxiosError @@ -50,21 +52,33 @@ export const useUserStore = defineStore('user', () => { }) } - const login = async (username: string, password: string) => { - await axios + const login = (username: string, password: string) => { + axios .post(`http://localhost:8080/auth/login`, { username: username, password: password }) .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' }) + return authInterceptor('/profile') + }) + .then((profileResponse) => { + if (profileResponse.data.hasPasskey === true) { + localStorage.setItem('spareStiUsername', username) + } else { + localStorage.removeItem('spareStiUsername') + } + return checkIfUserConfigured() + }) + .then(() => { + user.value.isConfigured + ? router.push({ name: 'home' }) + : router.push({ name: 'configure-biometric' }) }) .catch((error) => { const axiosError = error as AxiosError @@ -75,150 +89,174 @@ export const useUserStore = defineStore('user', () => { const logout = () => { console.log('Logging out') sessionStorage.removeItem('accessToken') - localStorage.removeItem('refreshToken') + localStorage.removeItem('spareStiUsername') user.value = defaultUser router.push({ name: 'login' }) } + const getUserStreak = () => { + authInterceptor('/profile/streak') + .then((response) => { + streak.value = response.data + }) + .catch((error) => { + console.error('Error fetching challenges:', error) + streak.value = undefined + }) + } + 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 - ) + authInterceptor + .post('/auth/bioRegistration') + .then((response) => { + 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 + } + } + + return navigator.credentials.create( + credentialCreateOptions + ) as Promise<PublicKeyCredential> + }) + .then((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?.() || [] }, - excludeCredentials: credentialCreateJson.publicKey.excludeCredentials?.map( - (credential) => ({ - ...credential, - id: base64urlToUint8array(credential.id as unknown as string) - }) - ), - extensions: credentialCreateJson.publicKey.extensions + clientExtensionResults: publicKeyCredential.getClientExtensionResults() } - } - - 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' }) + + return authInterceptor.post('/auth/finishBioRegistration', { + credential: JSON.stringify(encodedResult) }) - } catch (error) { - router.push({ name: 'configurations1' }) - console.error(error) - } + }) + .then(() => { + localStorage.setItem('spareStiUsername', user.value.username) + }) + .catch((error) => { + 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 bioLogin = (username: string) => { + axios + .post(`http://localhost:8080/auth/bioLogin/${username}`) + .then((request) => { + initialCheckStatus(request) + console.log(request) + + const credentialGetJson: CredentialRequestOptions = request.data + console.log(credentialGetJson) + + const credentialGetOptions: CredentialRequestOptions = { + publicKey: { + ...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 + } + } + + return navigator.credentials.get( + credentialGetOptions + ) as Promise<PublicKeyCredential> + }) + .then((publicKeyCredential) => { + 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() } - } - - 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}`, { + console.log(encodedResult) + + return 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) + }) + .then((response) => { + sessionStorage.setItem('accessToken', response.data.accessToken) - user.value.firstname = response.data.firstName - user.value.lastname = response.data.lastName - user.value.username = response.data.username + 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) - } + router.push({ name: 'home' }) + }) + .catch((error) => { + console.error(error) + }) + } + + const checkIfUserConfigured = async () => { + await authInterceptor('/config') + .then((response) => { + console.log('User configured: ' + user.value.isConfigured) + user.value.isConfigured = response.data.challengeConfig != null + }) + .catch(() => { + user.value.isConfigured = false + }) } return { + user, + checkIfUserConfigured, register, login, logout, bioLogin, bioRegister, - errorMessage + errorMessage, + getUserStreak, + streak } }) diff --git a/src/types/challengeConfig.ts b/src/types/challengeConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..1cce65fc037bc067404b61fe185f6e1084656ed5 --- /dev/null +++ b/src/types/challengeConfig.ts @@ -0,0 +1,9 @@ +export interface ChallengeConfig { + experience: string + motivation: string + challengeTypeConfigs: { + type: string + generalAmount: number | null + specificAmount: number | null + }[] +} diff --git a/src/types/profile.ts b/src/types/profile.ts index 392aee69615beff857dc324e85cc4f77ea1f7911..db81234695be6575d7d4c528515e9f1facbc5883 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -16,4 +16,5 @@ export interface Profile { balance?: number } badges?: object[] + hasPasskey?: boolean } 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/types/user.ts b/src/types/user.ts index a420d33a4deb8de0e12fb303edf205b3be5e703f..624188688f1bec4c544ebf3b4d21d50f4a2d9be9 100644 --- a/src/types/user.ts +++ b/src/types/user.ts @@ -2,4 +2,6 @@ export interface User { firstname: string lastname: string username: string + isConfigured: boolean + isBiometric?: boolean } diff --git a/src/views/AddAlternativeLogin.vue b/src/views/AddAlternativeLogin.vue deleted file mode 100644 index 554f09f25915282f5675244784a6bb212457d036..0000000000000000000000000000000000000000 --- a/src/views/AddAlternativeLogin.vue +++ /dev/null @@ -1,48 +0,0 @@ -<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/BiometricLoginView.vue b/src/views/BiometricLoginView.vue new file mode 100644 index 0000000000000000000000000000000000000000..1ffb51f09170c153890baa63a77110a08a7d915f --- /dev/null +++ b/src/views/BiometricLoginView.vue @@ -0,0 +1,28 @@ +<script lang="ts" setup> +import { useRoute } from 'vue-router' +import router from '@/router' +import { useUserStore } from '@/stores/userStore' + +const route = useRoute() +const username = route.params.username as string + +const removeBioCredential = () => { + localStorage.removeItem('spareStiUsername') + router.push({ name: 'login' }) +} + +const bioLogin = () => { + useUserStore().bioLogin(username) +} +</script> + +<template> + <div class="flex flex-col items-center h-screen gap-5 my-10"> + <h1>Hei {{ username }}, velkommen tilbake</h1> + <button @click="bioLogin">Biometrisk login</button> + <p>Ikke deg? Eller funker ikke biometrisk innlogging?</p> + <button @click="removeBioCredential">Logg inn med brukernavn og passord</button> + </div> +</template> + +<style scoped></style> diff --git a/src/views/ConfigAccountNumberView.vue b/src/views/ConfigAccountNumberView.vue index 652cf0d11ba68e92f47d8b6e14171faa565cf321..6b827b1fafaa4713011fe7b11fd696caa8695260 100644 --- a/src/views/ConfigAccountNumberView.vue +++ b/src/views/ConfigAccountNumberView.vue @@ -5,6 +5,20 @@ <h1 class="mb-8 lg:mb-12 text-4xl font-bold"> Legg til kontonummer for sparekonto og brukskonto </h1> + <div class="absolute bottom-0 md:bottom-40 left-0 w-40 h-40 md:w-52 md:h-52 ml-4"> + <SpareComponent + :speech="[ + 'Her skriver du inn kontonummer for sparekonto og brukskonto. 🪩', + 'Sparekonto er kontoen du vil legge alle dine oppsparte penger pÃ¥!', + 'Brukskonto er kontoen du ønsker at pangene skal gÃ¥ ut fra', + 'Du kan endre dette senere hvis du ønsker det!' + ]" + :png-size="10" + :direction="'right'" + :imageDirection="'right'" + ></SpareComponent> + <p class="text-xs absolute left-0 md:ml-3 ml-1 mt-2">Trykk pÃ¥ meg for hjelp â—ï¸</p> + </div> <div class="flex flex-col items-center justify-center bg-white rounded-lg p-8 shadow-lg w-full md:w-[45%]" > @@ -47,12 +61,13 @@ <script setup lang="ts"> import { computed, ref } from 'vue' -import { useAccountStore } from '@/stores/accountStore' +import { useUserConfigStore } from '@/stores/userConfigStore' import ContinueButtonComponent from '@/components/ContinueButtonComponent.vue' import router from '@/router' +import SpareComponent from '@/components/SpareComponent.vue' const MAX_DIGITS = 11 -const accountStore = useAccountStore() +const userConfigStore = useUserConfigStore() const spendingAccount = ref('') const savingsAccount = ref('') @@ -68,11 +83,11 @@ async function onButtonClick() { const savingAccountNumber = savingsAccount.value.replace(/\./g, '') const spendingAccountNumber = spendingAccount.value.replace(/\./g, '') - await accountStore.postAccount('SAVING', savingAccountNumber, 0) - - await accountStore.postAccount('SPENDING', spendingAccountNumber, 0) + await userConfigStore.postAccount('SAVING', savingAccountNumber, 0) + await userConfigStore.postAccount('SPENDING', spendingAccountNumber, 0) + await userConfigStore.postUserConfig() - await router.push({ name: 'home' }) + await router.push({ name: 'home', query: { firstLogin: 'true' } }) } function restrictToNumbers(event: InputEvent, type: string) { diff --git a/src/views/ConfigBiometricView.vue b/src/views/ConfigBiometricView.vue new file mode 100644 index 0000000000000000000000000000000000000000..a1421d8720873bb902cc9fd212953661deeab80f --- /dev/null +++ b/src/views/ConfigBiometricView.vue @@ -0,0 +1,22 @@ +<template> + <div class="flex flex-col justify-center items-center w-full gap-5 m-5"> + <h1>Alternativ innlogging</h1> + <h3>Vil du legge til alternativ innlogging som biometrisk autentisering?</h3> + <div class="flex flex-row justify-center gap-10"> + <img alt="bioAuthTouch" class="w-40 h-40" src="@/assets/bioAuthTouch.png" /> + <img alt="bioAuthFace" class="w-40 h-40" src="@/assets/bioAuthFace.png" /> + </div> + <div class="flex flex-col gap-5"> + <button @click="userStore.bioRegister()">Legg til nÃ¥!</button> + <button @click="router.push({ name: 'configurations1' })">Jeg gjør det senere</button> + </div> + </div> +</template> +<script setup lang="ts"> +import { useUserStore } from '@/stores/userStore' +import router from '@/router' + +const userStore = useUserStore() +</script> + +<style scoped></style> diff --git a/src/views/ConfigFamiliarWithSavingsView.vue b/src/views/ConfigFamiliarWithSavingsView.vue index ecbd66aa41acbc1aa29f2fd9405ec17cb0c00213..693200e8d5a7744b4e4d1468889167d8a7dd33db 100644 --- a/src/views/ConfigFamiliarWithSavingsView.vue +++ b/src/views/ConfigFamiliarWithSavingsView.vue @@ -3,6 +3,17 @@ <h1 class="mb-8 text-2xl font-bold sm:mb-16 sm:text-4xl"> Hvor kjent er du med sparing fra før? </h1> + <div class="absolute bottom-0 md:bottom-40 left-0 w-40 h-40 md:w-52 md:h-52 ml-4"> + <SpareComponent + :speech="[ + 'Her kan du fylle inn hvor kjent du er med sparing fra før, slik at vi kan hjelpe deg pÃ¥ best mulig mÃ¥te! 💡' + ]" + :png-size="10" + :direction="'right'" + :imageDirection="'right'" + ></SpareComponent> + <p class="text-xs absolute left-0 md:ml-3 ml-1 mt-2">Trykk pÃ¥ meg for hjelp â—ï¸</p> + </div> <div class="grid grid-cols-1 gap-8 mb-16 sm:gap-14 sm:mb-20 md:grid-cols-3"> <div :class="{ @@ -51,6 +62,7 @@ import { ref } from 'vue' import ContinueButtonComponent from '@/components/ContinueButtonComponent.vue' import router from '@/router' import { useUserConfigStore } from '@/stores/userConfigStore' +import SpareComponent from '@/components/SpareComponent.vue' const selectedOption = ref<string | null>(null) const userConfigStore = useUserConfigStore() diff --git a/src/views/ConfigHabitChangeView.vue b/src/views/ConfigHabitChangeView.vue index a1e8b37350d385d2c3f31db77f2b8c81b56b6e41..fec0b011cbd1d06208b65381f54d4a318a93843c 100644 --- a/src/views/ConfigHabitChangeView.vue +++ b/src/views/ConfigHabitChangeView.vue @@ -3,6 +3,17 @@ <h1 class="mb-8 text-2xl font-bold sm:mb-16 sm:text-4xl"> Hvor store vaneedringer er du villig til Ã¥ gjøre? </h1> + <div class="absolute bottom-0 md:bottom-40 left-0 w-40 h-40 md:w-52 md:h-52 ml-4"> + <SpareComponent + :speech="[ + 'Her kan du velge hvor mye innsats du er villig til Ã¥ legge inn for Ã¥ endre vanene dine! 📚' + ]" + :png-size="10" + :direction="'right'" + :imageDirection="'right'" + ></SpareComponent> + <p class="text-xs absolute left-0 md:ml-3 ml-1 mt-2">Trykk pÃ¥ meg for hjelp â—ï¸</p> + </div> <div class="grid grid-cols-1 gap-8 mb-16 sm:gap-14 sm:mb-20 md:grid-cols-3"> <div :class="{ @@ -51,6 +62,7 @@ import { ref } from 'vue' import ContinueButtonComponent from '@/components/ContinueButtonComponent.vue' import router from '@/router' import { useUserConfigStore } from '@/stores/userConfigStore' +import SpareComponent from '@/components/SpareComponent.vue' const selectedOption = ref<string | null>(null) const userConfigStore = useUserConfigStore() diff --git a/src/views/ConfigSpendingItemsAmountView.vue b/src/views/ConfigSpendingItemsAmountView.vue index aa77feb1371de00b5a70dfe7815dd86e77f939c5..b1f28b295aca4e3dec917faece5d9d333f2fa4d0 100644 --- a/src/views/ConfigSpendingItemsAmountView.vue +++ b/src/views/ConfigSpendingItemsAmountView.vue @@ -3,6 +3,18 @@ <h1 class="mb-8 text-2xl font-bold sm:mb-16 sm:text-4xl"> Hvor mye bruker du per kjøp pÃ¥ ... </h1> + <div class="absolute bottom-0 md:bottom-40 left-0 w-40 h-40 md:w-52 md:h-52 ml-4"> + <SpareComponent + :speech="[ + 'Her kan du skrive inn hvor mye penger du bruker per kjøp pÃ¥ ulike ting. ðŸ”', + 'For eksempel koster en kopp kaffe â˜•ï¸ kanskje 30 kr, mens en kinobillett ðŸŽŸï¸ koster 100 kr.' + ]" + :png-size="10" + :direction="'right'" + :imageDirection="'right'" + ></SpareComponent> + <p class="text-xs absolute left-0 md:ml-3 ml-1 mt-2">Trykk pÃ¥ meg for hjelp â—ï¸</p> + </div> <div class="w-full flex justify-center"> <div :class="[showSecondBox ? 'md:grid md:grid-cols-2 md:gap-4 sm:gap-8 mb-6' : '']"> <div @@ -79,6 +91,7 @@ import { computed, ref } from 'vue' import ContinueButtonComponent from '@/components/ContinueButtonComponent.vue' import router from '@/router' import { useUserConfigStore } from '@/stores/userConfigStore' +import SpareComponent from '@/components/SpareComponent.vue' const userConfigStore = useUserConfigStore() diff --git a/src/views/ConfigSpendingItemsTotalAmountView.vue b/src/views/ConfigSpendingItemsTotalAmountView.vue index 098ba86faec729bcbc8450ad91268d7e65d82536..e416dc51dd2791923831261a953569890c1ebc6f 100644 --- a/src/views/ConfigSpendingItemsTotalAmountView.vue +++ b/src/views/ConfigSpendingItemsTotalAmountView.vue @@ -3,6 +3,18 @@ <h1 class="mb-8 text-2xl font-bold sm:mb-16 sm:text-4xl"> Hvor mye bruker du per uke pÃ¥ ... </h1> + <div class="absolute bottom-0 md:bottom-40 left-0 w-40 h-40 md:w-52 md:h-52 ml-4"> + <SpareComponent + :speech="[ + 'Her skal du skrive inn hvor mye du bruker per uke pÃ¥ ulike kategorier. 🗓ï¸', + 'Hvis du kjøper kaffe hver dag, kan du skrive inn hvor mye du bruker pÃ¥ kaffe per uke.' + ]" + :png-size="10" + :direction="'right'" + :imageDirection="'right'" + ></SpareComponent> + <p class="text-xs absolute left-0 md:ml-3 ml-1 mt-2">Trykk pÃ¥ meg for hjelp â—ï¸</p> + </div> <div class="w-full flex justify-center"> <div :class="[showSecondBox ? 'md:grid md:grid-cols-2 md:gap-4 sm:gap-8 mb-6' : '']"> <div @@ -79,6 +91,7 @@ import { computed, ref } from 'vue' import ContinueButtonComponent from '@/components/ContinueButtonComponent.vue' import router from '@/router' import { useUserConfigStore } from '@/stores/userConfigStore' +import SpareComponent from '@/components/SpareComponent.vue' const userConfigStore = useUserConfigStore() @@ -93,7 +106,6 @@ const onButtonClick = async () => { parseFloat(amounts.value[index]) || 0 }) - userConfigStore.postUserConfig() await router.push({ name: 'configurations6' }) } diff --git a/src/views/ConfigSpendingItemsView.vue b/src/views/ConfigSpendingItemsView.vue index fbeda5f09011b4a3f8639d752c02eab917c32e14..7313cc1719c4f6d6f1700ecca5ab5ace4988e821 100644 --- a/src/views/ConfigSpendingItemsView.vue +++ b/src/views/ConfigSpendingItemsView.vue @@ -1,6 +1,19 @@ <template> <div class="flex flex-col items-center justify-center min-h-screen text-center"> <h1 class="mb-8 text-2xl font-bold sm:mb-16 sm:text-4xl">Hva bruker du mye penger pÃ¥?</h1> + <div class="absolute bottom-0 md:bottom-40 left-0 w-40 h-40 md:w-52 md:h-52 ml-4"> + <SpareComponent + :speech="[ + 'Her kan du velge hva du bruker mye penger pÃ¥, slik at vi kan hjelpe deg med Ã¥ spare penger! 💸', + 'Hvis du ikke finner noe som passer, kan du skrive inn egne kategorier i \'Annet ...\' feltet', + 'Du mÃ¥ minst velge en kategori!' + ]" + :png-size="10" + :direction="'right'" + :imageDirection="'right'" + ></SpareComponent> + <p class="text-xs absolute left-0 md:ml-3 ml-1 mt-2">Trykk pÃ¥ meg for hjelp â—ï¸</p> + </div> <div class="flex flex-wrap justify-center gap-8 mb-8"> <div class="flex flex-col items-center justify-center bg-white rounded-lg sm:p-8 shadow-lg sm:w-full md:w-[45%]" @@ -68,6 +81,7 @@ import { computed, ref } from 'vue' import ContinueButtonComponent from '@/components/ContinueButtonComponent.vue' import router from '@/router' import { useUserConfigStore } from '@/stores/userConfigStore' +import SpareComponent from '@/components/SpareComponent.vue' const userConfigStore = useUserConfigStore() const selectedOptions = ref<string[]>([]) diff --git a/src/views/FirstSavingChallengeView.vue b/src/views/FirstSavingChallengeView.vue deleted file mode 100644 index 23cc78e8411420523f7b280e58da5ef88564005b..0000000000000000000000000000000000000000 --- a/src/views/FirstSavingChallengeView.vue +++ /dev/null @@ -1,112 +0,0 @@ -<template> - <div class="flex flex-col items-center justify-start min-h-screen px-4 text-center"> - <div class="mb-20"> - <div - class="flex flex-col items-center justify-start bg-white shadow-md rounded-lg p-16" - style="height: 530px; min-height: 500px; min-width: 400px; max-width: 400px" - > - <template v-if="!skipped && !accepted"> - <div class="mb-6 w-full text-left"> - <label for="savings-goal" class="block text-4xl font-bold mb-2" - >Spareutfordring</label - > - </div> - <div class="flex flex-col w-full mb-4"> - <button - v-for="buttonText in buttonOptions" - :key="buttonText" - :class="[ - 'mb-4 text-xl font-bold w-full rounded-lg py-3 px-4', - selectedOptions.includes(buttonText) - ? 'bg-transparent border-2 border-[var(--green)]' - : 'bg-transparent border-2 border-gray-300' - ]" - @click="toggleOption(buttonText)" - > - {{ buttonText }} - </button> - </div> - <div class="flex justify-between w-full mt-4 space-x-2"> - <button - class="border-4 font-bold rounded-lg py-2 px-10 text-lg transition-all bg-[var(--green)] hover:brightness-90 active:brightness-75" - @click="skip" - style="margin-top: 29px" - > - Skip - </button> - <button - :class="[ - 'border-4 font-bold rounded-lg py-2 px-10 text-lg transition-all', - { - 'bg-[var(--green)] hover:brightness-90 active:brightness-75': - selectedOptions.length > 0 - }, - { - 'opacity-60 bg-[rgba(149,227,93,0.6)] cursor-not-allowed': - selectedOptions.length === 0 - } - ]" - :disabled="selectedOptions.length === 0" - @click="accept" - style="margin-top: 29px" - > - Godta - </button> - </div> - </template> - <template v-else> - <div class="flex justify-center items-center h-full"> - <div class="text-4xl font-bold">{{ acceptedMessage }}</div> - </div> - </template> - </div> - </div> - <ContinueButtonComponent - :disabled="!skipped && !accepted" - @click="onButtonClick" - class="px-10 py-3 text-2xl font-bold self-end mb-32 mt-[-10px]" - ></ContinueButtonComponent> - </div> -</template> - -<script setup lang="ts"> -import ContinueButtonComponent from '@/components/ContinueButtonComponent.vue' -import router from '@/router' -import { ref, watchEffect } from 'vue' - -const buttonOptions = ref(['Ikke kjøpe kaffe', 'Ikke kjøpe snus', 'Ikke kjøpe mat i kantina']) -const selectedOptions = ref<string[]>([]) -const skipped = ref(false) -const accepted = ref(false) - -const toggleOption = (option: string) => { - const index = selectedOptions.value.indexOf(option) - if (index === -1) { - selectedOptions.value.push(option) - } else { - selectedOptions.value.splice(index, 1) - } -} - -const onButtonClick = () => { - router.push('/') -} - -const skip = () => { - skipped.value = true -} - -const accept = () => { - accepted.value = true -} - -const acceptedMessage = ref('Du kan opprette spareutfordringer senere') - -watchEffect(() => { - if (accepted.value) { - acceptedMessage.value = 'Du har fÃ¥tt din første spareutfordring!' - } else if (skipped.value) { - acceptedMessage.value = 'Du kan opprette spareutfordringer senere' - } -}) -</script> diff --git a/src/views/FirstSavingGoalView.vue b/src/views/FirstSavingGoalView.vue deleted file mode 100644 index bd28ba97b26defe4d241f903d973e542c9636717..0000000000000000000000000000000000000000 --- a/src/views/FirstSavingGoalView.vue +++ /dev/null @@ -1,142 +0,0 @@ -<template> - <div class="flex flex-col items-center justify-start min-h-screen px-4 text-center"> - <div class="mb-20"> - <div - class="flex flex-col items-center justify-center bg-white shadow-md rounded-lg p-16" - style="height: 530px; min-height: 500px; min-width: 400px; max-width: 400px" - > - <template v-if="!skipped && !accepted"> - <div class="mb-6 w-full text-left"> - <label for="savings-goal" class="block text-xl font-bold mb-2" - >Jeg vil spare til:</label - > - <input - type="text" - id="savings-goal" - v-model="savingsGoal" - :class="{ - 'border-[var(--green)]': savingsGoal.valueOf(), - 'border-gray-300': !savingsGoal.valueOf() - }" - class="border-2 block w-full rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 text-xl" - placeholder="" - /> - </div> - <div class="mb-8 w-full flex items-center"> - <label for="amount" class="shrink-0 text-xl font-bold mr-2" - >Jeg vil spare:</label - > - <input - type="text" - id="amount" - v-model="rawAmount" - :class="{ - 'border-[var(--green)]': rawAmount.valueOf(), - 'border-gray-300': !rawAmount.valueOf() - }" - class="border-2 rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 text-xl mr-2 block w-full" - placeholder="" - min="0" - /> - <span class="shrink-0 text-xl font-bold">kr</span> - </div> - <div class="w-full px-4 py-2"> - <img src="@/assets/penger.png" alt="Savings" class="mx-auto w-36 h-32" /> - </div> - <div class="flex justify-between w-full mt-4 space-x-2"> - <button - class="bg-[var(--green)] border-4 border-[var(--green)] hover:brightness-90 active:brightness-75 font-bold rounded-lg py-2 px-10 text-lg" - @click="skip" - > - Skip - </button> - <button - :class="[ - 'border-4 font-bold rounded-lg py-2 px-10 text-lg transition-all', - canAccept - ? 'bg-[var(--green)] hover:brightness-90 active:brightness-75' - : 'opacity-60 bg-gray-300 cursor-not-allowed' - ]" - :disabled="!canAccept" - @click="accept" - > - Godta - </button> - </div> - </template> - <template v-else> - <div - class="flex justify-start items-center h-full min-h-[400px] min-w-[400px] max-w-[400px]" - > - <div class="text-4xl font-bold">{{ acceptedMessage }}</div> - </div> - </template> - </div> - </div> - <ContinueButtonComponent - :disabled="!skipped && !accepted" - @click="onButtonClick" - class="px-10 py-3 text-lg font-bold self-end mb-80 mt-[-10px]" - ></ContinueButtonComponent> - </div> -</template> - -<script setup lang="ts"> -import { computed, ref, watch, watchEffect } from 'vue' -import ContinueButtonComponent from '@/components/ContinueButtonComponent.vue' -import router from '@/router' - -const savingsGoal = ref('') -const rawAmount = ref('') -const skipped = ref(false) -const accepted = ref(false) - -const validateAmount = () => { - const validPattern = /^(\d+)?(,\d*)?$/ - if (!validPattern.test(rawAmount.value)) { - rawAmount.value = rawAmount.value.slice(0, -1) - } else if (rawAmount.value.includes(',')) { - rawAmount.value = rawAmount.value.replace(/,+/g, ',') - } -} - -const checkNegative = () => { - const numericValue = parseFloat(rawAmount.value.replace(',', '.')) - if (numericValue < 0) { - rawAmount.value = '' - } -} - -watch(rawAmount, validateAmount) -watch(() => parseFloat(rawAmount.value.replace(',', '.')), checkNegative) - -const canAccept = computed(() => savingsGoal.value.trim() !== '' && rawAmount.value.trim() !== '') - -const skip = () => { - skipped.value = true - acceptedMessage.value = 'Du kan opprette sparemÃ¥l senere' -} - -const accept = () => { - if (canAccept.value) { - accepted.value = true - acceptedMessage.value = 'Du har fÃ¥tt ditt første sparemÃ¥l!' - } -} - -const onButtonClick = () => { - if (skipped.value || accepted.value) { - router.push('/forsteSpareutfordring') - } -} - -const acceptedMessage = ref('') - -watchEffect(() => { - if (accepted.value) { - acceptedMessage.value = 'Du har fÃ¥tt ditt første sparemÃ¥l!' - } else if (skipped.value) { - acceptedMessage.value = 'Du kan opprette sparemÃ¥l senere' - } -}) -</script> diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index f596bcaee5dac3d20b57091ba4c959c50580dc9e..052d5d3d447ecd1f0b8e4686a625a2d7417c7f8c 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -1,39 +1,57 @@ <template> <div class="flex flex-col items-center max-h-[60vh] md:flex-row md:max-h-[80vh] mx-auto"> - <div class="flex flex-col basis-1/3 order-last md:order-first md:basis-1/4 md:pl-10"> - <InteractiveSpare + <div class="flex flex-col basis-1/3 order-last md:order-first md:basis-1/4 md:pl-1 mt-10"> + <SpareComponent :speech="speech" + :show="showWelcome" + :png-size="12" :direction="'right'" - :pngSize="15" - class="opacity-0 h-0 w-0 md:opacity-100 md:h-auto md:w-auto md:mx-auto md:my-20" - ></InteractiveSpare> - <div class="flex flex-row gap-2 items-center mx-auto my-4 md:flex-col md:gap-4 md:m-8"> + :imageDirection="'right'" + class="mt-24" + ></SpareComponent> + <div + class="flex flex-row gap-2 items-center mx-auto mt-4 mb-20 md:flex-col md:gap-4 md:m-8" + > <ButtonAddGoalOrChallenge :buttonText="'Legg til sparemÃ¥l'" :type="'goal'" /> <ButtonAddGoalOrChallenge :buttonText="'Legg til spareutfordring'" :type="'challenge'" /> + <ButtonAddGoalOrChallenge + :buttonText="'Generer spareutfordring'" + :type="'generatedChallenge'" + :showModal="showModal" + @click="showModal = true" + @update:showModal="showModal = $event" + /> </div> </div> <savings-path :challenges="challenges" :goal="goal"></savings-path> </div> + <GeneratedChallengesModal v-show="showModal" @update:showModal="showModal = $event" /> </template> <script setup lang="ts"> import { onMounted, ref } from 'vue' -import InteractiveSpare from '@/components/InteractiveSpare.vue' import ButtonAddGoalOrChallenge from '@/components/ButtonAddGoalOrChallenge.vue' import type { Challenge } from '@/types/challenge' import type { Goal } from '@/types/goal' import { useGoalStore } from '@/stores/goalStore' import { useChallengeStore } from '@/stores/challengeStore' import SavingsPath from '@/components/SavingsPath.vue' +import router from '@/router' +import GeneratedChallengesModal from '@/components/GeneratedChallengesModal.vue' +import SpareComponent from '@/components/SpareComponent.vue' + +const showModal = ref(false) const goalStore = useGoalStore() const challengeStore = useChallengeStore() +const speech = ref<string[]>([]) const challenges = ref<Challenge[]>([]) const goals = ref<Goal[]>([]) +const showWelcome = ref<boolean>(false) const goal = ref<Goal | null | undefined>(null) @@ -44,14 +62,31 @@ onMounted(async () => { goals.value = goalStore.goals goal.value = goals.value[0] console.log('Goals:', goals.value) + + const lastModalShow = localStorage.getItem('lastModalShow') + if (!lastModalShow || Date.now() - Number(lastModalShow) >= 24 * 60 * 60 * 1000) { + showModal.value = true + } + firstLoggedInSpeech() + SpareSpeech() }) -// Define your speech array -const speechArray = [ - 'Hei! Jeg er Sparemannen.', - 'Jeg hjelper deg med Ã¥ spare penger.', - 'Klikk pÃ¥ meg for Ã¥ høre mer.' -] +const firstLoggedInSpeech = () => { + const isFirstLogin = router.currentRoute.value.query.firstLogin === 'true' + if (isFirstLogin) { + showWelcome.value = true + speech.value.push('Hei, jeg er Spare!') + speech.value.push('Jeg skal hjelpe deg med Ã¥ spare penger.') + speech.value.push('Trykk pÃ¥ meg for Ã¥ høre hva jeg har Ã¥ si ðŸ·') + router.replace({ name: 'home', query: { firstLogin: 'false' } }) + } +} -const speech = ref(speechArray) +const SpareSpeech = () => { + speech.value = [ + 'Hei! Jeg er sparegrisen, Spare!', + 'Valkommen til SpareSti 👑', + 'Du kan trykke pÃ¥ meg for Ã¥ høre hva jeg har Ã¥ si ðŸ·' + ] +} </script> diff --git a/src/views/ManageConfigView.vue b/src/views/ManageConfigView.vue new file mode 100644 index 0000000000000000000000000000000000000000..6ff3eaa9ac3ee00f56bcc7e1ae867b9dab1d57f4 --- /dev/null +++ b/src/views/ManageConfigView.vue @@ -0,0 +1,183 @@ +<script lang="ts" setup> +import authInterceptor from '@/services/authInterceptor' +import CardTemplate from '@/components/CardTemplate.vue' +import type { ChallengeConfig } from '@/types/challengeConfig' +import { onMounted, ref } from 'vue' +import ModalComponent from '@/components/ModalComponent.vue' +import router from '@/router' + +const configuration = ref<ChallengeConfig>({ + motivation: '', + experience: '', + challengeTypeConfigs: [ + { + type: 'Kaffe', + generalAmount: 100, + specificAmount: 10 + } + ] +}) + +const error = ref<string | null>(null) + +const deleteChallengeType = (type: string) => { + if (configuration.value.challengeTypeConfigs) { + configuration.value.challengeTypeConfigs = configuration.value.challengeTypeConfigs.filter( + (item) => item.type !== type + ) + } +} + +const createChallengeType = () => { + configuration.value.challengeTypeConfigs?.push({ + type: '', + specificAmount: null, + generalAmount: null + }) +} + +const validateAndSave = () => { + if (!configuration.value.motivation) { + return (error.value = 'Du mÃ¥ velge hvor store vaneendringer du er villig til Ã¥ gjøre') + } + + if (!configuration.value.experience) { + return (error.value = 'Du mÃ¥ velge hvor kjent du er med sparing fra før av') + } + + if (configuration.value.challengeTypeConfigs.length == 0) { + return (error.value = 'Du mÃ¥ legge til minst én ting du bruker mye penger pÃ¥') + } + + if ( + configuration.value.challengeTypeConfigs.some( + (item) => !item.type || !item.specificAmount || !item.generalAmount + ) + ) { + return (error.value = 'Du mÃ¥ fylle ut alle feltene for ting du bruker mye penger pÃ¥') + } + + if ( + configuration.value.challengeTypeConfigs.some( + (item) => + (item.specificAmount && item.specificAmount < 0) || + (item.generalAmount && item.generalAmount < 0) + ) + ) { + return (error.value = 'Prisene kan ikke være negative') + } + + saveConfiguration() +} + +const saveConfiguration = () => { + authInterceptor + .put('/config/challenge', configuration.value) + .then(() => { + router.push({ name: 'profile' }) + }) + .catch((error) => { + error.value = error.response.data.message + }) +} + +onMounted(() => { + authInterceptor('/config/challenge') + .then((response) => { + configuration.value = response.data + console.log(configuration.value) + }) + .catch((error) => { + return console.log(error) + }) +}) +</script> + +<template> + <div class="w-full flex px-10 justify-center"> + <div class="flex flex-col justify-center items-center max-w-screen-xl gap-3"> + <h1>Rediger kofigurasjonen</h1> + + <h2 class="font-thin">Hvor store vaneedringer er du villig til Ã¥ gjøre?</h2> + <div v-if="configuration" class="flex flex-row gap-5"> + <CardTemplate + :class="{ 'bg-green-500': configuration.motivation === 'VERY_LOW' }" + class="cursor-pointer p-5" + @click="configuration.motivation = 'VERY_LOW'" + > + <p class="text-2xl">Litt</p> + </CardTemplate> + <CardTemplate + :class="{ 'bg-green-500': configuration.motivation === 'MEDIUM' }" + class="cursor-pointer p-5" + @click="configuration.motivation = 'MEDIUM'" + > + <p class="text-2xl">Passe</p> + </CardTemplate> + <CardTemplate + :class="{ 'bg-green-500': configuration.motivation === 'VERY_HIGH' }" + class="cursor-pointer p-5" + @click="configuration.motivation = 'VERY_HIGH'" + > + <p class="text-2xl">Store</p> + </CardTemplate> + </div> + + <h2 class="font-thin">Hvor kjent er du med sparing fra før av?</h2> + <div v-if="configuration" class="flex flex-row gap-5"> + <CardTemplate + :class="{ 'bg-green-500': configuration.experience === 'VERY_LOW' }" + class="cursor-pointer p-5" + @click="configuration.experience = 'VERY_LOW'" + > + <p class="text-2xl">Litt kjent</p> + </CardTemplate> + <CardTemplate + :class="{ 'bg-green-500': configuration.experience === 'MEDIUM' }" + class="cursor-pointer p-5" + @click="configuration.experience = 'MEDIUM'" + > + <p class="text-2xl">Noe kjent</p> + </CardTemplate> + <CardTemplate + :class="{ 'bg-green-500': configuration.experience === 'VERY_HIGH' }" + class="cursor-pointer p-5" + @click="configuration.experience = 'VERY_HIGH'" + > + <p class="text-2xl">Godt kjent</p> + </CardTemplate> + </div> + + <h2 class="font-thin my-0">Hva bruker du mye penger pÃ¥?</h2> + <div class="flex flex-col gap-4 p-4 items-center"> + <CardTemplate + v-for="(item, index) in configuration.challengeTypeConfigs" + :key="index" + class="flex flex-row flex-wrap justify-center gap-5 border-4 p-3" + > + <input v-model="item.type" placeholder="Type" type="text" /> + <input v-model="item.specificAmount" placeholder="Pris per uke" type="number" /> + <input v-model="item.generalAmount" placeholder="Generell pris" type="number" /> + <button + class="cursor-pointer bg-red-500 rounded-full w-min items-center" + @click="deleteChallengeType(item.type)" + v-text="'x'" + /> + </CardTemplate> + <button class="secondary" @click="createChallengeType" v-text="'+'" /> + </div> + + <div class="flex flex-row justify-center gap-5"> + <button class="secondary" @click="router.back()">Avbryt</button> + <button class="primary" @click="validateAndSave">Lagre</button> + </div> + </div> + + <ModalComponent v-if="error"> + <p class="my-4" v-text="error" /> + <button @click="error = null">Lukk</button> + </ModalComponent> + </div> +</template> + +<style scoped></style> diff --git a/src/views/ManageGoalView.vue b/src/views/ManageGoalView.vue index 14c44badfe752a364a420f17ac4fb022dd3f2f46..f3fd1a1850d1ee962e2ca24cc592745e29ef6e53 100644 --- a/src/views/ManageGoalView.vue +++ b/src/views/ManageGoalView.vue @@ -126,7 +126,7 @@ const updateGoal = () => { authInterceptor .put(`/goals/${goalInstance.value.id}`, goalInstance.value) .then(() => { - router.push({ name: 'goals' }) + router.back() }) .catch((error) => { console.error(error) diff --git a/src/views/EditProfileView.vue b/src/views/ManageProfileView.vue similarity index 81% rename from src/views/EditProfileView.vue rename to src/views/ManageProfileView.vue index b6aefeff8beedb21eb6b342b1361c15a72782685..6ddce344664e5226925b535a2f5343f432f49c31 100644 --- a/src/views/EditProfileView.vue +++ b/src/views/ManageProfileView.vue @@ -2,7 +2,7 @@ import authInterceptor from '@/services/authInterceptor' import { computed, onMounted, ref } from 'vue' import type { Profile } from '@/types/profile' -import CardTemplate from '@/views/CardTemplate.vue' +import CardTemplate from '@/components/CardTemplate.vue' import router from '@/router' import ToolTip from '@/components/ToolTip.vue' import InteractiveSpare from '@/components/InteractiveSpare.vue' @@ -27,14 +27,29 @@ const profile = ref<Profile>({ const updatePassword = ref<boolean>(false) const confirmPassword = ref<string>('') const errorMessage = ref<string>('') +const isModalOpen = ref(false) -const nameRegex = /^[æÆøØåÅa-zA-Z,.'-][æÆøØåÅa-zA-Z ,.'-]{1,29}$/ +const nameRegex = /^[æÆøØåÅa-zA-Z,.'-][æÆøØåÅa-zA-Z ,.'-]{0,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 MAX_DIGITS = 11 + +function restrictToNumbers(event: InputEvent, type: string) { + const inputValue = (event.target as HTMLInputElement)?.value + if (inputValue !== undefined) { + const sanitizedValue = inputValue.replace(/\D/g, '') + const truncatedValue = sanitizedValue.slice(0, MAX_DIGITS) + if (type === 'spending') { + profile.value.spendingAccount.accNumber = parseInt(truncatedValue) + } else { + profile.value.savingAccount.accNumber = parseInt(truncatedValue) + } + } +} + const isFirstNameValid = computed( () => nameRegex.test(profile.value.firstName) && profile.value.firstName ) @@ -42,7 +57,6 @@ 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() || '') @@ -57,7 +71,6 @@ const isFormInvalid = computed( isFirstNameValid, isLastNameValid, isEmailValid, - isUsernameValid, isSpendingAccountValid, isSavingAccountValid ].some((v) => !v.value) || @@ -107,7 +120,7 @@ const saveChanges = async () => { <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-slate-200 border-2 rounded-full shrink-0" /> + <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> @@ -122,6 +135,7 @@ const saveChanges = async () => { </div> <input v-model="profile.firstName" + :class="{ 'bg-green-200': isFirstNameValid }" name="firstname" placeholder="Skriv inn fornavn" type="text" @@ -136,6 +150,7 @@ const saveChanges = async () => { </div> <input v-model="profile.lastName" + :class="{ 'bg-green-200': isLastNameValid }" name="lastname" placeholder="Skriv inn etternavn" type="text" @@ -150,25 +165,12 @@ const saveChanges = async () => { </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" - 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"> @@ -183,6 +185,7 @@ const saveChanges = async () => { <input v-if="updatePassword" v-model="profile.password" + :class="{ 'bg-green-200': isPasswordValid }" class="w-full" name="password" placeholder="Skriv inn passord" @@ -190,6 +193,7 @@ const saveChanges = async () => { <input v-if="updatePassword" v-model="confirmPassword" + :class="{ 'bg-red-200': profile.password !== confirmPassword }" class="mt-2" name="confirm" placeholder="Bekreft passord" @@ -204,36 +208,41 @@ const saveChanges = async () => { :png-size="10" :speech="['Her kan du endre pÃ¥ profilen din!']" direction="left" + :isModalOpen="isModalOpen" /> <CardTemplate> - <div class="bg-red-100"> + <div class="bg-red-300"> <p class="font-bold mx-3" v-text="'Brukskonto'" /> </div> <input + @input="restrictToNumbers($event as InputEvent, 'spending')" v-model="profile.spendingAccount.accNumber" - class="border-1 rounded-none rounded-b-xl w-full" + :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-100"> + <div class="bg-red-300"> <p class="font-bold mx-3" v-text="'Sparekonto'" /> </div> <input + @input="restrictToNumbers($event as InputEvent, 'saving')" v-model="profile.savingAccount.accNumber" - class="border-1 rounded-none rounded-b-xl w-full" + :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="primary danger" @click="router.back()" v-text="'Avbryt'" /> + <button class="bg-button-other" @click="router.back()" v-text="'Avbryt'" /> <button - class="primary" + :disabled="isFormInvalid" @click="saveChanges" v-text="'Lagre endringer'" /> @@ -242,5 +251,3 @@ const saveChanges = async () => { </div> </div> </template> - -<style scoped></style> diff --git a/src/views/RegisterLoginView.vue b/src/views/RegisterLoginView.vue index 738002afa9461e9df0acbd0f592b327303ec33ba..16d78619fdf348fd038c8858499065d46d11c923 100644 --- a/src/views/RegisterLoginView.vue +++ b/src/views/RegisterLoginView.vue @@ -2,9 +2,7 @@ import FormLogin from '@/components/FormLogin.vue' import FormRegister from '@/components/FormRegister.vue' import { onMounted, ref } from 'vue' -import { useRouter } from 'vue-router' - -const router = useRouter() +import router from '@/router' const isLogin = ref<boolean>(true) diff --git a/src/views/UserGoalsView.vue b/src/views/UserGoalsView.vue index aaba6e667b3168cabf654dab806f659abced84e0..b41e7c44b7c36f44f09436d8d57f1ba38313dcfd 100644 --- a/src/views/UserGoalsView.vue +++ b/src/views/UserGoalsView.vue @@ -107,6 +107,3 @@ const changeOrder = async () => { /> </div> </template> - -<style scoped> -</style> \ No newline at end of file diff --git a/src/views/ViewChallengeView.vue b/src/views/ViewChallengeView.vue index d1b1e0d9603efe20bb3f2600a72b7d26151672a8..76f11a2a0777ed49e23caf1b4291dbdd434a9d61 100644 --- a/src/views/ViewChallengeView.vue +++ b/src/views/ViewChallengeView.vue @@ -4,12 +4,12 @@ import { computed, onMounted, ref } from 'vue' import ProgressBar from '@/components/ProgressBar.vue' import authInterceptor from '@/services/authInterceptor' import type { Challenge } from '@/types/challenge' -import InteractiveSpare from '@/components/InteractiveSpare.vue' +import SpareComponent from '@/components/SpareComponent.vue' const router = useRouter() const challengeInstance = ref<Challenge>({ - title: 'Test titel', + title: 'Tittel', perPurchase: 20, saved: 0, target: 100, @@ -144,7 +144,13 @@ const completeChallenge = () => { /> </div> </div> - <InteractiveSpare :png-size="10" :speech="motivation" direction="left" /> + <SpareComponent + :speech="motivation" + :png-size="15" + :imageDirection="'left'" + :direction="'right'" + class="mb-5" + ></SpareComponent> </div> </template> diff --git a/src/views/ViewGoalView.vue b/src/views/ViewGoalView.vue index 68ca0699389b294b385e61c595f725e9888a7b5d..2c2fa3a3b2d930feb3c044b97fd05739310fca17 100644 --- a/src/views/ViewGoalView.vue +++ b/src/views/ViewGoalView.vue @@ -4,7 +4,7 @@ import { computed, onMounted, ref } from 'vue' import ProgressBar from '@/components/ProgressBar.vue' import authInterceptor from '@/services/authInterceptor' import type { Goal } from '@/types/goal' -import InteractiveSpare from '@/components/InteractiveSpare.vue' +import SpareComponent from '@/components/SpareComponent.vue' const router = useRouter() @@ -40,7 +40,7 @@ const calculateSpeech = () => { ) } else if (completion.value >= 100) { return motivation.value.push( - `Fantastisk! Du har nÃ¥dd mÃ¥let ditt! Du har spart ${goalInstance.value.saved}kr av ${goalInstance.value.target}kr.` + `!Fantastisk Du har nÃ¥dd mÃ¥let ditt! Du har spart ${goalInstance.value.saved}kr av ${goalInstance.value.target}kr.` ) } } @@ -130,10 +130,14 @@ const completeGoal = () => { v-text="'Marker mÃ¥let som ferdig'" /> </div> - </div> - <InteractiveSpare :png-size="10" :speech="motivation" direction="left" /> - <div> </div> + <SpareComponent + :speech="motivation" + :png-size="15" + :imageDirection="'left'" + :direction="'right'" + class="mb-5" + ></SpareComponent> </div> </template> diff --git a/src/views/ProfileView.vue b/src/views/ViewProfileView.vue similarity index 71% rename from src/views/ProfileView.vue rename to src/views/ViewProfileView.vue index 028dc13975cc998868415dcd89418d38cb636c1f..6993e7d21e75ef166287754e724686434d228467 100644 --- a/src/views/ProfileView.vue +++ b/src/views/ViewProfileView.vue @@ -1,20 +1,22 @@ <script lang="ts" setup> import authInterceptor from '@/services/authInterceptor' -import { computed, onMounted, ref } from 'vue' +import { onMounted, ref } from 'vue' import type { Profile } from '@/types/profile' -import CardTemplate from '@/views/CardTemplate.vue' -import InteractiveSpare from '@/components/InteractiveSpare.vue' +import CardTemplate from '@/components/CardTemplate.vue' import type { Challenge } from '@/types/challenge' import type { Goal } from '@/types/goal' import CardGoal from '@/components/CardGoal.vue' import router from '@/router' +import SpareComponent from '@/components/SpareComponent.vue' +import { useUserStore } from '@/stores/userStore' const profile = ref<Profile>() const completedGoals = ref<Goal[]>([]) const completedChallenges = ref<Challenge[]>([]) +const speech = ref<string[]>([]) -onMounted(async () => { - await authInterceptor('/profile') +const updateUser = async () => { + authInterceptor('/profile') .then((response) => { profile.value = response.data console.log(profile.value) @@ -22,6 +24,10 @@ onMounted(async () => { .catch((error) => { return console.log(error) }) +} + +onMounted(async () => { + await updateUser() await authInterceptor(`/goals/completed?page=0&size=3`) .then((response) => { @@ -38,20 +44,30 @@ onMounted(async () => { .catch((error) => { return console.log(error) }) -}) -const welcome = computed(() => { - return [`Velkommen, ${profile.value?.firstName} ${profile.value?.lastName} !`] + openSpare() }) +const updateBiometrics = async () => { + await useUserStore().bioRegister() + await updateUser() +} + +const openSpare = () => { + speech.value = [ + `Velkommen, ${profile.value?.firstName} ${profile.value?.lastName} !`, + 'Her kan du finne en oversikt over dine profilinstillinger!', + 'Du kan ogsÃ¥ se dine fullførte sparemÃ¥l og utfordringer!' + ] +} </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>Profil</h1> + <h1>Profile</h1> <div class="flex flex-row gap-5"> - <div class="w-32 h-32 border-slate-200 border-2 rounded-full shrink-0" /> + <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"> @@ -61,7 +77,7 @@ const welcome = computed(() => { </div> </div> - <h3 class="font-bold" v-text="'Du har spart ' + '< totalSaved >' + ' kr!'" /> + <h3 class="font-bold" v-text="'Du har spart ' + '< totalSaved >' + 'kr'" /> <CardTemplate> <div class="bg-red-300"> @@ -83,13 +99,24 @@ const welcome = computed(() => { /> </CardTemplate> - <button - class="primary secondary w-40" - @click="router.push({ name: 'edit-profile' })" v-text="'Rediger bruker'" /> + <button @click="router.push({ name: 'edit-profile' })" v-text="'Rediger bruker'" /> + <button + @click="router.push({ name: 'edit-configuration' })" + v-text="'Rediger konfigurasjon'" + /> + <button @click="updateBiometrics"> + {{ profile?.hasPasskey ? 'Endre biometri' : 'Legg til biometri' }} + </button> </div> <div class="flex flex-col"> - <InteractiveSpare :png-size="10" :speech="welcome" direction="left" /> + <SpareComponent + :speech="speech" + :png-size="15" + :imageDirection="'left'" + :direction="'right'" + class="mb-5" + ></SpareComponent> <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'" /> @@ -113,5 +140,3 @@ const welcome = computed(() => { </div> </div> </template> - -<style scoped></style>