diff --git a/README.md b/README.md index 60de66f..9239179 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ React TypeScript web application for the StepFi BNPL protocol on Stellar. **Live URL:** https://stepfi-web.vercel.app +[](https://github.com/StepFi-app/StepFi-Web/actions/workflows/ci.yml) + ## Stack Vite, React 18, TypeScript, Tailwind CSS, Zustand, TanStack Query, Freighter API diff --git a/netlify.toml b/netlify.toml index d15200d..79bd4fb 100644 --- a/netlify.toml +++ b/netlify.toml @@ -10,10 +10,12 @@ [[headers]] for = "/*" [headers.values] + Content-Security-Policy = "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' https://stepfi-api.onrender.com; font-src 'self'; frame-ancestors 'none'; form-action 'self'; base-uri 'self'" X-Frame-Options = "DENY" X-Content-Type-Options = "nosniff" Referrer-Policy = "strict-origin-when-cross-origin" Permissions-Policy = "camera=(), microphone=(), geolocation=()" + Strict-Transport-Security = "max-age=31536000; includeSubDomains; preload" [[headers]] for = "/assets/*" diff --git a/package-lock.json b/package-lock.json index 56ca604..e15be9a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@tanstack/react-query": "^5.101.0", "axios": "^1.18.0", "clsx": "^2.1.1", + "dompurify": "^3.4.11", "framer-motion": "^12.40.0", "lucide-react": "^1.18.0", "react": "^19.2.6", @@ -22,6 +23,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/dompurify": "^3.0.5", "@types/node": "^24.13.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", @@ -1036,6 +1038,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -1087,6 +1099,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.61.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.0.tgz", @@ -1952,6 +1971,15 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index c0f3b07..d3d94f8 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@tanstack/react-query": "^5.101.0", "axios": "^1.18.0", "clsx": "^2.1.1", + "dompurify": "^3.4.11", "framer-motion": "^12.40.0", "lucide-react": "^1.18.0", "react": "^19.2.6", @@ -24,6 +25,7 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", + "@types/dompurify": "^3.0.5", "@types/node": "^24.13.2", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", diff --git a/src/components/layout/Navbar.tsx b/src/components/layout/Navbar.tsx index 1625bd5..0fd68cc 100644 --- a/src/components/layout/Navbar.tsx +++ b/src/components/layout/Navbar.tsx @@ -40,7 +40,11 @@ export function Navbar() {
{vouch.message}
+{sanitizeText(vouch.message)}
)}{new Date(vouch.createdAt).toLocaleDateString()} diff --git a/src/pages/Sponsors.tsx b/src/pages/Sponsors.tsx index 3219e69..f3beb56 100644 --- a/src/pages/Sponsors.tsx +++ b/src/pages/Sponsors.tsx @@ -1,11 +1,230 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { sponsorsService } from '../services/sponsors.service' +import { useTransaction } from '../hooks/useTransaction' +import { useWallet } from '../hooks/useWallet' +import { Button } from '../components/ui/Button' +import { Card } from '../components/ui/Card' +import { Badge } from '../components/ui/Badge' +import { Spinner } from '../components/ui/Spinner' +import { + TrendingUp, + Wallet, + ArrowUpRight, + CheckCircle2, + AlertCircle, + ExternalLink +} from 'lucide-react' + export function Sponsors() { + const { isConnected } = useWallet() + const [shares, setShares] = useState('') + const [successData, setSuccessData] = useState<{ + hash: string + amount: number + profit: number + } | null>(null) + + const { data: poolInfo, isLoading: poolLoading } = useQuery({ + queryKey: ['poolInfo'], + queryFn: sponsorsService.getPoolInfo, + enabled: isConnected + }) + + const { execute, isLoading: txLoading, error: txError } = useTransaction() + + const handleWithdraw = async (e: React.FormEvent) => { + e.preventDefault() + if (!shares || isNaN(Number(shares))) return + + try { + const result = await execute( + () => sponsorsService.withdraw(Number(shares)), + (signedXdr) => sponsorsService.submitTransaction(signedXdr) + ) + setSuccessData(result) + setShares('') + } catch { + // Error handled by useTransaction and shown in UI + } + } + + if (!isConnected) { + return ( +
+ You need a Stellar wallet to manage your liquidity pool shares. +
+- Pool stats and deposit options coming soon. -
++ Manage your deposits and earn yield from learner loans. +
+Total Deposits
+Available Liquidity
+Total Shares
++ {poolLoading ? '...' : poolInfo?.totalShares.toLocaleString()} +
+Share Price
++ {poolLoading ? '...' : `${poolInfo?.sharePrice.toFixed(4)} USDC`} +
+Locked Capital
++ {poolLoading ? '...' : `${poolInfo?.lockedLiquidity.toLocaleString()} USDC`} +
+Status
+Active
+{product.description}
+{sanitizeText(product.description)}
)}${product.price.toLocaleString()}
@@ -341,7 +342,7 @@ function ApiKeySection({ >
diff --git a/src/services/api.ts b/src/services/api.ts
index 8993a30..1d0937a 100644
--- a/src/services/api.ts
+++ b/src/services/api.ts
@@ -8,10 +8,25 @@ export const api = axios.create({
},
})
+const TOKEN_STORAGE_KEY = 'stepfi-user'
+
+function getStoredTokens(): { accessToken?: string; refreshToken?: string } {
+ try {
+ const raw = localStorage.getItem(TOKEN_STORAGE_KEY)
+ if (!raw) return {}
+ const parsed = JSON.parse(raw)
+ const state = parsed?.state
+ if (!state) return {}
+ return { accessToken: state.accessToken, refreshToken: state.refreshToken }
+ } catch {
+ return {}
+ }
+}
+
api.interceptors.request.use((config) => {
- const token = localStorage.getItem('accessToken')
- if (token) {
- config.headers.Authorization = `Bearer ${token}`
+ const { accessToken } = getStoredTokens()
+ if (accessToken) {
+ config.headers.Authorization = `Bearer ${accessToken}`
}
return config
})
@@ -20,8 +35,7 @@ api.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401) {
- localStorage.removeItem('accessToken')
- localStorage.removeItem('refreshToken')
+ localStorage.removeItem(TOKEN_STORAGE_KEY)
window.location.href = '/'
}
return Promise.reject(error)
diff --git a/src/services/sponsors.service.ts b/src/services/sponsors.service.ts
new file mode 100644
index 0000000..08db6be
--- /dev/null
+++ b/src/services/sponsors.service.ts
@@ -0,0 +1,23 @@
+import { api } from './api'
+import type { PoolInfo } from '../types'
+
+export const sponsorsService = {
+ getPoolInfo: async (): Promise