Back to Blog
BlockchainEthereumThe GraphReactWeb3Smart ContractsSoliditySupply Chain

Building a Blockchain-Based Product Tracking System

Build a complete farm-to-table tracking system using Ethereum, The Graph, and React with RainbowKit for supply chain transparency.

Nawab Khairuzzaman20 min read
Share:
BLOG POST

Food fraud costs the global economy over $40 billion annually. From mislabeled origins to tampered quality certificates, consumers have no reliable way to verify what they're actually buying. Traditional supply chain systems rely on centralized databases that can be manipulated, creating trust gaps between producers and consumers.

Blockchain technology offers a compelling solution: an immutable, transparent ledger that records every step of a product's journey. In this tutorial, we'll build a complete farm-to-table tracking system for mangoes—from the moment they're planted to when they reach your kitchen.

What We're Building

Our system tracks the complete mango journey through five stages:

  1. Farm - Planting date, cultivation practices, harvest information
  2. Processing - Sorting, quality grading, packaging details
  3. Shipping - Transportation routes, cold chain monitoring
  4. Retail - Store arrival, shelf placement, storage conditions
  5. Consumer - Purchase verification via QR code scanning

Tech Stack

  • Ethereum (Sepolia Testnet) - Smart contract for immutable record-keeping
  • The Graph - Indexing protocol for fast, efficient queries
  • React + Vite - Modern frontend framework
  • RainbowKit + wagmi - Wallet connection and contract interaction
  • Apollo Client - GraphQL queries to The Graph

System Architecture

plaintext
┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   Farmer    │────▶│  Processor  │────▶│  Shipper    │────▶│  Retailer   │
└─────────────┘     └─────────────┘     └─────────────┘     └─────────────┘
       │                   │                   │                   │
       │    Register       │    Update         │    Transfer       │    Update
       │    Product        │    Status         │    Ownership      │    Status
       ▼                   ▼                   ▼                   ▼
┌─────────────────────────────────────────────────────────────────────────────┐
│                      Ethereum Smart Contract                                │
│                   (MangoTracking.sol - Sepolia)                             │
│  • Stores product data on-chain                                             │
│  • Emits events for every state change                                      │
│  • Role-based access control                                                │
└─────────────────────────────────────────────────────────────────────────────┘

                                    │ Events

┌─────────────────────────────────────────────────────────────────────────────┐
│                         The Graph Subgraph                                  │
│  • Indexes ProductRegistered, StatusUpdated, OwnershipTransferred events    │
│  • Provides GraphQL API for efficient queries                               │
│  • Enables complex filtering and relationships                              │
└─────────────────────────────────────────────────────────────────────────────┘

                                    │ GraphQL

┌─────────────────────────────────────────────────────────────────────────────┐
│                    React Frontend + RainbowKit                              │
│  • Wallet connection (MetaMask, WalletConnect, etc.)                        │
│  • Product registration forms                                               │
│  • Timeline visualization                                                   │
│  • QR code scanning for consumers                                           │
└─────────────────────────────────────────────────────────────────────────────┘

Data Flow

  1. Farmer connects wallet and registers a new mango batch with origin, variety, and harvest details
  2. Smart contract stores the data and emits a ProductRegistered event
  3. The Graph indexes the event and makes it queryable via GraphQL
  4. Processor updates status to "Processed" with quality grade and packaging info
  5. Shipper takes ownership and adds transportation checkpoints
  6. Retailer marks arrival and shelf placement
  7. Consumer scans QR code to view complete history

Part 1: Smart Contract Development

Let's start with the Solidity smart contract that forms the backbone of our tracking system.

Contract Structure

solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
 
import "@openzeppelin/contracts/access/AccessControl.sol";
 
