mirror of
https://github.com/rowyio/rowy.git
synced 2025-12-29 00:16:39 +01:00
add Setup page, Modal
This commit is contained in:
@@ -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",
|
||||
|
||||
17
src/App.tsx
17
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() {
|
||||
<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
15
src/analytics.ts
Normal 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);
|
||||
@@ -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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
110
src/components/Modal/FullScreenModal.tsx
Normal file
110
src/components/Modal/FullScreenModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/Modal/ScrollableDialogContent.tsx
Normal file
67
src/components/Modal/ScrollableDialogContent.tsx
Normal 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]),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
77
src/components/Modal/SlideTransition.tsx
Normal file
77
src/components/Modal/SlideTransition.tsx
Normal 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} />;
|
||||
});
|
||||
155
src/components/Modal/index.tsx
Normal file
155
src/components/Modal/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
src/components/Setup/SetupItem.tsx
Normal file
50
src/components/Setup/SetupItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
306
src/components/Setup/SetupLayout.tsx
Normal file
306
src/components/Setup/SetupLayout.tsx
Normal 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 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 (
|
||||
<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
15
src/components/Setup/SetupStep.d.ts
vendored
Normal 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;
|
||||
}
|
||||
84
src/components/Setup/SignInWithGoogle.tsx
Normal file
84
src/components/Setup/SignInWithGoogle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
102
src/components/Setup/Steps/StepFinish.tsx
Normal file
102
src/components/Setup/Steps/StepFinish.tsx
Normal 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: "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 (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
146
src/components/Setup/Steps/StepRules.tsx
Normal file
146
src/components/Setup/Steps/StepRules.tsx
Normal 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"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
113
src/components/Setup/Steps/StepStorageRules.tsx
Normal file
113
src/components/Setup/Steps/StepStorageRules.tsx
Normal 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"}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
96
src/components/Setup/Steps/StepWelcome.tsx
Normal file
96
src/components/Setup/Steps/StepWelcome.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
25
src/pages/Setup.tsx
Normal 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
12
src/types/custom.d.ts
vendored
Normal 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;
|
||||
}
|
||||
4
src/types/react-element-scroll-hook.d.ts
vendored
Normal file
4
src/types/react-element-scroll-hook.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module "react-element-scroll-hook" {
|
||||
const hook: any;
|
||||
export default hook;
|
||||
}
|
||||
@@ -25,5 +25,5 @@
|
||||
"jsx": "react-jsx",
|
||||
"baseUrl": "src"
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src", "types"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user