From 22fbf87d5840031a7faca7e103f62e7050acd6d6 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Thu, 21 Nov 2024 21:19:30 +0000 Subject: [PATCH 1/8] Onboarding persistence backend updates --- .../0002_user_is_onboarding_complete.py | 27 +++++++++++++++++++ users/models.py | 3 +++ users/serializers.py | 2 ++ 3 files changed, 32 insertions(+) create mode 100644 users/migrations/0002_user_is_onboarding_complete.py diff --git a/users/migrations/0002_user_is_onboarding_complete.py b/users/migrations/0002_user_is_onboarding_complete.py new file mode 100644 index 0000000000..b2d1d86f5e --- /dev/null +++ b/users/migrations/0002_user_is_onboarding_complete.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.1 on 2024-11-21 18:42 + +from django.db import migrations, models + + +def migrate_is_onboarding_complete(apps, schema_editor): + """ + We assume that all existing users with an activated email have completed onboarding. + """ + + User = apps.get_model("users", "User") + User.objects.filter(is_active=True).update(is_onboarding_complete=True) + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="is_onboarding_complete", + field=models.BooleanField(default=False), + ), + migrations.RunPython(migrate_is_onboarding_complete, migrations.RunPython.noop), + ] diff --git a/users/models.py b/users/models.py index 57294efb28..b49808ec4a 100644 --- a/users/models.py +++ b/users/models.py @@ -56,6 +56,9 @@ class User(TimeStampedModel, AbstractUser): ) hide_community_prediction = models.BooleanField(default=False) + # Onboarding + is_onboarding_complete = models.BooleanField(default=False) + objects: models.Manager["User"] = UserManager() class Meta: diff --git a/users/serializers.py b/users/serializers.py index b191b7f6ea..02d31f6739 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -67,6 +67,7 @@ class Meta: "is_staff", "unsubscribed_mailing_tags", "hide_community_prediction", + "is_onboarding_complete", ) @@ -93,6 +94,7 @@ class Meta: "profile_picture", "unsubscribed_mailing_tags", "hide_community_prediction", + "is_onboarding_complete", ) From a17185fb7dd5773254d341c1ad0273b58f7a9950 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Thu, 21 Nov 2024 21:21:12 +0000 Subject: [PATCH 2/8] Frontend changes --- .../(home)/components/email_confirmation.tsx | 1 - .../app/(main)/accounts/profile/actions.tsx | 1 + .../src/app/(main)/components/mobile_menu.tsx | 6 +- front_end/src/app/(main)/questions/page.tsx | 2 + front_end/src/components/auth/index.tsx | 8 +-- .../components/onboarding/OnboardingCheck.tsx | 56 ++++++++++--------- .../components/onboarding/OnboardingModal.tsx | 10 ++++ front_end/src/services/profile.ts | 1 + front_end/src/types/users.ts | 1 + 9 files changed, 54 insertions(+), 32 deletions(-) diff --git a/front_end/src/app/(main)/(home)/components/email_confirmation.tsx b/front_end/src/app/(main)/(home)/components/email_confirmation.tsx index 379f7903ac..a9ecc9c587 100644 --- a/front_end/src/app/(main)/(home)/components/email_confirmation.tsx +++ b/front_end/src/app/(main)/(home)/components/email_confirmation.tsx @@ -10,7 +10,6 @@ const EmailConfirmation = () => { useEffect(() => { if (searchParams.get("event") === "emailConfirmed") { sendGAEvent("event", "emailConfirmed"); - router.replace("/?start_onboarding=true"); } }, [router, searchParams]); diff --git a/front_end/src/app/(main)/accounts/profile/actions.tsx b/front_end/src/app/(main)/accounts/profile/actions.tsx index 767ed4f8ab..be4eacddac 100644 --- a/front_end/src/app/(main)/accounts/profile/actions.tsx +++ b/front_end/src/app/(main)/accounts/profile/actions.tsx @@ -99,6 +99,7 @@ export async function updateProfileAction( | "unsubscribed_mailing_tags" | "unsubscribed_preferences_tags" | "hide_community_prediction" + | "is_onboarding_complete" > > ) { diff --git a/front_end/src/app/(main)/components/mobile_menu.tsx b/front_end/src/app/(main)/components/mobile_menu.tsx index 31f3bfaeec..96587203fa 100644 --- a/front_end/src/app/(main)/components/mobile_menu.tsx +++ b/front_end/src/app/(main)/components/mobile_menu.tsx @@ -115,7 +115,9 @@ const MobileMenu: FC = ({ community, onClick }) => { {t("settings")} - + setCurrentModal({ type: "onboarding" })} + > {t("tutorial")} {user.is_superuser && ( @@ -195,7 +197,7 @@ const MobileMenu: FC = ({ community, onClick }) => { {t("profile")} {t("settings")} - + setCurrentModal({ type: "onboarding" })}> {t("tutorial")} {user.is_superuser && ( diff --git a/front_end/src/app/(main)/questions/page.tsx b/front_end/src/app/(main)/questions/page.tsx index 18710fc7b9..c6f718ce71 100644 --- a/front_end/src/app/(main)/questions/page.tsx +++ b/front_end/src/app/(main)/questions/page.tsx @@ -1,6 +1,7 @@ import { Suspense } from "react"; import AwaitedCommunitiesFeed from "@/components/communities_feed"; +import OnboardingCheck from "@/components/onboarding/OnboardingCheck"; import AwaitedPostsFeed from "@/components/posts_feed"; import LoadingIndicator from "@/components/ui/loading_indicator"; import { POST_COMMUNITIES_FILTER } from "@/constants/posts_feed"; @@ -34,6 +35,7 @@ export default async function Questions({ return ( <>
+
diff --git a/front_end/src/components/auth/index.tsx b/front_end/src/components/auth/index.tsx index afcf6af3b4..fce3a9e276 100644 --- a/front_end/src/components/auth/index.tsx +++ b/front_end/src/components/auth/index.tsx @@ -64,12 +64,12 @@ const NavUserButton: FC = ({ btnClassName }) => { - setCurrentModal({ type: "onboarding" })} > {t("tutorial")} - + {user.is_superuser && ( diff --git a/front_end/src/components/onboarding/OnboardingCheck.tsx b/front_end/src/components/onboarding/OnboardingCheck.tsx index eb91913479..4e87077872 100644 --- a/front_end/src/components/onboarding/OnboardingCheck.tsx +++ b/front_end/src/components/onboarding/OnboardingCheck.tsx @@ -1,39 +1,45 @@ "use client"; -import { useSearchParams, useRouter } from "next/navigation"; -import { useEffect, useCallback } from "react"; +import { usePathname } from "next/navigation"; +import { useEffect } from "react"; import { useAuth } from "@/contexts/auth_context"; import { useModal } from "@/contexts/modal_context"; +import { useNavigation } from "@/contexts/navigation_context"; const OnboardingCheck: React.FC = () => { - const searchParams = useSearchParams(); - const router = useRouter(); const { setCurrentModal } = useModal(); const { user } = useAuth(); - - const handleOnboarding = useCallback(() => { - const startOnboarding = searchParams.get("start_onboarding"); - if (startOnboarding === "true") { - // Remove the query parameter - const newUrl = new URL(window.location.href); - newUrl.searchParams.delete("start_onboarding"); - router.replace(newUrl.toString()); - - // Check if the user is logged in - if (user) { - // Start the onboarding process - setCurrentModal({ type: "onboarding" }); - } else { - // Show the registration modal - setCurrentModal({ type: "signup" }); - } - } - }, [searchParams, router, user, setCurrentModal]); + const { previousPath, currentPath } = useNavigation(); + const pathname = usePathname(); + + // We want to avoid situations where a user skips the tutorial + // on the homepage, navigates directly to the questions feed, + // and then sees the tutorial pop up again (or vice versa). + // To handle this, we simply check that the previous page + // wasn’t the home or questions page. + const previousPathHasTutorial = + previousPath && + ["/", "/questions/"].includes( + new URL(previousPath, process.env.APP_URL).pathname + ); useEffect(() => { - handleOnboarding(); - }, [handleOnboarding]); + // Checks if the hook has already been refreshed. + // Sometimes, it takes a moment for useNavigation to update from the previous route's values, + // so we need to perform this check to ensure we have updated values of previousPath + const hookUpdated = currentPath === pathname; + + if ( + hookUpdated && + user?.id && + !user?.is_onboarding_complete && + !previousPathHasTutorial + ) { + // Start the onboarding process + setCurrentModal({ type: "onboarding" }); + } + }, [currentPath]); return null; // This component doesn't render anything }; diff --git a/front_end/src/components/onboarding/OnboardingModal.tsx b/front_end/src/components/onboarding/OnboardingModal.tsx index b7a979f84e..aa16157ab7 100644 --- a/front_end/src/components/onboarding/OnboardingModal.tsx +++ b/front_end/src/components/onboarding/OnboardingModal.tsx @@ -1,9 +1,12 @@ import { useRouter } from "next/navigation"; import React, { useState, useEffect, useRef } from "react"; +import { updateProfileAction } from "@/app/(main)/accounts/profile/actions"; import { getPost } from "@/app/(main)/questions/actions"; import BaseModal from "@/components/base_modal"; +import { useAuth } from "@/contexts/auth_context"; import { PostWithForecasts } from "@/types/post"; +import { logError } from "@/utils/errors"; import { onboardingTopics } from "./OnboardingSettings"; import Step1 from "./steps/Step1"; @@ -16,6 +19,7 @@ const OnboardingModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose, }) => { + const { user } = useAuth(); const [currentStep, setCurrentStep] = useState(1); const [selectedTopic, setSelectedTopic] = useState(null); const [questionData, setQuestionData] = useState( @@ -59,6 +63,12 @@ const OnboardingModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ }, [selectedTopic, currentStep]); const handleNext = () => { + // Treat tutorial as done when user opens 4th page + if (currentStep == 4 && !user?.is_onboarding_complete) { + // Mark tutorial as complete + updateProfileAction({ is_onboarding_complete: true }).catch(logError); + } + if (currentStep < 5) { setCurrentStep(currentStep + 1); scrollToTop(); diff --git a/front_end/src/services/profile.ts b/front_end/src/services/profile.ts index f4973c3ea0..2f54f07714 100644 --- a/front_end/src/services/profile.ts +++ b/front_end/src/services/profile.ts @@ -35,6 +35,7 @@ class ProfileApi { website?: string; unsubscribed_mailing_tags?: SubscriptionEmailType[]; unsubscribed_preference_tags?: ProfilePreferencesType[]; + is_onboarding_complete?: boolean; }) { return patch("/users/me/update/", props); } diff --git a/front_end/src/types/users.ts b/front_end/src/types/users.ts index 607b1cfd9a..918f1c1368 100644 --- a/front_end/src/types/users.ts +++ b/front_end/src/types/users.ts @@ -54,6 +54,7 @@ export type CurrentUser = User & { unsubscribed_mailing_tags: SubscriptionEmailType[]; unsubscribed_preferences_tags: ProfilePreferencesType[]; hide_community_prediction: boolean; + is_onboarding_complete: boolean; }; export enum ProfilePageMode { From 3fda5fc3f80e5ac51f01b947dc1e136fdb9b5829 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 22 Nov 2024 11:44:22 +0000 Subject: [PATCH 3/8] Small fix --- front_end/src/components/onboarding/OnboardingCheck.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/front_end/src/components/onboarding/OnboardingCheck.tsx b/front_end/src/components/onboarding/OnboardingCheck.tsx index 4e87077872..e184b1e8a4 100644 --- a/front_end/src/components/onboarding/OnboardingCheck.tsx +++ b/front_end/src/components/onboarding/OnboardingCheck.tsx @@ -39,7 +39,7 @@ const OnboardingCheck: React.FC = () => { // Start the onboarding process setCurrentModal({ type: "onboarding" }); } - }, [currentPath]); + }, [user?.id, currentPath]); return null; // This component doesn't render anything }; From 12b8fd2b105f1eecce59cc2e7d452853bfb46cd3 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 22 Nov 2024 12:56:23 +0000 Subject: [PATCH 4/8] Adjusted persistence --- .../components/onboarding/OnboardingCheck.tsx | 29 ++----------------- .../src/components/onboarding/steps/Step1.tsx | 5 ++++ 2 files changed, 7 insertions(+), 27 deletions(-) diff --git a/front_end/src/components/onboarding/OnboardingCheck.tsx b/front_end/src/components/onboarding/OnboardingCheck.tsx index e184b1e8a4..e8889ba376 100644 --- a/front_end/src/components/onboarding/OnboardingCheck.tsx +++ b/front_end/src/components/onboarding/OnboardingCheck.tsx @@ -1,45 +1,20 @@ "use client"; -import { usePathname } from "next/navigation"; import { useEffect } from "react"; import { useAuth } from "@/contexts/auth_context"; import { useModal } from "@/contexts/modal_context"; -import { useNavigation } from "@/contexts/navigation_context"; const OnboardingCheck: React.FC = () => { const { setCurrentModal } = useModal(); const { user } = useAuth(); - const { previousPath, currentPath } = useNavigation(); - const pathname = usePathname(); - - // We want to avoid situations where a user skips the tutorial - // on the homepage, navigates directly to the questions feed, - // and then sees the tutorial pop up again (or vice versa). - // To handle this, we simply check that the previous page - // wasn’t the home or questions page. - const previousPathHasTutorial = - previousPath && - ["/", "/questions/"].includes( - new URL(previousPath, process.env.APP_URL).pathname - ); useEffect(() => { - // Checks if the hook has already been refreshed. - // Sometimes, it takes a moment for useNavigation to update from the previous route's values, - // so we need to perform this check to ensure we have updated values of previousPath - const hookUpdated = currentPath === pathname; - - if ( - hookUpdated && - user?.id && - !user?.is_onboarding_complete && - !previousPathHasTutorial - ) { + if (user?.id && !user?.is_onboarding_complete) { // Start the onboarding process setCurrentModal({ type: "onboarding" }); } - }, [user?.id, currentPath]); + }, [user?.id]); return null; // This component doesn't render anything }; diff --git a/front_end/src/components/onboarding/steps/Step1.tsx b/front_end/src/components/onboarding/steps/Step1.tsx index 393d2a7787..1c7c47f726 100644 --- a/front_end/src/components/onboarding/steps/Step1.tsx +++ b/front_end/src/components/onboarding/steps/Step1.tsx @@ -2,6 +2,9 @@ import { sendGAEvent } from "@next/third-parties/google"; import { useTranslations } from "next-intl"; import React, { useEffect } from "react"; +import { updateProfileAction } from "@/app/(main)/accounts/profile/actions"; +import { logError } from "@/utils/errors"; + import { onboardingTopics } from "../OnboardingSettings"; import { onboardingStyles } from "../OnboardingStyles"; @@ -22,6 +25,8 @@ const Step1: React.FC = ({ onTopicSelect, onClose }) => { const handleSkipTutorial = () => { sendGAEvent({ event: "onboardingSkipped", event_category: "onboarding" }); + // Mark tutorial as complete + updateProfileAction({ is_onboarding_complete: true }).catch(logError); onClose(); }; From f9ae8f00d135af142cb40f3cf6a0b3b582438355 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 22 Nov 2024 14:32:34 +0000 Subject: [PATCH 5/8] Remember onboarding state --- .../components/onboarding/OnboardingModal.tsx | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/front_end/src/components/onboarding/OnboardingModal.tsx b/front_end/src/components/onboarding/OnboardingModal.tsx index aa16157ab7..e7b95de842 100644 --- a/front_end/src/components/onboarding/OnboardingModal.tsx +++ b/front_end/src/components/onboarding/OnboardingModal.tsx @@ -1,5 +1,5 @@ import { useRouter } from "next/navigation"; -import React, { useState, useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import { updateProfileAction } from "@/app/(main)/accounts/profile/actions"; import { getPost } from "@/app/(main)/questions/actions"; @@ -15,13 +15,27 @@ import Step3 from "./steps/Step3"; import Step4 from "./steps/Step4"; import Step5 from "./steps/Step5"; +type OnboardingLocalStorage = { + selectedTopic?: number; + currentStep?: number | null; +}; +const ONBOARDING_KEY = "onboardingState"; +const getLocalStorageOnboardingData = (): OnboardingLocalStorage => { + const storedValue = localStorage.getItem(ONBOARDING_KEY); + return storedValue !== null ? JSON.parse(storedValue) : {}; +}; + const OnboardingModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose, }) => { const { user } = useAuth(); - const [currentStep, setCurrentStep] = useState(1); - const [selectedTopic, setSelectedTopic] = useState(null); + const [currentStep, setCurrentStep] = useState( + () => getLocalStorageOnboardingData().currentStep || 1 + ); + const [selectedTopic, setSelectedTopic] = useState( + getLocalStorageOnboardingData().selectedTopic || null + ); const [questionData, setQuestionData] = useState( null ); @@ -38,6 +52,20 @@ const OnboardingModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ } }; + // Save state into local storage + useEffect(() => { + if (!user?.is_onboarding_complete) { + if (currentStep > 1 && currentStep < 5) { + localStorage.setItem( + ONBOARDING_KEY, + JSON.stringify({ selectedTopic, currentStep }) + ); + } else { + localStorage.removeItem(ONBOARDING_KEY); + } + } + }, [user?.is_onboarding_complete, selectedTopic, currentStep]); + useEffect(() => { async function fetchQuestionData() { if ( From 4abf9d21655207f682b90365d436b450aaf06307 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 22 Nov 2024 14:34:59 +0000 Subject: [PATCH 6/8] Revert "Adjusted persistence" This reverts commit 12b8fd2b105f1eecce59cc2e7d452853bfb46cd3. --- .../components/onboarding/OnboardingCheck.tsx | 29 +++++++++++++++++-- .../src/components/onboarding/steps/Step1.tsx | 5 ---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/front_end/src/components/onboarding/OnboardingCheck.tsx b/front_end/src/components/onboarding/OnboardingCheck.tsx index e8889ba376..e184b1e8a4 100644 --- a/front_end/src/components/onboarding/OnboardingCheck.tsx +++ b/front_end/src/components/onboarding/OnboardingCheck.tsx @@ -1,20 +1,45 @@ "use client"; +import { usePathname } from "next/navigation"; import { useEffect } from "react"; import { useAuth } from "@/contexts/auth_context"; import { useModal } from "@/contexts/modal_context"; +import { useNavigation } from "@/contexts/navigation_context"; const OnboardingCheck: React.FC = () => { const { setCurrentModal } = useModal(); const { user } = useAuth(); + const { previousPath, currentPath } = useNavigation(); + const pathname = usePathname(); + + // We want to avoid situations where a user skips the tutorial + // on the homepage, navigates directly to the questions feed, + // and then sees the tutorial pop up again (or vice versa). + // To handle this, we simply check that the previous page + // wasn’t the home or questions page. + const previousPathHasTutorial = + previousPath && + ["/", "/questions/"].includes( + new URL(previousPath, process.env.APP_URL).pathname + ); useEffect(() => { - if (user?.id && !user?.is_onboarding_complete) { + // Checks if the hook has already been refreshed. + // Sometimes, it takes a moment for useNavigation to update from the previous route's values, + // so we need to perform this check to ensure we have updated values of previousPath + const hookUpdated = currentPath === pathname; + + if ( + hookUpdated && + user?.id && + !user?.is_onboarding_complete && + !previousPathHasTutorial + ) { // Start the onboarding process setCurrentModal({ type: "onboarding" }); } - }, [user?.id]); + }, [user?.id, currentPath]); return null; // This component doesn't render anything }; diff --git a/front_end/src/components/onboarding/steps/Step1.tsx b/front_end/src/components/onboarding/steps/Step1.tsx index 1c7c47f726..393d2a7787 100644 --- a/front_end/src/components/onboarding/steps/Step1.tsx +++ b/front_end/src/components/onboarding/steps/Step1.tsx @@ -2,9 +2,6 @@ import { sendGAEvent } from "@next/third-parties/google"; import { useTranslations } from "next-intl"; import React, { useEffect } from "react"; -import { updateProfileAction } from "@/app/(main)/accounts/profile/actions"; -import { logError } from "@/utils/errors"; - import { onboardingTopics } from "../OnboardingSettings"; import { onboardingStyles } from "../OnboardingStyles"; @@ -25,8 +22,6 @@ const Step1: React.FC = ({ onTopicSelect, onClose }) => { const handleSkipTutorial = () => { sendGAEvent({ event: "onboardingSkipped", event_category: "onboarding" }); - // Mark tutorial as complete - updateProfileAction({ is_onboarding_complete: true }).catch(logError); onClose(); }; From 9754dfd4bb77e0a3fcc6d7bdbea9b4cfe61dcd7b Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 22 Nov 2024 15:15:40 +0000 Subject: [PATCH 7/8] Remember onboarding state --- front_end/messages/cs.json | 2 ++ front_end/messages/en.json | 2 ++ front_end/messages/es.json | 2 ++ front_end/messages/pt.json | 2 ++ front_end/messages/zh.json | 2 ++ .../components/onboarding/OnboardingCheck.tsx | 6 ++--- .../components/onboarding/OnboardingModal.tsx | 11 +++++++--- .../src/components/onboarding/steps/Step1.tsx | 22 ++++++++++++++++++- front_end/src/contexts/navigation_context.tsx | 9 +++++++- 9 files changed, 50 insertions(+), 8 deletions(-) diff --git a/front_end/messages/cs.json b/front_end/messages/cs.json index 9cd73fe4d7..343a310ee4 100644 --- a/front_end/messages/cs.json +++ b/front_end/messages/cs.json @@ -762,6 +762,8 @@ "nuclearSecurityDescription": "Kvantifikace globálních rizik pro zajištění naší bezpečnosti a ochrany pro prosperující budoucnost", "climateChange": "změna klimatu", "climateChangeDescription": "Předpovídání dlouhodobých změn v teplotních a povětrnostních vzorcích způsobených lidskou činností", + "remindMeLater": "Připomeňte mi později", + "onboardingRemindMeLaterDescription": "(Návod můžete vždy najít později v nabídce v horní části každé stránky)", "onboardingStep1Question1": "Mám si vzít deštník?", "onboardingStep1Question2": "Směřujeme k recesi?", "onboardingStep1Question3": "Zvítězí můj tým?", diff --git a/front_end/messages/en.json b/front_end/messages/en.json index ff067c9fbd..353b40dda0 100644 --- a/front_end/messages/en.json +++ b/front_end/messages/en.json @@ -901,6 +901,8 @@ "onboardingStep1Paragraph2": "Let's make a few quick predictions to get you started.", "onboardingStep1Paragraph3": "First, pick a topic you care about.", "skipTutorial": "Skip Tutorial", + "remindMeLater": "Remind me later", + "onboardingRemindMeLaterDescription": "(You can always find the tutorial later in the menu at the top of every page)", "skipQuestions": "Skip question", "onboardingStep2Title": "Here's a real Metaculus question about {topicName}:", "onboardingStep2CommunityThinks": "Other forecasters tend to think this is", diff --git a/front_end/messages/es.json b/front_end/messages/es.json index 4728cc05fd..4ab115fbb2 100644 --- a/front_end/messages/es.json +++ b/front_end/messages/es.json @@ -775,6 +775,8 @@ "nuclearSecurityDescription": "Cuantificar los riesgos globales para mantenernos seguros y protegidos para un futuro próspero", "climateChange": "cambio climático", "climateChangeDescription": "Predecir los cambios a largo plazo en los patrones de temperatura y clima causados por la actividad humana", + "remindMeLater": "Recuérdamelo más tarde", + "onboardingRemindMeLaterDescription": "(Siempre puedes encontrar el tutorial más tarde en el menú en la parte superior de cada página)", "onboardingStep1Question1": "¿Debería llevar un paraguas?", "onboardingStep1Question2": "¿Nos dirigimos a una recesión?", "onboardingStep1Question3": "¿Saldrá mi equipo victorioso?", diff --git a/front_end/messages/pt.json b/front_end/messages/pt.json index 204b476833..982e80ee25 100644 --- a/front_end/messages/pt.json +++ b/front_end/messages/pt.json @@ -885,6 +885,8 @@ "analytics": "Análise", "helpsImprovePlatform": "Nos ajuda a melhorar a plataforma", "saveSelected": "Salvar Selecionados", + "remindMeLater": "Lembrar-me mais tarde", + "onboardingRemindMeLaterDescription": "(Você sempre pode encontrar o tutorial mais tarde no menu no topo de cada página)", "onboardingStep1Question1": "Devo levar um guarda-chuva?", "onboardingStep1Question2": "Estamos caminhando para uma recessão?", "onboardingStep1Question3": "Meu time vai ganhar?", diff --git a/front_end/messages/zh.json b/front_end/messages/zh.json index 4f6860d3e7..cfdb1e6b5f 100644 --- a/front_end/messages/zh.json +++ b/front_end/messages/zh.json @@ -753,6 +753,8 @@ "nuclearSecurityDescription": "量化全球風險以確保我們安全並實現繁榮的未來", "climateChange": "氣候變化", "climateChangeDescription": "預測由人類活動引起的長期溫度和天氣模式變化", + "remindMeLater": "稍后提醒我", + "onboardingRemindMeLaterDescription": "(您随时可以在每页顶部的菜单中找到教程)", "onboardingStep1Question1": "我应该带伞吗?", "onboardingStep1Question2": "我们是否正走向衰退?", "onboardingStep1Question3": "我的团队会胜出吗?", diff --git a/front_end/src/components/onboarding/OnboardingCheck.tsx b/front_end/src/components/onboarding/OnboardingCheck.tsx index e184b1e8a4..597521115c 100644 --- a/front_end/src/components/onboarding/OnboardingCheck.tsx +++ b/front_end/src/components/onboarding/OnboardingCheck.tsx @@ -10,7 +10,7 @@ import { useNavigation } from "@/contexts/navigation_context"; const OnboardingCheck: React.FC = () => { const { setCurrentModal } = useModal(); const { user } = useAuth(); - const { previousPath, currentPath } = useNavigation(); + const { previousPath, currentPathname } = useNavigation(); const pathname = usePathname(); // We want to avoid situations where a user skips the tutorial @@ -28,7 +28,7 @@ const OnboardingCheck: React.FC = () => { // Checks if the hook has already been refreshed. // Sometimes, it takes a moment for useNavigation to update from the previous route's values, // so we need to perform this check to ensure we have updated values of previousPath - const hookUpdated = currentPath === pathname; + const hookUpdated = currentPathname === pathname; if ( hookUpdated && @@ -39,7 +39,7 @@ const OnboardingCheck: React.FC = () => { // Start the onboarding process setCurrentModal({ type: "onboarding" }); } - }, [user?.id, currentPath]); + }, [user?.id, currentPathname]); return null; // This component doesn't render anything }; diff --git a/front_end/src/components/onboarding/OnboardingModal.tsx b/front_end/src/components/onboarding/OnboardingModal.tsx index e7b95de842..23a6940d58 100644 --- a/front_end/src/components/onboarding/OnboardingModal.tsx +++ b/front_end/src/components/onboarding/OnboardingModal.tsx @@ -1,5 +1,7 @@ +"use client"; + import { useRouter } from "next/navigation"; -import React, { useEffect, useRef, useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; import { updateProfileAction } from "@/app/(main)/accounts/profile/actions"; import { getPost } from "@/app/(main)/questions/actions"; @@ -21,8 +23,11 @@ type OnboardingLocalStorage = { }; const ONBOARDING_KEY = "onboardingState"; const getLocalStorageOnboardingData = (): OnboardingLocalStorage => { - const storedValue = localStorage.getItem(ONBOARDING_KEY); - return storedValue !== null ? JSON.parse(storedValue) : {}; + const storedValue = + typeof window !== "undefined" && + window.localStorage && + localStorage.getItem(ONBOARDING_KEY); + return storedValue ? JSON.parse(storedValue) : {}; }; const OnboardingModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ diff --git a/front_end/src/components/onboarding/steps/Step1.tsx b/front_end/src/components/onboarding/steps/Step1.tsx index 393d2a7787..0a4b250ed3 100644 --- a/front_end/src/components/onboarding/steps/Step1.tsx +++ b/front_end/src/components/onboarding/steps/Step1.tsx @@ -2,6 +2,9 @@ import { sendGAEvent } from "@next/third-parties/google"; import { useTranslations } from "next-intl"; import React, { useEffect } from "react"; +import { updateProfileAction } from "@/app/(main)/accounts/profile/actions"; +import { logError } from "@/utils/errors"; + import { onboardingTopics } from "../OnboardingSettings"; import { onboardingStyles } from "../OnboardingStyles"; @@ -21,7 +24,15 @@ const Step1: React.FC = ({ onTopicSelect, onClose }) => { }, []); const handleSkipTutorial = () => { + // Mark tutorial as complete sendGAEvent({ event: "onboardingSkipped", event_category: "onboarding" }); + updateProfileAction({ is_onboarding_complete: true }).catch(logError); + onClose(); + }; + + const handleCloseTutorial = () => { + // Temporarily hide tutorial + sendGAEvent({ event: "onboardingClosed", event_category: "onboarding" }); onClose(); }; @@ -75,14 +86,23 @@ const Step1: React.FC = ({ onTopicSelect, onClose }) => { ))}
-
+
+
+

+ {t("onboardingRemindMeLaterDescription")} +

); }; diff --git a/front_end/src/contexts/navigation_context.tsx b/front_end/src/contexts/navigation_context.tsx index effd659a43..0144acb7d1 100644 --- a/front_end/src/contexts/navigation_context.tsx +++ b/front_end/src/contexts/navigation_context.tsx @@ -13,11 +13,13 @@ import { type NavigationContextType = { previousPath: null | string; currentPath: null | string; + currentPathname: null | string; }; export const NavigationContext = createContext({ previousPath: null, currentPath: null, + currentPathname: null, }); const NavigationProvider: FC = ({ children }) => { @@ -25,6 +27,7 @@ const NavigationProvider: FC = ({ children }) => { const [path, setPath] = useState({ previousPath: null, currentPath: null, + currentPathname: null, }); const searchParams = useSearchParams(); @@ -34,7 +37,11 @@ const NavigationProvider: FC = ({ children }) => { setPath((prevPath) => { return fullPath === prevPath.currentPath ? prevPath - : { previousPath: prevPath.currentPath, currentPath: fullPath }; + : { + previousPath: prevPath.currentPath, + currentPath: fullPath, + currentPathname: pathname, + }; }); }, [pathname, searchParams]); From 43ea4e79656f904d3c795e6c3e2bf9d00e7634f1 Mon Sep 17 00:00:00 2001 From: hlbmtc Date: Fri, 22 Nov 2024 17:05:39 +0000 Subject: [PATCH 8/8] - Fixed modal z-index - Onboarding "Remind Me Later" button suppresses modal for 1 day --- front_end/src/components/base_modal.tsx | 2 +- .../components/onboarding/OnboardingCheck.tsx | 30 ++----------- .../components/onboarding/OnboardingModal.tsx | 29 ++++-------- .../src/components/onboarding/steps/Step1.tsx | 3 ++ front_end/src/contexts/navigation_context.tsx | 9 +--- front_end/src/types/onboarding.ts | 4 ++ front_end/src/utils/onboarding.ts | 44 +++++++++++++++++++ front_end/tailwind.config.ts | 3 ++ 8 files changed, 68 insertions(+), 56 deletions(-) create mode 100644 front_end/src/types/onboarding.ts create mode 100644 front_end/src/utils/onboarding.ts diff --git a/front_end/src/components/base_modal.tsx b/front_end/src/components/base_modal.tsx index cac211afbc..c594396b65 100644 --- a/front_end/src/components/base_modal.tsx +++ b/front_end/src/components/base_modal.tsx @@ -40,7 +40,7 @@ const BaseModal: FC> = ({ {} : onClose} onWheel={(e) => isImmersive && e.stopPropagation()} > diff --git a/front_end/src/components/onboarding/OnboardingCheck.tsx b/front_end/src/components/onboarding/OnboardingCheck.tsx index 597521115c..2807a03ddd 100644 --- a/front_end/src/components/onboarding/OnboardingCheck.tsx +++ b/front_end/src/components/onboarding/OnboardingCheck.tsx @@ -1,45 +1,21 @@ "use client"; -import { usePathname } from "next/navigation"; import { useEffect } from "react"; import { useAuth } from "@/contexts/auth_context"; import { useModal } from "@/contexts/modal_context"; -import { useNavigation } from "@/contexts/navigation_context"; +import { checkOnboardingAllowed } from "@/utils/onboarding"; const OnboardingCheck: React.FC = () => { const { setCurrentModal } = useModal(); const { user } = useAuth(); - const { previousPath, currentPathname } = useNavigation(); - const pathname = usePathname(); - - // We want to avoid situations where a user skips the tutorial - // on the homepage, navigates directly to the questions feed, - // and then sees the tutorial pop up again (or vice versa). - // To handle this, we simply check that the previous page - // wasn’t the home or questions page. - const previousPathHasTutorial = - previousPath && - ["/", "/questions/"].includes( - new URL(previousPath, process.env.APP_URL).pathname - ); useEffect(() => { - // Checks if the hook has already been refreshed. - // Sometimes, it takes a moment for useNavigation to update from the previous route's values, - // so we need to perform this check to ensure we have updated values of previousPath - const hookUpdated = currentPathname === pathname; - - if ( - hookUpdated && - user?.id && - !user?.is_onboarding_complete && - !previousPathHasTutorial - ) { + if (checkOnboardingAllowed() && user?.id && !user?.is_onboarding_complete) { // Start the onboarding process setCurrentModal({ type: "onboarding" }); } - }, [user?.id, currentPathname]); + }, [user?.id]); return null; // This component doesn't render anything }; diff --git a/front_end/src/components/onboarding/OnboardingModal.tsx b/front_end/src/components/onboarding/OnboardingModal.tsx index 23a6940d58..cd34dfb865 100644 --- a/front_end/src/components/onboarding/OnboardingModal.tsx +++ b/front_end/src/components/onboarding/OnboardingModal.tsx @@ -9,6 +9,11 @@ import BaseModal from "@/components/base_modal"; import { useAuth } from "@/contexts/auth_context"; import { PostWithForecasts } from "@/types/post"; import { logError } from "@/utils/errors"; +import { + deleteOnboardingStoredState, + getOnboardingStoredState, + setOnboardingStoredState, +} from "@/utils/onboarding"; import { onboardingTopics } from "./OnboardingSettings"; import Step1 from "./steps/Step1"; @@ -17,29 +22,16 @@ import Step3 from "./steps/Step3"; import Step4 from "./steps/Step4"; import Step5 from "./steps/Step5"; -type OnboardingLocalStorage = { - selectedTopic?: number; - currentStep?: number | null; -}; -const ONBOARDING_KEY = "onboardingState"; -const getLocalStorageOnboardingData = (): OnboardingLocalStorage => { - const storedValue = - typeof window !== "undefined" && - window.localStorage && - localStorage.getItem(ONBOARDING_KEY); - return storedValue ? JSON.parse(storedValue) : {}; -}; - const OnboardingModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose, }) => { const { user } = useAuth(); const [currentStep, setCurrentStep] = useState( - () => getLocalStorageOnboardingData().currentStep || 1 + () => getOnboardingStoredState().currentStep || 1 ); const [selectedTopic, setSelectedTopic] = useState( - getLocalStorageOnboardingData().selectedTopic || null + getOnboardingStoredState().selectedTopic || null ); const [questionData, setQuestionData] = useState( null @@ -61,12 +53,9 @@ const OnboardingModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ useEffect(() => { if (!user?.is_onboarding_complete) { if (currentStep > 1 && currentStep < 5) { - localStorage.setItem( - ONBOARDING_KEY, - JSON.stringify({ selectedTopic, currentStep }) - ); + setOnboardingStoredState({ selectedTopic, currentStep }); } else { - localStorage.removeItem(ONBOARDING_KEY); + deleteOnboardingStoredState(); } } }, [user?.is_onboarding_complete, selectedTopic, currentStep]); diff --git a/front_end/src/components/onboarding/steps/Step1.tsx b/front_end/src/components/onboarding/steps/Step1.tsx index 0a4b250ed3..2a37869229 100644 --- a/front_end/src/components/onboarding/steps/Step1.tsx +++ b/front_end/src/components/onboarding/steps/Step1.tsx @@ -4,6 +4,7 @@ import React, { useEffect } from "react"; import { updateProfileAction } from "@/app/(main)/accounts/profile/actions"; import { logError } from "@/utils/errors"; +import { setOnboardingSuppressed } from "@/utils/onboarding"; import { onboardingTopics } from "../OnboardingSettings"; import { onboardingStyles } from "../OnboardingStyles"; @@ -33,6 +34,8 @@ const Step1: React.FC = ({ onTopicSelect, onClose }) => { const handleCloseTutorial = () => { // Temporarily hide tutorial sendGAEvent({ event: "onboardingClosed", event_category: "onboarding" }); + // Mark as temporarily suppressed + setOnboardingSuppressed(); onClose(); }; diff --git a/front_end/src/contexts/navigation_context.tsx b/front_end/src/contexts/navigation_context.tsx index 0144acb7d1..effd659a43 100644 --- a/front_end/src/contexts/navigation_context.tsx +++ b/front_end/src/contexts/navigation_context.tsx @@ -13,13 +13,11 @@ import { type NavigationContextType = { previousPath: null | string; currentPath: null | string; - currentPathname: null | string; }; export const NavigationContext = createContext({ previousPath: null, currentPath: null, - currentPathname: null, }); const NavigationProvider: FC = ({ children }) => { @@ -27,7 +25,6 @@ const NavigationProvider: FC = ({ children }) => { const [path, setPath] = useState({ previousPath: null, currentPath: null, - currentPathname: null, }); const searchParams = useSearchParams(); @@ -37,11 +34,7 @@ const NavigationProvider: FC = ({ children }) => { setPath((prevPath) => { return fullPath === prevPath.currentPath ? prevPath - : { - previousPath: prevPath.currentPath, - currentPath: fullPath, - currentPathname: pathname, - }; + : { previousPath: prevPath.currentPath, currentPath: fullPath }; }); }, [pathname, searchParams]); diff --git a/front_end/src/types/onboarding.ts b/front_end/src/types/onboarding.ts new file mode 100644 index 0000000000..a8d3e8fe57 --- /dev/null +++ b/front_end/src/types/onboarding.ts @@ -0,0 +1,4 @@ +export type OnboardingStoredState = { + selectedTopic?: number | null; + currentStep?: number | null; +}; diff --git a/front_end/src/utils/onboarding.ts b/front_end/src/utils/onboarding.ts new file mode 100644 index 0000000000..e5a1d0de1f --- /dev/null +++ b/front_end/src/utils/onboarding.ts @@ -0,0 +1,44 @@ +"use client"; + +import { addDays, isAfter } from "date-fns"; + +import { OnboardingStoredState } from "@/types/onboarding"; + +const ONBOARDING_SUPPRESSED_KEY = "onboardingSuppressedAt"; +const ONBOARDING_STATE_KEY = "onboardingState"; + +export function checkLocalStorageAvailable() { + return typeof window !== "undefined" && window.localStorage; +} + +export function checkOnboardingAllowed() { + const closedAt = + checkLocalStorageAvailable() && + localStorage.getItem(ONBOARDING_SUPPRESSED_KEY); + + return closedAt ? isAfter(new Date(), addDays(new Date(closedAt), 1)) : true; +} + +export function setOnboardingSuppressed() { + if (checkLocalStorageAvailable()) { + localStorage.setItem(ONBOARDING_SUPPRESSED_KEY, new Date().toISOString()); + } +} + +export function getOnboardingStoredState(): OnboardingStoredState { + const storedValue = + checkLocalStorageAvailable() && localStorage.getItem(ONBOARDING_STATE_KEY); + return storedValue ? JSON.parse(storedValue) : {}; +} + +export function setOnboardingStoredState(data: OnboardingStoredState) { + if (checkLocalStorageAvailable()) { + localStorage.setItem(ONBOARDING_STATE_KEY, JSON.stringify(data)); + } +} + +export function deleteOnboardingStoredState() { + if (checkLocalStorageAvailable()) { + localStorage.removeItem(ONBOARDING_STATE_KEY); + } +} diff --git a/front_end/tailwind.config.ts b/front_end/tailwind.config.ts index e408041426..f7ed89408d 100644 --- a/front_end/tailwind.config.ts +++ b/front_end/tailwind.config.ts @@ -47,6 +47,9 @@ const config: Config = { scrollMargin: { nav: "70px", }, + zIndex: { + "100": "100", + }, }, }, plugins: [