diff --git a/env.example b/env.example
index 7c2b35599..959306ed7 100644
--- a/env.example
+++ b/env.example
@@ -4,6 +4,8 @@ REACT_APP_CORS_PROXY_URL=http://localhost:8001
REACT_APP_DAO_DEPLOYER_API=http://localhost:3001
REACT_APP_ENV=DEV
REACT_APP_HASURA_URL=http://localhost:8080/v1/graphql
+# Homebase Lite backend: off-chain polls, communities, and DAO email alerts
+REACT_APP_LITE_API_URL=http://localhost:3005
REACT_APP_LAUNCH_DARKLY_SDK_DEV=your_launch_darkly_sdk_key_here
REACT_APP_MIXPANEL_DEBUG_ENABLED=false
diff --git a/src/models/Polls.ts b/src/models/Polls.ts
index 800ca70f3..9fbf567ef 100644
--- a/src/models/Polls.ts
+++ b/src/models/Polls.ts
@@ -2,6 +2,17 @@ export enum ProposalStatus {
ACTIVE = "active",
CLOSED = "closed"
}
+export interface PollFundingRequest {
+ recipient: string
+ amount: string
+}
+
+export interface PollOnchainProposal {
+ daoAddress: string
+ proposalKey: string
+ network: string
+}
+
export interface Poll {
_id?: string
daoID: string | undefined
@@ -27,6 +38,15 @@ export interface Poll {
isXTZ: boolean
id?: string
getStatus?: any
+ // Optional treasury funding attached to the poll. Sent to the lite backend
+ // inside the signed payload and returned by the poll read endpoints.
+ fundingRequest?: PollFundingRequest
+ // Set by the backend once the poll has been promoted to an on-chain proposal.
+ onchainProposal?: PollOnchainProposal
+ // Form-only fields for the optional "Funding request" section. They are
+ // folded into `fundingRequest` (or dropped) before the payload is signed.
+ fundingRecipient?: string
+ fundingAmount?: string
}
export interface Vote {
diff --git a/src/modules/explorer/components/DAOEmailAlerts.tsx b/src/modules/explorer/components/DAOEmailAlerts.tsx
new file mode 100644
index 000000000..093708fa6
--- /dev/null
+++ b/src/modules/explorer/components/DAOEmailAlerts.tsx
@@ -0,0 +1,125 @@
+import React, { useState } from "react"
+import { Grid, styled, TextField, Typography } from "@mui/material"
+import { SmallButton } from "modules/common/SmallButton"
+import { useNotification } from "modules/common/hooks/useNotification"
+import { useTezos } from "services/beacon/hooks/useTezos"
+import { subscribeToDAOAlerts } from "services/services/lite/lite-services"
+import { ContentContainer } from "./ContentContainer"
+
+const AlertsContainer = styled(ContentContainer)(({ theme }) => ({
+ padding: "24px 38px",
+ [theme.breakpoints.down("lg")]: {
+ width: "inherit"
+ }
+}))
+
+const TitleText = styled(Typography)({
+ fontSize: 18,
+ fontWeight: 500
+})
+
+const HelperText = styled(Typography)(({ theme }) => ({
+ fontSize: 14,
+ fontWeight: 300,
+ color: theme.palette.primary.light
+}))
+
+const EmailInput = styled(TextField)({
+ "background": "#2f3438",
+ "borderRadius": 8,
+ "flex": "1 1 280px",
+ "maxWidth": 480,
+ "& .MuiInputBase-input": {
+ padding: "12px 16px",
+ fontSize: 16,
+ fontWeight: 300
+ }
+})
+
+// Deliberately permissive: the backend is the authority on deliverability, this
+// only stops obviously malformed input from costing a round trip.
+const looksLikeEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
+
+export const DAOEmailAlerts: React.FC<{ daoAddress: string; daoName?: string }> = ({ daoAddress, daoName }) => {
+ const { network } = useTezos()
+ const openNotification = useNotification()
+ const [email, setEmail] = useState("")
+ const [isSubmitting, setIsSubmitting] = useState(false)
+ const [confirmationSent, setConfirmationSent] = useState(false)
+
+ const onSubscribe = async () => {
+ if (!looksLikeEmail(email)) {
+ openNotification({
+ message: "Please enter a valid email address",
+ autoHideDuration: 3000,
+ variant: "error"
+ })
+ return
+ }
+
+ try {
+ setIsSubmitting(true)
+ const resp = await subscribeToDAOAlerts(email.trim(), daoAddress, network, daoName)
+
+ if (!resp.ok) {
+ openNotification({
+ message: "Could not subscribe to email alerts",
+ autoHideDuration: 3000,
+ variant: "error"
+ })
+ return
+ }
+
+ setConfirmationSent(true)
+ setEmail("")
+ openNotification({
+ message: "Check your inbox to confirm",
+ autoHideDuration: 5000,
+ variant: "success"
+ })
+ } catch (error) {
+ console.log("error: ", error)
+ openNotification({
+ message: "Could not subscribe to email alerts",
+ autoHideDuration: 3000,
+ variant: "error"
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ return (
+
+
+
+ Get email alerts for this DAO
+ Be notified when proposals are created and when voting is about to close.
+
+
+ setEmail(event.target.value)}
+ onKeyDown={event => {
+ if (event.key === "Enter" && !isSubmitting) {
+ onSubscribe()
+ }
+ }}
+ />
+
+ {isSubmitting ? "Subscribing..." : "Subscribe"}
+
+
+ {confirmationSent ? (
+
+ Check your inbox to confirm
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/modules/explorer/components/PendingVotesBanner.tsx b/src/modules/explorer/components/PendingVotesBanner.tsx
new file mode 100644
index 000000000..3e6fdc346
--- /dev/null
+++ b/src/modules/explorer/components/PendingVotesBanner.tsx
@@ -0,0 +1,112 @@
+import React, { useMemo } from "react"
+import { Grid, styled, Typography } from "@mui/material"
+import HowToVoteIcon from "@mui/icons-material/HowToVote"
+import { useHistory } from "react-router-dom"
+import { useDAO } from "services/services/dao/hooks/useDAO"
+import { useProposals } from "services/services/dao/hooks/useProposals"
+import { ProposalStatus } from "services/services/dao/mappers/proposal/types"
+import { useTezos } from "services/beacon/hooks/useTezos"
+import { useDAOID } from "../pages/DAO/router"
+import { ContentContainer } from "./ContentContainer"
+
+const BannerContainer = styled(ContentContainer)(({ theme }) => ({
+ "padding": "18px 38px",
+ "cursor": "pointer",
+ "border": `1px solid ${theme.palette.secondary.main}`,
+ "&:hover": {
+ opacity: 0.9
+ },
+ [theme.breakpoints.down("lg")]: {
+ width: "inherit"
+ }
+}))
+
+const BannerText = styled(Typography)({
+ fontSize: 16,
+ fontWeight: 500
+})
+
+const BannerHint = styled(Typography)(({ theme }) => ({
+ fontSize: 14,
+ fontWeight: 300,
+ color: theme.palette.primary.light
+}))
+
+/**
+ * Nudges a governance-token holder towards proposals that are open for voting
+ * and that they have not voted on yet.
+ */
+export const PendingVotesBanner: React.FC = () => {
+ const daoId = useDAOID()
+ const navigate = useHistory()
+ const { account } = useTezos()
+ const { data: dao, cycleInfo, ledger } = useDAO(daoId)
+ const { data: proposals } = useProposals(daoId)
+
+ // Only nudge people who actually have a stake in this DAO.
+ const isTokenHolder = useMemo(() => {
+ if (!account || !ledger) {
+ return false
+ }
+
+ return ledger.some(
+ entry => entry.holder.address.toLowerCase() === account.toLowerCase() && entry.total_balance.gt(0)
+ )
+ }, [account, ledger])
+
+ const pendingProposals = useMemo(() => {
+ if (!proposals || !cycleInfo || !account) {
+ return []
+ }
+
+ return proposals.filter(proposal => {
+ const status = proposal.getStatus(cycleInfo.currentLevel).status
+ if (status !== ProposalStatus.ACTIVE) {
+ return false
+ }
+
+ return !proposal.voters.some(
+ (voter: { address: string }) => voter.address.toLowerCase() === account.toLowerCase()
+ )
+ })
+ }, [proposals, cycleInfo, account])
+
+ // The DAO alternates proposing/voting periods of `period` blocks. Only show a
+ // countdown when we have both the blocks left and an average block time.
+ const closesIn = useMemo(() => {
+ if (!cycleInfo || cycleInfo.type !== "voting" || !cycleInfo.timeEstimateForNextBlock) {
+ return undefined
+ }
+
+ const secondsLeft = cycleInfo.blocksLeft * cycleInfo.timeEstimateForNextBlock
+ if (!Number.isFinite(secondsLeft) || secondsLeft <= 0) {
+ return undefined
+ }
+
+ const hoursLeft = Math.round(secondsLeft / 3600)
+ if (hoursLeft < 1) {
+ return `${Math.max(1, Math.round(secondsLeft / 60))} minutes`
+ }
+
+ return `${hoursLeft} ${hoursLeft === 1 ? "hour" : "hours"}`
+ }, [cycleInfo])
+
+ if (!dao || !isTokenHolder || pendingProposals.length === 0) {
+ return null
+ }
+
+ return (
+ navigate.push(`/explorer/dao/${daoId}/proposals`)}>
+
+
+
+
+ {pendingProposals.length} {pendingProposals.length === 1 ? "proposal is" : "proposals are"} waiting for your
+ vote
+
+ {closesIn ? Voting closes in {closesIn} : null}
+
+
+
+ )
+}
diff --git a/src/modules/explorer/components/ProposalForm.tsx b/src/modules/explorer/components/ProposalForm.tsx
index 12691f1dd..22e9d0af5 100644
--- a/src/modules/explorer/components/ProposalForm.tsx
+++ b/src/modules/explorer/components/ProposalForm.tsx
@@ -60,6 +60,9 @@ interface Props {
handleClose: () => void
defaultValues?: ProposalFormDefaultValues
defaultTab: number
+ // Fired only when the form is actually submitted, so callers can tell a
+ // submit apart from a plain dismiss.
+ onSubmitted?: () => void
}
const enabledForms: Record<
@@ -92,7 +95,13 @@ const Content = styled(Grid)({
paddingBottom: 24
})
-export const ProposalFormContainer: React.FC = ({ open, handleClose, defaultValues, defaultTab }) => {
+export const ProposalFormContainer: React.FC = ({
+ open,
+ handleClose,
+ defaultValues,
+ defaultTab,
+ onSubmitted
+}) => {
const daoId = useDAOID()
const { data: dao } = useDAO(daoId)
const { data: daoHoldings } = useDAOHoldings(daoId)
@@ -159,8 +168,9 @@ export const ProposalFormContainer: React.FC = ({ open, handleClose, defa
methods.reset()
handleClose()
+ onSubmitted?.()
},
- [dao, handleClose, methods, registryMutate]
+ [dao, handleClose, methods, registryMutate, onSubmitted]
)
return (
diff --git a/src/modules/explorer/hooks/useAlertsOutcomeToast.ts b/src/modules/explorer/hooks/useAlertsOutcomeToast.ts
new file mode 100644
index 000000000..0b2b7cbeb
--- /dev/null
+++ b/src/modules/explorer/hooks/useAlertsOutcomeToast.ts
@@ -0,0 +1,42 @@
+import { useEffect } from "react"
+import { useHistory, useLocation } from "react-router-dom"
+import { useNotification } from "modules/common/hooks/useNotification"
+
+const OUTCOMES: Record = {
+ confirmed: { message: "Email alerts confirmed", variant: "success" },
+ unsubscribed: { message: "Unsubscribed from email alerts", variant: "info" },
+ invalid: { message: "This email alerts link is no longer valid", variant: "error" }
+}
+
+/**
+ * The confirm/unsubscribe links in the alert emails land back in the app with
+ * ?alerts=confirmed|unsubscribed|invalid. Show the outcome once and drop the
+ * param, leaving any other query params untouched.
+ */
+export const useAlertsOutcomeToast = () => {
+ const location = useLocation()
+ const history = useHistory()
+ const openNotification = useNotification()
+ const alertsParam = new URLSearchParams(location.search).get("alerts")
+
+ useEffect(() => {
+ if (!alertsParam) {
+ return
+ }
+
+ const outcome = OUTCOMES[alertsParam]
+ if (outcome) {
+ openNotification({
+ message: outcome.message,
+ autoHideDuration: 5000,
+ variant: outcome.variant
+ })
+ }
+
+ const searchParams = new URLSearchParams(location.search)
+ searchParams.delete("alerts")
+ history.replace({ pathname: location.pathname, search: searchParams.toString() })
+ // openNotification is recreated on every render, so it is deliberately not a dependency.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [alertsParam])
+}
diff --git a/src/modules/explorer/pages/DAO/index.tsx b/src/modules/explorer/pages/DAO/index.tsx
index 71c99126b..f57d5e890 100644
--- a/src/modules/explorer/pages/DAO/index.tsx
+++ b/src/modules/explorer/pages/DAO/index.tsx
@@ -8,6 +8,9 @@ import { useDAOID } from "./router"
import { ContentContainer } from "../../components/ContentContainer"
import { DAOStatsRow } from "../../components/DAOStatsRow"
import { UsersTable } from "../../components/UsersTable"
+import { DAOEmailAlerts } from "../../components/DAOEmailAlerts"
+import { PendingVotesBanner } from "../../components/PendingVotesBanner"
+import { useAlertsOutcomeToast } from "modules/explorer/hooks/useAlertsOutcomeToast"
import { SmallButton } from "../../../common/SmallButton"
import { DaoSettingModal } from "./components/Settings"
@@ -95,6 +98,8 @@ export const DAOOverview: React.FC = () => {
const [openDialog, setOpenDialog] = useState(false)
const [openChangeDialog, setChangeOpenDialog] = useState(false)
+ useAlertsOutcomeToast()
+
const handleCloseModal = () => {
setOpenDialog(false)
}
@@ -125,6 +130,7 @@ export const DAOOverview: React.FC = () => {
return (
+
@@ -227,6 +233,10 @@ export const DAOOverview: React.FC = () => {
+ {data?.data.address && !data?.data.network?.startsWith("etherlink") ? (
+
+ ) : null}
+
diff --git a/src/modules/explorer/pages/DAO/router.tsx b/src/modules/explorer/pages/DAO/router.tsx
index ed065b271..5091ff280 100644
--- a/src/modules/explorer/pages/DAO/router.tsx
+++ b/src/modules/explorer/pages/DAO/router.tsx
@@ -5,7 +5,7 @@ import { DAOOverview } from "modules/explorer/pages/DAO/index"
import { User } from "modules/explorer/pages/User"
import React, { useContext, useEffect, useState } from "react"
import { useHistory } from "react-router"
-import { Redirect, Route, RouteProps, Switch, useParams, useRouteMatch } from "react-router-dom"
+import { Redirect, Route, RouteProps, Switch, useLocation, useParams, useRouteMatch } from "react-router-dom"
import { Network } from "services/beacon"
import { useTezos } from "services/beacon/hooks/useTezos"
import { useDAO } from "services/services/dao/hooks/useDAO"
@@ -91,7 +91,7 @@ const DAORoute: React.FC = ({ children, ...props }) => {
const DAOContext = React.createContext("")
-const DAOProvider: React.FC<{ daoId: string }> = ({ daoId, children }) => {
+export const DAOProvider: React.FC<{ daoId: string }> = ({ daoId, children }) => {
return {children}
}
@@ -101,6 +101,7 @@ export const useDAOID = () => {
export const DAORouter = (): JSX.Element => {
const match = useRouteMatch()
+ const { search } = useLocation()
const { id: daoId } = useParams<{ id: string }>()
return (
@@ -129,7 +130,8 @@ export const DAORouter = (): JSX.Element => {
-
+ {/* Keep the query string: the email-alert links land on /explorer/dao/:id?alerts=... */}
+
diff --git a/src/modules/explorer/pages/DAOList/index.tsx b/src/modules/explorer/pages/DAOList/index.tsx
index d55b8fa9d..e9732774f 100644
--- a/src/modules/explorer/pages/DAOList/index.tsx
+++ b/src/modules/explorer/pages/DAOList/index.tsx
@@ -17,6 +17,7 @@ import ReactPaginate from "react-paginate"
import "./styles.css"
import { LoadingLine } from "components/ui/LoadingLine"
import { useQueryParam } from "modules/home/hooks/useQueryParam"
+import { useAlertsOutcomeToast } from "modules/explorer/hooks/useAlertsOutcomeToast"
import { getBlockExplorerUrl } from "modules/etherlink/utils"
import AnalyticsService from "services/services/analytics"
import { useRef } from "react"
@@ -39,6 +40,7 @@ export const DAOList: React.FC = () => {
const { network, etherlink, account } = useTezos()
const history = useHistory()
const location = useLocation()
+ useAlertsOutcomeToast()
// Helper: clear multiple query params in a single history update to avoid push loops
const clearQueryParams = useCallback(
diff --git a/src/modules/lite/explorer/components/PollFundingRequest.tsx b/src/modules/lite/explorer/components/PollFundingRequest.tsx
new file mode 100644
index 000000000..5f5310f6e
--- /dev/null
+++ b/src/modules/lite/explorer/components/PollFundingRequest.tsx
@@ -0,0 +1,262 @@
+import React, { useMemo, useState } from "react"
+import { Grid, styled, Typography } from "@mui/material"
+import BigNumber from "bignumber.js"
+import { Link as RouterLink } from "react-router-dom"
+import { Poll } from "models/Polls"
+import { Community } from "models/Community"
+import { SmallButton } from "modules/common/SmallButton"
+import { useNotification } from "modules/common/hooks/useNotification"
+import { ProposalFormContainer } from "modules/explorer/components/ProposalForm"
+import { DAOProvider } from "modules/explorer/pages/DAO/router"
+import { Network } from "services/beacon"
+import { useTezos } from "services/beacon/hooks/useTezos"
+import { getSignature } from "services/lite/utils"
+import { useIsProposalButtonDisabled } from "services/contracts/baseDAO/hooks/useCycleInfo"
+import { getEthSignature } from "services/utils/utils"
+import { linkPollToOnchainProposal } from "services/services/lite/lite-services"
+import { useDAOTreasuryBalance } from "../hooks/useDAOTreasuryBalance"
+import { useNewestOwnProposalKey } from "../hooks/useNewestOwnProposalKey"
+
+const Container = styled(Grid)(({ theme }) => ({
+ background: theme.palette.secondary.light,
+ borderRadius: 8
+}))
+
+const CardContent = styled(Grid)(({ theme }) => ({
+ padding: "40px 48px 42px 48px",
+ gap: 20,
+ [theme.breakpoints.down("lg")]: {
+ padding: "18px 25px"
+ }
+}))
+
+const TitleText = styled(Typography)({
+ fontSize: 24,
+ fontWeight: 600
+})
+
+const RowLabel = styled(Typography)({
+ fontSize: 18,
+ fontWeight: 600
+})
+
+const RowValue = styled(Typography)({
+ fontSize: 18,
+ fontWeight: 300,
+ wordBreak: "break-all"
+})
+
+const WarningText = styled(Typography)({
+ fontSize: 16,
+ fontWeight: 300,
+ color: "#ED254E"
+})
+
+const HintText = styled(Typography)(({ theme }) => ({
+ fontSize: 14,
+ fontWeight: 300,
+ marginTop: 8,
+ color: theme.palette.primary.light
+}))
+
+const ProposalLink = styled(RouterLink)(({ theme }) => ({
+ color: theme.palette.secondary.main,
+ fontSize: 18,
+ fontWeight: 300
+}))
+
+interface Props {
+ poll: Poll
+ community: Community | undefined
+ onLinked: () => void
+}
+
+/**
+ * Renders a poll's optional funding request and offers to promote it to an
+ * on-chain transfer proposal on the community's linked baseDAO.
+ */
+export const PollFundingRequest: React.FC = ({ poll, community, onLinked }) => {
+ const { network, account, wallet, etherlink } = useTezos()
+ const openNotification = useNotification()
+ const daoContract = community?.daoContract
+ const { data: treasuryBalance } = useDAOTreasuryBalance(daoContract, (community?.network || network) as Network)
+
+ const [isProposalFormOpen, setProposalFormOpen] = useState(false)
+ const [isLinking, setIsLinking] = useState(false)
+ const { startWatching, isWatching } = useNewestOwnProposalKey(daoContract)
+ // Same gate the explorer's own transfer entry points use: proposals can only
+ // be created during the proposing phase.
+ const isOutsideProposingPeriod = useIsProposalButtonDisabled(daoContract || "")
+
+ const fundingRequest = poll.fundingRequest
+
+ const requestedAmount = useMemo(() => {
+ if (!fundingRequest?.amount) {
+ return undefined
+ }
+ const amount = new BigNumber(fundingRequest.amount)
+ return amount.isFinite() ? amount : undefined
+ }, [fundingRequest])
+
+ const exceedsBalance = Boolean(requestedAmount && treasuryBalance && requestedAmount.gt(treasuryBalance))
+
+ // No outcome gate on purpose: an off-chain poll carries no authority, so the
+ // app only offers the shortcut and leaves the decision to the proposer.
+ const canPropose = Boolean(daoContract && (wallet || etherlink.isConnected) && !poll.onchainProposal)
+
+ const linkProposal = async (proposalKey: string) => {
+ if (!daoContract || !poll._id) {
+ return
+ }
+
+ const payload = {
+ daoAddress: daoContract,
+ proposalKey,
+ network: community?.network || network,
+ pollID: poll._id
+ }
+
+ try {
+ setIsLinking(true)
+
+ let signature: string | undefined
+ let payloadBytes: string
+ let publicKey: string | undefined
+
+ if (wallet) {
+ const signed = await getSignature(account, wallet, JSON.stringify(payload))
+ signature = signed.signature
+ payloadBytes = signed.payloadBytes
+ publicKey = (await wallet?.client.getActiveAccount())?.publicKey
+ } else {
+ publicKey = etherlink.account.address
+ const signed = await getEthSignature(publicKey, JSON.stringify(payload))
+ signature = signed.signature
+ payloadBytes = signed.payloadBytes
+ }
+
+ if (!signature) {
+ openNotification({
+ message: `Issue with Signature`,
+ autoHideDuration: 3000,
+ variant: "error"
+ })
+ return
+ }
+
+ const resp = await linkPollToOnchainProposal(poll._id, signature, publicKey, payloadBytes, network)
+
+ if (!resp.ok) {
+ const respData = await resp.json().catch(() => ({}))
+ openNotification({
+ message: respData?.message || "Could not link the on-chain proposal",
+ autoHideDuration: 3000,
+ variant: "error"
+ })
+ return
+ }
+
+ openNotification({
+ message: "On-chain proposal linked to this poll",
+ autoHideDuration: 5000,
+ variant: "success"
+ })
+ onLinked()
+ } catch (error) {
+ console.log("error: ", error)
+ openNotification({
+ message: "Could not link the on-chain proposal",
+ autoHideDuration: 3000,
+ variant: "error"
+ })
+ } finally {
+ setIsLinking(false)
+ }
+ }
+
+ // The proposal form fires and forgets, so watch the indexer for the proposal
+ // this account just created and link it once its key shows up.
+ const onProposalSubmitted = () => {
+ startWatching(linkProposal)
+ }
+
+ if (!fundingRequest) {
+ return null
+ }
+
+ const proposalFormDefaultValues = {
+ transferForm: {
+ transfers: [
+ {
+ recipient: fundingRequest.recipient,
+ amount: Number(fundingRequest.amount),
+ asset: { symbol: "XTZ" as const }
+ }
+ ],
+ isBatch: false
+ }
+ }
+
+ return (
+
+
+
+ Funding request
+
+
+ Recipient:
+ {fundingRequest.recipient}
+
+
+ Amount:
+ {fundingRequest.amount} XTZ
+
+ {treasuryBalance ? (
+
+ DAO treasury balance:
+ {treasuryBalance.dp(6, BigNumber.ROUND_DOWN).toString()} XTZ
+
+ ) : null}
+ {exceedsBalance ? (
+
+ Requested amount exceeds the DAO treasury balance
+
+ ) : null}
+
+ {poll.onchainProposal ? (
+
+
+ On-chain proposal
+
+
+ ) : canPropose ? (
+
+ setProposalFormOpen(true)}
+ >
+ {isLinking || isWatching ? "Linking proposal..." : "Create on-chain transfer proposal"}
+
+ {isOutsideProposingPeriod ? Not on proposal creation period : null}
+
+ ) : null}
+
+
+ {daoContract ? (
+
+ setProposalFormOpen(false)}
+ defaultValues={proposalFormDefaultValues}
+ defaultTab={0}
+ onSubmitted={onProposalSubmitted}
+ />
+
+ ) : null}
+
+ )
+}
diff --git a/src/modules/lite/explorer/components/ProposalTableRowStatusBadge.tsx b/src/modules/lite/explorer/components/ProposalTableRowStatusBadge.tsx
index a76ed2e71..6e872f729 100644
--- a/src/modules/lite/explorer/components/ProposalTableRowStatusBadge.tsx
+++ b/src/modules/lite/explorer/components/ProposalTableRowStatusBadge.tsx
@@ -2,7 +2,6 @@ import React from "react"
import { Grid, GridProps, Typography } from "@mui/material"
import { styled, Theme } from "@mui/material/styles"
import hexToRgba from "hex-to-rgba"
-import { theme } from "theme"
export enum ProposalStatus {
ACTIVE = "active",
@@ -41,7 +40,7 @@ const Text = styled(Typography)({
})
export const TableStatusBadge: React.FC<{ status: ProposalStatus } & GridProps> = ({ status }) => (
-
+
{status}
diff --git a/src/modules/lite/explorer/components/TreasuryBalanceHint.tsx b/src/modules/lite/explorer/components/TreasuryBalanceHint.tsx
new file mode 100644
index 000000000..292a2e071
--- /dev/null
+++ b/src/modules/lite/explorer/components/TreasuryBalanceHint.tsx
@@ -0,0 +1,52 @@
+import React from "react"
+import { Grid, styled, Typography } from "@mui/material"
+import BigNumber from "bignumber.js"
+import { Network } from "services/beacon"
+import { useDAOTreasuryBalance } from "../hooks/useDAOTreasuryBalance"
+
+const HintText = styled(Typography)(({ theme }) => ({
+ fontSize: 14,
+ fontWeight: 300,
+ color: theme.palette.primary.light
+}))
+
+const WarningText = styled(Typography)({
+ fontSize: 14,
+ fontWeight: 300,
+ color: "#ED254E"
+})
+
+interface Props {
+ daoContract: string | undefined
+ network: string | undefined
+ amount: string | undefined
+}
+
+/**
+ * Shows the linked on-chain DAO's XTZ treasury balance next to a requested
+ * amount, and warns when the request cannot be covered. Renders nothing when
+ * the community has no on-chain DAO.
+ */
+export const TreasuryBalanceHint: React.FC = ({ daoContract, network, amount }) => {
+ const { data: balance } = useDAOTreasuryBalance(daoContract, network as Network)
+
+ if (!daoContract || !balance) {
+ return null
+ }
+
+ const parsedAmount = amount ? new BigNumber(amount) : undefined
+ const exceedsBalance = parsedAmount && parsedAmount.isFinite() && parsedAmount.gt(balance)
+
+ return (
+
+
+ DAO treasury balance: {balance.dp(6, BigNumber.ROUND_DOWN).toString()} XTZ
+
+ {exceedsBalance ? (
+
+ Requested amount exceeds the DAO treasury balance
+
+ ) : null}
+
+ )
+}
diff --git a/src/modules/lite/explorer/hooks/useDAOTreasuryBalance.tsx b/src/modules/lite/explorer/hooks/useDAOTreasuryBalance.tsx
new file mode 100644
index 000000000..f1b38e315
--- /dev/null
+++ b/src/modules/lite/explorer/hooks/useDAOTreasuryBalance.tsx
@@ -0,0 +1,30 @@
+import { useQuery } from "@tanstack/react-query"
+import BigNumber from "bignumber.js"
+import { networkNameMap } from "services/bakingBad"
+import { Network } from "services/beacon"
+import { mutezToXtz } from "services/contracts/utils"
+
+/**
+ * XTZ balance of an on-chain DAO contract, read straight from TzKT.
+ *
+ * The explorer already has `useTezosBalance`, but that one depends on `useDAO`
+ * and therefore on the indexer plus the DAO route context. Lite pages live
+ * outside that context, so they read the balance directly by address.
+ */
+export const useDAOTreasuryBalance = (daoAddress: string | undefined, network: Network) => {
+ return useQuery({
+ queryKey: ["daoTreasuryBalance", daoAddress, network],
+ queryFn: async () => {
+ const url = `https://api.${networkNameMap[network]}.tzkt.io/v1/accounts/${daoAddress}/balance`
+ const response = await fetch(url)
+
+ if (!response.ok) {
+ throw new Error("Failed to fetch DAO treasury balance")
+ }
+
+ const mutez = await response.json()
+ return mutezToXtz(new BigNumber(mutez))
+ },
+ enabled: !!daoAddress && !!networkNameMap[network]
+ })
+}
diff --git a/src/modules/lite/explorer/hooks/useNewestOwnProposalKey.tsx b/src/modules/lite/explorer/hooks/useNewestOwnProposalKey.tsx
new file mode 100644
index 000000000..1c722ec5e
--- /dev/null
+++ b/src/modules/lite/explorer/hooks/useNewestOwnProposalKey.tsx
@@ -0,0 +1,107 @@
+import { useCallback, useEffect, useRef, useState } from "react"
+import { useTezos } from "services/beacon/hooks/useTezos"
+import { client } from "services/services/graphql"
+import { GET_PROPOSALS_QUERY } from "services/services/dao/queries"
+
+const POLL_INTERVAL_MS = 10000
+const MAX_ATTEMPTS = 18
+
+interface ProposalRow {
+ key: string
+ start_date: string
+ holder: { address: string }
+}
+
+interface ProposalsResponse {
+ daos: { proposals: ProposalRow[] }[]
+}
+
+/**
+ * The transfer-proposal form fires the origination and forgets it: nothing in
+ * the app returns the resulting proposal key, which only exists once the
+ * indexer has picked the operation up.
+ *
+ * `startWatching` therefore records the newest proposal key this account
+ * already has on the DAO, then polls the indexer until a newer one appears and
+ * hands its key to the callback. It gives up quietly after a few minutes.
+ */
+export const useNewestOwnProposalKey = (daoAddress: string | undefined) => {
+ const { account } = useTezos()
+ const [isWatching, setIsWatching] = useState(false)
+ const timerRef = useRef>()
+ const cancelledRef = useRef(false)
+
+ useEffect(() => {
+ return () => {
+ cancelledRef.current = true
+ if (timerRef.current) {
+ clearTimeout(timerRef.current)
+ }
+ }
+ }, [])
+
+ const fetchNewestOwnProposal = useCallback(async () => {
+ if (!daoAddress || !account) {
+ return undefined
+ }
+
+ const response = await client.request(GET_PROPOSALS_QUERY, { address: daoAddress })
+ const proposals = response.daos[0]?.proposals || []
+
+ return proposals
+ .filter(proposal => proposal.holder?.address?.toLowerCase() === account.toLowerCase())
+ .sort((a, b) => new Date(b.start_date).getTime() - new Date(a.start_date).getTime())[0]
+ }, [daoAddress, account])
+
+ const startWatching = useCallback(
+ async (onFound: (proposalKey: string) => void) => {
+ if (!daoAddress || !account || isWatching) {
+ return
+ }
+
+ cancelledRef.current = false
+ setIsWatching(true)
+
+ let knownNewestKey: string | undefined
+ try {
+ knownNewestKey = (await fetchNewestOwnProposal())?.key
+ } catch (error) {
+ console.log("error: ", error)
+ }
+
+ let attempts = 0
+
+ const tick = async () => {
+ if (cancelledRef.current) {
+ return
+ }
+
+ attempts += 1
+
+ try {
+ const newest = await fetchNewestOwnProposal()
+
+ if (newest?.key && newest.key !== knownNewestKey) {
+ setIsWatching(false)
+ onFound(newest.key)
+ return
+ }
+ } catch (error) {
+ console.log("error: ", error)
+ }
+
+ if (attempts >= MAX_ATTEMPTS) {
+ setIsWatching(false)
+ return
+ }
+
+ timerRef.current = setTimeout(tick, POLL_INTERVAL_MS)
+ }
+
+ timerRef.current = setTimeout(tick, POLL_INTERVAL_MS)
+ },
+ [daoAddress, account, isWatching, fetchNewestOwnProposal]
+ )
+
+ return { startWatching, isWatching }
+}
diff --git a/src/modules/lite/explorer/hooks/usePoll.tsx b/src/modules/lite/explorer/hooks/usePoll.tsx
index c180e0540..4d0935a0b 100644
--- a/src/modules/lite/explorer/hooks/usePoll.tsx
+++ b/src/modules/lite/explorer/hooks/usePoll.tsx
@@ -6,7 +6,7 @@ import { isProposalActive } from "services/lite/utils"
import { ProposalStatus } from "../components/ProposalTableRowStatusBadge"
import { EnvKey, getEnv } from "services/config"
-export const useSinglePoll = (pollId: string | undefined, id?: any, community?: any) => {
+export const useSinglePoll = (pollId: string | undefined, id?: any, community?: any, refresh?: number) => {
const [poll, setPoll] = useState()
const openNotification = useNotification()
@@ -49,6 +49,6 @@ export const useSinglePoll = (pollId: string | undefined, id?: any, community?:
}
fetchPoll()
return
- }, [id, community])
+ }, [id, community, refresh])
return poll
}
diff --git a/src/modules/lite/explorer/pages/CreateProposal/index.tsx b/src/modules/lite/explorer/pages/CreateProposal/index.tsx
index a4e2abb25..0f454211f 100644
--- a/src/modules/lite/explorer/pages/CreateProposal/index.tsx
+++ b/src/modules/lite/explorer/pages/CreateProposal/index.tsx
@@ -1,7 +1,19 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable react-hooks/exhaustive-deps */
import React, { useCallback, useState } from "react"
-import { Grid, styled, Typography, TextareaAutosize, useTheme, useMediaQuery, Tooltip } from "@mui/material"
+import {
+ Grid,
+ styled,
+ Typography,
+ TextareaAutosize,
+ useTheme,
+ useMediaQuery,
+ Tooltip,
+ Collapse,
+ Switch as ToggleSwitch
+} from "@mui/material"
+import BigNumber from "bignumber.js"
+import { validateAddress, validateContractAddress, ValidationResult } from "@taquito/utils"
import withStyles from "@mui/styles/withStyles"
import withTheme from "@mui/styles/withTheme"
@@ -29,6 +41,7 @@ import { ProposalCodeEditorInput } from "modules/explorer/components/ProposalFor
import Prism, { highlight } from "prismjs"
import "prism-themes/themes/prism-night-owl.css"
import { useCommunity } from "../../hooks/useCommunity"
+import { TreasuryBalanceHint } from "../../components/TreasuryBalanceHint"
import { getEthSignature } from "services/utils/utils"
dayjs.extend(duration)
@@ -256,6 +269,39 @@ const hasDuplicates = (options: string[]) => {
return new Set(trimOptions).size !== trimOptions.length
}
+// XTZ has 6 decimals on chain, so anything finer cannot be transferred.
+const XTZ_AMOUNT_REGEX = /^\d+(\.\d{1,6})?$/
+
+const isValidTezosAddress = (address: string) =>
+ validateContractAddress(address) === ValidationResult.VALID || validateAddress(address) === ValidationResult.VALID
+
+/**
+ * The funding request is optional: both fields empty means "no request".
+ * If either is filled, both must be valid.
+ */
+const validateFundingRequest = (values: Poll, errors: FormikErrors) => {
+ const recipient = (values.fundingRecipient || "").trim()
+ const amount = (values.fundingAmount || "").trim()
+
+ if (!recipient && !amount) {
+ return
+ }
+
+ if (!recipient) {
+ errors.fundingRecipient = "Required when an amount is set"
+ } else if (!isValidTezosAddress(recipient)) {
+ errors.fundingRecipient = "Not a valid Tezos address"
+ }
+
+ if (!amount) {
+ errors.fundingAmount = "Required when a recipient is set"
+ } else if (!XTZ_AMOUNT_REGEX.test(amount)) {
+ errors.fundingAmount = "Enter a positive amount with up to 6 decimals"
+ } else if (new BigNumber(amount).lte(0)) {
+ errors.fundingAmount = "Must be greater than zero"
+ }
+}
+
const validateForm = (values: Poll) => {
const errors: FormikErrors = {}
@@ -314,6 +360,8 @@ const validateForm = (values: Poll) => {
errors.endTimeDays = "Most be greater than zero"
}
+ validateFundingRequest(values, errors)
+
return errors
}
@@ -350,6 +398,10 @@ export const ProposalForm = ({
const shouldShowBar = pathname.includes("/lite") ? true : false
const [isMarkup, setIsMarkup] = useState(false)
+ // Reopen the section on re-render if the fields already hold something.
+ const [showFundingRequest, setShowFundingRequest] = useState(
+ Boolean(getIn(values, "fundingRecipient") || getIn(values, "fundingAmount"))
+ )
const grammar = Prism.languages.markup
const codeEditorPlaceholder = `
@@ -576,6 +628,57 @@ export const ProposalForm = ({
{errors?.externalLink && touched.externalLink ? {errors.externalLink} : null}
+
+
+ {
+ const next = !showFundingRequest
+ setShowFundingRequest(next)
+ if (!next) {
+ setFieldValue("fundingRecipient", "")
+ setFieldValue("fundingAmount", "")
+ }
+ }}
+ />
+ Funding request
+
+
+
+ Optionally attach a treasury payout to this poll. If it passes, it can be promoted to an on-chain
+ transfer proposal.
+
+
+
+
+
+ Recipient address
+
+ {errors?.fundingRecipient && touched.fundingRecipient ? (
+ {errors.fundingRecipient}
+ ) : null}
+
+
+ Amount (XTZ)
+
+ {errors?.fundingAmount && touched.fundingAmount ? (
+ {errors.fundingAmount}
+ ) : null}
+
+
+
+
+
+
{isMobileSmall ? (
@@ -720,7 +823,29 @@ export const ProposalCreator: React.FC<{ id?: string; onClose?: any }> = props =
endTimeDays: null,
endTimeHours: null,
endTimeMinutes: null,
- isXTZ: false
+ isXTZ: false,
+ fundingRecipient: "",
+ fundingAmount: ""
+ }
+
+ /**
+ * Turns the two flat form fields into the `fundingRequest` object the lite
+ * backend expects, and drops the form-only fields so they never reach the
+ * signed payload. Both fields empty means no funding request at all.
+ */
+ const buildPollPayload = (values: Poll): Poll => {
+ const { fundingRecipient, fundingAmount, ...rest } = values
+ const recipient = (fundingRecipient || "").trim()
+ const amount = (fundingAmount || "").trim()
+
+ if (!recipient || !amount) {
+ return rest as Poll
+ }
+
+ return {
+ ...rest,
+ fundingRequest: { recipient, amount }
+ } as Poll
}
const saveProposal = useCallback(
@@ -729,7 +854,7 @@ export const ProposalCreator: React.FC<{ id?: string; onClose?: any }> = props =
if (wallet) {
try {
setIsLoading(true)
- const data = values
+ const data = buildPollPayload(values)
data.daoID = id
data.startTime = String(dayjs().valueOf())
data.endTime = calculateEndTime(values.endTimeDays!, values.endTimeHours!, values.endTimeMinutes!)
@@ -783,7 +908,7 @@ export const ProposalCreator: React.FC<{ id?: string; onClose?: any }> = props =
}
} else if (etherlink.isConnected) {
try {
- const data = values
+ const data = buildPollPayload(values)
data.daoID = id
data.startTime = String(dayjs().valueOf())
data.endTime = calculateEndTime(values.endTimeDays!, values.endTimeHours!, values.endTimeMinutes!)
diff --git a/src/modules/lite/explorer/pages/ProposalDetails/index.tsx b/src/modules/lite/explorer/pages/ProposalDetails/index.tsx
index 521308d36..7c59682cf 100644
--- a/src/modules/lite/explorer/pages/ProposalDetails/index.tsx
+++ b/src/modules/lite/explorer/pages/ProposalDetails/index.tsx
@@ -23,6 +23,7 @@ import { useIsMember } from "../../hooks/useIsMember"
import { useHistoryLength } from "modules/explorer/context/HistoryLength"
import { getEthSignature } from "services/utils/utils"
import { SmallButton } from "modules/common/SmallButton"
+import { PollFundingRequest } from "../../components/PollFundingRequest"
const DescriptionText = styled(Typography)({
fontSize: 24,
@@ -74,7 +75,7 @@ export const ProposalDetails: React.FC<{ id: string }> = ({ id }) => {
const openNotification = useNotification()
const [refresh, setRefresh] = useState()
const community = useCommunity(id)
- const poll = useSinglePoll(proposalId, id, community)
+ const poll = useSinglePoll(proposalId, id, community, refresh)
const { state, pathname } = useLocation<{ poll: Poll; choices: Choice[]; daoId: string }>()
const { data: dao } = useDAO(state?.daoId)
const { data: voteWeight } = useTokenVoteWeight(
@@ -225,6 +226,11 @@ export const ProposalDetails: React.FC<{ id: string }> = ({ id }) => {
+ {poll?.fundingRequest ? (
+
+ setRefresh(Math.random())} />
+
+ ) : null}
{choices && choices.length > 0 ? (
<>
diff --git a/src/services/services/lite/lite-services.ts b/src/services/services/lite/lite-services.ts
index 0cf0697d8..5a2617556 100644
--- a/src/services/services/lite/lite-services.ts
+++ b/src/services/services/lite/lite-services.ts
@@ -204,6 +204,44 @@ export const updateCount = async (id: string) => {
return resp
}
+export const subscribeToDAOAlerts = async (email: string, daoAddress: string, network: Network, daoName?: string) => {
+ const resp = await fetch(`${getEnv(EnvKey.REACT_APP_LITE_API_URL)}/subscriptions`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ email,
+ daoAddress,
+ network,
+ daoName
+ })
+ })
+ return resp
+}
+
+export const linkPollToOnchainProposal = async (
+ pollId: string,
+ signature: string,
+ publicKey: string | undefined,
+ payloadBytes: string,
+ network: Network
+) => {
+ const resp = await fetch(`${getEnv(EnvKey.REACT_APP_LITE_API_URL)}/polls/${pollId}/link-proposal`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json"
+ },
+ body: JSON.stringify({
+ signature,
+ publicKey,
+ payloadBytes,
+ network
+ })
+ })
+ return resp
+}
+
export const fetchOffchainProposals = async (daoId: string) => {
return await fetch(`${getEnv(EnvKey.REACT_APP_LITE_API_URL)}/daos/${daoId}?include=polls`, {
method: "GET",