contract MangoTracking is AccessControl {
    // Role definitions
    bytes32 public constant FARMER_ROLE = keccak256("FARMER_ROLE");
    bytes32 public constant PROCESSOR_ROLE = keccak256("PROCESSOR_ROLE");
    bytes32 public constant SHIPPER_ROLE = keccak256("SHIPPER_ROLE");
    bytes32 public constant RETAILER_ROLE = keccak256("RETAILER_ROLE");
 
    // Product status enum
    enum Status {
        Planted,
        Harvested,
        Processed,
        Shipped,
        InStore,
        Sold
    }
 
    // Product data structure
    struct Product {
        uint256 id;
        string batchId;
        string origin;
        string variety;
        uint256 harvestDate;
        address currentOwner;
        Status status;
        uint256 registeredAt;
    }
 
    // History entry for tracking checkpoints
    struct HistoryEntry {
        uint256 timestamp;
        address actor;
        string action;
        string location;
        string details;
        Status newStatus;
    }
 
    // State variables
    uint256 private _productCounter;
    mapping(uint256 => Product) public products;
    mapping(uint256 => HistoryEntry[]) public productHistory;
    mapping(string => uint256) public batchIdToProductId;
 
    // Events
    event ProductRegistered(
        uint256 indexed productId,
        string batchId,
        string origin,
        address indexed farmer
    );
 
    event StatusUpdated(
        uint256 indexed productId,
        Status oldStatus,
        Status newStatus,
        address indexed actor,
        string details
    );
 
    event OwnershipTransferred(
        uint256 indexed productId,
        address indexed previousOwner,
        address indexed newOwner
    );
 
    event HistoryEntryAdded(
        uint256 indexed productId,
        address indexed actor,
        string action,
        string location
    );
 
    constructor() {
        _grantRole(DEFAULT_ADMIN_ROLE, msg.sender);
    }
 
    // Register a new product batch (Farmers only)
    function registerProduct(
        string calldata batchId,
        string calldata origin,
        string calldata variety,
        uint256 harvestDate
    ) external onlyRole(FARMER_ROLE) returns (uint256) {
        require(bytes(batchId).length > 0, "Batch ID required");
        require(batchIdToProductId[batchId] == 0, "Batch already exists");
 
        _productCounter++;
        uint256 productId = _productCounter;
 
        products[productId] = Product({
            id: productId,
            batchId: batchId,
            origin: origin,
            variety: variety,
            harvestDate: harvestDate,
            currentOwner: msg.sender,
            status: Status.Harvested,
            registeredAt: block.timestamp
        });
 
        batchIdToProductId[batchId] = productId;
 
        // Add initial history entry
        productHistory[productId].push(HistoryEntry({
            timestamp: block.timestamp,
            actor: msg.sender,
            action: "Product Registered",
            location: origin,
            details: string(abi.encodePacked("Variety: ", variety)),
            newStatus: Status.Harvested
        }));
 
        emit ProductRegistered(productId, batchId, origin, msg.sender);
 
        return productId;
    }
 
    // Update product status
    function updateStatus(
        uint256 productId,
        Status newStatus,
        string calldata location,
        string calldata details
    ) external {
        Product storage product = products[productId];
        require(product.id != 0, "Product not found");
        require(
            product.currentOwner == msg.sender || hasRole(DEFAULT_ADMIN_ROLE, msg.sender),
            "Not authorized"
        );
        require(uint8(newStatus) > uint8(product.status), "Invalid status transition");
 
        Status oldStatus = product.status;
        product.status = newStatus;
 
        productHistory[productId].push(HistoryEntry({
            timestamp: block.timestamp,
            actor: msg.sender,
            action: _statusToString(newStatus),
            location: location,
            details: details,
            newStatus: newStatus
        }));
 
        emit StatusUpdated(productId, oldStatus, newStatus, msg.sender, details);
        emit HistoryEntryAdded(productId, msg.sender, _statusToString(newStatus), location);
    }
 
    // Transfer ownership to next party in supply chain
    function transferOwnership(
        uint256 productId,
        address newOwner,
        string calldata location,
        string calldata details
    ) external {
        Product storage product = products[productId];
        require(product.id != 0, "Product not found");
        require(product.currentOwner == msg.sender, "Not the owner");
        require(newOwner != address(0), "Invalid new owner");
 
        address previousOwner = product.currentOwner;
        product.currentOwner = newOwner;
 
        productHistory[productId].push(HistoryEntry({
            timestamp: block.timestamp,
            actor: msg.sender,
            action: "Ownership Transferred",
            location: location,
            details: details,
            newStatus: product.status
        }));
 
        emit OwnershipTransferred(productId, previousOwner, newOwner);
        emit HistoryEntryAdded(productId, msg.sender, "Ownership Transferred", location);
    }
 
    // Add a checkpoint entry without changing status
    function addCheckpoint(
        uint256 productId,
        string calldata action,
        string calldata location,
        string calldata details
    ) external {
        Product storage product = products[productId];
        require(product.id != 0, "Product not found");
        require(product.currentOwner == msg.sender, "Not authorized");
 
        productHistory[productId].push(HistoryEntry({
            timestamp: block.timestamp,
            actor: msg.sender,
            action: action,
            location: location,
            details: details,
            newStatus: product.status
        }));
 
        emit HistoryEntryAdded(productId, msg.sender, action, location);
    }
 
    // View functions
    function getProduct(uint256 productId) external view returns (Product memory) {
        require(products[productId].id != 0, "Product not found");
        return products[productId];
    }
 
    function getProductByBatchId(string calldata batchId) external view returns (Product memory) {
        uint256 productId = batchIdToProductId[batchId];
        require(productId != 0, "Product not found");
        return products[productId];
    }
 
    function getHistory(uint256 productId) external view returns (HistoryEntry[] memory) {
        return productHistory[productId];
    }
 
    function getHistoryLength(uint256 productId) external view returns (uint256) {
        return productHistory[productId].length;
    }
 
    // Helper function to convert status to string
    function _statusToString(Status status) internal pure returns (string memory) {
        if (status == Status.Planted) return "Planted";
        if (status == Status.Harvested) return "Harvested";
        if (status == Status.Processed) return "Processed";
        if (status == Status.Shipped) return "Shipped";
        if (status == Status.InStore) return "In Store";
        if (status == Status.Sold) return "Sold";
        return "Unknown";
    }
 
    // Admin functions for role management
    function grantFarmerRole(address account) external onlyRole(DEFAULT_ADMIN_ROLE) {
        grantRole(FARMER_ROLE, account);
    }
 
    function grantProcessorRole(address account) external onlyRole(DEFAULT_ADMIN_ROLE) {
        grantRole(PROCESSOR_ROLE, account);
    }
 
    function grantShipperRole(address account) external onlyRole(DEFAULT_ADMIN_ROLE) {
        grantRole(SHIPPER_ROLE, account);
    }
 
    function grantRetailerRole(address account) external onlyRole(DEFAULT_ADMIN_ROLE) {
        grantRole(RETAILER_ROLE, account);
    }
}

