diff --git a/cypress/e2e/homeView.cy.ts b/cypress/e2e/homeView.cy.ts index 8ddc7675b8e2f8c39782ff114013650c1f9a9138..8483482bbebd08c33e298095c3766af6ef98d193 100644 --- a/cypress/e2e/homeView.cy.ts +++ b/cypress/e2e/homeView.cy.ts @@ -111,7 +111,7 @@ describe('Goals and Challenges Page Load', () => { cy.get('[data-cy=challenge-icon-1]').click(); // Assert that navigation has occurred - cy.url().should('include', '/spareutfordringer/1'); + cy.url().should('include', '/spareutfordringer/rediger/1'); }); }); diff --git a/src/assets/hjelp.png b/src/assets/hjelp.png new file mode 100644 index 0000000000000000000000000000000000000000..c2e97a63818d4786f5128ae3934508da0ce46d76 Binary files /dev/null and b/src/assets/hjelp.png differ diff --git a/src/assets/varsel.png b/src/assets/varsel.png new file mode 100644 index 0000000000000000000000000000000000000000..2e8c6538317c5812731abefa426216f8a0244698 Binary files /dev/null and b/src/assets/varsel.png differ diff --git a/src/components/InteractiveSpare.vue b/src/components/InteractiveSpare.vue index ac2d884a8198babddb40ff4256fe86d30989cfb4..e494738e8df5f7ec5c7a6a22f419b9825f71ccd8 100644 --- a/src/components/InteractiveSpare.vue +++ b/src/components/InteractiveSpare.vue @@ -1,43 +1,78 @@ <template> - <div - class="flex items-center mr-10 max-w-[60vh]" - :class="{ 'flex-row': direction === 'right', 'flex-row-reverse': direction === 'left' }" - > - <!-- Image --> - <img - :src="spareImageSrc" - :style="{ width: pngSize + 'rem', height: pngSize + 'rem' }" - :class="['object-contain', ...imageClass]" - alt="Sparemannen" - class="w-dynamic h-dynamic object-contain" - @click="nextSpeech" - /> - - <!-- Speech Bubble --> - <div - v-if="currentSpeech" - :class="`mb-40 inline-block relative w-64 bg-white p-4 rounded-3xl border border-gray-600 tri-right round ${bubbleDirection}`" - > - <div class="text-left leading-6"> - <p class="m-0">{{ currentSpeech }}</p> + <ModalComponent :is-modal-open="isModalOpen" @close="isModalOpen = false"> + <template v-slot:input> + <div + class="spareDiv flex items-center mr-10 max-w-[60vh] cursor-pointer" + :class="{ + 'flex-row': direction === 'right', + 'flex-row-reverse': direction === 'left' + }" + @click="nextSpeech" + > + <!-- Image --> + <img + :src="spareImageSrc" + :style="{ width: pngSize + 'rem', height: pngSize + 'rem' }" + :class="['object-contain', ...imageClass]" + alt="Spare" + class="w-dynamic h-dynamic object-contain" + /> + + <!-- Speech Bubble --> + <div + v-if="currentSpeech" + :class="`mb-40 inline-block relative w-64 bg-white p-4 rounded-3xl border border-gray-600 tri-right round ${bubbleDirection}`" + > + <div class="text-left leading-6"> + <p class="speech m-0">{{ currentSpeech }}</p> + </div> + </div> + </div> + <div class="-mb-5 mt-8 text-xs text-gray-500"> + <p class="justify-center items-center">Trykk for å se hva Spare har å si!</p> + <a + @click="clearSpeeches" + class="underline hover:bg-transparent font-normal text-gray-500 cursor-pointer transition-none hover:transition-none hover:p-0" + > + Skip + </a> </div> - </div> - </div> + </template> + </ModalComponent> </template> <script setup lang="ts"> -import { computed, defineProps, ref } from 'vue' +import { computed, defineProps, ref, watch } from 'vue' import spareImageSrc from '@/assets/spare.png' +import ModalComponent from '@/components/ModalComponent.vue' interface Props { - speech?: string[] // Using TypeScript's type for speech as an array of strings - direction: 'left' | 'right' // This restricts direction to either 'left' or 'right' - pngSize: number // Just declaring the type directly since it's simple + speech: string[] | null + direction: 'left' | 'right' + pngSize: number + isModalOpen: boolean } const props = defineProps<Props>() -const speech = ref<String[]>(props.speech || []) +const speech = ref<string[]>(props.speech || []) +const isModalOpen = ref(props.isModalOpen) + +// Watch the speech prop for changes +watch( + () => props.speech, + (newVal) => { + if (newVal) { + // Check if the new value is not null + speech.value = newVal // Update the reactive speech array + currentSpeechIndex.value = 0 // Reset the speech index + isModalOpen.value = true // Open the modal if new speech is available + } else { + speech.value = [] // Clear the speech array if null is received + isModalOpen.value = false // Close the modal if there's no speech + } + } +) const currentSpeechIndex = ref(0) const currentSpeech = computed(() => speech.value[currentSpeechIndex.value]) @@ -54,6 +89,8 @@ const nextSpeech = () => { } else { // If no speeches left, reset index to indicate no available speech currentSpeechIndex.value = -1 + // Close the modal if there are no speeches left + modalClosed() } } } @@ -68,6 +105,16 @@ const imageClass = computed(() => { const bubbleDirection = computed(() => { return props.direction === 'right' ? 'btm-left-in' : 'btm-right-in' }) + +const clearSpeeches = () => { + currentSpeechIndex.value = -1 + modalClosed() +} + +const modalClosed = () => { + isModalOpen.value = false + currentSpeechIndex.value = -1 +} </script> <style scoped> /* CSS talk bubble */ diff --git a/src/components/ModalComponent.vue b/src/components/ModalComponent.vue index 36b0ec9938082da477e918fa8c696b89245b2c06..f7a6c90f448566d7ab794d525d47afbe586d4666 100644 --- a/src/components/ModalComponent.vue +++ b/src/components/ModalComponent.vue @@ -1,9 +1,9 @@ <template> <div v-if="isModalOpen" - class="fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center z-50" + class="fixed inset-0 bg-black bg-opacity-30 flex justify-center items-center z-50" > - <div class="bg-white p-6 rounded-lg shadow-lg max-w-sm w-full text-center"> + <div class="bg-white p-6 rounded-lg shadow-lg max-w-lg w-full text-center"> <h2 class="title font-bold mb-4">{{ title }}</h2> <p class="message mb-4">{{ message }}</p> diff --git a/src/components/SavingsPath.vue b/src/components/SavingsPath.vue index 5e82a770cc22e2a37bab3fa8752ce7a4b0e858b1..e0eefe109987a664c2953f47ffc8b0e2583186e4 100644 --- a/src/components/SavingsPath.vue +++ b/src/components/SavingsPath.vue @@ -361,7 +361,6 @@ watch( (newGoal, oldGoal) => { if (newGoal !== oldGoal) { goal.value = newGoal - console.log('Updated goal:', goal.value) } }, { immediate: true } @@ -373,7 +372,6 @@ watch( if (newChallenges !== oldChallenges) { challenges.value = newChallenges sortChallenges() - console.log('Updated challenges:', challenges.value) } }, { immediate: true } @@ -404,8 +402,6 @@ const incrementSaved = async (challenge: Challenge) => { await challengeStore.completeUserChallenge(challenge) } - console.log('Incrementing saved amount for:', challenge) - // Safely update the goal's saved value, ensuring goal.value exists and is not null if (goal.value) { goal.value.saved = (goal.value.saved || 0) + challenge.perPurchase @@ -432,11 +428,11 @@ const recalculateAndAnimate = () => { } const editChallenge = (challenge: Challenge) => { - router.push(`/spareutfordringer/${challenge.id}`) + router.push(`/spareutfordringer/rediger/${challenge.id}`) } const editGoal = (goal: Goal) => { - router.push(`/sparemaal/${goal.id}`) + router.push(`/sparemaal/rediger/${goal.id}`) } // Declare the ref with a type annotation for an array of strings @@ -589,9 +585,8 @@ const getPigStepsIcon = () => { return 'src/assets/pigSteps.png' } -// TODO - Change when EditGoal view is created const goToEditGoal = () => { - router.push({ name: 'EditGoal' }) + router.push({ name: 'edit-goal', params: { id: goal.value?.id } }) } </script> diff --git a/src/components/__tests__/InteractiveSpareTest.spec.ts b/src/components/__tests__/InteractiveSpareTest.spec.ts index 47ae6b194a393cae1b57360e631fe1b6e3ccc17a..834c86ded8cd3fc66732ba222bb0750563484b2f 100644 --- a/src/components/__tests__/InteractiveSpareTest.spec.ts +++ b/src/components/__tests__/InteractiveSpareTest.spec.ts @@ -8,7 +8,8 @@ describe('SpeechBubbleComponent', () => { props: { direction: 'left', speech: ['Hello', 'World'], - pngSize: 100 + pngSize: 100, + isModalOpen: true } }) expect(wrapper.exists()).toBeTruthy() @@ -19,18 +20,20 @@ describe('SpeechBubbleComponent', () => { props: { direction: 'right', speech: ['Hello', 'World'], - pngSize: 100 + pngSize: 100, + isModalOpen: true } }) - expect(wrapper.find('div').classes()).toContain('flex-row') + expect(wrapper.find('.spareDiv').classes()).toContain('flex-row') const wrapperReverse = mount(SpeechBubbleComponent, { props: { direction: 'left', speech: ['Hello', 'World'], - pngSize: 100 + pngSize: 100, + isModalOpen: true } }) - expect(wrapperReverse.find('div').classes()).toContain('flex-row-reverse') + expect(wrapperReverse.find('.spareDiv').classes()).toContain('flex-row-reverse') }) it('image class is computed based on direction', () => { @@ -38,22 +41,24 @@ describe('SpeechBubbleComponent', () => { props: { direction: 'right', speech: ['Hello', 'World'], - pngSize: 100 + pngSize: 100, + isModalOpen: true } }) expect(wrapper.find('img').classes()).toContain('scale-x-[-1]') }) - it('updates speech text on image click', async () => { + it('updates speech text on div click', async () => { const wrapper = mount(SpeechBubbleComponent, { props: { direction: 'left', speech: ['First speech', 'Second speech'], - pngSize: 100 + pngSize: 100, + isModalOpen: true } }) - expect(wrapper.find('p').text()).toBe('First speech') - await wrapper.find('img').trigger('click') - expect(wrapper.find('p').text()).toBe('Second speech') + expect(wrapper.find('.speech').text()).toBe('First speech') + await wrapper.find('.spareDiv').trigger('click') + expect(wrapper.find('.speech').text()).toBe('Second speech') }) }) diff --git a/src/views/ConfigAccountNumberView.vue b/src/views/ConfigAccountNumberView.vue index 652cf0d11ba68e92f47d8b6e14171faa565cf321..0537138ef5da7fcda18c58521fbde24009064bd9 100644 --- a/src/views/ConfigAccountNumberView.vue +++ b/src/views/ConfigAccountNumberView.vue @@ -72,7 +72,7 @@ async function onButtonClick() { await accountStore.postAccount('SPENDING', spendingAccountNumber, 0) - await router.push({ name: 'home' }) + await router.push({ name: 'home', query: { firstLogin: 'true' } }) } function restrictToNumbers(event: InputEvent, type: string) { diff --git a/src/views/EditProfileView.vue b/src/views/EditProfileView.vue index 82da35481c774c789baffd3264b4a36cbbe45614..bb251d91ff657f3f5613f2e5caf2a168ad65e888 100644 --- a/src/views/EditProfileView.vue +++ b/src/views/EditProfileView.vue @@ -27,6 +27,7 @@ const profile = ref<Profile>({ const updatePassword = ref<boolean>(false) const confirmPassword = ref<string>('') const errorMessage = ref<string>('') +const isModalOpen = ref(false) const nameRegex = /^[æÆøØåÅa-zA-Z,.'-][æÆøØåÅa-zA-Z ,.'-]{1,29}$/ const emailRegex = @@ -210,6 +211,7 @@ const saveChanges = async () => { :png-size="10" :speech="['Her kan du endre på profilen din!']" direction="left" + :isModalOpen="isModalOpen" /> <CardTemplate> diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index f596bcaee5dac3d20b57091ba4c959c50580dc9e..fcdf62e7219bca4311b288fb439e6d10fd33d695 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -1,12 +1,21 @@ <template> <div class="flex flex-col items-center max-h-[60vh] md:flex-row md:max-h-[80vh] mx-auto"> - <div class="flex flex-col basis-1/3 order-last md:order-first md:basis-1/4 md:pl-10"> - <InteractiveSpare - :speech="speech" - :direction="'right'" - :pngSize="15" - class="opacity-0 h-0 w-0 md:opacity-100 md:h-auto md:w-auto md:mx-auto md:my-20" - ></InteractiveSpare> + <div class="flex flex-col basis-1/3 order-last md:order-first md:basis-1/4 md:pl-1 mt-10"> + <img + v-if="newSpeechAvailable" + alt="Varsel" + class="jump scale-x-[-1] w-1/12 h-1/12 ml-52 cursor-pointer z-10" + src="@/assets/varsel.png" + /> + <div class="flex items-center"> + <a @click="openInteractiveSpare" class="hover:bg-transparent z-20"> + <img + alt="Spare" + class="scale-x-[-1] md:h-5/6 md:w-5/6 w-2/3 h-2/3 cursor-pointer ml-14 md:ml-10" + src="@/assets/spare.png" + /> + </a> + </div> <div class="flex flex-row gap-2 items-center mx-auto my-4 md:flex-col md:gap-4 md:m-8"> <ButtonAddGoalOrChallenge :buttonText="'Legg til sparemål'" :type="'goal'" /> <ButtonAddGoalOrChallenge @@ -17,6 +26,18 @@ </div> <savings-path :challenges="challenges" :goal="goal"></savings-path> </div> + <InteractiveSpare + :speech="speech" + :direction="'right'" + :pngSize="15" + :isModalOpen="isModalOpen" + class="opacity-0 h-0 w-0 md:opacity-100 md:h-auto md:w-auto" + ></InteractiveSpare> + <div class="fixed bottom-5 left-5"> + <div @click="openHelp" class="hover:cursor-pointer"> + <img alt="Hjelp" class="w-1/12" src="@/assets/hjelp.png" /> + </div> + </div> </template> <script setup lang="ts"> @@ -28,9 +49,13 @@ import type { Goal } from '@/types/goal' import { useGoalStore } from '@/stores/goalStore' import { useChallengeStore } from '@/stores/challengeStore' import SavingsPath from '@/components/SavingsPath.vue' +import router from '@/router' const goalStore = useGoalStore() const challengeStore = useChallengeStore() +const isModalOpen = ref(false) +const speech = ref<string[]>([]) +const newSpeechAvailable = ref(false) const challenges = ref<Challenge[]>([]) const goals = ref<Goal[]>([]) @@ -43,15 +68,53 @@ onMounted(async () => { challenges.value = challengeStore.challenges goals.value = goalStore.goals goal.value = goals.value[0] - console.log('Goals:', goals.value) + firstLoggedInSpeech() }) -// Define your speech array -const speechArray = [ - 'Hei! Jeg er Sparemannen.', - 'Jeg hjelper deg med å spare penger.', - 'Klikk på meg for å høre mer.' -] +// Check if the user is logging in for the first time, and display the first login speech +const firstLoggedInSpeech = () => { + const isFirstLogin = router.currentRoute.value.query.firstLogin === 'true' + if (isFirstLogin) { + speech.value = [ + 'Hei, jeg er Spare!', + 'Jeg skal hjelpe deg med å spare penger.', + 'Du får varsel når jeg har noe å si!' + ] + isModalOpen.value = true + router.replace({ name: 'home', query: { firstLogin: 'false' } }) + } +} -const speech = ref(speechArray) +const openInteractiveSpare = () => { + // Check if there's new speech available before opening the modal. + if (newSpeechAvailable.value) { + isModalOpen.value = true // Open the modal + newSpeechAvailable.value = false // Reset the flag since the speech will now be displayed + } +} +const openHelp = () => { + speech.value = [ + 'Heisann, jeg er Spare!', + 'Jeg skal hjelpe deg med å spare penger.', + 'Du kan legge til sparemål og spareutfordringer!', + 'Sammen kan vi spare penger og nå dine mål!' + ] + isModalOpen.value = true +} </script> + +<style> +@keyframes jump { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-10px); + } +} + +.jump { + animation: jump 0.6s infinite ease-in-out; +} +</style> diff --git a/src/views/ManageGoalView.vue b/src/views/ManageGoalView.vue index 89111da2adca167258778dfe594d564b73693427..a3a246c4497701c183f38a3cf9b953a461b895c2 100644 --- a/src/views/ManageGoalView.vue +++ b/src/views/ManageGoalView.vue @@ -99,7 +99,7 @@ const updateGoal = () => { authInterceptor .put(`/goals/${goalInstance.value.id}`, goalInstance.value) .then(() => { - router.push({ name: 'goals' }) + router.back() }) .catch((error) => { console.error(error) diff --git a/src/views/ProfileView.vue b/src/views/ProfileView.vue index 436e522c7c815ad6cb5725724b76a8f76779d75d..19fa96330c311c4113831e724fb271284250308a 100644 --- a/src/views/ProfileView.vue +++ b/src/views/ProfileView.vue @@ -12,6 +12,7 @@ import router from '@/router' const profile = ref<Profile>() const completedGoals = ref<Goal[]>([]) const completedChallenges = ref<Challenge[]>([]) +const isModalOpen = ref(false) onMounted(async () => { await authInterceptor('/profile') @@ -43,6 +44,10 @@ onMounted(async () => { const welcome = computed(() => { return [`Velkommen, ${profile.value?.firstName} ${profile.value?.lastName} !`] }) + +const openInteractiveSpare = () => { + isModalOpen.value = true +} </script> <template> @@ -87,7 +92,21 @@ const welcome = computed(() => { </div> <div class="flex flex-col"> - <InteractiveSpare :png-size="10" :speech="welcome" direction="left" /> + <InteractiveSpare + :png-size="10" + :speech="welcome" + direction="left" + :isModalOpen="isModalOpen" + /> + <div class="flex items-center"> + <a @click="openInteractiveSpare" class="hover:bg-transparent z-20"> + <img + alt="Spare" + class="scale-x-[-1] md:h-5/6 md:w-5/6 w-2/3 h-2/3 cursor-pointer ml-14 md:ml-10" + src="@/assets/spare.png" + /> + </a> + </div> <div class="flex flex-row justify-between mx-4"> <p class="font-bold">Fullførte sparemål</p> <a class="hover:p-0 cursor-pointer" v-text="'Se alle'" /> diff --git a/src/views/ViewChallengeView.vue b/src/views/ViewChallengeView.vue index f7bf05e69a55a93bbb37f10f56a5b9a6f9f7370a..71b2a67e67c73f158626efb25d67a80bb0e78edc 100644 --- a/src/views/ViewChallengeView.vue +++ b/src/views/ViewChallengeView.vue @@ -29,6 +29,8 @@ const isCompleted = computed(() => challengeInstance.value.completedOn != null) const motivation = ref<string[]>([]) +const isModalOpen = ref(false) + const calculateSpeech = () => { if (completion.value === 0) { return motivation.value.push( @@ -140,7 +142,12 @@ const completeChallenge = () => { </button> </div> </div> - <InteractiveSpare :png-size="10" :speech="motivation" direction="left" /> + <InteractiveSpare + :png-size="10" + :speech="motivation" + direction="left" + :isModalOpen="isModalOpen" + /> </div> </template> diff --git a/src/views/ViewGoalView.vue b/src/views/ViewGoalView.vue index a2e9b176009b30935987283ef7482ae1560eec34..50ec73bb87ee4c101808f113f552a58e90ca94db 100644 --- a/src/views/ViewGoalView.vue +++ b/src/views/ViewGoalView.vue @@ -20,6 +20,11 @@ const completion = computed(() => (goalInstance.value.saved / goalInstance.value const isCompleted = computed(() => goalInstance.value.completedOn != null) const motivation = ref<string[]>([]) +const isModalOpen = ref(false) + +const openInteractiveSpare = () => { + isModalOpen.value = true +} const calculateSpeech = () => { if (completion.value === 0) { @@ -126,7 +131,21 @@ const completeGoal = () => { </button> </div> </div> - <InteractiveSpare :png-size="10" :speech="motivation" direction="left" /> + <div class="flex items-center"> + <a @click="openInteractiveSpare" class="hover:bg-transparent z-20"> + <img + alt="Spare" + class="scale-x-[-1] md:h-5/6 md:w-5/6 w-2/3 h-2/3 cursor-pointer ml-14 md:ml-10" + src="@/assets/spare.png" + /> + </a> + </div> + <InteractiveSpare + :png-size="10" + :speech="motivation" + direction="left" + :isModalOpen="isModalOpen" + /> </div> </template>