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.
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:
- Farm - Planting date, cultivation practices, harvest information
- Processing - Sorting, quality grading, packaging details
- Shipping - Transportation routes, cold chain monitoring
- Retail - Store arrival, shelf placement, storage conditions
- 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
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 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
- Farmer connects wallet and registers a new mango batch with origin, variety, and harvest details
- Smart contract stores the data and emits a
ProductRegisteredevent - The Graph indexes the event and makes it queryable via GraphQL
- Processor updates status to "Processed" with quality grade and packaging info
- Shipper takes ownership and adds transportation checkpoints
- Retailer marks arrival and shelf placement
- 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
// 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:
// 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:
npx hardhat run scripts/deploy.ts --network sepoliaPart 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:
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:
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.tsEvent Handlers
Create src/mapping.ts:
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
# 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-trackingSample GraphQL Queries
Once deployed, you can query your subgraph:
# 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
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-qrcodeConfigure wagmi and RainbowKit
Create src/config/wagmi.ts:
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:
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:
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:
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:
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:
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:
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:
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
# 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 sepoliaDeploy Subgraph
# Update subgraph.yaml with deployed contract address
# Build and deploy
graph codegen
graph build
graph deploy --studio mango-trackingDeploy Frontend
For Vercel:
npm run build
vercel deployFor Netlify:
npm run build
netlify deploy --prod --dir=distEnvironment Variables
Create .env for your frontend:
VITE_WALLETCONNECT_PROJECT_ID=your_project_id
VITE_CONTRACT_ADDRESS=0x...
VITE_SUBGRAPH_URL=https://api.studio.thegraph.com/query/.../mango-tracking/version/latestTesting the Complete Flow
- Connect wallet using RainbowKit
- Register a product as a farmer with batch ID, origin, and variety
- View the product in the lookup section
- Update status as it moves through the supply chain
- Generate QR code for consumer verification
- 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.
Nawab Khairuzzaman
Full-Stack Web & Blockchain Developer with 6+ years of experience building scalable applications.