diff --git a/apps/astraplusplus/type/dao.jsonc b/apps/astraplusplus/type/dao.jsonc new file mode 100644 index 0000000..c0cd782 --- /dev/null +++ b/apps/astraplusplus/type/dao.jsonc @@ -0,0 +1,109 @@ +{ + "properties": [ + + { + "name": "name", + "type": "string", + "required": true, + "string": { + "validation": { + "min": 3, + "max": 50 + } + } + }, + { + "name": "address", + "type": "string", + "required": true, + "string": { + "validation": { + "pattern": "^(([a-zd]+[-_])*[a-zd]+.)*([a-zd]+[-_])*[a-zd]+$" + } + } + }, + { + "name": "soulBoundTokenIssuer", + "type": "string", + "required": false, + "string": { + "validation": { + "pattern": "^(([a-zd]+[-_])*[a-zd]+.)*([a-zd]+[-_])*[a-zd]+$" + } + } + }, + { + "name": "purpose", + "type": "string", + "required": true, + "string": { + "validation": { + "min": 0, + "max": 1000 + } + } + }, + { + "name": "legalStatus", + "type": "string", + "required": false, + "string": { + "validation": { + "min": 0, + "max": 100 + } + } + }, + { + "name": "legalDocument", + "type": "string", + "required": false, + "string": { + "validation": { + "pattern": "(https?://(?:www.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^s]{2,}|www.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9].[^s]{2,}|https?://(?:www.|(?!www))[a-zA-Z0-9]+.[^s]{2,}|www.[a-zA-Z0-9]+.[^s]{2,})" + } + } + }, + { + "name": "links", + "type": "array", + "required": false, + "array": { + "type": "string", + "validation": { + "min": 0, + "max": 10 + } + } + }, + { + "name": "coolDownPeriod", + "type": "number", + "required": true, + "number": { + "validation": { + "min": 0, + "max": 31536000 + } + } + }, + { + "name": "policy", + "type": "/*__@appAccount__*//type/daoPolicy", + "required": true + }, + { + "name": "profileImage", + "required": false, + "type": "/*__@appAccount__*//type/image" + }, + { + "name": "coverImage", + "type": "/*__@appAccount__*//type/image", + "required": false + } + ], + "widgets": { + "create": "/*__@appAccount__*//widget/CreateDAO" + } +} diff --git a/apps/astraplusplus/type/daoPolicy.jsonc b/apps/astraplusplus/type/daoPolicy.jsonc new file mode 100644 index 0000000..0e0cd58 --- /dev/null +++ b/apps/astraplusplus/type/daoPolicy.jsonc @@ -0,0 +1,105 @@ +{ + "properties": [ + { + "name": "roles", + "type": "array", + "required": true, + "array": { + "validation": { + "min": 1 + }, + "type": { + "properties": [ + { + "name": "name", + "type": "string", + "required": true, + "string": { + "validation": { + "minLength": 1, + "maxLength": 32, + "pattern": "^[a-zA-Z0-9]+[a-zA-Z0-9- ]*[a-zA-Z0-9]+$" + } + } + }, + { + "name": "kind", + "type": [ + "string", + { + "properties": [ + { + "name": "Group", + "type": "array", + "required": true, + "array": { + "type": "string", + "min": 1, + "string": { + "validation": { + "minLength": 1, + "maxLength": 100, + "pattern": "^(([a-zd]+[-_])*[a-zd]+.)*([a-zd]+[-_])*[a-zd]+$" + } + } + } + } + ] + } + ] + }, + { + "name": "permissions", + "type": "array", + "array": { + "type": "string" + } + }, + { + "name": "vote_policy", + "type": "object" + } + ] + } + } + }, + { + "name": "default_vote_policy", + "type": { + "properties": [ + { + "name": "weight_kind", + "type": "string" + }, + { + "name": "quorum", + "type": "string" + }, + { + "name": "threshold", + "type": "array", + "array": { + "type": "number" + } + } + ] + } + }, + { + "name": "proposal_bond", + "type": "string" + }, + { + "name": "proposal_period", + "type": "string" + }, + { + "name": "bounty_bond", + "type": "string" + }, + { + "name": "bounty_forgiveness_period", + "type": "string" + } + ] +} diff --git a/apps/astraplusplus/widget/Common/Layout/Header.jsx b/apps/astraplusplus/widget/Common/Layout/Header.jsx new file mode 100644 index 0000000..822c91a --- /dev/null +++ b/apps/astraplusplus/widget/Common/Layout/Header.jsx @@ -0,0 +1,271 @@ +/** + To make sure that the sidebar displays correctly, you need to add some bootstrap classes to the parent component. + + Add the following className to the parent component: "row" + and add the following CSS to the content component: "col" + +Example: + +return ( +
+ +
home
+
+); + +*/ + +const hasSidebar = props.hasSidebar ?? true; +const items = props.items ?? []; + +State.init({ + sidebarExpanded: false, +}); + +const Header = styled.div` + border-bottom: 1px solid #e5e5e5; + + .sidebar-toggle { + display: none; + } + @media (max-width: 768px) { + .sidebar-toggle { + display: block; + } + } +`; + +const Sidebar = styled.div` + height: 100%; + transition: all 0.5s ease-in-out; + max-width: 300px; + background: #fff; + border-right: 1px solid #e5e5e5; + + &.collapsed { + padding-right: 26px; + max-width: 120px; + } + + @media (max-width: 768px) { + position: fixed !important; + top: 0; + bottom: 0; + z-index: 10000; + left: 0; + display: flex; + flex-direction: column; + justify-content: center; + + &.collapsed { + left: -400px; + } + } + + ul { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 16px; + align-items: flex-start; + width: 100%; + max-width: 240px; + } + + .group { + width: 100%; + } + + li { + background: #fff; + cursor: pointer; + border-radius: 8px; + width: 100%; + transition: all 100ms ease-in-out; + + div, + a { + padding: 8px 26px; + color: #000 !important; + text-decoration: none; + font-weight: 600; + font-size: 15px; + letter-spacing: 0; + display: flex; + gap: 12px; + align-items: center; + } + + i { + font-size: 19px; + } + } + + li:hover { + background-color: rgba(68, 152, 224, 0.1); + + * { + color: #4498e0 !important; + } + } + + li:active { + background-color: rgba(68, 152, 224, 0.12); + * { + color: #4498e0 !important; + } + } + + li.active { + background-color: rgba(68, 152, 224, 0.1); + + * { + color: #4498e0 !important; + } + } + + &.collapsed { + li { + width: 100% !important; + + div, + a { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + font-size: 12px; + text-align: center; + gap: 4px; + } + } + } +`; + +const Toggle = styled.div` + position: absolute; + top: 10px; + right: -20px; + z-index: 1000; + height: 40px; + width: 40px; + background: #4498e0; + color: #fff; + border-radius: 6px; + display: grid; + place-items: center; + + @media (max-width: 768px) { + bottom: 100px; + top: auto; + } +`; + +const MobileToggle = styled.div` + height: 40px; + width: 40px; + background: #4498e0; + color: #fff; + border-radius: 6px; + display: grid; + place-items: center; +`; + +return ( + <> +
+ + State.update({ sidebarExpanded: !state.sidebarExpanded }) + } + > + + + + ASTRA++ + + {context.accountId} +
+ + + State.update({ sidebarExpanded: !state.sidebarExpanded }) + } + > + {state.sidebarExpanded ? ( + + ) : ( + + )} + + + + +); diff --git a/apps/astraplusplus/widget/CreateDAO/Footer.jsx b/apps/astraplusplus/widget/CreateDAO/Footer.jsx new file mode 100644 index 0000000..7cadd84 --- /dev/null +++ b/apps/astraplusplus/widget/CreateDAO/Footer.jsx @@ -0,0 +1,54 @@ +const nextChildren = props.isLast ? ( + <> + Create new DAO + +) : ( + <> + Next step + +); +const hasPrevious = props.hasPrevious; +const onReset = props.onReset ?? (() => {}); +const onNext = props.onNext ?? (() => {}); +const onPrevious = props.onPrevious ?? (() => {}); + +return ( +
+ + + Previous step + + ), + variant: "info outline", + size: "lg", + onClick: onPrevious, + className: hasPrevious ? undefined : "d-none", + }} + /> + +
+); diff --git a/apps/astraplusplus/widget/CreateDAO/Step1.jsx b/apps/astraplusplus/widget/CreateDAO/Step1.jsx new file mode 100644 index 0000000..51c2d90 --- /dev/null +++ b/apps/astraplusplus/widget/CreateDAO/Step1.jsx @@ -0,0 +1,169 @@ +const { formState, errors, renderFooter } = props; + +const initialAnswers = { + name: formState.name, + address: formState.address, + soulBoundTokenIssuer: formState.soulBoundTokenIssuer, + purpose: formState.purpose, + legalStatus: formState.legalStatus, + legalDocument: formState.legalDocument, +}; + +State.init({ + answers: initialAnswers, +}); + +const onValueChange = (key, value) => { + State.update({ + answers: { + ...state.answers, + [key]: value, + }, + }); +}; + +return ( +
+
+

+ + 1 + + DAO Info & KYC +

+ { + onValueChange("name", v); + // generate address + onValueChange( + "address", + `${v + .toLowerCase() + .replace(/\s/g, "-") + .replace(/[^a-zA-Z0-9-]/g, "")}.sputnik-dao.near` + ); + }, + inputProps: { + name: "name", + defaultValue: state.answers.name, + }, + error: errors["name"], + }} + /> + + DAO Address{" "} + + (auto-filled) + + + ), + placeholder: "sample-dao-name.sputnik-dao.near", + value: + state.answers.address === "" ? undefined : state.answers.address, + size: "md", + disabled: true, + onChange: (v) => onValueChange("address", v), + inputProps: { + name: "address", + defaultValue: state.answers.address, + }, + error: errors["address"], + }} + /> + + Soul Bound Token Issuer{" "} + (optional) + + ), + placeholder: "The address of the token issuer", + size: "md", + onChange: (v) => onValueChange("soulBoundTokenIssuer", v), + error: errors["soulBoundTokenIssuer"], + inputProps: { + name: "soulBoundTokenIssuer", + defaultValue: state.answers.soulBoundTokenIssuer, + }, + }} + /> + onValueChange("purpose", v), + error: errors["purpose"], + }} + /> +

+ KYC (optional) +

+ + Please explain your DAO's Legal Status and Jurisdiction{" "} + (if known) + + ), + placeholder: "LLC", + size: "md", + onChange: (v) => onValueChange("legalStatus", v), + error: errors["legalStatus"], + inputProps: { + name: "legalStatus", + defaultValue: state.answers.legalStatus, + }, + }} + /> + + Please provide a link to your DAO's Legal Document{" "} + (if any) + + ), + placeholder: "https://Legal_Document", + size: "md", + onChange: (v) => onValueChange("legalDocument", v), + error: errors["legalDocument"], + inputProps: { + name: "legalDocument", + defaultValue: state.answers.legalDocument, + }, + }} + /> +
+ + {renderFooter(state.answers)} +
+); diff --git a/apps/astraplusplus/widget/CreateDAO/Step2.jsx b/apps/astraplusplus/widget/CreateDAO/Step2.jsx new file mode 100644 index 0000000..22508e1 --- /dev/null +++ b/apps/astraplusplus/widget/CreateDAO/Step2.jsx @@ -0,0 +1,125 @@ +const { formState, errors, renderFooter } = props; + +const initialAnswers = { + links: formState.links.length > 0 ? formState.links : [""], +}; + +State.init({ + answers: initialAnswers, +}); + +const update = (key, value) => + State.update({ + answers: { + ...state.answers, + [key]: value, + }, + }); + +const onAddLink = () => update("links", [...state.answers.links, ""]); + +const onLinkChange = (index, value) => { + const newLinks = [...state.answers.links]; + newLinks[index] = value; + update("links", newLinks); +}; + +const onRemoveLink = (index) => { + const newLinks = [...state.answers.links]; + newLinks[index] = null; + update("links", newLinks); +}; + +const Error = styled.span` + display: inline-block; + font-style: normal; + font-weight: 400; + font-size: 0.875em; + line-height: 1.25em; + color: #ff4d4f; + height: 0; + overflow: hidden; + transition: height 0.3s ease-in-out; + + &.show { + height: 1.25em; + } +`; + +return ( +
+
+
+
+

+ + 2 + + Links and socials{" "} + (optional) +

+ , + variant: "icon info outline", + size: "lg", + onClick: onAddLink, + }} + /> +
+

+ Looking to grow the DAO members? Add links to allow people to learn + more about your DAO. You can only add 10 links. +

