Tutorial: Product Highlights
You will build an illustrative Product Highlights extension in this tutorial in Node+React technology stack. You'll end up creating a working extension that would enable sellers to create highlights for required products and display the price drop tag under the price if it is altered recently.
Before starting with this tutorial, make sure you have created extension using create extension guide.
Tutorial is divided into following sections
- Adding DB Configuration
- Building the extension home page
- Building 'Highlights Listing' page
- Building 'Create Highlight' page
- Injecting Javascript into the storefront theme
- Configuring Webhook
What you will learn
- How to call Fynd Platform 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 to follow this tutorial series.
Adding DB Configuration
Step 1: Populate test products in your development account
Login to FDK-CLI using command below:
If logging in the previous step has been completed, there is no need to log in again. This step can be skipped.
fdk login
Verify if you are logged in or not by running the following command:
fdk user
Run the following command and provide company id of required development account to populate test products in it.
fdk populate
Step 2: Provide MongoDB configuration
Add port and MongoDB local URL in /.env
file
MONGODB_URI="mongodb://localhost:27017/productHighlights"
PORT=8080
Add MongoDB URL in /app/config.js
file
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 that merchants can view and edit the saved product highlights.
The database collection includes the product highlights, as well as the basic details about the product such as name, product brand, product slug, and product item code.
-
Install the
mongoose
package using the following commandnpm i mongoose
-
Create new
/app/db/mongo.js
file and add the following code to the fileconst 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.js
file to/app/routes/product.router.js
and make changes to theGET
/applications
APIconst { 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);
}
}); -
Also made the following changes to the
/app/server.js
filechange lines 3 and 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);
}
}) -
Also, add the following endpoints to the
Endpoints
object in/src/service/endpoint.service.js
fileGET_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`);
}, -
Also, add the following methods to the
MainService
object in/src/service/main-service.js
file// 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.jsx
and 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.css
and 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 a Route for ProductList component
-
Using react-router to create routes for the Product List page
-
Add the following objects to the
CreateBrowserRouter
list in/src/router/index.js
file{
path: "/company/:company_id/:application_id/product-list/",
element: <ProductList />
}, -
Import
ProductList
component at the top of/src/router/index.js
fileimport 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, that’s why any products are not showing.
In order to create product highlights we’ll need to create CreateProductHighlight
page.
Building 'Create Highlight' page
Step 1: Create Backend APIs
-
Add the following code to the
/app/routes/product.route.js
// get product list
router.get('/:application_id/products', async function view(req, res, next) {
try {
const { platformClient } = req;
const { application_id } = req.params;
const { query } = req.query;
let response = await platformClient.application(application_id).catalog.getAppProducts({
pageNo: 1,
pageSize: 10,
q: query
})
return res.status(200).json(response)
} catch(error) {
next(error);
}
})
// update product highlights
router.post("/:application_id/product/:item_id/highlights", async function view(req, res, next) {
try {
const { application_id, item_id } = req.params;
const { product_meta, highlights, enablePriceDrop } = req.body
const companyId = Number(req.fdkSession.company_id);
let productHighlightRecord = await ProductHighlightRecord.findOne({
company_id: companyId, application_id: application_id, product_item_code: item_id
}).exec()
if (productHighlightRecord) {
productHighlightRecord.product.highlights = highlights;
productHighlightRecord.product.enablePriceDrop = enablePriceDrop;
await productHighlightRecord.save();
} else {
const prepareRecord = {
company_id: companyId,
application_id: application_id,
product_item_code: product_meta.product_item_code,
product_slug: product_meta.product_slug,
product: {
name: product_meta.name,
image: product_meta.image,
brand_name: product_meta.brand_name,
category_slug: product_meta.category_slug,
highlights: highlights,
price: product_meta.price,
enablePriceDrop: enablePriceDrop
},
is_active: false
}
productHighlightRecord = await new ProductHighlightRecord(prepareRecord).save();
}
return res.status(200).json(productHighlightRecord);
} catch(error) {
next(error);
}
}) -
Also, add the following endpoints to the
Endpoints
object in/src/service/endpoint.service.js
fileGET_PRODUCTS(application_id) {
return urlJoin(envVars.EXAMPLE_MAIN_URL, `api/v1.0/${application_id}/products`);
},
CREATE_PRODUCT_HIGHLIGHTS(application_id, item_id) {
return urlJoin(envVars.EXAMPLE_MAIN_URL, `api/v1.0/${application_id}/product/${item_id}/highlights`);
}, -
Also, add the following methods to the
MainService
object in/src/service/main-service.js
filegetAllProducts(application_id, query) {
return axios.get(URLS.GET_PRODUCTS(application_id), {params: {query}});
},
createProductHighlights(application_id, item_id, data) {
return axios.post(URLS.CREATE_PRODUCT_HIGHLIGHTS(application_id, item_id), data);
},
Step 2: Create FrontEnd page
-
In the
/src/views/
directory create a new file calledCreateHighlight.jsx
-
Add the following code to the file
import React, { useEffect, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import {
Button, Dropdown, Input, Checkbox, SvgIcConfirm, SvgIcArrowBack, SvgIcTrash
} from '@gofynd/nitrozen-react';
import MainService from '../services/main-service';
import styles from './style/createHighlight.module.css'
export default function CreateHighlight() {
// page params
const { company_id, application_id, item_code } = useParams();
// navigation instance
const navigate = useNavigate();
// application product list
const [productItems, setProductItems] = useState([]);
const [searchText, setSearchText] = useState('');
// highlight text input
const [highlightInput, setHighlightInput] = useState("");
// locally maintained highlight list
const [highlightList, setHighlightList] = useState([]);
// current selected dropdown value
const [selectedDropdownProduct, setSelectedDropdownProduct] = useState({});
// is edit page
const [isEdit, setIsEdit] = useState(false);
const [editProduct, setEditProduct] = useState({});
// price drop
const [checkboxValue, setCheckboxValue] = useState(false);
// handle dropdown search
useEffect(() => {
const delayDebounceFn = setTimeout(() => {
console.log(searchText)
getApplicationProductList();
}, 500)
return () => clearTimeout(delayDebounceFn)
}, [searchText])
// application product list for dropdown
const getApplicationProductList = async () => {
if (item_code) {
setIsEdit(true);
const { data } = await MainService.getProductHighlight(application_id, item_code);
setHighlightList(data?.product?.highlights);
setCheckboxValue(data?.product?.enablePriceDrop);
setEditProduct({
name: data?.product?.name,
product_slug: data?.product_slug,
image: data?.product?.image,
brand_name: data?.product?.brand_name,
category_slug: data?.product?.category_slug,
product_item_code: data?.product_item_code
})
} else {
const { data } = await MainService.getAllProducts(application_id, searchText);
setProductItems(data.items);
}
}
// handle dropdown onChange
const dropdownChangeHandler = async (productMeta) => {
let { data } = await MainService.getProductHighlight(application_id, productMeta.product_item_code);
if (data) {
setHighlightList(data.product.highlights);
setCheckboxValue(data.product.enablePriceDrop);
} else {
setHighlightList([]);
setCheckboxValue(false);
}
setSelectedDropdownProduct(productMeta);
}
// Dropdown data
const getSearchItems = () => {
let prepareProductList = []
productItems.forEach((product) => {
let searchProduct = {}
searchProduct.text = product?.name
searchProduct.sub_text = product?.brand?.name
searchProduct.value = {
name: product?.name,
product_slug: product?.slug,
image: product?.images[0],
brand_name: product?.brand?.name,
category_slug: product?.category_slug,
product_item_code: product?.uid,
price: product?.price
}
searchProduct.logo = product?.images[0]
prepareProductList.push(searchProduct);
})
return prepareProductList;
}
const handleSubmit = async () => {
if (isEdit) {
await MainService.createProductHighlights(
application_id,
editProduct.product_item_code,
{
productMeta: editProduct,
highlights: highlightList,
enablePriceDrop: checkboxValue
}
)
} else {
await MainService.createProductHighlights(
application_id,
selectedDropdownProduct.product_item_code,
{
product_meta: selectedDropdownProduct,
highlights: highlightList,
enablePriceDrop: checkboxValue
}
)
}
navigate(`/company/${company_id}/${application_id}/product-list/`);
}
return (
<>
<div className={styles.main_wrapper}>
{/* NAVBAR */}
<div className={styles.navbar}>
{/* NAVBAR LEFT */}
<div className={styles.navbar_left_header}>
<div className={styles.back_arrow}>
<SvgIcArrowBack
color='#2E31BE'
style={{
width: "24px",
height: "auto"
}}
onClick={() => {
navigate(`/company/${company_id}/${application_id}/product-list/`)
}}
/>
</div>
<div className={styles.main_title}>
{isEdit ? ("Edit") : ("Create")} Product Highlight
</div>
</div>
{/* NAVBAR RIGHT */}
<div className={styles.navbar_buttons}>
{/* DISCARD BUTTON */}
<div>
<Button
state='default'
theme='secondary'
// size='small'
rounded={false}
onClick={() => {
navigate(`/company/${company_id}/${application_id}/product-list/`)
}}
>
Discard
</Button>
</div>
{/* SUBMIT BUTTON */}
<div>
<Button
state='default'
theme='primary'
// size='small'
rounded={false}
onClick={handleSubmit}
>
{isEdit ? ("Save") : ("Submit")}
</Button>
</div>
</div>
</div>
{/* END NAVBAR */}
<div className={styles.content_wrapper}>
<div className={styles.highlight_detail_box}>
<div>
<div className={styles.highlight_detail_box_header}>Product Highlight Detail</div>
</div>
{/* PRODUCT DROPDOWN */}
{!isEdit ? (
<div className={styles.select_product_dropdown}>
<Dropdown
placeholder="select product"
searchable={true}
items={getSearchItems()}
onChange={(productMeta) => {dropdownChangeHandler(productMeta);}}
onSearchInputChange={(e) => {setSearchText(e.text);}}
/>
</div>
) : (
<div className={styles.edit_product_title}>
<Input
type='text'
value={editProduct.name}
disabled={true}
/>
</div>
)}
<div className={styles.add_highlights_header}>
Add/Edit Highlights
</div>
{/* HIGHLIGHTS LIST */}
<div>
{highlightList?.map((highlight, index) => (
<div className={styles.highlight_list}>
<div>
{highlight}
</div>
<div className={styles.highlight_list_delete}>
<SvgIcTrash
className={styles.highlight_delete}
style={{
height: "24px",
width: "auto"
}}
color="#2E31BE"
onClick={() => {
setHighlightList((prevItem) => {
return [...prevItem.slice(0, index), ...prevItem.slice(index+1)]
})
}}
/>
</div>
</div>
))}
</div>
<div className={styles.highlight_input_ok}>
{/* HIGHLIGHT INPUT */}
<div className={styles.highlight_input_div}>
<Input
placeholder='add highlights'
style={{
padding: "0px"
}}
max={200}
min={1}
value={highlightInput}
disabled={ !isEdit && Object.keys(selectedDropdownProduct).length === 0 ? true : false }
onChange={(e) => {setHighlightInput(e.target.value)}}
onKeyPress={(e) => {
if (e.key === 'Enter') {
setHighlightList([...highlightList, highlightInput])
setHighlightInput("")
}
}}
/>
</div>
{/* HIGHLIGHT OK BUTTON */}
<div
onClick={() => {
setHighlightList([...highlightList, highlightInput])
setHighlightInput("")
}}
>
{highlightInput && (
<SvgIcConfirm
color='#2e31be'
style={{
height: "24px",
width: "auto",
cursor: "pointer"
}}
/>
)}
</div>
</div>
{/* ENABLE PRICE DROP CHECKBOX */}
<div className={styles.enable_price_drop_checkbox}>
<Checkbox
labelText="Enable 'Price Drop' label whenever price is reduced in last 2 days"
disabled={ !isEdit && Object.keys(selectedDropdownProduct).length === 0 ? true : false }
checkboxValue={checkboxValue}
onChange={(changedState) => {
setCheckboxValue(changedState);
}}
/>
</div>
</div>
<div className={styles.highlight_preview_box}>
<div className={styles.preview_box_header}>
Preview
</div>
<div className={styles.horizontal_line}></div>
<div>
{highlightList.length>0 && <div className={styles.highlightTitle}>Product Highlights</div>}
<div>
{highlightList?.map((highlight) => (
<div className={styles.highlightList}>{highlight}</div>
))}
</div>
</div>
</div>
</div>
</div>
</>
)
}
Step 3: Add CSS for the CreateHighlight component
-
In the
/src/views/style/
directory create a new file calledcreateHighlight.module.css
-
Add the following code to the file
@import url('https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap');
.main_wrapper {
font-family: Inter;
position: relative;
box-sizing: border-box;
background: #fff;
border: 1px solid #f3f3f3;
border-radius: 12px;
padding: 24px;
margin: 24px;
}
.navbar {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.navbar_left_header {
display: flex;
align-items: center;
}
.main_title {
font-size: 22px;
font-weight: 600;
margin: 0 24px;
color: #41434c;
}
.back_arrow {
cursor: pointer;
}
.navbar_buttons {
display: flex;
flex-direction: row;
align-items: center;
}
.navbar_buttons Button {
margin: 0 16px;
}
/* content wrapper */
.content_wrapper {
display: flex;
flex-direction: row;
gap: 16px;
margin-top: 16px;
}
/* details box */
.highlight_detail_box {
flex: 1.5;
padding: 16px;
}
.highlight_detail_box_header {
font-size: 16px;
font-weight: 700;
color: #41434c;
}
/* DROP DOWN */
.select_product_dropdown {
margin: 12px 0;
max-width: 50%;
}
.edit_product_title {
margin: 12px 0;
max-width: 50%;
}
.edit_product_title div {
padding: 0;
max-height: 64px;
}
.edit_product_title input {
padding: 6px 24px;
}
.add_highlights_header {
font-size: 16px;
font-weight: 400;
line-height: 140%;
margin-top: 24px;
color: rgba(102, 102, 102, 0.5);
}
/* HIGHLIGHT LIST */
.highlight_list {
display: flex;
flex-direction: row;
padding: 8px 16px;
align-items: center;
justify-content: space-between;
gap: 12px;
font-weight: 400;
line-height: 140%;
color: #4f4f4f;
font-size: 14px;
}
.highlight_list_delete {
padding: 0 4px;
cursor: pointer;
}
/* HIGHLIGHT INPUT */
.highlight_input_ok {
margin: 12px 0;
display: flex;
flex-direction: row;
align-items: center;
gap: 24px;
}
.highlight_input_div {
min-width: 360px;
max-width: 512px;
width: -webkit-fill-available;
}
.highlight_input_div input {
font-size: 16px;
padding: 0px 16px;
}
.highlight_input_div input::placeholder {
font-size: 16px;
}
/* preview box */
.highlight_preview_box {
margin-top: 12px;
padding: 16px;
flex: 1;
border: 1px solid #e4e5e6;
border-radius: 4px;
}
.preview_box_header {
font-size: 16px;
font-weight: 700;
color: #41434c;
}
.horizontal_line {
border: 1px solid #ccc;
}
.highlightTitle {
font-size: 18px;
font-weight: 700;
color: #000;
padding: 16px 0 0 0;
font-family: 'Montserrat', sans-serif;
}
.highlightList {
font-size: 16px;
font-weight: 400;
color: #000;
padding: 10px 0;
font-family: 'Montserrat', sans-serif;
}
.enable_price_drop_checkbox {
margin: 24px 0px;
}
.enable_price_drop_checkbox label {
justify-content: flex-start;
}
Step 4: Create a Route for CreateHighlight component
-
Add the following objects to the
CreateBrowserRouter
list in/src/router/index.js
file{
path: "/company/:company_id/:application_id/highlight/create",
element: <CreateHighlight />
},
{
path: "/company/:company_id/:application_id/highlight/:item_code",
element: <CreateHighlight />
} -
Import
ProductList
component at the top of/src/router/index.js
fileimport CreateHighlight from "../views/CreateHighlight";
noteWe are using the same component
CreateHighlight
for creating new product highlights and updating existing product highlights.
Step 5: Restart the extension server and refresh extension page in development account
-
Clicking on this arrow button redirect the user to the CreateHighlight page
Now you can create Highlights for products by following the below steps:
- Select any product from the dropdown.
- Add highlights using an input text box.
- After adding all the highlights click on Submit.
It’ll redirect to the ProductList page
We’ve completed the UI flow of the Extension. Now in the next step, we will add functionality to show highlights on the Product description page of the store website.
Injecting Javascript into the storefront theme
We’ll need to inject Javascript into the theme to show the product highlights and price drop label. Fynd Platform Theme engine is built with Vue2, that’s why we will have to create an Injectable script in Vue itself.
Fynd Platform now supports React themes as well. For more information on theme development, please refer to the documentation.
Step 1: Create Binding in Extension
- Visit Fynd Partners.
- Click on Manage and Select your organization.
- Click on Extensions from the left Menu.
- Select your Extension.
- Click on three dots and then select Bindings as shown in the below image.
- Click on Add Template and fill in the data as shown in the below image.
- Repeat Step 6 for the Price Drop Tag also.
Now in the next step, we’ll create Vue components and inject them using these bindings.
Step 2: Setup Vue project
-
Install vue-cli npm package using the following command
npm i -g @vue/cli
-
Create new directory
binding
inside your project root directory using the following commandmkdir bindings
cd bindings -
Create Vue project
vue create .
noteChoose Yes for the prompt “Generate project in the current directory?” and select
Vue2
for the prompt “Please pick a preset:” -
Open
/bindings/vue.config.js
file and replace the code with the following codemodule.exports = {
pages: {
index: {
// entry for the page
entry: process.env.NODE_ENV == "development" ? 'src/dev.js' : 'src/main.js'
},
},
devServer: {
disableHostCheck: true
}
} -
Install
axios
andurl-join
package inside/bindings/
directory using the following commandsnpm i axios
npm i url-join -
In the
/binding/
directory, open the/package.json
file and replace the following line"build": "vue-cli-service build",
with
"build": "vue-cli-service build --target lib src/main.js --name product-highlights",
-
Delete unnecessary files such as
README.md
andjsconfig.json
files from the/bindings/
directory -
Delete all the files and folders from the
/bindings/src
directory -
In the
/bindings/src/
directory, create a new file calleddev.js
and add the following code to the file:import App from './App.vue';
import Vue from 'vue';
new Vue({
render: h => h(App)
}).$mount('#app') -
In the
/bindings/src/
directory, create a new file calledmain.js
and add the following code to the file:import Highlights from './Highlights.vue'
import PriceDrop from './PriceDrop.vue'
window.FPI.extension.register("#product-highlights", {
mounted(element) {
window.FPI.extension.mountApp({
element,
component: Highlights
});
}
})
window.FPI.extension.register("#product-price-drop", {
mounted(element) {
window.FPI.extension.mountApp({
element,
component: PriceDrop
})
}
})
Step 3: Create Vue components
-
In the
/bindings/src/
directory, create a new file calledHighlights.vue
and add the following code to the file:<template>
<div class="product-highlights">
<div v-if="highlightsData">
<div class="highlightTitle">Product Highlights</div>
<div
v-for="(highlight, index) in highlightsData"
:key="index"
>
<div class="highlightList">{{highlight}}</div>
</div>
</div>
</div>
</template>
<script>
import axios from 'axios';
import urlJoin from "url-join";
export default {
name: "ProductHighlights",
data() {
return {
highlightsData: null
};
},
async mounted() {
const baseURL = window.location.origin;
const product_slug = this.$route.params.slug;
let { data } = await axios.get(
urlJoin(baseURL, 'ext/producthighlights/highlight'),
{ params: {slug: product_slug} }
);
if (data && data.is_active && data.product && data.product.highlights && data.product.highlights.length) {
this.highlightsData = data.product.highlights
}
},
}
</script>
<style>
.highlightTitle {
font-size: 14px;
font-weight: 700;
color: #000000;
padding: 32px 0 6px 0;
}
.highlightList {
font-size: 14px;
font-weight: 400;
color: #000000;
padding: 8px 0;
}
</style> -
Download
drop-price-tag.svg
file from GitHub and add it into the/bindings/assets/
directory. Here is the link to SVG : Click Here -
In the
/bindings/src/
directory, create a new file calledPriceDrop.vue
and add the following code to the file:<template>
<div>
<div v-if="showPriceDrop">
<img src="../assets/drop-price-tag.svg" alt="price-drop-png" />
</div>
</div>
</template>
<script>
import axios from "axios";
import urlJoin from "url-join";
export default {
name: 'PriceDrop',
data() {
return {
showPriceDrop: false
}
},
async mounted() {
const baseURL = window.location.origin;
const product_slug = this.$route.params.slug;
let { data } = await axios.get(
urlJoin(baseURL, 'ext/producthighlights/price-drop'),
{ params: {slug: product_slug} }
);
if (data && data.showPriceDrop) {
this.showPriceDrop = true;
}
}
}
</script>
<style scoped>
img {
height: 48px;
width: auto;
}
</style> -
Build the project using the following command inside
/bindings/
directory.npm run build
Step 4: Write logic for script injection
-
In the
/app/
directory, open the/server.js
file and replace following lineapp.use(express.static(path.resolve(__dirname, "../build/")));
with
app.use(express.static(path.resolve(__dirname, "../build/")));
app.use('/bindings/product-highlights', express.static(path.join(__dirname, '../bindings/dist')))with these we are serving min.js files which we have created by building the bindings bundle. Now these min.js files can be accessed using Extension base URL.
-
To maintain the record of the Proxy we will create one MongoDB schema. for that open the
/app/db/mongo.js
file and add the following codeconst ProxySchema = new mongoose.Schema({
company_id: {
type: String,
required: true
},
application_id: {
type: String,
required: true
},
attached_path: {
type: String
},
proxy_url:{
type: String
},
})
const ProxyRecord = mongoose.model("Proxy", ProxySchema); -
add also export newly created
ProxyRecord
at end of the filemodule.exports = { ProductHighlightRecord, ProxyRecord }
-
For adding a Proxy we’ll require unique string which will be passed as a parameter to the
addProxyPath
method. -
Add the following line at the end of
.env
file at project directoryPROXY_ATTACH_PATH="producthighlights"
-
Add
proxy_attach_path
to/app/config.js
fileproxy_attach_path: {
doc: "Proxy attach path",
default: '',
env: "PROXY_ATTACH_PATH"
}, -
In the
/app/routes/
directory, create a new file calledscript.router.js
and add the following code to the file:const express = require('express');
const router = express.Router();
const config = require('../config');
const { ProductHighlightRecord, ProxyRecord } = require('../db/mongo')
function getTagSchema(application_id) {
return [
{
"name": "Product Highlights injection script",
"sub_type": "external",
"type": "js",
"position": "body-bottom",
"url": `${config.extension.base_url}/bindings/product-highlights/product-highlights.umd.js`,
"attributes": {
"id": application_id
}
},
{
"name": "Product Highlights injection css",
"sub_type": "external",
"type": "css",
"position": "head",
"url": `${config.extension.base_url}/bindings/product-highlights/product-highlights.css`,
"attributes": {
}
}
]
}
router.post("/:application_id/tag/:item_code", async function view(req, res, next) {
try {
const { platformClient } = req;
const { application_id, item_code } = req.params;
const companyId = Number(req.fdkSession.company_id);
let response;
let data = await ProductHighlightRecord.findOne({
application_id: application_id, company_id: companyId, product_item_code: item_code
}).exec()
if (data) {
data.is_active = true;
await data.save();
// add proxy
let proxyCount = await ProxyRecord.find({company_id: companyId, application_id: application_id}).count();