From 8f65a94747323d2ee8372ace21a64f658d4d8852 Mon Sep 17 00:00:00 2001 From: nils Date: Wed, 8 Jan 2025 12:57:14 +0100 Subject: [PATCH] implement the trial summary popup --- src/components/plan-icon.tsx | 5 +- src/hooks/feature-flag.ts | 10 +- src/intl/en.json | 18 ++++ src/layouts/main/estimated-costs.tsx | 4 +- src/layouts/main/main-layout.tsx | 20 ++-- src/layouts/main/organization-plan.tsx | 110 ++++++++++++++++++++++ src/layouts/main/user-menu.tsx | 5 + src/modules/trial/trial-summary-popup.tsx | 60 ++++++++++++ 8 files changed, 220 insertions(+), 12 deletions(-) create mode 100644 src/layouts/main/organization-plan.tsx create mode 100644 src/modules/trial/trial-summary-popup.tsx diff --git a/src/components/plan-icon.tsx b/src/components/plan-icon.tsx index 8403d5d..a5d3e53 100644 --- a/src/components/plan-icon.tsx +++ b/src/components/plan-icon.tsx @@ -3,8 +3,8 @@ import { SvgComponent, SvgProps } from 'src/application/types'; import { IconCrown, IconGem, IconRocket } from './icons'; -export function PlanIcon({ plan, ...props }: { plan: OrganizationPlan } & SvgProps) { - const Icon = map[plan]; +export function PlanIcon({ plan, ...props }: { plan?: OrganizationPlan } & SvgProps) { + const Icon = map[plan!]; if (Icon) { return ; @@ -15,6 +15,7 @@ export function PlanIcon({ plan, ...props }: { plan: OrganizationPlan } & SvgPro const map: Partial> = { starter: IconRocket, + startup: IconGem, pro: IconGem, scale: IconCrown, }; diff --git a/src/hooks/feature-flag.ts b/src/hooks/feature-flag.ts index 9c8a848..fe6682d 100644 --- a/src/hooks/feature-flag.ts +++ b/src/hooks/feature-flag.ts @@ -3,11 +3,17 @@ import { useFeatureFlagEnabled } from 'posthog-js/react'; import { useMemo } from 'react'; import { z } from 'zod'; -export function FeatureFlag({ feature, children }: { feature: string; children: React.ReactNode }) { +type FeatureFlagProps = { + feature: string; + fallback?: React.ReactNode; + children: React.ReactNode; +}; + +export function FeatureFlag({ feature, fallback = null, children }: FeatureFlagProps) { const enabled = useFeatureFlag(feature); if (!enabled) { - return null; + return fallback; } return children; diff --git a/src/intl/en.json b/src/intl/en.json index 231926f..f3b52c3 100644 --- a/src/intl/en.json +++ b/src/intl/en.json @@ -1283,6 +1283,14 @@ "banner": { "content": "Your trial is active for the next {days, plural, =1 {{days} day} other {{days} days}} or until your credit runs out. Enjoy exploring or upgrade to Pro!" }, + "summaryPopup": { + "currentPlan": "{plan} Plan", + "badge": "Trial", + "usage": "Usage", + "creditLeft": "Trial credit left", + "timeLeft": "{days, plural, =1 {{days} day} other {{days} days}} of trial left", + "cta": "Go to billing" + }, "ended": { "planItem": { "upgrade": "Upgrade to {plan} Plan", @@ -1376,6 +1384,16 @@ "system": "System", "logout": "Log out" }, + "organizationPlan": { + "currentPlan": "{plan} Plan", + "upgrade": "Upgrade now", + "free": "Free", + "upsell": { + "title": "Get the full experience", + "description": "Upgrade to the Starter plan and get $20 of credit on your first invoice to try the full Koyeb experience", + "cta": "Upgrade" + } + }, "estimatedCost": { "currentPlan": "{plan} plan", "free": "Free", diff --git a/src/layouts/main/estimated-costs.tsx b/src/layouts/main/estimated-costs.tsx index 8fca29c..2cebb22 100644 --- a/src/layouts/main/estimated-costs.tsx +++ b/src/layouts/main/estimated-costs.tsx @@ -20,7 +20,7 @@ export function EstimatedCosts() { } return ( -
+ <>
@@ -37,7 +37,7 @@ export function EstimatedCosts() { {nextInvoiceQuery.isSuccess && } )} -
+ ); } diff --git a/src/layouts/main/main-layout.tsx b/src/layouts/main/main-layout.tsx index a8b36ab..1b59b04 100644 --- a/src/layouts/main/main-layout.tsx +++ b/src/layouts/main/main-layout.tsx @@ -32,6 +32,7 @@ import { GlobalAlert } from './global-alert'; import { HelpLinks } from './help-links'; import { Layout } from './layout'; import { Navigation } from './navigation'; +import { OrganizationPlan } from './organization-plan'; import { OrganizationSwitcher } from './organization-switcher'; import { PlatformStatus } from './platform-status'; import { UserMenu } from './user-menu'; @@ -103,13 +104,20 @@ function Menu({ collapsed = false }: { collapsed?: boolean }) { - {!collapsed && } - - +
+ {!collapsed && ( +
+ + }> + + +
+ )} -
- - +
+ + +
); diff --git a/src/layouts/main/organization-plan.tsx b/src/layouts/main/organization-plan.tsx new file mode 100644 index 0000000..729abd1 --- /dev/null +++ b/src/layouts/main/organization-plan.tsx @@ -0,0 +1,110 @@ +import clsx from 'clsx'; +import { useState } from 'react'; + +import { Badge, Floating } from '@koyeb/design-system'; +import { useOrganizationUnsafe } from 'src/api/hooks/session'; +import { routes } from 'src/application/routes'; +import { LinkButton } from 'src/components/link'; +import { PlanIcon } from 'src/components/plan-icon'; +import { useFeatureFlag } from 'src/hooks/feature-flag'; +import { createTranslate, TranslateEnum } from 'src/intl/translate'; +import { TrialSummaryPopup } from 'src/modules/trial/trial-summary-popup'; + +import { EstimatedCosts } from './estimated-costs'; + +const T = createTranslate('layouts.main.organizationPlan'); + +export function OrganizationPlan() { + const [open, setOpen] = useState(false); + const organization = useOrganizationUnsafe(); + const trial = useFeatureFlag('trial') && organization?.trial !== undefined; + + if (organization?.plan === 'hobby') { + return ; + } + + return ( + ( +
+
+
+ +
+ +
+ }} + /> +
+ + {trial && ( + + Trial + + )} +
+ + {organization?.trial && ( + + + + )} +
+ )} + renderFloating={(ref, props) => + trial ? ( + setOpen(false)} className="z-30" {...props} /> + ) : ( +
+ +
+ ) + } + /> + ); +} + +function HobbyPlan() { + return ( +
+
+ }} /> + + + + +
+ +
+
+ +
+ +
+ +
+
+ + + + +
+ ); +} diff --git a/src/layouts/main/user-menu.tsx b/src/layouts/main/user-menu.tsx index c344423..af954b0 100644 --- a/src/layouts/main/user-menu.tsx +++ b/src/layouts/main/user-menu.tsx @@ -63,6 +63,11 @@ export function UserMenu({ collapsed }: { collapsed: boolean }) { > {!collapsed && {user?.name}} + {!collapsed && ( + + + + )} )} renderFloating={(ref, props) => ( diff --git a/src/modules/trial/trial-summary-popup.tsx b/src/modules/trial/trial-summary-popup.tsx new file mode 100644 index 0000000..fa79bf7 --- /dev/null +++ b/src/modules/trial/trial-summary-popup.tsx @@ -0,0 +1,60 @@ +import clsx from 'clsx'; +import { forwardRef } from 'react'; + +import { Badge, ProgressBar } from '@koyeb/design-system'; +import { useOrganization } from 'src/api/hooks/session'; +import { routes } from 'src/application/routes'; +import { LinkButton } from 'src/components/link'; +import { createTranslate, TranslateEnum } from 'src/intl/translate'; + +const T = createTranslate('modules.trial.summaryPopup'); + +type TrialSummaryPopupProps = React.ComponentProps<'div'> & { + onClose: () => void; +}; + +export const TrialSummaryPopup = forwardRef( + function TrialSummaryPopup({ onClose, className, ...props }, ref) { + const organization = useOrganization(); + + return ( +
+
+ }} /> + + + + +
+ +
+
+
+ +
+
$0.00
+
+ +
+ +
+
+ +
+
$10.00
+
+ + + +
+ +
+ + + + +
+
+ ); + }, +);