Supercharge Your Shopify Landing Pages

Posted by Orly Knop on July 13, 2020

GSD

According to Shopify, there are currently over 1 million online stores powered by their eCommerce platform.  Shopify provides a great way for small businesses to get started selling online quickly and it scales well to support even the larger online brands.  In order for Shopify powered stores to gain that growth, it’s imperative that their marketing efforts and SEO optimization of their stores take a front seat.  If you love your Shopify store but are looking to supercharge your marketing pages with more complex layouts and dynamic content then this tutorial is for you!  We will show you how to connect your Shopify store with headless CMS ButterCMS to handle the content!

Tutorial roadmap

In order to add more complex content into your Shopify landing pages, this tutorial will explain in-depth how to add your Shopify products into ButterCMS collections, how to create ButterCMS Page Types and pass those Pages from ButterCMS through a template to produce fully rendered HTML for that landing page in your Shopify app. 

By the end of this tutorial, your Shopify store will have the following functionality:

  • Add new product data automatically from your Shopify store to ButterCMS
  • Display a list of ButterCMS promotional pages in your Shopify App
  • Create a promotional page built with the template and data fetched from ButterCMS that will include your products’ detailed information.


For the sake of simplicity, this guide won’t have any usage of databases. But of course, for production, there will be a need to store Shopify and ButterCMS tokens.

Create basic Shopify app

The first step to integrating your Shopify store to headless ButterCMS will require you to build a Shopify App.  For this tutorial, we will build it with Node.js.

install-1.png

Set up dummy service

In the project directory run commands:

npm init -y
npm install express


Create index.js file with this content:

const express = require('express');
const app = express();
const port = 3000;

app.get('/', (req, res) => res.send('Hello World!'));

app.listen(port, () => console.log(`Example app listening at http://localhost:${port}`));

This code creates a server on port 3000.

Update package.json file by adding a start script:

"scripts": {
    "start": "node index.js"
},


Install
ngrok to create secure introspectable tunnels to localhost:

npm install ngrok -g
ngrok http 3000


The second command didn’t work for me on Windows so I used:

ngrok.cmd http 3000


Create .env file and set a new variable APP_URL with ngrok https forwarding url. Note that it should have https and not http protocol. Add this file and node_modules to .gitignore.

Learn how your Marketing team can update your Shopify App with ButterCMS.

Create app and store in Shopify

Register or login to https://partners.shopify.com/. Create a new App by clicking “Apps” in the main menu and “Create App”. Set app type to be “Public app”, fill app name, and set app url to ngrok https forwarding url. As redirection URLs put:

{forwarding-url}/

{forwarding-url}/shopify/callback,

Where {forwarding URL} is ngrok https forwarding url. 

Note, that when you stop ngrok command, the forwarding URLs will stop being viable, so you will have to change these values and app URL as well.

Click “Create App”. You will be redirected to a new page where you could find your API key and API secret key. Save these values to the .env file. Name variables SHOPIFY_API_KEY and SHOPIFY_API_SECRET.

undefined

To test our application we need to create a store. Click “Stores” in the main menu and then click “Add Store”, pick “Development store”. Fill store name, store URL, passwords, and hit “Save”.

undefined

Add installation routes for the app

We need to create routes to allow merchants to install the app: GET "/shopify" and GET "/shopify/callback".

Create a new folder named services with a js file named shopify-service. This is going to be a service to interact with Shopify. Run command to install shopify-node-api and shopify-api-node:

npm i shopify-node-api shopify-api-node

Paste this code into shopify-service.js file:

class ShopifyService {
  constructor() {
    this.shops = {};
  }

  install(shopOptions) {
    let shop = this.shops[shopOptions.shop];

    if (!shop) {
      shop = new ShopifyAuthAPI({
        shop: shopOptions.shop,
        shopify_api_key: shopOptions.shopify_api_key,
        shopify_shared_secret: shopOptions.shopify_shared_secret,
        shopify_scope: shopOptions.shopify_scope,
        redirect_uri: shopOptions.redirect_uri,
        nonce: shopOptions.nonce,
        verbose: false
      });
      this.shops[shopOptions.shop] = shop;
    }

    return shop;
  }

  getShop(shopName) {
    return this.shops[shopName];
  }

  uninstall(shopName) {
    delete this.shops[shopName];
  }

  static exchangeTemporaryToken(shop, query) {
    return new Promise((resolve, reject) => {
      shop.exchange_temporary_token(query, (err, data) => {
        if (err) {
          return reject(Error(err));
        }
        return resolve(data);
      });
    });
  }


  static subscribeToUninstallWebHook(shop, address) {
    const params = {
      topic: "app/uninstalled",
      address,
      format: "json"
    };
    const shopify = new ShopifyAPI({
      shopName: shop.config.shop,
      accessToken: shop.config.access_token
    });
    return shopify.webhook.create(params);
  }
}

module.exports = { ShopifyService };

This service now has a method to install the app and to exchange a temporary store token to a constant one.

