Tutorial: Product Highlights
In this tutorial, you will build an illustrative Product Highlights extension using the Node+React technology stack. You'll create a working extension that enables sellers to create highlights for required products and display the price drop tag under the price if it has been altered recently.
Prerequisite
Before starting this tutorial, make sure you have created an extension. Refer to Getting Started for more details.
Tutorial is divided into following sections:
- Adding Database Configuration
- Building the extension home page
- Building 'Highlights Listing' page
- Building 'Create Highlight' page
- Injecting Javascript into the storefront theme
- Configuring Webhook
Learing Outcomes
- How to call Fynd Commerce SDK methods
- Build Extension UI using the Nitrozen design system
- Create script tag binding to inject script tag in storefront to render extension UI elements within storefront theme
- Configure Webhooks for listening to required events
Download the Product Highlight Example Extension
To view the complete code used for the Product Highlight example extension, you can download the source code from GitHub. To learn about the code step-by-step, we recommend you follow this tutorial series.
Adding Database Configuration
Step 1: Populate Test Products in Your Development Account
- Run the following command to login to FDK-CLI:
fdk login
If logging in the previous step has been completed, there is no need to log in again. This step can be skipped.
After login, you can run fdk user to verify if you've logged in.
- Run the following command:
fdk populate
- Provide company id of required development account to populate test products in it.
Step 2: Provide MongoDB Configuration
- Add port and MongoDB local URL in
/.envfile.
MONGODB_URI="mongodb://localhost:27017/productHighlights"
PORT=8080
- Add MongoDB URL in
/app/config.jsfile.
mongodb: {
uri: {
doc: "Mongodb uri",
default: "",
env: 'MONGODB_URI'
}
},
Step 3: Preview the Changes
- Preview the changes using these steps
Building the Extension Home Page
In this section, you'll use Node.js, React, MongoDB, and Nitrozen components to build your Extension home page.
This section consists of three steps:
- Create schema for saving product and its highlights using Mongoose library.
- Make changes in the Extension backend.
- Add additional components to the Home page of the Extension Frontend.
Step 1: Create MongoDB Schema
The app needs a database to store the Product details so sellers can view and edit the saved product highlights.
The database collection includes product highlights and basic details about the product such as name, product brand, product slug, and product item code.
- Run the following commands to install
mongoosepackage:
npm i mongoose
- Create new
/app/db/mongo.jsfile and add the following code to the file:
const config = require('../config');
const mongoose = require('mongoose');
// mongodb connection
mongoose.connect(config.mongodb.uri);
const ProductSchema = new mongoose.Schema({
name: {
type: String,
},
image: {
type: String
},
brand_name: {
type: String
},
category_slug: {
type: String
},
highlights: {
type: [String]
},
price: {
type: Object
},
enablePriceDrop: {
type: Boolean,
default: false
}
})
const ProductHighlightSchema = new mongoose.Schema({
company_id: {
type: String,
},
application_id: {
type: String,
},
product_item_code: {
type: Number,
unique: true,
index: true
},
product_slug: {
type: String,
unique: true,
index: true
},
product: {
type: ProductSchema
},
is_active: {
type: Boolean,
default: false
}
})
const ProductHighlightRecord = mongoose.model("productHighlight", ProductHighlightSchema);
module.exports = { ProductHighlightRecord }
Step 2: Update the Backend API
- Change the name of
/app/routes/v1.router.jsfile to/app/routes/product.router.jsand make changes to theGET/applicationsAPI.
const { ProductHighlightRecord } = require('../db/mongo')
// Get applications list
router.get('/applications', async function view(req, res, next) {
try {
const {
platformClient
} = req;
const companyId = req.fdkSession.company_id;
let applications = await platformClient.configuration.getApplications({
pageSize: 1000,
q: JSON.stringify({"is_active": true})
})
let activeApplicationSet = new Set();
let productSchema = await ProductHighlightRecord.find({company_id: companyId, is_active: true}, {application_id: 1});
for (let index = 0; index < productSchema.length; index++) {
activeApplicationSet.add(productSchema[index].application_id.toString());
}
for (let index = 0; index < applications?.items?.length; index++) {
applications.items[index] = {
...applications?.items?.[index],
is_active: activeApplicationSet.has(applications.items[index]._id.toString()) ? true : false
}
}
return res.status(200).json(applications);
} catch (err) {
next(err);
}
});
- Do the following changes in
/app/server.jsfile:
- Change line-3 and line-31 from
3. const v1Router = require("./routes/v1.router");
31. apiRoutes.use('/v1.0', v1Router)
to
3. const productRouter = require("./routes/product.router");
31. apiRoutes.use('/v1.0', productRouter)
Step 3: Changes in the Front End
We'll be using the Badge and Input components and SvgIcArrowNext Icon of the Nitrozen library
Check out Nitrozen's storybook for detailed documentation and usage instructions.
- Open
/src/views/Home.jsx - Delete the contents of the file.
- Add the following code:
import React, { useState, useEffect } from "react";
import { useNavigate, useParams } from 'react-router-dom'
import "./style/home.css";
import Loader from "../components/Loader";
import { Badge, SvgIcArrowNext, Input } from "@gofynd/nitrozen-react";
import MainService from "../services/main-service";
export default function Home() {
const [pageLoading, setPageLoading] = useState(false);
const [applicationList, setApplicationList] = useState([]);
const [allApplications, setAllApplications] = useState([]);
const navigate = useNavigate();
const { company_id } = useParams();
useEffect(() => {
fetchApplications();
}, []);
const fetchApplications = async () => {
setPageLoading(true);
try {
const { data } = await MainService.getAllApplications();
setAllApplications(data.items);
const temp = data.items.map((ele) => {
ele.text = ele.name;
ele.value = ele._id;
ele.image = ele.logo;
ele.logo = ele.image && ele.image.secure_url;
return ele;
});
setApplicationList(temp);
setPageLoading(false);
} catch (e) {
setPageLoading(false);
}
};
function searchApplication(event) {
let searchText = event.target.value;
if (!searchText) {
setApplicationList(allApplications.map((app) => app));
} else {
setApplicationList(
allApplications.filter((item) => {
return item.name.toLowerCase().includes(searchText.toLowerCase());
})
);
}
}
function clickOnSalesChannel(application_id) {
navigate(`/company/${company_id}/${application_id}/product-list`)
}
return (
<>
{pageLoading ? (
<Loader />
) : (
<div className="application-container">
<div className="saleschannel-title">Sales Channel</div>
<div className="search-box">
<Input
showSearchIcon
placeholder='search sales channels'
disabled={ Object.keys(allApplications).length === 0 ? true : false }
onChange={searchApplication}
/>
</div>
<div className="sales-channels-container">
{applicationList.map((application) => {
return (
<div className="app-box">
<div className="logo">
<img src={application.logo ? application.logo : "https://platform.fynd.com/public/admin/assets/pngs/fynd-store.png"} alt="logo" />
</div>
<div className="line-1">{application.name}</div>
<div className="line-2">{application.domain.name}</div>
<div className="button-and-arrow">
<div>
<Badge
fill
kind="normal"
state={application.is_active ? "success" : "disable"}
labelText={application.is_active ? "ACTIVE" : "INACTIVE"}
style={{
padding: "10px 6px"
}}
/>
</div>
<div className="card-arrow">
<div className="card-arrow-box"
onClick={() => clickOnSalesChannel(application._id)}
>
<SvgIcArrowNext
className="arrow-next"
/>
</div>
</div>
</div>
</div>
);
})}
{applicationList.length % 3 === 2 && (
<div className="app-box hidden"></div>
)}
</div>
</div>
)}
</>
);
}
Now, update the CSS file for the above code
- Open
/src/views/style/home.css - Delete the contents of the file.
- Add the following code:
CSS written in this file will have an impact on the entire project.
html {
height: 100%;
width: 100%;
font-size: 8px;
}
body {
margin: 0;
font-family: Inter;
background-color: #f8f8f8 !important;
width: 100%;
height: 100%;
@media @mobile {
-webkit-tap-highlight-color: transparent;
}
}
.application-container {
font-family: Inter;
position: relative;
box-sizing: border-box;
background: #fff;
border: 1px solid #f3f3f3;
border-radius: 12px;
padding: 24px;
margin: 24px;
}
.saleschannel-title {
font-weight: 700;
font-size: 20px;
margin-bottom: 8px;
}
.search-box {
margin-top: 20px;
}
.sales-channels-container {
display: grid;
grid-template-columns: 25% 25% 25% 25%;
grid-column-gap: 18px;
grid-row-gap: 18px;
margin-top: 20px;
width: calc(100% - 54px);
}
.app-box {
background-color: #ffffff;
border: 1px solid #e4e5e6;
padding: 20px;
border-radius: 12px;
}
.app-box .logo {
width: 48px;
height: 48px;
}
.app-box .logo img {
width: 100%;
height: auto;
}
.app-box .line-1 {
font-weight: 600;
font-size: 16px;
line-height: 26px;
margin-top: 20px;
}
.app-box .line-2 {
color: #9b9b9b;
line-height: 22px;
font-size: 12px;
}
.app-box + .app-box:nth-child(3n + 1) {
margin-left: 0;
}
/* card footer elements */
.app-box .button-and-arrow {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 40px;
}
.card-arrow-box {
border: 1px solid #2E31BE;
border-radius: 4px;
height: 36px;
width: 36px;
display: flex;
align-items: center;
justify-content: center;
}
.card-arrow-box:hover {
transition-duration: 0.4s;
background-color: #2E31BE;
cursor: pointer;
}
.arrow-next {
color: #2E31BE;
width: 16px;
height: auto;
}
.card-arrow-box:hover .arrow-next {
transition-duration: 0.4s;
color: #ffffff;
cursor: pointer;
}
.hidden {
visibility: hidden;
}
Step 4: Restart the Server and Refresh Extension Page in Development Account
![]()
Building 'Highlights Listing' page
Step 1: Create Backend APIs
- Add the following code to the
/app/routes/product.route.js.
// get product highlights list
router.get("/:application_id/highlight/list", async function view(req, res, next) {
try {
const { application_id } = req.params;
const companyId = Number(req.fdkSession.company_id);
let data = await ProductHighlightRecord.find({application_id: application_id, company_id: companyId}).exec();
return res.status(200).json(data);
} catch(error) {
next(error)
}
})
// get product highlight by product item code or slug
router.get("/:application_id/highlight", async function view(req, res, next) {
try {
const { application_id } = req.params;
let { slug, item_code } = req.query;
const companyId = Number(req.fdkSession.company_id);
// const companyId = 11197;
let data;
if (item_code) {
item_code = Number(item_code)
data = await ProductHighlightRecord.findOne({
application_id: application_id, company_id: companyId, product_item_code: item_code
}).exec();
} else if (slug) {
data = await ProductHighlightRecord.findOne({
company_id: companyId, application_id: application_id, product_slug: slug
})
} else {
return res.status(400).json({"Error": "Invalid Item code or slug in the query param"});
}
return res.status(200).json(data);
} catch(error) {
next(error);
}
})
// delete product highlight by item code or slug
router.delete("/:application_id/highlight", async function view(req, res, next) {
try {
const { application_id } = req.params;
const { slug } = req.query;
const item_code = Number(req.query.item_code);
const companyId = Number(req.fdkSession.company_id);
let data;
if (item_code !== "") {
data = await ProductHighlightRecord.deleteOne({
application_id: application_id, company_id: companyId, product_item_code: item_code
}).exec();
} else if (slug !== "") {
data = await ProductHighlightRecord.deleteOne({
company_id: companyId, application_id: application_id, product_slug: slug
}).exec();
} else {
throw new Error("Invalid Item code or slug in query param");
}
return res.status(200).json(data);
} catch(error) {
next(error);
}
})
- Add the following endpoints to the
Endpointsobject in/src/service/endpoint.service.jsfile:
GET_HIGHLIGHT_LIST(application_id) {
return urlJoin(envVars.EXAMPLE_MAIN_URL, `api/v1.0/${application_id}/highlight/list`);
},
PRODUCT_HIGHLIGHT(application_id) {
return urlJoin(envVars.EXAMPLE_MAIN_URL, `api/v1.0/${application_id}/highlight`);
},
- Add the following methods to the
MainServiceobject in/src/service/main-service.jsfile:
// product highlights
getHighlightList(application_id) {
return axios.get(URLS.GET_HIGHLIGHT_LIST(application_id));
},
getProductHighlight(application_id, item_code="", slug="") {
return axios.get(URLS.PRODUCT_HIGHLIGHT(application_id), {params: {item_code, slug}});
},
deleteProductHighlight(application_id, item_code="", slug="") {
return axios.delete(URLS.PRODUCT_HIGHLIGHT(application_id), {params: {item_code, slug}});
},
Step 2: Create FrontEnd Page
-
In the
/src/views/directory create a new file calledProductList.jsxand add the following code to the file:import React, { useEffect, useState, useRef } from "react";
import Loader from "../components/Loader";
import { Button, Input, ToggleButton, SvgIcEdit, SvgIcTrash, SvgIcArrowBack } from "@gofynd/nitrozen-react";
import { useParams, useNavigate } from 'react-router-dom'
import styles from './style/productList.module.css';
import MainService from "../services/main-service";
function ProductCard({productItem, onProductDelete}) {
const [toggleState, setToggleState] = useState(productItem.is_active);
const { company_id, application_id } = useParams();
const dummyState = useRef(false);
const navigate = useNavigate();
useEffect(() => {
if (dummyState.current) {
(async () => {
if (toggleState) {
await MainService.addInjectableTag(application_id, productItem.product_item_code);
} else {
await MainService.deleteInjectableTag(application_id, productItem.product_item_code);
}
})()
}
dummyState.current = true;
}, [application_id, productItem.product_item_code, toggleState])
return (
<>
<div className={styles.product_card}>
<div className={styles.card_left}>
{/* PRODUCT LOGO */}
<div className={styles.image_card}>
<img className={styles.logo} src={productItem.product.image} alt="product_image" />
</div>
{/* PRODUCT META */}
<div className={styles.product_metadata}>
<div className={styles.product_metadata_header}>
<div className={styles.header_name}>
{productItem.product.name}
</div>
<div className={styles.pipe}>
|
</div>
<span className={styles.item_code}>
Item code: {productItem.product_item_code}
</span>
</div>
<div className={styles.product_metadata_brand}>
{productItem.product.brand_name}
</div>
<div className={styles.product_metadata_category}>
category: {productItem.product.category_slug}
</div>
</div>
</div>
{/* TOGGLE BUTTON */}
<div className={styles.product_toggle_button}>
<ToggleButton
id={productItem.product_item_code}
size={"small"}
value={toggleState}
onToggle={async (event) => {
setToggleState((pre) => !pre);
}}
/>
</div>
<div className={styles.product_delete_edit}>
{/* DELETE SVG */}
<div>
<SvgIcTrash
color="#2E31BE"
className={styles.product_delete}
onClick={async () => {
if (toggleState) {
await MainService.deleteInjectableTag(application_id, productItem.product_item_code);
}
await MainService.deleteProductHighlight(application_id, productItem.product_item_code);
onProductDelete(productItem.product_item_code);
}}
/>
</div>
{/* EDIT SVG */}
<div>
<SvgIcEdit
color="#2E31BE"
className={styles.product_edit}
onClick={() => {
navigate(`/company/${company_id}/${application_id}/highlight/${productItem.product_item_code}`);
}}
/>
</div>
</div>
</div>
</>
)
}
export default function ProductList() {
const [pageLoading, setPageLoading] = useState(false);
const [productItems, setProductItems] = useState([]);
const [allProductItems, setAllProductItems] = useState([]);
const [searchTextValue, setSearchTextValue] = useState("");
const navigate = useNavigate();
const { company_id, application_id } = useParams();
async function fetchProductItems() {
const { data } = await MainService.getHighlightList(application_id);
setAllProductItems(data);
setProductItems(data);
setPageLoading(false);
}
function createProductHighlights() {
navigate(`/company/${company_id}/${application_id}/highlight/create`)
}
function onProductDelete(product_item_code) {
setAllProductItems((prevState) => {
let findIndex = prevState.findIndex(product => product.product_item_code === product_item_code);
prevState.splice(findIndex, 1);
let newArr = [...prevState]
return newArr;
})
}
useEffect(() => {
if (!searchTextValue) {
setProductItems(allProductItems.map((product) => product))
} else {
setProductItems(
allProductItems.filter((item) => {
return item.product.name.toLowerCase().includes(searchTextValue.toLowerCase());
})
)
}
}, [allProductItems, searchTextValue]);
useEffect(() => {
setPageLoading(true);
fetchProductItems()
}, []);
return (
<>
{ pageLoading ? (
<Loader />
) : (
<div className={styles.main_wrapper}>
<div className={styles.sticky_header}>
<div className={styles.navbar_left_section}>
{/* BACK ARROW */}
<div className={styles.back_arrow}>
<SvgIcArrowBack
color='#2E31BE'
style={{
width: "24px",
height: "auto"
}}
onClick={() => {
navigate(`/company/${company_id}/`);
}}
/>
</div>
{/* SEARCH INPUT */}
<div className={styles.search_product_highlight}>
<Input
showSearchIcon
className={styles.search_input}
type="text"
placeholder="search by product name"
value={searchTextValue}
disabled={Object.keys(allProductItems).length === 0 ? true : false }
onChange={(event) => {
setSearchTextValue(event.target.value);
}}
/>
</div>
</div>
{/* CREATE HIGHLIGHT BUTTON */}
<div className={styles.create_highlight_button}>
<Button
onClick={() => {createProductHighlights()}}
rounded={false}
>
Create Product Highlight
</Button>
</div>
</div>
<div className={styles.product_listing}>
{productItems.map((product) => (
<ProductCard
key={product.product_item_code}
productItem={product}
onProductDelete={onProductDelete}
/>
))}
</div>
</div>
)}
</>
);
}
Step 3: Add CSS for the ProductList Component
-
In the
/src/views/style/directory, create a new file calledproductList.module.cssand add the following code to the file:.main_wrapper {
font-family: Inter;
position: relative;
box-sizing: border-box;
background: #fff;
border: 1px solid #f3f3f3;
border-radius: 12px;
padding: 24px;
margin: 24px;
}
.sticky_header {
background-color: #ffffff;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.back_arrow {
cursor: pointer;
}
.navbar_left_section {
display: flex;
align-items: center;
}
/* button */
.create_highlight_button {
margin: 12px 12px;
max-height: 48px;
}
/* Product search */
.search_product_highlight {
max-width: 480px;
min-width: 480px;
margin: 0 24px;
}
.search_input {
height: 18px;
}
/* Product listing */
.product_listing {
margin: 0 16px;
padding-top: 24px;
}
.product_card {
display: flex;
justify-content: space-between;
border: 1px solid #e4e5e6;
min-height: 102px;
padding: 16px;
border-radius: 4px;
margin-bottom: 16px;
box-sizing: border-box;
transition: box-shadow 0.3s;
}
.product_card:hover {
box-shadow: 0px 9px 13px 0px rgba(221, 221, 221, 0.5);
}
.product_card .card_left {
display: flex;
flex-direction: row;
align-items: center;
flex: 2;
}
.product_card .image_card {
min-height: 60px;
min-width: 60px;
max-height: 60px;
max-width: 60px;
display: flex;
align-items: center;
margin: 0 12px;
}
.product_card .image_card .logo {
height: 60px;
width: 100%;
object-fit: cover;
border-radius: 50%;
}
.product_metadata {
display: flex;
flex-direction: column;
}
.product_metadata_header {
display: flex;
flex-direction: row;
align-items: center;
}
.product_metadata_header .header_name {
line-height: 21px;
margin-right: 10px;
color: #41434C;
font-weight: 600;
font-size: 14px;
-webkit-font-smoothing: antialiased;
}
.product_metadata_header .pipe {
line-height: 20px;
margin-right: 10px;
color: #9B9B9B;
font-weight: 400;
font-size: 12px;
-webkit-font-smoothing: antialiased;
}
.product_metadata_header .item_code {
line-height: 20px;
color: #9B9B9B;
font-weight: 400;
font-size: 12px;
-webkit-font-smoothing: antialiased;
}
.product_metadata_brand, .product_metadata_category {
color: #666666;
line-height: 21px;
font-weight: 400;
font-size: 12px;
-webkit-font-smoothing: antialiased;
}
.product_toggle_button {
flex: 0.5;
display: flex;
align-items: center;
justify-content: center;
}
.product_delete_edit {
display: flex;
flex-direction: row;
flex: 0.5;
align-items: center;
justify-content: space-evenly;
}
.product_delete, .product_edit {
height: 24px;
width: auto;
margin: 12px;
}
.product_edit:hover, .product_delete:hover {
cursor: pointer;
}
Step 4: Create Route for the ProductList Component
-
Using react-router to create routes for the Product List page
-
Add the following objects to the
CreateBrowserRouterlist in/src/router/index.jsfile:{
path: "/company/:company_id/:application_id/product-list/",
element: <ProductList />
}, -
Import
ProductListcomponent at the top of/src/router/index.jsfile.import ProductList from "../views/ProductList";
Step 5: Restart the Extension Server and Refresh Extension Page in Development Account
![]()
- Clicking on this arrow will redirect the user to the ProductList page.
![]()
Currently, we don't have highlights created for the products, which is why no products are showing. In order to create product highlights, we'll need to create a CreateProductHighlight page.