diff --git a/README.md b/README.md
index 7247e9e..f001ec1 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,7 @@
-# vite-template-redux
+### typescript magic:
-Uses [Vite](https://vitejs.dev/), [Vitest](https://vitest.dev/), and [React Testing Library](https://github.com/testing-library/react-testing-library) to create a modern [React](https://react.dev/) app compatible with [Create React App](https://create-react-app.dev/)
+
-```sh
-npx degit reduxjs/redux-templates/packages/vite-template-redux my-app
-```
+
-## Goals
-
-- Easy migration from Create React App or Vite
-- As beginner friendly as Create React App
-- Optimized performance compared to Create React App
-- Customizable without ejecting
-
-## Scripts
-
-- `dev`/`start` - start dev server and open browser
-- `build` - build for production
-- `preview` - locally preview production build
-- `test` - launch test runner
-
-## Inspiration
-
-- [Create React App](https://github.com/facebook/create-react-app/tree/main/packages/cra-template)
-- [Vite](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react)
-- [Vitest](https://github.com/vitest-dev/vitest/tree/main/examples/react-testing-lib)
+
\ No newline at end of file
diff --git a/img.png b/img.png
new file mode 100644
index 0000000..691e402
Binary files /dev/null and b/img.png differ
diff --git a/img_1.png b/img_1.png
new file mode 100644
index 0000000..76f5967
Binary files /dev/null and b/img_1.png differ
diff --git a/img_2.png b/img_2.png
new file mode 100644
index 0000000..145fc88
Binary files /dev/null and b/img_2.png differ
diff --git a/package.json b/package.json
index 5d1252f..d94e89d 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,8 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.0.5",
- "react-router-dom": "^6.11.2"
+ "react-router-dom": "^6.11.2",
+ "react-toastify": "^9.1.3"
},
"devDependencies": {
"@testing-library/dom": "^9.3.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f2bb7c7..9dbe180 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -31,6 +31,9 @@ dependencies:
react-router-dom:
specifier: ^6.11.2
version: 6.11.2(react-dom@18.2.0)(react@18.2.0)
+ react-toastify:
+ specifier: ^9.1.3
+ version: 9.1.3(react-dom@18.2.0)(react@18.2.0)
devDependencies:
'@testing-library/dom':
@@ -4895,6 +4898,17 @@ packages:
react: 18.2.0
dev: false
+ /react-toastify@9.1.3(react-dom@18.2.0)(react@18.2.0):
+ resolution: {integrity: sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==}
+ peerDependencies:
+ react: '>=16'
+ react-dom: '>=16'
+ dependencies:
+ clsx: 1.2.1
+ react: 18.2.0
+ react-dom: 18.2.0(react@18.2.0)
+ dev: false
+
/react-transition-group@4.4.5(react-dom@18.2.0)(react@18.2.0):
resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==}
peerDependencies:
diff --git a/src/App.tsx b/src/App.tsx
index 4ecef4d..72c72c2 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,44 +1,60 @@
import { Counter } from "./features/counter/Counter"
-import { createBrowserRouter, RouterProvider } from "react-router-dom"
+import {
+ createBrowserRouter,
+ Outlet,
+ RouterProvider,
+ useNavigate,
+} from "react-router-dom"
import "./App.css"
-import { store } from "@/app/store"
-import { Provider } from "react-redux"
-import { createTheme, ThemeProvider } from "@mui/material"
+import "react-toastify/dist/ReactToastify.css"
+import { createTheme, LinearProgress, ThemeProvider } from "@mui/material"
import { useAppDispatch, useAppSelector } from "@/app/hooks"
import { useEffect } from "react"
import { appActions } from "@/features/app/app.slice"
import { authThunks } from "@/features/auth/auth.slice"
+import { toast, ToastContainer } from "react-toastify"
export const Test = () => {
- const isLoading = useAppSelector((state) => state.app.isLoading)
const error = useAppSelector((state) => state.app.error)
const dispatch = useAppDispatch()
-
+ const navigate = useNavigate()
function handleErrorButtonClicked() {
- dispatch(appActions.setError({ error: "new error" }))
+ toast.success("something's gone right")
}
useEffect(() => {
setTimeout(() => {
dispatch(appActions.setIsLoading({ isLoading: false }))
- }, 3000)
+ }, 1000)
}, [dispatch])
- if (isLoading) return
loading...
+ const handleLoginClicked = () => {
+ dispatch(
+ authThunks.login({
+ email: "andres99.dev@gmail.com",
+ password: "123123123",
+ rememberMe: true,
+ }),
+ )
+ .unwrap()
+ .then(() => navigate("/hello"))
+ .catch((err) => console.warn(err))
+ }
+
return (
+
{!!error &&
{error}
}
@@ -56,17 +72,49 @@ const router = createBrowserRouter([
element:
hello
,
path: "/hello",
},
+ {
+ element:
,
+ children: [
+ {
+ element:
protected
,
+ },
+ ],
+ },
])
const theme = createTheme()
function App() {
+ const isLoading = useAppSelector((state) => state.app.isLoading)
return (
-
-
-
-
-
+
+
+ {isLoading && }
+
+
)
}
+function ProtectedRoute() {
+ const isAuthed = useAppSelector((state) => state.auth.isAuthed)
+
+ const navigate = useNavigate()
+
+ useEffect(() => {
+ if (isAuthed === null) return
+
+ if (!isAuthed) navigate("/")
+ }, [isAuthed, navigate])
+
+ if (!isAuthed) return null
+
+ return
+}
export default App
diff --git a/src/common/actions/index.ts b/src/common/actions/index.ts
new file mode 100644
index 0000000..6835954
--- /dev/null
+++ b/src/common/actions/index.ts
@@ -0,0 +1 @@
+export * from "./unhandled-action"
diff --git a/src/common/actions/unhandled-action.ts b/src/common/actions/unhandled-action.ts
new file mode 100644
index 0000000..89cb872
--- /dev/null
+++ b/src/common/actions/unhandled-action.ts
@@ -0,0 +1,3 @@
+import { createAction } from "@reduxjs/toolkit"
+
+export const unhandledAction = createAction
("common/unhandledAction")
diff --git a/src/common/utils/index.ts b/src/common/utils/index.ts
index bcae4d5..1ea16f3 100644
--- a/src/common/utils/index.ts
+++ b/src/common/utils/index.ts
@@ -1 +1,2 @@
export * from "./create-app-async-thunk"
+export * from "./thunk-try-catch"
diff --git a/src/common/utils/thunk-try-catch.ts b/src/common/utils/thunk-try-catch.ts
new file mode 100644
index 0000000..ae256f5
--- /dev/null
+++ b/src/common/utils/thunk-try-catch.ts
@@ -0,0 +1,17 @@
+import { BaseThunkAPI } from "@reduxjs/toolkit/dist/createAsyncThunk"
+import { AppDispatch, RootState } from "@/app/store"
+type Options = { showGlobalError?: boolean }
+export const thunkTryCatch = async (
+ thunkAPI: BaseThunkAPI,
+ promise: () => Promise,
+ options?: Options,
+): Promise> => {
+ const { showGlobalError } = options || {}
+ const { rejectWithValue } = thunkAPI
+
+ try {
+ return await promise()
+ } catch (e) {
+ return rejectWithValue({ error: e, showGlobalError })
+ }
+}
diff --git a/src/features/app/app.slice.ts b/src/features/app/app.slice.ts
index 75c10c2..376f309 100644
--- a/src/features/app/app.slice.ts
+++ b/src/features/app/app.slice.ts
@@ -1,9 +1,12 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit"
+import { isAxiosError } from "axios"
+import { toast } from "react-toastify"
const initialAppState = {
error: null as null | string,
isLoading: true,
isAppInitialized: false,
+ unhandledActions: [] as Array,
}
export type InitialAppState = typeof initialAppState
@@ -25,7 +28,58 @@ const slice = createSlice({
state.isAppInitialized = action.payload.isAppInitialized
},
},
+ extraReducers: (builder) => {
+ builder
+ .addMatcher(
+ (action) => {
+ return action.type.endsWith("/pending")
+ },
+ (state) => {
+ state.isLoading = true
+ },
+ )
+ .addMatcher(
+ (action) => {
+ return action.type.endsWith("/rejected")
+ },
+ (state, { payload: { error } }) => {
+ state.isLoading = false
+ const errorMessage = getErrorMessage(error)
+ if (errorMessage === null) return
+ toast.error(errorMessage)
+ },
+ )
+ .addMatcher(
+ (action) => {
+ return action.type.endsWith("/fulfilled")
+ },
+ (state) => {
+ state.isLoading = false
+ },
+ )
+ .addDefaultCase((state, action) => {
+ console.log("addDefaultCase 🚀", action.type)
+ state.unhandledActions.push(action)
+ })
+ },
})
+/* if null is returned no message should be shown */
+function getErrorMessage(error: unknown): null | string {
+ if (isAxiosError(error)) {
+ if (
+ error?.response?.status === 400 &&
+ error?.request.responseURL.endsWith("/login")
+ ) {
+ return null
+ }
+ return error?.response?.data?.error ?? error.message
+ }
+ if (error instanceof Error) {
+ return `Native error: ${error.message}`
+ }
+ return JSON.stringify(error)
+}
+
export const appReducer = slice.reducer
export const appActions = slice.actions
diff --git a/src/features/auth/auth.slice.ts b/src/features/auth/auth.slice.ts
index ab2bd47..023f953 100644
--- a/src/features/auth/auth.slice.ts
+++ b/src/features/auth/auth.slice.ts
@@ -3,56 +3,56 @@ import {
AuthApi,
LoginArgs,
RegisterArgs,
+ RegisterResponse,
User,
} from "@/features/auth/auth.api"
-import { createAppAsyncThunk } from "@/common"
+import { createAppAsyncThunk, thunkTryCatch } from "@/common"
+import { BaseThunkAPI } from "@reduxjs/toolkit/dist/createAsyncThunk"
+import { AppDispatch, RootState } from "@/app/store"
const THUNK_PREFIXES = {
REGISTER: "auth/register",
}
+const createThunkAction = (
+ promise: (arg: A) => Promise,
+ transformPromise: (arg: R) => T,
+) => {
+ return (
+ arg: A,
+ thunkAPI: BaseThunkAPI,
+ ) => {
+ return thunkTryCatch(thunkAPI, () => promise(arg).then(transformPromise))
+ }
+}
-const register = createAppAsyncThunk(
+const register = createAppAsyncThunk<{ user: RegisterResponse }, RegisterArgs>(
THUNK_PREFIXES.REGISTER,
- (arg) => {
- AuthApi.register(arg)
- .then((res) => {
- console.log(res)
- })
- .catch((res) => {
- console.error(res)
- })
- },
+ createThunkAction(AuthApi.register, (res) => ({ user: res.data })),
)
const login = createAppAsyncThunk<{ user: User }, LoginArgs>(
"auth/login",
- async (arg) => {
- const res = await AuthApi.login(arg)
- return { user: res.data }
- },
+ createThunkAction(AuthApi.login, (res) => ({ user: res.data })),
)
const slice = createSlice({
name: "auth",
- initialState: { user: null as User | null, isLoading: false },
+ initialState: {
+ user: null as User | null,
+ isAuthed: null as null | boolean,
+ isLoading: false,
+ },
reducers: {
setUser: (state, action: PayloadAction<{ user: User }>) => {
state.user = action.payload.user
},
},
extraReducers: (builder) => {
- builder.addCase(login.pending, (state) => {
- state.isLoading = true
- })
builder.addCase(login.fulfilled, (state, action) => {
if (action.payload?.user) {
state.user = action.payload.user
- state.isLoading = false
}
})
- builder.addCase(login.rejected, (state) => {
- state.isLoading = false
- })
},
})
diff --git a/src/main.tsx b/src/main.tsx
index d250666..cfb5323 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,9 +2,13 @@ import React from "react"
import ReactDOM from "react-dom/client"
import App from "./App"
import "./index.css"
+import { store } from "@/app/store"
+import { Provider } from "react-redux"
ReactDOM.createRoot(document.getElementById("root")!).render(
-
+
+
+
,
)