iT邦幫忙

2023 iThome 鐵人賽

DAY 14
0
Modern Web

由前向後,從前端邁向全端系列 第 14

14.【從前端到全端,Nextjs+Nestjs】創建商店頁面-加入狀態管理

  • 分享至 

  • xImage
  •  

文章重點

  • 使用recoil管理購物車和商品的狀態
  • 使用recoil組織資料的schemas與actions

本文

今天我們要對我們的網路商店加入狀態管理。我們這次使用recoil來進行管理。

pnpm add recoil

為了確保整個應用程序都可以訪問Recoil的狀態,我們在主要的provider中添加了RecoilRoot。打開我們的provider,並在apps\iron-ecommerce-next\app\app-provider.tsx加入RecoilRoot

"use client";

import { CSSProvider } from "@master/css.react";
import { Theme } from "@radix-ui/themes";

import config from "master.css";
import { RecoilRoot } from "recoil";

export default function AppProvider({ children }: { children: React.ReactNode }) {
	return (
		<RecoilRoot>
			<Theme>
				<CSSProvider config={config}>
					{children}
				</CSSProvider>
			</Theme>
		</RecoilRoot>
	);
}

接著我們創建store資料夾,並將狀態放入:
https://ithelp.ithome.com.tw/upload/images/20230930/20108931igoUPXwPM9.png

首先我們創建了一系列的schemas,這些schemas定義了購物車、商品和用戶的資料結構:

////// apps\iron-ecommerce-next\store\schemas\cart.schema.ts

import { z } from "zod";

export const cartItemSchema = z.object({
	productId: z.string(),
	productName: z.string(),
	price: z.number().nonnegative(),
	quantity: z.number().nonnegative()
});

export const cartSchema = z.array(cartItemSchema);

export type CartItem = z.infer<typeof cartItemSchema>;
export type Cart = z.infer<typeof cartSchema>;


////// apps\iron-ecommerce-next\store\schemas\product.schema.ts

import { z } from "zod";

export const productSchema = z.object({
	id: z.string(),
	name: z.string(),
	price: z.number().nonnegative(),
	description: z.string(),
	imageUrl: z.string()
});

export const productsSchema = z.array(productSchema);

export type Product = z.infer<typeof productSchema>;
export type Products = z.infer<typeof productsSchema>;


///// apps\iron-ecommerce-next\store\schemas\user.schema.ts

import { z } from "zod";

export const userSchema = z.union([
	z.null(),
	z.object({
		id: z.string(),
		name: z.string(),
		email: z.string().email()
	})
]);

export type User = z.infer<typeof userSchema>;


接著創建state:

///// apps\iron-ecommerce-next\store\state\cart.state.tsx

import { atom } from "recoil";
import { Cart, cartSchema } from "../schemas/cart.schema";

export const cartState = atom<Cart>({
	key: "cartState",
	default: cartSchema.safeParse([]).success ? [] : undefined
});


///// apps\iron-ecommerce-next\store\state\product.state.tsx

import { atom } from "recoil";
import { Products, productsSchema } from "../schemas/product.schema";

export const productsState = atom<Products>({
	key: "productsState",
	default: productsSchema.safeParse([]).success ? [] : undefined
});


///// apps\iron-ecommerce-next\store\state\user.state.tsx

import { atom } from "recoil";
import { User, userSchema } from "../schemas/user.schema";

export const userState = atom<User>({
	key: "userState",
	default: userSchema.safeParse(null).success ? null : undefined
});


我們需要一些操作來修改這些狀態。為此,我們創建了actions,這些actions提供了一個界面來與我們的狀態互動:

///// apps\iron-ecommerce-next\store\actions\cart.actions.tsx

import { selector, useRecoilState, useRecoilValue } from "recoil";
import { CartItem } from "../schemas/cart.schema";
import { cartState } from "../state/cart.state";

const cartTotalPrice = selector({
	key: "cartTotalPrice",
	get: ({ get }) => {
		const cart = get(cartState);
		return cart.reduce((total, item) => {
			return total + item.price * item.quantity;
		}, 0);
	}
});