Deploying the Contract

Create a deployment script using Hardhat:

typescript
// scripts/deploy.ts
import { ethers } from "hardhat";
 
async function main() {
  const MangoTracking = await ethers.getContractFactory("MangoTracking");
  const contract = await MangoTracking.deploy();
  await contract.waitForDeployment();
 
  const address = await contract.getAddress();
  console.log("MangoTracking deployed to:", address);
 
  // Grant roles to test accounts
  const [deployer, farmer, processor, shipper, retailer] = await ethers.getSigners();
 
  await contract.grantFarmerRole(farmer.address);
  await contract.grantProcessorRole(processor.address);
  await contract.grantShipperRole(shipper.address);
  await contract.grantRetailerRole(retailer.address);
 
  console.log("Roles granted successfully");
}
 
main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Deploy to Sepolia:

bash
npx hardhat run scripts/deploy.ts --network sepolia

Part 2: The Graph Subgraph

The Graph indexes blockchain events and provides a GraphQL API for efficient querying. This is essential because querying the blockchain directly for historical data is slow and expensive.

Schema Definition

Create schema.graphql:

graphql
type Product @entity {
  id: ID!
  productId: BigInt!
  batchId: String!
  origin: String!
  variety: String!
  harvestDate: BigInt!
  currentOwner: Bytes!
  status: String!
  registeredAt: BigInt!
  registeredBy: Bytes!
  history: [HistoryEntry!]! @derivedFrom(field: "product")
}
 
type HistoryEntry @entity {
  id: ID!
  product: Product!
  timestamp: BigInt!
  actor: Bytes!
  action: String!
  location: String!
  details: String!
  status: String!
  transactionHash: Bytes!
  blockNumber: BigInt!
}
 
type Actor @entity {
  id: ID!
  address: Bytes!
  productsOwned: [Product!]!
  actionsPerformed: BigInt!
  firstActionAt: BigInt!
  lastActionAt: BigInt!
}
 
type DailyStats @entity {
  id: ID!
  date: String!
  productsRegistered: BigInt!
  statusUpdates: BigInt!
  ownershipTransfers: BigInt!
}

Subgraph Manifest

Create subgraph.yaml:

yaml
specVersion: 0.0.5
schema:
  file: ./schema.graphql
dataSources:
  - kind: ethereum
    name: MangoTracking
    network: sepolia
    source:
      address: "YOUR_CONTRACT_ADDRESS"
      abi: MangoTracking
      startBlock: 12345678
    mapping:
      kind: ethereum/events
      apiVersion: 0.0.7
      language: wasm/assemblyscript
      entities:
        - Product
        - HistoryEntry
        - Actor
        - DailyStats
      abis:
        - name: MangoTracking
          file: ./abis/MangoTracking.json
      eventHandlers:
        - event: ProductRegistered(indexed uint256,string,string,indexed address)
          handler: handleProductRegistered
        - event: StatusUpdated(indexed uint256,uint8,uint8,indexed address,string)
          handler: handleStatusUpdated
        - event: OwnershipTransferred(indexed uint256,indexed address,indexed address)
          handler: handleOwnershipTransferred
        - event: HistoryEntryAdded(indexed uint256,indexed address,string,string)
          handler: handleHistoryEntryAdded
      file: ./src/mapping.ts

Event Handlers

Create src/mapping.ts:

typescript
import { BigInt, Bytes } from "@graphprotocol/graph-ts";
import {
  ProductRegistered,
  StatusUpdated,
  OwnershipTransferred,
  HistoryEntryAdded,
} from "../generated/MangoTracking/MangoTracking";
import { Product, HistoryEntry, Actor, DailyStats } from "../generated/schema";
 
const STATUS_MAP = [
  "Planted",
  "Harvested",
  "Processed",
  "Shipped",
  "InStore",
  "Sold",
];
 
function getOrCreateActor(address: Bytes, timestamp: BigInt): Actor {
  let actor = Actor.load(address.toHexString());
  if (!actor) {
    actor = new Actor(address.toHexString());
    actor.address = address;
    actor.actionsPerformed = BigInt.fromI32(0);
    actor.firstActionAt = timestamp;
    actor.productsOwned = [];
  }
  actor.lastActionAt = timestamp;
  actor.actionsPerformed = actor.actionsPerformed.plus(BigInt.fromI32(1));
  return actor;
}
 