Now run command to install other dependencies:

npm i dotenv nonce shopify-hmac-validation


Update index.js file by adding this code to the top:

require("dotenv").config();
const nonce = require("nonce")();
const bodyParser = require("body-parser");
const hmacValidity = require("shopify-hmac-validation");

const { ShopifyService } = require("./services/shopify-service");

const shopifyService = new ShopifyService();

const forwardingAddress = process.env.APP_URL;
const appConfig = {
  apiKey: process.env.SHOPIFY_API_KEY,
  apiSecret: process.env.SHOPIFY_API_SECRET,
  scopes: "read_products,read_product_listings,write_content"
};


functionverifyWebhookRequest(req, res, next) {
try {
if (
      !hmacValidity.checkWebhookHmacValidity(
        appConfig.apiSecret,
        req.rawBody,
        req.headers["x-shopify-hmac-sha256"]
      )
    ) {
throwError("Unauthorized");
    }
return next();
  } catch (e) {
console.log(e);
return res.status(401).json({ message: "Unauthorized" });
  }
}


function rawBodySaver(req, res, buf, encoding) {
  if (buf && buf.length) {
    req.rawBody = buf.toString(encoding || "utf8");
  }
}


Add the following code to index.js file after
const app = express();:

app.use(
  "/app/webhooks/app-uninstalled",
  bodyParser.json({ verify: rawBodySaver })
);

app.use(bodyParser.urlencoded({ extended: true }));
app.use(bodyParser.json());

app.get("/shopify", async (req, res) => {
  try {
    const shop = shopifyService.install({
      shop: req.query.shop,
      shopify_api_key: appConfig.apiKey,
      shopify_shared_secret: appConfig.apiSecret,
      shopify_scope: appConfig.scopes,
      redirect_uri: `${forwardingAddress}/shopify/callback`,
      nonce: nonce().toString()
    });

    const authURL = shop.buildAuthURL();
    return res.redirect(authURL);
  } catch (e) {
    console.log(e);
    return res.status(500).json({ message: "Server error" });
  }
});

app.get(
"/shopify/callback", async (req, res) => {
try {
const shop = shopifyService.getShop(req.query.shop);
if (!shop) {
thrownewError("No shop provided");
    }
await ShopifyService.exchangeTemporaryToken(shop, req.query);
await ShopifyService.subscribeToUninstallWebHook(
      shop,
`${forwardingAddress}/app/webhooks/app-uninstalled`
    );
return res.redirect(
`https://${shop.config.shop}/admin/apps/${appConfig.apiKey}`
    );
  } catch (e) {
console.log(e);
return res.status(500).json({ message: "Server error" });
  }
});

app.post("/app/webhooks/app-uninstalled", verifyWebhookRequest, (req, res) => {
try {
const shopName = req.headers["x-shopify-shop-domain"];
    shopifyService.uninstall(shopName);
return res.status(200).json({ message: "Successfully deleted all shop data" });
  } catch (error) {
console.log(error);
return res.status(400).send("Server error");
  }
});

