NFT Project Series Part 9: Modifying our Angular Frontend

NFT Project Series Part 9: Modifying our Angular Frontend

Learn How to Integrate Solidity Smart Contract with Angular

In the last part of this project series, we completed building our Solidity Smart Contract for minting keyboard NFTs and tipping the creators. We also deployed our contract on the Rinkeby Test Network. In short, we completed our Web 3.0 backend. In this article, we will modify our Angular App we created in the 4th part of our series, to work with the Smart Contract.

What Will We Have at the End of This Part?

This is what we will have at the end of this article. Before we start, we need one new package installed, so run:

yarn add ethers

# or

npm install ethers

Let The Modification Begin!

There are two main things we need to change in our app:

  1. Let our app talk to smart contract deployment code on Rinkeby Test Network rather than our API Server.

  2. Enable the tip functionality.

So, let's start with our Home component in app/home/home.component.ts file:

import { Component, OnInit } from '@angular/core';

@Component({
  selector: 'app-home',
  templateUrl: './home.component.html',
  styleUrls: ['./home.component.css'],
})
export class HomeComponent implements OnInit {
  // readonly METAMASK_KEY: string = 'metamask';

  public isIdentified: boolean = false;
  public ethereum: any;
  public isConnected: boolean = false;
  public ownerAddress: string = '';
  constructor() {}

  ngOnInit() {
    if (this.checkIfMetamaskInstalled()) {
      this.isIdentified = true;

      // if (this.checkIfMetamaskConnected()) {
      //   this.connected();
      // }
      if (this.ethereum) {
        this.connectMetamask();
      }
    }
  }
  private checkIfMetamaskInstalled(): boolean {
    if (typeof (window as any).ethereum !== 'undefined') {
      this.ethereum = (window as any).ethereum;
      return true;
    }
    return false;
  }

  private checkIfMetamaskConnected(): boolean {
    if (localStorage.getItem(this.METAMASK_KEY)) {
      return true;
    }
    return false;
  }

  private storeMetamask() {
    localStorage.setItem(this.METAMASK_KEY, this.ownerAddress);
  }

  private connected() {
    this.isConnected = true;
  }

  public async connectMetamask() {
    const accounts = await (window as any).ethereum.request({
      method: 'eth_requestAccounts',
    });
    const account = accounts[0];
    this.ownerAddress = account;
    // this.storeMetamask();
    this.connected();
  }
}

Take a close look at the minimal changes we made. First we added ethereum object as a public property. Then, we disabled the functions checkIfMetamaskConnected() and storeMetamask() to remove the usage of localStorage. Finally, in ngOnInit() we added the following snippet:

if(this.ethereum) {
    this.connectMetamask()
}

This code takes care of checking if the user has connected the metamask with our app or not. Based on that, it tells them to connect or already connects them.

Next, we go inside our home.component.html file and change the <app-show-nfts> part to pass the ethereum object as well. In big app, we can global state management but here, passing it to children is fine:

<app-show-nfts
    [ethereum]="ethereum"
    [currentOwner]="ownerAddress"
  ></app-show-nfts>

This will result in error, and therefor to fix it, we now go to our show-nfts.component.ts file and change a couple of things:

import { HttpClient } from '@angular/common/http';
import { Component, Input, OnInit } from '@angular/core';
// import { Observable } from 'rxjs';
import contract from '../Keyboard.json';
import { ethers } from 'ethers';

@Component({
  selector: 'app-show-nfts',
  templateUrl: './show-nfts.component.html',
})
export class ShowNftsComponent implements OnInit {
  @Input() ethereum: any;
  @Input() currentOwner: string = '';
  // readonly METAMASK_KEY: string = 'metamask';
  readonly CONTRACT_ADDRESS: string =
    '0xD76780E312cAb4202E9F8E66a04e76CBea886D07';
  public contractABI = contract.abi;
  public nfts: any = [];

  constructor(private _httpClient: HttpClient) {}

  ngOnInit(): void {
    this.fetchNFTs();
  }

  private async fetchNFTs(): Promise<any> {
    const provider = new ethers.providers.Web3Provider(this.ethereum);
    const signer = provider.getSigner();
    const keyboardsContract = new ethers.Contract(
      this.CONTRACT_ADDRESS,
      this.contractABI,
      signer
    );

    const keyboards = await keyboardsContract['getKeyboards']();
    console.log('Retrieved keyboards...', keyboards);
    this.nfts = keyboards;
  }
}