function getOrCreateDailyStats(timestamp: BigInt): DailyStats {
  let date = (timestamp.toI64() / 86400).toString();
  let stats = DailyStats.load(date);
  if (!stats) {
    stats = new DailyStats(date);
    stats.date = date;
    stats.productsRegistered = BigInt.fromI32(0);
    stats.statusUpdates = BigInt.fromI32(0);
    stats.ownershipTransfers = BigInt.fromI32(0);
  }
  return stats;
}
 
export function handleProductRegistered(event: ProductRegistered): void {
  let product = new Product(event.params.productId.toString());
  product.productId = event.params.productId;
  product.batchId = event.params.batchId;
  product.origin = event.params.origin;
  product.variety = "";
  product.harvestDate = BigInt.fromI32(0);
  product.currentOwner = event.params.farmer;
  product.status = "Harvested";
  product.registeredAt = event.block.timestamp;
  product.registeredBy = event.params.farmer;
  product.save();
 
  // Update actor
  let actor = getOrCreateActor(event.params.farmer, event.block.timestamp);
  let owned = actor.productsOwned;
  owned.push(product.id);
  actor.productsOwned = owned;
  actor.save();
 
  // Update daily stats
  let stats = getOrCreateDailyStats(event.block.timestamp);
  stats.productsRegistered = stats.productsRegistered.plus(BigInt.fromI32(1));
  stats.save();
 
  // Create initial history entry
  let historyId =
    event.transaction.hash.toHexString() + "-" + event.logIndex.toString();
  let history = new HistoryEntry(historyId);
  history.product = product.id;
  history.timestamp = event.block.timestamp;
  history.actor = event.params.farmer;
  history.action = "Product Registered";
  history.location = event.params.origin;
  history.details = "Initial registration";
  history.status = "Harvested";
  history.transactionHash = event.transaction.hash;
  history.blockNumber = event.block.number;
  history.save();
}
 
export function handleStatusUpdated(event: StatusUpdated): void {
  let product = Product.load(event.params.productId.toString());
  if (!product) return;
 
  let newStatus = STATUS_MAP[event.params.newStatus];
  product.status = newStatus;
  product.save();
 
  // Update actor
  let actor = getOrCreateActor(event.params.actor, event.block.timestamp);
  actor.save();
 
  // Update daily stats
  let stats = getOrCreateDailyStats(event.block.timestamp);
  stats.statusUpdates = stats.statusUpdates.plus(BigInt.fromI32(1));
  stats.save();
}
 
export function handleOwnershipTransferred(event: OwnershipTransferred): void {
  let product = Product.load(event.params.productId.toString());
  if (!product) return;
 
  product.currentOwner = event.params.newOwner;
  product.save();
 
  // Update new owner actor
  let newOwner = getOrCreateActor(event.params.newOwner, event.block.timestamp);
  let owned = newOwner.productsOwned;
  owned.push(product.id);
  newOwner.productsOwned = owned;
  newOwner.save();
 
  // Update daily stats
  let stats = getOrCreateDailyStats(event.block.timestamp);
  stats.ownershipTransfers = stats.ownershipTransfers.plus(BigInt.fromI32(1));
  stats.save();
}
 
export function handleHistoryEntryAdded(event: HistoryEntryAdded): void {
  let product = Product.load(event.params.productId.toString());
  if (!product) return;
 
  let historyId =
    event.transaction.hash.toHexString() + "-" + event.logIndex.toString();
  let history = new HistoryEntry(historyId);
  history.product = product.id;
  history.timestamp = event.block.timestamp;
  history.actor = event.params.actor;
  history.action = event.params.action;
  history.location = event.params.location;
  history.details = "";
  history.status = product.status;
  history.transactionHash = event.transaction.hash;
  history.blockNumber = event.block.number;
  history.save();
 
  // Update actor
  let actor = getOrCreateActor(event.params.actor, event.block.timestamp);
  actor.save();
}

Deploy to The Graph Studio

bash
# Install Graph CLI
npm install -g @graphprotocol/graph-cli
 
# Initialize subgraph
graph init --studio mango-tracking
 
# Authenticate
graph auth --studio YOUR_DEPLOY_KEY
 
# Generate types
graph codegen
 
# Build
graph build
 
# Deploy
graph deploy --studio mango-tracking

Sample GraphQL Queries

Once deployed, you can query your subgraph:

graphql
# Get product with full history
query GetProduct($id: ID!) {
  product(id: $id) {
    id
    batchId
    origin
    variety
    status
    currentOwner
    registeredAt
    history(orderBy: timestamp, orderDirection: asc) {
      timestamp
      actor
      action
      location
      details
      status
    }
  }
}
 
# Get all products by status
query GetProductsByStatus($status: String!) {
  products(where: { status: $status }, orderBy: registeredAt, orderDirection: desc) {
    id
    batchId
    origin
    currentOwner
    registeredAt
  }
}
 
