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:
@@ -8,10 +8,11 @@
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { currentOrg, isStudentLimitReached } from '$lib/utils/store/org';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
||||
import { BulkEmailSection, ExistingStudentsSection, TutorSelectSection } from '$features/people/components';
|
||||
import { UpgradeBanner } from '$features/ui';
|
||||
import type { OrgStudent, Tutor } from '$features/people/utils/types';
|
||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||
import { cohortApi } from '../api';
|
||||
@@ -220,7 +221,10 @@
|
||||
onSearchValueChange={handleStudentSearch}
|
||||
onAssign={assignExistingStudents}
|
||||
/>
|
||||
<BulkEmailSection onInvite={inviteNewStudents} />
|
||||
{#if $isStudentLimitReached}
|
||||
<UpgradeBanner removeParams={['add']}>{$t(`${INVITE_MODAL}.student_limit_reached`)}</UpgradeBanner>
|
||||
{/if}
|
||||
<BulkEmailSection onInvite={inviteNewStudents} disabled={$isStudentLimitReached} />
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
|
||||
+12
-2
@@ -3,9 +3,16 @@
|
||||
import { resolve } from '$app/paths';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { BackButton } from '@cio/ui';
|
||||
import { HoverableItem, NewsFeedIcon, CourseIcon, PeopleIcon, SettingsIcon } from '@cio/ui/custom/moving-icons';
|
||||
import {
|
||||
HoverableItem,
|
||||
NewsFeedIcon,
|
||||
CourseIcon,
|
||||
PeopleIcon,
|
||||
PremiumIcon,
|
||||
SettingsIcon
|
||||
} from '@cio/ui/custom/moving-icons';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrgPath, isOrgAdmin } from '$lib/utils/store/org';
|
||||
import { currentOrgPath, isOrgAdmin, isStudentLimitReached } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { cohortApi } from '$features/cohort/api';
|
||||
@@ -88,6 +95,9 @@
|
||||
<a href={resolve(item.url, {})} {...props}>
|
||||
<Icon {isHovered} size={16} />
|
||||
<span>{item.title}</span>
|
||||
{#if item.id === 'people' && $isStudentLimitReached}
|
||||
<PremiumIcon {isHovered} size={16} class="ui:text-primary ml-auto" />
|
||||
{/if}
|
||||
</a>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
|
||||
@@ -27,6 +27,7 @@ import { mapZodErrorsToTranslations } from '$lib/utils/validation';
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import { resolve } from '$app/paths';
|
||||
import { snackbar } from '$features/ui/snackbar/store';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { coursesApi } from './courses.svelte';
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { ContentType } from '@cio/utils/constants/content';
|
||||
@@ -310,6 +311,17 @@ export class CourseApi extends BaseApiWithErrors {
|
||||
onError: (result) => {
|
||||
if (typeof result === 'string') {
|
||||
snackbar.error('snackbar.invite.failed_join');
|
||||
} else if (
|
||||
typeof result === 'object' &&
|
||||
result !== null &&
|
||||
'code' in result &&
|
||||
result.code === 'UPGRADE_REQUIRED'
|
||||
) {
|
||||
snackbar.error(
|
||||
t.get('course.navItem.landing_page.enroll_page.student_limit_reached', {
|
||||
orgName: get(currentOrg)?.name ?? ''
|
||||
})
|
||||
);
|
||||
} else if (typeof result === 'object' && result !== null && 'error' in result) {
|
||||
snackbar.error((result as { error: string }).error);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { ROLE } from '@cio/utils/constants';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { courseApi } from '$features/course/api';
|
||||
import { currentOrg } from '$lib/utils/store/org';
|
||||
import { currentOrg, isStudentLimitReached } from '$lib/utils/store/org';
|
||||
import { peopleApi } from '$features/course/api';
|
||||
import { orgApi } from '$features/org/api/org.svelte';
|
||||
import { DEFAULT_ORG_AUDIENCE_QUERY } from '$features/org/utils/audience-query-utils';
|
||||
@@ -17,6 +17,7 @@
|
||||
import type { OrgTeamMember } from '$lib/utils/types/org';
|
||||
import type { OrgStudent, Tutor } from '$features/people/utils/types';
|
||||
import { TutorSelectSection, ExistingStudentsSection, BulkEmailSection } from '$features/people/components';
|
||||
import { UpgradeBanner } from '$features/ui';
|
||||
|
||||
let tutors = $state<Tutor[]>([]);
|
||||
let selectedIds = $state<string[]>([]);
|
||||
@@ -216,7 +217,11 @@
|
||||
onAssign={assignExistingStudents}
|
||||
/>
|
||||
|
||||
<BulkEmailSection onInvite={inviteNewStudents} />
|
||||
{#if $isStudentLimitReached}
|
||||
<UpgradeBanner removeParams={['add']}>{$t(`${INVITE_MODAL}.student_limit_reached`)}</UpgradeBanner>
|
||||
{/if}
|
||||
|
||||
<BulkEmailSection onInvite={inviteNewStudents} disabled={$isStudentLimitReached} />
|
||||
</div>
|
||||
</UnderlineTabs.Content>
|
||||
</UnderlineTabs.Root>
|
||||
|
||||
+6
-1
@@ -19,6 +19,7 @@
|
||||
MarksIcon,
|
||||
NewsFeedIcon,
|
||||
PeopleIcon,
|
||||
PremiumIcon,
|
||||
SettingsIcon,
|
||||
SubmissionIcon
|
||||
} from '@cio/ui/custom/moving-icons';
|
||||
@@ -31,7 +32,7 @@
|
||||
import { NAV_IDS } from './constants';
|
||||
import { complianceApi, courseApi } from '$features/course/api';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { currentOrg, isFreePlan, currentOrgPath } from '$lib/utils/store/org';
|
||||
import { currentOrg, isFreePlan, isStudentLimitReached, currentOrgPath } from '$lib/utils/store/org';
|
||||
import { isStudentExperience } from '$lib/utils/store/app';
|
||||
import { getNavItemRoute, getLessonsRoute } from '$features/course/utils/functions';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
@@ -48,6 +49,7 @@
|
||||
let { path, id, isStudent = false }: Props = $props();
|
||||
|
||||
const coursesListPath = $derived($isStudentExperience ? '/lms/mylearning' : `${$currentOrgPath}/courses`);
|
||||
|
||||
const contentData = $derived(getCourseContent(courseApi.course));
|
||||
const sidebar = useSidebar();
|
||||
const contentCount = $derived.by(() => {
|
||||
@@ -406,6 +408,9 @@
|
||||
<Icon {isHovered} size={16} />
|
||||
{/if}
|
||||
<span>{item.title}</span>
|
||||
{#if item.id === NAV_IDS.PEOPLE && $isStudentLimitReached}
|
||||
<PremiumIcon {isHovered} size={16} class="ui:text-primary ml-auto" />
|
||||
{/if}
|
||||
</a>
|
||||
{/snippet}
|
||||
</HoverableItem>
|
||||
|
||||
@@ -9,9 +9,10 @@
|
||||
|
||||
import { Chip } from '@cio/ui/custom/chip';
|
||||
import * as Avatar from '@cio/ui/base/avatar';
|
||||
import { ComingSoon, RoleBasedSecurity } from '$features/ui';
|
||||
import { ComingSoon, RoleBasedSecurity, UpgradeBanner } from '$features/ui';
|
||||
import InvitationModal from '$features/course/components/people/invitation-modal.svelte';
|
||||
import DeleteConfirmation from '$features/course/components/people/delete-confirmation.svelte';
|
||||
import { isStudentLimitReached } from '$lib/utils/store/org';
|
||||
|
||||
import { profile } from '$lib/utils/store/user';
|
||||
import type { CourseMembers, CourseMember } from '$features/course/utils/types';
|
||||
@@ -101,6 +102,10 @@
|
||||
|
||||
<InvitationModal />
|
||||
|
||||
{#if $isStudentLimitReached}
|
||||
<UpgradeBanner className="mb-2">{$t('course.navItem.people.invite_modal.student_limit_reached')}</UpgradeBanner>
|
||||
{/if}
|
||||
|
||||
<DeleteConfirmation email={member.email || (member.profile && member.profile.email)} {deletePerson} />
|
||||
|
||||
<section class="space-y-2">
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
descriptionKey?: string;
|
||||
submitKey?: string;
|
||||
noEmailsKey?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -19,7 +20,8 @@
|
||||
titleKey = 'course.navItem.people.invite_modal.invite_new_students_title',
|
||||
descriptionKey = 'course.navItem.people.invite_modal.invite_new_students_description',
|
||||
submitKey = 'course.navItem.people.invite_modal.invite_new_students',
|
||||
noEmailsKey = 'audience.import.snackbar_no_emails'
|
||||
noEmailsKey = 'audience.import.snackbar_no_emails',
|
||||
disabled = false
|
||||
}: Props = $props();
|
||||
|
||||
let recipientCsv = $state('');
|
||||
@@ -53,12 +55,18 @@
|
||||
bind:value={recipientCsv}
|
||||
rows={4}
|
||||
className="w-full"
|
||||
{disabled}
|
||||
placeholder={$t('audience.import.emails_placeholder')}
|
||||
/>
|
||||
<CheckboxField name="send-invite-emails" label={$t('audience.import.send_email')} bind:checked={sendInviteEmails} />
|
||||
<CheckboxField
|
||||
name="send-invite-emails"
|
||||
label={$t('audience.import.send_email')}
|
||||
bind:checked={sendInviteEmails}
|
||||
{disabled}
|
||||
/>
|
||||
</Field.Group>
|
||||
|
||||
<Button variant="secondary" onclick={inviteStudents} loading={isSubmitting}>
|
||||
<Button variant="secondary" onclick={inviteStudents} loading={isSubmitting} {disabled}>
|
||||
{$t(submitKey)}
|
||||
</Button>
|
||||
</Field.Set>
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { AccountOrg } from '$features/app/types';
|
||||
import BotIcon from '@lucide/svelte/icons/bot';
|
||||
import type { Component } from 'svelte';
|
||||
import { isActive } from '$lib/utils/functions/app';
|
||||
import type { PlanLimitResource } from '@cio/utils/plans';
|
||||
|
||||
export interface NavItem {
|
||||
title: string;
|
||||
@@ -31,6 +32,8 @@ export interface NavItem {
|
||||
matchPattern?: string;
|
||||
items?: NavItem[]; // for nested items like settings
|
||||
isPaid?: boolean; // Show upgrade indicator for free plan users
|
||||
/** True when this item's plan-limited resource has hit its cap (drives the upgrade indicator). */
|
||||
upgrade?: boolean;
|
||||
disabled?: boolean;
|
||||
// Metadata for breadcrumb generation
|
||||
useHashUrl?: boolean; // Use '#' as URL (for collapsible items like settings)
|
||||
@@ -50,6 +53,8 @@ export interface NavItemConfig {
|
||||
supportsDynamicSegment?: boolean; // Supports dynamic segments (like [slug])
|
||||
matchPattern?: string | ((orgSlug: string) => string); // Regex pattern for route matching
|
||||
isPaid?: boolean; // Show upgrade indicator for free plan users
|
||||
/** Plan-limited resource this item represents; when that resource is at its cap, `upgrade` is set. */
|
||||
upgradeResource?: PlanLimitResource;
|
||||
group?: string | null; // Group label key for sidebar grouping
|
||||
}
|
||||
|
||||
@@ -163,6 +168,7 @@ export const baseNavConfig: NavItemConfig[] = [
|
||||
titleKey: 'org_navigation.audience',
|
||||
path: '/audience',
|
||||
icon: PeopleIcon,
|
||||
upgradeResource: 'students',
|
||||
matchPattern: '^/org/[^/]+/audience(/.*)?$' // Matches nested routes
|
||||
},
|
||||
{
|
||||
@@ -376,7 +382,8 @@ export function getOrgNavigationGroups(
|
||||
currentOrg: AccountOrg,
|
||||
isOrgAdmin: boolean | null,
|
||||
t: (key: string) => string,
|
||||
pagePathname: string
|
||||
pagePathname: string,
|
||||
limitsReached: Partial<Record<PlanLimitResource, boolean>> = {}
|
||||
): NavGroup[] {
|
||||
const pathnameOnly = pagePathname.split('?')[0];
|
||||
const groupedMap = new Map<string | null, NavItem[]>();
|
||||
@@ -413,7 +420,8 @@ export function getOrgNavigationGroups(
|
||||
useHashUrl: config.useHashUrl,
|
||||
nestedRoutes: config.nestedRoutes,
|
||||
supportsDynamicSegment: config.supportsDynamicSegment,
|
||||
isPaid: config.isPaid
|
||||
isPaid: config.isPaid,
|
||||
upgrade: config.upgradeResource ? Boolean(limitsReached[config.upgradeResource]) : undefined
|
||||
};
|
||||
|
||||
if (visibleSubConfigs.length > 0) {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<script lang="ts">
|
||||
import { currentOrgPath, currentOrg, isOrgAdmin, isFreePlan } from '$lib/utils/store/org';
|
||||
import { currentOrgPath, currentOrg, isOrgAdmin, isFreePlan, isStudentLimitReached } from '$lib/utils/store/org';
|
||||
import * as Collapsible from '@cio/ui/base/collapsible';
|
||||
import * as Sidebar from '@cio/ui/base/sidebar';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { page } from '$app/state';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import RocketIcon from '@lucide/svelte/icons/rocket';
|
||||
import { getOrgNavigationGroups } from '$features/ui/navigation/org-navigation';
|
||||
import { HoverableItem } from '@cio/ui/custom/moving-icons';
|
||||
import { HoverableItem, PremiumIcon } from '@cio/ui/custom/moving-icons';
|
||||
|
||||
const groups = $derived(
|
||||
getOrgNavigationGroups($currentOrgPath, $currentOrg, $isOrgAdmin, $t, page.url.pathname + page.url.search)
|
||||
getOrgNavigationGroups($currentOrgPath, $currentOrg, $isOrgAdmin, $t, page.url.pathname + page.url.search, {
|
||||
students: $isStudentLimitReached
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -85,6 +86,9 @@
|
||||
{:else}
|
||||
<span>{item.title}</span>
|
||||
{/if}
|
||||
{#if item.upgrade}
|
||||
<PremiumIcon {isHovered} size={16} class="ui:text-primary ml-auto" />
|
||||
{/if}
|
||||
</a>
|
||||
{/if}
|
||||
{/snippet}
|
||||
@@ -102,7 +106,7 @@
|
||||
<a href={subItem.url} {...props}>
|
||||
<span>{subItem.title}</span>
|
||||
{#if subItem.isPaid && $isFreePlan}
|
||||
<RocketIcon class="text-primary size-4" />
|
||||
<PremiumIcon size={16} class="ui:text-primary ml-auto" />
|
||||
{/if}
|
||||
</a>
|
||||
{/snippet}
|
||||
|
||||
@@ -1,28 +1,50 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '@cio/ui/base/button';
|
||||
import { Progress } from '@cio/ui/base/progress';
|
||||
import RocketIcon from '@lucide/svelte/icons/rocket';
|
||||
import { t } from '$lib/utils/functions/translations';
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import { currentOrgMaxAudience, isFreePlan, studentUsage } from '$lib/utils/store/org';
|
||||
import { useSidebar } from '@cio/ui/base/sidebar';
|
||||
|
||||
const sidebar = useSidebar();
|
||||
|
||||
const studentCount = $derived($studentUsage?.used ?? 0);
|
||||
const studentLimit = $derived($studentUsage?.limit ?? $currentOrgMaxAudience);
|
||||
const studentsLeft = $derived(Math.max(0, studentLimit - studentCount));
|
||||
</script>
|
||||
|
||||
{#if $isFreePlan}
|
||||
<div class="animate-icon">
|
||||
{#if sidebar.open && !sidebar.isMobile}
|
||||
<div
|
||||
class="animate-gradient-border mx-2 flex flex-col items-center justify-center gap-4 rounded-md px-2 py-6 text-center transition-all ease-in-out"
|
||||
>
|
||||
<RocketIcon class="rocket-launch my-3 size-6" color="var(--primary)" />
|
||||
<span class="flex flex-col gap-1">
|
||||
<p class="text-base font-medium">{$t('org_navigation.early_adopter')}</p>
|
||||
<p class="ui:text-muted-foreground text-xs">{$t('org_navigation.unlock')}</p>
|
||||
</span>
|
||||
<Button data-sidebar="upgrade-trigger" data-slot="upgrade-trigger" type="button" onclick={openUpgradeModal}>
|
||||
<RocketIcon class="rocket-launch custom size-4" />
|
||||
{$t('org_navigation.upgrade')}
|
||||
<div class="animate-gradient-border mx-2 flex flex-col gap-3 rounded-md p-3 transition-all ease-in-out">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="ui:bg-primary/10 flex size-8 shrink-0 items-center justify-center rounded-full">
|
||||
<RocketIcon class="rocket-launch size-4" color="var(--primary)" />
|
||||
</span>
|
||||
<p class="text-sm">{$t('org_navigation.early_adopter')}</p>
|
||||
</div>
|
||||
|
||||
<Progress value={studentCount} max={studentLimit} variant="default" />
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="ui:text-muted-foreground text-xs">
|
||||
{$t('org_navigation.upgrade_students', { studentCount, studentLimit })}
|
||||
</span>
|
||||
<span class="ui:text-muted-foreground text-xs">
|
||||
{$t('org_navigation.upgrade_left', { count: studentsLeft })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
data-sidebar="upgrade-trigger"
|
||||
data-slot="upgrade-trigger"
|
||||
variant="outline"
|
||||
class="w-full"
|
||||
type="button"
|
||||
onclick={openUpgradeModal}
|
||||
>
|
||||
{$t('org_navigation.upgrade_now')}
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { openUpgradeModal } from '$lib/utils/functions/org';
|
||||
import { openUpgradeModalOver } from '$lib/utils/functions/org';
|
||||
import { isFreePlan } from '$lib/utils/store/org';
|
||||
import * as Item from '@cio/ui/base/item';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
@@ -9,10 +9,12 @@
|
||||
className?: string;
|
||||
onClick?: any;
|
||||
visible?: boolean;
|
||||
/** Query params to drop when opening the modal (e.g. `['add']` to close the invite modal first). */
|
||||
removeParams?: string[];
|
||||
children?: import('svelte').Snippet;
|
||||
}
|
||||
|
||||
let { className = '', onClick = () => {}, visible, ...restProps }: Props = $props();
|
||||
let { className = '', onClick = () => {}, visible, removeParams = [], ...restProps }: Props = $props();
|
||||
|
||||
const shouldShow = $derived(visible ?? $isFreePlan);
|
||||
</script>
|
||||
@@ -26,7 +28,7 @@
|
||||
{...props}
|
||||
onclick={() => {
|
||||
onClick();
|
||||
openUpgradeModal();
|
||||
openUpgradeModalOver(removeParams);
|
||||
}}
|
||||
>
|
||||
<Item.Media variant="icon">
|
||||
|
||||
Reference in New Issue
Block a user