diff --git a/cypress/e2e/homeView.cy.ts b/cypress/e2e/homeView.cy.ts index c36a497515644ec32fde9cce49a6e3f78850b3ae..8ddc7675b8e2f8c39782ff114013650c1f9a9138 100644 --- a/cypress/e2e/homeView.cy.ts +++ b/cypress/e2e/homeView.cy.ts @@ -14,6 +14,13 @@ describe('Goals and Challenges Page Load', () => { ], }, }).as('fetchGoals'); + // Mock the POST request for renewing the token if it's not implemented in the backend + cy.intercept('POST', '/auth/renewToken', { + statusCode: 200, + body: { + accessToken: 'newlyRenewedAccessToken' + } + }).as('renewToken'); cy.intercept('GET', '/challenges', { statusCode: 200, @@ -24,6 +31,14 @@ describe('Goals and Challenges Page Load', () => { }, }).as('fetchChallenges'); + cy.intercept('GET', '/profile/streak', { + statusCode: 200, + body: { + content: [ + { streak: 1, startDate: "2026-04-29T12:10:38.308Z" }, + ], + }, + }).as('fetchChallenges'); // Visit the component that triggers these requests in `onMounted` cy.visit('/hjem'); }); @@ -31,6 +46,13 @@ describe('Goals and Challenges Page Load', () => { it('loads and displays goals and challenges after onMounted', () => { // Wait for API calls made during `onMounted` to complete cy.wait(['@fetchGoals', '@fetchChallenges']); + // Mock the POST request for renewing the token if it's not implemented in the backend + cy.intercept('POST', '/auth/renewToken', { + statusCode: 200, + body: { + accessToken: 'newlyRenewedAccessToken' + } + }).as('renewToken'); // Check console logs for any errors or warnings that might indicate issues cy.window().then((win) => { diff --git a/package-lock.json b/package-lock.json index 962f0ccbb36ff69aa08fc85c6ff4ba24d66f855f..cdbcb6f750f1327fdc33e3e3d5fc21a2a8970dbd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "pinia": "^2.1.7", "vue": "^3.4.21", "vue-router": "^4.3.1", + "vue3-flip-countdown": "^0.1.6", "vuedraggable": "^4.1.0" }, "devDependencies": { @@ -7252,6 +7253,14 @@ "typescript": "*" } }, + "node_modules/vue3-flip-countdown": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/vue3-flip-countdown/-/vue3-flip-countdown-0.1.6.tgz", + "integrity": "sha512-RRz+iZ7Zvr1U9mrZRya7I5815jboDyRJz9vzgILq8ZCc2fQ6SxZPYwOr3pD5oWCDBprAEsPF9x4fsTtEitSmXw==", + "dependencies": { + "vue": "^3.0.0" + } + }, "node_modules/vuedraggable": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", diff --git a/package.json b/package.json index be13c2a5d3e4a0451de2749309b751fd1a3e17b4..3d1c8853a603f9b7c4fb8d0e0f161bdd8d57c335 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "pinia": "^2.1.7", "vue": "^3.4.21", "vue-router": "^4.3.1", + "vue3-flip-countdown": "^0.1.6", "vuedraggable": "^4.1.0" }, "devDependencies": { diff --git a/src/assets/archerSpare.gif b/src/assets/archerSpare.gif new file mode 100644 index 0000000000000000000000000000000000000000..ef0e3fa6fd0fcdd700e0e8cb233c4195b90b1355 Binary files /dev/null and b/src/assets/archerSpare.gif differ diff --git a/src/assets/backgroundSavingsPath.png b/src/assets/backgroundSavingsPath.png new file mode 100644 index 0000000000000000000000000000000000000000..43cfde5161f862f862f14f8612077b10bbcfb4e2 Binary files /dev/null and b/src/assets/backgroundSavingsPath.png differ diff --git a/src/assets/boatSpare.gif b/src/assets/boatSpare.gif new file mode 100644 index 0000000000000000000000000000000000000000..f0aaaa991168e07de3f56e5b129ae082094499f3 Binary files /dev/null and b/src/assets/boatSpare.gif differ diff --git a/src/assets/borderImage.png b/src/assets/borderImage.png new file mode 100644 index 0000000000000000000000000000000000000000..1c0ac6cdc5e2ef366679ecd0fb85f51c73ccc65f Binary files /dev/null and b/src/assets/borderImage.png differ diff --git a/src/assets/farmerSpare.gif b/src/assets/farmerSpare.gif new file mode 100644 index 0000000000000000000000000000000000000000..0f471d607f720952491c22b815486d4b58425b76 Binary files /dev/null and b/src/assets/farmerSpare.gif differ diff --git a/src/assets/finishLine.png b/src/assets/finishLine.png index f30b5a30213062013638f6e275673d65d0640777..9394bd3c85fed058ab7862e207667d0c9df00ff7 100644 Binary files a/src/assets/finishLine.png and b/src/assets/finishLine.png differ diff --git a/src/assets/flower.png b/src/assets/flower.png new file mode 100644 index 0000000000000000000000000000000000000000..c7481c94d5c7f137f0bfaad68fbf20b4d9396f9c Binary files /dev/null and b/src/assets/flower.png differ diff --git a/src/assets/infoIcon.png b/src/assets/infoIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..1aef35a260e16d428c804b38c584651fe42a1487 Binary files /dev/null and b/src/assets/infoIcon.png differ diff --git a/src/assets/pengesekkStreak.png b/src/assets/pengesekkStreak.png new file mode 100644 index 0000000000000000000000000000000000000000..54565d0fd1ad2795edeaab7eeb614e538d618dc2 Binary files /dev/null and b/src/assets/pengesekkStreak.png differ diff --git a/src/assets/savingsPathBg.png b/src/assets/savingsPathBg.png new file mode 100644 index 0000000000000000000000000000000000000000..fb1994e981fffba4df9736cd50ccd71d00e70340 Binary files /dev/null and b/src/assets/savingsPathBg.png differ diff --git a/src/components/ButtonDIsplayStreak.vue b/src/components/ButtonDIsplayStreak.vue deleted file mode 100644 index e5043e4fb746882e19dac98ed5cb8c42de707c1e..0000000000000000000000000000000000000000 --- a/src/components/ButtonDIsplayStreak.vue +++ /dev/null @@ -1,76 +0,0 @@ -<template> - <div class="flex flex-col items-center"> - <Span class="text-sm text-bold">STREAK</Span> - <button @click="toggleStreakCard" class="bg-transparent"> - <img src="@/assets/streak.png" alt="streak" class="mx-auto w-12 h-12" /> - </button> - - <div - v-if="displayStreakCard" - class="w-96 h-64 duration-500 group overflow-hidden absolute top-32 rounded bg-white-800 text-neutral-50 p-4 flex flex-col justify-evenly" - > - <div - class="absolute blur opacity-40 duration-500 group-hover:blur-none w-72 h-72 rounded-full group-hover:translate-x-12 group-hover:translate-y-12 bg-green-100 right-1 -bottom-24" - ></div> - <div - class="absolute blur opacity-40 duration-500 group-hover:blur-none w-12 h-12 rounded-full group-hover:translate-x-12 group-hover:translate-y-2 bg-green-300 right-12 bottom-12" - ></div> - <div - class="absolute blur opacity-40 duration-500 group-hover:blur-none w-36 h-36 rounded-full group-hover:translate-x-12 group-hover:-translate-y-12 bg-green-500 right-1 -top-12" - ></div> - <div - class="absolute blur opacity-40 duration-500 group-hover:blur-none w-24 h-24 bg-green-400 rounded-full group-hover:-translate-x-12" - ></div> - <div class="z-10 flex flex-col justify-evenly w-full h-full px-4"> - <span class="text-2xl font-bold text-black" - >{{ currentStreak }}{{ currentStreak === 1 ? ' dag' : ' dager' }} streak</span - > - <p class="text-black text-1xl font-bold"> - {{ - currentStreak > 0 - ? 'Bra jobba du har spart i ' + currentStreak + ' dager!' - : 'Du har ikke gjort noe i dag. Gjør noe nÃ¥ for Ã¥ starte en streak!' - }} - </p> - <!-- Row component with horizontal padding and auto margins for centering --> - <div - class="flex flex-row justify-content-between items-center h-20 w-full mx-auto bg-black-400 gap-4" - > - <div class="flex flex-1 overflow-x-auto"> - <div v-for="index in 6" :key="index" class="min-w-max mx-auto"> - <div class="flex flex-col items-center"> - <span class="text-black" - >Dag {{ currentStreak - ((currentStreak % 7) - index) }}</span - > - <!-- Conditional rendering for streak images --> - <img - v-if="index - 1 < currentStreak % 7" - src="@/assets/streak.png" - alt="challenge completed" - class="max-h-8 max-w-8" - /> - <img - v-else - src="@/assets/streak.png" - alt="challenge not completed" - class="max-h-8 max-w-8 grayscale" - /> - </div> - </div> - </div> - </div> - </div> - </div> - </div> -</template> - -<script setup lang="ts"> -import { ref } from 'vue' - -const displayStreakCard = ref(false) -const currentStreak = ref(20) - -function toggleStreakCard() { - displayStreakCard.value = !displayStreakCard.value -} -</script> diff --git a/src/components/ButtonDisplayStreak.vue b/src/components/ButtonDisplayStreak.vue new file mode 100644 index 0000000000000000000000000000000000000000..50ff4e445c0db7b234538d27824d34a65954824c --- /dev/null +++ b/src/components/ButtonDisplayStreak.vue @@ -0,0 +1,134 @@ +<template> + <div class="flex flex-col items-center absolute"> + <span class="text-sm text-bold">STREAK</span> + <button + @mouseover="display" + @mouseleave="hide" + class="cursor-pointer bg-transparent hover:bg-transparent hover:scale-150" + > + <img + src="@/assets/pengesekkStreak.png" + alt="streak" + class="mx-auto w-6 h-6 md:w-12 md:h-12" + /> + </button> + + <div + v-if="displayStreakCard" + class="w-[30vh] h-[20vh] md:w-auto md:h-auto group z-50 bg-opacity-50 overflow-hidden absolute left-0 top-14 md:top-20 flex flex-col justify-evenly text-wrap" + > + <div + class="flex flex-col justify-evenly w-full h-full py-2 px-4 md:py-0 bg-white rounded-2xl border-4 border-green-300" + > + <span class="text-xs md:text-2xl font-bold text-black" + >{{ currentStreak + }}{{ + currentStreak === 1 ? ' utfodring fullført' : ' utfodringer fullført' + }} + streak</span + > + <p class="text-black text-xs md:text-1xl md:font-bold"> + {{ + currentStreak! > 0 + ? 'Bra jobba du har fullført ' + currentStreak + ' utfordringer pÃ¥ rad!' + : 'Du har ikke fullført en utfordring det siste. Fullfør en nÃ¥ for Ã¥ starte en streak!' + }} + </p> + <Countdown + v-if="screenSize > 768 && currentStreak! > 0" + class="flex flex-row" + countdownSize="1rem" + labelSize=".5rem" + mainColor="white" + secondFlipColor="white" + mainFlipBackgroundColor="#30ab0e" + secondFlipBackgroundColor="#9af781" + :labels="{ days: 'dager', hours: 'timer', minutes: 'min', seconds: 'sek' }" + :deadlineISO="deadline" + ></Countdown> + <!-- Row component with horizontal padding and auto margins for centering --> + <div + class="flex flex-row items-center mx-auto h-20 w-4/5 md:w-full bg-black-400 gap-4" + > + <div class="flex flex-1 overflow-x-auto"> + <div v-for="index in 6" :key="index" class="min-w-max mx-auto"> + <div class="flex flex-col justify-around items-center"> + <span class="text-black text-xs md:text-1xl font-bold">{{ + currentStreak! - ((currentStreak! % 7) - index) + }}</span> + <!-- Conditional rendering for streak images --> + <img + v-if="index - 1 < currentStreak! % 7" + src="@/assets/pengesekkStreak.png" + alt="challenge completed" + class="max-h-6 max-w-6 md:max-h-10 md:max-w-10" + /> + <img + v-else + src="@/assets/pengesekkStreak.png" + alt="challenge not completed" + class="max-h-6 max-w-6 md:max-h-10 md:max-w-10 grayscale" + /> + </div> + </div> + </div> + </div> + </div> + </div> + </div> +</template> + +<script setup lang="ts"> +import { onMounted, onUnmounted, ref, watch } from 'vue' +import { useUserStore } from '@/stores/userStore' +// @ts-ignore +import { Countdown } from 'vue3-flip-countdown' + +const userStore = useUserStore() +const currentStreak = ref<number>() +const streakStart = ref<string>() +const deadline = ref<string>() +onMounted(async () => { + await userStore.getUserStreak() + if (userStore.streak) { + currentStreak.value = userStore.streak?.streak + streakStart.value = userStore.streak?.streakStart + deadline.value = userStore.streak?.streakStart + } + console.log('Streak:', currentStreak.value) + if (typeof window !== 'undefined') { + window.addEventListener('resize', handleWindowSizeChange) + } + handleWindowSizeChange() +}) + +const screenSize = ref<number>(window.innerWidth) + +onUnmounted(() => { + window.removeEventListener('resize', handleWindowSizeChange) +}) +const handleWindowSizeChange = () => { + screenSize.value = window.innerWidth +} + +watch( + () => currentStreak.value, + (newStreak, oldStreak) => { + if (newStreak !== oldStreak) { + currentStreak.value = newStreak + console.log('Updated Steak:', currentStreak) + } + }, + { immediate: true } +) + +const displayStreakCard = ref(false) + +const display = () => { + displayStreakCard.value = true +} + +const hide = () => { + displayStreakCard.value = false +} +</script> diff --git a/src/components/DisplayInfoForChallengeOrGoal.vue b/src/components/DisplayInfoForChallengeOrGoal.vue new file mode 100644 index 0000000000000000000000000000000000000000..b20221e248f67478e41e6df76fd9e8459645cc7d --- /dev/null +++ b/src/components/DisplayInfoForChallengeOrGoal.vue @@ -0,0 +1,87 @@ +<template> + <button @click="display" class="bg-transparent relative p-0 hover:bg-transparent"> + <img src="@/assets/infoIcon.png" alt="i" class="max-h-4 max-w-4 ml-1" /> + </button> + <div + v-if="displayInfoCard" + class="w-[40vh] h-[20vh]md:w-60 md:h-40 group z-50 bg-opacity-50 overflow-hidden absolute mt-8 md:mt-4 md:mr-0 flex flex-col justify-evenly text-wrap" + > + <div + class="flex flex-col justify-around w-3/4 md:w-full h-[80%] py-2 px-4 md:py-0 bg-white rounded-2xl border-4 border-green-300 overflow-auto" + > + <p class="text-base md:text-lg text-wrap text-bold">{{ title.toUpperCase() }}</p> + <p class="text-xs md:text-sm text-wrap mb-2">Beskrivelse: {{ description }}</p> + <p v-if="completion !== 100" class="text-xs md:text-sm text-nowrap text-green-800"> + Utløper om: + </p> + <Countdown + v-if="completion !== 100 && screenSize > 763" + class="flex flex-row" + countdownSize="1.3rem" + labelSize=".8rem" + mainColor="white" + secondFlipColor="white" + mainFlipBackgroundColor="#30ab0e" + secondFlipBackgroundColor="#9af781" + :labels="{ days: 'dager', hours: 'timer', minutes: 'min', seconds: 'sek' }" + :deadlineISO="deadline" + ></Countdown> + <Countdown + v-else-if="completion !== 100 && screenSize <= 763" + class="flex flex-row" + countdownSize="1.0rem" + labelSize=".6rem" + mainColor="white" + secondFlipColor="white" + mainFlipBackgroundColor="#30ab0e" + secondFlipBackgroundColor="#9af781" + :labels="{ days: 'dager', hours: 'timer', minutes: 'min', seconds: 'sek' }" + :deadlineISO="deadline" + ></Countdown> + <p class="text-nowrap text-xs md:text.sm" v-else> + Utfordring fullført.<br /> + Totalt spart: {{ amountSaved }}kr + </p> + </div> + </div> +</template> + +<script setup lang="ts"> +import type { Challenge } from '@/types/challenge' +import type { Goal } from '@/types/goal' +import { onUnmounted, ref } from 'vue' +// @ts-ignore +import { Countdown } from 'vue3-flip-countdown' + +interface Props { + challenge: Challenge | null | undefined + goal: Goal | null | undefined + isChallenge: boolean +} +const props = defineProps<Props>() + +const description = ref<string>( + props.isChallenge ? props.challenge!.description : props.goal!.description +) +const title = ref<string>(props.isChallenge ? props.challenge!.title : props.goal!.title) +const amountSaved = ref<number>(props.isChallenge ? props.challenge!.saved : props.goal!.saved) +const completion = ref<number>( + props.isChallenge ? props.challenge?.completion ?? 0 : props.goal?.completion ?? 0 +) +const deadline = ref<string>(props.isChallenge ? props.challenge!.due : props.goal!.due) + +const displayInfoCard = ref(false) + +const display = () => { + displayInfoCard.value = !displayInfoCard.value +} + +const screenSize = ref<number>(window.innerWidth) + +onUnmounted(() => { + window.removeEventListener('resize', handleWindowSizeChange) +}) +const handleWindowSizeChange = () => { + screenSize.value = window.innerWidth +} +</script> diff --git a/src/components/ImgGifTemplate.vue b/src/components/ImgGifTemplate.vue new file mode 100644 index 0000000000000000000000000000000000000000..46fecc79efe8cca7f8427a1a1e851e0c0ca15b79 --- /dev/null +++ b/src/components/ImgGifTemplate.vue @@ -0,0 +1,20 @@ +<template> + <div class="hover:scale-125"> + <img + v-if="index % 6 === modValue" + :src="url" + alt="could not load" + class="h-32 w-32 border-2 rounded-lg border-stale-400 shadow-md shadow-black" + /> + </div> +</template> + +<script setup lang="ts"> +interface Props { + url: string + index: number + modValue: number +} + +defineProps<Props>() +</script> diff --git a/src/components/NavBarComponent.vue b/src/components/NavBarComponent.vue index f73a9e94cf8f39d898f8641c099b9ccc91f0506c..d801c739ea7ab26801c7215d1fee8fe05070c038 100644 --- a/src/components/NavBarComponent.vue +++ b/src/components/NavBarComponent.vue @@ -10,8 +10,7 @@ </router-link> <div class="flex flex-row justify-center"> - <img alt="streak" class="w-8 h-8" src="@/assets/streakFlame.png" /> - <p class="font-bold">Streak</p> + <ButtonDisplayStreak></ButtonDisplayStreak> </div> </div> <div v-if="!isHamburger" class="flex flex-row gap-10"> @@ -68,6 +67,7 @@ import { RouterLink } from 'vue-router' import { onMounted, ref } from 'vue' import { useUserStore } from '@/stores/userStore' import ModalComponent from '@/components/ModalComponent.vue' +import ButtonDisplayStreak from '@/components/ButtonDisplayStreak.vue' const userStore = useUserStore() @@ -95,7 +95,9 @@ const updateWindowWidth = () => { } onMounted(() => { - window.addEventListener('resize', updateWindowWidth) + if (typeof window !== 'undefined') { + window.addEventListener('resize', updateWindowWidth) + } updateWindowWidth() }) diff --git a/src/components/SavingsPath.vue b/src/components/SavingsPath.vue index b71a11bb1b657160eea30453ff91600acded5f0f..5e82a770cc22e2a37bab3fa8752ce7a4b0e858b1 100644 --- a/src/components/SavingsPath.vue +++ b/src/components/SavingsPath.vue @@ -1,6 +1,6 @@ <template> <div - class="flex flex-col basis-2/3 max-h-full mx-auto max-w-5/6 md:basis-3/4 md:pr-20 md:max-mr-20" + class="flex flex-col basis-2/3 max-h-full mx-auto md:ml-20 md:mr-2 max-w-5/6 md:basis-3/4 md:max-pr-20 md:pr-10 md:max-mr-20" > <div class="flex justify-center align-center"> <span @@ -9,18 +9,28 @@ Din Sparesti </span> </div> + <button + class="h-auto w-auto absolute flex text-center self-end mr-10 md:mr-20 text-wrap shadow-sm shadow-black sm:top-50 sm:text-xs sm:mr-20 lg:mr-32 top-60 z-50 p-2 text-xs md:text-sm" + @click="scrollToFirstUncompleted" + v-show="!isAtFirstUncompleted" + > + Ufullførte utfordringer<br />↓ + </button> <div class="h-1 w-4/6 mx-auto my-2 opacity-10"></div> <div ref="containerRef" - class="container relative mx-auto pt-6 w-4/5 md:w-3/5 no-scrollbar h-full max-h-[60vh] md:max-h-[60v] overflow-y-auto border-2 border-slate-300 rounded-lg bg-white shadow-lg" + class="container relative pt-6 w-4/5 bg-cover bg-[center] md:[background-position: center;] mx-auto md:w-4/5 no-scrollbar h-full max-h-[60vh] md:max-h-[60vh] md:min-w-2/5 overflow-y-auto border-2 border-transparent rounded-xl bg-white shadow-lg shadow-slate-400" + style="background-image: url('src/assets/backgroundSavingsPath.png')" > <div> <img src="@/assets/start.png" alt="Spare" class="md:w-1/6 md:h-auto h-20" /> </div> + <div v-for="(challenge, index) in challenges" - :key="challenge.title" + :key="challenge.id" class="flex flex-col items-center" + :ref="(el) => assignRef(el, challenge, index)" > <!-- Challenge Row --> <div @@ -31,31 +41,44 @@ class="flex flex-row w-4/5 gap-8" > <div class="right-auto just"> - <img - v-if="index === 3" - src="@/assets/sleepingSpare.gif" - alt="could not load" - class="w-32 h-32 border-2 rounded-lg border-stale-400" - /> - <img - v-else-if="index === 1" - src="@/assets/golfSpare.gif" - alt="could not load" - class="w-32 h-32 border-2 rounded-lg border-stale-400" - /> + <img-gif-template + :index="index" + :mod-value="1" + url="src/assets/golfSpare.gif" + ></img-gif-template> + <img-gif-template + :index="index" + :mod-value="3" + url="src/assets/sleepingSpare.gif" + ></img-gif-template> + <img-gif-template + :index="index" + :mod-value="5" + url="src/assets/archerSpare.gif" + ></img-gif-template> </div> <!-- Challenge Icon and Details --> <div class="flex"> <!-- Challenge Icon --> - <div class="flex flex-col items-center"> - <p class="text-center" data-cy="challenge-title"> - {{ challenge.title }} - </p> + <div class="flex flex-col items-center gap-4"> + <div class="flex flex-row flex-nowrap"> + <p + class="text-center text-wrap text-xs md:text-lg" + data-cy="challenge-title" + > + {{ challenge.title }} + </p> + <display-info-for-challenge-or-goal + :goal="goal" + :challenge="challenge" + :is-challenge="true" + ></display-info-for-challenge-or-goal> + </div> <img @click="editChallenge(challenge)" :data-cy="'challenge-icon-' + challenge.id" :src="getChallengeIcon(challenge)" - class="max-w-20 max-h-20 cursor-pointer" + class="max-w-20 max-h-20 cursor-pointer hover:scale-125" :alt="challenge.title" /> <!-- Progress Bar, if the challenge is not complete --> @@ -65,7 +88,7 @@ " class="flex-grow w-full mt-2" > - <div class="flex flex-row"> + <div class="flex flex-row ml-5 md:ml-10 justify-center"> <div class="flex flex-col"> <div class="bg-gray-200 rounded-full h-2.5 dark:bg-gray-700" @@ -80,7 +103,7 @@ }" ></div> </div> - <div class="text-center"> + <div class="text-center text-xs md:text-base"> {{ challenge.saved }}kr / {{ challenge.target }}kr </div> </div> @@ -95,12 +118,14 @@ </button> </div> </div> - <span v-else class="text-center">Ferdig: {{ challenge.saved }}</span> + <span v-else class="text-center text-xs md:text-base" + >Ferdig: {{ challenge.saved }}</span + > </div> <!-- Check Icon --> <div v-if="challenge.completion !== undefined && challenge.completion >= 100" - class="max-w-10 max-h-10" + class="md:max-w-10 min-w-4 max-w-6 max-h-6 w-full h-auto md:max-h-10 min-h-4" > <img src="@/assets/completed.png" alt="" />ï¸ </div> @@ -109,18 +134,21 @@ </div> </div> <div class=""> - <img - v-if="index === 0" - src="@/assets/cowboySpare.gif" - alt="could not load" - class="h-32 w-32 border-2 rounded-lg border-stale-400" - /> - <img - v-else-if="index === 2" - src="@/assets/hotAirBalloonSpare.gif" - class="h-32 w-32 border-stale-400 border-2 rounded-lg" - alt="could not load" - /> + <img-gif-template + :index="index" + :mod-value="0" + url="src/assets/cowboySpare.gif" + ></img-gif-template> + <img-gif-template + :index="index" + :mod-value="2" + url="src/assets/hotAirBalloonSpare.gif" + ></img-gif-template> + <img-gif-template + :index="index" + :mod-value="4" + url="src/assets/farmerSpare.gif" + ></img-gif-template> </div> </div> <!-- Piggy Steps, centered --> @@ -155,9 +183,17 @@ </div> <!-- Goal --> <div v-if="goal" class="flex flex-row justify-around m-t-2 pt-6 w-full mx-auto"> - <div class="flex flex-col items-start cursor-pointer" @click="editGoal(goal)"> - <img :src="getGoalIcon(goal)" class="w-12 h-12 mx-auto" :alt="goal.title" /> - <div class="text-lg font-bold" data-cy="goal-title">{{ goal.title }}</div> + <div class="grid grid-rows-2 grid-flow-col gap 4"> + <div class="row-span-3 cursor-pointer" @click="editGoal(goal)"> + <img :src="getGoalIcon(goal)" class="w-12 h-12 mx-auto" :alt="goal.title" /> + <div class="text-lg font-bold" data-cy="goal-title">{{ goal.title }}</div> + </div> + <display-info-for-challenge-or-goal + class="col-span-2" + :goal="goal" + :challenge="null" + :is-challenge="false" + ></display-info-for-challenge-or-goal> </div> <div class="flex flex-col items-end"> <div @click="goToEditGoal" class="cursor-pointer"> @@ -182,7 +218,16 @@ </template> <script setup lang="ts"> -import { nextTick, onMounted, type Ref, ref, watch } from 'vue' +import { + type ComponentPublicInstance, + nextTick, + onMounted, + onUnmounted, + reactive, + type Ref, + ref, + watch +} from 'vue' import anime from 'animejs' import type { Challenge } from '@/types/challenge' import type { Goal } from '@/types/goal' @@ -190,6 +235,8 @@ import confetti from 'canvas-confetti' import { useRouter } from 'vue-router' import { useGoalStore } from '@/stores/goalStore' import { useChallengeStore } from '@/stores/challengeStore' +import DisplayInfoForChallengeOrGoal from '@/components/DisplayInfoForChallengeOrGoal.vue' +import ImgGifTemplate from '@/components/ImgGifTemplate.vue' const router = useRouter() const goalStore = useGoalStore() @@ -204,6 +251,110 @@ const props = defineProps<Props>() const challenges = ref<Challenge[]>(props.challenges) const goal = ref<Goal | null | undefined>(props.goal) +onMounted(async () => { + await goalStore.getUserGoals() + window.addEventListener('resize', handleWindowSizeChange) + handleWindowSizeChange() + sortChallenges() +}) + +const sortChallenges = () => { + challenges.value.sort((a, b) => { + // First, sort by completion status: non-completed (less than 100) before completed (100) + if (a.completion !== 100 && b.completion === 100) { + return 1 // 'a' is not completed and 'b' is completed, 'a' should come first + } else if (a.completion === 100 && b.completion !== 100) { + return -1 // 'a' is completed and 'b' is not, 'b' should come first + } else { + // Explicitly convert dates to numbers for subtraction + const dateA = new Date(a.due).getTime() + const dateB = new Date(b.due).getTime() + return dateA - dateB + } + }) +} + +const screenSize = ref<number>(window.innerWidth) + +onUnmounted(() => { + window.removeEventListener('resize', handleWindowSizeChange) +}) +const handleWindowSizeChange = () => { + screenSize.value = window.innerWidth +} + +interface ElementRefs { + [key: string]: HTMLElement | undefined +} + +const elementRefs = reactive<ElementRefs>({}) + +const isAtFirstUncompleted = ref(false) // This state tracks visibility of the button +const firstUncompletedRef: Ref<HTMLElement | undefined> = ref() + +function scrollToFirstUncompleted() { + let found = false + for (let i = 0; i < challenges.value.length; i++) { + if (challenges.value[i].completion! < 100) { + const refKey = `uncompleted-${i}` + if (elementRefs[refKey]) { + elementRefs[refKey]!.scrollIntoView({ behavior: 'smooth', block: 'start' }) + firstUncompletedRef.value = elementRefs[refKey] // Store the reference + found = true + isAtFirstUncompleted.value = true + break + } + } + } + if (!found) { + isAtFirstUncompleted.value = false + } +} + +onMounted(() => { + const container = containerRef.value + if (container) { + container.addEventListener('scroll', () => { + if (!firstUncompletedRef.value) return + const containerRect = container.getBoundingClientRect() + const firstUncompletedRect = firstUncompletedRef.value.getBoundingClientRect() + isAtFirstUncompleted.value = !( + firstUncompletedRect.top > containerRect.bottom || + firstUncompletedRect.bottom < containerRect.top + ) + }) + } + scrollToFirstUncompleted() +}) + +onUnmounted(() => { + const container = containerRef.value + if (container) { + container.removeEventListener('scroll', () => { + // Clean up the scroll listener + }) + } +}) + +const assignRef = ( + el: Element | ComponentPublicInstance | null, + challenge: Challenge, + index: number +) => { + const refKey = `uncompleted-${index}` + if (el instanceof HTMLElement) { + // Ensure that el is an HTMLElement + if (challenge.completion! < 100) { + elementRefs[refKey] = el + } + } else { + // Cleanup if the element is unmounted or not an HTMLElement + if (elementRefs[refKey]) { + delete elementRefs[refKey] + } + } +} + // Utilizing watch to specifically monitor for changes in the props watch( () => props.goal, @@ -221,6 +372,7 @@ watch( (newChallenges, oldChallenges) => { if (newChallenges !== oldChallenges) { challenges.value = newChallenges + sortChallenges() console.log('Updated challenges:', challenges.value) } }, @@ -243,15 +395,13 @@ const addSpareUtfordring = () => { // Increment saved amount const incrementSaved = async (challenge: Challenge) => { - // Set a default increment amount per purchase - challenge.perPurchase = 20 - // Safely increment the saved amount, ensuring it exists challenge.saved += challenge.perPurchase // Check if the saved amount meets or exceeds the target if (challenge.saved >= challenge.target) { challenge.completion = 100 + await challengeStore.completeUserChallenge(challenge) } console.log('Incrementing saved amount for:', challenge) diff --git a/src/components/__tests__/NavBarTest.spec.ts b/src/components/__tests__/NavBarTest.spec.ts index ea7c2814a609abe8a0e43501fa558e7f45f70dc5..a04985617f5d080bd2997f743beab7723f2a47e5 100644 --- a/src/components/__tests__/NavBarTest.spec.ts +++ b/src/components/__tests__/NavBarTest.spec.ts @@ -3,8 +3,29 @@ import NavBar from '@/components/NavBarComponent.vue' import router from '@/router' import { createPinia, setActivePinia } from 'pinia' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' vi.stubGlobal('scrollTo', vi.fn()) +// Mocking Axios correctly using `importOriginal` +const mocks = vi.hoisted(() => ({ + get: vi.fn(), + post: vi.fn() +})) + +vi.mock('axios', async (importActual) => { + const actual = await importActual<typeof import('axios')>() + + return { + default: { + ...actual.default, + create: vi.fn(() => ({ + ...actual.default.create(), + get: mocks.get, + post: mocks.post + })) + } + } +}) describe('NavBar Routing', () => { let wrapper: VueWrapper<any> @@ -21,6 +42,7 @@ describe('NavBar Routing', () => { await router.push('/') await router.isReady() + await nextTick() }) it('renders without errors', () => { diff --git a/src/components/__tests__/savingsPathTest.spec.ts b/src/components/__tests__/savingsPathTest.spec.ts index 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/stores/challengeStore.ts b/src/stores/challengeStore.ts index 8e4529c5cd004d75a741cd132bc35fa7d7e80310..a8417aa62bf51495a6a06b401145b0fe06f34a06 100644 --- a/src/stores/challengeStore.ts +++ b/src/stores/challengeStore.ts @@ -41,10 +41,31 @@ export const useChallengeStore = defineStore('challenge', () => { console.error('Error updating challenge:', error) } } + const completeUserChallenge = async (challenge: Challenge) => { + try { + const response = await authInterceptor.put( + `/challenges/${challenge.id}/complete`, + challenge + ) + if (response.data) { + // Update local challenge state to reflect changes + const index = challenges.value.findIndex((c) => c.id === challenge.id) + if (index !== -1) { + challenges.value[index] = { ...challenges.value[index], ...response.data } + console.log('Updated Challenge:', response.data) + } + } else { + console.error('No challenge content found in response data') + } + } catch (error) { + console.error('Error updating challenge:', error) + } + } return { challenges, getUserChallenges, - editUserChallenge + editUserChallenge, + completeUserChallenge } }) diff --git a/src/stores/userStore.ts b/src/stores/userStore.ts index ace14583f589aece97546bf3c653462a0fe3ee0e..eff4fd1479cd1bf9b70c10e6650a054ae0b6321c 100644 --- a/src/stores/userStore.ts +++ b/src/stores/userStore.ts @@ -4,9 +4,10 @@ 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', () => { @@ -18,6 +19,7 @@ export const useUserStore = defineStore('user', () => { const user = ref<User>(defaultUser) const errorMessage = ref<string>('') + const streak = ref<Streak>() const register = async ( firstname: string, @@ -79,6 +81,21 @@ export const useUserStore = defineStore('user', () => { user.value = defaultUser router.push({ name: 'login' }) } + const getUserStreak = async () => { + try { + const response = await authInterceptor('/profile/streak') + if (response.data) { + streak.value = response.data + console.log('Fetched Challenges:', streak.value) + } else { + streak.value = undefined + console.error('No challenge content found:', response.data) + } + } catch (error) { + console.error('Error fetching challenges:', error) + streak.value = undefined // Ensure challenges is always an array + } + } const bioRegister = async () => { try { @@ -219,6 +236,8 @@ export const useUserStore = defineStore('user', () => { logout, bioLogin, bioRegister, - errorMessage + errorMessage, + getUserStreak, + streak } }) 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 +}