1. Section Intro
Cart & Shipping Pages 购物车与配送页面
Now that we can add and remove items from the cart, we'll start the process of checkout. We're going to work on two of the four pages for checkout in this section. That is the cart page with the table as well as the shipping address page, which will allow the user to enter their shipping information.
现在我们可以添加和移除购物车中的商品了,接下来我们将开始结账流程。在本节中,我们将完成结账四个页面中的两个。即带有表格的购物车页面,以及允许用户输入配送信息的配送地址页面。
We'll start by creating the cart page that will show all the items in the user's cart.
我们将首先创建购物车页面,该页面将显示用户购物车中的所有商品。
Then we'll get started on the table of items. We will use the ShadCN table component for that.
然后我们将开始制作商品表格。我们将使用 ShadCN 表格组件来实现。
We're also going to need to format our currency, so we'll create a utility function to handle that.
我们还需要格式化货币,因此我们将创建一个工具函数来处理这个问题。
Then we'll start on the shipping address page, which will be the next step in the checkout process.
然后我们将开始制作配送地址页面,这是结账流程的下一步。
We'll add the form to add the user's address.
我们将添加表单来输入用户的地址。
Then we'll handle the submission with an action. Once the address is filled in, we'll use that address for that user to automatically fill in the fields when they order more products.
然后我们将使用一个操作来处理提交。一旦地址填写完成,我们将使用该地址为用户自动填充字段,以便他们下次订购更多产品时使用。
Lastly, we'll create a checkout steps component to show where in the proccess the user is.
最后,我们将创建一个结账步骤组件,以显示用户当前处于流程的哪个阶段。
2. Start Cart Page 开始购物车页面
We are going to start creating cart page, which will ultimately show a table of the items in the cart along with the total. We will start the main page here and then work on the ShadCN table in the next lesson.
我们将开始创建购物车页面,最终将显示购物车中商品的表格以及总价。我们将在这里开始主页面,然后在下一课中处理 ShadCN 表格。
Create a new file at
app/(root)/cart/page.tsx and add the following code:在
app/(root)/cart/page.tsx 创建一个新文件并添加以下代码:We added some metadata and made the function async. We will use this soon to fetch the cart items.
我们添加了一些元数据并将函数设为异步。我们很快就会用它来获取购物车商品。
You should see this page if you navigate to
/cart in your browser.如果你在浏览器中导航到
/cart,你应该能看到这个页面。Now, let's create the form. Create a new file at
app/(root)/cart/cart-table.tsx and add the following code:现在,让我们创建表单。在
app/(root)/cart/cart-table.tsx 创建一个新文件并添加以下代码:We are making it a client component because we will be using hookd. We are bringing in the
Cart type from the @/types folder. We are also passing in the cart prop to the component, which can be undefined.我们将其设为客户端组件,因为我们将使用钩子。我们从
@/types 文件夹引入 Cart 类型。我们还将 cart 属性传递给组件,该属性可以是 undefined。Now bring it into the
CartPage component along with our cart using the getMyCart action.现在使用
getMyCart 操作将其连同购物车一起引入 CartPage 组件。You should see the heading "Shopping Cart" on the page.
你应该在页面上看到标题"Shopping Cart"。
Let's continue with the cart form. Add the following imports:
让我们继续处理购物车表单。添加以下导入:
In the function, let's initialize the
toast and router variables and also our useTransition hook.在函数中,让我们初始化
toast 和 router 变量以及我们的 useTransition 钩子。For the return, add the following for now:
对于返回值,暂时添加以下内容:
We are checking if the cart is empty or undefined. If it is, we show a message to go shopping. If it is not, we show the cart. We will be displaying a ShadCN table for the cart items and I want to wrap it in some grid classes.
我们正在检查购物车是否为空或未定义。如果是,我们显示一条消息让用户去购物。如果不是,我们显示购物车。我们将为购物车商品显示一个 ShadCN 表格,我想用一些网格类来包裹它。
3. ShadCN UI Table ShadCN 表格
We will use the table component from ShadCN too display the cart items. We need to install it first:
我们将使用 ShadCN 的表格组件来显示购物车商品。我们需要先安装它:
Then we can use it in the
CartTable component. Let's add the following imports for the card, button and all the table elements:然后我们可以在
CartTable 组件中使用它。让我们为卡片、按钮和所有表格元素添加以下导入:Let's start with the headers. Add the following to the return:
让我们从表头开始。在返回值中添加以下内容:
Image & Name Cell 图片和名称单元格
Now let's add the table body and map over the cart items. We will add the first table cell, which will be the item image and name:
现在让我们添加表格主体并遍历购物车商品。我们将添加第一个表格单元格,即商品图片和名称:
Be sure you have the sample images in the
public/images/sample-products folder.确保你在
public/images/sample-products 文件夹中有示例图片。Quantity & Add/Remove Cell 数量和添加/移除单元格
The next cell will be the quantity and the add/remove buttons. We will add the following to the table body:
下一个单元格将是数量和添加/移除按钮。我们将在表格主体中添加以下内容:
We are doing the same thing that we did with the add to cart component. We are showing the loader when the request is pending and the button when it is not. We have the plus and minus buttons to add and remove items from the cart.
我们正在做与添加到购物车组件相同的事情。当请求挂起时显示加载器,否则显示按钮。我们有加号和减号按钮来添加和移除购物车中的商品。
Price Cell 价格单元格
Lastly, we will add the price cell. We will add the following to the table body:
最后,我们将添加价格单元格。我们将在表格主体中添加以下内容:
In the next lesson, we will have a card that shows the total. We are also going to create a custom function to format the currency.
在下一课中,我们将有一个显示总价的卡片。我们还将创建一个自定义函数来格式化货币。
4. Subtotal Card & Format Currency 小计卡片与货币格式化
We are going to add the subtotal to our cart page and create a utility function to format the currency.
我们将在购物车页面添加小计功能,并创建一个实用函数来格式化货币。
Open the file
lib/utils.ts and add the following code below the other functions:打开
lib/utils.ts 文件,在其他函数下方添加以下代码:This is a currency formatter.
Intl.NumberFormat is a built-in JavaScript object that provides language-sensitive number formatting and makes it easy to format numbers as currency, percentages, or general numbers based on locale. We are using the en-US locale and formatting the currency as USD. We are also setting the minimum number of fractional digits to 2.这是一个货币格式化器。
Intl.NumberFormat 是 JavaScript 的内置对象,提供语言敏感的数字格式化功能,可以轻松地根据区域设置将数字格式化为货币、百分比或常规数字。我们使用 en-US 区域设置并将货币格式化为美元(USD)。我们还设置了最小小数位数为 2。Now let's create the function below it:
现在让我们在其下方创建该函数:
This function takes in a number or string and returns a formatted currency string. If the input is a number, it formats it using the
CURRENCY_FORMATTER. If the input is a string, it converts it to a number and then formats it. If the input is not a number or string, it returns 'NaN', which is not a number.此函数接受数字或字符串作为参数,并返回格式化的货币字符串。如果输入是数字,则使用
CURRENCY_FORMATTER 进行格式化。如果输入是字符串,则先将其转换为数字再进行格式化。如果输入既不是数字也不是字符串,则返回 'NaN'(非数字)。Now, let's go back into the
CartTable component and bring in the formatCurrency function:现在,让我们回到
CartTable 组件中,引入 formatCurrency 函数:Now, let's go right above the last closing
</div> and add the following:现在,让我们在最后一个闭合的
</div> 标签上方添加以下内容:We are using the
Card component to display the subtotal. We get the subtotal by summing the quantity of each item in the cart by using the reduce method. We are also formatting the currency using the formatCurrency function.我们使用
Card 组件来显示小计。通过使用 reduce 方法对购物车中每个商品的数量进行求和来获取小计。我们还使用 formatCurrency 函数来格式化货币。Proceed To Checkout Button 前往结算按钮
Now let's add the button to proceed to checkout. Make the
Card component look like this:现在让我们添加前往结算的按钮。将
Card 组件修改如下:The button is disabled when the request is pending and the loader is shown when it is not. When the button is clicked, it will navigate to the
/shipping-address page.当请求处于待处理状态时,按钮将被禁用,并显示加载动画。当点击按钮时,将导航到
/shipping-address 页面。5. Shipping Address Zod Schema & Page 收货地址 Zod 模式与页面
Let's create our next step in the checkout process, the shipping address page and form.
让我们创建结账流程的下一步:收货地址页面和表单。
Shipping Address Schema 收货地址模式
Let's start by creating the Zod schema for the shipping address. Open the
lib/validator.ts file and add the following:让我们首先为收货地址创建 Zod 模式。打开
lib/validator.ts 文件并添加以下内容:Shipping Address Type 收货地址类型
Let's add the following type to the
types/index.ts file:让我们在
types/index.ts 文件中添加以下类型:Default Values / Constants 默认值/常量
Let's add some constants to the
lib/constants/index.ts file:让我们在
lib/constants/index.ts 文件中添加一些常量:Of course you can add different values if you would like.
当然,如果您愿意,可以添加不同的值。
Shipping Address Page 收货地址页面
Let's create the page. Create a new file at
app/(root)/shipping-address/page.tsx and add the following:让我们创建页面。在
app/(root)/shipping-address/page.tsx 创建一个新文件并添加以下内容:We are bringing in the
auth function from the auth file. This will give us the current user's session. We are using the getMyCart function from the cart.actions file to get the current user's cart. We are using the getUserById function from the user.actions file to get the current user's information. We have not created this yet, but we will in a minute.我们从
auth 文件中引入 auth 函数。这将为我们提供当前用户的会话信息。我们使用 cart.actions 文件中的 getMyCart 函数来获取当前用户的购物车。我们使用 user.actions 文件中的 getUserById 函数来获取当前用户的信息。我们还没有创建这个函数,但马上就会创建。We are using the
redirect function from next/navigation to redirect the user to the cart page if the cart is empty. We are using the Metadata type from next to set the page title. We are using the ShippingAddress type from the types file to type the user variable.我们使用
next/navigation 中的 redirect 函数在购物车为空时将用户重定向到购物车页面。我们使用 next 中的 Metadata 类型来设置页面标题。我们使用 types 文件中的 ShippingAddress 类型来为 user 变量添加类型。Get User By ID 根据 ID 获取用户
Open the
lib/actions/user.actions.ts file and add the following:打开
lib/actions/user.actions.ts 文件并添加以下内容:This is a simple function that gets the user by their ID. We are using the
prisma.user.findFirst method to get the user by their ID. We are using the where property to specify the ID of the user we want to get. We are using the if statement to check if the user exists. If the user does not exist, we are throwing an error. We are returning the user.这是一个简单的函数,根据用户 ID 获取用户信息。我们使用
prisma.user.findFirst 方法通过 ID 查找用户。我们使用 where 属性来指定要获取的用户 ID。我们使用 if 语句检查用户是否存在。如果用户不存在,我们抛出一个错误。最后返回用户信息。In the next lesson, we will create the shipping address form component.
在下一课中,我们将创建收货地址表单组件。
6. Shipping Address Form 收货地址表单
We have our shipping page, now we need to create the form.
我们已经有了收货地址页面,现在需要创建表单。
There are two packages that we need to install:
我们需要安装两个包:
react-hook-form: This is a library that helps us manage forms in React. It integrates well with ShadCN form components. It takes care of things like managing form state, handling form submissions, validating form inputs and displaying error messages. So we don't have to write all that ourselves.
react-hook-form:这是一个帮助我们管理 React 表单的库。它与 ShadCN 表单组件集成良好。它负责管理表单状态、处理表单提交、验证表单输入和显示错误消息等事务。所以我们不必自己编写所有这些代码。
@hookform/resolvers: The @hookform/resolvers package provides integrations between react-hook-form and validation libraries like zod. It enables react-hook-form to leverage the validation schemas from these libraries directly, making it simpler to apply custom validation rules. This way we don't have to write custom validation functions.
@hookform/resolvers:@hookform/resolvers 包提供了 react-hook-form 与验证库(如 zod)之间的集成。它使 react-hook-form 能够直接利用这些库的验证模式,从而更简单地应用自定义验证规则。这样我们就不必编写自定义验证函数。
Run the following in your terminal:
在终端中运行以下命令:
We also need to install the ShadCN form component:
我们还需要安装 ShadCN 表单组件:
Create a new file at
app/(root)/shipping-address/shipping-address-form.tsx and add the following for now:在
app/(root)/shipping-address/shipping-address-form.tsx 创建一个新文件,暂时添加以下内容:We are starting out simple and just adding the imports, passing in the address prop, initializing the router and toast, and returning the text, Shipping Form.
我们从简单的开始,只是添加导入、传入 address 属性、初始化 router 和 toast,并返回文本"Shipping Form"。
Now bring it into the shipping page:
现在将其引入收货地址页面:
Embed it in the page and pass in the user address:
将其嵌入页面并传入用户地址:
Now let's continue in the
app/(root)/shipping-address/shipping-address-form.tsx file.现在让我们继续在
app/(root)/shipping-address/shipping-address-form.tsx 文件中工作。With Reach Hook Form, we have access to a
useForm hook. This hook returns an object with a bunch of properties. We then pass this object to the Form component.使用 React Hook Form,我们可以访问
useForm 钩子。这个钩子返回一个包含许多属性的对象。然后我们将这个对象传递给 Form 组件。Add this above the return in the component:
在组件的 return 语句上方添加以下内容:
We are using the
zodResolver to validate the form data. We are also setting the default values to the address if it exists, or the default values from the lib/constants/index.ts file.我们使用
zodResolver 来验证表单数据。我们还将默认值设置为地址(如果存在),或者使用 lib/constants/index.ts 文件中的默认值。When you call
useForm() in a component with react-hook-form, it provides an object (form in this case) that contains various methods and properties essential for handling form functionality, like handleSubmit, control, reset, etc. By spreading ...form onto <Form>, we will effectively be passing each of these properties as individual props to the <Form> component.当您在使用 react-hook-form 的组件中调用
useForm() 时,它会提供一个对象(在本例中为 form),其中包含处理表单功能所需的各种方法和属性,如 handleSubmit、control、reset 等。通过将 ...form 展开到 <Form> 上,我们将有效地将每个属性作为单独的 props 传递给 <Form> 组件。Since we are using
useTransition, add the following next:由于我们正在使用
useTransition,接下来添加以下内容:Now let's add the form.
现在让我们添加表单。
Replace the
return statement with the following:用以下内容替换
return 语句:We are building our form with the
Form component from the @/components/ui/form. We are also using the FormField component to render the fields. The control='form-control' is used to bind the form control and state to the form. The name prop is used to bind the field to the form. The render prop is used to render the field. We are using ControllerRenderProps to get the field value and the field prop is used to bind the field to the form.我们使用
@/components/ui/form 中的 Form 组件来构建表单。我们还使用 FormField 组件来渲染字段。control='form-control' 用于将表单控件和状态绑定到表单。name 属性用于将字段绑定到表单。render 属性用于渲染字段。我们使用 ControllerRenderProps 来获取字段值,field 属性用于将字段绑定到表单。We are using the
Input component from the @/components/ui/input to render the inputs. We are also using the FormMessage component to render any error messages that have to do with the field.我们使用
@/components/ui/input 中的 Input 组件来渲染输入框。我们还使用 FormMessage 组件来渲染与字段相关的任何错误消息。The
render prop defines the field rendering, allowing react-hook-form to bind the input value to field with {...field} in each <Input /> component.render 属性定义了字段渲染,允许 react-hook-form 通过每个 <Input /> 组件中的 {...field} 将输入值绑定到字段。So a lot is being done behind the scenes here. It may seem a bit weird if you are used to handling forms manually. React Hook Form is a popular library and you will run into it a lot.
所以这里在幕后做了很多工作。如果您习惯于手动处理表单,这可能看起来有点奇怪。React Hook Form 是一个流行的库,您会经常遇到它。
添加临时的onsubmit函数:
In the next lesson we will create the submit handler and the action to update a user's address.
在下一课中,我们将创建提交处理程序和更新用户地址的操作。
7. Update User Address 更新用户地址
Now that we have the address form set up, we need to handle the form submission. We are going to create a new user action to update the shipping address.
现在我们已经设置好了地址表单,需要处理表单提交。我们将创建一个新的用户操作来更新收货地址。
Open the
lib/actions/user.actions.ts file and add the following function:打开
lib/actions/user.actions.ts 文件并添加以下函数:We are using the
auth function from the auth file to get the current user's session. We are using the prisma.user.findFirst method to get the current user's information. We are using the shippingAddressSchema from the validator file to parse the data. We are using the prisma.user.update method to update the user's address. We are returning a success message if the user is updated successfully. We are returning an error message if the user is not found. Be sure to import the auth function from the auth file and the shippingAddressSchema from the validator file.我们使用
auth 文件中的 auth 函数来获取当前用户的会话。我们使用 prisma.user.findFirst 方法来获取当前用户的信息。我们使用 validator 文件中的 shippingAddressSchema 来解析数据。我们使用 prisma.user.update 方法来更新用户的地址。如果用户更新成功,我们返回成功消息。如果用户未找到,我们返回错误消息。请确保从 auth 文件导入 auth 函数,并从 validator 文件导入 shippingAddressSchema。Shipping Address Form 收货地址表单
Now go back to the
app/(root)/shipping-address/shipping-address-form.tsx file and import the updateUserAddress function from the user.actions file.现在返回
app/(root)/shipping-address/shipping-address-form.tsx 文件,从 user.actions 文件导入 updateUserAddress 函数。Now add the following submit handler above the
return statement:现在在
return 语句上方添加以下提交处理程序:We are wrapping everything in a
startTransition to prevent the UI from freezing. We are using the updateUserAddress function from the user.actions file to update the user's address. We are using the toast function from the useToast hook to display a success message if the user is updated successfully. We are using the router.push function from the next/navigation package to redirect the user to the payment method page.我们将所有内容包装在
startTransition 中以防止 UI 冻结。我们使用 user.actions 文件中的 updateUserAddress 函数来更新用户的地址。我们使用 useToast 钩子中的 toast 函数来显示成功消息(如果用户更新成功)。我们使用 next/navigation 包中的 router.push 函数将用户重定向到支付方式页面。Add the following to the
<form> element:将以下内容添加到
<form> 元素:Be sure to keep the ShadCN
<Form> component. Do not replace it.请确保保留 ShadCN 的
<Form> 组件。不要替换它。So when we submit this shipping address form, we are going to call the
updateUserAddress function from the user.actions file. If the user is updated successfully, we are going to redirect the user to the payment method page.因此,当我们提交此收货地址表单时,我们将调用
user.actions 文件中的 updateUserAddress 函数。如果用户更新成功,我们将把用户重定向到支付方式页面。In the next lesson, we are going to create a
checkoutSteps component to keep track of where we are in the process.在下一课中,我们将创建一个
checkoutSteps 组件来跟踪我们在流程中的位置。8. Checkout Steps Component 结账步骤组件
We are gong to create a component for the checkout steps. This will keep track of where we are in the process.
我们将创建一个结账步骤组件。它将跟踪我们在流程中的位置。
Create a new file at
components/shared/checkout-steps.tsx and add the following:在
components/shared/checkout-steps.tsx 创建一个新文件并添加以下内容:The steps are as follows:
步骤如下:
- User Login 用户登录
- Shipping Address 收货地址
- Payment Method 支付方式
- Place Order 下单
This component takes in a
current prop which is the current step. It will display the steps and highlight the current step. We are using the cn function from the lib/utils.ts file to conditionally add the bg-secondary class to the step.这个组件接收一个
current 属性,表示当前步骤。它将显示所有步骤并高亮当前步骤。我们使用 lib/utils.ts 文件中的 cn 函数来有条件地为步骤添加 bg-secondary 类。We are mapping over the steps and rendering them. We are using a React Fragment to wrap each step and the horizontal line. A fragment is just a way to group elements without adding extra nodes to the DOM. You could just as well use a div, but it's a bit cleaner.
我们遍历步骤数组并渲染它们。我们使用 React Fragment 来包装每个步骤和水平线。Fragment 只是一种分组元素的方式,不会向 DOM 添加额外的节点。您也可以使用 div,但使用 Fragment 更简洁。
Display In Shipping Form 在收货地址表单中显示
Now let's bring the steps into the shipping form. Open the
app/(root)/shipping-address/shipping-address-form.tsx file and add the following import:现在让我们将步骤组件引入收货地址表单。打开
app/(root)/shipping-address/shipping-address-form.tsx 文件并添加以下导入:In the return statement, add the following just under the opening fragment (<>):
在 return 语句中,在开头的 fragment (<>) 下方添加以下内容:
You should see the steps with the shipping page step highlighted.
您应该能看到步骤显示,并且收货地址页面步骤被高亮显示。
Let's continue on to the payment method page.
让我们继续进入支付方式页面。
9. Persist Cart On Sign In 登录时保留购物车
So we have the first couple pages of our checkout and we have our cart. However, right now, if you add items to your cart as a guest and then log in, your cart will not persist. To fix this, we will ensure that the guest cart (session-based) is set as the user cart when the user signs in.
我们已经完成了结账流程的前几个页面,也有了购物车功能。但是,目前如果您以访客身份添加商品到购物车,然后登录,您的购物车将不会保留。为了解决这个问题,我们将确保当用户登录时,访客购物车(基于会话的)被设置为用户购物车。
We will do this by overwriting the user cart on sign in. When a user logs in, we check for their guest sessionCartId. If the guest cart exists, we update the user's cart with the session cart items.
我们将通过在登录时覆盖用户购物车来实现这一点。当用户登录时,我们检查他们的访客 sessionCartId。如果访客购物车存在,我们用会话购物车的商品更新用户的购物车。
Open the
auth.ts file and add the following:打开
auth.ts 文件并添加以下内容:We are checking for a sign in or sign up trigger and then getting the
sessionCartId from the cookie.我们检查登录或注册触发器,然后从 cookie 中获取
sessionCartId。We then find the user cart if there is one and overwrite it with the guests.
然后我们查找用户购物车(如果存在),并用访客购物车覆盖它。
You could merge the two but the code starts to get very complicated and I dont feel I have a good ability to explain that for this course.
您可以合并这两个购物车,但代码会变得非常复杂,我觉得我无法在本课程中很好地解释这一点。
10. Protecting Paths 保护路径
Right now, we can go to any path whether we are logged in or not. For instance, if we go to
/shipping-address and we are not logged in, it let's us. We see an error but it still let's us go to the page, which we don't want. We want it to redirect to the sign in page.目前,无论我们是否登录,都可以访问任何路径。例如,如果我们访问
/shipping-address 但没有登录,系统仍然允许我们访问。虽然我们会看到错误,但它仍然让我们进入页面,这不是我们想要的。我们希望它重定向到登录页面。Since we have the following line in our
middleware.ts file, we can add this functionality to the authorized callback in the auth.ts file.由于我们的
middleware.ts 文件中有以下代码,我们可以在 auth.ts 文件的 authorized 回调中添加此功能。Open the
auth.ts file and add the following at the top of the authorized callback:打开
auth.ts 文件,在 authorized 回调的顶部添加以下内容:We just set an array of regex patterns with the paths we want to protect, got the pathname and added a conditional to check if the user is logged in and trying to access a protected path. If so, we return false and that will redirect the user to the sign in page.
我们只是设置了一个包含我们要保护的路径的正则表达式模式数组,获取了路径名,并添加了一个条件来检查用户是否已登录并尝试访问受保护的路径。如果是这样,我们返回 false,这将把用户重定向到登录页面。
📎 参考文章
- 一些引用
- 引用文章
欢迎您在底部评论区留言,一起交流~
Loading...
