Skip to content

Commit fc54125

Browse files
committed
added redeem path
1 parent cc0b00a commit fc54125

4 files changed

Lines changed: 348 additions & 1 deletion

File tree

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,8 @@
4242
"Bash(ls /mnt/e/GitHub/rewards-program/src/app/error*)",
4343
"Read(//mnt/e/GitHub/fula-chain/**)",
4444
"Read(//mnt/e/GitHub/**)",
45-
"Bash(HARDHAT_CONTRACT_SIZER=false npx hardhat test test/governance/integration/RewardsProgram.test.ts --no-compile)"
45+
"Bash(HARDHAT_CONTRACT_SIZER=false npx hardhat test test/governance/integration/RewardsProgram.test.ts --no-compile)",
46+
"Bash(wc:*)"
4647
]
4748
}
4849
}

src/app/balance/page.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ function virtualAddr(memberID: string, programId: number): `0x${string}` {
2020
// Take last 20 bytes (address = uint160 of uint256)
2121
return getAddress("0x" + hash.slice(-40)) as `0x${string}`;
2222
}
23+
import { QRCodeSVG } from "qrcode.react";
2324
import { useProgramCount, useProgram, useTransferToParent, useWithdraw, useDepositTokens, useRewardTypes, useTransferLimit, useClaimMember } from "@/hooks/useRewardsProgram";
2425
import { OnChainDisclaimer } from "@/components/common/OnChainDisclaimer";
2526
import { QRCodeDisplay } from "@/components/common/QRCodeDisplay";
@@ -398,10 +399,31 @@ function BalanceContent() {
398399
setSearchID(m);
399400
};
400401

