Мой опыт создания frontend и backend приложений для моего стартапа

Моя цель - предложение широкого ассортимента товаров и услуг на постоянно высоком качестве обслуживания по самым выгодным ценам.

В прошлой части я рассказывал как появилась идея стартапа, как найти потребности пользователей, как спроектировать продуктовые требования. Также я рассказал как сделал проектирование и разработку дизайна. Напомню что я разрабатываю приложение для sass платформы ecwid, платформа позволяет создать интернет-магазин в один клик. Я создаю приложение которое расширяет функционал платформы ecwid и приложение работает за месячную подписку ($11). Приложение делает публикации на страницу Instagram магазина.

В этой части я хочу рассказать как проектировал backend & frontend приложения.

Напомню что мы разрабатываем приложения для мерчанта, которое интегрируется в административную панель через iframe. Наше приложение должно иметь доступ к товарам, для того чтобы мерчант мог настроить маркетинговые кампании. Также приложение должно автоматически совершать публикации в Instagram.

Разработка frontend приложения

Когда мне предстоит разработать большое приложение полностью самому, то я начинаю с frontend приложения. Потому что высока вероятность что в ходе проектирования я мог что-то упустить, поэтому сначала я проектирую пользовательский интерфейс смотрю удобно ли им пользоваться, требуется ли поменять форму и логику и уже после этого начинаю проектирование бэкенда. Часто бывает то что было разработано в макетах не user friendly и пользователь просто не захочет пользоваться таким продуктом.

Базовая архитектура проекта

Для web приложения будем использовать стэк: ReactJS +TypeScript + Mobx. Я выбрал такой стэк поскольку хорошо знаю его. Выбрал React поскольку нам будет достаточно клиентского рендеринга у него большое комьюнити и я хорошо знаю его. И очень рекомендую использовать типизированные языки и это сильно спасает от вероятности совершить ошибку и по мере роста проекта вероятность ошибиться будет увеличиваться. Тут рекомендую выбирать тот стэк на котором вы чувствуете себя максимально комфортно. Давайте разобьём проект на слои, я здесь вижу 6 слоёв:

  • routing – реализация нашей навигации

  • models – централизованное хранилище mobx

  • pages – страницы нашего приложения

  • ui-kit – базовые компоненты

  • components – компоненты приложения

  • lib – вспомогательные классы и функции

Routing

Маршрутизатор позволяет создавать вложенные пути, но важно помнить, что дочерний элемент должен указывать полный путь к родительскому.

В первом элементе объекта я передаю компонент Layout, который реализует базовую структуру страницы. Внутри этого компонента я использую Outlet из пакета react-router-dom для передачи вложенных элементов.

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

Я создал RootStore для инициализации других моделей с помощью factory create models. Фабрика позволяет создавать модели с теми же требуемыми аргументами.

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

На основе пакета Axios мы создадим нашу собственную реализацию, в которую добавим необходимые заголовки и обработчики ошибок в случае ответа сервера 401.

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;

Настройка алиасов

Возможно, вы заметили, что я использую псевдонимы при импорте модулей. Давайте создадим псевдоним вместе.

В вашем tsconfig.json добавьте новый путь:

{
    "compilerOptions": {
        "paths": {
            "@/*": ["./src/*"]
        },
    },
}

После этого установите пакет npm install @craco/craco --save и измените все скрипты в package.json

"scripts": {
    "start": "craco start",
    "build": "craco build",
    "test": "craco test",
    "eject": "craco eject"
  },

Окончательная версия архитектуры

Ниже вы можете ознакомиться с окончательной версией нашего интерфейсного проекта ReactJS на CodeSandbox.

Разработка ui-kit

В прошлой серии статей я не стал разрабатывать дизайн, поскольку у Ecwid есть свой css framework https://developers.ecwid.com/ecwid-css-framework/

Они предлагают в html документе прописать ссылки на css & js файлы. И дальше использовать html вёртску, но это неудобно, во-первых компоненты не адаптированы под React специфику, во-вторых не хочется каждый раз вставлять громоздкий код.

Давайте портируем компоненты на ReactJS стэк на примере сложных и популярных компонентов.

Checkbox

Так выглядит html разметка простого чекбокса, а что делать если мы например захотим сделать disabled состояние или увеличить размер?

Нам придётся добавить классы на disabled и на размер элемента

Приступим к созданию!

  1. Создадим в src директорию ui-kit, тут будут лежать все компоненты портированные из Ecwid CSS framework. Создадим директорию base – тут будут лежать базовые компоненты. Опишем наш компонент

  2. Переименуем все class на className и добавим закрывающиеся теги и типизируем все Props

Теперь подключим стили
Теперь подключим стили
<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>

Теперь мы можем импортировать компонент и переиспользовать логику чекбокса.

Разработка страниц для frontend приложения

В наших макетах у нас есть 4 страницы:

  • Dashboard – тут можем подключить Instagram аккаунт куда

  • Create campaign – состоит из 2 страниц

    • Выбор типа кампании

    • Форма создания кампании

  • Edit campaign – редактирование кампании

Dashboard page

Не буду останавливаться на вёрстке, а лучше разберу интеграцию подключения Instagram account в которые будет происходить публикация новых постов.

Для начала подключим SDK в самый конец нашей страницы. Вынесем appId в env (REACT_APP_FACEBOOK_APP_ID) переменную поскольку мы захотим управлять динамически переключаясь между продовым приложением и тестовым. После подключения в глобальном объекте window появится поле FB

<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>