Now when a merchant will click the install button for our application (or goes to {app-url}/shopify?shop={shop-url}, an example URL might look like this: https://94b32975b89c.ngrok.io/shopify?shop=normal-clothing-store-1.myshopify.com/) the following will happen:

  • The server will save the shop’ data if it has not been already saved in ShopifyService shops variable;
  • Merchant will be redirected to the page where he will give the app permission to everything that is set in appConfig.scopes if these permissions are not already granted;
  • Merchant will be redirected to /shopify/callback page where server will exchange store’s temporary token to the constant one;
  • Merchant will be redirected to the Shopify app.

When the merchant uninstalls the application Shopify will send a request to /app/webhooks/app-uninstalled. The server will verify that the request was sent by Shopify and will remove the shop from saved shops. To verify the request we used shopify-hmac-validation method. To make it work we needed to save the requested raw body value for the webhook request.

Note 1: if your forwarding url has http protocol, you will get an error when app/uninstall webhook event fires.

Note 2: created code does not verify if there is already a subscription to the app/uninstall webhook, so when a store installs the app the second time, Shopify will return an error. To get rid of this error, delete the app from the store and install the app again.

Learn how your Marketing team can update your Node App with ButterCMS.

Create UI in Shopify app to save ButterCMS token

To add items to a collection in ButterCMS we need merchants’ write-enabled token. This means that our Shopify app should have a configuration page with a form containing a single input field for this token. This form will be submitted to our API route /butter-cms/config which will save provided token.

install-2.png

Back end of Shopify app

Run this command to install all needed dependencies:

npm i buttercms request request-promise


Create file butter-cms-service.js in services folder and add the following code:

import Butter from "buttercms";
import requestPromise from "request-promise";

class ButterCMSService {
  constructor() {
    this.configs = {};
  }

  connect(shopName, configs) {
    this.configs[shopName] = configs;
  }

  disconnect(shopName) {
    delete this.configs[shopName];
  }
}

export default { ButterCMSService };


Add function to be used as middleware to verify that request was sent from the Shopify, by adding this code to index.js:

function verifyRequest(req, res, next) {
  try {
    if (!hmacValidity.checkHmacValidity(appConfig.apiSecret, req.query)) {
      throw Error("Unauthorized");
    }

    const shop = shopifyService.getShop(req.query.shop);
    if (!shop) {
      throw new Error("Shop not found");
    }
    res.locals.shop = shop;
    return next();
  } catch (e) {
    console.log(e);
    return res.status(401).json({ message: "Unauthorized" });
  }
}

This middleware also adds a shop instance to the res.locals.

Import butterCMSService and create instance in index.js:

const { ButterCMSService } = require("./services/butter-cms-service");

const butterCMSService = new ButterCMSService();


And finally, add the route:

app.post("/app/butter-cms/config", verifyRequest, (req, res) => {
  try {
    const writeToken = req.body.config.butterCMSWriteToken;

    if (!writeToken) {
      return res
        .status(404)
        .json({ message: "butterCMSWriteToken is missing" });
    }
    butterCMSService.connect(res.locals.shop.config.shop, {
      writeToken
    });
    return res
      .status(200)
      .json({ message: "Configurations have been successfully saved" });
  } catch (e) {
    console.log(e);
    return res.status(400).json({ message: "Something went wrong" });
  }
});


Also we should update
/app/webhooks/app-uninstalled route to forget token when app is uninstalled by adding butterCMSService.disconnect(shopName); after shopifyService.uninstall(shopName);

Front end of Shopify app

The API route is ready, so we need to create the UI. To create an Angular application named app-ui with Material and FlexLayout run following command:

npm install -g @angular/cli
ng new app-ui --routing=true --style=scss
cd app-ui
ng add @angular/material
npm install @angular/flex-layout
ng serve

The app should be available at localhost on port 4200. If you stumble upon an unexpected error related to flex-layout, try using version 9.0.0-beta.31. 

Update outputPath in angular.json file so the build files would go to the root directory

"architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:browser",
          "options": {
            "outputPath": "../dist/app-ui",

Remove following code from index.js file app.get("/", (req, res) => res.send("Hello World!"));

And add the following to serve angular build files:

const path = require("path");
...

app.use(express.static(path.join(__dirname, "/dist/app-ui")));

And add the following to serve angular build files:
  res.sendFile(path.join(__dirname, "/dist/app-ui/index.html"));
});

Create module and component for the config page:

ng g module config --module app --routing
ng g component config/config-form --skipSelector


Update app-routing.module.ts by adding routes:

{ path: 'config', component: ConfigFormComponent },
{
    path: '',
    pathMatch: 'full',
    redirectTo: 'config',
}

Update app.component.html to display link to config page:

<a [routerLink]="['/config']" queryParamsHandling="preserve">
Configurations
</a>
<router-outlet></router-outlet>

We use queryParamsHandling="preserve" because we need to keep query parameters that are used to verify request calls.

Create service to make http requests to the server:

ng g service core/api

Update service to create method configApp to send request to backend:

import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';

@Injectable({
  providedIn: 'root',
})
export class ApiService {
  private apiUrl = 'app';
  writeToken: string;

  constructor(private http: HttpClient) {}

  configApp(config: { butterCMSWriteToken: string }): Observable<void> {
    this.writeToken = config.butterCMSWriteToken;
    return this.http.post<void>(`${this.apiUrl}/butter-cms/config`, {
      config,
    });
  }
}

Add HttpClientModule to imports in app.module.

Create interceptor to add verification query parameters to all http requests, by running the command:

ng g interceptor core/url-query

Update created interceptor to add needed functionality:

import { Injectable } from '@angular/core';
import {
  HttpRequest,
  HttpHandler,
  HttpEvent,
  HttpInterceptor,
} from '@angular/common/http';
import { Observable } from 'rxjs';
import { ActivatedRoute } from '@angular/router';

@Injectable()
export class UrlQueryInterceptor implements HttpInterceptor {
  constructor(private route: ActivatedRoute) {}

  intercept<T>(
    request: HttpRequest<T>,
    next: HttpHandler
  ): Observable<HttpEvent<T>> {
    const params = this.route.snapshot.queryParams;
    const queryString = Object.keys(params)
      .map((key) => key + '=' + params[key])
      .join('&');

    const req = request.clone({
      url:
        request.url.indexOf('?') > -1
          ? `${request.url}&${queryString}`
          : `${request.url}?${queryString}`,
    });
    return next.handle(req);
  }
}
Include the interceptor in app.module providers by adding:
providers: [
    { provide: HTTP_INTERCEPTORS, useClass: UrlQueryInterceptor, multi: true },
]


Update config-form.component.ts file:

import { Component, OnInit } from '@angular/core';
import { FormGroup, FormControl, Validators } from '@angular/forms';
import { MatSnackBar } from '@angular/material/snack-bar';

