From 4cd2f5f295cc8d8583755013ea4bf6e169ee5a4c Mon Sep 17 00:00:00 2001 From: Rotimi Best Date: Tue, 18 Aug 2026 13:17:06 +0100 Subject: [PATCH] fix(dashboard): open upgrade modal without triggering unsaved-changes prompt (#919) * fix(dashboard): open the upgrade modal without navigating Opening the upgrade modal set `?upgrade=true` via `goto`, which SvelteKit treats as a navigation. On pages guarded by UnsavedChanges (the exercise editor, course settings, certificates) that triggered the native "You have unsaved changes" confirm before the modal could appear, and cancelling blocked it entirely. The modal now lives in app state instead of the URL, so opening and closing it never navigates. `?upgrade=true` is still honoured for billing emails and the post-checkout return URL: it is consumed once on load and stripped from the address bar with shallow routing. Because checkout is an external redirect that unloads the page, editors can register a handoff to persist in-progress work first. The exercise editor stashes its draft, and the checkout return URL now points back at the page the user upgraded from, so their questions are restored instead of lost. Co-authored-by: Rotimi Best * fix(dashboard): close question type select before opening upgrade modal The select dropdown sits at z-250 while the upgrade dialog is z-200, so opening the modal while the menu was still mounted left it covering the dialog. Close the select first, wait for its close animation, then open the upgrade modal. Co-authored-by: Rotimi Best * fix(dashboard): close question-type select before opening upgrade modal The premium-type button left the select open, and its z-menu-elevated content sat in front of the upgrade dialog. Close the select first and wait for the close animation before opening the modal. Co-authored-by: Rotimi Best --------- Co-authored-by: Rotimi Best --- .../ai-assistant/ai-course-chat.svelte | 2 +- .../features/ai-assistant/chat-input.svelte | 2 +- .../exercise/question-type-select.svelte | 31 +++++-- .../lib/features/course/pages/exercise.svelte | 15 ++++ .../features/course/utils/exercise-draft.ts | 79 ++++++++++++++++++ .../features/settings/pages/billing.svelte | 2 +- .../settings/pages/landingpage.svelte | 2 +- .../org-sidebar/upgrade-trigger.svelte | 2 +- .../src/lib/features/ui/upgrade-banner.svelte | 2 +- .../src/lib/features/ui/upgrade-modal.svelte | 60 ++++++++++---- apps/dashboard/src/lib/utils/functions/org.ts | 29 ------- .../src/lib/utils/store/upgrade-modal.ts | 83 +++++++++++++++++++ .../src/lib/utils/translations/da.json | 3 +- .../src/lib/utils/translations/de.json | 3 +- .../src/lib/utils/translations/en.json | 3 +- .../src/lib/utils/translations/es.json | 3 +- .../src/lib/utils/translations/fr.json | 3 +- .../src/lib/utils/translations/hi.json | 3 +- .../src/lib/utils/translations/pl.json | 3 +- .../src/lib/utils/translations/pt.json | 3 +- .../src/lib/utils/translations/ru.json | 3 +- .../src/lib/utils/translations/vi.json | 3 +- .../courses/[id]/certificates/+page.svelte | 2 +- .../[id]/exercises/[exerciseId]/+page.svelte | 7 ++ .../src/routes/(app)/org/[slug]/+page.svelte | 2 +- .../src/routes/api/polar/subscribe/+server.ts | 19 ++++- 26 files changed, 298 insertions(+), 71 deletions(-) create mode 100644 apps/dashboard/src/lib/features/course/utils/exercise-draft.ts create mode 100644 apps/dashboard/src/lib/utils/store/upgrade-modal.ts diff --git a/apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte b/apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte index 71f28b4f..1b2c2b59 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte +++ b/apps/dashboard/src/lib/features/ai-assistant/ai-course-chat.svelte @@ -44,7 +44,7 @@ import { getMentionableContent } from '$features/course/utils/content'; import { refreshExercisePageData } from '$features/course/utils/exercise-page-utils'; import { getRequestBaseUrl, apiClient } from '$lib/utils/services/api'; - import { openUpgradeModal } from '$lib/utils/functions/org'; + import { openUpgradeModal } from '$lib/utils/store/upgrade-modal'; import { isFreePlan } from '$lib/utils/store/org'; import { t } from '$lib/utils/functions/translations'; import { aiAssistantApi } from '$features/ai-assistant/api/ai-assistant.svelte'; diff --git a/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte b/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte index 3ac0ebe5..3bc3ac57 100644 --- a/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte +++ b/apps/dashboard/src/lib/features/ai-assistant/chat-input.svelte @@ -14,7 +14,7 @@ import { t } from '$lib/utils/functions/translations'; import { resolve } from '$app/paths'; import { currentOrgPath, isFreePlan } from '$lib/utils/store/org'; - import { openUpgradeModal } from '$lib/utils/functions/org'; + import { openUpgradeModal } from '$lib/utils/store/upgrade-modal'; import { ModelPicker } from '@cio/ui/custom/model-picker'; import type { AgentModelId } from '@cio/utils/agent-models'; import { AI_AGENT_RUNNING_WARNING_DISMISSED_KEY } from '$features/ai-assistant/utils/constants'; diff --git a/apps/dashboard/src/lib/features/course/components/exercise/question-type-select.svelte b/apps/dashboard/src/lib/features/course/components/exercise/question-type-select.svelte index 7fdc33aa..62f1bfae 100644 --- a/apps/dashboard/src/lib/features/course/components/exercise/question-type-select.svelte +++ b/apps/dashboard/src/lib/features/course/components/exercise/question-type-select.svelte @@ -4,8 +4,9 @@ import { PremiumIcon } from '@cio/ui/custom/moving-icons'; import { getExerciseEditorQuestionTypeLabel } from './question-type-utils'; import { isFreePlan } from '$lib/utils/store/org'; - import { openUpgradeModal } from '$lib/utils/functions/org'; + import { openUpgradeModal } from '$lib/utils/store/upgrade-modal'; import { t } from '$lib/utils/functions/translations'; + import { onDestroy } from 'svelte'; import type { Question } from '$features/course/types'; type QuestionTypeEntry = (typeof QUESTION_TYPES)[number]; @@ -19,8 +20,28 @@ let { value, onValueChange, triggerQuestionType, types }: Props = $props(); + const SELECT_CLOSE_ANIMATION_MS = 180; + + let isSelectOpen = $state(false); + let openUpgradeModalTimeout: ReturnType | undefined; + const autoGradableTypes = $derived(types.filter((typeEntry) => typeEntry.autoGradable)); const manuallyGradedTypes = $derived(types.filter((typeEntry) => !typeEntry.autoGradable)); + + function handlePremiumTypeClick(clickEvent: MouseEvent) { + clickEvent.preventDefault(); + clickEvent.stopPropagation(); + + isSelectOpen = false; + clearTimeout(openUpgradeModalTimeout); + openUpgradeModalTimeout = setTimeout(() => { + openUpgradeModal(); + }, SELECT_CLOSE_ANIMATION_MS); + } + + onDestroy(() => { + clearTimeout(openUpgradeModalTimeout); + }); {#snippet sectionTitle({ title, description })} @@ -40,11 +61,7 @@ {/snippet} - + {getExerciseEditorQuestionTypeLabel(triggerQuestionType)} diff --git a/apps/dashboard/src/lib/features/course/pages/exercise.svelte b/apps/dashboard/src/lib/features/course/pages/exercise.svelte index 1d689ca2..4017f477 100644 --- a/apps/dashboard/src/lib/features/course/pages/exercise.svelte +++ b/apps/dashboard/src/lib/features/course/pages/exercise.svelte @@ -48,6 +48,8 @@ } from '$features/ui'; import { isSelfPacedLikeCourse } from '$features/course/utils/compliance-utils'; import { getOrderedNavigableContent } from '$features/course/utils/content'; + import { saveExerciseDraft } from '$features/course/utils/exercise-draft'; + import { onUpgradeCheckoutHandoff } from '$lib/utils/store/upgrade-modal'; import { hydrateExercisePageData // , refreshExercisePageData @@ -463,6 +465,19 @@ selectedTab = normalizeExerciseTab(page.url.searchParams.get('tab')); }); + // Upgrading from a premium question type sends the browser to an external checkout. Stash the + // editor so it can be restored on the way back, and stand the leave guard down — with the work + // persisted there is nothing to warn about. + onMount(() => + onUpgradeCheckoutHandoff(() => { + const courseId = courseApi.course?.id; + if ($isOrgStudent || !courseId || !hasDirtyQuestionnaire()) return; + + saveExerciseDraft(courseId, exerciseId, $questionnaire); + hasUnsavedChanges = false; + }) + ); + $effect(() => { const nextTab = normalizeExerciseTab(page.url.searchParams.get('tab')); if (nextTab === untrack(() => selectedTab)) return; diff --git a/apps/dashboard/src/lib/features/course/utils/exercise-draft.ts b/apps/dashboard/src/lib/features/course/utils/exercise-draft.ts new file mode 100644 index 00000000..f72961bf --- /dev/null +++ b/apps/dashboard/src/lib/features/course/utils/exercise-draft.ts @@ -0,0 +1,79 @@ +import type { QuestionnaireState } from '$features/course/components/exercise/store'; +import { questionnaire } from '$features/course/components/exercise/store'; + +const EXERCISE_DRAFT_STORAGE_PREFIX = 'classroomio:exercise-draft'; + +/** A draft only bridges a redirect out of the app and back, so it goes stale quickly. */ +const EXERCISE_DRAFT_MAX_AGE_MS = 60 * 60 * 1000; + +interface StoredExerciseDraft { + savedAt: number; + questionnaire: QuestionnaireState; +} + +function getStorageKey(courseId: string, exerciseId: string) { + return `${EXERCISE_DRAFT_STORAGE_PREFIX}:${courseId}:${exerciseId}`; +} + +export function clearExerciseDraft(courseId: string, exerciseId: string) { + try { + localStorage.removeItem(getStorageKey(courseId, exerciseId)); + } catch (error) { + console.error('clearExerciseDraft error:', error); + } +} + +/** + * Stashes the editor state before the browser leaves the app (e.g. for plan checkout), so the + * teacher's in-progress questions survive the round trip. + */ +export function saveExerciseDraft(courseId: string, exerciseId: string, state: QuestionnaireState) { + if (!courseId || !exerciseId) return; + + const draft: StoredExerciseDraft = { savedAt: Date.now(), questionnaire: state }; + + try { + localStorage.setItem(getStorageKey(courseId, exerciseId), JSON.stringify(draft)); + } catch (error) { + console.error('saveExerciseDraft error:', error); + } +} + +function loadExerciseDraft(courseId: string, exerciseId: string): QuestionnaireState | null { + if (!courseId || !exerciseId) return null; + + try { + const stored = localStorage.getItem(getStorageKey(courseId, exerciseId)); + if (!stored) return null; + + const draft = JSON.parse(stored) as StoredExerciseDraft | null; + const isExpired = Date.now() - (draft?.savedAt ?? 0) >= EXERCISE_DRAFT_MAX_AGE_MS; + + if (!draft?.questionnaire || isExpired) { + clearExerciseDraft(courseId, exerciseId); + return null; + } + + return draft.questionnaire; + } catch (error) { + console.error('loadExerciseDraft error:', error); + clearExerciseDraft(courseId, exerciseId); + + return null; + } +} + +/** + * Replaces freshly hydrated server data with a stashed draft, if one is waiting. Must run after + * `hydrateExercisePageData`. Returns whether a draft was restored. + */ +export function restoreExerciseDraft(courseId: string, exerciseId: string) { + const draft = loadExerciseDraft(courseId, exerciseId); + + if (!draft) return false; + + clearExerciseDraft(courseId, exerciseId); + questionnaire.set(draft); + + return true; +} diff --git a/apps/dashboard/src/lib/features/settings/pages/billing.svelte b/apps/dashboard/src/lib/features/settings/pages/billing.svelte index c0158646..6099e443 100644 --- a/apps/dashboard/src/lib/features/settings/pages/billing.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/billing.svelte @@ -3,7 +3,7 @@ import { snackbar } from '$features/ui/snackbar/store'; import { currentOrgPlan, isFreePlan, currentOrg, isSecondaryWorkspace } from '$lib/utils/store/org'; import HourglassIcon from '@lucide/svelte/icons/hourglass'; - import { openUpgradeModal } from '$lib/utils/functions/org'; + import { openUpgradeModal } from '$lib/utils/store/upgrade-modal'; import ReceiptIcon from '@lucide/svelte/icons/receipt'; import { Button } from '@cio/ui/base/button'; import * as Item from '@cio/ui/base/item'; diff --git a/apps/dashboard/src/lib/features/settings/pages/landingpage.svelte b/apps/dashboard/src/lib/features/settings/pages/landingpage.svelte index 3cc4c4c7..838ebd9a 100644 --- a/apps/dashboard/src/lib/features/settings/pages/landingpage.svelte +++ b/apps/dashboard/src/lib/features/settings/pages/landingpage.svelte @@ -10,7 +10,7 @@ import ZapIcon from '@lucide/svelte/icons/zap'; import { currentOrg, currentOrgPath, isFreePlan } from '$lib/utils/store/org'; - import { openUpgradeModal } from '$lib/utils/functions/org'; + import { openUpgradeModal } from '$lib/utils/store/upgrade-modal'; import { t } from '$lib/utils/functions/translations'; import { user } from '$lib/utils/store/user'; import { basePath } from '$lib/utils/store/app'; diff --git a/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/upgrade-trigger.svelte b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/upgrade-trigger.svelte index e98c1c60..77dbaddb 100644 --- a/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/upgrade-trigger.svelte +++ b/apps/dashboard/src/lib/features/ui/sidebar/org-sidebar/upgrade-trigger.svelte @@ -3,7 +3,7 @@ import { Progress } from '@cio/ui/base/progress'; import RocketIcon from '@lucide/svelte/icons/rocket'; import { t } from '$lib/utils/functions/translations'; - import { openUpgradeModal } from '$lib/utils/functions/org'; + import { openUpgradeModal } from '$lib/utils/store/upgrade-modal'; import { currentOrgMaxAudience, isFreePlan, studentUsage } from '$lib/utils/store/org'; import { useSidebar } from '@cio/ui/base/sidebar'; diff --git a/apps/dashboard/src/lib/features/ui/upgrade-banner.svelte b/apps/dashboard/src/lib/features/ui/upgrade-banner.svelte index d462b0bf..688948ee 100644 --- a/apps/dashboard/src/lib/features/ui/upgrade-banner.svelte +++ b/apps/dashboard/src/lib/features/ui/upgrade-banner.svelte @@ -1,5 +1,5 @@ @@ -100,7 +128,7 @@ {/if} - + -