diff --git a/frontend/README.md b/frontend/README.md index ea2906e..b043efd 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -60,17 +60,35 @@ Build the static Storybook bundle with: npm run build-storybook ``` -## Env +## Environment variables -- `VITE_API_URL`: Base URL for API requests. Leave empty to use the local Vite proxy. -- `VITE_SOROBAN_RPC_URL`: Soroban RPC endpoint for read/write contract calls. -- `VITE_REWARDS_CONTRACT_ID`: Rewards contract ID for balance and claim flows. -- `VITE_CAMPAIGN_CONTRACT_ID`: Campaign contract ID for participant registration. +Create a `.env.local` file in `frontend/` when you need to point the app at non-default services. + +```bash +VITE_API_URL=http://localhost:3001 +VITE_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org +VITE_REWARDS_CONTRACT_ID=CC... +VITE_CAMPAIGN_CONTRACT_ID=CC... +VITE_STELLAR_NETWORK_PASSPHRASE="Test SDF Network ; September 2015" +``` + +- `VITE_API_URL`: Base URL used for frontend `fetch` calls. Leave empty to use the local Vite proxy. +- `VITE_SOROBAN_RPC_URL`: Soroban RPC endpoint used by frontend contract helpers. Defaults to Stellar testnet RPC. +- `VITE_REWARDS_CONTRACT_ID`: Optional rewards contract ID for frontend Soroban calls. +- `VITE_CAMPAIGN_CONTRACT_ID`: Optional campaign contract ID for frontend Soroban calls. - `VITE_STELLAR_NETWORK_PASSPHRASE`: Stellar network passphrase. Defaults to testnet. ## API routing -The frontend now targets `/api/v1/*` routes by default. Campaign loading uses the paginated response shape from `GET /api/v1/campaigns?page=1&limit=6`. Legacy `/api/*` routes are still supported by the backend for backward compatibility, but new integrations should use the v1 prefix. +The frontend targets `/api/v1/*` routes by default. Campaign loading uses the paginated response shape from `GET /api/v1/campaigns?page=1&limit=6`. Legacy `/api/*` routes are still supported by the backend for backward compatibility, but new integrations should use the v1 prefix. + +## Config usage + +The frontend reads these values from [src/config.js](/Users/CMI-James/od/Trivela/frontend/src/config.js): + +- API requests are built with `apiUrl(...)`. +- Soroban RPC access goes through `createSorobanServer()`. +- Rewards and campaign contract IDs are exposed through `getRewardsContract()` and `getCampaignContract()`. ## Stellar integration @@ -80,4 +98,4 @@ Use `@stellar/stellar-sdk` for: - Building and signing transactions - Invoking the rewards and campaign contracts -See [Stellar Developers](https://developers.stellar.org/docs) and the root README for contract IDs and flows. +See [Stellar Developers](https://developers.stellar.org/docs) and the root README for deployment flows. diff --git a/frontend/src/Landing.css b/frontend/src/Landing.css index 9931336..f64c8f9 100644 --- a/frontend/src/Landing.css +++ b/frontend/src/Landing.css @@ -514,6 +514,46 @@ color: var(--text-muted); } +.config-grid { + margin-top: 1.5rem; +} + +.config-card { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 16px; + padding: 1.5rem; +} + +.config-card h3 { + font-family: var(--font-heading); + font-size: 1.1rem; + margin: 0 0 0.75rem; +} + +.config-card p { + margin: 0 0 1rem; + color: var(--text-muted); +} + +.config-list { + list-style: none; + padding: 0; + margin: 0; + display: grid; + gap: 0.65rem; +} + +.config-list li { + color: var(--text-muted); + word-break: break-word; +} + +.config-list strong { + color: var(--text); +} + +/* ---- How it works ---- */ .how { background: transparent; } diff --git a/frontend/src/Landing.jsx b/frontend/src/Landing.jsx index 23121d7..5f7c24b 100644 --- a/frontend/src/Landing.jsx +++ b/frontend/src/Landing.jsx @@ -1,4 +1,12 @@ import { useEffect, useState } from 'react'; +import { + apiUrl, + CAMPAIGN_CONTRACT_ID, + REWARDS_CONTRACT_ID, + SOROBAN_RPC_URL, + getCampaignContract, + getRewardsContract, +} from './config'; import { fetchRewardsBalance, formatPoints, @@ -53,14 +61,15 @@ export default function Landing({ const [points, setPoints] = useState(null); const [pointsError, setPointsError] = useState(''); const [isPointsLoading, setIsPointsLoading] = useState(false); + const rewardsContract = getRewardsContract(); + const campaignContract = getCampaignContract(); useEffect(() => { const controller = new AbortController(); - const api = import.meta.env.VITE_API_URL || ''; setIsCampaignsLoading(true); setCampaignsError(''); - fetch(`${api}/api/v1/campaigns?page=${campaignPage}&limit=${CAMPAIGNS_PER_PAGE}`, { + fetch(apiUrl(`/api/v1/campaigns?page=${campaignPage}&limit=${CAMPAIGNS_PER_PAGE}`), { signal: controller.signal, }) .then(async (response) => { @@ -259,6 +268,21 @@ export default function Landing({

