In the previous part (
In this part, I want to talk about how I developed the backend and frontend applications.
Let me remind you that we are developing applications for a merchant that integrates into the administrative panel through an iframe. Our application must have access to products so that the merchant can set up marketing campaigns. The application should also automatically post to Instagram.
When I have to develop a large application completely on my own, I start with the front-end application. There is a high chance that I might miss something during the design process, so I first design the user interface, check whether it is convenient to use, whether it is necessary to change the form and logic, and only then I start designing the backend. It often happens that what was designed in the layouts is not user-friendly, and the user simply does not want to use such a product.
For the web application, we will use the following stack: ReactJS + TypeScript + Mobx. I chose this stack because I am well-versed in it. I chose React because client-side rendering will be sufficient for us; it has a large community, and I know it well. I highly recommend using typed languages, as they significantly reduce the likelihood of errors, which will increase as the project grows. It is advisable to choose a stack in which you feel most comfortable.
Let's break down the project into layers. I see six layers here:
Routing
The router allows you to create nested paths, but it is important to remember that the child element should specify the entire path of the parents.
In the first element of the object, I pass the Layout
component that implements the basic structure of the page. Inside this component, I use the Outlet
from the react-router-dom
package in order to pass the nested elements.
import React from "react";
import { createBrowserRouter, RouterProvider } from "react-router-dom";
import Dashboard from "@/pages/Dashboard";
import CreateCampaign from "@/pages/Campaign/CreateCampaign";
import EditCampaign from "@/pages/Campaign/EditCampaign";
import Layout from "@/components/Layout";
const router = createBrowserRouter([
{
path: "/",
element: <Layout />,
children: [
{ path: "/", element: <Dashboard /> },
{
path: "/campaign",
element: null,
children: [
{ path: "/campaign/create", element: <CreateCampaign /> },
{ path: "/campaign/edit/:id", element: <EditCampaign /> },
],
},
],
},
]);
export default function Router() {
return <RouterProvider router={router} />;
}
Storage
I created RootStore to initialize other models via factory create models. The factory allows the creation of models with the same required arguments.
import { createContext } from "react";
import Campaign from "./campaign";
import Dashboard from "./dashboard";
import Api from "@/api";
export interface ModelInterface {
[key: string]: any;
}
interface ModelConstructor {
new (context: RootStore): ModelInterface;
}
function createModel<T>(
ctor: ModelConstructor,
context: RootStore
): T {
return new ctor(context) as T;
}
export class RootStore {
api: Api;
campaign: Campaign;
dashboard: Dashboard;
constructor(api: Api) {
this.api = api;
this.campaign = createModel<Campaign>(Campaign, this);
this.dashboard = createModel<Dashboard>(Dashboard, this);
}
}
const api = new Api({
ecwidStore: { payload: "c2bh2nmjkkoa2" },
});
export const store = new RootStore(api);
export type StoreType = RootStore | Record<string, never>;
export const StoreContext = createContext<StoreType>({});
API
Based on the Axios package, we will create our own implementation where we will add the necessary headers and error handlers in the 401 server response case.
import axios, {AxiosInstance} from "axios";
import {endpointsInitFactory} from "./endpoints";
type InitialType = {
ecwidStore: {
payload: string
}
}
class Api {
endpoints
axios: AxiosInstance
constructor(initial: InitialType) {
this.axios = axios.create({
baseURL: process.env.REACT_APP_BASE_URL,
headers: {
"Content-Type": "application/json",
"ecwid-payload": initial.ecwidStore.payload,
},
});
this.endpoints = endpointsInitFactory(this.axios)
}
}
export default Api;
Setting aliases
You may have noticed that I use alias names when importing modules. Let's set up an alias together.
In your tsconfig.json add a new path:
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
},
},
}
After that install package npm install @craco/craco --save
and change all scripts in
"scripts": {
"start": "craco start",
"build": "craco build",
"test": "craco test",
"eject": "craco eject"
},
You can check out the final version of our frontend ReactJS project on CodeSandbox below.
In the previous article, I decided not to design the interface because Ecwid has its own CSS framework.
They suggest including links to CSS and JS files in the HTML document and then using HTML. However, this approach is inconvenient for several reasons: firstly, the components are not adapted to React specifics, and secondly, we do not want to insert bulky code every time.
Let's port the components to the ReactJS stack using the example of complex and popular components.
Checkbox
This is how the HTML markup of a simple checkbox looks like. But what if we want to, for example, disable the checkbox or increase its size?
We will have to replace «class» with «className», add changing the component state through «onChange», support the «disabled» and «size» states of the element.
Let's get started with the creation!
import React, {useCallback} from 'react';
import cn from 'classnames'
type OuterProps = {
checked: boolean
onChange: (value: boolean) => void
label: string
size?: 'micro' | 'large'
disabled?: boolean
}
export default function Checkbox({checked, onChange, label, size, disabled}: OuterProps) {
const handleChange = useCallback(() => {
onChange(!checked)
}, [checked, onChange])
const sizeClassName = size && `custom-checkbox--${size}`
return (
<div className={cn('custom-checkbox', disabled && 'custom-checkbox--disabled', sizeClassName)}>
<label>
<input type="checkbox" checked={checked} onChange={handleChange} tabIndex={0} disabled={disabled} className="custom-checkbox__input"/>
<span className="custom-checkbox__label"></span>
<span className="custom-checkbox__text">{label}</span>
</label>
</div>
);
}
Now we will connect styles.
<head>
<link rel="stylesheet" href="https://d35z3p2poghz10.cloudfront.net/ecwid-sdk/css/1.3.19/ecwid-app-ui.css"/>
</head>
<body>
<div>Some content</div>
<script type="text/javascript" src="https://d35z3p2poghz10.cloudfront.net/ecwid-sdk/css/1.3.19/ecwid-app-ui.min.js"></script>
</body>
Now, we can import the component and reuse the checkbox logic.
In our layouts, we have four pages:
I will not stop at the HTML but rather analyze the integration of the Instagram account connection into which new posts will be published.
First, we'll connect the SDK to the end of our page. Let's put the appID in the env (REACT_APP_FACEBOOK_APP_ID
) variable because we want to control dynamically switching between the production application and the test application. After connecting, the FB
field will appear in the global window object.
<script>
window.fbAsyncInit = function() {
FB.init({
appId : '%REACT_APP_FACEBOOK_APP_ID%',
cookie : true,
xfbml : true,
version : 'v9.0'
});
FB.AppEvents.logPageView();
};
(function(d, s, id){
var js, fjs = d.getElementsByTagName(s)[0];
if (d.getElementById(id)) {return;}
js = d.createElement(s); js.id = id;
js.src = "https://connect.facebook.net/en_US/sdk.js";
fjs.parentNode.insertBefore(js, fjs);
}(document, 'script', 'facebook-jssdk'));
</script>
Let's create a Dashboard.jsx
component that will consist of three parts:
an Instagram connection header;
a status block for our account. Since we are limited by the lifetime of the token, if the user changes the account password, for example, the old token will become invalid, and we will need to request a new one;
list of campaigns
In our useEffect
, we will request connected accounts from the server. After that, the server will return the accounts, and if we receive the field fbNeedToUpdate == true
, we will need to request the token again.
In the second useEffect
, we will wait for the fbNeedToUpdate
field to change in the store. If an update is required, we need to get the authorization status through the SDK and request the token again, and the whole process will go unnoticed by the user.
Important! Instagram account is connected via connection between the Instagram Business Page & Facebook Page, so you can see mentions in the code
// getting Facebook Page status from our server
useEffect(() => {
getSavePages();
}, [])
// Updating facebook token via Facebook SDK
useEffect(() => {
if (fbNeedToUpdate !== null && fbNeedToUpdate) {
getFBLoginStatus();
}
}, [fbNeedToUpdate]);
const getFBLoginStatus = () => {
window.FB.getLoginStatus((response) => {
console.log('Good to see you, ', response);
const {status, authResponse} = response;
setFbLoginStatus(status);
if (status === 'connected') {
const {accessToken, userID} = authResponse;
// set facebook information to server
setFacebookData(accessToken, userID);
// get all connected Facebook pages
getPages();
}
});
};
Now, let's look at the case when the user goes in for the first time and does not have any connected pages. Create an authorization function and hang it on the button for the onClick event. We extract the token and the userID and then save it on our server.
In the scope, we request basic account information, permission to publish posts, and permission to access users' Facebook pages.
const loginInst = () => {
window.FB.login((response) => {
if (response.status === 'connected') {
const {accessToken, userID} = response.authResponse;
setFacebookData(accessToken, userID);
getPages();
}
}, {
scope: 'instagram_basic, instagram_content_publish, pages_show_list, pages_read_engagement',
auth_type: 'rerequest',
return_scopes: true,
});
};
Finish HTML
return (
<div className="my-3">
<ConnectSocialNetwork
loading={loading}
icon={<img src={instagramLogo} alt="logo facebook"/>}
title={pages.length > 0 ? t('connected.title') : t('connect.title')}
text={pages.length > 0 ? t('connected.text') : t('connect.text')}
pages={pages}
rightContainer={(
<>
<Button
label={pages.length > 0 ? t('connected.btn') : t('connect.btn')}
onClick={onLogin}
loading={loading}
/>
<Button
label={t('helpConnectBtn')}
color="link"
icon={<InfoIcon/>}
size="small"
className="ml-1"
onClick={getHelp}
/>
</>
)}
/>
{fbNeedToUpdate && (
<Alert
modal
type="error"
title={t('expired.title')}
description={(
<Button label={t('expired.btn')} onClick={onLogin}/>
)}
/>
)}
</div>
);
If we consider these two pages (editing and creating a campaign), they differ in that in one, the data is pre-filled, and in the other, it is filled in by the user. And in these two forms, we go to different endpoints to create and edit.
There are two ways here:
Create a simple component that displays only the layout and contains only the general validation logic
Create a Higher-Order Component that will modify the behavior
I chose the first version as it is easier to understand and debug.
Let's create a basic component with a layout and logic for extracting variables.
const CampaignForm = () => {
const {
campaignStore: {
getProducts,
// import all variables for our form
},
dashboardStore: {
getSavePages
}
} = useStore();
// getting store products
useEffect(() => {
getProducts();
}, []);
// getting instagram accounts
useEffect(() => {
getSavePages();
}, []);
const Errors = () => {
if (typeof errors === 'string') {
return errors;
}
return (
<ul>
{errors.map((error, i) => (
<li key={i}>{error}</li>
))}
</ul>
);
}
return (
<form>
<Errors/>
{/*fields*/}
</form>
)
}
Let's create a campaign creation component.
import React from 'react';
import {observer} from "mobx-react-lite";
import {useHistory} from "react-router-dom";
import {useTranslation} from "react-i18next";
import Button from "../../components/Button/Button";
import Navbar from "../../components/Navbar/Navbar";
import CampaignForm from "./CampaignForm";
import Label from "../../components/Label/Label";
import {useStore} from "../../store";
const CreateRandomCampaign = () => {
const history = useHistory();
const {t} = useTranslation('campaigns');
const {
campaignStore: {
saveRandomCampaign, sendingForm
}
} = useStore();
const onSubmit = () => {
saveRandomCampaign()
.then(() => history.push('/'));
};
return (
<div className="mt-2">
<Navbar
title={<>
<span className="mx-1">
{t('randomForm.createTitle')}
</span>
<Label label="Random"/>
</>}
actions={
<>
<Button label={t('form.save')} loading={sendingForm} onClick={onSubmit}/>
<span className="mr-2"/>
<Button label={t('form.cancel')} color="link" onClick={() => history.push('/')}/>
</>
}
/>
<CampaignForm/>
</div>
);
};
export default observer(CreateRandomCampaign);
Let's look at the difference between the campaign editing form. The logic practically remains the same.
import React, {useEffect} from 'react';
import {observer} from "mobx-react-lite";
import {useHistory, useParams} from "react-router-dom";
import {useTranslation} from "react-i18next";
import {toJS} from "mobx";
import Button from "../../components/Button/Button";
import Navbar from "../../components/Navbar/Navbar";
import CampaignForm from "./CampaignForm";
import Label from "../../components/Label/Label";
import {useStore} from "../../store";
const UpdateRandomCampaign = () => {
const history = useHistory();
let {id} = useParams();
const {t} = useTranslation('campaigns');
const {
campaignStore: {
getCampaign, updateRandomCampaign, sendingForm
},
dashboardStore: {
activeCampaigns
}
} = useStore();
useEffect(() => {
getCampaign(id);
}, []);
const onSubmit = () => {
updateRandomCampaign(id)
.then(() => history.push('/'));
};
return (
<div className="mt-2">
<Navbar
title={
<>
<span className="mx-1">
{t('randomForm.editTitle')}
</span>
<Label label="Random"/>
</>
}
actions={
<>
<Button label={t('form.update')} loading={sendingForm} onClick={onSubmit}/>
<span className="mr-2"/>
<Button label={t('form.cancel')} color="link" onClick={() => history.push('/')}/>
</>
}
/>
<CampaignForm/>
</div>
);
};
export default observer(UpdateRandomCampaign);
At the top, we have a text editor where the user can insert his constants, such as:
The second feature of this editor is the creation of a variety of text presets in order to diversify posts with different messages.
Let's describe our editor's mobx storage.
import {action, makeAutoObservable, makeObservable, toJS} from "mobx";
import randomInteger from '../utils/random';
class CampaignStore {
// array of templates
templates = [""];
activeTemplate = 0;
constructor({api}) {
this.api = api;
makeObservable(this, {
addTemplate: action,
removeTemplate: action,
setActiveTemplate: action,
changeTemplate: action,
});
}
// add new template when user click add button
addTemplate = () => {
// create a copy of templates
const templates = this.templates.slice();
templates.push('');
this.templates = templates;
// change active template in the form
this.setActiveTemplate(this.templates.length - 1);
};
// remove template by index when user click trash icon button
removeTemplate = (index) => {
if (this.templates.length > 1) {
const templates = this.templates.slice();
templates.splice(index, 1);
this.templates = templates;
this.setActiveTemplate((index - 1) % templates.length);
}
};
// set active template when user choose template
setActiveTemplate = (index) => {
this.activeTemplate = index;
};
// change content inside editor for choosable template
changeTemplate = (value) => {
let templates = this.templates.slice();
templates[this.activeTemplate] = value;
this.templates = templates;
};
}
Let’s develop post-editor components
import React, {useCallback, useMemo, useRef, useState} from 'react';
import $ from 'jquery';
import PropTypes from 'prop-types';
import './styles/post-editor.scss';
import {ReactComponent as CloseIcon} from './assets/cancel.svg';
import {ReactComponent as ArrowIcon} from './assets/arrow.svg';
import Button from "../Button/Button";
import selectTemplates from "../../store/template/templates";
import {useTranslation} from "react-i18next";
import Skeleton from "react-loading-skeleton";
const PostEditor = (
{
disabled, changeTemplate, templates,
addTemplate,
removeTemplate,
activeTemplate,
setActiveTemplate, loading
}
) => {
const textAreaRef = useRef();
const {t} = useTranslation('campaigns');
// when user want to add constant we should determinate insert position
const insertConstant = (constant) => {
// getting cursor index
const cursorPos = $(textAreaRef.current).prop('selectionStart');
const value = templates[activeTemplate];
const textBefore = value.substring(0, cursorPos);
const textAfter = value.substring(cursorPos, value.length);
changeTemplate(textBefore + constant + textAfter);
};
// showing the skeletons while content is loading
if (loading) {
return (
<div className="fieldset">
<div className="fieldset__title">{t('form.contentLabel')}</div>
<div className="d-flex flex-wrap align-items-center">
<Skeleton width={32} height={32} className="mr-2 mb-1"/>
<Skeleton width={100} height={18}/>
</div>
<Skeleton width="100%" height={178}/>
</div>
);
}
return (
<div className="fieldset">
<div className="d-flex flex-wrap">
{/* render carousel btns with remove btn */}
{templates.map((template, index) => (
<div key={template + index}>
<Button
label={index + 1}
disabled={index === activeTemplate}
size="small"
color="default"
onClick={() => setActiveTemplate(index)}
/>
<div onClick={() => removeTemplate(index)}>
<CloseIcon width={8} height={8}/>
</div>
</div>
))}
{/* render add template btn */}
<Button label={t('form.addContentTemplate')} icon size="small" color="link" onClick={addTemplate}/>
</div>
<div className="postEditorWrap">
{/* selecting a pre-filled template */}
<SelectBox
onChange={onChange}
label={t('form.selectTemplate')}
options={selectTemplates}
/>
{/* selecting constants */}
<SelectBox
onChange={insertConstant}
label={t('form.insertConstant')}
options={t('form.constants', {returnObjects: true})}
/>
<textarea
rows={8}
className="postEditor"
ref={textAreaRef}
onChange={(e) => onChange(e.target.value)}
value={templates[activeTemplate]}
/>
</div>
</div>
);
}
export default PostEditor;
I would like to explain two things:
I have ready-made constants that are consistent with the database on the server. I pass this JSON as options in SelectBox. Then, when the user selects the desired constant, we determine the position and insert the value from the value field into this position.
We can represent our form as a string array, where each character has its own position. In order to get positions, use the $(textAreaRef.current).prop('selectionStart')
Next, extract the value of the editor from the stack and divide the string into two parts relative to the cursor index in the editor. Then we call changeTemplate
where we concatenate the beginning and end of the string with a constant.
"constants": [
{
"value": "{PRODUCT_LINK}",
"label": "{PRODUCT_LINK} - A direct link to the product"
},
{
"value": "{PRODUCT_TITLE}",
"label": "{PRODUCT_TITLE} - The product title"
},
{
"value": "{STORE_NAME}",
"label": "{STORE_NAME} - The store name"
},
{
"value": "{PRICE}",
"label": "{PRICE} - The product price"
},
{
"value": "{DISCOUNT_AMOUNT}",
"label": "{DISCOUNT_AMOUNT} - The product discount amount"
},
{
"value": "{DISCOUNT_CODE}",
"label": "{DISCOUNT_CODE} - The product discount code"
}
]
We will pass options with pre-filled templates to the selectbox. Where the label is the short description displayed in the selector, and the value is the value that we substitute.
When the user selects a template, we insert the value of the value field into the editor.
const templates = [
// 1
{
label: "😍 {PRODUCT_TITLE} 😍...",
value: `😍 {PRODUCT_TITLE} 😍
starting at {PRICE}
Shop Now 👉👉 {PRODUCT_LINK}`
},
// 2
{
label: "💎 {PRODUCT_TITLE} 💎...",
value: `💎 {PRODUCT_TITLE} 💎
Shop {VENDOR_NAME} Today 👉 {PRODUCT_LINK}`
}
]
To develop the backend, I chose the NestJS framework with TypeScript typing, a PostgreSQL relational database.
The first thing I always do is find out the list of roles, the essence of the application, and the actions that users can do. I wrote about this in the first episode. Let's refresh our memory.
This is an important step in creating a backend application, as everything revolves around data. All our logic services will be written in order to process the data, and when designing the diagram, we need to make sure that we have enough data and that we have set up the correct relationships between the tables.
I am always in favor of the order in the database, so let's turn to sqlstyle.guide and let's see how to name the tables (https://www.sqlstyle.guide/#tables):
staff
and employees
.tbl
or any other such descriptive prefix or Hungarian notation.cars_mechanics
prefer services
.
Table of stores
This is the key table around which other entities will be built. It is important not to miss every field.
Therefore, it is important for us to know the store ID, the store locale, access tokens through which the merchant is authorized in the application, the store name, currency, dates of creation, updating, and deletion of the store record in our database.
Since the application works through a subscription, the charge is carried out by Ecwid itself; it is important to know about the subscription start date and its completion + status.
***Table of facebook_pages ***
Instagram Facebook Facebook Pages I would like to analyze the table of connected Facebook pages (the Instagram account is connected via Facebook Pages)
It is important for us to know the token with which we can perform actions on behalf of the user. A photo of the account so that the user can quickly understand what kind of account he has chosen and expires – the expiration date of the token (the token may expire earlier, for example, when the user changes the password from the account)
Tables for campaign
The main table is campaigns, which stores the name of the campaign, the type of campaign (random publication of goods or publication of new products), the link to which page we publish, and the sign of an active and inactive campaign. And the link to the store to which the campaign belongs.
The templates table stores sets of texts for a post. It is quite primitive, there is content, and a connection with the company.
Let's look at how we store information about the selected categories and products that participate in the campaign's publication. The merchant can select entire categories that participate in the campaign. So, we must have a Many-to-Many relationship because many campaigns can refer to the same categories, just as many categories can relate to the same merchant campaigns.
Next, behind each category is the product that will be published. Again, the relationship should be Many to Many because for many products, it can relate to many categories, and many categories can relate to many products.
As a result, we get this table, with auxiliary tables for implementing the many-to-many relationship.
Let's see how such connections are implemented in NestJS.
CategoriesEntity[]
as the source
@JoinTable()
creates a junction table.@ManyToMany(() => CategoriesEntity)
where we specify CategoriesEntity
as a linkORM will create an additional table for us and take care of the logic of the cross-pointers.
import {
Entity,
Column,
PrimaryGeneratedColumn,
OneToMany,
OneToOne,
JoinColumn,
ManyToOne,
CreateDateColumn,
UpdateDateColumn,
ManyToMany,
JoinTable,
} from 'typeorm';
import { TemplatesEntity } from './templates/templates.entity';
import { CategoriesEntity } from '../categories/categories.entity';
import { DiscountsEntity } from './discounts/discounts.entity';
import { DatesEntity } from './dates/dates.entity';
import { CampaignsHistoryEntity } from './campaigns-history/campaigns-history.entity';
import { StoresEntity } from '../stores/stores.entity';
import { FacebookPagesEntity } from '../facebook-pages/facebook-pages.entity';
@Entity('campaigns')
export class CampaignsEntity {
@PrimaryGeneratedColumn()
id: number;
@Column('text')
type: string;
@Column('text')
name: string;
@Column('bool')
active: boolean;
@OneToMany(() => TemplatesEntity, (templates) => templates.campaign)
templates: TemplatesEntity[];
@ManyToMany(() => CategoriesEntity)
@JoinTable()
categories: CategoriesEntity[];
@OneToMany(() => DatesEntity, (categories) => categories.campaign)
dates: DatesEntity[];
@OneToMany(
() => CampaignsHistoryEntity,
(campaignsHistory) => campaignsHistory.campaign,
)
campaignsHistory: CampaignsHistoryEntity[];
@ManyToOne(() => StoresEntity, (store) => store.campaigns)
store: StoresEntity;
@OneToOne(() => DiscountsEntity)
@JoinColumn()
discount: DiscountsEntity;
@ManyToOne(
() => FacebookPagesEntity,
(facebookPage) => facebookPage.campaigns,
)
facebookPage: FacebookPagesEntity;
@CreateDateColumn({ type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamp' })
updatedAt: Date;
}
campaigns.entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
ManyToOne,
JoinColumn,
ManyToMany,
JoinTable,
OneToMany,
} from 'typeorm';
import { StoresEntity } from '../stores/stores.entity';
import { ProductsEntity } from '../products/products.entity';
@Entity('categories')
export class CategoriesEntity {
@PrimaryGeneratedColumn()
id: number;
@Column('bigint')
ecwidCategoryId: number;
@Column('text', { nullable: true })
thumbnailUrl: string;
@Column('text')
name: string;
@Column('boolean')
enabled: boolean;
@Column('integer', { nullable: true })
productCount: number;
@ManyToOne(() => StoresEntity)
@JoinColumn()
store: StoresEntity;
@ManyToMany(() => ProductsEntity, (products) => products.categories)
products: ProductsEntity[];
@ManyToOne((type) => CategoriesEntity, (category) => category.children)
parent: CategoriesEntity;
@OneToMany((type) => CategoriesEntity, (category) => category.parent)
children: CategoriesEntity[];
@CreateDateColumn({ type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamp' })
updatedAt: Date;
}
сategories.entity.ts
import {
Entity,
Column,
PrimaryGeneratedColumn,
CreateDateColumn,
UpdateDateColumn,
ManyToMany,
JoinTable,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { CategoriesEntity } from '../categories/categories.entity';
import { StoresEntity } from '../stores/stores.entity';
@Entity('products')
export class ProductsEntity {
@PrimaryGeneratedColumn()
id: number;
@Column('bigint')
productEcwidId: number;
@Column('text')
price: string;
@Column('text')
name: string;
@Column('text')
url: string;
@Column('text', { nullable: true })
hdThumbnailUrl: string;
@Column('text', { nullable: true })
originalImageUrl: string;
@Column('boolean')
enabled: boolean;
@ManyToMany(() => CategoriesEntity, (category) => category.products)
@JoinTable()
categories: CategoriesEntity[];
@ManyToOne(() => StoresEntity)
@JoinColumn()
store: StoresEntity;
@CreateDateColumn({ type: 'timestamp' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamp' })
updatedAt: Date;
}
products.entity.ts
Finally, we get this ER diagram of our database.
In order to implement the publication of campaigns, we need to implement a cron job that will be called every hour to check if there are any campaigns for publication. To synchronize the backend with the application, in the merchant's admin panel, I allowed the selection of only the hour without specifying minutes, i.e., the user can only choose 24 hours out of the day.
Сreate a new module called campaigns.
nest generate module campaigns
And create a file campaigns.service.ts
, inside let's create a method async handleCron() {}
import import { Cron } from '@nestjs/schedule';
and initialize a decorator above the method, which will call the handleCron
method every hour at the 50th minute.
The call at the 50th minute, and not exactly at the zero minute, is necessary in order to have time to prepare images for posts and upload them over the network to Facebook servers, and then publish them all at once. Let's consider what optimization options might exist:
Create a separate service that generates images for posts.
Also, you can start the generation process beforehand, for example, an hour before the upcoming publication. And upload them to the Facebook server immediately and at the moment of launching the campaign, only make a post-publication request, which is much faster than a request for media content upload.
You can get a detailed understanding of how Task Scheduling works in NestJS.
@Cron('0 55 */1 * * *')
async handleCron() {
this.logger.debug('Called every 50 minutes');
}
A little explanation of my entry.
* * * * * *
| | | | | |
| | | | | day of week (skip it)
| | | | months (skip it)
| | | day of month (skip it)
| | hours (I set the step each hour)
| minutes (I set the call at 55 minutes)
seconds (skip it)
Let's consider in which cases I need to generate a photo.
|
Product has photo? |
Campaign has discount? |
Do we need photo generation? |
---|---|---|---|
Variant 1 |
✅ |
✅ |
Yes |
Variant 2 |
❌ |
✅ |
Yes |
Variant 3 |
❌ |
❌ |
Yes |
Variant 4 |
✅ |
❌ |
No |
Therefore, photo generation is necessary only in cases when a campaign has been created that generates a promo code for the product and the product does not have a photo. So when the product does not have a photo, we need to create a picture for the post and overlay the name of the store and the name of the product.
To do this, set the canvas dependency.
import { createCanvas, loadImage } from 'canvas';
import * as fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
export class ProductPhoto {
private canvas = null;
private ctx = null;
private readonly MAX_WIDTH = 700;
private readonly MAX_HEIGHT = 875;
constructor(
private photoSrc,
private title,
private promocode,
private descriprion,
private storeName,
private productName,
) {
}
async generatePhoto() {
const photoImg = await loadImage(
this.photoSrc || './public/assets/empty-photo.jpg',
);
const {
width,
height,
canvasWidth,
canvasHeight,
} = ProductPhoto.getScalableSize(photoImg.width, photoImg.height);
const { x, y } = ProductPhoto.centerImageInCanvas(
width,
height,
canvasWidth,
canvasHeight,
);
this.createCanvas(canvasWidth, canvasHeight);
this.ctx.drawImage(photoImg, x, y, width, height);
this.ctx.quality = 'best';
this.ctx.patternQuality = 'best';
this.ctx.textDrawingMode = 'path';
if (!this.photoSrc) {
await this.createEmptyPhoto();
}
this.ctx.textAlign = 'left';
if (this.descriprion || this.promocode) {
await this.drawCoupon();
await this.drawDescription();
}
const dir = 'image-post';
const dirPublic = `./public/${dir}`;
const file = `/${uuidv4()}.jpeg`;
if (!fs.existsSync(dirPublic)) {
await fs.mkdirSync(dirPublic, { recursive: true });
}
const out = fs.createWriteStream(dirPublic + file);
console.log('creating');
const stream = await this.canvas.createJPEGStream({ quality: 1 });
await stream.pipe(out);
await out.on('finish', () => console.log('The JPEG file was created.'));
console.log('end');
return {
url: process.env.DOMAIN + dir + file,
path: dirPublic + file,
};
}
}
Let's consider the method of fixing the desired screen size for an image in order to use the static method of ProductPhoto.getScalableSize
. The method determines the aspect ratio, the ratio should be more than 0.9 and less than 1.9.
private static getScalableSize(width, height) {
const MIN_RATIO = 0.9,
MAX_RATIO = 1.9;
const MIN_WIDTH = 600,
MAX_WIDTH = 1080;
let canvasWidth: number, canvasHeight: number;
const ratio = width / height;
// Example: 903 / 4372 = 0.2
if (ratio < MIN_RATIO) {
canvasWidth = 900;
canvasHeight = 1000;
width = (canvasHeight * width) / height;
height = canvasHeight;
} else if (ratio > MAX_RATIO) {
// Example: 1080 / 437 = 2.47
canvasWidth = 1080;
canvasHeight = 568;
height = (canvasWidth * height) / width;
width = canvasWidth;
} else {
const maxSize = Math.max(width, height);
const minSize = Math.max(width, height);
let ratio = 1;
if (minSize < MIN_WIDTH || maxSize < MIN_WIDTH) {
ratio = MIN_WIDTH / minSize;
} else if (maxSize > MAX_WIDTH) {
ratio = MAX_WIDTH / maxSize;
}
width *= ratio;
height *= ratio;
canvasWidth = width;
canvasHeight = height;
}
return {
width,
height,
canvasWidth,
canvasHeight,
};
}
I also want to show you how to draw multiline text. To do this, create a private method.
Where text is our text to be drawn, x,y are the coordinates of the text location, lineHeight is the height of one line, fitWidth is the maximum width of the text.
private drawMultilineText(text, x, y, lineHeight, fitWidth) {
fitWidth = fitWidth || 0;
if (fitWidth <= 0) {
this.ctx.fillText(text, x, y);
return;
}
let words = text.split(' ');
let currentLine = 0;
let idx = 1;
while (words.length > 0 && idx <= words.length) {
const str = words.slice(0, idx).join(' ');
const w = this.ctx.measureText(str).width;
if (w > fitWidth) {
if (idx == 1) {
idx = 2;
}
this.ctx.fillText(
words.slice(0, idx - 1).join(' '),
x,
y + lineHeight * currentLine,
);
currentLine++;
words = words.splice(idx - 1);
idx = 1;
} else {
idx++;
}
}
if (idx > 0)
this.ctx.fillText(words.join(' '), x, y + lineHeight * currentLine);
}
The last stage is to publish our post, we have a cron task that collects campaigns for publication, we have a photo generator of the post, it remains only to publish.
Create service facebook-api.service.ts
, which will implement all the necessary API to work with Instagram.
createPhotoPost
method, using the query /v10.0/${pageId}/media
, we create a post where we specify the image and text of the post/v10.0/${pageId}/media_publish
we publish this postimport { HttpException, HttpService, Inject, Injectable } from '@nestjs/common';
import { catchError } from 'rxjs/operators';
import * as FormData from 'form-data';
import { Logger as LoggerW } from 'winston';
@Injectable()
export class FacebookApiService {
constructor(
@Inject('winston')
private readonly loggerW: LoggerW,
private httpService: HttpService,
) {
}
public async createPhotoPost(pageId, message, photoUrl, token) {
const response = await this.httpService
.post(
`https://graph.facebook.com/v10.0/${pageId}/media`,
{},
{
params: {
access_token: token,
caption: message,
image_url: photoUrl,
},
},
)
.pipe(
catchError((e) => {
console.log(e);
this.loggerW.error(e.response.data);
throw new HttpException(e.response.data, e.response.status);
}),
)
.toPromise();
const postRes = await this.httpService
.post(
`https://graph.facebook.com/v10.0/${pageId}/media_publish`,
{},
{
params: {
access_token: token,
creation_id: response.data?.id,
},
},
)
.pipe(
catchError((e) => {
console.log(e);
this.loggerW.error(e.response.data);
throw new HttpException(e.response.data, e.response.status);
}),
)
.toPromise();
console.log(postRes.data);
return postRes.data;
}
}
Using the example of my application, we looked at how I created the front & back applications, which stages they consisted of, and developed together the most complex modules.
As you can see from the example, there is nothing complicated in this, the main thing is to develop a plan, understand what the user wants and move in this direction. If you have any questions, ask them in the comments to this post, and I will be happy to answer!