Vue.js Athen Meetup at March 2nd, 2023
Vue.js Athen Meetup at March 2nd, 2023
Let’s take a look at an example of an "impolite popup"
A polite popup appears to visitors if they
Let’s start by writing a Vue composable for our polite popup:
export const usePolitePopup = () => { const visible = ref(false); const trigger = () => {} return { visible, trigger, }; };
export const usePolitePopup = () => { const visible = ref(false); const trigger = () => {} return { visible, trigger, }; };
The visitor must be actively scrolling the current page for 6 seconds or more.
import { useTimeoutFn } from '@vueuse/core' const config = { timeoutInMs: 6000 } as const export const usePolitePopup = () => { const visible = ref(false); const readTimeElapsed = ref(false) const { start } = useTimeoutFn( () => { readTimeElapsed.value = true }, config.timeoutInMs, { immediate: false } ) const trigger = () => { readTimeElapsed.value = false start() } return { visible, trigger, }; };
import { useTimeoutFn } from '@vueuse/core' const config = { timeoutInMs: 6000 } as const export const usePolitePopup = () => { const visible = ref(false); const readTimeElapsed = ref(false) const { start } = useTimeoutFn( () => { readTimeElapsed.value = true }, config.timeoutInMs, { immediate: false } ) const trigger = () => { readTimeElapsed.value = false start() } return { visible, trigger, }; };
The visitor must scroll through at least 35% of the current page during their visit.
import { useWindowSize, useWindowScroll } from '@vueuse/core' const config = { timeoutInMs: 6000, contentScrollThresholdInPercentage: 35, } as const export const usePolitePopup = () => { //... const { height: windowHeight } = useWindowSize() const { y: scrollTop } = useWindowScroll() // Returns percentage scrolled (ie: 80 or NaN if trackLength == 0) const amountScrolledInPercentage = computed(() => { const documentScrollHeight = document.documentElement.scrollHeight const trackLength = documentScrollHeight - windowHeight.value const scrollPercent = scrollTop.value / trackLength; const scrollPercentRounded = Math.floor(scrollPercent * 100); return scrollPercentRounded; }) const scrolledContent = computed(() => { return amountScrolledInPercentage.value >= config.contentScrollThresholdInPercentage )} return { visible, trigger, } }
import { useWindowSize, useWindowScroll } from '@vueuse/core' const config = { timeoutInMs: 6000, contentScrollThresholdInPercentage: 35, } as const export const usePolitePopup = () => { //... const { height: windowHeight } = useWindowSize() const { y: scrollTop } = useWindowScroll() // Returns percentage scrolled (ie: 80 or NaN if trackLength == 0) const amountScrolledInPercentage = computed(() => { const documentScrollHeight = document.documentElement.scrollHeight const trackLength = documentScrollHeight - windowHeight.value const scrollPercent = scrollTop.value / trackLength; const scrollPercentRounded = Math.floor(scrollPercent * 100); return scrollPercentRounded; }) const scrolledContent = computed(() => { return amountScrolledInPercentage.value >= config.contentScrollThresholdInPercentage )} return { visible, trigger, } }
We have now all information available to update the visible
reactive variable:
export const usePolitePopup = () => { const visible = ref(false) const readTimeElapsed = ref(false) //... const scrolledContent = computed(() => amountScrolledInPercentage.value >= config.contentScrollThresholdInPercentage) watch([readTimeElapsed, scrolledContent], ([newReadTimeElapsed, newScrolledContent]) => { if (newReadTimeElapsed && newScrolledContent) { visible.value = true } }) return { visible, trigger, } }
export const usePolitePopup = () => { const visible = ref(false) const readTimeElapsed = ref(false) //... const scrolledContent = computed(() => amountScrolledInPercentage.value >= config.contentScrollThresholdInPercentage) watch([readTimeElapsed, scrolledContent], ([newReadTimeElapsed, newScrolledContent]) => { if (newReadTimeElapsed && newScrolledContent) { visible.value = true } }) return { visible, trigger, } }
import { useLocalStorage } from '@vueuse/core' interface PolitePopupStorageDTO { status: 'unsubscribed' | 'subscribed' seenCount: number lastSeenAt: number } const isToday = (date: Date): boolean => { const today = new Date() return ( date.getDate() === today.getDate() && date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear() ) } export const usePolitePopup = () => { //... const storedData: Ref<PolitePopupStorageDTO> = useLocalStorage('polite-popup', { status: 'unsubscribed', seenCount: 0, lastSeenAt: 0, }) //... watch( [readTimeElapsed, scrolledContent], ([newReadTimeElapsed, newScrolledContent]) => { if (storedData.value.lastSeenAt && isToday(new Date(storedData.value.lastSeenAt))) { return } if (newReadTimeElapsed && newScrolledContent) { visible.value = true; storedData.value.lastSeenAt = new Date().getTime(); } } ); //... return { visible, trigger } }
import { useLocalStorage } from '@vueuse/core' interface PolitePopupStorageDTO { status: 'unsubscribed' | 'subscribed' seenCount: number lastSeenAt: number } const isToday = (date: Date): boolean => { const today = new Date() return ( date.getDate() === today.getDate() && date.getMonth() === today.getMonth() && date.getFullYear() === today.getFullYear() ) } export const usePolitePopup = () => { //... const storedData: Ref<PolitePopupStorageDTO> = useLocalStorage('polite-popup', { status: 'unsubscribed', seenCount: 0, lastSeenAt: 0, }) //... watch( [readTimeElapsed, scrolledContent], ([newReadTimeElapsed, newScrolledContent]) => { if (storedData.value.lastSeenAt && isToday(new Date(storedData.value.lastSeenAt))) { return } if (newReadTimeElapsed && newScrolledContent) { visible.value = true; storedData.value.lastSeenAt = new Date().getTime(); } } ); //... return { visible, trigger } }
import { useLocalStorage } from '@vueuse/core' const config = { maxSeenCount: 5, timeoutInMs: 6000, contentScrollThresholdInPercentage: 35, } as const export const usePolitePopup = () => { // ... watch( [readTimeElapsed, scrolledContent], ([newReadTimeElapsed, newScrolledContent]) => { if (storedData.value.seenCount >= config.maxSeenCount) { return } if (newReadTimeElapsed && newScrolledContent) { visible.value = true; storedData.value.seenCount += 1; storedData.value.lastSeenAt = new Date().getTime(); } } ); //... return { visible, trigger } }
import { useLocalStorage } from '@vueuse/core' const config = { maxSeenCount: 5, timeoutInMs: 6000, contentScrollThresholdInPercentage: 35, } as const export const usePolitePopup = () => { // ... watch( [readTimeElapsed, scrolledContent], ([newReadTimeElapsed, newScrolledContent]) => { if (storedData.value.seenCount >= config.maxSeenCount) { return } if (newReadTimeElapsed && newScrolledContent) { visible.value = true; storedData.value.seenCount += 1; storedData.value.lastSeenAt = new Date().getTime(); } } ); //... return { visible, trigger } }
import { useLocalStorage } from '@vueuse/core' export const usePolitePopup = () => { const setSubscribed = () => { storedData.value.status = 'subscribed' } // ... watch( [readTimeElapsed, scrolledContent], ([newReadTimeElapsed, newScrolledContent]) => { if (storedData.value.status === 'subscribed') { return } // ... if (newReadTimeElapsed && newScrolledContent) { visible.value = true; storedData.value.seenCount += 1; storedData.value.lastSeenAt = new Date().getTime(); } } ); //... return { visible, trigger, setSubscribed } }
import { useLocalStorage } from '@vueuse/core' export const usePolitePopup = () => { const setSubscribed = () => { storedData.value.status = 'subscribed' } // ... watch( [readTimeElapsed, scrolledContent], ([newReadTimeElapsed, newScrolledContent]) => { if (storedData.value.status === 'subscribed') { return } // ... if (newReadTimeElapsed && newScrolledContent) { visible.value = true; storedData.value.seenCount += 1; storedData.value.lastSeenAt = new Date().getTime(); } } ); //... return { visible, trigger, setSubscribed } }
In PolitePopupDialog.vue
we update the subscription status if the user successfully subscribed:
<script lang="ts" setup> defineProps<{ show: boolean }>() defineEmits<{ (e: 'close'): void }>() const { setSubscribed } = usePolitePopup() function onSubscribeSuccess() { setSubscribed() } </script> <template> <BaseDialog :show="show" @close="$emit('close')"> <template #title> <span>Subscribe for weekly Vue news</span> </template> <NewsletterSubscriptionForm @success="onSubscribeSuccess" /> </BaseDialog> </template>
<script lang="ts" setup> defineProps<{ show: boolean }>() defineEmits<{ (e: 'close'): void }>() const { setSubscribed } = usePolitePopup() function onSubscribeSuccess() { setSubscribed() } </script> <template> <BaseDialog :show="show" @close="$emit('close')"> <template #title> <span>Subscribe for weekly Vue news</span> </template> <NewsletterSubscriptionForm @success="onSubscribeSuccess" /> </BaseDialog> </template>
In [..slug].vue
we trigger the timer if the route path is equal to /vue
:
<template> <main> <ContentDoc /> </main> </template> <script setup lang="ts"> const route = useRoute(); const { trigger } = usePolitePopup(); if (route.path === "/vue") { trigger(); } </script>
<template> <main> <ContentDoc /> </main> </template> <script setup lang="ts"> const route = useRoute(); const { trigger } = usePolitePopup(); if (route.path === "/vue") { trigger(); } </script>