diff --git a/components/Shell.tsx b/components/Shell.tsx index 3b86d37c96..707d688373 100644 --- a/components/Shell.tsx +++ b/components/Shell.tsx @@ -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: { ))} +
diff --git a/ee/components/TrialBanner.tsx b/ee/components/TrialBanner.tsx new file mode 100644 index 0000000000..fc63226c24 --- /dev/null +++ b/ee/components/TrialBanner.tsx @@ -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 ( +
+
{t("trial_days_left", { days: trialDaysLeft })}
+ +
+ ); +}; + +export default TrialBanner; diff --git a/ee/lib/stripe/client.ts b/ee/lib/stripe/client.ts index f326e54fb2..a89840f0f8 100644 --- a/ee/lib/stripe/client.ts +++ b/ee/lib/stripe/client.ts @@ -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"; diff --git a/lib/config/constants.ts b/lib/config/constants.ts index b686c6f7da..e69f03ed85 100644 --- a/lib/config/constants.ts +++ b/lib/config/constants.ts @@ -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; diff --git a/next-i18next.config.js b/next-i18next.config.js index 6ab76d68aa..4d723d9bc8 100644 --- a/next-i18next.config.js +++ b/next-i18next.config.js @@ -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", }; diff --git a/pages/api/cron/downgradeUsers.ts b/pages/api/cron/downgradeUsers.ts index 0e1a1aa88f..fd60c8d236 100644 --- a/pages/api/cron/downgradeUsers.ts +++ b/pages/api/cron/downgradeUsers.ts @@ -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) { diff --git a/pages/api/upgrade.ts b/pages/api/upgrade.ts new file mode 100644 index 0000000000..e415ce62b6 --- /dev/null +++ b/pages/api/upgrade.ts @@ -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 || "/"); + } +} diff --git a/pages/auth/login.tsx b/pages/auth/login.tsx index e77375eff8..69358ee769 100644 --- a/pages/auth/login.tsx +++ b/pages/auth/login.tsx @@ -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
{t("dont_have_an_account")} {/* replace this with your account creation flow */} - + {t("create_an_account")}
diff --git a/playwright/lib/globalSetup.ts b/playwright/lib/globalSetup.ts index 273c6b0345..c5cbcfdd70 100644 --- a/playwright/lib/globalSetup.ts +++ b/playwright/lib/globalSetup.ts @@ -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); diff --git a/playwright/trial.test.ts b/playwright/trial.test.ts new file mode 100644 index 0000000000..debb80eae5 --- /dev/null +++ b/playwright/trial.test.ts @@ -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(); +}); diff --git a/public/static/locales/en/common.json b/public/static/locales/en/common.json index 473fc7942e..f51147fb22 100644 --- a/public/static/locales/en/common.json +++ b/public/static/locales/en/common.json @@ -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.",