Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,2 +1,20 @@
# StepFi Web — environment configuration
# Copy to `.env` (or `.env.local`) and adjust per environment. Every value has a
# testnet default in src/constants/config.ts, so all of these are optional
# locally; set them to target staging/mainnet without touching source.

# Backend API base URL (includes the /api/v1 prefix).
VITE_API_BASE_URL=https://stepfi-api.onrender.com/api/v1

# Stellar network: TESTNET or PUBLIC.
VITE_STELLAR_NETWORK=TESTNET

# Soroban RPC endpoint.
VITE_SOROBAN_RPC_URL=https://soroban-testnet.stellar.org

# Deployed contract IDs (override when pointing at a different deployment).
VITE_CONTRACT_CREDITLINE=CAQDHYG3TALPNXG466SZUMJEPOI7VYV732LPFF3GHE4ASPBCNMIQBS3X
VITE_CONTRACT_REPUTATION=CC3BO57ZRJGA63QJBIBSOMI25Z3X2I5CYTARYRAUXUAILX6L3OWBL5SB
VITE_CONTRACT_LIQUIDITY_POOL=CACKE7ML2BTOAGQTAAW5NEARHCFX4PXXKGEO6GMU6NHFBVYQFZRJS2BT
VITE_CONTRACT_VENDOR_REGISTRY=CCZ6T6NYCDNI26VGTPXKKWQDR7JCIZZ24LCEG4MMYHZJAG6BPWIVAU2L
VITE_CONTRACT_PARAMETERS=CCAE72SKYX55C5L56DBEFIMFVXRUIJY6JYLBREHEWRFNOW7AX5NBIJ5B
17 changes: 14 additions & 3 deletions src/constants/config.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,36 @@
// Runtime configuration. Every environment-specific value is read from a
// `VITE_*` build-time env var with a testnet-friendly default, so the app runs
// out of the box locally yet can be pointed at staging/mainnet purely through
// environment — no source edits. See `.env.example` for the full list.

export const API_BASE_URL =
'https://stepfi-api.onrender.com/api/v1'
import.meta.env?.VITE_API_BASE_URL || 'https://stepfi-api.onrender.com/api/v1'

export const STELLAR_NETWORK = 'TESTNET'
export const STELLAR_NETWORK =
import.meta.env?.VITE_STELLAR_NETWORK || 'TESTNET'

export const SOROBAN_RPC_URL =
import.meta.env?.VITE_SOROBAN_RPC_URL || 'https://soroban-testnet.stellar.org'


export const CONTRACT_IDS = {
creditline:
import.meta.env?.VITE_CONTRACT_CREDITLINE ||
'CAQDHYG3TALPNXG466SZUMJEPOI7VYV732LPFF3GHE4ASPBCNMIQBS3X',
reputation:
import.meta.env?.VITE_CONTRACT_REPUTATION ||
'CC3BO57ZRJGA63QJBIBSOMI25Z3X2I5CYTARYRAUXUAILX6L3OWBL5SB',
liquidityPool:
import.meta.env?.VITE_CONTRACT_LIQUIDITY_POOL ||
'CACKE7ML2BTOAGQTAAW5NEARHCFX4PXXKGEO6GMU6NHFBVYQFZRJS2BT',
vendorRegistry:
import.meta.env?.VITE_CONTRACT_VENDOR_REGISTRY ||
'CCZ6T6NYCDNI26VGTPXKKWQDR7JCIZZ24LCEG4MMYHZJAG6BPWIVAU2L',
parameters:
import.meta.env?.VITE_CONTRACT_PARAMETERS ||
'CCAE72SKYX55C5L56DBEFIMFVXRUIJY6JYLBREHEWRFNOW7AX5NBIJ5B',
}

// Public program links — fixed StepFi properties, not environment-specific.
export const GRANTFOX_URL =
'https://contribute.grantfox.xyz/org/StepFi-app'

Expand Down
5 changes: 2 additions & 3 deletions src/hooks/useOptimisticVouch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,14 @@ import type { VouchRequest, ActiveVouch } from '../types'

interface SubmitVouchParams {
learnerAddress: string
txHash?: string
}