# Get products by origin
query GetProductsByOrigin($origin: String!) {
  products(where: { origin_contains_nocase: $origin }) {
    id
    batchId
    status
    currentOwner
  }
}
 
# Get actor's products
query GetActorProducts($address: Bytes!) {
  actor(id: $address) {
    productsOwned {
      id
      batchId
      status
    }
    actionsPerformed
    firstActionAt
    lastActionAt
  }
}

Part 3: React Frontend with RainbowKit

Now let's build the user interface that ties everything together.

Project Setup

bash
npm create vite@latest mango-tracker -- --template react-ts
cd mango-tracker
npm install wagmi [email protected] @tanstack/react-query @rainbow-me/rainbowkit
npm install @apollo/client graphql
npm install react-qr-code html5-qrcode

Configure wagmi and RainbowKit

Create src/config/wagmi.ts:

typescript
import { getDefaultConfig } from "@rainbow-me/rainbowkit";
import { sepolia } from "wagmi/chains";
 
export const config = getDefaultConfig({
  appName: "Mango Tracker",
  projectId: "YOUR_WALLETCONNECT_PROJECT_ID",
  chains: [sepolia],
  ssr: false,
});
 
export const MANGO_TRACKING_ADDRESS = "YOUR_CONTRACT_ADDRESS" as const;
 
export const MANGO_TRACKING_ABI = [
  {
    inputs: [
      { name: "batchId", type: "string" },
      { name: "origin", type: "string" },
      { name: "variety", type: "string" },
      { name: "harvestDate", type: "uint256" },
    ],
    name: "registerProduct",
    outputs: [{ name: "", type: "uint256" }],
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    inputs: [
      { name: "productId", type: "uint256" },
      { name: "newStatus", type: "uint8" },
      { name: "location", type: "string" },
      { name: "details", type: "string" },
    ],
    name: "updateStatus",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    inputs: [
      { name: "productId", type: "uint256" },
      { name: "newOwner", type: "address" },
      { name: "location", type: "string" },
      { name: "details", type: "string" },
    ],
    name: "transferOwnership",
    outputs: [],
    stateMutability: "nonpayable",
    type: "function",
  },
  {
    inputs: [{ name: "productId", type: "uint256" }],
    name: "getProduct",
    outputs: [
      {
        components: [
          { name: "id", type: "uint256" },
          { name: "batchId", type: "string" },
          { name: "origin", type: "string" },
          { name: "variety", type: "string" },
          { name: "harvestDate", type: "uint256" },
          { name: "currentOwner", type: "address" },
          { name: "status", type: "uint8" },
          { name: "registeredAt", type: "uint256" },
        ],
        name: "",
        type: "tuple",
      },
    ],
    stateMutability: "view",
    type: "function",
  },
  {
    inputs: [{ name: "productId", type: "uint256" }],
    name: "getHistory",
    outputs: [
      {
        components: [
          { name: "timestamp", type: "uint256" },
          { name: "actor", type: "address" },
          { name: "action", type: "string" },
          { name: "location", type: "string" },
          { name: "details", type: "string" },
          { name: "newStatus", type: "uint8" },
        ],
        name: "",
        type: "tuple[]",
      },
    ],
    stateMutability: "view",
    type: "function",
  },
] as const;

Apollo Client Setup

Create src/config/apollo.ts:

typescript
import { ApolloClient, InMemoryCache } from "@apollo/client";
 
export const apolloClient = new ApolloClient({
  uri: "https://api.studio.thegraph.com/query/YOUR_SUBGRAPH_ID/mango-tracking/version/latest",
  cache: new InMemoryCache(),
});

Main App Component

Update src/App.tsx:

tsx
import "@rainbow-me/rainbowkit/styles.css";
import { RainbowKitProvider } from "@rainbow-me/rainbowkit";
import { WagmiProvider } from "wagmi";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApolloProvider } from "@apollo/client";
import { config } from "./config/wagmi";
import { apolloClient } from "./config/apollo";
import { Header } from "./components/Header";
import { ProductRegistration } from "./components/ProductRegistration";
import { ProductLookup } from "./components/ProductLookup";
import { ProductTimeline } from "./components/ProductTimeline";
import { useState } from "react";
 
const queryClient = new QueryClient();
 
function App() {
  const [selectedProductId, setSelectedProductId] = useState<string | null>(null);
 
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>
        <RainbowKitProvider>
          <ApolloProvider client={apolloClient}>
            <div className="min-h-screen bg-gray-50">
              <Header />
              <main className="container mx-auto px-4 py-8">
                <div className="grid md:grid-cols-2 gap-8">
                  <ProductRegistration />
                  <ProductLookup onProductSelect={setSelectedProductId} />
                </div>
                {selectedProductId && (
                  <ProductTimeline productId={selectedProductId} />
                )}
              </main>
            </div>
          </ApolloProvider>
        </RainbowKitProvider>
      </QueryClientProvider>
    </WagmiProvider>
  );
}
 