@Component({
  templateUrl: './config-form.component.html',
  styleUrls: ['./config-form.component.scss'],
})
export class ConfigFormComponent implements OnInit {
  form: FormGroup;
  submittingForm = false;
  errorMessage: string;
  tokenLength = 40;

  constructor(private apiService: ApiService, private snackbar: MatSnackBar) {}

  ngOnInit(): void {
    this.form = new FormGroup({
      butterCMSWriteToken: new FormControl('', [
        Validators.required,
        Validators.minLength(this.tokenLength),
        Validators.maxLength(this.tokenLength),
      ]),
    });
  }

  get butterCMSWriteToken() {
    return this.form.get('butterCMSWriteToken');
  }

  submitForm() {
    if (!this.form.valid) {
      this.form.markAsTouched();
      return;
    }
    this.submittingForm = true;
    this.errorMessage = '';
    this.apiService.configApp(this.form.value).subscribe(
      () => {
        this.snackbar.open('Token has been saved', null, {
          duration: 3000,
          panelClass: 'notification_success',
        });
        this.submittingForm = false;
      },
      (error) => {
        console.log(error);
        this.submittingForm = false;
        this.errorMessage =
          error.message && error.error.message
            ? error.error.message
            : error.message;
      }
    );
  }
}


Update config-form.component.html:

<h1 class="mat-h1">Configure ButterCMS</h1>
<form [formGroup]="form" (ngSubmit)="submitForm()">
  <mat-form-field appearance="outline" class="multi-line-error">
    <mat-label>Write-enabled Token</mat-label>
    <input
      type="text"
      required
      matInput
      cdkFocusInitial
      formControlName="butterCMSWriteToken"
      name="write-token"
      maxlength="40"
    />
    <mat-hint align="end">
      {{ butterCMSWriteToken.value.length }} / {{ tokenLength }}
    </mat-hint>
    <mat-error *ngIf="butterCMSWriteToken.hasError('required')">
      Provide token
    </mat-error>
    <mat-error
      *ngIf="
        butterCMSWriteToken.hasError('minlength') ||
        butterCMSWriteToken.hasError('maxlength')
      "
    >
      Token must be 40 characters
    </mat-error>
  </mat-form-field>
  <div fxLayout="column" fxLayoutAlign="center center">
    <div class="error-message" *ngIf="errorMessage">
      {{ errorMessage }}
    </div>
    <mat-spinner *ngIf="submittingForm" [diameter]="30" color="primary">
    </mat-spinner>
    <button
      mat-raised-button
      class="button_submit"
      color="accent"
      type="submit"
    >
      Save
    </button>
  </div>
</form>


Update config.module.ts file:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { MatInputModule } from '@angular/material/input';
import { MatButtonModule } from '@angular/material/button';
import { FlexLayoutModule } from '@angular/flex-layout';

import { ConfigFormComponent } from './config-form/config-form.component';
import { ReactiveFormsModule } from '@angular/forms';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatSnackBarModule } from '@angular/material/snack-bar';

@NgModule({
  declarations: [ConfigFormComponent],
  imports: [
    CommonModule,
    MatProgressSpinnerModule,
    ReactiveFormsModule,
    FlexLayoutModule,
    MatFormFieldModule,
    MatInputModule,
    MatButtonModule,
    MatSnackBarModule,
  ],
  exports: [ConfigFormComponent],
})
export class ConfigModule {}


The form that was created has very simple token validation: its length should be 40 characters. It could be a better practice to add pattern validation. The backend does not validate the token.

undefined

This is a screenshot of the configuration page. Look at the repository for the full code to see the implementation of the layout and styles.

Connect Shopify products to ButterCMS products collection

Every time you add a new product to your Shopify store, we want its data to be added to the ButterCMS collection automatically.  We will do this using the ButterCMS Write API.

install-3.png

First, let’s create a collection named Products in ButterCMS. Click “Collections” and “New Collection”. We need to store three attributes for each product: Name, Image and Description. To configure this collection we will configure the collection with three field types: “Short Text”, “Media” and “Long Text”. Click “Create Collection” and name it “Products.”

undefined

The ideal solution to add this functionality would be to subscribe to products/create webhook, however, this event might fire before the product’s image has been loaded and saved. Because of this, we have to subscribe to products/update webhook.

Add addItemToCollection method to butterCMSService:

async addItemToCollection(shopName, collectionName, item) {
    if (!this.configs[shopName] || !this.configs[shopName].writeToken) {
      return Promise.reject(Error("No write-enabled token configured"));
    }
    const butter = Butter(this.configs[shopName].writeToken);
    const collectionData = await butter.content.retrieve([collectionName]);

    if (collectionData.data) {
      const body = {
        key: collectionName,
        status: "published",
        fields: [
          {
            en: item
          }
        ]
      };
      const options = {
        method: "POST",
        uri: "https://api.buttercms.com/v2/content/",
        body,
        json: true,
        headers: {
          authorization: `Token ${this.configs[shopName].writeToken}`
        }
      };
      return requestPromise(options);
    }
    return Promise.reject(Error(`Collection ${collectionName}was not found.`));
  }