Few things to observe here:

  1. We have removed rxjs usage to get our mints. We are using ethers library and I wanted to keep it straight forwarded here, so using JavaScript Promise itself.

  2. We are adding our CONTRACT_ADDRESS here. This is the address that we get at the end of our last article when we deploy our final smart contract. If you have lost it, run the deployment script in solidity again to get a new one.

  3. We also have an import of Keyboard.json file. This is our ABI file - Application Binary Interface File which we get when we deploy our smart contract. This is located in artifacts/contracts/Keyboard.sol/Keyboard.json. Once you run the hardhat deployment script, just search for Keyboard.json file in it.

So, at this point, we will also need to include two changes in our tsconfig.json file:

/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
  "compileOnSave": false,
  "compilerOptions": {
    "baseUrl": "./",
    "outDir": "./dist/out-tsc",
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "noImplicitOverride": true,
    "noPropertyAccessFromIndexSignature": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "sourceMap": true,
    "declaration": false,
    "downlevelIteration": true,
    "experimentalDecorators": true,
    "moduleResolution": "node",
    "importHelpers": true,
    "target": "es2017",
    "module": "es2020",
    "resolveJsonModule": true, // this is new setting
    "allowSyntheticDefaultImports": true, // this is new setting
    "lib": ["es2020", "dom"]
  },
  "angularCompilerOptions": {
    "enableI18nLegacyMessageIdFormat": false,
    "strictInjectionParameters": true,
    "strictInputAccessModifiers": true,
    "strictTemplates": true
  }
}

The new settings are mentioned in comments. This makes it possible for json file imports.

Alright, so we are now simply fetching the NFTs from blockchain directly to this app with these lines of code:

private async fetchNFTs(): Promise<any> {
    const provider = new ethers.providers.Web3Provider(this.ethereum);
    const signer = provider.getSigner();
    const keyboardsContract = new ethers.Contract(
      this.CONTRACT_ADDRESS,
      this.contractABI,
      signer
    );

    const keyboards = await keyboardsContract['getKeyboards']();
    console.log('Retrieved keyboards...', keyboards);
    this.nfts = keyboards;
  }

First, we use ethers method to get the provider and signer. Then we retrieve our deployed smart contract code object from blockchain using ethers.Contract. It requires three arguments namely contract address, contract abi, and signer. The signer here is the user who is using our app - user's wallet to be more precise.

We then call the method getKeyboards() of our smart contract to get the keyboards associated with this smart contract address and finally store it in our nfts variable.

Next, we go to our show-nfts.component.html and change the code to:

<section class="nfts">
    <div class="nft" *ngFor="let nft of nfts; let i = index">
        <app-keyboard
            [index]="i"
            [kind]="nft[0]"
            [type]="nft[1] ? ('pbt' | uppercase) : ('abs' | uppercase)"
            [filter]="nft[2]"
            [owner]="nft[3]"
            [currentOwner]="currentOwner"
            [ethereum]="ethereum"
        ></app-keyboard>
    </div>
</section>

The index is to different between the displayed keyboard and pass it to the tip function for tipping. If you console the nfts in .ts component file, you will understand what is stored in [0], [1], [2], [3] respectively. We are also passing currentOwner and ethereum in our Keyboard component as well.

So, now we go to our keyboard.component.ts file and change the existing code to:

import { Component, Input, OnInit, SimpleChanges } from '@angular/core';
import { ethers } from 'ethers';
import contract from '../Keyboard.json';

@Component({
    selector: 'app-keyboard',
    templateUrl: './keyboard.component.html',
    styleUrls: [],
})
export class KeyboardComponent implements OnInit {
    @Input() kind: number = 0;
    @Input() type: string = '';
    @Input() filter: string = '';
    @Input() owner: string = '';
    @Input() preview: boolean = false;
    @Input() currentOwner: string = '';
    @Input() ethereum: any;
    @Input() index: number | undefined;

    public isTipping: boolean = false;
    public alt: string = '';
    public imagePath: string = '';
    public style: string = '';

    readonly CONTRACT_ADDRESS: string = '0xD76780E312cAb4202E9F8E66a04e76CBea886D07';
    public contractABI = contract.abi;

    constructor() {}

    ngOnInit(): void {
        this.displayImage();
    }

