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:
parent
8ee68e2ace
commit
ab78bb3802
8 changed files with 185 additions and 55 deletions
|
@ -1,8 +1,16 @@
|
||||||
import { Fragment } from "react";
|
import { Fragment, ReactNode } from "react";
|
||||||
import { Dialog, Transition } from "@headlessui/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 (
|
return (
|
||||||
<Transition.Root show={props.open} as={Fragment}>
|
<Transition.Root show={props.open} as={Fragment}>
|
||||||
<Dialog
|
<Dialog
|
||||||
|
@ -37,8 +45,18 @@ export default function Modal(props) {
|
||||||
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95">
|
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 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>
|
||||||
<div className="mx-auto flex items-center justify-center h-12 w-12 rounded-full bg-green-100">
|
<div
|
||||||
<CheckIcon className="h-6 w-6 text-green-600" aria-hidden="true" />
|
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>
|
||||||
<div className="mt-3 text-center sm:mt-5">
|
<div className="mt-3 text-center sm:mt-5">
|
||||||
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
|
<Dialog.Title as="h3" className="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
|
25
components/ui/Badge.tsx
Normal file
25
components/ui/Badge.tsx
Normal 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
5
lib/isBrandingHidden.tsx
Normal 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";
|
||||||
|
}
|
|
@ -93,7 +93,7 @@
|
||||||
"prisma": "^2.30.2",
|
"prisma": "^2.30.2",
|
||||||
"tailwindcss": "^2.2.7",
|
"tailwindcss": "^2.2.7",
|
||||||
"ts-node": "^10.2.1",
|
"ts-node": "^10.2.1",
|
||||||
"typescript": "^4.4.2"
|
"typescript": "^4.4.3"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"./{*,{pages,components,lib}/**/*}.{js,ts,jsx,tsx}": [
|
"./{*,{pages,components,lib}/**/*}.{js,ts,jsx,tsx}": [
|
||||||
|
|
|
@ -9,6 +9,7 @@ import PoweredByCalendso from "@components/ui/PoweredByCalendso";
|
||||||
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, GlobeIcon } from "@heroicons/react/solid";
|
import { ChevronDownIcon, ChevronUpIcon, ClockIcon, GlobeIcon } from "@heroicons/react/solid";
|
||||||
import { asStringOrNull } from "@lib/asStringOrNull";
|
import { asStringOrNull } from "@lib/asStringOrNull";
|
||||||
import { timeZone } from "@lib/clock";
|
import { timeZone } from "@lib/clock";
|
||||||
|
import { isBrandingHidden } from "@lib/isBrandingHidden";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@lib/telemetry";
|
||||||
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
import { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||||
|
@ -179,7 +180,7 @@ export default function Type(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{!props.user.hideBranding && <PoweredByCalendso />}
|
{!isBrandingHidden(props.user) && <PoweredByCalendso />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import { GetServerSideProps } from "next";
|
import { GetServerSidePropsContext } from "next";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { RefObject, useEffect, useRef, useState } from "react";
|
||||||
import prisma from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import Modal from "@components/Modal";
|
import Modal from "@components/Modal";
|
||||||
import Shell from "@components/Shell";
|
import Shell from "@components/Shell";
|
||||||
|
@ -12,19 +12,80 @@ import { UsernameInput } from "@components/ui/UsernameInput";
|
||||||
import ErrorAlert from "@components/ui/alerts/Error";
|
import ErrorAlert from "@components/ui/alerts/Error";
|
||||||
import ImageUploader from "@components/ImageUploader";
|
import ImageUploader from "@components/ImageUploader";
|
||||||
import crypto from "crypto";
|
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 = [
|
const themeOptions = [
|
||||||
{ value: "light", label: "Light" },
|
{ value: "light", label: "Light" },
|
||||||
{ value: "dark", label: "Dark" },
|
{ 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 [successModalOpen, setSuccessModalOpen] = useState(false);
|
||||||
const usernameRef = useRef<HTMLInputElement>();
|
const usernameRef = useRef<HTMLInputElement>(null);
|
||||||
const nameRef = useRef<HTMLInputElement>();
|
const nameRef = useRef<HTMLInputElement>(null);
|
||||||
const descriptionRef = useRef<HTMLTextAreaElement>();
|
const descriptionRef = useRef<HTMLTextAreaElement>();
|
||||||
const avatarRef = useRef<HTMLInputElement>();
|
const avatarRef = useRef<HTMLInputElement>(null);
|
||||||
const hideBrandingRef = useRef<HTMLInputElement>();
|
const hideBrandingRef = useRef<HTMLInputElement>(null);
|
||||||
const [selectedTheme, setSelectedTheme] = useState({ value: props.user.theme });
|
const [selectedTheme, setSelectedTheme] = useState({ value: props.user.theme });
|
||||||
const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone });
|
const [selectedTimeZone, setSelectedTimeZone] = useState({ value: props.user.timeZone });
|
||||||
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({ value: props.user.weekStart });
|
const [selectedWeekStartDay, setSelectedWeekStartDay] = useState({ value: props.user.weekStart });
|
||||||
|
@ -244,18 +305,12 @@ export default function Settings(props) {
|
||||||
<div>
|
<div>
|
||||||
<div className="relative flex items-start">
|
<div className="relative flex items-start">
|
||||||
<div className="flex items-center h-5">
|
<div className="flex items-center h-5">
|
||||||
<input
|
<HideBrandingInput user={props.user} hideBrandingRef={hideBrandingRef} />
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="ml-3 text-sm">
|
<div className="ml-3 text-sm">
|
||||||
<label htmlFor="hide-branding" className="font-medium text-gray-700">
|
<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>
|
</label>
|
||||||
<p className="text-gray-500">Hide all Calendso branding from your public pages.</p>
|
<p className="text-gray-500">Hide all Calendso branding from your public pages.</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -302,11 +357,7 @@ export default function Settings(props) {
|
||||||
</div>
|
</div>
|
||||||
<hr className="mt-8" />
|
<hr className="mt-8" />
|
||||||
<div className="py-4 flex justify-end">
|
<div className="py-4 flex justify-end">
|
||||||
<button
|
<Button type="submit">Save</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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</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);
|
const session = await getSession(context);
|
||||||
if (!session) {
|
if (!session?.user?.id) {
|
||||||
return { redirect: { permanent: false, destination: "/auth/login" } };
|
return { redirect: { permanent: false, destination: "/auth/login" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -342,9 +393,13 @@ export const getServerSideProps: GetServerSideProps = async (context) => {
|
||||||
weekStart: true,
|
weekStart: true,
|
||||||
hideBranding: true,
|
hideBranding: true,
|
||||||
theme: true,
|
theme: true,
|
||||||
|
plan: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error("User seems logged in but cannot be found in the db");
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
user: {
|
user: {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { HeadSeo } from "@components/seo/head-seo";
|
import { HeadSeo } from "@components/seo/head-seo";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import prisma, { whereAndSelect } from "@lib/prisma";
|
import prisma from "@lib/prisma";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { CheckIcon } from "@heroicons/react/outline";
|
import { CheckIcon } from "@heroicons/react/outline";
|
||||||
|
@ -12,12 +12,16 @@ import timezone from "dayjs/plugin/timezone";
|
||||||
import { createEvent } from "ics";
|
import { createEvent } from "ics";
|
||||||
import { getEventName } from "@lib/event";
|
import { getEventName } from "@lib/event";
|
||||||
import Theme from "@components/Theme";
|
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(utc);
|
||||||
dayjs.extend(toArray);
|
dayjs.extend(toArray);
|
||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
|
|
||||||
export default function Success(props) {
|
export default function Success(props: inferSSRProps<typeof getServerSideProps>) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { location, name } = router.query;
|
const { location, name } = router.query;
|
||||||
|
|
||||||
|
@ -220,7 +224,7 @@ export default function Success(props) {
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<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>
|
<a href="https://checkout.calendso.com">Create your own booking link with Calendso</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -235,30 +239,52 @@ export default function Success(props) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getServerSideProps(context) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const user = context.query.user
|
const username = asStringOrNull(context.query.user);
|
||||||
? await whereAndSelect(
|
const typeId = parseInt(asStringOrNull(context.query.type) ?? "");
|
||||||
prisma.user.findFirst,
|
if (!username || isNaN(typeId)) {
|
||||||
{
|
|
||||||
username: context.query.user,
|
|
||||||
},
|
|
||||||
["username", "name", "bio", "avatar", "hideBranding", "theme"]
|
|
||||||
)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventType = await whereAndSelect(
|
const user = await prisma.user.findUnique({
|
||||||
prisma.eventType.findUnique,
|
where: {
|
||||||
{
|
username,
|
||||||
id: parseInt(context.query.type),
|
|
||||||
},
|
},
|
||||||
["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 {
|
return {
|
||||||
props: {
|
props: {
|
||||||
|
|
|
@ -7667,10 +7667,10 @@ typeorm@^0.2.30:
|
||||||
yargs "^17.0.1"
|
yargs "^17.0.1"
|
||||||
zen-observable-ts "^1.0.0"
|
zen-observable-ts "^1.0.0"
|
||||||
|
|
||||||
typescript@^4.4.2:
|
typescript@^4.4.3:
|
||||||
version "4.4.2"
|
version "4.4.3"
|
||||||
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.4.2.tgz#6d618640d430e3569a1dfb44f7d7e600ced3ee86"
|
resolved "https://registry.npmjs.org/typescript/-/typescript-4.4.3.tgz#bdc5407caa2b109efd4f82fe130656f977a29324"
|
||||||
integrity sha512-gzP+t5W4hdy4c+68bfcv0t400HVJMMd2+H9B7gae1nQlBzCqvrXX+6GL/b3GAgyTH966pzrZ70/fRjwAtZksSQ==
|
integrity sha512-4xfscpisVgqqDfPaJo5vkd+Qd/ItkoagnHpufr+i2QCHBsNYp+G7UAoyFl8aPtx879u38wPV65rZ8qbGZijalA==
|
||||||
|
|
||||||
uglify-js@^3.1.4:
|
uglify-js@^3.1.4:
|
||||||
version "3.14.2"
|
version "3.14.2"
|
||||||
|
|
Loading…
Reference in a new issue