React-全栈项目第二版-三-

49 阅读1小时+

React 全栈项目第二版(三)

原文:zh.annas-archive.org/md5/35c59f78351aeb34721c43c78c53c92a

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:通过在线市场锻炼 MERN 技能

随着越来越多的业务在网上进行,在线市场环境中买卖的能力已成为许多网络平台的核心需求。在本章和接下来的两章中,我们将利用 MERN 技术栈开发一个具有用户买卖功能的在线市场应用程序。

我们将为这个应用程序构建从简单到高级的所有功能,从本章开始,我们将重复前几章中学到的全栈开发经验,为市场平台打下基础。我们将通过支持卖家账户和带有产品的商店来扩展 MERN 框架应用程序,逐步集成市场功能,如产品搜索和建议。到本章结束时,你将更好地掌握如何扩展、集成和组合全栈实现的各个方面,以向你的应用程序添加复杂功能。

在本章中,我们将通过以下主题开始构建在线市场:

  • 介绍 MERN 市场应用程序

  • 拥有卖家账户的用户

  • 在市场上添加商店

  • 向商店添加产品

  • 通过名称和类别搜索产品

介绍 MERN 市场应用程序

MERN 市场应用程序将允许用户成为卖家,他们可以管理多个商店并在每个商店中添加他们想要出售的产品。访问 MERN 市场的用户将能够搜索和浏览他们想要购买的产品,并将产品添加到购物车中下订单。最终的市场应用程序将如以下截图所示:

图片

完整的 MERN 市场应用程序的代码可在 GitHub 上找到,网址为github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter07%20and%2008/mern-marketplace。本章和下一章中讨论的实现可以在存储库的 shop-cart-order-pay 分支中访问。你可以在阅读本章剩余部分的代码解释时克隆此代码并运行应用程序。

在本章中,我们将扩展 MERN 框架以构建一个简单的在线市场版本,从以下功能开始:

  • 拥有卖家账户的用户

  • 店铺管理

  • 产品管理

  • 通过名称和类别搜索产品

与卖家账户、商店和产品相关的功能所需视图将通过扩展和修改 MERN 框架应用程序中现有的 React 组件来开发。下面所示的组件树展示了本章开发的 MERN 市场前端的所有自定义 React 组件:

我们将添加新的 React 组件来实现管理商店和产品以及浏览和搜索产品的视图。我们还将修改现有的组件,如 EditProfile、Menu 和 Home 组件,将骨架代码开发成市场应用程序,正如我们在本章的其余部分构建不同功能时那样。这些市场功能将取决于用户将他们的账户更新为卖家账户的能力。在下一节中,我们将通过更新现有的用户实现来启用卖家账户功能,开始构建 MERN 市场应用程序。

允许用户成为卖家

任何在 MERN 市场应用程序上有账户的用户都有将他们的账户更新为卖家账户的选项,通过更改他们的个人资料来实现。我们将在编辑个人资料页面添加此选项以转换为卖家账户,如下面的截图所示:

拥有活跃卖家账户的用户将被允许创建和管理他们自己的商店,在那里他们可以管理产品。普通用户将无法访问卖家仪表板,而拥有活跃卖家账户的用户将在菜单上看到一个指向他们仪表板的链接,显示为“我的商店”。以下截图显示了普通用户与拥有活跃卖家账户的用户在菜单上的区别:

要添加此卖家账户功能,我们需要更新用户模型、编辑个人资料视图,并在菜单中添加一个仅对卖家可见的“我的商店”链接,如以下各节所述。

更新用户模型

我们需要存储有关每个用户的额外详细信息,以确定用户是否是活跃的卖家。我们将更新我们在第三章,《使用 MongoDB、Express 和 Node 构建后端》中开发的用户模型,以添加一个默认设置为falseseller值来表示普通用户,并且可以额外设置为true来表示也是卖家的用户。我们将更新现有的用户模式以添加此seller字段,如下面的代码所示:

mern-marketplace/server/models/user.model.js:

seller: {
    type: Boolean,
    default: false
}

对于每个用户的此seller值必须在成功登录后发送给客户端,以便视图可以根据显示与卖家相关的信息进行渲染。我们将在signin控制器方法中更新返回的响应,以添加此详细信息,如下面的代码所示:

mern-marketplace/server/controllers/auth.controller.js:

...
return res.json({
      token,
      user: {
        _id: user._id,
        name: user.name,
        email: user.email,
        seller: user.seller
      }
    })
...
} 

使用此seller字段值,我们可以根据仅允许卖家账户的授权来渲染前端。在根据卖家授权渲染视图之前,我们首先需要在EditProfile视图中实现激活卖家账户功能的选项,如下一节所述。

更新编辑个人资料视图

已登录用户将在编辑个人资料视图中看到一个切换按钮,允许他们激活或停用卖家功能。我们将更新EditProfile组件,在FormControlLabel中添加一个Material-UI Switch组件,如下面的代码所示:

mern-marketplace/client/user/EditProfile.js:

<Typography variant="subtitle1" className={classes.subheading}>
   Seller Account
</Typography>
<FormControlLabel
     control={<Switch
                checked={values.seller}
                onChange={handleCheck}
             />}
     label={values.seller? 'Active' : 'Inactive'}
/>

任何对开关的更改将通过调用handleCheck方法设置为状态中seller的值。handleCheck方法的实现如下所示:

mern-marketplace/client/user/EditProfile.js:

const handleCheck = (event, checked) => {
    setValues({...values, 'seller': checked})
} 

当提交编辑个人资料详情的表单时,seller值也将添加到发送给服务器的更新详情中,如下面的代码所示:

mern-marketplace/client/user/EditProfile.js:

const clickSubmit = () => {
    const jwt = auth.isAuthenticated() 
    const user = {
      name: values.name || undefined,
      email: values.email || undefined,
      password: values.password || undefined,
      seller: values.seller || undefined
    }
    update({
      userId: match.params.userId
    }, {
      t: jwt.token
    }, user).then((data) => {
      if (data && data.error) {
        setValues({...values, error: data.error})
      } else {
        auth.updateUser(data, ()=>{
 setValues({...values, userId: data._id, redirectToProfile: true})
 })
      }
    })
  }

在成功更新后,用于认证目的存储在sessionStorage中的用户详情也应更新。通过调用auth.updateUser方法来完成此sessionStorage更新。auth.updateUser方法的实现已在第六章的更新编辑个人资料视图部分中讨论,构建基于 Web 的课堂应用

一旦在前端获得更新的seller值,我们可以使用它来相应地渲染界面。在下一节中,我们将看到如何根据查看应用的用户的卖家账户是否活跃来不同地渲染菜单。

更新菜单

在市场应用的前端,我们可以根据当前浏览应用的用户的卖家账户是否活跃来渲染不同的选项。在本节中,我们将添加代码以条件性地在导航栏上显示到我的商店的链接,该链接仅对已登录且拥有活跃卖家账户的用户可见。

我们将更新前一段代码中的Menu组件,使其仅在用户登录时渲染,如下所示:

mern-marketplace/client/core/Menu.js:

{auth.isAuthenticated().user.seller && 
  (<Link to="/seller/shops">
  <Button color = {isPartActive(history, "/seller/")}> My Shops </Button>
   </Link>)
}

导航栏上的此我的商店链接将带活跃卖家账户的用户带到卖家仪表板视图,在那里他们可以管理他们在市场上的商店。

通过对用户实现的这些更新,现在市场中的用户可以将他们的普通账户更新为卖家账户,我们可以开始整合允许这些卖家向市场添加商店的功能。我们将在下一节中看到如何实现这一点。

在市场中添加商店

MERN 市场中的卖家可以创建商店并向每个商店添加产品。为了存储商店数据和启用商店管理,我们将实现一个用于商店的 Mongoose 模式,后端 API 以访问和修改商店数据,以及面向商店所有者和浏览市场的买家的前端视图。

在接下来的章节中,我们将通过首先定义用于在数据库中存储商店数据的商店模型,然后实现商店相关功能的后端 API 和前端视图(包括创建新商店、列出所有商店、按所有者列出商店、显示单个商店、编辑商店和从应用程序中删除商店)来构建应用程序中的商店模块。

定义商店模型

我们将实现一个 Mongoose 模型来定义一个用于存储每个商店详情的商店模型。此模型将在server/models/shop.model.js中定义,其实现将与之前章节中覆盖的其他 Mongoose 模型实现类似,如第六章中定义的 Course 模型,构建基于 Web 的课堂应用程序。此模型中的商店模式将包含简单的字段以存储商店详情,包括标志图像以及指向拥有商店的用户引用。定义商店字段的代码块及其说明如下所示:

  • 商店名称和描述namedescription字段将是字符串类型,其中name为必填字段:
name: { 
    type: String, 
    trim: true, 
    required: 'Name is required' 
},
description: { 
    type: String, 
    trim: true 
},
  • 商店标志图像image字段将存储用户上传到 MongoDB 数据库中的标志图像文件:
image: { 
    data: Buffer, 
    contentType: String 
},
  • 商店所有者owner字段将引用创建商店的用户:
owner: {
    type: mongoose.Schema.ObjectId, 
    ref: 'User'
}
  • 创建时间和更新时间createdupdated字段将是Date类型,created在添加新商店时生成,而updated在修改任何商店详情时更改:
updated: Date,
created: { 
    type: Date, 
    default: Date.now 
},

在此模式定义中添加的字段将使我们能够实现 MERN Marketplace 中的商店相关功能。在下一节中,我们将通过实现允许卖家创建新商店的全栈切片来开始开发这些功能。

创建新商店

在 MERN Marketplace 中,一个已登录并拥有活跃卖家账户的用户将能够创建新的商店。为了实现这个功能,在接下来的章节中,我们将在后端添加创建商店 API,以及在前端获取此 API 的方法,以及一个用于输入商店字段的创建新商店表单视图。

创建商店 API

为了实现允许在数据库中创建新商店的创建商店 API,我们首先添加一个POST路由,如下面的代码所示:

mern-marketplace/server/routes/shop.routes.js:

router.route('/api/shops/by/:userId')
    .post(authCtrl.requireSignin, authCtrl.hasAuthorization, 
           userCtrl.isSeller, shopCtrl.create)

/api/shops/by/:userId此路由的POST请求将首先确保请求的用户已登录并且也是授权的所有者,换句话说,它是与路由参数中指定的:userId关联的同一用户。

为了处理:userId参数并从数据库中检索关联的用户,我们将利用用户控制器中的userByID方法。我们将在shop.routes.js中的Shop路由中添加以下内容,以便用户在request对象中作为profile可用:

mern-marketplace/server/routes/shop.routes.js:

router.param('userId', userCtrl.userByID) 

包含商店路由的shop.routes.js文件将与user.routes文件非常相似。为了在 Express 应用中加载这些新的商店路由,我们需要在express.js中挂载商店路由,如下所示,就像我们为认证和用户路由所做的那样:

mern-marketplace/server/express.js:

app.use('/', shopRoutes)

在创建商店路由的请求中,也会验证当前用户是否为卖家,然后再使用请求中传递的商店数据创建一个新的商店。我们将更新用户控制器以添加isSeller方法,以确保当前用户实际上是一个卖家。isSeller方法定义如下:

mern-marketplace/server/controllers/user.controller.js:

const isSeller = (req, res, next) => {
  const isSeller = req.profile && req.profile.seller
  if (!isSeller) {
    return res.status('403').json({
      error: "User is not a seller"
    })
  }
  next()
}

在商店控制器中,当卖家验证后,会调用create方法,该方法使用formidable节点模块解析可能包含用户上传的商店标志图像文件的 multipart 请求。如果有文件,formidable将暂时将其存储在文件系统中,我们将使用fs模块读取它,以检索文件类型和数据并将其存储在商店文档的image字段中。create控制器方法将如下所示:

mern-marketplace/server/controllers/shop.controller.js:

const create = (req, res, next) => {
  let form = new formidable.IncomingForm()
  form.keepExtensions = true
  form.parse(req, (err, fields, files) => {
    if (err) {
      res.status(400).json({
        message: "Image could not be uploaded"
      })
    }
    let shop = new Shop(fields)
    shop.owner= req.profile
    if(files.image){
      shop.image.data = fs.readFileSync(files.image.path)
      shop.image.contentType = files.image.type
    }
    shop.save((err, result) => {
      if (err) {
        return res.status(400).json({
          error: errorHandler.getErrorMessage(err)
        })
      }
      res.status(200).json(result)
    })
  })
}

用户上传的商店标志图像文件存储在 MongoDB 中作为数据。然后,为了在视图中显示,它作为单独的GET API 从数据库中检索出来,作为一个图像文件。GET API 设置为 Express 路由/api/shops/logo/:shopId,从 MongoDB 获取图像数据并将其作为文件发送在响应中。文件上传、存储和检索的实现步骤在第五章“从简单的社交媒体应用开始”的“上传个人照片”部分中详细说明。

现在可以在前端使用这个创建商店 API 端点来发起POST请求。接下来,我们将在客户端添加一个fetch方法,以便从应用程序的客户端界面发起这个请求。

在视图中获取创建 API

在前端,为了向这个创建 API 发起请求,我们将在客户端设置一个fetch方法,向 API 路由发送一个POST请求,并传递包含新商店详细信息的 multipart 表单数据。这个fetch方法定义如下:

mern-marketplace/client/shop/api-shop.js:

const create = (params, credentials, shop) => {
  return fetch('/api/shops/by/'+ params.userId, {
      method: 'POST',
      headers: {
        'Accept': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      },
      body: shop
    })
    .then((response) => {
      return response.json()
    }).catch((err) => console.log(err))
}

我们将在下一节实现的创建新商店表单视图中使用这个方法,将用户输入的商店详细信息发送到后端。

新商店组件

在市场应用中的卖家将通过表单视图输入新商店的详细信息并创建新商店。我们将在这个NewShop组件中渲染这个表单,允许卖家通过输入名称和描述,并从本地文件系统中上传标志图像文件来创建商店,如下面的截图所示:

我们将在名为 NewShop 的 React 组件中实现此表单。对于视图,我们首先使用 Material-UI 按钮和 HTML5 文件输入元素添加文件上传元素,如下面的代码所示:

mern-marketplace/client/shop/NewShop.js:

<input accept="image/*" onChange={handleChange('image')} 
       id="icon-button-file"
       style={display:'none'} type="file" />
<label htmlFor="icon-button-file">
   <Button variant="contained" color="secondary" component="span">
      Upload Logo <FileUpload/>
   </Button>
</label> 
<span>{values.image ? values.image.name : ''}</span>

然后,我们添加名称和描述表单字段,使用 TextField 组件,如下所示:

mern-marketplace/client/shop/NewShop.js:

<TextField 
    id="name" 
    label="Name" 
    value={values.name} 
    onChange={handleChange('name')}/> <br/>
<TextField 
    id="multiline-flexible" 
    label="Description"
    multiline rows="2" 
    value={values.description}
    onChange={handleChange('description')}/>

这些表单字段的变化将通过 handleChange 方法进行跟踪,当用户与输入字段交互输入值时。handleChange 函数将定义如下所示:

mern-marketplace/client/shop/NewShop.js:

const handleChange = name => event => {
    const value = name === 'image'
      ? event.target.files[0]
      : event.target.value
    setValues({ ...values, [name]: value })
}

handleChange 方法会更新状态,包括用户上传的图像文件名(如果有的话)。

最后,您可以通过添加一个提交按钮来完成此表单视图,当用户点击时,应将表单数据发送到服务器。我们将定义一个 clickSubmit 方法,如下所示,当用户点击提交按钮时将被调用:

mern-marketplace/client/shop/NewShop.js:

const clickSubmit = () => {
    const jwt = auth.isAuthenticated()
    let shopData = new FormData()
    values.name && shopData.append('name', values.name)
    values.description && shopData.append('description', values.description)
    values.image && shopData.append('image', values.image)
    create({
      userId: jwt.user._id
    }, {
      t: jwt.token
    }, shopData).then((data) => {
      if (data.error) {
        setValues({...values, error: data.error})
      } else {
        setValues({...values, error: '', redirect: true})
      }
    })
}

clickSubmit 函数将获取输入值并填充 shopData,这是一个 FormData 对象,确保数据以正确的格式存储,适用于 multipart/form-data 编码类型。然后调用 create fetch 方法,使用此表单数据在后端创建新的商店。在成功创建商店后,用户将被重定向回 MyShops 视图,如下面的代码所示:

mern-marketplace/client/shop/NewShop.js:

if (values.redirect) {
      return (<Redirect to={'/seller/shops'}/>)
}

NewShop 组件只能由已登录且也是卖家的用户查看。因此,我们将在 MainRouter 组件中添加一个 PrivateRoute,如下面的代码块所示,它只为在 /seller/shop/new 的认证用户提供此表单:

mern-marketplace/client/MainRouter.js:

<PrivateRoute path="/seller/shop/new" component={NewShop}/>

此链接可以添加到卖家可能访问的任何视图组件中,例如在卖家管理市场中的商店的视图中。现在,在市场上添加新商店成为可能,在下一节中,我们将讨论从后端数据库到前端应用视图检索和列出这些商店的实现。

列出商店

在 MERN Marketplace 中,普通用户将能够浏览平台上的所有商店列表,而每位店主将管理他们自己的商店列表。在以下章节中,我们将实现全栈切片,用于检索和显示两种不同的商店列表——所有商店的列表和特定用户拥有的商店列表。

列出所有商店