Создадим компонент Dashboard.jsx, который будет состоять из 3 частей, шапка с подключением Instagram, ниже блок статуса нашего аккаунта, т.к. мы ограничены временем жизни токена, и если пользователь например поменяет пароль от аккаунта, то старый токен будет невалидным и нам нужно будет перепросить токен.

ВuseEffect у нашего сервера запросим подключенные аккаунты, после сервер нам вернёт аккаунты и если мы получим поле fbNeedToUpdate == true , то необходимо будет перезапросить токен.

Во втором useEffect будем дожидаться изменения поля fbNeedToUpdate в store. Если потребуется обновление то нужно получить статус авторизации через SDK и повторно запросить токен и для пользователя пройдёт всё незаметно.

Важно! Подключение Instagram аккаунта происходит через связь бизнес страницы Instagram & Facebook Page, поэтому в коде можно видеть упоминания

// getting Facebook Page status
useEffect(() => {
    getSavePages();
}, [])

// Updating facebook token
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;
            setFacebookData(accessToken, userID);
            getPages();
        }
    });
};

Давайте теперь рассмотрим кейс, когда пользователь заходит впервые и у него нет подключённых страниц, создадим функцию авторизации и повесим его на button на событие onClick. Извлекаем токен и uderID и дальше сохраняем на нашем сервере.

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,
    });
};

И давайте закончим с вёрсткой.

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>
);

Логика редактирования и создания кампании

Если рассмотреть 2 эти страницы, то они отличаются тем, что в одной данные предзаполнены, на другой заполняются пользователем. И в двух этих формах мы ходим в разные эндпоинты для создания и редактирования.

Тут можно пойти 2 путями:

  1. Создать глупый компонент, который отображает только вёрстку и содержит только общую логику валидации

  2. Создать Higher-Order Component, который будет модифицировать поведение

Я выбрал первый вариант исполнения, он проще для понимания и отладки.

Создадим базовый компонент с вёрсткой и логикой извлечения переменных.

const CampaignForm = () => {
  const {
      campaignStore: {
          getProduct,
          // import all variables for our form
      },
      dashboardStore: {
          getSavePages
      }
  } = useStore();

	// getting store product
  useEffect(() => {
      getProduct();
  }, []);

  // getting instagram pages
  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>
  )
  
}

Создадим компонент создания кампании.

  • в самом верху создадим Navbar, где будут кнопки сохранения и отмены создания компании

  • В функции onSubmit вызовем метод saveRandomCampaign для сохранения кампаниикоторая публикует случайный товар.

    • и после успешного выполнения вызовем редирект на Dashboard page

  • Важно заложить позитивный UI когда пользователь кликает по кнопке мы запускаем отрисовку лоадера внутри, для этого извлечём переменную sendingForm

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);

Рассмотрим отличие формы редактирования кампании. Логика практически остаётся прежней

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);
Финальный результат
Финальный результат

Редактор поста

В самом верху у нас есть текстовый редактор в который пользователь может вставлять свои константы, такие как:

  • ссылка на товар

  • название товара

  • цена

Вторая фича этого редактора создание множества пресетов текста, для того чтобы разнообразить посты разными сообщениями.

Давайте опишем mobx хранилище нашего редактора

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;
    };
}

Опишем компонент редактора.

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;

Я бы хотел объяснить 2 вещи:

  • Вставка констант из SelectBox

  • Выбор готового шаблона из SelectBox

Вставка констант из SelectBox

У меня есть готовые константы, которые согласованы с базой данных на сервере. Эту JSON я передаю как options in SelectBox. Дальше когда пользователь выбирает нужную константу, мы определяем позицию и вставляет в эту позицию значение из поля value.

мы можем представить нашу форму как string массив, где у каждого символа есть свой символ. Для того чтобы получить позиции, воспользуемся функционалом $(textAreaRef.current).prop('selectionStart')

Далее извлечём значение редактора из стора и поделим строку на 2 части относительно индекса курсора в редакторе. Затем вызовем changeTemplate где конкатенируем начало строки с константой и концом строки.

"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"
      }
    ]

Выбор готового шаблона из SelectBox

В селектбокс передадим options с предзаполненными шаблонами. Где label – это крактое описание отображаемое в селекте, а value значение которое подставим.

Когда пользователь выбирает шаблон, мы подставляем значение поля value в редактор.

const templates = [
//    1
    {
        label: "						
Источник: https://habr.com/ru/articles/819489/


Интересные статьи

Интересные статьи

Аватарки популярны на многих сайтах и позволяют украсить профиль не используя свою реальную фотографию. В статье рассмотрим простые и доступные API для генерации аватарок на свой сайт.
В ИТ-среде зачастую считается, что для карьерного и  зарплатного роста нужно либо переходить в другую компанию, либо уходить на фриланс. Можно ли вырасти в пределах своей компании? Насколько доло...
В этом году я получил очень специфический опыт тимлидерства. Мало того, что это был мой первый опыт тимлидерства, так еще и проекты нужно было стартовать с нуля, и команды были собраны ровно под эти п...
ПредисловиеВсем привет! Меня зовут Александр, и я Junior Project Manager в ICL Services. На путь ПМа я встал в начале 2021 – и пора бы рассказать, с чего все началось и кто стал инициатором моего пути...
Всем привет. Опрос в моем прошлом посте показал, что людям хотелось бы почитать о моем опыте инвестиций. На данный момент мой ИИС открыт 1 год и 3 месяца назад. Инвестировал я 500 000...