export const useCartActions = () => {
	const [cart, setCart] = useRecoilState(cartState);

	const addToCart = (item: CartItem) => {
		const newCart = [...cart, item];
		setCart(newCart);
	};

	const removeFromCart = (productId: string) => {
		const newCart = cart.filter((item) => item.productId !== productId);
		setCart(newCart);
	};

	const updateCartItem = (productId: string, quantity: number) => {
		const newCart = cart.map((item) => (item.productId === productId ? { ...item, quantity } : item));
		setCart(newCart);
	};

	const checkout = () => {
		setCart([]);
	};

	const totalPrice = useRecoilValue(cartTotalPrice);

	return {
		addToCart,
		removeFromCart,
		updateCartItem,
		checkout,
		cart,
		totalPrice
	};
};


///// apps\iron-ecommerce-next\store\actions\product.actions.tsx

import { useRecoilState } from "recoil";
import { Products } from "../schemas/product.schema";
import { productsState } from "../state/product.state";

export const useProductActions = () => {
	const [products, setProducts] = useRecoilState(productsState);

	const updateProducts = (newProducts: Products) => {
		setProducts(newProducts);
	};

	return {
		updateProducts,
		products
	};
};


///// apps\iron-ecommerce-next\store\actions\user.actions.tsx

import { useRecoilState } from "recoil";
import { User } from "../schemas/user.schema";
import { userState } from "../state/user.state";

export const useUserActions = () => {
	const [user, setUser] = useRecoilState(userState);

	const login = (userData: User) => {
		setUser(userData);
	};

	const logout = () => {
		setUser(null);
	};

	return {
		login,
		logout,
		user
	};
};


product

接下來先在product page加入狀態來測試,apps\iron-ecommerce-next\app\products\products.client.tsx:

"use client";

import { Flex } from "@radix-ui/themes";
import { Product } from "../../store/schemas/product.schema";

import ProductCard from "libs/iron-components/src/lib/ProductCard";

interface ProductsProps {
	products: Product[];
}

const ProductsClient = ({ products }: ProductsProps) => {
	return (
		<Flex align="center" justify="center">
			<section className="w:60% p:1rem flex flex:wrap flex-direction:row gap:1rem jc:center">
				{products.map((product, i) => (
					<ProductCard
						key={product.id}
						title={product.name}
						price={`$${product.price.toFixed(2)}`}
						description={product.description}
						width="30%"
						imageUrl={product.imageUrl}
					/>
				))}
			</section>
		</Flex>
	);
};

export default ProductsClient;


apps\iron-ecommerce-next\app\products\page.tsx

import { NextPage } from "next";
import { productsSchema } from "../../store/schemas/product.schema";
import ProductsClient from "./products.client";

const fakeProducts = Array.from({ length: 30 }).map((_, i) => ({
	id: (i + 1).toString(),
	name: `Sample Product ${i + 1}`,
	price: 100.0,
	description: "This is a description for the sample product.",
	imageUrl: "https://www.w3schools.com/tags/img_girl.jpg"
}));

const getData = async () => {
	const result = productsSchema.safeParse(fakeProducts);

	const products = result.success ? result.data : [];

	return { products };
};

// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface ProductsPageProps {}

const ProductsPage: NextPage<ProductsPageProps> = async () => {
	const { products } = await getData();

	return <ProductsClient products={products} />;
};

export default ProductsPage;

cart

apps\iron-ecommerce-next\app\cart\cart.client.tsx:

"use client";

import { Card, Flex } from "@radix-ui/themes";
import CartForm from "libs/iron-components/src/lib/CartForm";
import { useCartActions } from "../../store/actions/cart.actions";

// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface CartProps {}

// eslint-disable-next-line no-empty-pattern
const CartClient = ({}: CartProps) => {
	const { cart } = useCartActions();
	const cartProducts = cart.map((item) => ({
		id: item.productId,
		name: item.productName,
		quantity: item.quantity,
		price: item.price
	}));

	return (
		<Flex align="center" justify="center">
			<section className="w:60% p:1rem flex flex:wrap flex-direction:row gap:1rem jc:center">
				<Card>
					<CartForm items={cartProducts} />
				</Card>
			</section>
		</Flex>
	);
};

