Files
shuffle-cracked/frontend/src/components/LicencePopup.jsx
T
monilprajapati d845513dc8 minor sync fix
2025-10-10 17:48:48 +05:30

1610 lines
52 KiB
React

import React, { useState, useEffect, useContext, useRef } from "react";
import ReactGA from "react-ga4";
import { getTheme } from "../theme.jsx";
import {
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 { toast } from "react-toastify";
import { Context } from "../context/ContextApi.jsx";
import {
ContentCopy as ContentCopyIcon,
Draw as DrawIcon,
Close as CloseIcon,
Done as DoneIcon,
} from "@mui/icons-material";
// 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,
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);
useEffect(() => {
if (selectedOrganization?.Billing?.Email !== BillingEmail) {
setBillingEmail(selectedOrganization?.Billing?.Email);
}
}, [selectedOrganization]);
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]);
// 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);
// 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);
if (isCloud) {
ReactGA.event({
category: "Billingpage",
action: "Billing Cycle Changed",
label: `${billingCycle} -> ${newValue}`,
});
}
// Add billing cycle to URL query params
const urlSearchParams = new URLSearchParams(window.location.search);
urlSearchParams.set("billing_cycle", newValue);
const newUrl = `${
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 ? "" : ""
} 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" : ""
}`;
},
branding: (limit) => {
const formattedLimit = formatLimit(limit);
return formattedLimit === "Unlimited"
? "Unlimited Branding"
: `${formattedLimit} Branding${
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)}`;
}
};
// 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");
}
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),
},
};
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 (
<Dialog
open={open}
onClose={handleCancel}
PaperProps={{
style: {
background: theme.palette.backgroundColor,
borderRadius: 8,
border: theme.palette.defaultBorder,
overflow: "hidden",
margin: 0,
minWidth: 760,
padding: 16,
color: theme.palette.text.primary,
fontFamily: theme.typography.fontFamily,
},
}}
>
<DialogTitle
sx={{
background: theme.palette.backgroundColor,
borderBottom: theme.palette.defaultBorder,
p: "16px",
marginBottom: 2,
color: theme.palette.text.primary,
fontFamily: theme.typography.fontFamily,
}}
>
Edit subscription
</DialogTitle>
<DialogContent
sx={{
background: theme.palette.backgroundColor,
color: theme.palette.text.primary,
fontFamily: theme.typography.fontFamily,
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 1fr",
gap: 12,
marginTop: 8,
}}
>
<TextField
label="Name"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
fullWidth
error={!!errors.name}
helperText={errors.name}
/>
<FormControlLabel
control={
<Switch
checked={!!form.active}
onChange={(e) =>
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"
/>
<TextField
label="Support level"
value={form.support_level}
onChange={(e) =>
setForm({ ...form, support_level: e.target.value })
}
fullWidth
/>
<TextField
label="Recurrence (string)"
value={form.recurrence}
onChange={(e) => setForm({ ...form, recurrence: e.target.value })}
fullWidth
error={!!errors.recurrence}
helperText={errors.recurrence}
/>
<TextField
label="Amount"
type="number"
value={form.amount}
InputProps={{
startAdornment: (
<InputAdornment position="start">$</InputAdornment>
),
}}
onChange={(e) => setForm({ ...form, amount: e.target.value })}
fullWidth
error={!!errors.amount}
helperText={errors.amount || "0 for Free"}
/>
<TextField
label="Start date"
type="date"
value={toInputDate(form.startdate)}
onChange={(e) =>
setForm({ ...form, startdate: toEpoch(e.target.value) })
}
InputLabelProps={{ shrink: true }}
fullWidth
/>
<TextField
label="End date"
type="date"
value={toInputDate(form.enddate)}
onChange={(e) =>
setForm({ ...form, enddate: toEpoch(e.target.value) })
}
InputLabelProps={{ shrink: true }}
fullWidth
error={!!errors.enddate}
helperText={errors.enddate}
/>
{form.active ? null : (
<TextField
label="Cancellation date"
type="date"
value={toInputDate(form.cancellationdate)}
onChange={(e) =>
setForm({
...form,
cancellationdate: toEpoch(e.target.value),
})
}
InputLabelProps={{ shrink: true }}
fullWidth
/>
)}
<div style={{ gridColumn: "1 / span 2" }}>
<Typography
variant="body2"
color="textSecondary"
style={{ marginBottom: 6 }}
>
Features
</Typography>
<TextField
value={featuresMarkdown}
onChange={(e) => setFeaturesMarkdown(e.target.value)}
placeholder={"- Feature\n - Sub feature"}
multiline
minRows={8}
fullWidth
inputRef={featuresInputRef}
onKeyDown={handleFeaturesKeyDown}
/>
<div style={{ marginTop: 10 }}>
<Typography
variant="body2"
color="textSecondary"
style={{ marginBottom: 4 }}
>
Preview
</Typography>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 6,
border: "1px solid #3a3a3a",
width: 350,
padding: 12,
borderRadius: 4,
}}
>
{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 (
<div
key={`md_feat_${idx}`}
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginLeft: depth * 16,
}}
>
{depth === 0 ? (
<DoneIcon
style={{ color: "#9be39b", fontSize: 18 }}
/>
) : (
<span
style={{
width: 6,
height: 6,
background: "#9be39b",
borderRadius: 999,
display: "inline-block",
}}
/>
)}
<Typography variant="body2">{label}</Typography>
</div>
);
})}
</div>
</div>
</div>
</div>
</DialogContent>
<DialogActions
sx={{
p: "12px 16px",
background: theme.palette.backgroundColor,
borderTop: theme.palette.defaultBorder,
fontFamily: theme.typography.fontFamily,
}}
>
<Button
variant="contained"
color="primary"
onClick={save}
disabled={saving}
sx={{ px: 4 }}
>
{saving ? <CircularProgress size={18} color="inherit" /> : "Update"}
</Button>
<Button onClick={handleCancel} disabled={saving} color="primary">
Cancel
</Button>
</DialogActions>
</Dialog>
);
};
// Skeleton loading component for subscription cards
const SubscriptionSkeleton = () => (
<div
style={{
backgroundColor: theme.palette.cardBackgroundColor,
padding: "2px 4px 2px 4px",
borderRadius: theme.palette?.borderRadius,
border: "1px solid #3a3a3a",
minWidth: 370,
maxWidth: 370,
width: 370,
}}
>
<div style={{ padding: 16, display: "flex", flexDirection: "column" }}>
{/* Header skeleton */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
marginBottom: 20,
}}
>
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<Skeleton variant="text" width={120} height={20} />
<Skeleton variant="text" width={80} height={16} />
</div>
<Skeleton variant="circular" width={32} height={32} />
</div>
{/* Price skeleton */}
<div style={{ marginBottom: 20 }}>
<Skeleton variant="text" width={80} height={32} />
</div>
{/* Divider */}
<Skeleton variant="rectangular" width="100%" height={1} style={{ marginBottom: 20 }} />
{/* App runs section skeleton */}
<div style={{ marginBottom: 20 }}>
<Skeleton variant="text" width={60} height={16} style={{ marginBottom: 6 }} />
<Skeleton variant="text" width={140} height={20} style={{ marginBottom: 10 }} />
<Skeleton variant="rectangular" width="100%" height={6} style={{ borderRadius: 6 }} />
</div>
{/* Features section skeleton */}
<div style={{ marginBottom: 20 }}>
<Skeleton variant="text" width={60} height={16} style={{ marginBottom: 6 }} />
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} style={{ display: "flex", alignItems: "center", gap: 8 }}>
<Skeleton variant="circular" width={18} height={18} />
<Skeleton variant="text" width={Math.random() * 100 + 80} height={16} />
</div>
))}
</div>
</div>
{/* Buttons skeleton */}
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
<Skeleton variant="rectangular" width="100%" height={36} style={{ borderRadius: 4 }} />
<Skeleton variant="rectangular" width="100%" height={36} style={{ borderRadius: 4 }} />
</div>
</div>
</div>
);
// 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" || (!isCloud && localSub.name.toLowerCase().includes("enterprise") && !selectedOrganization.cloud_sync);
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 <SubscriptionSkeleton />;
}
return (
<>
<EditSubscriptionDialog
open={editOpen}
onClose={() => 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 */}
<Dialog
open={signatureOpen}
PaperProps={{
style: {
pointerEvents: "auto",
color: "white",
minWidth: 750,
padding: 30,
maxHeight: 700,
overflowY: "auto",
overflowX: "hidden",
zIndex: 10012,
background: theme.palette.backgroundColor,
border: theme.palette.defaultBorder,
},
}}
>
<Tooltip
title="Close window"
placement="top"
style={{ zIndex: 10011 }}
>
<IconButton
style={{ zIndex: 5000, position: "absolute", top: 34, right: 34 }}
onClick={(e) => {
e.preventDefault();
setSignatureOpen(false);
setTosChecked(false);
}}
>
<CloseIcon style={{ color: "white" }} />
</IconButton>
</Tooltip>
<DialogTitle id="form-dialog-title">
Read and Accept the EULA
</DialogTitle>
<DialogContent>
<TextField
rows={17}
multiline
fullWidth
InputProps={{
readOnly: true,
style: {
fontSize: 14,
color: "rgba(255, 255, 255, 0.6)",
},
}}
value={localSub.eula}
/>
<Checkbox
disabled={localSub.eula_signed}
checked={tosChecked}
onChange={(e) => {
setTosChecked(e.target.checked);
}}
inputProps={{ "aria-label": "primary checkbox" }}
/>
<Typography
variant="body1"
style={{
display: "inline-block",
marginLeft: 10,
marginTop: 25,
cursor: "pointer",
}}
onClick={() => {
setTosChecked(!tosChecked);
}}
>
Accept
</Typography>
<Typography
variant="body2"
style={{ display: "inline-block", marginLeft: 10 }}
color="textSecondary"
>
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.
</Typography>
<div style={{ display: "flex", marginTop: 25 }}>
<Button
variant="contained"
color="primary"
style={{ marginLeft: "auto" }}
disabled={!tosChecked || localSub.eula_signed}
onClick={() => {
setSignatureOpen(false);
setLocalSub((prev) => ({ ...prev, eula_signed: true }));
// Push change to backend
sendSignatureRequest({ ...localSub, eula_signed: true });
// Refresh organization data from server
if (typeof handleGetOrg === "function") {
handleGetOrg(selectedOrganization.id);
}
}}
>
Submit
</Button>
</div>
</DialogContent>
</Dialog>
<div
style={{
backgroundColor: theme.palette.cardBackgroundColor,
padding: "2px 4px 2px 4px",
borderRadius: theme.palette?.borderRadius,
border: "1px solid #3a3a3a",
minWidth: 370,
maxWidth: 370,
width: 370,
}}
>
<div
style={{ padding: 16, display: "flex", flexDirection: "column" }}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<div style={{ display: "flex", flexDirection: "column" }}>
<Typography
variant="body2"
style={{ color: "#ff8544", lineHeight: 1.2, fontWeight: 600 }}
>
{localSub.name}
</Typography>
<Typography
variant="body2"
color="textSecondary"
style={{ lineHeight: 1.2, marginTop: 4, fontSize: 13 }}
>
{localSub.support_level}
</Typography>
</div>
<div
style={{
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 8,
}}
>
{/* EULA Signature Button */}
{false && (
<Tooltip
title={
localSub.eula_signed
? `EULA Signed by ${localSub.eula_signed_by}`
: "Sign EULA"
}
placement="top"
>
<IconButton
size="small"
onClick={() => {
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%",
}}
>
<DrawIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{isPaidPlan ? (
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
padding: "4px 10px",
borderRadius: 20,
background: !isCancelled
? "rgba(43, 192, 126, 0.1)"
: "rgba(255, 82, 82, 0.1)",
}}
>
<span
style={{
width: 8,
height: 8,
background: !isCancelled ? "#2BC07E" : "#FD4C62",
borderRadius: 999,
}}
/>
<Typography
variant="caption"
style={{
opacity: 0.9,
color: !isCancelled ? "#2BC07E" : "#FD4C62",
}}
>
{!isCancelled ? "Active" : "Inactive"}
</Typography>
</div>
) : null}
</div>
</div>
{ ((!isCloud && selectedOrganization.cloud_sync) || isCloud) && (
<div
style={{
display: "flex",
alignItems: "baseline",
gap: 8,
marginTop: 14,
}}
>
<Typography variant="h4" style={{ fontWeight: 600 }}>
{amountToshow}
</Typography>
{isPaidPlan && (
<Typography variant="body2" color="textSecondary">
/{" "}
{localSub.recurrence === "month"
? "Monthly"
: localSub.recurrence === "year"
? "Annual"
: localSub.recurrence}
</Typography>
)}
</div>
)}
{(localSub.enddate || localSub.Enddate) && localSub.active ? (
<Typography
variant="caption"
color="textSecondary"
style={{ marginTop: (isCloud || (!isCloud && selectedOrganization.cloud_sync)) ? 2 : 8 }}
>
{`${
isPaidPlan ? "Next billing: " : "App runs resets on "
}${new Date(
(localSub.enddate || localSub.Enddate) * 1000
).toLocaleDateString(undefined, {
day: "2-digit",
month: "short",
year: "numeric",
})}`}
</Typography>
) : null}
{localSub.cancellationdate !== 0 ? (
<Typography
variant="caption"
color="textSecondary"
style={{ marginTop: 2 }}
>
{`Cancelled on ${new Date(
(localSub.cancellationdate || localSub.CancellationDate) *
1000
).toLocaleDateString(undefined, {
day: "2-digit",
month: "short",
year: "numeric",
})}`}
</Typography>
) : null}
<Divider
style={{
marginTop: 12,
marginBottom: isCloud || (!isCloud && selectedOrganization.cloud_sync) ? 12 : 0,
borderColor: "#2f2f2f",
}}
/>
{
(isCloud || (!isCloud && selectedOrganization.cloud_sync)) && (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<Typography variant="body2" color="textSecondary" style={{}}>
App Runs
</Typography>
<div
style={{
display: "flex",
alignItems: "flex-start",
flexDirection: "column",
gap: 10,
}}
>
<Typography
variant="body1"
style={{ minWidth: 140, fontWeight: 600 }}
>
{usedAppRuns?.toLocaleString?.() || usedAppRuns} of{" "}
{appRunsLimit?.toLocaleString?.() || appRunsLimit}
</Typography>
<Box sx={{ width: "100%" }}>
<LinearProgress
variant="determinate"
value={appRunsPct}
sx={{
height: 6,
borderRadius: 6,
backgroundColor: "#3a3a3a",
"& .MuiLinearProgress-bar": {
backgroundColor: "#ff8544",
borderRadius: 6,
},
}}
/>
</Box>
</div>
</div>
)
}
<div style={{ marginTop: 16, marginBottom: 8 }}>
<Typography
variant="body2"
color="textSecondary"
style={{ marginBottom: 6 }}
>
Included:
</Typography>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{collapsed.map((feat, idx) => {
const depth = getFeatureIndent(feat);
const label = stripPrefix(feat);
return (
<div
key={`feat_${idx}`}
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginLeft: depth * 16,
}}
>
{depth === 0 ? (
<DoneIcon style={{ color: "#9be39b", fontSize: 18 }} />
) : (
<span
style={{
width: 6,
height: 6,
background: "#9be39b",
borderRadius: 999,
display: "inline-block",
}}
/>
)}
<Typography variant="body2">{label}</Typography>
</div>
);
})}
</div>
{visibleFeatures.length > 6 && (
<Button
onClick={() => setShowAllFeatures(!showAllFeatures)}
variant="text"
disableElevation
disableRipple
sx={{
textTransform: "none",
padding: 0,
marginTop: 1,
minWidth: 0,
boxShadow: "none",
background: "transparent",
textDecoration: "underline",
textUnderlineOffset: "2px",
fontFamily: theme?.palette?.fontFamily,
"&:hover": {
background: "transparent",
textDecoration: "underline",
textUnderlineOffset: "4px",
color: theme?.palette?.primary?.main,
},
}}
color="primary"
>
{showAllFeatures ? "Show less" : "See all..."}
</Button>
)}
</div>
<div
style={{
display: "flex",
flexDirection: "column",
gap: 10,
marginTop: 14,
}}
>
{isCloud &&
localSub.name.toLowerCase().includes("scale") &&
localSub?.reference &&
localSub.reference.length > 0 ? (
<Button
fullWidth
variant="outlined"
color="secondary"
onClick={() => {
const url = `${globalUrl}/api/v1/orgs/${selectedOrganization.id}/manage_subscription`;
fetch(url, {
method: "GET",
credentials: "include",
headers: { "Content-Type": "application/json" },
})
.then((r) => r.json())
.then((data) => {
if (data && data.success && data.url) {
window.location.href = data.url;
} else {
toast("Failed to open subscription portal");
}
})
.catch(() => {
toast("Failed to open subscription portal");
});
}}
>
Manage subscription
</Button>
) : null}
{!isPaidPlan && (
<Button
fullWidth
variant="outlined"
color="primary"
style={{ textTransform: "none" }}
onClick={() => {
if(isCloud) {
navigate("/pricing");
}else {
window.open("https://shuffler.io/pricing?env=Self-Hosted", "_blank")
}
}}
>
Upgrade Plan
</Button>
)}
<Button
fullWidth
variant="outlined"
color="primary"
onClick={() => {
if(isCloud) {
navigate("/contact?category=contact")
}else {
window.open("https://shuffler.io/contact?category=contact", "_blank")
}
}}
style={{ textTransform: "none" }}
>
Contact Us
</Button>
{userdata.support && (
<Button
fullWidth
variant="outlined"
color="secondary"
style={{ textTransform: "none" }}
onClick={() => {
setEditOpen(true);
}}
>
Edit subscription
</Button>
)}
</div>
</div>
</div>
</>
);
};
return (
<div>
<Grid
container
spacing={2}
style={{
flexDirection: "row",
flexWrap: "nowrap",
borderRadius: "16px",
display: "flex",
}}
>
<Grid item maxWidth={licensePopup ? 400 : 450}>
{isLoading ? (
<SubscriptionSkeleton />
) : (
<>
{selectedOrganization.subscriptions !== undefined &&
selectedOrganization.subscriptions !== null &&
selectedOrganization.subscriptions.length > 0
? (selectedOrganization.subscriptions || [])
.slice()
.map((sub, index) => {
return (
<SubscriptionObject
key={sub.id || index}
index={index + 1}
globalUrl={globalUrl}
userdata={userdata}
serverside={serverside}
billingInfo={billingInfo}
stripeKey={stripeKey}
selectedOrganization={selectedOrganization}
subscription={sub}
handleGetOrg={handleGetOrg}
highlight={true}
isLoading={false}
/>
);
})
: null}
</>
)}
</Grid>
</Grid>
</div>
);
};
export default LicencePopup;