    displayImage() {
        const kindDir = this.getKindDir(this.kind);
        const filename = this.type;
        this.imagePath = `assets/keyboards/${kindDir}/${filename}.png`;
        this.alt = `${kindDir} keyboard with ${filename} keys ${
            this.filter
                ? `with
${this.filter}`
                : ''
        }`;
        this.style = this.filter;
    }

    ngOnChanges(changes: SimpleChanges) {
        if (changes['kind']) {
            this.kind = changes['kind'].currentValue;
        }
        if (changes['type']) {
            this.type = changes['type'].currentValue;
        }
        if (changes['filter']) {
            this.filter = changes['filter'].currentValue;
        }
        this.displayImage();
    }

    getKindDir(kind: number) {
        return {
            0: 'sixty-percent',
            1: 'seventy-five-percent',
            2: 'eighty-percent',
            3: 'iso-105',
        }[kind];
    }

    async tip() {
        if (!this.ethereum) {
            console.error('Ethereum object is required to submit a tip');
            return;
        }
        this.isTipping = true;
        try {
            const provider = new ethers.providers.Web3Provider(this.ethereum);
            const signer = provider.getSigner();
            const keyboardsContract = new ethers.Contract(this.CONTRACT_ADDRESS, this.contractABI, signer);
            const tipTxn = await keyboardsContract['tip'](this.index, { value: ethers.utils.parseEther('0.01') });
            await tipTxn.wait();
            console.log('Sent tip!', tipTxn.hash);
        } catch (err: any) {
            console.error(err.message);
        } finally {
            this.isTipping = false;
        }
    }
}

Here, first we added three new @Input():

@Input() currentOwner: string = '';
@Input() ethereum: any;
@Input() index: number | undefined;

Then we imported our ethers and abi:

import { ethers } from 'ethers';
import contract from '../Keyboard.json';

We finally added a tip() function at the end. We get the provider, signer, and keyboard-smart-contract as usual.

const provider = new ethers.providers.Web3Provider(this.ethereum);
            const signer = provider.getSigner();
            const keyboardsContract = new ethers.Contract(this.CONTRACT_ADDRESS, this.contractABI, signer);

We then use tip function of smart-contract to generate a tip and then wait for it to finish.

const tipTxn = await keyboardsContract['tip'](this.index, { value: ethers.utils.parseEther('0.01') });
await tipTxn.wait();
console.log('Sent tip!', tipTxn.hash);

In our keyboard.component.html file, we change the code to:

<h2 *ngIf="preview">Preview</h2>
<div class="borders">
    <img [height]="230" [width]="360" [class]="filter" [src]="imagePath" [alt]="alt" />
</div>
<span *ngIf="!preview && !isTipping">
    <button *ngIf="!(owner | addressesEqual: currentOwner)" class="btn btn-tip" (click)="tip()">Tip</button>
    <button *ngIf="owner | addressesEqual: currentOwner" class="btn btn-no-tip">You own it!</button>
</span>

<span *ngIf="!preview && isTipping">
    <button *ngIf="!(owner | addressesEqual: currentOwner)" class="btn btn-tip">Tipping 0.01 ETH...</button>
</span>

The code is self-explanatory at this point.

Finally, we move to our form page: create-nft.component.ts

