From ad38a210532116e400f6981f986c17984c16d127 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 20 Aug 2025 13:19:26 +0200 Subject: [PATCH 01/15] :bug: Fix incorrect show request-access dialog on not-found on viewer When a user is not-authenticated --- frontend/src/app/main/ui/static.cljs | 47 ++++++++++++++-------------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 837c7c934b..03db808f87 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -501,16 +501,16 @@ profile (mf/deref refs/profile) auth-error? (= type :authentication) + not-found? (= type :not-found) authenticated? (is-authenticated? profile) request-access? (and - (or (= type :not-found) auth-error?) (or workspace? dashboard? view?) - (or (:file-id info) - (:team-id info)))] + (or (some? (:file-id info)) + (some? (:team-id info))))] (mf/with-effect [params info] (when-not (:loaded info) @@ -518,25 +518,26 @@ (rx/subs! (partial reset! info*) (partial reset! info* {:loaded true}))))) - (if (and auth-error? (not authenticated?)) - [:> context-wrapper* - {:is-workspace workspace? - :is-dashboard dashboard? - :is-viewer view? - :profile profile} - [:> login-dialog* {}]] - (when (get info :loaded false) - (if request-access? - [:> context-wrapper* {:is-workspace workspace? - :is-dashboard dashboard? - :is-viewer view? - :profile profile} - [:> request-access* {:file-id (:file-id info) - :team-id (:team-id info) - :is-default (:team-default info) - :profile profile - :is-workspace workspace?}]] - - [:> exception-section* props]))))) + (if (or auth-error? not-found?) + (if (not authenticated?) + [:> context-wrapper* + {:is-workspace workspace? + :is-dashboard dashboard? + :is-viewer view? + :profile profile} + [:> login-dialog* {}]] + (when (get info :loaded false) + (if request-access? + [:> context-wrapper* {:is-workspace workspace? + :is-dashboard dashboard? + :is-viewer view? + :profile profile} + [:> request-access* {:file-id (:file-id info) + :team-id (:team-id info) + :is-default (:team-default info) + :profile profile + :is-workspace workspace?}]] + [:> exception-section* props]))) + [:> exception-section* props]))) From ad0ef82ffcd527aa9b7464ed203da6dad124cfff Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 7 Aug 2025 18:27:08 +0200 Subject: [PATCH 02/15] :tada: Add management http api --- backend/src/app/http.clj | 5 + backend/src/app/http/management.clj | 206 ++++++++++++++++++ backend/src/app/main.clj | 6 + .../backend_tests/http_management_test.clj | 96 ++++++++ common/src/app/common/time.cljc | 66 +++++- 5 files changed, 377 insertions(+), 2 deletions(-) create mode 100644 backend/src/app/http/management.clj create mode 100644 backend/test/backend_tests/http_management_test.clj diff --git a/backend/src/app/http.clj b/backend/src/app/http.clj index 5a52146ff0..275f081fd0 100644 --- a/backend/src/app/http.clj +++ b/backend/src/app/http.clj @@ -17,6 +17,7 @@ [app.http.awsns :as-alias awsns] [app.http.debug :as-alias debug] [app.http.errors :as errors] + [app.http.management :as mgmt] [app.http.middleware :as mw] [app.http.session :as session] [app.http.websocket :as-alias ws] @@ -143,6 +144,7 @@ [::debug/routes schema:routes] [::mtx/routes schema:routes] [::awsns/routes schema:routes] + [::mgmt/routes schema:routes] ::session/manager ::setup/props ::db/pool]) @@ -170,6 +172,9 @@ ["/webhooks" (::awsns/routes cfg)] + ["/management" + (::mgmt/routes cfg)] + (::ws/routes cfg) ["/api" {:middleware [[mw/cors]]} diff --git a/backend/src/app/http/management.clj b/backend/src/app/http/management.clj new file mode 100644 index 0000000000..aa83d7b58e --- /dev/null +++ b/backend/src/app/http/management.clj @@ -0,0 +1,206 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.http.management + "Internal mangement HTTP API" + (:require + [app.common.schema :as sm] + [app.common.schema.generators :as sg] + [app.common.time :as ct] + [app.db :as db] + [app.main :as-alias main] + [app.rpc.commands.profile :as cmd.profile] + [app.setup :as-alias setup] + [app.tokens :as tokens] + [app.worker :as-alias wrk] + [integrant.core :as ig] + [yetti.response :as-alias yres])) + +;; ---- ROUTES + +(declare ^:private authenticate) +(declare ^:private get-customer) +(declare ^:private update-customer) + +(defmethod ig/assert-key ::routes + [_ params] + (assert (db/pool? (::db/pool params)) "expect valid database pool")) + +(defmethod ig/init-key ::routes + [_ cfg] + [["/authenticate" + {:handler (partial authenticate cfg) + :allowed-methods #{:post}}] + + ["/get-customer" + {:handler (partial get-customer cfg) + :allowed-methods #{:post}}] + + ["/update-customer" + {:handler (partial update-customer cfg) + :allowed-methods #{:post}}]]) + +;; ---- HELPERS + +(defn- coercer + [schema & {:as opts}] + (let [decode-fn (sm/decoder schema sm/json-transformer) + check-fn (sm/check-fn schema opts)] + (fn [data] + (-> data decode-fn check-fn)))) + +;; ---- API: AUTHENTICATE + +(defn- authenticate + [cfg request] + (let [token (-> request :params :token) + props (get cfg ::setup/props) + result (tokens/verify props {:token token :iss "authentication"})] + {::yres/status 200 + ::yres/body result})) + +;; ---- API: GET-CUSTOMER + +(def ^:private schema:get-customer + [:map [:id ::sm/uuid]]) + +(def ^:private coerce-get-customer-params + (coercer schema:get-customer + :type :validation + :hint "invalid data provided for `get-customer` rpc call")) + +(def ^:private sql:get-customer-slots + "WITH teams AS ( + SELECT tpr.team_id AS id, + tpr.profile_id AS profile_id + FROM team_profile_rel AS tpr + WHERE tpr.is_owner IS true + AND tpr.profile_id = ? + ), teams_with_slots AS ( + SELECT tpr.team_id AS id, + count(*) AS total + FROM team_profile_rel AS tpr + WHERE tpr.team_id IN (SELECT id FROM teams) + AND tpr.can_edit IS true + GROUP BY 1 + ORDER BY 2 + ) + SELECT max(total) AS total FROM teams_with_slots;") + +(defn- get-customer-slots + [cfg profile-id] + (let [result (db/exec-one! cfg [sql:get-customer-slots profile-id])] + (:total result))) + +(defn- get-customer + [cfg request] + (let [profile-id (-> request :params coerce-get-customer-params :id) + profile (cmd.profile/get-profile cfg profile-id) + result {:id (get profile :id) + :name (get profile :fullname) + :email (get profile :email) + :num-editors (get-customer-slots cfg profile-id) + :subscription (-> profile :props :subscription)}] + {::yres/status 200 + ::yres/body result})) + + +;; ---- API: UPDATE-CUSTOMER + +(def ^:private schema:timestamp + (sm/type-schema + {:type ::timestamp + :pred ct/inst? + :type-properties + {:title "inst" + :description "The same as :app.common.time/inst but encodes to epoch" + :error/message "should be an instant" + :gen/gen (->> (sg/small-int) + (sg/fmap (fn [v] (ct/inst v)))) + :decode/string ct/inst + :encode/string inst-ms + :decode/json ct/inst + :encode/json inst-ms}})) + +(def ^:private schema:subscription + [:map {:title "Subscription"} + [:id ::sm/text] + [:customer-id ::sm/text] + [:type [:enum + "unlimited" + "professional" + "enterprise"]] + [:status [:enum + "active" + "canceled" + "incomplete" + "incomplete_expired" + "past_due" + "paused" + "trialing" + "unpaid"]] + + [:billing-period [:enum + "month" + "day" + "week" + "year"]] + [:quantity :int] + [:description [:maybe ::sm/text]] + [:created-at schema:timestamp] + [:start-date [:maybe schema:timestamp]] + [:ended-at [:maybe schema:timestamp]] + [:trial-end [:maybe schema:timestamp]] + [:trial-start [:maybe schema:timestamp]] + [:cancel-at [:maybe schema:timestamp]] + [:canceled-at [:maybe schema:timestamp]] + [:current-period-end [:maybe schema:timestamp]] + [:current-period-start [:maybe schema:timestamp]] + [:cancel-at-period-end :boolean] + + [:cancellation-details + [:map {:title "CancellationDetails"} + [:comment [:maybe ::sm/text]] + [:reason [:maybe ::sm/text]] + [:feedback [:maybe + [:enum + "customer_service" + "low_quality" + "missing_feature" + "other" + "switched_service" + "too_complex" + "too_expensive" + "unused"]]]]]]) + +(def ^:private schema:update-customer + [:map + [:id ::sm/uuid] + [:subscription [:maybe schema:subscription]]]) + +(def ^:private coerce-update-customer-params + (coercer schema:update-customer + :type :validation + :hint "invalid data provided for `update-customer` rpc call")) + +(defn- update-customer + [cfg request] + (let [{:keys [id subscription]} + (-> request :params coerce-update-customer-params) + + {:keys [props] :as profile} + (cmd.profile/get-profile cfg id) + + props + (assoc props :subscription subscription)] + + (db/update! cfg :profile + {:props (db/tjson props)} + {:id id} + {::db/return-keys false}) + + {::yres/status 201 + ::yres/body nil})) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 544b530b4d..ff47c30a54 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -19,6 +19,7 @@ [app.http.awsns :as http.awsns] [app.http.client :as-alias http.client] [app.http.debug :as-alias http.debug] + [app.http.management :as mgmt] [app.http.session :as-alias session] [app.http.session.tasks :as-alias session.tasks] [app.http.websocket :as http.ws] @@ -272,6 +273,10 @@ ::email/blacklist (ig/ref ::email/blacklist) ::email/whitelist (ig/ref ::email/whitelist)} + ::mgmt/routes + {::db/pool (ig/ref ::db/pool) + ::setup/props (ig/ref ::setup/props)} + :app.http/router {::session/manager (ig/ref ::session/manager) ::db/pool (ig/ref ::db/pool) @@ -280,6 +285,7 @@ ::setup/props (ig/ref ::setup/props) ::mtx/routes (ig/ref ::mtx/routes) ::oidc/routes (ig/ref ::oidc/routes) + ::mgmt/routes (ig/ref ::mgmt/routes) ::http.debug/routes (ig/ref ::http.debug/routes) ::http.assets/routes (ig/ref ::http.assets/routes) ::http.ws/routes (ig/ref ::http.ws/routes) diff --git a/backend/test/backend_tests/http_management_test.clj b/backend/test/backend_tests/http_management_test.clj new file mode 100644 index 0000000000..a02bbe20d6 --- /dev/null +++ b/backend/test/backend_tests/http_management_test.clj @@ -0,0 +1,96 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.http-management-test + (:require + [app.common.data :as d] + [app.common.time :as ct] + [app.db :as db] + [app.http.access-token] + [app.http.management :as mgmt] + [app.http.session :as sess] + [app.main :as-alias main] + [app.rpc :as-alias rpc] + [backend-tests.helpers :as th] + [clojure.test :as t] + [mockery.core :refer [with-mocks]] + [yetti.response :as-alias yres])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + + +(t/deftest authenticate-method + (let [profile (th/create-profile* 1) + props (get th/*system* :app.setup/props) + token (#'sess/gen-token props {:profile-id (:id profile)}) + request {:params {:token token}} + response (#'mgmt/authenticate th/*system* request)] + + (t/is (= 200 (::yres/status response))) + (t/is (= "authentication" (-> response ::yres/body :iss))) + (t/is (= (:id profile) (-> response ::yres/body :uid))))) + +(t/deftest get-customer-method + (let [profile (th/create-profile* 1) + request {:params {:id (:id profile)}} + response (#'mgmt/get-customer th/*system* request)] + + (t/is (= 200 (::yres/status response))) + (t/is (= (:id profile) (-> response ::yres/body :id))) + (t/is (= (:fullname profile) (-> response ::yres/body :name))) + (t/is (= (:email profile) (-> response ::yres/body :email))) + (t/is (= 1 (-> response ::yres/body :num-editors))) + (t/is (nil? (-> response ::yres/body :subscription))))) + +(t/deftest update-customer-method + (let [profile (th/create-profile* 1) + + subs {:type "unlimited" + :description nil + :id "foobar" + :customer-id (str (:id profile)) + :status "past_due" + :billing-period "week" + :quantity 1 + :created-at (ct/truncate (ct/now) :day) + :cancel-at-period-end true + :start-date nil + :ended-at nil + :trial-end nil + :trial-start nil + :cancel-at nil + :canceled-at nil + :current-period-end nil + :current-period-start nil + + :cancellation-details + {:comment "other" + :reason "other" + :feedback "other"}} + + request {:params {:id (:id profile) + :subscription subs}} + response (#'mgmt/update-customer th/*system* request)] + + (t/is (= 201 (::yres/status response))) + (t/is (nil? (::yres/body response))) + + (let [request {:params {:id (:id profile)}} + response (#'mgmt/get-customer th/*system* request)] + + (t/is (= 200 (::yres/status response))) + (t/is (= (:id profile) (-> response ::yres/body :id))) + (t/is (= (:fullname profile) (-> response ::yres/body :name))) + (t/is (= (:email profile) (-> response ::yres/body :email))) + (t/is (= 1 (-> response ::yres/body :num-editors))) + + (let [subs' (-> response ::yres/body :subscription)] + (t/is (= subs' subs)))))) + + + + diff --git a/common/src/app/common/time.cljc b/common/src/app/common/time.cljc index 20e8b68dab..270c1e7463 100644 --- a/common/src/app/common/time.cljc +++ b/common/src/app/common/time.cljc @@ -7,14 +7,17 @@ (ns app.common.time "Minimal cross-platoform date time api for specific use cases on types definition and other common code." + (:refer-clojure :exclude [inst?]) #?(:cljs (:require ["luxon" :as lxn]) :clj (:import - java.time.format.DateTimeFormatter + java.time.Duration java.time.Instant - java.time.Duration))) + java.time.format.DateTimeFormatter + java.time.temporal.ChronoUnit + java.time.temporal.TemporalUnit))) #?(:cljs (def DateTime lxn/DateTime)) @@ -22,6 +25,42 @@ #?(:cljs (def Duration lxn/Duration)) +(defn- resolve-temporal-unit + [o] + (case o + (:nanos :nano) + #?(:clj ChronoUnit/NANOS + :cljs (throw (js/Error. "not supported nanos"))) + + (:micros :microsecond :micro) + #?(:clj ChronoUnit/MICROS + :cljs (throw (js/Error. "not supported nanos"))) + + (:millis :millisecond :milli) + #?(:clj ChronoUnit/MILLIS + :cljs "millisecond") + + (:seconds :second) + #?(:clj ChronoUnit/SECONDS + :cljs "second") + + (:minutes :minute) + #?(:clj ChronoUnit/MINUTES + :cljs "minute") + + (:hours :hour) + #?(:clj ChronoUnit/HOURS + :cljs "hour") + + (:days :day) + #?(:clj ChronoUnit/DAYS + :cljs "day"))) + +(defn temporal-unit + [o] + #?(:clj (if (instance? TemporalUnit o) o (resolve-temporal-unit o)) + :cljs (resolve-temporal-unit o))) + (defn now [] #?(:clj (Instant/now) @@ -49,6 +88,11 @@ #?(:clj (instance? Instant o) :cljs (instance? DateTime o))) +(defn inst? + [o] + #?(:clj (instance? Instant o) + :cljs (instance? DateTime o))) + (defn parse-instant [s] (cond @@ -63,6 +107,24 @@ #?(:clj (Instant/parse s) :cljs (.fromISO ^js DateTime s)))) +(defn inst + [s] + (parse-instant s)) + +#?(:clj + (defn truncate + [o unit] + (let [unit (temporal-unit unit)] + (cond + (inst? o) + (.truncatedTo ^Instant o ^TemporalUnit unit) + + (instance? Duration o) + (.truncatedTo ^Duration o ^TemporalUnit unit) + + :else + (throw (IllegalArgumentException. "only instant and duration allowed")))))) + (defn format-instant [v] #?(:clj (.format DateTimeFormatter/ISO_INSTANT ^Instant v) From 2846b80cf7fe318327fb578588fbd409fb19d029 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 14 Aug 2025 10:37:09 +0200 Subject: [PATCH 03/15] :sparkles: Add rpc methods for obtain editors --- backend/src/app/rpc/commands/profile.clj | 20 ++++++++++++++++++++ backend/src/app/rpc/commands/teams.clj | 13 ++++++++++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 6449bef09c..d57d4b6523 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -471,6 +471,26 @@ (-> (rph/wrap nil) (rph/with-transform (session/delete-fn cfg))))) +(def sql:get-subscription-editors + "SELECT DISTINCT + p.id, + p.fullname AS name, + p.email AS email + FROM team_profile_rel AS tpr1 + JOIN team_profile_rel AS tpr2 + ON (tpr1.team_id = tpr2.team_id) + JOIN profile AS p + ON (tpr2.profile_id = p.id) + WHERE tpr1.profile_id = ? + AND tpr1.is_owner IS true + AND tpr2.can_edit IS true") + +(sv/defmethod ::get-subscription-usage + {::doc/added "2.9"} + [cfg {:keys [::rpc/profile-id]}] + (let [editors (db/exec! cfg [sql:get-subscription-editors profile-id])] + {:editors editors})) + ;; --- HELPERS (def sql:owned-teams diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index a099223b83..95fa381146 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -142,7 +142,18 @@ ELSE COALESCE(p.props->'~:subscription'->>'~:status', 'incomplete') END, '~:seats', p.props->'~:subscription'->'~:quantity' - ) AS subscription + ) AS subscription, + + (SELECT count(distinct p.id) + FROM team_profile_rel AS tpr1 + JOIN team_profile_rel AS tpr2 + ON (tpr1.team_id = tpr2.team_id) + JOIN profile AS p + ON (tpr2.profile_id = p.id) + WHERE tpr1.profile_id = p.id + AND tpr1.is_owner IS true + AND tpr2.can_edit IS true) AS unique_editors + FROM team_profile_rel AS tp JOIN team AS t ON (t.id = tp.team_id) JOIN team_profile_rel AS tpr From 854f286364288b318c99a62e5d081b08f655c40c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Thu, 14 Aug 2025 08:52:48 +0200 Subject: [PATCH 04/15] :recycle: Fix subscriptions inconsistencies --- .../data/subscription/get-owned-teams.json | 14 -- .../get-subscription-usage-one-editor.json | 9 + .../subscription/get-subscription-usage.json | 44 ++++ .../subscription/get-team-invitations.json | 1 + ...-team-members-subscription-one-member.json | 16 ++ ...t-teams-unlimited-subscription-member.json | 3 +- ...et-teams-unlimited-subscription-owner.json | 3 +- .../ui/pages/SubscriptionProfilePage.js | 4 +- .../ui/specs/subscriptions-dashboard.spec.js | 211 +++++------------- .../ui/specs/subscriptions-workspace.spec.js | 36 +++ frontend/src/app/main/data/profile.cljs | 10 + frontend/src/app/main/ui/dashboard.cljs | 2 +- .../src/app/main/ui/dashboard/sidebar.cljs | 10 +- .../app/main/ui/dashboard/subscription.cljs | 105 ++++----- .../app/main/ui/dashboard/subscription.scss | 13 +- frontend/src/app/main/ui/dashboard/team.cljs | 34 +-- frontend/src/app/main/ui/dashboard/team.scss | 5 - .../app/main/ui/settings/subscription.cljs | 141 ++++++------ .../app/main/ui/settings/subscription.scss | 63 ++++-- frontend/translations/en.po | 134 +++++------ frontend/translations/es.po | 139 +++++------- 21 files changed, 499 insertions(+), 498 deletions(-) delete mode 100644 frontend/playwright/data/subscription/get-owned-teams.json create mode 100644 frontend/playwright/data/subscription/get-subscription-usage-one-editor.json create mode 100644 frontend/playwright/data/subscription/get-subscription-usage.json create mode 100644 frontend/playwright/data/subscription/get-team-invitations.json create mode 100644 frontend/playwright/data/subscription/get-team-members-subscription-one-member.json diff --git a/frontend/playwright/data/subscription/get-owned-teams.json b/frontend/playwright/data/subscription/get-owned-teams.json deleted file mode 100644 index 7d925f91fc..0000000000 --- a/frontend/playwright/data/subscription/get-owned-teams.json +++ /dev/null @@ -1,14 +0,0 @@ -[ - { - "~:id": "~uf88e52d7-2b77-81fd-8006-23413fafe56c", - "~:name": "The Alpaca team", - "~:total-editors": 3, - "~:total-members": 3 - }, - { - "~:id": "~u81be1d05-a07b-81d5-8006-3e728bea76fb", - "~:name": "The Quokka team", - "~:total-editors": 1, - "~:total-members": 1 - } -] \ No newline at end of file diff --git a/frontend/playwright/data/subscription/get-subscription-usage-one-editor.json b/frontend/playwright/data/subscription/get-subscription-usage-one-editor.json new file mode 100644 index 0000000000..5c9b94974d --- /dev/null +++ b/frontend/playwright/data/subscription/get-subscription-usage-one-editor.json @@ -0,0 +1,9 @@ +{ + "~:editors": [ + { + "~:id": "~u4f535993-36f9-8135-8006-9b18345c55cd", + "~:name": "Luke Skywalker", + "~:email": "luke@rebels.com" + } + ] +} \ No newline at end of file diff --git a/frontend/playwright/data/subscription/get-subscription-usage.json b/frontend/playwright/data/subscription/get-subscription-usage.json new file mode 100644 index 0000000000..2c6a1c03dd --- /dev/null +++ b/frontend/playwright/data/subscription/get-subscription-usage.json @@ -0,0 +1,44 @@ +{ + "~:editors": [ + { + "~:id": "~u4f535993-36f9-8135-8006-9b18345c55cd", + "~:name": "Luke Skywalker", + "~:email": "luke@rebels.com" + }, + { + "~:id": "~u70a3b232-3722-8008-8006-86646ed3b6af", + "~:name": "Leia Organa", + "~:email": "leia@rebels.com" + }, + { + "~:id": "~u81be1d05-a07b-81d5-8006-39095ea4121c", + "~:name": "Han Solo", + "~:email": "han@falcon.com" + }, + { + "~:id": "~u96ce2641-e3fd-803a-8006-5e516d034d57", + "~:name": "Darth Vader", + "~:email": "vader@empire.com" + }, + { + "~:id": "~uc9aa6cb0-9fb5-80a2-8006-9c3a0783ddc7", + "~:name": "Obi-Wan Kenobi", + "~:email": "obiwan@jedi.com" + }, + { + "~:id": "~uf88e52d7-2b77-81fd-8006-234039f9e8db", + "~:name": "Yoda", + "~:email": "yoda@jedi.com" + }, + { + "~:id": "~uf88e52d7-2b77-81fd-8006-2341585b061d", + "~:name": "Chewbacca", + "~:email": "chewie@falcon.com" + }, + { + "~:id": "~ufa35a73f-fa4f-81f9-8006-a558c4d406b1", + "~:name": "R2-D2", + "~:email": "r2d2@astromech.com" + } + ] +} \ No newline at end of file diff --git a/frontend/playwright/data/subscription/get-team-invitations.json b/frontend/playwright/data/subscription/get-team-invitations.json new file mode 100644 index 0000000000..0637a088a0 --- /dev/null +++ b/frontend/playwright/data/subscription/get-team-invitations.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/frontend/playwright/data/subscription/get-team-members-subscription-one-member.json b/frontend/playwright/data/subscription/get-team-members-subscription-one-member.json new file mode 100644 index 0000000000..ba78a4b89d --- /dev/null +++ b/frontend/playwright/data/subscription/get-team-members-subscription-one-member.json @@ -0,0 +1,16 @@ +[ + { + "~:is-admin": false, + "~:email": "foo@example.com", + "~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", + "~:name": "Princesa Leia", + "~:fullname": "Princesa Leia", + "~:is-owner": false, + "~:modified-at": "~m1713533116365", + "~:can-edit": true, + "~:is-active": true, + "~:id": "~u123456789-0000-0000-0000-abcdefabcdef", + "~:profile-id": "~u123456789-0000-0000-0000-abcdefabcdef", + "~:created-at": "~m1713533116365" + } +] diff --git a/frontend/playwright/data/subscription/get-teams-unlimited-subscription-member.json b/frontend/playwright/data/subscription/get-teams-unlimited-subscription-member.json index 59dc7071b8..db9eeb3f58 100644 --- a/frontend/playwright/data/subscription/get-teams-unlimited-subscription-member.json +++ b/frontend/playwright/data/subscription/get-teams-unlimited-subscription-member.json @@ -39,10 +39,11 @@ "~:is-admin": true, "~:can-edit": true }, + "~:unique-editors": 1, "~:subscription": { "~:type": "unlimited", "~:status": "trialing", - "~:seats": 2 + "~:seats": 5 }, "~:name": "Second team", "~:modified-at": "~m1701164272671", diff --git a/frontend/playwright/data/subscription/get-teams-unlimited-subscription-owner.json b/frontend/playwright/data/subscription/get-teams-unlimited-subscription-owner.json index b211cd6ddc..8a9eff3865 100644 --- a/frontend/playwright/data/subscription/get-teams-unlimited-subscription-owner.json +++ b/frontend/playwright/data/subscription/get-teams-unlimited-subscription-owner.json @@ -33,6 +33,7 @@ "fdata/shape-data-type" ] }, + "~:unique-editors": 1, "~:permissions": { "~:type": "~:owner", "~:is-owner": true, @@ -42,7 +43,7 @@ "~:subscription": { "~:type": "unlimited", "~:status": "trialing", - "~:seats": 2 + "~:seats": 5 }, "~:name": "Second team", "~:modified-at": "~m1701164272671", diff --git a/frontend/playwright/ui/pages/SubscriptionProfilePage.js b/frontend/playwright/ui/pages/SubscriptionProfilePage.js index 304856fdb4..b0e349a81a 100644 --- a/frontend/playwright/ui/pages/SubscriptionProfilePage.js +++ b/frontend/playwright/ui/pages/SubscriptionProfilePage.js @@ -7,8 +7,8 @@ export class SubscriptionProfilePage extends DashboardPage { await DashboardPage.mockRPC( page, - "get-owned-teams", - "subscription/get-owned-teams.json", + "get-subscription-usage", + "subscription/get-subscription-usage.json", ); } diff --git a/frontend/playwright/ui/specs/subscriptions-dashboard.spec.js b/frontend/playwright/ui/specs/subscriptions-dashboard.spec.js index 62cb1a718e..6e73ca711a 100644 --- a/frontend/playwright/ui/specs/subscriptions-dashboard.spec.js +++ b/frontend/playwright/ui/specs/subscriptions-dashboard.spec.js @@ -19,6 +19,12 @@ test.describe("Subscriptions: dashboard", () => { "subscription/get-profile-unlimited-subscription.json", ); + await DashboardPage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage.json", + ); + await DashboardPage.mockRPC( page, "get-team-info", @@ -55,6 +61,12 @@ test.describe("Subscriptions: dashboard", () => { "subscription/get-profile-unlimited-subscription.json", ); + await DashboardPage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage-one-editor.json", + ); + await DashboardPage.mockRPC( page, "get-team-info", @@ -89,6 +101,12 @@ test.describe("Subscriptions: dashboard", () => { "subscription/get-profile-unlimited-unpaid-subscription.json", ); + await DashboardPage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage.json", + ); + await DashboardPage.mockRPC( page, "get-team-info", @@ -123,6 +141,12 @@ test.describe("Subscriptions: dashboard", () => { "subscription/get-profile-enterprise-canceled-subscription.json", ); + await DashboardPage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage.json", + ); + await DashboardPage.mockRPC( page, "get-team-info", @@ -159,6 +183,12 @@ test.describe("Subscriptions: team members and invitations", () => { "logged-in-user/get-profile-logged-in.json", ); + await DashboardPage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage.json", + ); + await DashboardPage.mockRPC( page, "get-team-info", @@ -206,6 +236,12 @@ test.describe("Subscriptions: team members and invitations", () => { "subscription/get-profile-unlimited-subscription.json", ); + await DashboardPage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage.json", + ); + await DashboardPage.mockRPC( page, "get-team-info", @@ -251,7 +287,7 @@ test.describe("Subscriptions: team members and invitations", () => { ).toBeVisible(); }); - test("Members tab has warning message when team has more members than subscriptions. Subscribe link is shown for owners.", async ({ + test("Members tab has warning message when user has more seats than editors.", async ({ page, }) => { await DashboardPage.mockRPC( @@ -260,6 +296,12 @@ test.describe("Subscriptions: team members and invitations", () => { "subscription/get-profile-unlimited-subscription.json", ); + await DashboardPage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage-one-editor.json", + ); + await DashboardPage.mockRPC( page, "get-team-info", @@ -283,97 +325,7 @@ test.describe("Subscriptions: team members and invitations", () => { await DashboardPage.mockRPC( page, "get-team-members?team-id=*", - "subscription/get-team-members-subscription-owner.json", - ); - - await dashboardPage.mockRPC( - "push-audit-events", - "workspace/audit-event-empty.json", - ); - - await dashboardPage.goToSecondTeamMembersSection(); - await expect(page.getByTestId("cta")).toBeVisible(); - await expect(page.getByText("Subscribe now.")).toBeVisible(); - }); - - test("Members tab has warning message when team has more members than subscriptions. Contact to owner is shown for members.", async ({ - page, - }) => { - await DashboardPage.mockRPC( - page, - "get-profile", - "logged-in-user/get-profile-logged-in.json", - ); - - await DashboardPage.mockRPC( - page, - "get-team-info", - "subscription/get-team-info-subscriptions.json", - ); - - const dashboardPage = new DashboardPage(page); - await dashboardPage.setupDashboardFull(); - await DashboardPage.mockRPC( - page, - "get-teams", - "subscription/get-teams-unlimited-subscription-member.json", - ); - - await DashboardPage.mockRPC( - page, - "get-projects?team-id=*", - "dashboard/get-projects-second-team.json", - ); - - await DashboardPage.mockRPC( - page, - "get-team-members?team-id=*", - "subscription/get-team-members-subscription-member.json", - ); - - await dashboardPage.mockRPC( - "push-audit-events", - "workspace/audit-event-empty.json", - ); - - await dashboardPage.goToSecondTeamMembersSection(); - await expect(page.getByTestId("cta")).toBeVisible(); - await expect(page.getByText("Contact with the team owner")).toBeVisible(); - }); - - test("Members tab has warning message when has professional subscription and more than 8 members.", async ({ - page, - }) => { - await DashboardPage.mockRPC( - page, - "get-profile", - "logged-in-user/get-profile-logged-in.json", - ); - - await DashboardPage.mockRPC( - page, - "get-team-info", - "subscription/get-team-info-subscriptions.json", - ); - - const dashboardPage = new DashboardPage(page); - await dashboardPage.setupDashboardFull(); - await DashboardPage.mockRPC( - page, - "get-teams", - "subscription/get-teams-professional-subscription-owner.json", - ); - - await DashboardPage.mockRPC( - page, - "get-projects?team-id=*", - "dashboard/get-projects-second-team.json", - ); - - await DashboardPage.mockRPC( - page, - "get-team-members?team-id=*", - "subscription/get-team-members-more-than-8.json", + "subscription/get-team-members-subscription-one-member.json", ); await dashboardPage.mockRPC( @@ -384,13 +336,11 @@ test.describe("Subscriptions: team members and invitations", () => { await dashboardPage.goToSecondTeamMembersSection(); await expect(page.getByTestId("cta")).toBeVisible(); await expect( - page.getByText( - "The Professional plan is designed for teams of up to 8 editors (owner, admin, and editor).", - ), + page.getByText("Inviting people while on the unlimited plan"), ).toBeVisible(); }); - test("Invitations tab has warning message when team has more members than subscriptions", async ({ + test("Invitations tab has warning message when user has more seats than editors.", async ({ page, }) => { await DashboardPage.mockRPC( @@ -399,6 +349,12 @@ test.describe("Subscriptions: team members and invitations", () => { "subscription/get-profile-unlimited-subscription.json", ); + await DashboardPage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage-one-editor.json", + ); + await DashboardPage.mockRPC( page, "get-team-info", @@ -422,13 +378,13 @@ test.describe("Subscriptions: team members and invitations", () => { await DashboardPage.mockRPC( page, "get-team-members?team-id=*", - "subscription/get-team-members-subscription-owner.json", + "subscription/get-team-members-subscription-one-member.json", ); await DashboardPage.mockRPC( page, "get-team-invitations?team-id=*", - "dashboard/get-team-invitations-empty.json", + "subscription/get-team-invitations.json", ); await dashboardPage.mockRPC( @@ -439,64 +395,7 @@ test.describe("Subscriptions: team members and invitations", () => { await dashboardPage.goToSecondTeamInvitationsSection(); await expect(page.getByTestId("cta")).toBeVisible(); await expect( - page.getByText( - "Looks like your team has grown! Your plan includes 2 seats, but you're now using 3", - ), - ).toBeVisible(); - }); - - test("Invitations tab has warning message when has professional subscription and more than 8 members.", async ({ - page, - }) => { - await DashboardPage.mockRPC( - page, - "get-profile", - "subscription/get-profile-unlimited-subscription.json", - ); - - await DashboardPage.mockRPC( - page, - "get-team-info", - "subscription/get-team-info-subscriptions.json", - ); - - const dashboardPage = new DashboardPage(page); - await dashboardPage.setupDashboardFull(); - await DashboardPage.mockRPC( - page, - "get-teams", - "subscription/get-teams-unlimited-subscription-owner.json", - ); - - await DashboardPage.mockRPC( - page, - "get-projects?team-id=*", - "dashboard/get-projects-second-team.json", - ); - - await DashboardPage.mockRPC( - page, - "get-team-members?team-id=*", - "subscription/get-team-members-more-than-8.json", - ); - - await DashboardPage.mockRPC( - page, - "get-team-invitations?team-id=*", - "dashboard/get-team-invitations-empty.json", - ); - - await dashboardPage.mockRPC( - "push-audit-events", - "workspace/audit-event-empty.json", - ); - - await dashboardPage.goToSecondTeamInvitationsSection(); - await expect(page.getByTestId("cta")).toBeVisible(); - await expect( - page.getByText( - "Looks like your team has grown! Your plan includes 2 seats, but you're now using 9", - ), + page.getByText("Inviting people while on the unlimited plan"), ).toBeVisible(); }); }); diff --git a/frontend/playwright/ui/specs/subscriptions-workspace.spec.js b/frontend/playwright/ui/specs/subscriptions-workspace.spec.js index 237f4354b3..3fbedc3d75 100644 --- a/frontend/playwright/ui/specs/subscriptions-workspace.spec.js +++ b/frontend/playwright/ui/specs/subscriptions-workspace.spec.js @@ -22,6 +22,12 @@ test.describe("Subscriptions: workspace", () => { "subscription/get-profile-unlimited-subscription.json", ); + await WorkspacePage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage.json", + ); + await workspacePage.mockRPC( "push-audit-events", "workspace/audit-event-empty.json", @@ -44,6 +50,12 @@ test.describe("Subscriptions: workspace", () => { "subscription/get-profile-enterprise-subscription.json", ); + await WorkspacePage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage.json", + ); + await workspacePage.mockRPC( "push-audit-events", "workspace/audit-event-empty.json", @@ -60,6 +72,18 @@ test.describe("Subscriptions: workspace", () => { const workspacePage = new WorkspacePage(page); await workspacePage.setupEmptyFile(); + await WorkspacePage.mockRPC( + page, + "get-profile", + "subscription/get-profile-enterprise-subscription.json", + ); + + await WorkspacePage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage.json", + ); + await workspacePage.mockRPC( "push-audit-events", "workspace/audit-event-empty.json", @@ -90,6 +114,12 @@ test.describe("Subscriptions: workspace", () => { "subscription/get-profile-unlimited-subscription.json", ); + await WorkspacePage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage.json", + ); + await WorkspacePage.mockRPC( page, "get-teams", @@ -126,6 +156,12 @@ test.describe("Subscriptions: workspace", () => { "subscription/get-profile-enterprise-subscription.json", ); + await WorkspacePage.mockRPC( + page, + "get-subscription-usage", + "subscription/get-subscription-usage.json", + ); + await WorkspacePage.mockRPC( page, "get-teams", diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs index 75a42c0e26..aae15eb1b6 100644 --- a/frontend/src/app/main/data/profile.cljs +++ b/frontend/src/app/main/data/profile.cljs @@ -91,6 +91,16 @@ ptk/WatchEvent (watch [_ _ _] (->> (rp/cmd! :get-profile) + (rx/mapcat (fn [profile] + (if (and (contains? cf/flags :subscriptions) + (is-authenticated? profile)) + (->> (rp/cmd! :get-subscription-usage {}) + (rx/map (fn [{:keys [editors]}] + (update-in profile [:props :subscription] assoc :editors editors))) + (rx/catch (fn [cause] + (js/console.error "unexpected error on obtaining subscription usage" cause) + (rx/of profile)))) + (rx/of profile)))) (rx/map (partial ptk/data-event ::profile-fetched)) (rx/catch on-fetch-profile-exception))))) diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 05c523fa29..7ab47029fb 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -132,7 +132,7 @@ [:> team-members-page* {:team team :profile profile}] :dashboard-invitations - [:> team-invitations-page* {:team team}] + [:> team-invitations-page* {:team team :profile profile}] :dashboard-webhooks [:> webhooks-page* {:team team}] diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 7e88c005b3..722c2ed885 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -27,7 +27,11 @@ [app.main.ui.dashboard.comments :refer [comments-icon* comments-section]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] [app.main.ui.dashboard.project-menu :refer [project-menu*]] - [app.main.ui.dashboard.subscription :refer [subscription-sidebar* menu-team-icon* get-subscription-type]] + [app.main.ui.dashboard.subscription :refer [subscription-sidebar* + menu-team-icon* + dashboard-cta* + show-subscription-dashboard-banner? + get-subscription-type]] [app.main.ui.dashboard.team-form] [app.main.ui.icons :as i :refer [icon-xref]] [app.util.dom :as dom] @@ -991,7 +995,9 @@ [:* (when (contains? cf/flags :subscriptions) - [:> subscription-sidebar* {:profile profile}]) + (if (show-subscription-dashboard-banner? profile) + [:> dashboard-cta* {:profile profile}] + [:> subscription-sidebar* {:profile profile}])) ;; TODO remove this block when subscriptions is full implemented (when (contains? cf/flags :subscriptions-old) diff --git a/frontend/src/app/main/ui/dashboard/subscription.cljs b/frontend/src/app/main/ui/dashboard/subscription.cljs index 2a560f84bc..5902128c85 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.cljs +++ b/frontend/src/app/main/ui/dashboard/subscription.cljs @@ -20,7 +20,7 @@ (defn get-subscription-type [{:keys [type status] :as subscription}] - (if (and subscription (not (contains? #{"unpaid" "canceled"} status))) + (if (and subscription (:type subscription) (not (contains? #{"unpaid" "canceled"} status))) type "professional")) @@ -61,7 +61,7 @@ [:> cta-power-up* {:top-title (tr "subscription.dashboard.power-up.your-subscription") :top-description (tr "subscription.dashboard.power-up.professional.top-title") - :bottom-description (tr "subscription.dashboard.power-up.professional.bottom", subscription-href) + :bottom-description (tr "subscription.dashboard.power-up.professional.bottom-text" subscription-href) :has-dropdown true}] "unlimited" @@ -69,13 +69,13 @@ [:> cta-power-up* {:top-title (tr "subscription.dashboard.power-up.your-subscription") :top-description (tr "subscription.dashboard.power-up.trial.top-title") - :bottom-description (tr "subscription.dashboard.power-up.trial.bottom-description", subscription-href) + :bottom-description (tr "subscription.dashboard.power-up.trial.bottom-description" subscription-href) :has-dropdown true}] [:> cta-power-up* {:top-title (tr "subscription.dashboard.power-up.your-subscription") :top-description (tr "subscription.dashboard.power-up.unlimited-plan") - :bottom-description (tr "subscription.dashboard.power-up.unlimited.bottom-text", subscription-href) + :bottom-description (tr "subscription.dashboard.power-up.unlimited.bottom-text" subscription-href) :has-dropdown true}]) "enterprise" @@ -147,71 +147,74 @@ [:span {:class (stl/css :item-name)} (tr "subscription.workspace.header.menu.option.power-up")]])) (mf/defc members-cta* - [{:keys [banner-is-expanded team]}] - (let [subscription (:subscription team) + [] + [:> cta* {:class (stl/css :members-cta) + :title (tr "subscription.dashboard.unlimited-members-extra-editors-cta-title")} + [:> i18n/tr-html* + {:tag-name "span" + :class (stl/css :cta-message) + :content (tr "subscription.dashboard.unlimited-members-extra-editors-cta-text")}]]) + +(mf/defc dashboard-cta* + [{:keys [profile]}] + (let [subscription (-> profile :props :subscription) subscription-type (get-subscription-type subscription) - is-owner (-> team :permissions :is-owner) - - email-owner (:email (some #(when (:is-owner %) %) (:members team))) - mail-to-owner (str "" email-owner "") go-to-subscription (dm/str (u/join cf/public-uri "#/settings/subscriptions")) - seats (or (:seats subscription) 0) - editors (count (filterv :can-edit (:members team))) - - link - (if is-owner - go-to-subscription - mail-to-owner) - + seats (:quantity subscription) + editors (count (:editors subscription)) cta-title (cond - (and (= "professional" subscription-type) (>= editors 8)) - (tr "subscription.dashboard.cta.professional-plan-designed") + (= "professional" subscription-type) + (tr "subscription.dashboard.professional-dashboard-cta-title" editors) - (and (= "unlimited" subscription-type) (< seats editors)) - (tr "subscription.dashboard.cta.unlimited-many-editors" seats editors)) + (= "unlimited" subscription-type) + (tr "subscription.dashboard.unlimited-dashboard-cta-title" seats editors)) cta-message (cond - (and (= "professional" subscription-type) (>= editors 8) is-owner) - (tr "subscription.dashboard.cta.upgrade-to-unlimited-enterprise-owner" link) + (= "professional" subscription-type) + (tr "subscription.dashboard.professional-dashboard-cta-upgrade-owner" go-to-subscription) - (and (= "professional" subscription-type) (>= editors 8) (not is-owner)) - (tr "subscription.dashboard.cta.upgrade-to-unlimited-enterprise-member" link) + (= "unlimited" subscription-type) + (tr "subscription.dashboard.unlimited-dashboard-cta-upgrade-owner" go-to-subscription))] - (and (= "unlimited" subscription-type) (< seats editors) is-owner) - (tr "subscription.dashboard.cta.upgrade-more-seats-owner" link) - - (and (= "unlimited" subscription-type) (< seats editors) (not is-owner)) - (tr "subscription.dashboard.cta.upgrade-more-seats-member" link))] - - [:> cta* {:class (stl/css-case ::members-cta-full-width banner-is-expanded :members-cta (not banner-is-expanded)) :title cta-title} + [:> cta* {:class (stl/css :dashboard-cta) :title cta-title} [:> i18n/tr-html* {:tag-name "span" :class (stl/css :cta-message) :content cta-message}]])) -(defn show-subscription-members-main-banner? - [team] - (let [subscription-type (get-subscription-type (:subscription team)) - seats (-> team :subscription :seats) - editors (count (filter :can-edit (:members team)))] +(defn show-subscription-dashboard-banner? + [profile] + (let [subscription (-> profile :props :subscription) + subscription-type (get-subscription-type subscription) + seats (:quantity subscription) + editors (count (:editors subscription))] + (or (and (= subscription-type "professional") (> editors 8)) (and (= subscription-type "unlimited") - (>= editors 8) - (< seats editors))))) + (or + ;; common: seats < 25 and diff >= 4 + (and (< seats 25) + (>= (- editors seats) 4)) + ;; special: reached 25+ editors, seats < 25 and there is overuse + (and (< seats 25) + (>= editors 25) + (> editors seats))))))) -(defn show-subscription-members-small-banner? - [team] - (let [subscription-type (get-subscription-type (:subscription team)) - seats (-> team :subscription :seats) - editors (count (filterv :can-edit (:members team)))] - (or - (and (= subscription-type "professional") - (= editors 8)) - (and (= subscription-type "unlimited") - (< editors 8) - (< seats editors))))) +(defn show-subscription-members-banner? + [team profile] + (let [subscription (:subscription team) + subscription-type (get-subscription-type subscription) + seats (:seats subscription) + editors (count (-> profile :props :subscription :editors)) + is-owner (-> team :permissions :is-owner)] + (and + is-owner + (= subscription-type "unlimited") + ;; common: seats < 25 and diff >= 4 between editors/seats and there is underuse + (and (< seats 25) + (>= (- seats editors) 4))))) diff --git a/frontend/src/app/main/ui/dashboard/subscription.scss b/frontend/src/app/main/ui/dashboard/subscription.scss index e855b2340f..edf4616c08 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.scss +++ b/frontend/src/app/main/ui/dashboard/subscription.scss @@ -143,16 +143,21 @@ .members-cta { height: fit-content; margin-block-start: var(--sp-s); - margin-inline-start: $s-68; - max-width: $s-200; + margin-inline-start: $s-52; + max-width: $s-220; .cta-title { line-height: 1.2; } } -.members-cta-full-width { - max-width: $s-1000; +.dashboard-cta { + height: fit-content; + margin: var(--sp-l); + + .cta-title { + line-height: 1.2; + } } .cta-message { diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index e4594c7bad..b279bb8c66 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -24,8 +24,7 @@ [app.main.ui.dashboard.change-owner] [app.main.ui.dashboard.subscription :refer [team* members-cta* - show-subscription-members-main-banner? - show-subscription-members-small-banner?]] + show-subscription-members-banner?]] [app.main.ui.dashboard.team-form] [app.main.ui.ds.foundations.assets.icon :refer [icon*]] [app.main.ui.icons :as i] @@ -541,21 +540,15 @@ [:* [:& header {:section :dashboard-team-members :team team}] - [:section {:class (stl/css-case - :dashboard-container true - :dashboard-team-members true - :dashboard-top-cta (show-subscription-members-main-banner? team))} - (when (and (contains? cfg/flags :subscriptions) - (show-subscription-members-main-banner? team)) - [:> members-cta* {:banner-is-expanded true :team team}]) + [:section {:class (stl/css :dashboard-container :dashboard-team-members)} + [:> team-members* {:profile profile :team team}] - (when (and - (contains? cfg/flags :subscriptions) - (show-subscription-members-small-banner? team)) - [:> members-cta* {:banner-is-expanded false :team team}])]]) + (when (and (contains? cfg/flags :subscriptions) + (show-subscription-members-banner? team profile)) + [:> members-cta* {:team team}])]]) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INVITATIONS SECTION @@ -803,7 +796,7 @@ (mf/defc team-invitations-page* {::mf/props :obj} - [{:keys [team]}] + [{:keys [team profile]}] (mf/with-effect [team] (dom/set-html-title @@ -818,16 +811,13 @@ [:* [:& header {:section :dashboard-team-invitations :team team}] - [:section {:class (stl/css-case - :dashboard-team-invitations true - :dashboard-top-cta (show-subscription-members-main-banner? team))} - (when (and (contains? cfg/flags :subscriptions) - (show-subscription-members-main-banner? team)) - [:> members-cta* {:banner-is-expanded true :team team}]) + [:section {:class (stl/css :dashboard-team-invitations)} + [:> invitation-section* {:team team}] + (when (and (contains? cfg/flags :subscriptions) - (show-subscription-members-small-banner? team)) - [:> members-cta* {:banner-is-expanded false :team team}])]]) + (show-subscription-members-banner? team profile)) + [:> members-cta* {:team team}])]]) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; WEBHOOKS SECTION diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index 45469b01a2..847db33ff2 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -293,11 +293,6 @@ scrollbar-gutter: stable; } -.dashboard-team-invitations.dashboard-top-cta { - flex-direction: column; - justify-content: flex-start; -} - .invitations { display: grid; grid-template-rows: auto 1fr; diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index b1bbad4342..f305ae17cd 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -7,16 +7,14 @@ [app.main.data.event :as ev] [app.main.data.modal :as modal] [app.main.refs :as refs] - [app.main.repo :as rp] [app.main.router :as rt] [app.main.store :as st] [app.main.ui.components.forms :as fm] [app.main.ui.dashboard.subscription :refer [get-subscription-type]] [app.main.ui.icons :as i] [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [tr]] + [app.util.i18n :as i18n :refer [tr c]] [app.util.time :as dt] - [beicon.v2.core :as rx] [potok.v2.core :as ptk] [rumext.v2 :as mf])) @@ -54,14 +52,15 @@ :on-click cta-link} cta-text]) (when (and cta-link-trial cta-text-trial) [:button {:class (stl/css :cta-button :bottom-link) :on-click cta-link-trial} cta-text-trial])]) -(def ^:private schema:seats-form +(defn schema:seats-form [min-editors] [:map {:title "SeatsForm"} - [:min-members [::sm/number {:min 1 :max 9999}]]]) + [:min-members [::sm/number {:min min-editors + :max 9999}]]]) (mf/defc subscribe-management-dialog {::mf/register modal/components ::mf/register-as :management-dialog} - [{:keys [subscription-type current-subscription teams subscribe-to-trial]}] + [{:keys [subscription-type current-subscription editors subscribe-to-trial]}] (let [subscription-name (if subscribe-to-trial (if (= subscription-type "unlimited") @@ -71,9 +70,10 @@ "professional" (tr "subscription.settings.professional") "unlimited" (tr "subscription.settings.unlimited") "enterprise" (tr "subscription.settings.enterprise"))) - initial (mf/with-memo [] - {:min-members (or (some->> teams (map :total-editors) (apply max)) 1)}) - form (fm/use-form :schema schema:seats-form + min-editors (or (count editors) 1) + initial (mf/with-memo [min-editors] + {:min-members min-editors}) + form (fm/use-form :schema (schema:seats-form min-editors) :initial initial) subscribe-to-unlimited (mf/use-fn (fn [form] @@ -106,7 +106,14 @@ handle-close-dialog (mf/use-fn (fn [] (st/emit! (ptk/event ::ev/event {::ev/name "close-subscription-modal"})) - (modal/hide!)))] + (modal/hide!))) + + show-editors-list* (mf/use-state false) + show-editors-list (deref show-editors-list*) + handle-click (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (swap! show-editors-list* not)))] [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog)} @@ -115,15 +122,19 @@ (tr "subscription.settings.management.dialog.title" subscription-name)] [:div {:class (stl/css :modal-content)} - (if (seq teams) - [:* [:div {:class (stl/css :modal-text)} - (tr "subscription.settings.management.dialog.choose-this-plan")] - [:ul {:class (stl/css :teams-list)} - (for [team teams] - [:li {:key (dm/str (:id team)) :class (stl/css :team-name)} - (:name team) (tr "subscription.settings.management.dialog.members" (:total-editors team))])]] - [:div {:class (stl/css :modal-text)} - (tr "subscription.settings.management.dialog.no-teams")]) + (when (seq editors) + [:* [:p {:class (stl/css :editors-text)} + (tr "subscription.settings.management.dialog.currently-editors-title" (c (count editors)))] + [:button {:class (stl/css :cta-button :show-editors-button) :on-click handle-click} + (tr "subscription.settings.management.dialog.editors") + [:span {:class (stl/css :icon-dropdown)} i/arrow]] + (when show-editors-list + [:* + [:p {:class (stl/css :editors-text :editors-list-warning)} + (tr "subscription.settings.management.dialog.editors-explanation")] + [:ul {:class (stl/css :editors-list)} + (for [editor editors] + [:li {:key (dm/str (:id editor)) :class (stl/css :team-name)} "- " (:name editor)])]])]) (when (and (or (and (= subscription-type "professional") (contains? #{"unlimited" "enterprise"} (:type current-subscription))) @@ -138,8 +149,6 @@ [:& fm/form {:on-submit subscribe-to-unlimited :class (stl/css :seats-form) :form form} - [:label {:for "editors-subscription" :class (stl/css :modal-text :editors-label)} - (tr "subscription.settings.management.dialog.select-editors")] [:div {:class (stl/css :editors-wrapper)} [:div {:class (stl/css :fields-row)} @@ -149,7 +158,7 @@ :label "" :class (stl/css :input-field)}]] [:div {:class (stl/css :editors-cost)} - [:span {:class (stl/css :modal-text-small)} + [:span {:class (stl/css :modal-text-medium)} (when (> (get-in @form [:clean-data :min-members]) 25) [:> i18n/tr-html* {:class (stl/css :modal-text-cap) @@ -160,9 +169,16 @@ :tag-name "span" :content (tr "subscription.settings.management.dialog.price-month" (* 7 (or (get-in @form [:clean-data :min-members]) 0)))}]] - [:span {:class (stl/css :modal-text-small)} + [:span {:class (stl/css :modal-text-medium)} (tr "subscription.settings.management.dialog.payment-explanation")]]] + (when (get-in @form [:errors :min-members]) + [:div {:class (stl/css :error-message)} + (tr "subscription.settings.management.dialog.input-error")]) + + [:div {:class (stl/css :unlimited-capped-warning)} + (tr "subscription.settings.management.dialog.unlimited-capped-warning")] + [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} [:input @@ -214,6 +230,8 @@ [:div {:class (stl/css :modal-end)} [:div {:class (stl/css :modal-title)} (tr "subscription.settings.sucess.dialog.title" subscription-name)] + (when (not= subscription-name "professional") + [:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.success.dialog.thanks" subscription-name)]) [:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.success.dialog.description")] [:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.sucess.dialog.footer")] @@ -229,9 +247,6 @@ (let [route (mf/deref refs/route) authenticated? (da/is-authenticated? profile) - teams* (mf/use-state nil) - teams (deref teams*) - locale (mf/deref i18n/locale) params-subscription @@ -248,6 +263,9 @@ success-modal-is-trial? (-> route :params :query :trial) + subscription-editors + (-> profile :props :subscription :editors) + subscription (-> profile :props :subscription) @@ -284,7 +302,7 @@ open-subscription-modal (mf/use-fn - (mf/deps teams) + (mf/deps subscription-editors) (fn [subscription-type current-subscription] (st/emit! (ev/event {::ev/name "open-subscription-modal" ::ev/origin "settings:in-app"})) @@ -292,12 +310,7 @@ (modal/show :management-dialog {:subscription-type subscription-type :current-subscription current-subscription - :teams teams :subscribe-to-trial (not subscription)}))))] - - (mf/with-effect [] - (->> (rp/cmd! :get-owned-teams) - (rx/subs! (fn [teams] - (reset! teams* teams))))) + :editors subscription-editors :subscribe-to-trial (not (:type subscription))}))))] (mf/with-effect [] (dom/set-html-title (tr "subscription.labels"))) @@ -315,8 +328,8 @@ "unlimited" "enterprise") :current-subscription subscription - :teams teams - :subscribe-to-trial (not subscription)}) + :editors subscription-editors + :subscribe-to-trial (not (:type subscription))}) (rt/nav :settings-subscription {} {::rt/replace true})) ^boolean show-subscription-success-modal? @@ -341,18 +354,18 @@ (case subscription-type "professional" [:> plan-card* {:card-title (tr "subscription.settings.professional") - :benefits [(tr "subscription.settings.professional.projects-files"), - (tr "subscription.settings.professional.teams-editors"), - (tr "subscription.settings.professional.storage-autosave")]}] + :benefits [(tr "subscription.settings.professional.storage-benefit"), + (tr "subscription.settings.professional.autosave-benefit"), + (tr "subscription.settings.professional.teams-editors-benefit")]}] "unlimited" (if subscription-is-trial? [:> plan-card* {:card-title (tr "subscription.settings.unlimited-trial") :card-title-icon i/character-u - :benefits-title (tr "subscription.settings.benefits.all-professional-benefits") - :benefits [(tr "subscription.settings.unlimited.teams"), - (tr "subscription.settings.unlimited.bill"), - (tr "subscription.settings.unlimited.storage-autosave")] + :benefits-title (tr "subscription.settings.benefits.all-professional-benefits"), + :benefits [(tr "subscription.settings.unlimited.storage-benefit") + (tr "subscription.settings.unlimited.autosave-benefit"), + (tr "subscription.settings.unlimited.bill")] :cta-text (tr "subscription.settings.manage-your-subscription") :cta-link go-to-payments :cta-text-trial (tr "subscription.settings.add-payment-to-continue") @@ -362,9 +375,9 @@ [:> plan-card* {:card-title (tr "subscription.settings.unlimited") :card-title-icon i/character-u :benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits") - :benefits [(tr "subscription.settings.unlimited.teams"), - (tr "subscription.settings.unlimited.bill"), - (tr "subscription.settings.unlimited.storage-autosave")] + :benefits [(tr "subscription.settings.unlimited.storage-benefit"), + (tr "subscription.settings.unlimited.autosave-benefit"), + (tr "subscription.settings.unlimited.bill")] :cta-text (tr "subscription.settings.manage-your-subscription") :cta-link go-to-payments :editors (-> profile :props :subscription :quantity)}]) @@ -373,18 +386,18 @@ (if subscription-is-trial? [:> plan-card* {:card-title (tr "subscription.settings.enterprise-trial") :card-title-icon i/character-e - :benefits-title (tr "subscription.settings.benefits.all-professional-benefits") - :benefits [(tr "subscription.settings.enterprise.unlimited-storage"), - (tr "subscription.settings.enterprise.capped-bill"), - (tr "subscription.settings.enterprise.autosave")] + :benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits"), + :benefits [(tr "subscription.settings.enterprise.unlimited-storage-benefit"), + (tr "subscription.settings.enterprise.autosave"), + (tr "subscription.settings.enterprise.capped-bill")] :cta-text (tr "subscription.settings.manage-your-subscription") :cta-link go-to-payments}] [:> plan-card* {:card-title (tr "subscription.settings.enterprise") :card-title-icon i/character-e - :benefits-title (tr "subscription.settings.benefits.all-professional-benefits") - :benefits [(tr "subscription.settings.enterprise.unlimited-storage"), - (tr "subscription.settings.enterprise.capped-bill"), - (tr "subscription.settings.enterprise.autosave")] + :benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits"), + :benefits [(tr "subscription.settings.enterprise.unlimited-storage-benefit"), + (tr "subscription.settings.enterprise.autosave"), + (tr "subscription.settings.enterprise.capped-bill")] :cta-text (tr "subscription.settings.manage-your-subscription") :cta-link go-to-payments}])) @@ -406,9 +419,9 @@ [:> plan-card* {:card-title (tr "subscription.settings.professional") :price-value "$0" :price-period (tr "subscription.settings.price-editor-month") - :benefits [(tr "subscription.settings.professional.projects-files"), - (tr "subscription.settings.professional.teams-editors"), - (tr "subscription.settings.professional.storage-autosave")] + :benefits [(tr "subscription.settings.professional.storage-benefit"), + (tr "subscription.settings.professional.autosave-benefit"), + (tr "subscription.settings.professional.teams-editors-benefit")] :cta-text (tr "subscription.settings.subscribe") :cta-link #(open-subscription-modal "professional") :cta-text-with-icon (tr "subscription.settings.more-information") @@ -420,10 +433,10 @@ :price-value "$7" :price-period (tr "subscription.settings.price-editor-month") :benefits-title (tr "subscription.settings.benefits.all-professional-benefits") - :benefits [(tr "subscription.settings.unlimited.teams"), - (tr "subscription.settings.unlimited.bill"), - (tr "subscription.settings.unlimited.storage-autosave")] - :cta-text (if subscription (tr "subscription.settings.subscribe") (tr "subscription.settings.try-it-free")) + :benefits [(tr "subscription.settings.unlimited.storage-benefit"), + (tr "subscription.settings.unlimited.autosave-benefit"), + (tr "subscription.settings.unlimited.bill")] + :cta-text (if (:type subscription) (tr "subscription.settings.subscribe") (tr "subscription.settings.try-it-free")) :cta-link #(open-subscription-modal "unlimited" subscription) :cta-text-with-icon (tr "subscription.settings.more-information") :cta-link-with-icon go-to-pricing-page}]) @@ -434,10 +447,10 @@ :price-value "$950" :price-period (tr "subscription.settings.price-organization-month") :benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits") - :benefits [(tr "subscription.settings.enterprise.unlimited-storage"), - (tr "subscription.settings.enterprise.capped-bill"), - (tr "subscription.settings.enterprise.autosave")] - :cta-text (if subscription (tr "subscription.settings.subscribe") (tr "subscription.settings.try-it-free")) + :benefits [(tr "subscription.settings.enterprise.unlimited-storage-benefit"), + (tr "subscription.settings.enterprise.autosave"), + (tr "subscription.settings.enterprise.capped-bill")] + :cta-text (if (:type subscription) (tr "subscription.settings.subscribe") (tr "subscription.settings.try-it-free")) :cta-link #(open-subscription-modal "enterprise" subscription) :cta-text-with-icon (tr "subscription.settings.more-information") :cta-link-with-icon go-to-pricing-page}])]]])) diff --git a/frontend/src/app/main/ui/settings/subscription.scss b/frontend/src/app/main/ui/settings/subscription.scss index 43b8df9804..b74acbef43 100644 --- a/frontend/src/app/main/ui/settings/subscription.scss +++ b/frontend/src/app/main/ui/settings/subscription.scss @@ -63,7 +63,7 @@ .title-section { @include t.use-typography("title-large"); color: var(--color-foreground-primary); - margin-block-end: var(--sp-l); + margin-block-end: var(--sp-xxl); } .plan-section-title { @@ -81,7 +81,7 @@ .plan-card-header { display: flex; justify-content: space-between; - margin-block-end: var(--sp-s); + margin-block-end: var(--sp-m); } .plan-card-title-container { @@ -135,12 +135,13 @@ } .other-subscriptions { - margin-block-start: $s-36; + margin-block-start: $s-52; } .cta-button { @include t.use-typography("body-medium"); @include buttonStyle; + align-items: center; color: var(--color-accent-tertiary); display: flex; margin-block-start: var(--sp-m); @@ -155,6 +156,10 @@ margin-inline-start: var(--sp-xs); } +.icon-dropdown svg { + transform: rotate(90deg); +} + .bottom-link { margin-block-start: var(--sp-xs); } @@ -168,11 +173,11 @@ display: grid; grid-template-rows: auto 1fr auto; max-height: initial; - min-width: $s-520; + min-width: $s-548; } .modal-dialog.subscription-success { - min-width: $s-612; + min-width: $s-648; } .close-btn { @@ -191,12 +196,8 @@ margin-block-end: var(--sp-l); } -.modal-text-lage { - @include t.use-typography("body-large"); -} - -.modal-text-small { - @include t.use-typography("body-small"); +.modal-text-medium { + @include t.use-typography("body-medium"); } .modal-text-cap { @@ -207,7 +208,7 @@ text-decoration: line-through; } -.modal-text-small strong, +.modal-text-medium strong, .text-strikethrough strong, .modal-text-cap strong { font-weight: $fw700; @@ -260,11 +261,21 @@ } } -.teams-list { - list-style-position: inside; - list-style-type: disc; +.editors-text { + @include t.use-typography("body-medium"); + margin: 0; +} + +.editors-list-warning { + margin-inline-start: var(--sp-xl); + margin-block: var(--sp-s); +} + +.editors-list { + @include t.use-typography("body-medium"); + list-style-position: inside; + list-style-type: none; margin-inline-start: var(--sp-xl); - margin-block: var(--sp-xxl); max-height: $s-216; overflow-y: auto; } @@ -274,11 +285,14 @@ width: $s-80; } -.editors-label { - margin-block-start: var(--sp-xxl); +.error-message { + @include t.use-typography("body-small"); + color: var(--color-foreground-error); + margin-block-start: $s-8; } .editors-wrapper { + align-items: center; display: flex; gap: var(--sp-xl); margin-block-start: var(--sp-l); @@ -288,3 +302,16 @@ display: flex; flex-direction: column; } + +.unlimited-capped-warning { + @include t.use-typography("body-small"); + background-color: var(--color-background-tertiary); + border-radius: var(--sp-s); + margin-block-start: $s-40; + padding-block: var(--sp-s); + padding-inline: var(--sp-m); +} + +.show-editors-button { + padding-inline: 0; +} diff --git a/frontend/translations/en.po b/frontend/translations/en.po index c1e2ff5347..4480006e37 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -4275,50 +4275,14 @@ msgstr "Zoom lense increase" msgid "shortcuts.zoom-selected" msgstr "Zoom to selected" -#: src/app/main/ui/dashboard/subscription.cljs:154 -msgid "subscription.dashboard.cta.professional-plan-designed" -msgstr "" -"The Professional plan is designed for teams of up to 8 editors (owner, " -"admin, and editor)." - -#: src/app/main/ui/dashboard/subscription.cljs:160 -msgid "subscription.dashboard.cta.unlimited-many-editors" -msgstr "" -"Looks like your team has grown! Your plan includes %s seats, but you're now using %s" - -#: src/app/main/ui/dashboard/subscription.cljs:168 -#, markdown -msgid "subscription.dashboard.cta.upgrade-to-unlimited-enterprise-member" -msgstr "" -"Get more editors, more storage, and more autosaved versions with the " -"Unlimited or Enterprise plan. Contact with the team owner: %s" - -#: src/app/main/ui/dashboard/subscription.cljs:165 -#, markdown -msgid "subscription.dashboard.cta.upgrade-to-unlimited-enterprise-owner" -msgstr "" -"Get more editors, more storage, and more autosaved versions with the " -"Unlimited or Enterprise plan. [Subscribe now.|target:self](%s)" - -#: src/app/main/ui/dashboard/subscription.cljs:176 -#, markdown -msgid "subscription.dashboard.cta.upgrade-more-seats-owner" -msgstr "Please upgrade to match your usage. [Subscribe now.|target:self](%s)" - -#, markdown -msgid "subscription.dashboard.cta.upgrade-more-seats-member" -msgstr "Please upgrade to match your usage. Contact with the team owner: %s" - #: src/app/main/ui/dashboard/subscription.cljs:80 msgid "subscription.dashboard.power-up.enterprise-plan" msgstr "Enterprise plan" #: src/app/main/ui/dashboard/subscription.cljs:60 #, markdown -msgid "subscription.dashboard.power-up.professional.bottom" -msgstr "" -"Get extra editors and storage, file recovery and more with the Unlimited " -"plan. [Power up|target:self](%s)" +msgid "subscription.dashboard.power-up.professional.bottom-text" +msgstr "Get extra storage, file recovery and more for your teams with the Unlimited plan. [Power up!|target:self](%s)" #: src/app/main/ui/dashboard/subscription.cljs:59 msgid "subscription.dashboard.power-up.professional.top-title" @@ -4334,11 +4298,6 @@ msgstr "Subscribe" msgid "subscription.dashboard.power-up.trial.bottom-description" msgstr "Enjoying your trial? Unlock full access forever.[Subscribe|target:self](%s)" -#: src/app/main/ui/dashboard/subscription.cljs:62 -#, unused -msgid "subscription.dashboard.power-up.trial.top-description" -msgstr "Extra editors, storage and autosaved version, file backup and more." - #: src/app/main/ui/dashboard/subscription.cljs:67 msgid "subscription.dashboard.power-up.trial.top-title" msgstr "Unlimited plan (trial)" @@ -4353,9 +4312,7 @@ msgstr "Enterprise plan (trial)" #: src/app/main/ui/dashboard/subscription.cljs:74 #, markdown msgid "subscription.dashboard.power-up.unlimited.bottom-text" -msgstr "" -"Get extra editors, more backup, unlimited storage and more. " -"[Take a look to the Enterprise plan.|target:self](%s)" +msgstr "Get unlimited storage, extended file recovery and unlimited editors for all your teams at a fixed price. [Take a look at the Enterprise plan.|target:self](%s)" #: src/app/main/ui/dashboard/subscription.cljs:70 #, unused @@ -4379,6 +4336,26 @@ msgstr "Team plan" msgid "subscription.dashboard.upgrade-plan.power-up" msgstr "Power up" +msgid "subscription.dashboard.professional-dashboard-cta-title" +msgstr "You have %s editors across your owned teams, while your professional plan covers up to 8." + +#, markdown +msgid "subscription.dashboard.professional-dashboard-cta-upgrade-owner" +msgstr "Please upgrade now to Unlimited or Enterprise to unlock more editors, storage and file recovery. [Subscribe now.|target:self](%s)" + +msgid "subscription.dashboard.unlimited-dashboard-cta-title" +msgstr "Your team keeps growing! Your Unlimited plan covers up to %s editors, but you now have %s." + +#, markdown +msgid "subscription.dashboard.unlimited-dashboard-cta-upgrade-owner" +msgstr "Please upgrade now to match your current editor count. [Subscribe now.|target:self](%s)" + +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-title" +msgstr "Inviting people while on the Unlimited plan" + +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-text" +msgstr "Only new editors across your owned teams count towards future billing. A flat $175/month still applies for 25+ editors." + #: src/app/main/ui/settings/sidebar.cljs:116, src/app/main/ui/settings/subscription.cljs:222, src/app/main/ui/settings/subscription.cljs:231 msgid "subscription.labels" msgstr "Subscription" @@ -4404,23 +4381,31 @@ msgid "subscription.settings.enterprise-trial" msgstr "Enterprise (trial)" #: src/app/main/ui/settings/subscription.cljs:271, src/app/main/ui/settings/subscription.cljs:320 -msgid "subscription.settings.enterprise.unlimited-storage" -msgstr "Unlimited storage and 90-day autosave versions and file recovery" +msgid "subscription.settings.enterprise.unlimited-storage-benefit" +msgstr "Unlimited storage" msgid "subscription.settings.enterprise.autosave" msgstr "90-day autosave versions and file recovery" #: src/app/main/ui/settings/subscription.cljs:269, src/app/main/ui/settings/subscription.cljs:318 msgid "subscription.settings.enterprise.capped-bill" -msgstr "Capped monthly bill" +msgstr "Flat monthly bill" #: src/app/main/ui/dashboard/subscription.cljs:114, src/app/main/ui/settings/subscription.cljs:251, src/app/main/ui/settings/subscription.cljs:262, src/app/main/ui/settings/subscription.cljs:272 msgid "subscription.settings.manage-your-subscription" msgstr "Manage your subscription" #: src/app/main/ui/settings/subscription.cljs:102 -msgid "subscription.settings.management.dialog.choose-this-plan" -msgstr "You are choosing this plan for:" +msgid "subscription.settings.management.dialog.currently-editors-title" +msgid_plural "subscription.settings.management.dialog.currently-editors-title" +msgstr[0] "Currently, your have %s person across your teams who can edit." +msgstr[1] "Currently, your have %s people across your teams who can edit." + +msgid "subscription.settings.management.dialog.editors" +msgstr "Editors" + +msgid "subscription.settings.management.dialog.editors-explanation" +msgstr "(Owners, Admin and Editors. Viewers doesn't count as Editors)" #: src/app/main/ui/settings/subscription.cljs:132 msgid "subscription.settings.management.dialog.downgrade" @@ -4428,27 +4413,21 @@ msgstr "" "Heads up: switching to a lower plan means less storage and shorter backups " "and version history." -#: src/app/main/ui/settings/subscription.cljs:106 -msgid "subscription.settings.management.dialog.members" -msgstr " (%s editors)" - -#: src/app/main/ui/settings/subscription.cljs:108 -msgid "subscription.settings.management.dialog.no-teams" -msgstr "This plan will apply to all future teams you create or own." - #: src/app/main/ui/settings/subscription.cljs:126 msgid "subscription.settings.management.dialog.payment-explanation" -msgstr "(No payment will be made now)" +msgstr "Charged after trial. No credit card required right now." + +msgid "subscription.settings.management.dialog.input-error" +msgstr "You can't set fewer editors than you have now. Change the role (editor/admin to viewer) for people who don't actually edit files in the team settings." + +msgid "subscription.settings.management.dialog.unlimited-capped-warning" +msgstr "Tip: You can increase your seat count now to stay ahead of invites. At 25+ editors across teams, you’ll enjoy a flat $175/month." #: src/app/main/ui/settings/subscription.cljs:124 #, markdown msgid "subscription.settings.management.dialog.price-month" msgstr "" -"**$%s** per month" - -#: src/app/main/ui/settings/subscription.cljs:112 -msgid "subscription.settings.management.dialog.select-editors" -msgstr "Select number of editors (seats) you need:" +"**$%s**/month" #: src/app/main/ui/settings/subscription.cljs:97 msgid "subscription.settings.management.dialog.title" @@ -4479,16 +4458,16 @@ msgid "subscription.settings.professional" msgstr "Professional" #: src/app/main/ui/settings/subscription.cljs:239, src/app/main/ui/settings/subscription.cljs:290 -msgid "subscription.settings.professional.projects-files" -msgstr "Unlimited projects, files and drafts" +msgid "subscription.settings.professional.autosave-benefit" +msgstr "7-day autosave versions and file recovery" #: src/app/main/ui/settings/subscription.cljs:241, src/app/main/ui/settings/subscription.cljs:292 -msgid "subscription.settings.professional.storage-autosave" -msgstr "10GB of storage and 7-day autosave versions and file recovery" +msgid "subscription.settings.professional.storage-benefit" +msgstr "10GB of storage" #: src/app/main/ui/settings/subscription.cljs:240, src/app/main/ui/settings/subscription.cljs:291 -msgid "subscription.settings.professional.teams-editors" -msgstr "Unlimited teams of up to 8 editors" +msgid "subscription.settings.professional.teams-editors-benefit" +msgstr "Unlimited teams. Up to 8 editors across your owned teams." #: src/app/main/ui/settings/subscription.cljs:235 msgid "subscription.settings.section-plan" @@ -4505,6 +4484,9 @@ msgstr "Start free trial" msgid "subscription.settings.subscribe" msgstr "Subscribe" +msgid "subscription.settings.success.dialog.thanks" +msgstr "Thank your for chosing the Penpot %s plan!" + #: src/app/main/ui/settings/subscription.cljs:167 msgid "subscription.settings.success.dialog.description" msgstr "" @@ -4538,15 +4520,15 @@ msgstr "Unlimited (trial)" #: src/app/main/ui/settings/subscription.cljs:249, src/app/main/ui/settings/subscription.cljs:260, src/app/main/ui/settings/subscription.cljs:305 msgid "subscription.settings.unlimited.bill" -msgstr "Capped monthly bill" +msgstr "Capped monthly bill at $175" #: src/app/main/ui/settings/subscription.cljs:250, src/app/main/ui/settings/subscription.cljs:261, src/app/main/ui/settings/subscription.cljs:306 -msgid "subscription.settings.unlimited.storage-autosave" -msgstr "25GB of storage and 30-day autosave versions and file recovery" +msgid "subscription.settings.unlimited.storage-benefit" +msgstr "25GB of storage" #: src/app/main/ui/settings/subscription.cljs:248, src/app/main/ui/settings/subscription.cljs:259, src/app/main/ui/settings/subscription.cljs:304 -msgid "subscription.settings.unlimited.teams" -msgstr "Unlimited teams, no matter your team size" +msgid "subscription.settings.unlimited.autosave-benefit" +msgstr "30-day autosave versions and file recovery" #: src/app/main/ui/dashboard/subscription.cljs:133, src/app/main/ui/workspace/main_menu.cljs:928 msgid "subscription.workspace.header.menu.option.power-up" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 6b38dbc10e..68b0f8de12 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -4300,52 +4300,14 @@ msgstr "Incrementar zoom a objetivo" msgid "shortcuts.zoom-selected" msgstr "Zoom a selección" -#: src/app/main/ui/dashboard/subscription.cljs:154 -msgid "subscription.dashboard.cta.professional-plan-designed" -msgstr "" -"El plan Professional está diseñado para equipos de hasta 8 editores " -"(propietario, administrador y editor)." - -#: src/app/main/ui/dashboard/subscription.cljs:160 -msgid "subscription.dashboard.cta.unlimited-many-editors" -msgstr "" -"¡Parece que tu equipo ha crecido! Tu plan incluye %s asientos, pero ahora estás usando %s" - -#: src/app/main/ui/dashboard/subscription.cljs:168 -#, markdown -msgid "subscription.dashboard.cta.upgrade-to-unlimited-enterprise-member" -msgstr "" -"Consigue más editores, más almacenamiento y más versiones guardadas " -"automáticamente con el plan Unlimited o Enterprise. Contacta con el " -"propietario del equipo: %s" - -#: src/app/main/ui/dashboard/subscription.cljs:165 -#, markdown -msgid "subscription.dashboard.cta.upgrade-to-unlimited-enterprise-owner" -msgstr "" -"Consigue más editores, más almacenamiento y más versiones guardadas " -"automáticamente con el plan Unlimited o Enterprise. [Suscríbete ahora.|target:self](%s)" - -#: src/app/main/ui/dashboard/subscription.cljs:176 -#, markdown -msgid "subscription.dashboard.cta.upgrade-more-seats-owner" -msgstr "Por favor, mejóralo para adaptarlo a tu uso. [Suscríbete ahora.|target:self](%s)" - -#, markdown -msgid "subscription.dashboard.cta.upgrade-more-seats-member" -msgstr "Por favor, mejóralo para adaptarlo a tu uso. Contacta con el " -"propietario del equipo: %s" - #: src/app/main/ui/dashboard/subscription.cljs:80 msgid "subscription.dashboard.power-up.enterprise-plan" msgstr "Plan Enterprise" #: src/app/main/ui/dashboard/subscription.cljs:60 #, markdown -msgid "subscription.dashboard.power-up.professional.bottom" -msgstr "" -"Consigue editores y almacenamiento adicionales, recuperación de " -"archivos y mucho más con el Plan Unlimited[Mejóralo|target:self](%s)" +msgid "subscription.dashboard.power-up.professional.bottom-text" +msgstr "Consigue almacenamiento adicional, recuperación de archivos y mucho más para tus equipos con el Plan Unlimited. [Mejóralo!|target:self](%s)" #: src/app/main/ui/dashboard/subscription.cljs:59 msgid "subscription.dashboard.power-up.professional.top-title" @@ -4363,13 +4325,6 @@ msgstr "" "¿Disfrutas de la prueba? Desbloquea el acceso completo para " "siempre.[Suscríbete|target:self](%s)" -#: src/app/main/ui/dashboard/subscription.cljs:62 -#, unused -msgid "subscription.dashboard.power-up.trial.top-description" -msgstr "" -"Editores adicionales, almacenamiento y versión autoguardada, copia de " -"seguridad de archivos y mucho más." - #: src/app/main/ui/dashboard/subscription.cljs:67 msgid "subscription.dashboard.power-up.trial.top-title" msgstr "Plan Unlimited (Prueba)" @@ -4384,9 +4339,7 @@ msgstr "Plan Unlimited" #: src/app/main/ui/dashboard/subscription.cljs:74 #, markdown msgid "subscription.dashboard.power-up.unlimited.bottom-text" -msgstr "" -"Consigue editores adicionales, copias de seguridad, almacenamiento ilimitado y mucho más. " -"[Echa un ojo al Plan Enterprise|target:self](%s)" +msgstr "Consigue almacenamiento ilimitado, recuperación extendida de archivos y editores ilimitados para todos tus equipos a un precio fijo. [Echa un ojo al Plan Enterprise|target:self](%s)" #: src/app/main/ui/dashboard/subscription.cljs:70 #, unused @@ -4412,6 +4365,26 @@ msgstr "Plan de equipo" msgid "subscription.dashboard.upgrade-plan.power-up" msgstr "Mejora" +msgid "subscription.dashboard.professional-dashboard-cta-title" +msgstr "Tienes %s editores en todos tus equipos pero que tu plan profesional cubre hasta 8." + +#, markdown +msgid "subscription.dashboard.professional-dashboard-cta-upgrade-owner" +msgstr "Mejora ahora tu plan a Unlimited o Enterprise para desbloquear más editores, almacenamiento y recuperación de archivos. [Suscríbete ahora.|target:self](%s)" + +msgid "subscription.dashboard.unlimited-dashboard-cta-title" +msgstr "¡Tu equipo sigue creciendo! Tu plan Unlimited cubre hasta %s editores pero ya tienes %s." + +#, markdown +msgid "subscription.dashboard.unlimited-dashboard-cta-upgrade-owner" +msgstr "Por favor, actualiza ahora para ajustar el número actual de editores. [Suscríbete ahora.|target:self](%s)" + +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-title" +msgstr "Invita a personas mientras estás en el plan Unlimited" + +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-text" +msgstr "Solo los nuevos editores de tus equipos se tendrán en cuenta para la facturación futura. Se seguirá aplicando una tarifa plana de 175$/mes para 25+ editores." + #: src/app/main/ui/settings/sidebar.cljs:116, src/app/main/ui/settings/subscription.cljs:222, src/app/main/ui/settings/subscription.cljs:231 msgid "subscription.labels" msgstr "Suscripción" @@ -4437,23 +4410,31 @@ msgid "subscription.settings.enterprise-trial" msgstr "Enterprise (prueba)" #: src/app/main/ui/settings/subscription.cljs:271, src/app/main/ui/settings/subscription.cljs:320 -msgid "subscription.settings.enterprise.unlimited-storage" -msgstr "Almacenamiento ilimitado y versiones de autoguardado de 90 días y recuperación de archivos" +msgid "subscription.settings.enterprise.unlimited-storage-benefit" +msgstr "Almacenamiento ilimitado" msgid "subscription.settings.enterprise.autosave" msgstr "Versiones guardadas automáticamente cada 90 días y recuperación de archivos" #: src/app/main/ui/settings/subscription.cljs:269, src/app/main/ui/settings/subscription.cljs:318 msgid "subscription.settings.enterprise.capped-bill" -msgstr "Factura mensual limitada" +msgstr "Factura mensual fija" #: src/app/main/ui/dashboard/subscription.cljs:114, src/app/main/ui/settings/subscription.cljs:251, src/app/main/ui/settings/subscription.cljs:262, src/app/main/ui/settings/subscription.cljs:272 msgid "subscription.settings.manage-your-subscription" msgstr "Gestionar tu suscripción" #: src/app/main/ui/settings/subscription.cljs:102 -msgid "subscription.settings.management.dialog.choose-this-plan" -msgstr "Estás eligiendo este plan para:" +msgid "subscription.settings.management.dialog.currently-editors-title" +msgid_plural "subscription.settings.management.dialog.currently-editors-title" +msgstr[0] "Actualmente hay %s persona en tus equipos que pueden editar." +msgstr[1] "Actualmente hay %s personas en tus equipos que pueden editar." + +msgid "subscription.settings.management.dialog.editors" +msgstr "Editores" + +msgid "subscription.settings.management.dialog.editors-explanation" +msgstr "(Propietarios, administradores y editores. Los lectores no cuentan como editores)." #: src/app/main/ui/settings/subscription.cljs:132 msgid "subscription.settings.management.dialog.downgrade" @@ -4461,27 +4442,21 @@ msgstr "" "Ten en cuenta: cambiar a un plan inferior significa menos almacenamiento y " "copias de seguridad e historial de versiones más cortos." -#: src/app/main/ui/settings/subscription.cljs:106 -msgid "subscription.settings.management.dialog.members" -msgstr " (%s editores)" - -#: src/app/main/ui/settings/subscription.cljs:108 -msgid "subscription.settings.management.dialog.no-teams" -msgstr "Este plan se aplicará a todos los futuros equipos que crees o poseas." - #: src/app/main/ui/settings/subscription.cljs:126 msgid "subscription.settings.management.dialog.payment-explanation" -msgstr "(Ahora no se efectuará ningún pago)" +msgstr "Se cobrar después del período de prueba. No se requiere tarjeta de crédito en este momento." + +msgid "subscription.settings.management.dialog.input-error" +msgstr "No puedes establecer menos editores de los que tienes ahora. Cambia el rol (de editor/administrador a lector) para las personas que realmente no editan archivos en la configuración del equipo." + +msgid "subscription.settings.management.dialog.unlimited-capped-warning" +msgstr "Consejo: Puedes aumentar ahora el número de asientos para adelantarte a las invitaciones. Con más de 25 editores en todos tus equipos, disfrutarás de una tarifa plana de 175 $ al mes." #: src/app/main/ui/settings/subscription.cljs:124 #, markdown msgid "subscription.settings.management.dialog.price-month" msgstr "" -"**$%s** por mes" - -#: src/app/main/ui/settings/subscription.cljs:112 -msgid "subscription.settings.management.dialog.select-editors" -msgstr "Seleccione el número de editores (puestos) que necesitas:" +"**$%s**/mes" #: src/app/main/ui/settings/subscription.cljs:97 msgid "subscription.settings.management.dialog.title" @@ -4508,16 +4483,16 @@ msgid "subscription.settings.professional" msgstr "Professional" #: src/app/main/ui/settings/subscription.cljs:239, src/app/main/ui/settings/subscription.cljs:290 -msgid "subscription.settings.professional.projects-files" -msgstr "Proyectos, archivos y borradores ilimitados" +msgid "subscription.settings.professional.autosave-benefit" +msgstr "Versiones con autoguardado de 7 días y recuperación de archivos" #: src/app/main/ui/settings/subscription.cljs:241, src/app/main/ui/settings/subscription.cljs:292 -msgid "subscription.settings.professional.storage-autosave" -msgstr "10 GB de almacenamiento y versiones de autoguardado de 7 días y recuperación de archivos" +msgid "subscription.settings.professional.storage-benefit" +msgstr "10 GB de almacenamiento" #: src/app/main/ui/settings/subscription.cljs:240, src/app/main/ui/settings/subscription.cljs:291 -msgid "subscription.settings.professional.teams-editors" -msgstr "Equipos ilimitados de hasta 8 redactores" +msgid "subscription.settings.professional.teams-editors-benefit" +msgstr "Equipos ilimitados. Hasta 8 editores en todos tus equipos." #: src/app/main/ui/settings/subscription.cljs:235 msgid "subscription.settings.section-plan" @@ -4534,6 +4509,9 @@ msgstr "Comenzar prueba gratuita" msgid "subscription.settings.subscribe" msgstr "Suscríbete" +msgid "subscription.settings.success.dialog.thanks" +msgstr "¡Gracias por elegir el plan %s de Penpot!" + #: src/app/main/ui/settings/subscription.cljs:167 msgid "subscription.settings.success.dialog.description" msgstr "" @@ -4566,17 +4544,16 @@ msgstr "Unlimited (prueba)" #: src/app/main/ui/settings/subscription.cljs:249, src/app/main/ui/settings/subscription.cljs:260, src/app/main/ui/settings/subscription.cljs:305 msgid "subscription.settings.unlimited.bill" -msgstr "Factura mensual limitada" +msgstr "Factura mensual limitada en $175" #: src/app/main/ui/settings/subscription.cljs:250, src/app/main/ui/settings/subscription.cljs:261, src/app/main/ui/settings/subscription.cljs:306 -msgid "subscription.settings.unlimited.storage-autosave" +msgid "subscription.settings.unlimited.storage-benefit" msgstr "" -"25 GB de almacenamiento y 30 días de autoguardado de versiones y recuperación " -"de archivos" +"25 GB de almacenamiento" #: src/app/main/ui/settings/subscription.cljs:248, src/app/main/ui/settings/subscription.cljs:259, src/app/main/ui/settings/subscription.cljs:304 -msgid "subscription.settings.unlimited.teams" -msgstr "Equipos ilimitados, independientemente de su tamaño" +msgid "subscription.settings.unlimited.autosave-benefit" +msgstr "Versiones con autoguardado de 30 días y recuperación de archivos" #: src/app/main/ui/dashboard/subscription.cljs:133, src/app/main/ui/workspace/main_menu.cljs:928 msgid "subscription.workspace.header.menu.option.power-up" From f22383176685ec840df67bc939a88c249296c797 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Fri, 22 Aug 2025 08:42:53 +0200 Subject: [PATCH 05/15] :sparkles: Refresh subscription info when member role is updated --- frontend/src/app/main/data/team.cljs | 18 ++++++----- frontend/src/app/main/ui/dashboard/team.cljs | 34 ++++++++++---------- 2 files changed, 27 insertions(+), 25 deletions(-) diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index e98dddca3a..3b63bdd5af 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -16,6 +16,7 @@ [app.config :as cf] [app.main.data.event :as ev] [app.main.data.media :as di] + [app.main.data.profile :as dp] [app.main.features :as features] [app.main.repo :as rp] [app.main.router :as rt] @@ -142,8 +143,9 @@ (defn update-member-role [{:keys [role member-id] :as params}] - (dm/assert! (uuid? member-id)) - (dm/assert! (contains? ctt/valid-roles role)) + + (assert (uuid? member-id)) + (assert (contains? ctt/valid-roles role)) (ptk/reify ::update-member-role ptk/WatchEvent @@ -152,13 +154,13 @@ params (assoc params :team-id team-id)] (->> (rp/cmd! :update-team-member-role params) (rx/mapcat (fn [_] - (rx/of (fetch-members team-id) + (rx/of (dp/refresh-profile) + (fetch-members team-id) (fetch-teams) - (ptk/data-event ::ev/event - {::ev/name "update-team-member-role" - :team-id team-id - :role role - :member-id member-id}))))))))) + (ev/event {::ev/name "update-team-member-role" + :team-id team-id + :role role + :member-id member-id}))))))))) (defn delete-member [{:keys [member-id] :as params}] diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index b279bb8c66..fbfafefd4d 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -266,8 +266,7 @@ [:span {:class (stl/css :you)} (tr "labels.you")])] [:div {:class (stl/css :member-email)} (:email member)]]])) -(mf/defc rol-info - {::mf/props :obj} +(mf/defc rol-info* [{:keys [member team on-set-admin on-set-editor on-set-owner on-set-viewer profile]}] (let [member-is-owner (:is-owner member) member-is-admin (and (:is-admin member) (not member-is-owner)) @@ -282,13 +281,15 @@ is-you (= (:id profile) (:id member)) can-change-rol (or is-owner is-admin) - not-superior (or (and (not member-is-owner) is-admin) (and can-change-rol (or member-is-admin member-is-editor member-is-viewer))) + not-superior (or (and (not member-is-owner) is-admin) + (and can-change-rol (or member-is-admin member-is-editor member-is-viewer))) role (cond member-is-owner "labels.owner" member-is-admin "labels.admin" member-is-editor "labels.editor" :else "labels.viewer") + on-show (mf/use-fn #(reset! show? true)) on-hide (mf/use-fn #(reset! show? false))] [:* @@ -320,8 +321,7 @@ :class (stl/css :rol-dropdown-item)} (tr "labels.owner")])]]])) -(mf/defc member-actions - {::mf/props :obj} +(mf/defc member-actions* [{:keys [member team on-delete on-leave profile]}] (let [is-owner? (:is-owner member) owner? (dm/get-in team [:permissions :is-owner]) @@ -470,20 +470,20 @@ [:& member-info {:member member :profile profile}]] [:div {:class (stl/css :table-field :field-roles)} - [:& rol-info {:member member - :team team - :on-set-admin on-set-admin - :on-set-editor on-set-editor - :on-set-viewer on-set-viewer - :on-set-owner on-set-owner - :profile profile}]] + [:> rol-info* {:member member + :team team + :on-set-admin on-set-admin + :on-set-editor on-set-editor + :on-set-viewer on-set-viewer + :on-set-owner on-set-owner + :profile profile}]] [:div {:class (stl/css :table-field :field-actions)} - [:& member-actions {:member member - :profile profile - :team team - :on-delete on-delete - :on-leave on-leave'}]]])) + [:> member-actions* {:member member + :profile profile + :team team + :on-delete on-delete + :on-leave on-leave'}]]])) (mf/defc team-members* {::mf/props :obj From 090cb63568d4983f5a4f5c9e93a3818dff2f4258 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Mon, 25 Aug 2025 11:07:19 +0200 Subject: [PATCH 06/15] :bug: Fix condition for members warning --- frontend/src/app/main/ui/dashboard/subscription.cljs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/dashboard/subscription.cljs b/frontend/src/app/main/ui/dashboard/subscription.cljs index 5902128c85..68d2359cf9 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.cljs +++ b/frontend/src/app/main/ui/dashboard/subscription.cljs @@ -215,6 +215,6 @@ (and is-owner (= subscription-type "unlimited") - ;; common: seats < 25 and diff >= 4 between editors/seats and there is underuse + ;; common: seats < 25 and diff >= 4 between editors/seats and there is overuse (and (< seats 25) - (>= (- seats editors) 4))))) + (>= (- editors seats) 4))))) From c759afc10d21628775f73700f75634dc771f457c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 25 Aug 2025 11:28:57 +0200 Subject: [PATCH 07/15] :fire: Remove unnecessary and broken unique-editors field From the get-teams rpc method response --- backend/src/app/rpc/commands/teams.clj | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 95fa381146..a099223b83 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -142,18 +142,7 @@ ELSE COALESCE(p.props->'~:subscription'->>'~:status', 'incomplete') END, '~:seats', p.props->'~:subscription'->'~:quantity' - ) AS subscription, - - (SELECT count(distinct p.id) - FROM team_profile_rel AS tpr1 - JOIN team_profile_rel AS tpr2 - ON (tpr1.team_id = tpr2.team_id) - JOIN profile AS p - ON (tpr2.profile_id = p.id) - WHERE tpr1.profile_id = p.id - AND tpr1.is_owner IS true - AND tpr2.can_edit IS true) AS unique_editors - + ) AS subscription FROM team_profile_rel AS tp JOIN team AS t ON (t.id = tp.team_id) JOIN team_profile_rel AS tpr From 926b2c9cfb04a74d259ea12a15cb467b0fb92c9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Mon, 25 Aug 2025 15:42:47 +0200 Subject: [PATCH 08/15] :bug: Fix doble click to submit subscription --- .../src/app/main/ui/settings/subscription.cljs | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index f305ae17cd..0b97d3b5f5 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -75,15 +75,19 @@ {:min-members min-editors}) form (fm/use-form :schema (schema:seats-form min-editors) :initial initial) + submit-in-progress* (mf/use-state false) subscribe-to-unlimited (mf/use-fn (fn [form] - (let [data (:clean-data @form) - return-url (-> (rt/get-current-href) (rt/encode-url)) - href (dm/str "payments/subscriptions/create?type=unlimited&quantity=" (:min-members data) "&returnUrl=" return-url)] - (st/emit! (ptk/event ::ev/event {::ev/name "create-trial-subscription" - :type "unlimited" - :quantity (:min-members data)}) - (rt/nav-raw :href href))))) + (when (not @submit-in-progress*) + (reset! submit-in-progress* true) + (let [data (:clean-data @form) + return-url (-> (rt/get-current-href) (rt/encode-url)) + href (dm/str "payments/subscriptions/create?type=unlimited&quantity=" (:min-members data) "&returnUrl=" return-url)] + (reset! form nil) + (st/emit! (ptk/event ::ev/event {::ev/name "create-trial-subscription" + :type "unlimited" + :quantity (:min-members data)}) + (rt/nav-raw :href href)))))) subscribe-to-enterprise (mf/use-fn (fn [] From 723ea508df810c69f8fb774d8f4cef398cff40fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Wed, 27 Aug 2025 10:56:17 +0200 Subject: [PATCH 09/15] :bug: Fix missing link for enterprise trial --- frontend/src/app/main/ui/settings/subscription.cljs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index 0b97d3b5f5..b2e31cd632 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -395,7 +395,9 @@ (tr "subscription.settings.enterprise.autosave"), (tr "subscription.settings.enterprise.capped-bill")] :cta-text (tr "subscription.settings.manage-your-subscription") - :cta-link go-to-payments}] + :cta-link go-to-payments + :cta-text-trial (tr "subscription.settings.add-payment-to-continue") + :cta-link-trial go-to-payments}] [:> plan-card* {:card-title (tr "subscription.settings.enterprise") :card-title-icon i/character-e :benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits"), From 37b0c4adc0f8d5c814721e015ad2dda937895097 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 27 Aug 2025 11:58:42 +0200 Subject: [PATCH 10/15] :bug: Add support fror ::db/for-update on sql ns --- backend/src/app/db/sql.clj | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/backend/src/app/db/sql.clj b/backend/src/app/db/sql.clj index 341f0bd456..b3da7ef8af 100644 --- a/backend/src/app/db/sql.clj +++ b/backend/src/app/db/sql.clj @@ -53,8 +53,15 @@ opts (cond-> opts (::order-by opts) (assoc :order-by (::order-by opts)) (::columns opts) (assoc :columns (::columns opts)) - (::for-update opts) (assoc :suffix "FOR UPDATE") - (::for-share opts) (assoc :suffix "FOR SHARE"))] + + (or (::db/for-update opts) + (::for-update opts)) + (assoc :suffix "FOR UPDATE") + + (or (::db/for-share opts) + (::for-share opts)) + (assoc :suffix "FOR SHARE"))] + (sql/for-query table where-params opts)))) (defn update From 6c8873c7f587e79342e8ff5ec03ba33088010896 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 27 Aug 2025 11:59:35 +0200 Subject: [PATCH 11/15] :bug: Ensure for-update locking is used on updating profile props --- backend/src/app/rpc/commands/profile.clj | 40 ++++++++++++------------ 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index d57d4b6523..accdcaddfb 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -131,9 +131,7 @@ ;; NOTE: we need to retrieve the profile independently if we use ;; it or not for explicit locking and avoid concurrent updates of ;; the same row/object. - (let [profile (-> (db/get-by-id conn :profile profile-id ::sql/for-update true) - (decode-row)) - + (let [profile (get-profile conn profile-id ::db/for-update true) ;; Update the profile map with direct params profile (-> profile (assoc :fullname fullname) @@ -143,9 +141,9 @@ (db/update! conn :profile {:fullname fullname :lang lang - :theme theme - :props (db/tjson (:props profile))} - {:id profile-id}) + :theme theme} + {:id profile-id} + {::db/return-keys false}) (-> profile (strip-private-attrs) @@ -228,21 +226,22 @@ (defn- update-notifications! [{:keys [::db/conn] :as cfg} {:keys [profile-id dashboard-comments email-comments email-invites]}] - (let [profile (get-profile conn profile-id) + (let [profile + (get-profile conn profile-id ::db/for-update true) notifications {:dashboard-comments dashboard-comments :email-comments email-comments - :email-invites email-invites}] + :email-invites email-invites} - (db/update! - conn :profile - {:props - (-> (:props profile) - (assoc :notifications notifications) - (db/tjson))} - {:id (:id profile)}) + props + (-> (get profile :props) + (assoc :notifications notifications))] + (db/update! conn :profile + {:props (db/tjson props)} + {:id profile-id} + {::db/return-keys false}) nil)) ;; --- MUTATION: Update Photo @@ -411,7 +410,7 @@ (defn update-profile-props [{:keys [::db/conn] :as cfg} profile-id props] - (let [profile (get-profile conn profile-id ::sql/for-update true) + (let [profile (get-profile conn profile-id ::db/for-update true) props (reduce-kv (fn [props k v] ;; We don't accept namespaced keys (if (simple-ident? k) @@ -424,16 +423,17 @@ (db/update! conn :profile {:props (db/tjson props)} - {:id profile-id}) + {:id profile-id} + {::db/return-keys false}) (filter-props props))) (sv/defmethod ::update-profile-props {::doc/added "1.0" - ::sm/params schema:update-profile-props} + ::sm/params schema:update-profile-props + ::db/transaction true} [cfg {:keys [::rpc/profile-id props]}] - (db/tx-run! cfg (fn [cfg] - (update-profile-props cfg profile-id props)))) + (update-profile-props cfg profile-id props)) ;; --- MUTATION: Delete Profile From 5fed5fa1583f2cf39018d955a225d5fa820d9c76 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 27 Aug 2025 12:00:03 +0200 Subject: [PATCH 12/15] :sparkles: Add transactions support on management api --- backend/src/app/http/management.clj | 33 +++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/backend/src/app/http/management.clj b/backend/src/app/http/management.clj index aa83d7b58e..12f9659c25 100644 --- a/backend/src/app/http/management.clj +++ b/backend/src/app/http/management.clj @@ -29,19 +29,40 @@ [_ params] (assert (db/pool? (::db/pool params)) "expect valid database pool")) +(def ^:private default-system + {:name ::default-system + :compile + (fn [_ _] + (fn [handler cfg] + (fn [request] + (handler cfg request))))}) + +(def ^:private transaction + {:name ::transaction + :compile + (fn [data _] + (when (:transaction data) + (fn [handler] + (fn [cfg request] + (db/tx-run! cfg handler request)))))}) + (defmethod ig/init-key ::routes [_ cfg] - [["/authenticate" - {:handler (partial authenticate cfg) + ["" {:middleware [[default-system cfg] + [transaction]]} + ["/authenticate" + {:handler authenticate :allowed-methods #{:post}}] ["/get-customer" - {:handler (partial get-customer cfg) + {:handler get-customer + :transaction true :allowed-methods #{:post}}] ["/update-customer" - {:handler (partial update-customer cfg) - :allowed-methods #{:post}}]]) + {:handler update-customer + :allowed-methods #{:post} + :transaction true}]]) ;; ---- HELPERS @@ -192,7 +213,7 @@ (-> request :params coerce-update-customer-params) {:keys [props] :as profile} - (cmd.profile/get-profile cfg id) + (cmd.profile/get-profile cfg id ::db/for-update true) props (assoc props :subscription subscription)] From a114e9adb0dee2ee209a12d378ea196577f6a9dc Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 27 Aug 2025 12:02:39 +0200 Subject: [PATCH 13/15] :sparkles: Add logging for management update-customer method --- backend/src/app/http/management.clj | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/src/app/http/management.clj b/backend/src/app/http/management.clj index 12f9659c25..929cda32fd 100644 --- a/backend/src/app/http/management.clj +++ b/backend/src/app/http/management.clj @@ -7,6 +7,7 @@ (ns app.http.management "Internal mangement HTTP API" (:require + [app.common.logging :as l] [app.common.schema :as sm] [app.common.schema.generators :as sg] [app.common.time :as ct] @@ -218,6 +219,12 @@ props (assoc props :subscription subscription)] + (l/dbg :hint "update customer" + :profile-id (str id) + :subscription-type (get subscription :type) + :subscription-status (get subscription :status) + :subscription-quantity (get subscription :quantity)) + (db/update! cfg :profile {:props (db/tjson props)} {:id id} From cb7711f6374751a6772cdc1d71746f22a94efe12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marina=20L=C3=B3pez?= Date: Wed, 27 Aug 2025 12:30:21 +0200 Subject: [PATCH 14/15] :bug: Fix integration tests --- ...eam-members-subscription-eight-member.json | 114 ++++++++++++++++++ ...-team-members-subscription-one-member.json | 16 --- ...et-teams-unlimited-subscription-owner.json | 2 +- .../ui/specs/subscriptions-dashboard.spec.js | 22 +++- 4 files changed, 131 insertions(+), 23 deletions(-) create mode 100644 frontend/playwright/data/subscription/get-team-members-subscription-eight-member.json delete mode 100644 frontend/playwright/data/subscription/get-team-members-subscription-one-member.json diff --git a/frontend/playwright/data/subscription/get-team-members-subscription-eight-member.json b/frontend/playwright/data/subscription/get-team-members-subscription-eight-member.json new file mode 100644 index 0000000000..8dc55c2278 --- /dev/null +++ b/frontend/playwright/data/subscription/get-team-members-subscription-eight-member.json @@ -0,0 +1,114 @@ +[ + { + "~:is-admin": false, + "~:email": "luke@rebels.com", + "~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", + "~:name": "Luke Skywalker", + "~:fullname": "Luke Skywalker", + "~:is-owner": false, + "~:modified-at": "~m1713533116365", + "~:can-edit": true, + "~:is-active": true, + "~:id": "~u4f535993-36f9-8135-8006-9b18345c55cd", + "~:profile-id": "~u4f535993-36f9-8135-8006-9b18345c55cd", + "~:created-at": "~m1713533116365" + }, + { + "~:is-admin": false, + "~:email": "leia@rebels.com", + "~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", + "~:name": "Leia Organa", + "~:fullname": "Leia Organa", + "~:is-owner": false, + "~:modified-at": "~m1713533116365", + "~:can-edit": true, + "~:is-active": true, + "~:id": "~u70a3b232-3722-8008-8006-86646ed3b6af", + "~:profile-id": "~u70a3b232-3722-8008-8006-86646ed3b6af", + "~:created-at": "~m1713533116365" + }, + { + "~:is-admin": false, + "~:email": "han@falcon.com", + "~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", + "~:name": "Han Solo", + "~:fullname": "Han Solo", + "~:is-owner": false, + "~:modified-at": "~m1713533116365", + "~:can-edit": true, + "~:is-active": true, + "~:id": "~u81be1d05-a07b-81d5-8006-39095ea4121c", + "~:profile-id": "~u81be1d05-a07b-81d5-8006-39095ea4121c", + "~:created-at": "~m1713533116365" + }, + { + "~:is-admin": false, + "~:email": "vader@empire.com", + "~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", + "~:name": "Darth Vader", + "~:fullname": "Darth Vader", + "~:is-owner": false, + "~:modified-at": "~m1713533116365", + "~:can-edit": true, + "~:is-active": true, + "~:id": "~u96ce2641-e3fd-803a-8006-5e516d034d57", + "~:profile-id": "~u96ce2641-e3fd-803a-8006-5e516d034d57", + "~:created-at": "~m1713533116365" + }, + { + "~:is-admin": false, + "~:email": "obiwan@jedi.com", + "~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", + "~:name": "Obi-Wan Kenobi", + "~:fullname": "Obi-Wan Kenobi", + "~:is-owner": false, + "~:modified-at": "~m1713533116365", + "~:can-edit": true, + "~:is-active": true, + "~:id": "~uc9aa6cb0-9fb5-80a2-8006-9c3a0783ddc7", + "~:profile-id": "~uc9aa6cb0-9fb5-80a2-8006-9c3a0783ddc7", + "~:created-at": "~m1713533116365" + }, + { + "~:is-admin": false, + "~:email": "yoda@jedi.com", + "~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", + "~:name": "Yoda", + "~:fullname": "Yoda", + "~:is-owner": false, + "~:modified-at": "~m1713533116365", + "~:can-edit": true, + "~:is-active": true, + "~:id": "~uf88e52d7-2b77-81fd-8006-234039f9e8db", + "~:profile-id": "~uf88e52d7-2b77-81fd-8006-234039f9e8db", + "~:created-at": "~m1713533116365" + }, + { + "~:is-admin": false, + "~:email": "chewie@falcon.com", + "~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", + "~:name": "Chewbacca", + "~:fullname": "Chewbacca", + "~:is-owner": false, + "~:modified-at": "~m1713533116365", + "~:can-edit": true, + "~:is-active": true, + "~:id": "~uf88e52d7-2b77-81fd-8006-2341585b061d", + "~:profile-id": "~uf88e52d7-2b77-81fd-8006-2341585b061d", + "~:created-at": "~m1713533116365" + }, + { + "~:is-admin": false, + "~:email": "r2d2@astromech.com", + "~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", + "~:name": "R2-D2", + "~:fullname": "R2-D2", + "~:is-owner": false, + "~:modified-at": "~m1713533116365", + "~:can-edit": true, + "~:is-active": true, + "~:id": "~ufa35a73f-fa4f-81f9-8006-a558c4d406b1", + "~:profile-id": "~ufa35a73f-fa4f-81f9-8006-a558c4d406b1", + "~:created-at": "~m1713533116365" + } +] diff --git a/frontend/playwright/data/subscription/get-team-members-subscription-one-member.json b/frontend/playwright/data/subscription/get-team-members-subscription-one-member.json deleted file mode 100644 index ba78a4b89d..0000000000 --- a/frontend/playwright/data/subscription/get-team-members-subscription-one-member.json +++ /dev/null @@ -1,16 +0,0 @@ -[ - { - "~:is-admin": false, - "~:email": "foo@example.com", - "~:team-id": "~udd33ff88-f4e5-8033-8003-8096cc07bdf3", - "~:name": "Princesa Leia", - "~:fullname": "Princesa Leia", - "~:is-owner": false, - "~:modified-at": "~m1713533116365", - "~:can-edit": true, - "~:is-active": true, - "~:id": "~u123456789-0000-0000-0000-abcdefabcdef", - "~:profile-id": "~u123456789-0000-0000-0000-abcdefabcdef", - "~:created-at": "~m1713533116365" - } -] diff --git a/frontend/playwright/data/subscription/get-teams-unlimited-subscription-owner.json b/frontend/playwright/data/subscription/get-teams-unlimited-subscription-owner.json index 8a9eff3865..b2b708d02d 100644 --- a/frontend/playwright/data/subscription/get-teams-unlimited-subscription-owner.json +++ b/frontend/playwright/data/subscription/get-teams-unlimited-subscription-owner.json @@ -43,7 +43,7 @@ "~:subscription": { "~:type": "unlimited", "~:status": "trialing", - "~:seats": 5 + "~:seats": 2 }, "~:name": "Second team", "~:modified-at": "~m1701164272671", diff --git a/frontend/playwright/ui/specs/subscriptions-dashboard.spec.js b/frontend/playwright/ui/specs/subscriptions-dashboard.spec.js index 6e73ca711a..7945d87cb4 100644 --- a/frontend/playwright/ui/specs/subscriptions-dashboard.spec.js +++ b/frontend/playwright/ui/specs/subscriptions-dashboard.spec.js @@ -215,6 +215,12 @@ test.describe("Subscriptions: team members and invitations", () => { "subscription/get-team-members-subscription-member.json", ); + await DashboardPage.mockRPC( + page, + "get-team-stats?team-id=*", + "dashboard/get-team-stats.json", + ); + await dashboardPage.mockRPC( "push-audit-events", "workspace/audit-event-empty.json", @@ -299,7 +305,7 @@ test.describe("Subscriptions: team members and invitations", () => { await DashboardPage.mockRPC( page, "get-subscription-usage", - "subscription/get-subscription-usage-one-editor.json", + "subscription/get-subscription-usage.json", ); await DashboardPage.mockRPC( @@ -325,7 +331,7 @@ test.describe("Subscriptions: team members and invitations", () => { await DashboardPage.mockRPC( page, "get-team-members?team-id=*", - "subscription/get-team-members-subscription-one-member.json", + "subscription/get-team-members-subscription-eight-member.json", ); await dashboardPage.mockRPC( @@ -334,7 +340,9 @@ test.describe("Subscriptions: team members and invitations", () => { ); await dashboardPage.goToSecondTeamMembersSection(); - await expect(page.getByTestId("cta")).toBeVisible(); + + const ctas = page.getByTestId("cta"); + await expect(ctas).toHaveCount(2); await expect( page.getByText("Inviting people while on the unlimited plan"), ).toBeVisible(); @@ -352,7 +360,7 @@ test.describe("Subscriptions: team members and invitations", () => { await DashboardPage.mockRPC( page, "get-subscription-usage", - "subscription/get-subscription-usage-one-editor.json", + "subscription/get-subscription-usage.json", ); await DashboardPage.mockRPC( @@ -378,7 +386,7 @@ test.describe("Subscriptions: team members and invitations", () => { await DashboardPage.mockRPC( page, "get-team-members?team-id=*", - "subscription/get-team-members-subscription-one-member.json", + "subscription/get-team-members-subscription-eight-member.json", ); await DashboardPage.mockRPC( @@ -393,7 +401,9 @@ test.describe("Subscriptions: team members and invitations", () => { ); await dashboardPage.goToSecondTeamInvitationsSection(); - await expect(page.getByTestId("cta")).toBeVisible(); + + const ctas = page.getByTestId("cta"); + await expect(ctas).toHaveCount(2); await expect( page.getByText("Inviting people while on the unlimited plan"), ).toBeVisible(); From e0fb112bfb8912c103c5f9129652a58e150f8f24 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 27 Aug 2025 12:52:24 +0200 Subject: [PATCH 15/15] :paperclip: Update changelog --- CHANGES.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGES.md b/CHANGES.md index c0e908a3cb..988316e8df 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,6 +1,6 @@ # CHANGELOG -## 2.9.0 (Unreleased) +## 2.9.0 ### :rocket: Epics and highlights