Skip to content
Draft
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 .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ NEXT_PUBLIC_SAVING_CIRCLES_VIEWER_CONTRACT_ADDRESS=
NEXT_PUBLIC_AUTOMATIC_SAVING_CIRCLES_CONTRACT_ADDRESS=
# Optional; defaults to the zero address when unset (Goal savings stays hidden)
NEXT_PUBLIC_GOAL_SAVINGS_CONTRACT_ADDRESS=
NEXT_PUBLIC_MICROLOANS_CONTRACT_ADDRESS=
NEXT_PUBLIC_DEPOSIT_TOKEN_ADDRESS=
NEXT_PUBLIC_CHAIN_ID=
NEXT_PUBLIC_SAVING_CIRCLES_CONTRACT_CREATION_BLOCK=
Expand Down
3 changes: 3 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ The frontend is a Next.js App Router application that orchestrates both.
Rotating savings (`/new/rosca`) or a Shared goal (`/new/goal`). Both flows link back
to the picker. With the feature off, `/new` keeps the original rotating savings flow.
- `/goals/[id]` — a Shared goal's detail and actions (feature-gated: `goalSavings`).
- `/new/microloan`, `/microloans/[id]` — offer a Microloan, and its lender/borrower
actions (feature-gated: `microloans`, contract `NEXT_PUBLIC_MICROLOANS_CONTRACT_ADDRESS`).
Chain state only: no Supabase metadata or invite links yet.
- `/stacks/[id]` — a single circle's detail and actions.
- `/stacks/join` — accept an invite link.
- `api/onboard` — create the Supabase user record after Privy login.
Expand Down
14 changes: 14 additions & 0 deletions src/app/account/_components/account-microloans.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"use client";

import { FeatureGate } from "@/components/feature-gate";
import MicroloanList from "@/components/stack-lists/microloan-list";
import { Address } from "viem";

/** The feature-gated list of loans the profile owner lent or borrows. */
const AccountMicroloans = ({ address }: { address: Address }) => (
<FeatureGate feature="microloans">
<MicroloanList address={address} />
</FeatureGate>
);

export default AccountMicroloans;
2 changes: 2 additions & 0 deletions src/app/account/_components/your-stacks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Address } from "viem";
import AccountTab from "./account-tab";
import AccountUserStacks from "./account-user-stacks";
import AccountGoals from "./account-goals";
import AccountMicroloans from "./account-microloans";

