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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
"format": "prettier --write ."
},
"dependencies": {
"@echoxyz/sonar-core": "^0.13.0",
"@echoxyz/sonar-react": "^0.12.2",
"@echoxyz/sonar-core": "^0.14.0",
"@echoxyz/sonar-react": "^0.13.0",
"@tanstack/react-query": "^5.90.12",
"connectkit": "^1.9.1",
"next": "^15.5.7",
Expand Down
22 changes: 11 additions & 11 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

15 changes: 10 additions & 5 deletions src/app/Provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ConnectKitProvider, getDefaultConfig } from "connectkit";
import { baseSepolia } from "wagmi/chains";
import { SessionProvider } from "./hooks/use-session";
import { sonarConfig } from "@/lib/config";
import { SonarProvider } from "@echoxyz/sonar-react";

const config = createConfig(
getDefaultConfig({
Expand All @@ -27,11 +29,14 @@ const queryClient = new QueryClient();
export const Provider = ({ children }: { children: React.ReactNode }) => {
return (
<SessionProvider>
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<ConnectKitProvider>{children}</ConnectKitProvider>
</QueryClientProvider>
</WagmiProvider>
{/* Only required for un-authenticated requests direct from the frontend (e.g. to read sale commitment data) */}
<SonarProvider config={sonarConfig}>
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<ConnectKitProvider>{children}</ConnectKitProvider>
</QueryClientProvider>
</WagmiProvider>
</SonarProvider>
</SessionProvider>
);
};
119 changes: 119 additions & 0 deletions src/app/components/sale/CommitmentDataCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { useCommitmentData } from "@echoxyz/sonar-react";
import { Commitment, WalletTokenAmount } from "@echoxyz/sonar-core";

function formatAmount(amount: bigint, decimals: number): string {
const divisor = BigInt(10 ** decimals);
const integerPart = amount / divisor;
return `$${integerPart.toLocaleString()}`;
}

function calculateCommitmentTotal(amounts: WalletTokenAmount[]): bigint {
return amounts.reduce((sum, item) => sum + BigInt(item.Amount), BigInt(0));
}

// The timestamp is a string in ISO 8601 format
function formatTimestamp(timestamp: string ): string {
const date = new Date(timestamp);
return date.toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}

interface CommitmentRowProps {
commitment: Commitment;
decimals: number;
}

function CommitmentRow({ commitment, decimals }: CommitmentRowProps) {
// The commitment data gives the amount by wallet and payment token,
// but for simplicity, we just show the total amount and the wallet with the most committed amount.
const total = calculateCommitmentTotal(commitment.Amounts);

return (
<div className="flex justify-between items-center py-2 border-b border-gray-100 last:border-b-0">
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium text-gray-900">
{formatAmount(total, decimals)}
</span>
<span className="text-xs text-gray-500">{commitment.SaleSpecificEntityID}</span>
</div>
<span className="text-xs text-gray-400">{formatTimestamp(commitment.CreatedAt)}</span>
</div>
);
}

export function CommitmentDataCard({ saleUUID }: { saleUUID: string }) {
// Note: this is using a public API (no authentication required),
// so it doesn't matter whether we call this from the frontend or the backend.
const { loading, commitmentData, error } = useCommitmentData({ saleUUID, pollingIntervalMs: 10000 });

// Only show loading placeholder when there's no existing data to display
if (loading && !commitmentData) {
return (
<div className="flex flex-col gap-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-gray-600">Loading commitment data...</p>
</div>
);
}

if (error && !commitmentData) {
return (
<div className="flex flex-col gap-4 p-4 bg-red-50 rounded-lg border border-red-200">
<p className="text-red-800 font-medium">Error loading commitment data</p>
<p className="text-red-700 text-sm">{error.message}</p>
</div>
);
}

if (!commitmentData) {
return null;
}

// Commitments are sorted by CreatedAt in descending order, so we can just slice the first 20
const recentCommitments = commitmentData.Commitments.slice(0, 20);

return (
<div className="flex flex-col gap-4 p-4 bg-linear-to-r from-indigo-50 to-blue-50 rounded-lg border border-indigo-200">
{/* Summary Stats */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-white p-3 rounded-lg">
<p className="text-sm text-gray-500">Total Committers</p>
<p className="text-2xl font-bold text-gray-900">
{commitmentData.UniqueCommitmentCount.toLocaleString()}
</p>
</div>
<div className="bg-white p-3 rounded-lg">
<p className="text-sm text-gray-500">Total Committed</p>
<p className="text-2xl font-bold text-gray-900">
{formatAmount(BigInt(commitmentData.TotalCommitmentAmount), commitmentData.PaymentTokenDecimals)}
</p>
</div>
</div>

{/* Recent Commitments */}
{recentCommitments.length > 0 && (
<div className="flex flex-col gap-2">
<h4 className="text-sm font-medium text-gray-700">Recent Commitments</h4>
<div className="bg-white rounded-lg p-3 max-h-80 overflow-y-auto">
{recentCommitments.map((commitment) => (
<CommitmentRow
key={commitment.CommitmentID}
commitment={commitment}
decimals={commitmentData.PaymentTokenDecimals}
/>
))}
</div>
</div>
)}

{recentCommitments.length === 0 && (
<div className="bg-white rounded-lg p-4 text-center">
<p className="text-gray-500">No commitments yet</p>
</div>
)}
</div>
);
}
36 changes: 23 additions & 13 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import CommitCard from "./components/sale/CommitCard";
import { EntitiesList } from "./components/registration/EntitiesList";
import { EligibilityResults } from "./components/registration/EligibilityResults";
import { ConnectKitButton } from "connectkit";
import { CommitmentDataCard } from "./components/sale/CommitmentDataCard";

export default function Home() {
const [saleIsLive, setSaleIsLive] = useState(false);
Expand Down Expand Up @@ -189,24 +190,33 @@ export default function Home() {
)}

{/* Sale Phase */}
{sonarConnected && saleIsLive && (
{saleIsLive && (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold text-gray-900">Your Entity Information</h2>
<ConnectKitButton />
<EntitySection />

{isEligible && address && (
{sonarConnected && (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold text-gray-900">Commit funds</h2>
<CommitCard
entityID={entity.EntityID}
saleSpecificEntityID={entity.SaleSpecificEntityID}
walletAddress={address}
/>
<h2 className="text-xl font-semibold text-gray-900">Your Entity Information</h2>
<ConnectKitButton />
<EntitySection />

{isEligible && address && (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold text-gray-900">Commit funds</h2>
<CommitCard
entityID={entity.EntityID}
saleSpecificEntityID={entity.SaleSpecificEntityID}
walletAddress={address}
/>
</div>
)}

{entity && !isEligible && <NotEligibleMessage sonarHomeURL={sonarHomeURL.href} />}
</div>
)}

{entity && !isEligible && <NotEligibleMessage sonarHomeURL={sonarHomeURL.href} />}
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold text-gray-900">Sale Commitment Data</h2>
<CommitmentDataCard saleUUID={saleUUID} />
</div>
</div>
)}
</div>
Expand Down