V2 Payment Features (#4974)

* Added ability to add seats to enterprise
* first logged in date on people page
* Remove Premium config section				
* Cleanup add seat flow					
* Shrink numbers in plan 					
* Make editing text a server feature in the highlights
* default to dollar pricing				
* clear checkout logic when crash				
* Recongnise location and find pricing			
* Payment successful page

---------

Co-authored-by: Connor Yoh <[email protected]>
This commit is contained in:
ConnorYoh
2025-11-24 16:38:07 +00:00
committed by GitHub
co-authored by Connor Yoh
parent 050408639b
commit 5d18184e46
37 changed files with 2321 additions and 685 deletions
@@ -9,7 +9,6 @@ import AdminPrivacySection from '@app/components/shared/config/configSections/Ad
import AdminDatabaseSection from '@app/components/shared/config/configSections/AdminDatabaseSection';
import AdminAdvancedSection from '@app/components/shared/config/configSections/AdminAdvancedSection';
import AdminLegalSection from '@app/components/shared/config/configSections/AdminLegalSection';
import AdminPremiumSection from '@app/components/shared/config/configSections/AdminPremiumSection';
import AdminPlanSection from '@app/components/shared/config/configSections/AdminPlanSection';
import AdminFeaturesSection from '@app/components/shared/config/configSections/AdminFeaturesSection';
import AdminEndpointsSection from '@app/components/shared/config/configSections/AdminEndpointsSection';
@@ -129,14 +128,6 @@ export const createConfigNavSections = (
sections.push({
title: 'Licensing & Analytics',
items: [
{
key: 'adminPremium',
label: 'Premium',
icon: 'star-rounded',
component: <AdminPremiumSection />,
disabled: requiresLogin,
disabledTooltip: requiresLogin ? 'Enable login mode first' : undefined
},
{
key: 'adminPlan',
label: 'Plan',
@@ -1,23 +1,25 @@
import React, { useState, useCallback, useEffect } from 'react';
import { Divider, Loader, Alert, Select, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core';
import { Divider, Loader, Alert, Group, Text, Collapse, Button, TextInput, Stack, Paper } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { usePlans } from '@app/hooks/usePlans';
import licenseService, { PlanTierGroup } from '@app/services/licenseService';
import licenseService, { PlanTierGroup, mapLicenseToTier } from '@app/services/licenseService';
import { useCheckout } from '@app/contexts/CheckoutContext';
import { useLicense } from '@app/contexts/LicenseContext';
import AvailablePlansSection from '@app/components/shared/config/configSections/plan/AvailablePlansSection';
import StaticPlanSection from '@app/components/shared/config/configSections/plan/StaticPlanSection';
import { alert } from '@app/components/toast';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
import { ManageBillingButton } from '@app/components/shared/ManageBillingButton';
import { isSupabaseConfigured } from '@app/services/supabaseClient';
import { getPreferredCurrency, setCachedCurrency } from '@app/utils/currencyDetection';
const AdminPlanSection: React.FC = () => {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const { openCheckout } = useCheckout();
const { licenseInfo, refetchLicense } = useLicense();
const [currency, setCurrency] = useState<string>('gbp');
const [currency, setCurrency] = useState<string>(() => {
// Initialize with auto-detected currency on first render
return getPreferredCurrency(i18n.language);
});
const [useStaticVersion, setUseStaticVersion] = useState(false);
const [showLicenseKey, setShowLicenseKey] = useState(false);
const [licenseKeyInput, setLicenseKeyInput] = useState<string>('');
@@ -80,6 +82,36 @@ const AdminPlanSection: React.FC = () => {
{ value: 'idr', label: 'Indonesian rupiah (IDR, Rp)' },
];
const handleManageClick = useCallback(async () => {
try {
if (!licenseInfo?.licenseKey) {
throw new Error('No license key found. Please activate a license first.');
}
// Create billing portal session with license key
const response = await licenseService.createBillingPortalSession(
window.location.href,
licenseInfo.licenseKey
);
// Open billing portal in new tab
window.open(response.url, '_blank');
} catch (error: any) {
console.error('Failed to open billing portal:', error);
alert({
alertType: 'error',
title: t('billing.portal.error', 'Failed to open billing portal'),
body: error.message || 'Please try again or contact support.',
});
}
}, [licenseInfo, t]);
const handleCurrencyChange = useCallback((newCurrency: string) => {
setCurrency(newCurrency);
// Persist user's manual selection to localStorage
setCachedCurrency(newCurrency);
}, []);
const handleUpgradeClick = useCallback(
(planGroup: PlanTierGroup) => {
// Only allow upgrades for server and enterprise tiers
@@ -87,6 +119,20 @@ const AdminPlanSection: React.FC = () => {
return;
}
// Prevent free tier users from directly accessing enterprise (must have server first)
const currentTier = mapLicenseToTier(licenseInfo);
if (currentTier === 'free' && planGroup.tier === 'enterprise') {
alert({
alertType: 'warning',
title: t('plan.enterprise.requiresServer', 'Server Plan Required'),
body: t(
'plan.enterprise.requiresServerMessage',
'Please upgrade to the Server plan first before upgrading to Enterprise.'
),
});
return;
}
// Use checkout context to open checkout modal
openCheckout(planGroup.tier, {
currency,
@@ -97,7 +143,7 @@ const AdminPlanSection: React.FC = () => {
},
});
},
[openCheckout, currency, refetch]
[openCheckout, currency, refetch, licenseInfo, t]
);
// Show static version if Stripe is not configured or there's an error
@@ -129,40 +175,14 @@ const AdminPlanSection: React.FC = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '2rem' }}>
{/* Currency Selection & Manage Subscription */}
<Paper withBorder p="md" radius="md">
<Stack gap="md">
<Group justify="space-between" align="center">
<Text size="lg" fw={600}>
{t('plan.currency', 'Currency')}
</Text>
<Select
value={currency}
onChange={(value) => setCurrency(value || 'gbp')}
data={currencyOptions}
searchable
clearable={false}
w={300}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
</Group>
{/* Manage Subscription Button - Only show if user has active license and Supabase is configured */}
{licenseInfo?.licenseKey && isSupabaseConfigured && (
<Group justify="space-between" align="center">
<Text size="sm" c="dimmed">
{t('plan.manageSubscription.description', 'Manage your subscription, billing, and payment methods')}
</Text>
<ManageBillingButton />
</Group>
)}
</Stack>
</Paper>
<AvailablePlansSection
plans={plans}
currentLicenseInfo={licenseInfo}
onUpgradeClick={handleUpgradeClick}
onManageClick={handleManageClick}
currency={currency}
onCurrencyChange={handleCurrencyChange}
currencyOptions={currencyOptions}
/>
<Divider />
@@ -27,11 +27,14 @@ import { useAppConfig } from '@app/contexts/AppConfigContext';
import InviteMembersModal from '@app/components/shared/InviteMembersModal';
import { useLoginRequired } from '@app/hooks/useLoginRequired';
import LoginRequiredBanner from '@app/components/shared/config/LoginRequiredBanner';
import UpdateSeatsButton from '@app/components/shared/UpdateSeatsButton';
import { useLicense } from '@app/contexts/LicenseContext';
export default function PeopleSection() {
const { t } = useTranslation();
const { config } = useAppConfig();
const { loginEnabled } = useLoginRequired();
const { licenseInfo: globalLicenseInfo } = useLicense();
const [users, setUsers] = useState<User[]>([]);
const [teams, setTeams] = useState<Team[]>([]);
const [loading, setLoading] = useState(true);
@@ -342,6 +345,17 @@ export default function PeopleSection() {
+{licenseInfo.licenseMaxUsers} {t('workspace.people.license.fromLicense', 'from license')}
</Badge>
)}
{/* Enterprise Seat Management Button */}
{globalLicenseInfo?.licenseType === 'ENTERPRISE' && (
<>
<Text size="sm" c="dimmed" span></Text>
<UpdateSeatsButton
size="xs"
onSuccess={fetchData}
/>
</>
)}
</Group>
)}
@@ -494,9 +508,9 @@ export default function PeopleSection() {
<div>
<Text size="xs" fw={500}>Authentication: {user.authenticationType || 'Unknown'}</Text>
<Text size="xs">
Last Activity: {user.lastRequest
Last Activity: {user.lastRequest && new Date(user.lastRequest).getFullYear() >= 1980
? new Date(user.lastRequest).toLocaleString()
: 'Never'}
:t('never', 'Never')}
</Text>
</div>
}
@@ -1,21 +1,30 @@
import React, { useState, useMemo } from 'react';
import { Button, Collapse } from '@mantine/core';
import { Button, Collapse, Select, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import licenseService, { PlanTier, PlanTierGroup, LicenseInfo, mapLicenseToTier } from '@app/services/licenseService';
import PlanCard from '@app/components/shared/config/configSections/plan/PlanCard';
import FeatureComparisonTable from '@app/components/shared/config/configSections/plan/FeatureComparisonTable';
import { Z_INDEX_OVER_CONFIG_MODAL } from '@app/styles/zIndex';
interface AvailablePlansSectionProps {
plans: PlanTier[];
currentPlanId?: string;
currentLicenseInfo?: LicenseInfo | null;
onUpgradeClick: (planGroup: PlanTierGroup) => void;
onManageClick?: () => void;
currency?: string;
onCurrencyChange?: (currency: string) => void;
currencyOptions?: { value: string; label: string }[];
}
const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
plans,
currentLicenseInfo,
onUpgradeClick,
onManageClick,
currency,
onCurrencyChange,
currencyOptions,
}) => {
const { t } = useTranslation();
const [showComparison, setShowComparison] = useState(false);
@@ -58,25 +67,40 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
return (
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.availablePlans.title', 'Available Plans')}
</h3>
<p
style={{
margin: '0.25rem 0 1rem 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
}}
>
{t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')}
</p>
<Group justify="space-between" align="flex-start" mb="xs">
<div>
<h3 style={{ margin: 0, color: 'var(--mantine-color-text)', fontSize: '1rem' }}>
{t('plan.availablePlans.title', 'Available Plans')}
</h3>
<p
style={{
margin: '0.25rem 0 0 0',
color: 'var(--mantine-color-dimmed)',
fontSize: '0.875rem',
}}
>
{t('plan.availablePlans.subtitle', 'Choose the plan that fits your needs')}
</p>
</div>
{currency && onCurrencyChange && currencyOptions && (
<Select
value={currency}
onChange={(value) => onCurrencyChange(value || 'usd')}
data={currencyOptions}
searchable
clearable={false}
w={300}
comboboxProps={{ withinPortal: true, zIndex: Z_INDEX_OVER_CONFIG_MODAL }}
/>
)}
</Group>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '1rem',
marginBottom: '1rem',
marginBottom: '0.5rem',
}}
>
{groupedPlans.map((group) => (
@@ -86,7 +110,9 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
isCurrentTier={isCurrentTier(group)}
isDowngrade={isDowngrade(group)}
currentLicenseInfo={currentLicenseInfo}
currentTier={currentTier}
onUpgradeClick={onUpgradeClick}
onManageClick={onManageClick}
/>
))}
</div>
@@ -100,7 +126,7 @@ const AvailablePlansSection: React.FC<AvailablePlansSectionProps> = ({
</div>
<Collapse in={showComparison}>
<FeatureComparisonTable plans={groupedPlans} />
<FeatureComparisonTable plans={groupedPlans} currentTier={currentTier} />
</Collapse>
</div>
);
@@ -12,9 +12,10 @@ interface PlanWithFeatures {
interface FeatureComparisonTableProps {
plans: PlanWithFeatures[];
currentTier?: 'free' | 'server' | 'enterprise' | null;
}
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans }) => {
const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans, currentTier }) => {
const { t } = useTranslation();
return (
@@ -41,7 +42,7 @@ const FeatureComparisonTable: React.FC<FeatureComparisonTableProps> = ({ plans }
}}
>
{plan.name}
{plan.popular && (
{plan.popular && !(plan.tier === 'server' && currentTier === 'enterprise') && (
<Badge
color="blue"
variant="filled"
@@ -1,44 +1,42 @@
import React from 'react';
import { Button, Card, Badge, Text, Stack, Divider } from '@mantine/core';
import { Button, Card, Text, Stack, Divider, Tooltip } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { PlanTierGroup, LicenseInfo } from '@app/services/licenseService';
import { PricingBadge } from '@app/components/shared/stripeCheckout/components/PricingBadge';
import { PriceDisplay } from '@app/components/shared/stripeCheckout/components/PriceDisplay';
import { calculateDisplayPricing } from '@app/components/shared/stripeCheckout/utils/pricingUtils';
import { getBaseCardStyle } from '@app/components/shared/stripeCheckout/utils/cardStyles';
interface PlanCardProps {
planGroup: PlanTierGroup;
isCurrentTier: boolean;
isDowngrade: boolean;
currentLicenseInfo?: LicenseInfo | null;
currentTier?: 'free' | 'server' | 'enterprise' | null;
onUpgradeClick: (planGroup: PlanTierGroup) => void;
onManageClick?: () => void;
}
const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, onUpgradeClick }) => {
const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngrade, currentLicenseInfo, currentTier, onUpgradeClick, onManageClick }) => {
const { t } = useTranslation();
// Render Free plan
if (planGroup.tier === 'free') {
// Get currency from the free plan
const freeCurrency = planGroup.monthly?.currency || '$';
return (
<Card
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
minHeight: '400px',
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: isCurrentTier ? '2px' : undefined,
}}
style={getBaseCardStyle(isCurrentTier)}
>
{isCurrentTier && (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
<PricingBadge
type="current"
label={t('plan.current', 'Current Plan')}
/>
)}
<Stack gap="md" style={{ height: '100%' }}>
<div>
@@ -48,12 +46,12 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
<Text size="xs" c="dimmed" mb="xs" style={{ opacity: 0 }}>
{t('plan.from', 'From')}
</Text>
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
£0
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t('plan.free.forever', 'Forever free')}
</Text>
<PriceDisplay
mode="simple"
price={0}
currency={freeCurrency}
period={t('plan.free.forever', 'Forever free')}
/>
</div>
<Divider />
@@ -82,48 +80,32 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
const { monthly, yearly } = planGroup;
const isEnterprise = planGroup.tier === 'enterprise';
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
let displayPrice = monthly?.price || 0;
let displaySeatPrice = monthly?.seatPrice;
let displayCurrency = monthly?.currency || '£';
// Block enterprise for free tier users (must have server first)
const isEnterpriseBlockedForFree = isEnterprise && currentTier === 'free';
if (yearly) {
displayPrice = Math.round(yearly.price / 12);
displaySeatPrice = yearly.seatPrice ? Math.round(yearly.seatPrice / 12) : undefined;
displayCurrency = yearly.currency;
}
// Calculate "From" pricing - show yearly price divided by 12 for lowest monthly equivalent
const { displayPrice, displaySeatPrice, displayCurrency } = calculateDisplayPricing(
monthly || undefined,
yearly || undefined
);
return (
<Card
padding="lg"
radius="md"
withBorder
style={{
position: 'relative',
display: 'flex',
flexDirection: 'column',
minHeight: '400px',
borderColor: isCurrentTier ? 'var(--mantine-color-green-6)' : undefined,
borderWidth: isCurrentTier ? '2px' : undefined,
}}
style={getBaseCardStyle(isCurrentTier)}
>
{isCurrentTier ? (
<Badge
color="green"
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.current', 'Current Plan')}
</Badge>
) : planGroup.popular ? (
<Badge
variant="filled"
size="sm"
style={{ position: 'absolute', top: '1rem', right: '1rem' }}
>
{t('plan.popular', 'Popular')}
</Badge>
<PricingBadge
type="current"
label={t('plan.current', 'Current Plan')}
/>
) : planGroup.popular && !(planGroup.tier === 'server' && currentTier === 'enterprise') ? (
<PricingBadge
type="popular"
label={t('plan.popular', 'Popular')}
/>
) : null}
<Stack gap="md" style={{ height: '100%' }}>
@@ -140,29 +122,23 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
{/* Price */}
{isEnterprise && displaySeatPrice !== undefined ? (
<>
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
{displayCurrency}{displayPrice}
<Text span size="2.25rem" fw={600} style={{ lineHeight: 1 }}>
{displayCurrency}{displaySeatPrice.toFixed(2)}
</Text>
<Text span size="1.5rem" c="dimmed" mt="xs">
{t('plan.perSeat', '/seat')}
</Text>
<Text size="sm" c="dimmed" mt="xs">
+ {displayCurrency}{displaySeatPrice}/seat {t('plan.perMonth', '/month')}
{t('plan.perMonth', '/month')} {t('plan.withServer', '+ Server Plan')}
</Text>
</>
) : (
<>
<Text size="2.5rem" fw={700} style={{ lineHeight: 1 }}>
{displayCurrency}{displayPrice}
</Text>
<Text size="sm" c="dimmed" mt="xs">
{t('plan.perMonth', '/month')}
</Text>
</>
)}
{/* Show seat count for enterprise plans when current */}
{isEnterprise && isCurrentTier && currentLicenseInfo && currentLicenseInfo.maxUsers > 0 && (
<Text size="sm" c="green" fw={500} mt="xs">
{t('plan.licensedSeats', 'Licensed: {{count}} seats', { count: currentLicenseInfo.maxUsers })}
</Text>
<PriceDisplay
mode="simple"
price={displayPrice}
currency={displayCurrency}
period={t('plan.perMonth', '/month')}
/>
)}
</div>
@@ -179,21 +155,40 @@ const PlanCard: React.FC<PlanCardProps> = ({ planGroup, isCurrentTier, isDowngra
<div style={{ flexGrow: 1 }} />
<Stack gap="xs">
{/* Show seat count for enterprise plans when current */}
{isEnterprise && isCurrentTier && currentLicenseInfo && currentLicenseInfo.maxUsers > 0 && (
<Text size="sm" c="green" fw={500} ta="center">
{t('plan.licensedSeats', 'Licensed: {{count}} seats', { count: currentLicenseInfo.maxUsers })}
</Text>
)}
{/* Single Upgrade Button */}
<Button
variant={isCurrentTier || isDowngrade ? 'light' : 'filled'}
fullWidth
onClick={() => onUpgradeClick(planGroup)}
disabled={isCurrentTier || isDowngrade}
<Tooltip
label={t('plan.enterprise.requiresServer', 'Requires Server plan')}
disabled={!isEnterpriseBlockedForFree}
position="top"
withArrow
>
{isCurrentTier
? t('plan.current', 'Current Plan')
: isDowngrade
? t('plan.includedInCurrent', 'Included in Your Plan')
: isEnterprise
? t('plan.selectPlan', 'Select Plan')
: t('plan.upgrade', 'Upgrade')}
</Button>
<Button
variant={isCurrentTier ? 'filled' : isDowngrade ? 'filled' : isEnterpriseBlockedForFree ? 'light' : 'filled'}
fullWidth
onClick={() => isCurrentTier && onManageClick ? onManageClick() : onUpgradeClick(planGroup)}
disabled={isDowngrade || isEnterpriseBlockedForFree}
>
{isCurrentTier
? t('plan.manage', 'Manage')
: isDowngrade
? t('plan.free.included', 'Included')
: isEnterpriseBlockedForFree
? t('plan.enterprise.requiresServer', 'Requires Server')
: isEnterprise
? t('plan.selectPlan', 'Select Plan')
: t('plan.upgrade', 'Upgrade')}
</Button>
</Tooltip>
</Stack>
</Stack>
</Card>
);