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
61 changes: 34 additions & 27 deletions frontend/src/ClaimRewards.jsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,17 @@
import { useId, useState } from 'react';
import { submitClaimTransaction, normalizeError, getStellarNetwork } from './stellar';
import { submitClaimTransaction, getStellarNetwork } from './stellar';
import TransactionStatus from './components/TransactionStatus';
import { useOptimisticAction } from './hooks/useOptimisticAction';

/**
* ClaimRewards — lets the user enter a points amount, sign a Soroban
* `claim(user, amount)` transaction via Freighter, and see the result.
*
* The submit is optimistic: the input clears and a pending indicator appears
* immediately; on confirmation the parent reconciles the on-chain balance, and
* on failure the entered amount is restored and a class-aware error is shown.
* A second submit while one is in flight is ignored (double-submit guard).
*
* Props
* ─────
* @param {string} walletAddress – Connected Stellar public key.
Expand All @@ -15,40 +21,35 @@ import TransactionStatus from './components/TransactionStatus';
*/
export default function ClaimRewards({ walletAddress, onClaimSuccess }) {
const [amount, setAmount] = useState('');
const [isClaiming, setIsClaiming] = useState(false);
const [txHash, setTxHash] = useState('');
const [claimError, setClaimError] = useState('');
const amountId = useId();
const headingId = useId();
const feedbackId = useId();
const feedbackDescribedBy = txHash || claimError ? feedbackId : undefined;
const stellarNetwork = getStellarNetwork();
const { run, isPending, isError, error } = useOptimisticAction();

const parsedAmount = Number(amount);
const isValid = Number.isInteger(parsedAmount) && parsedAmount > 0;
const feedbackDescribedBy = txHash || isError ? feedbackId : undefined;

const handleClaim = async (event) => {
event.preventDefault();
if (!walletAddress || !isValid) return;

setIsClaiming(true);
setClaimError('');
setTxHash('');
const submittedAmount = amount;

try {
const { hash, newBalance } = await submitClaimTransaction(walletAddress, parsedAmount);

setTxHash(hash);
setAmount('');

if (onClaimSuccess) {
onClaimSuccess(newBalance);
}
} catch (error) {
setClaimError(normalizeError(error));
} finally {
setIsClaiming(false);
}
await run(() => submitClaimTransaction(walletAddress, parsedAmount), {
// Optimistic: clear the input right away so the action feels instant.
optimistic: () => setAmount(''),
// Rollback: restore the amount the user entered if the claim fails.
rollback: () => setAmount(submittedAmount),
// Reconcile: surface the tx + let the parent refresh the chain balance.
reconcile: ({ hash, newBalance }) => {
setTxHash(hash);
onClaimSuccess?.(newBalance);
},
});
};

return (
Expand All @@ -70,26 +71,32 @@ export default function ClaimRewards({ walletAddress, onClaimSuccess }) {
placeholder="e.g. 100"
className="claim-input"
value={amount}
disabled={isClaiming || !walletAddress}
aria-invalid={Boolean(claimError)}
disabled={isPending || !walletAddress}
aria-invalid={isError}
aria-describedby={feedbackDescribedBy}
onChange={(e) => setAmount(e.target.value)}
/>
<button
type="submit"
className="btn btn-primary btn-button"
disabled={!walletAddress || !isValid || isClaiming}
disabled={!walletAddress || !isValid || isPending}
>
{isClaiming ? 'Signing…' : 'Claim'}
{isPending ? 'Signing…' : 'Claim'}
</button>
</div>
</form>

{txHash && <TransactionStatus hash={txHash} network={stellarNetwork} />}
{isPending && (
<TransactionStatus variant="pending" network={stellarNetwork} status="Claiming…" />
)}
{!isPending && txHash && (
<TransactionStatus hash={txHash} network={stellarNetwork} status="Claim confirmed" />
)}

{claimError && (
{isError && error && (
<p id={feedbackId} className="claim-error" role="alert">
{claimError}
{error.message}
{error.recovery ? ` ${error.recovery}.` : ''}
</p>
)}
</section>
Expand Down
87 changes: 53 additions & 34 deletions frontend/src/RegisterCampaign.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,47 +7,53 @@ import {
getStellarNetwork,
} from './stellar';
import TransactionStatus from './components/TransactionStatus';
import { useOptimisticAction } from './hooks/useOptimisticAction';

/**
* RegisterCampaign — lets the connected wallet register as a campaign
* participant by calling the campaign contract's `register(participant)`.
*
* The submit flow is optimistic: the participant status flips to "Registered"
* the instant the user clicks, then either confirms on success or rolls back to
* the previous status on failure (with a class-aware error). A second click
* while a registration is in flight is ignored (double-submit guard).
*
* Props
* ─────
* @param {string} walletAddress – Connected Stellar public key.
*/
export default function RegisterCampaign({ walletAddress, onRegistered }) {
const [isRegistered, setIsRegistered] = useState(null);
const [isChecking, setIsChecking] = useState(false);
const [isRegistering, setIsRegistering] = useState(false);
const [txHash, setTxHash] = useState('');
const [error, setError] = useState('');
const [checkError, setCheckError] = useState('');
const [notice, setNotice] = useState('');
const headingId = useId();
const statusId = useId();
const campaignContractId = getCampaignContractId();
const stellarNetwork = getStellarNetwork();
const { run, isPending, isError, error } = useOptimisticAction();

/* On mount (and when the wallet changes), check participant status. */
useEffect(() => {
if (!walletAddress || !campaignContractId) {
setIsRegistered(null);
setError('');
setCheckError('');
setNotice('');
return;
}

let cancelled = false;
setIsChecking(true);
setError('');
setCheckError('');
setNotice('');

checkParticipantStatus(walletAddress)
.then((registered) => {
if (!cancelled) setIsRegistered(registered);
})
.catch((err) => {
if (!cancelled) setError(normalizeError(err));
if (!cancelled) setCheckError(normalizeError(err));
})
.finally(() => {
if (!cancelled) setIsChecking(false);
Expand All @@ -61,43 +67,45 @@ export default function RegisterCampaign({ walletAddress, onRegistered }) {
const handleRegister = async () => {
if (!walletAddress) return;

setIsRegistering(true);
setError('');
setNotice('');
setTxHash('');

try {
const { hash, alreadyRegistered } = await submitRegisterTransaction(walletAddress);
setTxHash(hash);
setIsRegistered(true);

if (alreadyRegistered) {
setNotice('You were already registered in this campaign.');
} else {
onRegistered?.();
}
} catch (err) {
setError(normalizeError(err));
} finally {
setIsRegistering(false);
}
setCheckError('');
const previousStatus = isRegistered;

await run(() => submitRegisterTransaction(walletAddress), {
// Optimistic: reflect "registered" immediately so the action feels instant.
optimistic: () => setIsRegistered(true),
// Rollback: restore the prior status if the transaction fails.
rollback: () => setIsRegistered(previousStatus),
// Reconcile with chain truth once confirmed.
reconcile: ({ hash, alreadyRegistered }) => {
setTxHash(hash);
if (alreadyRegistered) {
setNotice('You were already registered in this campaign.');
} else {
onRegistered?.();
}
},
});
};

if (!campaignContractId) return null;

const statusLabel = isChecking
? 'Checking…'
: isRegistered === true
? '✓ Registered'
: isRegistered === false
? 'Not registered'
: '—';
: isPending
? 'Registering…'
: isRegistered === true
? '✓ Registered'
: isRegistered === false
? 'Not registered'
: '—';

return (
<section
className="register-section"
aria-labelledby={headingId}
aria-busy={isChecking || isRegistering}
aria-busy={isChecking || isPending}
>
<h3 id={headingId} className="register-heading">
Campaign registration
Expand All @@ -114,24 +122,35 @@ export default function RegisterCampaign({ walletAddress, onRegistered }) {
<button
type="button"
className="btn btn-primary btn-button"
disabled={isRegistering || isChecking || !walletAddress}
disabled={isPending || isChecking || !walletAddress}
aria-describedby={statusId}
onClick={handleRegister}
>
{isRegistering ? 'Signing…' : 'Register in campaign'}
{isPending ? 'Signing…' : 'Register in campaign'}
</button>
)}

{txHash && <TransactionStatus hash={txHash} network={stellarNetwork} />}
{isPending && (
<TransactionStatus variant="pending" network={stellarNetwork} status="Registering…" />
)}
{!isPending && txHash && (
<TransactionStatus hash={txHash} network={stellarNetwork} status="Registered" />
)}

{notice && (
<p className="register-note" role="status">
{notice}
</p>
)}
{error && (
{isError && error && (
<p className="register-error" role="alert">
{error.message}
{error.recovery ? ` ${error.recovery}.` : ''}
</p>
)}
{checkError && (
<p className="register-error" role="alert">
{error}
{checkError}
</p>
)}
</section>
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/components/TransactionStatus.css
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,39 @@
.tx-explorer-link:hover svg {
transform: translate(1px, -1px);
}

/* ── Lifecycle variants (optimistic UI) ─────────────────────────────────── */

.tx-status--pending .tx-status-icon {
background: var(--warning, #d97706);
animation: txPulse 1.2s ease-in-out infinite;
}

.tx-status--pending .tx-status-label {
color: var(--warning, #d97706);
}

.tx-status--error .tx-status-icon {
background: var(--error, #dc2626);
}

.tx-status--error .tx-status-label {
color: var(--error, #dc2626);
}

.tx-status-message {
margin: 0 0 0.85rem;
font-size: 0.95rem;
line-height: 1.4;
color: var(--text-secondary, #4b5563);
}

@keyframes txPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
Loading
Loading