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
2 changes: 1 addition & 1 deletion examples/framework/nextjs-evm/src/app/Provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const config = createConfig(
// Required App Info
appName: "Sonar Next.js example app",
appDescription: "Next.js app showing how to integrate with the Sonar API via backend OAuth.",
})
}),
);

const queryClient = new QueryClient();
Expand Down
4 changes: 2 additions & 2 deletions examples/framework/nextjs-evm/src/app/actions/sonar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const getEntities = createSonarServerAction<ListAvailableEntitiesInput, L
throw new Error("Missing saleUUID");
}
return client.listAvailableEntities({ saleUUID });
}
},
);

type PrePurchaseCheckInput = { saleUUID: string; entityID: string; walletAddress: string };
Expand All @@ -32,7 +32,7 @@ export const prePurchaseCheck = createSonarServerAction<PrePurchaseCheckInput, P
throw new Error("Missing required parameters");
}
return client.prePurchaseCheck({ saleUUID, entityID, walletAddress });
}
},
);

type GeneratePurchasePermitInput = { saleUUID: string; entityID: string; walletAddress: string };
Expand Down
109 changes: 109 additions & 0 deletions examples/framework/nextjs-evm/src/app/components/sale/CancelCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"use client";

import { Hex } from "@echoxyz/sonar-core";
import { useState } from "react";
import { useSaleContract } from "../../hooks/use-sale-contract";

const CANCELLATION_STAGE = 2;

function CancelSection({ saleSpecificEntityID }: { saleSpecificEntityID: Hex }) {
const {
cancelBid,
contractStage,
entityState,
entityStateError,
awaitingTxReceipt,
txReceipt,
awaitingTxReceiptError,
} = useSaleContract(saleSpecificEntityID);

const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | undefined>(undefined);

const isCancellationStage = contractStage === CANCELLATION_STAGE;

const cancel = async () => {
setLoading(true);
setError(undefined);
try {
await cancelBid();
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
};

const committedAmount = entityState?.currentBid?.amount;
const hasCommitment = committedAmount !== undefined && committedAmount > BigInt(0);

return (
<div className="flex flex-col gap-4 items-center">
{entityStateError ? (
<div className="bg-white p-2 rounded-md w-full">
<p className="text-red-500 wrap-anywhere">{entityStateError.message}</p>
</div>
) : hasCommitment ? (
<>
<div className="bg-white p-2 rounded-md w-full">
<p className="text-gray-900">Current committed amount: {`${Number(committedAmount) / 1e6} USDC`}</p>
</div>

{!isCancellationStage && (
<div className="bg-amber-50 border border-amber-200 p-3 rounded-md w-full">
<p className="text-amber-800 text-sm font-medium">Contract not in Cancellation stage</p>
Comment thread
WillSewell marked this conversation as resolved.
<p className="text-amber-700 text-sm mt-1">
The &quot;Cancel Bid&quot; button is only active when the contract is in the Cancellation stage. To test
this feature, use the founder&apos;s dashboard to open the cancellation state (
<code className="font-mono bg-amber-100 px-1 rounded">openCancellation</code>). See the{" "}
<a
href="https://docs.echo.xyz/sonar/reference/contracts/settlement-sale"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
contract docs
</a>
.
</p>
</div>
)}

<button
disabled={loading || awaitingTxReceipt || !isCancellationStage}
className="bg-gray-700 hover:bg-gray-800 text-white font-medium py-2 px-6 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed w-full"
onClick={cancel}
>
<p className="text-gray-100">{loading || awaitingTxReceipt ? "Loading..." : "Cancel Bid"}</p>
</button>
</>
) : committedAmount !== undefined ? (
<div className="bg-gray-50 border border-gray-200 p-3 rounded-md w-full">
<p className="text-gray-600 text-sm">No active commitment to cancel.</p>
</div>
) : (
<div className="bg-white p-2 rounded-md w-full">
<p className="text-gray-500 text-sm">Loading...</p>
</div>
)}

{awaitingTxReceipt && !txReceipt && <p className="text-gray-900">Waiting for transaction receipt...</p>}
{txReceipt?.status === "success" && (
<p className="text-green-500">Cancellation successful — your funds have been refunded</p>
)}
{txReceipt?.status === "reverted" && <p className="text-red-500">Cancellation reverted</p>}
{error && <p className="text-red-500 wrap-anywhere">{error.message}</p>}
{awaitingTxReceiptError && <p className="text-red-500 wrap-anywhere">{awaitingTxReceiptError.message}</p>}
</div>
);
}

function CancelCard({ saleSpecificEntityID }: { saleSpecificEntityID: Hex }) {
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">
<CancelSection saleSpecificEntityID={saleSpecificEntityID} />
</div>
);
}

export default CancelCard;
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import { saleUUID, paymentTokenAddress } from "@/lib/config";
import { useSonarPurchase } from "../../hooks/use-sonar-purchase";
import { useSaleContract } from "../../hooks/use-sale-contract";

const COMMITMENT_STAGE = 1;