This method verifies that the collection exists and adds a new item. This setup assumes that the English locale (en) is set in ButterCMS.

Add subscribeToProductUpdateWebhook method to ShopifyService:

static subscribeToProductUpdateWebhook(shop, address) {
    const params = {
      topic: "products/update",
      address,
      format: "json"
    };

    const shopify = new ShopifyAPI({
      shopName: shop.config.shop,
      accessToken: shop.config.access_token
    });
    return shopify.webhook.create(params);
}

Add route to the server for the webhook:

app.post("/app/webhooks/product-update", verifyWebhookRequest, async (req, res) => {
  try {
    const shopName = req.headers["x-shopify-shop-domain"];
    await butterCMSService
      .addItemToCollection(shopName, "products", {
        name: req.body.title,
        image: req.body.image.src,
        description: req.body.body_html
      });
    return res
      .status(200)
      .json({ message: "Product has been added to collection" });
  } catch (error) {
    console.log(error);
    return res.status(400).json({ message: "Server error" });
  }
});

We again use verifyWebhookRequest verifyWebhookRequest middleware to verify the request. Of course, we could have added more layers to the verification.

Add raw body to that route by adding the following:

app.use(
  "/app/webhooks/product-update",
  bodyParser.json({ verify: rawBodySaver })
);


Now we should update /shopify/callback route to add another subscription to the webhooks:

await Promise.all([
    ShopifyService.subscribeToProductUpdateWebhook(
      shop,
      `${forwardingAddress}/app/webhooks/product-update`
    ),
    ShopifyService.subscribeToUninstallWebHook(
      shop,
      `${forwardingAddress}/app/webhooks/app-uninstalled`
    )
]);

The work is done. To check that it works we should build for production the Angular app, start the server, install the app, specify write-enabled token in the UI, update or create any product in the Shopify shop and go to https://buttercms.com/content/collections/l/en to verify that Products collection now has the same data as we provided in the Shopify store. Note, that subscription and collection update may take some time while Shopify sends the notification of update and new data updates.

Create page type in ButterCMS

We need to create a promotional page structure that will allow us to showcase shop products.  For this tutorial, we will keep things simple with configuring the page with some of the example custom Components that are provided by ButterCMS. This structure will influence the whole process of page creation on our server.

Page structure will be stored as a promotional_type Page Type in ButterCMS. 

To create a new Page Type we should go to https://buttercms.com/pages/, click the “New page” button, and then click “Create new page”. You will be redirected to a new page where you can build a page. Click “Component” and a new block should appear. Click “Add from Library” and on “SEO” in the list. Add another component named Twitter Card. Then add a Reference “Product Promo Banner” One-to-Many Products collection.

This is how the page structure should look like:

undefined

Click “Save”, and after redirection to another page click gear icon and “Configure Page Type”. Then you will be redirected again on a page where you should click “Create page type” and type name “promotional_page”.

If you have any problems creating this page type, read this article: https://buttercms.com/kb/creating-editing-and-deleting-pages-and-page-types#creatingapagetype.

Publish pages from ButterCMS to Shopify store

We want to allow the Shopify app to create pages for the shop using data from promotional pages stored in ButterCMS. We also want to allow merchants to specify which template they want to use to display the data. So our app should have a page with a list of promotional pages stored in ButterCMS each with a button to select a template and publish this page.

promotional-pages-1.png

promotional-pages-2.png

Back end

For this functionality backend should have two routes: 

  • Get a list of promotional pages
  • Create a new page in the Shopify shop with data related to a selected page by slug 

We will use mustache to fill templates with data:

npm i mustache

Require it in index.js:

const mustache = require("mustache");

Add to ButterCMSService methods to fetch all promotional pages and only a specific one:

getPromotionalPages(shopName, pageNumber) {
    if (!this.configs[shopName] || !this.configs[shopName].writeToken) {
      console.log("not configured");
      return Promise.reject(Error("No write-enabled token configured"));
    }
    const butter = Butter(this.configs[shopName].writeToken);
    const params = {
      preview: 1,
      page: pageNumber,
      page_size: 10,
      locale: "en",
      levels: 2
    };

    return butter.page.list("promotional_page", params);
  }

getPromotionalPage(shopName, slug) {
    if (!this.configs[shopName] || !this.configs[shopName].writeToken) {
      return Promise.reject(Error("No write-enabled token configured"));
    }
    const butter = Butter(this.configs[shopName].writeToken);
    return butter.page.retrieve("promotional_page", slug, {
      locale: "en",
      preview: 1
    });
}

Update ShopifyService by adding new static method:

static createPage(shop, pageOptions) {
  const shopify = new ShopifyAPI({
    shopName: shop.config.shop,
    accessToken: shop.config.access_token
  });
  return shopify.page.create(pageOptions);
}

