diff --git a/backend/go-app/go.mod b/backend/go-app/go.mod index 4896209c..5bf206d9 100644 --- a/backend/go-app/go.mod +++ b/backend/go-app/go.mod @@ -24,7 +24,7 @@ require ( github.com/gorilla/mux v1.8.1 github.com/h2non/filetype v1.1.3 github.com/satori/go.uuid v1.2.0 - github.com/shuffle/shuffle-shared v0.9.25 + github.com/shuffle/shuffle-shared v0.9.28 github.com/shuffle/singul v0.0.16 golang.org/x/crypto v0.40.0 google.golang.org/api v0.236.0 diff --git a/backend/go-app/main.go b/backend/go-app/main.go index f1d53d63..6845056e 100755 --- a/backend/go-app/main.go +++ b/backend/go-app/main.go @@ -48,6 +48,7 @@ import ( newscheduler "github.com/carlescere/scheduler" "golang.org/x/crypto/bcrypt" "gopkg.in/yaml.v3" + "sort" // Web "github.com/gorilla/mux" @@ -3950,6 +3951,14 @@ func remoteOrgJobController(org shuffle.Org, body []byte) error { shuffle.SetCache(ctx, cacheKey, featuresBytes, 1800) } + subscriptionCacheKey := fmt.Sprintf("org_subscriptions_%s", org.Id) + subscriptionsBytes, err := json.Marshal(responseData.Subscriptions) + if err != nil { + log.Printf("[ERROR] Failed to marshal Subscriptions for cache: %s", err) + } else { + shuffle.SetCache(ctx, subscriptionCacheKey, subscriptionsBytes, 1800) + } + for _, job := range responseData.Jobs { err = handleCloudJob(job) if err != nil { diff --git a/frontend/src/components/Billing.jsx b/frontend/src/components/Billing.jsx index 8b14eaf5..3ed76123 100644 --- a/frontend/src/components/Billing.jsx +++ b/frontend/src/components/Billing.jsx @@ -87,6 +87,8 @@ const Billing = memo((props) => { const [currentIndex, setCurrentIndex] = useState(0); const [deleteAlertIndex, setDeleteAlertIndex] = useState(-1); const [deleteAlertVerification, setDeleteAlertVerification] = useState(false); + const [supportAppRunLimit, setSupportAppRunLimit] = useState(selectedOrganization?.billing?.internal_app_runs_hard_limit || ''); + const [supportLimitDialogOpen, setSupportLimitDialogOpen] = useState(false); const [isScale, setIsScale] = useState(false); const [currentTab, setCurrentTab] = useState(0) const [allChildOrgs, setAllChildOrgs] = useState([]) @@ -145,6 +147,9 @@ const Billing = memo((props) => { const findCurrentIndex = sortedAlertThresholds.some(threshold => threshold.Email_send === false); setCurrentIndex(findCurrentIndex ? sortedAlertThresholds.findIndex(threshold => threshold.Email_send === false) : - 1); + if (selectedOrganization?.Billing?.internal_app_runs_hard_limit !== undefined && selectedOrganization?.Billing?.internal_app_runs_hard_limit !== null && selectedOrganization?.Billing?.internal_app_runs_hard_limit > 0) { + setSupportAppRunLimit(selectedOrganization?.Billing?.internal_app_runs_hard_limit) + } }, [selectedOrganization]); @@ -1903,6 +1908,52 @@ const Billing = memo((props) => { setAlertThresholds([...alertThresholds, { percentage: '', count: '', Email_send: false }]); }; + + const handleUpdateSupportAppRunLimit = () => { + if (!supportAppRunLimit || isNaN(supportAppRunLimit) || supportAppRunLimit < 0) { + toast.error("Please enter a valid app run limit"); + return; + } + + toast("Updating app run limit. Please wait..."); + + const data = { + org_id: selectedOrganization.id, + editing: "internal_appruns_hard_limit", + billing: { + internal_app_runs_hard_limit: parseInt(supportAppRunLimit) || 0, + } + }; + + const url = globalUrl + "/api/v1/orgs/" + selectedOrganization.id; + fetch(url, { + mode: "cors", + method: "POST", + body: JSON.stringify(data), + credentials: "include", + crossDomain: true, + withCredentials: true, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }) + .then((response) => { + if (response.status === 200) { + toast.success("Successfully updated app run limit"); + setSupportLimitDialogOpen(false); + if (handleGetOrg !== undefined) { + handleGetOrg(selectedOrganization.id); + } + } else { + toast.error("Failed to update app run limit. Please try again."); + } + }) + .catch((error) => { + console.log("Error updating app run limit:", error); + toast.error("Failed to update app run limit. Please try again."); + }); + }; + const updateAlertThreshold = (index, field, value) => { const totalValue = userdata.app_execution_limit; @@ -2025,6 +2076,13 @@ const Billing = memo((props) => { } }, [isChildOrg, currentTab]); + // Update supportAppRunLimit when selectedOrganization changes + useEffect(() => { + if (selectedOrganization?.billing?.internal_app_runs_hard_limit !== undefined) { + setSupportAppRunLimit(selectedOrganization.billing.internal_app_runs_hard_limit); + } + }, [selectedOrganization?.billing?.internal_app_runs_hard_limit]); + return (
@@ -2118,6 +2176,8 @@ const Billing = memo((props) => { globalUrl={globalUrl} selectedOrganization={selectedOrganization} billingInfo={billingInfo} + monthlyAppRunsParent={monthlyAppRunsParent} + monthlyAllSuborgExecutions={monthlyAllSuborgExecutions} isCloud={isCloud} userdata={userdata} stripeKey={stripeKey} @@ -2154,6 +2214,8 @@ const Billing = memo((props) => { isLoggedIn={isLoggedIn} globalUrl={globalUrl} selectedOrganization={selectedOrganization} + monthlyAppRunsParent={monthlyAppRunsParent} + monthlyAllSuborgExecutions={monthlyAllSuborgExecutions} billingInfo={billingInfo} isCloud={isCloud} userdata={userdata} @@ -2639,6 +2701,122 @@ const Billing = memo((props) => { Save + {userdata.support === true && ( +
+ + ⚠️ Support Only - App Run Limit Control + + + Note: Setting an app run hard limit below current usage will immediately stop all workflow executions for this organization. + + + Current app runs this month: {Number(monthlyAppRunsParent ?? 0) + Number(monthlyAllSuborgExecutions ?? 0)} / {userdata.app_execution_limit} + + + + + {/* Support Limit Dialog */} + setSupportLimitDialogOpen(false)} + maxWidth="sm" + fullWidth + PaperProps={{ + sx: { + borderRadius: theme?.palette?.DialogStyle?.borderRadius, + border: theme?.palette?.DialogStyle?.border, + minWidth: '440px', + fontFamily: theme?.typography?.fontFamily, + backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, + zIndex: 1000, + '& .MuiDialogContent-root': { + backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, + }, + '& .MuiDialogTitle-root': { + backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, + }, + '& .MuiDialogActions-root': { + backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, + } + } + }} + > + + ⚠️ Set App Run Limit + + + + Note: This will set a hard limit on app executions. If the organization reaches this limit, all workflow executions will be stopped. + + + Current usage: {Number(monthlyAppRunsParent ?? 0) + Number(monthlyAllSuborgExecutions ?? 0)} app runs this month + + + Current hard limit: {selectedOrganization?.billing?.internal_app_runs_hard_limit || 'Not set'} + + setSupportAppRunLimit(e.target.value)} + inputProps={{ min: 0 }} + style={{ marginTop: 10 }} + helperText="Set to 0 to completely disable app run hard limit" + /> + + + + + + +
+ )} +
): null} diff --git a/frontend/src/components/LicencePopup.jsx b/frontend/src/components/LicencePopup.jsx index 70e4cc6e..9d1dc0c0 100644 --- a/frontend/src/components/LicencePopup.jsx +++ b/frontend/src/components/LicencePopup.jsx @@ -1,133 +1,135 @@ -import React, { useState, useEffect, useContext } from "react"; -import ReactGA from 'react-ga4'; +import React, { useState, useEffect, useContext, useRef } from "react"; +import ReactGA from "react-ga4"; -import {getTheme} from "../theme.jsx"; -import countries from "../components/Countries.jsx"; +import { getTheme } from "../theme.jsx"; import { - Box, - Paper, - Typography, - Divider, - Button, - Grid, - Card, - Dialog, - DialogTitle, - DialogContent, - TextField, - InputAdornment, - IconButton, - Chip, - Checkbox, - Tooltip, - Slider, - DialogActions, - CardContent, - ButtonGroup, - DialogContentText, - ToggleButton, - ToggleButtonGroup, + Box, + Typography, + Divider, + Button, + Grid, + LinearProgress, + Dialog, + DialogTitle, + DialogContent, + TextField, + InputAdornment, + IconButton, + Chip, + Checkbox, + Tooltip, + DialogActions, + FormControlLabel, + Switch, + CircularProgress, + Skeleton, } from "@mui/material"; import { useNavigate, Link } from "react-router-dom"; -import { Autocomplete } from "@mui/material"; -import { toast } from "react-toastify" +import { toast } from "react-toastify"; import { Context } from "../context/ContextApi.jsx"; import { - Cached as CachedIcon, - ContentCopy as ContentCopyIcon, - Draw as DrawIcon, - Close as CloseIcon, - Done as DoneIcon, - Clear as ClearIcon, - AddTask as AddTaskIcon, + ContentCopy as ContentCopyIcon, + Draw as DrawIcon, + Close as CloseIcon, + Done as DoneIcon, } from "@mui/icons-material"; -import { typecost, typecost_single, } from "../views/HandlePaymentNew.jsx"; -import { handlePayasyougo } from "../views/HandlePaymentNew.jsx" -import Billing from "./Billing.jsx"; - +// This is the main component which shows the cards on Billing & Stats tab const LicencePopup = (props) => { - const { globalUrl, userdata, serverside, billingInfo, stripeKey, setModalOpen, isScale, isLoggedIn, isMobile, selectedOrganization, isCloud, features, licensePopup = false } = props; - //const alert = useAlert(); - let navigate = useNavigate(); - const [selectedDealModalOpen, setSelectedDealModalOpen] = React.useState(false); - const [dealList, setDealList] = React.useState([]); - const [dealName, setDealName] = React.useState(""); - const [dealAddress, setDealAddress] = React.useState(""); - const [dealType, setDealType] = React.useState("MSSP"); - const [dealCountry, setDealCountry] = React.useState("United States"); - const [dealCurrency, setDealCurrency] = React.useState("USD"); - const [dealStatus, setDealStatus] = React.useState("initiated"); - const [dealValue, setDealValue] = React.useState(""); - const [dealDiscount, setDealDiscount] = React.useState(""); - const [dealerror, setDealerror] = React.useState(""); - const [variant, setVariant] = useState(0) - const [shuffleVariant, setShuffleVariant] = useState(isCloud ? 0 : 1) - const [BillingEmail, setBillingEmail] = useState(selectedOrganization?.Billing?.Email); - const [openChangeEmailBox, setOpenChangeEmailBox] = useState(false); - // const parsedFields = maxFields === undefined ? 300 : maxFields - const initialShuffleVariant = isCloud ? 0 : 1; - const [paymentType, setPaymentType] = useState(0) - const [currentPrice, setCurrentPrice] = useState(129) - const [isLoaded, setIsLoaded] = useState(false) - const [errorMessage, setErrorMessage] = useState("") - const [highlight, setHighlight] = useState(false) + const { + globalUrl, + userdata, + serverside, + billingInfo, + stripeKey, + setModalOpen, + isScale, + isLoggedIn, + isMobile, + monthlyAppRunsParent, + monthlyAllSuborgExecutions, + selectedOrganization, + setSelectedOrganization, + isCloud, + features, + handleGetOrg, + licensePopup = false, + } = props; + //const alert = useAlert(); + let navigate = useNavigate(); + const [shuffleVariant, setShuffleVariant] = useState(isCloud ? 0 : 1); + const [BillingEmail, setBillingEmail] = useState( + selectedOrganization?.Billing?.Email + ); - const { themeMode } = useContext(Context); - const theme = getTheme(themeMode); + const { themeMode } = useContext(Context); + const theme = getTheme(themeMode); - // Cloud - const [calculatedApps, setCalculatedApps] = useState(600) - const [calculatedCost, setCalculatedCost] = useState("$600") - const [selectedValue, setSelectedValue] = useState(100) - useEffect(() => { - if(selectedOrganization?.Billing?.Email !== BillingEmail) { - setBillingEmail(selectedOrganization?.Billing?.Email); - } - }, [selectedOrganization]) + useEffect(() => { + if (selectedOrganization?.Billing?.Email !== BillingEmail) { + setBillingEmail(selectedOrganization?.Billing?.Email); + } + }, [selectedOrganization]); - // Onprem - const [calculatedCores, setCalculatedCores] = useState('600') - const [onpremSelectedValue, setOnpremSelectedValue] = useState(8) - const [billingCycle, setBillingCycle] = useState("annual") - const [scaleValue, setScaleValue] = useState( - new URLSearchParams(window.location.search).get("app_runs") || - (userdata?.app_execution_limit / 1000) + 50 || 10 - ); + const [billingCycle, setBillingCycle] = useState("annual"); + const [scaleValue, setScaleValue] = useState( + new URLSearchParams(window.location.search).get("app_runs") || + userdata?.app_execution_limit / 1000 + 50 || + 10 + ); + const [isLoading, setIsLoading] = useState(true); - useEffect(() => { - setScaleValue((userdata?.app_execution_limit / 1000) + 50 || 10) - }, [userdata]) + useEffect(() => { + setScaleValue(userdata?.app_execution_limit / 1000 + 50 || 10); + }, [userdata]); - const getPrice = (basePrice) => { - return Math.round(billingCycle === "annual" ? basePrice * 0.9 : basePrice); // 10% discount for annual - }; - - const stripe = typeof window === 'undefined' || window.location === undefined ? "" : props.stripeKey === undefined ? "" : window.Stripe ? window.Stripe(props.stripeKey) : "" + // Set loading state based on data availability + useEffect(() => { + if (selectedOrganization && userdata) { + // Add a small delay to show skeleton briefly for better UX + const timer = setTimeout(() => { + setIsLoading(false); + }, 500); + return () => clearTimeout(timer); + } + }, [selectedOrganization, userdata]); + + const getPrice = (basePrice) => { + return Math.round(billingCycle === "annual" ? basePrice * 0.9 : basePrice); // 10% discount for annual + }; + + const stripe = + typeof window === "undefined" || window.location === undefined + ? "" + : props.stripeKey === undefined + ? "" + : window.Stripe + ? window.Stripe(props.stripeKey) + : ""; // Handle slider change for Scale plan - const handleScaleChange = (event, newValue) => { - setScaleValue(newValue); + const handleScaleChange = (event, newValue) => { + setScaleValue(newValue); - // Add app runs to URL query params - const urlSearchParams = new URLSearchParams(window.location.search); - urlSearchParams.set("app_runs", newValue); // Convert to actual app runs (k to actual number) - const newUrl = `${window.location.pathname}?${urlSearchParams.toString()}`; - window.history.replaceState({}, "", newUrl); - }; + // Add app runs to URL query params + const urlSearchParams = new URLSearchParams(window.location.search); + urlSearchParams.set("app_runs", newValue); // Convert to actual app runs (k to actual number) + const newUrl = `${window.location.pathname}?${urlSearchParams.toString()}`; + window.history.replaceState({}, "", newUrl); + }; - // Handle billing cycle change - const handleBillingCycleChange = (event, newValue) => { - if (newValue !== null) { - setBillingCycle(newValue); + // Handle billing cycle change + const handleBillingCycleChange = (event, newValue) => { + if (newValue !== null) { + setBillingCycle(newValue); - if(isCloud){ + if (isCloud) { ReactGA.event({ - category: 'Billingpage', - action: 'Billing Cycle Changed', + category: "Billingpage", + action: "Billing Cycle Changed", label: `${billingCycle} -> ${newValue}`, }); } @@ -139,1479 +141,1459 @@ const LicencePopup = (props) => { window.location.pathname }?${urlSearchParams.toString()}`; window.history.replaceState({}, "", newUrl); - } + } + }; + + const payasyougo = "Pay as you go"; + + const paperStyle = { + padding: 20, + paddingBottom: 30, + borderRadius: theme.palette?.borderRadius, + height: "100%", + }; + + const userInScalePlan = userdata?.app_execution_limit > 2000; + + // These functions are being used for the dynamic features from the orgSyncFeatures + // Add this function to format the limit value + const formatLimit = (limit) => { + if (limit === null || limit === undefined || limit === 0) + return "Unlimited"; + if (typeof limit === "string" && limit.toLowerCase() === "unlimited") + return "Unlimited"; + if (typeof limit === "number") return limit.toLocaleString(); + return limit.toString(); + }; + + // Add this function to format the feature text with proper unlimited handling + const formatFeatureText = (feature, limit) => { + if (!feature) return ""; + + // Dynamic features that use limits + const featureMapping = { + app_executions: (limit) => { + const formattedLimit = formatLimit(limit); + return `Includes ${formattedLimit} App Executions per month`; + }, + multi_env: (limit) => { + const formattedLimit = formatLimit(limit); + return formattedLimit === "Unlimited" + ? "Unlimited Environments" + : `${formattedLimit} Environment${ + parseInt(formattedLimit) > 1 ? "s" : "" + }`; + }, + multi_tenant: (limit) => { + const formattedLimit = formatLimit(limit); + return formattedLimit === "Unlimited" + ? "Unlimited Tenants" + : `${formattedLimit} Tenant${ + parseInt(formattedLimit) > 1 ? "s" : "" + }`; + }, + multi_region: (limit) => { + const formattedLimit = formatLimit(limit); + return formattedLimit === "Unlimited" + ? "Unlimited Regions" + : `${formattedLimit} Region${ + parseInt(formattedLimit) > 1 ? "s" : "" + }`; + }, + webhook: (limit) => { + const formattedLimit = formatLimit(limit); + return formattedLimit === "Unlimited" + ? "Unlimited Webhooks" + : `${formattedLimit} Webhook${ + parseInt(formattedLimit) > 1 ? "s" : "" + }`; + }, + schedules: (limit) => { + const formattedLimit = formatLimit(limit); + return formattedLimit === "Unlimited" + ? "Unlimited Schedules" + : `${formattedLimit} Schedule${ + parseInt(formattedLimit) > 1 ? "s" : "" + }`; + }, + user_input: (limit) => { + const formattedLimit = formatLimit(limit); + return formattedLimit === "Unlimited" + ? "Unlimited User Inputs" + : `${formattedLimit} User Input${ + parseInt(formattedLimit) > 1 ? "s" : "" + }`; + }, + send_mail: (limit) => { + const formattedLimit = formatLimit(limit); + return formattedLimit === "Unlimited" + ? "Unlimited Emails per month" + : `${formattedLimit} Email${ + parseInt(formattedLimit) > 1 ? "s" : "" + } per month`; + }, + send_sms: (limit) => { + const formattedLimit = formatLimit(limit); + return formattedLimit === "Unlimited" + ? "Unlimited SMS per month" + : `${formattedLimit} SMS${ + parseInt(formattedLimit) > 1 ? "s" : "" + } per month`; + }, + email_trigger: (limit) => { + const formattedLimit = formatLimit(limit); + return formattedLimit === "Unlimited" + ? "Unlimited Email Triggers" + : `${formattedLimit} Email Trigger${ + parseInt(formattedLimit) > 1 ? "s" : "" + }`; + }, + notifications: (limit) => { + const formattedLimit = formatLimit(limit); + return formattedLimit === "Unlimited" + ? "Unlimited Notifications" + : `${formattedLimit} Notification${ + parseInt(formattedLimit) > 1 ? "s" : "" + }`; + }, + workflows: (limit) => { + const formattedLimit = formatLimit(limit); + return formattedLimit === "Unlimited" + ? "Unlimited Workflows" + : `${formattedLimit} Workflow${ + parseInt(formattedLimit) > 1 ? "s" : "" + }`; + }, + autocomplete: (limit) => { + const formattedLimit = formatLimit(limit); + return formattedLimit === "Unlimited" + ? "Unlimited Autocomplete" + : `${formattedLimit} Autocomplete${ + parseInt(formattedLimit) > 1 ? "s" : "" + }`; + }, + workflow_executions: (limit) => { + const formattedLimit = formatLimit(limit); + return formattedLimit === "Unlimited" + ? "Unlimited Workflow Executions" + : `${formattedLimit} Workflow Execution${ + parseInt(formattedLimit) > 1 ? "s" : "" + }`; + }, + authentication: (limit) => { + const formattedLimit = formatLimit(limit); + return formattedLimit === "Unlimited" + ? "Unlimited Authentication" + : `${formattedLimit} Authentication${ + parseInt(formattedLimit) > 1 ? "s" : "" + }`; + }, + shuffle_gpt: (limit) => { + const formattedLimit = formatLimit(limit); + return formattedLimit === "Unlimited" + ? "Unlimited Shuffle GPT" + : `${formattedLimit} Shuffle GPT${ + parseInt(formattedLimit) > 1 ? "s" : "" + }`; + }, }; - const payasyougo = "Pay as you go" - - const paperStyle = { - padding: 20, - paddingBottom: 30, - borderRadius: theme.palette?.borderRadius, - height: "100%", - - } - - const userInScalePlan = userdata?.app_execution_limit > 2000 - const appRuns = (userdata?.app_execution_limit / 1000) + "K App Runs" - - // Add this function to format the limit value - const formatLimit = (limit) => { - if (limit === null || limit === undefined || limit === 0) return "Unlimited"; - if (typeof limit === "string" && limit.toLowerCase() === "unlimited") return "Unlimited"; - if (typeof limit === "number") return limit.toLocaleString(); - return limit.toString(); - }; - - // Add this function to format the feature text with proper unlimited handling - const formatFeatureText = (feature, limit) => { - if (!feature) return ""; - - // Dynamic features that use limits - const featureMapping = { - app_executions: (limit) => { - const formattedLimit = formatLimit(limit); - return `Includes ${formattedLimit} App Executions per month`; - }, - multi_env: (limit) => { - const formattedLimit = formatLimit(limit); - return formattedLimit === "Unlimited" - ? "Unlimited Environments" - : `${formattedLimit} Environment${parseInt(formattedLimit) > 1 ? 's' : ''}`; - }, - multi_tenant: (limit) => { - const formattedLimit = formatLimit(limit); - return formattedLimit === "Unlimited" - ? "Unlimited Tenants" - : `${formattedLimit} Tenant${parseInt(formattedLimit) > 1 ? 's' : ''}`; - }, - multi_region: (limit) => { - const formattedLimit = formatLimit(limit); - return formattedLimit === "Unlimited" - ? "Unlimited Regions" - : `${formattedLimit} Region${parseInt(formattedLimit) > 1 ? 's' : ''}`; - }, - webhook: (limit) => { - const formattedLimit = formatLimit(limit); - return formattedLimit === "Unlimited" - ? "Unlimited Webhooks" - : `${formattedLimit} Webhook${parseInt(formattedLimit) > 1 ? 's' : ''}`; - }, - schedules: (limit) => { - const formattedLimit = formatLimit(limit); - return formattedLimit === "Unlimited" - ? "Unlimited Schedules" - : `${formattedLimit} Schedule${parseInt(formattedLimit) > 1 ? 's' : ''}`; - }, - user_input: (limit) => { - const formattedLimit = formatLimit(limit); - return formattedLimit === "Unlimited" - ? "Unlimited User Inputs" - : `${formattedLimit} User Input${parseInt(formattedLimit) > 1 ? 's' : ''}`; - }, - send_mail: (limit) => { - const formattedLimit = formatLimit(limit); - return formattedLimit === "Unlimited" - ? "Unlimited Emails per month" - : `${formattedLimit} Email${parseInt(formattedLimit) > 1 ? 's' : ''} per month`; - }, - send_sms: (limit) => { - const formattedLimit = formatLimit(limit); - return formattedLimit === "Unlimited" - ? "Unlimited SMS per month" - : `${formattedLimit} SMS${parseInt(formattedLimit) > 1 ? 's' : ''} per month`; - }, - email_trigger: (limit) => { - const formattedLimit = formatLimit(limit); - return formattedLimit === "Unlimited" - ? "Unlimited Email Triggers" - : `${formattedLimit} Email Trigger${parseInt(formattedLimit) > 1 ? 's' : ''}`; - }, - notifications: (limit) => { - const formattedLimit = formatLimit(limit); - return formattedLimit === "Unlimited" - ? "Unlimited Notifications" - : `${formattedLimit} Notification${parseInt(formattedLimit) > 1 ? 's' : ''}`; - }, - workflows: (limit) => { - const formattedLimit = formatLimit(limit); - return formattedLimit === "Unlimited" - ? "Unlimited Workflows" - : `${formattedLimit} Workflow${parseInt(formattedLimit) > 1 ? 's' : ''}`; - }, - autocomplete: (limit) => { - const formattedLimit = formatLimit(limit); - return formattedLimit === "Unlimited" - ? "Unlimited Autocomplete" - : `${formattedLimit} Autocomplete${parseInt(formattedLimit) > 1 ? 's' : ''}`; - }, - workflow_executions: (limit) => { - const formattedLimit = formatLimit(limit); - return formattedLimit === "Unlimited" - ? "Unlimited Workflow Executions" - : `${formattedLimit} Workflow Execution${parseInt(formattedLimit) > 1 ? 's' : ''}`; - }, - authentication: (limit) => { - const formattedLimit = formatLimit(limit); - return formattedLimit === "Unlimited" - ? "Unlimited Authentication" - : `${formattedLimit} Authentication${parseInt(formattedLimit) > 1 ? 's' : ''}`; - }, - shuffle_gpt: (limit) => { - const formattedLimit = formatLimit(limit); - return formattedLimit === "Unlimited" - ? "Unlimited Shuffle GPT" - : `${formattedLimit} Shuffle GPT${parseInt(formattedLimit) > 1 ? 's' : ''}`; - }, - }; - - try { - // Check if we have a mapping for this feature - const formatter = featureMapping[feature]; - if (formatter) { - return formatter(limit); - } - - // Default format for unknown features - const formattedLimit = formatLimit(limit); - return `${feature}: ${formattedLimit}`; - } catch (error) { - console.warn(`Error formatting feature ${feature}:`, error); - return `${feature}: ${formatLimit(limit)}`; - } - }; - - - // Update the subscription features section - billingInfo.subscription = { - "active": true, - "name": appRuns, - "price": userInScalePlan ? "" : "Free", - "currency": userInScalePlan ? "" : "Free", - "currency_text": "", - "interval": "", - "description": "", - "features": userInScalePlan ? [ - // Add static features first - ...(userInScalePlan ? ["Standard Email Support"] : []), - - // Then add dynamic features from the database - ...Object.entries(features || {}) - .filter(([_, featureData]) => { - return featureData && - typeof featureData === 'object' && - featureData.active === true; - }) - .map(([featureName, featureData]) => { - try { - return formatFeatureText(featureName, featureData?.limit); - } catch (error) { - console.warn(`Error processing feature ${featureName}:`, error); - return ""; - } - }) - .filter(feature => - feature.length > 0 && - !feature.toLowerCase().includes('unlimited') // Add this filter to remove "unlimited" features - ) - ] : [ - userInScalePlan ? "Standard Email Support" : "Community Support", - userInScalePlan ? `Includes ${appRuns}. ` : `Includes ${appRuns} for free. `, - userInScalePlan ? "Multi-Tenant & Multi-Region" : "Get all 2500+ Apps and 10 Workflows", - userInScalePlan ? "All features included in the Scale plan" : "Invite up to 5 users" - ], - "limit": userInScalePlan ? userdata?.app_execution_limit : 10000, - } - - const sendSignatureRequest = (subscription) => { - const url = `${globalUrl}/api/v1/orgs/${selectedOrganization.id}`; - - fetch(url, { - body: JSON.stringify({ - org_id: selectedOrganization.id, - subscription: subscription, - }), - mode: "cors", - method: "POST", - credentials: "include", - crossDomain: true, - withCredentials: true, - headers: { - "Content-Type": "application/json; charset=utf-8", - }, - }) - .then((response) => { - if (response.status !== 200) { - console.log("Error in response"); - } - return response.json(); - }) - .then((responseJson) => { - console.log("Response from signature request: ", responseJson); - }) - .catch((error) => { - console.log("Error: ", error); - }) - } - - // Create a function to remove duplicates and merge features - const mergeUniqueFeatures = (existingFeatures, newFeatures) => { - // Convert arrays to Sets to remove duplicates - const uniqueFeatures = new Set([ - ...(existingFeatures || []), - ...(newFeatures || []) - ]); - return Array.from(uniqueFeatures); - }; - - const SubscriptionObject = (props) => { - const { globalUrl, index, userdata, serverside, billingInfo, stripeKey, selectedOrganization, handleGetOrg, subscription, highlight, } = props; - - const [signatureOpen, setSignatureOpen] = React.useState(false); - const [tosChecked, setTosChecked] = React.useState(subscription.eula_signed) - const [hovered, setHovered] = React.useState(false) - const [newBillingEmail, setNewBillingEmail] = useState(''); - var top_text = userInScalePlan ? "Scale Plan" : "Starter Plan" - // if (subscription.limit === undefined && subscription.level === undefined || subscription.level === null || subscription.level === 0) { - // subscription.name = "Enterprise" - // subscription.currency_text = "$" - // subscription.price = subscription.level * 180 - // subscription.limit = subscription.level * 100000 - // subscription.interval = subscription.recurrence - // subscription.features = [ - // "Includes " + subscription.limit + " app runs/month. ", - // "Multi-Tenancy and Region-Selection", - // "And all other features from /pricing", - // ] - // } - - // if (userdata?.app_execution_limit >= 300000) { - // subscription.name = "Enterprise" - // subscription.currency_text = "$" - // subscription.price = typecost_single - // subscription.limit = userdata?.app_execution_limit - // subscription.interval = "app run / month" - // subscription.features = [ - // "Includes " + (userdata?.app_execution_limit/1000) + "K app runs/month. ", - // "Multi-Tenancy and Region-Selection", - // "And all other features from /pricing", - // ] - // } - - - var newPaperstyle = JSON.parse(JSON.stringify(paperStyle)) - if (subscription.name === "Enterprise" && subscription.active === true) { - top_text = "Enterprise Plan" - - // newPaperstyle.border = "1px solid #f85a3e" - } - - var showSupport = false - if (subscription.name.includes("default")) { - top_text = "Custom Contract" - // newPaperstyle.border = "1px solid #f85a3e" - showSupport = true - } - - if (subscription.name.includes("App Run Units")) { - top_text = "Scale Plan" - showSupport = true - } - - if (userdata?.app_execution_limit >= 300000) { - top_text = "Enterprise Plan" - } - - if (subscription.name.includes("Open Source")) { - top_text = "Open Source" - showSupport = true - } - - if (subscription.name.includes("Scale")) { - top_text = "Scale access" - } - - if (highlight === true) { - // Add an "Upgrade now" button - // newPaperstyle.border = "1px solid #f85a3e" - } - - const handleClickOpen = () => { - setOpenChangeEmailBox(true); - }; - - const HandleChangeBillingEmail = (orgId) => { - const email = newBillingEmail; - const emailPattern = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/; - if (!emailPattern.test(email)) { - toast("Please enter a valid email address"); - return; - } else { - setNewBillingEmail(email); - } - - toast("Updating billing email. Please Wait") - - const data = { - org_id: orgId, - email: newBillingEmail, - billing: { - email: newBillingEmail, - }, - }; - - const url = `${globalUrl}/api/v1/orgs/${orgId}/billing`; - fetch(url, { - method: "POST", - body: JSON.stringify(data), - credentials: "include", - headers: { - "Content-Type": "application/json", - }, - }) - .then((response) => { - if (response.status !== 200) { - console.log("Bad status code in get org:", response.status); - } - return response.json(); - }).then((responseJson) => { - if (responseJson.success === true) { - toast.success("Successfully updated billing email"); - setBillingEmail(newBillingEmail); - setOpenChangeEmailBox(false); - } else { - toast.error("Failed to update billing email. Please try again."); - } - }) - .catch((error) => { - console.log("Error getting org:", error); - }); - } - - - const extraFeatures = Object.entries(features || {}) - .filter(([_, featureData]) => { - return featureData && - typeof featureData === 'object' && - featureData.active === true; - }) - .map(([featureName, featureData]) => { - return formatFeatureText(featureName, featureData?.limit); - }) - .filter(feature => - feature.length > 0 && - !feature.toLowerCase().includes('unlimited') // Add this filter to remove "unlimited" features - ) - - subscription.features = mergeUniqueFeatures(subscription.features, extraFeatures); - - return ( - -
-
setHovered(true)} - // onMouseLeave={() => setHovered(false)} - > - - - { - e.preventDefault(); - setSignatureOpen(false); - setTosChecked(false) - }} - > - - - - Read and Accept the EULA - - - { - setTosChecked(e.target.checked) - }} - inputProps={{ 'aria-label': 'primary checkbox' }} - /> - { - setTosChecked(!tosChecked) - }}> - Accept - - - By clicking the “accept” button, you are signing the document, electronically agreeing that it has the same legal validity and effects as a handwritten signature, and that you have the competent authority to represent and sign on behalf an entity. Need support or have questions? Contact us at support@shuffler.io. - - -
- -
-
-
- {subscription.active === true && !isScale && } -
- {top_text === "Base Cloud Access" && userdata.has_card_available === true && !isScale ? - { - console.log("Clicked chip") - }} - variant="outlined" - color="primary" - /> - : null} - - {top_text} - - - {top_text === "Base Cloud Access" && userdata.has_card_available === false ? - - : null} - {isCloud && highlight === true && top_text !== "Starter Plan" ? - - { - setSignatureOpen(true) - }} - > - - - - : null} -
- -
- - {subscription.name} - - - {subscription.currency_text !== undefined ? -
- - {subscription.currency_text}{subscription.price} - - - {subscription.interval.length > 0 ? `/ ${subscription.interval}` : ""} - -
- : null} - - - Features - -
    - {subscription.features !== undefined && subscription.features !== null ? - subscription.features.map((feature, index) => { - var parsedFeature = feature - if (feature.includes("Documentation: ")) { - parsedFeature = - - Documentation to get started - - } - - if (feature.includes("Worker License: ")) { - const fieldId = "webhook_uri_field_" + index - parsedFeature = - - - Use the {feature.split("Worker License: ")[0]} Worker - - { }} - InputProps={{ - endAdornment: - - { - var copyText = document.getElementById(fieldId); - if (copyText !== undefined && copyText !== null) { - console.log("NAVIGATOR: ", navigator); - const clipboard = navigator.clipboard; - if (clipboard === undefined) { - toast("Can only copy over HTTPS (port 3443)"); - return; - } - - navigator.clipboard.writeText(copyText.value); - copyText.select(); - copyText.setSelectionRange( - 0, - 99999 - ); /* For mobile devices */ - - /* Copy the text inside the text field */ - document.execCommand("copy"); - toast("Copied Webhook URL"); - } else { - console.log("Couldn't find webhook URI field: ", copyText); - } - }} - edge="end" - > - - - - }} - fullWidth - /> - - } - - return ( -
  • - - {parsedFeature} - -
  • - ) - }) - : null} -