const YourStacks = ({
address,
Expand All @@ -16,6 +17,7 @@ const YourStacks = ({
<AccountTab basePath={basePath} address={address} />
<AccountUserStacks address={address} />
<AccountGoals address={address} />
<AccountMicroloans address={address} />
</div>
);

Expand Down
213 changes: 213 additions & 0 deletions src/app/microloans/[id]/_components/borrower-actions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
"use client";

import BalanceHint from "@/app/microloans/_components/balance-hint";
import LocalButton from "@/components/button";
import NumericInput from "@/components/numeric-input";
import { useModal } from "@/components/modal/context";
import { LoanTerms } from "@/hooks/use-microloan";
import { useMicroloanAction } from "@/hooks/use-microloan-action";
import {
MICROLOAN_ACCEPT_ERRORS,
MICROLOAN_REPAY_ERRORS,
} from "@/lib/contract-errors";
import {
DEPOSIT_TOKEN,
formatDepositAmount,
parseDepositAmount,
} from "@/lib/deposit-token";
import { agreementHash } from "@/lib/microloan-terms";
import { isLoanRepayable, LoanState } from "@/lib/microloan-state";
import { Body, formatBalance, Heading3, Logo } from "@breadcoop/ui";
import { useState } from "react";
import { zeroHash } from "viem";

const BorrowerActions = ({
loanId,
terms,
state,
outstanding,
}: {
loanId: bigint;
terms: LoanTerms;
state: LoanState;
outstanding: bigint;
}) => {
if (state === LoanState.Offered) {
return <AcceptLoan loanId={loanId} terms={terms} />;
}

if (isLoanRepayable(state, outstanding)) {
return (
<RepayLoan loanId={loanId} terms={terms} outstanding={outstanding} />
);
}

return null;
};

function AcceptLoan({ loanId, terms }: { loanId: bigint; terms: LoanTerms }) {
const { setModal } = useModal();
const { runMicroloanAction } = useMicroloanAction();
const [agreement, setAgreement] = useState("");

const needsAgreement = terms.termsHash !== zeroHash;
const matches =
!needsAgreement || agreementHash(agreement) === terms.termsHash;

const accept = () =>
setModal({
type: "STACK_TX_INIT",
stackType: "microloan",
action: "acceptLoan",
id: loanId,
amount: terms.principal,
onConfirm: () =>
runMicroloanAction({
action: "acceptLoan",
functionName: "accept",
args: [loanId, terms.termsHash],
errors: MICROLOAN_ACCEPT_ERRORS,
amount: terms.principal,
}),
});

return (
<section className="bg-paper-0 p-5 *:mb-4 last:mb-0">
<header className="border-b border-paper-2 pb-4">
<Heading3 className="text-2xl">Accept the loan</Heading3>
</header>
<Body className="text-surface-grey">
You receive {formatToken(terms.principal)} now with no interest. Repay
it in full within {Number(terms.repaymentPeriod / BigInt(86400))} days
to also receive the {formatToken(terms.grant)} grant.
</Body>
{needsAgreement && (
<div className="flex flex-col gap-2">
<Body bold>Loan agreement</Body>
<Body className="text-xs text-surface-grey">
Paste the agreement the lender shared with you. Accepting records
on-chain that you agreed to exactly this text.
</Body>
<textarea
value={agreement}
onChange={(e) => setAgreement(e.target.value)}
rows={4}
className="w-full border border-paper-2 bg-paper-1 py-3 px-4 placeholder:text-surface-grey text-surface-grey-2 placeholder:font-light"
placeholder="Paste the agreement here"
/>
{agreement.trim() && (
<Body
className={`text-xs ${matches ? "text-system-green" : "text-system-red"}`}
>
{matches
? "This matches the lender's agreement."
: "This doesn't match the lender's agreement. Check you copied all of it."}
</Body>
)}
</div>
)}
<LocalButton
className="w-full font-bold"
onClick={accept}
disabled={!matches}
>
Accept loan
</LocalButton>
</section>
);
}

function RepayLoan({
loanId,
terms,
outstanding,
}: {
loanId: bigint;
terms: LoanTerms;
outstanding: bigint;
}) {
const { setModal } = useModal();
const { runMicroloanAction, balance } = useMicroloanAction(terms.token);
const [amountInput, setAmountInput] = useState("");

let amount = BigInt(0);
try {
amount = amountInput ? parseDepositAmount(amountInput) : BigInt(0);
} catch {
// Unparseable input stays at zero and keeps the button disabled
}
// The contract only pulls what is owed, so never ask for more
const payAmount = amount > outstanding ? outstanding : amount;

const repay = () =>
setModal({
type: "STACK_TX_INIT",
stackType: "microloan",
action: "repay",
id: loanId,
amount: payAmount,
onConfirm: () =>
runMicroloanAction({
action: "repay",
functionName: "repay",
args: [loanId, payAmount],
errors: MICROLOAN_REPAY_ERRORS,
approveAmount: payAmount,
amount: payAmount,
}),
});

return (
<section className="bg-paper-0 p-5 *:mb-4 last:mb-0">
<header className="border-b border-paper-2 pb-4">
<Heading3 className="text-2xl">Repay</Heading3>
</header>
<Body className="text-surface-grey">
You still owe {formatToken(outstanding)}. Pay any amount, any time.
</Body>
<div className="flex flex-col gap-2">
<div className="relative">
<NumericInput
value={amountInput}
onChange={(e) => setAmountInput(e.target.value)}
className="w-full pr-24"
placeholder="Amount"
allowDecimal
/>
<div className="absolute top-1/2 -translate-y-1/2 right-3 flex items-center gap-2">
<button
type="button"
className="text-primary-blue text-sm font-bold"
onClick={() => setAmountInput(formatDepositAmount(outstanding))}
>
All
</button>
<div className="p-1 bg-paper-main">
<Logo
text={DEPOSIT_TOKEN.symbol}
className="size-6"
variant="square"
/>
</div>
</div>
</div>
<LocalButton
className="w-full font-bold"
onClick={repay}
disabled={
payAmount === BigInt(0) ||
(balance !== undefined && balance < payAmount)
}
>
Repay
</LocalButton>
<BalanceHint balance={balance} amount={payAmount} />
</div>
</section>
);
}

const formatToken = (amount: bigint) =>
`${formatBalance(+formatDepositAmount(amount), 2)} ${DEPOSIT_TOKEN.symbol}`;

export default BorrowerActions;
66 changes: 66 additions & 0 deletions src/app/microloans/[id]/_components/grant-panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"use client";

import LocalButton from "@/components/button";
import { useModal } from "@/components/modal/context";
import { LoanTerms } from "@/hooks/use-microloan";
import { useMicroloanAction } from "@/hooks/use-microloan-action";
import { MICROLOAN_PAYOUT_ERRORS } from "@/lib/contract-errors";
import { LoanState } from "@/lib/microloan-state";
import { Body, Heading3 } from "@breadcoop/ui";

/**
* Once the loan is repaid, anyone can release the grant; it can only ever go
* to the borrower.
*/
const GrantPanel = ({
loanId,
terms,
state,
}: {
loanId: bigint;
terms: LoanTerms;
state: LoanState;
}) => {
const { setModal } = useModal();
const { runMicroloanAction } = useMicroloanAction();

if (state !== LoanState.Repaid) return null;

const release = () =>
setModal({
type: "STACK_TX_INIT",
stackType: "microloan",
action: "releaseGrant",
id: loanId,
amount: terms.grant,
onConfirm: () =>
runMicroloanAction({
action: "releaseGrant",
functionName: "releaseGrant",
args: [loanId],
errors: MICROLOAN_PAYOUT_ERRORS,
amount: terms.grant,
}),
});

return (
<section className="bg-paper-0 p-5 *:mb-4 last:mb-0">
<header className="border-b border-paper-2 pb-4">
<Heading3 className="text-2xl">Release the grant</Heading3>
</header>
<Body className="text-surface-grey">
The loan is fully repaid. Anyone can release the grant, and it always
goes to the borrower.
</Body>
<LocalButton
variant="positive"
className="w-full font-bold"
onClick={release}
>
Release grant
</LocalButton>
</section>
);
};

export default GrantPanel;
Loading