ScandiPWA
Create Magento AppCreate ScandiPWA AppUser ManualGitHub
  • Why Scandi
  • πŸš€Quick-start Guide
  • πŸ—ΊοΈRoadmap
  • Introduction to the Stack
    • CMA, CSA, and ScandiPWA
    • Challenges
  • Setting up Scandi
    • Storefront Mode Setup
      • Proxying requests to server
    • Magento Mode Setup
    • Existing Magento 2 setup
    • Magento Commerce Cloud setup
    • Updating to new releases
      • Storefront mode upgrade
      • Magento mode upgrade
      • CMA upgrade
      • CSA upgrade
      • Custom ScandiPWA composer dependency update
      • Local ScandiPWA Composer Package Setup
    • Docker Setup [deprecated]
      • Legacy Docker setup
      • Migrating to CMA & CSA
  • Developing with Scandi
    • Override Mechanism
      • Overriding JavaScript
        • Overriding classes
        • Overriding non-classes
      • Overriding Styles
      • Overriding the HTML / PHP
      • Parent Themes
    • Extensions
      • Creating an extension
      • Installing an extension
      • Migrating from 3.x to 4.x
      • Publishing an extension
      • Extension Terminology
    • Working With Magento
      • Magento troubleshooting
      • Working with Magento modules
      • Working with GraphQL
      • GraphQL Security
      • Working with "granular cache"
    • Developer Tools
      • Debugging in VSCode
      • ScandiPWA CLI
      • Configuring ESLint
      • CSA Commands
    • Deploying Your App
      • Build & Deploy Android app
      • Build & Deploy iOS app
  • Structure
    • Directory Structure
    • Building Blocks
      • Components
        • Styling Components
      • Routes
      • Redux Stores
      • GraphQL Queries
      • Global Styles
      • The Util Directory
      • Type Checking
    • Application assets
    • Code Style
      • JavaScript Code Style
      • SCSS Code Style
  • Tutorials
    • Customizing Your Theme
      • Styling
        • Customizing the Global Styles
        • Adding a New Font
        • Overriding a Components Styles
        • Extending a Component's Styles
      • Customizing JavaScript
        • Customizing the Footer Copyright
        • Adding a New Page
        • Adding a Section in My Account
        • Adding a Tab on the Product Page
        • Creating a New Redux Store
    • Payment Method Integration
      • Setting Up for Development
      • Redirecting to the Payment Provider
      • Handling the Customer's Return
    • Creating a Custom Widget
      • Scandi CMS System Overview
      • Creating a Magento Widget
      • Implementing the Rendering
    • Video Tutorials
      • #1 Setting up and talking theory
      • #2 Templating in React
      • #3 Overriding a file
      • #4 Styling the application
      • #5 Patterns of ScandiPWA
    • Dark Mode Extension
    • Deploying Native Apps
    • Product 3D Model Extension
      • Part 1: Magento 3D Model Uploads
      • Part 2: GraphQL API
      • Part 3: Scandi Frontend
    • Social Share, Full Extension Development
      • STEP-1 and 2 Creating Magento 2 Module
      • STEP-3 Backend Configurations Settings
      • STEP-4 Simple GraphQl and Resolver
      • STEP-5 Creating Extension, Base Redux Store
      • STEP-6 Extension plugins
      • STEP-7 GraphQL types, Helpers
      • STEP-8 Query Field and FieldList
      • STEP-9 render Plugins and MSTP Plugin, Component creation
      • STEP-10 SocialShare Component Development
      • STEP-11 SocialShare for CategoryPage
      • TASK-1 Changing LinkedIn to Twitter
      • STEP-12 Comments for Admin Users
      • STEP-13 Final, bugfixes
    • Accessing Magento 2 Controllers
      • STEP-1 Creating Magento 2 Module
      • STEP-2 - Create Magento 2 Frontend Route and Basic Controller
      • STEP-3 Accessing Magento 2 Controller, Bypassing ScandiPWA frontend
      • STEP-4 Creating ScandiPWA Extension with additional dependencies
      • STEP-5 Creating Plugin and Axios request
  • About
    • Support
    • Release notes
    • Technical Information
    • Data Analytics
    • Contributing
      • Installation from Fork
      • Repository structure
      • Code contribution process
      • Submitting an Issue
      • Publishing ScandiPWA
