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:
Omar López 2022-01-03 15:50:59 -07:00 committed by GitHub
parent baa7e868bd
commit 4cd7a4ce5b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 113 additions and 5 deletions

View File

@ -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 />

View 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;

View File

@ -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";

View File

@ -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;

View File

@ -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",
};

View File

@ -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
View 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 || "/");
}
}

View File

@ -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>

View File

@ -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
View 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();
});

View File

@ -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.",