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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

* 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 <rotimi-best@users.noreply.github.com>

---------

Co-authored-by: Rotimi Best <rotimi-best@users.noreply.github.com>
This commit is contained in:
Rotimi Best
2026-08-18 13:17:06 +01:00
committed by GitHub
co-authored by Rotimi Best
parent 564b0735f0
commit 4cd2f5f295
26 changed files with 298 additions and 71 deletions
@@ -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';
@@ -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';
@@ -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<typeof setTimeout> | 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);
});
</script>
{#snippet sectionTitle({ title, description })}
@@ -40,11 +61,7 @@
<button
type="button"
class="ui:relative ui:flex ui:w-full ui:cursor-pointer ui:select-none ui:items-center ui:justify-between ui:gap-2 ui:rounded-sm ui:px-2 ui:py-1.5 ui:text-sm ui:outline-hidden ui:hover:bg-accent ui:hover:text-accent-foreground"
onclick={(clickEvent) => {
clickEvent.preventDefault();
clickEvent.stopPropagation();
openUpgradeModal();
}}
onclick={handlePremiumTypeClick}
title={$t('course.navItem.lessons.exercises.all_exercises.edit_mode.premium_question_type')}
>
<span>{getExerciseEditorQuestionTypeLabel(typeEntry)}</span>
@@ -52,7 +69,7 @@
</button>
{/snippet}
<Select.Root type="single" {value} {onValueChange}>
<Select.Root type="single" {value} {onValueChange} bind:open={isSelectOpen}>
<Select.Trigger class="w-full min-w-0 sm:w-[180px]">
{getExerciseEditorQuestionTypeLabel(triggerQuestionType)}
</Select.Trigger>
@@ -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;
@@ -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;
}
@@ -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';
@@ -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';
@@ -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';
@@ -1,5 +1,5 @@
<script lang="ts">
import { openUpgradeModalOver } from '$lib/utils/functions/org';
import { openUpgradeModalOver } from '$lib/utils/store/upgrade-modal';
import { isFreePlan } from '$lib/utils/store/org';
import * as Item from '@cio/ui/base/item';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
@@ -1,7 +1,6 @@
<script lang="ts">
import { untrack } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { Progress } from '@cio/ui/base/progress';
import RocketIcon from '@lucide/svelte/icons/rocket';
import * as Dialog from '@cio/ui/base/dialog';
@@ -15,6 +14,15 @@
import { snackbar } from '$features/ui/snackbar/store';
import { toggleConfetti } from './confetti/store';
import { currentOrg, isFreePlan } from '$lib/utils/store/org';
import {
UPGRADE_CONFIRMATION_PARAM,
UPGRADE_PARAM,
clearUpgradeSearchParams,
closeUpgradeModal,
isUpgradeModalOpen,
openUpgradeModal,
runUpgradeCheckoutHandoffs
} from '$lib/utils/store/upgrade-modal';
import Confetti from './confetti/confetti.svelte';
const planNames = Object.keys(PLANS);
@@ -22,13 +30,22 @@
let isLoadingPlan: string | null = $state(null);
let upgraded = $state(false);
let isYearlyPlan = $state(false);
let isConfirming = $state(false);
let handledUpgradeParamFor: string | null = null;
const query = $derived(new URLSearchParams(page.url.search));
let open = $state(false);
let isConfirming = $derived((query.get('confirmation') || '') === 'true');
// Billing emails and the post-checkout return URL open the modal with `?upgrade=true`.
// The params are consumed once here so that closing the modal never has to navigate.
$effect(() => {
open = (query.get('upgrade') || '') === 'true';
const currentHref = page.url.href;
if (page.url.searchParams.get(UPGRADE_PARAM) !== 'true') return;
if (handledUpgradeParamFor === currentHref) return;
untrack(() => {
handledUpgradeParamFor = currentHref;
isConfirming = page.url.searchParams.get(UPGRADE_CONFIRMATION_PARAM) === 'true';
clearUpgradeSearchParams();
openUpgradeModal();
});
});
async function handleClick(plan, planName: string) {
@@ -44,10 +61,18 @@
isLoadingPlan = planName;
try {
const checkoutURL = new URL('/api/polar/subscribe', page.url);
// Checkout unloads the page, so editors holding unsaved work stash it first.
runUpgradeCheckoutHandoffs();
checkoutURL.searchParams.set('products', isYearlyPlan ? plan.CTA.PRODUCT_ID_YEARLY : plan.CTA.PRODUCT_ID);
// Read the address bar rather than `page.url`: shallow routing leaves `page.url`
// holding upgrade params that were already stripped from the URL.
const returnTo = `${window.location.pathname}${window.location.search}`;
const productId = isYearlyPlan ? plan.CTA.PRODUCT_ID_YEARLY : plan.CTA.PRODUCT_ID;
const checkoutURL = new URL('/api/polar/subscribe', window.location.origin);
checkoutURL.searchParams.set('products', productId);
checkoutURL.searchParams.set('orgId', $currentOrg.id);
checkoutURL.searchParams.set('returnTo', returnTo);
requestAnimationFrame(() => {
window.location.href = checkoutURL.toString();
@@ -71,14 +96,17 @@
}, 1000);
}
function onClose() {
goto(page.url.pathname);
function handleClose() {
isConfirming = false;
upgraded = false;
isLoadingPlan = null;
closeUpgradeModal();
}
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
onClose();
}
if (newOpen) return;
handleClose();
}
function onLearnMore() {
@@ -92,7 +120,7 @@
}
$effect(() => {
handleUpgradeSuccess(Boolean($currentOrg.id && !$isFreePlan && open));
handleUpgradeSuccess(Boolean($currentOrg.id && !$isFreePlan && $isUpgradeModalOpen));
});
</script>
@@ -100,7 +128,7 @@
<Confetti />
{/if}
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
<Dialog.Root open={$isUpgradeModalOpen} onOpenChange={handleOpenChange}>
<Dialog.Content
class="top-[4dvh]! max-h-[92dvh] w-[96vw] translate-y-0! overflow-y-auto p-4 sm:top-[50%]! sm:max-h-none sm:translate-y-[-50%]! sm:p-6 {upgraded ||
isConfirming
@@ -118,7 +146,7 @@
{$t('pricing.modal.plan')}
</p>
<div class="flex w-full flex-col gap-3 sm:w-auto sm:flex-row sm:items-center">
<Button class="w-full sm:w-auto" variant="outline" onclick={onClose}>
<Button class="w-full sm:w-auto" variant="outline" onclick={handleClose}>
{$t('pricing.modal.close')}
</Button>
<Button class="w-full sm:w-auto" variant="default" onclick={onLearnMore}>
@@ -1,6 +1,3 @@
import { goto } from '$app/navigation';
import { resolve } from '$app/paths';
export function genQuizPin(): number {
const minm = 100000;
const maxm = 999999;
@@ -13,29 +10,3 @@ export function generateSitename(orgName: string): string {
.replace(/\s+/g, '-')
.replace(/[^a-zA-Z0-9-]/g, '');
}
function navigateToUpgradeModal(removeParams: string[]) {
const url = new URL(window.location.href);
for (const param of removeParams) {
url.searchParams.delete(param);
}
url.searchParams.set('upgrade', 'true');
goto(resolve(url.pathname + `?${url.searchParams.toString()}`, {}));
}
/** Opens the in-app upgrade modal by setting `?upgrade=true`. Safe to use directly as an event handler. */
export function openUpgradeModal() {
navigateToUpgradeModal([]);
}
/**
* Opens the upgrade modal while dropping query params that keep another modal
* open (e.g. `['add']` for the invite modal), so the upgrade modal isn't
* stacked behind it — done in a single navigation to avoid a read-after-goto race.
*/
export function openUpgradeModalOver(removeParams: string[]) {
navigateToUpgradeModal(removeParams);
}
@@ -0,0 +1,83 @@
import { goto, replaceState } from '$app/navigation';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { writable } from 'svelte/store';
/** Params used by entry points outside the app: billing emails and the post-checkout return URL. */
export const UPGRADE_PARAM = 'upgrade';
export const UPGRADE_CONFIRMATION_PARAM = 'confirmation';
/**
* The modal lives in app state rather than in the URL. Opening it must never navigate:
* pages that guard unsaved work (exercise editor, course settings, certificates) treat any
* navigation as leaving and ask the user to discard their changes before the modal can show.
*/
export const isUpgradeModalOpen = writable(false);
type UpgradeCheckoutHandoff = () => void;
const checkoutHandoffs = new Set<UpgradeCheckoutHandoff>();
/**
* Checkout is an external redirect that unloads the page, so an editor holding unsaved work
* registers here to stash it before the browser leaves. Returns an unregister function.
*/
export function onUpgradeCheckoutHandoff(handoff: UpgradeCheckoutHandoff) {
checkoutHandoffs.add(handoff);
return () => {
checkoutHandoffs.delete(handoff);
};
}
export function runUpgradeCheckoutHandoffs() {
for (const handoff of checkoutHandoffs) {
handoff();
}
}
/** Opens the in-app upgrade modal. Safe to use directly as an event handler. */
export function openUpgradeModal() {
isUpgradeModalOpen.set(true);
}
/**
* Opens the upgrade modal and closes another query-param driven modal (e.g. `['add']` for the
* invite modal) so the two don't stack. The modal opens first so it shows either way.
*/
export function openUpgradeModalOver(paramsToRemove: string[]) {
openUpgradeModal();
const url = new URL(page.url);
const presentParams = paramsToRemove.filter((param) => url.searchParams.has(param));
if (presentParams.length === 0) return;
for (const param of presentParams) {
url.searchParams.delete(param);
}
goto(resolve(`${url.pathname}${url.search}`, {}), { replaceState: true, keepFocus: true, noScroll: true });
}
export function closeUpgradeModal() {
isUpgradeModalOpen.set(false);
}
/**
* Strips the upgrade params from the address bar with shallow routing, so a refresh doesn't
* reopen the modal. This leaves `page.url` untouched — SvelteKit only updates it on a real
* navigation — so callers must track for themselves that the params were already handled.
*/
export function clearUpgradeSearchParams() {
const url = new URL(page.url);
const hasUpgradeParams = url.searchParams.has(UPGRADE_PARAM) || url.searchParams.has(UPGRADE_CONFIRMATION_PARAM);
if (!hasUpgradeParams) return;
url.searchParams.delete(UPGRADE_PARAM);
url.searchParams.delete(UPGRADE_CONFIRMATION_PARAM);
replaceState(resolve(`${url.pathname}${url.search}`, {}), page.state);
}
@@ -330,7 +330,8 @@
"success": "Gemt",
"submission_updated": "Indsendelse opdateret til",
"error_fetching": "Hentning af øvelser mislykkedes. Genindlæs venligst siden",
"passed_requires_points": "Gennemførelse ved bestået kræver mindst ét tilgængeligt point."
"passed_requires_points": "Gennemførelse ved bestået kræver mindst ét tilgængeligt point.",
"draft_restored": "Vi har gendannet de ikke-gemte ændringer, du efterlod"
},
"poll": {
"info": {
@@ -330,7 +330,8 @@
"success": "Erfolgreich gespeichert",
"submission_updated": "Einreichung aktualisiert auf",
"error_fetching": "Das Abrufen der Übungen ist fehlgeschlagen. Bitte laden Sie die Seite neu",
"passed_requires_points": "Für den Abschluss durch Bestehen muss mindestens ein Punkt verfügbar sein."
"passed_requires_points": "Für den Abschluss durch Bestehen muss mindestens ein Punkt verfügbar sein.",
"draft_restored": "Wir haben die nicht gespeicherten Änderungen wiederhergestellt, die Sie hinterlassen haben"
},
"poll": {
"info": {
@@ -4220,7 +4220,8 @@
"passed_requires_points": "Passed completion needs at least one point available.",
"success": "Saved Successfully",
"submission_updated": "Submission updated to",
"error_fetching": "Fetching exercises failed, please reload the page"
"error_fetching": "Fetching exercises failed, please reload the page",
"draft_restored": "We restored the unsaved changes you left behind"
},
"materials": {
"apology": "We apologise, there was an error saving your lesson.",
@@ -330,7 +330,8 @@
"success": "Guardado exitosamente",
"submission_updated": "Envío actualizado a",
"error_fetching": "No se han podido recuperar los ejercicios. Vuelva a cargar la página.",
"passed_requires_points": "La finalización por aprobado necesita al menos un punto disponible."
"passed_requires_points": "La finalización por aprobado necesita al menos un punto disponible.",
"draft_restored": "Restauramos los cambios sin guardar que dejaste"
},
"poll": {
"info": {
@@ -330,7 +330,8 @@
"success": "Enregistré avec succès",
"submission_updated": "Soumission mise à jour pour",
"error_fetching": "La récupération des exercices a échoué, veuillez recharger la page",
"passed_requires_points": "La validation par réussite nécessite au moins un point disponible."
"passed_requires_points": "La validation par réussite nécessite au moins un point disponible.",
"draft_restored": "Nous avons restauré les modifications non enregistrées que vous aviez laissées"
},
"poll": {
"info": {
@@ -330,7 +330,8 @@
"success": "सफलतापूर्वक सहेजा गया",
"submission_updated": "सबमिशन को अद्यतन किया गया",
"error_fetching": "फ़ेचिंग अभ्यास विफल रहा, कृपया पृष्ठ पुनः लोड करें",
"passed_requires_points": "पास होकर पूर्ण करने के लिए कम से कम एक अंक उपलब्ध होना चाहिए।"
"passed_requires_points": "पास होकर पूर्ण करने के लिए कम से कम एक अंक उपलब्ध होना चाहिए।",
"draft_restored": "आपके द्वारा छोड़े गए सहेजे न किए गए परिवर्तन हमने पुनर्स्थापित कर दिए हैं"
},
"poll": {
"info": {
@@ -330,7 +330,8 @@
"success": "Zapisano pomyślnie",
"submission_updated": "Zgłoszenie zaktualizowane do",
"error_fetching": "Pobieranie ćwiczeń nie powiodło się. Załaduj stronę ponownie",
"passed_requires_points": "Ukończenie przez zaliczenie wymaga co najmniej jednego dostępnego punktu."
"passed_requires_points": "Ukończenie przez zaliczenie wymaga co najmniej jednego dostępnego punktu.",
"draft_restored": "Przywróciliśmy niezapisane zmiany, które pozostawiłeś"
},
"poll": {
"info": {
@@ -330,7 +330,8 @@
"success": "Salvo com sucesso",
"submission_updated": "Envio atualizado para",
"error_fetching": "Falha ao buscar exercícios. Atualize a página",
"passed_requires_points": "A conclusão por aprovação precisa de pelo menos um ponto disponível."
"passed_requires_points": "A conclusão por aprovação precisa de pelo menos um ponto disponível.",
"draft_restored": "Restauramos as alterações não salvas que você deixou"
},
"poll": {
"info": {
@@ -330,7 +330,8 @@
"success": "Сохранено успешно",
"submission_updated": "Представление обновлено до",
"error_fetching": "Не удалось загрузить упражнения. Обновите страницу.",
"passed_requires_points": "Для завершения по условию прохождения должен быть доступен хотя бы один балл."
"passed_requires_points": "Для завершения по условию прохождения должен быть доступен хотя бы один балл.",
"draft_restored": "Мы восстановили несохранённые изменения, которые вы оставили"
},
"poll": {
"info": {
@@ -330,7 +330,8 @@
"success": "Đã lưu thành công",
"submission_updated": "Đã cập nhật bài nộp vào",
"error_fetching": "Tìm nạp bài tập không thành công, vui lòng tải lại trang",
"passed_requires_points": "Hoàn thành theo điều kiện đạt yêu cầu cần có ít nhất một điểm khả dụng."
"passed_requires_points": "Hoàn thành theo điều kiện đạt yêu cầu cần có ít nhất một điểm khả dụng.",
"draft_restored": "Chúng tôi đã khôi phục các thay đổi chưa lưu mà bạn để lại"
},
"poll": {
"info": {
@@ -14,7 +14,7 @@
import { ZCourseUpdate } from '@cio/utils/validation/course/course';
import { validateWithTranslation } from '$lib/utils/validation';
import { snackbar } from '$features/ui/snackbar/store';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { openUpgradeModal } from '$lib/utils/store/upgrade-modal';
let errors = $state<Record<string, string>>({});
@@ -9,7 +9,9 @@
import { courseApi } from '$features/course/api';
import { isOrgStudent } from '$lib/utils/store/app';
import { hydrateExercisePageData } from '$features/course/utils/exercise-page-utils';
import { restoreExerciseDraft } from '$features/course/utils/exercise-draft';
import { getStudentContentLockReason } from '$features/ai-assistant/utils/content-ask-ai-bar';
import { snackbar } from '$features/ui/snackbar/store';
import { ContentType } from '@cio/utils/constants/content';
let { data = $bindable() } = $props();
@@ -47,6 +49,11 @@
}
hydrateExercisePageData(data.exercise, data.exerciseId);
// Puts back work stashed before an upgrade checkout redirect.
if (restoreExerciseDraft(data.courseId, data.exerciseId)) {
snackbar.success('snackbar.exercise.draft_restored');
}
});
</script>
@@ -20,7 +20,7 @@
import GraduationCapIcon from '@lucide/svelte/icons/graduation-cap';
import CompassIcon from '@lucide/svelte/icons/compass';
import AwardIcon from '@lucide/svelte/icons/award';
import { openUpgradeModal } from '$lib/utils/functions/org';
import { openUpgradeModal } from '$lib/utils/store/upgrade-modal';
import { isFreePlan } from '$lib/utils/store/org';
import { AI_CHAT_MODEL_STORAGE_KEY } from '$features/ai-assistant/utils/constants';
import type { TCourseType } from '@cio/db/types';
@@ -14,6 +14,17 @@ const SUBSCRIPTION_PRODUCT_IDS = new Set(
.filter((id): id is string => Boolean(id))
);
/**
* Sends the customer back to the page they upgraded from. Only in-app paths are accepted:
* `//host` and `/\host` are protocol-relative URLs that would redirect off-site.
*/
function getReturnPath(returnTo: string | null, fallbackPath: string) {
if (!returnTo || !returnTo.startsWith('/')) return fallbackPath;
if (returnTo.startsWith('//') || returnTo.startsWith('/\\')) return fallbackPath;
return returnTo;
}
export const GET = async ({ url, locals, cookies }: RequestEvent) => {
const { user } = locals;
@@ -60,11 +71,17 @@ export const GET = async ({ url, locals, cookies }: RequestEvent) => {
server: dev ? 'sandbox' : 'production'
});
const returnPath = getReturnPath(url.searchParams.get('returnTo'), `/org/${org.siteName}`);
const successUrl = new URL(returnPath, url.origin);
successUrl.searchParams.set('upgrade', 'true');
successUrl.searchParams.set('confirmation', 'true');
const checkout = await polar.checkouts.create({
products: [productId],
customerEmail: accountData.profile?.email ?? user.email,
customerName: accountData.profile?.fullname || undefined,
successUrl: `${url.origin}/org/${org.siteName}?upgrade=true&confirmation=true`,
successUrl: successUrl.toString(),
metadata: {
orgId: org.id,
orgSlug: org.siteName,