From 44c37b11589b690d6caa96d05b1248c331f054d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Tymczuk?= Date: Fri, 4 Oct 2024 21:27:04 +0200 Subject: [PATCH] chore(dashboard): free trial card (#6612) --- apps/dashboard/package.json | 7 +- apps/dashboard/src/api/billing.ts | 8 ++ apps/dashboard/src/components/icons/index.ts | 1 + .../src/components/icons/logo-circle.tsx | 46 +++++++++ .../src/components/primitives/button.tsx | 3 + .../src/components/primitives/progress.tsx | 44 +++++++++ .../src/components/primitives/tooltip.tsx | 71 ++++++++++++++ apps/dashboard/src/components/show.tsx | 5 + .../side-navigation/free-trial-card.tsx | 73 ++++++++++++++ .../side-navigation/side-navigation.tsx | 5 +- .../src/components/side-navigation/types.ts | 1 - apps/dashboard/src/hooks/index.ts | 1 + .../src/hooks/use-billing-subscription.ts | 37 +++++++ apps/dashboard/src/utils/query-keys.ts | 1 + apps/dashboard/src/utils/routes.ts | 1 + pnpm-lock.yaml | 97 ++++++++++++++++++- 16 files changed, 394 insertions(+), 7 deletions(-) create mode 100644 apps/dashboard/src/api/billing.ts create mode 100644 apps/dashboard/src/components/icons/index.ts create mode 100644 apps/dashboard/src/components/icons/logo-circle.tsx create mode 100644 apps/dashboard/src/components/primitives/progress.tsx create mode 100644 apps/dashboard/src/components/primitives/tooltip.tsx create mode 100644 apps/dashboard/src/components/show.tsx create mode 100644 apps/dashboard/src/components/side-navigation/free-trial-card.tsx create mode 100644 apps/dashboard/src/hooks/index.ts create mode 100644 apps/dashboard/src/hooks/use-billing-subscription.ts diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index e374c10a7a4..f0e95e2e0c9 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -23,16 +23,19 @@ "@clerk/clerk-react": "^5.2.5", "@hookform/resolvers": "^2.9.1", "@novu/react": "^2.3.0", - "@novu/shared": "^2.1.0", + "@novu/shared": "workspace:*", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-tooltip": "^1.1.3", "@segment/analytics-next": "^1.73.0", "@tanstack/react-query": "^4.20.4", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "lucide-react": "^0.439.0", "mixpanel-browser": "^2.52.0", "react": "^18.3.1", @@ -49,8 +52,8 @@ "@clerk/types": "^4.6.1", "@eslint/js": "^9.9.0", "@playwright/test": "^1.44.0", - "@types/node": "^22.7.0", "@types/mixpanel-browser": "^2.49.0", + "@types/node": "^22.7.0", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", diff --git a/apps/dashboard/src/api/billing.ts b/apps/dashboard/src/api/billing.ts new file mode 100644 index 00000000000..85371ebf06d --- /dev/null +++ b/apps/dashboard/src/api/billing.ts @@ -0,0 +1,8 @@ +import type { GetSubscriptionDto } from '@novu/shared'; +import { get } from './api.client'; + +export async function getBillingSubscription() { + const { data } = await get<{ data: GetSubscriptionDto }>('/billing/subscription'); + + return data; +} diff --git a/apps/dashboard/src/components/icons/index.ts b/apps/dashboard/src/components/icons/index.ts new file mode 100644 index 00000000000..81f119288c5 --- /dev/null +++ b/apps/dashboard/src/components/icons/index.ts @@ -0,0 +1 @@ +export * from './logo-circle'; diff --git a/apps/dashboard/src/components/icons/logo-circle.tsx b/apps/dashboard/src/components/icons/logo-circle.tsx new file mode 100644 index 00000000000..327eb393b7c --- /dev/null +++ b/apps/dashboard/src/components/icons/logo-circle.tsx @@ -0,0 +1,46 @@ +export const LogoCircle = (props: React.ComponentPropsWithoutRef<'svg'>) => { + return ( + + + + + + + + + + + + + + + + + + + + + ); +}; diff --git a/apps/dashboard/src/components/primitives/button.tsx b/apps/dashboard/src/components/primitives/button.tsx index fa8916619ee..d420c60783c 100644 --- a/apps/dashboard/src/components/primitives/button.tsx +++ b/apps/dashboard/src/components/primitives/button.tsx @@ -16,6 +16,9 @@ const buttonVariants = cva( 'bg-gradient-to-b from-destructive/90 to-destructive text-destructive-foreground shadow-[inset_0_-4px_2px_-2px_hsl(var(--destructive)),inset_0_0_0_1px_rgba(255,255,255,0.16),0_0_0_1px_hsl(var(--destructive)),0px_1px_2px_0px_#0E121B3D] after:content-[""] after:absolute after:w-full after:h-full after:bg-gradient-to-b after:from-background/10 after:opacity-0 hover:after:opacity-100 after:rounded-lg after:transition-opacity after:duration-300', outline: 'border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent', + link: 'text-primary underline-offset-4 hover:underline', + light: + 'bg-destructive/10 hover:bg-background hover:border hover:border-destructive text-destructive focus-visible:ring-destructive/10 focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:bg-background focus-visible:border focus-visible:border-destructive', }, size: { default: 'h-9 p-2.5', diff --git a/apps/dashboard/src/components/primitives/progress.tsx b/apps/dashboard/src/components/primitives/progress.tsx new file mode 100644 index 00000000000..fcbb3e9dab1 --- /dev/null +++ b/apps/dashboard/src/components/primitives/progress.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import * as ProgressPrimitive from '@radix-ui/react-progress'; + +import { cn } from '@/utils/ui'; +import { cva, VariantProps } from 'class-variance-authority'; + +const indicatorVariants = cva(`h-full w-full flex-1 transition-all`, { + variants: { + variant: { + default: 'bg-neutral-800', + novu: 'bg-information', + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +type ProgressProps = React.ComponentPropsWithoutRef & + VariantProps; + +const Progress = React.forwardRef, ProgressProps>( + ({ className, variant, value, max, ...props }, ref) => { + const percentage = (value ?? 100) / (max ?? 100); + const translateX = (percentage - 1) * 100; + + return ( + + + + ); + } +); +Progress.displayName = ProgressPrimitive.Root.displayName; + +export { Progress }; diff --git a/apps/dashboard/src/components/primitives/tooltip.tsx b/apps/dashboard/src/components/primitives/tooltip.tsx new file mode 100644 index 00000000000..f2440a87f10 --- /dev/null +++ b/apps/dashboard/src/components/primitives/tooltip.tsx @@ -0,0 +1,71 @@ +import * as React from 'react'; +import * as TooltipPrimitive from '@radix-ui/react-tooltip'; + +import { cn } from '@/utils/ui'; +import { cva, VariantProps } from 'class-variance-authority'; + +const TooltipProvider = TooltipPrimitive.Provider; + +const Tooltip = TooltipPrimitive.Root; + +const TooltipTrigger = TooltipPrimitive.Trigger; + +const tooltipContentVariants = cva( + `z-50 overflow-hidden px-3 py-1.5 text-xs animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2`, + { + variants: { + variant: { + default: 'bg-primary text-primary-foreground', + light: 'border border-stroke/20 bg-background shadow-xs', + }, + size: { + default: 'rounded-md', + '2xs': '', + xs: '', + lg: 'p-3 w-72 rounded-[12px]', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +); + +type TooltipContentProps = React.ComponentPropsWithoutRef & + VariantProps; + +const TooltipContent = React.forwardRef, TooltipContentProps>( + ({ className, sideOffset = 4, variant, size, ...props }, ref) => ( + + ) +); + +const tooltipArrowVariants = cva(``, { + variants: { + variant: { + default: 'fill-primary', + light: 'fill-background drop-shadow-[0_0_0_rgb(0,0,0)]', + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +type TooltipArrowProps = React.ComponentPropsWithoutRef & + VariantProps; + +const TooltipArrow = React.forwardRef, TooltipArrowProps>( + ({ className, variant, ...props }, ref) => ( + + ) +); + +TooltipContent.displayName = TooltipPrimitive.Content.displayName; + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider, TooltipArrow }; diff --git a/apps/dashboard/src/components/show.tsx b/apps/dashboard/src/components/show.tsx new file mode 100644 index 00000000000..81552ef0cc3 --- /dev/null +++ b/apps/dashboard/src/components/show.tsx @@ -0,0 +1,5 @@ +import { ReactNode } from 'react'; + +export const Show = ({ when, fallback, children }: { when: boolean; fallback?: ReactNode; children: ReactNode }) => { + return when ? children : fallback ? fallback : null; +}; diff --git a/apps/dashboard/src/components/side-navigation/free-trial-card.tsx b/apps/dashboard/src/components/side-navigation/free-trial-card.tsx new file mode 100644 index 00000000000..6568bcde2c2 --- /dev/null +++ b/apps/dashboard/src/components/side-navigation/free-trial-card.tsx @@ -0,0 +1,73 @@ +import { useBillingSubscription } from '@/hooks'; +import { LogoCircle } from '../icons'; +import { RiArrowRightDoubleLine, RiInformationFill } from 'react-icons/ri'; +import { Progress } from '../primitives/progress'; +import { Button } from '../primitives/button'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger, TooltipArrow } from '../primitives/tooltip'; +import { LEGACY_ROUTES } from '@/utils/routes'; + +const transition = 'transition-all duration-300 ease-out'; + +const pluralizeDaysLeft = (numberOfDays: number) => { + return `${numberOfDays} day${numberOfDays > 1 ? 's' : ''}`; +}; + +export const FreeTrialCard = () => { + const { subscription, daysLeft, isLoading } = useBillingSubscription(); + + if (isLoading || !subscription || !subscription.trial.isActive || subscription?.hasPaymentMethod) { + return null; + } + + const pluralizedDays = pluralizeDaysLeft(daysLeft); + + 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}. + +
+ 0 ? subscription.trial.daysTotal : 100} /> +
+
+ +
+
+ ); +}; diff --git a/apps/dashboard/src/components/side-navigation/side-navigation.tsx b/apps/dashboard/src/components/side-navigation/side-navigation.tsx index c832003f93e..70b5ff5be1a 100644 --- a/apps/dashboard/src/components/side-navigation/side-navigation.tsx +++ b/apps/dashboard/src/components/side-navigation/side-navigation.tsx @@ -8,6 +8,7 @@ import { useEnvironment } from '@/context/environment/hooks'; import { OrganizationDropdown } from './organization-dropdown'; import { navigationItems } from './constants'; import { NavItemsGroup, NavItem } from './types'; +import { FreeTrialCard } from './free-trial-card'; const linkVariants = cva( `flex items-center gap-2 text-sm py-1.5 px-3 rounded-lg focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring `, @@ -50,7 +51,6 @@ const NavLink = ({ to, isExternal, className, variant, children }: NavLinkProps) ); } - return ( {children} @@ -94,7 +94,8 @@ export const SideNavigation = () => { const onEnvironmentChange = (value: string) => switchEnvironment(value); return ( -