Add Activepieces integration for workflow automation
- Add Activepieces fork with SmoothSchedule custom piece - Create integrations app with Activepieces service layer - Add embed token endpoint for iframe integration - Create Automations page with embedded workflow builder - Add sidebar visibility fix for embed mode - Add list inactive customers endpoint to Public API - Include SmoothSchedule triggers: event created/updated/cancelled - Include SmoothSchedule actions: create/update/cancel events, list resources/services/customers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import { typeboxResolver } from '@hookform/resolvers/typebox';
|
||||
import { Static, Type } from '@sinclair/typebox';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import { useForm } from 'react-hook-form';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Form, FormField, FormItem, FormMessage } from '@/components/ui/form';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { LoadingSpinner } from '@/components/ui/spinner';
|
||||
import { platformHooks } from '@/hooks/platform-hooks';
|
||||
|
||||
const LicenseKeySchema = Type.Object({
|
||||
tempLicenseKey: Type.String({
|
||||
errorMessage: t('License key is invalid'),
|
||||
}),
|
||||
});
|
||||
|
||||
type LicenseKeySchema = Static<typeof LicenseKeySchema>;
|
||||
|
||||
interface ActivateLicenseDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ActivateLicenseDialog = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: ActivateLicenseDialogProps) => {
|
||||
const queryClinet = useQueryClient();
|
||||
|
||||
const form = useForm<LicenseKeySchema>({
|
||||
resolver: typeboxResolver(LicenseKeySchema),
|
||||
defaultValues: {
|
||||
tempLicenseKey: '',
|
||||
},
|
||||
mode: 'onChange',
|
||||
});
|
||||
|
||||
const { mutate: activateLicenseKey, isPending } =
|
||||
platformHooks.useUpdateLisenceKey(queryClinet);
|
||||
|
||||
const handleSubmit = (data: LicenseKeySchema) => {
|
||||
form.clearErrors();
|
||||
activateLicenseKey(data.tempLicenseKey, {
|
||||
onSuccess: () => handleClose(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset();
|
||||
form.clearErrors();
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('Activate License Key')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tempLicenseKey"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Input
|
||||
{...field}
|
||||
required
|
||||
type="text"
|
||||
placeholder={t('Enter your license key')}
|
||||
disabled={isPending}
|
||||
/>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form?.formState?.errors?.root?.serverError && (
|
||||
<FormMessage>
|
||||
{form.formState.errors.root.serverError.message}
|
||||
</FormMessage>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isPending}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<Button
|
||||
onClick={form.handleSubmit(handleSubmit)}
|
||||
disabled={isPending || !form.watch('tempLicenseKey')?.trim()}
|
||||
className="min-w-20"
|
||||
>
|
||||
{isPending ? <LoadingSpinner className="size-4" /> : t('Activate')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
import { t } from 'i18next';
|
||||
import { CircleHelp, Plus, Zap } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { flagsHooks } from '@/hooks/flags-hooks';
|
||||
import { PRICE_PER_EXTRA_ACTIVE_FLOWS } from '@activepieces/ee-shared';
|
||||
import {
|
||||
ApEdition,
|
||||
ApFlagId,
|
||||
isNil,
|
||||
PlanName,
|
||||
PlatformBillingInformation,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { useManagePlanDialogStore } from '../../lib/active-flows-addon-dialog-state';
|
||||
|
||||
type BusinessActiveFlowsProps = {
|
||||
platformSubscription: PlatformBillingInformation;
|
||||
};
|
||||
|
||||
export function ActiveFlowAddon({
|
||||
platformSubscription,
|
||||
}: BusinessActiveFlowsProps) {
|
||||
const { openDialog } = useManagePlanDialogStore();
|
||||
|
||||
const { plan, usage } = platformSubscription;
|
||||
const currentActiveFlows = usage.activeFlows || 0;
|
||||
|
||||
const { data: edition } = flagsHooks.useFlag<ApEdition>(ApFlagId.EDITION);
|
||||
const canManageActiveFlowsLimit =
|
||||
edition !== ApEdition.COMMUNITY && plan.plan === PlanName.STANDARD;
|
||||
|
||||
const activeFlowsLimit = plan.activeFlowsLimit;
|
||||
const usagePercentage =
|
||||
!isNil(activeFlowsLimit) && activeFlowsLimit > 0
|
||||
? Math.round((currentActiveFlows / activeFlowsLimit) * 100)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg border">
|
||||
<Zap className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t('Active Flows')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('Monitor your active flows usage')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{canManageActiveFlowsLimit && (
|
||||
<Button
|
||||
variant="default"
|
||||
className="gap-2"
|
||||
onClick={() => {
|
||||
openDialog();
|
||||
}}
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
{t('Manage Active Flows')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-base font-medium">{t('Active Flows Usage')}</h4>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<CircleHelp className="w-4 h-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{t(
|
||||
`Count of active flows, $${PRICE_PER_EXTRA_ACTIVE_FLOWS} for extra 5 active flows`,
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<div className="rounded-lg space-y-3">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{currentActiveFlows.toLocaleString()} /{' '}
|
||||
{isNil(activeFlowsLimit)
|
||||
? 'Unlimited'
|
||||
: activeFlowsLimit.toLocaleString()}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t('Plan Limit')}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={usagePercentage} className="w-full" />
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{usagePercentage}% of plan allocation used
|
||||
</span>
|
||||
{usagePercentage > 80 && (
|
||||
<span className="text-destructive font-medium">
|
||||
Approaching limit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { t } from 'i18next';
|
||||
import { Zap, Info, Loader2 } from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Slider } from '@/components/ui/slider';
|
||||
import { platformHooks } from '@/hooks/platform-hooks';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
ApSubscriptionStatus,
|
||||
PRICE_PER_EXTRA_ACTIVE_FLOWS,
|
||||
} from '@activepieces/ee-shared';
|
||||
import { PlatformPlan } from '@activepieces/shared';
|
||||
|
||||
import { useManagePlanDialogStore } from '../../lib/active-flows-addon-dialog-state';
|
||||
import { billingMutations, billingQueries } from '../../lib/billing-hooks';
|
||||
|
||||
export function PurchaseExtraFlowsDialog() {
|
||||
const { closeDialog, isOpen } = useManagePlanDialogStore();
|
||||
const { platform } = platformHooks.useCurrentPlatform();
|
||||
const { data: platformPlanInfo, isLoading: isPlatformSubscriptionLoading } =
|
||||
billingQueries.usePlatformSubscription(platform.id);
|
||||
|
||||
const activeFlowsUsage = platformPlanInfo?.usage?.activeFlows ?? 0;
|
||||
const activeFlowsLimit = platformPlanInfo?.plan.activeFlowsLimit ?? 0;
|
||||
const platformPlan = platformPlanInfo?.plan as PlatformPlan;
|
||||
|
||||
const [selectedLimit, setSelectedLimit] = useState(activeFlowsLimit);
|
||||
|
||||
const flowPrice = PRICE_PER_EXTRA_ACTIVE_FLOWS;
|
||||
const maxFlows = 100;
|
||||
const baseActiveFlows = 10;
|
||||
|
||||
const isUpgrade = selectedLimit > activeFlowsLimit;
|
||||
const isSame = selectedLimit === activeFlowsLimit;
|
||||
const isDowngrade = selectedLimit < activeFlowsLimit;
|
||||
|
||||
const difference = Math.abs(selectedLimit - activeFlowsLimit);
|
||||
|
||||
const calculatePaidFlows = (limit: number) =>
|
||||
Math.max(0, limit - baseActiveFlows);
|
||||
const currentPaidFlows = calculatePaidFlows(activeFlowsLimit);
|
||||
const newPaidFlows = calculatePaidFlows(selectedLimit);
|
||||
|
||||
const currentCost = currentPaidFlows * flowPrice;
|
||||
const additionalCost = isUpgrade
|
||||
? (newPaidFlows - currentPaidFlows) * flowPrice
|
||||
: 0;
|
||||
const newTotalCost = newPaidFlows * flowPrice;
|
||||
|
||||
const {
|
||||
mutate: updateActiveFlowsLimit,
|
||||
isPending: isUpdateActiveFlowsLimitPending,
|
||||
} = billingMutations.useUpdateActiveFlowsLimit(() => closeDialog());
|
||||
const {
|
||||
mutate: createSubscription,
|
||||
isPending: isCreatingSubscriptionPending,
|
||||
} = billingMutations.useCreateSubscription(() => closeDialog());
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedLimit(activeFlowsLimit);
|
||||
}, [isOpen]);
|
||||
|
||||
const isLoading =
|
||||
isUpdateActiveFlowsLimitPending || isCreatingSubscriptionPending;
|
||||
|
||||
const handlePurchase = () => {
|
||||
if (!isSame) {
|
||||
if (
|
||||
platformPlan.stripeSubscriptionStatus !== ApSubscriptionStatus.ACTIVE
|
||||
) {
|
||||
createSubscription({ newActiveFlowsLimit: selectedLimit });
|
||||
} else {
|
||||
updateActiveFlowsLimit({ newActiveFlowsLimit: selectedLimit });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = () =>
|
||||
dayjs(
|
||||
dayjs.unix(platformPlan.stripeSubscriptionEndDate!).toISOString(),
|
||||
).format('MMM D, YYYY');
|
||||
|
||||
if (isPlatformSubscriptionLoading) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && closeDialog()}>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
'max-w-[480px] transition-all border duration-300 ease-in-out',
|
||||
)}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||
{t('Purchase Extra Active Flows')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
'Currently using {activeFlowsUsage} of {activeFlowsLimit} flows',
|
||||
{ activeFlowsUsage, activeFlowsLimit },
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between text-sm font-medium">
|
||||
<span>{t('Select your new limit')}</span>
|
||||
<span className="text-primary font-semibold">
|
||||
{t('{selectedLimit} flows', { selectedLimit })}
|
||||
</span>
|
||||
</div>
|
||||
<Slider
|
||||
value={[selectedLimit]}
|
||||
onValueChange={(v) => setSelectedLimit(v[0])}
|
||||
min={baseActiveFlows}
|
||||
max={maxFlows}
|
||||
step={1}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{baseActiveFlows}</span>
|
||||
<span>{maxFlows}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4 transition-all duration-300 ease-in-out',
|
||||
isUpgrade
|
||||
? 'bg-primary/5 border-primary/30'
|
||||
: isDowngrade
|
||||
? 'bg-amber-50 border-amber-200'
|
||||
: 'bg-muted/40 border-border',
|
||||
)}
|
||||
>
|
||||
{isUpgrade && (
|
||||
<div className="space-y-3 animate-in fade-in duration-300">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t('Current limit')}
|
||||
</span>
|
||||
<span>
|
||||
{t('{activeFlowsLimit} flows', { activeFlowsLimit })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t('Current cost')}
|
||||
</span>
|
||||
<span>
|
||||
{t('${currentCost}/mo', {
|
||||
currentCost: currentCost.toFixed(2),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t('Additional flows')}
|
||||
</span>
|
||||
<span className="text-primary font-medium">
|
||||
{t('+{difference}', { difference })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{t('Additional cost')}
|
||||
</span>
|
||||
<span className="text-primary font-medium">
|
||||
{t('+${additionalCost}/mo', {
|
||||
additionalCost: additionalCost.toFixed(2),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
<div className="flex justify-between text-sm font-medium">
|
||||
<span>{t('New total')}</span>
|
||||
<span>{t('{selectedLimit} flows', { selectedLimit })}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="text-sm font-medium">
|
||||
{t('New monthly cost')}
|
||||
</span>
|
||||
<span className="text-xl font-bold text-primary">
|
||||
{t('${newTotalCost}/mo', {
|
||||
newTotalCost: newTotalCost.toFixed(2),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-border" />
|
||||
|
||||
<div className="flex justify-between items-baseline">
|
||||
<span className="text-sm font-semibold">
|
||||
{t('Due today')}
|
||||
</span>
|
||||
<span className="text-2xl font-bold text-primary">
|
||||
{t('${additionalCost}', {
|
||||
additionalCost: additionalCost.toFixed(2),
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isDowngrade && (
|
||||
<div className="space-y-3 animate-in fade-in duration-300">
|
||||
<div className="flex items-start text-sm gap-2">
|
||||
<Info className="w-4 h-4 mt-0.5 text-amber-500 shrink-0" />
|
||||
<div className="space-y-2">
|
||||
<p className="font-medium">
|
||||
{t(
|
||||
'New limit: {selectedLimit} flows (−{difference} flows)',
|
||||
{ selectedLimit, difference },
|
||||
)}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
{t('Change takes effect on {date}.', {
|
||||
date: formatDate(),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSame && (
|
||||
<div className="space-y-3 animate-in fade-in duration-300">
|
||||
<div className="flex items-start gap-2 text-sm text-muted-foreground">
|
||||
<Info className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium text-foreground mb-1">
|
||||
{t('No changes')}
|
||||
</p>
|
||||
<p>
|
||||
{t(
|
||||
'Your flow limit remains at {activeFlowsLimit} flows (${currentCost}/mo)',
|
||||
{
|
||||
activeFlowsLimit,
|
||||
currentCost: currentCost.toFixed(2),
|
||||
},
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => closeDialog()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handlePurchase}
|
||||
className="gap-2"
|
||||
disabled={isSame || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
) : (
|
||||
<Zap className="w-4 h-4" />
|
||||
)}
|
||||
{isLoading
|
||||
? t('Processing...')
|
||||
: isUpgrade
|
||||
? t('Purchase +{difference} flows', { difference })
|
||||
: isDowngrade
|
||||
? t('Confirm Downgrade')
|
||||
: t('No Changes')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { t } from 'i18next';
|
||||
import { Sparkles, Info, Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { ApSubscriptionStatus } from '@activepieces/ee-shared';
|
||||
import {
|
||||
AiOverageState,
|
||||
PlatformBillingInformation,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
import { billingMutations } from '../lib/billing-hooks';
|
||||
|
||||
import { EnableAIOverageDialog } from './enable-ai-credits-overage';
|
||||
|
||||
interface AiCreditUsageProps {
|
||||
platformSubscription: PlatformBillingInformation;
|
||||
}
|
||||
|
||||
export function AICreditUsage({ platformSubscription }: AiCreditUsageProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const { plan, usage } = platformSubscription;
|
||||
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const planIncludedCredits = plan.includedAiCredits;
|
||||
const overageLimit = plan.aiCreditsOverageLimit;
|
||||
const totalCreditsUsed = usage.aiCredits;
|
||||
|
||||
const hasActiveSubscription =
|
||||
plan.stripeSubscriptionStatus === ApSubscriptionStatus.ACTIVE;
|
||||
const aiOverrageState =
|
||||
plan.aiCreditsOverageState ?? AiOverageState.NOT_ALLOWED;
|
||||
|
||||
const overageConfig = useMemo(() => {
|
||||
const isAllowed = aiOverrageState !== AiOverageState.NOT_ALLOWED;
|
||||
const isEnabled = aiOverrageState === AiOverageState.ALLOWED_AND_ON;
|
||||
|
||||
return {
|
||||
allowed: isAllowed,
|
||||
enabled: isEnabled,
|
||||
canToggle: isAllowed,
|
||||
};
|
||||
}, [aiOverrageState]);
|
||||
|
||||
const [usageBasedEnabled, setUsageBasedEnabled] = useState(
|
||||
overageConfig.enabled,
|
||||
);
|
||||
const [usageLimit, setUsageLimit] = useState<number>(overageLimit ?? 500);
|
||||
|
||||
const {
|
||||
mutate: setAiCreditOverageLimit,
|
||||
isPending: settingAiCreditsOverageLimit,
|
||||
} = billingMutations.useSetAiCreditOverageLimit(queryClient);
|
||||
|
||||
const {
|
||||
mutate: toggleAiCreditsOverageEnabled,
|
||||
isPending: togglingAiCreditsOverageEnabled,
|
||||
} = billingMutations.useToggleAiCreditOverageEnabled(queryClient);
|
||||
|
||||
const creditMetrics = useMemo(() => {
|
||||
const creditsUsedFromPlan = Math.min(totalCreditsUsed, planIncludedCredits);
|
||||
const overageCreditsUsed = Math.max(
|
||||
0,
|
||||
totalCreditsUsed - planIncludedCredits,
|
||||
);
|
||||
|
||||
const planUsagePercentage = Math.min(
|
||||
100,
|
||||
Math.round((creditsUsedFromPlan / planIncludedCredits) * 100),
|
||||
);
|
||||
|
||||
const overageUsagePercentage =
|
||||
usageBasedEnabled && overageLimit
|
||||
? Math.min(100, Math.round((overageCreditsUsed / overageLimit) * 100))
|
||||
: 0;
|
||||
|
||||
return {
|
||||
creditsUsedFromPlan,
|
||||
overageCreditsUsed,
|
||||
planUsagePercentage,
|
||||
overageUsagePercentage,
|
||||
isPlanLimitApproaching: planUsagePercentage > 80,
|
||||
isPlanLimitExceeded: totalCreditsUsed > planIncludedCredits,
|
||||
isOverageLimitApproaching: overageUsagePercentage > 80,
|
||||
};
|
||||
}, [totalCreditsUsed, planIncludedCredits, usageBasedEnabled, overageLimit]);
|
||||
|
||||
const handleSaveAiCreditUsageLimit = useCallback(() => {
|
||||
setAiCreditOverageLimit({ limit: usageLimit });
|
||||
}, [setAiCreditOverageLimit, usageLimit]);
|
||||
|
||||
const handleToggleAiCreditUsage = useCallback(() => {
|
||||
const newState = usageBasedEnabled
|
||||
? AiOverageState.ALLOWED_BUT_OFF
|
||||
: AiOverageState.ALLOWED_AND_ON;
|
||||
|
||||
if (!hasActiveSubscription) {
|
||||
setIsOpen(true);
|
||||
} else {
|
||||
toggleAiCreditsOverageEnabled(
|
||||
{ state: newState },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setUsageBasedEnabled(!usageBasedEnabled);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [usageBasedEnabled, toggleAiCreditsOverageEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
setUsageBasedEnabled(overageConfig.enabled);
|
||||
}, [overageConfig.enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
setUsageLimit(overageLimit ?? 500);
|
||||
}, [overageLimit]);
|
||||
|
||||
return (
|
||||
<Card className="w-full">
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg border">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t('AI Credits')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your AI usage and limits
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{overageConfig.canToggle && (
|
||||
<div className="flex items-center gap-3 py-2">
|
||||
<span className="text-sm font-medium">
|
||||
{t('Usage Based Billing')}
|
||||
</span>
|
||||
<Switch
|
||||
checked={usageBasedEnabled}
|
||||
disabled={togglingAiCreditsOverageEnabled}
|
||||
onCheckedChange={handleToggleAiCreditUsage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="p-6 space-y-10">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-base font-medium">{t('Plan Credits Usage')}</h4>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="w-4 h-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Credits reset monthly with your billing cycle
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg space-y-3">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{Math.round(creditMetrics.creditsUsedFromPlan)} /{' '}
|
||||
{planIncludedCredits}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t('Plan Included')}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={creditMetrics.planUsagePercentage}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{creditMetrics.planUsagePercentage}% of plan credits used
|
||||
</span>
|
||||
{creditMetrics.isPlanLimitApproaching &&
|
||||
!creditMetrics.isPlanLimitExceeded && (
|
||||
<span className="text-orange-600 font-medium">
|
||||
Approaching limit
|
||||
</span>
|
||||
)}
|
||||
{creditMetrics.isPlanLimitExceeded && (
|
||||
<span className="text-destructive font-medium">
|
||||
Plan limit exceeded
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{usageBasedEnabled && overageConfig.canToggle && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="text-base font-medium">
|
||||
{t('Additional Credits Usage')}
|
||||
</h4>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Info className="w-4 h-4 text-muted-foreground" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Credits used beyond your plan limit ($0.01 each)
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg space-y-3">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{creditMetrics.overageCreditsUsed} /{' '}
|
||||
{overageLimit ?? 'unknown'}
|
||||
</span>
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t('Usage Limit')}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={creditMetrics.overageUsagePercentage}
|
||||
className="w-full"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
{creditMetrics.overageUsagePercentage}% of usage limit used
|
||||
</span>
|
||||
{creditMetrics.isOverageLimitApproaching && (
|
||||
<span className="text-destructive font-medium">
|
||||
Approaching usage limit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h5 className="text-base font-medium mb-1">
|
||||
{t('Set Usage Limit')}
|
||||
</h5>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Set a maximum number of additional AI credits to prevent
|
||||
unexpected charges
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg space-y-4">
|
||||
<div className="flex items-end gap-3">
|
||||
<div className="flex-1 max-w-xs space-y-2">
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="Enter limit"
|
||||
value={usageLimit}
|
||||
onChange={(e) => setUsageLimit(Number(e.target.value))}
|
||||
className="w-full"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSaveAiCreditUsageLimit}
|
||||
disabled={settingAiCreditsOverageLimit}
|
||||
className="whitespace-nowrap"
|
||||
>
|
||||
{settingAiCreditsOverageLimit && (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
)}
|
||||
{t('Save Limit')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Recommended: Set 20-50% above your expected monthly overage
|
||||
usage
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-muted-foreground bg-muted/30 rounded-lg p-3">
|
||||
{t('$1 per 1000 additional credits beyond plan limit')}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
<EnableAIOverageDialog isOpen={isOpen} onOpenChange={setIsOpen} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { t } from 'i18next';
|
||||
import { Info } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
|
||||
import { billingMutations } from '../lib/billing-hooks';
|
||||
|
||||
interface EnableAIOverageDialogProps {
|
||||
isOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function EnableAIOverageDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
}: EnableAIOverageDialogProps) {
|
||||
const {
|
||||
mutate: createSubscription,
|
||||
isPending: isCreatingSubscriptionPending,
|
||||
} = billingMutations.useCreateSubscription(onOpenChange);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[420px] p-8 text-center">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="rounded-full bg-purple-50 p-4 mb-6">
|
||||
<Info className="w-10 h-10 text-primary" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-2xl font-semibold">
|
||||
{t('Start a Subscription')}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm max-w-sm">
|
||||
{t(
|
||||
'To enable AI credit overage and unlock advanced features, please start your subscription first.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col w-full gap-3">
|
||||
<Button
|
||||
onClick={() => createSubscription({ newActiveFlowsLimit: 0 })}
|
||||
disabled={isCreatingSubscriptionPending}
|
||||
loading={isCreatingSubscriptionPending}
|
||||
className="w-full"
|
||||
>
|
||||
{t('Start Subscription (Free)')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { t } from 'i18next';
|
||||
import { AlertCircle, RefreshCw, Home } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CardContent } from '@/components/ui/card';
|
||||
|
||||
export const Error = () => {
|
||||
const navigate = useNavigate();
|
||||
const [countdown, setCountdown] = useState(5);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
navigate('/platform/setup/billing');
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [navigate]);
|
||||
|
||||
return (
|
||||
<div className="h-full bg-background flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md border-destructive/20">
|
||||
<CardContent className="pt-8 pb-6 px-6">
|
||||
<div className="text-center space-y-6">
|
||||
<div className="mx-auto w-20 h-20 bg-destructive/10 rounded-full flex items-center justify-center">
|
||||
<AlertCircle className="w-10 h-10 text-destructive" />
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
{t('Something went wrong')}
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
{t('Subscription update failed')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 text-left">
|
||||
<h3 className="text-sm font-medium text-foreground mb-2">
|
||||
{t('What you can do:')}
|
||||
</h3>
|
||||
<ul className="text-sm text-muted-foreground space-y-1">
|
||||
<li>{t('Verify your payment method')}</li>
|
||||
<li>{t('Try again in a few moments')}</li>
|
||||
<li>{t('Contact support if issues persist')}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Button
|
||||
onClick={() => navigate('/platform/setup/billing')}
|
||||
className="w-full"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
{t('Try Again')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<Home className="w-4 h-4 mr-2" />
|
||||
{t('Go to Dashboard')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('Redirecting to billing in {countdown} seconds...', {
|
||||
countdown,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,115 @@
|
||||
import { t } from 'i18next';
|
||||
import { Check, Lock } from 'lucide-react';
|
||||
|
||||
import { StatusIconWithText } from '@/components/ui/status-icon-with-text';
|
||||
import {
|
||||
PlatformPlanLimits,
|
||||
PlatformWithoutSensitiveData,
|
||||
} from '@activepieces/shared';
|
||||
|
||||
const LICENSE_PROPS_MAP = {
|
||||
environmentsEnabled: {
|
||||
label: 'Team Collaboration via Git',
|
||||
description:
|
||||
'Work together on projects with version control and team features',
|
||||
},
|
||||
analyticsEnabled: {
|
||||
label: 'Analytics',
|
||||
description: 'View reports and insights about your workflow performance',
|
||||
},
|
||||
auditLogEnabled: {
|
||||
label: 'Audit Log',
|
||||
description: 'Track all changes and activities in your workspace',
|
||||
},
|
||||
embeddingEnabled: {
|
||||
label: 'Embedding',
|
||||
description: 'Add workflows directly into your website or application',
|
||||
},
|
||||
globalConnectionsEnabled: {
|
||||
label: 'Global Connections',
|
||||
description: 'Create centralized connections for your projects',
|
||||
},
|
||||
managePiecesEnabled: {
|
||||
label: 'Manage Pieces',
|
||||
description: 'Create and organize custom building blocks for workflows',
|
||||
},
|
||||
manageTemplatesEnabled: {
|
||||
label: 'Manage Templates',
|
||||
description: 'Save and share workflow templates across your team',
|
||||
},
|
||||
customAppearanceEnabled: {
|
||||
label: 'Brand Activepieces',
|
||||
description: 'Customize the look and feel with your company branding',
|
||||
},
|
||||
teamProjectsLimit: {
|
||||
label: 'Team Projects Limit',
|
||||
description: 'Control the number of projects your team can create',
|
||||
},
|
||||
projectRolesEnabled: {
|
||||
label: 'Project Roles',
|
||||
description: 'Control who can view, edit, or manage different projects',
|
||||
},
|
||||
customDomainsEnabled: {
|
||||
label: 'Custom Domains',
|
||||
description: 'Use your own web address instead of the default domain',
|
||||
},
|
||||
apiKeysEnabled: {
|
||||
label: 'API Keys',
|
||||
description: 'Connect external services and applications to your workflows',
|
||||
},
|
||||
ssoEnabled: {
|
||||
label: 'Single Sign On',
|
||||
description: 'Log in using your company account without separate passwords',
|
||||
},
|
||||
customRolesEnabled: {
|
||||
label: 'Custom Roles',
|
||||
description: 'Create and manage custom roles for your team',
|
||||
},
|
||||
};
|
||||
|
||||
export const FeatureStatus = ({
|
||||
platform,
|
||||
}: {
|
||||
platform: PlatformWithoutSensitiveData;
|
||||
}) => {
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
{Object.entries(LICENSE_PROPS_MAP)
|
||||
.sort(([aKey], [bKey]) => {
|
||||
const aEnabled = platform?.plan?.[aKey as keyof PlatformPlanLimits];
|
||||
const bEnabled = platform?.plan?.[bKey as keyof PlatformPlanLimits];
|
||||
return (aEnabled ? 0 : 1) - (bEnabled ? 0 : 1);
|
||||
})
|
||||
.map(([key, value]) => {
|
||||
const featureEnabled =
|
||||
platform?.plan?.[key as keyof PlatformPlanLimits];
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between p-3 rounded-lg bg-accent/50"
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-medium">{t(value.label)}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t(value.description)}
|
||||
</span>
|
||||
</div>
|
||||
{featureEnabled ? (
|
||||
<StatusIconWithText
|
||||
icon={Check}
|
||||
text="Enabled"
|
||||
variant="success"
|
||||
/>
|
||||
) : (
|
||||
<StatusIconWithText
|
||||
icon={Lock}
|
||||
text="Upgrade"
|
||||
variant="default"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { t } from 'i18next';
|
||||
import { Shield, AlertTriangle, Check, Zap } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { StatusIconWithText } from '@/components/ui/status-icon-with-text';
|
||||
import { formatUtils } from '@/lib/utils';
|
||||
import { isNil, PlatformWithoutSensitiveData } from '@activepieces/shared';
|
||||
|
||||
import { ActivateLicenseDialog } from './activate-license-dialog';
|
||||
import { FeatureStatus } from './features-status';
|
||||
|
||||
export const LicenseKey = ({
|
||||
platform,
|
||||
}: {
|
||||
platform: PlatformWithoutSensitiveData;
|
||||
}) => {
|
||||
const [isActivateLicenseKeyDialogOpen, setIsActivateLicenseKeyDialogOpen] =
|
||||
useState(false);
|
||||
|
||||
const expired =
|
||||
!isNil(platform?.plan?.licenseExpiresAt) &&
|
||||
dayjs(platform.plan.licenseExpiresAt).isBefore(dayjs());
|
||||
const expiresSoon =
|
||||
!expired &&
|
||||
!isNil(platform?.plan?.licenseExpiresAt) &&
|
||||
dayjs(platform.plan.licenseExpiresAt).isBefore(dayjs().add(7, 'day'));
|
||||
|
||||
const getStatusBadge = () => {
|
||||
if (expired) {
|
||||
return (
|
||||
<StatusIconWithText
|
||||
text={t('Expired')}
|
||||
icon={AlertTriangle}
|
||||
variant="error"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (expiresSoon) {
|
||||
return (
|
||||
<StatusIconWithText
|
||||
text={t('Expires soon')}
|
||||
icon={AlertTriangle}
|
||||
variant="default"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<StatusIconWithText text={t('Active')} icon={Check} variant="success" />
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="border-b">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center justify-center w-10 h-10 rounded-lg border">
|
||||
<Shield className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">{t('License Key')}</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('Activate your platform and unlock enterprise features')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setIsActivateLicenseKeyDialogOpen(true)}
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
{platform.plan.licenseKey
|
||||
? t('Update License')
|
||||
: t('Activate License')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-6 p-6">
|
||||
{platform.plan.licenseKey && (
|
||||
<div className="flex items-center justify-between p-4 bg-accent/50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t('License Active')}</p>
|
||||
{!isNil(platform.plan.licenseExpiresAt) && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('Valid until')}{' '}
|
||||
{formatUtils.formatDateOnly(
|
||||
dayjs(platform.plan.licenseExpiresAt).toDate(),
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{getStatusBadge()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-base font-semibold mb-4">
|
||||
{t('Enabled Features')}
|
||||
</h3>
|
||||
<FeatureStatus platform={platform} />
|
||||
</div>
|
||||
</CardContent>
|
||||
<ActivateLicenseDialog
|
||||
isOpen={isActivateLicenseKeyDialogOpen}
|
||||
onOpenChange={setIsActivateLicenseKeyDialogOpen}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
LicenseKey.displayName = 'LicenseKeys';
|
||||
@@ -0,0 +1,56 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { t } from 'i18next';
|
||||
import { CalendarDays } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { isNil, PlatformBillingInformation } from '@activepieces/shared';
|
||||
|
||||
type SubscriptionInfoProps = {
|
||||
info: PlatformBillingInformation;
|
||||
};
|
||||
|
||||
export const SubscriptionInfo = ({ info }: SubscriptionInfoProps) => {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Badge variant="accent" className="rounded-sm text-sm">
|
||||
{isNil(info.plan.plan)
|
||||
? t('Free')
|
||||
: info?.plan.plan.charAt(0).toUpperCase() + info?.plan.plan.slice(1)}
|
||||
</Badge>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<div className="text-5xl font-semibold">
|
||||
${info.nextBillingAmount || Number(0).toFixed(2)}
|
||||
</div>
|
||||
<div className="text-xl text-muted-foreground">{t('/month')}</div>
|
||||
</div>
|
||||
|
||||
{info?.nextBillingDate && isNil(info.cancelAt) && (
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<CalendarDays className="w-4 h-4" />
|
||||
<span>
|
||||
{t('Next billing date ')}
|
||||
<span className="font-semibold">
|
||||
{dayjs(dayjs.unix(info.nextBillingDate).toISOString()).format(
|
||||
'MMM D, YYYY',
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{info?.cancelAt && (
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2">
|
||||
<CalendarDays className="w-4 h-4" />
|
||||
<span>
|
||||
{t('Subscription will end')}{' '}
|
||||
<span className="font-semibold">
|
||||
{dayjs(dayjs.unix(info.cancelAt).toISOString()).format(
|
||||
'MMM D, YYYY',
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { t } from 'i18next';
|
||||
import { Check, TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CardContent } from '@/components/ui/card';
|
||||
|
||||
export const Success = () => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [countdown, setCountdown] = useState(5);
|
||||
|
||||
const action = searchParams.get('action') || '';
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => {
|
||||
if (prev <= 1) {
|
||||
navigate('/platform/setup/billing');
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [navigate]);
|
||||
|
||||
const getActionConfig = () => {
|
||||
switch (action) {
|
||||
case 'upgrade':
|
||||
return {
|
||||
icon: TrendingUp,
|
||||
iconBg: 'bg-emerald-50 dark:bg-emerald-950',
|
||||
iconColor: 'text-emerald-600 dark:text-emerald-400',
|
||||
title: t('Successfully Upgraded!'),
|
||||
description: t('Subscription updated successfully'),
|
||||
};
|
||||
case 'downgrade':
|
||||
return {
|
||||
icon: TrendingDown,
|
||||
iconBg: 'bg-orange-50 dark:bg-orange-950',
|
||||
iconColor: 'text-orange-600 dark:text-orange-400',
|
||||
title: t('Plan Downgraded'),
|
||||
description: t('Subscription updated successfully'),
|
||||
};
|
||||
case 'create':
|
||||
return {
|
||||
icon: Check,
|
||||
iconBg: 'bg-primary/10',
|
||||
iconColor: 'text-primary',
|
||||
title: t('Success!'),
|
||||
description: t('Subscription created successfully'),
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: Check,
|
||||
iconBg: 'bg-primary/10',
|
||||
iconColor: 'text-primary',
|
||||
title: t('Success!'),
|
||||
description: t('Subscription updated successfully'),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const config = getActionConfig();
|
||||
const IconComponent = config.icon;
|
||||
|
||||
return (
|
||||
<div className="h-full bg-background flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<CardContent className="pt-8 pb-6 px-6">
|
||||
<div className="text-center space-y-6">
|
||||
<div
|
||||
className={`mx-auto w-20 h-20 ${config.iconBg} rounded-full flex items-center justify-center`}
|
||||
>
|
||||
<IconComponent className={`w-10 h-10 ${config.iconColor}`} />
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-2xl font-semibold text-foreground">
|
||||
{config.title}
|
||||
</h1>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
{config.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 pt-2">
|
||||
<Button onClick={() => navigate('/')} className="w-full">
|
||||
{t('Go to Dashboard')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => navigate('/platform/setup/billing')}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{t('View Billing Details')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('Redirecting to billing in {countdown} seconds...', {
|
||||
countdown,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user