From d664a04ef91859b90285517e52dc507e64b545e4 Mon Sep 17 00:00:00 2001 From: Sidney Alcantara Date: Mon, 25 Apr 2022 12:26:59 +1000 Subject: [PATCH] add Setup page, Modal --- package.json | 1 + src/App.tsx | 17 +- src/analytics.ts | 15 + src/assets/BrandedBackground.tsx | 15 +- src/components/Modal/FullScreenModal.tsx | 110 +++++++ .../Modal/ScrollableDialogContent.tsx | 67 ++++ src/components/Modal/SlideTransition.tsx | 77 +++++ src/components/Modal/index.tsx | 155 +++++++++ src/components/Setup/SetupItem.tsx | 50 +++ src/components/Setup/SetupLayout.tsx | 306 ++++++++++++++++++ src/components/Setup/SetupStep.d.ts | 15 + src/components/Setup/SignInWithGoogle.tsx | 84 +++++ src/components/Setup/Steps/StepFinish.tsx | 102 ++++++ src/components/Setup/Steps/StepRules.tsx | 146 +++++++++ .../Setup/Steps/StepStorageRules.tsx | 113 +++++++ src/components/Setup/Steps/StepWelcome.tsx | 96 ++++++ src/pages/Auth/ImpersonatorAuth.tsx | 16 +- src/pages/NotFound.tsx | 42 +-- src/pages/Setup.tsx | 25 ++ src/types/custom.d.ts | 12 + src/types/react-element-scroll-hook.d.ts | 4 + tsconfig.json | 2 +- yarn.lock | 5 + 23 files changed, 1425 insertions(+), 50 deletions(-) create mode 100644 src/analytics.ts create mode 100644 src/components/Modal/FullScreenModal.tsx create mode 100644 src/components/Modal/ScrollableDialogContent.tsx create mode 100644 src/components/Modal/SlideTransition.tsx create mode 100644 src/components/Modal/index.tsx create mode 100644 src/components/Setup/SetupItem.tsx create mode 100644 src/components/Setup/SetupLayout.tsx create mode 100644 src/components/Setup/SetupStep.d.ts create mode 100644 src/components/Setup/SignInWithGoogle.tsx create mode 100644 src/components/Setup/Steps/StepFinish.tsx create mode 100644 src/components/Setup/Steps/StepRules.tsx create mode 100644 src/components/Setup/Steps/StepStorageRules.tsx create mode 100644 src/components/Setup/Steps/StepWelcome.tsx create mode 100644 src/pages/Setup.tsx create mode 100644 src/types/custom.d.ts create mode 100644 src/types/react-element-scroll-hook.d.ts diff --git a/package.json b/package.json index 98cd98f1..3846a560 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "react-data-grid": "7.0.0-beta.5", "react-div-100vh": "^0.7.0", "react-dom": "^18.0.0", + "react-element-scroll-hook": "^1.1.0", "react-error-boundary": "^3.1.4", "react-helmet-async": "^1.3.0", "react-router-dom": "^6.3.0", diff --git a/src/App.tsx b/src/App.tsx index e03d3ad8..ca7917a7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,11 +18,14 @@ import SignOutPage from "@src/pages/Auth/SignOut"; // prettier-ignore const AuthPage = lazy(() => import("@src/pages/Auth/index" /* webpackChunkName: "AuthPage" */)); // prettier-ignore -const SignUpPage = lazy(() => import("@src/pages/Auth/SignUp" /* webpackChunkName: "Auth/SignUpPage" */)); +const SignUpPage = lazy(() => import("@src/pages/Auth/SignUp" /* webpackChunkName: "SignUpPage" */)); // prettier-ignore -const JwtAuthPage = lazy(() => import("@src/pages/Auth/JwtAuth" /* webpackChunkName: "Auth/JwtAuthPage" */)); +const JwtAuthPage = lazy(() => import("@src/pages/Auth/JwtAuth" /* webpackChunkName: "JwtAuthPage" */)); // prettier-ignore -const ImpersonatorAuthPage = lazy(() => import("@src/pages/Auth/ImpersonatorAuth" /* webpackChunkName: "Auth/ImpersonatorAuthPage" */)); +const ImpersonatorAuthPage = lazy(() => import("@src/pages/Auth/ImpersonatorAuth" /* webpackChunkName: "ImpersonatorAuthPage" */)); + +// prettier-ignore +const SetupPage = lazy(() => import("@src/pages/Setup" /* webpackChunkName: "SetupPage" */)); export default function App() { const [currentUser] = useAtom(currentUserAtom, globalScope); @@ -43,9 +46,15 @@ export default function App() { } /> } + element={ + + + + } /> + } /> + - - + /> ); } diff --git a/src/components/Modal/FullScreenModal.tsx b/src/components/Modal/FullScreenModal.tsx new file mode 100644 index 00000000..a6a3006d --- /dev/null +++ b/src/components/Modal/FullScreenModal.tsx @@ -0,0 +1,110 @@ +import { useState } from "react"; + +import { + Dialog, + DialogProps, + Slide, + IconButton, + Container, +} from "@mui/material"; +import CloseIcon from "@mui/icons-material/Close"; + +import ScrollableDialogContent, { + IScrollableDialogContentProps, +} from "./ScrollableDialogContent"; + +export interface IFullScreenModalProps + extends Partial> { + onClose: (setOpen: React.Dispatch>) => void; + disableBackdropClick?: boolean; + disableEscapeKeyDown?: boolean; + + "aria-labelledby": DialogProps["aria-labelledby"]; + header?: React.ReactNode; + children?: React.ReactNode; + footer?: React.ReactNode; + + hideCloseButton?: boolean; + ScrollableDialogContentProps?: Partial; +} + +export default function FullScreenModal({ + onClose, + disableBackdropClick, + disableEscapeKeyDown, + header, + children, + footer, + hideCloseButton, + ScrollableDialogContentProps, + ...props +}: IFullScreenModalProps) { + const [open, setOpen] = useState(true); + const handleClose: NonNullable = (_, reason) => { + if ( + (disableBackdropClick && reason === "backdropClick") || + (disableEscapeKeyDown && reason === "escapeKeyDown") + ) { + setEmphasizeCloseButton(true); + return; + } + + setOpen(false); + setEmphasizeCloseButton(false); + setTimeout(() => onClose(setOpen), 300); + }; + + const [emphasizeCloseButton, setEmphasizeCloseButton] = useState(false); + + return ( + + + {!hideCloseButton && ( + theme.spacing(1), + right: (theme) => theme.spacing(1), + + bgcolor: emphasizeCloseButton ? "error.main" : undefined, + color: emphasizeCloseButton ? "error.contrastText" : undefined, + "&:hover": emphasizeCloseButton + ? { bgcolor: "error.dark" } + : undefined, + }} + className="dialog-close" + > + + + )} + + {header} + + + {children} + + + {footer} + + + ); +} diff --git a/src/components/Modal/ScrollableDialogContent.tsx b/src/components/Modal/ScrollableDialogContent.tsx new file mode 100644 index 00000000..f041f099 --- /dev/null +++ b/src/components/Modal/ScrollableDialogContent.tsx @@ -0,0 +1,67 @@ +import { memo } from "react"; +import useScrollInfo from "react-element-scroll-hook"; + +import { + Divider, + DividerProps, + DialogContent, + DialogContentProps, +} from "@mui/material"; + +const MemoizedDialogContent = memo(function MemoizedDialogContent_({ + setRef, + ...props +}: DialogContentProps & { setRef: any }) { + return ; +}); + +export interface IScrollableDialogContentProps extends DialogContentProps { + disableTopDivider?: boolean; + disableBottomDivider?: boolean; + dividerSx?: DividerProps["sx"]; + topDividerSx?: DividerProps["sx"]; + bottomDividerSx?: DividerProps["sx"]; +} + +export default function ScrollableDialogContent({ + disableTopDivider = false, + disableBottomDivider = false, + dividerSx = [], + topDividerSx = [], + bottomDividerSx = [], + ...props +}: IScrollableDialogContentProps) { + const [scrollInfo, setRef] = useScrollInfo(); + + return ( + <> + {!disableTopDivider && scrollInfo.y.percentage !== null && ( + 0 ? "visible" : "hidden", + }} + sx={[ + ...(Array.isArray(dividerSx) ? dividerSx : [dividerSx]), + ...(Array.isArray(topDividerSx) ? topDividerSx : [topDividerSx]), + ]} + /> + )} + + + + {!disableBottomDivider && scrollInfo.y.percentage !== null && ( + + )} + + ); +} diff --git a/src/components/Modal/SlideTransition.tsx b/src/components/Modal/SlideTransition.tsx new file mode 100644 index 00000000..01706aaf --- /dev/null +++ b/src/components/Modal/SlideTransition.tsx @@ -0,0 +1,77 @@ +import { forwardRef, cloneElement } from "react"; +import { useTheme } from "@mui/material"; +import { Transition } from "react-transition-group"; +import { TransitionProps } from "react-transition-group/Transition"; +import { TransitionProps as MuiTransitionProps } from "@mui/material/transitions"; + +export const SlideTransition: React.ForwardRefExoticComponent< + Pick & React.RefAttributes +> = forwardRef( + ({ children, ...props }: TransitionProps, ref: React.Ref) => { + const theme = useTheme(); + + if (!children) return null; + + const defaultStyle = { + opacity: 0, + transform: "translateY(40px)", + + transition: theme.transitions.create(["transform", "opacity"], { + duration: "300ms", + easing: "cubic-bezier(0.1, 0.8, 0.1, 1)", + }), + }; + + const transitionStyles = { + entering: { + willChange: "transform, opacity", + }, + + entered: { + opacity: 1, + transform: "none", + }, + + exiting: { + opacity: 0, + transform: "none", + + transition: theme.transitions.create(["opacity"], { + duration: theme.transitions.duration.leavingScreen, + }), + }, + + exited: { + opacity: 0, + transform: "none", + transition: "none", + }, + + unmounted: {}, + }; + + return ( + + {(state) => + cloneElement(children as any, { + style: { ...defaultStyle, ...transitionStyles[state] }, + ref, + }) + } + + ); + } +); + +export default SlideTransition; + +export const SlideTransitionMui = forwardRef(function Transition( + props: MuiTransitionProps & { children?: React.ReactElement }, + ref: React.Ref +) { + return ; +}); diff --git a/src/components/Modal/index.tsx b/src/components/Modal/index.tsx new file mode 100644 index 00000000..184a02e5 --- /dev/null +++ b/src/components/Modal/index.tsx @@ -0,0 +1,155 @@ +import { useState } from "react"; + +import { + useTheme, + useMediaQuery, + Dialog, + DialogProps, + Stack, + DialogTitle, + IconButton, + DialogActions, + Button, + ButtonProps, + Slide, +} from "@mui/material"; +import LoadingButton, { LoadingButtonProps } from "@mui/lab/LoadingButton"; +import CloseIcon from "@mui/icons-material/Close"; + +import { SlideTransitionMui } from "./SlideTransition"; +import ScrollableDialogContent, { + IScrollableDialogContentProps, +} from "./ScrollableDialogContent"; + +export interface IModalProps extends Partial> { + onClose: (setOpen: React.Dispatch>) => void; + disableBackdropClick?: boolean; + disableEscapeKeyDown?: boolean; + + title: React.ReactNode; + header?: React.ReactNode; + footer?: React.ReactNode; + + children?: React.ReactNode; + body?: React.ReactNode; + + actions?: { + primary?: Partial; + secondary?: Partial; + }; + + hideCloseButton?: boolean; + fullHeight?: boolean; + ScrollableDialogContentProps?: Partial; +} + +export default function Modal({ + onClose, + disableBackdropClick, + disableEscapeKeyDown, + title, + header, + footer, + children, + body, + actions, + hideCloseButton, + fullHeight, + ScrollableDialogContentProps, + ...props +}: IModalProps) { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down("sm")); + + const [open, setOpen] = useState(true); + const handleClose: NonNullable = (_, reason) => { + if ( + (disableBackdropClick && reason === "backdropClick") || + (disableEscapeKeyDown && reason === "escapeKeyDown") + ) { + setEmphasizeCloseButton(true); + return; + } + + setOpen(false); + setEmphasizeCloseButton(false); + setTimeout(() => onClose(setOpen), 300); + }; + + const [emphasizeCloseButton, setEmphasizeCloseButton] = useState(false); + + return ( + + + + {title} + + + {!hideCloseButton && ( + + + + )} + + + {header} + + + {children || body} + + + {footer} + + {actions && ( + + {actions.secondary && + ); +} diff --git a/src/components/Setup/SetupItem.tsx b/src/components/Setup/SetupItem.tsx new file mode 100644 index 00000000..3612ccbc --- /dev/null +++ b/src/components/Setup/SetupItem.tsx @@ -0,0 +1,50 @@ +import { Stack, Typography } from "@mui/material"; +import CheckIcon from "@mui/icons-material/Check"; +import ArrowIcon from "@mui/icons-material/ArrowForward"; +import CircularProgressOptical from "@src/components/CircularProgressOptical"; + +export interface ISetupItemProps { + status: "complete" | "loading" | "incomplete"; + title: React.ReactNode; + children?: React.ReactNode; +} + +export default function SetupItem({ + status, + title, + children, +}: ISetupItemProps) { + return ( + + {status === "complete" ? ( + + ) : status === "loading" ? ( + + ) : ( + + )} + + + + {title} + + + {children} + + + ); +} diff --git a/src/components/Setup/SetupLayout.tsx b/src/components/Setup/SetupLayout.tsx new file mode 100644 index 00000000..afd0b72f --- /dev/null +++ b/src/components/Setup/SetupLayout.tsx @@ -0,0 +1,306 @@ +import React, { useState, createElement } from "react"; +import { use100vh } from "react-div-100vh"; +import { SwitchTransition } from "react-transition-group"; +import { logEvent } from "firebase/analytics"; +import type { ISetupStep } from "./SetupStep"; + +import { + useMediaQuery, + Paper, + Stepper, + Step, + StepButton, + MobileStepper, + IconButton, + Typography, + Stack, + DialogActions, +} from "@mui/material"; +import { alpha } from "@mui/material/styles"; +import LoadingButton from "@mui/lab/LoadingButton"; +import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; +import ChevronRightIcon from "@mui/icons-material/ChevronRight"; + +import BrandedBackground, { Wrapper } from "@src/assets/BrandedBackground"; +import Logo from "@src/assets/Logo"; +import ScrollableDialogContent from "@src/components/Modal/ScrollableDialogContent"; +import { SlideTransition } from "@src/components/Modal/SlideTransition"; + +import { analytics } from "@src/analytics"; + +const BASE_WIDTH = 1024; + +export interface ISetupLayoutProps { + steps: ISetupStep[]; + completion: Record; + setCompletion: React.Dispatch>>; + continueButtonLoading?: boolean | string; + onContinue?: ( + completion: Record + ) => Promise>; + logo?: React.ReactNode; +} + +export default function SetupLayout({ + steps, + completion, + setCompletion, + continueButtonLoading = false, + onContinue, + logo, +}: ISetupLayoutProps) { + const fullScreenHeight = use100vh() ?? 0; + const isMobile = useMediaQuery((theme: any) => theme.breakpoints.down("sm")); + + // Store current step’s ID to prevent confusion + const [stepId, setStepId] = useState("welcome"); + // Get current step object + const step = + steps.find((step) => step.id === (stepId || steps[0].id)) ?? steps[0]; + // Get current step index + const stepIndex = steps.indexOf(step); + const listedSteps = steps.filter((step) => step.layout !== "centered"); + + // Continue goes to the next incomplete step + const handleContinue = async () => { + let updatedCompletion = completion; + if (onContinue && step.layout !== "centered") + updatedCompletion = await onContinue(completion); + + let nextIncompleteStepIndex = stepIndex + 1; + while (updatedCompletion[steps[nextIncompleteStepIndex]?.id]) { + // console.log("iteration", steps[nextIncompleteStepIndex]?.id); + nextIncompleteStepIndex++; + } + + const nextStepId = steps[nextIncompleteStepIndex].id; + logEvent(analytics, "setup_step", { step: nextStepId }); + setStepId(nextStepId); + }; + + // Inject props into step.body + const body = createElement(step.body, { + completion, + setCompletion, + isComplete: completion[step.id], + setComplete: (value: boolean = true) => + setCompletion((c) => ({ ...c, [step.id]: value })), + }); + + return ( + + +
{ + e.preventDefault(); + try { + handleContinue(); + } catch (e: any) { + throw new Error(e.message); + } + return false; + }} + > + + alpha(theme.palette.background.paper, 0.75), + backdropFilter: "blur(20px) saturate(150%)", + + maxWidth: BASE_WIDTH, + width: (theme) => `calc(100vw - ${theme.spacing(2)})`, + height: (theme) => + `calc(${ + fullScreenHeight > 0 ? `${fullScreenHeight}px` : "100vh" + } - ${theme.spacing( + 2 + )} - env(safe-area-inset-top) - env(safe-area-inset-bottom))`, + resize: "both", + + p: 0, + "& > *, & > .MuiDialogContent-root": { px: { xs: 2, sm: 4 } }, + display: "flex", + flexDirection: "column", + + "& .MuiTypography-inherit, & .MuiDialogContent-root": { + typography: "body1", + }, + + "& p": { + maxWidth: "80ch", + }, + }} + > + {stepId === "welcome" ? null : !isMobile ? ( + + {listedSteps.map(({ id, shortTitle }, i) => ( + + setStepId(id)} + disabled={i > 0 && !completion[listedSteps[i - 1]?.id]} + sx={{ py: 2, my: -2, borderRadius: 1 }} + > + {shortTitle} + + + ))} + + ) : ( + setStepId(steps[stepIndex - 1].id)} + > + + + } + nextButton={ + setStepId(steps[stepIndex + 1].id)} + > + + + } + position="static" + sx={{ + background: "none", + p: 0, + "& .MuiMobileStepper-dot": { mx: 0.5 }, + }} + /> + )} + + {step.layout === "centered" ? ( + + + {stepId === "welcome" && ( + + + {logo || } + + + )} + + + + + {step.title} + + + + + + + + {step.description} + + + + + + + + {body} + + + + + + ) : ( + <> + + + + {step.title} + + + + + + + + + {step.description} + + + + + + + {body} + + + + + )} + + {step.layout !== "centered" && ( + + + ) + } + disabled={!completion[stepId]} + > + {typeof continueButtonLoading === "string" + ? continueButtonLoading + : "Continue"} + + + )} + +
+
+ ); +} diff --git a/src/components/Setup/SetupStep.d.ts b/src/components/Setup/SetupStep.d.ts new file mode 100644 index 00000000..a4e3037f --- /dev/null +++ b/src/components/Setup/SetupStep.d.ts @@ -0,0 +1,15 @@ +export interface ISetupStep { + id: string; + layout?: "centered"; + shortTitle: string; + title: React.ReactNode; + description?: React.ReactNode; + body: React.ComponentType; +} + +export interface ISetupStepBodyProps { + completion: Record; + setCompletion: React.Dispatch>>; + isComplete: boolean; + setComplete: (value: boolean = true) => void; +} diff --git a/src/components/Setup/SignInWithGoogle.tsx b/src/components/Setup/SignInWithGoogle.tsx new file mode 100644 index 00000000..e9dfe87f --- /dev/null +++ b/src/components/Setup/SignInWithGoogle.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { useAtom } from "jotai"; +import { signInWithPopup, GoogleAuthProvider, signOut } from "firebase/auth"; + +import { Typography } from "@mui/material"; +import LoadingButton, { LoadingButtonProps } from "@mui/lab/LoadingButton"; + +import { globalScope } from "@src/atoms/globalScope"; +import { firebaseAuthAtom } from "@src/sources/ProjectSourceFirebase"; + +const googleProvider = new GoogleAuthProvider(); +googleProvider.setCustomParameters({ prompt: "select_account" }); + +export interface ISignInWithGoogleProps extends Partial { + matchEmail?: string; +} + +export default function SignInWithGoogle({ + matchEmail, + ...props +}: ISignInWithGoogleProps) { + const [firebaseAuth] = useAtom(firebaseAuthAtom, globalScope); + const [status, setStatus] = useState<"IDLE" | "LOADING" | string>("IDLE"); + + const handleSignIn = async () => { + setStatus("LOADING"); + try { + const result = await signInWithPopup(firebaseAuth, googleProvider); + if (!result.user) throw new Error("Missing user"); + if ( + matchEmail && + matchEmail.toLowerCase() !== result.user.email?.toLowerCase() + ) + throw Error(`Account is not ${matchEmail}`); + + setStatus("IDLE"); + } catch (error: any) { + if (firebaseAuth.currentUser) signOut(firebaseAuth); + console.log(error); + setStatus(error.message); + } + }; + + return ( +
+ + } + onClick={handleSignIn} + loading={status === "LOADING"} + style={{ minHeight: 40 }} + sx={{ + minHeight: 40, + "& .MuiButton-startIcon": { mr: 3 }, + "&.MuiButton-outlined": { pr: 3 }, + }} + {...props} + > + Sign in with Google + + + {status !== "LOADING" && status !== "IDLE" && ( + + {status} + + )} +
+ ); +} diff --git a/src/components/Setup/Steps/StepFinish.tsx b/src/components/Setup/Steps/StepFinish.tsx new file mode 100644 index 00000000..d8c3ae24 --- /dev/null +++ b/src/components/Setup/Steps/StepFinish.tsx @@ -0,0 +1,102 @@ +import { useState, useEffect } from "react"; +import { useAtom } from "jotai"; +import { useSnackbar } from "notistack"; +import { Link } from "react-router-dom"; +import { logEvent } from "firebase/analytics"; +import { doc, updateDoc } from "firebase/firestore"; +import type { ISetupStep } from "@src/components/Setup/SetupStep"; + +import { + Typography, + Stack, + RadioGroup, + RadioGroupProps, + Radio, + Button, +} from "@mui/material"; +import ThumbUpIcon from "@mui/icons-material/ThumbUpAlt"; +import ThumbUpOffIcon from "@mui/icons-material/ThumbUpOffAlt"; +import ThumbDownIcon from "@mui/icons-material/ThumbDownAlt"; +import ThumbDownOffIcon from "@mui/icons-material/ThumbDownOffAlt"; + +import { analytics } from "@src/analytics"; +import { globalScope } from "@src/atoms/globalScope"; +import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; +import { routes } from "@src/constants/routes"; +import { SETTINGS } from "config/dbPaths"; + +export default { + id: "finish", + layout: "centered", + shortTitle: "Finish", + title: "You’re all set up!", + description: + "You can now continue to Rowy and create a table from your Firestore collections.", + body: StepFinish, +} as ISetupStep; + +function StepFinish() { + const [firebaseDb] = useAtom(firebaseDbAtom, globalScope); + const { enqueueSnackbar } = useSnackbar(); + + useEffect(() => { + updateDoc(doc(firebaseDb, SETTINGS), { setupCompleted: true }); + }, [firebaseDb]); + const [rating, setRating] = useState<"up" | "down" | undefined>(); + + const handleRate: RadioGroupProps["onChange"] = (e) => { + setRating(e.target.value as typeof rating); + logEvent(analytics, "setup_rating", { rating: e.target.value }); + enqueueSnackbar("Thanks for your feedback!"); + }; + + return ( + <> + + + How was your setup experience? + + + + } + icon={} + inputProps={{ "aria-label": "Thumbs up" }} + value="up" + color="secondary" + disabled={rating !== undefined} + /> + } + icon={} + inputProps={{ "aria-label": "Thumbs down" }} + value="down" + color="secondary" + disabled={rating !== undefined} + /> + + + + + + ); +} diff --git a/src/components/Setup/Steps/StepRules.tsx b/src/components/Setup/Steps/StepRules.tsx new file mode 100644 index 00000000..5430a952 --- /dev/null +++ b/src/components/Setup/Steps/StepRules.tsx @@ -0,0 +1,146 @@ +import { useState } from "react"; +import { useAtom } from "jotai"; +import { useSnackbar } from "notistack"; +import type { + ISetupStep, + ISetupStepBodyProps, +} from "@src/components/Setup/SetupStep"; + +import { + Typography, + FormControlLabel, + Checkbox, + Button, + Grid, +} from "@mui/material"; +import CopyIcon from "@src/assets/icons/Copy"; +import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; +import DoneIcon from "@mui/icons-material/Done"; + +import SetupItem from "@src/components/Setup/SetupItem"; + +import { globalScope } from "@src/atoms/globalScope"; +import { projectIdAtom } from "@src/atoms/project"; +import { CONFIG } from "@src/config/dbPaths"; +import { + RULES_START, + RULES_END, + REQUIRED_RULES, + ADMIN_RULES, + RULES_UTILS, +} from "@src/config/firestoreRules"; + +export default { + id: "rules", + shortTitle: "Firestore Rules", + title: "Set up Firestore rules", + description: ( + <> + Rowy configuration is stored in the {CONFIG} collection on + Firestore. Your users will need read access to this collection and admins + will need write access. + + ), + body: StepRules, +} as ISetupStep; + +function StepRules({ isComplete, setComplete }: ISetupStepBodyProps) { + const [projectId] = useAtom(projectIdAtom, globalScope); + const { enqueueSnackbar } = useSnackbar(); + + const [adminRule, setAdminRule] = useState(true); + + const rules = + RULES_START + + (adminRule ? ADMIN_RULES : "") + + REQUIRED_RULES + + RULES_UTILS + + RULES_END; + + return ( + <> + + setAdminRule(e.target.checked)} + /> + } + label="Allow admins to read and write all documents" + sx={{ "&&": { ml: -11 / 8, mb: -11 / 8 }, width: "100%" }} + /> + + $1` + ), + }} + /> + +
+ + + + + + + + + +
+
+ + } + onClick={() => setComplete()} + > + Mark as done + + ) + } + status={isComplete ? "complete" : "incomplete"} + /> + + ); +} diff --git a/src/components/Setup/Steps/StepStorageRules.tsx b/src/components/Setup/Steps/StepStorageRules.tsx new file mode 100644 index 00000000..4f31de3f --- /dev/null +++ b/src/components/Setup/Steps/StepStorageRules.tsx @@ -0,0 +1,113 @@ +import { useAtom } from "jotai"; +import { useSnackbar } from "notistack"; +import type { + ISetupStep, + ISetupStepBodyProps, +} from "@src/components/Setup/SetupStep"; + +import { Typography, Button, Grid } from "@mui/material"; +import CopyIcon from "@src/assets/icons/Copy"; +import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon"; +import DoneIcon from "@mui/icons-material/Done"; + +import SetupItem from "@src/components/Setup/SetupItem"; + +import { globalScope } from "@src/atoms/globalScope"; +import { projectIdAtom } from "@src/atoms/project"; +import { + RULES_START, + RULES_END, + REQUIRED_RULES, +} from "@src/config/storageRules"; + +export default { + id: "storageRules", + shortTitle: "Storage rules", + title: "Set up Firebase Storage rules", + description: + "Image and File fields store files in Firebase Storage. Your users will need read and write access.", + body: StepStorageRules, +} as ISetupStep; + +const rules = RULES_START + REQUIRED_RULES + RULES_END; + +function StepStorageRules({ isComplete, setComplete }: ISetupStepBodyProps) { + const [projectId] = useAtom(projectIdAtom, globalScope); + const { enqueueSnackbar } = useSnackbar(); + + return ( + <> + + $1` + ), + }} + /> + +
+ + + + + + + + + +
+
+ + } + onClick={() => setComplete()} + > + Mark as done + + ) + } + status={isComplete ? "complete" : "incomplete"} + /> + + ); +} diff --git a/src/components/Setup/Steps/StepWelcome.tsx b/src/components/Setup/Steps/StepWelcome.tsx new file mode 100644 index 00000000..6f049512 --- /dev/null +++ b/src/components/Setup/Steps/StepWelcome.tsx @@ -0,0 +1,96 @@ +import { useAtom } from "jotai"; +import type { + ISetupStep, + ISetupStepBodyProps, +} from "@src/components/Setup/SetupStep"; + +import { + FormControlLabel, + Checkbox, + Typography, + Link, + Button, +} from "@mui/material"; + +import { EXTERNAL_LINKS } from "@src/constants/externalLinks"; +import { globalScope } from "@src/atoms/globalScope"; +import { projectIdAtom } from "@src/atoms/project"; + +export default { + id: "welcome", + layout: "centered", + shortTitle: "Welcome", + title: "Welcome", + description: ( + <> + Get started with Rowy in just a few minutes. +
+
+ We have no access to your data and it always stays on your Firebase + project. + + ), + body: StepWelcome, +} as ISetupStep; + +function StepWelcome({ isComplete, setComplete }: ISetupStepBodyProps) { + const [projectId] = useAtom(projectIdAtom, globalScope); + + return ( + <> + + Project: {projectId} + + + setComplete(e.target.checked)} + /> + } + label={ + <> + I agree to the{" "} + + Terms and Conditions + {" "} + and{" "} + + Privacy Policy + + + } + sx={{ + pr: 1, + textAlign: "left", + alignItems: "flex-start", + p: 0, + m: 0, + }} + /> + + + + ); +} diff --git a/src/pages/Auth/ImpersonatorAuth.tsx b/src/pages/Auth/ImpersonatorAuth.tsx index d3f95449..f644a3b3 100644 --- a/src/pages/Auth/ImpersonatorAuth.tsx +++ b/src/pages/Auth/ImpersonatorAuth.tsx @@ -2,12 +2,12 @@ import { useEffect, useState } from "react"; import { useAtom } from "jotai"; import { useSnackbar } from "notistack"; -import { signOut } from "firebase/auth"; +import { getIdTokenResult, signOut } from "firebase/auth"; import { Typography, Button, TextField } from "@mui/material"; import AuthLayout from "@src/layouts/AuthLayout"; -// import FirebaseUi from "@src/components/Auth/FirebaseUi"; +import FirebaseUi from "@src/components/FirebaseUi"; import { globalScope } from "@src/atoms/globalScope"; import { firebaseAuthAtom } from "@src/sources/ProjectSourceFirebase"; @@ -16,6 +16,7 @@ import { runRoutes } from "@src/constants/runRoutes"; export default function ImpersonatorAuthPage() { const [firebaseAuth] = useAtom(firebaseAuthAtom, globalScope); const { enqueueSnackbar } = useSnackbar(); + // TODO: // const { rowyRun } = useProjectContext(); useEffect(() => { @@ -66,19 +67,20 @@ export default function ImpersonatorAuthPage() { } > - {/* {adminUser === undefined ? ( + {adminUser === undefined ? ( { - authUser.user.getIdTokenResult().then((result) => { - if (result.claims.roles?.includes("ADMIN")) { + getIdTokenResult(authUser.user).then((result) => { + const roles = result.claims.roles; + if (Array.isArray(roles) && roles.includes("ADMIN")) { setAdminUser(authUser.user); } else { enqueueSnackbar("Not an admin account", { variant: "error", }); - signOut(); + signOut(firebaseAuth); } }); @@ -104,7 +106,7 @@ export default function ImpersonatorAuthPage() { Sign in - )} */} + )} ); } diff --git a/src/pages/NotFound.tsx b/src/pages/NotFound.tsx index 7ef53432..2fda7f4f 100644 --- a/src/pages/NotFound.tsx +++ b/src/pages/NotFound.tsx @@ -5,9 +5,7 @@ import { Button } from "@mui/material"; import GoIcon from "@src/assets/icons/Go"; import HomeIcon from "@mui/icons-material/HomeOutlined"; -// import AuthLayout from "@src/components/Auth/AuthLayout"; -// import Navigation, { APP_BAR_HEIGHT } from "@src/components/Navigation"; -import EmptyState from "@src/components/EmptyState"; +import AuthLayout from "@src/layouts/AuthLayout"; import meta from "@root/package.json"; import routes from "@src/constants/routes"; @@ -17,27 +15,9 @@ import { currentUserAtom } from "@src/atoms/auth"; export default function NotFound() { const [currentUser] = useAtom(currentUserAtom, globalScope); - // if (currentUser === undefined) throw new Promise(() => {}); - - if (!currentUser) - return ( - // - - // - ); - return ( - //
}> - + {currentUser ? ( - } - fullScreen - // style={{ marginTop: -APP_BAR_HEIGHT }} - /> - // + ) : ( + + )} + ); } diff --git a/src/pages/Setup.tsx b/src/pages/Setup.tsx new file mode 100644 index 00000000..d8401efc --- /dev/null +++ b/src/pages/Setup.tsx @@ -0,0 +1,25 @@ +import { useState } from "react"; + +import SetupLayout from "@src/components/Setup/SetupLayout"; +import StepWelcome from "@src/components/Setup/Steps/StepWelcome"; +import StepRules from "@src/components/Setup/Steps/StepRules"; +import StepStorageRules from "@src/components/Setup/Steps/StepStorageRules"; +import StepFinish from "@src/components/Setup/Steps/StepFinish"; + +const steps = [StepWelcome, StepRules, StepStorageRules, StepFinish]; + +export default function SetupPage() { + const [completion, setCompletion] = useState>({ + welcome: false, + rules: false, + storageRules: false, + }); + + return ( + + ); +} diff --git a/src/types/custom.d.ts b/src/types/custom.d.ts new file mode 100644 index 00000000..63f9f2d0 --- /dev/null +++ b/src/types/custom.d.ts @@ -0,0 +1,12 @@ +declare module "*.css" { + const content: any; + export default content; +} +declare module "*.mp4" { + const content: any; + export default content; +} +declare module "!!raw-loader!*" { + const content: string; + export default content; +} diff --git a/src/types/react-element-scroll-hook.d.ts b/src/types/react-element-scroll-hook.d.ts new file mode 100644 index 00000000..7fb58bf7 --- /dev/null +++ b/src/types/react-element-scroll-hook.d.ts @@ -0,0 +1,4 @@ +declare module "react-element-scroll-hook" { + const hook: any; + export default hook; +} diff --git a/tsconfig.json b/tsconfig.json index 3dcbe2b5..e72702c5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,5 +25,5 @@ "jsx": "react-jsx", "baseUrl": "src" }, - "include": ["src"] + "include": ["src", "types"] } diff --git a/yarn.lock b/yarn.lock index 56dcd7bf..c2851d67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9485,6 +9485,11 @@ react-dom@^18.0.0: loose-envify "^1.1.0" scheduler "^0.21.0" +react-element-scroll-hook@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/react-element-scroll-hook/-/react-element-scroll-hook-1.1.0.tgz#4a472933f381667007ae249fb5790c39134bcae3" + integrity sha512-c9ITNZIb57HT93HbuK+7lVWNOt5ZmmPWguYrf2YbWpLT8AJMHlxXp0926jLxXnFEr0qr4yiyTA8tfkHx+H+scg== + react-error-boundary@^3.1.0, react-error-boundary@^3.1.4: version "3.1.4" resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0"