Powered by GitBook
On this page
  • Prerequisites
  • Creating a Scandi Extension
  • Updating the Product Query
  • Creating a 3D Model Component
  • Rendering a Product Tab
  • Implementing 3D Rendering
  • What Next?

Was this helpful?

  1. Tutorials
  2. Product 3D Model Extension

Part 3: Scandi Frontend

Display 3D models on the product page

PreviousPart 2: GraphQL APINextSocial Share, Full Extension Development

Last updated 4 years ago

Was this helpful?

Prerequisites

Before you start with this section, you need to have a and .

Creating a Scandi Extension

We already have a backend module for our extension, but we need a separate Scandi extension Javascript module, responsible for implementing the frontend functionality. An extension is a reusable package that can be installed on any Scandi theme.

To create an extension, navigate to the root of your CSA application in the terminal. You can using the scandipwa script:

scandipwa extension create 

If you haven't installed the , you can do so with npm i -g scandipwa-cli. It will automate certain tasks, making development faster.

This script will initialize a new extension named packages/product-3d-scandi and configure your theme to install it. It will also enable it by setting scandipwa.extensions["product-3d-scandi"] to true in package.json.

Updating the Product Query

We have an API available to get the 3D models for each product. However, due to the design of GraphQL, this data won't be fetched unless the query specifically requests it (this helps save bandwidth on unused fields). Hence, the first step is to update the query so that 3D models are requested as well.

Unlike many other applications, Scandi builds queries at runtime, using its . This means that updating the query for the purposes of an extension is as easy as .

The ProductList query creator is responsible for making product queries. In particular, we are interested in the _getProductInterfaceFields method, which is responsible for specifying the product fields we want to query.

If need to plug into a query creator, but don't know its class, you can inspect the related source code to see which query it uses.

The current implementation of _getProductInterfaceFields already returns multiple fields, represented as an array of strings:

[
  "id",
  "sku",
  "name",
  "type_id",
  "stock_status",
  // [...]
]

More complex fields that also have sub-fields can be represented by an instance of the Field class. This will not be necessary for our purposes.

We want to wrap around this method, get the "original" array, and return an array with a new field, "model_3d_urls", in addition to the original ones:

src/plugin/ProductList.query.plugin.js
export const _getProductInterfaceFields = (args, callback, instance) => {
  const originalFields = callback(...args);
  return [...originalFields, "model_3d_urls"];
};

export default {
  "Query/ProductList": {
    "member-function": { _getProductInterfaceFields },
  },
};

The plugin mechanism will wrap the _getProductInterfaceFields method of the ProductList query with our custom plugin – whenever _getProductInterfaceFieldsis called, our plugin will be used instead.

