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:
@@ -0,0 +1,18 @@
|
|||||||
|
# Greptile review rules
|
||||||
|
|
||||||
|
Repo-specific conventions for code review. See https://www.greptile.com/docs/code-review/custom-standards.
|
||||||
|
|
||||||
|
## One-time admin/ops scripts (`packages/db/src/scripts/**`)
|
||||||
|
|
||||||
|
These are manual, operator-invoked, run-once maintenance scripts (backfills, notifications, migrations). They are **not** part of the request/runtime path.
|
||||||
|
|
||||||
|
- **Do not flag missing idempotency / "can resend on rerun" / "can re-process on rerun".** Re-run behavior is the operator's responsibility, controlled by an explicit `--execute` flag (dry-run by default). This is intentional and not a bug.
|
||||||
|
- These scripts live in `packages/db` and have **no access to the BullMQ queue or Redis** (`@cio/jobs`), so they send email directly via `@cio/email` (`sendEmail`) rather than the runtime's queued, daily-idempotent path. Do not suggest routing them through the queue as a required change.
|
||||||
|
|
||||||
|
## Plan/usage limit checks
|
||||||
|
|
||||||
|
- The org student-cap guard (`assertStudentCapacityOrThrow`) reads the count outside the insert transaction. This is a known, accepted best-effort TOCTOU consistent with the existing workspace-allowance check — do not re-flag it as a race-condition bug unless hard atomic enforcement is explicitly requested.
|
||||||
|
|
||||||
|
## Code style
|
||||||
|
|
||||||
|
- Never call an `await` or a complex/multi-call expression inside an object literal (construction or update). Assign to a well-named local first, then reference it. (Also documented in `AGENTS.md`.)
|
||||||
@@ -25,6 +25,15 @@ When a task requires factual information (API specifications, context window siz
|
|||||||
- Use kebab-case for files (e.g. `user-profile.svelte`, `org.svelte.ts`).
|
- Use kebab-case for files (e.g. `user-profile.svelte`, `org.svelte.ts`).
|
||||||
- **Variables must read clearly at the call site.** Avoid opaque single-letter or ultra-short names (`d`, `x`, `tmp`) unless they match a universal convention (e.g. `i` in a trivial index loop). Prefer names that state intent (`deadlineDate`, `parsedUrl`, `nextSection`).
|
- **Variables must read clearly at the call site.** Avoid opaque single-letter or ultra-short names (`d`, `x`, `tmp`) unless they match a universal convention (e.g. `i` in a trivial index loop). Prefer names that state intent (`deadlineDate`, `parsedUrl`, `nextSection`).
|
||||||
- **After an early `return` (or other guard exit), leave a blank line** before the next statement so the “happy path” block is visually separated from guards.
|
- **After an early `return` (or other guard exit), leave a blank line** before the next statement so the “happy path” block is visually separated from guards.
|
||||||
|
- **Never put an `await` or a complex/multi-call expression inside an object literal** (construction or update). Assign each value to a well-named local first, then reference it in the object. This keeps the call site readable and debuggable.
|
||||||
|
```ts
|
||||||
|
// ❌ don't
|
||||||
|
org.limits = { students: toResourceUsage(await countActiveStudents(org.id), getPlanLimit('students', plan)) };
|
||||||
|
// ✅ do
|
||||||
|
const studentsUsed = await countActiveStudents(org.id);
|
||||||
|
const studentsLimit = getPlanLimit('students', plan);
|
||||||
|
org.limits = { students: toResourceUsage(studentsUsed, studentsLimit) };
|
||||||
|
```
|
||||||
|
|
||||||
## Creating a New Route
|
## Creating a New Route
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { AppError, ErrorCodes } from '@api/utils/errors';
|
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||||
import {
|
import {
|
||||||
|
countActiveStudents,
|
||||||
createOrganizationMember,
|
createOrganizationMember,
|
||||||
getFirstOrganization,
|
getFirstOrganization,
|
||||||
getOrganizationByProfileId,
|
getOrganizationByProfileId,
|
||||||
hasOrgMemberByProfileIdOrEmail
|
hasOrgMemberByProfileIdOrEmail
|
||||||
} from '@cio/db/queries/organization';
|
} from '@cio/db/queries/organization';
|
||||||
|
import { getPlanLimit, toResourceUsage } from '@cio/utils/plans';
|
||||||
import {
|
import {
|
||||||
getProfileByEmail,
|
getProfileByEmail,
|
||||||
getProfileById,
|
getProfileById,
|
||||||
@@ -65,6 +67,23 @@ export async function getAccountData(userId: string): Promise<GetAccountDataResu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Attach per-resource usage + plan limits for admin/tutor members only.
|
||||||
|
// Plain COUNTs (no rows) so the payload stays light and students never
|
||||||
|
// receive org limit data. Skip self-hosted (unlimited, matching the guard).
|
||||||
|
if (!isSelfHosted) {
|
||||||
|
await Promise.all(
|
||||||
|
organizations.map(async (org) => {
|
||||||
|
if (org.roleId !== ROLE.ADMIN && org.roleId !== ROLE.TUTOR) return;
|
||||||
|
|
||||||
|
const activePlan = org.plans.find((plan) => plan.isActive);
|
||||||
|
const studentsUsed = await countActiveStudents(org.id);
|
||||||
|
const studentsLimit = getPlanLimit('students', activePlan?.planName);
|
||||||
|
|
||||||
|
org.limits = { students: toResourceUsage(studentsUsed, studentsLimit) };
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
profile,
|
profile,
|
||||||
organizations,
|
organizations,
|
||||||
|
|||||||
@@ -42,9 +42,14 @@ import {
|
|||||||
import { getCourseGroupIds } from '@cio/db/queries/course';
|
import { getCourseGroupIds } from '@cio/db/queries/course';
|
||||||
import { insertGroupMembersOnConflictDoNothing } from '@cio/db/queries/group';
|
import { insertGroupMembersOnConflictDoNothing } from '@cio/db/queries/group';
|
||||||
import { getProfileByEmail } from '@cio/db/queries/auth';
|
import { getProfileByEmail } from '@cio/db/queries/auth';
|
||||||
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
|
import {
|
||||||
|
getOrgMembersByProfileIds,
|
||||||
|
getOrganizationMemberIdByOrgAndProfile,
|
||||||
|
insertOrganizationMembersOnConflictDoNothing
|
||||||
|
} from '@cio/db/queries/organization';
|
||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
import { db } from '@cio/db/drizzle';
|
import { db } from '@cio/db/drizzle';
|
||||||
|
import { assertStudentCapacityOrThrow } from '../organization/student-limit';
|
||||||
|
|
||||||
type CohortMemberEnrollment = {
|
type CohortMemberEnrollment = {
|
||||||
profileId: string;
|
profileId: string;
|
||||||
@@ -64,6 +69,17 @@ async function enrollCohortStudentsInGroups(
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const studentProfileIds = studentMembers
|
||||||
|
.map((member) => member.profileId)
|
||||||
|
.filter((profileId): profileId is string => Boolean(profileId));
|
||||||
|
const existingMembers = await getOrgMembersByProfileIds(organizationId, studentProfileIds);
|
||||||
|
const existingMemberProfileIds = new Set(existingMembers.map((member) => member.profileId));
|
||||||
|
const newStudentProfileIds = new Set(
|
||||||
|
studentProfileIds.filter((profileId) => !existingMemberProfileIds.has(profileId))
|
||||||
|
);
|
||||||
|
|
||||||
|
await assertStudentCapacityOrThrow(organizationId, newStudentProfileIds.size);
|
||||||
|
|
||||||
const organizationMemberRows = studentMembers.map((member) => ({
|
const organizationMemberRows = studentMembers.map((member) => ({
|
||||||
organizationId,
|
organizationId,
|
||||||
roleId: ROLE.STUDENT,
|
roleId: ROLE.STUDENT,
|
||||||
@@ -241,6 +257,15 @@ export async function addCohortMembers(cohortId: string, data: TAddCohortMembers
|
|||||||
if (member && member.roleId === ROLE.STUDENT && member.profileId && courseGroupIds.length > 0) {
|
if (member && member.roleId === ROLE.STUDENT && member.profileId && courseGroupIds.length > 0) {
|
||||||
const validCourseGroupIds = courseGroupIds.filter((groupId): groupId is string => Boolean(groupId));
|
const validCourseGroupIds = courseGroupIds.filter((groupId): groupId is string => Boolean(groupId));
|
||||||
|
|
||||||
|
const existingOrgMemberId = await getOrganizationMemberIdByOrgAndProfile(
|
||||||
|
cohort.organizationId,
|
||||||
|
member.profileId,
|
||||||
|
tx
|
||||||
|
);
|
||||||
|
if (!existingOrgMemberId) {
|
||||||
|
await assertStudentCapacityOrThrow(cohort.organizationId, 1);
|
||||||
|
}
|
||||||
|
|
||||||
await insertOrganizationMembersOnConflictDoNothing(
|
await insertOrganizationMembersOnConflictDoNothing(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { enqueueTransactionalEmail } from '@api/services/jobs';
|
|||||||
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
|
import { getProfileByEmail, markUserAndProfileEmailVerified } from '@cio/db/queries/auth';
|
||||||
import { generateSlug } from '@cio/utils/functions';
|
import { generateSlug } from '@cio/utils/functions';
|
||||||
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
import { ensureComplianceEnrollmentRecordsForProfiles } from './compliance';
|
||||||
|
import { assertStudentCapacityOrThrow } from '../organization/student-limit';
|
||||||
import { getWelcomeSessionIcs } from './session-invite';
|
import { getWelcomeSessionIcs } from './session-invite';
|
||||||
import { db } from '@cio/db/drizzle';
|
import { db } from '@cio/db/drizzle';
|
||||||
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
|
import { trackServerEvent, SERVER_EVENTS } from '@cio/analytics';
|
||||||
@@ -671,6 +672,8 @@ export async function enrollInCourse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!orgMemberId) {
|
if (!orgMemberId) {
|
||||||
|
await assertStudentCapacityOrThrow(org.id, 1);
|
||||||
|
|
||||||
await createOrganizationMember({
|
await createOrganizationMember({
|
||||||
organizationId: org.id,
|
organizationId: org.id,
|
||||||
roleId: ROLE.STUDENT,
|
roleId: ROLE.STUDENT,
|
||||||
@@ -1001,6 +1004,8 @@ export async function acceptStudentInvite(token: string, user: TAuthUser, contex
|
|||||||
const orgMemberId = await getOrganizationMemberIdByOrgAndProfile(organization.id, user.id, tx);
|
const orgMemberId = await getOrganizationMemberIdByOrgAndProfile(organization.id, user.id, tx);
|
||||||
|
|
||||||
if (!orgMemberId) {
|
if (!orgMemberId) {
|
||||||
|
await assertStudentCapacityOrThrow(organization.id, 1);
|
||||||
|
|
||||||
await createOrganizationMember(
|
await createOrganizationMember(
|
||||||
{
|
{
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { updateOrganizationAudienceMember } from '@cio/db/queries/organization';
|
|||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||||
|
import { assertStudentCapacityOrThrow } from './student-limit';
|
||||||
import { getProfilesByEmails } from '@cio/db/queries/auth';
|
import { getProfilesByEmails } from '@cio/db/queries/auth';
|
||||||
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
|
import { ensureComplianceEnrollmentRecordsForProfiles } from '../course/compliance';
|
||||||
import { getWelcomeSessionIcs } from '../course/session-invite';
|
import { getWelcomeSessionIcs } from '../course/session-invite';
|
||||||
@@ -528,6 +529,8 @@ export async function importAudienceMembers(orgId: string, data: TImportAudience
|
|||||||
let importEmailsFailed = 0;
|
let importEmailsFailed = 0;
|
||||||
|
|
||||||
if (newEmails.length > 0) {
|
if (newEmails.length > 0) {
|
||||||
|
await assertStudentCapacityOrThrow(orgId, newEmails.length);
|
||||||
|
|
||||||
await createOrganizationMembers(
|
await createOrganizationMembers(
|
||||||
newEmails.map((email) => ({
|
newEmails.map((email) => ({
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
|
|
||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
import { getProfileById } from '@cio/db/queries/auth';
|
import { getProfileById } from '@cio/db/queries/auth';
|
||||||
|
import { assertStudentCapacityOrThrow } from './student-limit';
|
||||||
|
|
||||||
interface OrgSignupSettings {
|
interface OrgSignupSettings {
|
||||||
signup?: {
|
signup?: {
|
||||||
@@ -56,6 +57,8 @@ export async function autoEnrollStudent(userId: string, orgId: string): Promise<
|
|||||||
throw new AppError('Profile email not found', ErrorCodes.NOT_FOUND, 404);
|
throw new AppError('Profile email not found', ErrorCodes.NOT_FOUND, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await assertStudentCapacityOrThrow(orgId, 1);
|
||||||
|
|
||||||
await createOrganizationMember({
|
await createOrganizationMember({
|
||||||
organizationId: orgId,
|
organizationId: orgId,
|
||||||
profileId: userId,
|
profileId: userId,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { ROLE } from '@cio/utils/constants';
|
|||||||
import type { TNewOrganizationInviteAudit } from '@db/types';
|
import type { TNewOrganizationInviteAudit } from '@db/types';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import { db, type DbOrTxClient } from '@cio/db/drizzle';
|
import { db, type DbOrTxClient } from '@cio/db/drizzle';
|
||||||
|
import { assertStudentCapacityOrThrow } from './student-limit';
|
||||||
import { getAppBaseUrl } from '@cio/core/config/dashboard-url';
|
import { getAppBaseUrl } from '@cio/core/config/dashboard-url';
|
||||||
import { parseCourseIdsFromInviteMetadata, parseCohortIdsFromInviteMetadata } from '@api/utils/org';
|
import { parseCourseIdsFromInviteMetadata, parseCohortIdsFromInviteMetadata } from '@api/utils/org';
|
||||||
import { getProfileById, markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
|
import { getProfileById, markUserAndProfileEmailVerified } from '@cio/db/queries/auth/profile';
|
||||||
@@ -137,6 +138,10 @@ async function syncOrgMemberForOrgInvite(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (params.roleId === ROLE.STUDENT) {
|
||||||
|
await assertStudentCapacityOrThrow(params.organizationId, 1);
|
||||||
|
}
|
||||||
|
|
||||||
await createOrganizationMember(
|
await createOrganizationMember(
|
||||||
{
|
{
|
||||||
organizationId: params.organizationId,
|
organizationId: params.organizationId,
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { AppError, ErrorCodes } from '@api/utils/errors';
|
||||||
|
import { enqueueTransactionalEmail } from '@api/services/jobs';
|
||||||
|
import { buildEmailBranding } from '@cio/email';
|
||||||
|
import { getDashboardBaseUrl } from '@cio/core/config/dashboard-url';
|
||||||
|
import { getStudentLimit } from '@cio/utils/plans';
|
||||||
|
import { env } from '@cio/core/config/env';
|
||||||
|
import {
|
||||||
|
countActiveStudents,
|
||||||
|
getActiveOrganizationPlan,
|
||||||
|
getOrganizationAdminEmails,
|
||||||
|
getOrganizationById
|
||||||
|
} from '@cio/db/queries/organization';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifies org admins that the org has hit its plan's student limit. Enqueued
|
||||||
|
* with a per-day idempotency key so repeated blocked attempts don't spam admins.
|
||||||
|
*/
|
||||||
|
async function notifyAdminsStudentLimitReached(
|
||||||
|
orgId: string,
|
||||||
|
studentCount: number,
|
||||||
|
studentLimit: number
|
||||||
|
): Promise<void> {
|
||||||
|
const [admins, org] = await Promise.all([getOrganizationAdminEmails(orgId), getOrganizationById(orgId)]);
|
||||||
|
|
||||||
|
if (!admins.length || !org) return;
|
||||||
|
|
||||||
|
const todayKey = new Date().toISOString().slice(0, 10);
|
||||||
|
const upgradeUrl = `${getDashboardBaseUrl()}/org/${org.siteName}?upgrade=true`;
|
||||||
|
|
||||||
|
await enqueueTransactionalEmail('studentLimitReached', {
|
||||||
|
to: admins.map((admin) => admin.email),
|
||||||
|
fields: {
|
||||||
|
orgName: org.name,
|
||||||
|
studentCount,
|
||||||
|
studentLimit,
|
||||||
|
upgradeUrl,
|
||||||
|
branding: buildEmailBranding({ name: org.name, avatarUrl: org.avatarUrl, theme: org.theme })
|
||||||
|
},
|
||||||
|
idempotencyKey: `student-limit-reached:${orgId}:${todayKey}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Throws `UPGRADE_REQUIRED` when adding `additionalStudents` new org members
|
||||||
|
* with the student role would push the org past its plan's student limit.
|
||||||
|
* Self-hosted orgs and paid plans with an unlimited allowance are exempt.
|
||||||
|
*
|
||||||
|
* Fires an admin-notification email (fire-and-forget) the moment the limit is hit.
|
||||||
|
*/
|
||||||
|
export async function assertStudentCapacityOrThrow(orgId: string, additionalStudents: number): Promise<void> {
|
||||||
|
if (additionalStudents <= 0) return;
|
||||||
|
if (env.PUBLIC_IS_SELFHOSTED === 'true') return;
|
||||||
|
|
||||||
|
const activePlan = await getActiveOrganizationPlan(orgId);
|
||||||
|
const limit = getStudentLimit(activePlan?.planName);
|
||||||
|
|
||||||
|
if (!Number.isFinite(limit)) return;
|
||||||
|
|
||||||
|
const currentCount = await countActiveStudents(orgId);
|
||||||
|
|
||||||
|
if (currentCount + additionalStudents > limit) {
|
||||||
|
notifyAdminsStudentLimitReached(orgId, currentCount, limit).catch((error) => {
|
||||||
|
console.error('notifyAdminsStudentLimitReached error:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
throw new AppError(
|
||||||
|
`This organization has reached its ${limit}-student limit on the Free plan`,
|
||||||
|
ErrorCodes.UPGRADE_REQUIRED,
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,11 @@
|
|||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
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 { orgApi } from '$features/org/api/org.svelte';
|
||||||
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
||||||
import { BulkEmailSection, ExistingStudentsSection, TutorSelectSection } from '$features/people/components';
|
import { BulkEmailSection, ExistingStudentsSection, TutorSelectSection } from '$features/people/components';
|
||||||
|
import { UpgradeBanner } from '$features/ui';
|
||||||
import type { OrgStudent, Tutor } from '$features/people/utils/types';
|
import type { OrgStudent, Tutor } from '$features/people/utils/types';
|
||||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||||
import { cohortApi } from '../api';
|
import { cohortApi } from '../api';
|
||||||
@@ -220,7 +221,10 @@
|
|||||||
onSearchValueChange={handleStudentSearch}
|
onSearchValueChange={handleStudentSearch}
|
||||||
onAssign={assignExistingStudents}
|
onAssign={assignExistingStudents}
|
||||||
/>
|
/>
|
||||||
<BulkEmailSection onInvite={inviteNewStudents} />
|
{#if $isStudentLimitReached}
|
||||||
|
<UpgradeBanner removeParams={['add']}>{$t(`${INVITE_MODAL}.student_limit_reached`)}</UpgradeBanner>
|
||||||
|
{/if}
|
||||||
|
<BulkEmailSection onInvite={inviteNewStudents} disabled={$isStudentLimitReached} />
|
||||||
</div>
|
</div>
|
||||||
</UnderlineTabs.Content>
|
</UnderlineTabs.Content>
|
||||||
</UnderlineTabs.Root>
|
</UnderlineTabs.Root>
|
||||||
|
|||||||
+12
-2
@@ -3,9 +3,16 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||||
import { BackButton } from '@cio/ui';
|
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 { 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 { isStudentExperience } from '$lib/utils/store/app';
|
||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
import { cohortApi } from '$features/cohort/api';
|
import { cohortApi } from '$features/cohort/api';
|
||||||
@@ -88,6 +95,9 @@
|
|||||||
<a href={resolve(item.url, {})} {...props}>
|
<a href={resolve(item.url, {})} {...props}>
|
||||||
<Icon {isHovered} size={16} />
|
<Icon {isHovered} size={16} />
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
|
{#if item.id === 'people' && $isStudentLimitReached}
|
||||||
|
<PremiumIcon {isHovered} size={16} class="ui:text-primary ml-auto" />
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</HoverableItem>
|
</HoverableItem>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
|||||||
import { profile } from '$lib/utils/store/user';
|
import { profile } from '$lib/utils/store/user';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { snackbar } from '$features/ui/snackbar/store';
|
import { snackbar } from '$features/ui/snackbar/store';
|
||||||
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { coursesApi } from './courses.svelte';
|
import { coursesApi } from './courses.svelte';
|
||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
import { ContentType } from '@cio/utils/constants/content';
|
import { ContentType } from '@cio/utils/constants/content';
|
||||||
@@ -310,6 +311,17 @@ export class CourseApi extends BaseApiWithErrors {
|
|||||||
onError: (result) => {
|
onError: (result) => {
|
||||||
if (typeof result === 'string') {
|
if (typeof result === 'string') {
|
||||||
snackbar.error('snackbar.invite.failed_join');
|
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) {
|
} else if (typeof result === 'object' && result !== null && 'error' in result) {
|
||||||
snackbar.error((result as { error: string }).error);
|
snackbar.error((result as { error: string }).error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { courseApi } from '$features/course/api';
|
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 { peopleApi } from '$features/course/api';
|
||||||
import { orgApi } from '$features/org/api/org.svelte';
|
import { orgApi } from '$features/org/api/org.svelte';
|
||||||
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
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 { OrgTeamMember } from '$lib/utils/types/org';
|
||||||
import type { OrgStudent, Tutor } from '$features/people/utils/types';
|
import type { OrgStudent, Tutor } from '$features/people/utils/types';
|
||||||
import { TutorSelectSection, ExistingStudentsSection, BulkEmailSection } from '$features/people/components';
|
import { TutorSelectSection, ExistingStudentsSection, BulkEmailSection } from '$features/people/components';
|
||||||
|
import { UpgradeBanner } from '$features/ui';
|
||||||
|
|
||||||
let tutors = $state<Tutor[]>([]);
|
let tutors = $state<Tutor[]>([]);
|
||||||
let selectedIds = $state<string[]>([]);
|
let selectedIds = $state<string[]>([]);
|
||||||
@@ -216,7 +217,11 @@
|
|||||||
onAssign={assignExistingStudents}
|
onAssign={assignExistingStudents}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BulkEmailSection onInvite={inviteNewStudents} />
|
{#if $isStudentLimitReached}
|
||||||
|
<UpgradeBanner removeParams={['add']}>{$t(`${INVITE_MODAL}.student_limit_reached`)}</UpgradeBanner>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<BulkEmailSection onInvite={inviteNewStudents} disabled={$isStudentLimitReached} />
|
||||||
</div>
|
</div>
|
||||||
</UnderlineTabs.Content>
|
</UnderlineTabs.Content>
|
||||||
</UnderlineTabs.Root>
|
</UnderlineTabs.Root>
|
||||||
|
|||||||
+6
-1
@@ -19,6 +19,7 @@
|
|||||||
MarksIcon,
|
MarksIcon,
|
||||||
NewsFeedIcon,
|
NewsFeedIcon,
|
||||||
PeopleIcon,
|
PeopleIcon,
|
||||||
|
PremiumIcon,
|
||||||
SettingsIcon,
|
SettingsIcon,
|
||||||
SubmissionIcon
|
SubmissionIcon
|
||||||
} from '@cio/ui/custom/moving-icons';
|
} from '@cio/ui/custom/moving-icons';
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
import { NAV_IDS } from './constants';
|
import { NAV_IDS } from './constants';
|
||||||
import { complianceApi, courseApi } from '$features/course/api';
|
import { complianceApi, courseApi } from '$features/course/api';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
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 { isStudentExperience } from '$lib/utils/store/app';
|
||||||
import { getNavItemRoute, getLessonsRoute } from '$features/course/utils/functions';
|
import { getNavItemRoute, getLessonsRoute } from '$features/course/utils/functions';
|
||||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||||
@@ -48,6 +49,7 @@
|
|||||||
let { path, id, isStudent = false }: Props = $props();
|
let { path, id, isStudent = false }: Props = $props();
|
||||||
|
|
||||||
const coursesListPath = $derived($isStudentExperience ? '/lms/mylearning' : `${$currentOrgPath}/courses`);
|
const coursesListPath = $derived($isStudentExperience ? '/lms/mylearning' : `${$currentOrgPath}/courses`);
|
||||||
|
|
||||||
const contentData = $derived(getCourseContent(courseApi.course));
|
const contentData = $derived(getCourseContent(courseApi.course));
|
||||||
const sidebar = useSidebar();
|
const sidebar = useSidebar();
|
||||||
const contentCount = $derived.by(() => {
|
const contentCount = $derived.by(() => {
|
||||||
@@ -406,6 +408,9 @@
|
|||||||
<Icon {isHovered} size={16} />
|
<Icon {isHovered} size={16} />
|
||||||
{/if}
|
{/if}
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
|
{#if item.id === NAV_IDS.PEOPLE && $isStudentLimitReached}
|
||||||
|
<PremiumIcon {isHovered} size={16} class="ui:text-primary ml-auto" />
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</HoverableItem>
|
</HoverableItem>
|
||||||
|
|||||||
@@ -9,9 +9,10 @@
|
|||||||
|
|
||||||
import { Chip } from '@cio/ui/custom/chip';
|
import { Chip } from '@cio/ui/custom/chip';
|
||||||
import * as Avatar from '@cio/ui/base/avatar';
|
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 InvitationModal from '$features/course/components/people/invitation-modal.svelte';
|
||||||
import DeleteConfirmation from '$features/course/components/people/delete-confirmation.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 { profile } from '$lib/utils/store/user';
|
||||||
import type { CourseMembers, CourseMember } from '$features/course/utils/types';
|
import type { CourseMembers, CourseMember } from '$features/course/utils/types';
|
||||||
@@ -101,6 +102,10 @@
|
|||||||
|
|
||||||
<InvitationModal />
|
<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} />
|
<DeleteConfirmation email={member.email || (member.profile && member.profile.email)} {deletePerson} />
|
||||||
|
|
||||||
<section class="space-y-2">
|
<section class="space-y-2">
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
descriptionKey?: string;
|
descriptionKey?: string;
|
||||||
submitKey?: string;
|
submitKey?: string;
|
||||||
noEmailsKey?: string;
|
noEmailsKey?: string;
|
||||||
|
disabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -19,7 +20,8 @@
|
|||||||
titleKey = 'course.navItem.people.invite_modal.invite_new_students_title',
|
titleKey = 'course.navItem.people.invite_modal.invite_new_students_title',
|
||||||
descriptionKey = 'course.navItem.people.invite_modal.invite_new_students_description',
|
descriptionKey = 'course.navItem.people.invite_modal.invite_new_students_description',
|
||||||
submitKey = 'course.navItem.people.invite_modal.invite_new_students',
|
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();
|
}: Props = $props();
|
||||||
|
|
||||||
let recipientCsv = $state('');
|
let recipientCsv = $state('');
|
||||||
@@ -53,12 +55,18 @@
|
|||||||
bind:value={recipientCsv}
|
bind:value={recipientCsv}
|
||||||
rows={4}
|
rows={4}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
|
{disabled}
|
||||||
placeholder={$t('audience.import.emails_placeholder')}
|
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>
|
</Field.Group>
|
||||||
|
|
||||||
<Button variant="secondary" onclick={inviteStudents} loading={isSubmitting}>
|
<Button variant="secondary" onclick={inviteStudents} loading={isSubmitting} {disabled}>
|
||||||
{$t(submitKey)}
|
{$t(submitKey)}
|
||||||
</Button>
|
</Button>
|
||||||
</Field.Set>
|
</Field.Set>
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import type { AccountOrg } from '$features/app/types';
|
|||||||
import BotIcon from '@lucide/svelte/icons/bot';
|
import BotIcon from '@lucide/svelte/icons/bot';
|
||||||
import type { Component } from 'svelte';
|
import type { Component } from 'svelte';
|
||||||
import { isActive } from '$lib/utils/functions/app';
|
import { isActive } from '$lib/utils/functions/app';
|
||||||
|
import type { PlanLimitResource } from '@cio/utils/plans';
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -31,6 +32,8 @@ export interface NavItem {
|
|||||||
matchPattern?: string;
|
matchPattern?: string;
|
||||||
items?: NavItem[]; // for nested items like settings
|
items?: NavItem[]; // for nested items like settings
|
||||||
isPaid?: boolean; // Show upgrade indicator for free plan users
|
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;
|
disabled?: boolean;
|
||||||
// Metadata for breadcrumb generation
|
// Metadata for breadcrumb generation
|
||||||
useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings)
|
useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings)
|
||||||
@@ -50,6 +53,8 @@ export interface NavItemConfig {
|
|||||||
supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug])
|
supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug])
|
||||||
matchPattern?: string | ((orgSlug: string) => string); // Regex pattern for route matching
|
matchPattern?: string | ((orgSlug: string) => string); // Regex pattern for route matching
|
||||||
isPaid?: boolean; // Show upgrade indicator for free plan users
|
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
|
group?: string | null; // Group label key for sidebar grouping
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +168,7 @@ export const baseNavConfig: NavItemConfig[] = [
|
|||||||
titleKey: 'org_navigation.audience',
|
titleKey: 'org_navigation.audience',
|
||||||
path: '/audience',
|
path: '/audience',
|
||||||
icon: PeopleIcon,
|
icon: PeopleIcon,
|
||||||
|
upgradeResource: 'students',
|
||||||
matchPattern: '^/org/[^/]+/audience(/.*)?$' // Matches nested routes
|
matchPattern: '^/org/[^/]+/audience(/.*)?$' // Matches nested routes
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -376,7 +382,8 @@ export function getOrgNavigationGroups(
|
|||||||
currentOrg: AccountOrg,
|
currentOrg: AccountOrg,
|
||||||
isOrgAdmin: boolean | null,
|
isOrgAdmin: boolean | null,
|
||||||
t: (key: string) => string,
|
t: (key: string) => string,
|
||||||
pagePathname: string
|
pagePathname: string,
|
||||||
|
limitsReached: Partial<Record<PlanLimitResource, boolean>> = {}
|
||||||
): NavGroup[] {
|
): NavGroup[] {
|
||||||
const pathnameOnly = pagePathname.split('?')[0];
|
const pathnameOnly = pagePathname.split('?')[0];
|
||||||
const groupedMap = new Map<string | null, NavItem[]>();
|
const groupedMap = new Map<string | null, NavItem[]>();
|
||||||
@@ -413,7 +420,8 @@ export function getOrgNavigationGroups(
|
|||||||
useHashUrl: config.useHashUrl,
|
useHashUrl: config.useHashUrl,
|
||||||
nestedRoutes: config.nestedRoutes,
|
nestedRoutes: config.nestedRoutes,
|
||||||
supportsDynamicSegment: config.supportsDynamicSegment,
|
supportsDynamicSegment: config.supportsDynamicSegment,
|
||||||
isPaid: config.isPaid
|
isPaid: config.isPaid,
|
||||||
|
upgrade: config.upgradeResource ? Boolean(limitsReached[config.upgradeResource]) : undefined
|
||||||
};
|
};
|
||||||
|
|
||||||
if (visibleSubConfigs.length > 0) {
|
if (visibleSubConfigs.length > 0) {
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
<script lang="ts">
|
<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 Collapsible from '@cio/ui/base/collapsible';
|
||||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { page } from '$app/state';
|
import { page } from '$app/state';
|
||||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||||
import RocketIcon from '@lucide/svelte/icons/rocket';
|
|
||||||
import { getOrgNavigationGroups } from '$features/ui/navigation/org-navigation';
|
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(
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -85,6 +86,9 @@
|
|||||||
{:else}
|
{:else}
|
||||||
<span>{item.title}</span>
|
<span>{item.title}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if item.upgrade}
|
||||||
|
<PremiumIcon {isHovered} size={16} class="ui:text-primary ml-auto" />
|
||||||
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
@@ -102,7 +106,7 @@
|
|||||||
<a href={subItem.url} {...props}>
|
<a href={subItem.url} {...props}>
|
||||||
<span>{subItem.title}</span>
|
<span>{subItem.title}</span>
|
||||||
{#if subItem.isPaid && $isFreePlan}
|
{#if subItem.isPaid && $isFreePlan}
|
||||||
<RocketIcon class="text-primary size-4" />
|
<PremiumIcon size={16} class="ui:text-primary ml-auto" />
|
||||||
{/if}
|
{/if}
|
||||||
</a>
|
</a>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|||||||
@@ -1,28 +1,50 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '@cio/ui/base/button';
|
import { Button } from '@cio/ui/base/button';
|
||||||
|
import { Progress } from '@cio/ui/base/progress';
|
||||||
import RocketIcon from '@lucide/svelte/icons/rocket';
|
import RocketIcon from '@lucide/svelte/icons/rocket';
|
||||||
import { t } from '$lib/utils/functions/translations';
|
import { t } from '$lib/utils/functions/translations';
|
||||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
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';
|
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||||
|
|
||||||
const sidebar = useSidebar();
|
const sidebar = useSidebar();
|
||||||
|
|
||||||
|
const studentCount = $derived($studentUsage?.used ?? 0);
|
||||||
|
const studentLimit = $derived($studentUsage?.limit ?? $currentOrgMaxAudience);
|
||||||
|
const studentsLeft = $derived(Math.max(0, studentLimit - studentCount));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if $isFreePlan}
|
{#if $isFreePlan}
|
||||||
<div class="animate-icon">
|
<div class="animate-icon">
|
||||||
{#if sidebar.open && !sidebar.isMobile}
|
{#if sidebar.open && !sidebar.isMobile}
|
||||||
<div
|
<div class="animate-gradient-border mx-2 flex flex-col gap-3 rounded-md p-3 transition-all ease-in-out">
|
||||||
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"
|
<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 my-3 size-6" color="var(--primary)" />
|
<RocketIcon class="rocket-launch size-4" color="var(--primary)" />
|
||||||
<span class="flex flex-col gap-1">
|
</span>
|
||||||
<p class="text-base font-medium">{$t('org_navigation.early_adopter')}</p>
|
<p class="text-sm">{$t('org_navigation.early_adopter')}</p>
|
||||||
<p class="ui:text-muted-foreground text-xs">{$t('org_navigation.unlock')}</p>
|
</div>
|
||||||
</span>
|
|
||||||
<Button data-sidebar="upgrade-trigger" data-slot="upgrade-trigger" type="button" onclick={openUpgradeModal}>
|
<Progress value={studentCount} max={studentLimit} variant="default" />
|
||||||
<RocketIcon class="rocket-launch custom size-4" />
|
|
||||||
{$t('org_navigation.upgrade')}
|
<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>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<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 { isFreePlan } from '$lib/utils/store/org';
|
||||||
import * as Item from '@cio/ui/base/item';
|
import * as Item from '@cio/ui/base/item';
|
||||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||||
@@ -9,10 +9,12 @@
|
|||||||
className?: string;
|
className?: string;
|
||||||
onClick?: any;
|
onClick?: any;
|
||||||
visible?: boolean;
|
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;
|
children?: import('svelte').Snippet;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { className = '', onClick = () => {}, visible, ...restProps }: Props = $props();
|
let { className = '', onClick = () => {}, visible, removeParams = [], ...restProps }: Props = $props();
|
||||||
|
|
||||||
const shouldShow = $derived(visible ?? $isFreePlan);
|
const shouldShow = $derived(visible ?? $isFreePlan);
|
||||||
</script>
|
</script>
|
||||||
@@ -26,7 +28,7 @@
|
|||||||
{...props}
|
{...props}
|
||||||
onclick={() => {
|
onclick={() => {
|
||||||
onClick();
|
onClick();
|
||||||
openUpgradeModal();
|
openUpgradeModalOver(removeParams);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Item.Media variant="icon">
|
<Item.Media variant="icon">
|
||||||
|
|||||||
@@ -14,12 +14,28 @@ export function generateSitename(orgName: string): string {
|
|||||||
.replace(/[^a-zA-Z0-9-]/g, '');
|
.replace(/[^a-zA-Z0-9-]/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function openUpgradeModal() {
|
function navigateToUpgradeModal(removeParams: string[]) {
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
|
|
||||||
|
for (const param of removeParams) {
|
||||||
|
url.searchParams.delete(param);
|
||||||
|
}
|
||||||
|
|
||||||
url.searchParams.set('upgrade', 'true');
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import merge from 'lodash/merge';
|
|||||||
|
|
||||||
import type { AccountOrg } from '$features/app/types';
|
import type { AccountOrg } from '$features/app/types';
|
||||||
import type { OrgTeamMember } from '../types/org';
|
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 { PUBLIC_IS_SELFHOSTED } from '$env/static/public';
|
||||||
import { BRAND_ROOT_DOMAIN, ROLE, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
|
import { BRAND_ROOT_DOMAIN, ROLE, TENANT_ROOT_DOMAIN } from '@cio/utils/constants';
|
||||||
import { STEPS } from '../constants/quiz';
|
import { STEPS } from '../constants/quiz';
|
||||||
@@ -160,14 +160,22 @@ export const hasPublicApiAccess = derived(currentOrg, ($currentOrg) => {
|
|||||||
return canUsePublicApi(plan?.planName, PUBLIC_IS_SELFHOSTED === 'true');
|
return canUsePublicApi(plan?.planName, PUBLIC_IS_SELFHOSTED === 'true');
|
||||||
});
|
});
|
||||||
|
|
||||||
export const currentOrgMaxAudience = derived(currentOrgPlan, ($plan) =>
|
export const currentOrgMaxAudience = derived(currentOrgPlan, ($plan) => {
|
||||||
!$plan
|
const limit = getStudentLimit($plan?.planName);
|
||||||
? 20
|
return Number.isFinite(limit) ? limit : Number.MAX_SAFE_INTEGER;
|
||||||
: $plan.planName === PLAN.EARLY_ADOPTER
|
});
|
||||||
? 10000
|
|
||||||
: $plan.planName === PLAN.ENTERPRISE
|
/**
|
||||||
? Number.MAX_SAFE_INTEGER
|
* Per-resource org usage + limits delivered by `/account` (admin/tutor only).
|
||||||
: 20
|
* `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
|
// Quiz
|
||||||
|
|||||||
@@ -709,7 +709,8 @@
|
|||||||
"cancel": "Annuller",
|
"cancel": "Annuller",
|
||||||
"confirm": "Slet",
|
"confirm": "Slet",
|
||||||
"snackbar_success": "Medlem slettet"
|
"snackbar_success": "Medlem slettet"
|
||||||
}
|
},
|
||||||
|
"import_limit_reached": "Du har nået din plans elevgrænse. Opgrader for at importere flere elever."
|
||||||
},
|
},
|
||||||
"community": {
|
"community": {
|
||||||
"title": "Fællesskab",
|
"title": "Fællesskab",
|
||||||
@@ -894,7 +895,8 @@
|
|||||||
"back_to_course": "Tilbage til kurset",
|
"back_to_course": "Tilbage til kurset",
|
||||||
"join_course": "Deltag i kursus",
|
"join_course": "Deltag i kursus",
|
||||||
"enrolled_success": "Du er tilmeldt dette 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": {
|
"pricing_section": {
|
||||||
"free": "Gratis",
|
"free": "Gratis",
|
||||||
@@ -1226,7 +1228,8 @@
|
|||||||
"multi_use_7d": "Multi-brug (7 dage)",
|
"multi_use_7d": "Multi-brug (7 dage)",
|
||||||
"multi_use_30d": "Multi-brug (30 dage)",
|
"multi_use_30d": "Multi-brug (30 dage)",
|
||||||
"custom": "Brugerdefineret"
|
"custom": "Brugerdefineret"
|
||||||
}
|
},
|
||||||
|
"student_limit_reached": "Du har nået din plans elevgrænse. Opgrader for at invitere flere elever."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"marks": {
|
"marks": {
|
||||||
@@ -2811,7 +2814,10 @@
|
|||||||
"stats": "Statistik",
|
"stats": "Statistik",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
"compliance": "Overholdelse",
|
"compliance": "Overholdelse",
|
||||||
"cohorts": "Programmer"
|
"cohorts": "Programmer",
|
||||||
|
"upgrade_now": "Opgrader nu",
|
||||||
|
"upgrade_students": "{studentCount} af {studentLimit} elever",
|
||||||
|
"upgrade_left": "{count} tilbage"
|
||||||
},
|
},
|
||||||
"media_manager": {
|
"media_manager": {
|
||||||
"page_title": "Media Manager - ClassroomIO",
|
"page_title": "Media Manager - ClassroomIO",
|
||||||
@@ -3636,7 +3642,8 @@
|
|||||||
"widgets": "Widgets",
|
"widgets": "Widgets",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"students": "studerende",
|
"students": "studerende",
|
||||||
"settings": "Indstillinger"
|
"settings": "Indstillinger",
|
||||||
|
"cohorts": "Kohorter"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -709,7 +709,8 @@
|
|||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"confirm": "Löschen",
|
"confirm": "Löschen",
|
||||||
"snackbar_success": "Mitglied gelöscht"
|
"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": {
|
"community": {
|
||||||
"title": "Gemeinschaft",
|
"title": "Gemeinschaft",
|
||||||
@@ -894,7 +895,8 @@
|
|||||||
"back_to_course": "Zurück zum Kurs",
|
"back_to_course": "Zurück zum Kurs",
|
||||||
"join_course": "Nehmen Sie am Kurs teil",
|
"join_course": "Nehmen Sie am Kurs teil",
|
||||||
"enrolled_success": "Sie sind für diesen Kurs eingeschrieben.",
|
"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": {
|
"pricing_section": {
|
||||||
"free": "Kostenlos",
|
"free": "Kostenlos",
|
||||||
@@ -1226,7 +1228,8 @@
|
|||||||
"multi_use_7d": "Mehrfachnutzung (7 Tage)",
|
"multi_use_7d": "Mehrfachnutzung (7 Tage)",
|
||||||
"multi_use_30d": "Mehrfachnutzung (30 Tage)",
|
"multi_use_30d": "Mehrfachnutzung (30 Tage)",
|
||||||
"custom": "Benutzerdefiniert"
|
"custom": "Benutzerdefiniert"
|
||||||
}
|
},
|
||||||
|
"student_limit_reached": "Sie haben das Studentenlimit Ihres Plans erreicht. Führen Sie ein Upgrade durch, um weitere Schüler einzuladen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"marks": {
|
"marks": {
|
||||||
@@ -2811,7 +2814,10 @@
|
|||||||
"stats": "Statistiken",
|
"stats": "Statistiken",
|
||||||
"analytics": "Analytik",
|
"analytics": "Analytik",
|
||||||
"compliance": "Compliance",
|
"compliance": "Compliance",
|
||||||
"cohorts": "Programme"
|
"cohorts": "Programme",
|
||||||
|
"upgrade_now": "Jetzt upgraden",
|
||||||
|
"upgrade_students": "{studentCount} von {studentLimit} Studenten",
|
||||||
|
"upgrade_left": "{count} übrig"
|
||||||
},
|
},
|
||||||
"media_manager": {
|
"media_manager": {
|
||||||
"page_title": "Medienmanager - ClassroomIO",
|
"page_title": "Medienmanager - ClassroomIO",
|
||||||
@@ -3636,7 +3642,8 @@
|
|||||||
"widgets": "Widgets",
|
"widgets": "Widgets",
|
||||||
"tags": "Schlagworte",
|
"tags": "Schlagworte",
|
||||||
"students": "Studenten",
|
"students": "Studenten",
|
||||||
"settings": "Einstellungen"
|
"settings": "Einstellungen",
|
||||||
|
"cohorts": "Kohorten"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1259,6 +1259,9 @@
|
|||||||
"early_adopter": "Become an Early Adopter",
|
"early_adopter": "Become an Early Adopter",
|
||||||
"unlock": "Unlock unlimited features and invest in our future",
|
"unlock": "Unlock unlimited features and invest in our future",
|
||||||
"upgrade": "Upgrade Now",
|
"upgrade": "Upgrade Now",
|
||||||
|
"upgrade_now": "Upgrade now",
|
||||||
|
"upgrade_students": "{studentCount} of {studentLimit} students",
|
||||||
|
"upgrade_left": "{count} left",
|
||||||
"cohorts": "Cohorts"
|
"cohorts": "Cohorts"
|
||||||
},
|
},
|
||||||
"pricing": {
|
"pricing": {
|
||||||
@@ -3054,6 +3057,7 @@
|
|||||||
"to_add": "To add tutor to your organization,",
|
"to_add": "To add tutor to your organization,",
|
||||||
"go_to": "go to organization settings",
|
"go_to": "go to organization settings",
|
||||||
"invite_students": "Add Students",
|
"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_link": "You can invite students via an invite link",
|
||||||
"via_qr": "You can invite students via QR",
|
"via_qr": "You can invite students via QR",
|
||||||
"download_qr": "Download QR code",
|
"download_qr": "Download QR code",
|
||||||
@@ -3373,7 +3377,8 @@
|
|||||||
"back_to_course": "Back to course",
|
"back_to_course": "Back to course",
|
||||||
"join_course": "Join Course",
|
"join_course": "Join Course",
|
||||||
"enrolled_success": "You're enrolled in this 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",
|
"course_content": "Course content",
|
||||||
"modules": "modules",
|
"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.",
|
"page_subtitle": "See who belongs to your org, who is learning, who is invited, and how to reach them.",
|
||||||
"export": "Export",
|
"export": "Export",
|
||||||
"import_users": "Import Users",
|
"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",
|
"no_audience": "No audience to manage",
|
||||||
"manage": "Manage all your students here and stay connected with them",
|
"manage": "Manage all your students here and stay connected with them",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
|||||||
@@ -709,7 +709,8 @@
|
|||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"confirm": "Eliminar",
|
"confirm": "Eliminar",
|
||||||
"snackbar_success": "Miembro eliminado"
|
"snackbar_success": "Miembro eliminado"
|
||||||
}
|
},
|
||||||
|
"import_limit_reached": "Has alcanzado el límite de estudiantes de tu plan. Actualice para importar más estudiantes."
|
||||||
},
|
},
|
||||||
"community": {
|
"community": {
|
||||||
"title": "Comunidad",
|
"title": "Comunidad",
|
||||||
@@ -894,7 +895,8 @@
|
|||||||
"back_to_course": "volver al curso",
|
"back_to_course": "volver al curso",
|
||||||
"join_course": "Unirse al curso",
|
"join_course": "Unirse al curso",
|
||||||
"enrolled_success": "Estás inscrito en este 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": {
|
"pricing_section": {
|
||||||
"free": "Gratis",
|
"free": "Gratis",
|
||||||
@@ -1226,7 +1228,8 @@
|
|||||||
"multi_use_7d": "Multiuso (7 días)",
|
"multi_use_7d": "Multiuso (7 días)",
|
||||||
"multi_use_30d": "Multiuso (30 días)",
|
"multi_use_30d": "Multiuso (30 días)",
|
||||||
"custom": "personalizado"
|
"custom": "personalizado"
|
||||||
}
|
},
|
||||||
|
"student_limit_reached": "Has alcanzado el límite de estudiantes de tu plan. Actualice para invitar a más estudiantes."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"marks": {
|
"marks": {
|
||||||
@@ -2811,7 +2814,10 @@
|
|||||||
"stats": "Estadísticas",
|
"stats": "Estadísticas",
|
||||||
"analytics": "Analítica",
|
"analytics": "Analítica",
|
||||||
"compliance": "Cumplimiento",
|
"compliance": "Cumplimiento",
|
||||||
"cohorts": "Programas"
|
"cohorts": "Programas",
|
||||||
|
"upgrade_now": "Actualiza ahora",
|
||||||
|
"upgrade_students": "{studentCount} de {studentLimit} estudiantes",
|
||||||
|
"upgrade_left": "{count} restante"
|
||||||
},
|
},
|
||||||
"media_manager": {
|
"media_manager": {
|
||||||
"page_title": "Administrador de medios - ClassroomIO",
|
"page_title": "Administrador de medios - ClassroomIO",
|
||||||
@@ -3636,7 +3642,8 @@
|
|||||||
"widgets": "widgets",
|
"widgets": "widgets",
|
||||||
"tags": "Etiquetas",
|
"tags": "Etiquetas",
|
||||||
"students": "estudiantes",
|
"students": "estudiantes",
|
||||||
"settings": "Configuración"
|
"settings": "Configuración",
|
||||||
|
"cohorts": "Cohortes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -709,7 +709,8 @@
|
|||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
"confirm": "Supprimer",
|
"confirm": "Supprimer",
|
||||||
"snackbar_success": "Membre supprimé"
|
"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": {
|
"community": {
|
||||||
"title": "Communauté",
|
"title": "Communauté",
|
||||||
@@ -894,7 +895,8 @@
|
|||||||
"back_to_course": "Retour au cours",
|
"back_to_course": "Retour au cours",
|
||||||
"join_course": "Rejoindre le cours",
|
"join_course": "Rejoindre le cours",
|
||||||
"enrolled_success": "Vous êtes inscrit à ce 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": {
|
"pricing_section": {
|
||||||
"free": "Gratuit",
|
"free": "Gratuit",
|
||||||
@@ -1226,7 +1228,8 @@
|
|||||||
"multi_use_7d": "Multi-usage (7 jours)",
|
"multi_use_7d": "Multi-usage (7 jours)",
|
||||||
"multi_use_30d": "Multi-usage (30 jours)",
|
"multi_use_30d": "Multi-usage (30 jours)",
|
||||||
"custom": "Personnalisé"
|
"custom": "Personnalisé"
|
||||||
}
|
},
|
||||||
|
"student_limit_reached": "Vous avez atteint la limite d'étudiants de votre plan. Mettez à niveau pour inviter plus d'étudiants."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"marks": {
|
"marks": {
|
||||||
@@ -2811,7 +2814,10 @@
|
|||||||
"stats": "Statistiques",
|
"stats": "Statistiques",
|
||||||
"analytics": "Analyse",
|
"analytics": "Analyse",
|
||||||
"compliance": "Conformité",
|
"compliance": "Conformité",
|
||||||
"cohorts": "Programmes"
|
"cohorts": "Programmes",
|
||||||
|
"upgrade_now": "Mettre à niveau maintenant",
|
||||||
|
"upgrade_students": "{studentCount} sur {studentLimit} étudiants",
|
||||||
|
"upgrade_left": "Il reste {count}"
|
||||||
},
|
},
|
||||||
"media_manager": {
|
"media_manager": {
|
||||||
"page_title": "Gestionnaire de médias - ClassroomIO",
|
"page_title": "Gestionnaire de médias - ClassroomIO",
|
||||||
@@ -3636,7 +3642,8 @@
|
|||||||
"widgets": "Widgets",
|
"widgets": "Widgets",
|
||||||
"tags": "Balises",
|
"tags": "Balises",
|
||||||
"students": "Étudiants",
|
"students": "Étudiants",
|
||||||
"settings": "Paramètres"
|
"settings": "Paramètres",
|
||||||
|
"cohorts": "Cohortes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -709,7 +709,8 @@
|
|||||||
"cancel": "रद्द करें",
|
"cancel": "रद्द करें",
|
||||||
"confirm": "हटाएँ",
|
"confirm": "हटाएँ",
|
||||||
"snackbar_success": "सदस्य हटा दिया गया"
|
"snackbar_success": "सदस्य हटा दिया गया"
|
||||||
}
|
},
|
||||||
|
"import_limit_reached": "आप अपनी योजना की छात्र सीमा तक पहुँच गए हैं। अधिक छात्रों को आयात करने के लिए अपग्रेड करें।"
|
||||||
},
|
},
|
||||||
"community": {
|
"community": {
|
||||||
"title": "समुदाय",
|
"title": "समुदाय",
|
||||||
@@ -894,7 +895,8 @@
|
|||||||
"back_to_course": "पाठ्यक्रम पर वापस जाएँ",
|
"back_to_course": "पाठ्यक्रम पर वापस जाएँ",
|
||||||
"join_course": "पाठ्यक्रम में शामिल हों",
|
"join_course": "पाठ्यक्रम में शामिल हों",
|
||||||
"enrolled_success": "आप इस पाठ्यक्रम में नामांकित हैं।",
|
"enrolled_success": "आप इस पाठ्यक्रम में नामांकित हैं।",
|
||||||
"enrolled_verify_email": "आप नामांकित हैं! इस पाठ्यक्रम तक पहुँचने के लिए अपना ईमेल सत्यापित करें।"
|
"enrolled_verify_email": "आप नामांकित हैं! इस पाठ्यक्रम तक पहुँचने के लिए अपना ईमेल सत्यापित करें।",
|
||||||
|
"student_limit_reached": "{orgName} अपनी छात्र क्षमता तक पहुंच गया है। कृपया अपने संगठन प्रशासक से संपर्क करें."
|
||||||
},
|
},
|
||||||
"pricing_section": {
|
"pricing_section": {
|
||||||
"free": "निःशुल्क",
|
"free": "निःशुल्क",
|
||||||
@@ -1226,7 +1228,8 @@
|
|||||||
"multi_use_7d": "बहु-उपयोग (7 दिन)",
|
"multi_use_7d": "बहु-उपयोग (7 दिन)",
|
||||||
"multi_use_30d": "बहु-उपयोग (30 दिन)",
|
"multi_use_30d": "बहु-उपयोग (30 दिन)",
|
||||||
"custom": "कस्टम"
|
"custom": "कस्टम"
|
||||||
}
|
},
|
||||||
|
"student_limit_reached": "आप अपनी योजना की छात्र सीमा तक पहुँच गए हैं। अधिक छात्रों को आमंत्रित करने के लिए अपग्रेड करें।"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"marks": {
|
"marks": {
|
||||||
@@ -2811,7 +2814,10 @@
|
|||||||
"stats": "आँकड़े",
|
"stats": "आँकड़े",
|
||||||
"analytics": "विश्लेषिकी",
|
"analytics": "विश्लेषिकी",
|
||||||
"compliance": "अनुपालन",
|
"compliance": "अनुपालन",
|
||||||
"cohorts": "कार्यक्रम"
|
"cohorts": "कार्यक्रम",
|
||||||
|
"upgrade_now": "अभी अपग्रेड करें",
|
||||||
|
"upgrade_students": "{studentLimit} छात्रों की {studentCount}",
|
||||||
|
"upgrade_left": "{गिनती} बाएँ"
|
||||||
},
|
},
|
||||||
"media_manager": {
|
"media_manager": {
|
||||||
"page_title": "मीडिया मैनेजर - क्लासरूमआईओ",
|
"page_title": "मीडिया मैनेजर - क्लासरूमआईओ",
|
||||||
@@ -3636,7 +3642,8 @@
|
|||||||
"widgets": "विजेट्स",
|
"widgets": "विजेट्स",
|
||||||
"tags": "टैग",
|
"tags": "टैग",
|
||||||
"students": "छात्र",
|
"students": "छात्र",
|
||||||
"settings": "सेटिंग्स"
|
"settings": "सेटिंग्स",
|
||||||
|
"cohorts": "समानता रखने वाले लोग"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -709,7 +709,8 @@
|
|||||||
"cancel": "Anuluj",
|
"cancel": "Anuluj",
|
||||||
"confirm": "Usuń",
|
"confirm": "Usuń",
|
||||||
"snackbar_success": "Członek usunięty"
|
"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": {
|
"community": {
|
||||||
"title": "Społeczność",
|
"title": "Społeczność",
|
||||||
@@ -894,7 +895,8 @@
|
|||||||
"back_to_course": "Powrót na kurs",
|
"back_to_course": "Powrót na kurs",
|
||||||
"join_course": "Dołącz do kursu",
|
"join_course": "Dołącz do kursu",
|
||||||
"enrolled_success": "Zapisano Cię na ten kurs.",
|
"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": {
|
"pricing_section": {
|
||||||
"free": "Bezpłatny",
|
"free": "Bezpłatny",
|
||||||
@@ -1226,7 +1228,8 @@
|
|||||||
"multi_use_7d": "Wielokrotnego użytku (7 dni)",
|
"multi_use_7d": "Wielokrotnego użytku (7 dni)",
|
||||||
"multi_use_30d": "Wielokrotnego użytku (30 dni)",
|
"multi_use_30d": "Wielokrotnego użytku (30 dni)",
|
||||||
"custom": "Niestandardowe"
|
"custom": "Niestandardowe"
|
||||||
}
|
},
|
||||||
|
"student_limit_reached": "Osiągnąłeś limit studentów w swoim planie. Uaktualnij, aby zaprosić więcej uczniów."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"marks": {
|
"marks": {
|
||||||
@@ -2811,7 +2814,10 @@
|
|||||||
"stats": "Statystyki",
|
"stats": "Statystyki",
|
||||||
"analytics": "Analityka",
|
"analytics": "Analityka",
|
||||||
"compliance": "Zgodność",
|
"compliance": "Zgodność",
|
||||||
"cohorts": "Programy"
|
"cohorts": "Programy",
|
||||||
|
"upgrade_now": "Uaktualnij teraz",
|
||||||
|
"upgrade_students": "{studentCount} z {studentLimit} uczniów",
|
||||||
|
"upgrade_left": "Pozostało {count}"
|
||||||
},
|
},
|
||||||
"media_manager": {
|
"media_manager": {
|
||||||
"page_title": "Menedżer multimediów - ClassroomIO",
|
"page_title": "Menedżer multimediów - ClassroomIO",
|
||||||
@@ -3636,7 +3642,8 @@
|
|||||||
"widgets": "Widżety",
|
"widgets": "Widżety",
|
||||||
"tags": "Tagi",
|
"tags": "Tagi",
|
||||||
"students": "Studenci",
|
"students": "Studenci",
|
||||||
"settings": "Ustawienia"
|
"settings": "Ustawienia",
|
||||||
|
"cohorts": "Kohorty"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -709,7 +709,8 @@
|
|||||||
"cancel": "Cancelar",
|
"cancel": "Cancelar",
|
||||||
"confirm": "Excluir",
|
"confirm": "Excluir",
|
||||||
"snackbar_success": "Membro excluído"
|
"snackbar_success": "Membro excluído"
|
||||||
}
|
},
|
||||||
|
"import_limit_reached": "Você atingiu o limite de alunos do seu plano. Atualize para importar mais alunos."
|
||||||
},
|
},
|
||||||
"community": {
|
"community": {
|
||||||
"title": "Comunidade",
|
"title": "Comunidade",
|
||||||
@@ -894,7 +895,8 @@
|
|||||||
"back_to_course": "De volta ao curso",
|
"back_to_course": "De volta ao curso",
|
||||||
"join_course": "Participe do curso",
|
"join_course": "Participe do curso",
|
||||||
"enrolled_success": "Você está inscrito neste 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": {
|
"pricing_section": {
|
||||||
"free": "Grátis",
|
"free": "Grátis",
|
||||||
@@ -1226,7 +1228,8 @@
|
|||||||
"multi_use_7d": "Multiuso (7 dias)",
|
"multi_use_7d": "Multiuso (7 dias)",
|
||||||
"multi_use_30d": "Multiuso (30 dias)",
|
"multi_use_30d": "Multiuso (30 dias)",
|
||||||
"custom": "Personalizado"
|
"custom": "Personalizado"
|
||||||
}
|
},
|
||||||
|
"student_limit_reached": "Você atingiu o limite de alunos do seu plano. Atualize para convidar mais alunos."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"marks": {
|
"marks": {
|
||||||
@@ -2811,7 +2814,10 @@
|
|||||||
"stats": "Estatísticas",
|
"stats": "Estatísticas",
|
||||||
"analytics": "Análise",
|
"analytics": "Análise",
|
||||||
"compliance": "Conformidade",
|
"compliance": "Conformidade",
|
||||||
"cohorts": "Programas"
|
"cohorts": "Programas",
|
||||||
|
"upgrade_now": "Atualize agora",
|
||||||
|
"upgrade_students": "{studentCount} de {studentLimit} alunos",
|
||||||
|
"upgrade_left": "{contagem} restantes"
|
||||||
},
|
},
|
||||||
"media_manager": {
|
"media_manager": {
|
||||||
"page_title": "Gerenciador de mídia - ClassroomIO",
|
"page_title": "Gerenciador de mídia - ClassroomIO",
|
||||||
@@ -3636,7 +3642,8 @@
|
|||||||
"widgets": "Widgets",
|
"widgets": "Widgets",
|
||||||
"tags": "Etiquetas",
|
"tags": "Etiquetas",
|
||||||
"students": "Alunos",
|
"students": "Alunos",
|
||||||
"settings": "Configurações"
|
"settings": "Configurações",
|
||||||
|
"cohorts": "Coortes"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -709,7 +709,8 @@
|
|||||||
"cancel": "Отмена",
|
"cancel": "Отмена",
|
||||||
"confirm": "Удалить",
|
"confirm": "Удалить",
|
||||||
"snackbar_success": "Участник удален"
|
"snackbar_success": "Участник удален"
|
||||||
}
|
},
|
||||||
|
"import_limit_reached": "Вы достигли лимита учащихся, предусмотренного вашим планом. Обновите, чтобы импортировать больше студентов."
|
||||||
},
|
},
|
||||||
"community": {
|
"community": {
|
||||||
"title": "Сообщество",
|
"title": "Сообщество",
|
||||||
@@ -894,7 +895,8 @@
|
|||||||
"back_to_course": "Вернуться к курсу",
|
"back_to_course": "Вернуться к курсу",
|
||||||
"join_course": "Присоединиться к курсу",
|
"join_course": "Присоединиться к курсу",
|
||||||
"enrolled_success": "Вы записаны на этот курс.",
|
"enrolled_success": "Вы записаны на этот курс.",
|
||||||
"enrolled_verify_email": "Вы записаны! Подтвердите email, чтобы получить доступ к курсу."
|
"enrolled_verify_email": "Вы записаны! Подтвердите email, чтобы получить доступ к курсу.",
|
||||||
|
"student_limit_reached": "В {orgName} достигнута вместимость учащихся. Обратитесь к администратору вашей организации."
|
||||||
},
|
},
|
||||||
"pricing_section": {
|
"pricing_section": {
|
||||||
"free": "Бесплатно",
|
"free": "Бесплатно",
|
||||||
@@ -1226,7 +1228,8 @@
|
|||||||
"multi_use_7d": "Многоразовое использование (7 дней)",
|
"multi_use_7d": "Многоразовое использование (7 дней)",
|
||||||
"multi_use_30d": "Многоразовое использование (30 дней)",
|
"multi_use_30d": "Многоразовое использование (30 дней)",
|
||||||
"custom": "Пользовательский"
|
"custom": "Пользовательский"
|
||||||
}
|
},
|
||||||
|
"student_limit_reached": "Вы достигли лимита учащихся, предусмотренного вашим планом. Обновите, чтобы пригласить больше студентов."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"marks": {
|
"marks": {
|
||||||
@@ -2811,7 +2814,10 @@
|
|||||||
"stats": "Статистика",
|
"stats": "Статистика",
|
||||||
"analytics": "Аналитика",
|
"analytics": "Аналитика",
|
||||||
"compliance": "Соответствие",
|
"compliance": "Соответствие",
|
||||||
"cohorts": "Программы"
|
"cohorts": "Программы",
|
||||||
|
"upgrade_now": "Обновите сейчас",
|
||||||
|
"upgrade_students": "{studentCount} из {studentLimit} студентов",
|
||||||
|
"upgrade_left": "{count} осталось"
|
||||||
},
|
},
|
||||||
"media_manager": {
|
"media_manager": {
|
||||||
"page_title": "Медиа-менеджер — ClassroomIO",
|
"page_title": "Медиа-менеджер — ClassroomIO",
|
||||||
@@ -3636,7 +3642,8 @@
|
|||||||
"widgets": "Виджеты",
|
"widgets": "Виджеты",
|
||||||
"tags": "Теги",
|
"tags": "Теги",
|
||||||
"students": "Студенты",
|
"students": "Студенты",
|
||||||
"settings": "Настройки"
|
"settings": "Настройки",
|
||||||
|
"cohorts": "когорты"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -709,7 +709,8 @@
|
|||||||
"cancel": "Hủy bỏ",
|
"cancel": "Hủy bỏ",
|
||||||
"confirm": "Xóa",
|
"confirm": "Xóa",
|
||||||
"snackbar_success": "Thành viên đã bị 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": {
|
"community": {
|
||||||
"title": "cộng đồng",
|
"title": "cộng đồng",
|
||||||
@@ -894,7 +895,8 @@
|
|||||||
"back_to_course": "Quay lại khóa học",
|
"back_to_course": "Quay lại khóa học",
|
||||||
"join_course": "Tham gia 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_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": {
|
"pricing_section": {
|
||||||
"free": "miễn phí",
|
"free": "miễn phí",
|
||||||
@@ -1226,7 +1228,8 @@
|
|||||||
"multi_use_7d": "Sử dụng nhiều lần (7 ngày)",
|
"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)",
|
"multi_use_30d": "Sử dụng nhiều lần (30 ngày)",
|
||||||
"custom": "tùy chỉnh"
|
"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": {
|
"marks": {
|
||||||
@@ -2811,7 +2814,10 @@
|
|||||||
"stats": "Thống kê",
|
"stats": "Thống kê",
|
||||||
"analytics": "Phân tích",
|
"analytics": "Phân tích",
|
||||||
"compliance": "Tuân thủ",
|
"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": {
|
"media_manager": {
|
||||||
"page_title": "Quản lý truyền thông - ClassroomIO",
|
"page_title": "Quản lý truyền thông - ClassroomIO",
|
||||||
@@ -3636,7 +3642,8 @@
|
|||||||
"widgets": "Widget",
|
"widgets": "Widget",
|
||||||
"tags": "Thẻ",
|
"tags": "Thẻ",
|
||||||
"students": "Sinh viên",
|
"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 { shortenName } from '$lib/utils/functions/string';
|
||||||
import { ROLE } from '@cio/utils/constants';
|
import { ROLE } from '@cio/utils/constants';
|
||||||
import { ROLE_LABEL } from '$lib/utils/constants/roles';
|
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 { profile } from '$lib/utils/store/user';
|
||||||
|
import { UpgradeBanner } from '$features/ui';
|
||||||
import InviteMembersModal from '$features/cohort/components/invite-members-modal.svelte';
|
import InviteMembersModal from '$features/cohort/components/invite-members-modal.svelte';
|
||||||
import GoalsOverviewTiles from '$features/cohort/components/goals/goals-overview-tiles.svelte';
|
import GoalsOverviewTiles from '$features/cohort/components/goals/goals-overview-tiles.svelte';
|
||||||
import { cohortApi } from '$features/cohort/api';
|
import { cohortApi } from '$features/cohort/api';
|
||||||
@@ -148,6 +149,10 @@
|
|||||||
|
|
||||||
<Page.Body>
|
<Page.Body>
|
||||||
{#snippet child()}
|
{#snippet child()}
|
||||||
|
{#if $isStudentLimitReached}
|
||||||
|
<UpgradeBanner className="mb-2">{$t('course.navItem.people.invite_modal.student_limit_reached')}</UpgradeBanner>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<GoalsOverviewTiles cohortId={data.cohortId} />
|
<GoalsOverviewTiles cohortId={data.cohortId} />
|
||||||
|
|
||||||
<section class="space-y-2">
|
<section class="space-y-2">
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const audienceLength = $derived(data.pagination?.total || 0);
|
const audienceLength = $derived(data.pagination?.total || 0);
|
||||||
|
const atStudentLimit = $derived(audienceLength >= $currentOrgMaxAudience);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -41,7 +42,12 @@
|
|||||||
<Button variant="outline" onclick={exportAudience} disabled={isLoading} loading={isLoading}>
|
<Button variant="outline" onclick={exportAudience} disabled={isLoading} loading={isLoading}>
|
||||||
{$t('audience.export')}
|
{$t('audience.export')}
|
||||||
</Button>
|
</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')}
|
{$t('audience.import_users')}
|
||||||
</Button>
|
</Button>
|
||||||
</Page.Action>
|
</Page.Action>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export const load = async ({ params, url }) => {
|
|||||||
inviteEmail: data.inviteContext?.recipientEmail ?? null,
|
inviteEmail: data.inviteContext?.recipientEmail ?? null,
|
||||||
inviteEmailExists: data.inviteContext?.recipientExists ?? false,
|
inviteEmailExists: data.inviteContext?.recipientExists ?? false,
|
||||||
requiresPaymentOrInvite: false,
|
requiresPaymentOrInvite: false,
|
||||||
|
studentLimitReached: false,
|
||||||
token: inviteToken
|
token: inviteToken
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -97,6 +98,7 @@ export const load = async ({ params, url }) => {
|
|||||||
currentOrg,
|
currentOrg,
|
||||||
invite: null,
|
invite: null,
|
||||||
requiresPaymentOrInvite: !isFree,
|
requiresPaymentOrInvite: !isFree,
|
||||||
|
studentLimitReached: courseData.studentLimitReached ?? false,
|
||||||
token: null as string | null
|
token: null as string | null
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,8 +54,14 @@
|
|||||||
|
|
||||||
const inviteStatus = $derived(data.invite?.status ?? 'INVALID');
|
const inviteStatus = $derived(data.invite?.status ?? 'INVALID');
|
||||||
const hasActiveInvite = $derived(Boolean(data.invite) && inviteStatus === 'ACTIVE');
|
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(
|
const canJoinCourse = $derived(
|
||||||
data.requiresPaymentOrInvite
|
data.requiresPaymentOrInvite || blocksNewSignup
|
||||||
? false
|
? false
|
||||||
: (hasActiveInvite || (!data.invite && data.course?.allowNewStudent !== false)) &&
|
: (hasActiveInvite || (!data.invite && data.course?.allowNewStudent !== false)) &&
|
||||||
data.course?.status === 'ACTIVE' &&
|
data.course?.status === 'ACTIVE' &&
|
||||||
@@ -63,6 +69,11 @@
|
|||||||
);
|
);
|
||||||
|
|
||||||
function getBlockedMessage(): string {
|
function getBlockedMessage(): string {
|
||||||
|
if (blocksNewSignup) {
|
||||||
|
return t.get('course.navItem.landing_page.enroll_page.student_limit_reached', {
|
||||||
|
orgName: data.currentOrg?.name ?? ''
|
||||||
|
});
|
||||||
|
}
|
||||||
if (data.requiresPaymentOrInvite) {
|
if (data.requiresPaymentOrInvite) {
|
||||||
return t.get('course.navItem.landing_page.enroll_page.requires_payment_or_invite');
|
return t.get('course.navItem.landing_page.enroll_page.requires_payment_or_invite');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,11 +36,34 @@ import { and, eq } from 'drizzle-orm';
|
|||||||
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
|
import { exerciseBelongsToCourse } from '@cio/db/queries/course/certification-exercise';
|
||||||
import { updateExercise, updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
|
import { updateExercise, updateExercisesSectionId } from '@cio/db/queries/exercise/exercise';
|
||||||
import { getProfileById } from '@cio/db/queries/auth';
|
import { getProfileById } from '@cio/db/queries/auth';
|
||||||
import { insertOrganizationMembersOnConflictDoNothing } from '@cio/db/queries/organization';
|
import {
|
||||||
|
countActiveStudents,
|
||||||
|
getActiveOrganizationPlan,
|
||||||
|
getOrganizationMemberIdByOrgAndProfile,
|
||||||
|
insertOrganizationMembersOnConflictDoNothing
|
||||||
|
} from '@cio/db/queries/organization';
|
||||||
|
import { getStudentLimit } from '@cio/utils/plans';
|
||||||
|
import { env } from '../../config/env';
|
||||||
import { annotateCourseContentWithProgression } from './progression';
|
import { annotateCourseContentWithProgression } from './progression';
|
||||||
import { buildCourseContent, calcPercentageWithRounding, type CourseContent } from './utils';
|
import { buildCourseContent, calcPercentageWithRounding, type CourseContent } from './utils';
|
||||||
import { guardCourseTypeTransition } from './public-course-guard';
|
import { guardCourseTypeTransition } from './public-course-guard';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a *new* student would be turned away from this org right now
|
||||||
|
* (mirrors the check in `apps/api/src/services/organization/student-limit.ts`,
|
||||||
|
* duplicated here since packages/core can't depend on the api app).
|
||||||
|
*/
|
||||||
|
async function isStudentLimitReached(orgId: string): Promise<boolean> {
|
||||||
|
if (env.PUBLIC_IS_SELFHOSTED === 'true') return false;
|
||||||
|
|
||||||
|
const activePlan = await getActiveOrganizationPlan(orgId);
|
||||||
|
const limit = getStudentLimit(activePlan?.planName);
|
||||||
|
if (!Number.isFinite(limit)) return false;
|
||||||
|
|
||||||
|
const currentCount = await countActiveStudents(orgId);
|
||||||
|
return currentCount + 1 > limit;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_CONTENT_GROUPING = true;
|
const DEFAULT_CONTENT_GROUPING = true;
|
||||||
const DEFAULT_SECTION_TITLE = 'First Section [edit me]';
|
const DEFAULT_SECTION_TITLE = 'First Section [edit me]';
|
||||||
|
|
||||||
@@ -57,6 +80,17 @@ export async function ensureProgramCourseAccess(courseId: string, profileId: str
|
|||||||
|
|
||||||
const normalizedEmail = access.profileEmail?.trim() || undefined;
|
const normalizedEmail = access.profileEmail?.trim() || undefined;
|
||||||
|
|
||||||
|
if (access.roleId === ROLE.STUDENT) {
|
||||||
|
const existingMemberId = await getOrganizationMemberIdByOrgAndProfile(organizationId, profileId);
|
||||||
|
if (!existingMemberId && (await isStudentLimitReached(organizationId))) {
|
||||||
|
throw new AppError(
|
||||||
|
'This organization has reached its student limit on the Free plan',
|
||||||
|
ErrorCodes.UPGRADE_REQUIRED,
|
||||||
|
403
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
await insertOrganizationMembersOnConflictDoNothing(
|
await insertOrganizationMembersOnConflictDoNothing(
|
||||||
[
|
[
|
||||||
@@ -160,9 +194,12 @@ export async function getCourse(courseId?: string, slug?: string, profileId?: st
|
|||||||
|
|
||||||
const { contentItems, org: courseOrg, ...rest } = course;
|
const { contentItems, org: courseOrg, ...rest } = course;
|
||||||
|
|
||||||
|
const studentLimitReached = courseOrg ? await isStudentLimitReached(courseOrg.id) : false;
|
||||||
|
|
||||||
const base = {
|
const base = {
|
||||||
...rest,
|
...rest,
|
||||||
content,
|
content,
|
||||||
|
studentLimitReached,
|
||||||
metadata: {
|
metadata: {
|
||||||
...course.metadata,
|
...course.metadata,
|
||||||
progressionMode
|
progressionMode
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"db:login-link": "tsx src/scripts/generate-login-link.ts",
|
"db:login-link": "tsx src/scripts/generate-login-link.ts",
|
||||||
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts",
|
"db:sync-orphaned-profiles": "tsx src/scripts/sync-orphaned-profiles.ts",
|
||||||
"db:backfill-profile-email-verification": "tsx src/scripts/backfill-profile-email-verification.ts",
|
"db:backfill-profile-email-verification": "tsx src/scripts/backfill-profile-email-verification.ts",
|
||||||
|
"db:notify-students-over-limit": "tsx src/scripts/notify-students-over-limit.ts",
|
||||||
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
|
"move-users": "tsx src/scripts/supabase-to-betterauth.ts",
|
||||||
"pull": "drizzle-kit pull --config drizzle.config.ts",
|
"pull": "drizzle-kit pull --config drizzle.config.ts",
|
||||||
"generate-types": "tsx scripts/generate-types.ts && pnpm format",
|
"generate-types": "tsx scripts/generate-types.ts && pnpm format",
|
||||||
|
|||||||
@@ -567,6 +567,58 @@ export const getOrganizationTeam = async (orgId: string) => {
|
|||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts active organization members with the student role. Used to enforce per-plan student caps.
|
||||||
|
*/
|
||||||
|
export async function countActiveStudents(orgId: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
const [row] = await db
|
||||||
|
.select({ count: count(schema.organizationmember.id) })
|
||||||
|
.from(schema.organizationmember)
|
||||||
|
.where(
|
||||||
|
and(eq(schema.organizationmember.organizationId, orgId), eq(schema.organizationmember.roleId, ROLE.STUDENT))
|
||||||
|
);
|
||||||
|
|
||||||
|
return Number(row?.count ?? 0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('countActiveStudents error:', error);
|
||||||
|
throw new Error('Failed to count active students');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets verified admin emails for an organization. Used to notify admins about plan-limit events.
|
||||||
|
*/
|
||||||
|
export async function getOrganizationAdminEmails(orgId: string): Promise<Array<{ email: string; fullname: string }>> {
|
||||||
|
try {
|
||||||
|
const result = await db
|
||||||
|
.select({
|
||||||
|
email: schema.organizationmember.email,
|
||||||
|
profileEmail: schema.profile.email,
|
||||||
|
fullname: schema.profile.fullname
|
||||||
|
})
|
||||||
|
.from(schema.organizationmember)
|
||||||
|
.leftJoin(schema.profile, eq(schema.organizationmember.profileId, schema.profile.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(schema.organizationmember.organizationId, orgId),
|
||||||
|
eq(schema.organizationmember.roleId, ROLE.ADMIN),
|
||||||
|
eq(schema.organizationmember.verified, true)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return result
|
||||||
|
.map((member) => ({
|
||||||
|
email: member.profileEmail || member.email || '',
|
||||||
|
fullname: member.fullname || ''
|
||||||
|
}))
|
||||||
|
.filter((member) => member.email);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('getOrganizationAdminEmails error:', error);
|
||||||
|
throw new Error('Failed to get organization admin emails');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets organization audience (all organization members with student role).
|
* Gets organization audience (all organization members with student role).
|
||||||
* Includes invited members without a profile (LEFT JOIN profile).
|
* Includes invited members without a profile (LEFT JOIN profile).
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { TOrganization, TOrganizationPlan } from '@db/types';
|
import { TOrganization, TOrganizationPlan } from '@db/types';
|
||||||
|
import type { OrgUsageLimits } from '@cio/utils/plans';
|
||||||
|
|
||||||
export type OrganizationPlan = Pick<TOrganizationPlan, 'planName' | 'isActive' | 'provider' | 'subscriptionId'> & {
|
export type OrganizationPlan = Pick<TOrganizationPlan, 'planName' | 'isActive' | 'provider' | 'subscriptionId'> & {
|
||||||
customerId: string | null;
|
customerId: string | null;
|
||||||
@@ -8,6 +9,7 @@ export type OrganizationWithMemberAndPlans = TOrganization & {
|
|||||||
memberId?: number;
|
memberId?: number;
|
||||||
roleId?: number;
|
roleId?: number;
|
||||||
plans: OrganizationPlan[];
|
plans: OrganizationPlan[];
|
||||||
|
limits?: OrgUsageLimits;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OrganizationWithPlans = TOrganization & {
|
export type OrganizationWithPlans = TOrganization & {
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* One-time (manual, not cron) backfill: email admins of every free-plan org
|
||||||
|
* that already exceeds the student limit, so they know new enrolments/invites
|
||||||
|
* are now blocked. Dry-run by default; pass --execute to actually send.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* pnpm db:notify-students-over-limit # dry run (default)
|
||||||
|
* pnpm db:notify-students-over-limit -- --execute # send emails
|
||||||
|
*/
|
||||||
|
import 'dotenv/config';
|
||||||
|
|
||||||
|
import { buildEmailBranding, sendEmail } from '@cio/email';
|
||||||
|
import { PLAN, getStudentLimit, isOrgOnFreePlan } from '@cio/utils/plans';
|
||||||
|
|
||||||
|
import {
|
||||||
|
countActiveStudents,
|
||||||
|
getOrganizationAdminEmails,
|
||||||
|
getOrganizations
|
||||||
|
} from '../queries/organization/organization';
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const execute = process.argv.includes('--execute');
|
||||||
|
const orgs = await getOrganizations();
|
||||||
|
const limit = getStudentLimit(PLAN.BASIC);
|
||||||
|
|
||||||
|
let notified = 0;
|
||||||
|
|
||||||
|
for (const org of orgs) {
|
||||||
|
const isFree = isOrgOnFreePlan({ plans: org.plans, isSelfHosted: false, orgId: org.id });
|
||||||
|
if (!isFree) continue;
|
||||||
|
|
||||||
|
const studentCount = await countActiveStudents(org.id);
|
||||||
|
if (studentCount <= limit) continue;
|
||||||
|
|
||||||
|
const admins = await getOrganizationAdminEmails(org.id);
|
||||||
|
if (!admins.length) {
|
||||||
|
console.log(`Skipping ${org.name} (${org.id}): ${studentCount}/${limit} but no admin emails`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${execute ? 'Notifying' : '[dry-run] Would notify'} ${org.name} (${org.id}): ${studentCount}/${limit} students → ${admins.length} admin(s)`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (execute) {
|
||||||
|
const branding = buildEmailBranding({ name: org.name, avatarUrl: org.avatarUrl, theme: org.theme });
|
||||||
|
const upgradeUrl = `https://app.classroomio.com/org/${org.siteName ?? ''}?upgrade=true`;
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
admins.map((admin) =>
|
||||||
|
sendEmail('studentLimitReached', {
|
||||||
|
to: admin.email,
|
||||||
|
fields: { orgName: org.name, studentCount, studentLimit: limit, upgradeUrl, branding }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
notified++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(execute ? `Done. Notified ${notified} org(s).` : 'Dry run complete — re-run with --execute to send.');
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(() => process.exit(0))
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -26,6 +26,7 @@ export * from './quiz-assigned';
|
|||||||
export * from './session';
|
export * from './session';
|
||||||
export * from './submission-received';
|
export * from './submission-received';
|
||||||
export * from './submission-graded';
|
export * from './submission-graded';
|
||||||
|
export * from './student-limit-reached';
|
||||||
|
|
||||||
import type { newsfeedCommentEmail, newsfeedPostEmail } from './newsfeed';
|
import type { newsfeedCommentEmail, newsfeedPostEmail } from './newsfeed';
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ import type { quizAssignedEmail } from './quiz-assigned';
|
|||||||
import type { sessionReminderEmail, sessionUpdatedEmail } from './session';
|
import type { sessionReminderEmail, sessionUpdatedEmail } from './session';
|
||||||
import type { submissionReceivedEmail } from './submission-received';
|
import type { submissionReceivedEmail } from './submission-received';
|
||||||
import type { submissionGradedEmail } from './submission-graded';
|
import type { submissionGradedEmail } from './submission-graded';
|
||||||
|
import type { studentLimitReachedEmail } from './student-limit-reached';
|
||||||
import type { teacherCourseWelcomeEmail } from './teacher-course-welcome';
|
import type { teacherCourseWelcomeEmail } from './teacher-course-welcome';
|
||||||
import type { teacherStudentBuyRequestEmail } from './teacher-student-buy-request';
|
import type { teacherStudentBuyRequestEmail } from './teacher-student-buy-request';
|
||||||
import type { teacherStudentJoinedEmail } from './teacher-student-joined';
|
import type { teacherStudentJoinedEmail } from './teacher-student-joined';
|
||||||
@@ -79,4 +81,5 @@ export type EmailSchemas = {
|
|||||||
sessionUpdated: typeof sessionUpdatedEmail.template.schema;
|
sessionUpdated: typeof sessionUpdatedEmail.template.schema;
|
||||||
submissionReceived: typeof submissionReceivedEmail.template.schema;
|
submissionReceived: typeof submissionReceivedEmail.template.schema;
|
||||||
submissionGraded: typeof submissionGradedEmail.template.schema;
|
submissionGraded: typeof submissionGradedEmail.template.schema;
|
||||||
|
studentLimitReached: typeof studentLimitReachedEmail.template.schema;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as z from 'zod';
|
||||||
|
|
||||||
|
import { defineEmail } from '../send';
|
||||||
|
import { getDefaultTemplate } from '../templates';
|
||||||
|
import { ZEmailBranding } from '../core/branding';
|
||||||
|
|
||||||
|
export const studentLimitReachedEmail = defineEmail({
|
||||||
|
id: 'studentLimitReached',
|
||||||
|
subject: "You've reached your Free plan student limit",
|
||||||
|
schema: z.object({
|
||||||
|
orgName: z.string().min(1),
|
||||||
|
studentCount: z.number(),
|
||||||
|
studentLimit: z.number(),
|
||||||
|
upgradeUrl: z.string().url(),
|
||||||
|
branding: ZEmailBranding
|
||||||
|
}),
|
||||||
|
render: (fields) => {
|
||||||
|
const content = `
|
||||||
|
<p>Hi there,</p>
|
||||||
|
<p><strong>${fields.orgName}</strong> has reached the ${fields.studentLimit}-student limit on the Free plan (currently ${fields.studentCount} students). New students can't enrol or be invited until you upgrade.</p>
|
||||||
|
<div>
|
||||||
|
<a class="button" href="${fields.upgradeUrl}">Upgrade your plan</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return getDefaultTemplate(content, fields.branding);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -12,6 +12,7 @@ export const EMAIL_IDS = [
|
|||||||
'sessionUpdated',
|
'sessionUpdated',
|
||||||
'submissionGraded',
|
'submissionGraded',
|
||||||
'submissionReceived',
|
'submissionReceived',
|
||||||
|
'studentLimitReached',
|
||||||
'studentCourseInvite',
|
'studentCourseInvite',
|
||||||
'studentCourseCompletion',
|
'studentCourseCompletion',
|
||||||
'studentCourseWelcome',
|
'studentCourseWelcome',
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import Root from './progress.svelte';
|
import Root, { type ProgressVariant, progressVariants } from './progress.svelte';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Root,
|
Root,
|
||||||
//
|
//
|
||||||
Root as Progress
|
Root as Progress,
|
||||||
|
progressVariants,
|
||||||
|
type ProgressVariant
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,25 @@
|
|||||||
|
<script lang="ts" module>
|
||||||
|
import { type VariantProps, tv } from 'tailwind-variants';
|
||||||
|
|
||||||
|
export const progressVariants = tv({
|
||||||
|
slots: {
|
||||||
|
root: 'ui:relative ui:h-2 ui:w-full ui:overflow-hidden ui:rounded-md',
|
||||||
|
indicator: 'ui:h-full ui:w-full ui:flex-1 ui:transition-all'
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: { root: 'ui:bg-primary/20', indicator: 'ui:bg-primary' },
|
||||||
|
muted: { root: 'ui:bg-muted', indicator: 'ui:bg-muted-foreground/40' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ProgressVariant = VariantProps<typeof progressVariants>['variant'];
|
||||||
|
</script>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Progress as ProgressPrimitive } from 'bits-ui';
|
import { Progress as ProgressPrimitive } from 'bits-ui';
|
||||||
import { cn, type WithoutChildrenOrChild } from '../../tools';
|
import { cn, type WithoutChildrenOrChild } from '../../tools';
|
||||||
@@ -5,23 +27,19 @@
|
|||||||
let {
|
let {
|
||||||
ref = $bindable(null),
|
ref = $bindable(null),
|
||||||
class: className,
|
class: className,
|
||||||
|
variant = 'default',
|
||||||
max = 100,
|
max = 100,
|
||||||
value,
|
value,
|
||||||
...restProps
|
...restProps
|
||||||
}: WithoutChildrenOrChild<ProgressPrimitive.RootProps> = $props();
|
}: WithoutChildrenOrChild<ProgressPrimitive.RootProps> & { variant?: ProgressVariant } = $props();
|
||||||
|
|
||||||
|
const styles = $derived(progressVariants({ variant }));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<ProgressPrimitive.Root
|
<ProgressPrimitive.Root bind:ref data-slot="progress" class={cn(styles.root(), className)} {value} {max} {...restProps}>
|
||||||
bind:ref
|
|
||||||
data-slot="progress"
|
|
||||||
class={cn('ui:bg-primary/20 ui:relative ui:h-2 ui:w-full ui:overflow-hidden ui:rounded-md', className)}
|
|
||||||
{value}
|
|
||||||
{max}
|
|
||||||
{...restProps}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
data-slot="progress-indicator"
|
data-slot="progress-indicator"
|
||||||
class="ui:bg-primary ui:h-full ui:w-full ui:flex-1 ui:transition-all"
|
class={styles.indicator()}
|
||||||
style="transform: translateX(-{100 - (100 * (value ?? 0)) / (max ?? 1)}%)"
|
style="transform: translateX(-{100 - (100 * (value ?? 0)) / (max ?? 1)}%)"
|
||||||
></div>
|
></div>
|
||||||
</ProgressPrimitive.Root>
|
</ProgressPrimitive.Root>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -62,3 +62,17 @@ export function getWorkspaceAllowance(planName: string | null | undefined): numb
|
|||||||
export function canCreateWorkspaces(planName: string | null | undefined): boolean {
|
export function canCreateWorkspaces(planName: string | null | undefined): boolean {
|
||||||
return planName === PLAN.ENTERPRISE;
|
return planName === PLAN.ENTERPRISE;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Max students allowed per plan (org-wide, counts ROLE.STUDENT organizationmember rows).
|
||||||
|
*/
|
||||||
|
export const STUDENT_LIMITS: Record<string, number> = {
|
||||||
|
[PLAN.BASIC]: 20,
|
||||||
|
[PLAN.EARLY_ADOPTER]: 10000,
|
||||||
|
[PLAN.ENTERPRISE]: Infinity
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStudentLimit(planName: string | null | undefined): number {
|
||||||
|
if (!planName) return STUDENT_LIMITS[PLAN.BASIC];
|
||||||
|
return STUDENT_LIMITS[planName] ?? STUDENT_LIMITS[PLAN.BASIC];
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
export * from './constants';
|
export * from './constants';
|
||||||
|
export * from './limits';
|
||||||
export * from './automation';
|
export * from './automation';
|
||||||
export * from './public-api';
|
export * from './public-api';
|
||||||
export * from './org-plan';
|
export * from './org-plan';
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { PLAN, STUDENT_LIMITS } from './constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Org-scoped resources that carry a per-plan limit and are surfaced to the
|
||||||
|
* dashboard. Extend this tuple (plus PLAN_LIMITS) to add a new limited resource.
|
||||||
|
*/
|
||||||
|
export const PLAN_LIMIT_RESOURCES = ['students'] as const;
|
||||||
|
export type PlanLimitResource = (typeof PLAN_LIMIT_RESOURCES)[number];
|
||||||
|
|
||||||
|
/** Per-resource, per-plan numeric caps (Infinity = unlimited). */
|
||||||
|
export const PLAN_LIMITS: Record<PlanLimitResource, Record<string, number>> = {
|
||||||
|
students: STUDENT_LIMITS
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPlanLimit(resource: PlanLimitResource, planName: string | null | undefined): number {
|
||||||
|
const table = PLAN_LIMITS[resource];
|
||||||
|
if (!planName) return table[PLAN.BASIC];
|
||||||
|
|
||||||
|
return table[planName] ?? table[PLAN.BASIC];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** JSON-safe usage record carried in the account payload. `limit: null` = unlimited. */
|
||||||
|
export type ResourceUsage = { used: number; limit: number | null };
|
||||||
|
export type OrgUsageLimits = Partial<Record<PlanLimitResource, ResourceUsage>>;
|
||||||
|
|
||||||
|
/** Build a usage record, converting a non-JSON-safe Infinity limit to null. */
|
||||||
|
export function toResourceUsage(used: number, planLimit: number): ResourceUsage {
|
||||||
|
return { used, limit: Number.isFinite(planLimit) ? planLimit : null };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isResourceLimitReached(usage: ResourceUsage | undefined): boolean {
|
||||||
|
if (!usage || usage.limit === null) return false;
|
||||||
|
|
||||||
|
return usage.used >= usage.limit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resourceLimitRemaining(usage: ResourceUsage | undefined): number | null {
|
||||||
|
if (!usage || usage.limit === null) return null;
|
||||||
|
|
||||||
|
return Math.max(0, usage.limit - usage.used);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user