export default App;

Header Component

Create src/components/Header.tsx:

tsx
import { ConnectButton } from "@rainbow-me/rainbowkit";
 
export function Header() {
  return (
    <header className="bg-white shadow-sm">
      <div className="container mx-auto px-4 py-4 flex justify-between items-center">
        <div className="flex items-center gap-2">
          <span className="text-2xl">🥭</span>
          <h1 className="text-xl font-bold text-gray-900">Mango Tracker</h1>
        </div>
        <ConnectButton />
      </div>
    </header>
  );
}

Product Registration Form

Create src/components/ProductRegistration.tsx:

tsx
import { useState } from "react";
import { useWriteContract, useWaitForTransactionReceipt } from "wagmi";
import { MANGO_TRACKING_ADDRESS, MANGO_TRACKING_ABI } from "../config/wagmi";
 
export function ProductRegistration() {
  const [batchId, setBatchId] = useState("");
  const [origin, setOrigin] = useState("");
  const [variety, setVariety] = useState("");
  const [harvestDate, setHarvestDate] = useState("");
 
  const { writeContract, data: hash, isPending, error } = useWriteContract();
 
  const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({
    hash,
  });
 
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
 
    const harvestTimestamp = Math.floor(new Date(harvestDate).getTime() / 1000);
 
    writeContract({
      address: MANGO_TRACKING_ADDRESS,
      abi: MANGO_TRACKING_ABI,
      functionName: "registerProduct",
      args: [batchId, origin, variety, BigInt(harvestTimestamp)],
    });
  };
 
  return (
    <div className="bg-white rounded-lg shadow-md p-6">
      <h2 className="text-lg font-semibold mb-4">Register New Product</h2>
      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label className="block text-sm font-medium text-gray-700">
            Batch ID
          </label>
          <input
            type="text"
            value={batchId}
            onChange={(e) => setBatchId(e.target.value)}
            placeholder="MANGO-2024-001"
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500"
            required
          />
        </div>
 
        <div>
          <label className="block text-sm font-medium text-gray-700">
            Origin (Farm Location)
          </label>
          <input
            type="text"
            value={origin}
            onChange={(e) => setOrigin(e.target.value)}
            placeholder="Rajshahi, Bangladesh"
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500"
            required
          />
        </div>
 
        <div>
          <label className="block text-sm font-medium text-gray-700">
            Variety
          </label>
          <select
            value={variety}
            onChange={(e) => setVariety(e.target.value)}
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500"
            required
          >
            <option value="">Select variety</option>
            <option value="Himsagar">Himsagar</option>
            <option value="Langra">Langra</option>
            <option value="Fazli">Fazli</option>
            <option value="Alphonso">Alphonso</option>
            <option value="Kesar">Kesar</option>
          </select>
        </div>
 
        <div>
          <label className="block text-sm font-medium text-gray-700">
            Harvest Date
          </label>
          <input
            type="date"
            value={harvestDate}
            onChange={(e) => setHarvestDate(e.target.value)}
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500"
            required
          />
        </div>
 
        <button
          type="submit"
          disabled={isPending || isConfirming}
          className="w-full bg-green-600 text-white py-2 px-4 rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
        >
          {isPending
            ? "Confirming..."
            : isConfirming
            ? "Processing..."
            : "Register Product"}
        </button>
 
        {error && (
          <p className="text-red-500 text-sm">Error: {error.message}</p>
        )}
 
        {isSuccess && (
          <div className="bg-green-50 border border-green-200 rounded-md p-3">
            <p className="text-green-700 text-sm">
              Product registered successfully!
            </p>
            <a
              href={`https://sepolia.etherscan.io/tx/${hash}`}
              target="_blank"
              rel="noopener noreferrer"
              className="text-green-600 text-xs underline"
            >
              View on Etherscan
            </a>
          </div>
        )}
      </form>
    </div>
  );
}

Product Lookup Component

Create src/components/ProductLookup.tsx:

tsx
import { useState } from "react";
import { useQuery, gql } from "@apollo/client";
import QRCode from "react-qr-code";
 
const SEARCH_PRODUCTS = gql`
  query SearchProducts($batchId: String!) {
    products(where: { batchId_contains_nocase: $batchId }, first: 10) {
      id
      batchId
      origin
      variety
      status
      currentOwner
      registeredAt
    }
  }
`;
 
interface ProductLookupProps {
  onProductSelect: (id: string) => void;
}
 