402+
const [redeemQrUrl, setRedeemQrUrl] = useState("");
403+
useEffect(() => {
404+
if (typeof window !== "undefined" && searchID && codeParam && claimParam && memberExists) {
405+
setRedeemQrUrl(`${window.location.origin}/redeem?member=${encodeURIComponent(searchID)}&claim=${encodeURIComponent(claimParam)}&code=${encodeURIComponent(codeParam)}`);
406+
} else {
407+
setRedeemQrUrl("");
408+
}
409+
}, [searchID, codeParam, claimParam, memberExists]);
410+
401411
return (
402412
<Box>
403413
<Typography variant="h4" gutterBottom>Member Balance</Typography>
404414

415+
{redeemQrUrl && (
416+
<Paper sx={{ p: 2, mb: 3, textAlign: "center" }}>
417+
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
418+
Scan to redeem rewards
419+
</Typography>
420+
<QRCodeSVG value={redeemQrUrl} size={isMobile ? 160 : 200} level="M" />
421+
<Typography variant="caption" display="block" color="text.secondary" sx={{ mt: 1 }}>
422+
{searchID} &middot; Program {claimParam}
423+
</Typography>
424+
</Paper>
425+
)}
426+
405427
<Paper sx={{ p: 3, mb: 3 }}>
406428
<Box sx={{ display: "flex", gap: 2, alignItems: "flex-end", flexWrap: "wrap" }}>
407429
<TextField

src/app/redeem/page.tsx

Lines changed: 298 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
1+
"use client";
2+
3+
import { useState, useEffect, Suspense } from "react";
4+
import {
5+
Typography, Box, Paper, TextField, Button, Alert,
6+
CircularProgress, IconButton, Tooltip,
7+
} from "@mui/material";
8+
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
9+
import EditIcon from "@mui/icons-material/Edit";
10+
import SaveIcon from "@mui/icons-material/Save";
11+
import { useAccount, useReadContract } from "wagmi";
12+
import { useSearchParams } from "next/navigation";
13+
import { ConnectButton } from "@rainbow-me/rainbowkit";
14+
import { zeroAddress, encodePacked, keccak256, getAddress } from "viem";
15+
import { CONTRACTS, REWARDS_PROGRAM_ABI } from "@/config/contracts";
16+
import { toBytes12, fromBytes12, formatFula, formatContractError, safeParseAmount } from "@/lib/utils";
17+
import { useActForMember } from "@/hooks/useRewardsProgram";
18+
19+
function virtualAddr(memberID: string, programId: number): `0x${string}` {
20+
const memberIDBytes = toBytes12(memberID);
21+
const hash = keccak256(encodePacked(["bytes12", "uint32"], [memberIDBytes, programId]));
22+
return getAddress("0x" + hash.slice(-40)) as `0x${string}`;
23+
}
24+
25+
function RedeemContent() {
26+
const searchParams = useSearchParams();
27+
const memberParam = (searchParams.get("member") || "").toUpperCase();
28+
const claimParam = searchParams.get("claim") || "";
29+
const codeParam = searchParams.get("code") || "";
30+
31+
const { isConnected } = useAccount();
32+
const programId = parseInt(claimParam) || 0;
33+
34+
// Member code state
35+
const [memberID, setMemberID] = useState(memberParam);
36+
const [codeFromUrl, setCodeFromUrl] = useState(!!codeParam);
37+
const [editCode, setEditCode] = useState(codeParam);
38+
const [editingCode, setEditingCode] = useState(!codeParam);
39+
const [codeInput, setCodeInput] = useState("");
40+
41+
// Transfer state
42+
const [amount, setAmount] = useState("");
43+
const { actForMember, isPending, isConfirming, isSuccess, error, hash } = useActForMember();
44+
const [transferredAmount, setTransferredAmount] = useState("");
45+
46+
// When member ID changes manually, invalidate the code
47+
const handleMemberChange = (val: string) => {
48+
const upper = val.toUpperCase();
49+
setMemberID(upper);
50+
if (upper !== memberParam) {
51+
setCodeFromUrl(false);
52+
setEditCode("");
53+
setEditingCode(true);
54+
setCodeInput("");
55+
} else if (codeParam) {
56+
setCodeFromUrl(true);
57+
setEditCode(codeParam);
58+
setEditingCode(false);
59+
}
60+
};
61+
62+
const handleSaveCode = () => {
63+
if (codeInput.trim()) {
64+
const code = codeInput.trim().startsWith("0x") ? codeInput.trim() : `0x${codeInput.trim()}`;
65+
setEditCode(code);
66+
setEditingCode(false);
67+
}
68+
};
69+
70+
// Read member info
71+
const memberIDBytes = toBytes12(memberID);
72+
const { data: member, isLoading: memberLoading } = useReadContract({
73+
address: CONTRACTS.rewardsProgram,
74+
abi: REWARDS_PROGRAM_ABI,
75+
functionName: "getMemberByID",
76+
args: [memberIDBytes, programId],
77+
query: { enabled: !!memberID && programId > 0 },
78+
});
79+
80+
// Compute balance key (virtual address for walletless)
81+
const balanceKey: `0x${string}` | undefined = member?.active
82+
? (member.wallet && member.wallet !== zeroAddress
83+
? member.wallet as `0x${string}`
84+
: virtualAddr(memberID, programId))
85+
: undefined;
86+
87+
// Read balance
88+
const { data: balance, isLoading: balanceLoading } = useReadContract({
89+
address: CONTRACTS.rewardsProgram,
90+
abi: REWARDS_PROGRAM_ABI,
91+
functionName: "getBalance",
92+
args: balanceKey ? [programId, balanceKey] : undefined,
93+
query: { enabled: !!balanceKey },
94+
});
95+
96+
// Read parent info to get parent's memberID
97+
const parentAddr = member?.active ? member.parent as `0x${string}` : undefined;
98+
const { data: parentMember } = useReadContract({
99+
address: CONTRACTS.rewardsProgram,
100+
abi: REWARDS_PROGRAM_ABI,
101+
functionName: "getMember",
102+
args: parentAddr && parentAddr !== zeroAddress ? [programId, parentAddr] : undefined,
103+
query: { enabled: !!parentAddr && parentAddr !== zeroAddress && programId > 0 },
104+
});
105+
106+
const parentMemberID = parentMember?.active
107+
? fromBytes12(parentMember.memberID as `0x${string}`)
108+
: "";
109+
110+
const totalBalance = balance
111+
? (balance[0] as bigint) + (balance[1] as bigint) + (balance[2] as bigint)
112+
: BigInt(0);
113+
114+
const handleRedeem = () => {
115+
if (!memberID || !programId || !amount) return;
116+
setTransferredAmount(amount);
117+
actForMember(programId, memberID, 3, zeroAddress, amount, "Redeemed via portal");
118+
};
119+
120+
// Reset success state on new transfer
121+
useEffect(() => {
122+
if (isSuccess) {
123+
setAmount("");
124+
}
125+
}, [isSuccess]);
126+
127+
const loading = memberLoading || balanceLoading;
128+
const parsedAmount = safeParseAmount(amount);
129+
const canTransfer = isConnected && memberID && programId > 0 && parsedAmount && parsedAmount > BigInt(0) && !isPending && !isConfirming && member?.active;
130+
131+
return (
132+
<Box sx={{ maxWidth: 480, mx: "auto", px: 2, py: 3 }}>
133+
<Typography variant="h4" sx={{ textAlign: "center", mb: 3, fontWeight: 700 }}>
134+
Redeem Rewards
135+
</Typography>
136+
137+
{/* Member code row */}
138+
<Paper sx={{ p: 2, mb: 2 }}>
139+
<Box sx={{ display: "flex", gap: 1, alignItems: "center" }}>
140+
<TextField
141+
label="Member Code"
142+
value={memberID}
143+
onChange={(e) => handleMemberChange(e.target.value)}
144+
size="small"
145+
sx={{ flexGrow: 1 }}
146+
inputProps={{ maxLength: 12, style: { textTransform: "uppercase" } }}
147+
/>
148+
{!editingCode && editCode ? (
149+
<Tooltip title="Edit code verified from link">
150+
<Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
151+
<CheckCircleIcon sx={{ color: "success.main", fontSize: 28 }} />
152+
<IconButton size="small" onClick={() => { setEditingCode(true); setCodeInput(editCode); }}>
153+
<EditIcon sx={{ fontSize: 18 }} />
154+
</IconButton>
155+
</Box>
156+
</Tooltip>
157+
) : (
158+
<Box sx={{ display: "flex", gap: 0.5, alignItems: "center", minWidth: 0, flexShrink: 1 }}>
159+
<TextField
160+
label="Edit Code"
161+
value={codeInput}
162+
onChange={(e) => setCodeInput(e.target.value)}
163+
size="small"
164+
placeholder="0x..."
165+
sx={{ width: 140, "& input": { fontFamily: "monospace", fontSize: 12 } }}
166+
/>
167+
<IconButton size="small" color="primary" onClick={handleSaveCode} disabled={!codeInput.trim()}>
168+
<SaveIcon />
169+
</IconButton>
170+
</Box>
171+
)}
172+
</Box>
173+
</Paper>
174+
175+
{/* Loading */}
176+
{loading && memberID && programId > 0 && (
177+
<Box sx={{ textAlign: "center", py: 3 }}>
178+
<CircularProgress size={32} />
179+
</Box>
180+
)}
181+
182+
{/* Member not found */}
183+
{!loading && memberID && programId > 0 && (!member || !member.active) && (
184+
<Alert severity="warning" sx={{ mb: 2 }}>
185+
Member &quot;{memberID}&quot; not found in program {programId}.
186+
</Alert>
187+
)}
188+
189+
{/* Balance display */}
190+
{member?.active && balance && (
191+
<>
192+
<Paper sx={{ p: 3, mb: 2, textAlign: "center" }}>
193+
<Typography variant="caption" color="text.secondary" display="block" sx={{ mb: 0.5 }}>
194+
Total Balance
195+
</Typography>
196+
<Typography variant="h3" sx={{ color: "success.main", fontWeight: 700, lineHeight: 1.2 }}>
197+
{formatFula(totalBalance)}
198+
</Typography>
199+
<Typography variant="caption" color="text.secondary" display="block" sx={{ mt: 0.5 }}>
200+
FULA
201+
</Typography>
202+
<Box sx={{ display: "flex", justifyContent: "center", gap: 2, mt: 1.5 }}>
203+
<Box>
204+
<Typography variant="caption" color="text.secondary" display="block">Available</Typography>
205+
<Typography variant="body2" sx={{ color: "success.main", fontWeight: 600 }}>
206+
{formatFula(balance[0] as bigint)}
207+
</Typography>
208+
</Box>
209+
<Box>
210+
<Typography variant="caption" color="text.secondary" display="block">Locked</Typography>
211+
<Typography variant="body2" sx={{ color: "error.main", fontWeight: 600 }}>
212+
{formatFula(balance[1] as bigint)}
213+
</Typography>
214+
</Box>
215+
<Box>
216+
<Typography variant="caption" color="text.secondary" display="block">Time-Locked</Typography>
217+
<Typography variant="body2" sx={{ color: "warning.main", fontWeight: 600 }}>
218+
{formatFula(balance[2] as bigint)}
219+
</Typography>
220+
</Box>
221+
</Box>
222+
</Paper>
223+
224+
{/* Wallet connection */}
225+
{!isConnected && (
226+
<Paper sx={{ p: 2, mb: 2, textAlign: "center" }}>
227+
<Typography variant="body2" color="text.secondary" sx={{ mb: 1.5 }}>
228+
Connect your wallet to redeem tokens
229+
</Typography>
230+
<ConnectButton />
231+
</Paper>
232+
)}
233+
234+
{/* Transfer section */}
235+
{isConnected && (
236+
<Paper sx={{ p: 2, mb: 2 }}>
237+
<TextField
238+
label="Amount (FULA)"
239+
value={amount}
240+
onChange={(e) => setAmount(e.target.value)}
241+
fullWidth
242+
size="small"
243+
type="number"
244+
sx={{ mb: 2 }}
245+
inputProps={{ min: 0, step: "any" }}
246+
/>
247+
<Button
248+
variant="contained"
249+
fullWidth
250+
size="large"
251+
onClick={handleRedeem}
252+
disabled={!canTransfer}
253+
sx={{ py: 1.5, fontWeight: 700, fontSize: "1rem" }}
254+
>
255+
{isPending || isConfirming ? (
256+
<CircularProgress size={24} color="inherit" />
257+
) : parentMemberID ? (
258+
`Redeem to ${parentMemberID}`
259+
) : (
260+
"Redeem to Parent"
261+
)}
262+
</Button>
263+
</Paper>
264+
)}
265+
266+
{/* Success */}
267+
{isSuccess && (
268+
<Alert severity="success" sx={{ mb: 2 }}>
269+
Transferred {transferredAmount} FULA from {memberID} to {parentMemberID || "parent"}.
270+
</Alert>
271+
)}
272+
273+
{/* Error */}
274+
{error && (
275+
<Alert severity="error" sx={{ mb: 2 }}>
276+
{formatContractError(error)}
277+
</Alert>
278+
)}
279+
</>
280+
)}
281+
282+
{/* No program ID */}
283+
{!programId && (
284+
<Alert severity="info" sx={{ mb: 2 }}>
285+
No program specified. This page should be opened by scanning a member QR code.
286+
</Alert>
287+
)}
288+
</Box>
289+
);
290+
}
291+
292+
export default function RedeemPage() {
293+
return (
294+
<Suspense fallback={<Box sx={{ textAlign: "center", py: 4 }}><CircularProgress /></Box>}>
295+
<RedeemContent />
296+
</Suspense>
297+
);
298+
}

src/hooks/useRewardsProgram.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,32 @@ export function useRemoveSubType() {
524524
return { removeSubType, isPending, isConfirming, isSuccess, error, hash };
525525
}
526526

527+
export function useActForMember() {
528+
const { writeContract, data: hash, isPending, error } = useWriteContract();
529+
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash });
530+
useRefetchOnSuccess(isSuccess);
531+
532+
const actForMember = (
533+
programId: number,
534+
memberID: string,
535+
action: number,
536+
to: `0x${string}`,
537+
amount: string,
538+
note: string = ""
539+
) => {
540+
const parsed = safeParseAmount(amount);
541+
if (!parsed) return;
542+
writeContract({
543+
address: CONTRACTS.rewardsProgram,
544+
abi: REWARDS_PROGRAM_ABI,
545+
functionName: "actForMember",
546+
args: [programId, toBytes12(memberID), action, to, parsed, false, 0, 0, note],
547+
});
548+
};
549+
550+
return { actForMember, isPending, isConfirming, isSuccess, error, hash };
551+
}
552+
527553
export function useUpdateMemberID() {
528554
const { writeContract, data: hash, isPending, error } = useWriteContract();
529555
const { isLoading: isConfirming, isSuccess } = useWaitForTransactionReceipt({ hash });

0 commit comments

Comments
 (0)