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.
+
+
+ - Campaigns API: {apiUrl('/api/v1/campaigns')}
+ - Soroban RPC: {SOROBAN_RPC_URL}
+ - Rewards contract: {rewardsContract ? REWARDS_CONTRACT_ID : 'Not configured'}
+ - Campaign contract: {campaignContract ? CAMPAIGN_CONTRACT_ID : 'Not configured'}
+
+
+
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.