diff --git a/apps/namadillo/.gitignore b/apps/namadillo/.gitignore index f263da0cc5..3bca05b5d3 100644 --- a/apps/namadillo/.gitignore +++ b/apps/namadillo/.gitignore @@ -10,3 +10,4 @@ /playwright/.cache/ /public/localnet-config.toml .vercel +.env*.local diff --git a/apps/namadillo/package.json b/apps/namadillo/package.json index 34940fa9f8..6f15702278 100644 --- a/apps/namadillo/package.json +++ b/apps/namadillo/package.json @@ -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", @@ -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", diff --git a/apps/namadillo/src/App/AppRoutes.tsx b/apps/namadillo/src/App/AppRoutes.tsx index bec97e126a..136bb70590 100644 --- a/apps/namadillo/src/App/AppRoutes.tsx +++ b/apps/namadillo/src/App/AppRoutes.tsx @@ -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"; @@ -59,6 +60,9 @@ export const MainRoutes = (): JSX.Element => { element={} errorElement={} > + {/* Referrals */} + } /> + {/* Home */} } /> diff --git a/apps/namadillo/src/App/Layout/AppLayout.tsx b/apps/namadillo/src/App/Layout/AppLayout.tsx index 785b8aadbf..eeeb931de1 100644 --- a/apps/namadillo/src/App/Layout/AppLayout.tsx +++ b/apps/namadillo/src/App/Layout/AppLayout.tsx @@ -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"; @@ -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 ( <> diff --git a/apps/namadillo/src/App/Layout/TopNavigation.tsx b/apps/namadillo/src/App/Layout/TopNavigation.tsx index 4e5b810d89..84b854e937 100644 --- a/apps/namadillo/src/App/Layout/TopNavigation.tsx +++ b/apps/namadillo/src/App/Layout/TopNavigation.tsx @@ -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 ( @@ -53,7 +59,7 @@ export const TopNavigation = (): JSX.Element => { return (
-
+
{maspEnabled && ( { Transfer )} + {isValidityOps && ( + + Referrals + + )}
diff --git a/apps/namadillo/src/App/Referrals/ReferralSheetModal.tsx b/apps/namadillo/src/App/Referrals/ReferralSheetModal.tsx new file mode 100644 index 0000000000..c36f5e7063 --- /dev/null +++ b/apps/namadillo/src/App/Referrals/ReferralSheetModal.tsx @@ -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(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: [ + , + , +
{r.epoch}
, +
{r.amount.toFormat(6)}
, + ], + }), + [] + ); + + const byReferrer = rewardsData.reduce>( + (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>((acc, r) => { + const amt = new BigNumber(r.amount); // ensure BigNumber + acc[r.referrerAddress] = (acc[r.referrerAddress] ?? new BigNumber(0)).plus( + amt + ); + return acc; + }, {}); + + return ( + + + {/* header */} +
+ +
Referral Rewards
+
+ + {/* body – single scroll */} +
+ {rows.length === 0 ? +
No rewards found
+ : <> + {/* totals */} +
+

Referrer Total

+
+ {selectedReferrer && totals[selectedReferrer] && ( + + )} +
+
+ + {/* epoch table */} +
+

Rewards by Epoch

+ + {/* referrer selector */} +
+ {Object.keys(byReferrer).map((addr) => ( + + ))} +
+ + {}} /* 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" }} + /> +
+ + } +
+
+
+ ); +}; + +const Addr = ({ value }: { value: string }): JSX.Element => ( +
+ {shortenAddress(value, 10, 6)} +
+); + +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 ( +
+
+ Referrer: +
+ {shortenAddress(addr, 10, 6)} + +
+
+
+ Total NAM Referred: + {amount.toFormat(6)} +
+
+ Validator Cut: + + {validatorCut.minus(referrerCut).minus(toolBuilderCut).toFormat(6)} + +
+
+ Referrer Cut: + + {referrerCut.toFormat(6)} + +
+
+ ); +}; diff --git a/apps/namadillo/src/App/Referrals/Referrals.tsx b/apps/namadillo/src/App/Referrals/Referrals.tsx new file mode 100644 index 0000000000..283eddadf8 --- /dev/null +++ b/apps/namadillo/src/App/Referrals/Referrals.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const defaultAccount = useAtomValue(defaultAccountAtom); + const isValidityOps = + defaultAccount.data?.address && + [ + "tnam1q8lhvxys53dlc8wzlg7dyqf9avd0vff6wvav4amt", + "tnam1qr0e06vqhw9u0yqy9d5zmtq0q8ekckhe2vkqc3ky", + ].includes(defaultAccount.data?.address); + + const navigate = useNavigate(); + useEffect(() => { + const fetchReferrals = async (): Promise => { + 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
You are not authorized to view this page
; + } + + return ( + +
+

Referrals

+ + {loading && ( +
Loading referrals...
+ )} + + {error &&
{error}
} + + {!loading && !error && referrals.length === 0 && ( +
No referrals found
+ )} + + {!loading && !error && referrals.length > 0 && ( + + )} +
+
+ ); +}; diff --git a/apps/namadillo/src/App/Referrals/ReferralsTable.tsx b/apps/namadillo/src/App/Referrals/ReferralsTable.tsx new file mode 100644 index 0000000000..c789ea365d --- /dev/null +++ b/apps/namadillo/src/App/Referrals/ReferralsTable.tsx @@ -0,0 +1,385 @@ +import { ActionButton, TableRow } from "@namada/components"; +import { shortenAddress } from "@namada/utils"; +import { TableWithPaginator } from "App/Common/TableWithPaginator"; +import { chainStatusAtom } from "atoms/chain"; +import BigNumber from "bignumber.js"; +import { useAtomValue } from "jotai"; +import { useCallback, useState } from "react"; +import { IoIosCopy } from "react-icons/io"; +import { + IoCheckmarkCircleOutline, + IoCloseCircleOutline, +} from "react-icons/io5"; +import { twMerge } from "tailwind-merge"; +import { downloadMultiSheetExcel } from "./excel"; +import { ReferralSheetModal } from "./ReferralSheetModal"; +import { + Referral, + ReferralReward, + ReferralsTableProps, + RewardData, + ValidatorInfo, +} from "./types"; + +export const ReferralsTable = ({ + id, + referrals, + resultsPerPage = 50, + initialPage = 0, + tableClassName, +}: ReferralsTableProps): JSX.Element => { + const [page, setPage] = useState(initialPage); + const [isGenerating, setIsGenerating] = useState(false); + const [generationError, setGenerationError] = useState(null); + const [rewardsData, setRewardsData] = useState([]); + const [showModal, setShowModal] = useState(false); + const chainStatus = useAtomValue(chainStatusAtom); + + const headers = [ + "Referrer Address", + "Referee Address", + "Start Epoch", + "Created At", + "Is Active", + "Check On Chain", + ]; + + const renderRow = useCallback( + (ref: Referral): TableRow => ({ + key: `referral-${ref.id}`, + cells: [ +
+ {shortenAddress(ref.referrer_address, 10, 6)} + +
, +
+ {shortenAddress(ref.referee_address, 10, 6)} + +
, +
+ {ref.last_paid_epoch} +
, +
+ {ref.created_at ? new Date(ref.created_at).toLocaleString() : "N/A"} +
, +
+ {ref.active ? + + : } +
, +
+ + Verify + +
, + ], + }), + [] + ); + + // Sort referrals - active ones first + const sortedReferrals = [...referrals].sort((a, b) => { + // Sort by active status (active first) + if (a.active && !b.active) return -1; + if (!a.active && b.active) return 1; + // If both have same active status, sort by creation date (newest first) + if (a.created_at && b.created_at) { + return ( + new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + ); + } + return 0; + }); + + const paginatedItems = sortedReferrals.slice( + page * resultsPerPage, + page * resultsPerPage + resultsPerPage + ); + const pageCount = Math.ceil(referrals.length / resultsPerPage); + + const generateReferralSheet = async (): Promise => { + if (!chainStatus?.epoch) { + setGenerationError("Chain status not available"); + return; + } + setIsGenerating(true); + setGenerationError(null); + setRewardsData([]); + + try { + type ReferralEpochMapItem = { + type: "current" | "previous"; + referrerAddress: string; + refereeAddress: string; + epoch: number; + }; + type ResponseData = ReferralEpochMapItem & { + data: RewardData[] | null; + success: boolean; + }; + + // map: referralKey → { epoch: { amount, validator } } + const cumByReferral = new Map< + string, + Record + >(); + const fetchPromises: Promise[] = []; + const referralEpochMap: ReferralEpochMapItem[] = []; + + for (const { + referrer_address, + referee_address, + last_paid_epoch, + active, + } of referrals) { + // Dont fetch inactive referrals + if (!active) continue; + + console.log( + `Fetching rewards for ${referrer_address}-${referee_address}, last paid: ${last_paid_epoch}` + ); + + // fetch previous epoch cumulative + if (last_paid_epoch > 0) { + referralEpochMap.push({ + type: "previous", + referrerAddress: referrer_address, + refereeAddress: referee_address, + epoch: last_paid_epoch - 1, + }); + fetchPromises.push( + fetch( + `${process.env.INDEXER_URL}/api/v1/pos/reward/${referee_address}/tnam1q8lhvxys53dlc8wzlg7dyqf9avd0vff6wvav4amt/${ + last_paid_epoch - 1 + }` + ) + ); + } + + // Ensure we're fetching ALL epochs since last_paid_epoch + if (chainStatus.epoch > last_paid_epoch) { + console.log( + `Fetching ${chainStatus.epoch - last_paid_epoch} epochs for this referral` + ); + } + + // fetch current epochs + for (let epoch = last_paid_epoch; epoch < chainStatus.epoch; epoch++) { + referralEpochMap.push({ + type: "current", + referrerAddress: referrer_address, + refereeAddress: referee_address, + epoch, + }); + fetchPromises.push( + fetch( + `${process.env.INDEXER_URL}/api/v1/pos/reward/${referee_address}/tnam1q8lhvxys53dlc8wzlg7dyqf9avd0vff6wvav4amt/${epoch}` + ) + ); + } + } + + const responses = await Promise.all(fetchPromises); + const responseData: ResponseData[] = await Promise.all( + responses.map(async (res, i) => { + if (res.ok) { + const data = (await res.json()) as RewardData[]; + return { ...referralEpochMap[i], data, success: true }; + } + return { ...referralEpochMap[i], data: null, success: false }; + }) + ); + + console.log("Processed response data count:", responseData.length); + console.log("Cumulative rewards map entries:", cumByReferral.size); + + // build cumulative map + responseData.forEach((r) => { + if (!r.success || !r.data?.length) return; + const key = `${r.referrerAddress}-${r.refereeAddress}`; + const amt = new BigNumber(r.data[0].minDenomAmount).dividedBy(1e6); + if (!cumByReferral.has(key)) cumByReferral.set(key, {}); + cumByReferral.get(key)![r.epoch] = { + amount: amt, + validator: r.data[0].validator, + }; + }); + + // compute incremental rewards + const rewards: ReferralReward[] = []; + + console.log("Processed response data count:", responseData.length); + console.log("Cumulative rewards map entries:", cumByReferral.size); + + for (const [key, epochMap] of cumByReferral) { + const [referrerAddress, refereeAddress] = key.split("-"); + console.log(`Processing pair: ${referrerAddress}-${refereeAddress}`); + console.log(`Epochs available:`, Object.keys(epochMap)); + + const startEpoch = referrals.find( + (r) => + r.referrer_address === referrerAddress && + r.referee_address === refereeAddress + )!.last_paid_epoch; + + console.log(`Starting epoch: ${startEpoch}`); + + const epochs = Object.keys(epochMap) + .map((n) => +n) + .filter((e) => e >= startEpoch) + .sort((a, b) => a - b); + + console.log(`Filtered epochs to process: ${epochs.join(", ")}`); + + // Track number of rewards added for this referral pair + let rewardsAdded = 0; + + for (const epoch of epochs) { + const curr = epochMap[epoch]; + const prev = epochMap[epoch - 1]; + + // Calculate delta between current and previous epoch + const delta = prev ? curr.amount.minus(prev.amount) : curr.amount; + + // Always add the reward, even if zero or negative (for debugging) + // In production you might want to filter these out again + if (true) { + // Changed from delta.isGreaterThan(0) + rewardsAdded++; + rewards.push({ + epoch, + referrerAddress, + refereeAddress, + amount: delta.isLessThan(0) ? curr.amount : delta, // Use full amount if delta is negative + validator: curr.validator, + }); + } + } + + console.log(`Added ${rewardsAdded} rewards for this referral pair`); + } + + console.log(`Total rewards to be exported: ${rewards.length}`); + console.log( + `Unique epochs in rewards:`, + [...new Set(rewards.map((r) => r.epoch))].sort((a, b) => a - b) + ); + + // Group rewards by referrer address for the multi-tab file + const rewardsByReferrer: Record = {}; + rewards.forEach((reward) => { + if (!rewardsByReferrer[reward.referrerAddress]) { + rewardsByReferrer[reward.referrerAddress] = []; + } + rewardsByReferrer[reward.referrerAddress].push(reward); + }); + + // Sort rewards by epoch so entries with the same epoch are grouped together + const rewardsSortedByEpoch = [...rewards].sort((a, b) => { + // First sort by epoch + if (a.epoch !== b.epoch) return a.epoch - b.epoch; + // Then by referrer address for consistent ordering within the same epoch + return a.referrerAddress.localeCompare(b.referrerAddress); + }); + setRewardsData(rewardsSortedByEpoch); + + if (rewards.length > 0) { + // Log data to help troubleshoot generation + console.log( + `Generated ${rewards.length} reward entries for ${Object.keys(rewardsByReferrer).length} referrers` + ); + + // Group rewards by epoch to check how many rewards per epoch + const rewardsByEpoch = rewards.reduce( + (acc, reward) => { + acc[reward.epoch] = (acc[reward.epoch] || 0) + 1; + return acc; + }, + {} as Record + ); + + console.log("Rewards per epoch:", rewardsByEpoch); + + // Download the multi-sheet Excel file + downloadMultiSheetExcel(rewardsByReferrer); + } + + setShowModal(true); + } catch (error) { + console.error("Error generating referral sheet:", error); + setGenerationError("Failed to generate referral sheet"); + } finally { + setIsGenerating(false); + } + }; + + return ( +
+ + {generationError && ( +
{generationError}
+ )} + + {isGenerating ? "Generating..." : "Generate Referral Sheets"} + + + {showModal && rewardsData.length > 0 && ( + setShowModal(false)} + /> + )} +
+ ); +}; diff --git a/apps/namadillo/src/App/Referrals/excel.ts b/apps/namadillo/src/App/Referrals/excel.ts new file mode 100644 index 0000000000..fba88dd124 --- /dev/null +++ b/apps/namadillo/src/App/Referrals/excel.ts @@ -0,0 +1,284 @@ +import { shortenAddress } from "@namada/utils"; +import * as XLSX from "xlsx"; +import { ReferralReward } from "./types"; + +// Helper function to generate CSV data +const generateCsvContent = (rewardsData: ReferralReward[]): string => { + // CSV Headers + const headers = + "Referrer Address,Referee Address,Epoch,Reward (NAM),Validator Name\r\n"; + + if (!rewardsData || rewardsData.length === 0) { + console.warn("No rewards data to generate CSV"); + return headers; + } + + try { + // Sort rewards by referee address to group them + const sortedRewards = [...rewardsData].sort( + (a, b) => + a.refereeAddress.localeCompare(b.refereeAddress) || a.epoch - b.epoch + ); + + // Format each reward as a CSV row and join with Windows-style line breaks (CRLF) + let currentRefereeAddress: string | null = null; + const csvRows: string[] = []; + + sortedRewards.forEach((r) => { + // Check if we have all required data + if (!r || !r.validator) { + console.warn("Invalid reward data item", r); + return; + } + + // Add a blank row when referee address changes + if ( + currentRefereeAddress !== null && + currentRefereeAddress !== r.refereeAddress + ) { + csvRows.push(""); // Add blank row + } + + currentRefereeAddress = r.refereeAddress; + + // Escape any commas in text fields with quotes + const validatorName = + r.validator.name ? + `"${r.validator.name.replace(/"/g, '""')}"` + : "Unknown"; + + csvRows.push( + [ + `"${r.referrerAddress}"`, + `"${r.refereeAddress}"`, + r.epoch, + r.amount.toFixed(6), + validatorName, + ].join(",") + ); + }); + + return headers + csvRows.join("\r\n"); + } catch (error) { + console.error("Error generating CSV content:", error); + return headers + "Error generating CSV data"; + } +}; + +// Helper function to download CSV +const downloadCsv = (data: string, filename: string): void => { + // Use BOM (Byte Order Mark) to help Excel recognize UTF-8 + const BOM = "\uFEFF"; + const csvContent = BOM + data; + + // Create a blob with proper MIME type for CSV + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8" }); + const url = URL.createObjectURL(blob); + + const link = document.createElement("a"); + link.setAttribute("href", url); + link.setAttribute("download", filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +}; + +// Helper function to create a multi-sheet Excel file +export const downloadMultiSheetExcel = ( + rewardsByReferrer: Record +): void => { + try { + // Create a new workbook + const workbook = XLSX.utils.book_new(); + + // First, add a sheet with all rewards combined + const allRewards = Object.values(rewardsByReferrer).flat(); + if (allRewards.length > 0) { + // Sort all rewards by referee address to group them together + const sortedAllRewards = [...allRewards].sort( + (a, b) => + a.refereeAddress.localeCompare(b.refereeAddress) || a.epoch - b.epoch + ); + + // Convert rewards to array format with blank rows between referee addresses + const allRewardsFormatted: (Record | null)[] = []; + let currentRefereeAddress: string | null = null; + + sortedAllRewards.forEach((reward) => { + // Add a blank row when referee address changes + if ( + currentRefereeAddress !== null && + currentRefereeAddress !== reward.refereeAddress + ) { + allRewardsFormatted.push(null); // Add blank row + } + + currentRefereeAddress = reward.refereeAddress; + + allRewardsFormatted.push({ + "Referrer Address": reward.referrerAddress, + "Referee Address": reward.refereeAddress, + Epoch: String(reward.epoch), + "Reward (NAM)": String(reward.amount.toFixed(6)), + "Validator Name": reward.validator.name || "Unknown", + }); + }); + + // Convert to AOA format with null rows becoming empty arrays (blank rows) + const headers = [ + "Referrer Address", + "Referee Address", + "Epoch", + "Reward (NAM)", + "Validator Name", + ]; + const dataRows = allRewardsFormatted.map((row) => + row ? Object.values(row) : Array(headers.length).fill("") + ); + + // Create worksheet with formatting options + const worksheet = XLSX.utils.aoa_to_sheet([ + headers, // Headers + ...dataRows, // Data rows with blank rows + ]); + + // Auto-size columns based on content (filter out null entries for fitToColumn) + const columnWidths = fitToColumn( + allRewardsFormatted.filter(Boolean) as Record[] + ); + worksheet["!cols"] = columnWidths; + + XLSX.utils.book_append_sheet(workbook, worksheet, "All Rewards"); + } + + // Add a sheet for each referrer + for (const [referrerAddress, rewards] of Object.entries( + rewardsByReferrer + )) { + if (rewards.length > 0) { + // Sort rewards by referee address to group them together + const sortedRewards = [...rewards].sort( + (a, b) => + a.refereeAddress.localeCompare(b.refereeAddress) || + a.epoch - b.epoch + ); + + // Convert rewards to array format with blank rows between referee addresses + const referrerRewardsFormatted: (Record | null)[] = []; + let currentRefereeAddress: string | null = null; + + sortedRewards.forEach((reward) => { + // Add a blank row when referee address changes + if ( + currentRefereeAddress !== null && + currentRefereeAddress !== reward.refereeAddress + ) { + referrerRewardsFormatted.push(null); // Add blank row + } + + currentRefereeAddress = reward.refereeAddress; + + referrerRewardsFormatted.push({ + "Referrer Address": reward.referrerAddress, + "Referee Address": reward.refereeAddress, + Epoch: String(reward.epoch), + "Reward (NAM)": String(reward.amount.toFixed(6)), + "Validator Name": reward.validator.name || "Unknown", + }); + }); + + // Create worksheet - use shortened address for sheet name + const shortAddr = shortenAddress(referrerAddress, 8, 4); + + // Convert to AOA format with null rows becoming empty arrays (blank rows) + const headers = [ + "Referrer Address", + "Referee Address", + "Epoch", + "Reward (NAM)", + "Validator Name", + ]; + const dataRows = referrerRewardsFormatted.map((row) => + row ? Object.values(row) : Array(headers.length).fill("") + ); + + // Create worksheet with formatting options + const worksheet = XLSX.utils.aoa_to_sheet([ + headers, // Headers + ...dataRows, // Data rows with blank rows + ]); + + // Auto-size columns based on content (filter out null entries for fitToColumn) + const columnWidths = fitToColumn( + referrerRewardsFormatted.filter(Boolean) as Record[] + ); + worksheet["!cols"] = columnWidths; + + XLSX.utils.book_append_sheet(workbook, worksheet, shortAddr); + } + } + + // Generate Excel file with formatting options + const excelBuffer = XLSX.write(workbook, { + bookType: "xlsx", + type: "array", + cellStyles: true, + }); + + // Generate filename with current date and time + const now = new Date(); + const dateString = now.toISOString().split("T")[0]; + const filename = `Referral-Rewards-${dateString}.xlsx`; + + // Convert to Blob and download + const blob = new Blob([excelBuffer], { + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + } catch (error) { + console.error("Error creating Excel file:", error); + + // Fallback: Just download a single CSV with all data + const allRewards = Object.values(rewardsByReferrer).flat(); + if (allRewards.length > 0) { + downloadCsv(generateCsvContent(allRewards), "referral_rewards.csv"); + } + } +}; + +// Helper function to calculate column widths based on content +export const fitToColumn = ( + data: Record[] +): { wch: number }[] => { + if (!data || data.length === 0) return []; + + // Get all the keys from the first object + const columnNames = Object.keys(data[0]); + + // Initialize the width array with the column header lengths + const columnWidths = columnNames.map((name) => ({ + wch: Math.max(10, name.length * 1.2), // Base width on header with minimum of 10 + })); + + // Check the width needed for each cell value + data.forEach((row) => { + columnNames.forEach((col, i) => { + const value = row[col]?.toString() || ""; + const cellWidth = Math.min(50, value.length * 1.2); // Cap width at 50 chars + + if (cellWidth > columnWidths[i].wch) { + columnWidths[i].wch = cellWidth; + } + }); + }); + + return columnWidths; +}; diff --git a/apps/namadillo/src/App/Referrals/types.ts b/apps/namadillo/src/App/Referrals/types.ts new file mode 100644 index 0000000000..f90f7f79b8 --- /dev/null +++ b/apps/namadillo/src/App/Referrals/types.ts @@ -0,0 +1,46 @@ +import BigNumber from "bignumber.js"; + +export type Referral = { + id: number; + referrer_address: string; + referee_address: string; + last_paid_epoch: number; + active: boolean; + created_at?: string; +}; +export type ReferralsTableProps = { + id: string; + referrals: Referral[]; + resultsPerPage?: number; + initialPage?: number; + tableClassName?: string; +}; + +export type ValidatorInfo = { + address: string; + votingPower: string; + maxCommission: string; + commission: string; + state: string; + name: string; + email: string; + website: string; + description: string; + discordHandle: string | null; + avatar: string; + validatorId: string; + rank: string | null; +}; + +export type RewardData = { + minDenomAmount: string; + validator: ValidatorInfo; +}; + +export type ReferralReward = { + epoch: number; + referrerAddress: string; + refereeAddress: string; + amount: BigNumber; + validator: ValidatorInfo; +}; diff --git a/apps/namadillo/src/App/Staking/AllValidatorsTable.tsx b/apps/namadillo/src/App/Staking/AllValidatorsTable.tsx index 5c0b856944..93f4e2d0a4 100644 --- a/apps/namadillo/src/App/Staking/AllValidatorsTable.tsx +++ b/apps/namadillo/src/App/Staking/AllValidatorsTable.tsx @@ -29,7 +29,7 @@ export const AllValidatorsTable = ({ initialPage = 0, }: AllValidatorsProps): JSX.Element => { const validators = useAtomValue(allValidatorsAtom); - const [searchTerm, setSearchTerm] = useState(""); + const [_, setSearchTerm] = useState(""); const userHasAccount = useUserHasAccount(); const filteredValidators = useValidatorFilter({ diff --git a/apps/namadillo/src/App/Staking/IncrementBonding.tsx b/apps/namadillo/src/App/Staking/IncrementBonding.tsx index 80b4b9eb2c..fac799124b 100644 --- a/apps/namadillo/src/App/Staking/IncrementBonding.tsx +++ b/apps/namadillo/src/App/Staking/IncrementBonding.tsx @@ -62,7 +62,7 @@ const IncrementBonding = (): JSX.Element => { const validatorLink = isNamadaAddress(searchParams.get("validator") ?? "") ? searchParams.get("validator") - : null; + : "ValidityOps#1"; const [filter, setFilter] = useState(validatorLink ?? ""); const [onlyMyValidators, setOnlyMyValidators] = useState(false); const [validatorFilter, setValidatorFilter] = diff --git a/apps/namadillo/src/App/routes.ts b/apps/namadillo/src/App/routes.ts index b72e21797d..42ff2d6ad9 100644 --- a/apps/namadillo/src/App/routes.ts +++ b/apps/namadillo/src/App/routes.ts @@ -1,6 +1,9 @@ export const routes = { root: "/", + // Referrals + referrals: "/referrals", + // Staking staking: "/staking", stakingBondingIncrement: "/staking/bonding/increment", diff --git a/apps/namadillo/src/atoms/staking/services.ts b/apps/namadillo/src/atoms/staking/services.ts index 03bc739a7d..d4381370f5 100644 --- a/apps/namadillo/src/atoms/staking/services.ts +++ b/apps/namadillo/src/atoms/staking/services.ts @@ -83,6 +83,7 @@ export const createWithdrawTx = async ( gasConfig: GasConfig ): Promise> => { const sdk = await getSdkInstance(); + return await buildTx( sdk, account, @@ -92,7 +93,6 @@ export const createWithdrawTx = async ( sdk.tx.buildWithdraw ); }; - export const createClaimTx = async ( chain: ChainSettings, account: Account, diff --git a/apps/namadillo/src/hooks/useTransactionCallbacks.tsx b/apps/namadillo/src/hooks/useTransactionCallbacks.tsx index 2e2d7f120e..4c001297f3 100644 --- a/apps/namadillo/src/hooks/useTransactionCallbacks.tsx +++ b/apps/namadillo/src/hooks/useTransactionCallbacks.tsx @@ -1,10 +1,12 @@ import { accountBalanceAtom, defaultAccountAtom } from "atoms/accounts"; import { shieldedBalanceAtom } from "atoms/balance/atoms"; +import { chainStatusAtom } from "atoms/chain"; import { shouldUpdateBalanceAtom, shouldUpdateProposalAtom } from "atoms/etc"; import { claimableRewardsAtom } from "atoms/staking"; import { useAtomValue, useSetAtom } from "jotai"; import { TransferStep, TransferTransactionData } from "types"; import { useTransactionEventListener } from "utils"; +import { saveReferralToSupabase } from "utils/supabase"; import { useTransactionActions } from "./useTransactionActions"; export const useTransactionCallback = (): void => { @@ -16,7 +18,7 @@ export const useTransactionCallback = (): void => { const { changeTransaction } = useTransactionActions(); const shouldUpdateProposal = useSetAtom(shouldUpdateProposalAtom); const shouldUpdateBalance = useSetAtom(shouldUpdateBalanceAtom); - + const chainStatus = useAtomValue(chainStatusAtom); const onBalanceUpdate = (): void => { // TODO: refactor this after event subscription is enabled on indexer shouldUpdateBalance(true); @@ -31,7 +33,15 @@ export const useTransactionCallback = (): void => { } }; - useTransactionEventListener("Bond.Success", onBalanceUpdate); + const successfulBond = async (): Promise => { + onBalanceUpdate(); + const referrerAddress = localStorage.getItem("referrerAddress"); + const refereeAddress = localStorage.getItem("refereeAddress"); + const epoch = chainStatus?.epoch; + await saveReferralToSupabase(referrerAddress!, refereeAddress!, epoch!); + }; + + useTransactionEventListener("Bond.Success", successfulBond); useTransactionEventListener("Unbond.Success", onBalanceUpdate); useTransactionEventListener("Withdraw.Success", onBalanceUpdate); useTransactionEventListener("Redelegate.Success", onBalanceUpdate); diff --git a/apps/namadillo/src/utils/supabase.ts b/apps/namadillo/src/utils/supabase.ts new file mode 100644 index 0000000000..ccf29867ad --- /dev/null +++ b/apps/namadillo/src/utils/supabase.ts @@ -0,0 +1,86 @@ +import { createClient } from "@supabase/supabase-js"; + +const supabaseUrl = process.env.SUPABASE_URL; +const supabaseKey = process.env.SUPABASE_ANON_KEY; +const supabase = createClient(supabaseUrl!, supabaseKey!); + +export const saveReferralToSupabase = async ( + referrerAddress: string, + refereeAddress: string, + epoch: number +): Promise<{ success: boolean; error?: unknown; data?: unknown }> => { + try { + // Make sure we have the required data + if (!referrerAddress || !refereeAddress || !epoch) { + console.error("Missing required referral data"); + return { success: false, error: "Missing required referral data" }; + } + + // First check if this referee already exists in the database + const { data: existingReferrals, error: fetchError } = await supabase + .from("referrals") + .select("*") + .eq("referee_address", refereeAddress) + .eq("referrer_address", referrerAddress) + .limit(1); + + if (fetchError) { + console.error("Error checking existing referral:", fetchError); + return { success: false, error: fetchError }; + } + + // If referee already exists, skip insertion + if (existingReferrals && existingReferrals.length > 0) { + console.log("Referee already exists in database, skipping insertion"); + return { success: true, data: existingReferrals[0] }; + } + + // Create referral record with timestamp + const referralData = { + referrer_address: referrerAddress, + referee_address: refereeAddress, + last_paid_epoch: epoch, + active: true, + }; + + // Send data to Supabase + const { data, error } = await supabase + .from("referrals") + .insert([referralData]); + + if (error) { + console.error("Error saving referral:", error); + return { success: false, error }; + } + + console.log("Referral saved successfully:", data); + return { success: true, data }; + } catch (err) { + console.error("Unexpected error saving referral:", err); + return { success: false, error: err }; + } +}; + +export const getReferralsFromSupabase = async (): Promise<{ + success: boolean; + error?: unknown; + data?: unknown; +}> => { + try { + const { data, error } = await supabase + .from("referrals") + .select("*") + .eq("active", true); + + if (error) { + console.error("Error fetching referrals:", error); + return { success: false, error }; + } + + console.log("Referrals fetched successfully:", data); + return { success: true, data }; + } catch (err) { + console.error("Unexpected error fetching referrals:", err); + return { success: false, error: err }; + } +}; diff --git a/apps/namadillo/vite.config.mjs b/apps/namadillo/vite.config.mjs index 671ffe3868..ff364c1fe4 100644 --- a/apps/namadillo/vite.config.mjs +++ b/apps/namadillo/vite.config.mjs @@ -1,12 +1,14 @@ /* eslint-disable */ import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; +import { defineConfig, loadEnv } from "vite"; import checker from "vite-plugin-checker"; import { nodePolyfills } from "vite-plugin-node-polyfills"; import { VitePWA } from "vite-plugin-pwa"; import tsconfigPaths from "vite-tsconfig-paths"; -export default defineConfig(() => { +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), ""); + return { server: { headers: { @@ -44,6 +46,11 @@ export default defineConfig(() => { plugins: () => [tsconfigPaths()], format: "es", }, + define: { + "process.env.SUPABASE_URL": JSON.stringify(env.SUPABASE_URL), + "process.env.SUPABASE_ANON_KEY": JSON.stringify(env.SUPABASE_ANON_KEY), + "process.env.INDEXER_URL": JSON.stringify(env.INDEXER_URL), + }, optimizeDeps: { esbuildOptions: { // Node.js global to browser globalThis diff --git a/yarn.lock b/yarn.lock index beb4c80fd3..bcee655934 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3829,6 +3829,7 @@ __metadata: "@namada/chain-registry": "npm:^1.0.0" "@namada/indexer-client": "npm:2.4.4" "@playwright/test": "npm:^1.24.1" + "@supabase/supabase-js": "npm:^2.49.4" "@svgr/webpack": "npm:^6.5.1" "@tailwindcss/container-queries": "npm:^0.1.1" "@tanstack/query-core": "npm:^5.40.0" @@ -3908,6 +3909,7 @@ __metadata: vite-tsconfig-paths: "npm:^5.0.1" web-vitals: "npm:^2.1.4" wonka: "npm:^6.3.4" + xlsx: "npm:^0.18.5" languageName: unknown linkType: soft @@ -4739,6 +4741,77 @@ __metadata: languageName: node linkType: hard +"@supabase/auth-js@npm:2.69.1": + version: 2.69.1 + resolution: "@supabase/auth-js@npm:2.69.1" + dependencies: + "@supabase/node-fetch": "npm:^2.6.14" + checksum: 10c0/efc08fc6be48769efc84105617f2cb681791641ba86480d29a316ae83beac8cf4f747bf00b4947c32992f9b7b0b40e3c2bf74013f01e070c38558169ab68b6f1 + languageName: node + linkType: hard + +"@supabase/functions-js@npm:2.4.4": + version: 2.4.4 + resolution: "@supabase/functions-js@npm:2.4.4" + dependencies: + "@supabase/node-fetch": "npm:^2.6.14" + checksum: 10c0/35871341ca96c35a416d81a6f035bd0d594d278f5cbe4492173766b3d6b9acfc52374b0a2b50e31a900a8e3a9dcb131d1eadf3808a9a9e1c10bbab7a2045d2d3 + languageName: node + linkType: hard + +"@supabase/node-fetch@npm:2.6.15, @supabase/node-fetch@npm:^2.6.14": + version: 2.6.15 + resolution: "@supabase/node-fetch@npm:2.6.15" + dependencies: + whatwg-url: "npm:^5.0.0" + checksum: 10c0/98d25cab2eba53c93c59e730d52d50065b1a7fe216c65224471e83e2064ebd45ae51ad09cb39ec263c3cb59e3d41870fc2e789ea2e9587480d7ba212b85daf38 + languageName: node + linkType: hard + +"@supabase/postgrest-js@npm:1.19.4": + version: 1.19.4 + resolution: "@supabase/postgrest-js@npm:1.19.4" + dependencies: + "@supabase/node-fetch": "npm:^2.6.14" + checksum: 10c0/1d14adc841f720e0035045f8b06cf2cb9f3b0a83ac903e268d5afb80b64d240ae64cb24372e0e9c857420b07010bfdb9a806f024fe60ac13468fd791ada2eb7f + languageName: node + linkType: hard + +"@supabase/realtime-js@npm:2.11.2": + version: 2.11.2 + resolution: "@supabase/realtime-js@npm:2.11.2" + dependencies: + "@supabase/node-fetch": "npm:^2.6.14" + "@types/phoenix": "npm:^1.5.4" + "@types/ws": "npm:^8.5.10" + ws: "npm:^8.18.0" + checksum: 10c0/1e91c8e70d4bf2cd25ed9d7d8c75c0fcc2ef4f53d03647cbaac790cf9f359295b5aa6ce0876f12e7e15804cfe9979398cd57fc92f19a43ee4e691d29abbe8e14 + languageName: node + linkType: hard + +"@supabase/storage-js@npm:2.7.1": + version: 2.7.1 + resolution: "@supabase/storage-js@npm:2.7.1" + dependencies: + "@supabase/node-fetch": "npm:^2.6.14" + checksum: 10c0/bcaa8bd275c59b8c5f6f00b9590ef54f008b63aacdcd8bf1747cb73f61ea7bd321bb816314ae0cf1bb318cd4d398515f9a135bde84ef960c19ac3c11e38d00fd + languageName: node + linkType: hard + +"@supabase/supabase-js@npm:^2.49.4": + version: 2.49.4 + resolution: "@supabase/supabase-js@npm:2.49.4" + dependencies: + "@supabase/auth-js": "npm:2.69.1" + "@supabase/functions-js": "npm:2.4.4" + "@supabase/node-fetch": "npm:2.6.15" + "@supabase/postgrest-js": "npm:1.19.4" + "@supabase/realtime-js": "npm:2.11.2" + "@supabase/storage-js": "npm:2.7.1" + checksum: 10c0/43f5500b4cee89fa975ef13036846e6406d7719c79e1c9563b9e6a54529aa7b8d17e86bd0cd2cd2394ce6894e85628f43a29809572a438d2fc493175bb25ac25 + languageName: node + linkType: hard + "@surma/rollup-plugin-off-main-thread@npm:^2.2.3": version: 2.2.3 resolution: "@surma/rollup-plugin-off-main-thread@npm:2.2.3" @@ -5638,6 +5711,13 @@ __metadata: languageName: node linkType: hard +"@types/phoenix@npm:^1.5.4": + version: 1.6.6 + resolution: "@types/phoenix@npm:1.6.6" + checksum: 10c0/4dfcb3fd36341ed5500de030291af14163c599857e00d2d4ff065d4c4600317d5d20aa170913fb9609747a09436e3add44db7d0c709bdf80f36cddcc67a42021 + languageName: node + linkType: hard + "@types/postcss-modules-local-by-default@npm:^4.0.2": version: 4.0.2 resolution: "@types/postcss-modules-local-by-default@npm:4.0.2" @@ -5913,6 +5993,15 @@ __metadata: languageName: node linkType: hard +"@types/ws@npm:^8.5.10": + version: 8.18.1 + resolution: "@types/ws@npm:8.18.1" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/61aff1129143fcc4312f083bc9e9e168aa3026b7dd6e70796276dcfb2c8211c4292603f9c4864fae702f2ed86e4abd4d38aa421831c2fd7f856c931a481afbab + languageName: node + linkType: hard + "@types/ws@npm:^8.5.5": version: 8.5.13 resolution: "@types/ws@npm:8.5.13" @@ -6483,6 +6572,13 @@ __metadata: languageName: node linkType: hard +"adler-32@npm:~1.3.0": + version: 1.3.1 + resolution: "adler-32@npm:1.3.1" + checksum: 10c0/c1b7185526ee1bbe0eac8ed414d5226af4cd02a0540449a72ec1a75f198c5e93352ba4d7b9327231eea31fd83c2d080d13baf16d8ed5710fb183677beb85f612 + languageName: node + linkType: hard + "adm-zip@npm:~0.5.x": version: 0.5.16 resolution: "adm-zip@npm:0.5.16" @@ -7827,6 +7923,16 @@ __metadata: languageName: node linkType: hard +"cfb@npm:~1.2.1": + version: 1.2.2 + resolution: "cfb@npm:1.2.2" + dependencies: + adler-32: "npm:~1.3.0" + crc-32: "npm:~1.2.0" + checksum: 10c0/87f6d9c3878268896ed6ca29dfe32a2aa078b12d0f21d8405c95911b74ab6296823d7312bbf5e18326d00b16cc697f587e07a17018c5edf7a1ba31dd5bc6da36 + languageName: node + linkType: hard + "chain-registry@npm:^1.63.100": version: 1.69.89 resolution: "chain-registry@npm:1.69.89" @@ -8113,6 +8219,13 @@ __metadata: languageName: node linkType: hard +"codepage@npm:~1.15.0": + version: 1.15.0 + resolution: "codepage@npm:1.15.0" + checksum: 10c0/2455b482302cb784b46dea60a8ee83f0c23e794bdd979556bdb107abe681bba722af62a37f5c955ff4efd68fdb9688c3986e719b4fd536c0e06bb25bc82abea3 + languageName: node + linkType: hard + "collect-v8-coverage@npm:^1.0.0": version: 1.0.2 resolution: "collect-v8-coverage@npm:1.0.2" @@ -8569,6 +8682,15 @@ __metadata: languageName: node linkType: hard +"crc-32@npm:~1.2.0, crc-32@npm:~1.2.1": + version: 1.2.2 + resolution: "crc-32@npm:1.2.2" + bin: + crc32: bin/crc32.njs + checksum: 10c0/11dcf4a2e77ee793835d49f2c028838eae58b44f50d1ff08394a610bfd817523f105d6ae4d9b5bef0aad45510f633eb23c903e9902e4409bed1ce70cb82b9bf0 + languageName: node + linkType: hard + "create-ecdh@npm:^4.0.4": version: 4.0.4 resolution: "create-ecdh@npm:4.0.4" @@ -11081,6 +11203,13 @@ __metadata: languageName: node linkType: hard +"frac@npm:~1.1.2": + version: 1.1.2 + resolution: "frac@npm:1.1.2" + checksum: 10c0/640740eb58b590eb38c78c676955bee91cd22d854f5876241a15c49d4495fa53a84898779dcf7eca30aabfe1c1a4a705752b5f224934257c5dda55c545413ba7 + languageName: node + linkType: hard + "fraction.js@npm:^4.3.7": version: 4.3.7 resolution: "fraction.js@npm:4.3.7" @@ -18980,6 +19109,15 @@ __metadata: languageName: node linkType: hard +"ssf@npm:~0.11.2": + version: 0.11.2 + resolution: "ssf@npm:0.11.2" + dependencies: + frac: "npm:~1.1.2" + checksum: 10c0/c3fd24a90dc37a9dc5c4154cb4121e27507c33ebfeee3532aaf03625756b2c006cf79c0a23db0ba16c4a6e88e1349455327867e03453fc9d54b32c546bc18ca6 + languageName: node + linkType: hard + "sshpk@npm:^1.7.0": version: 1.18.0 resolution: "sshpk@npm:1.18.0" @@ -21804,6 +21942,13 @@ __metadata: languageName: node linkType: hard +"wmf@npm:~1.0.1": + version: 1.0.2 + resolution: "wmf@npm:1.0.2" + checksum: 10c0/3fa5806f382632cadfe65d4ef24f7a583b0c0720171edb00e645af5248ad0bb6784e8fcee1ccd9f475a1a12a7523e2512e9c063731fbbdae14dc469e1c033d93 + languageName: node + linkType: hard + "wonka@npm:^6.3.4": version: 6.3.4 resolution: "wonka@npm:6.3.4" @@ -21818,6 +21963,13 @@ __metadata: languageName: node linkType: hard +"word@npm:~0.3.0": + version: 0.3.0 + resolution: "word@npm:0.3.0" + checksum: 10c0/c6da2a9f7a0d81a32fa6768a638d21b153da2be04f94f3964889c7cc1365d74b6ecb43b42256c3f926cd59512d8258206991c78c21000c3da96d42ff1238b840 + languageName: node + linkType: hard + "wordwrap@npm:^1.0.0": version: 1.0.0 resolution: "wordwrap@npm:1.0.0" @@ -22193,6 +22345,23 @@ __metadata: languageName: node linkType: hard +"xlsx@npm:^0.18.5": + version: 0.18.5 + resolution: "xlsx@npm:0.18.5" + dependencies: + adler-32: "npm:~1.3.0" + cfb: "npm:~1.2.1" + codepage: "npm:~1.15.0" + crc-32: "npm:~1.2.1" + ssf: "npm:~0.11.2" + wmf: "npm:~1.0.1" + word: "npm:~0.3.0" + bin: + xlsx: bin/xlsx.njs + checksum: 10c0/787cfa77034a3e86fdcde21572f1011c8976f87823a5e0ee5057f13b2f6e48f17a1710732a91b8ae15d7794945c7cba8a3ca904ea7150e028260b0ab8e1158c8 + languageName: node + linkType: hard + "xml-name-validator@npm:^4.0.0": version: 4.0.0 resolution: "xml-name-validator@npm:4.0.0"