浏览市场中的任何用户都将能够看到市场上所有商店的列表。为了实现此功能,我们必须查询 shops 集合以检索数据库中的所有商店,并将其显示给最终用户。我们通过添加以下全栈切片来实现这一点:

  • 一个用于检索商店列表的后端 API

  • 前端的一个 fetch 方法用于向 API 发送请求

  • 一个用于显示商店列表的 React 组件

商店列表 API

在后端,我们将定义一个 API 来从数据库检索所有商店,以便在前端列出市场中的商店。此 API 将接受来自客户端的请求以查询 shops 集合,并在响应中返回结果商店文档。首先,当服务器在 '/api/shops' 接收到 GET 请求时,我们将添加一个路由来检索存储在数据库中的所有商店。此路由声明如下所示:

mern-marketplace/server/routes/shop.routes.js

router.route('/api/shops')
    .get(shopCtrl.list)

在此路由接收到的 GET 请求将调用 list 控制器方法,该方法将查询数据库中的 shops 集合以返回所有商店。list 方法定义如下:

mern-marketplace/server/controllers/shop.controller.js:

const list = async (req, res) => {
  try {
    let shops = await Shop.find()
    res.json(shops)
  } catch (err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

此方法将返回数据库中的所有商店以响应请求的客户端。接下来,我们将看到如何从客户端向此商店列表 API 发送请求。

获取所有商店以供查看

为了在前端使用商店列表 API,我们将定义一个 fetch 方法,该方法可以被 React 组件用来加载此商店列表。客户端的 list 方法将使用 fetch 向 API 发送 GET 请求,如下所示:

mern-marketplace/client/shop/api-shop.js:

const list = async (signal) => {
  try {
    let response = await fetch('/api/shops', {
      method: 'GET',
      signal: signal
    })
    return response.json()
  }catch(err) {
    console.log(err)
  }
}

如我们将在下一节中看到的,此 list 方法可以在 React 组件中用来显示商店列表。

Shops 组件

Shops 组件中,在从服务器获取数据并将数据设置在状态中以供显示后,我们将使用 Material-UI List 渲染商店列表,如下所示:

图片

为了实现此组件,我们首先需要获取并渲染商店列表。我们将在 useEffect 钩子中调用 fetch API 调用,并将接收到的 shops 数组设置在状态中,如下所示:

mern-marketplace/client/shop/Shops.js:

export default function Shops(){
  const [shops, setShops] = useState([])

  useEffect(() => {
    const abortController = new AbortController()
    const signal = abortController.signal
    list(signal).then((data) => {
      if (!data.error) {
        setShops(data)
      }
    })
    return function cleanup(){
      abortController.abort()
    }

  }, [])
...
}

Shops 组件视图中,使用 map 迭代检索到的 shops 数组,每个商店的数据在视图中以 Material-UI ListItem 的形式渲染,并且每个 ListItem 也链接到单个商店的视图,如下所示:

mern-marketplace/client/shop/Shops.js:

{shops.map((shop, i) => {
     return <Link to={"/shops/"+shop._id} key={i}>
              <Divider/>
              <ListItem button>
                 <ListItemAvatar>
                    <Avatar src={'/api/shops/logo/'+shop._id+"?" + new Date().getTime()}/>
                 </ListItemAvatar>
                 <div className={classes.details}>
                    <Typography type="headline" 
                        component="h2" color="primary">
                      {shop.name}
                    </Typography>
                    <Typography type="subheading" component="h4">
                      {shop.description}
                    </Typography>
                 </div>
              </ListItem>
              <Divider/>
             </Link>
})}

Shops 组件将由最终用户在 /shops/all 路径下访问,该路径通过 React Router 设置,并在 MainRouter.js 中声明如下:

mern-marketplace/client/MainRouter.js:

 <Route path="/shops/all" component={Shops}/>

将此链接添加到应用程序中的任何视图中,将用户重定向到显示市场内所有商店的视图。接下来,我们将类似地实现列出特定用户拥有的商店的功能。

按所有者列出商店

市场上的授权卖家将看到他们创建的商店列表,他们可以通过编辑或删除列表中的任何商店来管理这些商店。为了实现这个功能,我们必须查询商店集合以检索所有具有相同所有者的商店,并仅向授权的所有者显示。我们通过添加以下全栈切片来实现这一点:

  • 一个后端 API,确保请求用户已授权并检索相关的商店列表

  • 前端的一个fetch方法来请求这个 API

  • 一个 React 组件用于向授权用户显示商店列表

按所有者分组的商店 API

我们将在后端实现一个 API 来返回特定所有者的商店列表,以便在前端渲染给最终用户。我们将从在服务器接收到对/api/shops/by/:userIdGET请求时检索给定用户创建的所有商店的后端路由开始。此路由声明如下所示:

mern-marketplace/server/routes/shop.routes.js:

router.route('/api/shops/by/:userId')
    .get(authCtrl.requireSignin, authCtrl.hasAuthorization, shopCtrl.listByOwner)

对这个路由的GET请求将首先确保请求用户已登录并且也是授权的所有者,然后调用shop.controller.js中的listByOwner控制器方法。此方法将在数据库中查询Shop集合以获取匹配的商店。listByOwner方法定义如下:

mern-marketplace/server/controllers/shop.controller.js:

const listByOwner = async (req, res) => {
   try {
     let shops = await Shop.find({owner: req.profile._id}).populate('owner', '_id name')
     res.json(shops)
   } catch (err){
     return res.status(400).json({
         error: errorHandler.getErrorMessage(err)
     })
   }
}

在查询 Shop 集合时,我们找到所有owner字段与用户指定的userId参数匹配的商店,然后在owner字段中填充引用的用户 ID 和名称,并将结果商店以数组形式返回给客户端。接下来,我们将看到如何从客户端发起对这个 API 的请求。

获取用于视图的用户拥有的所有商店

在前端,为了使用按所有者分组的 API 获取特定用户的商店,我们将添加一个fetch方法,该方法接受已登录用户的凭据,并通过将特定用户 ID 传递到 URL 中,向 API 路由发起GET请求。此fetch方法定义如下:

mern-marketplace/client/shop/api-shop.js:

const listByOwner = async (params, credentials, signal) => {
  try {
    let response = await fetch('/api/shops/by/'+params.userId, {
      method: 'GET',
      signal: signal,
      headers: {
        'Accept': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      }
    })
    return response.json()
  } catch(err){
    console.log(err)
  }
}

使用此方法从服务器返回的响应中的商店可以在 React 组件中渲染,以向授权用户显示商店,如下一节所述。

MyShops 组件

MyShops组件与Shops组件类似。它获取当前用户拥有的商店列表,并如图所示在ListItem中渲染每个商店:

此外,每个商店都有一个编辑删除选项,与Shops中的项目列表不同。MyShops组件的实现与Shops相同,除了以下添加的编辑和删除按钮:

mern-marketplace/client/shop/MyShops.js:

<ListItemSecondaryAction>
   <Link to={"/seller/shop/edit/" + shop._id}>
       <IconButton aria-label="Edit" color="primary">
             <Edit/>
       </IconButton>
   </Link>
   <DeleteShop shop={shop} onRemove={removeShop}/>
</ListItemSecondaryAction>

编辑按钮链接到<q>编辑商店</q>视图,而DeleteShop组件(将在本章后面讨论),处理删除操作。DeleteShop组件通过调用从MyShops传递的removeShop方法来更新列表。这个removeShop方法允许我们使用当前用户的修改后的商店列表更新状态,并在MyShops组件中定义,如下所示:

mern-marketplace/client/shop/MyShops.js:

const removeShop = (shop) => {
    const updatedShops = [...shops]
    const index = updatedShops.indexOf(shop)
    updatedShops.splice(index, 1)
    setShops(updatedShops)
}

MyShops组件只能由已登录的卖家查看。因此,我们将在MainRouter组件中添加一个PrivateRoute,它只为认证用户在/seller/shops上渲染此组件,如下所示代码所示:

mern-marketplace/client/MainRouter.js:

<PrivateRoute path="/seller/shops" component={MyShops}/>

在市场应用程序中,我们在导航菜单中添加此链接,以便将已登录的卖家重定向到他们可以编辑或删除商店以管理他们拥有的商店的视图。在添加编辑或删除商店的能力之前,我们接下来将探讨如何从后端检索单个商店并将其显示给最终用户。

显示商店

访问 MERN 市场的任何用户都将能够浏览每个单独的商店。在以下章节中,我们将通过向后端添加读取商店 API、从前端调用此 API 的方法以及将在视图中显示商店详情的 React 组件来实现单个商店视图。

读取商店 API

为了在后端实现读取商店 API,我们将首先添加一个GET路由,该路由通过 ID 查询Shop集合,并在响应中返回商店。该路由与路由参数处理程序一起声明,如下所示:

mern-marketplace/server/routes/shop.routes.js:

router.route('/api/shop/:shopId')
    .get(shopCtrl.read)
router.param('shopId', shopCtrl.shopByID)

路由 URL 中的:shopId参数将调用shopByID控制器方法,该方法类似于userByID控制器方法。它从数据库中检索商店并将其附加到请求对象中,以便在next方法中使用。shopByID方法定义如下:

mern-marketplace/server/controllers/shop.controller.js:

const shopByID = async (req, res, next, id) => {
  try {
    let shop = await Shop.findById(id).populate('owner', '_id name').exec()
    if (!shop)
      return res.status('400').json({
        error: "Shop not found"
      })
    req.shop = shop
    next()
  } catch (err) {
    return res.status('400').json({
      error: "Could not retrieve shop"
    })
  }
}

从数据库查询到的商店对象也将包含所有者的名称和 ID 详情,正如我们在populate()方法中指定的。然后read控制器方法将这个商店对象作为对客户端的响应返回。read控制器方法定义如下所示:

mern-marketplace/server/controllers/shop.controller.js:

const read = (req, res) => {
  req.shop.image = undefined
  return res.json(req.shop)
}

在发送响应之前,我们正在删除图像字段,因为图像将通过单独的路由作为文件检索。有了这个后端 API 就绪,你现在可以添加在api-shop.js中调用它的实现,类似于已为其他 API 实现添加的其他fetch方法。我们将使用fetch方法在将渲染商店详情的 React 组件中调用读取商店 API,如下一节所述。

商店组件

Shop 组件将渲染商店详情,并使用产品列表组件列出指定商店中的产品,这将在 产品 部分进行讨论。完成的单个 Shop 视图将如图所示:

为了实现这个 Shop 组件,我们首先会在 useEffect 钩子中使用 fetch 调用读取 API 来检索商店详情,并将接收到的值设置到状态中,如下面的代码所示:

mern-marketplace/client/shop/Shop.js:

export default function Shop({match}) {
  const [shop, setShop] = useState('')
  const [error, setError] = useState('')

  useEffect(() => {
    const abortController = new AbortController()
    const signal = abortController.signal

    read({
      shopId: match.params.shopId
    }, signal).then((data) => {
      if (data.error) {
        setError(data.error)
      } else {
        setShop(data)
      }
    })
    return function cleanup(){
      abortController.abort()
    }

  }, [match.params.shopId])
...
}

这个 useEffect 钩子仅在路由参数中的 shopId 发生变化时运行。

获取到的商店数据被设置到状态中,并在视图中渲染以显示商店的名称、标志和描述,如下面的代码所示:

mern-marketplace/client/shop/Shop.js:

<CardContent>
  <Typography type="headline" component="h2">
    {shop.name}
  </Typography><br/>
  <Avatar src={logoUrl}/><br/>
  <Typography type="subheading" component="h2">
    {shop.description}
  </Typography><br/>
</CardContent>

logoUrl 指向从数据库中检索标志图像的路由(如果图像存在),其定义如下:

mern-marketplace/client/shop/Shop.js:

const logoUrl = shop._id
          ? `/api/shops/logo/${shop._id}?${new Date().getTime()}`
          : '/api/shops/defaultphoto'

Shop 组件将通过浏览器中的 /shops/:shopId 路由进行访问,该路由在 MainRouter 中定义如下:

mern-marketplace/client/MainRouter.js:

<Route path="/shops/:shopId" component={Shop}/>

这个路由可以在任何组件中使用,以链接到特定的商店,并且这个链接将用户带到加载了商店详细信息的相应 Shop 视图。在下一节中,我们将添加允许商店所有者编辑这些商店详细信息的功能。

编辑商店

应用程序中的授权卖家将能够更新他们已经添加到市场中的商店。为了实现这一功能,我们需要创建一个后端 API,允许在确认请求用户已认证并授权后对特定商店进行更新操作。然后需要从前端调用这个更新后的 API,并传入商店更改的详细信息。在接下来的章节中,我们将构建这个后端 API 和 React 组件,以便卖家可以更改他们的商店信息。

商店编辑 API

在后端,我们需要一个 API,允许如果请求用户是给定商店的授权卖家,则更新数据库中的现有商店。我们首先声明接受客户端更新请求的 PUT 路由如下:

mern-marketplace/server/routes/shop.routes.js:

router.route('/api/shops/:shopId')
    .put(authCtrl.requireSignin, shopCtrl.isOwner, shopCtrl.update)

/api/shops/:shopId 路由接收到的 PUT 请求首先检查已登录用户是否是 URL 中提供的 shopId 相关商店的所有者,使用 isOwner 控制器方法,该方法定义如下:

mern-marketplace/server/controllers/shop.controller.js:

const isOwner = (req, res, next) => {
  const isOwner = req.shop && req.auth 
                           && req.shop.owner._id == req.auth._id
  if(!isOwner){
    return res.status('403').json({
      error: "User is not authorized"
    })
  }
  next()
}

在这个方法中,如果发现用户是授权的,则通过调用 next() 来调用 update 控制器。

update控制器方法将使用与前面讨论的create控制器方法中相同的formidablefs模块来解析表单数据并更新数据库中的现有商店。商店控制器中的update方法定义如下所示:

mern-marketplace/server/controllers/shop.controller.js:

const update = (req, res) => {
  let form = new formidable.IncomingForm()
  form.keepExtensions = true
  form.parse(req, async (err, fields, files) => {
    if (err) {
      res.status(400).json({
        message: "Photo could not be uploaded"
      })
    }
    let shop = req.shop
    shop = extend(shop, fields)
    shop.updated = Date.now()
    if(files.image){
      shop.image.data = fs.readFileSync(files.image.path)
      shop.image.contentType = files.image.type
    }
    try {
      let result = await shop.save()
      res.json(result)
    } catch (err){
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
  })
}

要在前端使用此更新 API,您需要定义一个fetch方法,该方法接受商店 ID、用户认证凭据和更新的商店详情,以对该更新商店 API 进行 fetch 调用,就像我们在创建新商店部分中为其他 API 实现所做的那样。

现在我们有一个可以用于前端更新商店详情的商店更新 API。我们将在下一节讨论的EditShop组件中使用此 API。

编辑商店组件

EditShop组件将显示一个类似于创建新商店表单的表单,预先填充了现有商店的详情。此组件还将显示此商店的产品列表,将在产品部分讨论。完成的编辑商店视图如图所示:

图片

此视图的表单部分用于编辑商店详情,与NewShop组件中的表单类似,具有相同的表单字段和一个formData对象,该对象包含要随update fetch方法发送的多部分表单数据。与NewShop组件相比,在此组件中,我们需要利用读取商店 API 在useEffect钩子中获取指定商店的详情并预先填充表单字段。您可以将针对NewShop组件和Shop组件讨论的实现结合起来,以完成EditShop组件。

EditShop组件只能由授权的商店所有者访问。因此,我们将在MainRouter组件中添加一个PrivateRoute,如以下所示,它将只为经过认证的用户在/seller/shop/edit/:shopId渲染此组件:

mern-marketplace/client/MainRouter.js:

<PrivateRoute path="/seller/shop/edit/:shopId" component={EditShop}/>

MyShops组件中,为每个商店添加了一个编辑图标,允许卖家访问他们每个商店的编辑页面。在MyShops视图中,卖家还可以删除他们的商店,如下一节所述。

删除商店

作为管理他们拥有的商店的一部分,授权卖家将有权删除他们自己的任何商店。为了允许卖家从市场移除商店,在以下章节中,我们首先将定义一个从数据库中删除商店的后端 API,然后实现一个 React 组件,当用户与前端交互以执行此删除时,该组件将使用此 API。

删除商店的 API

为了从数据库中删除商店,我们将在后端实现一个删除商店 API,该 API 将接受客户端在/api/shops/:shopId上的 DELETE 请求。我们将为这个 API 添加以下代码所示的DELETE路由,这将允许授权卖家删除他们自己的商店之一:

mern-marketplace/server/routes/shop.routes.js:

router.route('/api/shops/:shopId')
    .delete(authCtrl.requireSignin, shopCtrl.isOwner, shopCtrl.remove)

当接收到此路由的 DELETE 请求时,如果isOwner方法确认已登录的用户是该商店的所有者,那么remove控制器方法将删除由参数中的shopId指定的商店。remove方法定义如下:

mern-marketplace/server/controllers/shop.controller.js:

const remove = async (req, res) => {
  try {
    let shop = req.shop
    let deletedShop = shop.remove()
    res.json(deletedShop)
  } catch (err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  } 
}

这个remove方法简单地从数据库中的Shops集合中删除与提供的 ID 相对应的商店文档。为了在前端访问这个后端 API,你还需要一个具有此路由的fetch方法,类似于其他 API 实现。fetch方法需要获取商店 ID 和当前用户的认证凭证,然后使用这些值调用删除商店 API。

当用户在前端界面中点击按钮执行删除操作时,将使用fetch方法。在下一节中,我们将讨论一个名为DeleteShop的 React 组件,用户将通过该组件执行删除商店操作。

删除商店组件

DeleteShop组件被添加到列表中的每个商店的MyShops组件中。它从MyShops接收shop对象和onRemove方法作为 props。此组件基本上是一个按钮,当点击时,会打开一个Dialog组件,提示用户确认删除操作,如下面的截图所示:

图片

DeleteShop组件的实现类似于在第四章中讨论的DeleteUser组件,即添加 React 前端以完成 MERN。当它添加到MyShops时,DeleteShop组件将从MyShops组件接收shop对象和onRemove函数定义作为 props,如下所示:

mern-marketplace/client/shop/MyShops.js:

<DeleteShop shop={shop} onRemove={removeShop}/>

通过这种实现,授权卖家将能够从市场上删除他们拥有的商店。

我们通过首先定义用于存储商店数据的 Shop 模型,然后集成后端 API 和前端视图,以便能够在应用程序中执行对商店的 CRUD 操作,从而实现了市场中的商店模块。这些商店功能,包括创建新商店、显示商店、编辑和删除商店的能力,将允许买家和卖家与市场中的商店进行交互。商店还将拥有以下讨论的产品,所有者将负责管理,买家将能够浏览,并可以选择将产品添加到购物车中。

将产品添加到商店

产品是市场应用中最关键的部分。在 MERN 市场中,卖家可以管理他们店铺中的产品,访客可以搜索和浏览产品。虽然我们将实现允许授权卖家添加、修改和删除他们店铺中产品的功能,但我们还将整合对最终用户有意义的列出产品的方式。在应用中,我们将通过特定店铺、与给定产品相关联的产品以及最新添加到市场中的产品来检索和显示产品。在接下来的章节中,我们将通过首先定义用于在数据库中存储产品数据的 product 模型,然后实现与产品相关功能的后端 API 和前端视图来构建产品模块,包括向店铺添加新产品、渲染不同的产品列表、显示单个产品、编辑产品和删除产品。

定义产品模型

产品将存储在数据库中的产品集合中。为了实现这一点,我们将添加一个 Mongoose 模型来定义一个 Product 模型,用于存储每个产品的详细信息。此模型将在 server/models/product.model.js 中定义,其实现将与之前章节中覆盖的其他 Mongoose 模型实现类似,如第六章构建基于 Web 的课堂应用中定义的课程模型。

对于 MERN 市场来说,我们将保持产品架构简单,支持 namedescriptionimagecategoryquantitypricecreated atupdated at 以及对店铺的引用等字段。定义产品架构中产品字段的代码如下,以及相应的解释:

  • 产品名称和描述namedescription 字段将被定义为 String 类型,其中 name 是一个 required 字段:
name: { 
    type: String, 
    trim: true, 
    required: 'Name is required' 
},
description: { 
    type: String, 
    trim: true 
},
  • 产品图片image 字段将存储用户上传到 MongoDB 数据库中的图片文件:
image: { 
    data: Buffer, 
    contentType: String 
},
  • 产品类别category 值将允许将相同类型的产品分组在一起:
category: { 
    type: String 
},
  • 产品数量quantity 字段将表示店铺中可供销售的产品的数量:
quantity: { 
    type: Number, 
    required: "Quantity is required" 
},
  • 产品价格price 字段将保存此产品将花费买家的单价:
price: { 
    type: Number, 
    required: "Price is required" 
},
  • 产品店铺shop 字段将引用添加产品的店铺:
shop: {
    type: mongoose.Schema.ObjectId, 
    ref: 'Shop'
}
  • 创建和更新时间createdupdated 字段将被定义为 Date 类型,created 字段在添加新产品时生成,而 updated 时间在修改产品详情时改变:
updated: Date,
created: { 
    type: Date, 
    default: Date.now 
},

此架构定义中的字段将使我们能够在 MERN 市场中实现产品相关功能。为了开始这些功能的实现,在下一节中,我们将实现一个全栈切片,允许卖家向他们在市场中的现有店铺添加新产品。

创建新产品

MERN 市场中的卖家将能够向他们在平台上拥有的商店添加新产品。为了实现此功能,在接下来的章节中,我们将在后台添加创建产品 API,以及在前端获取此 API 的方法,还有一个用于收集用户输入的产品字段的新产品表单视图。

创建产品 API

我们将添加一个后端 API,允许授权的店主通过客户端的POST请求将新产品保存到数据库中。为了在后台实现这个创建产品 API,我们首先将在/api/products/by/:shopId路径下添加一个路由,该路由接受包含产品数据的POST请求。向此路由发送请求将创建一个与:shopId参数指定的商店相关联的新产品。此创建产品 API 路由的声明如下所示:

mern-marketplace/server/routes/product.routes.js:

router.route('/api/products/by/:shopId')
  .post(authCtrl.requireSignin, shopCtrl.isOwner, productCtrl.create)
router.param('shopId', shopCtrl.shopByID)

包含此路由声明的product.routes.js文件将与shop.routes.js文件非常相似,为了在 Express 应用中加载这些新路由,我们需要在express.js中挂载产品路由,如下所示:

mern-marketplace/server/express.js:

app.use('/', productRoutes)

处理创建产品 API 路由请求的代码将首先检查当前用户是否为新产品将被添加的商店的所有者,然后在数据库中创建新产品。此 API 利用来自商店控制器的shopByIDisOwner方法来处理:shopId参数,并在调用create控制器方法之前验证当前用户是否是商店所有者。create方法定义如下:

mern-marketplace/server/controllers/product.controller.js:

const create = (req, res, next) => {
  let form = new formidable.IncomingForm()
  form.keepExtensions = true
  form.parse(req, async (err, fields, files) => {
    if (err) {
      return res.status(400).json({
        message: "Image could not be uploaded"
      })
    }
    let product = new Product(fields)
    product.shop= req.shop
    if(files.image){
      product.image.data = fs.readFileSync(files.image.path)
      product.image.contentType = files.image.type
    }
    try {
      let result = await product.save()
      res.json(result)
    } catch (err){
      return res.status(400).json({
        error: errorHandler.getErrorMessage(err)
      })
    }
  })
}

在产品控制器中,此create方法使用formidable节点模块来解析可能包含用户上传的图像文件以及产品字段的 multipart 请求。然后,解析后的数据被保存到产品集合中作为新产品。

在前端,为了使用此创建产品 API,你还需要在client/product/api-product.js中设置一个fetch方法,通过传递从视图中的 multipart 表单数据来向创建 API 发送POST请求。然后,此fetch方法可以在 React 组件中使用,该组件从用户那里获取产品详情并发送请求以创建新产品。基于此表单的 React 组件创建新产品的实现将在下一节中讨论。

新产品组件

在市场平台上已经创建店铺的授权卖家将看到一个用于添加新产品的表单视图。我们将在这个名为 NewProduct 的 React 组件中实现这个表单视图。NewProduct 组件将与 NewShop 组件类似。它将包含一个表单,允许卖家通过输入名称、描述、类别、数量和价格来创建产品,并从他们的本地文件系统中上传产品图片文件,如图下截图所示:

图片

NewProduct 组件可以几乎与 NewShop 组件完全相同地实现,唯一的区别是从渲染 NewProduct 组件的前端路由 URL 中检索店铺 ID。此组件将在与特定店铺关联的路由上加载,因此只有登录的卖家才能向他们拥有的店铺添加产品。为了定义此路由,我们在 MainRouter 组件中添加了一个 PrivateRoute,如下所示,它将只为授权用户在 URL '/seller/:shopId/products/new' 上渲染此表单:

mern-marketplace/client/MainRouter.js:

<PrivateRoute path="/seller/:shopId/products/new" component={NewProduct}/>

在前端视图中的任何地方添加此特定店铺的链接将渲染 NewProduct 组件供登录用户使用。在这个视图中,用户将能够填写表单中的新产品详细信息,然后如果他们是给定店铺的授权所有者,将产品保存到后端数据库中。接下来,我们将探讨检索和在不同列表中显示这些产品的实现方法。

列出产品

在 MERN 市场平台上,产品将以多种方式向用户展示。两个主要区别在于产品对于卖家和买家的列出方式。在以下章节中,我们将了解如何为卖家和买家列出店铺中的产品,然后还将讨论如何列出为买家提供的产品建议,包括与特定产品相关的产品以及最新添加到市场平台的产品。

按店铺列出

市场平台的访客将浏览每个店铺中的产品,卖家将管理他们每个店铺中的产品列表。这两个功能将共享相同的后端 API,该 API 将检索特定店铺的所有产品,但将为两种类型的用户以不同的方式渲染。在以下章节中,首先,我们将实现用于检索特定店铺中产品的后端 API。然后,我们将使用该 API 在两个不同的 React 组件中渲染产品列表,一个用于店铺的卖家,另一个用于买家。

店铺产品 API

为了实现从数据库中检索特定店铺产品的后端 API,我们将在 /api/products/by/:shopId 上设置一个 GET 路由,如下面的代码所示:

mern-marketplace/server/routes/product.routes.js:

router.route('/api/products/by/:shopId')
    .get(productCtrl.listByShop)

对此请求执行listByShop控制器方法将查询产品集合以返回与给定商店引用匹配的产品。listByShop方法定义如下代码所示:

mern-marketplace/server/controllers/product.controller.js:

const listByShop = async (req, res) => {
  try {
    let products = await Product.find({shop: req.shop._id})
                          .populate('shop', '_id name').select('-image')
    res.json(products)
  } catch (err) {
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

结果产品数组中的每个产品都将包含相关商店的名称和 ID 详情,我们将省略image字段,因为可以通过单独的 API 路由检索图像。

在前端,为了使用按商店列表的 API 获取特定商店的产品,我们还需要在api-product.js中添加一个fetch方法,类似于我们的其他 API 实现。然后,可以在任何 React 组件中调用fetch方法来渲染产品,例如,在下一节中讨论的显示给所有买家的商店中的产品。

买家产品组件

我们将构建一个Products组件,主要用于向可能购买产品的访客展示产品。我们可以在整个应用程序中重用此组件以渲染与买家相关的不同产品列表。它将从显示产品列表的父组件接收产品列表作为 props。渲染后的产品视图可能看起来如下截图所示:

图片

在市场应用程序中,商店中的产品列表将以单独的Shop视图的形式显示给用户。因此,此Products组件被添加到Shop组件中,并提供了相关产品的列表作为 props,如下所示:

mern-marketplace/client/shop/Shop.js:

<Products products={products} searched={false}/></Card>

searched prop 传递了此列表是否是产品搜索的结果,因此可以渲染适当的消息。

Shop组件中,我们需要在useEffect钩子中添加对listByShop fetch 方法的调用以检索相关产品并将其设置到状态中,如下所示代码所示:

mern-marketplace/client/shop/Shop.js:

useEffect(() => {
    const abortController = new AbortController()
    const signal = abortController.signal

    listByShop({
      shopId: match.params.shopId
    }, signal).then((data)=>{
      if (data.error) {
        setError(data.error)
      } else {
        setProducts(data)
      }
    })

    return function cleanup(){
      abortController.abort()
    }
}, [match.params.shopId])

Products组件中,如果通过 props 传入的产品列表包含产品,则会遍历列表,并在 Material-UI 的GridListTile中渲染每个产品的相关详情,包括指向单个产品视图的链接以及AddToCart组件(其实现将在第八章[7514f26d-29e1-46e2-ac46-7515b2c3a6d0.xhtml]中讨论,扩展订单和支付的市场。以下是添加渲染产品列表的代码:

mern-marketplace/client/product/Products.js:

{props.products.length > 0 ?
    (<div>
       <GridList cellHeight={200} cols={3}>
           {props.products.map((product, i) => (
            <GridListTile key={i}>
              <Link to={"/product/"+product._id}>
                <img src={'/api/product/image/'+product._id}
                     alt={product.name} />
              </Link>
              <GridListTileBar
                title={<Link to={"/product/"+product._id}>
                    {product.name}</Link>}
                subtitle={<span>$ {product.price} </span>}
                actionIcon={
                  <AddToCart item={product}/>
                }
              />
            </GridListTile>))
           }
       </GridList>
    </div>) : props.searched && (<Typography component="h4">
                                    No products found! :(</Typography>)}

如果在 props 中发送的products数组被发现为空,并且这是用户搜索操作的结果,我们将渲染一条适当的消息来通知用户没有找到产品。

这个 Products 组件可以用来渲染不同类型的买家产品列表,包括商店中的产品、按类别划分的产品以及搜索结果中的产品。在下一节中,我们将实现一个 MyProducts 组件,它将只为商店老板渲染产品列表,为他们提供一组不同的交互选项。

为商店老板的 MyProducts 组件

Products 组件不同,client/product/MyProducts.js 中的 MyProducts 组件仅用于向卖家展示产品,以便他们可以管理他们拥有的每个商店中的产品,并且将如图所示显示给最终用户:

如下代码所示,将 MyProducts 组件添加到 EditShop 视图中,以便卖家可以在一个地方管理商店及其内容。它通过属性提供商店的 ID,以便可以获取相关产品:

mern-marketplace/client/shop/EditShop.js:

<MyProducts shopId={match.params.shopId}/>

MyProducts 中,相关产品首先使用 listByShop 获取方法,通过 useEffect 钩子加载到一个状态,如下面的代码所示:

mern-marketplace/client/product/MyProducts.js:

export default function MyProducts (props){
  const [products, setProducts] = useState([])
  useEffect(() => {
    const abortController = new AbortController()
    const signal = abortController.signal
    listByShop({
      shopId: props.shopId
    }, signal).then((data)=>{
      if (data.error) {
        console.log(data.error)
      } else {
        setProducts(data)
      }
    })
    return function cleanup(){
      abortController.abort()
    }
  }, [])
...
}

此产品列表随后被迭代,每个产品都在 ListItem 组件中渲染,并附带编辑和删除选项,类似于 MyShops 列表视图。编辑按钮链接到编辑产品视图。DeleteProduct 组件处理删除操作,并通过调用从 MyProducts 传递的 onRemove 方法来重新加载列表,以更新当前商店的产品列表状态。

MyProducts 中定义的 removeProduct 方法作为 onRemove 属性传递给 DeleteProduct 组件。removeProduct 方法定义如下:

mern-marketplace/client/product/MyProducts.js:

const removeProduct = (product) => {
    const updatedProducts = [...products]
    const index = updatedProducts.indexOf(product)
    updatedProducts.splice(index, 1)
    setProducts(updatedProducts)
}   

然后当它被添加到 MyProducts 中时,作为属性传递给 DeleteProduct 组件,如下所示:

mern-marketplace/client/product/MyProducts.js:

<DeleteProduct
       product={product}
       shopId={props.shopId}
       onRemove={removeProduct}/>

以这种方式实现一个单独的 MyProducts 组件,使商店老板能够查看他们商店中的产品列表,并可选择编辑和删除每个产品。在下一节中,我们将完成从后端检索不同类型产品列表的实现,并在前端将它们作为买家产品建议渲染。

列出产品建议

访问 MERN 市场的访客将看到产品建议,例如最新添加到市场中的产品以及与他们当前查看的产品相关的产品。在接下来的几节中,我们将首先查看获取最新产品和给定产品相关产品列表的后端 API 实现,然后实现一个名为 Suggestions 的 React 组件来渲染这些产品列表。

最新产品

在 MERN Marketplace 的主页上,我们将显示最近添加到市场的五个最新产品。为了获取最新产品,我们将设置一个后端 API,该 API 将在/api/products/latest接收GET请求,如下所示:

mern-marketplace/server/routes/product.routes.js:

router.route('/api/products/latest')
      .get(productCtrl.listLatest)

在此路由接收到的GET请求将调用listLatest控制器方法。此方法将找到所有产品,按数据库中产品的created日期字段从新到旧排序产品列表,并在响应中返回排序列表中的前五个。此listLatest控制器方法定义如下:

mern-marketplace/server/controllers/product.controller.js:

const listLatest = async (req, res) => {
  try {
    let products = await Product.find({}).sort('-created')
         .limit(5).populate('shop', '_id name').exec()
    res.json(products)
  } catch (err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

要在前端使用此 API,您还需要在api-product.js中设置相应的fetch方法,用于此最新产品 API,类似于其他 API 实现。然后,检索到的列表将被渲染在Suggestions组件中,以添加到主页。接下来,我们将讨论用于检索相关产品列表的类似 API。

相关产品

在每个单个产品视图中,我们将展示五个相关产品作为建议。为了检索这些相关产品,我们将设置一个后端 API,该 API 在/api/products/related接收请求,如下所示。

mern-marketplace/server/routes/product.routes.js:

router.route('/api/products/related/:productId')
              .get(productCtrl.listRelated)
router.param('productId', productCtrl.productByID)

路由 URL 中的:productId参数将调用productByID控制器方法,该方法类似于shopByID控制器方法,从数据库中检索产品并将其附加到请求对象中,以便在next方法中使用。productByID控制器方法定义如下:

mern-marketplace/server/controllers/product.controller.js:

const productByID = async (req, res, next, id) => {
  try {
    let product = await Product.findById(id)
       .populate('shop', '_id  name').exec()
    if (!product)
      return res.status('400').json({
        error: "Product not found"
      })
    req.product = product
    next()
  } catch (err) {
    return res.status('400').json({
      error: "Could not retrieve product"
    })
  }
}

一旦检索到产品,就会调用listRelated控制器方法。此方法查询数据库中的Product集合,以找到与给定产品具有相同类别的其他产品(不包括给定产品),并返回结果列表中的前五个产品。此listRelated控制器方法定义如下:

mern-marketplace/server/controllers/product.controller.js:

const listRelated = async (req, res) => {
  try{
    let products = await Product.find({ "_id": { "$ne": req.product }, 
         "category": req.product.category})
             .limit(5).populate('shop', '_id name').exec()
    res.json(products)
  } catch (err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

为了在前端利用此相关产品 API,我们将在api-product.js中设置相应的fetch方法。fetch方法将在Product组件中使用产品 ID 调用,以填充产品视图中渲染的Suggestions组件。我们将在下一节查看此Suggestions组件的实现。

建议组件

Suggestions组件将在主页和单个产品页面上渲染,分别显示最新产品和相关产品。一旦渲染,Suggestions组件可能看起来如下所示:

图片

此组件将从父组件接收相关的产品列表作为 props,以及列表的标题:

<Suggestions  products={suggestions} title={suggestionTitle}/>

Suggestions 组件中,遍历接收到的列表,并使用相关详细信息、单个产品页面链接和 AddToCart 组件渲染单个产品,如下所示。

mern-marketplace/client/product/Suggestions.js:

<Typography type="title"> {props.title} </Typography>
{props.products.map((item, i) => { 
  return <span key={i}> 
           <Card>
             <CardMedia image={'/api/product/image/'+item._id} 
                        title={item.name}/>
                <CardContent>
                   <Link to={'/product/'+item._id}>
                     <Typography type="title" component="h3">
                    {item.name}</Typography>
                   </Link>
                   <Link to={'/shops/'+item.shop._id}>
                     <Typography type="subheading">
                        <Icon>shopping_basket</Icon> {item.shop.name}
                     </Typography>
                   </Link>
                   <Typography component="p">
                      Added on {(new 
                     Date(item.created)).toDateString()}
                   </Typography>
                </CardContent>
                <Typography type="subheading" component="h3">$ 
                 {item.price}</Typography>
                <Link to={'/product/'+item._id}>
                  <IconButton color="secondary" dense="dense">
                    <ViewIcon className={classes.iconButton}/>
                  </IconButton>
                </Link>
                <AddToCart item={item}/>
           </Card>
         </span>})}

这个 Suggestions 组件可以被重用来向买家渲染任何产品列表。在本节中,我们讨论了如何检索和显示两个不同的产品列表。列表中的每个产品都链接到一个视图,该视图将渲染单个产品的详细信息。在下一节中,我们将查看读取和向最终用户显示单个产品的实现。

显示产品

访问 MERN 市场的访客将能够查看每个产品的更多详细信息。在以下章节中,我们将实现一个后端 API 来从数据库中检索单个产品,然后在前端使用它来在 React 组件中渲染单个产品。

读取产品 API

在后端,我们将添加一个带有 GET 路由的 API,该路由通过 ID 查询产品集合并返回响应中的产品。该路由的声明如下所示:

mern-marketplace/server/routes/product.routes.js:

router.route('/api/products/:productId')
      .get(productCtrl.read) 

URL 中的 :productId 参数调用 productByID 控制器方法,该方法从数据库中检索产品并将其附加到请求对象中。请求对象中的产品被 read 控制器方法用于响应 GET 请求。read 控制器方法定义如下:

mern-marketplace/server/controllers/product.controller.js:

const read = (req, res) => {
  req.product.image = undefined
  return res.json(req.product)
}

要在前端使用这个读取产品 API,我们需要在 client/product/api-product.js 中添加一个 fetch 方法,类似于其他 API 实现。然后这个 fetch 方法可以在 React 组件中使用,该组件将渲染单个产品详细信息,如下一节所述。

产品组件

我们将添加一个名为 Product 的 React 组件来渲染单个产品的详细信息,并提供添加到购物车的选项。在这个单个产品视图中,我们还将显示相关产品的列表,如图所示:

Product 组件可以通过 /product/:productID 路由在浏览器中访问,该路由在 MainRouter 中定义如下:

mern-marketplace/client/MainRouter.js:

<Route path="/product/:productId" component={Product}/>

通过使用 useEffect 钩子调用相关 API 并使用路由参数中指定的 productId 来获取产品详情和相关产品列表数据,如下所示:

mern-marketplace/client/product/Product.js:

export default function Product ({match}) {
  const [product, setProduct] = useState({shop:{}})
  const [suggestions, setSuggestions] = useState([])
  const [error, setError] = useState('')
    useEffect(() => {
      const abortController = new AbortController()
      const signal = abortController.signal

      read({productId: match.params.productId}, signal).then((data) => {
        if (data.error) {
          setError(data.error)
        } else {
          setProduct(data)
        }
      })
    return function cleanup(){
      abortController.abort()
    }
  }, [match.params.productId])

  useEffect(() => {
    const abortController = new AbortController()
    const signal = abortController.signal

        listRelated({
          productId: match.params.productId}, signal).then((data) => {
          if (data.error) {
            setError(data.error)
          } else {
            setSuggestions(data)
          }
        })
  return function cleanup(){
    abortController.abort()
  }
}, [match.params.productId])

在第一个 useEffect 钩子中,我们调用 read API 来检索指定的产品并将其设置到状态中。在第二个钩子中,我们调用 listRelated API 来获取相关产品的列表并将其设置到要作为属性传递给在产品视图中添加的 Suggestions 组件的状态中。

组件的产品详细信息部分显示有关产品的相关信息以及一个 Material-UI Card组件中的AddToCart组件,如下面的代码所示:

mern-marketplace/client/product/Product.js:

<Card>
  <CardHeader
    action={<AddToCart cartStyle={classes.addCart} 
    item={product}/>}
    title={product.name}
    subheader={product.quantity > 0? 'In Stock': 'Out of   
   Stock'}
  />
  <CardMedia image={imageUrl} title={product.name}/>
  <Typography component="p" variant="subtitle1">
    {product.description}<br/>
    $ {product.price}
    <Link to={'/shops/'+product.shop._id}>
      <Icon>shopping_basket</Icon> {product.shop.name}
    </Link>
  </Typography>
</Card>

在“产品”视图中添加了建议组件,该组件通过属性传递相关列表数据,如下所示:

mern-marketplace/client/product/Product.js:

<Suggestions products={suggestions} title='Related Products'/>

完成此视图后,市场应用程序的访客将能够了解更多关于特定产品的信息,以及探索其他类似的产品。在下一节中,我们将讨论如何为店主添加编辑和删除他们添加到市场中的产品的能力。

编辑和删除产品

在应用程序中编辑和删除产品的方法与编辑和删除商店的方法类似,如前几节所述,编辑商店删除商店。这些功能将需要在后端使用相应的 API、在前端使用获取方法,以及带有表单和操作的 React 组件视图。在以下章节中,我们将突出显示编辑和从市场删除产品的前端视图、路由和后端 API 端点。

编辑

编辑功能与我们之前实现的创建产品功能非常相似。可以实现的EditProduct表单组件,可以渲染一个允许修改产品详细信息的表单,也仅对经过验证的卖家在/seller/:shopId/:productId/edit处可访问。

要限制对此视图的访问,我们可以在MainRouter中添加一个PrivateRoute来声明指向EditProduct视图的路由,如下所示:

mern-marketplace/client/MainRouter.js:

<PrivateRoute path="/seller/:shopId/:productId/edit" component={EditProduct}/>

EditProduct组件包含与NewProduct相同的表单,但使用读取产品 API 检索的产品值进行填充。在表单提交时,它使用fetch方法通过 PUT 请求将多部分表单数据发送到后端在/api/products/by/:shopId处的编辑产品 API。此编辑产品 API 的后端路由声明如下:

mern-marketplace/server/routes/product.routes.js:

router.route('/api/product/:shopId/:productId')
      .put(authCtrl.requireSignin, shopCtrl.isOwner, productCtrl.update)

当授权用户向此 API 发送 PUT 请求时,将调用update控制器方法。它与产品create方法和商店update方法类似。它使用formidable处理多部分表单数据,并将产品详细信息扩展以保存到数据库中的更新。

此编辑产品表单视图的实现与后端更新 API 集成,将允许店主修改他们商店中产品的详细信息。接下来,我们将探讨将产品删除功能集成到应用程序中的重点。

删除

为了实现删除产品功能,我们可以实现一个类似于DeleteShop组件的DeleteProduct组件,并将其添加到MyProducts组件中,为列表中的每个产品。它可以从MyProducts组件中作为属性接收product对象、shopIDonRemove方法,如为店主提供的MyProducts组件部分所述。

组件将像DeleteShop一样工作,在按钮点击时打开一个确认对话框,然后,当用户确认删除意图后,调用用于删除的fetch方法,该方法向服务器在/api/product/:shopId/:productId处发出 DELETE 请求。此从数据库删除产品的后端 API 将如下声明,与其他产品路由一起声明:

mern-marketplace/server/routes/product.routes.js:

router.route('/api/product/:shopId/:productId')
      .delete(authCtrl.requireSignin, shopCtrl.isOwner, productCtrl.remove)

如果授权用户向此 API 发出 DELETE 请求,则将调用remove控制器方法,并从数据库中删除指定的产品,就像为商店的remove控制器方法一样。

我们在本节中开始实现市场产品相关功能,首先定义一个用于存储产品详情的模式,然后讨论创建、列出、读取、更新和删除应用程序中产品的全栈切片。在下一节中,我们将探讨如何允许市场中的用户以不同的方式搜索产品,以便他们可以轻松找到他们想要的产品。

通过名称和类别搜索产品

在 MERN 市场,访客将能够通过名称和特定类别搜索特定产品。在接下来的章节中,我们将讨论如何通过首先查看从产品集合中检索独特类别的后端 API,并对存储的产品执行搜索查询来添加此搜索功能。然后,我们将讨论利用这些 API 的不同情况,例如执行搜索操作的视图和按类别显示产品的视图。

类别 API

为了允许用户选择一个特定的类别进行搜索,我们首先设置一个 API,该 API 从数据库中产品集合检索所有独特的类别。对/api/products/categoriesGET请求将返回一个唯一类别的数组,并且此路由如以下所示声明:

mern-marketplace/server/routes/product.routes.js:

router.route('/api/products/categories')
      .get(productCtrl.listCategories)

listCategories控制器方法使用以下代码对Products集合执行针对category字段的distinct调用:

mern-marketplace/server/controllers/product.controller.js:

const listCategories = async (req, res) => {
  try {
    let products = await Product.distinct('category',{})
    res.json(products)
  } catch (err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

此类别 API 可以在前端使用相应的fetch方法检索独特类别的数组并在视图中显示。这可以与搜索 API 配对,允许用户在特定类别中通过其名称搜索产品。在下一节中,我们将讨论此搜索 API。

搜索产品 API

我们可以定义一个搜索产品的 API,该 API 将接受一个GET请求,URL 为/api/products?search=value&category=value,其中 URL 中的查询参数用于查询包含提供的搜索文本和类别值的 Products 集合。此搜索 API 的路由定义如下:

mern-marketplace/server/routes/product.routes.js:

router.route('/api/products')
      .get(productCtrl.list)

list控制器方法首先处理请求中的查询参数,然后在给定类别中查找具有与提供的搜索文本部分匹配的名称的产品(如果有的话)。此list方法定义如下:

mern-marketplace/server/controllers/product.controller.js:

const list = async (req, res) => {
  const query = {}
  if(req.query.search)
    query.name = {'$regex': req.query.search, '$options': "i"}
  if(req.query.category && req.query.category != 'All')
    query.category = req.query.category
  try {
    let products = await Product.find(query)
                                .populate('shop', '_id name')
                                .select('-image').exec()
    res.json(products)
  } catch (err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

根据请求中提供的查询参数返回的结果产品将填充商店详情,并通过删除图像字段值进行缩小,然后作为响应发送回。为了在前端使用此 API 执行产品搜索,我们需要一个可以构造请求 URL 中查询参数的fetch方法,如下一节所述。

获取视图的搜索结果

为了在前端使用此搜索 API,我们将设置一个方法来构造带有查询参数的 URL,并调用fetch来向搜索产品 API 发起请求。此fetch方法定义如下。

mern-marketplace/client/product/api-product.js:

import queryString from 'query-string'
const list = (params) => {
  const query = queryString.stringify(params)
  return fetch('/api/products?'+query, {
    method: 'GET',
  }).then(response => {
    return response.json()
  }).catch((err) => console.log(err))
}

为了以正确的格式构造查询参数,我们将使用query-string节点模块,它将帮助将 params 对象转换为可以附加到请求路由 URL 的查询字符串。此 params 对象中的键和值将由调用此list方法的 React 组件定义。接下来,我们将查看Search组件,该组件将利用此方法使最终用户能够在市场上搜索产品。

搜索组件

将类别 API 和搜索 API 结合使用以执行搜索操作的第一个用例是在Search组件中。一旦实现并功能化,该组件将如图所示:

Search组件为用户提供了一个简单的表单,包含一个搜索输入文本字段和一个来自父组件的类别选项下拉菜单,该父组件将使用不同的类别 API 检索列表。渲染此搜索表单视图的代码如下:

mern-marketplace/client/product/Search.js:

<TextField id="select-category" select label="Select category" value={category}
     onChange={handleChange('category')}
     selectProps={{ MenuProps: { className: classes.menu, } }}>
  <MenuItem value="All"> All </MenuItem>
  {props.categories.map(option => (
    <MenuItem key={option} value={option}> {option} </MenuItem>
        ))}
</TextField>
<TextField id="search" label="Search products" type="search" onKeyDown={enterKey}
     onChange={handleChange('search')}
/>
<Button raised onClick={search}> Search </Button>

当用户输入搜索文本并按下Enter键时,我们将调用search方法。为了检测是否按下了Enter键,我们使用TextField上的onKeyDown属性,并定义如下enterKey处理方法:

mern-marketplace/client/product/Search.js:

const enterKey = (event) => {
   if(event.keyCode == 13){
     event.preventDefault()
     search()
   }
}

“搜索”方法使用list获取方法调用搜索 API,并向它提供必要的搜索查询参数和值。此“搜索”方法定义如下所示:

mern-marketplace/client/product/Search.js:

const search = () => {
    if(values.search){
      list({
        search: values.search || undefined, category: values.category
      }).then((data) => {
        if (data.error) {
          console.log(data.error)
        } else {
          setValues({...values, results: data, searched:true})
        }
      })
    }
}

在这个方法中,提供给list方法的查询参数是搜索文本值(如果有)和所选类别值。然后,从后端接收到的结果数组被设置为状态中的值,并作为属性传递给Products组件,如下所示,以在搜索表单下方渲染匹配的产品:

mern-marketplace/client/product/Search.js:

<Products products={results} searched={searched}/>

这个搜索视图为访客提供了一个有用的工具,可以在可能存储在完整市场数据库中的许多产品中查找他们想要的具体产品。在下一节中,我们将探讨在前端利用类别和搜索 API 的另一个简单用例。

类别组件

“类别”组件是独特类别和搜索 API 的第二个用例。对于这个组件,我们首先在父组件中获取类别列表,并将其作为属性发送以向用户显示类别,如下面的截图所示:

图片

当用户在显示列表中选择一个类别时,会调用带有仅类别值的搜索 API,后端返回所选类别的所有产品。然后,这些返回的产品将在“产品”组件中渲染。这可以是一种简单的方法来组合这些 API,并向浏览市场的买家展示有意义的商品。

在这个 MERN 市场的第一个版本中,用户可以成为卖家来创建商店并添加产品,访客可以浏览商店并搜索产品,同时应用程序还会向访客推荐产品。

摘要

在本章中,我们开始使用 MERN 堆栈构建在线市场应用程序。MERN 骨架被扩展,以便用户可以拥有活跃的卖家账户,这样他们就可以创建商店并向每个商店添加产品,目的是向其他用户销售。我们还探讨了如何利用堆栈来实现产品浏览、搜索和为对购买感兴趣的普通用户提供建议等功能。

在浏览本章的实现过程中,我们探讨了如何通过全栈实现来奠定基础,以便能够组合和扩展诸如搜索和建议等有趣的功能。您可以在构建可能需要这些功能的其他全栈应用程序时应用这些相同的方法。

即使包含了这些功能,一个市场应用如果没有购物车、订单管理和支付处理功能,仍然是不完整的。在下一章中,我们将扩展我们的市场应用,添加这些高级功能,并了解如何使用 MERN 堆栈来实现电子商务应用的核心方面。

第十一章:扩展 MERN Marketplace 以支持订单和支付

当客户下单时处理客户支付,并允许卖家管理这些订单是电子商务应用的关键方面。在本章中,我们将通过实现买家将产品添加到购物车、结账和下单的能力,以及卖家管理这些订单和通过市场应用处理支付的能力,扩展我们在上一章中构建的在线市场。一旦你完成本章并添加了这些功能,除了扩展市场应用的高级功能外,你还将能够利用浏览器存储、使用 Stripe 处理支付,并将其他技术集成到这个堆栈中。

在本章中,我们将通过以下主题扩展在线市场:

  • 在 MERN Marketplace 中引入购物车、支付和订单

  • 实现购物车

  • 使用 Stripe 进行支付

  • 集成结账流程

  • 创建新订单

  • 列出每个商店的订单

  • 查看单个订单详情

在 MERN Marketplace 中引入购物车、支付和订单

我们在第七章,《使用在线市场锻炼 MERN 技能》中开发的 MERN Marketplace 应用具有非常简单的功能,缺少核心的电子商务功能。在本章中,我们将扩展这个市场应用,使其包括买家的购物车功能、处理信用卡支付的 Stripe 集成以及卖家的基本订单管理流程。以下实现保持简单,作为开发您自己应用中这些功能的更复杂版本的起点。

完整的 MERN Marketplace 应用程序代码可在 GitHub 上找到:github.com/PacktPublishing/Full-Stack-React-Projects-Second-Edition/tree/master/Chapter07%20and%2008/mern-marketplace。你可以克隆此代码,并在阅读本章其余部分的代码解释时运行应用程序。要使 Stripe 支付代码工作,你需要创建自己的 Stripe 账户,并将config/config.js文件中的 Stripe API 密钥、密钥和 Stripe Connect 客户端 ID 更新为你的测试值。

下面的组件树图显示了构成 MERN Marketplace 前端的自定义组件,包括本章其余部分将实现的购物车、支付和与订单相关的功能:

图片

本章将讨论的功能将修改一些现有组件,例如ProfileMyShopsProductsSuggestions,并添加新的组件,例如AddToCartMyOrdersCartShopOrders。在下一节中,我们将开始通过实现购物车来扩展在线市场。

实现购物车

访问 MERN 市场的访客可以通过点击每个产品的“添加到购物车”按钮将他们想要购买的产品添加到购物车中。菜单上的购物车图标将指示用户在浏览市场时已添加到购物车中的产品数量。他们还可以通过打开购物车视图来更新购物车的内容并开始结账过程。但为了完成结账过程并下订单,用户将需要登录。

购物车主要是前端功能,因此购物车详情将存储在客户端本地,直到用户在结账时下订单。为了实现购物车功能,我们将在client/cart/cart-helper.js中设置辅助方法,这将帮助从相关的 React 组件中操作购物车详情。

在以下几节中,我们将探讨如何将产品添加到购物车,更新菜单以显示购物车的状态,并实现购物车视图,用户可以在结账前查看和修改已添加到购物车中的所有项目。

添加到购物车

当在市场上浏览产品时,用户将看到在每个产品上添加到他们购物车的选项。这个选项将通过名为AddToCart的 React 组件实现。这个AddToCart组件在client/Cart/AddToCart.js中从它添加到的父组件接收product对象和 CSS 样式对象作为 props。例如,在 MERN 市场中,它被添加到产品视图如下:

<AddToCart cartStyle={classes.addCart} item={product}/>

AddToCart组件渲染时,会根据传递的项目是否有库存显示购物车图标按钮,如下面的截图所示:

图片

例如,如果项目数量大于0,则显示AddCartIcon;否则,渲染DisabledCartIcon。图标的显示取决于传递给 props 的 CSS 样式对象。渲染AddToCart按钮这些变体的代码如下。

mern-marketplace/client/cart/AddToCart.js:

{ props.item.quantity >= 0 ?
     <IconButton color="secondary" dense="dense" onClick={addToCart}>
        <AddCartIcon className={props.cartStyle || classes.iconButton}/>
     </IconButton> :
     <IconButton disabled={true} color="secondary" dense="dense">
        <DisabledCartIcon className={props.cartStyle || classes.disabledIconButton}/>
     </IconButton>
}

当点击AddCartIcon按钮时,会调用addToCart方法。addToCart方法定义如下。

mern-marketplace/client/cart/AddToCart.js:

const addToCart = () => {
   cart.addItem(props.item, () => {
     setRedirect({redirect:true})
   })
}

addToCart方法调用在cart-helper.js中定义的addItem辅助方法。这个addItem方法接受product项目和状态更新callback函数作为参数,并将更新后的购物车详情存储在localStorage中,并执行传递的回调,如下面的代码所示。

mern-marketplace/client/cart/cart-helper.js:

addItem(item, cb) {
    let cart = []
    if (typeof window !== "undefined") {
      if (localStorage.getItem('cart')) {
        cart = JSON.parse(localStorage.getItem('cart'))
      }
      cart.push({
        product: item,
        quantity: 1,
        shop: item.shop._id
      })
      localStorage.setItem('cart', JSON.stringify(cart))
      cb()
    }
}

存储在localStorage中的购物车数据包含一个购物车项目对象数组,每个对象包含产品详情、添加到购物车中的产品数量(默认设置为1)以及产品所属商店的 ID。当产品被添加到购物车并存储在localStorage中时,我们还将如下一节所述在导航菜单上显示更新的项目计数。

菜单中的购物车图标

在菜单中,我们将添加一个链接到购物车视图,以及一个显示存储在localStorage中的购物车数组长度的徽章,以便视觉上告知用户当前购物车中有多少个项目。渲染的链接和徽章将如下所示:

购物车链接将与菜单中的其他链接类似,但有一个例外,即 Material-UI Badge组件,它显示购物车长度。它将按如下方式添加:

mern-marketplace/client/core/Menu.js:

<Link to="/cart">
    <Button color={isActive(history, "/cart")}>
       Cart
       <Badge invisible={false} color="secondary" 
             badgeContent= {cart.itemTotal()}>
           <CartIcon />
       </Badge>
    </Button>
</Link>

购物车长度由cart-helper.js中的itemTotal辅助方法返回,该方法读取存储在localStorage中的cart数组并返回数组的长度。itemTotal方法定义如下。

mern-marketplace/client/cart/cart-helper.js:

itemTotal() {
    if (typeof window !== "undefined") {
      if (localStorage.getItem('cart')) {
        return JSON.parse(localStorage.getItem('cart')).length
      }
    }
    return 0
}

点击此购物车链接,菜单上显示项目总数,将用户带到购物车视图并显示已添加到购物车的项目详情。在下一节中,我们将讨论此购物车视图的实现。

购物车视图

购物车视图将包含购物车项目和结账详情。但最初,只有购物车详情将被显示,直到用户准备好结账。渲染此购物车视图的代码将按如下方式添加。

mern-marketplace/client/cart/Cart.js:

<Grid container spacing={24}>
      <Grid item xs={6} sm={6}>
            <CartItems checkout={checkout}
 setCheckout={showCheckout}/>
      </Grid>
 {checkout && 
      <Grid item xs={6} sm={6}>
        <Checkout/>
      </Grid>}
</Grid>

显示购物车项目的CartItems组件,它传递一个checkout布尔值和用于此结账值的州更新方法,以便根据用户交互有条件地渲染Checkout组件及其选项。

用于更新checkout值的showCheckout方法定义如下。

mern-marketplace/client/cart/Cart.js:

const showCheckout = val => {
    setCheckout(val)
}

Cart组件将在/cart路由下访问,因此我们需要将Route添加到MainRouter组件中,如下所示。

mern-marketplace/client/MainRouter.js:

<Route path="/cart" component={Cart}/>

这是我们在菜单中使用的链接,用于将用户重定向到包含购物车详情的购物车视图。在下一节中,我们将查看CartItems组件的实现,该组件将渲染购物车中每个项目的详情并允许修改。

CartItems组件

CartItems组件将允许用户查看和更新他们购物车中的项目。它还将给他们提供选项,如果他们已登录,则可以开始结账过程,如下面的截图所示:

如果购物车包含项目,CartItems 组件将遍历项目并在购物车中渲染产品。如果没有添加任何项目,购物车视图将只显示一条消息,说明购物车为空。此实现的代码如下。

mern-marketplace/client/cart/CartItems.js:

{cartItems.length > 0 ? <span>
      {cartItems.map((item, i) => {
          ...          
            … Display product details
              … Edit quantity
              … Remove product option
          ...
        })
      }
     … Show total price and Checkout options … 
    </span> : 
    <Typography variant="subtitle1" component="h3" color="primary">
        No items added to your cart.    
    </Typography>
}

对于每个产品项目,我们显示产品的详情和一个可编辑的数量文本字段,以及一个移除项目的选项。最后,我们显示购物车中项目的总价以及开始结账操作的选项。在接下来的章节中,我们将探讨这些购物车项目显示和修改选项的实现。

检索购物车详情

在显示购物车项目详情之前,我们需要检索存储在 localStorage 中的购物车详情。为此,我们在 cart-helper.js 中实现了 getCart 辅助方法,该方法从 localStorage 中检索并返回购物车详情,如下面的代码所示。

mern-marketplace/client/cart/cart-helper.js:

getCart() {
    if (typeof window !== "undefined") {
      if (localStorage.getItem('cart')) {
        return JSON.parse(localStorage.getItem('cart'))
      }
    }
    return []
}

CartItems 组件中,我们将使用 getCart 辅助方法检索购物车项目,并将其设置为 cartItems 的初始状态,如下面的代码所示。

mern-marketplace/client/cart/CartItems.js:

const [cartItems, setCartItems] = useState(cart.getCart())

然后,使用 map 函数遍历从 localStorage 中检索到的 cartItems 数组,以显示每个项目的详情,如下面的代码所示。

mern-marketplace/client/cart/CartItems.js:

<span key={i}>
  <Card>
    <CardMedia image={'/api/product/image/'+item.product._id}
         title={item.product.name}/>
         <CardContent>
                <Link to={'/product/'+item.product._id}>
                    <Typography type="title" component="h3" 
                    color="primary">
                      {item.product.name}</Typography>
                </Link>
                <Typography type="subheading" component="h3" 
               color="primary">
                      $ {item.product.price}
                </Typography>
                <span>${item.product.price * item.quantity}</span>
                <span>Shop: {item.product.shop.name}</span>
         </CardContent>
         <div>
          … Editable quantity …
          … Remove item option ...
         </div>
  </Card>
  <Divider/>
</span> 

对于每个渲染的购物车项目,我们还将提供用户更改数量的选项,如下一节所述。

修改数量

在购物车视图中显示的每个购物车项目都将包含一个可编辑的 TextField,允许用户更新他们购买的每个产品的数量,最小允许值为 1,如下面的代码所示。

mern-marketplace/client/cart/CartItems.js:

Quantity: <TextField
          value={item.quantity}
          onChange={handleChange(i)}
          type="number"
          inputProps={{ min:1 }}
          InputLabelProps={{
            shrink: true,
          }}
        />

当用户更新此值时,将调用 handleChange 方法以执行最小值验证,更新状态中的 cartItems,并使用辅助方法更新 localStorage 中的购物车。handleChange 方法定义如下。

mern-marketplace/client/cart/CartItems.js:

const handleChange = index => event => {
  let updatedCartItems = cartItems
  if(event.target.value == 0){
     updatedCartItems[index].quantity = 1
  }else{
     updatedCartItems[index].quantity = event.target.value
  }
  setCartItems([...updatedCartItems])
  cart.updateCart(index, event.target.value)
}

updateCart 辅助方法接受购物车数组中正在更新的产品的索引和新数量值作为参数,并更新存储在 localStorage 中的详情。此 updateCart 辅助方法定义如下。

mern-marketplace/client/cart/cart-helper.js:

updateCart(itemIndex, quantity) {
    let cart = []
    if (typeof window !== "undefined") {
      if (localStorage.getItem('cart')) {
        cart = JSON.parse(localStorage.getItem('cart'))
      }
      cart[itemIndex].quantity = quantity
      localStorage.setItem('cart', JSON.stringify(cart))
    }
}

除了在购物车中更新项目数量外,用户还可以选择从购物车中移除项目,如下一节所述。

移除项目

购物车中的每个项目旁边都将有一个移除选项。这个移除项目选项是一个按钮,当点击时,将传递项目的数组索引到 removeItem 方法,以便可以从数组中移除。此按钮的渲染代码如下。

mern-marketplace/client/cart/CartItems.js:

<Button color="primary" onClick={removeItem(i)}>x Remove</Button>

removeItem点击处理方法使用removeItem辅助方法从localStorage中的购物车移除项目,然后更新状态中的cartItems。此方法还检查购物车是否已清空,以便可以使用从Cart组件传递的属性作为setCheckout函数隐藏结账。removeItem点击处理方法定义如下。

mern-marketplace/client/cart/CartItems.js:

const removeItem = index => event =>{
    let updatedCartItems = cart.removeItem(index)
    if(updatedCartItems.length == 0){
      props.setCheckout(false)
    }
    setCartItems(updatedCartItems)
}

cart-helper.js中的removeItem辅助方法接受要从中移除的产品索引,将其从数组中移除,并在返回更新后的cart数组之前更新localStorage。此removeItem辅助方法定义如下。

mern-marketplace/client/cart/cart-helper.js:

removeItem(itemIndex) {
    let cart = []
    if (typeof window !== "undefined") {
      if (localStorage.getItem('cart')) {
        cart = JSON.parse(localStorage.getItem('cart'))
      }
      cart.splice(itemIndex, 1)
      localStorage.setItem('cart', JSON.stringify(cart))
    }
    return cart
}

当用户通过更改数量或移除项目来修改购物车中的项目时,他们也将看到当前购物车中所有项目的更新总价,如下一节所述。

显示总价

CartItems组件的底部,我们将显示购物车中项目的总价。它将使用以下代码渲染。

mern-marketplace/client/cart/CartItems.js:

<span className={classes.total}>Total: ${getTotal()}</span>

getTotal方法将在考虑cartItems数组中每个项目的单价和数量时计算总价。此方法定义如下。

mern-marketplace/client/cart/CartItems.js:

const getTotal = () => {
    return cartItems.reduce((a, b) => {
        return a + (b.quantity*b.product.price)
    }, 0)
}

通过这种方式,用户在准备结账并下订单之前,将能够了解他们要购买的内容及其费用概览。在下一节中,我们将探讨如何根据购物车状态和用户是否已登录有条件地渲染结账选项。

结账选项

用户将根据他们是否已登录以及结账是否已被打开,看到执行结账的选项,如以下代码所示。

mern-marketplace/client/cart/CartItems.js:

{!props.checkout && (auth.isAuthenticated() ? 
    <Button onClick={openCheckout}>
        Checkout
    </Button> : 
    <Link to="/signin">
        <Button>Sign in to checkout</Button>
    </Link>)
}

当点击结账按钮时,openCheckout方法将使用作为属性传递的setCheckout方法将Cart组件中的结账值设置为trueopenCheckout方法定义如下。

mern-marketplace/client/cart/CartItems.js:

const openCheckout = () => {
    props.setCheckout(true)
}

一旦在购物车视图中将结账值设置为trueCheckout组件将被渲染,以允许用户输入结账详情并下订单。

这将为用户完成购买过程,现在他们能够将项目添加到购物车并修改每个项目,直到他们准备结账。但在讨论结账功能的实现之前,该功能将涉及收集和处理支付信息,在下一节中,我们将讨论如何在我们的应用程序中使用 Stripe 添加预期的支付相关功能。

使用 Stripe 进行支付

在结账、订单创建和订单管理流程的实现中都需要支付处理。这还涉及到对买家和卖家用户数据的更新。在我们深入探讨结账和订单功能的实现之前,我们将简要讨论使用 Stripe 的支付处理选项和考虑因素,并学习如何在 MERN 市场中集成它。

Stripe 提供了一套广泛的工具,这些工具对于在任何 Web 应用程序中集成支付都是必要的。这些工具可以根据具体的应用类型和正在实施的支付用例以不同的方式选择和使用。

在 MERN 市场设置的情况下,应用程序本身将在 Stripe 上拥有一个平台,并期望卖家在平台上连接 Stripe 账户,以便应用程序可以代表卖家向在结账时输入信用卡详情的用户收费。在 MERN 市场中,用户可以将不同商店的产品添加到购物车中,这样只有在卖家处理订单时,应用程序才会为所订购的具体产品创建卡片上的费用。此外,卖家将能够通过自己的 Stripe 仪表板完全控制代表他们创建的费用。我们将演示如何使用 Stripe 提供的工具来使这个支付设置生效。

Stripe 为每个工具提供了一套完整的文档和指南,同时也为在 Stripe 上设置的账户和平台提供了测试数据。为了在 MERN 市场中实现支付,我们将使用测试密钥,并将其留给您来扩展实现以支持实时支付。

在以下章节中,我们将讨论如何为每个卖家连接 Stripe 账户,使用 Stripe 卡元素从用户那里收集信用卡详情,使用 Stripe 客户记录用户的支付信息以安全的方式,以及使用 Stripe 创建费用以处理支付。

为每个卖家提供 Stripe 连接的账户

为了代表卖家创建费用,应用程序将允许一个卖家用户将他们的 Stripe 账户连接到他们的 MERN 市场用户账户。在以下章节中,我们将通过更新用户模型以存储 Stripe 凭证,添加视图组件以允许用户连接到 Stripe,以及添加后端 API 以在更新数据库之前完成 Stripe OAuth 来实现这一功能。

更新用户模型

当卖家将他们的 Stripe 账户连接到市场时,我们需要将他们的 Stripe 凭证与他们其他用户详情一起存储,以便他们在销售产品时可以用于支付处理。为了在用户成功连接他们的 Stripe 账户后存储 Stripe OAuth 凭证,我们将更新我们在第三章,使用 MongoDB、Express 和 Node 构建后端中开发的用户模型,如下所示的字段。

mern-marketplace/server/models/user.model.js:

stripe_seller: {}

这个stripe_seller字段将存储从 Stripe 在认证过程中收到的卖家 Stripe 账户凭证。当需要通过 Stripe 处理他们从商店销售的产品时的费用时,将使用这些凭证。接下来,我们将查看前端组件,该组件将允许用户从我们的应用程序连接到 Stripe。

连接到 Stripe 的按钮

在卖家的用户资料页面中,如果用户尚未连接他们的 Stripe 账户,我们将显示一个按钮,该按钮将用户带到 Stripe 进行认证并连接他们的 Stripe 账户。连接到 Stripe 的按钮将在“资料”视图中如下渲染:

图片

如果用户已经成功连接了他们的 Stripe 账户,我们将显示一个禁用的 STRIPE CONNECTED 按钮,如下面的截图所示:

图片

添加到Profile组件中的代码将检查用户是否为卖家,然后再渲染与 Stripe 相关的按钮。然后,第二个检查将确认给定用户的stripe_seller字段中是否已经存在 Stripe 凭证。如果用户已经存在 Stripe 凭证,则显示禁用的STRIPE CONNECTED按钮;否则,将显示一个使用他们的 OAuth 链接连接到 Stripe 的链接,如以下代码所示。

mern-marketplace/client/user/Profile.js:

{user.seller && (user.stripe_seller ? 
    (<Button variant="contained" disabled className={classes.stripe_connected}>
                Stripe connected
     </Button>)
  : (<a href={"https://connect.stripe.com/oauth/authorize?                                 response_type=code&client_id="                                                                                   +config.stripe_connect_test_client_id+"&scope=read_write"} 
     className={classes.stripe_connect}>
    <img src={stripeButton}/>
     </a>)
  )
}  

OAuth 链接包含平台的客户端 ID,我们将将其设置在config变量中,以及其他选项值作为查询参数。此链接将用户带到 Stripe,并允许用户连接现有的 Stripe 账户或创建一个新的账户。一旦 Stripe 的认证过程完成,它将使用在 Stripe 仪表板上的平台连接设置中设置的重定向 URL 返回到我们的应用程序。Stripe 将认证代码或错误消息附加为查询参数到重定向 URL。

MERN Marketplace 的重定向 URI 设置为/seller/stripe/connect,这将渲染StripeConnect组件。我们将如下声明此路由。

mern-marketplace/client/MainRouter.js:

<Route path="/seller/stripe/connect" component={StripeConnect}/>

当 Stripe 将用户重定向到这个 URL 时,我们将渲染StripeConnect组件,以便它处理 Stripe 对认证的响应,如下一节所述。

StripeConnect 组件

StripeConnect 组件将基本上完成与 Stripe 的剩余授权流程步骤,并根据 Stripe 连接是否成功渲染相关消息,如下面的截图所示:

StripeConnect 组件加载时,我们将使用 useEffect 钩子解析从 Stripe 重定向附加到 URL 的查询参数,如下面的代码所示。

mern-marketplace/client/user/StripeConnect.js:

  useEffect(() => {
    const abortController = new AbortController()
    const signal = abortController.signal
    const jwt = auth.isAuthenticated()
    const parsed = queryString.parse(props.location.search)
    if(parsed.error){
      setValues({...values, error: true})
    }
    if(parsed.code){
      setValues({...values, connecting: true, error: false})
      //post call to stripe, get credentials and update user data
      stripeUpdate({
        userId: jwt.user._id
      }, {
        t: jwt.token
      }, parsed.code, signal).then((data) => {
        if (data.error) {
          setValues({...values, error: true, connected: false,
             connecting: false})
        } else {
          setValues({...values, connected: true, 
             connecting: false, error: false})
        }
      })
    }
    return function cleanup(){
      abortController.abort()
    }
  }, [])

对于解析,我们使用之前用于实现产品搜索的相同 query-string 节点模块。然后,如果 URL 的 query 参数包含授权 code 而不是 error,我们将通过 stripeUpdate 获取方法在我们的服务器上发起 API 调用来完成 Stripe OAuth。

stripeUpdate 获取方法在 api-user.js 中定义,并将从 Stripe 检索到的授权码传递到我们将在服务器上设置的 '/api/stripe_auth/:userId' 的 API。此 stripeUpdate 获取方法定义如下。

mern-marketplace/client/user/api-user.js:

const stripeUpdate = async (params, credentials, auth_code, signal) => {
  try {
    let response = await fetch ('/api/stripe_auth/'+params.userId, {
      method: 'PUT',
      signal: signal,
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      },
      body: JSON.stringify({stripe: auth_code})
    })
    return await response.json()
  } catch(err) {
    console.log(err)
  }
}

此获取方法正在调用我们必须在我们的服务器上添加的后端 API,以完成 OAuth 流程并将检索到的凭证保存到数据库中。我们将在下一节中实现此 API。

Stripe 授权更新 API

一旦连接了 Stripe 账户,为了完成 OAuth 流程,我们需要从我们的服务器向 Stripe OAuth 发起 POST API 调用。我们需要通过 POST API 调用将之前检索到的授权码发送给 Stripe OAuth,并接收要存储在卖家用户账户中以处理费用的凭证。我们将通过在后端实现更新 API 来实现此 Stripe 授权更新。此 Stripe 授权更新 API 将在 /api/stripe_auth/:userId 接收 PUT 请求并启动 POST API 调用来从 Stripe 获取凭证。

此 Stripe 授权更新 API 的路由将在服务器上的用户路由中声明,如下所示。

mern-marketplace/server/routes/user.routes.js:

router.route('/api/stripe_auth/:userId')
   .put(authCtrl.requireSignin, authCtrl.hasAuthorization,   
    userCtrl.stripe_auth, userCtrl.update)

对此路由的请求使用 stripe_auth 控制器方法从 Stripe 获取凭证,并将其传递给现有的用户更新方法,以便它可以存储在数据库中。

为了从我们的服务器向 Stripe API 发起 POST 请求,我们将使用 request 节点模块,需要从命令行使用以下命令安装:

yarn add request 

用户控制器中的 stripe_auth 控制器方法将定义如下。

mern-marketplace/server/controllers/user.controller.js:

const stripe_auth = (req, res, next) => {
  request({
    url: "https://connect.stripe.com/oauth/token",
    method: "POST",
    json: true,
    body: { client_secret:config.stripe_test_secret_key,                                                               
            code:req.body.stripe, 
            grant_type:'authorization_code'}
  }, (error, response, body) => {
    if(body.error){
      return res.status('400').json({
        error: body.error_description
      })
    }
    req.body.stripe_seller = body
    next()
  })
}

使用 Stripe 的 POST API 调用需要平台的密钥和检索到的授权码来完成授权。然后,它将在 body 中返回连接账户的凭证,这些凭证随后被附加到请求体中,以便在 next() 调用 update 控制器方法时更新用户的详细信息。

从 Stripe 获取的这些认证凭据可以在我们的应用程序中使用,代表卖家在他们的商店销售产品时,在客户信用卡上创建费用。在下一节中,我们将学习如何使用 Stripe 在结账过程中收集客户信用卡详情。

Stripe Card Elements 用于结账

在结账过程中,为了从用户那里收集信用卡详情,我们将使用 Stripe 的 Card Elements 将信用卡字段添加到结账表单中。为了将 Card Elements 集成到我们的 React 接口中,我们将利用 react-stripe-elements 节点模块,可以通过在命令行中运行以下命令来安装:

yarn add react-stripe-elements

我们还需要将 Stripe.js 代码注入到 template.js 中,以便在前端代码中访问 Stripe,如下所示。

mern-marketplace/template.js:

<script id="stripe-js" src="img/"></script>

对于 MERN Marketplace,Stripe 将在购物车视图中需要,其中 Checkout 组件需要它来渲染 Card Elements 并处理卡详情输入。我们将使用来自 react-stripe-elementsStripeProvider 组件包装我们在 Cart.js 中添加的 Checkout 组件,以便 Checkout 中的 Elements 组件可以访问 Stripe 实例。

mern-marketplace/client/cart/Cart.js:

<StripeProvider apiKey={config.stripe_test_api_key}> 
     <Checkout/>
</StripeProvider>

然后,在 Checkout 组件中,我们将使用 Stripe 的 Elements 组件。使用 Stripe 的 Card Elements 将使应用程序能够收集用户的信用卡详情,并使用 Stripe 实例来标记卡信息,而不是在我们的服务器上处理。在结账过程中收集卡详情和生成卡令牌的实现细节将在 整合结账过程创建新订单 部分中讨论。在下一节中,我们将讨论如何使用 Stripe 安全地记录从用户那里通过 Card Elements 收到的卡详情。

Stripe 客户用于记录卡详情

当在结账过程的最后放置订单时,生成的卡令牌将被用来创建或更新代表我们的用户的 Stripe 客户(stripe.com/docs/api#customers)。这是将信用卡信息(stripe.com/docs/saving-cards)存储在 Stripe 中以供进一步使用的好方法,例如,当卖家从他们的商店处理已订购的产品时,在购物车中对特定产品创建费用。这消除了需要在自己的服务器上安全存储用户信用卡详情的复杂性。为了将 Stripe 客户集成到我们的应用程序中,在接下来的章节中,我们将更新用户模型以便它存储 Stripe 客户详情,并更新用户控制器方法,以便我们可以使用后端的 Stripe 节点模块创建或更新 Stripe 客户信息。

更新用户模型

为了使用 Stripe 客户端安全地存储每个用户的信用卡信息并在应用中按需处理支付,我们需要存储与每个用户关联的 Stripe 客户端详情。为了跟踪我们数据库中用户的相应 Stripe 客户端信息,我们将更新用户模型,如下所示的字段:

stripe_customer: {},

此字段将存储一个 Stripe 客户端对象,这将允许我们创建周期性费用并跟踪与我们平台中同一用户关联的多个费用。为了能够创建或更新 Stripe 客户端,我们需要利用 Stripe 的客户 API。在下一节中,我们将更新用户控制器,以便我们可以集成和使用来自 Stripe 的此客户 API。

更新用户控制器

当用户在输入信用卡详情后下订单时,我们将创建一个新的或更新现有的 Stripe 客户端。为了实现这一点,我们将更新用户控制器,以便在服务器接收到创建订单 API 请求(如“创建新订单”部分所述)之前调用 stripeCustomer 方法。

stripeCustomer 控制器方法中,我们需要使用 stripe 节点模块,可以使用以下命令安装:

yarn add stripe 

在安装了 stripe 模块后,需要将其导入到用户控制器文件中。然后,需要使用应用程序的 Stripe 秘密密钥初始化 stripe 实例。

mern-marketplace/server/controllers/user.controller.js:

import stripe from 'stripe'
const myStripe = stripe(config.stripe_test_secret_key)

stripeCustomer 控制器方法将检查当前用户是否已经在数据库中存储了相应的 Stripe 客户端,然后使用从前端接收到的卡令牌来创建一个新的 Stripe 客户端或更新现有的一个,如下文所述。

创建新的 Stripe 客户端

如果当前用户没有对应的 Stripe 客户端 - 也就是说,stripe_customer 字段没有存储值 - 我们将使用 Stripe 的创建客户 API (stripe.com/docs/api#create_customer),如下所示。

mern-marketplace/server/controllers/user.controller.js:

myStripe.customers.create({
            email: req.profile.email,
            source: req.body.token
      }).then((customer) => {
          User.update({'_id':req.profile._id},
            {'$set': { 'stripe_customer': customer.id }},
            (err, order) => {
              if (err) {
                return res.status(400).send({
                  error: errorHandler.getErrorMessage(err)
                })
              }
              req.body.order.payment_id = customer.id
              next()
        })
})

如果 Stripe 客户端创建成功,我们将通过在 stripe_customer 字段中存储 Stripe 客户端 ID 引用来更新当前用户的数据。我们还将把此客户 ID 添加到正在下订单中,以便更容易创建与订单相关的费用。一旦创建了 Stripe 客户端,我们就可以在用户为新的订单输入信用卡详情时更新 Stripe 客户端,如下一节所述。

更新现有的 Stripe 客户端

对于现有的 Stripe 客户端 - 也就是说,当前用户已经在 stripe_customer 字段中存储了值 - 我们将使用 Stripe API 来更新一个 Stripe 客户端,如下所示。

mern-marketplace/server/controllers/user.controller.js:

 myStripe.customers.update(req.profile.stripe_customer, {
       source: req.body.token
     }, 
       (err, customer) => {
         if(err){
           return res.status(400).send({
             error: "Could not update charge details"
           })
         }
         req.body.order.payment_id = customer.id
         next()
       })

一旦成功更新 Stripe 客户,我们将在next()调用中将客户 ID 添加到正在创建的订单中。虽然这里没有涉及,但 Stripe 客户功能可以用来允许用户从应用程序中存储和更新他们的信用卡信息。随着用户的支付信息被安全存储并可供访问,我们可以探讨如何使用这些信息在卖家处理订购产品时处理支付。

为每个处理的产品创建费用

当卖家通过处理他们在商店中订购的产品来更新订单时,应用程序将代表卖家在客户的信用卡上创建一个费用,费用为订购产品的成本。

为了实现这一点,我们将更新user.controller.js文件,添加一个createCharge控制器方法,该方法将使用 Stripe 的创建费用 API,并需要卖家的 Stripe 账户 ID 以及买家的 Stripe 客户 ID。createCharge控制器方法将定义如下。

mern-marketplace/server/controllers/user.controller.js:

const createCharge = (req, res, next) => {
  if(!req.profile.stripe_seller){
    return res.status('400').json({
      error: "Please connect your Stripe account"
    })
  }
  myStripe.tokens.create({
    customer: req.order.payment_id,
  }, {
    stripeAccount: req.profile.stripe_seller.stripe_user_id,
  }).then((token) => {
      myStripe.charges.create({
        amount: req.body.amount * 100, //amount in cents
        currency: "usd",
        source: token.id,
      }, {
        stripeAccount: req.profile.stripe_seller.stripe_user_id,
      }).then((charge) => {
        next()
      })
  })
}

如果卖家尚未连接他们的 Stripe 账户,createCharge方法将返回一个 400 错误响应,以指示需要连接的 Stripe 账户。

为了能够代表卖家的 Stripe 账户向 Stripe 客户收费,我们需要使用客户 ID 和卖家的 Stripe 账户 ID 生成一个 Stripe 令牌,然后使用该令牌创建费用。

当服务器接收到更新订单的请求,并将产品状态更改为处理中时(此订单更新请求的 API 实现将在按商店列出订单部分讨论),将调用createCharge控制器方法。

这涵盖了所有与 MERN Marketplace 特定用例的支付处理相关的 Stripe 相关概念。现在,我们将继续我们的实现,以便允许用户完成结账过程并从购物车中下单。

集成结账过程

已登录并已将商品添加到购物车的用户将能够开始结账过程。我们将添加一个结账表单来收集客户详情、送货地址信息和信用卡信息,如下面的截图所示:

此结账视图将包括两部分,第一部分用于收集买家的详细信息,包括姓名、电子邮件和送货地址,第二部分用于输入信用卡详情并下单。在以下章节中,我们将通过初始化结账表单详情并添加收集买家详情的字段来完成结账过程的实现。然后,我们将收集买家的信用卡详情,以便他们可以下单并完成结账过程。

初始化结账详情

在本节中,我们将创建结算视图,该视图包含表单字段和“下单”选项,位于“结算”组件中。在这个组件中,我们将在从表单收集详细信息之前在状态中初始化checkoutDetails对象。我们将根据当前用户的详细信息预先填充客户信息,并将当前购物车商品添加到checkoutDetails中,如下面的代码所示。

mern-marketplace/client/cart/Checkout.js:

 const user = auth.isAuthenticated().user
 const [values, setValues] = useState({
    checkoutDetails: {
      products: cart.getCart(),
      customer_name: user.name,
      customer_email:user.email,
      delivery_address: { street: '', city: '', state: '', 
          zipcode: '', country:''}
    },
    error: ''
})

这些客户信息值,在checkoutDetails中初始化,将在用户与表单字段交互时更新。在以下章节中,我们将添加收集在此结算视图中要收集的客户信息和送货地址详细信息的表单字段和更改处理函数。

客户信息

在结算表单中,我们将有收集客户姓名和电子邮件地址的字段。为了将这些文本字段添加到Checkout组件中,我们将使用以下代码。

mern-marketplace/client/cart/Checkout.js:

<TextField id="name" label="Name" value={values.checkoutDetails.customer_name} onChange={handleCustomerChange('customer_name')}/>
<TextField id="email" type="email" label="Email" value={values.checkoutDetails.customer_email} onChange={handleCustomerChange('customer_email')}/><br/>  

当用户更新这两个字段的值时,handleCustomerChange方法将更新状态中的相关详细信息。handleCustomerChange方法定义如下。

mern-marketplace/client/cart/Checkout.js:

const handleCustomerChange = name => event => {
    let checkoutDetails = values.checkoutDetails
    checkoutDetails[name] = event.target.value || undefined
    setValues({...values, checkoutDetails: checkoutDetails})
}

这将使用户能够更新与该订单关联的客户的姓名和电子邮件。接下来,我们将查看收集此订单送货地址详细信息实现的示例。

送货地址

为了从用户那里收集送货地址,我们将在结算表单中添加收集地址详细信息(如街道地址、城市、州、邮政编码和国家名称)的字段。我们将使用以下代码添加文本字段,以便用户输入这些地址详细信息。

mern-marketplace/client/cart/Checkout.js:

<TextField id="street" label="Street Address" value=          {values.checkoutDetails.delivery_address.street} onChange={handleAddressChange('street')}/>
<TextField id="city" label="City" value={values.checkoutDetails.delivery_address.city} onChange={handleAddressChange('city')}/>
<TextField id="state" label="State" value={values.checkoutDetails.delivery_address.state} onChange={handleAddressChange('state')}/>
<TextField id="zipcode" label="Zip Code" value={values.checkoutDetails.delivery_address.zipcode} onChange={handleAddressChange('zipcode')}/>
<TextField id="country" label="Country" value={values.checkoutDetails.delivery_address.country} onChange={handleAddressChange('country')}/> 

当用户更新这些地址字段时,handleAddressChange方法将更新状态中的相关详细信息,如下所示。

mern-marketplace/client/cart/Checkout.js:

const handleAddressChange = name => event => {
    let checkoutDetails = values.checkoutDetails
    checkoutDetails.delivery_address[name] = 
          event.target.value || undefined
    setValues({...values, checkoutDetails: checkoutDetails})
}

在这些文本字段和处理更改函数就绪后,状态中的checkoutDetails对象将包含用户输入的客户信息和送货地址。在下一节中,我们将从买家那里收集支付信息,并将其与其他结算详细信息一起使用,以完成结算过程并下单。

下单

结账过程的剩余步骤将涉及安全地收集用户的信用卡详细信息,从而使用户能够下订单,从存储中清空购物车,并将用户重定向到包含订单详情的视图。我们将通过构建一个 PlaceOrder 组件来实现这些步骤,该组件由结账视图中的剩余元素组成,即信用卡字段和下订单按钮。在接下来的章节中,当我们开发这个组件时,我们将使用 Stripe 卡元素来收集信用卡详细信息,为用户添加一个完成结账过程的下订单按钮,利用购物车辅助方法清空购物车,并将用户重定向到订单视图。

使用 Stripe 卡元素

为了使用来自 react-stripe-elements 的 Stripe 的 CardElement 组件将信用卡字段添加到 PlaceOrder 组件中,我们需要使用 Stripe 的 injectStripe 高阶组件HOC)来包装 PlaceOrder 组件。

这是因为 CardElement 组件需要成为由 injectStripe 构建,并用 Elements 组件包装的支付表单组件的一部分。因此,当我们创建一个名为 PlaceOrder 的组件时,我们将在导出之前用 injectStripe 包装它,如下面的代码所示。

mern-marketplace/client/cart/PlaceOrder.js:

const PlaceOrder = (props) => { … } 
PlaceOrder.propTypes = {
  checkoutDetails: PropTypes.object.isRequired
}
export default injectStripe(PlaceOrder)

然后,我们将此 PlaceOrder 组件添加到结账表单中,将其 checkoutDetails 对象作为属性传递,并用来自 react-stripe-elementsElements 组件包装,如下所示。

mern-marketplace/client/cart/Checkout.js:

<Elements> <PlaceOrder checkoutDetails={values.checkoutDetails} /> </Elements>

injectStripe HOC 提供了 props.stripe 属性,该属性管理 Elements 组。这将允许我们在 PlaceOrder 中调用 props.stripe.createToken,将卡详细信息提交给 Stripe 并获取卡令牌。接下来,我们将学习如何使用 Stripe CardElement 组件在 PlaceOrder 组件内部收集信用卡详细信息。

The CardElement component

Stripe 的 CardElement 是自包含的,因此我们只需将其添加到 PlaceOrder 组件中,然后根据需要添加样式,信用卡详细信息输入将由它处理。我们将按照以下方式将 CardElement 组件添加到 PlaceOrder 中。

mern-marketplace/client/cart/PlaceOrder.js:

<CardElement className={classes.StripeElement}
      {...{style: {
      base: {
        color: '#424770',
        letterSpacing: '0.025em',
        '::placeholder': {
          color: '#aab7c4',
        },
      },
      invalid: {
        color: '#9e2146',
      },
    }}}/>

这将在结账表单视图中渲染信用卡详细信息字段。在下一节中,我们将学习如何安全地验证和存储用户在点击按钮下订单并完成结账过程时在此字段中输入的信用卡详细信息。

添加一个下订单按钮

在结账视图中的最后一个元素是“下订单”按钮,如果所有详细信息都正确输入,它将完成结账过程。我们将在 CardElement 之后将此按钮添加到 PlaceOrder 组件中,如下面的代码所示。

mern-marketplace/client/cart/PlaceOrder.js:

<Button color="secondary" variant="raised" onClick={placeOrder}>Place Order</Button>

点击“下单”按钮将调用placeOrder方法,该方法将尝试使用stripe.createToken对卡详情进行标记化。如果这失败,用户将被告知错误,但如果成功,则结账详情和生成的卡标记将被发送到我们的服务器创建订单 API(下一节将介绍)。placeOrder方法定义如下。

mern-marketplace/client/cart/PlaceOrder.js:

const placeOrder = ()=>{
    props.stripe.createToken().then(payload => {
      if(payload.error){
        setValues({...values, error: payload.error.message})
      }else{
        const jwt = auth.isAuthenticated()
        create({userId:jwt.user._id}, {
          t: jwt.token
        }, props.checkoutDetails, payload.token.id).then((data) => {
          if (data.error) {
            setValues({...values, error: data.error})
          } else {
            cart.emptyCart(()=> {
              setValues({...values, 'orderId':data._id,'redirect': true})
            })
          }
        })
      }
  })
}

我们在这里调用的create fetch 方法用于向后端创建订单 API 发起 POST 请求,定义在client/order/api-order.js中。它接受结账详情、卡标记和用户凭证作为参数,并将它们发送到 API,如前述 API 实现中所示。当新订单成功创建时,我们将在localStorage中清空购物车,如下一节所述。

清空购物车

如果向创建订单 API 的请求成功,我们将清空localStorage中的购物车,以便用户可以添加新项目到购物车,并在需要的情况下下单。为了在浏览器存储中清空购物车,我们将使用cart-helper.js中的emptyCart辅助方法,其定义如下。

mern-marketplace/client/cart/cart-helper.js:

emptyCart(cb) {
  if(typeof window !== "undefined"){
     localStorage.removeItem('cart')
     cb()
  }
}

emptyCart方法从localStorage中移除购物车对象,并通过执行从placeOrder方法传递给它的回调来更新视图的状态,其中它被调用。在结账过程完成后,我们现在可以将用户从购物车和结账视图中重定向出去,如下一节所述。

重定向到订单视图

下单完成后,购物车清空,我们可以将用户重定向到订单查看页面,该页面将显示他们刚刚下单的订单详情。为了实现这个重定向,我们可以使用 React Router 中的 Redirect 组件,如下面的代码所示。

mern-marketplace/client/cart/PlaceOrder.js:

if (values.redirect) {
      return (<Redirect to={'/order/' + values.orderId}/>)
}

这种重定向也作为用户结账过程完成的指示。完成的结账过程还会在应用程序的后端创建一个新的订单。在下一节中,我们将探讨创建和存储这些新订单到数据库的实现。

创建新订单

当用户下单时,结账时确认的订单详情将被用于在数据库中创建一个新的订单记录,更新或为用户创建 Stripe 客户,并减少已订购产品的库存数量。在接下来的几节中,我们将添加一个订单模型来定义要存储在数据库中的订单详情,并讨论从前端调用以创建新订单记录的后端 API 的实现。

定义订单模型

为了在后端存储订单,我们将为订单模型定义一个架构,该架构将记录订单详情,包括客户详情、支付信息以及所订购产品的数组。这个数组中每个产品的结构将在一个单独的子架构 CartItemSchema 中定义。在接下来的章节中,我们将定义这些架构,以便我们可以在数据库中存储订单和购物车项目。

Order 架构

server/models/course.model.js 中定义的 Order 架构将包含用于存储客户姓名和电子邮件、用户账户引用、送货地址信息、支付引用、创建和更新时间戳以及所订购产品数组的字段。定义订单字段的相关代码如下:

  • 客户姓名和电子邮件:为了记录订单的目标客户的详细信息,我们将向 Order 架构添加 customer_namecustomer_email 字段:
customer_name: { type: String,  trim: true, required: 'Name is required' },
customer_email: { type: String, trim: true,
    match: [/.+\@.+\..+/, 'Please fill a valid email address'],
    required: 'Email is required' }
  • 下单用户:为了引用下单的已登录用户,我们将添加一个 ordered_by 字段:
ordered_by: {type: mongoose.Schema.ObjectId, ref: 'User'}
  • 送货地址:订单的送货地址信息将存储在具有 streetcitystatezipcodecountry 字段的送货地址子文档中:
delivery_address: {
    street: {type: String, required: 'Street is required'},
    city: {type: String, required: 'City is required'},
    state: {type: String},
    zipcode: {type: String, required: 'Zip Code is required'},
    country: {type: String, required: 'Country is required'}
  },
  • 支付引用:当订单更新并且卖家处理完所订购的产品后需要创建费用时,支付信息将是相关的。我们将记录与信用卡详情相关的 Stripe 客户 ID,并将其作为 payment_id 字段中的引用,以记录此订单的支付信息:
payment_id: {},
  • 所订购的产品:订单的主要内容将是所订购产品的列表,以及如每个产品的数量等详细信息。我们将在 Order 架构中的 products 字段中记录此列表。每个产品的结构将在 CartItemSchema 中单独定义。

mern-marketplace/server/models/order.model.js

products: [CartItemSchema],

该架构定义中的字段将使我们能够存储每个订单所需的详细信息。用于记录所订购的每个产品详情的 CartItemSchema 将在下一节中讨论。

CartItem 架构

当下单时,CartItem 架构将代表所订购的每个产品。它将包含对产品的引用、用户所订购的产品数量、对产品所属商店的引用以及其状态,如下面的代码所示。

mern-marketplace/server/models/order.model.js

const CartItemSchema = new mongoose.Schema({
  product: {type: mongoose.Schema.ObjectId, ref: 'Product'},
  quantity: Number,
  shop: {type: mongoose.Schema.ObjectId, ref: 'Shop'},
  status: {type: String,
    default: 'Not processed',
    enum: ['Not processed' , 'Processing', 'Shipped', 'Delivered', 
   'Cancelled']}
}) 
const CartItem = mongoose.model('CartItem', CartItemSchema)

产品的 status 只能具有在 enums 中定义的值,默认值设置为 "未处理"。这代表产品订单的当前状态,由卖家更新。

这里定义的 Order 架构和 CartItem 架构将允许我们记录有关客户和已订购产品的详细信息,以便完成用户购买产品的购买步骤。接下来,我们将讨论允许前端在数据库的 Orders 集合中创建订单文档的后端 API 实现。

创建订单 API

后端创建订单 API 将从前端接收 POST 请求以在数据库中创建订单。API 路由将在 server/routes/order.routes.js 中声明,以及其他订单路由。这些订单路由将与用户路由非常相似。为了在 Express 应用中加载订单路由,我们需要在 express.js 中挂载路由,就像我们为 auth 和用户路由所做的那样。

mern-marketplace/server/express.js:

app.use('/', orderRoutes)

当创建订单 API 在 /api/orders/:userId 接收到 POST 请求时,以下一系列动作发生:

  • 确保当前用户已登录。

  • Stripe Customer 是通过我们之前在 Stripe Customer to record card details 部分讨论过的 stripeCustomer 用户控制器方法创建或更新的。

  • 使用 decreaseQuanity 产品控制器方法更新所有已订购产品的库存数量。

  • 使用 create 订单控制器方法在订单集合中创建订单。

此创建订单 API 的路由定义如下。

mern-marketplace/server/routes/order.routes.js:

router.route('/api/orders/:userId') 
    .post(authCtrl.requireSignin, userCtrl.stripeCustomer, 
          productCtrl.decreaseQuantity, orderCtrl.create)

要检索与路由中的 :userId 参数关联的用户,我们将使用 userByID 用户控制器方法。我们将编写处理此参数的代码,包括其他订单路由声明。

mern-marketplace/server/routes/order.routes.js:

router.param('userId', userCtrl.userByID)

userByID 方法从 User 集合中获取用户并将其附加到请求对象中,以便后续几个方法可以访问。当此 API 收到请求时,将调用包括产品控制器方法以减少库存数量和订单控制器方法将新订单保存到数据库在内的几个后续方法。我们将在以下几节中讨论这两个方法的实现。

减少产品库存数量

当下单时,我们将根据用户订购的数量减少每个产品的库存数量。订单下单后,这将自动反映相关商店中产品的更新数量。我们将在 decreaseQuantity 控制器方法中实现此产品数量减少更新,该方法将与其他产品控制器方法一起添加,如下所示。

mern-marketplace/server/controllers/product.controller.js:

const decreaseQuantity = async (req, res, next) => {
  let bulkOps = req.body.order.products.map((item) => {
    return {
        "updateOne": {
            "filter": { "_id": item.product._id } ,
            "update": { "$inc": {"quantity": -item.quantity} }
        }
    }
   })
   try {
     await Product.bulkWrite(bulkOps, {})
     next()
   } catch (err){
      return res.status(400).json({
        error: "Could not update product"
      })
   }
}

由于在此情况下,更新操作涉及在匹配到已订购的产品数组后对集合中的多个产品进行批量更新,因此我们使用 MongoDB 的bulkWrite方法通过一个命令向 MongoDB 服务器发送多个updateOne操作。所需的多个updateOne操作使用map函数列在bulkOps中。这将比发送多个独立的保存或更新操作更快,因为使用bulkWrite(),只有一个往返 MongoDB。

此方法更新产品数量后,将调用下一个方法以将新订单保存到数据库中。在下一节中,我们将看到此方法的实现,它创建这个新订单。

创建控制器方法

在订单控制器中定义的create控制器方法是在创建订单 API 收到请求时调用的最后一个方法。此方法接受订单详情,创建一个新订单并将其保存到 MongoDB 的订单集合中。create控制器方法实现如下。

mern-marketplace/server/controllers/order.controller.js:

const create = async (req, res) => {
  try {
    req.body.order.user = req.profile
    let order = new Order(req.body.order)
    let result = await order.save()
    res.status(200).json(result)
  } catch (err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

实现这一点后,任何在 MERN Marketplace 上注册的用户都可以在后台创建和存储订单。现在,我们可以设置 API 来获取用户订单列表、商店订单列表或读取单个订单,并将获取的数据显示在前端视图中。在下一节中,我们将学习如何按商店列出订单,以便店主可以处理和管理他们收到的产品订单。

按商店列出订单

市场的一个重要功能是允许卖家查看和更新他们商店中收到的产品订单的状态。为了实现这一点,我们将设置后端 API 来按商店列出订单并更新订单,当卖家更改已购买产品的状态时。然后,我们将添加一些前端视图来显示订单并允许卖家与每个订单进行交互。

按商店列表 API

在本节中,我们将实现一个 API 来获取特定商店的订单,以便认证的卖家可以在一个地方查看他们每个商店的订单。这个 API 的请求将在/api/orders/shop/:shopId接收,路由在order.routes.js中定义,如下所示。

mern-marketplace/server/routes/order.routes.js:

router.route('/api/orders/shop/:shopId') 
    .get(authCtrl.requireSignin, shopCtrl.isOwner, orderCtrl.listByShop)
router.param('shopId', shopCtrl.shopByID)

为了检索与路由中的:shopId参数关联的商店,我们将使用shopByID商店控制器方法,该方法从商店集合中获取商店并将其附加到请求对象中,以便后续方法可以访问。

listByShop控制器方法将检索使用匹配的商店 ID 购买的产品订单,然后为每个产品填充 ID、名称和价格字段,按日期从最近到最远排序订单。listByShop控制器方法定义如下。

mern-marketplace/server/controllers/order.controller.js:

const listByShop = async (req, res) => {
  try {
    let orders = await Order.find({"products.shop": req.shop._id})
      .populate({path: 'products.product', select: '_id name price'})
      .sort('-created')
      .exec()
    res.json(orders)
  } catch (err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

为了在前端获取此 API,我们将在api-order.js中添加相应的listByShop方法,类似于我们的其他 API 实现。此获取方法将在ShopOrders组件中使用,以显示每个商店的订单。我们将在下一节中查看ShopOrders组件的实现。

ShopOrders 组件

ShopOrders组件将是卖家可以看到给定商店收到的订单列表的视图。在此视图中,每个订单将仅显示与商店相关的购买产品,并允许卖家使用可能的状

为了在前端路由中渲染此视图,我们将使用PrivateRoute更新MainRouter,以便在/seller/orders/:shop/:shopId路由中加载ShopOrders组件,如下面的代码所示。

mern-marketplace/client/MainRouter.js:

<PrivateRoute path="/seller/orders/:shop/:shopId" component={ShopOrders}/>

访问此链接将加载视图中的ShopOrders组件。在ShopOrders组件中,我们将获取并列出给定商店的订单,并为每个订单,我们将使用名为ProductOrderEdit的 React 组件渲染订单详情以及已订购的产品列表。在接下来的章节中,我们将学习如何加载订单列表并讨论ProductOrderEdit组件的实现。

列出订单

ShopOrders组件在视图中挂载时,我们将从数据库中检索提供的商店 ID 的订单列表,并将其设置为要在视图中渲染的状态。我们将通过使用listByShop获取方法向后端 API 请求按商店列出订单,并在useEffect钩子中将检索到的订单设置到状态中,如下面的代码所示。

mern-marketplace/client/order/ShopOrders.js:

useEffect(() => {
  const jwt = auth.isAuthenticated()
  const abortController = new AbortController()
  const signal = abortController.signal
  listByShop({
    shopId: match.params.shopId
  }, {t: jwt.token}, signal).then((data) => {
     if (data.error) {
       console.log(data)
     } else {
       setOrders(data)
     }
  })
    return function cleanup(){
       abortController.abort()
    }
}, [])

在视图中,我们将遍历订单列表,并将每个订单渲染为Material-UI的可折叠列表,点击时展开。此视图的代码将如下添加。

mern-marketplace/client/order/ShopOrders.js:

<Typography type="title"> Orders in {match.params.shop} </Typography>
<List dense> {orders.map((order, index) => { return 
    <span key={index}>
        <ListItem button onClick={handleClick(index)}>
           <ListItemText primary={'Order # '+order._id} 
                 secondary={(new Date(order.created)).toDateString()}/>
           {open == index ? <ExpandLess /> : <ExpandMore />}
        </ListItem>
        <Collapse component="li" in={open == index} 
       timeout="auto" unmountOnExit>
           <ProductOrderEdit shopId={match.params.shopId} 
           order={order} orderIndex={index} 
           updateOrders={updateOrders}/>
           <Typography type="subheading"> Deliver to:</Typography>
           <Typography type="subheading" color="primary">
               {order.customer_name} ({order.customer_email})
          </Typography>
           <Typography type="subheading" color="primary">
               {order.delivery_address.street}</Typography>
           <Typography type="subheading" color="primary">
               {order.delivery_address.city}, 
           {order.delivery_address.state}
               {order.delivery_address.zipcode}</Typography>
           <Typography type="subheading" color="primary">
               {order.delivery_address.country}</Typography>
        </Collapse>
    </span>})}
</List>

每个展开的订单将显示订单详情和ProductOrderEdit组件。ProductOrderEdit组件将显示购买的产品,并允许卖家编辑每个产品的状态。updateOrders方法作为 prop 传递给ProductOrderEdit组件,以便在产品状态更改时更新状态。updateOrders方法定义如下。

mern-marketplace/client/order/ShopOrders.js:

  const updateOrders = (index, updatedOrder) => {
    let updatedOrders = orders
    updatedOrders[index] = updatedOrder
    setOrders([...updatedOrders])
  }

ProductOrderEdit组件中,当卖家与ProductOrderEdit组件中将要渲染的任何产品的状态更新下拉菜单交互时,我们将调用此updateOrders方法。在下一节中,我们将探讨ProductOrderEdit组件的实现。

ProductOrderEdit 组件

在本节中,我们将实现一个ProductOrderEdit组件来渲染订单中的所有产品,并带有编辑状态选项。这个ProductOrderEdit组件将接受一个订单对象作为属性,遍历订单的products数组以显示仅从当前商店购买的产品,以及一个下拉菜单来更改每个产品的状态值。渲染每个订单产品的代码如下所示。

mern-marketplace/client/order/ProductOrderEdit.js:

{props.order.products.map((item, index) => { return <span key={index}> 
     { item.shop == props.shopId && 
          <ListItem button>
              <ListItemText primary={ <div>
                     <img src=
                    {'/api/product/image/'+item.product._id}/> 
                     {item.product.name}
                     <p>{"Quantity: "+item.quantity}</p>
              </div>}/>
              <TextField id="select-status" select
                   label="Update Status" value={item.status}
                   onChange={handleStatusChange(index)}
                   SelectProps={{
                       MenuProps: { className: classes.menu },
                   }}>
                      {statusValues.map(option => (
                          <MenuItem key={option} value={option}>
                            {option}
                          </MenuItem>
                      ))}
              </TextField>
          </ListItem>}

为了能够在下拉菜单选项中列出有效的状态值以更新已订购产品的状态,我们将在ProductOrderEdit组件的useEffect钩子中从服务器检索可能的州值列表,如下面的代码所示。

mern-marketplace/client/order/ProductOrderEdit.js:

  useEffect(() => {
    const abortController = new AbortController()
    const signal = abortController.signal
    getStatusValues(signal).then((data) => {
      if (data.error) {
        setValues({...values, error: "Could not get status"})
      } else {
        setValues({...values, statusValues: data, error: ''})
      }
    })
    return function cleanup(){
      abortController.abort()
    }
  }, [])

从服务器检索的状态值被设置为状态并作为MenuItem渲染在下拉菜单中。当从下拉菜单中选择可能的州值时,将调用handleStatusChange方法来更新状态中的订单,以及根据所选值向适当的后端 API 发送请求。handleStatusChange方法的结构如下,根据所选的状态值调用不同的后端 API。

mern-marketplace/client/order/ProductOrderEdit.js:

const handleStatusChange = productIndex => event => {
    let order = props.order
    order.products[productIndex].status = event.target.value
    let product = order.products[productIndex]

    if (event.target.value == "Cancelled") {
      // 1\. ... call the cancel product API ..
    } else if (event.target.value == "Processing") {
      // 2\. ... call the process charge API ...
    } else {
      // 3\. ... call the order update API ...
  }

更新已订购产品的状态将根据从下拉菜单中选择的值产生不同的影响。选择取消或处理产品订单将调用后端中的单独 API,而不是选择其他任何状态值时调用的 API。在以下章节中,我们将学习当用户与下拉菜单交互并选择状态值时,如何在handleStatusChange方法中处理这些操作。

处理取消产品订单的操作

如果卖家希望取消产品的订单,并在订单中特定产品的状态值下拉菜单中选择已取消,我们将在handleStatusChange方法中调用cancelProduct获取方法,如下面的代码所示。

mern-marketplace/client/order/ProductOrderEdit.js:

cancelProduct({
          shopId: props.shopId,
          productId: product.product._id
        }, {
          t: jwt.token
        }, {
          cartItemId: product._id,
          status: event.target.value,
          quantity: product.quantity
        })
        .then((data) => {
          if (data.error) {
            setValues({
              ...values,
              error: "Status not updated, try again"
            })
          } else {
            props.updateOrders(props.orderIndex, order)
            setValues({
              ...values,
              error: ''
            })
          }
        })

cancelProduct获取方法将接受相应的商店 ID、产品 ID、购物车项目 ID、所选状态值、产品的订购数量以及要发送的用户凭据,以及向后端取消产品 API 发送请求。在从后端收到成功响应后,我们将更新视图中的订单。

此取消产品 API 将更新受此操作影响的订单和产品的数据库。在深入实现此取消产品订单 API 之前,接下来,我们将看看如果卖家选择处理产品订单而不是取消它,将如何调用处理收费 API。

处理对产品进行收费的操作

如果卖家选择处理产品的订单,我们需要调用一个 API 来向客户收取订购产品的总费用。因此,当卖家在订单中为特定产品选择状态值下拉菜单中的“处理”时,我们将在handleStatusChange方法内部调用processChargefetch 方法,如下面的代码所示。

mern-marketplace/client/order/ProductOrderEdit.js:

processCharge({
          userId: jwt.user._id,
          shopId: props.shopId,
          orderId: order._id
        }, {
          t: jwt.token
        }, {
          cartItemId: product._id,
          status: event.target.value,
          amount: (product.quantity * product.product.price)
        })
        .then((data) => {
          if (data.error) {
            setValues({
              ...values,
              error: "Status not updated, try again"
            })
          } else {
            props.updateOrders(props.orderIndex, order)
            setValues({
              ...values,
              error: ''
            })
          }
        })

processChargefetch 方法将获取相应的订单 ID、商店 ID、客户的用户 ID、购物车项目 ID、选定的状态值、订购产品的总费用和用户凭据,以及发送到后端处理费用 API 的请求。在收到后端成功的响应后,我们将相应地更新视图中的订单。

此过程费用 API 将更新受此操作影响的订单和用户的数据库。在深入了解此 API 的实现之前,接下来,我们将查看如果卖家选择将已订购产品的状态更新为除已取消或处理之外的其他值时,如何调用更新订单 API。

处理更新产品状态的操作

如果卖家选择更新已订购产品的状态,使其具有除已取消或处理之外的其他值,我们需要调用一个 API 来更新数据库中订单的更改产品状态。因此,当卖家在订单中为特定产品选择下拉菜单中的其他状态值时,我们将在handleStatusChange方法内部调用updatefetch 方法,如下面的代码所示。

mern-marketplace/client/order/ProductOrderEdit.js:

update({
          shopId: props.shopId
        }, {
          t: jwt.token
        }, {
          cartItemId: product._id,
          status: event.target.value
        })
        .then((data) => {
          if (data.error) {
            setValues({
              ...values,
              error: "Status not updated, try again"
            })
          } else {
            props.updateOrders(props.orderIndex, order)
            setValues({
              ...values,
              error: ''
            })
          }
      })

updatefetch 方法将获取相应的商店 ID、购物车项目 ID、选定的状态值和用户凭据,以及发送到后端更新订单 API 的请求。在收到后端成功的响应后,我们将更新视图中的订单。

cancelProductprocessChargeupdatefetch 方法定义在api-order.js中,以便它们可以调用后端中相应的 API 来更新已取消产品的库存数量,在处理产品订单时在客户的信用卡上创建费用,以及分别更新订单的产品状态更改。接下来,我们将查看这些 API 的实现。

订购产品的 API

允许卖家更新产品的状态将需要设置四个不同的 API,包括一个用于检索可能状态值的 API。然后,实际的状态更新操作将需要 API 来处理订单本身的状态更新,以便启动相关操作,例如增加已取消产品的库存数量,以及在处理产品时在客户的信用卡上创建费用。在以下部分,我们将查看检索可能状态值、更新订单状态、取消产品订单和处理已订购产品费用的 API 实现。

获取状态值

订单产品的可能状态值在CartItem模式中设置为枚举。为了将这些值作为选项显示在下拉视图中,我们将在/api/order/status_values上设置一个 GET API 路由,以检索这些值。此 API 路由将声明如下。

mern-marketplace/server/routes/order.routes.js:

router.route('/api/order/status_values')
    .get(orderCtrl.getStatusValues)

getStatusValues控制器方法将返回CartItem模式中status字段的枚举值。getStatusValues控制器方法定义如下。

mern-marketplace/server/controllers/order.controller.js:

const getStatusValues = (req, res) => {
  res.json(CartItem.schema.path('status').enumValues)
}

我们还需要在api-order.js中设置一个相应的fetch方法,该方法用于视图中的ProductOrderEdit组件,以便向此 API 发出请求,检索状态值,并在下拉菜单中渲染这些值。在下一节中,我们将查看更新订单 API 端点,当卖家从下拉菜单中选择相关状态值时需要调用此端点。

更新订单状态

当产品的状态更改为除处理中已取消之外的任何值时,将对'/api/order/status/:shopId'发出 PUT 请求,直接在数据库中更新订单,前提是当前用户是订购产品的商店的验证所有者。我们将如此声明此更新 API 的路由。

mern-marketplace/server/routes/order.routes.js:

router.route('/api/order/status/:shopId')
    .put(authCtrl.requireSignin, shopCtrl.isOwner, orderCtrl.update)

update控制器方法将查询订单集合,找到与更新产品匹配的CartItem对象,并在订单的products数组中设置此匹配的CartItemstatus值。update控制器方法定义如下。

mern-marketplace/server/controllers/order.controller.js:

const update = async (req, res) => {
  try {
    let order = await Order.updateOne({'products._id': req.body.cartItemId}, {
        'products.$.status': req.body.status
    })
      res.json(order)
  } catch (err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

要从前端访问此 API,我们将在api-order.js中添加一个update获取方法,以便调用此更新 API,并传递从视图传递的所需参数。update获取方法将定义如下。

mern-marketplace/client/order/api-order.js:

const update = async (params, credentials, product) => {
  try {
    let response = await fetch('/api/order/status/' + params.shopId, {
      method: 'PUT',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      },
      body: JSON.stringify(product)
    })
    return response.json()
  } catch(err){
    console.log(err)
  }
}

当卖家从订单产品下拉菜单中选择除“处理”或“已取消”之外的任何值时,会调用update获取方法,该方法在ProductOrderEdit视图中被调用。在下一节中,我们将查看取消产品订单 API,如果卖家选择“已取消”作为值,则会调用此 API。

取消产品订单

当卖家决定取消产品的订单时,将向/api/order/:shopId/cancel/:productId发送 PUT 请求,以便增加产品的库存数量并在数据库中更新订单。为了实现此取消产品订单 API,我们将声明 API 路由如下。

mern-marketplace/server/routes/order.routes.js:

router.route('/api/order/:shopId/cancel/:productId')
       .put(authCtrl.requireSignin, shopCtrl.isOwner,
            productCtrl.increaseQuantity, orderCtrl.update)
router.param('productId', productCtrl.productByID)

要检索与路由中productId参数关联的产品,我们也将使用productByID产品控制器方法。这将检索产品并将其附加到请求对象中,以便next方法可以访问。

当此 API 收到请求时,要更新产品的库存数量,我们将使用添加到product.controller.js中的increaseQuantity控制器方法,如下所示。

mern-marketplace/server/controllers/product.controller.js:

const increaseQuantity = async (req, res, next) => {
  try {
    await Product.findByIdAndUpdate(req.product._id, 
       {$inc: {"quantity": req.body.quantity}}, {new: true})
    .exec()
      next()
  } catch (err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

increaseQuantity控制器方法通过在产品集合中找到匹配的 ID 来查找产品,并将数量值增加客户订购的数量。现在订单已取消,它执行此操作。

从视图中,我们将使用添加到api-order.js中的相应获取方法来调用此取消产品订单 API。cancelProduct获取方法定义如下。

mern-marketplace/client/order/api-order.js:

const cancelProduct = async (params, credentials, product) => {
  try {
    let response = await fetch('/api/order/'+params.shopId+'/cancel/'+params.productId, {
      method: 'PUT',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      },
      body: JSON.stringify(product)
    })
    return response.json()
  }catch(err){
    console.log(err)
  }
}

当卖家在订单产品下拉菜单中选择已取消时,会调用cancelProduct获取方法,该方法在ProductOrderEdit视图中被调用。在下一节中,我们将查看处理费用 API,如果卖家选择“处理”作为状态值,则会调用此 API。

为产品处理费用

当卖家将已订购产品的状态更改为处理时,我们将设置后端 API 不仅更新订单,还要为客户信用卡创建产品价格乘以订购数量的费用。此 API 的路由声明如下。

mern-marketplace/server/routes/order.routes.js:

router.route('/api/order/:orderId/charge/:userId/:shopId')
            .put(authCtrl.requireSignin, shopCtrl.isOwner,     
            userCtrl.createCharge, orderCtrl.update)
router.param('orderId', orderCtrl.orderByID)

要检索路由中与orderId参数关联的订单,我们将使用orderByID订单控制器方法,该方法从订单集合中获取订单并将其附加到请求对象中,以便next方法可以访问。此orderByID方法定义如下。

mern-marketplace/server/controllers/order.controller.js:

const orderByID = async (req, res, next, id) => {
  try {
    let order = await Order.findById(id)
                .populate('products.product', 'name price')
                .populate('products.shop', 'name').exec()
    if (!order)
      return res.status('400').json({
        error: "Order not found"
      })
    req.order = order
    next()
  } catch (err){
    return res.status(400).json({
      error: errorHandler.getErrorMessage(err)
    })
  }
}

订单费用 API 将在/api/order/:orderId/charge/:userId/:shopId接收一个 PUT 请求。在成功验证用户后,它将通过调用我们在使用 Stripe 进行支付部分讨论的createCharge用户控制器来创建费用。最后,将使用我们在更新订单状态部分讨论的update控制器方法更新相应的订单。

从视图中,我们将使用api-order.js中的processCharge获取方法,并提供所需的路由参数值、凭证和产品详情,包括要收取的金额。processCharge获取方法定义如下。

mern-marketplace/client/order/api-order.js:

const processCharge = async (params, credentials, product) => {
  try {
    let response = await  fetch('/api/order/'+params.orderId+
        '/charge/'+params.userId+'/'+params.shopId, {
      method: 'PUT',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        'Authorization': 'Bearer ' + credentials.t
      },
      body: JSON.stringify(product)
    })
    return response.json()
  } catch(err) {
    console.log(err)
  }
}

当卖家从下拉菜单中选择“处理”时,ProductOrderEdit视图中会调用此processCharge获取方法。

在这些实现到位后,卖家可以查看他们每个店铺收到的产品订单,并轻松更新每个已订购产品的状态,同时应用程序处理其他任务,例如更新库存数量和启动支付。这涵盖了 MERN Marketplace 应用程序的基本订单管理功能,可以根据需要进一步扩展。在下一节中,我们将讨论如何轻松扩展当前实现以实现其他用于显示订单详情的视图。

查看单次订单详情

在设置了订单集合和数据库访问之后,继续前进,很容易添加为每个用户列出订单以及在一个单独的视图中显示单个订单的详细信息的功能,用户可以在该视图中跟踪每个已订购产品的状态。可以设计并实现一个视图来向客户展示单个订单的详细信息,其外观如下所示:

图片

按照本书中反复提到的步骤设置后端 API 以检索数据并在前端构建前端视图,你可以根据需要开发订单相关的视图。例如,可以渲染以下视图来显示单个用户已下订单:

图片

你可以将构建 MERN Marketplace 应用程序全栈功能时学到的经验应用到实现这些订单详情视图中,并从 MERN Marketplace 应用程序的这些样本视图快照中汲取灵感。

在本章和第七章(03fd3b4a-b7fd-4b42-ad7e-5bc34b5612b0.xhtml)中开发的 MERN Marketplace 应用程序,使用在线市场锻炼 MERN 技能,通过构建 MERN 骨架应用程序涵盖了标准在线市场应用程序的关键功能。这反过来又展示了如何扩展 MERN 堆栈以包含复杂功能。

摘要

在本章中,我们扩展了 MERN Marketplace 应用程序,探讨了如何在在线市场应用程序中为买家添加购物车,实现带有信用卡支付的结账流程,以及为卖家管理订单。

我们发现,当我们实现购物车结账流程并使用 Stripe 提供的工具处理订单产品的信用卡费用时,MERN 堆叠技术可以很好地与第三方集成。

我们还解锁了 MERN 的更多可能性,例如在 MongoDB 中优化批量写入操作,以响应单个 API 调用更新多个文档。这使得我们能够一次性减少多个产品的库存数量,例如当用户从不同商店订购多个产品时。

通过我们探索的这些新方法和实现,你可以轻松集成支付处理,在浏览器中使用离线存储,以及为任何你选择的基于 MERN 的应用程序执行批量数据库操作。

你在 MERN Marketplace 应用程序中开发的市场功能揭示了如何通过添加可能简单或更复杂的特性来利用这个堆叠和结构设计和构建不断增长的应用程序。

在下一章中,我们将利用本书中迄今为止学到的经验,通过扩展这个 MERN Marketplace 应用程序,使其包含实时竞标功能,来探索这个堆叠的更多高级可能性。