penpot/frontend/src/app/main/data/nitrate.cljs
Juanfran f79cfafae5 Show nitrate checkout error on subscription page
When the Stripe checkout fails to start, the subscription page now
  shows an inline error in the Business Nitrate card under the CTA
  instead of a toast. When the post-payment activation fails, the toast
  message is updated to point users to support@penpot.app.

  The nitrate-form modal also passed a URI object to
  build-nitrate-callback-urls while the underlying append-query-param
  relied on lambdaisland's u/parse, which only accepts strings. Switched
  to the local u/uri helper so both strings and URI records work, so
  failures opened from the modal land on the subscription page.
2026-05-07 14:13:02 +02:00

254 lines
11 KiB
Clojure

(ns app.main.data.nitrate
(:require
[app.common.data.macros :as dm]
[app.common.uri :as u]
[app.config :as cf]
[app.main.data.common :as dcm]
[app.main.data.modal :as modal]
[app.main.data.notifications :as ntf]
[app.main.data.team :as dt]
[app.main.repo :as rp]
[app.main.router :as rt]
[app.main.store :as st]
[app.util.i18n :refer [tr]]
[app.util.storage :as storage]
[beicon.v2.core :as rx]
[cuerdas.core :as str]
[potok.v2.core :as ptk]))
(def ^:private nitrate-entry-active-key ::nitrate-entry-active)
(def ^:private nitrate-entry-pending-popup-key ::nitrate-entry-pending-popup)
(defn activate-nitrate-entry-popup!
[]
(binding [storage/*sync* true]
(swap! storage/storage assoc
nitrate-entry-active-key true
nitrate-entry-pending-popup-key true)))
(defn nitrate-entry-active?
[]
(true? (get storage/storage nitrate-entry-active-key)))
(defn nitrate-entry-popup-pending?
[]
(true? (get storage/storage nitrate-entry-pending-popup-key)))
(defn consume-nitrate-entry-popup!
[]
(binding [storage/*sync* true]
(swap! storage/storage dissoc
nitrate-entry-active-key
nitrate-entry-pending-popup-key)))
(defn show-nitrate-popup
([popup-type] (show-nitrate-popup popup-type {}))
([popup-type extra-props]
(ptk/reify ::show-nitrate-popup
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! ::get-nitrate-connectivity {})
(rx/map (fn [connectivity]
(modal/show popup-type (merge (or connectivity {}) extra-props)))))))))
(defn go-to-nitrate-cc
([]
(st/emit! (rt/nav-raw :href "/control-center/")))
([{:keys [organization-id organization-slug]}]
(if (and organization-id organization-slug)
(let [href (dm/str "/control-center/org/"
(u/percent-encode organization-slug)
"/"
(u/percent-encode (str organization-id))
"/people/")]
(st/emit! (rt/nav-raw :href href)))
(st/emit! (rt/nav-raw :href "/control-center/")))))
(defn go-to-nitrate-cc-create-org
[]
(st/emit! (rt/nav-raw :href "/control-center/?action=create-org")))
(def go-to-subscription-url (u/join cf/public-uri "#/settings/subscriptions"))
(defn go-to-nitrate-billing
[]
(let [href (dm/str "/control-center/licenses/billing?callback=" (js/encodeURIComponent go-to-subscription-url))]
(st/emit! (rt/nav-raw :href href))))
(def nitrate-checkout-error-token "nitrate-checkout-error")
(def nitrate-checkout-finish-error-token "nitrate-checkout-finish-error")
(def nitrate-checkout-cancelled-token "nitrate-checkout-cancelled")
(defn- append-query-param
[url key value]
(let [assoc-q (fn [u]
(update u :query
(fn [q]
(-> (u/query-string->map (or q ""))
(assoc (name key) value)
u/map->query-string))))
parsed (u/uri url)
fragment (:fragment parsed)]
(if (str/blank? fragment)
(str (assoc-q parsed))
(-> parsed
(assoc :fragment (str (assoc-q (u/parse fragment))))
str))))
(defn build-nitrate-callback-urls
"Build the success/error/cancel callback URLs from a base URL by appending
a `subscription` query param identifying the outcome."
[base-url]
(let [build (fn [token]
(append-query-param base-url :subscription token))]
{:success-callback (build "subscribed-to-penpot-nitrate")
:error-callback (build nitrate-checkout-error-token)
:finish-error-callback (build nitrate-checkout-finish-error-token)
:cancel-callback (build nitrate-checkout-cancelled-token)}))
(defn go-to-buy-nitrate-license
[subscription base-url]
(let [{:keys [success-callback error-callback finish-error-callback cancel-callback]}
(build-nitrate-callback-urls base-url)
params {:subscription subscription
:callback success-callback
:error_callback error-callback
:finish_error_callback finish-error-callback
:cancel_callback cancel-callback}
href (dm/str "/control-center/licenses/start?" (u/map->query-string params))]
(st/emit! (rt/nav-raw :href href))))
(defn fetch-connectivity
[]
(rp/cmd! ::get-nitrate-connectivity {}))
(defn is-valid-license?
[profile]
(and (contains? cf/flags :nitrate)
;; Possible values: "active" "canceled" "incomplete" "incomplete_expired" "past_due" "paused" "trialing" "unpaid"
(contains? #{"active" "past_due" "trialing"}
(dm/get-in profile [:subscription :status]))))
(defn leave-org
[{:keys [id name default-team-id teams-to-delete teams-to-leave on-error] :as params}]
(ptk/reify ::leave-org
ptk/WatchEvent
(watch [_ state _]
(let [profile-team-id (dm/get-in state [:profile :default-team-id])]
(->> (rp/cmd! ::leave-org {:id id
:name name
:default-team-id default-team-id
:teams-to-delete teams-to-delete
:teams-to-leave teams-to-leave})
(rx/mapcat
(fn [_]
(rx/of
(dt/fetch-teams)
(dcm/go-to-dashboard-recent :team-id profile-team-id)
(modal/hide)
(ntf/show {:content (tr "dasboard.leave-org.toast" name)
:type :toast
:level :success}))))
(rx/catch on-error))))))
(defn remove-team-from-org
[{:keys [team-id organization-id organization-name] :as params}]
(ptk/reify ::remove-team-from-org
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! ::remove-team-from-org {:team-id team-id :organization-id organization-id :organization-name organization-name})
(rx/mapcat
(fn [_]
(rx/of (modal/hide))))))))
(defn add-team-to-org
[{:keys [team-id organization-id] :as params}]
(ptk/reify ::add-team-to-org
ptk/WatchEvent
(watch [_ _ _]
(->> (rp/cmd! ::add-team-to-organization {:team-id team-id :organization-id organization-id})
(rx/mapcat
(fn [_]
(rx/of (modal/hide))))))))
(defn show-add-team-to-org-modal
"Fetches fresh team/org data, then shows the add-to-org modal
restricted to orgs where the user has permission, or the no-permission
modal if none qualify."
[{:keys [team-id]}]
(ptk/reify ::show-add-team-to-org-modal
ptk/WatchEvent
(watch [_ state _]
(let [profile-id (dm/get-in state [:profile :id])]
(->> (rp/cmd! :get-teams)
(rx/mapcat
(fn [teams]
(let [all-orgs (map dt/team->organization
(filter #(and (:is-default %) (:organization-id %)) teams))
orgs (filter (fn [org]
(let [perm (:create-teams org)
is-own? (= profile-id (:owner-id org))]
(or (= perm "any") is-own?))) all-orgs)
team (first (filter #(= (:id %) team-id) teams))
on-confirm (fn [organization-id]
(st/emit! (add-team-to-org {:team-id team-id
:organization-id organization-id})))]
(rx/of (dt/teams-fetched teams)
(if (empty? orgs)
(modal/show :no-org-allows-create-team {})
(let [has-filtered? (< (count orgs) (count all-orgs))
extra-props (when has-filtered?
{:info-message-key "dashboard.select-org-modal.permission-info"})]
(modal/show :select-organization-modal
(merge {:organizations orgs
:current-organization-id (:organization-id team)
:on-confirm on-confirm
:title-key "dashboard.select-org-modal.title"
:choose-key "dashboard.select-org-modal.choose"
:placeholder-key "dashboard.select-org-modal.select"
:accept-key "dashboard.select-org-modal.accept"
:cancel-key "labels.cancel"}
extra-props)))))))))))))
(defn show-change-team-org-modal
"Fetches fresh team/org data, then shows the change-org modal
restricted to orgs where the user has permission, or the no-permission
modal if none qualify."
[{:keys [team-id]}]
(ptk/reify ::show-change-team-org-modal
ptk/WatchEvent
(watch [_ state _]
(let [profile-id (dm/get-in state [:profile :id])]
(->> (rp/cmd! :get-teams)
(rx/mapcat
(fn [teams]
(let [all-orgs (map dt/team->organization
(filter #(and (:is-default %) (:organization-id %)) teams))
orgs (filter (fn [org]
(let [perm (:create-teams org)
is-own? (= profile-id (:owner-id org))]
(or (= perm "any") is-own?))) all-orgs)
team (first (filter #(= (:id %) team-id) teams))
on-confirm (fn [organization-id]
(st/emit! (add-team-to-org {:team-id team-id
:organization-id organization-id})))]
(rx/of (dt/teams-fetched teams)
(if (empty? orgs)
(modal/show :no-org-allows-create-team {})
(let [has-filtered? (< (count orgs) (count all-orgs))
extra-props (when has-filtered?
{:info-message-key "dashboard.select-org-modal.permission-info"})]
(modal/show :select-organization-modal
(merge {:organizations orgs
:current-organization-id (:organization-id team)
:on-confirm on-confirm
:title-key "dashboard.change-org-modal.title"
:choose-key "dashboard.change-org-modal.choose"
:placeholder-key "dashboard.change-org-modal.select"
:accept-key "dashboard.change-org-modal.accept"
:cancel-key "labels.cancel"}
extra-props)))))))))))))