The original arguments will be provided in the args parameter, and the original function will be given in callback (instance is the class instance the method was called on, but we don't need it in this example).

If you restart your app and open the Product Detail Page, you can verify in your browser's network tab that the model_3d_urls are indeed returned in the product request.

Creating a 3D Model Component

We want to be able to display a carousel of 3D models on the product page, but we don't have any components with this functionality – so we need to create a new one. Again, we can use the CLI tool instead of creating the boilerplate manually

scandipwa create component Product3DViewer

Expected output:

     The following files have been created:
     src/component/Product3DViewer/Product3DViewer.component.js
     src/component/Product3DViewer/Product3DViewer.style.scss
     src/component/Product3DViewer/index.js

Rendering a Product Tab

Now that we have a placeholder component, let's render it on the product page – we want it to appear in one of the tabs below the product image:

We can do this with a plugin, but first we need to find the function we need to plug in to. To do this, we can inspect the source code for the ProductPage component.

How did I know to check the ProductPage component? I used the React developer tools to determine the name of the component, then found it in the source code directory. It's a good idea to install these developer extensions to make such tasks easier.

After looking through the source code, it becomes clear that we need to focus on the tabMap property:

Scandi base code: route/ProductPage/ProductPage.component.js
// [...]

/** @namespace Route/ProductPage/Component */
export class ProductPage extends PureComponent {
// [...]

    tabMap = {
        [PRODUCT_INFORMATION]: {
            name: __('About'),
            shouldTabRender: () => {
                const { isInformationTabEmpty } = this.props;
                return isInformationTabEmpty;
            },
            render: (key) => this.renderProductInformationTab(key)
        },
        [PRODUCT_ATTRIBUTES]: {
            name: __('Details'),
            shouldTabRender: () => {
                const { isAttributesTabEmpty } = this.props;
                return isAttributesTabEmpty;
            },
            render: (key) => this.renderProductAttributesTab(key)
        },
        [PRODUCT_REVIEWS]: {
            name: __('Reviews'),
            // Return false since it always returns 'Add review' button
            shouldTabRender: () => false,
            render: (key) => this.renderProductReviewsTab(key)
        }
    };
    
// [...]
}

export default ProductPage;

This -map pattern is fairly common in Scandi. It works by defining an object of similar, but distinct renderable elements. Then, a render function takes this data and renders it in the appropriate place.

This extra step of defining an object might seem counter-intuitive, but it will actually be quite helpful for our purposes. Now, to add a tab, all we have to do is add an entry to this tabMap field. This is easy to do with a member property plugin:

import Product3DViewer
  from "../component/Product3DViewer/Product3DViewer.component";

export const PRODUCT_3D_MODEL_TAB = "PRODUCT_3D_MODEL_TAB";

export const render3dModelTab = (key, modelUrls) => {
  return (
    <Product3DViewer key={key} modelUrls={modelUrls}/>
  );
};

// a member plugin takes the original member value
// and returns the new value that the member should have
export const tabMap = (member, instance) => {
  return {
    ...member,
    [PRODUCT_3D_MODEL_TAB]: {
      name: __("3D Models"),
      shouldTabRender: () => {
        const { product: { model_3d_urls = [] } = {} } = instance.props;
        console.log(instance.props);

        // For some reason, shouldTabRender needs to return the opposite
        // of what you would think
        return !(model_3d_urls.length > 0);
      },
      render: (key) => {
        const { product: { model_3d_urls = [] } = {} } = instance.props;

        return render3dModelTab(key, model_3d_urls);
      },
    },
  };
};

export default {
  "Route/ProductPage/Component": {
    "member-property": { tabMap },
  },
};

You might have noticed we used a function called __. Its job is to translate strings to the appropriate locale in production builds. It is a good practice to always wrap text visible to the user in __(""), in case you want to add translations later.

Implementing 3D Rendering

It also integrates seamlessly with React – all we have to do to use it is to render a <model-viewer> element (this is not technically a React component, but we can still treat it like any other element).

First, we need to load the library. For some reason, installing it via npm and importing it as a module did not work – most likely, there is some conflict between the Webpack configuration in Scandi and model-viewer's module format. To work around this, we can load the module with a CDN:

src/component/Product3DViewer/Product3DViewer.component.js
function loadModelViewer() {
  const MODULE_URL =
    "https://unpkg.com/@google/model-viewer/dist/model-viewer.min.js";

  const script = document.createElement("script");
  script.src = MODULE_URL;
  script.type = "module";

  document.head.appendChild(script);
}

loadModelViewer();

Now we can render a 3D model in our Product3DViewer component:

  render() {
    const { modelUrls } = this.props;

    const url = modelUrls[0];

    return (
      <div block="Product3DViewer">
        <model-viewer
          class="Product3DViewer-Model"
          src={url}
          alt={__("Product 3D Model")}
          auto-rotate
          camera-controls
        ></model-viewer>
      </div>
    );
  }

The only functionality left to implement is pagination between different models – as the admin might have uploaded more than one, but currently only the first one is shown. To implement this pagination, we need to store the currently active model index in the state:

  state = {
    // Keep track of the currently visible model
    activeModelIndex: 0,
  };

Now, we can use modelUrls[activeModelIndex] to get the current model. By updating the state, we can change which model is active: setState({ activeModelIndex: activeModelIndex + 1 }), so let's implement a switcher enabling the user to navigate between different models:

renderModelSwitcher() {
    const { modelUrls } = this.props;
    const { activeModelIndex } = this.state;

    // activeModelIndex is 0-indexed, but 1-indexed pages make more sense
    // so we add 1 to display it to the user
    const activeNumber = activeModelIndex + 1;
    
    const size = modelUrls.length;

    // We'll want to disable the "Previous" button for the first model
    // and the "Next" button for the last model
    const isFirst = activeModelIndex <= 0;
    const isLast = activeModelIndex >= size - 1;

    // Render no switcher if there is only one model
    if (size < 2) {
      return null;
    }

    return (
      <div block="Product3DViewer" elem="Switcher">
        <button
          block="Product3DViewer"
          elem="Button"
          disabled={isFirst}
          onClick={() =>
            this.setState({ activeModelIndex: activeModelIndex - 1 })
          }
        >
          {__("Previous")}
        </button>
        <span block="Product3DViewer" elem="ActiveIndex">
          {activeNumber}
        </span>
        <button
          block="Product3DViewer"
          elem="Button"
          disabled={isLast}
          onClick={() =>
            this.setState({ activeModelIndex: activeModelIndex + 1 })
          }
        >
          {__("Next")}
        </button>
      </div>
    );
  }

Whenever the user clicks a button that updates the state, the active model index is changed, and the component is re-rendered with the new model. When we put this all together, we get a component that can display a carousel of 3D models:

src/component/Product3DViewer/Product3DViewer.component.js
import PropTypes from "prop-types";
import { PureComponent } from "react";

import "./Product3DViewer.style";

export class Product3DViewer extends PureComponent {
  // Define the props that this component expects to receive
  // Checked at runtime in development builds
  static propTypes = {
    modelUrls: PropTypes.arrayOf(PropTypes.string),
  };

  state = {
    activeModelIndex: 0,
  };

  renderModelSwitcher() {
    const { modelUrls } = this.props;
    const { activeModelIndex } = this.state;

    const activeNumber = activeModelIndex + 1;
    const size = modelUrls.length;

    const isFirst = activeModelIndex <= 0;
    const isLast = activeModelIndex >= size - 1;

    if (size < 2) {
      return null;
    }

    return (
      <div block="Product3DViewer" elem="Switcher">
        <button
          block="Product3DViewer"
          elem="Button"
          disabled={isFirst}
          onClick={() =>
            this.setState({ activeModelIndex: activeModelIndex - 1 })
          }
        >
          {__("Previous")}
        </button>
        <span block="Product3DViewer" elem="ActiveIndex">
          {activeNumber}
        </span>
        <button
          block="Product3DViewer"
          elem="Button"
          disabled={isLast}
          onClick={() =>
            this.setState({ activeModelIndex: activeModelIndex + 1 })
          }
        >
          {__("Next")}
        </button>
      </div>
    );
  }

  render() {
    const { modelUrls } = this.props;
    const { activeModelIndex } = this.state;

    const url = modelUrls[activeModelIndex];

    return (
      <div block="Product3DViewer">
        {this.renderModelSwitcher()}
        <model-viewer
          class="Product3DViewer-Model"
          src={url}
          alt={__("Product 3D Model")}
          auto-rotate
          camera-controls
        ></model-viewer>
      </div>
    );
  }
}

export default Product3DViewer;

What Next?

Congratulations, now you have learned how to implement completely new functionality in Scandi, from the Magento backend all the way to the Scandi React frontend. We can't wait to see what you'll create with this knowledge!

We get the original fields returned by the function by calling the original function with the provided arguments (line 3). If this still seems confusing, feel free to refer to the .

Now we need to implement the main functionality – 3D model rendering. Since this is quite a complex feature, we will make use of a library to do all the heavy lifting for us. I considered implementing the viewer in , which has bindings for use in React. However, I found that there is a library that makes things even easier. The library not only displays a 3D model, but also provides mouse interaction functionality and automatic rotation out-of-the-box.

If you are new to React, this might seem a bit overwhelming. The are a great place to learn the basics of React.

3D Models used in the example above: "" by and "" by

By the way, you can find the .

Written by Reinis Mazeiks. Feel free to ask questions and share feedback in the . Thanks for reading!

Scandi theme set up
linked to your Magento instance
create a new extension
Scandi CLI script
GraphQL library
creating a plugin
plugin documentation
Three.js
model-viewer
React tutorials
National Park Binoculars - Hand Painted
Adam Tabone
Low poly McCree
Seafoam
final code created in this tutorial on Gitlab
Slack channel
3D models on the product page!!