diff --git a/app/api/counter/route.ts b/app/api/counter/route.ts new file mode 100644 index 0000000..ca485b3 --- /dev/null +++ b/app/api/counter/route.ts @@ -0,0 +1,12 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +export async function POST(request: NextRequest) { + const body: { amount: number } = await request.json(); + const { amount = 1 } = body; + + // simulate IO latency + await new Promise((resolve) => setTimeout(resolve, 500)); + + return NextResponse.json({ data: amount }); +} \ No newline at end of file diff --git a/app/demo/page.tsx b/app/demo/page.tsx index b15c526..324fa0b 100644 --- a/app/demo/page.tsx +++ b/app/demo/page.tsx @@ -1,7 +1,7 @@ "use client"; /* react */ -import { Suspense, useState } from "react"; +import {Suspense, useState} from "react"; // Components import CartDrawerItem from "@/components/cart-drawer/item"; @@ -15,361 +15,449 @@ import Breadcrumbs from "@/components/ui/breadcrumbs"; import Marker from "@/components/ui/marker"; import Tag from "@/components/ui/tag"; import Badge from "@/components/ui/badge"; -import { AlertTriangle, Check, ListOrdered, Truck } from "lucide-react"; +import {AlertTriangle, Check, ListOrdered, Truck} from "lucide-react"; import Header from "@/components/layout/header"; import Footer from "@/components/layout/footer"; import Nav from "@/components/layout/nav"; -import { SortingType } from "@/lib/types"; +import {SortingType} from "@/lib/types"; import Sorting from "@/components/pages/shop/sorting"; import CartDrawer from "@/components/cart-drawer"; +/* hooks*/ +import {useAppDispatch} from "@/hooks/use-app-dispatch"; +import {useAppSelector} from "@/hooks/use-app-selector"; + +/* lib */ +import { + decrement, + increment, + incrementAsync, + incrementByAmount, + selectCount, + selectStatus, + incrementIfOdd +} from "@/lib/features/counter/counterSlice"; + +/* styles */ +import "./styles.css"; + // --- Helper Component for Layout --- const DemoSection = ({ - title, - children, -}: { - title: string; - children: React.ReactNode; + title, + children, + }: { + title: string; + children: React.ReactNode; }) => ( -
-

{title}

- {/* This inner div acts as a "canvas" for the components */} -
- {children} -
-
+
+

{title}

+ {/* This inner div acts as a "canvas" for the components */} +
+ {children} +
+
); +export default function Page() { + // State for Steppers + const [cartQuantity1, setCartQuantity1] = useState(10); + const [cartQuantity2, setCartQuantity2] = useState(10); + const [cartQuantity3, setCartQuantity3] = useState(10); + // Data for Breadcrumbs + const crumbs = [ + {label: "Scent", href: "/"}, + {label: "Shop", href: "/shop"}, + {label: "Product X", href: "/shop/product"}, + ]; -export default function Page() { - // State for Steppers - const [cartQuantity1, setCartQuantity1] = useState(10); - const [cartQuantity2, setCartQuantity2] = useState(10); - const [cartQuantity3, setCartQuantity3] = useState(10); + // State for sorting + const [sorting] = useState({criteria: "name", order: "DESC"}); - // Data for Breadcrumbs - const crumbs = [ - { label: "Scent", href: "/" }, - { label: "Shop", href: "/shop" }, - { label: "Product X", href: "/shop/product" }, - ]; + // Redux usage example: Counter + const dispatch = useAppDispatch(); + const count = useAppSelector(selectCount); + const status = useAppSelector(selectStatus); + const [incrementAmount, setIncrementAmount] = useState("2"); - // State for sorting - const [sorting] = useState({ criteria: "name", order: "DESC" }); + const incrementValue = Number(incrementAmount) || 0; - return ( -
-
-
+ ); } diff --git a/app/demo/styles.css b/app/demo/styles.css new file mode 100644 index 0000000..942ee9b --- /dev/null +++ b/app/demo/styles.css @@ -0,0 +1,28 @@ +@reference "../globals.css" + +.async-button { + @apply relative; +} + +.async-button::after { + content: ""; + background-color: rgba(112, 76, 182, 0.15); + display: block; + position: absolute; + width: 100%; + height: 100%; + left: 0; + top: 0; + opacity: 0; + transition: width 1s linear, opacity 0.5s ease 1s; +} + +.async-button:active::after { + width: 0; + opacity: 1; + transition: none; +} + +.row:not(:last-child) { + @apply mb-4; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 1ade7bd..23d5c71 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import type { Metadata } from "next"; import "./globals.css"; /* fonts*/ import { inter } from "@/assets/fonts"; +import StoreProvider from "@/app/store-provider"; export const metadata: Metadata = { title: "Scents", @@ -16,12 +17,14 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - + + + {children} - - + + + ); } diff --git a/app/store-provider.tsx b/app/store-provider.tsx new file mode 100644 index 0000000..f37ce7a --- /dev/null +++ b/app/store-provider.tsx @@ -0,0 +1,35 @@ +'use client' +import {useEffect, useRef} from 'react' +import { Provider } from 'react-redux' +import { makeStore, AppStore } from '../lib/store' +import {setupListeners} from "@reduxjs/toolkit/query"; + +export default function StoreProvider({ + children, + }: { + children: React.ReactNode +}) { + const storeRef = useRef(undefined); + + // The code is copy-pasted from the official Redux documentation https://redux-toolkit.js.org/usage/nextjs#providing-the-store. + // Disable ESLint to follow the guidelines. + // eslint-disable-next-line + if (!storeRef.current) { + // Create the store instance the first time this renders + storeRef.current = makeStore() + } + + useEffect(() => { + if (storeRef.current != null) { + // configure listeners using the provided defaults + // optional, but required for `refetchOnFocus`/`refetchOnReconnect` behaviors + const unsubscribe = setupListeners(storeRef.current.dispatch); + return unsubscribe; + } + }, []); + + // The code is copy-pasted from the official Redux documentation https://redux-toolkit.js.org/usage/nextjs#providing-the-store. + // Disable ESLint to follow the guidelines. + // eslint-disable-next-line + return {children} +} \ No newline at end of file diff --git a/hooks/use-app-dispatch.ts b/hooks/use-app-dispatch.ts new file mode 100644 index 0000000..6bb7c90 --- /dev/null +++ b/hooks/use-app-dispatch.ts @@ -0,0 +1,4 @@ +import {useDispatch} from "react-redux"; +import {AppDispatch} from "@/lib/store"; + +export const useAppDispatch = useDispatch.withTypes(); \ No newline at end of file diff --git a/hooks/use-app-selector.ts b/hooks/use-app-selector.ts new file mode 100644 index 0000000..3c94a42 --- /dev/null +++ b/hooks/use-app-selector.ts @@ -0,0 +1,4 @@ +import {useSelector} from "react-redux"; +import {RootState} from "@/lib/store"; + +export const useAppSelector = useSelector.withTypes(); \ No newline at end of file diff --git a/hooks/use-app-store.ts b/hooks/use-app-store.ts new file mode 100644 index 0000000..8822165 --- /dev/null +++ b/hooks/use-app-store.ts @@ -0,0 +1,4 @@ +import {useStore} from "react-redux"; +import {AppStore} from "@/lib/store"; + +export const useAppStore = useStore.withTypes() \ No newline at end of file diff --git a/lib/createAppSlice.ts b/lib/createAppSlice.ts new file mode 100644 index 0000000..7e681ba --- /dev/null +++ b/lib/createAppSlice.ts @@ -0,0 +1,6 @@ +import { asyncThunkCreator, buildCreateSlice } from "@reduxjs/toolkit"; + +// `buildCreateSlice` allows us to create a slice with async thunks. +export const createAppSlice = buildCreateSlice({ + creators: { asyncThunk: asyncThunkCreator }, +}); \ No newline at end of file diff --git a/lib/features/counter/counterAPI.ts b/lib/features/counter/counterAPI.ts new file mode 100644 index 0000000..0c4a683 --- /dev/null +++ b/lib/features/counter/counterAPI.ts @@ -0,0 +1,11 @@ +// A mock function to mimic making an async request for data +export const fetchCount = async (amount = 1) => { + const response = await fetch("http://localhost:3000/api/counter", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amount }), + }); + const result: { data: number } = await response.json(); + + return result; +}; \ No newline at end of file diff --git a/lib/features/counter/counterSlice.ts b/lib/features/counter/counterSlice.ts new file mode 100644 index 0000000..4b4dda6 --- /dev/null +++ b/lib/features/counter/counterSlice.ts @@ -0,0 +1,71 @@ +import {PayloadAction} from "@reduxjs/toolkit"; +import {createAppSlice} from "@/lib/createAppSlice"; +import {fetchCount} from "@/lib/features/counter/counterAPI"; +import {AppThunk} from "@/lib/store"; + +export interface CounterSliceState { + value: number; + status: "idle" | "loading" | "failed"; +} + +const initialState: CounterSliceState = { + value: 0, + status: "idle", +}; + +export const counterSlice = createAppSlice({ + name: "counter", + initialState, + reducers: (create) => ({ + increment: create.reducer((state) => { + state.value += 1; + }), + decrement: create.reducer((state) => { + state.value -= 1; + }), + incrementByAmount: create.reducer((state, action: PayloadAction) => { + state.value += action.payload; + }), + incrementAsync: create.asyncThunk( + async (amount: number) => { + const response = await fetchCount(amount); + // The value we return becomes the `fulfilled` action payload + return response.data; + }, + { + pending: (state) => { + state.status = "loading"; + }, + fulfilled: (state, action) => { + state.status = "idle"; + state.value += action.payload; + }, + rejected: (state) => { + state.status = "failed"; + }, + }, + ), + }), + selectors: { + selectCount: (counter) => counter.value, + selectStatus: (counter) => counter.status, + }, +}); + +// Action creators are generated for each case reducer function. +export const { decrement, increment, incrementAsync, incrementByAmount } = counterSlice.actions; + +// Selectors returned by `slice.selectors` take the root state as their first argument. +export const { selectCount, selectStatus} = counterSlice.selectors; + +// We can also write thunks by hand, which may contain both sync and async logic. +// Here's an example of conditionally dispatching actions based on current state. +export const incrementIfOdd = + (amount: number): AppThunk => + (dispatch, getState) => { + const currentValue = selectCount(getState()); + + if (currentValue % 2 === 1 || currentValue % 2 === -1) { + dispatch(incrementByAmount(amount)); + } + }; \ No newline at end of file diff --git a/lib/store.ts b/lib/store.ts new file mode 100644 index 0000000..f9b030a --- /dev/null +++ b/lib/store.ts @@ -0,0 +1,28 @@ +import {Action, combineSlices, configureStore, ThunkAction} from "@reduxjs/toolkit"; +import {counterSlice} from "@/lib/features/counter/counterSlice"; + +// `combineSlices` automatically combines the reducers using +// their `reducerPath`s, therefore we no longer need to call `combineReducers`. +const rootReducer = combineSlices(counterSlice); + +/** +* Function for creating a new store instance per-request (instead of global singleton) +* to avoid data contamination from different requests. +* */ +export const makeStore = () => { + return configureStore({ + reducer: rootReducer, + }) +} + +// Infer the type of makeStore +export type AppStore = ReturnType; +// Infer the `RootState` type from the root reducer +export type RootState = ReturnType; +export type AppDispatch = AppStore['dispatch']; +export type AppThunk = ThunkAction< + ThunkReturnType, + RootState, + unknown, + Action +>; \ No newline at end of file diff --git a/package.json b/package.json index f082ba1..3687d23 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "devDependencies": { "@commitlint/cli": "^21.0.1", "@commitlint/config-conventional": "^21.0.1", + "@reduxjs/toolkit": "^2.12.0", "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", @@ -38,6 +39,7 @@ "eslint": "^9", "eslint-config-next": "16.0.3", "husky": "^9.1.7", + "react-redux": "^9.3.0", "tailwindcss": "^4", "tw-animate-css": "^1.4.0", "typescript": "^5"