export default CartClient;

並且修正一下我們的元件HeaderCartForm以及ProductCard

Header:

// libs\iron-components\src\lib\Header\Header.tsx

import { Button, Flex, Heading, Popover, Text } from "@radix-ui/themes";
import Link from "next/link";
import React from "react";

// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface HeaderProps {
	cart: {
    productId: string;
		productName: string;
    quantity: number;
		price: number;
	}[];
	onRemoveFromCart?: (productId: string) => void;
}

const Header: React.FC<HeaderProps> = ({cart, onRemoveFromCart}) => {
	const handleRemoveFromCart = (productId: string) => {
		if (onRemoveFromCart) {
			onRemoveFromCart(productId);
		}
	}

	return (
		<header className="flex flex-direction:row jc:space-between ai:center p:1em bg:#333 color:#fff">
			<div className="flex-shrink:0 f:1.5rem f:bold">Iron Shop</div>
			<nav className="flex-grow:1 flex m:0|1em">
				<ul className="list-style:none flex gap:1rem m:0 p:0 {cursor:pointer}>li">
					<li>
						<Link href="/" className="text-decoration:none color:#fff">
							Home
						</Link>
					</li>
					<li>
						<Link href="/products" className="text-decoration:none color:#fff">
							Products
						</Link>
					</li>
				</ul>
			</nav>
			<div className="flex gap:1rem">
				<Popover.Root>
					<Popover.Trigger>
						<Text className="color:#fff cursor:pointer">Cart</Text>
					</Popover.Trigger>
					<Popover.Content style={{ width: 360 }}>
						<Heading size="2" mb="1">
							Cart Items
						</Heading>

						<Flex direction="column" align="stretch">
							{
								cart.map((item) => (
									<Flex key={item.productId} align="center" gap="1" style={{ padding: "0.5rem" }}>
										<Text size="3" weight="bold">{item.productName}</Text>
										<Text size="2" color="gray">{`${item.price}$ x ${item.quantity}`}</Text>
										<Button size="1" variant="soft" onClick={() => handleRemoveFromCart(item.productId)}>
											Remove
										</Button>
									</Flex>
								))
							}
							<Link href="/cart">
								<Button size="1" variant="soft">
									Go to Cart
								</Button>
							</Link>
						</Flex>
					</Popover.Content>
				</Popover.Root>

				<Link href="/user/tester" className="text-decoration:none color:#fff">
					User
				</Link>
			</div>
		</header>
	);
};

export default Header;

ProductCard:

// libs\iron-components\src\lib\ProductCard\ProductCard.tsx

import { Box, Button, Card, Flex, Text } from "@radix-ui/themes";
import React from "react";

interface ProductCardProps {
	id?: string;
	title?: string;
	description?: string;
	price?: string;
	imageUrl?: string;
	width?: string;
	onAddToCart?: (args: { productId: string; quantity: number }) => void;
}

const ProductCard: React.FC<ProductCardProps> = (props) => {
	const { id, title, description, price, imageUrl, width = "auto", onAddToCart } = props;

	const handleAddToCart = () => {
		if (onAddToCart) {
				onAddToCart({ productId: id ?? "-1", quantity: 1 });
		}
	};

	return (
		<Card className={`w:${width}`}>
			<Box>
				<picture>
					<img src={imageUrl} aria-hidden alt="Sample Image" width="100%" />
				</picture>
				<Flex>
					<Text as="div" size="2" weight="bold">
						{title}
					</Text>
					<Button size="1" variant="soft" onClick={handleAddToCart}>Add</Button>
				</Flex>
				{price && (
					<Text as="div" size="2" color="gray">
						{price}
					</Text>
				)}
				{description && (
					<Text as="div" size="2" color="gray">
						{description}
					</Text>
				)}
			</Box>
		</Card>
	);
};

export default ProductCard;

CartForm

// libs\iron-components\src\lib\CartForm\CartForm.tsx

import { zodResolver } from "@hookform/resolvers/zod";
import { Button, Table, TextField } from "@radix-ui/themes";

import React from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";

