Near Learn

A small web frontend

Build a NEAR dApp frontend in TypeScript: wallet-selector sign-in, free RPC view calls, and a payable pledge function call with an attached NEAR deposit.

Intermediate12 min read3-question check

Backers will not use the CLI. In this milestone you write the thin TypeScript layer a web app needs: sign in with a NEAR wallet, read campaigns with free view calls, and sign a pledge with NEAR attached. It is framework-agnostic — drop it into React, Svelte, Vue or plain Vite.

JobEVM stackNEAR stack
Connect a walletRainbowKit / Web3Modal + wagmi@near-wallet-selector/core + modal-ui + wallet modules
Read contract statepublicClient.readContract (eth_call)JsonRpcProvider.callFunction (RPC call_function query)
Send a transactionwalletClient.writeContract with valuewallet.signAndSendTransaction with a functionCall action and a deposit
AmountsparseEther / formatEtherparseNearAmount / formatNearAmount (24 decimals)
The moving parts, next to their EVM equivalents
Install
Shell
npm install @near-wallet-selector/core @near-wallet-selector/modal-ui \
  @near-wallet-selector/my-near-wallet @near-js/providers @near-js/utils

The NEAR layer#

src/near.ts — wallet, views and the pledge transaction
TypeScript
import { setupWalletSelector, actionCreators, type WalletSelector } from '@near-wallet-selector/core'
import { setupModal, type WalletSelectorModal } from '@near-wallet-selector/modal-ui'
import { setupMyNearWallet } from '@near-wallet-selector/my-near-wallet'
import { JsonRpcProvider } from '@near-js/providers'
import { parseNearAmount } from '@near-js/utils'
import '@near-wallet-selector/modal-ui/styles.css'

export const CONTRACT_ID = 'crowdfund.you.testnet'
const provider = new JsonRpcProvider({ url: 'https://rpc.testnet.near.org' })

let selector: WalletSelector
let modal: WalletSelectorModal

export async function initNear() {
  selector = await setupWalletSelector({
    network: 'testnet',
    modules: [setupMyNearWallet()], // add more wallet modules here
  })
  modal = setupModal(selector, { contractId: CONTRACT_ID })
}

export const signIn = () => modal.show()
export const signOut = async () => (await selector.wallet()).signOut()

export function accountId(): string | null {
  const active = selector.store.getState().accounts.find((a) => a.active)
  return active?.accountId ?? null
}

// ---- views: free, no wallet needed ----

export type CampaignView = {
  id: number
  creator: string
  title: string
  goal: string   // U128: yoctoNEAR (or token units) as a string
  raised: string
  deadline_ms: number
  token: string | null
  claimed: boolean
  backer_count: number
  status: 'Open' | 'Succeeded' | 'Failed'
}

export async function getCampaigns(fromIndex = 0, limit = 20): Promise<CampaignView[]> {
  const page = await provider.callFunction(CONTRACT_ID, 'get_campaigns', {
    from_index: fromIndex,
    limit,
  })
  return (page ?? []) as CampaignView[]
}

// ---- change calls: signed by the user's wallet ----

const GAS = BigInt('30000000000000')        // 30 Tgas
const PAYOUT_GAS = BigInt('50000000000000') // 50 Tgas: transfer + callback

export async function pledge(campaignId: number, amountNear: string) {
  const deposit = parseNearAmount(amountNear) // "2.5" -> "2500000000000000000000000"
  if (!deposit) throw new Error('invalid amount')
  const wallet = await selector.wallet()
  return wallet.signAndSendTransaction({
    receiverId: CONTRACT_ID,
    actions: [
      actionCreators.functionCall('pledge', { campaign_id: campaignId }, GAS, BigInt(deposit)),
    ],
  })
}

export async function refund(campaignId: number) {
  const wallet = await selector.wallet()
  return wallet.signAndSendTransaction({
    receiverId: CONTRACT_ID,
    actions: [
      actionCreators.functionCall('refund', { campaign_id: campaignId }, PAYOUT_GAS, BigInt(0)),
    ],
  })
}
  • Views never touch the wallet. callFunction sends a read-only RPC query, so the campaign list works for visitors who have not signed in — and costs nobody anything.
  • The deposit is the pledge. functionCall(method, args, gas, deposit) takes yoctoNEAR as a bigint. The contract subtracts the storage cost for a first-time backer and pledges the rest (lesson 3).
  • Gas is a budget, not a price. Unused gas is refunded. Payout methods get more because they schedule a transfer and a callback.
  • Browser wallets may redirect. signAndSendTransaction can navigate away to the wallet and back, so do not rely on its return value; refresh your views when the page loads again.

Rendering a campaign#

src/main.ts — a minimal page with no framework
TypeScript
import { formatNearAmount } from '@near-js/utils'
import { initNear, signIn, accountId, getCampaigns, pledge, type CampaignView } from './near'

function card(c: CampaignView): string {
  const raised = formatNearAmount(c.raised, 2)
  const goal = formatNearAmount(c.goal, 2)
  const ends = new Date(c.deadline_ms).toLocaleString()
  return `<li>
    <h3>${c.title}</h3>
    <p>${raised} / ${goal} NEAR · ${c.backer_count} backers · ${c.status} · ends ${ends}</p>
    ${c.status === 'Open' && !c.token ? `<button data-pledge="${c.id}">Pledge 1 NEAR</button>` : ''}
  </li>`
}

async function render() {
  const list = document.querySelector('#campaigns')!
  const campaigns = await getCampaigns()
  list.innerHTML = campaigns.map(card).join('')
}

document.addEventListener('click', async (e) => {
  const id = (e.target as HTMLElement).dataset.pledge
  if (id === undefined) return
  if (!accountId()) return signIn()
  await pledge(Number(id), '1')
  await render()
})

await initNear()
document.querySelector('#who')!.textContent = accountId() ?? 'not signed in'
await render()

Check yourself

3 questions · progress saved in this browser

  1. 1.How does the frontend read the campaign list?
  2. 2.In functionCall("pledge", args, gas, deposit), what unit is deposit?
  3. 3.Why should the UI not rely on the return value of signAndSendTransaction?