From 05442bd57d991ff0f15a67e8b1fdbc6c19bf9f5b Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sat, 8 Feb 2025 23:28:33 +1100 Subject: [PATCH 01/24] Add teams page --- .../dialogs/manage-team-dialog.component.tsx | 258 +++++++++++ apps/web/components/entity/empty-state.tsx | 24 +- .../components/layout/header.component.tsx | 1 + apps/web/data/routes.data.ts | 3 + apps/web/data/schema.ts | 7 +- apps/web/pages/_app.tsx | 38 +- apps/web/pages/pages/index.tsx | 3 +- apps/web/pages/teams/index.tsx | 419 ++++++++++++++++++ apps/web/utils/useDatabase.ts | 2 +- packages/supabase/migrations/16_teams.sql | 71 +++ packages/supabase/types/index.ts | 144 +++++- packages/supabase/types/page.ts | 4 + 12 files changed, 930 insertions(+), 44 deletions(-) create mode 100644 apps/web/components/dialogs/manage-team-dialog.component.tsx create mode 100644 apps/web/pages/teams/index.tsx create mode 100644 packages/supabase/migrations/16_teams.sql diff --git a/apps/web/components/dialogs/manage-team-dialog.component.tsx b/apps/web/components/dialogs/manage-team-dialog.component.tsx new file mode 100644 index 0000000..05b75df --- /dev/null +++ b/apps/web/components/dialogs/manage-team-dialog.component.tsx @@ -0,0 +1,258 @@ +import { ITeam } from "@changes-page/supabase/types/page"; +import { Dialog, Transition } from "@headlessui/react"; +import { PlusIcon } from "@heroicons/react/outline"; +import { useFormik } from "formik"; +import { useRouter } from "next/router"; +import { Fragment, useEffect, useRef } from "react"; +import { InferType } from "yup"; +import { ROUTES } from "../../data/routes.data"; +import { NewTeamSchema } from "../../data/schema"; +import { useUserData } from "../../utils/useUser"; +import { InlineErrorMessage } from "../forms/notification.component"; + +export default function ManageTeamDialog({ + open, + setOpen, + team, + onSuccess, + onCancel, +}: { + open: boolean; + setOpen: (open: boolean) => void; + team?: ITeam; + onSuccess: () => void; + onCancel: () => void; +}) { + const router = useRouter(); + const cancelButtonRef = useRef(null); + const { supabase, user } = useUserData(); + + useEffect(() => { + if (open) { + formik.resetForm(); + } + + if (team) { + formik.setValues({ + name: team.name, + image: team.image, + }); + } + }, [open, team]); + + const formik = useFormik>({ + initialValues: { + name: "", + image: null, + }, + validationSchema: NewTeamSchema, + onSubmit: async (values) => { + if (!user) { + router.push(ROUTES.LOGIN); + return; + } + + if (!values.name) { + formik.setFieldError("name", "Name is required"); + return; + } + + if (team) { + await supabase + .from("teams") + .update({ + name: values.name, + image: values.image, + }) + .match({ id: team.id }); + onSuccess(); + } else { + await supabase + .from("teams") + .insert([ + { + name: values.name, + image: values.image, + owner_id: user.id, + }, + ]) + .select(); + onSuccess(); + } + + setOpen(false); + }, + }); + + return ( + + +
+ + + + + {/* This element is to trick the browser into centering the modal contents. */} + + +
+
+
+
+
+
+ + {team ? "Edit Team" : "Create Team"} + +
+

+ {team + ? "Edit your team details." + : "Create a new team to manage your pages and posts."} +

+
+ +
+
+
+
+ + +
+ + + {formik.errors.name && formik.touched.name && ( +
+ +
+ )} +
+
+ +
+ + +
+ + + {formik.errors.image && formik.touched.image && ( +
+ +
+ )} + + {formik.values.image && !formik.errors.image && ( +
+ Team Avatar Preview { + e.currentTarget.onerror = null; + formik.setFieldError( + "image", + "Failed to load image" + ); + }} + /> +
+ )} +
+
+
+
+
+
+
+
+ +
+ + +
+
+
+
+
+
+ ); +} diff --git a/apps/web/components/entity/empty-state.tsx b/apps/web/components/entity/empty-state.tsx index aec78af..74ca515 100644 --- a/apps/web/components/entity/empty-state.tsx +++ b/apps/web/components/entity/empty-state.tsx @@ -16,6 +16,15 @@ export function EntityEmptyState({ buttonLabel, disabled = false, footer = null, + onButtonClick, +}: { + title: string; + message: string; + buttonLink?: string; + buttonLabel?: string; + disabled?: boolean; + footer?: React.ReactNode; + onButtonClick?: () => void; }) { return (
@@ -38,7 +47,7 @@ export function EntityEmptyState({ {title}

{message}

- {!disabled && ( + {!disabled && buttonLabel && buttonLink ? (
- )} + ) : null} + {onButtonClick && buttonLabel ? ( +
+ +
+ ) : null} {footer}
); diff --git a/apps/web/components/layout/header.component.tsx b/apps/web/components/layout/header.component.tsx index 8660ada..39b35ba 100644 --- a/apps/web/components/layout/header.component.tsx +++ b/apps/web/components/layout/header.component.tsx @@ -27,6 +27,7 @@ export default function HeaderComponent() { if (billingDetails?.has_active_subscription) { return [ { name: "Pages", href: ROUTES.PAGES }, + { name: "Teams", href: ROUTES.TEAMS }, { name: "Zapier", href: ROUTES.ZAPIER }, { name: "Billing", href: ROUTES.BILLING }, { name: "Support", href: ROUTES.SUPPORT, external: true }, diff --git a/apps/web/data/routes.data.ts b/apps/web/data/routes.data.ts index 6a21fc5..798531d 100644 --- a/apps/web/data/routes.data.ts +++ b/apps/web/data/routes.data.ts @@ -12,6 +12,9 @@ export const ROUTES = { PAGES: "/pages", NEW_PAGE: "/pages/new", + // teams + TEAMS: "/teams", + // account BILLING: "/account/billing", diff --git a/apps/web/data/schema.ts b/apps/web/data/schema.ts index 7d28b24..ff97989 100644 --- a/apps/web/data/schema.ts +++ b/apps/web/data/schema.ts @@ -39,4 +39,9 @@ export const NewPostSchema = object().shape({ allow_reactions: boolean(), email_notified: boolean(), notes: string().optional().nullable(), -}); \ No newline at end of file +}); + +export const NewTeamSchema = object().shape({ + name: string().required("Name is required").min(3, "Name must be at least 3 characters long"), + image: string().optional().nullable(), +}); diff --git a/apps/web/pages/_app.tsx b/apps/web/pages/_app.tsx index 7e2f09d..2efebb2 100644 --- a/apps/web/pages/_app.tsx +++ b/apps/web/pages/_app.tsx @@ -3,9 +3,7 @@ import { SessionContextProvider } from "@supabase/auth-helpers-react"; import { Analytics } from "@vercel/analytics/react"; import localFont from "next/font/local"; import Head from "next/head"; -import { useRouter } from "next/router"; -import Script from "next/script"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import "../styles/global.css"; import { UserContextProvider } from "../utils/useUser"; @@ -26,22 +24,6 @@ export default function App({ Component, pageProps }) { const getLayout = Component.getLayout || ((page) => page); const [supabaseClient] = useState(() => createPagesBrowserClient()); - const router = useRouter(); - const googleTagId = "AW-11500375049"; - - useEffect(() => { - const handleRouteChange = (url: string) => { - // @ts-ignore - window.gtag("config", googleTagId, { - page_path: url, - }); - }; - router.events.on("routeChangeComplete", handleRouteChange); - return () => { - router.events.off("routeChangeComplete", handleRouteChange); - }; - }, [router.events, googleTagId]); - return ( <> @@ -55,24 +37,6 @@ export default function App({ Component, pageProps }) { --geist-font: ${geist.style.fontFamily}; } `} -