Add new routes to the server:

app.get(
  "/app/butter-cms/promotional-pages/page/:page",
  verifyRequest,
  async (req, res) => {
    try {
      const { shop } = res.locals;
      const shopName = shop.config.shop;
      const pages = await butterCMSService.getPromotionalPages(
        shopName,
        req.params.page || 1
      );
      return res.status(200).json(pages.data);
    } catch (e) {
      console.log("catch error here", e);
      return res.status(500).json({ message: "Server error" });
    }
  }
);

app.post(
  "/app/butter-cms/promotional-page",
  verifyRequest,
  async (req, res) => {
    try {
      const { slug, template } = req.body;
      if (!slug) {
        return res.status(400).json({ message: "slug is missing" });
      }
      if (!template) {
        return res.status(400).json({ message: "template is missing" });
      }
      const { shop } = res.locals;
      const shopName = shop.config.shop;
      const response = await butterCMSService.getPromotionalPage(
        shopName,
        slug
      );
      const page = response.data;
      const pageHtml = mustache.render(template, page.data);
      await ShopifyService.createPage(shop, {
        title: page.data.fields.seo.title,
        body_html: pageHtml,
        slug: page.data.slug
      });
      return res.status(200).json({ message: "Page has been successfully created" });
    } catch (e) {
      console.log(e);
      return res.status(500).json({ message: "Server error" });
    }
  }
);

Front end

Create types.ts file with the following content:

export interface PromotionalPage {
  slug: string;
  page_type: 'promotional_page';
  fields: {
    seo: { title: string; meta_description: string };
    twitter_card: {
      title: string;
      Description: string;
      image: string;
    };
    products: [
      {
        product_name: string;
        product_image: string;
        product_description: string;
      }
    ];
  };
}

Add two methods to ApiService:

getPromotionalPages(
  pageNumber: number
): Observable<{
  meta: {
    next_page: number | null;
    previous_page: number | null;
    count: number;
  };
  data: PromotionalPage[];
}> {
  return this.http.get<{
    meta: {
      next_page: number | null;
      previous_page: number | null;
      count: number;
    };
    data: PromotionalPage[];
  }>(`${this.apiUrl}/butter-cms/promotional-pages/page/${pageNumber}`);
}

createPageFromButterCMSPage(
  slug: string,
  template: string
): Observable<void> {
  return this.http.post<void>(`${this.apiUrl}/butter-cms/promotional-page/`, {
    slug,
    template,
  });
}

Create a new module:


ng g module promotional-pages --module app --route promotional-pages


Add to app.component a link to promotional pages list:

<a [routerLink]="['/promotional-pages']" queryParamsHandling="preserve">
  Promotional pages
</a>

Update promotional-pages.component.ts:

import { Component, OnInit } from '@angular/core';
import { ApiService } from '../core/api.service';
import { Observable, throwError } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { MatDialog } from '@angular/material/dialog';

import { PromotionalPage } from '../types';
import { TemplateDialogComponent } from './template-dialog/template-dialog.component';

@Component({
  selector: 'app-promotional-pages',
  templateUrl: './promotional-pages.component.html',
  styleUrls: ['./promotional-pages.component.scss'],
})
export class PromotionalPagesComponent implements OnInit {
  pages: Observable<{
    meta: {
      next_page: number | null;
      previous_page: number | null;
      count: number;
    };
    data: PromotionalPage[];
  }>;
  displayedColumns = ['image', 'title', 'description', 'actions'];
  errorMessage: string;

  constructor(private apiService: ApiService, private dialog: MatDialog) {}

  ngOnInit(): void {
    this.pages = this.apiService.getPromotionalPages(1).pipe(
      catchError((error) => {
        console.log(error);
        this.errorMessage =
          error.message && error.error.message
            ? error.error.message
            : error.message;
        return throwError(error);
      })
    );
  }

  createPage(page: PromotionalPage) {
    this.dialog.open(TemplateDialogComponent, {
      width: '800px',
      maxWidth: '80%',
      data: { slug: page.slug },
    });
  }
}

Update promotional-pages.component.html:

