Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/droposal-splits.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@buildeross/create-proposal-ui': minor
---

Redesign the droposal builder and add 0xSplits revenue-split support.

- **Layout**: the flat field list is regrouped into scannable sections (Collection, Artwork, Sale, Revenue) with an Advanced section that holds edition type/size, royalty, mint-limit-per-address, and default admin. Defaults follow the friendlier Gnars pattern — open edition and unlimited mints per wallet unless changed in Advanced.
- **Revenue split (0xSplits)**: an optional "use revenue split" card deploys a 0xSplits v1 split from the connected wallet (`@0xsplits/splits-sdk`, wagmi-native) and sets it as the droposal funds recipient. Recipients are validated (addresses, unique, allocations totalling 100%) and visualized with a Sankey-style flow chart. Ported from the production Gnars pattern (r4topunk/gnars-website).
1 change: 1 addition & 0 deletions packages/create-proposal-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"description": "Create Proposal UI components for BuilderOSS apps",
"version": "0.3.2",
"dependencies": {
"@0xsplits/splits-sdk": "^4.0.0",
"@ethereum-attestation-service/eas-sdk": "^2.7.0",
"@openzeppelin/merkle-tree": "^1.0.8",
"@smartinvoicexyz/types": "^0.1.27",
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { vars } from '@buildeross/zord'
import { style } from '@vanilla-extract/css'

/** Grouped-section card for the droposal builder — Builder tokens only. */
export const section = style({
borderStyle: 'solid',
borderWidth: '1px',
borderColor: vars.color.border,
borderRadius: vars.radii.curved,
padding: '1.25rem 1.25rem 1.35rem',
})

export const sectionHead = style({
display: 'flex',
alignItems: 'baseline',
justifyContent: 'space-between',
gap: '1rem',
marginBottom: '1.1rem',
})

export const eyebrow = style({
fontSize: '11.5px',
fontWeight: 700,
letterSpacing: '0.09em',
textTransform: 'uppercase',
color: vars.color.text3,
})

export const sectionHint = style({
fontSize: '12px',
color: vars.color.text4,
})

/** Two-up field row that collapses to one column on narrow widths. */
export const grid2 = style({
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '1rem',
'@media': {
'screen and (max-width: 560px)': {
gridTemplateColumns: '1fr',
},
},
})

/** Collapsible "Advanced" header button. */
export const advancedToggle = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
border: 'none',
background: 'transparent',
padding: 0,
cursor: 'pointer',
color: vars.color.text3,
fontFamily: 'inherit',
})

export const advancedChevron = style({
transition: 'transform 0.15s ease',
color: vars.color.text3,
fontSize: '16px',
})

export const advancedChevronOpen = style({
transform: 'rotate(90deg)',
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { vars } from '@buildeross/zord'
import { style } from '@vanilla-extract/css'

export const container = style({
position: 'relative',
width: '100%',
borderStyle: 'solid',
borderWidth: '1px',
borderColor: vars.color.border,
borderRadius: vars.radii.curved,
overflow: 'hidden',
background: 'linear-gradient(135deg, rgba(16,185,129,0.05), rgba(16,185,129,0.12))',
})

export const svg = style({
display: 'block',
width: '100%',
height: 'auto',
})

export const sourceLabel = style({
fill: vars.color.text1,
fontSize: '12px',
fontWeight: 600,
})

export const sourceSub = style({
fill: vars.color.text3,
fontSize: '10px',
})

export const nodeLabel = style({
fill: vars.color.text1,
fontSize: '12px',
fontWeight: 600,
})

export const nodePct = style({
fill: '#10b981',
fontSize: '11px',
fontWeight: 700,
})

export const nodeSub = style({
fill: vars.color.text3,
fontSize: '9px',
})

export const emptyState = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '200px',
padding: '1rem',
textAlign: 'center',
color: vars.color.text3,
fontSize: '14px',
})

