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
1 change: 1 addition & 0 deletions apps/namadillo/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@
/playwright/.cache/
/public/localnet-config.toml
.vercel
.env*.local
4 changes: 3 additions & 1 deletion apps/namadillo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"@keplr-wallet/types": "^0.12.136",
"@namada/chain-registry": "^1.0.0",
"@namada/indexer-client": "2.4.4",
"@supabase/supabase-js": "^2.49.4",
"@tailwindcss/container-queries": "^0.1.1",
"@tanstack/query-core": "^5.40.0",
"@tanstack/react-query": "^5.40.0",
Expand Down Expand Up @@ -42,7 +43,8 @@
"traverse": "^0.6.9",
"vite-plugin-checker": "^0.8.0",
"web-vitals": "^2.1.4",
"wonka": "^6.3.4"
"wonka": "^6.3.4",
"xlsx": "^0.18.5"
},
"scripts": {
"start:proxy": "node ./scripts/startProxies.js",
Expand Down
4 changes: 4 additions & 0 deletions apps/namadillo/src/App/AppRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { SubmitVote } from "./Governance/SubmitVote";
import { ViewJson } from "./Governance/ViewJson";
import { IbcShieldAll } from "./Ibc/IbcShieldAll";
import { MoveAssets } from "./Layout/MoveAssets";
import { Referrals } from "./Referrals/Referrals";
import { routes } from "./routes";
import { Advanced } from "./Settings/Advanced";
import { EnableFeatures } from "./Settings/EnableFeatures";
Expand Down Expand Up @@ -59,6 +60,9 @@ export const MainRoutes = (): JSX.Element => {
element={<App />}
errorElement={<RouteErrorBoundary />}
>
{/* Referrals */}
<Route path={routes.referrals} element={<Referrals />} />

{/* Home */}
<Route index element={<AccountOverview />} />

Expand Down
30 changes: 29 additions & 1 deletion apps/namadillo/src/App/Layout/AppLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { AlphaVersionTopHeader } from "App/Common/AlphaVersionTopHeader";
import { ReactNode, useState } from "react";
import { defaultAccountAtom } from "atoms/accounts";
import { connectedWalletsAtom } from "atoms/integrations";
import { useAtomValue } from "jotai";
import { ReactNode, useEffect, useState } from "react";
import { IoMdClose } from "react-icons/io";
import { useLocation, useNavigate } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { AppHeader } from "./AppHeader";
import { BurgerButton } from "./BurgerButton";
Expand All @@ -12,6 +16,30 @@ export const AppLayout = ({
children: ReactNode;
}): JSX.Element => {
const [displayNavigation, setDisplayNavigation] = useState(false);
const location = useLocation();
const navigate = useNavigate();
const connectedWallets = useAtomValue(connectedWalletsAtom);
const defaultAccount = useAtomValue(defaultAccountAtom);

useEffect(() => {
const queryParams = new URLSearchParams(location.search);
const referrerAddress = queryParams.get("referral");

if (referrerAddress && referrerAddress.startsWith("tnam")) {
const userAddress = defaultAccount.data?.address;
if (userAddress) {
// Store referral address in local storage
localStorage.setItem("refereeAddress", userAddress);
localStorage.setItem("referrerAddress", referrerAddress);

// Clear the query parameter from URL
queryParams.delete("referral");
const newSearch = queryParams.toString();
const newPath = location.pathname + (newSearch ? `?${newSearch}` : "");
navigate(newPath, { replace: true });
}
}
}, [location, navigate, connectedWallets, defaultAccount]);

return (
<>
Expand Down
13 changes: 12 additions & 1 deletion apps/namadillo/src/App/Layout/TopNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export const TopNavigation = (): JSX.Element => {
const defaultAccount = useAtomValue(defaultAccountAtom);
const location = useLocation();
const navigate = useNavigate();
const isValidityOps =
defaultAccount.data?.address &&
[
"tnam1q8lhvxys53dlc8wzlg7dyqf9avd0vff6wvav4amt",
"tnam1qr0e06vqhw9u0yqy9d5zmtq0q8ekckhe2vkqc3ky",
].includes(defaultAccount.data?.address);

if (!userHasAccount) {
return (
Expand All @@ -53,7 +59,7 @@ export const TopNavigation = (): JSX.Element => {

return (
<div className="flex-1 flex items-center gap-4 sm:gap-6">
<div className="hidden lg:grid lg:grid-cols-3 gap-2">
<div className="hidden lg:grid lg:grid-cols-4 gap-2">
{maspEnabled && (
<ActionButton
className="py-2"
Expand Down Expand Up @@ -87,6 +93,11 @@ export const TopNavigation = (): JSX.Element => {
Transfer
</ActionButton>
)}
{isValidityOps && (
<ActionButton href={routes.referrals} className="py-2" size="xs">
Referrals
</ActionButton>
)}
</div>

<div className="flex-1" />
Expand Down
198 changes: 198 additions & 0 deletions apps/namadillo/src/App/Referrals/ReferralSheetModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import { Modal, TableRow } from "@namada/components";
import { shortenAddress } from "@namada/utils";
import { ModalTransition } from "App/Common/ModalTransition";
import { TableWithPaginator } from "App/Common/TableWithPaginator";
import BigNumber from "bignumber.js";
import { useCallback, useState } from "react";
import { IoClose } from "react-icons/io5";
import { twMerge } from "tailwind-merge";
import { ReferralReward } from "./types";

// TODO:
// - Once we pay a user we need to update their entry in the DB to last_paid_epoch
// - After this we should send a confirmation email to Paul et al to let them know:
// - A user has been paid
// - The amount paid
// - The address of the user paid
// - The epoch at which the user was paid

export const ReferralSheetModal = ({
rewardsData,
onClose,
}: {
rewardsData: ReferralReward[];
onClose: () => void;
}): JSX.Element => {
const [selectedReferrer, setSelectedReferrer] = useState<string | null>(null);

const headers = [
"Referrer Address",
"Referee Address",
"Epoch",
"Reward (NAM)",
];

const renderRow = useCallback(
(r: ReferralReward): TableRow => ({
key: `rw-${r.referrerAddress}-${r.refereeAddress}-${r.epoch}`,
cells: [
<Addr key="ref" value={r.referrerAddress} />,
<Addr key="ree" value={r.refereeAddress} />,
<div key="ep">{r.epoch}</div>,
<div key="amt">{r.amount.toFormat(6)}</div>,
],
}),
[]
);

const byReferrer = rewardsData.reduce<Record<string, ReferralReward[]>>(
(a, r) => ((a[r.referrerAddress] ??= []).push(r), a),
{}
);

if (!selectedReferrer && Object.keys(byReferrer).length)
setSelectedReferrer(Object.keys(byReferrer)[0]);

const rows =
selectedReferrer ? (byReferrer[selectedReferrer] ?? []) : rewardsData;

const totals = rewardsData.reduce<Record<string, BigNumber>>((acc, r) => {
const amt = new BigNumber(r.amount); // ensure BigNumber
acc[r.referrerAddress] = (acc[r.referrerAddress] ?? new BigNumber(0)).plus(
amt
);
return acc;
}, {});

return (
<Modal onClose={onClose}>
<ModalTransition
className="relative flex flex-col w-[90vw] max-w-[1200px] h-[90svh]
bg-neutral-800 text-white rounded-md"
>
{/* header */}
<div className="sticky top-0 z-10 bg-neutral-800 px-6 pt-3.5 pb-2 border-b border-neutral-700">
<IoClose
onClick={onClose}
className="absolute top-1.5 right-6 text-3xl cursor-pointer hover:text-yellow"
/>
<header className="text-lg text-center">Referral Rewards</header>
</div>

{/* body – single scroll */}
<div className="flex-1 px-6 py-4 flex flex-col overflow-y-auto">
{rows.length === 0 ?
<div className="text-center py-8">No rewards found</div>
: <>
{/* totals */}
<div className="bg-neutral-700 p-4 rounded-md mb-4">
<h3 className="text-lg font-semibold mb-2">Referrer Total</h3>
<div className="grid grid-cols-1 gap-4">
{selectedReferrer && totals[selectedReferrer] && (
<TotalCard
key={selectedReferrer}
addr={selectedReferrer}
amount={totals[selectedReferrer]}
/>
)}
</div>
</div>

{/* epoch table */}
<div className="bg-neutral-700 p-4 rounded-md flex flex-col flex-1">
<h3 className="text-lg font-semibold mb-2">Rewards by Epoch</h3>

{/* referrer selector */}
<div className="mb-4 border-b border-neutral-600 flex flex-wrap">
{Object.keys(byReferrer).map((addr) => (
<button
key={addr}
onClick={() => setSelectedReferrer(addr)}
className={`px-4 py-2 whitespace-nowrap ${
selectedReferrer === addr ?
"border-b-2 border-yellow text-yellow"
: "text-neutral-400"
}`}
>
{shortenAddress(addr, 6, 4)}
</button>
))}
</div>

<TableWithPaginator
id="rewards-table"
headers={headers}
renderRow={renderRow}
itemList={rows}
page={0}
pageCount={1}
onPageChange={() => {}} /* no-op */
tableProps={{
className: twMerge(
"w-full min-w-[600px] [&_td]:px-3 [&_th]:px-3 [&_td]:h-[64px]",
"[&_td:first-child]:pl-4 [&_td:last-child]:pr-4",
"[&_td:first-child]:rounded-s-md [&_td:last-child]:rounded-e-md"
),
}}
headProps={{ className: "text-neutral-500" }}
/>
</div>
</>
}
</div>
</ModalTransition>
</Modal>
);
};

const Addr = ({ value }: { value: string }): JSX.Element => (
<div className="text-left font-medium max-w-[200px] group/tooltip relative">
{shortenAddress(value, 10, 6)}
</div>
);

const TotalCard = ({
addr,
amount,
}: {
addr: string;
amount: BigNumber;
}): JSX.Element => {
const validatorCut = amount.multipliedBy(0.05);
const referrerCut = validatorCut.multipliedBy(0.25);
const toolBuilderCut = referrerCut;
return (
<div className="flex flex-col p-3 border border-neutral-600 rounded-md w-[400px]">
<div className="flex justify-between mb-1 text-sm text-neutral-400">
<span>Referrer:</span>
<div className="text-left font-medium">
{shortenAddress(addr, 10, 6)}
<button
className="p-1 ml-2 bg-neutral-800 rounded text-xs"
onClick={() => {
navigator.clipboard.writeText(addr);
}}
>
Copy
</button>
</div>
</div>
<div className="flex justify-between">
<span className="text-sm text-neutral-400">Total NAM Referred:</span>
<span className="font-bold text-green-400">{amount.toFormat(6)}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-neutral-400">Validator Cut:</span>
<span className="font-bold text-green-400">
{validatorCut.minus(referrerCut).minus(toolBuilderCut).toFormat(6)}
</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-neutral-400">Referrer Cut:</span>
<span className="font-bold text-green-400">
{referrerCut.toFormat(6)}
</span>
</div>
</div>
);
};
76 changes: 76 additions & 0 deletions apps/namadillo/src/App/Referrals/Referrals.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Panel } from "@namada/components";
import { routes } from "App/routes";
import { defaultAccountAtom } from "atoms/accounts";
import { useAtomValue } from "jotai";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { getReferralsFromSupabase } from "../../utils/supabase";
import { ReferralsTable } from "./ReferralsTable";
import { Referral } from "./types";

export const Referrals = (): JSX.Element => {
const [referrals, setReferrals] = useState<Referral[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const defaultAccount = useAtomValue(defaultAccountAtom);
const isValidityOps =
defaultAccount.data?.address &&
[
"tnam1q8lhvxys53dlc8wzlg7dyqf9avd0vff6wvav4amt",
"tnam1qr0e06vqhw9u0yqy9d5zmtq0q8ekckhe2vkqc3ky",
].includes(defaultAccount.data?.address);

const navigate = useNavigate();
useEffect(() => {
const fetchReferrals = async (): Promise<void> => {
try {
setLoading(true);
const { success, data, error } = await getReferralsFromSupabase();

if (success && data) {
setReferrals(data as Referral[]);
} else {
console.error("Failed to fetch referrals:", error);
setError("Failed to fetch referrals");
}
} catch (err) {
console.error("Error fetching referrals:", err);
setError("Error fetching referrals");
} finally {
setLoading(false);
}
};
if (!isValidityOps) return navigate(routes.root);
fetchReferrals();
}, []);

if (!isValidityOps) {
return <div>You are not authorized to view this page</div>;
}

return (
<Panel className="min-h-600">
<div className="flex flex-col gap-4">
<h1 className="text-2xl font-bold">Referrals</h1>

{loading && (
<div className="text-center py-4">Loading referrals...</div>
)}

{error && <div className="text-red-500 py-2">{error}</div>}

{!loading && !error && referrals.length === 0 && (
<div className="text-center py-4">No referrals found</div>
)}

{!loading && !error && referrals.length > 0 && (
<ReferralsTable
id="referrals-table"
referrals={referrals}
resultsPerPage={10}
/>
)}
</div>
</Panel>
);
};
Loading