+
+ + {state.answers.links.map((l, i) => ( +
+ onLinkChange(i, v), + inputProps: { + name: `link-${i}`, + defaultValue: l, + }, + }} + /> + , + variant: "icon danger outline", + size: "lg", + onClick: () => onRemoveLink(i), + }} + /> +
+ ))} + {errors.links && ( + + {errors.links} + + )} +
+ + {renderFooter({ + links: state.answers.links.filter((l) => l !== null && l !== ""), + })} +
+); diff --git a/apps/astraplusplus/widget/CreateDAO/Step3.jsx b/apps/astraplusplus/widget/CreateDAO/Step3.jsx new file mode 100644 index 0000000..bf70488 --- /dev/null +++ b/apps/astraplusplus/widget/CreateDAO/Step3.jsx @@ -0,0 +1,62 @@ +const { formState, errors, renderFooter } = props; + +const initialAnswers = { + gracePeriod: formState.gracePeriod, +}; + +State.init({ + answers: initialAnswers, +}); + +const update = (key, value) => + State.update({ + answers: { + ...state.answers, + [key]: value, + }, + }); + +return ( +
+
+
+

+ + 3 + + Cool Down Period +

+

+ Setup the period between when a proposal is approved and is executed. +

+
+ + update("gracePeriod", parseInt(v)), + }} + /> +
+ + {renderFooter(state.answers)} +
+); diff --git a/apps/astraplusplus/widget/CreateDAO/Step4.jsx b/apps/astraplusplus/widget/CreateDAO/Step4.jsx new file mode 100644 index 0000000..71177b5 --- /dev/null +++ b/apps/astraplusplus/widget/CreateDAO/Step4.jsx @@ -0,0 +1,320 @@ +const { formState, errors, renderFooter } = props; +const { accountId } = context; + +const initialAnswers = { + policy: formState.policy, +}; + +// not using formState because, the formState doesn't match the ui state + +const initialMembers = []; + +for (const role of initialAnswers.policy.roles) { + if (!role.kind.Group) continue; + for (const member of role.kind.Group) { + initialMembers.push({ + role: role.name, + name: member, + }); + } +} + +const initialState = { + roles: initialAnswers.policy.roles.length + ? initialAnswers.policy.roles.map((r) => r.name) + : ["all", "council"], + members: initialMembers.length + ? initialMembers + : [{ role: "council", name: accountId }], +}; + +State.init({ + answers: initialState, +}); + +// -- roles +const onAddEmptyRole = () => { + State.update({ + answers: { + ...state.answers, + roles: [...state.answers.roles, ""], + }, + }); +}; + +const onRemoveRole = (index) => { + State.update({ + answers: { + ...state.answers, + roles: state.answers.roles.map((role, i) => (i === index ? null : role)), + }, + }); +}; + +const onSetRoleName = (index, name) => { + State.update({ + answers: { + ...state.answers, + roles: state.answers.roles.map((role, i) => (i === index ? name : role)), + }, + }); +}; + +// -- members +const onAddEmptyMember = () => { + State.update({ + answers: { + ...state.answers, + members: [ + ...state.answers.members, + { name: "", role: state.answers.roles[0] }, + ], + }, + }); +}; + +const onRemoveMember = (index) => { + State.update({ + answers: { + ...state.answers, + members: state.answers.members.map((member, i) => + i === index ? null : member + ), + }, + }); +}; + +const onSetMemberName = (index, name) => { + State.update({ + answers: { + ...state.answers, + members: state.answers.members.map((member, i) => + i === index ? { ...member, name } : member + ), + }, + }); +}; + +const onSetMemberRole = (index, role) => { + State.update({ + answers: { + ...state.answers, + members: state.answers.members.map((member, i) => + i === index ? { ...member, role } : member + ), + }, + }); +}; + +// Make the state back to formState format +const finalState = { + policy: { + ...formState.policy, + roles: state.answers.roles + .filter((role, i) => role !== null && role !== "") + .map((role, i) => { + if (role === "all") + return { + name: role, + permissions: formState.policy.roles[i]?.permissions || [], + kind: "Everyone", + vote_policy: formState.policy.roles[i]?.vote_policy || {}, + }; + return { + name: role, + kind: { + Group: state.answers.members + .filter((m) => m.role === role && m !== null && m.name !== "") + .map((m) => m.name), + }, + permissions: + formState.policy.roles[i]?.permissions || role === "council" + ? ["*:*"] + : [], + vote_policy: formState.policy.roles[i]?.vote_policy || {}, + }; + }), + }, +}; + +return ( +
+
+

+ + 4 + + Add Groups & Members +

+ +
+
+
+

Add Groups

+

+ Adding groups to DAO during creation is not supported using web + based wallets. Anyway, you can add more groups later in DAO + settings +

+
+ , + variant: "icon info outline", + size: "lg", + onClick: onAddEmptyRole, + }} + /> +
+ {state.answers.roles.map((r, i) => ( +
+ onSetRoleName(i, v), + useTimeout: true, + error: + errors.policy.roles[ + finalState.policy.roles.findIndex((role) => role.name === r) + ].name, + + inputProps: { defaultValue: r }, + }} + /> + {i > 1 && ( + , + variant: "icon danger outline", + size: "lg", + onClick: () => onRemoveRole(i), + }} + /> + )} +
+ ))} +
+ +
+
+
+

Add Members

+

+ Add members to the DAO and set their{" "} + + roles + + . +