export const legend = style({
position: 'absolute',
bottom: '8px',
right: '10px',
fontSize: '10px',
color: vars.color.text3,
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
'use client'

import { useDaoStore } from '@buildeross/stores'
import React, { useMemo } from 'react'
import { useAccount } from 'wagmi'

import { formatSplitAddress, type SplitRecipient } from '../../../utils/splits'
import {
container,
emptyState,
legend,
nodeLabel,
nodePct,
nodeSub,
sourceLabel,
sourceSub,
svg as svgClass,
} from './SplitFlowChart.css'

const W = 480
const H = 340
const PAD = 32
const SOURCE_X = 70
const TARGET_X = 300

/**
* Sankey-style visualization of how NFT sale revenue flows from the split
* contract to each recipient — flow width is proportional to allocation %.
* Ported from the production Gnars website pattern, adapted to zord/SVG.
*/
export const SplitFlowChart: React.FC<{ recipients: SplitRecipient[] }> = ({
recipients,
}) => {
const { address } = useAccount()
const { treasury } = useDaoStore((x) => x.addresses)

const totalPercent = useMemo(
() => recipients.reduce((sum, r) => sum + (r.percentAllocation || 0), 0),
[recipients]
)

// Filter out empty/zero recipients and merge duplicate addresses.
const valid = useMemo(() => {
const grouped = new Map<string, { address: string; percentAllocation: number }>()
recipients
.filter((r) => r.percentAllocation > 0 && r.address)
.forEach((r) => {
const key = r.address.toLowerCase()
const existing = grouped.get(key)
if (existing) existing.percentAllocation += r.percentAllocation
else
grouped.set(key, { address: r.address, percentAllocation: r.percentAllocation })
})
return Array.from(grouped.values())
}, [recipients])

if (valid.length === 0 || totalPercent === 0) {
return (
<div className={emptyState}>
Add recipients with allocations to see the split visualization
</div>
)
}

const avail = H - PAD * 2
const sourceY = H / 2

return (
<div className={container}>
<svg
className={svgClass}
viewBox={`0 0 ${W} ${H}`}
preserveAspectRatio="xMidYMid meet"
>
<defs>
<linearGradient id="splitFlow" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#10b981" stopOpacity="0.2" />
<stop offset="50%" stopColor="#10b981" stopOpacity="0.85" />
<stop offset="100%" stopColor="#10b981" stopOpacity="0.2" />
</linearGradient>
<linearGradient id="splitFlowTreasury" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stopColor="#10b981" stopOpacity="0.4" />
<stop offset="50%" stopColor="#10b981" stopOpacity="1" />
<stop offset="100%" stopColor="#10b981" stopOpacity="0.4" />
</linearGradient>
<filter id="splitGlow">
<feGaussianBlur stdDeviation="2" result="b" />
<feMerge>
<feMergeNode in="b" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>

{/* Source node */}
<g>
<circle
cx={SOURCE_X}
cy={sourceY}
r="10"
fill="#10b981"
filter="url(#splitGlow)"
/>
<text x={SOURCE_X} y={sourceY - 18} textAnchor="middle" className={sourceLabel}>
Split
</text>
<text x={SOURCE_X} y={sourceY + 26} textAnchor="middle" className={sourceSub}>
Contract
</text>
</g>

{/* Flows — outer first so the center renders on top */}
{valid
.map((recipient, index) => ({
recipient,
index,
distance: Math.abs(index - (valid.length - 1) / 2),
}))
.sort((a, b) => b.distance - a.distance)
.map(({ recipient, index }) => {
const spacing = avail / valid.length
const targetY = PAD + spacing * index + spacing / 2
const flowHeight = Math.max(
(recipient.percentAllocation / 100) * avail * 0.4,
8
)
const midX = (SOURCE_X + TARGET_X) / 2
const curve = Math.abs(targetY - sourceY) < 10 ? 30 : 0
const path = `M ${SOURCE_X} ${sourceY} C ${midX} ${sourceY + curve}, ${midX} ${targetY - curve}, ${TARGET_X} ${targetY}`
const isTreasury =
!!treasury && recipient.address.toLowerCase() === treasury.toLowerCase()
return (
<path
key={`flow-${recipient.address.toLowerCase()}`}
d={path}
stroke={isTreasury ? 'url(#splitFlowTreasury)' : 'url(#splitFlow)'}
strokeWidth={flowHeight}
fill="none"
strokeLinecap="round"
opacity={isTreasury ? 1 : 0.9}
/>
)
})}

{/* Recipient nodes */}
{valid.map((recipient, index) => {
const spacing = avail / valid.length
const targetY = PAD + spacing * index + spacing / 2
const isTreasury =
!!treasury && recipient.address.toLowerCase() === treasury.toLowerCase()
const isYou =
!!address && recipient.address.toLowerCase() === address.toLowerCase()
return (
<g key={`node-${recipient.address.toLowerCase()}`}>
<circle
cx={TARGET_X}
cy={targetY}
r="16"
fill="#10b981"
fillOpacity="0.18"
stroke="#10b981"
strokeWidth="2"
/>
<text x={TARGET_X + 26} y={targetY - 4} className={nodeLabel}>
{isTreasury ? 'DAO Treasury' : formatSplitAddress(recipient.address)}
</text>
<text x={TARGET_X + 26} y={targetY + 11} className={nodePct}>
{recipient.percentAllocation.toFixed(2)}%
</text>
{isYou && !isTreasury && (
<text x={TARGET_X + 26} y={targetY + 23} className={nodeSub}>
(You)
</text>
)}
</g>
)
})}
</svg>
<div className={legend}>Flow width = allocation %</div>
</div>
)
}
Loading