Adds trial banner and in-app upgrade (#1402)
* WIP trial banner * Fixes days left count * Defers stripe loading until needed * Fixes auth issues * Checkout fixes * Allows for signup testing * Debugging checkout * Adds tests for trial banner Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
		
							parent
							
								
									baa7e868bd
								
							
						
					
					
						commit
						4cd7a4ce5b
					
				
					 11 changed files with 113 additions and 5 deletions
				
			
		|  | @ -16,6 +16,7 @@ import React, { ReactNode, useEffect, useState } from "react"; | |||
| import { Toaster } from "react-hot-toast"; | ||||
| 
 | ||||
| import LicenseBanner from "@ee/components/LicenseBanner"; | ||||
| import TrialBanner from "@ee/components/TrialBanner"; | ||||
| import HelpMenuItemDynamic from "@ee/lib/intercom/HelpMenuItemDynamic"; | ||||
| 
 | ||||
| import classNames from "@lib/classNames"; | ||||
|  | @ -242,6 +243,7 @@ export default function Shell(props: { | |||
|                   ))} | ||||
|                 </nav> | ||||
|               </div> | ||||
|               <TrialBanner /> | ||||
|               <div className="p-2 pt-2 pr-2 m-2 rounded-sm hover:bg-gray-100"> | ||||
|                 <span className="hidden lg:inline"> | ||||
|                   <UserDropdown /> | ||||
|  |  | |||
							
								
								
									
										35
									
								
								ee/components/TrialBanner.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								ee/components/TrialBanner.tsx
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,35 @@ | |||
| import dayjs from "dayjs"; | ||||
| 
 | ||||
| import { TRIAL_LIMIT_DAYS } from "@lib/config/constants"; | ||||
| import { useLocale } from "@lib/hooks/useLocale"; | ||||
| 
 | ||||
| import { useMeQuery } from "@components/Shell"; | ||||
| import Button from "@components/ui/Button"; | ||||
| 
 | ||||
| const TrialBanner = () => { | ||||
|   const { t } = useLocale(); | ||||
|   const query = useMeQuery(); | ||||
|   const user = query.data; | ||||
| 
 | ||||
|   if (!user || user.plan !== "TRIAL") return null; | ||||
| 
 | ||||
|   const trialDaysLeft = dayjs(user.createdDate) | ||||
|     .add(TRIAL_LIMIT_DAYS + 1, "day") | ||||
|     .diff(dayjs(), "day"); | ||||
| 
 | ||||
|   return ( | ||||
|     <div | ||||
|       className="p-4 m-4 text-sm font-medium text-center text-gray-600 bg-yellow-200 rounded-md" | ||||
|       data-testid="trial-banner"> | ||||
|       <div className="mb-2 text-left">{t("trial_days_left", { days: trialDaysLeft })}</div> | ||||
|       <Button | ||||
|         href="/api/upgrade" | ||||
|         color="minimal" | ||||
|         className="justify-center w-full border-2 border-gray-600 hover:bg-yellow-100"> | ||||
|         {t("upgrade_now")} | ||||
|       </Button> | ||||
|     </div> | ||||
|   ); | ||||
| }; | ||||
| 
 | ||||
| export default TrialBanner; | ||||
|  | @ -1,4 +1,5 @@ | |||
| import { loadStripe, Stripe } from "@stripe/stripe-js"; | ||||
| import { Stripe } from "@stripe/stripe-js"; | ||||
| import { loadStripe } from "@stripe/stripe-js/pure"; | ||||
| import { stringify } from "querystring"; | ||||
| 
 | ||||
| import { Maybe } from "@trpc/server"; | ||||
|  |  | |||
|  | @ -1,2 +1,4 @@ | |||
| export const BASE_URL = process.env.BASE_URL || `https://${process.env.VERCEL_URL}`; | ||||
| export const WEBSITE_URL = process.env.NEXT_PUBLIC_APP_URL || "https://cal.com"; | ||||
| export const IS_PRODUCTION = process.env.NODE_ENV === "production"; | ||||
| export const TRIAL_LIMIT_DAYS = 14; | ||||
|  |  | |||
|  | @ -7,4 +7,5 @@ module.exports = { | |||
|     locales: ["en", "fr", "it", "ru", "es", "de", "pt", "ro", "nl", "pt-BR", "es-419", "ko", "ja"], | ||||
|   }, | ||||
|   localePath: path.resolve("./public/static/locales"), | ||||
|   reloadOnPrerender: process.env.NODE_ENV !== "production", | ||||
| }; | ||||
|  |  | |||
|  | @ -1,10 +1,9 @@ | |||
| import dayjs from "dayjs"; | ||||
| import type { NextApiRequest, NextApiResponse } from "next"; | ||||
| 
 | ||||
| import { TRIAL_LIMIT_DAYS } from "@lib/config/constants"; | ||||
| import prisma from "@lib/prisma"; | ||||
| 
 | ||||
| const TRIAL_LIMIT_DAYS = 14; | ||||
| 
 | ||||
