diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 7c97b9109f..ea8e0f08e6 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -66,3 +66,4 @@ jobs: name: videos path: | cypress/videos + cypress/screenshots diff --git a/components/ui/Alert.tsx b/components/ui/Alert.tsx new file mode 100644 index 0000000000..a22ce26cf4 --- /dev/null +++ b/components/ui/Alert.tsx @@ -0,0 +1,42 @@ +import { XCircleIcon, InformationCircleIcon, CheckCircleIcon } from "@heroicons/react/solid"; +import classNames from "classnames"; +import { ReactNode } from "react"; + +export interface AlertProps { + title: ReactNode; + message?: ReactNode; + className?: string; + severity: "success" | "warning" | "error"; +} +export function Alert(props: AlertProps) { + const { severity } = props; + + return ( +
+
+
+ {severity === "error" && ( +
+
+

{props.title}

+
{props.message}
+
+
+
+ ); +} diff --git a/components/ui/Button.tsx b/components/ui/Button.tsx index 2ef72e9bf6..f02882ac64 100644 --- a/components/ui/Button.tsx +++ b/components/ui/Button.tsx @@ -68,7 +68,7 @@ export const Button = function Button(props: ButtonProps) { ? "text-gray-400 bg-transparent" : "text-red-700 bg-transparent hover:bg-red-100 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:bg-red-50 focus:ring-red-500"), // set not-allowed cursor if disabled - disabled && "cursor-not-allowed", + loading ? "cursor-wait" : disabled ? "cursor-not-allowed" : "", props.className ), // if we click a disabled button, we prevent going through the click handler diff --git a/components/ui/alerts/Error.tsx b/components/ui/alerts/Error.tsx index 19b0e2ce49..76407593f3 100644 --- a/components/ui/alerts/Error.tsx +++ b/components/ui/alerts/Error.tsx @@ -1,19 +1,8 @@ -import { XCircleIcon } from "@heroicons/react/solid"; +import { Alert } from "../Alert"; -export default function ErrorAlert(props) { - return ( -
-
-
-
-
-

Something went wrong

-
-

{props.message}

-
-
-
-
- ); +/** + * @deprecated use `` instead + */ +export default function ErrorAlert(props: { message: string; className?: string }) { + return ; } diff --git a/cypress.json b/cypress.json index 17ef242e71..0b378fff58 100644 --- a/cypress.json +++ b/cypress.json @@ -1,3 +1,4 @@ { - "baseUrl": "http://localhost:3000" + "baseUrl": "http://localhost:3000", + "chromeWebSecurity": false } diff --git a/cypress/integration/booking-pages.spec.ts b/cypress/integration/booking-pages.spec.ts new file mode 100644 index 0000000000..084c9f1c97 --- /dev/null +++ b/cypress/integration/booking-pages.spec.ts @@ -0,0 +1,62 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +describe("booking pages", () => { + describe("free user", () => { + it("only one visibile event", () => { + cy.visit("/free"); + cy.get("[data-testid=event-types]").children().should("have.length", 1); + cy.get('[href="/free/30min"]').should("exist"); + cy.get('[href="/free/60min"]').should("not.exist"); + }); + + it("/free/30min is bookable", () => { + cy.request({ + method: "GET", + url: "/free/30min", + failOnStatusCode: false, + }).then((res) => { + expect(res.status).to.eql(200); + }); + }); + + it("/free/60min is not bookable", () => { + cy.request({ + method: "GET", + url: "/free/60min", + failOnStatusCode: false, + }).then((res) => { + expect(res.status).to.eql(404); + }); + }); + }); + it("pro user's page has at least 2 visibile events", () => { + cy.visit("/pro"); + cy.get("[data-testid=event-types]").children().should("have.length.at.least", 2); + }); + + describe("free user with first hidden", () => { + it("has no visible events", () => { + cy.visit("/free-first-hidden"); + cy.contains("This user hasn't set up any event types yet."); + }); + + it("/free-first-hidden/30min is not bookable", () => { + cy.request({ + method: "GET", + url: "/free-first-hidden/30min", + failOnStatusCode: false, + }).then((res) => { + expect(res.status).to.eql(404); + }); + }); + + it("/free-first-hidden/60min is not bookable", () => { + cy.request({ + method: "GET", + url: "/free-first-hidden/60min", + failOnStatusCode: false, + }).then((res) => { + expect(res.status).to.eql(404); + }); + }); + }); +}); diff --git a/cypress/integration/event-types.spec.ts b/cypress/integration/event-types.spec.ts new file mode 100644 index 0000000000..15391a5e5d --- /dev/null +++ b/cypress/integration/event-types.spec.ts @@ -0,0 +1,66 @@ +function randomString(length: number) { + let result = ""; + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const charactersLength = characters.length; + for (let i = 0; i < length; i++) { + result += characters.charAt(Math.floor(Math.random() * charactersLength)); + } + return result; +} + +describe("pro user", () => { + before(() => { + cy.visit("/event-types"); + cy.login("pro@example.com", "pro"); + }); + beforeEach(() => { + cy.visit("/event-types"); + }); + + it("has at least 2 events", () => { + cy.get("[data-testid=event-types]").children().should("have.length.at.least", 2); + cy.get("[data-testid=event-types]") + .children() + .each(($el) => { + expect($el).to.have.attr("data-disabled", "0"); + }); + }); + + it("can add new event type", () => { + cy.get("[data-testid=new-event-type]").click(); + const nonce = randomString(3); + const eventTitle = `hello ${nonce}`; + + cy.get("[name=title]").focus().type(eventTitle); + cy.get("[name=length]").focus().type("10"); + cy.get("[type=submit]").click(); + + cy.location("pathname").should("not.eq", "/event-types"); + cy.visit("/event-types"); + + cy.get("[data-testid=event-types]").contains(eventTitle); + }); +}); + +describe("free user", () => { + before(() => { + cy.visit("/event-types"); + cy.login("free@example.com", "free"); + }); + describe("/event-types", () => { + beforeEach(() => { + cy.visit("/event-types"); + }); + + it("has at least 2 events where first is enabled", () => { + cy.get("[data-testid=event-types]").children().should("have.length.at.least", 2); + + cy.get("[data-testid=event-types]").children().first().should("have.attr", "data-disabled", "0"); + cy.get("[data-testid=event-types]").children().last().should("have.attr", "data-disabled", "1"); + }); + + it("can not add new event type", () => { + cy.get("[data-testid=new-event-type]").should("be.disabled"); + }); + }); +}); diff --git a/cypress/integration/load.test.js b/cypress/integration/smoke-test.spec.ts similarity index 59% rename from cypress/integration/load.test.js rename to cypress/integration/smoke-test.spec.ts index e3a2bee29f..cbc6dc6129 100644 --- a/cypress/integration/load.test.js +++ b/cypress/integration/smoke-test.spec.ts @@ -1,6 +1,4 @@ -/// - -describe("silly test", () => { +describe("smoke test", () => { it("loads /", () => { cy.visit("/"); cy.contains("Sign in to your account"); diff --git a/cypress/support/commands.js b/cypress/support/commands.js deleted file mode 100644 index 119ab03f7c..0000000000 --- a/cypress/support/commands.js +++ /dev/null @@ -1,25 +0,0 @@ -// *********************************************** -// This example commands.js shows you how to -// create various custom commands and overwrite -// existing commands. -// -// For more comprehensive examples of custom -// commands please read more here: -// https://on.cypress.io/custom-commands -// *********************************************** -// -// -// -- This is a parent command -- -// Cypress.Commands.add('login', (email, password) => { ... }) -// -// -// -- This is a child command -- -// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) -// -// -// -- This is a dual command -- -// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) -// -// -// -- This will overwrite an existing command -- -// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) diff --git a/cypress/support/commands.ts b/cypress/support/commands.ts new file mode 100644 index 0000000000..e26d427dd5 --- /dev/null +++ b/cypress/support/commands.ts @@ -0,0 +1,26 @@ +/* eslint-disable @typescript-eslint/no-namespace */ + +declare global { + namespace Cypress { + interface Chainable { + login(email: string, password: string): Chainable; + } + } +} +Cypress.Commands.add("login", (email: string, password: string) => { + cy.log(` 🗝 Logging in with ${email}`); + + Cypress.Cookies.defaults({ + preserve: /next-auth/, + }); + cy.clearCookies(); + cy.clearCookie("next-auth.session-token"); + cy.reload(); + + cy.get("[name=email]").focus().clear().type(email); + cy.get("[name=password]").focus().clear().type(password); + cy.get("[type=submit]").click(); + cy.wait(500); +}); + +export {}; diff --git a/cypress/support/index.js b/cypress/support/index.js deleted file mode 100644 index d076cec9fd..0000000000 --- a/cypress/support/index.js +++ /dev/null @@ -1,20 +0,0 @@ -// *********************************************************** -// This example support/index.js is processed and -// loaded automatically before your test files. -// -// This is a great place to put global configuration and -// behavior that modifies Cypress. -// -// You can change the location of this file or turn off -// automatically serving support files with the -// 'supportFile' configuration option. -// -// You can read more here: -// https://on.cypress.io/configuration -// *********************************************************** - -// Import commands.js using ES2015 syntax: -import "./commands"; - -// Alternatively you can use CommonJS syntax: -// require('./commands') diff --git a/cypress/support/index.ts b/cypress/support/index.ts new file mode 100644 index 0000000000..ea933116bc --- /dev/null +++ b/cypress/support/index.ts @@ -0,0 +1,4 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +/* eslint-disable @typescript-eslint/no-namespace */ + +import "./commands"; diff --git a/cypress/tsconfig.json b/cypress/tsconfig.json new file mode 100644 index 0000000000..0cbfffb01a --- /dev/null +++ b/cypress/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "strict": true, + "baseUrl": "../node_modules", + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress"] + }, + "include": ["**/*.ts"] +} diff --git a/lib/asStringOrNull.tsx b/lib/asStringOrNull.tsx index 5b661e420e..ce9c8b72ad 100644 --- a/lib/asStringOrNull.tsx +++ b/lib/asStringOrNull.tsx @@ -1,3 +1,11 @@ export function asStringOrNull(str: unknown) { return typeof str === "string" ? str : null; } + +export function asStringOrThrow(str: unknown): string { + const type = typeof str; + if (type !== "string") { + throw new Error(`Expected "string" - got ${type}`); + } + return str; +} diff --git a/lib/hooks/useToggleQuery.tsx b/lib/hooks/useToggleQuery.tsx new file mode 100644 index 0000000000..065e11366d --- /dev/null +++ b/lib/hooks/useToggleQuery.tsx @@ -0,0 +1,31 @@ +import { useRouter } from "next/router"; +import { useMemo } from "react"; + +export function useToggleQuery(name: string) { + const router = useRouter(); + + const hrefOff = useMemo(() => { + const query = { + ...router.query, + }; + delete query[name]; + return { + query, + }; + }, [router.query, name]); + const hrefOn = useMemo(() => { + const query = { + ...router.query, + [name]: "1", + }; + return { + query, + }; + }, [router.query, name]); + + return { + hrefOn, + hrefOff, + isOn: router.query[name] === "1", + }; +} diff --git a/lib/prisma.ts b/lib/prisma.ts index 2c556fbe7d..0ae677b5a4 100644 --- a/lib/prisma.ts +++ b/lib/prisma.ts @@ -7,7 +7,9 @@ if (process.env.NODE_ENV === "production") { prisma = new PrismaClient(); } else { if (!globalAny.prisma) { - globalAny.prisma = new PrismaClient(); + globalAny.prisma = new PrismaClient({ + log: ["query", "error", "warn"], + }); } prisma = globalAny.prisma; } diff --git a/lib/types/inferSSRProps.ts b/lib/types/inferSSRProps.ts index ea6d84cf43..ad198523c9 100644 --- a/lib/types/inferSSRProps.ts +++ b/lib/types/inferSSRProps.ts @@ -2,7 +2,7 @@ type GetSSRResult = // - { props: TProps } | { redirect: any } | { notFound: true }; + { props: TProps } | { redirect: any } | { notFound: boolean }; type GetSSRFn = (...args: any[]) => Promise>; diff --git a/package.json b/package.json index c703d23329..7ff0092843 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "db-migrate": "yarn prisma migrate dev", "db-seed": "yarn ts-node scripts/seed.ts", "db-nuke": "docker-compose down --volumes --remove-orphans", - "dx": "DATABASE_URL=postgresql://postgres:@localhost:5450/calendso eval 'yarn db-up && yarn prisma migrate dev && yarn db-seed && yarn dev'", + "dx": "BASE_URL='http://localhost:3000' DATABASE_URL=postgresql://postgres:@localhost:5450/calendso eval 'yarn db-up && yarn prisma migrate dev && yarn db-seed && yarn dev'", "test": "node node_modules/.bin/jest", "build": "next build", "start": "next start", @@ -51,6 +51,7 @@ "next-seo": "^4.26.0", "next-transpile-modules": "^8.0.0", "nodemailer": "^6.6.3", + "npm-run-all": "^4.1.5", "react": "17.0.2", "react-dates": "^21.8.0", "react-dom": "17.0.2", @@ -73,6 +74,7 @@ "@types/nodemailer": "^6.4.4", "@types/react": "^17.0.18", "@types/react-dates": "^21.8.3", + "@types/react-select": "^4.0.17", "@typescript-eslint/eslint-plugin": "^4.30.0", "@typescript-eslint/parser": "^4.29.2", "autoprefixer": "^10.3.1", @@ -91,7 +93,7 @@ "prisma": "^2.30.2", "tailwindcss": "^2.2.7", "ts-node": "^10.2.1", - "typescript": "^4.3.5" + "typescript": "^4.4.2" }, "lint-staged": { "./{*,{pages,components,lib}/**/*}.{js,ts,jsx,tsx}": [ diff --git a/pages/[user].tsx b/pages/[user].tsx index 6773abba9e..8c2ffa9055 100644 --- a/pages/[user].tsx +++ b/pages/[user].tsx @@ -1,51 +1,17 @@ -import { GetServerSideProps } from "next"; -import { HeadSeo } from "@components/seo/head-seo"; -import Link from "next/link"; -import prisma, { whereAndSelect } from "@lib/prisma"; import Avatar from "@components/Avatar"; +import { HeadSeo } from "@components/seo/head-seo"; import Theme from "@components/Theme"; -import { ClockIcon, InformationCircleIcon, UserIcon } from "@heroicons/react/solid"; -import React from "react"; import { ArrowRightIcon } from "@heroicons/react/outline"; +import { ClockIcon, InformationCircleIcon, UserIcon } from "@heroicons/react/solid"; +import prisma from "@lib/prisma"; +import { inferSSRProps } from "@lib/types/inferSSRProps"; +import { GetServerSidePropsContext } from "next"; +import Link from "next/link"; +import React from "react"; -export default function User(props): User { +export default function User(props: inferSSRProps) { const { isReady } = Theme(props.user.theme); - const eventTypes = props.eventTypes.map((type) => ( - - )); return ( <>

{props.user.bio}

-
{eventTypes}
- {eventTypes.length == 0 && ( + + {props.eventTypes.length == 0 && (

Uh oh!

@@ -84,33 +86,45 @@ export default function User(props): User { ); } -export const getServerSideProps: GetServerSideProps = async (context) => { - const user = await whereAndSelect( - prisma.user.findFirst, - { - username: context.query.user.toLowerCase(), +export const getServerSideProps = async (context: GetServerSidePropsContext) => { + const username = (context.query.user as string).toLowerCase(); + + const user = await prisma.user.findUnique({ + where: { + username, }, - ["id", "username", "email", "name", "bio", "avatar", "theme"] - ); + select: { + id: true, + username: true, + email: true, + name: true, + bio: true, + avatar: true, + theme: true, + plan: true, + }, + }); if (!user) { return { notFound: true, }; } - const eventTypes = await prisma.eventType.findMany({ + const eventTypesWithHidden = await prisma.eventType.findMany({ where: { userId: user.id, - hidden: false, }, select: { + id: true, slug: true, title: true, length: true, description: true, + hidden: true, }, + take: user.plan === "FREE" ? 1 : undefined, }); - + const eventTypes = eventTypesWithHidden.filter((evt) => !evt.hidden); return { props: { user, diff --git a/pages/[user]/[type].tsx b/pages/[user]/[type].tsx index f86c838957..02d297523e 100644 --- a/pages/[user]/[type].tsx +++ b/pages/[user]/[type].tsx @@ -235,6 +235,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => availability: true, hideBranding: true, theme: true, + plan: true, }, }); @@ -243,11 +244,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => notFound: true, } as const; } - - const eventType = await prisma.eventType.findFirst({ + const eventType = await prisma.eventType.findUnique({ where: { - userId: user.id, - slug: typeParam, + userId_slug: { + userId: user.id, + slug: typeParam, + }, }, select: { id: true, @@ -262,15 +264,32 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => periodEndDate: true, periodCountCalendarDays: true, minimumBookingNotice: true, + hidden: true, }, }); - if (!eventType) { + if (!eventType || eventType.hidden) { return { notFound: true, } as const; } + // check this is the first event + if (user.plan === "FREE") { + const firstEventType = await prisma.eventType.findFirst({ + where: { + userId: user.id, + }, + select: { + id: true, + }, + }); + if (firstEventType?.id !== eventType.id) { + return { + notFound: true, + } as const; + } + } const getWorkingHours = (providesAvailability: { availability: Availability[] }) => providesAvailability.availability && providesAvailability.availability.length ? providesAvailability.availability diff --git a/pages/event-types/[type].tsx b/pages/event-types/[type].tsx index bd5c35a354..8b88d7a440 100644 --- a/pages/event-types/[type].tsx +++ b/pages/event-types/[type].tsx @@ -1,8 +1,7 @@ -import Link from "next/link"; import { useRouter } from "next/router"; import Modal from "@components/Modal"; import React, { useEffect, useRef, useState } from "react"; -import Select, { OptionBase } from "react-select"; +import Select, { OptionTypeBase } from "react-select"; import prisma from "@lib/prisma"; import { EventTypeCustomInput, EventTypeCustomInputType } from "@prisma/client"; import { LocationType } from "@lib/location"; @@ -25,7 +24,7 @@ import { import dayjs from "dayjs"; import utc from "dayjs/plugin/utc"; import timezone from "dayjs/plugin/timezone"; -import { Availability, EventType, User } from "@prisma/client"; +import { Availability } from "@prisma/client"; import { validJson } from "@lib/jsonUtils"; import classnames from "classnames"; import throttle from "lodash.throttle"; @@ -42,6 +41,8 @@ import updateEventType from "@lib/mutations/event-types/update-event-type"; import deleteEventType from "@lib/mutations/event-types/delete-event-type"; import showToast from "@lib/notification"; import { inferSSRProps } from "@lib/types/inferSSRProps"; +import { asStringOrThrow } from "@lib/asStringOrNull"; +import Button from "@components/ui/Button"; dayjs.extend(utc); dayjs.extend(timezone); @@ -66,7 +67,7 @@ const EventTypePage = (props: inferSSRProps) => { const router = useRouter(); const [successModalOpen, setSuccessModalOpen] = useState(false); - const inputOptions: OptionBase[] = [ + const inputOptions: OptionTypeBase[] = [ { value: EventTypeCustomInputType.TEXT, label: "Text" }, { value: EventTypeCustomInputType.TEXTLONG, label: "Multiline Text" }, { value: EventTypeCustomInputType.NUMBER, label: "Number" }, @@ -130,8 +131,8 @@ const EventTypePage = (props: inferSSRProps) => { const [showLocationModal, setShowLocationModal] = useState(false); const [showAddCustomModal, setShowAddCustomModal] = useState(false); const [selectedTimeZone, setSelectedTimeZone] = useState(""); - const [selectedLocation, setSelectedLocation] = useState(undefined); - const [selectedInputOption, setSelectedInputOption] = useState(inputOptions[0]); + const [selectedLocation, setSelectedLocation] = useState(undefined); + const [selectedInputOption, setSelectedInputOption] = useState(inputOptions[0]); const [locations, setLocations] = useState(eventType.locations || []); const [selectedCustomInput, setSelectedCustomInput] = useState(undefined); const [customInputs, setCustomInputs] = useState( @@ -162,14 +163,14 @@ const EventTypePage = (props: inferSSRProps) => { }); const [hidden, setHidden] = useState(eventType.hidden); - const titleRef = useRef(); - const slugRef = useRef(); - const descriptionRef = useRef(); - const lengthRef = useRef(); - const requiresConfirmationRef = useRef(); - const eventNameRef = useRef(); - const periodDaysRef = useRef(); - const periodDaysTypeRef = useRef(); + const titleRef = useRef(null); + const slugRef = useRef(null); + const descriptionRef = useRef(null); + const lengthRef = useRef(null); + const requiresConfirmationRef = useRef(null); + const eventNameRef = useRef(null); + const periodDaysRef = useRef(null); + const periodDaysTypeRef = useRef(null); useEffect(() => { setSelectedTimeZone(eventType.timeZone || user.timeZone); @@ -804,17 +805,11 @@ const EventTypePage = (props: inferSSRProps) => { )} -
- - - Cancel - - - +
+ +
) => { export const getServerSideProps = async (context: GetServerSidePropsContext) => { const { req, query } = context; const session = await getSession({ req }); - if (!session) { + const typeParam = asStringOrThrow(query.type); + + if (!session?.user?.id) { return { redirect: { permanent: false, @@ -1042,22 +1039,38 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }; } - const user: User = await prisma.user.findFirst({ + const user = await prisma.user.findUnique({ where: { - email: session.user.email, + id: session.user.id, }, select: { + id: true, username: true, timeZone: true, startTime: true, endTime: true, availability: true, + plan: true, }, }); - const eventType: EventType | null = await prisma.eventType.findUnique({ + if (!user) { + return { + notFound: true, + } as const; + } + + const eventType = await prisma.eventType.findFirst({ where: { - id: parseInt(query.type as string), + userId: user.id, + OR: [ + { + slug: typeParam, + }, + { + id: parseInt(typeParam), + }, + ], }, select: { id: true, @@ -1116,10 +1129,10 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => }, ]; - const locationOptions: OptionBase[] = [ + const locationOptions: OptionTypeBase[] = [ { value: LocationType.InPerson, label: "In-person meeting" }, { value: LocationType.Phone, label: "Phone call" }, - { value: LocationType.Zoom, label: "Zoom Video" }, + { value: LocationType.Zoom, label: "Zoom Video", disabled: true }, ]; const hasGoogleCalendarIntegration = integrations.find( diff --git a/pages/event-types/index.tsx b/pages/event-types/index.tsx index b133947fe6..2dde812bc9 100644 --- a/pages/event-types/index.tsx +++ b/pages/event-types/index.tsx @@ -1,4 +1,4 @@ -import { Dialog, DialogClose, DialogContent } from "@components/Dialog"; +import { Dialog, DialogContent } from "@components/Dialog"; import Loader from "@components/Loader"; import { Tooltip } from "@components/Tooltip"; import { Button } from "@components/ui/Button"; @@ -21,81 +21,57 @@ import { useRouter } from "next/router"; import React, { Fragment, useRef } from "react"; import Shell from "@components/Shell"; import prisma from "@lib/prisma"; -import { GetServerSidePropsContext, InferGetServerSidePropsType } from "next"; +import { GetServerSidePropsContext } from "next"; import { useMutation } from "react-query"; import createEventType from "@lib/mutations/event-types/create-event-type"; -import { ONBOARDING_INTRODUCED_AT } from "@lib/getting-started"; import { getSession } from "@lib/auth"; +import { ONBOARDING_INTRODUCED_AT } from "@lib/getting-started"; +import { useToggleQuery } from "@lib/hooks/useToggleQuery"; +import { inferSSRProps } from "@lib/types/inferSSRProps"; +import { Alert } from "@components/ui/Alert"; -const EventTypesPage = (props: InferGetServerSidePropsType) => { +const EventTypesPage = (props: inferSSRProps) => { const { user, types } = props; const [session, loading] = useSession(); const router = useRouter(); const createMutation = useMutation(createEventType, { onSuccess: async ({ eventType }) => { - await router.replace("/event-types/" + eventType.id); + await router.push("/event-types/" + eventType.id); showToast(`${eventType.title} event type created successfully`, "success"); }, onError: (err: Error) => { showToast(err.message, "error"); }, }); + const modalOpen = useToggleQuery("new"); - const titleRef = useRef(); - const slugRef = useRef(); - const descriptionRef = useRef(); - const lengthRef = useRef(); - - const dialogOpen = router.query.new === "1"; - - async function createEventTypeHandler(event) { - event.preventDefault(); - - const enteredTitle = titleRef.current.value; - const enteredSlug = slugRef.current.value; - const enteredDescription = descriptionRef.current.value; - const enteredLength = parseInt(lengthRef.current.value); - - const body = { - title: enteredTitle, - slug: enteredSlug, - description: enteredDescription, - length: enteredLength, - }; - - createMutation.mutate(body); - } - - function autoPopulateSlug() { - let t = titleRef.current.value; - t = t.replace(/\s+/g, "-").toLowerCase(); - slugRef.current.value = t; - } + const slugRef = useRef(null); if (loading) { return ; } - const CreateNewEventDialog = () => ( + const renderEventDialog = () => ( { - const newQuery = { - ...router.query, - }; - delete newQuery["new"]; - if (!isOpen) { - router.push({ pathname: router.pathname, query: newQuery }); - } + router.push(isOpen ? modalOpen.hrefOn : modalOpen.hrefOff); }}> - @@ -108,7 +84,24 @@ const EventTypesPage = (props: InferGetServerSidePropsTypeCreate a new event type for people to book times with.

-
+ { + e.preventDefault(); + + const target = e.target as unknown as Record< + "title" | "slug" | "description" | "length", + { value: string } + >; + + const body = { + title: target.title.value, + slug: target.slug.value, + description: target.description.value, + length: parseInt(target.length.value), + }; + + createMutation.mutate(body); + }}>