介绍
这是一篇由OneEntry CMS赞助的合作文章。
构建电子商务应用程序通常是一项具有挑战性的任务。有如此多的替代方案,选择适合项目要求、可扩展性需求和长期可持续性的技术堆栈并不容易。
另一个关键点是电子商务项目处理大量数据和 CRUD 操作。即使对于最有经验的开发人员来说,创建一个可靠、可扩展且安全的后端系统也可能需要花费大量时间。
我选择了一个基于 NextJS、TypeScript、Tailwind CSS 和 OneEntry CMS 的技术堆栈。我们将自己构建一个实用的电子商务项目,看看它如何协同工作以及如何使用它来简化内容管理。
该项目的代码将在GitHub 存储库中提供。
技术栈的选择
NextJS 是一个用于构建快速高效的 Web 应用程序的 React 框架,它具有客户端和服务器渲染、数据获取、路由处理程序、中间件、内置优化等功能。
TypeScript在 JavaScript 中添加了静态类型,这使得捕获和修复电子商务等可扩展项目的错误变得更加容易。它还通过自动完成和重构辅助等功能提高生产力。
Tailwind CSS加速了 Web 应用程序的样式部分,允许开发人员在标记内设置元素的样式,而无需在外部 CSS 文件之间切换并为每个文件提供类名称。
OneEntry CMS是一款无头内容管理系统,具有易于使用的界面、易于扩展的后端、快速的 API 和清晰的文档,可提高您的网站内容创建和管理体验的生产力。
内容与设计
登陆页面将显示标题,列出商店的功能,并包括英雄图像。
第一个商店部分将专门销售服装。
第二个商店部分将包括齿轮。
每个项目都有一个包含详细信息的单独预览页面。
购物车中已有的商品可以选择删除。
购物车将列出所有选定的商品并计算总数。
创建 OneEntry 项目
首先,用户需要注册一个新帐户。为此,请导航至 OneEntry 主页并通过您的电子邮件帐户注册。
之后,登录,您将被引导至 OneEntry 仪表板。
首先创建一个新项目。
您将收到使用学习计划一个月的免费代码。您将有机会在项目的创建过程中激活它。
创建项目将需要几分钟的时间。准备就绪后,项目状态将更改为“正在运行”,并且状态指示灯将呈绿色。
创建页面
创建项目后,您将收到一封电子邮件,其中包含登录详细信息,用于访问 CMS 门户以创建和存储应用程序的数据。
登录后,您将能够创建您的第一个页面。
导航到内容管理,单击创建新页面,然后填写所需的所有数据 - 页面类型、页面标题、页面 ULR 和菜单项名称。
输入后所有数据都会自动保存。
为主页、服装、装备和购物车创建 4 个不同的页面。创建后,页面应如下面的屏幕截图所示。
创建属性
接下来,我们需要创建我们将存储的数据结构。在OneEntry CMS中,它是通过为数据创建属性来实现的。
导航到“设置”并在水平菜单中选择“属性”。为主页创建一个属性集,提供名称、标记和类型:
创建后,它将如下面的屏幕截图所示:
同样,让我们为 Clothing 和 Gear 创建两个单独的属性集。创建后,结果应如下面的屏幕截图所示。
现在,让我们为每个集合定义特定属性。
根据我们之前在“主页”部分线框中包含的内容,我们希望显示标题、描述和图像。
单击“主页”的齿轮项目,然后创建以下属性名称、标记和属性类型,如下列表所示。
现在,返回并单击“服装”的齿轮图标。
该页面的属性会有所不同,因为我们要显示产品标题、副标题、描述、图像和价格。
属性结构如下所示:
接下来,对 Gear 页面执行相同的操作,该页面将使用相同的结构:
添加内容
在项目的这个阶段,我们已经定义了内容结构并准备开始创建内容本身。
导航到您之前为网站创建所有页面的内容管理部分:
单击主页的编辑按钮。之后,单击水平菜单上的属性选项卡:
选择“主页”作为属性集。这将加载我们之前在主页设置中创建的所有属性。
现在填写一些您想要在主页上显示的示例数据。
现在,让我们为服装和装备页面添加一些内容。
由于我们选择页面类型作为目录,因此从左侧菜单中选择目录,两个页面都应该在那里可见:
现在,单击“服装”的“添加”图标,然后添加一些项目。
首先,添加要添加的产品的标题。
现在切换到“属性”选项卡,选择“服装”作为属性集,然后填写所需的数据。
返回“目录”菜单以及更多有关服装和装备的项目。对于我们的演示应用程序,我添加了 4 个项目,如下面的屏幕截图所示:
创建 API 访问令牌
OneEntry CMS 中创建的所有数据都受到保护,因此我们必须创建一个私有令牌才能访问它。
为此,请导航至“设置”,然后选择“应用程序令牌”。输入应用程序名称和到期日期,然后单击“创建”。这将生成一个唯一的 API 密钥。
单击操作列表中的查看图标,您将能够看到密钥。将其复制到剪贴板,因为我们将在教程的下一部分中需要它。
设置 NextJS 项目
在本教程的这一部分中,我们将开始使用代码并配置 NextJS 项目以与 OneEntry CMS 配合使用。
打开终端,然后运行命令npx create-next-app@latest
。
CLI 将启动设置向导。输入您的项目名称,并选择所有默认值,如下所示:
请花一分钟时间完成设置,创建 NextJS 应用程序后您将收到通知。
之后,使用命令cd winter-sports
将目录更改为新创建的文件夹,然后运行npm run dev
启动开发者服务器。
要访问它,请单击终端上提供的链接或打开 Web 浏览器并手动导航到http://localhost:3000 。
您应该会看到 NextJS 开发者服务器登录页面:
现在,让我们创建应用程序所需的环境价值。切换回代码编辑器,并在项目的根目录下创建一个.env
文件。
将之前复制的 API 密钥粘贴到剪贴板,如下所示:
API_KEY=your-api-code-from-oneentry
一旦我们进行 API 调用以从 OneEntry CMS 获取数据,这将允许我们通过process.env.API_KEY
访问密钥。
我们还需要配置 NextJS,以便它允许我们包含来自外部域的媒体。我们需要它来访问 OneEntry CMS 中的图像。
打开项目根目录下的next.config.js
文件,编辑如下:
const nextConfig = { images: { remotePatterns: [ { hostname: "ecommerce.oneentry.cloud", }, ], }, }; module.exports = nextConfig;
最后,我们需要重置应用程序的 Tailwind 默认样式,因为我们将从头开始编写所有样式。
打开src
文件夹下app
目录下的globals.css
文件,将文件内容修改为:
@tailwind base; @tailwind components; @tailwind utilities;
创建类型
由于我们将使用 TypeScript,因此我们需要定义我们将在应用程序中使用的数据类型。
我们可以在页面和组件内执行此操作,但为了保持代码简洁并避免重复,请在app
程序目录中创建一个新文件夹interfaces
。在新创建的文件夹中创建文件data.tsx
并包含代码:
export interface Product { id: string; category: string; title: string; subtitle: string; description: string; image: string; price: number; } export interface ProductAPI { id: string; attributeValues: { en_US: { producttitle: { value: { htmlValue: string }[]; }; productsubtitle: { value: { htmlValue: string }[]; }; productdescription: { value: { htmlValue: string }[]; }; productimage: { value: { downloadLink: string }[]; }; productprice: { value: number; }; }; }; } export interface Page { pageUrl: string; title: string; description: string; image: string; localizeInfos: { en_US: { title: string; }; }; } export interface PageAPI { attributeValues: { en_US: { herotitle: { value: { htmlValue: string }[]; }; herodescription: { value: { htmlValue: string }[]; }; heroimage: { value: { downloadLink: string }[]; }; }; }; } export interface URLProps { params: { category: string; productId: string; }; } export interface TextProps { className: string; text: string; }
产品和页面数据都具有前端呈现数据结构的类型以及通过 fetch 方法来自 API 的响应。
此外,我们还为来自 URL 参数的数据定义了数据类型,并为从 CMS 中的文本输入字段接收的数据定义了文本渲染器。
创建 API 获取函数
现在,让我们创建一些函数,用于与 OneEntry CMS 通信以获取页面和产品的数据。
同样,我们可以在每个文件中执行此操作,但为了使代码更清晰,让我们在app
目录中创建一个新文件夹services
,其中包含文件fetchData.tsx
:
export async function getPages() { const response = await fetch( "https://ecommerce.oneentry.cloud/api/content/pages", { method: "GET", headers: { "x-app-token": `${process.env.API_KEY}`, }, } ); return await response.json(); } export async function getProducts(category: string) { const response = await fetch( `https://ecommerce.oneentry.cloud/api/content/products/page/url/${category}?limit=4&offset=0&langCode=en_US&sortOrder=DESC&sortKey=id`, { method: "GET", headers: { "x-app-token": `${process.env.API_KEY}`, }, } ); return await response.json(); } export async function getProduct(id: string) { const response = await fetch( `https://ecommerce.oneentry.cloud/api/content/products/${id}`, { method: "GET", headers: { "x-app-token": `${process.env.API_KEY}`, }, } ); return await response.json(); }
getPages
函数将获取我们在 OneEntry CMS 中创建的所有页面的数据。
getProducts
函数将根据category
参数获取特定产品集合的数据。当我们将函数导入产品页面时,我们将传递该参数。
getProduct
函数将根据我们打开的产品的id
获取数据。当我们将函数导入到任何特定产品的预览页面时,我们都会传递该参数。
请注意,我们使用process.env.API_KEY
来访问我们之前在.env
文件中定义的 API 密钥,以验证对 OneEntry CMS 的访问。
创建辅助函数
另外,当我们仍在services
文件夹中时,让我们在其中创建另一个名为helpers.tsx
的新文件,其中将包含小型实用程序函数:
export function calculateTotal(items: { price: number }[]) { return items.reduce((total, item) => total + Number(item.price), 0); } export function boughtStatus(items: { id: string }[], id: string) { return items.some((item) => item.id === id); } export function cartIndex(items: { id: string }[], id: string) { return items.findIndex((item) => item.id === id); }
calculateTotal
函数将添加到购物车的产品的价格相加,并返回总价值。
boughtStatus
将检测预览路线中的各个商品是否已添加到购物车。
cartIndex
将检测已添加到购物车的产品在数组中的位置。
创建组件
导航回app
程序目录,并在其中创建一个新文件夹components
。
打开新创建的文件夹并在其中包含七个单独的文件: Header.tsx
、 Footer.tsx
、 Text.tsx
、 Card.tsx
、 Preview.tsx
、 Order.tsx
、 AddToCart.tsx
。
标头组件
打开文件Header.tsx
,并包含以下代码:
import Link from "next/link"; import { Page } from "../interfaces/data"; export default function Header({ pages }: { pages: Page[] }) { return ( <div className="flex justify-between items-center mb-10 p-6"> <Link href="/"> <h1 className="text-xl">🏂 Alpine Sports</h1> </Link> <div className="flex space-x-4 list-none"> {pages.map((page, index: number) => ( <Link key={index} href={page.pageUrl === "home" ? "/" : `/${page.pageUrl}`} > {page.localizeInfos.en_US.title} </Link> ))} </div> </div> ); }
对于标题,我们显示了公司名称,并循环浏览导航链接,一旦组件导入到页面中,我们将从 API 中获得这些链接。
我们创建了一个两列布局,并将两个元素水平放置在屏幕的相对两侧,以实现典型的导航外观。
页脚组件
打开文件Footer.tsx
,并包含以下代码:
export default function Footer() { return ( <div className="text-center mt-auto p-6"> <h1>Alpine Sports, Inc.</h1> <p>All rights reserved, {new Date().getFullYear()}</p> </div> ); }
在页脚中,我们包含了公司的示例名称以及当年的内容权利。我们将内容居中并添加了一些填充。
文本组件
打开文件Text.tsx
并包含以下代码:
import { TextProps } from "../interfaces/data"; export default function Text({ className, text }: TextProps) { return ( <div className={className} dangerouslySetInnerHTML={{ __html: text }} /> ); }
文本组件将呈现我们从 OneEntry CMS 接收的文本数据,并在我们的应用程序中正确显示它,无需 HTML 标签。
卡组件
打开文件Card.tsx
并包含以下代码:
import Link from "next/link"; import Text from "../components/Text"; import { Product } from "../interfaces/data"; export default function Card({ product }: { product: Product }) { return ( <Link href={`/${product.category}/${product.id}`}> <div className="group relative"> <div className="group-hover:opacity-75 h-80"> <img src={product.image} alt="Product card image" className="h-full w-full object-cover object-center" /> </div> <div className="mt-4 flex justify-between"> <div> <h3 className="text-sm text-gray-700"> <Text className="" text={product.title} /> </h3> <Text className="mt-1 text-sm text-gray-500" text={product.subtitle} /> </div> <p className="text-sm font-medium text-gray-900">${product.price}</p> </div> </div> </Link> ); }
在卡片组件中,我们显示了每个产品的图像、标题、副标题和价格。一旦将所有项目导入页面,我们将对其进行映射。
图像将显示在卡片的顶部,后面是标题和说明,价格则显示在组件的右下角。
预览组件
打开文件Preview.tsx
,并包含以下代码:
"use-client"; import Image from "next/image"; import Text from "./Text"; import { Product } from "../interfaces/data"; export default function Preview({ children, productItem, }: { children: React.ReactNode; productItem: Product; }) { return ( <div className="flex mx-auto max-w-screen-xl"> <div className="flex-1 flex justify-start items-center"> <Image src={productItem.image} alt="Product preview image" width="450" height="900" /> </div> <div className="flex-1"> <Text className="text-5xl pb-8" text={productItem.title} /> <Text className="text-4xl pb-8 text-gray-700" text={`$${productItem.price}`} /> <Text className="pb-8 text-gray-500 text-justify" text={productItem.description} /> {children} </div> </div> ); }
预览组件将用于在用户单击每个产品后显示有关每个产品的更多信息。
我们将显示产品图片、标题、价格和描述。布局将分为 2 列,图像显示在左列,其余内容显示在右列。
订单组件
打开文件Order.tsx
,并包含以下代码:
"use client"; import { useState, useEffect } from "react"; import Link from "next/link"; import Image from "next/image"; import Text from "./Text"; import { calculateTotal } from "../services/helpers"; import { Product } from "../interfaces/data"; export default function Order() { const [cartItems, setCartItems] = useState<Product[]>([]); useEffect(() => { const storedCartItems = localStorage.getItem("cartItems"); const cartItems = storedCartItems ? JSON.parse(storedCartItems) : []; setCartItems(cartItems); }, []); return ( <div> {cartItems.map((item, index) => ( <div key={index} className="flex items-center border-b border-gray-300 py-2" > <div className="w-20 h-20 mr-12"> <Image src={item.image} alt={item.title} width={80} height={80} /> </div> <div> <Link href={`/${item.category}/${item.id}`} className="text-lg font-semibold" > <Text className="" text={item.title} /> </Link> <Text className="text-gray-600" text={item.subtitle} /> <p className="text-gray-800">Price: ${item.price}</p> </div> </div> ))} <div className="mt-4 text-end"> <h2 className="text-xl font-semibold mb-8"> Total Amount: ${calculateTotal(cartItems)} </h2> <button className="bg-blue-500 hover:bg-blue-700 py-2 px-8 rounded"> Proceed to checkout </button> </div> </div> ); }
订单组件将列出用户已添加到购物车的所有商品。对于每个项目,将显示图像、标题、副标题和价格。
渲染组件后,应用程序将访问购物车中当前的所有商品,将它们设置为cardItems
状态变量,并通过map
方法将它们渲染到屏幕上。
渲染项目的总量将通过我们从helpers.tsx
文件导入的calculateTotal
函数计算。
添加到购物车组件
打开文件AddToCart.tsx
,并包含以下代码:
"use client"; import React, { useState, useEffect } from "react"; import { boughtStatus, cartIndex } from "../services/helpers"; import { Product } from "../interfaces/data"; export default function AddToCart({ category, id, title, subtitle, image, price, }: Product) { const storedCartItems = JSON.parse(localStorage.getItem("cartItems") || "[]"); const isPurchased = boughtStatus(storedCartItems, id); const indexInCart = cartIndex(storedCartItems, id); const [btnState, setBtnState] = useState(false); useEffect(() => { isPurchased && setBtnState(true); }, []); const handleButtonClick = () => { const updatedCartItems = [...storedCartItems]; if (!btnState && !isPurchased) { updatedCartItems.push({ category, id, title, subtitle, image, price }); } else if (isPurchased) { updatedCartItems.splice(indexInCart, 1); } localStorage.setItem("cartItems", JSON.stringify(updatedCartItems)); setBtnState(!btnState); }; return ( <button className={`${ !btnState ? "bg-blue-500 hover:bg-blue-600" : "bg-yellow-300 hover:bg-yellow-400" } py-2 px-8 rounded`} onClick={handleButtonClick} > {!btnState ? "Add to Cart" : "Remove from Cart"} </button> ); }
addToCart 组件将显示在单个产品预览页面上,并允许用户将产品添加到购物车。
渲染后, isPurchased
函数将检测该产品之前是否已添加到购物车。如果不是呈现的按钮,将显示“添加到购物车”,否则会显示“从购物车删除”。
handleButtonClick
函数的单击功能将根据上述状态相应地从 items 数组中添加或删除产品。
创建页面
最后,让我们导入我们在教程上一节中创建的组件并为应用程序创建页面逻辑。
主页
打开app
目录下的page.tsx
,编辑其内容如下:
import Image from "next/image"; import Header from "./components/Header"; import Text from "./components/Text"; import Footer from "./components/Footer"; import { getPages } from "./services/fetchData"; import { PageAPI } from "./interfaces/data"; export default async function Home() { const pages = await getPages(); const getValues = (el: PageAPI) => { const { herotitle, herodescription, heroimage } = el.attributeValues.en_US; return { title: herotitle.value[0].htmlValue, description: herodescription.value[0].htmlValue, image: heroimage.value[0].downloadLink, }; }; const pageContent = getValues(pages[0]); return ( <div className="flex flex-col min-h-screen"> <Header pages={pages} /> <div className="flex flex-row mx-auto max-w-screen-xl"> <div className="flex-1"> <Text className="text-6xl pb-10 text-gray-900" text={pageContent.title} /> <Text className="text-xl pb-8 text-gray-500 text-justify" text={pageContent.description} /> </div> <div className="flex-1 flex justify-end items-center"> <Image src={pageContent.image} alt="Photo by Karsten Winegeart on Unsplash" width={450} height={900} /> </div> </div> <Footer /> </div> ); }
在首页上,我们首先调用getPages
函数来获取 Header 的数据。
然后我们使用getValues
函数来获取 Hero 页面数据,然后将其转换为pageContent
对象以便于处理。
然后,我们渲染导入的页眉和页脚组件,并传递英雄标题、描述和图像的必要值。
产品页面
在app
程序目录中创建一个新文件夹[category]
并在其中创建一个文件page.tsx
。
使用特定文件名很重要,因为 NextJS 使用它来处理路由和访问 URL 参数。
在page.tsx
中包含以下代码:
import Header from "../components/Header"; import Footer from "../components/Footer"; import Card from "../components/Card"; import { getPages, getProducts } from "../services/fetchData"; import { ProductAPI, URLProps } from "../interfaces/data"; export default async function Product({ params }: URLProps) { const { category } = params; const pages = await getPages(); const products = await getProducts(category); const getValues = (products: ProductAPI[]) => { return products.map((el) => { const { producttitle, productsubtitle, productdescription, productimage, productprice, } = el.attributeValues.en_US; return { id: el.id, category: category, title: producttitle.value[0].htmlValue, subtitle: productsubtitle.value[0].htmlValue, description: productdescription.value[0].htmlValue, image: productimage.value[0].downloadLink, price: productprice.value, }; }); }; const productItems = getValues(products.items); return ( <div className="flex flex-col min-h-screen"> <Header pages={pages} /> <div className="mx-auto max-w-screen-xl px-8"> <h2 className="text-4xl text-gray-900 mb-12"> Browse our {category} collection: </h2> <div className="grid gap-x-6 gap-y-10 grid-cols-4 mt-6"> {productItems.map((product) => { return <Card key={product.id} product={product} />; })} </div> </div> <Footer /> </div> ); }
对于产品页面,我们首先从 URL 中获取category
参数,并将其进一步传递到getProducts
函数中,以描述我们需要根据访问网站的哪个页面来获取哪个类别的产品。
收到数据后,我们将创建一个对象数组productItems
,其中包含页面的所有必要属性,以便于处理。
然后我们通过map
方法循环它,并通过将 props 传递给我们从component
文件夹导入的 Card 组件来将其渲染到屏幕上。
预览页面
在[category]
文件夹内,创建另一个名为[productId]
的文件夹。
打开新创建的文件夹,并在其中创建一个文件page.tsx
,代码如下:
import Header from "../../components/Header"; import Preview from "../../components/Preview"; import AddToCart from "../../components/AddToCart"; import Footer from "../../components/Footer"; import { getPages, getProduct } from "../../services/fetchData"; import { ProductAPI, URLProps } from "../../interfaces/data"; export default async function Product({ params }: URLProps) { const { category, productId } = params; const pages = await getPages(); const product = await getProduct(productId); const getValues = (el: ProductAPI) => { const { producttitle, productsubtitle, productdescription, productimage, productprice, } = el.attributeValues.en_US; return { id: el.id, category: category, title: producttitle.value[0].htmlValue, subtitle: productsubtitle.value[0].htmlValue, description: productdescription.value[0].htmlValue, image: productimage.value[0].downloadLink, price: productprice.value, }; }; const productItem = getValues(product); return ( <div className="flex flex-col min-h-screen"> <Header pages={pages} /> <div className="flex mx-auto max-w-screen-xl"> <div className="flex-1 flex justify-start items-center"> <Preview productItem={productItem}> <AddToCart id={productId} category={category} title={productItem.title} subtitle={productItem.subtitle} description={productItem.description} image={productItem.image} price={productItem.price} /> </Preview> </div> </div> <Footer /> </div> ); }
一旦用户单击产品页面上的卡片,此页面将允许用户查看任何单个产品的更多详细信息。
我们首先从 URL 中获取productId
参数,并将其进一步传递到getProduct
函数中,以根据预览页面上查看的产品来指定需要获取的产品。
收到数据后,我们创建一个对象productItem
,其中包含要作为 props 传递到 Preview 组件的所有必要属性。
我们还获取category
参数,因为我们需要将其传递给添加到购物车组件,因此我们可以为购物车页面中的商品创建有效链接。
购物车页面
最后,在app
目录中创建一个新的文件夹cart
。
打开它,使用以下代码在其中创建一个新文件page.tsx
:
import Header from "../components/Header"; import Order from "../components/Order"; import Footer from "../components/Footer"; import { getPages } from "../services/fetchData"; export default async function Cart() { const pages = await getPages(); return ( <div className="flex flex-col min-h-screen"> <Header pages={pages} /> <div className="container mx-auto max-w-screen-xl px-8"> <h2 className="text-4xl text-gray-900 mb-12">Shopping cart summary:</h2> <Order /> </div> <Footer /> </div> ); }
我们首先获取必要的数据,然后将其作为 props 传递到 Header 中。
然后,我们渲染带有导航的 Header 组件、列出用户已添加到购物车的所有商品的 Order 组件以及带有公司名称和版权信息的 Footer 组件。
测试
恭喜,您已经完成了一个工作项目!
首先,检查开发者服务器是否仍在运行。如果没有,请运行命令npm run dev
重新启动并访问localhost:3000查看。
您的项目现在应该如下所示:
正如您所看到的,主页部分内容已成功从我们在数据字段中指定的主页属性集中获取。
此外,OneEntry CMS 目录中的所有项目均已在“服装”和“装备”部分中获取,并且所有信息均已正确呈现。
借助 NextJS 路由处理和产品参数,用户还可以在其专用页面上单独预览每个产品。
此外,所有功能和事件都按预期工作,用户可以在购物车中添加和删除商品,并计算总数。
结论
在本教程中,我们创建了一个电子商务项目,允许用户创建、更新和删除网站页面及其内容,并通过OneEntry CMS 的易于使用的目录界面轻松管理产品。
该代码可在GitHub上找到,因此请随意克隆它并向其添加更多功能以满足您的需求。您可以向其中添加更多菜单部分、扩展各个组件,甚至添加更多组件来实现新功能。
希望这对您有用,让您深入了解如何使用 OneEntry CMS 作为后端解决方案、如何将其与应用程序的前端配对,以及如何使用 NextJS、Typescript 和 Tailwind 的最佳功能。
通过订阅我的时事通讯,确保获得我发现的最好的资源、工具、生产力技巧和职业发展技巧!
另外,请在Twitter 、 LinkedIn和GitHub上与我联系!
也发布在这里