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/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 9ee6d1ea5c..10d609b15b 100644 --- a/front_end/src/app/(main)/components/mobile_menu.tsx +++ b/front_end/src/app/(main)/components/mobile_menu.tsx @@ -1,11 +1,10 @@ "use client"; import { + faArrowLeft, faBars, faMagnifyingGlass, faMinus, - faXmark, faPlus, - faArrowLeft, } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { @@ -18,7 +17,7 @@ import { import classNames from "classnames"; import Link from "next/link"; import { useTranslations } from "next-intl"; -import { FC, PropsWithChildren, useState, useRef, useEffect } from "react"; +import { FC, PropsWithChildren, useEffect, useRef, useState } from "react"; import { LogOut } from "@/app/(main)/accounts/actions"; import LanguageMenu from "@/components/language_menu"; @@ -153,7 +152,9 @@ const MobileMenu: FC = ({ community, onClick }) => { {t("settings")} - + setCurrentModal({ type: "onboarding" })} + > {t("tutorial")} {user.is_superuser && ( @@ -245,7 +246,9 @@ const MobileMenu: FC = ({ community, onClick }) => { {t("settings")} - + setCurrentModal({ type: "onboarding" })} + > {t("tutorial")} {user.is_superuser && ( @@ -279,10 +282,7 @@ const MobileMenu: FC = ({ community, onClick }) => { ref={searchContainerRef} className="fixed inset-x-0 top-12 z-40 bg-blue-200-dark p-2 shadow-md" > - + )} diff --git a/front_end/src/app/(main)/questions/page.tsx b/front_end/src/app/(main)/questions/page.tsx index 73b64888c9..a22d357b1a 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/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 eb91913479..2807a03ddd 100644 --- a/front_end/src/components/onboarding/OnboardingCheck.tsx +++ b/front_end/src/components/onboarding/OnboardingCheck.tsx @@ -1,39 +1,21 @@ "use client"; -import { useSearchParams, useRouter } from "next/navigation"; -import { useEffect, useCallback } from "react"; +import { useEffect } from "react"; import { useAuth } from "@/contexts/auth_context"; import { useModal } from "@/contexts/modal_context"; +import { checkOnboardingAllowed } from "@/utils/onboarding"; 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]); - useEffect(() => { - handleOnboarding(); - }, [handleOnboarding]); + if (checkOnboardingAllowed() && user?.id && !user?.is_onboarding_complete) { + // Start the onboarding process + setCurrentModal({ type: "onboarding" }); + } + }, [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 b7a979f84e..cd34dfb865 100644 --- a/front_end/src/components/onboarding/OnboardingModal.tsx +++ b/front_end/src/components/onboarding/OnboardingModal.tsx @@ -1,9 +1,19 @@ +"use client"; + import { useRouter } from "next/navigation"; -import React, { useState, useEffect, useRef } 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"; 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"; @@ -16,8 +26,13 @@ const OnboardingModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ isOpen, onClose, }) => { - const [currentStep, setCurrentStep] = useState(1); - const [selectedTopic, setSelectedTopic] = useState(null); + const { user } = useAuth(); + const [currentStep, setCurrentStep] = useState( + () => getOnboardingStoredState().currentStep || 1 + ); + const [selectedTopic, setSelectedTopic] = useState( + getOnboardingStoredState().selectedTopic || null + ); const [questionData, setQuestionData] = useState( null ); @@ -34,6 +49,17 @@ const OnboardingModal: React.FC<{ isOpen: boolean; onClose: () => void }> = ({ } }; + // Save state into local storage + useEffect(() => { + if (!user?.is_onboarding_complete) { + if (currentStep > 1 && currentStep < 5) { + setOnboardingStoredState({ selectedTopic, currentStep }); + } else { + deleteOnboardingStoredState(); + } + } + }, [user?.is_onboarding_complete, selectedTopic, currentStep]); + useEffect(() => { async function fetchQuestionData() { if ( @@ -59,6 +85,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/components/onboarding/steps/Step1.tsx b/front_end/src/components/onboarding/steps/Step1.tsx index 393d2a7787..2a37869229 100644 --- a/front_end/src/components/onboarding/steps/Step1.tsx +++ b/front_end/src/components/onboarding/steps/Step1.tsx @@ -2,6 +2,10 @@ 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 { setOnboardingSuppressed } from "@/utils/onboarding"; + import { onboardingTopics } from "../OnboardingSettings"; import { onboardingStyles } from "../OnboardingStyles"; @@ -21,7 +25,17 @@ 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" }); + // Mark as temporarily suppressed + setOnboardingSuppressed(); onClose(); }; @@ -75,14 +89,23 @@ const Step1: React.FC = ({ onTopicSelect, onClose }) => { ))}
-
+
+
+

+ {t("onboardingRemindMeLaterDescription")} +

); }; 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/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/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 { 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: [ 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", )