<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"> <span class="w-full max-w-60 max-h-12 text-black text-2xl font-bold py-2 rounded mt-8 text-center space-x-2 drop-shadow-lg" > Din Sparesti </span> </div> <button v-if="!allChallengesCompleted()" class="h-auto w-auto absolute flex text-center self-end mr-10 md:mr-20 text-wrap border-2 border-gray-200 rounded-xl 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 hover:scale-105" @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 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-transparent rounded-lg bg-white shadow-md shadow-slate-400" style="background-image: url('src/assets/bakgrunn.png')" > <div> <img src="@/assets/start-sign.png" alt="Spare" class="md:w-1/6 md:h-auto h-20" /> </div> <div v-for="(challenge, index) in challengesLocal" :key="challenge.id" class="flex flex-col items-center" :ref="(el) => assignRef(el, challenge, index)" > <!-- Challenge Row --> <div :class="{ '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-full md:w-4/5 justify-start gap-4 md:gap-8 h-auto" > <div class="flex"> <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> <card-challenge-savings-path :goal="goalLocal!" :challenge="challenge" @update-challenge="handleChallengeUpdate" ></card-challenge-savings-path> <div class="flex"> <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 --> <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 md:w-24 lg:w-32 h-20 md:h-24 lg:h-32" alt="Pig Steps" /> </div> <div v-if="index === challengesLocal.length - 1 && index % 2 === 0" class="flex flex-row mt-2" > <button class="text-2xl ml-48 mr-2 primary" @click="addSpareUtfordring"> + </button> <p class="">Legg til <br />Spareutfordring</p> </div> <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 primary" @click="addSpareUtfordring"> + </button> <p class="pl-2">Legg til <br />Spareutfordring</p> </div> <!-- Finish line --> </div> <img src="@/assets/finishline2.png" class="w-full max-h-auto mx-auto mt-4" alt="Finish Line" /> </div> <!-- Goal --> <div v-if="goalLocal" class="flex flex-row md:justify-between justify-around m-t-2 pt-6 w-[80%] mx-auto" > <div class="grid grid-rows-2 grid-flow-col gap 4"> <p class="md:mr-20 md:text-xl mt-4 font-bold text-sm md:text-nowrap h-auto w-32 mr-0" > Ditt neste sparemål🤩: </p> <div class="row-span-3 cursor-pointer md:ml-10 text-center" @click="editGoal(goalLocal)" > <img :src="goalImageUrl" class="w-12 h-12 mx-auto rounded-sm" :alt="goalLocal.title" /> <div class="md:text-lg text-xs font-bold" data-cy="goal-title"> {{ goalLocal.title }} </div> </div> </div> <div class="flex flex-col items-end gap-2"> <button class="primary secondary md:text-lg text-xs" @click="goToEditGoal"> Endre mål </button> <div :key="componentKey" ref="targetRef" class="bg-yellow-300 px-4 py-1 rounded-2xl text-black font-bold md:text-lg text-xs text-nowrap" > {{ goalLocal.saved }}kr / {{ goalLocal.target }}kr </div> </div> </div> </div> <!-- Animation icon --> <img src="@/assets/penger.png" alt="Penger" ref="iconRef" class="max-w-20 max-h-20 absolute opacity-0" /> <img v-if="goalLocal" :src="goalImageUrl" alt="could not load" ref="goalIconRef" class="shadow-sm shadow-amber-300 max-w-20 max-h-20 absolute opacity-0 rounded-sm" /> </template> <script setup lang="ts"> import { type ComponentPublicInstance, nextTick, onMounted, onUnmounted, reactive, type Ref, ref } from 'vue' import anime from 'animejs' import type { Challenge } from '@/types/challenge' import type { Goal } from '@/types/goal' import confetti from 'canvas-confetti' import { useRouter } from 'vue-router' import { useGoalStore } from '@/stores/goalStore' import ImgGifTemplate from '@/components/ImgGifTemplate.vue' import CardChallengeSavingsPath from '@/components/CardChallengeSavingsPath.vue' import authInterceptor from '@/services/authInterceptor' const router = useRouter() const goalStore = useGoalStore() const emit = defineEmits(['complete-challenge']) interface Props { challenges: Challenge[] goal: Goal | null | undefined } const props = defineProps<Props>() 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 () => { window.addEventListener('resize', handleWindowSizeChange) handleWindowSizeChange() challengesLocal.value = props.challenges goalLocal = props.goal sortChallenges() 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 ) }) } scrollToFirstUncompleted() }, 300) // Timeout set to 300 milliseconds // Load existing animated states first loadAnimatedStates() // 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 // 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] // 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', () => { // Clean up the scroll listener }) } }) const handleChallengeUpdate = (updatedChallenge: Challenge) => { if (challengesLocal.value) { const index = challengesLocal.value.findIndex((c) => c.id === updatedChallenge.id) if (index !== -1) { challengesLocal.value[index] = { ...updatedChallenge } } if ( updatedChallenge.completion! >= 100 && !animatedChallenges.value.includes(updatedChallenge.id as number) ) { animateChallenge(updatedChallenge) saveAnimatedStateChallenge(updatedChallenge) emit('complete-challenge') } if (goalLocal) { incrementGoalSaved(updatedChallenge) // Force component update right here might be more appropriate componentKey.value++ } } } 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 } } } /** * Navigates to the spareutfordringer page */ const addSpareUtfordring = () => { router.push('/spareutfordringer').catch((error) => { console.error('Routing error:', error) }) } /** * 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 } //-----------Animation for goal and challenge completion-----------------// // 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) : [] } /** * 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 && !animatedChallenges.value.includes(challenge.id as number) ) { if (challenge.id != null) { animatedChallenges.value.push(challenge.id) } // Ensure no duplication saveAnimatedStateChallenge(challenge) // Refactor this to update localStorage correctly triggerConfetti() recalculateAndAnimate(false) } } /** * 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) } /** * 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.') } } /** * 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)) } /** * 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) { console.error('Required animation elements are not available.') return } // Obtain bounding rectangles safely const containerRect = container.getBoundingClientRect() const targetRect = target?.getBoundingClientRect() const iconRect = icon.getBoundingClientRect() // 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 }) } } /** * Triggers confeti animation */ const triggerConfetti = () => { confetti({ particleCount: 400, spread: 80, origin: { x: 0.8, y: 0.8 } }) } const goalImageUrl = ref('src/assets/pengesekkStreak.png') const getGoalIcon = async (goalId: number) => { try { const imageResponse = await authInterceptor.get(`/goals/picture?id=${goalId}`, { responseType: 'blob' }) goalImageUrl.value = URL.createObjectURL(imageResponse.data) } catch (error) { console.error('Failed to load challenge icon:', error) goalImageUrl.value = 'src/assets/pengesekkStreak.png' } } onMounted(() => { if (props.goal?.id) { getGoalIcon(props.goal.id) } else { console.error('Goal id is undefined') } }) const getPigStepsIcon = () => { return 'src/assets/pigSteps.png' } const goToEditGoal = () => { 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.completedOn === null && b.completedOn !== null) { return 1 // 'a' is not completed and 'b' is completed, 'a' should come first } else if (a.completion !== null && b.completion === null) { 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> <style scoped> /* Tailwind CSS - Custom CSS for hiding scrollbars */ .no-scrollbar::-webkit-scrollbar { display: none; /* for Chrome, Safari, and Opera */ } .no-scrollbar { -ms-overflow-style: none; /* for Internet Explorer and Edge */ } </style>