feat(billing): enforce free-plan student cap across the app (#733)

* feat(billing): enforce free-plan student cap across the app

Free (BASIC) plan orgs were able to exceed the advertised 20-student
limit because the cap existed only as dashboard cosmetic copy with no
server-side enforcement.

Add a single source of truth (getStudentLimit / STUDENT_LIMITS) and a
central guard (assertStudentCapacityOrThrow) wired into every path that
creates a new student-role org member: self-enrolment, course-invite
acceptance, bulk audience import, and org-invite acceptance. The guard
throws UPGRADE_REQUIRED (403) and notifies org admins by email (throttled
to once per org per day) when the limit is hit.

Surface the limit in the UI: disable the audience import button and the
course new-student invite section with upgrade banners for admins, and
show a "contact your administrator" message on the public enrol page for
students (who cannot upgrade). Expose a studentLimitReached flag from the
public course endpoint so the enrol button can be disabled proactively.

Add a one-time (manual, non-cron) db script,
db:notify-students-over-limit, to email admins of orgs already over the
limit at rollout.

* feat(billing): guard remaining student-creating paths against the cap

The initial cap enforcement missed several service entry points that also
create a new student-role org member. Add the capacity guard to:

- autoEnrollStudent (public org self-join on a tenant site)
- cohort enrollment (bulk group enrollment and single member accept)
- ensureProgramCourseAccess (program/cohort-granted course access)

For the cohort and program paths, only genuinely new members are counted
(existing org members are skipped) so re-enrolling current students is
never falsely blocked. The core path enforces via the shared limit check;
it throws UPGRADE_REQUIRED without the admin email since the queue lives
in the api layer.

* fix(billing): don't show cap block to existing org members on enroll page

The public course-by-slug endpoint is unauthenticated, so the enroll
page's studentLimitReached flag is org-wide and can't tell whether the
current viewer is already an org member. An existing student of a full
BASIC org would see Join disabled even though the backend correctly lets
existing members enroll without consuming a new seat.

Only pre-block anonymous visitors (would-be new joiners). Logged-in users
are governed by the membership-aware backend, which allows existing
members and returns UPGRADE_REQUIRED (surfaced via the enroll onError
mapping) for genuinely new students.

* feat(billing): deliver org plan-limits via /account with an extensible registry

Replace the ad-hoc, per-surface audience fetches (each calling getOrgAudience
just to read a count) with a single, future-proof delivery of org usage +
limits on the /account payload, gated to admin/tutor members (students never
receive limit data; a plain COUNT, no student rows).

Model it generically so future org limits (courses, etc.) drop in without
reshaping the payload or re-plumbing the frontend:
- New packages/utils plan-limits registry: PLAN_LIMIT_RESOURCES, PLAN_LIMITS
  (reuses STUDENT_LIMITS), getPlanLimit, and a JSON-safe ResourceUsage /
  OrgUsageLimits shape with isResourceLimitReached / resourceLimitRemaining.
- getAccountData attaches a per-org `limits` map for admin/tutor orgs.
- Store selectors currentOrgLimits / studentUsage / isStudentLimitReached;
  sidebars, upgrade card, People pages, and invite modals read these instead
  of fetching audience rows.

Bring cohort surfaces to parity with course: cohort People page banner and
cohort sidebar People upgrade icon, matching the course equivalents.

Also: close the upgrade modal that opened over the invite modal (drop the
`add` param via openUpgradeModalOver), redesign the sidebar upgrade card
(progress toward the student limit), add a Progress `variant`, and document
the no-complex-expressions-in-object-literals rule in AGENTS.md.

* refactor(billing): address PR review on the limits work

- Dedupe new student profile IDs before the cohort seat check so duplicate
  batch entries can't overcount against the cap (Greptile P1).
- Drive the org-sidebar upgrade indicator from a dynamic nav-item `upgrade`
  flag (audience config declares `upgradeResource: 'students'`; groups set it
  from a `limitsReached` map) instead of a hardcoded path check.
- Collapse the audience Import button into a single button with ternary
  `disabled`/`href`.
- Simplify the notify-students-over-limit upgrade link to app.classroomio.com.
- Drop the comment on the org `limits` field.
- Add .greptile/rules.md documenting the manual one-time db/src/scripts
  convention (operator-controlled reruns) so idempotency isn't reflagged.

* fix(billing): name the org in the enroll capacity message

Change the enroll-page student-limit message from "This course has reached
its student capacity" to "{orgName} has reached its student capacity", since
the cap is org-wide, not course-specific. Pass orgName from the loaded org on
the page and from the currentOrg store in the enroll onError snackbar.

* style(billing): use PremiumIcon for the Authentication upgrade indicator

Replace the RocketIcon on paid settings sub-items (e.g. Authentication) with
the PremiumIcon used on the Audience item, right-aligned (ml-auto) to match.
This commit is contained in:
Rotimi Best
2026-07-12 11:33:04 +01:00
committed by GitHub
parent 0b5247bed3
commit fd8bec3e70
50 changed files with 740 additions and 109 deletions
@@ -8,10 +8,11 @@
import { Button } from '@cio/ui/base/button';
import { ROLE } from '@cio/utils/constants';
import { t } from '$lib/utils/functions/translations';
import { currentOrg } from '$lib/utils/store/org';
import { currentOrg, isStudentLimitReached } from '$lib/utils/store/org';
import { orgApi } from '$features/org/api/org.svelte';
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
import { BulkEmailSection, ExistingStudentsSection, TutorSelectSection } from '$features/people/components';
import { UpgradeBanner } from '$features/ui';
import type { OrgStudent, Tutor } from '$features/people/utils/types';
import type { OrgTeamMember } from '$lib/utils/types/org';
import { cohortApi } from '../api';
@@ -220,7 +221,10 @@
onSearchValueChange={handleStudentSearch}
onAssign={assignExistingStudents}
/>
<BulkEmailSection onInvite={inviteNewStudents} />
{#if $isStudentLimitReached}
<UpgradeBanner removeParams={['add']}>{$t(`${INVITE_MODAL}.student_limit_reached`)}</UpgradeBanner>
{/if}
<BulkEmailSection onInvite={inviteNewStudents} disabled={$isStudentLimitReached} />
</div>
</UnderlineTabs.Content>
</UnderlineTabs.Root>
@@ -3,9 +3,16 @@
import { resolve } from '$app/paths';
import * as Sidebar from '@cio/ui/base/sidebar';
import { BackButton } from '@cio/ui';
import { HoverableItem, NewsFeedIcon, CourseIcon, PeopleIcon, SettingsIcon } from '@cio/ui/custom/moving-icons';
import {
HoverableItem,
NewsFeedIcon,
CourseIcon,
PeopleIcon,
PremiumIcon,
SettingsIcon
} from '@cio/ui/custom/moving-icons';
import { t } from '$lib/utils/functions/translations';
import { currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
import { currentOrgPath, isOrgAdmin, isStudentLimitReached } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app';
import { profile } from '$lib/utils/store/user';
import { cohortApi } from '$features/cohort/api';
@@ -88,6 +95,9 @@
<a href={resolve(item.url, {})} {...props}>
<Icon {isHovered} size={16} />
<span>{item.title}</span>
{#if item.id === 'people' && $isStudentLimitReached}
<PremiumIcon {isHovered} size={16} class="ui:text-primary ml-auto" />
{/if}
</a>
{/snippet}
</HoverableItem>
@@ -27,6 +27,7 @@ import { mapZodErrorsToTranslations } from '$lib/utils/validation';
import { profile } from '$lib/utils/store/user';
import { resolve } from '$app/paths';
import { snackbar } from '$features/ui/snackbar/store';
import { t } from '$lib/utils/functions/translations';
import { coursesApi } from './courses.svelte';
import { ROLE } from '@cio/utils/constants';
import { ContentType } from '@cio/utils/constants/content';
@@ -310,6 +311,17 @@ export class CourseApi extends BaseApiWithErrors {
onError: (result) => {
if (typeof result === 'string') {
snackbar.error('snackbar.invite.failed_join');
} else if (
typeof result === 'object' &&
result !== null &&
'code' in result &&
result.code === 'UPGRADE_REQUIRED'
) {
snackbar.error(
t.get('course.navItem.landing_page.enroll_page.student_limit_reached', {
orgName: get(currentOrg)?.name ?? ''
})
);
} else if (typeof result === 'object' && result !== null && 'error' in result) {
snackbar.error((result as { error: string }).error);
}
@@ -9,7 +9,7 @@
import { ROLE } from '@cio/utils/constants';
import { t } from '$lib/utils/functions/translations';
import { courseApi } from '$features/course/api';
import { currentOrg } from '$lib/utils/store/org';
import { currentOrg, isStudentLimitReached } from '$lib/utils/store/org';
import { peopleApi } from '$features/course/api';
import { orgApi } from '$features/org/api/org.svelte';
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
@@ -17,6 +17,7 @@
import type { OrgTeamMember } from '$lib/utils/types/org';
import type { OrgStudent, Tutor } from '$features/people/utils/types';
import { TutorSelectSection, ExistingStudentsSection, BulkEmailSection } from '$features/people/components';
import { UpgradeBanner } from '$features/ui';
let tutors = $state<Tutor[]>([]);
let selectedIds = $state<string[]>([]);
@@ -216,7 +217,11 @@
onAssign={assignExistingStudents}
/>
<BulkEmailSection onInvite={inviteNewStudents} />
{#if $isStudentLimitReached}
<UpgradeBanner removeParams={['add']}>{$t(`${INVITE_MODAL}.student_limit_reached`)}</UpgradeBanner>
{/if}
<BulkEmailSection onInvite={inviteNewStudents} disabled={$isStudentLimitReached} />
</div>
</UnderlineTabs.Content>
</UnderlineTabs.Root>
@@ -19,6 +19,7 @@
MarksIcon,
NewsFeedIcon,
PeopleIcon,
PremiumIcon,
SettingsIcon,
SubmissionIcon
} from '@cio/ui/custom/moving-icons';
@@ -31,7 +32,7 @@
import { NAV_IDS } from './constants';
import { complianceApi, courseApi } from '$features/course/api';
import { t } from '$lib/utils/functions/translations';
import { currentOrg, isFreePlan, currentOrgPath } from '$lib/utils/store/org';
import { currentOrg, isFreePlan, isStudentLimitReached, currentOrgPath } from '$lib/utils/store/org';
import { isStudentExperience } from '$lib/utils/store/app';
import { getNavItemRoute, getLessonsRoute } from '$features/course/utils/functions';
import { useSidebar } from '@cio/ui/base/sidebar';
@@ -48,6 +49,7 @@
let { path, id, isStudent = false }: Props = $props();
const coursesListPath = $derived($isStudentExperience ? '/lms/mylearning' : `${$currentOrgPath}/courses`);
const contentData = $derived(getCourseContent(courseApi.course));
const sidebar = useSidebar();
const contentCount = $derived.by(() => {
@@ -406,6 +408,9 @@
<Icon {isHovered} size={16} />
{/if}
<span>{item.title}</span>
{#if item.id === NAV_IDS.PEOPLE && $isStudentLimitReached}
<PremiumIcon {isHovered} size={16} class="ui:text-primary ml-auto" />
{/if}
</a>
{/snippet}
</HoverableItem>
@@ -9,9 +9,10 @@
import { Chip } from '@cio/ui/custom/chip';
import * as Avatar from '@cio/ui/base/avatar';
import { ComingSoon, RoleBasedSecurity } from '$features/ui';
import { ComingSoon, RoleBasedSecurity, UpgradeBanner } from '$features/ui';
import InvitationModal from '$features/course/components/people/invitation-modal.svelte';
import DeleteConfirmation from '$features/course/components/people/delete-confirmation.svelte';
import { isStudentLimitReached } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import type { CourseMembers, CourseMember } from '$features/course/utils/types';
@@ -101,6 +102,10 @@
<InvitationModal />
{#if $isStudentLimitReached}
<UpgradeBanner className="mb-2">{$t('course.navItem.people.invite_modal.student_limit_reached')}</UpgradeBanner>
{/if}
<DeleteConfirmation email={member.email || (member.profile && member.profile.email)} {deletePerson} />
<section class="space-y-2">
@@ -12,6 +12,7 @@
descriptionKey?: string;
submitKey?: string;
noEmailsKey?: string;
disabled?: boolean;
}
let {
@@ -19,7 +20,8 @@
titleKey = 'course.navItem.people.invite_modal.invite_new_students_title',
descriptionKey = 'course.navItem.people.invite_modal.invite_new_students_description',
submitKey = 'course.navItem.people.invite_modal.invite_new_students',
noEmailsKey = 'audience.import.snackbar_no_emails'
noEmailsKey = 'audience.import.snackbar_no_emails',
disabled = false
}: Props = $props();
let recipientCsv = $state('');
@@ -53,12 +55,18 @@
bind:value={recipientCsv}
rows={4}
className="w-full"
{disabled}
placeholder={$t('audience.import.emails_placeholder')}
/>
<CheckboxField name="send-invite-emails" label={$t('audience.import.send_email')} bind:checked={sendInviteEmails} />
<CheckboxField
name="send-invite-emails"
label={$t('audience.import.send_email')}
bind:checked={sendInviteEmails}
{disabled}
/>
</Field.Group>
<Button variant="secondary" onclick={inviteStudents} loading={isSubmitting}>
<Button variant="secondary" onclick={inviteStudents} loading={isSubmitting} {disabled}>
{$t(submitKey)}
</Button>
</Field.Set>
@@ -19,6 +19,7 @@ import type { AccountOrg } from '$features/app/types';
import BotIcon from '@lucide/svelte/icons/bot';
import type { Component } from 'svelte';
import { isActive } from '$lib/utils/functions/app';
import type { PlanLimitResource } from '@cio/utils/plans';
export interface NavItem {
title: string;
@@ -31,6 +32,8 @@ export interface NavItem {
matchPattern?: string;
items?: NavItem[]; // for nested items like settings
isPaid?: boolean; // Show upgrade indicator for free plan users
/** True when this item's plan-limited resource has hit its cap (drives the upgrade indicator). */
upgrade?: boolean;
disabled?: boolean;
// Metadata for breadcrumb generation
useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings)
@@ -50,6 +53,8 @@ export interface NavItemConfig {
supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug])
matchPattern?: string | ((orgSlug: string) => string); // Regex pattern for route matching
isPaid?: boolean; // Show upgrade indicator for free plan users
/** Plan-limited resource this item represents; when that resource is at its cap, `upgrade` is set. */
upgradeResource?: PlanLimitResource;
group?: string | null; // Group label key for sidebar grouping
}
@@ -163,6 +168,7 @@ export const baseNavConfig: NavItemConfig[] = [
titleKey: 'org_navigation.audience',
path: '/audience',
icon: PeopleIcon,
upgradeResource: 'students',
matchPattern: '^/org/[^/]+/audience(/.*)?$' // Matches nested routes
},
{
@@ -376,7 +382,8 @@ export function getOrgNavigationGroups(
currentOrg: AccountOrg,
isOrgAdmin: boolean | null,
t: (key: string) => string,
pagePathname: string
pagePathname: string,
limitsReached: Partial<Record<PlanLimitResource, boolean>> = {}
): NavGroup[] {
const pathnameOnly = pagePathname.split('?')[0];
const groupedMap = new Map<string | null, NavItem[]>();
@@ -413,7 +420,8 @@ export function getOrgNavigationGroups(
useHashUrl: config.useHashUrl,
nestedRoutes: config.nestedRoutes,
supportsDynamicSegment: config.supportsDynamicSegment,
isPaid: config.isPaid
isPaid: config.isPaid,
upgrade: config.upgradeResource ? Boolean(limitsReached[config.upgradeResource]) : undefined
};
if (visibleSubConfigs.length > 0) {
@@ -1,16 +1,17 @@
<script lang="ts">
import { currentOrgPath, currentOrg, isOrgAdmin, isFreePlan } from '$lib/utils/store/org';
import { currentOrgPath, currentOrg, isOrgAdmin, isFreePlan, isStudentLimitReached } from '$lib/utils/store/org';
import * as Collapsible from '@cio/ui/base/collapsible';
import * as Sidebar from '@cio/ui/base/sidebar';
import { t } from '$lib/utils/functions/translations';
import { page } from '$app/state';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
import RocketIcon from '@lucide/svelte/icons/rocket';
import { getOrgNavigationGroups } from '$features/ui/navigation/org-navigation';
import { HoverableItem } from '@cio/ui/custom/moving-icons';
import { HoverableItem, PremiumIcon } from '@cio/ui/custom/moving-icons';
const groups = $derived(
getOrgNavigationGroups($currentOrgPath, $currentOrg, $isOrgAdmin, $t, page.url.pathname + page.url.search)
getOrgNavigationGroups($currentOrgPath, $currentOrg, $isOrgAdmin, $t, page.url.pathname + page.url.search, {
students: $isStudentLimitReached
})
);
</script>
@@ -85,6 +86,9 @@
{:else}
<span>{item.title}</span>
{/if}
{#if item.upgrade}
<PremiumIcon {isHovered} size={16} class="ui:text-primary ml-auto" />
{/if}
</a>
{/if}
{/snippet}
@@ -102,7 +106,7 @@
<a href={subItem.url} {...props}>
<span>{subItem.title}</span>
{#if subItem.isPaid && $isFreePlan}
<RocketIcon class="text-primary size-4" />
<PremiumIcon size={16} class="ui:text-primary ml-auto" />
{/if}
</a>
{/snippet}
@@ -1,28 +1,50 @@
<script lang="ts">
import { Button } from '@cio/ui/base/button';
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 { isFreePlan } from '$lib/utils/store/org';
import { currentOrgMaxAudience, isFreePlan, studentUsage } from '$lib/utils/store/org';
import { useSidebar } from '@cio/ui/base/sidebar';
const sidebar = useSidebar();
const studentCount = $derived($studentUsage?.used ?? 0);
const studentLimit = $derived($studentUsage?.limit ?? $currentOrgMaxAudience);
const studentsLeft = $derived(Math.max(0, studentLimit - studentCount));
</script>
{#if $isFreePlan}
<div class="animate-icon">
{#if sidebar.open && !sidebar.isMobile}
<div
class="animate-gradient-border mx-2 flex flex-col items-center justify-center gap-4 rounded-md px-2 py-6 text-center transition-all ease-in-out"
>
<RocketIcon class="rocket-launch my-3 size-6" color="var(--primary)" />
<span class="flex flex-col gap-1">
<p class="text-base font-medium">{$t('org_navigation.early_adopter')}</p>
<p class="ui:text-muted-foreground text-xs">{$t('org_navigation.unlock')}</p>
</span>
<Button data-sidebar="upgrade-trigger" data-slot="upgrade-trigger" type="button" onclick={openUpgradeModal}>
<RocketIcon class="rocket-launch custom size-4" />
{$t('org_navigation.upgrade')}
<div class="animate-gradient-border mx-2 flex flex-col gap-3 rounded-md p-3 transition-all ease-in-out">
<div class="flex items-center gap-2">
<span class="ui:bg-primary/10 flex size-8 shrink-0 items-center justify-center rounded-full">
<RocketIcon class="rocket-launch size-4" color="var(--primary)" />
</span>
<p class="text-sm">{$t('org_navigation.early_adopter')}</p>
</div>
<Progress value={studentCount} max={studentLimit} variant="default" />
<div class="flex items-center justify-between">
<span class="ui:text-muted-foreground text-xs">
{$t('org_navigation.upgrade_students', { studentCount, studentLimit })}
</span>
<span class="ui:text-muted-foreground text-xs">
{$t('org_navigation.upgrade_left', { count: studentsLeft })}
</span>
</div>
<Button
data-sidebar="upgrade-trigger"
data-slot="upgrade-trigger"
variant="outline"
class="w-full"
type="button"
onclick={openUpgradeModal}
>
{$t('org_navigation.upgrade_now')}
</Button>
</div>
{:else}
@@ -1,5 +1,5 @@
<script lang="ts">
import { openUpgradeModal } from '$lib/utils/functions/org';
import { openUpgradeModalOver } from '$lib/utils/functions/org';
import { isFreePlan } from '$lib/utils/store/org';
import * as Item from '@cio/ui/base/item';
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
@@ -9,10 +9,12 @@
className?: string;
onClick?: any;
visible?: boolean;
/** Query params to drop when opening the modal (e.g. `['add']` to close the invite modal first). */
removeParams?: string[];
children?: import('svelte').Snippet;
}
let { className = '', onClick = () => {}, visible, ...restProps }: Props = $props();
let { className = '', onClick = () => {}, visible, removeParams = [], ...restProps }: Props = $props();
const shouldShow = $derived(visible ?? $isFreePlan);
</script>
@@ -26,7 +28,7 @@
{...props}
onclick={() => {
onClick();
openUpgradeModal();
openUpgradeModalOver(removeParams);
}}
>
<Item.Media variant="icon">
+19 -3
View File
@@ -14,12 +14,28 @@ export function generateSitename(orgName: string): string {
.replace(/[^a-zA-Z0-9-]/g, '');
}
export function openUpgradeModal() {
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');
const searchParams = url.searchParams.toString();
goto(resolve(url.pathname + `?${url.searchParams.toString()}`, {}));
}
goto(resolve(window.location.pathname + `?${searchParams}`, {}));
/** 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);
}
+17 -9
View File
@@ -4,7 +4,7 @@ import merge from 'lodash/merge';
import type { AccountOrg } from '$features/app/types';
import type { OrgTeamMember } from '../types/org';
import { canUsePublicApi, isOrgOnFreePlan, PLAN } from '@cio/utils/plans';
import { canUsePublicApi, getStudentLimit, isOrgOnFreePlan, isResourceLimitReached, PLAN } from '@cio/utils/plans';
import { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
import { BRAND_ROOT_DOMAIN, ROLE, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
import { STEPS } from '../constants/quiz';
@@ -160,14 +160,22 @@ export const hasPublicApiAccess = derived(currentOrg, ($currentOrg) => {
return canUsePublicApi(plan?.planName, PUBLIC_IS_SELFHOSTED === 'true');
});
export const currentOrgMaxAudience = derived(currentOrgPlan, ($plan) =>
!$plan
? 20
: $plan.planName === PLAN.EARLY_ADOPTER
? 10000
: $plan.planName === PLAN.ENTERPRISE
? Number.MAX_SAFE_INTEGER
: 20
export const currentOrgMaxAudience = derived(currentOrgPlan, ($plan) => {
const limit = getStudentLimit($plan?.planName);
return Number.isFinite(limit) ? limit : Number.MAX_SAFE_INTEGER;
});
/**
* Per-resource org usage + limits delivered by `/account` (admin/tutor only).
* `studentUsage`/`isStudentLimitReached` are convenience selectors; generic
* consumers can read `$currentOrgLimits.<resource>` with `isResourceLimitReached`.
*/
export const currentOrgLimits = derived(currentOrg, ($currentOrg) => $currentOrg.limits ?? {});
export const studentUsage = derived(currentOrg, ($currentOrg) => $currentOrg.limits?.students);
export const isStudentLimitReached = derived(currentOrg, ($currentOrg) =>
isResourceLimitReached($currentOrg.limits?.students)
);
// Quiz
@@ -709,7 +709,8 @@
"cancel": "Annuller",
"confirm": "Slet",
"snackbar_success": "Medlem slettet"
}
},
"import_limit_reached": "Du har nået din plans elevgrænse. Opgrader for at importere flere elever."
},
"community": {
"title": "Fællesskab",
@@ -894,7 +895,8 @@
"back_to_course": "Tilbage til kurset",
"join_course": "Deltag i kursus",
"enrolled_success": "Du er tilmeldt dette kursus.",
"enrolled_verify_email": "Du er tilmeldt! Bekræft din e-mail for at få adgang til dette kursus."
"enrolled_verify_email": "Du er tilmeldt! Bekræft din e-mail for at få adgang til dette kursus.",
"student_limit_reached": "{orgName} har nået sin elevkapacitet. Kontakt venligst din organisations administrator."
},
"pricing_section": {
"free": "Gratis",
@@ -1226,7 +1228,8 @@
"multi_use_7d": "Multi-brug (7 dage)",
"multi_use_30d": "Multi-brug (30 dage)",
"custom": "Brugerdefineret"
}
},
"student_limit_reached": "Du har nået din plans elevgrænse. Opgrader for at invitere flere elever."
}
},
"marks": {
@@ -2811,7 +2814,10 @@
"stats": "Statistik",
"analytics": "Analytics",
"compliance": "Overholdelse",
"cohorts": "Programmer"
"cohorts": "Programmer",
"upgrade_now": "Opgrader nu",
"upgrade_students": "{studentCount} af {studentLimit} elever",
"upgrade_left": "{count} tilbage"
},
"media_manager": {
"page_title": "Media Manager - ClassroomIO",
@@ -3636,7 +3642,8 @@
"widgets": "Widgets",
"tags": "Tags",
"students": "studerende",
"settings": "Indstillinger"
"settings": "Indstillinger",
"cohorts": "Kohorter"
}
}
},
@@ -709,7 +709,8 @@
"cancel": "Abbrechen",
"confirm": "Löschen",
"snackbar_success": "Mitglied gelöscht"
}
},
"import_limit_reached": "Sie haben das Studentenlimit Ihres Plans erreicht. Führen Sie ein Upgrade durch, um mehr Schüler zu importieren."
},
"community": {
"title": "Gemeinschaft",
@@ -894,7 +895,8 @@
"back_to_course": "Zurück zum Kurs",
"join_course": "Nehmen Sie am Kurs teil",
"enrolled_success": "Sie sind für diesen Kurs eingeschrieben.",
"enrolled_verify_email": "Sie sind eingeschrieben! Bestätigen Sie Ihre E-Mail, um auf diesen Kurs zuzugreifen."
"enrolled_verify_email": "Sie sind eingeschrieben! Bestätigen Sie Ihre E-Mail, um auf diesen Kurs zuzugreifen.",
"student_limit_reached": "{orgName} hat seine Studentenkapazität erreicht. Bitte wenden Sie sich an den Administrator Ihrer Organisation."
},
"pricing_section": {
"free": "Kostenlos",
@@ -1226,7 +1228,8 @@
"multi_use_7d": "Mehrfachnutzung (7 Tage)",
"multi_use_30d": "Mehrfachnutzung (30 Tage)",
"custom": "Benutzerdefiniert"
}
},
"student_limit_reached": "Sie haben das Studentenlimit Ihres Plans erreicht. Führen Sie ein Upgrade durch, um weitere Schüler einzuladen."
}
},
"marks": {
@@ -2811,7 +2814,10 @@
"stats": "Statistiken",
"analytics": "Analytik",
"compliance": "Compliance",
"cohorts": "Programme"
"cohorts": "Programme",
"upgrade_now": "Jetzt upgraden",
"upgrade_students": "{studentCount} von {studentLimit} Studenten",
"upgrade_left": "{count} übrig"
},
"media_manager": {
"page_title": "Medienmanager - ClassroomIO",
@@ -3636,7 +3642,8 @@
"widgets": "Widgets",
"tags": "Schlagworte",
"students": "Studenten",
"settings": "Einstellungen"
"settings": "Einstellungen",
"cohorts": "Kohorten"
}
}
},
@@ -1259,6 +1259,9 @@
"early_adopter": "Become an Early Adopter",
"unlock": "Unlock unlimited features and invest in our future",
"upgrade": "Upgrade Now",
"upgrade_now": "Upgrade now",
"upgrade_students": "{studentCount} of {studentLimit} students",
"upgrade_left": "{count} left",
"cohorts": "Cohorts"
},
"pricing": {
@@ -3054,6 +3057,7 @@
"to_add": "To add tutor to your organization,",
"go_to": "go to organization settings",
"invite_students": "Add Students",
"student_limit_reached": "You've reached your plan's student limit. Upgrade to invite more students.",
"via_link": "You can invite students via an invite link",
"via_qr": "You can invite students via QR",
"download_qr": "Download QR code",
@@ -3373,7 +3377,8 @@
"back_to_course": "Back to course",
"join_course": "Join Course",
"enrolled_success": "You're enrolled in this course.",
"enrolled_verify_email": "You're enrolled! Verify your email to access this course."
"enrolled_verify_email": "You're enrolled! Verify your email to access this course.",
"student_limit_reached": "{orgName} has reached its student capacity. Please contact your organization administrator."
},
"course_content": "Course content",
"modules": "modules",
@@ -3558,6 +3563,7 @@
"page_subtitle": "See who belongs to your org, who is learning, who is invited, and how to reach them.",
"export": "Export",
"import_users": "Import Users",
"import_limit_reached": "You've reached your plan's student limit. Upgrade to import more students.",
"no_audience": "No audience to manage",
"manage": "Manage all your students here and stay connected with them",
"name": "Name",
@@ -709,7 +709,8 @@
"cancel": "Cancelar",
"confirm": "Eliminar",
"snackbar_success": "Miembro eliminado"
}
},
"import_limit_reached": "Has alcanzado el límite de estudiantes de tu plan. Actualice para importar más estudiantes."
},
"community": {
"title": "Comunidad",
@@ -894,7 +895,8 @@
"back_to_course": "volver al curso",
"join_course": "Unirse al curso",
"enrolled_success": "Estás inscrito en este curso.",
"enrolled_verify_email": "¡Estás inscrito! Verifica tu correo electrónico para acceder a este curso."
"enrolled_verify_email": "¡Estás inscrito! Verifica tu correo electrónico para acceder a este curso.",
"student_limit_reached": "{orgName} ha alcanzado su capacidad de estudiantes. Comuníquese con el administrador de su organización."
},
"pricing_section": {
"free": "Gratis",
@@ -1226,7 +1228,8 @@
"multi_use_7d": "Multiuso (7 días)",
"multi_use_30d": "Multiuso (30 días)",
"custom": "personalizado"
}
},
"student_limit_reached": "Has alcanzado el límite de estudiantes de tu plan. Actualice para invitar a más estudiantes."
}
},
"marks": {
@@ -2811,7 +2814,10 @@
"stats": "Estadísticas",
"analytics": "Analítica",
"compliance": "Cumplimiento",
"cohorts": "Programas"
"cohorts": "Programas",
"upgrade_now": "Actualiza ahora",
"upgrade_students": "{studentCount} de {studentLimit} estudiantes",
"upgrade_left": "{count} restante"
},
"media_manager": {
"page_title": "Administrador de medios - ClassroomIO",
@@ -3636,7 +3642,8 @@
"widgets": "widgets",
"tags": "Etiquetas",
"students": "estudiantes",
"settings": "Configuración"
"settings": "Configuración",
"cohorts": "Cohortes"
}
}
},
@@ -709,7 +709,8 @@
"cancel": "Annuler",
"confirm": "Supprimer",
"snackbar_success": "Membre supprimé"
}
},
"import_limit_reached": "Vous avez atteint la limite d'étudiants de votre plan. Mettez à niveau pour importer plus d'étudiants."
},
"community": {
"title": "Communauté",
@@ -894,7 +895,8 @@
"back_to_course": "Retour au cours",
"join_course": "Rejoindre le cours",
"enrolled_success": "Vous êtes inscrit à ce cours.",
"enrolled_verify_email": "Vous êtes inscrit ! Vérifiez votre e-mail pour accéder à ce cours."
"enrolled_verify_email": "Vous êtes inscrit ! Vérifiez votre e-mail pour accéder à ce cours.",
"student_limit_reached": "{orgName} a atteint sa capacité d'accueil. Veuillez contacter l'administrateur de votre organisation."
},
"pricing_section": {
"free": "Gratuit",
@@ -1226,7 +1228,8 @@
"multi_use_7d": "Multi-usage (7 jours)",
"multi_use_30d": "Multi-usage (30 jours)",
"custom": "Personnalisé"
}
},
"student_limit_reached": "Vous avez atteint la limite d'étudiants de votre plan. Mettez à niveau pour inviter plus d'étudiants."
}
},
"marks": {
@@ -2811,7 +2814,10 @@
"stats": "Statistiques",
"analytics": "Analyse",
"compliance": "Conformité",
"cohorts": "Programmes"
"cohorts": "Programmes",
"upgrade_now": "Mettre à niveau maintenant",
"upgrade_students": "{studentCount} sur {studentLimit} étudiants",
"upgrade_left": "Il reste {count}"
},
"media_manager": {
"page_title": "Gestionnaire de médias - ClassroomIO",
@@ -3636,7 +3642,8 @@
"widgets": "Widgets",
"tags": "Balises",
"students": "Étudiants",
"settings": "Paramètres"
"settings": "Paramètres",
"cohorts": "Cohortes"
}
}
},
@@ -709,7 +709,8 @@
"cancel": "रद्द करें",
"confirm": "हटाएँ",
"snackbar_success": "सदस्य हटा दिया गया"
}
},
"import_limit_reached": "आप अपनी योजना की छात्र सीमा तक पहुँच गए हैं। अधिक छात्रों को आयात करने के लिए अपग्रेड करें।"
},
"community": {
"title": "समुदाय",
@@ -894,7 +895,8 @@
"back_to_course": "पाठ्यक्रम पर वापस जाएँ",
"join_course": "पाठ्यक्रम में शामिल हों",
"enrolled_success": "आप इस पाठ्यक्रम में नामांकित हैं।",
"enrolled_verify_email": "आप नामांकित हैं! इस पाठ्यक्रम तक पहुँचने के लिए अपना ईमेल सत्यापित करें।"
"enrolled_verify_email": "आप नामांकित हैं! इस पाठ्यक्रम तक पहुँचने के लिए अपना ईमेल सत्यापित करें।",
"student_limit_reached": "{orgName} अपनी छात्र क्षमता तक पहुंच गया है। कृपया अपने संगठन प्रशासक से संपर्क करें."
},
"pricing_section": {
"free": "निःशुल्क",
@@ -1226,7 +1228,8 @@
"multi_use_7d": "बहु-उपयोग (7 दिन)",
"multi_use_30d": "बहु-उपयोग (30 दिन)",
"custom": "कस्टम"
}
},
"student_limit_reached": "आप अपनी योजना की छात्र सीमा तक पहुँच गए हैं। अधिक छात्रों को आमंत्रित करने के लिए अपग्रेड करें।"
}
},
"marks": {
@@ -2811,7 +2814,10 @@
"stats": "आँकड़े",
"analytics": "विश्लेषिकी",
"compliance": "अनुपालन",
"cohorts": "कार्यक्रम"
"cohorts": "कार्यक्रम",
"upgrade_now": "अभी अपग्रेड करें",
"upgrade_students": "{studentLimit} छात्रों की {studentCount}",
"upgrade_left": "{गिनती} बाएँ"
},
"media_manager": {
"page_title": "मीडिया मैनेजर - क्लासरूमआईओ",
@@ -3636,7 +3642,8 @@
"widgets": "विजेट्स",
"tags": "टैग",
"students": "छात्र",
"settings": "सेटिंग्स"
"settings": "सेटिंग्स",
"cohorts": "समानता रखने वाले लोग"
}
}
},
@@ -709,7 +709,8 @@
"cancel": "Anuluj",
"confirm": "Usuń",
"snackbar_success": "Członek usunięty"
}
},
"import_limit_reached": "Osiągnąłeś limit studentów w swoim planie. Uaktualnij, aby zaimportować więcej uczniów."
},
"community": {
"title": "Społeczność",
@@ -894,7 +895,8 @@
"back_to_course": "Powrót na kurs",
"join_course": "Dołącz do kursu",
"enrolled_success": "Zapisano Cię na ten kurs.",
"enrolled_verify_email": "Jesteś zapisany! Zweryfikuj swój e-mail, aby uzyskać dostęp do tego kursu."
"enrolled_verify_email": "Jesteś zapisany! Zweryfikuj swój e-mail, aby uzyskać dostęp do tego kursu.",
"student_limit_reached": "Organizacja {orgName} osiągnęła już limit uczniów. Skontaktuj się z administratorem organizacji."
},
"pricing_section": {
"free": "Bezpłatny",
@@ -1226,7 +1228,8 @@
"multi_use_7d": "Wielokrotnego użytku (7 dni)",
"multi_use_30d": "Wielokrotnego użytku (30 dni)",
"custom": "Niestandardowe"
}
},
"student_limit_reached": "Osiągnąłeś limit studentów w swoim planie. Uaktualnij, aby zaprosić więcej uczniów."
}
},
"marks": {
@@ -2811,7 +2814,10 @@
"stats": "Statystyki",
"analytics": "Analityka",
"compliance": "Zgodność",
"cohorts": "Programy"
"cohorts": "Programy",
"upgrade_now": "Uaktualnij teraz",
"upgrade_students": "{studentCount} z {studentLimit} uczniów",
"upgrade_left": "Pozostało {count}"
},
"media_manager": {
"page_title": "Menedżer multimediów - ClassroomIO",
@@ -3636,7 +3642,8 @@
"widgets": "Widżety",
"tags": "Tagi",
"students": "Studenci",
"settings": "Ustawienia"
"settings": "Ustawienia",
"cohorts": "Kohorty"
}
}
},
@@ -709,7 +709,8 @@
"cancel": "Cancelar",
"confirm": "Excluir",
"snackbar_success": "Membro excluído"
}
},
"import_limit_reached": "Você atingiu o limite de alunos do seu plano. Atualize para importar mais alunos."
},
"community": {
"title": "Comunidade",
@@ -894,7 +895,8 @@
"back_to_course": "De volta ao curso",
"join_course": "Participe do curso",
"enrolled_success": "Você está inscrito neste curso.",
"enrolled_verify_email": "Você está inscrito! Verifique seu e-mail para acessar este curso."
"enrolled_verify_email": "Você está inscrito! Verifique seu e-mail para acessar este curso.",
"student_limit_reached": "{orgName} atingiu sua capacidade de alunos. Entre em contato com o administrador da sua organização."
},
"pricing_section": {
"free": "Grátis",
@@ -1226,7 +1228,8 @@
"multi_use_7d": "Multiuso (7 dias)",
"multi_use_30d": "Multiuso (30 dias)",
"custom": "Personalizado"
}
},
"student_limit_reached": "Você atingiu o limite de alunos do seu plano. Atualize para convidar mais alunos."
}
},
"marks": {
@@ -2811,7 +2814,10 @@
"stats": "Estatísticas",
"analytics": "Análise",
"compliance": "Conformidade",
"cohorts": "Programas"
"cohorts": "Programas",
"upgrade_now": "Atualize agora",
"upgrade_students": "{studentCount} de {studentLimit} alunos",
"upgrade_left": "{contagem} restantes"
},
"media_manager": {
"page_title": "Gerenciador de mídia - ClassroomIO",
@@ -3636,7 +3642,8 @@
"widgets": "Widgets",
"tags": "Etiquetas",
"students": "Alunos",
"settings": "Configurações"
"settings": "Configurações",
"cohorts": "Coortes"
}
}
},
@@ -709,7 +709,8 @@
"cancel": "Отмена",
"confirm": "Удалить",
"snackbar_success": "Участник удален"
}
},
"import_limit_reached": "Вы достигли лимита учащихся, предусмотренного вашим планом. Обновите, чтобы импортировать больше студентов."
},
"community": {
"title": "Сообщество",
@@ -894,7 +895,8 @@
"back_to_course": "Вернуться к курсу",
"join_course": "Присоединиться к курсу",
"enrolled_success": "Вы записаны на этот курс.",
"enrolled_verify_email": "Вы записаны! Подтвердите email, чтобы получить доступ к курсу."
"enrolled_verify_email": "Вы записаны! Подтвердите email, чтобы получить доступ к курсу.",
"student_limit_reached": "В {orgName} достигнута вместимость учащихся. Обратитесь к администратору вашей организации."
},
"pricing_section": {
"free": "Бесплатно",
@@ -1226,7 +1228,8 @@
"multi_use_7d": "Многоразовое использование (7 дней)",
"multi_use_30d": "Многоразовое использование (30 дней)",
"custom": "Пользовательский"
}
},
"student_limit_reached": "Вы достигли лимита учащихся, предусмотренного вашим планом. Обновите, чтобы пригласить больше студентов."
}
},
"marks": {
@@ -2811,7 +2814,10 @@
"stats": "Статистика",
"analytics": "Аналитика",
"compliance": "Соответствие",
"cohorts": "Программы"
"cohorts": "Программы",
"upgrade_now": "Обновите сейчас",
"upgrade_students": "{studentCount} из {studentLimit} студентов",
"upgrade_left": "{count} осталось"
},
"media_manager": {
"page_title": "Медиа-менеджер — ClassroomIO",
@@ -3636,7 +3642,8 @@
"widgets": "Виджеты",
"tags": "Теги",
"students": "Студенты",
"settings": "Настройки"
"settings": "Настройки",
"cohorts": "когорты"
}
}
},
@@ -709,7 +709,8 @@
"cancel": "Hủy bỏ",
"confirm": "Xóa",
"snackbar_success": "Thành viên đã bị xóa"
}
},
"import_limit_reached": "Bạn đã đạt đến giới hạn sinh viên trong kế hoạch của mình. Nâng cấp để nhập thêm sinh viên."
},
"community": {
"title": "cộng đồng",
@@ -894,7 +895,8 @@
"back_to_course": "Quay lại khóa học",
"join_course": "Tham gia khóa học",
"enrolled_success": "Bạn đã ghi danh khóa học này.",
"enrolled_verify_email": "Bạn đã ghi danh! Xác minh email để truy cập khóa học này."
"enrolled_verify_email": "Bạn đã ghi danh! Xác minh email để truy cập khóa học này.",
"student_limit_reached": "{orgName} đã đạt đủ số lượng sinh viên. Vui lòng liên hệ với quản trị viên tổ chức của bạn."
},
"pricing_section": {
"free": "miễn phí",
@@ -1226,7 +1228,8 @@
"multi_use_7d": "Sử dụng nhiều lần (7 ngày)",
"multi_use_30d": "Sử dụng nhiều lần (30 ngày)",
"custom": "tùy chỉnh"
}
},
"student_limit_reached": "Bạn đã đạt đến giới hạn sinh viên trong kế hoạch của mình. Nâng cấp để mời thêm sinh viên."
}
},
"marks": {
@@ -2811,7 +2814,10 @@
"stats": "Thống kê",
"analytics": "Phân tích",
"compliance": "Tuân thủ",
"cohorts": "Chương trình"
"cohorts": "Chương trình",
"upgrade_now": "Nâng cấp ngay bây giờ",
"upgrade_students": "{studentCount} trong số {studentLimit} sinh viên",
"upgrade_left": "còn {count}"
},
"media_manager": {
"page_title": "Quản lý truyền thông - ClassroomIO",
@@ -3636,7 +3642,8 @@
"widgets": "Widget",
"tags": "Thẻ",
"students": "Sinh viên",
"settings": "Cài đặt"
"settings": "Cài đặt",
"cohorts": "Nhóm thuần tập"
}
}
},
@@ -21,8 +21,9 @@
import { shortenName } from '$lib/utils/functions/string';
import { ROLE } from '@cio/utils/constants';
import { ROLE_LABEL } from '$lib/utils/constants/roles';
import { isOrgAdmin } from '$lib/utils/store/org';
import { isOrgAdmin, isStudentLimitReached } from '$lib/utils/store/org';
import { profile } from '$lib/utils/store/user';
import { UpgradeBanner } from '$features/ui';
import InviteMembersModal from '$features/cohort/components/invite-members-modal.svelte';
import GoalsOverviewTiles from '$features/cohort/components/goals/goals-overview-tiles.svelte';
import { cohortApi } from '$features/cohort/api';
@@ -148,6 +149,10 @@
<Page.Body>
{#snippet child()}
{#if $isStudentLimitReached}
<UpgradeBanner className="mb-2">{$t('course.navItem.people.invite_modal.student_limit_reached')}</UpgradeBanner>
{/if}
<GoalsOverviewTiles cohortId={data.cohortId} />
<section class="space-y-2">
@@ -18,6 +18,7 @@
}
const audienceLength = $derived(data.pagination?.total || 0);
const atStudentLimit = $derived(audienceLength >= $currentOrgMaxAudience);
</script>
<svelte:head>
@@ -41,7 +42,12 @@
<Button variant="outline" onclick={exportAudience} disabled={isLoading} loading={isLoading}>
{$t('audience.export')}
</Button>
<Button variant="secondary" href={resolve(`${page.url.pathname}/import`, {})}>
<Button
variant="secondary"
disabled={atStudentLimit}
href={atStudentLimit ? '#' : resolve(`${page.url.pathname}/import`, {})}
title={atStudentLimit ? $t('audience.import_limit_reached') : undefined}
>
{$t('audience.import_users')}
</Button>
</Page.Action>
@@ -51,6 +51,7 @@ export const load = async ({ params, url }) => {
inviteEmail: data.inviteContext?.recipientEmail ?? null,
inviteEmailExists: data.inviteContext?.recipientExists ?? false,
requiresPaymentOrInvite: false,
studentLimitReached: false,
token: inviteToken
};
}
@@ -97,6 +98,7 @@ export const load = async ({ params, url }) => {
currentOrg,
invite: null,
requiresPaymentOrInvite: !isFree,
studentLimitReached: courseData.studentLimitReached ?? false,
token: null as string | null
};
};
@@ -54,8 +54,14 @@
const inviteStatus = $derived(data.invite?.status ?? 'INVALID');
const hasActiveInvite = $derived(Boolean(data.invite) && inviteStatus === 'ACTIVE');
// The org-wide cap only applies to *new* students joining the org. We can't
// know the viewer's membership here (public, unauthenticated load), so only
// pre-block anonymous visitors (would-be new joiners). Logged-in users are
// governed by the membership-aware backend, which allows existing members
// and returns UPGRADE_REQUIRED (surfaced via the enroll onError) for new ones.
const blocksNewSignup = $derived(Boolean(data.studentLimitReached) && !isLoggedIn);
const canJoinCourse = $derived(
data.requiresPaymentOrInvite
data.requiresPaymentOrInvite || blocksNewSignup
? false
: (hasActiveInvite || (!data.invite && data.course?.allowNewStudent !== false)) &&
data.course?.status === 'ACTIVE' &&
@@ -63,6 +69,11 @@
);
function getBlockedMessage(): string {
if (blocksNewSignup) {
return t.get('course.navItem.landing_page.enroll_page.student_limit_reached', {
orgName: data.currentOrg?.name ?? ''
});
}
if (data.requiresPaymentOrInvite) {
return t.get('course.navItem.landing_page.enroll_page.requires_payment_or_invite');
}