interface Product {
	id: string;
	name: string;
	price: number;
}

interface CartItem extends Product {
	quantity: number;
}

type CartFormValues = Record<string, { quantity: string }>;

interface CartFormProps {
	items: CartItem[];
}

const CartItemSchema = z.object({
	quantity: z.string().regex(/^\d+$/, "必須是有效的正整數")
});

const generateCartSchema = (items: CartItem[]) => {
	const schemaShape: Record<string, typeof CartItemSchema> = {};
	items.forEach((item) => {
		schemaShape[item.id] = CartItemSchema;
	});
	return z.object(schemaShape);
};

const CartForm: React.FC<CartFormProps> = ({ items }) => {
	const cartSchema = generateCartSchema(items);
	const {
		register,
		handleSubmit,
		watch,
		formState: { errors }
	} = useForm<CartFormValues>({
		resolver: zodResolver(cartSchema)
	});

	const watchedValues = watch();

	const calculateItemTotal = (id: string, price: number) => {
		const quantity = parseInt(watchedValues[id]?.quantity || "0", 10);
		return quantity * price;
	};

	const grandTotal = items.reduce((acc, item) => acc + calculateItemTotal(item.id, item.price), 0);

	const onSubmit = (data: CartFormValues) => {
		console.log("Form data:", data);
	};

	return (
		<form onSubmit={handleSubmit(onSubmit)}>
			<Table.Root>
				<Table.Header>
					<Table.Row>
						<Table.ColumnHeaderCell>商品名稱</Table.ColumnHeaderCell>
						<Table.ColumnHeaderCell>價格</Table.ColumnHeaderCell>
						<Table.ColumnHeaderCell>數量</Table.ColumnHeaderCell>
						<Table.ColumnHeaderCell>總計</Table.ColumnHeaderCell>
					</Table.Row>
				</Table.Header>

				<Table.Body>
					{items.map((item) => (
						<Table.Row key={item.id}>
							<Table.RowHeaderCell>{item.name}</Table.RowHeaderCell>
							<Table.Cell>{item.price} 元</Table.Cell>
							<Table.Cell>
								<TextField.Input
									{...register(`${item.id}.quantity`)}
									defaultValue={item.quantity.toString()}
									type="number"
								/>
								{typeof errors[item.id]?.quantity?.message === "string" && (
									<span style={{ color: "red" }}>{errors[item.id]?.quantity?.message}</span>
								)}
							</Table.Cell>
							<Table.Cell>{calculateItemTotal(item.id, item.price)} 元</Table.Cell>
						</Table.Row>
					))}
				</Table.Body>
			</Table.Root>
			<div className="flex jc:space-between ai:center mt:5em">
				<span>總計:{grandTotal} 元</span>
				<Button type="submit">更新購物車</Button>
			</div>
		</form>
	);
};

export default CartForm;

接下來修正一下我們的header,打開apps\iron-ecommerce-next\app\template.tsx:

"use client";

import { Flex } from "@radix-ui/themes";
import Header from "libs/iron-components/src/lib/Header";
import { useCartActions } from "../store/actions/cart.actions";

export default function Template({ children }: { children: React.ReactNode }) {
	const { cart, removeFromCart } = useCartActions();

	return (
		<Flex direction="column">
			<Header cart={cart} onRemoveFromCart={removeFromCart} />
			{children}
		</Flex>
	);
}

https://ithelp.ithome.com.tw/upload/images/20230930/20108931D0NHecbuF2.png


總結

透過本文,我們介紹了如何使用Recoil進行狀態管理。從創建數據schemas,到狀態的組織和管理,再到如何在實際組件中應用這些狀態和操作。


上一篇
13.【從前端到全端,Nextjs+Nestjs】創建商店頁面-建立表單
下一篇
15.【從前端到全端,Nextjs+Nestjs】在Nextjs 13設置並使用GraphQL
系列文
由前向後,從前端邁向全端30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
DL
iT邦新手 5 級 ‧ 2024-02-18 13:43:22

樓主你好,
我裝完recoil後一直啟動失敗~
請問這章節適用的版本是0.7.7嗎

我要留言

立即登入留言