summaryrefslogtreecommitdiff
path: root/client/src
diff options
context:
space:
mode:
authorMichael Hunteman <michael@huntm.net>2024-05-17 15:20:30 -0700
committerMichael Hunteman <michael@huntm.net>2024-05-17 15:20:30 -0700
commit7103019890960e793deefb64987a09b33be60b42 (patch)
treec1c9402aa250c68b2cbe13d62598232bbf20b1e2 /client/src
parentfc5c111bcfe296bec82e1cf9fdb88fc80fb24f89 (diff)
Add golang server
Diffstat (limited to 'client/src')
-rw-r--r--client/src/App.tsx16
-rw-r--r--client/src/ThemeContextProvider.tsx57
-rw-r--r--client/src/apiSlice.ts67
-rw-r--r--client/src/components/Admin.tsx31
-rw-r--r--client/src/components/Desktop.tsx29
-rw-r--r--client/src/components/Home.tsx73
-rw-r--r--client/src/components/Mobile.tsx55
-rw-r--r--client/src/components/NavBar.tsx29
-rw-r--r--client/src/components/Registry.tsx11
-rw-r--r--client/src/components/Rsvp.tsx29
-rw-r--r--client/src/components/RsvpForm.tsx242
-rw-r--r--client/src/components/Schedule.tsx107
-rw-r--r--client/src/components/active.css7
-rw-r--r--client/src/features/auth/GuestLogin.tsx79
-rw-r--r--client/src/features/auth/authSlice.ts26
-rw-r--r--client/src/main.css4
-rw-r--r--client/src/main.tsx69
-rw-r--r--client/src/mocks/browser.ts4
-rw-r--r--client/src/mocks/handlers.ts30
-rw-r--r--client/src/pages.ts8
-rw-r--r--client/src/store.ts16
-rw-r--r--client/src/vite-env.d.ts1
22 files changed, 990 insertions, 0 deletions
diff --git a/client/src/App.tsx b/client/src/App.tsx
new file mode 100644
index 0000000..27fc180
--- /dev/null
+++ b/client/src/App.tsx
@@ -0,0 +1,16 @@
+import React from 'react';
+import { Outlet } from 'react-router-dom';
+import CssBaseline from '@mui/material/CssBaseline';
+import NavBar from './components/NavBar';
+
+function App() {
+ return (
+ <>
+ <CssBaseline />
+ <NavBar />
+ <Outlet />
+ </>
+ );
+}
+
+export default App;
diff --git a/client/src/ThemeContextProvider.tsx b/client/src/ThemeContextProvider.tsx
new file mode 100644
index 0000000..6ae1430
--- /dev/null
+++ b/client/src/ThemeContextProvider.tsx
@@ -0,0 +1,57 @@
+import React from 'react';
+import { ReactNode, createContext, useMemo, useState } from 'react';
+import { ThemeProvider, createTheme } from '@mui/material/styles';
+import useMediaQuery from '@mui/material/useMediaQuery';
+import type { PaletteMode } from '@mui/material';
+
+type ThemeContextType = {
+ toggleColorMode: () => void;
+};
+
+type ThemeProviderProps = {
+ children: ReactNode;
+};
+
+export const ThemeContext = createContext<ThemeContextType>({
+ toggleColorMode: () => {},
+});
+
+function ThemeContextProvider({ children }: ThemeProviderProps) {
+ const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)');
+ const [mode, setMode] = useState<'light' | 'dark'>(
+ prefersDarkMode ? 'dark' : 'light'
+ );
+
+ const toggleColorMode = () => {
+ setMode((prevMode: PaletteMode) =>
+ prevMode === 'light' ? 'dark' : 'light'
+ );
+ };
+
+ const getDesignTokens = (mode: PaletteMode) => ({
+ palette: {
+ mode,
+ ...(mode === 'light'
+ ? {
+ primary: {
+ main: '#007bff',
+ },
+ }
+ : {
+ primary: {
+ main: '#78bef8',
+ },
+ }),
+ },
+ });
+
+ const theme = useMemo(() => createTheme(getDesignTokens(mode)), [mode]);
+
+ return (
+ <ThemeContext.Provider value={{ toggleColorMode }}>
+ <ThemeProvider theme={theme}>{children}</ThemeProvider>
+ </ThemeContext.Provider>
+ );
+}
+
+export default ThemeContextProvider;
diff --git a/client/src/apiSlice.ts b/client/src/apiSlice.ts
new file mode 100644
index 0000000..5d987f9
--- /dev/null
+++ b/client/src/apiSlice.ts
@@ -0,0 +1,67 @@
+import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
+import type { RootState } from './store';
+
+export interface LoginRequest {
+ firstName: string;
+ lastName: string;
+}
+
+export interface LoginResponse {
+ guest: Guest;
+ token: string;
+}
+
+export interface Guest {
+ id: number;
+ firstName: string;
+ lastName: string;
+ attendance: string;
+ email: string;
+ message: string;
+ partySize: number;
+ partyList: Array<PartyGuest>;
+}
+
+export interface PartyGuest {
+ firstName: string;
+ lastName: string;
+}
+
+export const apiSlice = createApi({
+ reducerPath: 'api',
+ baseQuery: fetchBaseQuery({
+ baseUrl: '/',
+ prepareHeaders: (headers, { getState }) => {
+ const token = (getState() as RootState).auth.token;
+ if (token) {
+ headers.set('authorization', `Bearer ${token}`);
+ }
+ return headers;
+ },
+ }),
+ tagTypes: ['Guests'],
+ endpoints: (builder) => ({
+ getGuests: builder.query<void, void>({
+ query: () => '/guests',
+ providesTags: ['Guests'],
+ }),
+ updateGuest: builder.mutation<Guest, Guest>({
+ query: (guest) => ({
+ url: `/guests/${guest?.id}`,
+ method: 'PATCH',
+ body: guest,
+ providesTags: ['Guests'],
+ }),
+ }),
+ login: builder.mutation<LoginResponse, LoginRequest>({
+ query: (credentials) => ({
+ url: '/guest-login',
+ method: 'POST',
+ body: credentials,
+ }),
+ }),
+ }),
+});
+
+export const { useGetGuestsQuery, useUpdateGuestMutation, useLoginMutation } =
+ apiSlice;
diff --git a/client/src/components/Admin.tsx b/client/src/components/Admin.tsx
new file mode 100644
index 0000000..1c941a5
--- /dev/null
+++ b/client/src/components/Admin.tsx
@@ -0,0 +1,31 @@
+import React from 'react';
+import { useGetGuestsQuery } from '../apiSlice';
+
+function Admin() {
+ const {
+ data: guests,
+ isLoading,
+ isSuccess,
+ isError,
+ error,
+ } = useGetGuestsQuery();
+
+ let content;
+
+ if (isLoading) {
+ content = <p>Loading...</p>;
+ } else if (isSuccess) {
+ content = JSON.stringify(guests);
+ } else if (isError) {
+ content = <>{error.toString()}</>;
+ }
+
+ return (
+ <>
+ <p>Admin</p>
+ {content}
+ </>
+ );
+}
+
+export default Admin;
diff --git a/client/src/components/Desktop.tsx b/client/src/components/Desktop.tsx
new file mode 100644
index 0000000..13de4ee
--- /dev/null
+++ b/client/src/components/Desktop.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { useContext } from 'react';
+import { Link } from 'react-router-dom';
+import { Button, IconButton } from '@mui/material';
+import DarkModeIcon from '@mui/icons-material/DarkMode';
+import LightModeIcon from '@mui/icons-material/LightMode';
+import { useTheme } from '@mui/material/styles';
+import { ThemeContext } from '../ThemeContextProvider';
+import pages from '../pages';
+
+function Desktop() {
+ const theme = useTheme();
+ const { toggleColorMode } = useContext(ThemeContext);
+
+ return (
+ <div style={{ marginLeft: 'auto' }}>
+ {pages.map((page) => (
+ <Button color="inherit" component={Link} to={page?.to} key={page?.name}>
+ {page?.name}
+ </Button>
+ ))}
+ <IconButton color="inherit" onClick={toggleColorMode}>
+ {theme.palette.mode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />}
+ </IconButton>
+ </div>
+ );
+}
+
+export default Desktop;
diff --git a/client/src/components/Home.tsx b/client/src/components/Home.tsx
new file mode 100644
index 0000000..839667a
--- /dev/null
+++ b/client/src/components/Home.tsx
@@ -0,0 +1,73 @@
+import React from 'react';
+import { useEffect, useRef, useState } from 'react';
+import './active.css';
+
+function Home() {
+ const [index, setIndex] = useState(0);
+ const colors = ['#FF0000', '#00FF00', '#0000FF'];
+ const timeout = useRef(0);
+
+ useEffect(() => {
+ resetTimeout();
+ timeout.current = window.setTimeout(
+ () =>
+ setIndex((prevIndex) =>
+ prevIndex === colors.length - 1 ? 0 : prevIndex + 1
+ ),
+ 2500
+ );
+
+ return () => {
+ resetTimeout();
+ };
+ }, [index]);
+
+ const resetTimeout = () => {
+ if (timeout.current) {
+ clearTimeout(timeout.current);
+ }
+ };
+
+ return (
+ <div style={{ margin: 'auto', overflow: 'hidden' }}>
+ <div
+ style={{
+ whiteSpace: 'nowrap',
+ transform: `translateX(${-index * 100}%)`,
+ transition: 'ease 1000ms',
+ }}
+ >
+ {colors.map((backgroundColor, colorIndex) => (
+ <div
+ key={colorIndex}
+ style={{
+ display: 'inline-block',
+ backgroundColor,
+ height: '80vh',
+ width: '100%',
+ }}
+ />
+ ))}
+ </div>
+ <div style={{ display: 'flex', justifyContent: 'center' }}>
+ {colors.map((_, colorIndex) => (
+ <div
+ key={colorIndex}
+ style={{
+ height: '0.75rem',
+ width: '0.75rem',
+ borderRadius: '50%',
+ margin: '0.75rem',
+ }}
+ className={colorIndex === index ? 'active' : 'inactive'}
+ onClick={() => {
+ setIndex(colorIndex);
+ }}
+ />
+ ))}
+ </div>
+ </div>
+ );
+}
+
+export default Home;
diff --git a/client/src/components/Mobile.tsx b/client/src/components/Mobile.tsx
new file mode 100644
index 0000000..2e7b15c
--- /dev/null
+++ b/client/src/components/Mobile.tsx
@@ -0,0 +1,55 @@
+import React from 'react';
+import { useContext, useState } from 'react';
+import { Link } from 'react-router-dom';
+import { Button, IconButton, Menu, MenuItem } from '@mui/material';
+import DarkModeIcon from '@mui/icons-material/DarkMode';
+import LightModeIcon from '@mui/icons-material/LightMode';
+import { useTheme } from '@mui/material/styles';
+import { ThemeContext } from '../ThemeContextProvider';
+import MenuIcon from '@mui/icons-material/Menu';
+import pages from '../pages';
+
+function Mobile() {
+ const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
+ const theme = useTheme();
+ const { toggleColorMode } = useContext(ThemeContext);
+
+ const handleOpenNavMenu = (event: React.MouseEvent<HTMLElement>) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleCloseNavMenu = () => {
+ setAnchorEl(null);
+ };
+
+ return (
+ <>
+ <IconButton color="inherit" onClick={toggleColorMode}>
+ {theme.palette.mode === 'dark' ? <LightModeIcon /> : <DarkModeIcon />}
+ </IconButton>
+ <IconButton
+ color="inherit"
+ sx={{ ml: 'auto' }}
+ onClick={handleOpenNavMenu}
+ >
+ <MenuIcon />
+ </IconButton>
+ <Menu anchorEl={anchorEl} open={!!anchorEl} onClose={handleCloseNavMenu}>
+ {pages.map((page) => (
+ <MenuItem key={page.name} onClick={handleCloseNavMenu}>
+ <Button
+ color="inherit"
+ component={Link}
+ to={page?.to}
+ key={page?.name}
+ >
+ {page?.name}
+ </Button>
+ </MenuItem>
+ ))}
+ </Menu>
+ </>
+ );
+}
+
+export default Mobile;
diff --git a/client/src/components/NavBar.tsx b/client/src/components/NavBar.tsx
new file mode 100644
index 0000000..52e8e6c
--- /dev/null
+++ b/client/src/components/NavBar.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import { AppBar, Toolbar, Typography } from '@mui/material';
+import useMediaQuery from '@mui/material/useMediaQuery';
+import Desktop from './Desktop';
+import Mobile from './Mobile';
+
+function NavBar() {
+ const isMobile = useMediaQuery('(max-width: 768px)');
+
+ return (
+ <AppBar position="relative">
+ <Toolbar>
+ <Typography
+ variant="h5"
+ component={Link}
+ to="/"
+ color="inherit"
+ sx={{ textDecoration: 'none' }}
+ >
+ Madison and Michael's Wedding
+ </Typography>
+ {isMobile ? <Mobile /> : <Desktop />}
+ </Toolbar>
+ </AppBar>
+ );
+}
+
+export default NavBar;
diff --git a/client/src/components/Registry.tsx b/client/src/components/Registry.tsx
new file mode 100644
index 0000000..60a73f9
--- /dev/null
+++ b/client/src/components/Registry.tsx
@@ -0,0 +1,11 @@
+import React from 'react';
+
+function Registry() {
+ return (
+ <>
+ <p>Registry</p>
+ </>
+ );
+}
+
+export default Registry;
diff --git a/client/src/components/Rsvp.tsx b/client/src/components/Rsvp.tsx
new file mode 100644
index 0000000..dad7213
--- /dev/null
+++ b/client/src/components/Rsvp.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import { useMemo } from 'react';
+import { useLocation, Navigate, Outlet } from 'react-router-dom';
+import { useSelector } from 'react-redux';
+import CssBaseline from '@mui/material/CssBaseline';
+import NavBar from './NavBar';
+import { selectCurrentGuest } from '../features/auth/authSlice';
+
+const authenticate = () => {
+ const guest = useSelector(selectCurrentGuest);
+ return useMemo(() => ({ guest }), [guest]);
+};
+
+function Rsvp() {
+ const auth = authenticate();
+ const location = useLocation();
+
+ return auth?.guest ? (
+ <>
+ <CssBaseline />
+ <NavBar />
+ <Outlet context={auth?.guest} />
+ </>
+ ) : (
+ <Navigate to="/guest-login" state={{ from: location }} replace />
+ );
+}
+
+export default Rsvp;
diff --git a/client/src/components/RsvpForm.tsx b/client/src/components/RsvpForm.tsx
new file mode 100644
index 0000000..71db0d8
--- /dev/null
+++ b/client/src/components/RsvpForm.tsx
@@ -0,0 +1,242 @@
+import React from 'react';
+import { useRef } from 'react';
+import { useOutletContext } from 'react-router-dom';
+import {
+ Button,
+ Container,
+ FormControl,
+ FormControlLabel,
+ FormLabel,
+ Grid,
+ Radio,
+ RadioGroup,
+ TextField,
+} from '@mui/material';
+import { useForm, Controller, useFieldArray } from 'react-hook-form';
+import { useUpdateGuestMutation } from '../apiSlice';
+import type { Guest } from '../apiSlice';
+
+type FormValues = {
+ id: number;
+ firstName: string;
+ lastName: string;
+ attendance: string;
+ email: string;
+ partySize: number;
+ message: string;
+ partyList: {
+ firstName: string;
+ lastName: string;
+ }[];
+};
+
+function RsvpForm() {
+ const [updateGuest] = useUpdateGuestMutation();
+ const guest: Guest = useOutletContext();
+ const previousPartySize = useRef(0);
+
+ const {
+ register,
+ handleSubmit,
+ control,
+ watch,
+ formState: { errors },
+ } = useForm<FormValues>({
+ defaultValues: {
+ id: guest?.id,
+ firstName: guest?.firstName,
+ lastName: guest?.lastName,
+ attendance: '',
+ email: '',
+ message: '',
+ partySize: 1,
+ partyList: [],
+ },
+ });
+
+ const onSubmit = async (data: Guest) => {
+ updateGuest({ ...data });
+ };
+
+ const { fields, append, remove } = useFieldArray({
+ control,
+ name: 'partyList',
+ });
+
+ const handleParty = () => {
+ const partySize = Number(watch('partySize')) - 1;
+ if (
+ partySize > previousPartySize.current &&
+ partySize > 0 &&
+ partySize < 10
+ ) {
+ append(
+ new Array(partySize - previousPartySize.current).fill({
+ firstName: '',
+ lastName: '',
+ })
+ );
+ previousPartySize.current = partySize;
+ } else if (partySize < previousPartySize.current && partySize >= 0) {
+ remove(
+ [...Array(previousPartySize.current - partySize).keys()].map(
+ (_, i) => partySize - 1 + i
+ )
+ );
+ previousPartySize.current = partySize;
+ }
+ };
+
+ return (
+ <Container
+ component="form"
+ maxWidth="sm"
+ noValidate
+ onSubmit={handleSubmit(onSubmit)}
+ >
+ <Grid container spacing={2} sx={{ mt: 8 }}>
+ <Grid item xs={12}>
+ <p>
+ Please RSVP for the wedding by March 10, 2025. The ceremony will
+ commence at 3 PM on April 26 in Divine Shepherd. The reception will
+ follow at 5 PM in A Venue on the Ridge.
+ </p>
+ </Grid>
+ <Grid item xs={12}>
+ <div style={{ display: 'flex', justifyContent: 'center' }}>
+ <FormControl>
+ <div style={{ display: 'flex', alignItems: 'center' }}>
+ <FormLabel sx={{ mr: 2 }} error={!!errors.attendance} required>
+ Will you attend?
+ </FormLabel>
+ <Controller
+ name="attendance"
+ control={control}
+ rules={{ required: true }}
+ render={({ field }) => (
+ <RadioGroup {...field} row>
+ <FormControlLabel
+ value="yes"
+ control={<Radio />}
+ label="Yes"
+ />
+ <FormControlLabel
+ value="no"
+ control={<Radio />}
+ label="No"
+ />
+ </RadioGroup>
+ )}
+ />
+ </div>
+ </FormControl>
+ </div>
+ </Grid>
+ <Grid item xs={12} md={6} lg={6}>
+ <TextField
+ label="Email"
+ type="email"
+ variant="outlined"
+ fullWidth
+ error={!!errors.email}
+ helperText={errors.email?.message}
+ required
+ {...register('email', {
+ required: 'This field is required',
+ pattern: {
+ value: /\S+@\S+\.\S+/,
+ message: 'Please enter a valid email address',
+ },
+ })}
+ />
+ </Grid>
+ <Grid item xs={12} md={6} lg={6}>
+ <TextField
+ label="Party Size"
+ type="number"
+ variant="outlined"
+ fullWidth
+ onWheel={(event) => {
+ event.currentTarget.blur();
+ }}
+ error={!!errors.partySize}
+ helperText={errors.partySize?.message}
+ required
+ {...register('partySize', {
+ onChange: handleParty,
+ required: 'This field is required',
+ min: { value: 1, message: 'Please enter a positive integer' },
+ max: {
+ value: 9,
+ message: 'Please enter an integer less than 10',
+ },
+ })}
+ />
+ </Grid>
+ <Grid item xs={12}>
+ <TextField
+ label="Message to the couple"
+ variant="outlined"
+ fullWidth
+ multiline
+ rows={3}
+ {...register('message')}
+ />
+ </Grid>
+ {fields.map((field, index) => {
+ return (
+ <Grid
+ container
+ item
+ columnSpacing={2}
+ rowSpacing={{ xs: 1 }}
+ key={field.id}
+ >
+ <Grid item xs={12} md={6} lg={6}>
+ <TextField
+ label="First Name"
+ variant="outlined"
+ fullWidth
+ error={!!errors.partyList?.[index]?.firstName}
+ helperText={errors.partyList?.[index]?.firstName?.message}
+ required
+ {...register(`partyList.${index}.firstName`, {
+ required: 'This field is required',
+ })}
+ />
+ </Grid>
+ <Grid item xs={12} md={6} lg={6}>
+ <TextField
+ label="Last Name"
+ variant="outlined"
+ fullWidth
+ error={!!errors.partyList?.[index]?.lastName}
+ helperText={errors.partyList?.[index]?.lastName?.message}
+ required
+ {...register(`partyList.${index}.lastName`, {
+ required: 'This field is required',
+ })}
+ />
+ </Grid>
+ </Grid>
+ );
+ })}
+ <Grid item xs={12}>
+ <div
+ style={{
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ }}
+ >
+ <Button type="submit" variant="contained">
+ RSVP
+ </Button>
+ </div>
+ </Grid>
+ </Grid>
+ </Container>
+ );
+}
+
+export default RsvpForm;
diff --git a/client/src/components/Schedule.tsx b/client/src/components/Schedule.tsx
new file mode 100644
index 0000000..808499c
--- /dev/null
+++ b/client/src/components/Schedule.tsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { Container, Paper, Typography, useTheme } from '@mui/material';
+
+function Schedule() {
+ const theme = useTheme();
+ return (
+ <Container
+ maxWidth="sm"
+ sx={{
+ display: 'flex',
+ justifyContent: 'center',
+ }}
+ >
+ <Paper
+ elevation={3}
+ sx={{
+ mt: 8,
+ height: '100%',
+ width: '100%',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ }}
+ >
+ <div
+ style={{
+ height: '100%',
+ width: '90%',
+ display: 'flex',
+ flexDirection: 'column',
+ justifyContent: 'center',
+ }}
+ >
+ <div
+ style={{
+ height: '100%',
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ }}
+ >
+ <div style={{ width: '35%' }}>
+ <p>April 26, 2025</p>
+ </div>
+ <div style={{ width: '65%' }}>
+ <Typography variant="h5">Wedding Schedule</Typography>
+ </div>
+ </div>
+ <hr style={{ width: '100%' }} />
+ <div
+ style={{
+ height: '100%',
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ }}
+ >
+ <div style={{ width: '35%' }}>
+ <p>2:00 PM</p>
+ </div>
+ <div style={{ width: '65%' }}>
+ <Typography variant="h6">Ceremony</Typography>
+ <p>
+ Divine Shepherd
+ <br />
+ <a
+ href="https://maps.app.goo.gl/dGWvmjPiVjNGBVkZ9"
+ style={{ color: theme.palette.primary.main }}
+ >
+ 15005 Q St, Omaha, NE 68137
+ </a>
+ </p>
+ </div>
+ </div>
+ <hr style={{ width: '100%' }} />
+ <div
+ style={{
+ height: '100%',
+ width: '100%',
+ display: 'flex',
+ alignItems: 'center',
+ }}
+ >
+ <div style={{ width: '35%' }}>
+ <p>4:00 PM</p>
+ </div>
+ <div style={{ width: '65%' }}>
+ <Typography variant="h6">Reception</Typography>
+ <p>
+ A Venue on the Ridge
+ <br />
+ <a
+ href="https://maps.app.goo.gl/35RRqxzQdq6E4eSMA"
+ style={{ color: theme.palette.primary.main }}
+ >
+ 20033 Elkhorn Ridge Dr, Elkhorn, NE 68022
+ </a>
+ </p>
+ </div>
+ </div>
+ </div>
+ </Paper>
+ </Container>
+ );
+}
+
+export default Schedule;
diff --git a/client/src/components/active.css b/client/src/components/active.css
new file mode 100644
index 0000000..e8b5f60
--- /dev/null
+++ b/client/src/components/active.css
@@ -0,0 +1,7 @@
+.active {
+ background-color: #1976d2
+}
+
+.inactive {
+ background-color: #c4c4c4
+}
diff --git a/client/src/features/auth/GuestLogin.tsx b/client/src/features/auth/GuestLogin.tsx
new file mode 100644
index 0000000..4da7e45
--- /dev/null
+++ b/client/src/features/auth/GuestLogin.tsx
@@ -0,0 +1,79 @@
+import React from 'react';
+import { useNavigate } from 'react-router-dom';
+import { useDispatch } from 'react-redux';
+import { Button, Container, TextField, Typography } from '@mui/material';
+import { useForm } from 'react-hook-form';
+import { setCredentials } from './authSlice';
+import { useLoginMutation } from '../../apiSlice';
+import type { LoginRequest } from '../../apiSlice';
+
+function GuestLogin() {
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+ const [login] = useLoginMutation();
+
+ const {
+ register,
+ handleSubmit,
+ formState: { errors },
+ } = useForm<LoginRequest>({
+ defaultValues: {
+ firstName: '',
+ lastName: '',
+ },
+ });
+
+ const onSubmit = async (data: LoginRequest) => {
+ try {
+ dispatch(setCredentials(await login(data).unwrap()));
+ navigate('/rsvp');
+ } catch (e) {
+ console.log(e);
+ }
+ };
+
+ return (
+ <Container
+ component="form"
+ maxWidth="xs"
+ noValidate
+ onSubmit={handleSubmit(onSubmit)}
+ >
+ <div
+ style={{
+ marginTop: 80,
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ }}
+ >
+ <Typography variant="h6">Guest Login</Typography>
+ <TextField
+ label="First Name"
+ variant="outlined"
+ margin="normal"
+ fullWidth
+ error={!!errors.firstName}
+ helperText={errors.firstName?.message}
+ required
+ {...register('firstName', { required: 'This field is required' })}
+ />
+ <TextField
+ label="Last Name"
+ variant="outlined"
+ margin="normal"
+ fullWidth
+ error={!!errors.lastName}
+ helperText={errors.lastName?.message}
+ required
+ {...register('lastName', { required: 'This field is required' })}
+ />
+ <Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
+ Log in
+ </Button>
+ </div>
+ </Container>
+ );
+}
+
+export default GuestLogin;
diff --git a/client/src/features/auth/authSlice.ts b/client/src/features/auth/authSlice.ts
new file mode 100644
index 0000000..bff2bdd
--- /dev/null
+++ b/client/src/features/auth/authSlice.ts
@@ -0,0 +1,26 @@
+import { createSlice } from '@reduxjs/toolkit';
+import type { RootState } from '../../store';
+import type { Guest } from '../../apiSlice';
+
+type AuthState = {
+ guest?: Guest;
+ token?: string;
+};
+
+const authSlice = createSlice({
+ name: 'auth',
+ initialState: { guest: undefined, token: undefined } as AuthState,
+ reducers: {
+ setCredentials: (state, action) => {
+ const { guest, token } = action.payload;
+ state.guest = guest;
+ state.token = token;
+ },
+ },
+});
+
+export const { setCredentials } = authSlice.actions;
+
+export default authSlice.reducer;
+
+export const selectCurrentGuest = (state: RootState) => state.auth.guest;
diff --git a/client/src/main.css b/client/src/main.css
new file mode 100644
index 0000000..5cb7cd6
--- /dev/null
+++ b/client/src/main.css
@@ -0,0 +1,4 @@
+#root, body {
+ height: 100vh;
+ margin: 0
+}
diff --git a/client/src/main.tsx b/client/src/main.tsx
new file mode 100644
index 0000000..8ada188
--- /dev/null
+++ b/client/src/main.tsx
@@ -0,0 +1,69 @@
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { createBrowserRouter, RouterProvider } from 'react-router-dom';
+import { Provider } from 'react-redux';
+import App from './App';
+import store from './store';
+import ThemeContextProvider from './ThemeContextProvider';
+import Schedule from './components/Schedule';
+import Registry from './components/Registry';
+import GuestLogin from './features/auth/GuestLogin';
+import Rsvp from './components/Rsvp';
+import RsvpForm from './components/RsvpForm';
+import Admin from './components/Admin';
+import Home from './components/Home';
+import './main.css';
+
+const router = createBrowserRouter([
+ {
+ element: <App />,
+ children: [
+ {
+ path: '/',
+ element: <Home />,
+ },
+ {
+ path: 'schedule',
+ element: <Schedule />,
+ },
+ {
+ path: 'registry',
+ element: <Registry />,
+ },
+ {
+ path: 'guest-login',
+ element: <GuestLogin />,
+ },
+ {
+ path: 'admin',
+ element: <Admin />,
+ },
+ ],
+ },
+ {
+ element: <Rsvp />,
+ children: [
+ {
+ path: 'rsvp',
+ element: <RsvpForm />,
+ },
+ ],
+ },
+]);
+
+const enableMocking = async () => {
+ const { worker } = await import('./mocks/browser');
+ return worker.start();
+};
+
+enableMocking().then(() => {
+ ReactDOM.createRoot(document.getElementById('root')!).render(
+ <React.StrictMode>
+ <Provider store={store}>
+ <ThemeContextProvider>
+ <RouterProvider router={router} />
+ </ThemeContextProvider>
+ </Provider>
+ </React.StrictMode>
+ );
+});
diff --git a/client/src/mocks/browser.ts b/client/src/mocks/browser.ts
new file mode 100644
index 0000000..0a56427
--- /dev/null
+++ b/client/src/mocks/browser.ts
@@ -0,0 +1,4 @@
+import { setupWorker } from 'msw/browser';
+import { handlers } from './handlers';
+
+export const worker = setupWorker(...handlers);
diff --git a/client/src/mocks/handlers.ts b/client/src/mocks/handlers.ts
new file mode 100644
index 0000000..217a7d5
--- /dev/null
+++ b/client/src/mocks/handlers.ts
@@ -0,0 +1,30 @@
+import { http, HttpResponse } from 'msw';
+import { nanoid } from '@reduxjs/toolkit';
+
+const token = nanoid();
+
+export const handlers = [
+ http.post('/guest-login', () => {
+ return HttpResponse.json({
+ guest: {
+ id: 1,
+ firstName: 'Michael',
+ lastName: 'Hunteman',
+ attendance: 'false',
+ email: '',
+ message: '',
+ },
+ token,
+ });
+ }),
+ http.patch('/guests/1', () => {
+ return HttpResponse.json({
+ id: 1,
+ firstName: 'Michael',
+ lastName: 'Hunteman',
+ attendance: 'true',
+ email: '',
+ message: '',
+ });
+ }),
+];
diff --git a/client/src/pages.ts b/client/src/pages.ts
new file mode 100644
index 0000000..bad57f6
--- /dev/null
+++ b/client/src/pages.ts
@@ -0,0 +1,8 @@
+const pages = [
+ { name: 'Home', to: '/' },
+ { name: 'Schedule', to: '/schedule' },
+ { name: 'RSVP', to: '/guest-login' },
+ { name: 'Registry', to: '/registry' },
+];
+
+export default pages;
diff --git a/client/src/store.ts b/client/src/store.ts
new file mode 100644
index 0000000..264639e
--- /dev/null
+++ b/client/src/store.ts
@@ -0,0 +1,16 @@
+import { configureStore } from '@reduxjs/toolkit';
+import { apiSlice } from './apiSlice';
+import authReducer from './features/auth/authSlice';
+
+const store = configureStore({
+ reducer: {
+ [apiSlice.reducerPath]: apiSlice.reducer,
+ auth: authReducer,
+ },
+ middleware: (getDefaultMiddleware) =>
+ getDefaultMiddleware().concat(apiSlice.middleware),
+});
+
+export default store;
+export type RootState = ReturnType<typeof store.getState>;
+export type AppDispatch = typeof store.dispatch;
diff --git a/client/src/vite-env.d.ts b/client/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/client/src/vite-env.d.ts
@@ -0,0 +1 @@
+/// <reference types="vite/client" />