import { HttpClient } from '@angular/common/http';
import { Component, Input, OnInit } from '@angular/core';
import { FormBuilder, FormGroup, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import contract from '../Keyboard.json';
import { ethers } from 'ethers';

const API_URL = 'http://localhost:5000';

@Component({
    selector: 'app-create-nft',
    templateUrl: './create-nft.component.html',
    styleUrls: ['./create-nft.component.css'],
})
export class CreateNftComponent implements OnInit {
    public form: FormGroup | any;
    public success: boolean = false;
    public error: boolean = false;
    public ethereum: any;
    public isMinting: boolean = false;

    // public owner: string = <string>localStorage.getItem('metamask');
    readonly CONTRACT_ADDRESS: string = '0xD76780E312cAb4202E9F8E66a04e76CBea886D07';
    public contractABI = contract.abi;
    public nfts: any = [];

    constructor(private _fb: FormBuilder, private _httpClient: HttpClient, private _router: Router) {
        this.initializeForm();
    }

    ngOnInit(): void {
        this.checkIfMetamaskInstalled();
    }

    private initializeForm() {
        this.form = this._fb.group({
            keyboardKind: ['0', Validators.required],
            keyboardType: ['abs', Validators.required],
            keyboardFilter: ['none', Validators.required],
            // ownerAddress: [this.owner, Validators.required],
        });
    }

    private checkIfMetamaskInstalled(): boolean {
        if (typeof (window as any).ethereum !== 'undefined') {
            this.ethereum = (window as any).ethereum;
            return true;
        }
        return false;
    }

    async onSubmit() {
        // const nftObj = {
        //   kind: this.form.get('keyboardKind').value,
        //   type: this.form.get('keyboardType').value,
        //   filter: this.form.get('keyboardFilter').value,
        //   // owner: this.form.get('ownerAddress').value,
        // };
        const kind: string = this.form.get('keyboardKind').value;
        const isPbt: boolean = this.form.get('keyboardType').value === 'pbt' ? true : false;
        const filter: string = this.form.get('keyboardFilter').value;
        if (!this.ethereum) {
            console.error('Ethereum object is required to create a keyboard');
            return;
        }
        this.isMinting = true;
        const provider = new ethers.providers.Web3Provider(this.ethereum);
        const signer = provider.getSigner();
        const keyboardsContract = new ethers.Contract(this.CONTRACT_ADDRESS, this.contractABI, signer);

        try {
            console.log({ kind, isPbt, filter });
            const createTxn = await keyboardsContract['create'](kind, isPbt, filter);
            console.log('Create transaction started...', createTxn.hash);

            await createTxn.wait();
            console.log('Created keyboard!', createTxn.hash);
            this.isMinting = false;
            this._router.navigate(['']);
        } catch (err: any) {
            console.error(err.message);
        }
        // this._httpClient.post(`${API_URL}/nft`, nftObj).subscribe(
        //   (result: any) => {
        //     console.log('Result:::', result);
        //     if (result.success) {
        //       this._router.navigate(['']);
        //     }
        //   },
        //   (error: any) => {
        //     console.error('Error in Creation:::', error.error);
        //     if (!error.error.success) {
        //       alert(error.error.message);
        //     }
        //   }
        // );
    }
}

Here, we find that we disabled owner variable. We are disabling ownerAddress in the form initialisation. We are importing ethers. In the onSubmit() function, we are no longer creating nftObj variable but getting the individual variables in kind, isPbt (now a boolean value), and filter.

We then do the usual - provider, signer, and contract retrieval. And then we are calling our create function of smart contract inside the try block to mint an NFT. We wait for the mint to complete and then we route to the home page.

You will also see that we have disabled the http call to API Server.

And last, only one line is changed in the create-nft.component.html file:

<button type="submit" class="btn btn-black" *ngIf="!isMinting">Mint NFT</button>

<button type="submit" class="btn btn-black" *ngIf="isMinting">Minting new NFT...</button>

And that's it. Now just save all the code and simply run the app with:

yarn start

# or

npm start

Then go to the app and play with it to see everything works!

Bonus: Adding Event Listeners

Alright, so we can actually stop here or we can modify our contract and few code section to include the feature of monitoring events like keyboard-creation and tipping! Let's do them as well:

//SPDX-License-Identifier: Unlicense
pragma solidity ^0.8.0;

import "hardhat/console.sol";

contract Keyboard {
    enum KeyboardKind {
        SixtyPercent,
        SeventyFivePercent,
        EightyPercent,
        Iso105
    }

    struct _Keyboard {
        KeyboardKind kind;
        // ABS = false, PBT = true
        bool isPBT;
        string filter;
        address owner;
    }

    event KeyboardCreated(_Keyboard keyboard);
    event TipSent(address recipient, uint256 amount);

    _Keyboard[] public keyboards;

    function getKeyboards() public view returns (_Keyboard[] memory) {
        return keyboards;
    }

    function create(
        KeyboardKind _kind,
        bool _isPBT,
        string calldata _filter
    ) external {
        _Keyboard memory newKeyboard = _Keyboard({
            kind: _kind,
            isPBT: _isPBT,
            filter: _filter,
            owner: msg.sender
        });

        keyboards.push(newKeyboard);
        emit KeyboardCreated(newKeyboard);
    }

    function tip(uint256 _index) external payable {
        address payable owner = payable(keyboards[_index].owner);
        owner.transfer(msg.value);
        emit TipSent(owner, msg.value);
    }
}

Here, we revisit our contract to add events in our smart contract. We have two events, KeyboardCreated and TipSent. Add these modification and then redeploy the contract again. This will generate a new contract address and abi Keyboard.json file. Copy them both and replace the old ones present in our angular app with the new ones.

Then in our angular app, we go inside show-nfts.component.ts file and add the following code:

import { HttpClient } from '@angular/common/http';
import { Component, Input, OnInit } from '@angular/core';
// import { Observable } from 'rxjs';
import contract from '../Keyboard.json';
import { ethers } from 'ethers';

@Component({
    selector: 'app-show-nfts',
    templateUrl: './show-nfts.component.html',
})
export class ShowNftsComponent implements OnInit {
    @Input() ethereum: any;
    @Input() currentOwner: string = '';
    // readonly METAMASK_KEY: string = 'metamask';
    readonly CONTRACT_ADDRESS: string = '0x488295ECdFc67d1a44aF585264EF8e4EE0b0f08C';
    public contractABI = contract.abi;
    public nfts: any = [];

    constructor(private _httpClient: HttpClient) {}

    ngOnInit(): void {
        this.fetchNFTs();
        this.addKeyboardEvent();
    }

    private async addKeyboardEvent(): Promise<any> {
        const provider = new ethers.providers.Web3Provider(this.ethereum);
        const signer = provider.getSigner();
        const keyboardsContract = new ethers.Contract(this.CONTRACT_ADDRESS, this.contractABI, signer);
        keyboardsContract.on('KeyboardCreated', async (keyboard) => {
            if (this.currentOwner && !this.addressEqual(keyboard.owner, this.currentOwner)) {
                alert('Somebody created a new keyboard!');
            }
            await this.fetchNFTs();
        });

        keyboardsContract.on('TipSent', async (recipient, amount) => {
            if (this.currentOwner && this.addressEqual(recipient, this.currentOwner)) {
                alert(`You received a tip of ${ethers.utils.formatEther(amount)} eth!`);
            }
        });
    }

    private async fetchNFTs(): Promise<any> {
        const provider = new ethers.providers.Web3Provider(this.ethereum);
        const signer = provider.getSigner();
        const keyboardsContract = new ethers.Contract(this.CONTRACT_ADDRESS, this.contractABI, signer);
        const keyboards = await keyboardsContract['getKeyboards']();
        console.log('Retrieved keyboards...', keyboards);
        this.nfts = keyboards;
    }

    private addressEqual(owner: string, currentOwner: string) {
        if (!owner || !currentOwner) return false;
        return owner.toUpperCase() === currentOwner.toUpperCase();
    }
}

The main changes above are in ngOnInit():

this.addKeyboardEvent();

and then creating this new function:

private async addKeyboardEvent(): Promise<any> {
        const provider = new ethers.providers.Web3Provider(this.ethereum);
        const signer = provider.getSigner();
        const keyboardsContract = new ethers.Contract(this.CONTRACT_ADDRESS, this.contractABI, signer);
        keyboardsContract.on('KeyboardCreated', async (keyboard) => {
            if (this.currentOwner && !this.addressEqual(keyboard.owner, this.currentOwner)) {
                alert('Somebody created a new keyboard!');
            }
            await this.fetchNFTs();
        });

        keyboardsContract.on('TipSent', async (recipient, amount) => {
            if (this.currentOwner && this.addressEqual(recipient, this.currentOwner)) {
                alert(`You received a tip of ${ethers.utils.formatEther(amount)} eth!`);
            }
        });
}

private addressEqual(owner: string, currentOwner: string) {
        if (!owner || !currentOwner) return false;
        return owner.toUpperCase() === currentOwner.toUpperCase();
}

We simply add the events we defined in our contract and then show the alert. For tip, we are checking if the recipient and the current owner are same. For keyboard creation, we are checking the opposite as the one who creates it will automatically refresh it back once routing happens.

Now, try to open the app in two browsers with different wallet address and check by creating new keyboard from one. The other one will get the event and auto-refresh. The same in case of tipping.

Final Words

So, this completes our Angular Web 3.0 Minting Project. In case you want to enhance this, you can explore more of the documentation to do so. In the next article, we will take a look at how to build the same in React. If you don't want to read those, you can directly go to part 13 of the article series to know some key learning points from this project.