Vite UI with Freighter wallet connection, paginated campaigns, and contract interactions.

+
+
+

Environment-driven wiring

+

+ Frontend API and Soroban targets are configured through Vite env values so each deployment + can point at its own backend, rewards contract, and campaign contract without code changes. +

+ +
+
diff --git a/frontend/src/config.js b/frontend/src/config.js new file mode 100644 index 0000000..809c92c --- /dev/null +++ b/frontend/src/config.js @@ -0,0 +1,34 @@ +import { Contract, Networks, rpc } from '@stellar/stellar-sdk'; + +const DEFAULT_SOROBAN_RPC_URL = 'https://soroban-testnet.stellar.org'; + +function trimTrailingSlash(value) { + return value.replace(/\/+$/, ''); +} + +export const API_BASE_URL = trimTrailingSlash(import.meta.env.VITE_API_URL || ''); +export const SOROBAN_RPC_URL = import.meta.env.VITE_SOROBAN_RPC_URL || DEFAULT_SOROBAN_RPC_URL; +export const REWARDS_CONTRACT_ID = import.meta.env.VITE_REWARDS_CONTRACT_ID || ''; +export const CAMPAIGN_CONTRACT_ID = import.meta.env.VITE_CAMPAIGN_CONTRACT_ID || ''; +export const NETWORK_PASSPHRASE = + import.meta.env.VITE_STELLAR_NETWORK_PASSPHRASE || Networks.TESTNET; + +export function apiUrl(path) { + if (!path.startsWith('/')) { + throw new Error(`API path must start with "/": ${path}`); + } + + return `${API_BASE_URL}${path}`; +} + +export function createSorobanServer() { + return new rpc.Server(SOROBAN_RPC_URL); +} + +export function getRewardsContract() { + return REWARDS_CONTRACT_ID ? new Contract(REWARDS_CONTRACT_ID) : null; +} + +export function getCampaignContract() { + return CAMPAIGN_CONTRACT_ID ? new Contract(CAMPAIGN_CONTRACT_ID) : null; +} diff --git a/frontend/src/stellar.js b/frontend/src/stellar.js index fc5fc95..91b5434 100644 --- a/frontend/src/stellar.js +++ b/frontend/src/stellar.js @@ -7,52 +7,43 @@ import { Address, - Contract, - Networks, TransactionBuilder, BASE_FEE, scValToNative, nativeToScVal, - rpc, -} from "@stellar/stellar-sdk"; - -/* ---------- environment configuration ---------- */ - -export const SOROBAN_RPC_URL = - import.meta.env.VITE_SOROBAN_RPC_URL || "https://soroban-testnet.stellar.org"; - -export const REWARDS_CONTRACT_ID = - import.meta.env.VITE_REWARDS_CONTRACT_ID || ""; - -export const CAMPAIGN_CONTRACT_ID = - import.meta.env.VITE_CAMPAIGN_CONTRACT_ID || ""; - -export const NETWORK_PASSPHRASE = - import.meta.env.VITE_STELLAR_NETWORK_PASSPHRASE || Networks.TESTNET; +} from '@stellar/stellar-sdk'; +import { + CAMPAIGN_CONTRACT_ID, + createSorobanServer, + getCampaignContract, + getRewardsContract, + NETWORK_PASSPHRASE, + REWARDS_CONTRACT_ID, +} from './config'; + +export { + CAMPAIGN_CONTRACT_ID, + NETWORK_PASSPHRASE, + REWARDS_CONTRACT_ID, +} from './config'; export const HORIZON_URL = - import.meta.env.VITE_HORIZON_URL || "https://horizon-testnet.stellar.org"; + import.meta.env.VITE_HORIZON_URL || 'https://horizon-testnet.stellar.org'; /* ---------- Freighter helpers ---------- */ -/** - * Return the injected Freighter browser API or throw. - */ export function getFreighterApi() { const freighterApi = window.freighterApi; if (!freighterApi) { throw new Error( - "Freighter API is unavailable. Install or unlock the Freighter browser extension.", + 'Freighter API is unavailable. Install or unlock the Freighter browser extension.', ); } return freighterApi; } -/** - * Connect the Freighter wallet and return the public key. - */ export async function getWalletAddress() { const freighterApi = getFreighterApi(); @@ -60,7 +51,7 @@ export async function getWalletAddress() { if (freighterStatus.error) throw new Error(freighterStatus.error); if (!freighterStatus.isConnected) { throw new Error( - "Freighter extension was not detected. Install or unlock Freighter to connect a wallet.", + 'Freighter extension was not detected. Install or unlock Freighter to connect a wallet.', ); } @@ -70,21 +61,19 @@ export async function getWalletAddress() { const access = await freighterApi.requestAccess(); if (access.error) throw new Error(access.error); - if (!access.address) - throw new Error("Freighter did not return a wallet address."); + if (!access.address) { + throw new Error('Freighter did not return a wallet address.'); + } return access.address; } /* ---------- formatting ---------- */ -/** - * Safely format a raw balance value (bigint | number) to a display string. - */ export function formatPoints(points) { - if (typeof points === "bigint") return points.toString(); - if (typeof points === "number") return String(points); - return "0"; + if (typeof points === 'bigint') return points.toString(); + if (typeof points === 'number') return String(points); + return '0'; } /** @@ -92,7 +81,7 @@ export function formatPoints(points) { */ export function formatWalletBalance(balance) { const numericBalance = Number(balance); - if (!Number.isFinite(numericBalance)) return "0 XLM"; + if (!Number.isFinite(numericBalance)) return '0 XLM'; return `${numericBalance.toFixed(2)} XLM`; } @@ -100,21 +89,19 @@ export function formatWalletBalance(balance) { * Turn an unknown error value into a human-readable message. */ export function normalizeError(error) { - if (!error) return "Unable to load points right now."; + if (!error) return 'Unable to load points right now.'; const message = - typeof error === "string" + typeof error === 'string' ? error - : error.message || - error.toString?.() || - "Unable to load points right now."; + : error.message || error.toString?.() || 'Unable to load points right now.'; if (/not found|missing|404/i.test(message)) { - return "Rewards contract is not deployed on the configured Soroban network yet."; + return 'Rewards contract is not deployed on the configured Soroban network yet.'; } if (/unsupported address type/i.test(message)) { - return "Connected wallet address is invalid for Soroban calls."; + return 'Connected wallet address is invalid for Soroban calls.'; } return message; @@ -138,33 +125,30 @@ export async function fetchWalletBalance(walletAddress) { const account = await response.json(); const nativeBalance = account.balances?.find( - (balance) => balance.asset_type === "native", + (balance) => balance.asset_type === 'native', ); - return nativeBalance?.balance || "0"; + return nativeBalance?.balance || '0'; } /** * Simulate a read-only `balance(user)` call and return the raw result. */ export async function fetchRewardsBalance(walletAddress) { - if (!REWARDS_CONTRACT_ID) { - throw new Error("Set VITE_REWARDS_CONTRACT_ID to load on-chain points."); + const contract = getRewardsContract(); + if (!contract) { + throw new Error('Set VITE_REWARDS_CONTRACT_ID to load on-chain points.'); } - const server = new rpc.Server(SOROBAN_RPC_URL); + const server = createSorobanServer(); const sourceAccount = await server.getAccount(walletAddress); - const contract = new Contract(REWARDS_CONTRACT_ID); const transaction = new TransactionBuilder(sourceAccount, { fee: BASE_FEE, networkPassphrase: NETWORK_PASSPHRASE, }) .addOperation( - contract.call( - "balance", - nativeToScVal(Address.fromString(walletAddress)), - ), + contract.call('balance', nativeToScVal(Address.fromString(walletAddress))), ) .setTimeout(30) .build(); @@ -173,7 +157,7 @@ export async function fetchRewardsBalance(walletAddress) { if (simulation.error) throw new Error(simulation.error); if (!simulation.result) { - throw new Error("Soroban RPC returned no result for rewards balance."); + throw new Error('Soroban RPC returned no result for rewards balance.'); } return scValToNative(simulation.result.retval); @@ -184,19 +168,14 @@ export async function fetchRewardsBalance(walletAddress) { const TX_POLL_INTERVAL_MS = 1500; const TX_POLL_MAX_ATTEMPTS = 40; -/** - * Build, sign (Freighter), submit, and poll a `claim(user, amount)` call. - * - * Returns `{ hash: string, newBalance: string }` on success. - */ export async function submitClaimTransaction(walletAddress, amount) { - if (!REWARDS_CONTRACT_ID) { - throw new Error("Set VITE_REWARDS_CONTRACT_ID before claiming rewards."); + const contract = getRewardsContract(); + if (!contract) { + throw new Error('Set VITE_REWARDS_CONTRACT_ID before claiming rewards.'); } - const server = new rpc.Server(SOROBAN_RPC_URL); + const server = createSorobanServer(); const sourceAccount = await server.getAccount(walletAddress); - const contract = new Contract(REWARDS_CONTRACT_ID); /* 1. Build the transaction */ const tx = new TransactionBuilder(sourceAccount, { @@ -205,9 +184,9 @@ export async function submitClaimTransaction(walletAddress, amount) { }) .addOperation( contract.call( - "claim", + 'claim', nativeToScVal(Address.fromString(walletAddress)), - nativeToScVal(amount, { type: "u64" }), + nativeToScVal(amount, { type: 'u64' }), ), ) .setTimeout(30) @@ -233,9 +212,9 @@ export async function submitClaimTransaction(walletAddress, amount) { /* 5. Submit */ const sendResult = await server.sendTransaction(signedTx); - if (sendResult.status === "ERROR") { + if (sendResult.status === 'ERROR') { throw new Error( - sendResult.errorResult?.toString() || "Transaction submission failed.", + sendResult.errorResult?.toString() || 'Transaction submission failed.', ); } @@ -244,21 +223,19 @@ export async function submitClaimTransaction(walletAddress, amount) { for (let i = 0; i < TX_POLL_MAX_ATTEMPTS; i++) { // eslint-disable-next-line no-await-in-loop getResult = await server.getTransaction(sendResult.hash); - if (getResult.status !== "NOT_FOUND") break; + if (getResult.status !== 'NOT_FOUND') break; // eslint-disable-next-line no-await-in-loop await new Promise((r) => setTimeout(r, TX_POLL_INTERVAL_MS)); } - if (!getResult || getResult.status === "NOT_FOUND") { + if (!getResult || getResult.status === 'NOT_FOUND') { throw new Error( - "Transaction was submitted but could not be confirmed in time.", + 'Transaction was submitted but could not be confirmed in time.', ); } - if (getResult.status === "FAILED") { - throw new Error( - "Transaction failed on-chain. You may not have enough points.", - ); + if (getResult.status === 'FAILED') { + throw new Error('Transaction failed on-chain. You may not have enough points.'); } const newBalance = getResult.returnValue @@ -270,21 +247,14 @@ export async function submitClaimTransaction(walletAddress, amount) { /* ---------- campaign contract helpers ---------- */ -/** - * Simulate a read-only `is_participant(participant)` call. - * - * Returns `true` if the wallet is already registered, `false` otherwise. - */ export async function checkParticipantStatus(walletAddress) { - if (!CAMPAIGN_CONTRACT_ID) { - throw new Error( - "Set VITE_CAMPAIGN_CONTRACT_ID to check participant status.", - ); + const contract = getCampaignContract(); + if (!contract) { + throw new Error('Set VITE_CAMPAIGN_CONTRACT_ID to check participant status.'); } - const server = new rpc.Server(SOROBAN_RPC_URL); + const server = createSorobanServer(); const sourceAccount = await server.getAccount(walletAddress); - const contract = new Contract(CAMPAIGN_CONTRACT_ID); const tx = new TransactionBuilder(sourceAccount, { fee: BASE_FEE, @@ -292,7 +262,7 @@ export async function checkParticipantStatus(walletAddress) { }) .addOperation( contract.call( - "is_participant", + 'is_participant', nativeToScVal(Address.fromString(walletAddress)), ), ) @@ -316,13 +286,13 @@ export async function checkParticipantStatus(walletAddress) { * - `alreadyRegistered === true` means they were already registered (contract returned false). */ export async function submitRegisterTransaction(walletAddress) { - if (!CAMPAIGN_CONTRACT_ID) { - throw new Error("Set VITE_CAMPAIGN_CONTRACT_ID before registering."); + const contract = getCampaignContract(); + if (!contract) { + throw new Error('Set VITE_CAMPAIGN_CONTRACT_ID before registering.'); } - const server = new rpc.Server(SOROBAN_RPC_URL); + const server = createSorobanServer(); const sourceAccount = await server.getAccount(walletAddress); - const contract = new Contract(CAMPAIGN_CONTRACT_ID); // For open registration, pass empty leaf and proof const emptyLeaf = new Uint8Array(32); // 32 bytes of zeros @@ -335,7 +305,7 @@ export async function submitRegisterTransaction(walletAddress) { }) .addOperation( contract.call( - "register", + 'register', nativeToScVal(Address.fromString(walletAddress)), nativeToScVal(emptyLeaf), nativeToScVal(emptyProof), @@ -364,9 +334,9 @@ export async function submitRegisterTransaction(walletAddress) { /* 5. Submit */ const sendResult = await server.sendTransaction(signedTx); - if (sendResult.status === "ERROR") { + if (sendResult.status === 'ERROR') { throw new Error( - sendResult.errorResult?.toString() || "Registration transaction failed.", + sendResult.errorResult?.toString() || 'Registration transaction failed.', ); } @@ -375,19 +345,19 @@ export async function submitRegisterTransaction(walletAddress) { for (let i = 0; i < TX_POLL_MAX_ATTEMPTS; i++) { // eslint-disable-next-line no-await-in-loop getResult = await server.getTransaction(sendResult.hash); - if (getResult.status !== "NOT_FOUND") break; + if (getResult.status !== 'NOT_FOUND') break; // eslint-disable-next-line no-await-in-loop await new Promise((r) => setTimeout(r, TX_POLL_INTERVAL_MS)); } - if (!getResult || getResult.status === "NOT_FOUND") { + if (!getResult || getResult.status === 'NOT_FOUND') { throw new Error( - "Registration transaction was submitted but could not be confirmed in time.", + 'Registration transaction was submitted but could not be confirmed in time.', ); } - if (getResult.status === "FAILED") { - throw new Error("Registration transaction failed on-chain."); + if (getResult.status === 'FAILED') { + throw new Error('Registration transaction failed on-chain.'); } // Contract returns true for a fresh registration, false if already registered.