- - { - isCloud ? - userdata?.app_execution_limit && userdata?.app_execution_limit !== 10000 ? - `You have already subscribed to the ${top_text}, which includes ${userdata?.app_execution_limit/1000}K app runs/month. You can increase the limit by upgrading current plan. Contact support@shuffler.io for more information.` : - `You are using free Starter plan with max ${userdata?.app_execution_limit === 10000 ? "10,000" : "2,000"} runs per month. Upgrade to increase this limit.` - - : - `You are not subscribed to any plan and are using the free, open source plan. This plan has no enforced limits, but scale issues may occur due to CPU congestion.` - } - - {/* {isCloud && (userdata.has_card_available === true || selectedOrganization?.Billing?.Email?.length > 0 )? -
- Billing email: {BillingEmail} - - {setOpenChangeEmailBox(false)}} - PaperProps={{ - sx: { - borderRadius: theme?.palette?.DialogStyle?.borderRadius, - border: theme?.palette?.DialogStyle?.border, - minWidth: '440px', - fontFamily: theme?.typography?.fontFamily, - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - zIndex: 1000, - '& .MuiDialogContent-root': { - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - }, - '& .MuiDialogTitle-root': { - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - }, - '& .MuiDialogActions-root': { - backgroundColor: theme?.palette?.DialogStyle?.backgroundColor, - }, - } - }} - > - Change Billing Email - - - Enter the new billing email address. - - { if (event.key === 'Enter') HandleChangeBillingEmail(selectedOrganization.id) }} - onChange={(e) => setNewBillingEmail(e.target.value)} - /> - - - - - - -
- : null} */} -
- {isCloud ? ( - ) : null} - - -
- {/* -
- - Schedule Call Now -
- */} - -
-
- ) - } - - // useEffect(() => { - // console.log("New variant: ", shuffleVariant) - - // if (shuffleVariant === 1) { - // setCalculatedCost("$960") - // setSelectedValue(8) - // } else { - // if (userdata && userdata?.app_execution_limit) { - // if (userdata.app_execution_limit >= 30000 && userdata.app_execution_limit < 40000) { - // setSelectedValue(400) - // setCalculatedCost("$1280") - // }else if (userdata?.app_execution_limit >= 40000 && userdata?.app_execution_limit < 50000) { - // setSelectedValue(500) - // setCalculatedCost("$1600") - // } else if (userdata?.app_execution_limit >= 500000 && userdata?.app_execution_limit < 600000) { - // setSelectedValue(600) - // setCalculatedCost("$1920") - // } else if (userdata?.app_execution_limit >= 60000 && userdata?.app_execution_limit < 70000) { - // setSelectedValue(700) - // setCalculatedCost("$2240") - // } else if (userdata?.app_execution_limit >= 70000 && userdata?.app_execution_limit < 80000) { - // setSelectedValue(800) - // setCalculatedCost("$2560") - // } else if (userdata?.app_execution_limit >= 80000 && userdata?.app_execution_limit < 90000) { - // setSelectedValue(900) - // setCalculatedCost("$2880") - // }else { - // setCalculatedCost("$960") - // setSelectedValue(300) - // } - // }else { - // setCalculatedCost("$960") - // setSelectedValue(300) - // } - // } - // }, [userdata]) - - if (typeof window === 'undefined' || window.location === undefined) { - return null - } - - const setMonthlyCost = (variant, paymentType) => { - setErrorMessage("") - if (variant === 0 && paymentType === 0) { - setCurrentPrice(129) - } else if (variant === 0 && paymentType === 1) { - setCurrentPrice(155) - } else if (variant === 1 && paymentType === 0) { - setCurrentPrice(999) - } else if (variant === 1 && paymentType === 1) { - setCurrentPrice(1199) - } else if (variant === 2 && paymentType === 0) { - setCurrentPrice(15) - } else if (variant === 2 && paymentType === 1) { - setCurrentPrice(18) - } - } - - const handleChange = (event, newValue) => { - - if (shuffleVariant === 1) { - setSelectedValue(newValue) - if (newValue === 32) { - setCalculatedCost(`Get A Quote`) - } else { - setCalculatedCost(`$${newValue * 120}`) - } - } else { - setSelectedValue(newValue) - if (newValue < 300) { - setCalculatedCost(`Pay as you go`) - } else if (newValue === 1000) { - setCalculatedCost(`Get A Quote`) - } else { - setCalculatedCost(`$${newValue * 1000 * typecost}`) - } - } - } - - if (!isLoaded) { - setIsLoaded(true) - - const tmpsearch = typeof window === 'undefined' || window.location === undefined ? "" : window.location.search - const tmpVar = new URLSearchParams(tmpsearch).get("variant") - if (tmpVar !== undefined && tmpVar !== null && tmpVar < 3) { - setVariant(parseInt(tmpVar)) - } - - const tmpType = new URLSearchParams(tmpsearch).get("payment_type") - if (tmpType !== undefined && tmpType !== null && tmpType < 2) { - setPaymentType(parseInt(tmpType)) - } - - const modal = new URLSearchParams(tmpsearch).get("payment_modal") - if (modal !== undefined && modal !== null && modal === "open") { - setModalOpen(true) - } - - const tmpView = new URLSearchParams(tmpsearch).get("view") - if (tmpView !== undefined && tmpView !== null && tmpView === "failure") { - setErrorMessage("Something went wrong with your payment. Please try again.") - } - - const urlSearchParams = new URLSearchParams(window.location.search); - const params = Object.fromEntries(urlSearchParams.entries()); - const foundTab = params["tab"]; - if (foundTab !== null && foundTab !== undefined) { - if (foundTab === "onprem") { - setShuffleVariant(1); - } else if (foundTab === "cloud") { - setShuffleVariant(0); - } - } - - const foundHighlight = params["highlight"]; - if (foundHighlight !== null && foundHighlight !== undefined) { - setHighlight(true) - } - } - - //const skipFreemode = window.location.pathname.startsWith("/admin") - const skipFreemode = false - const maxwidth = isMobile ? "91%" : skipFreemode ? 1100 : 1200 - const activeIcon = - const inActiveIcon = - const defaultTaskIcon = - - const buttonBackground = "linear-gradient(to right, #f86a3e, #f34079)" - const level1Button = - - - const level2Button = - - - const level3Button = skipFreemode ? null : - - - - const cardStyle = { - // height: "100%", - // width: "100%", - // textAlign: "center", - color: "white", - } - - // const isLoggedInHandler = () => { - // if (calculatedCost === payasyougo) { - // handlePayasyougo(props.userdata) - // return - // } - - // const priceItem = - // window.location.origin === "https://shuffler.io/" || "https://sandbox.shuffler.io/" - // ? shuffleVariant === 0 - // ? "price_1PWI3uDzMUgUjxHSffUBwWCy" - // : "price_1PWI8EDzMUgUjxHSfEhUB7oL" - - // : shuffleVariant === 0 - // ? "price_1PZPSSEJjT17t98NLJoTMYja" - // : "price_1PZPQuEJjT17t98N3yORUtd9"; - - // const successUrl = `${window.location.origin}/admin?admin_tab=billingstats&payment=success` - // const failUrl = `${window.location.origin}/admin?admin_tab=billingstats&payment=failure` - // const quantity = shuffleVariant === 0 ? selectedValue / 100 : selectedValue - - // console.log("Priceitem: ", priceItem, quantity, shuffleVariant) - // var checkoutObject = { - // lineItems: [ - // { - // price: priceItem, - // quantity: quantity, - // }, - // ], - // mode: "subscription", - // billingAddressCollection: "auto", - // successUrl: successUrl, - // cancelUrl: failUrl, - // clientReferenceId: props.userdata.active_org.id, - // } - - // if (stripe === undefined || stripe === null || stripe.redirectToCheckout === undefined) { - // window.open("https://shuffler.io/admin?admin_tab=billingstats&payment=stripe_error", "_self") - // } - - // stripe.redirectToCheckout(checkoutObject) - // .then(function (result) { - // console.log("SUCCESS STRIPE?: ", result) - - // ReactGA.event({ - // category: "pricing", - // action: "add_card_success", - // label: "", - // }) - // }) - // .catch(function (error) { - // console.error("STRIPE ERROR: ", error) - - // ReactGA.event({ - // category: "pricing", - // action: "add_card_error", - // label: "", - // }) - // }) - // } - - const isLoggedInHandler = () => { - var priceItem; - if (window.location.origin === "https://shuffler.io" || window.location.origin === "https://sandbox.shuffler.io") { - priceItem = billingCycle === "monthly" ? "price_1R66rbEJjT17t98NHIQ78nrz" : "price_1R671UEJjT17t98NzfqWvSG7" - } else if (window.location.origin === "http://localhost:3002") { - priceItem = billingCycle === "monthly" ? "price_1R678hEJjT17t98Nai5J50gs" : "price_1R6c84EJjT17t98NR68gUfT7" + try { + // Check if we have a mapping for this feature + const formatter = featureMapping[feature]; + if (formatter) { + return formatter(limit); } - - const successUrl = `${window.location.origin}/admin?admin_tab=billingstats&payment=success`; - const failUrl = `${window.location.origin}/pricing?admin_tab=billingstats&payment=failure`; - - let quantity; - - if (billingCycle === "monthly") { - quantity = scaleValue / 10 - } else { - quantity = (scaleValue / 10) * 12 + + // Default format for unknown features + const formattedLimit = formatLimit(limit); + return `${feature}: ${formattedLimit}`; + } catch (error) { + console.warn(`Error formatting feature ${feature}:`, error); + return `${feature}: ${formatLimit(limit)}`; + } + }; + + // Send signature request to backend + const sendSignatureRequest = (subscription) => { + const url = `${globalUrl}/api/v1/orgs/${selectedOrganization.id}`; + + fetch(url, { + body: JSON.stringify({ + org_id: selectedOrganization.id, + editing: "subscription_update", + subscription_index: 0, + subscription: subscription, + }), + mode: "cors", + method: "POST", + credentials: "include", + crossDomain: true, + withCredentials: true, + headers: { + "Content-Type": "application/json; charset=utf-8", + }, + }) + .then((response) => { + if (response.status !== 200) { + console.log("Error in response"); } - - redirectToCheckout(priceItem, quantity, successUrl, failUrl); - }; - - const redirectToCheckout = (priceItem, quantity, successUrl, failUrl) => { - const checkoutObject = { - lineItems: [ - { - price: priceItem, - quantity: quantity, - }, - ], - mode: "subscription", - billingAddressCollection: "auto", - successUrl: successUrl, - cancelUrl: failUrl, - clientReferenceId: userdata.active_org.id, + return response.json(); + }) + .then((responseJson) => { + console.log("Response from signature request: ", responseJson); + if (typeof handleGetOrg === "function") { + handleGetOrg(selectedOrganization.id); + } + }) + .catch((error) => { + console.log("Error: ", error); + }); + }; + + // Create a function to remove duplicates and merge features + const mergeUniqueFeatures = (existingFeatures, newFeatures) => { + // Convert arrays to Sets to remove duplicates + const uniqueFeatures = new Set([ + ...(existingFeatures || []), + ...(newFeatures || []), + ]); + return Array.from(uniqueFeatures); + }; + + // This is the dialog for editing subscription with better UX + const EditSubscriptionDialog = ({ + open, + onClose, + subscription, + globalUrl, + selectedOrganization, + onSaved, + }) => { + const initialForm = { + name: subscription?.name || "", + active: !!subscription?.active, + support_level: subscription?.support_level || "", + recurrence: subscription?.recurrence || "month", + amount: subscription?.amount || "", + currency: subscription?.currency || "USD", + level: subscription?.level || "", + limit: subscription?.limit || 0, + startdate: subscription?.startdate || 0, + enddate: subscription?.enddate || 0, + cancellationdate: subscription?.cancellationdate || 0, + features: Array.isArray(subscription?.features) + ? subscription.features + : [], + eula: subscription?.eula, + eula_signed: subscription?.eula_signed, + eula_signed_by: subscription?.eula_signed_by, + reference: subscription?.reference, + }; + const [form, setForm] = useState(initialForm); + + useEffect(() => { + if (!open) return; + setForm(initialForm); + setFeaturesMarkdown(featuresToMarkdown(subscription?.features)); + setErrors({}); + }, [open, subscription]); + + const handleCancel = () => { + setForm(initialForm); + setFeaturesMarkdown(featuresToMarkdown(subscription?.features)); + setErrors({}); + onClose?.(); + }; + + const toInputDate = (epoch) => { + if (!epoch || isNaN(epoch)) return ""; + try { + return new Date(epoch * 1000).toISOString().slice(0, 10); + } catch (e) { + return ""; + } + }; + const toEpoch = (dateStr) => { + if (!dateStr) return 0; + const ms = Date.parse(dateStr); + return isNaN(ms) ? 0 : Math.floor(ms / 1000); + }; + + const [errors, setErrors] = useState({}); + const [saving, setSaving] = useState(false); + + // Simple helper: Convert features array <-> markdown list + const featuresToMarkdown = (arr) => { + const list = Array.isArray(arr) ? arr : []; + return list + .map((line) => { + const text = String(line || ""); + // If already looks like a list item, keep as-is + if (/^\s*-\s+/.test(text)) return text; + return `- ${text}`; + }) + .join("\n"); + }; + + const markdownToFeatures = (markdown) => { + if (!markdown) return []; + return String(markdown) + .split("\n") + .map((raw) => raw.replace(/\s+$/, "")) + .filter(Boolean) + .map((line) => { + // Keep indentation depth of multiples of two spaces before dash + const m = line.match(/^(\s*)-\s+(.*)$/); + if (!m) { + return line.trim(); + } + const indent = m[1] || ""; + const text = m[2] || ""; + return `${indent}- ${text}`.trimEnd(); + }); + }; + + const [featuresMarkdown, setFeaturesMarkdown] = useState( + featuresToMarkdown(subscription?.features) + ); + const featuresInputRef = useRef(null); + + // Handle tab indentation for markdown textarea + const handleFeaturesKeyDown = (e) => { + if (e.key !== "Tab") return; + + const textarea = featuresInputRef.current; + if (!textarea) return; + + e.preventDefault(); + + const { selectionStart, selectionEnd } = textarea; + const text = featuresMarkdown; + + // Find the start and end of the current line(s) + const lineStart = text.lastIndexOf("\n", selectionStart - 1) + 1; + const lineEnd = text.indexOf("\n", selectionEnd); + const actualLineEnd = lineEnd === -1 ? text.length : lineEnd; + + // Get the selected lines + const selectedText = text.slice(lineStart, actualLineEnd); + const lines = selectedText.split("\n"); + + // Apply indentation + const indent = " "; // 2 spaces + const newLines = lines.map(line => { + if (e.shiftKey) { + // Shift+Tab: remove indentation + if (line.startsWith(indent)) { + return line.slice(indent.length); + } + if (line.startsWith(" ")) { + return line.slice(1); + } + return line; + } else { + // Tab: add indentation + return `${indent}${line}`; + } + }); + + // Update the text + const newText = + text.slice(0, lineStart) + + newLines.join("\n") + + text.slice(actualLineEnd); + + setFeaturesMarkdown(newText); + + // Update cursor position + const indentChange = e.shiftKey ? -indent.length : indent.length; + const newSelectionStart = Math.max(lineStart, selectionStart + indentChange); + const newSelectionEnd = Math.max(lineStart, selectionEnd + (indentChange * lines.length)); + + // Use requestAnimationFrame for better performance than setTimeout + requestAnimationFrame(() => { + try { + textarea.selectionStart = newSelectionStart; + textarea.selectionEnd = newSelectionEnd; + } catch (error) { + // Ignore selection errors + } + }); + }; + + const validate = () => { + const next = {}; + if (!form.name || form.name.trim().length === 0) + next.name = "Name is required"; + if (form.amount !== "" && Number.isNaN(Number(form.amount))) + next.amount = "Amount must be a number"; + if (form.limit !== "" && Number.isNaN(Number(form.limit))) + next.limit = "Limit must be a number"; + if (form.startdate && form.enddate && form.enddate < form.startdate) + next.enddate = "End date must be after start date"; + if (!form.recurrence || String(form.recurrence).trim().length === 0) + next.recurrence = "Recurrence is required"; + setErrors(next); + return Object.keys(next).length === 0; + }; + + const save = async () => { + if (!validate()) return; + setSaving(true); + try { + const url = `${globalUrl}/api/v1/orgs/${selectedOrganization.id}`; + const payload = { + org_id: selectedOrganization.id, + editing: "subscription_update", + subscription_index: 0, + subscription: { + ...form, + // Ensure backend gets array of features + features: markdownToFeatures(featuresMarkdown), + }, }; - - console.log("OBJECT: ", priceItem, checkoutObject); - - stripe - .redirectToCheckout(checkoutObject) - .then(function (result) { - console.log("SUCCESS STRIPE?: ", result); - }) - .catch(function (error) { - console.error("STRIPE ERROR: ", error); + const res = await fetch(url, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await res.json().catch(() => ({})); + if (res.ok && data && data.success !== false) { + toast.success("Subscription updated"); + onClose?.(); + onSaved?.({ + ...form, + features: markdownToFeatures(featuresMarkdown), }); - }; + } else { + toast.error("Failed to update subscription"); + } + } catch (e) { + toast.error("Failed to update subscription"); + } finally { + setSaving(false); + } + }; return ( -
- - - {(selectedOrganization.subscriptions === undefined || selectedOrganization.subscriptions === null || selectedOrganization.subscriptions.length === 0) && isCloud ? - - : - selectedOrganization.subscriptions !== undefined && - selectedOrganization.subscriptions !== null && - selectedOrganization.subscriptions.length > 0 ? - selectedOrganization.subscriptions - .reverse() - .map((sub, index) => { - return ( - - ) - }) - : null} - {!isCloud ? - - - {/* - - */} - - : null} + + + Edit subscription + + +
+ setForm({ ...form, name: e.target.value })} + fullWidth + error={!!errors.name} + helperText={errors.name} + /> + + setForm((prev) => { + const active = e.target.checked; + const todayEpoch = toEpoch( + new Date().toISOString().slice(0, 10) + ); + return { + ...prev, + active, + cancellationdate: active + ? 0 + : prev.cancellationdate && prev.cancellationdate !== 0 + ? prev.cancellationdate + : todayEpoch, + }; + }) + } + /> + } + label="Active" + /> - {/* {isCloud && - selectedOrganization.subscriptions !== undefined && - selectedOrganization.subscriptions !== null && - selectedOrganization.subscriptions.length > 0 ? - selectedOrganization.subscriptions - .reverse() - .map((sub, index) => { - return ( - - ) - }) - : null} */} - - { - licensePopup && - ( - - {errorMessage.length > 0 ? Error: {errorMessage} : null} - -
- - { - billingCycle === "annual" && - ( - - - 10% OFF - - - ) - } -
-
- - {scaleValue > 300 ? "Enterprise Plan" : "Scale Plan"} - - - - - Monthly - - - Annual - - - -
- - - App Runs Units - + + setForm({ ...form, support_level: e.target.value }) + } + fullWidth + /> + setForm({ ...form, recurrence: e.target.value })} + fullWidth + error={!!errors.recurrence} + helperText={errors.recurrence} + /> -
- - {scaleValue > 300 ? "Let's Talk" : `$${getPrice(32) * (scaleValue / 10)}`} - - 300 ? 1 : 0, - }} - > - {scaleValue > 300 ? `for ${scaleValue > 500 ? "500k+" : `${scaleValue}k`} App Runs` : `/month for ${scaleValue}k App Runs`} - -
- - { - if(value === 510){ - return "500k+" - } - return `${value}k` - }} - step={10} - min={10} - max={510} - marks - sx={{ - color: "#ff8544", - "& .MuiSlider-thumb": { - width: 15, - height: 15, - }, - "& .MuiSlider-valueLabel": { - backgroundColor: "rgba(33, 33, 33, 1)", - color: "rgba(241, 241, 241, 1)", - fontSize: 14, - borderRadius: "4px", - border: "1px solid rgba(73, 73, 73, 1)", - fontFamily: theme?.typography?.fontFamily, - }, - }} - /> - + $ + ), + }} + onChange={(e) => setForm({ ...form, amount: e.target.value })} + fullWidth + error={!!errors.amount} + helperText={errors.amount || "0 for Free"} + /> -
-
- {defaultTaskIcon} - Standard Email Support -
- -
- {defaultTaskIcon} - - {shuffleVariant === 0 ? "Multi-Tenant" : "Lightning-Fast Workflows"} - -
- -
- {defaultTaskIcon} - - {shuffleVariant === 0 ? "Multi-Region Tenants" : "High Availability"} - -
- -
- {defaultTaskIcon} - 30 Days workflow run history -
-
-
- -
- - - -
-
- - - ) + + setForm({ ...form, startdate: toEpoch(e.target.value) }) + } + InputLabelProps={{ shrink: true }} + fullWidth + /> + + setForm({ ...form, enddate: toEpoch(e.target.value) }) + } + InputLabelProps={{ shrink: true }} + fullWidth + error={!!errors.enddate} + helperText={errors.enddate} + /> + {form.active ? null : ( + + setForm({ + ...form, + cancellationdate: toEpoch(e.target.value), + }) } - + InputLabelProps={{ shrink: true }} + fullWidth + /> + )} + +
+ + Features + + setFeaturesMarkdown(e.target.value)} + placeholder={"- Feature\n - Sub feature"} + multiline + minRows={8} + fullWidth + inputRef={featuresInputRef} + onKeyDown={handleFeaturesKeyDown} + /> +
+ + Preview + +
+ {markdownToFeatures(featuresMarkdown).map((feat, idx) => { + const depth = (feat.match(/^(\s+)-\s+/) || [])[1] + ? Math.min( + 3, + Math.floor( + (feat.match(/^(\s+)-\s+/) || [])[1].length / 2 + ) + ) + : 0; + const label = String(feat).replace(/^\s*-\s+/, ""); + return ( +
+ {depth === 0 ? ( + + ) : ( + + )} + {label} +
+ ); + })} +
+
+
+
+ + + + + +
+ ); + }; + + // Skeleton loading component for subscription cards + const SubscriptionSkeleton = () => ( +
+
+ {/* Header skeleton */} +
+
+ + +
+
- ) -} + + {/* Price skeleton */} +
+ +
+ + {/* Divider */} + + + {/* App runs section skeleton */} +
+ + + +
+ + {/* Features section skeleton */} +
+ +
+ {[1, 2, 3, 4, 5].map((i) => ( +
+ + +
+ ))} +
+
+ + {/* Buttons skeleton */} +
+ + +
+
+
+ ); + + // Actual Subscription Object + const SubscriptionObject = (props) => { + const { + globalUrl, + userdata, + selectedOrganization, + handleGetOrg, + subscription, + isLoading = false, + } = props; + + const [signatureOpen, setSignatureOpen] = React.useState(false); + const [tosChecked, setTosChecked] = React.useState( + subscription?.eula_signed + ); + // Edit subscription dialog state + const [editOpen, setEditOpen] = React.useState(false); + const [localSub, setLocalSub] = React.useState(subscription); + // Keep local subscription state in sync with latest DB data + React.useEffect(() => { + setLocalSub(subscription); + }, [subscription]); + // Keep tosChecked in sync with local subscription + React.useEffect(() => { + setTosChecked(!!(localSub && localSub.eula_signed)); + }, [localSub && localSub.eula_signed]); + const [newBillingEmail, setNewBillingEmail] = useState(""); + + // Old function for changing billing email -> Not in use anymore + const HandleChangeBillingEmail = (orgId) => { + const email = newBillingEmail; + const emailPattern = /^[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}$/; + if (!emailPattern.test(email)) { + toast("Please enter a valid email address"); + return; + } else { + setNewBillingEmail(email); + } + + toast("Updating billing email. Please Wait"); + + const data = { + org_id: orgId, + email: newBillingEmail, + billing: { + email: newBillingEmail, + }, + }; + + const url = `${globalUrl}/api/v1/orgs/${orgId}/billing`; + fetch(url, { + method: "POST", + body: JSON.stringify(data), + credentials: "include", + headers: { + "Content-Type": "application/json", + }, + }) + .then((response) => { + if (response.status !== 200) { + console.log("Bad status code in get org:", response.status); + } + return response.json(); + }) + .then((responseJson) => { + if (responseJson.success === true) { + toast.success("Successfully updated billing email"); + setBillingEmail(newBillingEmail); + } else { + toast.error("Failed to update billing email. Please try again."); + } + }) + .catch((error) => { + console.log("Error getting org:", error); + }); + }; + + // Get extra features from features object + const extraFeatures = Object.entries(features || {}) + .filter(([_, featureData]) => { + return ( + featureData && + typeof featureData === "object" && + featureData.active === true + ); + }) + .map(([featureName, featureData]) => { + return formatFeatureText(featureName, featureData?.limit); + }) + .filter( + (feature) => + feature.length > 0 && + !feature.toLowerCase().includes("unlimited") && // Add this filter to remove "unlimited" features + !feature.includes("App Executions per month") + ); + + const finalFeatures = mergeUniqueFeatures(localSub.features, extraFeatures); + + const usedAppRuns = Number(monthlyAppRunsParent) + Number(monthlyAllSuborgExecutions); + const appRunsLimit = userdata?.app_execution_limit || selectedOrganization?.sync_features?.app_executions?.limit; + const appRunsPct = + appRunsLimit > 0 + ? Math.min(100, Math.round((usedAppRuns / appRunsLimit) * 100)) + : 0; + + const [showAllFeatures, setShowAllFeatures] = useState(false); + + // Render new Current Subscription Card UI if this is the active plan + const visibleFeatures = (finalFeatures || []).filter(Boolean); + const collapsed = showAllFeatures + ? visibleFeatures + : visibleFeatures.slice(0, 6); + + const getFeatureIndent = (text) => { + // Count leading spaces in patterns like " - sub item" + const match = String(text).match(/^(\s+)-\s+/); + if (!match) return 0; + const spaces = match[1].length; + return Math.min(3, Math.floor(spaces / 2)); + }; + + const stripPrefix = (text) => { + return String(text) + .replace(/^\s*-\s+/, "") + .trim(); + }; + + const isCancelled = localSub.cancellationdate !== 0; + const isPaidPlan = localSub.amount !== "0"; + const amountToshow = isPaidPlan + ? String(localSub.currency || "").toLowerCase() === "usd" + ? "$" + localSub?.amount + : localSub?.currency + localSub?.amount + : "Free"; + + if (typeof window === "undefined" || window.location === undefined) { + return null; + } + + // Show skeleton if loading + if (isLoading) { + return ; + } + + return ( + <> + setEditOpen(false)} + subscription={localSub} + globalUrl={globalUrl} + selectedOrganization={selectedOrganization} + onSaved={(updated) => { + // Update local card immediately for responsive UI + setLocalSub((prev) => ({ ...prev, ...updated })); + // Refresh organization data from server + if (typeof handleGetOrg === "function") { + handleGetOrg(selectedOrganization.id); + } + }} + /> + + {/* EULA Signature Dialog */} + + + { + e.preventDefault(); + setSignatureOpen(false); + setTosChecked(false); + }} + > + + + + + Read and Accept the EULA + + + + { + setTosChecked(e.target.checked); + }} + inputProps={{ "aria-label": "primary checkbox" }} + /> + { + setTosChecked(!tosChecked); + }} + > + Accept + + + By clicking the “accept” button, you are signing the document, + electronically agreeing that it has the same legal validity and + effects as a handwritten signature, and that you have the + competent authority to represent and sign on behalf an entity. + Need support or have questions? Contact us at support@shuffler.io. + + +
+ +
+
+
+ +
+
+
+
+ + {localSub.name} + + + {localSub.support_level} + +
+
+ {/* EULA Signature Button */} + {isCloud && localSub.eula && appRunsLimit >= 12000 && ( + + { + if (localSub.eula_signed && !userdata.support) { + return; + } + setSignatureOpen(true); + }} + style={{ + padding: "6px", + color: localSub.eula_signed ? "#545454" : "#ff8544", + backgroundColor: localSub.eula_signed + ? "rgba(241, 241, 241, 0.1)" + : "rgba(255, 133, 68, 0.1)", + borderRadius: "50%", + }} + > + + + + )} + {isPaidPlan ? ( +
+ + + {!isCancelled ? "Active" : "Inactive"} + +
+ ) : null} +
+
+ +
+ + {amountToshow} + + {isPaidPlan && ( + + /{" "} + {localSub.recurrence === "month" + ? "Monthly" + : localSub.recurrence === "year" + ? "Annual" + : localSub.recurrence} + + )} +
+ {(localSub.enddate || localSub.Enddate) && localSub.active ? ( + + {`${ + isPaidPlan ? "Next billing: " : "App runs resets on " + }${new Date( + (localSub.enddate || localSub.Enddate) * 1000 + ).toLocaleDateString(undefined, { + day: "2-digit", + month: "short", + year: "numeric", + })}`} + + ) : null} + + {localSub.cancellationdate !== 0 ? ( + + {`Cancelled on ${new Date( + (localSub.cancellationdate || localSub.CancellationDate) * + 1000 + ).toLocaleDateString(undefined, { + day: "2-digit", + month: "short", + year: "numeric", + })}`} + + ) : null} + + + + { + (isCloud || (!isCloud && selectedOrganization.cloud_sync)) && ( +
+ + App Runs + +
+ + {usedAppRuns?.toLocaleString?.() || usedAppRuns} of{" "} + {appRunsLimit?.toLocaleString?.() || appRunsLimit} + + + + +
+
+ ) + } + +
+ + Included: + +
+ {collapsed.map((feat, idx) => { + const depth = getFeatureIndent(feat); + const label = stripPrefix(feat); + return ( +
+ {depth === 0 ? ( + + ) : ( + + )} + {label} +
+ ); + })} +
+ {visibleFeatures.length > 6 && ( + + )} +
+ +
+ {isCloud && + localSub.name.toLowerCase().includes("scale") && + localSub?.reference && + localSub.reference.length > 0 ? ( + + ) : null} + {subscription.amount === "0" && ( + + )} + + {userdata.support && ( + + )} +
+
+
+ + ); + }; + + + return ( +
+ + + {isLoading ? ( + + ) : ( + <> + {selectedOrganization.subscriptions !== undefined && + selectedOrganization.subscriptions !== null && + selectedOrganization.subscriptions.length > 0 + ? (selectedOrganization.subscriptions || []) + .slice() + .map((sub, index) => { + return ( + + ); + }) + : null} + + )} + + +
+ ); +}; export default LicencePopup;