mirror of
https://github.com/hcengineering/platform.git
synced 2026-09-26 19:44:57 +02:00
Merge branch 'develop' of https://github.com/hcengineering/platform into staging-new
Signed-off-by: Artem Savchenko <armisav@gmail.com>
This commit is contained in:
Generated
+3
-2
@@ -5199,7 +5199,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/model-billing@file:projects/model-billing.tgz':
|
||||
resolution: {integrity: sha512-1eQLuznSm1zZvdCWlGy3hA8xIwRz8RheuLbgmTh/amnobGuia/8ycCJEiPykrsvoLJSiVHrKyaXyySM5+r5jPg==, tarball: file:projects/model-billing.tgz}
|
||||
resolution: {integrity: sha512-gXVvJywcc4AxaQFQNIy3i/YTJpxtJ7Bo+RSWx4Kn8lVMVSHQ1hK8Crr81hKwP0gNslAXV+TOH+xWRn5WY/xntA==, tarball: file:projects/model-billing.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/model-bitrix@file:projects/model-bitrix.tgz':
|
||||
@@ -5599,7 +5599,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/pod-billing@file:projects/pod-billing.tgz':
|
||||
resolution: {integrity: sha512-8NzYAsIUJIXwLfBF2mhvK1N70CrgXR/zN4Ppn0YiKrCAPWUaEg6mxz47qvAKyoYZcNvnzGr/6jQbR/Iv7kPEvA==, tarball: file:projects/pod-billing.tgz}
|
||||
resolution: {integrity: sha512-JaJcuMheg+Um4mTADRrB3cYwpb/XFg9rZQCuh1CAWdZDsKh6g8EoeImFZtD1TeiKXB2wkT+mdbFN0dXYR65LXg==, tarball: file:projects/pod-billing.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/pod-calendar-mailer@file:projects/pod-calendar-mailer.tgz':
|
||||
@@ -28046,6 +28046,7 @@ snapshots:
|
||||
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1)(typescript@5.9.3)
|
||||
'@typescript-eslint/parser': 6.21.0(eslint@8.57.1)(typescript@5.9.3)
|
||||
cors: 2.8.5
|
||||
cross-env: 7.0.3
|
||||
dotenv: 16.0.3
|
||||
esbuild: 0.25.11
|
||||
eslint: 8.57.1
|
||||
|
||||
@@ -37,8 +37,10 @@
|
||||
"@hcengineering/core": "^0.7.20",
|
||||
"@hcengineering/model": "^0.7.17",
|
||||
"@hcengineering/model-core": "^0.7.0",
|
||||
"@hcengineering/model-presentation": "^0.7.0",
|
||||
"@hcengineering/setting": "^0.7.0",
|
||||
"@hcengineering/billing": "^0.7.0",
|
||||
"@hcengineering/platform": "^0.7.17"
|
||||
"@hcengineering/platform": "^0.7.17",
|
||||
"@hcengineering/workbench": "^0.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ import { type IntlString } from '@hcengineering/platform'
|
||||
import setting from '@hcengineering/setting'
|
||||
import billing, { type Tier } from '@hcengineering/billing'
|
||||
import { AccountRole, DOMAIN_MODEL } from '@hcengineering/core'
|
||||
import presentation from '@hcengineering/model-presentation'
|
||||
import workbench from '@hcengineering/workbench'
|
||||
|
||||
export { billingId } from '@hcengineering/billing'
|
||||
export { billing as default }
|
||||
@@ -107,4 +109,9 @@ export function createModel (builder: Builder): void {
|
||||
},
|
||||
billing.tier.Legendary
|
||||
)
|
||||
|
||||
builder.createDoc(presentation.class.ComponentPointExtension, core.space.Model, {
|
||||
extension: workbench.extensions.WorkbenchExtensions,
|
||||
component: billing.component.WorkbenchExtension
|
||||
})
|
||||
}
|
||||
|
||||
@@ -138,6 +138,15 @@
|
||||
|
||||
--theme-button-container-color: #25262A;
|
||||
|
||||
--theme-button-attention-gradient: linear-gradient(135deg, #7C3AED, #A855F7);
|
||||
--theme-button-attention-border: #A855F7;
|
||||
--theme-button-attention-hover-gradient: linear-gradient(135deg, #8B5CF6, #7C3AED);
|
||||
--theme-button-attention-hover-border: #7C3AED;
|
||||
--theme-button-attention-active-gradient: linear-gradient(135deg, #6D28D9, #7C3AED);
|
||||
--theme-button-attention-hover-shadow: 0 8px 24px rgba(124, 58, 237, 0.4);
|
||||
--theme-button-attention-active-shadow: 0 4px 12px rgba(124, 58, 237, 0.6);
|
||||
--theme-button-attention-focus-shadow: 0 0 0 3px rgba(124, 58, 237, 0.3);
|
||||
|
||||
--theme-refinput-divider: rgba(255, 255, 255, .07);
|
||||
--theme-refinput-border: rgba(255, 255, 255, .1);
|
||||
|
||||
@@ -449,6 +458,15 @@
|
||||
|
||||
--theme-button-container-color: #F1F1F1;
|
||||
|
||||
--theme-button-attention-gradient: linear-gradient(135deg, #EC4899, #F97316);
|
||||
--theme-button-attention-border: #F97316;
|
||||
--theme-button-attention-hover-gradient: linear-gradient(135deg, #F97316, #EC4899);
|
||||
--theme-button-attention-hover-border: #EC4899;
|
||||
--theme-button-attention-active-gradient: linear-gradient(135deg, #DC2626, #F97316);
|
||||
--theme-button-attention-hover-shadow: 0 8px 24px rgba(236, 72, 153, 0.3);
|
||||
--theme-button-attention-active-shadow: 0 4px 12px rgba(236, 72, 153, 0.5);
|
||||
--theme-button-attention-focus-shadow: 0 0 0 3px rgba(236, 72, 153, 0.2);
|
||||
|
||||
--theme-refinput-divider: rgba(0, 0, 0, .07);
|
||||
--theme-refinput-border: rgba(0, 0, 0, .1);
|
||||
|
||||
|
||||
@@ -169,6 +169,65 @@
|
||||
}
|
||||
}
|
||||
|
||||
&.attention {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
border: 2px solid var(--theme-button-attention-border);
|
||||
background: var(--theme-button-attention-gradient);
|
||||
animation: attention-pulse 2s ease-in-out infinite;
|
||||
|
||||
.icon { color: var(--button-accent-IconColor); }
|
||||
span { color: var(--button-accent-LabelColor); }
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--theme-button-attention-shine);
|
||||
transition: left 0.6s ease;
|
||||
}
|
||||
|
||||
&:not(.disabled, :disabled):hover {
|
||||
background: var(--theme-button-attention-hover-gradient);
|
||||
border-color: var(--theme-button-attention-hover-border);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--theme-button-attention-hover-shadow);
|
||||
|
||||
&::before { left: 100%; }
|
||||
}
|
||||
|
||||
&:not(.disabled, :disabled):active,
|
||||
&.pressed:not(.disabled, :disabled) {
|
||||
transform: translateY(0) scale(0.98);
|
||||
box-shadow: var(--theme-button-attention-active-shadow);
|
||||
}
|
||||
|
||||
&:not(.no-focus):focus {
|
||||
box-shadow: var(--theme-button-attention-focus-shadow);
|
||||
}
|
||||
|
||||
&:disabled:not(.loading),
|
||||
&.disabled:not(.loading) {
|
||||
background: var(--button-disabled-BackgroundColor);
|
||||
border-color: var(--button-disabled-BackgroundColor);
|
||||
animation: none;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
|
||||
&::before { display: none; }
|
||||
}
|
||||
|
||||
&.loading {
|
||||
background: var(--theme-button-attention-active-gradient);
|
||||
|
||||
span { color: var(--button-accent-LabelColor); }
|
||||
}
|
||||
}
|
||||
|
||||
& > * { pointer-events: none; }
|
||||
}
|
||||
|
||||
@@ -532,6 +591,63 @@
|
||||
background-color: var(--negative-button-disabled);
|
||||
}
|
||||
}
|
||||
&.attention {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
color: var(--primary-button-color);
|
||||
background: var(--theme-button-attention-gradient);
|
||||
border: 2px solid var(--theme-button-attention-border);
|
||||
animation: attention-pulse 2s ease-in-out infinite;
|
||||
|
||||
.btn-icon,
|
||||
.btn-right-icon { color: var(--primary-button-color); }
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--theme-button-attention-shine);
|
||||
transition: left 0.6s ease;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--theme-button-attention-hover-gradient);
|
||||
border-color: var(--theme-button-attention-hover-border);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--theme-button-attention-hover-shadow);
|
||||
|
||||
&::before { left: 100%; }
|
||||
}
|
||||
|
||||
&:active,
|
||||
&.pressed,
|
||||
&.pressed:hover {
|
||||
transform: translateY(0) scale(0.98);
|
||||
box-shadow: var(--theme-button-attention-active-shadow);
|
||||
}
|
||||
|
||||
&:not(.no-focus):focus {
|
||||
box-shadow: var(--theme-button-attention-focus-shadow);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: var(--primary-button-disabled-color);
|
||||
background: var(--primary-button-disabled);
|
||||
border-color: var(--primary-button-disabled);
|
||||
animation: none;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
|
||||
&::before { display: none; }
|
||||
|
||||
.btn-icon,
|
||||
.btn-right-icon { color: var(--primary-button-disabled-color); }
|
||||
}
|
||||
}
|
||||
&.contrast {
|
||||
padding: .75rem 1rem;
|
||||
font-weight: 500;
|
||||
@@ -902,4 +1018,15 @@
|
||||
content: none;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes attention-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: var(--theme-button-attention-active-shadow), 0 0 0 0 var(--theme-button-attention-border);
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
box-shadow: var(--theme-button-attention-hover-shadow), 0 0 0 4px rgba(124, 58, 237, 0.1);
|
||||
transform: scale(1.02);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@
|
||||
label === undefined &&
|
||||
$$slots.content === undefined &&
|
||||
(icon !== undefined || iconRight !== undefined || $$slots.icon || $$slots.iconRight)
|
||||
$: primary = ['primary', 'secondary', 'positive', 'negative'].some((p) => p === kind)
|
||||
$: primary = ['primary', 'secondary', 'positive', 'negative', 'attention'].some((p) => p === kind)
|
||||
|
||||
$: devSize = $deviceInfo.size
|
||||
$: adaptive = adaptiveShrink !== null ? checkAdaptiveMatching(devSize, adaptiveShrink) : false
|
||||
|
||||
@@ -174,6 +174,10 @@
|
||||
max-height: unset;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&.extendedHeight {
|
||||
max-height: 90vh;
|
||||
}
|
||||
}
|
||||
|
||||
.shadow {
|
||||
|
||||
@@ -174,6 +174,7 @@ export type ButtonKind =
|
||||
| 'list-header'
|
||||
| 'contrast'
|
||||
| 'stepper'
|
||||
| 'attention'
|
||||
export type ButtonSize = 'inline' | 'x-small' | 'small' | 'medium' | 'large' | 'x-large'
|
||||
export type ButtonShape =
|
||||
| 'rectangle'
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
"StorageUsage": "Úložiště",
|
||||
"TrafficUsage": "Video/audio provoz",
|
||||
"Usage": "Využití",
|
||||
"Of": "z"
|
||||
"Of": "z",
|
||||
"UpgradePlan": "Upgradovat plán",
|
||||
"LimitReached": "Dosažen limit pracovního prostoru, některé funkce mohou být zakázány. Upgradujte pro obnovení plného přístupu.",
|
||||
"AskBillingAdmin": "Kontaktujte prosím svého správce fakturace pro upgrade plánu."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
"StorageUsage": "Speicher",
|
||||
"TrafficUsage": "Video/Audio-Traffic",
|
||||
"Usage": "Nutzung",
|
||||
"Of": "von"
|
||||
"Of": "von",
|
||||
"UpgradePlan": "Plan upgraden",
|
||||
"LimitReached": "Arbeitsbereich-Limit erreicht, einige Funktionen könnten deaktiviert sein. Upgraden Sie für vollen Zugriff.",
|
||||
"AskBillingAdmin": "Bitte kontaktieren Sie Ihren Abrechnungsadministrator, um den Plan zu upgraden."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
"StorageUsage": "Storage",
|
||||
"TrafficUsage": "Video/Audio traffic",
|
||||
"Usage": "Usage",
|
||||
"Of": "of"
|
||||
"Of": "of",
|
||||
"UpgradePlan": "Upgrade Plan",
|
||||
"LimitReached": "Workspace limit reached, some functionality may be disabled. Upgrade to restore full access.",
|
||||
"AskBillingAdmin": "Please contact your billing administrator to upgrade the plan."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
"StorageUsage": "Almacenamiento",
|
||||
"TrafficUsage": "Tráfico de vídeo/audio",
|
||||
"Usage": "Uso",
|
||||
"Of": "de"
|
||||
"Of": "de",
|
||||
"UpgradePlan": "Actualizar Plan",
|
||||
"LimitReached": "Límite del espacio de trabajo alcanzado, algunas funciones pueden estar deshabilitadas. Actualiza para restaurar acceso completo.",
|
||||
"AskBillingAdmin": "Por favor, contacta a tu administrador de facturación para actualizar el plan."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
"StorageUsage": "Stockage",
|
||||
"TrafficUsage": "Trafic vidéo/audio",
|
||||
"Usage": "Utilisation",
|
||||
"Of": "de"
|
||||
"Of": "de",
|
||||
"UpgradePlan": "Mettre à niveau le plan",
|
||||
"LimitReached": "Limite de l'espace de travail atteinte, certaines fonctionnalités peuvent être désactivées. Mettez à niveau pour restaurer l'accès complet.",
|
||||
"AskBillingAdmin": "Veuillez contacter votre administrateur de facturation pour mettre à niveau le plan."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
"StorageUsage": "Archiviazione",
|
||||
"TrafficUsage": "Traffico video/audio",
|
||||
"Usage": "Utilizzo",
|
||||
"Of": "di"
|
||||
"Of": "di",
|
||||
"UpgradePlan": "Aggiorna Piano",
|
||||
"LimitReached": "Limite workspace raggiunto, alcune funzionalità potrebbero essere disabilitate. Aggiorna per ripristinare l'accesso completo.",
|
||||
"AskBillingAdmin": "Contatta il tuo amministratore di fatturazione per aggiornare il piano."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
"StorageUsage": "ストレージ",
|
||||
"TrafficUsage": "ビデオ/オーディオトラフィック",
|
||||
"Usage": "使用状況",
|
||||
"Of": "/"
|
||||
"Of": "/",
|
||||
"UpgradePlan": "プランをアップグレード",
|
||||
"LimitReached": "ワークスペースの制限に達しました。一部の機能が無効になる場合があります。アップグレードして完全アクセスを復元してください。",
|
||||
"AskBillingAdmin": "プランをアップグレードするには、請求管理者にお問い合わせください。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
"StorageUsage": "Armazenamento",
|
||||
"TrafficUsage": "Tráfego de vídeo/áudio",
|
||||
"Usage": "Uso",
|
||||
"Of": "de"
|
||||
"Of": "de",
|
||||
"UpgradePlan": "Atualizar Plano",
|
||||
"LimitReached": "Limite do workspace atingido, algumas funcionalidades podem estar desabilitadas. Atualize para restaurar acesso completo.",
|
||||
"AskBillingAdmin": "Por favor, contate seu administrador de faturamento para atualizar o plano."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
"StorageUsage": "Хранилище",
|
||||
"TrafficUsage": "Видео/аудио трафик",
|
||||
"Usage": "Использование",
|
||||
"Of": "из"
|
||||
"Of": "из",
|
||||
"UpgradePlan": "Обновить план",
|
||||
"LimitReached": "Достигнут лимит рабочего пространства, некоторые функции могут быть отключены. Обновите план для восстановления полного доступа.",
|
||||
"AskBillingAdmin": "Пожалуйста, свяжитесь с вашим администратором биллинга для обновления плана."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
"StorageUsage": "Depolama",
|
||||
"TrafficUsage": "Video/ses trafiği",
|
||||
"Usage": "Kullanım",
|
||||
"Of": "/"
|
||||
"Of": "/",
|
||||
"UpgradePlan": "Planı Yükselt",
|
||||
"LimitReached": "Çalışma alanı limiti aşıldı, bazı işlevler devre dışı bırakılabilir. Tam erişimi geri yüklemek için yükseltin.",
|
||||
"AskBillingAdmin": "Lütfen planı yükseltmek için faturalama yöneticinizle iletişime geçin."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
"StorageUsage": "存储",
|
||||
"TrafficUsage": "视频/音频流量",
|
||||
"Usage": "使用情况",
|
||||
"Of": "/"
|
||||
"Of": "/",
|
||||
"UpgradePlan": "升级计划",
|
||||
"LimitReached": "已达到工作空间限制,某些功能可能被禁用。请升级以恢复完全访问权限。",
|
||||
"AskBillingAdmin": "请联系您的计费管理员以升级计划。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<!--
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { ModernDialog } from '@hcengineering/ui'
|
||||
import billing from '../plugin'
|
||||
import Subscriptions from './Subscriptions.svelte'
|
||||
</script>
|
||||
|
||||
<ModernDialog width="100%" label={billing.string.UpgradePlan} withoutFooter={true} className="extendedHeight" on:close>
|
||||
<Subscriptions />
|
||||
</ModernDialog>
|
||||
@@ -0,0 +1,75 @@
|
||||
<!--
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { Button } from '@hcengineering/ui'
|
||||
import { onMount, onDestroy } from 'svelte'
|
||||
import billing from '../plugin'
|
||||
import { upgradePlan, isLimitExceeded } from '../utils'
|
||||
|
||||
export let disabled: boolean = false
|
||||
export let size: 'medium' | 'small' = 'small'
|
||||
|
||||
let limitExceeded = false
|
||||
let pollInterval: number | undefined
|
||||
|
||||
const POLL_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes in milliseconds
|
||||
|
||||
// TODO: Store flag or recommended plan in workspace info
|
||||
async function checkLimits (): Promise<void> {
|
||||
try {
|
||||
limitExceeded = await isLimitExceeded()
|
||||
} catch (error) {
|
||||
console.error('Error checking limits:', error)
|
||||
limitExceeded = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleClick (): Promise<void> {
|
||||
if (!disabled) {
|
||||
void upgradePlan()
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Initial check
|
||||
void checkLimits()
|
||||
|
||||
// Set up polling every 10 minutes
|
||||
pollInterval = setInterval(() => {
|
||||
void checkLimits()
|
||||
}, POLL_INTERVAL_MS)
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollInterval !== undefined) {
|
||||
clearInterval(pollInterval)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if limitExceeded}
|
||||
<div class="px-2">
|
||||
<Button
|
||||
label={billing.string.UpgradePlan}
|
||||
showTooltip={{
|
||||
label: billing.string.LimitReached
|
||||
}}
|
||||
kind="attention"
|
||||
{size}
|
||||
{disabled}
|
||||
on:click={handleClick}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,32 @@
|
||||
<!--
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte'
|
||||
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import { pushRootBarComponent } from '@hcengineering/ui'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
|
||||
import billing from '../plugin'
|
||||
|
||||
onMount(() => {
|
||||
const paymentUrl = getMetadata(presentation.metadata.PaymentUrl)
|
||||
if (paymentUrl !== '') {
|
||||
pushRootBarComponent('right', billing.component.UpgradeButtonExt, 10)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<div id="recorder-workbench-ext" class="hidden" />
|
||||
@@ -15,9 +15,13 @@
|
||||
|
||||
import type { Resources } from '@hcengineering/platform'
|
||||
import Settings from './components/Settings.svelte'
|
||||
import UpgradeButton from './components/UpgradeButton.svelte'
|
||||
import WorkbenchExtension from './components/WorkbenchExtension.svelte'
|
||||
|
||||
export default async (): Promise<Resources> => ({
|
||||
component: {
|
||||
Settings
|
||||
Settings,
|
||||
UpgradeButtonExt: UpgradeButton,
|
||||
WorkbenchExtension
|
||||
}
|
||||
})
|
||||
|
||||
@@ -14,11 +14,20 @@
|
||||
//
|
||||
import login from '@hcengineering/login'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import presentation, { MessageBox, getClient } from '@hcengineering/presentation'
|
||||
import billing from '@hcengineering/billing'
|
||||
import { getClient as getAccountClientRaw, type AccountClient } from '@hcengineering/account-client'
|
||||
import {
|
||||
getClient as getAccountClientRaw,
|
||||
type AccountClient,
|
||||
type SubscriptionData
|
||||
} from '@hcengineering/account-client'
|
||||
import { getClient as getBillingClientRaw, type BillingClient } from '@hcengineering/billing-client'
|
||||
import { getClient as getPaymentClientRaw, type PaymentClient } from '@hcengineering/payment-client'
|
||||
import { AccountRole, getCurrentAccount, hasAccountRole } from '@hcengineering/core'
|
||||
import { showPopup } from '@hcengineering/ui'
|
||||
import { type Tier } from '@hcengineering/billing'
|
||||
|
||||
import SubscriptionsModal from './components/SubscriptionsModal.svelte'
|
||||
|
||||
export function getAccountClient (): AccountClient | null {
|
||||
const accountsUrl = getMetadata(login.metadata.AccountsUrl) ?? ''
|
||||
@@ -48,3 +57,99 @@ export function getPaymentClient (): PaymentClient | null {
|
||||
|
||||
return getPaymentClientRaw(paymentUrl, token)
|
||||
}
|
||||
|
||||
export async function isLimitExceeded (): Promise<boolean> {
|
||||
try {
|
||||
const accountClient = getAccountClient()
|
||||
if (accountClient == null) return false
|
||||
|
||||
const workspaceInfo = await accountClient.getWorkspaceInfo(false)
|
||||
const usageInfo = workspaceInfo.usageInfo ?? null
|
||||
|
||||
if (usageInfo === null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const subscription = await getCurrentSubscription(accountClient)
|
||||
if (subscription == null) {
|
||||
return true
|
||||
}
|
||||
|
||||
const tier = await getTierByPlan(subscription.plan)
|
||||
if (tier == null) {
|
||||
return true
|
||||
}
|
||||
|
||||
return checkUsageAgainstLimits(usageInfo, tier)
|
||||
} catch (error) {
|
||||
console.error('Error checking usage limits:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function getTierByPlan (plan: string): Promise<Tier | null> {
|
||||
try {
|
||||
const client = getClient()
|
||||
const tiers = client.getModel().findAllSync(billing.class.Tier, {})
|
||||
|
||||
return (
|
||||
tiers.find((tier) => {
|
||||
const tierPlan = getTierPlan(tier._id)
|
||||
return tierPlan === plan
|
||||
}) ?? null
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error fetching tier by plan:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function getTierPlan (tierId: string): string {
|
||||
const parts = tierId.split(':')
|
||||
return parts.length >= 3 ? parts[2].toLowerCase() : ''
|
||||
}
|
||||
|
||||
function checkUsageAgainstLimits (usageInfo: any, tier: Tier): boolean {
|
||||
const storageUsedBytes = usageInfo.usage.storageBytes ?? 0
|
||||
const trafficUsedBytes = usageInfo.usage.livekitTrafficBytes ?? 0
|
||||
|
||||
const storageLimitBytes = tier.storageLimitGB * 1000 * 1000 * 1000
|
||||
const trafficLimitBytes = tier.trafficLimitGB * 1000 * 1000 * 1000
|
||||
|
||||
return storageUsedBytes > storageLimitBytes || trafficUsedBytes > trafficLimitBytes
|
||||
}
|
||||
|
||||
export async function getCurrentSubscription (accountClient: AccountClient): Promise<SubscriptionData | undefined> {
|
||||
const subscriptions = await accountClient.getSubscriptions()
|
||||
return subscriptions.find((p) => p.type === 'tier')
|
||||
}
|
||||
|
||||
export async function upgradePlan (): Promise<void> {
|
||||
try {
|
||||
const accountClient = getAccountClient()
|
||||
if (accountClient == null) return
|
||||
const currentAccount = getCurrentAccount()
|
||||
if (currentAccount == null) {
|
||||
return
|
||||
}
|
||||
|
||||
const workspaceInfo = await accountClient.getWorkspaceInfo(false)
|
||||
|
||||
const isBillingAccount =
|
||||
workspaceInfo.billingAccount != null
|
||||
? workspaceInfo.billingAccount === currentAccount.uuid
|
||||
: hasAccountRole(currentAccount, AccountRole.Owner)
|
||||
|
||||
if (isBillingAccount) {
|
||||
showPopup(SubscriptionsModal, {})
|
||||
} else {
|
||||
showPopup(MessageBox, {
|
||||
label: billing.string.UpgradePlan,
|
||||
message: billing.string.AskBillingAdmin,
|
||||
params: { canSubmit: false }
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to show upgrade plan modal:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ export const billingPlugin = plugin(billingId, {
|
||||
BillingURL: '' as Metadata<string>
|
||||
},
|
||||
component: {
|
||||
Settings: '' as AnyComponent
|
||||
Settings: '' as AnyComponent,
|
||||
WorkbenchExtension: '' as AnyComponent,
|
||||
UpgradeButtonExt: '' as AnyComponent
|
||||
},
|
||||
string: {
|
||||
Billing: '' as IntlString,
|
||||
@@ -50,7 +52,10 @@ export const billingPlugin = plugin(billingId, {
|
||||
Epic: '' as IntlString,
|
||||
EpicDescription: '' as IntlString,
|
||||
Legendary: '' as IntlString,
|
||||
LegendaryDescription: '' as IntlString
|
||||
LegendaryDescription: '' as IntlString,
|
||||
UpgradePlan: '' as IntlString,
|
||||
LimitReached: '' as IntlString,
|
||||
AskBillingAdmin: '' as IntlString
|
||||
},
|
||||
icon: {
|
||||
Billing: '' as Asset,
|
||||
|
||||
@@ -48,7 +48,11 @@ export interface EmbedNodeProvider {
|
||||
autoEmbedUrl?: (src: string) => boolean
|
||||
}
|
||||
|
||||
export type EmbedNodeView = (editor: Editor, root: HTMLDivElement) => EmbedNodeViewHandle | undefined
|
||||
export type EmbedNodeView = (
|
||||
editor: Editor,
|
||||
root: HTMLDivElement,
|
||||
getPos: () => number
|
||||
) => EmbedNodeViewHandle | undefined
|
||||
export type EmbedNodeProviderConstructor<T> = (options: T) => EmbedNodeProvider
|
||||
|
||||
export type EmbedCursor = ToolbarCursor<EmbedCursorProps>
|
||||
@@ -101,7 +105,7 @@ export const EmbedNode = BaseEmbedNode.extend<EmbedNodeOptions>({
|
||||
},
|
||||
|
||||
addNodeView () {
|
||||
return ({ node, HTMLAttributes, editor }) => {
|
||||
return ({ node, HTMLAttributes, editor, getPos }) => {
|
||||
const providerPromise = matchUrl(this.options.providers, node.attrs.src)
|
||||
|
||||
const root = document.createElement('div')
|
||||
@@ -109,7 +113,8 @@ export const EmbedNode = BaseEmbedNode.extend<EmbedNodeOptions>({
|
||||
root.setAttribute('data-embed-src', node.attrs.src)
|
||||
root.classList.add('embed-node')
|
||||
|
||||
setLoadingState(editor.view, root, true)
|
||||
const pos = typeof getPos === 'function' ? getPos() : 0
|
||||
setLoadingState(editor.view, pos, true)
|
||||
root.setAttribute('block-editor-blur', 'true')
|
||||
|
||||
let handle: EmbedNodeViewHandle | undefined
|
||||
@@ -117,13 +122,14 @@ export const EmbedNode = BaseEmbedNode.extend<EmbedNodeOptions>({
|
||||
void providerPromise
|
||||
.then((view) => {
|
||||
view = view ?? StubEmbedNodeView
|
||||
handle = view(editor, root)
|
||||
handle = view(editor, root, getPos)
|
||||
if (handle !== undefined) {
|
||||
root.classList.add(`embed-${handle.name}`)
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setLoadingState(editor.view, root, false)
|
||||
const pos = typeof getPos === 'function' ? getPos() : 0
|
||||
setLoadingState(editor.view, pos, false)
|
||||
})
|
||||
|
||||
return {
|
||||
@@ -500,7 +506,7 @@ export function replacePreviewContent (
|
||||
return tr
|
||||
}
|
||||
|
||||
const StubEmbedNodeView: EmbedNodeView = (editor: Editor, root: HTMLElement) => {
|
||||
const StubEmbedNodeView: EmbedNodeView = (editor: Editor, root: HTMLDivElement, getPos: () => number) => {
|
||||
const hint = document.createElement('p')
|
||||
const hintIcon = hint.appendChild(document.createElementNS('http://www.w3.org/2000/svg', 'svg'))
|
||||
const hintSpan = hint.appendChild(document.createElement('span'))
|
||||
|
||||
@@ -59,9 +59,10 @@ export const DriveEmbedProvider: EmbedNodeProviderConstructor<DriveEmbedOptions>
|
||||
|
||||
if (previewType === undefined) return
|
||||
|
||||
return (editor: Editor, root: HTMLDivElement) => {
|
||||
return (editor: Editor, root: HTMLDivElement, getPos: () => number) => {
|
||||
const setLoading = (loading: boolean): void => {
|
||||
setLoadingState(editor.view, root, loading)
|
||||
const pos = typeof getPos === 'function' ? getPos() : 0
|
||||
setLoadingState(editor.view, pos, loading)
|
||||
}
|
||||
const renderer = new SvelteRenderer(FilePreview as any, {
|
||||
element: root,
|
||||
|
||||
@@ -21,7 +21,7 @@ export const YoutubeEmbedProvider: EmbedNodeProviderConstructor<YoutubeEmbedUrlO
|
||||
const url = getEmbedUrlFromYoutubeUrl(src, options)
|
||||
if (url === undefined) return
|
||||
|
||||
return (editor: Editor, root: HTMLDivElement) => {
|
||||
return (editor: Editor, root: HTMLDivElement, getPos: () => number) => {
|
||||
root.setAttribute('data-block-toolbar-mouse-lock', 'true')
|
||||
|
||||
const iframe = document.createElement('iframe')
|
||||
|
||||
@@ -151,7 +151,7 @@ export const ImageExtension = ImageNode.extend<ImageOptions>({
|
||||
addNodeView () {
|
||||
const imageSrcCache = new Map<string, { src: string, srcset: string }>()
|
||||
|
||||
return ({ view, node, HTMLAttributes }) => {
|
||||
return ({ view, node, HTMLAttributes, getPos }) => {
|
||||
const container = document.createElement('div')
|
||||
const imgElement = document.createElement('img')
|
||||
container.append(imgElement)
|
||||
@@ -161,12 +161,14 @@ export const ImageExtension = ImageNode.extend<ImageOptions>({
|
||||
'data-align': node.attrs.align
|
||||
}
|
||||
|
||||
setLoadingState(view, container, true)
|
||||
const pos = typeof getPos === 'function' ? getPos() : 0
|
||||
setLoadingState(view, pos, true)
|
||||
const setImageProps = (src: string | null, srcset: string | null): void => {
|
||||
if (src != null) imgElement.src = src
|
||||
if (srcset != null) imgElement.srcset = srcset
|
||||
void imgElement.decode().finally(() => {
|
||||
setLoadingState(view, container, false)
|
||||
const pos = typeof getPos === 'function' ? getPos() : 0
|
||||
setLoadingState(view, pos, false)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ export interface SelectionCursorContext {
|
||||
|
||||
export const ToolbarExtension = Extension.create<ToolbarOptions>({
|
||||
addProseMirrorPlugins () {
|
||||
return [ToolbarControlPlugin(this.editor, this.options)]
|
||||
return [LoadingStatePlugin(), ToolbarControlPlugin(this.editor, this.options)]
|
||||
}
|
||||
})
|
||||
|
||||
@@ -98,6 +98,67 @@ export interface ToolbarControlTxMeta {
|
||||
providers?: Array<ToolbarProvider<any>>
|
||||
}
|
||||
|
||||
export const loadingStatePluginKey = new PluginKey('loadingState')
|
||||
|
||||
export interface LoadingStatePluginState {
|
||||
loadingNodes: Set<number>
|
||||
}
|
||||
|
||||
export interface LoadingStateTxMeta {
|
||||
setLoading?: { pos: number, loading: boolean }
|
||||
}
|
||||
|
||||
export function LoadingStatePlugin (): Plugin<LoadingStatePluginState> {
|
||||
return new Plugin<LoadingStatePluginState>({
|
||||
key: loadingStatePluginKey,
|
||||
|
||||
state: {
|
||||
init: () => ({
|
||||
loadingNodes: new Set<number>()
|
||||
}),
|
||||
|
||||
apply (tr, prevState) {
|
||||
const meta = tr.getMeta(loadingStatePluginKey) as LoadingStateTxMeta | undefined
|
||||
let loadingNodes = prevState.loadingNodes
|
||||
|
||||
// Handle loading state changes
|
||||
if (meta?.setLoading !== undefined) {
|
||||
loadingNodes = new Set(loadingNodes)
|
||||
if (meta.setLoading.loading) {
|
||||
loadingNodes.add(meta.setLoading.pos)
|
||||
} else {
|
||||
loadingNodes.delete(meta.setLoading.pos)
|
||||
}
|
||||
}
|
||||
|
||||
// Map positions through document changes
|
||||
if (tr.docChanged && loadingNodes.size > 0) {
|
||||
const mapped = new Set<number>()
|
||||
loadingNodes.forEach((pos) => {
|
||||
try {
|
||||
const mappedPos = tr.mapping.map(pos, -1)
|
||||
// Verify position is still valid
|
||||
if (mappedPos >= 0 && mappedPos < tr.doc.content.size) {
|
||||
mapped.add(mappedPos)
|
||||
}
|
||||
} catch (e) {
|
||||
// Position no longer valid, skip it
|
||||
console.debug('LoadingStatePlugin: position no longer valid', pos)
|
||||
}
|
||||
})
|
||||
loadingNodes = mapped
|
||||
}
|
||||
|
||||
return { loadingNodes }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function getLoadingState (state: EditorState): LoadingStatePluginState | undefined {
|
||||
return loadingStatePluginKey.getState(state) as LoadingStatePluginState | undefined
|
||||
}
|
||||
|
||||
export const toolbarPluginKey = new PluginKey('dynamicToolbar')
|
||||
|
||||
export interface ToolbarControlPluginState {
|
||||
@@ -326,6 +387,7 @@ export function ToolbarControlPlugin (editor: Editor, options: ToolbarOptions):
|
||||
editor.on('transaction', ({ editor, transaction: tr }) => {
|
||||
const meta = tr.getMeta(toolbarPluginKey) as ToolbarControlTxMeta
|
||||
const loadingState = tr.getMeta('loadingState') as boolean | undefined
|
||||
|
||||
if (meta?.cursor !== undefined && !eqCursors(currCursor, meta.cursor)) {
|
||||
prevCursor = currCursor
|
||||
currCursor = meta.cursor !== null ? { ...meta.cursor } : null
|
||||
@@ -506,17 +568,22 @@ function updateCursorFromMouseEvent (view: EditorView, event: MouseEvent): void
|
||||
}
|
||||
|
||||
function scanForLoadingState (view: EditorView, range: Range): boolean {
|
||||
let isLoading = false
|
||||
view.state.doc.nodesBetween(range.from, range.to, (node, pos) => {
|
||||
const element = view.nodeDOM(pos)
|
||||
if (!(element instanceof HTMLElement)) return
|
||||
if (range.from > range.to || range.from < 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (element.dataset.loading === 'true') {
|
||||
isLoading = true
|
||||
return false
|
||||
const loadingState = getLoadingState(view.state)
|
||||
if (loadingState === undefined || loadingState.loadingNodes.size === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const pos of loadingState.loadingNodes) {
|
||||
if (pos >= range.from && pos <= range.to) {
|
||||
return true
|
||||
}
|
||||
})
|
||||
return isLoading
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function scanForAnchor (view: EditorView, range: Range): HTMLElement | undefined {
|
||||
@@ -653,15 +720,12 @@ function minmax (value = 0, min = 0, max = 0): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
|
||||
export function setLoadingState (view: EditorView, element: HTMLElement, loading: boolean): void {
|
||||
if (loading) {
|
||||
element.setAttribute('data-loading', 'true')
|
||||
} else {
|
||||
element.removeAttribute('data-loading')
|
||||
requestAnimationFrame(() => {
|
||||
view.dispatch(view.state.tr.setMeta('loadingState', loading))
|
||||
})
|
||||
export function setLoadingState (view: EditorView, pos: number, loading: boolean): void {
|
||||
const meta: LoadingStateTxMeta = {
|
||||
setLoading: { pos, loading }
|
||||
}
|
||||
|
||||
view.dispatch(view.state.tr.setMeta(loadingStatePluginKey, meta).setMeta('loadingState', loading))
|
||||
}
|
||||
|
||||
export const GeneralToolbarProvider: ToolbarProvider<any> = {
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
"typescript": "^5.9.3",
|
||||
"@types/morgan": "~1.9.9",
|
||||
"@types/on-headers": "^1.0.2",
|
||||
"@types/uuid": "^8.3.1"
|
||||
"@types/uuid": "^8.3.1",
|
||||
"cross-env": "~7.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hcengineering/analytics": "^0.7.17",
|
||||
|
||||
Reference in New Issue
Block a user