<div>
  <h1>Promotional pages in ButterCMS</h1>
  <div
    *ngIf="pages | async as pagesList; else loadingOrError"
    class="table__wrapper"
  >
    <table mat-table [dataSource]="pagesList.data" class="mat-elevation-z8">
      <ng-container matColumnDef="image">
        <th mat-header-cell *matHeaderCellDef>Image</th>
        <td mat-cell *matCellDef="let element">
          <img
            mat-card-image
            [src]="element.fields.twitter_card.image"
            alt="Promotional page image"
            class="page__image"
          />
        </td>
      </ng-container>

      <ng-container matColumnDef="title">
        <th mat-header-cell *matHeaderCellDef>Title</th>
        <td mat-cell *matCellDef="let element">
          {{ element.fields.seo.title }}
        </td>
      </ng-container>

      <ng-container matColumnDef="description">
        <th mat-header-cell *matHeaderCellDef>Description</th>
        <td mat-cell *matCellDef="let element">
          <div [innerHTML]="element.fields.twitter_card.Description"></div>
        </td>
      </ng-container>

      <ng-container matColumnDef="actions">
        <th mat-header-cell *matHeaderCellDef>Actions</th>
        <td mat-cell *matCellDef="let element">
          <div>
            <button mat-raised-button (click)="createPage(element)">
              Create page
            </button>
          </div>
        </td>
      </ng-container>

      <tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
      <tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
    </table>
  </div>
  <ng-template #loadingOrError>
    <div
      *ngIf="!errorMessage; else errorLoading"
      fxLayout="row"
      fxLayoutAlign="center center"
    >
      <mat-spinner color="primary"></mat-spinner>
    </div>
  </ng-template>
  <ng-template #errorLoading>
    <div class="error-message">
      {{ errorMessage }}
    </div>
  </ng-template>
</div>

Update promotional-components.module.ts:

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { HTMLEscapeUnescapeModule } from 'html-escape-unescape';
import { ReactiveFormsModule } from '@angular/forms';
import { FlexLayoutModule } from '@angular/flex-layout';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatDialogModule } from '@angular/material/dialog';
import { MatTableModule } from '@angular/material/table';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { ClipboardModule } from '@angular/cdk/clipboard';
import { MatSnackBarModule, MatSnackBar } from '@angular/material/snack-bar';

import { PromotionalPagesRoutingModule } from './promotional-pages-routing.module';
import { PromotionalPagesComponent } from './promotional-pages.component';
import { TemplateDialogComponent } from './template-dialog/template-dialog.component';
import { TemplateDialogFormComponent } from './template-dialog-form/template-dialog-form.component';

@NgModule({
  declarations: [
    PromotionalPagesComponent,
    TemplateDialogComponent,
    TemplateDialogFormComponent,
  ],
  imports: [
    CommonModule,
    PromotionalPagesRoutingModule,
    MatDialogModule,
    MatTableModule,
    MatButtonModule,
    MatProgressSpinnerModule,
    ReactiveFormsModule,
    FlexLayoutModule,
    MatIconModule,
    FlexLayoutModule,
    MatFormFieldModule,
    MatInputModule,
    MatButtonModule,
    ClipboardModule,
    HTMLEscapeUnescapeModule,
    MatSnackBarModule,
  ],
  entryComponents: [TemplateDialogComponent],
})
export class PromotionalPagesModule {}

Create dialog component and form component:

ng g component promotional-pages/template-dialog --skipSelector
ng g component promotional-pages/template-dialog-form

Set template-dialog.component.ts:

import { Component, OnInit, Inject } from '@angular/core';
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
import { MatSnackBar } from '@angular/material/snack-bar';
import { ApiService } from 'src/app/core/api.service';

@Component({
  templateUrl: './template-dialog.component.html',
  styleUrls: ['./template-dialog.component.scss'],
})
export class TemplateDialogComponent implements OnInit {
  errorMessage: string;
  isSubmitting = false;

  constructor(
    private dialogRef: MatDialogRef<TemplateDialogComponent>,
    private snackBar: MatSnackBar,
    private apiService: ApiService,
    @Inject(MAT_DIALOG_DATA) private pageData: { slug: string }
  ) {}
  ngOnInit() {}

  onSubmitForm(template: string) {
    this.errorMessage = '';
    this.isSubmitting = true;
    this.apiService
      .createPageFromButterCMSPage(this.pageData.slug, template)
      .subscribe(
        (res) => {
          this.isSubmitting = false;
          this.dialogRef.close();
          this.snackBar.open(
            'Page was successfully created. Check your shop pages.',
            null,
            { duration: 3000, panelClass: 'notification_success' }
          );
        },
        (error) => {
          this.errorMessage =
            error.error && error.error.message
              ? error.error.message
              : error.statusText;
          this.isSubmitting = false;
          console.log(error);
        }
      );
  }
}

Set content of template-dialog.component.html:

<h1 mat-dialog-title>Set page template</h1>
<mat-dialog-content class="mat-typography">
  <promotional-pages-template-dialog-form
    (formSubmitted)="onSubmitForm($event)"
    [errorMessage]="errorMessage"
    [isSubmitting]="isSubmitting"
  ></promotional-pages-template-dialog-form>
</mat-dialog-content>

We will allow to read template from doc/docx file with mammoth, so run:

npm i mammoth

Update template-dialog-form.component.ts:

import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { FormGroup, Validators, FormControl } from '@angular/forms';
import * as mammoth from 'mammoth/mammoth.browser';

@Component({
  selector: 'promotional-pages-template-dialog-form',
  templateUrl: './template-dialog-form.component.html',
  styleUrls: ['./template-dialog-form.component.scss'],
})
export class TemplateDialogFormComponent implements OnInit {
  @Input() errorMessage: string;
  @Input() isSubmitting: boolean;