+
+ , + variant: "icon info outline", + size: "lg", + onClick: onAddEmptyMember, + }} + /> +
+ + {state.answers.members.map((member, i) => { + const trueRoleIndex = + member !== null && + finalState.policy.roles.findIndex( + (role) => role.name === member.role + ); + const trueMemberIndex = + member !== null && + trueRoleIndex !== -1 && + typeof finalState.policy.roles[trueRoleIndex].kind === "object" + ? finalState.policy.roles[trueRoleIndex].kind.Group.findIndex( + (m) => m === member.name + ) + : null; + + return ( +
+ onSetMemberName(i, v), + disabled: i === 0, + error: + trueMemberIndex !== null && + errors.policy.roles[trueRoleIndex].kind.Group[ + trueMemberIndex + ], + }} + /> + r !== null && r !== "" && r !== "all") + .map((r) => ({ + title: r, + value: r, + })), + value: member.role, + onChange: (v) => onSetMemberRole(i, v), + disabled: i === 0, + }} + /> + {i > 0 && ( + , + variant: "icon danger outline", + size: "lg", + onClick: () => onRemoveMember(i), + }} + /> + )} +
+ ); + })} +
+
+ + {renderFooter(finalState)} +
+); diff --git a/apps/astraplusplus/widget/CreateDAO/Step5.jsx b/apps/astraplusplus/widget/CreateDAO/Step5.jsx new file mode 100644 index 0000000..50f53f4 --- /dev/null +++ b/apps/astraplusplus/widget/CreateDAO/Step5.jsx @@ -0,0 +1,434 @@ +const { formState, errors, renderFooter } = props; +const { accountId } = context; + +const initialAnswers = { + policy: formState.policy, +}; + +State.init({ + answers: initialAnswers, +}); + +// -- roles +const rolesArray = [...state.answers.policy.roles.map((role) => role.name)]; + +const proposalKinds = { + ChangeDAOConfig: { + title: "Change DAO Config", + key: "config", + }, + ChangeDAOPolicy: { + title: "Change DAO Policy", + key: "policy", + }, + Bounty: { + title: "Bounty", + key: "add_bounty", + }, + BountyDone: { + title: "Bounty Done", + key: "bounty_done", + }, + Transfer: { + title: "Transfer", + key: "transfer", + }, + Polls: { + title: "Polls", + key: "vote", + }, + RemoveMembers: { + title: "Remove Members", + key: "remove_member_from_role", + }, + AddMembers: { + title: "Add Members", + key: "add_member_to_role", + }, + FunctionCall: { + title: "Function Call", + key: "call", + }, + UpgradeSelf: { + title: "Upgrade Self", + key: "upgrade_self", + }, + UpgradeRemote: { + title: "Upgrade Remote", + key: "upgrade_remote", + }, + SetVoteToken: { + title: "Set Vote Token", + key: "set_vote_token", + }, +}; + +const proposalActions = { + AddProposal: { + title: "Add Proposal", + key: "AddProposal", + }, + VoteApprove: { + title: "Vote Approve", + key: "VoteApprove", + }, + VoteReject: { + title: "Vote Reject", + key: "VoteReject", + }, + VoteRemove: { + title: "Vote Remove", + key: "VoteRemove", + }, +}; + +const allActionArray = Object.keys(proposalActions).map( + (key) => proposalActions[key].key +); +const allProposalKindArray = Object.keys(proposalKinds).map( + (key) => proposalKinds[key].key +); + +const hasPermission = (role, proposalKind, permissionType) => { + const roleObj = state.answers.policy.roles.find((r) => r.name === role); + + if (roleObj) { + const permission = `${proposalKind}:${permissionType}`; + return roleObj.permissions.some( + (p) => + p === permission || + p === "*:*" || + p === `${proposalKind}:*` || + p === `*:${permissionType}` + ); + } else { + return false; + } +}; + +const cleanPermissions = (permissions) => { + // if there is a *:* permission, remove all other permissions + if (permissions.includes("*:*")) return ["*:*"]; + + // if there is a *:proposalAction, remove all other permissions with the same action except *:proposalAction + allActionArray.forEach((action) => { + if (permissions.some((p) => p === `*:${action}`)) { + permissions = permissions.filter( + (p) => !p.endsWith(`:${action}`) || p === `*:${action}` + ); + } + }); + + // if there is a proposalKind:*, remove all other permissions with the same proposalKind except proposalKind:* + allProposalKindArray.forEach((kind) => { + if (permissions.some((p) => p === `${kind}:*`)) { + permissions = permissions.filter( + (p) => !p.startsWith(`${kind}:`) || p === `${kind}:*` + ); + } + }); + + // Check for proposalKind:[allProposalActions], if true remove them and add proposalKind:* + allProposalKindArray.forEach((kind) => { + if ( + allActionArray.every((action) => + permissions.includes(`${kind}:${action}`) + ) + ) { + permissions = permissions.filter( + (p) => !p.startsWith(`${kind}:`) || p === `${kind}:*` + ); + permissions.push(`${kind}:*`); + } + }); + + // Check for [allProposalKinds]:proposalAction, if true remove them and add *:proposalAction + allActionArray.forEach((action) => { + if ( + allProposalKindArray.every((kind) => + permissions.includes(`${kind}:${action}`) + ) + ) { + permissions = permissions.filter( + (p) => !p.endsWith(`:${action}`) || p === `*:${action}` + ); + permissions.push(`*:${action}`); + } + }); + + // if there is a [allProposalKinds]:[allProposalActions], remove all other permissions and add *:* + if ( + allProposalKindArray.every((kind) => permissions.includes(`${kind}:*`)) && + allActionArray.every((action) => permissions.includes(`*:${action}`)) + ) { + permissions = ["*:*"]; + } + + return permissions; +}; + +const popActionWildCard = (permissions) => { + let expandedPermissions = []; + permissions.forEach((permission) => { + const [proposalKind, action] = permission.split(":"); + if (action === "*") { + expandedPermissions.push( + ...allActionArray.map((a) => `${proposalKind}:${a}`) + ); + } else { + expandedPermissions.push(permission); + } + }); + return [...new Set(expandedPermissions)]; // Remove duplicates +}; + +const popProposalKindWildCard = (permissions) => { + let expandedPermissions = []; + permissions.forEach((permission) => { + const [proposalKind, action] = permission.split(":"); + if (proposalKind === "*") { + expandedPermissions.push( + ...allProposalKindArray.map((k) => `${k}:${action}`) + ); + } else { + expandedPermissions.push(permission); + } + }); + return [...new Set(expandedPermissions)]; // Remove duplicates +}; + +const popAllWildCards = (permissions) => { + permissions = popActionWildCard(permissions); + permissions = popProposalKindWildCard(permissions); + return permissions; +}; + +const setPermission = (role, proposalKind, permissionType, value) => { + const roleObj = role; + + const permission = `${proposalKind}:${permissionType}`; + + // if true add permission + if (value) { + // if permission already exists or there is a wildcard, do nothing + if (hasPermission(role.name, proposalKind, permissionType)) { + return roleObj; + } + // if permission does not exist, add it + roleObj.permissions.push(permission); + // clean up permissions and add wildcards if needed + roleObj.permissions = cleanPermissions(roleObj.permissions); + } + // if false remove permission + else { + // if permission does not exist, do nothing + if (!hasPermission(role.name, proposalKind, permissionType)) { + return roleObj; + } + // if permission exists, make sure to pop all wildcards + roleObj.permissions = popAllWildCards(roleObj.permissions); + // remove permission + roleObj.permissions = roleObj.permissions.filter((p) => p !== permission); + // clean up permissions and add wildcards back if needed + roleObj.permissions = cleanPermissions(roleObj.permissions); + } + return roleObj; +}; + +const setCreatePermission = (roleName, proposalKind, value) => { + const role = state.answers.policy.roles.find((r) => r.name === roleName); + + const newRole = setPermission(role, proposalKind, "AddProposal", value); + + const newRoles = state.answers.policy.roles.map((r) => + r.name === roleName ? newRole : r + ); + + State.update({ + answers: { + ...state.answers, + policy: { + ...state.answers.policy, + roles: newRoles, + }, + }, + }); +}; + +const setVotePermission = (roleName, proposalKind, value) => { + const role = state.answers.policy.roles.find((r) => r.name === roleName); + + let newRole = setPermission(role, proposalKind, "VoteApprove", value); + newRole = setPermission(newRole, proposalKind, "VoteReject", value); + newRole = setPermission(newRole, proposalKind, "VoteRemove", value); + + const newRoles = state.answers.policy.roles.map((r) => + r.name === roleName ? newRole : r + ); + + State.update({ + answers: { + ...state.answers, + policy: { + ...state.answers.policy, + roles: newRoles, + }, + }, + }); +}; + +const Table = styled.ul` + border-radius: 4px; + width: 100%; + list-style: none; + padding: 0; + display: flex; + flex-direction: column; + overflow-x: auto; + position: relative; + + li { + flex: 1; + display: grid; + grid-template-columns: minmax(100px, 2fr) repeat( + auto-fit, + minmax(100px, 1fr) + ); + grid-auto-flow: column; + grid-auto-columns: minmax(100px, 1fr); + max-width: 100%; + align-items: center; + padding: 16px; + justify-items: center; + align-items: center; + gap: 16px; + border-bottom: 1px solid #eee; + + & > *:first-child { + justify-self: flex-start; + display: block; + } + + &:first-child { + border-color: #4498e0; + } + + &:last-child { + border-bottom: none; + } + } + + p { + font-size: 0.8rem; + color: #666; + margin: 0; + } + + li .label { + display: none; + } + + .cbx:not(:last-child) { + margin-right: 0 !important; + } + + @media (max-width: 600px) { + li { + display: flex; + flex-wrap: wrap; + } + li > div:first-child { + margin-bottom: 1rem; + flex: 1; + min-width: 100%; + } + li .label { + display: block; + } + + .cbx:not(:last-child) { + margin-right: 6px !important; + } + .hide-on-mobile { + display: none; + } + } +`; + +const renderTable = (roles, rows, action) => { + return ( + +
  • + Actions + {roles.map((role) => ( + {role} + ))} +
  • + {Object.keys(rows).map((key) => ( +
  • +
    {rows[key].title}
    + {roles.map((role) => ( + { + if (action === "Vote") { + setVotePermission(role, rows[key].key, checked); + } else if (action === "AddProposal") { + setCreatePermission(role, rows[key].key, checked); + } + }, + checked: + action === "Vote" + ? hasPermission(role, rows[key].key, "VoteApprove") || + hasPermission(role, rows[key].key, "VoteReject") || + hasPermission(role, rows[key].key, "VoteRemove") + : hasPermission(role, rows[key].key, action), + }} + /> + ))} +
  • + ))} +
    + ); +}; + +return ( +
    +
    +

    + + 5 + + Proposal and permissions +

    + +
    +

    Proposal creation

    +

    + Choose what creation rights you give DAO groups. This can be changed + in settings later. +

    + {renderTable(rolesArray, proposalKinds, "AddProposal")} +
    + +
    +

    Voting Permissions

    +

    + Choose what voting permissions you give DAO groups. +

    + {renderTable(rolesArray, proposalKinds, "Vote")} +
    +
    + + {renderFooter(state.answers)} +
    +); diff --git a/apps/astraplusplus/widget/CreateDAO/Step6.jsx b/apps/astraplusplus/widget/CreateDAO/Step6.jsx new file mode 100644 index 0000000..6d1fcc4 --- /dev/null +++ b/apps/astraplusplus/widget/CreateDAO/Step6.jsx @@ -0,0 +1,251 @@ +const { formState, errors, renderFooter } = props; + +const initialAnswers = { + profileImage: formState.profileImage, + coverImage: formState.coverImage, +}; + +State.init({ + answers: initialAnswers, +}); + +const onValueChange = (key, value) => { + State.update({ + answers: { + ...state.answers, + [key]: value, + }, + }); +}; + +const Profile = styled.div` + .avatar { + width: 15%; + max-width: 180px; + min-width: 100px; + margin-left: 50px; + transform: translateY(-50%); + background-color: #eee; + background-size: cover; + background-position: center; + } +`; + +const BG = styled.div` + --bs-aspect-ratio: 20%; + background-color: #eee; + background-size: cover; + background-position: center; + min-height: 120px; +`; + +const renderUploadButton = ({ onChange, value }) => { + return ( + ( + , + variant: "icon info", + size: "md", + className: "position-absolute bottom-0 end-0 m-3", + ...otherProps, + }} + /> + ), + deleteButton: (otherProps) => ( + , + variant: "icon danger", + size: "md", + className: "position-absolute bottom-0 end-0 m-3", + ...otherProps, + }} + /> + ), + loadingButton: (otherProps) => ( + + ), + variant: "icon info", + size: "md", + className: "position-absolute bottom-0 end-0 m-3", + ...otherProps, + }} + /> + ), + }} + /> + ); +}; + +const renderAssetsEditor = (hideEditButtons) => { + return ( + + +
    + {!hideEditButtons && + renderUploadButton({ + onChange: (v) => onValueChange("coverImage", v), + value: state.answers.coverImage, + })} +
    +
    +
    +
    + {!hideEditButtons && + renderUploadButton({ + onChange: (v) => onValueChange("profileImage", v), + value: state.answers.profileImage, + })} +
    +
    +
    + ); +}; + +const daoPreviewState = ` +\`\`\`json +${JSON.stringify({ ...formState, ...state.answers }, null, 2)} +\`\`\` +`; + +return ( +
    +
    +
    +

    + + 6 + + Create DAO Assets +

    + +

    DAO Preview

    + + {renderFooter(state.answers, { + hasPrevious: false, + })} +
    + ), + toggle: ( + + ), + }} + /> +
    +
    +
    +

    Set profile image and background image

    +

    + Update the DAO profile image and background images below +

    +
    + +

    Preview DAO Assets

    +

    Profile & Background Image

    + {renderAssetsEditor(true)} +

    DAO Name

    +
    {formState.name}
    +
    + ), + toggle: ( + + Preview DAO Assets + + ), + variant: "outline info", + size: "lg", + }} + /> + ), + }} + /> +
    + {renderAssetsEditor()} + +
    +

    + + Create a new DAO costs 6 NEAR. +

    +

    + The 6 NEAR will be used to pay for the contract deployment and + storage. +

    +
    + + + {renderFooter(state.answers)} + +); diff --git a/apps/astraplusplus/widget/CreateDAO/form.jsx b/apps/astraplusplus/widget/CreateDAO/form.jsx new file mode 100644 index 0000000..fa664af --- /dev/null +++ b/apps/astraplusplus/widget/CreateDAO/form.jsx @@ -0,0 +1,236 @@ +const { typeToEmptyData, validateType, types } = props; + +const initialFormState = typeToEmptyData(types["/*__@appAccount__*//type/dao"]); + +// Set default values here +initialFormState.gracePeriod = 1; +initialFormState.profileImage = + "https://ipfs.near.social/ipfs/bafkreiad5c4r3ngmnm7q6v52joaz4yti7kgsgo6ls5pfbsjzclljpvorsu"; +initialFormState.coverImage = + "https://ipfs.near.social/ipfs/bafkreicd7wmjfizslx72ycmnsmo7m7mnvfsyrw6wghsaseq45ybslbejvy"; + +State.init({ + step: 0, + form: initialFormState, + errors: null, +}); + +const handleStepComplete = (value) => { + const stepValid = true; + Object.keys(value).forEach((key) => { + const properties = types["/*__@appAccount__*//type/dao"].properties.find( + (p) => p.name === key + ); + const validation = validateType(properties.type, value[key], properties); + if (validation) { + State.update({ + errors: { + ...state.errors, + [key]: validation, + }, + }); + stepValid = false; + } else { + State.update({ + errors: { + ...state.errors, + [key]: null, + }, + }); + } + }); + + if (!stepValid) return; + + if (state.step === 5) { + const finalAnswers = { + ...state.form, + ...value, + }; + + State.update({ + step: state.step, + form: finalAnswers, + }); + handleFormComplete(finalAnswers); + return; + } + State.update({ + step: state.step + 1, + form: { + ...state.form, + ...value, + }, + }); +}; + +function handleFormComplete(value) { + const sputnikFactoryArgs = { + name: value.address.replaceAll(".sputnik-dao.near", ""), + // encode args to base64 + args: { + purpose: typeof value.purpose === "string" ? value.purpose : "", + bond: "100000000000000000000000", + vote_period: "604800000000000", + grace_period: Big( + typeof value.gracePeriod === "number" ? parseInt(value.gracePeriod) : 1 + ).times(86400000000000), + policy: { + roles: value.policy.roles, + default_vote_policy: { + weight_kind: "RoleWeight", + quorum: "0", + threshold: [1, 2], + }, + proposal_bond: "100000000000000000000000", + proposal_period: "604800000000000", + bounty_bond: "100000000000000000000000", + bounty_forgiveness_period: "604800000000000", + }, + config: { + purpose: typeof value.purpose === "string" ? value.purpose : "", + name: value.address.replaceAll(".sputnik-dao.near", ""), + // encode metadata to base64 + metadata: { + soulBoundTokenIssuer: + typeof value.soulBoundTokenIssuer === "string" + ? value.soulBoundTokenIssuer + : undefined, + links: Array.isArray(value.links) ? value.links : [], + flagCover: + typeof value.coverImage === "string" ? value.coverImage : "", + flagLogo: + typeof value.profileImage === "string" ? value.profileImage : "", + displayName: typeof value.name === "string" ? value.name : "", + legal: { + legalStatus: + typeof value.legalStatus === "string" ? value.legalStatus : "", + legalLink: + typeof value.legalDocument === "string" + ? value.legalDocument + : "", + }, + }, + }, + }, + }; + + // encode metadata and args to base64 + const finalSputnikFactoryArgs = { + ...sputnikFactoryArgs, + args: Buffer.from( + JSON.stringify({ + ...sputnikFactoryArgs.args, + config: { + ...sputnikFactoryArgs.args.config, + metadata: Buffer.from( + JSON.stringify(sputnikFactoryArgs.args.config.metadata) + ).toString("base64"), + }, + }) + ).toString("base64"), + }; + + Near.call([ + { + contractName: "sputnik-dao.near", + methodName: "create", + args: finalSputnikFactoryArgs, + deposit: "6000000000000000000000000", // 6N + }, + { + contractName: "social.near", + methodName: "set", + }, + ]); +} + +const steps = [ + { + title: "DAO Info & KYC", + active: state.step === 0, + icon: state.step > 0 ? : undefined, + className: state.step > 0 ? "active-outline" : undefined, + }, + { + title: "Links & Socials", + active: state.step === 1, + icon: state.step > 1 ? : undefined, + className: state.step > 1 ? "active-outline" : undefined, + }, + { + title: "Cool Down Period", + active: state.step === 2, + icon: state.step > 2 ? : undefined, + className: state.step > 2 ? "active-outline" : undefined, + }, + { + title: "Add Groups & Members", + active: state.step === 3, + icon: state.step > 3 ? : undefined, + className: state.step > 3 ? "active-outline" : undefined, + }, + { + title: "Proposal & Voting Permission", + active: state.step === 4, + icon: state.step > 4 ? : undefined, + className: state.step > 4 ? "active-outline" : undefined, + }, + { + title: "DAO Assets", + active: state.step === 5, + icon: state.step > 5 ? : undefined, + className: state.step > 5 ? "active-outline" : undefined, + }, +]; + +return ( + <> +

    Create a new DAO

    + { + if (i > state.step) return; + State.update({ + step: i, + }); + }, + }} + /> + ( + = steps.length - 1, + hasPrevious: state.step > 0, + onNext: () => { + handleStepComplete(stepState); + }, + onPrevious: () => { + State.update({ + step: state.step - 1, + }); + }, + onReset: () => { + State.update({ + step: 0, + form: initialFormState, + errors: null, + }); + }, + ...otherProps, + }} + /> + ), + }} + /> + +); diff --git a/apps/astraplusplus/widget/CreateDAO/index.jsx b/apps/astraplusplus/widget/CreateDAO/index.jsx new file mode 100644 index 0000000..4a0c2eb --- /dev/null +++ b/apps/astraplusplus/widget/CreateDAO/index.jsx @@ -0,0 +1,228 @@ +// -- Read and process types from SocialDB + helper functions + +const isPrimitiveType = (type) => + ["string", "number", "boolean"].includes(type); + +const isComplexType = (type) => + Array.isArray(type) + ? "typesArray" + : type === "array" + ? "array" + : typeof type === "object" + ? "object" + : typeof type === "string" && !isPrimitiveType(type) + ? "custom" + : null; + +const rawTypes = Social.get("/*__@appAccount__*//type/*", "final"); +if (rawTypes === null) return null; + +const types = {}; + +// It finds custom types in the type definitions and fetches them from SocialDB. +function getCustomTypes(type, depth) { + depth = depth || 0; + if (depth > 10) { + throw { + message: `Maximum type depth exceeded, please check your type definitions.`, + depth, + current: type, + types, + }; + } + + type.properties.forEach((prop) => { + (Array.isArray(prop.type) ? prop.type : [prop.type]).forEach((type) => { + if (isComplexType(type) === "custom" && !types[type]) { + const rawType = Social.get(`${type}`, "final"); + if (rawType) { + types[type] = JSON.parse(rawType); + getCustomTypes(types[type], depth + 1); + } + } + }); + }); +} + +Object.keys(rawTypes).forEach((key) => { + const type = JSON.parse(rawTypes[key]); + types["/*__@appAccount__*//type/" + key] = type; + getCustomTypes(type); +}); + +const PRIMITIVE_VALIDATIONS = { + string: (value, { min, max, pattern }) => { + if (typeof value !== "string") + return `Expected a string, got ${typeof value}.`; + + if (min && value.length < min) + return `Must be at least ${min} characters long.`; + + if (max && value.length > max) + return `Must be at most ${max} characters long.`; + + if (pattern && !value.match(pattern)) + return `The value "${value}" does not match expected pattern: ${pattern}`; + }, + number: (value, { min, max }) => { + if (typeof value !== "number") + return `Expected a number, got ${typeof value}.`; + + if (min && value < min) return `Must be at least ${min}.`; + + if (max && value > max) return `Must be at most ${max}.`; + }, + boolean: (value) => { + if (typeof value !== "boolean") + return `Expected a boolean, got ${typeof value}.`; + }, +}; + +function validatePrimitiveType(type, value, constraints) { + if (!isPrimitiveType(type)) + throw { + message: `Unknown primitive type: ${type}`, + type, + value, + }; + + return PRIMITIVE_VALIDATIONS[type](value, constraints); +} + +function validateType(type, value, parent) { + if (value === undefined || value === "" || value === null) { + if (parent.required) { + return `This field is required but missing.`; + } + return; + } + + if (isPrimitiveType(type)) + return validatePrimitiveType(type, value, parent[type].validation); + + if (isComplexType(type) === "typesArray") { + const errors = []; + for (const subType of type) { + const error = validateType(subType, value, parent[subType]); + if (!error) return; // Stop if a valid type is found + errors.push(error); + } + if (errors.length === type.length) { + // only return the deepest error + for (const error of errors) { + if (typeof error === "object") return error; + } + return errors[errors.length - 1]; + } + } + + if (isComplexType(type) === "array") { + if (!Array.isArray(value)) { + return `Expected an array, got ${typeof value}.`; + } + + if ( + parent["array"].validation.min && + value.length < parent["array"].validation.min + ) { + return `Must have at least ${parent["array"].validation.min} items.`; + } + + if ( + parent["array"].validation.max && + value.length > parent["array"].validation.max + ) { + return `Must have at most ${parent["array"].validation.max} items.`; + } + + for (const item of value) { + const error = validateType(parent["array"].type, item, parent["array"]); + if (error) + return { + [value.indexOf(item)]: error, + }; + } + } + + if (isComplexType(type) === "object") { + if (typeof value !== "object" || Array.isArray(value)) { + return `Expected an object, got ${typeof value}.`; + } + + // Validate properties of the object + for (const property of type.properties) { + const propName = property.name; + const propType = property.type; + const propValue = value[propName]; + + if (property.required && propValue === undefined) { + return `Property ${propName} is required but missing.`; + } + + if (propValue !== undefined) { + const error = validateType(propType, propValue, property); + if (error) + return { + [propName]: error, + }; + } + } + } + + if (isComplexType(type) === "custom") { + return validateType(types[type], value); + } +} + +const typeToEmptyData = (type) => { + if (isPrimitiveType(type)) { + switch (type) { + case "string": + return ""; + case "number": + return null; + case "boolean": + return null; + } + } + if (isComplexType(type) === "array") { + return []; + } + if (isComplexType(type) === "typesArray") { + return typeToEmptyData(type[0]); + } + if (isComplexType(type) === "object") { + const obj = {}; + + type.properties.forEach((prop) => { + const propType = + isComplexType(prop.type) === "typesArray" ? prop.type[0] : prop.type; + + if (isPrimitiveType(propType)) { + obj[prop.name] = typeToEmptyData(propType); + } else if (isComplexType(propType) === "array") { + obj[prop.name] = typeToEmptyData(propType); + } else if (isComplexType(propType) === "object") { + obj[prop.name] = typeToEmptyData(prop[propType]); + } else if (isComplexType(propType) === "custom") { + obj[prop.name] = typeToEmptyData(types[propType]); + } + }); + + return obj; + } + if (isComplexType(type) === "custom") { + return typeToEmptyData(types[type]); + } +}; + +return ( + +); diff --git a/apps/astraplusplus/widget/DAO.jsx b/apps/astraplusplus/widget/DAO.jsx new file mode 100644 index 0000000..84677c6 --- /dev/null +++ b/apps/astraplusplus/widget/DAO.jsx @@ -0,0 +1,33 @@ +const widgetOwner = "/*__@appAccount__*/"; + +// Trying to improve the UX by not showing the widget until it's ready +const Experiment = styled.div` + opacity: 0; + animation: fade-in 0.5s ease-in-out forwards; + animation-delay: .5s; + + @keyframes fade-in { + 0% { + opacity: 0; + overflow: hidden; + max-height: 0; + } + 50% { + opacity: 0; + overflow: hidden; + max-height: none; + } + 100% { + opacity: 1; + } + } +`; + +return ( + + + +); diff --git a/apps/astraplusplus/widget/DAO/Bounties.jsx b/apps/astraplusplus/widget/DAO/Bounties.jsx new file mode 100644 index 0000000..374e286 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Bounties.jsx @@ -0,0 +1,6 @@ +return ( + +); diff --git a/apps/astraplusplus/widget/DAO/Discussion.jsx b/apps/astraplusplus/widget/DAO/Discussion.jsx new file mode 100644 index 0000000..4cde79b --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Discussion.jsx @@ -0,0 +1,16 @@ +return ( + <> +

    Curated Posts

    + + Update Feed + +
    + + +); diff --git a/apps/astraplusplus/widget/DAO/Followers.jsx b/apps/astraplusplus/widget/DAO/Followers.jsx new file mode 100644 index 0000000..98a60a7 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Followers.jsx @@ -0,0 +1,3 @@ +return ( + +); diff --git a/apps/astraplusplus/widget/DAO/Funds/Balance.jsx b/apps/astraplusplus/widget/DAO/Funds/Balance.jsx new file mode 100644 index 0000000..70205a6 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Funds/Balance.jsx @@ -0,0 +1,20 @@ +const fetcher = props?.fether ?? (() => {}); +const daoId = props?.daoId; + +const balances = fetcher.balances([daoId]); + +if (!balances.body) { + return "Loading..."; +} + +return ( +
    +

    Current Balance

    + +
    +); diff --git a/apps/astraplusplus/widget/DAO/Funds/Outgoing.jsx b/apps/astraplusplus/widget/DAO/Funds/Outgoing.jsx new file mode 100644 index 0000000..81f8660 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Funds/Outgoing.jsx @@ -0,0 +1,95 @@ +// DRAFT + +const fetcher = props.fether ?? {}; +const daoId = props.daoId; + +const outgoing = fetcher["outgoing_near"](daoId); + +if (!outgoing.body) { + // TODO: Add proper loading + return "Loading..."; +} + +const colors = props.colors ?? [ + "#4498E0", + "#FFD50D", + "#F29BC0", + "#82E299", + "#F19D38", +]; + +// Organize the data +const data = outgoing.body.slice(0, 20).map((d) => parseFloat(d.amount)); +const labels = outgoing.body.slice(0, 20).map((d) => { + if (d.receiver.length > 20) { + return d.receiver.slice(0, 19) + "..."; + } + return d.receiver; +}); + +console.log(data); + +// fill the rest of colors if balanceData.length > colors.length +if (data.length > colors.length) { + for (let i = colors.length; i < data.length; i++) { + colors.push("#" + Math.floor(Math.random() * 16777215).toString(16)); + } +} + +// format to small characters like 200k, 200m, 200b... +const formatNumber = (num) => { + if (num >= 1000000000) { + return (num / 1000000000).toFixed(1).replace(/\.0$/, "") + "b"; + } + if (num >= 1000000) { + return (num / 1000000).toFixed(1).replace(/\.0$/, "") + "m"; + } + if (num >= 1000) { + return (num / 1000).toFixed(1).replace(/\.0$/, "") + "k"; + } + return num; +}; + +const chartData = { + labels: labels, + datasets: [ + { + label: "Total NEAR received", + data: data, + backgroundColor: colors, + borderWidth: 1, + }, + ], +}; + +const chartOptions = { + type: "bar", + options: { + indexAxis: "y", + elements: { + bar: { + borderWidth: 2, + }, + }, + responsive: true, + plugins: { + legend: { + display: false, + }, + }, + }, +}; + +return ( +
    + +
    +); diff --git a/apps/astraplusplus/widget/DAO/Funds/TransactionLine.jsx b/apps/astraplusplus/widget/DAO/Funds/TransactionLine.jsx new file mode 100644 index 0000000..81fa840 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Funds/TransactionLine.jsx @@ -0,0 +1,156 @@ +const transfer = props.transfer; +const daoId = props.daoId; +const type = props.type; + +const direction = transfer.sender == daoId ? "out" : "in"; +const explorerUrl = "https://explorer.near.org"; + +// convert timestamp from ns to ms +const timestampMillis = Big(transfer.timestamp).div(1000000); +const date = new Date(timestampMillis.toNumber()).toLocaleString(); + +const ellipsis = (str, length) => { + if (str.length <= length) { + return str; + } + const mid = Math.floor(length / 2); + return str.slice(0, mid) + "..." + str.slice(str.length - mid); +}; + +let proposalId = null; + +if (direction === "out") { + // const res = fetch("https://archival-rpc.mainnet.near.org", { + // method: "POST", + // headers: { + // "Content-Type": "application/json", + // }, + // body: JSON.stringify({ + // jsonrpc: "2.0", + // id: "dontcare", + // method: "EXPERIMENTAL_tx_status", + // params: [transfer.transaction_id, transfer.sender], + // }), + // }); + + // const argsBase64 = res.body.result.transaction.actions[0].FunctionCall.args; + // if (argsBase64) { + // const args = JSON.parse( + // Buffer.from(argsBase64, "base64").toString("utf-8") + // ); + // proposalId = args.id; + // } + const res = fetch( + "https://api.pikespeak.ai/tx/graph-by-hash/" + transfer.transaction_id, + { + method: "GET", + headers: { + "Content-Type": "application/json", + "x-api-key": "36f2b87a-7ee6-40d8-80b9-5e68e587a5b5", + }, + } + ); + + proposalId = + res.body[0].transaction_graph.transaction.actions[0].action.args.id; +} + +const TransferDirectionIcon = styled.div` + background-color: ${(props) => + props.direction == "in" ? "#5BC65F" : "#DD5E56"}; + color: white; + border-radius: 50%; + margin-right: 1rem; + height: 50px; + width: 50px; + min-width: 50px; + display: flex; + align-items: center; + justify-content: center; + font-size: 28px; +`; + +const Container = styled.div` + display: grid; + grid-template-columns: 50px auto minmax(120px, 160px) minmax(120px, 160px); + grid-template-rows: 1fr; + grid-column-gap: 1rem; + align-items: center; +`; + +cons + +return ( + + + {direction == "out" ? ( + + ) : ( + + )} + + +
    + +
    +
    + {proposalId && ( + + ), + content: ( + + ), + }} + /> + )} +
    +
    +); diff --git a/apps/astraplusplus/widget/DAO/Funds/Transactions.jsx b/apps/astraplusplus/widget/DAO/Funds/Transactions.jsx new file mode 100644 index 0000000..a2af348 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Funds/Transactions.jsx @@ -0,0 +1,115 @@ +const fetcher = props.fether ?? (() => {}); +const daoId = props.daoId; +const widgetOwner = props.widgetOwner ?? "/*__@appAccount__*/"; +const config = props.config ?? { + limitPerPage: 10, + type: "near", // near | ft +}; + +State.init({ + currentPage: state.currentPage ?? 0, + type: state.type ?? config.type, +}); + +const currentOffset = state.currentPage * config.limitPerPage; +const transfers = fetcher[ + state.type == "ft" ? "ft_transfers" : "near_transfers" +]( + daoId, + config.limitPerPage, + currentOffset, + state.type == "ft" ? undefined : config.minNearAmount +); + +if (!transfers.body) { + // TODO: Add proper loading + return "Loading..."; +} + +console.log("transfers", transfers.body); + + +const Table = styled.div` + & > div { + padding: 1.61rem 0.61rem; + border-bottom: 1px solid #eee; + + &:last-child { + border-bottom: none; + } + } + + .tx-info { + font-size: 14px; + color: #999; + text-transform: lowercase; + + span:nth-child(1) { + color: #000; + font-weight: 600; + font-size: 16px; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + } +`; + + +return ( +
    +
    +

    NEAR Transactions

    +
    + State.update({ type: v }), + value: state.type, + }} + /> +
    +
    + + {transfers.body.length == 0 && ( +
    No transactions found
    + )} + {transfers.body.map((transfer) => { + return ( + + ); + })} +
    +
    + 0, + hasNext: transfers.body.length == config.limitPerPage, + onPrev: () => State.update({ currentPage: state.currentPage - 1 }), + onNext: () => State.update({ currentPage: state.currentPage + 1 }), + }} + /> +
    +
    +); diff --git a/apps/astraplusplus/widget/DAO/Funds/index.jsx b/apps/astraplusplus/widget/DAO/Funds/index.jsx new file mode 100644 index 0000000..4173418 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Funds/index.jsx @@ -0,0 +1,74 @@ +const widgetOwner = props.widgetOwner || "/*__@appAccount__*/"; +const daoId = props.daoId; + +const baseApi = "https://api.pikespeak.ai"; +const publicApiKey = "36f2b87a-7ee6-40d8-80b9-5e68e587a5b5"; + +const fetchApiConfig = { + mode: "cors", + headers: { + "x-api-key": publicApiKey, + }, +}; + +const constructURL = (baseURL, paramObj) => { + let params = ""; + for (const [key, value] of Object.entries(paramObj ?? {})) { + params += `${key}=${value}&`; + } + params = params.slice(0, -1); + return `${baseURL}?${params}`; +}; + +const fether = { + balances: (accounts) => { + return fetch( + constructURL(`${baseApi}/account/balances`, { accounts }), + fetchApiConfig + ); + }, + near_transfers: (account, limit, offset, minamount) => { + return fetch( + constructURL(`${baseApi}/account/near-transfer/${account}`, { + offset, + limit, + minamount, + }), + fetchApiConfig + ); + }, + ft_transfers: (account, limit, offset) => { + return fetch( + constructURL(`${baseApi}/account/ft-transfer/${account}`, { + offset, + limit, + }), + fetchApiConfig + ); + }, + outgoing_near: (account) => { + return fetch( + constructURL(`${baseApi}/account/outgoing-near/${account}`), + fetchApiConfig + ); + }, +}; + +const Container = styled.div``; + +return ( + + + + {/* */} + +); diff --git a/apps/astraplusplus/widget/DAO/Layout/Header.jsx b/apps/astraplusplus/widget/DAO/Layout/Header.jsx new file mode 100644 index 0000000..1a64008 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Layout/Header.jsx @@ -0,0 +1,41 @@ +const daoId = props.daoId; +const profile = daoId && Social.get(`${daoId}/profile/**`, "final"); + +const BackgroundImage = styled.div` + height: 240px; + border-radius: 20px 20px 0 0; + overflow: hidden; + margin: 0 -12px; + background: #eceef0; + + img { + object-fit: cover; + width: 100%; + height: 100%; + } + + @media (max-width: 1200px) { + margin: calc(var(--body-top-padding) * -1) -12px 0; + border-radius: 0; + } + + @media (max-width: 900px) { + height: 100px; + } +`; + +return ( + + {profile.backgroundImage && ( + + )} + +); diff --git a/apps/astraplusplus/widget/DAO/Layout/Sidebar.jsx b/apps/astraplusplus/widget/DAO/Layout/Sidebar.jsx new file mode 100644 index 0000000..743b854 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Layout/Sidebar.jsx @@ -0,0 +1,24 @@ +const daoId = props.daoId ?? ""; +const profile = daoId ? Social.get(`${daoId}/profile/**`, "final") : {}; + +const Sidebar = styled.div` + position: relative; + z-index: 5; + margin-top: -55px; + + @media (max-width: 1024px) { + margin-top: -40px; + } +`; + +return ( + + + +); diff --git a/apps/astraplusplus/widget/DAO/Layout/Tabs.jsx b/apps/astraplusplus/widget/DAO/Layout/Tabs.jsx new file mode 100644 index 0000000..6ad8d06 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Layout/Tabs.jsx @@ -0,0 +1,68 @@ +const tabs = props.tabs; +const selected = props.tab; +const update = props.update; + +const Container = styled.div` + display: flex; + height: 48px; + border-bottom: 1px solid #eceef0; + margin-bottom: 28px; + overflow: auto; + scroll-behavior: smooth; + + @media (max-width: 1200px) { + border-top: 1px solid #eceef0; + margin: 0 -12px 26px; + + > * { + flex: 1; + } + } +`; + +const Item = styled.a` + display: inline-flex; + align-items: center; + justify-content: center; + height: 100%; + font-weight: 600; + font-size: 12px; + padding: 0 12px; + position: relative; + color: ${(p) => (p.selected ? "#11181C" : "#687076")}; + background: none; + border: none; + outline: none; + text-align: center; + text-decoration: none !important; + + &:hover { + color: #11181c; + } + + &::after { + content: ""; + display: ${(p) => (p.selected ? "block" : "none")}; + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: #59e692; + } +`; + +return ( + + {Object.keys(tabs).map((id) => ( + update({ tab: id })} + > + {tabs[id].name} + + ))} + +); diff --git a/apps/astraplusplus/widget/DAO/Members/index.jsx b/apps/astraplusplus/widget/DAO/Members/index.jsx new file mode 100644 index 0000000..921fad1 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Members/index.jsx @@ -0,0 +1,351 @@ +const daoId = props.daoId; + +State.init({ + filterByRole, +}); + +const processPolicy = (policy) => { + const obj = { + policy, + users: {}, + roles: {}, + everyone: {}, + }; + policy.roles.forEach((role) => { + if (role.kind === "Everyone") { + obj.everyone = role; + } + if (role.kind.Group) { + if (!obj.roles[role.name]) { + obj.roles[role.name] = role; + } + role.kind.Group.forEach((user) => { + if (!obj.users[user]) { + obj.users[user] = []; + } + + obj.users[user].push(role.name); + }); + } + }); + + return obj; +}; + +const policy = useCache( + () => + Near.asyncView(daoId, "get_policy").then((policy) => processPolicy(policy)), + daoId + "-policy", + { subscribe: false } +); + +if (policy === null) return ""; + +const isUserAllowedTo = (user, kind, action) => { + const userRoles = policy.users[user] || ["Everyone"]; + + let allowed = false; + + userRoles.forEach((role) => { + let permissions = policy.roles[role].permissions; + if (role === "Everyone") { + permissions = policy.everyone.permissions; + } + const allowedRole = + permissions.includes(`${kind.toString()}:${action.toString()}`) || + permissions.includes(`${kind.toString()}:*`) || + permissions.includes(`*:${action.toString()}`) || + permissions.includes("*:*"); + allowed = allowed || allowedRole; + return allowedRole; + }); + + return allowed; +}; + +const onRemoveUserProposal = (memberId, roleId) => { + Near.call([ + { + contractName: daoId, + methodName: "add_proposal", + args: { + proposal: { + description: "Remove DAO member", + kind: { + RemoveMemberFromRole: { + member_id: memberId, + role: roleId ?? "council", + }, + }, + }, + }, + gas: 219000000000000, + deposit: policy.policy.proposal_bond, + }, + ]); +}; + +const Wrapper = styled.div` + .userRow { + width: 100%; + @media screen and (min-width: 600px) { + width: calc(50% - 1rem); + } + @media screen and (min-width: 1400px) { + width: calc(33% - 1rem); + } + } +`; + +const renderUserRow = (user, roles, i) => { + return ( +
    +
    + +
    + {roles.map((role, i) => { + return ( + + ); + })} +
    +
    + + + {isUserAllowedTo( + context.accountId, + "remove_member_from_role", + "AddProposal" + ) && ( + + ), + content: ( +
    + { + return { + title: r, + value: r, + }; + }), + onChange: (v) => State.update({ removeFromRole: v }), + value: state.removeFromRole, + }} + /> + + onRemoveUserProposal(user, state.removeFromRole), + }} + /> +
    + ), + }} + /> + )} +
    +
    +
    + ); +}; + +const renderGroups = () => { + return ( +
    + + State.update({ + filterByRole: null, + }), + }} + key={i} + /> + {Object.keys(policy.roles).map((role, i) => { + return ( + + State.update({ + filterByRole: role, + }), + }} + key={i} + /> + ); + })} +
    + ); +}; + +const actions = { + AddProposal: "create proposal", + VoteApprove: "vote approve", + VoteReject: "vote reject", + VoteRemove: "vote remove", +}; + +const kinds = { + config: "Change config", + policy: "Change policy", + add_member_to_role: "Add member to role", + remove_member_from_role: "Remove member from role", + call: "Call", + upgrade_self: "Upgrade self", + upgrade_remote: "Upgrade remote", + transfer: "Transfer", + set_vote_token: "Set staking contract", + add_bounty: "Add bounty", + bounty_done: "Bounty done", + vote: "Vote", + factory_info_update: "Factory info update", + policy_add_or_update_role: "Change policy add or update role", + policy_remove_role: "Change policy remove role", + policy_update_default_vote_policy: "Change policy update default vote policy", + policy_update_parameters: "Change policy update parameters", + "*": "All types", +}; + +const renderPermissions = (role) => { + const permissions = new Map(); + + const rolePermissions = + role === "all" + ? policy.everyone?.permissions + : policy.roles[role].permissions; + + rolePermissions?.forEach((p) => { + const [kindKey, actionKey] = p.split(":"); + + const kind = kinds[kindKey] || kindKey; + const action = actions[actionKey] || actionKey; + + if (!permissions.has(action)) { + permissions.set(action, new Set()); + } + + permissions.get(action).add(kind); + }); + + const filteredPermissions = new Map( + [...permissions].filter(([action, kindsSet]) => kindsSet.size > 0) + ); + + const sortedPermissions = Array.from(filteredPermissions.entries()).sort( + (a, b) => { + if (a[0] === actions.AddProposal) { + return -1; + } + if (b[0] === actions.AddProposal) { + return 1; + } + return 0; + } + ); + + return sortedPermissions.map(([action, kindsSet], i) => ( +
  • + {action}{" "} + {action === actions.AddProposal + ? "of the following types:" + : "on proposals of the following types:"} +
      + {Array.from(kindsSet).map((kind, j) => ( +
    • {kind}
    • + ))} +
    +
  • + )); +}; + +const users = !state.filterByRole + ? Object.keys(policy.users) + : Object.keys(policy.users).filter((user) => + policy.users[user].includes(state.filterByRole) + ); + +return ( + +

    Members & Policy

    +
    +

    Groups

    + {renderGroups()} +
    +

    + + {state.filterByRole || "Everyone"} + {" "} + have permission to:{" "} +

    + {renderPermissions(state.filterByRole || "all")} +
    +
    +
    + {users.map((user, i) => renderUserRow(user, policy.users[user], i))} +
    +
    +); diff --git a/apps/astraplusplus/widget/DAO/Policy.jsx b/apps/astraplusplus/widget/DAO/Policy.jsx new file mode 100644 index 0000000..490011d --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Policy.jsx @@ -0,0 +1,3 @@ +return ( + +); diff --git a/apps/astraplusplus/widget/DAO/Projects.jsx b/apps/astraplusplus/widget/DAO/Projects.jsx new file mode 100644 index 0000000..ac269e7 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Projects.jsx @@ -0,0 +1,6 @@ +return ( + +); diff --git a/apps/astraplusplus/widget/DAO/Proposals/Card/Arguments.jsx b/apps/astraplusplus/widget/DAO/Proposals/Card/Arguments.jsx new file mode 100644 index 0000000..84171e6 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Proposals/Card/Arguments.jsx @@ -0,0 +1,276 @@ +const daoId = props.daoId; +const kind = props.kind; + +const proposal_type = typeof kind === "string" ? kind : Object.keys(kind)[0]; + +if (proposal_type === "Vote") return ""; + +const MarkdownContainer = styled.div` + position: relative; + width: 100%; + padding: 24px; + background-color: #f8f9fa; + color: #1b1b18; + border-radius: 14px; + max-height: 700px; + overflow-y: auto; + color: #333; + line-height: 1.6; + box-shadow: rgba(0, 0, 0, 0.05) 0px 0px 0px 1px; + + h1 { + font-size: 2em; + color: #111; + border-bottom: 1px solid #ccc; + padding-bottom: 0.3em; + margin-bottom: 1em; + } + + h2 { + font-size: 1.5em; + color: #222; + margin-bottom: 0.75em; + } + + h3 { + font-size: 1.3em; + color: #333; + margin-bottom: 0.6em; + } + + h4 { + font-size: 1.2em; + color: #444; + margin-bottom: 0.5em; + } + + h5 { + font-size: 1.1em; + color: #555; + margin-bottom: 0.4em; + } + + p { + font-size: 1em; + margin-bottom: 1em; + } + + a { + color: #0645ad; + text-decoration: none; + } + + a:hover { + text-decoration: underline; + } +`; + +if (proposal_type === "Transfer") + return ( + <> +
    +
    Amount
    + +
    +
    +
    Receiver
    + +
    + + ); + +if (proposal_type === "FunctionCall") { + return ( + <> + {kind.FunctionCall.actions.reduce( + (acc, { method_name, args, deposit }) => { + return acc.concat( +
    +
    +
    Smart Contract Address
    +

    {kind.FunctionCall.receiver_id}

    +
    +
    +
    Method Name
    +

    {method_name}

    +
    +
    +
    Deposit
    + +
    +
    +
    Arguments
    + +
    +
    + ); + }, + [] + )} + + ); +} + +if ( + proposal_type === "AddMemberToRole" || + proposal_type === "RemoveMemberFromRole" +) + return ( + <> +
    +
    Member
    + +
    +
    +
    Role
    +

    {kind[proposal_type].role}

    +
    + + ); + +if (proposal_type === "AddBounty") + return ( + <> +
    +
    Amount
    + +
    +
    +
    Times
    +

    {kind.AddBounty.bounty.times}

    +
    +
    +
    Deadline
    +

    {new Date(kind.AddBounty.bounty.max_deadline).toLocaleString()}

    +
    +
    +
    Bounty Description
    + + + +
    + + ); + +if (proposal_type === "BountyDone") + return ( + <> +
    +
    Receiver
    + +
    +
    +
    Bounty ID
    +

    {kind.BountyDone.bounty_id}

    +
    + + ); + +function deepSortObject(obj) { + if (typeof obj !== "object" || obj === null) { + // Return non-object values as is + return obj; + } + + if (Array.isArray(obj)) { + // If the input is an array, recursively sort each element + return obj.map(deepSortObject).sort(); + } + + const sortedObject = {}; + const sortedKeys = Object.keys(obj).sort((keyA, keyB) => { + // Compare keys in a case-insensitive manner + return keyA.toLowerCase().localeCompare(keyB.toLowerCase()); + }); + + for (const key of sortedKeys) { + sortedObject[key] = deepSortObject(obj[key]); + } + + return sortedObject; +} + +// TODO: ChangePolicy component need some UI improvements to be more readable +if (proposal_type === "ChangePolicy") { + const old_policy = Near.view(daoId, "get_policy"); + if (old_policy === null) return ""; + return ( + <> +
    +
    Policy Changes
    +
    + +
    +
    + + ); +} diff --git a/apps/astraplusplus/widget/DAO/Proposals/Card/MultiVote.jsx b/apps/astraplusplus/widget/DAO/Proposals/Card/MultiVote.jsx new file mode 100644 index 0000000..03bc6c1 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Proposals/Card/MultiVote.jsx @@ -0,0 +1,108 @@ +const proposal = props.proposal; +const isAllowedToVote = props.isAllowedToVote; +const daoId = props.daoId; +const canVote = props.canVote; + +const STORAGE_KEY = "proposalsMultiVote"; +const STORAGE = Storage.get(STORAGE_KEY); +const selectedVote = STORAGE[daoId][proposal.id]; + +const handleClick = (e) => { + const [proposalId, vote] = e.target.value.split(","); + + // using Storage.privateSet instead of State to avoid re-rendering everything + // using daoId as key so that the storage doesn't grow indefinitely + Storage.set(STORAGE_KEY, { + [daoId]: { + ...STORAGE[daoId], + [proposalId]: vote, + }, + }); +}; + +const Wrapper = styled.div` + .form-check { + padding: 6px 14px; + border-radius: 16px; + color: #000; + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; + cursor: pointer; + + &.disabled { + cursor: not-allowed !important; + opacity: 0.7; + + label { + cursor: not-allowed !important; + } + } + + input { + margin: 0 !important; + } + + label { + flex: 1; + font-weight: 600; + cursor: pointer; + } + + &:first-child { + background-color: #82e29930; + + &.active { + background-color: #82e299; + } + } + &:nth-child(2) { + background-color: #ff646430; + + &.active { + background-color: #ff6464; + } + } + &:nth-child(3) { + background-color: #ffd50d30; + + &.active { + background-color: #ffd50d; + } + } + } +`; + +return ( + + {["Yes", "No", "Spam"].map((option, index) => { + return ( +
    + + +
    + ); + })} +
    +); diff --git a/apps/astraplusplus/widget/DAO/Proposals/Card/Vote.jsx b/apps/astraplusplus/widget/DAO/Proposals/Card/Vote.jsx new file mode 100644 index 0000000..bc3d320 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Proposals/Card/Vote.jsx @@ -0,0 +1,232 @@ +const accountId = context.accountId; +const daoId = props.daoId ?? "multi.sputnik-dao.near"; +const vote_counts = props.proposal.vote_counts ?? { + // yes, no, spam + community: [3, 0, 2], + council: [1, 6, 0], +}; + +const userVote = props.proposal.votes[accountId]; +const isAllowedToVote = props.isAllowedToVote ?? [true, true, true]; +const canVote = + isAllowedToVote[0] && + isAllowedToVote[1] && + isAllowedToVote[2] && + !userVote && + props.proposal.statusName === "In Progress" && + accountId; +const yesWin = props.proposal.statusName === "Approved"; +const noWin = props.proposal.statusName === "Rejected"; + +let totalYesVotes = 0; +let totalNoVotes = 0; +let totalSpamVotes = 0; +Object.keys(vote_counts).forEach((key) => { + totalYesVotes += vote_counts[key][0]; + totalNoVotes += vote_counts[key][1]; + totalSpamVotes += vote_counts[key][2]; +}); +const totalVotes = totalYesVotes + totalNoVotes + totalSpamVotes; + +// Functions + +const handleApprove = () => { + Near.call([ + { + contractName: daoId, + methodName: "act_proposal", + args: { + id: JSON.parse(props.proposal.id), + action: "VoteApprove", + }, + gas: 200000000000000, + }, + ]); +}; + +const handleReject = () => { + Near.call([ + { + contractName: daoId, + methodName: "act_proposal", + args: { + id: JSON.parse(props.proposal.id), + action: "VoteReject", + }, + gas: 200000000000000, + }, + ]); +}; + +const handleSpam = () => { + Near.call([ + { + contractName: daoId, + methodName: "act_proposal", + args: { + id: JSON.parse(props.proposal.id), + action: "VoteRemove", + }, + gas: 200000000000000, + }, + ]); +}; + +const VoteButton = styled.button` + border-radius: 20px; + border: none; + display: flex; + padding: 0; + position: relative; + background: rgb(128 128 128 / 13%); + width: 100%; + margin-bottom: 14px; + cursor: pointer; + + .button { + border-radius: 20px; + padding: 12px 20px; + text-align: left; + font-weight: 600; + font-size: 15px; + transition: all 0.4s ease-in-out; + text-align: center; + display: flex; + justify-content: center; + min-width: 90px; + width: 90px; + + @media (max-width: 600px) { + justify-content: start; + } + } + + .vote { + opacity: 0; + transition: all 0.4s ease-in-out; + max-width: 0; + display: block; + margin-right: 3px; + position: relative; + z-index: 1; + } + + .votes { + text-align: right; + padding: 12px 16px; + position: absolute; + right: 0; + top: 0; + bottom: 0; + left: 0; + color: rgb(27, 27, 24); + } + + .preview { + position: absolute; + top: 0; + left: 0; + height: 100%; + border-radius: 20px; + transition: all 0.4s ease-in-out; + z-index: 0; + } + + &:hover { + .button { + width: 100%; + } + .vote { + opacity: 1; + max-width: 100px; + } + } + + &[disabled] { + cursor: not-allowed; + opacity: 0.8; + + .vote { + opacity: 0; + max-width: 0; + } + + &.yes .button { + width: ${(totalYesVotes / totalVotes) * 100 || 0}%; + } + &.no .button { + width: ${(totalNoVotes / totalVotes) * 100 || 0}%; + } + &.spam .button { + width: ${(totalSpamVotes / totalVotes) * 100 || 0}%; + } + } + + &.yes { + .button { + background-color: #59e692; + color: #000; + } + .preview { + background-color: #59e69220; + width: ${(totalYesVotes / totalVotes) * 100 || 0}%; + } + } + + &.no { + .button { + background-color: #e5484d; + color: #fff; + } + .preview { + background-color: #e5484d20; + width: ${(totalNoVotes / totalVotes) * 100 || 0}%; + } + } + + &.spam { + .button { + background-color: #ffda09; + color: #000; + } + .preview { + background-color: #ffda0920; + width: ${(totalSpamVotes / totalVotes) * 100 || 0}%; + } + } +`; + +return ( + <> + + + Vote Yes + + + + {totalYesVotes} Votes ( + {Math.round((totalYesVotes / totalVotes) * 100 || 0)}%) + + + + + Vote No + + + + {totalNoVotes} Votes ( + {Math.round((totalNoVotes / totalVotes) * 100 || 0)}%) + + + + + Vote Spam + + + + {totalSpamVotes} Votes ( + {Math.round((totalSpamVotes / totalVotes) * 100 || 0)}%) + + + +); diff --git a/apps/astraplusplus/widget/DAO/Proposals/Card/index.jsx b/apps/astraplusplus/widget/DAO/Proposals/Card/index.jsx new file mode 100644 index 0000000..bf57df9 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Proposals/Card/index.jsx @@ -0,0 +1,137 @@ +const multiSelectMode = props.multiSelectMode ?? false; +const { proposalString, proposalId, daoId } = props; +const accountId = context.accountId; + +const proposal = proposalString ? JSON.parse(proposalString) : null; + + +let roles = Near.view(daoId, "get_policy"); + +if (roles === null) +return ; + + +let new_proposal = null; +if (!proposalString && proposalId && daoId) { + new_proposal = Near.view(daoId, "get_proposal", { + id: Number(proposalId), + }); + if (new_proposal === null) { + return ; + } else if (!new_proposal) { + return "Proposal not found, check console for details."; + } +} else if (!proposalString) { + return "Please provide a daoId and a proposal or proposalId."; +} + +const expensiveWork = () => { + let my_proposal = new_proposal ? new_proposal : proposal; + + // --- check user permissions + const proposalKinds = { + ChangeConfig: "config", + ChangePolicy: "policy", + AddMemberToRole: "add_member_to_role", + RemoveMemberFromRole: "remove_member_from_role", + FunctionCall: "call", + UpgradeSelf: "upgrade_self", + UpgradeRemote: "upgrade_remote", + Transfer: "transfer", + SetStakingContract: "set_vote_token", + AddBounty: "add_bounty", + BountyDone: "bounty_done", + Vote: "vote", + FactoryInfoUpdate: "factory_info_update", + ChangePolicyAddOrUpdateRole: "policy_add_or_update_role", + ChangePolicyRemoveRole: "policy_remove_role", + ChangePolicyUpdateDefaultVotePolicy: "policy_update_default_vote_policy", + ChangePolicyUpdateParameters: "policy_update_parameters", + }; + + const actions = { + AddProposal: "AddProposal", + VoteApprove: "VoteApprove", + VoteReject: "VoteReject", + VoteRemove: "VoteRemove", + }; + + // -- Get all the roles from the DAO policy + roles = roles === null ? [] : roles.roles; + + // -- Filter the user roles + const userRoles = []; + for (const role of roles) { + if (role.kind === "Everyone") { + userRoles.push(role); + continue; + } + if (!role.kind.Group) continue; + if (accountId && role.kind.Group && role.kind.Group.includes(accountId)) { + userRoles.push(role); + } + } + + const isAllowedTo = (kind, action) => { + // -- Check if the user is allowed to perform the action + let allowed = false; + userRoles + .filter(({ permissions }) => { + const allowedRole = + permissions.includes(`${kind.toString()}:${action.toString()}`) || + permissions.includes(`${kind.toString()}:*`) || + permissions.includes(`*:${action.toString()}`) || + permissions.includes("*:*"); + allowed = allowed || allowedRole; + return allowedRole; + }) + .map((role) => role.name); + return allowed; + }; + + const kindName = + typeof proposal.kind === "string" + ? proposal.kind + : Object.keys(proposal.kind)[0]; + + const isAllowedToVote = [ + isAllowedTo(proposalKinds[kindName], actions.VoteApprove), + isAllowedTo(proposalKinds[kindName], actions.VoteReject), + isAllowedTo(proposalKinds[kindName], actions.VoteRemove), + ]; + + // --- end check user permissions + + my_proposal.typeName = kindName.replace(/([A-Z])/g, " $1").trim(); // Add spaces between camelCase + my_proposal.statusName = proposal.status.replace(/([A-Z])/g, " $1").trim(); + + if (!state) { + State.init({ + proposal: my_proposal, + isAllowedToVote, + }); + } else { + State.update({ + proposal: my_proposal, + isAllowedToVote, + }); + } +}; + +if (!state || state.proposal.id !== proposal.id) { + // Only execute expensive work once + expensiveWork(); + return ; +} + +return ( + +); diff --git a/apps/astraplusplus/widget/DAO/Proposals/Card/skeleton.jsx b/apps/astraplusplus/widget/DAO/Proposals/Card/skeleton.jsx new file mode 100644 index 0000000..19c6166 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Proposals/Card/skeleton.jsx @@ -0,0 +1,94 @@ +let layout = [ + { + type: "row", + content: [ + { + type: "text", + variants: ["lg"], + rows: 1, + style: { + width: "50%", + }, + }, + { + type: "text", + variants: ["sm"], + rows: 1, + style: { + width: "80px", + marginStart: "auto", + }, + }, + ], + }, + { + type: "row", + variants: ["me-auto"], + content: [ + { + type: "avatar", + variants: ["md", "me-1"], + }, + { + type: "text", + variants: ["md"], + rows: 1, + style: { + width: "150px", + }, + }, + ], + }, + { + type: "box", + variants: ["lg", "mb-5"], + }, + { + type: "row", + variants: ["flex-column"], + content: [ + { + type: "box", + variants: ["rounded-5"], + style: { + height: "46px", + }, + count: 3, + }, + ], + }, + { + type: "row", + variants: ["justify-content-start", "mt-4"], + content: [ + { + type: "box", + variants: ["rounded-5"], + count: 2, + style: { + height: "38px", + width: "160px", + }, + }, + ], + }, +]; + +const Wrapper = styled.div` + background-color: ${statusBackgroundColor}; + margin: 16px auto; + max-width: 900px; + border-radius: 16px; + padding: 24px; + box-shadow: rgba(0, 0, 0, 0.18) 0px 2px 4px; + display: flex; + flex-direction: column; + gap: 24px; + min-height: 500px; +`; + +return ( + + + +); diff --git a/apps/astraplusplus/widget/DAO/Proposals/Card/ui.jsx b/apps/astraplusplus/widget/DAO/Proposals/Card/ui.jsx new file mode 100644 index 0000000..0d922ec --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Proposals/Card/ui.jsx @@ -0,0 +1,207 @@ +const { id, typeName, proposer, description, kind, statusName } = + props.proposal; +const { daoId, isAllowedToVote, multiSelectMode, proposal } = props; + +const statusColor = + statusName === "Approved" + ? "#28a930" + : statusName === "In Progress" + ? "#58a1ff" + : statusName === "Failed" + ? "#dc3545" + : "#6c757d"; + +const statusBackgroundColor = + statusName === "Approved" + ? "#ecf7ef" + : statusName === "Failed" || statusName === "Rejected" + ? "#fdf4f4" + : "#fff"; + +const Wrapper = styled.div` + background-color: ${statusBackgroundColor}; + margin: 16px auto; + max-width: 900px; + border-radius: 16px; + padding: 24px; + box-shadow: rgba(0, 0, 0, 0.18) 0px 2px 4px; + display: flex; + flex-direction: column; + gap: 24px; + min-height: 500px; + + p { + line-height: 1.4; + font-weight: 400; + font-size: 15px; + color: #868682; + margin: 0; + } + + h3 { + font-weight: 600; + font-size: 24px; + color: #1b1b18; + } + + h5 { + font-size: 14px; + font-weight: 500; + line-height: 1.2; + color: #6c757d; + } + + .status { + font-size: 14px; + font-weight: 600; + line-height: 1.2; + color: ${statusColor}; + } +`; + +const MarkdownContainer = styled.div` + position: relative; + width: 100%; + padding: 24px; + background-color: #f8f9fa; + color: #1b1b18; + border-radius: 14px; + max-height: 700px; + overflow-y: auto; + color: #333; + line-height: 1.6; + box-shadow: rgba(0, 0, 0, 0.05) 0px 0px 0px 1px; + + h1 { + font-size: 2em; + color: #111; + border-bottom: 1px solid #ccc; + padding-bottom: 0.3em; + margin-bottom: 1em; + } + + h2 { + font-size: 1.5em; + color: #222; + margin-bottom: 0.75em; + } + + h3 { + font-size: 1.3em; + color: #333; + margin-bottom: 0.6em; + } + + h4 { + font-size: 1.2em; + color: #444; + margin-bottom: 0.5em; + } + + h5 { + font-size: 1.1em; + color: #555; + margin-bottom: 0.4em; + } + + p { + font-size: 1em; + margin-bottom: 1em; + } + + a { + color: #0645ad; + text-decoration: none; + } + + a:hover { + text-decoration: underline; + } +`; + +return ( + +
    +
    +
    Proposal ID: {id}
    +

    + {typeName} + + + +

    +
    +
    +
    Status
    + {statusName} +
    +
    +
    +
    Proposer
    + +
    +
    +
    Description
    + {typeName === "Vote" || typeName === "Bounty Done" ? ( + + + + ) : ( +

    {description}

    + )} +
    +
    + +
    + +
    +
    Votes
    + {multiSelectMode ? ( + + ) : ( + + )} +
    + + +
    +); diff --git a/apps/astraplusplus/widget/DAO/Proposals/FilterModal.jsx b/apps/astraplusplus/widget/DAO/Proposals/FilterModal.jsx new file mode 100644 index 0000000..12ccf32 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Proposals/FilterModal.jsx @@ -0,0 +1,282 @@ +const applyFilters = props.applyFilters; +const cancel = props.cancel; +const filters = props.filters ?? { + proposal_types: [], + status: "", + time_start: "", + time_end: "", +}; + +State.init({ + filters, +}); + +const setFilters = (f) => { + State.update({ + filters: f, + }); +}; + +const type = { + operations: [ + { + title: "Transfer funds", + value: "Transfer", + }, + { + title: "Voting proposal", + value: "Vote", + }, + { + title: "Custom function", + value: "FunctionCall", + }, + { + title: "Issue a new bounty", + value: "AddBounty", + }, + { + title: "Request pay for bounty", + value: "BountyDone", + }, + { + title: "Set staking contract", + value: "SetStakingContract", + }, + ], + policy: [ + { + title: "Change Policy", + value: "ChangePolicy", + }, + { + title: "Add or Update Role", + value: "ChangePolicyAddOrUpdateRole", + }, + { + title: "Remove Role", + value: "ChangePolicyRemoveRole", + }, + { + title: "ChangePolicyUpdateParameters", + value: "ChangePolicyUpdateParameters", + }, + { + title: "ChangePolicyUpdateDefaultVotePolicy", + value: "ChangePolicyUpdateDefaultVotePolicy", + }, + ], + "Membership & Config": [ + { + title: "Add member to role", + value: "AddMemberToRole", + }, + { + title: "Remove member from role", + value: "RemoveMemberFromRole", + }, + { + title: "Change Config", + value: "ChangeConfig", + }, + { + title: "Factory Info Update", + value: "FactoryInfoUpdate", + }, + { + title: "Upgrade Remote", + value: "UpgradeRemote", + }, + { + title: "Upgrade Self", + value: "UpgradeSelf", + }, + ], +}; + +const statuss = [ + { + title: "Approved", + value: "Approved", + }, + { + title: "Rejected", + value: "Rejected", + }, + { + title: "In Progress", + value: "InProgress", + }, + { + title: "Expired", + value: "Expired", + }, +]; + +const Wrapper = styled.div` + .check-subtitle { + font-size: 12px; + font-weight: 500; + color: #999; + padding: 0 !important; + } + + .category-title { + margin: 0px; + font-size: 14px; + font-weight: 500; + color: #999; + text-transform: capitalize; + margin-bottom: 6px; + } + + .filter-title { + font-style: normal; + font-weight: 600; + font-size: 15px; + line-height: 1.25em; + color: #222; + margin-bottom: 12px; + } +`; + +return ( + +

    Type

    +
    + {Object.keys(type).map((key) => { + return ( +
    +
    {key}
    + {type[key].map((item) => { + return ( + { + setFilters({ + ...state.filters, + proposal_types: checked + ? [...state.filters.proposal_types, item.value] + : state.filters.proposal_types.filter( + (x) => x !== item.value + ), + }); + }, + label: ( + <> + {item.title} +
    + + {"{" + item.value + "}"} + + + ), + id: item.value, + }} + /> + ); + })} +
    + ); + })} +
    +

    Status

    +
    + {statuss.map((item) => { + return ( + { + setFilters({ + ...state.filters, + status: checked + ? [...state.filters.status, item.value] + : state.filters.status.filter((x) => x !== item.value), + }); + }, + label: item.title, + }} + /> + ); + })} +
    +
    + { + setFilters({ + ...state.filters, + time_start: value, + }); + }, + label: "From Date", + inputProps: { + type: "date", + }, + }} + /> + { + setFilters({ + ...state.filters, + time_end: value, + }); + }, + label: "To Date", + inputProps: { + type: "date", + }, + }} + /> +
    +
    + { + cancel(); + }, + variant: "secondary outline", + className: "me-auto", + }} + /> + { + setFilters({ + proposal_types: [], + status: [], + time_start: "", + time_end: "", + }); + }, + variant: "secondary outline", + }} + /> + { + applyFilters(state.filters); + }, + variant: "secondary", + }} + /> +
    +
    +); diff --git a/apps/astraplusplus/widget/DAO/Proposals/MultiVoteSubmit.jsx b/apps/astraplusplus/widget/DAO/Proposals/MultiVoteSubmit.jsx new file mode 100644 index 0000000..49571f6 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Proposals/MultiVoteSubmit.jsx @@ -0,0 +1,109 @@ +const daoId = props.daoId; +const onHideMultiSelect = props.onHideMultiSelect; + +State.init({ + openModal: false, + page: 0, +}); + +const STORAGE_KEY = "proposalsMultiVote"; +const STORAGE_SRC = "/*__@appAccount__*//widget/DAO.Proposals.Card.MultiVote"; +const STORAGE = Storage.get(STORAGE_KEY, STORAGE_SRC); + +console.log(STORAGE); + +if (STORAGE === null) return ""; + +if (Object.keys(STORAGE[daoId] || {}).length < 1) { + return ""; +} + +const proposal_ids = Object.keys(STORAGE[daoId]).map((id) => parseInt(id)); + + +const handleSubmit = () => { + const calls = []; + Object.keys(STORAGE[daoId]).forEach((id) => { + let vote = STORAGE[daoId][id]; + switch (`${vote}`) { + case "0": + vote = "VoteApprove"; + break; + case "1": + vote = "VoteReject"; + break; + case "2": + vote = "VoteRemove"; + break; + default: + console.error("Invalid vote"); + break; + } + calls.push({ + contractName: daoId, + methodName: "act_proposal", + args: { + id: parseInt(id), + action: vote, + }, + gas: 200000000000000, + }); + }); + return Near.call(calls); +}; + +let Wrapper = styled.div` + position: fixed; + bottom: 12px; + left: 50%; + transform: translateX(-50%); + min-width: 500px; + max-width: 100vw; + background-color: #fff; +`; + +return ( + +

    Voting on multiple proposals

    +

    + Proposals:{" "} + {Object.keys(STORAGE[daoId]) + .map((id) => parseInt(id)) + .reverse() + .join(", ")} +

    +
    + { + onHideMultiSelect(); + }, + variant: "secondary outline", + className: "me-auto", + }} + /> + { + Storage.set(STORAGE_KEY, {}); + }, + variant: "secondary outline", + }} + /> + { + handleSubmit(); + }, + variant: "secondary", + }} + /> +
    +
    +); diff --git a/apps/astraplusplus/widget/DAO/Proposals/ProposalsBlockchain.jsx b/apps/astraplusplus/widget/DAO/Proposals/ProposalsBlockchain.jsx new file mode 100644 index 0000000..8f07de2 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Proposals/ProposalsBlockchain.jsx @@ -0,0 +1,59 @@ +const daoId = props.daoId; +const proposalsPerPage = props.proposalsPerPage ?? 10; // Number of proposals to fetch at a time + +State.init({ + daoId, + proposals: [], + lastProposalId: null, // To keep track of the last loaded proposal + hasMore: true, // Boolean to know if there are more proposals to load +}); + +const loadProposals = () => { + const lastProposalId = + state.lastProposalId !== null + ? state.lastProposalId + : Near.view(daoId, "get_last_proposal_id"); + if (lastProposalId === null) return; + + // Prevents multiple calls to loadProposals() before the first call is finished + if (state.proposals.length > 0 && state.proposals[0].id === lastProposalId) + return; + + const fromIndex = Math.max(0, lastProposalId - proposalsPerPage + 1); // Ensures fromIndex is never less than 0 + const limit = fromIndex === 0 ? lastProposalId + 1 : proposalsPerPage; // Ensure we don't fetch the same proposals twice if fromIndex is 0 + + const newProposals = Near.view(daoId, "get_proposals", { + from_index: fromIndex, + limit: limit, + }); + if (newProposals === null) return; + + console.log("Saving new proposals..."); + State.update({ + ...state, + hasMore: fromIndex > 0, + proposals: [...state.proposals, ...newProposals.reverse()], + lastProposalId: fromIndex - 1, + isLoading: false, + }); +}; + +return ( + <> + + {state.proposals.map((proposal, i) => { + if (proposal.status === "Removed") return null; + return ( + + ); + })} + + +); diff --git a/apps/astraplusplus/widget/DAO/Proposals/ProposalsPikespeak.jsx b/apps/astraplusplus/widget/DAO/Proposals/ProposalsPikespeak.jsx new file mode 100644 index 0000000..af8eca2 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Proposals/ProposalsPikespeak.jsx @@ -0,0 +1,241 @@ +const daoId = props.daoId; +const proposalsPerPage = props.proposalsPerPage ?? 10; // Number of proposals to fetch at a time + +const apiUrl = `https://api.pikespeak.ai/daos/proposals`; +const publicApiKey = "36f2b87a-7ee6-40d8-80b9-5e68e587a5b5"; +const resPerPage = 10; + +const defaultMultiSelectMode = Storage.privateGet("multiSelectMode"); + +if (defaultMultiSelectMode === null) return ""; +console.log(defaultMultiSelectMode) + +State.init({ + daoId, + daos: [daoId], + page: 0, + filters: { + proposal_types: [], + status: [], + time_start: "", + time_end: "", + }, + filtersOpen: false, + multiSelectMode: defaultMultiSelectMode ?? false, +}); + + +const forgeUrl = (apiUrl, params) => + apiUrl + + Object.keys(params) + .sort() + .reduce((paramString, p) => paramString + `${p}=${params[p]}&`, "?"); + +const res = fetch( + forgeUrl(apiUrl, { + offset: state.page * resPerPage, + limit: resPerPage, + daos: state.daos, + proposal_types: state.filters.proposal_types, + status: state.filters.status, + time_start: state.filters.time_start, + time_end: state.filters.time_end, + }), + { + mode: "cors", + headers: { + "x-api-key": publicApiKey, + "no-cache": true, + }, + } +); + +return ( + <> +
    + + ), + inputProps: { + title: "Disabled because no API for searching yet", + }, + }} + /> + + Hide Multi-Select + + + ) : ( + <> + Show Multi-Select + + + ), + variant: "secondary outline", + size: "md", + onClick: () => { + Storage.privateSet("multiSelectMode", !state.multiSelectMode); + State.update({ + ...state, + multiSelectMode: !state.multiSelectMode, + }); + }, + }} + /> + { + State.update({ + ...state, + filtersOpen: open, + }); + }, + toggle: ( + + Filter + + + ), + variant: "secondary outline", + size: "md", + }} + /> + ), + content: ( + { + State.update({ + ...state, + filtersOpen: false, + }); + }, + applyFilters: (filters) => { + State.update({ + ...state, + filters, + filtersOpen: false, + }); + }, + }} + /> + ), + }} + /> +
    + {res !== null && !res.body && ( +
    + Couldn't fetch proposals from API. Please try again later. +
    + )} + +
    + {res == null && ( + <> + {new Array(resPerPage).fill(0).map((_, i) => ( + + ))} + + )} + {res !== null && + res.body.map(({ proposal, proposal_type, proposal_id }, i) => { + proposal.kind = { + [proposal_type]: { + ...proposal.kind, + }, + }; + proposal.id = proposal_id; + if (proposal.status === "Removed") return <>; + Object.keys(proposal.vote_counts).forEach((k) => { + if (typeof proposal.vote_counts[k] == "string") { + proposal.vote_counts[k] = proposal.vote_counts[k] + .match(/.{1,2}/g) + .map((x) => parseInt(x)); + } + }); + return ( + + ); + })} + +
    + 0, + hasNext: res.body.length == resPerPage, + onPrev: () => { + State.update({ + page: state.page - 1, + }); + }, + onNext: () => { + State.update({ + page: state.page + 1, + }); + }, + }} + /> +
    +
    + {state.multiSelectMode && ( + <> +
    + { + State.update({ + ...state, + multiSelectMode: false, + }); + Storage.privateSet("multiSelectMode", false); + }, + }} + /> + + )} + +); diff --git a/apps/astraplusplus/widget/DAO/Proposals/index.jsx b/apps/astraplusplus/widget/DAO/Proposals/index.jsx new file mode 100644 index 0000000..2dbaa07 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/Proposals/index.jsx @@ -0,0 +1,51 @@ +const daoId = props.daoId; + +return ( + <> +
    +
    +

    Proposals

    + + Create Proposal + + + ), + variant: "secondary", + }} + /> + ), + content: ( +
    + +
    + ), + }} + /> +
    + + +
    + +); diff --git a/apps/astraplusplus/widget/DAO/index.jsx b/apps/astraplusplus/widget/DAO/index.jsx new file mode 100644 index 0000000..23a6412 --- /dev/null +++ b/apps/astraplusplus/widget/DAO/index.jsx @@ -0,0 +1,178 @@ +const widgetOwner = props.widgetOwner ?? "/*__@appAccount__*/"; + +State.init({ + tab: props.tab ?? "home", + accountId: props.accountId ?? context.accountId, + daoId: props.daoId, + proposalId: props.proposalId, +}); + +const update = (state) => State.update(state); + +const constructURL = (paramObj, base) => { + const baseURL = base ?? `#/${widgetOwner}/widget/DAO`; + let params = ""; + for (const [key, value] of Object.entries(paramObj)) { + params += `${key}=${value}&`; + } + params = params.slice(0, -1); + return `${baseURL}?${params}`; +}; + +const tabs = { + home: { + name: "Discussion", + widget: "DAO.Discussion", + href: constructURL({ tab: "home", daoId: state.daoId }), + }, + proposals: { + name: "Proposals", + widget: "DAO.Proposals.index", + href: constructURL({ tab: "proposals", daoId: state.daoId }), + }, + funds: { + name: "Fund Flows", + widget: "DAO.Funds.index", + href: constructURL({ tab: "funds", daoId: state.daoId }), + }, + members: { + name: "Members & Policy", + widget: "DAO.Members.index", + href: constructURL({ tab: "members", daoId: state.daoId }), + }, + projects: { + name: "Projects", + widget: "DAO.Projects", + href: constructURL({ tab: "projects", daoId: state.daoId }), + }, + followers: { + name: "Followers", + widget: "DAO.Followers", + href: constructURL({ tab: "followers", daoId: state.daoId }), + }, + bounties: { + name: "Bounties", + widget: "DAO.Bounties", + href: constructURL({ tab: "bounties", daoId: state.daoId }), + }, +}; + +if (!props.daoId) { + // TODO: add a proper error screen + return "Please provide a DAO ID"; +} + +const tabContent = ( + +); + +const Main = styled.div` + display: grid; + gap: 40px; + grid-template-columns: 352px minmax(0, 1fr); + align-items: start; + + @media (max-width: 1024px) { + grid-template-columns: minmax(0, 1fr); + } +`; + +// To keep our styles consistent across widgets, let's define them here based on html tags and classes +const Root = styled.div` + font-family: "Open Sans", "Manrope", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-size: 16px; + line-height: 1.5; + color: #000; + + h1, + h2, + h3, + h4, + h5, + h6 { + font-weight: 600; + letter-spacing: -0.02em; + margin-bottom: 0.5em; + } + + h1 { + font-size: 28px; + } + + h2 { + font-size: 24px; + } + + h3 { + font-size: 20px; + } + + h4 { + font-size: 16px; + } + + h5 { + font-size: 14px; + } + + h6 { + font-size: 12px; + } + + a { + color: #000; + text-decoration: none; + } + + a:hover { + color: #4498e0; + } + + .ndc-card { + border-radius: 16px; + box-shadow: rgba(0, 0, 0, 0.1) 0 1px 3px, rgba(0, 0, 0, 0.05) 0 1px 20px; + background-color: #fff; + } +`; + +return ( + + + + +
    + +
    + + {tabContent} +
    +
    +
    +); diff --git a/apps/astraplusplus/widget/DAOs/Card.jsx b/apps/astraplusplus/widget/DAOs/Card.jsx new file mode 100644 index 0000000..240f48e --- /dev/null +++ b/apps/astraplusplus/widget/DAOs/Card.jsx @@ -0,0 +1,208 @@ +const daoId = props.daoId; +const balance = props.balance; + +if (!daoId) { + return "DAO ID not provided"; +} +// -- Pikespeak API +const baseApi = "https://api.pikespeak.ai"; +const publicApiKey = "36f2b87a-7ee6-40d8-80b9-5e68e587a5b5"; + +const fetchApiConfig = { + mode: "cors", + headers: { + "x-api-key": publicApiKey, + }, +}; + +const constructURL = (baseURL, paramObj) => { + let params = ""; + for (const [key, value] of Object.entries(paramObj ?? {})) { + params += `${key}=${value}&`; + } + params = params.slice(0, -1); + return `${baseURL}?${params}`; +}; + +const fether = { + balances: (accounts) => { + return fetch( + constructURL(`${baseApi}/account/balances`, { accounts }), + fetchApiConfig + ); + }, + proposalsStatus: (daoId) => { + return fetch( + constructURL(`${baseApi}/daos/proposals/status/${daoId}`), + fetchApiConfig + ); + }, +}; +const balances = fether.balances([daoId]); +const proposalsStatus = fether.proposalsStatus(daoId); +let activeProposalsCount; +let totalProposalsCount; + +proposalsStatus && +proposalsStatus.body?.forEach((p) => { + activeProposalsCount += p["InProgress"] ? parseInt(p["InProgress"]) : 0; + totalProposalsCount += p["Total"] ? parseInt(p["Total"]) : 0; +}); +// -- + +// -- Social DB +const profile = Social.get(`${daoId}/profile/**`, "final"); +// -- + +// -- Smart Contract +const policy = Near.view(daoId, "get_policy"); +let members = []; +policy && policy.roles.forEach((role) => { + if (typeof role.kind.Group === "object") { + members = members.concat(role.kind.Group); + } +}); +members = [...new Set(members)]; +// -- + +const shorten = (str, len) => { + if (str.length <= len) { + return str; + } + return str.slice(0, len) + "..."; +}; + +const shortenNumber = (n) => { + if (n < 1e3) return n; + if (n >= 1e3 && n < 1e6) return (n / 1e3).toFixed(1) + "k"; + if (n >= 1e6 && n < 1e9) return (n / 1e6).toFixed(1) + "m"; + if (n >= 1e9 && n < 1e12) return (n / 1e9).toFixed(1) + "b"; + if (n >= 1e12) return (n / 1e12).toFixed(1) + "t"; +}; + +const daoLink = ({ daoId, tab }) => { + return `/#//*__@appAccount__*//widget/DAO?daoId=${daoId}${tab && `&tab=${tab}`}`; +}; + +const Wrapper = styled.div` + border: 1px solid transparent; + + &:hover { + border: 1px solid #4498e0; + } + + .dao-card-stats { + grid-template-columns: repeat(3, 1fr); + grid-template-rows: repeat(2, 1fr); + column-gap: 0.5rem; + row-gap: 0.2rem; + + & > p:nth-child(1), + & > p:nth-child(2), + & > p:nth-child(3) { + font-size: 0.8rem; + color: #4498e0; + margin: 0; + } + + & > p:nth-child(4), + & > p:nth-child(5), + & > p:nth-child(6) { + font-size: 0.8rem; + font-weight: 600; + margin: 0; + } + + p > b { + font-size: 1.15rem; + } + } + + a { + color: #4498e0; + font-size: 0.8rem; + font-weight: 600; + text-decoration: none; + + &:hover { + color: #4498e0cc; + } +`; + +return ( + + + {shorten(profile?.description || "", 80)} + {profile !== null && + (!profile?.description || profile?.description?.length < 1) && + "No description"} +

    +
    +

    DAO Funds

    +

    Members/Groups

    +

    Active proposals

    +

    + {balances && ( + <> + {shortenNumber(balances.body.totalUsd)}USD + + )} +

    +

    + {members.length}/{policy.roles.length - 1} +

    +

    + {activeProposalsCount}/{totalProposalsCount} +

    +
    + +
    + + View Profile, + href: daoLink({ daoId }), + }} + /> +
    +
    +); diff --git a/apps/astraplusplus/widget/DAOs/index.jsx b/apps/astraplusplus/widget/DAOs/index.jsx new file mode 100644 index 0000000..ea24562 --- /dev/null +++ b/apps/astraplusplus/widget/DAOs/index.jsx @@ -0,0 +1,69 @@ +const defaultFilters = props.defaultFilters ?? {}; + +const createDAOLink = "#//*__@appAccount__*//widget/index?tab=create-dao"; + +const renderHeader = () => ( +
    +

    DAOs

    + + Create a new DAO + + + ), + href: createDAOLink, + }} + /> +
    +); + + +const publicApiKey = "36f2b87a-7ee6-40d8-80b9-5e68e587a5b5"; +const resPerPage = 10; + +const forgeUrl = (apiUrl, params) => + apiUrl + + Object.keys(params) + .sort() + .reduce((paramString, p) => paramString + `${p}=${params[p]}&`, "?"); + +const daos = useCache( + () => + asyncFetch(forgeUrl(`https://api.pikespeak.ai/daos/all`, {}), { + mode: "cors", + headers: { + "x-api-key": publicApiKey, + "cache-control": "max-age=86400", // 1 day + }, + }).then((res) => res.body), + "all-daos", + { subscribe: false } +); + +const renderDAOs = () => { + return ( + + ); +}; + +return ( +
    + {renderHeader()} + {renderDAOs()} +
    +); diff --git a/apps/astraplusplus/widget/DAOs/list.jsx b/apps/astraplusplus/widget/DAOs/list.jsx new file mode 100644 index 0000000..13cdc70 --- /dev/null +++ b/apps/astraplusplus/widget/DAOs/list.jsx @@ -0,0 +1,197 @@ +const daos = props.daos; +const currentPage = props.page ?? 1; +const resPerPage = props.resPerPage ?? 6; + +const createDAOLink = "#//*__@appAccount__*//widget/index?tab=create-dao"; + +State.init({ + currentPage: currentPage, + search: null, + isTyping: false, +}); + +const renderSubheader = () => ( +
    + + ), + onChange: (v) => { + State.update({ + search: v, + isTyping: true, + }); + setTimeout(() => { + State.update({ + isTyping: false, + }); + }, 1500); + }, + value: undefined, + inputProps: { + autoFocus: true, + }, + useTimeout: 600, + }} + /> + { + State.update({ + ...state, + filtersOpen: open, + }); + }, + toggle: ( + + Filter + + + ), + variant: "info outline", + size: "md", + }} + /> + ), + content:
    WIP
    , + }} + /> +
    +); + +const renderEmpty = () => ( +
    + +

    No DAO is found. Would you like to create one now?

    + + +
    +); + +const renderLoading = () => <>loading; + +let filteredDAOs = []; +let paginatedDAOs = []; +if (daos && !state.isTyping) { + filteredDAOs = daos.filter((d) => { + if (state.search == null) { + return true; + } + return d.contract_id.toLowerCase().includes(state.search.toLowerCase()); + }); + paginatedDAOs = filteredDAOs.slice( + (state.currentPage - 1) * resPerPage, + state.currentPage * resPerPage + ); +} + +const renderDAOsRows = () => { + return ( +
    + {paginatedDAOs.map((dao) => ( +
    + +
    + ))} + {paginatedDAOs.length < resPerPage && + [...Array(resPerPage - paginatedDAOs.length)].map((_, i) => ( +
    + ))} +
    + ); +}; +const renderPagination = () => ( +
    + { + State.update({ + currentPage: page, + }); + }, + revaluateOnRender: true, + }} + /> +
    +); + +return ( + <> + {renderSubheader()} + {!state.isTyping && + daos != null && + (!daos || daos.length < 1 || filteredDAOs.length < 1) && + renderEmpty()} + {(state.isTyping || daos == null) && renderLoading()} + {!state.isTyping && ((daos != null && daos) || daos.length > 0) && ( +
    {renderDAOsRows()}
    + )} + {((daos != null && daos) || + daos.length > resPerPage || + filteredDAOs.length > resPerPage) && + renderPagination()} + +); diff --git a/apps/astraplusplus/widget/home.jsx b/apps/astraplusplus/widget/home.jsx new file mode 100644 index 0000000..2358aae --- /dev/null +++ b/apps/astraplusplus/widget/home.jsx @@ -0,0 +1 @@ +return
    Hello
    ; diff --git a/apps/astraplusplus/widget/index.jsonc b/apps/astraplusplus/widget/index.jsonc new file mode 100644 index 0000000..8df3037 --- /dev/null +++ b/apps/astraplusplus/widget/index.jsonc @@ -0,0 +1,11 @@ +/*__@noStringify__*/ +{ + "metadata": { + "name": "Astra++ - Under Construction", + "description": "", + "image": {}, + "tags": { + "app": "" + } + } +} \ No newline at end of file diff --git a/apps/astraplusplus/widget/index.jsx b/apps/astraplusplus/widget/index.jsx new file mode 100644 index 0000000..7e54e69 --- /dev/null +++ b/apps/astraplusplus/widget/index.jsx @@ -0,0 +1,135 @@ +let { tab } = props; +if (!tab) { + tab = "home"; +} +const currentLink = "#//*__@appAccount__*//widget/index"; + +const tabs = [ + { + title: "Home", + icon: , + href: currentLink + "?tab=home", + active: tab === "home", + widgetName: "home", + }, + [ + { + title: "DAOs", + icon: , + active: tab.split("-")[0] === "daos", + href: currentLink + "?tab=daos-all", + widgetName: "DAOs.index", + defaultProps: {}, + }, + { + title: "NDC", + active: tab === "daos-ndc", + href: currentLink + "?tab=daos-ndc", + widgetName: "DAOs.index", + defaultProps: {}, + }, + { + title: "Following", + active: tab === "daos-following", + href: currentLink + "?tab=daos-following", + widgetName: "DAOs.index", + defaultProps: {}, + }, + { + title: "All", + active: tab === "daos-all", + href: currentLink + "?tab=daos-all", + widgetName: "DAOs.index", + defaultProps: {}, + }, + ], + { + title: "Bounties area", + icon: , + active: tab === "bounties", + href: currentLink + "?tab=bounties", + widgetName: "bounties", + defaultProps: {}, + }, + { + title: "Actions library", + icon: , + active: tab === "actions", + href: currentLink + "?tab=actions", + widgetName: "actions", + }, + { + title: "Create DAO", + active: tab === "create-dao", + href: currentLink + "?tab=create-dao", + widgetName: "CreateDAO.index", + hidden: true, + }, +]; + +let activeTab = null; +tabs.find((tab) => { + if (Array.isArray(tab)) { + return tab.find((subTab) => { + if (subTab.active) { + activeTab = subTab; + return true; + } + return false; + }); + } + if (tab.active) { + activeTab = tab; + return true; + } + return false; +}); + +const tabContent = activeTab ? ( + +) : ( + "404" +); + +const Root = styled.div` + font-family: "Open Sans", "Manrope", system-ui, -apple-system, "Segoe UI", + Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, + "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + font-size: 16px; + line-height: 1.5; + color: #000; + + a { + color: #000; + text-decoration: none; + } + + a:hover { + color: #4498e0; + } + + .ndc-card { + border-radius: 16px; + box-shadow: rgba(0, 0, 0, 0.1) 0 1px 3px, rgba(0, 0, 0, 0.05) 0 1px 20px; + background-color: #fff; + } +`; +return ( + + + + +
    {tabContent}
    +
    +);