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} - + -