Skip to content
Open
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
145 changes: 9 additions & 136 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
import {
debounce,
filterBounties,
validateStellarPublicKey,
} from "./utils";
import {
type Bounty,
Expand All @@ -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";

Expand Down Expand Up @@ -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<BountyStatus | "all"> = [
"all",
"reserved",
Expand All @@ -121,16 +103,12 @@ function App() {
const { dark, toggle: toggleDark } = useDarkMode();
const freighter = useFreighter();
const initialFilters = useMemo(() => readInitialFilters(), []);
const [form, setForm] = useState<CreateBountyPayload>(initialForm);
const [bounties, setBounties] = useState<Bounty[]>([]);
const [, setIssues] = useState<OpenIssue[]>([]);
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);
Expand Down Expand Up @@ -605,41 +583,14 @@ function App() {
);
}

async function handleCreate(event: FormEvent<HTMLFormElement>) {
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;
}
}

Expand Down Expand Up @@ -669,88 +620,10 @@ function App() {
A decentralized bounty platform powered by Stellar. Reserve tasks, submit solutions,
and get paid instantly.
</p>
<form className="bounty-form" onSubmit={handleCreate}>
<div className="form-row">
<label>
Repository
<input
value={form.repo}
onChange={(e) => {
setForm({ ...form, repo: e.target.value });
setIsFormDirty(true);
}}
placeholder="owner/repo"
/>
</label>
<label>
Issue #
<input
type="number"
value={form.issueNumber}
onChange={(e) => {
setForm({ ...form, issueNumber: Number(e.target.value) });
setIsFormDirty(true);
}}
/>
</label>
</div>
<div className="form-row">
<label>
Title
<input
value={form.title}
onChange={(e) => {
setForm({ ...form, title: e.target.value });
setIsFormDirty(true);
}}
placeholder="Add WebSocket updates..."
/>
</label>
</div>
<div className="form-row">
<label>
Reward
<input
type="number"
value={form.amount}
onChange={(e) => {
setForm({ ...form, amount: Number(e.target.value) });
setIsFormDirty(true);
}}
/>
</label>
<label>
Asset
<select
value={form.tokenSymbol}
onChange={(e) => {
setForm({ ...form, tokenSymbol: e.target.value });
setIsFormDirty(true);
}}
>
<option value="XLM">XLM</option>
<option value="USDC">USDC</option>
</select>
</label>
</div>
<div className="form-actions">
<button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Bounty"}
</button>
{isFormDirty && (
<button
type="button"
className="ghost-button"
onClick={() => {
setForm(initialForm);
setIsFormDirty(false);
}}
>
Discard
</button>
)}
</div>
</form>
<BountyCreationForm
onSubmit={handleCreateBounty}
onDirtyChange={setIsFormDirty}
/>
</div>
</div>
</section>
Expand Down
160 changes: 160 additions & 0 deletions frontend/src/BountyCreationForm.tsx
Original file line number Diff line number Diff line change
@@ -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<void>;
onDirtyChange?: (isDirty: boolean) => void;
}

export default function BountyCreationForm({ onSubmit, onDirtyChange }: BountyCreationFormProps) {
const [form, setForm] = useState<CreateBountyPayload>(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<HTMLFormElement>) {
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 (
<form className="bounty-form" onSubmit={handleCreate}>
<div className="form-row">
<label>
Repository
<input
value={form.repo}
onChange={(e) => {
setForm({ ...form, repo: e.target.value });
updateDirtyState(true);
}}
placeholder="owner/repo"
/>
</label>
<label>
Issue #
<input
type="number"
value={form.issueNumber}
onChange={(e) => {
setForm({ ...form, issueNumber: Number(e.target.value) });
updateDirtyState(true);
}}
/>
</label>
</div>
<div className="form-row">
<label>
Title
<input
value={form.title}
onChange={(e) => {
setForm({ ...form, title: e.target.value });
updateDirtyState(true);
}}
placeholder="Add WebSocket updates..."
/>
</label>
</div>
<div className="form-row">
<label>
Reward
<input
type="number"
value={form.amount}
onChange={(e) => {
setForm({ ...form, amount: Number(e.target.value) });
updateDirtyState(true);
}}
/>
</label>
<label>
Asset
<select
value={form.tokenSymbol}
onChange={(e) => {
setForm({ ...form, tokenSymbol: e.target.value });
updateDirtyState(true);
}}
>
<option value="XLM">XLM</option>
<option value="USDC">USDC</option>
</select>
</label>
</div>
<div className="form-actions">
<button type="submit" disabled={submitting}>
{submitting ? "Creating..." : "Create Bounty"}
</button>
{isFormDirty && (
<button
type="button"
className="ghost-button"
onClick={() => {
setForm(initialForm);
updateDirtyState(false);
}}
>
Discard
</button>
)}
</div>
</form>
);
}
8 changes: 8 additions & 0 deletions frontend/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,3 +324,11 @@ export async function getXlmRate(): Promise<number | null> {

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;
}
Loading