move calendso branding into pro (#629)

* badge

* mv branding to paid plan

* upgrade ts

* hideBranding check

* user.plan

* lint fixes

* `isBrandingHidden` helper

* hide pro for non-pros
This commit is contained in:
Alex Johansson 2021-09-13 11:48:55 +02:00 committed by GitHub
parent 8ee68e2ace
commit ab78bb3802
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 185 additions and 55 deletions

View File

@ -1,8 +1,16 @@
import { Fragment } from "react";
import { Fragment, ReactNode } from "react";
import { Dialog, Transition } from "@headlessui/react";
import { CheckIcon } from "@heroicons/react/outline";
import { CheckIcon, InformationCircleIcon } from "@heroicons/react/outline";
import classNames from "@lib/classNames";
export default function Modal(props) {
export default function Modal(props: {
heading: ReactNode;
description: ReactNode;
handleClose: () => void;
open: boolean;
variant?: "success" | "warning";
}) {
const { variant = "success" } = props;
return (
<Transition.Root show={props.open} as={Fragment}>
<Dialog
@ -37,8 +45,18 @@ export default function Modal(props) {
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
<div className="inline-block align-bottom bg-white rounded-lg px-4 pt-5 pb-4 text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-sm sm:w-full sm:p-6">
<div>
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
<CheckIcon className="h-6 w-6 text-green-600" aria-hidden="true" />
<div
className={classNames(
"mx-auto flex items-center justify-center h-12 w-12 rounded-full",
variant === "success" && "bg-green-100",
variant === "warning" && "bg-yellow-100"
)}>
{variant === "success" && (
<CheckIcon className="h-6 w-6 text-green-600" aria-hidden="true" />
)}
{variant === "warning" && (
<InformationCircleIcon className={"h-6 w-6 text-yellow-400"} aria-hidden="true" />
)}
</div>
<div className="mt-3 text-center sm:mt-5">
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">

25
components/ui/Badge.tsx Normal file
View File

@ -0,0 +1,25 @@
import classNames from "@lib/classNames";
import React from "react";
export type BadgeProps = {
variant: "default" | "success";
} & JSX.IntrinsicElements["span"];
export const Badge = function Badge(props: BadgeProps) {
const { variant, className, ...passThroughProps } = props;
return (
<span
{...passThroughProps}
className={classNames(
"font-bold px-2 py-0.5 inline-block",
variant === "default" && "bg-yellow-100 text-yellow-800",
variant === "success" && "bg-green-100 text-green-800",
className
)}>
{props.children}
</span>
);
};
export default Badge;

5
lib/isBrandingHidden.tsx Normal file
View File

@ -0,0 +1,5 @@
import { User } from "@prisma/client";
export function isBrandingHidden<TUser extends Pick<User, "hideBranding" | "plan">>(user: TUser) {
return user.hideBranding && user.plan !== "FREE";
}

View File

@ -93,7 +93,7 @@
"prisma": "^2.30.2",
"tailwindcss": "^2.2.7",
"ts-node": "^10.2.1",
"typescript": "^4.4.2"
"typescript": "^4.4.3"
},
"lint-staged": {
"./{*,{pages,components,lib}/**/*}.{js,ts,jsx,tsx}": [

View File

@ -9,6 +9,7 @@ import PoweredByCalendso from "@components/ui/PoweredByCalendso";
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, GlobeIcon } from "@heroicons/react/solid";
import { asStringOrNull } from "@lib/asStringOrNull";
import { timeZone } from "@lib/clock";
import { isBrandingHidden } from "@lib/isBrandingHidden";
import prisma from "@lib/prisma";
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
import { inferSSRProps } from "@lib/types/inferSSRProps";
@ -179,7 +180,7 @@ export default function Type(props: inferSSRProps<typeof getServerSideProps>) {
)}
</div>
</div>
{!props.user.hideBranding && <PoweredByCalendso />}
{!isBrandingHidden(props.user) && <PoweredByCalendso />}
</main>
</div>
)}

View File

@ -1,5 +1,5 @@
import { GetServerSideProps } from "next";
import { useEffect, useRef, useState } from "react";
import { GetServerSidePropsContext } from "next";
import { RefObject, useEffect, useRef, useState } from "react";
import prisma from "@lib/prisma";
import Modal from "@components/Modal";
import Shell from "@components/Shell";
@ -12,19 +12,80 @@ import { UsernameInput } from "@components/ui/UsernameInput";
import ErrorAlert from "@components/ui/alerts/Error";
import ImageUploader from "@components/ImageUploader";
import crypto from "crypto";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import Badge from "@components/ui/Badge";
import Button from "@components/ui/Button";
import { isBrandingHidden } from "@lib/isBrandingHidden";
const themeOptions = [
{ value: "light", label: "Light" },
{ value: "dark", label: "Dark" },
];
export default function Settings(props) {
type Props = inferSSRProps<typeof getServerSideProps>;
function HideBrandingInput(props: {
//
hideBrandingRef: RefObject<HTMLInputElement>;
user: Props["user"];
}) {
const [modelOpen, setModalOpen] = useState(false);
return (
<>
<input
id="hide-branding"
name="hide-branding"
type="checkbox"
ref={props.hideBrandingRef}
defaultChecked={isBrandingHidden(props.user)}
className={
"focus:ring-neutral-500 h-4 w-4 text-neutral-900 border-gray-300 rounded-sm disabled:opacity-50"
}
onClick={(e) => {
if (!e.currentTarget.checked || props.user.plan !== "FREE") {
return;
}
// prevent checking the input
e.preventDefault();
setModalOpen(true);
}}
/>
<Modal
heading="This feature is only available in paid plan"
variant="warning"
description={
<div className="flex flex-col space-y-3">
<p>
In order to remove the Calendso branding from your booking pages, you need to upgrade to a paid
account.
</p>
<p>
{" "}
To upgrade go to{" "}
<a href="https://calendso.com/upgrade" className="underline">
calendso.com/upgrade
</a>
.
</p>
</div>
}
open={modelOpen}
handleClose={() => setModalOpen(false)}
/>
</>
);
}
export default function Settings(props: Props) {
const [successModalOpen, setSuccessModalOpen] = useState(false);
const usernameRef = useRef<HTMLInputElement>();
const nameRef = useRef<HTMLInputElement>();
const usernameRef = useRef<HTMLInputElement>(null);
const nameRef = useRef<HTMLInputElement>(null);
const descriptionRef = useRef<HTMLTextAreaElement>();
const avatarRef = useRef<HTMLInputElement>();
const hideBrandingRef = useRef<HTMLInputElement>();
const avatarRef = useRef<HTMLInputElement>(null);
const hideBrandingRef = useRef<HTMLInputElement>(null);
const [selectedTheme, setSelectedTheme] = useState({ value: props.user.theme });
const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone });
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({ value: props.user.weekStart });
@ -244,18 +305,12 @@ export default function Settings(props) {
<div>
<div className="relative flex items-start">
<div className="flex items-center h-5">
<input
id="hide-branding"
name="hide-branding"
type="checkbox"
ref={hideBrandingRef}
defaultChecked={props.user.hideBranding}
className="focus:ring-neutral-500 h-4 w-4 text-neutral-900 border-gray-300 rounded-sm"
/>
<HideBrandingInput user={props.user} hideBrandingRef={hideBrandingRef} />
</div>
<div className="ml-3 text-sm">
<label htmlFor="hide-branding" className="font-medium text-gray-700">
Disable Calendso branding
Disable Calendso branding{" "}
{props.user.plan !== "PRO" && <Badge variant="default">PRO</Badge>}
</label>
<p className="text-gray-500">Hide all Calendso branding from your public pages.</p>
</div>
@ -302,11 +357,7 @@ export default function Settings(props) {
</div>
<hr className="mt-8" />
<div className="py-4 flex justify-end">
<button
type="submit"
className="ml-2 bg-neutral-900 border border-transparent rounded-sm shadow-sm py-2 px-4 inline-flex justify-center text-sm font-medium text-white hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-500">
Save
</button>
<Button type="submit">Save</Button>
</div>
</div>
</form>
@ -321,9 +372,9 @@ export default function Settings(props) {
);
}
export const getServerSideProps: GetServerSideProps = async (context) => {
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
const session = await getSession(context);
if (!session) {
if (!session?.user?.id) {
return { redirect: { permanent: false, destination: "/auth/login" } };
}
@ -342,9 +393,13 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
weekStart: true,
hideBranding: true,
theme: true,
plan: true,
},
});
if (!user) {
throw new Error("User seems logged in but cannot be found in the db");
}
return {
props: {
user: {

View File

@ -1,6 +1,6 @@
import { HeadSeo } from "@components/seo/head-seo";
import Link from "next/link";
import prisma, { whereAndSelect } from "@lib/prisma";
import prisma from "@lib/prisma";
import { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { CheckIcon } from "@heroicons/react/outline";
@ -12,12 +12,16 @@ import timezone from "dayjs/plugin/timezone";
import { createEvent } from "ics";
import { getEventName } from "@lib/event";
import Theme from "@components/Theme";
import { GetServerSidePropsContext } from "next";
import { asStringOrNull } from "../lib/asStringOrNull";
import { inferSSRProps } from "@lib/types/inferSSRProps";
import { isBrandingHidden } from "@lib/isBrandingHidden";
dayjs.extend(utc);
dayjs.extend(toArray);
dayjs.extend(timezone);
export default function Success(props) {
export default function Success(props: inferSSRProps<typeof getServerSideProps>) {
const router = useRouter();
const { location, name } = router.query;
@ -220,7 +224,7 @@ export default function Success(props) {
</div>
</div>
)}
{!props.user.hideBranding && (
{!isBrandingHidden(props.user) && (
<div className="mt-4 pt-4 border-t dark:border-gray-900 text-gray-400 text-center text-xs dark:text-white">
<a href="https://checkout.calendso.com">Create your own booking link with Calendso</a>
</div>
@ -235,30 +239,52 @@ export default function Success(props) {
);
}
export async function getServerSideProps(context) {
const user = context.query.user
? await whereAndSelect(
prisma.user.findFirst,
{
username: context.query.user,
},
["username", "name", "bio", "avatar", "hideBranding", "theme"]
)
: null;
if (!user) {
export async function getServerSideProps(context: GetServerSidePropsContext) {
const username = asStringOrNull(context.query.user);
const typeId = parseInt(asStringOrNull(context.query.type) ?? "");
if (!username || isNaN(typeId)) {
return {
notFound: true,
};
}
const eventType = await whereAndSelect(
prisma.eventType.findUnique,
{
id: parseInt(context.query.type),
const user = await prisma.user.findUnique({
where: {
username,
},
["id", "title", "description", "length", "eventName", "requiresConfirmation"]
);
select: {
username: true,
name: true,
bio: true,
avatar: true,
hideBranding: true,
theme: true,
plan: true,
},
});
if (!user) {
return {
notFound: true,
};
}
const eventType = await prisma.eventType.findUnique({
where: {
id: typeId,
},
select: {
id: true,
title: true,
description: true,
length: true,
eventName: true,
requiresConfirmation: true,
},
});
if (!eventType) {
return {
notFound: true,
};
}
return {
props: {

View File

@ -7667,10 +7667,10 @@ typeorm@^0.2.30:
yargs "^17.0.1"
zen-observable-ts "^1.0.0"
typescript@^4.4.2:
version "4.4.2"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.4.2.tgz#6d618640d430e3569a1dfb44f7d7e600ced3ee86"
integrity sha512-gzP+t5W4hdy4c+68bfcv0t400HVJMMd2+H9B7gae1nQlBzCqvrXX+6GL/b3GAgyTH966pzrZ70/fRjwAtZksSQ==
typescript@^4.4.3:
version "4.4.3"
resolved "https://registry.npmjs.org/typescript/-/typescript-4.4.3.tgz#bdc5407caa2b109efd4f82fe130656f977a29324"
integrity sha512-4xfscpisVgqqDfPaJo5vkd+Qd/ItkoagnHpufr+i2QCHBsNYp+G7UAoyFl8aPtx879u38wPV65rZ8qbGZijalA==
uglify-js@^3.1.4:
version "3.14.2"