From 4a4bf0036f2d622f98df59eca1dc5c6ca3f21bd8 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 13:03:22 +0200 Subject: [PATCH 01/38] feat: settings page --- .../src/components/primitives/tabs.tsx | 14 +- .../side-navigation/side-navigation.tsx | 2 +- apps/dashboard/src/main.tsx | 21 +++ apps/dashboard/src/pages/index.ts | 1 + apps/dashboard/src/pages/settings.tsx | 140 ++++++++++++++++++ apps/dashboard/src/utils/routes.ts | 5 + 6 files changed, 178 insertions(+), 5 deletions(-) create mode 100644 apps/dashboard/src/pages/settings.tsx diff --git a/apps/dashboard/src/components/primitives/tabs.tsx b/apps/dashboard/src/components/primitives/tabs.tsx index a68e15e3d87..c87746d7635 100644 --- a/apps/dashboard/src/components/primitives/tabs.tsx +++ b/apps/dashboard/src/components/primitives/tabs.tsx @@ -4,23 +4,29 @@ import * as TabsPrimitive from '@radix-ui/react-tabs'; import { cn } from '@/utils/ui'; import { cva, VariantProps } from 'class-variance-authority'; -const tabsListVariants = cva('inline-flex items-center', { +const tabsListVariants = cva('inline-flex', { variants: { variant: { - default: 'h-9 justify-center rounded-[10px] bg-neutral-alpha-100 p-1 text-muted-foreground', + default: 'h-9 rounded-[10px] bg-neutral-alpha-100 p-1 text-muted-foreground', regular: 'border-neutral-alpha-200 w-full justify-start gap-6 border-b border-t px-3.5', }, + align: { + center: 'justify-center', + start: 'justify-start', + end: 'justify-end', + }, }, defaultVariants: { variant: 'default', + align: 'center', }, }); type TabsListProps = React.ComponentPropsWithoutRef & VariantProps; const TabsList = React.forwardRef, TabsListProps>( - ({ className, variant, ...props }, ref) => ( - + ({ className, variant, align, ...props }, ref) => ( + ) ); TabsList.displayName = TabsPrimitive.List.displayName; diff --git a/apps/dashboard/src/components/side-navigation/side-navigation.tsx b/apps/dashboard/src/components/side-navigation/side-navigation.tsx index 37ce6903e1c..f3ba4cd2712 100644 --- a/apps/dashboard/src/components/side-navigation/side-navigation.tsx +++ b/apps/dashboard/src/components/side-navigation/side-navigation.tsx @@ -129,7 +129,7 @@ export const SideNavigation = () => { - + Settings diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index ad17b5dc93d..8eb6db8107f 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -10,6 +10,7 @@ import { OrganizationListPage, QuestionnairePage, UsecaseSelectPage, + SettingsPage, } from '@/pages'; import './index.css'; import { ROUTES } from './utils/routes'; @@ -94,6 +95,26 @@ const router = createBrowserRouter([ }, ], }, + { + path: ROUTES.SETTINGS, + element: , + }, + { + path: ROUTES.SETTINGS_PROFILE, + element: , + }, + { + path: ROUTES.SETTINGS_ORGANIZATION, + element: , + }, + { + path: ROUTES.SETTINGS_TEAM, + element: , + }, + { + path: ROUTES.SETTINGS_SECURITY, + element: , + }, { path: '*', element: , diff --git a/apps/dashboard/src/pages/index.ts b/apps/dashboard/src/pages/index.ts index 0c500c8d250..28051ec9520 100644 --- a/apps/dashboard/src/pages/index.ts +++ b/apps/dashboard/src/pages/index.ts @@ -4,3 +4,4 @@ export * from './sign-up'; export * from './organization-list'; export * from './questionnaire-page'; export * from './usecase-select-page'; +export * from './settings'; diff --git a/apps/dashboard/src/pages/settings.tsx b/apps/dashboard/src/pages/settings.tsx new file mode 100644 index 00000000000..c35b7d4c2da --- /dev/null +++ b/apps/dashboard/src/pages/settings.tsx @@ -0,0 +1,140 @@ +import { Card } from '@/components/primitives/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; +import { OrganizationProfile, UserProfile } from '@clerk/clerk-react'; +import { useOrganization } from '@clerk/clerk-react'; +import { DashboardLayout } from '../components/dashboard-layout'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { ROUTES } from '@/utils/routes'; + +export const clerkComponentAppearance = { + elements: { + navbar: { display: 'none' }, + navbarMobileMenuRow: { display: 'none !important' }, + rootBox: { + width: '100%', + height: '100%', + }, + cardBox: { + display: 'block', + width: '100%', + height: '100%', + boxShadow: 'none', + }, + + pageScrollBox: { + padding: '0 !important', + }, + header: { + display: 'none', + }, + profileSection: { + borderTop: 'none', + borderBottom: '1px solid #e0e0e0', + }, + page: { + padding: '0 5px', + }, + }, +}; + +export function SettingsPage() { + const { organization } = useOrganization(); + const navigate = useNavigate(); + const location = useLocation(); + + const currentTab = + location.pathname === ROUTES.SETTINGS ? 'profile' : location.pathname.split('/settings/')[1] || 'profile'; + + const handleTabChange = (value: string) => { + switch (value) { + case 'profile': + navigate(ROUTES.SETTINGS_PROFILE); + break; + case 'organization': + navigate(ROUTES.SETTINGS_ORGANIZATION); + break; + case 'team': + navigate(ROUTES.SETTINGS_TEAM); + break; + case 'security': + navigate(ROUTES.SETTINGS_SECURITY); + break; + } + }; + + return ( + Settings}> + + + + Profile + + + Security + + {organization && ( + + Organization + + )} + + Team + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ ); +} diff --git a/apps/dashboard/src/utils/routes.ts b/apps/dashboard/src/utils/routes.ts index 7b982b2dd24..2f096d918a3 100644 --- a/apps/dashboard/src/utils/routes.ts +++ b/apps/dashboard/src/utils/routes.ts @@ -6,6 +6,11 @@ export const ROUTES = { USECASE_SELECT: '/auth/usecase', ROOT: '/', ENV: '/env', + SETTINGS: '/settings', + SETTINGS_PROFILE: '/settings', + SETTINGS_ORGANIZATION: '/settings/organization', + SETTINGS_TEAM: '/settings/team', + SETTINGS_SECURITY: '/settings/security', WORKFLOWS: '/env/:environmentSlug/workflows', EDIT_WORKFLOW: '/env/:environmentSlug/workflows/:workflowSlug', TEST_WORKFLOW: '/env/:environmentSlug/workflows/:workflowSlug/test', From 062b172bc170642ae7603953910eaeeab84fab32 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 13:18:42 +0200 Subject: [PATCH 02/38] feat: base billing page --- .../components/billing/active-plan-banner.tsx | 81 +++++ .../src/components/billing/features.tsx | 294 ++++++++++++++++++ .../src/components/billing/highlights-row.tsx | 56 ++++ .../billing/hooks/use-subscription.ts | 63 ++++ .../src/components/billing/plan-switcher.tsx | 42 +++ .../dashboard/src/components/billing/plan.tsx | 65 ++++ .../src/components/billing/plans-row.tsx | 76 +++++ .../billing/subscription-provider.tsx | 33 ++ apps/dashboard/src/main.tsx | 4 + apps/dashboard/src/pages/settings.tsx | 19 ++ apps/dashboard/src/utils/routes.ts | 1 + 11 files changed, 734 insertions(+) create mode 100644 apps/dashboard/src/components/billing/active-plan-banner.tsx create mode 100644 apps/dashboard/src/components/billing/features.tsx create mode 100644 apps/dashboard/src/components/billing/highlights-row.tsx create mode 100644 apps/dashboard/src/components/billing/hooks/use-subscription.ts create mode 100644 apps/dashboard/src/components/billing/plan-switcher.tsx create mode 100644 apps/dashboard/src/components/billing/plan.tsx create mode 100644 apps/dashboard/src/components/billing/plans-row.tsx create mode 100644 apps/dashboard/src/components/billing/subscription-provider.tsx diff --git a/apps/dashboard/src/components/billing/active-plan-banner.tsx b/apps/dashboard/src/components/billing/active-plan-banner.tsx new file mode 100644 index 00000000000..b2eab936d05 --- /dev/null +++ b/apps/dashboard/src/components/billing/active-plan-banner.tsx @@ -0,0 +1,81 @@ +import { Badge } from '@/components/primitives/badge'; +import { Button } from '@/components/primitives/button'; +import { Card } from '@/components/primitives/card'; +import { Progress } from '@/components/primitives/progress'; +import { useSubscriptionContext } from './subscription-provider'; +import { cn } from '../../utils/ui'; + +interface ActivePlanBannerProps { + selectedBillingInterval: 'month' | 'year'; +} + +export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerProps) { + const subscription = useSubscriptionContext(); + const { trial, apiServiceLevel, events } = subscription; + const { current: currentEvents, included: maxEvents } = events; + + const getProgressColor = (current: number, max: number) => { + const percentage = (current / max) * 100; + if (percentage > 90) return 'bg-destructive'; + if (percentage > 75) return 'bg-warning'; + return 'bg-primary'; + }; + + return ( +
+

Active Plan

+ +
+
+
+

{apiServiceLevel.toLowerCase()}

+ {trial.isActive && ( + <> + Trial +
+ {trial.daysLeft} + days left +
+ + )} +
+ +
+
+ + + {currentEvents.toLocaleString()} + {' '} + events used between {new Date(subscription.currentPeriodStart || Date.now()).toLocaleDateString()} and{' '} + {new Date(subscription.currentPeriodEnd || Date.now()).toLocaleDateString()}. + +
+ + Updates every hour +
+
+ +
+ + {subscription.status === 'trialing' && trial.end && ( + + Trial ends on {new Date(trial.end).toLocaleDateString()} + + )} +
+
+
+
+ ); +} diff --git a/apps/dashboard/src/components/billing/features.tsx b/apps/dashboard/src/components/billing/features.tsx new file mode 100644 index 00000000000..7c2fb902e24 --- /dev/null +++ b/apps/dashboard/src/components/billing/features.tsx @@ -0,0 +1,294 @@ +import { Check } from 'lucide-react'; +import { ApiServiceLevelEnum } from '@novu/shared'; +import { cn } from '../../utils/ui'; + +enum SupportedPlansEnum { + FREE = ApiServiceLevelEnum.FREE, + BUSINESS = ApiServiceLevelEnum.BUSINESS, + ENTERPRISE = ApiServiceLevelEnum.ENTERPRISE, +} + +type FeatureValue = { + value: React.ReactNode; +}; + +type Feature = { + label: string; + isTitle?: boolean; + values: { + [SupportedPlansEnum.FREE]: FeatureValue; + [SupportedPlansEnum.BUSINESS]: FeatureValue; + [SupportedPlansEnum.ENTERPRISE]: FeatureValue; + }; +}; + +const features: Feature[] = [ + { + label: 'Platform', + isTitle: true, + values: { + [SupportedPlansEnum.FREE]: { value: '' }, + [SupportedPlansEnum.BUSINESS]: { value: '' }, + [SupportedPlansEnum.ENTERPRISE]: { value: '' }, + }, + }, + { + label: 'Monthly events', + values: { + [SupportedPlansEnum.FREE]: { value: 'Up to 30,000' }, + [SupportedPlansEnum.BUSINESS]: { value: 'Up to 250,000' }, + [SupportedPlansEnum.ENTERPRISE]: { value: '5,000,000' }, + }, + }, + { + label: 'Additional Events', + values: { + [SupportedPlansEnum.FREE]: { value: '-' }, + [SupportedPlansEnum.BUSINESS]: { value: '$0.0012 per event' }, + [SupportedPlansEnum.ENTERPRISE]: { value: 'Custom' }, + }, + }, + { + label: 'Email, InApp, SMS, Chat, Push Channels', + values: { + [SupportedPlansEnum.FREE]: { value: }, + [SupportedPlansEnum.BUSINESS]: { value: }, + [SupportedPlansEnum.ENTERPRISE]: { value: }, + }, + }, + { + label: 'Notification subscribers', + values: { + [SupportedPlansEnum.FREE]: { value: 'Unlimited' }, + [SupportedPlansEnum.BUSINESS]: { value: 'Unlimited' }, + [SupportedPlansEnum.ENTERPRISE]: { value: 'Unlimited' }, + }, + }, + { + label: 'Framework', + isTitle: true, + values: { + [SupportedPlansEnum.FREE]: { value: '' }, + [SupportedPlansEnum.BUSINESS]: { value: '' }, + [SupportedPlansEnum.ENTERPRISE]: { value: '' }, + }, + }, + { + label: 'Total workflows', + values: { + [SupportedPlansEnum.FREE]: { value: 'Unlimited' }, + [SupportedPlansEnum.BUSINESS]: { value: 'Unlimited' }, + [SupportedPlansEnum.ENTERPRISE]: { value: 'Unlimited' }, + }, + }, + { + label: 'Provider integrations', + values: { + [SupportedPlansEnum.FREE]: { value: 'Unlimited' }, + [SupportedPlansEnum.BUSINESS]: { value: 'Unlimited' }, + [SupportedPlansEnum.ENTERPRISE]: { value: 'Unlimited' }, + }, + }, + { + label: 'Activity Feed retention', + values: { + [SupportedPlansEnum.FREE]: { value: '30 days' }, + [SupportedPlansEnum.BUSINESS]: { value: '90 days' }, + [SupportedPlansEnum.ENTERPRISE]: { value: 'Unlimited' }, + }, + }, + { + label: 'Digests', + values: { + [SupportedPlansEnum.FREE]: { value: }, + [SupportedPlansEnum.BUSINESS]: { value: }, + [SupportedPlansEnum.ENTERPRISE]: { value: }, + }, + }, + { + label: 'Step controls', + values: { + [SupportedPlansEnum.FREE]: { value: }, + [SupportedPlansEnum.BUSINESS]: { value: }, + [SupportedPlansEnum.ENTERPRISE]: { value: }, + }, + }, + { + label: 'Inbox', + isTitle: true, + values: { + [SupportedPlansEnum.FREE]: { value: '' }, + [SupportedPlansEnum.BUSINESS]: { value: '' }, + [SupportedPlansEnum.ENTERPRISE]: { value: '' }, + }, + }, + { + label: 'Inbox component', + values: { + [SupportedPlansEnum.FREE]: { value: }, + [SupportedPlansEnum.BUSINESS]: { value: }, + [SupportedPlansEnum.ENTERPRISE]: { value: }, + }, + }, + { + label: 'User preferences component', + values: { + [SupportedPlansEnum.FREE]: { value: }, + [SupportedPlansEnum.BUSINESS]: { value: }, + [SupportedPlansEnum.ENTERPRISE]: { value: }, + }, + }, + { + label: 'Remove Novu branding', + values: { + [SupportedPlansEnum.FREE]: { value: '-' }, + [SupportedPlansEnum.BUSINESS]: { value: }, + [SupportedPlansEnum.ENTERPRISE]: { value: }, + }, + }, + { + label: 'Account administration and security', + isTitle: true, + values: { + [SupportedPlansEnum.FREE]: { value: '' }, + [SupportedPlansEnum.BUSINESS]: { value: '' }, + [SupportedPlansEnum.ENTERPRISE]: { value: '' }, + }, + }, + { + label: 'Team members', + values: { + [SupportedPlansEnum.FREE]: { value: '3' }, + [SupportedPlansEnum.BUSINESS]: { value: '10' }, + [SupportedPlansEnum.ENTERPRISE]: { value: 'Unlimited' }, + }, + }, + { + label: 'RBAC', + values: { + [SupportedPlansEnum.FREE]: { value: '-' }, + [SupportedPlansEnum.BUSINESS]: { value: }, + [SupportedPlansEnum.ENTERPRISE]: { value: }, + }, + }, + { + label: 'GDPR compliance', + values: { + [SupportedPlansEnum.FREE]: { value: }, + [SupportedPlansEnum.BUSINESS]: { value: }, + [SupportedPlansEnum.ENTERPRISE]: { value: }, + }, + }, + { + label: 'SAML SSO and Enterprise SSO providers', + values: { + [SupportedPlansEnum.FREE]: { value: '-' }, + [SupportedPlansEnum.BUSINESS]: { value: '-' }, + [SupportedPlansEnum.ENTERPRISE]: { value: }, + }, + }, + { + label: 'Support and account management', + isTitle: true, + values: { + [SupportedPlansEnum.FREE]: { value: '' }, + [SupportedPlansEnum.BUSINESS]: { value: '' }, + [SupportedPlansEnum.ENTERPRISE]: { value: '' }, + }, + }, + { + label: 'Support SLA', + values: { + [SupportedPlansEnum.FREE]: { value: '-' }, + [SupportedPlansEnum.BUSINESS]: { value: '48 hours' }, + [SupportedPlansEnum.ENTERPRISE]: { value: '24 hours' }, + }, + }, + { + label: 'Support channels', + values: { + [SupportedPlansEnum.FREE]: { value: 'Community & Discord' }, + [SupportedPlansEnum.BUSINESS]: { value: 'Slack & Email' }, + [SupportedPlansEnum.ENTERPRISE]: { value: 'Dedicated' }, + }, + }, + { + label: 'Legal & Vendor management', + isTitle: true, + values: { + [SupportedPlansEnum.FREE]: { value: '' }, + [SupportedPlansEnum.BUSINESS]: { value: '' }, + [SupportedPlansEnum.ENTERPRISE]: { value: '' }, + }, + }, + { + label: 'Payment method', + values: { + [SupportedPlansEnum.FREE]: { value: '-' }, + [SupportedPlansEnum.BUSINESS]: { value: 'Credit card only' }, + [SupportedPlansEnum.ENTERPRISE]: { value: 'Credit card & PO and Invoicing' }, + }, + }, + { + label: 'Terms of service', + values: { + [SupportedPlansEnum.FREE]: { value: 'Standard' }, + [SupportedPlansEnum.BUSINESS]: { value: 'Standard' }, + [SupportedPlansEnum.ENTERPRISE]: { value: 'Custom' }, + }, + }, + { + label: 'DPA', + values: { + [SupportedPlansEnum.FREE]: { value: 'Standard' }, + [SupportedPlansEnum.BUSINESS]: { value: 'Standard' }, + [SupportedPlansEnum.ENTERPRISE]: { value: 'Custom' }, + }, + }, + { + label: 'Security review', + values: { + [SupportedPlansEnum.FREE]: { value: 'SOC 2 and ISO 27001 upon request' }, + [SupportedPlansEnum.BUSINESS]: { value: 'Custom' }, + [SupportedPlansEnum.ENTERPRISE]: { value: 'Custom' }, + }, + }, +]; + +function FeatureRow({ feature, index }: { feature: Feature; index: number }) { + return ( +
+
+ + {feature.label} + +
+ + {Object.entries(feature.values).map(([plan, value]) => ( +
+ {value.value} +
+ ))} +
+ ); +} + +export function Features() { + return ( +
+ {features.map((feature, index) => ( + + ))} +
+ ); +} diff --git a/apps/dashboard/src/components/billing/highlights-row.tsx b/apps/dashboard/src/components/billing/highlights-row.tsx new file mode 100644 index 00000000000..84e4a4d6c40 --- /dev/null +++ b/apps/dashboard/src/components/billing/highlights-row.tsx @@ -0,0 +1,56 @@ +import { Badge } from '@/components/primitives/badge'; +import { ApiServiceLevelEnum } from '@novu/shared'; + +interface Highlight { + text: string; + badgeLabel?: string; +} + +type PlanHighlights = { + [key in ApiServiceLevelEnum]?: Highlight[]; +}; + +const highlights: PlanHighlights = { + [ApiServiceLevelEnum.FREE]: [ + { text: 'Up to 30,000 events per month' }, + { text: '3 teammates' }, + { text: '30 days Activity Feed retention' }, + ], + [ApiServiceLevelEnum.BUSINESS]: [ + { text: 'Up to 250,000 events per month' }, + { text: '50 teammates' }, + { text: '90 days Activity Feed retention' }, + ], + [ApiServiceLevelEnum.ENTERPRISE]: [ + { text: 'Up to 5,000,000 events per month' }, + { text: 'Unlimited teammates' }, + { text: 'SAML SSO' }, + ], +}; + +function PlanHighlights({ planHighlights }: { planHighlights: Highlight[] }) { + return ( +
+
    + {planHighlights.map((item, index) => ( +
  • + {item.text} {item.badgeLabel && {item.badgeLabel}} +
  • + ))} +
+
+ ); +} + +export function HighlightsRow() { + return ( +
+
+ Highlights +
+ {Object.entries(highlights).map(([planName, planHighlights]) => ( + + ))} +
+ ); +} diff --git a/apps/dashboard/src/components/billing/hooks/use-subscription.ts b/apps/dashboard/src/components/billing/hooks/use-subscription.ts new file mode 100644 index 00000000000..0bfe6bfdc50 --- /dev/null +++ b/apps/dashboard/src/components/billing/hooks/use-subscription.ts @@ -0,0 +1,63 @@ +import { useQuery } from '@tanstack/react-query'; +import { ApiServiceLevelEnum } from '@novu/shared'; +import { get } from '../../../api/api.client'; + +export interface UseSubscriptionType { + isLoading: boolean; + apiServiceLevel: ApiServiceLevelEnum; + isActive: boolean; + hasPaymentMethod: boolean; + status: string; + currentPeriodStart: string | null; + currentPeriodEnd: string | null; + billingInterval: 'month' | 'year' | null; + events: { + current: number; + included: number; + }; + trial: { + isActive: boolean; + start: string; + end: string; + daysTotal: number; + daysLeft: number; + }; +} + +type SubscriptionResponse = Omit; + +export function useSubscription(): UseSubscriptionType { + const { data, isLoading } = useQuery({ + queryKey: ['subscription'], + queryFn: () => get('/billing/subscription'), + }); + + if (isLoading || !data) { + return { + isLoading, + apiServiceLevel: ApiServiceLevelEnum.FREE, + isActive: false, + hasPaymentMethod: false, + status: 'trialing', + currentPeriodStart: null, + currentPeriodEnd: null, + billingInterval: null, + events: { + current: 0, + included: 0, + }, + trial: { + isActive: false, + start: new Date().toISOString(), + end: new Date().toISOString(), + daysTotal: 0, + daysLeft: 0, + }, + }; + } + + return { + isLoading: false, + ...data, + }; +} diff --git a/apps/dashboard/src/components/billing/plan-switcher.tsx b/apps/dashboard/src/components/billing/plan-switcher.tsx new file mode 100644 index 00000000000..9e8b4d155dd --- /dev/null +++ b/apps/dashboard/src/components/billing/plan-switcher.tsx @@ -0,0 +1,42 @@ +import { Tabs, TabsList, TabsTrigger } from '@/components/primitives/tabs'; +import { cn } from '../../utils/ui'; + +interface PlanSwitcherProps { + selectedBillingInterval: 'month' | 'year'; + setSelectedBillingInterval: (value: 'month' | 'year') => void; +} + +export function PlanSwitcher({ selectedBillingInterval, setSelectedBillingInterval }: PlanSwitcherProps) { + return ( +
+

All plans

+
+ setSelectedBillingInterval(value)} + > + + + Monthly + + + Annually 10% off + + + +
+
+ ); +} diff --git a/apps/dashboard/src/components/billing/plan.tsx b/apps/dashboard/src/components/billing/plan.tsx new file mode 100644 index 00000000000..739f4c4c460 --- /dev/null +++ b/apps/dashboard/src/components/billing/plan.tsx @@ -0,0 +1,65 @@ +import { useEffect, useState } from 'react'; +import { useSegment } from '../../context/segment'; +import { useSubscription } from './hooks/use-subscription'; +import { ActivePlanBanner } from './active-plan-banner'; +import { PlanSwitcher } from './plan-switcher'; +import { PlansRow } from './plans-row'; +import { HighlightsRow } from './highlights-row'; +import { Features } from './features'; +import { cn } from '../../utils/ui'; +import { Skeleton } from '../primitives/skeleton'; + +export function Plan() { + const segment = useSegment(); + const { isLoading, billingInterval: subscriptionBillingInterval } = useSubscription(); + const [selectedBillingInterval, setSelectedBillingInterval] = useState<'month' | 'year'>( + subscriptionBillingInterval || 'month' + ); + + useEffect(() => { + const checkoutResult = new URLSearchParams(window.location.search).get('result'); + + if (checkoutResult === 'success') { + // TODO: Add toast notification + console.log('Payment was successful.'); + } + + if (checkoutResult === 'canceled') { + // TODO: Add toast notification + console.log('Order canceled.'); + } + }, []); + + useEffect(() => { + segment.track('Billing Page Viewed'); + }, [segment]); + + if (isLoading) { + return ( +
+ + +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ + +
+ ); + } + + return ( +
+ + + + + +
+ ); +} diff --git a/apps/dashboard/src/components/billing/plans-row.tsx b/apps/dashboard/src/components/billing/plans-row.tsx new file mode 100644 index 00000000000..02c2f202987 --- /dev/null +++ b/apps/dashboard/src/components/billing/plans-row.tsx @@ -0,0 +1,76 @@ +import { Badge } from '@/components/primitives/badge'; +import { Button } from '@/components/primitives/button'; +import { ApiServiceLevelEnum } from '@novu/shared'; +import { useSubscriptionContext } from './subscription-provider'; + +interface PlansRowProps { + selectedBillingInterval: 'month' | 'year'; +} + +interface PlanDisplayProps { + price: string; + subtitle: string; + events: string; +} + +function PlanDisplay({ price, subtitle, events }: PlanDisplayProps) { + return ( +
+
+ {price} + {subtitle} +
+ {events} +
+ ); +} + +export function PlansRow({ selectedBillingInterval }: PlansRowProps) { + const { apiServiceLevel } = useSubscriptionContext(); + const businessPlanPrice = selectedBillingInterval === 'year' ? '$2,700' : '$250'; + + return ( +
+
+

Plans

+
+ +
+

Free

+ +
+ +
+
+

Business

+ Popular +
+ + +
+ +
+
+

Enterprise

+

Custom pricing, billing, and extended services.

+
+ +
+
+ ); +} diff --git a/apps/dashboard/src/components/billing/subscription-provider.tsx b/apps/dashboard/src/components/billing/subscription-provider.tsx new file mode 100644 index 00000000000..c72190a78f8 --- /dev/null +++ b/apps/dashboard/src/components/billing/subscription-provider.tsx @@ -0,0 +1,33 @@ +import { createContext, useContext } from 'react'; +import { ApiServiceLevelEnum } from '@novu/shared'; +import { useSubscription, type UseSubscriptionType } from './hooks/use-subscription'; + +const SubscriptionContext = createContext({ + isLoading: false, + apiServiceLevel: ApiServiceLevelEnum.FREE, + isActive: false, + hasPaymentMethod: false, + status: 'trialing', + currentPeriodStart: null, + currentPeriodEnd: null, + billingInterval: null, + events: { + current: 0, + included: 0, + }, + trial: { + isActive: false, + start: new Date().toISOString(), + end: new Date().toISOString(), + daysTotal: 0, + daysLeft: 0, + }, +}); + +export const useSubscriptionContext = () => useContext(SubscriptionContext); + +export function SubscriptionProvider({ children }: { children: React.ReactNode }) { + const props = useSubscription(); + + return {children}; +} diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index 8eb6db8107f..e5fe4cbde73 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -115,6 +115,10 @@ const router = createBrowserRouter([ path: ROUTES.SETTINGS_SECURITY, element: , }, + { + path: ROUTES.SETTINGS_BILLING, + element: , + }, { path: '*', element: , diff --git a/apps/dashboard/src/pages/settings.tsx b/apps/dashboard/src/pages/settings.tsx index c35b7d4c2da..e3b35f978ad 100644 --- a/apps/dashboard/src/pages/settings.tsx +++ b/apps/dashboard/src/pages/settings.tsx @@ -5,6 +5,8 @@ import { useOrganization } from '@clerk/clerk-react'; import { DashboardLayout } from '../components/dashboard-layout'; import { useNavigate, useLocation } from 'react-router-dom'; import { ROUTES } from '@/utils/routes'; +import { SubscriptionProvider } from '../components/billing/subscription-provider'; +import { Plan } from '../components/billing/plan'; export const clerkComponentAppearance = { elements: { @@ -56,6 +58,9 @@ export function SettingsPage() { case 'team': navigate(ROUTES.SETTINGS_TEAM); break; + case 'billing': + navigate(ROUTES.SETTINGS_BILLING); + break; case 'security': navigate(ROUTES.SETTINGS_SECURITY); break; @@ -95,6 +100,12 @@ export function SettingsPage() { > Team + + Billing +
@@ -133,6 +144,14 @@ export function SettingsPage() { + + + + + + + +
diff --git a/apps/dashboard/src/utils/routes.ts b/apps/dashboard/src/utils/routes.ts index 2f096d918a3..9c21452675b 100644 --- a/apps/dashboard/src/utils/routes.ts +++ b/apps/dashboard/src/utils/routes.ts @@ -11,6 +11,7 @@ export const ROUTES = { SETTINGS_ORGANIZATION: '/settings/organization', SETTINGS_TEAM: '/settings/team', SETTINGS_SECURITY: '/settings/security', + SETTINGS_BILLING: '/settings/billing', WORKFLOWS: '/env/:environmentSlug/workflows', EDIT_WORKFLOW: '/env/:environmentSlug/workflows/:workflowSlug', TEST_WORKFLOW: '/env/:environmentSlug/workflows/:workflowSlug/test', From 9a5a375b5a159e196eec0cb8148867e42dd62845 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 13:49:42 +0200 Subject: [PATCH 03/38] feat: add billingbase --- .../components/billing/active-plan-banner.tsx | 106 ++++++++++-------- .../billing/hooks/use-subscription.ts | 85 ++++++++------ .../src/components/billing/plans-row.tsx | 8 +- 3 files changed, 121 insertions(+), 78 deletions(-) diff --git a/apps/dashboard/src/components/billing/active-plan-banner.tsx b/apps/dashboard/src/components/billing/active-plan-banner.tsx index b2eab936d05..5e9d8eb3e59 100644 --- a/apps/dashboard/src/components/billing/active-plan-banner.tsx +++ b/apps/dashboard/src/components/billing/active-plan-banner.tsx @@ -2,77 +2,95 @@ import { Badge } from '@/components/primitives/badge'; import { Button } from '@/components/primitives/button'; import { Card } from '@/components/primitives/card'; import { Progress } from '@/components/primitives/progress'; -import { useSubscriptionContext } from './subscription-provider'; import { cn } from '../../utils/ui'; +import { useSubscription } from './hooks/use-subscription'; +import { CalendarDays, ChevronRight } from 'lucide-react'; interface ActivePlanBannerProps { selectedBillingInterval: 'month' | 'year'; } export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerProps) { - const subscription = useSubscriptionContext(); - const { trial, apiServiceLevel, events } = subscription; - const { current: currentEvents, included: maxEvents } = events; + const { data: subscription } = useSubscription(); + const { trial, apiServiceLevel, events } = subscription || {}; + const { current: currentEvents, included: maxEvents } = events || {}; const getProgressColor = (current: number, max: number) => { + const percentage = (current / max) * 100; + if (percentage > 90) return 'text-destructive'; + if (percentage > 75) return 'text-warning'; + return 'text-primary'; + }; + + const getProgressBarColor = (current: number, max: number) => { const percentage = (current / max) * 100; if (percentage > 90) return 'bg-destructive'; if (percentage > 75) return 'bg-warning'; return 'bg-primary'; }; - return ( -
-

Active Plan

- -
-
-
-

{apiServiceLevel.toLowerCase()}

- {trial.isActive && ( - <> - Trial -
- {trial.daysLeft} - days left -
- - )} -
+ const formatDate = (date: string | number) => { + return new Date(date).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }); + }; -
-
- - - {currentEvents.toLocaleString()} - {' '} - events used between {new Date(subscription.currentPeriodStart || Date.now()).toLocaleDateString()} and{' '} - {new Date(subscription.currentPeriodEnd || Date.now()).toLocaleDateString()}. - + return ( +
+ +
+
+
+
+

{apiServiceLevel?.toLowerCase()}

+ {trial?.isActive && ( + + Trial + + )}
- - Updates every hour + {trial?.isActive &&
{trial.daysLeft} days left
}
-
-
- {subscription.status === 'trialing' && trial.end && ( - - Trial ends on {new Date(trial.end).toLocaleDateString()} +
+ +
+
+ + + {formatDate(subscription.currentPeriodStart || Date.now())} -{' '} + {formatDate(subscription.currentPeriodEnd || Date.now())} - )} +
+ +
+
+
+ + {currentEvents?.toLocaleString()} + {' '} + of {maxEvents?.toLocaleString()} events +
+ Updates hourly +
+ +
diff --git a/apps/dashboard/src/components/billing/hooks/use-subscription.ts b/apps/dashboard/src/components/billing/hooks/use-subscription.ts index 0bfe6bfdc50..a1f499d2909 100644 --- a/apps/dashboard/src/components/billing/hooks/use-subscription.ts +++ b/apps/dashboard/src/components/billing/hooks/use-subscription.ts @@ -1,26 +1,30 @@ import { useQuery } from '@tanstack/react-query'; import { ApiServiceLevelEnum } from '@novu/shared'; import { get } from '../../../api/api.client'; +import { differenceInDays, isSameDay } from 'date-fns'; +import { useMemo } from 'react'; export interface UseSubscriptionType { isLoading: boolean; - apiServiceLevel: ApiServiceLevelEnum; - isActive: boolean; - hasPaymentMethod: boolean; - status: string; - currentPeriodStart: string | null; - currentPeriodEnd: string | null; - billingInterval: 'month' | 'year' | null; - events: { - current: number; - included: number; - }; - trial: { + data: { + apiServiceLevel: ApiServiceLevelEnum; isActive: boolean; - start: string; - end: string; - daysTotal: number; - daysLeft: number; + hasPaymentMethod: boolean; + status: string; + currentPeriodStart: string | null; + currentPeriodEnd: string | null; + billingInterval: 'month' | 'year' | null; + events: { + current: number; + included: number; + }; + trial: { + isActive: boolean; + start: string; + end: string; + daysTotal: number; + daysLeft: number; + }; }; } @@ -32,30 +36,47 @@ export function useSubscription(): UseSubscriptionType { queryFn: () => get('/billing/subscription'), }); + const daysLeft = useMemo(() => { + const today = new Date(); + if (!data?.data.trial.end) return 0; + + return isSameDay(new Date(data.data.trial.end), today) ? 0 : differenceInDays(new Date(data.data.trial.end), today); + }, [data?.data.trial.end]); + if (isLoading || !data) { return { isLoading, - apiServiceLevel: ApiServiceLevelEnum.FREE, - isActive: false, - hasPaymentMethod: false, - status: 'trialing', - currentPeriodStart: null, - currentPeriodEnd: null, - billingInterval: null, - events: { - current: 0, - included: 0, - }, - trial: { + data: { + apiServiceLevel: ApiServiceLevelEnum.FREE, isActive: false, - start: new Date().toISOString(), - end: new Date().toISOString(), - daysTotal: 0, - daysLeft: 0, + hasPaymentMethod: false, + status: 'trialing', + currentPeriodStart: null, + currentPeriodEnd: null, + billingInterval: null, + events: { + current: 0, + included: 0, + }, + trial: { + isActive: false, + start: new Date().toISOString(), + end: new Date().toISOString(), + daysTotal: 0, + daysLeft: 0, + }, }, }; } + data.data = { + ...data.data, + trial: { + ...data.data.trial, + daysLeft, + }, + }; + return { isLoading: false, ...data, diff --git a/apps/dashboard/src/components/billing/plans-row.tsx b/apps/dashboard/src/components/billing/plans-row.tsx index 02c2f202987..84ae1a03b75 100644 --- a/apps/dashboard/src/components/billing/plans-row.tsx +++ b/apps/dashboard/src/components/billing/plans-row.tsx @@ -2,6 +2,7 @@ import { Badge } from '@/components/primitives/badge'; import { Button } from '@/components/primitives/button'; import { ApiServiceLevelEnum } from '@novu/shared'; import { useSubscriptionContext } from './subscription-provider'; +import { useSubscription } from './hooks/use-subscription'; interface PlansRowProps { selectedBillingInterval: 'month' | 'year'; @@ -26,7 +27,8 @@ function PlanDisplay({ price, subtitle, events }: PlanDisplayProps) { } export function PlansRow({ selectedBillingInterval }: PlansRowProps) { - const { apiServiceLevel } = useSubscriptionContext(); + const { data: subscription } = useSubscription(); + const { apiServiceLevel } = subscription || {}; const businessPlanPrice = selectedBillingInterval === 'year' ? '$2,700' : '$250'; return ( @@ -58,7 +60,9 @@ export function PlansRow({ selectedBillingInterval }: PlansRowProps) { window.location.href = '/v1/billing/checkout-session'; }} > - {apiServiceLevel === ApiServiceLevelEnum.BUSINESS ? 'Manage subscription' : 'Upgrade plan'} + {apiServiceLevel === ApiServiceLevelEnum.BUSINESS && !subscription?.trial?.isActive + ? 'Manage subscription' + : 'Upgrade plan'}
From 5e76db8f0553da15b3b4eb8845cf95b085499713 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 14:17:30 +0200 Subject: [PATCH 04/38] feat: padi --- .../components/billing/active-plan-banner.tsx | 24 ++- .../src/components/billing/highlights-row.tsx | 14 +- .../components/billing/plan-action-button.tsx | 66 ++++++++ .../src/components/billing/plan-switcher.tsx | 7 +- .../dashboard/src/components/billing/plan.tsx | 2 +- .../src/components/billing/plans-row.tsx | 143 ++++++++++++------ apps/dashboard/src/pages/settings.tsx | 2 +- 7 files changed, 189 insertions(+), 69 deletions(-) create mode 100644 apps/dashboard/src/components/billing/plan-action-button.tsx diff --git a/apps/dashboard/src/components/billing/active-plan-banner.tsx b/apps/dashboard/src/components/billing/active-plan-banner.tsx index 5e9d8eb3e59..a42395a38e3 100644 --- a/apps/dashboard/src/components/billing/active-plan-banner.tsx +++ b/apps/dashboard/src/components/billing/active-plan-banner.tsx @@ -1,10 +1,10 @@ import { Badge } from '@/components/primitives/badge'; -import { Button } from '@/components/primitives/button'; import { Card } from '@/components/primitives/card'; import { Progress } from '@/components/primitives/progress'; import { cn } from '../../utils/ui'; import { useSubscription } from './hooks/use-subscription'; -import { CalendarDays, ChevronRight } from 'lucide-react'; +import { CalendarDays } from 'lucide-react'; +import { PlanActionButton } from './plan-action-button'; interface ActivePlanBannerProps { selectedBillingInterval: 'month' | 'year'; @@ -14,6 +14,8 @@ export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerPr const { data: subscription } = useSubscription(); const { trial, apiServiceLevel, events } = subscription || {}; const { current: currentEvents, included: maxEvents } = events || {}; + const isPaidSubscriptionActive = + subscription?.isActive && !trial?.isActive && apiServiceLevel !== ApiServiceLevelEnum.FREE; const getProgressColor = (current: number, max: number) => { const percentage = (current / max) * 100; @@ -38,8 +40,8 @@ export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerPr }; return ( -
- +
+
@@ -54,17 +56,13 @@ export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerPr {trial?.isActive &&
{trial.daysLeft} days left
}
- + showIcon + className="shrink-0" + />
diff --git a/apps/dashboard/src/components/billing/highlights-row.tsx b/apps/dashboard/src/components/billing/highlights-row.tsx index 84e4a4d6c40..156a1d6629f 100644 --- a/apps/dashboard/src/components/billing/highlights-row.tsx +++ b/apps/dashboard/src/components/billing/highlights-row.tsx @@ -1,5 +1,7 @@ import { Badge } from '@/components/primitives/badge'; +import { Card } from '@/components/primitives/card'; import { ApiServiceLevelEnum } from '@novu/shared'; +import { cn } from '../../utils/ui'; interface Highlight { text: string; @@ -30,24 +32,22 @@ const highlights: PlanHighlights = { function PlanHighlights({ planHighlights }: { planHighlights: Highlight[] }) { return ( -
-
    + +
      {planHighlights.map((item, index) => (
    • +
      {item.text} {item.badgeLabel && {item.badgeLabel}}
    • ))}
    -
+ ); } export function HighlightsRow() { return ( -
-
- Highlights -
+
{Object.entries(highlights).map(([planName, planHighlights]) => ( ))} diff --git a/apps/dashboard/src/components/billing/plan-action-button.tsx b/apps/dashboard/src/components/billing/plan-action-button.tsx new file mode 100644 index 00000000000..c30cff35d2b --- /dev/null +++ b/apps/dashboard/src/components/billing/plan-action-button.tsx @@ -0,0 +1,66 @@ +import { Button } from '@/components/primitives/button'; +import { ApiServiceLevelEnum } from '@novu/shared'; +import { useMutation } from '@tanstack/react-query'; +import { get, post } from '../../api/api.client'; +import { toast } from 'sonner'; +import { useSubscription } from './hooks/use-subscription'; +import { cn } from '../../utils/ui'; +import { ChevronRight } from 'lucide-react'; + +interface PlanActionButtonProps { + selectedBillingInterval: 'month' | 'year'; + variant?: 'default' | 'outline'; + showIcon?: boolean; + className?: string; + size?: 'default' | 'sm' | 'lg'; +} + +export function PlanActionButton({ + selectedBillingInterval, + variant = 'default', + showIcon = false, + className, + size = 'default', +}: PlanActionButtonProps) { + const { data: subscription } = useSubscription(); + const { trial, apiServiceLevel } = subscription || {}; + const isPaidSubscriptionActive = + subscription?.isActive && !trial?.isActive && apiServiceLevel !== ApiServiceLevelEnum.FREE; + + const { mutateAsync: goToPortal, isPending: isGoingToPortal } = useMutation({ + mutationFn: () => get<{ data: string }>('/billing/portal'), + onSuccess: (data) => { + window.location.href = data.data; + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Unexpected error occurred'); + }, + }); + + const { mutateAsync: checkout, isPending: isCheckingOut } = useMutation({ + mutationFn: () => + post<{ data: { stripeCheckoutUrl: string } }>('/billing/checkout-session', { + billingInterval: selectedBillingInterval, + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + }), + onSuccess: (data) => { + window.location.href = data.data.stripeCheckoutUrl; + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : 'Unexpected error occurred'); + }, + }); + + return ( + + ); +} diff --git a/apps/dashboard/src/components/billing/plan-switcher.tsx b/apps/dashboard/src/components/billing/plan-switcher.tsx index 9e8b4d155dd..353cd4f773f 100644 --- a/apps/dashboard/src/components/billing/plan-switcher.tsx +++ b/apps/dashboard/src/components/billing/plan-switcher.tsx @@ -8,12 +8,11 @@ interface PlanSwitcherProps { export function PlanSwitcher({ selectedBillingInterval, setSelectedBillingInterval }: PlanSwitcherProps) { return ( -
-

All plans

-
+
+
setSelectedBillingInterval(value)} + onValueChange={(value) => setSelectedBillingInterval(value as 'month' | 'year')} > +
- {price} - {subtitle} + {price} + {subtitle}
{events}
@@ -28,53 +31,107 @@ function PlanDisplay({ price, subtitle, events }: PlanDisplayProps) { export function PlansRow({ selectedBillingInterval }: PlansRowProps) { const { data: subscription } = useSubscription(); - const { apiServiceLevel } = subscription || {}; + const { apiServiceLevel, trial } = subscription || {}; const businessPlanPrice = selectedBillingInterval === 'year' ? '$2,700' : '$250'; + const isPaidSubscriptionActive = + subscription?.isActive && !trial?.isActive && apiServiceLevel !== ApiServiceLevelEnum.FREE; return ( -
-
-

Plans

-
- -
-

Free

- -
+
+ {/* Free Plan */} + +
+
+

Free

+ +
    +
  • + + All core features +
  • +
  • + + Up to 3 team members +
  • +
  • + + Community support +
  • +
+
+
+
-
-
-

Business

- Popular + {/* Business Plan */} + +
+ POPULAR
- - -
+
+
+
+

Business

+ Most Popular +
+ +
    +
  • + + Everything in Free +
  • +
  • + + Up to 10 team members +
  • +
  • + + Priority support +
  • +
+
+
+ +
+
+ -
-
-

Enterprise

-

Custom pricing, billing, and extended services.

+ {/* Enterprise Plan */} + +
+
+

Enterprise

+
+
+ Custom pricing +
+ For large-scale operations +
+
    +
  • + + Everything in Business +
  • +
  • + + Unlimited team members +
  • +
  • + + Custom contracts & SLA +
  • +
+
+
+ +
- -
+
); } diff --git a/apps/dashboard/src/pages/settings.tsx b/apps/dashboard/src/pages/settings.tsx index e3b35f978ad..9315ffd38e6 100644 --- a/apps/dashboard/src/pages/settings.tsx +++ b/apps/dashboard/src/pages/settings.tsx @@ -72,7 +72,7 @@ export function SettingsPage() { Date: Tue, 3 Dec 2024 14:35:11 +0200 Subject: [PATCH 05/38] feat: add hubspot forms --- .../billing/contact-sales-button.tsx | 26 +++++ .../billing/contact-sales-modal.tsx | 47 ++++++++ .../src/components/billing/plans-row.tsx | 7 +- .../billing/utils/hubspot.constants.ts | 3 + .../src/components/hubspot-form.module.css | 108 ++++++++++++++++++ .../dashboard/src/components/hubspot-form.tsx | 71 ++++++++++++ 6 files changed, 258 insertions(+), 4 deletions(-) create mode 100644 apps/dashboard/src/components/billing/contact-sales-button.tsx create mode 100644 apps/dashboard/src/components/billing/contact-sales-modal.tsx create mode 100644 apps/dashboard/src/components/billing/utils/hubspot.constants.ts create mode 100644 apps/dashboard/src/components/hubspot-form.module.css create mode 100644 apps/dashboard/src/components/hubspot-form.tsx diff --git a/apps/dashboard/src/components/billing/contact-sales-button.tsx b/apps/dashboard/src/components/billing/contact-sales-button.tsx new file mode 100644 index 00000000000..651f6309637 --- /dev/null +++ b/apps/dashboard/src/components/billing/contact-sales-button.tsx @@ -0,0 +1,26 @@ +import { Button } from '@/components/primitives/button'; +import { ApiServiceLevelEnum } from '@novu/shared'; +import { useState } from 'react'; +import { ContactSalesModal } from './contact-sales-modal'; + +interface ContactSalesButtonProps { + className?: string; + variant?: 'default' | 'outline'; +} + +export function ContactSalesButton({ className, variant = 'outline' }: ContactSalesButtonProps) { + const [isContactSalesModalOpen, setIsContactSalesModalOpen] = useState(false); + + return ( + <> + + setIsContactSalesModalOpen(false)} + intendedApiServiceLevel={ApiServiceLevelEnum.ENTERPRISE} + /> + + ); +} diff --git a/apps/dashboard/src/components/billing/contact-sales-modal.tsx b/apps/dashboard/src/components/billing/contact-sales-modal.tsx new file mode 100644 index 00000000000..02706ec82aa --- /dev/null +++ b/apps/dashboard/src/components/billing/contact-sales-modal.tsx @@ -0,0 +1,47 @@ +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/primitives/dialog'; +import { ApiServiceLevelEnum } from '@novu/shared'; +import { HubspotForm } from '../hubspot-form'; +import { HUBSPOT_FORM_IDS } from './utils/hubspot.constants'; +import { useAuth } from '@/context/auth/hooks'; +import { toast } from 'sonner'; + +interface ContactSalesModalProps { + isOpen: boolean; + onClose: () => void; + intendedApiServiceLevel: ApiServiceLevelEnum; +} + +export function ContactSalesModal({ isOpen, onClose, intendedApiServiceLevel }: ContactSalesModalProps) { + const { currentUser, currentOrganization } = useAuth(); + + if (!isOpen || !currentUser || !currentOrganization) { + return null; + } + + return ( + + + + Contact sales + + { + toast.success('Thank you for contacting us! We will be in touch soon.'); + onClose(); + }} + /> + + + ); +} diff --git a/apps/dashboard/src/components/billing/plans-row.tsx b/apps/dashboard/src/components/billing/plans-row.tsx index 55a87b6fc77..57880658613 100644 --- a/apps/dashboard/src/components/billing/plans-row.tsx +++ b/apps/dashboard/src/components/billing/plans-row.tsx @@ -6,6 +6,7 @@ import { useSubscription } from './hooks/use-subscription'; import { cn } from '../../utils/ui'; import { Check } from 'lucide-react'; import { PlanActionButton } from './plan-action-button'; +import { ContactSalesButton } from './contact-sales-button'; interface PlansRowProps { selectedBillingInterval: 'month' | 'year'; @@ -71,7 +72,7 @@ export function PlansRow({ selectedBillingInterval }: PlansRowProps) {

Business

- Most Popular + Most Popular
- +
diff --git a/apps/dashboard/src/components/billing/utils/hubspot.constants.ts b/apps/dashboard/src/components/billing/utils/hubspot.constants.ts new file mode 100644 index 00000000000..04ddc92300e --- /dev/null +++ b/apps/dashboard/src/components/billing/utils/hubspot.constants.ts @@ -0,0 +1,3 @@ +export const HUBSPOT_FORM_IDS = { + UPGRADE_CONTACT_SALES: '297d0b87-4c09-4ccf-a74c-28fd5e3b37f6', +} as const; diff --git a/apps/dashboard/src/components/hubspot-form.module.css b/apps/dashboard/src/components/hubspot-form.module.css new file mode 100644 index 00000000000..1bc8245a053 --- /dev/null +++ b/apps/dashboard/src/components/hubspot-form.module.css @@ -0,0 +1,108 @@ +.container :global(.hubspot-form-wrapper) { + color: var(--text-color, #333); + display: flex; + flex-direction: column; + gap: 16px; +} + +.container :global(.hubspot-form-wrapper .form-columns-1), +.container :global(.hubspot-form-wrapper .form-columns-2) { + min-width: 100%; + display: flex; + gap: 20px; +} + +.container :global(.hubspot-form-wrapper .form-columns-1 > *), +.container :global(.hubspot-form-wrapper .form-columns-2 > *) { + width: 100%; +} + +.container :global(.hubspot-form-wrapper .hs-input) { + width: 100%; + appearance: none; + background-color: transparent; + border-radius: 7px; + border: 1px solid var(--border-color, #e1e1e1); + box-sizing: border-box; + display: block; + font-family: var(--font-family, system-ui, -apple-system, sans-serif); + font-size: 14px; + height: 42px; + line-height: 40px; + margin: 5px 0; + min-height: 50px; + padding: 0 14px; + resize: none; + text-align: left; + transition: border-color 100ms ease; + color: var(--text-color, #333); +} + +.container :global(.hubspot-form-wrapper .hs-input:focus-visible) { + outline: none; + border-color: var(--focus-border-color, #000); +} + +.container :global(.hubspot-form-wrapper .hs-fieldtype-textarea .hs-input) { + resize: vertical; + min-height: 100px; + padding: 14px; + line-height: 1.5; +} + +.container :global(.hubspot-form-wrapper .hs-button) { + appearance: none; + background: var(--gradient, linear-gradient(90deg, #dd2476 0%, #ff512f 100%)); + border-radius: 7px; + border: 0; + box-sizing: border-box; + cursor: pointer; + display: inline-block; + font-family: var(--font-family, system-ui, -apple-system, sans-serif); + font-size: 14px; + font-weight: 600; + height: 42px; + line-height: 1; + padding: 0 22px; + position: relative; + color: #fff; + text-align: right; + text-decoration: none; + user-select: none; + width: auto; +} + +.container :global(.hubspot-form-wrapper .hs-form-field label) { + cursor: default; + display: inline-block; + font-size: 14px; + font-weight: 700; + line-height: 17px; + margin: 5px 0; + word-break: break-word; + color: var(--text-color, #333); +} + +.container :global(.hubspot-form-wrapper .hs-submit .actions) { + display: flex; + justify-content: flex-end; +} + +.container :global(.hubspot-form-wrapper .legal-consent-container) { + font-size: 12px; + color: var(--secondary-text-color, #666); + line-height: 16px; +} + +.container :global(.hubspot-form-wrapper .legal-consent-container p) { + margin: 0; +} + +.container :global(.hubspot-form-wrapper a) { + color: var(--link-color, #dd2476); + text-decoration: none; +} + +.container :global(.hubspot-form-wrapper a:hover) { + text-decoration: underline; +} diff --git a/apps/dashboard/src/components/hubspot-form.tsx b/apps/dashboard/src/components/hubspot-form.tsx new file mode 100644 index 00000000000..6b0cee6bde3 --- /dev/null +++ b/apps/dashboard/src/components/hubspot-form.tsx @@ -0,0 +1,71 @@ +import { useEffect, useRef } from 'react'; +import styles from './hubspot-form.module.css'; + +declare global { + interface Window { + hbspt?: { + forms: { + create: (config: any) => void; + }; + }; + } +} + +interface HubspotFormProps { + formId: string; + properties?: Record; + readonlyProperties?: string[]; + focussedProperty?: string; + onFormSubmitted?: () => void; +} + +const HUBSPOT_FORM_CLASS = 'hubspot-form-wrapper'; + +export function HubspotForm({ + formId, + properties = {}, + readonlyProperties = [], + focussedProperty, + onFormSubmitted, +}: HubspotFormProps) { + const formContainerRef = useRef(null); + + useEffect(() => { + const script = document.createElement('script'); + script.src = '//js.hsforms.net/forms/embed/v2.js'; + script.async = true; + script.onload = () => { + if (window.hbspt && formContainerRef.current) { + window.hbspt.forms.create({ + region: 'na1', + portalId: '44416662', + formId, + target: `#${formContainerRef.current.id}`, + onFormSubmitted, + cssClass: HUBSPOT_FORM_CLASS, + inlineMessage: 'Thank you for your submission.', + fieldLabels: Object.entries(properties).reduce( + (acc, [key, value]) => { + if (readonlyProperties.includes(key)) { + acc[key] = { value, hidden: true }; + } + return acc; + }, + {} as Record + ), + }); + } + }; + document.body.appendChild(script); + + return () => { + document.body.removeChild(script); + }; + }, [formId, properties, readonlyProperties, onFormSubmitted]); + + return ( +
+
+
+ ); +} From f1a8e6da402beb429d76cb8c00bdc6578f8966b8 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 14:40:03 +0200 Subject: [PATCH 06/38] feat: add tests --- apps/dashboard/src/components/billing/plan.tsx | 11 +++++------ .../components/side-navigation/free-trial-card.tsx | 9 +++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/apps/dashboard/src/components/billing/plan.tsx b/apps/dashboard/src/components/billing/plan.tsx index 8e9e711f025..f59fe981129 100644 --- a/apps/dashboard/src/components/billing/plan.tsx +++ b/apps/dashboard/src/components/billing/plan.tsx @@ -8,25 +8,24 @@ import { HighlightsRow } from './highlights-row'; import { Features } from './features'; import { cn } from '../../utils/ui'; import { Skeleton } from '../primitives/skeleton'; +import { toast } from 'sonner'; export function Plan() { const segment = useSegment(); - const { isLoading, billingInterval: subscriptionBillingInterval } = useSubscription(); + const { isLoading, data } = useSubscription(); const [selectedBillingInterval, setSelectedBillingInterval] = useState<'month' | 'year'>( - subscriptionBillingInterval || 'month' + data?.billingInterval || 'month' ); useEffect(() => { const checkoutResult = new URLSearchParams(window.location.search).get('result'); if (checkoutResult === 'success') { - // TODO: Add toast notification - console.log('Payment was successful.'); + toast.success('Payment was successful.'); } if (checkoutResult === 'canceled') { - // TODO: Add toast notification - console.log('Order canceled.'); + toast.error('Payment canseledt canceled.'); } }, []); diff --git a/apps/dashboard/src/components/side-navigation/free-trial-card.tsx b/apps/dashboard/src/components/side-navigation/free-trial-card.tsx index 1ca715fed79..89a7060c44c 100644 --- a/apps/dashboard/src/components/side-navigation/free-trial-card.tsx +++ b/apps/dashboard/src/components/side-navigation/free-trial-card.tsx @@ -3,8 +3,9 @@ import { RiArrowRightDoubleLine, RiInformationFill } from 'react-icons/ri'; import { Progress } from '../primitives/progress'; import { Button } from '../primitives/button'; import { Tooltip, TooltipContent, TooltipTrigger, TooltipArrow } from '../primitives/tooltip'; -import { LEGACY_ROUTES } from '@/utils/routes'; +import { LEGACY_ROUTES, ROUTES } from '@/utils/routes'; import { useBillingSubscription } from '@/hooks/use-billing-subscription'; +import { Link } from 'react-router-dom'; const transition = 'transition-all duration-300 ease-out'; @@ -23,8 +24,8 @@ export const FreeTrialCard = () => { const pluralizedDays = pluralizeDaysLeft(daysLeft); return ( -
@@ -67,6 +68,6 @@ export const FreeTrialCard = () => { Upgrade now
-
+ ); }; From 33a606fcea9100ebdddc798177bcb1eb67085746 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 14:46:04 +0200 Subject: [PATCH 07/38] feat:ff --- .../side-navigation/free-trial-card.tsx | 114 +++++++++++------- apps/dashboard/src/pages/settings.tsx | 27 +++-- packages/shared/src/types/feature-flags.ts | 1 + 3 files changed, 88 insertions(+), 54 deletions(-) diff --git a/apps/dashboard/src/components/side-navigation/free-trial-card.tsx b/apps/dashboard/src/components/side-navigation/free-trial-card.tsx index 89a7060c44c..8ad586f607e 100644 --- a/apps/dashboard/src/components/side-navigation/free-trial-card.tsx +++ b/apps/dashboard/src/components/side-navigation/free-trial-card.tsx @@ -6,6 +6,8 @@ import { Tooltip, TooltipContent, TooltipTrigger, TooltipArrow } from '../primit import { LEGACY_ROUTES, ROUTES } from '@/utils/routes'; import { useBillingSubscription } from '@/hooks/use-billing-subscription'; import { Link } from 'react-router-dom'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; +import { FeatureFlagsKeysEnum } from '@novu/shared'; const transition = 'transition-all duration-300 ease-out'; @@ -13,61 +15,83 @@ const pluralizeDaysLeft = (numberOfDays: number) => { return `${numberOfDays} day${numberOfDays > 1 ? 's' : ''}`; }; +const CardContent = ({ + pluralizedDays, + daysTotal, + daysLeft, +}: { + pluralizedDays: string; + daysTotal: number; + daysLeft: number; +}) => ( + <> +
+
+ +
+ {pluralizedDays} left on trial + + + + + + + + + + + After the trial ends, continue to enjoy novu's free tier with unlimited workflows and up to 30k + events/month. + + + +
+ + Experience Novu without any limits for free for the next {pluralizedDays}. + +
+ +
+
+ +
+ +); + export const FreeTrialCard = () => { const { subscription, daysLeft, isLoading } = useBillingSubscription(); const daysTotal = subscription && subscription.trial.daysTotal > 0 ? subscription.trial.daysTotal : 100; + const isV2BillingEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_V2_DASHBOARD_BILLING_ENABLED); if (isLoading || !subscription || !subscription.trial.isActive || subscription?.hasPaymentMethod) { return null; } const pluralizedDays = pluralizeDaysLeft(daysLeft); + const cardClassName = + 'bg-background group absolute bottom-3 left-2 flex w-[calc(100%-1rem)] cursor-pointer flex-col gap-2 rounded-lg p-3 shadow'; + + if (isV2BillingEnabled) { + return ( + + + + ); + } return ( - -
-
- -
- {pluralizedDays} left on trial - - - - - - - - - - - After the trial ends, continue to enjoy novu's free tier with unlimited workflows and up to 30k - events/month. - - - -
- - Experience Novu without any limits for free for the next {pluralizedDays}. - -
- -
-
- -
- + + + ); }; diff --git a/apps/dashboard/src/pages/settings.tsx b/apps/dashboard/src/pages/settings.tsx index 9315ffd38e6..5bd2a21d0d8 100644 --- a/apps/dashboard/src/pages/settings.tsx +++ b/apps/dashboard/src/pages/settings.tsx @@ -4,9 +4,11 @@ import { OrganizationProfile, UserProfile } from '@clerk/clerk-react'; import { useOrganization } from '@clerk/clerk-react'; import { DashboardLayout } from '../components/dashboard-layout'; import { useNavigate, useLocation } from 'react-router-dom'; -import { ROUTES } from '@/utils/routes'; +import { ROUTES, LEGACY_ROUTES } from '@/utils/routes'; import { SubscriptionProvider } from '../components/billing/subscription-provider'; import { Plan } from '../components/billing/plan'; +import { useFeatureFlag } from '@/hooks/use-feature-flag'; +import { FeatureFlagsKeysEnum } from '@novu/shared'; export const clerkComponentAppearance = { elements: { @@ -43,6 +45,7 @@ export function SettingsPage() { const { organization } = useOrganization(); const navigate = useNavigate(); const location = useLocation(); + const isV2BillingEnabled = useFeatureFlag(FeatureFlagsKeysEnum.IS_V2_DASHBOARD_BILLING_ENABLED); const currentTab = location.pathname === ROUTES.SETTINGS ? 'profile' : location.pathname.split('/settings/')[1] || 'profile'; @@ -59,7 +62,11 @@ export function SettingsPage() { navigate(ROUTES.SETTINGS_TEAM); break; case 'billing': - navigate(ROUTES.SETTINGS_BILLING); + if (isV2BillingEnabled) { + navigate(ROUTES.SETTINGS_BILLING); + } else { + window.location.href = LEGACY_ROUTES.BILLING; + } break; case 'security': navigate(ROUTES.SETTINGS_SECURITY); @@ -145,13 +152,15 @@ export function SettingsPage() { - - - - - - - + {isV2BillingEnabled && ( + + + + + + + + )}
diff --git a/packages/shared/src/types/feature-flags.ts b/packages/shared/src/types/feature-flags.ts index 99ae28f1d6f..bf031721a01 100644 --- a/packages/shared/src/types/feature-flags.ts +++ b/packages/shared/src/types/feature-flags.ts @@ -43,4 +43,5 @@ export enum FeatureFlagsKeysEnum { IS_CONTROLS_AUTOCOMPLETE_ENABLED = 'IS_CONTROLS_AUTOCOMPLETE_ENABLED', IS_USAGE_ALERTS_ENABLED = 'IS_USAGE_ALERTS_ENABLED', IS_NEW_DASHBOARD_ENABLED = 'IS_NEW_DASHBOARD_ENABLED', + IS_V2_DASHBOARD_BILLING_ENABLED = 'IS_V2_DASHBOARD_BILLING_ENABLED', } From 0d49ca8f98acdabc638dc85b6e2720cbd2c71bf6 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 15:09:07 +0200 Subject: [PATCH 08/38] feat:cspell --- .cspell.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.cspell.json b/.cspell.json index 93c9b7b81ff..be7f4058949 100644 --- a/.cspell.json +++ b/.cspell.json @@ -708,7 +708,8 @@ "rstrip", "truncatewords", "xmlschema", - "jsonify" + "jsonify", + "hsforms" ], "flagWords": [], "patterns": [ From 4c6a1dc72da54d94bee50ef65526c5666fe084a3 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 15:23:41 +0200 Subject: [PATCH 09/38] Update .source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index e37e9d3f03e..5ea60f985d4 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit e37e9d3f03e2574565e00f8ed52c4ea11bfd37aa +Subproject commit 5ea60f985d4736bea8f7305977c08b200de1471c From 7cbb10b6b38255ec6ecd3fd5e9458d0bbfe9a640 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 15:25:28 +0200 Subject: [PATCH 10/38] fix: v2 dashboard path --- apps/dashboard/src/components/billing/active-plan-banner.tsx | 3 +-- apps/dashboard/src/components/billing/plan-action-button.tsx | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/src/components/billing/active-plan-banner.tsx b/apps/dashboard/src/components/billing/active-plan-banner.tsx index a42395a38e3..59e5517a636 100644 --- a/apps/dashboard/src/components/billing/active-plan-banner.tsx +++ b/apps/dashboard/src/components/billing/active-plan-banner.tsx @@ -5,6 +5,7 @@ import { cn } from '../../utils/ui'; import { useSubscription } from './hooks/use-subscription'; import { CalendarDays } from 'lucide-react'; import { PlanActionButton } from './plan-action-button'; +import { ApiServiceLevelEnum } from '@novu/shared'; interface ActivePlanBannerProps { selectedBillingInterval: 'month' | 'year'; @@ -14,8 +15,6 @@ export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerPr const { data: subscription } = useSubscription(); const { trial, apiServiceLevel, events } = subscription || {}; const { current: currentEvents, included: maxEvents } = events || {}; - const isPaidSubscriptionActive = - subscription?.isActive && !trial?.isActive && apiServiceLevel !== ApiServiceLevelEnum.FREE; const getProgressColor = (current: number, max: number) => { const percentage = (current / max) * 100; diff --git a/apps/dashboard/src/components/billing/plan-action-button.tsx b/apps/dashboard/src/components/billing/plan-action-button.tsx index c30cff35d2b..a8a1c450844 100644 --- a/apps/dashboard/src/components/billing/plan-action-button.tsx +++ b/apps/dashboard/src/components/billing/plan-action-button.tsx @@ -28,7 +28,7 @@ export function PlanActionButton({ subscription?.isActive && !trial?.isActive && apiServiceLevel !== ApiServiceLevelEnum.FREE; const { mutateAsync: goToPortal, isPending: isGoingToPortal } = useMutation({ - mutationFn: () => get<{ data: string }>('/billing/portal'), + mutationFn: () => get<{ data: string }>('/billing/portal?isV2Dashboard=true'), onSuccess: (data) => { window.location.href = data.data; }, @@ -42,6 +42,7 @@ export function PlanActionButton({ post<{ data: { stripeCheckoutUrl: string } }>('/billing/checkout-session', { billingInterval: selectedBillingInterval, apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + isV2Dashboard: true, }), onSuccess: (data) => { window.location.href = data.data.stripeCheckoutUrl; From e462996f0219a6c726242c72a8ae5ae5c65af82c Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 16:08:06 +0200 Subject: [PATCH 11/38] feat: align styles --- apps/dashboard/src/pages/settings.tsx | 25 +++++++------------------ 1 file changed, 7 insertions(+), 18 deletions(-) diff --git a/apps/dashboard/src/pages/settings.tsx b/apps/dashboard/src/pages/settings.tsx index c35b7d4c2da..99a88f943d1 100644 --- a/apps/dashboard/src/pages/settings.tsx +++ b/apps/dashboard/src/pages/settings.tsx @@ -66,29 +66,21 @@ export function SettingsPage() { Settings}> - Profile + Account - Security + Organization - {organization && ( - - Organization - - )} -
+
- + - - - - +

Security

From 0ab6a0aa2c931d1eac16de240d177da724fdaeaa Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 16:08:47 +0200 Subject: [PATCH 12/38] Update settings.tsx --- apps/dashboard/src/pages/settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/pages/settings.tsx b/apps/dashboard/src/pages/settings.tsx index 99a88f943d1..3183e4ea237 100644 --- a/apps/dashboard/src/pages/settings.tsx +++ b/apps/dashboard/src/pages/settings.tsx @@ -89,7 +89,7 @@ export function SettingsPage() { -
+
From 7daa07658dca3f129ce4297c2bdcda3fa2e69071 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 16:11:02 +0200 Subject: [PATCH 13/38] fix: style --- apps/dashboard/src/main.tsx | 6 +----- apps/dashboard/src/pages/settings.tsx | 14 +++++--------- apps/dashboard/src/utils/routes.ts | 3 +-- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/apps/dashboard/src/main.tsx b/apps/dashboard/src/main.tsx index 8eb6db8107f..6501c8938e9 100644 --- a/apps/dashboard/src/main.tsx +++ b/apps/dashboard/src/main.tsx @@ -100,7 +100,7 @@ const router = createBrowserRouter([ element: , }, { - path: ROUTES.SETTINGS_PROFILE, + path: ROUTES.SETTINGS_ACCOUNT, element: , }, { @@ -111,10 +111,6 @@ const router = createBrowserRouter([ path: ROUTES.SETTINGS_TEAM, element: , }, - { - path: ROUTES.SETTINGS_SECURITY, - element: , - }, { path: '*', element: , diff --git a/apps/dashboard/src/pages/settings.tsx b/apps/dashboard/src/pages/settings.tsx index 3183e4ea237..0ea16e31766 100644 --- a/apps/dashboard/src/pages/settings.tsx +++ b/apps/dashboard/src/pages/settings.tsx @@ -38,17 +38,16 @@ export const clerkComponentAppearance = { }; export function SettingsPage() { - const { organization } = useOrganization(); const navigate = useNavigate(); const location = useLocation(); const currentTab = - location.pathname === ROUTES.SETTINGS ? 'profile' : location.pathname.split('/settings/')[1] || 'profile'; + location.pathname === ROUTES.SETTINGS ? 'account' : location.pathname.split('/settings/')[1] || 'account'; const handleTabChange = (value: string) => { switch (value) { - case 'profile': - navigate(ROUTES.SETTINGS_PROFILE); + case 'account': + navigate(ROUTES.SETTINGS_ACCOUNT); break; case 'organization': navigate(ROUTES.SETTINGS_ORGANIZATION); @@ -56,9 +55,6 @@ export function SettingsPage() { case 'team': navigate(ROUTES.SETTINGS_TEAM); break; - case 'security': - navigate(ROUTES.SETTINGS_SECURITY); - break; } }; @@ -70,7 +66,7 @@ export function SettingsPage() { className="border-border/20 relative mt-4 flex w-full items-end justify-start space-x-2 rounded-none border-b bg-transparent px-1.5 pb-0" > Account @@ -90,7 +86,7 @@ export function SettingsPage() {
- + diff --git a/apps/dashboard/src/utils/routes.ts b/apps/dashboard/src/utils/routes.ts index 2f096d918a3..f127dd91e7d 100644 --- a/apps/dashboard/src/utils/routes.ts +++ b/apps/dashboard/src/utils/routes.ts @@ -7,10 +7,9 @@ export const ROUTES = { ROOT: '/', ENV: '/env', SETTINGS: '/settings', - SETTINGS_PROFILE: '/settings', + SETTINGS_ACCOUNT: '/settings/account', SETTINGS_ORGANIZATION: '/settings/organization', SETTINGS_TEAM: '/settings/team', - SETTINGS_SECURITY: '/settings/security', WORKFLOWS: '/env/:environmentSlug/workflows', EDIT_WORKFLOW: '/env/:environmentSlug/workflows/:workflowSlug', TEST_WORKFLOW: '/env/:environmentSlug/workflows/:workflowSlug/test', From f1eb4fba4b11d90b12d5805846b2271b32c3e115 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 16:22:58 +0200 Subject: [PATCH 14/38] fix: width --- .../components/billing/active-plan-banner.tsx | 10 ++++---- .../src/components/billing/plan-switcher.tsx | 23 ++++--------------- apps/dashboard/src/pages/settings.tsx | 8 ++++++- 3 files changed, 17 insertions(+), 24 deletions(-) diff --git a/apps/dashboard/src/components/billing/active-plan-banner.tsx b/apps/dashboard/src/components/billing/active-plan-banner.tsx index 59e5517a636..4294f8dc0c6 100644 --- a/apps/dashboard/src/components/billing/active-plan-banner.tsx +++ b/apps/dashboard/src/components/billing/active-plan-banner.tsx @@ -20,14 +20,14 @@ export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerPr const percentage = (current / max) * 100; if (percentage > 90) return 'text-destructive'; if (percentage > 75) return 'text-warning'; - return 'text-primary'; + return 'text-success'; }; const getProgressBarColor = (current: number, max: number) => { const percentage = (current / max) * 100; if (percentage > 90) return 'bg-destructive'; if (percentage > 75) return 'bg-warning'; - return 'bg-primary'; + return 'bg-success'; }; const formatDate = (date: string | number) => { @@ -40,7 +40,7 @@ export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerPr return (
- +
@@ -52,7 +52,9 @@ export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerPr )}
- {trial?.isActive &&
{trial.daysLeft} days left
} + {trial?.isActive && ( +
{trial.daysLeft} days left for trial
+ )}
-
+
setSelectedBillingInterval(value as 'month' | 'year')} > - - - Monthly - - + + Monthly + Annually 10% off diff --git a/apps/dashboard/src/pages/settings.tsx b/apps/dashboard/src/pages/settings.tsx index a910d8b631d..3d8b336de0b 100644 --- a/apps/dashboard/src/pages/settings.tsx +++ b/apps/dashboard/src/pages/settings.tsx @@ -8,6 +8,7 @@ import { SubscriptionProvider } from '../components/billing/subscription-provide import { Plan } from '../components/billing/plan'; import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { FeatureFlagsKeysEnum } from '@novu/shared'; +import { cn } from '../utils/ui'; export const clerkComponentAppearance = { elements: { @@ -102,7 +103,12 @@ export function SettingsPage() { -
+
From 377a9ab1de1097d78ae92be2b54ef147bac1c1c8 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 16:26:12 +0200 Subject: [PATCH 15/38] fix: rows --- apps/dashboard/src/components/billing/features.tsx | 2 +- apps/dashboard/src/components/billing/plans-row.tsx | 8 -------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/dashboard/src/components/billing/features.tsx b/apps/dashboard/src/components/billing/features.tsx index 7c2fb902e24..6baa84667fd 100644 --- a/apps/dashboard/src/components/billing/features.tsx +++ b/apps/dashboard/src/components/billing/features.tsx @@ -258,7 +258,7 @@ const features: Feature[] = [ function FeatureRow({ feature, index }: { feature: Feature; index: number }) { return (
From abcce8b41f97077179cba39f63de2a1a8e472608 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 16:27:10 +0200 Subject: [PATCH 16/38] Update settings.tsx --- apps/dashboard/src/pages/settings.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/dashboard/src/pages/settings.tsx b/apps/dashboard/src/pages/settings.tsx index 0ea16e31766..0e639b1376f 100644 --- a/apps/dashboard/src/pages/settings.tsx +++ b/apps/dashboard/src/pages/settings.tsx @@ -1,7 +1,6 @@ import { Card } from '@/components/primitives/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitives/tabs'; import { OrganizationProfile, UserProfile } from '@clerk/clerk-react'; -import { useOrganization } from '@clerk/clerk-react'; import { DashboardLayout } from '../components/dashboard-layout'; import { useNavigate, useLocation } from 'react-router-dom'; import { ROUTES } from '@/utils/routes'; From 465fa4ee3bc63c02c87f5448d6d9576f417b85dc Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 16:30:04 +0200 Subject: [PATCH 17/38] fix: team invite CTA --- .../src/components/side-navigation/side-navigation.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/side-navigation/side-navigation.tsx b/apps/dashboard/src/components/side-navigation/side-navigation.tsx index f3ba4cd2712..3faf47318c8 100644 --- a/apps/dashboard/src/components/side-navigation/side-navigation.tsx +++ b/apps/dashboard/src/components/side-navigation/side-navigation.tsx @@ -135,7 +135,7 @@ export const SideNavigation = () => { - + Invite teammates From 0a8d6d62ee4676d37087352cde5eb33b5c9e894a Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 16:31:27 +0200 Subject: [PATCH 18/38] fix: space --- apps/dashboard/src/pages/settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/src/pages/settings.tsx b/apps/dashboard/src/pages/settings.tsx index 0e639b1376f..f9206f61f14 100644 --- a/apps/dashboard/src/pages/settings.tsx +++ b/apps/dashboard/src/pages/settings.tsx @@ -86,7 +86,7 @@ export function SettingsPage() {
- + From 8f9f4521a6c962a75229e9d9f50f6e1d47703652 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 16:42:53 +0200 Subject: [PATCH 19/38] fix: lint --- .../components/billing/active-plan-banner.tsx | 1 - .../src/components/billing/highlights-row.tsx | 1 - .../billing/subscription-provider.tsx | 34 ++++++++++--------- .../dashboard/src/components/hubspot-form.tsx | 8 +---- apps/web/env-config.js | 0 5 files changed, 19 insertions(+), 25 deletions(-) delete mode 100644 apps/web/env-config.js diff --git a/apps/dashboard/src/components/billing/active-plan-banner.tsx b/apps/dashboard/src/components/billing/active-plan-banner.tsx index 4294f8dc0c6..5aad0753ed2 100644 --- a/apps/dashboard/src/components/billing/active-plan-banner.tsx +++ b/apps/dashboard/src/components/billing/active-plan-banner.tsx @@ -5,7 +5,6 @@ import { cn } from '../../utils/ui'; import { useSubscription } from './hooks/use-subscription'; import { CalendarDays } from 'lucide-react'; import { PlanActionButton } from './plan-action-button'; -import { ApiServiceLevelEnum } from '@novu/shared'; interface ActivePlanBannerProps { selectedBillingInterval: 'month' | 'year'; diff --git a/apps/dashboard/src/components/billing/highlights-row.tsx b/apps/dashboard/src/components/billing/highlights-row.tsx index 156a1d6629f..331673493a2 100644 --- a/apps/dashboard/src/components/billing/highlights-row.tsx +++ b/apps/dashboard/src/components/billing/highlights-row.tsx @@ -1,7 +1,6 @@ import { Badge } from '@/components/primitives/badge'; import { Card } from '@/components/primitives/card'; import { ApiServiceLevelEnum } from '@novu/shared'; -import { cn } from '../../utils/ui'; interface Highlight { text: string; diff --git a/apps/dashboard/src/components/billing/subscription-provider.tsx b/apps/dashboard/src/components/billing/subscription-provider.tsx index c72190a78f8..2f9d0b28023 100644 --- a/apps/dashboard/src/components/billing/subscription-provider.tsx +++ b/apps/dashboard/src/components/billing/subscription-provider.tsx @@ -4,23 +4,25 @@ import { useSubscription, type UseSubscriptionType } from './hooks/use-subscript const SubscriptionContext = createContext({ isLoading: false, - apiServiceLevel: ApiServiceLevelEnum.FREE, - isActive: false, - hasPaymentMethod: false, - status: 'trialing', - currentPeriodStart: null, - currentPeriodEnd: null, - billingInterval: null, - events: { - current: 0, - included: 0, - }, - trial: { + data: { + apiServiceLevel: ApiServiceLevelEnum.FREE, isActive: false, - start: new Date().toISOString(), - end: new Date().toISOString(), - daysTotal: 0, - daysLeft: 0, + hasPaymentMethod: false, + status: 'trialing', + currentPeriodStart: null, + currentPeriodEnd: null, + billingInterval: null, + events: { + current: 0, + included: 0, + }, + trial: { + isActive: false, + start: new Date().toISOString(), + end: new Date().toISOString(), + daysTotal: 0, + daysLeft: 0, + }, }, }); diff --git a/apps/dashboard/src/components/hubspot-form.tsx b/apps/dashboard/src/components/hubspot-form.tsx index 6b0cee6bde3..b31085a45e3 100644 --- a/apps/dashboard/src/components/hubspot-form.tsx +++ b/apps/dashboard/src/components/hubspot-form.tsx @@ -21,13 +21,7 @@ interface HubspotFormProps { const HUBSPOT_FORM_CLASS = 'hubspot-form-wrapper'; -export function HubspotForm({ - formId, - properties = {}, - readonlyProperties = [], - focussedProperty, - onFormSubmitted, -}: HubspotFormProps) { +export function HubspotForm({ formId, properties = {}, readonlyProperties = [], onFormSubmitted }: HubspotFormProps) { const formContainerRef = useRef(null); useEffect(() => { diff --git a/apps/web/env-config.js b/apps/web/env-config.js deleted file mode 100644 index e69de29bb2d..00000000000 From 628a7846e7c81242d4f60d29e15ef877ba117ecb Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 16:49:34 +0200 Subject: [PATCH 20/38] fix: compare plans --- apps/dashboard/src/components/billing/plan-switcher.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/dashboard/src/components/billing/plan-switcher.tsx b/apps/dashboard/src/components/billing/plan-switcher.tsx index d63b535a188..542edd9f5a0 100644 --- a/apps/dashboard/src/components/billing/plan-switcher.tsx +++ b/apps/dashboard/src/components/billing/plan-switcher.tsx @@ -1,4 +1,5 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/primitives/tabs'; +import { Title } from '@radix-ui/react-dialog'; interface PlanSwitcherProps { selectedBillingInterval: 'month' | 'year'; @@ -8,7 +9,8 @@ interface PlanSwitcherProps { export function PlanSwitcher({ selectedBillingInterval, setSelectedBillingInterval }: PlanSwitcherProps) { return (
-
+

Compare Plans

+
setSelectedBillingInterval(value as 'month' | 'year')} From 15621651580587f2a634a0cdd6a8a9f1f015e101 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 16:49:47 +0200 Subject: [PATCH 21/38] fix: broken import --- apps/dashboard/src/components/billing/plan-switcher.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/dashboard/src/components/billing/plan-switcher.tsx b/apps/dashboard/src/components/billing/plan-switcher.tsx index 542edd9f5a0..1e7db535458 100644 --- a/apps/dashboard/src/components/billing/plan-switcher.tsx +++ b/apps/dashboard/src/components/billing/plan-switcher.tsx @@ -1,5 +1,4 @@ import { Tabs, TabsList, TabsTrigger } from '@/components/primitives/tabs'; -import { Title } from '@radix-ui/react-dialog'; interface PlanSwitcherProps { selectedBillingInterval: 'month' | 'year'; From 93d5957a32ce7a00b13fc9167b160452b5d6cb80 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Tue, 3 Dec 2024 23:14:01 +0200 Subject: [PATCH 22/38] fix: update src --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 61941c5bd9f..e37e9d3f03e 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 61941c5bd9f09f22a68620f2a51e36bcdc0f3abe +Subproject commit e37e9d3f03e2574565e00f8ed52c4ea11bfd37aa From 7b09e3e48f93795fc08621e75c09d6e0463565a3 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 5 Dec 2024 13:03:27 +0200 Subject: [PATCH 23/38] feat: pointer latest --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index 5ea60f985d4..c0e8ec7aef6 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 5ea60f985d4736bea8f7305977c08b200de1471c +Subproject commit c0e8ec7aef6c1572e0e1f0033c758aca3e7ab65b From 189a8156ab306f0c31a246fbc181760185301a59 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 5 Dec 2024 17:41:51 +0200 Subject: [PATCH 24/38] Update .source --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index c0e8ec7aef6..d31bb54387b 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit c0e8ec7aef6c1572e0e1f0033c758aca3e7ab65b +Subproject commit d31bb54387b6fcf45fc0606d9fcc90361cabd73b From 64d63607d0f31d8905ec19f8e1dde6365f2db2fe Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 5 Dec 2024 17:45:59 +0200 Subject: [PATCH 25/38] fix: boolean pipe --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index d31bb54387b..7d2c4921031 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit d31bb54387b6fcf45fc0606d9fcc90361cabd73b +Subproject commit 7d2c49210316cf66120fd1e1e8c4cfa5a0ec09ff From 70afd1cb7e0976dc73f0c2b76c374e8e3d93982a Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 5 Dec 2024 17:52:59 +0200 Subject: [PATCH 26/38] fix: small refactor --- .../components/billing/active-plan-banner.tsx | 76 ++++++++++----- .../billing/hooks/use-subscription.ts | 96 +++++++------------ apps/dashboard/src/pages/settings.tsx | 6 +- 3 files changed, 96 insertions(+), 82 deletions(-) diff --git a/apps/dashboard/src/components/billing/active-plan-banner.tsx b/apps/dashboard/src/components/billing/active-plan-banner.tsx index 5aad0753ed2..50237a77ab5 100644 --- a/apps/dashboard/src/components/billing/active-plan-banner.tsx +++ b/apps/dashboard/src/components/billing/active-plan-banner.tsx @@ -5,15 +5,14 @@ import { cn } from '../../utils/ui'; import { useSubscription } from './hooks/use-subscription'; import { CalendarDays } from 'lucide-react'; import { PlanActionButton } from './plan-action-button'; +import { Skeleton } from '@/components/primitives/skeleton'; interface ActivePlanBannerProps { selectedBillingInterval: 'month' | 'year'; } export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerProps) { - const { data: subscription } = useSubscription(); - const { trial, apiServiceLevel, events } = subscription || {}; - const { current: currentEvents, included: maxEvents } = events || {}; + const { data: subscription, isLoading } = useSubscription(); const getProgressColor = (current: number, max: number) => { const percentage = (current / max) * 100; @@ -44,15 +43,21 @@ export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerPr
-

{apiServiceLevel?.toLowerCase()}

- {trial?.isActive && ( + {!subscription ? ( + + ) : ( +

{subscription.apiServiceLevel.toLowerCase()}

+ )} + {subscription?.trial.isActive && ( Trial )}
- {trial?.isActive && ( -
{trial.daysLeft} days left for trial
+ {subscription?.trial.isActive && ( +
+ {subscription.trial.daysLeft} days left for trial +
)}
@@ -68,26 +73,53 @@ export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerPr
- - {formatDate(subscription.currentPeriodStart || Date.now())} -{' '} - {formatDate(subscription.currentPeriodEnd || Date.now())} - + {!subscription ? ( + + ) : ( + + {formatDate(subscription.currentPeriodStart ?? Date.now())} -{' '} + {formatDate(subscription.currentPeriodEnd ?? Date.now())} + + )}
-
- - {currentEvents?.toLocaleString()} - {' '} - of {maxEvents?.toLocaleString()} events -
- Updates hourly + {!subscription ? ( + <> + + + + ) : ( + <> +
+ + {subscription.events.current.toLocaleString()} + {' '} + + of {subscription.events.included.toLocaleString()} events + +
+ Updates hourly + + )}
- + {!subscription ? ( + + ) : ( + + )}
diff --git a/apps/dashboard/src/components/billing/hooks/use-subscription.ts b/apps/dashboard/src/components/billing/hooks/use-subscription.ts index a1f499d2909..4083d2bab17 100644 --- a/apps/dashboard/src/components/billing/hooks/use-subscription.ts +++ b/apps/dashboard/src/components/billing/hooks/use-subscription.ts @@ -4,81 +4,59 @@ import { get } from '../../../api/api.client'; import { differenceInDays, isSameDay } from 'date-fns'; import { useMemo } from 'react'; -export interface UseSubscriptionType { - isLoading: boolean; - data: { - apiServiceLevel: ApiServiceLevelEnum; +export interface ISubscriptionData { + apiServiceLevel: ApiServiceLevelEnum; + isActive: boolean; + hasPaymentMethod: boolean; + status: string; + currentPeriodStart: string | null; + currentPeriodEnd: string | null; + billingInterval: 'month' | 'year' | null; + events: { + current: number; + included: number; + }; + trial: { isActive: boolean; - hasPaymentMethod: boolean; - status: string; - currentPeriodStart: string | null; - currentPeriodEnd: string | null; - billingInterval: 'month' | 'year' | null; - events: { - current: number; - included: number; - }; - trial: { - isActive: boolean; - start: string; - end: string; - daysTotal: number; - daysLeft: number; - }; + start: string; + end: string; + daysTotal: number; + daysLeft: number; }; } -type SubscriptionResponse = Omit; +export interface UseSubscriptionType { + isLoading: boolean; + data: ISubscriptionData | null; +} export function useSubscription(): UseSubscriptionType { - const { data, isLoading } = useQuery({ + const { data, isLoading } = useQuery<{ data: ISubscriptionData | null }>({ queryKey: ['subscription'], queryFn: () => get('/billing/subscription'), }); - const daysLeft = useMemo(() => { - const today = new Date(); - if (!data?.data.trial.end) return 0; + const enrichedData = useMemo(() => { + if (!data?.data) return null; - return isSameDay(new Date(data.data.trial.end), today) ? 0 : differenceInDays(new Date(data.data.trial.end), today); - }, [data?.data.trial.end]); + const today = new Date(); + const daysLeft = !data.data.trial.end + ? 0 + : isSameDay(new Date(data.data.trial.end), today) + ? 0 + : differenceInDays(new Date(data.data.trial.end), today); - if (isLoading || !data) { return { - isLoading, - data: { - apiServiceLevel: ApiServiceLevelEnum.FREE, - isActive: false, - hasPaymentMethod: false, - status: 'trialing', - currentPeriodStart: null, - currentPeriodEnd: null, - billingInterval: null, - events: { - current: 0, - included: 0, - }, - trial: { - isActive: false, - start: new Date().toISOString(), - end: new Date().toISOString(), - daysTotal: 0, - daysLeft: 0, - }, + ...data.data, + trial: { + ...data.data.trial, + daysLeft, }, }; - } - - data.data = { - ...data.data, - trial: { - ...data.data.trial, - daysLeft, - }, - }; + }, [data?.data]); return { - isLoading: false, - ...data, + isLoading, + data: enrichedData, }; } diff --git a/apps/dashboard/src/pages/settings.tsx b/apps/dashboard/src/pages/settings.tsx index ed6f24ed68f..5aedb5b7bc5 100644 --- a/apps/dashboard/src/pages/settings.tsx +++ b/apps/dashboard/src/pages/settings.tsx @@ -3,9 +3,13 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/primitive import { OrganizationProfile, UserProfile } from '@clerk/clerk-react'; import { DashboardLayout } from '../components/dashboard-layout'; import { useNavigate, useLocation } from 'react-router-dom'; -import { ROUTES } from '@/utils/routes'; +import { LEGACY_ROUTES, ROUTES } from '@/utils/routes'; import { Appearance } from '@clerk/types'; import { motion } from 'motion/react'; +import { FeatureFlagsKeysEnum } from '@novu/shared'; +import { useFeatureFlag } from '../hooks/use-feature-flag'; +import { SubscriptionProvider } from '../components/billing/subscription-provider'; +import { Plan } from '../components/billing/plan'; const FADE_ANIMATION = { initial: { opacity: 0 }, From 069ca188eb32fbc90c98592fb4552b78aec8871c Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 5 Dec 2024 18:38:03 +0200 Subject: [PATCH 27/38] Update settings.tsx --- apps/dashboard/src/pages/settings.tsx | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/apps/dashboard/src/pages/settings.tsx b/apps/dashboard/src/pages/settings.tsx index 5aedb5b7bc5..26cb0ff166a 100644 --- a/apps/dashboard/src/pages/settings.tsx +++ b/apps/dashboard/src/pages/settings.tsx @@ -107,6 +107,15 @@ export function SettingsPage() { > Team + + {isV2BillingEnabled && ( + + Billing + + )}
From 27372c897ca2c8ee6911a1dc236154e75d2256be Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Thu, 5 Dec 2024 19:12:10 +0200 Subject: [PATCH 28/38] feat: pr review --- .source | 2 +- .../components/billing/active-plan-banner.tsx | 17 +++++++------ .../components/billing/plan-action-button.tsx | 20 ++++++++------- .../dashboard/src/components/billing/plan.tsx | 19 +------------- .../src/components/billing/plans-row.tsx | 4 --- .../test-workflow/test-workflow-tabs.tsx | 2 +- apps/dashboard/src/pages/settings.tsx | 25 ++++++------------- 7 files changed, 31 insertions(+), 58 deletions(-) diff --git a/.source b/.source index 7d2c4921031..0667afc26e1 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 7d2c49210316cf66120fd1e1e8c4cfa5a0ec09ff +Subproject commit 0667afc26e1d2172f991b2b6989c6296b7ea0640 diff --git a/apps/dashboard/src/components/billing/active-plan-banner.tsx b/apps/dashboard/src/components/billing/active-plan-banner.tsx index 50237a77ab5..a44dc94fed4 100644 --- a/apps/dashboard/src/components/billing/active-plan-banner.tsx +++ b/apps/dashboard/src/components/billing/active-plan-banner.tsx @@ -12,20 +12,22 @@ interface ActivePlanBannerProps { } export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerProps) { - const { data: subscription, isLoading } = useSubscription(); + const { data: subscription } = useSubscription(); const getProgressColor = (current: number, max: number) => { const percentage = (current / max) * 100; if (percentage > 90) return 'text-destructive'; if (percentage > 75) return 'text-warning'; - return 'text-success'; + + return ''; }; const getProgressBarColor = (current: number, max: number) => { const percentage = (current / max) * 100; - if (percentage > 90) return 'bg-destructive'; - if (percentage > 75) return 'bg-warning'; - return 'bg-success'; + if (percentage > 90) return 'bg-gradient-to-r from-red-500 to-red-400'; + if (percentage > 75) return 'bg-gradient-to-r from-amber-500 to-amber-400'; + + return 'bg-gradient-to-r from-emerald-500 to-emerald-400'; }; const formatDate = (date: string | number) => { @@ -65,7 +67,6 @@ export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerPr selectedBillingInterval={selectedBillingInterval} variant="outline" size="sm" - showIcon className="shrink-0" />
@@ -113,9 +114,9 @@ export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerPr ) : ( diff --git a/apps/dashboard/src/components/billing/plan-action-button.tsx b/apps/dashboard/src/components/billing/plan-action-button.tsx index a8a1c450844..6a67e2f85aa 100644 --- a/apps/dashboard/src/components/billing/plan-action-button.tsx +++ b/apps/dashboard/src/components/billing/plan-action-button.tsx @@ -5,7 +5,6 @@ import { get, post } from '../../api/api.client'; import { toast } from 'sonner'; import { useSubscription } from './hooks/use-subscription'; import { cn } from '../../utils/ui'; -import { ChevronRight } from 'lucide-react'; interface PlanActionButtonProps { selectedBillingInterval: 'month' | 'year'; @@ -18,14 +17,11 @@ interface PlanActionButtonProps { export function PlanActionButton({ selectedBillingInterval, variant = 'default', - showIcon = false, className, size = 'default', }: PlanActionButtonProps) { - const { data: subscription } = useSubscription(); + const { data: subscription, isLoading } = useSubscription(); const { trial, apiServiceLevel } = subscription || {}; - const isPaidSubscriptionActive = - subscription?.isActive && !trial?.isActive && apiServiceLevel !== ApiServiceLevelEnum.FREE; const { mutateAsync: goToPortal, isPending: isGoingToPortal } = useMutation({ mutationFn: () => get<{ data: string }>('/billing/portal?isV2Dashboard=true'), @@ -52,16 +48,22 @@ export function PlanActionButton({ }, }); + function isPaidSubscriptionActive() { + if (!subscription || !trial) return false; + + return subscription.isActive && !trial.isActive && apiServiceLevel !== ApiServiceLevelEnum.FREE; + } + return ( ); } diff --git a/apps/dashboard/src/components/billing/plan.tsx b/apps/dashboard/src/components/billing/plan.tsx index f59fe981129..8bdc434f340 100644 --- a/apps/dashboard/src/components/billing/plan.tsx +++ b/apps/dashboard/src/components/billing/plan.tsx @@ -7,12 +7,11 @@ import { PlansRow } from './plans-row'; import { HighlightsRow } from './highlights-row'; import { Features } from './features'; import { cn } from '../../utils/ui'; -import { Skeleton } from '../primitives/skeleton'; import { toast } from 'sonner'; export function Plan() { const segment = useSegment(); - const { isLoading, data } = useSubscription(); + const { data } = useSubscription(); const [selectedBillingInterval, setSelectedBillingInterval] = useState<'month' | 'year'>( data?.billingInterval || 'month' ); @@ -33,22 +32,6 @@ export function Plan() { segment.track('Billing Page Viewed'); }, [segment]); - if (isLoading) { - return ( -
- - -
- {Array.from({ length: 3 }).map((_, i) => ( - - ))} -
- - -
- ); - } - return (
diff --git a/apps/dashboard/src/components/billing/plans-row.tsx b/apps/dashboard/src/components/billing/plans-row.tsx index 7226b1ae54f..67dfdfb0072 100644 --- a/apps/dashboard/src/components/billing/plans-row.tsx +++ b/apps/dashboard/src/components/billing/plans-row.tsx @@ -57,14 +57,10 @@ export function PlansRow({ selectedBillingInterval }: PlansRowProps) { {/* Business Plan */} -
- POPULAR -

Business

- Most Popular
-
+
setIsContactSalesModalOpen(false)} + onClose={handleModalClose} intendedApiServiceLevel={ApiServiceLevelEnum.ENTERPRISE} /> diff --git a/apps/dashboard/src/components/billing/plan-action-button.tsx b/apps/dashboard/src/components/billing/plan-action-button.tsx index 6a67e2f85aa..8db7c5906c8 100644 --- a/apps/dashboard/src/components/billing/plan-action-button.tsx +++ b/apps/dashboard/src/components/billing/plan-action-button.tsx @@ -5,6 +5,8 @@ import { get, post } from '../../api/api.client'; import { toast } from 'sonner'; import { useSubscription } from './hooks/use-subscription'; import { cn } from '../../utils/ui'; +import { useTelemetry } from '../../hooks/use-telemetry'; +import { TelemetryEvent } from '../../utils/telemetry'; interface PlanActionButtonProps { selectedBillingInterval: 'month' | 'year'; @@ -20,46 +22,69 @@ export function PlanActionButton({ className, size = 'default', }: PlanActionButtonProps) { - const { data: subscription, isLoading } = useSubscription(); - const { trial, apiServiceLevel } = subscription || {}; + const { data, isLoading } = useSubscription(); + const track = useTelemetry(); - const { mutateAsync: goToPortal, isPending: isGoingToPortal } = useMutation({ - mutationFn: () => get<{ data: string }>('/billing/portal?isV2Dashboard=true'), - onSuccess: (data) => { - window.location.href = data.data; - }, - onError: (error) => { - toast.error(error instanceof Error ? error.message : 'Unexpected error occurred'); - }, - }); + const isPaidSubscriptionActive = () => { + return data?.isActive && !data?.trial?.isActive && data?.apiServiceLevel !== ApiServiceLevelEnum.FREE; + }; - const { mutateAsync: checkout, isPending: isCheckingOut } = useMutation({ - mutationFn: () => - post<{ data: { stripeCheckoutUrl: string } }>('/billing/checkout-session', { + const { mutateAsync: checkout, isLoading: isCheckingOut } = useMutation( + () => + post('/v1/billing/checkout-session', { billingInterval: selectedBillingInterval, apiServiceLevel: ApiServiceLevelEnum.BUSINESS, - isV2Dashboard: true, }), - onSuccess: (data) => { - window.location.href = data.data.stripeCheckoutUrl; + { + onSuccess: (data) => { + track(TelemetryEvent.BILLING_UPGRADE_INITIATED, { + fromPlan: data?.apiServiceLevel, + toPlan: ApiServiceLevelEnum.BUSINESS, + billingInterval: selectedBillingInterval, + }); + window.location.href = data.stripeCheckoutUrl; + }, + onError: (error: Error) => { + track(TelemetryEvent.BILLING_UPGRADE_ERROR, { + error: error.message, + billingInterval: selectedBillingInterval, + }); + toast.error(error.message || 'Unexpected error'); + }, + } + ); + + const { mutateAsync: goToPortal, isLoading: isGoingToPortal } = useMutation(() => get('/v1/billing/portal'), { + onSuccess: (url) => { + track(TelemetryEvent.BILLING_PORTAL_ACCESSED, { + currentPlan: data?.apiServiceLevel, + billingInterval: selectedBillingInterval, + }); + window.location.href = url; }, - onError: (error) => { - toast.error(error instanceof Error ? error.message : 'Unexpected error occurred'); + onError: (error: Error) => { + track(TelemetryEvent.BILLING_PORTAL_ERROR, { + error: error.message, + currentPlan: data?.apiServiceLevel, + }); + toast.error(error.message || 'Unexpected error'); }, }); - function isPaidSubscriptionActive() { - if (!subscription || !trial) return false; - - return subscription.isActive && !trial.isActive && apiServiceLevel !== ApiServiceLevelEnum.FREE; - } + const handleAction = () => { + if (isPaidSubscriptionActive()) { + goToPortal(); + } else { + checkout(); + } + }; return (
@@ -86,40 +84,43 @@ export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerPr
- {!subscription ? ( - <> - - - - ) : ( + {subscription ? ( <>
- {subscription.events.current.toLocaleString()} + {subscription.events.current?.toLocaleString() ?? 0} {' '} - of {subscription.events.included.toLocaleString()} events + of {subscription.events.included?.toLocaleString() ?? 0} events
Updates hourly + ) : ( + <> + + + )}
- {!subscription ? ( - - ) : ( + {subscription ? ( + ) : ( + )}
diff --git a/apps/dashboard/src/components/billing/hooks/use-subscription.ts b/apps/dashboard/src/components/billing/hooks/use-subscription.ts deleted file mode 100644 index 4083d2bab17..00000000000 --- a/apps/dashboard/src/components/billing/hooks/use-subscription.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { useQuery } from '@tanstack/react-query'; -import { ApiServiceLevelEnum } from '@novu/shared'; -import { get } from '../../../api/api.client'; -import { differenceInDays, isSameDay } from 'date-fns'; -import { useMemo } from 'react'; - -export interface ISubscriptionData { - apiServiceLevel: ApiServiceLevelEnum; - isActive: boolean; - hasPaymentMethod: boolean; - status: string; - currentPeriodStart: string | null; - currentPeriodEnd: string | null; - billingInterval: 'month' | 'year' | null; - events: { - current: number; - included: number; - }; - trial: { - isActive: boolean; - start: string; - end: string; - daysTotal: number; - daysLeft: number; - }; -} - -export interface UseSubscriptionType { - isLoading: boolean; - data: ISubscriptionData | null; -} - -export function useSubscription(): UseSubscriptionType { - const { data, isLoading } = useQuery<{ data: ISubscriptionData | null }>({ - queryKey: ['subscription'], - queryFn: () => get('/billing/subscription'), - }); - - const enrichedData = useMemo(() => { - if (!data?.data) return null; - - const today = new Date(); - const daysLeft = !data.data.trial.end - ? 0 - : isSameDay(new Date(data.data.trial.end), today) - ? 0 - : differenceInDays(new Date(data.data.trial.end), today); - - return { - ...data.data, - trial: { - ...data.data.trial, - daysLeft, - }, - }; - }, [data?.data]); - - return { - isLoading, - data: enrichedData, - }; -} diff --git a/apps/dashboard/src/components/billing/plan-action-button.tsx b/apps/dashboard/src/components/billing/plan-action-button.tsx index 8db7c5906c8..34cd8dd1287 100644 --- a/apps/dashboard/src/components/billing/plan-action-button.tsx +++ b/apps/dashboard/src/components/billing/plan-action-button.tsx @@ -3,10 +3,10 @@ import { ApiServiceLevelEnum } from '@novu/shared'; import { useMutation } from '@tanstack/react-query'; import { get, post } from '../../api/api.client'; import { toast } from 'sonner'; -import { useSubscription } from './hooks/use-subscription'; import { cn } from '../../utils/ui'; import { useTelemetry } from '../../hooks/use-telemetry'; import { TelemetryEvent } from '../../utils/telemetry'; +import { useFetchSubscription } from '../../hooks/use-fetch-subscription'; interface PlanActionButtonProps { selectedBillingInterval: 'month' | 'year'; @@ -16,45 +16,53 @@ interface PlanActionButtonProps { size?: 'default' | 'sm' | 'lg'; } +interface CheckoutResponse { + stripeCheckoutUrl: string; + apiServiceLevel: ApiServiceLevelEnum; +} + +interface CheckoutVariables { + billingInterval: 'month' | 'year'; + apiServiceLevel: ApiServiceLevelEnum; +} + export function PlanActionButton({ selectedBillingInterval, variant = 'default', className, size = 'default', }: PlanActionButtonProps) { - const { data, isLoading } = useSubscription(); + const { subscription: data, isLoading } = useFetchSubscription(); const track = useTelemetry(); const isPaidSubscriptionActive = () => { return data?.isActive && !data?.trial?.isActive && data?.apiServiceLevel !== ApiServiceLevelEnum.FREE; }; - const { mutateAsync: checkout, isLoading: isCheckingOut } = useMutation( - () => - post('/v1/billing/checkout-session', { - billingInterval: selectedBillingInterval, - apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + const { mutateAsync: checkout, isPending: isCheckingOut } = useMutation({ + mutationFn: (variables: CheckoutVariables) => + post('/v1/billing/checkout-session', { + body: variables, }), - { - onSuccess: (data) => { - track(TelemetryEvent.BILLING_UPGRADE_INITIATED, { - fromPlan: data?.apiServiceLevel, - toPlan: ApiServiceLevelEnum.BUSINESS, - billingInterval: selectedBillingInterval, - }); - window.location.href = data.stripeCheckoutUrl; - }, - onError: (error: Error) => { - track(TelemetryEvent.BILLING_UPGRADE_ERROR, { - error: error.message, - billingInterval: selectedBillingInterval, - }); - toast.error(error.message || 'Unexpected error'); - }, - } - ); + onSuccess: (data) => { + track(TelemetryEvent.BILLING_UPGRADE_INITIATED, { + fromPlan: data.apiServiceLevel, + toPlan: ApiServiceLevelEnum.BUSINESS, + billingInterval: selectedBillingInterval, + }); + window.location.href = data.stripeCheckoutUrl; + }, + onError: (error: Error) => { + track(TelemetryEvent.BILLING_UPGRADE_ERROR, { + error: error.message, + billingInterval: selectedBillingInterval, + }); + toast.error(error.message || 'Unexpected error'); + }, + }); - const { mutateAsync: goToPortal, isLoading: isGoingToPortal } = useMutation(() => get('/v1/billing/portal'), { + const { mutateAsync: goToPortal, isPending: isGoingToPortal } = useMutation({ + mutationFn: () => get('/v1/billing/portal'), onSuccess: (url) => { track(TelemetryEvent.BILLING_PORTAL_ACCESSED, { currentPlan: data?.apiServiceLevel, @@ -75,7 +83,7 @@ export function PlanActionButton({ if (isPaidSubscriptionActive()) { goToPortal(); } else { - checkout(); + checkout({ billingInterval: selectedBillingInterval, apiServiceLevel: ApiServiceLevelEnum.BUSINESS }); } }; diff --git a/apps/dashboard/src/components/billing/plan.tsx b/apps/dashboard/src/components/billing/plan.tsx index 968f59f7880..26e8c090a2a 100644 --- a/apps/dashboard/src/components/billing/plan.tsx +++ b/apps/dashboard/src/components/billing/plan.tsx @@ -1,6 +1,4 @@ import { useEffect, useState } from 'react'; -import { useSegment } from '../../context/segment'; -import { useSubscription } from './hooks/use-subscription'; import { ActivePlanBanner } from './active-plan-banner'; import { PlanSwitcher } from './plan-switcher'; import { PlansRow } from './plans-row'; @@ -10,11 +8,11 @@ import { cn } from '../../utils/ui'; import { toast } from 'sonner'; import { useTelemetry } from '../../hooks/use-telemetry'; import { TelemetryEvent } from '../../utils/telemetry'; +import { useFetchSubscription } from '../../hooks/use-fetch-subscription'; export function Plan() { - const segment = useSegment(); const track = useTelemetry(); - const { data } = useSubscription(); + const { subscription: data } = useFetchSubscription(); const [selectedBillingInterval, setSelectedBillingInterval] = useState<'month' | 'year'>( data?.billingInterval || 'month' ); @@ -47,7 +45,7 @@ export function Plan() { isTrialActive: data?.trial?.isActive, }); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [segment]); + }, []); const handleBillingIntervalChange = (interval: 'month' | 'year') => { track(TelemetryEvent.BILLING_INTERVAL_CHANGED, { diff --git a/apps/dashboard/src/components/billing/subscription-provider.tsx b/apps/dashboard/src/components/billing/subscription-provider.tsx deleted file mode 100644 index 93307a2ac36..00000000000 --- a/apps/dashboard/src/components/billing/subscription-provider.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { createContext, useContext } from 'react'; -import { useSubscription, type UseSubscriptionType } from './hooks/use-subscription'; - -const SubscriptionContext = createContext({ - isLoading: false, - data: null, -}); - -export const useSubscriptionContext = () => useContext(SubscriptionContext); - -export function SubscriptionProvider({ children }: { children: React.ReactNode }) { - const props = useSubscription(); - - return {children}; -} diff --git a/apps/dashboard/src/pages/settings.tsx b/apps/dashboard/src/pages/settings.tsx index 666f03466be..ea7060e317c 100644 --- a/apps/dashboard/src/pages/settings.tsx +++ b/apps/dashboard/src/pages/settings.tsx @@ -153,9 +153,7 @@ export function SettingsPage() { - - - + From c837b979c601b4b6c897d2963b6cf8d17ddb3799 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Sun, 8 Dec 2024 10:29:44 +0200 Subject: [PATCH 36/38] fix: navigation link and pr comments --- .source | 2 +- .../components/billing/active-plan-banner.tsx | 2 +- .../components/billing/plan-action-button.tsx | 70 +++---------------- .../dashboard/src/hooks/use-billing-portal.ts | 30 ++++++++ .../src/hooks/use-checkout-session.ts | 48 +++++++++++++ 5 files changed, 89 insertions(+), 63 deletions(-) create mode 100644 apps/dashboard/src/hooks/use-billing-portal.ts create mode 100644 apps/dashboard/src/hooks/use-checkout-session.ts diff --git a/.source b/.source index 0667afc26e1..e35355f6aa8 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit 0667afc26e1d2172f991b2b6989c6296b7ea0640 +Subproject commit e35355f6aa816aa0d9a953e63fc1c992594a5b64 diff --git a/apps/dashboard/src/components/billing/active-plan-banner.tsx b/apps/dashboard/src/components/billing/active-plan-banner.tsx index 33456301475..1dfbc6bfd2a 100644 --- a/apps/dashboard/src/components/billing/active-plan-banner.tsx +++ b/apps/dashboard/src/components/billing/active-plan-banner.tsx @@ -48,7 +48,7 @@ export function ActivePlanBanner({ selectedBillingInterval }: ActivePlanBannerPr {!subscription ? ( ) : ( -

{subscription.apiServiceLevel.toLowerCase()}

+

{subscription.apiServiceLevel?.toLowerCase()}

)} {subscription?.trial.isActive && ( diff --git a/apps/dashboard/src/components/billing/plan-action-button.tsx b/apps/dashboard/src/components/billing/plan-action-button.tsx index 34cd8dd1287..8b7a7779aa0 100644 --- a/apps/dashboard/src/components/billing/plan-action-button.tsx +++ b/apps/dashboard/src/components/billing/plan-action-button.tsx @@ -1,12 +1,9 @@ import { Button } from '@/components/primitives/button'; import { ApiServiceLevelEnum } from '@novu/shared'; -import { useMutation } from '@tanstack/react-query'; -import { get, post } from '../../api/api.client'; -import { toast } from 'sonner'; import { cn } from '../../utils/ui'; -import { useTelemetry } from '../../hooks/use-telemetry'; -import { TelemetryEvent } from '../../utils/telemetry'; import { useFetchSubscription } from '../../hooks/use-fetch-subscription'; +import { useCheckoutSession } from '../../hooks/use-checkout-session'; +import { useBillingPortal } from '../../hooks/use-billing-portal'; interface PlanActionButtonProps { selectedBillingInterval: 'month' | 'year'; @@ -16,74 +13,25 @@ interface PlanActionButtonProps { size?: 'default' | 'sm' | 'lg'; } -interface CheckoutResponse { - stripeCheckoutUrl: string; - apiServiceLevel: ApiServiceLevelEnum; -} - -interface CheckoutVariables { - billingInterval: 'month' | 'year'; - apiServiceLevel: ApiServiceLevelEnum; -} - export function PlanActionButton({ selectedBillingInterval, variant = 'default', className, size = 'default', }: PlanActionButtonProps) { - const { subscription: data, isLoading } = useFetchSubscription(); - const track = useTelemetry(); + const { subscription: data, isLoading: isLoadingSubscription } = useFetchSubscription(); + const { navigateToCheckout, isLoading: isCheckingOut } = useCheckoutSession(); + const { navigateToPortal, isLoading: isLoadingPortal } = useBillingPortal(selectedBillingInterval); const isPaidSubscriptionActive = () => { return data?.isActive && !data?.trial?.isActive && data?.apiServiceLevel !== ApiServiceLevelEnum.FREE; }; - const { mutateAsync: checkout, isPending: isCheckingOut } = useMutation({ - mutationFn: (variables: CheckoutVariables) => - post('/v1/billing/checkout-session', { - body: variables, - }), - onSuccess: (data) => { - track(TelemetryEvent.BILLING_UPGRADE_INITIATED, { - fromPlan: data.apiServiceLevel, - toPlan: ApiServiceLevelEnum.BUSINESS, - billingInterval: selectedBillingInterval, - }); - window.location.href = data.stripeCheckoutUrl; - }, - onError: (error: Error) => { - track(TelemetryEvent.BILLING_UPGRADE_ERROR, { - error: error.message, - billingInterval: selectedBillingInterval, - }); - toast.error(error.message || 'Unexpected error'); - }, - }); - - const { mutateAsync: goToPortal, isPending: isGoingToPortal } = useMutation({ - mutationFn: () => get('/v1/billing/portal'), - onSuccess: (url) => { - track(TelemetryEvent.BILLING_PORTAL_ACCESSED, { - currentPlan: data?.apiServiceLevel, - billingInterval: selectedBillingInterval, - }); - window.location.href = url; - }, - onError: (error: Error) => { - track(TelemetryEvent.BILLING_PORTAL_ERROR, { - error: error.message, - currentPlan: data?.apiServiceLevel, - }); - toast.error(error.message || 'Unexpected error'); - }, - }); - const handleAction = () => { if (isPaidSubscriptionActive()) { - goToPortal(); + navigateToPortal(); } else { - checkout({ billingInterval: selectedBillingInterval, apiServiceLevel: ApiServiceLevelEnum.BUSINESS }); + navigateToCheckout(selectedBillingInterval); } }; @@ -93,8 +41,8 @@ export function PlanActionButton({ size={size} className={cn('gap-2', className)} onClick={handleAction} - disabled={isGoingToPortal} - isLoading={isCheckingOut || isLoading} + disabled={isLoadingPortal} + isLoading={isCheckingOut || isLoadingSubscription} > {isPaidSubscriptionActive() ? 'Manage Account' : 'Upgrade plan'} diff --git a/apps/dashboard/src/hooks/use-billing-portal.ts b/apps/dashboard/src/hooks/use-billing-portal.ts new file mode 100644 index 00000000000..08a9a612044 --- /dev/null +++ b/apps/dashboard/src/hooks/use-billing-portal.ts @@ -0,0 +1,30 @@ +import { useMutation } from '@tanstack/react-query'; +import { get } from '../api/api.client'; +import { toast } from 'sonner'; +import { useTelemetry } from './use-telemetry'; +import { TelemetryEvent } from '../utils/telemetry'; + +export function useBillingPortal(billingInterval?: 'month' | 'year') { + const track = useTelemetry(); + + const { mutateAsync: navigateToPortal, isPending: isLoading } = useMutation({ + mutationFn: () => get<{ data: string }>('/billing/portal?isV2Dashboard=true'), + onSuccess: (response) => { + track(TelemetryEvent.BILLING_PORTAL_ACCESSED, { + billingInterval, + }); + window.location.href = response?.data; + }, + onError: (error: Error) => { + track(TelemetryEvent.BILLING_PORTAL_ERROR, { + error: error.message, + }); + toast.error(error.message || 'Unexpected error'); + }, + }); + + return { + navigateToPortal, + isLoading, + }; +} diff --git a/apps/dashboard/src/hooks/use-checkout-session.ts b/apps/dashboard/src/hooks/use-checkout-session.ts new file mode 100644 index 00000000000..cb508410eff --- /dev/null +++ b/apps/dashboard/src/hooks/use-checkout-session.ts @@ -0,0 +1,48 @@ +import { useMutation } from '@tanstack/react-query'; +import { ApiServiceLevelEnum } from '@novu/shared'; +import { post } from '../api/api.client'; +import { toast } from 'sonner'; +import { useTelemetry } from './use-telemetry'; +import { TelemetryEvent } from '../utils/telemetry'; + +interface CheckoutResponse { + data: { + stripeCheckoutUrl: string; + apiServiceLevel: ApiServiceLevelEnum; + }; +} + +export function useCheckoutSession() { + const track = useTelemetry(); + + const { mutateAsync: navigateToCheckout, isPending: isLoading } = useMutation({ + mutationFn: (billingInterval: 'month' | 'year') => + post('/billing/checkout-session', { + body: { + billingInterval, + apiServiceLevel: ApiServiceLevelEnum.BUSINESS, + isV2Dashboard: true, + }, + }), + onSuccess: (response, billingInterval) => { + track(TelemetryEvent.BILLING_UPGRADE_INITIATED, { + fromPlan: response.data.apiServiceLevel, + toPlan: ApiServiceLevelEnum.BUSINESS, + billingInterval, + }); + window.location.href = response.data.stripeCheckoutUrl; + }, + onError: (error: Error, billingInterval) => { + track(TelemetryEvent.BILLING_UPGRADE_ERROR, { + error: error.message, + billingInterval, + }); + toast.error(error.message || 'Unexpected error'); + }, + }); + + return { + navigateToCheckout, + isLoading, + }; +} From 372dd8927444cd7b17a00dc436af3e450197d246 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Sun, 8 Dec 2024 10:50:53 +0200 Subject: [PATCH 37/38] fix: lint errors --- .../dashboard/src/components/side-navigation/free-trial-card.tsx | 1 - apps/dashboard/src/pages/settings.tsx | 1 - 2 files changed, 2 deletions(-) diff --git a/apps/dashboard/src/components/side-navigation/free-trial-card.tsx b/apps/dashboard/src/components/side-navigation/free-trial-card.tsx index ad00bba3190..a490217f878 100644 --- a/apps/dashboard/src/components/side-navigation/free-trial-card.tsx +++ b/apps/dashboard/src/components/side-navigation/free-trial-card.tsx @@ -4,7 +4,6 @@ import { Progress } from '../primitives/progress'; import { Button } from '../primitives/button'; import { Tooltip, TooltipContent, TooltipTrigger, TooltipArrow } from '../primitives/tooltip'; import { LEGACY_ROUTES, ROUTES } from '@/utils/routes'; -import { useBillingSubscription } from '@/hooks/use-billing-subscription'; import { Link } from 'react-router-dom'; import { useFeatureFlag } from '@/hooks/use-feature-flag'; import { FeatureFlagsKeysEnum } from '@novu/shared'; diff --git a/apps/dashboard/src/pages/settings.tsx b/apps/dashboard/src/pages/settings.tsx index ea7060e317c..6817e0d1117 100644 --- a/apps/dashboard/src/pages/settings.tsx +++ b/apps/dashboard/src/pages/settings.tsx @@ -8,7 +8,6 @@ import { Appearance } from '@clerk/types'; import { motion } from 'motion/react'; import { FeatureFlagsKeysEnum } from '@novu/shared'; import { useFeatureFlag } from '../hooks/use-feature-flag'; -import { SubscriptionProvider } from '../components/billing/subscription-provider'; import { Plan } from '../components/billing/plan'; const FADE_ANIMATION = { From bdf809299605fefd4d3eadb8f7bf5a3845f766d7 Mon Sep 17 00:00:00 2001 From: Dima Grossman Date: Mon, 9 Dec 2024 11:36:46 +0200 Subject: [PATCH 38/38] fix: pointer --- .source | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.source b/.source index e35355f6aa8..1eaf99c1369 160000 --- a/.source +++ b/.source @@ -1 +1 @@ -Subproject commit e35355f6aa816aa0d9a953e63fc1c992594a5b64 +Subproject commit 1eaf99c1369b3d2fe6eeaa062462ff6027435992