  @Output() formSubmitted = new EventEmitter<void>();

  form: FormGroup;
  hasExamples = false;
  exampleTemplate = `<h1>{{fields.twitter_card.title}}</h1>
<img style="max=width: 100%" src="{{fields.twitter_card.image}}"/>
<p>{{fields.twitter_card.Description}}</p>
<h2>{{fields.product_promo_banner.headline}}</h2>
<div style="display: flex; flex-wrap: wrap; justify-content: space-between">
{{#fields.product_promo_banner.product}}
  <div style="flex:1; padding: 10px">
    <a href="/collections/all/products/{{name}}">{{name}}</a>
    <img style="width: 100%" src="{{image}}"/>
    <p>{{description}}</p>
  </div>
{{/fields.product_promo_banner.product}}
</div>`;
  templateError: string;

  constructor() {}

  ngOnInit(): void {
    this.form = new FormGroup({
      template: new FormControl('', Validators.required),
    });
  }

  get template() {
    return this.form.get('template');
  }

  submitForm() {
    if (!this.form.valid) {
      this.form.markAsTouched();
      return;
    }
    this.formSubmitted.emit(this.template.value);
  }

  onFileChange(event: Event) {
    const file = (event.target as HTMLInputElement).files[0];
    if (!file) {
      return;
    }
    const type = file.name.split('.').pop();
    if (type === 'txt') {
      this.readTxtFile(file);
    } else if (type === 'doc' || type === 'docx') {
      this.readDocFile(file);
    } else {
      this.templateError =
        'Invalid file format. Only .txt, .doc and .docx are supported';
    }
  }

  private readDocFile(file: File) {
    const reader = new FileReader();
    reader.onloadend = () => {
      const arrayBuffer = reader.result;

      mammoth.extractRawText({ arrayBuffer }).then((resultObject) => {
        this.template.setValue(resultObject.value);
      });
    };
    reader.readAsArrayBuffer(file);
  }

  private readTxtFile(file: File) {
    const reader = new FileReader();
    reader.onload = (event) => {
      const contents = event.target.result;
      this.template.setValue(contents);
    };

    reader.readAsText(file);
  }
}

To display example template run npm i html-escape-unescape

Set template-dialog-form.component.html:

<div fxLayout="row" fxLayoutAlign="start center">
  <mat-icon>help</mat-icon>
  <div class="template__help" (click)="hasExamples = !hasExamples">
    {{ hasExamples ? "Hide" : "Show" }} example template
  </div>
</div>
<div *ngIf="hasExamples" class="example">
  <button
    mat-icon-button
    class="example__copy-button"
    [cdkCopyToClipboard]="exampleTemplate"
  >
    <mat-icon>content_copy</mat-icon>
  </button>
  <div class="example__text">
    {{ exampleTemplate | unescape }}
  </div>
</div>
<form class="template__form" [formGroup]="form" (ngSubmit)="submitForm()">
  <div class="file-loader">
    <input
      class="file-loader__input"
      type="file"
      (change)="onFileChange($event)"
      accept=".doc,.docx,.txt"
      #file
    />
    <button
      mat-raised-button
      color="primary"
      (click)="file.click()"
      type="button"
    >
      Load template from doc/docx/txt file
    </button>
  </div>
  <mat-form-field appearance="outline" class="multi-line-error">
    <mat-label>Template for the page</mat-label>
    <textarea
      required
      matInput
      cdkFocusInitial
      formControlName="template"
      rows="10"
      name="page-template"
    ></textarea>
    <mat-error *ngIf="template.hasError('required')">
      Set template
    </mat-error>
  </mat-form-field>
  <div fxLayout="column" fxLayoutAlign="center center">
    <div class="error-message" *ngIf="errorMessage || templateError">
      {{ errorMessage || templateError }}
    </div>
    <mat-spinner *ngIf="isSubmitting" [diameter]="20"></mat-spinner>
    <button
      mat-raised-button
      class="button_submit"
      color="accent"
      type="submit"
    >
      Set template
    </button>
  </div>
</form>

Now on the promotional page, we will get a list of promotional pages fetched from ButterCMS with functionality to create a Shopify page using the template set in textarea and page data from ButterCMS.

App screenshots:

App screenshot I.JPG

App screenshot II.JPG

App screenshot III.JPG

Full project code including scss files is available at GitHub repo at https://github.com/ButterCMS/shopify-buttercms.

Summary

In this tutorial, we created a Shopify application that allowed us to create promotional pages easier and faster. Using this application you will not spend time copying and pasting data which is already in your shop to create great pages. Now you can concentrate on making these pages more amazing.

Make sure you receive the freshest Butter product updates.
    
Orly Knop

Web developer who loves Angular 2+ and Node.js.

ButterCMS is the #1 rated Headless CMS

Related articles

Don’t miss a single post

Get our latest articles, stay updated!