| export default async function handler(req: NextApiRequest, res: NextApiResponse) { | ||||
|   const apiKey = req.headers.authorization || req.query.apiKey; | ||||
|   if (process.env.CRON_API_KEY !== apiKey) { | ||||
|  |  | |||
							
								
								
									
										50
									
								
								pages/api/upgrade.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								pages/api/upgrade.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,50 @@ | |||
| import { Prisma } from "@prisma/client"; | ||||
| import type { NextApiRequest, NextApiResponse } from "next"; | ||||
| 
 | ||||
| import { getSession } from "@lib/auth"; | ||||
| import { WEBSITE_URL } from "@lib/config/constants"; | ||||
| import { HttpError as HttpCode } from "@lib/core/http/error"; | ||||
| import prisma from "@lib/prisma"; | ||||
| 
 | ||||
| export default async function handler(req: NextApiRequest, res: NextApiResponse) { | ||||
|   const session = await getSession({ req }); | ||||
|   if (!session?.user?.id) { | ||||
|     return res.status(401).json({ message: "Not authenticated" }); | ||||
|   } | ||||
| 
 | ||||
|   if (req.method !== "GET") { | ||||
|     throw new HttpCode({ statusCode: 405, message: "Method Not Allowed" }); | ||||
|   } | ||||
| 
 | ||||
|   const user = await prisma.user.findUnique({ | ||||
|     rejectOnNotFound: true, | ||||
|     where: { | ||||
|       id: session.user.id, | ||||
|     }, | ||||
|     select: { | ||||
|       email: true, | ||||
|       metadata: true, | ||||
|     }, | ||||
|   }); | ||||
| 
 | ||||
|   try { | ||||
|     const response = await fetch(`${WEBSITE_URL}/api/upgrade`, { | ||||
|       method: "POST", | ||||
|       credentials: "include", | ||||
|       headers: { | ||||
|         "Content-Type": "application/json", | ||||
|       }, | ||||
|       body: JSON.stringify({ | ||||
|         stripeCustomerId: (user.metadata as Prisma.JsonObject)?.stripeCustomerId, | ||||
|         email: user.email, | ||||
|         fromApp: true, | ||||
|       }), | ||||
|     }); | ||||
|     const data = await response.json(); | ||||
| 
 | ||||
|     res.redirect(303, data.url); | ||||
|   } catch (error) { | ||||
|     console.error(`error`, error); | ||||
|     res.redirect(303, req.headers.origin || "/"); | ||||
|   } | ||||
| } | ||||
|  | @ -5,6 +5,7 @@ import { useRouter } from "next/router"; | |||
| import { useState } from "react"; | ||||
| 
 | ||||
| import { ErrorCode, getSession } from "@lib/auth"; | ||||
| import { WEBSITE_URL } from "@lib/config/constants"; | ||||
| import { useLocale } from "@lib/hooks/useLocale"; | ||||
| import { inferSSRProps } from "@lib/types/inferSSRProps"; | ||||
| 
 | ||||
|  | @ -176,7 +177,7 @@ export default function Login({ csrfToken }: inferSSRProps<typeof getServerSideP | |||
|         </div> | ||||
|         <div className="mt-4 text-sm text-center text-neutral-600"> | ||||
|           {t("dont_have_an_account")} {/* replace this with your account creation flow */} | ||||
|           <a href="https://cal.com/signup" className="font-medium text-neutral-900"> | ||||
|           <a href={`${WEBSITE_URL}/signup`} className="font-medium text-neutral-900"> | ||||
|             {t("create_an_account")} | ||||
|           </a> | ||||
|         </div> | ||||
|  |  | |||
|  | @ -26,7 +26,7 @@ async function globalSetup(/* config: FullConfig */) { | |||
|   await loginAsUser("onboarding", browser); | ||||
|   //   await loginAsUser("free-first-hidden", browser);
 | ||||
|   await loginAsUser("pro", browser); | ||||
|   //   await loginAsUser("trial", browser);
 | ||||
|   await loginAsUser("trial", browser); | ||||
|   await loginAsUser("free", browser); | ||||
|   //   await loginAsUser("usa", browser);
 | ||||
|   //   await loginAsUser("teamfree", browser);
 | ||||
|  |  | |||
							
								
								
									
										13
									
								
								playwright/trial.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								playwright/trial.test.ts
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,13 @@ | |||
| import { expect, test } from "@playwright/test"; | ||||
| 
 | ||||
| // Using logged in state from globalSteup
 | ||||
| test.use({ storageState: "playwright/artifacts/trialStorageState.json" }); | ||||
| 
 | ||||
| test("Trial banner should be visible to TRIAL users", async ({ page }) => { | ||||
|   // Try to go homepage
 | ||||
|   await page.goto("/"); | ||||
|   // It should redirect you to the event-types page
 | ||||
|   await page.waitForSelector("[data-testid=event-types]"); | ||||
| 
 | ||||
|   await expect(page.locator(`[data-testid=trial-banner]`)).toBeVisible(); | ||||
| }); | ||||
|  | @ -1,4 +1,8 @@ | |||
| { | ||||
|   "trial_days_left": "You have $t(day, {\"count\": {{days}} }) left on your PRO trial", | ||||
|   "day": "{{count}} day",  | ||||
|   "day_plural": "{{count}} days", | ||||
|   "upgrade_now": "Upgrade now", | ||||
|   "accept_invitation": "Accept Invitation", | ||||
|   "calcom_explained": "Cal.com is the open source Calendly alternative putting you in control of your own data, workflow and appearance.", | ||||
|   "have_any_questions": "Have questions? We're here to help.", | ||||
|  |  | |||
		Loading…
	
		Reference in a new issue
	
	 Omar López
						Omar López