diff --git a/src/App.vue b/src/App.vue index 510b3b1f5301d25a1974acfeb8a52a64abaee399..6713c1cf7e165d3b6f1969ed07b447812ea39ef0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,6 +16,30 @@ const showNavBar = computed(() => { ) }) +const backgroundImageStyle = computed(() => { + if (showSti.value) { + return { + backgroundImage: "url('src/assets/sti.png')" + } + } else { + return { + backgroundImage: 'none' + } + } +}) + +const showSti = computed(() => { + return !( + route.path == '/' || + route.path == '/registrer' || + route.path == '/logginn' || + route.path == '/forgotPassword' || + route.path.startsWith('/konfigurasjon') || + route.path == '/hjem' || + route.path == '/profil' + ) +}) + const showHelp = computed(() => { return !( route.path == '/' || @@ -118,12 +142,17 @@ const helpMessages = computed(() => { </script> <template> - <NavBarComponent v-if="showNavBar" /> - - <main class="mb-10"> - <RouterView /> + <div + class="min-h-screen bg-left-bottom bg-phone md:bg-pc bg-no-repeat" + :style="backgroundImageStyle" + > <HelpComponent v-if="showHelp" :speech="helpMessages" /> - </main> + <NavBarComponent v-if="showNavBar" /> + + <main class="mb-10"> + <RouterView /> + </main> + </div> </template> <style> diff --git a/src/assets/base.css b/src/assets/base.css index 3254e7db976246c0354e7c9d688816a04beba4d9..db174147f881bd99eff24edb10a0ec0322d2a2c6 100644 --- a/src/assets/base.css +++ b/src/assets/base.css @@ -22,7 +22,10 @@ --color-button: var(--green); --color-button-disabled: var(--grey); --color-nav-hover: var(--light-grey); + --color-button-edit: var(--grey); --color-button-hover: var(--light-green); + --color-button-danger: var(--accent3); + --color-button-danger-hover: var(--accent1); --color-link: var(--accent3); --color-border: var(--black); @@ -30,6 +33,16 @@ --section-gap: 160px; } +input[type='number']::-webkit-inner-spin-button, +input[type='number']::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +input[type='number'] { + -moz-appearance: textfield; /* Firefox */ +} + *, *::before, *::after { diff --git a/src/assets/main.css b/src/assets/main.css index fb3adbdedd532ef58ec23cf3738f192b8852b7c4..6aea95d7ffc247b4457e16f54cacbf0462f3af7a 100644 --- a/src/assets/main.css +++ b/src/assets/main.css @@ -34,25 +34,52 @@ h3 { margin-bottom: 1rem; } -button { - background-color: var(--color-button); +button.primary { + background-color: transparent; + border-color: var(--color-button); color: var(--color-text); - border-color: transparent; padding: 0.2rem 1rem; + font-weight: bold; border-radius: 1rem; + border-width: 2px; cursor: pointer; transition: 0.4s; } -button:disabled { +button.primary:disabled { background-color: var(--color-button-disabled); + border-color: var(--color-button-disabled); cursor: not-allowed; } -button:hover { - background-color: var(--color-button-hover); +button.primary:hover { + border-color: var(--color-button-hover); transition: 0.7s; } +button.secondary { + border-color: var(--color-button-edit); +} + +button.secondary:hover { + border-color: black; + transition: 0.7s; +} + +button.danger { + background-color: var(--color-button-danger); + border-color: transparent; +} + +button.danger:hover { + background-color: var(--color-button-danger-hover); + border-color: var(--color-button-danger); + transition: 0.7s; +} + +button.logout { + background-color: var(--color-button); +} + a { text-decoration: none; color: var(--color-text); diff --git a/src/assets/sti.png b/src/assets/sti.png new file mode 100644 index 0000000000000000000000000000000000000000..2076cc2d098f14608f4ba0f7d758f816471d5377 Binary files /dev/null and b/src/assets/sti.png differ diff --git a/src/components/ButtonAddGoalOrChallenge.vue b/src/components/ButtonAddGoalOrChallenge.vue index 315df32fde52a4eb46597be56b1a0c2e6b6ba4cd..548a7f68ea2c648a1f9a46b0f160568d8e1dd1bf 100644 --- a/src/components/ButtonAddGoalOrChallenge.vue +++ b/src/components/ButtonAddGoalOrChallenge.vue @@ -1,6 +1,6 @@ <template> <button - class="w-full max-w-60 max-h-12 font-bold py-2 rounded-full flex items-center justify-start pl-4 space-x-2 focus:outline-none focus:ring-2 focus:ring-green-700 focus:ring-opacity-50 shadow-md transition duration-300 ease-in-out text-xs md:text-sm lg:text-base" + class="primary w-full max-w-60 py-2 flex items-center justify-start pl-4 space-x-2 focus:outline-none focus:ring-2 focus:ring-opacity-50 shadow-md text-xs md:text-sm lg:text-base" @click="routeToGoalOrChallenge" > <svg diff --git a/src/components/ButtonDisplayStreak.vue b/src/components/ButtonDisplayStreak.vue index 50ff4e445c0db7b234538d27824d34a65954824c..b9fbb10340301bf7a65c12b678844b7e5953771d 100644 --- a/src/components/ButtonDisplayStreak.vue +++ b/src/components/ButtonDisplayStreak.vue @@ -37,10 +37,22 @@ <Countdown v-if="screenSize > 768 && currentStreak! > 0" class="flex flex-row" - countdownSize="1rem" - labelSize=".5rem" - mainColor="white" - secondFlipColor="white" + countdownSize="1.4rem" + labelSize="0.8rem" + mainColor="black" + secondFlipColor="black" + mainFlipBackgroundColor="#30ab0e" + secondFlipBackgroundColor="#9af781" + :labels="{ days: 'dager', hours: 'timer', minutes: 'min', seconds: 'sek' }" + :deadlineISO="deadline" + ></Countdown> + <Countdown + v-if="screenSize <= 768 && currentStreak! > 0" + class="flex flex-row" + countdownSize="1.1rem" + labelSize=".6rem" + mainColor="black" + secondFlipColor="black" mainFlipBackgroundColor="#30ab0e" secondFlipBackgroundColor="#9af781" :labels="{ days: 'dager', hours: 'timer', minutes: 'min', seconds: 'sek' }" @@ -51,23 +63,24 @@ 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 v-for="index in 7" :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 --> + <!-- Display the current streak day number adjusted by index --> + <span class="text-black text-xs md:text-1xl font-bold"> + {{ currentStreak! - ((currentStreak! % 7) - index) }} + </span> + <!-- Display images based on completion --> <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" + :alt=" + index <= currentStreak! % 7 + ? 'challenge completed' + : 'challenge not completed' + " + :class="{ + 'max-h-6 max-w-6 md:max-h-10 md:max-w-10': true, + grayscale: index > currentStreak! % 7 + }" /> </div> </div> @@ -79,21 +92,19 @@ </template> <script setup lang="ts"> -import { onMounted, onUnmounted, ref, watch } from 'vue' +import { onMounted, onUnmounted, ref } 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() + userStore.getUserStreak() if (userStore.streak) { currentStreak.value = userStore.streak?.streak - streakStart.value = userStore.streak?.streakStart - deadline.value = userStore.streak?.streakStart + deadline.value = userStore.streak?.firstDue } console.log('Streak:', currentStreak.value) if (typeof window !== 'undefined') { @@ -111,21 +122,13 @@ 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 + userStore.getUserStreak() + currentStreak.value = userStore.streak?.streak + deadline.value = userStore.streak?.firstDue } const hide = () => { diff --git a/src/components/CardChallenge.vue b/src/components/CardChallenge.vue index b2349e03b0c33535a4d0a25512e8543ea7b4a967..f2637c77298f2ffa6be437a72c4327152d856ec0 100644 --- a/src/components/CardChallenge.vue +++ b/src/components/CardChallenge.vue @@ -22,8 +22,8 @@ const handleCardClick = () => { <template> <div - :class="{ 'bg-green-200 cursor-default': isCompleted }" - class="border-2 border-black rounded-xl p-4 flex flex-col items-center gap-2 cursor-pointer w-52 overflow-hidden" + :class="{ 'cursor-default': isCompleted }" + class="border-2 border-lime-400 rounded-xl p-4 flex flex-col items-center gap-2 cursor-pointer w-52 overflow-hidden transition-transform duration-100 ease-in-out hover:scale-105 hover:opacity-90" @click="handleCardClick" > <h3 class="my-0 mx-6">{{ challengeInstance.title }}</h3> diff --git a/src/components/CardChallengeSavingsPath.vue b/src/components/CardChallengeSavingsPath.vue new file mode 100644 index 0000000000000000000000000000000000000000..cb34ba2490ebcaa4e16a54ec2ac4823e210e4a0e --- /dev/null +++ b/src/components/CardChallengeSavingsPath.vue @@ -0,0 +1,113 @@ +<template> + <!-- Challenge Icon and Details --> + <div + v-if="challenge" + class="flex items-center justify-center shadow-black min-w-24 w-full h-auto md:max-h-full min-h-24 max-w-32 max-h-32 md:min-h-32 md:min-w-32 md:max-w-48 overflow-hidden" + > + <!-- Challenge Icon --> + <div class="flex flex-col items-center mx-auto md:mx-2 my-auto"> + <div class="flex flex-col flex-nowrap self-center"> + <!-- Check Icon --> + <div + v-if="challenge.completion !== undefined && challenge.completion >= 100" + class="min-w-6 min-h-6 max-w-6 max-h-6 md:min-h-8 md:max-h-8 md:min-w-8 md:max-w-8 ml-20 md:ml-32 p-1 basis-1/4 self-end" + > + <img src="@/assets/completed.png" alt="" />ï¸ + </div> + <div + v-else + class="min-w-6 min-h-6 max-w-6 max-h-6 md:min-h-8 md:max-h-8 md:min-w-8 md:max-w-8 ml-20 md:ml-32 p-1 basis-1/4 self-end" + > + <img src="@/assets/pending.png" alt="" />ï¸ + </div> + <div class="basis-3/4"> + <p + class="text-center text-wrap text-xs lg:text-lg md:text-md" + data-cy="challenge-title" + > + {{ challenge.title }} + </p> + </div> + </div> + <img + @click="editChallenge(challenge)" + :data-cy="'challenge-icon-' + challenge.id" + :src="getChallengeIcon(challenge)" + class="max-w-12 max-h-12 md:max-h-16 md:max-w-16 lg:max-w-20 lg:max-h-20 cursor-pointer hover:scale-125" + :alt="challenge.title" + /> + <!-- Progress Bar, if the challenge is not complete --> + <div + v-if="challenge.completion != undefined && challenge.completion < 100" + class="flex-grow w-full mt-2" + > + <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"> + <div + class="bg-green-600 h-2.5 rounded-full" + data-cy="challenge-progress" + :style="{ + width: (challenge.saved / challenge.target) * 100 + '%' + }" + ></div> + </div> + <div class="text-center text-nowrap text-xs md:text-base"> + {{ challenge.saved }}kr / {{ challenge.target }}kr + </div> + </div> + + <button + @click="incrementSaved(challenge)" + :data-cy="'increment-challenge' + challenge.id" + type="button" + class="inline-block mb-2 ml-2 h-7 w-8 rounded-full p-1 uppercase leading-normal transition duration-150 ease-in-out focus:bg-green-accent-300 focus:shadow-green-2 focus:outline-none focus:ring-0 active:bg-green-600 active:shadow-green-200 motion-reduce:transition-none dark:shadow-black/30 dark:hover:shadow-dark-strong dark:focus:shadow-dark-strong dark:active:shadow-dark-strong" + > + + + </button> + </div> + </div> + <span v-else class="text-center text-xs md:text-base" + >Ferdig: {{ challenge.saved }}</span + > + </div> + </div> +</template> + +<script setup lang="ts"> +import type { Challenge } from '@/types/challenge' +import { useChallengeStore } from '@/stores/challengeStore' +import router from '@/router' + +const challengeStore = useChallengeStore() + +interface Props { + challenge: Challenge +} +defineProps<Props>() + +const emit = defineEmits(['update-challenge', 'complete-challenge']) + +// Increment saved amount +// In your incrementSaved function in the child component +const incrementSaved = async (challenge: Challenge) => { + challenge.saved += challenge.perPurchase + // Trigger the update in the store + + const updatedChallenge = (await challengeStore.editUserChallenge(challenge)) as Challenge + + console.log('updated challenge in child: ', updatedChallenge) + + // Emit an event to inform the parent component of the update + emit('update-challenge', updatedChallenge) +} + +const editChallenge = (challenge: Challenge) => { + router.push(`/spareutfordringer/rediger/${challenge.id}`) +} +// Helper methods to get icons +const getChallengeIcon = (challenge: Challenge): string => { + //TODO change to challenge.icon + return `src/assets/coffee.png` +} +</script> diff --git a/src/components/CardGoal.vue b/src/components/CardGoal.vue index f04058b90dc740d2ec1d11b132f3cd6639344005..458cf1fb6b426e5a02c6ef4c32024bd385511f14 100644 --- a/src/components/CardGoal.vue +++ b/src/components/CardGoal.vue @@ -28,8 +28,8 @@ const handleCardClick = () => { <template> <div - :class="{ 'bg-green-200 cursor-default': isCompleted }" - class="border-2 border-black rounded-xl p-4 flex flex-col items-center gap-2 cursor-pointer w-52 overflow-hidden" + :class="{ 'cursor-default': isCompleted }" + class="border-2 border-lime-400 rounded-xl p-4 flex flex-col items-center gap-2 cursor-pointer w-52 overflow-hidden transition-transform duration-100 ease-in-out hover:scale-105 hover:opacity-90" @click="handleCardClick" > <h3 class="my-0 mx-6">{{ goalInstance.title }}</h3> diff --git a/src/components/FormLogin.vue b/src/components/FormLogin.vue index 77ea487e96e130c97a5da09c0ab61825043e5509..e676c6c6fec21252758cd1fe527d2c2985ecc01a 100644 --- a/src/components/FormLogin.vue +++ b/src/components/FormLogin.vue @@ -111,7 +111,7 @@ watch( <button name="submit" :disabled="'' == username.valueOf() || '' == password.valueOf()" - class="grow-0" + class="primary grow-0" @click="submitForm" > Logg inn diff --git a/src/components/FormRegister.vue b/src/components/FormRegister.vue index c10565fe213ab6d4a16c2c12ddb4a7f800f406b2..f28be87ece27d5a2f706b421a457b01787c855b0 100644 --- a/src/components/FormRegister.vue +++ b/src/components/FormRegister.vue @@ -62,7 +62,7 @@ watch( <input v-model="firstName" name="firstName" - :class="{ 'bg-green-200': isFirstNameValid }" + :class="{ 'border-2 border-lime-400': isFirstNameValid }" placeholder="Skriv inn fornavn" type="text" /> @@ -77,7 +77,7 @@ watch( <input v-model="lastName" name="lastName" - :class="{ 'bg-green-200': isLastNameValid }" + :class="{ 'border-2 border-lime-400': isLastNameValid }" placeholder="Skriv inn etternavn" type="text" /> @@ -92,7 +92,7 @@ watch( <input v-model="email" name="email" - :class="{ 'bg-green-200': isEmailValid }" + :class="{ 'border-2 border-lime-400': isEmailValid }" placeholder="Skriv inn e-post" type="text" /> @@ -109,7 +109,7 @@ watch( name="username" placeholder="Skriv inn brukernavn" type="text" - :class="{ 'bg-green-200': isUsernameValid }" + :class="{ 'border-2 border-lime-400': isUsernameValid }" /> </div> <div class="flex flex-col"> @@ -126,7 +126,7 @@ watch( :type="showPassword ? 'text' : 'password'" placeholder="Skriv inn passord" class="w-full" - :class="{ 'bg-green-200': isPasswordValid }" + :class="{ 'border-2 border-lime-400': isPasswordValid }" /> <button class="absolute right-0 top-1 bg-transparent hover:bg-transparent" @@ -137,7 +137,9 @@ watch( </div> <input v-model="confirm" - :class="{ 'bg-green-200': password == confirm && '' !== confirm.valueOf() }" + :class="{ + 'border-2 border-lime-400': password == confirm && '' !== confirm.valueOf() + }" class="mt-2" name="confirm" placeholder="Bekreft passord" @@ -145,7 +147,12 @@ watch( /> </div> <div class="flex flex-row gap-5"> - <button :disabled="isFormInvalid" class="grow-0" name="submit" @click="submitForm"> + <button + :disabled="isFormInvalid" + class="grow-0 primary" + name="submit" + @click="submitForm" + > Registrer deg </button> <p>{{ errorMessage }}</p> diff --git a/src/components/ImgGifTemplate.vue b/src/components/ImgGifTemplate.vue index 46fecc79efe8cca7f8427a1a1e851e0c0ca15b79..f23f1723afa3e37e176788ddc3c565729e455fa0 100644 --- a/src/components/ImgGifTemplate.vue +++ b/src/components/ImgGifTemplate.vue @@ -1,10 +1,10 @@ <template> - <div class="hover:scale-125"> + <div class="hover:scale-110 flex justify-center items-center"> <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" + class="min-w-24 w-full h-auto min-h-24 max-w-32 max-h-32 md:min-h-32 md:max-h-44 md:min-w-32 md:max-w-44 border-2 rounded-lg border-stale-400 shadow-md shadow-black" /> </div> </template> diff --git a/src/components/ModalComponent.vue b/src/components/ModalComponent.vue index 548eaf395fbedef621288794a777c1ac1af258de..af2c54296db630cc3e6b305707d9debaee16aaf3 100644 --- a/src/components/ModalComponent.vue +++ b/src/components/ModalComponent.vue @@ -5,7 +5,7 @@ > <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">{{ message }}</p> + <p class="message mb-4" v-html="message"></p> <slot /> diff --git a/src/components/NavBarComponent.vue b/src/components/NavBarComponent.vue index 0cbf7b73691ea988752af2df6e4a97798c9a4de6..924816689e21f4e077342f8c2a448454838c8a5d 100644 --- a/src/components/NavBarComponent.vue +++ b/src/components/NavBarComponent.vue @@ -23,9 +23,14 @@ </div> <div v-if="!isHamburger" class="flex justify-center w-40"> - <button class="focus:ring focus:ring-black-300" @click="openModal">Logg ut</button> + <button + class="primary bg-[#95e35d] logout focus:ring focus:ring-black-300" + @click="openModal" + > + Logg ut + </button> </div> - <button v-if="isHamburger" @click="toggleMenu">☰</button> + <button class="primary logout" v-if="isHamburger" @click="toggleMenu">☰</button> </nav> <div v-if="hamburgerOpen" class="flex flex-col bg-white border border-slate-300 z-50"> @@ -35,9 +40,7 @@ >💰Spareutfordringer</router-link > <router-link to="/profil" @click="hamburgerOpen = false">ðŸ¤Profil</router-link> - <button class="focus:ring focus:ring-black-300 bg-transparent" @click="openModal"> - Logg ut - </button> + <button class="focus:ring focus:ring-black-300" @click="openModal">Logg ut</button> </div> <ModalComponent :title="'Vil du logge ut?'" @@ -46,18 +49,8 @@ @close="isModalOpen = false" > <template v-slot:buttons> - <button - @click="logout" - class="active-button font-bold py-2 px-4 w-1/2 border-2 disabled:border-transparent" - > - Logg ut - </button> - <button - @click="closeModal" - class="active-button font-bold py-2 px-4 w-1/2 border-2 disabled:border-transparent bg-red-400 hover:bg-red-300" - > - Avbryt - </button> + <button @click="logout" class="primary">Logg ut</button> + <button @click="closeModal" class="primary danger">Avbryt</button> </template> </ModalComponent> </template> diff --git a/src/components/PageControl.vue b/src/components/PageControl.vue index 3ed526b63868ef5c5270af6045b52f6f53251e70..921f2c9f9d9651bbccebef8116c4b19a18a99ab8 100644 --- a/src/components/PageControl.vue +++ b/src/components/PageControl.vue @@ -17,18 +17,22 @@ defineProps({ <template> <div v-if="totalPages > 0" class="flex justify-center gap-4"> - <button :disabled="currentPage === 0" @click="onPageChange(currentPage - 1)"> + <button + class="primary" + :disabled="currentPage === 0" + @click="onPageChange(currentPage - 1)" + > Forrige </button> <p>{{ currentPage + 1 }} / {{ totalPages }}</p> - <button :disabled="currentPage === totalPages - 1" @click="onPageChange(currentPage + 1)"> + <button + class="primary" + :disabled="currentPage === totalPages - 1" + @click="onPageChange(currentPage + 1)" + > Neste </button> </div> </template> -<style scoped> -button:disabled:hover { - background-color: #cbcbcb; -} -</style> +<style scoped></style> diff --git a/src/components/ProgressBar.vue b/src/components/ProgressBar.vue index 770c75b9bbd250fa7c55ae5d4b1cdbd11ea3312b..3a29f2fa6eec6da409198d57d50b13e84cd2fefc 100644 --- a/src/components/ProgressBar.vue +++ b/src/components/ProgressBar.vue @@ -6,7 +6,7 @@ defineProps({ <template> <div class="w-full bg-gray-200 rounded-full overflow-hidden"> - <div :style="{ width: completion + '%' }" class="bg-green-500 h-2 rounded-full"></div> + <div :style="{ width: completion + '%' }" class="bg-lime-400 h-2 rounded-full"></div> </div> </template> diff --git a/src/components/SavingsPath.vue b/src/components/SavingsPath.vue index 20b1466e412b49bc26538bc8c8584afd54d38a2d..ba90e65812ff6047a6fba7f2df80db6b54252812 100644 --- a/src/components/SavingsPath.vue +++ b/src/components/SavingsPath.vue @@ -1,5 +1,6 @@ <template> <div + v-if="isMounted" 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"> @@ -10,6 +11,7 @@ </span> </div> <button + v-if="!allChallengesCompleted()" 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" @@ -18,6 +20,7 @@ </button> <div class="h-1 w-4/6 mx-auto my-2 opacity-10"></div> <div + v-if="challengesLocal" ref="containerRef" 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')" @@ -27,7 +30,7 @@ </div> <div - v-for="(challenge, index) in challenges" + v-for="(challenge, index) in challengesLocal" :key="challenge.id" class="flex flex-col items-center" :ref="(el) => assignRef(el, challenge, index)" @@ -35,12 +38,12 @@ <!-- Challenge Row --> <div :class="{ - 'justify-end md:mx-auto md:justify-between': index % 2 === 1, - 'justify-start md:justify-between md:mx-auto': index % 2 === 0 + 'justify-center mx-auto md:justify-between': index % 2 === 1, + 'justify-center md:justify-between mx-auto': index % 2 === 0 }" - class="flex flex-row w-4/5 gap-8" + class="flex flex-row w-full md:w-4/5 justify-start gap-4 md:gap-8" > - <div class="right-auto just"> + <div class="flex"> <img-gif-template :index="index" :mod-value="1" @@ -57,83 +60,12 @@ url="src/assets/archerSpare.gif" ></img-gif-template> </div> - <!-- Challenge Icon and Details --> + <card-challenge-savings-path + :goal="goalLocal!" + :challenge="challenge" + @update-challenge="handleChallengeUpdate" + ></card-challenge-savings-path> <div class="flex"> - <!-- Challenge Icon --> - <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 hover:scale-125" - :alt="challenge.title" - /> - <!-- Progress Bar, if the challenge is not complete --> - <div - v-if=" - challenge.completion != undefined && challenge.completion < 100 - " - class="flex-grow w-full mt-2" - > - <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" - > - <div - class="bg-green-600 h-2.5 rounded-full" - data-cy="challenge-progress" - :style="{ - width: - (challenge.saved / challenge.target) * 100 + - '%' - }" - ></div> - </div> - <div class="text-center text-xs md:text-base"> - {{ challenge.saved }}kr / {{ challenge.target }}kr - </div> - </div> - - <button - @click="incrementSaved(challenge)" - :data-cy="'increment-challenge' + challenge.id" - type="button" - class="inline-block mb-2 ml-2 h-7 w-8 rounded-full p-1 uppercase leading-normal transition duration-150 ease-in-out focus:bg-green-accent-300 focus:shadow-green-2 focus:outline-none focus:ring-0 active:bg-green-600 active:shadow-green-200 motion-reduce:transition-none dark:shadow-black/30 dark:hover:shadow-dark-strong dark:focus:shadow-dark-strong dark:active:shadow-dark-strong" - > - + - </button> - </div> - </div> - <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="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> - <div v-else class="max-w-6 max-h-6"> - <img src="@/assets/pending.png" alt="" />ï¸ - </div> - </div> - <div class=""> <img-gif-template :index="index" :mod-value="0" @@ -152,26 +84,30 @@ </div> </div> <!-- Piggy Steps, centered --> - <div v-if="index !== challenges.length" class="flex justify-center w-full"> + <div v-if="index !== challengesLocal.length" class="flex justify-center w-full"> <img :src="getPigStepsIcon()" :class="{ 'transform scale-x-[-1]': index % 2 === 0 }" - class="w-20 h-20" + class="w-20 md:w-24 lg:w-32 h-20 md:h-24 lg:h-32" alt="Pig Steps" /> </div> <div - v-if="index === challenges.length - 1 && index % 2 === 0" + v-if="index === challengesLocal.length - 1 && index % 2 === 0" class="flex flex-row mt-2" > <button class="text-2xl ml-48" @click="addSpareUtfordring">+</button> - <span class="">Legg til <br />Spareutfordring</span> + <p class="">Legg til <br />Spareutfordring</p> </div> - <div v-else-if="index === challenges.length - 1 && index % 2 !== 0" class="mr-40"> + <div + v-else-if="index === challengesLocal.length - 1 && index % 2 !== 0" + class="mr-20 flex flex-row" + > <button class="text-2xl ml-10 rounded-full" @click="addSpareUtfordring"> + </button> + <p class="">Legg til <br />Spareutfordring</p> </div> <!-- Finish line --> </div> @@ -182,28 +118,27 @@ /> </div> <!-- Goal --> - <div v-if="goal" class="flex flex-row justify-around m-t-2 pt-6 w-full mx-auto"> + <div v-if="goalLocal" class="flex flex-row justify-around m-t-2 pt-6 w-full mx-auto"> <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 class="row-span-3 cursor-pointer" @click="editGoal(goalLocal)"> + <img + :src="getGoalIcon(goalLocal)" + class="w-12 h-12 mx-auto" + :alt="goalLocal.title" + /> + <div class="text-lg font-bold" data-cy="goal-title">{{ goalLocal.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"> <h3 class="text-blue-500 text-base">Endre mÃ¥l</h3> </div> <div + :key="componentKey" ref="targetRef" class="bg-yellow-400 px-4 py-1 rounded-full text-black font-bold" > - {{ goal.saved }}kr / {{ goal.target }}kr + {{ goalLocal.saved }}kr / {{ goalLocal.target }}kr </div> </div> </div> @@ -215,6 +150,13 @@ ref="iconRef" class="max-w-20 max-h-20 absolute opacity-0" /> + <img + v-if="goalLocal" + :src="getGoalIcon(goalLocal)" + alt="could not load" + ref="goalIconRef" + class="shadow-sm shadow-amber-300 max-w-20 max-h-20 absolute opacity-0" + /> </template> <script setup lang="ts"> @@ -225,8 +167,7 @@ import { onUnmounted, reactive, type Ref, - ref, - watch + ref } from 'vue' import anime from 'animejs' import type { Challenge } from '@/types/challenge' @@ -234,13 +175,11 @@ import type { Goal } from '@/types/goal' 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' +import CardChallengeSavingsPath from '@/components/CardChallengeSavingsPath.vue' const router = useRouter() const goalStore = useGoalStore() -const challengeStore = useChallengeStore() interface Props { challenges: Challenge[] @@ -248,86 +187,59 @@ interface Props { } const props = defineProps<Props>() -const challenges = ref<Challenge[]>(props.challenges) -const goal = ref<Goal | null | undefined>(props.goal) +const challengesLocal = ref<Challenge[]>() +let goalLocal: Goal | null | undefined = reactive({ + title: '', // Default empty string to prevent undefined errors + saved: 0, + target: 0 +} as Goal) +const isMounted = ref<boolean>(false) +const componentKey = ref<number>(0) + +//Initialisation: onMounted(async () => { - await goalStore.getUserGoals() window.addEventListener('resize', handleWindowSizeChange) handleWindowSizeChange() + challengesLocal.value = props.challenges + goalLocal = props.goal 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 + allChallengesCompleted() + // Delay the execution of the following logic by 300ms + setTimeout(() => { + 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 + ) + }) } - }) -} - -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>({}) + scrollToFirstUncompleted() + }, 300) // Timeout set to 300 milliseconds + // Load existing animated states first + loadAnimatedStates() -const isAtFirstUncompleted = ref(false) // This state tracks visibility of the button -const firstUncompletedRef: Ref<HTMLElement | undefined> = ref() + // Get completed challenge IDs, ensuring that only defined IDs are considered + const completedChallenges = challengesLocal.value + .filter((challenge) => challenge.completion! >= 100 && challenge.id !== undefined) + .map((challenge) => challenge.id as number) // Use 'as number' to assert that ids are numbers after the check -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 - } -} + // Update only new completions that are not already in the animatedChallenges + const newAnimations = completedChallenges.filter((id) => !animatedChallenges.value.includes(id)) + animatedChallenges.value = [...animatedChallenges.value, ...newAnimations] -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() + // Save the updated list back to localStorage + localStorage.setItem('animatedChallenges', JSON.stringify(animatedChallenges.value)) + isMounted.value = true }) onUnmounted(() => { + window.removeEventListener('resize', handleWindowSizeChange) const container = containerRef.value if (container) { container.removeEventListener('scroll', () => { @@ -336,251 +248,448 @@ onUnmounted(() => { } }) -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 +const handleChallengeUpdate = (updatedChallenge: Challenge) => { + if (challengesLocal.value) { + const index = challengesLocal.value.findIndex((c) => c.id === updatedChallenge.id) + if (index !== -1) { + challengesLocal.value[index] = { ...updatedChallenge } } - } else { - // Cleanup if the element is unmounted or not an HTMLElement - if (elementRefs[refKey]) { - delete elementRefs[refKey] + + if ( + updatedChallenge.completion! >= 100 && + !animatedChallenges.value.includes(updatedChallenge.id as number) + ) { + animateChallenge(updatedChallenge) + saveAnimatedStateChallenge(updatedChallenge) + } + + if (goalLocal) { + incrementGoalSaved(updatedChallenge) + // Force component update right here might be more appropriate + componentKey.value++ } } } -// Utilizing watch to specifically monitor for changes in the props -watch( - () => props.goal, - (newGoal, oldGoal) => { - if (newGoal !== oldGoal) { - goal.value = newGoal - } - }, - { immediate: true } -) - -watch( - () => props.challenges, - (newChallenges, oldChallenges) => { - if (newChallenges !== oldChallenges) { - challenges.value = newChallenges - sortChallenges() +const incrementGoalSaved = async (challenge: Challenge) => { + if (goalLocal) { + // Correct the addition mistake and remove setTimeout + goalLocal.saved = goalLocal.saved + challenge.perPurchase + await nextTick() // Only add the perPurchase amount + + const completion = (goalLocal.saved / goalLocal.target) * 100 + if (completion >= 100 && !animatedGoals.value.includes(goalLocal.id as number)) { + animateGoal(goalLocal) + setTimeout(() => { + goalStore.getUserGoals() + goalLocal = goalStore.priorityGoal + }, 4000) // Keep this delay only for the store update and goal switch + } else { + await goalStore.getUserGoals() + goalLocal = goalStore.priorityGoal } - }, - { immediate: true } -) -// Reactive references for DOM elements -const iconRef = ref<HTMLElement | null>(null) -const containerRef = ref<HTMLElement | null>(null) -const targetRef = ref<HTMLElement | null>(null) - -// Define your goal + } +} -// AddSpareUtfordring +/** + * Navigates to the spareutfordringer page + */ const addSpareUtfordring = () => { router.push('/spareutfordringer').catch((error) => { console.error('Routing error:', error) }) } -// Increment saved amount -const incrementSaved = async (challenge: Challenge) => { - // 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) - } - - // 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 - // Update the goal in the store, ensuring goal is not null or undefined - if (goal.value) { - await goalStore.editUserGoal(goal.value) - } - } else { - console.error('No goal selected for incrementing saved value.') - } - - // Update the challenge in the store - await challengeStore.editUserChallenge(challenge) -} - -const recalculateAndAnimate = () => { - nextTick(() => { - if (iconRef.value && containerRef.value && targetRef.value) { - animateIcon() - } else { - console.error('Element references are not ready.') +/** + * Checks if all challenges are completed + */ +const allChallengesCompleted = () => { + // Assuming challenges.value is an array of challenge objects + if (challengesLocal.value) { + for (const challenge of challengesLocal.value) { + if (challenge.completion !== 100) { + return false // If any challenge is not completed, return false + } } - }) + return true + } // If all challenges are completed, return true } -const editChallenge = (challenge: Challenge) => { - router.push(`/spareutfordringer/rediger/${challenge.id}`) -} +//-----------Animation for goal and challenge completion-----------------// -const editGoal = (goal: Goal) => { - router.push(`/sparemaal/rediger/${goal.id}`) -} +// Reactive references for DOM elements +const iconRef = ref<HTMLElement | null>(null) +const goalIconRef = ref<HTMLElement | null>(null) +const containerRef = ref<HTMLElement | null>(null) +const targetRef = ref<HTMLElement | null>(null) // Declare the ref with a type annotation for an array of strings const animatedChallenges: Ref<number[]> = ref([]) +const animatedGoals: Ref<number[]> = ref([]) +/** + * Loads the states for animated goals and challenges + */ const loadAnimatedStates = () => { const animated = localStorage.getItem('animatedChallenges') + const animatedG = localStorage.getItem('animatedGoals') animatedChallenges.value = animated ? JSON.parse(animated) : [] + animatedGoals.value = animatedG ? JSON.parse(animatedG) : [] } -const saveAnimatedState = (challenge: Challenge) => { - if (challenge.id != null) { - animatedChallenges.value.push(challenge.id) - } - localStorage.setItem('animatedChallenges', JSON.stringify(animatedChallenges.value)) -} - +/** + * Saves the animated state for challenge + * triggers the confetti method + * triggers the recalculation of dom positioning + * @param challenge + */ const animateChallenge = (challenge: Challenge) => { if ( - challenge.completion === 100 && + challenge.completion! >= 100 && !animatedChallenges.value.includes(challenge.id as number) ) { if (challenge.id != null) { animatedChallenges.value.push(challenge.id) } // Ensure no duplication - saveAnimatedState(challenge) // Refactor this to update localStorage correctly + saveAnimatedStateChallenge(challenge) // Refactor this to update localStorage correctly triggerConfetti() - recalculateAndAnimate() + recalculateAndAnimate(false) } } -const triggerConfetti = () => { - confetti({ - particleCount: 400, - spread: 80, - origin: { x: 0.8, y: 0.8 } - }) +/** + * Saves the animated state for goal + * triggers the confetti method + * triggers the recalculation of dom positioning + * @param goal + */ +const animateGoal = (goal: Goal) => { + console.log('im in animated goal') + + if (goal.id != null) { + animatedGoals.value.push(goal.id) + } // Ensure no duplication + saveAnimatedStateGoal(goal) // Refactor this to update localStorage correctly + triggerConfetti() + recalculateAndAnimate(true) } -watch( - challenges, - (newChallenges) => { - newChallenges.forEach((challenge) => { - //wait for 300ms before animating maybe? - nextTick(() => { - if (challenge.completion === 100) { - if (!animatedChallenges.value.includes(challenge.id as number)) { - animateChallenge(challenge) - saveAnimatedState(challenge) // Refactor this to update localStorage correctly - } - } - }) - }) - }, - { deep: true } -) -onMounted(() => { - // Load existing animated states first - loadAnimatedStates() - - // Get completed challenge IDs, ensuring that only defined IDs are considered - const completedChallenges = challenges.value - .filter((challenge) => challenge.completion === 100 && challenge.id !== undefined) - .map((challenge) => challenge.id as number) // Use 'as number' to assert that ids are numbers after the check - - // Update only new completions that are not already in the animatedChallenges - const newAnimations = completedChallenges.filter((id) => !animatedChallenges.value.includes(id)) - animatedChallenges.value = [...animatedChallenges.value, ...newAnimations] +/** + * Recalculates the position of the dom elements + * @param isGoal + */ +const recalculateAndAnimate = (isGoal: boolean) => { + console.log('im in recalculate and animate') + + if (!isGoal && iconRef.value && containerRef.value && targetRef.value) { + animateIcon(isGoal) + } else if (isGoal && containerRef.value && goalIconRef.value) { + animateIcon(isGoal) + } else if (!isGoal && !targetRef.value) { + animateIcon(isGoal) + } else { + console.error('Element references are not ready.') + } +} - // Save the updated list back to localStorage +/** + * Saves the animated state for challenge + * @param challenge + */ +const saveAnimatedStateChallenge = (challenge: Challenge) => { + if (challenge.id != null) { + animatedChallenges.value.push(challenge.id) + } localStorage.setItem('animatedChallenges', JSON.stringify(animatedChallenges.value)) -}) +} + +/** + * Saves the animated state for goal + * @param goal + */ +const saveAnimatedStateGoal = (goal: Goal) => { + console.log('Saving animated state for:', goal.id) + if (goal.id != null) { + animatedGoals.value.push(goal.id) + } + localStorage.setItem('animatedGoals', JSON.stringify(animatedGoals.value)) +} -const animateIcon = () => { +/** + * animates the icon images + * @param isGoal + */ +const animateIcon = (isGoal: boolean) => { + console.log('im in animate icon') const icon = iconRef.value const container = containerRef.value const target = targetRef.value - if (!icon || !container || !target) { + if (!icon || !container) { console.error('Required animation elements are not available.') return } - + // Obtain bounding rectangles safely const containerRect = container.getBoundingClientRect() - const targetRect = target.getBoundingClientRect() + const targetRect = target?.getBoundingClientRect() const iconRect = icon.getBoundingClientRect() - const translateX1 = - containerRect.left + containerRect.width / 2 - iconRect.width / 2 - iconRect.left - const translateY1 = - containerRect.top + containerRect.height / 2 - iconRect.height / 2 - iconRect.top - - const translateX2 = targetRect.left + targetRect.width / 2 - iconRect.width / 2 - iconRect.left - const translateY2 = targetRect.top + targetRect.height / 2 - iconRect.height / 2 - iconRect.top - - anime - .timeline({ - easing: 'easeInOutQuad', - duration: 1500 - }) - .add({ - targets: icon, - translateX: translateX1, - translateY: translateY1, - opacity: 0, // Start invisible - duration: 1000 - }) - .add({ - targets: icon, - opacity: 1, // Reveal the icon once it starts moving to the container - duration: 1000, // Make the opacity change almost instantaneously - scale: 3 - }) - .add({ - targets: icon, - translateX: translateX2, - translateY: translateY2, - scale: 0.5, - opacity: 1, // Keep the icon visible while moving to the target - duration: 1500 - }) - .add({ - targets: icon, - opacity: 0, // Fade out once it reaches the target - scale: 1, - duration: 500 - }) - .add({ - targets: icon, - translateX: 0, // Reset translation to original - translateY: 0, // Reset translation to original - duration: 500 - }) + // Initialize translation coordinates + let translateX1 = 0, + translateY1 = 0, + translateX2 = 0, + translateY2 = 0 + + if (isGoal) { + const goal = goalIconRef.value + if (goal) { + const goalRect = goal.getBoundingClientRect() + if (goalRect) { + // Calculate the translation coordinates for the goal + translateX1 = + containerRect.left + + containerRect.width / 2 - + goalRect.width / 2 - + goalRect.left + translateY1 = + containerRect.top + + containerRect.height / 2 - + goalRect.height / 2 - + goalRect.top + + anime + .timeline({ + easing: 'easeInOutQuad', + duration: 1500 + }) + .add({ + targets: goal, + translateX: translateX1, + translateY: translateY1, + opacity: [0, 1], // Fix: start from 0 opacity and animate to 1 + duration: 1000 + }) + .add({ + targets: goal, + opacity: [1, 0], // Fade out after moving + duration: 3000, + scale: 3, + begin: function (anim) { + if (icon) icon.classList.add('glow') // Ensure icon exists before applying class + }, + complete: function (anim) { + if (icon) icon.classList.remove('glow') // Clean up: remove class after animation + } + }) + } else { + console.error('Goal rectangle is not available.') + } + } else { + console.error('Goal element is not available.') + } + } else if (!isGoal && target && targetRect) { + // Calculate the translation coordinates for the icon + translateX1 = + containerRect.left + containerRect.width / 2 - iconRect.width / 2 - iconRect.left + translateY1 = + containerRect.top + containerRect.height / 2 - iconRect.height / 2 - iconRect.top + translateX2 = targetRect.left + targetRect.width / 2 - iconRect.width / 2 - iconRect.left + translateY2 = targetRect.top + targetRect.height / 2 - iconRect.height / 2 - iconRect.top + + anime + .timeline({ + easing: 'easeInOutQuad', + duration: 1500 + }) + .add({ + targets: icon, + translateX: translateX1, + translateY: translateY1, + opacity: 0, // Start invisible + duration: 1000 + }) + .add({ + targets: icon, + opacity: 1, // Reveal the icon once it starts moving to the container + duration: 1000, // Make the opacity change almost instantaneously + scale: 3 + }) + .add({ + targets: icon, + translateX: translateX2, + translateY: translateY2, + scale: 0.5, + opacity: 1, // Keep the icon visible while moving to the target + duration: 1500 + }) + .add({ + targets: icon, + opacity: 0, // Fade out once it reaches the target + scale: 1, + duration: 500 + }) + .add({ + targets: icon, + translateX: 0, // Reset translation to original + translateY: 0, // Reset translation to original + duration: 500 + }) + } else if (!isGoal && !target) { + // Calculate the translation coordinates for the icon + translateX1 = + containerRect.left + containerRect.width / 2 - iconRect.width / 2 - iconRect.left + translateY1 = + containerRect.top + containerRect.height / 2 - iconRect.height / 2 - iconRect.top + anime + .timeline({ + easing: 'easeInOutQuad', + duration: 1500 + }) + .add({ + targets: icon, + translateX: translateX1, + translateY: translateY1, + opacity: 0, // Start invisible + duration: 1000 + }) + .add({ + targets: icon, + opacity: 1, // Reveal the icon once it starts moving to the container + duration: 1000, // Make the opacity change almost instantaneously + scale: 3 + }) + .add({ + targets: icon, + opacity: 0, // Fade out once it reaches the target + scale: 1, + duration: 500 + }) + .add({ + targets: icon, + translateX: 0, // Reset translation to original + translateY: 0, // Reset translation to original + duration: 500 + }) + } } - -// Helper methods to get icons -const getChallengeIcon = (challenge: Challenge): string => { - return `src/assets/${challenge.type.toLowerCase()}.png` +/** + * Triggers confeti animation + */ +const triggerConfetti = () => { + confetti({ + particleCount: 400, + spread: 80, + origin: { x: 0.8, y: 0.8 } + }) } +//fetching images const getGoalIcon = (goal: Goal): string => { - return `src/assets/${goal.title.toLowerCase()}.png` + if (goal) { + return `src/assets/${goal.title.toLowerCase()}.png` + } else { + return 'src/assets/pengesekkStreak.png' + } } const getPigStepsIcon = () => { return 'src/assets/pigSteps.png' } const goToEditGoal = () => { - router.push({ name: 'edit-goal', params: { id: goal.value?.id } }) + router.push({ name: 'edit-goal', params: { id: goalLocal?.id } }) +} + +const editGoal = (goal: Goal) => { + router.push(`/sparemaal/rediger/${goal.id}`) +} + +/** + * Sorts the challenges by completion status and due date + + */ +const sortChallenges = () => { + if (challengesLocal.value) { + challengesLocal.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 + } + }) + } +} + +// Interface for element references +interface ElementRefs { + [key: string]: HTMLElement | undefined +} + +const elementRefs = reactive<ElementRefs>({}) +const isAtFirstUncompleted = ref(false) +const firstUncompletedRef: Ref<HTMLElement | undefined> = ref() +const screenSize = ref<number>(window.innerWidth) + +/** + * Handles the window size change event + */ +const handleWindowSizeChange = () => { + screenSize.value = window.innerWidth +} + +/** + * Scrolls to the first uncompleted challenge + + */ +const scrollToFirstUncompleted = () => { + if (challengesLocal.value) { + let found = false + for (let i = 0; i < challengesLocal.value.length; i++) { + if (challengesLocal.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 + } + } +} + +/** + * Assigns the reference to the element + * @param el + * @param challenge + * @param index + */ +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] + } + } } </script> diff --git a/src/components/__tests__/savingsPathTest.spec.ts b/src/components/__tests__/savingsPathTest.spec.ts deleted file mode 100644 index 40739f5a4e0a9b042239fadd17c40fe3ae9c6a61..0000000000000000000000000000000000000000 --- a/src/components/__tests__/savingsPathTest.spec.ts +++ /dev/null @@ -1,123 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { mount } from '@vue/test-utils' -import { createPinia, setActivePinia } from 'pinia' -import SavingsPath from '@/components/SavingsPath.vue' - -vi.mock('canvas-confetti', () => ({ - default: vi.fn(() => ({ - reset: vi.fn(), - addFettis: vi.fn(), - render: vi.fn(), - 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: { - plugins: [pinia] - }, - props: { - challenges: [ - { - id: 1, - title: 'Test challenge', - perPurchase: 20, - saved: 100, - target: 1000, - description: 'Test description', - due: '2022-01-01T00:00:00Z', - createdOn: '2021-01-01T00:00:00Z', - type: 'Challenge type', - completion: 10 - } - ], - goal: { - id: 1, - title: 'Test goal', - saved: 100, - target: 1000, - description: 'Test description', - due: '2022-01-01T00:00:00Z', - createdOn: '2021-01-01T00:00:00Z', - completion: 10 - } - } - }) - }) - - describe('Initial Render', () => { - it('should render challenge and goal details correctly', async () => { - await wrapper.vm.$nextTick() - const challengeText = wrapper.text() - expect(challengeText).toContain('Test challenge') - expect(challengeText).toContain('100kr / 1000kr') - expect(challengeText).toContain('Test goal') - expect(challengeText).toContain('100kr / 1000kr') - }) - - it('should display the correct number of challenge elements', () => { - const challengeElements = wrapper.findAll('[data-cy="challenge-title"]') - expect(challengeElements.length).toBe(1) - }) - }) - - describe('User Interactions', () => { - it('should update challenge progress when increment button is clicked', async () => { - await wrapper.vm.$nextTick() - const incrementButton = wrapper.find('[data-cy="increment-challenge1"]') - expect(incrementButton.exists()).toBe(true) - await incrementButton.trigger('click') - expect(wrapper.vm.challenges[0].saved).toBe(120) - }) - }) - - describe('State Management', () => { - it('should react to changes in challenge completion status', async () => { - // Initially incomplete - let progressBar = wrapper.find('.bg-green-600') - expect(progressBar.element.style.width).toBe('10%') - - // Update challenge to be almost complete - await wrapper.setProps({ - challenges: [ - { - ...wrapper.props().challenges[0], - saved: 900, - completion: 90 - } - ] - }) - 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 0c2523ef15a4b7e735ea1562ea00aace42ef27cf..7eb27b71f09821a97a22ee79e76055909b34a8ff 100644 --- a/src/stores/challengeStore.ts +++ b/src/stores/challengeStore.ts @@ -14,6 +14,7 @@ export const useChallengeStore = defineStore('challenge', () => { challenges.value = response.data.content } else { challenges.value = [] + console.error('No challenge content found:', response.data) } } catch (error) { console.error('Error fetching challenges:', error) @@ -30,10 +31,16 @@ export const useChallengeStore = defineStore('challenge', () => { 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) + return challenges.value[index] } + } else { + console.error('No challenge content found in response data') + return null } } catch (error) { console.error('Error updating challenge:', error) + return null } } const completeUserChallenge = async (challenge: Challenge) => { @@ -47,12 +54,17 @@ export const useChallengeStore = defineStore('challenge', () => { 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) + console.log('Challenge Completed store:', challenges.value[index]) + return challenges.value[index] } } else { console.error('No challenge content found in response data') + return null } } catch (error) { console.error('Error updating challenge:', error) + return null } } diff --git a/src/stores/goalStore.ts b/src/stores/goalStore.ts index 3701fa92bf8eeda2f891dceeed47ca95ac8bfaa7..ce033826cace512c30dacc7d9d297d6fe9e9e037 100644 --- a/src/stores/goalStore.ts +++ b/src/stores/goalStore.ts @@ -5,19 +5,31 @@ import authInterceptor from '@/services/authInterceptor' export const useGoalStore = defineStore('goal', () => { const goals = ref<Goal[]>([]) + const priorityGoal = ref<Goal | null>(null) const getUserGoals = async () => { try { const response = await authInterceptor('/goals') if (response.data && response.data.content) { goals.value = response.data.content + for (const goal of goals.value) { + if (goal.priority === 1) { + priorityGoal.value = goal + break + } else { + priorityGoal.value = null + } + } + console.log(response.data.content) } else { goals.value = [] + console.error('No goal content found:', response.data) } } catch (error) { console.error('Error fetching challenges:', error) goals.value = [] // Ensure challenges is always an array } } + // Assuming 'challenges' is a reactive state in your store that holds the list of challenges const editUserGoal = async (goal: Goal) => { if (!goal || goal.id === null) { @@ -41,6 +53,7 @@ export const useGoalStore = defineStore('goal', () => { } return { goals, + priorityGoal, getUserGoals, editUserGoal } diff --git a/src/types/challenge.ts b/src/types/challenge.ts index ef9acee9b95f6d9ba0b28103b6981461bb1d8b83..8b7b5ab571d851b09415ec23d61de868e9283eab 100644 --- a/src/types/challenge.ts +++ b/src/types/challenge.ts @@ -9,7 +9,7 @@ export interface Challenge { description: string due: string // Mapping ZonedDateTime to Date, optional since Temporal annotation not always implies required createdOn?: string // Mapping ZonedDateTime to Date - type: string // Not specified as @NotNull, so it's optional + type?: string // Not specified as @NotNull, so it's optional completion?: number // Assuming BigDecimal maps to number, optional due to @Transient completedOn?: string // Adding the new variable as optional } diff --git a/src/types/streak.ts b/src/types/streak.ts index 61dafae24c954c7ca99a3596ca43a659f1ef122c..f49346322939e0b0517ebca400e5fcaeccddedc3 100644 --- a/src/types/streak.ts +++ b/src/types/streak.ts @@ -1,4 +1,5 @@ export interface Streak { streakStart?: string streak?: number + firstDue?: string } diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index bb601386b1560568ee83366d32597d5ec1eb18e3..2aae863d362ac964b2e3b00a2da7cae122b6f178 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -26,7 +26,7 @@ /> </div> </div> - <savings-path :challenges="challenges" :goal="goal"></savings-path> + <savings-path v-if="isMounted" :challenges="challenges" :goal="goal"></savings-path> </div> <GeneratedChallengesModal v-show="showModal" @update:showModal="showModal = $event" /> </template> @@ -50,24 +50,23 @@ 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) +const isMounted = ref(false) onMounted(async () => { await goalStore.getUserGoals() await challengeStore.getUserChallenges() challenges.value = challengeStore.challenges - goals.value = goalStore.goals - goal.value = goals.value[0] - + goal.value = goalStore.priorityGoal const lastModalShow = localStorage.getItem('lastModalShow') if (!lastModalShow || Date.now() - Number(lastModalShow) >= 24 * 60 * 60 * 1000) { showModal.value = true } firstLoggedInSpeech() SpareSpeech() + isMounted.value = true }) const firstLoggedInSpeech = () => { diff --git a/src/views/ManageChallengeView.vue b/src/views/ManageChallengeView.vue index 7cf90c7a0afbce65fd3c47a48a693370bbc2af48..a60084093ad69cd9d586c5cc61fb759cc6610a50 100644 --- a/src/views/ManageChallengeView.vue +++ b/src/views/ManageChallengeView.vue @@ -4,113 +4,87 @@ import { computed, onMounted, ref, watch } from 'vue' import ProgressBar from '@/components/ProgressBar.vue' import authInterceptor from '@/services/authInterceptor' import type { Challenge } from '@/types/challenge' +import ModalComponent from '@/components/ModalComponent.vue' const router = useRouter() +const modalTitle = ref('') +const modalMessage = ref('') +const confirmModalOpen = ref(false) +const errorModalOpen = ref(false) + const oneWeekFromNow = new Date() oneWeekFromNow.setDate(oneWeekFromNow.getDate() + 7) -const minDate = oneWeekFromNow.toISOString().slice(0, 16) +const minDate = new Date(new Date().setDate(new Date().getDate() + 1)).toISOString().slice(0, 10) +const selectedDate = ref<string>(minDate) const thirtyDaysFromNow = new Date() thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30) -const maxDate = thirtyDaysFromNow.toISOString().slice(0, 16) +const maxDate = thirtyDaysFromNow.toISOString().slice(0, 10) const challengeInstance = ref<Challenge>({ title: '', - perPurchase: 20, + perPurchase: 0, saved: 0, - target: 100, + target: 0, description: '', - due: minDate + ':00.000Z', - type: '' + due: '' }) -const isAmountSaved = ref(false) -const timesSaved = ref(challengeInstance.value.saved / challengeInstance.value.perPurchase) - -watch( - () => timesSaved.value, - (newVal) => { - challengeInstance.value.saved = newVal * challengeInstance.value.perPurchase - challengeInstance.value.saved = parseFloat(challengeInstance.value.saved.toFixed(2)) - } -) - -watch( - () => challengeInstance.value.saved, - (newVal) => { - challengeInstance.value.saved = Math.max( - 0, - Math.min(challengeInstance.value.target, newVal) - ) - challengeInstance.value.saved = parseFloat(challengeInstance.value.saved.toFixed(2)) - timesSaved.value = challengeInstance.value.saved / challengeInstance.value.perPurchase - timesSaved.value = parseFloat(timesSaved.value.toFixed(2)) - } -) - -watch( - () => challengeInstance.value.perPurchase, - (newVal) => { - challengeInstance.value.perPurchase = Math.max( - 1, - Math.min(challengeInstance.value.target, newVal) - ) - challengeInstance.value.perPurchase = parseFloat( - challengeInstance.value.perPurchase.toFixed(2) - ) - timesSaved.value = challengeInstance.value.saved / challengeInstance.value.perPurchase - timesSaved.value = parseFloat(timesSaved.value.toFixed(2)) - } -) - -watch( - () => challengeInstance.value.target, - (newVal) => { - challengeInstance.value.target = Math.max( - Math.max(challengeInstance.value.saved, 1), - newVal - ) - } -) - -const selectedDate = ref(minDate) -watch( - () => selectedDate.value, - (newVal) => { - if (newVal) { - selectedDate.value = newVal < minDate ? minDate : newVal - challengeInstance.value.due = selectedDate.value + ':00.000Z' - } - } -) +watch(selectedDate, (newDate) => { + challengeInstance.value.due = newDate +}) const isEdit = computed(() => router.currentRoute.value.name === 'edit-challenge') -const pageTitle = computed(() => (isEdit.value ? 'Rediger utfordring' : 'Ny utfordring')) +const pageTitle = computed(() => (isEdit.value ? 'Rediger utfordring🎨' : 'Ny utfordring🎨')) const submitButton = computed(() => (isEdit.value ? 'Oppdater' : 'Opprett')) const completion = computed( () => (challengeInstance.value.saved / challengeInstance.value.target) * 100 ) -const isInputValid = computed(() => { - return ( - challengeInstance.value.title.length > 0 && - challengeInstance.value.title.length <= 20 && - challengeInstance.value.description.length <= 280 && - challengeInstance.value.target > 0 && - challengeInstance.value.due !== '' - ) -}) +function validateInputs() { + const errors = [] -const submitAction = () => { - if (!isInputValid.value) { - return () => alert('Fyll ut alle feltene') + challengeInstance.value.due = selectedDate.value + 'T23:59:59.999Z' + + if (!challengeInstance.value.title || challengeInstance.value.title.length > 20) { + errors.push('Tittelen mÃ¥ være mellom 1 og 20 tegn.') + } + if (challengeInstance.value.description.length > 280) { + errors.push('Beskrivelsen mÃ¥ være under 280 tegn.') + } + if (challengeInstance.value.target <= 0) { + errors.push('MÃ¥lbeløpet mÃ¥ være større enn 0.') } + if (new Date(challengeInstance.value.due) < new Date(minDate)) { + errors.push('Forfallsdatoen mÃ¥ være minst en uke frem i tid.') + } + if (challengeInstance.value.perPurchase <= 0) { + errors.push('Pris per sparing mÃ¥ være større enn 0.') + } + return errors +} - if (isEdit.value) { - updateChallenge() - } else { - createChallenge() +const submitAction = async () => { + const errors = validateInputs() + if (errors.length > 0) { + const formatErrors = errors.join('\n') + modalTitle.value = 'Oops! Noe er feil med det du har fylt ut🚨' + modalMessage.value = formatErrors + errorModalOpen.value = true + return + } + try { + if (isEdit.value) { + updateChallenge() + } else { + createChallenge() + } + } catch (error) { + console.error(error) + modalTitle.value = 'Systemfeil' + modalMessage.value = 'En feil oppstod under lagring av utfordringen.' + errorModalOpen.value = true } } @@ -156,6 +130,27 @@ const updateChallenge = () => { console.error(error) }) } + +const cancelCreation = () => { + if ( + challengeInstance.value.title !== '' || + challengeInstance.value.description !== '' || + challengeInstance.value.perPurchase !== 0 || + challengeInstance.value.saved !== 0 || + challengeInstance.value.target !== 0 + ) { + modalTitle.value = 'Du er i ferd med Ã¥ avbryte redigeringen🚨' + modalMessage.value = 'Er du sikker pÃ¥ at du vil avbryte?' + confirmModalOpen.value = true + } else { + router.push({ name: 'challenges' }) + } +} + +const confirmCancel = () => { + router.push({ name: 'challenges' }) + confirmModalOpen.value = false +} </script> <template> @@ -172,12 +167,7 @@ const updateChallenge = () => { </div> <div class="flex flex-col"> - <p class="mx-4">Type</p> - <input v-model="challengeInstance.type" placeholder="Skriv en type" type="text" /> - </div> - - <div class="flex flex-col"> - <p class="mx-4">Beskrivelse</p> + <p class="mx-4">Beskrivelse (valgfri)</p> <textarea v-model="challengeInstance.description" class="w-80 h-20 no-rezise" @@ -187,7 +177,7 @@ const updateChallenge = () => { <div class="flex flex-col sm:flex-row gap-3"> <div class="flex flex-col"> - <p class="mx-4">Pris per sparing</p> + <p class="mx-4">Spare per gang</p> <input v-model="challengeInstance.perPurchase" class="w-40 text-right" @@ -198,30 +188,18 @@ const updateChallenge = () => { <div class="flex flex-col"> <div class="flex flex-row justify-between mx-4"> - <p>{{ isAmountSaved ? 'Kroner spart' : 'Antall sparinger' }}</p> - <button class="p-0 bg-transparent" @click="isAmountSaved = !isAmountSaved"> - ðŸ”„ï¸ - </button> + <p>Kroner spart💸</p> </div> <input - v-if="isAmountSaved" v-model="challengeInstance.saved" class="w-40 text-right" - min="0" placeholder="Sparebeløp" type="number" /> - <input - v-else - v-model="timesSaved" - class="w-40 text-right" - placeholder="Kr spart per sparing" - type="number" - /> </div> <div class="flex flex-col"> - <p class="mx-4">Av mÃ¥lbeløp...*</p> + <p class="mx-4">Av mÃ¥lbeløp💯*</p> <input v-model="challengeInstance.target" class="w-40 text-right" @@ -232,26 +210,64 @@ const updateChallenge = () => { </div> <ProgressBar :completion="completion" /> - <div class="flex flex-col"> - <p class="mx-4">Forfallsdato*</p> - <input - v-model="selectedDate" - :max="maxDate" - :min="minDate" - placeholder="Forfallsdato" - type="datetime-local" - /> - </div> + <div class="flex flex-row gap-4"> + <div class="flex flex-col"> + <p class="mx-4">Forfallsdato*</p> + <input + :min="minDate" + :max="maxDate" + v-model="selectedDate" + placeholder="Forfallsdato" + type="date" + /> + </div> + <div class="flex flex-col"> + <p>Last opp ikon for utfordringen📸</p> + <button + class="mt-2 font-bold cursor-pointer transition-transform duration-300 ease-in-out hover:scale-110 hover:opacity-90" + > + 💾 + </button> + </div> + </div> <div class="flex flex-row justify-between w-full"> - <button :disabled="!isInputValid" @click="submitAction" v-text="submitButton" /> + <button class="primary danger" @click="cancelCreation" v-text="'Avbryt'" /> - <button - class="bg-button-other" - @click="router.push({ name: 'challenges' })" - v-text="'Avbryt'" - /> + <button class="primary" @click="submitAction" v-text="submitButton" /> </div> + <ModalComponent + :title="modalTitle" + :message="modalMessage" + :isModalOpen="errorModalOpen" + @close="errorModalOpen = false" + > + <template v-slot:input> + <div class="flex justify-center items-center"> + <div class="flex flex-col gap-5"> + <button class="primary" @click="errorModalOpen = false">Lukk</button> + </div> + </div> + </template> + </ModalComponent> + + <ModalComponent + :title="modalTitle" + :message="modalMessage" + :isModalOpen="confirmModalOpen" + @close="confirmModalOpen = false" + > + <template v-slot:input> + <div class="flex justify-center items-center"> + <div class="flex flex-col gap-5"> + <button class="primary" @click="confirmCancel">Bekreft</button> + <button class="primary danger" @click="confirmModalOpen = false"> + Avbryt + </button> + </div> + </div> + </template> + </ModalComponent> </div> </div> </template> diff --git a/src/views/ManageGoalView.vue b/src/views/ManageGoalView.vue index a3a246c4497701c183f38a3cf9b953a461b895c2..d2d8e75bc429e79a1f0aed0dc3f498f6960c541a 100644 --- a/src/views/ManageGoalView.vue +++ b/src/views/ManageGoalView.vue @@ -4,68 +4,90 @@ import { computed, onMounted, ref, watch } from 'vue' import type { Goal } from '@/types/goal' import ProgressBar from '@/components/ProgressBar.vue' import authInterceptor from '@/services/authInterceptor' +import ModalComponent from '@/components/ModalComponent.vue' const router = useRouter() -const selectedDate = ref<string>('') -const minDate = new Date(new Date().setDate(new Date().getDate() + 1)).toISOString().slice(0, 16) +const minDate = new Date(new Date().setDate(new Date().getDate() + 1)).toISOString().slice(0, 10) +const selectedDate = ref<string>(minDate) + +const modalMessage = ref<string>('') +const modalTitle = ref<string>('') +const errorModalOpen = ref<boolean>(false) +const confirmModalOpen = ref<boolean>(false) const goalInstance = ref<Goal>({ title: '', saved: 0, - target: 100, + target: 0, description: '', due: '' }) -watch( - () => goalInstance.value.saved, - (newVal) => { - goalInstance.value.saved = Math.max(0, Math.min(goalInstance.value.target, newVal)) - } -) - -watch( - () => goalInstance.value.target, - (newVal) => { - goalInstance.value.target = Math.max(Math.max(goalInstance.value.saved, 1), newVal) - } -) - -watch( - () => selectedDate.value, - (newVal) => { - if (newVal < minDate) selectedDate.value = minDate - goalInstance.value.due = newVal + ':00.000Z' - } -) +watch(selectedDate, (newDate) => { + goalInstance.value.due = newDate +}) const isEdit = computed(() => router.currentRoute.value.name === 'edit-goal') -const pageTitle = computed(() => (isEdit.value ? 'Rediger sparemÃ¥l' : 'Nytt sparemÃ¥l')) +const pageTitle = computed(() => (isEdit.value ? 'Rediger sparemÃ¥l🎨' : 'Nytt sparemÃ¥l🎨')) const submitButton = computed(() => (isEdit.value ? 'Oppdater' : 'Opprett')) const completion = computed(() => (goalInstance.value.saved / goalInstance.value.target) * 100) -const isInputValid = computed(() => { - return ( - goalInstance.value.title.length > 0 && - goalInstance.value.title.length <= 20 && - goalInstance.value.description.length <= 280 && - goalInstance.value.target > 0 && - goalInstance.value.due !== '' - ) -}) +function validateInputs() { + const errors = [] + + goalInstance.value.due = selectedDate.value + 'T23:59:59.999Z' + + if (!goalInstance.value.title) { + errors.push('Tittel mÃ¥ fylles ut') + } + if (!goalInstance.value.target) { + errors.push('MÃ¥lbeløp mÃ¥ fylles ut') + } + if (!goalInstance.value.due) { + errors.push('Forfallsdato mÃ¥ fylles ut') + } -const submitAction = () => { - if (!isInputValid.value) { - return () => alert('Fyll ut alle feltene') + if (goalInstance.value.target < 1) { + errors.push('MÃ¥lbeløp mÃ¥ være større enn 0') } - if (isEdit.value) { - updateGoal() - } else { - createGoal() + if (goalInstance.value.saved < 0) { + errors.push('Sparebeløp kan ikke være negativt') } + + if (goalInstance.value.saved > goalInstance.value.target) { + errors.push('Sparebeløp kan ikke være større enn mÃ¥lbeløp') + } + + return errors } +const submitAction = async () => { + const errors = validateInputs() + if (errors.length > 0) { + const formatErrors = errors.join('\n') + modalTitle.value = 'Oops! Noe er feil med det du har fylt ut🚨' + modalMessage.value = formatErrors.replace(/\n/g, '<br>') + errorModalOpen.value = true + return + } + try { + if (isEdit.value) { + updateGoal() + } else { + createGoal() + } + } catch (error) { + console.error(error) + modalTitle.value = 'Systemfeil' + modalMessage.value = 'En feil oppstod under lagring av utfordringen.' + errorModalOpen.value = true + } +} + +watch(selectedDate, (newDate) => { + console.log(newDate) +}) onMounted(async () => { if (isEdit.value) { @@ -75,12 +97,14 @@ onMounted(async () => { await authInterceptor(`/goals/${goalId}`) .then((response) => { goalInstance.value = response.data - selectedDate.value = response.data.due.slice(0, 16) + selectedDate.value = response.data.due.slice(0, 10) }) .catch((error) => { console.error(error) router.push({ name: 'goals' }) }) + } else { + goalInstance.value.due = selectedDate.value } }) @@ -116,6 +140,26 @@ const deleteGoal = () => { console.error(error) }) } + +function cancelCreation() { + if ( + goalInstance.value.title !== '' || + goalInstance.value.description !== '' || + goalInstance.value.target !== 0 || + selectedDate.value !== '' + ) { + modalTitle.value = 'Du er i ferd med Ã¥ avbryte redigeringen🚨' + modalMessage.value = 'Er du sikker pÃ¥ at du vil avbryte?' + confirmModalOpen.value = true + } else { + router.push({ name: 'goals' }) + } +} + +const confirmCancel = () => { + router.push({ name: 'goals' }) + confirmModalOpen.value = false +} </script> <template> @@ -128,7 +172,7 @@ const deleteGoal = () => { </div> <div class="flex flex-col"> - <p class="mx-4">Beskrivelse</p> + <p class="mx-4">Beskrivelse (valgfri)</p> <textarea v-model="goalInstance.description" class="w-80 h-20 no-rezise" @@ -138,18 +182,17 @@ const deleteGoal = () => { <div class="flex flex-col sm:flex-row gap-3"> <div class="flex flex-col"> - <p class="mx-4">Kroner spart...</p> + <p class="mx-4">Kroner spart💸</p> <input v-model="goalInstance.saved" class="w-40 text-right" - min="0" placeholder="Sparebeløp" type="number" /> </div> <div class="flex flex-col"> - <p class="mx-4">Av mÃ¥lbeløp...*</p> + <p class="mx-4">Av mÃ¥lbeløp💯*</p> <input v-model="goalInstance.target" class="w-40 text-right" @@ -160,31 +203,73 @@ const deleteGoal = () => { </div> <ProgressBar :completion="completion" /> - <div class="flex flex-col"> - <p class="mx-4">Forfallsdato*</p> - <input - :min="minDate" - v-model="selectedDate" - placeholder="Forfallsdato" - type="datetime-local" - /> + <div class="flex flex-row gap-4"> + <div class="flex flex-col"> + <p class="mx-4">Forfallsdato*</p> + <input + :min="minDate" + v-model="selectedDate" + placeholder="Forfallsdato" + type="date" + /> + </div> + <div class="flex flex-col"> + <p>Last opp ikon for utfordringen📸</p> + <button + class="mt-2 font-bold cursor-pointer transition-transform duration-300 ease-in-out hover:scale-110 hover:opacity-90" + > + 💾 + </button> + </div> </div> <div class="flex flex-row justify-between w-full"> - <button :disabled="!isInputValid" @click="submitAction" v-text="submitButton" /> <button v-if="isEdit" - class="ml-2 bg-button-danger" + class="ml-2 primary danger" @click="deleteGoal" v-text="'Slett'" /> <button v-else - class="ml-2 bg-button-other" - @click="router.push({ name: 'goals' })" + class="ml-2 primary danger" + @click="cancelCreation" v-text="'Avbryt'" /> + <button class="primary" @click="submitAction" v-text="submitButton" /> </div> + <ModalComponent + :title="modalTitle" + :message="modalMessage" + :isModalOpen="errorModalOpen" + @close="errorModalOpen = false" + > + <template v-slot:input> + <div class="flex justify-center items-center"> + <div class="flex flex-col gap-5"> + <button class="primary" @click="errorModalOpen = false">Lukk</button> + </div> + </div> + </template> + </ModalComponent> + + <ModalComponent + :title="modalTitle" + :message="modalMessage" + :isModalOpen="confirmModalOpen" + @close="confirmModalOpen = false" + > + <template v-slot:input> + <div class="flex justify-center items-center"> + <div class="flex flex-col gap-5"> + <button class="primary" @click="confirmCancel">Bekreft</button> + <button class="primary danger" @click="confirmModalOpen = false"> + Avbryt + </button> + </div> + </div> + </template> + </ModalComponent> </div> </div> </template> diff --git a/src/views/UserChallengesView.vue b/src/views/UserChallengesView.vue index fa5f8047a1a797065cc638881842ac1d6fecae23..2e05bb08119724ddaa01620cda852b335942b490 100644 --- a/src/views/UserChallengesView.vue +++ b/src/views/UserChallengesView.vue @@ -50,18 +50,19 @@ onMounted(async () => { <h1 class="font-bold text-center">Dine utfordringer</h1> <div class="flex flex-col gap-5 items-center"> <div class="flex flex-row gap-5"> - <button @click="router.push({ name: 'new-challenge' })"> + <button class="primary" @click="router.push({ name: 'new-challenge' })"> Opprett en ny utfordring </button> </div> - <h2 class="font-bold">Aktive utfordringer</h2> + <h2 class="font-bold">Aktive utfordringer🚀</h2> <div class="flex flex-row justify-center gap-10 flex-wrap"> <CardChallenge v-for="challenge in activeChallenges" :key="challenge.id" :challenge-instance="challenge" /> + <p v-if="!activeChallenges">Du har ingen aktive spareutfordringer😢</p> </div> <PageControl :currentPage="currentPageActive" @@ -69,9 +70,10 @@ onMounted(async () => { :totalPages="totalPagesActive" /> - <h2 class="font-bold">Fullførte utfordringer</h2> + <h2 class="font-bold">Fullførte utfordringer💯</h2> <div class="flex flex-row justify-center gap-10 flex-wrap"> <CardChallenge + class="border-2 border-slate-200 hover:bg-slate-50" v-for="challenge in completedChallenges" :key="challenge.id" :challenge-instance="challenge" diff --git a/src/views/UserGoalsView.vue b/src/views/UserGoalsView.vue index 4d882ae6c3d40972e2b7c74fa02a06ed00b1480e..734f469364f66c7002b66895c3b7ef0de6b97cc2 100644 --- a/src/views/UserGoalsView.vue +++ b/src/views/UserGoalsView.vue @@ -60,8 +60,10 @@ const changeOrder = async () => { <template> <div class="flex flex-col gap-5 items-center"> <h1 class="font-bold m-0">Dine sparemÃ¥l</h1> - <button @click="router.push({ name: 'new-goal' })">Opprett et nytt sparemÃ¥l</button> - <h2 class="font-thin m-0">Aktive sparemÃ¥l</h2> + <button class="primary" @click="router.push({ name: 'new-goal' })"> + Opprett et nytt sparemÃ¥l + </button> + <h2 class="font-bold m-0">Aktive sparemÃ¥l🚀</h2> <p v-if="activeGoals.length === 0">Du har ingen aktive sparemÃ¥l</p> <draggable v-else @@ -75,20 +77,30 @@ const changeOrder = async () => { :key="index" :class="[ { 'cursor-move shadow-xl -translate-y-2 duration-300': isDraggable }, - { 'border-4 border-green-500': index === 0 } + { 'border-2 border-lime-400': index === 0 }, + { 'border-2 border-slate-200 hover:bg-slate-50': index !== 0 } ]" :goal-instance="element" :is-clickable="!isDraggable" /> </template> </draggable> - <button :disabled="activeGoals.length === 0" @click="changeOrder()"> + <button + class="primary secondary" + :disabled="activeGoals.length === 0" + @click="changeOrder()" + > {{ isDraggable ? 'Lagre rekkefølge' : 'Endre rekkefølge' }} </button> - <h2 class="font-thin m-0">Fullførte sparemÃ¥l</h2> - <p v-if="completedGoals.length === 0">Du har ingen fullførte sparemÃ¥l</p> + <h2 class="font-bold m-0">Fullførte sparemÃ¥l💯</h2> + <p v-if="completedGoals.length === 0">Du har ingen fullførte sparemÃ¥l😢</p> <div v-else class="flex flex-row flex-wrap justify-center gap-10"> - <CardGoal v-for="goal in completedGoals" :key="goal.id" :goal-instance="goal" /> + <CardGoal + class="border-2 border-slate-200 hover:bg-slate-50" + v-for="goal in completedGoals" + :key="goal.id" + :goal-instance="goal" + /> </div> <PageControl :current-page="currentPage" diff --git a/src/views/ViewChallengeView.vue b/src/views/ViewChallengeView.vue index 6efadbcaefac2f4660ca1e50c0baf931941dabcc..924d5ae1ab49df8b8b912655fb49b449b835224f 100644 --- a/src/views/ViewChallengeView.vue +++ b/src/views/ViewChallengeView.vue @@ -81,14 +81,14 @@ const completeChallenge = () => { <div class="flex flex-row flex-wrap items-center justify-center gap-10"> <div class="flex flex-col gap-5 max-w-96"> <button - class="w-min" + class="w-min bg-transparent rounded-lg font-bold left-10 cursor-pointer transition-transform duration-300 ease-in-out hover:scale-110 hover:opacity-100 justify-start" @click="router.push({ name: 'challenges', params: { id: challengeInstance.id } })" > - Oversikt + 👈Oversikt </button> <div - class="flex flex-col justify-center border-4 border-black rounded-3xl align-middle p-5 card-shadow overflow-hidden w-full" + class="flex flex-col justify-center border-2 rounded-3xl align-middle p-5 card-shadow overflow-hidden w-full" > <h2 class="my-0">Spareutfordring:</h2> <h2 class="font-light"> @@ -106,10 +106,24 @@ const completeChallenge = () => { Du sparer {{ challengeInstance.perPurchase }}kr hver gang du dropper Ã¥ bruke penger pÃ¥ {{ challengeInstance.type }} </p> + <div class="justify-center pl-20"> + <button + class="primary danger mt-2 rounded-2xl p-2 w-40" + @click=" + authInterceptor + .delete(`/challenges/${challengeInstance.id}`) + .then(() => router.push({ name: 'challenges' })) + .catch((error) => console.error(error)) + " + > + Slett + </button> + </div> </div> <div class="flex flex-row justify-between w-full"> <button + class="primary secondary" v-if="!isCompleted" @click=" router.push({ @@ -122,22 +136,11 @@ const completeChallenge = () => { </button> <button + class="primary" v-if="!isCompleted" @click="completeChallenge" v-text="'Sett utfordring til ferdig'" /> - - <button - class="bg-button-danger hover:bg-button-danger" - @click=" - authInterceptor - .delete(`/challenges/${challengeInstance.id}`) - .then(() => router.push({ name: 'challenges' })) - .catch((error) => console.error(error)) - " - > - Slett - </button> </div> </div> <SpareComponent diff --git a/src/views/ViewGoalView.vue b/src/views/ViewGoalView.vue index 430a9d5cd35c13724a46c4b75e9c7722d87b1c39..04f4b42d2cf34b1df7eba571c095f723e7740c90 100644 --- a/src/views/ViewGoalView.vue +++ b/src/views/ViewGoalView.vue @@ -73,29 +73,36 @@ const completeGoal = () => { <div class="flex flex-row flex-wrap items-center justify-center gap-10"> <div class="flex flex-col gap-5 max-w-96"> <button - class="w-min" + class="w-min bg-transparent rounded-lg font-bold left-10 cursor-pointer transition-transform duration-200 ease-in-out hover:scale-110 hover:opacity-100 justify-start" @click="router.push({ name: 'goals', params: { id: goalInstance.id } })" > - Oversikt + 👈Oversikt </button> <div - class="flex flex-col justify-center border-4 border-black rounded-3xl align-middle p-5 card-shadow overflow-hidden w-full" + class="flex flex-col justify-center border-2 rounded-3xl align-middle p-5 card-shadow overflow-hidden w-full" > <h2 class="my-0">SparemÃ¥l:</h2> <h2 class="font-light"> {{ goalInstance.title }} </h2> - <p class="text-wrap break-words">{{ goalInstance.description }}</p> + <div class="flex flex-row gap-4 justify-center"> + <p class="text-wrap break-words">{{ goalInstance.description }}</p> + <div> + <img + class="w-20 h-20" + src="https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_1280.png" + alt="Profilbilde" + /> + </div> + </div> <br /> <p class="text-center"> Du har spart {{ goalInstance.saved }}kr av {{ goalInstance.target }}kr </p> <ProgressBar :completion="completion" /> - </div> - - <div class="flex flex-row justify-between gap-2 w-full"> <button + class="primary secondary mt-6" v-if="!isCompleted" @click=" router.push({ @@ -106,15 +113,8 @@ const completeGoal = () => { > Rediger </button> - - <button - v-if="!isCompleted" - @click="completeGoal" - v-text="'Marker mÃ¥let som ferdig'" - /> - <button - class="bg-button-danger hover:bg-button-danger" + class="danger mt-2 rounded-2xl p-1" @click=" authInterceptor .delete(`/goals/${goalInstance.id}`) @@ -124,6 +124,12 @@ const completeGoal = () => { > Slett </button> + <button + class="primary mt-4" + v-if="!isCompleted" + @click="completeGoal" + v-text="'Marker mÃ¥let som ferdig'" + /> </div> </div> <SpareComponent diff --git a/tailwind.config.js b/tailwind.config.js index 85dfc48accae773f41c89b06e72c48669c4df697..602557ab182f079569a219c33e01d5ccca4f462a 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -27,7 +27,14 @@ export default { 'button-disabled': 'var(--grey)', 'button-danger': 'var(--red)', 'button-other': 'var(--accent1)' - } + }, + backgroundSize: { + 'auto': 'auto', + 'cover': 'cover', + 'contain': 'contain', + 'pc': '20%', + 'phone': '50%', + }, } }, plugins: []