mirror of
https://github.com/Kong/insomnia.git
synced 2026-01-29 16:22:28 -05:00
feat: new pricing features (#9176)
* feat: new pricing feat: invite check fix: fixes based on comments feat: invite_not_permitted event fix: sync user info after trial Popup for upgrade plan when creating a git project change wording fix theme color feat: add segment events feat: report user local git project count fix theme issue feat: INS-1582 new design for upgrade modal Add pop-up for inviting members feat: add the plan indicator feat: new design of top-right corner fix: fix quota fix: fix top-right corner width * fix: remove button * Add upgrade plan banner in invite modal * fix type issue * fix smoke test * Remove console.log * fix: fix quota issue * update useIsLightTheme * fix type * add annotation * move doc link * fix: fix source --------- Co-authored-by: yaoweiprc <6896642+yaoweiprc@users.noreply.github.com>
This commit is contained in:
@@ -635,4 +635,10 @@ export default (app: Application) => {
|
||||
app.delete('/v1/desktop/organizations/:organizationId/collaborators/:collaboratorId/unlink', (_req, res) => {
|
||||
res.json(null);
|
||||
});
|
||||
|
||||
app.post('/v1/organizations/:organizationId/check-seats', (_req, res) => {
|
||||
res.json({
|
||||
isAllowed: true,
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
2
packages/insomnia/src/basic-components/icon.tsx
Normal file
2
packages/insomnia/src/basic-components/icon.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from '../ui/components/icon';
|
||||
// https://fontawesome.com/search?q=user&ic=free&o=r
|
||||
69
packages/insomnia/src/basic-components/modal.tsx
Normal file
69
packages/insomnia/src/basic-components/modal.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import classnames from 'classnames';
|
||||
import type React from 'react';
|
||||
import { Button, Dialog, Heading, Modal as RAModal, ModalOverlay } from 'react-aria-components';
|
||||
|
||||
import { Icon } from '~/basic-components/icon';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
title?: React.ReactNode;
|
||||
closable?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const Modal: React.FC<React.PropsWithChildren<Props>> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
className,
|
||||
title,
|
||||
closable,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen={isOpen}
|
||||
onOpenChange={isOpen => {
|
||||
!isOpen && onClose?.();
|
||||
}}
|
||||
isDismissable
|
||||
className="fixed left-0 top-0 z-10 flex h-[--visual-viewport-height] w-full items-center justify-center bg-black/30"
|
||||
>
|
||||
<RAModal
|
||||
onOpenChange={isOpen => {
|
||||
!isOpen && onClose?.();
|
||||
}}
|
||||
className={classnames(
|
||||
'flex flex-col rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] p-[--padding-lg] text-[--color-font]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Dialog className="flex h-full flex-1 flex-col overflow-hidden outline-none">
|
||||
{({ close }) => (
|
||||
<>
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden">
|
||||
{' '}
|
||||
<div className="flex flex-shrink-0 items-center justify-between gap-2">
|
||||
{title && (
|
||||
<Heading slot="title" className="text-3xl">
|
||||
{title}
|
||||
</Heading>
|
||||
)}
|
||||
{closable && (
|
||||
<Button
|
||||
className="flex aspect-square h-6 flex-shrink-0 items-center justify-center rounded-sm text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
|
||||
onPress={() => close()}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</Dialog>
|
||||
</RAModal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
};
|
||||
32
packages/insomnia/src/basic-components/progress.tsx
Normal file
32
packages/insomnia/src/basic-components/progress.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import classNames from 'classnames';
|
||||
|
||||
interface Props {
|
||||
percent: number;
|
||||
className?: string;
|
||||
status?: 'success' | 'error' | 'normal';
|
||||
}
|
||||
|
||||
export const Progress = ({ className, percent, status }: Props) => {
|
||||
return (
|
||||
<div
|
||||
// FIXME: use css variables for colors
|
||||
className={classNames(
|
||||
'h-[10px] flex-grow overflow-hidden rounded-full bg-[#f1e6ff]',
|
||||
status === 'error' && 'bg-[#db110040]',
|
||||
status === 'success' && 'bg-[#00bf7340]',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'transition-width h-full rounded-full bg-[--color-surprise] duration-1000 ease-in-out',
|
||||
status === 'error' && 'bg-[--color-danger]',
|
||||
status === 'success' && 'bg-[--color-success]',
|
||||
)}
|
||||
style={{
|
||||
width: `${percent}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,8 @@ export const docsIntroductionToInsoCLI = insomniaDocs('/inso-cli/introduction');
|
||||
export const docsPreRequestScript = insomniaDocs('/insomnia/pre-request-script');
|
||||
export const docsAfterResponseScript = insomniaDocs('/insomnia/after-response-script');
|
||||
export const docsMcpClient = insomniaDocs('/insomnia/mcp-clients-in-insomnia');
|
||||
export const docsPricingLearnMoreLink =
|
||||
'https://developer.konghq.com/insomnia/storage/#what-are-the-user-and-git-sync-limits-for-the-essentials-plan';
|
||||
|
||||
export const docsGitAccessToken = {
|
||||
github: 'https://docs.github.com/github/authenticating-to-github/creating-a-personal-access-token',
|
||||
|
||||
@@ -130,6 +130,7 @@ interface ResourceCacheType {
|
||||
|
||||
let resourceCacheList: ResourceCacheType[] = [];
|
||||
|
||||
// All models that can be exported should be listed here
|
||||
export const MODELS_BY_EXPORT_TYPE: Record<AllExportTypes, AllTypes> = {
|
||||
request: 'Request',
|
||||
websocket_payload: 'WebSocketPayload',
|
||||
|
||||
@@ -65,7 +65,7 @@ export type PersonalPlanType = 'free' | 'individual' | 'team' | 'enterprise' | '
|
||||
export const formatCurrentPlanType = (type: PersonalPlanType) => {
|
||||
switch (type) {
|
||||
case 'free': {
|
||||
return 'Hobby';
|
||||
return 'Essentials';
|
||||
}
|
||||
case 'individual': {
|
||||
return 'Individual';
|
||||
@@ -94,4 +94,6 @@ export interface CurrentPlan {
|
||||
quantity: number;
|
||||
type: PersonalPlanType;
|
||||
planName: string;
|
||||
status: 'trialing' | 'active';
|
||||
trialingEnd: string;
|
||||
}
|
||||
|
||||
@@ -10,6 +10,11 @@ import { isRequest, type Request } from './request';
|
||||
import { isSocketIORequest, type SocketIORequest } from './socket-io-request';
|
||||
import { isWebSocketRequest, type WebSocketRequest } from './websocket-request';
|
||||
|
||||
/* When viewing a specific request, the user can click the Send button to test-send it.
|
||||
Each time the user sends the request, the parameters may differ—they might edit the body, headers, and so on—and Insomnia records every sent request as history.
|
||||
When the user browses the send history for a request and selects one of the entries, the current request is restored to the exact state it had when that request was sent, including the body, headers, and other settings.
|
||||
A Request Version is essentially a snapshot of the request at the moment it was test-sent. */
|
||||
|
||||
export const name = 'Request Version';
|
||||
|
||||
export const type = 'RequestVersion';
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { href } from 'react-router';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
import { userSession } from '~/models';
|
||||
import { insomniaFetch } from '~/ui/insomniaFetch';
|
||||
import { createFetcherLoadHook } from '~/utils/router';
|
||||
|
||||
import type { Route } from './+types/organization.$organizationId.collaborators-check-seats';
|
||||
|
||||
export const needsToUpgrade = 'NEEDS_TO_UPGRADE';
|
||||
export const needsToIncreaseSeats = 'NEEDS_TO_INCREASE_SEATS';
|
||||
|
||||
export interface CheckSeatsResponse {
|
||||
isAllowed: boolean;
|
||||
code?: typeof needsToUpgrade | typeof needsToIncreaseSeats;
|
||||
}
|
||||
|
||||
export async function clientLoader({ params }: Route.ClientLoaderArgs) {
|
||||
const { id: sessionId } = await userSession.get();
|
||||
|
||||
const { organizationId } = params;
|
||||
|
||||
try {
|
||||
// Check whether the user can add a new collaborator
|
||||
// Use a random email to avoid hitting any existing member emails
|
||||
const checkResponseData = await insomniaFetch<CheckSeatsResponse>({
|
||||
method: 'POST',
|
||||
path: `/v1/organizations/${organizationId}/check-seats`,
|
||||
data: { emails: [`insomnia-mock-check-seats-${uuidv4()}@example.net`] },
|
||||
sessionId,
|
||||
onlyResolveOnSuccess: true,
|
||||
});
|
||||
return checkResponseData;
|
||||
} catch {
|
||||
return { isAllowed: true };
|
||||
}
|
||||
}
|
||||
|
||||
export const useCollaboratorsCheckSeatsLoaderFetcher = createFetcherLoadHook(
|
||||
load =>
|
||||
({ organizationId, query }: { organizationId: string; query?: string }) => {
|
||||
return load(
|
||||
`${href(`/organization/:organizationId/collaborators-check-seats`, { organizationId })}?${encodeURIComponent(query || '')}`,
|
||||
);
|
||||
},
|
||||
clientLoader,
|
||||
);
|
||||
@@ -3,6 +3,7 @@ import { href, redirect } from 'react-router';
|
||||
import { database } from '~/common/database';
|
||||
import { projectLock } from '~/common/project';
|
||||
import * as models from '~/models';
|
||||
import { reportGitProjectCount } from '~/routes/organization.$organizationId.project.new';
|
||||
import { insomniaFetch } from '~/ui/insomniaFetch';
|
||||
import { invariant } from '~/utils/invariant';
|
||||
import { createFetcherSubmitHook, getInitialRouteForOrganization } from '~/utils/router';
|
||||
@@ -53,6 +54,8 @@ export async function clientAction({ params }: Route.ClientActionArgs) {
|
||||
|
||||
await database.flushChanges(bufferId);
|
||||
|
||||
project.gitRepositoryId && reportGitProjectCount(organizationId, sessionId);
|
||||
|
||||
// When redirect to `/organizations/:organizationId`, it sometimes doesn't reload the index loader, so manually redirect to the initial route for the organization
|
||||
const initialOrganizationRoute = await getInitialRouteForOrganization({ organizationId });
|
||||
return redirect(initialOrganizationRoute);
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { OauthProviderName } from '~/models/git-credentials';
|
||||
import type { GitCredentials } from '~/models/git-repository';
|
||||
import { EMPTY_GIT_PROJECT_ID } from '~/models/project';
|
||||
import type { WorkspaceMeta } from '~/models/workspace-meta';
|
||||
import { reportGitProjectCount } from '~/routes/organization.$organizationId.project.new';
|
||||
import { SegmentEvent } from '~/ui/analytics';
|
||||
import { insomniaFetch } from '~/ui/insomniaFetch';
|
||||
import { invariant } from '~/utils/invariant';
|
||||
@@ -179,6 +180,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
|
||||
}
|
||||
|
||||
await models.project.update(project, { name, remoteId: newCloudProject.id, gitRepositoryId: null });
|
||||
|
||||
project.gitRepositoryId && reportGitProjectCount(organizationId, sessionId);
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
@@ -278,6 +281,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
|
||||
}
|
||||
}
|
||||
|
||||
reportGitProjectCount(organizationId, sessionId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
@@ -290,6 +295,8 @@ export async function clientAction({ request, params }: Route.ClientActionArgs)
|
||||
gitRepository && (await models.gitRepository.remove(gitRepository));
|
||||
await models.project.update(project, { name, gitRepositoryId: null });
|
||||
|
||||
reportGitProjectCount(organizationId, sessionId);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { href, redirect } from 'react-router';
|
||||
|
||||
import { database } from '~/common/database';
|
||||
import { isNotNullOrUndefined } from '~/common/misc';
|
||||
import { projectLock } from '~/common/project';
|
||||
import * as models from '~/models';
|
||||
import type { GitCredentials, OauthProviderName } from '~/models/git-repository';
|
||||
import { EMPTY_GIT_PROJECT_ID } from '~/models/project';
|
||||
import { EMPTY_GIT_PROJECT_ID, type Project } from '~/models/project';
|
||||
import { SegmentEvent } from '~/ui/analytics';
|
||||
import { insomniaFetch } from '~/ui/insomniaFetch';
|
||||
import { invariant } from '~/utils/invariant';
|
||||
@@ -29,6 +31,34 @@ export interface CreateProjectData {
|
||||
connectRepositoryLater?: boolean;
|
||||
}
|
||||
|
||||
export const reportGitProjectCount = async (organizationId: string, sessionId: string, maxRetries = 3) => {
|
||||
const projects = await database.find<Project>(models.project.type, {
|
||||
parentId: organizationId,
|
||||
});
|
||||
const gitRepositoryIds = projects.map(p => p.gitRepositoryId).filter(isNotNullOrUndefined);
|
||||
const gitProjectsCount = gitRepositoryIds.length;
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
await insomniaFetch({
|
||||
method: 'PATCH',
|
||||
path: `/v1/organizations/${organizationId}/git-projects`,
|
||||
sessionId,
|
||||
onlyResolveOnSuccess: true,
|
||||
data: {
|
||||
count: gitProjectsCount,
|
||||
},
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
if (attempt < maxRetries) {
|
||||
await new Promise(resolve => setTimeout(resolve, attempt * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.warn('Report git project count failed');
|
||||
};
|
||||
|
||||
export const createProject = async (organizationId: string, newProjectData: CreateProjectData) => {
|
||||
const createProjectImpl = async (organizationId: string, newProjectData: CreateProjectData) => {
|
||||
const user = await models.userSession.getOrCreate();
|
||||
@@ -51,6 +81,7 @@ export const createProject = async (organizationId: string, newProjectData: Crea
|
||||
parentId: organizationId,
|
||||
gitRepositoryId: EMPTY_GIT_PROJECT_ID,
|
||||
});
|
||||
reportGitProjectCount(organizationId, sessionId);
|
||||
|
||||
return project._id;
|
||||
}
|
||||
@@ -91,6 +122,7 @@ export const createProject = async (organizationId: string, newProjectData: Crea
|
||||
if (errors) {
|
||||
throw new Error(errors.join(', '));
|
||||
}
|
||||
reportGitProjectCount(organizationId, sessionId);
|
||||
|
||||
return projectId;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import { getLoginUrl } from '~/ui/auth-session-provider.client';
|
||||
import { CommandPalette } from '~/ui/components/command-palette';
|
||||
import { GitHubStarsButton } from '~/ui/components/github-stars-button';
|
||||
import { HeaderInviteButton } from '~/ui/components/header-invite-button';
|
||||
import { HeaderPlanIndicator } from '~/ui/components/header-plan-indicator';
|
||||
import { HeaderUserButton } from '~/ui/components/header-user-button';
|
||||
import { Hotkey } from '~/ui/components/hotkey';
|
||||
import { Icon } from '~/ui/components/icon';
|
||||
@@ -58,7 +59,6 @@ export async function clientLoader(_args: Route.ClientLoaderArgs) {
|
||||
const organizations = JSON.parse(localStorage.getItem(`${accountId}:organizations`) || '[]') as Organization[];
|
||||
const user = JSON.parse(localStorage.getItem(`${accountId}:user`) || '{}') as UserProfileResponse;
|
||||
const currentPlan = JSON.parse(localStorage.getItem(`${accountId}:currentPlan`) || '{}') as CurrentPlan;
|
||||
|
||||
return {
|
||||
organizations: sortOrganizations(accountId, organizations),
|
||||
user,
|
||||
@@ -240,12 +240,12 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
|
||||
'organizationSidebarOpen',
|
||||
true,
|
||||
);
|
||||
const [isMinimal, setIsMinimal] = reactUse.useLocalStorage('isMinimal', false);
|
||||
|
||||
useCloseConnection({
|
||||
organizationId,
|
||||
});
|
||||
|
||||
const [isMinimal, setIsMinimal] = reactUse.useLocalStorage('isMinimal', false);
|
||||
return (
|
||||
<InsomniaEventStreamProvider>
|
||||
<InsomniaTabProvider>
|
||||
@@ -262,11 +262,15 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
|
||||
{!user ? <GitHubStarsButton /> : null}
|
||||
</div>
|
||||
<CommandPalette />
|
||||
<div className="flex items-center justify-end gap-[--padding-sm] p-2">
|
||||
<div className="flex min-w-min items-center justify-end gap-[--padding-sm] space-x-3 p-2">
|
||||
{user ? (
|
||||
<Fragment>
|
||||
<PresentUsers />
|
||||
<HeaderInviteButton className="border border-solid border-[--hl-md] bg-[rgba(var(--color-surprise-rgb),var(--tw-bg-opacity))] bg-opacity-100 font-semibold text-[--color-font-surprise]" />
|
||||
<HeaderInviteButton
|
||||
organizationId={organizationId}
|
||||
className="border border-solid border-[--hl-md] bg-[rgba(var(--color-surprise-rgb),var(--tw-bg-opacity))] bg-opacity-100 font-semibold text-[--color-font-surprise]"
|
||||
/>
|
||||
<HeaderPlanIndicator isMinimal={isMinimal} />
|
||||
<HeaderUserButton user={user} currentPlan={currentPlan} isMinimal={isMinimal} />
|
||||
</Fragment>
|
||||
) : (
|
||||
@@ -583,7 +587,8 @@ const Component = ({ loaderData }: Route.ComponentProps) => {
|
||||
{user ? (
|
||||
<Fragment>
|
||||
<PresentUsers />
|
||||
<HeaderInviteButton className="text-[--color-font]" />
|
||||
<HeaderInviteButton className="text-[--color-font]" organizationId={organizationId} />
|
||||
<HeaderPlanIndicator isMinimal={isMinimal} />
|
||||
<HeaderUserButton user={user} currentPlan={currentPlan} isMinimal={isMinimal} />
|
||||
</Fragment>
|
||||
) : (
|
||||
|
||||
119
packages/insomnia/src/routes/resource.usage.tsx
Normal file
119
packages/insomnia/src/routes/resource.usage.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { href } from 'react-router';
|
||||
|
||||
import { userSession } from '~/models';
|
||||
import type { CurrentPlan } from '~/models/organization';
|
||||
import { getTrialEligibility } from '~/routes/trial.check';
|
||||
import { insomniaFetch } from '~/ui/insomniaFetch';
|
||||
import { createFetcherLoadHook } from '~/utils/router';
|
||||
|
||||
interface ResourceUsage {
|
||||
mocks: {
|
||||
quota: number;
|
||||
calls: number;
|
||||
autoPurchase: {
|
||||
enabled: boolean;
|
||||
unit: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface EnterpriseOwner {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
interface AccountUsedSeats {
|
||||
memberCount: number;
|
||||
inviteCount: number;
|
||||
used: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface LicenseUsage {
|
||||
used: number;
|
||||
total: number;
|
||||
memberCount: number;
|
||||
inviteCount: number;
|
||||
free: number;
|
||||
}
|
||||
|
||||
function getResourceUsage(sessionId: string) {
|
||||
return insomniaFetch<ResourceUsage>({
|
||||
method: 'GET',
|
||||
path: '/v1/user/resource-usage',
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
function getOwnEnterprises(sessionId: string) {
|
||||
return insomniaFetch<EnterpriseOwner[]>({
|
||||
method: 'GET',
|
||||
path: '/v1/user/enterprises',
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
async function getCurrentEnterprise(sessionId: string) {
|
||||
const enterprises = await getOwnEnterprises(sessionId);
|
||||
if (!Array.isArray(enterprises)) {
|
||||
return null;
|
||||
}
|
||||
return enterprises.find(ent => ent.role === 'owner') ?? enterprises[0];
|
||||
}
|
||||
|
||||
function getAccountUsedSeats(sessionId: string) {
|
||||
return insomniaFetch<AccountUsedSeats>({
|
||||
method: 'GET',
|
||||
path: '/v1/accounts/seats',
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
function getEnterpriseLicenseUsage(sessionId: string, enterpriseId: string) {
|
||||
return insomniaFetch<LicenseUsage>({
|
||||
method: 'GET',
|
||||
path: `/v1/enterprise/${enterpriseId}/license-usage`,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
function getLicenseUsage(sessionId: string, enterpriseId?: string | null) {
|
||||
return enterpriseId ? getEnterpriseLicenseUsage(sessionId, enterpriseId) : getAccountUsedSeats(sessionId);
|
||||
}
|
||||
|
||||
export async function clientLoader() {
|
||||
const { id: sessionId, accountId } = await userSession.get();
|
||||
|
||||
if (!sessionId) {
|
||||
return {
|
||||
resourceUsage: null,
|
||||
licenseUsage: null,
|
||||
isEligible: false,
|
||||
};
|
||||
}
|
||||
|
||||
const currentPlan = JSON.parse(localStorage.getItem(`${accountId}:currentPlan`) || '{}') as CurrentPlan;
|
||||
const enterpriseId = currentPlan?.type === 'enterprise' ? (await getCurrentEnterprise(sessionId))?.id : null;
|
||||
const [resourceUsage, licenseUsage, trialEligibility] = await Promise.allSettled([
|
||||
getResourceUsage(sessionId),
|
||||
getLicenseUsage(sessionId, enterpriseId),
|
||||
getTrialEligibility(sessionId),
|
||||
]);
|
||||
|
||||
return {
|
||||
resourceUsage: resourceUsage.status === 'fulfilled' ? resourceUsage.value : null,
|
||||
licenseUsage: licenseUsage.status === 'fulfilled' ? licenseUsage.value : null,
|
||||
isEligible:
|
||||
trialEligibility.status === 'fulfilled' && 'isEligible' in trialEligibility.value
|
||||
? trialEligibility.value?.isEligible
|
||||
: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const useResourceUsageFetcher = createFetcherLoadHook(
|
||||
load => () => {
|
||||
return load(href('/resource/usage'));
|
||||
},
|
||||
clientLoader,
|
||||
);
|
||||
47
packages/insomnia/src/routes/trial.check.tsx
Normal file
47
packages/insomnia/src/routes/trial.check.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { href } from 'react-router';
|
||||
|
||||
import { userSession } from '~/models';
|
||||
import { insomniaFetch } from '~/ui/insomniaFetch';
|
||||
import { createFetcherLoadHook } from '~/utils/router';
|
||||
|
||||
import type { Route } from './+types/settings.update';
|
||||
|
||||
interface Eligible {
|
||||
isEligible: boolean;
|
||||
}
|
||||
|
||||
export function getTrialEligibility(sessionId: string) {
|
||||
return insomniaFetch<Eligible | { error: string }>({
|
||||
method: 'GET',
|
||||
path: '/v1/trials/eligibility',
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clientLoader(_args: Route.ClientLoaderArgs) {
|
||||
const { id: sessionId } = await userSession.get();
|
||||
|
||||
if (!sessionId) {
|
||||
return {
|
||||
isEligible: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const check = await getTrialEligibility(sessionId);
|
||||
return {
|
||||
isEligible: 'isEligible' in check ? check.isEligible : false,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
isEligible: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const useTrialCheckLoaderFetcher = createFetcherLoadHook(
|
||||
load => () => {
|
||||
return load(href('/trial/check'));
|
||||
},
|
||||
clientLoader,
|
||||
);
|
||||
47
packages/insomnia/src/routes/trial.start.tsx
Normal file
47
packages/insomnia/src/routes/trial.start.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { userSession } from '~/models';
|
||||
import { insomniaFetch } from '~/ui/insomniaFetch';
|
||||
import { syncCurrentPlan } from '~/ui/organization-utils';
|
||||
import { createFetcherSubmitHook } from '~/utils/router';
|
||||
|
||||
import type { Route } from './+types/settings.update';
|
||||
|
||||
interface StartResult {
|
||||
success: boolean;
|
||||
}
|
||||
|
||||
export async function clientAction(_args: Route.ClientActionArgs) {
|
||||
const { id: sessionId, accountId } = await userSession.get();
|
||||
|
||||
if (!sessionId || !accountId) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await insomniaFetch<StartResult>({
|
||||
method: 'POST',
|
||||
path: '/v1/trials/start',
|
||||
sessionId,
|
||||
});
|
||||
if (result.success) {
|
||||
await syncCurrentPlan(sessionId, accountId);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const useTrialStartActionFetcher = createFetcherSubmitHook(
|
||||
submit => () => {
|
||||
return submit(null, {
|
||||
method: 'POST',
|
||||
action: '/trial/start',
|
||||
encType: 'application/json',
|
||||
});
|
||||
},
|
||||
clientAction,
|
||||
);
|
||||
@@ -43,7 +43,7 @@ export enum SegmentEvent {
|
||||
vcsSyncComplete = 'VCS Sync Completed',
|
||||
vcsAction = 'VCS Action Executed',
|
||||
buttonClick = 'Button Clicked',
|
||||
inviteMember = 'Invite Member',
|
||||
inviteMember = 'Invite Sent',
|
||||
inviteResent = 'Invite Resent',
|
||||
inviteRevoked = 'Invite Revoked',
|
||||
projectCreated = 'Project Created',
|
||||
@@ -56,6 +56,7 @@ export enum SegmentEvent {
|
||||
recommendCommitsClicked = 'Recommend Commits Clicked',
|
||||
mcpClientWorkspaceCreate = 'MCP Client Workspace Created',
|
||||
mcpClientAdded = 'MCP Client Added',
|
||||
inviteNotPermitted = 'Invite Not Permitted',
|
||||
}
|
||||
|
||||
type PushPull = 'push' | 'pull';
|
||||
|
||||
@@ -1,28 +1,158 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button } from 'react-aria-components';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Button, Heading, Link, Radio, RadioGroup } from 'react-aria-components';
|
||||
|
||||
import { Modal } from '~/basic-components/modal';
|
||||
import { getAppWebsiteBaseURL } from '~/common/constants';
|
||||
import { SegmentEvent } from '~/ui/analytics';
|
||||
import { Tooltip } from '~/ui/components/tooltip';
|
||||
|
||||
import { Icon } from './icon';
|
||||
import { InviteModalContainer } from './modals/invite-modal/invite-modal';
|
||||
import {
|
||||
getCurrentUserPermissionsInOrg,
|
||||
InviteModalContainer,
|
||||
type Permission,
|
||||
} from './modals/invite-modal/invite-modal';
|
||||
|
||||
export const HeaderInviteButton = ({ className = '' }) => {
|
||||
export const HeaderInviteButton = ({
|
||||
className = '',
|
||||
organizationId,
|
||||
}: {
|
||||
className?: string;
|
||||
organizationId: string;
|
||||
}) => {
|
||||
const [isInviteModalOpen, setIsInviteModalOpen] = useState(false);
|
||||
const [userPermission, setUserPermission] = useState<Record<Permission, boolean> | null>(null);
|
||||
|
||||
// TODO: should manage this in the scope of organization context
|
||||
useEffect(() => {
|
||||
if (organizationId) {
|
||||
getCurrentUserPermissionsInOrg(organizationId).then(permissions => {
|
||||
setUserPermission(permissions);
|
||||
});
|
||||
}
|
||||
}, [organizationId]);
|
||||
|
||||
// TODO: let backend handle the license check currently
|
||||
const hasAvailableLicenses = true;
|
||||
// if backend API fails, we still allow user to invite, and let backend handle the error
|
||||
const hasPermissions =
|
||||
userPermission == null || (userPermission['create:invitation'] && userPermission['read:membership']);
|
||||
const tip = !hasAvailableLicenses ? (
|
||||
hasPermissions ? (
|
||||
<div>
|
||||
You cannot invite anyone as there are no available licenses.{' '}
|
||||
<Link href={`${getAppWebsiteBaseURL()}/app/home`} className="text-[var(--color-surprise)]">
|
||||
You can review your usage here.
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
'You cannot invite anyone as there are no available licenses. Contact your organization’s Insomnia admins for more info.'
|
||||
)
|
||||
) : (
|
||||
// !hasPermissions && hasAvailableLicenses: will popup 'missing someone'
|
||||
''
|
||||
);
|
||||
|
||||
const [missingOpen, setMissingOpen] = React.useState(false);
|
||||
|
||||
const button = (
|
||||
<Button
|
||||
isDisabled={Boolean(tip)}
|
||||
aria-label="Invite collaborators"
|
||||
className={`${className} flex h-full items-center justify-center gap-2 rounded-md px-4 py-2 text-sm ring-1 ring-transparent transition-all hover:bg-opacity-80 focus:ring-inset focus:ring-[--hl-md] aria-pressed:opacity-80`}
|
||||
onPress={() => {
|
||||
if (!hasPermissions) {
|
||||
setMissingOpen(true);
|
||||
return;
|
||||
}
|
||||
setIsInviteModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<Icon icon="user-plus" />
|
||||
<span className="truncate">Invite</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tip) {
|
||||
return (
|
||||
<Tooltip message={tip} position="bottom">
|
||||
{button}
|
||||
<InviteModalContainer
|
||||
{...{
|
||||
isOpen: isInviteModalOpen,
|
||||
setIsOpen: setIsInviteModalOpen,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
aria-label="Invite collaborators"
|
||||
className={`${className} flex h-full items-center justify-center gap-2 rounded-md px-4 py-2 text-sm ring-1 ring-transparent transition-all hover:bg-opacity-80 focus:ring-inset focus:ring-[--hl-md] aria-pressed:opacity-80`}
|
||||
onPress={() => setIsInviteModalOpen(true)}
|
||||
>
|
||||
<Icon icon="user-plus" />
|
||||
<span className="truncate">Invite</span>
|
||||
</Button>
|
||||
{button}
|
||||
<InviteModalContainer
|
||||
{...{
|
||||
isOpen: isInviteModalOpen,
|
||||
setIsOpen: setIsInviteModalOpen,
|
||||
}}
|
||||
/>
|
||||
{!hasPermissions && <MissingSomeoneModal isOpen={missingOpen} onClose={() => setMissingOpen(false)} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MissingSomeoneModal = ({ isOpen, onClose }: any) => {
|
||||
const [reason, setReason] = useState<string | null>(null);
|
||||
const handleClose = () => {
|
||||
if (reason) {
|
||||
window.main.trackSegmentEvent({
|
||||
event: SegmentEvent.inviteNotPermitted,
|
||||
properties: {
|
||||
collaboration_type: reason,
|
||||
},
|
||||
});
|
||||
}
|
||||
onClose?.();
|
||||
};
|
||||
return (
|
||||
<Modal title="Missing someone?" isOpen={isOpen} onClose={handleClose}>
|
||||
<p className="mt-8">
|
||||
You're on a paid plan, so please contact your company's Insomnia admins to get anyone added to this account.
|
||||
</p>
|
||||
<p className="my-2 font-semibold">Just curious - why do you want to invite someone?</p>
|
||||
<RadioGroup
|
||||
name="inviteReason"
|
||||
value={reason}
|
||||
onChange={reason => {
|
||||
setReason(reason);
|
||||
}}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<Radio
|
||||
value="long-term"
|
||||
className="flex-1 rounded border border-solid border-[--hl-md] p-4 transition-colors hover:bg-[--hl-xs] focus:bg-[--hl-sm] focus:outline-none data-[selected]:border-[--color-surprise] data-[disabled]:opacity-25 data-[selected]:ring-2 data-[selected]:ring-[--color-surprise]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Heading className="text-lg font-bold">To work together in Insomnia long-term</Heading>
|
||||
</div>
|
||||
</Radio>
|
||||
<Radio
|
||||
value="one-time"
|
||||
className="flex-1 rounded border border-solid border-[--hl-md] p-4 transition-colors hover:bg-[--hl-xs] focus:bg-[--hl-sm] focus:outline-none data-[selected]:border-[--color-surprise] data-[disabled]:opacity-25 data-[selected]:ring-2 data-[selected]:ring-[--color-surprise]"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Heading className="text-lg font-bold">Just to show them something</Heading>
|
||||
</div>
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
className="mt-8 rounded-md bg-[--color-surprise] px-4 py-2 text-white hover:brightness-90 focus:brightness-90"
|
||||
onPress={handleClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
216
packages/insomnia/src/ui/components/header-plan-indicator.tsx
Normal file
216
packages/insomnia/src/ui/components/header-plan-indicator.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import classNames from 'classnames';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Dialog, DialogTrigger, Link, Popover, Separator } from 'react-aria-components';
|
||||
|
||||
import { Progress } from '~/basic-components/progress';
|
||||
import { getAppWebsiteBaseURL } from '~/common/constants';
|
||||
import type { CurrentPlan } from '~/models/organization';
|
||||
import { useResourceUsageFetcher } from '~/routes/resource.usage';
|
||||
import { useTrialStartActionFetcher } from '~/routes/trial.start';
|
||||
import { Icon } from '~/ui/components/icon';
|
||||
import { Tooltip } from '~/ui/components/tooltip';
|
||||
import { usePlanData } from '~/ui/hooks/use-plan';
|
||||
import { useUserService } from '~/ui/hooks/use-user-service';
|
||||
import { formatNumber } from '~/utils';
|
||||
|
||||
interface Props {
|
||||
isMinimal?: boolean;
|
||||
currentPlan?: CurrentPlan;
|
||||
}
|
||||
|
||||
export const HeaderPlanIndicator = ({ isMinimal }: Props) => {
|
||||
const { planDisplayName } = usePlanData();
|
||||
const { isEnterpriseMember, isEssential, isEnterpriseOwner, isEnterpriseLike, trialDaysLeft, isTrailing } =
|
||||
useUserService();
|
||||
const [open, _setOpen] = useState(false);
|
||||
const [canTrial, setCanTrial] = useState(false);
|
||||
const planName = `${planDisplayName} Plan`;
|
||||
|
||||
const startFetcher = useTrialStartActionFetcher();
|
||||
const { load: usageLoad, state: usageState, data: usageData } = useResourceUsageFetcher();
|
||||
|
||||
function handleStartTrial() {
|
||||
if (startFetcher.state === 'idle') {
|
||||
startFetcher.submit();
|
||||
}
|
||||
}
|
||||
|
||||
const checked = useRef(false);
|
||||
const setOpen = (value: boolean) => {
|
||||
if (!value) {
|
||||
checked.current = false;
|
||||
}
|
||||
_setOpen(value);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
if (usageState === 'idle' && !checked.current) {
|
||||
checked.current = true;
|
||||
usageLoad();
|
||||
}
|
||||
}, [usageLoad, usageState, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof usageData?.isEligible === 'boolean') {
|
||||
setCanTrial(usageData.isEligible);
|
||||
}
|
||||
}, [usageData?.isEligible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startFetcher.data?.success) {
|
||||
setCanTrial(false);
|
||||
}
|
||||
}, [startFetcher.data?.success]);
|
||||
|
||||
const isUnlimited = isEnterpriseLike;
|
||||
|
||||
// resourceUsage
|
||||
const usedMocks = Math.min(usageData?.resourceUsage?.mocks?.calls || 0, usageData?.resourceUsage?.mocks?.quota || 0);
|
||||
const mockUsage = ((usedMocks || 0) / (usageData?.resourceUsage?.mocks?.quota || 1)) * 100;
|
||||
const mockStatus = isUnlimited ? 'success' : mockUsage >= 100 ? 'error' : 'normal';
|
||||
const mockTip =
|
||||
mockStatus === 'error'
|
||||
? 'You have reached your monthly limit of mock server requests. Add more by Upgrading your plan.'
|
||||
: 'This number represents the amount of mock server requests are in your plan.';
|
||||
|
||||
// licenseUsage
|
||||
const total = usageData?.licenseUsage?.total;
|
||||
const used = usageData?.licenseUsage?.used;
|
||||
const isUserUnlimited = total === -1;
|
||||
const free =
|
||||
usageData?.licenseUsage && 'free' in usageData.licenseUsage ? (usageData?.licenseUsage.free as number) : null;
|
||||
const seatsUsage = ((used || 0) / (total || 1)) * 100;
|
||||
const userStatus = isUserUnlimited ? 'success' : seatsUsage >= 100 ? 'error' : 'normal';
|
||||
const userTip =
|
||||
userStatus === 'error'
|
||||
? 'You have reached your limit of licensed users. Invite more by Upgrading your plan.'
|
||||
: 'This number represents the amount of licensed users are in your plan.';
|
||||
|
||||
return (
|
||||
<DialogTrigger isOpen={open} onOpenChange={setOpen}>
|
||||
<Button className="flex h-[30px] flex-shrink-0 items-center justify-center gap-2 rounded-md px-2 text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm] data-[pressed]:bg-[--hl-sm]">
|
||||
<span>{planName}</span>
|
||||
{isTrailing && (
|
||||
<span className="flex h-[20px] items-center rounded bg-[--color-surprise] px-[4px] text-[--color-font-surprise]">
|
||||
Trial
|
||||
</span>
|
||||
)}
|
||||
<Icon className="w-4" icon={isMinimal ? 'caret-up' : 'caret-down'} />
|
||||
</Button>
|
||||
<Popover
|
||||
className="max-h-[85vh] min-w-max select-none overflow-y-auto rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] py-2 text-sm shadow-lg focus:outline-none"
|
||||
placement="bottom end"
|
||||
>
|
||||
<Dialog className="focus:outline-none">
|
||||
<div className="mt-[8px] flex w-[250px] flex-col text-[--color-font]">
|
||||
<div className="flex items-center justify-between px-[12px]">
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text[--color-font-surprise] flex items-center gap-[4px] font-semibold">
|
||||
{planName}
|
||||
{isTrailing && (
|
||||
<span className="flex h-[20px] items-center rounded bg-[--color-surprise] px-[4px] text-[--color-font-surprise]">
|
||||
Trial
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{isTrailing && <span>{trialDaysLeft ?? '--'} days left of free trial</span>}
|
||||
</div>
|
||||
{!isEnterpriseMember && (
|
||||
<Link
|
||||
className="rounded-sm border border-solid border-[--hl-md] px-4 py-1 text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] hover:bg-opacity-80 focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
|
||||
href={
|
||||
isEssential
|
||||
? getAppWebsiteBaseURL() + '/app/pricing'
|
||||
: getAppWebsiteBaseURL() + '/app/subscription/update?plan=enterprise&source=app_topbar'
|
||||
}
|
||||
>
|
||||
{isEnterpriseLike ? 'Add seats' : 'Upgrade'}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="mt-[12px] border border-solid border-[--hl-sm]" />
|
||||
|
||||
<div className="my-[8px] flex flex-col gap-[18px] px-[12px]">
|
||||
{!isEnterpriseMember && (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<span className="capitalize">{isEnterpriseLike ? 'managed ' : ''}users</span>
|
||||
<Tooltip position="bottom" message={userTip}>
|
||||
<Icon
|
||||
icon={userStatus === 'error' ? 'exclamation-triangle' : 'info-circle'}
|
||||
className={classNames('ml-2', userStatus === 'error' && 'text-[--color-danger]')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<span className="ml-auto">
|
||||
{used}/{isUserUnlimited ? 'Unlimited' : formatNumber(total || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress className="mt-2" status={userStatus} percent={isUserUnlimited ? 100 : seatsUsage} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEnterpriseOwner && (
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<span className="capitalize">Unmanaged users</span>
|
||||
<Tooltip
|
||||
position="bottom"
|
||||
message="This number represents the amount of free users are in your plan."
|
||||
>
|
||||
<Icon icon="info-circle" className="ml-2" />
|
||||
</Tooltip>
|
||||
<span className="ml-auto">{free || 0}</span>
|
||||
</div>
|
||||
<Progress className="mt-2" status={free ? 'success' : 'normal'} percent={free ? 100 : 0} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<div className="flex items-center">
|
||||
<span className="capitalize">Mock Requests</span>
|
||||
<Tooltip position="bottom" message={mockTip}>
|
||||
<Icon
|
||||
icon={mockStatus === 'error' ? 'exclamation-triangle' : 'info-circle'}
|
||||
className={classNames('ml-2', mockStatus === 'error' && 'text-[--color-danger]')}
|
||||
/>
|
||||
</Tooltip>
|
||||
<span className="ml-auto">
|
||||
{formatNumber(usedMocks)}/{' '}
|
||||
{isUnlimited ? 'Unlimited' : formatNumber(usageData?.resourceUsage?.mocks?.quota || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress className="mt-2" status={mockStatus} percent={isUnlimited ? 100 : mockUsage} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isEnterpriseMember && (
|
||||
<div className="my-[8px] px-[12px]">
|
||||
<Icon icon="gear" className="text-[--color-font]" />
|
||||
<a
|
||||
href={getAppWebsiteBaseURL() + '/app/home'}
|
||||
className="px-3 py-1 text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-opacity-80 focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
|
||||
>
|
||||
Manage
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{canTrial && (
|
||||
<div className="my-[8px] px-[12px]">
|
||||
<Button
|
||||
className="h-[22px] rounded bg-[--color-surprise] px-[12px] text-center text-sm text-[--color-font-surprise]"
|
||||
onPress={() => handleStartTrial()}
|
||||
>
|
||||
Free Enterprise Trial
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
);
|
||||
};
|
||||
@@ -1,74 +1,18 @@
|
||||
import { Button, Menu, MenuItem, MenuTrigger, Popover } from 'react-aria-components';
|
||||
|
||||
import { getAppWebsiteBaseURL } from '~/common/constants';
|
||||
import type { CurrentPlan, PersonalPlanType, UserProfileResponse } from '~/models/organization';
|
||||
import type { CurrentPlan, UserProfileResponse } from '~/models/organization';
|
||||
import { useLogoutFetcher } from '~/routes/auth.logout';
|
||||
import { Avatar } from '~/ui/components/avatar';
|
||||
import { Icon } from '~/ui/components/icon';
|
||||
|
||||
const formatCurrentPlanType = (type: PersonalPlanType) => {
|
||||
switch (type) {
|
||||
case 'free': {
|
||||
return 'Hobby';
|
||||
}
|
||||
case 'individual': {
|
||||
return 'Individual';
|
||||
}
|
||||
case 'team': {
|
||||
return 'Pro';
|
||||
}
|
||||
case 'enterprise': {
|
||||
return 'Enterprise';
|
||||
}
|
||||
case 'enterprise-member': {
|
||||
return 'Enterprise Member';
|
||||
}
|
||||
default: {
|
||||
return 'Free';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const UpgradeButton = ({ currentPlan }: { currentPlan: CurrentPlan }) => {
|
||||
// For the enterprise-member plan we don't show the upgrade button.
|
||||
if (currentPlan?.type === 'enterprise-member') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// If user has a team or enterprise plan we navigate them to the Enterprise contact page.
|
||||
if (['team', 'enterprise'].includes(currentPlan?.type || '')) {
|
||||
return (
|
||||
<a
|
||||
className="flex items-center justify-center gap-2 rounded-sm border border-solid border-[--hl-md] px-4 py-1 text-sm font-semibold text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] hover:bg-opacity-80 focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
|
||||
href={'https://insomnia.rest/pricing/contact'}
|
||||
>
|
||||
{currentPlan?.type === 'enterprise' ? '+ Add more seats' : 'Upgrade'}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
let to = '/app/subscription/update?plan=individual&pay_schedule=year';
|
||||
|
||||
if (currentPlan?.type === 'individual') {
|
||||
to = `/app/subscription/update?plan=team&pay_schedule=${currentPlan?.period}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
href={getAppWebsiteBaseURL() + to}
|
||||
className="flex items-center justify-center gap-2 rounded-sm border border-solid border-[--hl-md] px-4 py-1 text-sm font-semibold text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] hover:bg-opacity-80 focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
|
||||
>
|
||||
Upgrade
|
||||
</a>
|
||||
);
|
||||
};
|
||||
import { showSettingsModal } from '~/ui/components/modals/settings-modal';
|
||||
|
||||
interface UserButtonProps {
|
||||
user: UserProfileResponse;
|
||||
currentPlan?: CurrentPlan;
|
||||
isMinimal?: boolean;
|
||||
}
|
||||
export const HeaderUserButton = ({ user, currentPlan, isMinimal = false }: UserButtonProps) => {
|
||||
export const HeaderUserButton = ({ user, isMinimal = false }: UserButtonProps) => {
|
||||
const logoutFetcher = useLogoutFetcher();
|
||||
|
||||
return (
|
||||
@@ -78,16 +22,9 @@ export const HeaderUserButton = ({ user, currentPlan, isMinimal = false }: UserB
|
||||
className="flex flex-shrink-0 items-center justify-center gap-2 rounded-md px-1 py-1 text-sm text-[--color-font] ring-1 ring-transparent transition-all hover:bg-[--hl-xs] focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm] data-[pressed]:bg-[--hl-sm]"
|
||||
>
|
||||
<Avatar src={user.picture} alt={user.name} />
|
||||
<span className="truncate">{user.name}</span>
|
||||
<Icon className="w-4 pr-2" icon={isMinimal ? 'caret-up' : 'caret-down'} />
|
||||
</Button>
|
||||
<Popover className="max-h-[85vh] min-w-max select-none overflow-y-auto rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] py-2 text-sm shadow-lg focus:outline-none">
|
||||
{currentPlan && Boolean(currentPlan.type) && (
|
||||
<div className="flex h-[--line-height-xs] w-full items-center justify-between gap-2 whitespace-nowrap border-b border-solid border-[--hl-sm] px-[--padding-md] pb-2 capitalize text-[--color-font]">
|
||||
<span>{currentPlan?.planName ?? formatCurrentPlanType(currentPlan.type)} Plan</span>
|
||||
<UpgradeButton currentPlan={currentPlan} />
|
||||
</div>
|
||||
)}
|
||||
<Menu
|
||||
className="focus:outline-none"
|
||||
onAction={action => {
|
||||
@@ -95,30 +32,30 @@ export const HeaderUserButton = ({ user, currentPlan, isMinimal = false }: UserB
|
||||
logoutFetcher.submit();
|
||||
}
|
||||
|
||||
if (action === 'account-settings') {
|
||||
window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/settings/account`);
|
||||
if (action === 'my-profile') {
|
||||
window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/settings/profile`);
|
||||
}
|
||||
|
||||
if (action === 'manage-organizations') {
|
||||
window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/dashboard/organizations`);
|
||||
if (action === 'preferences') {
|
||||
showSettingsModal();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<MenuItem
|
||||
id="manage-organizations"
|
||||
className="flex h-[--line-height-xs] w-full items-center gap-2 whitespace-nowrap bg-transparent px-[--padding-md] text-[--color-font] transition-colors hover:bg-[--hl-sm] focus:bg-[--hl-xs] focus:outline-none disabled:cursor-not-allowed aria-selected:font-bold"
|
||||
aria-label="Manage organizations"
|
||||
>
|
||||
<Icon icon="users" />
|
||||
<span>Manage Organizations</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
id="account-settings"
|
||||
className="flex h-[--line-height-xs] w-full items-center gap-2 whitespace-nowrap bg-transparent px-[--padding-md] text-[--color-font] transition-colors hover:bg-[--hl-sm] focus:bg-[--hl-xs] focus:outline-none disabled:cursor-not-allowed aria-selected:font-bold"
|
||||
aria-label="Account settings"
|
||||
id="preferences"
|
||||
className="text-md flex h-[--line-height-xs] w-full items-center gap-2 whitespace-nowrap bg-transparent px-[--padding-md] text-[--color-font] transition-colors hover:bg-[--hl-sm] focus:bg-[--hl-xs] focus:outline-none disabled:cursor-not-allowed aria-selected:font-bold"
|
||||
aria-label="preferences"
|
||||
>
|
||||
<Icon icon="gear" />
|
||||
<span>Account Settings</span>
|
||||
<span>Preferences</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
id="my-profile"
|
||||
className="text-md flex h-[--line-height-xs] w-full items-center gap-2 whitespace-nowrap bg-transparent px-[--padding-md] text-[--color-font] transition-colors hover:bg-[--hl-sm] focus:bg-[--hl-xs] focus:outline-none disabled:cursor-not-allowed aria-selected:font-bold"
|
||||
aria-label="My profile"
|
||||
>
|
||||
<Icon icon="user" />
|
||||
<span>My Profile</span>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
id="logout"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Heading,
|
||||
type Key,
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
@@ -11,10 +13,22 @@ import {
|
||||
} from 'react-aria-components';
|
||||
import { useParams, useSearchParams } from 'react-router';
|
||||
|
||||
import { getAppWebsiteBaseURL } from '~/common/constants';
|
||||
import { docsPricingLearnMoreLink } from '~/common/documentation';
|
||||
import { debounce } from '~/common/misc';
|
||||
import { isOwnerOfOrganization } from '~/models/organization';
|
||||
import { useRootLoaderData } from '~/root';
|
||||
import { useOrganizationLoaderData } from '~/routes/organization';
|
||||
import {
|
||||
type CheckSeatsResponse,
|
||||
needsToIncreaseSeats,
|
||||
needsToUpgrade,
|
||||
} from '~/routes/organization.$organizationId.collaborators-check-seats';
|
||||
import { useCollaboratorsSearchLoaderFetcher } from '~/routes/organization.$organizationId.collaborators-search';
|
||||
import { SegmentEvent } from '~/ui/analytics';
|
||||
import { Icon } from '~/ui/components/icon';
|
||||
import { useIsLightTheme } from '~/ui/hooks/theme';
|
||||
import { insomniaFetch } from '~/ui/insomniaFetch';
|
||||
|
||||
import { startInvite } from './encryption';
|
||||
import { OrganizationMemberRolesSelector, type Role, SELECTOR_TYPE } from './organization-member-roles-selector';
|
||||
@@ -38,9 +52,61 @@ export function getSearchParamsString(
|
||||
|
||||
interface EmailsInputProps {
|
||||
allRoles: Role[];
|
||||
senderRole: Role;
|
||||
onInviteCompleted?: () => void;
|
||||
}
|
||||
|
||||
const upgradeModalWording = {
|
||||
[needsToUpgrade]: {
|
||||
ownerTitle: 'Upgrade plan to invite more people',
|
||||
memberTitle: 'Ask plan owner to upgrade to invite more people',
|
||||
ownerDescription: (
|
||||
<>
|
||||
Your Essentials plan contains Git Sync projects, so you can only collaborate with up to 3 members. Upgrade to
|
||||
collaborate with unlimited users.{' '}
|
||||
<a href={docsPricingLearnMoreLink} className="underline">
|
||||
Learn more ↗
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
memberDescription: (
|
||||
<>
|
||||
Your Essentials plan contains Git Sync projects, so you can only collaborate with up to 3 members. Contact your
|
||||
plan owner to upgrade your team's plan to collaborate with more people.{' '}
|
||||
<a href={docsPricingLearnMoreLink} className="underline">
|
||||
Learn more ↗
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
submitText: 'Upgrade',
|
||||
submitLink: getAppWebsiteBaseURL() + '/app/pricing',
|
||||
},
|
||||
[needsToIncreaseSeats]: {
|
||||
ownerTitle: 'Increase plan seats to invite more people',
|
||||
memberTitle: 'Your team is full',
|
||||
ownerDescription: (
|
||||
<>
|
||||
Your team has reached your plan's total purchased seats. Increase your plan's number of seats to continue
|
||||
inviting new people.{' '}
|
||||
<a href={docsPricingLearnMoreLink} className="underline">
|
||||
Learn more ↗
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
memberDescription: (
|
||||
<>
|
||||
Your team has reached your plan's total purchased seats. Tell your plan's owner to increase the number of seats
|
||||
to continue inviting new people.{' '}
|
||||
<a href={docsPricingLearnMoreLink} className="underline">
|
||||
Learn more ↗
|
||||
</a>
|
||||
</>
|
||||
),
|
||||
submitText: 'Increase seats',
|
||||
submitLink: getAppWebsiteBaseURL() + '/app/pricing',
|
||||
},
|
||||
};
|
||||
|
||||
export interface EmailInput {
|
||||
email: string;
|
||||
isValid: boolean;
|
||||
@@ -58,10 +124,21 @@ const isValidEmail = (email: string): boolean => {
|
||||
|
||||
const defaultRoleName = 'member';
|
||||
|
||||
export const InviteForm = ({ allRoles, onInviteCompleted }: EmailsInputProps) => {
|
||||
export const InviteForm = ({
|
||||
allRoles,
|
||||
onInviteCompleted,
|
||||
senderRole,
|
||||
checkSeatsResponseData,
|
||||
}: EmailsInputProps & { checkSeatsResponseData: CheckSeatsResponse | undefined }) => {
|
||||
const organizationId = useParams().organizationId as string;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const organizationId = useParams().organizationId as string;
|
||||
const { userSession } = useRootLoaderData()!;
|
||||
const organizationData = useOrganizationLoaderData();
|
||||
const organization = organizationData?.organizations.find(o => o.id === organizationId);
|
||||
const isUserOwner =
|
||||
organization && userSession.accountId && isOwnerOfOrganization({ organization, accountId: userSession.accountId });
|
||||
const sessionId = userSession.id;
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -75,17 +152,39 @@ export const InviteForm = ({ allRoles, onInviteCompleted }: EmailsInputProps) =>
|
||||
const selectedRoleRef = React.useRef<Role>(allRoles.find(role => role.name === defaultRoleName) as Role);
|
||||
|
||||
const collaboratorSearchLoader = useCollaboratorsSearchLoaderFetcher();
|
||||
let upgradeBannerStatus: 'closed' | typeof needsToUpgrade | typeof needsToIncreaseSeats = 'closed';
|
||||
if (checkSeatsResponseData && !checkSeatsResponseData.isAllowed) {
|
||||
if (checkSeatsResponseData.code === needsToUpgrade) {
|
||||
upgradeBannerStatus = needsToUpgrade;
|
||||
} else if (checkSeatsResponseData.code === needsToIncreaseSeats) {
|
||||
upgradeBannerStatus = needsToIncreaseSeats;
|
||||
}
|
||||
}
|
||||
|
||||
const searchResult = useMemo(() => collaboratorSearchLoader.data || [], [collaboratorSearchLoader.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchResult.length > 0) {
|
||||
setShowResults(true);
|
||||
} else {
|
||||
setShowResults(false);
|
||||
}
|
||||
setShowResults(searchResult.length > 0);
|
||||
}, [searchResult]);
|
||||
|
||||
useEffect(() => {
|
||||
const checkSeats = async () => {
|
||||
const validEmails = emails.filter(e => e.isValid);
|
||||
if (validEmails.length === 0) {
|
||||
setError('');
|
||||
} else {
|
||||
const data = await insomniaFetch<CheckSeatsResponse>({
|
||||
method: 'POST',
|
||||
path: `/v1/organizations/${organizationId}/check-seats`,
|
||||
data: { emails: validEmails.map(e => e.email) },
|
||||
sessionId,
|
||||
});
|
||||
setError(data.isAllowed ? '' : 'You cannot invite more people than the seats you have remaining');
|
||||
}
|
||||
};
|
||||
checkSeats();
|
||||
}, [emails, organizationId, sessionId]);
|
||||
|
||||
const addEmail = ({
|
||||
email,
|
||||
teamId,
|
||||
@@ -154,8 +253,40 @@ export const InviteForm = ({ allRoles, onInviteCompleted }: EmailsInputProps) =>
|
||||
}
|
||||
};
|
||||
|
||||
const isLightTheme = useIsLightTheme();
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{upgradeBannerStatus !== 'closed' && (
|
||||
<div
|
||||
className={classNames('mb-5 mt-3 flex items-start justify-start gap-5 rounded-md px-6 py-5', {
|
||||
'bg-[#292535]': !isLightTheme,
|
||||
'bg-[#EEEBFF]': isLightTheme,
|
||||
})}
|
||||
>
|
||||
<Icon icon="circle-info" className="pt-1.5" />
|
||||
<div className="flex flex-col items-start justify-start gap-3.5">
|
||||
<Heading className="text-lg font-bold">
|
||||
{isUserOwner
|
||||
? upgradeModalWording[upgradeBannerStatus].ownerTitle
|
||||
: upgradeModalWording[upgradeBannerStatus].memberTitle}
|
||||
</Heading>
|
||||
<p>
|
||||
{isUserOwner
|
||||
? upgradeModalWording[upgradeBannerStatus].ownerDescription
|
||||
: upgradeModalWording[upgradeBannerStatus].memberDescription}
|
||||
</p>
|
||||
{isUserOwner && (
|
||||
<a
|
||||
href={upgradeModalWording[upgradeBannerStatus].submitLink}
|
||||
className="rounded-sm border border-solid border-[--hl-md] px-3 py-2 text-[--color-font] transition-colors hover:bg-opacity-90 hover:no-underline"
|
||||
>
|
||||
{upgradeModalWording[upgradeBannerStatus].submitText}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div
|
||||
className="flex flex-1 justify-between gap-3 rounded-md border border-[#4c4c4c] bg-[--hl-xs] p-2"
|
||||
@@ -207,12 +338,13 @@ export const InviteForm = ({ allRoles, onInviteCompleted }: EmailsInputProps) =>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="min-h-[24px] grow-[inherit] border-none px-2 py-1 leading-6 outline-none"
|
||||
className="min-h-[24px] grow-[inherit] border-none px-2 py-1 leading-6 outline-none disabled:cursor-not-allowed"
|
||||
placeholder={emails.length > 0 ? 'Enter more emails...' : 'Enter emails, separated by comma...'}
|
||||
onKeyDown={handleInputKeyPress}
|
||||
onBlur={handleInputBlur}
|
||||
onPaste={handlePaste}
|
||||
onChange={e => handleSearch(e.currentTarget.value)}
|
||||
disabled={checkSeatsResponseData && !checkSeatsResponseData.isAllowed}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-[81px] items-center">
|
||||
@@ -228,9 +360,9 @@ export const InviteForm = ({ allRoles, onInviteCompleted }: EmailsInputProps) =>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="h-[40px] w-[67px] shrink-0 self-end rounded bg-[#4000bf] text-center text-[--color-font-surprise] disabled:opacity-70"
|
||||
isDisabled={loading}
|
||||
onPress={() => {
|
||||
className="h-[40px] w-[67px] shrink-0 self-end rounded bg-[#4000bf] text-center text-[--color-font-surprise] disabled:cursor-not-allowed disabled:opacity-70"
|
||||
isDisabled={loading || (checkSeatsResponseData && !checkSeatsResponseData.isAllowed)}
|
||||
onPress={async () => {
|
||||
if (emails.some(({ isValid }) => !isValid)) {
|
||||
setError('Some emails are invalid, please correct them before inviting.');
|
||||
return;
|
||||
@@ -256,7 +388,8 @@ export const InviteForm = ({ allRoles, onInviteCompleted }: EmailsInputProps) =>
|
||||
properties: {
|
||||
numberOfInvites: emailsToInvite.length,
|
||||
numberOfTeams: groupsToInvite.length,
|
||||
role: selectedRoleRef.current.name,
|
||||
receiver_role: selectedRoleRef.current.name,
|
||||
sender_role: senderRole.name,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { debounce } from '~/common/misc';
|
||||
import { type Collaborator, useCollaboratorsFetcher } from '~/routes/organization.$organizationId.collaborators';
|
||||
import { useInviteFetcher } from '~/routes/organization.$organizationId.collaborators.invites.$invitationId';
|
||||
import { useReinviteFetcher } from '~/routes/organization.$organizationId.collaborators.invites.$invitationId.reinvite';
|
||||
import { useCollaboratorsCheckSeatsLoaderFetcher } from '~/routes/organization.$organizationId.collaborators-check-seats';
|
||||
import { useOrganizationMemberRolesActionFetcher } from '~/routes/organization.$organizationId.members.$userId.roles';
|
||||
import { SegmentEvent } from '~/ui/analytics';
|
||||
import { PromptButton } from '~/ui/components/base/prompt-button';
|
||||
@@ -128,9 +129,16 @@ const InviteModal: FC<{
|
||||
setSearchParams(getSearchParamsString(searchParams, { page, filter: queryInputString }));
|
||||
};
|
||||
|
||||
const collaboratorsCheckSeatsLoader = useCollaboratorsCheckSeatsLoaderFetcher();
|
||||
const checkSeatsResponseData = collaboratorsCheckSeatsLoader.data;
|
||||
const collaboratorsCheckSeatsLoaderLoad = collaboratorsCheckSeatsLoader.load;
|
||||
useEffect(() => {
|
||||
collaboratorsCheckSeatsLoaderLoad({ organizationId });
|
||||
}, [collaboratorsCheckSeatsLoaderLoad, organizationId]);
|
||||
|
||||
return (
|
||||
<ModalOverlay
|
||||
isDismissable={true}
|
||||
isDismissable={false}
|
||||
isOpen={true}
|
||||
onOpenChange={setIsOpen}
|
||||
className="theme--transparent-overlay fixed left-0 top-0 z-10 flex h-[--visual-viewport-height] w-full items-center justify-center bg-[--color-bg]"
|
||||
@@ -149,9 +157,12 @@ const InviteModal: FC<{
|
||||
onInviteCompleted={() => {
|
||||
if (organizationId) {
|
||||
resetCollaboratorsList();
|
||||
collaboratorsCheckSeatsLoaderLoad({ organizationId });
|
||||
}
|
||||
}}
|
||||
senderRole={currentUserRoleInOrg}
|
||||
allRoles={allRoles}
|
||||
checkSeatsResponseData={checkSeatsResponseData}
|
||||
/>
|
||||
<hr className="my-[24px] border" />
|
||||
</>
|
||||
@@ -214,6 +225,9 @@ const InviteModal: FC<{
|
||||
revalidateCurrentUserRoleAndPermissionsInOrg={revalidateCurrentUserRoleAndPermissionsInOrg}
|
||||
onResetCurrentPage={resetCurrentPage}
|
||||
onError={setError}
|
||||
onRemoveMember={() => {
|
||||
collaboratorsCheckSeatsLoaderLoad({ organizationId });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ListBox>
|
||||
@@ -258,6 +272,7 @@ const MemberListItem: FC<{
|
||||
revalidateCurrentUserRoleAndPermissionsInOrg: (organizationId: string) => Promise<[void, void]>;
|
||||
onResetCurrentPage: () => void;
|
||||
onError: (error: string | null) => void;
|
||||
onRemoveMember: () => void;
|
||||
}> = ({
|
||||
organizationId,
|
||||
member,
|
||||
@@ -270,6 +285,7 @@ const MemberListItem: FC<{
|
||||
revalidateCurrentUserRoleAndPermissionsInOrg,
|
||||
onResetCurrentPage,
|
||||
onError,
|
||||
onRemoveMember,
|
||||
}) => {
|
||||
const reinviteCollaboratorFetcher = useReinviteFetcher();
|
||||
const reinviting = reinviteCollaboratorFetcher.state !== 'idle';
|
||||
@@ -447,7 +463,15 @@ const MemberListItem: FC<{
|
||||
doneMessage={isFailed ? 'Failed' : isAcceptedMember || isGroup ? 'Removed' : 'Revoked'}
|
||||
disabled={memberRoleName === 'owner' || isCurrentUser}
|
||||
onClick={() => {
|
||||
if (!permissionRef.current['delete:membership']) {
|
||||
if (isPendingMember && member.metadata.invitationId) {
|
||||
if (!permissionRef.current['delete:invitation']) {
|
||||
showModal(AlertModal, {
|
||||
title: 'Permission required',
|
||||
message: "You don't have permission to make this action, please contact the organization owner.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (!permissionRef.current['delete:membership']) {
|
||||
showModal(AlertModal, {
|
||||
title: 'Permission required',
|
||||
message: "You don't have permission to make this action, please contact the organization owner.",
|
||||
@@ -462,6 +486,7 @@ const MemberListItem: FC<{
|
||||
deleteMember(organizationId, member.metadata.userId!)
|
||||
.then(() => {
|
||||
onResetCurrentPage();
|
||||
onRemoveMember();
|
||||
})
|
||||
.catch(error => {
|
||||
onError(error.message);
|
||||
@@ -473,6 +498,7 @@ const MemberListItem: FC<{
|
||||
revokeOrganizationInvite(organizationId, member.metadata.invitationId)
|
||||
.then(() => {
|
||||
onResetCurrentPage();
|
||||
onRemoveMember();
|
||||
window.main.trackSegmentEvent({ event: SegmentEvent.inviteRevoked });
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -485,6 +511,7 @@ const MemberListItem: FC<{
|
||||
unlinkTeam(organizationId, member.id)
|
||||
.then(() => {
|
||||
onResetCurrentPage();
|
||||
onRemoveMember();
|
||||
})
|
||||
.catch(error => {
|
||||
onError(error.message);
|
||||
|
||||
@@ -41,7 +41,9 @@ export const UpgradeModal = forwardRef<UpgradeModalHandle, ModalProps>((_, ref)
|
||||
const message = `${featureName} is only enabled for ${planDetail}, ${upgradeDetail}`;
|
||||
const onDone = async (isYes: boolean) => {
|
||||
if (isYes) {
|
||||
window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/subscription/update?plan=team`);
|
||||
window.main.openInBrowser(
|
||||
`${getAppWebsiteBaseURL()}/app/subscription/update?plan=team&source=app_feature_${featureName}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
if (isOwner) {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import classnames from 'classnames';
|
||||
import React, { useEffect, useLayoutEffect, useState } from 'react';
|
||||
import { Button, Dialog, Heading, Modal, ModalOverlay } from 'react-aria-components';
|
||||
|
||||
import { getAppWebsiteBaseURL } from '~/common/constants';
|
||||
import { useRootLoaderData } from '~/root';
|
||||
import { useTrialCheckLoaderFetcher } from '~/routes/trial.check';
|
||||
import { useTrialStartActionFetcher } from '~/routes/trial.start';
|
||||
import { Icon } from '~/ui/components/icon';
|
||||
import { usePlanData } from '~/ui/hooks/use-plan';
|
||||
|
||||
export interface UpgradeModalOptions extends Partial<any> {
|
||||
featureName: string;
|
||||
isOwner: boolean;
|
||||
}
|
||||
|
||||
export interface UpgradeModalHandle {
|
||||
show: (options: UpgradeModalOptions) => void;
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
const SIXTY_DAYS = 60 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const UpgradePlanModal = () => {
|
||||
const { userSession } = useRootLoaderData()!;
|
||||
const { isFreePlan } = usePlanData();
|
||||
const { firstName, email, accountId } = userSession;
|
||||
const [open, setOpen] = useState(false);
|
||||
const { load: checkerLoad, data: checkerData } = useTrialCheckLoaderFetcher();
|
||||
const startFetcher = useTrialStartActionFetcher();
|
||||
|
||||
const handleUpgrade = () => {
|
||||
window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/pricing`);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
window.localStorage.setItem(`upgrade-modal-dismissed:${accountId}`, new Date().toISOString());
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const handleStartTrial = () => {
|
||||
if (startFetcher.state === 'idle') {
|
||||
startFetcher.submit();
|
||||
}
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
// show once every 60 days, it is more safe to use useLayoutEffect in case of localStorage failure
|
||||
useLayoutEffect(() => {
|
||||
// only show for free plan
|
||||
if (!isFreePlan) {
|
||||
return;
|
||||
}
|
||||
const dismissedDate = window.localStorage.getItem(`upgrade-modal-dismissed:${accountId}`);
|
||||
if (!dismissedDate || new Date(dismissedDate).getTime() + SIXTY_DAYS < Date.now()) {
|
||||
checkerLoad();
|
||||
}
|
||||
}, [checkerLoad, accountId, isFreePlan]);
|
||||
|
||||
useEffect(() => {
|
||||
if (checkerData?.isEligible) {
|
||||
setOpen(true);
|
||||
}
|
||||
}, [checkerData?.isEligible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (startFetcher.data?.success) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [startFetcher.data?.success]);
|
||||
|
||||
return (
|
||||
<ModalOverlay
|
||||
isOpen={open}
|
||||
onOpenChange={isOpen => {
|
||||
!isOpen && handleClose();
|
||||
}}
|
||||
className="fixed left-0 top-0 z-10 flex h-[--visual-viewport-height] w-full items-center justify-center bg-black/30"
|
||||
>
|
||||
<Modal
|
||||
onOpenChange={isOpen => {
|
||||
!isOpen && handleClose();
|
||||
}}
|
||||
className={classnames(
|
||||
'flex w-[540px] flex-col rounded-md border border-solid border-[--hl-sm] bg-[--color-bg] p-[--padding-lg] text-[--color-font]',
|
||||
)}
|
||||
>
|
||||
<Dialog className="flex h-full flex-1 flex-col overflow-hidden outline-none">
|
||||
{({ close }) => (
|
||||
<div className="flex flex-1 flex-col gap-4 overflow-hidden">
|
||||
<div className="flex flex-shrink-0 items-center justify-between gap-2">
|
||||
<Heading slot="title" className="text-[18px] text-[--color-font]">
|
||||
Welcome to Insomnia, {firstName || email} 🎉
|
||||
</Heading>
|
||||
<Button
|
||||
className="ml-auto flex h-6 flex-shrink-0 items-center justify-center rounded-sm text-sm text-[--color-font] ring-1 ring-transparent transition-all focus:ring-inset focus:ring-[--hl-md] aria-pressed:bg-[--hl-sm]"
|
||||
onPress={() => close()}
|
||||
>
|
||||
<Icon icon="x" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="text-md">
|
||||
You’re on your way to easier and safer API testing. Before you dive in, check out how our Enterprise
|
||||
plan can help you and your team work efficiently and at scale!
|
||||
</p>
|
||||
<p className="mt-[26px] text-[16px]">Upgrade to Enterprise now for access to...</p>
|
||||
<ul className="mt-2 flex flex-col items-start gap-2">
|
||||
<li className="flex flex-col py-2">
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<Icon icon="check-circle" className="h-[16px] text-[--color-surprise]" />
|
||||
<span className="font-semibold">User Governance</span>
|
||||
</div>
|
||||
<span className="ml-[24px] mt-1 text-sm">
|
||||
SSO, SCIM, RBAC and Teams let you control who can access what
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex flex-1 flex-col py-2">
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<Icon icon="check-circle" className="h-[16px] text-[--color-surprise]" />
|
||||
<span className="font-semibold">Increased Storage & Security</span>
|
||||
</div>
|
||||
<span className="ml-[24px] mt-1 text-sm">
|
||||
Mandate Git, Cloud or Local project storage, plus E2EE
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex flex-1 flex-col py-2">
|
||||
<div className="flex items-center gap-[8px]">
|
||||
<Icon icon="check-circle" className="h-[16px] text-[--color-surprise]" />
|
||||
<span className="font-semibold">World Class Support</span>
|
||||
</div>
|
||||
<span className="ml-[24px] mt-1 text-sm">
|
||||
A dedicated CSM that understands you, support access, and optional pro services to start quickly
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mt-3 flex justify-start gap-[20px]">
|
||||
<Button
|
||||
className="h-[30px] rounded bg-[--color-surprise] px-[12px] text-center text-sm text-[--color-font-surprise]"
|
||||
onPress={handleUpgrade}
|
||||
>
|
||||
Buy Now
|
||||
</Button>
|
||||
{checkerData?.isEligible && (
|
||||
<Button
|
||||
className="h-[30px] rounded border border-solid !border-[--hl-md] px-[12px] text-sm text-[--color-font] hover:bg-[--hl-xs]"
|
||||
isDisabled={startFetcher.state !== 'idle'}
|
||||
onPress={handleStartTrial}
|
||||
>
|
||||
Try Free for 14 Days
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="ml-auto h-[30px] rounded !border-none px-[0px] text-sm text-[--color-font]"
|
||||
onPress={() => close()}
|
||||
>
|
||||
No, Thanks
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Dialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
);
|
||||
};
|
||||
|
||||
UpgradePlanModal.displayName = 'UpgradePlanModal';
|
||||
@@ -1,3 +1,4 @@
|
||||
import classNames from 'classnames';
|
||||
import type { FC } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
@@ -22,10 +23,20 @@ import {
|
||||
} from 'react-aria-components';
|
||||
import { useParams } from 'react-router';
|
||||
|
||||
import { getAppWebsiteBaseURL } from '~/common/constants';
|
||||
import { docsPricingLearnMoreLink } from '~/common/documentation';
|
||||
import { isGitCredentialsOAuth } from '~/models/git-repository';
|
||||
import type { StorageRules } from '~/models/organization';
|
||||
import { isOwnerOfOrganization, type StorageRules } from '~/models/organization';
|
||||
import { useRootLoaderData } from '~/root';
|
||||
import { useGitProjectInitCloneActionFetcher } from '~/routes/git.init-clone';
|
||||
import { useOrganizationLoaderData } from '~/routes/organization';
|
||||
import {
|
||||
fallbackFeatures,
|
||||
useOrganizationPermissionsLoaderFetcher,
|
||||
} from '~/routes/organization.$organizationId.permissions';
|
||||
import { useProjectNewActionFetcher } from '~/routes/organization.$organizationId.project.new';
|
||||
import { useIsLightTheme } from '~/ui/hooks/theme';
|
||||
import { useLoaderDeferData } from '~/ui/hooks/use-loader-defer-data';
|
||||
|
||||
import type { OauthProviderName } from '../../../models/git-credentials';
|
||||
import type { GitRepository } from '../../../models/git-repository';
|
||||
@@ -87,6 +98,17 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
}) => {
|
||||
const { organizationId } = useParams() as { organizationId: string };
|
||||
|
||||
const permissionsFetcher = useOrganizationPermissionsLoaderFetcher({ key: `permissions:${organizationId}` });
|
||||
const permissionsFetcherLoad = permissionsFetcher.load;
|
||||
useEffect(() => {
|
||||
permissionsFetcherLoad({
|
||||
organizationId,
|
||||
});
|
||||
}, [organizationId, permissionsFetcherLoad]);
|
||||
const { featuresPromise } = permissionsFetcher.data || {};
|
||||
const [features = fallbackFeatures] = useLoaderDeferData(featuresPromise, organizationId);
|
||||
isGitSyncEnabled = features.gitSync.enabled;
|
||||
|
||||
const [storageType, setStorageType] = useState<'local' | 'remote' | 'git'>(
|
||||
getDefaultProjectStorageType(storageRules, project),
|
||||
);
|
||||
@@ -221,6 +243,14 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const organizationData = useOrganizationLoaderData();
|
||||
const { userSession } = useRootLoaderData()!;
|
||||
const organization = organizationData?.organizations.find(o => o.id === organizationId);
|
||||
const isUserOwner =
|
||||
organization && userSession.accountId && isOwnerOfOrganization({ organization, accountId: userSession.accountId });
|
||||
|
||||
const isLightTheme = useIsLightTheme();
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-[600px] flex-col gap-4">
|
||||
{error && (
|
||||
@@ -283,7 +313,7 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
</p>
|
||||
</Radio>
|
||||
<Radio
|
||||
isDisabled={!isGitSyncEnabled || !storageRules.enableGitSync}
|
||||
isDisabled={!storageRules.enableGitSync}
|
||||
value="git"
|
||||
className="flex-1 rounded border border-solid border-[--hl-md] p-4 transition-colors hover:bg-[--hl-xs] focus:bg-[--hl-sm] focus:outline-none data-[selected]:border-[--color-surprise] data-[disabled]:opacity-25 data-[selected]:ring-2 data-[selected]:ring-[--color-surprise]"
|
||||
>
|
||||
@@ -296,6 +326,57 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
</p>
|
||||
</Radio>
|
||||
</div>
|
||||
{storageType === 'git' && !isGitSyncEnabled && (
|
||||
<div
|
||||
className={classNames('mt-3 flex items-start justify-start gap-5 rounded-md px-6 py-5', {
|
||||
'bg-[#292535]': !isLightTheme,
|
||||
'bg-[#EEEBFF]': isLightTheme,
|
||||
})}
|
||||
>
|
||||
<Icon icon="circle-info" className="pt-1.5" />
|
||||
<div className="flex flex-col items-start justify-start gap-3.5">
|
||||
<Heading className="text-lg font-bold">
|
||||
Git Sync limited to organizations of 3 or fewer users
|
||||
</Heading>
|
||||
{isUserOwner ? (
|
||||
<>
|
||||
<p>
|
||||
Git Sync is included on your plan for up to 3 users. Since your team is larger, you’ll need to
|
||||
upgrade your plan to use it.{' '}
|
||||
<a href={docsPricingLearnMoreLink} className="underline">
|
||||
Learn more ↗
|
||||
</a>
|
||||
</p>
|
||||
<a
|
||||
href={
|
||||
getAppWebsiteBaseURL() +
|
||||
'/app/subscription/update?plan=team&pay_schedule=year&source=app_feature_git_sync'
|
||||
}
|
||||
className="rounded-sm border border-solid border-[--hl-md] px-3 py-2 text-[--color-font] transition-colors hover:bg-opacity-90 hover:no-underline"
|
||||
>
|
||||
Upgrade
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
Git Sync is included on your plan for up to 3 users. Because your team is larger, your admin
|
||||
will need to upgrade the plan for you to access it.
|
||||
</p>
|
||||
<a
|
||||
href={
|
||||
getAppWebsiteBaseURL() +
|
||||
'/app/subscription/update?plan=team&pay_schedule=year&source=app_feature_git_sync'
|
||||
}
|
||||
className="rounded-sm border border-solid border-[--hl-md] px-3 py-2 text-[--color-font] transition-colors hover:bg-opacity-90 hover:no-underline"
|
||||
>
|
||||
Learn More ↗
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</RadioGroup>
|
||||
{showStorageRestrictionMessage && (
|
||||
<div className="flex items-center gap-2 rounded-sm bg-[rgba(var(--color-warning-rgb),0.5)] px-2 py-1 text-sm text-[--color-font-warning]">
|
||||
@@ -319,6 +400,7 @@ export const ProjectSettingsForm: FC<Props> = ({
|
||||
)}
|
||||
{storageType === 'git' && (
|
||||
<Button
|
||||
isDisabled={!isGitSyncEnabled}
|
||||
onPress={() => setActiveView('git-clone')}
|
||||
className="flex h-full w-[10ch] items-center justify-center gap-2 rounded-md border border-solid border-[--hl-md] bg-[rgba(var(--color-surprise-rgb),var(--tw-bg-opacity))] bg-opacity-100 px-4 py-2 text-sm font-semibold text-[--color-font-surprise] ring-1 ring-transparent transition-all hover:bg-opacity-80 focus:ring-inset focus:ring-[--hl-md] aria-pressed:opacity-80"
|
||||
>
|
||||
|
||||
@@ -19,7 +19,7 @@ export const UpgradeNotice = (props: UpgradeNoticeProps) => {
|
||||
: 'Please contact the organization owner to upgrade the plan.';
|
||||
const message = `${featureName} is only enabled for ${planDetail}.`;
|
||||
const handleUpgradePlan = () => {
|
||||
window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/subscription/update?plan=team`);
|
||||
window.main.openInBrowser(`${getAppWebsiteBaseURL()}/app/subscription/update?plan=team&source=app_${featureName}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import * as reactUse from 'react-use';
|
||||
|
||||
import { useRootLoaderData } from '~/root';
|
||||
@@ -91,20 +91,54 @@ export const useThemes = () => {
|
||||
|
||||
export const useIsLightTheme = () => {
|
||||
const rootLoaderData = useRootLoaderData();
|
||||
const isLightTheme = useMemo(() => {
|
||||
|
||||
let lightTheme = 'default';
|
||||
let darkTheme = 'default';
|
||||
let theme = 'default';
|
||||
let autoDetectColorScheme = false;
|
||||
if (rootLoaderData?.settings) {
|
||||
lightTheme = rootLoaderData.settings.lightTheme;
|
||||
darkTheme = rootLoaderData.settings.darkTheme;
|
||||
theme = rootLoaderData.settings.theme;
|
||||
autoDetectColorScheme = rootLoaderData.settings.autoDetectColorScheme;
|
||||
}
|
||||
|
||||
const calcIsLightTheme = useCallback(() => {
|
||||
let isLightTheme = false;
|
||||
if (rootLoaderData?.settings) {
|
||||
const colorScheme = getColorScheme(rootLoaderData.settings);
|
||||
if (colorScheme === 'light') {
|
||||
isLightTheme = true;
|
||||
} else if (colorScheme === 'dark') {
|
||||
isLightTheme = false;
|
||||
} else {
|
||||
// check if user has selected a light theme
|
||||
isLightTheme = rootLoaderData.settings.theme.includes('light');
|
||||
}
|
||||
const colorScheme = getColorScheme({
|
||||
autoDetectColorScheme,
|
||||
darkTheme,
|
||||
lightTheme,
|
||||
theme,
|
||||
});
|
||||
if (colorScheme === 'light') {
|
||||
isLightTheme = lightTheme.includes('light');
|
||||
} else if (colorScheme === 'dark') {
|
||||
isLightTheme = darkTheme.includes('light');
|
||||
} else {
|
||||
// check if user has selected a light theme
|
||||
isLightTheme = theme.includes('light');
|
||||
}
|
||||
return isLightTheme;
|
||||
}, [rootLoaderData?.settings]);
|
||||
}, [lightTheme, darkTheme, theme, autoDetectColorScheme]);
|
||||
|
||||
const [isLightTheme, setIsLightTheme] = useState<boolean>(calcIsLightTheme);
|
||||
|
||||
// Listen to system theme changes
|
||||
useEffect(() => {
|
||||
const matches = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onChange = () => {
|
||||
setIsLightTheme(calcIsLightTheme());
|
||||
};
|
||||
matches.addEventListener('change', onChange);
|
||||
return () => {
|
||||
matches.removeEventListener('change', onChange);
|
||||
};
|
||||
}, [calcIsLightTheme]);
|
||||
|
||||
// Listen to settings changes
|
||||
useEffect(() => {
|
||||
setIsLightTheme(calcIsLightTheme());
|
||||
}, [calcIsLightTheme, lightTheme, darkTheme, theme, autoDetectColorScheme]);
|
||||
return isLightTheme;
|
||||
};
|
||||
|
||||
25
packages/insomnia/src/ui/hooks/use-user-service.ts
Normal file
25
packages/insomnia/src/ui/hooks/use-user-service.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useOrganizationLoaderData } from '~/routes/organization';
|
||||
import { diffInDayCeil } from '~/utils';
|
||||
|
||||
export function useUserService() {
|
||||
const { currentPlan, user } = useOrganizationLoaderData()!;
|
||||
const isPro = currentPlan?.type === 'individual' || currentPlan?.type === 'team';
|
||||
const isEnterpriseOwner = currentPlan?.type === 'enterprise';
|
||||
const isEnterpriseMember = currentPlan?.type === 'enterprise-member';
|
||||
const isEssential = currentPlan?.type === 'free';
|
||||
const isEnterpriseLike = isEnterpriseOwner || isEnterpriseMember;
|
||||
const isTrailing = currentPlan?.status === 'trialing';
|
||||
const trialDaysLeft: number | null =
|
||||
isTrailing && currentPlan?.trialingEnd ? diffInDayCeil(new Date(currentPlan?.trialingEnd), new Date()) : null;
|
||||
return {
|
||||
isPro,
|
||||
isEnterpriseOwner,
|
||||
isEnterpriseMember,
|
||||
isEssential,
|
||||
isEnterpriseLike,
|
||||
canUpgrade: !isEnterpriseLike,
|
||||
displayName: user?.name || user?.email,
|
||||
isTrailing,
|
||||
trialDaysLeft,
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useWorkspaceLoaderData } from '~/routes/organization.$organizationId.project.$projectId.workspace.$workspaceId';
|
||||
import { OAuthAuthorizationStatusModal } from '~/ui/components/modals/oauth-authorization-status-modal';
|
||||
import { UpgradePlanModal } from '~/ui/components/modals/upgrade-plan-modal';
|
||||
|
||||
import { ErrorBoundary } from './components/error-boundary';
|
||||
import { registerModal } from './components/modals';
|
||||
@@ -22,7 +23,6 @@ import { WrapperModal } from './components/modals/wrapper-modal';
|
||||
const Modals = () => {
|
||||
const workspaceData = useWorkspaceLoaderData();
|
||||
const { activeWorkspace, activeEnvironment } = workspaceData || {};
|
||||
|
||||
return (
|
||||
<div key="modals" className="modals">
|
||||
<ErrorBoundary showAlert>
|
||||
@@ -49,6 +49,8 @@ const Modals = () => {
|
||||
|
||||
<SettingsModal ref={instance => registerModal(instance, 'SettingsModal')} />
|
||||
|
||||
<UpgradePlanModal />
|
||||
|
||||
<ResponseDebugModal ref={instance => registerModal(instance, 'ResponseDebugModal')} />
|
||||
|
||||
<AddKeyCombinationModal ref={instance => registerModal(instance, 'AddKeyCombinationModal')} />
|
||||
|
||||
@@ -54,6 +54,21 @@ export function sortOrganizations(accountId: string, organizations: Organization
|
||||
return [...(home ? [home] : []), ...myOrgs, ...notMyOrgs];
|
||||
}
|
||||
|
||||
export async function syncCurrentPlan(sessionId: string, accountId: string) {
|
||||
const [currentPlanResult] = await Promise.allSettled([
|
||||
insomniaFetch<CurrentPlan | void>({
|
||||
method: 'GET',
|
||||
path: '/v1/billing/current-plan',
|
||||
sessionId,
|
||||
}),
|
||||
]);
|
||||
if (currentPlanResult.status === 'fulfilled' && currentPlanResult.value) {
|
||||
localStorage.setItem(`${accountId}:currentPlan`, JSON.stringify(currentPlanResult.value));
|
||||
} else {
|
||||
console.log('[current-plan] Failed to load current-plan', currentPlanResult.status);
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncOrganizations(sessionId: string, accountId: string) {
|
||||
try {
|
||||
const [organizationsResult, user, currentPlan] = await Promise.all([
|
||||
|
||||
@@ -78,3 +78,12 @@ export const moveAfter = (list: any[], key: Key, keys: Iterable<Key>) => {
|
||||
export const typedKeys = <T extends object>(obj: T) => {
|
||||
return Object.keys(obj) as (keyof T)[];
|
||||
};
|
||||
|
||||
export function diffInDayCeil(dateA: Date, dateB: Date) {
|
||||
const diffTime = dateA.getTime() - dateB.getTime();
|
||||
return Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
|
||||
export function formatNumber(num: number) {
|
||||
return num.toString().replace(/\B(?=(\d{3})+(?!\d)(?=\.\d*|$))/g, ',');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user