diff --git a/.env.local.example b/.env.local.example index dcc96d3b..ab220a33 100644 --- a/.env.local.example +++ b/.env.local.example @@ -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= diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 257a52b7..abb8a94b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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. diff --git a/src/app/account/_components/account-microloans.tsx b/src/app/account/_components/account-microloans.tsx new file mode 100644 index 00000000..04bf3e2a --- /dev/null +++ b/src/app/account/_components/account-microloans.tsx @@ -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 }) => ( + + + +); + +export default AccountMicroloans; diff --git a/src/app/account/_components/your-stacks.tsx b/src/app/account/_components/your-stacks.tsx index bd15a607..a1c97236 100644 --- a/src/app/account/_components/your-stacks.tsx +++ b/src/app/account/_components/your-stacks.tsx @@ -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, @@ -16,6 +17,7 @@ const YourStacks = ({ + ); diff --git a/src/app/microloans/[id]/_components/borrower-actions.tsx b/src/app/microloans/[id]/_components/borrower-actions.tsx new file mode 100644 index 00000000..c050f65c --- /dev/null +++ b/src/app/microloans/[id]/_components/borrower-actions.tsx @@ -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 ; + } + + if (isLoanRepayable(state, outstanding)) { + return ( + + ); + } + + 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 ( +
+
+ Accept the loan +
+ + 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. + + {needsAgreement && ( +
+ Loan agreement + + Paste the agreement the lender shared with you. Accepting records + on-chain that you agreed to exactly this text. + +