export function ProductLookup({ onProductSelect }: ProductLookupProps) {
  const [searchTerm, setSearchTerm] = useState("");
  const [selectedProduct, setSelectedProduct] = useState<string | null>(null);
 
  const { data, loading } = useQuery(SEARCH_PRODUCTS, {
    variables: { batchId: searchTerm },
    skip: searchTerm.length < 2,
  });
 
  const handleSelect = (product: { id: string; batchId: string }) => {
    setSelectedProduct(product.id);
    onProductSelect(product.id);
  };
 
  const statusColors: Record<string, string> = {
    Harvested: "bg-yellow-100 text-yellow-800",
    Processed: "bg-blue-100 text-blue-800",
    Shipped: "bg-purple-100 text-purple-800",
    InStore: "bg-green-100 text-green-800",
    Sold: "bg-gray-100 text-gray-800",
  };
 
  return (
    <div className="bg-white rounded-lg shadow-md p-6">
      <h2 className="text-lg font-semibold mb-4">Product Lookup</h2>
 
      <div className="mb-4">
        <input
          type="text"
          value={searchTerm}
          onChange={(e) => setSearchTerm(e.target.value)}
          placeholder="Search by Batch ID..."
          className="block w-full rounded-md border-gray-300 shadow-sm focus:border-green-500 focus:ring-green-500"
        />
      </div>
 
      {loading && <p className="text-gray-500 text-sm">Searching...</p>}
 
      {data?.products?.length > 0 && (
        <div className="space-y-2 mb-4">
          {data.products.map((product: {
            id: string;
            batchId: string;
            origin: string;
            status: string;
          }) => (
            <button
              key={product.id}
              onClick={() => handleSelect(product)}
              className={`w-full text-left p-3 rounded-md border transition-colors ${
                selectedProduct === product.id
                  ? "border-green-500 bg-green-50"
                  : "border-gray-200 hover:border-gray-300"
              }`}
            >
              <div className="flex justify-between items-center">
                <span className="font-medium">{product.batchId}</span>
                <span
                  className={`px-2 py-1 rounded-full text-xs ${
                    statusColors[product.status] || "bg-gray-100"
                  }`}
                >
                  {product.status}
                </span>
              </div>
              <p className="text-sm text-gray-500">{product.origin}</p>
            </button>
          ))}
        </div>
      )}
 
      {selectedProduct && (
        <div className="border-t pt-4">
          <h3 className="text-sm font-medium mb-2">QR Code for Verification</h3>
          <div className="bg-white p-4 inline-block rounded-lg">
            <QRCode
              value={`${window.location.origin}/verify/${selectedProduct}`}
              size={150}
            />
          </div>
          <p className="text-xs text-gray-500 mt-2">
            Scan to verify product authenticity
          </p>
        </div>
      )}
    </div>
  );
}

Product Timeline Component

Create src/components/ProductTimeline.tsx:

tsx
import { useQuery, gql } from "@apollo/client";
 
const GET_PRODUCT_HISTORY = gql`
  query GetProductHistory($id: ID!) {
    product(id: $id) {
      id
      batchId
      origin
      variety
      status
      currentOwner
      registeredAt
      history(orderBy: timestamp, orderDirection: asc) {
        id
        timestamp
        actor
        action
        location
        details
        status
        transactionHash
      }
    }
  }
`;
 
interface ProductTimelineProps {
  productId: string;
}
 