export function useSubmitVouch() {
const queryClient = useQueryClient()

return useMutation({
mutationFn: ({ learnerAddress, txHash }: SubmitVouchParams) =>
vouchingService.submitVouch(learnerAddress, txHash),
mutationFn: ({ learnerAddress }: SubmitVouchParams) =>
vouchingService.submitVouch(learnerAddress),
onMutate: async ({ learnerAddress }) => {
await queryClient.cancelQueries({ queryKey: queryKeys.vouches.requests() })
await queryClient.cancelQueries({ queryKey: queryKeys.vouches.myVouches() })
Expand Down
36 changes: 9 additions & 27 deletions src/pages/MentorDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
Clock, DollarSign, Percent, Ban, ExternalLink, XCircle,
UserCheck, TrendingUp, Wallet,
} from 'lucide-react'
import { signTransaction, isConnected, requestAccess } from '@stellar/freighter-api'
import { useSubmitVouch, useRevokeVouch } from '../hooks/useOptimisticVouch'
import { useMentor } from '../hooks/useMentor'
import { Card } from '../components/ui/Card'
Expand All @@ -16,7 +15,6 @@ import { VouchRequestCard } from '../components/vouch/VouchRequestCard'
import { VouchImpactPreview } from '../components/vouch/VouchImpactPreview'
import { useWallet } from '../hooks/useWallet'
import { useToast } from '../hooks/useToast'
import { STELLAR_NETWORK } from '../constants/config'
import type { VouchRequest } from '../types'

const REPAYMENT_VARIANTS: Record<string, 'green' | 'blue' | 'amber' | 'red' | 'muted'> = {
Expand Down Expand Up @@ -209,35 +207,19 @@ export function MentorDashboard() {
if (!previewRequest) return

try {
// A mentor must be connected + authenticated (wallet-based JWT) to approve.
if (!walletConnected) {
await connectFreighter()
}

const connection = await isConnected()
if (!connection.isConnected) {
throw new Error('Freighter not installed. Download at freighter.app')
}

const access = await requestAccess()
if (access.error) {
throw new Error(access.error.message)
}

const txXdr = `AAAAAgAAAABz...${Math.random().toString(36).slice(2)}`
const result = await signTransaction(txXdr, {
networkPassphrase:
STELLAR_NETWORK === 'TESTNET'
? 'Test SDF Network ; September 2015'
: 'Public Global Stellar Network ; September 2015',
})

const txHash = 'signedTxXdr' in result ? (result as { signedTxXdr: string }).signedTxXdr : ''

// Approving a vouch is an authenticated backend operation: POST
// /vouching/approve transitions the vouch record from PENDING to APPROVED
// using the mentor's JWT identity. It is intentionally off-chain today, so
// there is no transaction for the mentor to sign here. If on-chain vouching
// is wired up later, route it through useTransaction + a backend-built XDR
// the way deposits do — never a client-fabricated one.
submitMutation.mutate(
{
learnerAddress: previewRequest.learnerAddress,
txHash,
},
{ learnerAddress: previewRequest.learnerAddress },
{
onSuccess: () => {
setPreviewRequest(null)
Expand All @@ -250,7 +232,7 @@ export function MentorDashboard() {
}
)
} catch (err) {
const message = err instanceof Error ? err.message : 'Transaction failed'
const message = err instanceof Error ? err.message : 'Failed to submit vouch.'
toast.error(message)
}
}
Expand Down
36 changes: 9 additions & 27 deletions src/pages/Vouch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import { ClipboardList, ShieldCheck, Award, AlertTriangle, RotateCw, Clock, DollarSign, Percent, Ban, ExternalLink, XCircle } from 'lucide-react'
import { signTransaction, isConnected, requestAccess } from '@stellar/freighter-api'
import { vouchingService } from '../services/vouching.service'
import { queryKeys } from '../services/queryKeys'
import { useSubmitVouch, useRevokeVouch } from '../hooks/useOptimisticVouch'
Expand All @@ -14,7 +13,6 @@ import { VouchRequestCard } from '../components/vouch/VouchRequestCard'
import { VouchImpactPreview } from '../components/vouch/VouchImpactPreview'
import { useWallet } from '../hooks/useWallet'
import { useToast } from '../hooks/useToast'
import { STELLAR_NETWORK } from '../constants/config'
import type { VouchRequest } from '../types'

const REPAYMENT_VARIANTS: Record<string, 'green' | 'blue' | 'amber' | 'red' | 'muted'> = {
Expand Down Expand Up @@ -118,35 +116,19 @@ export function Vouch() {
if (!previewRequest) return

try {
// A mentor must be connected + authenticated (wallet-based JWT) to approve.
if (!walletConnected) {
await connectFreighter()
}

const connection = await isConnected()
if (!connection.isConnected) {
throw new Error('Freighter not installed. Download at freighter.app')
}

const access = await requestAccess()
if (access.error) {
throw new Error(access.error.message)
}

const txXdr = `AAAAAgAAAABz...${Math.random().toString(36).slice(2)}`
const result = await signTransaction(txXdr, {
networkPassphrase:
STELLAR_NETWORK === 'TESTNET'
? 'Test SDF Network ; September 2015'
: 'Public Global Stellar Network ; September 2015',
})

const txHash = 'signedTxXdr' in result ? (result as { signedTxXdr: string }).signedTxXdr : ''

// Approving a vouch is an authenticated backend operation: POST
// /vouching/approve transitions the vouch record from PENDING to APPROVED
// using the mentor's JWT identity. It is intentionally off-chain today, so
// there is no transaction for the mentor to sign here. If on-chain vouching
// is wired up later, route it through useTransaction + a backend-built XDR
// the way deposits do — never a client-fabricated one.
submitMutation.mutate(
{
learnerAddress: previewRequest.learnerAddress,
txHash,
},
{ learnerAddress: previewRequest.learnerAddress },
{
onSuccess: () => {
setPreviewRequest(null)
Expand All @@ -159,7 +141,7 @@ export function Vouch() {
}
)
} catch (err) {
const message = err instanceof Error ? err.message : 'Transaction failed'
const message = err instanceof Error ? err.message : 'Failed to submit vouch.'
toast.error(message)
}
}
Expand Down
91 changes: 58 additions & 33 deletions src/router/index.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,32 @@
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom'
import { lazy, Suspense } from 'react'
import type { ReactNode } from 'react'
import { Layout } from '../components/layout/Layout'
import { Home } from '../pages/Home'
import { Docs } from '../pages/Docs'
import { Contracts } from '../pages/Contracts'
import { Dashboard } from '../pages/Dashboard'
import { Vendors } from '../pages/Vendors'
import { VendorDetail } from '../pages/VendorDetail'
import { VendorRegister } from '../pages/VendorRegister'
import { VendorDashboard } from '../pages/VendorDashboard'
import { Sponsors } from '../pages/Sponsors'
import { SponsorOnboarding } from '../pages/SponsorOnboarding'
import { Vouch } from '../pages/Vouch'
import { MentorDashboard } from '../pages/MentorDashboard'
import { LearnerProfile } from '../pages/LearnerProfile'
import { NotFound } from '../pages/NotFound'
import { History } from '../pages/History'
import { RoleSelect } from '../pages/RoleSelect'
import { Spinner } from '../components/ui/Spinner'
import { useRoleStore } from '../stores/role.store'
import type { UserRole } from '../stores/role.store'
import { useWallet } from '../hooks/useWallet'
import type { ReactNode } from 'react'

// Route pages are code-split so heavy per-page dependencies (e.g. the Stellar
// SDK pulled in by the dashboard/vouch flows) load on demand instead of
// inflating the initial bundle. Pages use named exports, so each is mapped to a
// default export for React.lazy.
const Home = lazy(() => import('../pages/Home').then((m) => ({ default: m.Home })))
const Docs = lazy(() => import('../pages/Docs').then((m) => ({ default: m.Docs })))
const Contracts = lazy(() => import('../pages/Contracts').then((m) => ({ default: m.Contracts })))
const Dashboard = lazy(() => import('../pages/Dashboard').then((m) => ({ default: m.Dashboard })))
const Vendors = lazy(() => import('../pages/Vendors').then((m) => ({ default: m.Vendors })))
const VendorDetail = lazy(() => import('../pages/VendorDetail').then((m) => ({ default: m.VendorDetail })))
const VendorRegister = lazy(() => import('../pages/VendorRegister').then((m) => ({ default: m.VendorRegister })))
const VendorDashboard = lazy(() => import('../pages/VendorDashboard').then((m) => ({ default: m.VendorDashboard })))
const Sponsors = lazy(() => import('../pages/Sponsors').then((m) => ({ default: m.Sponsors })))
const SponsorOnboarding = lazy(() => import('../pages/SponsorOnboarding').then((m) => ({ default: m.SponsorOnboarding })))
const Vouch = lazy(() => import('../pages/Vouch').then((m) => ({ default: m.Vouch })))
const MentorDashboard = lazy(() => import('../pages/MentorDashboard').then((m) => ({ default: m.MentorDashboard })))
const LearnerProfile = lazy(() => import('../pages/LearnerProfile').then((m) => ({ default: m.LearnerProfile })))
const NotFound = lazy(() => import('../pages/NotFound').then((m) => ({ default: m.NotFound })))
const History = lazy(() => import('../pages/History').then((m) => ({ default: m.History })))
const RoleSelect = lazy(() => import('../pages/RoleSelect').then((m) => ({ default: m.RoleSelect })))

function RoleGuard({
allowedRole,
Expand All @@ -43,70 +50,88 @@ function RoleGuard({
return <>{children}</>
}

// Layout (Navbar + Footer) renders immediately; only the lazily-loaded page
// content suspends, so the navigation chrome never flashes on route changes.
function page(node: ReactNode) {
return (
<Layout>
<Suspense
fallback={
<div className="flex items-center justify-center py-32">
<Spinner size={28} />
</div>
}
>
{node}
</Suspense>
</Layout>
)
}

const router = createBrowserRouter([
{
path: '/',
element: <Layout><Home /></Layout>,
element: page(<Home />),
},
{
path: '/docs',
element: <Layout><Docs /></Layout>,
element: page(<Docs />),
},
{
path: '/contracts',
element: <Layout><Contracts /></Layout>,
element: page(<Contracts />),
},
{
path: '/role-select',
element: <Layout><RoleSelect /></Layout>,
element: page(<RoleSelect />),
},
{
path: '/dashboard',
element: <Layout><Dashboard /></Layout>,
element: page(<Dashboard />),
},
{
path: '/vendors',
element: <Layout><Vendors /></Layout>,
element: page(<Vendors />),
},
{
path: '/vendors/dashboard',
element: <Layout><RoleGuard allowedRole="vendor"><VendorDashboard /></RoleGuard></Layout>,
element: page(<RoleGuard allowedRole="vendor"><VendorDashboard /></RoleGuard>),
},
{
path: '/vendors/register',
element: <Layout><RoleGuard allowedRole="vendor"><VendorRegister /></RoleGuard></Layout>,
element: page(<RoleGuard allowedRole="vendor"><VendorRegister /></RoleGuard>),
},
{
path: '/vendors/:id',
element: <Layout><VendorDetail /></Layout>,
element: page(<VendorDetail />),
},
{
path: '/sponsors',
element: <Layout><RoleGuard allowedRole="sponsor"><Sponsors /></RoleGuard></Layout>,
element: page(<RoleGuard allowedRole="sponsor"><Sponsors /></RoleGuard>),
},
{
path: '/sponsors/onboarding',
element: <Layout><RoleGuard allowedRole="sponsor"><SponsorOnboarding /></RoleGuard></Layout>,
element: page(<RoleGuard allowedRole="sponsor"><SponsorOnboarding /></RoleGuard>),
},
{
path: '/mentor',
element: <Layout><RoleGuard allowedRole="mentor"><MentorDashboard /></RoleGuard></Layout>,
element: page(<RoleGuard allowedRole="mentor"><MentorDashboard /></RoleGuard>),
},
{
path: '/vouch',
element: <Layout><RoleGuard allowedRole="mentor"><Vouch /></RoleGuard></Layout>,
element: page(<RoleGuard allowedRole="mentor"><Vouch /></RoleGuard>),
},
{
path: '/learner/:walletAddress',
element: <Layout><LearnerProfile /></Layout>,
element: page(<LearnerProfile />),
},
{
path: '/history',
element: <Layout><History /></Layout>,
element: page(<History />),
},
{
path: '*',
element: <Layout><NotFound /></Layout>,
element: page(<NotFound />),
},
])

Expand Down
Loading
Loading