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 (
+
+);
+
+*/
+
+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 (
+ <>
+
+ >
+ );
+}
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}
+
+);