Преглед изворни кода

Add billing banner and modal on Home page (#4556)

Co-authored-by: jusrhee <justin@porter.run>
Mauricio Araujo пре 2 година
родитељ
комит
c349348cdf

+ 1 - 1
dashboard/src/lib/billing/types.tsx

@@ -23,7 +23,7 @@ export const PlanValidator = z.object({
   plan_description: z.string(),
   starting_on: z.string(),
   trial_info: TrialValidator,
-});
+}).nullable();
 
 export type UsageMetric = z.infer<typeof UsageMetricValidator>;
 export const UsageMetricValidator = z.object({

+ 28 - 31
dashboard/src/lib/hooks/useStripe.tsx

@@ -7,6 +7,7 @@ import {
   CreditGrantsValidator,
   PaymentMethodValidator,
   PlanValidator,
+  Plan,
   UsageValidator,
   type CreditGrants,
   type PaymentMethod,
@@ -165,23 +166,21 @@ export const useCreatePaymentMethod = (): TCreatePaymentMethod => {
 export const checkIfProjectHasPayment = (): TCheckHasPaymentEnabled => {
   const { currentProject } = useContext(Context);
 
-  if (!currentProject?.id) {
-    throw new Error("Project ID is missing");
-  }
-
-  // Fetch list of payment methods
+  // Check if payment is enabled for the project
   const paymentEnabledReq = useQuery(
-    ["checkPaymentEnabled", currentProject?.id],
-    async (): Promise<boolean> => {
-      const res = await api.getHasBilling(
-        "<token>",
-        {},
-        { project_id: currentProject.id }
-      );
-
-      const data = z.boolean().parse(res.data);
-      return data;
-    }
+    currentProject?.id ? ["checkPaymentEnabled", currentProject.id] : ["checkPaymentEnabled", null],
+    currentProject?.id
+      ? async (): Promise<boolean> => {
+        const res = await api.getHasBilling(
+          "<token>",
+          {},
+          { project_id: currentProject.id }
+        );
+
+        const data = z.boolean().parse(res.data);
+        return data;
+      }
+      : async () => false
   );
 
   return {
@@ -242,7 +241,7 @@ export const usePublishableKey = (): TGetPublishableKey => {
     ["getPublishableKey", currentProject?.id],
     async () => {
       if (!currentProject?.id || currentProject.id === -1) {
-        return;
+        return null;
       }
       const res = await api.getPublishableKey(
         "<token>",
@@ -291,21 +290,19 @@ export const useCustomerPlan = (): TGetPlan => {
 
   // Fetch current plan
   const planReq = useQuery(
-    ["getCustomerPlan", currentProject?.id],
-    async () => {
-      if (!currentProject?.id || currentProject.id === -1) {
-        return;
+    currentProject?.id ? ["getCustomerPlan", currentProject.id] : ["getCustomerPlan", null],
+    currentProject?.id
+      ? async (): Promise<PlanType> => {
+        const res = await api.getCustomerPlan(
+          "<token>",
+          {},
+          { project_id: currentProject.id }
+        );
+
+        const plan = PlanValidator.parse(res.data);
+        return plan;
       }
-      const res = await api.getCustomerPlan(
-        "<token>",
-        {},
-        {
-          project_id: currentProject?.id,
-        }
-      );
-      const plan = PlanValidator.parse(res.data);
-      return plan;
-    }
+      : async () => null
   );
 
   return {

+ 83 - 3
dashboard/src/main/home/Home.tsx

@@ -12,9 +12,11 @@ import ConfirmOverlay from "components/ConfirmOverlay";
 import Loading from "components/Loading";
 import NoClusterPlaceHolder from "components/NoClusterPlaceHolder";
 import Button from "components/porter/Button";
+import Link from "components/porter/Link";
 import Modal from "components/porter/Modal";
 import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
+import { checkIfProjectHasPayment, useCustomerPlan } from "lib/hooks/useStripe";
 
 import api from "shared/api";
 import { withAuth, type WithAuthProps } from "shared/auth/AuthorizationHoc";
@@ -22,6 +24,7 @@ import { fakeGuardedRoute } from "shared/auth/RouteGuard";
 import { Context } from "shared/Context";
 import DeploymentTargetProvider from "shared/DeploymentTargetContext";
 import { pushFiltered, pushQueryParams, type PorterUrl } from "shared/routing";
+import { relativeDate, timeFrom } from "shared/string_utils";
 import midnight from "shared/themes/midnight";
 import standard from "shared/themes/standard";
 import {
@@ -56,6 +59,7 @@ import CreateClusterForm from "./infrastructure-dashboard/forms/CreateClusterFor
 import Integrations from "./integrations/Integrations";
 import LaunchWrapper from "./launch/LaunchWrapper";
 import ModalHandler from "./ModalHandler";
+import BillingModal from "./modals/BillingModal";
 import Navbar from "./navbar/Navbar";
 import { NewProjectFC } from "./new-project/NewProject";
 import Onboarding from "./onboarding/Onboarding";
@@ -108,6 +112,7 @@ const Home: React.FC<Props> = (props) => {
     setShouldRefreshClusters,
   } = useContext(Context);
 
+  const [showBillingModal, setShowBillingModal] = useState(false);
   const [showWelcome, setShowWelcome] = useState(false);
   const [forceRefreshClusters, setForceRefreshClusters] = useState(false);
   const [ghRedirect, setGhRedirect] = useState(false);
@@ -364,12 +369,77 @@ const Home: React.FC<Props> = (props) => {
   };
 
   const { cluster, baseRoute } = props.match.params as any;
+  const { hasPaymentEnabled } = checkIfProjectHasPayment();
+  const { plan } = useCustomerPlan();
+
+  const isTrialExpired = (timestamp: string): boolean => {
+    if (timestamp === "") {
+      return true;
+    }
+
+    const diff = timeFrom("2024-04-17T00:00:00.000Z");
+    if (diff.when === "future") {
+      return false;
+    }
+
+    return true;
+  };
+
+  const showCardBanner = !hasPaymentEnabled;
+  const trialExpired = plan && isTrialExpired(plan.trial_info.ending_before);
+
   return (
     <ThemeProvider
       theme={currentProject?.simplified_view_enabled ? midnight : standard}
     >
       <DeploymentTargetProvider>
-        <StyledHome>
+        <StyledHome
+          padTop={
+            !currentProject?.sandbox_enabled &&
+            showCardBanner &&
+            plan &&
+            !trialExpired
+          }
+        >
+          {!currentProject?.sandbox_enabled && showCardBanner && plan && (
+            <>
+              {!trialExpired && (
+                <GlobalBanner>
+                  <i className="material-icons-round">warning</i>
+                  Please
+                  <Spacer width="5px" inline />
+                  <Link
+                    hasunderline
+                    onClick={() => {
+                      setShowBillingModal(true);
+                    }}
+                  >
+                    connect a valid payment method
+                  </Link>
+                  . Your free trial is ending in{" "}
+                  {relativeDate(plan.trial_info.ending_before, true)}.
+                </GlobalBanner>
+              )}
+              {!trialExpired && showBillingModal && (
+                <BillingModal
+                  back={() => {
+                    setShowBillingModal(false);
+                  }}
+                  onCreate={async () => {
+                    setShowBillingModal(false);
+                  }}
+                />
+              )}
+              {trialExpired && (
+                <BillingModal
+                  trialExpired
+                  onCreate={async () => {
+                    setShowBillingModal(false);
+                  }}
+                />
+              )}
+            </>
+          )}
           <ModalHandler setRefreshClusters={setForceRefreshClusters} />
           {currentOverlay &&
             createPortal(
@@ -620,9 +690,10 @@ const GlobalBanner = styled.div`
   z-index: 999;
   position: fixed;
   top: 0;
+  color: #fefefe;
   left: 0;
   height: 35px;
-  background: #263061;
+  background: #4752ba;
   display: flex;
   align-items: center;
   justify-content: center;
@@ -633,6 +704,12 @@ const GlobalBanner = styled.div`
     height: 16px;
     margin-right: 10px;
   }
+
+  > i {
+    margin-right: 10px;
+    font-size: 16px;
+    opacity: 0.8;
+  }
 `;
 
 const ViewWrapper = styled.div`
@@ -657,13 +734,16 @@ const DashboardWrapper = styled.div`
   height: fit-content;
 `;
 
-const StyledHome = styled.div`
+const StyledHome = styled.div<{
+  padTop: boolean | null | undefined;
+}>`
   width: 100vw;
   height: 100vh;
   position: fixed;
   top: 0;
   left: 0;
   margin: 0;
+  padding-top: ${(props) => (props.padTop ? "35px" : "0")};
   user-select: none;
   display: flex;
   justify-content: center;

+ 1 - 1
dashboard/src/main/home/app-dashboard/apps/Apps.tsx

@@ -58,8 +58,8 @@ const Apps: React.FC = () => {
   const { deploymentTargetList } = useDeploymentTargetList({ preview: false });
   const [deploymentTargetIdFilter, setDeploymentTargetIdFilter] =
     useState<string>("all");
-
   const { hasPaymentEnabled } = checkIfProjectHasPayment();
+
   const history = useHistory();
 
   const [searchValue, setSearchValue] = useState("");

+ 54 - 23
dashboard/src/main/home/modals/BillingModal.tsx

@@ -1,4 +1,4 @@
-import React from "react";
+import React, { useContext } from "react";
 import { Elements } from "@stripe/react-stripe-js";
 import { loadStripe } from "@stripe/stripe-js";
 
@@ -8,17 +8,27 @@ import Spacer from "components/porter/Spacer";
 import Text from "components/porter/Text";
 import { usePublishableKey } from "lib/hooks/useStripe";
 
+import { Context } from "shared/Context";
+
 import PaymentSetupForm from "./PaymentSetupForm";
 
 const BillingModal = ({
   back,
   onCreate,
+  trialExpired,
 }: {
-  back: (value: React.SetStateAction<boolean>) => void;
+  back?: (value: React.SetStateAction<boolean>) => void;
   onCreate: () => Promise<void>;
-}) => {
+  trialExpired?: boolean;
+}): JSX.Element => {
+  const { currentProject } = useContext(Context);
   const { publishableKey } = usePublishableKey();
-  const stripePromise = loadStripe(publishableKey);
+
+  let stripePromise;
+  if (publishableKey) {
+    stripePromise = loadStripe(publishableKey);
+
+  }
 
   const appearance = {
     variables: {
@@ -44,27 +54,48 @@ const BillingModal = ({
   return (
     <Modal closeModal={back}>
       <div id="checkout">
-        <Text size={16}>Add payment method</Text>
-        <Spacer y={1} />
-        <Text color="helper">
-          <Text style={{ fontWeight: 500 }}>
-            You will not be charged until you have an app deployed and have run
-            out of credits.
-          </Text>{" "}
-          A payment method is required to begin deploying applications on
-          Porter. You can learn more about our pricing{" "}
-          <Link target="_blank" to="https://porter.run/pricing">
-            here
-          </Link>
+        <Text size={16}>
+          {trialExpired
+            ? "Your Porter trial has expired"
+            : "Add payment method"}
         </Text>
         <Spacer y={1} />
-        <Elements
-          stripe={stripePromise}
-          options={options}
-          appearance={appearance}
-        >
-          <PaymentSetupForm onCreate={onCreate}></PaymentSetupForm>
-        </Elements>
+        {currentProject?.sandbox_enabled ? (
+          <Text color="helper">
+            <Text style={{ fontWeight: 500 }}>
+              You will not be charged until you have an app deployed and have
+              run out of credits.
+            </Text>{" "}
+            A payment method is required to begin deploying applications on
+            Porter. You can learn more about our pricing{" "}
+            <Link target="_blank" to="https://porter.run/pricing">
+              here
+            </Link>
+          </Text>
+        ) : (
+          <Text color="helper">
+            {trialExpired
+              ? `Your applications will continue to run but you will not be able to access your project until you link a payment method. `
+              : "Link a payment method to your Porter project."}
+            <br />
+            <br />
+            {`You can learn more about our pricing under "For Businesses" `}
+            <Link target="_blank" to="https://porter.run/pricing">
+              here
+            </Link>
+          </Text>
+        )}
+        <Spacer y={1} />
+        {
+          publishableKey ? <Elements
+            stripe={stripePromise}
+            options={options}
+            appearance={appearance}
+          >
+            <PaymentSetupForm onCreate={onCreate}></PaymentSetupForm>
+          </Elements> : null
+        }
+
       </div>
     </Modal>
   );

+ 30 - 57
dashboard/src/main/home/project-settings/BillingPage.tsx

@@ -17,6 +17,7 @@ import {
   usePorterCredits,
   useSetDefaultPaymentMethod,
 } from "lib/hooks/useStripe";
+import { relativeDate } from "shared/string_utils";
 
 import { Context } from "shared/Context";
 import cardIcon from "assets/credit-card.svg";
@@ -80,35 +81,6 @@ function BillingPage(): JSX.Element {
   const formatCredits = (credits: number): string => {
     return (credits / 100).toFixed(2);
   };
-  const monthDiff = (d1: Date, d2: Date): number => {
-    let months;
-    months = (d2.getFullYear() - d1.getFullYear()) * 12;
-    months -= d1.getMonth();
-    months += d2.getMonth();
-    return months <= 0 ? 0 : months;
-  };
-
-  const daysDiff = (d1: Date, d2: Date): number => {
-    const _MS_PER_DAY = 1000 * 60 * 60 * 24;
-    const utc1 = Date.UTC(d1.getFullYear(), d1.getMonth(), d1.getDate());
-    const utc2 = Date.UTC(d2.getFullYear(), d2.getMonth(), d2.getDate());
-
-    return Math.floor((utc2 - utc1) / _MS_PER_DAY);
-  };
-
-  const relativeTime = (timestampUTC: string): string => {
-    const tsDate = new Date(timestampUTC);
-    const now = new Date();
-
-    const remainingMonths = monthDiff(now, tsDate);
-    const remainingDays = daysDiff(now, tsDate);
-
-    const relativeFormat = remainingMonths > 0 ? "months" : "days";
-    const relativeValue = remainingMonths > 0 ? remainingMonths : remainingDays;
-
-    const rt = new Intl.RelativeTimeFormat("en", { style: "short" });
-    return rt.format(relativeValue, relativeFormat);
-  };
 
   const readableDate = (s: string): string => new Date(s).toLocaleDateString();
 
@@ -210,31 +182,34 @@ function BillingPage(): JSX.Element {
       </Button>
       <Spacer y={2} />
 
-      {currentProject?.metronome_enabled ? (
+      {currentProject?.metronome_enabled && (
         <div>
-          <div>
-            <Text size={16}>Porter credit grants</Text>
-            <Spacer y={1} />
-            <Text color="helper">
-              View the amount of Porter credits you have available to spend on
-              resources within this project.
-            </Text>
-            <Spacer y={1} />
 
-            <Container>
-              <Image src={gift} style={{ marginTop: "-2px" }} />
-              <Spacer inline x={1} />
-              <Text size={20}>
-                {creditGrants !== undefined &&
-                creditGrants.remaining_credits > 0
-                  ? `$${formatCredits(
+          {currentProject?.sandbox_enabled && (
+            <div>
+              <Text size={16}>Porter credit grants</Text>
+              <Spacer y={1} />
+              <Text color="helper">
+                View the amount of Porter credits you have available to spend on
+                resources within this project.
+              </Text>
+              <Spacer y={1} />
+
+              <Container>
+                <Image src={gift} style={{ marginTop: "-2px" }} />
+                <Spacer inline x={1} />
+                <Text size={20}>
+                  {creditGrants !== undefined &&
+                    creditGrants.remaining_credits > 0
+                    ? `$${formatCredits(
                       creditGrants.remaining_credits
                     )}/$${formatCredits(creditGrants.granted_credits)}`
-                  : "$ 0.00"}
-              </Text>
-            </Container>
-            <Spacer y={2} />
-          </div>
+                    : "$ 0.00"}
+                </Text>
+              </Container>
+              <Spacer y={2} />
+            </div>
+          )}
 
           <div>
             <Text size={16}>Plan Details</Text>
@@ -244,7 +219,7 @@ function BillingPage(): JSX.Element {
             </Text>
             <Spacer y={1} />
 
-            {plan !== undefined && plan.plan_name !== "" ? (
+            {plan && plan.plan_name !== "" ? (
               <div>
                 <Text>Active Plan</Text>
                 <Spacer y={0.5} />
@@ -255,10 +230,10 @@ function BillingPage(): JSX.Element {
                     </Container>
                     <Container row>
                       {plan.trial_info !== undefined &&
-                      plan.trial_info.ending_before !== "" ? (
+                        plan.trial_info.ending_before !== "" ? (
                         <Text>
                           Free trial ends{" "}
-                          {relativeTime(plan.trial_info.ending_before)}
+                          {relativeDate(plan.trial_info.ending_before, true)}
                         </Text>
                       ) : (
                         <Text>Started on {readableDate(plan.starting_on)}</Text>
@@ -274,8 +249,8 @@ function BillingPage(): JSX.Element {
                 </Text>
                 <Spacer y={1} />
                 {usage?.length &&
-                usage.length > 0 &&
-                usage[0].usage_metrics.length > 0 ? (
+                  usage.length > 0 &&
+                  usage[0].usage_metrics.length > 0 ? (
                   <Flex>
                     <BarWrapper>
                       <Bars
@@ -311,8 +286,6 @@ function BillingPage(): JSX.Element {
             )}
           </div>
         </div>
-      ) : (
-        <div></div>
       )}
     </>
   );

+ 13 - 5
dashboard/src/shared/string_utils.ts

@@ -8,7 +8,7 @@ export const readableDate = (s: string) => {
   return `${time} on ${date}`;
 };
 
-export const relativeDate = (date: string | number) => {
+export const relativeDate = (date: string | number, future: boolean) => {
   if (!date) {
     return "N/A";
   }
@@ -25,7 +25,14 @@ export const relativeDate = (date: string | number) => {
     return "N/A";
   }
 
-  return rtf.format(-time.time, time.unitOfTime);
+  let format;
+  if (future) {
+    format = rtf.format(time.time, time.unitOfTime);
+  } else {
+    format = rtf.format(-time.time, time.unitOfTime);
+  }
+
+  return format;
 };
 
 export const feedDate = (timestamp: string) => {
@@ -34,11 +41,11 @@ export const feedDate = (timestamp: string) => {
     day: "numeric",
     hour: "numeric",
     minute: "2-digit",
-    hour12: true
+    hour12: true,
   });
 
   return localTime;
-}
+};
 
 export const timeFrom = (
   time: string | number,
@@ -114,7 +121,8 @@ export const capitalize = (s: string) => {
   return s.charAt(0).toUpperCase() + s.substring(1).toLowerCase();
 };
 
-const LINE = /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm;
+const LINE =
+  /(?:^|^)\s*(?:export\s+)?([\w.-]+)(?:\s*=\s*?|:\s+?)(\s*'(?:\\'|[^'])*'|\s*"(?:\\"|[^"])*"|\s*`(?:\\`|[^`])*`|[^#\r\n]+)?\s*(?:#.*)?(?:$|$)/gm;
 
 export const dotenv_parse = (src: string): Record<string, string> => {
   // Parser src into an Object