diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 4f9e0867..53f3a7b4 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -36,6 +36,7 @@ import {
import {
debounce,
filterBounties,
+ validateStellarPublicKey,
} from "./utils";
import {
type Bounty,
@@ -53,6 +54,7 @@ import ContributorProfilePage from "./ContributorProfilePage";
import ContributorDashboard from "./ContributorDashboard";
import ErrorBoundary from "./ErrorBoundary";
import SubmissionChecklistModal, { type SubmissionFormData } from "./SubmissionChecklistModal";
+import BountyCreationForm from "./BountyCreationForm";
const DARK_MODE_KEY = "stellar-bounty-board-theme";
@@ -80,26 +82,6 @@ function useDarkMode() {
return { dark, toggle: () => setDark((d) => !d) };
}
-const initialForm: CreateBountyPayload = {
- repo: "ritik4ever/stellar-stream",
- issueNumber: 48,
- title: "",
- summary: "",
- maintainer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
- tokenSymbol: "XLM",
- amount: 150,
- deadlineDays: 14,
- labels: [{ name: "help wanted", color: "0075ca" }],
-};
-
-function validateStellarPublicKey(input: string): string | null {
- const value = input.trim();
- if (!value) return "Address is required.";
- if (!/^G[A-Z0-9]{55}$/.test(value))
- return "Enter a Stellar public key (starts with 'G', 56 characters)";
- return null;
-}
-
const contributorStatuses: Array = [
"all",
"reserved",
@@ -121,16 +103,12 @@ function App() {
const { dark, toggle: toggleDark } = useDarkMode();
const freighter = useFreighter();
const initialFilters = useMemo(() => readInitialFilters(), []);
- const [form, setForm] = useState(initialForm);
const [bounties, setBounties] = useState([]);
const [, setIssues] = useState([]);
const [loading, setLoading] = useState(true);
- const [submitting, setSubmitting] = useState(false);
const [showShortcutsOverlay, setShowShortcutsOverlay] = useState(false);
const [isFormDirty, setIsFormDirty] = useState(false);
- useBeforeUnload(isFormDirty);
-
useEffect(() => {
function goOnline() {
// setIsOffline(false);
@@ -605,41 +583,14 @@ function App() {
);
}
- async function handleCreate(event: FormEvent) {
- event.preventDefault();
- setSubmitting(true);
+ async function handleCreateBounty(payload: CreateBountyPayload) {
try {
- // Validate required fields
- if (!form.repo.trim()) {
- toast.error("Repository is required.");
- return;
- }
- if (!form.title.trim()) {
- toast.error("Title is required.");
- return;
- }
- if (form.amount <= 0) {
- toast.error("Reward amount must be greater than 0.");
- return;
- }
- const maintainerError = validateStellarPublicKey(form.maintainer);
- if (maintainerError) {
- toast.error(`Maintainer address: ${maintainerError}`);
- return;
- }
- await createBounty({
- ...form,
- maintainer: form.maintainer.trim(),
- labels: form.labels.filter(Boolean),
- });
- setForm({ ...initialForm, issueNumber: form.issueNumber + 1 });
- setIsFormDirty(false);
+ await createBounty(payload);
await refresh();
toast.success("Bounty created successfully!");
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to create bounty.");
- } finally {
- setSubmitting(false);
+ throw err;
}
}
@@ -669,88 +620,10 @@ function App() {
A decentralized bounty platform powered by Stellar. Reserve tasks, submit solutions,
and get paid instantly.
-
+
diff --git a/frontend/src/BountyCreationForm.tsx b/frontend/src/BountyCreationForm.tsx
new file mode 100644
index 00000000..41d711bf
--- /dev/null
+++ b/frontend/src/BountyCreationForm.tsx
@@ -0,0 +1,160 @@
+import React, { useState, type FormEvent } from "react";
+import { toast } from "sonner";
+import { type CreateBountyPayload } from "./types";
+import { validateStellarPublicKey } from "./utils";
+import { useBeforeUnload } from "./useBeforeUnload";
+
+const initialForm: CreateBountyPayload = {
+ repo: "ritik4ever/stellar-stream",
+ issueNumber: 48,
+ title: "",
+ summary: "",
+ maintainer: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF",
+ tokenSymbol: "XLM",
+ amount: 150,
+ deadlineDays: 14,
+ labels: [{ name: "help wanted", color: "0075ca" }],
+};
+
+interface BountyCreationFormProps {
+ onSubmit: (payload: CreateBountyPayload) => Promise;
+ onDirtyChange?: (isDirty: boolean) => void;
+}
+
+export default function BountyCreationForm({ onSubmit, onDirtyChange }: BountyCreationFormProps) {
+ const [form, setForm] = useState(initialForm);
+ const [submitting, setSubmitting] = useState(false);
+ const [isFormDirty, setIsFormDirty] = useState(false);
+
+ useBeforeUnload(isFormDirty);
+
+ const updateDirtyState = (dirty: boolean) => {
+ setIsFormDirty(dirty);
+ onDirtyChange?.(dirty);
+ };
+
+ async function handleCreate(event: FormEvent) {
+ event.preventDefault();
+ setSubmitting(true);
+ try {
+ // Validate required fields
+ if (!form.repo.trim()) {
+ toast.error("Repository is required.");
+ return;
+ }
+ if (!form.title.trim()) {
+ toast.error("Title is required.");
+ return;
+ }
+ if (form.amount <= 0) {
+ toast.error("Reward amount must be greater than 0.");
+ return;
+ }
+ const maintainerError = validateStellarPublicKey(form.maintainer);
+ if (maintainerError) {
+ toast.error(`Maintainer address: ${maintainerError}`);
+ return;
+ }
+
+ const payload = {
+ ...form,
+ maintainer: form.maintainer.trim(),
+ labels: form.labels.filter(Boolean),
+ };
+
+ await onSubmit(payload);
+
+ setForm({ ...initialForm, issueNumber: form.issueNumber + 1 });
+ updateDirtyState(false);
+ } catch (err) {
+ // Allow parent to handle/toast error, keep form state
+ } finally {
+ setSubmitting(false);
+ }
+ }
+
+ return (
+
+ );
+}
diff --git a/frontend/src/ContributorProfilePage.tsx b/frontend/src/ContributorProfilePage.tsx
index b88a2dbd..aecc96b7 100644
--- a/frontend/src/ContributorProfilePage.tsx
+++ b/frontend/src/ContributorProfilePage.tsx
@@ -22,6 +22,39 @@ async function fetchLeaderboard(): Promise {
return body.data ?? [];
}
+type Reputation = {
+ score: number;
+};
+
+type Badge = {
+ id: string;
+ name: string;
+ imageUrl?: string;
+ description?: string;
+};
+
+async function fetchReputation(address: string): Promise {
+ try {
+ const res = await fetch(`/api/reputation?contributor=${encodeURIComponent(address)}`);
+ if (!res.ok) return null;
+ const body = await res.json();
+ return body.data ?? null;
+ } catch (err) {
+ return null;
+ }
+}
+
+async function fetchBadges(address: string): Promise {
+ try {
+ const res = await fetch(`/api/badges?contributor=${encodeURIComponent(address)}`);
+ if (!res.ok) return [];
+ const body = await res.json();
+ return body.data ?? [];
+ } catch (err) {
+ return [];
+ }
+}
+
export default function ContributorProfilePage({
address,
onBack,
@@ -32,10 +65,26 @@ export default function ContributorProfilePage({
const [bounties, setBounties] = useState([]);
const [allBounties, setAllBounties] = useState([]);
const [leaderboard, setLeaderboard] = useState([]);
+ const [reputation, setReputation] = useState(null);
+ const [badges, setBadges] = useState([]);
+ const [loadingReputation, setLoadingReputation] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let active = true;
+ setLoadingReputation(true);
+
+ Promise.all([
+ fetchReputation(address),
+ fetchBadges(address)
+ ]).then(([repData, badgesData]) => {
+ if (active) {
+ setReputation(repData);
+ setBadges(badgesData);
+ setLoadingReputation(false);
+ }
+ });
+
void fetchContributorBounties(address)
.then((data) => {
if (active) setBounties(data);
@@ -105,6 +154,41 @@ export default function ContributorProfilePage({
{error && {error}
}
+
+ Reputation & Badges
+ {loadingReputation ? (
+
+ Loading reputation...
+
+ ) : (!reputation && badges.length === 0) ? (
+ No reputation data yet. Complete bounties to earn reputation!
+ ) : (
+
+ {reputation && (
+
+ Reputation Score:
+ {reputation.score}
+
+ )}
+
+ {badges.length > 0 && (
+
+ {badges.map((b) => (
+
+ {b.imageUrl ? (
+

+ ) : (
+
🏆
+ )}
+
{b.name}
+
+ ))}
+
+ )}
+
+ )}
+
+
diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts
index 8da1bc4b..1f3a61ce 100644
--- a/frontend/src/utils.ts
+++ b/frontend/src/utils.ts
@@ -324,3 +324,11 @@ export async function getXlmRate(): Promise {
return pendingRequest;
}
+
+export function validateStellarPublicKey(input: string): string | null {
+ const value = input.trim();
+ if (!value) return "Address is required.";
+ if (!/^G[A-Z0-9]{55}$/.test(value))
+ return "Enter a Stellar public key (starts with 'G', 56 characters)";
+ return null;
+}