Part 1: Setup & Contract Integration
Module 1 & 2 (09:15 - 12:30) | Setup + Contract Integration
📋 Overview
Di part ini, kita akan:
- ✅ Setup Next.js 15 + React 19 project
- ✅ Install Panna SDK, Thirdweb, dan dependencies lainnya
- ✅ Configure Lisk Sepolia network
- ✅ Setup contract types & ABI
- ✅ Create contract interaction utilities
- ✅ Build custom React hooks
Module 1: Project Setup (90 menit)
Step 1: Create Next.js Project
Buka terminal dan jalankan:
npx create-next-app@latest lisk-garden-dapp
Pilih options berikut:
✔ Would you like to use TypeScript? … Yes
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like your code inside a `src/` directory? … No
✔ Would you like to use App Router? … Yes
✔ Would you like to use Turbopack for next dev? … No
✔ Would you like to customize the import alias (@/* by default)? … No
Masuk ke project directory:
cd lisk-garden-dapp
Step 2: Install Dependencies
Install semua dependencies yang dibutuhkan menggunakan yarn:
yarn add panna-sdk ethers@^6 lucide-react next-themes sonner date-fns @vercel/analytics
Penjelasan packages:
panna-sdk- Wallet connection + Account Abstraction (gasless transactions!)ethers@^6- Ethereum utilitieslucide-react- Icon librarynext-themes- Dark/light mode supportsonner- Toast notificationsdate-fns- Date formatting utilities@vercel/analytics- Vercel analytics (optional)
Step 3: Install shadcn/ui
shadcn/ui adalah component library yang akan kita gunakan.
Initialize shadcn/ui:
npx shadcn@latest init
Pilih options:
✔ Preflight checks.
✔ Verifying framework. Found Next.js.
✔ Validating Tailwind CSS.
✔ Validating import alias.
✔ Which style would you like to use? › New York
✔ Which color would you like to use as the base color? › Zinc
✔ Would you like to use CSS variables for theming? › yes
Install components yang dibutuhkan:
npx shadcn@latest add card button dialog input label progress separator sheet skeleton textarea toast tooltip toggle
Components ini akan digunakan untuk build UI garden kita.
Step 4: Setup Environment Variables
Create file .env.local di root project:
touch .env.local
Isi dengan:
# Panna SDK Credentials (dari https://panna.network)
NEXT_PUBLIC_PANNA_CLIENT_ID=your_panna_client_id_here
NEXT_PUBLIC_PANNA_PARTNER_ID=your_panna_partner_id_here
# Lisk Sepolia Network
NEXT_PUBLIC_CHAIN_ID=4202
# LiskGarden Contract Address (dari Sesi 1)
NEXT_PUBLIC_CONTRACT_ADDRESS=0xYourContractAddressHere
📝 Cara dapat Panna credentials:
- Buka panna.network
- Sign up / Login
- Create new project
- Copy
Client IDdanPartner ID - Paste ke
.env.local
🔒 Security Note:
File .env.local sudah auto-ignored oleh Next.js. Jangan commit ke git!
Step 5: Setup Contract Types & ABI
Create folder types dan file contracts.ts:
mkdir types
touch types/contracts.ts
File: types/contracts.ts
// Contract Types and Constants untuk LiskGarden DApp
// Growth stages enum (matching Solidity contract)
export enum GrowthStage {
SEED = 0,
SPROUT = 1,
GROWING = 2,
BLOOMING = 3,
}
// Plant interface (matching contract struct)
export interface Plant {
id: bigint
owner: string
stage: GrowthStage
plantedDate: bigint // timestamp dalam seconds
lastWatered: bigint // timestamp dalam seconds
waterLevel: number // 0-100
exists: boolean
isDead: boolean // apakah plant mati karena kurang air
}
// Growth stage display names
export const STAGE_NAMES = {
[GrowthStage.SEED]: 'seed',
[GrowthStage.SPROUT]: 'sprout',
[GrowthStage.GROWING]: 'growing',
[GrowthStage.BLOOMING]: 'blooming',
} as const
// Contract constants (sesuai dengan smart contract)
export const PLANT_PRICE = '0.001' // ETH
export const HARVEST_REWARD = '0.003' // ETH
export const STAGE_DURATION = 60 // 60 seconds = 1 menit per stage
export const WATER_DEPLETION_TIME = 30 // 30 seconds
export const WATER_DEPLETION_RATE = 20 // 20% per interval
// Contract address dari environment variable
export const LISK_GARDEN_CONTRACT_ADDRESS =
process.env.NEXT_PUBLIC_CONTRACT_ADDRESS || ''
// Contract ABI (Application Binary Interface)
// Ini adalah interface untuk berinteraksi dengan smart contract
export const LISK_GARDEN_ABI = [
// Constructor
{"inputs":[],"stateMutability":"nonpayable","type":"constructor"},
// Events
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"uint256","name":"plantId","type":"uint256"}],"name":"PlantDied","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"uint256","name":"plantId","type":"uint256"},{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":false,"internalType":"uint256","name":"reward","type":"uint256"}],"name":"PlantHarvested","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"uint256","name":"plantId","type":"uint256"}],"name":"PlantSeeded","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"uint256","name":"plantId","type":"uint256"},{"indexed":false,"internalType":"uint8","name":"newWaterLevel","type":"uint8"}],"name":"PlantWatered","type":"event"},
{"anonymous":false,"inputs":[{"indexed":true,"internalType":"uint256","name":"plantId","type":"uint256"},{"indexed":false,"internalType":"enum LiskGarden.GrowthStage","name":"newStage","type":"uint8"}],"name":"StageAdvanced","type":"event"},
// View functions (read-only)
{"inputs":[],"name":"HARVEST_REWARD","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"PLANT_PRICE","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"STAGE_DURATION","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"WATER_DEPLETION_RATE","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"WATER_DEPLETION_TIME","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"uint256","name":"plantId","type":"uint256"}],"name":"calculateWaterLevel","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"uint256","name":"plantId","type":"uint256"}],"name":"getPlant","outputs":[{"components":[{"internalType":"uint256","name":"id","type":"uint256"},{"internalType":"address","name":"owner","type":"address"},{"internalType":"enum LiskGarden.GrowthStage","name":"stage","type":"uint8"},{"internalType":"uint256","name":"plantedDate","type":"uint256"},{"internalType":"uint256","name":"lastWatered","type":"uint256"},{"internalType":"uint8","name":"waterLevel","type":"uint8"},{"internalType":"bool","name":"exists","type":"bool"},{"internalType":"bool","name":"isDead","type":"bool"}],"internalType":"struct LiskGarden.Plant","name":"","type":"tuple"}],"stateMutability":"view","type":"function"},
{"inputs":[{"internalType":"address","name":"user","type":"address"}],"name":"getUserPlants","outputs":[{"internalType":"uint256[]","name":"","type":"uint256[]"}],"stateMutability":"view","type":"function"},
{"inputs":[],"name":"plantCounter","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"view","type":"function"},
// Write functions (state-changing)
{"inputs":[],"name":"plantSeed","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"stateMutability":"payable","type":"function"},
{"inputs":[{"internalType":"uint256","name":"plantId","type":"uint256"}],"name":"waterPlant","outputs":[],"stateMutability":"nonpayable","type":"function"},
{"inputs":[{"internalType":"uint256","name":"plantId","type":"uint256"}],"name":"updatePlantStage","outputs":[],"stateMutability":"nonpayable","type":"function"},
{"inputs":[{"internalType":"uint256","name":"plantId","type":"uint256"}],"name":"harvestPlant","outputs":[],"stateMutability":"nonpayable","type":"function"},
] as const
💡 Penjelasan:
GrowthStageenum untuk tracking tahap pertumbuhanPlantinterface untuk type safetyLISK_GARDEN_ABIadalah contract interface - seperti "menu" fungsi yang tersedia- Constants seperti
PLANT_PRICE,HARVEST_REWARDuntuk referensi
Step 6: Setup Contract Utilities
Create folder lib dan file contract.ts:
mkdir lib
touch lib/contract.ts
File: lib/contract.ts
// Contract interaction utilities untuk LiskGarden DApp
// Menggunakan Panna SDK & Thirdweb untuk berinteraksi dengan smart contract
import { liskSepolia } from 'panna-sdk'
import { prepareContractCall, sendTransaction, readContract, waitForReceipt } from 'thirdweb/transaction'
import { getContract } from 'thirdweb/contract'
import { toWei } from 'thirdweb/utils'
import {
LISK_GARDEN_CONTRACT_ADDRESS,
LISK_GARDEN_ABI,
Plant,
GrowthStage,
STAGE_NAMES,
PLANT_PRICE,
HARVEST_REWARD,
STAGE_DURATION,
WATER_DEPLETION_TIME,
WATER_DEPLETION_RATE,
} from '@/types/contracts'
// ============================================
// HELPER FUNCTIONS
// ============================================
// Convert raw contract data menjadi typed Plant object
export function parsePlantData(rawPlant: any): Plant {
// Handle both array-like tuples and object-like structures
const isArray = Array.isArray(rawPlant)
return {
id: BigInt(isArray ? rawPlant[0] ?? 0 : rawPlant.id ?? 0),
owner: isArray ? rawPlant[1] ?? '' : rawPlant.owner ?? '',
stage: Number(isArray ? rawPlant[2] ?? 0 : rawPlant.stage ?? 0) as GrowthStage,
plantedDate: BigInt(isArray ? rawPlant[3] ?? 0 : rawPlant.plantedDate ?? 0),
lastWatered: BigInt(isArray ? rawPlant[4] ?? 0 : rawPlant.lastWatered ?? 0),
waterLevel: Number(isArray ? rawPlant[5] ?? 0 : rawPlant.waterLevel ?? 0),
exists: Boolean(isArray ? rawPlant[6] ?? false : rawPlant.exists ?? false),
isDead: Boolean(isArray ? rawPlant[7] ?? false : rawPlant.isDead ?? false),
}
}
// ============================================
// CONTRACT WRITE FUNCTIONS (Mengubah state)
// ============================================
// Plant a new seed (payable - butuh ETH)
export async function plantSeed(client: any, account: any) {
const tx = prepareContractCall({
contract: getContract({
client,
chain: liskSepolia,
address: LISK_GARDEN_CONTRACT_ADDRESS,
}),
method: 'function plantSeed() payable returns (uint256)',
params: [],
value: toWei(PLANT_PRICE), // Convert 0.001 ETH ke wei
})
const result = await sendTransaction({
account,
transaction: tx,
})
// Wait sampai transaction di-mine
await waitForReceipt(result)
return result
}
// Water a plant
export async function waterPlant(client: any, account: any, plantId: bigint) {
const tx = prepareContractCall({
contract: getContract({
client,
chain: liskSepolia,
address: LISK_GARDEN_CONTRACT_ADDRESS,
}),
method: 'function waterPlant(uint256 plantId)',
params: [plantId],
})
const result = await sendTransaction({
account,
transaction: tx,
})
await waitForReceipt(result)
return result
}
// Harvest a blooming plant
export async function harvestPlant(client: any, account: any, plantId: bigint) {
const tx = prepareContractCall({
contract: getContract({
client,
chain: liskSepolia,
address: LISK_GARDEN_CONTRACT_ADDRESS,
}),
method: 'function harvestPlant(uint256 plantId)',
params: [plantId],
})
const result = await sendTransaction({
account,
transaction: tx,
})
await waitForReceipt(result)
return result
}
// Update plant stage manually (sync dengan blockchain)
export async function updatePlantStage(client: any, account: any, plantId: bigint) {
const tx = prepareContractCall({
contract: getContract({
client,
chain: liskSepolia,
address: LISK_GARDEN_CONTRACT_ADDRESS,
}),
method: 'function updatePlantStage(uint256 plantId)',
params: [plantId],
})
const result = await sendTransaction({
account,
transaction: tx,
})
await waitForReceipt(result)
return result
}
// ============================================
// CONTRACT READ FUNCTIONS (Read-only, tidak butuh gas)
// ============================================
// Get single plant data
export async function getPlant(client: any, plantId: bigint): Promise<Plant> {
const contract = getContract({
client,
chain: liskSepolia,
address: LISK_GARDEN_CONTRACT_ADDRESS,
})
const rawPlant = await readContract({
contract,
method: 'function getPlant(uint256 plantId) view returns (uint256 id, address owner, uint8 stage, uint256 plantedDate, uint256 lastWatered, uint8 waterLevel, bool exists, bool isDead)',
params: [plantId],
})
return parsePlantData(rawPlant)
}
// Calculate current water level dari blockchain
export async function calculateWaterLevel(client: any, plantId: bigint, plant?: Plant): Promise<number> {
// Optimization: Skip blockchain call untuk blooming plants
// Blooming plants tidak kehilangan air
if (plant && plant.stage === GrowthStage.BLOOMING) {
return plant.waterLevel
}
const contract = getContract({
client,
chain: liskSepolia,
address: LISK_GARDEN_CONTRACT_ADDRESS,
})
const waterLevel = await readContract({
contract,
method: 'function calculateWaterLevel(uint256 plantId) view returns (uint8)',
params: [plantId],
})
return Number(waterLevel)
}
// Get all plants milik user
export async function getUserPlants(client: any, userAddress: string): Promise<bigint[]> {
const contract = getContract({
client,
chain: liskSepolia,
address: LISK_GARDEN_CONTRACT_ADDRESS,
})
const plantIds = await readContract({
contract,
method: 'function getUserPlants(address user) view returns (uint256[])',
params: [userAddress],
})
return plantIds.map((id: any) => BigInt(id))
}
// ============================================
// CLIENT-SIDE HELPER FUNCTIONS (Tidak hit blockchain)
// ============================================
// Format plant age menjadi human-readable string
export function formatPlantAge(plantedDate: bigint): string {
const now = Date.now()
const planted = Number(plantedDate) * 1000
const diff = now - planted
const days = Math.floor(diff / (1000 * 60 * 60 * 24))
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60))
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const seconds = Math.floor((diff % (1000 * 60)) / 1000)
if (days > 0) return `${days} hari lalu`
if (hours > 0) return `${hours}j ${minutes}m lalu`
if (minutes > 0) return `${minutes}m ${seconds}d lalu`
return `${seconds}d lalu`
}
// Format last watered time
export function formatLastWatered(lastWatered: bigint): string {
return formatPlantAge(lastWatered)
}
// Get stage display name
export function getStageDisplayName(stage: GrowthStage): string {
return STAGE_NAMES[stage]
}
// Check apakah plant bisa di-harvest
export function canHarvest(plant: Plant): boolean {
return plant.stage === GrowthStage.BLOOMING && plant.exists && !plant.isDead
}
// Calculate plant progress percentage
export function getPlantProgress(plant: Plant): number {
const now = Date.now() / 1000
const planted = Number(plant.plantedDate)
const timePassed = now - planted
if (plant.stage === GrowthStage.BLOOMING) return 100
// Use STAGE_DURATION constant (60 seconds per stage)
const currentStageStart = Number(plant.stage) * STAGE_DURATION
const currentStageProgress = ((timePassed - currentStageStart) / STAGE_DURATION) * 25
return Math.min(Number(plant.stage) * 25 + currentStageProgress, 100)
}
// Calculate current water level (client-side, no blockchain call)
export function getClientWaterLevel(plant: Plant): number {
if (!plant.exists || plant.isDead) return 0
// Blooming plants tidak kehilangan air - mereka siap panen!
if (plant.stage === GrowthStage.BLOOMING) {
return plant.waterLevel
}
const now = Date.now() / 1000
const timeSinceWatered = now - Number(plant.lastWatered)
const depletionIntervals = Math.floor(timeSinceWatered / WATER_DEPLETION_TIME)
const waterLost = depletionIntervals * WATER_DEPLETION_RATE
if (waterLost >= plant.waterLevel) return 0
return plant.waterLevel - waterLost
}
// Check apakah plant butuh disiram (di bawah 50%)
export function needsWater(plant: Plant): boolean {
if (plant.isDead || !plant.exists) return false
if (plant.stage === GrowthStage.BLOOMING) return false
return getClientWaterLevel(plant) < 50
}
// Check apakah plant dalam kondisi kritis (di bawah 20%)
export function isCritical(plant: Plant): boolean {
if (plant.isDead || !plant.exists) return false
if (plant.stage === GrowthStage.BLOOMING) return false
return getClientWaterLevel(plant) < 20
}
// Calculate expected stage berdasarkan waktu
export function getExpectedStage(plant: Plant): GrowthStage {
if (plant.isDead || !plant.exists) return plant.stage
const now = Date.now() / 1000
const planted = Number(plant.plantedDate)
const timePassed = now - planted
// Calculate stage berdasarkan waktu
const calculatedStage = Math.min(Math.floor(timePassed / STAGE_DURATION), 3)
return calculatedStage as GrowthStage
}
// Check apakah plant stage perlu di-sync
export function isStageOutOfSync(plant: Plant): boolean {
if (plant.isDead || !plant.exists) return false
const expectedStage = getExpectedStage(plant)
return plant.stage < expectedStage
}
// Export constants
export { LISK_GARDEN_CONTRACT_ADDRESS, PLANT_PRICE, HARVEST_REWARD, STAGE_DURATION }
💡 Key Points:
- Functions dibagi 3 kategori: Write (ubah state), Read (dari blockchain), Helper (client-side)
- Write functions menggunakan Panna SDK - GASLESS otomatis!
- Read functions tidak butuh gas
- Helper functions pure JavaScript, tidak hit blockchain
☕ ISTIRAHAT (10:45 - 11:00)
Setelah istirahat, kita lanjut ke Module 2!
Module 2: Custom React Hooks (90 menit)
Step 7: Create useContract Hook
Create file hooks/useContract.ts:
mkdir hooks
touch hooks/useContract.ts
File: hooks/useContract.ts
'use client'
import { useMemo } from 'react'
import { useActiveAccount, usePanna } from 'panna-sdk'
import { LISK_GARDEN_CONTRACT_ADDRESS } from '@/lib/contract'
/**
* Hook untuk get Panna client dan active account
* Returns client, account, dan wallet connection status
*/
export function useContract() {
const activeAccount = useActiveAccount()
const { client } = usePanna()
const contractInfo = useMemo(() => {
return {
client: client || null,
account: activeAccount || null,
isConnected: !!activeAccount && !!client,
address: activeAccount?.address || null,
contractAddress: LISK_GARDEN_CONTRACT_ADDRESS,
}
}, [activeAccount, client])
return contractInfo
}
💡 Penjelasan:
useActiveAccount()- Hook dari Panna SDK untuk get connected accountusePanna()- Hook untuk get Panna clientuseMemo- Optimize performance, hanya re-calculate kalau dependencies berubah- Returns object dengan
client,account,isConnected,address
Step 8: Create usePlants Hook
Create file hooks/usePlants.ts:
touch hooks/usePlants.ts
File: hooks/usePlants.ts
'use client'
import { useState, useEffect, useCallback } from 'react'
import { useContract } from './useContract'
import {
getUserPlants,
getPlant,
plantSeed as plantSeedContract,
waterPlant as waterPlantContract,
harvestPlant as harvestPlantContract,
updatePlantStage as updatePlantStageContract,
isStageOutOfSync,
} from '@/lib/contract'
import { Plant } from '@/types/contracts'
import { useToast } from '@/hooks/use-toast'
/**
* Hook untuk manage user's plants
* Fetch plants dari contract dan provides plant operations
*/
export function usePlants() {
const { client, account, isConnected, address } = useContract()
const { toast } = useToast()
const [plants, setPlants] = useState<Plant[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
// Fetch user's plants (dengan optional silent mode untuk auto-refresh)
const fetchPlants = useCallback(async (silent = false) => {
if (!client || !address) {
setPlants([])
return
}
// Hanya show loading state kalau bukan silent (user-initiated actions)
if (!silent) {
setLoading(true)
}
setError(null)
try {
// Get user's plant IDs
const plantIds = await getUserPlants(client, address)
// Fetch data setiap plant
const plantPromises = plantIds.map(async (id) => {
try {
const plant = await getPlant(client, id)
return plant.exists ? plant : null
} catch (err) {
console.error(`Error fetching plant ${id}:`, err)
return null
}
})
const fetchedPlants = await Promise.all(plantPromises)
const validPlants = fetchedPlants.filter((p): p is Plant => p !== null)
setPlants(validPlants)
} catch (err) {
console.error('Error fetching plants:', err)
setError(err as Error)
// Hanya show error toast kalau bukan silent
if (!silent) {
toast({
title: 'Error',
description: 'Gagal mengambil data plants. Silakan coba lagi.',
variant: 'destructive',
})
}
} finally {
if (!silent) {
setLoading(false)
}
}
}, [client, address, toast])
// Plant new seed
const plantSeed = useCallback(async () => {
if (!client || !account) {
toast({
title: 'Wallet belum terkoneksi',
description: 'Silakan connect wallet terlebih dahulu',
variant: 'destructive',
})
return
}
setLoading(true)
try {
// Send transaction dan wait untuk receipt
await plantSeedContract(client, account)
toast({
title: 'Seed berhasil ditanam!',
description: 'Plant Anda berhasil dibuat. Cost: 0.001 ETH',
})
// Transaction confirmed, refresh plants
await fetchPlants()
} catch (err: any) {
console.error('Error planting seed:', err)
toast({
title: 'Error',
description: err.message || 'Gagal menanam seed. Silakan coba lagi.',
variant: 'destructive',
})
} finally {
setLoading(false)
}
}, [client, account, toast, fetchPlants])
// Water a plant
const waterPlant = useCallback(
async (plantId: bigint) => {
if (!client || !account) {
toast({
title: 'Wallet belum terkoneksi',
description: 'Silakan connect wallet terlebih dahulu',
variant: 'destructive',
})
return
}
setLoading(true)
try {
// Check apakah stage perlu update
const plant = await getPlant(client, plantId)
const needsStageUpdate = isStageOutOfSync(plant)
if (needsStageUpdate) {
toast({
title: 'Syncing stage...',
description: 'Update plant stage terlebih dahulu, lalu watering.',
})
await updatePlantStageContract(client, account, plantId)
}
// Send transaction
await waterPlantContract(client, account, plantId)
toast({
title: 'Plant berhasil disiram!',
description: needsStageUpdate
? 'Stage di-sync dan plant disiram!'
: 'Plant Anda berhasil disiram. GRATIS - hanya gas!',
})
// Refresh plants
await fetchPlants()
} catch (err: any) {
console.error('Error watering plant:', err)
toast({
title: 'Error',
description: err.message || 'Gagal menyiram plant. Silakan coba lagi.',
variant: 'destructive',
})
} finally {
setLoading(false)
}
},
[client, account, toast, fetchPlants]
)
// Harvest a plant
const harvestPlant = useCallback(
async (plantId: bigint) => {
if (!client || !account) {
toast({
title: 'Wallet belum terkoneksi',
description: 'Silakan connect wallet terlebih dahulu',
variant: 'destructive',
})
return
}
setLoading(true)
try {
// Check apakah stage perlu update sebelum harvest
const plant = await getPlant(client, plantId)
const needsStageUpdate = isStageOutOfSync(plant)
if (needsStageUpdate) {
toast({
title: 'Syncing stage...',
description: 'Update ke blooming stage sebelum harvest.',
})
await updatePlantStageContract(client, account, plantId)
}
// Send transaction
await harvestPlantContract(client, account, plantId)
toast({
title: 'Plant berhasil dipanen!',
description: needsStageUpdate
? 'Stage di-sync dan di-harvest! Anda menerima 0.003 ETH 🎉'
: 'Anda menerima 0.003 ETH reward! 🎉',
})
// Refresh plants
await fetchPlants()
} catch (err: any) {
console.error('Error harvesting plant:', err)
toast({
title: 'Error',
description: err.message || 'Gagal panen plant. Silakan coba lagi.',
variant: 'destructive',
})
} finally {
setLoading(false)
}
},
[client, account, toast, fetchPlants]
)
// Update plant stage manually
const updatePlantStage = useCallback(
async (plantId: bigint) => {
if (!client || !account) {
toast({
title: 'Wallet belum terkoneksi',
description: 'Silakan connect wallet terlebih dahulu',
variant: 'destructive',
})
return
}
setLoading(true)
try {
await updatePlantStageContract(client, account, plantId)
toast({
title: 'Stage berhasil di-update!',
description: 'Plant stage sudah di-sync dengan blockchain.',
})
await fetchPlants()
} catch (err: any) {
console.error('Error updating plant stage:', err)
toast({
title: 'Error',
description: err.message || 'Gagal update plant stage. Silakan coba lagi.',
variant: 'destructive',
})
} finally {
setLoading(false)
}
},
[client, account, toast, fetchPlants]
)
// Auto-fetch plants saat connected
useEffect(() => {
if (isConnected && address) {
fetchPlants()
}
}, [isConnected, address, fetchPlants])
// Auto-refresh data setiap 5 detik (silent mode untuk seamless updates)
useEffect(() => {
if (!isConnected || !address) {
return
}
// Set interval untuk refetch setiap 5 detik dalam silent mode
const intervalId = setInterval(() => {
fetchPlants(true) // true = silent mode (no loading state)
}, 5000)
// Cleanup interval on unmount atau dependencies change
return () => clearInterval(intervalId)
}, [isConnected, address, fetchPlants])
return {
plants,
loading,
error,
fetchPlants,
plantSeed,
waterPlant,
harvestPlant,
updatePlantStage,
}
}
💡 Key Features:
fetchPlants- Dengan silent mode untuk auto-refresh tanpa loading flickerplantSeed,waterPlant,harvestPlant- All actions dengan proper error handling- Auto-sync stage sebelum water/harvest kalau perlu
- Auto-refresh setiap 5 detik untuk real-time updates
- Toast notifications untuk user feedback
Step 9: Setup Providers
Create file components/providers.tsx:
mkdir components
touch components/providers.tsx
File: components/providers.tsx
'use client'
import { PannaProvider } from 'panna-sdk'
import { ThemeProvider } from '@/components/theme-provider'
import { Toaster } from '@/components/ui/toaster'
export function Providers({ children }: { children: React.ReactNode }) {
return (
<PannaProvider
clientId={process.env.NEXT_PUBLIC_PANNA_CLIENT_ID}
partnerId={process.env.NEXT_PUBLIC_PANNA_PARTNER_ID}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
<Toaster />
</ThemeProvider>
</PannaProvider>
)
}
💡 Penjelasan:
PannaProvider- Setup Panna SDK dengan credentials dari.env.local- GASLESS OTOMATIS AKTIF via Panna SDK! 🎉
ThemeProvider- Dark/light mode supportToaster- Toast notifications component
Step 10: Create ThemeProvider
Create file components/theme-provider.tsx:
touch components/theme-provider.tsx
File: components/theme-provider.tsx
'use client'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import type { ThemeProviderProps } from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}
Step 11: Update Root Layout
Update app/layout.tsx:
import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import { Analytics } from "@vercel/analytics/next"
import { Providers } from "@/components/providers"
import "./globals.css"
const geist = Geist({ subsets: ["latin"] })
const geistMono = Geist_Mono({ subsets: ["latin"] })
export const metadata: Metadata = {
title: "Lisk Garden - Web3 Garden Game",
description: "Grow your virtual garden on the Lisk blockchain",
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={`font-sans antialiased`}>
<Providers>
{children}
</Providers>
<Analytics />
</body>
</html>
)
}
✅ Testing Setup
Test apakah setup berhasil:
yarn dev
Checklist:
- App running tanpa error
- No TypeScript errors
- Environment variables loaded
- Ready untuk build UI components!
🎯 Module 1 & 2 Complete!
Sekarang kita sudah punya:
- ✅ Next.js 15 + React 19 project
- ✅ Panna SDK setup (gasless otomatis!)
- ✅ Thirdweb SDK untuk contract interactions
- ✅ Contract types & ABI
- ✅ Contract interaction utilities
- ✅ Custom React hooks (
useContract,usePlants)
Istirahat Makan Siang (12:30 - 13:30) 🍽️
Setelah makan siang, kita akan build UI components!
← Back to Main Index | Next: Part 2A - Building UI Components →
#BuildOnLisk | #EthereumJakarta | #Web3Indonesia