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.
| Job | EVM stack | NEAR stack |
|---|---|---|
| Connect a wallet | RainbowKit / Web3Modal + wagmi | @near-wallet-selector/core + modal-ui + wallet modules |
| Read contract state | publicClient.readContract (eth_call) | JsonRpcProvider.callFunction (RPC call_function query) |
| Send a transaction | walletClient.writeContract with value | wallet.signAndSendTransaction with a functionCall action and a deposit |
| Amounts | parseEther / formatEther | parseNearAmount / formatNearAmount (24 decimals) |
Shell
npm install @near-wallet-selector/core @near-wallet-selector/modal-ui \
@near-wallet-selector/my-near-wallet @near-js/providers @near-js/utilsThe NEAR layer#
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.
callFunctionsends 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 abigint. 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.
signAndSendTransactioncan 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#
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