export function ProductTimeline({ productId }: ProductTimelineProps) {
  const { data, loading, error } = useQuery(GET_PRODUCT_HISTORY, {
    variables: { id: productId },
  });
 
  if (loading) {
    return (
      <div className="mt-8 bg-white rounded-lg shadow-md p-6">
        <div className="animate-pulse space-y-4">
          <div className="h-4 bg-gray-200 rounded w-1/4"></div>
          <div className="h-20 bg-gray-200 rounded"></div>
        </div>
      </div>
    );
  }
 
  if (error || !data?.product) {
    return (
      <div className="mt-8 bg-white rounded-lg shadow-md p-6">
        <p className="text-red-500">Failed to load product history</p>
      </div>
    );
  }
 
  const { product } = data;
 
  const statusIcons: Record<string, string> = {
    Harvested: "🌱",
    Processed: "📦",
    Shipped: "🚚",
    InStore: "🏪",
    Sold: "✅",
  };
 
  return (
    <div className="mt-8 bg-white rounded-lg shadow-md p-6">
      <div className="mb-6">
        <h2 className="text-lg font-semibold">Product Journey</h2>
        <div className="mt-2 flex gap-4 text-sm text-gray-600">
          <span>
            <strong>Batch:</strong> {product.batchId}
          </span>
          <span>
            <strong>Origin:</strong> {product.origin}
          </span>
          <span>
            <strong>Variety:</strong> {product.variety}
          </span>
        </div>
      </div>
 
      <div className="relative">
        {/* Timeline line */}
        <div className="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200"></div>
 
        {/* Timeline entries */}
        <div className="space-y-6">
          {product.history.map((entry: {
            id: string;
            timestamp: string;
            action: string;
            location: string;
            details: string;
            status: string;
            actor: string;
            transactionHash: string;
          }, index: number) => (
            <div key={entry.id} className="relative pl-10">
              {/* Timeline dot */}
              <div
                className={`absolute left-2 w-5 h-5 rounded-full flex items-center justify-center text-xs ${
                  index === product.history.length - 1
                    ? "bg-green-500 ring-4 ring-green-100"
                    : "bg-gray-300"
                }`}
              >
                {statusIcons[entry.status] || "•"}
              </div>
 
              <div className="bg-gray-50 rounded-lg p-4">
                <div className="flex justify-between items-start">
                  <div>
                    <h3 className="font-medium text-gray-900">{entry.action}</h3>
                    <p className="text-sm text-gray-500">{entry.location}</p>
                  </div>
                  <time className="text-xs text-gray-400">
                    {new Date(parseInt(entry.timestamp) * 1000).toLocaleString()}
                  </time>
                </div>
 
                {entry.details && (
                  <p className="mt-2 text-sm text-gray-600">{entry.details}</p>
                )}
 
                <div className="mt-2 flex gap-2 text-xs">
                  <span className="text-gray-400">
                    Actor: {entry.actor.slice(0, 6)}...{entry.actor.slice(-4)}
                  </span>
                  <a
                    href={`https://sepolia.etherscan.io/tx/${entry.transactionHash}`}
                    target="_blank"
                    rel="noopener noreferrer"
                    className="text-green-600 hover:underline"
                  >
                    View TX
                  </a>
                </div>
              </div>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

QR Scanner Component

Create src/components/QRScanner.tsx:

tsx
import { useEffect, useRef, useState } from "react";
import { Html5QrcodeScanner } from "html5-qrcode";
 
interface QRScannerProps {
  onScan: (productId: string) => void;
}
 
export function QRScanner({ onScan }: QRScannerProps) {
  const [isScanning, setIsScanning] = useState(false);
  const scannerRef = useRef<Html5QrcodeScanner | null>(null);
 
  useEffect(() => {
    if (isScanning && !scannerRef.current) {
      scannerRef.current = new Html5QrcodeScanner(
        "qr-reader",
        { fps: 10, qrbox: { width: 250, height: 250 } },
        false
      );
 
      scannerRef.current.render(
        (decodedText) => {
          // Extract product ID from URL
          const match = decodedText.match(/\/verify\/(\d+)/);
          if (match) {
            onScan(match[1]);
            scannerRef.current?.clear();
            setIsScanning(false);
          }
        },
        (error) => {
          console.log(error);
        }
      );
    }
 
    return () => {
      if (scannerRef.current) {
        scannerRef.current.clear();
        scannerRef.current = null;
      }
    };
  }, [isScanning, onScan]);
 
  return (
    <div className="bg-white rounded-lg shadow-md p-6">
      <h2 className="text-lg font-semibold mb-4">Scan Product QR Code</h2>
 
      {!isScanning ? (
        <button
          onClick={() => setIsScanning(true)}
          className="w-full bg-green-600 text-white py-3 px-4 rounded-md hover:bg-green-700"
        >
          Start Scanner
        </button>
      ) : (
        <div>
          <div id="qr-reader" className="mb-4"></div>
          <button
            onClick={() => {
              scannerRef.current?.clear();
              setIsScanning(false);
            }}
            className="w-full bg-gray-200 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-300"
          >
            Cancel
          </button>
        </div>
      )}
    </div>
  );
}

Part 4: Deployment

Deploy Smart Contract to Sepolia

bash
# Install dependencies
npm install --save-dev hardhat @nomicfoundation/hardhat-toolbox
 
# Configure hardhat.config.ts
# Add Sepolia network with your RPC URL and private key
 
# Deploy
npx hardhat run scripts/deploy.ts --network sepolia

Deploy Subgraph

bash
# Update subgraph.yaml with deployed contract address
# Build and deploy
graph codegen
graph build
graph deploy --studio mango-tracking

Deploy Frontend

For Vercel:

bash
npm run build
vercel deploy

For Netlify:

bash
npm run build
netlify deploy --prod --dir=dist

Environment Variables

Create .env for your frontend:

plaintext
VITE_WALLETCONNECT_PROJECT_ID=your_project_id
VITE_CONTRACT_ADDRESS=0x...
VITE_SUBGRAPH_URL=https://api.studio.thegraph.com/query/.../mango-tracking/version/latest

Testing the Complete Flow

  1. Connect wallet using RainbowKit
  2. Register a product as a farmer with batch ID, origin, and variety
  3. View the product in the lookup section
  4. Update status as it moves through the supply chain
  5. Generate QR code for consumer verification
  6. Scan QR to view complete history

Conclusion

You've built a complete blockchain-based supply chain tracking system that provides:

  • Immutability: Records cannot be altered once written
  • Transparency: Anyone can verify the product journey
  • Trust: Consumers can make informed decisions
  • Efficiency: Real-time tracking with The Graph indexing

Potential Extensions

  • IPFS Integration: Store product images and certificates
  • IoT Sensors: Automatic temperature/humidity logging
  • NFT Certificates: Premium product authenticity tokens
  • Mobile App: Native scanner with offline support
  • Multi-chain: Deploy on Polygon for lower fees

Resources

The complete source code for this tutorial is available on GitHub.

N

Nawab Khairuzzaman

Full-Stack Web & Blockchain Developer with 6+ years of experience building scalable applications.

Comments

Related Posts