function readinessConfig(
sonarPurchaser: UseSonarPurchaseResultReadyToPurchase | UseSonarPurchaseResultNotReadyToPurchase
sonarPurchaser: UseSonarPurchaseResultReadyToPurchase | UseSonarPurchaseResultNotReadyToPurchase,
) {
const okConfig = (msg: string) => ({
fgCol: "text-green-800",
Expand Down Expand Up @@ -39,11 +41,11 @@ function readinessConfig(
return warningConfig("The connected wallet is not eligible for this sale. Connect a different wallet.");
case PrePurchaseFailureReason.MAX_WALLETS_USED:
return warningConfig(
"Maximum number of wallets reached — This entity can't use the connected wallet. Use a previous wallet."
"Maximum number of wallets reached — This entity can't use the connected wallet. Use a previous wallet.",
);
case PrePurchaseFailureReason.WALLET_NOT_LINKED:
return warningConfig(
"Wallet not linked — The connected wallet is not linked to your entity. Please link it first."
"Wallet not linked — The connected wallet is not linked to your entity. Please link it first.",
);
case PrePurchaseFailureReason.SALE_NOT_ACTIVE:
return errorConfig("The sale is not currently active.");
Expand All @@ -70,6 +72,7 @@ function CommitSection({
awaitingTxReceiptError,
isWrongChain,
usdcBalance,
contractStage,
} = useSaleContract(saleSpecificEntityID);

const [loading, setLoading] = useState(false);
Expand All @@ -87,6 +90,7 @@ function CommitSection({

const hasExistingCommitment = isEntityStateLoaded && currentTotalRaw > 0n;
const hasInsufficientBalance = usdcBalance != null && isIncrementAmountValid && incrementRaw > usdcBalance;
const notInCommitmentStage = contractStage !== undefined && contractStage !== COMMITMENT_STAGE;

const [showInput, setShowInput] = useState(true);

Expand Down Expand Up @@ -127,10 +131,27 @@ function CommitSection({
<div className="flex flex-col gap-2">
{hasExistingCommitment && (
<p className="text-sm text-gray-600">
Current commitment:{" "}
<span className="font-semibold text-gray-900">{currentTotalReadableStr} USDC</span>
Current commitment: <span className="font-semibold text-gray-900">{currentTotalReadableStr} USDC</span>
</p>
)}
{notInCommitmentStage && (
<div className="bg-amber-50 border border-amber-200 p-3 rounded-md w-full">
<p className="text-amber-800 text-sm font-medium">Contract not in Commitment stage</p>
<p className="text-amber-700 text-sm mt-1">
The Commit button is only active during the Commitment stage. Use the founder&apos;s dashboard to open the
commitment period (<code className="font-mono bg-amber-100 px-1 rounded">openCommitment</code>). See the{" "}
<a
href="https://docs.echo.xyz/sonar/reference/contracts/settlement-sale"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
contract docs
</a>
.
</p>
</div>
)}
{showInput ? (
<>
<div className="flex flex-col gap-1">
Expand All @@ -154,7 +175,7 @@ function CommitSection({
)}
</div>
<button
disabled={loading || awaitingTxReceipt || !isIncrementAmountValid || hasInsufficientBalance}
disabled={loading || awaitingTxReceipt || !isIncrementAmountValid || hasInsufficientBalance || notInCommitmentStage}
className="bg-blue-600 hover:bg-blue-700 text-white font-medium py-2 px-6 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
onClick={purchase}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ function calculateCommitmentTotal(amounts: WalletTokenAmount[]): bigint {
}

// The timestamp is a string in ISO 8601 format
function formatTimestamp(timestamp: string ): string {
function formatTimestamp(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleString(undefined, {
month: "short",
Expand All @@ -35,9 +35,7 @@ function CommitmentRow({ commitment, decimals }: CommitmentRowProps) {
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-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>
Expand Down Expand Up @@ -81,9 +79,7 @@ export function CommitmentDataCard({ saleUUID }: { saleUUID: string }) {
<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>
<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>
Expand Down
29 changes: 28 additions & 1 deletion examples/framework/nextjs-evm/src/app/hooks/use-sale-contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,20 @@ export const useSaleContract = (saleSpecificEntityID: Hex) => {
[writeContractAsync, config, chainId, switchChainAsync],
);

const cancelBid = useCallback(async () => {
if (chainId !== baseSepolia.id) {
await switchChainAsync({ chainId: baseSepolia.id });
}
const { request } = await simulateContract(config, {
address: saleContract,
abi: settlementSaleAbi,
functionName: "cancelBid",
args: [],
});
const hash = await writeContractAsync(request);
setTxHash(hash);
}, [writeContractAsync, config, chainId, switchChainAsync]);

const { data: entityStates, error: entityStateError } = useReadContract({
address: saleContract,
abi: settlementSaleAbi,
Expand All @@ -111,18 +125,31 @@ export const useSaleContract = (saleSpecificEntityID: Hex) => {
});

const isEntityStateLoaded = entityStates !== undefined;
const currentTotalRaw: bigint = entityStates?.[0]?.currentBid?.amount ?? 0n;
const entityState = entityStates?.[0];
const currentTotalRaw: bigint = entityState?.currentBid?.amount ?? 0n;
const currentTotalReadableStr = (Number(currentTotalRaw) / 1e6).toLocaleString(undefined, {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});

const { data: contractStage } = useReadContract({
address: saleContract,
abi: settlementSaleAbi,
functionName: "stage",
query: {
refetchInterval: 3000,
},
});

return {
entityStateError,
isEntityStateLoaded,
entityState,
currentTotalRaw,
currentTotalReadableStr,
commitWithPermit,
cancelBid,
contractStage,
awaitingTxReceipt,
txReceipt,
awaitingTxReceiptError,
Expand Down
Loading
Loading