add Setup page, Modal

This commit is contained in:
Sidney Alcantara
2022-04-25 12:26:59 +10:00
parent 813bb07bde
commit d664a04ef9
23 changed files with 1425 additions and 50 deletions

View File

@@ -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",

View File

@@ -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() {
<Route path={routes.jwtAuth} element={<JwtAuthPage />} />
<Route
path={routes.impersonatorAuth}
element={<ImpersonatorAuthPage />}
element={
<RequireAuth>
<ImpersonatorAuthPage />
</RequireAuth>
}
/>
<Route path={routes.setup} element={<SetupPage />} />
<Route
path="/"
element={

15
src/analytics.ts Normal file
View File

@@ -0,0 +1,15 @@
import { initializeApp } from "firebase/app";
import { getAnalytics } from "firebase/analytics";
const firebaseConfig = {
apiKey: "AIzaSyArABiYGK7dZgwSk0pw_6vKbOt6U1ZRPpc",
authDomain: "rowy-service.firebaseapp.com",
projectId: "rowy-service",
storageBucket: "rowy-service.appspot.com",
messagingSenderId: "305614947641",
appId: "1:305614947641:web:cb10467e7c11c93d6e14e8",
measurementId: "G-0VWE25LFZJ",
};
const rowyServiceApp = initializeApp(firebaseConfig, "rowy-service");
export const analytics = getAnalytics(rowyServiceApp);

View File

@@ -1,19 +1,15 @@
import { Helmet } from "react-helmet-async";
import { use100vh } from "react-div-100vh";
import { useTheme, alpha } from "@mui/material/styles";
import { Box, BoxProps } from "@mui/material";
import { GlobalStyles, Box, BoxProps } from "@mui/material";
import { alpha } from "@mui/material/styles";
import bgPattern from "@src/assets/bg-pattern.svg";
import bgPatternDark from "@src/assets/bg-pattern-dark.svg";
export default function BrandedBackground() {
const theme = useTheme();
return (
<Helmet>
<style type="text/css">
{`
<GlobalStyles
styles={(theme) => `
body {
background-size: 100%;
background-image: ${
@@ -44,8 +40,7 @@ export default function BrandedBackground() {
mix-blend-mode: overlay;
}
`}
</style>
</Helmet>
/>
);
}

View File

@@ -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<Omit<DialogProps, "title">> {
onClose: (setOpen: React.Dispatch<React.SetStateAction<boolean>>) => void;
disableBackdropClick?: boolean;
disableEscapeKeyDown?: boolean;
"aria-labelledby": DialogProps["aria-labelledby"];
header?: React.ReactNode;
children?: React.ReactNode;
footer?: React.ReactNode;
hideCloseButton?: boolean;
ScrollableDialogContentProps?: Partial<IScrollableDialogContentProps>;
}
export default function FullScreenModal({
onClose,
disableBackdropClick,
disableEscapeKeyDown,
header,
children,
footer,
hideCloseButton,
ScrollableDialogContentProps,
...props
}: IFullScreenModalProps) {
const [open, setOpen] = useState(true);
const handleClose: NonNullable<DialogProps["onClose"]> = (_, 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 (
<Dialog
fullScreen
open={open}
onClose={handleClose}
TransitionComponent={Slide}
TransitionProps={{ direction: "up" } as any}
{...props}
>
<Container
sx={{
display: "flex",
flexDirection: "column",
height: "100%",
pt: { xs: "var(--dialog-spacing)", xl: 6 },
}}
>
{!hideCloseButton && (
<IconButton
onClick={handleClose as any}
aria-label="Close"
sx={{
position: "absolute",
top: (theme) => 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"
>
<CloseIcon />
</IconButton>
)}
{header}
<ScrollableDialogContent
style={{ padding: 0 }}
{...ScrollableDialogContentProps}
>
{children}
</ScrollableDialogContent>
{footer}
</Container>
</Dialog>
);
}

View File

@@ -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 <DialogContent {...props} ref={setRef} />;
});
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 && (
<Divider
style={{
visibility: scrollInfo.y.percentage > 0 ? "visible" : "hidden",
}}
sx={[
...(Array.isArray(dividerSx) ? dividerSx : [dividerSx]),
...(Array.isArray(topDividerSx) ? topDividerSx : [topDividerSx]),
]}
/>
)}
<MemoizedDialogContent {...props} setRef={setRef} />
{!disableBottomDivider && scrollInfo.y.percentage !== null && (
<Divider
style={{
visibility: scrollInfo.y.percentage < 1 ? "visible" : "hidden",
}}
sx={[
...(Array.isArray(dividerSx) ? dividerSx : [dividerSx]),
...(Array.isArray(bottomDividerSx)
? bottomDividerSx
: [bottomDividerSx]),
]}
/>
)}
</>
);
}

View File

@@ -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<TransitionProps, React.ReactText> & React.RefAttributes<any>
> = forwardRef(
({ children, ...props }: TransitionProps, ref: React.Ref<any>) => {
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 (
<Transition
appear
timeout={{ enter: 0, exit: theme.transitions.duration.leavingScreen }}
{...props}
>
{(state) =>
cloneElement(children as any, {
style: { ...defaultStyle, ...transitionStyles[state] },
ref,
})
}
</Transition>
);
}
);
export default SlideTransition;
export const SlideTransitionMui = forwardRef(function Transition(
props: MuiTransitionProps & { children?: React.ReactElement<any, any> },
ref: React.Ref<unknown>
) {
return <SlideTransition ref={ref} {...props} />;
});

View File

@@ -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<Omit<DialogProps, "title">> {
onClose: (setOpen: React.Dispatch<React.SetStateAction<boolean>>) => void;
disableBackdropClick?: boolean;
disableEscapeKeyDown?: boolean;
title: React.ReactNode;
header?: React.ReactNode;
footer?: React.ReactNode;
children?: React.ReactNode;
body?: React.ReactNode;
actions?: {
primary?: Partial<LoadingButtonProps>;
secondary?: Partial<ButtonProps>;
};
hideCloseButton?: boolean;
fullHeight?: boolean;
ScrollableDialogContentProps?: Partial<IScrollableDialogContentProps>;
}
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<DialogProps["onClose"]> = (_, 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 (
<Dialog
open={open}
onClose={handleClose}
fullWidth
fullScreen={isMobile}
TransitionComponent={isMobile ? Slide : SlideTransitionMui}
TransitionProps={isMobile ? ({ direction: "up" } as any) : undefined}
aria-labelledby="modal-title"
{...props}
sx={
fullHeight
? {
...props.sx,
"& .MuiDialog-paper": {
height: "100%",
...(props.sx as any)?.["& .MuiDialog-paper"],
},
}
: props.sx
}
>
<Stack direction="row" alignItems="flex-start">
<DialogTitle
id="modal-title"
style={{ flexGrow: 1, userSelect: "none" }}
>
{title}
</DialogTitle>
{!hideCloseButton && (
<IconButton
onClick={handleClose as any}
aria-label="Close"
sx={{
m: { xs: 1, sm: 1.5 },
ml: { xs: -1, sm: -1 },
bgcolor: emphasizeCloseButton ? "error.main" : undefined,
color: emphasizeCloseButton ? "error.contrastText" : undefined,
"&:hover": emphasizeCloseButton
? { bgcolor: "error.dark" }
: undefined,
}}
className="dialog-close"
>
<CloseIcon />
</IconButton>
)}
</Stack>
{header}
<ScrollableDialogContent {...ScrollableDialogContentProps}>
{children || body}
</ScrollableDialogContent>
{footer}
{actions && (
<DialogActions>
{actions.secondary && <Button {...actions.secondary} />}
{actions.primary && (
<LoadingButton
variant="contained"
color="primary"
{...actions.primary}
/>
)}
</DialogActions>
)}
</Dialog>
);
}

View File

@@ -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 (
<Stack
direction="row"
spacing={2}
alignItems="flex-start"
aria-busy={status === "loading"}
aria-describedby={status === "loading" ? "progress" : undefined}
style={{ width: "100%" }}
>
{status === "complete" ? (
<CheckIcon aria-label="Item complete" color="action" />
) : status === "loading" ? (
<CircularProgressOptical id="progress" size={20} sx={{ m: 0.25 }} />
) : (
<ArrowIcon aria-label="Item" color="primary" />
)}
<Stack
spacing={2}
alignItems="flex-start"
style={{ flexGrow: 1, minWidth: 0 }}
>
<Typography
variant="inherit"
sx={{ "& .MuiButton-root": { mt: -0.5 } }}
>
{title}
</Typography>
{children}
</Stack>
</Stack>
);
}

View File

@@ -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<string, boolean>;
setCompletion: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
continueButtonLoading?: boolean | string;
onContinue?: (
completion: Record<string, boolean>
) => Promise<Record<string, boolean>>;
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 steps 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 (
<Wrapper>
<BrandedBackground />
<form
onSubmit={(e) => {
e.preventDefault();
try {
handleContinue();
} catch (e: any) {
throw new Error(e.message);
}
return false;
}}
>
<Paper
component="main"
elevation={4}
sx={{
backgroundColor: (theme) =>
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 ? (
<Stepper
activeStep={stepIndex - 1}
nonLinear
sx={{
mt: 2.5,
mb: 3,
"& .MuiStep-root:first-child": { pl: 0 },
"& .MuiStep-root:last-child": { pr: 0 },
userSelect: "none",
}}
>
{listedSteps.map(({ id, shortTitle }, i) => (
<Step key={id} completed={completion[id]}>
<StepButton
onClick={() => setStepId(id)}
disabled={i > 0 && !completion[listedSteps[i - 1]?.id]}
sx={{ py: 2, my: -2, borderRadius: 1 }}
>
{shortTitle}
</StepButton>
</Step>
))}
</Stepper>
) : (
<MobileStepper
variant="dots"
steps={listedSteps.length}
activeStep={stepIndex - 1}
backButton={
<IconButton
aria-label="Previous step"
disabled={stepIndex === 0}
onClick={() => setStepId(steps[stepIndex - 1].id)}
>
<ChevronLeftIcon />
</IconButton>
}
nextButton={
<IconButton
aria-label="Next step"
disabled={!completion[stepId]}
onClick={() => setStepId(steps[stepIndex + 1].id)}
>
<ChevronRightIcon />
</IconButton>
}
position="static"
sx={{
background: "none",
p: 0,
"& .MuiMobileStepper-dot": { mx: 0.5 },
}}
/>
)}
{step.layout === "centered" ? (
<ScrollableDialogContent disableTopDivider disableBottomDivider>
<Stack
alignItems="center"
justifyContent="center"
spacing={3}
sx={{
minHeight: "100%",
maxWidth: 440,
margin: "0 auto",
textAlign: "center",
py: 3,
}}
>
{stepId === "welcome" && (
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={25}>
{logo || <Logo size={2} />}
</SlideTransition>
</SwitchTransition>
)}
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={50}>
<Typography
variant="h4"
component="h1"
sx={{ mb: 1, typography: { xs: "h5", md: "h4" } }}
>
{step.title}
</Typography>
</SlideTransition>
</SwitchTransition>
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={100}>
<Typography variant="inherit">
{step.description}
</Typography>
</SlideTransition>
</SwitchTransition>
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={150}>
<Stack spacing={4} alignItems="center">
{body}
</Stack>
</SlideTransition>
</SwitchTransition>
</Stack>
</ScrollableDialogContent>
) : (
<>
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={50}>
<Typography
variant="h4"
component="h1"
sx={{ mb: 1, typography: { xs: "h5", md: "h4" } }}
>
{step.title}
</Typography>
</SlideTransition>
</SwitchTransition>
<ScrollableDialogContent
disableTopDivider={step.layout === "centered"}
sx={{ overflowX: "auto", pb: 3 }}
>
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={100}>
<Typography variant="inherit">
{step.description}
</Typography>
</SlideTransition>
</SwitchTransition>
<SwitchTransition mode="out-in">
<SlideTransition key={stepId} appear timeout={150}>
<Stack spacing={4}>{body}</Stack>
</SlideTransition>
</SwitchTransition>
</ScrollableDialogContent>
</>
)}
{step.layout !== "centered" && (
<DialogActions>
<LoadingButton
variant="contained"
color="primary"
size="large"
type="submit"
loading={Boolean(continueButtonLoading)}
loadingPosition={
typeof continueButtonLoading === "string" ? "start" : "center"
}
startIcon={
typeof continueButtonLoading === "string" && (
<div style={{ width: 24 }} />
)
}
disabled={!completion[stepId]}
>
{typeof continueButtonLoading === "string"
? continueButtonLoading
: "Continue"}
</LoadingButton>
</DialogActions>
)}
</Paper>
</form>
</Wrapper>
);
}

15
src/components/Setup/SetupStep.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
export interface ISetupStep {
id: string;
layout?: "centered";
shortTitle: string;
title: React.ReactNode;
description?: React.ReactNode;
body: React.ComponentType<ISetupStepBodyProps>;
}
export interface ISetupStepBodyProps {
completion: Record<string, boolean>;
setCompletion: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
isComplete: boolean;
setComplete: (value: boolean = true) => void;
}

View File

@@ -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<LoadingButtonProps> {
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 (
<div>
<LoadingButton
startIcon={
<img
src="https://www.gstatic.com/firebasejs/ui/2.0.0/images/auth/google.svg"
alt="Google logo"
width={18}
height={18}
style={{
margin: (24 - 18) / 2,
filter: props.disabled ? "grayscale(1)" : "",
}}
/>
}
onClick={handleSignIn}
loading={status === "LOADING"}
style={{ minHeight: 40 }}
sx={{
minHeight: 40,
"& .MuiButton-startIcon": { mr: 3 },
"&.MuiButton-outlined": { pr: 3 },
}}
{...props}
>
Sign in with Google
</LoadingButton>
{status !== "LOADING" && status !== "IDLE" && (
<Typography
variant="caption"
color="error"
display="block"
sx={{ m: 0.5 }}
>
{status}
</Typography>
)}
</div>
);
}

View File

@@ -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: "Youre 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 (
<>
<Stack
component="fieldset"
spacing={1}
direction="row"
alignItems="center"
justifyContent="center"
sx={{ appearance: "none", p: 0, m: 0, border: "none" }}
>
<Typography variant="body1" component="legend">
How was your setup experience?
</Typography>
<RadioGroup
style={{ flexDirection: "row" }}
value={rating}
onChange={handleRate}
>
<Radio
checkedIcon={<ThumbUpIcon />}
icon={<ThumbUpOffIcon />}
inputProps={{ "aria-label": "Thumbs up" }}
value="up"
color="secondary"
disabled={rating !== undefined}
/>
<Radio
checkedIcon={<ThumbDownIcon />}
icon={<ThumbDownOffIcon />}
inputProps={{ "aria-label": "Thumbs down" }}
value="down"
color="secondary"
disabled={rating !== undefined}
/>
</RadioGroup>
</Stack>
<Button
variant="contained"
color="primary"
size="large"
component={Link}
to={routes.auth}
>
Sign in to your Rowy project
</Button>
</>
);
}

View File

@@ -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 <code>{CONFIG}</code> 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 (
<>
<SetupItem
status="incomplete"
title="Add the following rules to enable access to Rowy configuration:"
>
<FormControlLabel
control={
<Checkbox
checked={adminRule}
onChange={(e) => setAdminRule(e.target.checked)}
/>
}
label="Allow admins to read and write all documents"
sx={{ "&&": { ml: -11 / 8, mb: -11 / 8 }, width: "100%" }}
/>
<Typography
component="pre"
sx={{
width: "100%",
height: 400,
resize: "both",
overflow: "auto",
"& .comment": { color: "info.dark" },
}}
dangerouslySetInnerHTML={{
__html: rules.replace(
/(\/\/.*$)/gm,
`<span class="comment">$1</span>`
),
}}
/>
<div>
<Grid container spacing={1}>
<Grid item>
<Button
startIcon={<CopyIcon />}
onClick={() => {
navigator.clipboard.writeText(rules);
enqueueSnackbar("Copied to clipboard");
}}
>
Copy to clipboard
</Button>
</Grid>
<Grid item>
<Button
variant="contained"
color="primary"
href={`https://console.firebase.google.com/project/${
projectId || "_"
}/firestore/rules`}
target="_blank"
rel="noopener noreferrer"
>
Set up in Firebase Console
<InlineOpenInNewIcon />
</Button>
</Grid>
</Grid>
</div>
</SetupItem>
<SetupItem
title={
isComplete ? (
"Marked as done"
) : (
<Button
variant="contained"
color="primary"
startIcon={<DoneIcon />}
onClick={() => setComplete()}
>
Mark as done
</Button>
)
}
status={isComplete ? "complete" : "incomplete"}
/>
</>
);
}

View File

@@ -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 (
<>
<SetupItem
status="incomplete"
title="Add the following rules to allow users to access Firebase Storage:"
>
<Typography
component="pre"
sx={{
width: "100%",
height: 250,
resize: "both",
overflow: "auto",
"& .comment": { color: "info.dark" },
}}
dangerouslySetInnerHTML={{
__html: rules.replace(
/(\/\/.*$)/gm,
`<span class="comment">$1</span>`
),
}}
/>
<div>
<Grid container spacing={1}>
<Grid item>
<Button
startIcon={<CopyIcon />}
onClick={() => {
navigator.clipboard.writeText(rules);
enqueueSnackbar("Copied to clipboard");
}}
>
Copy to clipboard
</Button>
</Grid>
<Grid item>
<Button
variant="contained"
color="primary"
href={`https://console.firebase.google.com/project/${
projectId || "_"
}/firestore/rules`}
target="_blank"
rel="noopener noreferrer"
>
Set up in Firebase Console
<InlineOpenInNewIcon />
</Button>
</Grid>
</Grid>
</div>
</SetupItem>
<SetupItem
title={
isComplete ? (
"Marked as done"
) : (
<Button
variant="contained"
color="primary"
startIcon={<DoneIcon />}
onClick={() => setComplete()}
>
Mark as done
</Button>
)
}
status={isComplete ? "complete" : "incomplete"}
/>
</>
);
}

View File

@@ -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.
<br />
<br />
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 (
<>
<Typography variant="inherit">
Project: <code>{projectId}</code>
</Typography>
<FormControlLabel
control={
<Checkbox
checked={isComplete}
onChange={(e) => setComplete(e.target.checked)}
/>
}
label={
<>
I agree to the{" "}
<Link
href={EXTERNAL_LINKS.terms}
target="_blank"
rel="noopener noreferrer"
variant="body2"
color="text.primary"
>
Terms and Conditions
</Link>{" "}
and{" "}
<Link
href={EXTERNAL_LINKS.privacy}
target="_blank"
rel="noopener noreferrer"
variant="body2"
color="text.primary"
>
Privacy Policy
</Link>
</>
}
sx={{
pr: 1,
textAlign: "left",
alignItems: "flex-start",
p: 0,
m: 0,
}}
/>
<Button
variant="contained"
color="primary"
size="large"
disabled={!isComplete}
type="submit"
>
Get started
</Button>
</>
);
}

View File

@@ -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 ? (
<FirebaseUi
uiConfig={{
callbacks: {
signInSuccessWithAuthResult: (authUser) => {
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
</Button>
</>
)} */}
)}
</AuthLayout>
);
}

View File

@@ -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 (
// <AuthLayout title="Page not found">
<Button
variant="outlined"
sx={{ mt: 3 }}
href={meta.homepage}
endIcon={<GoIcon style={{ margin: "0 -0.33em" }} />}
>
{meta.homepage.split("//")[1].replace(/\//g, "")}
</Button>
// </AuthLayout>
);
return (
// <Navigation title="Page not found" titleComponent={() => <div />}>
<EmptyState
message="Page not found"
description={
<AuthLayout title="Page not found" hideLinks={Boolean(currentUser)}>
{currentUser ? (
<Button
variant="outlined"
sx={{ mt: 3 }}
@@ -47,10 +27,16 @@ export default function NotFound() {
>
Home
</Button>
}
fullScreen
// style={{ marginTop: -APP_BAR_HEIGHT }}
/>
// </Navigation>
) : (
<Button
variant="outlined"
sx={{ mt: 3 }}
href={meta.homepage}
endIcon={<GoIcon style={{ margin: "0 -0.33em" }} />}
>
{meta.homepage.split("//")[1].replace(/\//g, "")}
</Button>
)}
</AuthLayout>
);
}

25
src/pages/Setup.tsx Normal file
View File

@@ -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<Record<string, boolean>>({
welcome: false,
rules: false,
storageRules: false,
});
return (
<SetupLayout
steps={steps}
completion={completion}
setCompletion={setCompletion}
/>
);
}

12
src/types/custom.d.ts vendored Normal file
View File

@@ -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;
}

View File

@@ -0,0 +1,4 @@
declare module "react-element-scroll-hook" {
const hook: any;
export default hook;
}

View File

@@ -25,5 +25,5 @@
"jsx": "react-jsx",
"baseUrl": "src"
},
"include": ["src"]
"include": ["src", "types"]
}

View File

@@ -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"